From 2fbb9e600475a215deb69b840ca2e5cb22c5af3d Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Wed, 14 Oct 2026 23:35:06 -0400 Subject: [PATCH 001/459] app: codeaf app, the desktop app's JSON-lines backend, holding every agent in process internal/appwire is the transport: hello first, one writer assigning event seq, unknown_method and bad_params, exit on stdin EOF, and per-agent outboxes that fold turn.delta for 16 ms or 4 KB. cmd/codeaf/app.go implements the methods over conversations built by assembleEngine (bootEngine without its chdir) and reached over remote.Loopback, joining a window's host when it already holds the conversation. Lists read the disk; idle conversations are let go after ten minutes and reopened by the next send. --- cmd/codeaf/app.go | 988 ++++++++++++++++++ cmd/codeaf/app_test.go | 498 +++++++++ cmd/codeaf/beltdoors_test.go | 3 +- cmd/codeaf/chatv3_track_test.go | 10 +- cmd/codeaf/engine.go | 7 + cmd/codeaf/main.go | 6 + docs/changes/unreleased/0-app-backend.md | 17 + internal/appwire/outbox.go | 150 +++ internal/appwire/server.go | 403 +++++++ internal/appwire/server_test.go | 383 +++++++ internal/appwire/wire.go | 245 +++++ .../manual/chat/running-from-the-terminal.md | 6 +- 12 files changed, 2709 insertions(+), 7 deletions(-) create mode 100644 cmd/codeaf/app.go create mode 100644 cmd/codeaf/app_test.go create mode 100644 docs/changes/unreleased/0-app-backend.md create mode 100644 internal/appwire/outbox.go create mode 100644 internal/appwire/server.go create mode 100644 internal/appwire/server_test.go create mode 100644 internal/appwire/wire.go diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go new file mode 100644 index 0000000000..1615f68e0a --- /dev/null +++ b/cmd/codeaf/app.go @@ -0,0 +1,988 @@ +package main + +// ── codeaf app: THE DESKTOP APP'S BACKEND ─────────────────────────────────── +// +// `codeaf app` is started by the desktop app as a child process and spoken to +// over its own stdin and stdout, one JSON object per line +// (docs/design/app-v3/PROTOCOL.md). internal/appwire owns the bytes; this file +// owns what the methods mean. +// +// EVERY AGENT LIVES IN THIS ONE PROCESS, the way a person's tabs do. An agent +// the app opens is a conversation built by the very assembly a host builds one +// with ([assembleEngine]) and spoken to through a real [remote.Client] over an +// in-memory pipe ([remote.Loopback]), so the app's conversations are the same +// conversations, with the same toolbelt, gate and journal, as every other +// window's. Nothing is spawned per agent. +// +// ONE DIFFERENCE FROM A HOST, AND IT IS THE DIRECTORY. A host is one workspace +// and moves the process into it; this process holds conversations in several +// workspaces at once, so it moves nowhere. Every tool already resolves against +// its conversation's own workspace rather than the process's directory, and the +// launch directory a new conversation records is where the app was started. +// +// AND IT IS LAZY BY LAW. Starting up reads nothing but the flags, so the hello +// line leaves before anything is opened; listing agents, tasks and crews reads +// the disk; only agents.create, agents.open and agents.send build a +// conversation, and one left alone for [appAgentIdle] is let go again and +// rebuilt transparently by the next send. + +import ( + "context" + "errors" + "fmt" + "os" + "os/signal" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/enginehost" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/redact" + "github.com/Agent-Field/codeaf/internal/remote" + "github.com/Agent-Field/codeaf/internal/session" + teamstore "github.com/Agent-Field/codeaf/internal/teams" +) + +// appAgentIdle is how long a conversation the app holds may sit with no turn +// running, no question waiting and nothing sent before it is let go. Letting go +// closes the conversation in this process and nothing else: its journal is on +// disk, its state still reads idle, and the next send opens it again. +const appAgentIdle = 10 * time.Minute + +// appSweepEvery is how often the idle rule above is asked. +const appSweepEvery = 30 * time.Second + +// appCrewPoll is how often the teams file is looked at for crew.changed, once +// the app has asked for the crews at all. It is one stat when nothing moved. +const appCrewPoll = 2 * time.Second + +// appReleaseWait bounds how long closing one conversation waits for its turn's +// last events to be written before the outbox is shut regardless. +const appReleaseWait = 5 * time.Second + +func runApp(args []string) error { + flags := commandFlags("app") + homeFlag := flags.String("home", "", "codeaf's state folder for this app, instead of CODEAF_HOME or ~/.codeaf") + if err := parseCommandFlags(flags, args); err != nil { + return err + } + if flags.NArg() > 0 { + return fmt.Errorf("usage: codeaf app [--home dir]") + } + if dir := strings.TrimSpace(*homeFlag); dir != "" { + abs, err := filepath.Abs(dir) + if err != nil { + return err + } + if err := os.Setenv(home.EnvVar, abs); err != nil { + return err + } + } + // STDOUT IS THE PROTOCOL'S AND NOBODY ELSE'S. The wire keeps the real + // descriptor, and anything else in this process that prints to stdout + // lands on stderr, where the app collects logs. + wire := os.Stdout + os.Stdout = os.Stderr + defer func() { os.Stdout = wire }() + + ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) + defer stop() + server := appwire.NewServer(wire) + backend := newAppBackend(server, appOpenInProcess) + err := server.Serve(ctx, os.Stdin, appwire.Hello{Version: resolvedVersion(), PID: os.Getpid(), Home: home.Dir()}, backend) + if backend.assembled.Load() { + // The process half every conversation shared, closed the way the pipe + // engine closes it once its conversation is gone (engine.go). + session.CloseUsage() + closeEngineProcess() + } + return err +} + +// appConversation is what the backend needs of one open conversation. A +// [remote.Agent] is one; a test's fake is another. +type appConversation interface { + Submit(ctx context.Context, text string) (<-chan session.Event, error) + Interrupt() + Model() string + Title() string + NeedsPerson() bool + WatchTaskUpdates() (<-chan session.Event, func()) +} + +// appOpenAsk is which conversation to open: a saved one by transcript, or a +// new one in a workspace. +type appOpenAsk struct { + Workspace string + Transcript string + Model string + New bool +} + +// appConn is an opened conversation and the one way to give it back. +type appConn struct { + Conv appConversation + Workspace string + Transcript string + Release func() + // Assembled says this process built the conversation itself, so the shared + // engine half must be closed when the app leaves. + Assembled bool +} + +type appOpener func(appOpenAsk) (*appConn, error) + +// appOpenInProcess opens a conversation for the app. A saved conversation that +// a window's host already holds is JOINED there, as a second view of the same +// conversation, because opening it here as well would be a second writer on its +// journal. Everything else is built in this process. +func appOpenInProcess(ask appOpenAsk) (*appConn, error) { + if ask.Transcript != "" { + if socket, err := enginehost.DialStartingHost(ask.Workspace); err == nil { + client, err := remote.Dial(socket, "", remote.Hello{Workspace: ask.Workspace, Session: ask.Transcript, Join: true}) + if err == nil { + return appConnOf(client, func() { _ = client.Close() }, false), nil + } + _ = socket.Close() + } + } + workspace := ask.Workspace + transcript := ask.Transcript + hello := remote.Hello{ + Workspace: workspace, + Session: transcript, + New: ask.New, + Model: ask.Model, + Launch: &remote.LaunchShape{Interactive: true}, + } + loop, err := remote.Loopback(hello, remote.Options{ + Boot: func(h remote.Hello) (*remote.Engine, error) { + return assembleEngine(h, workspace, transcript) + }, + }) + if err != nil { + return nil, err + } + return appConnOf(loop.Client, func() { _ = loop.Close() }, true), nil +} + +func appConnOf(client *remote.Client, release func(), assembled bool) *appConn { + welcome := client.Welcome() + return &appConn{Conv: client.Agent(), Workspace: welcome.Workspace, Transcript: welcome.SessionFile, Release: release, Assembled: assembled} +} + +// appBackend is the [appwire.Engine] over this process's conversations. +type appBackend struct { + emit appwire.Emitter + open appOpener + now func() time.Time + turns atomic.Uint64 + assembled atomic.Bool + + mu sync.Mutex + agents map[string]*appAgent + closing bool + + sweepStop chan struct{} + sweepDone chan struct{} + crews *appCrewWatch +} + +// appAgent is every agent this process has heard of: listed off the disk, +// created or opened. live is nil while no conversation is held for it. +type appAgent struct { + // gate serializes opening and letting go of this one agent, so two sends + // racing for a closed conversation open it once. + gate sync.Mutex + view appwire.Agent + transcript string + live *appLive +} + +// appLive is one conversation the process holds. +type appLive struct { + conn *appConn + box *appwire.Outbox + stopTasks func() + running bool + lastUse time.Time + pumpDone chan struct{} + lanesDone chan struct{} + tasks map[string]appwire.Task +} + +func newAppBackend(emit appwire.Emitter, open appOpener) *appBackend { + b := &appBackend{ + emit: emit, + open: open, + now: time.Now, + agents: map[string]*appAgent{}, + sweepStop: make(chan struct{}), + sweepDone: make(chan struct{}), + } + go b.sweep() + return b +} + +// appAgentID is a conversation's id from its transcript: the folder's name. +func appAgentID(transcript string) string { + return filepath.Base(filepath.Dir(filepath.Clean(transcript))) +} + +// agentLocked answers the record for id, making one if this is the first time. +func (b *appBackend) agentLocked(id string) *appAgent { + a := b.agents[id] + if a == nil { + a = &appAgent{view: appwire.Agent{ID: id, State: appwire.StateIdle}} + b.agents[id] = a + } + return a +} + +// rowView is an agent as the disk says it is. +func appRowView(row session.SessionRow) appwire.Agent { + at := row.At + if at.IsZero() { + at = row.Created + } + state := appwire.StateIdle + if row.Live { + switch { + case row.Presence.NeedsPerson(): + state = appwire.StateNeedsYou + case row.Presence.State == session.PresenceWorking: + state = appwire.StateWorking + } + } + return appwire.Agent{ID: row.ID, Title: row.Title, Workspace: row.Workspace, Model: row.Model, State: state, UpdatedAt: at} +} + +// remember files what the disk said about an agent, without touching one this +// process is holding: a held conversation's own facts are newer than the disk's. +func (b *appBackend) remember(row session.SessionRow) appwire.Agent { + b.mu.Lock() + defer b.mu.Unlock() + a := b.agentLocked(row.ID) + if a.transcript == "" { + a.transcript = row.Transcript + } + if a.live != nil || a.view.State == appwire.StateClosed || a.view.State == appwire.StateStopped { + view := a.view + if view.Title == "" { + view.Title = row.Title + } + return view + } + a.view = appRowView(row) + return a.view +} + +// find answers the agent for id, reading the disk for one this process has not +// heard of yet. +func (b *appBackend) find(id string) (*appAgent, error) { + b.mu.Lock() + a := b.agents[id] + b.mu.Unlock() + if a != nil && a.transcript != "" { + return a, nil + } + row, ok := appRowOnDisk(id) + if !ok { + return nil, appwire.Errorf(appwire.CodeNotFound, "there is no conversation with id %s", id) + } + b.remember(row) + b.mu.Lock() + defer b.mu.Unlock() + return b.agents[id], nil +} + +// appRowOnDisk finds one conversation folder by id under the places root +// without walking every conversation on the machine: one directory listing and +// one stat per project. +func appRowOnDisk(id string) (session.SessionRow, bool) { + if id == "" || id != filepath.Base(id) || strings.HasPrefix(id, ".") { + return session.SessionRow{}, false + } + root := session.PlacesRoot() + buckets, err := os.ReadDir(root) + if err != nil { + return session.SessionRow{}, false + } + for _, bucket := range buckets { + if !bucket.IsDir() { + continue + } + dir := filepath.Join(root, bucket.Name(), id) + if info, err := os.Stat(dir); err != nil || !info.IsDir() { + continue + } + transcript := session.Place{Dir: dir}.Transcript() + if row, ok := session.ReadRows([]string{transcript})[filepath.Clean(transcript)]; ok { + return row, true + } + // A folder minted and never spoken in is still a conversation the app + // may have created; its meta names the workspace. + meta, err := session.LoadMeta(dir) + if err != nil || strings.TrimSpace(meta.ID) == "" { + continue + } + return session.SessionRow{ID: id, Dir: dir, Transcript: transcript, Title: meta.Title, Workspace: meta.Workspace, Created: meta.Created}, true + } + return session.SessionRow{}, false +} + +// ListAgents reads the disk and never opens a conversation. +func (b *appBackend) ListAgents(workspace string) ([]appwire.Agent, error) { + want := "" + if strings.TrimSpace(workspace) != "" { + want = filepath.Clean(workspace) + } + matches := func(where ...string) bool { + if want == "" { + return true + } + for _, w := range where { + if w != "" && filepath.Clean(w) == want { + return true + } + } + return false + } + seen := map[string]bool{} + var agents []appwire.Agent + for _, row := range session.ReadWorld(session.PlacesRoot()).Sessions() { + if row.DeletionPending || !matches(row.Workspace, row.ProjectDir) { + continue + } + seen[row.ID] = true + agents = append(agents, b.remember(row)) + } + // A conversation this process created and nobody has spoken in yet is not + // on the walk, which skips empty shells; it is still the app's agent. + b.mu.Lock() + for id, a := range b.agents { + if !seen[id] && a.live != nil && matches(a.view.Workspace) { + agents = append(agents, a.view) + } + } + b.mu.Unlock() + sort.SliceStable(agents, func(i, j int) bool { return agents[i].UpdatedAt.After(agents[j].UpdatedAt) }) + return agents, nil +} + +// CreateAgent opens a new conversation in workspace. +func (b *appBackend) CreateAgent(workspace, model string) (appwire.Agent, error) { + resolved, err := engineWorkspace(workspace) + if err != nil { + return appwire.Agent{}, appwire.Errorf(appwire.CodeBadParams, "%s", err.Error()) + } + if b.isClosing() { + return appwire.Agent{}, appwire.Errorf(appwire.CodeClosed, "codeaf is shutting down") + } + conn, err := b.open(appOpenAsk{Workspace: resolved, Model: strings.TrimSpace(model), New: true}) + if err != nil { + return appwire.Agent{}, appEngineError(err) + } + id := appAgentID(conn.Transcript) + b.mu.Lock() + a := b.agentLocked(id) + b.mu.Unlock() + a.gate.Lock() + defer a.gate.Unlock() + view := b.hold(a, conn, resolved) + b.emit.Emit(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) + return view, nil +} + +// OpenAgent attaches a conversation to a saved agent and answers it. +func (b *appBackend) OpenAgent(id string) (appwire.Agent, error) { + a, err := b.find(id) + if err != nil { + return appwire.Agent{}, err + } + view, opened, err := b.ensure(a) + if err != nil { + return appwire.Agent{}, err + } + if opened { + b.emit.Emit(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) + } + return view, nil +} + +// ensure holds a conversation for a, opening it if none is held, and reports +// whether it had to. +func (b *appBackend) ensure(a *appAgent) (appwire.Agent, bool, error) { + a.gate.Lock() + defer a.gate.Unlock() + return b.ensureLocked(a) +} + +// ensureLocked is [appBackend.ensure] for a caller already holding a.gate. +func (b *appBackend) ensureLocked(a *appAgent) (appwire.Agent, bool, error) { + b.mu.Lock() + if a.live != nil { + view := a.view + b.mu.Unlock() + return view, false, nil + } + transcript, workspace := a.transcript, a.view.Workspace + b.mu.Unlock() + if b.isClosing() { + return appwire.Agent{}, false, appwire.Errorf(appwire.CodeClosed, "codeaf is shutting down") + } + conn, err := b.open(appOpenAsk{Workspace: workspace, Transcript: transcript}) + if err != nil { + return appwire.Agent{}, false, appEngineError(err) + } + return b.hold(a, conn, workspace), true, nil +} + +// hold makes conn the agent's live conversation. Callers hold a.gate. +func (b *appBackend) hold(a *appAgent, conn *appConn, workspace string) appwire.Agent { + if conn.Assembled { + b.assembled.Store(true) + } + live := &appLive{conn: conn, box: appwire.NewOutbox(b.emit), lastUse: b.now(), tasks: map[string]appwire.Task{}, lanesDone: make(chan struct{})} + b.mu.Lock() + if conn.Transcript != "" { + a.transcript = conn.Transcript + } + if conn.Workspace != "" { + workspace = conn.Workspace + } + a.view.Workspace = workspace + if title := conn.Conv.Title(); title != "" { + a.view.Title = title + } + if model := conn.Conv.Model(); model != "" { + a.view.Model = model + } + a.view.State = appwire.StateIdle + if conn.Conv.NeedsPerson() { + a.view.State = appwire.StateNeedsYou + } + if a.view.UpdatedAt.IsZero() { + a.view.UpdatedAt = b.now() + } + a.live = live + view := a.view + b.mu.Unlock() + lane, stop := conn.Conv.WatchTaskUpdates() + live.stopTasks = stop + id := view.ID + go func() { + defer close(live.lanesDone) + for event := range lane { + if event.Kind == session.EventTaskUpdate && event.Task != nil { + b.taskNotice(id, live, event.Task) + } + } + }() + return view +} + +// live answers the agent's held conversation, or nil. +func (b *appBackend) liveOf(a *appAgent) *appLive { + b.mu.Lock() + defer b.mu.Unlock() + return a.live +} + +// Send starts one turn, opening the conversation first if it is not held. +func (b *appBackend) Send(id, text string) (uint64, error) { + a, err := b.find(id) + if err != nil { + return 0, err + } + // The gate keeps the idle sweep from letting the conversation go between + // opening it and starting the turn. + a.gate.Lock() + view, opened, err := b.ensureLocked(a) + if err != nil { + a.gate.Unlock() + return 0, err + } + if opened { + b.emit.Emit(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) + } + b.mu.Lock() + live := a.live + if live.running { + b.mu.Unlock() + a.gate.Unlock() + return 0, appwire.Errorf(appwire.CodeBusy, "this agent is already working on a turn") + } + live.running = true + live.lastUse = b.now() + turn := b.turns.Add(1) + b.mu.Unlock() + events, err := live.conn.Conv.Submit(context.Background(), text) + if err != nil { + b.mu.Lock() + live.running = false + b.mu.Unlock() + a.gate.Unlock() + return 0, appEngineError(err) + } + b.mu.Lock() + a.view.State = appwire.StateWorking + a.view.UpdatedAt = b.now() + view = a.view + live.pumpDone = make(chan struct{}) + b.mu.Unlock() + live.box.Event(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) + live.box.Event(appwire.EventTurnStarted, appwire.TurnStarted{Agent: id, Turn: turn}) + go b.pump(a, id, live, turn, events) + a.gate.Unlock() + return turn, nil +} + +// pump maps one turn's session events onto the wire. It never waits on the +// writer: everything goes into the agent's outbox. +func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, events <-chan session.Event) { + defer close(live.pumpDone) + ok, failure := true, "" + changed := func(mutate func(*appwire.Agent)) { + b.mu.Lock() + mutate(&a.view) + view := a.view + b.mu.Unlock() + live.box.Event(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) + } + for event := range events { + switch event.Kind { + case session.EventTextDelta: + live.box.Delta(id, turn, appwire.DeltaText, event.Text) + case session.EventReasoning: + live.box.Delta(id, turn, appwire.DeltaThinking, event.Text) + case session.EventToolBegin: + live.box.Event(appwire.EventTurnTool, appwire.TurnTool{Agent: id, Turn: turn, Call: event.CallID, Name: event.Tool, Phase: appwire.PhaseBegin, Summary: event.Hint}) + case session.EventToolEnd, session.EventToolFailed: + succeeded := event.Kind == session.EventToolEnd + summary := event.Hint + if summary == "" && !succeeded { + summary = event.Text + } + live.box.Event(appwire.EventTurnTool, appwire.TurnTool{Agent: id, Turn: turn, Call: event.CallID, Name: event.Tool, Phase: appwire.PhaseEnd, OK: &succeeded, Summary: redact.Secrets(summary)}) + case session.EventError: + ok, failure = false, "the turn ended with an error" + if event.Err != nil { + failure = redact.Secrets(event.Err.Error()) + } + case session.EventTurnDone: + // A turn the person steered ends once per stretch; the last ending + // on the stream is the turn's. + ok, failure = true, "" + case session.EventConsentRequest, session.EventQuestion: + changed(func(v *appwire.Agent) { v.State = appwire.StateNeedsYou }) + case session.EventQuestionAnswered, session.EventQuestionWithdrawn: + changed(func(v *appwire.Agent) { v.State = appwire.StateWorking }) + case session.EventTitleChanged: + if title := strings.TrimSpace(event.Text); title != "" { + changed(func(v *appwire.Agent) { v.Title = title }) + } + case session.EventTaskUpdate: + if event.Task != nil { + b.taskNotice(id, live, event.Task) + } + } + } + live.box.Event(appwire.EventTurnDone, appwire.TurnDone{Agent: id, Turn: turn, OK: ok, Error: failure}) + b.mu.Lock() + live.running = false + live.lastUse = b.now() + if a.live != live { + // Let go or closed while the turn ran: that road has said the state. + b.mu.Unlock() + return + } + a.view.State = appwire.StateIdle + if live.conn.Conv.NeedsPerson() { + a.view.State = appwire.StateNeedsYou + } + a.view.UpdatedAt = b.now() + view := a.view + b.mu.Unlock() + live.box.Event(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) +} + +// appTaskState is a task's state in the protocol's five words. The engine's +// interrupted (nothing is driving it, every step is kept) has no word of its +// own on this wire and reads as failed; a state this build has never heard of +// answers "" and the row is left out rather than given a word it did not say. +func appTaskState(state string) string { + switch session.TaskState(strings.TrimSpace(state)) { + case session.TaskQueued: + return appwire.TaskQueued + case session.TaskRunning: + return appwire.TaskRunning + case session.TaskDone: + return appwire.TaskDone + case session.TaskFailed, session.TaskInterrupted: + return appwire.TaskFailed + case session.TaskUnverified: + return appwire.TaskUnverified + } + return "" +} + +// taskNotice turns one task update into task.changed, once per change: the +// same update can arrive on the turn's stream and on the task lane. +func (b *appBackend) taskNotice(id string, live *appLive, notice *session.TaskNotice) { + state := appTaskState(string(notice.State)) + if state == "" { + return + } + task := appwire.Task{ID: strconv.FormatUint(notice.ID, 10), Agent: id, Title: notice.Title, State: state, UpdatedAt: b.now()} + b.mu.Lock() + if before, ok := live.tasks[task.ID]; ok && before.State == task.State && (task.Title == "" || before.Title == task.Title) { + b.mu.Unlock() + return + } + if task.Title == "" { + task.Title = live.tasks[task.ID].Title + } + live.tasks[task.ID] = task + b.mu.Unlock() + live.box.Event(appwire.EventTaskChanged, appwire.TaskChanged{Task: task}) +} + +// Stop interrupts the running turn. An agent with nothing held has nothing +// running in this process, which is answered as done. +func (b *appBackend) Stop(id string) error { + a, err := b.find(id) + if err != nil { + return err + } + if live := b.liveOf(a); live != nil { + live.conn.Conv.Interrupt() + } + return nil +} + +// CloseAgent lets the conversation go and says the agent is closed. Its journal +// stays on disk; agents.open or agents.send opens it again. +func (b *appBackend) CloseAgent(id string) error { + a, err := b.find(id) + if err != nil { + return err + } + a.gate.Lock() + b.release(a) + b.mu.Lock() + a.view.State = appwire.StateClosed + view := a.view + b.mu.Unlock() + a.gate.Unlock() + b.emit.Emit(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) + return nil +} + +// release gives a's conversation back and flushes its last events. Callers +// hold a.gate. +func (b *appBackend) release(a *appAgent) { + b.mu.Lock() + live := a.live + a.live = nil + b.mu.Unlock() + if live == nil { + return + } + if live.stopTasks != nil { + live.stopTasks() + } + live.conn.Release() + wait := time.After(appReleaseWait) + if live.pumpDone != nil { + select { + case <-live.pumpDone: + case <-wait: + } + } + select { + case <-live.lanesDone: + case <-wait: + } + live.box.Close() +} + +// ListTasks reads the agent's tasks off its project's task index, and lays +// over them what the held conversation has said since. +func (b *appBackend) ListTasks(id string) ([]appwire.Task, error) { + a, err := b.find(id) + if err != nil { + return nil, err + } + b.mu.Lock() + transcript := a.transcript + fallback := a.view.UpdatedAt + var heard map[string]appwire.Task + if a.live != nil { + heard = make(map[string]appwire.Task, len(a.live.tasks)) + for k, v := range a.live.tasks { + heard[k] = v + } + } + b.mu.Unlock() + var tasks []appwire.Task + index := map[string]int{} + if path := session.TaskIndexPath(transcript); path != "" { + for _, row := range session.ReadTaskIndex(path) { + if row.SessionID != id { + continue + } + state := appTaskState(row.Status) + if state == "" { + continue + } + at := row.EndedAt + if at.IsZero() { + at = row.StartedAt + } + if at.IsZero() { + at = fallback + } + title := row.Title + if title == "" { + title = row.Label + } + index[row.ID] = len(tasks) + tasks = append(tasks, appwire.Task{ID: row.ID, Agent: id, Title: title, State: state, UpdatedAt: at}) + } + } + for taskID, task := range heard { + if i, ok := index[taskID]; ok { + if task.Title == "" { + task.Title = tasks[i].Title + } + tasks[i] = task + continue + } + tasks = append(tasks, task) + } + return tasks, nil +} + +// ListCrews reads the teams file directly and starts watching it. +func (b *appBackend) ListCrews() ([]appwire.Crew, error) { + crews, err := appReadCrews() + if err != nil { + return nil, appEngineError(err) + } + b.mu.Lock() + if b.crews == nil && !b.closing { + b.crews = newAppCrewWatch(b.emit, crews) + } + b.mu.Unlock() + return crews, nil +} + +// appReadCrews is every open team, members named by agent id. +func appReadCrews() ([]appwire.Crew, error) { + file, err := teamstore.Load(config.ProfileDir()) + if err != nil { + return nil, err + } + var crews []appwire.Crew + for _, team := range file.Teams { + if team.Closed() { + continue + } + crew := appwire.Crew{ID: team.ID, Name: team.Name, Members: []appwire.CrewMember{}} + for _, member := range team.Members { + agent := appMemberAgent(member) + if member.Key == team.Manager { + crew.Manager = agent + } + crew.Members = append(crew.Members, appwire.CrewMember{Agent: agent, Handle: member.Handle}) + } + crews = append(crews, crew) + } + return crews, nil +} + +// appMemberAgent is a team member's agent id: its transcript's folder name. +func appMemberAgent(member teamstore.Member) string { + transcript := member.File + if strings.TrimSpace(transcript) == "" { + transcript = member.Key + } + return appAgentID(transcript) +} + +// Close lets every conversation go and stops the watchers. +func (b *appBackend) Close() error { + b.mu.Lock() + if b.closing { + b.mu.Unlock() + return nil + } + b.closing = true + crews := b.crews + agents := make([]*appAgent, 0, len(b.agents)) + for _, a := range b.agents { + agents = append(agents, a) + } + b.mu.Unlock() + close(b.sweepStop) + <-b.sweepDone + if crews != nil { + crews.close() + } + var wg sync.WaitGroup + for _, a := range agents { + if b.liveOf(a) == nil { + continue + } + wg.Add(1) + go func(a *appAgent) { + defer wg.Done() + // Stop first: a turn still running is interrupted rather than left + // holding the gate while the process leaves. + // A conversation joined on a window's host is that window's, and + // is only detached from. + if live := b.liveOf(a); live != nil && live.conn.Assembled { + live.conn.Conv.Interrupt() + } + a.gate.Lock() + b.release(a) + a.gate.Unlock() + }(a) + } + wg.Wait() + return nil +} + +func (b *appBackend) isClosing() bool { + b.mu.Lock() + defer b.mu.Unlock() + return b.closing +} + +// sweep lets idle conversations go ([appAgentIdle]). +func (b *appBackend) sweep() { + defer close(b.sweepDone) + ticker := time.NewTicker(appSweepEvery) + defer ticker.Stop() + for { + select { + case <-b.sweepStop: + return + case <-ticker.C: + b.sweepOnce() + } + } +} + +// sweepOnce is one look at the idle rule. A conversation is kept while a turn +// runs or a question waits for the person, whatever the clock says. +func (b *appBackend) sweepOnce() { + now := b.now() + b.mu.Lock() + var idle []*appAgent + for _, a := range b.agents { + if live := a.live; live != nil && !live.running && now.Sub(live.lastUse) >= appAgentIdle { + idle = append(idle, a) + } + } + b.mu.Unlock() + for _, a := range idle { + if !a.gate.TryLock() { + continue + } + b.mu.Lock() + live := a.live + keep := live == nil || live.running || now.Sub(live.lastUse) < appAgentIdle + b.mu.Unlock() + if !keep && !live.conn.Conv.NeedsPerson() { + // The state the app reads does not move: the conversation is as + // idle on disk as it was in memory. + b.release(a) + } + a.gate.Unlock() + } +} + +// appEngineError keeps a wire error's code and calls anything else the +// engine's failure, in words fit for a person. +func appEngineError(err error) error { + var wire *appwire.Error + if errors.As(err, &wire) { + return wire + } + return &appwire.Error{Code: appwire.CodeEngine, Message: redact.Secrets(plainWords(err.Error()))} +} + +// appCrewWatch emits crew.changed when the teams file changes under the app. +type appCrewWatch struct { + emit appwire.Emitter + stop chan struct{} + done chan struct{} + known map[string]string +} + +func newAppCrewWatch(emit appwire.Emitter, crews []appwire.Crew) *appCrewWatch { + w := &appCrewWatch{emit: emit, stop: make(chan struct{}), done: make(chan struct{}), known: appCrewPrints(crews)} + go w.run() + return w +} + +func appCrewPrints(crews []appwire.Crew) map[string]string { + prints := make(map[string]string, len(crews)) + for _, crew := range crews { + prints[crew.ID] = fmt.Sprintf("%#v", crew) + } + return prints +} + +func (w *appCrewWatch) run() { + defer close(w.done) + path := teamstore.Path(config.ProfileDir()) + stamp := func() (time.Time, int64) { + info, err := os.Stat(path) + if err != nil { + return time.Time{}, -1 + } + return info.ModTime(), info.Size() + } + at, size := stamp() + ticker := time.NewTicker(appCrewPoll) + defer ticker.Stop() + for { + select { + case <-w.stop: + return + case <-ticker.C: + } + nowAt, nowSize := stamp() + if nowAt.Equal(at) && nowSize == size { + continue + } + at, size = nowAt, nowSize + crews, err := appReadCrews() + if err != nil { + continue + } + prints := appCrewPrints(crews) + for _, crew := range crews { + if w.known[crew.ID] != prints[crew.ID] { + w.emit.Emit(appwire.EventCrewChanged, appwire.CrewChanged{Crew: crew}) + } + } + w.known = prints + } +} + +func (w *appCrewWatch) close() { + close(w.stop) + <-w.done +} diff --git a/cmd/codeaf/app_test.go b/cmd/codeaf/app_test.go new file mode 100644 index 0000000000..7cc7e6ae2a --- /dev/null +++ b/cmd/codeaf/app_test.go @@ -0,0 +1,498 @@ +package main + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/session" + teamstore "github.com/Agent-Field/codeaf/internal/teams" +) + +// THE COMMAND ITSELF, ON ITS OWN PIPES. The hello is the first line and names +// the home the flag chose, ping answers, crews come off the teams file with +// their members named by agent id, an unknown method is refused, and the +// process leaves when its input ends. +func TestAppCommandAnswersHelloPingCrewsAndUnknownMethod(t *testing.T) { + root := resolvedTempDir(t) + t.Setenv("HOME", filepath.Join(root, "home")) + t.Setenv(home.EnvVar, filepath.Join(root, "unused")) + t.Setenv(config.ProfileDirEnv, filepath.Join(root, "profile")) + member := filepath.Join(root, "state", "v3", "places", "proj", "0123abcd", "transcript.jsonl") + manager := filepath.Join(root, "state", "v3", "places", "proj", "89efcdab", "transcript.jsonl") + if err := teamstore.Save(filepath.Join(root, "profile"), []teamstore.Team{{ + ID: "0a0a0a0a0a0a", Name: "harbor", Manager: manager, + Members: []teamstore.Member{{Key: manager, File: manager, Handle: "boss"}, {Key: member, File: member, Handle: "web"}}, + }}); err != nil { + t.Fatal(err) + } + + inR, inW, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + outR, outW, err := os.Pipe() + if err != nil { + t.Fatal(err) + } + oldIn, oldOut := os.Stdin, os.Stdout + os.Stdin, os.Stdout = inR, outW + t.Cleanup(func() { os.Stdin, os.Stdout = oldIn, oldOut }) + state := filepath.Join(root, "state") + done := make(chan error, 1) + go func() { + done <- runApp([]string{"--home", state}) + outW.Close() + }() + lines := bufio.NewScanner(outR) + next := func() map[string]any { + t.Helper() + if !lines.Scan() { + t.Fatalf("the app's output ended: %v", lines.Err()) + } + var line map[string]any + if err := json.Unmarshal(lines.Bytes(), &line); err != nil { + t.Fatalf("not a JSON line: %q", lines.Text()) + } + return line + } + send := func(line string) { + t.Helper() + if _, err := io.WriteString(inW, line+"\n"); err != nil { + t.Fatal(err) + } + } + + hello := next() + data, _ := hello["data"].(map[string]any) + if hello["event"] != "hello" || hello["seq"] != float64(1) || data["contract"] != float64(appwire.Contract) || data["home"] != state || data["pid"] != float64(os.Getpid()) { + t.Fatalf("hello = %v", hello) + } + + send(`{"id":1,"method":"ping"}`) + if got := next(); got["id"] != float64(1) || got["result"] == nil { + t.Fatalf("ping = %v", got) + } + + send(`{"id":2,"method":"crews.list"}`) + got := next() + crews, _ := got["result"].(map[string]any)["crews"].([]any) + if got["id"] != float64(2) || len(crews) != 1 { + t.Fatalf("crews.list = %v", got) + } + crew := crews[0].(map[string]any) + members := crew["members"].([]any) + if crew["name"] != "harbor" || crew["manager"] != "89efcdab" || len(members) != 2 { + t.Fatalf("crew = %v", crew) + } + if first := members[1].(map[string]any); first["agent"] != "0123abcd" || first["handle"] != "web" { + t.Fatalf("member = %v", first) + } + + send(`{"id":3,"method":"agents.teleport","params":{}}`) + got = next() + if got["id"] != float64(3) || got["error"].(map[string]any)["code"] != appwire.CodeUnknownMethod { + t.Fatalf("unknown method = %v", got) + } + + // Listing reads the disk and opens nothing: an empty home lists nothing. + send(`{"id":4,"method":"agents.list"}`) + got = next() + if list, ok := got["result"].(map[string]any)["agents"].([]any); !ok || len(list) != 0 { + t.Fatalf("agents.list = %v", got) + } + + inW.Close() + select { + case err := <-done: + if err != nil { + t.Fatalf("the app left with %v", err) + } + case <-time.After(10 * time.Second): + t.Fatal("the app did not leave when its input ended") + } +} + +// fakeConversation plays a scripted turn and counts what is asked of it. +type fakeConversation struct { + mu sync.Mutex + script []session.Event + hold chan struct{} + submits int + interrupted int + needs bool + tasks chan session.Event +} + +func (f *fakeConversation) Submit(ctx context.Context, text string) (<-chan session.Event, error) { + f.mu.Lock() + f.submits++ + script, hold := f.script, f.hold + f.mu.Unlock() + events := make(chan session.Event) + go func() { + defer close(events) + for _, event := range script { + events <- event + } + if hold != nil { + <-hold + } + }() + return events, nil +} +func (f *fakeConversation) Interrupt() { f.mu.Lock(); f.interrupted++; f.mu.Unlock() } +func (f *fakeConversation) Model() string { return "deepseek/deepseek-v4-flash" } +func (f *fakeConversation) Title() string { return "" } +func (f *fakeConversation) NeedsPerson() bool { f.mu.Lock(); defer f.mu.Unlock(); return f.needs } +func (f *fakeConversation) WatchTaskUpdates() (<-chan session.Event, func()) { + f.mu.Lock() + defer f.mu.Unlock() + f.tasks = make(chan session.Event, 4) + lane := f.tasks + var once sync.Once + return lane, func() { once.Do(func() { close(lane) }) } +} + +// appRecorder is an Emitter a test can wait on. +type appRecorder struct { + mu sync.Mutex + events []string + data []any + more chan struct{} +} + +func newAppRecorder() *appRecorder { return &appRecorder{more: make(chan struct{}, 1)} } + +func (r *appRecorder) Emit(event string, data any) { + r.mu.Lock() + r.events = append(r.events, event) + r.data = append(r.data, data) + r.mu.Unlock() + select { + case r.more <- struct{}{}: + default: + } +} + +// waitFor waits until an event satisfies match and answers its index. +func (r *appRecorder) waitFor(t *testing.T, within time.Duration, match func(string, any) bool) int { + t.Helper() + deadline := time.After(within) + seen := 0 + for { + r.mu.Lock() + for ; seen < len(r.events); seen++ { + if match(r.events[seen], r.data[seen]) { + r.mu.Unlock() + return seen + } + } + r.mu.Unlock() + select { + case <-r.more: + case <-deadline: + r.mu.Lock() + defer r.mu.Unlock() + t.Fatalf("no matching event; heard %v", r.events) + return -1 + } + } +} + +func appTurnDone(event string, _ any) bool { return event == appwire.EventTurnDone } + +func fakeOpener(conv *fakeConversation, opens *int, releases *int, mu *sync.Mutex) appOpener { + return func(ask appOpenAsk) (*appConn, error) { + mu.Lock() + *opens++ + mu.Unlock() + return &appConn{ + Conv: conv, Workspace: ask.Workspace, + Transcript: "/state/v3/places/proj/5e55a0f1/transcript.jsonl", + Release: func() { mu.Lock(); *releases++; mu.Unlock() }, + }, nil + } +} + +// AN AGENT'S TURN ON THE WIRE. Every session event a turn streams lands as the +// protocol's event, in order: the agent goes to work, the turn starts, chunks +// of the reply are folded per kind, tools begin and end with their outcome, a +// task's change is said once, the turn ends, and the agent is idle again. +func TestAppSendMapsSessionEventsOntoTurnEvents(t *testing.T) { + conv := &fakeConversation{script: []session.Event{ + {Kind: session.EventReasoning, Text: "hm"}, + {Kind: session.EventReasoning, Text: "m"}, + {Kind: session.EventTextDelta, Text: "hello"}, + {Kind: session.EventTextDelta, Text: " world"}, + {Kind: session.EventToolBegin, CallID: "c1", Tool: "read", Hint: "read go.mod"}, + {Kind: session.EventToolEnd, CallID: "c1", Tool: "read", Hint: "12 lines"}, + {Kind: session.EventToolBegin, CallID: "c2", Tool: "bash", Hint: "bash go test"}, + {Kind: session.EventToolFailed, CallID: "c2", Tool: "bash", Hint: "exit status 1"}, + {Kind: session.EventTaskUpdate, Task: &session.TaskNotice{ID: 3, Title: "fix the parser", State: session.TaskRunning}}, + {Kind: session.EventTaskUpdate, Task: &session.TaskNotice{ID: 3, Title: "fix the parser", State: session.TaskRunning}}, + {Kind: session.EventTitleChanged, Text: "Parser fix"}, + {Kind: session.EventTurnDone}, + }} + var mu sync.Mutex + var opens, releases int + rec := newAppRecorder() + backend := newAppBackend(rec, fakeOpener(conv, &opens, &releases, &mu)) + defer backend.Close() + + agent, err := backend.CreateAgent(t.TempDir(), "deepseek/deepseek-v4-flash") + if err != nil { + t.Fatal(err) + } + if agent.ID != "5e55a0f1" || agent.State != appwire.StateIdle || agent.Model != "deepseek/deepseek-v4-flash" { + t.Fatalf("created = %+v", agent) + } + turn, err := backend.Send(agent.ID, "fix it") + if err != nil || turn == 0 { + t.Fatalf("send = %d, %v", turn, err) + } + done := rec.waitFor(t, 5*time.Second, appTurnDone) + rec.waitFor(t, 5*time.Second, func(event string, data any) bool { + changed, ok := data.(appwire.AgentChanged) + return event == appwire.EventAgentChanged && ok && changed.Agent.State == appwire.StateIdle && changed.Agent.Title == "Parser fix" + }) + + rec.mu.Lock() + defer rec.mu.Unlock() + // The first event is the create's own agent.changed. + got := rec.events[1 : done+2] + want := []string{ + appwire.EventAgentChanged, appwire.EventTurnStarted, + appwire.EventTurnDelta, appwire.EventTurnDelta, + appwire.EventTurnTool, appwire.EventTurnTool, appwire.EventTurnTool, appwire.EventTurnTool, + appwire.EventTaskChanged, appwire.EventAgentChanged, appwire.EventTurnDone, appwire.EventAgentChanged, + } + if strings.Join(got, " ") != strings.Join(want, " ") { + t.Fatalf("events:\n got %v\nwant %v", got, want) + } + data := rec.data[1 : done+2] + if s := data[0].(appwire.AgentChanged).Agent.State; s != appwire.StateWorking { + t.Fatalf("first change = %s, want working", s) + } + if d := data[1].(appwire.TurnStarted); d.Turn != turn || d.Agent != agent.ID { + t.Fatalf("turn.started = %+v", d) + } + if d := data[2].(appwire.TurnDelta); d.Kind != appwire.DeltaThinking || d.Text != "hmm" { + t.Fatalf("thinking = %+v", d) + } + if d := data[3].(appwire.TurnDelta); d.Kind != appwire.DeltaText || d.Text != "hello world" { + t.Fatalf("text = %+v", d) + } + begin := data[4].(appwire.TurnTool) + end := data[5].(appwire.TurnTool) + failed := data[7].(appwire.TurnTool) + if begin.Phase != appwire.PhaseBegin || begin.Call != "c1" || begin.Name != "read" || begin.OK != nil { + t.Fatalf("begin = %+v", begin) + } + if end.Phase != appwire.PhaseEnd || end.OK == nil || !*end.OK || end.Summary != "12 lines" { + t.Fatalf("end = %+v", end) + } + if failed.Phase != appwire.PhaseEnd || failed.OK == nil || *failed.OK || failed.Summary != "exit status 1" { + t.Fatalf("failed = %+v", failed) + } + if task := data[8].(appwire.TaskChanged).Task; task.ID != "3" || task.Agent != agent.ID || task.State != appwire.TaskRunning || task.Title != "fix the parser" { + t.Fatalf("task = %+v", task) + } + if d := data[10].(appwire.TurnDone); !d.OK || d.Turn != turn || d.Error != "" { + t.Fatalf("turn.done = %+v", d) + } +} + +// A turn that errs ends not ok with the reason, and a second send while one is +// running is refused busy rather than queued behind it. +func TestAppSendRefusesBusyAndReportsAFailedTurn(t *testing.T) { + hold := make(chan struct{}) + conv := &fakeConversation{hold: hold, script: []session.Event{{Kind: session.EventError, Err: errors.New("the provider refused")}}} + var mu sync.Mutex + var opens, releases int + rec := newAppRecorder() + backend := newAppBackend(rec, fakeOpener(conv, &opens, &releases, &mu)) + defer backend.Close() + agent, err := backend.CreateAgent(t.TempDir(), "") + if err != nil { + t.Fatal(err) + } + if _, err := backend.Send(agent.ID, "one"); err != nil { + t.Fatal(err) + } + _, err = backend.Send(agent.ID, "two") + var wire *appwire.Error + if !errors.As(err, &wire) || wire.Code != appwire.CodeBusy { + t.Fatalf("second send = %v, want busy", err) + } + if err := backend.Stop(agent.ID); err != nil || conv.interrupted != 1 { + t.Fatalf("stop = %v, interrupted %d", err, conv.interrupted) + } + close(hold) + i := rec.waitFor(t, 5*time.Second, appTurnDone) + rec.mu.Lock() + d := rec.data[i].(appwire.TurnDone) + rec.mu.Unlock() + if d.OK || !strings.Contains(d.Error, "the provider refused") { + t.Fatalf("turn.done = %+v", d) + } +} + +// IDLE CONVERSATIONS ARE LET GO AND COME BACK BY THEMSELVES. One past the idle +// rule is released with its state left idle; one still waiting on the person is +// kept; the next send opens the conversation again without being asked. +func TestAppLetsAnIdleConversationGoAndTheNextSendReopensIt(t *testing.T) { + conv := &fakeConversation{script: []session.Event{{Kind: session.EventTurnDone}}} + var mu sync.Mutex + var opens, releases int + rec := newAppRecorder() + backend := newAppBackend(rec, fakeOpener(conv, &opens, &releases, &mu)) + defer backend.Close() + clock := time.Now() + backend.now = func() time.Time { return clock } + agent, err := backend.CreateAgent(t.TempDir(), "") + if err != nil { + t.Fatal(err) + } + + setNeeds := func(v bool) { conv.mu.Lock(); conv.needs = v; conv.mu.Unlock() } + setNeeds(true) + clock = clock.Add(appAgentIdle + time.Second) + backend.sweepOnce() + mu.Lock() + kept := releases == 0 + mu.Unlock() + if !kept { + t.Fatal("a conversation waiting on the person was let go") + } + setNeeds(false) + backend.sweepOnce() + mu.Lock() + if releases != 1 { + t.Fatalf("released %d times, want once", releases) + } + mu.Unlock() + backend.mu.Lock() + state := backend.agents[agent.ID].view.State + backend.mu.Unlock() + if state != appwire.StateIdle { + t.Fatalf("state after letting go = %s", state) + } + + if _, err := backend.Send(agent.ID, "again"); err != nil { + t.Fatalf("send after letting go = %v", err) + } + rec.waitFor(t, 5*time.Second, appTurnDone) + mu.Lock() + defer mu.Unlock() + if opens != 2 { + t.Fatalf("opened %d times, want the create and the reopen", opens) + } +} + +// agents.close gives the conversation back, says closed, and leaves the agent +// openable; an id nobody has is not found. +func TestAppCloseSaysClosedAndUnknownIdsAreNotFound(t *testing.T) { + conv := &fakeConversation{} + var mu sync.Mutex + var opens, releases int + rec := newAppRecorder() + backend := newAppBackend(rec, fakeOpener(conv, &opens, &releases, &mu)) + defer backend.Close() + t.Setenv(home.EnvVar, t.TempDir()) + agent, err := backend.CreateAgent(t.TempDir(), "") + if err != nil { + t.Fatal(err) + } + if err := backend.CloseAgent(agent.ID); err != nil { + t.Fatal(err) + } + rec.waitFor(t, time.Second, func(event string, data any) bool { + c, ok := data.(appwire.AgentChanged) + return ok && c.Agent.State == appwire.StateClosed + }) + if releases != 1 { + t.Fatalf("released %d", releases) + } + if _, err := backend.OpenAgent(agent.ID); err != nil { + t.Fatalf("reopen = %v", err) + } + _, err = backend.OpenAgent("feedface") + var wire *appwire.Error + if !errors.As(err, &wire) || wire.Code != appwire.CodeNotFound { + t.Fatalf("unknown = %v", err) + } +} + +// A REAL CONVERSATION, HELD IN THIS PROCESS. The backend builds it with the +// same assembly a host uses, over an in-memory pipe, against a scripted model +// endpoint; a sent message comes back as the model's reply on the wire, the +// conversation is on disk for agents.list, and closing the backend lets it go. +func TestAppHoldsARealConversationInProcess(t *testing.T) { + root := resolvedTempDir(t) + _, workspace := wakeEnvironment(t, root) + rec := newAppRecorder() + backend := newAppBackend(rec, appOpenInProcess) + agent, err := backend.CreateAgent(workspace, "") + if err != nil { + backend.Close() + t.Fatalf("create = %v", err) + } + if agent.Workspace != workspace || agent.ID == "" { + backend.Close() + t.Fatalf("agent = %+v", agent) + } + turn, err := backend.Send(agent.ID, "say done") + if err != nil { + backend.Close() + t.Fatalf("send = %v", err) + } + i := rec.waitFor(t, 30*time.Second, appTurnDone) + rec.mu.Lock() + done := rec.data[i].(appwire.TurnDone) + var text strings.Builder + for j, event := range rec.events { + if d, ok := rec.data[j].(appwire.TurnDelta); ok && event == appwire.EventTurnDelta && d.Kind == appwire.DeltaText && d.Turn == turn { + text.WriteString(d.Text) + } + } + rec.mu.Unlock() + if !done.OK || done.Turn != turn { + backend.Close() + t.Fatalf("turn.done = %+v", done) + } + if !strings.Contains(text.String(), "done") { + backend.Close() + t.Fatalf("the reply on the wire was %q", text.String()) + } + agents, err := backend.ListAgents(workspace) + if err != nil || len(agents) != 1 || agents[0].ID != agent.ID { + backend.Close() + t.Fatalf("agents.list = %+v, %v", agents, err) + } + tasks, err := backend.ListTasks(agent.ID) + if err != nil || len(tasks) != 0 { + backend.Close() + t.Fatalf("tasks.list = %+v, %v", tasks, err) + } + if err := backend.Close(); err != nil { + t.Fatal(err) + } + if live := backend.liveOf(backend.agents[agent.ID]); live != nil { + t.Fatal("closing the backend kept a conversation") + } + if !backend.assembled.Load() { + t.Fatal("the backend did not record that it built a conversation") + } +} diff --git a/cmd/codeaf/beltdoors_test.go b/cmd/codeaf/beltdoors_test.go index db2c4139db..d54178056d 100644 --- a/cmd/codeaf/beltdoors_test.go +++ b/cmd/codeaf/beltdoors_test.go @@ -33,6 +33,7 @@ const helpLineCap = 117 // deliberately does NOT name, so the check below does not demand a line for // them: // +// - `app` is the desktop app's backend, which the app starts as its child. // - `engine` and `clock` are machinery a window starts, not things a person // runs by hand; main.go says so where it dispatches them and // internal/manual's running-from-the-terminal page says so again. @@ -43,7 +44,7 @@ const helpLineCap = 117 // `plan revise`; the page names the commands they became. // - `help` is the alias for `--help` itself. var doorsOffThePage = map[string]bool{ - "engine": true, "tick": true, "clock": true, "wake": true, "show": true, "revise": true, "help": true, + "engine": true, "app": true, "tick": true, "clock": true, "wake": true, "show": true, "revise": true, "help": true, } // THE FIRST GESTURE ON AN UNFAMILIAR DOOR IS `-h`, and it must not be a diff --git a/cmd/codeaf/chatv3_track_test.go b/cmd/codeaf/chatv3_track_test.go index d01e537305..7f97034ba0 100644 --- a/cmd/codeaf/chatv3_track_test.go +++ b/cmd/codeaf/chatv3_track_test.go @@ -115,12 +115,14 @@ func TestTheEngineDoorTracksEveryConversationItBuildsAndForgetsIt(t *testing.T) } var boot *ast.FuncDecl for _, decl := range file.Decls { - if function, ok := decl.(*ast.FuncDecl); ok && function.Name.Name == "bootEngine" { + // The assembly half of the door: bootEngine moves into the workspace + // and hands over to it, and `codeaf app` calls it directly. + if function, ok := decl.(*ast.FuncDecl); ok && function.Name.Name == "assembleEngine" { boot = function } } if boot == nil { - t.Fatal("bootEngine is gone from engine.go — this law names the door that opens an engine conversation") + t.Fatal("assembleEngine is gone from engine.go — this law names the door that opens an engine conversation") } built, tracked, closed := 0, 0, false @@ -149,10 +151,10 @@ func TestTheEngineDoorTracksEveryConversationItBuildsAndForgetsIt(t *testing.T) }) if built == 0 { - t.Fatal("bootEngine builds no conversation — this law can no longer read the door it was written for") + t.Fatal("assembleEngine builds no conversation — this law can no longer read the door it was written for") } if tracked != built { - t.Fatalf("bootEngine builds %d conversations and tracks %d: a conversation this process does not hold is one no broadcast reaches, so a service connected while it is open never arrives", built, tracked) + t.Fatalf("assembleEngine builds %d conversations and tracks %d: a conversation this process does not hold is one no broadcast reaches, so a service connected while it is open never arrives", built, tracked) } if !closed { t.Fatal("the engine hands the wire no Closed door: tracking without it grows the held list for the life of a daemon that outlives every conversation in it") diff --git a/cmd/codeaf/engine.go b/cmd/codeaf/engine.go index 206c9efc25..82478f37e4 100644 --- a/cmd/codeaf/engine.go +++ b/cmd/codeaf/engine.go @@ -933,7 +933,14 @@ func bootEngine(hello remote.Hello, workspaceFlag, sessionFlag string) (*remote. if err := os.Chdir(workspace); err != nil { return nil, fmt.Errorf("open %s: %w", workspace, err) } + return assembleEngine(hello, workspace, sessionFlag) +} +// assembleEngine is [bootEngine] after the chdir: the conversation built for a +// hello in a workspace that is already resolved. `codeaf app` calls it directly +// because it holds conversations in several workspaces in one process and so +// cannot move the process into any one of them (app.go says what that costs). +func assembleEngine(hello remote.Hello, workspace, sessionFlag string) (*remote.Engine, error) { // ONE PROCESS, ONE WORKSPACE — and, when this process is a host, SEVERAL // CONVERSATIONS IN IT. The process half of a launch is opened once and // shared ([openEngineProcess]): the profile, the model catalog, the harness diff --git a/cmd/codeaf/main.go b/cmd/codeaf/main.go index 39c0af6d1c..a872e6af3a 100644 --- a/cmd/codeaf/main.go +++ b/cmd/codeaf/main.go @@ -298,6 +298,12 @@ func run() error { // command that draws nothing and reads no keys would only be a puzzle // in a list of commands that do. return runRemoteEngine(os.Args[2:]) + case "app": + // The desktop app's backend: JSON lines on this process's own pipes, + // every agent held in this one process (app.go). Like `engine` it is + // absent from the usage text, because the app starts it and a person + // never types it. + return runApp(os.Args[2:]) case "serve": // The other half of reaching this machine, for the machines ssh cannot // reach: it dials OUT to a relay and holds the connection open, so a diff --git a/docs/changes/unreleased/0-app-backend.md b/docs/changes/unreleased/0-app-backend.md new file mode 100644 index 0000000000..332442faed --- /dev/null +++ b/docs/changes/unreleased/0-app-backend.md @@ -0,0 +1,17 @@ +--- +kind: added +title: codeaf app is the desktop app's backend, speaking JSON lines on its own pipes +pr: 0 +surface: [engine] +invalidates: + - "The desktop app's backend was planned as one `codeaf engine` child per agent (the app/v2 branch). `codeaf app` holds every agent in its own process, built by the same assembly a host uses and reached over an in-memory pipe; nothing is spawned per agent." + - "bootEngine was the only way to assemble an engine conversation and always moved the process into its workspace. assembleEngine now does the assembly for a resolved workspace without the chdir; bootEngine is the chdir and then assembleEngine." +--- + +`codeaf app [--home dir]` implements docs/design/app-v3/PROTOCOL.md contract 1: +hello first, then ping, shutdown, agents.list/create/open/send/stop/close, +tasks.list and crews.list, with agent.changed, turn.started/delta/tool/done, +task.changed, crew.changed and log events. Listing reads the disk and opens +nothing; a conversation is built on create, open or send, let go after ten idle +minutes, and rebuilt by the next send. Text and thinking chunks are folded per +agent and kind for 16 ms or 4 KB. internal/appwire is the transport. diff --git a/internal/appwire/outbox.go b/internal/appwire/outbox.go new file mode 100644 index 0000000000..e30d91af53 --- /dev/null +++ b/internal/appwire/outbox.go @@ -0,0 +1,150 @@ +package appwire + +import ( + "sync" + "time" +) + +// The two numbers delta coalescing is measured in. A streamed reply arrives as +// many small chunks, often a word each, and a line per chunk is a line the app +// has to parse and a render it has to schedule for every word. So chunks of one +// kind are folded into one turn.delta and sent when the first of them is +// DeltaWindow old or the folded text reaches DeltaBytes, whichever comes first. +// Sixteen milliseconds is one frame at sixty hertz: the app could not have +// drawn the chunks separately anyway. +const ( + DeltaWindow = 16 * time.Millisecond + DeltaBytes = 4 << 10 +) + +// Outbox is one agent's ordered queue of events on the way to the writer. +// +// IT IS WHAT LETS AN ENGINE'S READ LOOP NEVER WAIT ON STDOUT. Adding to it +// takes a lock and appends; the waiting on the writer happens on the outbox's +// own goroutine. And because every event of one agent goes through the same +// queue, a delta folded here can never be overtaken by the tool event or the +// turn end that followed it: anything queued behind a delta sends the delta +// first, at once, without waiting out the window. +type Outbox struct { + emit Emitter + + mu sync.Mutex + queue []outboxItem + closed bool + wake chan struct{} + done chan struct{} + now func() time.Time +} + +type outboxItem struct { + event string + data any + delta *TurnDelta + born time.Time +} + +// NewOutbox starts an outbox writing to emit. +func NewOutbox(emit Emitter) *Outbox { + o := &Outbox{emit: emit, wake: make(chan struct{}, 1), done: make(chan struct{}), now: time.Now} + go o.run() + return o +} + +// Delta queues one chunk of streamed text, folding it into the chunk before it +// when that one is the same turn and kind and still waiting. +func (o *Outbox) Delta(agent string, turn uint64, kind, text string) { + if text == "" { + return + } + o.mu.Lock() + if o.closed { + o.mu.Unlock() + return + } + if n := len(o.queue); n > 0 { + last := o.queue[n-1].delta + if last != nil && last.Agent == agent && last.Turn == turn && last.Kind == kind && len(last.Text) < DeltaBytes { + last.Text += text + o.mu.Unlock() + o.poke() + return + } + } + o.queue = append(o.queue, outboxItem{delta: &TurnDelta{Agent: agent, Turn: turn, Kind: kind, Text: text}, born: o.now()}) + o.mu.Unlock() + o.poke() +} + +// Event queues one event behind everything already queued. +func (o *Outbox) Event(event string, data any) { + o.mu.Lock() + if o.closed { + o.mu.Unlock() + return + } + o.queue = append(o.queue, outboxItem{event: event, data: data}) + o.mu.Unlock() + o.poke() +} + +// Close sends everything still queued, without waiting out any window, and +// returns once it has been handed to the writer. +func (o *Outbox) Close() { + o.mu.Lock() + o.closed = true + o.mu.Unlock() + o.poke() + <-o.done +} + +func (o *Outbox) poke() { + select { + case o.wake <- struct{}{}: + default: + } +} + +func (o *Outbox) run() { + defer close(o.done) + timer := time.NewTimer(time.Hour) + timer.Stop() + for { + o.mu.Lock() + if len(o.queue) == 0 { + closed := o.closed + o.mu.Unlock() + if closed { + return + } + <-o.wake + continue + } + head := o.queue[0] + // A lone delta that is young and small waits for more of itself. + // Anything behind it, a full one, or a closing outbox sends it now. + if head.delta != nil && len(o.queue) == 1 && !o.closed && len(head.delta.Text) < DeltaBytes { + if wait := head.born.Add(DeltaWindow).Sub(o.now()); wait > 0 { + o.mu.Unlock() + timer.Reset(wait) + select { + case <-timer.C: + case <-o.wake: + if !timer.Stop() { + <-timer.C + } + } + continue + } + } + o.queue = o.queue[1:] + if len(o.queue) == 0 { + o.queue = nil + } + o.mu.Unlock() + if head.delta != nil { + o.emit.Emit(EventTurnDelta, *head.delta) + } else { + o.emit.Emit(head.event, head.data) + } + } +} diff --git a/internal/appwire/server.go b/internal/appwire/server.go new file mode 100644 index 0000000000..29c6ca34c0 --- /dev/null +++ b/internal/appwire/server.go @@ -0,0 +1,403 @@ +package appwire + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "io" + "strings" + "sync" + "time" +) + +// writerQueue is how many frames may wait for stdout before a sender waits. +// It bounds the memory a stalled reader can make this process hold, and it is +// never what the engine's own read loops wait on: those hand their events to +// an [Outbox], whose sender is the one that waits here. +const writerQueue = 1024 + +// handlerGrace is how long a leaving process waits for calls still in flight +// to answer after the engine has been closed under them. A call that has not +// answered by then is abandoned; the process is exiting either way. +const handlerGrace = 2 * time.Second + +// Server is one stdio connection to the app: the one writer, the sequence +// counter, and the dispatch table. +type Server struct { + frames chan frame + + // mu guards closed against a late Emit. A sender holds the read lock while + // it queues, and the leaving process takes the write lock before it closes + // the queue, so no frame is ever sent on a closed channel. + mu sync.RWMutex + closed bool + + wrote chan struct{} + err error +} + +// NewServer starts the writer over out. Every line this process writes to the +// app goes through it, so the sequence numbers are assigned in exactly the +// order the lines leave. +func NewServer(out io.Writer) *Server { + s := &Server{frames: make(chan frame, writerQueue), wrote: make(chan struct{})} + go s.write(out) + return s +} + +// write is the one writer. It encodes each frame once, writes it to a +// buffered stdout, and flushes when nothing else is waiting: a burst of frames +// is one system call, and a lone frame leaves at once. +func (s *Server) write(out io.Writer) { + defer close(s.wrote) + buffered := bufio.NewWriterSize(out, 64<<10) + var seq uint64 + put := func(f frame) { + if s.err != nil { + return + } + number := uint64(0) + if f.event != "" { + seq++ + number = seq + } + line, err := f.encode(number) + if err != nil { + // A value that cannot be encoded is a bug in this binary, and the + // app is still owed an answer to the request that produced it. + if f.event != "" { + return + } + line, _ = frame{id: f.id, err: Errorf(CodeEngine, "the answer could not be encoded: %v", err)}.encode(0) + } + line = append(line, '\n') + if _, err := buffered.Write(line); err != nil { + s.err = err + } + } + for f := range s.frames { + put(f) + drain: + for { + select { + case next, ok := <-s.frames: + if !ok { + break drain + } + put(next) + default: + break drain + } + } + if s.err == nil { + if err := buffered.Flush(); err != nil { + s.err = err + } + } + } + if s.err == nil { + _ = buffered.Flush() + } +} + +// Emit queues one event. It is safe from any goroutine, and after the server +// has closed it drops the event: the app has stopped reading. +func (s *Server) Emit(event string, data any) { + s.send(frame{event: event, data: data}) +} + +func (s *Server) send(f frame) { + s.mu.RLock() + defer s.mu.RUnlock() + if s.closed { + return + } + s.frames <- f +} + +// finish closes the queue and waits for every queued line to be written. +func (s *Server) finish() { + s.mu.Lock() + if !s.closed { + s.closed = true + close(s.frames) + } + s.mu.Unlock() + <-s.wrote +} + +type request struct { + ID json.RawMessage `json:"id"` + Method string `json:"method"` + Params json.RawMessage `json:"params"` +} + +// Serve is the whole lifetime of the connection: the hello line first, then +// requests until stdin ends, the context is cancelled, or the app asks for +// shutdown. Every way out closes the engine and writes every queued line +// before it returns. +func (s *Server) Serve(ctx context.Context, in io.Reader, hello Hello, engine Engine) error { + hello.Contract = Contract + s.Emit(EventHello, hello) + + lines := make(chan []byte) + readErr := make(chan error, 1) + stop := make(chan struct{}) + go func() { + reader := bufio.NewReaderSize(in, 64<<10) + for { + line, err := reader.ReadBytes('\n') + if len(line) > 0 { + select { + case lines <- line: + case <-stop: + return + } + } + if err != nil { + if errors.Is(err, io.EOF) { + err = nil + } + readErr <- err + return + } + } + }() + + calls, cancel := context.WithCancel(ctx) + var inflight sync.WaitGroup + var result error +loop: + for { + select { + case <-ctx.Done(): + break loop + case result = <-readErr: + break loop + case line := <-lines: + if s.handle(calls, line, engine, &inflight) { + break loop + } + } + } + close(stop) + cancel() + _ = engine.Close() + waited := make(chan struct{}) + go func() { inflight.Wait(); close(waited) }() + select { + case <-waited: + case <-time.After(handlerGrace): + } + s.finish() + if result == nil { + result = s.err + } + return result +} + +// handle answers one line, and reports whether it was the request to leave. +func (s *Server) handle(ctx context.Context, line []byte, engine Engine, inflight *sync.WaitGroup) bool { + text := strings.TrimSpace(string(line)) + if text == "" { + return false + } + var req request + if err := json.Unmarshal([]byte(text), &req); err != nil { + s.Emit(EventLog, Log{Level: "warn", Message: "a line that is not a JSON object was ignored"}) + return false + } + var id uint64 + if len(req.ID) == 0 || json.Unmarshal(req.ID, &id) != nil { + s.Emit(EventLog, Log{Level: "warn", Message: "a request without a numeric id was ignored"}) + return false + } + if req.Method == "shutdown" { + s.send(frame{id: id, result: struct{}{}}) + return true + } + method, ok := methods[req.Method] + if !ok { + s.send(frame{id: id, err: Errorf(CodeUnknownMethod, "there is no method named %q", req.Method)}) + return false + } + params := req.Params + if len(params) == 0 || string(params) == "null" { + params = json.RawMessage("{}") + } + if strings.TrimSpace(string(params))[0] != '{' { + s.send(frame{id: id, err: Errorf(CodeBadParams, "params must be an object")}) + return false + } + inflight.Add(1) + go func() { + defer inflight.Done() + result, err := method(engine, params) + if ctx.Err() != nil && err != nil { + err = Errorf(CodeClosed, "codeaf is shutting down") + } + if err != nil { + var wire *Error + if !errors.As(err, &wire) { + wire = &Error{Code: CodeEngine, Message: err.Error()} + } + s.send(frame{id: id, err: wire}) + return + } + s.send(frame{id: id, result: result}) + }() + return false +} + +// method answers one request from its params, which are already known to be +// a JSON object. +type method func(Engine, json.RawMessage) (any, error) + +// methods is the dispatch table. shutdown is not in it: it is answered by the +// loop itself, because it ends the loop. +var methods = map[string]method{ + "ping": func(Engine, json.RawMessage) (any, error) { return struct{}{}, nil }, + "agents.list": func(e Engine, raw json.RawMessage) (any, error) { + var p struct { + Workspace string `json:"workspace"` + } + if err := decode(raw, &p); err != nil { + return nil, err + } + agents, err := e.ListAgents(p.Workspace) + if err != nil { + return nil, err + } + return struct { + Agents []Agent `json:"agents"` + }{nonNil(agents)}, nil + }, + "agents.create": func(e Engine, raw json.RawMessage) (any, error) { + var p struct { + Workspace string `json:"workspace"` + Model string `json:"model"` + } + if err := decode(raw, &p); err != nil { + return nil, err + } + if strings.TrimSpace(p.Workspace) == "" { + return nil, Errorf(CodeBadParams, "agents.create needs a workspace") + } + agent, err := e.CreateAgent(p.Workspace, p.Model) + if err != nil { + return nil, err + } + return agentResult{agent}, nil + }, + "agents.open": func(e Engine, raw json.RawMessage) (any, error) { + id, err := decodeID(raw, "id") + if err != nil { + return nil, err + } + agent, err := e.OpenAgent(id) + if err != nil { + return nil, err + } + return agentResult{agent}, nil + }, + "agents.send": func(e Engine, raw json.RawMessage) (any, error) { + var p struct { + ID string `json:"id"` + Text string `json:"text"` + } + if err := decode(raw, &p); err != nil { + return nil, err + } + if strings.TrimSpace(p.ID) == "" { + return nil, Errorf(CodeBadParams, "agents.send needs an id") + } + if strings.TrimSpace(p.Text) == "" { + return nil, Errorf(CodeBadParams, "agents.send needs some text") + } + turn, err := e.Send(p.ID, p.Text) + if err != nil { + return nil, err + } + return struct { + Turn uint64 `json:"turn"` + }{turn}, nil + }, + "agents.stop": func(e Engine, raw json.RawMessage) (any, error) { + id, err := decodeID(raw, "id") + if err != nil { + return nil, err + } + return struct{}{}, e.Stop(id) + }, + "agents.close": func(e Engine, raw json.RawMessage) (any, error) { + id, err := decodeID(raw, "id") + if err != nil { + return nil, err + } + return struct{}{}, e.CloseAgent(id) + }, + "tasks.list": func(e Engine, raw json.RawMessage) (any, error) { + id, err := decodeID(raw, "agent") + if err != nil { + return nil, err + } + tasks, err := e.ListTasks(id) + if err != nil { + return nil, err + } + return struct { + Tasks []Task `json:"tasks"` + }{nonNil(tasks)}, nil + }, + "crews.list": func(e Engine, raw json.RawMessage) (any, error) { + crews, err := e.ListCrews() + if err != nil { + return nil, err + } + for i := range crews { + crews[i].Members = nonNil(crews[i].Members) + } + return struct { + Crews []Crew `json:"crews"` + }{nonNil(crews)}, nil + }, +} + +type agentResult struct { + Agent Agent `json:"agent"` +} + +// decode reads params into v, answering bad_params when their shape is wrong. +func decode(raw json.RawMessage, v any) error { + if err := json.Unmarshal(raw, v); err != nil { + return Errorf(CodeBadParams, "the params could not be read: %v", err) + } + return nil +} + +// decodeID reads the one required string a method takes. +func decodeID(raw json.RawMessage, field string) (string, error) { + var p map[string]json.RawMessage + if err := decode(raw, &p); err != nil { + return "", err + } + var id string + if value, ok := p[field]; ok { + if err := json.Unmarshal(value, &id); err != nil { + return "", Errorf(CodeBadParams, "%s must be a string", field) + } + } + if strings.TrimSpace(id) == "" { + return "", Errorf(CodeBadParams, "%s is required", field) + } + return id, nil +} + +// nonNil keeps an empty list a list on the wire, never null. +func nonNil[T any](list []T) []T { + if list == nil { + return []T{} + } + return list +} diff --git a/internal/appwire/server_test.go b/internal/appwire/server_test.go new file mode 100644 index 0000000000..1d6cf0c0e4 --- /dev/null +++ b/internal/appwire/server_test.go @@ -0,0 +1,383 @@ +package appwire + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "io" + "strings" + "sync" + "testing" + "time" +) + +// fakeEngine answers every method from fixed values and records closing. +type fakeEngine struct { + mu sync.Mutex + closed int + block chan struct{} +} + +func (f *fakeEngine) ListAgents(workspace string) ([]Agent, error) { + if workspace == "/nowhere" { + return nil, nil + } + return []Agent{{ID: "a1", Title: "one", Workspace: "/w", State: StateIdle}}, nil +} +func (f *fakeEngine) CreateAgent(workspace, model string) (Agent, error) { + return Agent{ID: "new", Workspace: workspace, Model: model, State: StateIdle}, nil +} +func (f *fakeEngine) OpenAgent(id string) (Agent, error) { + if id != "a1" { + return Agent{}, Errorf(CodeNotFound, "no agent %s", id) + } + return Agent{ID: id, State: StateIdle}, nil +} +func (f *fakeEngine) Send(id, text string) (uint64, error) { + if f.block != nil { + <-f.block + } + return 7, nil +} +func (f *fakeEngine) Stop(id string) error { return errors.New("the engine went away") } +func (f *fakeEngine) CloseAgent(id string) error { return nil } +func (f *fakeEngine) ListTasks(agent string) ([]Task, error) { + return nil, nil +} +func (f *fakeEngine) ListCrews() ([]Crew, error) { + return []Crew{{ID: "c1", Name: "harbor"}}, nil +} +func (f *fakeEngine) Close() error { + f.mu.Lock() + f.closed++ + f.mu.Unlock() + if f.block != nil { + select { + case <-f.block: + default: + close(f.block) + } + } + return nil +} + +// wire is a running server over two pipes, as the app would hold it. +type wire struct { + t *testing.T + in *io.PipeWriter + lines *bufio.Scanner + served chan error + engine *fakeEngine +} + +func startWire(t *testing.T, engine *fakeEngine) *wire { + t.Helper() + inR, inW := io.Pipe() + outR, outW := io.Pipe() + served := make(chan error, 1) + server := NewServer(outW) + go func() { + served <- server.Serve(context.Background(), inR, Hello{Version: "test", PID: 42, Home: "/h"}, engine) + outW.Close() + }() + scanner := bufio.NewScanner(outR) + scanner.Buffer(make([]byte, 1<<20), 1<<20) + w := &wire{t: t, in: inW, lines: scanner, served: served, engine: engine} + t.Cleanup(func() { inW.Close() }) + return w +} + +func (w *wire) send(line string) { + w.t.Helper() + if _, err := io.WriteString(w.in, line+"\n"); err != nil { + w.t.Fatal(err) + } +} + +func (w *wire) next() map[string]any { + w.t.Helper() + got := make(chan map[string]any, 1) + go func() { + if !w.lines.Scan() { + got <- nil + return + } + var line map[string]any + if err := json.Unmarshal(w.lines.Bytes(), &line); err != nil { + w.t.Errorf("a line that is not JSON: %q", w.lines.Text()) + } + got <- line + }() + select { + case line := <-got: + if line == nil { + w.t.Fatal("the output ended") + } + return line + case <-time.After(5 * time.Second): + w.t.Fatal("no line arrived") + return nil + } +} + +func TestHelloIsTheFirstLineAndCarriesTheContract(t *testing.T) { + w := startWire(t, &fakeEngine{}) + hello := w.next() + if hello["event"] != "hello" || hello["seq"] != float64(1) { + t.Fatalf("first line = %v", hello) + } + data := hello["data"].(map[string]any) + if data["contract"] != float64(Contract) || data["version"] != "test" || data["pid"] != float64(42) || data["home"] != "/h" { + t.Fatalf("hello data = %v", data) + } +} + +func TestPingAnswersAnEmptyObjectAndUnknownMethodsAreRefused(t *testing.T) { + w := startWire(t, &fakeEngine{}) + w.next() + w.send(`{"id":1,"method":"ping"}`) + if got := w.next(); got["id"] != float64(1) || got["result"] == nil || len(got["result"].(map[string]any)) != 0 { + t.Fatalf("ping = %v", got) + } + w.send(`{"id":2,"method":"agents.fly","params":{}}`) + got := w.next() + if got["id"] != float64(2) || got["error"].(map[string]any)["code"] != CodeUnknownMethod { + t.Fatalf("unknown method = %v", got) + } +} + +func TestBadParamsAreAnsweredWithTheirCode(t *testing.T) { + w := startWire(t, &fakeEngine{}) + w.next() + for i, line := range []string{ + `{"id":1,"method":"agents.open","params":{}}`, + `{"id":2,"method":"agents.open","params":{"id":5}}`, + `{"id":3,"method":"agents.create","params":[]}`, + `{"id":4,"method":"agents.send","params":{"id":"a1"}}`, + `{"id":5,"method":"tasks.list"}`, + } { + w.send(line) + got := w.next() + if got["id"] != float64(i+1) || got["error"] == nil || got["error"].(map[string]any)["code"] != CodeBadParams { + t.Fatalf("%s = %v", line, got) + } + } +} + +func TestEngineErrorsKeepTheirCodeOrBecomeEngine(t *testing.T) { + w := startWire(t, &fakeEngine{}) + w.next() + w.send(`{"id":1,"method":"agents.open","params":{"id":"zz","extra":true}}`) + if got := w.next(); got["error"].(map[string]any)["code"] != CodeNotFound { + t.Fatalf("open = %v", got) + } + w.send(`{"id":2,"method":"agents.stop","params":{"id":"a1"}}`) + got := w.next() + failure := got["error"].(map[string]any) + if failure["code"] != CodeEngine || !strings.Contains(failure["message"].(string), "went away") { + t.Fatalf("stop = %v", got) + } +} + +func TestListsAreNeverNull(t *testing.T) { + w := startWire(t, &fakeEngine{}) + w.next() + w.send(`{"id":1,"method":"agents.list","params":{"workspace":"/nowhere"}}`) + got := w.next() + if list, ok := got["result"].(map[string]any)["agents"].([]any); !ok || len(list) != 0 { + t.Fatalf("agents.list = %v", got) + } + w.send(`{"id":2,"method":"crews.list"}`) + got = w.next() + crews := got["result"].(map[string]any)["crews"].([]any) + if len(crews) != 1 || crews[0].(map[string]any)["members"] == nil { + t.Fatalf("crews.list = %v", got) + } +} + +func TestMalformedLinesAreLoggedAndTheConnectionCarriesOn(t *testing.T) { + w := startWire(t, &fakeEngine{}) + w.next() + w.send(`not json`) + if got := w.next(); got["event"] != EventLog || got["seq"] != float64(2) { + t.Fatalf("malformed line = %v", got) + } + w.send(`{"method":"ping"}`) + if got := w.next(); got["event"] != EventLog || got["seq"] != float64(3) { + t.Fatalf("missing id = %v", got) + } + w.send(`{"id":9,"method":"ping"}`) + if got := w.next(); got["id"] != float64(9) { + t.Fatalf("ping after noise = %v", got) + } +} + +func TestEndOfInputClosesTheEngineAndReturns(t *testing.T) { + engine := &fakeEngine{} + w := startWire(t, engine) + w.next() + w.in.Close() + select { + case err := <-w.served: + if err != nil { + t.Fatalf("serve = %v", err) + } + case <-time.After(5 * time.Second): + t.Fatal("serve did not return at end of input") + } + if engine.closed != 1 { + t.Fatalf("engine closed %d times", engine.closed) + } +} + +func TestShutdownAnswersThenLeaves(t *testing.T) { + engine := &fakeEngine{} + w := startWire(t, engine) + w.next() + w.send(`{"id":3,"method":"shutdown"}`) + if got := w.next(); got["id"] != float64(3) || got["result"] == nil { + t.Fatalf("shutdown = %v", got) + } + select { + case <-w.served: + case <-time.After(5 * time.Second): + t.Fatal("serve did not return after shutdown") + } + if engine.closed != 1 { + t.Fatalf("engine closed %d times", engine.closed) + } +} + +// A slow call does not hold up the next request, and a call still waiting +// when the process leaves is answered `closed`. +func TestResponsesArriveInAnyOrderAndALateOneIsClosed(t *testing.T) { + engine := &fakeEngine{block: make(chan struct{})} + w := startWire(t, engine) + w.next() + w.send(`{"id":1,"method":"agents.send","params":{"id":"a1","text":"hi"}}`) + w.send(`{"id":2,"method":"ping"}`) + if got := w.next(); got["id"] != float64(2) { + t.Fatalf("expected the ping first, got %v", got) + } + w.in.Close() + got := w.next() + if got["id"] != float64(1) { + t.Fatalf("expected the send's answer, got %v", got) + } + // The fake answers once its block opens, which Close does; the server + // reports a call that finished after shutdown began as successful only if + // it succeeded, so either answer is one answer. + if got["result"] == nil && got["error"] == nil { + t.Fatalf("send answered nothing: %v", got) + } + <-w.served +} + +// Sequence numbers count every event, in the order the lines are written, +// whichever goroutine emitted them. +func TestSequenceNumbersAreConsecutiveAcrossGoroutines(t *testing.T) { + outR, outW := io.Pipe() + server := NewServer(outW) + var wg sync.WaitGroup + for g := 0; g < 8; g++ { + wg.Add(1) + go func() { + defer wg.Done() + for i := 0; i < 50; i++ { + server.Emit(EventLog, Log{Level: "info", Message: "x"}) + } + }() + } + go func() { + wg.Wait() + server.finish() + outW.Close() + }() + scanner := bufio.NewScanner(outR) + want := uint64(1) + for scanner.Scan() { + var line eventLine + if err := json.Unmarshal(scanner.Bytes(), &line); err != nil { + t.Fatal(err) + } + if line.Seq != want { + t.Fatalf("seq %d, want %d", line.Seq, want) + } + want++ + } + if want != 401 { + t.Fatalf("read %d events, want 400", want-1) + } +} + +// recorder is an Emitter that keeps what it was handed. +type recorder struct { + mu sync.Mutex + events []string + data []any +} + +func (r *recorder) Emit(event string, data any) { + r.mu.Lock() + r.events = append(r.events, event) + r.data = append(r.data, data) + r.mu.Unlock() +} + +func TestOutboxFoldsDeltasAndKeepsOrderWithToolsAndTheEnd(t *testing.T) { + rec := &recorder{} + box := NewOutbox(rec) + box.Event(EventTurnStarted, TurnStarted{Agent: "a", Turn: 1}) + for _, word := range []string{"hel", "lo ", "there"} { + box.Delta("a", 1, DeltaText, word) + } + box.Delta("a", 1, DeltaThinking, "hmm") + box.Event(EventTurnTool, TurnTool{Agent: "a", Turn: 1, Call: "c", Name: "read", Phase: PhaseBegin}) + box.Delta("a", 1, DeltaText, "after") + box.Event(EventTurnDone, TurnDone{Agent: "a", Turn: 1, OK: true}) + box.Close() + want := []string{EventTurnStarted, EventTurnDelta, EventTurnDelta, EventTurnTool, EventTurnDelta, EventTurnDone} + if strings.Join(rec.events, ",") != strings.Join(want, ",") { + t.Fatalf("events = %v", rec.events) + } + if got := rec.data[1].(TurnDelta); got.Text != "hello there" || got.Kind != DeltaText { + t.Fatalf("folded delta = %+v", got) + } + if got := rec.data[2].(TurnDelta); got.Text != "hmm" || got.Kind != DeltaThinking { + t.Fatalf("thinking delta = %+v", got) + } + if got := rec.data[4].(TurnDelta); got.Text != "after" { + t.Fatalf("delta after the tool = %+v", got) + } +} + +func TestOutboxSendsALoneDeltaAfterTheWindowAndAFullOneAtOnce(t *testing.T) { + rec := &recorder{} + box := NewOutbox(rec) + defer box.Close() + box.Delta("a", 1, DeltaText, "x") + deadline := time.Now().Add(2 * time.Second) + for { + rec.mu.Lock() + n := len(rec.events) + rec.mu.Unlock() + if n == 1 { + break + } + if time.Now().After(deadline) { + t.Fatal("a lone delta was never sent") + } + time.Sleep(time.Millisecond) + } + big := strings.Repeat("y", DeltaBytes) + box.Delta("a", 1, DeltaText, big) + box.Delta("a", 1, DeltaText, "z") + box.Close() + if len(rec.events) != 3 { + t.Fatalf("events = %d, want the lone one, the full one and the next", len(rec.events)) + } + if rec.data[1].(TurnDelta).Text != big || rec.data[2].(TurnDelta).Text != "z" { + t.Fatal("a full delta was folded past its size") + } +} diff --git a/internal/appwire/wire.go b/internal/appwire/wire.go new file mode 100644 index 0000000000..fa0c6012ac --- /dev/null +++ b/internal/appwire/wire.go @@ -0,0 +1,245 @@ +// Package appwire is the desktop app's door into codeaf: one JSON object per +// line on the process's own stdin and stdout, as docs/design/app-v3/PROTOCOL.md +// states it. This package is the transport and the dispatch and nothing else. +// It knows what a request, a response and an event look like, in what order +// they leave, and which method names exist; it does NOT know what an agent is. +// That half is an [Engine], which cmd/codeaf implements over the conversations +// it holds, and which a test implements in a few lines. +// +// THE SPLIT IS WHAT MAKES THE WIRE PROVABLE WITHOUT A MODEL. Framing, sequence +// numbers, the hello line, the refusal of an unknown method and the exit on +// end-of-input are all facts about bytes, and they are tested here over +// io.Pipe with no conversation anywhere in the process. +package appwire + +import ( + "encoding/json" + "fmt" + "time" +) + +// Contract is the protocol revision this build speaks, carried on the hello +// line. The app refuses a contract it does not know and kills the child, so a +// change that breaks a reader moves this number and nothing else does. +const Contract = 1 + +// The error codes a response may carry. They are words rather than numbers so +// the app can branch on them without a table, and the set is closed: a failure +// that is none of the others is [CodeEngine], with the message saying how. +const ( + CodeUnknownMethod = "unknown_method" + CodeBadParams = "bad_params" + CodeNotFound = "not_found" + CodeBusy = "busy" + CodeEngine = "engine" + CodeClosed = "closed" +) + +// The states an agent can be in, as the app draws them. +const ( + StateIdle = "idle" + StateWorking = "working" + StateNeedsYou = "needs_you" + StateStopped = "stopped" + StateClosed = "closed" +) + +// The states a task can be in on the wire. +const ( + TaskQueued = "queued" + TaskRunning = "running" + TaskDone = "done" + TaskFailed = "failed" + TaskUnverified = "unverified" +) + +// The event names this package and its engines send. +const ( + EventHello = "hello" + EventAgentChanged = "agent.changed" + EventTurnStarted = "turn.started" + EventTurnDelta = "turn.delta" + EventTurnTool = "turn.tool" + EventTurnDone = "turn.done" + EventTaskChanged = "task.changed" + EventCrewChanged = "crew.changed" + EventLog = "log" +) + +// Error is a refusal the app can read: a code from the closed set above and a +// sentence. An engine returns one when it knows which code applies; any other +// error reaching the dispatcher is answered as [CodeEngine] with its text. +type Error struct { + Code string `json:"code"` + Message string `json:"message"` +} + +func (e *Error) Error() string { return e.Code + ": " + e.Message } + +// Errorf builds an [Error] with a formatted sentence. +func Errorf(code, format string, args ...any) *Error { + return &Error{Code: code, Message: fmt.Sprintf(format, args...)} +} + +// Hello is the first line codeaf writes, before it reads anything. +type Hello struct { + Contract int `json:"contract"` + Version string `json:"version"` + PID int `json:"pid"` + Home string `json:"home"` +} + +// Agent is one conversation as the app sees it. ID is the conversation's +// session id, which is its folder's name on disk and stable across launches. +type Agent struct { + ID string `json:"id"` + Title string `json:"title"` + Workspace string `json:"workspace"` + Model string `json:"model,omitempty"` + State string `json:"state"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Task is one node of an agent's task graph. +type Task struct { + ID string `json:"id"` + Agent string `json:"agent"` + Title string `json:"title"` + State string `json:"state"` + UpdatedAt time.Time `json:"updated_at"` +} + +// Crew is one codeaf team, with its members named by agent id. +type Crew struct { + ID string `json:"id"` + Name string `json:"name"` + Manager string `json:"manager,omitempty"` + Members []CrewMember `json:"members"` +} + +// CrewMember is one conversation in a crew. +type CrewMember struct { + Agent string `json:"agent"` + Handle string `json:"handle,omitempty"` +} + +// The data of each event, one struct per name so the field spellings live in +// one place. +type ( + AgentChanged struct { + Agent Agent `json:"agent"` + } + TurnStarted struct { + Agent string `json:"agent"` + Turn uint64 `json:"turn"` + } + TurnDelta struct { + Agent string `json:"agent"` + Turn uint64 `json:"turn"` + Kind string `json:"kind"` + Text string `json:"text"` + } + TurnTool struct { + Agent string `json:"agent"` + Turn uint64 `json:"turn"` + Call string `json:"call"` + Name string `json:"name"` + Phase string `json:"phase"` + OK *bool `json:"ok,omitempty"` + Summary string `json:"summary,omitempty"` + } + TurnDone struct { + Agent string `json:"agent"` + Turn uint64 `json:"turn"` + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + } + TaskChanged struct { + Task Task `json:"task"` + } + CrewChanged struct { + Crew Crew `json:"crew"` + } + Log struct { + Level string `json:"level"` + Message string `json:"message"` + } +) + +// The two delta kinds. +const ( + DeltaText = "text" + DeltaThinking = "thinking" +) + +// The two tool phases. +const ( + PhaseBegin = "begin" + PhaseEnd = "end" +) + +// Emitter is how an engine puts an event on the wire. Emit may block while the +// writer is behind, so an engine never calls it from a loop that must keep +// reading; it hands agent-scoped events to an [Outbox] instead, which does. +type Emitter interface { + Emit(event string, data any) +} + +// Engine is everything behind the methods. Each call is made on its own +// goroutine, so a slow create never holds up a ping, and each answers either a +// result or an error ([Error] for a code the app can act on). +type Engine interface { + ListAgents(workspace string) ([]Agent, error) + CreateAgent(workspace, model string) (Agent, error) + OpenAgent(id string) (Agent, error) + Send(id, text string) (uint64, error) + Stop(id string) error + CloseAgent(id string) error + ListTasks(agent string) ([]Task, error) + ListCrews() ([]Crew, error) + // Close releases every conversation the engine holds. It is called once, + // when the process is leaving, and returns when they are all let go. + Close() error +} + +// frame is one line on the way out: a response (ID set) or an event (Event +// set). The event's sequence number is assigned by the writer, at the moment +// it is written, so the numbers are in the order the app reads them. +type frame struct { + id uint64 + result any + err *Error + event string + data any +} + +type responseLine struct { + ID uint64 `json:"id"` + Result any `json:"result,omitempty"` + Error *Error `json:"error,omitempty"` +} + +type eventLine struct { + Event string `json:"event"` + Seq uint64 `json:"seq"` + Data any `json:"data"` +} + +// encode is the one json encoding a frame gets. +func (f frame) encode(seq uint64) ([]byte, error) { + if f.event != "" { + data := f.data + if data == nil { + data = struct{}{} + } + return json.Marshal(eventLine{Event: f.event, Seq: seq, Data: data}) + } + if f.err != nil { + return json.Marshal(responseLine{ID: f.id, Error: f.err}) + } + result := f.result + if result == nil { + result = struct{}{} + } + return json.Marshal(responseLine{ID: f.id, Result: result}) +} diff --git a/internal/manual/chat/running-from-the-terminal.md b/internal/manual/chat/running-from-the-terminal.md index 8dfe73d696..24aa780079 100644 --- a/internal/manual/chat/running-from-the-terminal.md +++ b/internal/manual/chat/running-from-the-terminal.md @@ -295,8 +295,10 @@ to a person: **`codeaf plandb`** answers the same commands the worker runs in ba critical-path` — against the run's own `plandb.db`, so a plan a worker is driving can be read the way the worker reads it. -Four more exist and are deliberately kept out of the help text, because nothing types -them by hand: **`codeaf engine`** is the far half of `chat --host`, started by ssh, and +Five more exist and are deliberately kept out of the help text, because nothing types +them by hand: **`codeaf app`** is the backend the codeaf desktop app starts and speaks to +over its own pipes, holding every agent the app opens in that one process; +**`codeaf engine`** is the far half of `chat --host`, started by ssh, and **`codeaf clock`** is the one process that runs your automations while a codeaf window is open — a window starts it, and it leaves by itself half a minute after the last window closes. Neither draws anything or reads a key. **`codeaf tick`** and **`codeaf wake`** are From e5b3267e2a90079a2d3326ffd397e4baa0ca3f53 Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Wed, 14 Oct 2026 23:55:57 -0400 Subject: [PATCH 002/459] app: asks, dead-engine detection, crew.removed, launch folder, and a garbled sidecar asks.list/asks.answer and ask.opened/ask.closed ride the engine's own question lane and ResolveQuestion, so an approval or question raised from any window opens an ask with the engine's choices and an answer from a joined TUI closes it by:"elsewhere"; an app answer closes by:"app". An agent is needs_you while any ask is open. A held conversation whose question lane ends is a dead engine: the agent reads stopped, its asks close by:"stopped", and the next send reopens it. crews gain crew.removed when a team closes or disappears. A new conversation records the requested workspace as its launch folder, not the app's cwd, via a launchDir passed through assembleEngine. interrupted tasks are left out of tasks.list rather than mislabelled failed. make app-sidecar / app-sidecars build the bundled codeaf with garble -literals -tiny (pinned v0.17.0) to dist/app/codeaf-, CGO off. --- .gitignore | 3 + Makefile | 52 +++- cmd/codeaf/app.go | 290 ++++++++++++++++-- cmd/codeaf/app_test.go | 274 +++++++++++++++++ cmd/codeaf/chatv3.go | 9 + cmd/codeaf/chatv3_layout.go | 10 +- cmd/codeaf/engine.go | 14 +- docs/changes/unreleased/0-app-asks-sidecar.md | 19 ++ internal/appwire/server.go | 31 ++ internal/appwire/server_test.go | 10 +- internal/appwire/wire.go | 46 +++ .../manual/chat/running-from-the-terminal.md | 4 +- 12 files changed, 727 insertions(+), 35 deletions(-) create mode 100644 docs/changes/unreleased/0-app-asks-sidecar.md diff --git a/.gitignore b/.gitignore index 120deb43b2..ea589d26fc 100644 --- a/.gitignore +++ b/.gitignore @@ -41,3 +41,6 @@ internal/manual/pages.pack.gz .furrow/ bench/idle-surface/runs/ work/ +# The desktop app's garbled sidecars (`make app-sidecar`), built for a bundle +# and never committed. +/dist/ diff --git a/Makefile b/Makefile index c1a7810fd8..6ddd64bd23 100644 --- a/Makefile +++ b/Makefile @@ -2,7 +2,7 @@ # anywhere else — so a stale copy can't shadow a fresh one. BINARY := bin/codeaf -.PHONY: all build build-check build-cross debug demo-home clean-run embed manual-pack-law furrow test test-focus test-report test-quick test-tooling test-touched test-touched-preflight pr-ready test-laws test-carried-help test-belt-pages fmt-check test-packed-manual manual-gates test-remote test-e2e test-e2e-tui vet check size clean \ +.PHONY: all build build-check build-cross app-sidecar app-sidecars debug demo-home clean-run embed manual-pack-law furrow test test-focus test-report test-quick test-tooling test-touched test-touched-preflight pr-ready test-laws test-carried-help test-belt-pages fmt-check test-packed-manual manual-gates test-remote test-e2e test-e2e-tui vet check size clean \ changelog changelog-new changelog-check changelog-preview # What the shipped binary is allowed to weigh, in bytes, checked in beside the @@ -426,6 +426,56 @@ build-cross: check: vet fmt-check test test-packed-manual size build-cross +# ── the desktop app's sidecar ─────────────────────────────────────────────── +# +# THE DESKTOP APP SHIPS codeaf INSIDE ITSELF, as a Tauri sidecar named for the +# Rust target triple it runs on, and that copy is built with garble so the +# bundle does not carry the program's own symbol and string table in the clear. +# It is the release build otherwise: the packed manual, -trimpath, the same +# stamp, the target's own furrow staged first, and CGO off, which is also how +# the release cross-builds darwin. +# +# GARBLE IS PINNED, and installed into bin/tools under its version, because an +# obfuscator that moves under a build is a binary nobody can reproduce. v0.18 +# requires Go 1.27 and this module is on 1.26, so v0.17 is the newest that +# builds here; move both together when the module's Go line moves. +# +# make app-sidecar APP_GOOS=darwin APP_GOARCH=arm64 # one triple +# make app-sidecars # all four +GARBLE_VERSION := v0.17.0 +GARBLE := bin/tools/garble-$(GARBLE_VERSION) +APP_GOOS ?= $(shell go env GOOS) +APP_GOARCH ?= $(shell go env GOARCH) +APP_DIST := dist/app + +$(GARBLE): + @mkdir -p $(@D)/garble-$(GARBLE_VERSION).d + env -u GOOS -u GOARCH GOBIN=$(CURDIR)/$(@D)/garble-$(GARBLE_VERSION).d go install mvdan.cc/garble@$(GARBLE_VERSION) + mv $(@D)/garble-$(GARBLE_VERSION).d/garble $@ + rmdir $(@D)/garble-$(GARBLE_VERSION).d + +app-sidecar: embed $(GARBLE) + @set -eu; \ + case "$(APP_GOOS)/$(APP_GOARCH)" in \ + darwin/arm64) triple=aarch64-apple-darwin ;; \ + darwin/amd64) triple=x86_64-apple-darwin ;; \ + linux/amd64) triple=x86_64-unknown-linux-gnu ;; \ + linux/arm64) triple=aarch64-unknown-linux-gnu ;; \ + *) printf 'app-sidecar: no desktop target for %s/%s\n' "$(APP_GOOS)" "$(APP_GOARCH)" >&2; exit 1 ;; \ + esac; \ + env -u GOOS -u GOARCH go run ./internal/furrowbin/cmd/fetch -goos=$(APP_GOOS) -goarch=$(APP_GOARCH); \ + mkdir -p $(APP_DIST); \ + CGO_ENABLED=0 GOOS=$(APP_GOOS) GOARCH=$(APP_GOARCH) $(GARBLE) -literals -tiny build \ + -tags=$(MANUAL_TAG) -trimpath -ldflags="-s -w $(BUILD_STAMP)" \ + -o $(APP_DIST)/codeaf-$$triple ./cmd/codeaf; \ + printf 'app-sidecar: %s\n' "$(APP_DIST)/codeaf-$$triple" + +app-sidecars: + @for target in darwin/arm64 darwin/amd64 linux/amd64 linux/arm64; do \ + $(MAKE) --no-print-directory app-sidecar APP_GOOS=$${target%/*} APP_GOARCH=$${target#*/} || exit 1; \ + done + + # ── the changelog ─────────────────────────────────────────────────────────── # # ONE FILE PER PULL REQUEST, ROLLED UP WHEN A VERSION IS CUT. The entries live diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index 1615f68e0a..18e602da96 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -17,8 +17,9 @@ package main // ONE DIFFERENCE FROM A HOST, AND IT IS THE DIRECTORY. A host is one workspace // and moves the process into it; this process holds conversations in several // workspaces at once, so it moves nowhere. Every tool already resolves against -// its conversation's own workspace rather than the process's directory, and the -// launch directory a new conversation records is where the app was started. +// its conversation's own workspace rather than the process's directory, and a +// new conversation records the workspace the app asked for as where the person +// stood, since the process's own directory is wherever the app was started. // // AND IT IS LAZY BY LAW. Starting up reads nothing but the flags, so the hello // line leaves before anything is opened; listing agents, tasks and crews reads @@ -116,6 +117,11 @@ type appConversation interface { Title() string NeedsPerson() bool WatchTaskUpdates() (<-chan session.Event, func()) + // The question lane and its one answer door, the same pair the chat's + // question page uses: every approval and every question the conversation + // raises, answers and withdrawals included, from whichever window. + WatchQuestions() (<-chan session.Event, func()) + ResolveQuestion(session.Answer) error } // appOpenAsk is which conversation to open: a saved one by transcript, or a @@ -165,7 +171,7 @@ func appOpenInProcess(ask appOpenAsk) (*appConn, error) { } loop, err := remote.Loopback(hello, remote.Options{ Boot: func(h remote.Hello) (*remote.Engine, error) { - return assembleEngine(h, workspace, transcript) + return assembleEngine(h, workspace, transcript, workspace) }, }) if err != nil { @@ -216,7 +222,31 @@ type appLive struct { lastUse time.Time pumpDone chan struct{} lanesDone chan struct{} + asksDone chan struct{} + stopAsks func() tasks map[string]appwire.Task + // asks are the open asks in the order they were raised, and questions the + // engine's own question behind each, which is what an answer is built from. + asks []appwire.Ask + questions map[string]session.Question + // answered names the asks this app answered, so their closing says so. + answered map[string]bool +} + +// needsYou is the one reading of "waiting on the person": an open ask, or the +// engine's own word for a wait the question lane does not carry. Callers hold +// b.mu. +func (live *appLive) needsYou() bool { + return len(live.asks) > 0 || live.conn.Conv.NeedsPerson() +} + +// restingState is what an agent with no turn running reads as. Callers hold +// b.mu. +func (live *appLive) restingState() string { + if live.needsYou() { + return appwire.StateNeedsYou + } + return appwire.StateIdle } func newAppBackend(emit appwire.Emitter, open appOpener) *appBackend { @@ -451,7 +481,11 @@ func (b *appBackend) hold(a *appAgent, conn *appConn, workspace string) appwire. if conn.Assembled { b.assembled.Store(true) } - live := &appLive{conn: conn, box: appwire.NewOutbox(b.emit), lastUse: b.now(), tasks: map[string]appwire.Task{}, lanesDone: make(chan struct{})} + live := &appLive{ + conn: conn, box: appwire.NewOutbox(b.emit), lastUse: b.now(), + tasks: map[string]appwire.Task{}, lanesDone: make(chan struct{}), asksDone: make(chan struct{}), + questions: map[string]session.Question{}, answered: map[string]bool{}, + } b.mu.Lock() if conn.Transcript != "" { a.transcript = conn.Transcript @@ -466,10 +500,7 @@ func (b *appBackend) hold(a *appAgent, conn *appConn, workspace string) appwire. if model := conn.Conv.Model(); model != "" { a.view.Model = model } - a.view.State = appwire.StateIdle - if conn.Conv.NeedsPerson() { - a.view.State = appwire.StateNeedsYou - } + a.view.State = live.restingState() if a.view.UpdatedAt.IsZero() { a.view.UpdatedAt = b.now() } @@ -487,9 +518,143 @@ func (b *appBackend) hold(a *appAgent, conn *appConn, workspace string) appwire. } } }() + questions, stopQuestions := conn.Conv.WatchQuestions() + live.stopAsks = stopQuestions + go func() { + defer close(live.asksDone) + for event := range questions { + b.questionEvent(a, id, live, event) + } + // A LANE THAT ENDS WHILE THE CONVERSATION IS STILL HELD IS A DEAD + // CONNECTION: letting go stops the lane only after taking the + // conversation away, so the only other way it ends is the engine + // behind it going (internal/remote's bury finishes every lane). + b.died(a, live) + }() return view } +// appAskID is the stable id of one ask: the engine's own question key. +func appAskID(q session.Question) string { + return string(q.Kind) + ":" + q.Token() +} + +// appAskOf is the engine's question as the app's ask. +func appAskOf(agent string, q session.Question) appwire.Ask { + ask := appwire.Ask{ID: appAskID(q), Agent: agent, Kind: appwire.AskQuestion, Title: strings.TrimSpace(q.Head), Detail: strings.TrimSpace(q.Reason)} + if q.Kind == session.QuestionConsent { + ask.Kind = appwire.AskApproval + } + for _, option := range q.Options { + ask.Options = append(ask.Options, appwire.AskOption{ID: option.Key, Label: option.Label}) + } + return ask +} + +// questionEvent keeps the open asks in step with the question lane and says +// every change on the wire, the agent's state with it. +func (b *appBackend) questionEvent(a *appAgent, id string, live *appLive, event session.Event) { + if event.Question == nil { + return + } + q := *event.Question + askID := appAskID(q) + switch event.Kind { + case session.EventQuestion: + ask := appAskOf(id, q) + b.mu.Lock() + if _, open := live.questions[askID]; open { + // The lane replays what is open when it is subscribed again, and + // a question restated in place is the same ask. + live.questions[askID] = q + for i := range live.asks { + if live.asks[i].ID == askID { + live.asks[i] = ask + } + } + b.mu.Unlock() + return + } + live.questions[askID] = q + live.asks = append(live.asks, ask) + view, moved := b.restateLocked(a, live) + b.mu.Unlock() + live.box.Event(appwire.EventAskOpened, appwire.AskOpened{Ask: ask}) + if moved { + live.box.Event(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) + } + case session.EventQuestionAnswered, session.EventQuestionWithdrawn: + b.mu.Lock() + if _, open := live.questions[askID]; !open { + b.mu.Unlock() + return + } + by := appwire.ClosedByElsewhere + if live.answered[askID] { + by = appwire.ClosedByApp + } + b.dropAskLocked(live, askID) + view, moved := b.restateLocked(a, live) + b.mu.Unlock() + live.box.Event(appwire.EventAskClosed, appwire.AskClosed{Agent: id, ID: askID, By: by}) + if moved { + live.box.Event(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) + } + } +} + +// dropAskLocked forgets one ask. Callers hold b.mu. +func (b *appBackend) dropAskLocked(live *appLive, askID string) { + delete(live.questions, askID) + delete(live.answered, askID) + kept := live.asks[:0] + for _, ask := range live.asks { + if ask.ID != askID { + kept = append(kept, ask) + } + } + live.asks = kept +} + +// restateLocked recomputes a held agent's state after its asks moved, and +// reports whether it changed. Callers hold b.mu. +func (b *appBackend) restateLocked(a *appAgent, live *appLive) (appwire.Agent, bool) { + if a.live != live { + return a.view, false + } + state := live.restingState() + if live.running && state == appwire.StateIdle { + state = appwire.StateWorking + } + if state == a.view.State { + return a.view, false + } + a.view.State = state + return a.view, true +} + +// died is a held conversation whose engine went away without being let go: +// the agent reads stopped, its open asks close as stopped, and the next send +// opens the conversation again. +func (b *appBackend) died(a *appAgent, live *appLive) { + b.mu.Lock() + if a.live != live { + b.mu.Unlock() + return + } + a.live = nil + a.view.State = appwire.StateStopped + view := a.view + asks := live.asks + live.asks, live.questions = nil, map[string]session.Question{} + b.mu.Unlock() + for _, ask := range asks { + live.box.Event(appwire.EventAskClosed, appwire.AskClosed{Agent: view.ID, ID: ask.ID, By: appwire.ClosedByStopped}) + } + live.box.Event(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) + go b.finish(live) +} + // live answers the agent's held conversation, or nil. func (b *appBackend) liveOf(a *appAgent) *appLive { b.mu.Lock() @@ -535,6 +700,9 @@ func (b *appBackend) Send(id, text string) (uint64, error) { } b.mu.Lock() a.view.State = appwire.StateWorking + if live.needsYou() { + a.view.State = appwire.StateNeedsYou + } a.view.UpdatedAt = b.now() view = a.view live.pumpDone = make(chan struct{}) @@ -582,10 +750,6 @@ func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, ev // A turn the person steered ends once per stretch; the last ending // on the stream is the turn's. ok, failure = true, "" - case session.EventConsentRequest, session.EventQuestion: - changed(func(v *appwire.Agent) { v.State = appwire.StateNeedsYou }) - case session.EventQuestionAnswered, session.EventQuestionWithdrawn: - changed(func(v *appwire.Agent) { v.State = appwire.StateWorking }) case session.EventTitleChanged: if title := strings.TrimSpace(event.Text); title != "" { changed(func(v *appwire.Agent) { v.Title = title }) @@ -605,20 +769,18 @@ func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, ev b.mu.Unlock() return } - a.view.State = appwire.StateIdle - if live.conn.Conv.NeedsPerson() { - a.view.State = appwire.StateNeedsYou - } + a.view.State = live.restingState() a.view.UpdatedAt = b.now() view := a.view b.mu.Unlock() live.box.Event(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) } -// appTaskState is a task's state in the protocol's five words. The engine's -// interrupted (nothing is driving it, every step is kept) has no word of its -// own on this wire and reads as failed; a state this build has never heard of -// answers "" and the row is left out rather than given a word it did not say. +// appTaskState is a task's state in the protocol's five words, and "" for a +// state none of them says. That includes the engine's interrupted — nothing is +// driving it and every step is kept — which the chat draws as `interrupted`, +// apart from failed; until the protocol has a word for it such a task is left +// out rather than given one it did not say. func appTaskState(state string) string { switch session.TaskState(strings.TrimSpace(state)) { case session.TaskQueued: @@ -627,7 +789,7 @@ func appTaskState(state string) string { return appwire.TaskRunning case session.TaskDone: return appwire.TaskDone - case session.TaskFailed, session.TaskInterrupted: + case session.TaskFailed: return appwire.TaskFailed case session.TaskUnverified: return appwire.TaskUnverified @@ -697,9 +859,18 @@ func (b *appBackend) release(a *appAgent) { if live == nil { return } + b.finish(live) +} + +// finish gives a conversation that is no longer held back, and shuts its +// outbox once the turn and the lanes have said their last. +func (b *appBackend) finish(live *appLive) { if live.stopTasks != nil { live.stopTasks() } + if live.stopAsks != nil { + live.stopAsks() + } live.conn.Release() wait := time.After(appReleaseWait) if live.pumpDone != nil { @@ -708,13 +879,70 @@ func (b *appBackend) release(a *appAgent) { case <-wait: } } - select { - case <-live.lanesDone: - case <-wait: + for _, done := range []chan struct{}{live.lanesDone, live.asksDone} { + select { + case <-done: + case <-wait: + } } live.box.Close() } +// ListAsks answers the agent's open asks. An agent with no conversation held +// has none this process can answer. +func (b *appBackend) ListAsks(id string) ([]appwire.Ask, error) { + a, err := b.find(id) + if err != nil { + return nil, err + } + b.mu.Lock() + defer b.mu.Unlock() + if a.live == nil { + return nil, nil + } + return append([]appwire.Ask(nil), a.live.asks...), nil +} + +// AnswerAsk answers one open ask through the engine's own answer door, the one +// the chat's question page uses. Its closing arrives on the question lane. +func (b *appBackend) AnswerAsk(id, askID, option, text string) error { + a, err := b.find(id) + if err != nil { + return err + } + b.mu.Lock() + live := a.live + var q session.Question + open := false + if live != nil { + q, open = live.questions[askID] + } + if !open { + b.mu.Unlock() + return appwire.Errorf(appwire.CodeNotFound, "the ask %s is not open; it may have been answered elsewhere", askID) + } + if option != "" { + if _, offered := q.Option(option); !offered { + b.mu.Unlock() + return appwire.Errorf(appwire.CodeBadParams, "the ask %s offers no option %q", askID, option) + } + } + live.answered[askID] = true + live.lastUse = b.now() + b.mu.Unlock() + answer := session.Answer{Kind: q.Kind, ID: q.ID, Ref: q.Ref, Ask: q.Ask, Key: option, Change: strings.TrimSpace(text)} + if option != "" { + answer.Picked = []string{option} + } + if err := live.conn.Conv.ResolveQuestion(answer); err != nil { + b.mu.Lock() + delete(live.answered, askID) + b.mu.Unlock() + return appEngineError(err) + } + return nil +} + // ListTasks reads the agent's tasks off its project's task index, and lays // over them what the held conversation has said since. func (b *appBackend) ListTasks(id string) ([]appwire.Task, error) { @@ -862,6 +1090,12 @@ func (b *appBackend) Close() error { return nil } +func (b *appBackend) liveNeedsYou(live *appLive) bool { + b.mu.Lock() + defer b.mu.Unlock() + return live.needsYou() +} + func (b *appBackend) isClosing() bool { b.mu.Lock() defer b.mu.Unlock() @@ -903,7 +1137,7 @@ func (b *appBackend) sweepOnce() { live := a.live keep := live == nil || live.running || now.Sub(live.lastUse) < appAgentIdle b.mu.Unlock() - if !keep && !live.conn.Conv.NeedsPerson() { + if !keep && !b.liveNeedsYou(live) { // The state the app reads does not move: the conversation is as // idle on disk as it was in memory. b.release(a) @@ -978,6 +1212,12 @@ func (w *appCrewWatch) run() { w.emit.Emit(appwire.EventCrewChanged, appwire.CrewChanged{Crew: crew}) } } + // A team deleted or closed is no longer a crew. + for id := range w.known { + if _, still := prints[id]; !still { + w.emit.Emit(appwire.EventCrewRemoved, appwire.CrewRemoved{ID: id}) + } + } w.known = prints } } diff --git a/cmd/codeaf/app_test.go b/cmd/codeaf/app_test.go index 7cc7e6ae2a..3ee38db48d 100644 --- a/cmd/codeaf/app_test.go +++ b/cmd/codeaf/app_test.go @@ -133,6 +133,40 @@ type fakeConversation struct { interrupted int needs bool tasks chan session.Event + questions chan session.Event + endLane func() + answers []session.Answer + // onAnswer, when set, is what the engine does with an answer. + onAnswer func(session.Answer) +} + +func (f *fakeConversation) WatchQuestions() (<-chan session.Event, func()) { + f.mu.Lock() + defer f.mu.Unlock() + lane := make(chan session.Event, 8) + var once sync.Once + end := func() { once.Do(func() { close(lane) }) } + f.questions, f.endLane = lane, end + return lane, end +} + +func (f *fakeConversation) ResolveQuestion(answer session.Answer) error { + f.mu.Lock() + f.answers = append(f.answers, answer) + on := f.onAnswer + f.mu.Unlock() + if on != nil { + on(answer) + } + return nil +} + +// raise puts one event on the conversation's question lane. +func (f *fakeConversation) raise(event session.Event) { + f.mu.Lock() + lane := f.questions + f.mu.Unlock() + lane <- event } func (f *fakeConversation) Submit(ctx context.Context, text string) (<-chan session.Event, error) { @@ -496,3 +530,243 @@ func TestAppHoldsARealConversationInProcess(t *testing.T) { t.Fatal("the backend did not record that it built a conversation") } } + +func appAskEvent(kind string) func(string, any) bool { + return func(event string, _ any) bool { return event == kind } +} + +// ASKS ARE THE ENGINE'S QUESTIONS, OPENED AND CLOSED ON ITS QUESTION LANE. An +// approval arrives with the engine's own choices and puts the agent at +// needs_you; an option it does not offer is refused; answering one goes +// through the engine's answer door and closes it by the app; one answered in +// another window closes by elsewhere; and an ask no longer open is not found. +func TestAppAsksOpenAnswerAndCloseThroughTheQuestionLane(t *testing.T) { + conv := &fakeConversation{} + conv.onAnswer = func(answer session.Answer) { + q := session.Question{Kind: answer.Kind, ID: answer.ID} + conv.raise(session.Event{Kind: session.EventQuestionAnswered, Question: &q, Answer: &answer}) + } + var mu sync.Mutex + var opens, releases int + rec := newAppRecorder() + backend := newAppBackend(rec, fakeOpener(conv, &opens, &releases, &mu)) + defer backend.Close() + agent, err := backend.CreateAgent(t.TempDir(), "") + if err != nil { + t.Fatal(err) + } + + approval := session.Question{ID: 4, Kind: session.QuestionConsent, Head: "bash rm -rf build", Reason: "bash asks first", Options: session.AnswerOptions(session.QuestionConsent)} + conv.raise(session.Event{Kind: session.EventQuestion, Question: &approval}) + i := rec.waitFor(t, 5*time.Second, appAskEvent(appwire.EventAskOpened)) + rec.waitFor(t, 5*time.Second, func(event string, data any) bool { + c, ok := data.(appwire.AgentChanged) + return ok && c.Agent.State == appwire.StateNeedsYou + }) + rec.mu.Lock() + opened := rec.data[i].(appwire.AskOpened).Ask + rec.mu.Unlock() + if opened.ID != "consent:4" || opened.Kind != appwire.AskApproval || opened.Title != "bash rm -rf build" || opened.Detail != "bash asks first" || len(opened.Options) != 3 || opened.Options[2].Label != "deny" { + t.Fatalf("ask = %+v", opened) + } + asks, err := backend.ListAsks(agent.ID) + if err != nil || len(asks) != 1 || asks[0].ID != "consent:4" { + t.Fatalf("asks.list = %+v, %v", asks, err) + } + var wire *appwire.Error + if err := backend.AnswerAsk(agent.ID, "consent:4", "9", ""); !errors.As(err, &wire) || wire.Code != appwire.CodeBadParams { + t.Fatalf("answer with an option not offered = %v", err) + } + if err := backend.AnswerAsk(agent.ID, "consent:4", "1", ""); err != nil { + t.Fatal(err) + } + closed := rec.waitFor(t, 5*time.Second, appAskEvent(appwire.EventAskClosed)) + rec.waitFor(t, 5*time.Second, func(event string, data any) bool { + c, ok := data.(appwire.AgentChanged) + return ok && c.Agent.State == appwire.StateIdle + }) + rec.mu.Lock() + if c := rec.data[closed].(appwire.AskClosed); c.ID != "consent:4" || c.By != appwire.ClosedByApp { + rec.mu.Unlock() + t.Fatalf("ask.closed = %+v", c) + } + rec.mu.Unlock() + conv.mu.Lock() + got := conv.answers[0] + conv.mu.Unlock() + if got.Kind != session.QuestionConsent || got.ID != 4 || got.Key != "1" || len(got.Picked) != 1 { + t.Fatalf("the engine was handed %+v", got) + } + + question := session.Question{ID: 7, Kind: session.QuestionAsk, Head: "Which database?", Options: []session.AnswerOption{{Key: "1", Label: "sqlite"}}} + conv.raise(session.Event{Kind: session.EventQuestion, Question: &question}) + rec.waitFor(t, 5*time.Second, func(event string, data any) bool { + o, ok := data.(appwire.AskOpened) + return ok && o.Ask.ID == "ask:7" && o.Ask.Kind == appwire.AskQuestion + }) + conv.raise(session.Event{Kind: session.EventQuestionAnswered, Question: &question}) + rec.waitFor(t, 5*time.Second, func(event string, data any) bool { + c, ok := data.(appwire.AskClosed) + return ok && c.ID == "ask:7" && c.By == appwire.ClosedByElsewhere + }) + if err := backend.AnswerAsk(agent.ID, "ask:7", "1", ""); !errors.As(err, &wire) || wire.Code != appwire.CodeNotFound { + t.Fatalf("answering a closed ask = %v", err) + } +} + +// A CONVERSATION WHOSE ENGINE DIES READS STOPPED. Its open asks close as +// stopped, and the next send opens it again and works. +func TestAppADeadConversationReadsStoppedAndTheNextSendReopensIt(t *testing.T) { + conv := &fakeConversation{script: []session.Event{{Kind: session.EventTurnDone}}} + var mu sync.Mutex + var opens, releases int + rec := newAppRecorder() + backend := newAppBackend(rec, fakeOpener(conv, &opens, &releases, &mu)) + defer backend.Close() + agent, err := backend.CreateAgent(t.TempDir(), "") + if err != nil { + t.Fatal(err) + } + q := session.Question{ID: 2, Kind: session.QuestionConsent, Head: "bash make", Options: session.AnswerOptions(session.QuestionConsent)} + conv.raise(session.Event{Kind: session.EventQuestion, Question: &q}) + rec.waitFor(t, 5*time.Second, appAskEvent(appwire.EventAskOpened)) + + conv.mu.Lock() + end := conv.endLane + conv.mu.Unlock() + end() + rec.waitFor(t, 5*time.Second, func(event string, data any) bool { + c, ok := data.(appwire.AskClosed) + return ok && c.ID == "consent:2" && c.By == appwire.ClosedByStopped + }) + rec.waitFor(t, 5*time.Second, func(event string, data any) bool { + c, ok := data.(appwire.AgentChanged) + return ok && c.Agent.State == appwire.StateStopped + }) + if asks, _ := backend.ListAsks(agent.ID); len(asks) != 0 { + t.Fatalf("a stopped agent still lists %d asks", len(asks)) + } + agents, _ := backend.ListAgents("") + for _, listed := range agents { + if listed.ID == agent.ID && listed.State != appwire.StateStopped { + t.Fatalf("listed as %s", listed.State) + } + } + if _, err := backend.Send(agent.ID, "again"); err != nil { + t.Fatalf("send after the engine died = %v", err) + } + rec.waitFor(t, 5*time.Second, appTurnDone) + mu.Lock() + defer mu.Unlock() + if opens != 2 { + t.Fatalf("opened %d times, want the create and the reopen", opens) + } +} + +// A REAL APPROVAL, ANSWERED FROM THE APP. The engine is built in this process +// with every bash call asking first; the scripted model asks for one; the ask +// opens with the engine's own choices, `allow once` is sent through the +// engine's answer door, the ask closes by the app, the command runs and the +// turn ends well. The conversation records the workspace the app asked for as +// where the person stood, not the directory the app was started in. +func TestAppAnswersARealApprovalAndRecordsTheWorkspaceAsTheLaunchFolder(t *testing.T) { + root := resolvedTempDir(t) + _, started := wakeEnvironment(t, root) + model := consentSource(t, "printf app-ask-probe") + t.Cleanup(model.Close) + t.Setenv("CODEAF_BASE_URL", model.URL) + state := filepath.Join(root, "state") + if err := os.MkdirAll(state, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(state, "config.json"), []byte(`{"tools.approvalMode":"prompt"}`), 0o600); err != nil { + t.Fatal(err) + } + workspace := filepath.Join(root, "elsewhere") + if err := os.MkdirAll(workspace, 0o700); err != nil { + t.Fatal(err) + } + rec := newAppRecorder() + backend := newAppBackend(rec, appOpenInProcess) + defer backend.Close() + agent, err := backend.CreateAgent(workspace, "") + if err != nil { + t.Fatalf("create = %v", err) + } + backend.mu.Lock() + transcript := backend.agents[agent.ID].transcript + backend.mu.Unlock() + meta, err := session.LoadMeta(filepath.Dir(transcript)) + if err != nil || meta.LaunchDir != workspace { + t.Fatalf("launch folder = %q (app started in %q), %v", meta.LaunchDir, started, err) + } + + if _, err := backend.Send(agent.ID, "run the probe"); err != nil { + t.Fatal(err) + } + i := rec.waitFor(t, 30*time.Second, appAskEvent(appwire.EventAskOpened)) + rec.mu.Lock() + ask := rec.data[i].(appwire.AskOpened).Ask + rec.mu.Unlock() + if ask.Kind != appwire.AskApproval || len(ask.Options) == 0 || ask.Options[0].Label != "allow once" { + t.Fatalf("ask = %+v", ask) + } + if err := backend.AnswerAsk(agent.ID, ask.ID, ask.Options[0].ID, ""); err != nil { + t.Fatalf("answer = %v", err) + } + rec.waitFor(t, 30*time.Second, func(event string, data any) bool { + c, ok := data.(appwire.AskClosed) + return ok && c.ID == ask.ID && c.By == appwire.ClosedByApp + }) + d := rec.waitFor(t, 30*time.Second, appTurnDone) + rec.mu.Lock() + done := rec.data[d].(appwire.TurnDone) + var ran bool + for j, event := range rec.events { + if tool, ok := rec.data[j].(appwire.TurnTool); ok && event == appwire.EventTurnTool && tool.Phase == appwire.PhaseEnd && tool.Name == "bash" && tool.OK != nil && *tool.OK { + ran = true + } + } + rec.mu.Unlock() + if !done.OK || !ran { + t.Fatalf("turn.done = %+v, bash ran = %v", done, ran) + } +} + +// A CREW THAT CLOSES OR DISAPPEARS IS REMOVED. Once the app has listed the +// crews, a team closed in the teams file is announced as crew.removed with its +// id, and a renamed one as crew.changed. +func TestAppAnnouncesAClosedCrewAsRemoved(t *testing.T) { + profile := t.TempDir() + t.Setenv(config.ProfileDirEnv, profile) + member := filepath.Join(profile, "places", "p", "0123abcd", "transcript.jsonl") + teamsNow := []teamstore.Team{ + {ID: "0a0a0a0a0a0a", Name: "harbor", Members: []teamstore.Member{{Key: member, File: member}}}, + {ID: "0b0b0b0b0b0b", Name: "dock", Members: []teamstore.Member{{Key: member, File: member}}}, + } + if err := teamstore.Save(profile, teamsNow); err != nil { + t.Fatal(err) + } + rec := newAppRecorder() + backend := newAppBackend(rec, nil) + defer backend.Close() + crews, err := backend.ListCrews() + if err != nil || len(crews) != 2 { + t.Fatalf("crews = %+v, %v", crews, err) + } + // The poll compares the file's stamp, so the change must land on a later one. + time.Sleep(20 * time.Millisecond) + teamsNow[0].State = teamstore.TeamClosed + teamsNow[1].Name = "pier" + if err := teamstore.Save(profile, teamsNow); err != nil { + t.Fatal(err) + } + rec.waitFor(t, 4*appCrewPoll, func(event string, data any) bool { + removed, ok := data.(appwire.CrewRemoved) + return ok && removed.ID == "0a0a0a0a0a0a" + }) + rec.waitFor(t, 4*appCrewPoll, func(event string, data any) bool { + changed, ok := data.(appwire.CrewChanged) + return ok && changed.Crew.Name == "pier" + }) +} diff --git a/cmd/codeaf/chatv3.go b/cmd/codeaf/chatv3.go index 529b2334eb..d6f00a8702 100644 --- a/cmd/codeaf/chatv3.go +++ b/cmd/codeaf/chatv3.go @@ -778,6 +778,12 @@ type v3Options struct { // posture every session has always had, where the model stopping is the // session stopping. Budget session.Budget + // LaunchDir is where the person stood, when the door knows it better than + // the process does. Empty is the process's own launch directory, which is + // every terminal door; `codeaf app` holds conversations in many folders + // from one process started wherever the desktop app was, so it names the + // workspace the person asked for instead. + LaunchDir string } // joinV3Notices puts the launch's dim lines on one row, in the order they were @@ -881,6 +887,9 @@ func openV3Launch(proc *v3Process, opts v3Options) (*v3Launch, error) { // (Decision 26). A door that named a workspace has already answered the // project question and its answer is taken as given. launchDir := proc.LaunchDir + if named := strings.TrimSpace(opts.LaunchDir); named != "" { + launchDir = named + } project, owned := v3Workspace(launchDir, opts.Workspace) found, err := v3ResolveSession(strings.TrimSpace(opts.Session), project, diff --git a/cmd/codeaf/chatv3_layout.go b/cmd/codeaf/chatv3_layout.go index 11beaea45f..d3649501fe 100644 --- a/cmd/codeaf/chatv3_layout.go +++ b/cmd/codeaf/chatv3_layout.go @@ -481,6 +481,12 @@ func v3MintSession(bucket, workspace, launchDir string, owned bool) (session.Pla // and it falls back to the workspace's own bucket for a launch that has no // folder at all (an explicit flat --session). func v3NextSession(current session.Place, workspace string) (session.Place, error) { + return v3NextSessionFrom(current, workspace, v3LaunchDir()) +} + +// v3NextSessionFrom is [v3NextSession] with the launch directory named by the +// door rather than read off the process. +func v3NextSessionFrom(current session.Place, workspace, launchDir string) (session.Place, error) { if dir := strings.TrimSpace(current.Dir); dir != "" { root := strings.TrimSpace(current.Workspace) if current.Owned { @@ -489,14 +495,14 @@ func v3NextSession(current session.Place, workspace string) (session.Place, erro root = workspace } return v3MintSession(filepath.Dir(dir), root, - v3StampLaunchDir(v3LaunchDir(), root), current.Owned) + v3StampLaunchDir(launchDir, root), current.Owned) } bucket, err := v3ProjectDir(workspace) if err != nil { return session.Place{}, err } return v3MintSession(bucket, workspace, - v3StampLaunchDir(v3LaunchDir(), workspace), false) + v3StampLaunchDir(launchDir, workspace), false) } // v3LaunchDir is where the person is standing, and "" when even that cannot be diff --git a/cmd/codeaf/engine.go b/cmd/codeaf/engine.go index 82478f37e4..789a71a644 100644 --- a/cmd/codeaf/engine.go +++ b/cmd/codeaf/engine.go @@ -933,14 +933,15 @@ func bootEngine(hello remote.Hello, workspaceFlag, sessionFlag string) (*remote. if err := os.Chdir(workspace); err != nil { return nil, fmt.Errorf("open %s: %w", workspace, err) } - return assembleEngine(hello, workspace, sessionFlag) + return assembleEngine(hello, workspace, sessionFlag, "") } // assembleEngine is [bootEngine] after the chdir: the conversation built for a // hello in a workspace that is already resolved. `codeaf app` calls it directly // because it holds conversations in several workspaces in one process and so -// cannot move the process into any one of them (app.go says what that costs). -func assembleEngine(hello remote.Hello, workspace, sessionFlag string) (*remote.Engine, error) { +// cannot move the process into any one of them; it names the launch directory a +// new conversation records, which is otherwise the process's own. +func assembleEngine(hello remote.Hello, workspace, sessionFlag, launchDir string) (*remote.Engine, error) { // ONE PROCESS, ONE WORKSPACE — and, when this process is a host, SEVERAL // CONVERSATIONS IN IT. The process half of a launch is opened once and // shared ([openEngineProcess]): the profile, the model catalog, the harness @@ -969,6 +970,7 @@ func assembleEngine(hello remote.Hello, workspace, sessionFlag string) (*remote. // built INTO the session rather than switched on after it has opened // ([engineLaunchOptions] holds the mapping). launchOptions := engineLaunchOptions(hello, workspace, sessionFlag) + launchOptions.LaunchDir = launchDir launch, err := openV3Launch(proc, launchOptions) if err != nil { return nil, err @@ -988,7 +990,11 @@ func assembleEngine(hello remote.Hello, workspace, sessionFlag string) (*remote. // ([remote.Hello.New] holds the whole of why the two intentions are two // flags). if hello.New { - place, err := v3NextSession(cfg.Place, workspace) + from := launchDir + if strings.TrimSpace(from) == "" { + from = v3LaunchDir() + } + place, err := v3NextSessionFrom(cfg.Place, workspace, from) if err != nil { return nil, err } diff --git a/docs/changes/unreleased/0-app-asks-sidecar.md b/docs/changes/unreleased/0-app-asks-sidecar.md new file mode 100644 index 0000000000..b4f0a7d08f --- /dev/null +++ b/docs/changes/unreleased/0-app-asks-sidecar.md @@ -0,0 +1,19 @@ +--- +kind: added +title: codeaf app answers asks, detects a dead conversation, and builds a garbled sidecar +pr: 0 +surface: [engine, build] +invalidates: + - "codeaf app had no approval or question door, so a needs_you agent could only be stopped. It now serves asks.list and asks.answer and the ask.opened/ask.closed events, over the engine's own question lane and answer door — an answer from a joined TUI window closes the ask with by:\"elsewhere\"." + - "A codeaf app conversation whose engine died stayed drawn as working. It now reads agent state \"stopped\", closes its open asks with by:\"stopped\", and reopens on the next send." + - "A team that closes or disappears left its crew on the app's list. It now sends crew.removed {id}." + - "assembleEngine recorded the app's own working directory as a new conversation's launch folder. It now records the workspace the app asked for." +--- + +The engine's `interrupted` task state has no PROTOCOL word, so an interrupted +task is left out of `tasks.list` rather than reported as `failed` — the chat +draws it as `interrupted`, apart from `failed`, and guessing would mislabel it. + +`make app-sidecar APP_GOOS=… APP_GOARCH=…` (and `app-sidecars` for all four) +builds the desktop app's bundled codeaf with garble `-literals -tiny`, pinned +at v0.17.0, output `dist/app/codeaf-`. diff --git a/internal/appwire/server.go b/internal/appwire/server.go index 29c6ca34c0..518a92b758 100644 --- a/internal/appwire/server.go +++ b/internal/appwire/server.go @@ -337,6 +337,37 @@ var methods = map[string]method{ } return struct{}{}, e.CloseAgent(id) }, + "asks.list": func(e Engine, raw json.RawMessage) (any, error) { + id, err := decodeID(raw, "agent") + if err != nil { + return nil, err + } + asks, err := e.ListAsks(id) + if err != nil { + return nil, err + } + return struct { + Asks []Ask `json:"asks"` + }{nonNil(asks)}, nil + }, + "asks.answer": func(e Engine, raw json.RawMessage) (any, error) { + var p struct { + Agent string `json:"agent"` + ID string `json:"id"` + Option string `json:"option"` + Text string `json:"text"` + } + if err := decode(raw, &p); err != nil { + return nil, err + } + if strings.TrimSpace(p.Agent) == "" || strings.TrimSpace(p.ID) == "" { + return nil, Errorf(CodeBadParams, "asks.answer needs an agent and an id") + } + if strings.TrimSpace(p.Option) == "" && strings.TrimSpace(p.Text) == "" { + return nil, Errorf(CodeBadParams, "asks.answer needs an option, some text, or both") + } + return struct{}{}, e.AnswerAsk(p.Agent, p.ID, strings.TrimSpace(p.Option), p.Text) + }, "tasks.list": func(e Engine, raw json.RawMessage) (any, error) { id, err := decodeID(raw, "agent") if err != nil { diff --git a/internal/appwire/server_test.go b/internal/appwire/server_test.go index 1d6cf0c0e4..47ac74ee3e 100644 --- a/internal/appwire/server_test.go +++ b/internal/appwire/server_test.go @@ -40,8 +40,12 @@ func (f *fakeEngine) Send(id, text string) (uint64, error) { } return 7, nil } -func (f *fakeEngine) Stop(id string) error { return errors.New("the engine went away") } -func (f *fakeEngine) CloseAgent(id string) error { return nil } +func (f *fakeEngine) Stop(id string) error { return errors.New("the engine went away") } +func (f *fakeEngine) CloseAgent(id string) error { return nil } +func (f *fakeEngine) ListAsks(agent string) ([]Ask, error) { return nil, nil } +func (f *fakeEngine) AnswerAsk(agent, id, option, text string) error { + return Errorf(CodeNotFound, "no open ask %s", id) +} func (f *fakeEngine) ListTasks(agent string) ([]Task, error) { return nil, nil } @@ -156,6 +160,8 @@ func TestBadParamsAreAnsweredWithTheirCode(t *testing.T) { `{"id":3,"method":"agents.create","params":[]}`, `{"id":4,"method":"agents.send","params":{"id":"a1"}}`, `{"id":5,"method":"tasks.list"}`, + `{"id":6,"method":"asks.list","params":{}}`, + `{"id":7,"method":"asks.answer","params":{"agent":"a1","id":"consent:1"}}`, } { w.send(line) got := w.next() diff --git a/internal/appwire/wire.go b/internal/appwire/wire.go index fa0c6012ac..a01fee7c19 100644 --- a/internal/appwire/wire.go +++ b/internal/appwire/wire.go @@ -61,8 +61,11 @@ const ( EventTurnDelta = "turn.delta" EventTurnTool = "turn.tool" EventTurnDone = "turn.done" + EventAskOpened = "ask.opened" + EventAskClosed = "ask.closed" EventTaskChanged = "task.changed" EventCrewChanged = "crew.changed" + EventCrewRemoved = "crew.removed" EventLog = "log" ) @@ -117,6 +120,34 @@ type Crew struct { Members []CrewMember `json:"members"` } +// The two kinds of ask, and who closed one. +const ( + AskApproval = "approval" + AskQuestion = "question" + + ClosedByApp = "app" + ClosedByElsewhere = "elsewhere" + ClosedByStopped = "stopped" +) + +// Ask is one thing an agent is waiting on the person for: a tool call waiting +// to be allowed, or a question the agent put. Options are the engine's own +// choices, keyed by the id an answer names. +type Ask struct { + ID string `json:"id"` + Agent string `json:"agent"` + Kind string `json:"kind"` + Title string `json:"title"` + Detail string `json:"detail,omitempty"` + Options []AskOption `json:"options,omitempty"` +} + +// AskOption is one answer an ask offers. +type AskOption struct { + ID string `json:"id"` + Label string `json:"label"` +} + // CrewMember is one conversation in a crew. type CrewMember struct { Agent string `json:"agent"` @@ -154,12 +185,23 @@ type ( OK bool `json:"ok"` Error string `json:"error,omitempty"` } + AskOpened struct { + Ask Ask `json:"ask"` + } + AskClosed struct { + Agent string `json:"agent"` + ID string `json:"id"` + By string `json:"by"` + } TaskChanged struct { Task Task `json:"task"` } CrewChanged struct { Crew Crew `json:"crew"` } + CrewRemoved struct { + ID string `json:"id"` + } Log struct { Level string `json:"level"` Message string `json:"message"` @@ -195,6 +237,10 @@ type Engine interface { Send(id, text string) (uint64, error) Stop(id string) error CloseAgent(id string) error + ListAsks(agent string) ([]Ask, error) + // AnswerAsk answers one open ask with an option, words, or both. An ask + // that is no longer open answers not_found. + AnswerAsk(agent, id, option, text string) error ListTasks(agent string) ([]Task, error) ListCrews() ([]Crew, error) // Close releases every conversation the engine holds. It is called once, diff --git a/internal/manual/chat/running-from-the-terminal.md b/internal/manual/chat/running-from-the-terminal.md index 24aa780079..b4ff51ae6a 100644 --- a/internal/manual/chat/running-from-the-terminal.md +++ b/internal/manual/chat/running-from-the-terminal.md @@ -297,7 +297,9 @@ read the way the worker reads it. Five more exist and are deliberately kept out of the help text, because nothing types them by hand: **`codeaf app`** is the backend the codeaf desktop app starts and speaks to -over its own pipes, holding every agent the app opens in that one process; +over its own pipes, holding every agent the app opens in that one process — the app +answers approvals and questions through it, and a dead conversation shows there as stopped +until the next message reopens it; **`codeaf engine`** is the far half of `chat --host`, started by ssh, and **`codeaf clock`** is the one process that runs your automations while a codeaf window is open — a window starts it, and it leaves by itself half a minute after the last window From 0a1329453c476770a27cc46c4c989d90c3591855 Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Thu, 15 Oct 2026 00:55:10 -0400 Subject: [PATCH 003/459] codeaf app wave 1: create with text, entries, set_model, retry, models, settings, spend, permission options and always rules agents.create takes workspace, model, budget and first words; app agents keep their first-message name. agents.entries reads the transcript off disk for any agent. turn.done sorts failures as provider, budget, stopped or other, and agents.retry resends the failed words. Permission asks offer allow_once, deny and always; always on a shell call writes that command as an allow rule the engine reads before the call goes on. Receipts follow every closed ask as entry.added. Critical sections in app.go now unlock from defers and every goroutine runs under guard, per internal/guard's laws. --- cmd/codeaf/app.go | 1069 +++++++++++++---- cmd/codeaf/app_test.go | 501 +++++++- cmd/codeaf/engine.go | 4 + docs/changes/unreleased/0-app-wave1.md | 25 + internal/appwire/server.go | 95 +- internal/appwire/server_test.go | 107 +- internal/appwire/wire.go | 134 ++- .../manual/chat/running-from-the-terminal.md | 6 +- internal/remote/wire.go | 9 + internal/session/appnames.go | 44 + internal/session/appnames_test.go | 20 + internal/session/peek.go | 2 +- internal/session/place.go | 13 +- internal/session/session.go | 11 + internal/session/sessionfile.go | 17 +- internal/session/title.go | 24 +- internal/session/turnerror.go | 47 + 17 files changed, 1831 insertions(+), 297 deletions(-) create mode 100644 docs/changes/unreleased/0-app-wave1.md create mode 100644 internal/session/appnames.go create mode 100644 internal/session/appnames_test.go create mode 100644 internal/session/turnerror.go diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index 18e602da96..3cf5bfcbee 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -29,6 +29,7 @@ package main import ( "context" + "encoding/json" "errors" "fmt" "os" @@ -43,8 +44,10 @@ import ( "time" "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/catalog" "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/enginehost" + "github.com/Agent-Field/codeaf/internal/guard" "github.com/Agent-Field/codeaf/internal/home" "github.com/Agent-Field/codeaf/internal/redact" "github.com/Agent-Field/codeaf/internal/remote" @@ -122,6 +125,11 @@ type appConversation interface { // raises, answers and withdrawals included, from whichever window. WatchQuestions() (<-chan session.Event, func()) ResolveQuestion(session.Answer) error + SetModel(model string) + SetSpendRail(usd float64) error + // ResolveConsentRemember answers one approval and says how long the answer + // lasts; the app uses it for an always that wrote a rule first. + ResolveConsentRemember(id uint64, allow bool, scope session.ConsentScope) } // appOpenAsk is which conversation to open: a saved one by transcript, or a @@ -167,7 +175,9 @@ func appOpenInProcess(ask appOpenAsk) (*appConn, error) { Session: transcript, New: ask.New, Model: ask.Model, - Launch: &remote.LaunchShape{Interactive: true}, + // An app agent keeps the name its first message gave it: the engine's + // own naming does not rename it afterwards. + Launch: &remote.LaunchShape{Interactive: true, DeterministicNames: true}, } loop, err := remote.Loopback(hello, remote.Options{ Boot: func(h remote.Hello) (*remote.Engine, error) { @@ -200,6 +210,11 @@ type appBackend struct { sweepStop chan struct{} sweepDone chan struct{} crews *appCrewWatch + // models is the lazily warmed model list, made by the first models.list + // that found nothing remembered on disk and never before. + models *catalog.Catalog + // spent is the last figure spend.changed said, so it is said on a change. + spent float64 } // appAgent is every agent this process has heard of: listed off the disk, @@ -211,6 +226,9 @@ type appAgent struct { view appwire.Agent transcript string live *appLive + // failed is the words of the last turn that ended in failure, which is + // what agents.retry sends again; a turn that ends well clears it. + failed string } // appLive is one conversation the process holds. @@ -229,8 +247,20 @@ type appLive struct { // engine's own question behind each, which is what an answer is built from. asks []appwire.Ask questions map[string]session.Question - // answered names the asks this app answered, so their closing says so. - answered map[string]bool + // answered names the asks this app answered and the option it gave, so + // their closing says so. + answered map[string]string + // consents are the approvals' calls as the turn announced them, by the + // engine's question id: what an always is written down from. + consents map[uint64]appConsentCall + // stopping says the person stopped the running turn. + stopping bool +} + +// appConsentCall is the call one approval is about. +type appConsentCall struct { + Tool string + Args string } // needsYou is the one reading of "waiting on the person": an open ask, or the @@ -258,7 +288,7 @@ func newAppBackend(emit appwire.Emitter, open appOpener) *appBackend { sweepStop: make(chan struct{}), sweepDone: make(chan struct{}), } - go b.sweep() + guard.Go("app/sweep", b.sweep) return b } @@ -267,6 +297,21 @@ func appAgentID(transcript string) string { return filepath.Base(filepath.Dir(filepath.Clean(transcript))) } +// locked runs fn holding b.mu. Every critical section in this file goes +// through it or opens with a deferred unlock, so a panic a guard absorbs never +// leaves the lock held (internal/guard's lock law). +func (b *appBackend) locked(fn func()) { + b.mu.Lock() + defer b.mu.Unlock() + fn() +} + +// agent answers the record for id, making one if this is the first time. +func (b *appBackend) agent(id string) (a *appAgent) { + b.locked(func() { a = b.agentLocked(id) }) + return a +} + // agentLocked answers the record for id, making one if this is the first time. func (b *appBackend) agentLocked(id string) *appAgent { a := b.agents[id] @@ -318,10 +363,13 @@ func (b *appBackend) remember(row session.SessionRow) appwire.Agent { // find answers the agent for id, reading the disk for one this process has not // heard of yet. func (b *appBackend) find(id string) (*appAgent, error) { - b.mu.Lock() - a := b.agents[id] - b.mu.Unlock() - if a != nil && a.transcript != "" { + var a *appAgent + held := false + b.locked(func() { + a = b.agents[id] + held = a != nil && a.transcript != "" + }) + if held { return a, nil } row, ok := appRowOnDisk(id) @@ -397,38 +445,59 @@ func (b *appBackend) ListAgents(workspace string) ([]appwire.Agent, error) { } // A conversation this process created and nobody has spoken in yet is not // on the walk, which skips empty shells; it is still the app's agent. - b.mu.Lock() - for id, a := range b.agents { - if !seen[id] && a.live != nil && matches(a.view.Workspace) { - agents = append(agents, a.view) + b.locked(func() { + for id, a := range b.agents { + if !seen[id] && a.live != nil && matches(a.view.Workspace) { + agents = append(agents, a.view) + } } - } - b.mu.Unlock() + }) sort.SliceStable(agents, func(i, j int) bool { return agents[i].UpdatedAt.After(agents[j].UpdatedAt) }) return agents, nil } -// CreateAgent opens a new conversation in workspace. -func (b *appBackend) CreateAgent(workspace, model string) (appwire.Agent, error) { - resolved, err := engineWorkspace(workspace) +// CreateAgent opens a new conversation. With no workspace it is the person's +// home folder; with a budget the conversation's own spend limit is set before +// anything is spent; with words the first turn starts at once and the agent is +// named by them. +func (b *appBackend) CreateAgent(ask appwire.CreateAsk) (appwire.Agent, error) { + // engineWorkspace answers the home folder for no workspace at all. + resolved, err := engineWorkspace(ask.Workspace) if err != nil { return appwire.Agent{}, appwire.Errorf(appwire.CodeBadParams, "%s", err.Error()) } if b.isClosing() { return appwire.Agent{}, appwire.Errorf(appwire.CodeClosed, "codeaf is shutting down") } - conn, err := b.open(appOpenAsk{Workspace: resolved, Model: strings.TrimSpace(model), New: true}) + conn, err := b.open(appOpenAsk{Workspace: resolved, Model: strings.TrimSpace(ask.Model), New: true}) if err != nil { return appwire.Agent{}, appEngineError(err) } - id := appAgentID(conn.Transcript) - b.mu.Lock() - a := b.agentLocked(id) - b.mu.Unlock() + if ask.BudgetUSD != nil && *ask.BudgetUSD > 0 { + if err := conn.Conv.SetSpendRail(*ask.BudgetUSD); err != nil { + conn.Release() + return appwire.Agent{}, appEngineError(err) + } + } + a := b.agent(appAgentID(conn.Transcript)) a.gate.Lock() defer a.gate.Unlock() view := b.hold(a, conn, resolved) + text := strings.TrimSpace(ask.Text) + if title := session.FirstMessageName(text); title != "" { + b.locked(func() { + a.view.Title = title + view = a.view + }) + } b.emit.Emit(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) + if text == "" { + return view, nil + } + if _, err := b.startLocked(a, text); err != nil { + return view, err + } + b.locked(func() { view = a.view }) return view, nil } @@ -458,14 +527,16 @@ func (b *appBackend) ensure(a *appAgent) (appwire.Agent, bool, error) { // ensureLocked is [appBackend.ensure] for a caller already holding a.gate. func (b *appBackend) ensureLocked(a *appAgent) (appwire.Agent, bool, error) { - b.mu.Lock() - if a.live != nil { - view := a.view - b.mu.Unlock() + var view appwire.Agent + var transcript, workspace string + held := false + b.locked(func() { + view, held = a.view, a.live != nil + transcript, workspace = a.transcript, a.view.Workspace + }) + if held { return view, false, nil } - transcript, workspace := a.transcript, a.view.Workspace - b.mu.Unlock() if b.isClosing() { return appwire.Agent{}, false, appwire.Errorf(appwire.CodeClosed, "codeaf is shutting down") } @@ -484,43 +555,46 @@ func (b *appBackend) hold(a *appAgent, conn *appConn, workspace string) appwire. live := &appLive{ conn: conn, box: appwire.NewOutbox(b.emit), lastUse: b.now(), tasks: map[string]appwire.Task{}, lanesDone: make(chan struct{}), asksDone: make(chan struct{}), - questions: map[string]session.Question{}, answered: map[string]bool{}, - } - b.mu.Lock() - if conn.Transcript != "" { - a.transcript = conn.Transcript - } - if conn.Workspace != "" { - workspace = conn.Workspace - } - a.view.Workspace = workspace - if title := conn.Conv.Title(); title != "" { - a.view.Title = title - } - if model := conn.Conv.Model(); model != "" { - a.view.Model = model - } - a.view.State = live.restingState() - if a.view.UpdatedAt.IsZero() { - a.view.UpdatedAt = b.now() - } - a.live = live - view := a.view - b.mu.Unlock() + questions: map[string]session.Question{}, answered: map[string]string{}, + consents: map[uint64]appConsentCall{}, + } + title, model := conn.Conv.Title(), conn.Conv.Model() + var view appwire.Agent + b.locked(func() { + if conn.Transcript != "" { + a.transcript = conn.Transcript + } + if conn.Workspace != "" { + workspace = conn.Workspace + } + a.view.Workspace = workspace + if title != "" { + a.view.Title = title + } + if model != "" { + a.view.Model = model + } + a.view.State = live.restingState() + if a.view.UpdatedAt.IsZero() { + a.view.UpdatedAt = b.now() + } + a.live = live + view = a.view + }) lane, stop := conn.Conv.WatchTaskUpdates() live.stopTasks = stop id := view.ID - go func() { + guard.Go("app/tasks", func() { defer close(live.lanesDone) for event := range lane { if event.Kind == session.EventTaskUpdate && event.Task != nil { b.taskNotice(id, live, event.Task) } } - }() + }) questions, stopQuestions := conn.Conv.WatchQuestions() live.stopAsks = stopQuestions - go func() { + guard.Go("app/asks", func() { defer close(live.asksDone) for event := range questions { b.questionEvent(a, id, live, event) @@ -530,7 +604,7 @@ func (b *appBackend) hold(a *appAgent, conn *appConn, workspace string) appwire. // conversation away, so the only other way it ends is the engine // behind it going (internal/remote's bury finishes every lane). b.died(a, live) - }() + }) return view } @@ -543,14 +617,108 @@ func appAskID(q session.Question) string { func appAskOf(agent string, q session.Question) appwire.Ask { ask := appwire.Ask{ID: appAskID(q), Agent: agent, Kind: appwire.AskQuestion, Title: strings.TrimSpace(q.Head), Detail: strings.TrimSpace(q.Reason)} if q.Kind == session.QuestionConsent { - ask.Kind = appwire.AskApproval + ask.Kind, ask.Subkind = appwire.AskApproval, appwire.SubkindPermission + ask.Irreversible = q.Stakes == session.StakesIrreversible } - for _, option := range q.Options { - ask.Options = append(ask.Options, appwire.AskOption{ID: option.Key, Label: option.Label}) + for _, option := range appOptionsOf(q) { + ask.Options = append(ask.Options, appwire.AskOption{ID: option.id, Label: option.label}) } return ask } +// appOption is one answer as the app names it, with the engine key behind it. +type appOption struct { + id, key, label string +} + +// appOptionsOf is a question's answers in the app's names. A permission's are +// allow_once, deny and always, in that order, and always is left off a call +// that cannot be undone. Any other question keeps the engine's own keys, and so +// does a permission whose answers are not the three the gate offers. +func appOptionsOf(q session.Question) []appOption { + var plain []appOption + for _, option := range q.Options { + plain = append(plain, appOption{id: option.Key, key: option.Key, label: option.Label}) + } + if q.Kind != session.QuestionConsent { + return plain + } + byID := map[string]appOption{} + for _, option := range q.Options { + action, known := session.AnswerFromKey(session.QuestionConsent, option.Key) + if !known { + return plain + } + id := appwire.OptionAllowOnce + switch { + case !action.Allow: + id = appwire.OptionDeny + case action.Scope == session.ConsentToolSession: + id = appwire.OptionAlways + } + if _, twice := byID[id]; twice { + return plain + } + byID[id] = appOption{id: id, key: option.Key, label: option.Label} + } + var mapped []appOption + for _, id := range []string{appwire.OptionAllowOnce, appwire.OptionDeny, appwire.OptionAlways} { + option, offered := byID[id] + if !offered || (id == appwire.OptionAlways && q.Stakes == session.StakesIrreversible) { + continue + } + mapped = append(mapped, option) + } + return mapped +} + +// appOptionNamed finds the answer the app named by its id. +func appOptionNamed(q session.Question, id string) (appOption, bool) { + for _, option := range appOptionsOf(q) { + if option.id == id { + return option, true + } + } + return appOption{}, false +} + +// appOptionOfKey is the app's name for an answer the engine reports by key. +func appOptionOfKey(q session.Question, key string) string { + for _, option := range appOptionsOf(q) { + if option.key == key { + return option.id + } + } + return "" +} + +// appReceipt is the line a closed ask leaves in the conversation: what was +// decided, by whom, and when, in the person's own clock. +func appReceipt(by, option string, answer *session.Answer, at time.Time) string { + word := "Answered" + switch option { + case appwire.OptionAllowOnce: + word = "Allowed once" + case appwire.OptionDeny: + word = "Denied" + case appwire.OptionAlways: + word = "Always allowed" + } + who := "you" + switch by { + case appwire.ClosedByStopped: + return "Not answered · the agent stopped · " + at.Local().Format("15:04") + case appwire.ClosedByElsewhere: + if answer == nil { + return "No longer needed · " + at.Local().Format("15:04") + } + if answer.DecidedBy == session.DecidedByDial { + who = "your settings" + } + } + return word + " · " + who + " · " + at.Local().Format("15:04") +} + // questionEvent keeps the open asks in step with the question lane and says // every change on the wire, the agent's state with it. func (b *appBackend) questionEvent(a *appAgent, id string, live *appLive, event session.Event) { @@ -562,41 +730,65 @@ func (b *appBackend) questionEvent(a *appAgent, id string, live *appLive, event switch event.Kind { case session.EventQuestion: ask := appAskOf(id, q) - b.mu.Lock() - if _, open := live.questions[askID]; open { - // The lane replays what is open when it is subscribed again, and - // a question restated in place is the same ask. - live.questions[askID] = q - for i := range live.asks { - if live.asks[i].ID == askID { - live.asks[i] = ask + restated, moved := false, false + var view appwire.Agent + b.locked(func() { + if _, open := live.questions[askID]; open { + // The lane replays what is open when it is subscribed again, + // and a question restated in place is the same ask. + restated = true + live.questions[askID] = q + for i := range live.asks { + if live.asks[i].ID == askID { + live.asks[i] = ask + } } + return } - b.mu.Unlock() + live.questions[askID] = q + live.asks = append(live.asks, ask) + view, moved = b.restateLocked(a, live) + }) + if restated { return } - live.questions[askID] = q - live.asks = append(live.asks, ask) - view, moved := b.restateLocked(a, live) - b.mu.Unlock() live.box.Event(appwire.EventAskOpened, appwire.AskOpened{Ask: ask}) if moved { live.box.Event(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) } case session.EventQuestionAnswered, session.EventQuestionWithdrawn: - b.mu.Lock() - if _, open := live.questions[askID]; !open { - b.mu.Unlock() + open, moved := false, false + by, option := appwire.ClosedByElsewhere, "" + var answer *session.Answer + var view appwire.Agent + b.locked(func() { + if _, open = live.questions[askID]; !open { + return + } + if event.Kind == session.EventQuestionAnswered { + answer = event.Answer + if answer == nil { + answer = &session.Answer{} + } + option = appOptionOfKey(live.questions[askID], answer.Key) + } + if given := live.answered[askID]; given != "" { + by, option = appwire.ClosedByApp, strings.TrimPrefix(given, appAnsweredMark) + if answer == nil { + answer = &session.Answer{} + } + } + b.dropAskLocked(live, askID) + view, moved = b.restateLocked(a, live) + }) + if !open { return } - by := appwire.ClosedByElsewhere - if live.answered[askID] { - by = appwire.ClosedByApp - } - b.dropAskLocked(live, askID) - view, moved := b.restateLocked(a, live) - b.mu.Unlock() - live.box.Event(appwire.EventAskClosed, appwire.AskClosed{Agent: id, ID: askID, By: by}) + at := b.now() + live.box.Event(appwire.EventAskClosed, appwire.AskClosed{Agent: id, ID: askID, By: by, Option: option, At: at}) + live.box.Event(appwire.EventEntryAdded, appwire.EntryAdded{Entry: appwire.Entry{ + ID: id + "-" + askID, Agent: id, Kind: appwire.EntryReceipt, At: at, Text: appReceipt(by, option, answer, at), + }}) if moved { live.box.Event(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) } @@ -605,6 +797,9 @@ func (b *appBackend) questionEvent(a *appAgent, id string, live *appLive, event // dropAskLocked forgets one ask. Callers hold b.mu. func (b *appBackend) dropAskLocked(live *appLive, askID string) { + if q, ok := live.questions[askID]; ok && q.Kind == session.QuestionConsent { + delete(live.consents, q.ID) + } delete(live.questions, askID) delete(live.answered, askID) kept := live.asks[:0] @@ -637,22 +832,31 @@ func (b *appBackend) restateLocked(a *appAgent, live *appLive) (appwire.Agent, b // the agent reads stopped, its open asks close as stopped, and the next send // opens the conversation again. func (b *appBackend) died(a *appAgent, live *appLive) { - b.mu.Lock() - if a.live != live { - b.mu.Unlock() + mine := false + var view appwire.Agent + var asks []appwire.Ask + b.locked(func() { + if mine = a.live == live; !mine { + return + } + a.live = nil + a.view.State = appwire.StateStopped + view = a.view + asks = live.asks + live.asks, live.questions = nil, map[string]session.Question{} + }) + if !mine { return } - a.live = nil - a.view.State = appwire.StateStopped - view := a.view - asks := live.asks - live.asks, live.questions = nil, map[string]session.Question{} - b.mu.Unlock() + at := b.now() for _, ask := range asks { - live.box.Event(appwire.EventAskClosed, appwire.AskClosed{Agent: view.ID, ID: ask.ID, By: appwire.ClosedByStopped}) + live.box.Event(appwire.EventAskClosed, appwire.AskClosed{Agent: view.ID, ID: ask.ID, By: appwire.ClosedByStopped, At: at}) + live.box.Event(appwire.EventEntryAdded, appwire.EntryAdded{Entry: appwire.Entry{ + ID: view.ID + "-" + ask.ID, Agent: view.ID, Kind: appwire.EntryReceipt, At: at, Text: appReceipt(appwire.ClosedByStopped, "", nil, at), + }}) } live.box.Event(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) - go b.finish(live) + guard.Go("app/finish", func() { b.finish(live) }) } // live answers the agent's held conversation, or nil. @@ -671,68 +875,118 @@ func (b *appBackend) Send(id, text string) (uint64, error) { // The gate keeps the idle sweep from letting the conversation go between // opening it and starting the turn. a.gate.Lock() + defer a.gate.Unlock() view, opened, err := b.ensureLocked(a) if err != nil { - a.gate.Unlock() return 0, err } if opened { b.emit.Emit(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) } - b.mu.Lock() - live := a.live - if live.running { - b.mu.Unlock() - a.gate.Unlock() - return 0, appwire.Errorf(appwire.CodeBusy, "this agent is already working on a turn") - } - live.running = true - live.lastUse = b.now() - turn := b.turns.Add(1) - b.mu.Unlock() + return b.startLocked(a, text) +} + +// Retry sends the words of the agent's last failed turn again. +func (b *appBackend) Retry(id string) (uint64, error) { + a, err := b.find(id) + if err != nil { + return 0, err + } + var text string + b.locked(func() { text = a.failed }) + if text == "" { + return 0, appwire.Errorf(appwire.CodeNotFound, "this agent has no failed turn to run again") + } + return b.Send(id, text) +} + +// startLocked starts one turn on a held conversation. The caller holds a.gate. +func (b *appBackend) startLocked(a *appAgent, text string) (uint64, error) { + var id string + var live *appLive + var turn uint64 + var refused error + b.locked(func() { + id, live = a.view.ID, a.live + switch { + case live == nil: + refused = appwire.Errorf(appwire.CodeEngine, "the conversation was let go before the turn could start") + case live.running: + refused = appwire.Errorf(appwire.CodeBusy, "this agent is already working on a turn") + default: + live.running, live.stopping = true, false + live.lastUse = b.now() + turn = b.turns.Add(1) + } + }) + if refused != nil { + return 0, refused + } events, err := live.conn.Conv.Submit(context.Background(), text) if err != nil { - b.mu.Lock() - live.running = false - b.mu.Unlock() - a.gate.Unlock() + b.locked(func() { live.running = false }) return 0, appEngineError(err) } - b.mu.Lock() - a.view.State = appwire.StateWorking - if live.needsYou() { - a.view.State = appwire.StateNeedsYou - } - a.view.UpdatedAt = b.now() - view = a.view - live.pumpDone = make(chan struct{}) - b.mu.Unlock() + var view appwire.Agent + b.locked(func() { + a.view.State = appwire.StateWorking + if live.needsYou() { + a.view.State = appwire.StateNeedsYou + } + a.view.UpdatedAt = b.now() + view = a.view + live.pumpDone = make(chan struct{}) + }) live.box.Event(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) live.box.Event(appwire.EventTurnStarted, appwire.TurnStarted{Agent: id, Turn: turn}) - go b.pump(a, id, live, turn, events) - a.gate.Unlock() + live.box.Event(appwire.EventEntryAdded, appwire.EntryAdded{Entry: appwire.Entry{ + ID: appLiveEntryID(id, turn, 0), Agent: id, Turn: turn, Kind: appwire.EntryUser, At: b.now(), Text: text, + }}) + guard.Go("app/turn", func() { b.pump(a, id, live, turn, text, events) }) return turn, nil } // pump maps one turn's session events onto the wire. It never waits on the // writer: everything goes into the agent's outbox. -func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, events <-chan session.Event) { +func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, text string, events <-chan session.Event) { defer close(live.pumpDone) ok, failure := true, "" + kind, status := "", 0 changed := func(mutate func(*appwire.Agent)) { - b.mu.Lock() - mutate(&a.view) - view := a.view - b.mu.Unlock() + var view appwire.Agent + b.locked(func() { + mutate(&a.view) + view = a.view + }) live.box.Event(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) } + // The reply is said as an answer entry once a stretch of it is over: when + // a tool call starts after it, or when the turn ends. + entries := 0 + entry := func(e appwire.Entry) { + entries++ + e.ID, e.Agent, e.Turn, e.At = appLiveEntryID(id, turn, entries), id, turn, b.now() + live.box.Event(appwire.EventEntryAdded, appwire.EntryAdded{Entry: e}) + } + var reply, thinking strings.Builder + sayReply := func() { + words := strings.TrimSpace(reply.String()) + if words != "" { + entry(appwire.Entry{Kind: appwire.EntryAnswer, Text: words, Thinking: strings.TrimSpace(thinking.String())}) + } + reply.Reset() + thinking.Reset() + } for event := range events { switch event.Kind { case session.EventTextDelta: + reply.WriteString(event.Text) live.box.Delta(id, turn, appwire.DeltaText, event.Text) case session.EventReasoning: + thinking.WriteString(event.Text) live.box.Delta(id, turn, appwire.DeltaThinking, event.Text) case session.EventToolBegin: + sayReply() live.box.Event(appwire.EventTurnTool, appwire.TurnTool{Agent: id, Turn: turn, Call: event.CallID, Name: event.Tool, Phase: appwire.PhaseBegin, Summary: event.Hint}) case session.EventToolEnd, session.EventToolFailed: succeeded := event.Kind == session.EventToolEnd @@ -741,15 +995,20 @@ func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, ev summary = event.Text } live.box.Event(appwire.EventTurnTool, appwire.TurnTool{Agent: id, Turn: turn, Call: event.CallID, Name: event.Tool, Phase: appwire.PhaseEnd, OK: &succeeded, Summary: redact.Secrets(summary)}) - case session.EventError: - ok, failure = false, "the turn ended with an error" - if event.Err != nil { - failure = redact.Secrets(event.Err.Error()) + case session.EventConsentRequest: + call := appConsentCall{Tool: event.Tool, Args: event.Args} + b.locked(func() { live.consents[event.ID] = call }) + case session.EventNotice: + if words := strings.TrimSpace(event.Text); words != "" { + entry(appwire.Entry{Kind: appwire.EntryNotice, Text: redact.Secrets(words)}) } + case session.EventError: + ok = false + kind, status, failure = appTurnFailure(event) case session.EventTurnDone: // A turn the person steered ends once per stretch; the last ending // on the stream is the turn's. - ok, failure = true, "" + ok, failure, kind, status = true, "", "", 0 case session.EventTitleChanged: if title := strings.TrimSpace(event.Text); title != "" { changed(func(v *appwire.Agent) { v.Title = title }) @@ -760,20 +1019,64 @@ func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, ev } } } - live.box.Event(appwire.EventTurnDone, appwire.TurnDone{Agent: id, Turn: turn, OK: ok, Error: failure}) - b.mu.Lock() - live.running = false - live.lastUse = b.now() - if a.live != live { + sayReply() + b.locked(func() { + if !ok && live.stopping { + kind = appwire.ErrorStopped + } + a.failed = "" + if !ok { + a.failed = text + } + }) + if !ok { + entry(appwire.Entry{Kind: appwire.EntryError, Text: failure, ErrorKind: kind, Status: status}) + } + live.box.Event(appwire.EventTurnDone, appwire.TurnDone{Agent: id, Turn: turn, OK: ok, Error: failure, ErrorKind: kind, Status: status}) + held := false + var view appwire.Agent + b.locked(func() { + live.running = false + live.lastUse = b.now() // Let go or closed while the turn ran: that road has said the state. - b.mu.Unlock() - return + if held = a.live == live; held { + a.view.State = live.restingState() + a.view.UpdatedAt = b.now() + view = a.view + } + }) + if held { + live.box.Event(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) } - a.view.State = live.restingState() - a.view.UpdatedAt = b.now() - view := a.view - b.mu.Unlock() - live.box.Event(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) + b.spendMoved() +} + +// appLiveEntryID names an entry said while its turn ran. The listing names the +// same items by their place in the transcript instead ([appEntriesOf]). +func appLiveEntryID(agent string, turn uint64, n int) string { + return agent + "-t" + strconv.FormatUint(turn, 10) + "-" + strconv.Itoa(n) +} + +// appTurnFailure sorts a turn's error: its kind, the provider's status where +// there is one, and the words a person reads, which for a provider refusal are +// the provider's own. +func appTurnFailure(event session.Event) (kind string, status int, words string) { + kind, status, words = event.ErrKind, event.ErrStatus, event.ErrWords + if kind == "" && event.Err != nil { + kind, status, words = session.ClassifyTurnError(event.Err) + } + switch kind { + case session.TurnErrorProvider, session.TurnErrorBudget, session.TurnErrorStopped: + default: + kind = appwire.ErrorOther + } + if kind != session.TurnErrorProvider || strings.TrimSpace(words) == "" { + words = "the turn ended with an error" + if event.Err != nil { + words = event.Err.Error() + } + } + return kind, status, redact.Secrets(strings.TrimSpace(words)) } // appTaskState is a task's state in the protocol's five words, and "" for a @@ -805,16 +1108,20 @@ func (b *appBackend) taskNotice(id string, live *appLive, notice *session.TaskNo return } task := appwire.Task{ID: strconv.FormatUint(notice.ID, 10), Agent: id, Title: notice.Title, State: state, UpdatedAt: b.now()} - b.mu.Lock() - if before, ok := live.tasks[task.ID]; ok && before.State == task.State && (task.Title == "" || before.Title == task.Title) { - b.mu.Unlock() + same := false + b.locked(func() { + if before, ok := live.tasks[task.ID]; ok && before.State == task.State && (task.Title == "" || before.Title == task.Title) { + same = true + return + } + if task.Title == "" { + task.Title = live.tasks[task.ID].Title + } + live.tasks[task.ID] = task + }) + if same { return } - if task.Title == "" { - task.Title = live.tasks[task.ID].Title - } - live.tasks[task.ID] = task - b.mu.Unlock() live.box.Event(appwire.EventTaskChanged, appwire.TaskChanged{Task: task}) } @@ -826,6 +1133,7 @@ func (b *appBackend) Stop(id string) error { return err } if live := b.liveOf(a); live != nil { + b.locked(func() { live.stopping = live.running }) live.conn.Conv.Interrupt() } return nil @@ -838,24 +1146,28 @@ func (b *appBackend) CloseAgent(id string) error { if err != nil { return err } - a.gate.Lock() - b.release(a) - b.mu.Lock() - a.view.State = appwire.StateClosed - view := a.view - b.mu.Unlock() - a.gate.Unlock() + view := b.closeAgent(a) b.emit.Emit(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) return nil } +// closeAgent lets a's conversation go and marks it closed. +func (b *appBackend) closeAgent(a *appAgent) (view appwire.Agent) { + a.gate.Lock() + defer a.gate.Unlock() + b.release(a) + b.locked(func() { + a.view.State = appwire.StateClosed + view = a.view + }) + return view +} + // release gives a's conversation back and flushes its last events. Callers // hold a.gate. func (b *appBackend) release(a *appAgent) { - b.mu.Lock() - live := a.live - a.live = nil - b.mu.Unlock() + var live *appLive + b.locked(func() { live, a.live = a.live, nil }) if live == nil { return } @@ -903,6 +1215,10 @@ func (b *appBackend) ListAsks(id string) ([]appwire.Ask, error) { return append([]appwire.Ask(nil), a.live.asks...), nil } +// appAnsweredMark starts the value [appLive.answered] holds for an ask this app +// answered, so an answer of words alone, with no option, is still marked. +const appAnsweredMark = "app:" + // AnswerAsk answers one open ask through the engine's own answer door, the one // the chat's question page uses. Its closing arrives on the question lane. func (b *appBackend) AnswerAsk(id, askID, option, text string) error { @@ -910,39 +1226,302 @@ func (b *appBackend) AnswerAsk(id, askID, option, text string) error { if err != nil { return err } - b.mu.Lock() - live := a.live + var live *appLive var q session.Question - open := false - if live != nil { - q, open = live.questions[askID] - } - if !open { - b.mu.Unlock() - return appwire.Errorf(appwire.CodeNotFound, "the ask %s is not open; it may have been answered elsewhere", askID) - } - if option != "" { - if _, offered := q.Option(option); !offered { - b.mu.Unlock() - return appwire.Errorf(appwire.CodeBadParams, "the ask %s offers no option %q", askID, option) + var key string + var refused error + b.locked(func() { + live = a.live + open := false + if live != nil { + q, open = live.questions[askID] + } + if !open { + refused = appwire.Errorf(appwire.CodeNotFound, "the ask %s is not open; it may have been answered elsewhere", askID) + return + } + if option != "" { + picked, offered := appOptionNamed(q, option) + if !offered { + refused = appwire.Errorf(appwire.CodeBadParams, "the ask %s offers no option %q", askID, option) + return + } + key = picked.key } + live.answered[askID] = appAnsweredMark + option + live.lastUse = b.now() + }) + if refused != nil { + return refused + } + if q.Kind == session.QuestionConsent && option == appwire.OptionAlways && b.rememberAlways(b.consentCall(live, q.ID)) { + // THE RULE IS WRITTEN FIRST and the answer says so, and the engine + // reads the rules again before it lets the call go: the next call + // like this one is answered by the person's own settings. + live.conn.Conv.ResolveConsentRemember(q.ID, true, session.ConsentRule) + return nil } - live.answered[askID] = true - live.lastUse = b.now() - b.mu.Unlock() - answer := session.Answer{Kind: q.Kind, ID: q.ID, Ref: q.Ref, Ask: q.Ask, Key: option, Change: strings.TrimSpace(text)} - if option != "" { - answer.Picked = []string{option} + answer := session.Answer{Kind: q.Kind, ID: q.ID, Ref: q.Ref, Ask: q.Ask, Key: key, Change: strings.TrimSpace(text)} + if key != "" { + answer.Picked = []string{key} } if err := live.conn.Conv.ResolveQuestion(answer); err != nil { - b.mu.Lock() - delete(live.answered, askID) - b.mu.Unlock() + b.locked(func() { delete(live.answered, askID) }) return appEngineError(err) } return nil } +// appConsentWait bounds how long an always waits for the turn to have said +// which call it is about. The question and the call travel on two lanes and the +// question can arrive first; the wait is only ever paid on an always. +const appConsentWait = 2 * time.Second + +// consentCall is the call approval id is about, as the turn announced it. +func (b *appBackend) consentCall(live *appLive, id uint64) appConsentCall { + deadline := time.Now().Add(appConsentWait) + for { + var call appConsentCall + seen := false + b.locked(func() { call, seen = live.consents[id] }) + if seen || time.Now().After(deadline) { + return call + } + time.Sleep(5 * time.Millisecond) + } +} + +// rememberAlways writes an always down as a rule in the person's own profile: +// the one command line for a shell call, the tool for any other. It reports +// whether the rule is written; when it is not (a compound command, a settings +// row that already answers otherwise, a call the turn never announced) the +// always is the engine's own, for the rest of this conversation. +func (b *appBackend) rememberAlways(call appConsentCall) bool { + tool := strings.TrimSpace(call.Tool) + if tool == "" { + return false + } + profile := config.ProfileDir() + if tool == "bash" { + var args struct { + Command string `json:"command"` + } + if err := json.Unmarshal([]byte(call.Args), &args); err != nil || strings.TrimSpace(args.Command) == "" { + return false + } + return config.RememberBashApproval(profile, args.Command) == nil + } + return config.RememberToolApproval(profile, tool, "allow") == nil +} + +// Entries reads the agent's transcript off the disk, whether or not a +// conversation is held for it: the journal is written as the turn goes. +func (b *appBackend) Entries(id, before string, limit int) ([]appwire.Entry, bool, error) { + a, err := b.find(id) + if err != nil { + return nil, false, err + } + var transcript string + var at time.Time + b.locked(func() { transcript, at = a.transcript, a.view.UpdatedAt }) + if transcript == "" { + return []appwire.Entry{}, false, nil + } + record := session.ReadTranscript(transcript) + all := appEntriesOf(id, append(append([]session.DisplayEntry(nil), record.Earlier...), record.Entries...), at) + if words := strings.TrimSpace(record.Unreadable); words != "" { + all = append(all, appwire.Entry{ID: "e" + strconv.Itoa(len(all)), Agent: id, Kind: appwire.EntryNotice, At: at, Text: words}) + } + end := len(all) + if before != "" { + end = -1 + for i, entry := range all { + if entry.ID == before { + end = i + break + } + } + if end < 0 { + return nil, false, appwire.Errorf(appwire.CodeNotFound, "this conversation has no entry %s", before) + } + } + start := max(end-limit, 0) + page := append([]appwire.Entry{}, all[start:end]...) + return page, start > 0, nil +} + +// appEntriesOf is a transcript as the app's entries, named by their place in +// it. The journal keeps no clock per message, so every entry carries the +// conversation's own last moment. +func appEntriesOf(agent string, items []session.DisplayEntry, at time.Time) []appwire.Entry { + entries := make([]appwire.Entry, 0, len(items)) + for _, item := range items { + entry := appwire.Entry{Agent: agent, At: at} + switch item.Role { + case "user": + entry.Kind, entry.Text = appwire.EntryUser, item.Text + case "assistant": + if strings.TrimSpace(item.Text) == "" { + continue + } + entry.Kind, entry.Text = appwire.EntryAnswer, item.Text + case "tool": + entry.Kind, entry.Call, entry.Name, entry.Summary = appwire.EntryTool, item.CallID, item.Tool, redact.Secrets(item.Hint) + case "note", "aside": + if strings.TrimSpace(item.Text) == "" { + continue + } + entry.Kind, entry.Text = appwire.EntryNotice, item.Text + default: + continue + } + entry.ID = "e" + strconv.Itoa(len(entries)) + entries = append(entries, entry) + } + return entries +} + +// SetModel moves the agent to another model, opening its conversation first if +// none is held: the choice is the conversation's own and is saved with it. +func (b *appBackend) SetModel(id, model string) (appwire.Agent, error) { + model = strings.TrimSpace(model) + if model == "" { + return appwire.Agent{}, appwire.Errorf(appwire.CodeBadParams, "name a model") + } + a, err := b.find(id) + if err != nil { + return appwire.Agent{}, err + } + a.gate.Lock() + defer a.gate.Unlock() + if _, _, err := b.ensureLocked(a); err != nil { + return appwire.Agent{}, err + } + var live *appLive + b.locked(func() { + live = a.live + live.lastUse = b.now() + }) + live.conn.Conv.SetModel(model) + if now := live.conn.Conv.Model(); now != "" { + model = now + } + var view appwire.Agent + b.locked(func() { + a.view.Model = model + view = a.view + }) + b.emit.Emit(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) + return view, nil +} + +// ListModels answers the models the configured service lists, from the list +// remembered on disk. With nothing remembered the list is fetched once, behind +// the answer, and the answer meanwhile is the built-in few for the default +// service; nothing of this happens before the app asks. +func (b *appBackend) ListModels() ([]appwire.Model, error) { + settings, err := config.LoadKeyless() + if err != nil { + return nil, appEngineError(err) + } + discovery := catalog.Options{ + BaseURL: settings.BaseURL, APIKey: settings.APIKey, Dir: settings.ProfileDir, + HTTPClient: config.CatalogHTTPClient(settings.Sources.Default()), + } + rows := catalog.Recall(discovery).ModelsNow() + if len(rows) == 0 { + var lazy *catalog.Catalog + b.locked(func() { + if b.models == nil && !b.closing { + b.models = catalog.LoadLazy(context.Background(), discovery) + } + lazy = b.models + }) + if lazy != nil { + rows = lazy.ModelsNow() + } + } + models := make([]appwire.Model, 0, len(rows)) + for _, row := range rows { + name := strings.TrimSpace(row.Name) + if name == "" { + name = row.ID + } + provider, _, _ := strings.Cut(strings.TrimPrefix(row.ID, "~"), "/") + models = append(models, appwire.Model{ID: row.ID, Name: name, Provider: provider}) + } + return models, nil +} + +// Settings reads the profile's own rows; a figure nobody set is null. +func (b *appBackend) Settings() (appwire.Settings, error) { + profile := config.ProfileDir() + settings := appwire.Settings{ + DefaultModel: config.ChatModelAt(profile), + AsksBefore: config.ToolApprovalModeAt(profile), + BudgetUSD: appFigure(config.SpendRailUSDAt(profile)), + } + if settings.DefaultModel == "" { + // Nobody chose one: the model a new conversation opens on. + settings.DefaultModel = config.ChatDefaultAt(profile) + } + dayCap, err := config.DailyBudgetUSDAt(profile) + if err != nil { + return appwire.Settings{}, appEngineError(err) + } + settings.DayCapUSD = appFigure(dayCap) + return settings, nil +} + +// appFigure is a money figure, or nil for none. +func appFigure(usd float64) *float64 { + if usd <= 0 { + return nil + } + return &usd +} + +// SpendToday reads this machine's usage ledger for today. +func (b *appBackend) SpendToday() (appwire.Spend, error) { + now := b.now() + lines, err := session.ReadUsage(session.UsageLedgerPath(), now.Add(-48*time.Hour)) + if err != nil { + return appwire.Spend{}, appEngineError(err) + } + spend := appwire.Spend{TodayUSD: session.SpendToday(lines, now)} + if dayCap, err := config.DailyBudgetUSDAt(config.ProfileDir()); err == nil { + spend.DayCapUSD = appFigure(dayCap) + } + return spend, nil +} + +// appSpendSettle is how long after a turn ends its spending is read again: the +// ledger is written behind the turn, not in front of its ending. +const appSpendSettle = 300 * time.Millisecond + +// spendMoved says spend.changed once a turn's spending has reached the ledger, +// if today's figure moved. +func (b *appBackend) spendMoved() { + time.AfterFunc(appSpendSettle, func() { + defer guard.Recover("app/spend") + if b.isClosing() { + return + } + spend, err := b.SpendToday() + if err != nil { + return + } + moved := false + b.locked(func() { + moved = spend.TodayUSD != b.spent + b.spent = spend.TodayUSD + }) + if moved { + b.emit.Emit(appwire.EventSpendChanged, appwire.SpendChanged{Spend: spend}) + } + }) +} + // ListTasks reads the agent's tasks off its project's task index, and lays // over them what the held conversation has said since. func (b *appBackend) ListTasks(id string) ([]appwire.Task, error) { @@ -950,17 +1529,18 @@ func (b *appBackend) ListTasks(id string) ([]appwire.Task, error) { if err != nil { return nil, err } - b.mu.Lock() - transcript := a.transcript - fallback := a.view.UpdatedAt + var transcript string + var fallback time.Time var heard map[string]appwire.Task - if a.live != nil { - heard = make(map[string]appwire.Task, len(a.live.tasks)) - for k, v := range a.live.tasks { - heard[k] = v + b.locked(func() { + transcript, fallback = a.transcript, a.view.UpdatedAt + if a.live != nil { + heard = make(map[string]appwire.Task, len(a.live.tasks)) + for k, v := range a.live.tasks { + heard[k] = v + } } - } - b.mu.Unlock() + }) var tasks []appwire.Task index := map[string]int{} if path := session.TaskIndexPath(transcript); path != "" { @@ -1006,11 +1586,11 @@ func (b *appBackend) ListCrews() ([]appwire.Crew, error) { if err != nil { return nil, appEngineError(err) } - b.mu.Lock() - if b.crews == nil && !b.closing { - b.crews = newAppCrewWatch(b.emit, crews) - } - b.mu.Unlock() + b.locked(func() { + if b.crews == nil && !b.closing { + b.crews = newAppCrewWatch(b.emit, crews) + } + }) return crews, nil } @@ -1049,30 +1629,40 @@ func appMemberAgent(member teamstore.Member) string { // Close lets every conversation go and stops the watchers. func (b *appBackend) Close() error { - b.mu.Lock() - if b.closing { - b.mu.Unlock() + already := false + var crews *appCrewWatch + var models *catalog.Catalog + var agents []*appAgent + b.locked(func() { + if already = b.closing; already { + return + } + b.closing = true + crews = b.crews + for _, a := range b.agents { + agents = append(agents, a) + } + }) + if already { return nil } - b.closing = true - crews := b.crews - agents := make([]*appAgent, 0, len(b.agents)) - for _, a := range b.agents { - agents = append(agents, a) - } - b.mu.Unlock() close(b.sweepStop) <-b.sweepDone if crews != nil { crews.close() } + // No models.list can start a fetch once closing is set. + b.locked(func() { models = b.models }) + if models != nil { + models.Close() + } var wg sync.WaitGroup for _, a := range agents { if b.liveOf(a) == nil { continue } wg.Add(1) - go func(a *appAgent) { + guard.Go("app/close", func() { defer wg.Done() // Stop first: a turn still running is interrupted rather than left // holding the gate while the process leaves. @@ -1081,15 +1671,20 @@ func (b *appBackend) Close() error { if live := b.liveOf(a); live != nil && live.conn.Assembled { live.conn.Conv.Interrupt() } - a.gate.Lock() - b.release(a) - a.gate.Unlock() - }(a) + b.letGo(a) + }) } wg.Wait() return nil } +// letGo gives a's conversation back under its gate. +func (b *appBackend) letGo(a *appAgent) { + a.gate.Lock() + defer a.gate.Unlock() + b.release(a) +} + func (b *appBackend) liveNeedsYou(live *appLive) bool { b.mu.Lock() defer b.mu.Unlock() @@ -1121,28 +1716,36 @@ func (b *appBackend) sweep() { // runs or a question waits for the person, whatever the clock says. func (b *appBackend) sweepOnce() { now := b.now() - b.mu.Lock() var idle []*appAgent - for _, a := range b.agents { - if live := a.live; live != nil && !live.running && now.Sub(live.lastUse) >= appAgentIdle { - idle = append(idle, a) + b.locked(func() { + for _, a := range b.agents { + if live := a.live; live != nil && !live.running && now.Sub(live.lastUse) >= appAgentIdle { + idle = append(idle, a) + } } - } - b.mu.Unlock() + }) for _, a := range idle { - if !a.gate.TryLock() { - continue - } - b.mu.Lock() - live := a.live - keep := live == nil || live.running || now.Sub(live.lastUse) < appAgentIdle - b.mu.Unlock() - if !keep && !b.liveNeedsYou(live) { - // The state the app reads does not move: the conversation is as - // idle on disk as it was in memory. - b.release(a) - } - a.gate.Unlock() + b.sweepAgent(a, now) + } +} + +// sweepAgent lets one idle conversation go, unless something took its gate or +// woke it since the look. +func (b *appBackend) sweepAgent(a *appAgent, now time.Time) { + if !a.gate.TryLock() { + return + } + defer a.gate.Unlock() + var live *appLive + keep := true + b.locked(func() { + live = a.live + keep = live == nil || live.running || now.Sub(live.lastUse) < appAgentIdle + }) + if !keep && !b.liveNeedsYou(live) { + // The state the app reads does not move: the conversation is as + // idle on disk as it was in memory. + b.release(a) } } @@ -1166,7 +1769,7 @@ type appCrewWatch struct { func newAppCrewWatch(emit appwire.Emitter, crews []appwire.Crew) *appCrewWatch { w := &appCrewWatch{emit: emit, stop: make(chan struct{}), done: make(chan struct{}), known: appCrewPrints(crews)} - go w.run() + guard.Go("app/crews", w.run) return w } diff --git a/cmd/codeaf/app_test.go b/cmd/codeaf/app_test.go index 3ee38db48d..2cd49d96d4 100644 --- a/cmd/codeaf/app_test.go +++ b/cmd/codeaf/app_test.go @@ -14,8 +14,10 @@ import ( "time" "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/catalog" "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/provider" "github.com/Agent-Field/codeaf/internal/session" teamstore "github.com/Agent-Field/codeaf/internal/teams" ) @@ -136,6 +138,10 @@ type fakeConversation struct { questions chan session.Event endLane func() answers []session.Answer + model string + rail float64 + remembered []session.ConsentScope + texts []string // onAnswer, when set, is what the engine does with an answer. onAnswer func(session.Answer) } @@ -172,6 +178,7 @@ func (f *fakeConversation) raise(event session.Event) { func (f *fakeConversation) Submit(ctx context.Context, text string) (<-chan session.Event, error) { f.mu.Lock() f.submits++ + f.texts = append(f.texts, text) script, hold := f.script, f.hold f.mu.Unlock() events := make(chan session.Event) @@ -186,8 +193,27 @@ func (f *fakeConversation) Submit(ctx context.Context, text string) (<-chan sess }() return events, nil } -func (f *fakeConversation) Interrupt() { f.mu.Lock(); f.interrupted++; f.mu.Unlock() } -func (f *fakeConversation) Model() string { return "deepseek/deepseek-v4-flash" } +func (f *fakeConversation) Interrupt() { f.mu.Lock(); f.interrupted++; f.mu.Unlock() } +func (f *fakeConversation) Model() string { + f.mu.Lock() + defer f.mu.Unlock() + if f.model != "" { + return f.model + } + return "deepseek/deepseek-v4-flash" +} +func (f *fakeConversation) SetModel(model string) { f.mu.Lock(); f.model = model; f.mu.Unlock() } +func (f *fakeConversation) SetSpendRail(usd float64) error { + f.mu.Lock() + f.rail = usd + f.mu.Unlock() + return nil +} +func (f *fakeConversation) ResolveConsentRemember(id uint64, allow bool, scope session.ConsentScope) { + f.mu.Lock() + f.remembered = append(f.remembered, scope) + f.mu.Unlock() +} func (f *fakeConversation) Title() string { return "" } func (f *fakeConversation) NeedsPerson() bool { f.mu.Lock(); defer f.mu.Unlock(); return f.needs } func (f *fakeConversation) WatchTaskUpdates() (<-chan session.Event, func()) { @@ -285,7 +311,7 @@ func TestAppSendMapsSessionEventsOntoTurnEvents(t *testing.T) { backend := newAppBackend(rec, fakeOpener(conv, &opens, &releases, &mu)) defer backend.Close() - agent, err := backend.CreateAgent(t.TempDir(), "deepseek/deepseek-v4-flash") + agent, err := backend.CreateAgent(appwire.CreateAsk{Workspace: t.TempDir(), Model: "deepseek/deepseek-v4-flash"}) if err != nil { t.Fatal(err) } @@ -307,8 +333,8 @@ func TestAppSendMapsSessionEventsOntoTurnEvents(t *testing.T) { // The first event is the create's own agent.changed. got := rec.events[1 : done+2] want := []string{ - appwire.EventAgentChanged, appwire.EventTurnStarted, - appwire.EventTurnDelta, appwire.EventTurnDelta, + appwire.EventAgentChanged, appwire.EventTurnStarted, appwire.EventEntryAdded, + appwire.EventTurnDelta, appwire.EventTurnDelta, appwire.EventEntryAdded, appwire.EventTurnTool, appwire.EventTurnTool, appwire.EventTurnTool, appwire.EventTurnTool, appwire.EventTaskChanged, appwire.EventAgentChanged, appwire.EventTurnDone, appwire.EventAgentChanged, } @@ -322,15 +348,21 @@ func TestAppSendMapsSessionEventsOntoTurnEvents(t *testing.T) { if d := data[1].(appwire.TurnStarted); d.Turn != turn || d.Agent != agent.ID { t.Fatalf("turn.started = %+v", d) } - if d := data[2].(appwire.TurnDelta); d.Kind != appwire.DeltaThinking || d.Text != "hmm" { + if e := data[2].(appwire.EntryAdded).Entry; e.Kind != appwire.EntryUser || e.Text != "fix it" || e.Turn != turn || e.Agent != agent.ID { + t.Fatalf("the person's entry = %+v", e) + } + if d := data[3].(appwire.TurnDelta); d.Kind != appwire.DeltaThinking || d.Text != "hmm" { t.Fatalf("thinking = %+v", d) } - if d := data[3].(appwire.TurnDelta); d.Kind != appwire.DeltaText || d.Text != "hello world" { + if d := data[4].(appwire.TurnDelta); d.Kind != appwire.DeltaText || d.Text != "hello world" { t.Fatalf("text = %+v", d) } - begin := data[4].(appwire.TurnTool) - end := data[5].(appwire.TurnTool) - failed := data[7].(appwire.TurnTool) + if e := data[5].(appwire.EntryAdded).Entry; e.Kind != appwire.EntryAnswer || e.Text != "hello world" || e.Thinking != "hmm" { + t.Fatalf("the answer entry = %+v", e) + } + begin := data[6].(appwire.TurnTool) + end := data[7].(appwire.TurnTool) + failed := data[9].(appwire.TurnTool) if begin.Phase != appwire.PhaseBegin || begin.Call != "c1" || begin.Name != "read" || begin.OK != nil { t.Fatalf("begin = %+v", begin) } @@ -340,10 +372,10 @@ func TestAppSendMapsSessionEventsOntoTurnEvents(t *testing.T) { if failed.Phase != appwire.PhaseEnd || failed.OK == nil || *failed.OK || failed.Summary != "exit status 1" { t.Fatalf("failed = %+v", failed) } - if task := data[8].(appwire.TaskChanged).Task; task.ID != "3" || task.Agent != agent.ID || task.State != appwire.TaskRunning || task.Title != "fix the parser" { + if task := data[10].(appwire.TaskChanged).Task; task.ID != "3" || task.Agent != agent.ID || task.State != appwire.TaskRunning || task.Title != "fix the parser" { t.Fatalf("task = %+v", task) } - if d := data[10].(appwire.TurnDone); !d.OK || d.Turn != turn || d.Error != "" { + if d := data[12].(appwire.TurnDone); !d.OK || d.Turn != turn || d.Error != "" { t.Fatalf("turn.done = %+v", d) } } @@ -358,7 +390,7 @@ func TestAppSendRefusesBusyAndReportsAFailedTurn(t *testing.T) { rec := newAppRecorder() backend := newAppBackend(rec, fakeOpener(conv, &opens, &releases, &mu)) defer backend.Close() - agent, err := backend.CreateAgent(t.TempDir(), "") + agent, err := backend.CreateAgent(appwire.CreateAsk{Workspace: t.TempDir()}) if err != nil { t.Fatal(err) } @@ -395,7 +427,7 @@ func TestAppLetsAnIdleConversationGoAndTheNextSendReopensIt(t *testing.T) { defer backend.Close() clock := time.Now() backend.now = func() time.Time { return clock } - agent, err := backend.CreateAgent(t.TempDir(), "") + agent, err := backend.CreateAgent(appwire.CreateAsk{Workspace: t.TempDir()}) if err != nil { t.Fatal(err) } @@ -445,7 +477,7 @@ func TestAppCloseSaysClosedAndUnknownIdsAreNotFound(t *testing.T) { backend := newAppBackend(rec, fakeOpener(conv, &opens, &releases, &mu)) defer backend.Close() t.Setenv(home.EnvVar, t.TempDir()) - agent, err := backend.CreateAgent(t.TempDir(), "") + agent, err := backend.CreateAgent(appwire.CreateAsk{Workspace: t.TempDir()}) if err != nil { t.Fatal(err) } @@ -478,7 +510,7 @@ func TestAppHoldsARealConversationInProcess(t *testing.T) { _, workspace := wakeEnvironment(t, root) rec := newAppRecorder() backend := newAppBackend(rec, appOpenInProcess) - agent, err := backend.CreateAgent(workspace, "") + agent, err := backend.CreateAgent(appwire.CreateAsk{Workspace: workspace}) if err != nil { backend.Close() t.Fatalf("create = %v", err) @@ -529,6 +561,28 @@ func TestAppHoldsARealConversationInProcess(t *testing.T) { if !backend.assembled.Load() { t.Fatal("the backend did not record that it built a conversation") } + + // The transcript reads back with nothing held: the person's words, then + // the reply, oldest first; a page of one is the newest, with more before. + entries, more, err := backend.Entries(agent.ID, "", appwire.MaxEntries) + if err != nil || more || len(entries) < 2 { + t.Fatalf("entries = %+v, more %v, %v", entries, more, err) + } + if first := entries[0]; first.Kind != appwire.EntryUser || first.Text != "say done" { + t.Fatalf("first entry = %+v", first) + } + last := entries[len(entries)-1] + if last.Kind != appwire.EntryAnswer || !strings.Contains(last.Text, "done") { + t.Fatalf("last entry = %+v", last) + } + page, more, err := backend.Entries(agent.ID, "", 1) + if err != nil || !more || len(page) != 1 || page[0].ID != last.ID { + t.Fatalf("a page of one = %+v, more %v, %v", page, more, err) + } + earlier, _, err := backend.Entries(agent.ID, last.ID, 1) + if err != nil || len(earlier) != 1 || earlier[0].ID != entries[len(entries)-2].ID { + t.Fatalf("the page before = %+v, %v", earlier, err) + } } func appAskEvent(kind string) func(string, any) bool { @@ -551,7 +605,7 @@ func TestAppAsksOpenAnswerAndCloseThroughTheQuestionLane(t *testing.T) { rec := newAppRecorder() backend := newAppBackend(rec, fakeOpener(conv, &opens, &releases, &mu)) defer backend.Close() - agent, err := backend.CreateAgent(t.TempDir(), "") + agent, err := backend.CreateAgent(appwire.CreateAsk{Workspace: t.TempDir()}) if err != nil { t.Fatal(err) } @@ -566,7 +620,8 @@ func TestAppAsksOpenAnswerAndCloseThroughTheQuestionLane(t *testing.T) { rec.mu.Lock() opened := rec.data[i].(appwire.AskOpened).Ask rec.mu.Unlock() - if opened.ID != "consent:4" || opened.Kind != appwire.AskApproval || opened.Title != "bash rm -rf build" || opened.Detail != "bash asks first" || len(opened.Options) != 3 || opened.Options[2].Label != "deny" { + if opened.ID != "consent:4" || opened.Kind != appwire.AskApproval || opened.Subkind != appwire.SubkindPermission || opened.Title != "bash rm -rf build" || opened.Detail != "bash asks first" || len(opened.Options) != 3 || + opened.Options[0].ID != appwire.OptionAllowOnce || opened.Options[1].ID != appwire.OptionDeny || opened.Options[1].Label != "deny" || opened.Options[2].ID != appwire.OptionAlways { t.Fatalf("ask = %+v", opened) } asks, err := backend.ListAsks(agent.ID) @@ -574,10 +629,10 @@ func TestAppAsksOpenAnswerAndCloseThroughTheQuestionLane(t *testing.T) { t.Fatalf("asks.list = %+v, %v", asks, err) } var wire *appwire.Error - if err := backend.AnswerAsk(agent.ID, "consent:4", "9", ""); !errors.As(err, &wire) || wire.Code != appwire.CodeBadParams { - t.Fatalf("answer with an option not offered = %v", err) + if err := backend.AnswerAsk(agent.ID, "consent:4", "1", ""); !errors.As(err, &wire) || wire.Code != appwire.CodeBadParams { + t.Fatalf("answer with the engine's key instead of the app's = %v", err) } - if err := backend.AnswerAsk(agent.ID, "consent:4", "1", ""); err != nil { + if err := backend.AnswerAsk(agent.ID, "consent:4", appwire.OptionAllowOnce, ""); err != nil { t.Fatal(err) } closed := rec.waitFor(t, 5*time.Second, appAskEvent(appwire.EventAskClosed)) @@ -586,11 +641,15 @@ func TestAppAsksOpenAnswerAndCloseThroughTheQuestionLane(t *testing.T) { return ok && c.Agent.State == appwire.StateIdle }) rec.mu.Lock() - if c := rec.data[closed].(appwire.AskClosed); c.ID != "consent:4" || c.By != appwire.ClosedByApp { + if c := rec.data[closed].(appwire.AskClosed); c.ID != "consent:4" || c.By != appwire.ClosedByApp || c.Option != appwire.OptionAllowOnce || c.At.IsZero() { rec.mu.Unlock() t.Fatalf("ask.closed = %+v", c) } + receipt, ok := rec.data[closed+1].(appwire.EntryAdded) rec.mu.Unlock() + if !ok || receipt.Entry.Kind != appwire.EntryReceipt || !strings.HasPrefix(receipt.Entry.Text, "Allowed once · you · ") { + t.Fatalf("the receipt = %+v", receipt) + } conv.mu.Lock() got := conv.answers[0] conv.mu.Unlock() @@ -623,7 +682,7 @@ func TestAppADeadConversationReadsStoppedAndTheNextSendReopensIt(t *testing.T) { rec := newAppRecorder() backend := newAppBackend(rec, fakeOpener(conv, &opens, &releases, &mu)) defer backend.Close() - agent, err := backend.CreateAgent(t.TempDir(), "") + agent, err := backend.CreateAgent(appwire.CreateAsk{Workspace: t.TempDir()}) if err != nil { t.Fatal(err) } @@ -689,7 +748,7 @@ func TestAppAnswersARealApprovalAndRecordsTheWorkspaceAsTheLaunchFolder(t *testi rec := newAppRecorder() backend := newAppBackend(rec, appOpenInProcess) defer backend.Close() - agent, err := backend.CreateAgent(workspace, "") + agent, err := backend.CreateAgent(appwire.CreateAsk{Workspace: workspace}) if err != nil { t.Fatalf("create = %v", err) } @@ -770,3 +829,397 @@ func TestAppAnnouncesAClosedCrewAsRemoved(t *testing.T) { return ok && changed.Crew.Name == "pier" }) } + +// CREATED WITH WORDS, AN AGENT STARTS AT ONCE AND IS NAMED BY THEM. No +// workspace is the home folder; the budget is the conversation's own limit, +// set before the first turn; the title is the first words cut at forty +// characters with an ellipsis; the turn starts with the words given. +func TestAppCreateWithTextStartsTheTurnAndNamesTheAgent(t *testing.T) { + homeDir := resolvedTempDir(t) + t.Setenv("HOME", homeDir) + conv := &fakeConversation{script: []session.Event{{Kind: session.EventTurnDone}}} + var mu sync.Mutex + var opens, releases int + rec := newAppRecorder() + backend := newAppBackend(rec, fakeOpener(conv, &opens, &releases, &mu)) + defer backend.Close() + budget := 3.0 + words := "Please fix the flaky parser test in the lexer package before lunch" + agent, err := backend.CreateAgent(appwire.CreateAsk{Text: words, BudgetUSD: &budget}) + if err != nil { + t.Fatal(err) + } + if agent.Workspace != homeDir { + t.Fatalf("workspace = %q, want the home folder %q", agent.Workspace, homeDir) + } + if want := session.FirstMessageName(words); agent.Title != want || !strings.HasSuffix(want, "…") || len([]rune(want)) > 41 { + t.Fatalf("title = %q, want %q", agent.Title, want) + } + rec.waitFor(t, 5*time.Second, func(event string, data any) bool { + started, ok := data.(appwire.TurnStarted) + return ok && started.Agent == agent.ID + }) + rec.waitFor(t, 5*time.Second, appTurnDone) + conv.mu.Lock() + defer conv.mu.Unlock() + if conv.rail != budget || len(conv.texts) != 1 || conv.texts[0] != words { + t.Fatalf("rail %v, sent %q", conv.rail, conv.texts) + } +} + +// A FAILED TURN IS SORTED AND CAN BE RUN AGAIN. A provider's refusal ends the +// turn as provider with its status and its own words, and leaves an error +// entry; agents.retry sends the same words again; with nothing failed it is +// not found. +func TestAppSortsAProviderFailureAndRetriesIt(t *testing.T) { + refused := &provider.APIError{Status: 429, Message: "upstream is rate limited"} + conv := &fakeConversation{script: []session.Event{{Kind: session.EventError, Err: refused}}} + var mu sync.Mutex + var opens, releases int + rec := newAppRecorder() + backend := newAppBackend(rec, fakeOpener(conv, &opens, &releases, &mu)) + defer backend.Close() + agent, err := backend.CreateAgent(appwire.CreateAsk{Workspace: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + var wire *appwire.Error + if _, err := backend.Retry(agent.ID); !errors.As(err, &wire) || wire.Code != appwire.CodeNotFound { + t.Fatalf("retry with nothing failed = %v", err) + } + if _, err := backend.Send(agent.ID, "summarise the repo"); err != nil { + t.Fatal(err) + } + i := rec.waitFor(t, 5*time.Second, appTurnDone) + rec.mu.Lock() + done := rec.data[i].(appwire.TurnDone) + errorEntry, _ := rec.data[i-1].(appwire.EntryAdded) + rec.mu.Unlock() + if done.OK || done.ErrorKind != appwire.ErrorProvider || done.Status != 429 || done.Error != "upstream is rate limited" { + t.Fatalf("turn.done = %+v", done) + } + if e := errorEntry.Entry; e.Kind != appwire.EntryError || e.ErrorKind != appwire.ErrorProvider || e.Status != 429 || e.Text != done.Error { + t.Fatalf("the error entry = %+v", e) + } + // The next try goes through. + rec.waitFor(t, 5*time.Second, func(event string, data any) bool { + c, ok := data.(appwire.AgentChanged) + return ok && c.Agent.State == appwire.StateIdle + }) + conv.mu.Lock() + conv.script = []session.Event{{Kind: session.EventTurnDone}} + conv.mu.Unlock() + turn, err := backend.Retry(agent.ID) + if err != nil { + t.Fatal(err) + } + rec.waitFor(t, 5*time.Second, func(event string, data any) bool { + d, ok := data.(appwire.TurnDone) + return ok && d.Turn == turn && d.OK + }) + conv.mu.Lock() + texts := append([]string(nil), conv.texts...) + conv.mu.Unlock() + if len(texts) != 2 || texts[1] != "summarise the repo" { + t.Fatalf("sent %q", texts) + } + if _, err := backend.Retry(agent.ID); !errors.As(err, &wire) || wire.Code != appwire.CodeNotFound { + t.Fatalf("retry after a good turn = %v", err) + } +} + +// The error kinds a turn's failure is sorted into. +func TestAppTurnFailureKinds(t *testing.T) { + for _, c := range []struct { + err error + kind string + status int + }{ + {&provider.APIError{Status: 502, Message: "bad gateway"}, appwire.ErrorProvider, 502}, + {session.ErrSpendRail, appwire.ErrorBudget, 0}, + {context.Canceled, appwire.ErrorStopped, 0}, + {errors.New("disk full"), appwire.ErrorOther, 0}, + } { + kind, status, words := appTurnFailure(session.Event{Kind: session.EventError, Err: c.err}) + if kind != c.kind || status != c.status || words == "" { + t.Errorf("%v: kind %q status %d words %q", c.err, kind, status, words) + } + } +} + +// agents.set_model moves the conversation and says so. +func TestAppSetModelMovesTheConversation(t *testing.T) { + conv := &fakeConversation{} + var mu sync.Mutex + var opens, releases int + rec := newAppRecorder() + backend := newAppBackend(rec, fakeOpener(conv, &opens, &releases, &mu)) + defer backend.Close() + agent, err := backend.CreateAgent(appwire.CreateAsk{Workspace: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + moved, err := backend.SetModel(agent.ID, "qwen/qwen3-coder") + if err != nil || moved.Model != "qwen/qwen3-coder" { + t.Fatalf("set_model = %+v, %v", moved, err) + } + rec.waitFor(t, time.Second, func(event string, data any) bool { + c, ok := data.(appwire.AgentChanged) + return ok && c.Agent.Model == "qwen/qwen3-coder" + }) +} + +// ALWAYS WRITES A RULE THE ENGINE HONOURS. The approval names the command the +// turn announced; answering always banks that one command line in the +// person's own profile and hands the engine the rule-scoped answer, so the +// next call like it is answered by the rule. A call that cannot be undone +// offers no always at all. +func TestAppAlwaysWritesABashRule(t *testing.T) { + profile := t.TempDir() + t.Setenv(config.ProfileDirEnv, profile) + hold := make(chan struct{}) + conv := &fakeConversation{hold: hold, script: []session.Event{ + {Kind: session.EventConsentRequest, ID: 4, Tool: "bash", Args: `{"command":"go test ./internal/lexer/"}`}, + }} + var mu sync.Mutex + var opens, releases int + rec := newAppRecorder() + backend := newAppBackend(rec, fakeOpener(conv, &opens, &releases, &mu)) + defer func() { close(hold); backend.Close() }() + agent, err := backend.CreateAgent(appwire.CreateAsk{Workspace: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + if _, err := backend.Send(agent.ID, "run the lexer tests"); err != nil { + t.Fatal(err) + } + // The pump has seen the call once the turn's own event is behind it. + deadline := time.Now().Add(5 * time.Second) + for { + backend.mu.Lock() + _, seen := backend.agents[agent.ID].live.consents[4] + backend.mu.Unlock() + if seen { + break + } + if time.Now().After(deadline) { + t.Fatal("the turn's approval call was never recorded") + } + time.Sleep(5 * time.Millisecond) + } + q := session.Question{ID: 4, Kind: session.QuestionConsent, Head: "bash go test ./internal/lexer/", Options: session.AnswerOptions(session.QuestionConsent)} + conv.raise(session.Event{Kind: session.EventQuestion, Question: &q}) + rec.waitFor(t, 5*time.Second, appAskEvent(appwire.EventAskOpened)) + if err := backend.AnswerAsk(agent.ID, "consent:4", appwire.OptionAlways, ""); err != nil { + t.Fatal(err) + } + conv.mu.Lock() + remembered, answers := conv.remembered, len(conv.answers) + conv.mu.Unlock() + if len(remembered) != 1 || remembered[0] != session.ConsentRule || answers != 0 { + t.Fatalf("remembered %v, answered %d through the question door", remembered, answers) + } + if rules := config.BashApprovalsAt(profile); !strings.Contains(rules, "go test ./internal/lexer/") { + t.Fatalf("the profile's shell rules = %s", rules) + } + // The engine retires the question once the call goes on; the receipt says always. + conv.raise(session.Event{Kind: session.EventQuestionWithdrawn, Question: &q}) + rec.waitFor(t, 5*time.Second, func(event string, data any) bool { + e, ok := data.(appwire.EntryAdded) + return ok && e.Entry.Kind == appwire.EntryReceipt && strings.HasPrefix(e.Entry.Text, "Always allowed · you · ") + }) + + final := session.Question{ID: 5, Kind: session.QuestionConsent, Head: "bash rm -rf dist", Stakes: session.StakesIrreversible, Options: session.AnswerOptions(session.QuestionConsent)} + conv.raise(session.Event{Kind: session.EventQuestion, Question: &final}) + i := rec.waitFor(t, 5*time.Second, func(event string, data any) bool { + o, ok := data.(appwire.AskOpened) + return ok && o.Ask.ID == "consent:5" + }) + rec.mu.Lock() + ask := rec.data[i].(appwire.AskOpened).Ask + rec.mu.Unlock() + if !ask.Irreversible || len(ask.Options) != 2 || ask.Options[0].ID != appwire.OptionAllowOnce || ask.Options[1].ID != appwire.OptionDeny { + t.Fatalf("an irreversible ask = %+v", ask) + } +} + +// settings.get reads the profile's rows, and a figure nobody set is null. +func TestAppSettingsAndSpendReadTheProfile(t *testing.T) { + profile := t.TempDir() + t.Setenv(config.ProfileDirEnv, profile) + t.Setenv(home.EnvVar, t.TempDir()) + t.Setenv("CODEAF_DAILY_BUDGET", "") + if err := os.WriteFile(config.BudgetConfigPath(profile), []byte(`{"model.talk":"qwen/qwen3-coder","tools.approvalMode":"deny","daily_budget_usd":12}`), 0o600); err != nil { + t.Fatal(err) + } + backend := newAppBackend(newAppRecorder(), nil) + defer backend.Close() + settings, err := backend.Settings() + if err != nil { + t.Fatal(err) + } + if settings.DefaultModel != "qwen/qwen3-coder" || settings.AsksBefore != "deny" || settings.BudgetUSD != nil || settings.DayCapUSD == nil || *settings.DayCapUSD != 12 { + t.Fatalf("settings = %+v (day cap %v)", settings, settings.DayCapUSD) + } + spend, err := backend.SpendToday() + if err != nil || spend.TodayUSD != 0 || spend.DayCapUSD == nil || *spend.DayCapUSD != 12 { + t.Fatalf("spend = %+v, %v", spend, err) + } +} + +// models.list answers what the profile remembers of the service's list, and +// reaches no network when there is one. +func TestAppModelsListReadsTheRememberedList(t *testing.T) { + profile := t.TempDir() + t.Setenv(config.ProfileDirEnv, profile) + t.Setenv(home.EnvVar, t.TempDir()) + t.Setenv("CODEAF_BASE_URL", "http://127.0.0.1:1/v1") + settings, err := config.LoadKeyless() + if err != nil { + t.Fatal(err) + } + options := catalog.Options{BaseURL: settings.BaseURL, Dir: settings.ProfileDir} + if err := catalog.Remember(options, []catalog.Model{{ID: "qwen/qwen3-coder", Name: "Qwen3 Coder"}, {ID: "deepseek/deepseek-v4-flash"}}); err != nil { + t.Fatal(err) + } + backend := newAppBackend(newAppRecorder(), nil) + defer backend.Close() + models, err := backend.ListModels() + if err != nil { + t.Fatal(err) + } + byID := map[string]appwire.Model{} + for _, m := range models { + byID[m.ID] = m + } + if m := byID["qwen/qwen3-coder"]; m.Name != "Qwen3 Coder" || m.Provider != "qwen" { + t.Fatalf("models = %+v", models) + } + if m := byID["deepseek/deepseek-v4-flash"]; m.Name != "deepseek/deepseek-v4-flash" || m.Provider != "deepseek" { + t.Fatalf("models = %+v", models) + } + if backend.models != nil { + t.Fatal("a remembered list still started a fetch") + } +} + +// A REAL ALWAYS, HONOURED BY THE ENGINE. Every shell call asks first; the +// first turn's call is answered always from the app, which writes the command +// down as a rule; the second turn makes the same call and is not asked at all, +// because the engine's own gate now reads the rule. +func TestAppARealAlwaysIsHonouredOnTheNextCall(t *testing.T) { + root := resolvedTempDir(t) + _, workspace := wakeEnvironment(t, root) + model := consentSource(t, "printf app-always-probe") + t.Cleanup(model.Close) + t.Setenv("CODEAF_BASE_URL", model.URL) + state := filepath.Join(root, "state") + if err := os.MkdirAll(state, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(state, "config.json"), []byte(`{"tools.approvalMode":"prompt"}`), 0o600); err != nil { + t.Fatal(err) + } + rec := newAppRecorder() + backend := newAppBackend(rec, appOpenInProcess) + defer backend.Close() + agent, err := backend.CreateAgent(appwire.CreateAsk{Workspace: workspace}) + if err != nil { + t.Fatalf("create = %v", err) + } + first, err := backend.Send(agent.ID, "run the probe") + if err != nil { + t.Fatal(err) + } + i := rec.waitFor(t, 30*time.Second, appAskEvent(appwire.EventAskOpened)) + rec.mu.Lock() + ask := rec.data[i].(appwire.AskOpened).Ask + rec.mu.Unlock() + if ask.Subkind != appwire.SubkindPermission || len(ask.Options) != 3 || ask.Options[2].ID != appwire.OptionAlways { + t.Fatalf("ask = %+v", ask) + } + if err := backend.AnswerAsk(agent.ID, ask.ID, appwire.OptionAlways, ""); err != nil { + t.Fatalf("answer = %v", err) + } + rec.waitFor(t, 30*time.Second, func(event string, data any) bool { + c, ok := data.(appwire.AskClosed) + return ok && c.ID == ask.ID && c.By == appwire.ClosedByApp && c.Option == appwire.OptionAlways + }) + rec.waitFor(t, 30*time.Second, func(event string, data any) bool { + d, ok := data.(appwire.TurnDone) + return ok && d.Turn == first && d.OK + }) + if rules := config.BashApprovalsAt(config.ProfileDir()); !strings.Contains(rules, "printf app-always-probe") { + t.Fatalf("the profile's shell rules = %s", rules) + } + rec.waitFor(t, 30*time.Second, func(event string, data any) bool { + c, ok := data.(appwire.AgentChanged) + return ok && c.Agent.State == appwire.StateIdle + }) + + second, err := backend.Send(agent.ID, "run it again") + if err != nil { + t.Fatal(err) + } + d := rec.waitFor(t, 30*time.Second, func(event string, data any) bool { + done, ok := data.(appwire.TurnDone) + return ok && done.Turn == second + }) + rec.mu.Lock() + defer rec.mu.Unlock() + if done := rec.data[d].(appwire.TurnDone); !done.OK { + t.Fatalf("second turn = %+v", done) + } + asked, ran := 0, false + for j := i + 1; j <= d; j++ { + if _, ok := rec.data[j].(appwire.AskOpened); ok { + asked++ + } + if tool, ok := rec.data[j].(appwire.TurnTool); ok && tool.Turn == second && tool.Phase == appwire.PhaseEnd && tool.Name == "bash" && tool.OK != nil && *tool.OK { + ran = true + } + } + if asked != 0 || !ran { + t.Fatalf("after always: asked %d more times, the call ran = %v", asked, ran) + } +} + +// AN APP AGENT KEEPS THE NAME ITS FIRST MESSAGE GAVE IT. A real conversation +// created with words is named by them at once, the engine's own naming does +// not rename it once the turn is answered, and the disk says the same name +// after the conversation is let go. +func TestAppARealAgentKeepsItsFirstMessageName(t *testing.T) { + root := resolvedTempDir(t) + _, workspace := wakeEnvironment(t, root) + rec := newAppRecorder() + backend := newAppBackend(rec, appOpenInProcess) + words := "say done and then tell me about the weather in the harbour today" + agent, err := backend.CreateAgent(appwire.CreateAsk{Workspace: workspace, Text: words}) + if err != nil { + backend.Close() + t.Fatalf("create = %v", err) + } + want := session.FirstMessageName(words) + if agent.Title != want { + backend.Close() + t.Fatalf("title = %q, want %q", agent.Title, want) + } + rec.waitFor(t, 30*time.Second, appTurnDone) + if err := backend.Close(); err != nil { + t.Fatal(err) + } + rec.mu.Lock() + for j, event := range rec.events { + if c, ok := rec.data[j].(appwire.AgentChanged); ok && event == appwire.EventAgentChanged && c.Agent.Title != want { + rec.mu.Unlock() + t.Fatalf("renamed to %q", c.Agent.Title) + } + } + rec.mu.Unlock() + fresh := newAppBackend(newAppRecorder(), nil) + defer fresh.Close() + agents, err := fresh.ListAgents(workspace) + if err != nil || len(agents) != 1 || agents[0].Title != want { + t.Fatalf("listed = %+v, %v", agents, err) + } +} diff --git a/cmd/codeaf/engine.go b/cmd/codeaf/engine.go index 789a71a644..19b1e2d4e4 100644 --- a/cmd/codeaf/engine.go +++ b/cmd/codeaf/engine.go @@ -1040,6 +1040,10 @@ func assembleEngine(hello remote.Hello, workspace, sessionFlag, launchDir string // The builder came out of the shape above with the adaptive runner already // decided: "runs are on" and "build it through [v3OpenSession]" are one fact // (chatv3_lanes.go says why they cannot be two). + // The desktop app names its conversations by rule ([session.FirstMessageName]). + if hello.Launch != nil { + cfg.DeterministicNames = hello.Launch.DeterministicNames + } agent, cfg, notice, err := openV3Agent(cfg, workspace, open) if err != nil { return nil, err diff --git a/docs/changes/unreleased/0-app-wave1.md b/docs/changes/unreleased/0-app-wave1.md new file mode 100644 index 0000000000..a2c0997f4a --- /dev/null +++ b/docs/changes/unreleased/0-app-wave1.md @@ -0,0 +1,25 @@ +--- +kind: added +title: codeaf app starts agents from a first message, reads transcripts, retries, and saves always rules +pr: 0 +surface: [engine] +invalidates: + - "codeaf app's agents.create took only a workspace and a model. It now takes {workspace?, model?, budget_usd?, text?}: no workspace is the home folder, a budget is the conversation's own spend limit, and words start the first turn at once and name the agent by its first forty characters with an ellipsis." + - "An app agent was renamed by the engine's generated title after its first turn. A conversation launched with deterministic names keeps the name its first message gave it, on disk as well as on the wire." + - "A permission ask offered the engine's digit keys. It now offers allow_once, deny and always (no always on a call that cannot be undone), carries subkind permission and irreversible, and an always on a shell call writes that one command line as an allow rule the engine reads before the call goes on." + - "turn.done said only ok and an error string. It now sorts a failure as provider, budget, stopped or other, with the provider's status and its own words, and agents.retry sends the failed turn's words again." +--- + +New methods: `agents.entries` (the transcript off disk, oldest first, paged +with `before`/`limit`, for agents nothing holds), `agents.set_model`, +`agents.retry`, `models.list` (the remembered list; with none, one lazy fetch +starts behind the answer), `settings.get` and `spend.today`. New events: +`entry.added` (the person's message, each answer, notices, errors and the +receipt after every closed ask, e.g. "Allowed once · you · 14:02") and +`spend.changed` after a turn's spending reaches the ledger. `ask.closed` now +carries the option and the moment. + +The engine raises no budget question, so the `budget` subkind is never sent +yet. Transcript entries carry the conversation's last moment rather than their +own, and no thinking, errors or receipts, because the journal keeps none of +those per message; entries said live are named apart from listed ones. diff --git a/internal/appwire/server.go b/internal/appwire/server.go index 518a92b758..6621c9c45b 100644 --- a/internal/appwire/server.go +++ b/internal/appwire/server.go @@ -17,6 +17,10 @@ import ( // an [Outbox], whose sender is the one that waits here. const writerQueue = 1024 +// MaxEntries is the most transcript entries one agents.entries answers, and +// what a request that names no limit gets. +const MaxEntries = 200 + // handlerGrace is how long a leaving process waits for calls still in flight // to answer after the engine has been closed under them. A call that has not // answered by then is abandoned; the process is exiting either way. @@ -274,22 +278,103 @@ var methods = map[string]method{ }{nonNil(agents)}, nil }, "agents.create": func(e Engine, raw json.RawMessage) (any, error) { + var p CreateAsk + if err := decode(raw, &p); err != nil { + return nil, err + } + if p.BudgetUSD != nil && *p.BudgetUSD < 0 { + return nil, Errorf(CodeBadParams, "budget_usd cannot be negative") + } + agent, err := e.CreateAgent(p) + if err != nil { + return nil, err + } + return agentResult{agent}, nil + }, + "agents.entries": func(e Engine, raw json.RawMessage) (any, error) { var p struct { - Workspace string `json:"workspace"` - Model string `json:"model"` + ID string `json:"id"` + Before string `json:"before"` + Limit int `json:"limit"` + } + if err := decode(raw, &p); err != nil { + return nil, err + } + if strings.TrimSpace(p.ID) == "" { + return nil, Errorf(CodeBadParams, "agents.entries needs an id") + } + if p.Limit < 0 { + return nil, Errorf(CodeBadParams, "limit cannot be negative") + } + if p.Limit == 0 || p.Limit > MaxEntries { + p.Limit = MaxEntries + } + entries, more, err := e.Entries(p.ID, p.Before, p.Limit) + if err != nil { + return nil, err + } + return struct { + Entries []Entry `json:"entries"` + More bool `json:"more"` + }{nonNil(entries), more}, nil + }, + "agents.set_model": func(e Engine, raw json.RawMessage) (any, error) { + var p struct { + ID string `json:"id"` + Model string `json:"model"` } if err := decode(raw, &p); err != nil { return nil, err } - if strings.TrimSpace(p.Workspace) == "" { - return nil, Errorf(CodeBadParams, "agents.create needs a workspace") + if strings.TrimSpace(p.ID) == "" || strings.TrimSpace(p.Model) == "" { + return nil, Errorf(CodeBadParams, "agents.set_model needs an id and a model") } - agent, err := e.CreateAgent(p.Workspace, p.Model) + agent, err := e.SetModel(p.ID, strings.TrimSpace(p.Model)) if err != nil { return nil, err } return agentResult{agent}, nil }, + "agents.retry": func(e Engine, raw json.RawMessage) (any, error) { + id, err := decodeID(raw, "id") + if err != nil { + return nil, err + } + turn, err := e.Retry(id) + if err != nil { + return nil, err + } + return struct { + Turn uint64 `json:"turn"` + }{turn}, nil + }, + "models.list": func(e Engine, _ json.RawMessage) (any, error) { + models, err := e.ListModels() + if err != nil { + return nil, err + } + return struct { + Models []Model `json:"models"` + }{nonNil(models)}, nil + }, + "settings.get": func(e Engine, _ json.RawMessage) (any, error) { + settings, err := e.Settings() + if err != nil { + return nil, err + } + return struct { + Settings Settings `json:"settings"` + }{settings}, nil + }, + "spend.today": func(e Engine, _ json.RawMessage) (any, error) { + spend, err := e.SpendToday() + if err != nil { + return nil, err + } + return struct { + Spend Spend `json:"spend"` + }{spend}, nil + }, "agents.open": func(e Engine, raw json.RawMessage) (any, error) { id, err := decodeID(raw, "id") if err != nil { diff --git a/internal/appwire/server_test.go b/internal/appwire/server_test.go index 47ac74ee3e..5726923aa0 100644 --- a/internal/appwire/server_test.go +++ b/internal/appwire/server_test.go @@ -14,9 +14,11 @@ import ( // fakeEngine answers every method from fixed values and records closing. type fakeEngine struct { - mu sync.Mutex - closed int - block chan struct{} + mu sync.Mutex + closed int + block chan struct{} + created CreateAsk + limit int } func (f *fakeEngine) ListAgents(workspace string) ([]Agent, error) { @@ -25,8 +27,34 @@ func (f *fakeEngine) ListAgents(workspace string) ([]Agent, error) { } return []Agent{{ID: "a1", Title: "one", Workspace: "/w", State: StateIdle}}, nil } -func (f *fakeEngine) CreateAgent(workspace, model string) (Agent, error) { - return Agent{ID: "new", Workspace: workspace, Model: model, State: StateIdle}, nil +func (f *fakeEngine) CreateAgent(ask CreateAsk) (Agent, error) { + f.mu.Lock() + f.created = ask + f.mu.Unlock() + return Agent{ID: "new", Workspace: ask.Workspace, Model: ask.Model, State: StateIdle}, nil +} +func (f *fakeEngine) Entries(id, before string, limit int) ([]Entry, bool, error) { + f.mu.Lock() + f.limit = limit + f.mu.Unlock() + if id != "a1" { + return nil, false, Errorf(CodeNotFound, "no agent %s", id) + } + return []Entry{{ID: "e0", Agent: id, Kind: EntryUser, Text: "hi"}}, true, nil +} +func (f *fakeEngine) SetModel(id, model string) (Agent, error) { + return Agent{ID: id, Model: model, State: StateIdle}, nil +} +func (f *fakeEngine) Retry(id string) (uint64, error) { + return 0, Errorf(CodeNotFound, "nothing failed") +} +func (f *fakeEngine) ListModels() ([]Model, error) { return nil, nil } +func (f *fakeEngine) Settings() (Settings, error) { + return Settings{DefaultModel: "deepseek/deepseek-v4-flash", AsksBefore: "prompt"}, nil +} +func (f *fakeEngine) SpendToday() (Spend, error) { + cap := 5.0 + return Spend{TodayUSD: 1.25, DayCapUSD: &cap}, nil } func (f *fakeEngine) OpenAgent(id string) (Agent, error) { if id != "a1" { @@ -387,3 +415,72 @@ func TestOutboxSendsALoneDeltaAfterTheWindowAndAFullOneAtOnce(t *testing.T) { t.Fatal("a full delta was folded past its size") } } + +// THE WAVE-ONE METHODS ON THE WIRE. agents.create passes the whole ask and +// refuses a negative budget; agents.entries caps the page and answers a list +// and whether there is more; models.list answers an empty list rather than +// null; settings.get says a figure nobody set as null; spend.today carries +// the cap; agents.retry with nothing failed is not found. +func TestWaveOneMethodsAnswerTheirShapes(t *testing.T) { + engine := &fakeEngine{} + w := startWire(t, engine) + w.next() + + w.send(`{"id":1,"method":"agents.create","params":{"text":"fix the parser","budget_usd":2.5}}`) + got := w.next() + if got["id"] != float64(1) || got["result"] == nil { + t.Fatalf("agents.create = %v", got) + } + engine.mu.Lock() + created := engine.created + engine.mu.Unlock() + if created.Workspace != "" || created.Text != "fix the parser" || created.BudgetUSD == nil || *created.BudgetUSD != 2.5 { + t.Fatalf("the engine was asked %+v", created) + } + w.send(`{"id":2,"method":"agents.create","params":{"budget_usd":-1}}`) + if got := w.next(); got["error"].(map[string]any)["code"] != CodeBadParams { + t.Fatalf("negative budget = %v", got) + } + + w.send(`{"id":3,"method":"agents.entries","params":{"id":"a1","limit":5000}}`) + got = w.next() + result := got["result"].(map[string]any) + if entries := result["entries"].([]any); len(entries) != 1 || result["more"] != true { + t.Fatalf("agents.entries = %v", got) + } + engine.mu.Lock() + limit := engine.limit + engine.mu.Unlock() + if limit != MaxEntries { + t.Fatalf("limit = %d, want the cap %d", limit, MaxEntries) + } + w.send(`{"id":4,"method":"agents.entries","params":{}}`) + if got := w.next(); got["error"].(map[string]any)["code"] != CodeBadParams { + t.Fatalf("entries without an id = %v", got) + } + + w.send(`{"id":5,"method":"models.list"}`) + if got := w.next(); got["result"].(map[string]any)["models"] == nil { + t.Fatalf("models.list = %v", got) + } + w.send(`{"id":6,"method":"settings.get"}`) + got = w.next() + settings := got["result"].(map[string]any)["settings"].(map[string]any) + if value, present := settings["budget_usd"]; !present || value != nil || settings["asks_before"] != "prompt" { + t.Fatalf("settings.get = %v", got) + } + w.send(`{"id":7,"method":"spend.today"}`) + got = w.next() + spend := got["result"].(map[string]any)["spend"].(map[string]any) + if spend["today_usd"] != 1.25 || spend["day_cap_usd"] != 5.0 { + t.Fatalf("spend.today = %v", got) + } + w.send(`{"id":8,"method":"agents.retry","params":{"id":"a1"}}`) + if got := w.next(); got["error"].(map[string]any)["code"] != CodeNotFound { + t.Fatalf("agents.retry = %v", got) + } + w.send(`{"id":9,"method":"agents.set_model","params":{"id":"a1"}}`) + if got := w.next(); got["error"].(map[string]any)["code"] != CodeBadParams { + t.Fatalf("set_model without a model = %v", got) + } +} diff --git a/internal/appwire/wire.go b/internal/appwire/wire.go index a01fee7c19..f8ee7ccc26 100644 --- a/internal/appwire/wire.go +++ b/internal/appwire/wire.go @@ -63,6 +63,8 @@ const ( EventTurnDone = "turn.done" EventAskOpened = "ask.opened" EventAskClosed = "ask.closed" + EventEntryAdded = "entry.added" + EventSpendChanged = "spend.changed" EventTaskChanged = "task.changed" EventCrewChanged = "crew.changed" EventCrewRemoved = "crew.removed" @@ -134,12 +136,88 @@ const ( // to be allowed, or a question the agent put. Options are the engine's own // choices, keyed by the id an answer names. type Ask struct { - ID string `json:"id"` - Agent string `json:"agent"` - Kind string `json:"kind"` - Title string `json:"title"` - Detail string `json:"detail,omitempty"` - Options []AskOption `json:"options,omitempty"` + ID string `json:"id"` + Agent string `json:"agent"` + Kind string `json:"kind"` + Subkind string `json:"subkind,omitempty"` + Title string `json:"title"` + Detail string `json:"detail,omitempty"` + // Options carry the engine's own ids; for a permission they are + // allow_once, deny and, unless the call is irreversible, always. + Options []AskOption `json:"options,omitempty"` + Irreversible bool `json:"irreversible,omitempty"` +} + +// The subkinds of an approval, and the option ids a permission offers. +const ( + SubkindPermission = "permission" + SubkindBudget = "budget" + + OptionAllowOnce = "allow_once" + OptionDeny = "deny" + OptionAlways = "always" +) + +// Entry is one item of a conversation's transcript, in order. Which of the +// optional fields are set depends on Kind. +type Entry struct { + ID string `json:"id"` + Agent string `json:"agent"` + Turn uint64 `json:"turn,omitempty"` + Kind string `json:"kind"` + At time.Time `json:"at"` + // Text is the words of a user, answer, receipt, notice or error entry. + Text string `json:"text,omitempty"` + Thinking string `json:"thinking,omitempty"` + // Call, Name, OK and Summary describe a tool entry. + Call string `json:"call,omitempty"` + Name string `json:"name,omitempty"` + OK *bool `json:"ok,omitempty"` + Summary string `json:"summary,omitempty"` + // Ref is what a notice points at; ErrorKind and Status sort an error. + Ref string `json:"ref,omitempty"` + ErrorKind string `json:"error_kind,omitempty"` + Status int `json:"status,omitempty"` +} + +// The kinds of entry. +const ( + EntryUser = "user" + EntryAnswer = "answer" + EntryTool = "tool" + EntryReceipt = "receipt" + EntryNotice = "notice" + EntryError = "error" +) + +// The kinds a turn's failure is sorted into. +const ( + ErrorProvider = "provider" + ErrorBudget = "budget" + ErrorStopped = "stopped" + ErrorOther = "other" +) + +// Model is one model the configured keys can use. +type Model struct { + ID string `json:"id"` + Name string `json:"name"` + Provider string `json:"provider"` +} + +// Settings are the few defaults the app shows. A figure nobody set is null. +type Settings struct { + DefaultModel string `json:"default_model"` + BudgetUSD *float64 `json:"budget_usd"` + AsksBefore string `json:"asks_before"` + DayCapUSD *float64 `json:"day_cap_usd"` +} + +// Spend is today's spending on this machine against the daily cap, which is +// null when there is none. +type Spend struct { + TodayUSD float64 `json:"today_usd"` + DayCapUSD *float64 `json:"day_cap_usd"` } // AskOption is one answer an ask offers. @@ -180,18 +258,28 @@ type ( Summary string `json:"summary,omitempty"` } TurnDone struct { - Agent string `json:"agent"` - Turn uint64 `json:"turn"` - OK bool `json:"ok"` - Error string `json:"error,omitempty"` + Agent string `json:"agent"` + Turn uint64 `json:"turn"` + OK bool `json:"ok"` + Error string `json:"error,omitempty"` + ErrorKind string `json:"error_kind,omitempty"` + Status int `json:"status,omitempty"` } AskOpened struct { Ask Ask `json:"ask"` } AskClosed struct { - Agent string `json:"agent"` - ID string `json:"id"` - By string `json:"by"` + Agent string `json:"agent"` + ID string `json:"id"` + By string `json:"by"` + Option string `json:"option,omitempty"` + At time.Time `json:"at"` + } + EntryAdded struct { + Entry Entry `json:"entry"` + } + SpendChanged struct { + Spend Spend `json:"spend"` } TaskChanged struct { Task Task `json:"task"` @@ -232,7 +320,13 @@ type Emitter interface { // result or an error ([Error] for a code the app can act on). type Engine interface { ListAgents(workspace string) ([]Agent, error) - CreateAgent(workspace, model string) (Agent, error) + CreateAgent(ask CreateAsk) (Agent, error) + // Entries answers up to limit transcript entries before the one named + // (the newest when before is empty), oldest first, and whether there are + // older ones still. It reads the disk for an agent nothing holds. + Entries(id, before string, limit int) ([]Entry, bool, error) + SetModel(id, model string) (Agent, error) + Retry(id string) (uint64, error) OpenAgent(id string) (Agent, error) Send(id, text string) (uint64, error) Stop(id string) error @@ -243,11 +337,23 @@ type Engine interface { AnswerAsk(agent, id, option, text string) error ListTasks(agent string) ([]Task, error) ListCrews() ([]Crew, error) + ListModels() ([]Model, error) + Settings() (Settings, error) + SpendToday() (Spend, error) // Close releases every conversation the engine holds. It is called once, // when the process is leaving, and returns when they are all let go. Close() error } +// CreateAsk is agents.create's params. An empty workspace is the person's home +// folder; a non-empty Text starts the first turn at once. +type CreateAsk struct { + Workspace string `json:"workspace"` + Model string `json:"model"` + BudgetUSD *float64 `json:"budget_usd"` + Text string `json:"text"` +} + // frame is one line on the way out: a response (ID set) or an event (Event // set). The event's sequence number is assigned by the writer, at the moment // it is written, so the numbers are in the order the app reads them. diff --git a/internal/manual/chat/running-from-the-terminal.md b/internal/manual/chat/running-from-the-terminal.md index b4ff51ae6a..b6a783241d 100644 --- a/internal/manual/chat/running-from-the-terminal.md +++ b/internal/manual/chat/running-from-the-terminal.md @@ -296,10 +296,8 @@ critical-path` — against the run's own `plandb.db`, so a plan a worker is driv read the way the worker reads it. Five more exist and are deliberately kept out of the help text, because nothing types -them by hand: **`codeaf app`** is the backend the codeaf desktop app starts and speaks to -over its own pipes, holding every agent the app opens in that one process — the app -answers approvals and questions through it, and a dead conversation shows there as stopped -until the next message reopens it; +them by hand: **`codeaf app`** is the backend the codeaf desktop app starts and talks to +over its own pipes, holding every agent the app shows in that one process; **`codeaf engine`** is the far half of `chat --host`, started by ssh, and **`codeaf clock`** is the one process that runs your automations while a codeaf window is open — a window starts it, and it leaves by itself half a minute after the last window diff --git a/internal/remote/wire.go b/internal/remote/wire.go index 3e475fef92..9d02ef746d 100644 --- a/internal/remote/wire.go +++ b/internal/remote/wire.go @@ -800,6 +800,10 @@ const ( // how a surface tells "the engine built what I asked for" from "I joined // somebody else's conversation". type LaunchShape struct { + // DeterministicNames names the conversation from its first message by rule + // rather than by a model ([session.FirstMessageName]); the desktop app sets it. + DeterministicNames bool `json:"deterministicNames,omitempty"` + Yolo bool `json:"yolo,omitempty"` NoCompact bool `json:"noCompact,omitempty"` OneModel bool `json:"oneModel,omitempty"` @@ -1904,6 +1908,11 @@ func WireEvent(ev session.Event) EventWire { w := EventWire{Event: ev} if ev.Err != nil { w.Err = ev.Err.Error() + // The typed cause does not survive JSON, so its sorting rides beside + // the sentence for a surface that has to tell a refusal from a stop. + if ev.Kind == session.EventError && w.Event.ErrKind == "" { + w.Event.ErrKind, w.Event.ErrStatus, w.Event.ErrWords = session.ClassifyTurnError(ev.Err) + } } w.Event.Err = nil return w diff --git a/internal/session/appnames.go b/internal/session/appnames.go new file mode 100644 index 0000000000..f749409cc6 --- /dev/null +++ b/internal/session/appnames.go @@ -0,0 +1,44 @@ +package session + +import ( + "strings" +) + +// AppNameLimit is how many characters of the first message a deterministic +// name keeps before it is cut and marked with an ellipsis. +const AppNameLimit = 40 + +// FirstMessageName is the desktop app's naming rule: the first message with its +// whitespace folded, kept whole when it fits in [AppNameLimit] characters and +// otherwise cut at the last word boundary inside the limit (or at the limit, +// for one long word) and ended with "…". It is a rule rather than a model's +// summary so the app can show the same name the moment the message is sent. +func FirstMessageName(text string) string { + folded := []rune(strings.Join(strings.Fields(text), " ")) + if len(folded) <= AppNameLimit { + return string(folded) + } + cut := folded[:AppNameLimit] + if at := strings.LastIndex(string(cut), " "); at > 0 { + cut = []rune(string(cut)[:at]) + } + return strings.TrimSpace(string(cut)) + "…" +} + +// savedTitle is the name a journal's title line carries: a literal name kept +// exactly, a generated label healed ([healedTitle]). +func (entry sessionEntry) savedTitle() string { + if entry.LiteralName { + return strings.TrimSpace(entry.Title) + } + return healedTitle(entry.Title) +} + +// stampLiteralName writes a rule's name onto the folder's row, marked literal +// so a later read keeps it as written. +func (a *Agent) stampLiteralName(name string) { + dir, snapshot := a.metaSnapshotAt() + a.updateMeta(dir, snapshot, func(meta *Meta) { + meta.Title, meta.ShortTitle, meta.LiteralName = name, "", true + }) +} diff --git a/internal/session/appnames_test.go b/internal/session/appnames_test.go new file mode 100644 index 0000000000..c0de141da4 --- /dev/null +++ b/internal/session/appnames_test.go @@ -0,0 +1,20 @@ +package session + +import "testing" + +// The app's naming rule: whitespace folded, a short message kept whole, a long +// one cut at the last word inside the limit and marked, one long word cut at +// the limit. +func TestFirstMessageName(t *testing.T) { + for _, c := range []struct{ in, want string }{ + {" fix the\nparser ", "fix the parser"}, + {"Please fix the flaky parser test in the lexer package", "Please fix the flaky parser test in the…"}, + {"0123456789012345678901234567890123456789", "0123456789012345678901234567890123456789"}, + {"01234567890123456789012345678901234567890123", "0123456789012345678901234567890123456789…"}, + {"", ""}, + } { + if got := FirstMessageName(c.in); got != c.want { + t.Errorf("FirstMessageName(%q) = %q, want %q", c.in, got, c.want) + } + } +} diff --git a/internal/session/peek.go b/internal/session/peek.go index d039268b16..f4554287dc 100644 --- a/internal/session/peek.go +++ b/internal/session/peek.go @@ -103,7 +103,7 @@ func Peek(path string) (Summary, bool) { // is a name that was changed — and a name that is the namer's own // instruction is read as no name at all ([healedTitle], title.go), // so the row falls back to Opening like any unnamed session's. - if named := healedTitle(entry.Title); named != "" { + if named := entry.savedTitle(); named != "" { summary.Title = named } case "message": diff --git a/internal/session/place.go b/internal/session/place.go index 9988d4b93b..634a9a2fe5 100644 --- a/internal/session/place.go +++ b/internal/session/place.go @@ -185,8 +185,11 @@ type Meta struct { ID string `json:"id"` // Title is what a picker row says. Empty until something names the // session; an empty title marks a session the launch groom may reuse. - Title string `json:"title,omitempty"` - ShortTitle string `json:"shortTitle,omitempty"` // Deprecated: accepted for old records; never used as a name. + Title string `json:"title,omitempty"` + // LiteralName distinguishes a rule's or a person's exact name from a + // generated label, which is healed on read and this is not. + LiteralName bool `json:"literalName,omitempty"` + ShortTitle string `json:"shortTitle,omitempty"` // Deprecated: accepted for old records; never used as a name. // Workspace is the REAL workspace path — the resolved git root for a // borrowed session, the work/ directory for an owned one. The encoded // bucket directory above the session folder is derived from it and is @@ -307,10 +310,12 @@ func LoadMeta(dir string) (Meta, error) { // before the name arrived. Only a folder that actually holds one of these // pays for that read ([openingPlaceholder]), and the next thing said in the // session stamps the words back onto the file ([Agent.stampUserLocked]). - if strings.TrimSpace(meta.Title) != "" && healedTitle(meta.Title) == "" { + if !meta.LiteralName && strings.TrimSpace(meta.Title) != "" && healedTitle(meta.Title) == "" { meta.Title = openingPlaceholder(dir) } - meta.Title = healedTitle(meta.Title) + if !meta.LiteralName { + meta.Title = healedTitle(meta.Title) + } meta.ShortTitle = "" // Old tab labels no longer participate in naming. return meta, nil } diff --git a/internal/session/session.go b/internal/session/session.go index 6fcb9ca968..c7eedca95e 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -691,6 +691,13 @@ type Event struct { Tool string Hint string Err error + // ErrKind, ErrStatus and ErrWords sort an EventError for a reader on the + // far side of a wire, where the typed cause cannot travel and only Err's + // sentence does ([ClassifyTurnError]). They are empty on every other kind + // and on an event that never crossed a wire. + ErrKind string `json:"ErrKind,omitempty"` + ErrStatus int `json:"ErrStatus,omitempty"` + ErrWords string `json:"ErrWords,omitempty"` // Discard says an EventError ended a cut attempt whose streamed text was // never a reply. The surface withdraws that attempt before drawing the // error, as it does for EventRetrying, without counting another retry. @@ -1205,6 +1212,10 @@ type TaskLanding struct { } type Config struct { + // DeterministicNames names the conversation from its first message by rule + // ([FirstMessageName]) instead of asking a model, for the desktop app. + DeterministicNames bool + // WaitForBeltSteps is for the run worker that enforces its limits and // delivers notes from tool-end events. Its sole event reader must close // Event.BeltStepHandled after processing each such event. Other agents diff --git a/internal/session/sessionfile.go b/internal/session/sessionfile.go index 4240470154..cf50843304 100644 --- a/internal/session/sessionfile.go +++ b/internal/session/sessionfile.go @@ -266,8 +266,11 @@ type sessionEntry struct { // created, and the name is not known until the first turn has been // answered. A line is also how a name can be rewritten later without any // reader having to rewrite the file: the replay takes the LAST title line. - Title string `json:"title,omitempty"` - ShortTitle string `json:"shortTitle,omitempty"` // Deprecated: accepted for old records; never used as a name. + Title string `json:"title,omitempty"` + // LiteralName says the title is a rule's or a person's exact words rather + // than a generated label, so the replay keeps it verbatim (appnames.go). + LiteralName bool `json:"literalName,omitempty"` + ShortTitle string `json:"shortTitle,omitempty"` // Deprecated: accepted for old records; never used as a name. // Usage is what one COMPLETED turn — or one auxiliary call beside it — cost, // and it is on its own line rather than on the assistant message that ended @@ -2193,7 +2196,7 @@ func readJournal(reader io.Reader, path string, rebuild bool) (replayedSession, // is the namer's own instruction is read as NO name ([healedTitle], // title.go), which is what heals the sessions that were already // written down under one. - if named := healedTitle(entry.Title); named != "" { + if named := entry.savedTitle(); named != "" { title = named shortTitle = "" } @@ -2903,6 +2906,12 @@ func (s *sessionFile) appendRewind(dropped int) { // other: a later name simply lands after this one, and the replay takes the // last. Nothing rewrites the file. func (s *sessionFile) appendTitle(title, _ string) { + s.appendNamedTitle(title, false) +} + +// appendNamedTitle is [sessionFile.appendTitle] with the literal mark: a name +// the replay must keep exactly as written rather than heal like a label. +func (s *sessionFile) appendNamedTitle(title string, literal bool) { title = strings.TrimSpace(title) if title == "" { return @@ -2910,7 +2919,7 @@ func (s *sessionFile) appendTitle(title, _ string) { s.mu.Lock() s.title, s.shortTitle = title, "" s.mu.Unlock() - s.writeLine(sessionEntry{Type: "title", Title: title, Timestamp: stamp()}) + s.writeLine(sessionEntry{Type: "title", Title: title, LiteralName: literal, Timestamp: stamp()}) } // appendUsage journals what one seal cost: the turn's own figures, or one diff --git a/internal/session/title.go b/internal/session/title.go index a6f2cf31b6..01e256104b 100644 --- a/internal/session/title.go +++ b/internal/session/title.go @@ -172,6 +172,18 @@ func (a *Agent) startTitleLocked() { // tax on every turn. a.titleTried = true ctx := a.titleCtx + // A CONVERSATION NAMED BY RULE IS NAMED FROM ITS FIRST MESSAGE, with no + // model asked: the desktop app shows a name the moment a person sends + // (appnames.go), and a generated label arriving later would rename it. + if a.config.DeterministicNames { + name := FirstMessageName(question) + a.titleJobs.Add(1) + go func() { + defer a.titleJobs.Done() + a.publishTitle(ctx, conversationTitle{full: name}) + }() + return + } model := a.model a.titleJobs.Add(1) go func() { @@ -370,7 +382,9 @@ func (a *Agent) publishTitle(ctx context.Context, title conversationTitle) { return } // The title the handle is read off exists now (handlepick.go). - a.chooseTeamHandlesLater(title.full, nil) + if !a.config.DeterministicNames { + a.chooseTeamHandlesLater(title.full, nil) + } event := Event{Kind: EventTitleChanged, Text: title.full} a.mu.Lock() hub := a.hub @@ -486,12 +500,16 @@ func (a *Agent) setTitleIfUnnamed(title string, _ ...string) bool { file := a.file a.mu.Unlock() if file != nil { - file.appendTitle(title, "") + file.appendNamedTitle(title, a.config.DeterministicNames) } // The folder's row says what the journal says. Until now it has carried the // person's opening words as a placeholder (placemeta.go); this is the name // the conversation actually earned. - a.stampTitle(title) + if a.config.DeterministicNames { + a.stampLiteralName(title) + } else { + a.stampTitle(title) + } return true } diff --git a/internal/session/turnerror.go b/internal/session/turnerror.go new file mode 100644 index 0000000000..96bd213733 --- /dev/null +++ b/internal/session/turnerror.go @@ -0,0 +1,47 @@ +package session + +import ( + "context" + "errors" + "strings" + + "github.com/Agent-Field/codeaf/internal/provider" +) + +// The kinds a turn's ending error is sorted into for a surface that reads it +// across a wire ([ClassifyTurnError]). +const ( + // TurnErrorProvider is the model's service refusing after the engine's + // retry ladder gave up on it; the status and words are the service's own. + TurnErrorProvider = "provider" + // TurnErrorBudget is the conversation's spend rail reached ([ErrSpendRail]). + TurnErrorBudget = "budget" + // TurnErrorStopped is a turn that ended because it was cancelled. + TurnErrorStopped = "stopped" + // TurnErrorOther is anything else. + TurnErrorOther = "other" +) + +// ClassifyTurnError sorts the error an EventError carries. For a provider +// refusal it answers the HTTP status and the provider's own words: the +// upstream's raw answer when the router passed one on, its message otherwise. +func ClassifyTurnError(err error) (kind string, status int, words string) { + if err == nil { + return "", 0, "" + } + switch { + case errors.Is(err, ErrSpendRail): + return TurnErrorBudget, 0, "" + case errors.Is(err, context.Canceled): + return TurnErrorStopped, 0, "" + } + var refused *provider.APIError + if errors.As(err, &refused) { + words = strings.TrimSpace(refused.Raw) + if words == "" { + words = strings.TrimSpace(refused.Message) + } + return TurnErrorProvider, refused.Status, words + } + return TurnErrorOther, 0, "" +} From 03c7b994a5faf9686b8b81ede2e94b74904c727f Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Thu, 15 Oct 2026 06:38:22 -0400 Subject: [PATCH 004/459] codeaf app rev 2: instant create, client ids, message index, stable entry ids, tool titles, quiet notices, picker models, set_effort agents.create mints the conversation folder and answers at once; the conversation is assembled behind the answer and a failure arrives as a failed turn and a stopped agent. The engine's process half is warmed after hello. The person's entry echoes client_id; deltas and answer entries carry msg; entry ids are counted from the transcript the same way live and in agents.entries. Tool rows carry the terminal's own line for the call, engine notices are quiet, models.list is the terminal picker's list with the default marked, and agents.set_effort sets the reasoning level. --- cmd/codeaf/app.go | 496 +++++++++++++++++++++++++------- cmd/codeaf/app_test.go | 381 ++++++++++++++++++++---- internal/appwire/outbox.go | 8 +- internal/appwire/server.go | 33 ++- internal/appwire/server_test.go | 46 ++- internal/appwire/wire.go | 52 +++- internal/tui3/appseams.go | 51 ++++ 7 files changed, 880 insertions(+), 187 deletions(-) create mode 100644 internal/tui3/appseams.go diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index 3cf5bfcbee..d992e4e359 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -44,7 +44,6 @@ import ( "time" "github.com/Agent-Field/codeaf/internal/appwire" - "github.com/Agent-Field/codeaf/internal/catalog" "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/enginehost" "github.com/Agent-Field/codeaf/internal/guard" @@ -53,6 +52,7 @@ import ( "github.com/Agent-Field/codeaf/internal/remote" "github.com/Agent-Field/codeaf/internal/session" teamstore "github.com/Agent-Field/codeaf/internal/teams" + "github.com/Agent-Field/codeaf/internal/tui3" ) // appAgentIdle is how long a conversation the app holds may sit with no turn @@ -130,15 +130,20 @@ type appConversation interface { // ResolveConsentRemember answers one approval and says how long the answer // lasts; the app uses it for an always that wrote a rule first. ResolveConsentRemember(id uint64, allow bool, scope session.ConsentScope) + // How hard the model is asked to think, per model. + ReasoningFor(model string) string + SetReasoningFor(model, level string) } // appOpenAsk is which conversation to open: a saved one by transcript, or a -// new one in a workspace. +// new one in a workspace. Minted is a folder this process minted a moment ago +// for agents.create: nobody else can be holding it, so it is never joined. type appOpenAsk struct { Workspace string Transcript string Model string New bool + Minted bool } // appConn is an opened conversation and the one way to give it back. @@ -159,7 +164,7 @@ type appOpener func(appOpenAsk) (*appConn, error) // conversation, because opening it here as well would be a second writer on its // journal. Everything else is built in this process. func appOpenInProcess(ask appOpenAsk) (*appConn, error) { - if ask.Transcript != "" { + if ask.Transcript != "" && !ask.Minted { if socket, err := enginehost.DialStartingHost(ask.Workspace); err == nil { client, err := remote.Dial(socket, "", remote.Hello{Workspace: ask.Workspace, Session: ask.Transcript, Join: true}) if err == nil { @@ -210,11 +215,14 @@ type appBackend struct { sweepStop chan struct{} sweepDone chan struct{} crews *appCrewWatch - // models is the lazily warmed model list, made by the first models.list - // that found nothing remembered on disk and never before. - models *catalog.Catalog // spent is the last figure spend.changed said, so it is said on a change. spent float64 + // mint makes a new conversation's folder and answers its transcript path: + // the one piece of agents.create that happens before the answer. + mint func(workspace string) (string, error) + // warmed closes when [appBackend.Warm] has finished, which Close waits + // for: the process half it opens is closed after the backend is. + warmed chan struct{} } // appAgent is every agent this process has heard of: listed off the disk, @@ -229,6 +237,21 @@ type appAgent struct { // failed is the words of the last turn that ended in failure, which is // what agents.retry sends again; a turn that ends well clears it. failed string + // boot is what agents.create asked of a conversation not yet open, taken + // by whichever road opens it first; opening says one is being assembled. + boot *appBoot + opening bool + // users is how many messages of the person's the transcript holds, which + // is what an entry's id is counted from ([appUserEntryID]); counted says + // it has been read. + users int + counted bool +} + +// appBoot is agents.create's ask, kept for the background open. +type appBoot struct { + model string + budget *float64 } // appLive is one conversation the process holds. @@ -287,11 +310,24 @@ func newAppBackend(emit appwire.Emitter, open appOpener) *appBackend { agents: map[string]*appAgent{}, sweepStop: make(chan struct{}), sweepDone: make(chan struct{}), + mint: appMint, } guard.Go("app/sweep", b.sweep) return b } +// appMint makes a new conversation's folder in workspace, the very folder a +// host's new conversation is minted as, with the workspace stamped as where the +// person stood. It is a directory and a small file, so agents.create can answer +// with the agent's lasting id before anything slow happens. +func appMint(workspace string) (string, error) { + place, err := v3NextSessionFrom(session.Place{}, workspace, workspace) + if err != nil { + return "", err + } + return place.Transcript(), nil +} + // appAgentID is a conversation's id from its transcript: the folder's name. func appAgentID(transcript string) string { return filepath.Base(filepath.Dir(filepath.Clean(transcript))) @@ -447,7 +483,7 @@ func (b *appBackend) ListAgents(workspace string) ([]appwire.Agent, error) { // on the walk, which skips empty shells; it is still the app's agent. b.locked(func() { for id, a := range b.agents { - if !seen[id] && a.live != nil && matches(a.view.Workspace) { + if !seen[id] && (a.live != nil || a.opening) && matches(a.view.Workspace) { agents = append(agents, a.view) } } @@ -456,10 +492,12 @@ func (b *appBackend) ListAgents(workspace string) ([]appwire.Agent, error) { return agents, nil } -// CreateAgent opens a new conversation. With no workspace it is the person's -// home folder; with a budget the conversation's own spend limit is set before -// anything is spent; with words the first turn starts at once and the agent is -// named by them. +// CreateAgent answers a new agent AT ONCE and assembles its conversation +// behind the answer. With no workspace it is the person's home folder; with a +// budget the conversation's own spend limit is set before anything is spent; +// with words the first turn starts as soon as the conversation is open and the +// agent is named by them. A conversation that cannot be opened says so as a +// failed turn and a stopped agent. func (b *appBackend) CreateAgent(ask appwire.CreateAsk) (appwire.Agent, error) { // engineWorkspace answers the home folder for no workspace at all. resolved, err := engineWorkspace(ask.Workspace) @@ -469,36 +507,81 @@ func (b *appBackend) CreateAgent(ask appwire.CreateAsk) (appwire.Agent, error) { if b.isClosing() { return appwire.Agent{}, appwire.Errorf(appwire.CodeClosed, "codeaf is shutting down") } - conn, err := b.open(appOpenAsk{Workspace: resolved, Model: strings.TrimSpace(ask.Model), New: true}) + transcript, err := b.mint(resolved) if err != nil { return appwire.Agent{}, appEngineError(err) } - if ask.BudgetUSD != nil && *ask.BudgetUSD > 0 { - if err := conn.Conv.SetSpendRail(*ask.BudgetUSD); err != nil { - conn.Release() - return appwire.Agent{}, appEngineError(err) + text := strings.TrimSpace(ask.Text) + model := strings.TrimSpace(ask.Model) + a := b.agent(appAgentID(transcript)) + var view appwire.Agent + b.locked(func() { + a.transcript = transcript + a.view.Title = session.FirstMessageName(text) + a.view.Workspace = resolved + a.view.Model = model + a.view.State = appwire.StateIdle + if text != "" { + a.view.State = appwire.StateWorking } - } - a := b.agent(appAgentID(conn.Transcript)) + a.view.UpdatedAt = b.now() + a.boot = &appBoot{model: model, budget: ask.BudgetUSD} + a.opening = true + a.users, a.counted = 0, true + view = a.view + }) + b.emit.Emit(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) + clientID := ask.ClientID + guard.Go("app/create", func() { b.bootAgent(a, text, clientID) }) + return view, nil +} + +// bootAgent opens a created agent's conversation and starts its first turn. +func (b *appBackend) bootAgent(a *appAgent, text, clientID string) { a.gate.Lock() defer a.gate.Unlock() - view := b.hold(a, conn, resolved) - text := strings.TrimSpace(ask.Text) - if title := session.FirstMessageName(text); title != "" { - b.locked(func() { - a.view.Title = title - view = a.view - }) + view, opened, err := b.ensureLocked(a) + b.locked(func() { a.opening = false }) + if err != nil { + b.bootFailed(a, text, err) + return } - b.emit.Emit(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) if text == "" { - return view, nil + if opened { + b.emit.Emit(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) + } + return } - if _, err := b.startLocked(a, text); err != nil { - return view, err + // The agent already reads working; the turn's own start says the rest. + if _, err := b.startLocked(a, text, clientID); err != nil { + b.bootFailed(a, text, err) } - b.locked(func() { view = a.view }) - return view, nil +} + +// bootFailed is a created agent whose conversation could not be opened or +// started: a failed turn the person can retry, and an agent that reads stopped. +func (b *appBackend) bootFailed(a *appAgent, text string, err error) { + words := err.Error() + var wire *appwire.Error + if errors.As(appEngineError(err), &wire) { + words = wire.Message + } + var view appwire.Agent + b.locked(func() { + if text != "" { + a.failed = text + } + a.boot = nil + if a.live == nil { + a.view.State = appwire.StateStopped + } + view = a.view + }) + if text != "" { + turn := b.turns.Add(1) + b.emit.Emit(appwire.EventTurnDone, appwire.TurnDone{Agent: view.ID, Turn: turn, OK: false, Error: words, ErrorKind: appwire.ErrorOther}) + } + b.emit.Emit(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) } // OpenAgent attaches a conversation to a saved agent and answers it. @@ -529,10 +612,14 @@ func (b *appBackend) ensure(a *appAgent) (appwire.Agent, bool, error) { func (b *appBackend) ensureLocked(a *appAgent) (appwire.Agent, bool, error) { var view appwire.Agent var transcript, workspace string + var boot *appBoot held := false b.locked(func() { view, held = a.view, a.live != nil transcript, workspace = a.transcript, a.view.Workspace + if !held { + boot, a.boot = a.boot, nil + } }) if held { return view, false, nil @@ -540,10 +627,20 @@ func (b *appBackend) ensureLocked(a *appAgent) (appwire.Agent, bool, error) { if b.isClosing() { return appwire.Agent{}, false, appwire.Errorf(appwire.CodeClosed, "codeaf is shutting down") } - conn, err := b.open(appOpenAsk{Workspace: workspace, Transcript: transcript}) + ask := appOpenAsk{Workspace: workspace, Transcript: transcript} + if boot != nil { + ask.Model, ask.Minted = boot.model, true + } + conn, err := b.open(ask) if err != nil { return appwire.Agent{}, false, appEngineError(err) } + if boot != nil && boot.budget != nil && *boot.budget > 0 { + if err := conn.Conv.SetSpendRail(*boot.budget); err != nil { + conn.Release() + return appwire.Agent{}, false, appEngineError(err) + } + } return b.hold(a, conn, workspace), true, nil } @@ -559,8 +656,19 @@ func (b *appBackend) hold(a *appAgent, conn *appConn, workspace string) appwire. consents: map[uint64]appConsentCall{}, } title, model := conn.Conv.Title(), conn.Conv.Model() + effort := conn.Conv.ReasoningFor(model) + var counted bool + b.locked(func() { counted = a.counted }) + users := 0 + if !counted && conn.Transcript != "" { + users = appUserCount(conn.Transcript) + } var view appwire.Agent b.locked(func() { + if !a.counted { + a.users, a.counted = users, true + } + a.view.Effort = effort if conn.Transcript != "" { a.transcript = conn.Transcript } @@ -787,7 +895,7 @@ func (b *appBackend) questionEvent(a *appAgent, id string, live *appLive, event at := b.now() live.box.Event(appwire.EventAskClosed, appwire.AskClosed{Agent: id, ID: askID, By: by, Option: option, At: at}) live.box.Event(appwire.EventEntryAdded, appwire.EntryAdded{Entry: appwire.Entry{ - ID: id + "-" + askID, Agent: id, Kind: appwire.EntryReceipt, At: at, Text: appReceipt(by, option, answer, at), + ID: appReceiptEntryID(askID), Agent: id, Kind: appwire.EntryReceipt, At: at, Text: appReceipt(by, option, answer, at), }}) if moved { live.box.Event(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) @@ -852,7 +960,7 @@ func (b *appBackend) died(a *appAgent, live *appLive) { for _, ask := range asks { live.box.Event(appwire.EventAskClosed, appwire.AskClosed{Agent: view.ID, ID: ask.ID, By: appwire.ClosedByStopped, At: at}) live.box.Event(appwire.EventEntryAdded, appwire.EntryAdded{Entry: appwire.Entry{ - ID: view.ID + "-" + ask.ID, Agent: view.ID, Kind: appwire.EntryReceipt, At: at, Text: appReceipt(appwire.ClosedByStopped, "", nil, at), + ID: appReceiptEntryID(ask.ID), Agent: view.ID, Kind: appwire.EntryReceipt, At: at, Text: appReceipt(appwire.ClosedByStopped, "", nil, at), }}) } live.box.Event(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) @@ -867,7 +975,7 @@ func (b *appBackend) liveOf(a *appAgent) *appLive { } // Send starts one turn, opening the conversation first if it is not held. -func (b *appBackend) Send(id, text string) (uint64, error) { +func (b *appBackend) Send(id, text, clientID string) (uint64, error) { a, err := b.find(id) if err != nil { return 0, err @@ -883,7 +991,7 @@ func (b *appBackend) Send(id, text string) (uint64, error) { if opened { b.emit.Emit(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) } - return b.startLocked(a, text) + return b.startLocked(a, text, clientID) } // Retry sends the words of the agent's last failed turn again. @@ -897,14 +1005,13 @@ func (b *appBackend) Retry(id string) (uint64, error) { if text == "" { return 0, appwire.Errorf(appwire.CodeNotFound, "this agent has no failed turn to run again") } - return b.Send(id, text) + return b.Send(id, text, "") } // startLocked starts one turn on a held conversation. The caller holds a.gate. -func (b *appBackend) startLocked(a *appAgent, text string) (uint64, error) { +func (b *appBackend) startLocked(a *appAgent, text, clientID string) (uint64, error) { var id string var live *appLive - var turn uint64 var refused error b.locked(func() { id, live = a.view.ID, a.live @@ -913,15 +1020,32 @@ func (b *appBackend) startLocked(a *appAgent, text string) (uint64, error) { refused = appwire.Errorf(appwire.CodeEngine, "the conversation was let go before the turn could start") case live.running: refused = appwire.Errorf(appwire.CodeBusy, "this agent is already working on a turn") - default: - live.running, live.stopping = true, false - live.lastUse = b.now() - turn = b.turns.Add(1) } }) if refused != nil { return 0, refused } + // A conversation joined on a window's host can have heard from the person + // in that window too, so its count is read again before it is used. + if !live.conn.Assembled && live.conn.Transcript != "" { + users := appUserCount(live.conn.Transcript) + b.locked(func() { a.users = users }) + } + var turn uint64 + var n int + b.locked(func() { + if live.running { + refused = appwire.Errorf(appwire.CodeBusy, "this agent is already working on a turn") + return + } + live.running, live.stopping = true, false + live.lastUse = b.now() + turn = b.turns.Add(1) + n = a.users + }) + if refused != nil { + return 0, refused + } events, err := live.conn.Conv.Submit(context.Background(), text) if err != nil { b.locked(func() { live.running = false }) @@ -929,6 +1053,7 @@ func (b *appBackend) startLocked(a *appAgent, text string) (uint64, error) { } var view appwire.Agent b.locked(func() { + a.users = n + 1 a.view.State = appwire.StateWorking if live.needsYou() { a.view.State = appwire.StateNeedsYou @@ -940,15 +1065,16 @@ func (b *appBackend) startLocked(a *appAgent, text string) (uint64, error) { live.box.Event(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) live.box.Event(appwire.EventTurnStarted, appwire.TurnStarted{Agent: id, Turn: turn}) live.box.Event(appwire.EventEntryAdded, appwire.EntryAdded{Entry: appwire.Entry{ - ID: appLiveEntryID(id, turn, 0), Agent: id, Turn: turn, Kind: appwire.EntryUser, At: b.now(), Text: text, + ID: appUserEntryID(n), Agent: id, Turn: turn, Kind: appwire.EntryUser, At: b.now(), Text: text, ClientID: clientID, }}) - guard.Go("app/turn", func() { b.pump(a, id, live, turn, text, events) }) + guard.Go("app/turn", func() { b.pump(a, id, live, turn, n, text, events) }) return turn, nil } // pump maps one turn's session events onto the wire. It never waits on the -// writer: everything goes into the agent's outbox. -func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, text string, events <-chan session.Event) { +// writer: everything goes into the agent's outbox. n is the turn's place among +// the person's messages, which every entry id of the turn is counted from. +func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, n int, text string, events <-chan session.Event) { defer close(live.pumpDone) ok, failure := true, "" kind, status := "", 0 @@ -960,47 +1086,68 @@ func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, te }) live.box.Event(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) } - // The reply is said as an answer entry once a stretch of it is over: when - // a tool call starts after it, or when the turn ends. - entries := 0 entry := func(e appwire.Entry) { - entries++ - e.ID, e.Agent, e.Turn, e.At = appLiveEntryID(id, turn, entries), id, turn, b.now() + e.Agent, e.Turn, e.At = id, turn, b.now() live.box.Event(appwire.EventEntryAdded, appwire.EntryAdded{Entry: e}) } + // EVERY ASSISTANT MESSAGE OF THE TURN HAS ITS INDEX, msg, and the app keys + // an answer by it: the deltas of message 0 append to answer 0, and answer + // 0's entry replaces them, so the words before a tool call and the words + // after it are two answers and neither is ever drawn twice. A message ends + // where a tool call begins after its words, or where the turn ends, and + // it is counted only if it said something — the same rule the listing + // counts by ([appEntriesOf]), so both name it alike. + msg, notices := 0, 0 var reply, thinking strings.Builder sayReply := func() { words := strings.TrimSpace(reply.String()) if words != "" { - entry(appwire.Entry{Kind: appwire.EntryAnswer, Text: words, Thinking: strings.TrimSpace(thinking.String())}) + at := msg + entry(appwire.Entry{ID: appAnswerEntryID(n, at), Kind: appwire.EntryAnswer, Text: words, Thinking: strings.TrimSpace(thinking.String()), Msg: &at}) + msg++ } reply.Reset() thinking.Reset() } + titles := map[string]string{} for event := range events { switch event.Kind { case session.EventTextDelta: reply.WriteString(event.Text) - live.box.Delta(id, turn, appwire.DeltaText, event.Text) + live.box.Delta(id, turn, msg, appwire.DeltaText, event.Text) case session.EventReasoning: thinking.WriteString(event.Text) - live.box.Delta(id, turn, appwire.DeltaThinking, event.Text) + live.box.Delta(id, turn, msg, appwire.DeltaThinking, event.Text) case session.EventToolBegin: sayReply() - live.box.Event(appwire.EventTurnTool, appwire.TurnTool{Agent: id, Turn: turn, Call: event.CallID, Name: event.Tool, Phase: appwire.PhaseBegin, Summary: event.Hint}) + title := redact.Secrets(tui3.ToolTitle(event.Tool, event.Args, event.Hint)) + titles[event.CallID] = title + live.box.Event(appwire.EventTurnTool, appwire.TurnTool{Agent: id, Turn: turn, Call: event.CallID, Name: event.Tool, Title: title, Phase: appwire.PhaseBegin, Summary: event.Hint}) case session.EventToolEnd, session.EventToolFailed: succeeded := event.Kind == session.EventToolEnd summary := event.Hint if summary == "" && !succeeded { summary = event.Text + if summary == "" && event.Err != nil { + summary = event.Err.Error() + } + } + summary = redact.Secrets(summary) + title := titles[event.CallID] + if title == "" { + title = redact.Secrets(tui3.ToolTitle(event.Tool, event.Args, "")) } - live.box.Event(appwire.EventTurnTool, appwire.TurnTool{Agent: id, Turn: turn, Call: event.CallID, Name: event.Tool, Phase: appwire.PhaseEnd, OK: &succeeded, Summary: redact.Secrets(summary)}) + live.box.Event(appwire.EventTurnTool, appwire.TurnTool{Agent: id, Turn: turn, Call: event.CallID, Name: event.Tool, Title: title, Detail: appDetail(summary), Phase: appwire.PhaseEnd, OK: &succeeded, Summary: summary}) case session.EventConsentRequest: call := appConsentCall{Tool: event.Tool, Args: event.Args} b.locked(func() { live.consents[event.ID] = call }) case session.EventNotice: + // THE ENGINE'S NOTICES ARE ITS DIM LINES — the skills a turn + // carried, a request reshaped to land — drawn by the terminal as + // one muted row and never as a message, and quiet says the same. if words := strings.TrimSpace(event.Text); words != "" { - entry(appwire.Entry{Kind: appwire.EntryNotice, Text: redact.Secrets(words)}) + entry(appwire.Entry{ID: appLiveNoticeID(n, notices), Kind: appwire.EntryNotice, Text: redact.Secrets(words), Quiet: true}) + notices++ } case session.EventError: ok = false @@ -1030,7 +1177,7 @@ func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, te } }) if !ok { - entry(appwire.Entry{Kind: appwire.EntryError, Text: failure, ErrorKind: kind, Status: status}) + entry(appwire.Entry{ID: appErrorEntryID(n), Kind: appwire.EntryError, Text: failure, ErrorKind: kind, Status: status}) } live.box.Event(appwire.EventTurnDone, appwire.TurnDone{Agent: id, Turn: turn, OK: ok, Error: failure, ErrorKind: kind, Status: status}) held := false @@ -1051,10 +1198,56 @@ func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, te b.spendMoved() } -// appLiveEntryID names an entry said while its turn ran. The listing names the -// same items by their place in the transcript instead ([appEntriesOf]). -func appLiveEntryID(agent string, turn uint64, n int) string { - return agent + "-t" + strconv.FormatUint(turn, 10) + "-" + strconv.Itoa(n) +// ── entry ids ─────────────────────────────────────────────────────────────── +// +// AN ENTRY'S ID IS THE SAME LIVE AND READ BACK. Both are counted from the +// conversation's own record: n is the place of the person's message among the +// person's messages (u0, u1 …), an answer is the m-th assistant message after +// it that said something (a.), and a tool entry is its call's own id +// (t). The live pump counts as it streams and the listing counts as it +// reads, by one rule, so agents.entries names what entry.added named. What +// only ever exists live — a turn's error, an ask's receipt, the engine's dim +// notices — has an id of its own kind that the listing never makes. + +func appUserEntryID(n int) string { return "u" + strconv.Itoa(n) } + +func appAnswerEntryID(n, m int) string { return "a" + strconv.Itoa(n) + "." + strconv.Itoa(m) } + +func appToolEntryID(call string) string { return "t" + call } + +func appNoteEntryID(n, k int) string { return "n" + strconv.Itoa(n) + "." + strconv.Itoa(k) } + +func appLiveNoticeID(n, k int) string { return "q" + strconv.Itoa(n) + "." + strconv.Itoa(k) } + +func appErrorEntryID(n int) string { return "x" + strconv.Itoa(n) } + +func appReceiptEntryID(askID string) string { return "r" + askID } + +// appUserCount is how many of the person's messages a transcript holds. +func appUserCount(transcript string) int { + record := session.ReadTranscript(transcript) + users := 0 + for _, items := range [][]session.DisplayEntry{record.Earlier, record.Entries} { + for _, item := range items { + if item.Role == "user" { + users++ + } + } + } + return users +} + +// appDetailLimit bounds a tool's short result line. +const appDetailLimit = 160 + +// appDetail is a tool's short result line: its first line, bounded. +func appDetail(text string) string { + line, _, _ := strings.Cut(strings.TrimSpace(text), "\n") + line = strings.TrimSpace(line) + if runes := []rune(line); len(runes) > appDetailLimit { + line = string(runes[:appDetailLimit-1]) + "…" + } + return line } // appTurnFailure sorts a turn's error: its kind, the provider's status where @@ -1330,7 +1523,7 @@ func (b *appBackend) Entries(id, before string, limit int) ([]appwire.Entry, boo record := session.ReadTranscript(transcript) all := appEntriesOf(id, append(append([]session.DisplayEntry(nil), record.Earlier...), record.Entries...), at) if words := strings.TrimSpace(record.Unreadable); words != "" { - all = append(all, appwire.Entry{ID: "e" + strconv.Itoa(len(all)), Agent: id, Kind: appwire.EntryNotice, At: at, Text: words}) + all = append(all, appwire.Entry{ID: "unreadable", Agent: id, Kind: appwire.EntryNotice, At: at, Text: words}) } end := len(all) if before != "" { @@ -1350,32 +1543,43 @@ func (b *appBackend) Entries(id, before string, limit int) ([]appwire.Entry, boo return page, start > 0, nil } -// appEntriesOf is a transcript as the app's entries, named by their place in -// it. The journal keeps no clock per message, so every entry carries the -// conversation's own last moment. +// appEntriesOf is a transcript as the app's entries, named by the rule the +// live pump names them by (see the entry ids above). The journal keeps no +// clock per message, so every entry carries the conversation's own last moment. func appEntriesOf(agent string, items []session.DisplayEntry, at time.Time) []appwire.Entry { entries := make([]appwire.Entry, 0, len(items)) - for _, item := range items { + n, m, k := -1, 0, 0 + for i, item := range items { entry := appwire.Entry{Agent: agent, At: at} switch item.Role { case "user": - entry.Kind, entry.Text = appwire.EntryUser, item.Text + n, m, k = n+1, 0, 0 + entry.ID, entry.Kind, entry.Text = appUserEntryID(n), appwire.EntryUser, item.Text case "assistant": if strings.TrimSpace(item.Text) == "" { continue } - entry.Kind, entry.Text = appwire.EntryAnswer, item.Text + msg := m + entry.ID, entry.Kind, entry.Text, entry.Msg = appAnswerEntryID(n, msg), appwire.EntryAnswer, strings.TrimSpace(item.Text), &msg + m++ case "tool": - entry.Kind, entry.Call, entry.Name, entry.Summary = appwire.EntryTool, item.CallID, item.Tool, redact.Secrets(item.Hint) + call := item.CallID + if call == "" { + call = "#" + strconv.Itoa(i) + } + entry.ID, entry.Kind, entry.Call, entry.Name = appToolEntryID(call), appwire.EntryTool, item.CallID, item.Tool + entry.Title = redact.Secrets(tui3.ToolTitle(item.Tool, item.Args, item.Hint)) + entry.Summary = redact.Secrets(item.Hint) + entry.Detail = appDetail(redact.Secrets(item.Output)) case "note", "aside": if strings.TrimSpace(item.Text) == "" { continue } - entry.Kind, entry.Text = appwire.EntryNotice, item.Text + entry.ID, entry.Kind, entry.Text = appNoteEntryID(n, k), appwire.EntryNotice, item.Text + k++ default: continue } - entry.ID = "e" + strconv.Itoa(len(entries)) entries = append(entries, entry) } return entries @@ -1415,56 +1619,128 @@ func (b *appBackend) SetModel(id, model string) (appwire.Agent, error) { return view, nil } -// ListModels answers the models the configured service lists, from the list -// remembered on disk. With nothing remembered the list is fetched once, behind -// the answer, and the answer meanwhile is the built-in few for the default -// service; nothing of this happens before the app asks. +// SetEffort sets how hard the agent's model is asked to think, opening its +// conversation first if none is held. The level is the conversation's own, +// kept per model the way the terminal's ctrl+t keeps it. +func (b *appBackend) SetEffort(id, effort string) (appwire.Agent, error) { + a, err := b.find(id) + if err != nil { + return appwire.Agent{}, err + } + a.gate.Lock() + defer a.gate.Unlock() + if _, _, err := b.ensureLocked(a); err != nil { + return appwire.Agent{}, err + } + var live *appLive + b.locked(func() { + live = a.live + live.lastUse = b.now() + }) + model := live.conn.Conv.Model() + live.conn.Conv.SetReasoningFor(model, effort) + if now := live.conn.Conv.ReasoningFor(model); now != "" { + effort = now + } + var view appwire.Agent + b.locked(func() { + a.view.Effort = effort + view = a.view + }) + b.emit.Emit(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) + return view, nil +} + +// Warm opens the engine's process half — the profile, the model catalog, the +// harness and skill shelves every conversation is built on — behind the hello, +// so the first agents.create does not wait for it. It is what the terminal does +// as it starts, network included. +func (b *appBackend) Warm() { + run := false + b.locked(func() { + if !b.closing && b.warmed == nil { + b.warmed, run = make(chan struct{}), true + } + }) + if !run { + return + } + defer close(b.warmed) + defer guard.Recover("app/warm") + _, _ = b.process() +} + +// process is the engine's shared process half, opened once. +func (b *appBackend) process() (*v3Process, error) { + proc, err := openEngineProcess() + if err == nil { + b.assembled.Store(true) + } + return proc, err +} + +// appModelsWait bounds how long models.list waits for a model list still +// arriving when nothing is remembered on disk yet. +const appModelsWait = 3 * time.Second + +// ListModels answers exactly what the terminal's model picker offers for the +// configured services — the same process catalog, the same per-service shelf, +// the same caches, through the picker's own composer ([tui3.PickerModels]) — +// with the model a new conversation runs on marked default, and added when no +// list names it, so the answer is never empty while a turn can run. func (b *appBackend) ListModels() ([]appwire.Model, error) { - settings, err := config.LoadKeyless() + proc, err := b.process() if err != nil { return nil, appEngineError(err) } - discovery := catalog.Options{ - BaseURL: settings.BaseURL, APIKey: settings.APIKey, Dir: settings.ProfileDir, - HTTPClient: config.CatalogHTTPClient(settings.Sources.Default()), + read := func() []tui3.Model { + _, sources := proc.currentAccount() + return tui3.PickerModels(proc.ProfileDir, sources, proc.Shelf.pickerModels, proc.Shelf.modelsForService) } - rows := catalog.Recall(discovery).ModelsNow() - if len(rows) == 0 { - var lazy *catalog.Catalog - b.locked(func() { - if b.models == nil && !b.closing { - b.models = catalog.LoadLazy(context.Background(), discovery) - } - lazy = b.models - }) - if lazy != nil { - rows = lazy.ModelsNow() - } + rows := read() + if len(rows) == 0 && proc.Models != nil { + ctx, cancel := context.WithTimeout(context.Background(), appModelsWait) + proc.Models.Warmed(ctx) + cancel() + rows = read() } - models := make([]appwire.Model, 0, len(rows)) + chosen := appDefaultModel() + models := make([]appwire.Model, 0, len(rows)+1) + seen := false for _, row := range rows { - name := strings.TrimSpace(row.Name) - if name == "" { - name = row.ID + provider := strings.TrimSpace(row.Group) + if provider == "" { + provider, _, _ = strings.Cut(strings.TrimPrefix(row.ID, "~"), "/") } - provider, _, _ := strings.Cut(strings.TrimPrefix(row.ID, "~"), "/") - models = append(models, appwire.Model{ID: row.ID, Name: name, Provider: provider}) + model := appwire.Model{ID: row.ID, Name: row.ID, Provider: provider, Default: row.ID == chosen} + seen = seen || model.Default + models = append(models, model) + } + if !seen && chosen != "" { + provider, _, _ := strings.Cut(strings.TrimPrefix(chosen, "~"), "/") + models = append(models, appwire.Model{ID: chosen, Name: chosen, Provider: provider, Default: true}) } return models, nil } +// appDefaultModel is the model a new conversation runs on, by the rule every +// local door opens one with ([v3TalkModel]). +func appDefaultModel() string { + settings, err := config.LoadKeyless() + if err != nil { + return "" + } + return v3TalkModel("", settings) +} + // Settings reads the profile's own rows; a figure nobody set is null. func (b *appBackend) Settings() (appwire.Settings, error) { profile := config.ProfileDir() settings := appwire.Settings{ - DefaultModel: config.ChatModelAt(profile), + DefaultModel: appDefaultModel(), AsksBefore: config.ToolApprovalModeAt(profile), BudgetUSD: appFigure(config.SpendRailUSDAt(profile)), } - if settings.DefaultModel == "" { - // Nobody chose one: the model a new conversation opens on. - settings.DefaultModel = config.ChatDefaultAt(profile) - } dayCap, err := config.DailyBudgetUSDAt(profile) if err != nil { return appwire.Settings{}, appEngineError(err) @@ -1631,14 +1907,14 @@ func appMemberAgent(member teamstore.Member) string { func (b *appBackend) Close() error { already := false var crews *appCrewWatch - var models *catalog.Catalog var agents []*appAgent + var warmed chan struct{} b.locked(func() { if already = b.closing; already { return } b.closing = true - crews = b.crews + crews, warmed = b.crews, b.warmed for _, a := range b.agents { agents = append(agents, a) } @@ -1648,14 +1924,12 @@ func (b *appBackend) Close() error { } close(b.sweepStop) <-b.sweepDone + if warmed != nil { + <-warmed + } if crews != nil { crews.close() } - // No models.list can start a fetch once closing is set. - b.locked(func() { models = b.models }) - if models != nil { - models.Close() - } var wg sync.WaitGroup for _, a := range agents { if b.liveOf(a) == nil { diff --git a/cmd/codeaf/app_test.go b/cmd/codeaf/app_test.go index 2cd49d96d4..80d7ef034d 100644 --- a/cmd/codeaf/app_test.go +++ b/cmd/codeaf/app_test.go @@ -6,8 +6,11 @@ import ( "encoding/json" "errors" "io" + "net/http" + "net/http/httptest" "os" "path/filepath" + "strconv" "strings" "sync" "testing" @@ -139,6 +142,7 @@ type fakeConversation struct { endLane func() answers []session.Answer model string + effort string rail float64 remembered []session.ConsentScope texts []string @@ -203,6 +207,16 @@ func (f *fakeConversation) Model() string { return "deepseek/deepseek-v4-flash" } func (f *fakeConversation) SetModel(model string) { f.mu.Lock(); f.model = model; f.mu.Unlock() } +func (f *fakeConversation) ReasoningFor(model string) string { + f.mu.Lock() + defer f.mu.Unlock() + return f.effort +} +func (f *fakeConversation) SetReasoningFor(model, level string) { + f.mu.Lock() + f.effort = level + f.mu.Unlock() +} func (f *fakeConversation) SetSpendRail(usd float64) error { f.mu.Lock() f.rail = usd @@ -273,6 +287,40 @@ func (r *appRecorder) waitFor(t *testing.T, within time.Duration, match func(str func appTurnDone(event string, _ any) bool { return event == appwire.EventTurnDone } +// appFakeTranscript is the one conversation a fake opener holds. +const appFakeTranscript = "/state/v3/places/proj/5e55a0f1/transcript.jsonl" + +// newFakeBackend is a backend over a fake opener, minting the fake's own +// folder rather than one on disk. +func newFakeBackend(rec appwire.Emitter, open appOpener) *appBackend { + backend := newAppBackend(rec, open) + backend.mint = func(string) (string, error) { return appFakeTranscript, nil } + return backend +} + +// mustCreate creates an agent and, when it was given no words, waits for its +// conversation to be open, which agents.create does behind its answer. +func mustCreate(t *testing.T, backend *appBackend, ask appwire.CreateAsk) (appwire.Agent, error) { + t.Helper() + agent, err := backend.CreateAgent(ask) + if err != nil || strings.TrimSpace(ask.Text) != "" { + return agent, err + } + deadline := time.Now().Add(30 * time.Second) + for { + a, _ := backend.find(agent.ID) + if a != nil && backend.liveOf(a) != nil { + var view appwire.Agent + backend.locked(func() { view = a.view }) + return view, nil + } + if time.Now().After(deadline) { + t.Fatalf("the agent's conversation never opened") + } + time.Sleep(2 * time.Millisecond) + } +} + func fakeOpener(conv *fakeConversation, opens *int, releases *int, mu *sync.Mutex) appOpener { return func(ask appOpenAsk) (*appConn, error) { mu.Lock() @@ -280,7 +328,7 @@ func fakeOpener(conv *fakeConversation, opens *int, releases *int, mu *sync.Mute mu.Unlock() return &appConn{ Conv: conv, Workspace: ask.Workspace, - Transcript: "/state/v3/places/proj/5e55a0f1/transcript.jsonl", + Transcript: appFakeTranscript, Release: func() { mu.Lock(); *releases++; mu.Unlock() }, }, nil } @@ -308,17 +356,17 @@ func TestAppSendMapsSessionEventsOntoTurnEvents(t *testing.T) { var mu sync.Mutex var opens, releases int rec := newAppRecorder() - backend := newAppBackend(rec, fakeOpener(conv, &opens, &releases, &mu)) + backend := newFakeBackend(rec, fakeOpener(conv, &opens, &releases, &mu)) defer backend.Close() - agent, err := backend.CreateAgent(appwire.CreateAsk{Workspace: t.TempDir(), Model: "deepseek/deepseek-v4-flash"}) + agent, err := mustCreate(t, backend, appwire.CreateAsk{Workspace: t.TempDir(), Model: "deepseek/deepseek-v4-flash"}) if err != nil { t.Fatal(err) } if agent.ID != "5e55a0f1" || agent.State != appwire.StateIdle || agent.Model != "deepseek/deepseek-v4-flash" { t.Fatalf("created = %+v", agent) } - turn, err := backend.Send(agent.ID, "fix it") + turn, err := backend.Send(agent.ID, "fix it", "") if err != nil || turn == 0 { t.Fatalf("send = %d, %v", turn, err) } @@ -330,8 +378,9 @@ func TestAppSendMapsSessionEventsOntoTurnEvents(t *testing.T) { rec.mu.Lock() defer rec.mu.Unlock() - // The first event is the create's own agent.changed. - got := rec.events[1 : done+2] + // The first two events are the create's own agent.changed and the one + // that says its conversation opened behind the answer. + got := rec.events[2 : done+2] want := []string{ appwire.EventAgentChanged, appwire.EventTurnStarted, appwire.EventEntryAdded, appwire.EventTurnDelta, appwire.EventTurnDelta, appwire.EventEntryAdded, @@ -341,14 +390,14 @@ func TestAppSendMapsSessionEventsOntoTurnEvents(t *testing.T) { if strings.Join(got, " ") != strings.Join(want, " ") { t.Fatalf("events:\n got %v\nwant %v", got, want) } - data := rec.data[1 : done+2] + data := rec.data[2 : done+2] if s := data[0].(appwire.AgentChanged).Agent.State; s != appwire.StateWorking { t.Fatalf("first change = %s, want working", s) } if d := data[1].(appwire.TurnStarted); d.Turn != turn || d.Agent != agent.ID { t.Fatalf("turn.started = %+v", d) } - if e := data[2].(appwire.EntryAdded).Entry; e.Kind != appwire.EntryUser || e.Text != "fix it" || e.Turn != turn || e.Agent != agent.ID { + if e := data[2].(appwire.EntryAdded).Entry; e.Kind != appwire.EntryUser || e.ID != "u0" || e.Text != "fix it" || e.Turn != turn || e.Agent != agent.ID { t.Fatalf("the person's entry = %+v", e) } if d := data[3].(appwire.TurnDelta); d.Kind != appwire.DeltaThinking || d.Text != "hmm" { @@ -357,13 +406,13 @@ func TestAppSendMapsSessionEventsOntoTurnEvents(t *testing.T) { if d := data[4].(appwire.TurnDelta); d.Kind != appwire.DeltaText || d.Text != "hello world" { t.Fatalf("text = %+v", d) } - if e := data[5].(appwire.EntryAdded).Entry; e.Kind != appwire.EntryAnswer || e.Text != "hello world" || e.Thinking != "hmm" { + if e := data[5].(appwire.EntryAdded).Entry; e.Kind != appwire.EntryAnswer || e.ID != "a0.0" || e.Msg == nil || *e.Msg != 0 || e.Text != "hello world" || e.Thinking != "hmm" { t.Fatalf("the answer entry = %+v", e) } begin := data[6].(appwire.TurnTool) end := data[7].(appwire.TurnTool) failed := data[9].(appwire.TurnTool) - if begin.Phase != appwire.PhaseBegin || begin.Call != "c1" || begin.Name != "read" || begin.OK != nil { + if begin.Phase != appwire.PhaseBegin || begin.Call != "c1" || begin.Name != "read" || begin.Title != "read go.mod" || begin.OK != nil { t.Fatalf("begin = %+v", begin) } if end.Phase != appwire.PhaseEnd || end.OK == nil || !*end.OK || end.Summary != "12 lines" { @@ -388,16 +437,16 @@ func TestAppSendRefusesBusyAndReportsAFailedTurn(t *testing.T) { var mu sync.Mutex var opens, releases int rec := newAppRecorder() - backend := newAppBackend(rec, fakeOpener(conv, &opens, &releases, &mu)) + backend := newFakeBackend(rec, fakeOpener(conv, &opens, &releases, &mu)) defer backend.Close() - agent, err := backend.CreateAgent(appwire.CreateAsk{Workspace: t.TempDir()}) + agent, err := mustCreate(t, backend, appwire.CreateAsk{Workspace: t.TempDir()}) if err != nil { t.Fatal(err) } - if _, err := backend.Send(agent.ID, "one"); err != nil { + if _, err := backend.Send(agent.ID, "one", ""); err != nil { t.Fatal(err) } - _, err = backend.Send(agent.ID, "two") + _, err = backend.Send(agent.ID, "two", "") var wire *appwire.Error if !errors.As(err, &wire) || wire.Code != appwire.CodeBusy { t.Fatalf("second send = %v, want busy", err) @@ -423,11 +472,11 @@ func TestAppLetsAnIdleConversationGoAndTheNextSendReopensIt(t *testing.T) { var mu sync.Mutex var opens, releases int rec := newAppRecorder() - backend := newAppBackend(rec, fakeOpener(conv, &opens, &releases, &mu)) + backend := newFakeBackend(rec, fakeOpener(conv, &opens, &releases, &mu)) defer backend.Close() clock := time.Now() backend.now = func() time.Time { return clock } - agent, err := backend.CreateAgent(appwire.CreateAsk{Workspace: t.TempDir()}) + agent, err := mustCreate(t, backend, appwire.CreateAsk{Workspace: t.TempDir()}) if err != nil { t.Fatal(err) } @@ -456,7 +505,7 @@ func TestAppLetsAnIdleConversationGoAndTheNextSendReopensIt(t *testing.T) { t.Fatalf("state after letting go = %s", state) } - if _, err := backend.Send(agent.ID, "again"); err != nil { + if _, err := backend.Send(agent.ID, "again", ""); err != nil { t.Fatalf("send after letting go = %v", err) } rec.waitFor(t, 5*time.Second, appTurnDone) @@ -474,10 +523,10 @@ func TestAppCloseSaysClosedAndUnknownIdsAreNotFound(t *testing.T) { var mu sync.Mutex var opens, releases int rec := newAppRecorder() - backend := newAppBackend(rec, fakeOpener(conv, &opens, &releases, &mu)) + backend := newFakeBackend(rec, fakeOpener(conv, &opens, &releases, &mu)) defer backend.Close() t.Setenv(home.EnvVar, t.TempDir()) - agent, err := backend.CreateAgent(appwire.CreateAsk{Workspace: t.TempDir()}) + agent, err := mustCreate(t, backend, appwire.CreateAsk{Workspace: t.TempDir()}) if err != nil { t.Fatal(err) } @@ -510,7 +559,7 @@ func TestAppHoldsARealConversationInProcess(t *testing.T) { _, workspace := wakeEnvironment(t, root) rec := newAppRecorder() backend := newAppBackend(rec, appOpenInProcess) - agent, err := backend.CreateAgent(appwire.CreateAsk{Workspace: workspace}) + agent, err := mustCreate(t, backend, appwire.CreateAsk{Workspace: workspace}) if err != nil { backend.Close() t.Fatalf("create = %v", err) @@ -519,7 +568,7 @@ func TestAppHoldsARealConversationInProcess(t *testing.T) { backend.Close() t.Fatalf("agent = %+v", agent) } - turn, err := backend.Send(agent.ID, "say done") + turn, err := backend.Send(agent.ID, "say done", "") if err != nil { backend.Close() t.Fatalf("send = %v", err) @@ -603,9 +652,9 @@ func TestAppAsksOpenAnswerAndCloseThroughTheQuestionLane(t *testing.T) { var mu sync.Mutex var opens, releases int rec := newAppRecorder() - backend := newAppBackend(rec, fakeOpener(conv, &opens, &releases, &mu)) + backend := newFakeBackend(rec, fakeOpener(conv, &opens, &releases, &mu)) defer backend.Close() - agent, err := backend.CreateAgent(appwire.CreateAsk{Workspace: t.TempDir()}) + agent, err := mustCreate(t, backend, appwire.CreateAsk{Workspace: t.TempDir()}) if err != nil { t.Fatal(err) } @@ -680,9 +729,9 @@ func TestAppADeadConversationReadsStoppedAndTheNextSendReopensIt(t *testing.T) { var mu sync.Mutex var opens, releases int rec := newAppRecorder() - backend := newAppBackend(rec, fakeOpener(conv, &opens, &releases, &mu)) + backend := newFakeBackend(rec, fakeOpener(conv, &opens, &releases, &mu)) defer backend.Close() - agent, err := backend.CreateAgent(appwire.CreateAsk{Workspace: t.TempDir()}) + agent, err := mustCreate(t, backend, appwire.CreateAsk{Workspace: t.TempDir()}) if err != nil { t.Fatal(err) } @@ -711,7 +760,7 @@ func TestAppADeadConversationReadsStoppedAndTheNextSendReopensIt(t *testing.T) { t.Fatalf("listed as %s", listed.State) } } - if _, err := backend.Send(agent.ID, "again"); err != nil { + if _, err := backend.Send(agent.ID, "again", ""); err != nil { t.Fatalf("send after the engine died = %v", err) } rec.waitFor(t, 5*time.Second, appTurnDone) @@ -748,7 +797,7 @@ func TestAppAnswersARealApprovalAndRecordsTheWorkspaceAsTheLaunchFolder(t *testi rec := newAppRecorder() backend := newAppBackend(rec, appOpenInProcess) defer backend.Close() - agent, err := backend.CreateAgent(appwire.CreateAsk{Workspace: workspace}) + agent, err := mustCreate(t, backend, appwire.CreateAsk{Workspace: workspace}) if err != nil { t.Fatalf("create = %v", err) } @@ -760,7 +809,7 @@ func TestAppAnswersARealApprovalAndRecordsTheWorkspaceAsTheLaunchFolder(t *testi t.Fatalf("launch folder = %q (app started in %q), %v", meta.LaunchDir, started, err) } - if _, err := backend.Send(agent.ID, "run the probe"); err != nil { + if _, err := backend.Send(agent.ID, "run the probe", ""); err != nil { t.Fatal(err) } i := rec.waitFor(t, 30*time.Second, appAskEvent(appwire.EventAskOpened)) @@ -841,11 +890,11 @@ func TestAppCreateWithTextStartsTheTurnAndNamesTheAgent(t *testing.T) { var mu sync.Mutex var opens, releases int rec := newAppRecorder() - backend := newAppBackend(rec, fakeOpener(conv, &opens, &releases, &mu)) + backend := newFakeBackend(rec, fakeOpener(conv, &opens, &releases, &mu)) defer backend.Close() budget := 3.0 words := "Please fix the flaky parser test in the lexer package before lunch" - agent, err := backend.CreateAgent(appwire.CreateAsk{Text: words, BudgetUSD: &budget}) + agent, err := mustCreate(t, backend, appwire.CreateAsk{Text: words, BudgetUSD: &budget}) if err != nil { t.Fatal(err) } @@ -877,9 +926,9 @@ func TestAppSortsAProviderFailureAndRetriesIt(t *testing.T) { var mu sync.Mutex var opens, releases int rec := newAppRecorder() - backend := newAppBackend(rec, fakeOpener(conv, &opens, &releases, &mu)) + backend := newFakeBackend(rec, fakeOpener(conv, &opens, &releases, &mu)) defer backend.Close() - agent, err := backend.CreateAgent(appwire.CreateAsk{Workspace: t.TempDir()}) + agent, err := mustCreate(t, backend, appwire.CreateAsk{Workspace: t.TempDir()}) if err != nil { t.Fatal(err) } @@ -887,7 +936,7 @@ func TestAppSortsAProviderFailureAndRetriesIt(t *testing.T) { if _, err := backend.Retry(agent.ID); !errors.As(err, &wire) || wire.Code != appwire.CodeNotFound { t.Fatalf("retry with nothing failed = %v", err) } - if _, err := backend.Send(agent.ID, "summarise the repo"); err != nil { + if _, err := backend.Send(agent.ID, "summarise the repo", ""); err != nil { t.Fatal(err) } i := rec.waitFor(t, 5*time.Second, appTurnDone) @@ -953,9 +1002,9 @@ func TestAppSetModelMovesTheConversation(t *testing.T) { var mu sync.Mutex var opens, releases int rec := newAppRecorder() - backend := newAppBackend(rec, fakeOpener(conv, &opens, &releases, &mu)) + backend := newFakeBackend(rec, fakeOpener(conv, &opens, &releases, &mu)) defer backend.Close() - agent, err := backend.CreateAgent(appwire.CreateAsk{Workspace: t.TempDir()}) + agent, err := mustCreate(t, backend, appwire.CreateAsk{Workspace: t.TempDir()}) if err != nil { t.Fatal(err) } @@ -984,13 +1033,13 @@ func TestAppAlwaysWritesABashRule(t *testing.T) { var mu sync.Mutex var opens, releases int rec := newAppRecorder() - backend := newAppBackend(rec, fakeOpener(conv, &opens, &releases, &mu)) + backend := newFakeBackend(rec, fakeOpener(conv, &opens, &releases, &mu)) defer func() { close(hold); backend.Close() }() - agent, err := backend.CreateAgent(appwire.CreateAsk{Workspace: t.TempDir()}) + agent, err := mustCreate(t, backend, appwire.CreateAsk{Workspace: t.TempDir()}) if err != nil { t.Fatal(err) } - if _, err := backend.Send(agent.ID, "run the lexer tests"); err != nil { + if _, err := backend.Send(agent.ID, "run the lexer tests", ""); err != nil { t.Fatal(err) } // The pump has seen the call once the turn's own event is behind it. @@ -1067,19 +1116,26 @@ func TestAppSettingsAndSpendReadTheProfile(t *testing.T) { } } -// models.list answers what the profile remembers of the service's list, and -// reaches no network when there is one. -func TestAppModelsListReadsTheRememberedList(t *testing.T) { - profile := t.TempDir() - t.Setenv(config.ProfileDirEnv, profile) - t.Setenv(home.EnvVar, t.TempDir()) - t.Setenv("CODEAF_BASE_URL", "http://127.0.0.1:1/v1") +// MODELS.LIST IS THE TERMINAL'S PICKER. With an OpenRouter-style profile — a +// key, the default service, its model list remembered from an earlier +// listing — the answer is that list as the picker offers it, each row's +// provider its own, and the model a new conversation runs on is marked +// default; a profile whose default no list names still gets it, marked. +func TestAppModelsListIsThePickersList(t *testing.T) { + root := resolvedTempDir(t) + _, _ = wakeEnvironment(t, root) settings, err := config.LoadKeyless() if err != nil { t.Fatal(err) } options := catalog.Options{BaseURL: settings.BaseURL, Dir: settings.ProfileDir} - if err := catalog.Remember(options, []catalog.Model{{ID: "qwen/qwen3-coder", Name: "Qwen3 Coder"}, {ID: "deepseek/deepseek-v4-flash"}}); err != nil { + if err := catalog.Remember(options, []catalog.Model{ + {ID: "deepseek/deepseek-v4.1-flash", Name: "DeepSeek V4.1 Flash", InputModalities: []string{"text"}, OutputModalities: []string{"text"}}, + {ID: "qwen/qwen3-coder", Name: "Qwen3 Coder", InputModalities: []string{"text"}, OutputModalities: []string{"text"}}, + }); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(config.BudgetConfigPath(settings.ProfileDir), []byte(`{"model.talk":"deepseek/deepseek-v4.1-flash"}`), 0o600); err != nil { t.Fatal(err) } backend := newAppBackend(newAppRecorder(), nil) @@ -1089,18 +1145,19 @@ func TestAppModelsListReadsTheRememberedList(t *testing.T) { t.Fatal(err) } byID := map[string]appwire.Model{} + defaults := 0 for _, m := range models { byID[m.ID] = m + if m.Default { + defaults++ + } } - if m := byID["qwen/qwen3-coder"]; m.Name != "Qwen3 Coder" || m.Provider != "qwen" { + if m := byID["deepseek/deepseek-v4.1-flash"]; !m.Default || m.Provider != "deepseek" || defaults != 1 { t.Fatalf("models = %+v", models) } - if m := byID["deepseek/deepseek-v4-flash"]; m.Name != "deepseek/deepseek-v4-flash" || m.Provider != "deepseek" { + if m := byID["qwen/qwen3-coder"]; m.Provider != "qwen" || m.Default { t.Fatalf("models = %+v", models) } - if backend.models != nil { - t.Fatal("a remembered list still started a fetch") - } } // A REAL ALWAYS, HONOURED BY THE ENGINE. Every shell call asks first; the @@ -1123,11 +1180,11 @@ func TestAppARealAlwaysIsHonouredOnTheNextCall(t *testing.T) { rec := newAppRecorder() backend := newAppBackend(rec, appOpenInProcess) defer backend.Close() - agent, err := backend.CreateAgent(appwire.CreateAsk{Workspace: workspace}) + agent, err := mustCreate(t, backend, appwire.CreateAsk{Workspace: workspace}) if err != nil { t.Fatalf("create = %v", err) } - first, err := backend.Send(agent.ID, "run the probe") + first, err := backend.Send(agent.ID, "run the probe", "") if err != nil { t.Fatal(err) } @@ -1157,7 +1214,7 @@ func TestAppARealAlwaysIsHonouredOnTheNextCall(t *testing.T) { return ok && c.Agent.State == appwire.StateIdle }) - second, err := backend.Send(agent.ID, "run it again") + second, err := backend.Send(agent.ID, "run it again", "") if err != nil { t.Fatal(err) } @@ -1194,7 +1251,7 @@ func TestAppARealAgentKeepsItsFirstMessageName(t *testing.T) { rec := newAppRecorder() backend := newAppBackend(rec, appOpenInProcess) words := "say done and then tell me about the weather in the harbour today" - agent, err := backend.CreateAgent(appwire.CreateAsk{Workspace: workspace, Text: words}) + agent, err := mustCreate(t, backend, appwire.CreateAsk{Workspace: workspace, Text: words}) if err != nil { backend.Close() t.Fatalf("create = %v", err) @@ -1223,3 +1280,217 @@ func TestAppARealAgentKeepsItsFirstMessageName(t *testing.T) { t.Fatalf("listed = %+v, %v", agents, err) } } + +// AGENTS.CREATE ANSWERS AT ONCE. The answer leaves while the conversation is +// still being assembled — here an opener that does not return until it is +// let — with the agent already working and named; the turn starts once the +// conversation is open. A conversation that cannot be opened ends as a failed +// turn and a stopped agent the person can retry. +func TestAppCreateAnswersBeforeTheConversationIsOpen(t *testing.T) { + conv := &fakeConversation{script: []session.Event{{Kind: session.EventTurnDone}}} + gate := make(chan struct{}) + var mu sync.Mutex + var opens, releases int + inner := fakeOpener(conv, &opens, &releases, &mu) + rec := newAppRecorder() + backend := newFakeBackend(rec, func(ask appOpenAsk) (*appConn, error) { + <-gate + if !ask.Minted || ask.Model != "qwen/qwen3-coder" { + t.Errorf("the open was asked %+v", ask) + } + return inner(ask) + }) + defer backend.Close() + start := time.Now() + agent, err := backend.CreateAgent(appwire.CreateAsk{Workspace: t.TempDir(), Model: "qwen/qwen3-coder", Text: "fix the parser", ClientID: "c-1"}) + took := time.Since(start) + if err != nil { + t.Fatal(err) + } + if took > 50*time.Millisecond || agent.State != appwire.StateWorking || agent.Title != "fix the parser" || agent.ID != "5e55a0f1" { + t.Fatalf("create took %v and answered %+v", took, agent) + } + if listed, _ := backend.ListAgents(""); len(listed) != 1 || listed[0].ID != agent.ID { + t.Fatalf("an agent being assembled is not listed: %+v", listed) + } + close(gate) + i := rec.waitFor(t, 5*time.Second, func(event string, data any) bool { + e, ok := data.(appwire.EntryAdded) + return ok && e.Entry.Kind == appwire.EntryUser + }) + rec.mu.Lock() + user := rec.data[i].(appwire.EntryAdded).Entry + rec.mu.Unlock() + if user.ClientID != "c-1" || user.ID != "u0" || user.Text != "fix the parser" { + t.Fatalf("the person's entry = %+v", user) + } + rec.waitFor(t, 5*time.Second, appTurnDone) + + failing := newFakeBackend(rec, func(appOpenAsk) (*appConn, error) { return nil, errors.New("the profile could not be read") }) + defer failing.Close() + broken, err := failing.CreateAgent(appwire.CreateAsk{Workspace: t.TempDir(), Text: "hello"}) + if err != nil { + t.Fatal(err) + } + rec.waitFor(t, 5*time.Second, func(event string, data any) bool { + d, ok := data.(appwire.TurnDone) + return ok && d.Agent == broken.ID && !d.OK && strings.Contains(d.Error, "profile could not be read") + }) + rec.waitFor(t, 5*time.Second, func(event string, data any) bool { + c, ok := data.(appwire.AgentChanged) + return ok && c.Agent.ID == broken.ID && c.Agent.State == appwire.StateStopped + }) +} + +// The engine's dim notices are quiet entries, and set_effort moves the level. +func TestAppNoticesAreQuietAndEffortMoves(t *testing.T) { + conv := &fakeConversation{script: []session.Event{ + {Kind: session.EventNotice, Text: "skills carried: skill-installer, find-skills"}, + {Kind: session.EventTurnDone}, + }} + var mu sync.Mutex + var opens, releases int + rec := newAppRecorder() + backend := newFakeBackend(rec, fakeOpener(conv, &opens, &releases, &mu)) + defer backend.Close() + agent, err := mustCreate(t, backend, appwire.CreateAsk{Workspace: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + if _, err := backend.Send(agent.ID, "go", ""); err != nil { + t.Fatal(err) + } + rec.waitFor(t, 5*time.Second, func(event string, data any) bool { + e, ok := data.(appwire.EntryAdded) + return ok && e.Entry.Kind == appwire.EntryNotice && e.Entry.Quiet && strings.HasPrefix(e.Entry.Text, "skills carried") + }) + rec.waitFor(t, 5*time.Second, appTurnDone) + moved, err := backend.SetEffort(agent.ID, appwire.EffortHigh) + if err != nil || moved.Effort != appwire.EffortHigh { + t.Fatalf("set_effort = %+v, %v", moved, err) + } +} + +// appTwoMessageSource is a scripted model whose turn says something, calls one +// shell command, and then says something else: two assistant messages with a +// tool call between them. +func appTwoMessageSource(t *testing.T, command string) *httptest.Server { + t.Helper() + return httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + var envelope struct { + Messages []struct { + Role string `json:"role"` + } `json:"messages"` + Tools []json.RawMessage `json:"tools"` + Stream bool `json:"stream"` + } + if err := json.NewDecoder(request.Body).Decode(&envelope); err != nil { + http.Error(writer, err.Error(), http.StatusBadRequest) + return + } + toolTurn := len(envelope.Tools) > 0 && (len(envelope.Messages) == 0 || envelope.Messages[len(envelope.Messages)-1].Role != "tool") + content := `"content":"All done, the probe printed."` + finish := "stop" + if toolTurn { + arguments, _ := json.Marshal(map[string]string{"command": command}) + content = `"content":"Let me run the probe first.","tool_calls":[{"index":0,"id":"call-probe","type":"function","function":{"name":"bash","arguments":` + strconv.Quote(string(arguments)) + `}}]` + finish = "tool_calls" + } + body := `{"id":"two-messages","choices":[{"index":0,"delta":{"role":"assistant",` + content + `},"finish_reason":"` + finish + `"}]}` + if envelope.Stream { + writer.Header().Set("Content-Type", "text/event-stream") + _, _ = writer.Write([]byte("data: " + body + "\n\ndata: [DONE]\n\n")) + return + } + _, _ = writer.Write([]byte(`{"choices":[{"index":0,"message":{"role":"assistant","content":"done"},"finish_reason":"stop"}]}`)) + })) +} + +// ONE MESSAGE, ONE ENTRY. A real turn that says something, runs a tool and +// says something else, recorded event by event: the person's message is one +// user entry carrying the app's client id; each assistant message is exactly +// one answer entry, with the msg its deltas carried; the tool row carries the +// terminal's own line for the call; and agents.entries afterwards names every +// one of those entries by the id it was sent with. +func TestAppATurnWithToolsSaysEachMessageOnce(t *testing.T) { + root := resolvedTempDir(t) + _, workspace := wakeEnvironment(t, root) + model := appTwoMessageSource(t, "printf app-two-messages") + t.Cleanup(model.Close) + t.Setenv("CODEAF_BASE_URL", model.URL) + state := filepath.Join(root, "state") + if err := os.MkdirAll(state, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(state, "config.json"), []byte(`{"tools.approvalMode":"allow"}`), 0o600); err != nil { + t.Fatal(err) + } + rec := newAppRecorder() + backend := newAppBackend(rec, appOpenInProcess) + defer backend.Close() + agent, err := backend.CreateAgent(appwire.CreateAsk{Workspace: workspace, Text: "run the probe", ClientID: "bubble-7"}) + if err != nil { + t.Fatal(err) + } + d := rec.waitFor(t, 30*time.Second, appTurnDone) + rec.mu.Lock() + events := append([]string(nil), rec.events[:d+1]...) + data := append([]any(nil), rec.data[:d+1]...) + rec.mu.Unlock() + if done := data[d].(appwire.TurnDone); !done.OK { + t.Fatalf("turn.done = %+v", done) + } + var users, answers []appwire.Entry + deltas := map[int]string{} + var title string + for j, event := range events { + switch v := data[j].(type) { + case appwire.EntryAdded: + switch v.Entry.Kind { + case appwire.EntryUser: + users = append(users, v.Entry) + case appwire.EntryAnswer: + answers = append(answers, v.Entry) + } + case appwire.TurnDelta: + if event == appwire.EventTurnDelta && v.Kind == appwire.DeltaText { + deltas[v.Msg] += v.Text + } + case appwire.TurnTool: + if v.Phase == appwire.PhaseBegin { + title = v.Title + } + } + } + if len(users) != 1 || users[0].ClientID != "bubble-7" || users[0].Text != "run the probe" { + t.Fatalf("user entries = %+v", users) + } + if len(answers) != 2 || answers[0].Msg == nil || *answers[0].Msg != 0 || answers[1].Msg == nil || *answers[1].Msg != 1 { + t.Fatalf("answer entries = %+v", answers) + } + if answers[0].Text != "Let me run the probe first." || answers[1].Text != "All done, the probe printed." { + t.Fatalf("answers = %q, %q", answers[0].Text, answers[1].Text) + } + if strings.TrimSpace(deltas[0]) != answers[0].Text || strings.TrimSpace(deltas[1]) != answers[1].Text { + t.Fatalf("deltas by message = %v", deltas) + } + if title != "bash printf app-two-messages" { + t.Fatalf("the tool's title = %q", title) + } + listed, _, err := backend.Entries(agent.ID, "", appwire.MaxEntries) + if err != nil { + t.Fatal(err) + } + ids := map[string]appwire.Entry{} + for _, e := range listed { + ids[e.ID] = e + } + for _, live := range append(users, answers...) { + if back, ok := ids[live.ID]; !ok || back.Kind != live.Kind || back.Text != live.Text { + t.Fatalf("live entry %s (%s %q) reads back as %+v; listed %+v", live.ID, live.Kind, live.Text, back, listed) + } + } + if tool, ok := ids["tcall-probe"]; !ok || tool.Title != "bash printf app-two-messages" { + t.Fatalf("the tool entry = %+v; listed %+v", tool, listed) + } +} diff --git a/internal/appwire/outbox.go b/internal/appwire/outbox.go index e30d91af53..a093c87227 100644 --- a/internal/appwire/outbox.go +++ b/internal/appwire/outbox.go @@ -51,8 +51,8 @@ func NewOutbox(emit Emitter) *Outbox { } // Delta queues one chunk of streamed text, folding it into the chunk before it -// when that one is the same turn and kind and still waiting. -func (o *Outbox) Delta(agent string, turn uint64, kind, text string) { +// when that one is the same turn, message and kind and still waiting. +func (o *Outbox) Delta(agent string, turn uint64, msg int, kind, text string) { if text == "" { return } @@ -63,14 +63,14 @@ func (o *Outbox) Delta(agent string, turn uint64, kind, text string) { } if n := len(o.queue); n > 0 { last := o.queue[n-1].delta - if last != nil && last.Agent == agent && last.Turn == turn && last.Kind == kind && len(last.Text) < DeltaBytes { + if last != nil && last.Agent == agent && last.Turn == turn && last.Msg == msg && last.Kind == kind && len(last.Text) < DeltaBytes { last.Text += text o.mu.Unlock() o.poke() return } } - o.queue = append(o.queue, outboxItem{delta: &TurnDelta{Agent: agent, Turn: turn, Kind: kind, Text: text}, born: o.now()}) + o.queue = append(o.queue, outboxItem{delta: &TurnDelta{Agent: agent, Turn: turn, Msg: msg, Kind: kind, Text: text}, born: o.now()}) o.mu.Unlock() o.poke() } diff --git a/internal/appwire/server.go b/internal/appwire/server.go index 6621c9c45b..fe100aad1d 100644 --- a/internal/appwire/server.go +++ b/internal/appwire/server.go @@ -144,6 +144,9 @@ type request struct { func (s *Server) Serve(ctx context.Context, in io.Reader, hello Hello, engine Engine) error { hello.Contract = Contract s.Emit(EventHello, hello) + if warmer, ok := engine.(Warmer); ok { + go warmer.Warm() + } lines := make(chan []byte) readErr := make(chan error, 1) @@ -335,6 +338,29 @@ var methods = map[string]method{ } return agentResult{agent}, nil }, + "agents.set_effort": func(e Engine, raw json.RawMessage) (any, error) { + var p struct { + ID string `json:"id"` + Effort string `json:"effort"` + } + if err := decode(raw, &p); err != nil { + return nil, err + } + effort := strings.ToLower(strings.TrimSpace(p.Effort)) + if strings.TrimSpace(p.ID) == "" { + return nil, Errorf(CodeBadParams, "agents.set_effort needs an id") + } + switch effort { + case EffortLow, EffortMedium, EffortHigh: + default: + return nil, Errorf(CodeBadParams, "effort is low, medium or high") + } + agent, err := e.SetEffort(p.ID, effort) + if err != nil { + return nil, err + } + return agentResult{agent}, nil + }, "agents.retry": func(e Engine, raw json.RawMessage) (any, error) { id, err := decodeID(raw, "id") if err != nil { @@ -388,8 +414,9 @@ var methods = map[string]method{ }, "agents.send": func(e Engine, raw json.RawMessage) (any, error) { var p struct { - ID string `json:"id"` - Text string `json:"text"` + ID string `json:"id"` + Text string `json:"text"` + ClientID string `json:"client_id"` } if err := decode(raw, &p); err != nil { return nil, err @@ -400,7 +427,7 @@ var methods = map[string]method{ if strings.TrimSpace(p.Text) == "" { return nil, Errorf(CodeBadParams, "agents.send needs some text") } - turn, err := e.Send(p.ID, p.Text) + turn, err := e.Send(p.ID, p.Text, p.ClientID) if err != nil { return nil, err } diff --git a/internal/appwire/server_test.go b/internal/appwire/server_test.go index 5726923aa0..d6dc496270 100644 --- a/internal/appwire/server_test.go +++ b/internal/appwire/server_test.go @@ -62,7 +62,10 @@ func (f *fakeEngine) OpenAgent(id string) (Agent, error) { } return Agent{ID: id, State: StateIdle}, nil } -func (f *fakeEngine) Send(id, text string) (uint64, error) { +func (f *fakeEngine) SetEffort(id, effort string) (Agent, error) { + return Agent{ID: id, Effort: effort, State: StateIdle}, nil +} +func (f *fakeEngine) Send(id, text, clientID string) (uint64, error) { if f.block != nil { <-f.block } @@ -364,11 +367,11 @@ func TestOutboxFoldsDeltasAndKeepsOrderWithToolsAndTheEnd(t *testing.T) { box := NewOutbox(rec) box.Event(EventTurnStarted, TurnStarted{Agent: "a", Turn: 1}) for _, word := range []string{"hel", "lo ", "there"} { - box.Delta("a", 1, DeltaText, word) + box.Delta("a", 1, 0, DeltaText, word) } - box.Delta("a", 1, DeltaThinking, "hmm") + box.Delta("a", 1, 0, DeltaThinking, "hmm") box.Event(EventTurnTool, TurnTool{Agent: "a", Turn: 1, Call: "c", Name: "read", Phase: PhaseBegin}) - box.Delta("a", 1, DeltaText, "after") + box.Delta("a", 1, 0, DeltaText, "after") box.Event(EventTurnDone, TurnDone{Agent: "a", Turn: 1, OK: true}) box.Close() want := []string{EventTurnStarted, EventTurnDelta, EventTurnDelta, EventTurnTool, EventTurnDelta, EventTurnDone} @@ -390,7 +393,7 @@ func TestOutboxSendsALoneDeltaAfterTheWindowAndAFullOneAtOnce(t *testing.T) { rec := &recorder{} box := NewOutbox(rec) defer box.Close() - box.Delta("a", 1, DeltaText, "x") + box.Delta("a", 1, 0, DeltaText, "x") deadline := time.Now().Add(2 * time.Second) for { rec.mu.Lock() @@ -405,8 +408,8 @@ func TestOutboxSendsALoneDeltaAfterTheWindowAndAFullOneAtOnce(t *testing.T) { time.Sleep(time.Millisecond) } big := strings.Repeat("y", DeltaBytes) - box.Delta("a", 1, DeltaText, big) - box.Delta("a", 1, DeltaText, "z") + box.Delta("a", 1, 0, DeltaText, big) + box.Delta("a", 1, 0, DeltaText, "z") box.Close() if len(rec.events) != 3 { t.Fatalf("events = %d, want the lone one, the full one and the next", len(rec.events)) @@ -484,3 +487,32 @@ func TestWaveOneMethodsAnswerTheirShapes(t *testing.T) { t.Fatalf("set_model without a model = %v", got) } } + +// Two messages of one turn are never folded into one delta: the app keys +// answers by message, and a fold across them would put one message's words +// under the other. +func TestOutboxKeepsMessagesApart(t *testing.T) { + rec := &recorder{} + box := NewOutbox(rec) + box.Delta("a", 1, 0, DeltaText, "first") + box.Delta("a", 1, 1, DeltaText, "second") + box.Close() + if len(rec.data) != 2 || rec.data[0].(TurnDelta).Msg != 0 || rec.data[1].(TurnDelta).Msg != 1 || rec.data[1].(TurnDelta).Text != "second" { + t.Fatalf("deltas = %+v", rec.data) + } +} + +// agents.set_effort takes low, medium or high and nothing else. +func TestSetEffortTakesThreeWords(t *testing.T) { + w := startWire(t, &fakeEngine{}) + w.next() + w.send(`{"id":1,"method":"agents.set_effort","params":{"id":"a1","effort":"High"}}`) + got := w.next() + if agent := got["result"].(map[string]any)["agent"].(map[string]any); agent["effort"] != "high" { + t.Fatalf("set_effort = %v", got) + } + w.send(`{"id":2,"method":"agents.set_effort","params":{"id":"a1","effort":"max"}}`) + if got := w.next(); got["error"].(map[string]any)["code"] != CodeBadParams { + t.Fatalf("set_effort max = %v", got) + } +} diff --git a/internal/appwire/wire.go b/internal/appwire/wire.go index f8ee7ccc26..7013c101db 100644 --- a/internal/appwire/wire.go +++ b/internal/appwire/wire.go @@ -97,10 +97,13 @@ type Hello struct { // Agent is one conversation as the app sees it. ID is the conversation's // session id, which is its folder's name on disk and stable across launches. type Agent struct { - ID string `json:"id"` - Title string `json:"title"` - Workspace string `json:"workspace"` - Model string `json:"model,omitempty"` + ID string `json:"id"` + Title string `json:"title"` + Workspace string `json:"workspace"` + Model string `json:"model,omitempty"` + // Effort is how hard the model is asked to think: low, medium or high, + // and empty when the conversation runs on the model's own default. + Effort string `json:"effort,omitempty"` State string `json:"state"` UpdatedAt time.Time `json:"updated_at"` } @@ -169,13 +172,24 @@ type Entry struct { // Text is the words of a user, answer, receipt, notice or error entry. Text string `json:"text,omitempty"` Thinking string `json:"thinking,omitempty"` - // Call, Name, OK and Summary describe a tool entry. + // ClientID echoes the id the app sent with the person's message, on that + // message's user entry. + ClientID string `json:"client_id,omitempty"` + // Msg is an answer's place among the assistant messages of its turn, + // counted from zero; the turn.delta lines of that message carry the same. + Msg *int `json:"msg,omitempty"` + // Call, Name, Title, Detail, OK and Summary describe a tool entry: Title + // is the one line the terminal shows for the call, Detail its short result. Call string `json:"call,omitempty"` Name string `json:"name,omitempty"` + Title string `json:"title,omitempty"` + Detail string `json:"detail,omitempty"` OK *bool `json:"ok,omitempty"` Summary string `json:"summary,omitempty"` - // Ref is what a notice points at; ErrorKind and Status sort an error. + // Ref is what a notice points at, and Quiet marks a notice the terminal + // shows as one dim line; ErrorKind and Status sort an error. Ref string `json:"ref,omitempty"` + Quiet bool `json:"quiet,omitempty"` ErrorKind string `json:"error_kind,omitempty"` Status int `json:"status,omitempty"` } @@ -203,8 +217,17 @@ type Model struct { ID string `json:"id"` Name string `json:"name"` Provider string `json:"provider"` + // Default marks the model a new conversation runs on. + Default bool `json:"default,omitempty"` } +// The three efforts agents.set_effort takes. +const ( + EffortLow = "low" + EffortMedium = "medium" + EffortHigh = "high" +) + // Settings are the few defaults the app shows. A figure nobody set is null. type Settings struct { DefaultModel string `json:"default_model"` @@ -245,6 +268,7 @@ type ( TurnDelta struct { Agent string `json:"agent"` Turn uint64 `json:"turn"` + Msg int `json:"msg"` Kind string `json:"kind"` Text string `json:"text"` } @@ -253,6 +277,8 @@ type ( Turn uint64 `json:"turn"` Call string `json:"call"` Name string `json:"name"` + Title string `json:"title,omitempty"` + Detail string `json:"detail,omitempty"` Phase string `json:"phase"` OK *bool `json:"ok,omitempty"` Summary string `json:"summary,omitempty"` @@ -328,7 +354,9 @@ type Engine interface { SetModel(id, model string) (Agent, error) Retry(id string) (uint64, error) OpenAgent(id string) (Agent, error) - Send(id, text string) (uint64, error) + // Send starts a turn; clientID is echoed on the person's entry. + Send(id, text, clientID string) (uint64, error) + SetEffort(id, effort string) (Agent, error) Stop(id string) error CloseAgent(id string) error ListAsks(agent string) ([]Ask, error) @@ -347,11 +375,21 @@ type Engine interface { // CreateAsk is agents.create's params. An empty workspace is the person's home // folder; a non-empty Text starts the first turn at once. +// +// It answers at once: the conversation is assembled behind the answer. type CreateAsk struct { Workspace string `json:"workspace"` Model string `json:"model"` BudgetUSD *float64 `json:"budget_usd"` Text string `json:"text"` + ClientID string `json:"client_id"` +} + +// Warmer is an engine with something slow it can start on before anything is +// asked of it. The server calls Warm once, on its own goroutine, after the +// hello line is queued, so warming never delays the hello. +type Warmer interface { + Warm() } // frame is one line on the way out: a response (ID set) or an event (Event diff --git a/internal/tui3/appseams.go b/internal/tui3/appseams.go new file mode 100644 index 0000000000..8153562398 --- /dev/null +++ b/internal/tui3/appseams.go @@ -0,0 +1,51 @@ +package tui3 + +import ( + "strings" + + modelcatalog "github.com/Agent-Field/codeaf/internal/catalog" + "github.com/Agent-Field/codeaf/internal/modelsource" +) + +// The desktop app's backend (cmd/codeaf's app.go) draws the same facts this +// surface draws, and it reads them through these doors rather than restating +// the rules, so a tool row and a model menu say the same thing in both places. + +// ToolTitle is the one line a tool row says for a call: the tool's name and +// what the call is about, exactly as [app.toolLineLayout] lays them out before +// any width is spent (`read src/auth/session.ts`, `bash pnpm test`). +func ToolTitle(tool, args, hint string) string { + name, _ := toolWords(tool, hint) + target := toolTarget(tool, args, hint) + if target == "" { + return name + } + if name == "" { + return target + } + return name + " " + target +} + +// PickerModels is the chat model picker's selectable rows for a profile's +// services: the default service's rows from defaults (the process catalog, as +// the terminal's Options.Models), every other connected service's from +// forService, and the small model caches on disk where the terminal reads them. +// Rows that are a group's notice rather than a model are left out. It never +// reaches the network. +func PickerModels(profileDir string, sources modelsource.Set, defaults func() []Model, forService func(modelsource.Connected) []Model) []Model { + a := &app{profileDir: profileDir, sources: sources, models: defaults, modelsForService: forService} + a.modelLists = newLearned(readModelCacheName) + // The names the terminal learns when it opens, read here at once. + a.modelLists.learn(modelCacheNameFor("", modelcatalog.DefaultBaseURL)) + for _, service := range sources.All() { + a.modelLists.learn(modelCacheNameFor(service.Source.ID, service.Address)) + } + var rows []Model + for _, model := range a.modelList() { + if model.Unavailable || model.Notice != "" || strings.TrimSpace(model.ID) == "" { + continue + } + rows = append(rows, model) + } + return rows +} From fa82f3e9f7a7047e8d42a04c7c351b3ff6e9ad90 Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Thu, 15 Oct 2026 07:02:45 -0400 Subject: [PATCH 005/459] codeaf app: stops are receipts, interrupted answers read back as answers, nested tasks, trace recorder A stopped turn ends turn.done {ok:false, error_kind:stopped} with one receipt entry; the engine ends an interrupt cleanly, so the person's stop is what says so. The journal keeps a cut-short answer as an aside, and agents.entries now reads it back as the answer it streamed as rather than a second copy as a notice. Task proposals are queued tasks, and tasks carry parent and group. docs/design/app-v3/tools/app-trace.py records real protocol traces. --- cmd/codeaf/app.go | 75 ++++++++++++++++++++++----- cmd/codeaf/app_test.go | 70 +++++++++++++++++++++++-- docs/changes/unreleased/0-app-rev2.md | 17 ++++++ internal/appwire/wire.go | 8 ++- 4 files changed, 153 insertions(+), 17 deletions(-) create mode 100644 docs/changes/unreleased/0-app-rev2.md diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index d992e4e359..e05f04ef8e 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -695,7 +695,7 @@ func (b *appBackend) hold(a *appAgent, conn *appConn, workspace string) appwire. guard.Go("app/tasks", func() { defer close(live.lanesDone) for event := range lane { - if event.Kind == session.EventTaskUpdate && event.Task != nil { + if (event.Kind == session.EventTaskUpdate || event.Kind == session.EventTaskProposal) && event.Task != nil { b.taskNotice(id, live, event.Task) } } @@ -1160,23 +1160,39 @@ func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, n if title := strings.TrimSpace(event.Text); title != "" { changed(func(v *appwire.Agent) { v.Title = title }) } - case session.EventTaskUpdate: + case session.EventTaskUpdate, session.EventTaskProposal: if event.Task != nil { b.taskNotice(id, live, event.Task) } } } sayReply() + byPerson := false b.locked(func() { - if !ok && live.stopping { - kind = appwire.ErrorStopped + byPerson = live.stopping + // A STOPPED TURN ENDS CLEANLY ON THE ENGINE'S SIDE — an interrupt is + // not an error there — so the person's stop is what says it stopped. + if live.stopping { + ok, kind, status = false, appwire.ErrorStopped, 0 } a.failed = "" if !ok { a.failed = text } }) - if !ok { + switch { + case !ok && kind == appwire.ErrorStopped: + // A STOP IS NOT AN ANSWER AND NOT AN ERROR. It is said once, as a + // receipt, and the turn's end says stopped; the words the turn had + // streamed are the answer they already were. + failure = "stopped" + at := b.now() + words := "Stopped · " + at.Local().Format("15:04") + if byPerson { + words = "Stopped · you · " + at.Local().Format("15:04") + } + entry(appwire.Entry{ID: appStopEntryID(n), Kind: appwire.EntryReceipt, Text: words}) + case !ok: entry(appwire.Entry{ID: appErrorEntryID(n), Kind: appwire.EntryError, Text: failure, ErrorKind: kind, Status: status}) } live.box.Event(appwire.EventTurnDone, appwire.TurnDone{Agent: id, Turn: turn, OK: ok, Error: failure, ErrorKind: kind, Status: status}) @@ -1221,6 +1237,8 @@ func appLiveNoticeID(n, k int) string { return "q" + strconv.Itoa(n) + "." + str func appErrorEntryID(n int) string { return "x" + strconv.Itoa(n) } +func appStopEntryID(n int) string { return "s" + strconv.Itoa(n) } + func appReceiptEntryID(askID string) string { return "r" + askID } // appUserCount is how many of the person's messages a transcript holds. @@ -1296,19 +1314,35 @@ func appTaskState(state string) string { // taskNotice turns one task update into task.changed, once per change: the // same update can arrive on the turn's stream and on the task lane. func (b *appBackend) taskNotice(id string, live *appLive, notice *session.TaskNotice) { - state := appTaskState(string(notice.State)) + // A PROPOSAL IS A TASK THAT HAS NOT STARTED, which is queued: it carries + // no state of its own until its first update, under the same id. + raw := string(notice.State) + if strings.TrimSpace(raw) == "" { + raw = string(session.TaskQueued) + } + state := appTaskState(raw) if state == "" { return } - task := appwire.Task{ID: strconv.FormatUint(notice.ID, 10), Agent: id, Title: notice.Title, State: state, UpdatedAt: b.now()} + task := appwire.Task{ID: strconv.FormatUint(notice.ID, 10), Agent: id, Title: notice.Title, State: state, Group: notice.Run, UpdatedAt: b.now()} + if notice.Parent != 0 && notice.Parent != notice.ID { + task.Parent = strconv.FormatUint(notice.Parent, 10) + } same := false b.locked(func() { if before, ok := live.tasks[task.ID]; ok && before.State == task.State && (task.Title == "" || before.Title == task.Title) { same = true return } + before := live.tasks[task.ID] if task.Title == "" { - task.Title = live.tasks[task.ID].Title + task.Title = before.Title + } + if task.Parent == "" { + task.Parent = before.Parent + } + if task.Group == "" { + task.Group = before.Group } live.tasks[task.ID] = task }) @@ -1555,10 +1589,20 @@ func appEntriesOf(agent string, items []session.DisplayEntry, at time.Time) []ap case "user": n, m, k = n+1, 0, 0 entry.ID, entry.Kind, entry.Text = appUserEntryID(n), appwire.EntryUser, item.Text - case "assistant": + case "assistant", "aside": if strings.TrimSpace(item.Text) == "" { continue } + // AN ANSWER CUT SHORT BY A STOP IS STILL THE ANSWER IT WAS. The + // journal keeps it apart from the conversation, as a line the + // session wrote, but it is the message the person watched stream + // under this id; read back as a notice it was that paragraph said + // a second time. Every other aside is the session's own dim line. + if item.Role == "aside" && !item.Interrupted { + entry.ID, entry.Kind, entry.Text, entry.Quiet = appNoteEntryID(n, k), appwire.EntryNotice, item.Text, true + k++ + break + } msg := m entry.ID, entry.Kind, entry.Text, entry.Msg = appAnswerEntryID(n, msg), appwire.EntryAnswer, strings.TrimSpace(item.Text), &msg m++ @@ -1571,11 +1615,11 @@ func appEntriesOf(agent string, items []session.DisplayEntry, at time.Time) []ap entry.Title = redact.Secrets(tui3.ToolTitle(item.Tool, item.Args, item.Hint)) entry.Summary = redact.Secrets(item.Hint) entry.Detail = appDetail(redact.Secrets(item.Output)) - case "note", "aside": + case "note": if strings.TrimSpace(item.Text) == "" { continue } - entry.ID, entry.Kind, entry.Text = appNoteEntryID(n, k), appwire.EntryNotice, item.Text + entry.ID, entry.Kind, entry.Text, entry.Quiet = appNoteEntryID(n, k), appwire.EntryNotice, item.Text, true k++ default: continue @@ -1840,7 +1884,11 @@ func (b *appBackend) ListTasks(id string) ([]appwire.Task, error) { title = row.Label } index[row.ID] = len(tasks) - tasks = append(tasks, appwire.Task{ID: row.ID, Agent: id, Title: title, State: state, UpdatedAt: at}) + parent := row.Parent + if parent == row.ID { + parent = "" + } + tasks = append(tasks, appwire.Task{ID: row.ID, Agent: id, Parent: parent, Title: title, State: state, UpdatedAt: at}) } } for taskID, task := range heard { @@ -1848,6 +1896,9 @@ func (b *appBackend) ListTasks(id string) ([]appwire.Task, error) { if task.Title == "" { task.Title = tasks[i].Title } + if task.Parent == "" { + task.Parent = tasks[i].Parent + } tasks[i] = task continue } diff --git a/cmd/codeaf/app_test.go b/cmd/codeaf/app_test.go index 80d7ef034d..31f26e3fb7 100644 --- a/cmd/codeaf/app_test.go +++ b/cmd/codeaf/app_test.go @@ -429,8 +429,8 @@ func TestAppSendMapsSessionEventsOntoTurnEvents(t *testing.T) { } } -// A turn that errs ends not ok with the reason, and a second send while one is -// running is refused busy rather than queued behind it. +// A second send while a turn runs is refused busy rather than queued behind +// it, and a turn the person stops ends stopped, said once as a receipt. func TestAppSendRefusesBusyAndReportsAFailedTurn(t *testing.T) { hold := make(chan struct{}) conv := &fakeConversation{hold: hold, script: []session.Event{{Kind: session.EventError, Err: errors.New("the provider refused")}}} @@ -459,9 +459,29 @@ func TestAppSendRefusesBusyAndReportsAFailedTurn(t *testing.T) { rec.mu.Lock() d := rec.data[i].(appwire.TurnDone) rec.mu.Unlock() - if d.OK || !strings.Contains(d.Error, "the provider refused") { + // The person stopped it, so it ends stopped — with one receipt saying so, + // and no error entry and no answer made of the word. + if d.OK || d.ErrorKind != appwire.ErrorStopped || d.Error != "stopped" { t.Fatalf("turn.done = %+v", d) } + rec.mu.Lock() + defer rec.mu.Unlock() + receipts := 0 + for _, data := range rec.data { + if e, ok := data.(appwire.EntryAdded); ok { + switch e.Entry.Kind { + case appwire.EntryReceipt: + if strings.HasPrefix(e.Entry.Text, "Stopped · you · ") { + receipts++ + } + case appwire.EntryError, appwire.EntryAnswer: + t.Fatalf("a stopped turn said %+v", e.Entry) + } + } + } + if receipts != 1 { + t.Fatalf("%d stop receipts", receipts) + } } // IDLE CONVERSATIONS ARE LET GO AND COME BACK BY THEMSELVES. One past the idle @@ -1494,3 +1514,47 @@ func TestAppATurnWithToolsSaysEachMessageOnce(t *testing.T) { t.Fatalf("the tool entry = %+v; listed %+v", tool, listed) } } + +// EVERY TASK, NESTED ONES INCLUDED, IS SAID AS IT MOVES. A proposal is a queued +// task; its first update moves it under the same id; a sub-task names its +// parent and an adaptive run's rows name their group. +func TestAppSaysNestedTasksLive(t *testing.T) { + conv := &fakeConversation{script: []session.Event{ + {Kind: session.EventTaskProposal, Task: &session.TaskNotice{ID: 1, Title: "outer"}}, + {Kind: session.EventTaskUpdate, Task: &session.TaskNotice{ID: 1, Title: "outer", State: session.TaskRunning}}, + {Kind: session.EventTaskUpdate, Task: &session.TaskNotice{ID: 2, Title: "middle", Parent: 1, State: session.TaskQueued}}, + {Kind: session.EventTaskUpdate, Task: &session.TaskNotice{ID: 3, Title: "inner", Parent: 2, Run: "run-9", State: session.TaskRunning}}, + {Kind: session.EventTurnDone}, + }} + var mu sync.Mutex + var opens, releases int + rec := newAppRecorder() + backend := newFakeBackend(rec, fakeOpener(conv, &opens, &releases, &mu)) + defer backend.Close() + agent, err := mustCreate(t, backend, appwire.CreateAsk{Workspace: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + if _, err := backend.Send(agent.ID, "make three nested tasks", ""); err != nil { + t.Fatal(err) + } + rec.waitFor(t, 5*time.Second, appTurnDone) + var changes []appwire.Task + rec.mu.Lock() + for _, data := range rec.data { + if c, ok := data.(appwire.TaskChanged); ok { + changes = append(changes, c.Task) + } + } + rec.mu.Unlock() + if len(changes) != 4 || changes[0].State != appwire.TaskQueued || changes[1].State != appwire.TaskRunning || changes[1].ID != "1" { + t.Fatalf("task.changed = %+v", changes) + } + if changes[2].Parent != "1" || changes[3].Parent != "2" || changes[3].Group != "run-9" { + t.Fatalf("nested = %+v", changes) + } + tasks, err := backend.ListTasks(agent.ID) + if err != nil || len(tasks) != 3 { + t.Fatalf("tasks.list = %+v, %v", tasks, err) + } +} diff --git a/docs/changes/unreleased/0-app-rev2.md b/docs/changes/unreleased/0-app-rev2.md new file mode 100644 index 0000000000..278610f1d7 --- /dev/null +++ b/docs/changes/unreleased/0-app-rev2.md @@ -0,0 +1,17 @@ +--- +kind: changed +title: codeaf app answers agents.create at once and names every message once +pr: 0 +surface: [engine] +invalidates: + - "agents.create waited for the whole conversation to be assembled, which took seconds on a real profile. It now mints the folder, answers with the agent in about a millisecond, and assembles behind the answer; the engine's process half is warmed after hello." + - "An answer was drawn twice: once from turn.delta and once from entry.added, and the words before a tool call came back again after it. Deltas and answer entries now carry msg, the person's entry echoes client_id, and entry ids are counted from the transcript so agents.entries names what entry.added named." + - "A stopped turn ended ok, and its partial answer read back from agents.entries as a notice — the same paragraph a second time. A stop now ends turn.done {ok:false, error_kind:stopped} with one receipt, and the partial answer reads back as that answer." + - "models.list read only the default service's remembered list and could answer nothing. It now answers what the terminal's model picker offers, through the picker's own composer, with the default model marked." + - "task.changed said only running and settled tasks. A proposal is now a queued task, and tasks carry parent and group, so a nested task tree arrives whole." +--- + +Tool rows carry `title`, the terminal's own line for the call, and `detail`; +the engine's dim notices are `quiet`; `agents.set_effort` sets low, medium or +high. `docs/design/app-v3/tools/app-trace.py` records real protocol traces +against a real model. diff --git a/internal/appwire/wire.go b/internal/appwire/wire.go index 7013c101db..387905119e 100644 --- a/internal/appwire/wire.go +++ b/internal/appwire/wire.go @@ -110,8 +110,12 @@ type Agent struct { // Task is one node of an agent's task graph. type Task struct { - ID string `json:"id"` - Agent string `json:"agent"` + ID string `json:"id"` + Agent string `json:"agent"` + // Parent is the task that asked for this one, and empty for a task the + // agent itself started; Group names the adaptive run a task belongs to. + Parent string `json:"parent,omitempty"` + Group string `json:"group,omitempty"` Title string `json:"title"` State string `json:"state"` UpdatedAt time.Time `json:"updated_at"` From 8251b5f130474d4a930c7d358095d2728c60f194 Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Thu, 15 Oct 2026 07:22:01 -0400 Subject: [PATCH 006/459] codeaf app wave 2: keys in memory, providers, key check, sign-in, setup, settings, budget and day cap asks, caps.raise --- cmd/codeaf/app.go | 147 ++++-- cmd/codeaf/app_test.go | 30 +- cmd/codeaf/appaccount.go | 318 +++++++++++++ cmd/codeaf/appmoney.go | 443 ++++++++++++++++++ cmd/codeaf/appstate.go | 394 ++++++++++++++++ cmd/codeaf/appwave2_test.go | 382 +++++++++++++++ docs/changes/unreleased/0-app-wave2.md | 19 + internal/appwire/server_test.go | 1 + internal/appwire/setup.go | 221 +++++++++ internal/appwire/setup_test.go | 116 +++++ internal/appwire/wire.go | 152 +++++- internal/config/apikey.go | 6 + internal/config/appcredentials.go | 156 ++++++ internal/config/appcredentials_test.go | 65 +++ internal/config/budget.go | 3 + internal/config/credentials.go | 5 + internal/config/sources.go | 13 + .../manual/chat/running-from-the-terminal.md | 24 + internal/redact/redact.go | 37 ++ 19 files changed, 2490 insertions(+), 42 deletions(-) create mode 100644 cmd/codeaf/appaccount.go create mode 100644 cmd/codeaf/appmoney.go create mode 100644 cmd/codeaf/appstate.go create mode 100644 cmd/codeaf/appwave2_test.go create mode 100644 docs/changes/unreleased/0-app-wave2.md create mode 100644 internal/appwire/setup.go create mode 100644 internal/appwire/setup_test.go create mode 100644 internal/config/appcredentials.go create mode 100644 internal/config/appcredentials_test.go diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index e05f04ef8e..0f59668d3c 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -32,6 +32,7 @@ import ( "encoding/json" "errors" "fmt" + "net/http" "os" "os/signal" "path/filepath" @@ -48,6 +49,7 @@ import ( "github.com/Agent-Field/codeaf/internal/enginehost" "github.com/Agent-Field/codeaf/internal/guard" "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/openrouterauth" "github.com/Agent-Field/codeaf/internal/redact" "github.com/Agent-Field/codeaf/internal/remote" "github.com/Agent-Field/codeaf/internal/session" @@ -99,6 +101,9 @@ func runApp(args []string) error { ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM) defer stop() + // THE APP'S KEYS LIVE IN THIS PROCESS'S MEMORY, installed before anything + // can read a key, and no profile write may put one on disk while they are. + defer config.UseAppCredentials(config.NewAppCredentials())() server := appwire.NewServer(wire) backend := newAppBackend(server, appOpenInProcess) err := server.Serve(ctx, os.Stdin, appwire.Hello{Version: resolvedVersion(), PID: os.Getpid(), Home: home.Dir()}, backend) @@ -127,6 +132,9 @@ type appConversation interface { ResolveQuestion(session.Answer) error SetModel(model string) SetSpendRail(usd float64) error + // Usage is what the conversation has spent so far, by the provider's own + // figures, which its budget is measured against. + Usage() session.Usage // ResolveConsentRemember answers one approval and says how long the answer // lasts; the app uses it for an always that wrote a rule first. ResolveConsentRemember(id uint64, allow bool, scope session.ConsentScope) @@ -223,6 +231,17 @@ type appBackend struct { // warmed closes when [appBackend.Warm] has finished, which Close waits // for: the process half it opens is closed after the backend is. warmed chan struct{} + // keys are the keys the app handed over (appaccount.go); keyClient and + // keyAddress replace the network and the provider's address in a test. + keys *config.AppCredentials + keyClient *http.Client + keyAddress func(provider string) string + // signIn is the browser sign-in waiting for the person, if any. + signIn *openrouterauth.Flow + // state is the app's own file: setup, its settings, budgets and raises. + state *appState + // today replaces the usage ledger's figure for today in a test. + today func(time.Time) float64 } // appAgent is every agent this process has heard of: listed off the disk, @@ -237,6 +256,9 @@ type appAgent struct { // failed is the words of the last turn that ended in failure, which is // what agents.retry sends again; a turn that ends well clears it. failed string + // failedKind is how that turn failed; a turn refused for money is run + // again when the limit is raised. + failedKind string // boot is what agents.create asked of a conversation not yet open, taken // by whichever road opens it first; opening says one is being assembled. boot *appBoot @@ -278,6 +300,12 @@ type appLive struct { consents map[uint64]appConsentCall // stopping says the person stopped the running turn. stopping bool + // money are the open budget asks this process raised itself, by ask id; + // budgetAsked is the last budget level asked about, so it is asked once; + // lastAnswer is the agent's last words, which a budget ask quotes. + money map[string]appMoneyAsk + budgetAsked string + lastAnswer string } // appConsentCall is the call one approval is about. @@ -303,7 +331,13 @@ func (live *appLive) restingState() string { } func newAppBackend(emit appwire.Emitter, open appOpener) *appBackend { + keys := config.AppCredentialSource() + if keys == nil { + keys = config.NewAppCredentials() + } b := &appBackend{ + keys: keys, + state: &appState{}, emit: emit, open: open, now: time.Now, @@ -470,6 +504,14 @@ func (b *appBackend) ListAgents(workspace string) ([]appwire.Agent, error) { } return false } + // AN AGENT NOBODY HAS TOUCHED FOR THE SETTING'S DAYS READS CLOSED: its + // journal is kept, and opening or sending to it opens it again. + idleDays := appIdleCloseDays + _ = b.state.read(func(f *appStateFile) { idleDays = f.idleDays() }) + stale := time.Time{} + if idleDays > 0 { + stale = b.now().Add(-time.Duration(idleDays) * 24 * time.Hour) + } seen := map[string]bool{} var agents []appwire.Agent for _, row := range session.ReadWorld(session.PlacesRoot()).Sessions() { @@ -477,7 +519,11 @@ func (b *appBackend) ListAgents(workspace string) ([]appwire.Agent, error) { continue } seen[row.ID] = true - agents = append(agents, b.remember(row)) + view := b.remember(row) + if !stale.IsZero() && view.State == appwire.StateIdle && !view.UpdatedAt.IsZero() && view.UpdatedAt.Before(stale) && !b.held(view.ID) { + view.State = appwire.StateClosed + } + agents = append(agents, view) } // A conversation this process created and nobody has spoken in yet is not // on the walk, which skips empty shells; it is still the app's agent. @@ -499,8 +545,12 @@ func (b *appBackend) ListAgents(workspace string) ([]appwire.Agent, error) { // agent is named by them. A conversation that cannot be opened says so as a // failed turn and a stopped agent. func (b *appBackend) CreateAgent(ask appwire.CreateAsk) (appwire.Agent, error) { - // engineWorkspace answers the home folder for no workspace at all. - resolved, err := engineWorkspace(ask.Workspace) + // No workspace is the work folder the person chose, or their home folder. + workspace := ask.Workspace + if strings.TrimSpace(workspace) == "" { + _ = b.state.read(func(f *appStateFile) { workspace = f.workFolder() }) + } + resolved, err := engineWorkspace(workspace) if err != nil { return appwire.Agent{}, appwire.Errorf(appwire.CodeBadParams, "%s", err.Error()) } @@ -514,6 +564,21 @@ func (b *appBackend) CreateAgent(ask appwire.CreateAsk) (appwire.Agent, error) { text := strings.TrimSpace(ask.Text) model := strings.TrimSpace(ask.Model) a := b.agent(appAgentID(transcript)) + if ask.BudgetUSD != nil && *ask.BudgetUSD > 0 { + // The budget is the app's to keep: the engine's figure is the open + // conversation's only, and it is bound again at every open. + usd, id := *ask.BudgetUSD, appAgentID(transcript) + err := b.state.write(func(f *appStateFile) error { + if f.Budgets == nil { + f.Budgets = map[string]float64{} + } + f.Budgets[id] = usd + return nil + }) + if err != nil { + return appwire.Agent{}, appEngineError(err) + } + } var view appwire.Agent b.locked(func() { a.transcript = transcript @@ -569,7 +634,7 @@ func (b *appBackend) bootFailed(a *appAgent, text string, err error) { var view appwire.Agent b.locked(func() { if text != "" { - a.failed = text + a.failed, a.failedKind = text, appwire.ErrorOther } a.boot = nil if a.live == nil { @@ -641,6 +706,10 @@ func (b *appBackend) ensureLocked(a *appAgent) (appwire.Agent, bool, error) { return appwire.Agent{}, false, appEngineError(err) } } + if err := b.bindBudget(view.ID, conn.Conv); err != nil { + conn.Release() + return appwire.Agent{}, false, appEngineError(err) + } return b.hold(a, conn, workspace), true, nil } @@ -653,7 +722,7 @@ func (b *appBackend) hold(a *appAgent, conn *appConn, workspace string) appwire. conn: conn, box: appwire.NewOutbox(b.emit), lastUse: b.now(), tasks: map[string]appwire.Task{}, lanesDone: make(chan struct{}), asksDone: make(chan struct{}), questions: map[string]session.Question{}, answered: map[string]string{}, - consents: map[uint64]appConsentCall{}, + consents: map[uint64]appConsentCall{}, money: map[string]appMoneyAsk{}, } title, model := conn.Conv.Title(), conn.Conv.Model() effort := conn.Conv.ReasoningFor(model) @@ -908,6 +977,7 @@ func (b *appBackend) dropAskLocked(live *appLive, askID string) { if q, ok := live.questions[askID]; ok && q.Kind == session.QuestionConsent { delete(live.consents, q.ID) } + delete(live.money, askID) delete(live.questions, askID) delete(live.answered, askID) kept := live.asks[:0] @@ -967,6 +1037,14 @@ func (b *appBackend) died(a *appAgent, live *appLive) { guard.Go("app/finish", func() { b.finish(live) }) } +// held says this process holds a conversation for the agent id. +func (b *appBackend) held(id string) bool { + b.mu.Lock() + defer b.mu.Unlock() + a := b.agents[id] + return a != nil && (a.live != nil || a.opening) +} + // live answers the agent's held conversation, or nil. func (b *appBackend) liveOf(a *appAgent) *appLive { b.mu.Lock() @@ -1031,6 +1109,18 @@ func (b *appBackend) startLocked(a *appAgent, text, clientID string) (uint64, er users := appUserCount(live.conn.Transcript) b.locked(func() { a.users = users }) } + // THE DAY CAP IS CHECKED BEFORE THE ENGINE HEARS OF THE TURN: a refused + // turn sends nothing and costs nothing, and its words wait for a raise. + now := b.now() + if limit := b.dayCap(now); limit > 0 { + if spent := b.spentToday(now); spent >= limit { + return b.dayRefused(a, live, text, spent, limit), nil + } + } + // The agent's budget is bound again, so a raise for today ends at midnight. + if err := b.bindBudget(id, live.conn.Conv); err != nil { + return 0, appEngineError(err) + } var turn uint64 var n int b.locked(func() { @@ -1102,6 +1192,7 @@ func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, n sayReply := func() { words := strings.TrimSpace(reply.String()) if words != "" { + b.locked(func() { live.lastAnswer = words }) at := msg entry(appwire.Entry{ID: appAnswerEntryID(n, at), Kind: appwire.EntryAnswer, Text: words, Thinking: strings.TrimSpace(thinking.String()), Msg: &at}) msg++ @@ -1175,9 +1266,9 @@ func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, n if live.stopping { ok, kind, status = false, appwire.ErrorStopped, 0 } - a.failed = "" + a.failed, a.failedKind = "", "" if !ok { - a.failed = text + a.failed, a.failedKind = text, kind } }) switch { @@ -1211,7 +1302,7 @@ func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, n if held { live.box.Event(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) } - b.spendMoved() + b.spendMoved(func(spend appwire.Spend) { b.moneyAfter(a, live, spend.TodayUSD) }) } // ── entry ids ─────────────────────────────────────────────────────────────── @@ -1453,6 +1544,9 @@ func (b *appBackend) AnswerAsk(id, askID, option, text string) error { if err != nil { return err } + if handled, err := b.answerMoney(a, askID, option, text); handled { + return err + } var live *appLive var q session.Question var key string @@ -1777,22 +1871,6 @@ func appDefaultModel() string { return v3TalkModel("", settings) } -// Settings reads the profile's own rows; a figure nobody set is null. -func (b *appBackend) Settings() (appwire.Settings, error) { - profile := config.ProfileDir() - settings := appwire.Settings{ - DefaultModel: appDefaultModel(), - AsksBefore: config.ToolApprovalModeAt(profile), - BudgetUSD: appFigure(config.SpendRailUSDAt(profile)), - } - dayCap, err := config.DailyBudgetUSDAt(profile) - if err != nil { - return appwire.Settings{}, appEngineError(err) - } - settings.DayCapUSD = appFigure(dayCap) - return settings, nil -} - // appFigure is a money figure, or nil for none. func appFigure(usd float64) *float64 { if usd <= 0 { @@ -1804,15 +1882,7 @@ func appFigure(usd float64) *float64 { // SpendToday reads this machine's usage ledger for today. func (b *appBackend) SpendToday() (appwire.Spend, error) { now := b.now() - lines, err := session.ReadUsage(session.UsageLedgerPath(), now.Add(-48*time.Hour)) - if err != nil { - return appwire.Spend{}, appEngineError(err) - } - spend := appwire.Spend{TodayUSD: session.SpendToday(lines, now)} - if dayCap, err := config.DailyBudgetUSDAt(config.ProfileDir()); err == nil { - spend.DayCapUSD = appFigure(dayCap) - } - return spend, nil + return appwire.Spend{TodayUSD: b.spentToday(now), DayCapUSD: appFigure(b.dayCap(now))}, nil } // appSpendSettle is how long after a turn ends its spending is read again: the @@ -1820,8 +1890,8 @@ func (b *appBackend) SpendToday() (appwire.Spend, error) { const appSpendSettle = 300 * time.Millisecond // spendMoved says spend.changed once a turn's spending has reached the ledger, -// if today's figure moved. -func (b *appBackend) spendMoved() { +// if today's figure moved, and hands today's figure to after. +func (b *appBackend) spendMoved(after func(appwire.Spend)) { time.AfterFunc(appSpendSettle, func() { defer guard.Recover("app/spend") if b.isClosing() { @@ -1839,6 +1909,9 @@ func (b *appBackend) spendMoved() { if moved { b.emit.Emit(appwire.EventSpendChanged, appwire.SpendChanged{Spend: spend}) } + if after != nil { + after(spend) + } }) } @@ -1966,6 +2039,10 @@ func (b *appBackend) Close() error { } b.closing = true crews, warmed = b.crews, b.warmed + if b.signIn != nil { + b.signIn.Cancel() + b.signIn = nil + } for _, a := range b.agents { agents = append(agents, a) } diff --git a/cmd/codeaf/app_test.go b/cmd/codeaf/app_test.go index 31f26e3fb7..8ff29eb451 100644 --- a/cmd/codeaf/app_test.go +++ b/cmd/codeaf/app_test.go @@ -13,6 +13,7 @@ import ( "strconv" "strings" "sync" + "sync/atomic" "testing" "time" @@ -146,6 +147,7 @@ type fakeConversation struct { rail float64 remembered []session.ConsentScope texts []string + cost float64 // onAnswer, when set, is what the engine does with an answer. onAnswer func(session.Answer) } @@ -228,6 +230,11 @@ func (f *fakeConversation) ResolveConsentRemember(id uint64, allow bool, scope s f.remembered = append(f.remembered, scope) f.mu.Unlock() } +func (f *fakeConversation) Usage() session.Usage { + f.mu.Lock() + defer f.mu.Unlock() + return session.Usage{CostUSD: f.cost} +} func (f *fakeConversation) Title() string { return "" } func (f *fakeConversation) NeedsPerson() bool { f.mu.Lock(); defer f.mu.Unlock(); return f.needs } func (f *fakeConversation) WatchTaskUpdates() (<-chan session.Event, func()) { @@ -292,12 +299,19 @@ const appFakeTranscript = "/state/v3/places/proj/5e55a0f1/transcript.jsonl" // newFakeBackend is a backend over a fake opener, minting the fake's own // folder rather than one on disk. +// +// Every fake backend keeps its own app file, because every fake agent has the +// same id and a budget one test gave it must not reach the next. func newFakeBackend(rec appwire.Emitter, open appOpener) *appBackend { backend := newAppBackend(rec, open) backend.mint = func(string) (string, error) { return appFakeTranscript, nil } + backend.state = &appState{path: filepath.Join(os.TempDir(), "codeaf-app-test-"+strconv.Itoa(os.Getpid()), strconv.FormatUint(appFakeStates.Add(1), 10), appStateName)} return backend } +// appFakeStates numbers the fake backends' app files. +var appFakeStates atomic.Uint64 + // mustCreate creates an agent and, when it was given no words, waits for its // conversation to be open, which agents.create does behind its answer. func mustCreate(t *testing.T, backend *appBackend, ask appwire.CreateAsk) (appwire.Agent, error) { @@ -1112,7 +1126,10 @@ func TestAppAlwaysWritesABashRule(t *testing.T) { } } -// settings.get reads the profile's rows, and a figure nobody set is null. +// settings.get reads the profile's rows for what the terminal shares, and the +// app's own file for the rest, with its defaults: a $20.00 day cap that is the +// app's and not the terminal's daily limit, a twelve-second proposal clock, +// seven idle days, and the home folder to work in. func TestAppSettingsAndSpendReadTheProfile(t *testing.T) { profile := t.TempDir() t.Setenv(config.ProfileDirEnv, profile) @@ -1127,11 +1144,18 @@ func TestAppSettingsAndSpendReadTheProfile(t *testing.T) { if err != nil { t.Fatal(err) } - if settings.DefaultModel != "qwen/qwen3-coder" || settings.AsksBefore != "deny" || settings.BudgetUSD != nil || settings.DayCapUSD == nil || *settings.DayCapUSD != 12 { + homeDir, _ := os.UserHomeDir() + if settings.DefaultModel != "qwen/qwen3-coder" || settings.AsksBefore != "deny" || settings.BudgetUSD != nil || settings.DayCapUSD == nil || *settings.DayCapUSD != appDayCapDefault { t.Fatalf("settings = %+v (day cap %v)", settings, settings.DayCapUSD) } + if settings.ProposedTaskClockS != 12 || settings.IdleCloseDays != 7 || settings.WorkFolder != homeDir || settings.TaskCapUSD == nil || *settings.TaskCapUSD != config.CrewTaskCapDefault { + t.Fatalf("app settings = %+v", settings) + } + if daily, _ := config.DailyBudgetUSDAt(profile); daily != 12 { + t.Fatalf("the terminal's daily limit moved to %v", daily) + } spend, err := backend.SpendToday() - if err != nil || spend.TodayUSD != 0 || spend.DayCapUSD == nil || *spend.DayCapUSD != 12 { + if err != nil || spend.TodayUSD != 0 || spend.DayCapUSD == nil || *spend.DayCapUSD != appDayCapDefault { t.Fatalf("spend = %+v, %v", spend, err) } } diff --git a/cmd/codeaf/appaccount.go b/cmd/codeaf/appaccount.go new file mode 100644 index 0000000000..ad2f15c359 --- /dev/null +++ b/cmd/codeaf/appaccount.go @@ -0,0 +1,318 @@ +package main + +// ── codeaf app: PROVIDERS AND KEYS ────────────────────────────────────────── +// +// KEYS NEVER TOUCH DISK IN THE APP'S PROCESS. The app keeps them in the macOS +// keychain and hands each one over at every launch with keys.set; this process +// holds them in memory ([config.AppCredentials]), where every key reader in +// the engine looks first, and the profile writer refuses to put one on disk. +// +// keys.check is EXACTLY ONE request to the provider, with no redirect +// followed and ten seconds to answer, and a rejection is said in the +// provider's own words with its own status code. A browser sign-in exists +// where the engine already has one: OpenRouter's. + +import ( + "context" + "encoding/json" + "errors" + "io" + "net" + "net/http" + "net/url" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/guard" + "github.com/Agent-Field/codeaf/internal/modelsource" + "github.com/Agent-Field/codeaf/internal/openrouterauth" + "github.com/Agent-Field/codeaf/internal/redact" + "github.com/Agent-Field/codeaf/internal/trace" +) + +// appKeyCheckWait is how long keys.check waits for the provider: the probe +// ceiling every connect shares, because a person is watching this one too. +const appKeyCheckWait = modelsource.ProbeTimeout + +// appKeyAnswerLimit bounds how much of a provider's answer is read. +const appKeyAnswerLimit = 4 << 20 + +// appSignInWait is how long a browser sign-in is waited for. +const appSignInWait = 10 * time.Minute + +// appProvider is one service the app can connect, with the address its key is +// proven against. +type appProvider struct { + id, name string + check string + signIn bool +} + +// appProviders is every service a pasted key connects, OpenRouter first. The +// Codex sign-in, a local Ollama and a custom endpoint are not here: the first +// keeps its tokens on disk, and the other two are addresses rather than keys. +func appProviders() []appProvider { + providers := []appProvider{{ + id: modelsource.DefaultID, name: modelsource.DefaultSource("").Name, + check: strings.TrimRight(config.DefaultBaseURL, "/") + "/models/user", signIn: true, + }} + for _, source := range modelsource.Vendored() { + if source.ID == "codex" || source.KeyOptional || modelsource.IsCustomID(source.ID) { + continue + } + address := source.Address + if address == "" && len(source.Regions) > 0 { + address = source.Regions[0].Address + } + if address == "" { + continue + } + providers = append(providers, appProvider{id: source.ID, name: source.Name, check: strings.TrimRight(address, "/") + "/models"}) + } + return providers +} + +// appProviderNamed finds one provider by id. +func appProviderNamed(id string) (appProvider, bool) { + for _, p := range appProviders() { + if p.id == id { + return p, true + } + } + return appProvider{}, false +} + +// appKeyHint is the last four characters of a key, which is all of it a +// person is ever shown again. +func appKeyHint(key string) string { + runes := []rune(strings.TrimSpace(key)) + if len(runes) <= 4 { + return "" + } + return "…" + string(runes[len(runes)-4:]) +} + +// providerView is one provider as the app sees it: connected when a turn on it +// would find a key, wherever that key came from. +func (b *appBackend) providerView(p appProvider, sources modelsource.Set) appwire.Provider { + view := appwire.Provider{ID: p.id, Name: p.name, SignIn: p.signIn} + key := "" + if service, ok := sources.ByID(p.id); ok { + key = service.Key + } + if key != "" { + view.SignedIn, view.KeyHint = true, appKeyHint(key) + } + return view +} + +// ListProviders answers every provider and whether it is connected. +func (b *appBackend) ListProviders() ([]appwire.Provider, error) { + profile := config.ProfileDir() + sources := config.ResolveSources(profile, config.APIKeyAt(profile), config.DefaultBaseURL) + providers := appProviders() + views := make([]appwire.Provider, 0, len(providers)) + for _, p := range providers { + views = append(views, b.providerView(p, sources)) + } + return views, nil +} + +// providerChanged says provider.changed for one provider, with key carried +// only when this process made the key itself and the app has never seen it. +func (b *appBackend) providerChanged(id, key string) { + p, ok := appProviderNamed(id) + if !ok { + return + } + profile := config.ProfileDir() + sources := config.ResolveSources(profile, config.APIKeyAt(profile), config.DefaultBaseURL) + b.emit.Emit(appwire.EventProviderChanged, appwire.ProviderChanged{Provider: b.providerView(p, sources), Key: key}) +} + +// CheckKey proves a key with one request and keeps nothing. +func (b *appBackend) CheckKey(provider, key string) (appwire.KeyCheck, error) { + p, ok := appProviderNamed(provider) + if !ok { + return appwire.KeyCheck{}, appwire.Errorf(appwire.CodeBadParams, "codeaf cannot connect %s with a key", provider) + } + trace.Secret(key) + redact.Register(key) + address := p.check + if b.keyAddress != nil { + address = b.keyAddress(p.id) + } + ctx, cancel := context.WithTimeout(context.Background(), appKeyCheckWait) + defer cancel() + return appCheckKey(ctx, b.keyClient, address, p.id, key), nil +} + +// appCheckKey is the one request: an authenticated listing of the models the +// key reaches. OpenRouter's public listing proves nothing about a key, so its +// listing filtered to the key's own account is the one asked. +func appCheckKey(ctx context.Context, client *http.Client, address, provider, key string) appwire.KeyCheck { + host := address + if parsed, err := url.Parse(address); err == nil && parsed.Host != "" { + host = parsed.Hostname() + } + unreachable := appwire.KeyCheck{Status: "timeout", Message: "No answer · couldn't reach " + host + " in 10s"} + request, err := http.NewRequestWithContext(ctx, http.MethodGet, address, nil) + if err != nil { + return unreachable + } + request.Header.Set("Accept", "application/json") + request.Header.Set("Authorization", "Bearer "+key) + if client == nil { + client = http.DefaultClient + } + // A REDIRECT MUST NEVER TURN THIS ONE CALL INTO A KEY SENT ELSEWHERE. + local := *client + local.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + response, err := local.Do(request) + if err != nil { + var netErr net.Error + if errors.Is(err, context.DeadlineExceeded) || (errors.As(err, &netErr) && netErr.Timeout()) { + return unreachable + } + unreachable.Status = "unreachable" + unreachable.Message = "No answer · couldn't reach " + host + return unreachable + } + defer response.Body.Close() + raw, err := io.ReadAll(io.LimitReader(response.Body, appKeyAnswerLimit+1)) + if err != nil || len(raw) > appKeyAnswerLimit { + return appwire.KeyCheck{Status: response.StatusCode, Message: "the provider's answer could not be read"} + } + if response.StatusCode != http.StatusOK { + return appwire.KeyCheck{Status: response.StatusCode, Message: redact.Secrets(appProviderWords(raw, response.StatusCode))} + } + var listing struct { + Data []struct { + ID string `json:"id"` + Name string `json:"name"` + } `json:"data"` + } + if err := json.Unmarshal(raw, &listing); err != nil || listing.Data == nil { + return appwire.KeyCheck{Status: response.StatusCode, Message: "the provider answered, but not with a list of models"} + } + check := appwire.KeyCheck{OK: true, Models: []appwire.Model{}} + for _, row := range listing.Data { + id := strings.TrimSpace(row.ID) + if id == "" { + continue + } + name := strings.TrimSpace(row.Name) + if name == "" { + name = id + } + check.Models = append(check.Models, appwire.Model{ID: id, Name: name, Provider: provider}) + } + return check +} + +// appProviderWords are a rejection's own words, read from the shapes +// providers answer in, or the status's name when they said nothing readable. +func appProviderWords(raw []byte, status int) string { + var body struct { + Message string `json:"message"` + Error json.RawMessage `json:"error"` + } + _ = json.Unmarshal(raw, &body) + if len(body.Error) > 0 { + var nested struct { + Message string `json:"message"` + } + if json.Unmarshal(body.Error, &nested) == nil && nested.Message != "" { + body.Message = nested.Message + } else { + var plain string + if json.Unmarshal(body.Error, &plain) == nil && plain != "" { + body.Message = plain + } + } + } + if words := strings.TrimSpace(body.Message); words != "" { + return words + } + return http.StatusText(status) +} + +// SetKey holds a provider's key for this launch and makes it live in every +// conversation the process holds. +func (b *appBackend) SetKey(provider, key string) error { + if _, ok := appProviderNamed(provider); !ok { + return appwire.Errorf(appwire.CodeBadParams, "codeaf cannot connect %s with a key", provider) + } + b.keys.Set(provider, key) + b.keysMoved(provider) + b.providerChanged(provider, "") + return nil +} + +// RemoveKey disconnects a provider for the rest of this launch, and a key in +// the profile or the shell does not quietly take its place. +func (b *appBackend) RemoveKey(provider string) error { + if _, ok := appProviderNamed(provider); !ok { + return appwire.Errorf(appwire.CodeBadParams, "codeaf has no provider %s", provider) + } + b.keys.Remove(provider) + b.keysMoved(provider) + b.providerChanged(provider, "") + return nil +} + +// keysMoved makes the keys live in the engine's process half, if it is open: +// one that opens later reads them as it opens. +func (b *appBackend) keysMoved(provider string) { + if !b.assembled.Load() { + return + } + proc, err := openEngineProcess() + if err != nil || proc == nil { + return + } + if provider == modelsource.DefaultID { + key, _ := b.keys.Key(provider) + _ = proc.setAPIKey(key) + } + proc.refreshModelSources() +} + +// SignIn starts a browser sign-in and answers the address to open. The key it +// produces is held like a pasted one and handed to the app once, on +// provider.changed, for the keychain. +func (b *appBackend) SignIn(provider string) (string, error) { + p, ok := appProviderNamed(provider) + if !ok || !p.signIn { + return "", appwire.Errorf(appwire.CodeBadParams, "%s has no browser sign-in; paste a key instead", provider) + } + ctx, cancel := context.WithTimeout(context.Background(), appSignInWait) + flow, err := openrouterauth.Begin(ctx, openrouterauth.Options{}) + if err != nil { + cancel() + return "", appEngineError(err) + } + var previous *openrouterauth.Flow + b.locked(func() { previous, b.signIn = b.signIn, flow }) + if previous != nil { + previous.Cancel() + } + guard.Go("app/sign-in", func() { + defer cancel() + key, err := flow.Wait(ctx) + b.locked(func() { + if b.signIn == flow { + b.signIn = nil + } + }) + if err != nil || strings.TrimSpace(key) == "" || b.isClosing() { + return + } + b.keys.Set(p.id, key) + b.keysMoved(p.id) + b.providerChanged(p.id, key) + }) + return flow.URL(), nil +} diff --git a/cmd/codeaf/appmoney.go b/cmd/codeaf/appmoney.go new file mode 100644 index 0000000000..b91de6339f --- /dev/null +++ b/cmd/codeaf/appmoney.go @@ -0,0 +1,443 @@ +package main + +// ── codeaf app: BUDGETS, THE DAY CAP AND THE ASKS ABOUT THEM ──────────────── +// +// TWO LIMITS, EACH CHECKED WHERE IT CAN HOLD. An agent's budget is its +// conversation's own spend limit, which the engine checks before every turn +// (internal/session/rail.go); the app keeps the figure in its file, because the +// engine's is the open conversation's only, and binds it again every time the +// conversation opens or a turn starts. The day cap is the app's own and is +// checked here, before a turn is handed to the engine: a turn the cap refuses +// is never sent, so it costs nothing and its words are kept for a retry. +// +// NEITHER CUTS A TURN IN HALF. A turn that crosses a line finishes, as the +// engine's own limit does, and the line is looked at again when it ends. +// +// AND EVERY LINE IS AN ASK, NOT A WALL. Near or at its budget, an agent asks +// whether to raise it, stop, or be told something; at the day cap it asks +// whether to raise the cap for today or wait. A raise that unblocks a refused +// turn runs that turn again with the same words. + +import ( + "fmt" + "math" + "strconv" + "strings" + "sync/atomic" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/guard" + "github.com/Agent-Field/codeaf/internal/session" +) + +// appNearBudget is the share of its budget at which an agent first asks. +const appNearBudget = 0.8 + +// appMoneyAsks numbers the budget asks this process opens. +var appMoneyAsks atomic.Uint64 + +// appMoneyAsk is one open budget ask: which cap it is about. +type appMoneyAsk struct { + scope string +} + +// appUSD writes a figure as the asks say it: to the cent, and to a hundredth +// of a cent under one, so a tiny budget is never written as nothing. +func appUSD(usd float64) string { + if usd > 0 && usd < 0.01 { + return fmt.Sprintf("$%.4f", usd) + } + return fmt.Sprintf("$%.2f", usd) +} + +// appParseUSD reads a figure a person typed: "8", "8.00" or "$8.00". +func appParseUSD(text string) (float64, bool) { + text = strings.TrimSpace(strings.TrimPrefix(strings.TrimSpace(text), "$")) + usd, err := strconv.ParseFloat(text, 64) + if err != nil || usd <= 0 || math.IsNaN(usd) || math.IsInf(usd, 0) { + return 0, false + } + return usd, true +} + +// agentBudget is the budget in force for one agent at now: the app's figure +// with any raise for today over it, or the profile's own conversation limit +// when the app never set one. set says the figure is the app's. +func (b *appBackend) agentBudget(id string, now time.Time) (usd float64, set bool) { + _ = b.state.read(func(f *appStateFile) { + base, ok := f.Budgets[id] + if ok && base > 0 { + usd, set = f.raised("agent:"+id, base, now), true + } + }) + if set { + return usd, true + } + return config.SpendRailUSDAt(config.ProfileDir()), false +} + +// dayCap is the day cap in force at now, and zero for none. +func (b *appBackend) dayCap(now time.Time) float64 { + usd := 0.0 + _ = b.state.read(func(f *appStateFile) { usd = f.dayCapAt(now) }) + return usd +} + +// spentToday is today's spending on this machine, from the usage ledger. +func (b *appBackend) spentToday(now time.Time) float64 { + if b.today != nil { + return b.today(now) + } + lines, err := session.ReadUsage(session.UsageLedgerPath(), now.Add(-48*time.Hour)) + if err != nil { + return 0 + } + return session.SpendToday(lines, now) +} + +// bindBudget gives an open conversation the app's budget for it, when the app +// has one. The engine reads it before the next turn. +func (b *appBackend) bindBudget(id string, conv appConversation) error { + if usd, set := b.agentBudget(id, b.now()); set { + return conv.SetSpendRail(usd) + } + return nil +} + +// dayRefused is a turn the day cap would not let start: it is answered like a +// turn that failed before it began, its words kept for a retry, and the agent +// asks about the cap. +func (b *appBackend) dayRefused(a *appAgent, live *appLive, text string, spent, limit float64) uint64 { + turn := b.turns.Add(1) + words := "Day cap reached · " + appUSD(spent) + " of " + appUSD(limit) + var id string + b.locked(func() { + id = a.view.ID + a.failed, a.failedKind = text, appwire.ErrorBudget + }) + live.box.Event(appwire.EventTurnStarted, appwire.TurnStarted{Agent: id, Turn: turn}) + live.box.Event(appwire.EventTurnDone, appwire.TurnDone{Agent: id, Turn: turn, OK: false, Error: words, ErrorKind: appwire.ErrorBudget}) + b.askDay(a, live, spent, limit, text) + return turn +} + +// askDay opens the day cap's ask on one agent, unless one is open there. +func (b *appBackend) askDay(a *appAgent, live *appLive, spent, limit float64, waiting string) { + detail := "Spent " + appUSD(spent) + " today across every agent" + if line := appDetail(waiting); line != "" { + detail += " · waiting to send: " + line + } + b.openMoneyAsk(a, live, appwire.CapDay, appwire.Ask{ + Title: "Day cap reached · " + appUSD(spent) + " of " + appUSD(limit), + Detail: detail, + Options: []appwire.AskOption{ + {ID: appwire.OptionRaiseToday, Label: "Raise for today"}, + {ID: appwire.OptionWait, Label: "Wait until tomorrow"}, + }, + }) +} + +// askBudget opens an agent's budget ask: close to it, or at it. +func (b *appBackend) askBudget(a *appAgent, live *appLive, spent, budget float64) { + title := "It's close to its " + appUSD(budget) + " budget" + if spent >= budget { + title = "It reached its " + appUSD(budget) + " budget" + } + detail := "Spent " + appUSD(spent) + " so far" + var last string + b.locked(func() { last = live.lastAnswer }) + if line := appDetail(last); line != "" { + detail += " · " + line + } + b.openMoneyAsk(a, live, appwire.CapAgent, appwire.Ask{ + Title: title, + Detail: detail, + Options: []appwire.AskOption{ + {ID: appwire.OptionRaise, Label: "Raise budget"}, + {ID: appwire.OptionStop, Label: "Stop"}, + {ID: appwire.OptionTell, Label: "Tell it something"}, + }, + }) +} + +// openMoneyAsk raises one budget ask on a held agent, unless one about the +// same cap is already open there. +func (b *appBackend) openMoneyAsk(a *appAgent, live *appLive, scope string, ask appwire.Ask) { + ask.ID = "budget:" + scope + ":" + strconv.FormatUint(appMoneyAsks.Add(1), 10) + ask.Kind, ask.Subkind = appwire.AskApproval, appwire.SubkindBudget + opened, moved := false, false + var view appwire.Agent + b.locked(func() { + if a.live != live { + return + } + for _, open := range live.money { + if open.scope == scope { + return + } + } + ask.Agent = a.view.ID + live.money[ask.ID] = appMoneyAsk{scope: scope} + live.asks = append(live.asks, ask) + opened = true + view, moved = b.restateLocked(a, live) + }) + if !opened { + return + } + live.box.Event(appwire.EventAskOpened, appwire.AskOpened{Ask: ask}) + if moved { + live.box.Event(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) + } +} + +// closeMoneyAsks closes the open budget asks about one cap on one agent, as +// answered by the app with option, and answers whether any was open. +func (b *appBackend) closeMoneyAsks(a *appAgent, scope, option, receipt string) bool { + var live *appLive + var closed []string + moved := false + var view appwire.Agent + b.locked(func() { + if live = a.live; live == nil { + return + } + for askID, open := range live.money { + if open.scope == scope { + closed = append(closed, askID) + delete(live.money, askID) + b.dropAskLocked(live, askID) + } + } + if len(closed) > 0 { + view, moved = b.restateLocked(a, live) + } + }) + if len(closed) == 0 { + return false + } + at := b.now() + for _, askID := range closed { + live.box.Event(appwire.EventAskClosed, appwire.AskClosed{Agent: view.ID, ID: askID, By: appwire.ClosedByApp, Option: option, At: at}) + live.box.Event(appwire.EventEntryAdded, appwire.EntryAdded{Entry: appwire.Entry{ + ID: appReceiptEntryID(askID), Agent: view.ID, Kind: appwire.EntryReceipt, At: at, Text: receipt + " · you · " + at.Local().Format("15:04"), + }}) + } + if moved { + live.box.Event(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) + } + return true +} + +// retryRefused runs an agent's turn again when the last one failed for want of +// money, which is what a raise was for. +func (b *appBackend) retryRefused(a *appAgent) { + var id string + retry := false + b.locked(func() { + id = a.view.ID + retry = a.failed != "" && a.failedKind == appwire.ErrorBudget + }) + if retry { + guard.Go("app/retry", func() { _, _ = b.Retry(id) }) + } +} + +// moneyAfter looks at both lines once a turn's spending has reached the +// ledger, and asks about the one that was crossed or is close. +func (b *appBackend) moneyAfter(a *appAgent, live *appLive, today float64) { + now := b.now() + var id string + held := false + b.locked(func() { id, held = a.view.ID, a.live == live }) + if !held { + return + } + if limit := b.dayCap(now); limit > 0 && today >= limit { + b.askDay(a, live, today, limit, "") + } + budget, _ := b.agentBudget(id, now) + if budget <= 0 { + return + } + spent := live.conn.Conv.Usage().CostUSD + if spent < budget*appNearBudget { + return + } + // ONCE PER FIGURE: an agent that was asked at this budget and kept going + // is not asked again until its budget moves or it reaches it. + level := "near" + if spent >= budget { + level = "reached" + } + mark := level + ":" + strconv.FormatFloat(budget, 'f', -1, 64) + ask := false + b.locked(func() { + if live.budgetAsked != mark { + live.budgetAsked, ask = mark, true + } + }) + if ask { + b.askBudget(a, live, spent, budget) + } +} + +// answerMoney answers one of this process's budget asks, and says whether the +// ask was one. +func (b *appBackend) answerMoney(a *appAgent, askID, option, text string) (bool, error) { + var live *appLive + var open appMoneyAsk + var id string + found, running := false, false + b.locked(func() { + live, id = a.live, a.view.ID + if live != nil { + open, found = live.money[askID] + running = live.running + } + }) + if !found { + return false, nil + } + now := b.now() + switch open.scope { + case appwire.CapAgent: + switch option { + case appwire.OptionRaise: + usd, ok := appParseUSD(text) + if !ok { + return true, appwire.Errorf(appwire.CodeBadParams, "raise needs the new budget as text, such as \"8.00\"") + } + return true, b.RaiseCap(appwire.CapRaise{Scope: appwire.CapAgent, ID: id, ToUSD: usd, For: appwire.RaiseGood}) + case appwire.OptionStop: + if running { + b.locked(func() { live.stopping = true }) + live.conn.Conv.Interrupt() + } + b.closeMoneyAsks(a, appwire.CapAgent, appwire.OptionStop, "Stopped") + return true, nil + case appwire.OptionTell: + words := strings.TrimSpace(text) + if words == "" { + return true, appwire.Errorf(appwire.CodeBadParams, "tell needs the words to tell it") + } + if running { + return true, appwire.Errorf(appwire.CodeBusy, "this agent is still working on a turn; tell it when the turn ends") + } + b.closeMoneyAsks(a, appwire.CapAgent, appwire.OptionTell, "Told it") + guard.Go("app/tell", func() { _, _ = b.Send(id, words, "") }) + return true, nil + } + return true, appwire.Errorf(appwire.CodeBadParams, "the ask %s offers raise, stop and tell", askID) + case appwire.CapDay: + switch option { + case appwire.OptionRaiseToday: + usd, ok := appParseUSD(text) + if !ok { + // Nothing typed raises the cap by itself again: twice the + // cap, for the rest of today. + usd = 2 * b.dayCap(now) + if usd <= 0 { + usd = 2 * appDayCapDefault + } + } + return true, b.RaiseCap(appwire.CapRaise{Scope: appwire.CapDay, ToUSD: usd, For: appwire.RaiseToday}) + case appwire.OptionWait: + b.closeMoneyAsks(a, appwire.CapDay, appwire.OptionWait, "Waiting until tomorrow") + return true, nil + } + return true, appwire.Errorf(appwire.CodeBadParams, "the ask %s offers raise_today and wait", askID) + } + return true, appwire.Errorf(appwire.CodeNotFound, "the ask %s is not open", askID) +} + +// RaiseCap raises an agent's budget or the day cap, for today or for good, +// closes the asks it answers and runs again any turn the old figure refused. +func (b *appBackend) RaiseCap(raise appwire.CapRaise) error { + now := b.now() + switch raise.Scope { + case appwire.CapAgent: + a, err := b.find(raise.ID) + if err != nil { + return err + } + err = b.state.write(func(f *appStateFile) error { + if f.Budgets == nil { + f.Budgets = map[string]float64{} + } + if raise.For == appwire.RaiseToday { + if _, ok := f.Budgets[raise.ID]; !ok { + f.Budgets[raise.ID] = config.SpendRailUSDAt(config.ProfileDir()) + } + if f.Raises == nil { + f.Raises = map[string]appRaise{} + } + f.Raises["agent:"+raise.ID] = appRaise{USD: raise.ToUSD, Until: appMidnight(now)} + return nil + } + f.Budgets[raise.ID] = raise.ToUSD + delete(f.Raises, "agent:"+raise.ID) + return nil + }) + if err != nil { + return appEngineError(err) + } + if live := b.liveOf(a); live != nil { + if err := b.bindBudget(raise.ID, live.conn.Conv); err != nil { + return appEngineError(err) + } + } + b.closeMoneyAsks(a, appwire.CapAgent, appwire.OptionRaise, "Budget raised to "+appUSD(raise.ToUSD)) + b.retryRefused(a) + return nil + case appwire.CapDay: + err := b.state.write(func(f *appStateFile) error { + if raise.For == appwire.RaiseToday { + if f.Raises == nil { + f.Raises = map[string]appRaise{} + } + f.Raises[appMidnightRaiseScope] = appRaise{USD: raise.ToUSD, Until: appMidnight(now)} + return nil + } + usd := raise.ToUSD + f.DayCapUSD = &usd + delete(f.Raises, appMidnightRaiseScope) + return nil + }) + if err != nil { + return appEngineError(err) + } + receipt := "Day cap raised to " + appUSD(raise.ToUSD) + if raise.For == appwire.RaiseToday { + receipt += " for today" + } else { + b.settingsMoved() + } + var agents []*appAgent + b.locked(func() { + for _, a := range b.agents { + agents = append(agents, a) + } + }) + for _, a := range agents { + if b.closeMoneyAsks(a, appwire.CapDay, appwire.OptionRaiseToday, receipt) { + b.retryRefused(a) + } + } + b.spendSay() + return nil + } + return appwire.Errorf(appwire.CodeBadParams, "scope is agent or day") +} + +// spendSay says spend.changed with what today reads now. +func (b *appBackend) spendSay() { + spend, err := b.SpendToday() + if err != nil { + return + } + b.locked(func() { b.spent = spend.TodayUSD }) + b.emit.Emit(appwire.EventSpendChanged, appwire.SpendChanged{Spend: spend}) +} diff --git a/cmd/codeaf/appstate.go b/cmd/codeaf/appstate.go new file mode 100644 index 0000000000..eb3c5c7c65 --- /dev/null +++ b/cmd/codeaf/appstate.go @@ -0,0 +1,394 @@ +package main + +// ── codeaf app: SETUP AND SETTINGS ────────────────────────────────────────── +// +// TWO KINDS OF SETTING, AND WHERE EACH LIVES. A setting the terminal also has +// — the default model, a conversation's budget, what asks before it runs, the +// task cap, the default effort — is the profile's own row, written through the +// profile's own writers, so the app and the terminal never disagree about it. +// A setting only the app has — the day cap the app's own setup chose, the +// folder a new agent works in, the proposed-task clock, how long an idle agent +// stays open — and how far first-run setup has come are this file's: one small +// JSON file in codeaf's home, read the first time it is asked for and written +// whole on every change. The terminal's own daily limit and its default are +// untouched by anything here. + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/effort" + "github.com/Agent-Field/codeaf/internal/home" +) + +// The app's own defaults. The day cap is what first-run setup offers at its +// third step; the terminal's daily limit keeps its own default. +const ( + appDayCapDefault = 20.0 + appProposedClockS = 12 + appIdleCloseDays = 7 + appStateFolder = "app" + appStateName = "state.json" + appStateFileMode = 0o600 + appStateFolderMode = 0o700 + appMidnightRaiseScope = "day" +) + +// appRaise is a cap raised until a moment, which is the next local midnight. +type appRaise struct { + USD float64 `json:"usd"` + Until time.Time `json:"until"` +} + +// appStateFile is the app's file. A nil figure is one nobody set, which reads +// as its default; a day cap of zero is one the person turned off. +type appStateFile struct { + Setup *appwire.Setup `json:"setup,omitempty"` + WorkFolder string `json:"work_folder,omitempty"` + DayCapUSD *float64 `json:"day_cap_usd,omitempty"` + ProposedTaskClockS *int `json:"proposed_task_clock_s,omitempty"` + IdleCloseDays *int `json:"idle_close_days,omitempty"` + Budgets map[string]float64 `json:"budgets,omitempty"` + Raises map[string]appRaise `json:"raises,omitempty"` +} + +// appState is the file, loaded once and written whole. +type appState struct { + mu sync.Mutex + path string + loaded bool + file appStateFile +} + +// appStatePath is where the file lives under codeaf's home. +func appStatePath() string { + return filepath.Join(home.Dir(), appStateFolder, appStateName) +} + +// loadLocked reads the file the first time. A file that is missing is the +// defaults; one that cannot be read is refused rather than written over. +func (s *appState) loadLocked() error { + if s.loaded { + return nil + } + if s.path == "" { + s.path = appStatePath() + } + raw, err := os.ReadFile(s.path) + switch { + case errors.Is(err, os.ErrNotExist): + case err != nil: + return err + default: + if err := json.Unmarshal(raw, &s.file); err != nil { + return err + } + } + s.loaded = true + return nil +} + +// read hands fn the file as it stands. +func (s *appState) read(fn func(*appStateFile)) error { + s.mu.Lock() + defer s.mu.Unlock() + if err := s.loadLocked(); err != nil { + return err + } + fn(&s.file) + return nil +} + +// write changes the file and saves it whole: a new file beside it, renamed +// over it, so a quit halfway never leaves half a file. +func (s *appState) write(fn func(*appStateFile) error) error { + s.mu.Lock() + defer s.mu.Unlock() + if err := s.loadLocked(); err != nil { + return err + } + next := s.file.clone() + if err := fn(&next); err != nil { + return err + } + raw, err := json.MarshalIndent(next, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(s.path), appStateFolderMode); err != nil { + return err + } + temp := s.path + ".new" + if err := os.WriteFile(temp, raw, appStateFileMode); err != nil { + return err + } + if err := os.Rename(temp, s.path); err != nil { + return err + } + s.file = next + return nil +} + +// clone is a copy that shares nothing with the file it came from. +func (f appStateFile) clone() appStateFile { + out := f + if f.Setup != nil { + setup := *f.Setup + out.Setup = &setup + } + out.Budgets = make(map[string]float64, len(f.Budgets)) + for k, v := range f.Budgets { + out.Budgets[k] = v + } + out.Raises = make(map[string]appRaise, len(f.Raises)) + for k, v := range f.Raises { + out.Raises[k] = v + } + return out +} + +// dayCap is the app's day cap without any raise, and zero for none. +func (f *appStateFile) dayCap() float64 { + if f.DayCapUSD == nil { + return appDayCapDefault + } + return *f.DayCapUSD +} + +// raised is a cap with its raise for today laid over it, if one is current. +func (f *appStateFile) raised(scope string, base float64, now time.Time) float64 { + if raise, ok := f.Raises[scope]; ok && now.Before(raise.Until) && raise.USD > base { + return raise.USD + } + return base +} + +// dayCapAt is the day cap in force at now, and zero for none. +func (f *appStateFile) dayCapAt(now time.Time) float64 { + base := f.dayCap() + if base <= 0 { + return 0 + } + return f.raised(appMidnightRaiseScope, base, now) +} + +func (f *appStateFile) clock() int { + if f.ProposedTaskClockS == nil { + return appProposedClockS + } + return *f.ProposedTaskClockS +} + +func (f *appStateFile) idleDays() int { + if f.IdleCloseDays == nil { + return appIdleCloseDays + } + return *f.IdleCloseDays +} + +// appMidnight is the next local midnight after now, which is when a raise for +// today ends. +func appMidnight(now time.Time) time.Time { + return time.Date(now.Year(), now.Month(), now.Day()+1, 0, 0, 0, 0, now.Location()) +} + +// workFolder is where an agent created with no workspace works: the folder +// the person chose, or their home folder. +func (f *appStateFile) workFolder() string { + if folder := strings.TrimSpace(f.WorkFolder); folder != "" { + return folder + } + if dir, err := os.UserHomeDir(); err == nil { + return dir + } + return "" +} + +// Setup answers how far first-run setup has come; a setup never started is at +// step one, offering the app's day cap. +func (b *appBackend) Setup() (appwire.Setup, error) { + var setup appwire.Setup + err := b.state.read(func(f *appStateFile) { + if f.Setup != nil { + setup = *f.Setup + return + } + usd := f.dayCap() + setup = appwire.Setup{Step: appwire.SetupStep{N: 1}, DayCapUSD: appFigure(usd)} + }) + if err != nil { + return appwire.Setup{}, appEngineError(err) + } + return setup, nil +} + +// SetSetup keeps the setup the app sent, so quitting halfway resumes at the +// same step. What it chose is also put in force at once: a default model is the +// profile's default, and a day cap is the app's day cap. +func (b *appBackend) SetSetup(setup appwire.Setup) (appwire.Setup, error) { + setup.Provider = strings.ToLower(strings.TrimSpace(setup.Provider)) + setup.DefaultModel = strings.TrimSpace(setup.DefaultModel) + if setup.DefaultModel != "" && setup.DefaultModel != appDefaultModel() { + if err := config.WriteChatModel(config.ProfileDir(), setup.DefaultModel); err != nil { + return appwire.Setup{}, appEngineError(err) + } + } + err := b.state.write(func(f *appStateFile) error { + kept := setup + f.Setup = &kept + if setup.DayCapUSD != nil { + usd := *setup.DayCapUSD + f.DayCapUSD = &usd + } + return nil + }) + if err != nil { + return appwire.Setup{}, appEngineError(err) + } + if setup.DefaultModel != "" || setup.DayCapUSD != nil { + b.settingsMoved() + } + return setup, nil +} + +// Settings reads the profile's rows and the app's own file; a figure nobody +// set is null. +func (b *appBackend) Settings() (appwire.Settings, error) { + profile := config.ProfileDir() + var file appStateFile + if err := b.state.read(func(f *appStateFile) { file = f.clone() }); err != nil { + return appwire.Settings{}, appEngineError(err) + } + settings := appwire.Settings{ + DefaultModel: appDefaultModel(), + AsksBefore: config.ToolApprovalModeAt(profile), + BudgetUSD: appFigure(config.SpendRailUSDAt(profile)), + DayCapUSD: appFigure(file.dayCap()), + WorkFolder: file.workFolder(), + TaskCapUSD: appFigure(config.CrewTaskCapAt(profile)), + ProposedTaskClockS: file.clock(), + IdleCloseDays: file.idleDays(), + } + if rung := config.DefaultEffortAt(profile); rung.Valid() { + settings.Effort = rung.String() + } + return settings, nil +} + +// SetSettings changes the fields the patch names, each through its own door, +// and says settings.changed once with what they all read now. +func (b *appBackend) SetSettings(patch appwire.SettingsPatch) (appwire.Settings, error) { + profile := config.ProfileDir() + apply := func(key, raw string) error { + row, ok := config.NewSettings(config.SettingsOptions{ProfileDir: profile}).Row(key) + if !ok { + return appwire.Errorf(appwire.CodeEngine, "this build has no %s setting", key) + } + if err := row.Apply(raw); err != nil { + return appwire.Errorf(appwire.CodeBadParams, "%s", err.Error()) + } + return nil + } + if patch.DefaultModel != nil { + if *patch.DefaultModel == "" { + return appwire.Settings{}, appwire.Errorf(appwire.CodeBadParams, "name a model") + } + if err := config.WriteChatModel(profile, *patch.DefaultModel); err != nil { + return appwire.Settings{}, appwire.Errorf(appwire.CodeBadParams, "%s", err.Error()) + } + } + if patch.AsksBefore != nil { + if err := apply(config.KeyToolApprovalMode, *patch.AsksBefore); err != nil { + return appwire.Settings{}, err + } + } + if patch.BudgetUSD != nil { + raw := "none" + if patch.BudgetUSD.USD != nil { + raw = appDollars(*patch.BudgetUSD.USD) + } + if err := apply(config.KeySpendRail, raw); err != nil { + return appwire.Settings{}, err + } + } + if patch.TaskCapUSD != nil { + if patch.TaskCapUSD.USD == nil { + return appwire.Settings{}, appwire.Errorf(appwire.CodeBadParams, "a task always has a limit — a dollar amount above zero") + } + if err := config.SetCrewTaskCap(profile, appDollars(*patch.TaskCapUSD.USD)); err != nil { + return appwire.Settings{}, appwire.Errorf(appwire.CodeBadParams, "%s", err.Error()) + } + } + if patch.Effort != nil { + rung, ok := effort.Parse(*patch.Effort) + if !ok || (rung != effort.None && rung != effort.Low && rung != effort.Medium && rung != effort.High) { + return appwire.Settings{}, appwire.Errorf(appwire.CodeBadParams, "effort is low, medium, high, or empty for the model's own") + } + if err := config.WriteDefaultEffort(profile, rung); err != nil { + return appwire.Settings{}, appEngineError(err) + } + } + if patch.WorkFolder != nil && *patch.WorkFolder != "" { + if _, err := engineWorkspace(*patch.WorkFolder); err != nil { + return appwire.Settings{}, appwire.Errorf(appwire.CodeBadParams, "%s", err.Error()) + } + } + if patch.DayCapUSD != nil || patch.WorkFolder != nil || patch.ProposedTaskClockS != nil || patch.IdleCloseDays != nil { + err := b.state.write(func(f *appStateFile) error { + if patch.DayCapUSD != nil { + usd := 0.0 + if patch.DayCapUSD.USD != nil { + usd = *patch.DayCapUSD.USD + } + f.DayCapUSD = &usd + // A new day cap replaces any raise for today: the figure the + // person just chose is the one in force. + delete(f.Raises, appMidnightRaiseScope) + } + if patch.WorkFolder != nil { + f.WorkFolder = *patch.WorkFolder + } + if patch.ProposedTaskClockS != nil { + n := *patch.ProposedTaskClockS + f.ProposedTaskClockS = &n + } + if patch.IdleCloseDays != nil { + n := *patch.IdleCloseDays + f.IdleCloseDays = &n + } + return nil + }) + if err != nil { + return appwire.Settings{}, appEngineError(err) + } + } + settings, err := b.Settings() + if err != nil { + return appwire.Settings{}, err + } + b.emit.Emit(appwire.EventSettingsChanged, appwire.SettingsChanged{Settings: settings}) + if patch.DayCapUSD != nil { + b.spendSay() + } + return settings, nil +} + +// settingsMoved says settings.changed with what the settings read now. +func (b *appBackend) settingsMoved() { + if settings, err := b.Settings(); err == nil { + b.emit.Emit(appwire.EventSettingsChanged, appwire.SettingsChanged{Settings: settings}) + } +} + +// appDollars writes a figure the way the profile's money writers read one. +func appDollars(usd float64) string { + return strconv.FormatFloat(usd, 'f', -1, 64) +} diff --git a/cmd/codeaf/appwave2_test.go b/cmd/codeaf/appwave2_test.go new file mode 100644 index 0000000000..f4efb30703 --- /dev/null +++ b/cmd/codeaf/appwave2_test.go @@ -0,0 +1,382 @@ +package main + +import ( + "context" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/redact" +) + +// KEYS.CHECK IS ONE REQUEST. A rejected key is answered with the provider's +// own status and words, a good one with the models it reaches, a redirect is +// never followed with the key on it, and nothing is kept. +func TestAppKeyCheckIsOneRequestInTheProvidersWords(t *testing.T) { + var calls atomic.Int32 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + calls.Add(1) + switch r.Header.Get("Authorization") { + case "Bearer sk-good-key-0001": + _, _ = w.Write([]byte(`{"data":[{"id":"deepseek/deepseek-v4-flash","name":"DeepSeek V4 Flash"},{"id":"qwen/qwen3-coder"}]}`)) + case "Bearer sk-moved-key-0002": + http.Redirect(w, r, "https://elsewhere.example/steal", http.StatusFound) + default: + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":{"message":"User not found.","code":401}}`)) + } + })) + defer server.Close() + backend := newFakeBackend(newAppRecorder(), nil) + defer backend.Close() + backend.keyAddress = func(string) string { return server.URL + "/models/user" } + + bad, err := backend.CheckKey("openrouter", "sk-bad-key-0000") + if err != nil || bad.OK || bad.Status != http.StatusUnauthorized || bad.Message != "User not found." { + t.Fatalf("bad key = %+v, %v", bad, err) + } + good, err := backend.CheckKey("openrouter", "sk-good-key-0001") + if err != nil || !good.OK || len(good.Models) != 2 || good.Models[0].Name != "DeepSeek V4 Flash" || good.Models[1].Name != "qwen/qwen3-coder" || good.Models[0].Provider != "openrouter" { + t.Fatalf("good key = %+v, %v", good, err) + } + moved, _ := backend.CheckKey("openrouter", "sk-moved-key-0002") + if moved.OK || moved.Status != http.StatusFound { + t.Fatalf("a redirect = %+v", moved) + } + if n := calls.Load(); n != 3 { + t.Fatalf("three checks made %d requests", n) + } + if _, ok := backend.keys.Key("openrouter"); ok { + t.Fatal("a checked key was kept") + } + if _, err := backend.CheckKey("nowhere", "sk-good-key-0001"); err == nil { + t.Fatal("a provider codeaf cannot connect was checked") + } + // A provider that never answers is said as the host it could not reach. + stall := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { <-r.Context().Done() })) + defer stall.Close() + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + timeout := appCheckKey(ctx, nil, stall.URL+"/models", "deepseek", "sk-good-key-0001") + if timeout.OK || timeout.Status != "timeout" || timeout.Message != "No answer · couldn't reach 127.0.0.1 in 10s" { + t.Fatalf("timeout = %+v", timeout) + } +} + +// KEYS LIVE IN MEMORY. A key the app sets is the one every reader finds, it +// is never written to the profile, a removed key is not replaced by the +// profile's, and the providers list says which are connected by four +// characters only. +func TestAppKeysAreHeldInMemoryAndOutrankTheProfile(t *testing.T) { + profile := t.TempDir() + t.Setenv(config.ProfileDirEnv, profile) + t.Setenv("OPENROUTER_API_KEY", "") + t.Setenv("OPENAI_API_KEY", "") + t.Setenv("DEEPSEEK_API_KEY", "") + if err := os.WriteFile(config.BudgetConfigPath(profile), []byte(`{"api_key":"sk-or-profile-key-1111"}`), 0o600); err != nil { + t.Fatal(err) + } + creds := config.NewAppCredentials() + restore := config.UseAppCredentials(creds) + defer restore() + rec := newAppRecorder() + backend := newFakeBackend(rec, nil) + defer backend.Close() + if backend.keys != creds { + t.Fatal("the backend holds other keys than the process's") + } + + if err := backend.SetKey("openrouter", "sk-or-app-key-a91f"); err != nil { + t.Fatal(err) + } + if got := config.APIKeyAt(profile); got != "sk-or-app-key-a91f" { + t.Fatalf("the key readers found %q", redact.Secrets(got)) + } + if err := backend.SetKey("deepseek", "sk-deepseek-app-key-7c2e"); err != nil { + t.Fatal(err) + } + providers, err := backend.ListProviders() + if err != nil { + t.Fatal(err) + } + byID := map[string]appwire.Provider{} + for _, p := range providers { + byID[p.ID] = p + } + if p := byID["openrouter"]; !p.SignedIn || p.KeyHint != "…a91f" || !p.SignIn || providers[0].ID != "openrouter" { + t.Fatalf("openrouter = %+v", p) + } + if p := byID["deepseek"]; !p.SignedIn || p.KeyHint != "…7c2e" || p.SignIn { + t.Fatalf("deepseek = %+v", p) + } + if _, listed := byID["codex"]; listed { + t.Fatal("the codex sign-in, which keeps tokens on disk, is listed") + } + rec.waitFor(t, time.Second, func(event string, data any) bool { + changed, ok := data.(appwire.ProviderChanged) + return ok && changed.Provider.ID == "deepseek" && changed.Provider.SignedIn && changed.Key == "" + }) + raw, _ := os.ReadFile(config.BudgetConfigPath(profile)) + if strings.Contains(string(raw), "app-key") { + t.Fatalf("a key reached the profile: %s", redact.Secrets(string(raw))) + } + + if err := backend.RemoveKey("openrouter"); err != nil { + t.Fatal(err) + } + if got := config.APIKeyAt(profile); got != "" { + t.Fatal("a removed key was replaced by the profile's") + } + if err := backend.SetKey("nowhere", "sk-x-0000000000"); err == nil { + t.Fatal("a key for no provider was kept") + } +} + +// SETUP RESUMES. A setup never started is at step one offering the $20.00 day +// cap; a step the app saved is where the next launch finds it, and the day cap +// it chose is the app's. +func TestAppSetupIsKeptAcrossLaunches(t *testing.T) { + t.Setenv(config.ProfileDirEnv, t.TempDir()) + path := filepath.Join(t.TempDir(), appStateName) + first := newFakeBackend(newAppRecorder(), nil) + first.state = &appState{path: path} + setup, err := first.Setup() + if err != nil || setup.Step != (appwire.SetupStep{N: 1}) || setup.DayCapUSD == nil || *setup.DayCapUSD != 20 { + t.Fatalf("a new setup = %+v, %v", setup, err) + } + cap := 35.0 + if _, err := first.SetSetup(appwire.Setup{Step: appwire.SetupStep{N: 3}, Provider: "OpenRouter", DayCapUSD: &cap}); err != nil { + t.Fatal(err) + } + first.Close() + + second := newFakeBackend(newAppRecorder(), nil) + defer second.Close() + second.state = &appState{path: path} + setup, err = second.Setup() + if err != nil || setup.Step != (appwire.SetupStep{N: 3}) || setup.Provider != "openrouter" || *setup.DayCapUSD != 35 { + t.Fatalf("the resumed setup = %+v, %v", setup, err) + } + if settings, _ := second.Settings(); settings.DayCapUSD == nil || *settings.DayCapUSD != 35 { + t.Fatalf("the setup's day cap is not the app's: %+v", settings.DayCapUSD) + } +} + +// SETTINGS.SET changes only what it names, says settings.changed, writes the +// shared rows to the profile and the app's own to its file, and a new agent +// with no workspace works in the chosen folder. +func TestAppSettingsSetChangesWhatItNames(t *testing.T) { + profile := t.TempDir() + t.Setenv(config.ProfileDirEnv, profile) + t.Setenv(home.EnvVar, t.TempDir()) + work := resolvedTempDir(t) + conv := &fakeConversation{} + var mu sync.Mutex + var opens, releases int + var opened []string + open := fakeOpener(conv, &opens, &releases, &mu) + rec := newAppRecorder() + backend := newFakeBackend(rec, func(ask appOpenAsk) (*appConn, error) { + mu.Lock() + opened = append(opened, ask.Workspace) + mu.Unlock() + return open(ask) + }) + defer backend.Close() + backend.state = &appState{path: filepath.Join(t.TempDir(), appStateName)} + + clock, idle, folder, model := 0, 3, work, "qwen/qwen3-coder" + cap, task := 40.0, 2.5 + settings, err := backend.SetSettings(appwire.SettingsPatch{ + DefaultModel: &model, DayCapUSD: &appwire.Figure{USD: &cap}, TaskCapUSD: &appwire.Figure{USD: &task}, + ProposedTaskClockS: &clock, IdleCloseDays: &idle, WorkFolder: &folder, BudgetUSD: &appwire.Figure{USD: &task}, + }) + if err != nil { + t.Fatal(err) + } + if settings.DefaultModel != model || *settings.DayCapUSD != 40 || *settings.TaskCapUSD != 2.5 || *settings.BudgetUSD != 2.5 || settings.ProposedTaskClockS != 0 || settings.IdleCloseDays != 3 || settings.WorkFolder != work { + t.Fatalf("settings = %+v", settings) + } + rec.waitFor(t, time.Second, func(event string, data any) bool { + changed, ok := data.(appwire.SettingsChanged) + return ok && changed.Settings.IdleCloseDays == 3 + }) + if config.CrewTaskCapAt(profile) != 2.5 || config.SpendRailUSDAt(profile) != 2.5 { + t.Fatal("the shared rows did not reach the profile") + } + settings, _ = backend.SetSettings(appwire.SettingsPatch{BudgetUSD: &appwire.Figure{}, DayCapUSD: &appwire.Figure{}}) + if settings.BudgetUSD != nil || settings.DayCapUSD != nil || settings.IdleCloseDays != 3 { + t.Fatalf("cleared figures = %+v", settings) + } + if _, err := backend.SetSettings(appwire.SettingsPatch{TaskCapUSD: &appwire.Figure{}}); err == nil { + t.Fatal("a task cap of none was taken") + } + nowhere := filepath.Join(work, "missing") + if _, err := backend.SetSettings(appwire.SettingsPatch{WorkFolder: &nowhere}); err == nil { + t.Fatal("a folder that is not there was taken") + } + + if _, err := mustCreate(t, backend, appwire.CreateAsk{}); err != nil { + t.Fatal(err) + } + mu.Lock() + defer mu.Unlock() + if len(opened) != 1 || opened[0] != work { + t.Fatalf("a new agent opened in %v", opened) + } +} + +// AN AGENT CLOSE TO ITS BUDGET ASKS, ONCE. The ask is an approval of subkind +// budget with raise, stop and tell; raising it binds the new figure to the +// conversation, keeps it in the app's file and closes the ask with a receipt. +func TestAppBudgetAskRaisesTheAgentsBudget(t *testing.T) { + t.Setenv(config.ProfileDirEnv, t.TempDir()) + conv := &fakeConversation{script: nil, cost: 4.2} + var mu sync.Mutex + var opens, releases int + rec := newAppRecorder() + backend := newFakeBackend(rec, fakeOpener(conv, &opens, &releases, &mu)) + defer backend.Close() + backend.state = &appState{path: filepath.Join(t.TempDir(), appStateName)} + backend.today = func(time.Time) float64 { return 1 } + + budget := 5.0 + agent, err := mustCreate(t, backend, appwire.CreateAsk{Workspace: t.TempDir(), BudgetUSD: &budget}) + if err != nil { + t.Fatal(err) + } + if _, err := backend.Send(agent.ID, "keep going", ""); err != nil { + t.Fatal(err) + } + i := rec.waitFor(t, 5*time.Second, func(event string, data any) bool { + opened, ok := data.(appwire.AskOpened) + return ok && opened.Ask.Subkind == appwire.SubkindBudget + }) + rec.mu.Lock() + ask := rec.data[i].(appwire.AskOpened).Ask + rec.mu.Unlock() + if ask.Kind != appwire.AskApproval || ask.Title != "It's close to its $5.00 budget" || !strings.HasPrefix(ask.Detail, "Spent $4.20 so far") || len(ask.Options) != 3 || + ask.Options[0].ID != appwire.OptionRaise || ask.Options[1].ID != appwire.OptionStop || ask.Options[2].ID != appwire.OptionTell { + t.Fatalf("the budget ask = %+v", ask) + } + if asks, _ := backend.ListAsks(agent.ID); len(asks) != 1 { + t.Fatalf("open asks = %+v", asks) + } + if err := backend.AnswerAsk(agent.ID, ask.ID, appwire.OptionRaise, "not money"); err == nil { + t.Fatal("a raise with no figure was taken") + } + if err := backend.AnswerAsk(agent.ID, ask.ID, appwire.OptionRaise, "$8.00"); err != nil { + t.Fatal(err) + } + rec.waitFor(t, time.Second, func(event string, data any) bool { + closed, ok := data.(appwire.AskClosed) + return ok && closed.ID == ask.ID && closed.By == appwire.ClosedByApp && closed.Option == appwire.OptionRaise + }) + rec.waitFor(t, time.Second, func(event string, data any) bool { + added, ok := data.(appwire.EntryAdded) + return ok && added.Entry.Kind == appwire.EntryReceipt && strings.HasPrefix(added.Entry.Text, "Budget raised to $8.00 · you · ") + }) + conv.mu.Lock() + rail := conv.rail + conv.mu.Unlock() + if rail != 8 { + t.Fatalf("the conversation's limit is %v", rail) + } + if usd, set := backend.agentBudget(agent.ID, time.Now()); !set || usd != 8 { + t.Fatalf("the app keeps %v (%v)", usd, set) + } + if asks, _ := backend.ListAsks(agent.ID); len(asks) != 0 { + t.Fatalf("asks still open: %+v", asks) + } +} + +// THE DAY CAP REFUSES A TURN BEFORE THE ENGINE HEARS OF IT, and asks. The +// refused turn ends as a budget failure, nothing is submitted, and raising the +// cap for today runs it again with the same words; a raise for today ends at +// midnight. +func TestAppDayCapRefusesAndARaiseRunsTheTurnAgain(t *testing.T) { + t.Setenv(config.ProfileDirEnv, t.TempDir()) + conv := &fakeConversation{} + var mu sync.Mutex + var opens, releases int + rec := newAppRecorder() + backend := newFakeBackend(rec, fakeOpener(conv, &opens, &releases, &mu)) + defer backend.Close() + backend.state = &appState{path: filepath.Join(t.TempDir(), appStateName)} + backend.today = func(time.Time) float64 { return 40 } + cap := 40.0 + if _, err := backend.SetSettings(appwire.SettingsPatch{DayCapUSD: &appwire.Figure{USD: &cap}}); err != nil { + t.Fatal(err) + } + + agent, err := mustCreate(t, backend, appwire.CreateAsk{Workspace: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + turn, err := backend.Send(agent.ID, "ship it", "") + if err != nil || turn == 0 { + t.Fatalf("send = %d, %v", turn, err) + } + done := rec.waitFor(t, time.Second, appTurnDone) + rec.mu.Lock() + refused := rec.data[done].(appwire.TurnDone) + rec.mu.Unlock() + if refused.OK || refused.ErrorKind != appwire.ErrorBudget || refused.Error != "Day cap reached · $40.00 of $40.00" { + t.Fatalf("the refused turn = %+v", refused) + } + i := rec.waitFor(t, time.Second, func(event string, data any) bool { + opened, ok := data.(appwire.AskOpened) + return ok && opened.Ask.Subkind == appwire.SubkindBudget + }) + rec.mu.Lock() + ask := rec.data[i].(appwire.AskOpened).Ask + rec.mu.Unlock() + if ask.Title != "Day cap reached · $40.00 of $40.00" || len(ask.Options) != 2 || ask.Options[0].ID != appwire.OptionRaiseToday || ask.Options[1].ID != appwire.OptionWait || !strings.Contains(ask.Detail, "waiting to send: ship it") { + t.Fatalf("the day ask = %+v", ask) + } + conv.mu.Lock() + submits := conv.submits + conv.mu.Unlock() + if submits != 0 { + t.Fatal("a refused turn reached the engine") + } + + if err := backend.AnswerAsk(agent.ID, ask.ID, appwire.OptionRaiseToday, "60"); err != nil { + t.Fatal(err) + } + rec.waitFor(t, time.Second, func(event string, data any) bool { + added, ok := data.(appwire.EntryAdded) + return ok && added.Entry.Kind == appwire.EntryReceipt && strings.HasPrefix(added.Entry.Text, "Day cap raised to $60.00 for today · you · ") + }) + rec.waitFor(t, 5*time.Second, func(event string, data any) bool { + started, ok := data.(appwire.TurnStarted) + return ok && started.Turn > turn + }) + conv.mu.Lock() + texts := append([]string(nil), conv.texts...) + conv.mu.Unlock() + if len(texts) != 1 || texts[0] != "ship it" { + t.Fatalf("the turn run again sent %q", texts) + } + spend, _ := backend.SpendToday() + if spend.DayCapUSD == nil || *spend.DayCapUSD != 60 { + t.Fatalf("today's cap = %v", spend.DayCapUSD) + } + if settings, _ := backend.Settings(); *settings.DayCapUSD != 40 { + t.Fatalf("a raise for today moved the setting to %v", *settings.DayCapUSD) + } + var tomorrow float64 + _ = backend.state.read(func(f *appStateFile) { tomorrow = f.dayCapAt(appMidnight(time.Now()).Add(time.Minute)) }) + if tomorrow != 40 { + t.Fatalf("tomorrow's cap = %v", tomorrow) + } +} diff --git a/docs/changes/unreleased/0-app-wave2.md b/docs/changes/unreleased/0-app-wave2.md new file mode 100644 index 0000000000..b2905137ff --- /dev/null +++ b/docs/changes/unreleased/0-app-wave2.md @@ -0,0 +1,19 @@ +--- +kind: added +title: codeaf app holds keys in memory, keeps setup, and asks before a budget or day cap stops it +pr: 0 +surface: [engine] +invalidates: + - "codeaf app read keys only from the profile and the shell. The app now hands keys over with keys.set and the process holds them in memory, ahead of every other source; while they are installed no profile write puts a key on disk, and keys.remove disconnects a provider for the launch without the profile's key taking its place." + - "settings.get's day_cap_usd was the terminal's daily limit. It is now the app's own day cap, $20.00 unless setup or settings.set chose another, kept in app/state.json under codeaf's home; the terminal's daily limit and its default are unchanged." + - "An agent created with no workspace always worked in the home folder. It now works in settings' work_folder, which defaults to the home folder." + - "An agent's budget_usd lasted only while its conversation stayed open. The app now keeps it and binds it again at every open and every turn." +--- + +New methods: `providers.list`, `keys.check` (one request to the provider, ten +seconds, the provider's own status and words), `keys.set`, `keys.remove`, +`providers.sign_in` (OpenRouter's browser sign-in; its key arrives once on +`provider.changed`), `setup.get` / `setup.set`, `settings.set` with +`settings.changed`, and `caps.raise`. An agent near or at its budget, or a +message sent at the day cap, opens an approval ask of subkind `budget`; a raise +runs again the turn the limit refused. diff --git a/internal/appwire/server_test.go b/internal/appwire/server_test.go index d6dc496270..b72ee4e5fc 100644 --- a/internal/appwire/server_test.go +++ b/internal/appwire/server_test.go @@ -19,6 +19,7 @@ type fakeEngine struct { block chan struct{} created CreateAsk limit int + patch SettingsPatch } func (f *fakeEngine) ListAgents(workspace string) ([]Agent, error) { diff --git a/internal/appwire/setup.go b/internal/appwire/setup.go new file mode 100644 index 0000000000..38c20c7e1b --- /dev/null +++ b/internal/appwire/setup.go @@ -0,0 +1,221 @@ +package appwire + +import ( + "encoding/json" + "math" + "strings" +) + +// The methods of setup, keys, settings and money (PROTOCOL.md, Wave 2). They +// join the one dispatch table, so they are refused, framed and answered by +// exactly the rules every other method is. +func init() { + for name, m := range setupMethods { + methods[name] = m + } +} + +var setupMethods = map[string]method{ + "providers.list": func(e Engine, _ json.RawMessage) (any, error) { + providers, err := e.ListProviders() + if err != nil { + return nil, err + } + return struct { + Providers []Provider `json:"providers"` + }{nonNil(providers)}, nil + }, + "keys.check": func(e Engine, raw json.RawMessage) (any, error) { + provider, key, err := decodeKey(raw, "keys.check") + if err != nil { + return nil, err + } + return e.CheckKey(provider, key) + }, + "keys.set": func(e Engine, raw json.RawMessage) (any, error) { + provider, key, err := decodeKey(raw, "keys.set") + if err != nil { + return nil, err + } + return struct{}{}, e.SetKey(provider, key) + }, + "keys.remove": func(e Engine, raw json.RawMessage) (any, error) { + provider, err := decodeID(raw, "provider") + if err != nil { + return nil, err + } + return struct{}{}, e.RemoveKey(strings.ToLower(strings.TrimSpace(provider))) + }, + "providers.sign_in": func(e Engine, raw json.RawMessage) (any, error) { + provider, err := decodeID(raw, "provider") + if err != nil { + return nil, err + } + url, err := e.SignIn(strings.ToLower(strings.TrimSpace(provider))) + if err != nil { + return nil, err + } + return struct { + URL string `json:"url"` + }{url}, nil + }, + "setup.get": func(e Engine, _ json.RawMessage) (any, error) { + setup, err := e.Setup() + if err != nil { + return nil, err + } + return setupResult{setup}, nil + }, + "setup.set": func(e Engine, raw json.RawMessage) (any, error) { + var p Setup + if err := decode(raw, &p); err != nil { + return nil, err + } + if p.Step == (SetupStep{}) { + return nil, Errorf(CodeBadParams, "setup.set needs a step") + } + if p.DayCapUSD != nil && !money(*p.DayCapUSD) { + return nil, Errorf(CodeBadParams, "day_cap_usd must be a positive amount") + } + if p.Start != "" && p.Start != SetupAgents { + return nil, Errorf(CodeBadParams, "start is %q or nothing", SetupAgents) + } + setup, err := e.SetSetup(p) + if err != nil { + return nil, err + } + return setupResult{setup}, nil + }, + "settings.set": func(e Engine, raw json.RawMessage) (any, error) { + patch, err := decodeSettingsPatch(raw) + if err != nil { + return nil, err + } + settings, err := e.SetSettings(patch) + if err != nil { + return nil, err + } + return struct { + Settings Settings `json:"settings"` + }{settings}, nil + }, + "caps.raise": func(e Engine, raw json.RawMessage) (any, error) { + var p CapRaise + if err := decode(raw, &p); err != nil { + return nil, err + } + switch p.Scope { + case CapDay: + case CapAgent: + if strings.TrimSpace(p.ID) == "" { + return nil, Errorf(CodeBadParams, "raising an agent's cap needs its id") + } + default: + return nil, Errorf(CodeBadParams, "scope is agent or day") + } + if p.For != RaiseToday && p.For != RaiseGood { + return nil, Errorf(CodeBadParams, "for is today or good") + } + if !money(p.ToUSD) { + return nil, Errorf(CodeBadParams, "to_usd must be a positive amount") + } + return struct{}{}, e.RaiseCap(p) + }, +} + +type setupResult struct { + Setup Setup `json:"setup"` +} + +// money says a figure is a real positive amount of dollars. +func money(usd float64) bool { + return usd > 0 && !math.IsNaN(usd) && !math.IsInf(usd, 0) +} + +// decodeKey reads a provider and a key, both required. The key is never put +// into an error's words. +func decodeKey(raw json.RawMessage, name string) (string, string, error) { + var p struct { + Provider string `json:"provider"` + Key string `json:"key"` + } + if err := json.Unmarshal(raw, &p); err != nil { + return "", "", Errorf(CodeBadParams, "%s needs a provider and a key, both strings", name) + } + provider, key := strings.ToLower(strings.TrimSpace(p.Provider)), strings.TrimSpace(p.Key) + if provider == "" || key == "" { + return "", "", Errorf(CodeBadParams, "%s needs a provider and a key", name) + } + if strings.ContainsAny(key, "\r\n\x00") { + return "", "", Errorf(CodeBadParams, "a key is one line") + } + return provider, key, nil +} + +// decodeSettingsPatch reads settings.set's params: the fields present, each +// in its own shape, and nothing it does not know. +func decodeSettingsPatch(raw json.RawMessage) (SettingsPatch, error) { + var fields map[string]json.RawMessage + if err := decode(raw, &fields); err != nil { + return SettingsPatch{}, err + } + var patch SettingsPatch + text := func(name string, into **string) error { + var s string + if err := json.Unmarshal(fields[name], &s); err != nil { + return Errorf(CodeBadParams, "%s must be a string", name) + } + s = strings.TrimSpace(s) + *into = &s + return nil + } + whole := func(name string, into **int) error { + var n int + if err := json.Unmarshal(fields[name], &n); err != nil || n < 0 { + return Errorf(CodeBadParams, "%s must be a whole number, zero or more", name) + } + *into = &n + return nil + } + figure := func(name string, into **Figure) error { + if strings.TrimSpace(string(fields[name])) == "null" { + *into = &Figure{} + return nil + } + var usd float64 + if err := json.Unmarshal(fields[name], &usd); err != nil || !money(usd) { + return Errorf(CodeBadParams, "%s must be a positive amount, or null for none", name) + } + *into = &Figure{USD: &usd} + return nil + } + for name := range fields { + var err error + switch name { + case "default_model": + err = text(name, &patch.DefaultModel) + case "asks_before": + err = text(name, &patch.AsksBefore) + case "work_folder": + err = text(name, &patch.WorkFolder) + case "effort": + err = text(name, &patch.Effort) + case "budget_usd": + err = figure(name, &patch.BudgetUSD) + case "day_cap_usd": + err = figure(name, &patch.DayCapUSD) + case "task_cap_usd": + err = figure(name, &patch.TaskCapUSD) + case "proposed_task_clock_s": + err = whole(name, &patch.ProposedTaskClockS) + case "idle_close_days": + err = whole(name, &patch.IdleCloseDays) + default: + err = Errorf(CodeBadParams, "settings has no field %s", name) + } + if err != nil { + return SettingsPatch{}, err + } + } + return patch, nil +} diff --git a/internal/appwire/setup_test.go b/internal/appwire/setup_test.go new file mode 100644 index 0000000000..bf1cc28308 --- /dev/null +++ b/internal/appwire/setup_test.go @@ -0,0 +1,116 @@ +package appwire + +import ( + "encoding/json" + "testing" +) + +func (f *fakeEngine) ListProviders() ([]Provider, error) { + return []Provider{{ID: "openrouter", Name: "OpenRouter", SignedIn: true, KeyHint: "…a91f", SignIn: true}}, nil +} +func (f *fakeEngine) CheckKey(provider, key string) (KeyCheck, error) { + if key == "good" { + return KeyCheck{OK: true, Models: []Model{{ID: "m", Name: "m", Provider: provider}}}, nil + } + return KeyCheck{OK: false, Status: 401, Message: "No auth credentials found"}, nil +} +func (f *fakeEngine) SetKey(provider, key string) error { return nil } +func (f *fakeEngine) RemoveKey(provider string) error { return nil } +func (f *fakeEngine) SignIn(provider string) (string, error) { + if provider != "openrouter" { + return "", Errorf(CodeBadParams, "%s has no browser sign-in", provider) + } + return "https://openrouter.ai/auth?x", nil +} +func (f *fakeEngine) Setup() (Setup, error) { + cap := 20.0 + return Setup{Step: SetupStep{N: 1}, DayCapUSD: &cap}, nil +} +func (f *fakeEngine) SetSetup(setup Setup) (Setup, error) { return setup, nil } +func (f *fakeEngine) SetSettings(patch SettingsPatch) (Settings, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.patch = patch + return Settings{ProposedTaskClockS: 12, IdleCloseDays: 7}, nil +} +func (f *fakeEngine) RaiseCap(raise CapRaise) error { return nil } + +func TestSetupMethodsAnswerTheirShapes(t *testing.T) { + engine := &fakeEngine{} + w := startWire(t, engine) + w.next() + result := func(line string) map[string]any { + t.Helper() + w.send(line) + got := w.next() + if got["error"] != nil { + t.Fatalf("%s = %v", line, got) + } + return got["result"].(map[string]any) + } + providers := result(`{"id":1,"method":"providers.list"}`)["providers"].([]any) + if p := providers[0].(map[string]any); p["id"] != "openrouter" || p["signed_in"] != true || p["key_hint"] != "…a91f" || p["sign_in"] != true { + t.Fatalf("providers = %v", providers) + } + bad := result(`{"id":2,"method":"keys.check","params":{"provider":"openrouter","key":"nope"}}`) + if bad["ok"] != false || bad["status"] != float64(401) || bad["message"] != "No auth credentials found" { + t.Fatalf("bad key = %v", bad) + } + setup := result(`{"id":3,"method":"setup.get"}`)["setup"].(map[string]any) + if setup["step"] != float64(1) || setup["day_cap_usd"] != float64(20) { + t.Fatalf("setup = %v", setup) + } + done := result(`{"id":4,"method":"setup.set","params":{"step":"done","start":"agents"}}`)["setup"].(map[string]any) + if done["step"] != "done" || done["start"] != "agents" { + t.Fatalf("setup done = %v", done) + } + if url := result(`{"id":5,"method":"providers.sign_in","params":{"provider":"OpenRouter"}}`)["url"]; url == "" { + t.Fatal("sign in answered no url") + } + settings := result(`{"id":6,"method":"settings.set","params":{"budget_usd":null,"day_cap_usd":40,"idle_close_days":3}}`)["settings"].(map[string]any) + if settings["proposed_task_clock_s"] != float64(12) { + t.Fatalf("settings = %v", settings) + } + engine.mu.Lock() + patch := engine.patch + engine.mu.Unlock() + if patch.BudgetUSD == nil || patch.BudgetUSD.USD != nil || patch.DayCapUSD == nil || *patch.DayCapUSD.USD != 40 || *patch.IdleCloseDays != 3 || patch.DefaultModel != nil { + t.Fatalf("patch = %+v", patch) + } + result(`{"id":7,"method":"caps.raise","params":{"scope":"day","to_usd":60,"for":"today"}}`) +} + +func TestSetupMethodsRefuseBadParams(t *testing.T) { + w := startWire(t, &fakeEngine{}) + w.next() + for i, line := range []string{ + `{"id":1,"method":"keys.check","params":{"provider":"openrouter"}}`, + `{"id":2,"method":"keys.set","params":{"provider":"openrouter","key":"a\nb"}}`, + `{"id":3,"method":"setup.set","params":{"step":5}}`, + `{"id":4,"method":"setup.set","params":{}}`, + `{"id":5,"method":"settings.set","params":{"colour":"blue"}}`, + `{"id":6,"method":"settings.set","params":{"day_cap_usd":-1}}`, + `{"id":7,"method":"caps.raise","params":{"scope":"agent","to_usd":5,"for":"today"}}`, + `{"id":8,"method":"caps.raise","params":{"scope":"day","to_usd":5,"for":"ever"}}`, + `{"id":9,"method":"providers.sign_in","params":{"provider":"deepseek"}}`, + } { + w.send(line) + got := w.next() + if got["id"] != float64(i+1) || got["error"] == nil || got["error"].(map[string]any)["code"] != CodeBadParams { + t.Fatalf("%s = %v", line, got) + } + } +} + +func TestSetupStepIsANumberOrDone(t *testing.T) { + for _, step := range []SetupStep{{N: 3}, {Done: true}} { + raw, err := json.Marshal(step) + if err != nil { + t.Fatal(err) + } + var back SetupStep + if err := json.Unmarshal(raw, &back); err != nil || back != step { + t.Fatalf("%s read back as %+v (%v)", raw, back, err) + } + } +} diff --git a/internal/appwire/wire.go b/internal/appwire/wire.go index 387905119e..72b7c8487e 100644 --- a/internal/appwire/wire.go +++ b/internal/appwire/wire.go @@ -69,6 +69,9 @@ const ( EventCrewChanged = "crew.changed" EventCrewRemoved = "crew.removed" EventLog = "log" + + EventSettingsChanged = "settings.changed" + EventProviderChanged = "provider.changed" ) // Error is a refusal the app can read: a code from the closed set above and a @@ -163,6 +166,15 @@ const ( OptionAllowOnce = "allow_once" OptionDeny = "deny" OptionAlways = "always" + + // A budget ask about one agent offers raise (its text is the new figure), + // stop and tell (its text is what to tell the agent); one about the day cap + // offers raise_today and wait. + OptionRaise = "raise" + OptionStop = "stop" + OptionTell = "tell" + OptionRaiseToday = "raise_today" + OptionWait = "wait" ) // Entry is one item of a conversation's transcript, in order. Which of the @@ -233,13 +245,125 @@ const ( ) // Settings are the few defaults the app shows. A figure nobody set is null. +// WorkFolder is where an agent created with no workspace works; +// ProposedTaskClockS is how long a proposed task waits before it starts on its +// own; IdleCloseDays is how long an agent nobody touches stays open. type Settings struct { - DefaultModel string `json:"default_model"` - BudgetUSD *float64 `json:"budget_usd"` - AsksBefore string `json:"asks_before"` - DayCapUSD *float64 `json:"day_cap_usd"` + DefaultModel string `json:"default_model"` + BudgetUSD *float64 `json:"budget_usd"` + AsksBefore string `json:"asks_before"` + DayCapUSD *float64 `json:"day_cap_usd"` + WorkFolder string `json:"work_folder"` + TaskCapUSD *float64 `json:"task_cap_usd"` + ProposedTaskClockS int `json:"proposed_task_clock_s"` + IdleCloseDays int `json:"idle_close_days"` + Effort string `json:"effort,omitempty"` +} + +// SettingsPatch is settings.set's params: only the fields present change. A +// money figure sent as null is cleared, which the settings that always have a +// figure refuse. +type SettingsPatch struct { + DefaultModel *string + BudgetUSD *Figure + AsksBefore *string + DayCapUSD *Figure + WorkFolder *string + TaskCapUSD *Figure + ProposedTaskClockS *int + IdleCloseDays *int + Effort *string +} + +// Figure is one money field of a patch: USD is nil for a figure sent as null. +type Figure struct { + USD *float64 +} + +// Provider is one service a key or a sign-in connects. KeyHint is the last +// four characters of the key in use, and SignIn says the provider can be +// connected by a browser sign-in instead of a pasted key. +type Provider struct { + ID string `json:"id"` + Name string `json:"name"` + SignedIn bool `json:"signed_in"` + KeyHint string `json:"key_hint,omitempty"` + SignIn bool `json:"sign_in,omitempty"` +} + +// KeyCheck is keys.check's answer. Status is the provider's own status code +// for a rejection, or the word timeout when the provider never answered. +type KeyCheck struct { + OK bool `json:"ok"` + Models []Model `json:"models,omitempty"` + Status any `json:"status,omitempty"` + Message string `json:"message,omitempty"` +} + +// Setup is how far the person has come through first-run setup, kept by +// codeaf so quitting halfway resumes at the same step. +type Setup struct { + Step SetupStep `json:"step"` + Provider string `json:"provider,omitempty"` + DefaultModel string `json:"default_model,omitempty"` + DayCapUSD *float64 `json:"day_cap_usd,omitempty"` + Start string `json:"start,omitempty"` } +// SetupStep is a setup step: 1 to 4 on the wire as a number, or "done". +type SetupStep struct { + N int + Done bool +} + +// The number of setup steps, and the one place a setup may say it starts. +const ( + SetupSteps = 4 + SetupAgents = "agents" +) + +func (s SetupStep) MarshalJSON() ([]byte, error) { + if s.Done { + return []byte(`"done"`), nil + } + return json.Marshal(s.N) +} + +func (s *SetupStep) UnmarshalJSON(raw []byte) error { + var word string + if json.Unmarshal(raw, &word) == nil { + if word != "done" { + return fmt.Errorf("step is 1 to %d or \"done\"", SetupSteps) + } + *s = SetupStep{Done: true} + return nil + } + var n int + if err := json.Unmarshal(raw, &n); err != nil || n < 1 || n > SetupSteps { + return fmt.Errorf("step is 1 to %d or \"done\"", SetupSteps) + } + *s = SetupStep{N: n} + return nil +} + +// CapRaise is caps.raise's params: which cap, to what, and whether the raise +// lasts until midnight (for: today) or from now on (for: good). +type CapRaise struct { + Scope string `json:"scope"` + ID string `json:"id"` + ToUSD float64 `json:"to_usd"` + For string `json:"for"` +} + +// The two scopes a cap is raised in, and how long a raise lasts. +const ( + CapAgent = "agent" + CapDay = "day" + + RaiseToday = "today" + RaiseGood = "good" +) + // Spend is today's spending on this machine against the daily cap, which is // null when there is none. type Spend struct { @@ -324,6 +448,15 @@ type ( Level string `json:"level"` Message string `json:"message"` } + SettingsChanged struct { + Settings Settings `json:"settings"` + } + // ProviderChanged carries the key a browser sign-in produced, once, so + // the app can keep it in the keychain and hand it back at the next launch. + ProviderChanged struct { + Provider Provider `json:"provider"` + Key string `json:"key,omitempty"` + } ) // The two delta kinds. @@ -372,6 +505,17 @@ type Engine interface { ListModels() ([]Model, error) Settings() (Settings, error) SpendToday() (Spend, error) + // Wave 2: providers and keys, first-run setup, settings and caps. A key + // is held in memory only; CheckKey makes exactly one provider request. + ListProviders() ([]Provider, error) + CheckKey(provider, key string) (KeyCheck, error) + SetKey(provider, key string) error + RemoveKey(provider string) error + SignIn(provider string) (string, error) + Setup() (Setup, error) + SetSetup(setup Setup) (Setup, error) + SetSettings(patch SettingsPatch) (Settings, error) + RaiseCap(raise CapRaise) error // Close releases every conversation the engine holds. It is called once, // when the process is leaving, and returns when they are all let go. Close() error diff --git a/internal/config/apikey.go b/internal/config/apikey.go index 6ede54afc2..c66441dcec 100644 --- a/internal/config/apikey.go +++ b/internal/config/apikey.go @@ -101,6 +101,9 @@ func defaultKeyEndpoint(base string) bool { // different rung than the client actually used. On OpenRouter a connected key // outranks the compatibility variable; custom endpoints retain their old order. func apiKeyResolution(values map[string]json.RawMessage) (string, string) { + if key, ok := appKey(modelsource.DefaultID); ok { + return key, "the app's key" + } base := env.Get("CODEAF_BASE_URL") profile, fallback := persistedAPIKeyFrom(values), usableOpenAIKey(base) candidates := []struct{ value, source string }{ @@ -177,6 +180,9 @@ func LooksLikeAPIKey(key string) bool { // the path that holds it. func EnsurePersistedAPIKey(profileDir string) (bool, string, error) { path := BudgetConfigPath(profileDir) + if AppMode() { + return false, path, ErrAppKeys + } if PersistedAPIKey(profileDir) != "" { return false, path, nil } diff --git a/internal/config/appcredentials.go b/internal/config/appcredentials.go new file mode 100644 index 0000000000..57aecd4849 --- /dev/null +++ b/internal/config/appcredentials.go @@ -0,0 +1,156 @@ +package config + +import ( + "encoding/json" + "errors" + "sort" + "strings" + "sync" + "sync/atomic" + + "github.com/Agent-Field/codeaf/internal/modelsource" + "github.com/Agent-Field/codeaf/internal/redact" + "github.com/Agent-Field/codeaf/internal/trace" +) + +// ErrAppKeys is the refusal a profile write meets when it would put a key on +// disk in a process the desktop app started: the app keeps keys in the +// person's keychain and hands them over at every launch. +var ErrAppKeys = errors.New("keys are kept by the app") + +// AppCredentials are the keys the desktop app handed this process, held in +// memory and nowhere else. AN EMPTY ENTRY IS A REMOVAL AND NOT AN ABSENCE: a +// provider the person just disconnected must not quietly reconnect itself from +// a key in the profile or the shell. +type AppCredentials struct { + mu sync.RWMutex + keys map[string]string +} + +var appCredentials atomic.Pointer[AppCredentials] + +// NewAppCredentials is an empty set of app keys. +func NewAppCredentials() *AppCredentials { + return &AppCredentials{keys: make(map[string]string)} +} + +// UseAppCredentials installs the keys every key reader consults first, before +// any conversation starts, and answers how to take them away again. +func UseAppCredentials(source *AppCredentials) func() { + previous := appCredentials.Swap(source) + return func() { appCredentials.Store(previous) } +} + +// AppMode says this process is the desktop app's, so keys never reach disk. +func AppMode() bool { return appCredentials.Load() != nil } + +// AppCredentialSource is the installed app keys, or nil outside the app. +func AppCredentialSource() *AppCredentials { return appCredentials.Load() } + +// Set holds one provider's key, and registers it with the redactor and the +// trace scrubber so no log line ever carries it. +func (c *AppCredentials) Set(provider, key string) { + provider, key = strings.ToLower(strings.TrimSpace(provider)), strings.TrimSpace(key) + trace.Secret(key) + redact.Register(key) + c.mu.Lock() + defer c.mu.Unlock() + c.keys[provider] = key +} + +// Remove disconnects one provider for the rest of this launch. +func (c *AppCredentials) Remove(provider string) { + c.mu.Lock() + defer c.mu.Unlock() + c.keys[strings.ToLower(strings.TrimSpace(provider))] = "" +} + +// Key answers one provider's key and whether the app has said anything about +// it; an empty key that is known is a removal. +func (c *AppCredentials) Key(provider string) (string, bool) { + c.mu.RLock() + defer c.mu.RUnlock() + key, ok := c.keys[strings.ToLower(strings.TrimSpace(provider))] + return key, ok +} + +func appKey(provider string) (string, bool) { + if source := appCredentials.Load(); source != nil { + return source.Key(provider) + } + return "", false +} + +// Snapshot is a copy of the keys by provider. Nothing writes it anywhere. +func (c *AppCredentials) Snapshot() map[string]string { + c.mu.RLock() + defer c.mu.RUnlock() + keys := make(map[string]string, len(c.keys)) + for id, key := range c.keys { + keys[id] = key + } + return keys +} + +// appSourceRows adds the services the app holds a key for to the persisted +// rows, without touching config.json. A persisted row still owns its region and +// endpoint; the app's key only answers for it. +func appSourceRows(rows []PersistedSource) []PersistedSource { + source := appCredentials.Load() + if source == nil { + return rows + } + keys := source.Snapshot() + known := make(map[string]bool) + for _, row := range rows { + known[row.ID] = true + } + var ids []string + for id, key := range keys { + if key != "" && !known[id] && id != modelsource.DefaultID { + ids = append(ids, id) + } + } + sort.Strings(ids) + rows = append([]PersistedSource(nil), rows...) + for _, id := range ids { + rows = append(rows, PersistedSource{ID: id, Written: id}) + } + return rows +} + +// refuseAppCredentialUpdates guards the one profile writer: in the app's +// process no secret row and no service row carrying a key is written. Every +// other setting is written as it always is. +func refuseAppCredentialUpdates(updates map[string]any) error { + if !AppMode() { + return nil + } + secretFields := map[string]bool{KeyAPIKey: true} + for _, row := range NewSettings(SettingsOptions{}).Rows() { + if row.Secret { + secretFields[row.Key] = true + } + } + for name, value := range updates { + if secretFields[name] { + return ErrAppKeys + } + if name == keyModelSources { + raw, err := json.Marshal(value) + if err != nil { + return err + } + var rows []PersistedSource + if json.Unmarshal(raw, &rows) != nil { + return ErrAppKeys + } + for _, row := range rows { + if row.Key != "" { + return ErrAppKeys + } + } + } + } + return nil +} diff --git a/internal/config/appcredentials_test.go b/internal/config/appcredentials_test.go new file mode 100644 index 0000000000..ae65be6642 --- /dev/null +++ b/internal/config/appcredentials_test.go @@ -0,0 +1,65 @@ +package config + +import ( + "errors" + "os" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/modelsource" + "github.com/Agent-Field/codeaf/internal/redact" +) + +// THE APP'S KEYS ARE MEMORY ONLY. Installed, they outrank the profile and the +// shell for every reader; a removal is not filled back in from either; the +// profile writer refuses a secret while they are installed and writes every +// other setting; and a key handed over is covered by the redactor. +func TestAppCredentialsOutrankTheProfileAndNeverReachIt(t *testing.T) { + dir := t.TempDir() + t.Setenv("OPENROUTER_API_KEY", "") + t.Setenv("OPENAI_API_KEY", "") + t.Setenv("DEEPSEEK_API_KEY", "") + if err := writeProfileValue(dir, KeyAPIKey, "sk-or-profile-0000000001"); err != nil { + t.Fatal(err) + } + creds := NewAppCredentials() + restore := UseAppCredentials(creds) + defer restore() + if !AppMode() { + t.Fatal("installed keys are not the app's mode") + } + if got := APIKeyAt(dir); got != "sk-or-profile-0000000001" { + t.Fatalf("with no app key the profile's is %q", got) + } + creds.Set("OpenRouter", "opaque-app-token-a91f") + if got := APIKeyAt(dir); got != "opaque-app-token-a91f" { + t.Fatalf("the app's key lost to %q", got) + } + if said := redact.Secrets("token opaque-app-token-a91f here"); strings.Contains(said, "opaque-app-token") { + t.Fatalf("the redactor let the key through: %q", said) + } + creds.Set(modelsource.DefaultID, "sk-or-app-0000000002") + creds.Set("deepseek", "sk-deepseek-app-0000000003") + sources := ResolveSources(dir, APIKeyAt(dir), DefaultBaseURL) + if service, ok := sources.ByID("deepseek"); !ok || service.Key != "sk-deepseek-app-0000000003" { + t.Fatalf("deepseek = %+v, %v", service, ok) + } + creds.Remove("openrouter") + if got := APIKeyAt(dir); got != "" { + t.Fatalf("a removed key was filled back in with %q", got) + } + if err := writeProfileValue(dir, KeyAPIKey, "sk-or-written-0000000004"); !errors.Is(err, ErrAppKeys) { + t.Fatalf("a key write was answered %v", err) + } + if err := writeProfileValue(dir, KeyToolApprovalMode, "deny"); err != nil { + t.Fatalf("an ordinary setting was refused: %v", err) + } + raw, _ := os.ReadFile(BudgetConfigPath(dir)) + if strings.Contains(string(raw), "app-0") || strings.Contains(string(raw), "written") { + t.Fatalf("an app key reached the profile: %s", raw) + } + restore() + if AppMode() { + t.Fatal("the app's mode outlived its keys") + } +} diff --git a/internal/config/budget.go b/internal/config/budget.go index d60e4bc4b4..f12eced158 100644 --- a/internal/config/budget.go +++ b/internal/config/budget.go @@ -129,6 +129,9 @@ var removeProfileKey = profileKeyRemoval{} type profileKeyRemoval struct{} func writeProfileValues(profileDir string, updates map[string]any) error { + if err := refuseAppCredentialUpdates(updates); err != nil { + return err + } if len(updates) == 0 { return nil } diff --git a/internal/config/credentials.go b/internal/config/credentials.go index 2072ba4b2f..fcd1dddf40 100644 --- a/internal/config/credentials.go +++ b/internal/config/credentials.go @@ -50,6 +50,11 @@ func Credentials(profileDir string) []string { // OPENAI_API_KEY is a secret that must be kept out of a debug record. add(os.Getenv("OPENAI_API_KEY")) add(APIKeyAt(profileDir)) + if source := appCredentials.Load(); source != nil { + for _, key := range source.Snapshot() { + add(key) + } + } for _, service := range ResolveSources(profileDir, APIKeyAt(profileDir), DefaultBaseURL).All()[1:] { add(service.Key) } diff --git a/internal/config/sources.go b/internal/config/sources.go index 9108fd2583..f8d22daa66 100644 --- a/internal/config/sources.go +++ b/internal/config/sources.go @@ -109,6 +109,9 @@ func WriteSources(profileDir string, rows []PersistedSource) error { // existing three-rung implementation; another service reads its conventional // variable, its stored key, then the variable a person named. func SourceKeyAt(profileDir string, row PersistedSource, src modelsource.Source) string { + if key, ok := appKey(src.ID); ok { + return key + } if strings.EqualFold(strings.TrimSpace(src.ID), modelsource.DefaultID) { return APIKeyAt(profileDir) } @@ -171,6 +174,10 @@ func sourceHomes(set modelsource.Set, profileDir string) modelsource.Set { // rows from the profile snapshot it already read for api_key, while callers // that need a fresh reading keep using ResolveSources. func resolveSources(defaultKey, defaultBase string, rows []PersistedSource, keyAt func(PersistedSource, modelsource.Source) string) modelsource.Set { + if key, ok := appKey(modelsource.DefaultID); ok { + defaultKey = key + } + rows = appSourceRows(rows) defaultSource := modelsource.DefaultSource(defaultBase) connected := []modelsource.Connected{{Source: defaultSource, Key: defaultKey, Address: defaultBase}} vendored := modelsource.Vendored() @@ -270,6 +277,9 @@ func CodexRememberedModels(service modelsource.Connected, profileDir string) []c // ConnectCodex keeps a completed browser sign-in, persists its service row and // seeds the picker from the account's own visible model list. func ConnectCodex(ctx context.Context, profileDir string, tokens codexauth.Tokens) (modelsource.Outcome, error) { + if AppMode() { + return modelsource.Outcome{}, ErrAppKeys + } if err := ctx.Err(); err != nil { return modelsource.Outcome{}, err } @@ -370,6 +380,9 @@ func resolvedPlanPaused(row PersistedSource) string { // ConnectService proves a key and says what it reaches, and writes nothing on // any answer but yes. Ten seconds; a person is watching this one. func ConnectService(ctx context.Context, profileDir string, row PersistedSource, src modelsource.Source, authors []string) (modelsource.Outcome, error) { + if AppMode() { + return modelsource.Outcome{}, ErrAppKeys + } if strings.TrimSpace(row.ID) == "" { row.ID = src.ID } diff --git a/internal/manual/chat/running-from-the-terminal.md b/internal/manual/chat/running-from-the-terminal.md index b6a783241d..4012ca650a 100644 --- a/internal/manual/chat/running-from-the-terminal.md +++ b/internal/manual/chat/running-from-the-terminal.md @@ -305,6 +305,30 @@ closes. Neither draws anything or reads a key. **`codeaf tick`** and **`codeaf w kept out of it as well, because they do nothing any more (see *codeaf wake and codeaf tick* below). +## The desktop app's keys, day cap and budget asks — what codeaf app keeps + +Keys pasted into the codeaf desktop app are kept in the Mac's keychain, not by codeaf. +The app hands them to `codeaf app` at every launch and that process holds them in memory +only: while it runs, a key never reaches the profile's `config.json`, and a provider the +app disconnected is not quietly reconnected from a key in the profile or the shell. The +app's key check is one request to the provider with ten seconds to answer; a rejected key +is reported in the provider's own words and status code. Browser sign-in in the app is +OpenRouter's only; the ChatGPT plan sign-in stays in the terminal (`codeaf connect codex`). + +The app has a day cap of its own, $20.00 unless its setup or settings chose another, and +it is separate from the terminal's daily limit (`/budget day`), which keeps its own +figure. A message sent once today's spending has reached the app's day cap is not sent at +all; the agent asks instead, `Day cap reached · $40.00 of $40.00`, with *Raise for today* +(back to the set figure at midnight) or *Wait until tomorrow*. An agent with a budget asks +once it has spent four fifths of it — `It's close to its $5.00 budget` — and again when it +reaches it, offering *Raise budget*, *Stop* or *Tell it something*. Raising either limit +runs again the message it refused. Neither limit stops a turn halfway: a turn that crosses +one finishes, and the next is the one that waits. + +The app's own settings (day cap, new agents' folder, the 12-second proposed-task clock, +7 idle days before an agent reads closed) and its setup progress live in +`app/state.json` under codeaf's home folder, so quitting setup halfway resumes there. + ## Connect from the terminal without opening the chat — codeaf connect and codeaf disconnect `codeaf connect` lists every model provider this profile knows, whether it is connected, diff --git a/internal/redact/redact.go b/internal/redact/redact.go index 9a3d0bee98..93c9211f32 100644 --- a/internal/redact/redact.go +++ b/internal/redact/redact.go @@ -37,9 +37,45 @@ package redact import ( "regexp" + "sort" "strings" + "sync" ) +var registered struct { + sync.RWMutex + values []string +} + +// Register covers a secret whose shape cannot identify it, such as a key the +// desktop app handed over. A key removed later stays covered, because a line +// written while it was in use can still be read after. +func Register(value string) { + if value == "" { + return + } + registered.Lock() + defer registered.Unlock() + for _, known := range registered.values { + if known == value { + return + } + } + registered.values = append(registered.values, value) + sort.SliceStable(registered.values, func(i, j int) bool { return len(registered.values[i]) > len(registered.values[j]) }) +} + +// registeredOut takes every registered value out of text, longest first, so a +// key that contains a shorter one is never left half covered. +func registeredOut(text string) string { + registered.RLock() + defer registered.RUnlock() + for _, secret := range registered.values { + text = strings.ReplaceAll(text, secret, "[redacted]") + } + return text +} + // shape is one recognisable form of a secret. type shape struct { // name is what a person is told was taken out. It goes into the marker @@ -172,6 +208,7 @@ func Secrets(text string) string { if text == "" { return text } + text = registeredOut(text) for _, s := range shapes { text = s.apply(text) } From 86918bc1c98f009231c466573bf3b7e66726afc6 Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Thu, 15 Oct 2026 08:11:55 -0400 Subject: [PATCH 007/459] codeaf app: tasks, quick tasks, processes, running long and sleep Task verbs and cards with clock, typing, irreversible and your call; quick task undo and growth; background processes with output, follow, stop and rerun; turn.long and move to task with the app's checkpoint switch; the task live line; tasks.list read from a conversation's own task file; entries carry turn and client id; power.sleep and power.wake; honest model providers. --- cmd/codeaf/app.go | 286 +++-- cmd/codeaf/app_test.go | 102 +- cmd/codeaf/appwork.go | 1064 +++++++++++++++++ cmd/codeaf/appwork_test.go | 418 +++++++ cmd/codeaf/engine.go | 4 + docs/changes/unreleased/0-app-wave3.md | 20 + internal/appwire/wire.go | 22 + internal/appwire/work.go | 392 ++++++ internal/manual/chat/how-tasks-run.md | 5 +- .../manual/chat/running-from-the-terminal.md | 9 + internal/manual/chat/tasks.md | 70 +- internal/remote/app_processes.go | 69 ++ internal/remote/app_processes_test.go | 55 + internal/remote/callclass.go | 7 +- internal/remote/driver.go | 5 +- internal/remote/server.go | 6 +- internal/remote/wire.go | 6 + internal/remote/wire_app_task.go | 127 ++ internal/remote/wire_app_task_test.go | 101 ++ internal/session/agent.go | 4 + internal/session/app_process_output.go | 121 ++ internal/session/app_processes.go | 194 +++ internal/session/app_processes_test.go | 211 ++++ internal/session/assignment.go | 49 +- internal/session/checkpoint.go | 41 + internal/session/jobnotice.go | 29 +- internal/session/jobretention.go | 10 +- internal/session/jobrow.go | 1 + internal/session/jobs.go | 31 +- internal/session/loop.go | 10 +- internal/session/prompts/quick.md | 13 +- internal/session/session.go | 14 +- internal/session/task.go | 103 +- internal/session/task_app.go | 640 ++++++++++ internal/session/task_app_test.go | 424 +++++++ internal/session/task_contract.go | 3 + internal/session/task_quick.go | 34 +- internal/session/task_quick_files.go | 284 +++++ internal/session/task_read.go | 39 + internal/session/task_run.go | 19 +- internal/session/task_store.go | 72 +- internal/tui3/appseams.go | 7 + 42 files changed, 4922 insertions(+), 199 deletions(-) create mode 100644 cmd/codeaf/appwork.go create mode 100644 cmd/codeaf/appwork_test.go create mode 100644 docs/changes/unreleased/0-app-wave3.md create mode 100644 internal/appwire/work.go create mode 100644 internal/remote/app_processes.go create mode 100644 internal/remote/app_processes_test.go create mode 100644 internal/remote/wire_app_task.go create mode 100644 internal/remote/wire_app_task_test.go create mode 100644 internal/session/app_process_output.go create mode 100644 internal/session/app_processes.go create mode 100644 internal/session/app_processes_test.go create mode 100644 internal/session/task_app.go create mode 100644 internal/session/task_app_test.go create mode 100644 internal/session/task_quick_files.go create mode 100644 internal/session/task_read.go diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index 0f59668d3c..4723097128 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -49,6 +49,7 @@ import ( "github.com/Agent-Field/codeaf/internal/enginehost" "github.com/Agent-Field/codeaf/internal/guard" "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/modelsource" "github.com/Agent-Field/codeaf/internal/openrouterauth" "github.com/Agent-Field/codeaf/internal/redact" "github.com/Agent-Field/codeaf/internal/remote" @@ -77,6 +78,7 @@ const appReleaseWait = 5 * time.Second func runApp(args []string) error { flags := commandFlags("app") homeFlag := flags.String("home", "", "codeaf's state folder for this app, instead of CODEAF_HOME or ~/.codeaf") + turnLong := flags.Duration("turn-long", appTurnLong, "how long a reply runs before the app is told it is running long") if err := parseCommandFlags(flags, args); err != nil { return err } @@ -106,6 +108,9 @@ func runApp(args []string) error { defer config.UseAppCredentials(config.NewAppCredentials())() server := appwire.NewServer(wire) backend := newAppBackend(server, appOpenInProcess) + if *turnLong > 0 { + backend.turnLong = *turnLong + } err := server.Serve(ctx, os.Stdin, appwire.Hello{Version: resolvedVersion(), PID: os.Getpid(), Home: home.Dir()}, backend) if backend.assembled.Load() { // The process half every conversation shared, closed the way the pipe @@ -141,6 +146,17 @@ type appConversation interface { // How hard the model is asked to think, per model. ReasoningFor(model string) string SetReasoningFor(model, level string) + // The task graph's app reads and verbs (internal/session's task_app.go), + // the move of a running reply to a task, and the job registry's notices. + AppTasks() ([]session.AppTaskView, error) + AppTask(id string) (session.AppTaskView, bool, error) + AppTaskAction(intent session.AppTaskIntent) error + AppQuickTasks() ([]session.AppQuickTaskView, error) + AppQuickTaskAction(intent session.AppTaskIntent) ([]string, []string, error) + MoveTurnToTask() error + JobNotices() ([]session.JobNotice, error) + RerunProcess(id int) (session.JobNotice, error) + Cancel(id string) (string, error) } // appOpenAsk is which conversation to open: a saved one by transcript, or a @@ -152,6 +168,9 @@ type appOpenAsk struct { Model string New bool Minted bool + // ClockS is how many seconds a proposed task waits before it starts on + // its own: the app's own setting, never the terminal's. + ClockS int } // appConn is an opened conversation and the one way to give it back. @@ -183,6 +202,7 @@ func appOpenInProcess(ask appOpenAsk) (*appConn, error) { } workspace := ask.Workspace transcript := ask.Transcript + clock := ask.ClockS hello := remote.Hello{ Workspace: workspace, Session: transcript, @@ -190,7 +210,9 @@ func appOpenInProcess(ask appOpenAsk) (*appConn, error) { Model: ask.Model, // An app agent keeps the name its first message gave it: the engine's // own naming does not rename it afterwards. - Launch: &remote.LaunchShape{Interactive: true, DeterministicNames: true}, + // And only the person moves a running reply to a task, and a proposed + // task's clock is the app's. + Launch: &remote.LaunchShape{Interactive: true, DeterministicNames: true, HandOverOnAsk: true, ProposalClockS: &clock}, } loop, err := remote.Loopback(hello, remote.Options{ Boot: func(h remote.Hello) (*remote.Engine, error) { @@ -242,6 +264,12 @@ type appBackend struct { state *appState // today replaces the usage ledger's figure for today in a test. today func(time.Time) float64 + // turnLong is how long a reply runs before turn.long ([appTurnLong]). + turnLong time.Duration + // follows are the processes whose output the app is following. + follows map[string]*appFollow + // asleep says the Mac is asleep (power.sleep until power.wake). + asleep bool } // appAgent is every agent this process has heard of: listed off the disk, @@ -268,6 +296,15 @@ type appAgent struct { // it has been read. users int counted bool + // marks are the turn number and client id of each message of the + // person's this process sent, by its place, so a listing can say them. + marks map[int]appTurnMark +} + +// appTurnMark is what this process knows of one of the person's messages. +type appTurnMark struct { + turn uint64 + clientID string } // appBoot is agents.create's ask, kept for the background open. @@ -306,6 +343,26 @@ type appLive struct { money map[string]appMoneyAsk budgetAsked string lastAnswer string + // turn is the running turn's number and steps how many tool calls it has + // made, which turn.long says. + turn uint64 + steps int + // phases are the running tasks' phase words, quicks and conflicts the + // quick tasks as last said and the files an undo found changed, and + // processes and logs the processes as last said and their spools. + phases map[string]string + // said is what each running task last said about its present (the gap + // it is closing, what it waits on, its own phase), calls the call each + // is making right now, and pilots the way to stop watching each one. + said map[string]string + calls map[string]string + pilots map[string]func() + quicks map[string]appwire.Quick + conflicts map[string][]string + processes map[string]appwire.Process + logs map[string]string + // slept are the processes a sleep stopped, said once on wake. + slept []appSlept } // appConsentCall is the call one approval is about. @@ -338,6 +395,8 @@ func newAppBackend(emit appwire.Emitter, open appOpener) *appBackend { b := &appBackend{ keys: keys, state: &appState{}, + turnLong: appTurnLong, + follows: map[string]*appFollow{}, emit: emit, open: open, now: time.Now, @@ -692,7 +751,8 @@ func (b *appBackend) ensureLocked(a *appAgent) (appwire.Agent, bool, error) { if b.isClosing() { return appwire.Agent{}, false, appwire.Errorf(appwire.CodeClosed, "codeaf is shutting down") } - ask := appOpenAsk{Workspace: workspace, Transcript: transcript} + ask := appOpenAsk{Workspace: workspace, Transcript: transcript, ClockS: appProposedClockS} + _ = b.state.read(func(f *appStateFile) { ask.ClockS = f.clock() }) if boot != nil { ask.Model, ask.Minted = boot.model, true } @@ -723,6 +783,9 @@ func (b *appBackend) hold(a *appAgent, conn *appConn, workspace string) appwire. tasks: map[string]appwire.Task{}, lanesDone: make(chan struct{}), asksDone: make(chan struct{}), questions: map[string]session.Question{}, answered: map[string]string{}, consents: map[uint64]appConsentCall{}, money: map[string]appMoneyAsk{}, + phases: map[string]string{}, quicks: map[string]appwire.Quick{}, conflicts: map[string][]string{}, + processes: map[string]appwire.Process{}, logs: map[string]string{}, + said: map[string]string{}, calls: map[string]string{}, pilots: map[string]func(){}, } title, model := conn.Conv.Title(), conn.Conv.Model() effort := conn.Conv.ReasoningFor(model) @@ -764,9 +827,7 @@ func (b *appBackend) hold(a *appAgent, conn *appConn, workspace string) appwire. guard.Go("app/tasks", func() { defer close(live.lanesDone) for event := range lane { - if (event.Kind == session.EventTaskUpdate || event.Kind == session.EventTaskProposal) && event.Task != nil { - b.taskNotice(id, live, event.Task) - } + b.workEvent(id, live, event) } }) questions, stopQuestions := conn.Conv.WatchQuestions() @@ -962,9 +1023,20 @@ func (b *appBackend) questionEvent(a *appAgent, id string, live *appLive, event return } at := b.now() + words := appReceipt(by, option, answer, at) + // A PROPOSAL ITS CLOCK STARTED closes with no answer from anybody; the + // task it became, already said, is what tells it from one taken back. + if q.Kind == session.QuestionTask && answer == nil { + var task appwire.Task + known := false + b.locked(func() { task, known = live.tasks[appTaskID(id, q.ID)] }) + if known && !task.Proposed && task.State != appwire.TaskFailed { + words = "Started on its own · " + at.Local().Format("15:04") + } + } live.box.Event(appwire.EventAskClosed, appwire.AskClosed{Agent: id, ID: askID, By: by, Option: option, At: at}) live.box.Event(appwire.EventEntryAdded, appwire.EntryAdded{Entry: appwire.Entry{ - ID: appReceiptEntryID(askID), Agent: id, Kind: appwire.EntryReceipt, At: at, Text: appReceipt(by, option, answer, at), + ID: appReceiptEntryID(askID), Agent: id, Kind: appwire.EntryReceipt, At: at, Text: words, }}) if moved { live.box.Event(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) @@ -1131,7 +1203,12 @@ func (b *appBackend) startLocked(a *appAgent, text, clientID string) (uint64, er live.running, live.stopping = true, false live.lastUse = b.now() turn = b.turns.Add(1) + live.turn, live.steps = turn, 0 n = a.users + if a.marks == nil { + a.marks = map[int]appTurnMark{} + } + a.marks[n] = appTurnMark{turn: turn, clientID: clientID} }) if refused != nil { return 0, refused @@ -1157,7 +1234,11 @@ func (b *appBackend) startLocked(a *appAgent, text, clientID string) (uint64, er live.box.Event(appwire.EventEntryAdded, appwire.EntryAdded{Entry: appwire.Entry{ ID: appUserEntryID(n), Agent: id, Turn: turn, Kind: appwire.EntryUser, At: b.now(), Text: text, ClientID: clientID, }}) - guard.Go("app/turn", func() { b.pump(a, id, live, turn, n, text, events) }) + long := b.watchLong(id, live, turn, b.now()) + guard.Go("app/turn", func() { + defer long.Stop() + b.pump(a, id, live, turn, n, text, events) + }) return turn, nil } @@ -1210,6 +1291,7 @@ func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, n thinking.WriteString(event.Text) live.box.Delta(id, turn, msg, appwire.DeltaThinking, event.Text) case session.EventToolBegin: + b.locked(func() { live.steps++ }) sayReply() title := redact.Secrets(tui3.ToolTitle(event.Tool, event.Args, event.Hint)) titles[event.CallID] = title @@ -1251,10 +1333,8 @@ func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, n if title := strings.TrimSpace(event.Text); title != "" { changed(func(v *appwire.Agent) { v.Title = title }) } - case session.EventTaskUpdate, session.EventTaskProposal: - if event.Task != nil { - b.taskNotice(id, live, event.Task) - } + case session.EventTaskUpdate, session.EventTaskProposal, session.EventTaskPhase, session.EventJobUpdate: + b.workEvent(id, live, event) } } sayReply() @@ -1402,45 +1482,23 @@ func appTaskState(state string) string { return "" } -// taskNotice turns one task update into task.changed, once per change: the -// same update can arrive on the turn's stream and on the task lane. -func (b *appBackend) taskNotice(id string, live *appLive, notice *session.TaskNotice) { - // A PROPOSAL IS A TASK THAT HAS NOT STARTED, which is queued: it carries - // no state of its own until its first update, under the same id. - raw := string(notice.State) - if strings.TrimSpace(raw) == "" { - raw = string(session.TaskQueued) - } - state := appTaskState(raw) - if state == "" { - return - } - task := appwire.Task{ID: strconv.FormatUint(notice.ID, 10), Agent: id, Title: notice.Title, State: state, Group: notice.Run, UpdatedAt: b.now()} - if notice.Parent != 0 && notice.Parent != notice.ID { - task.Parent = strconv.FormatUint(notice.Parent, 10) - } - same := false - b.locked(func() { - if before, ok := live.tasks[task.ID]; ok && before.State == task.State && (task.Title == "" || before.Title == task.Title) { - same = true - return - } - before := live.tasks[task.ID] - if task.Title == "" { - task.Title = before.Title +// workEvent is one task, phase or process event, from the turn's stream or +// the task lane: both carry the same news and each change is said once. +func (b *appBackend) workEvent(id string, live *appLive, event session.Event) { + switch event.Kind { + case session.EventTaskUpdate, session.EventTaskProposal: + if event.Task != nil { + b.taskNotice(id, live, event.Task, event.Kind == session.EventTaskProposal) } - if task.Parent == "" { - task.Parent = before.Parent + case session.EventTaskPhase: + if event.TaskPhase != nil { + b.taskPhase(id, live, event.TaskPhase) } - if task.Group == "" { - task.Group = before.Group + case session.EventJobUpdate: + if event.Job != nil { + b.processMoved(id, live, event.Job) } - live.tasks[task.ID] = task - }) - if same { - return } - live.box.Event(appwire.EventTaskChanged, appwire.TaskChanged{Task: task}) } // Stop interrupts the running turn. An agent with nothing held has nothing @@ -1495,6 +1553,7 @@ func (b *appBackend) release(a *appAgent) { // finish gives a conversation that is no longer held back, and shuts its // outbox once the turn and the lanes have said their last. func (b *appBackend) finish(live *appLive) { + b.landPilots(live) if live.stopTasks != nil { live.stopTasks() } @@ -1644,12 +1703,18 @@ func (b *appBackend) Entries(id, before string, limit int) ([]appwire.Entry, boo } var transcript string var at time.Time - b.locked(func() { transcript, at = a.transcript, a.view.UpdatedAt }) + marks := map[int]appTurnMark{} + b.locked(func() { + transcript, at = a.transcript, a.view.UpdatedAt + for n, mark := range a.marks { + marks[n] = mark + } + }) if transcript == "" { return []appwire.Entry{}, false, nil } record := session.ReadTranscript(transcript) - all := appEntriesOf(id, append(append([]session.DisplayEntry(nil), record.Earlier...), record.Entries...), at) + all := appEntriesOf(id, append(append([]session.DisplayEntry(nil), record.Earlier...), record.Entries...), at, marks) if words := strings.TrimSpace(record.Unreadable); words != "" { all = append(all, appwire.Entry{ID: "unreadable", Agent: id, Kind: appwire.EntryNotice, At: at, Text: words}) } @@ -1674,7 +1739,10 @@ func (b *appBackend) Entries(id, before string, limit int) ([]appwire.Entry, boo // appEntriesOf is a transcript as the app's entries, named by the rule the // live pump names them by (see the entry ids above). The journal keeps no // clock per message, so every entry carries the conversation's own last moment. -func appEntriesOf(agent string, items []session.DisplayEntry, at time.Time) []appwire.Entry { +// +// A message this process sent carries its turn number, and the person's entry +// its client id, from marks; one sent before this launch has neither. +func appEntriesOf(agent string, items []session.DisplayEntry, at time.Time, marks map[int]appTurnMark) []appwire.Entry { entries := make([]appwire.Entry, 0, len(items)) n, m, k := -1, 0, 0 for i, item := range items { @@ -1718,6 +1786,12 @@ func appEntriesOf(agent string, items []session.DisplayEntry, at time.Time) []ap default: continue } + if mark, ok := marks[n]; ok && n >= 0 { + entry.Turn = mark.turn + if entry.Kind == appwire.EntryUser { + entry.ClientID = mark.clientID + } + } entries = append(entries, entry) } return entries @@ -1824,23 +1898,40 @@ const appModelsWait = 3 * time.Second // ListModels answers exactly what the terminal's model picker offers for the // configured services — the same process catalog, the same per-service shelf, // the same caches, through the picker's own composer ([tui3.PickerModels]) — -// with the model a new conversation runs on marked default, and added when no -// list names it, so the answer is never empty while a turn can run. +// in the picker's groups and order, with the model a new conversation runs on +// marked default, and added when no list names it, so the answer is never +// empty while a turn can run. +// +// A MODEL'S PROVIDER IS THE SERVICE THE PICKER GROUPS IT UNDER: the name it was +// connected as ("local" for a custom endpoint written that way, "codex" for +// the ChatGPT sign-in, "openrouter" for the default), and provider_name the +// service's own name. Only services with a key are listed, as in the picker. func (b *appBackend) ListModels() ([]appwire.Model, error) { proc, err := b.process() if err != nil { return nil, appEngineError(err) } - read := func() []tui3.Model { - _, sources := proc.currentAccount() - return tui3.PickerModels(proc.ProfileDir, sources, proc.Shelf.pickerModels, proc.Shelf.modelsForService) - } - rows := read() - if len(rows) == 0 && proc.Models != nil { + // THE DEFAULT SERVICE'S CATALOG IS WAITED FOR EVEN WHEN ANOTHER SERVICE HAS + // ANSWERED: a list read before it lands is every other service's models + // and none of the default's, which reads as the default not being there. + if proc.Models != nil { ctx, cancel := context.WithTimeout(context.Background(), appModelsWait) proc.Models.Warmed(ctx) cancel() - rows = read() + } + _, sources := proc.currentAccount() + rows := tui3.PickerModels(proc.ProfileDir, sources, proc.Shelf.pickerModels, proc.Shelf.modelsForService) + names := map[string]string{} + for _, service := range sources.All() { + group := strings.ToLower(strings.TrimSpace(service.Source.Written)) + if group == "" { + group = strings.ToLower(strings.TrimSpace(service.Source.Name)) + } + name := strings.TrimSpace(service.Source.Name) + if modelsource.IsCustomID(service.Source.ID) || name == "" { + name = strings.TrimSpace(service.Source.Written) + } + names[group] = name } chosen := appDefaultModel() models := make([]appwire.Model, 0, len(rows)+1) @@ -1848,15 +1939,25 @@ func (b *appBackend) ListModels() ([]appwire.Model, error) { for _, row := range rows { provider := strings.TrimSpace(row.Group) if provider == "" { - provider, _, _ = strings.Cut(strings.TrimPrefix(row.ID, "~"), "/") + provider = modelsource.DefaultID + } + name := row.ID + if row.Direct { + name = strings.TrimPrefix(row.ID, provider+"/") + } + model := appwire.Model{ID: row.ID, Name: name, Provider: provider, ProviderName: names[provider], Default: row.ID == chosen} + if model.ProviderName == "" && provider == modelsource.DefaultID { + model.ProviderName = modelsource.DefaultSource("").Name } - model := appwire.Model{ID: row.ID, Name: row.ID, Provider: provider, Default: row.ID == chosen} seen = seen || model.Default models = append(models, model) } if !seen && chosen != "" { - provider, _, _ := strings.Cut(strings.TrimPrefix(chosen, "~"), "/") - models = append(models, appwire.Model{ID: chosen, Name: chosen, Provider: provider, Default: true}) + provider := modelsource.DefaultID + if service, bare := sources.For(chosen); bare != chosen && service.Source.Written != "" { + provider = strings.ToLower(service.Source.Written) + } + models = append(models, appwire.Model{ID: chosen, Name: chosen, Provider: provider, ProviderName: names[provider], Default: true}) } return models, nil } @@ -1915,8 +2016,11 @@ func (b *appBackend) spendMoved(after func(appwire.Spend)) { }) } -// ListTasks reads the agent's tasks off its project's task index, and lays -// over them what the held conversation has said since. +// ListTasks reads the agent's tasks off the disk whether or not its +// conversation is held — its own checkpoint first, which holds every task it +// ever had, finished ones included, then its project's task index for any the +// checkpoint does not name — and lays over them what a held conversation has +// said since. func (b *appBackend) ListTasks(id string) ([]appwire.Task, error) { a, err := b.find(id) if err != nil { @@ -1936,6 +2040,38 @@ func (b *appBackend) ListTasks(id string) ([]appwire.Task, error) { }) var tasks []appwire.Task index := map[string]int{} + add := func(task appwire.Task) { + if _, dup := index[task.ID]; dup { + return + } + index[task.ID] = len(tasks) + tasks = append(tasks, task) + } + for _, notice := range session.ReadSessionTasks(transcript) { + if notice.Kind == session.TaskKindQuick { + continue + } + // A TASK THE CONVERSATION LEFT RUNNING WHEN IT CLOSED is interrupted: + // nothing is driving it. The protocol has no word for that, and it is + // not done, so it is listed as failed with the reason as its result. + if notice.State == session.TaskInterrupted { + notice.State = session.TaskFailed + if strings.TrimSpace(notice.Result) == "" { + notice.Result = "Interrupted" + } + } + at := notice.EndedAt + if at.IsZero() { + at = notice.StartedAt + } + if at.IsZero() { + at = fallback + } + notice := notice + if task, ok := appTaskOf(id, ¬ice, false, "", at); ok { + add(task) + } + } if path := session.TaskIndexPath(transcript); path != "" { for _, row := range session.ReadTaskIndex(path) { if row.SessionID != id { @@ -1956,12 +2092,11 @@ func (b *appBackend) ListTasks(id string) ([]appwire.Task, error) { if title == "" { title = row.Label } - index[row.ID] = len(tasks) - parent := row.Parent - if parent == row.ID { - parent = "" + parent := "" + if row.Parent != "" && row.Parent != row.ID { + parent = appIndexTaskID(id, row.Parent) } - tasks = append(tasks, appwire.Task{ID: row.ID, Agent: id, Parent: parent, Title: title, State: state, UpdatedAt: at}) + add(appwire.Task{ID: appIndexTaskID(id, row.ID), Agent: id, Parent: parent, Title: title, State: state, UpdatedAt: at, YourCall: state == appwire.TaskUnverified}) } } for taskID, task := range heard { @@ -1980,6 +2115,15 @@ func (b *appBackend) ListTasks(id string) ([]appwire.Task, error) { return tasks, nil } +// appIndexTaskID is a task index row's id on the wire: the agent's own number +// named with the agent, as every task id is (appwork.go). +func appIndexTaskID(agent, raw string) string { + if n, err := strconv.ParseUint(strings.TrimSpace(raw), 10, 64); err == nil { + return appTaskID(agent, n) + } + return raw +} + // ListCrews reads the teams file directly and starts watching it. func (b *appBackend) ListCrews() ([]appwire.Crew, error) { crews, err := appReadCrews() @@ -2043,6 +2187,10 @@ func (b *appBackend) Close() error { b.signIn.Cancel() b.signIn = nil } + for id, f := range b.follows { + close(f.stop) + delete(b.follows, id) + } for _, a := range b.agents { agents = append(agents, a) } diff --git a/cmd/codeaf/app_test.go b/cmd/codeaf/app_test.go index 8ff29eb451..4a3b9f1cc3 100644 --- a/cmd/codeaf/app_test.go +++ b/cmd/codeaf/app_test.go @@ -148,10 +148,32 @@ type fakeConversation struct { remembered []session.ConsentScope texts []string cost float64 + // The task, quick task and process doors' scripted answers, and what was + // asked of them. + appTasks []session.AppTaskView + quickTasks []session.AppQuickTaskView + intents []session.AppTaskIntent + undoChanged []string + jobs []session.JobNotice + cancels []string + moves int + // room is the lane one task's calls are watched on. + room chan session.Event // onAnswer, when set, is what the engine does with an answer. onAnswer func(session.Answer) } +func (f *fakeConversation) WatchTaskRoom(uint64) (<-chan session.Event, func(), error) { + f.mu.Lock() + defer f.mu.Unlock() + if f.room == nil { + f.room = make(chan session.Event, 8) + } + lane := f.room + var once sync.Once + return lane, func() { once.Do(func() { close(lane) }) }, nil +} + func (f *fakeConversation) WatchQuestions() (<-chan session.Event, func()) { f.mu.Lock() defer f.mu.Unlock() @@ -230,6 +252,76 @@ func (f *fakeConversation) ResolveConsentRemember(id uint64, allow bool, scope s f.remembered = append(f.remembered, scope) f.mu.Unlock() } +func (f *fakeConversation) AppTasks() ([]session.AppTaskView, error) { + f.mu.Lock() + defer f.mu.Unlock() + return append([]session.AppTaskView(nil), f.appTasks...), nil +} +func (f *fakeConversation) AppTask(id string) (session.AppTaskView, bool, error) { + f.mu.Lock() + defer f.mu.Unlock() + for _, view := range f.appTasks { + if view.ID == id { + return view, true, nil + } + } + return session.AppTaskView{}, false, nil +} +func (f *fakeConversation) AppTaskAction(intent session.AppTaskIntent) error { + f.mu.Lock() + defer f.mu.Unlock() + f.intents = append(f.intents, intent) + return nil +} +func (f *fakeConversation) AppQuickTasks() ([]session.AppQuickTaskView, error) { + f.mu.Lock() + defer f.mu.Unlock() + return append([]session.AppQuickTaskView(nil), f.quickTasks...), nil +} +func (f *fakeConversation) AppQuickTaskAction(intent session.AppTaskIntent) ([]string, []string, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.intents = append(f.intents, intent) + if intent.Action == "undo" && len(f.undoChanged) > 0 { + return nil, append([]string(nil), f.undoChanged...), nil + } + if intent.Action == "undo" { + for i := range f.quickTasks { + if f.quickTasks[i].ID == intent.ID { + f.quickTasks[i].State = "undone" + } + } + } + return nil, nil, nil +} +func (f *fakeConversation) MoveTurnToTask() error { + f.mu.Lock() + defer f.mu.Unlock() + f.moves++ + return nil +} +func (f *fakeConversation) JobNotices() ([]session.JobNotice, error) { + f.mu.Lock() + defer f.mu.Unlock() + return append([]session.JobNotice(nil), f.jobs...), nil +} +func (f *fakeConversation) RerunProcess(id int) (session.JobNotice, error) { + f.mu.Lock() + defer f.mu.Unlock() + for _, job := range f.jobs { + if job.ID == id { + job.ID, job.State = id+100, session.JobRunning + return job, nil + } + } + return session.JobNotice{}, errors.New("no such process") +} +func (f *fakeConversation) Cancel(id string) (string, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.cancels = append(f.cancels, id) + return "stopped", nil +} func (f *fakeConversation) Usage() session.Usage { f.mu.Lock() defer f.mu.Unlock() @@ -435,7 +527,7 @@ func TestAppSendMapsSessionEventsOntoTurnEvents(t *testing.T) { if failed.Phase != appwire.PhaseEnd || failed.OK == nil || *failed.OK || failed.Summary != "exit status 1" { t.Fatalf("failed = %+v", failed) } - if task := data[10].(appwire.TaskChanged).Task; task.ID != "3" || task.Agent != agent.ID || task.State != appwire.TaskRunning || task.Title != "fix the parser" { + if task := data[10].(appwire.TaskChanged).Task; task.ID != agent.ID+":3" || task.Agent != agent.ID || task.State != appwire.TaskRunning || task.Title != "fix the parser" { t.Fatalf("task = %+v", task) } if d := data[12].(appwire.TurnDone); !d.OK || d.Turn != turn || d.Error != "" { @@ -1196,10 +1288,10 @@ func TestAppModelsListIsThePickersList(t *testing.T) { defaults++ } } - if m := byID["deepseek/deepseek-v4.1-flash"]; !m.Default || m.Provider != "deepseek" || defaults != 1 { + if m := byID["deepseek/deepseek-v4.1-flash"]; !m.Default || m.Provider != "openrouter" || m.ProviderName != "OpenRouter" || defaults != 1 { t.Fatalf("models = %+v", models) } - if m := byID["qwen/qwen3-coder"]; m.Provider != "qwen" || m.Default { + if m := byID["qwen/qwen3-coder"]; m.Provider != "openrouter" || m.Default { t.Fatalf("models = %+v", models) } } @@ -1571,10 +1663,10 @@ func TestAppSaysNestedTasksLive(t *testing.T) { } } rec.mu.Unlock() - if len(changes) != 4 || changes[0].State != appwire.TaskQueued || changes[1].State != appwire.TaskRunning || changes[1].ID != "1" { + if len(changes) != 4 || changes[0].State != appwire.TaskQueued || changes[1].State != appwire.TaskRunning || changes[1].ID != agent.ID+":1" || !changes[0].Proposed { t.Fatalf("task.changed = %+v", changes) } - if changes[2].Parent != "1" || changes[3].Parent != "2" || changes[3].Group != "run-9" { + if changes[2].Parent != agent.ID+":1" || changes[3].Parent != agent.ID+":2" || changes[3].Group != "run-9" { t.Fatalf("nested = %+v", changes) } tasks, err := backend.ListTasks(agent.ID) diff --git a/cmd/codeaf/appwork.go b/cmd/codeaf/appwork.go new file mode 100644 index 0000000000..e2814dbc2b --- /dev/null +++ b/cmd/codeaf/appwork.go @@ -0,0 +1,1064 @@ +package main + +// ── codeaf app: TASKS, QUICK TASKS, PROCESSES, RUNNING LONG, SLEEP ─────────── +// +// Everything here is read from and done through the engine's own doors — the +// task graph's app reads and verbs (internal/session's task_app.go), the job +// registry's notices, the checkpoint's one move — so the app and the terminal +// never disagree about what a task is doing or what a process said. +// +// IDS ARE THE APP'S, AND THEY NAME THE AGENT. A task's number is its own +// conversation's, and two agents both have a task 3, so every id on this wire +// is ":" for a task or a quick task and ":p" for a process. +// The methods that take only an id find the agent from it. + +import ( + "bufio" + "errors" + "io" + "os" + "reflect" + "strconv" + "strings" + "sync/atomic" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/guard" + "github.com/Agent-Field/codeaf/internal/redact" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/tui3" +) + +// appTurnLong is how long a reply runs before the app is told it is running +// long and may be moved to a task. `codeaf app --turn-long` changes it. +const appTurnLong = 180 * time.Second + +// appMoveWait bounds how long agents.move_to_task waits for the reply's task: +// the move happens at the reply's next step and writes a brief first. +const appMoveWait = 2 * time.Minute + +// appFollowEvery is how often a followed process's output is looked at. +const appFollowEvery = 500 * time.Millisecond + +// appOutputLines bounds one processes.output answer. +const appOutputLines = 2000 + +// appTailBytes is how much of a process's output is read for its last line. +const appTailBytes = 4096 + +// appPromoteWait bounds how long quick.promote waits for its task to appear. +const appPromoteWait = 5 * time.Second + +// appTaskID is a task's or a quick task's id on the wire. +func appTaskID(agent string, n uint64) string { return agent + ":" + strconv.FormatUint(n, 10) } + +// appProcessID is a process's id on the wire. +func appProcessID(agent string, job int) string { return agent + ":p" + strconv.Itoa(job) } + +// appSplitID is an id's agent and the agent's own number for the thing. +func appSplitID(id string) (agent, local string, ok bool) { + i := strings.LastIndex(id, ":") + if i <= 0 || i == len(id)-1 { + return "", "", false + } + return id[:i], id[i+1:], true +} + +// appProcessJob is a process id's agent and job number. +func appProcessJob(id string) (string, int, bool) { + agent, local, ok := appSplitID(id) + if !ok || !strings.HasPrefix(local, "p") { + return "", 0, false + } + job, err := strconv.Atoi(local[1:]) + return agent, job, err == nil && job > 0 +} + +// held answers the held conversation for an agent id, opening it if none is. +func (b *appBackend) heldLive(agent string) (*appAgent, *appLive, error) { + a, err := b.find(agent) + if err != nil { + return nil, nil, err + } + view, opened, err := b.ensure(a) + if err != nil { + return nil, nil, err + } + if opened { + b.emit.Emit(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) + } + live := b.liveOf(a) + if live == nil { + return nil, nil, appwire.Errorf(appwire.CodeEngine, "the conversation was let go; try again") + } + b.locked(func() { live.lastUse = b.now() }) + return a, live, nil +} + +// ── tasks ─────────────────────────────────────────────────────────────────── + +// appTaskLine is a task's live status line as one notice says it, by the +// terminal's own order: the gap it is closing, what it is waiting on, its phase +// in its own words, and the check's phase the engine said last. A running +// task's line on the wire also has the call it is making ([appBackend.lineOf]). +func appTaskLine(notice *session.TaskNotice, phase string) string { + return appFirstLine(appTaskSaid(notice), phase) +} + +// appTaskSaid is what one notice says about its task's present, before any +// phase: the gap, the wait, or the task's own phase word. +func appTaskSaid(notice *session.TaskNotice) string { + return appFirstLine(notice.Mending, notice.Waiting, notice.Doing) +} + +// appFirstLine is the first of lines that says something. +func appFirstLine(lines ...string) string { + for _, line := range lines { + if line = strings.TrimSpace(line); line != "" && line != string(session.PresenceIdle) { + return redact.Secrets(line) + } + } + return "" +} + +// lineOf is one running task's line on the wire, the order the terminal's +// task page uses: what it said about its present, then the check's phase, +// then the call it is making. Callers hold b.mu. +func lineOf(live *appLive, key string) string { + return appFirstLine(live.said[key], live.phases[key], live.calls[key]) +} + +// appFiles is a list of changed paths as file changes whose line counts are +// not known. +func appFiles(paths []string) []appwire.FileChange { + var files []appwire.FileChange + for _, path := range paths { + if path = strings.TrimSpace(path); path != "" { + files = append(files, appwire.FileChange{Path: path}) + } + } + return files +} + +// appTaskOf is one task notice on the wire. proposal says it arrived as a +// proposal: a task that has not started and is waiting for the person. +func appTaskOf(agent string, notice *session.TaskNotice, proposal bool, phase string, now time.Time) (appwire.Task, bool) { + raw := string(notice.State) + if strings.TrimSpace(raw) == "" { + raw = string(session.TaskQueued) + } + state := appTaskState(raw) + if state == "" { + return appwire.Task{}, false + } + task := appwire.Task{ + ID: appTaskID(agent, notice.ID), Agent: agent, Title: notice.Title, State: state, Group: notice.Run, UpdatedAt: now, + Irreversible: notice.Irreversible, YourCall: state == appwire.TaskUnverified, + Result: redact.Secrets(strings.TrimSpace(notice.Result)), Files: appFiles(notice.Changed), SpentUSD: appFigure(notice.CostUSD), + } + if notice.Parent != 0 && notice.Parent != notice.ID { + task.Parent = appTaskID(agent, notice.Parent) + } + switch { + case notice.Withdrawn != "": + task.State, task.Result = appwire.TaskFailed, notice.Withdrawn + case notice.Decided != nil && !notice.Decided.Approved: + task.State, task.Result = appwire.TaskFailed, "Declined" + case proposal && notice.Decided == nil: + task.Proposed = true + if !notice.Deadline.IsZero() && !notice.Irreversible { + until := notice.Deadline + task.ClockUntil = &until + } + } + if state == appwire.TaskRunning { + task.Line = appTaskLine(notice, phase) + } + return task, true +} + +// appTaskOfView is one task as the engine's app read describes it. +func appTaskOfView(agent string, view session.AppTaskView) appwire.Task { + n, _ := strconv.ParseUint(view.ID, 10, 64) + id := view.ID + if n != 0 { + id = appTaskID(agent, n) + } + task := appwire.Task{ID: id, Agent: agent, Title: view.Title, Result: redact.Secrets(view.Result), Files: appFiles(view.Files), BudgetUSD: appFigure(view.Cap), SpentUSD: appFigure(view.Spent)} + if p, err := strconv.ParseUint(view.Parent, 10, 64); err == nil && p != 0 { + task.Parent = appTaskID(agent, p) + } + switch view.State { + case "waiting", "paused": + task.State = appwire.TaskQueued + case "proposed", "held": + task.State, task.Proposed = appwire.TaskQueued, true + if view.State == "proposed" && !view.Deadline.IsZero() { + until := view.Deadline + task.ClockUntil = &until + } + case "your_call": + task.State, task.YourCall = appwire.TaskUnverified, true + case "done": + task.State = appwire.TaskDone + case "running": + task.State = appwire.TaskRunning + default: + task.State = appwire.TaskFailed + } + return task +} + +// taskNotice turns one task update into task.changed, once per change: the +// same update can arrive on the turn's stream and on the task lane. A quick +// task's update is a quick.changed instead. +func (b *appBackend) taskNotice(id string, live *appLive, notice *session.TaskNotice, proposal bool) { + if notice.Kind == session.TaskKindQuick { + b.quickMoved(id, live, notice.ID) + return + } + key := appTaskID(id, notice.ID) + var phase string + b.locked(func() { phase = live.phases[key] }) + task, ok := appTaskOf(id, notice, proposal, phase, b.now()) + if !ok { + return + } + same := false + b.locked(func() { + before, known := live.tasks[key] + // What an update leaves out it has not changed: its title, its place + // in the tree and its run are kept from what was said before. + if task.Title == "" { + task.Title = before.Title + } + if task.Parent == "" { + task.Parent = before.Parent + } + if task.Group == "" { + task.Group = before.Group + } + if task.Result == "" && task.State == before.State { + task.Result = before.Result + } + if task.Files == nil && task.State == before.State { + task.Files = before.Files + } + if task.State == appwire.TaskRunning { + live.said[key] = appTaskSaid(notice) + task.Line = lineOf(live, key) + } + if known { + at := before.UpdatedAt + before.UpdatedAt = task.UpdatedAt + same = reflect.DeepEqual(before, task) + if same { + task.UpdatedAt = at + } + } + if task.State == appwire.TaskDone || task.State == appwire.TaskFailed { + delete(live.phases, key) + delete(live.said, key) + delete(live.calls, key) + } + live.tasks[key] = task + }) + if task.State == appwire.TaskRunning { + b.flyPilot(live, key, notice.ID) + } else { + b.landPilot(live, key) + } + if same { + return + } + live.box.Event(appwire.EventTaskChanged, appwire.TaskChanged{Task: task}) +} + +// taskPhase folds one phase move into its task's live line. +func (b *appBackend) taskPhase(id string, live *appLive, notice *session.TaskPhaseNotice) { + key := appTaskID(id, notice.ID) + words := tui3.TaskPhaseWords(notice.Phase, notice.Round, notice.Rounds) + var task appwire.Task + moved := false + b.locked(func() { + // A phase move is newer than the wait the task last said it was in. + live.phases[key] = words + delete(live.said, key) + task, moved = b.relineLocked(live, key) + }) + if moved { + live.box.Event(appwire.EventTaskChanged, appwire.TaskChanged{Task: task}) + } +} + +// relineLocked redraws one running task's line and answers the task when the +// line moved. Callers hold b.mu. +func (b *appBackend) relineLocked(live *appLive, key string) (appwire.Task, bool) { + before, known := live.tasks[key] + line := lineOf(live, key) + if !known || before.State != appwire.TaskRunning || before.Line == line { + return appwire.Task{}, false + } + before.Line, before.UpdatedAt = line, b.now() + live.tasks[key] = before + return before, true +} + +// appTaskRoom is a conversation that can be watched one task at a time, the +// door the terminal's task rail reads a running task's current call through. +// A conversation joined in a window's host has none, and its tasks' lines are +// what the engine says about them. +type appTaskRoom interface { + WatchTaskRoom(id uint64) (<-chan session.Event, func(), error) +} + +// flyPilot watches one running task's calls, once, so its line can say the +// call it is making: `write essay3.md`, `bash go test ./...`. +func (b *appBackend) flyPilot(live *appLive, key string, n uint64) { + room, ok := live.conn.Conv.(appTaskRoom) + if !ok { + return + } + flying := false + b.locked(func() { + _, flying = live.pilots[key] + if !flying { + live.pilots[key] = func() {} + } + }) + if flying { + return + } + lane, stop, err := room.WatchTaskRoom(n) + if err != nil { + b.locked(func() { delete(live.pilots, key) }) + return + } + if stop == nil { + stop = func() {} + } + landed := false + b.locked(func() { + // The task may have landed while the lane was opening. + _, kept := live.pilots[key] + if landed = !kept; !landed { + live.pilots[key] = stop + } + }) + if landed { + stop() + return + } + guard.Go("app/pilot", func() { b.pilot(live, key, lane) }) +} + +// pilot folds one task's call events into its line until the lane closes. +func (b *appBackend) pilot(live *appLive, key string, lane <-chan session.Event) { + for event := range lane { + var call string + switch event.Kind { + case session.EventToolAnnounced, session.EventToolBegin: + call = redact.Secrets(tui3.ToolTitle(event.Tool, event.Args, event.Hint)) + case session.EventToolEnd, session.EventToolFailed: + default: + continue + } + var task appwire.Task + moved := false + b.locked(func() { + if _, watched := live.pilots[key]; !watched { + return + } + live.calls[key] = call + task, moved = b.relineLocked(live, key) + }) + if moved { + live.box.Event(appwire.EventTaskChanged, appwire.TaskChanged{Task: task}) + } + } +} + +// landPilot stops watching one task, which has landed. +func (b *appBackend) landPilot(live *appLive, key string) { + var stop func() + b.locked(func() { + stop = live.pilots[key] + delete(live.pilots, key) + delete(live.calls, key) + }) + if stop != nil { + stop() + } +} + +// landPilots stops watching every task of a conversation being given back. +func (b *appBackend) landPilots(live *appLive) { + var stops []func() + b.locked(func() { + for key, stop := range live.pilots { + stops = append(stops, stop) + delete(live.pilots, key) + } + }) + for _, stop := range stops { + stop() + } +} + +// GetTask answers one task's page. +func (b *appBackend) GetTask(id string) (appwire.TaskPage, error) { + agent, local, ok := appSplitID(id) + if !ok { + return appwire.TaskPage{}, appwire.Errorf(appwire.CodeBadParams, "%s is not a task id", id) + } + _, live, err := b.heldLive(agent) + if err != nil { + return appwire.TaskPage{}, err + } + view, found, err := live.conn.Conv.AppTask(local) + if err != nil { + return appwire.TaskPage{}, appEngineError(err) + } + if !found { + return appwire.TaskPage{}, appwire.Errorf(appwire.CodeNotFound, "there is no task %s", id) + } + task := appTaskOfView(agent, view) + b.locked(func() { + // What the lane said is newer than a read, for the live line above all. + if said, known := live.tasks[task.ID]; known { + task.Line, task.Group, task.Irreversible = said.Line, said.Group, said.Irreversible + if task.Proposed { + task.ClockUntil = said.ClockUntil + } + } + }) + page := appwire.TaskPage{Task: task, Result: task.Result, Files: task.Files, Brief: view.Brief} + at := b.now() + for i, line := range view.WorkLog { + if line = strings.TrimSpace(line); line != "" { + page.Log = append(page.Log, appwire.Entry{ID: task.ID + ".l" + strconv.Itoa(i), Agent: agent, Kind: appwire.EntryNotice, At: at, Text: redact.Secrets(line), Quiet: true}) + } + } + for i, note := range view.Notes { + page.Notes = append(page.Notes, appwire.TaskNote{ID: task.ID + ".n" + strconv.Itoa(i), Text: note.Text, At: at, ReadAtStep: note.ReadAtStep}) + } + return page, nil +} + +// ActOnTask is every verb on a task, through the engine's one task door. +func (b *appBackend) ActOnTask(id, action, verdict, text string) error { + agent, local, ok := appSplitID(id) + if !ok { + return appwire.Errorf(appwire.CodeBadParams, "%s is not a task id", id) + } + _, live, err := b.heldLive(agent) + if err != nil { + return err + } + intent := session.AppTaskIntent{ID: local, Action: action, Text: strings.TrimSpace(text), Verb: verdict} + switch action { + case "decide": + // TELL IT… WITH WORDS AND NO VERDICT IS A NOTE; not_right carries them. + if verdict == appwire.VerdictNotRight { + intent.Verb = "not_right" + } + case "hold", "typing": + // A held card is a held clock: the same engine hold, whether the person + // pressed hold or started typing in the card. + intent.Action = "hold" + } + if err := live.conn.Conv.AppTaskAction(intent); err != nil { + return appTaskError(err) + } + return nil +} + +// appTaskError sorts an engine refusal about a task into the app's codes. +func appTaskError(err error) error { + words := err.Error() + switch { + case strings.Contains(words, "no such task"), strings.Contains(words, "no such quick task"): + return appwire.Errorf(appwire.CodeNotFound, "%s", plainWords(words)) + case strings.Contains(words, "stale"), strings.Contains(words, "not waiting"), strings.Contains(words, "finished"), strings.Contains(words, "unknown"), strings.Contains(words, "must be"): + return appwire.Errorf(appwire.CodeBadParams, "%s", plainWords(words)) + } + return appEngineError(err) +} + +// ── quick tasks ───────────────────────────────────────────────────────────── + +// appQuickOf is one quick task on the wire. +func appQuickOf(agent string, view session.AppQuickTaskView, conflict []string) appwire.Quick { + n, _ := strconv.ParseUint(view.ID, 10, 64) + quick := appwire.Quick{ID: appTaskID(agent, n), Agent: agent, Title: view.Title, Files: []appwire.FileChange{}} + for _, file := range view.Files { + quick.Files = append(quick.Files, appwire.FileChange{Path: file.Path, Add: file.Added, Del: file.Removed}) + } + switch view.State { + case "running": + quick.State = appwire.QuickRunning + case "undone": + quick.State = appwire.QuickUndone + case "grew": + quick.State = appwire.QuickGrew + quick.Grew = &appwire.QuickGrowth{Files: len(view.Files), Reason: view.Grew} + default: + quick.State = appwire.QuickDone + } + if len(conflict) > 0 && quick.State == appwire.QuickDone { + quick.State = appwire.QuickConflict + quick.Conflict = &appwire.QuickChanged{Changed: append([]string(nil), conflict...)} + } + return quick +} + +// quickMoved reads one quick task again and says quick.changed if it moved. +// The read is a call to the engine, so it is made off the lane that heard it. +func (b *appBackend) quickMoved(agent string, live *appLive, n uint64) { + guard.Go("app/quick", func() { b.sayQuick(agent, live, appTaskID(agent, n)) }) +} + +// sayQuick reads one quick task and says quick.changed if it moved. +func (b *appBackend) sayQuick(agent string, live *appLive, id string) (appwire.Quick, bool) { + _, local, _ := appSplitID(id) + rows, err := live.conn.Conv.AppQuickTasks() + if err != nil { + return appwire.Quick{}, false + } + for _, row := range rows { + if row.ID != local { + continue + } + var conflict []string + b.locked(func() { conflict = live.conflicts[id] }) + quick := appQuickOf(agent, row, conflict) + same := false + b.locked(func() { + before, known := live.quicks[id] + same = known && reflect.DeepEqual(before, quick) + live.quicks[id] = quick + }) + if !same { + live.box.Event(appwire.EventQuickChanged, appwire.QuickChangedEvent{Quick: quick}) + } + return quick, true + } + return appwire.Quick{}, false +} + +// UndoQuick puts back every file a quick task wrote, or none: a file changed +// since is a conflict, named, and nothing is touched. +func (b *appBackend) UndoQuick(id string) error { + agent, local, ok := appSplitID(id) + if !ok { + return appwire.Errorf(appwire.CodeBadParams, "%s is not a quick task id", id) + } + _, live, err := b.heldLive(agent) + if err != nil { + return err + } + _, changed, err := live.conn.Conv.AppQuickTaskAction(session.AppTaskIntent{ID: local, Action: "undo"}) + if err != nil { + return appTaskError(err) + } + b.locked(func() { + if len(changed) > 0 { + live.conflicts[id] = changed + } else { + delete(live.conflicts, id) + } + }) + b.sayQuick(agent, live, id) + if len(changed) > 0 { + return &appwire.Error{Code: appwire.CodeConflict, Message: "changed since the quick task wrote them: " + strings.Join(changed, ", "), Changed: changed} + } + return nil +} + +// PromoteQuick makes a quick task a full task and answers that task. +func (b *appBackend) PromoteQuick(id string) (appwire.Task, error) { + agent, local, ok := appSplitID(id) + if !ok { + return appwire.Task{}, appwire.Errorf(appwire.CodeBadParams, "%s is not a quick task id", id) + } + _, live, err := b.heldLive(agent) + if err != nil { + return appwire.Task{}, err + } + before := map[string]bool{} + if views, err := live.conn.Conv.AppTasks(); err == nil { + for _, view := range views { + before[view.ID] = true + } + } + if _, _, err := live.conn.Conv.AppQuickTaskAction(session.AppTaskIntent{ID: local, Action: "promote"}); err != nil { + return appwire.Task{}, appTaskError(err) + } + b.sayQuick(agent, live, id) + deadline := time.Now().Add(appPromoteWait) + for { + if views, err := live.conn.Conv.AppTasks(); err == nil { + for _, view := range views { + if !before[view.ID] && view.ID != local { + return appTaskOfView(agent, view), nil + } + } + } + if time.Now().After(deadline) { + return appwire.Task{}, appwire.Errorf(appwire.CodeEngine, "the quick task was promoted, but its task has not appeared yet") + } + time.Sleep(50 * time.Millisecond) + } +} + +// ── processes ─────────────────────────────────────────────────────────────── + +// appFollow is one process the app is following, and how to stop following. +type appFollow struct { + stop chan struct{} + done chan struct{} +} + +// appProcessOf is one job notice on the wire. +func appProcessOf(agent string, job session.JobNotice) appwire.Process { + process := appwire.Process{ + ID: appProcessID(agent, job.ID), Agent: agent, Title: redact.Secrets(job.Label()), Command: redact.Secrets(job.Command), + StartedAt: job.Started, LastLine: redact.Secrets(appLastLine(job.LogPath)), + } + switch job.State { + case session.JobRunning: + process.State = appwire.ProcessRunning + case session.JobDone: + process.State = appwire.ProcessEnded + case session.JobFailed: + process.State = appwire.ProcessFailed + default: + process.State = appwire.ProcessStopped + } + if process.State != appwire.ProcessRunning { + if !job.Started.IsZero() { + ended := job.Started.Add(job.Elapsed) + process.EndedAt = &ended + } + if process.State != appwire.ProcessStopped { + code := job.ExitCode + process.ExitCode = &code + } + } + return process +} + +// appLastLine is the last line a process wrote, read off its spool's tail. +func appLastLine(path string) string { + if strings.TrimSpace(path) == "" { + return "" + } + file, err := os.Open(path) + if err != nil { + return "" + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return "" + } + start := max(info.Size()-appTailBytes, 0) + buf := make([]byte, info.Size()-start) + if _, err := file.ReadAt(buf, start); err != nil && !errors.Is(err, io.EOF) { + return "" + } + lines := strings.Split(strings.TrimRight(string(buf), "\n"), "\n") + for i := len(lines) - 1; i >= 0; i-- { + if line := strings.TrimSpace(lines[i]); line != "" { + return appDetail(line) + } + } + return "" +} + +// processMoved says process.changed for one job update, once per change. +func (b *appBackend) processMoved(agent string, live *appLive, job *session.JobNotice) { + if job.Kind == session.JobKindTask { + return + } + process := appProcessOf(agent, *job) + same := false + b.locked(func() { + before, known := live.processes[process.ID] + live.processes[process.ID] = process + same = known && reflect.DeepEqual(before, process) + live.logs[process.ID] = job.LogPath + }) + if !same { + live.box.Event(appwire.EventProcessChanged, appwire.ProcessChanged{Process: process}) + } +} + +// processRows is an agent's processes: the live registry's while its +// conversation is held, its record on disk otherwise. +func (b *appBackend) processRows(agent string) ([]session.JobNotice, *appLive, error) { + a, err := b.find(agent) + if err != nil { + return nil, nil, err + } + var transcript string + live := b.liveOf(a) + b.locked(func() { transcript = a.transcript }) + if live != nil { + rows, err := live.conn.Conv.JobNotices() + if err != nil { + return nil, nil, appEngineError(err) + } + return rows, live, nil + } + rows, err := session.ReadProcessNotices(transcript) + if err != nil { + return nil, nil, appEngineError(err) + } + return rows, nil, nil +} + +// ListProcesses answers an agent's processes, running and ended. +func (b *appBackend) ListProcesses(agent string) ([]appwire.Process, error) { + rows, live, err := b.processRows(agent) + if err != nil { + return nil, err + } + var out []appwire.Process + for _, row := range rows { + if row.Kind == session.JobKindTask { + continue + } + process := appProcessOf(agent, row) + if live != nil { + b.locked(func() { live.logs[process.ID] = row.LogPath }) + } + out = append(out, process) + } + return out, nil +} + +// processLog is where one process's output is spooled. +func (b *appBackend) processLog(id string) (string, error) { + agent, job, ok := appProcessJob(id) + if !ok { + return "", appwire.Errorf(appwire.CodeBadParams, "%s is not a process id", id) + } + rows, _, err := b.processRows(agent) + if err != nil { + return "", err + } + for _, row := range rows { + if row.ID == job { + return row.LogPath, nil + } + } + return "", appwire.Errorf(appwire.CodeNotFound, "there is no process %s", id) +} + +// appReadLines reads a spool's complete lines from line from, at most limit, +// and answers the next line to read. +func appReadLines(path string, from, limit int) ([]string, int, error) { + file, err := os.Open(path) + if errors.Is(err, os.ErrNotExist) { + return []string{}, from, nil + } + if err != nil { + return nil, from, err + } + defer file.Close() + reader := bufio.NewReaderSize(file, 64<<10) + lines, n := []string{}, 0 + for len(lines) < limit { + line, err := reader.ReadString('\n') + if err != nil { + // A line still being written is not a line yet. + break + } + if n >= from { + lines = append(lines, redact.Secrets(strings.TrimRight(line, "\r\n"))) + } + n++ + } + return lines, from + len(lines), nil +} + +// ProcessOutput answers a process's output from a line on. +func (b *appBackend) ProcessOutput(id string, from int) ([]string, int, error) { + path, err := b.processLog(id) + if err != nil { + return nil, 0, err + } + lines, next, err := appReadLines(path, from, appOutputLines) + if err != nil { + return nil, 0, appEngineError(err) + } + return lines, next, nil +} + +// FollowProcess starts or stops the live tail of one process's output, which +// arrives as process.output only while it is followed. +func (b *appBackend) FollowProcess(id string, follow bool) error { + path, err := b.processLog(id) + if err != nil { + return err + } + var stop *appFollow + start := false + b.locked(func() { + current := b.follows[id] + switch { + case !follow && current != nil: + stop = current + delete(b.follows, id) + case follow && current == nil && !b.closing: + b.follows[id] = &appFollow{stop: make(chan struct{}), done: make(chan struct{})} + start = true + } + }) + if stop != nil { + close(stop.stop) + <-stop.done + } + if start { + var f *appFollow + b.locked(func() { f = b.follows[id] }) + from, _ := appCountLines(path) + guard.Go("app/follow", func() { b.follow(id, path, from, f) }) + } + return nil +} + +// appCountLines is how many complete lines a spool holds now. +func appCountLines(path string) (int, error) { + file, err := os.Open(path) + if err != nil { + return 0, nil + } + defer file.Close() + reader := bufio.NewReaderSize(file, 64<<10) + n := 0 + for { + if _, err := reader.ReadString('\n'); err != nil { + return n, nil + } + n++ + } +} + +// follow tails one spool until it is unfollowed or the backend closes. +func (b *appBackend) follow(id, path string, from int, f *appFollow) { + defer close(f.done) + ticker := time.NewTicker(appFollowEvery) + defer ticker.Stop() + for { + select { + case <-f.stop: + return + case <-ticker.C: + } + lines, next, err := appReadLines(path, from, appOutputLines) + if err != nil || len(lines) == 0 { + continue + } + from = next + b.emit.Emit(appwire.EventProcessOutput, appwire.ProcessOutput{ID: id, Lines: lines}) + } +} + +// StopProcess stops one running process. +func (b *appBackend) StopProcess(id string) error { + agent, job, ok := appProcessJob(id) + if !ok { + return appwire.Errorf(appwire.CodeBadParams, "%s is not a process id", id) + } + _, live, err := b.heldLive(agent) + if err != nil { + return err + } + if _, err := live.conn.Conv.Cancel("job:" + strconv.Itoa(job)); err != nil { + return appEngineError(err) + } + return nil +} + +// RerunProcess runs an ended process's command again, as a new process. +func (b *appBackend) RerunProcess(id string) (appwire.Process, error) { + agent, job, ok := appProcessJob(id) + if !ok { + return appwire.Process{}, appwire.Errorf(appwire.CodeBadParams, "%s is not a process id", id) + } + _, live, err := b.heldLive(agent) + if err != nil { + return appwire.Process{}, err + } + row, err := live.conn.Conv.RerunProcess(job) + if err != nil { + return appwire.Process{}, appEngineError(err) + } + return appProcessOf(agent, row), nil +} + +// ── running long ──────────────────────────────────────────────────────────── + +// watchLong says turn.long once if the turn is still running after the +// backend's long figure, with how many steps it has taken. +func (b *appBackend) watchLong(id string, live *appLive, turn uint64, started time.Time) *time.Timer { + return time.AfterFunc(b.turnLong, func() { + defer guard.Recover("app/long") + running := false + steps := 0 + b.locked(func() { + running = live.running && live.turn == turn + steps = live.steps + }) + if !running { + return + } + elapsed := int(b.now().Sub(started).Round(time.Second) / time.Second) + live.box.Event(appwire.EventTurnLong, appwire.TurnLong{Agent: id, Turn: turn, ElapsedS: elapsed, Steps: steps}) + }) +} + +// MoveToTask moves the running reply to a task at its next step and answers +// that task. +func (b *appBackend) MoveToTask(agent string, turn uint64) (appwire.Task, error) { + a, err := b.find(agent) + if err != nil { + return appwire.Task{}, err + } + live := b.liveOf(a) + known := map[string]bool{} + running := false + b.locked(func() { + if live == nil { + return + } + running = live.running && (turn == 0 || live.turn == turn) + for id := range live.tasks { + known[id] = true + } + }) + if !running { + return appwire.Task{}, appwire.Errorf(appwire.CodeNotFound, "that reply is not running") + } + if err := live.conn.Conv.MoveTurnToTask(); err != nil { + return appwire.Task{}, appEngineError(err) + } + deadline := time.Now().Add(appMoveWait) + for { + var found *appwire.Task + still := false + b.locked(func() { + still = live.running && (turn == 0 || live.turn == turn) + for id, task := range live.tasks { + if !known[id] && task.Parent == "" { + found = &task + return + } + } + }) + if found != nil { + return *found, nil + } + if !still { + return appwire.Task{}, appwire.Errorf(appwire.CodeNotFound, "the reply finished before it could move to a task") + } + if time.Now().After(deadline) { + return appwire.Task{}, appwire.Errorf(appwire.CodeEngine, "the reply has not moved to a task yet; it moves at its next step") + } + time.Sleep(100 * time.Millisecond) + } +} + +// ── sleep ─────────────────────────────────────────────────────────────────── + +// appWakeNotices numbers the notices a wake leaves. +var appWakeNotices atomic.Uint64 + +// appSlept is one process stopped because the Mac went to sleep. +type appSlept struct { + id, label string +} + +// Sleep stops every running process before the Mac sleeps, and every held +// agent reads asleep until it wakes. +func (b *appBackend) Sleep() error { + var agents []*appAgent + b.locked(func() { + b.asleep = true + for _, a := range b.agents { + if a.live != nil { + agents = append(agents, a) + } + } + }) + for _, a := range agents { + live := b.liveOf(a) + if live == nil { + continue + } + var id string + b.locked(func() { id = a.view.ID }) + rows, err := live.conn.Conv.JobNotices() + if err == nil { + for _, row := range rows { + if row.State != session.JobRunning || row.Kind == session.JobKindTask { + continue + } + if _, err := live.conn.Conv.Cancel("job:" + strconv.Itoa(row.ID)); err == nil { + slept := appSlept{id: appProcessID(id, row.ID), label: row.Label()} + b.locked(func() { live.slept = append(live.slept, slept) }) + } + } + } + var view appwire.Agent + b.locked(func() { + a.view.State = appwire.StateAsleep + view = a.view + }) + live.box.Event(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) + } + return nil +} + +// Wake brings every agent back, and says once per process the sleep stopped +// that it stopped, so the person can run it again. +func (b *appBackend) Wake() error { + var agents []*appAgent + b.locked(func() { + b.asleep = false + for _, a := range b.agents { + if a.live != nil { + agents = append(agents, a) + } + } + }) + at := b.now() + for _, a := range agents { + live := b.liveOf(a) + if live == nil { + continue + } + var slept []appSlept + var view appwire.Agent + b.locked(func() { + slept, live.slept = live.slept, nil + a.view.State = live.restingState() + if live.running && a.view.State == appwire.StateIdle { + a.view.State = appwire.StateWorking + } + view = a.view + }) + for _, s := range slept { + n := appWakeNotices.Add(1) + live.box.Event(appwire.EventEntryAdded, appwire.EntryAdded{Entry: appwire.Entry{ + ID: "w" + strconv.FormatUint(n, 10), Agent: view.ID, Kind: appwire.EntryNotice, At: at, + Text: s.label + " stopped while this Mac slept · Rerun", Ref: s.id, + }}) + } + live.box.Event(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) + } + return nil +} diff --git a/cmd/codeaf/appwork_test.go b/cmd/codeaf/appwork_test.go new file mode 100644 index 0000000000..36c8a151f3 --- /dev/null +++ b/cmd/codeaf/appwork_test.go @@ -0,0 +1,418 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/session" +) + +// workBackend is a fake backend with one open agent whose task lane the test +// can speak on. +func workBackend(t *testing.T, conv *fakeConversation) (*appBackend, *appRecorder, appwire.Agent) { + t.Helper() + var mu sync.Mutex + var opens, releases int + rec := newAppRecorder() + backend := newFakeBackend(rec, fakeOpener(conv, &opens, &releases, &mu)) + t.Cleanup(func() { backend.Close() }) + backend.state = &appState{path: filepath.Join(t.TempDir(), appStateName)} + agent, err := mustCreate(t, backend, appwire.CreateAsk{Workspace: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + return backend, rec, agent +} + +// lane puts one event on the fake's task lane. +func (f *fakeConversation) lane(event session.Event) { + f.mu.Lock() + lane := f.tasks + f.mu.Unlock() + lane <- event +} + +func taskChanged(rec *appRecorder, t *testing.T, match func(appwire.Task) bool) appwire.Task { + t.Helper() + i := rec.waitFor(t, 2*time.Second, func(event string, data any) bool { + changed, ok := data.(appwire.TaskChanged) + return ok && match(changed.Task) + }) + rec.mu.Lock() + defer rec.mu.Unlock() + return rec.data[i].(appwire.TaskChanged).Task +} + +// A TASK SAYS WHAT ITS CARD AND ITS ROW DRAW. A proposal is proposed with its +// clock, an irreversible one has none, a running task carries the terminal's +// live line and its phase, and a finished one waiting on the person is your +// call; every id names its agent. +func TestAppTasksCarryTheCardAndTheLiveLine(t *testing.T) { + conv := &fakeConversation{} + backend, rec, agent := workBackend(t, conv) + deadline := time.Now().Add(12 * time.Second).Round(time.Second) + + conv.lane(session.Event{Kind: session.EventTaskProposal, Task: &session.TaskNotice{ID: 4, Title: "add a cache", Deadline: deadline}}) + proposed := taskChanged(rec, t, func(task appwire.Task) bool { return task.Title == "add a cache" }) + if proposed.ID != agent.ID+":4" || !proposed.Proposed || proposed.ClockUntil == nil || !proposed.ClockUntil.Equal(deadline) || proposed.State != appwire.TaskQueued { + t.Fatalf("the proposal = %+v", proposed) + } + conv.lane(session.Event{Kind: session.EventTaskProposal, Task: &session.TaskNotice{ID: 5, Title: "drop the table", Irreversible: true}}) + final := taskChanged(rec, t, func(task appwire.Task) bool { return task.Title == "drop the table" }) + if !final.Irreversible || final.ClockUntil != nil || !final.Proposed { + t.Fatalf("an irreversible proposal = %+v", final) + } + + conv.lane(session.Event{Kind: session.EventTaskUpdate, Task: &session.TaskNotice{ID: 4, Title: "add a cache", State: session.TaskRunning, Waiting: "waiting for a slot", CostUSD: 0.25}}) + running := taskChanged(rec, t, func(task appwire.Task) bool { return task.ID == agent.ID+":4" && task.State == appwire.TaskRunning }) + if running.Proposed || running.Line != "waiting for a slot" || running.SpentUSD == nil || *running.SpentUSD != 0.25 { + t.Fatalf("the running task = %+v", running) + } + conv.lane(session.Event{Kind: session.EventTaskPhase, TaskPhase: &session.TaskPhaseNotice{ID: 4, Phase: session.TaskPhaseRepairing, Round: 1, Rounds: 2}}) + checked := taskChanged(rec, t, func(task appwire.Task) bool { return strings.HasPrefix(task.Line, "closing gaps") }) + if checked.Line != "closing gaps · round 1 of 2" { + t.Fatalf("the phase line = %q", checked.Line) + } + // Plain working says the call the task is making, as the terminal's row + // does, and nothing once the call ends. + conv.lane(session.Event{Kind: session.EventTaskPhase, TaskPhase: &session.TaskPhaseNotice{ID: 4, Phase: session.TaskPhaseWorking}}) + room := func() chan session.Event { + for i := 0; i < 200; i++ { + conv.mu.Lock() + room := conv.room + conv.mu.Unlock() + if room != nil { + return room + } + time.Sleep(10 * time.Millisecond) + } + t.Fatal("the running task's calls were never watched") + return nil + }() + room <- session.Event{Kind: session.EventToolBegin, Tool: "write", Args: `{"path":"cache.go"}`} + calling := taskChanged(rec, t, func(task appwire.Task) bool { return strings.Contains(task.Line, "cache.go") }) + if calling.State != appwire.TaskRunning { + t.Fatalf("the task making a call = %+v", calling) + } + room <- session.Event{Kind: session.EventToolEnd, Tool: "write"} + taskChanged(rec, t, func(task appwire.Task) bool { + return task.ID == agent.ID+":4" && task.State == appwire.TaskRunning && task.Line == "" + }) + conv.lane(session.Event{Kind: session.EventTaskUpdate, Task: &session.TaskNotice{ID: 4, State: session.TaskUnverified, Result: "cached the reads", Changed: []string{"cache.go"}}}) + call := taskChanged(rec, t, func(task appwire.Task) bool { return task.State == appwire.TaskUnverified }) + if !call.YourCall || call.Line != "" || call.Result != "cached the reads" || len(call.Files) != 1 || call.Files[0].Path != "cache.go" || call.Title != "add a cache" { + t.Fatalf("the task waiting on you = %+v", call) + } + if err := backend.ActOnTask("nonsense", "start", "", ""); err == nil { + t.Fatal("an id that names no agent was taken") + } +} + +// EVERY CARD VERB GOES THROUGH THE ENGINE'S ONE TASK DOOR with the agent's own +// number: typing holds the clock as hold does, and not_right carries its words. +func TestAppTaskVerbsReachTheTaskDoor(t *testing.T) { + conv := &fakeConversation{appTasks: []session.AppTaskView{{ID: "4", Title: "add a cache", State: "your_call", Brief: "cache the reads", Result: "done it", Files: []string{"cache.go"}, WorkLog: []string{"read cache.go", "edit cache.go"}, Notes: []session.AppTaskNote{{Text: "keep it small"}}, Cap: 5, Spent: 1.5}}} + backend, _, agent := workBackend(t, conv) + id := agent.ID + ":4" + for _, act := range [][4]string{ + {"start", "", ""}, {"hold", "", ""}, {"typing", "", ""}, {"decline", "", ""}, {"stop", "", ""}, + {"note", "", "use the LRU"}, {"decide", appwire.VerdictNotRight, "it misses the writes"}, {"decide", appwire.VerdictAccept, ""}, + } { + if err := backend.ActOnTask(id, act[0], act[1], act[2]); err != nil { + t.Fatalf("%v: %v", act, err) + } + } + conv.mu.Lock() + intents := append([]session.AppTaskIntent(nil), conv.intents...) + conv.mu.Unlock() + want := []string{"start", "hold", "hold", "decline", "stop", "note", "decide", "decide"} + if len(intents) != len(want) { + t.Fatalf("intents = %+v", intents) + } + for i, intent := range intents { + if intent.ID != "4" || intent.Action != want[i] { + t.Fatalf("intent %d = %+v, want %s on 4", i, intent, want[i]) + } + } + if intents[5].Text != "use the LRU" || intents[6].Verb != "not_right" || intents[6].Text != "it misses the writes" || intents[7].Verb != "accept" { + t.Fatalf("the words did not travel: %+v", intents[5:]) + } + + page, err := backend.GetTask(id) + if err != nil { + t.Fatal(err) + } + if page.Task.ID != id || !page.Task.YourCall || page.Brief != "cache the reads" || page.Result != "done it" || len(page.Log) != 2 || len(page.Notes) != 1 || page.Notes[0].Text != "keep it small" || + page.Task.BudgetUSD == nil || *page.Task.BudgetUSD != 5 || len(page.Files) != 1 { + t.Fatalf("the page = %+v", page) + } + if _, err := backend.GetTask(agent.ID + ":9"); err == nil { + t.Fatal("a task that is not there was answered") + } +} + +// A QUICK TASK IS SAID AS A QUICK TASK, with each file's lines. Its undo +// refuses a file changed since, naming it, and the quick task reads conflict; +// once the file is put back the undo goes through and it reads undone. +func TestAppQuickTasksAndUndo(t *testing.T) { + conv := &fakeConversation{quickTasks: []session.AppQuickTaskView{{ID: "7", Title: "rename the flag", State: "done", Files: []session.AppQuickFile{{Path: "main.go", Added: 3, Removed: 1}}}}, undoChanged: []string{"main.go"}} + backend, rec, agent := workBackend(t, conv) + conv.lane(session.Event{Kind: session.EventTaskUpdate, Task: &session.TaskNotice{ID: 7, Kind: session.TaskKindQuick, Title: "rename the flag", State: session.TaskDone}}) + i := rec.waitFor(t, 2*time.Second, func(event string, data any) bool { _, ok := data.(appwire.QuickChangedEvent); return ok }) + rec.mu.Lock() + quick := rec.data[i].(appwire.QuickChangedEvent).Quick + rec.mu.Unlock() + if quick.ID != agent.ID+":7" || quick.State != appwire.QuickDone || len(quick.Files) != 1 || quick.Files[0].Add != 3 || quick.Files[0].Del != 1 { + t.Fatalf("the quick task = %+v", quick) + } + rec.mu.Lock() + for _, event := range rec.events { + if event == appwire.EventTaskChanged { + t.Fatal("a quick task was also said as a task") + } + } + rec.mu.Unlock() + + err := backend.UndoQuick(quick.ID) + var wire *appwire.Error + if !errors.As(err, &wire) || wire.Code != appwire.CodeConflict || len(wire.Changed) != 1 || wire.Changed[0] != "main.go" { + t.Fatalf("a conflicted undo = %v", err) + } + rec.waitFor(t, time.Second, func(event string, data any) bool { + changed, ok := data.(appwire.QuickChangedEvent) + return ok && changed.Quick.State == appwire.QuickConflict && changed.Quick.Conflict != nil && changed.Quick.Conflict.Changed[0] == "main.go" + }) + conv.mu.Lock() + conv.undoChanged = nil + conv.mu.Unlock() + if err := backend.UndoQuick(quick.ID); err != nil { + t.Fatal(err) + } + rec.waitFor(t, time.Second, func(event string, data any) bool { + changed, ok := data.(appwire.QuickChangedEvent) + return ok && changed.Quick.State == appwire.QuickUndone && changed.Quick.Conflict == nil + }) +} + +// A PROCESS IS SAID AS IT MOVES, its output is read from its spool by line and +// followed live only while followed, stop and rerun go through the engine, +// and a sleep stops what is running and says so once on wake. +func TestAppProcessesOutputFollowStopAndSleep(t *testing.T) { + spool := filepath.Join(t.TempDir(), "job-3.log") + if err := os.WriteFile(spool, []byte("checking 10 links\nok /docs\n"), 0o600); err != nil { + t.Fatal(err) + } + job := session.JobNotice{ID: 3, Name: "link checker", Command: "npx linkinator .", State: session.JobRunning, LogPath: spool, Started: time.Now()} + conv := &fakeConversation{jobs: []session.JobNotice{job}} + backend, rec, agent := workBackend(t, conv) + conv.lane(session.Event{Kind: session.EventJobUpdate, Job: &job}) + i := rec.waitFor(t, 2*time.Second, func(event string, data any) bool { _, ok := data.(appwire.ProcessChanged); return ok }) + rec.mu.Lock() + process := rec.data[i].(appwire.ProcessChanged).Process + rec.mu.Unlock() + id := agent.ID + ":p3" + if process.ID != id || process.Title != "link checker" || process.State != appwire.ProcessRunning || process.LastLine != "ok /docs" || process.ExitCode != nil { + t.Fatalf("the process = %+v", process) + } + list, err := backend.ListProcesses(agent.ID) + if err != nil || len(list) != 1 || list[0].ID != id { + t.Fatalf("processes.list = %+v, %v", list, err) + } + lines, next, err := backend.ProcessOutput(id, 1) + if err != nil || len(lines) != 1 || lines[0] != "ok /docs" || next != 2 { + t.Fatalf("output from line 1 = %q, %d, %v", lines, next, err) + } + + if err := backend.FollowProcess(id, true); err != nil { + t.Fatal(err) + } + appendLine := func(line string) { + f, err := os.OpenFile(spool, os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + t.Fatal(err) + } + defer f.Close() + if _, err := f.WriteString(line + "\n"); err != nil { + t.Fatal(err) + } + } + appendLine("broken /old") + rec.waitFor(t, 3*time.Second, func(event string, data any) bool { + out, ok := data.(appwire.ProcessOutput) + return ok && out.ID == id && len(out.Lines) == 1 && out.Lines[0] == "broken /old" + }) + if err := backend.FollowProcess(id, false); err != nil { + t.Fatal(err) + } + + if err := backend.StopProcess(id); err != nil { + t.Fatal(err) + } + rerun, err := backend.RerunProcess(id) + if err != nil || rerun.ID != agent.ID+":p103" || rerun.State != appwire.ProcessRunning { + t.Fatalf("rerun = %+v, %v", rerun, err) + } + + if err := backend.Sleep(); err != nil { + t.Fatal(err) + } + rec.waitFor(t, time.Second, func(event string, data any) bool { + changed, ok := data.(appwire.AgentChanged) + return ok && changed.Agent.State == appwire.StateAsleep + }) + conv.mu.Lock() + cancels := append([]string(nil), conv.cancels...) + conv.mu.Unlock() + if len(cancels) != 2 || cancels[0] != "job:3" || cancels[1] != "job:3" { + t.Fatalf("stops = %q", cancels) + } + if err := backend.Wake(); err != nil { + t.Fatal(err) + } + rec.waitFor(t, time.Second, func(event string, data any) bool { + added, ok := data.(appwire.EntryAdded) + return ok && added.Entry.Kind == appwire.EntryNotice && added.Entry.Text == "link checker stopped while this Mac slept · Rerun" && added.Entry.Ref == id + }) + rec.waitFor(t, time.Second, func(event string, data any) bool { + changed, ok := data.(appwire.AgentChanged) + return ok && changed.Agent.State == appwire.StateIdle + }) +} + +// A REPLY THAT RUNS LONG SAYS SO ONCE, with its steps, and stays a reply until +// the person moves it; the move answers the task it became. +func TestAppTurnLongAndMoveToTask(t *testing.T) { + hold := make(chan struct{}) + conv := &fakeConversation{script: []session.Event{ + {Kind: session.EventToolBegin, CallID: "c1", Tool: "read"}, + {Kind: session.EventToolEnd, CallID: "c1", Tool: "read"}, + }, hold: hold} + backend, rec, agent := workBackend(t, conv) + backend.turnLong = 50 * time.Millisecond + turn, err := backend.Send(agent.ID, "refactor everything", "c-9") + if err != nil { + t.Fatal(err) + } + i := rec.waitFor(t, 2*time.Second, func(event string, data any) bool { _, ok := data.(appwire.TurnLong); return ok }) + rec.mu.Lock() + long := rec.data[i].(appwire.TurnLong) + rec.mu.Unlock() + if long.Agent != agent.ID || long.Turn != turn || long.Steps != 1 { + t.Fatalf("turn.long = %+v", long) + } + + moved := make(chan appwire.Task, 1) + failed := make(chan error, 1) + go func() { + task, err := backend.MoveToTask(agent.ID, turn) + if err != nil { + failed <- err + return + } + moved <- task + }() + deadline := time.Now().Add(2 * time.Second) + for { + conv.mu.Lock() + asked := conv.moves + conv.mu.Unlock() + if asked == 1 { + break + } + if time.Now().After(deadline) { + t.Fatal("the move never reached the engine") + } + time.Sleep(5 * time.Millisecond) + } + conv.lane(session.Event{Kind: session.EventTaskUpdate, Task: &session.TaskNotice{ID: 11, Title: "refactor everything", State: session.TaskRunning}}) + select { + case task := <-moved: + if task.ID != agent.ID+":11" { + t.Fatalf("moved to %+v", task) + } + case err := <-failed: + t.Fatal(err) + case <-time.After(3 * time.Second): + t.Fatal("the move did not answer") + } + close(hold) + rec.waitFor(t, 2*time.Second, appTurnDone) + if _, err := backend.MoveToTask(agent.ID, turn); err == nil { + t.Fatal("a finished reply was moved") + } + + entries, _, err := backend.Entries(agent.ID, "", appwire.MaxEntries) + if err != nil { + t.Fatal(err) + } + _ = entries + var marks map[int]appTurnMark + a, _ := backend.find(agent.ID) + backend.locked(func() { marks = a.marks }) + if mark := marks[0]; mark.turn != turn || mark.clientID != "c-9" { + t.Fatalf("the message's mark = %+v", mark) + } + listed := appEntriesOf(agent.ID, []session.DisplayEntry{{Role: "user", Text: "refactor everything"}, {Role: "assistant", Text: "on it"}}, time.Now(), marks) + if listed[0].Turn != turn || listed[0].ClientID != "c-9" || listed[1].Turn != turn || listed[1].ClientID != "" { + t.Fatalf("listed entries = %+v", listed) + } +} + +// A CONVERSATION THE APP IS NOT HOLDING STILL LISTS ITS TASKS, finished ones +// included, read off its own folder's tasks.json: a terminal conversation the +// app has never opened has every task it ever ran. +func TestAppTasksListReadsAConversationsOwnTaskFile(t *testing.T) { + root := resolvedTempDir(t) + t.Setenv(home.EnvVar, filepath.Join(root, "state")) + t.Setenv(config.ProfileDirEnv, filepath.Join(root, "profile")) + workspace := filepath.Join(root, "work") + if err := os.MkdirAll(workspace, 0o700); err != nil { + t.Fatal(err) + } + transcript, err := appMint(workspace) + if err != nil { + t.Fatal(err) + } + tasks := `{"type":"tasks","version":1,"seq":4,"nodes":[ +{"id":1,"title":"fix the parser","brief":"b","acceptance":"a","state":"done","report":"fixed it","changed":["parser.go"],"costUsd":0.4}, +{"id":2,"title":"add tests","brief":"b","acceptance":"a","state":"running","interrupted":true,"parent":1}, +{"id":3,"title":"lint it","brief":"b","acceptance":"a","state":"unverified"}, +{"id":4,"title":"rename","brief":"b","acceptance":"a","state":"done","kind":"quick"}]}` + if err := os.WriteFile(filepath.Join(filepath.Dir(transcript), "tasks.json"), []byte(tasks), 0o600); err != nil { + t.Fatal(err) + } + backend := newAppBackend(newAppRecorder(), nil) + defer backend.Close() + id := appAgentID(transcript) + list, err := backend.ListTasks(id) + if err != nil { + t.Fatal(err) + } + if len(list) != 3 { + t.Fatalf("tasks = %+v", list) + } + byTitle := map[string]appwire.Task{} + for _, task := range list { + byTitle[task.Title] = task + } + if done := byTitle["fix the parser"]; done.ID != id+":1" || done.State != appwire.TaskDone || done.Result != "fixed it" || len(done.Files) != 1 || done.SpentUSD == nil { + t.Fatalf("the finished task = %+v", done) + } + if left := byTitle["add tests"]; left.State != appwire.TaskFailed || left.Result != "Interrupted" || left.Parent != id+":1" { + t.Fatalf("the interrupted task = %+v", left) + } + if call := byTitle["lint it"]; !call.YourCall { + t.Fatalf("the task waiting on you = %+v", call) + } + if backend.liveOf(backend.agent(id)) != nil { + t.Fatal("listing tasks opened the conversation") + } +} diff --git a/cmd/codeaf/engine.go b/cmd/codeaf/engine.go index 19b1e2d4e4..5ff225cdb9 100644 --- a/cmd/codeaf/engine.go +++ b/cmd/codeaf/engine.go @@ -1043,6 +1043,10 @@ func assembleEngine(hello remote.Hello, workspace, sessionFlag, launchDir string // The desktop app names its conversations by rule ([session.FirstMessageName]). if hello.Launch != nil { cfg.DeterministicNames = hello.Launch.DeterministicNames + cfg.HandOverOnAsk = hello.Launch.HandOverOnAsk + if clock := hello.Launch.ProposalClockS; clock != nil { + cfg.TaskAutoApproveSeconds = *clock + } } agent, cfg, notice, err := openV3Agent(cfg, workspace, open) if err != nil { diff --git a/docs/changes/unreleased/0-app-wave3.md b/docs/changes/unreleased/0-app-wave3.md new file mode 100644 index 0000000000..87764ce3df --- /dev/null +++ b/docs/changes/unreleased/0-app-wave3.md @@ -0,0 +1,20 @@ +--- +kind: added +title: codeaf app drives tasks, quick tasks, processes, long replies and sleep +pr: 0 +surface: [engine] +invalidates: + - "A reply running long in an app conversation was moved to a task by the checkpoints on their own. In the app it now stays a reply: turn.long is said at 180 seconds and only agents.move_to_task moves it. The terminal is unchanged." + - "A quick task could run any shell command. It now asks to become a full task before a shell call (tests included), media creation or its 31st file, and a finished quick task can be undone when none of its files changed since." + - "App task ids were the conversation's own number. They are now :, and process ids :p, because two agents both have a task 1." + - "tasks.list read only the project's task index and listed nothing for a terminal conversation the app was not holding. It now reads the conversation's own tasks.json, finished tasks included." + - "models.list named each model's provider by its id's vendor prefix and could return only one service's models when the default catalog was still loading. Provider is now the service the picker groups the model under, with provider_name, and the default catalog is waited for." +--- + +New methods: `tasks.get`, `tasks.start` / `hold` / `decline` / `typing` / +`decide` / `note` / `stop`, `quick.undo`, `quick.promote`, `processes.list` / +`output` / `follow` / `unfollow` / `stop` / `rerun`, `agents.move_to_task`, +`power.sleep` / `power.wake`. Tasks carry `proposed`, `clock_until`, +`irreversible`, `your_call`, `result`, `files`, `spent_usd` and the live +`line`; listed entries carry `turn` and `client_id` for messages this launch +sent. Events: `quick.changed`, `process.changed`, `process.output`, `turn.long`. diff --git a/internal/appwire/wire.go b/internal/appwire/wire.go index 72b7c8487e..e91f021dba 100644 --- a/internal/appwire/wire.go +++ b/internal/appwire/wire.go @@ -80,6 +80,8 @@ const ( type Error struct { Code string `json:"code"` Message string `json:"message"` + // Changed names the files a conflict is about ([CodeConflict]). + Changed []string `json:"changed,omitempty"` } func (e *Error) Error() string { return e.Code + ": " + e.Message } @@ -122,6 +124,24 @@ type Task struct { Title string `json:"title"` State string `json:"state"` UpdatedAt time.Time `json:"updated_at"` + // Proposed is a task waiting for the person's yes; ClockUntil is when it + // starts on its own, absent while the clock is held and always absent + // for Irreversible work. YourCall is a finished task waiting for the + // person's verdict (tasks.decide). + Proposed bool `json:"proposed,omitempty"` + ClockUntil *time.Time `json:"clock_until,omitempty"` + Irreversible bool `json:"irreversible,omitempty"` + YourCall bool `json:"your_call,omitempty"` + // Result is what the task said it did, Files what it changed, and + // BudgetUSD and SpentUSD its cap and what it has spent. + Result string `json:"result,omitempty"` + Files []FileChange `json:"files,omitempty"` + BudgetUSD *float64 `json:"budget_usd,omitempty"` + SpentUSD *float64 `json:"spent_usd,omitempty"` + // Line is the task's live status line, the one the terminal draws under + // a running task: the phase it is in, the gap it is closing, or what it + // is waiting on. It is empty when there is nothing to say. + Line string `json:"line,omitempty"` } // Crew is one codeaf team, with its members named by agent id. @@ -233,6 +253,8 @@ type Model struct { ID string `json:"id"` Name string `json:"name"` Provider string `json:"provider"` + // ProviderName is the provider's own name, as the picker heads its group. + ProviderName string `json:"provider_name,omitempty"` // Default marks the model a new conversation runs on. Default bool `json:"default,omitempty"` } diff --git a/internal/appwire/work.go b/internal/appwire/work.go new file mode 100644 index 0000000000..c96f596bdc --- /dev/null +++ b/internal/appwire/work.go @@ -0,0 +1,392 @@ +package appwire + +import ( + "encoding/json" + "strings" + "time" +) + +// Wave 3: tasks, quick tasks, processes, running long and sleep +// (PROTOCOL.md). The types and the methods sit together, as setup.go's do. + +// StateAsleep is an agent whose Mac is asleep: its processes were stopped and +// it resumes on wake. +const StateAsleep = "asleep" + +// CodeConflict is quick.undo's refusal when a file it would restore changed +// since the quick task wrote it; the error names the files in Changed. +const CodeConflict = "conflict" + +// The events of this wave. +const ( + EventQuickChanged = "quick.changed" + EventProcessChanged = "process.changed" + EventProcessOutput = "process.output" + EventTurnLong = "turn.long" +) + +// The quick task states. +const ( + QuickRunning = "running" + QuickDone = "done" + QuickUndone = "undone" + QuickGrew = "grew" + QuickConflict = "conflict" +) + +// The process states. +const ( + ProcessRunning = "running" + ProcessEnded = "ended" + ProcessFailed = "failed" + ProcessStopped = "stopped" +) + +// The verdicts of a task waiting for the person's call. +const ( + VerdictAccept = "accept" + VerdictNotRight = "not_right" + VerdictRecheck = "recheck" + VerdictLetItDecide = "let_it_decide" +) + +// FileChange is one file a task or a quick task changed, with the lines it +// added and removed where they are known. +type FileChange struct { + Path string `json:"path"` + Add int `json:"add"` + Del int `json:"del"` +} + +// TaskNote is one note the person sent a task, and the step it was read at. +type TaskNote struct { + ID string `json:"id"` + Text string `json:"text"` + At time.Time `json:"at"` + ReadAtStep *int `json:"read_at_step,omitempty"` +} + +// TaskPage is everything one task's page draws. +type TaskPage struct { + Task Task `json:"task"` + Result string `json:"result,omitempty"` + Files []FileChange `json:"files"` + Log []Entry `json:"log"` + Notes []TaskNote `json:"notes"` + Brief string `json:"brief"` +} + +// Quick is one quick task: work done where the person is, which can be undone. +type Quick struct { + ID string `json:"id"` + Agent string `json:"agent"` + Title string `json:"title"` + Files []FileChange `json:"files"` + State string `json:"state"` + Grew *QuickGrowth `json:"grew,omitempty"` + Conflict *QuickChanged `json:"conflict,omitempty"` +} + +// QuickGrowth is why a quick task asked to become a full task. +type QuickGrowth struct { + Files int `json:"files"` + Reason string `json:"reason"` +} + +// QuickChanged names the files that changed since a quick task wrote them. +type QuickChanged struct { + Changed []string `json:"changed"` +} + +// Process is one background process an agent started. +type Process struct { + ID string `json:"id"` + Agent string `json:"agent"` + Title string `json:"title"` + Command string `json:"command"` + State string `json:"state"` + StartedAt time.Time `json:"started_at"` + EndedAt *time.Time `json:"ended_at,omitempty"` + ExitCode *int `json:"exit_code,omitempty"` + LastLine string `json:"last_line,omitempty"` +} + +// The data of this wave's events. +type ( + QuickChangedEvent struct { + Quick Quick `json:"quick"` + } + ProcessChanged struct { + Process Process `json:"process"` + } + ProcessOutput struct { + ID string `json:"id"` + Lines []string `json:"lines"` + } + TurnLong struct { + Agent string `json:"agent"` + Turn uint64 `json:"turn"` + ElapsedS int `json:"elapsed_s"` + Steps int `json:"steps"` + } +) + +// Worker is the engine's half of this wave. A [Engine] that is also a Worker +// serves these methods; one that is not refuses them as unknown. +type Worker interface { + GetTask(id string) (TaskPage, error) + // ActOnTask is every verb on a task: start, hold, decline, typing, note, + // stop, and decide with a verdict. + ActOnTask(id, action, verdict, text string) error + UndoQuick(id string) error + PromoteQuick(id string) (Task, error) + ListProcesses(agent string) ([]Process, error) + ProcessOutput(id string, from int) ([]string, int, error) + FollowProcess(id string, follow bool) error + StopProcess(id string) error + RerunProcess(id string) (Process, error) + MoveToTask(agent string, turn uint64) (Task, error) + Sleep() error + Wake() error +} + +func init() { + for name, m := range workMethods { + methods[name] = m + } +} + +// worker is the engine as a Worker, or a refusal saying the method is not +// served by this engine. +func worker(e Engine, name string) (Worker, error) { + w, ok := e.(Worker) + if !ok { + return nil, Errorf(CodeUnknownMethod, "this engine does not serve %s", name) + } + return w, nil +} + +// taskVerb is one card verb that takes only an id. +func taskVerb(action string) method { + return func(e Engine, raw json.RawMessage) (any, error) { + w, err := worker(e, "tasks."+action) + if err != nil { + return nil, err + } + id, err := decodeID(raw, "id") + if err != nil { + return nil, err + } + return struct{}{}, w.ActOnTask(id, action, "", "") + } +} + +var workMethods = map[string]method{ + "tasks.get": func(e Engine, raw json.RawMessage) (any, error) { + w, err := worker(e, "tasks.get") + if err != nil { + return nil, err + } + id, err := decodeID(raw, "id") + if err != nil { + return nil, err + } + page, err := w.GetTask(id) + if err != nil { + return nil, err + } + page.Files, page.Log, page.Notes = nonNil(page.Files), nonNil(page.Log), nonNil(page.Notes) + return struct { + Page TaskPage `json:"page"` + }{page}, nil + }, + "tasks.start": taskVerb("start"), + "tasks.hold": taskVerb("hold"), + "tasks.decline": taskVerb("decline"), + "tasks.typing": taskVerb("typing"), + "tasks.stop": taskVerb("stop"), + "tasks.note": func(e Engine, raw json.RawMessage) (any, error) { + w, err := worker(e, "tasks.note") + if err != nil { + return nil, err + } + var p struct { + ID string `json:"id"` + Text string `json:"text"` + } + if err := decode(raw, &p); err != nil { + return nil, err + } + if strings.TrimSpace(p.ID) == "" || strings.TrimSpace(p.Text) == "" { + return nil, Errorf(CodeBadParams, "tasks.note needs an id and some text") + } + return struct{}{}, w.ActOnTask(p.ID, "note", "", p.Text) + }, + "tasks.decide": func(e Engine, raw json.RawMessage) (any, error) { + w, err := worker(e, "tasks.decide") + if err != nil { + return nil, err + } + var p struct { + ID string `json:"id"` + Verdict string `json:"verdict"` + Text string `json:"text"` + } + if err := decode(raw, &p); err != nil { + return nil, err + } + if strings.TrimSpace(p.ID) == "" { + return nil, Errorf(CodeBadParams, "tasks.decide needs an id") + } + switch p.Verdict { + case VerdictAccept, VerdictNotRight, VerdictRecheck, VerdictLetItDecide: + default: + return nil, Errorf(CodeBadParams, "verdict is accept, not_right, recheck or let_it_decide") + } + if p.Verdict == VerdictNotRight && strings.TrimSpace(p.Text) == "" { + return nil, Errorf(CodeBadParams, "not_right needs the words to tell it") + } + return struct{}{}, w.ActOnTask(p.ID, "decide", p.Verdict, p.Text) + }, + "quick.undo": func(e Engine, raw json.RawMessage) (any, error) { + w, err := worker(e, "quick.undo") + if err != nil { + return nil, err + } + id, err := decodeID(raw, "id") + if err != nil { + return nil, err + } + return struct{}{}, w.UndoQuick(id) + }, + "quick.promote": func(e Engine, raw json.RawMessage) (any, error) { + w, err := worker(e, "quick.promote") + if err != nil { + return nil, err + } + id, err := decodeID(raw, "id") + if err != nil { + return nil, err + } + task, err := w.PromoteQuick(id) + if err != nil { + return nil, err + } + return taskResult{task}, nil + }, + "processes.list": func(e Engine, raw json.RawMessage) (any, error) { + w, err := worker(e, "processes.list") + if err != nil { + return nil, err + } + agent, err := decodeID(raw, "agent") + if err != nil { + return nil, err + } + list, err := w.ListProcesses(agent) + if err != nil { + return nil, err + } + return struct { + Processes []Process `json:"processes"` + }{nonNil(list)}, nil + }, + "processes.output": func(e Engine, raw json.RawMessage) (any, error) { + w, err := worker(e, "processes.output") + if err != nil { + return nil, err + } + var p struct { + ID string `json:"id"` + FromLine int `json:"from_line"` + } + if err := decode(raw, &p); err != nil { + return nil, err + } + if strings.TrimSpace(p.ID) == "" || p.FromLine < 0 { + return nil, Errorf(CodeBadParams, "processes.output needs an id and a from_line of zero or more") + } + lines, next, err := w.ProcessOutput(p.ID, p.FromLine) + if err != nil { + return nil, err + } + return struct { + Lines []string `json:"lines"` + NextLine int `json:"next_line"` + }{nonNil(lines), next}, nil + }, + "processes.follow": processVerb(func(w Worker, id string) error { return w.FollowProcess(id, true) }), + "processes.unfollow": processVerb(func(w Worker, id string) error { return w.FollowProcess(id, false) }), + "processes.stop": processVerb(func(w Worker, id string) error { return w.StopProcess(id) }), + "processes.rerun": func(e Engine, raw json.RawMessage) (any, error) { + w, err := worker(e, "processes.rerun") + if err != nil { + return nil, err + } + id, err := decodeID(raw, "id") + if err != nil { + return nil, err + } + process, err := w.RerunProcess(id) + if err != nil { + return nil, err + } + return struct { + Process Process `json:"process"` + }{process}, nil + }, + "agents.move_to_task": func(e Engine, raw json.RawMessage) (any, error) { + w, err := worker(e, "agents.move_to_task") + if err != nil { + return nil, err + } + var p struct { + ID string `json:"id"` + Turn uint64 `json:"turn"` + } + if err := decode(raw, &p); err != nil { + return nil, err + } + if strings.TrimSpace(p.ID) == "" { + return nil, Errorf(CodeBadParams, "agents.move_to_task needs an id") + } + task, err := w.MoveToTask(p.ID, p.Turn) + if err != nil { + return nil, err + } + return taskResult{task}, nil + }, + "power.sleep": func(e Engine, _ json.RawMessage) (any, error) { + w, err := worker(e, "power.sleep") + if err != nil { + return nil, err + } + return struct{}{}, w.Sleep() + }, + "power.wake": func(e Engine, _ json.RawMessage) (any, error) { + w, err := worker(e, "power.wake") + if err != nil { + return nil, err + } + return struct{}{}, w.Wake() + }, +} + +// processVerb is one process verb that takes only an id. +func processVerb(act func(Worker, string) error) method { + return func(e Engine, raw json.RawMessage) (any, error) { + w, err := worker(e, "processes") + if err != nil { + return nil, err + } + id, err := decodeID(raw, "id") + if err != nil { + return nil, err + } + return struct{}{}, act(w, id) + } +} + +type taskResult struct { + Task Task `json:"task"` +} diff --git a/internal/manual/chat/how-tasks-run.md b/internal/manual/chat/how-tasks-run.md index 5d94ec84c7..b3da43b55f 100644 --- a/internal/manual/chat/how-tasks-run.md +++ b/internal/manual/chat/how-tasks-run.md @@ -111,8 +111,9 @@ which is what lets it start the instant it is asked for and finish with nothing writing; what a quick task holds is only the files it named when it started and the ones it has already written, and a write of one of those is refused with the task named until it finishes. Two quick tasks that name the same file run one after the other rather than -at once. The *tasks* page has it under *What a quick task is*. Everything below is about -the ordinary kind. +at once. Before a shell call (tests included), media creation or the 31st file, it asks +to become an ordinary task. Its existing edits stay in the same folder. The *tasks* page +describes Undo and promotion. Everything below is about the ordinary kind. By default, no. Each code task gets its own checkout of **the repository the work is about** — its ground, resolved from what this conversation has been reading and editing (above) — on diff --git a/internal/manual/chat/running-from-the-terminal.md b/internal/manual/chat/running-from-the-terminal.md index 4012ca650a..4279cb4812 100644 --- a/internal/manual/chat/running-from-the-terminal.md +++ b/internal/manual/chat/running-from-the-terminal.md @@ -329,6 +329,15 @@ The app's own settings (day cap, new agents' folder, the 12-second proposed-task 7 idle days before an agent reads closed) and its setup progress live in `app/state.json` under codeaf's home folder, so quitting setup halfway resumes there. +## The desktop app's processes — stop, rerun, and what a Mac going to sleep does to them + +The app lists each conversation's background processes, running and ended, for +seven days, and shows a process's output by line or follows it live. Stop +stops it; Rerun starts its command again as a new process. When the Mac goes +to sleep the app stops every running process and the agent reads asleep; on +waking, each stopped process leaves one line, such as `link checker stopped +while this Mac slept · Rerun`, and nothing is restarted for you. + ## Connect from the terminal without opening the chat — codeaf connect and codeaf disconnect `codeaf connect` lists every model provider this profile knows, whether it is connected, diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index fd7ad5a2f1..2b4ac8fc99 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -126,7 +126,8 @@ it must wait for, and a model to run on. What it gets back is one line, at once: quick task 7 started: compare the four configs · 4 items ``` -That is the whole of the ceremony. If you want the other road instead, say so — *"do that +That starts the quick work. A shell call or a 31st file asks for promotion before it runs. +If you want the other road instead, say so — *"do that as a proper task"* — and the model proposes an ordinary task, card, branch, check and all. *"Just do it quickly"* goes the other way. @@ -5748,3 +5749,70 @@ before its cost was saved also stays interrupted: inspect the saved work before asking again. Its cost is never guessed or reset to make it resume, it is never counted as completed, and its folder is never guessed. Programs that had already started keep their existing interrupted-program behavior; this restart rule concerns ordinary planned tasks. + +## App task proposals, Hold, typing, and irreversible tasks + +The app's Tasks panel reads the engine's own task tree, progress, spending and +steps, and a conversation the app is not holding still lists every task its +folder's `tasks.json` holds, finished ones included; one the conversation left +running when it closed is listed as failed with the result `Interrupted`. A +proposed task in the app starts after 12 seconds by default; the app's own +*proposed task clock* setting changes that, and the terminal's +`task.autoapprove_seconds` is not read by the app. **Hold** and typing in one proposal remove only that task's +clock. Other proposals keep their own deadlines. Leaving the tab does not hold +anything. Start it answers immediately; No leaves a "Declined by you" receipt. +A proposal marked `irreversible:true` never gets a clock. Without a watching +session, it refuses with "this task needs your answer in a watching session". + +The app's task ids include their agent (`:1`), so task 1 in two agents +is two separate objects. A stale expected state or version refuses with +"stale: that task changed; open it again". Refresh that task before answering again. + +## App task notes, Change the brief, Pause, and your call + +A note reaches a running task through its existing steering lane. Its +`read_at_step` stays absent until a model request carries those words. An +opened task shows the result, changed file paths, work log, notes and brief. +Change the brief sends new instructions to the same task; the original task +contract remains on its record. Plan tasks use their existing note and brief +writers; they omit a step receipt when the engine has no such fact. + +Pause lets the current request or action finish and holds the next request or +action. Resume releases it. Stop keeps the work already written. The per-task +cap is the saved one, $5 when nothing was saved, and the engine stops further +worker calls at that cap. + +A task at **your call** has no clock. Accept takes the work as done. Not right +and Tell it… reopen the same task, with your words. Recheck asks its checker +again. Let it decide hands this decision to codeaf through the existing task +decision door. These five actions do not answer an unstarted proposal. + +## Undo a quick task, file hashes, and promotion before tests or file 31 + +A quick task keeps each regular file's bytes before its first write and its +latest after bytes in its private engine checkpoint. Repeated writes keep the +same original. The app shows its paths and SHA-256 hashes, never the saved +bytes. An absent file has an empty hash; an empty file has a real hash. + +Undo is available for an ended quick task, including one you stopped. It checks **every** after hash +first. If any file changed, it restores nothing and lists `changed_since`, +with "Can't undo cleanly: 2 files changed since" for two paths. Otherwise it +restores every original file, including its permissions, and removes files +that the quick task created. The bytes and hashes survive an engine restart. + +Before writing the 31st distinct file, the worker asks to become a full task. +It also asks before **any shell call**, including tests hidden in scripts or +aliases, before workspace restores or merges, and before media creation. Shell effects cannot be counted safely. +There is no clock on this promotion. Promote starts an ordinary task after +the quick worker leaves, continuing in the same folder with its existing +edits and the blocked action in its brief. Cancel stops the quick worker and +keeps its edits. Promote is also available before the limit is reached. + +## A reply running long in the app, and moving it to a task + +In the desktop app a running reply is never moved to a task on its own: the +checkpoints that move a long or wide reply in the terminal stand down. After +three minutes the app is told the reply is running long, with how many steps +it has taken, and offers to move it. Moving it waits for the reply's next step, +writes a brief from what it has done, and continues the work as a task; a reply +that finishes first simply finishes. diff --git a/internal/remote/app_processes.go b/internal/remote/app_processes.go new file mode 100644 index 0000000000..f7e3bdb5cc --- /dev/null +++ b/internal/remote/app_processes.go @@ -0,0 +1,69 @@ +package remote + +import ( + "encoding/json" + "errors" + + "github.com/Agent-Field/codeaf/internal/session" +) + +// These additive doors carry the engine's own process shelf and retained +// commands. They introduce no client-supplied command execution route. +const MethodProcessList = "Process.List" +const MethodProcessRerun = "Process.Rerun" + +type ProcessRerunArgs struct { + ID int `json:"id"` +} + +func (a *Agent) JobNotices() ([]session.JobNotice, error) { + raw, err := a.c.call(nil, MethodProcessList, nil) + if err != nil { + return nil, err + } + var rows []session.JobNotice + err = json.Unmarshal(raw, &rows) + return rows, err +} +func (a *Agent) RerunProcess(id int) (session.JobNotice, error) { + raw, err := a.c.call(nil, MethodProcessRerun, ProcessRerunArgs{ID: id}) + if err != nil { + return session.JobNotice{}, err + } + var row session.JobNotice + err = json.Unmarshal(raw, &row) + return row, err +} +func serveAppProcesses(agent WrappedAgent, call Frame) ([]byte, error) { + switch call.Method { + case MethodProcessList: + door, ok := agent.(interface { + JobNotices() ([]session.JobNotice, error) + }) + if !ok { + return nil, errors.New("this engine cannot list processes") + } + rows, err := door.JobNotices() + if err != nil { + return nil, err + } + return json.Marshal(rows) + case MethodProcessRerun: + args, err := arg[ProcessRerunArgs](call) + if err != nil { + return nil, err + } + door, ok := agent.(interface { + RerunProcess(int) (session.JobNotice, error) + }) + if !ok { + return nil, errors.New("this engine cannot rerun processes") + } + row, err := door.RerunProcess(args.ID) + if err != nil { + return nil, err + } + return json.Marshal(row) + } + return nil, errors.New("no such process action") +} diff --git a/internal/remote/app_processes_test.go b/internal/remote/app_processes_test.go new file mode 100644 index 0000000000..bba8981978 --- /dev/null +++ b/internal/remote/app_processes_test.go @@ -0,0 +1,55 @@ +package remote + +import ( + "encoding/json" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/session" +) + +type processWireFake struct { + *fakeAgent + rerun int +} + +func (f *processWireFake) JobNotices() ([]session.JobNotice, error) { + return []session.JobNotice{{ID: 7, Command: "printf ok", Kind: session.JobKindCommand, State: session.JobDone, OutputTruncated: true}}, nil +} +func (f *processWireFake) RerunProcess(id int) (session.JobNotice, error) { + f.rerun = id + return session.JobNotice{ID: 8, Command: "printf ok", Kind: session.JobKindCommand, State: session.JobRunning}, nil +} +func TestAppProcessWireClientAndServer(t *testing.T) { + f := &processWireFake{fakeAgent: &fakeAgent{}} + sess := newsSession(t, f) + pipe, engine := Pipe() + go func() { + _ = ServeAttach(engine, engine, AttachOptions{Open: func(Hello) (*Session, error) { return sess, nil }}) + _ = engine.Close() + }() + client, err := Dial(pipe, "app", Hello{}) + if err != nil { + t.Fatal(err) + } + defer client.Close() + rows, err := client.Agent().JobNotices() + if err != nil || len(rows) != 1 || rows[0].Command != "printf ok" || !rows[0].OutputTruncated { + t.Fatalf("list: %+v %v", rows, err) + } + row, err := client.Agent().RerunProcess(7) + if err != nil || row.ID != 8 || f.rerun != 7 { + t.Fatalf("rerun: %+v %v", row, err) + } +} +func TestAppProcessWireRefusesAbsentEngineAndBadArguments(t *testing.T) { + for _, call := range []Frame{{Method: MethodProcessList}, {Method: MethodProcessRerun, Payload: json.RawMessage(`{"id":7}`)}} { + if _, err := serveAppProcesses(&fakeAgent{}, call); err == nil || !strings.Contains(err.Error(), "cannot") { + t.Fatalf("absent process door: %v", err) + } + } + f := &processWireFake{fakeAgent: &fakeAgent{}} + if _, err := serveAppProcesses(f, Frame{Method: MethodProcessRerun, Payload: json.RawMessage(`{"id":"x"}`)}); err == nil { + t.Fatal("invalid process number accepted") + } +} diff --git a/internal/remote/callclass.go b/internal/remote/callclass.go index 69d48a3213..9be046dcc1 100644 --- a/internal/remote/callclass.go +++ b/internal/remote/callclass.go @@ -146,7 +146,7 @@ const lateCallTail = " did not answer in time" func classify(method string) callClass { switch method { - case MethodPing, + case MethodAppTasks, MethodAppTask, MethodAppQuickTasks, MethodPing, MethodModel, MethodTitle, MethodUsage, MethodContextTokens, MethodTranscript, MethodEarlier, MethodRewindPoints, MethodPlanSpend, MethodPlanTasks, MethodPlanTaskPage, MethodPlanTaskWork, MethodPlanRunSummary, MethodRefreshRunSummary, @@ -161,13 +161,16 @@ func classify(method string) callClass { // update loop and bounded by the wall; queued behind a turn they would // wait out the wall's patience and answer nobody. MethodTeamsName, MethodTeamsPropose, + // The desktop app's task and process readings are drawn while a reply + // streams, so they are never queued behind it. + MethodProcessList, // The automations readings are the watcher's beat (wire_automations.go): // a store read every two seconds that a turn in flight must never hold // up, or a run's line would arrive when the reply did. MethodAutomationsList, MethodAutomationsRuns, MethodAutomationsChanges, MethodAutomationsCursor, MethodAutomationsActive, MethodAutomationsWindows: return classGetter - case MethodQuestionResolve, MethodQuestionHold, + case MethodAppTaskAction, MethodAppQuickTaskAction, MethodAppMoveToTask, MethodProcessRerun, MethodQuestionResolve, MethodQuestionHold, MethodConsent, MethodConsentRemember, MethodHarness, MethodConnect, MethodConnectKey, MethodNoteConnected, diff --git a/internal/remote/driver.go b/internal/remote/driver.go index 0eadada68d..87c5d4bb10 100644 --- a/internal/remote/driver.go +++ b/internal/remote/driver.go @@ -293,8 +293,9 @@ var watcherReads = map[string]bool{ MethodTaskWatch: true, MethodQuestionWatch: true, MethodPlanTaskPage: true, - MethodDetach: true, - MethodPing: true, + MethodAppTasks: true, MethodAppTask: true, MethodAppQuickTasks: true, + MethodDetach: true, + MethodPing: true, } func watcherMay(method string) bool { return watcherReads[method] } diff --git a/internal/remote/server.go b/internal/remote/server.go index 7eb66cce0e..ce735a3589 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -2076,13 +2076,17 @@ 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, MethodSubmit, MethodFollowUp, MethodUnqueueFollowUp, MethodSteer, MethodQuestionReplace, MethodSubmitImage, MethodSubmitFiles, - MethodTaskSteer, MethodTaskStop, MethodTaskRetry: + MethodTaskSteer, MethodTaskStop, MethodTaskRetry, MethodProcessRerun, MethodAppTaskAction, MethodAppQuickTaskAction, MethodAppMoveToTask: if err := s.mayDrive(); err != nil { return nil, err } } switch call.Method { + case MethodProcessList, MethodProcessRerun: + return serveAppProcesses(agent, call) + case MethodAppTasks, MethodAppTask, MethodAppTaskAction, MethodAppQuickTasks, MethodAppQuickTaskAction, MethodAppMoveToTask: + return dispatchAppTask(agent, call) case MethodTaskRetry: args, err := arg[TaskSetupArgs](call) if err != nil { diff --git a/internal/remote/wire.go b/internal/remote/wire.go index 9d02ef746d..79b3122609 100644 --- a/internal/remote/wire.go +++ b/internal/remote/wire.go @@ -803,6 +803,12 @@ type LaunchShape struct { // DeterministicNames names the conversation from its first message by rule // rather than by a model ([session.FirstMessageName]); the desktop app sets it. DeterministicNames bool `json:"deterministicNames,omitempty"` + // HandOverOnAsk keeps a running reply in the conversation until the person + // moves it to a task ([session.Config.HandOverOnAsk]); the desktop app sets it. + HandOverOnAsk bool `json:"handOverOnAsk,omitempty"` + // ProposalClockS, when set, is how many seconds a proposed task waits before + // it starts on its own ([session.Config.TaskAutoApproveSeconds]). + ProposalClockS *int `json:"proposalClockS,omitempty"` Yolo bool `json:"yolo,omitempty"` NoCompact bool `json:"noCompact,omitempty"` diff --git a/internal/remote/wire_app_task.go b/internal/remote/wire_app_task.go new file mode 100644 index 0000000000..facd0f172e --- /dev/null +++ b/internal/remote/wire_app_task.go @@ -0,0 +1,127 @@ +package remote + +import ( + "encoding/json" + "errors" + + "github.com/Agent-Field/codeaf/internal/session" +) + +// App task calls preserve engine refusals; a failed read never becomes an empty tree. +const ( + MethodAppTasks = "App.Tasks" + MethodAppTask = "App.Task" + MethodAppTaskAction = "App.TaskAction" + MethodAppQuickTasks = "App.QuickTasks" + MethodAppQuickTaskAction = "App.QuickTaskAction" + // MethodAppMoveToTask asks that the running reply move to a task at its + // next step boundary ([session.Agent.MoveTurnToTask]). + MethodAppMoveToTask = "App.MoveToTask" +) + +type appTaskPageResult struct { + Task session.AppTaskView + Found bool +} +type appQuickActionResult struct{ Undone, Changed []string } +type appTaskDoor interface { + AppTasks() ([]session.AppTaskView, error) + AppTask(string) (session.AppTaskView, bool, error) + AppTaskAction(session.AppTaskIntent) error + AppQuickTasks() ([]session.AppQuickTaskView, error) + AppQuickTaskAction(session.AppTaskIntent) ([]string, []string, error) + MoveTurnToTask() error +} + +func dispatchAppTask(agent any, call Frame) (json.RawMessage, error) { + door, ok := agent.(appTaskDoor) + if !ok { + return nil, errors.New("engine: app tasks are unavailable") + } + switch call.Method { + case MethodAppMoveToTask: + return nil, door.MoveTurnToTask() + case MethodAppTasks: + rows, err := door.AppTasks() + if err != nil { + return nil, err + } + return json.Marshal(rows) + case MethodAppQuickTasks: + rows, err := door.AppQuickTasks() + if err != nil { + return nil, err + } + return json.Marshal(rows) + case MethodAppTask: + args, err := arg[PlanTaskArgs](call) + if err != nil { + return nil, err + } + task, found, err := door.AppTask(args.ID) + if err != nil { + return nil, err + } + return json.Marshal(appTaskPageResult{task, found}) + case MethodAppTaskAction, MethodAppQuickTaskAction: + intent, err := arg[session.AppTaskIntent](call) + if err != nil { + return nil, err + } + if call.Method == MethodAppTaskAction { + return nil, door.AppTaskAction(intent) + } + undone, changed, err := door.AppQuickTaskAction(intent) + if err != nil { + return nil, err + } + return json.Marshal(appQuickActionResult{undone, changed}) + } + return nil, errors.New("unknown app task method") +} +func (a *Agent) AppTasks() ([]session.AppTaskView, error) { + raw, err := a.c.call(nil, MethodAppTasks, nil) + if err != nil { + return nil, err + } + var rows []session.AppTaskView + err = json.Unmarshal(raw, &rows) + return rows, err +} +func (a *Agent) AppTask(id string) (session.AppTaskView, bool, error) { + raw, err := a.c.call(nil, MethodAppTask, PlanTaskArgs{ID: id}) + if err != nil { + return session.AppTaskView{}, false, err + } + var result appTaskPageResult + err = json.Unmarshal(raw, &result) + return result.Task, result.Found, err +} +func (a *Agent) AppTaskAction(intent session.AppTaskIntent) error { + _, err := a.c.call(nil, MethodAppTaskAction, intent) + return err +} +func (a *Agent) AppQuickTasks() ([]session.AppQuickTaskView, error) { + raw, err := a.c.call(nil, MethodAppQuickTasks, nil) + if err != nil { + return nil, err + } + var rows []session.AppQuickTaskView + err = json.Unmarshal(raw, &rows) + return rows, err +} +func (a *Agent) AppQuickTaskAction(intent session.AppTaskIntent) ([]string, []string, error) { + raw, err := a.c.call(nil, MethodAppQuickTaskAction, intent) + if err != nil { + return nil, nil, err + } + var result appQuickActionResult + err = json.Unmarshal(raw, &result) + return result.Undone, result.Changed, err +} + +// MoveTurnToTask asks the engine to move the running reply to a task. +func (a *Agent) MoveTurnToTask() error { + _, err := a.c.call(nil, MethodAppMoveToTask, nil) + return err +} diff --git a/internal/remote/wire_app_task_test.go b/internal/remote/wire_app_task_test.go new file mode 100644 index 0000000000..c373ef7d75 --- /dev/null +++ b/internal/remote/wire_app_task_test.go @@ -0,0 +1,101 @@ +package remote + +import ( + "encoding/json" + "errors" + "reflect" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/session" +) + +type appTasksFake struct { + rows []session.AppTaskView + quick []session.AppQuickTaskView + intent session.AppTaskIntent + failure error +} + +func (f *appTasksFake) AppTasks() ([]session.AppTaskView, error) { return f.rows, f.failure } +func (f *appTasksFake) MoveTurnToTask() error { return f.failure } +func (f *appTasksFake) AppTask(id string) (session.AppTaskView, bool, error) { + return f.rows[0], id == f.rows[0].ID, f.failure +} +func (f *appTasksFake) AppTaskAction(intent session.AppTaskIntent) error { + f.intent = intent + return f.failure +} +func (f *appTasksFake) AppQuickTasks() ([]session.AppQuickTaskView, error) { return f.quick, f.failure } +func (f *appTasksFake) AppQuickTaskAction(intent session.AppTaskIntent) ([]string, []string, error) { + f.intent = intent + return nil, []string{"changed.txt"}, f.failure +} + +func TestAppTaskWireCarriesHashesReceiptsAndRefusals(t *testing.T) { + client, engine := newEngine(t) + step := 9 + state := "your_call" + version := uint64(3) + row := session.AppTaskView{ID: "7", Title: "Edit", State: state, Notes: []session.AppTaskNote{{Text: "words", ReadAtStep: &step}}, Cap: 2, Version: version} + quick := session.AppQuickTaskView{ID: "8", State: "done", Files: []session.AppQuickFile{{Path: "one.txt", BeforeHash: "before", AfterHash: "after"}}} + engine.answers[MethodAppTasks] = []session.AppTaskView{row} + engine.answers[MethodAppTask] = appTaskPageResult{row, true} + engine.answers[MethodAppQuickTasks] = []session.AppQuickTaskView{quick} + engine.answers[MethodAppQuickTaskAction] = appQuickActionResult{Changed: []string{"changed.txt"}} + if got, err := client.Agent().AppTasks(); err != nil || !reflect.DeepEqual(got, []session.AppTaskView{row}) { + t.Fatalf("tasks %+v %v", got, err) + } + if got, found, err := client.Agent().AppTask("7"); err != nil || !found || !reflect.DeepEqual(got, row) { + t.Fatalf("page %+v %v %v", got, found, err) + } + if got, err := client.Agent().AppQuickTasks(); err != nil || !reflect.DeepEqual(got, []session.AppQuickTaskView{quick}) { + t.Fatalf("quick %+v %v", got, err) + } + intent := session.AppTaskIntent{ID: "7", Action: "decide", Verb: "tell", Text: "words", State: &state, Version: &version} + if err := client.Agent().AppTaskAction(intent); err != nil { + t.Fatal(err) + } + undone, changed, err := client.Agent().AppQuickTaskAction(session.AppTaskIntent{ID: "8", Action: "undo"}) + if err != nil || len(undone) != 0 || !reflect.DeepEqual(changed, []string{"changed.txt"}) { + t.Fatalf("undo %v %v %v", undone, changed, err) + } + var got session.AppTaskIntent + if err := json.Unmarshal(engine.calls(MethodAppTaskAction)[0].Payload, &got); err != nil || !reflect.DeepEqual(got, intent) { + t.Fatalf("intent %+v %v", got, err) + } + engine.fails[MethodAppTasks] = "engine: cannot read tasks" + if _, err := client.Agent().AppTasks(); err == nil || !strings.Contains(err.Error(), "cannot read tasks") { + t.Fatalf("read lost refusal %v", err) + } +} + +func TestAppTaskEngineDispatchAndDriverClassification(t *testing.T) { + fake := &appTasksFake{rows: []session.AppTaskView{{ID: "7", State: "proposed", Cap: 2}}, quick: []session.AppQuickTaskView{{ID: "8", State: "done"}}} + for _, method := range []string{MethodAppTasks, MethodAppTask, MethodAppTaskAction, MethodAppQuickTasks, MethodAppQuickTaskAction} { + payload, _ := json.Marshal(session.AppTaskIntent{ID: "7", Action: "hold"}) + if method == MethodAppTask { + payload, _ = json.Marshal(PlanTaskArgs{ID: "7"}) + } + if _, err := dispatchAppTask(fake, Frame{Method: method, Payload: payload}); err != nil { + t.Fatalf("%s: %v", method, err) + } + mutating := method == MethodAppTaskAction || method == MethodAppQuickTaskAction + if mutating && classify(method) != classAct { + t.Fatalf("%s can queue behind the turn it must release", method) + } + if mutating && watcherMay(method) { + t.Fatalf("reader can mutate with %s", method) + } + if !mutating && (classify(method) != classGetter || !watcherMay(method)) { + t.Fatalf("%s is not a read", method) + } + } + fake.failure = errors.New("stale: task changed") + if _, err := dispatchAppTask(fake, Frame{Method: MethodAppTasks}); err == nil { + t.Fatal("dispatch lost refusal") + } + if _, err := dispatchAppTask(struct{}{}, Frame{Method: MethodAppTasks}); err == nil { + t.Fatal("missing engine door became an empty list") + } +} diff --git a/internal/session/agent.go b/internal/session/agent.go index 79522cba80..6a13a49357 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -1800,6 +1800,9 @@ func (a *Agent) startTurnLocked(ctx context.Context, user userMessage, watcher * a.refreshSystemLocked() hub := a.newReplayHubLocked() a.hub = hub + // An ask to move a reply that ended before a boundary is not carried into + // the next reply. + a.moveAsked.Store(false) turnCtx, cancel := context.WithCancelCause(ctx) a.cancel = cancel // AND THE TURN CARRIES ITS OWN SECOND STAGE (abandon.go). The signal rides on @@ -2597,6 +2600,7 @@ func (a *Agent) Close() error { if a.jobs != nil { a.jobs.shutdown(jobShutdownGrace) } + a.finishProcessRecords() // AND THE SESSION STOPS SAYING IT IS HERE, and takes its presence file with // it (taskpresence.go). It happens after the jobs round because work still diff --git a/internal/session/app_process_output.go b/internal/session/app_process_output.go new file mode 100644 index 0000000000..7785370dc1 --- /dev/null +++ b/internal/session/app_process_output.go @@ -0,0 +1,121 @@ +package session + +import ( + "bytes" + "fmt" + "io" + "regexp" +) + +// trimAppWindowLocked drops oldest complete lines in a bounded batch. Keeping +// the live inode preserves its writer lease and durable id reservation. A line +// longer than the whole limit is cut by bytes so arbitrary output stays bounded. +func (s *jobSink) trimAppWindowLocked() bool { + drop := s.chunkBytes / 8 + if drop < 1 { + drop = 1 + } + probe := make([]byte, 32<<10) + boundary := int64(-1) + for offset := drop; offset < s.spoolBytes; { + size := min(int64(len(probe)), s.spoolBytes-offset) + n, err := s.file.ReadAt(probe[:size], offset) + if err != nil { + s.breakSpoolLocked(err) + return false + } + if i := bytes.IndexByte(probe[:n], '\n'); i >= 0 { + boundary = offset + int64(i+1) + break + } + offset += int64(n) + } + if boundary >= 0 { + drop = boundary + } else { + // An unfinished tail may follow a few complete old lines. Remove those + // first; only a line occupying the whole window needs a byte cut. + last := int64(-1) + for offset := int64(0); offset < drop; { + size := min(int64(len(probe)), drop-offset) + n, err := s.file.ReadAt(probe[:size], offset) + if err != nil { + s.breakSpoolLocked(err) + return false + } + if i := bytes.LastIndexByte(probe[:n], '\n'); i >= 0 { + last = offset + int64(i+1) + } + offset += int64(n) + } + if last > 0 { + drop = last + } + } + remaining := s.spoolBytes - drop + if remaining < 0 { + remaining = 0 + } + buf := make([]byte, 32<<10) + for offset := int64(0); offset < remaining; { + size := int64(len(buf)) + if remaining-offset < size { + size = remaining - offset + } + n, err := s.file.ReadAt(buf[:size], drop+offset) + if err != nil { + s.breakSpoolLocked(err) + return false + } + written, err := s.file.WriteAt(buf[:n], offset) + if err != nil || written != n { + s.breakSpoolLocked(fmt.Errorf("process output trim: %w", errorsOrShort(err))) + return false + } + offset += int64(n) + } + if err := s.file.Truncate(remaining); err != nil { + s.breakSpoolLocked(err) + return false + } + if _, err := s.file.Seek(remaining, io.SeekStart); err != nil { + s.breakSpoolLocked(err) + return false + } + s.spoolBytes = remaining + s.noticeText = "log truncated: only the most recent " + spoolSizeText(s.chunkBytes) + " is kept" + return true +} +func errorsOrShort(err error) error { + if err != nil { + return err + } + return io.ErrShortWrite +} + +var localProcessURL = regexp.MustCompile(`(?:https?://)?(?:localhost|127\.0\.0\.1):[0-9]{1,5}(?:/[^\s\x00-\x20]*)?`) + +// Capture the first address before compaction can discard it. A tiny overlap +// with the old ring covers an address split across stdout writes. +func (s *jobSink) captureProcessURLLocked(data []byte) { + if s.url != "" { + return + } + start := len(s.ring) - 128 + if start < 0 { + start = 0 + } + end := len(data) + if end > 128 { + end = 128 + } + overlap := append(append([]byte{}, s.ring[start:]...), data[:end]...) + if match := localProcessURL.Find(overlap); match != nil { + s.url = string(match) + return + } + if match := localProcessURL.Find(data); match != nil { + s.url = string(match) + } +} +func (s *jobSink) processURL() string { s.mu.Lock(); defer s.mu.Unlock(); return s.url } diff --git a/internal/session/app_processes.go b/internal/session/app_processes.go new file mode 100644 index 0000000000..d7deddfdc2 --- /dev/null +++ b/internal/session/app_processes.go @@ -0,0 +1,194 @@ +package session + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "sort" + "strings" + "sync" + "time" +) + +// AppProcessOutputBytes is the disk limit for the app's process output. The +// terminal keeps its existing chunk policy; both readers keep the 64 KiB ring. +const AppProcessOutputBytes = 10 << 20 +const AppProcessOutputRetention = 7 * 24 * time.Hour + +// A conversation has one engine writer. This lock orders its asynchronous +// naming and exit updates so a late name cannot overwrite a settled process. +var processRecordMu sync.Mutex + +func processRecordPath(file string) string { + return filepath.Join(filepath.Dir(file), "processes.json") +} + +// ReadProcessNotices reads the durable shelf without opening a conversation or +// reviving its work. Output expiry never removes these ended process records. +func ReadProcessNotices(file string) ([]JobNotice, error) { + if file == "" { + return []JobNotice{}, nil + } + raw, err := os.ReadFile(processRecordPath(file)) + if errors.Is(err, os.ErrNotExist) { + return []JobNotice{}, nil + } + if err != nil { + return nil, err + } + var rows []JobNotice + err = json.Unmarshal(raw, &rows) + return rows, err +} + +func (a *Agent) keepProcessNotice(n JobNotice) { + if n.Kind == JobKindTask || a.SessionPath() == "" { + return + } + processRecordMu.Lock() + defer processRecordMu.Unlock() + a.mu.Lock() + closed := a.closed + a.mu.Unlock() + if closed { + return + } + rows, err := ReadProcessNotices(a.SessionPath()) + if err != nil { + return + } + found := false + for i, old := range rows { + if old.ID != n.ID { + continue + } + if old.Over() && !n.Over() { + return + } + rows[i], found = n, true + } + if !found { + rows = append(rows, n) + } + writeProcessRecords(a.SessionPath(), rows) +} + +func writeProcessRecords(file string, rows []JobNotice) { + raw, err := json.Marshal(rows) + if err != nil { + return + } + path := processRecordPath(file) + tmp, err := os.CreateTemp(filepath.Dir(path), ".processes-*") + if err != nil { + return + } + defer os.Remove(tmp.Name()) + _ = tmp.Chmod(0600) + _, writeErr := tmp.Write(raw) + syncErr := tmp.Sync() + closeErr := tmp.Close() + if writeErr == nil && syncErr == nil && closeErr == nil { + _ = os.Rename(tmp.Name(), path) + } +} + +// JobNotices joins the saved shelf with the live registry. A saved running row +// whose process no longer exists is stopped, rather than revived by a read. +func (a *Agent) JobNotices() ([]JobNotice, error) { + saved, err := ReadProcessNotices(a.SessionPath()) + if err != nil { + return nil, err + } + live := a.liveJobNotices() + out := make(map[int]JobNotice, len(saved)+len(live)) + for _, n := range saved { + if n.State == JobRunning { + n.State = JobStopped + } + out[n.ID] = n + } + for id, n := range live { + out[id] = n + } + rows := make([]JobNotice, 0, len(out)) + for _, n := range out { + rows = append(rows, n) + } + sort.Slice(rows, func(i, j int) bool { return rows[i].ID > rows[j].ID }) + return rows, nil +} + +// RerunProcess takes the retained command from this engine's shelf, never a +// client-supplied shell string. Only ended shell commands can be run again. +func (a *Agent) RerunProcess(id int) (JobNotice, error) { + rows, err := a.JobNotices() + if err != nil { + return JobNotice{}, err + } + for _, n := range rows { + if n.ID != id { + continue + } + if !n.Over() || n.Kind != JobKindCommand || strings.TrimSpace(n.Command) == "" { + return JobNotice{}, errors.New("only ended commands can be run again") + } + started, err := a.jobs.start(n.Command) + if err != nil { + return JobNotice{}, err + } + return noticeOf(started.info()), nil + } + return JobNotice{}, errors.New("no such process") +} + +// SweepProcessOutput expires only completed managed logs. Writer flocks keep +// active output safe, and the shelf and durable id history are never swept. +func SweepProcessOutput(file string, now time.Time) error { + if file == "" { + return nil + } + root := jobRetentionAnchor(filepath.Dir(file)) + for _, dir := range []string{filepath.Join(root, placeLogs), filepath.Join(root, placeLogs, droppingJobs)} { + info, err := os.Lstat(dir) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + if !info.IsDir() { + return errRetentionUnsafe + } + } + err := jobRetentionSweepBefore(filepath.Join(root, placeLogs, droppingJobs), defaultJobRetentionBudget(), now.Add(-AppProcessOutputRetention)) + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err +} + +// Close fences asynchronous exit and naming announcements before its final +// shelf write. The record mutex joins writes already admitted; the closed flag +// refuses later ones, so no callback can recreate a directory after cleanup. +func (a *Agent) finishProcessRecords() { + if a.SessionPath() == "" { + return + } + processRecordMu.Lock() + defer processRecordMu.Unlock() + rows, err := a.JobNotices() + if err != nil { + return + } + if len(rows) == 0 { + return + } + for i := range rows { + if rows[i].State == JobRunning { + rows[i].State = JobStopped + } + } + writeProcessRecords(a.SessionPath(), rows) +} diff --git a/internal/session/app_processes_test.go b/internal/session/app_processes_test.go new file mode 100644 index 0000000000..f406dea281 --- /dev/null +++ b/internal/session/app_processes_test.go @@ -0,0 +1,211 @@ +package session + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/config" +) + +func TestAppProcessOutputCapKeepsNewestLinesAndWriterLease(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "1.log") + file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + t.Fatal(err) + } + sink := newJobSink(file, path) + sink.appWindow = true + sink.chunkBytes = AppProcessOutputBytes + defer sink.close() + original, _ := file.Stat() + line := []byte(strings.Repeat("x", 1023) + "\n") + if _, err := sink.Write(bytes.Repeat(line, 12<<10)); err != nil { + t.Fatal(err) + } + _, _ = sink.Write([]byte("first server localhost:4321\n")) + _, _ = sink.Write([]byte("other server localhost:5000\n")) + _, _ = sink.Write([]byte("the last line\n")) + if sink.processURL() != "localhost:4321" { + t.Fatalf("first URL: %q", sink.processURL()) + } + info, _ := file.Stat() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if info.Size() > AppProcessOutputBytes || !os.SameFile(original, info) || !bytes.HasSuffix(raw, []byte("the last line\n")) { + t.Fatalf("cap=%d inode=%v suffix=%v", info.Size(), os.SameFile(original, info), bytes.HasSuffix(raw, []byte("the last line\n"))) + } + if len(raw) > 0 && raw[0] != 'x' { + t.Fatal("compaction left partial line") + } + if !strings.Contains(sink.notice(), "truncated") { + t.Fatal("lost output was not disclosed") + } + if _, err := os.Stat(path + ".1"); !os.IsNotExist(err) { + t.Fatal("app output allocated a second log") + } + if len(sink.tail(20)) > jobRingBytes { + t.Fatal("model output ring grew") + } +} +func TestAppProcessOutputSevenDaysKeepsActiveAndShelf(t *testing.T) { + defer config.UseAppCredentials(config.NewAppCredentials())() + dir := t.TempDir() + registry := newJobRegistry(dir, Place{Dir: dir}, nil) + active, err := registry.newJob("active", jobKindBash) + if err != nil { + t.Fatal(err) + } + defer active.sink.close() + ended, err := registry.newJob("ended", jobKindBash) + if err != nil { + t.Fatal(err) + } + ended.sink.close() + registry.joinRetention() + now := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC) + old := now.Add(-AppProcessOutputRetention - time.Second) + for _, p := range []string{active.logPath, ended.logPath} { + if err := os.Chtimes(p, old, old); err != nil { + t.Fatal(err) + } + } + legacy := filepath.Join(filepath.Dir(active.logPath), "999.log") + _ = os.WriteFile(legacy, []byte("older writer"), 0600) + _ = os.Chtimes(legacy, old, old) + shelf := filepath.Join(dir, "processes.json") + _ = os.WriteFile(shelf, []byte("[]"), 0600) + if err := SweepProcessOutput(filepath.Join(dir, "transcript.jsonl"), now); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(ended.logPath); !os.IsNotExist(err) { + t.Fatal("expired ended output kept") + } + for _, p := range []string{active.logPath, legacy, shelf} { + if _, err := os.Stat(p); err != nil { + t.Fatalf("expired protected %s: %v", p, err) + } + } + if budget := defaultJobRetentionBudget(); budget.groups == 64 || budget.bytes == 128<<20 { + t.Fatal("terminal directory budget shortened the app's seven-day promise") + } +} +func TestAppProcessesStartStopRerunAndRecover(t *testing.T) { + defer config.UseAppCredentials(config.NewAppCredentials())() + dir := t.TempDir() + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { c.SessionFile = filepath.Join(dir, "transcript.jsonl"); c.Place = Place{Dir: dir} }) + lane := rosterLane(t, agent) + id := startJob(t, agent, "printf 'process output\\n'") + awaitJob(t, lane, JobDone) + rows, err := agent.JobNotices() + if err != nil { + t.Fatal(err) + } + var first JobNotice + for _, n := range rows { + if n.ID == id { + first = n + } + } + if first.Command == "" || first.State != JobDone || first.ExitCode != 0 { + t.Fatalf("process: %+v", first) + } + rerun, err := agent.RerunProcess(id) + if err != nil { + t.Fatal(err) + } + if rerun.ID == id || rerun.Command != first.Command { + t.Fatalf("rerun: %+v", rerun) + } + awaitJob(t, lane, JobDone) + live := startJob(t, agent, "sleep 60") + if _, err := agent.RerunProcess(live); err == nil { + t.Fatal("reran a live process") + } + if _, err := agent.Cancel("job:" + strconv.Itoa(live)); err != nil { + t.Fatal(err) + } + awaitJob(t, lane, JobStopped) + if err := agent.Close(); err != nil { + t.Fatal(err) + } + saved, err := ReadProcessNotices(agent.SessionPath()) + if err != nil { + t.Fatal(err) + } + if len(saved) < 3 { + t.Fatalf("lost process shelf: %v", saved) + } + for _, n := range saved { + if n.ID == id && n.Command != first.Command { + t.Fatal("lost rerun command") + } + } +} +func TestAppProcessShelfMarksInterruptedRowsStopped(t *testing.T) { + dir := t.TempDir() + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { c.SessionFile = filepath.Join(dir, "transcript.jsonl"); c.Place = Place{Dir: dir} }) + path := processRecordPath(agent.SessionPath()) + n := JobNotice{ID: 72, Command: "npm run dev", Kind: JobKindCommand, State: JobRunning, Started: time.Now()} + raw, _ := json.Marshal([]JobNotice{n}) + if err := os.WriteFile(path, raw, 0600); err != nil { + t.Fatal(err) + } + rows, err := agent.JobNotices() + if err != nil || len(rows) != 1 || rows[0].State != JobStopped { + t.Fatalf("recovery: %+v %v", rows, err) + } +} + +func TestAppProcessOutputDropsWholeLongLines(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "1.log") + file, err := os.OpenFile(path, os.O_CREATE|os.O_RDWR, 0600) + if err != nil { + t.Fatal(err) + } + sink := newJobSink(file, path) + sink.appWindow = true + sink.chunkBytes = 128 << 10 + defer sink.close() + raw := append(bytes.Repeat([]byte("a"), 100<<10), '\n') + raw = append(raw, append(bytes.Repeat([]byte("b"), 100<<10), '\n')...) + _, _ = sink.Write(raw) + output, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if len(output) > 128<<10 || bytes.Contains(output, []byte("a")) || !bytes.HasSuffix(output, []byte("b\n")) { + t.Fatalf("long line was cut instead of dropped: bytes=%d", len(output)) + } +} + +func TestAppProcessCloseFencesLateRecords(t *testing.T) { + dir := t.TempDir() + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { c.SessionFile = filepath.Join(dir, "transcript.jsonl"); c.Place = Place{Dir: dir} }) + id := startJob(t, agent, "sleep 60") + if err := agent.Close(); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(processRecordPath(agent.SessionPath())) + if err != nil { + t.Fatal(err) + } + rows, err := ReadProcessNotices(agent.SessionPath()) + if err != nil || len(rows) != 1 || rows[0].ID != id || rows[0].State != JobStopped { + t.Fatalf("close shelf: %+v %v", rows, err) + } + agent.keepProcessNotice(JobNotice{ID: 999, State: JobRunning, Kind: JobKindCommand, Command: "late callback"}) + after, err := os.ReadFile(processRecordPath(agent.SessionPath())) + if err != nil || !bytes.Equal(before, after) { + t.Fatalf("late record wrote after Close: %v", err) + } +} diff --git a/internal/session/assignment.go b/internal/session/assignment.go index 431718c41a..151183e3e0 100644 --- a/internal/session/assignment.go +++ b/internal/session/assignment.go @@ -133,8 +133,10 @@ const ( // taskDirection is one line said to a running task, as the node's record keeps // it: what was said, when, by whom, and what became of it. type taskDirection struct { - id uint64 - words string + // The receipt names the next step only when a request carries these words. + readAtStep *int + id uint64 + words string // at is WHEN THE PERSON SAID IT, which for a line typed into this task's room // is when it arrived and for a forwarded one is when they typed it in the // conversation (task_forward.go). It is the order their corrections are @@ -760,8 +762,21 @@ func (a *Agent) markDirectionsCarried(ids []uint64) { return } node.graph.mu.Lock() + recorder := node.room.recorder() + node.graph.mu.Unlock() + live := recorder.state(0) + node.graph.mu.Lock() node.assignment.markCarried(ids) + for i := range node.assignment.directions { + for _, id := range ids { + if node.assignment.directions[i].id == id && node.assignment.directions[i].readAtStep == nil { + step := live.Steps + 1 + node.assignment.directions[i].readAtStep = &step + } + } + } node.graph.mu.Unlock() + node.graph.checkpoint() } // reviseAssignment applies one of the person's directions and answers the new @@ -890,13 +905,14 @@ func recordedAssignment(a taskAssignment) *assignmentRecord { } for _, said := range a.directions { record.Directions = append(record.Directions, directionRecord{ - ID: said.id, - Words: said.words, - At: said.at, - From: said.from.String(), - State: string(said.state), - Version: said.version, - Source: recordedSource(said.source), + ID: said.id, + Words: said.words, + At: said.at, + From: said.from.String(), + State: string(said.state), + ReadAtStep: said.readAtStep, + Version: said.version, + Source: recordedSource(said.source), }) } return record @@ -928,13 +944,14 @@ func restoredAssignment(record *assignmentRecord) taskAssignment { } for _, said := range record.Directions { restored.directions = append(restored.directions, taskDirection{ - id: said.ID, - words: said.Words, - at: said.At, - from: directionFromWord(said.From), - state: directionStateWord(said.State), - version: said.Version, - source: restoredSource(said.Source), + id: said.ID, + words: said.Words, + at: said.At, + from: directionFromWord(said.From), + state: directionStateWord(said.State), + readAtStep: said.ReadAtStep, + version: said.Version, + source: restoredSource(said.Source), }) // A record written by a build with a different idea of the counter still // leaves this one able to mint ids nothing else holds. diff --git a/internal/session/checkpoint.go b/internal/session/checkpoint.go index d7e532d689..9cb5f2d7b2 100644 --- a/internal/session/checkpoint.go +++ b/internal/session/checkpoint.go @@ -599,6 +599,26 @@ const checkpointSketchAsk = "[checkpoint] Above is what was asked and what has b // "can" and not "will". const checkpointCeilingNote = "this is running long · moving it to a task that is watched and can split" +// checkpointAskedNote is the line a reply the person moved to a task leaves. +const checkpointAskedNote = "moved to a task, as you asked · it is watched and can split" + +// errNothingToMove is [Agent.MoveTurnToTask]'s answer when no reply is running. +var errNothingToMove = errors.New("no reply is running to move") + +// MoveTurnToTask asks that the running reply move to a task at its next step +// boundary. It answers at once; the task arrives on the task lane like any +// other, and a reply that ends first simply ends. +func (a *Agent) MoveTurnToTask() error { + a.mu.Lock() + running := a.hub != nil + a.mu.Unlock() + if !running { + return errNothingToMove + } + a.moveAsked.Store(true) + return nil +} + // checkpointHandoffAsk is what the model is asked for on the way out, and it is // the LAST thing this turn does with it. // @@ -2144,6 +2164,9 @@ const ( // every drop above because nothing was decided about the WORK: the seam has // no owner to admit a task on behalf of ([Agent.handOverRunningTurn]). checkpointCeilingAbandoned = "dropped:turn-abandoned" + // checkpointCeilingAppHolds is a reply in the desktop app, which only the + // person moves to a task ([Config.HandOverOnAsk]). + checkpointCeilingAppHolds = "dropped:the-person-moves-it" ) // THE LAW: ONE ENDING ROW PER ENDING, WRITTEN AT THE SEAM THAT TOOK IT. @@ -2175,6 +2198,8 @@ const ( checkpointSeamCeiling = "ceiling" checkpointSeamWall = "wall" checkpointSeamCarry = "carry-on" + // checkpointSeamAsked is the person moving the reply ([Agent.MoveTurnToTask]). + checkpointSeamAsked = "asked" ) // journalCarryOnAwaited writes down the one ending the carry-on road takes on @@ -2884,6 +2909,17 @@ func checkpointLastSaid(messages []ai.Message) string { // injected, nothing is said, the model is not told it was looked at, and the // meter simply walks on to the next mark. func (a *Agent) checkpointRound(ctx context.Context, hub *eventHub, user userMessage, meter *checkpointMeter, turn *Usage, started time.Time, model string, calls []ai.ToolCall, taken *Decision, aside *markAside) bool { + // IN THE DESKTOP APP THE PERSON MOVES A RUNNING REPLY, NOT THE CLOCK. No + // mark, ceiling, write seam or wall share moves it on its own; the app says + // the reply is running long and the person's ask is taken here, at the + // first boundary after it, through the one door every other move uses. + if a.config.HandOverOnAsk { + if !a.moveAsked.Swap(false) { + return false + } + return a.handOverRunningTurn(ctx, hub, turn, started, model, + checkpointAskedNote, checkpointSeamAsked, meter.rounds, meter, meter.raced, checkpointRead{}, taken).moved + } if !a.checkpoints(ctx, user) { return false } @@ -4177,6 +4213,11 @@ func (a *Agent) handOverRunningTurn(ctx context.Context, hub *eventHub, turn *Us if ctx.Err() != nil { return checkpointHandover{decision: checkpointCeilingAbandoned} } + // IN THE DESKTOP APP ONLY THE PERSON MOVES A REPLY ([Config.HandOverOnAsk]): + // every other road into this function leaves the turn running. + if a.config.HandOverOnAsk && seam != checkpointSeamAsked { + return checkpointHandover{decision: checkpointCeilingAppHolds} + } // THE FLOOR STANDS IN FRONT OF EVERYTHING ELSE. A caller that reached here // through [Agent.checkpoints] already asked, but looped.go's looping // handoff and any future door share this function, and a handover that diff --git a/internal/session/jobnotice.go b/internal/session/jobnotice.go index e6e3921f4a..a6a6a99c3c 100644 --- a/internal/session/jobnotice.go +++ b/internal/session/jobnotice.go @@ -109,6 +109,11 @@ func (s JobState) Over() bool { return s != JobRunning } // the difference between this and what it replaces — the packed report was the // engine choosing a surface's wording, and it fitted no width in particular. type JobNotice struct { + // OutputTruncated survives an ended process's shelf so a later full-window + // read still discloses that older output was discarded or could not be kept. + OutputTruncated bool + // URL keeps the first local server address even after its output line expires. + URL string // ID is the job's own number, and it is the ONLY id it has. It is what // `jobs output 3` and `jobs kill 3` take, what [Agent.Cancel] takes as // `job:3`, and what a person reads on the row. One thing, one number. @@ -181,17 +186,19 @@ func (n JobNotice) Label() string { // the one place the two vocabularies meet. func noticeOf(info jobInfo) JobNotice { return JobNotice{ - ID: info.id, - Name: jobNameOf(info), - Command: info.command, - Detail: info.detail, - Kind: jobKindWord(info.kind), - State: jobStateWord(info), - LogPath: info.logPath, - Started: info.started, - Elapsed: info.elapsed, - ExitCode: info.code, - Ticks: info.ticks, + ID: info.id, + OutputTruncated: info.outputTruncated, + URL: info.url, + Name: jobNameOf(info), + Command: info.command, + Detail: info.detail, + Kind: jobKindWord(info.kind), + State: jobStateWord(info), + LogPath: info.logPath, + Started: info.started, + Elapsed: info.elapsed, + ExitCode: info.code, + Ticks: info.ticks, } } diff --git a/internal/session/jobretention.go b/internal/session/jobretention.go index 865b697ca2..993d04f636 100644 --- a/internal/session/jobretention.go +++ b/internal/session/jobretention.go @@ -17,6 +17,7 @@ import ( "strings" "time" + "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/filelock" ) @@ -35,7 +36,14 @@ type jobRetentionBudget struct { groups int } -func defaultJobRetentionBudget() jobRetentionBudget { return jobRetentionBudget{128 << 20, 64} } +func defaultJobRetentionBudget() jobRetentionBudget { + // App output has a per-process cap and a seven-day lifetime. A terminal's + // directory budget must not silently shorten that promised lifetime. + if config.AppMode() { + return jobRetentionBudget{1<<63 - 1, int(^uint(0) >> 1)} + } + return jobRetentionBudget{128 << 20, 64} +} // Only caller-selected roots are resolved. Never resolve an entire jobs path: // a link planted inside the owned log subtree must still fail closed. diff --git a/internal/session/jobrow.go b/internal/session/jobrow.go index 7fffd85d32..a149aeb857 100644 --- a/internal/session/jobrow.go +++ b/internal/session/jobrow.go @@ -208,6 +208,7 @@ func (a *Agent) announceJobRow(info jobInfo) { // outlives the turn that started it for the same reason a node does and the // subscribers who already hold that lane are who will draw it. func (a *Agent) emitJobUpdate(notice JobNotice) { + a.keepProcessNotice(notice) event := Event{Kind: EventJobUpdate, Job: ¬ice} a.mu.Lock() hub := a.hub diff --git a/internal/session/jobs.go b/internal/session/jobs.go index 218b1d67ee..824f00c545 100644 --- a/internal/session/jobs.go +++ b/internal/session/jobs.go @@ -63,6 +63,7 @@ import ( "syscall" "time" + "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/exec/bare" "github.com/Agent-Field/codeaf/internal/processgroup" ) @@ -256,11 +257,13 @@ type job struct { // jobInfo is a job's status copied out from under its lock, so rendering never // holds it. type jobInfo struct { - id int - command string - kind jobKind - label string - detail string + outputTruncated bool + url string + id int + command string + kind jobKind + label string + detail string // name is the short name the job is CALLED — the label where the registry // minted one, and otherwise whatever the cheap namer answered (jobname.go). // It is empty until there is one: naming is an errand and the work never @@ -286,12 +289,18 @@ type jobInfo struct { func (j *job) info() jobInfo { j.mu.Lock() defer j.mu.Unlock() + truncated := false + url := "" + if j.sink != nil { + truncated = j.sink.notice() != "" + url = j.sink.processURL() + } elapsed := time.Since(j.started) if j.state != jobRunning { elapsed = j.ended.Sub(j.started) } return jobInfo{ - id: j.id, command: j.command, kind: j.kind, label: j.label, detail: j.detail, + id: j.id, command: j.command, kind: j.kind, label: j.label, detail: j.detail, outputTruncated: truncated, url: url, name: j.name, logPath: j.logPath, dir: j.dir, @@ -655,6 +664,10 @@ func (r *jobRegistry) newJob(command string, kind jobKind) (*job, error) { done: make(chan struct{}), } started.sink = newJobSink(logFile, spoolPath) + if config.AppMode() { + started.sink.appWindow = true + started.sink.chunkBytes = AppProcessOutputBytes + } // THE ORIGIN IS TAKEN AT LAUNCH, ONCE. A job's real ending may arrive long // after the turn that started it, and it must be stamped with the turn that // owns it rather than the one that happens to be live when it settles. @@ -1404,6 +1417,8 @@ type jobSink struct { file *os.File closed bool base string + url string + appWindow bool chunkBytes int64 spoolBytes int64 spoolBroken bool @@ -1438,6 +1453,7 @@ func newJobSink(file *os.File, base string) *jobSink { func (s *jobSink) Write(data []byte) (int, error) { s.mu.Lock() defer s.mu.Unlock() + s.captureProcessURLLocked(data) if s.file != nil && !s.closed { s.spoolLocked(data) } @@ -1507,6 +1523,9 @@ func (s *jobSink) spoolWriteLocked(piece []byte) (int, error) { // previous backup is removed before the copy, so even during rotation there // are at most two chunks. A backup is only removed if this sink created it. func (s *jobSink) rotateLocked() bool { + if s.appWindow { + return s.trimAppWindowLocked() + } root, err := os.OpenRoot(filepath.Dir(s.base)) if err != nil { s.breakSpoolLocked(err) diff --git a/internal/session/loop.go b/internal/session/loop.go index a6226f5e02..d73bbfcccc 100644 --- a/internal/session/loop.go +++ b/internal/session/loop.go @@ -1836,6 +1836,14 @@ func (a *Agent) completeWithRetry(ctx context.Context, hub *eventHub, model stri } func (a *Agent) completeWithRetryReasoning(ctx context.Context, hub *eventHub, model string, rung effort.Rung, partial *partialBuffer, reached *reachedThePerson, reasoning *reasoningBuffer, warm *warmBatch, forming *formingBatch, frozenToolHistory int) (*ai.Response, string, error) { + if node := a.config.taskControl; node != nil { + if err := node.appBoundary(ctx); err != nil { + return nil, model, err + } + if a.config.taskCapUSD > 0 && node.spend() >= a.config.taskCapUSD { + return nil, model, ErrSpendStopped{Action: config.CrewTaskCapAction(a.config.taskCapUSD)} + } + } // THIS CALL'S WORDS ARE A REPLY SOMEBODY READS, and it is the one place in // this package that can say so: every request that goes out through here is // the turn's own, and every gate, judge, title and memo is made from some @@ -3668,7 +3676,7 @@ func (a *Agent) dispatchTool(ctx context.Context, ep *episode, hub *eventHub, ca // somehow, and the id on the row is the only handle it has (promote.go). // It is set at this chokepoint rather than per tool, so the early warm // start carries it exactly as the batch does. - text, isError, err := tool.Execute(withCallID(ctx, call.ID), args) + text, isError, err := a.executeTaskTool(withCallID(ctx, call.ID), tool, running, args) // THE ROW'S CLOCK IS THIS CALL'S OWN CLOCK. The result cannot be sent // yet — it goes out with the batch, in call order, because that is the // order the transcript is written in — but the fact that this call is diff --git a/internal/session/prompts/quick.md b/internal/session/prompts/quick.md index 42e4bbb3ca..1a809a525c 100644 --- a/internal/session/prompts/quick.md +++ b/internal/session/prompts/quick.md @@ -7,8 +7,8 @@ WHERE YOUR CALLER WORKS, in their own directory. There is no worktree, no branch and nothing to merge, so anything you write is in their working copy the moment you write it, and anything you break is broken for them, now, while they are looking at it. Everything else the section says still holds — you own the -outcome, a direction sent to you arrives in your next turn, and there is nobody -to ask. +outcome, and a direction sent to you arrives in your next turn. The engine asks +before promoting this work into a full task. Where you were given a list of files you may write, those are the only ones you can touch, and another quick task may be waiting for you to finish with them. @@ -26,10 +26,11 @@ and begin it, and fold each answer as it comes rather than watching for it. Items that feed each other stay yours, in order. Whatever you start is smaller than you are, and your own last message is still what your caller reads. -Do not build, run tests or run a check unless an item says so. Nobody is going -to audit this and nothing is waiting on a green run: what was asked for is the -work, and a build nobody asked for is minutes of somebody's time spent on a -question nobody put. +Shell calls (including builds, tests and scripts), media creation, and a 31st +distinct file require promotion to a full task first. The engine asks and holds +the action; there is no countdown. When it is approved, this worker stops and +the full task continues in the same folder. Use read, write and edit for quick +work. Do not run a check merely to fill the list. YOUR LAST MESSAGE IS THE ANSWER. It is delivered to your caller whole, as it stands, and there is no report, no summary and no second chance to say it — so diff --git a/internal/session/session.go b/internal/session/session.go index c7eedca95e..0f3174930d 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -1216,6 +1216,11 @@ type Config struct { // ([FirstMessageName]) instead of asking a model, for the desktop app. DeterministicNames bool + // HandOverOnAsk is the desktop app's: a running reply is never moved to a + // task by the checkpoints on their own; it moves only when the person asks + // ([Agent.MoveTurnToTask]), and the app says the reply is running long. + HandOverOnAsk bool + // WaitForBeltSteps is for the run worker that enforces its limits and // delivers notes from tool-end events. Its sole event reader must close // Event.BeltStepHandled after processing each such event. Other agents @@ -2168,6 +2173,10 @@ type Config struct { // reason too: no surface sets it, the executor wires it from the node // (task_run.go's newTaskAgentOn). quickItems func(done int, add []string) string + // taskControl binds file receipts and step holds to the node that owns them. + taskControl *TaskNode + // The saved per-task cap applies across this node's worker and repair rounds. + taskCapUSD float64 // fixesDir is the project bucket the error→fix sidecar keeps its file in // (fixstore.go), and it is set only when this agent is a NODE. A node's @@ -2371,7 +2380,10 @@ type Agent struct { // that lock. Atomic publication keeps both roads on the same figure. liveSpendRail atomic.Uint64 liveSpendRailSet atomic.Bool - client Completer + // moveAsked is the person's ask to move the running reply to a task, taken + // at the next step boundary ([Agent.MoveTurnToTask]). + moveAsked atomic.Bool + client Completer // managedClient distinguishes the provider adapter built by New from a test // completer handed to newAgent. clientAccount is the resolved account the // adapter holds, so a service-set change can replace it before another call. diff --git a/internal/session/task.go b/internal/session/task.go index 3589d8c9c0..9fcfc82980 100644 --- a/internal/session/task.go +++ b/internal/session/task.go @@ -233,6 +233,7 @@ var taskSchemaJSON = `{"type":"object","properties":{` + expectsSchemaJSON + `,` + checksSchemaJSON + `,` + `"depends_on":{"type":"array","items":{"type":"integer"},"description":"Ids that must finish first, only ones propose_task returned in this session. Its brief is given their reports; an unknown or failed id refuses the proposal"},` + + `"irreversible":{"type":"boolean","description":"True for work that cannot be undone; it never starts on a clock"},` + `"wide":{"type":"boolean","description":"Optional. True when the work is wider than one pair of hands. Say true whenever you judged it broad; a wrong true costs nothing"},` + `"model":{"type":"string","description":"Optional, only where the person asked for one: a catalog id or part of one, never a class word, so resolve \"fast\" to a concrete model. A word fitting several is shown to the person to settle"},` + `"effort":{"type":"string","enum":["best","cheap"],"description":"Only when the person asked"},` + @@ -248,13 +249,14 @@ var taskSchemaWithoutViaJSON = strings.Replace(taskSchemaJSON, taskViaSchemaJSON // taskArguments is the wire form. type taskArguments struct { - Title string `json:"title"` - Summary string `json:"summary"` - Brief string `json:"brief"` - Deliverable string `json:"deliverable"` - Where string `json:"where"` - Ground string `json:"ground"` - Acceptance string `json:"acceptance"` + Irreversible bool `json:"irreversible,omitempty"` + Title string `json:"title"` + Summary string `json:"summary"` + Brief string `json:"brief"` + Deliverable string `json:"deliverable"` + Where string `json:"where"` + Ground string `json:"ground"` + Acceptance string `json:"acceptance"` // Expects is what this brief assumes is already true of the folder the // worker will get, checked before it is allowed to spend anything // (handoffcontract.go). It is optional and the harness never writes one. @@ -287,7 +289,9 @@ type taskArguments struct { // a person may still move afterwards is `model`, and only from inside the node's // own room — see the field itself. type taskSpec struct { - title string + // Irreversible work always waits for an explicit answer. + irreversible bool + title 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 @@ -1128,7 +1132,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), + irreversible: parsed.Irreversible, // 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) != "", @@ -1231,10 +1236,12 @@ func parseTaskArguments(args json.RawMessage) (taskSpec, string) { // [taskWait] is waiting. Holding closes hold exactly once but keeps answer alive, because // typing removes the clock rather than answering the question. type taskQuestion struct { - answer chan TaskAnswer - hold chan struct{} - held bool - notice TaskNotice + // Version distinguishes model and hold edits while the proposal is open. + appVersion uint64 + answer chan TaskAnswer + hold chan struct{} + held bool + notice TaskNotice } // ResolveTask answers one EventTaskProposal. A surface hands back the id the @@ -1270,15 +1277,14 @@ func (a *Agent) HoldTask(id uint64) { return } question.held = true + question.appVersion++ question.notice.Deadline = time.Time{} notice := question.notice hub := a.hub close(question.hold) a.mu.Unlock() - if hub != nil { - hub.send(Event{Kind: EventTaskProposal, Tool: "propose_task", Task: ¬ice}) - } + a.broadcastTaskProposal(hub, notice) } // openTask puts one proposal in front of the person, starts its clock, and @@ -1312,6 +1318,9 @@ func (a *Agent) HoldTask(id uint64) { // elsewhere is the preflight's one line about other windows already in these // files, or "" — a fact the card draws beside the work, not a reason to wait. func (a *Agent) openTask(ctx context.Context, id uint64, spec taskSpec, elsewhere string) (*taskWait, error) { + if owner := a.taskProposalOwner(spec); owner != a { + return owner.openTask(ctx, id, spec, elsewhere) + } a.mu.Lock() if a.closed { a.mu.Unlock() @@ -1321,6 +1330,13 @@ func (a *Agent) openTask(ctx context.Context, id uint64, spec taskSpec, elsewher // runs inside a turn, and the turn's fan-out is where its question is seen. hub := a.hub clock, countdown := a.taskClock(hub) + if spec.irreversible { + clock = false + if hub == nil && len(a.taskWatchers) == 0 { + a.mu.Unlock() + return nil, errors.New("this task needs your answer in a watching session") + } + } var deadline time.Time if clock { deadline = a.taskClockNow().Add(countdown) @@ -1467,12 +1483,9 @@ func (w *taskWait) decided(answer TaskAnswer) { hub := a.hub notice := w.question.notice a.mu.Unlock() - if hub == nil { - return - } notice.Deadline = time.Time{} notice.Decided = &answer - hub.send(Event{Kind: EventTaskProposal, Tool: "propose_task", Task: ¬ice}) + a.broadcastTaskProposal(hub, notice) } // answer is what the wait came to, waiting for it if it has not come to @@ -1521,9 +1534,7 @@ func (w *taskWait) withdraw() { a.mu.Unlock() notice.Deadline = time.Time{} notice.Withdrawn = taskWithdrawnReason - if hub != nil { - hub.send(Event{Kind: EventTaskProposal, Tool: "propose_task", Task: ¬ice}) - } + a.broadcastTaskProposal(hub, notice) } // taskClock is the clock's law from [Agent.openTask]'s comment, decided once @@ -1548,16 +1559,17 @@ func newTaskQuestion(id uint64, spec taskSpec, elsewhere string, deadline time.T answer: make(chan TaskAnswer, 1), hold: make(chan struct{}), notice: TaskNotice{ - ID: id, - Title: spec.title, - Summary: spec.summary, - Brief: spec.brief, - Acceptance: spec.acceptance, - Where: taskCardWhere(config, id, spec), - Ground: spec.ground, - Mode: spec.mode, - DependsOn: spec.dependsOn, - Deadline: deadline, + ID: id, + Title: spec.title, + Summary: spec.summary, + Brief: spec.brief, + Acceptance: spec.acceptance, + Where: taskCardWhere(config, id, spec), + Ground: spec.ground, + Mode: spec.mode, + DependsOn: spec.dependsOn, + Deadline: deadline, + Irreversible: spec.irreversible, // What it will run on, and — when one word fit more than one model // — what it could run on instead. A surface draws the first as a // fact and offers the second as a choice; both are settled by the @@ -1577,9 +1589,6 @@ func newTaskQuestion(id uint64, spec taskSpec, elsewhere string, deadline time.T // put it in. A nil hub is a headless run, and a headless run has nobody to // show a card to; the clock admits the work on its own. func (a *Agent) announceTask(hub *eventHub, question *taskQuestion) { - if hub == nil { - return - } // AND THE WARM LINE, WHERE THE HANDOFF CAME OUT OF THE WORK ITSELF. It // goes ABOVE the card because it is the reason the card is there, and it // is [EventNotice] — the dim one-liner a surface already draws for @@ -1590,15 +1599,11 @@ func (a *Agent) announceTask(hub *eventHub, question *taskQuestion) { // nobody groomed). Silence still starts it, so the person is told and the // work opens; what the card adds is a window to redirect, which is more // than an auto-start could give them and not less. - if a.alreadyWorking() { + if hub != nil && a.alreadyWorking() { hub.send(Event{Kind: EventNotice, Text: taskEscalationNote}) } notice := question.notice - hub.send(Event{ - Kind: EventTaskProposal, - Tool: "propose_task", - Task: ¬ice, - }) + a.broadcastTaskProposal(hub, notice) } // taskSilenceAdmits is what an expired clock has to ask before it may approve @@ -1784,3 +1789,17 @@ func (a *Agent) PendingTasks() []uint64 { func (a *Agent) proposalAsk(id uint64, notice TaskNotice) Question { return a.proposalQuestion(id, notice) } + +// A proposal stays visible after its originating turn ends, like a running task. +func (a *Agent) broadcastTaskProposal(hub *eventHub, notice TaskNotice) { + event := Event{Kind: EventTaskProposal, Tool: "propose_task", Task: ¬ice} + if hub != nil { + hub.send(event) + } + a.mu.Lock() + watchers := append([]*eventStream(nil), a.taskWatchers...) + a.mu.Unlock() + for _, watcher := range watchers { + watcher.send(event) + } +} diff --git a/internal/session/task_app.go b/internal/session/task_app.go new file mode 100644 index 0000000000..95bda4cc08 --- /dev/null +++ b/internal/session/task_app.go @@ -0,0 +1,640 @@ +package session + +import ( + "context" + "errors" + "sort" + "strconv" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/config" +) + +// AppTaskView is an engine read, independent of the app's JSON contract. +// Numeric graph ids and plan ids keep their existing bytes behind the HTTP codec. +type AppTaskView struct { + ID, Parent, Title, Brief, State, Model, Origin, Result string + Waits []string + Deadline time.Time + Held bool + Cap, Spent float64 + Step int + Files []string + Notes []AppTaskNote + WorkLog []string + Elapsed int64 + Version uint64 +} +type AppTaskNote struct { + Direction uint64 `json:"direction,omitempty"` + Text string + ReadAtStep *int +} +type AppQuickTaskView struct { + // Grew is why the quick work asked to become a full task, while it waits + // for that answer; State is then "grew". + ID, Title, State, Grew string + Files []AppQuickFile + Added, Removed int + Version uint64 +} +type AppQuickFile struct { + Path, BeforeHash, AfterHash string + // Added and Removed are this file's own changed lines. + Added, Removed int +} +type AppTaskIntent struct { + ID, Action, Text, Model, Verb string + State *string + Version *uint64 +} + +// The metadata records a person's holds and note receipts beside the work itself. +// Wake channels belong to this process; a restored hold creates one on demand. +type appTaskRecord struct { + Paused bool `json:"paused,omitempty"` + Declined bool `json:"declined,omitempty"` + Version uint64 `json:"version,omitempty"` + Notes []AppTaskNote `json:"notes,omitempty"` + Brief string `json:"brief,omitempty"` +} + +func cloneAppTaskRecord(in appTaskRecord) appTaskRecord { + in.Notes = append([]AppTaskNote(nil), in.Notes...) + for i := range in.Notes { + if in.Notes[i].ReadAtStep != nil { + step := *in.Notes[i].ReadAtStep + in.Notes[i].ReadAtStep = &step + } + } + return in +} + +// writeAppControl uses the checkpoint's established store-before-graph lock order. +// A failed disk write restores the in-memory change before any reader can see it. +func (g *TaskGraph) writeAppControl(change func() (func(), error)) error { + g.mu.Lock() + store := g.store + g.mu.Unlock() + if store != nil { + store.mu.Lock() + defer store.mu.Unlock() + } + g.mu.Lock() + defer g.mu.Unlock() + rollback, err := change() + if err != nil { + return err + } + if store != nil { + if err := store.writeLocked(g.documentLocked()); err != nil { + if rollback != nil { + rollback() + } + return err + } + } + return nil +} + +// appBoundary holds the next action without changing whether a note was read. +func (n *TaskNode) appBoundary(ctx context.Context) error { + for { + n.graph.mu.Lock() + paused := n.app.Paused + if paused && n.appWake == nil { + n.appWake = make(chan struct{}) + } + wake := n.appWake + n.graph.mu.Unlock() + if !paused { + break + } + select { + case <-ctx.Done(): + return ctx.Err() + case <-wake: + } + } + return ctx.Err() +} + +func appNodeState(n *TaskNode) string { + if n.app.Declined { + return "declined" + } + if n.stopped { + return "stopped" + } + if n.app.Paused && !n.state.settled() { + return "paused" + } + switch n.state { + case TaskQueued: + return "waiting" + case TaskUnverified: + return "your_call" + case TaskInterrupted: + return "paused" + default: + return string(n.state) + } +} + +// AppTasks reads both engine task roads and pending proposals; no UI copy owns state. +func (a *Agent) AppTasks() ([]AppTaskView, error) { + out := []AppTaskView{} + cap, _ := config.CrewTaskSpendCap(a.config.ProfileDir) + graph := a.tasker() + graphIDs := map[string]bool{} + if graph != nil { + graph.mu.Lock() + nodes := make([]*TaskNode, 0, len(graph.order)) + for _, id := range graph.order { + nodes = append(nodes, graph.nodes[id]) + } + graph.mu.Unlock() + for _, node := range nodes { + if node == nil || node.spec.quick != nil { + continue + } + notice := node.notice() + _, live, _ := a.taskLiveRead(node.id, taskLiveMaxTail) + graph.mu.Lock() + view := AppTaskView{ID: strconv.FormatUint(node.id, 10), Title: notice.Title, Brief: notice.Brief, State: appNodeState(node), Model: notice.Model, Cap: cap, Spent: notice.CostUSD, Step: live.Steps, Result: notice.Report, Files: append([]string{}, notice.Changed...), Notes: node.appNotesLocked(), WorkLog: live.Lines, Elapsed: int64(notice.Elapsed.Seconds()), Version: node.app.Version + 1, Origin: "proposed"} + if node.app.Brief != "" { + view.Brief = node.app.Brief + } + if node.parent != 0 { + view.Parent = strconv.FormatUint(node.parent, 10) + } + for _, id := range node.dependsOn { + view.Waits = append(view.Waits, strconv.FormatUint(id, 10)) + } + if len(view.Files) == 0 { + view.Files = append([]string{}, node.wrote...) + } + journal := node.journal + graph.mu.Unlock() + view.WorkLog = appTaskWorkLog(view.WorkLog, journal) + graphIDs[view.ID] = true + out = append(out, view) + } + } + for _, row := range a.PlanTasks() { + if graphIDs[row.ID] { + continue + } + state := row.StateWord() + if state == "queued" { + state = "waiting" + } + if state == "your call" { + state = "your_call" + } + if state == "incomplete" { + state = "failed" + } + out = append(out, AppTaskView{ID: row.ID, Parent: row.Parent, Title: row.Title, State: state, Model: row.Model, Cap: cap, Spent: row.USD, Step: row.Steps, Waits: append([]string{}, row.Waits...), Origin: "asked"}) + } + out = append(out, a.appProposalViews(cap)...) + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out, nil +} + +func (a *Agent) AppTask(id string) (AppTaskView, bool, error) { + rows, err := a.AppTasks() + if err != nil { + return AppTaskView{}, false, err + } + for _, row := range rows { + if row.ID == id { + if page, ok := a.PlanTaskPage(id); ok { + if row.Brief == "" { + row.Brief = page.Description + } + if row.Result == "" { + row.Result = page.Result + } + if len(row.Notes) == 0 { + for _, note := range page.Notes { + row.Notes = append(row.Notes, AppTaskNote{Text: note.Body}) + } + } + if len(row.WorkLog) == 0 { + for _, step := range page.Steps { + row.WorkLog = append(row.WorkLog, step.Command) + } + } + } + return row, true, nil + } + } + return AppTaskView{}, false, nil +} + +func appExpect(intent AppTaskIntent, state string, version uint64) error { + if (intent.State != nil && *intent.State != state) || (intent.Version != nil && *intent.Version != version) { + return errors.New("stale: that task changed; open it again") + } + return nil +} + +// AppTaskAction keeps admission and landing separate: a proposal answers a wait; +// a finished task uses the existing continuation, check and acceptance doors. +func (a *Agent) AppTaskAction(intent AppTaskIntent) error { + id, numeric := strconv.ParseUint(intent.ID, 10, 64) + if numeric == nil { + if handled, err := a.appProposalAction(id, intent); handled { + return err + } + } + row, found, err := a.AppTask(intent.ID) + if err != nil { + return err + } + if !found { + return errors.New("no such task") + } + if err := appExpect(intent, row.State, row.Version); err != nil { + return err + } + node := a.taskNode(id) + if numeric != nil || node == nil { + return a.appPlanAction(intent) + } + return a.appGraphAction(node, intent, row) +} + +// A proposal's clock and model are edited under the same lock as its answer. +func (a *Agent) appProposalAction(id uint64, intent AppTaskIntent) (bool, error) { + var choice taskModelChoice + if intent.Action == "model" { + choice = a.resolveTaskModel(intent.Model) + } + a.mu.Lock() + question := a.taskAnswers[id] + if question == nil { + a.mu.Unlock() + return false, nil + } + state := "proposed" + if question.held { + state = "held" + } + if err := appExpect(intent, state, question.appVersion+1); err != nil { + a.mu.Unlock() + return true, err + } + switch intent.Action { + case "hold", "typing": + a.mu.Unlock() + a.HoldTask(id) + return true, nil + case "model": + if choice.problem != "" || choice.model == "" { + a.mu.Unlock() + return true, errors.New("choose one known model") + } + question.notice.Model = choice.model + question.appVersion++ + notice, hub := question.notice, a.hub + a.mu.Unlock() + a.broadcastTaskProposal(hub, notice) + return true, nil + case "start", "decline": + delete(a.taskAnswers, id) + notice := question.notice + a.mu.Unlock() + if intent.Action == "decline" { + a.keepAppDeclined(id, notice) + } + question.answer <- TaskAnswer{Approved: intent.Action == "start", Model: notice.Model} + return true, nil + default: + a.mu.Unlock() + return true, errors.New("that proposal must be started, held or declined") + } +} + +func (a *Agent) appPlanAction(intent AppTaskIntent) error { + switch intent.Action { + case "note": + return a.PlanNote(intent.ID, intent.Text) + case "brief": + return a.PlanAmend(intent.ID, intent.Text) + case "pause": + return a.PlanPause(intent.ID) + case "resume": + return a.PlanResume(intent.ID) + case "stop": + return a.PlanCancel(intent.ID) + } + return errors.New("that action is unavailable for this plan task") +} +func (a *Agent) appGraphAction(node *TaskNode, intent AppTaskIntent, row AppTaskView) error { + switch intent.Action { + case "pause", "resume": + return node.appPause(intent) + case "stop": + _, err := a.Cancel("task:" + intent.ID) + return err + case "model": + _, err := a.RetargetTask(node.id, intent.Model) + return err + case "note", "brief": + return a.appTaskNote(node, intent) + case "decide": + return a.appTaskDecide(node.id, intent, row.State) + } + return errors.New("unknown task action") +} + +// A queued hold keeps the frontier still; a running hold releases at a boundary. +func (node *TaskNode) appPause(intent AppTaskIntent) error { + var wake chan struct{} + err := node.graph.writeAppControl(func() (func(), error) { + if err := appExpect(intent, appNodeState(node), node.app.Version+1); err != nil { + return nil, err + } + if node.state.settled() { + return nil, errors.New("that task has finished") + } + old := node.app + node.app.Paused = intent.Action == "pause" + node.app.Version++ + if intent.Action == "resume" { + wake = node.appWake + } + return func() { node.app = old }, nil + }) + if err != nil { + return err + } + if wake != nil { + node.graph.mu.Lock() + if node.appWake == wake { + close(wake) + node.appWake = nil + } + node.graph.mu.Unlock() + } + node.graph.announce(node) + if intent.Action == "resume" { + node.graph.runFrontier() + } + return nil +} + +func (a *Agent) appTaskNote(node *TaskNode, intent AppTaskIntent) error { + if strings.TrimSpace(intent.Text) == "" { + return errors.New("words required") + } + receipt, err := a.SteerTask(node.id, intent.Text) + if err != nil { + return err + } + err = node.graph.writeAppControl(func() (func(), error) { + old := cloneAppTaskRecord(node.app) + node.app.Notes = append(node.app.Notes, AppTaskNote{Text: intent.Text, Direction: receipt.Direction}) + if intent.Action == "brief" { + node.app.Brief = intent.Text + } + node.app.Version++ + return func() { node.app = old }, nil + }) + if err == nil { + node.graph.announce(node) + } + return err +} +func (a *Agent) appTaskDecide(id uint64, intent AppTaskIntent, state string) error { + if state != "your_call" { + return errors.New("that task is not waiting for your call") + } + switch intent.Verb { + case "accept": + return a.ResolveUnverified(id, TaskAccept, intent.Text) + case "recheck": + return a.ResolveUnverified(id, TaskReaudit, intent.Text) + case "not_right", "tell": + return a.ContinueTask(id, intent.Text) + case "let_it_decide": + return a.HandUnverifiedToModel(id) + } + return errors.New("unknown your call verb") +} + +// A declined proposal has a record but never enters the running frontier. +func (a *Agent) keepAppDeclined(id uint64, notice TaskNotice) { + graph := a.graph() + node := &TaskNode{graph: graph, id: id, kind: "", state: TaskFailed, done: make(chan struct{}), spec: taskSpec{title: notice.Title, named: true, brief: notice.Brief, acceptance: notice.Acceptance, model: notice.Model}, app: appTaskRecord{Declined: true}, report: "Declined by you"} + close(node.done) + graph.mu.Lock() + if graph.nodes[id] == nil { + graph.nodes[id] = node + graph.order = append(graph.order, id) + } + graph.mu.Unlock() + graph.checkpoint() +} + +func (a *Agent) AppQuickTasks() ([]AppQuickTaskView, error) { + out := []AppQuickTaskView{} + graph := a.tasker() + if graph == nil { + return out, nil + } + graph.mu.Lock() + defer graph.mu.Unlock() + for _, id := range graph.order { + node := graph.nodes[id] + if node == nil || node.spec.quick == nil { + continue + } + quick := node.spec.quick + state := "running" + if node.state.settled() { + state = "done" + } + if quick.promotion != 0 && !quick.promoted && quick.grew != "" { + state = "grew" + } + if quick.promoted { + state = "promoted" + } + if quick.undone { + state = "undone" + } + view := AppQuickTaskView{ID: strconv.FormatUint(id, 10), Title: node.spec.title, State: state, Grew: quick.grew, Version: node.app.Version + 1, Files: []AppQuickFile{}} + for _, c := range quick.captures { + added, removed := quickLineChanges(c.Before, c.After) + view.Files = append(view.Files, AppQuickFile{Path: c.Path, BeforeHash: quickHash(c.Before, c.BeforeExists), AfterHash: quickHash(c.After, c.AfterExists), Added: added, Removed: removed}) + view.Added += added + view.Removed += removed + } + out = append(out, view) + } + return out, nil +} + +func (a *Agent) AppQuickTaskAction(intent AppTaskIntent) ([]string, []string, error) { + id, err := strconv.ParseUint(intent.ID, 10, 64) + if err != nil { + return nil, nil, errors.New("no such quick task") + } + node := a.taskNode(id) + if node == nil || node.spec.quick == nil { + return nil, nil, errors.New("no such quick task") + } + rows, _ := a.AppQuickTasks() + for _, row := range rows { + if row.ID == intent.ID { + if err := appExpect(intent, row.State, row.Version); err != nil { + return nil, nil, err + } + } + } + switch intent.Action { + case "undo": + return a.UndoQuickTask(id) + case "cancel": + _, err := a.Cancel("task:" + intent.ID) + return nil, nil, err + case "promote": + return nil, nil, a.appPromoteQuick(node) + } + return nil, nil, errors.New("unknown quick task action") +} + +// Note receipts come from the steering record that was carried into a request. +func (n *TaskNode) appNotesLocked() []AppTaskNote { + notes := cloneAppTaskRecord(n.app).Notes + known := map[uint64]bool{} + for i := range notes { + known[notes[i].Direction] = true + if said, ok := n.assignment.direction(notes[i].Direction); ok { + notes[i].ReadAtStep = said.readAtStep + } + } + for _, said := range n.assignment.directions { + if said.from == directionFromPerson && !known[said.id] { + notes = append(notes, AppTaskNote{Direction: said.id, Text: said.words, ReadAtStep: said.readAtStep}) + } + } + return notes +} + +// An explicit Promote press is permission to start the ordinary task road. +func (a *Agent) promoteQuickDirect(node *TaskNode) error { + if !node.quickIO.TryLock() { + return errors.New("stale: that quick task is changing; read it again") + } + defer node.quickIO.Unlock() + node.graph.mu.Lock() + quick := node.spec.quick + if quick.promoted || quick.undone { + node.graph.mu.Unlock() + return errors.New("that quick task has ended") + } + brief := quick.line + "\n\nContinue from these in-place edits; do not discard them." + for _, capture := range quick.captures { + brief += "\n" + capture.Path + } + title, model, parent, owner := node.spec.title, node.spec.model, node.parent, node.owner + ground := node.Ground + depends := []uint64{} + if !node.state.settled() { + depends = append(depends, node.id) + } + cancel := node.cancel + node.graph.mu.Unlock() + id := node.graph.reserve() + err := node.graph.writeAppControl(func() (func(), error) { + quick.promoted = true + old := node.app.Version + node.app.Version++ + return func() { quick.promoted = false; node.app.Version = old }, nil + }) + if err != nil { + return err + } + spec := taskSpec{admission: a.admissionContext(), title: title, named: true, brief: brief, acceptance: taskPersonAcceptance, model: model, ground: ground, where: "in place", mode: TaskModeInPlace, parent: parent, owner: owner, dependsOn: depends} + node.graph.admit(id, spec) + if cancel != nil { + cancel() + } + return nil +} + +func (a *Agent) appProposalViews(cap float64) []AppTaskView { + out := []AppTaskView{} + a.mu.Lock() + for id, question := range a.taskAnswers { + notice := question.notice + state := "proposed" + if question.held { + state = "held" + } + view := AppTaskView{ID: strconv.FormatUint(id, 10), Title: notice.Title, Brief: notice.Brief, State: state, Model: notice.Model, Deadline: notice.Deadline, Held: question.held, Cap: cap, Origin: "proposed", Version: question.appVersion + 1} + for _, depends := range notice.DependsOn { + view.Waits = append(view.Waits, strconv.FormatUint(depends, 10)) + } + out = append(out, view) + } + a.mu.Unlock() + return out +} + +// A restored node reads its existing journal when no live work-log tail remains. +func appTaskWorkLog(live []string, journal string) []string { + if len(live) > 0 || journal == "" { + return live + } + out := []string{} + for _, entry := range ReadTranscript(journal).Entries { + if entry.Role == "tool" || entry.Role == "assistant" { + out = append(out, entry.Hint+" "+entry.Text) + } + } + return out +} + +// A stale checkpoint may name a proposal from the previous engine lifetime. +func (a *Agent) appPromoteQuick(node *TaskNode) error { + node.graph.mu.Lock() + promotion, promoted, active := node.spec.quick.promotion, node.spec.quick.promoted, !node.state.settled() + node.graph.mu.Unlock() + if promoted { + return errors.New("that quick task was already promoted") + } + if promotion != 0 { + if owner := node.graph.quickProposalOwner(node); owner != nil { + if handled, err := owner.appProposalAction(promotion, AppTaskIntent{ID: strconv.FormatUint(promotion, 10), Action: "start"}); handled { + return err + } + } + if active { + return errors.New("stale: that promotion is still opening; read it again") + } + } + return a.promoteQuickDirect(node) +} + +// A quick worker's promotion belongs to the person, even inside a nested task. +func (g *TaskGraph) quickProposalOwner(node *TaskNode) *Agent { + if g.home != nil { + return g.home + } + return g.runner(node) +} +func (a *Agent) taskProposalOwner(spec taskSpec) *Agent { + if !spec.irreversible || !a.config.InTask { + return a + } + if graph := a.tasker(); graph != nil && graph.home != nil { + return graph.home + } + return a +} diff --git a/internal/session/task_app_test.go b/internal/session/task_app_test.go new file mode 100644 index 0000000000..9dd1041381 --- /dev/null +++ b/internal/session/task_app_test.go @@ -0,0 +1,424 @@ +package session + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/exec/bare" +) + +func appNodeFixture(t *testing.T, quick bool) (*Agent, *TaskNode, string) { + t.Helper() + agent, dir := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { c.AskConsent = true; c.TaskAutoApproveSeconds = 12 }) + graph := agent.graph() + node := &TaskNode{graph: graph, id: 1, state: TaskRunning, done: make(chan struct{}), Ground: dir, Mode: TaskModeInPlace, spec: taskSpec{title: "Edit a file", named: true, brief: "edit the file", acceptance: taskPersonAcceptance, model: agent.Model()}, room: newTaskRoom()} + if quick { + node.spec.quick = newQuickTaskSpec("edit the file", nil, nil) + node.kind = TaskKindQuick + } + graph.mu.Lock() + graph.nodes[1] = node + graph.order = append(graph.order, 1) + graph.seq = 1 + graph.mu.Unlock() + agent.config.taskControl = node + return agent, node, dir +} +func appWriteCall(path, content string) ai.ToolCall { + raw, _ := json.Marshal(map[string]string{"path": path, "content": content}) + return ai.ToolCall{ID: "write-test", Function: ai.ToolCallFunction{Name: "write", Arguments: string(raw)}} +} +func appBareTool(t *testing.T, dir, name string) bare.Tool { + t.Helper() + for _, tool := range bare.AllTools(dir) { + if tool.Name == name { + return tool + } + } + t.Fatalf("missing tool %s", name) + return bare.Tool{} +} + +func TestAppQuickWritesKeepOriginalBytesAndUndoEveryFileAfterRestart(t *testing.T) { + agent, node, dir := appNodeFixture(t, true) + original := filepath.Join(dir, "original.txt") + created := filepath.Join(dir, "created.txt") + if err := os.WriteFile(original, []byte("keep\nold\n"), 0640); err != nil { + t.Fatal(err) + } + for _, call := range []ai.ToolCall{appWriteCall(original, "keep\nfirst\n"), appWriteCall(original, "keep\nlast\n"), appWriteCall(created, "")} { + result := agent.executeTool(context.Background(), agent.newEpisode(), nil, call, "") + if result.isError { + t.Fatalf("real dispatch: %s", result.text) + } + } + node.graph.mu.Lock() + node.state = TaskDone + record := node.recordLocked() + node.graph.mu.Unlock() + if len(record.Quick.Captures) != 2 || string(record.Quick.Captures[0].Before) != "keep\nold\n" { + t.Fatalf("captures: %+v", record.Quick) + } + node.graph.mu.Lock() + restored := restoreNode(node.graph, record) + node.graph.nodes[1] = restored + node.graph.mu.Unlock() + rows, err := agent.AppQuickTasks() + if err != nil || len(rows) != 1 || rows[0].Added != 1 || rows[0].Removed != 1 || rows[0].Files[0].BeforeHash == rows[0].Files[0].AfterHash { + t.Fatalf("read: %+v, %v", rows, err) + } + undone, changed, err := agent.UndoQuickTask(1) + if err != nil || len(undone) != 2 || len(changed) != 0 { + t.Fatalf("undo %v %v %v", undone, changed, err) + } + raw, _ := os.ReadFile(original) + if string(raw) != "keep\nold\n" { + t.Fatalf("original=%q", raw) + } + info, _ := os.Stat(original) + if info.Mode().Perm() != 0640 { + t.Fatalf("mode %o", info.Mode().Perm()) + } + if _, err := os.Stat(created); !os.IsNotExist(err) { + t.Fatalf("created file remains: %v", err) + } + rows, _ = agent.AppQuickTasks() + if rows[0].State != "undone" { + t.Fatal(rows) + } +} + +func TestAppQuickUndoMismatchNeverRestoresTheOtherFile(t *testing.T) { + agent, node, dir := appNodeFixture(t, true) + tool := appBareTool(t, dir, "write") + for _, path := range []string{"one", "two"} { + call := appWriteCall(path, "ours") + if _, bad, err := agent.executeTaskTool(context.Background(), tool, call, json.RawMessage(call.Function.Arguments)); bad || err != nil { + t.Fatalf("write: %v", err) + } + } + node.graph.mu.Lock() + node.state = TaskDone + node.graph.mu.Unlock() + if err := os.WriteFile(filepath.Join(dir, "two"), []byte("external"), 0600); err != nil { + t.Fatal(err) + } + undone, changed, err := agent.UndoQuickTask(1) + if err != nil || len(undone) != 0 || len(changed) != 1 { + t.Fatalf("undo %v %v %v", undone, changed, err) + } + raw, _ := os.ReadFile(filepath.Join(dir, "one")) + if string(raw) != "ours" { + t.Fatalf("partially restored %q", raw) + } +} + +func TestAppQuickCannotRunShellOrWriteFile31BeforePromotion(t *testing.T) { + for _, kind := range []string{"shell", "file31"} { + t.Run(kind, func(t *testing.T) { + agent, node, dir := appNodeFixture(t, true) + lane, stop := agent.WatchTaskUpdates() + defer stop() + call := ai.ToolCall{ID: "test", Function: ai.ToolCallFunction{Name: "bash", Arguments: `{"command":"go test ./..."}`}} + if kind == "file31" { + tool := appBareTool(t, dir, "write") + for i := 0; i < QuickTaskFileLimit; i++ { + call := appWriteCall(strconv.Itoa(i), "ours") + if _, bad, err := agent.executeTaskTool(context.Background(), tool, call, json.RawMessage(call.Function.Arguments)); bad || err != nil { + t.Fatalf("file %d: %v", i, err) + } + } + call = appWriteCall("31", "blocked") + } + ran := make(chan struct{}, 1) + tool := bare.Tool{Name: call.Function.Name, Execute: func(context.Context, json.RawMessage) (string, bool, error) { + ran <- struct{}{} + return "ran", false, nil + }} + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan string, 1) + go func() { + text, _, _ := agent.executeTaskTool(ctx, tool, call, json.RawMessage(call.Function.Arguments)) + done <- text + }() + var proposal TaskNotice + for proposal.ID == 0 { + select { + case event := <-lane: + if event.Kind == EventTaskProposal && event.Task != nil { + proposal = *event.Task + } + case <-time.After(2 * time.Second): + t.Fatal("promotion did not ask") + } + } + if !proposal.Deadline.IsZero() { + t.Fatal("promotion has a clock") + } + select { + case <-ran: + t.Fatal("action ran before answer") + default: + } + if err := agent.AppTaskAction(AppTaskIntent{ID: strconv.FormatUint(proposal.ID, 10), Action: "decline"}); err != nil { + t.Fatal(err) + } + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("decline did not release worker") + } + select { + case <-ran: + t.Fatal("declined action ran") + default: + } + if kind == "file31" { + if _, err := os.Stat(filepath.Join(dir, "31")); !os.IsNotExist(err) { + t.Fatal("file 31 exists") + } + } + node.graph.mu.Lock() + node.state = TaskDone + node.graph.mu.Unlock() + }) + } +} + +func TestAppProposalClockAndTypingHoldArePerTask(t *testing.T) { + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { c.AskConsent = true; c.TaskAutoApproveSeconds = 12 }) + hub := newEventHub() + agent.mu.Lock() + agent.hub = hub + agent.mu.Unlock() + now := time.Date(2026, 10, 13, 12, 0, 0, 0, time.UTC) + agent.taskNow = func() time.Time { return now } + expiry := make(chan time.Time, 1) + timerCalls := 0 + agent.taskTimer = func(after time.Duration) (<-chan time.Time, func()) { + timerCalls++ + if after != 12*time.Second { + t.Errorf("timer=%v", after) + } + return expiry, func() {} + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + waits := []*taskWait{} + for id := uint64(1); id <= 2; id++ { + wait, err := agent.openTask(ctx, id, taskSpec{title: "Proposal", brief: "work", acceptance: taskPersonAcceptance}, "") + if err != nil { + t.Fatal(err) + } + waits = append(waits, wait) + } + if err := agent.AppTaskAction(AppTaskIntent{ID: "1", Action: "typing"}); err != nil { + t.Fatal(err) + } + rows, err := agent.AppTasks() + if err != nil || len(rows) != 2 { + t.Fatalf("rows %+v %v", rows, err) + } + if rows[0].State != "held" || !rows[0].Deadline.IsZero() || !rows[1].Deadline.Equal(now.Add(12*time.Second)) { + t.Fatalf("per-task clock %+v", rows) + } + irreversible, err := agent.openTask(ctx, 3, taskSpec{title: "Irreversible", irreversible: true}, "") + if err != nil { + t.Fatal(err) + } + if timerCalls != 2 { + t.Fatalf("irreversible started timer: %d", timerCalls) + } + for _, id := range []uint64{1, 2, 3} { + agent.ResolveTask(id, TaskAnswer{Approved: false}) + } + for _, wait := range append(waits, irreversible) { + _, _ = wait.answer() + } +} + +func TestAppTaskNotesReceiptMarksOnlyTheRequestThatReadIt(t *testing.T) { + agent, node, _ := appNodeFixture(t, false) + node.graph.mu.Lock() + direction, _, _ := node.assignment.hear("read this", directionFromPerson, time.Now(), spokenSource{}) + node.app.Notes = []AppTaskNote{{Text: "read this", Direction: direction}} + node.graph.mu.Unlock() + row, found, err := agent.AppTask("1") + if err != nil || !found || row.Notes[0].ReadAtStep != nil { + t.Fatalf("premature receipt %+v %v", row, err) + } + agent.config.tasker = node.graph + agent.config.taskID = node.id + agent.markDirectionsCarried([]uint64{direction}) + row, _, _ = agent.AppTask("1") + if row.Notes[0].ReadAtStep == nil || *row.Notes[0].ReadAtStep != 1 { + t.Fatal(row.Notes) + } + node.graph.mu.Lock() + record := node.recordLocked() + node.graph.mu.Unlock() + restored := restoreNode(node.graph, record) + restored.graph.mu.Lock() + notes := restored.appNotesLocked() + restored.graph.mu.Unlock() + if notes[0].ReadAtStep == nil || *notes[0].ReadAtStep != 1 { + t.Fatal(notes) + } +} + +func TestAppTaskPauseAndStaleResumeCannotStartTheNextStep(t *testing.T) { + agent, node, _ := appNodeFixture(t, false) + if err := agent.AppTaskAction(AppTaskIntent{ID: "1", Action: "pause"}); err != nil { + t.Fatal(err) + } + stale := uint64(1) + if err := agent.AppTaskAction(AppTaskIntent{ID: "1", Action: "resume", Version: &stale}); err == nil || !strings.Contains(err.Error(), "stale") { + t.Fatalf("resume: %v", err) + } + node.graph.mu.Lock() + paused := node.app.Paused + node.graph.mu.Unlock() + if !paused { + t.Fatal("stale resume released pause") + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if err := node.appBoundary(ctx); err != context.Canceled { + t.Fatalf("boundary: %v", err) + } + if err := agent.AppTaskAction(AppTaskIntent{ID: "1", Action: "resume"}); err != nil { + t.Fatal(err) + } + if err := node.appBoundary(context.Background()); err != nil { + t.Fatal(err) + } +} + +func TestAppTaskCapStopsTheNextModelCall(t *testing.T) { + agent, node, _ := appNodeFixture(t, false) + agent.config.taskCapUSD = 2 + node.graph.mu.Lock() + node.cost = 2 + node.graph.mu.Unlock() + _, _, err := agent.completeWithRetry(context.Background(), nil, agent.Model(), "", nil, nil, nil) + if err == nil || !strings.Contains(err.Error(), "$2 limit") { + t.Fatalf("cap: %v", err) + } +} + +func TestAppQuickApprovalAdmitsAnOrdinaryTaskWithoutExecutingTheBlockedCall(t *testing.T) { + agent, node, dir := appNodeFixture(t, true) + lane, stop := agent.WatchTaskUpdates() + defer stop() + call := ai.ToolCall{ID: "test", Function: ai.ToolCallFunction{Name: "bash", Arguments: `{"command":"go test ./..."}`}} + ran := false + tool := bare.Tool{Name: "bash", Execute: func(context.Context, json.RawMessage) (string, bool, error) { ran = true; return "ran", false, nil }} + finished := make(chan struct{}) + go func() { + defer close(finished) + _, _, _ = agent.executeTaskTool(context.Background(), tool, call, json.RawMessage(call.Function.Arguments)) + }() + var proposal TaskNotice + for proposal.ID == 0 { + select { + case event := <-lane: + if event.Kind == EventTaskProposal && event.Task != nil { + proposal = *event.Task + } + case <-time.After(2 * time.Second): + t.Fatal("no promotion") + } + } + approved := make(chan error, 1) + go func() { + _, _, err := agent.AppQuickTaskAction(AppTaskIntent{ID: "1", Action: "promote"}) + approved <- err + }() + select { + case err := <-approved: + if err != nil { + t.Fatal(err) + } + case <-time.After(2 * time.Second): + t.Fatal("Promote waited on its own worker") + } + + select { + case <-finished: + case <-time.After(2 * time.Second): + t.Fatal("approval did not finish") + } + node.graph.mu.Lock() + full := node.graph.nodes[proposal.ID] + promoted := node.spec.quick.promoted + node.graph.mu.Unlock() + if ran || !promoted || full == nil || full.kind == TaskKindQuick || full.spec.quick != nil || full.Ground != dir || full.Mode != TaskModeInPlace || full.spec.acceptance == "" || len(full.dependsOn) != 1 || full.dependsOn[0] != node.id { + t.Fatalf("promoted=%v ran=%v task=%+v", promoted, ran, full) + } + node.graph.mu.Lock() + node.state = TaskDone + node.graph.quitting = true + node.graph.mu.Unlock() +} + +func TestAppQuickCaptureFailureLeavesTheFileUntouched(t *testing.T) { + agent, node, dir := appNodeFixture(t, true) + blocked := filepath.Join(dir, "cannot-write-checkpoint") + if err := os.Mkdir(blocked, 0700); err != nil { + t.Fatal(err) + } + node.graph.mu.Lock() + node.graph.store = &taskStore{path: blocked} + node.graph.mu.Unlock() + path := filepath.Join(dir, "target.txt") + if err := os.WriteFile(path, []byte("before"), 0600); err != nil { + t.Fatal(err) + } + call := appWriteCall(path, "after") + _, failed, err := agent.executeTaskTool(context.Background(), appBareTool(t, dir, "write"), call, json.RawMessage(call.Function.Arguments)) + if !failed || err != nil { + t.Fatalf("capture failure: %v %v", failed, err) + } + raw, _ := os.ReadFile(path) + if string(raw) != "before" { + t.Fatalf("file changed: %q", raw) + } + node.graph.mu.Lock() + captures := len(node.spec.quick.captures) + node.graph.store = nil + node.graph.mu.Unlock() + if captures != 0 { + t.Fatalf("failed snapshot leaked %d captures", captures) + } +} + +func TestAppQuickStoppedTaskCanUndoItsCapturedWrites(t *testing.T) { + agent, node, dir := appNodeFixture(t, true) + call := appWriteCall("stopped.txt", "ours") + if _, bad, err := agent.executeTaskTool(context.Background(), appBareTool(t, dir, "write"), call, json.RawMessage(call.Function.Arguments)); bad || err != nil { + t.Fatalf("write: %v", err) + } + node.graph.mu.Lock() + node.state = TaskFailed + node.stopped = true + node.graph.mu.Unlock() + rows, _ := agent.AppQuickTasks() + if rows[0].State != "done" { + t.Fatal(rows) + } + undone, changed, err := agent.UndoQuickTask(1) + if err != nil || len(undone) != 1 || len(changed) != 0 { + t.Fatalf("undo stopped task: %v %v %v", undone, changed, err) + } + if _, err := os.Stat(filepath.Join(dir, "stopped.txt")); !os.IsNotExist(err) { + t.Fatalf("captured file remains: %v", err) + } +} diff --git a/internal/session/task_contract.go b/internal/session/task_contract.go index 5873ce7d6e..df4104dd67 100644 --- a/internal/session/task_contract.go +++ b/internal/session/task_contract.go @@ -544,6 +544,9 @@ type TaskNotice struct { // countdown (task.autoapprove_seconds). A zero Deadline means the clock is // off or has been held by typing, and only an answer resolves the proposal. Deadline time.Time + // Irreversible marks work that cannot be undone, which never starts on a + // clock: its Deadline is always zero and only an answer admits it. + Irreversible bool // Withdrawn is set on the one rebroadcast of a proposal that takes it back // before anybody's answer admitted it, and it is the reason in the engine's // words. It happens to exactly one kind of proposal: one whose card went up diff --git a/internal/session/task_quick.go b/internal/session/task_quick.go index 5195812b23..f9bca27602 100644 --- a/internal/session/task_quick.go +++ b/internal/session/task_quick.go @@ -64,6 +64,14 @@ const ( // ([TaskNode.quickItemChange]) — so every reader of either takes // `graph.mu` first, exactly as the row's own fields are read. type quickTaskSpec struct { + // Captures keep the original bytes through repeated writes and engine restarts. + captures []QuickFileCapture + promotion uint64 + // grew is why the quick work asked to become a full task: a test run, a + // shell call, or one file past [QuickTaskFileLimit]. + grew string + promoted bool + undone bool // line is what to do, in one sentence. It is also the node's title when the // caller named none. line string @@ -326,11 +334,10 @@ func quickReportOnClose(record taskRecord) string { // ([Config.mayQuickTask] puts both verbs on a belt or neither). What is left // here is what only this string says: the two roads' shape in one sentence, the // step that is not a hand-off at all, and the grain. -var quickTaskDescription = "A task gets its own copy of the folder, is checked, and lands. A quick task works " + - "where you are and its last message is its answer. One edit, one " + - "read, one command is a step: do it yourself. Related steps that share what they learn are one quick " + - "task's items, not several quick tasks. KEEP ONE SMALL, a few files and a few minutes: reading is not " + - "progress, so " + strconv.Itoa(taskNoProgress) + " steps that only read end it." +var quickTaskDescription = "Quick work runs here; its last message is the answer. One step: do it yourself. " + + "Related steps sharing context: one quick task's items. " + strconv.Itoa(taskNoProgress) + + " calls that only read stop it. Shell, media, workspace restore/merge or file " + + strconv.Itoa(QuickTaskFileLimit+1) + " ask to promote it to a full task." // quickTaskSchemaJSON is the wire schema. Every field but `line` is optional, // which is the whole shape of the verb: there is no contract to groom, no @@ -970,11 +977,11 @@ const ( // it does: everything that can go wrong here is something the person who asked // for the work has to be told in words. // -// THERE IS NO GATE UNDER THIS AND THERE IS NOT MEANT TO BE. No worktree, so -// nothing to merge; no auditor, so nothing to verify; no settle policy, so a -// quick node is never [TaskUnverified] and never asks anybody anything. Its last -// message is its result, and the road from here — complete, announce, the -// landing note — is the road every task already takes. +// QUICK WORK HAS NO LANDING GATE. Its last message is its result, and it never +// becomes [TaskUnverified]. The execution boundary may ask to promote work +// before an unbounded action or file 31; that admits a separate ordinary node +// after this worker leaves, so its contract and check belong to that node. +// Completion, announcement and the landing note still use the ordinary road. func (a *Agent) runQuickNode(ctx context.Context, node *TaskNode, listed *job) TaskState { quick := node.spec.quick log := taskLog(listed) @@ -1042,6 +1049,13 @@ func (a *Agent) runQuickNode(ctx context.Context, node *TaskNode, listed *job) T node.keepWorkerConclusion(said, log) report := composeTaskReport(said) + node.graph.mu.Lock() + promoted := quick.promoted + node.graph.mu.Unlock() + if promoted { + return a.landQuickNode(node, "Promoted to a task", wrote, TaskDone) + } + // A PERSON'S ✕ IS NOT A FAILURE OF THE WORK, and it is asked first because // [TaskGraph.stop] sets `stopped` before it cuts the context: everything below // would otherwise read a cancelled run as one that ran out of road. diff --git a/internal/session/task_quick_files.go b/internal/session/task_quick_files.go new file mode 100644 index 0000000000..c158bbc2e0 --- /dev/null +++ b/internal/session/task_quick_files.go @@ -0,0 +1,284 @@ +package session + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "os" + "sort" + "strings" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/exec/bare" + "github.com/Agent-Field/codeaf/internal/furrow" + "github.com/aymanbagabas/go-udiff" +) + +// QuickTaskFileLimit is enforced before the next distinct file can be changed. +const QuickTaskFileLimit = 30 + +// QuickFileCapture stays in the private task checkpoint. Bytes never cross the app wire. +// Existence is separate from a digest because an empty file is still a file. +type QuickFileCapture struct { + Path string `json:"path"` + Before []byte `json:"before,omitempty"` + After []byte `json:"after,omitempty"` + BeforeExists bool `json:"before_exists"` + AfterExists bool `json:"after_exists"` + Mode os.FileMode `json:"mode"` +} + +func cloneQuickCaptures(in []QuickFileCapture) []QuickFileCapture { + out := append([]QuickFileCapture(nil), in...) + for i := range out { + out[i].Before = append([]byte(nil), in[i].Before...) + out[i].After = append([]byte(nil), in[i].After...) + } + return out +} + +func quickHash(bytes []byte, exists bool) string { + if !exists { + return "" + } + sum := sha256.Sum256(bytes) + return hex.EncodeToString(sum[:]) +} + +func quickRead(path string) ([]byte, bool, os.FileMode, error) { + info, err := os.Lstat(path) + if errors.Is(err, os.ErrNotExist) { + return nil, false, 0600, nil + } + if err != nil { + return nil, false, 0, err + } + if !info.Mode().IsRegular() { + return nil, false, 0, errors.New("quick tasks only capture regular files") + } + raw, err := os.ReadFile(path) + return raw, true, info.Mode().Perm(), err +} + +// executeTaskTool surrounds the actual execution, so a batch cannot overwrite a +// first capture or mistake a later external edit for its own after bytes. +func (a *Agent) executeTaskTool(ctx context.Context, tool bare.Tool, call ai.ToolCall, args json.RawMessage) (string, bool, error) { + node := a.config.taskControl + if node == nil { + return tool.Execute(ctx, args) + } + if err := node.appBoundary(ctx); err != nil { + return err.Error(), true, nil + } + if node.spec.quick == nil { + return tool.Execute(ctx, args) + } + node.quickIO.Lock() + defer node.quickIO.Unlock() + node.graph.mu.Lock() + promoted, undone := node.spec.quick.promoted, node.spec.quick.undone + node.graph.mu.Unlock() + if promoted || undone { + return "this quick task has ended", true, nil + } + // A shell's effects cannot be bounded from its text. All shell execution asks + // first, which includes wrappers, aliases and scripts that hide a test run. + if quickToolNeedsPromotion(tool.Name) { + return a.proposeQuickPromotion(ctx, node, call, "This needs a full task before running a command or creating media") + } + path, _, writes := a.mutatingPath(call) + if !writes { + return tool.Execute(ctx, args) + } + return a.executeQuickFileTool(ctx, node, tool, call, args, path) +} + +// A known-path mutation keeps both measurements inside one serialized action. +func (a *Agent) executeQuickFileTool(ctx context.Context, node *TaskNode, tool bare.Tool, call ai.ToolCall, args json.RawMessage, path string) (string, bool, error) { + node.graph.mu.Lock() + captures := cloneQuickCaptures(node.spec.quick.captures) + node.graph.mu.Unlock() + initialCount := len(captures) + index := -1 + for i := range captures { + if captures[i].Path == path { + index = i + break + } + } + if index < 0 && len(captures) >= QuickTaskFileLimit { + return a.proposeQuickPromotion(ctx, node, call, fmt.Sprintf("This is bigger than a quick task · %d files", len(captures)+1)) + } + before, exists, mode, err := quickRead(path) + if err != nil { + return "Can't capture the file before writing: " + err.Error(), true, nil + } + if index < 0 { + index = len(captures) + captures = append(captures, QuickFileCapture{Path: path, Before: before, BeforeExists: exists, After: before, AfterExists: exists, Mode: mode}) + } else if quickHash(before, exists) != quickHash(captures[index].After, captures[index].AfterExists) { + return "The file changed outside this quick task; nothing was written: " + path, true, nil + } + if err := node.saveQuickCaptures(captures); err != nil { + return "Can't keep Undo before writing: " + err.Error(), true, nil + } + text, failed, runErr := tool.Execute(ctx, args) + after, afterExists, _, readErr := quickRead(path) + if readErr != nil { + return "Can't read the file after writing: " + readErr.Error(), true, nil + } + captures[index].After, captures[index].AfterExists = after, afterExists + if failed && index == initialCount && quickHash(before, exists) == quickHash(after, afterExists) { + captures = captures[:initialCount] + } + if err := node.saveQuickCaptures(captures); err != nil { + return "The file was written, but its Undo receipt could not be saved: " + err.Error(), true, nil + } + return text, failed, runErr +} + +func (n *TaskNode) saveQuickCaptures(captures []QuickFileCapture) error { + err := n.graph.writeAppControl(func() (func(), error) { + old := n.spec.quick.captures + n.spec.quick.captures = cloneQuickCaptures(captures) + n.app.Version++ + return func() { n.spec.quick.captures = old; n.app.Version-- }, nil + }) + if err == nil { + n.graph.announce(n) + } + return err +} + +// A promotion has no clock and the blocked action never runs in the quick worker. +// The new task waits for that worker to leave, then continues in the same folder. +func (a *Agent) proposeQuickPromotion(ctx context.Context, node *TaskNode, call ai.ToolCall, reason string) (string, bool, error) { + owner := node.graph.quickProposalOwner(node) + if owner == nil { + return reason + " · ask for a full task", true, nil + } + graph := node.graph + id := graph.reserve() + graph.mu.Lock() + line, items := node.spec.quick.line, append([]string(nil), node.spec.quick.items...) + graph.mu.Unlock() + spec := taskSpec{title: node.title(), named: true, summary: reason, brief: line + "\n\nItems: " + strings.Join(items, "; ") + "\n\nThe next blocked action was " + call.Function.Name + ": " + call.Function.Arguments, + acceptance: taskPersonAcceptance, ground: a.config.Workspace, where: "in place", mode: TaskModeInPlace, + admission: a.admissionContext(), model: node.runModel(), irreversible: true, dependsOn: []uint64{node.id}, parent: node.parent, owner: node.owner} + // The quick-task route must know which proposal to answer before that + // proposal is published; otherwise an immediate Promote can wait on itself. + if err := graph.writeAppControl(func() (func(), error) { + old, oldReason := node.spec.quick.promotion, node.spec.quick.grew + node.spec.quick.promotion, node.spec.quick.grew = id, reason + return func() { node.spec.quick.promotion, node.spec.quick.grew = old, oldReason }, nil + }); err != nil { + return "Can't keep the promotion: " + err.Error(), true, nil + } + wait, err := owner.openTask(ctx, id, spec, "") + if err != nil { + clearErr := graph.writeAppControl(func() (func(), error) { + old := node.spec.quick.promotion + node.spec.quick.promotion = 0 + return func() { node.spec.quick.promotion = old }, nil + }) + return errors.Join(err, clearErr).Error(), true, nil + } + answer, err := wait.answer() + if err != nil { + return err.Error(), true, nil + } + if !answer.Approved { + return reason + " · promotion declined; nothing ran", true, nil + } + if answer.Redirect != "" { + spec.brief += "\n\n" + answer.Redirect + } + if answer.Model != "" { + spec.model = answer.Model + } + if err := graph.writeAppControl(func() (func(), error) { + node.spec.quick.promoted = true + return func() { node.spec.quick.promoted = false }, nil + }); err != nil { + return err.Error(), true, nil + } + graph.admit(id, spec) + graph.mu.Lock() + cancel := node.cancel + graph.mu.Unlock() + if cancel != nil { + cancel() + } + return "Promoted to a task; the blocked action will run there", true, nil +} + +// Undo validates every after hash before changing any file. A mismatch changes +// nothing and names every path, including a newly created file removed elsewhere. +func (a *Agent) UndoQuickTask(id uint64) ([]string, []string, error) { + node := a.taskNode(id) + if node == nil || node.spec.quick == nil { + return nil, nil, errors.New("no such quick task") + } + node.quickIO.Lock() + defer node.quickIO.Unlock() + node.graph.mu.Lock() + quick := node.spec.quick + state := node.state + captures := cloneQuickCaptures(quick.captures) + ended := quick.promoted || quick.undone + node.graph.mu.Unlock() + if !state.settled() || ended { + return nil, nil, errors.New("only a finished quick task can be undone") + } + changed := []string{} + for _, capture := range captures { + raw, exists, _, err := quickRead(capture.Path) + if err != nil || quickHash(raw, exists) != quickHash(capture.After, capture.AfterExists) { + changed = append(changed, capture.Path) + } + } + if len(changed) > 0 { + sort.Strings(changed) + return nil, changed, nil + } + undone := []string{} + for _, capture := range captures { + var err error + if capture.BeforeExists { + err = os.WriteFile(capture.Path, capture.Before, capture.Mode) + if err == nil { + err = os.Chmod(capture.Path, capture.Mode) + } + } else { + err = os.Remove(capture.Path) + } + if err != nil { + return undone, nil, fmt.Errorf("undo %s: %w", capture.Path, err) + } + undone = append(undone, capture.Path) + } + err := node.graph.writeAppControl(func() (func(), error) { quick.undone = true; return func() { quick.undone = false }, nil }) + return undone, nil, err +} + +// Line edits count only changes, rather than charging every unchanged line twice. +func quickLineChanges(before, after []byte) (added, removed int) { + old, new := string(before), string(after) + for _, edit := range udiff.Lines(old, new) { + added += lineTally(edit.New) + removed += lineTally(old[edit.Start:edit.End]) + } + return +} + +// Workspace restores and merges have the same unbounded file effects as a shell. +func quickToolNeedsPromotion(name string) bool { + switch name { + case "bash", "edit_video", "speak", furrow.ToolRestore, furrow.ToolFork, furrow.ToolMerge: + return true + } + return strings.HasPrefix(name, "generate_") +} diff --git a/internal/session/task_read.go b/internal/session/task_read.go new file mode 100644 index 0000000000..642b04e81b --- /dev/null +++ b/internal/session/task_read.go @@ -0,0 +1,39 @@ +package session + +// ReadSessionTasks is every task one conversation's checkpoint holds — running, +// waiting and finished — as the notices a surface draws, read off the disk +// without opening the conversation. A desktop app listing a conversation it is +// not holding reads this; a conversation that never had a task, or whose +// checkpoint cannot be read, answers nothing. +// +// A JOB IS NOT A TASK HERE, for the reason it is not one on the roster: the +// store keeps a background process as a row (jobrow.go), and it is published +// as a process. +func ReadSessionTasks(sessionFile string) []TaskNotice { + document, ok := loadTaskCheckpoint(taskCheckpointPath(sessionFile)) + if !ok { + return nil + } + out := make([]TaskNotice, 0, len(document.Nodes)+len(document.Runs)) + for _, record := range document.Nodes { + if record.Kind == TaskKindJob { + continue + } + state := record.State + if record.Interrupted && !state.settled() { + state = TaskInterrupted + } + out = append(out, TaskNotice{ + ID: record.ID, Title: record.Title, Kind: record.Kind, Parent: record.Parent, State: state, + Brief: record.Brief, Report: record.Report, Result: record.Report, Changed: append([]string(nil), record.Changed...), + CostUSD: record.CostUSD, StartedAt: record.StartedAt, EndedAt: record.EndedAt, Model: record.Model, + Irreversible: record.Irreversible, + }) + } + for _, run := range document.Runs { + if notice := runRowNotice(run); notice.Kind != TaskKindJob { + out = append(out, notice) + } + } + return out +} diff --git a/internal/session/task_run.go b/internal/session/task_run.go index 5e8b3ca460..ebbc2b44e6 100644 --- a/internal/session/task_run.go +++ b/internal/session/task_run.go @@ -90,6 +90,7 @@ import ( "github.com/Agent-Field/agentfield/sdk/go/ai" "github.com/Agent-Field/codeaf/internal/approval" + "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/effort" "github.com/Agent-Field/codeaf/internal/exec/bare" "github.com/Agent-Field/codeaf/internal/guard" @@ -280,6 +281,11 @@ const ( // brief is assembled once at start (runFrontier's JIT assembly) from spec.brief // plus prerequisites' reports and is not the spec. type TaskNode struct { + // File captures serialize before and after bytes across concurrent tool calls. + quickIO sync.Mutex + // App holds are checkpointed with the node, so a restart never grants a resume. + app appTaskRecord + appWake chan struct{} // Continuation choices preserve the completed attempt until work is reopened. nextModel string nextEffort *string @@ -1602,6 +1608,7 @@ func (g *TaskGraph) runFrontier() { // hand, and never at proposal time when they did not exist yet. node.brief = g.briefLocked(node, orders) node.state = TaskRunning + node.app.Version++ node.started = time.Now() // The hold is lifted by the start itself, so the running update this // node is about to send carries no reason to be waiting. @@ -1879,6 +1886,9 @@ func (g *TaskGraph) doomedDependencies(ids []uint64) (missing, failed []uint64) // not finish is a node whose brief can never be assembled, and leaving it // queued forever would be a task the person watches wait on nothing. func (g *TaskGraph) readinessLocked(node *TaskNode) (bool, string) { + if node.app.Paused { + return false, "" + } ready := true for _, id := range node.dependsOn { prerequisite := g.nodes[id] @@ -2094,6 +2104,7 @@ func shareReports(reports []string, pot int) []string { func (g *TaskGraph) complete(node *TaskNode, state TaskState) { g.mu.Lock() node.state = state + node.app.Version++ node.ended = time.Now() if !node.started.IsZero() { // The age stops here. A finished node's elapsed is a fact about how long @@ -2155,6 +2166,7 @@ func (g *TaskGraph) handBackSlotLocked(node *TaskNode) { func (g *TaskGraph) resettle(node *TaskNode, state TaskState) { g.mu.Lock() node.state = state + node.app.Version++ node.ended = time.Now() // AND THE SETTLE'S OWN CLAIM IS HANDED BACK IN THE SAME WRITE — the claim // covers everything down to the resettle ([TaskNode.claimSettle]), so @@ -7617,6 +7629,7 @@ func (a *Agent) newTaskAgentOn(ctx context.Context, dir string, node *TaskNode, workerSeat = effort.RoleWork } + taskCapUSD, _ := config.CrewTaskSpendCap(parent.ProfileDir) child, err := a.newChildAgent(Config{ // Search authority follows the work without enabling memory writes. ConversationHistory: parent.conversationHistory(), @@ -7755,8 +7768,10 @@ func (a *Agent) newTaskAgentOn(ctx context.Context, dir string, node *TaskNode, // handed over afterwards would be a verb the model is never told it has. // It is nil for every other node — there is no list to tick — which is // what keeps `items` off every other belt in this build (task_quick.go). - writeScope: node.quickScope(), - quickItems: node.quickDoor(), + writeScope: node.quickScope(), + quickItems: node.quickDoor(), + taskControl: node, + taskCapUSD: taskCapUSD, // AND THE DESIGN THREAD'S ONE EXTRA HAND, wired here for roomThread's // reason: a belt is assembled once, when the agent is constructed // (agent.go), so a door handed over after this call would be a verb the diff --git a/internal/session/task_store.go b/internal/session/task_store.go index cc4de10f80..93432df15f 100644 --- a/internal/session/task_store.go +++ b/internal/session/task_store.go @@ -142,10 +142,12 @@ func (c Config) checkpointFile() string { // the reports its prerequisites left, which is the same thing it would have done // had nothing died. type taskRecord struct { - Restart *taskRestartRecord `json:"restart,omitempty"` - ID uint64 `json:"id"` - Title string `json:"title"` - Summary string `json:"summary,omitempty"` + App appTaskRecord `json:"app_control,omitempty,omitzero"` + Irreversible bool `json:"irreversible,omitempty"` + Restart *taskRestartRecord `json:"restart,omitempty"` + ID uint64 `json:"id"` + Title string `json:"title"` + Summary string `json:"summary,omitempty"` // Request is the person's own message, kept because a node that resumes // without it would be re-opened on the model's paraphrase alone — the one // part of what it was told that nothing downstream could reconstruct @@ -583,10 +585,14 @@ type taskRecord struct { // // Done is parallel to Items, one tick per item, exactly as it is in memory. type quickRecord struct { - Line string `json:"line"` - Items []string `json:"items,omitempty"` - Done []bool `json:"done,omitempty"` - Files []string `json:"files,omitempty"` + Captures []QuickFileCapture `json:"captures,omitempty"` + Promotion uint64 `json:"promotion,omitempty"` + Promoted bool `json:"promoted,omitempty"` + Undone bool `json:"undone,omitempty"` + Line string `json:"line"` + Items []string `json:"items,omitempty"` + Done []bool `json:"done,omitempty"` + Files []string `json:"files,omitempty"` } // quickRecordLocked copies a quick body out, with the graph held — the lock the @@ -597,7 +603,9 @@ func quickRecordLocked(spec *quickTaskSpec) *quickRecord { return nil } return &quickRecord{ - Line: spec.line, + Line: spec.line, + Captures: cloneQuickCaptures(spec.captures), + Promotion: spec.promotion, Promoted: spec.promoted, Undone: spec.undone, Items: append([]string(nil), spec.items...), Done: append([]bool(nil), spec.done...), Files: append([]string(nil), spec.files...), @@ -614,6 +622,8 @@ func (r *quickRecord) body() *quickTaskSpec { return nil } spec := newQuickTaskSpec(r.Line, append([]string(nil), r.Items...), append([]string(nil), r.Files...)) + spec.captures = cloneQuickCaptures(r.Captures) + spec.promotion, spec.promoted, spec.undone = r.Promotion, r.Promoted, r.Undone for index := range spec.done { spec.done[index] = index < len(r.Done) && r.Done[index] } @@ -650,12 +660,13 @@ type revisionRecord struct { // stops a landing publishing, and a process that died between the words and the // landing must not come back having forgotten them. type directionRecord struct { - ID uint64 `json:"id"` - Words string `json:"words"` - At time.Time `json:"at,omitzero"` - From string `json:"from,omitempty"` - State string `json:"state,omitempty"` - Version uint64 `json:"version,omitempty"` + ReadAtStep *int `json:"read_at_step,omitempty"` + ID uint64 `json:"id"` + Words string `json:"words"` + At time.Time `json:"at,omitzero"` + From string `json:"from,omitempty"` + State string `json:"state,omitempty"` + Version uint64 `json:"version,omitempty"` // Source is the person's message this was forwarded from (task_forward.go), // absent on a line said into the node's own room and on every checkpoint // written before forwarding existed. @@ -913,7 +924,23 @@ func (s *taskStore) writeLocked(document taskDocument) error { } } temporary := s.path + ".tmp" - if err := os.WriteFile(temporary, append(encoded, '\n'), 0o644); err != nil { + file, err := os.OpenFile(temporary, os.O_CREATE|os.O_WRONLY, 0o600) + if err != nil { + return err + } + // A stale temporary file may predate private byte captures, so chmod comes + // before truncation or writing, rather than trusting its creation mode. + if err := file.Chmod(0o600); err != nil { + file.Close() + return err + } + if err := file.Truncate(0); err != nil { + file.Close() + return err + } + _, writeErr := file.Write(append(encoded, '\n')) + closeErr := file.Close() + if err := errors.Join(writeErr, closeErr); err != nil { return err } if err := os.Rename(temporary, s.path); err != nil { @@ -1311,10 +1338,11 @@ func (n *TaskNode) recordLocked() taskRecord { Kind: n.kind, Offer: n.offer, Quick: quickRecordLocked(n.spec.quick), - Unshaped: n.spec.unshaped, - Unsized: n.spec.unsized, - Expects: append([]Expectation(nil), n.spec.expects...), - Assignment: recordedAssignment(n.assignment), + App: cloneAppTaskRecord(n.app), Irreversible: n.spec.irreversible, + Unshaped: n.spec.unshaped, + Unsized: n.spec.unsized, + Expects: append([]Expectation(nil), n.spec.expects...), + Assignment: recordedAssignment(n.assignment), } } @@ -2113,6 +2141,7 @@ func restoreNode(graph *TaskGraph, record taskRecord) *TaskNode { record.Title = taskPersonTitle(subject) } node := &TaskNode{ + app: cloneAppTaskRecord(record.App), nextModel: record.NextModel, nextEffort: record.NextEffort, graph: graph, id: record.ID, @@ -2141,7 +2170,8 @@ func restoreNode(graph *TaskGraph, record taskRecord) *TaskNode { expects: record.Expects, // AND A QUICK NODE'S BODY, which is what tells the runner it is quick // and what the next checkpoint writes back ([taskRecord.Quick]). - quick: record.Quick.body(), + quick: record.Quick.body(), + irreversible: record.Irreversible, // AND THE READINGS THIS NODE IS STILL OWED, so that a `/task` that was // queued when the engine went down still has its brief written and its // width read beside the worker it finally gets ([taskRecord.Unshaped]). diff --git a/internal/tui3/appseams.go b/internal/tui3/appseams.go index 8153562398..9c4c9e0e5a 100644 --- a/internal/tui3/appseams.go +++ b/internal/tui3/appseams.go @@ -49,3 +49,10 @@ func PickerModels(profileDir string, sources modelsource.Set, defaults func() [] } return rows } + +// TaskPhaseWords is what the terminal draws under a running task for the phase +// it is in — "checking what it left", "closing gaps · round 1 of 2", "sizing +// the work" — and "" for plain working, which says nothing. +func TaskPhaseWords(phase string, round, rounds int) string { + return taskPhaseWords(phase, round, rounds) +} From 2095bddc4dcd8b072ba4bb2a6ab3cf0bfe1a4967 Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Thu, 15 Oct 2026 08:34:56 -0400 Subject: [PATCH 008/459] codeaf app: work log detail and a running task's current call Every tool row and tool entry carries its kind, clock, batch, exit code, earlier run, files, account, task, ask, input, tail and line count; long calls are said again with a fresh tail; tools.output reads a call's whole output from its kept file and tools.follow streams new lines. The engine sends a model's shell output live when the app asks, and its tool events carry the batch, the account and the exit code. A running task's line now names the call it is making, read from its journal for a hosted conversation. --- cmd/codeaf/app.go | 44 +- cmd/codeaf/apptools.go | 654 ++++++++++++++++++ cmd/codeaf/apptools_test.go | 121 ++++ cmd/codeaf/appwork.go | 93 ++- cmd/codeaf/engine.go | 1 + docs/changes/unreleased/0-app-wave3.md | 10 + internal/appwire/wire.go | 3 + internal/appwire/worklog.go | 146 ++++ internal/exec/bare/tools.go | 14 +- .../manual/chat/running-from-the-terminal.md | 11 + internal/remote/wire.go | 3 + internal/session/loop.go | 56 +- internal/session/session.go | 18 +- internal/session/userbash.go | 8 +- internal/tui3/appseams.go | 24 + 15 files changed, 1179 insertions(+), 27 deletions(-) create mode 100644 cmd/codeaf/apptools.go create mode 100644 cmd/codeaf/apptools_test.go create mode 100644 internal/appwire/worklog.go diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index 4723097128..ccc64a7934 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -212,7 +212,7 @@ func appOpenInProcess(ask appOpenAsk) (*appConn, error) { // own naming does not rename it afterwards. // And only the person moves a running reply to a task, and a proposed // task's clock is the app's. - Launch: &remote.LaunchShape{Interactive: true, DeterministicNames: true, HandOverOnAsk: true, ProposalClockS: &clock}, + Launch: &remote.LaunchShape{Interactive: true, DeterministicNames: true, HandOverOnAsk: true, ProposalClockS: &clock, LiveToolOutput: true}, } loop, err := remote.Loopback(hello, remote.Options{ Boot: func(h remote.Hello) (*remote.Engine, error) { @@ -266,6 +266,9 @@ type appBackend struct { today func(time.Time) float64 // turnLong is how long a reply runs before turn.long ([appTurnLong]). turnLong time.Duration + // toolQuiet is how long a call runs before its row is said again with a + // fresh tail ([appToolQuiet]). + toolQuiet time.Duration // follows are the processes whose output the app is following. follows map[string]*appFollow // asleep says the Mac is asleep (power.sleep until power.wake). @@ -299,6 +302,8 @@ type appAgent struct { // marks are the turn number and client id of each message of the // person's this process sent, by its place, so a listing can say them. marks map[int]appTurnMark + // calls is the work log's record of this agent's tool calls (apptools.go). + calls *appCallLog } // appTurnMark is what this process knows of one of the person's messages. @@ -396,6 +401,7 @@ func newAppBackend(emit appwire.Emitter, open appOpener) *appBackend { keys: keys, state: &appState{}, turnLong: appTurnLong, + toolQuiet: appToolQuiet, follows: map[string]*appFollow{}, emit: emit, open: open, @@ -967,6 +973,9 @@ func (b *appBackend) questionEvent(a *appAgent, id string, live *appLive, event askID := appAskID(q) switch event.Kind { case session.EventQuestion: + if q.Subject.Kind == session.SubjectCall { + b.callAsked(a, id, live, q.Subject.CallID, askID) + } ask := appAskOf(id, q) restated, moved := false, false var view appwire.Agent @@ -1235,8 +1244,11 @@ func (b *appBackend) startLocked(a *appAgent, text, clientID string) (uint64, er ID: appUserEntryID(n), Agent: id, Turn: turn, Kind: appwire.EntryUser, At: b.now(), Text: text, ClientID: clientID, }}) long := b.watchLong(id, live, turn, b.now()) + b.newTurnCalls(a) + tails := b.tickTools(a, id, live) guard.Go("app/turn", func() { defer long.Stop() + defer tails() b.pump(a, id, live, turn, n, text, events) }) return turn, nil @@ -1295,7 +1307,13 @@ func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, n sayReply() title := redact.Secrets(tui3.ToolTitle(event.Tool, event.Args, event.Hint)) titles[event.CallID] = title - live.box.Event(appwire.EventTurnTool, appwire.TurnTool{Agent: id, Turn: turn, Call: event.CallID, Name: event.Tool, Title: title, Phase: appwire.PhaseBegin, Summary: event.Hint}) + row := b.callBegan(a, id, turn, event, title) + row.Summary = event.Hint + live.box.Event(appwire.EventTurnTool, row) + case session.EventToolOutput: + b.callOutput(a, id, live, event) + case session.EventToolFinished: + b.callFinished(a, event) case session.EventToolEnd, session.EventToolFailed: succeeded := event.Kind == session.EventToolEnd summary := event.Hint @@ -1310,7 +1328,7 @@ func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, n if title == "" { title = redact.Secrets(tui3.ToolTitle(event.Tool, event.Args, "")) } - live.box.Event(appwire.EventTurnTool, appwire.TurnTool{Agent: id, Turn: turn, Call: event.CallID, Name: event.Tool, Title: title, Detail: appDetail(summary), Phase: appwire.PhaseEnd, OK: &succeeded, Summary: summary}) + live.box.Event(appwire.EventTurnTool, b.callEnded(a, id, turn, event, title, summary, appDetail(summary), succeeded)) case session.EventConsentRequest: call := appConsentCall{Tool: event.Tool, Args: event.Args} b.locked(func() { live.consents[event.ID] = call }) @@ -1334,6 +1352,10 @@ func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, n changed(func(v *appwire.Agent) { v.Title = title }) } case session.EventTaskUpdate, session.EventTaskProposal, session.EventTaskPhase, session.EventJobUpdate: + // A task a running task call made is that call's task. + if task := event.Task; task != nil && (event.Kind == session.EventTaskProposal || task.State == "" || task.State == session.TaskQueued) { + b.callTask(a, appTaskID(id, task.ID)) + } b.workEvent(id, live, event) } } @@ -1714,7 +1736,20 @@ func (b *appBackend) Entries(id, before string, limit int) ([]appwire.Entry, boo return []appwire.Entry{}, false, nil } record := session.ReadTranscript(transcript) - all := appEntriesOf(id, append(append([]session.DisplayEntry(nil), record.Earlier...), record.Entries...), at, marks) + items := append(append([]session.DisplayEntry(nil), record.Earlier...), record.Entries...) + all := appEntriesOf(id, items, at, marks) + // A tool entry carries its call's facts, as its row did live. + calls := map[string]session.DisplayEntry{} + for _, item := range items { + if item.Role == "tool" && item.CallID != "" { + calls[item.CallID] = item + } + } + for i := range all { + if item, ok := calls[all[i].Call]; ok && all[i].Kind == appwire.EntryTool { + all[i].ToolFacts = b.appEntryFacts(a, item) + } + } if words := strings.TrimSpace(record.Unreadable); words != "" { all = append(all, appwire.Entry{ID: "unreadable", Agent: id, Kind: appwire.EntryNotice, At: at, Text: words}) } @@ -1880,6 +1915,7 @@ func (b *appBackend) Warm() { defer close(b.warmed) defer guard.Recover("app/warm") _, _ = b.process() + b.sweepOutputs() } // process is the engine's shared process half, opened once. diff --git a/cmd/codeaf/apptools.go b/cmd/codeaf/apptools.go new file mode 100644 index 0000000000..28e3b7a785 --- /dev/null +++ b/cmd/codeaf/apptools.go @@ -0,0 +1,654 @@ +package main + +// ── codeaf app: THE WORK LOG'S DETAIL ────────────────────────────────────── +// +// The app groups, folds, pins and tails a turn's tool rows by its own rules +// (PROTOCOL.md, "Work log detail"); this file supplies the facts of each call +// those rules read: its kind, its clock, the calls started with it, its exit +// code, the earlier run it repeats, the files it changed, the account, task or +// ask it belongs to, what it was asked, and the tail of what it printed. +// +// A CALL'S OUTPUT IS KEPT ON DISK, ONE FILE PER CALL, under the app's own +// folder. The engine sends a shell call's output as it arrives (the app sets +// session.Config.LiveToolOutput) and a quiet tool's output once it ends, and +// both are written to the call's file, which is what tools.output reads: after +// the turn, after the conversation is let go, and after the app is restarted. +// Files older than a week are swept. + +import ( + "bufio" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/exec/bare" + "github.com/Agent-Field/codeaf/internal/guard" + "github.com/Agent-Field/codeaf/internal/redact" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/tui3" +) + +const ( + // appTailLive and appTailEnd are how many lines of a call's output its + // row carries while it runs and once it has ended. + appTailLive = 3 + appTailEnd = 12 + // appToolBeat is how often a long call's row is said again with a fresh + // tail, and appToolQuiet how long a call runs before it is. + appToolBeat = 500 * time.Millisecond + appToolQuiet = 5 * time.Second + // appCallsKept is how many calls one agent's log remembers in memory; + // their output files outlive them. + appCallsKept = 2000 + // appOutputKept is how long a call's output file is kept. + appOutputKept = 7 * 24 * time.Hour + // appOutputPage and appOutputPageMax are tools.output's default and + // largest page, in lines. + appOutputPage = 1000 + appOutputPageMax = 10000 +) + +// appCall is one tool call as the work log knows it. +type appCall struct { + id, tool, args, hint, title string + turn uint64 + facts appwire.ToolFacts + ended bool + ok *bool + summary, detail string + // path is the call's output file, open in file while output arrives; + // lines counts its complete lines, partial is the line still being + // written, and tail the last complete lines. + path string + file *os.File + lines int + partial string + tail []string + streamed bool + followed bool + // sent is the tail the row last carried. + sent string +} + +// appCallLog is one agent's calls, by id, and the order they began in. +type appCallLog struct { + mu sync.Mutex + dir string + calls map[string]*appCall + order []string + // turn is the running turn's calls in order, which a retry is found in. + turn []*appCall +} + +func (l *appCallLog) locked(fn func()) { + l.mu.Lock() + defer l.mu.Unlock() + fn() +} + +// outputRoot is the folder every agent's call output is kept under. +func (b *appBackend) outputRoot() string { + path := b.state.path + if path == "" { + path = appStatePath() + } + return filepath.Join(filepath.Dir(path), "output") +} + +// callLog is one agent's call log, made the first time it is needed. +func (b *appBackend) callLog(a *appAgent) *appCallLog { + var log *appCallLog + b.locked(func() { + if a.calls == nil { + a.calls = &appCallLog{dir: filepath.Join(b.outputRoot(), appFileName(a.view.ID)), calls: map[string]*appCall{}} + } + log = a.calls + }) + return log +} + +// appFileName is an id as a file name: itself when it is plain, and a hash +// of it when it is not. +func appFileName(id string) string { + plain := id != "" + for _, r := range id { + if !(r >= 'a' && r <= 'z' || r >= 'A' && r <= 'Z' || r >= '0' && r <= '9' || r == '_' || r == '-') { + plain = false + break + } + } + if plain && len(id) <= 100 { + return id + } + sum := sha256.Sum256([]byte(id)) + return hex.EncodeToString(sum[:16]) +} + +// appToolKind is a call's kind, read off the engine's own family for the +// tool and the account it acts through, never a list kept here. +func appToolKind(tool, service string) string { + if service != "" { + return appwire.ToolConnector + } + switch session.ActionCategoryForTool(tool) { + case session.ActionSearch: + if strings.HasPrefix(tool, "web_") { + return appwire.ToolWeb + } + return appwire.ToolSearch + case session.ActionRead: + return appwire.ToolRead + case session.ActionEdit, session.ActionCreate: + return appwire.ToolEdit + case session.ActionRun, session.ActionTest: + return appwire.ToolRun + case session.ActionBrowse: + if tool == "use_service" { + return appwire.ToolConnector + } + return appwire.ToolWeb + case session.ActionCoordinate: + return appwire.ToolTask + } + return appwire.ToolOther +} + +// appToolInput is what a call was asked: its command, query or path, and for +// a command the folder it runs in and how long it may run. +func appToolInput(kind, tool, args, hint, workspace string) *appwire.ToolInput { + var fields map[string]json.RawMessage + _ = json.Unmarshal([]byte(args), &fields) + text := "" + if raw, ok := fields["command"]; ok { + _ = json.Unmarshal(raw, &text) + } + if text == "" { + text = tui3.ToolTarget(tool, args, hint) + } + input := &appwire.ToolInput{Text: redact.Secrets(text)} + if kind == appwire.ToolRun { + input.Cwd = workspace + seconds := bare.BashCeilingSeconds + if raw, ok := fields["timeout"]; ok { + var given float64 + if json.Unmarshal(raw, &given) == nil && given > 0 { + seconds = int(given) + } + } + input.TimeoutS = &seconds + } + if input.Text == "" && input.Cwd == "" { + return nil + } + return input +} + +// newTurnCalls starts a turn's own list of calls, which retries are found in. +func (b *appBackend) newTurnCalls(a *appAgent) { + log := b.callLog(a) + log.locked(func() { log.turn = nil }) +} + +// callBegan records a call that began and answers its row. +func (b *appBackend) callBegan(a *appAgent, agent string, turn uint64, event session.Event, title string) appwire.TurnTool { + log := b.callLog(a) + var workspace string + b.locked(func() { workspace = a.view.Workspace }) + var row appwire.TurnTool + log.locked(func() { + c := log.calls[event.CallID] + if c == nil || event.CallID == "" { + c = &appCall{id: event.CallID} + if event.CallID != "" { + log.calls[event.CallID] = c + log.order = append(log.order, event.CallID) + log.forgetLocked() + } + at := b.now() + c.facts.StartedAt = &at + log.turn = append(log.turn, c) + } + c.tool, c.args, c.hint, c.title, c.turn = event.Tool, event.Args, event.Hint, title, turn + kind := appToolKind(event.Tool, event.Service) + if c.facts.Ask != "" { + kind = appwire.ToolAsksFirst + } + c.facts.Kind, c.facts.Batch = kind, event.Batch + c.facts.Input = appToolInput(kind, event.Tool, event.Args, event.Hint, workspace) + switch kind { + case appwire.ToolConnector: + c.facts.Connector = event.ServiceName + if c.facts.Connector == "" { + c.facts.Connector = event.Service + } + if c.facts.Connector == "" { + c.facts.Connector = redact.Secrets(strings.TrimSpace(event.Hint)) + } + case appwire.ToolRun: + c.facts.RetriedOf = log.retriedLocked(c) + } + if path, adds, dels, ok := tui3.ToolFileChange(event.Tool, event.Args); ok { + c.facts.Files = []appwire.FileChange{{Path: path, Add: adds, Del: dels}} + } + row = b.rowLocked(agent, c) + }) + return row +} + +// retriedLocked is the latest earlier call of this turn that ran the same +// command as c, or "". Callers hold l.mu. +func (l *appCallLog) retriedLocked(c *appCall) string { + if c.facts.Input == nil || strings.TrimSpace(c.facts.Input.Text) == "" { + return "" + } + for i := len(l.turn) - 1; i >= 0; i-- { + prior := l.turn[i] + if prior == c || prior.facts.Kind != appwire.ToolRun || prior.facts.Input == nil { + continue + } + if prior.facts.Input.Text == c.facts.Input.Text { + return prior.id + } + } + return "" +} + +// forgetLocked lets the oldest calls go once the log is full; their files +// stay. Callers hold l.mu. +func (l *appCallLog) forgetLocked() { + for len(l.order) > appCallsKept { + oldest := l.calls[l.order[0]] + if oldest != nil && !oldest.ended { + return + } + delete(l.calls, l.order[0]) + l.order = l.order[1:] + } +} + +// rowLocked is a call's row as it stands. Callers hold the log's lock. +func (b *appBackend) rowLocked(agent string, c *appCall) appwire.TurnTool { + phase := appwire.PhaseBegin + if c.ended { + phase = appwire.PhaseEnd + } + row := appwire.TurnTool{Agent: agent, Turn: c.turn, Call: c.id, Name: c.tool, Title: c.title, Phase: phase, OK: c.ok, Summary: c.summary, Detail: c.detail} + row.ToolFacts = c.factsLocked() + return row +} + +// factsLocked is a call's facts with its tail as it stands: three lines while +// it runs, twelve once it has ended. +func (c *appCall) factsLocked() appwire.ToolFacts { + facts := c.facts + if c.facts.Files != nil { + facts.Files = append([]appwire.FileChange(nil), c.facts.Files...) + } + tail := c.tail + if c.partial != "" { + tail = append(append([]string(nil), tail...), c.partial) + } + keep := appTailEnd + if !c.ended { + keep = appTailLive + } + if len(tail) > keep { + tail = tail[len(tail)-keep:] + } + if len(tail) > 0 { + facts.Tail = append([]string(nil), tail...) + } + if c.lines > 0 || c.partial != "" { + lines := c.lines + if c.partial != "" { + lines++ + } + facts.Lines = &lines + } + return facts +} + +// callOutput writes what a call printed to its file and its tail, and says +// the new lines to a follower. +func (b *appBackend) callOutput(a *appAgent, agent string, live *appLive, event session.Event) { + if event.CallID == "" || event.Text == "" { + return + } + log := b.callLog(a) + var said []string + followed := false + log.locked(func() { + c := log.calls[event.CallID] + if c == nil || c.ended { + return + } + c.streamed = true + said = log.writeLocked(c, redact.Secrets(event.Text)) + followed = c.followed + }) + if followed && len(said) > 0 { + live.box.Event(appwire.EventToolOutput, appwire.ToolOutputEvent{Agent: agent, Call: event.CallID, Lines: said}) + } +} + +// writeLocked appends text to a call's file and tail and answers the lines it +// completed. Callers hold l.mu. +func (l *appCallLog) writeLocked(c *appCall, text string) []string { + if c.file == nil && c.path == "" { + c.path = filepath.Join(l.dir, appFileName(c.id)+".log") + if err := os.MkdirAll(l.dir, 0o700); err == nil { + c.file, _ = os.OpenFile(c.path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, 0o600) + } + } + if c.file != nil { + _, _ = io.WriteString(c.file, text) + } + parts := strings.Split(c.partial+text, "\n") + c.partial = parts[len(parts)-1] + done := parts[:len(parts)-1] + for i := range done { + done[i] = strings.TrimSuffix(done[i], "\r") + } + c.lines += len(done) + c.tail = append(c.tail, done...) + if len(c.tail) > appTailEnd { + c.tail = append([]string(nil), c.tail[len(c.tail)-appTailEnd:]...) + } + return done +} + +// callFinished keeps when a call's own work ended, which the engine says +// before the batch around it does. +func (b *appBackend) callFinished(a *appAgent, event session.Event) { + log := b.callLog(a) + log.locked(func() { + if c := log.calls[event.CallID]; c != nil && c.facts.StartedAt != nil && event.Took > 0 { + ended := c.facts.StartedAt.Add(event.Took) + c.facts.EndedAt = &ended + } + }) +} + +// callEnded records a call's end and answers its row. +func (b *appBackend) callEnded(a *appAgent, agent string, turn uint64, event session.Event, title, summary, detail string, ok bool) appwire.TurnTool { + log := b.callLog(a) + var row appwire.TurnTool + log.locked(func() { + c := log.calls[event.CallID] + if c == nil { + c = &appCall{id: event.CallID, tool: event.Tool, args: event.Args, turn: turn} + c.facts.Kind = appToolKind(event.Tool, "") + if event.CallID != "" { + log.calls[event.CallID] = c + log.order = append(log.order, event.CallID) + } + } + if c.title == "" { + c.title = title + } + c.summary, c.detail, c.ok = summary, detail, &ok + if c.facts.EndedAt == nil { + at := b.now() + c.facts.EndedAt = &at + } + if c.facts.Kind == appwire.ToolRun { + code := 0 + switch { + case event.Exit != nil: + code = *event.Exit + c.facts.ExitCode = &code + case ok: + c.facts.ExitCode = &code + } + } + if !c.streamed && strings.TrimSpace(event.Output) != "" { + log.writeLocked(c, redact.Secrets(event.Output)) + } + if c.partial != "" && c.file == nil { + c.lines++ + c.tail = append(c.tail, c.partial) + c.partial = "" + } + if c.file != nil { + _ = c.file.Close() + c.file = nil + } + c.ended = true + row = b.rowLocked(agent, c) + }) + return row +} + +// callAsked marks the call an approval is about: the log pauses there. +func (b *appBackend) callAsked(a *appAgent, agent string, live *appLive, call, ask string) { + if call == "" { + return + } + log := b.callLog(a) + var row appwire.TurnTool + known := false + log.locked(func() { + c := log.calls[call] + if c == nil { + return + } + c.facts.Kind, c.facts.Ask, known = appwire.ToolAsksFirst, ask, true + row = b.rowLocked(agent, c) + }) + if known { + live.box.Event(appwire.EventTurnTool, row) + } +} + +// callTask names the task a running task call made, once. +func (b *appBackend) callTask(a *appAgent, task string) { + log := b.callLog(a) + log.locked(func() { + for _, c := range log.turn { + if !c.ended && c.facts.Kind == appwire.ToolTask && c.facts.Task == "" { + c.facts.Task = task + return + } + } + }) +} + +// tickTools says a long call's row again with a fresh tail, at most every +// half second, until the turn ends. +func (b *appBackend) tickTools(a *appAgent, agent string, live *appLive) func() { + done := make(chan struct{}) + var once sync.Once + guard.Go("app/tool-tail", func() { + tick := time.NewTicker(appToolBeat) + defer tick.Stop() + log := b.callLog(a) + for { + select { + case <-done: + return + case <-tick.C: + } + var rows []appwire.TurnTool + now := b.now() + log.locked(func() { + for _, c := range log.turn { + if c.ended || c.facts.StartedAt == nil || now.Sub(*c.facts.StartedAt) < b.toolQuiet { + continue + } + row := b.rowLocked(agent, c) + said := strings.Join(row.Tail, "\n") + if said == c.sent { + continue + } + c.sent = said + rows = append(rows, row) + } + }) + for _, row := range rows { + live.box.Event(appwire.EventTurnTool, row) + } + } + }) + return func() { once.Do(func() { close(done) }) } +} + +// FollowTool says a call's new lines as tool.output events while followed. +func (b *appBackend) FollowTool(agent, call string, follow bool) error { + a, err := b.find(agent) + if err != nil { + return err + } + log := b.callLog(a) + log.locked(func() { + if c := log.calls[call]; c != nil { + c.followed = follow + } + }) + return nil +} + +// ToolOutput is one call's whole output by line, from its file, or from the +// conversation's own record for a call this app never watched. +func (b *appBackend) ToolOutput(agent, call string, from, limit int) ([]string, int, int, error) { + a, err := b.find(agent) + if err != nil { + return nil, 0, 0, err + } + if limit <= 0 { + limit = appOutputPage + } + limit = min(limit, appOutputPageMax) + log := b.callLog(a) + path := filepath.Join(log.dir, appFileName(call)+".log") + if file, err := os.Open(path); err == nil { + defer file.Close() + lines, total := appLinesOf(file, from, limit) + return lines, from + len(lines), total, nil + } + var transcript string + b.locked(func() { transcript = a.transcript }) + if transcript != "" { + record := session.ReadTranscript(transcript) + for _, items := range [][]session.DisplayEntry{record.Earlier, record.Entries} { + for _, item := range items { + if item.Role == "tool" && item.CallID == call { + lines, total := appLinesOf(strings.NewReader(redact.Secrets(item.Output)), from, limit) + return lines, from + len(lines), total, nil + } + } + } + } + return nil, 0, 0, appwire.Errorf(appwire.CodeNotFound, "there is no output for call %s", call) +} + +// appReadLines reads lines [from, from+limit) of r and counts them all. +func appLinesOf(r io.Reader, from, limit int) ([]string, int) { + reader := bufio.NewReader(r) + var lines []string + total := 0 + for { + line, err := reader.ReadString('\n') + if line != "" { + if total >= from && len(lines) < limit { + lines = append(lines, strings.TrimSuffix(strings.TrimSuffix(line, "\n"), "\r")) + } + total++ + } + if err != nil { + break + } + } + return lines, total +} + +// appEntryFacts is a tool entry's facts read back: what this process saw of +// the call, or what its record and its file still say. +func (b *appBackend) appEntryFacts(a *appAgent, item session.DisplayEntry) appwire.ToolFacts { + log := b.callLog(a) + var facts appwire.ToolFacts + known := false + log.locked(func() { + if c := log.calls[item.CallID]; c != nil && item.CallID != "" { + facts, known = c.factsLocked(), true + } + }) + if known { + return facts + } + var workspace string + b.locked(func() { workspace = a.view.Workspace }) + facts.Kind = appToolKind(item.Tool, "") + facts.Input = appToolInput(facts.Kind, item.Tool, item.Args, item.Hint, workspace) + if path, adds, dels, ok := tui3.ToolFileChange(item.Tool, item.Args); ok { + facts.Files = []appwire.FileChange{{Path: path, Add: adds, Del: dels}} + } + if facts.Kind == appwire.ToolRun && item.Answered { + code := 0 + if exit := appExitOf(item.Output); exit != nil { + code = *exit + } + facts.ExitCode = &code + } + output := redact.Secrets(item.Output) + if file, err := os.Open(filepath.Join(log.dir, appFileName(item.CallID)+".log")); err == nil && item.CallID != "" { + data, _ := io.ReadAll(io.LimitReader(file, 64<<20)) + _ = file.Close() + output = string(data) + } + if output != "" { + lines := strings.Split(strings.TrimSuffix(output, "\n"), "\n") + count := len(lines) + facts.Lines = &count + facts.Tail = lines[max(0, len(lines)-appTailEnd):] + } + return facts +} + +// appExitRe is the status line a shell result ends with when its command +// exited with a code other than zero. +var appExitRe = regexp.MustCompile(`Command exited with code (-?\d+)`) + +// appExitOf is the exit code a shell result's status line names, or nil. +func appExitOf(output string) *int { + all := appExitRe.FindAllStringSubmatch(output, -1) + if len(all) == 0 { + return nil + } + code, err := strconv.Atoi(all[len(all)-1][1]) + if err != nil { + return nil + } + return &code +} + +// sweepOutputs removes call output files older than a week, once, after the +// app has said hello. +func (b *appBackend) sweepOutputs() { + root := b.outputRoot() + cutoff := b.now().Add(-appOutputKept) + _ = filepath.WalkDir(root, func(path string, entry os.DirEntry, err error) error { + if err != nil { + if errors.Is(err, os.ErrNotExist) { + return filepath.SkipAll + } + return nil + } + if entry.IsDir() || !strings.HasSuffix(path, ".log") { + return nil + } + if info, err := entry.Info(); err == nil && info.ModTime().Before(cutoff) { + _ = os.Remove(path) + } + return nil + }) +} diff --git a/cmd/codeaf/apptools_test.go b/cmd/codeaf/apptools_test.go new file mode 100644 index 0000000000..3bbcaeeebe --- /dev/null +++ b/cmd/codeaf/apptools_test.go @@ -0,0 +1,121 @@ +package main + +import ( + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/session" +) + +// toolRow waits for the turn.tool row matching. +func toolRow(rec *appRecorder, t *testing.T, match func(appwire.TurnTool) bool) appwire.TurnTool { + t.Helper() + i := rec.waitFor(t, 3*time.Second, func(event string, data any) bool { + row, ok := data.(appwire.TurnTool) + return ok && match(row) + }) + rec.mu.Lock() + defer rec.mu.Unlock() + return rec.data[i].(appwire.TurnTool) +} + +// EVERY CALL CARRIES THE FACTS ITS ROW IS GROUPED BY: its kind, its clock, the +// calls started with it, its exit code, the earlier run it repeats, the lines +// an edit changed, what it was asked, and the tail of what it printed, which +// tools.output reads whole after the turn. +func TestAppToolRowsCarryTheWorkLogFacts(t *testing.T) { + exit := 1 + hold := make(chan struct{}) + defer close(hold) + conv := &fakeConversation{script: []session.Event{ + {Kind: session.EventToolBegin, CallID: "c1", Tool: "bash", Args: `{"command":"go test ./api/...","timeout":120}`, Batch: "c1"}, + {Kind: session.EventToolBegin, CallID: "c2", Tool: "grep", Args: `{"pattern":"MaxAttempts","path":"api/"}`, Batch: "c1"}, + {Kind: session.EventToolOutput, CallID: "c1", Tool: "bash", Text: "ok acme/api/billing\n--- FAIL: TestRetry\nFAIL acme/api/webhooks\n"}, + {Kind: session.EventToolFinished, CallID: "c1", Tool: "bash", Took: 1200 * time.Millisecond}, + {Kind: session.EventToolFailed, CallID: "c1", Tool: "bash", Hint: "FAIL acme/api/webhooks", Exit: &exit}, + {Kind: session.EventToolEnd, CallID: "c2", Tool: "grep", Output: "api/retry.go:12: MaxAttempts = 5"}, + {Kind: session.EventToolBegin, CallID: "c3", Tool: "edit", Args: `{"path":"api/deliver.go","edits":[{"oldText":"a\nb\n","newText":"a\nc\nd\n"}]}`}, + {Kind: session.EventToolEnd, CallID: "c3", Tool: "edit"}, + {Kind: session.EventToolBegin, CallID: "c4", Tool: "bash", Args: `{"command":"go test ./api/..."}`}, + {Kind: session.EventToolEnd, CallID: "c4", Tool: "bash", Output: "ok acme/api/webhooks"}, + {Kind: session.EventToolBegin, CallID: "c5", Tool: "bash", Args: `{"command":"sleep 60"}`}, + }, hold: hold} + backend, rec, agent := workBackend(t, conv) + backend.toolQuiet = 0 + if _, err := backend.Send(agent.ID, "fix the retries", ""); err != nil { + t.Fatal(err) + } + + run := toolRow(rec, t, func(row appwire.TurnTool) bool { return row.Call == "c1" && row.Phase == appwire.PhaseBegin }) + if run.Kind != appwire.ToolRun || run.Batch != "c1" || run.StartedAt == nil || run.Input == nil || run.Input.Text != "go test ./api/..." || run.Input.TimeoutS == nil || *run.Input.TimeoutS != 120 || run.Input.Cwd == "" { + t.Fatalf("the command's row = %+v", run) + } + failed := toolRow(rec, t, func(row appwire.TurnTool) bool { return row.Call == "c1" && row.Phase == appwire.PhaseEnd }) + if failed.ExitCode == nil || *failed.ExitCode != 1 || failed.EndedAt == nil || failed.EndedAt.Sub(*failed.StartedAt) != 1200*time.Millisecond || failed.Lines == nil || *failed.Lines != 3 || len(failed.Tail) != 3 || failed.Tail[2] != "FAIL acme/api/webhooks" { + t.Fatalf("the failed command's row = %+v", failed) + } + search := toolRow(rec, t, func(row appwire.TurnTool) bool { return row.Call == "c2" && row.Phase == appwire.PhaseEnd }) + if search.Kind != appwire.ToolSearch || search.Batch != "c1" || search.Input == nil || search.Input.Text == "" || search.ExitCode != nil || len(search.Tail) != 1 { + t.Fatalf("the search's row = %+v", search) + } + edit := toolRow(rec, t, func(row appwire.TurnTool) bool { return row.Call == "c3" && row.Phase == appwire.PhaseEnd }) + if edit.Kind != appwire.ToolEdit || len(edit.Files) != 1 || edit.Files[0].Path != "api/deliver.go" || edit.Files[0].Add != 2 || edit.Files[0].Del != 1 || edit.Batch != "" { + t.Fatalf("the edit's row = %+v", edit) + } + retry := toolRow(rec, t, func(row appwire.TurnTool) bool { return row.Call == "c4" && row.Phase == appwire.PhaseEnd }) + if retry.RetriedOf != "c1" || retry.ExitCode == nil || *retry.ExitCode != 0 { + t.Fatalf("the rerun's row = %+v", retry) + } + + // A long call is said again with a fresh tail, and a follower is told + // each new line. + toolRow(rec, t, func(row appwire.TurnTool) bool { return row.Call == "c5" && row.Phase == appwire.PhaseBegin }) + if err := backend.FollowTool(agent.ID, "c5", true); err != nil { + t.Fatal(err) + } + a, _ := backend.find(agent.ID) + live := backend.liveOf(a) + backend.callOutput(a, agent.ID, live, session.Event{Kind: session.EventToolOutput, CallID: "c5", Text: "tick 1\ntick 2\n"}) + rec.waitFor(t, 3*time.Second, func(event string, data any) bool { + out, ok := data.(appwire.ToolOutputEvent) + return ok && out.Call == "c5" && len(out.Lines) == 2 && out.Lines[1] == "tick 2" + }) + fresh := toolRow(rec, t, func(row appwire.TurnTool) bool { return row.Call == "c5" && len(row.Tail) == 2 }) + if fresh.Phase != appwire.PhaseBegin || fresh.Tail[0] != "tick 1" { + t.Fatalf("the long call said again = %+v", fresh) + } + + lines, next, total, err := backend.ToolOutput(agent.ID, "c1", 1, 1) + if err != nil || len(lines) != 1 || lines[0] != "--- FAIL: TestRetry" || next != 2 || total != 3 { + t.Fatalf("tools.output = %q %d %d %v", lines, next, total, err) + } + if _, _, _, err := backend.ToolOutput(agent.ID, "nothing", 0, 0); err == nil || !strings.Contains(err.Error(), "no output") { + t.Fatalf("a call with no output = %v", err) + } +} + +// A COMMAND'S KIND COMES FROM THE ENGINE'S OWN FAMILIES: a page fetched is +// web, a task proposed is a task, an account's tool is a connector. +func TestAppToolKindsReadTheEnginesFamilies(t *testing.T) { + for _, c := range []struct{ tool, service, want string }{ + {"read", "", appwire.ToolRead}, + {"find", "", appwire.ToolSearch}, + {"web_search", "", appwire.ToolWeb}, + {"web_fetch", "", appwire.ToolWeb}, + {"write", "", appwire.ToolEdit}, + {"bash", "", appwire.ToolRun}, + {"propose_task", "", appwire.ToolTask}, + {"gmail_search", "google", appwire.ToolConnector}, + {"use_service", "", appwire.ToolConnector}, + {"speak", "", appwire.ToolOther}, + } { + if got := appToolKind(c.tool, c.service); got != c.want { + t.Errorf("%s = %q, want %q", c.tool, got, c.want) + } + } + if code := appExitOf("boom\n\nCommand exited with code 2"); code == nil || *code != 2 { + t.Fatalf("the exit code = %v", code) + } +} diff --git a/cmd/codeaf/appwork.go b/cmd/codeaf/appwork.go index e2814dbc2b..98de5159ff 100644 --- a/cmd/codeaf/appwork.go +++ b/cmd/codeaf/appwork.go @@ -20,6 +20,7 @@ import ( "reflect" "strconv" "strings" + "sync" "sync/atomic" "time" @@ -307,17 +308,29 @@ func (b *appBackend) relineLocked(live *appLive, key string) (appwire.Task, bool // appTaskRoom is a conversation that can be watched one task at a time, the // door the terminal's task rail reads a running task's current call through. -// A conversation joined in a window's host has none, and its tasks' lines are -// what the engine says about them. type appTaskRoom interface { WatchTaskRoom(id uint64) (<-chan session.Event, func(), error) } +// appTaskJournal is a conversation in another process, whose tasks are read +// off the tail of their own journals, as the terminal's hosted task page does. +type appTaskJournal interface { + TaskRoom(id uint64, tail int) (session.TaskRecord, error) +} + +// appPilotTail is how much of a task's journal one look reads, in bytes: the +// call in flight is the last thing written. +const appPilotTail = 128 << 10 + +// appPilotBeat is how often a task's journal is looked at for its call. +const appPilotBeat = time.Second + // flyPilot watches one running task's calls, once, so its line can say the // call it is making: `write essay3.md`, `bash go test ./...`. func (b *appBackend) flyPilot(live *appLive, key string, n uint64) { - room, ok := live.conn.Conv.(appTaskRoom) - if !ok { + room, watches := live.conn.Conv.(appTaskRoom) + journal, reads := live.conn.Conv.(appTaskJournal) + if !watches && !reads { return } flying := false @@ -330,10 +343,19 @@ func (b *appBackend) flyPilot(live *appLive, key string, n uint64) { if flying { return } - lane, stop, err := room.WatchTaskRoom(n) - if err != nil { - b.locked(func() { delete(live.pilots, key) }) - return + var lane <-chan session.Event + var stop func() + if watches { + var err error + if lane, stop, err = room.WatchTaskRoom(n); err != nil { + b.locked(func() { delete(live.pilots, key) }) + return + } + } else { + done := make(chan struct{}) + var once sync.Once + stop = func() { once.Do(func() { close(done) }) } + lane = appJournalLane(journal, n, done) } if stop == nil { stop = func() {} @@ -353,6 +375,61 @@ func (b *appBackend) flyPilot(live *appLive, key string, n uint64) { guard.Go("app/pilot", func() { b.pilot(live, key, lane) }) } +// appJournalLane turns looks at a task's journal into the call events a +// watched lane would carry: a begin when a call is in flight, an end when +// none is. It closes when done does. +func appJournalLane(journal appTaskJournal, n uint64, done <-chan struct{}) <-chan session.Event { + lane := make(chan session.Event, 1) + guard.Go("app/pilot-journal", func() { + defer close(lane) + tick := time.NewTicker(appPilotBeat) + defer tick.Stop() + said := "" + for { + select { + case <-done: + return + case <-tick.C: + } + record, err := journal.TaskRoom(n, appPilotTail) + if err != nil { + continue + } + event := session.Event{Kind: session.EventToolEnd} + read := session.ReadTranscriptBytes(record.Journal) + if last := appInFlight(read.Entries); last != nil { + event = session.Event{Kind: session.EventToolBegin, Tool: last.Tool, Args: last.Args, Hint: last.Hint} + } + now := event.Tool + "\x00" + event.Args + if now == said { + continue + } + said = now + select { + case lane <- event: + case <-done: + return + } + } + }) + return lane +} + +// appInFlight is the call a journal's tail has asked for and not yet heard +// back from, or nil. +func appInFlight(entries []session.DisplayEntry) *session.DisplayEntry { + for i := len(entries) - 1; i >= 0; i-- { + if entries[i].Role != "tool" { + continue + } + if entries[i].Answered { + return nil + } + return &entries[i] + } + return nil +} + // pilot folds one task's call events into its line until the lane closes. func (b *appBackend) pilot(live *appLive, key string, lane <-chan session.Event) { for event := range lane { diff --git a/cmd/codeaf/engine.go b/cmd/codeaf/engine.go index 5ff225cdb9..282664d2af 100644 --- a/cmd/codeaf/engine.go +++ b/cmd/codeaf/engine.go @@ -1044,6 +1044,7 @@ func assembleEngine(hello remote.Hello, workspace, sessionFlag, launchDir string if hello.Launch != nil { cfg.DeterministicNames = hello.Launch.DeterministicNames cfg.HandOverOnAsk = hello.Launch.HandOverOnAsk + cfg.LiveToolOutput = hello.Launch.LiveToolOutput if clock := hello.Launch.ProposalClockS; clock != nil { cfg.TaskAutoApproveSeconds = *clock } diff --git a/docs/changes/unreleased/0-app-wave3.md b/docs/changes/unreleased/0-app-wave3.md index 87764ce3df..6cce97d941 100644 --- a/docs/changes/unreleased/0-app-wave3.md +++ b/docs/changes/unreleased/0-app-wave3.md @@ -18,3 +18,13 @@ New methods: `tasks.get`, `tasks.start` / `hold` / `decline` / `typing` / `irreversible`, `your_call`, `result`, `files`, `spent_usd` and the live `line`; listed entries carry `turn` and `client_id` for messages this launch sent. Events: `quick.changed`, `process.changed`, `process.output`, `turn.long`. + +The work log's detail: `turn.tool` and tool entries carry `kind`, `started_at`, +`ended_at`, `batch`, `exit_code`, `retried_of`, `files`, `connector`, `task`, +`ask`, `input`, `tail` and `lines`, and a call running past five seconds is +said again at most every half second with a fresh tail. `tools.output` reads a +call's whole output by line, after the turn too, and `tools.follow` / +`tools.unfollow` say a running call's new lines as `tool.output` events. The +engine sends a model's shell output as it arrives when the launch asks for it +(`liveToolOutput`), and tool begin and end events carry the batch, the account +and the exit code. diff --git a/internal/appwire/wire.go b/internal/appwire/wire.go index e91f021dba..067032b84c 100644 --- a/internal/appwire/wire.go +++ b/internal/appwire/wire.go @@ -228,6 +228,8 @@ type Entry struct { Quiet bool `json:"quiet,omitempty"` ErrorKind string `json:"error_kind,omitempty"` Status int `json:"status,omitempty"` + // A tool entry carries the facts of its call (worklog.go). + ToolFacts } // The kinds of entry. @@ -432,6 +434,7 @@ type ( Phase string `json:"phase"` OK *bool `json:"ok,omitempty"` Summary string `json:"summary,omitempty"` + ToolFacts } TurnDone struct { Agent string `json:"agent"` diff --git a/internal/appwire/worklog.go b/internal/appwire/worklog.go new file mode 100644 index 0000000000..358edff2db --- /dev/null +++ b/internal/appwire/worklog.go @@ -0,0 +1,146 @@ +package appwire + +import ( + "encoding/json" + "strings" + "time" +) + +// The work log's detail (PROTOCOL.md, "Work log detail"): the facts of every +// tool call, from which the app groups, folds, pins and tails its rows, and +// the full output of one call by line. + +// The kinds of tool call. +const ( + ToolRead = "read" + ToolSearch = "search" + ToolRun = "run" + ToolEdit = "edit" + ToolWeb = "web" + ToolConnector = "connector" + ToolTask = "task" + ToolAsksFirst = "asks_first" + ToolOther = "other" +) + +// EventToolOutput is a followed call's new output lines. +const EventToolOutput = "tool.output" + +// ToolFacts are one call's facts, on turn.tool and on its tool entry. +type ToolFacts struct { + Kind string `json:"kind,omitempty"` + StartedAt *time.Time `json:"started_at,omitempty"` + EndedAt *time.Time `json:"ended_at,omitempty"` + // Batch is the same on every call the agent started together. + Batch string `json:"batch,omitempty"` + ExitCode *int `json:"exit_code,omitempty"` + // RetriedOf is the earlier call of this turn that ran the same command. + RetriedOf string `json:"retried_of,omitempty"` + Files []FileChange `json:"files,omitempty"` + Connector string `json:"connector,omitempty"` + Task string `json:"task,omitempty"` + Ask string `json:"ask,omitempty"` + Input *ToolInput `json:"input,omitempty"` + // Tail is the last lines of the call's output: three while it runs, up + // to twelve once it has ended. Lines is how many it wrote in all. + Tail []string `json:"tail,omitempty"` + Lines *int `json:"lines,omitempty"` +} + +// ToolInput is what a call was asked to do: the command, query or path, and +// where and for how long a command may run. +type ToolInput struct { + Cwd string `json:"cwd,omitempty"` + TimeoutS *int `json:"timeout_s,omitempty"` + Text string `json:"text"` +} + +// ToolOutputEvent is a followed call's new lines. +type ToolOutputEvent struct { + Agent string `json:"agent"` + Call string `json:"call"` + Lines []string `json:"lines"` +} + +// ToolReader is the engine's half of the work log's output: one call's whole +// output by line, and following one call's output as it arrives. +type ToolReader interface { + ToolOutput(agent, call string, from, limit int) (lines []string, next, total int, err error) + FollowTool(agent, call string, follow bool) error +} + +func init() { + for name, m := range toolMethods { + methods[name] = m + } +} + +// toolReader is the engine as a ToolReader, or a refusal. +func toolReader(e Engine, name string) (ToolReader, error) { + r, ok := e.(ToolReader) + if !ok { + return nil, Errorf(CodeUnknownMethod, "this engine does not serve %s", name) + } + return r, nil +} + +// toolCallParams names one call of one agent. +type toolCallParams struct { + Agent string `json:"agent"` + Call string `json:"call"` + FromLine int `json:"from_line"` + Limit int `json:"limit"` +} + +func decodeToolCall(raw json.RawMessage, name string) (toolCallParams, error) { + var p toolCallParams + if err := decode(raw, &p); err != nil { + return p, err + } + if strings.TrimSpace(p.Agent) == "" || strings.TrimSpace(p.Call) == "" { + return p, Errorf(CodeBadParams, "%s needs an agent and a call", name) + } + if p.FromLine < 0 || p.Limit < 0 { + return p, Errorf(CodeBadParams, "from_line and limit are zero or more") + } + return p, nil +} + +// toolFollow is tools.follow or tools.unfollow. +func toolFollow(name string, follow bool) method { + return func(e Engine, raw json.RawMessage) (any, error) { + r, err := toolReader(e, name) + if err != nil { + return nil, err + } + p, err := decodeToolCall(raw, name) + if err != nil { + return nil, err + } + return struct{}{}, r.FollowTool(p.Agent, p.Call, follow) + } +} + +var toolMethods = map[string]method{ + "tools.output": func(e Engine, raw json.RawMessage) (any, error) { + r, err := toolReader(e, "tools.output") + if err != nil { + return nil, err + } + p, err := decodeToolCall(raw, "tools.output") + if err != nil { + return nil, err + } + lines, next, total, err := r.ToolOutput(p.Agent, p.Call, p.FromLine, p.Limit) + if err != nil { + return nil, err + } + return struct { + Lines []string `json:"lines"` + NextLine int `json:"next_line"` + Total int `json:"total"` + }{nonNil(lines), next, total}, nil + }, + "tools.follow": toolFollow("tools.follow", true), + "tools.unfollow": toolFollow("tools.unfollow", false), +} diff --git a/internal/exec/bare/tools.go b/internal/exec/bare/tools.go index 30ff7841d0..6e0af96223 100644 --- a/internal/exec/bare/tools.go +++ b/internal/exec/bare/tools.go @@ -398,14 +398,20 @@ const maxTimeoutMs = 2147483647 type bashOutputKey struct{} +// WithBashOutput is a context whose shell calls mirror their raw output to +// output as it arrives, the way [RunBash] does for a command the person typed. +func WithBashOutput(ctx context.Context, output io.Writer) context.Context { + if output == nil { + return ctx + } + return context.WithValue(ctx, bashOutputKey{}, output) +} + // RunBash runs the ordinary non-interactive shell and mirrors its raw output to // a caller-owned writer as it arrives. The runner retains its normal bounds, // cancellation, process isolation and spill handling. func RunBash(ctx context.Context, cwd string, args json.RawMessage, caps Caps, output io.Writer) (string, bool, error) { - if output != nil { - ctx = context.WithValue(ctx, bashOutputKey{}, output) - } - return newBashTool(cwd, caps).Execute(ctx, args) + return newBashTool(cwd, caps).Execute(WithBashOutput(ctx, output), args) } func newBashTool(cwd string, caps Caps) Tool { diff --git a/internal/manual/chat/running-from-the-terminal.md b/internal/manual/chat/running-from-the-terminal.md index 4279cb4812..483ccc441c 100644 --- a/internal/manual/chat/running-from-the-terminal.md +++ b/internal/manual/chat/running-from-the-terminal.md @@ -338,6 +338,17 @@ to sleep the app stops every running process and the agent reads asleep; on waking, each stopped process leaves one line, such as `link checker stopped while this Mac slept · Rerun`, and nothing is restarted for you. +## The desktop app's work log — what each step says, and a command's whole output + +Each step in the app's work log says its kind (read, search, command, edit, +web, an account, a task, or a step that asked you first), when it started and +ended, which steps the agent started together, a command's exit code, and +whether it repeats an earlier command of the same reply. An edit says the +lines it added and removed. A command still going shows its last three lines, +and twelve once it ends. A command's whole output is kept on this Mac for a +week under the app's folder in codeaf's home, so it can be opened after the +reply ends, or after the app restarts. + ## Connect from the terminal without opening the chat — codeaf connect and codeaf disconnect `codeaf connect` lists every model provider this profile knows, whether it is connected, diff --git a/internal/remote/wire.go b/internal/remote/wire.go index 79b3122609..90d5ec18f3 100644 --- a/internal/remote/wire.go +++ b/internal/remote/wire.go @@ -809,6 +809,9 @@ type LaunchShape struct { // ProposalClockS, when set, is how many seconds a proposed task waits before // it starts on its own ([session.Config.TaskAutoApproveSeconds]). ProposalClockS *int `json:"proposalClockS,omitempty"` + // LiveToolOutput sends a model's shell output as it arrives + // ([session.Config.LiveToolOutput]); the desktop app sets it. + LiveToolOutput bool `json:"liveToolOutput,omitempty"` Yolo bool `json:"yolo,omitempty"` NoCompact bool `json:"noCompact,omitempty"` diff --git a/internal/session/loop.go b/internal/session/loop.go index d73bbfcccc..7ea3f488ce 100644 --- a/internal/session/loop.go +++ b/internal/session/loop.go @@ -255,6 +255,13 @@ const argsLimit = 32768 // or two of a file or a build log, which is what an expanded row is for. const outputLimit = 4000 +// liveToolOutputBytes and liveToolOutputLines bound what one call sends as it +// runs ([Config.LiveToolOutput]); its result is bounded by the runner as ever. +const ( + liveToolOutputBytes = 8 << 20 + liveToolOutputLines = 200000 +) + // ── the turn ──────────────────────────────────────────────────────────────── // settleBoundTripped reports whether the settle turn has spent its bound, and @@ -3430,14 +3437,27 @@ func (a *Agent) runToolsWarm(ctx context.Context, ep *episode, calls []ai.ToolCa // pairs a finished row with the row it has been drawing — so one rendering is // not an optimization of three, it is the honest spelling of them. rendered := make([]string, len(calls)) + // Calls started together carry one name for their batch: the first call's + // id, and nothing on a call that runs alone. + batch := "" + if len(calls) > 1 { + batch = calls[0].ID + } for index, call := range calls { rendered[index] = argsText(call) + service, serviceName := a.serviceOf(call.Function.Name), "" + if service != "" { + serviceName = a.serviceName(service) + } hub.send(Event{ - Kind: EventToolBegin, - Tool: call.Function.Name, - Hint: a.gloss(call), - Args: rendered[index], - CallID: call.ID, + Kind: EventToolBegin, + Tool: call.Function.Name, + Hint: a.gloss(call), + Args: rendered[index], + CallID: call.ID, + Batch: batch, + Service: service, + ServiceName: serviceName, }) } @@ -3553,6 +3573,7 @@ func (a *Agent) runToolsWarm(ctx context.Context, ep *episode, calls []ai.ToolCa Args: rendered[index], Output: capOutput(results[index].text), CallID: call.ID, + Exit: resultExit(results[index].text), // WHOSE FAILURE THIS WAS travels with it. Everything counting // steps out of band — the runner's no-progress ledger above all — // reads events and not results, so a fact kept only on the result @@ -3574,11 +3595,30 @@ func (a *Agent) runToolsWarm(ctx context.Context, ep *episode, calls []ai.ToolCa Args: rendered[index], Output: capOutput(results[index].text), CallID: call.ID, + Exit: resultExit(results[index].text), }) } return results } +// resultExitRe is the status line a shell call's result ends with when its +// command exited with a code other than zero. +var resultExitRe = regexp.MustCompile(`Command exited with code (-?\d+)`) + +// resultExit is the exit code a call's result reports, or nil for a result +// that reports none: a command that exited zero says nothing about it. +func resultExit(text string) *int { + all := resultExitRe.FindAllStringSubmatch(text, -1) + if len(all) == 0 { + return nil + } + code, err := strconv.Atoi(all[len(all)-1][1]) + if err != nil { + return nil + } + return &code +} + // executeTool dispatches one call to the matching belt tool. // // ── THE PRE-ACTION CHOKEPOINT ── @@ -3676,7 +3716,11 @@ func (a *Agent) dispatchTool(ctx context.Context, ep *episode, hub *eventHub, ca // somehow, and the id on the row is the only handle it has (promote.go). // It is set at this chokepoint rather than per tool, so the early warm // start carries it exactly as the batch does. - text, isError, err := a.executeTaskTool(withCallID(ctx, call.ID), tool, running, args) + callCtx := withCallID(ctx, call.ID) + if hub != nil && a.config.LiveToolOutput { + callCtx = bare.WithBashOutput(callCtx, &shellOutput{hub: hub, id: call.ID, tool: call.Function.Name, bytes: liveToolOutputBytes, lines: liveToolOutputLines}) + } + text, isError, err := a.executeTaskTool(callCtx, tool, running, args) // THE ROW'S CLOCK IS THIS CALL'S OWN CLOCK. The result cannot be sent // yet — it goes out with the batch, in call order, because that is the // order the transcript is written in — but the fact that this call is diff --git a/internal/session/session.go b/internal/session/session.go index 0f3174930d..40851ede32 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -880,6 +880,15 @@ type Event struct { // the same field with two answers. CallID string + // Exit is the exit code a call's result reports, on EventToolEnd and + // EventToolFailed, and nil when it reports none. + Exit *int `json:"Exit,omitempty"` + + // Batch names the calls the model asked for together, which run at once: + // the first call's id, on every EventToolBegin of a batch of two or more, + // and empty on a call that ran alone. + Batch string `json:"Batch,omitempty"` + // ArgsText is the RAW, PARTIAL arguments text of a forming call: exactly what // the provider has streamed so far, uncompacted and unparsed. It is set on // EventToolForming and empty everywhere else — Args is the display JSON of a @@ -1020,7 +1029,9 @@ type Event struct { // [Agent.ResolveConnect]. Service is the account's id — "google" — on all // three kinds; ServiceName is the word a person reads — "Google" — on the // ask. AuthURL is the page to open, on EventConnectAuth only. Account and - // Failed are the outcome, on EventConnectDone only. + // Failed are the outcome, on EventConnectDone only. Service and + // ServiceName also name the account an EventToolBegin's call acts through, + // and are empty on a call that reaches no account. ConnectID string Service string ServiceName string @@ -1221,6 +1232,11 @@ type Config struct { // ([Agent.MoveTurnToTask]), and the app says the reply is running long. HandOverOnAsk bool + // LiveToolOutput is the desktop app's too: a shell call the model makes + // sends its output as it arrives (EventToolOutput), as one the person + // typed always does, so a work log can show what a long command is saying. + LiveToolOutput bool + // WaitForBeltSteps is for the run worker that enforces its limits and // delivers notes from tool-end events. Its sole event reader must close // Event.BeltStepHandled after processing each such event. Other agents diff --git a/internal/session/userbash.go b/internal/session/userbash.go index a9cb1fe06a..8a73142c5b 100644 --- a/internal/session/userbash.go +++ b/internal/session/userbash.go @@ -96,7 +96,7 @@ func displayToolOutput(id, output string) string { // is retained across writes so remote JSON frames cannot corrupt split runes. type shellOutput struct { hub *eventHub - id string + id, tool string bytes, lines int pending []byte cut bool @@ -122,12 +122,12 @@ func (w *shellOutput) Write(p []byte) (int, error) { } } if end > 0 { - w.hub.send(Event{Kind: EventToolOutput, Tool: "bash", CallID: w.id, Text: strings.ToValidUTF8(string(w.pending[:end]), "�")}) + w.hub.send(Event{Kind: EventToolOutput, Tool: w.tool, CallID: w.id, Text: strings.ToValidUTF8(string(w.pending[:end]), "�")}) } w.pending = append(w.pending[:0], w.pending[end:]...) if w.cut { w.pending = nil - w.hub.send(Event{Kind: EventToolOutput, Tool: "bash", CallID: w.id, Text: "\n[Live output limit reached; the final result includes the saved output path.]\n"}) + w.hub.send(Event{Kind: EventToolOutput, Tool: w.tool, CallID: w.id, Text: "\n[Live output limit reached; the final result includes the saved output path.]\n"}) } return n, nil } @@ -158,7 +158,7 @@ func (a *Agent) runUserBash(ctx context.Context, hub *eventHub, command string) output, failed = "Command cancelled", true } else { caps := a.resultCaps() - writer := &shellOutput{hub: hub, id: call.ID, bytes: caps.MaxBytes, lines: caps.MaxLines} + writer := &shellOutput{hub: hub, id: call.ID, tool: "bash", bytes: caps.MaxBytes, lines: caps.MaxLines} var err error output, failed, err = bare.RunBash(ctx, a.config.Workspace, json.RawMessage(checked.Function.Arguments), caps, writer) if err != nil { diff --git a/internal/tui3/appseams.go b/internal/tui3/appseams.go index 9c4c9e0e5a..7eec990cf7 100644 --- a/internal/tui3/appseams.go +++ b/internal/tui3/appseams.go @@ -56,3 +56,27 @@ func PickerModels(profileDir string, sources modelsource.Set, defaults func() [] func TaskPhaseWords(phase string, round, rounds int) string { return taskPhaseWords(phase, round, rounds) } + +// ToolTarget is what a call is about, as its row says it after the tool's +// name: the command, the path, the query. "" when the call names nothing. +func ToolTarget(tool, args, hint string) string { + return toolTarget(tool, args, hint) +} + +// ToolFileChange is the file a call writes and the lines it adds and removes, +// by the same counts the terminal's row draws (+12 −3), and false for a call +// that writes no file it can count. +func ToolFileChange(tool, args string) (path string, adds, dels int, ok bool) { + fields := argsOf(args) + path = argString(fields, "path") + switch tool { + case "edit": + adds, dels, _ = editStat(args) + case "write": + content, _ := argBody(argString(fields, "content")) + adds = lineCount(content) + default: + return "", 0, 0, false + } + return path, adds, dels, path != "" +} From bdf09d7531a21fbb1d62c102296a987fa3c3c3dc Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Thu, 15 Oct 2026 09:08:20 -0400 Subject: [PATCH 009/459] codeaf app: conversation (steer, queue, thinking, decision dock, notes to a task) and the Inbox Wave 3b and Wave 4 of the desktop app backend, committed together because their wiring in app.go is interleaved. Renames ToolFacts.Kind to tool_kind and Entry.Footer to footer_kind per the spec, caps an agent at 16 running processes, and adds the manual sections for the conversation and the Inbox. --- cmd/codeaf/app.go | 170 ++- cmd/codeaf/app_test.go | 61 +- cmd/codeaf/appconvo.go | 1072 +++++++++++++++++ cmd/codeaf/appconvo_test.go | 317 +++++ cmd/codeaf/appinbox.go | 450 +++++++ cmd/codeaf/appinbox_test.go | 86 ++ cmd/codeaf/appstate.go | 10 + cmd/codeaf/apptools.go | 7 +- cmd/codeaf/appwork.go | 73 +- docs/changes/unreleased/0-app-wave3b.md | 23 + docs/changes/unreleased/0-app-wave4-inbox.md | 12 + internal/appwire/convo.go | 486 ++++++++ internal/appwire/inbox.go | 195 +++ internal/appwire/outbox.go | 9 +- internal/appwire/server.go | 39 +- internal/appwire/server_test.go | 6 +- internal/appwire/wire.go | 50 +- internal/appwire/work.go | 15 +- internal/appwire/worklog.go | 2 +- .../manual/chat/running-from-the-terminal.md | 26 + internal/session/jobs.go | 28 + 21 files changed, 3068 insertions(+), 69 deletions(-) create mode 100644 cmd/codeaf/appconvo.go create mode 100644 cmd/codeaf/appconvo_test.go create mode 100644 cmd/codeaf/appinbox.go create mode 100644 cmd/codeaf/appinbox_test.go create mode 100644 docs/changes/unreleased/0-app-wave3b.md create mode 100644 docs/changes/unreleased/0-app-wave4-inbox.md create mode 100644 internal/appwire/convo.go create mode 100644 internal/appwire/inbox.go diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index ccc64a7934..6ab7b636f8 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -154,6 +154,10 @@ type appConversation interface { AppQuickTasks() ([]session.AppQuickTaskView, error) AppQuickTaskAction(intent session.AppTaskIntent) ([]string, []string, error) MoveTurnToTask() error + // Steer puts words into the running turn (internal/session's steer.go), + // and RetryTask runs a failed task again. + Steer(text string) (<-chan session.Event, error) + RetryTask(id uint64) error JobNotices() ([]session.JobNotice, error) RerunProcess(id int) (session.JobNotice, error) Cancel(id string) (string, error) @@ -266,6 +270,8 @@ type appBackend struct { today func(time.Time) float64 // turnLong is how long a reply runs before turn.long ([appTurnLong]). turnLong time.Duration + // inbox is every open ask as one list (appinbox.go); emit is its tap. + inbox *appInbox // toolQuiet is how long a call runs before its row is said again with a // fresh tail ([appToolQuiet]). toolQuiet time.Duration @@ -304,12 +310,25 @@ type appAgent struct { marks map[int]appTurnMark // calls is the work log's record of this agent's tool calls (apptools.go). calls *appCallLog + // queue is the person's messages waiting for the turn to end, attach the + // attachments kept for the next message, walk the last walk of the + // workspace's files, and sending the message being sent (appconvo.go). + queue []appQueued + queueN int + attach map[string]appAttach + attachN int + walk *appWalk + sending *appSending } // appTurnMark is what this process knows of one of the person's messages. type appTurnMark struct { turn uint64 clientID string + // text and attachments are the person's own words and the files and + // links that rode with them, where the engine was sent both as words. + text string + attachments []appwire.Attachment } // appBoot is agents.create's ask, kept for the background open. @@ -368,6 +387,16 @@ type appLive struct { logs map[string]string // slept are the processes a sleep stopped, said once on wake. slept []appSlept + // steers counts the turn's steers; adopting is how many fallen-through + // steers are about to become turns; batchDone are the answered calls of + // each batch of asks; notes are the notes sent to each task not yet + // read, and noteN counts them; closed says the conversation was let go. + steers int + adopting int + batchDone map[string][]appwire.BatchItem + notes map[string][]appNotePending + noteN int + closed bool } // appConsentCall is the call one approval is about. @@ -403,7 +432,6 @@ func newAppBackend(emit appwire.Emitter, open appOpener) *appBackend { turnLong: appTurnLong, toolQuiet: appToolQuiet, follows: map[string]*appFollow{}, - emit: emit, open: open, now: time.Now, agents: map[string]*appAgent{}, @@ -411,6 +439,7 @@ func newAppBackend(emit appwire.Emitter, open appOpener) *appBackend { sweepDone: make(chan struct{}), mint: appMint, } + b.emit = b.newAppInbox(emit) guard.Go("app/sweep", b.sweep) return b } @@ -977,6 +1006,8 @@ func (b *appBackend) questionEvent(a *appAgent, id string, live *appLive, event b.callAsked(a, id, live, q.Subject.CallID, askID) } ask := appAskOf(id, q) + appAskFacts(id, &ask, q) + defer b.restateBatch(live, q) restated, moved := false, false var view appwire.Agent b.locked(func() { @@ -1033,6 +1064,11 @@ func (b *appBackend) questionEvent(a *appAgent, id string, live *appLive, event } at := b.now() words := appReceipt(by, option, answer, at) + // HANDED BACK, the agent's own pick answered it, and the receipt says so. + if picked, decided := strings.CutPrefix(option, appDecideMark); decided { + option = "you_decide" + words = "You let it decide · it chose " + picked + " · " + at.Local().Format("15:04") + } // A PROPOSAL ITS CLOCK STARTED closes with no answer from anybody; the // task it became, already said, is what tells it from one taken back. if q.Kind == session.QuestionTask && answer == nil { @@ -1204,6 +1240,7 @@ func (b *appBackend) startLocked(a *appAgent, text, clientID string) (uint64, er } var turn uint64 var n int + shown, attachments := text, []appwire.Attachment(nil) b.locked(func() { if live.running { refused = appwire.Errorf(appwire.CodeBusy, "this agent is already working on a turn") @@ -1212,12 +1249,20 @@ func (b *appBackend) startLocked(a *appAgent, text, clientID string) (uint64, er live.running, live.stopping = true, false live.lastUse = b.now() turn = b.turns.Add(1) - live.turn, live.steps = turn, 0 + live.turn, live.steps, live.steers = turn, 0, 0 n = a.users if a.marks == nil { a.marks = map[int]appTurnMark{} } - a.marks[n] = appTurnMark{turn: turn, clientID: clientID} + mark := appTurnMark{turn: turn, clientID: clientID} + if a.sending != nil { + mark.text, mark.attachments = a.sending.text, a.sending.attachments + } + a.marks[n] = mark + shown, attachments = text, mark.attachments + if mark.text != "" { + shown = mark.text + } }) if refused != nil { return 0, refused @@ -1241,7 +1286,7 @@ func (b *appBackend) startLocked(a *appAgent, text, clientID string) (uint64, er live.box.Event(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) live.box.Event(appwire.EventTurnStarted, appwire.TurnStarted{Agent: id, Turn: turn}) live.box.Event(appwire.EventEntryAdded, appwire.EntryAdded{Entry: appwire.Entry{ - ID: appUserEntryID(n), Agent: id, Turn: turn, Kind: appwire.EntryUser, At: b.now(), Text: text, ClientID: clientID, + ID: appUserEntryID(n), Agent: id, Turn: turn, Kind: appwire.EntryUser, At: b.now(), Text: shown, ClientID: clientID, Attachments: attachments, }}) long := b.watchLong(id, live, turn, b.now()) b.newTurnCalls(a) @@ -1250,6 +1295,7 @@ func (b *appBackend) startLocked(a *appAgent, text, clientID string) (uint64, er defer long.Stop() defer tails() b.pump(a, id, live, turn, n, text, events) + b.sendQueued(a, live) }) return turn, nil } @@ -1280,31 +1326,63 @@ func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, n // where a tool call begins after its words, or where the turn ends, and // it is counted only if it said something — the same rule the listing // counts by ([appEntriesOf]), so both name it alike. - msg, notices := 0, 0 - var reply, thinking strings.Builder - sayReply := func() { + msg, notices, summaries := 0, 0, 0 + var reply strings.Builder + // A MESSAGE BEFORE A TOOL CALL IS AN UPDATE, the agent saying what it + // found mid-turn; the last one is the answer. + sayReply := func(kind string) { words := strings.TrimSpace(reply.String()) if words != "" { b.locked(func() { live.lastAnswer = words }) at := msg - entry(appwire.Entry{ID: appAnswerEntryID(n, at), Kind: appwire.EntryAnswer, Text: words, Thinking: strings.TrimSpace(thinking.String()), Msg: &at}) + entry(appwire.Entry{ID: appAnswerEntryID(n, at), Kind: kind, Text: words, Msg: &at}) msg++ } reply.Reset() - thinking.Reset() + } + // A THOUGHT IS SHOWN WHILE IT HAPPENS AND NEVER SAVED: its words go out + // as turn.thinking, and its length once the agent moves on. + var thoughtAt time.Time + endThought := func() { + if !thoughtAt.IsZero() { + live.box.Event(appwire.EventTurnThinkingDone, appwire.TurnThinkingDone{Agent: id, Turn: turn, Seconds: appwire.ThinkingSeconds(b.now().Sub(thoughtAt))}) + thoughtAt = time.Time{} + } } titles := map[string]string{} for event := range events { switch event.Kind { case session.EventTextDelta: + endThought() reply.WriteString(event.Text) live.box.Delta(id, turn, msg, appwire.DeltaText, event.Text) case session.EventReasoning: - thinking.WriteString(event.Text) + if thoughtAt.IsZero() { + thoughtAt = b.now() + } live.box.Delta(id, turn, msg, appwire.DeltaThinking, event.Text) + case session.EventRetrying: + // The attempt is void: what it streamed is dropped, here and on + // the page, and a move to another model is said once. + reply.Reset() + thoughtAt = time.Time{} + retry := appwire.TurnRetrying{Agent: id, Turn: turn, Attempt: 1} + if news := event.Retry; news != nil { + retry.Attempt = max(1, news.Attempt) + if strings.TrimSpace(news.Next) != "" { + footer := appFooter("f"+strconv.Itoa(n)+".m"+strconv.Itoa(retry.Attempt), id, turn, appwire.FooterFallback, redact.Secrets(strings.TrimSpace(event.Text)), b.now()) + footer.ModelFrom, footer.ModelTo = news.Model, news.Next + live.box.Event(appwire.EventEntryAdded, appwire.EntryAdded{Entry: footer}) + } + } + live.box.Event(appwire.EventTurnRetrying, retry) + case session.EventCompacted: + entry(appwire.Entry{ID: "c" + strconv.Itoa(n) + "." + strconv.Itoa(summaries), Kind: appwire.EntrySummary, Text: "Earlier messages summarized"}) + summaries++ case session.EventToolBegin: b.locked(func() { live.steps++ }) - sayReply() + endThought() + sayReply(appwire.EntryUpdate) title := redact.Secrets(tui3.ToolTitle(event.Tool, event.Args, event.Hint)) titles[event.CallID] = title row := b.callBegan(a, id, turn, event, title) @@ -1329,6 +1407,11 @@ func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, n title = redact.Secrets(tui3.ToolTitle(event.Tool, event.Args, "")) } live.box.Event(appwire.EventTurnTool, b.callEnded(a, id, turn, event, title, summary, appDetail(summary), succeeded)) + // THE SEVENTEENTH PROCESS is refused by the engine, and the turn + // says so once at its end. + if !succeeded && strings.Contains(event.Output+summary, "processes are running for this agent.") { + entry(appFooter("pl"+event.CallID, id, turn, appwire.FooterProcessLimit, strings.TrimSpace(summary), b.now())) + } case session.EventConsentRequest: call := appConsentCall{Tool: event.Tool, Args: event.Args} b.locked(func() { live.consents[event.ID] = call }) @@ -1359,7 +1442,8 @@ func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, n b.workEvent(id, live, event) } } - sayReply() + endThought() + sayReply(appwire.EntryAnswer) byPerson := false b.locked(func() { byPerson = live.stopping @@ -1382,9 +1466,13 @@ func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, n at := b.now() words := "Stopped · " + at.Local().Format("15:04") if byPerson { - words = "Stopped · you · " + at.Local().Format("15:04") + words = "Stopped by you · " + at.Local().Format("15:04") } - entry(appwire.Entry{ID: appStopEntryID(n), Kind: appwire.EntryReceipt, Text: words}) + entry(appFooter(appStopEntryID(n), id, turn, appwire.FooterStoppedByYou, words, at)) + case !ok && kind == appwire.ErrorProvider: + footer := appFooter(appErrorEntryID(n), id, turn, appwire.FooterProviderFailed, failure, b.now()) + footer.ErrorKind, footer.Status = kind, status + entry(footer) case !ok: entry(appwire.Entry{ID: appErrorEntryID(n), Kind: appwire.EntryError, Text: failure, ErrorKind: kind, Status: status}) } @@ -1436,18 +1524,31 @@ func appReceiptEntryID(askID string) string { return "r" + askID } // appUserCount is how many of the person's messages a transcript holds. func appUserCount(transcript string) int { - record := session.ReadTranscript(transcript) users := 0 - for _, items := range [][]session.DisplayEntry{record.Earlier, record.Entries} { - for _, item := range items { - if item.Role == "user" { - users++ - } + for _, item := range appRecordItems(session.ReadTranscript(transcript)) { + if item.Role == "user" { + users++ } } return users } +// appSummaryRole marks where a compaction summarized what came before. +const appSummaryRole = "summary" + +// appRecordItems is a record told once and whole: the conversation above the +// last compaction, a mark where it was summarized, and the live transcript +// beneath its floor. +func appRecordItems(record session.Record) []session.DisplayEntry { + if len(record.Earlier) == 0 { + return record.Entries + } + items := append([]session.DisplayEntry(nil), record.Earlier...) + items = append(items, session.DisplayEntry{Role: appSummaryRole}) + floor := min(max(record.Floor, 0), len(record.Entries)) + return append(items, record.Entries[floor:]...) +} + // appDetailLimit bounds a tool's short result line. const appDetailLimit = 160 @@ -1575,6 +1676,7 @@ func (b *appBackend) release(a *appAgent) { // finish gives a conversation that is no longer held back, and shuts its // outbox once the turn and the lanes have said their last. func (b *appBackend) finish(live *appLive) { + b.locked(func() { live.closed = true }) b.landPilots(live) if live.stopTasks != nil { live.stopTasks() @@ -1736,7 +1838,7 @@ func (b *appBackend) Entries(id, before string, limit int) ([]appwire.Entry, boo return []appwire.Entry{}, false, nil } record := session.ReadTranscript(transcript) - items := append(append([]session.DisplayEntry(nil), record.Earlier...), record.Entries...) + items := appRecordItems(record) all := appEntriesOf(id, items, at, marks) // A tool entry carries its call's facts, as its row did live. calls := map[string]session.DisplayEntry{} @@ -1779,10 +1881,27 @@ func (b *appBackend) Entries(id, before string, limit int) ([]appwire.Entry, boo // its client id, from marks; one sent before this launch has neither. func appEntriesOf(agent string, items []session.DisplayEntry, at time.Time, marks map[int]appTurnMark) []appwire.Entry { entries := make([]appwire.Entry, 0, len(items)) - n, m, k := -1, 0, 0 + n, m, k, c := -1, 0, 0, 0 + // A MESSAGE FOLLOWED BY A TOOL CALL BEFORE THE PERSON SPOKE AGAIN WAS AN + // UPDATE, as the live pump named it. + update := make([]bool, len(items)) + called := false + for i := len(items) - 1; i >= 0; i-- { + switch items[i].Role { + case "user": + called = false + case "tool": + called = true + case "assistant": + update[i] = called + } + } for i, item := range items { entry := appwire.Entry{Agent: agent, At: at} switch item.Role { + case appSummaryRole: + entry.ID, entry.Kind, entry.Text = "c"+strconv.Itoa(max(n, 0))+".s"+strconv.Itoa(c), appwire.EntrySummary, "Earlier messages summarized" + c++ case "user": n, m, k = n+1, 0, 0 entry.ID, entry.Kind, entry.Text = appUserEntryID(n), appwire.EntryUser, item.Text @@ -1802,6 +1921,9 @@ func appEntriesOf(agent string, items []session.DisplayEntry, at time.Time, mark } msg := m entry.ID, entry.Kind, entry.Text, entry.Msg = appAnswerEntryID(n, msg), appwire.EntryAnswer, strings.TrimSpace(item.Text), &msg + if update[i] && item.Role == "assistant" { + entry.Kind = appwire.EntryUpdate + } m++ case "tool": call := item.CallID @@ -1825,6 +1947,9 @@ func appEntriesOf(agent string, items []session.DisplayEntry, at time.Time, mark entry.Turn = mark.turn if entry.Kind == appwire.EntryUser { entry.ClientID = mark.clientID + if mark.text != "" { + entry.Text, entry.Attachments = mark.text, mark.attachments + } } } entries = append(entries, entry) @@ -2148,6 +2273,7 @@ func (b *appBackend) ListTasks(id string) ([]appwire.Task, error) { } tasks = append(tasks, task) } + appTaskExtras(tasks) return tasks, nil } diff --git a/cmd/codeaf/app_test.go b/cmd/codeaf/app_test.go index 4a3b9f1cc3..1a972fb514 100644 --- a/cmd/codeaf/app_test.go +++ b/cmd/codeaf/app_test.go @@ -159,6 +159,11 @@ type fakeConversation struct { moves int // room is the lane one task's calls are watched on. room chan session.Event + // steer is what a steer answers with, steered the words steered, and + // retried the tasks run again. + steer chan session.Event + steered []string + retried []uint64 // onAnswer, when set, is what the engine does with an answer. onAnswer func(session.Answer) } @@ -166,10 +171,8 @@ type fakeConversation struct { func (f *fakeConversation) WatchTaskRoom(uint64) (<-chan session.Event, func(), error) { f.mu.Lock() defer f.mu.Unlock() - if f.room == nil { - f.room = make(chan session.Event, 8) - } - lane := f.room + lane := make(chan session.Event, 8) + f.room = lane var once sync.Once return lane, func() { once.Do(func() { close(lane) }) }, nil } @@ -294,6 +297,23 @@ func (f *fakeConversation) AppQuickTaskAction(intent session.AppTaskIntent) ([]s } return nil, nil, nil } +func (f *fakeConversation) Steer(text string) (<-chan session.Event, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.steered = append(f.steered, text) + if f.steer == nil { + return nil, session.ErrNothingToSteer + } + return f.steer, nil +} + +func (f *fakeConversation) RetryTask(id uint64) error { + f.mu.Lock() + defer f.mu.Unlock() + f.retried = append(f.retried, id) + return nil +} + func (f *fakeConversation) MoveTurnToTask() error { f.mu.Lock() defer f.mu.Unlock() @@ -489,7 +509,7 @@ func TestAppSendMapsSessionEventsOntoTurnEvents(t *testing.T) { got := rec.events[2 : done+2] want := []string{ appwire.EventAgentChanged, appwire.EventTurnStarted, appwire.EventEntryAdded, - appwire.EventTurnDelta, appwire.EventTurnDelta, appwire.EventEntryAdded, + appwire.EventTurnThinking, appwire.EventTurnThinkingDone, appwire.EventTurnDelta, appwire.EventEntryAdded, appwire.EventTurnTool, appwire.EventTurnTool, appwire.EventTurnTool, appwire.EventTurnTool, appwire.EventTaskChanged, appwire.EventAgentChanged, appwire.EventTurnDone, appwire.EventAgentChanged, } @@ -506,18 +526,23 @@ func TestAppSendMapsSessionEventsOntoTurnEvents(t *testing.T) { if e := data[2].(appwire.EntryAdded).Entry; e.Kind != appwire.EntryUser || e.ID != "u0" || e.Text != "fix it" || e.Turn != turn || e.Agent != agent.ID { t.Fatalf("the person's entry = %+v", e) } - if d := data[3].(appwire.TurnDelta); d.Kind != appwire.DeltaThinking || d.Text != "hmm" { + // The thought is said live and never kept; the words before a tool call + // are an update. + if d := data[3].(appwire.TurnThinking); d.Text != "hmm" || d.Turn != turn { t.Fatalf("thinking = %+v", d) } - if d := data[4].(appwire.TurnDelta); d.Kind != appwire.DeltaText || d.Text != "hello world" { + if d := data[4].(appwire.TurnThinkingDone); d.Seconds < 1 { + t.Fatalf("thinking done = %+v", d) + } + if d := data[5].(appwire.TurnDelta); d.Kind != appwire.DeltaText || d.Text != "hello world" { t.Fatalf("text = %+v", d) } - if e := data[5].(appwire.EntryAdded).Entry; e.Kind != appwire.EntryAnswer || e.ID != "a0.0" || e.Msg == nil || *e.Msg != 0 || e.Text != "hello world" || e.Thinking != "hmm" { - t.Fatalf("the answer entry = %+v", e) + if e := data[6].(appwire.EntryAdded).Entry; e.Kind != appwire.EntryUpdate || e.ID != "a0.0" || e.Msg == nil || *e.Msg != 0 || e.Text != "hello world" || e.Thinking != "" { + t.Fatalf("the update entry = %+v", e) } - begin := data[6].(appwire.TurnTool) - end := data[7].(appwire.TurnTool) - failed := data[9].(appwire.TurnTool) + begin := data[7].(appwire.TurnTool) + end := data[8].(appwire.TurnTool) + failed := data[10].(appwire.TurnTool) if begin.Phase != appwire.PhaseBegin || begin.Call != "c1" || begin.Name != "read" || begin.Title != "read go.mod" || begin.OK != nil { t.Fatalf("begin = %+v", begin) } @@ -527,10 +552,10 @@ func TestAppSendMapsSessionEventsOntoTurnEvents(t *testing.T) { if failed.Phase != appwire.PhaseEnd || failed.OK == nil || *failed.OK || failed.Summary != "exit status 1" { t.Fatalf("failed = %+v", failed) } - if task := data[10].(appwire.TaskChanged).Task; task.ID != agent.ID+":3" || task.Agent != agent.ID || task.State != appwire.TaskRunning || task.Title != "fix the parser" { + if task := data[11].(appwire.TaskChanged).Task; task.ID != agent.ID+":3" || task.Agent != agent.ID || task.State != appwire.TaskRunning || task.Title != "fix the parser" { t.Fatalf("task = %+v", task) } - if d := data[12].(appwire.TurnDone); !d.OK || d.Turn != turn || d.Error != "" { + if d := data[13].(appwire.TurnDone); !d.OK || d.Turn != turn || d.Error != "" { t.Fatalf("turn.done = %+v", d) } } @@ -576,8 +601,8 @@ func TestAppSendRefusesBusyAndReportsAFailedTurn(t *testing.T) { for _, data := range rec.data { if e, ok := data.(appwire.EntryAdded); ok { switch e.Entry.Kind { - case appwire.EntryReceipt: - if strings.HasPrefix(e.Entry.Text, "Stopped · you · ") { + case appwire.EntryFooter: + if e.Entry.Footer == appwire.FooterStoppedByYou && strings.HasPrefix(e.Entry.Text, "Stopped by you · ") { receipts++ } case appwire.EntryError, appwire.EntryAnswer: @@ -1073,7 +1098,7 @@ func TestAppSortsAProviderFailureAndRetriesIt(t *testing.T) { if done.OK || done.ErrorKind != appwire.ErrorProvider || done.Status != 429 || done.Error != "upstream is rate limited" { t.Fatalf("turn.done = %+v", done) } - if e := errorEntry.Entry; e.Kind != appwire.EntryError || e.ErrorKind != appwire.ErrorProvider || e.Status != 429 || e.Text != done.Error { + if e := errorEntry.Entry; e.Kind != appwire.EntryFooter || e.Footer != appwire.FooterProviderFailed || e.ErrorKind != appwire.ErrorProvider || e.Status != 429 || e.Text != done.Error { t.Fatalf("the error entry = %+v", e) } // The next try goes through. @@ -1585,7 +1610,7 @@ func TestAppATurnWithToolsSaysEachMessageOnce(t *testing.T) { switch v.Entry.Kind { case appwire.EntryUser: users = append(users, v.Entry) - case appwire.EntryAnswer: + case appwire.EntryAnswer, appwire.EntryUpdate: answers = append(answers, v.Entry) } case appwire.TurnDelta: diff --git a/cmd/codeaf/appconvo.go b/cmd/codeaf/appconvo.go new file mode 100644 index 0000000000..319b91ce33 --- /dev/null +++ b/cmd/codeaf/appconvo.go @@ -0,0 +1,1072 @@ +package main + +// ── codeaf app: THE CONVERSATION, THE DOCK, THE QUEUE, TASKS AS CHATS ─────── +// +// Wave 3b (PROTOCOL.md, V65–V70). Everything here is the engine's own +// machinery said on the app's wire: a steer is the engine's Steer, consumed or +// fallen through by its own word; a thought is its reasoning stream; a summary +// is its compaction; a retry, a fallback and a failure are its retry news; an +// ask's choices, suggestion, clock, evidence and the tasks it holds up are the +// question's own fields; a task's pause, brief, notes and their read receipts +// are its task door's. +// +// TWO THINGS ARE THE APP'S OWN. The queue is kept here, because the engine's +// follow-up queue cannot be edited or reordered in place; a queued message is +// sent as an ordinary message when the turn before it ends. And the files and +// links a message carries are written into the words the engine is sent, as a +// person would have written them, because the engine takes words. + +import ( + "bytes" + "errors" + "io" + "io/fs" + "net/url" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/guard" + "github.com/Agent-Field/codeaf/internal/redact" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/tui3" +) + +const ( + // appPasteLines and appPasteChars are where a paste becomes a card. + appPasteLines = 12 + appPasteChars = 2000 + // appEvidenceLines is how many lines of an ask's evidence it carries. + appEvidenceLines = 8 + // appFilesKept bounds one walk of a workspace for files.find. + appFilesKept = 50000 + // appFilesFresh is how long one walk is answered from before another. + appFilesFresh = 30 * time.Second + // appNoteBeat is how often a task's unread notes are looked at. + appNoteBeat = 2 * time.Second + // appOutputKeep is how much of a long output is kept: the last 512 KB. + appOutputKeep = 512 << 10 +) + +// appTrimmedLine is the line that stands where earlier output was dropped. +const appTrimmedLine = "… earlier output trimmed to the last 512 KB" + +// appQueued is one message of the person's waiting for the turn to end. +type appQueued struct { + id, text, clientID string +} + +// appAttach is one attachment the composer added, with what it carries. +type appAttach struct { + view appwire.Attachment + content string +} + +// appNotePending is a note to a task not yet read. +type appNotePending struct { + entry appwire.Entry + text string + index int +} + +// ── steering ──────────────────────────────────────────────────────────────── + +// Steer puts the person's words into the running turn at its next step. A +// steer the turn ends before reading falls through, and becomes the next +// message, as the engine does it. +func (b *appBackend) Steer(agent, text, clientID string) error { + a, err := b.find(agent) + if err != nil { + return err + } + live := b.liveOf(a) + running := false + if live != nil { + b.locked(func() { running = live.running }) + } + if !running { + return appwire.Errorf(appwire.CodeBusy, "nothing is running to steer; send it as a message") + } + events, err := live.conn.Conv.Steer(text) + if err != nil { + return appEngineError(err) + } + var entry appwire.Entry + b.locked(func() { + live.steers++ + entry = appwire.Entry{ID: "st" + strconv.FormatUint(live.turn, 10) + "." + strconv.Itoa(live.steers), Agent: agent, Turn: live.turn, Kind: appwire.EntrySteer, At: b.now(), Text: text, ClientID: clientID, State: appwire.SteerPending} + }) + live.box.Event(appwire.EventEntryAdded, appwire.EntryAdded{Entry: entry}) + guard.Go("app/steer", func() { b.followSteer(a, live, entry, events) }) + return nil +} + +// followSteer reads one steer's own stream: consumed at a step, or fallen +// through, after which the same stream is the turn its words start. +func (b *appBackend) followSteer(a *appAgent, live *appLive, entry appwire.Entry, events <-chan session.Event) { + for event := range events { + switch event.Kind { + case session.EventSteerConsumed: + b.locked(func() { + step := live.steps + entry.Step = &step + }) + entry.State, entry.At = appwire.SteerConsumed, b.now() + live.box.Event(appwire.EventEntryAdded, appwire.EntryAdded{Entry: entry}) + case session.EventSteerFellThrough: + entry.State, entry.At = appwire.SteerFellThrough, b.now() + b.locked(func() { live.adopting++ }) + live.box.Event(appwire.EventEntryAdded, appwire.EntryAdded{Entry: entry}) + b.adoptTurn(a, live, entry.Text, entry.ClientID, events) + return + } + } +} + +// adoptTurn makes the rest of a fallen-through steer's stream the agent's +// next turn, once the turn it missed has said its last. +func (b *appBackend) adoptTurn(a *appAgent, live *appLive, text, clientID string, events <-chan session.Event) { + var done chan struct{} + b.locked(func() { done = live.pumpDone }) + if done != nil { + <-done + } + turn, n, id, ok := uint64(0), 0, "", false + b.locked(func() { + live.adopting-- + if a.live != live || live.running { + return + } + ok = true + id = a.view.ID + n = a.users + a.users = n + 1 + live.running, live.stopping = true, false + turn = b.turns.Add(1) + live.turn, live.steps, live.steers = turn, 0, 0 + live.pumpDone = make(chan struct{}) + if a.marks == nil { + a.marks = map[int]appTurnMark{} + } + a.marks[n] = appTurnMark{turn: turn, clientID: clientID} + a.view.State = appwire.StateWorking + a.view.UpdatedAt = b.now() + }) + if !ok { + for range events { + } + return + } + live.box.Event(appwire.EventTurnStarted, appwire.TurnStarted{Agent: id, Turn: turn}) + live.box.Event(appwire.EventEntryAdded, appwire.EntryAdded{Entry: appwire.Entry{ + ID: appUserEntryID(n), Agent: id, Turn: turn, Kind: appwire.EntryUser, At: b.now(), Text: text, ClientID: clientID, + }}) + long := b.watchLong(id, live, turn, b.now()) + b.newTurnCalls(a) + tails := b.tickTools(a, id, live) + defer long.Stop() + defer tails() + b.pump(a, id, live, turn, n, text, events) + b.sendQueued(a, live) +} + +// ── the queue ─────────────────────────────────────────────────────────────── + +// Queue keeps a message for when the running turn ends, or sends it now when +// nothing is running. +func (b *appBackend) Queue(agent, text, clientID string) ([]appwire.Queued, error) { + a, err := b.find(agent) + if err != nil { + return nil, err + } + live := b.liveOf(a) + busy := false + if live != nil { + b.locked(func() { busy = live.running || live.adopting > 0 }) + } + if !busy { + if _, err := b.Send(agent, text, clientID); err != nil { + return nil, err + } + return b.queued(a), nil + } + var queued []appwire.Queued + b.locked(func() { + a.queueN++ + a.queue = append(a.queue, appQueued{id: "q" + strconv.Itoa(a.queueN), text: text, clientID: clientID}) + queued = appQueuedOf(a.queue) + }) + b.emit.Emit(appwire.EventQueueChanged, appwire.QueueChanged{Agent: agent, Queued: queued}) + return queued, nil +} + +func appQueuedOf(queue []appQueued) []appwire.Queued { + out := make([]appwire.Queued, 0, len(queue)) + for _, q := range queue { + out = append(out, appwire.Queued{ID: q.id, Text: q.text}) + } + return out +} + +func (b *appBackend) queued(a *appAgent) []appwire.Queued { + var out []appwire.Queued + b.locked(func() { out = appQueuedOf(a.queue) }) + return out +} + +// changeQueue applies one change to a queued message, refusing one that has +// already been sent. +func (b *appBackend) changeQueue(agent, id string, change func(a *appAgent, at int)) ([]appwire.Queued, error) { + a, err := b.find(agent) + if err != nil { + return nil, err + } + var queued []appwire.Queued + found := false + b.locked(func() { + for i, q := range a.queue { + if q.id == id { + found = true + change(a, i) + break + } + } + queued = appQueuedOf(a.queue) + }) + if !found { + return nil, appwire.Errorf(appwire.CodeSent, "that message has already been sent") + } + b.emit.Emit(appwire.EventQueueChanged, appwire.QueueChanged{Agent: agent, Queued: queued}) + return queued, nil +} + +// EditQueued changes a queued message's words. +func (b *appBackend) EditQueued(agent, id, text string) ([]appwire.Queued, error) { + return b.changeQueue(agent, id, func(a *appAgent, at int) { a.queue[at].text = text }) +} + +// RemoveQueued takes a queued message out. +func (b *appBackend) RemoveQueued(agent, id string) ([]appwire.Queued, error) { + return b.changeQueue(agent, id, func(a *appAgent, at int) { a.queue = append(a.queue[:at], a.queue[at+1:]...) }) +} + +// MoveQueued puts a queued message at another place in the queue. +func (b *appBackend) MoveQueued(agent, id string, to int) ([]appwire.Queued, error) { + return b.changeQueue(agent, id, func(a *appAgent, at int) { + moved := a.queue[at] + rest := append(append([]appQueued(nil), a.queue[:at]...), a.queue[at+1:]...) + to = min(to, len(rest)) + a.queue = append(append(append([]appQueued(nil), rest[:to]...), moved), rest[to:]...) + }) +} + +// sendQueued sends the first queued message once a turn has ended and +// nothing else is about to start. +func (b *appBackend) sendQueued(a *appAgent, live *appLive) { + var next appQueued + var queued []appwire.Queued + var agent string + ok := false + b.locked(func() { + if a.live != live || live.running || live.adopting > 0 || len(a.queue) == 0 { + return + } + next, a.queue = a.queue[0], a.queue[1:] + queued, agent, ok = appQueuedOf(a.queue), a.view.ID, true + }) + if !ok { + return + } + b.emit.Emit(appwire.EventQueueChanged, appwire.QueueChanged{Agent: agent, Queued: queued}) + if _, err := b.Send(agent, next.text, next.clientID); err != nil { + live.box.Event(appwire.EventEntryAdded, appwire.EntryAdded{Entry: appwire.Entry{ + ID: "qx" + next.id, Agent: agent, Kind: appwire.EntryError, At: b.now(), Text: "the queued message was not sent: " + err.Error(), + }}) + } +} + +// ── the turn's own entries ────────────────────────────────────────────────── + +// appFooter is one quiet line at the end of a turn. +func appFooter(id, agent string, turn uint64, kind, text string, at time.Time) appwire.Entry { + return appwire.Entry{ID: id, Agent: agent, Turn: turn, Kind: appwire.EntryFooter, Footer: kind, At: at, Text: text} +} + +// ── the decision dock ─────────────────────────────────────────────────────── + +// appAskFacts adds the dock's facts to an ask from its question. +func appAskFacts(agent string, ask *appwire.Ask, q session.Question) { + ask.Reason = strings.TrimSpace(q.Reason) + ask.Irreversible = ask.Irreversible || q.Stakes == session.StakesIrreversible + ask.Source = &appwire.AskSource{Kind: "reply"} + if q.Subject.Kind == session.SubjectNode && q.Subject.ID != 0 { + ask.Source = &appwire.AskSource{Kind: "task", Task: appTaskID(agent, q.Subject.ID)} + } else if q.Kind == session.QuestionTask && q.ID != 0 { + ask.Source = &appwire.AskSource{Kind: "task", Task: appTaskID(agent, q.ID)} + } + var lines []string + for _, block := range q.Attach { + body := strings.TrimRight(block.Body, "\n") + if body == "" { + continue + } + lines = append(lines, strings.Split(redact.Secrets(body), "\n")...) + } + if len(lines) > 0 { + shown := lines[:min(len(lines), appEvidenceLines)] + ask.Evidence = &appwire.Evidence{Lines: shown, More: len(lines) - len(shown)} + } + if q.Kind != session.QuestionConsent && len(q.Options) > 0 { + for _, option := range q.Options { + choice := appwire.AskChoice{ID: option.Key, Label: option.Label, Detail: strings.TrimSpace(option.Body)} + if q.Pick != nil && q.Pick.Key == option.Key { + choice.Suggested = true + } + ask.Choices = append(ask.Choices, choice) + } + } + if !q.Deadline.IsZero() && !ask.Irreversible { + until := q.Deadline + ask.ClockUntil = &until + } + for _, task := range q.Blocking.Tasks { + if n, err := strconv.ParseUint(strings.TrimSpace(task), 10, 64); err == nil { + ask.HoldingUp = append(ask.HoldingUp, appTaskID(agent, n)) + } + } + ask.SayWhy = q.Kind == session.QuestionConsent +} + +// batchLocked is the calls asked about together with q, open and answered, +// and the open asks of the batch. Callers hold b.mu. +func (b *appBackend) batchLocked(live *appLive, q session.Question) ([]appwire.BatchItem, []string) { + if q.Batch == "" || q.Kind != session.QuestionConsent { + return nil, nil + } + var items []appwire.BatchItem + var open []string + for _, ask := range live.asks { + sibling, ok := live.questions[ask.ID] + if !ok || sibling.Batch != q.Batch || sibling.Kind != session.QuestionConsent { + continue + } + call := live.consents[sibling.ID] + text := tui3.ToolTitle(call.Tool, call.Args, "") + if text == "" { + text = sibling.Head + } + items = append(items, appwire.BatchItem{ID: ask.ID, Text: redact.Secrets(text)}) + open = append(open, ask.ID) + } + for _, done := range live.batchDone[q.Batch] { + items = append(items, done) + } + if len(items) < 2 { + return nil, open + } + sort.SliceStable(items, func(i, j int) bool { return items[i].ID < items[j].ID }) + return items, open +} + +// restateBatch says every open ask of a batch again with the batch as it +// stands now. +func (b *appBackend) restateBatch(live *appLive, q session.Question) { + if q.Batch == "" { + return + } + var asks []appwire.Ask + b.locked(func() { + items, open := b.batchLocked(live, q) + if len(items) < 2 { + return + } + for _, id := range open { + for i := range live.asks { + if live.asks[i].ID == id { + live.asks[i].Batch = items + asks = append(asks, live.asks[i]) + } + } + } + }) + for _, ask := range asks { + live.box.Event(appwire.EventAskOpened, appwire.AskOpened{Ask: ask}) + } +} + +// AnswerAskWith is asks.answer whole: an option and words, a reason with +// them, one call of a batch or all of them, putting it aside for later, or +// handing the choice back to the agent's own suggestion. +func (b *appBackend) AnswerAskWith(agent, id string, answer appwire.AskAnswer) error { + a, err := b.find(agent) + if err != nil { + return err + } + live := b.liveOf(a) + if live == nil { + return b.AnswerAsk(agent, id, answer.Option, answer.Text) + } + var q session.Question + open := false + b.locked(func() { q, open = live.questions[id] }) + if answer.Later { + if !open { + return appwire.Errorf(appwire.CodeNotFound, "the ask %s is not open", id) + } + var ask appwire.Ask + b.locked(func() { + for i := range live.asks { + if live.asks[i].ID == id { + live.asks[i].Later = true + ask = live.asks[i] + } + } + }) + live.box.Event(appwire.EventAskOpened, appwire.AskOpened{Ask: ask}) + return nil + } + text := strings.TrimSpace(strings.TrimSpace(answer.Text) + "\n" + strings.TrimSpace(answer.Why)) + if answer.YouDecide { + if !open { + return appwire.Errorf(appwire.CodeNotFound, "the ask %s is not open", id) + } + if q.Pick == nil || q.Pick.Key == "" { + return appwire.Errorf(appwire.CodeBadParams, "this ask has no suggested answer to hand back") + } + label := q.Pick.Key + for _, option := range q.Options { + if option.Key == q.Pick.Key { + label = option.Label + } + } + b.locked(func() { live.answered[id] = appAnsweredMark + appDecideMark + label }) + resolved := session.Answer{Kind: q.Kind, ID: q.ID, Ref: q.Ref, Ask: q.Ask, Key: q.Pick.Key, Picked: []string{q.Pick.Key}, Change: text} + if err := live.conn.Conv.ResolveQuestion(resolved); err != nil { + b.locked(func() { delete(live.answered, id) }) + return appEngineError(err) + } + return nil + } + targets := []string{id} + if answer.BatchItem != "" { + targets = []string{answer.BatchItem} + } else if open { + b.locked(func() { + if _, siblings := b.batchLocked(live, q); len(siblings) > 1 { + targets = siblings + } + }) + } + for _, target := range targets { + var sibling session.Question + var callText string + b.locked(func() { + sibling = live.questions[target] + call := live.consents[sibling.ID] + callText = redact.Secrets(tui3.ToolTitle(call.Tool, call.Args, "")) + }) + if err := b.AnswerAsk(agent, target, answer.Option, text); err != nil { + return err + } + if sibling.Batch != "" { + state := "allowed" + if answer.Option == appwire.OptionDeny { + state = "denied" + } + b.locked(func() { + if live.batchDone == nil { + live.batchDone = map[string][]appwire.BatchItem{} + } + live.batchDone[sibling.Batch] = append(live.batchDone[sibling.Batch], appwire.BatchItem{ID: target, Text: callText, State: state}) + }) + } + } + if q.Batch != "" { + b.restateBatch(live, q) + } + return nil +} + +// appDecideMark marks an ask the person handed back to the agent's own pick. +const appDecideMark = "decide:" + +// HoldAsk stops an ask's clock. Only a proposed task has one: its hold is the +// task's own. +func (b *appBackend) HoldAsk(agent, id string) error { + a, err := b.find(agent) + if err != nil { + return err + } + live := b.liveOf(a) + if live == nil { + return appwire.Errorf(appwire.CodeNotFound, "the ask %s is not open", id) + } + var q session.Question + open := false + b.locked(func() { q, open = live.questions[id] }) + if !open { + return appwire.Errorf(appwire.CodeNotFound, "the ask %s is not open", id) + } + if q.Kind != session.QuestionTask || q.Deadline.IsZero() { + return nil + } + return b.ActOnTask(appTaskID(agent, q.ID), "hold", "", "") +} + +// ── files and links in the composer ───────────────────────────────────────── + +// appWalk is one walk of a workspace's files, kept a little while. +type appWalk struct { + at time.Time + paths []string + dirs map[string]bool +} + +// changedInChat is every file this agent's calls changed, with its counts. +func (b *appBackend) changedInChat(a *appAgent) map[string]appwire.FileChange { + changed := map[string]appwire.FileChange{} + log := b.callLog(a) + log.locked(func() { + for _, id := range log.order { + if c := log.calls[id]; c != nil { + for _, f := range c.facts.Files { + was := changed[f.Path] + changed[f.Path] = appwire.FileChange{Path: f.Path, Add: was.Add + f.Add, Del: was.Del + f.Del} + } + } + } + }) + return changed +} + +// walkFiles is the workspace's files and folders, relative, from the last +// walk when it is fresh. +func (b *appBackend) walkFiles(a *appAgent, workspace string) appWalk { + var walk appWalk + b.locked(func() { + if a.walk != nil && b.now().Sub(a.walk.at) < appFilesFresh { + walk = *a.walk + } + }) + if walk.paths != nil { + return walk + } + walk = appWalk{at: b.now(), paths: []string{}, dirs: map[string]bool{}} + _ = filepath.WalkDir(workspace, func(path string, entry fs.DirEntry, err error) error { + if err != nil || len(walk.paths) >= appFilesKept { + if entry != nil && entry.IsDir() { + return filepath.SkipDir + } + return nil + } + rel, relErr := filepath.Rel(workspace, path) + if relErr != nil || rel == "." { + return nil + } + name := entry.Name() + if entry.IsDir() { + if strings.HasPrefix(name, ".") || name == "node_modules" || name == "vendor" || name == "target" || name == "dist" || name == "__pycache__" { + return filepath.SkipDir + } + walk.dirs[rel] = true + } + walk.paths = append(walk.paths, rel) + return nil + }) + b.locked(func() { a.walk = &walk }) + return walk +} + +// FindFiles answers the files a reference may name: the ones changed in this +// chat first, then the workspace's, best match first. +func (b *appBackend) FindFiles(agent, query string, limit int) ([]appwire.FileGroup, error) { + a, err := b.find(agent) + if err != nil { + return nil, err + } + if limit <= 0 { + limit = 50 + } + var workspace string + b.locked(func() { workspace = a.view.Workspace }) + query = strings.ToLower(strings.TrimSpace(query)) + matches := func(path string) int { + lower := strings.ToLower(path) + base := strings.ToLower(filepath.Base(path)) + switch { + case query == "": + return 1 + case strings.HasPrefix(base, query): + return 3 + case strings.Contains(base, query): + return 2 + case strings.Contains(lower, query): + return 1 + } + return 0 + } + var groups []appwire.FileGroup + changed := b.changedInChat(a) + seen := map[string]bool{} + var mine []appwire.FoundFile + for path, change := range changed { + rel := path + if r, err := filepath.Rel(workspace, path); err == nil && !strings.HasPrefix(r, "..") { + rel = r + } + if matches(rel) == 0 { + continue + } + add, del := change.Add, change.Del + mine = append(mine, appwire.FoundFile{Path: rel, Add: &add, Del: &del}) + seen[rel] = true + } + sort.Slice(mine, func(i, j int) bool { return mine[i].Path < mine[j].Path }) + if len(mine) > 0 { + groups = append(groups, appwire.FileGroup{Label: "Changed in this chat", Files: mine[:min(len(mine), limit)]}) + } + if workspace != "" { + walk := b.walkFiles(a, workspace) + type scored struct { + path string + score int + } + var found []scored + for _, path := range walk.paths { + if seen[path] { + continue + } + if score := matches(path); score > 0 { + found = append(found, scored{path, score}) + } + } + sort.SliceStable(found, func(i, j int) bool { + if found[i].score != found[j].score { + return found[i].score > found[j].score + } + return len(found[i].path) < len(found[j].path) + }) + var files []appwire.FoundFile + for _, f := range found[:min(len(found), limit)] { + files = append(files, appwire.FoundFile{Path: f.path, Dir: walk.dirs[f.path]}) + } + if len(files) > 0 { + groups = append(groups, appwire.FileGroup{Label: filepath.Base(workspace), Files: files}) + } + } + return groups, nil +} + +// statFile is one path as the composer shows it. +func (b *appBackend) statFile(workspace string, changed map[string]appwire.FileChange, path string) appwire.FileStat { + full := path + if !filepath.IsAbs(full) { + full = filepath.Join(workspace, path) + } + full = filepath.Clean(full) + stat := appwire.FileStat{Path: path, State: appwire.FileExists} + if rel, err := filepath.Rel(workspace, full); workspace == "" || err != nil || rel == ".." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) { + stat.State = appwire.FileOutside + return stat + } + info, err := os.Stat(full) + if err != nil { + stat.State = appwire.FileMissing + return stat + } + size := info.Size() + stat.Size = &size + if !info.IsDir() { + if file, err := os.Open(full); err == nil { + head := make([]byte, 8000) + n, _ := io.ReadFull(file, head) + _ = file.Close() + stat.Binary = bytes.IndexByte(head[:n], 0) >= 0 + } + } + if change, ok := changed[full]; ok { + stat.State = appwire.FileChanged + add, del := change.Add, change.Del + stat.Add, stat.Del = &add, &del + } else if change, ok := changed[path]; ok { + stat.State = appwire.FileChanged + add, del := change.Add, change.Del + stat.Add, stat.Del = &add, &del + } + return stat +} + +// StatFiles answers what each path is now: there, changed in this chat, +// missing, or outside the workspace. +func (b *appBackend) StatFiles(agent string, paths []string) ([]appwire.FileStat, error) { + a, err := b.find(agent) + if err != nil { + return nil, err + } + var workspace string + b.locked(func() { workspace = a.view.Workspace }) + changed := b.changedInChat(a) + out := make([]appwire.FileStat, 0, len(paths)) + for _, path := range paths { + if strings.TrimSpace(path) != "" { + out = append(out, b.statFile(workspace, changed, path)) + } + } + return out, nil +} + +// AddAttachment keeps a file, a link or a long paste for the next message. +func (b *appBackend) AddAttachment(agent string, attachment appwire.Attachment, text string) (appwire.Attachment, error) { + a, err := b.find(agent) + if err != nil { + return appwire.Attachment{}, err + } + var workspace string + b.locked(func() { workspace = a.view.Workspace }) + kept := appAttach{view: attachment} + switch attachment.Kind { + case appwire.AttachFile: + stat := b.statFile(workspace, b.changedInChat(a), attachment.Path) + kept.view.State, kept.view.Add, kept.view.Del = stat.State, stat.Add, stat.Del + kept.view.Title = filepath.Base(attachment.Path) + case appwire.AttachLink: + parsed, err := url.Parse(strings.TrimSpace(attachment.URL)) + if err != nil || parsed.Host == "" { + return appwire.Attachment{}, appwire.Errorf(appwire.CodeBadParams, "that is not a link") + } + kept.view.URL, kept.view.Title = parsed.String(), parsed.Host + case appwire.AttachPaste: + lines := strings.Count(strings.TrimRight(text, "\n"), "\n") + 1 + if lines <= appPasteLines && len(text) <= appPasteChars { + return appwire.Attachment{}, appwire.Errorf(appwire.CodeBadParams, "a paste of %d lines goes into the message as text", lines) + } + kept.view.Title, kept.view.Lines, kept.content = "Pasted text", &lines, text + } + b.locked(func() { + a.attachN++ + kept.view.ID = "at" + strconv.Itoa(a.attachN) + if a.attach == nil { + a.attach = map[string]appAttach{} + } + a.attach[kept.view.ID] = kept + }) + return kept.view, nil +} + +// SendWith sends a message with its attachments, written into the words the +// engine is sent, and keeps them on the person's entry. +func (b *appBackend) SendWith(agent, text, clientID string, ids []string) (uint64, error) { + a, err := b.find(agent) + if err != nil { + return 0, err + } + var kept []appAttach + var missing string + b.locked(func() { + for _, id := range ids { + attach, ok := a.attach[id] + if !ok { + missing = id + return + } + kept = append(kept, attach) + } + for _, id := range ids { + delete(a.attach, id) + } + }) + if missing != "" { + return 0, appwire.Errorf(appwire.CodeNotFound, "there is no attachment %s", missing) + } + var words strings.Builder + words.WriteString(text) + var views []appwire.Attachment + for _, attach := range kept { + views = append(views, attach.view) + switch attach.view.Kind { + case appwire.AttachFile: + words.WriteString("\n\nAttached file: " + attach.view.Path) + case appwire.AttachLink: + words.WriteString("\n\nLink: " + attach.view.URL) + case appwire.AttachPaste: + words.WriteString("\n\nPasted text:\n```\n" + strings.TrimRight(attach.content, "\n") + "\n```") + } + } + b.locked(func() { a.sending = &appSending{text: text, attachments: views} }) + turn, err := b.Send(agent, words.String(), clientID) + b.locked(func() { a.sending = nil }) + return turn, err +} + +// appSending is the words and attachments of the message being sent, which +// its entry shows instead of the words the engine was given. +type appSending struct { + text string + attachments []appwire.Attachment +} + +// ── tasks as conversations ────────────────────────────────────────────────── + +// RetryTask runs a failed task again from where it failed, with the same +// brief. +func (b *appBackend) RetryTask(id string) error { + agent, local, ok := appSplitID(id) + if !ok { + return appwire.Errorf(appwire.CodeBadParams, "%s is not a task id", id) + } + _, live, err := b.heldLive(agent) + if err != nil { + return err + } + n, err := strconv.ParseUint(local, 10, 64) + if err != nil { + return appwire.Errorf(appwire.CodeBadParams, "%s is not a task id", id) + } + if err := live.conn.Conv.RetryTask(n); err != nil { + return appEngineError(err) + } + return nil +} + +// NoteTask sends a note to a task and answers its entry; the note is marked +// read when the task's next step carries it. +func (b *appBackend) NoteTask(id, text, clientID string) (appwire.Entry, error) { + agent, local, ok := appSplitID(id) + if !ok { + return appwire.Entry{}, appwire.Errorf(appwire.CodeBadParams, "%s is not a task id", id) + } + _, live, err := b.heldLive(agent) + if err != nil { + return appwire.Entry{}, err + } + before := 0 + if view, found, err := live.conn.Conv.AppTask(local); err == nil && found { + before = len(view.Notes) + } + var entry appwire.Entry + b.locked(func() { + live.noteN++ + entry = appwire.Entry{ID: "tn" + strconv.Itoa(live.noteN), Agent: agent, Kind: appwire.EntryNote, At: b.now(), Text: text, ClientID: clientID, From: "you", State: appwire.NoteSending} + entry.ToolFacts.Task = id + }) + live.box.Event(appwire.EventEntryAdded, appwire.EntryAdded{Entry: entry}) + if err := b.ActOnTask(id, "note", "", text); err != nil { + return appwire.Entry{}, err + } + entry.State = appwire.NoteWaiting + live.box.Event(appwire.EventNoteChanged, appwire.NoteChanged{Task: id, ID: entry.ID, State: appwire.NoteWaiting}) + start := false + b.locked(func() { + if live.notes == nil { + live.notes = map[string][]appNotePending{} + } + start = len(live.notes[id]) == 0 + live.notes[id] = append(live.notes[id], appNotePending{entry: entry, text: text, index: before}) + }) + if start { + guard.Go("app/note-read", func() { b.watchNotes(live, id, local) }) + } + return entry, nil +} + +// watchNotes looks at a task's notes until every note sent to it is read. +func (b *appBackend) watchNotes(live *appLive, id, local string) { + tick := time.NewTicker(appNoteBeat) + defer tick.Stop() + for range tick.C { + var pending []appNotePending + gone := false + b.locked(func() { + pending = append(pending, live.notes[id]...) + gone = live.closed + }) + if len(pending) == 0 || gone { + return + } + view, found, err := live.conn.Conv.AppTask(local) + if err != nil || !found { + continue + } + var read []appwire.NoteChanged + var left []appNotePending + for _, note := range pending { + if note.index < len(view.Notes) && view.Notes[note.index].ReadAtStep != nil { + step := *view.Notes[note.index].ReadAtStep + read = append(read, appwire.NoteChanged{Task: id, ID: note.entry.ID, State: appwire.NoteRead, ReadAtStep: &step}) + continue + } + left = append(left, note) + } + b.locked(func() { live.notes[id] = left }) + for _, changed := range read { + live.box.Event(appwire.EventNoteChanged, changed) + } + if appTaskSettled(view.State) { + b.locked(func() { delete(live.notes, id) }) + return + } + } +} + +// appTaskSettled says a task's state is one no step follows. +func appTaskSettled(state string) bool { + switch state { + case "done", "failed", "stopped", "cancelled", "declined": + return true + } + return false +} + +// TaskEntries is a task's own conversation: its journal read as entries, its +// notes both ways, and its own tasks. +func (b *appBackend) TaskEntries(id, before string, limit int) ([]appwire.Entry, bool, error) { + agent, local, ok := appSplitID(id) + if !ok { + return nil, false, appwire.Errorf(appwire.CodeBadParams, "%s is not a task id", id) + } + a, live, err := b.heldLive(agent) + if err != nil { + return nil, false, err + } + n, err := strconv.ParseUint(local, 10, 64) + if err != nil { + return nil, false, appwire.Errorf(appwire.CodeBadParams, "%s is not a task id", id) + } + at := b.now() + var all []appwire.Entry + if journal, ok := live.conn.Conv.(appTaskJournal); ok { + record, err := journal.TaskRoom(n, session.TaskJournalTail) + if err != nil { + return nil, false, appEngineError(err) + } + read := session.ReadTranscriptBytes(record.Journal) + items := appRecordItems(read) + for _, entry := range appEntriesOf(agent, items, at, nil) { + entry.ID = id + "/" + entry.ID + all = append(all, entry) + } + } + if view, found, err := live.conn.Conv.AppTask(local); err == nil && found { + for i, note := range view.Notes { + entry := appwire.Entry{ID: id + "/note" + strconv.Itoa(i), Agent: agent, Kind: appwire.EntryNote, At: at, Text: redact.Secrets(note.Text), From: "you", State: appwire.NoteWaiting} + if note.Direction != 0 { + entry.From, entry.State = "task", "" + } + if note.ReadAtStep != nil { + step := *note.ReadAtStep + entry.State, entry.ReadAtStep = appwire.NoteRead, &step + } + entry.ToolFacts.Task = id + all = append(all, entry) + } + } + tasks, err := b.ListTasks(agent) + if err == nil { + for _, task := range tasks { + if task.Parent == id { + entry := appwire.Entry{ID: id + "/" + task.ID, Agent: agent, Kind: appwire.EntryNotice, At: at, Text: task.Title + " · " + task.State, Ref: task.ID} + entry.ToolFacts.Task = task.ID + all = append(all, entry) + } + } + } + _ = a + end := len(all) + if before != "" { + end = -1 + for i, entry := range all { + if entry.ID == before { + end = i + break + } + } + if end < 0 { + return nil, false, appwire.Errorf(appwire.CodeNotFound, "this task has no entry %s", before) + } + } + start := max(end-limit, 0) + return append([]appwire.Entry{}, all[start:end]...), start > 0, nil +} + +// appTaskExtras fills what a task list says about each task's place among +// the others: what waits on it, and how many of its own tasks are done. +func appTaskExtras(tasks []appwire.Task) { + byID := map[string]int{} + for i := range tasks { + byID[tasks[i].ID] = i + } + children, done := map[string]int{}, map[string]int{} + for _, task := range tasks { + if task.Parent != "" { + children[task.Parent]++ + if task.State == appwire.TaskDone { + done[task.Parent]++ + } + } + for _, waited := range task.WaitsOn { + if i, ok := byID[waited]; ok && task.State != appwire.TaskDone && task.State != appwire.TaskFailed { + tasks[i].HoldingUp = append(tasks[i].HoldingUp, task.ID) + } + } + } + for i := range tasks { + if count := children[tasks[i].ID]; count > 0 { + finished := done[tasks[i].ID] + tasks[i].Children, tasks[i].ChildrenDone = &count, &finished + } + } +} + +// ── long output ───────────────────────────────────────────────────────────── + +// appTrimOutput keeps only the last 512 KB of a file of output, with a line +// saying so, once it has ended. +func appTrimOutput(path string) { + info, err := os.Stat(path) + if err != nil || info.Size() <= appOutputKeep { + return + } + file, err := os.Open(path) + if err != nil { + return + } + defer file.Close() + tail := make([]byte, appOutputKeep) + if _, err := file.ReadAt(tail, info.Size()-appOutputKeep); err != nil && !errors.Is(err, io.EOF) { + return + } + if cut := bytes.IndexByte(tail, '\n'); cut >= 0 { + tail = tail[cut+1:] + } + _ = os.WriteFile(path, append([]byte(appTrimmedLine+"\n"), tail...), 0o600) +} + +// appTrimmedStart is the first line of a process's output that the last +// 512 KB of it holds, for a reader that starts at the beginning. +func appTrimmedStart(path string) int { + info, err := os.Stat(path) + if err != nil || info.Size() <= appOutputKeep { + return 0 + } + file, err := os.Open(path) + if err != nil { + return 0 + } + defer file.Close() + lines := 0 + left := info.Size() - appOutputKeep + buf := make([]byte, 64<<10) + for left > 0 { + n, err := file.Read(buf[:min(int64(len(buf)), left)]) + lines += bytes.Count(buf[:n], []byte{'\n'}) + left -= int64(n) + if err != nil { + break + } + } + return lines + 1 +} diff --git a/cmd/codeaf/appconvo_test.go b/cmd/codeaf/appconvo_test.go new file mode 100644 index 0000000000..72e97a3bd5 --- /dev/null +++ b/cmd/codeaf/appconvo_test.go @@ -0,0 +1,317 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/session" +) + +// entryWith waits for the entry.added matching. +func entryWith(rec *appRecorder, t *testing.T, match func(appwire.Entry) bool) appwire.Entry { + t.Helper() + i := rec.waitFor(t, 5*time.Second, func(event string, data any) bool { + added, ok := data.(appwire.EntryAdded) + return ok && match(added.Entry) + }) + rec.mu.Lock() + defer rec.mu.Unlock() + return rec.data[i].(appwire.EntryAdded).Entry +} + +// A STEER IS THE ENGINE'S. It is pending when sent, consumed at the step the +// engine reads it, and when the turn ends first it falls through and its own +// stream becomes the next turn, with the person's words as its message. +func TestAppSteerIsConsumedOrFallsThroughIntoTheNextTurn(t *testing.T) { + hold := make(chan struct{}) + conv := &fakeConversation{script: []session.Event{{Kind: session.EventToolBegin, CallID: "c1", Tool: "bash", Args: `{"command":"go test ./..."}`}}, hold: hold} + backend, rec, agent := workBackend(t, conv) + if err := backend.Steer(agent.ID, "too early", ""); err == nil { + t.Fatal("a steer with nothing running was taken") + } + if _, err := backend.Send(agent.ID, "run the suite", ""); err != nil { + t.Fatal(err) + } + rec.waitFor(t, 5*time.Second, func(event string, data any) bool { _, ok := data.(appwire.TurnTool); return ok }) + + first := make(chan session.Event, 4) + conv.mu.Lock() + conv.steer = first + conv.mu.Unlock() + if err := backend.Steer(agent.ID, "skip the e2e suite", "s-1"); err != nil { + t.Fatal(err) + } + pending := entryWith(rec, t, func(e appwire.Entry) bool { return e.Kind == appwire.EntrySteer }) + if pending.State != appwire.SteerPending || pending.ClientID != "s-1" || pending.Text != "skip the e2e suite" { + t.Fatalf("the pending steer = %+v", pending) + } + first <- session.Event{Kind: session.EventSteerAccepted} + first <- session.Event{Kind: session.EventSteerConsumed} + close(first) + consumed := entryWith(rec, t, func(e appwire.Entry) bool { return e.ID == pending.ID && e.State == appwire.SteerConsumed }) + if consumed.Step == nil || *consumed.Step != 1 { + t.Fatalf("the consumed steer = %+v", consumed) + } + + late := make(chan session.Event, 8) + conv.mu.Lock() + conv.steer = late + conv.mu.Unlock() + if err := backend.Steer(agent.ID, "and bump the version", "s-2"); err != nil { + t.Fatal(err) + } + late <- session.Event{Kind: session.EventSteerFellThrough} + late <- session.Event{Kind: session.EventTextDelta, Text: "Bumped."} + late <- session.Event{Kind: session.EventTurnDone} + close(late) + close(hold) + entryWith(rec, t, func(e appwire.Entry) bool { return e.Kind == appwire.EntrySteer && e.State == appwire.SteerFellThrough }) + user := entryWith(rec, t, func(e appwire.Entry) bool { return e.Kind == appwire.EntryUser && e.Text == "and bump the version" }) + if user.ClientID != "s-2" || user.Turn == 0 { + t.Fatalf("the fallen-through message = %+v", user) + } + answer := entryWith(rec, t, func(e appwire.Entry) bool { return e.Kind == appwire.EntryAnswer && e.Text == "Bumped." }) + if answer.Turn != user.Turn { + t.Fatalf("the answer belongs to turn %d, the message to %d", answer.Turn, user.Turn) + } +} + +// THE QUEUE IS EDITED IN PLACE AND SENT IN ITS ORDER when the turn ends; a +// message already sent cannot be edited. +func TestAppQueueIsEditedReorderedAndSentInOrder(t *testing.T) { + hold := make(chan struct{}) + conv := &fakeConversation{hold: hold} + backend, rec, agent := workBackend(t, conv) + if _, err := backend.Send(agent.ID, "first", ""); err != nil { + t.Fatal(err) + } + var ids []string + for _, text := range []string{"then the README", "and the patch version", "tag it"} { + queued, err := backend.Queue(agent.ID, text, "") + if err != nil { + t.Fatal(err) + } + ids = append(ids, queued[len(queued)-1].ID) + } + if _, err := backend.EditQueued(agent.ID, ids[1], "and bump the patch version"); err != nil { + t.Fatal(err) + } + if _, err := backend.MoveQueued(agent.ID, ids[2], 0); err != nil { + t.Fatal(err) + } + queued, err := backend.RemoveQueued(agent.ID, ids[0]) + if err != nil { + t.Fatal(err) + } + if len(queued) != 2 || queued[0].Text != "tag it" || queued[1].Text != "and bump the patch version" { + t.Fatalf("the queue = %+v", queued) + } + rec.waitFor(t, 5*time.Second, func(event string, data any) bool { + c, ok := data.(appwire.QueueChanged) + return ok && len(c.Queued) == 2 + }) + conv.mu.Lock() + conv.hold = nil + conv.mu.Unlock() + close(hold) + deadline := time.Now().Add(5 * time.Second) + for { + conv.mu.Lock() + texts := append([]string(nil), conv.texts...) + conv.mu.Unlock() + if len(texts) == 3 { + if texts[1] != "tag it" || texts[2] != "and bump the patch version" { + t.Fatalf("sent in the order %q", texts) + } + break + } + if time.Now().After(deadline) { + t.Fatalf("sent %q", texts) + } + time.Sleep(20 * time.Millisecond) + } + if _, err := backend.EditQueued(agent.ID, ids[2], "too late"); err == nil || !strings.Contains(err.Error(), "already been sent") { + t.Fatalf("an edit after the send = %v", err) + } +} + +// AN ASK CARRIES THE DOCK'S FACTS from its question: where it comes from, the +// evidence, the choices with the suggestion, the clock and the tasks it holds +// up; putting it aside and handing it back are answers of their own. +func TestAppAskCarriesTheDocksFactsAndCanBeHandedBack(t *testing.T) { + conv := &fakeConversation{} + backend, rec, agent := workBackend(t, conv) + deadline := time.Now().Add(12 * time.Second).Round(time.Second) + q := session.Question{ + ID: 9, Kind: session.QuestionAsk, Ask: session.AskChoice, Head: "Should trailing commas be on by default?", Reason: "the parser accepts both", + Subject: session.SubjectRef{Kind: session.SubjectNode, ID: 4}, + Options: []session.AnswerOption{{Key: "strict", Label: "Keep strict", Body: "Opt in with strict: false."}, {Key: "tolerant", Label: "Tolerant by default"}}, + Pick: &session.Pick{Key: "strict"}, Deadline: deadline, + Blocking: session.Blocking{Tasks: []string{"5"}}, + Attach: []session.Block{{Kind: session.BlockText, Body: "a\nb\nc\nd\ne\nf\ng\nh\ni\nj"}}, + } + conv.raise(session.Event{Kind: session.EventQuestion, Question: &q}) + i := rec.waitFor(t, 5*time.Second, appAskEvent(appwire.EventAskOpened)) + rec.mu.Lock() + ask := rec.data[i].(appwire.AskOpened).Ask + rec.mu.Unlock() + if ask.Source == nil || ask.Source.Kind != "task" || ask.Source.Task != agent.ID+":4" || ask.Reason != "the parser accepts both" { + t.Fatalf("where the ask comes from = %+v", ask) + } + if len(ask.Choices) != 2 || !ask.Choices[0].Suggested || ask.Choices[1].Suggested || ask.Choices[0].Detail == "" { + t.Fatalf("the choices = %+v", ask.Choices) + } + if ask.ClockUntil == nil || !ask.ClockUntil.Equal(deadline) || len(ask.HoldingUp) != 1 || ask.HoldingUp[0] != agent.ID+":5" { + t.Fatalf("the clock and what it holds up = %+v", ask) + } + if ask.Evidence == nil || len(ask.Evidence.Lines) != appEvidenceLines || ask.Evidence.More != 2 { + t.Fatalf("the evidence = %+v", ask.Evidence) + } + if err := backend.AnswerAskWith(agent.ID, ask.ID, appwire.AskAnswer{Later: true}); err != nil { + t.Fatal(err) + } + rec.waitFor(t, 5*time.Second, func(event string, data any) bool { + o, ok := data.(appwire.AskOpened) + return ok && o.Ask.ID == ask.ID && o.Ask.Later + }) + conv.mu.Lock() + conv.onAnswer = func(answer session.Answer) { + closed := session.Question{Kind: answer.Kind, ID: answer.ID} + conv.raise(session.Event{Kind: session.EventQuestionAnswered, Question: &closed, Answer: &answer}) + } + conv.mu.Unlock() + if err := backend.AnswerAskWith(agent.ID, ask.ID, appwire.AskAnswer{YouDecide: true}); err != nil { + t.Fatal(err) + } + receipt := entryWith(rec, t, func(e appwire.Entry) bool { return e.Kind == appwire.EntryReceipt }) + if !strings.HasPrefix(receipt.Text, "You let it decide · it chose Keep strict · ") { + t.Fatalf("the receipt = %q", receipt.Text) + } + conv.mu.Lock() + got := conv.answers[0] + conv.mu.Unlock() + if got.Key != "strict" { + t.Fatalf("the engine was handed %+v", got) + } +} + +// FILES AND LINKS IN THE COMPOSER: a reference finds the workspace's files, a +// path is stat'ed as there, missing or outside, a long paste becomes a card, +// and a message's attachments ride on its entry while the engine is sent words. +func TestAppFilesAndAttachmentsRideOnTheMessage(t *testing.T) { + conv := &fakeConversation{script: []session.Event{{Kind: session.EventTurnDone}}} + backend, rec, agent := workBackend(t, conv) + work := agent.Workspace + if err := os.MkdirAll(filepath.Join(work, "internal", "retry"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(work, "internal", "retry", "backoff.go"), []byte("package retry\n"), 0o644); err != nil { + t.Fatal(err) + } + groups, err := backend.FindFiles(agent.ID, "backo", 10) + if err != nil || len(groups) != 1 || len(groups[0].Files) != 1 || groups[0].Files[0].Path != filepath.Join("internal", "retry", "backoff.go") { + t.Fatalf("files.find = %+v %v", groups, err) + } + stats, err := backend.StatFiles(agent.ID, []string{"internal/retry/backoff.go", "gone.go", "/etc/hosts"}) + if err != nil || stats[0].State != appwire.FileExists || stats[1].State != appwire.FileMissing || stats[2].State != appwire.FileOutside { + t.Fatalf("files.stat = %+v %v", stats, err) + } + if _, err := backend.AddAttachment(agent.ID, appwire.Attachment{Kind: appwire.AttachPaste}, "short"); err == nil { + t.Fatal("a short paste became a card") + } + paste, err := backend.AddAttachment(agent.ID, appwire.Attachment{Kind: appwire.AttachPaste}, strings.Repeat("--- FAIL: TestLoad\n", 20)) + if err != nil || paste.Lines == nil || *paste.Lines != 20 { + t.Fatalf("the paste card = %+v %v", paste, err) + } + file, err := backend.AddAttachment(agent.ID, appwire.Attachment{Kind: appwire.AttachFile, Path: "internal/retry/backoff.go"}, "") + if err != nil || file.State != appwire.FileExists { + t.Fatalf("the file chip = %+v %v", file, err) + } + if _, err := backend.SendWith(agent.ID, "why does it fail?", "m-1", []string{file.ID, paste.ID}); err != nil { + t.Fatal(err) + } + user := entryWith(rec, t, func(e appwire.Entry) bool { return e.Kind == appwire.EntryUser }) + if user.Text != "why does it fail?" || len(user.Attachments) != 2 { + t.Fatalf("the person's entry = %+v", user) + } + conv.mu.Lock() + sent := conv.texts[0] + conv.mu.Unlock() + if !strings.Contains(sent, "Attached file: internal/retry/backoff.go") || !strings.Contains(sent, "--- FAIL: TestLoad") { + t.Fatalf("the engine was sent %q", sent) + } +} + +// A NOTE TO A TASK is waiting until the engine says a step carried it, and +// then read at that step. +func TestAppTaskNoteIsReadAtAStep(t *testing.T) { + conv := &fakeConversation{appTasks: []session.AppTaskView{{ID: "4", Title: "Update fixtures", State: "running"}}} + backend, rec, agent := workBackend(t, conv) + entry, err := backend.NoteTask(agent.ID+":4", "Also cover the empty-array case.", "n-1") + if err != nil || entry.State != appwire.NoteWaiting || entry.ToolFacts.Task != agent.ID+":4" || entry.From != "you" { + t.Fatalf("the note = %+v %v", entry, err) + } + step := 5 + conv.mu.Lock() + conv.appTasks[0].Notes = []session.AppTaskNote{{Text: "Also cover the empty-array case.", ReadAtStep: &step}} + conv.mu.Unlock() + i := rec.waitFor(t, 6*time.Second, func(event string, data any) bool { + changed, ok := data.(appwire.NoteChanged) + return ok && changed.State == appwire.NoteRead + }) + rec.mu.Lock() + read := rec.data[i].(appwire.NoteChanged) + rec.mu.Unlock() + if read.ID != entry.ID || read.ReadAtStep == nil || *read.ReadAtStep != 5 { + t.Fatalf("the read receipt = %+v", read) + } + if err := backend.RetryTask(agent.ID + ":4"); err != nil { + t.Fatal(err) + } + conv.mu.Lock() + retried := append([]uint64(nil), conv.retried...) + conv.mu.Unlock() + if len(retried) != 1 || retried[0] != 4 { + t.Fatalf("retried %v", retried) + } +} + +// A LISTING SAYS WHAT THE LIVE TURN SAID: an update before a tool call, and a +// mark where a compaction summarized what came before, which it shows once. +func TestAppListingNamesUpdatesAndSummaries(t *testing.T) { + record := session.Record{ + Earlier: []session.DisplayEntry{{Role: "user", Text: "old question"}, {Role: "assistant", Text: "old answer"}}, + Entries: []session.DisplayEntry{{Role: "user", Text: "rewritten"}, {Role: "user", Text: "now"}, {Role: "assistant", Text: "looking"}, {Role: "tool", Tool: "read", CallID: "c1", Answered: true}, {Role: "assistant", Text: "done"}}, + Floor: 1, + } + entries := appEntriesOf("a", appRecordItems(record), time.Now(), nil) + var kinds []string + for _, e := range entries { + kinds = append(kinds, e.Kind) + } + want := []string{appwire.EntryUser, appwire.EntryAnswer, appwire.EntrySummary, appwire.EntryUser, appwire.EntryUpdate, appwire.EntryTool, appwire.EntryAnswer} + if strings.Join(kinds, " ") != strings.Join(want, " ") { + t.Fatalf("kinds = %v, want %v", kinds, want) + } +} + +// A LONG OUTPUT KEEPS ITS LAST 512 KB, and says so on its first line. +func TestAppLongOutputIsTrimmedToItsLast512KB(t *testing.T) { + path := filepath.Join(t.TempDir(), "out.log") + line := strings.Repeat("x", 99) + "\n" + if err := os.WriteFile(path, []byte(strings.Repeat(line, 8000)), 0o600); err != nil { + t.Fatal(err) + } + if start := appTrimmedStart(path); start == 0 { + t.Fatal("a long output is read whole") + } + appTrimOutput(path) + data, err := os.ReadFile(path) + if err != nil || !strings.HasPrefix(string(data), appTrimmedLine+"\n") || len(data) > appOutputKeep+len(appTrimmedLine)+1 { + t.Fatalf("trimmed to %d bytes, %v", len(data), err) + } +} diff --git a/cmd/codeaf/appinbox.go b/cmd/codeaf/appinbox.go new file mode 100644 index 0000000000..10d0631024 --- /dev/null +++ b/cmd/codeaf/appinbox.go @@ -0,0 +1,450 @@ +package main + +// ── codeaf app: THE INBOX ────────────────────────────────────────────────── +// +// Every open ask of every agent this app holds is one Inbox item (PROTOCOL.md, +// Wave 4). The Inbox is not a second store of questions: it is read off the +// same events the app already says — an ask opening, an ask closing and the +// receipt that follows — so an ask answered anywhere (the dock, a task's +// page, a crew, the terminal) leaves the Inbox in the same instant it leaves +// the conversation, and one answered from the Inbox closes everywhere else the +// same way, through the conversation's one answer door. +// +// AN ANSWER FROM THE INBOX IS HELD FIVE SECONDS before it is sent, so a +// person who answered the wrong row can take it back; nothing reaches the +// engine until the hold ends. +// +// An agent with an open ask is never let go by the idle sweep (app.go's +// [appBackend.sweepAgent]), which is what keeps its asks answerable here. + +import ( + "strings" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/guard" +) + +const ( + // appInboxHold is how long an Inbox answer waits before it is sent. + appInboxHold = 5 * time.Second + // appInboxRecent is how many of an agent's recent entries are kept for + // an item's context, and appInboxDone how many done items are kept. + appInboxRecent = 60 + appInboxDone = 500 +) + +// appInbox is the Inbox's state: the open items, the done ones, the recent +// conversation of each agent, and the answers being held. +type appInbox struct { + mu sync.Mutex + next appwire.Emitter + items map[string]*appwire.InboxItem + order []string + done []appwire.InboxItem + recent map[string][]appwire.Entry + closing map[string]appwire.AskClosed + held map[string]*appInboxHeld + from map[string]string +} + +// appInboxHeld is one Inbox answer waiting out its hold. +type appInboxHeld struct { + timer *time.Timer + until time.Time + sent bool +} + +func (in *appInbox) locked(fn func()) { + in.mu.Lock() + defer in.mu.Unlock() + fn() +} + +// appInboxTap is the backend's emitter with the Inbox listening: every event +// goes out as it is, and the Inbox says what it changed after it. +type appInboxTap struct { + b *appBackend + next appwire.Emitter +} + +func (t *appInboxTap) Emit(event string, data any) { + t.next.Emit(event, data) + t.b.inboxSaw(event, data) +} + +// newAppInbox listens on emit and answers the emitter the backend uses. +func (b *appBackend) newAppInbox(emit appwire.Emitter) appwire.Emitter { + b.inbox = &appInbox{next: emit, items: map[string]*appwire.InboxItem{}, recent: map[string][]appwire.Entry{}, closing: map[string]appwire.AskClosed{}, held: map[string]*appInboxHeld{}, from: map[string]string{}} + return &appInboxTap{b: b, next: emit} +} + +// appInboxID is an item's id: its agent and its ask. +func appInboxID(agent, ask string) string { return agent + "/" + ask } + +// appInboxSplit is an item id's agent and ask. +func appInboxSplit(id string) (string, string, bool) { + agent, ask, ok := strings.Cut(id, "/") + return agent, ask, ok && agent != "" && ask != "" +} + +// inboxSaw folds one event the app said into the Inbox. +func (b *appBackend) inboxSaw(event string, data any) { + in := b.inbox + if in == nil { + return + } + switch v := data.(type) { + case appwire.AskOpened: + b.inboxOpened(v.Ask) + case appwire.AskClosed: + id := appInboxID(v.Agent, v.ID) + var held *appInboxHeld + in.locked(func() { + in.closing[id] = v + held = in.held[id] + if held != nil && !held.sent { + held.timer.Stop() + } + delete(in.held, id) + }) + case appwire.EntryAdded: + entry := v.Entry + switch entry.Kind { + case appwire.EntryUser, appwire.EntryAnswer, appwire.EntryUpdate: + in.locked(func() { + recent := append(in.recent[entry.Agent], entry) + if len(recent) > appInboxRecent { + recent = recent[len(recent)-appInboxRecent:] + } + in.recent[entry.Agent] = recent + }) + case appwire.EntryReceipt: + if ask, ok := strings.CutPrefix(entry.ID, "r"); ok { + b.inboxResolved(appInboxID(entry.Agent, ask), entry) + } + } + } +} + +// inboxOpened adds an ask to the Inbox, or says it again as it now stands. +func (b *appBackend) inboxOpened(ask appwire.Ask) { + in := b.inbox + id := appInboxID(ask.Agent, ask.ID) + var item appwire.InboxItem + known := false + in.locked(func() { + if have := in.items[id]; have != nil { + known = true + have.Ask = ask + have.List = appwire.InboxWaiting + if ask.Later { + have.List = appwire.InboxLater + } + item = *have + } + }) + if !known { + item = appwire.InboxItem{ID: id, Ask: ask, Where: b.inboxWhere(ask), At: b.now(), List: appwire.InboxWaiting} + if ask.Later { + item.List = appwire.InboxLater + } + in.locked(func() { + item.Context = appInboxContext(in.recent[ask.Agent]) + kept := item + in.items[id] = &kept + in.order = append(in.order, id) + delete(in.closing, id) + }) + } + in.next.Emit(appwire.EventInboxAdded, appwire.InboxAddedEvent{Item: item}) + b.inboxCounts() +} + +// appInboxContext is the two turns before a question: from the person's +// second-to-last message on. +func appInboxContext(recent []appwire.Entry) []appwire.Entry { + users := 0 + start := 0 + for i := len(recent) - 1; i >= 0; i-- { + if recent[i].Kind == appwire.EntryUser { + users++ + start = i + if users == 2 { + break + } + } + } + return append([]appwire.Entry{}, recent[start:]...) +} + +// inboxWhere is where an ask comes from: its agent, crew and task, and the +// names on the way there. +func (b *appBackend) inboxWhere(ask appwire.Ask) appwire.InboxWhere { + where := appwire.InboxWhere{Agent: ask.Agent} + var title, taskTitle string + b.locked(func() { + if a := b.agents[ask.Agent]; a != nil { + title = a.view.Title + if ask.Source != nil && ask.Source.Task != "" && a.live != nil { + where.Task = ask.Source.Task + taskTitle = a.live.tasks[ask.Source.Task].Title + } + } + }) + if taskTitle != "" { + where.Path = append(where.Path, taskTitle) + } + if crews, err := appReadCrews(); err == nil { + for _, crew := range crews { + for _, member := range crew.Members { + if member.Agent == ask.Agent { + where.Crew = crew.ID + where.Path = append([]string{crew.Name}, where.Path...) + } + } + } + } + if title != "" { + at := 0 + if where.Crew != "" { + at = 1 + } + where.Path = append(where.Path[:at], append([]string{title}, where.Path[at:]...)...) + } + return where +} + +// inboxResolved moves an item to done once its receipt is said. +func (b *appBackend) inboxResolved(id string, receipt appwire.Entry) { + in := b.inbox + var closed appwire.AskClosed + open := false + where := "" + in.locked(func() { + item := in.items[id] + if item == nil { + return + } + open = true + closed = in.closing[id] + where = in.from[id] + delete(in.items, id) + delete(in.closing, id) + delete(in.from, id) + for i, have := range in.order { + if have == id { + in.order = append(in.order[:i], in.order[i+1:]...) + break + } + } + by := closed.By + if by == "" { + by = appwire.ClosedByElsewhere + } + at := closed.At + if at.IsZero() { + at = b.now() + } + item.List = appwire.InboxDone + item.Answered = &appwire.InboxAnswered{Option: closed.Option, By: by, At: at, Where: where} + in.done = append(in.done, *item) + if len(in.done) > appInboxDone { + in.done = in.done[len(in.done)-appInboxDone:] + } + }) + if !open { + return + } + by := closed.By + switch { + case strings.HasPrefix(receipt.Text, "No longer needed"): + by = appwire.ResolvedNoLongerNeeded + case by == "": + by = appwire.ClosedByElsewhere + } + in.next.Emit(appwire.EventInboxResolved, appwire.InboxResolvedEvent{ID: id, By: by, Receipt: receipt.Text}) + b.inboxCounts() +} + +// countsLocked are the Inbox's counts now. Callers hold in.mu. +func (b *appBackend) countsLocked() appwire.InboxCounts { + in := b.inbox + var counts appwire.InboxCounts + for _, item := range in.items { + if item.List == appwire.InboxLater { + counts.Later++ + } else { + counts.Waiting++ + } + } + now := b.now().Local() + midnight := time.Date(now.Year(), now.Month(), now.Day(), 0, 0, 0, 0, now.Location()) + for _, item := range in.done { + if item.Answered != nil && !item.Answered.At.Before(midnight) { + counts.DoneToday++ + } + } + return counts +} + +// inboxCounts says the counts, which drive the rail badge and the Dock badge. +func (b *appBackend) inboxCounts() { + var counts appwire.InboxCounts + b.inbox.locked(func() { counts = b.countsLocked() }) + b.inbox.next.Emit(appwire.EventInboxCounts, counts) +} + +// ListInbox answers one of the Inbox's lists, newest first, narrowed by a +// query over its words and where it comes from. +func (b *appBackend) ListInbox(list, query string) ([]appwire.InboxItem, appwire.InboxCounts, error) { + in := b.inbox + query = strings.ToLower(query) + matches := func(item appwire.InboxItem) bool { + if query == "" { + return true + } + words := strings.ToLower(item.Ask.Title + " " + item.Ask.Detail + " " + item.Ask.Reason + " " + strings.Join(item.Where.Path, " ")) + return strings.Contains(words, query) + } + var items []appwire.InboxItem + var counts appwire.InboxCounts + in.locked(func() { + counts = b.countsLocked() + if list == appwire.InboxDone { + for i := len(in.done) - 1; i >= 0; i-- { + if matches(in.done[i]) { + items = append(items, in.done[i]) + } + } + return + } + for i := len(in.order) - 1; i >= 0; i-- { + item := in.items[in.order[i]] + if item != nil && item.List == list && matches(*item) { + items = append(items, *item) + } + } + }) + return items, counts, nil +} + +// AnswerInbox holds an answer for five seconds, then sends it through the +// conversation's own answer door. +func (b *appBackend) AnswerInbox(id, option, text, why string) (time.Time, error) { + agent, ask, ok := appInboxSplit(id) + if !ok { + return time.Time{}, appwire.Errorf(appwire.CodeBadParams, "%s is not an Inbox item", id) + } + in := b.inbox + var refused error + var until time.Time + in.locked(func() { + switch { + case in.items[id] == nil: + refused = appwire.Errorf(appwire.CodeNotFound, "the Inbox item %s is no longer waiting", id) + case in.held[id] != nil: + refused = appwire.Errorf(appwire.CodeBusy, "the Inbox item %s is already being answered", id) + default: + until = b.now().Add(appInboxHold) + held := &appInboxHeld{until: until} + held.timer = time.AfterFunc(appInboxHold, func() { + defer guard.Recover("app/inbox-answer") + send := false + in.locked(func() { + if in.held[id] == held { + held.sent, send = true, true + in.from[id] = "inbox" + } + }) + if !send { + return + } + if err := b.AnswerAskWith(agent, ask, appwire.AskAnswer{Option: option, Text: text, Why: why}); err != nil { + in.locked(func() { + delete(in.held, id) + delete(in.from, id) + }) + } + }) + in.held[id] = held + } + }) + return until, refused +} + +// UndoInbox takes back an Inbox answer still in its hold: nothing is sent and +// the item stays waiting. +func (b *appBackend) UndoInbox(id string) error { + in := b.inbox + var refused error + in.locked(func() { + held := in.held[id] + switch { + case held == nil: + refused = appwire.Errorf(appwire.CodeNotFound, "there is no answer to take back for %s", id) + case held.sent: + refused = appwire.Errorf(appwire.CodeSent, "that answer has already been sent") + default: + held.timer.Stop() + delete(in.held, id) + } + }) + return refused +} + +// LaterInbox puts an item aside, or brings it back to waiting; the dock reads +// the same mark. +func (b *appBackend) LaterInbox(id string, later bool) error { + agent, askID, ok := appInboxSplit(id) + if !ok { + return appwire.Errorf(appwire.CodeBadParams, "%s is not an Inbox item", id) + } + a, err := b.find(agent) + if err != nil { + return err + } + live := b.liveOf(a) + if live == nil { + return appwire.Errorf(appwire.CodeNotFound, "the Inbox item %s is no longer waiting", id) + } + var ask appwire.Ask + found := false + b.locked(func() { + for i := range live.asks { + if live.asks[i].ID == askID { + live.asks[i].Later = later + ask, found = live.asks[i], true + } + } + }) + if !found { + return appwire.Errorf(appwire.CodeNotFound, "the Inbox item %s is no longer waiting", id) + } + live.box.Event(appwire.EventAskOpened, appwire.AskOpened{Ask: ask}) + return nil +} + +// OpenInbox answers where an item leads: its agent, and its task when it has +// one. +func (b *appBackend) OpenInbox(id string) (string, string, error) { + in := b.inbox + var where appwire.InboxWhere + found := false + in.locked(func() { + if item := in.items[id]; item != nil { + where, found = item.Where, true + return + } + for _, item := range in.done { + if item.ID == id { + where, found = item.Where, true + } + } + }) + if !found { + return "", "", appwire.Errorf(appwire.CodeNotFound, "there is no Inbox item %s", id) + } + return where.Agent, where.Task, nil +} diff --git a/cmd/codeaf/appinbox_test.go b/cmd/codeaf/appinbox_test.go new file mode 100644 index 0000000000..ddfe9dc731 --- /dev/null +++ b/cmd/codeaf/appinbox_test.go @@ -0,0 +1,86 @@ +package main + +import ( + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/session" +) + +// EVERY OPEN ASK IS AN INBOX ITEM with the turns before it; an answer from the +// Inbox is held and can be taken back, and an answer anywhere else clears the +// item with its receipt. +func TestAppInboxHoldsUndoesAndClearsEverywhere(t *testing.T) { + conv := &fakeConversation{script: []session.Event{ + {Kind: session.EventTextDelta, Text: "Looking at the build."}, + {Kind: session.EventTurnDone}, + }} + backend, rec, agent := workBackend(t, conv) + conv.mu.Lock() + conv.onAnswer = func(answer session.Answer) { + closed := session.Question{Kind: answer.Kind, ID: answer.ID} + conv.raise(session.Event{Kind: session.EventQuestionAnswered, Question: &closed, Answer: &answer}) + } + conv.mu.Unlock() + if _, err := backend.Send(agent.ID, "clean the build dir", ""); err != nil { + t.Fatal(err) + } + rec.waitFor(t, 5*time.Second, appTurnDone) + + first := session.Question{ID: 4, Kind: session.QuestionConsent, Head: "bash rm -rf build", Reason: "bash asks first", Options: session.AnswerOptions(session.QuestionConsent)} + conv.raise(session.Event{Kind: session.EventQuestion, Question: &first}) + i := rec.waitFor(t, 5*time.Second, func(event string, data any) bool { _, ok := data.(appwire.InboxAddedEvent); return ok }) + rec.mu.Lock() + item := rec.data[i].(appwire.InboxAddedEvent).Item + rec.mu.Unlock() + if item.ID != agent.ID+"/consent:4" || item.List != appwire.InboxWaiting || item.Where.Agent != agent.ID || len(item.Context) != 2 || item.Context[0].Text != "clean the build dir" { + t.Fatalf("the item = %+v", item) + } + items, counts, _ := backend.ListInbox(appwire.InboxWaiting, "rm -rf") + if len(items) != 1 || counts.Waiting != 1 { + t.Fatalf("waiting = %+v %+v", items, counts) + } + + until, err := backend.AnswerInbox(item.ID, appwire.OptionAllowOnce, "", "") + if err != nil || until.Before(time.Now().Add(4*time.Second)) { + t.Fatalf("the held answer = %v %v", until, err) + } + if err := backend.UndoInbox(item.ID); err != nil { + t.Fatal(err) + } + time.Sleep(appInboxHold + 500*time.Millisecond) + conv.mu.Lock() + sent := len(conv.answers) + conv.mu.Unlock() + if sent != 0 { + t.Fatal("an answer taken back was sent") + } + + if err := backend.LaterInbox(item.ID, true); err != nil { + t.Fatal(err) + } + rec.waitFor(t, 5*time.Second, func(event string, data any) bool { + added, ok := data.(appwire.InboxAddedEvent) + return ok && added.Item.ID == item.ID && added.Item.List == appwire.InboxLater + }) + // Answered in the dock, it leaves the Inbox with its receipt. + if err := backend.AnswerAsk(agent.ID, "consent:4", appwire.OptionAllowOnce, ""); err != nil { + t.Fatal(err) + } + j := rec.waitFor(t, 5*time.Second, func(event string, data any) bool { _, ok := data.(appwire.InboxResolvedEvent); return ok }) + rec.mu.Lock() + resolved := rec.data[j].(appwire.InboxResolvedEvent) + rec.mu.Unlock() + if resolved.ID != item.ID || resolved.By != appwire.ClosedByApp || !strings.HasPrefix(resolved.Receipt, "Allowed once · you · ") { + t.Fatalf("resolved = %+v", resolved) + } + done, counts, _ := backend.ListInbox(appwire.InboxDone, "") + if len(done) != 1 || counts.DoneToday != 1 || counts.Later != 0 || done[0].Answered == nil { + t.Fatalf("done = %+v %+v", done, counts) + } + if a, task, err := backend.OpenInbox(item.ID); err != nil || a != agent.ID || task != "" { + t.Fatalf("open = %s %s %v", a, task, err) + } +} diff --git a/cmd/codeaf/appstate.go b/cmd/codeaf/appstate.go index eb3c5c7c65..0ef736ab95 100644 --- a/cmd/codeaf/appstate.go +++ b/cmd/codeaf/appstate.go @@ -68,6 +68,16 @@ type appState struct { file appStateFile } +// where is where this state is kept. +func (s *appState) where() string { + s.mu.Lock() + defer s.mu.Unlock() + if s.path == "" { + return appStatePath() + } + return s.path +} + // appStatePath is where the file lives under codeaf's home. func appStatePath() string { return filepath.Join(home.Dir(), appStateFolder, appStateName) diff --git a/cmd/codeaf/apptools.go b/cmd/codeaf/apptools.go index 28e3b7a785..84840baabe 100644 --- a/cmd/codeaf/apptools.go +++ b/cmd/codeaf/apptools.go @@ -98,11 +98,7 @@ func (l *appCallLog) locked(fn func()) { // outputRoot is the folder every agent's call output is kept under. func (b *appBackend) outputRoot() string { - path := b.state.path - if path == "" { - path = appStatePath() - } - return filepath.Join(filepath.Dir(path), "output") + return filepath.Join(filepath.Dir(b.state.where()), "output") } // callLog is one agent's call log, made the first time it is needed. @@ -422,6 +418,7 @@ func (b *appBackend) callEnded(a *appAgent, agent string, turn uint64, event ses if c.file != nil { _ = c.file.Close() c.file = nil + appTrimOutput(c.path) } c.ended = true row = b.rowLocked(agent, c) diff --git a/cmd/codeaf/appwork.go b/cmd/codeaf/appwork.go index 98de5159ff..95b640012f 100644 --- a/cmd/codeaf/appwork.go +++ b/cmd/codeaf/appwork.go @@ -37,7 +37,7 @@ const appTurnLong = 180 * time.Second // appMoveWait bounds how long agents.move_to_task waits for the reply's task: // the move happens at the reply's next step and writes a brief first. -const appMoveWait = 2 * time.Minute +const appMoveWait = 10 * time.Minute // appFollowEvery is how often a followed process's output is looked at. const appFollowEvery = 500 * time.Millisecond @@ -161,6 +161,22 @@ func appTaskOf(agent string, notice *session.TaskNotice, proposal bool, phase st if notice.Parent != 0 && notice.Parent != notice.ID { task.Parent = appTaskID(agent, notice.Parent) } + // The task as its own conversation: its model, its clock, its brief, what + // it waits on, and whether the person paused it. + task.Model, task.Paused = strings.TrimSpace(notice.Model), notice.Paused + task.Instructions = redact.Secrets(strings.TrimSpace(notice.Brief)) + task.CostUSD = task.SpentUSD + if !notice.StartedAt.IsZero() { + started := notice.StartedAt + task.StartedAt = &started + } + if !notice.EndedAt.IsZero() && (state == appwire.TaskDone || state == appwire.TaskFailed || state == appwire.TaskUnverified) { + ended := notice.EndedAt + task.FinishedAt = &ended + } + for _, waited := range notice.DependsOn { + task.WaitsOn = append(task.WaitsOn, appTaskID(agent, waited)) + } switch { case notice.Withdrawn != "": task.State, task.Result = appwire.TaskFailed, notice.Withdrawn @@ -208,6 +224,19 @@ func appTaskOfView(agent string, view session.AppTaskView) appwire.Task { default: task.State = appwire.TaskFailed } + task.Paused = view.State == "paused" || view.Held + task.Model, task.StartedBy = view.Model, view.Origin + task.Instructions = redact.Secrets(strings.TrimSpace(view.Brief)) + task.CostUSD = task.SpentUSD + if view.Step > 0 { + steps := view.Step + task.Steps = &steps + } + for _, waited := range view.Waits { + if w, err := strconv.ParseUint(waited, 10, 64); err == nil { + task.WaitsOn = append(task.WaitsOn, appTaskID(agent, w)) + } + } return task } @@ -545,12 +574,28 @@ func (b *appBackend) ActOnTask(id, action, verdict, text string) error { // pressed hold or started typing in the card. intent.Action = "hold" } - if err := live.conn.Conv.AppTaskAction(intent); err != nil { - return appTaskError(err) + // A TASK THE APP HAS ALREADY SEEN START can be a moment ahead of the + // engine's own task list; the action is asked again for a few seconds + // rather than refused for a task that is there. + known := false + b.locked(func() { _, known = live.tasks[id] }) + deadline := b.now().Add(appTaskSettleWait) + for { + err := live.conn.Conv.AppTaskAction(intent) + if err == nil { + return nil + } + if !known || !strings.Contains(err.Error(), "no such task") || b.now().After(deadline) { + return appTaskError(err) + } + time.Sleep(250 * time.Millisecond) } - return nil } +// appTaskSettleWait bounds how long an action on a task the app has seen +// waits for the engine's list to name it. +const appTaskSettleWait = 3 * time.Second + // appTaskError sorts an engine refusal about a task into the app's codes. func appTaskError(err error) error { words := err.Error() @@ -760,16 +805,24 @@ func (b *appBackend) processMoved(agent string, live *appLive, job *session.JobN return } process := appProcessOf(agent, *job) - same := false + same, finished := false, false b.locked(func() { before, known := live.processes[process.ID] live.processes[process.ID] = process same = known && reflect.DeepEqual(before, process) + finished = known && before.State == appwire.ProcessRunning && (process.State == appwire.ProcessEnded || process.State == appwire.ProcessFailed) live.logs[process.ID] = job.LogPath }) if !same { live.box.Event(appwire.EventProcessChanged, appwire.ProcessChanged{Process: process}) } + // A PROCESS THAT ENDS ON ITS OWN leaves one quiet line in the + // conversation, pointing at its output. + if finished { + footer := appFooter("pf"+process.ID, agent, 0, appwire.FooterProcessFinished, "Process finished · "+process.Title, b.now()) + footer.Ref = process.ID + live.box.Event(appwire.EventEntryAdded, appwire.EntryAdded{Entry: footer}) + } } // processRows is an agent's processes: the live registry's while its @@ -867,10 +920,20 @@ func (b *appBackend) ProcessOutput(id string, from int) ([]string, int, error) { if err != nil { return nil, 0, err } + // A long output is read from its last 512 KB, with a line saying so. + trimmed := false + if from == 0 { + if start := appTrimmedStart(path); start > 0 { + from, trimmed = start, true + } + } lines, next, err := appReadLines(path, from, appOutputLines) if err != nil { return nil, 0, appEngineError(err) } + if trimmed { + lines = append([]string{appTrimmedLine}, lines...) + } return lines, next, nil } diff --git a/docs/changes/unreleased/0-app-wave3b.md b/docs/changes/unreleased/0-app-wave3b.md new file mode 100644 index 0000000000..94b64a8b6b --- /dev/null +++ b/docs/changes/unreleased/0-app-wave3b.md @@ -0,0 +1,23 @@ +--- +kind: added +title: codeaf app steers, queues, docks questions and treats tasks as chats +pr: 0 +surface: [engine] +invalidates: + - "An app turn's answer entry carried the model's thinking, and thinking streamed as turn.delta. Thinking is now turn.thinking and turn.thinking_done only, never kept, and words before a tool call are an update entry." + - "A stopped app turn ended with a receipt and a provider failure with an error entry. Both are now footer entries (footer_kind stopped_by_you, provider_failed)." + - "agents.entries listed a compacted conversation's rewritten window twice. It now lists the earlier conversation, a summary mark, and the live transcript beneath the compaction's floor." + - "An agent could start any number of background processes. The seventeenth is now refused with 16 processes are running for this agent." +--- + +New methods: `agents.steer`, `agents.queue`, `queue.edit` / `remove` / `move`, +`asks.hold`, `files.find`, `files.stat`, `attachments.add`, `tasks.pause` / +`resume` / `retry` / `brief` / `entries`; `asks.answer` takes `why`, +`batch_item`, `later` and `you_decide`; `agents.send` takes `attachments`; +`tasks.note` answers its entry. Events: `turn.thinking`, +`turn.thinking_done`, `turn.retrying`, `queue.changed`, `note.changed`. Entries: +`update`, `steer`, `note`, `summary`, `footer`. Asks carry source, reason, +evidence, choices with the suggestion, clock, batch, the tasks they hold up and +say_why. Tasks carry paused, waits_on, holding_up, model, steps, cost, clock, +started_by, instructions and their own tasks' counts. A tool row's kind is +`tool_kind`. Output longer than 512 KB keeps its last 512 KB. diff --git a/docs/changes/unreleased/0-app-wave4-inbox.md b/docs/changes/unreleased/0-app-wave4-inbox.md new file mode 100644 index 0000000000..27e9709372 --- /dev/null +++ b/docs/changes/unreleased/0-app-wave4-inbox.md @@ -0,0 +1,12 @@ +--- +kind: added +title: codeaf app keeps every open question in one Inbox with a held answer and Undo +pr: 0 +surface: [engine] +--- + +New methods: `inbox.list`, `inbox.answer` (held five seconds, answers +`undo_until`), `inbox.undo`, `inbox.later`, `inbox.back`, `inbox.open`. Events: +`inbox.added`, `inbox.resolved` with the receipt, and `inbox.counts` for the +badges. Items carry where they come from (agent, crew, task, path) and the two +turns before the question; an answer anywhere resolves the item at once. diff --git a/internal/appwire/convo.go b/internal/appwire/convo.go new file mode 100644 index 0000000000..c4a81e71c2 --- /dev/null +++ b/internal/appwire/convo.go @@ -0,0 +1,486 @@ +package appwire + +import ( + "encoding/json" + "strings" + "time" +) + +// Wave 3b (PROTOCOL.md, V65–V70): the conversation's own entries and events, +// the decision dock's ask facts, steering and the queue, files and links in +// the composer, and a task as a conversation. + +// The events of this wave. +const ( + EventTurnThinking = "turn.thinking" + EventTurnThinkingDone = "turn.thinking_done" + EventTurnRetrying = "turn.retrying" + EventQueueChanged = "queue.changed" + EventNoteChanged = "note.changed" +) + +// A steer's states, and a note's. +const ( + SteerPending = "pending" + SteerConsumed = "consumed" + SteerFellThrough = "fell_through" + + NoteSending = "sending" + NoteWaiting = "waiting" + NoteRead = "read" +) + +// The kinds of footer. +const ( + FooterStoppedByYou = "stopped_by_you" + FooterRetrying = "retrying" + FooterProviderFailed = "provider_failed" + FooterFallback = "fallback" + FooterProcessFinished = "process_finished" + FooterProcessLimit = "process_limit" +) + +// CodeSent refuses an edit to a queued message that has already been sent. +const CodeSent = "sent" + +// AskSource is where an ask comes from: this reply, or one of its tasks. +type AskSource struct { + Kind string `json:"kind"` + Task string `json:"task,omitempty"` +} + +// Evidence is the first lines of what an ask shows, and how many more there are. +type Evidence struct { + Lines []string `json:"lines"` + More int `json:"more"` +} + +// AskChoice is one answer of a choice, with the asker's suggestion marked. +type AskChoice struct { + ID string `json:"id"` + Label string `json:"label"` + Detail string `json:"detail,omitempty"` + Suggested bool `json:"suggested,omitempty"` +} + +// BatchItem is one call of several asked about together. +type BatchItem struct { + ID string `json:"id"` + Text string `json:"text"` + State string `json:"state,omitempty"` +} + +// Attachment is a file, a link or a long paste that rides on a message. +type Attachment struct { + ID string `json:"id,omitempty"` + Kind string `json:"kind"` + Path string `json:"path,omitempty"` + URL string `json:"url,omitempty"` + Title string `json:"title,omitempty"` + Lines *int `json:"lines,omitempty"` + Add *int `json:"add,omitempty"` + Del *int `json:"del,omitempty"` + State string `json:"state,omitempty"` +} + +// The attachment kinds and a file's states. +const ( + AttachFile = "file" + AttachLink = "link" + AttachPaste = "paste" + + FileExists = "exists" + FileChanged = "changed" + FileMissing = "missing" + FileOutside = "outside" +) + +// Queued is one message waiting for the turn to end. +type Queued struct { + ID string `json:"id"` + Text string `json:"text"` +} + +// FileGroup is one group of files.find's answer. +type FileGroup struct { + Label string `json:"label"` + Files []FoundFile `json:"files"` +} + +// FoundFile is one file or folder a reference may name. +type FoundFile struct { + Path string `json:"path"` + Add *int `json:"add,omitempty"` + Del *int `json:"del,omitempty"` + Dir bool `json:"dir,omitempty"` +} + +// FileStat is one path as files.stat finds it. +type FileStat struct { + Path string `json:"path"` + State string `json:"state"` + Add *int `json:"add,omitempty"` + Del *int `json:"del,omitempty"` + Size *int64 `json:"size,omitempty"` + Binary bool `json:"binary,omitempty"` +} + +// AskAnswer is the whole of an asks.answer: the option, the words, a reason, +// which call of a batch, or putting it aside or handing it back. +type AskAnswer struct { + Option string + Text string + Why string + BatchItem string + Later bool + YouDecide bool +} + +// The data of this wave's events. +type ( + TurnThinking struct { + Agent string `json:"agent"` + Turn uint64 `json:"turn"` + Text string `json:"text"` + } + TurnThinkingDone struct { + Agent string `json:"agent"` + Turn uint64 `json:"turn"` + Seconds int `json:"seconds"` + } + TurnRetrying struct { + Agent string `json:"agent"` + Turn uint64 `json:"turn"` + InS *int `json:"in_s,omitempty"` + Attempt int `json:"attempt"` + } + QueueChanged struct { + Agent string `json:"agent"` + Queued []Queued `json:"queued"` + } + NoteChanged struct { + Task string `json:"task"` + ID string `json:"id"` + State string `json:"state"` + ReadAtStep *int `json:"read_at_step,omitempty"` + } +) + +// Talker is the engine's half of this wave. An [Engine] that is also a +// Talker serves these methods; one that is not refuses them as unknown. +type Talker interface { + Steer(agent, text, clientID string) error + Queue(agent, text, clientID string) ([]Queued, error) + EditQueued(agent, id, text string) ([]Queued, error) + RemoveQueued(agent, id string) ([]Queued, error) + MoveQueued(agent, id string, to int) ([]Queued, error) + AnswerAskWith(agent, id string, answer AskAnswer) error + HoldAsk(agent, id string) error + FindFiles(agent, query string, limit int) ([]FileGroup, error) + StatFiles(agent string, paths []string) ([]FileStat, error) + AddAttachment(agent string, attachment Attachment, text string) (Attachment, error) + SendWith(agent, text, clientID string, attachments []string) (uint64, error) + RetryTask(id string) error + NoteTask(id, text, clientID string) (Entry, error) + TaskEntries(id, before string, limit int) ([]Entry, bool, error) +} + +func init() { + for name, m := range talkMethods { + methods[name] = m + } +} + +// talker is the engine as a Talker, or a refusal. +func talker(e Engine, name string) (Talker, error) { + t, ok := e.(Talker) + if !ok { + return nil, Errorf(CodeUnknownMethod, "this engine does not serve %s", name) + } + return t, nil +} + +// queueResult is every queue method's answer. +type queueResult struct { + Queued []Queued `json:"queued"` +} + +// sayTo is agents.steer and agents.queue's params. +type sayTo struct { + ID string `json:"id"` + Text string `json:"text"` + ClientID string `json:"client_id"` +} + +func decodeSay(raw json.RawMessage, name string) (sayTo, error) { + var p sayTo + if err := decode(raw, &p); err != nil { + return p, err + } + if strings.TrimSpace(p.ID) == "" || strings.TrimSpace(p.Text) == "" { + return p, Errorf(CodeBadParams, "%s needs an id and some text", name) + } + return p, nil +} + +// queueAt is the queue methods' params. +type queueAt struct { + Agent string `json:"agent"` + ID string `json:"id"` + Text string `json:"text"` + To *int `json:"to"` +} + +func decodeQueueAt(raw json.RawMessage, name string) (queueAt, error) { + var p queueAt + if err := decode(raw, &p); err != nil { + return p, err + } + if strings.TrimSpace(p.Agent) == "" || strings.TrimSpace(p.ID) == "" { + return p, Errorf(CodeBadParams, "%s needs an agent and an id", name) + } + return p, nil +} + +// talkTask is one task verb that takes only an id, served by the Worker. +func talkTaskVerb(action string) method { + return func(e Engine, raw json.RawMessage) (any, error) { + w, err := worker(e, "tasks."+action) + if err != nil { + return nil, err + } + id, err := decodeID(raw, "id") + if err != nil { + return nil, err + } + return struct{}{}, w.ActOnTask(id, action, "", "") + } +} + +var talkMethods = map[string]method{ + "agents.steer": func(e Engine, raw json.RawMessage) (any, error) { + t, err := talker(e, "agents.steer") + if err != nil { + return nil, err + } + p, err := decodeSay(raw, "agents.steer") + if err != nil { + return nil, err + } + return struct{}{}, t.Steer(p.ID, p.Text, p.ClientID) + }, + "agents.queue": func(e Engine, raw json.RawMessage) (any, error) { + t, err := talker(e, "agents.queue") + if err != nil { + return nil, err + } + p, err := decodeSay(raw, "agents.queue") + if err != nil { + return nil, err + } + queued, err := t.Queue(p.ID, p.Text, p.ClientID) + return queueResult{nonNil(queued)}, err + }, + "queue.edit": func(e Engine, raw json.RawMessage) (any, error) { + t, err := talker(e, "queue.edit") + if err != nil { + return nil, err + } + p, err := decodeQueueAt(raw, "queue.edit") + if err != nil { + return nil, err + } + if strings.TrimSpace(p.Text) == "" { + return nil, Errorf(CodeBadParams, "queue.edit needs the new text; queue.remove takes a message out") + } + queued, err := t.EditQueued(p.Agent, p.ID, p.Text) + return queueResult{nonNil(queued)}, err + }, + "queue.remove": func(e Engine, raw json.RawMessage) (any, error) { + t, err := talker(e, "queue.remove") + if err != nil { + return nil, err + } + p, err := decodeQueueAt(raw, "queue.remove") + if err != nil { + return nil, err + } + queued, err := t.RemoveQueued(p.Agent, p.ID) + return queueResult{nonNil(queued)}, err + }, + "queue.move": func(e Engine, raw json.RawMessage) (any, error) { + t, err := talker(e, "queue.move") + if err != nil { + return nil, err + } + p, err := decodeQueueAt(raw, "queue.move") + if err != nil { + return nil, err + } + if p.To == nil || *p.To < 0 { + return nil, Errorf(CodeBadParams, "queue.move needs to, a place counted from zero") + } + queued, err := t.MoveQueued(p.Agent, p.ID, *p.To) + return queueResult{nonNil(queued)}, err + }, + "asks.hold": func(e Engine, raw json.RawMessage) (any, error) { + t, err := talker(e, "asks.hold") + if err != nil { + return nil, err + } + var p struct { + Agent string `json:"agent"` + ID string `json:"id"` + } + if err := decode(raw, &p); err != nil { + return nil, err + } + if strings.TrimSpace(p.Agent) == "" || strings.TrimSpace(p.ID) == "" { + return nil, Errorf(CodeBadParams, "asks.hold needs an agent and an id") + } + return struct{}{}, t.HoldAsk(p.Agent, p.ID) + }, + "files.find": func(e Engine, raw json.RawMessage) (any, error) { + t, err := talker(e, "files.find") + if err != nil { + return nil, err + } + var p struct { + Agent string `json:"agent"` + Query string `json:"query"` + Limit int `json:"limit"` + } + if err := decode(raw, &p); err != nil { + return nil, err + } + if strings.TrimSpace(p.Agent) == "" || p.Limit < 0 { + return nil, Errorf(CodeBadParams, "files.find needs an agent and a limit of zero or more") + } + groups, err := t.FindFiles(p.Agent, p.Query, p.Limit) + if err != nil { + return nil, err + } + for i := range groups { + groups[i].Files = nonNil(groups[i].Files) + } + return struct { + Groups []FileGroup `json:"groups"` + }{nonNil(groups)}, nil + }, + "files.stat": func(e Engine, raw json.RawMessage) (any, error) { + t, err := talker(e, "files.stat") + if err != nil { + return nil, err + } + var p struct { + Agent string `json:"agent"` + Paths []string `json:"paths"` + } + if err := decode(raw, &p); err != nil { + return nil, err + } + if strings.TrimSpace(p.Agent) == "" || len(p.Paths) == 0 { + return nil, Errorf(CodeBadParams, "files.stat needs an agent and some paths") + } + files, err := t.StatFiles(p.Agent, p.Paths) + return struct { + Files []FileStat `json:"files"` + }{nonNil(files)}, err + }, + "attachments.add": func(e Engine, raw json.RawMessage) (any, error) { + t, err := talker(e, "attachments.add") + if err != nil { + return nil, err + } + var p struct { + Agent string `json:"agent"` + Kind string `json:"kind"` + Path string `json:"path"` + Text string `json:"text"` + URL string `json:"url"` + } + if err := decode(raw, &p); err != nil { + return nil, err + } + switch { + case strings.TrimSpace(p.Agent) == "": + return nil, Errorf(CodeBadParams, "attachments.add needs an agent") + case p.Kind == AttachFile && strings.TrimSpace(p.Path) == "", + p.Kind == AttachLink && strings.TrimSpace(p.URL) == "", + p.Kind == AttachPaste && p.Text == "": + return nil, Errorf(CodeBadParams, "a file needs a path, a link a url, and a paste its text") + case p.Kind != AttachFile && p.Kind != AttachLink && p.Kind != AttachPaste: + return nil, Errorf(CodeBadParams, "kind is file, link or paste") + } + attachment, err := t.AddAttachment(p.Agent, Attachment{Kind: p.Kind, Path: p.Path, URL: p.URL}, p.Text) + if err != nil { + return nil, err + } + return struct { + Attachment Attachment `json:"attachment"` + }{attachment}, nil + }, + "tasks.pause": talkTaskVerb("pause"), + "tasks.resume": talkTaskVerb("resume"), + "tasks.retry": func(e Engine, raw json.RawMessage) (any, error) { + t, err := talker(e, "tasks.retry") + if err != nil { + return nil, err + } + id, err := decodeID(raw, "id") + if err != nil { + return nil, err + } + return struct{}{}, t.RetryTask(id) + }, + "tasks.brief": func(e Engine, raw json.RawMessage) (any, error) { + w, err := worker(e, "tasks.brief") + if err != nil { + return nil, err + } + var p struct { + ID string `json:"id"` + Text string `json:"text"` + } + if err := decode(raw, &p); err != nil { + return nil, err + } + if strings.TrimSpace(p.ID) == "" || strings.TrimSpace(p.Text) == "" { + return nil, Errorf(CodeBadParams, "tasks.brief needs an id and the new brief") + } + return struct{}{}, w.ActOnTask(p.ID, "brief", "", p.Text) + }, + "tasks.entries": func(e Engine, raw json.RawMessage) (any, error) { + t, err := talker(e, "tasks.entries") + if err != nil { + return nil, err + } + var p struct { + ID string `json:"id"` + Before string `json:"before"` + Limit int `json:"limit"` + } + if err := decode(raw, &p); err != nil { + return nil, err + } + if strings.TrimSpace(p.ID) == "" || p.Limit < 0 { + return nil, Errorf(CodeBadParams, "tasks.entries needs an id") + } + if p.Limit == 0 { + p.Limit = 200 + } + entries, more, err := t.TaskEntries(p.ID, p.Before, p.Limit) + if err != nil { + return nil, err + } + return struct { + Entries []Entry `json:"entries"` + More bool `json:"more"` + }{nonNil(entries), more}, nil + }, +} + +// ThinkingSeconds is how long a thought lasted, in whole seconds, at least one. +func ThinkingSeconds(d time.Duration) int { + return max(1, int(d.Round(time.Second)/time.Second)) +} diff --git a/internal/appwire/inbox.go b/internal/appwire/inbox.go new file mode 100644 index 0000000000..fef00171cf --- /dev/null +++ b/internal/appwire/inbox.go @@ -0,0 +1,195 @@ +package appwire + +import ( + "encoding/json" + "strings" + "time" +) + +// Wave 4: the Inbox (PROTOCOL.md; V04, V68 "The dock and the Inbox"). Every +// open ask from every agent is one item, answered from here or anywhere else. + +// The Inbox's events. +const ( + EventInboxAdded = "inbox.added" + EventInboxResolved = "inbox.resolved" + EventInboxCounts = "inbox.counts" +) + +// The Inbox's lists, and how an item was resolved. +const ( + InboxWaiting = "waiting" + InboxLater = "later" + InboxDone = "done" + + ResolvedNoLongerNeeded = "no_longer_needed" +) + +// InboxItem is one ask in the Inbox, with where it comes from and the two +// turns before it. +type InboxItem struct { + ID string `json:"id"` + Ask Ask `json:"ask"` + Where InboxWhere `json:"where"` + At time.Time `json:"at"` + List string `json:"list"` + Answered *InboxAnswered `json:"answered,omitempty"` + Context []Entry `json:"context"` +} + +// InboxWhere is where an item comes from: its agent, its crew and task when +// it has them, and the names to show for the way there. +type InboxWhere struct { + Agent string `json:"agent"` + Crew string `json:"crew,omitempty"` + Task string `json:"task,omitempty"` + Path []string `json:"path"` +} + +// InboxAnswered is how a done item was answered. +type InboxAnswered struct { + Option string `json:"option,omitempty"` + Text string `json:"text,omitempty"` + By string `json:"by"` + At time.Time `json:"at"` + Where string `json:"where,omitempty"` +} + +// InboxCounts are the Inbox's three counts. +type InboxCounts struct { + Waiting int `json:"waiting"` + Later int `json:"later"` + DoneToday int `json:"done_today"` +} + +// The data of the Inbox's events. +type ( + InboxAddedEvent struct { + Item InboxItem `json:"item"` + } + InboxResolvedEvent struct { + ID string `json:"id"` + By string `json:"by"` + Receipt string `json:"receipt"` + } +) + +// Inboxer is the engine's half of the Inbox. +type Inboxer interface { + ListInbox(list, query string) ([]InboxItem, InboxCounts, error) + // AnswerInbox holds an answer for a few seconds, which UndoInbox takes + // back, and answers when the hold ends. + AnswerInbox(id, option, text, why string) (time.Time, error) + UndoInbox(id string) error + LaterInbox(id string, later bool) error + OpenInbox(id string) (agent, task string, err error) +} + +func init() { + for name, m := range inboxMethods { + methods[name] = m + } +} + +func inboxer(e Engine, name string) (Inboxer, error) { + i, ok := e.(Inboxer) + if !ok { + return nil, Errorf(CodeUnknownMethod, "this engine does not serve %s", name) + } + return i, nil +} + +// inboxVerb is one Inbox verb that takes only an id. +func inboxVerb(name string, act func(Inboxer, string) error) method { + return func(e Engine, raw json.RawMessage) (any, error) { + i, err := inboxer(e, name) + if err != nil { + return nil, err + } + id, err := decodeID(raw, "id") + if err != nil { + return nil, err + } + return struct{}{}, act(i, id) + } +} + +var inboxMethods = map[string]method{ + "inbox.list": func(e Engine, raw json.RawMessage) (any, error) { + i, err := inboxer(e, "inbox.list") + if err != nil { + return nil, err + } + var p struct { + List string `json:"list"` + Query string `json:"query"` + } + if err := decode(raw, &p); err != nil { + return nil, err + } + if p.List == "" { + p.List = InboxWaiting + } + if p.List != InboxWaiting && p.List != InboxLater && p.List != InboxDone { + return nil, Errorf(CodeBadParams, "list is waiting, later or done") + } + items, counts, err := i.ListInbox(p.List, strings.TrimSpace(p.Query)) + if err != nil { + return nil, err + } + for k := range items { + items[k].Context = nonNil(items[k].Context) + items[k].Where.Path = nonNil(items[k].Where.Path) + } + return struct { + Items []InboxItem `json:"items"` + Counts InboxCounts `json:"counts"` + }{nonNil(items), counts}, nil + }, + "inbox.answer": func(e Engine, raw json.RawMessage) (any, error) { + i, err := inboxer(e, "inbox.answer") + if err != nil { + return nil, err + } + var p struct { + ID string `json:"id"` + Option string `json:"option"` + Text string `json:"text"` + Why string `json:"why"` + } + if err := decode(raw, &p); err != nil { + return nil, err + } + if strings.TrimSpace(p.ID) == "" || (strings.TrimSpace(p.Option) == "" && strings.TrimSpace(p.Text) == "") { + return nil, Errorf(CodeBadParams, "inbox.answer needs an id and an option, some text, or both") + } + until, err := i.AnswerInbox(p.ID, strings.TrimSpace(p.Option), p.Text, p.Why) + if err != nil { + return nil, err + } + return struct { + UndoUntil time.Time `json:"undo_until"` + }{until}, nil + }, + "inbox.undo": inboxVerb("inbox.undo", func(i Inboxer, id string) error { return i.UndoInbox(id) }), + "inbox.later": inboxVerb("inbox.later", func(i Inboxer, id string) error { return i.LaterInbox(id, true) }), + "inbox.back": inboxVerb("inbox.back", func(i Inboxer, id string) error { return i.LaterInbox(id, false) }), + "inbox.open": func(e Engine, raw json.RawMessage) (any, error) { + i, err := inboxer(e, "inbox.open") + if err != nil { + return nil, err + } + id, err := decodeID(raw, "id") + if err != nil { + return nil, err + } + agent, task, err := i.OpenInbox(id) + if err != nil { + return nil, err + } + return struct { + Agent string `json:"agent"` + Task string `json:"task,omitempty"` + }{agent, task}, nil + }, +} diff --git a/internal/appwire/outbox.go b/internal/appwire/outbox.go index a093c87227..32f273e098 100644 --- a/internal/appwire/outbox.go +++ b/internal/appwire/outbox.go @@ -141,9 +141,14 @@ func (o *Outbox) run() { o.queue = nil } o.mu.Unlock() - if head.delta != nil { + switch { + case head.delta != nil && head.delta.Kind == DeltaThinking: + // A thought folds like text does and is said as turn.thinking: + // shown while it happens, never kept. + o.emit.Emit(EventTurnThinking, TurnThinking{Agent: head.delta.Agent, Turn: head.delta.Turn, Text: head.delta.Text}) + case head.delta != nil: o.emit.Emit(EventTurnDelta, *head.delta) - } else { + default: o.emit.Emit(head.event, head.data) } } diff --git a/internal/appwire/server.go b/internal/appwire/server.go index fe100aad1d..ad4cdfa32d 100644 --- a/internal/appwire/server.go +++ b/internal/appwire/server.go @@ -414,9 +414,10 @@ var methods = map[string]method{ }, "agents.send": func(e Engine, raw json.RawMessage) (any, error) { var p struct { - ID string `json:"id"` - Text string `json:"text"` - ClientID string `json:"client_id"` + ID string `json:"id"` + Text string `json:"text"` + ClientID string `json:"client_id"` + Attachments []string `json:"attachments"` } if err := decode(raw, &p); err != nil { return nil, err @@ -427,7 +428,13 @@ var methods = map[string]method{ if strings.TrimSpace(p.Text) == "" { return nil, Errorf(CodeBadParams, "agents.send needs some text") } - turn, err := e.Send(p.ID, p.Text, p.ClientID) + var turn uint64 + var err error + if t, ok := e.(Talker); ok && len(p.Attachments) > 0 { + turn, err = t.SendWith(p.ID, p.Text, p.ClientID, p.Attachments) + } else { + turn, err = e.Send(p.ID, p.Text, p.ClientID) + } if err != nil { return nil, err } @@ -464,10 +471,14 @@ var methods = map[string]method{ }, "asks.answer": func(e Engine, raw json.RawMessage) (any, error) { var p struct { - Agent string `json:"agent"` - ID string `json:"id"` - Option string `json:"option"` - Text string `json:"text"` + Agent string `json:"agent"` + ID string `json:"id"` + Option string `json:"option"` + Text string `json:"text"` + Why string `json:"why"` + BatchItem string `json:"batch_item"` + Later bool `json:"later"` + YouDecide bool `json:"you_decide"` } if err := decode(raw, &p); err != nil { return nil, err @@ -475,8 +486,16 @@ var methods = map[string]method{ if strings.TrimSpace(p.Agent) == "" || strings.TrimSpace(p.ID) == "" { return nil, Errorf(CodeBadParams, "asks.answer needs an agent and an id") } - if strings.TrimSpace(p.Option) == "" && strings.TrimSpace(p.Text) == "" { - return nil, Errorf(CodeBadParams, "asks.answer needs an option, some text, or both") + if strings.TrimSpace(p.Option) == "" && strings.TrimSpace(p.Text) == "" && !p.Later && !p.YouDecide { + return nil, Errorf(CodeBadParams, "asks.answer needs an option, some text, later or you_decide") + } + if t, ok := e.(Talker); ok { + return struct{}{}, t.AnswerAskWith(p.Agent, p.ID, AskAnswer{ + Option: strings.TrimSpace(p.Option), Text: p.Text, Why: p.Why, BatchItem: p.BatchItem, Later: p.Later, YouDecide: p.YouDecide, + }) + } + if p.Later || p.YouDecide || p.BatchItem != "" || p.Why != "" { + return nil, Errorf(CodeBadParams, "this engine answers with an option and words only") } return struct{}{}, e.AnswerAsk(p.Agent, p.ID, strings.TrimSpace(p.Option), p.Text) }, diff --git a/internal/appwire/server_test.go b/internal/appwire/server_test.go index b72ee4e5fc..0f8d26e78b 100644 --- a/internal/appwire/server_test.go +++ b/internal/appwire/server_test.go @@ -375,15 +375,15 @@ func TestOutboxFoldsDeltasAndKeepsOrderWithToolsAndTheEnd(t *testing.T) { box.Delta("a", 1, 0, DeltaText, "after") box.Event(EventTurnDone, TurnDone{Agent: "a", Turn: 1, OK: true}) box.Close() - want := []string{EventTurnStarted, EventTurnDelta, EventTurnDelta, EventTurnTool, EventTurnDelta, EventTurnDone} + want := []string{EventTurnStarted, EventTurnDelta, EventTurnThinking, EventTurnTool, EventTurnDelta, EventTurnDone} if strings.Join(rec.events, ",") != strings.Join(want, ",") { t.Fatalf("events = %v", rec.events) } if got := rec.data[1].(TurnDelta); got.Text != "hello there" || got.Kind != DeltaText { t.Fatalf("folded delta = %+v", got) } - if got := rec.data[2].(TurnDelta); got.Text != "hmm" || got.Kind != DeltaThinking { - t.Fatalf("thinking delta = %+v", got) + if got := rec.data[2].(TurnThinking); got.Text != "hmm" || got.Turn != 1 { + t.Fatalf("thinking = %+v", got) } if got := rec.data[4].(TurnDelta); got.Text != "after" { t.Fatalf("delta after the tool = %+v", got) diff --git a/internal/appwire/wire.go b/internal/appwire/wire.go index 067032b84c..4c107b9559 100644 --- a/internal/appwire/wire.go +++ b/internal/appwire/wire.go @@ -142,6 +142,21 @@ type Task struct { // a running task: the phase it is in, the gap it is closing, or what it // is waiting on. It is empty when there is nothing to say. Line string `json:"line,omitempty"` + // The task as its own conversation (V65, V66): whether the person paused + // it, what it waits on and what waits on it, its model, its steps, cost and + // clock, who started it, its instructions, and its own tasks. + Paused bool `json:"paused,omitempty"` + WaitsOn []string `json:"waits_on,omitempty"` + HoldingUp []string `json:"holding_up,omitempty"` + Model string `json:"model,omitempty"` + Steps *int `json:"steps,omitempty"` + CostUSD *float64 `json:"cost_usd,omitempty"` + StartedAt *time.Time `json:"started_at,omitempty"` + StartedBy string `json:"started_by,omitempty"` + Instructions string `json:"instructions,omitempty"` + Children *int `json:"children,omitempty"` + ChildrenDone *int `json:"children_done,omitempty"` + FinishedAt *time.Time `json:"finished_at,omitempty"` } // Crew is one codeaf team, with its members named by agent id. @@ -176,6 +191,19 @@ type Ask struct { // allow_once, deny and, unless the call is irreversible, always. Options []AskOption `json:"options,omitempty"` Irreversible bool `json:"irreversible,omitempty"` + // The decision dock's facts (V68): where the ask comes from, why now and + // the evidence, the choices with the asker's suggestion, the clock, the + // calls asked about together, the tasks it holds up, and whether a reason + // may go with the answer. Later says the person put it aside. + Source *AskSource `json:"source,omitempty"` + Reason string `json:"reason,omitempty"` + Evidence *Evidence `json:"evidence,omitempty"` + Choices []AskChoice `json:"choices,omitempty"` + ClockUntil *time.Time `json:"clock_until,omitempty"` + Batch []BatchItem `json:"batch,omitempty"` + HoldingUp []string `json:"holding_up,omitempty"` + SayWhy bool `json:"say_why,omitempty"` + Later bool `json:"later,omitempty"` } // The subkinds of an approval, and the option ids a permission offers. @@ -228,7 +256,20 @@ type Entry struct { Quiet bool `json:"quiet,omitempty"` ErrorKind string `json:"error_kind,omitempty"` Status int `json:"status,omitempty"` - // A tool entry carries the facts of its call (worklog.go). + // A steer's and a note's state, the step a steer landed at and the step a + // note was read at; a note's direction; a footer's kind and figures; the + // files and links a message of the person's carried (convo.go). + State string `json:"state,omitempty"` + Step *int `json:"step,omitempty"` + From string `json:"from,omitempty"` + ReadAtStep *int `json:"read_at_step,omitempty"` + Footer string `json:"footer_kind,omitempty"` + RetryInS *int `json:"retry_in_s,omitempty"` + ModelFrom string `json:"model_from,omitempty"` + ModelTo string `json:"model_to,omitempty"` + Attachments []Attachment `json:"attachments,omitempty"` + // A tool entry carries the facts of its call (worklog.go); a note entry + // names its task there too. ToolFacts } @@ -240,6 +281,13 @@ const ( EntryReceipt = "receipt" EntryNotice = "notice" EntryError = "error" + // Wave 3b: an interim message mid-turn, a steer, a note to or from a + // task, an earlier stretch summarized, and a turn's footer. + EntryUpdate = "update" + EntrySteer = "steer" + EntryNote = "note" + EntrySummary = "summary" + EntryFooter = "footer" ) // The kinds a turn's failure is sorted into. diff --git a/internal/appwire/work.go b/internal/appwire/work.go index c96f596bdc..9966f720b0 100644 --- a/internal/appwire/work.go +++ b/internal/appwire/work.go @@ -211,8 +211,9 @@ var workMethods = map[string]method{ return nil, err } var p struct { - ID string `json:"id"` - Text string `json:"text"` + ID string `json:"id"` + Text string `json:"text"` + ClientID string `json:"client_id"` } if err := decode(raw, &p); err != nil { return nil, err @@ -220,6 +221,16 @@ var workMethods = map[string]method{ if strings.TrimSpace(p.ID) == "" || strings.TrimSpace(p.Text) == "" { return nil, Errorf(CodeBadParams, "tasks.note needs an id and some text") } + // An engine that keeps notes as entries answers the note's entry. + if t, ok := e.(Talker); ok { + entry, err := t.NoteTask(p.ID, p.Text, p.ClientID) + if err != nil { + return nil, err + } + return struct { + Entry Entry `json:"entry"` + }{entry}, nil + } return struct{}{}, w.ActOnTask(p.ID, "note", "", p.Text) }, "tasks.decide": func(e Engine, raw json.RawMessage) (any, error) { diff --git a/internal/appwire/worklog.go b/internal/appwire/worklog.go index 358edff2db..d9ec85aa01 100644 --- a/internal/appwire/worklog.go +++ b/internal/appwire/worklog.go @@ -28,7 +28,7 @@ const EventToolOutput = "tool.output" // ToolFacts are one call's facts, on turn.tool and on its tool entry. type ToolFacts struct { - Kind string `json:"kind,omitempty"` + Kind string `json:"tool_kind,omitempty"` StartedAt *time.Time `json:"started_at,omitempty"` EndedAt *time.Time `json:"ended_at,omitempty"` // Batch is the same on every call the agent started together. diff --git a/internal/manual/chat/running-from-the-terminal.md b/internal/manual/chat/running-from-the-terminal.md index 483ccc441c..70dc2b59df 100644 --- a/internal/manual/chat/running-from-the-terminal.md +++ b/internal/manual/chat/running-from-the-terminal.md @@ -349,6 +349,32 @@ and twelve once it ends. A command's whole output is kept on this Mac for a week under the app's folder in codeaf's home, so it can be opened after the reply ends, or after the app restarts. +## The desktop app's conversation — steer or queue, the decision dock, and notes to a task + +In the desktop app a message typed while the agent works can steer or queue. +A steer reaches the agent after its current step, like a correction typed in +the terminal; if the reply ends first, the words are sent as the next message. +Queued messages wait in order until the reply ends; they can be edited, +reordered or taken out until then. Thinking shows while it happens and is not +kept. Words the agent says before a step show as an update. + +The dock above the message box shows one question at a time: why it is asked, +what it shows, the agent's suggested answer, the tasks waiting on it, and a +clock only when the question has one. Later puts it aside; You decide takes +the agent's suggestion. Commands asked about together are answered together +or one by one. A note to a task says when a step of the task read it, and a +task can be paused, resumed or tried again from its page. + +## The desktop app's Inbox — every open question in one list, Undo, and Later + +The app's Inbox lists every question any agent the app holds is waiting on, +each with the two turns before it and where it comes from. An answer given +there waits five seconds before it is sent, and Undo takes it back. Later puts +a question aside without answering it. A question answered anywhere else, in +the conversation, on a task's page or in the terminal, leaves the Inbox at +once with its receipt. An agent with a question open is kept open until the +question is answered. + ## Connect from the terminal without opening the chat — codeaf connect and codeaf disconnect `codeaf connect` lists every model provider this profile knows, whether it is connected, diff --git a/internal/session/jobs.go b/internal/session/jobs.go index 824f00c545..cdd0438e80 100644 --- a/internal/session/jobs.go +++ b/internal/session/jobs.go @@ -843,7 +843,32 @@ func (r *jobRegistry) settled(one *job, code int) bool { // whole purpose is to outlive the turn would be killed by the very act of // answering the person. The job's lifetime is the session's, and Close is the // only thing that ends it early. +// jobProcessCap is how many background processes one agent may have running +// at once; the next is refused with [errJobProcessCap]'s sentence. +const jobProcessCap = 16 + +var errJobProcessCap = fmt.Errorf("%d processes are running for this agent.", jobProcessCap) + +// runningProcesses counts the background processes still running. +func (r *jobRegistry) runningProcesses() int { + running := 0 + for _, one := range r.all() { + if one.kind != jobKindBash { + continue + } + select { + case <-one.done: + default: + running++ + } + } + return running +} + func (r *jobRegistry) start(command string) (*job, error) { + if r.runningProcesses() >= jobProcessCap { + return nil, errJobProcessCap + } started, err := r.newJob(command, jobKindBash) if err != nil { return nil, err @@ -1066,6 +1091,9 @@ type adoption struct { // WHAT THE CALLER KNOWS AND THIS DOES NOT is [adoption], the two facts about the // road the takeover came down. func (r *jobRegistry) adopt(taken *bare.BashCall, how adoption) (*job, error) { + if r.runningProcesses() >= jobProcessCap { + return nil, errJobProcessCap + } started, err := r.newJob(taken.Command(), jobKindBash) if err != nil { return nil, err From 47de01cc6e5b382d0bb3cd6a8e6c868782094ab1 Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Thu, 15 Oct 2026 10:09:55 -0400 Subject: [PATCH 010/459] codeaf app: a note reaches a handed-over task, and an Inbox item answers only its own question A task handed over from inside the work runs as a crew the engine's task list does not name, so tasks.note now sends through the task's own room when the list refuses it. An Inbox answer to one ask of a batch no longer answers its siblings. The trace's retry scenario uses a 503 stub, since a refused dial is read as no connection and never retried. --- cmd/codeaf/appconvo.go | 39 ++++++++++++++++++++++++++++++++++++++- cmd/codeaf/appinbox.go | 4 +++- 2 files changed, 41 insertions(+), 2 deletions(-) diff --git a/cmd/codeaf/appconvo.go b/cmd/codeaf/appconvo.go index 319b91ce33..c051b7cf03 100644 --- a/cmd/codeaf/appconvo.go +++ b/cmd/codeaf/appconvo.go @@ -854,11 +854,22 @@ func (b *appBackend) NoteTask(id, text, clientID string) (appwire.Entry, error) entry.ToolFacts.Task = id }) live.box.Event(appwire.EventEntryAdded, appwire.EntryAdded{Entry: entry}) + handed := false if err := b.ActOnTask(id, "note", "", text); err != nil { - return appwire.Entry{}, err + // A TASK HANDED OVER FROM INSIDE THE WORK runs as a crew of its own and + // is not on the engine's task list, so its note goes through the door the + // task's own room speaks through. Nothing there says at which step the + // note was read, so it stays waiting. + if err := b.steerHandedTask(live, local, text, err); err != nil { + return appwire.Entry{}, err + } + handed = true } entry.State = appwire.NoteWaiting live.box.Event(appwire.EventNoteChanged, appwire.NoteChanged{Task: id, ID: entry.ID, State: appwire.NoteWaiting}) + if handed { + return entry, nil + } start := false b.locked(func() { if live.notes == nil { @@ -873,6 +884,32 @@ func (b *appBackend) NoteTask(id, text, clientID string) (appwire.Entry, error) return entry, nil } +// appTaskSteerer is the door a task's own room sends words through. +type appTaskSteerer interface { + SteerTask(id uint64, text string) (session.SteerReceipt, error) +} + +// steerHandedTask sends a note to a task the engine's task list does not name, +// and answers the original refusal when that door cannot take it either. +func (b *appBackend) steerHandedTask(live *appLive, local, text string, refused error) error { + var wire *appwire.Error + if !errors.As(refused, &wire) || wire.Code != appwire.CodeNotFound { + return refused + } + n, err := strconv.ParseUint(local, 10, 64) + if err != nil { + return refused + } + steerer, ok := live.conn.Conv.(appTaskSteerer) + if !ok { + return refused + } + if _, err := steerer.SteerTask(n, text); err != nil { + return appTaskError(err) + } + return nil +} + // watchNotes looks at a task's notes until every note sent to it is read. func (b *appBackend) watchNotes(live *appLive, id, local string) { tick := time.NewTicker(appNoteBeat) diff --git a/cmd/codeaf/appinbox.go b/cmd/codeaf/appinbox.go index 10d0631024..ef8b3c638e 100644 --- a/cmd/codeaf/appinbox.go +++ b/cmd/codeaf/appinbox.go @@ -361,7 +361,9 @@ func (b *appBackend) AnswerInbox(id, option, text, why string) (time.Time, error if !send { return } - if err := b.AnswerAskWith(agent, ask, appwire.AskAnswer{Option: option, Text: text, Why: why}); err != nil { + // ONE INBOX ITEM IS ONE QUESTION: an ask that came in a batch is + // answered alone here, and its siblings stay in the Inbox. + if err := b.AnswerAskWith(agent, ask, appwire.AskAnswer{Option: option, Text: text, Why: why, BatchItem: ask}); err != nil { in.locked(func() { delete(in.held, id) delete(in.from, id) From d8736c11a785badc47b6d0428e29f1de2c5a1b35 Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Thu, 15 Oct 2026 10:15:57 -0400 Subject: [PATCH 011/459] codeaf app: every wait before a step is asked again is said with its countdown The app reads the engine's phase news, the same the terminal's status line draws from, and says turn.retrying with in_s and the turn's attempt count for a provider refusal, a rate limit (reason paced) and a lost network (reason offline). The loop's own retry ladder now puts its wait's end on its phase, as the dispatcher's already did. --- cmd/codeaf/app.go | 45 ++++++++++--- cmd/codeaf/appconvo.go | 1 + cmd/codeaf/appretry.go | 115 ++++++++++++++++++++++++++++++++++ cmd/codeaf/appretry_test.go | 68 ++++++++++++++++++++ internal/appwire/convo.go | 3 + internal/session/loop.go | 5 +- internal/session/phasenews.go | 29 ++++++--- 7 files changed, 246 insertions(+), 20 deletions(-) create mode 100644 cmd/codeaf/appretry.go create mode 100644 cmd/codeaf/appretry_test.go diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index 6ab7b636f8..aa62b92102 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -51,6 +51,7 @@ import ( "github.com/Agent-Field/codeaf/internal/home" "github.com/Agent-Field/codeaf/internal/modelsource" "github.com/Agent-Field/codeaf/internal/openrouterauth" + "github.com/Agent-Field/codeaf/internal/provider" "github.com/Agent-Field/codeaf/internal/redact" "github.com/Agent-Field/codeaf/internal/remote" "github.com/Agent-Field/codeaf/internal/session" @@ -236,11 +237,13 @@ func appConnOf(client *remote.Client, release func(), assembled bool) *appConn { // appBackend is the [appwire.Engine] over this process's conversations. type appBackend struct { - emit appwire.Emitter - open appOpener - now func() time.Time - turns atomic.Uint64 - assembled atomic.Bool + // stopPhases puts the engine's phase news back the way it was. + stopPhases func() + emit appwire.Emitter + open appOpener + now func() time.Time + turns atomic.Uint64 + assembled atomic.Bool mu sync.Mutex agents map[string]*appAgent @@ -396,7 +399,14 @@ type appLive struct { batchDone map[string][]appwire.BatchItem notes map[string][]appNotePending noteN int - closed bool + // retries counts the running turn's waits before a step is asked again, + // retryPhase and retryUntil are the last one said, and retrySaid says the + // next void attempt was already said with its countdown. + retries int + retryPhase provider.Phase + retryUntil time.Time + retrySaid bool + closed bool } // appConsentCall is the call one approval is about. @@ -440,6 +450,7 @@ func newAppBackend(emit appwire.Emitter, open appOpener) *appBackend { mint: appMint, } b.emit = b.newAppInbox(emit) + b.stopPhases = b.listenPhases() guard.Go("app/sweep", b.sweep) return b } @@ -1250,6 +1261,7 @@ func (b *appBackend) startLocked(a *appAgent, text, clientID string) (uint64, er live.lastUse = b.now() turn = b.turns.Add(1) live.turn, live.steps, live.steers = turn, 0, 0 + live.retries, live.retrySaid, live.retryPhase = 0, false, "" n = a.users if a.marks == nil { a.marks = map[int]appTurnMark{} @@ -1366,16 +1378,28 @@ func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, n // the page, and a move to another model is said once. reply.Reset() thoughtAt = time.Time{} - retry := appwire.TurnRetrying{Agent: id, Turn: turn, Attempt: 1} + // A wait already said with its countdown is not said twice; the + // attempt is the turn's own count of them either way. + announce := false + retry := appwire.TurnRetrying{Agent: id, Turn: turn} + b.locked(func() { + announce = !live.retrySaid + live.retrySaid = false + if announce { + live.retries++ + } + retry.Attempt = max(1, live.retries) + }) if news := event.Retry; news != nil { - retry.Attempt = max(1, news.Attempt) if strings.TrimSpace(news.Next) != "" { footer := appFooter("f"+strconv.Itoa(n)+".m"+strconv.Itoa(retry.Attempt), id, turn, appwire.FooterFallback, redact.Secrets(strings.TrimSpace(event.Text)), b.now()) footer.ModelFrom, footer.ModelTo = news.Model, news.Next live.box.Event(appwire.EventEntryAdded, appwire.EntryAdded{Entry: footer}) } } - live.box.Event(appwire.EventTurnRetrying, retry) + if announce { + live.box.Event(appwire.EventTurnRetrying, retry) + } case session.EventCompacted: entry(appwire.Entry{ID: "c" + strconv.Itoa(n) + "." + strconv.Itoa(summaries), Kind: appwire.EntrySummary, Text: "Earlier messages summarized"}) summaries++ @@ -2362,6 +2386,9 @@ func (b *appBackend) Close() error { } close(b.sweepStop) <-b.sweepDone + if b.stopPhases != nil { + b.stopPhases() + } if warmed != nil { <-warmed } diff --git a/cmd/codeaf/appconvo.go b/cmd/codeaf/appconvo.go index c051b7cf03..f260dac263 100644 --- a/cmd/codeaf/appconvo.go +++ b/cmd/codeaf/appconvo.go @@ -148,6 +148,7 @@ func (b *appBackend) adoptTurn(a *appAgent, live *appLive, text, clientID string live.running, live.stopping = true, false turn = b.turns.Add(1) live.turn, live.steps, live.steers = turn, 0, 0 + live.retries, live.retrySaid, live.retryPhase = 0, false, "" live.pumpDone = make(chan struct{}) if a.marks == nil { a.marks = map[int]appTurnMark{} diff --git a/cmd/codeaf/appretry.go b/cmd/codeaf/appretry.go new file mode 100644 index 0000000000..07762f906b --- /dev/null +++ b/cmd/codeaf/appretry.go @@ -0,0 +1,115 @@ +package main + +import ( + "math" + "sync/atomic" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/lane" + "github.com/Agent-Field/codeaf/internal/provider" + "github.com/Agent-Field/codeaf/internal/session" +) + +// ── a wait before the step is asked again ─────────────────────────────────── +// +// A provider that refuses a request (busy, overloaded, a rate limit) is asked +// again after a pause, and the engine says that pause as a phase with the +// moment the request goes out again on it. The app reads the same phases the +// terminal draws its status line from, so the countdown a person sees in the +// app is the wait the engine is really sitting out. +// +// A REFUSED CONNECTION IS NOT RETRIED, IN THE APP OR IN THE TERMINAL. The engine +// reads it as no network and waits for the network to come back for up to two +// minutes, then ends the reply with "connection is still unavailable"; that +// wait is said here as a retry with the reason offline. + +// The reasons a retry says beside its countdown. +const ( + appRetryPaced = "paced" + appRetryOffline = "offline" +) + +// appRetrySlack is how early before a wait's end news of a new wait of the +// same kind may arrive and still be a new one. +const appRetrySlack = 500 * time.Millisecond + +// appPhaseReader is what the app put in front of the engine's phase news, kept +// so closing puts the previous reader back. +type appPhaseReader struct { + previous atomic.Pointer[func(session.PhaseNews)] +} + +// listenPhases starts reading the engine's phase news, and answers the function +// that stops. +func (b *appBackend) listenPhases() func() { + reader := &appPhaseReader{} + previous := session.OnPhaseNews(func(news session.PhaseNews) { + if next := reader.previous.Load(); next != nil && *next != nil { + (*next)(news) + } + b.phaseNews(news) + }) + reader.previous.Store(&previous) + return func() { session.OnPhaseNews(previous) } +} + +// phaseNews says a conversation's wait before its reply is asked again. +func (b *appBackend) phaseNews(news session.PhaseNews) { + // Only the reply a person is reading is said: a task's own request and the + // errands beside a reply (its naming, its memory) have their own pages. + if news.Subject != "" || news.Role != lane.RoleTalk { + return + } + reason := "" + switch news.Phase { + case provider.PhaseRetrying: + case provider.PhasePaced: + reason = appRetryPaced + case provider.PhaseConnectionLost: + reason = appRetryOffline + default: + return + } + if news.Deadline.IsZero() { + return + } + var live *appLive + b.locked(func() { + if a := b.agents[news.Session]; a != nil { + live = a.live + } + }) + if live == nil { + return + } + // The engine says a held stage again every few seconds, and a stage that + // crossed from a host has its moments measured again on arrival; one wait + // is said once, so news of the same kind before the last wait's end is the + // same wait. + at := news.At + if at.IsZero() { + at = time.Now() + } + var retry appwire.TurnRetrying + said := false + b.locked(func() { + if !live.running || live.closed { + return + } + if live.retryPhase == news.Phase && at.Before(live.retryUntil.Add(-appRetrySlack)) { + return + } + live.retryPhase, live.retryUntil = news.Phase, news.Deadline + live.retries++ + live.retrySaid = true + retry = appwire.TurnRetrying{Agent: news.Session, Turn: live.turn, Attempt: live.retries, Reason: reason} + said = true + }) + if !said { + return + } + in := max(0, int(math.Ceil(news.Deadline.Sub(at).Seconds()))) + retry.InS = &in + live.box.Event(appwire.EventTurnRetrying, retry) +} diff --git a/cmd/codeaf/appretry_test.go b/cmd/codeaf/appretry_test.go new file mode 100644 index 0000000000..34db177b66 --- /dev/null +++ b/cmd/codeaf/appretry_test.go @@ -0,0 +1,68 @@ +package main + +import ( + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/lane" + "github.com/Agent-Field/codeaf/internal/provider" + "github.com/Agent-Field/codeaf/internal/session" +) + +// A WAIT BEFORE A STEP IS ASKED AGAIN IS SAID WITH ITS COUNTDOWN, once, from the +// engine's own phase: a provider's refusal, a rate limit and a lost network +// each count as one attempt, and a task's own wait or an errand's is not the +// reply's. +func TestAppRetryIsSaidWithItsCountdownOnce(t *testing.T) { + hold := make(chan struct{}) + defer close(hold) + conv := &fakeConversation{script: []session.Event{{Kind: session.EventToolBegin, CallID: "c1", Tool: "bash", Args: `{"command":"ls"}`}}, hold: hold} + backend, rec, agent := workBackend(t, conv) + if _, err := backend.Send(agent.ID, "list it", ""); err != nil { + t.Fatal(err) + } + rec.waitFor(t, 5*time.Second, func(event string, data any) bool { _, ok := data.(appwire.TurnTool); return ok }) + + now := time.Now() + busy := session.PhaseNews{Phase: provider.PhaseRetrying, Role: lane.RoleTalk, Session: agent.ID, Since: now, At: now, Deadline: now.Add(4 * time.Second)} + backend.phaseNews(busy) + busy.At = now.Add(time.Second) + backend.phaseNews(busy) + backend.phaseNews(session.PhaseNews{Phase: provider.PhaseRetrying, Role: lane.RoleTalk, Session: agent.ID, Subject: agent.ID + "#3", Since: now, At: now, Deadline: now.Add(time.Second)}) + backend.phaseNews(session.PhaseNews{Phase: provider.PhaseRetrying, Role: lane.RoleMemory, Session: agent.ID, Since: now, At: now, Deadline: now.Add(time.Second)}) + later := now.Add(5 * time.Second) + backend.phaseNews(session.PhaseNews{Phase: provider.PhasePaced, Role: lane.RoleTalk, Session: agent.ID, Since: later, At: later, Deadline: later.Add(8 * time.Second)}) + offline := now.Add(14 * time.Second) + backend.phaseNews(session.PhaseNews{Phase: provider.PhaseConnectionLost, Role: lane.RoleTalk, Session: agent.ID, Since: offline, At: offline, Deadline: offline.Add(2 * time.Minute)}) + + var said []appwire.TurnRetrying + rec.waitFor(t, 5*time.Second, func(_ string, data any) bool { + if retry, ok := data.(appwire.TurnRetrying); ok { + said = append(said, retry) + } + return len(said) == 3 + }) + want := []struct { + in int + attempt int + reason string + }{{4, 1, ""}, {8, 2, appRetryPaced}, {120, 3, appRetryOffline}} + for i, w := range want { + got := said[i] + if got.InS == nil || *got.InS != w.in || got.Attempt != w.attempt || got.Reason != w.reason || got.Agent != agent.ID { + t.Fatalf("retry %d = %+v, want %+v", i, got, w) + } + } + rec.mu.Lock() + defer rec.mu.Unlock() + count := 0 + for _, data := range rec.data { + if _, ok := data.(appwire.TurnRetrying); ok { + count++ + } + } + if count != 3 { + t.Fatalf("%d retries were said, want 3", count) + } +} diff --git a/internal/appwire/convo.go b/internal/appwire/convo.go index c4a81e71c2..da4a05b68d 100644 --- a/internal/appwire/convo.go +++ b/internal/appwire/convo.go @@ -153,6 +153,9 @@ type ( Turn uint64 `json:"turn"` InS *int `json:"in_s,omitempty"` Attempt int `json:"attempt"` + // Reason is empty for a failed request being asked again, paced for a + // rate limit, and offline while the network is being waited for. + Reason string `json:"reason,omitempty"` } QueueChanged struct { Agent string `json:"agent"` diff --git a/internal/session/loop.go b/internal/session/loop.go index 7ea3f488ce..84b05c7a1d 100644 --- a/internal/session/loop.go +++ b/internal/session/loop.go @@ -2463,7 +2463,10 @@ func (a *Agent) completeWithRetryReasoning(ctx context.Context, hub *eventHub, m if verdict.Unbounded && cuts > 0 { detail = waitingOnOneMachine(cuts, turnNow().Sub(waitingSince)) } - a.tellPhase(provider.PhaseRetrying, detail, time.Now()) + // AND IT SAYS WHEN, so a surface can count down to the moment the + // step is asked again rather than draw a wait with no end. + began := time.Now() + a.tellPhaseUntil(provider.PhaseRetrying, detail, "", began, began.Add(wait)) waitBegan := turnNow() waitErr := turnBackoff(ctx, wait) if took := turnNow().Sub(waitBegan); took < wait { diff --git a/internal/session/phasenews.go b/internal/session/phasenews.go index b7094580b0..21a57cb3c5 100644 --- a/internal/session/phasenews.go +++ b/internal/session/phasenews.go @@ -396,6 +396,14 @@ func (a *Agent) tellPhase(phase provider.Phase, detail string, since time.Time) // `switching model` without naming the model would be telling somebody their // answer is changing hands and refusing to say to whom. func (a *Agent) tellPhaseThen(phase provider.Phase, detail, then string, since time.Time) { + a.tellPhaseUntil(phase, detail, then, since, time.Time{}) +} + +// tellPhaseUntil is [Agent.tellPhaseThen] for a stage with a real moment at its +// end: the retry ladder's wait, whose deadline is when the step is asked again. +// A surface counts down to it, which is why it is only ever a moment that will +// really happen. +func (a *Agent) tellPhaseUntil(phase provider.Phase, detail, then string, since, deadline time.Time) { if a == nil { return } @@ -418,15 +426,16 @@ func (a *Agent) tellPhaseThen(phase provider.Phase, detail, then string, since t // model id overwrote each other's stage and a node's own room could never be // asked what its own node was doing. news := PhaseNews{ - Phase: phase, - Since: since, - Detail: detail, - Then: strings.TrimSpace(then), - Model: model, - Role: a.laneRole(), - Session: a.newsKey(), - Subject: a.newsSubject(), - At: now, + Phase: phase, + Since: since, + Detail: detail, + Then: strings.TrimSpace(then), + Deadline: deadline, + Model: model, + Role: a.laneRole(), + Session: a.newsKey(), + Subject: a.newsSubject(), + At: now, } a.phase.mu.Lock() defer a.phase.mu.Unlock() @@ -464,7 +473,7 @@ func (a *Agent) interruptPhase(phase provider.Phase, detail string, since time.T } // THE OLD STAGE KEEPS ITS OWN START, so its clock counts the whole stage // rather than restarting at the moment the gate let go of the line. - a.tellPhaseThen(held.Phase, held.Detail, held.Then, held.Since) + a.tellPhaseUntil(held.Phase, held.Detail, held.Then, held.Since, held.Deadline) } } From bbbf87da19e440e6a56392b4788bc063b16a037c Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Thu, 15 Oct 2026 11:14:53 -0400 Subject: [PATCH 012/459] app v3 wave 5: crews are codeaf teams, with the app layer beside them crews.*, members.* and roles.* read and write the teams store; the app performs a held manager's start and stop lines, holds open crews' members, and turns packets waiting on the person into asks with the path they climbed. Goals, items, roles, pauses and proposals live in app/crews.json. Turns a conversation starts on its own, and turns another window sends, are adopted from the connection and drawn as turns. A crew message to a member mid-turn is queued, and a closed crew is said as closed. --- cmd/codeaf/app.go | 173 +- cmd/codeaf/app_test.go | 22 +- cmd/codeaf/appcrewlayer.go | 194 ++ cmd/codeaf/appcrewmembers.go | 671 +++++++ cmd/codeaf/appcrews.go | 1560 +++++++++++++++++ cmd/codeaf/appcrews_test.go | 205 +++ cmd/codeaf/appinbox.go | 17 +- cmd/codeaf/appwakes.go | 179 ++ docs/changes/unreleased/0-app-wave5-crews.md | 18 + internal/appwire/crews.go | 705 ++++++++ internal/appwire/server.go | 16 +- internal/appwire/wire.go | 28 +- .../manual/chat/running-from-the-terminal.md | 20 + 13 files changed, 3657 insertions(+), 151 deletions(-) create mode 100644 cmd/codeaf/appcrewlayer.go create mode 100644 cmd/codeaf/appcrewmembers.go create mode 100644 cmd/codeaf/appcrews.go create mode 100644 cmd/codeaf/appcrews_test.go create mode 100644 cmd/codeaf/appwakes.go create mode 100644 docs/changes/unreleased/0-app-wave5-crews.md create mode 100644 internal/appwire/crews.go diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index aa62b92102..6bd273575f 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -112,6 +112,9 @@ func runApp(args []string) error { if *turnLong > 0 { backend.turnLong = *turnLong } + // The crews are watched from the start: a manager's start or a member + // woken by its manager must not wait for the window to ask for crews. + guard.Go("app/crews-start", func() { backend.crews() }) err := server.Serve(ctx, os.Stdin, appwire.Hello{Version: resolvedVersion(), PID: os.Getpid(), Home: home.Dir()}, backend) if backend.assembled.Load() { // The process half every conversation shared, closed the way the pipe @@ -184,6 +187,12 @@ type appConn struct { Workspace string Transcript string Release func() + // Follow is the turns this connection did not start — the ones the + // conversation starts on its own and the ones another window sends — + // when the conversation is reached across a connection. Nil is a + // conversation held in this process, which hands its own turns over + // directly (appwakes.go). + Follow func() <-chan remote.Following // Assembled says this process built the conversation itself, so the shared // engine half must be closed when the app leaves. Assembled bool @@ -232,13 +241,20 @@ func appOpenInProcess(ask appOpenAsk) (*appConn, error) { func appConnOf(client *remote.Client, release func(), assembled bool) *appConn { welcome := client.Welcome() - return &appConn{Conv: client.Agent(), Workspace: welcome.Workspace, Transcript: welcome.SessionFile, Release: release, Assembled: assembled} + return &appConn{Conv: client.Agent(), Workspace: welcome.Workspace, Transcript: welcome.SessionFile, Release: release, Follow: client.Follow, Assembled: assembled} } // appBackend is the [appwire.Engine] over this process's conversations. type appBackend struct { // stopPhases puts the engine's phase news back the way it was. stopPhases func() + // profileDir is the profile whose teams are the crews, and the process's + // own when empty; layer is the crews' app layer and crewRun the watch over + // them, started by the first crew call. + profileDir string + crewOnce sync.Once + layer *appCrewLayer + crewRun *appCrewRun emit appwire.Emitter open appOpener now func() time.Time @@ -251,7 +267,6 @@ type appBackend struct { sweepStop chan struct{} sweepDone chan struct{} - crews *appCrewWatch // spent is the last figure spend.changed said, so it is said on a change. spent float64 // mint makes a new conversation's folder and answers its transcript path: @@ -399,6 +414,12 @@ type appLive struct { batchDone map[string][]appwire.BatchItem notes map[string][]appNotePending noteN int + // msgBase and noteBase are where the next turn's answer and notice counts + // start, and lastN, lastMsgs and lastNotices are where the last turn left + // them under the person's message it answered. + msgBase, noteBase int + lastN, lastMsgs, lastNotices int + stopWakes func() // retries counts the running turn's waits before a step is asked again, // retryPhase and retryUntil are the last one said, and retrySaid says the // next void attempt was already said with its countdown. @@ -640,6 +661,9 @@ func (b *appBackend) ListAgents(workspace string) ([]appwire.Agent, error) { } }) sort.SliceStable(agents, func(i, j int) bool { return agents[i].UpdatedAt.After(agents[j].UpdatedAt) }) + for i := range agents { + agents[i].Crews = b.crewsOf(agents[i].ID) + } return agents, nil } @@ -889,6 +913,7 @@ func (b *appBackend) hold(a *appAgent, conn *appConn, workspace string) appwire. // behind it going (internal/remote's bury finishes every lane). b.died(a, live) }) + live.stopWakes = b.watchWakes(a, live) return view } @@ -1339,6 +1364,15 @@ func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, n // it is counted only if it said something — the same rule the listing // counts by ([appEntriesOf]), so both name it alike. msg, notices, summaries := 0, 0, 0 + // A turn that answers under a message other turns already answered (a + // woken one) starts its counts where they left off. + b.locked(func() { + msg, notices = live.msgBase, live.noteBase + live.msgBase, live.noteBase = 0, 0 + }) + defer func() { + b.locked(func() { live.lastN, live.lastMsgs, live.lastNotices = n, msg, notices }) + }() var reply strings.Builder // A MESSAGE BEFORE A TOOL CALL IS AN UPDATE, the agent saying what it // found mid-turn; the last one is the answer. @@ -1708,6 +1742,9 @@ func (b *appBackend) finish(live *appLive) { if live.stopAsks != nil { live.stopAsks() } + if live.stopWakes != nil { + live.stopWakes() + } live.conn.Release() wait := time.After(appReleaseWait) if live.pumpDone != nil { @@ -1732,12 +1769,13 @@ func (b *appBackend) ListAsks(id string) ([]appwire.Ask, error) { if err != nil { return nil, err } - b.mu.Lock() - defer b.mu.Unlock() - if a.live == nil { - return nil, nil - } - return append([]appwire.Ask(nil), a.live.asks...), nil + var asks []appwire.Ask + b.locked(func() { + if a.live != nil { + asks = append(asks, a.live.asks...) + } + }) + return append(asks, b.crewAsksOf(id)...), nil } // appAnsweredMark starts the value [appLive.answered] holds for an ask this app @@ -1747,6 +1785,9 @@ const appAnsweredMark = "app:" // AnswerAsk answers one open ask through the engine's own answer door, the one // the chat's question page uses. Its closing arrives on the question lane. func (b *appBackend) AnswerAsk(id, askID, option, text string) error { + if handled, err := b.answerCrewAsk(id, askID, option, text, ""); handled { + return err + } a, err := b.find(id) if err != nil { return err @@ -2310,44 +2351,6 @@ func appIndexTaskID(agent, raw string) string { return raw } -// ListCrews reads the teams file directly and starts watching it. -func (b *appBackend) ListCrews() ([]appwire.Crew, error) { - crews, err := appReadCrews() - if err != nil { - return nil, appEngineError(err) - } - b.locked(func() { - if b.crews == nil && !b.closing { - b.crews = newAppCrewWatch(b.emit, crews) - } - }) - return crews, nil -} - -// appReadCrews is every open team, members named by agent id. -func appReadCrews() ([]appwire.Crew, error) { - file, err := teamstore.Load(config.ProfileDir()) - if err != nil { - return nil, err - } - var crews []appwire.Crew - for _, team := range file.Teams { - if team.Closed() { - continue - } - crew := appwire.Crew{ID: team.ID, Name: team.Name, Members: []appwire.CrewMember{}} - for _, member := range team.Members { - agent := appMemberAgent(member) - if member.Key == team.Manager { - crew.Manager = agent - } - crew.Members = append(crew.Members, appwire.CrewMember{Agent: agent, Handle: member.Handle}) - } - crews = append(crews, crew) - } - return crews, nil -} - // appMemberAgent is a team member's agent id: its transcript's folder name. func appMemberAgent(member teamstore.Member) string { transcript := member.File @@ -2360,7 +2363,7 @@ func appMemberAgent(member teamstore.Member) string { // Close lets every conversation go and stops the watchers. func (b *appBackend) Close() error { already := false - var crews *appCrewWatch + var crews *appCrewRun var agents []*appAgent var warmed chan struct{} b.locked(func() { @@ -2368,7 +2371,7 @@ func (b *appBackend) Close() error { return } b.closing = true - crews, warmed = b.crews, b.warmed + crews, warmed = b.crewRun, b.warmed if b.signIn != nil { b.signIn.Cancel() b.signIn = nil @@ -2481,7 +2484,8 @@ func (b *appBackend) sweepAgent(a *appAgent, now time.Time) { live = a.live keep = live == nil || live.running || now.Sub(live.lastUse) < appAgentIdle }) - if !keep && !b.liveNeedsYou(live) { + // A member of an open crew stays held while the crew is open. + if !keep && !b.liveNeedsYou(live) && !b.crewKeeps(a.view.ID) { // The state the app reads does not move: the conversation is as // idle on disk as it was in memory. b.release(a) @@ -2497,74 +2501,3 @@ func appEngineError(err error) error { } return &appwire.Error{Code: appwire.CodeEngine, Message: redact.Secrets(plainWords(err.Error()))} } - -// appCrewWatch emits crew.changed when the teams file changes under the app. -type appCrewWatch struct { - emit appwire.Emitter - stop chan struct{} - done chan struct{} - known map[string]string -} - -func newAppCrewWatch(emit appwire.Emitter, crews []appwire.Crew) *appCrewWatch { - w := &appCrewWatch{emit: emit, stop: make(chan struct{}), done: make(chan struct{}), known: appCrewPrints(crews)} - guard.Go("app/crews", w.run) - return w -} - -func appCrewPrints(crews []appwire.Crew) map[string]string { - prints := make(map[string]string, len(crews)) - for _, crew := range crews { - prints[crew.ID] = fmt.Sprintf("%#v", crew) - } - return prints -} - -func (w *appCrewWatch) run() { - defer close(w.done) - path := teamstore.Path(config.ProfileDir()) - stamp := func() (time.Time, int64) { - info, err := os.Stat(path) - if err != nil { - return time.Time{}, -1 - } - return info.ModTime(), info.Size() - } - at, size := stamp() - ticker := time.NewTicker(appCrewPoll) - defer ticker.Stop() - for { - select { - case <-w.stop: - return - case <-ticker.C: - } - nowAt, nowSize := stamp() - if nowAt.Equal(at) && nowSize == size { - continue - } - at, size = nowAt, nowSize - crews, err := appReadCrews() - if err != nil { - continue - } - prints := appCrewPrints(crews) - for _, crew := range crews { - if w.known[crew.ID] != prints[crew.ID] { - w.emit.Emit(appwire.EventCrewChanged, appwire.CrewChanged{Crew: crew}) - } - } - // A team deleted or closed is no longer a crew. - for id := range w.known { - if _, still := prints[id]; !still { - w.emit.Emit(appwire.EventCrewRemoved, appwire.CrewRemoved{ID: id}) - } - } - w.known = prints - } -} - -func (w *appCrewWatch) close() { - close(w.stop) - <-w.done -} diff --git a/cmd/codeaf/app_test.go b/cmd/codeaf/app_test.go index 1a972fb514..d6de002442 100644 --- a/cmd/codeaf/app_test.go +++ b/cmd/codeaf/app_test.go @@ -992,10 +992,12 @@ func TestAppAnswersARealApprovalAndRecordsTheWorkspaceAsTheLaunchFolder(t *testi } } -// A CREW THAT CLOSES OR DISAPPEARS IS REMOVED. Once the app has listed the -// crews, a team closed in the teams file is announced as crew.removed with its -// id, and a renamed one as crew.changed. -func TestAppAnnouncesAClosedCrewAsRemoved(t *testing.T) { +// A CREW THAT CLOSES IS SAID AGAIN AS CLOSED, AND ONE THAT DISAPPEARS IS +// REMOVED. Once the app has listed the crews, a team closed in the teams file is +// announced as crew.changed in the closed state (it stays readable and can be +// reopened), a renamed one as crew.changed, and one deleted from the file as +// crew.removed with its id. +func TestAppAnnouncesAClosedCrewAndRemovesADeletedOne(t *testing.T) { profile := t.TempDir() t.Setenv(config.ProfileDirEnv, profile) member := filepath.Join(profile, "places", "p", "0123abcd", "transcript.jsonl") @@ -1021,13 +1023,21 @@ func TestAppAnnouncesAClosedCrewAsRemoved(t *testing.T) { t.Fatal(err) } rec.waitFor(t, 4*appCrewPoll, func(event string, data any) bool { - removed, ok := data.(appwire.CrewRemoved) - return ok && removed.ID == "0a0a0a0a0a0a" + changed, ok := data.(appwire.CrewChanged) + return ok && changed.Crew.ID == "0a0a0a0a0a0a" && changed.Crew.State == appwire.CrewClosed }) rec.waitFor(t, 4*appCrewPoll, func(event string, data any) bool { changed, ok := data.(appwire.CrewChanged) return ok && changed.Crew.Name == "pier" }) + time.Sleep(20 * time.Millisecond) + if err := teamstore.Save(profile, teamsNow[1:]); err != nil { + t.Fatal(err) + } + rec.waitFor(t, 4*appCrewPoll, func(event string, data any) bool { + removed, ok := data.(appwire.CrewRemoved) + return ok && removed.ID == "0a0a0a0a0a0a" + }) } // CREATED WITH WORDS, AN AGENT STARTS AT ONCE AND IS NAMED BY THEM. No diff --git a/cmd/codeaf/appcrewlayer.go b/cmd/codeaf/appcrewlayer.go new file mode 100644 index 0000000000..2fac9fd9e2 --- /dev/null +++ b/cmd/codeaf/appcrewlayer.go @@ -0,0 +1,194 @@ +package main + +// ── codeaf app: THE CREWS' APP LAYER ──────────────────────────────────────── +// +// A crew is a codeaf team, and everything a team keeps is read from the teams +// store. The design asks for a few things teams do not keep — a goal and an end +// date, linked items, roles, a paused member, what a member was, the agent's +// tasks before it became a crew, an agent's proposal to become one — and those +// live HERE, in one small file beside the app's own state, keyed by team id and +// member agent id. Nothing here is ever written into the teams file, and the +// terminal never reads it: a crew made in the terminal simply has none of it. +// +// A member's budget is the one app field that is not in this file: it is the +// agent's own budget the app already keeps (appstate.go's budgets), bound to the +// member's conversation as its spending limit, so the engine stops it at the +// cap with a budget ask exactly as it stops any agent. + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strconv" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" +) + +// appCrewLayerName is the layer's file, beside the app's state file. +const appCrewLayerName = "crews.json" + +// appCrewExtra is what the layer keeps for one crew. +type appCrewExtra struct { + Goal string `json:"goal,omitempty"` + EndsAt *time.Time `json:"ends_at,omitempty"` + History []string `json:"history,omitempty"` + Items []appwire.CrewItem `json:"items,omitempty"` +} + +// appMemberExtra is what the layer keeps for one member of one crew. +type appMemberExtra struct { + Role string `json:"role,omitempty"` + Paused bool `json:"paused,omitempty"` + Was *appwire.MemberWas `json:"was,omitempty"` +} + +// appPendingStart is what a member started from a proposal is to be given +// once it joins: its role and model. +type appPendingStart struct { + Role string `json:"role,omitempty"` + Model string `json:"model,omitempty"` +} + +// appCrewLayerFile is the whole layer. +type appCrewLayerFile struct { + Crews map[string]*appCrewExtra `json:"crews,omitempty"` + Members map[string]*appMemberExtra `json:"members,omitempty"` + Roles map[string]*appwire.Role `json:"roles,omitempty"` + Proposals map[string]*appwire.Proposal `json:"proposals,omitempty"` + Pending map[string]appPendingStart `json:"pending,omitempty"` + Seq int `json:"seq,omitempty"` +} + +// appCrewLayer is the file, read once and written whole. +type appCrewLayer struct { + mu sync.Mutex + path string + loaded bool + file appCrewLayerFile +} + +// appMemberKey is a member's key in the layer: its crew and its agent. +func appMemberKey(crew, agent string) string { return crew + "/" + agent } + +// appPendingKey is a pending start's key: its crew and the handle it was +// started under. +func appPendingKey(crew, handle string) string { return crew + "/@" + handle } + +func (l *appCrewLayer) loadLocked() error { + if l.loaded { + return nil + } + raw, err := os.ReadFile(l.path) + switch { + case errors.Is(err, os.ErrNotExist): + case err != nil: + return err + default: + if err := json.Unmarshal(raw, &l.file); err != nil { + return err + } + } + l.loaded = true + return nil +} + +// read hands fn the layer as it stands. +func (l *appCrewLayer) read(fn func(*appCrewLayerFile)) error { + l.mu.Lock() + defer l.mu.Unlock() + if err := l.loadLocked(); err != nil { + return err + } + fn(&l.file) + return nil +} + +// write changes the layer and saves it whole: a new file renamed over the old. +func (l *appCrewLayer) write(fn func(*appCrewLayerFile) error) error { + l.mu.Lock() + defer l.mu.Unlock() + if err := l.loadLocked(); err != nil { + return err + } + // The change is made on a copy, so a refusal leaves the layer as it was. + raw, err := json.Marshal(l.file) + if err != nil { + return err + } + var next appCrewLayerFile + if err := json.Unmarshal(raw, &next); err != nil { + return err + } + if err := fn(&next); err != nil { + return err + } + out, err := json.MarshalIndent(next, "", " ") + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(l.path), appStateFolderMode); err != nil { + return err + } + temp := l.path + ".tmp" + if err := os.WriteFile(temp, out, appStateFileMode); err != nil { + return err + } + if err := os.Rename(temp, l.path); err != nil { + return err + } + l.file = next + return nil +} + +// crew is the layer's record for a crew, made when missing. +func (f *appCrewLayerFile) crew(id string) *appCrewExtra { + if f.Crews == nil { + f.Crews = map[string]*appCrewExtra{} + } + if f.Crews[id] == nil { + f.Crews[id] = &appCrewExtra{} + } + return f.Crews[id] +} + +// member is the layer's record for a member, made when missing. +func (f *appCrewLayerFile) member(crew, agent string) *appMemberExtra { + if f.Members == nil { + f.Members = map[string]*appMemberExtra{} + } + key := appMemberKey(crew, agent) + if f.Members[key] == nil { + f.Members[key] = &appMemberExtra{} + } + return f.Members[key] +} + +// next is a new id with a prefix, unique in the layer. +func (f *appCrewLayerFile) next(prefix string) string { + f.Seq++ + return prefix + strconv.Itoa(f.Seq) +} + +// crewLayer is the backend's layer, beside its state file. +func (b *appBackend) crewLayer() *appCrewLayer { + b.crewOnce.Do(func() { + b.layer = &appCrewLayer{path: filepath.Join(filepath.Dir(b.state.where()), appCrewLayerName)} + }) + return b.layer +} + +// crewPaused says the person paused this agent in some open crew. +func (b *appBackend) crewPaused(agent string) bool { + paused := false + _ = b.crewLayer().read(func(f *appCrewLayerFile) { + for key, m := range f.Members { + if m != nil && m.Paused && filepath.Base(key) == agent { + paused = true + } + } + }) + return paused +} diff --git a/cmd/codeaf/appcrewmembers.go b/cmd/codeaf/appcrewmembers.go new file mode 100644 index 0000000000..ccf55dc845 --- /dev/null +++ b/cmd/codeaf/appcrewmembers.go @@ -0,0 +1,671 @@ +package main + +// ── codeaf app: A CREW'S MEMBERS, ROLES AND PROPOSALS ─────────────────────── +// +// The member verbs are the team store's own (add, remove, handle, home, and a +// stop on the crew's log the member's engine reads) beside the few the app +// layer keeps (a role, a pause, a budget, what a member was). Where the layer +// has to make a field true it does so through doors the engine already has: +// a role's instructions are a message in the member's own conversation, its +// model the conversation's model, a member's budget the agent's own spending +// limit, and a pause the crew's own stop with every turn the conversation +// starts by itself stopped until the person resumes it (appwakes.go). +// +// A PROPOSAL is the agent's own: the app asks it, in its chat where the person +// can read the question, for one JSON block naming the crew and the helpers it +// would start, and the answer becomes an ask. Accepting it makes the team with +// the agent as manager and writes each helper's start on the crew's log as the +// manager's own line, its brief the manager's own words from the proposal; +// the app then performs those starts exactly as it performs any manager's. + +import ( + "encoding/json" + "strings" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/guard" + "github.com/Agent-Field/codeaf/internal/redact" + "github.com/Agent-Field/codeaf/internal/session" + teamstore "github.com/Agent-Field/codeaf/internal/teams" +) + +// Member answers one member verb with the member as it now stands. +func (b *appBackend) Member(verb string, ask appwire.MemberAsk) (appwire.CrewMember, error) { + t, _, err := b.crewTeam(ask.Crew) + if err != nil { + return appwire.CrewMember{}, err + } + b.crews() + crew := ask.Crew + agent := strings.TrimSpace(ask.Agent) + switch verb { + case "add": + agent, err = b.memberAdd(t, agent, ask) + case "start": + agent, err = b.memberStart(t, ask) + default: + m, ok := b.crewMemberOf(t, agent) + if !ok { + return appwire.CrewMember{}, appwire.Errorf(appwire.CodeNotFound, "%s is not in %s", agent, t.Name) + } + switch verb { + case "stop": + err = b.memberStop(t, m, agent, ask.Reason) + case "pause": + if err = b.memberStop(t, m, agent, "paused"); err == nil { + err = b.layerMember(crew, agent, func(x *appMemberExtra) { x.Paused = true }) + } + case "resume": + err = b.layerMember(crew, agent, func(x *appMemberExtra) { x.Paused = false }) + case "remove": + if m.Key == t.Manager { + return appwire.CrewMember{}, appwire.Errorf(appwire.CodeBadParams, "the manager is not removed; make another member manager first") + } + err = b.crewEdit(func(f *teamstore.File) error { return f.RemoveMember(crew, m.Key) }) + if err == nil { + view := appwire.CrewMember{Agent: agent, Crew: crew, Handle: m.Handle} + return view, nil + } + case "handle": + err = b.crewEdit(func(f *teamstore.File) error { return f.SetHandle(crew, m.Key, strings.TrimSpace(ask.Handle)) }) + case "home": + err = b.crewEdit(func(f *teamstore.File) error { return f.SetHome(m.Key, crew) }) + case "cap": + err = b.memberCap(agent, ask.CapUSD) + case "role": + err = b.memberRole(crew, agent, ask.Role, ask.Brief, ask.Model) + case "move": + crew, err = b.memberMove(t, m, agent, ask.To) + default: + return appwire.CrewMember{}, appwire.Errorf(appwire.CodeUnknownMethod, "members.%s is not a member verb", verb) + } + } + if err != nil { + return appwire.CrewMember{}, err + } + return b.memberNow(crew, agent) +} + +// memberNow is a member as its crew shows it now. +func (b *appBackend) memberNow(crew, agent string) (appwire.CrewMember, error) { + view, err := b.CrewGet(crew) + if err != nil { + return appwire.CrewMember{}, err + } + for _, m := range view.Members { + if m.Agent == agent { + return m, nil + } + } + return appwire.CrewMember{}, appwire.Errorf(appwire.CodeNotFound, "%s is not in %s", agent, view.Name) +} + +// layerMember changes a member's app-layer record. +func (b *appBackend) layerMember(crew, agent string, fn func(*appMemberExtra)) error { + if err := b.crewLayer().write(func(f *appCrewLayerFile) error { + fn(f.member(crew, agent)) + return nil + }); err != nil { + return appEngineError(err) + } + return nil +} + +// memberAdd puts an existing agent in the crew, as the manager's team_add does. +func (b *appBackend) memberAdd(t teamstore.Team, agent string, ask appwire.MemberAsk) (string, error) { + if agent == "" { + return "", appwire.Errorf(appwire.CodeBadParams, "members.add needs an agent") + } + m, err := b.agentMember(agent) + if err != nil { + return "", err + } + handle := strings.TrimSpace(ask.Handle) + err = b.crewEdit(func(f *teamstore.File) error { + if err := f.AddMember(t.ID, m); err != nil { + return err + } + if handle != "" { + return f.SetHandle(t.ID, m.Key, handle) + } + return nil + }) + if err != nil { + return "", err + } + if strings.TrimSpace(ask.Role) != "" { + if err := b.memberRole(t.ID, agent, ask.Role, ask.Brief, ask.Model); err != nil { + return "", err + } + } + return agent, nil +} + +// memberStart opens a new conversation in the crew's folder whose first +// message is the person's brief, and puts it in the crew under the handle. +func (b *appBackend) memberStart(t teamstore.Team, ask appwire.MemberAsk) (string, error) { + handle, brief := strings.TrimSpace(ask.Handle), strings.TrimSpace(ask.Brief) + if err := teamstore.ValidHandle(handle); err != nil { + return "", appwire.Errorf(appwire.CodeBadParams, "a handle is 2 to 12 lowercase letters or digits") + } + if _, taken := t.ByHandle(handle); taken { + return "", appwire.Errorf(appwire.CodeBadParams, "@%s is already in %s", handle, t.Name) + } + if brief == "" { + return "", appwire.Errorf(appwire.CodeBadParams, "members.start needs a brief") + } + model := strings.TrimSpace(ask.Model) + var role *appwire.Role + if ask.Role != "" { + role = b.roleNamed(ask.Role) + if role != nil && model == "" { + model = role.Model + } + } + agent, err := b.CreateAgent(appwire.CreateAsk{Workspace: appCrewWhere(t), Model: model}) + if err != nil { + return "", err + } + if _, _, err := b.heldLive(agent.ID); err != nil { + return "", err + } + m, err := b.agentMember(agent.ID) + if err != nil { + return "", err + } + m.Handle = handle + if err := b.crewEdit(func(f *teamstore.File) error { + if err := f.AddMember(t.ID, m); err != nil { + return err + } + return f.SetHandle(t.ID, m.Key, handle) + }); err != nil { + return "", err + } + if ask.Role != "" { + if err := b.memberRole(t.ID, agent.ID, ask.Role, "", ""); err != nil { + return "", err + } + } + if _, err := b.Send(agent.ID, brief, ""); err != nil { + return "", err + } + return agent.ID, nil +} + +// memberStop is the crew's own stop: a line on its log the member's engine +// reads, and the member's running turn stopped. +func (b *appBackend) memberStop(t teamstore.Team, m teamstore.Member, agent, reason string) error { + if strings.TrimSpace(reason) == "" { + reason = "stopped by you" + } + if err := teamstore.AppendTraffic(b.crewProfile(), t.ID, teamstore.Entry{ + Kind: teamstore.KindStop, From: teamstore.FromYou, To: m.Handle, ToKey: m.Key, Member: m.Key, Text: reason, + }); err != nil { + return appEngineError(err) + } + if b.isHeld(agent) { + return b.Stop(agent) + } + return nil +} + +// memberCap is a member's budget: the agent's own spending limit. +func (b *appBackend) memberCap(agent string, usd *float64) error { + if usd == nil || *usd <= 0 { + return appwire.Errorf(appwire.CodeBadParams, "a member's budget is more than $0") + } + return b.RaiseCap(appwire.CapRaise{Scope: appwire.CapAgent, ID: agent, ToUSD: *usd, For: appwire.RaiseGood}) +} + +// memberMove moves a member to another crew under the same handle, and keeps +// where it was. +func (b *appBackend) memberMove(t teamstore.Team, m teamstore.Member, agent, to string) (string, error) { + if strings.TrimSpace(to) == "" || to == t.ID { + return "", appwire.Errorf(appwire.CodeBadParams, "members.move needs another crew in to") + } + if m.Key == t.Manager { + return "", appwire.Errorf(appwire.CodeBadParams, "the manager does not move; make another member manager first") + } + err := b.crewEdit(func(f *teamstore.File) error { + moved := m + moved.Home, moved.Started = false, false + if err := f.AddMember(to, moved); err != nil { + return err + } + if moved.Handle != "" { + _ = f.SetHandle(to, moved.Key, moved.Handle) + } + return f.RemoveMember(t.ID, m.Key) + }) + if err != nil { + return "", err + } + err = b.crewLayer().write(func(f *appCrewLayerFile) error { + old := f.member(t.ID, agent) + next := f.member(to, agent) + next.Role, next.Paused = old.Role, old.Paused + next.Was = &appwire.MemberWas{Crew: t.ID, Role: old.Role, At: b.now()} + delete(f.Members, appMemberKey(t.ID, agent)) + return nil + }) + if err != nil { + return "", appEngineError(err) + } + return to, nil +} + +// ── roles ─────────────────────────────────────────────────────────────────── + +// roleNamed is a role by id or by name, or nil. +func (b *appBackend) roleNamed(idOrName string) *appwire.Role { + var found *appwire.Role + _ = b.crewLayer().read(func(f *appCrewLayerFile) { + for _, role := range f.Roles { + if role != nil && (role.ID == idOrName || strings.EqualFold(role.Name, idOrName)) { + copied := *role + found = &copied + } + } + }) + return found +} + +// memberRole gives a member a role, making the role when it is new: its +// instructions go to the member as a message in its own conversation and its +// model becomes the member's. +func (b *appBackend) memberRole(crew, agent, role, instructions, model string) error { + role, instructions, model = strings.TrimSpace(role), strings.TrimSpace(instructions), strings.TrimSpace(model) + if role == "" { + return appwire.Errorf(appwire.CodeBadParams, "name the role") + } + var given appwire.Role + err := b.crewLayer().write(func(f *appCrewLayerFile) error { + var found *appwire.Role + for _, r := range f.Roles { + if r != nil && (r.ID == role || strings.EqualFold(r.Name, role)) { + found = r + } + } + if found == nil { + if f.Roles == nil { + f.Roles = map[string]*appwire.Role{} + } + found = &appwire.Role{ID: f.next("role"), Name: role, Instructions: instructions, Model: model, MadeAt: b.now()} + f.Roles[found.ID] = found + } + m := f.member(crew, agent) + if m.Role != found.ID { + if m.Role != "" { + m.Was = &appwire.MemberWas{Role: m.Role, At: b.now()} + } + m.Role = found.ID + } + given = *found + return nil + }) + if err != nil { + return appEngineError(err) + } + return b.tellRole(agent, crew, given) +} + +// tellRole puts a role's instructions in a member's conversation and moves it +// to the role's model. +func (b *appBackend) tellRole(agent, crew string, role appwire.Role) error { + if role.Model != "" { + if _, err := b.SetModel(agent, role.Model); err != nil { + return err + } + } + if strings.TrimSpace(role.Instructions) == "" { + return nil + } + name := crew + if t, _, err := b.crewTeam(crew); err == nil { + name = t.Name + } + _, err := b.Queue(agent, "Your role in "+name+" is "+role.Name+".\n\n"+role.Instructions, "") + return err +} + +// Roles is every role, with the members that carry it. +func (b *appBackend) Roles() ([]appwire.Role, error) { + var out []appwire.Role + _ = b.crewLayer().read(func(f *appCrewLayerFile) { + for _, role := range f.Roles { + if role == nil { + continue + } + copied := *role + copied.Copies = []appwire.RoleCopy{} + for key, m := range f.Members { + if m != nil && m.Role == role.ID { + crew, agent, _ := strings.Cut(key, "/") + copied.Copies = append(copied.Copies, appwire.RoleCopy{Crew: crew, Agent: agent}) + } + } + out = append(out, copied) + } + }) + return out, nil +} + +// RoleUpdate edits a role; every member that carries it is told the new +// instructions and moved to the new model. +func (b *appBackend) RoleUpdate(id string, name, instructions, model *string) (appwire.Role, error) { + var role appwire.Role + var copies []appwire.RoleCopy + told, moved := false, false + err := b.crewLayer().write(func(f *appCrewLayerFile) error { + r := f.Roles[id] + if r == nil { + return appwire.Errorf(appwire.CodeNotFound, "there is no role %s", id) + } + if name != nil && strings.TrimSpace(*name) != "" { + r.Name = strings.TrimSpace(*name) + } + if instructions != nil && strings.TrimSpace(*instructions) != r.Instructions { + r.Instructions, told = strings.TrimSpace(*instructions), true + } + if model != nil && strings.TrimSpace(*model) != r.Model { + r.Model, moved = strings.TrimSpace(*model), true + } + role = *r + for key, m := range f.Members { + if m != nil && m.Role == id { + crew, agent, _ := strings.Cut(key, "/") + copies = append(copies, appwire.RoleCopy{Crew: crew, Agent: agent}) + } + } + return nil + }) + if err != nil { + var wire *appwire.Error + if ok := asWire(err, &wire); ok { + return appwire.Role{}, wire + } + return appwire.Role{}, appEngineError(err) + } + for _, c := range copies { + send := role + if !told { + send.Instructions = "" + } + if !moved { + send.Model = "" + } + if err := b.tellRole(c.Agent, c.Crew, send); err != nil { + return appwire.Role{}, err + } + } + role.Copies = append([]appwire.RoleCopy{}, copies...) + return role, nil +} + +// asWire unwraps a wire error. +func asWire(err error, wire **appwire.Error) bool { + w, ok := err.(*appwire.Error) + if ok { + *wire = w + } + return ok +} + +// ── proposals ─────────────────────────────────────────────────────────────── + +// appProposalAsk is what the app asks an agent for when the person asks it to +// propose a crew. It is said in the agent's own chat, so the person reads it. +const appProposalAsk = "Propose a crew for the work in this conversation: the helpers you would start and manage. " + + "Reply with one JSON object and nothing else, shaped " + + `{"name": "the crew's name", "goal": "one line", "helpers": [{"handle": "2 to 12 lowercase letters or digits", "role": "a short role name", "brief": "the helper's whole assignment"}]}. ` + + "Do not start anyone yet; the person decides." + +// CrewPropose asks the agent for a crew proposal; its answer arrives as an ask. +func (b *appBackend) CrewPropose(agent string) error { + a, live, err := b.heldLive(agent) + if err != nil { + return err + } + b.crews() + if _, err := b.Send(agent, appProposalAsk, ""); err != nil { + return err + } + var done chan struct{} + b.locked(func() { done = live.pumpDone }) + guard.Go("app/crew-propose", func() { + if done != nil { + <-done + } + b.proposalAnswered(a, agent) + }) + return nil +} + +// proposalAnswered reads the agent's answer as a proposal and asks the person. +func (b *appBackend) proposalAnswered(a *appAgent, agent string) { + var transcript string + b.locked(func() { transcript = a.transcript }) + answer := "" + for _, entry := range appEntriesOf(agent, appRecordItems(session.ReadTranscript(transcript)), b.now(), nil) { + if entry.Kind == appwire.EntryAnswer { + answer = entry.Text + } + } + p, ok := appParseProposal(answer) + if !ok { + b.emit.Emit(appwire.EventEntryAdded, appwire.EntryAdded{Entry: appwire.Entry{ + ID: "crewprop-none-" + b.now().Format("150405"), Agent: agent, Kind: appwire.EntryNotice, At: b.now(), + Text: "No crew proposal came back in a form the app can read", Quiet: true, + }}) + return + } + p.Agent, p.At = agent, b.now() + if err := b.crewLayer().write(func(f *appCrewLayerFile) error { + p.ID = f.next("prop") + if f.Proposals == nil { + f.Proposals = map[string]*appwire.Proposal{} + } + kept := p + f.Proposals[p.ID] = &kept + return nil + }); err != nil { + return + } + ask := appProposalAskOf(p) + run := b.crews() + run.locked(func() { run.asks[ask.ID] = ask }) + b.emit.Emit(appwire.EventAskOpened, appwire.AskOpened{Ask: ask}) +} + +// appProposalAskOf is a proposal as the Bring in helpers ask. +func appProposalAskOf(p appwire.Proposal) appwire.Ask { + var detail []string + for _, h := range p.Helpers { + line := "@" + h.Handle + if h.Role != "" { + line += " · " + h.Role + } + detail = append(detail, line) + } + return appwire.Ask{ + ID: appProposalAskPrefix + p.ID, Agent: p.Agent, Kind: appwire.AskQuestion, Subkind: appwire.SubkindCrewProposal, + Title: "Bring in helpers: " + p.Name, Detail: strings.Join(detail, "\n"), Reason: p.Goal, + Options: []appwire.AskOption{ + {ID: appwire.ProposalCreate, Label: "Make the crew"}, + {ID: appwire.ProposalEdit, Label: "Edit"}, + {ID: appwire.ProposalKeep, Label: "Keep it one agent"}, + }, + } +} + +// appParseProposal reads the one JSON object in an answer. +func appParseProposal(answer string) (appwire.Proposal, bool) { + start, end := strings.Index(answer, "{"), strings.LastIndex(answer, "}") + if start < 0 || end <= start { + return appwire.Proposal{}, false + } + var p appwire.Proposal + if err := json.Unmarshal([]byte(answer[start:end+1]), &p); err != nil { + return appwire.Proposal{}, false + } + p.Name = strings.TrimSpace(p.Name) + var helpers []appwire.ProposalHelper + for _, h := range p.Helpers { + h.Handle = strings.ToLower(strings.TrimPrefix(strings.TrimSpace(h.Handle), "@")) + h.Brief = strings.TrimSpace(h.Brief) + if teamstore.ValidHandle(h.Handle) == nil && h.Brief != "" { + helpers = append(helpers, h) + } + } + p.Helpers = helpers + return p, p.Name != "" && len(helpers) > 0 +} + +// answerProposal is the person's answer to a proposal: make the crew, or keep +// the agent alone. Edit is the app's editor, and leaves the ask open. +func (b *appBackend) answerProposal(agent, id, option string) error { + switch option { + case appwire.ProposalCreate: + _, err := b.CrewCreate(appwire.CrewCreate{From: "proposal", Proposal: id}) + return err + case appwire.ProposalEdit: + return nil + case appwire.ProposalKeep: + b.dropProposal(id, appwire.ProposalKeep) + return nil + } + return appwire.Errorf(appwire.CodeBadParams, "a proposal is answered create, edit or keep") +} + +// dropProposal forgets a proposal and closes its ask. +func (b *appBackend) dropProposal(id, option string) { + _ = b.crewLayer().write(func(f *appCrewLayerFile) error { + delete(f.Proposals, id) + return nil + }) + run := b.crews() + var ask appwire.Ask + run.locked(func() { + ask = run.asks[appProposalAskPrefix+id] + delete(run.asks, appProposalAskPrefix+id) + }) + if ask.ID == "" { + return + } + at := b.now() + words := "Kept it one agent" + if option == appwire.ProposalCreate { + words = "Made the crew" + } + b.emit.Emit(appwire.EventAskClosed, appwire.AskClosed{Agent: ask.Agent, ID: ask.ID, By: appwire.ClosedByApp, Option: option, At: at}) + b.emit.Emit(appwire.EventEntryAdded, appwire.EntryAdded{Entry: appwire.Entry{ + ID: "r" + ask.ID, Agent: ask.Agent, Kind: appwire.EntryReceipt, At: at, Text: words + " · you · " + at.Format("15:04"), + }}) +} + +// createFromProposal makes the proposed crew: the agent its manager, the +// agents it named as members, and each helper the manager's start. +func (b *appBackend) createFromProposal(ask appwire.CrewCreate) (appwire.Crew, error) { + var p appwire.Proposal + found := false + _ = b.crewLayer().read(func(f *appCrewLayerFile) { + if got := f.Proposals[ask.Proposal]; got != nil { + p, found = *got, true + } + }) + if !found { + return appwire.Crew{}, appwire.Errorf(appwire.CodeNotFound, "there is no proposal %s", ask.Proposal) + } + if e := ask.Edits; e != nil { + if strings.TrimSpace(e.Name) != "" { + p.Name = strings.TrimSpace(e.Name) + } + if e.Helpers != nil { + p.Helpers = e.Helpers + } + if e.Existing != nil { + p.Existing = e.Existing + } + if e.Hue != nil { + p.Hue = e.Hue + } + if e.CapUSDDay != nil { + p.CapUSDDay = e.CapUSDDay + } + if e.Goal != "" { + p.Goal = e.Goal + } + if e.EndsAt != nil { + p.EndsAt = e.EndsAt + } + } + manager, err := b.agentMember(p.Agent) + if err != nil { + return appwire.Crew{}, err + } + members := []teamstore.Member{manager} + for _, agent := range p.Existing { + m, err := b.agentMember(agent) + if err != nil { + return appwire.Crew{}, err + } + members = append(members, m) + } + hue, err := b.askedHue(p.Hue, "") + if err != nil { + return appwire.Crew{}, err + } + id, err := b.newTeam(p.Name, "", members, manager.Key, hue, nil, p.CapUSDDay) + if err != nil { + return appwire.Crew{}, err + } + b.crewExtraOnCreate(id, p.Agent, p.Goal, p.EndsAt) + err = b.crewLayer().write(func(f *appCrewLayerFile) error { + for _, h := range p.Helpers { + if h.Role == "" && h.Model == "" { + continue + } + role := "" + if h.Role != "" { + var found *appwire.Role + for _, r := range f.Roles { + if r != nil && strings.EqualFold(r.Name, h.Role) { + found = r + } + } + if found == nil { + if f.Roles == nil { + f.Roles = map[string]*appwire.Role{} + } + found = &appwire.Role{ID: f.next("role"), Name: h.Role, Model: h.Model, MadeAt: b.now()} + f.Roles[found.ID] = found + } + role = found.ID + } + if f.Pending == nil { + f.Pending = map[string]appPendingStart{} + } + f.Pending[appPendingKey(id, h.Handle)] = appPendingStart{Role: role, Model: h.Model} + } + return nil + }) + if err != nil { + return appwire.Crew{}, appEngineError(err) + } + // The watch performs these starts as it performs any manager's: it is told + // to read this new crew's log from its first line. + run := b.crews() + run.locked(func() { run.cursors[id] = appTrafficStart }) + for _, h := range p.Helpers { + if err := teamstore.AppendTraffic(b.crewProfile(), id, teamstore.Entry{ + Kind: teamstore.KindStart, From: teamstore.FromManager, FromKey: manager.Key, To: h.Handle, Text: redact.Secrets(h.Brief), + }); err != nil { + return appwire.Crew{}, appEngineError(err) + } + } + b.crewReceipt(p.Agent, id, p.Name) + b.dropProposal(p.ID, appwire.ProposalCreate) + b.crewTick(run, false) + return b.CrewGet(id) +} diff --git a/cmd/codeaf/appcrews.go b/cmd/codeaf/appcrews.go new file mode 100644 index 0000000000..2c817fee2c --- /dev/null +++ b/cmd/codeaf/appcrews.go @@ -0,0 +1,1560 @@ +package main + +// ── codeaf app: CREWS ARE TEAMS ───────────────────────────────────────────── +// +// A crew IS a codeaf team (internal/teams), read from and written to the same +// store the terminal's Teams page uses: the teams file, each team's Traffic +// log, the decision packets and the usage ledger. Nothing here keeps a second +// account of a team. What the design asks for and teams do not keep is the +// app layer's (appcrewlayer.go), and is never written into the teams file. +// +// THE APP DOES WHAT THE TERMINAL WINDOW DOES FOR THE TEAMS IT HOLDS +// (internal/tui3's teamtraffic.go): a manager's `start` line is a new +// conversation it opens in the crew's folder and adds under the manager's +// handle, and a manager's `stop` line is the member's own stop. It acts only on +// lines that arrive while it watches, never on history, and only while it +// holds the manager. Every member of an open crew stays held while the crew is +// open, so a member woken by its manager is a turn on the wire +// (appwakes.go) and never a conversation nobody is watching. +// +// A decision packet waiting on the person is also an ask on the manager that +// brought it to them, so it reaches the dock and the Inbox like any question; +// answering either decides the packet through the store's own door. + +import ( + "encoding/json" + "errors" + "fmt" + "math" + "path/filepath" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/guard" + "github.com/Agent-Field/codeaf/internal/redact" + teamstore "github.com/Agent-Field/codeaf/internal/teams" +) + +// The ids a crew's asks carry: a decision packet, and an agent's proposal. +const ( + appCrewAskPrefix = "crew:" + appProposalAskPrefix = "crewprop:" +) + +// appTrafficStart is a cursor before a log's first line. +var appTrafficStart = strings.Repeat("0", 12) + +// appCrewLogPage bounds one read of a crew's log. +const appCrewLogPage = 200 + +// The named tints and the hue each stands for, in OKLCH degrees. +var appTints = []struct { + name string + hue float64 +}{{"rose", 15}, {"sand", 75}, {"sage", 145}, {"tide", 215}, {"iris", 285}} + +// appTintGraphite is the root crew's tint, and an uncoloured crew's. +const appTintGraphite = "graphite" + +// appTintOf is the named tint nearest a hue. +func appTintOf(t teamstore.Team) string { + if t.Root || !t.Hued() { + return appTintGraphite + } + best, gap := appTintGraphite, math.Inf(1) + for _, tint := range appTints { + if g := teamstore.HueGap(t.Hue, tint.hue); g < gap { + best, gap = tint.name, g + } + } + return best +} + +// appTintHue is a named tint's hue. +func appTintHue(name string) (float64, bool) { + for _, tint := range appTints { + if tint.name == name { + return tint.hue, true + } + } + return 0, false +} + +// appCrewRun is the app's watch over the crews: what it last said of each, +// where it is in each log, the packets waiting on the person, the asks they +// are, and which agents an open crew keeps held. +type appCrewRun struct { + mu sync.Mutex + stop chan struct{} + done chan struct{} + watch teamstore.Watch + prints map[string]string + members map[string]string + cursors map[string]string + acted map[string]bool + packets map[string]teamstore.Packet + asks map[string]appwire.Ask + held map[string][]string + opening map[string]bool +} + +func (c *appCrewRun) locked(fn func()) { + c.mu.Lock() + defer c.mu.Unlock() + fn() +} + +// crewProfile is the profile whose teams the app reads. +func (b *appBackend) crewProfile() string { + if b.profileDir != "" { + return b.profileDir + } + return config.ProfileDir() +} + +// crews starts the watch the first time anything asks for a crew, and answers it. +func (b *appBackend) crews() *appCrewRun { + var run *appCrewRun + start := false + b.locked(func() { + if b.crewRun == nil && !b.closing { + b.crewRun = &appCrewRun{ + stop: make(chan struct{}), done: make(chan struct{}), + prints: map[string]string{}, members: map[string]string{}, cursors: map[string]string{}, + acted: map[string]bool{}, packets: map[string]teamstore.Packet{}, asks: map[string]appwire.Ask{}, + held: map[string][]string{}, opening: map[string]bool{}, + } + start = true + } + run = b.crewRun + }) + if start { + b.crewTick(run, true) + guard.Go("app/crews", func() { b.crewLoop(run) }) + } + return run +} + +// crewLoop looks at the crews every beat until the app closes. +func (b *appBackend) crewLoop(run *appCrewRun) { + defer close(run.done) + ticker := time.NewTicker(appCrewPoll) + defer ticker.Stop() + for { + select { + case <-run.stop: + return + case <-ticker.C: + b.crewTick(run, false) + } + } +} + +// crewStop ends the watch. +func (run *appCrewRun) close() { + close(run.stop) + <-run.done +} + +// appCrewRead is one look at the store: the teams, the defaults, the layer, +// and the packets waiting on the person. +type appCrewRead struct { + file *teamstore.File + defaults teamstore.Defaults + layer appCrewLayerFile + waiting []teamstore.Packet +} + +func (b *appBackend) crewRead() (appCrewRead, error) { + profile := b.crewProfile() + file, err := teamstore.Load(profile) + if err != nil { + return appCrewRead{}, err + } + read := appCrewRead{file: file, defaults: teamstore.DefaultsAt(profile)} + _ = b.crewLayer().read(func(f *appCrewLayerFile) { + raw, _ := json.Marshal(f) + _ = json.Unmarshal(raw, &read.layer) + }) + read.waiting, _, _ = teamstore.OpenPackets(profile, teamstore.Person) + return read, nil +} + +// crewTick is one look: every crew said again where it changed, every new log +// line said and acted on, every packet on the person made an ask or closed, +// and every member of an open crew held. +func (b *appBackend) crewTick(run *appCrewRun, first bool) { + read, err := b.crewRead() + if err != nil { + return + } + views := map[string]appwire.Crew{} + held := map[string][]string{} + for _, t := range read.file.Teams { + view := b.crewView(read, t) + views[t.ID] = view + if !t.Closed() { + for _, m := range view.Members { + held[m.Agent] = append(held[m.Agent], t.ID) + } + } + } + var changed []appwire.Crew + var removed []string + var members []appwire.CrewMember + run.locked(func() { + for id, view := range views { + raw, _ := json.Marshal(view) + if run.prints[id] != string(raw) { + run.prints[id] = string(raw) + if !first { + changed = append(changed, view) + } + } + for _, m := range view.Members { + raw, _ := json.Marshal(m) + key := appMemberKey(id, m.Agent) + if run.members[key] != string(raw) { + run.members[key] = string(raw) + if !first { + members = append(members, m) + } + } + } + } + for id := range run.prints { + if _, still := views[id]; !still { + delete(run.prints, id) + removed = append(removed, id) + } + } + run.held = held + }) + sort.Slice(changed, func(i, j int) bool { return changed[i].ID < changed[j].ID }) + for _, view := range changed { + b.emit.Emit(appwire.EventCrewChanged, appwire.CrewChanged{Crew: view}) + } + for _, m := range members { + b.emit.Emit(appwire.EventMemberChanged, appwire.MemberChanged{Member: m}) + } + for _, id := range removed { + b.emit.Emit(appwire.EventCrewRemoved, appwire.CrewRemoved{ID: id}) + } + for _, t := range read.file.Teams { + if !t.Closed() { + b.crewTraffic(run, read, t) + } + } + b.crewPackets(run, read, first) + b.holdMembers(run, held) +} + +// ── the view ──────────────────────────────────────────────────────────────── + +// crewView is one team as a crew. +func (b *appBackend) crewView(read appCrewRead, t teamstore.Team) appwire.Crew { + f := read.file + view := appwire.Crew{ + ID: t.ID, Name: t.Name, Parent: t.Parent, Root: t.Root, Hue: t.Hue, Tier: t.Tier, Tint: appTintOf(t), + ClosedWith: t.ClosedWith, Report: t.Report, Members: []appwire.CrewMember{}, + Settings: appwire.CrewSettings{QuestionsUp: t.Settings.QuestionsUp, CapUSDDay: t.Settings.CapUSDDay, DepthLimit: t.Settings.DepthLimit, SubShare: t.Settings.SubShare, Wake: t.Settings.Wake}, + } + if !t.Made.IsZero() { + made := t.Made + view.MadeAt = &made + } + if !t.ClosedAt.IsZero() { + closed := t.ClosedAt + view.ClosedAt = &closed + } + if t.Wrap != nil { + view.Wrap = &appwire.CrewWrap{Started: t.Wrap.Started, BoundS: int(t.Wrap.Bound.Seconds())} + } + e := f.Effective(t.ID, read.defaults) + origin := func(o teamstore.Origin) appwire.CrewOrigin { + return appwire.CrewOrigin{Kind: o.Kind, Crew: o.Team, Name: o.Name} + } + view.Effective = appwire.CrewEffective{ + QuestionsUp: e.QuestionsUp, QuestionsUpFrom: origin(e.QuestionsUpFrom), + CapUSDDay: e.CapUSDDay, CapFrom: origin(e.CapFrom), + DepthLimit: e.DepthLimit, DepthFrom: origin(e.DepthFrom), + SubShare: e.SubShare, SubShareFrom: origin(e.SubShareFrom), + Wake: e.Wake, WakeFrom: origin(e.WakeFrom), + } + spend, _ := teamstore.TeamSpend(b.crewProfile(), t.ID, teamstore.Today()) + view.Spend = appwire.CrewSpend{Day: spend.Day, USD: spend.USD, Calls: spend.Calls} + if len(spend.ByMember) > 0 { + view.Spend.ByMember = map[string]float64{} + } + keys := map[string]string{} + for _, m := range t.Members { + keys[m.Key] = appMemberAgent(m) + } + for key, usd := range spend.ByMember { + agent := keys[key] + if agent == "" { + agent = appAgentID(key) + } + view.Spend.ByMember[agent] += usd + } + if m, ok := t.Member(t.Manager); ok { + view.Manager = appMemberAgent(m) + } + for _, m := range t.Members { + view.Members = append(view.Members, b.memberView(read, t, m, view.Spend.ByMember)) + } + for _, m := range t.FormerMembers { + view.FormerMembers = append(view.FormerMembers, appwire.CrewMember{Agent: appMemberAgent(m), Crew: t.ID, Handle: m.Handle, Title: m.Word}) + } + if t.Parent != "" { + view.Brief = b.subCrewBrief(t) + } + if extra := read.layer.Crews[t.ID]; extra != nil { + view.Goal, view.EndsAt, view.History = extra.Goal, extra.EndsAt, extra.History + view.Items = append(view.Items, extra.Items...) + } + view.State = b.crewState(read, t, view) + return view +} + +// subCrewBrief is the words a sub-crew's manager was started with: the +// parent's start line that named this crew. +func (b *appBackend) subCrewBrief(t teamstore.Team) string { + entries, err := teamstore.ReadTraffic(b.crewProfile(), t.Parent, "", 0) + if err != nil { + return "" + } + for i := len(entries) - 1; i >= 0; i-- { + if e := entries[i]; e.Kind == teamstore.KindStart && e.Team == t.ID { + return redact.Secrets(strings.TrimSpace(e.Text)) + } + } + return "" +} + +// crewState is a crew's one word: closed, waiting on the person, working, or +// idle. +func (b *appBackend) crewState(read appCrewRead, t teamstore.Team, view appwire.Crew) string { + if t.Closed() { + return appwire.CrewClosed + } + for _, p := range read.waiting { + if p.Origin == t.ID { + return appwire.CrewNeedsYou + } + } + working := false + for _, m := range view.Members { + switch m.State { + case teamstore.StateAsking: + return appwire.CrewNeedsYou + case teamstore.StateRunning: + working = true + } + } + if working { + return appwire.CrewWorking + } + return appwire.CrewIdle +} + +// memberView is one member as the crew shows it. +func (b *appBackend) memberView(read appCrewRead, t teamstore.Team, m teamstore.Member, spent map[string]float64) appwire.CrewMember { + agent := appMemberAgent(m) + view := appwire.CrewMember{ + Agent: agent, Crew: t.ID, Handle: m.Handle, HandleBy: m.HandleBy, Title: m.Word, Workspace: m.Where, + Started: m.Started, Home: m.Home, Independent: m.Independent, SpentUSD: spent[agent], + } + if !m.JoinedAt.IsZero() { + joined := m.JoinedAt + view.JoinedAt = &joined + } + if home, ok := read.file.Home(m.Key); ok { + manager := "" + if team, ok := read.file.Team(home.Team); ok { + if mm, ok := team.Member(home.Manager); ok { + manager = appMemberAgent(mm) + } + } + view.ReportsTo = &appwire.CrewReportsTo{Crew: home.Team, Manager: manager, Distance: home.Distance} + } + for _, other := range read.file.Teams { + if other.ID != t.ID && !other.Closed() && other.Holds(m.Key) { + view.AlsoIn = append(view.AlsoIn, other.ID) + } + } + view.State, view.Model, view.Title = b.memberLive(agent, view.Title) + if usd, set := b.agentBudget(agent, b.now()); set { + view.CapUSD = &usd + } + if extra := read.layer.Members[appMemberKey(t.ID, agent)]; extra != nil { + view.Role, view.Paused, view.Was = extra.Role, extra.Paused, extra.Was + if role := read.layer.Roles[extra.Role]; role != nil { + view.Role = role.ID + } + } + return view +} + +// memberLive is a member's state, model and title as the app holds its agent: +// the engine's member words (running, asking, idle, failed). +func (b *appBackend) memberLive(agent, title string) (state, model, name string) { + state, name = teamstore.StateIdle, title + b.locked(func() { + a := b.agents[agent] + if a == nil { + return + } + model = a.view.Model + if a.view.Title != "" { + name = a.view.Title + } + switch { + case a.view.State == appwire.StateWorking: + state = teamstore.StateRunning + case a.view.State == appwire.StateNeedsYou: + state = teamstore.StateAsking + case a.failed != "": + state = teamstore.StateFailed + } + }) + return state, model, name +} + +// ── the log, and the manager's starts and stops ───────────────────────────── + +// appLogLine is one Traffic line as the app says it. +func appLogLine(t teamstore.Team, e teamstore.Entry) appwire.LogLine { + line := appwire.LogLine{ + ID: e.ID, Crew: t.ID, At: e.At, Kind: e.Kind, From: e.From, To: e.To, Text: redact.Secrets(e.Text), + Handles: e.Handles, Answers: e.Answers, Files: e.Files, Member: e.Member, State: e.State, + Packet: e.Packet, Reply: e.Reply, Team: e.Team, + } + key := e.Member + if key == "" && e.From != "" { + if m, ok := t.ByHandle(e.From); ok { + key = m.Key + } + } + if key != "" { + if m, ok := t.Member(key); ok { + line.Agent = appMemberAgent(m) + } else { + line.Agent = appAgentID(key) + } + } + return line +} + +// crewTraffic says a crew's new log lines and acts on its manager's starts and +// stops. The first look at a crew only finds where its log ends. +func (b *appBackend) crewTraffic(run *appCrewRun, read appCrewRead, t teamstore.Team) { + profile := b.crewProfile() + var cursor string + known := false + run.locked(func() { cursor, known = run.cursors[t.ID] }) + if !known { + tail, _, err := run.watch.Traffic(profile, t.ID, "", 1) + if err != nil { + return + } + cursor = appTrafficStart + if len(tail) > 0 { + cursor = tail[len(tail)-1].ID + } + run.locked(func() { run.cursors[t.ID] = cursor }) + return + } + entries, _, err := run.watch.Traffic(profile, t.ID, cursor, appCrewLogPage) + if err != nil || len(entries) == 0 { + return + } + run.locked(func() { run.cursors[t.ID] = entries[len(entries)-1].ID }) + for _, e := range entries { + b.emit.Emit(appwire.EventCrewLog, appwire.CrewLogEvent{Line: appLogLine(t, e)}) + b.crewAct(run, read, t, e) + } +} + +// crewAct performs a manager's start or stop once, when the app holds the +// manager. +func (b *appBackend) crewAct(run *appCrewRun, read appCrewRead, t teamstore.Team, e teamstore.Entry) { + if e.Kind != teamstore.KindStop && e.Kind != teamstore.KindStart { + return + } + done := t.ID + "/" + e.ID + acted := false + run.locked(func() { + acted = run.acted[done] + run.acted[done] = true + }) + if acted { + return + } + switch e.Kind { + case teamstore.KindStop: + key := e.Member + if key == "" || !t.Holds(key) { + if m, ok := t.ByHandle(e.To); ok { + key = m.Key + } + } + m, ok := t.Member(key) + if !ok || m.Independent { + return + } + if home, ok := read.file.Home(key); ok && home.Team != t.ID { + return + } + agent := appMemberAgent(m) + if b.isHeld(agent) { + _ = b.Stop(agent) + } + case teamstore.KindStart: + manager, ok := t.Member(t.Manager) + if !ok || !b.isHeld(appMemberAgent(manager)) { + return + } + // ANOTHER WINDOW HOLDING THE SAME MANAGER MAY HAVE DONE IT ALREADY: a + // member under the handle that joined after the line is that start. + if m, ok := t.ByHandle(e.To); ok && !m.JoinedAt.Before(e.At) { + return + } + guard.Go("app/crew-start", func() { b.crewStart(t, manager, e) }) + } +} + +// isHeld says the app holds the agent's conversation. +func (b *appBackend) isHeld(agent string) bool { + held := false + b.locked(func() { held = b.agents[agent] != nil && b.agents[agent].live != nil }) + return held +} + +// appPostureDoor is a conversation whose approval posture can be read or set. +type appPostureDoor interface { + ResolvedApprovalPosture() string + SetApprovalPosture(string) error +} + +// crewStart is a manager's start: a new conversation in the crew's folder, +// added under the manager's handle for it; the conversation reads its brief +// off the start line itself and takes its first turn. +func (b *appBackend) crewStart(t teamstore.Team, manager teamstore.Member, e teamstore.Entry) { + handle := strings.TrimSpace(e.To) + var pending appPendingStart + _ = b.crewLayer().read(func(f *appCrewLayerFile) { pending = f.Pending[appPendingKey(t.ID, handle)] }) + where := appCrewWhere(t) + agent, err := b.CreateAgent(appwire.CreateAsk{Workspace: where, Model: pending.Model}) + if err != nil { + b.crewNote(manager, "could not start @"+handle+": "+err.Error()) + return + } + _, live, err := b.heldLive(agent.ID) + if err != nil { + b.crewNote(manager, "could not start @"+handle+": "+err.Error()) + return + } + posture := strings.TrimSpace(e.Approval) + if posture == "" { + if _, mlive, err := b.heldLive(appMemberAgent(manager)); err == nil { + if door, ok := mlive.conn.Conv.(appPostureDoor); ok { + posture = door.ResolvedApprovalPosture() + } + } + } + if posture != "" { + if door, ok := live.conn.Conv.(appPostureDoor); ok { + _ = door.SetApprovalPosture(posture) + } + } + member, err := b.agentMember(agent.ID) + if err != nil { + b.crewNote(manager, "could not start @"+handle+": "+err.Error()) + return + } + // A MEMBER A MANAGER STARTED HAS NO JOINING MOMENT: its brief is the start + // line written before it existed, and a joining moment after that line + // would hide the brief from it (the session reads nothing older than a + // member's joining). The terminal's start writes none either. + member.Handle, member.JoinedAt, member.Started = handle, time.Time{}, true + err = teamstore.Update(b.crewProfile(), func(f *teamstore.File) error { + if err := f.AddMember(t.ID, member); err != nil { + return err + } + if team, ok := f.Team(t.ID); ok { + if got, _ := team.Member(member.Key); got.Handle != handle && teamstore.ValidHandle(handle) == nil { + _ = f.SetHandle(t.ID, member.Key, handle) + } + } + return nil + }) + if err != nil { + b.crewNote(manager, "@"+handle+" opened but did not join "+t.Name+": "+err.Error()) + return + } + if pending.Role != "" { + _ = b.crewLayer().write(func(f *appCrewLayerFile) error { + f.member(t.ID, agent.ID).Role = pending.Role + delete(f.Pending, appPendingKey(t.ID, handle)) + b.roleCopyLocked(f, pending.Role) + return nil + }) + } +} + +// roleCopyLocked is nothing to do: a role's copies are read from the members +// that carry it. It is named so the place a copy is made reads as one. +func (b *appBackend) roleCopyLocked(*appCrewLayerFile, string) {} + +// crewNote says something beside the manager, in its conversation's own lane. +func (b *appBackend) crewNote(manager teamstore.Member, text string) { + agent := appMemberAgent(manager) + b.emit.Emit(appwire.EventEntryAdded, appwire.EntryAdded{Entry: appwire.Entry{ + ID: "crew-note-" + strconv.FormatInt(b.now().UnixNano(), 36), Agent: agent, Kind: appwire.EntryNotice, + At: b.now(), Text: redact.Secrets(text), Quiet: true, + }}) +} + +// appCrewWhere is the folder a crew's new members open in: the manager's, else +// the one every member shares. +func appCrewWhere(t teamstore.Team) string { + if m, ok := t.Member(t.Manager); ok && strings.TrimSpace(m.Where) != "" { + return m.Where + } + where := "" + for _, m := range t.Members { + w := strings.TrimSpace(m.Where) + if w == "" { + continue + } + if where == "" { + where = filepath.Clean(w) + } else if filepath.Clean(w) != where { + return "" + } + } + return where +} + +// agentMember is an agent as a team member: its conversation key and file, +// its folder and title. +func (b *appBackend) agentMember(agent string) (teamstore.Member, error) { + a, err := b.find(agent) + if err != nil { + return teamstore.Member{}, err + } + var transcript, where, title string + b.locked(func() { transcript, where, title = a.transcript, a.view.Workspace, a.view.Title }) + if strings.TrimSpace(transcript) == "" { + return teamstore.Member{}, appwire.Errorf(appwire.CodeNotFound, "the conversation of %s has no file yet", agent) + } + key := filepath.Clean(transcript) + if real, err := filepath.EvalSymlinks(key); err == nil { + key = filepath.Clean(real) + } + return teamstore.Member{Key: key, File: transcript, Where: where, Word: title, JoinedAt: b.now()}, nil +} + +// holdMembers keeps every member of an open crew held, opening one that is +// not, one at a time. +func (b *appBackend) holdMembers(run *appCrewRun, held map[string][]string) { + for agent := range held { + if b.isHeld(agent) { + continue + } + start := false + run.locked(func() { + if !run.opening[agent] { + run.opening[agent], start = true, true + } + }) + if !start { + continue + } + guard.Go("app/crew-hold", func() { + defer run.locked(func() { delete(run.opening, agent) }) + if a, err := b.find(agent); err == nil { + _, _, _ = b.ensure(a) + } + }) + } +} + +// crewKeeps says an open crew keeps this agent held. +func (b *appBackend) crewKeeps(agent string) bool { + var run *appCrewRun + b.locked(func() { run = b.crewRun }) + if run == nil { + return false + } + keeps := false + run.locked(func() { keeps = len(run.held[agent]) > 0 }) + return keeps +} + +// crewsOf is the open crews an agent is a member of. +func (b *appBackend) crewsOf(agent string) []string { + var run *appCrewRun + b.locked(func() { run = b.crewRun }) + if run == nil { + return nil + } + var out []string + run.locked(func() { out = append(out, run.held[agent]...) }) + sort.Strings(out) + return out +} + +// ── decisions ─────────────────────────────────────────────────────────────── + +// appDecisionOf is a packet as a decision, its path named by the crews it +// climbed through. +func appDecisionOf(f *teamstore.File, p teamstore.Packet) appwire.Decision { + name := func(id string) string { + if id == teamstore.Person { + return "you" + } + if t, ok := f.Team(id); ok { + return t.Name + } + return id + } + d := appwire.Decision{ + ID: p.ID, Crew: p.Origin, WaitsOn: p.Team, Kind: p.Kind, RaisedBy: p.RaisedBy, Question: redact.Secrets(p.Question), + State: p.State, DecidedBy: p.DecidedBy, Decision: p.Decision, Reason: p.Reason, Raised: p.Raised, At: p.At, + } + for _, party := range p.Parties { + dp := appwire.DecisionParty{Handle: party.Handle, Crew: party.Team, Context: redact.Secrets(party.Context)} + if party.Key != "" { + dp.Agent = appAgentID(party.Key) + } + d.Parties = append(d.Parties, dp) + } + for _, o := range p.Options { + d.Options = append(d.Options, appwire.DecisionOption{ID: o.ID, Label: o.Label, Consequence: o.Consequence}) + } + if p.Recommendation != nil { + d.Recommendation = &appwire.DecisionPick{Option: p.Recommendation.Option, Reason: p.Recommendation.Reason} + } + if r := p.Report; r != nil { + d.Report = &appwire.CrewReport{Done: r.Done, Left: r.Left, Files: r.Files, SpendUSD: r.SpendUSD, Incomplete: r.Incomplete} + } + if c := p.Cap; c != nil { + d.Cap = &appwire.CrewCap{Crew: c.Team, Day: c.Day, CapUSD: c.CapUSD, SpentUSD: c.SpentUSD, RaiseTo: c.RaiseTo} + } + d.Path = []string{name(p.Origin)} + for _, hop := range p.Trail { + d.Trail = append(d.Trail, appwire.DecisionHop{From: hop.From, To: hop.To, By: hop.By, Reason: hop.Reason, At: hop.At}) + d.Path = append(d.Path, name(hop.To)) + } + if d.Path[len(d.Path)-1] != name(p.Team) { + d.Path = append(d.Path, name(p.Team)) + } + return d +} + +// decisionAgent is the agent a decision on the person is asked on: the +// manager of the crew that brought it up, else the crew it was raised in. +func decisionAgent(f *teamstore.File, p teamstore.Packet) string { + from := p.Origin + if n := len(p.Trail); n > 0 { + from = p.Trail[n-1].From + } + for _, id := range []string{from, p.Origin} { + if t, ok := f.Team(id); ok { + if m, ok := t.Member(t.Manager); ok { + return appMemberAgent(m) + } + if len(t.Members) > 0 { + return appMemberAgent(t.Members[0]) + } + } + } + return "" +} + +// appDecisionAsk is a decision on the person as an ask. +func appDecisionAsk(f *teamstore.File, p teamstore.Packet) appwire.Ask { + d := appDecisionOf(f, p) + ask := appwire.Ask{ + ID: appCrewAskPrefix + p.ID, Agent: decisionAgent(f, p), Kind: appwire.AskQuestion, Subkind: appwire.SubkindCrewDecision, + Title: d.Question, Path: d.Path, SayWhy: true, + } + if d.Recommendation != nil { + ask.Reason = d.Recommendation.Reason + } + for _, o := range d.Options { + ask.Options = append(ask.Options, appwire.AskOption{ID: o.ID, Label: o.Label}) + choice := appwire.AskChoice{ID: o.ID, Label: o.Label, Detail: o.Consequence} + if d.Recommendation != nil && d.Recommendation.Option == o.ID { + choice.Suggested = true + } + ask.Choices = append(ask.Choices, choice) + } + return ask +} + +// crewPackets says each packet that began or stopped waiting on the person, +// as a decision and as an ask. +func (b *appBackend) crewPackets(run *appCrewRun, read appCrewRead, first bool) { + now := map[string]teamstore.Packet{} + for _, p := range read.waiting { + now[p.ID] = p + } + var opened, gone []teamstore.Packet + run.locked(func() { + for id, p := range now { + if _, had := run.packets[id]; !had { + opened = append(opened, p) + } + } + for id, p := range run.packets { + if _, still := now[id]; !still { + gone = append(gone, p) + } + } + run.packets = now + }) + for _, p := range opened { + ask := appDecisionAsk(read.file, p) + run.locked(func() { run.asks[ask.ID] = ask }) + if !first { + b.emit.Emit(appwire.EventDecisionOpened, appwire.DecisionEvent{Decision: appDecisionOf(read.file, p)}) + } + b.emit.Emit(appwire.EventAskOpened, appwire.AskOpened{Ask: ask}) + } + for _, p := range gone { + id := appCrewAskPrefix + p.ID + var ask appwire.Ask + run.locked(func() { + ask = run.asks[id] + delete(run.asks, id) + }) + decided, err := teamstore.PacketByID(b.crewProfile(), p.ID) + if err != nil { + decided = p + } + b.emit.Emit(appwire.EventDecisionChanged, appwire.DecisionEvent{Decision: appDecisionOf(read.file, decided)}) + b.closeCrewAsk(ask, decided.Decision, decided.DecidedBy) + } +} + +// closeCrewAsk closes a crew's ask with its receipt, which the Inbox and the +// dock read. +func (b *appBackend) closeCrewAsk(ask appwire.Ask, option, by string) { + if ask.ID == "" || ask.Agent == "" { + return + } + at := b.now() + who := "you" + if by != "" && by != teamstore.Person { + who = "@" + by + } + b.emit.Emit(appwire.EventAskClosed, appwire.AskClosed{Agent: ask.Agent, ID: ask.ID, By: appwire.ClosedByApp, Option: option, At: at}) + b.emit.Emit(appwire.EventEntryAdded, appwire.EntryAdded{Entry: appwire.Entry{ + ID: "r" + ask.ID, Agent: ask.Agent, Kind: appwire.EntryReceipt, At: at, Text: "Decided · " + who + " · " + at.Format("15:04"), + }}) +} + +// crewAsksOf is the crew asks waiting on an agent. +func (b *appBackend) crewAsksOf(agent string) []appwire.Ask { + var run *appCrewRun + b.locked(func() { run = b.crewRun }) + if run == nil { + return nil + } + var out []appwire.Ask + run.locked(func() { + for _, ask := range run.asks { + if ask.Agent == agent { + out = append(out, ask) + } + } + }) + sort.Slice(out, func(i, j int) bool { return out[i].ID < out[j].ID }) + return out +} + +// answerCrewAsk answers a crew's ask: a decision is decided through the store, +// a proposal made into a crew or let go. +func (b *appBackend) answerCrewAsk(agent, id, option, text, why string) (bool, error) { + switch { + case strings.HasPrefix(id, appCrewAskPrefix): + _, err := b.CrewDecide(strings.TrimPrefix(id, appCrewAskPrefix), option, text, why) + return true, err + case strings.HasPrefix(id, appProposalAskPrefix): + return true, b.answerProposal(agent, strings.TrimPrefix(id, appProposalAskPrefix), option) + } + return false, nil +} + +// ── the methods ───────────────────────────────────────────────────────────── + +// CrewList is every open crew, or every closed one. +func (b *appBackend) CrewList(closed bool) ([]appwire.Crew, error) { + b.crews() + read, err := b.crewRead() + if err != nil { + return nil, appEngineError(err) + } + teams := read.file.Open() + if closed { + teams = read.file.ClosedTeams() + } + out := make([]appwire.Crew, 0, len(teams)) + for _, t := range teams { + out = append(out, b.crewView(read, t)) + } + return out, nil +} + +// ListCrews is the open crews. +func (b *appBackend) ListCrews() ([]appwire.Crew, error) { return b.CrewList(false) } + +// CrewGet is one crew. +func (b *appBackend) CrewGet(id string) (appwire.Crew, error) { + b.crews() + read, err := b.crewRead() + if err != nil { + return appwire.Crew{}, appEngineError(err) + } + t, ok := read.file.Team(id) + if !ok { + return appwire.Crew{}, appwire.Errorf(appwire.CodeNotFound, "there is no crew %s", id) + } + return b.crewView(read, t), nil +} + +// crewEdit is one write to the teams store, its refusal in the app's words. +func (b *appBackend) crewEdit(fn func(*teamstore.File) error) error { + if err := teamstore.Update(b.crewProfile(), fn); err != nil { + var wire *appwire.Error + if errors.As(err, &wire) { + return wire + } + return appwire.Errorf(appwire.CodeBadParams, "%s", strings.TrimPrefix(err.Error(), "teams: ")) + } + return nil +} + +// CrewCreate makes a crew from agents, or from an agent's proposal. +func (b *appBackend) CrewCreate(ask appwire.CrewCreate) (appwire.Crew, error) { + if ask.From == "proposal" { + return b.createFromProposal(ask) + } + name := strings.TrimSpace(ask.Name) + if name == "" { + return appwire.Crew{}, appwire.Errorf(appwire.CodeBadParams, "give the crew a name") + } + if len(ask.Agents) == 0 { + return appwire.Crew{}, appwire.Errorf(appwire.CodeBadParams, "a crew needs at least one agent") + } + members := make([]teamstore.Member, 0, len(ask.Agents)) + managerKey := "" + for _, agent := range ask.Agents { + m, err := b.agentMember(agent) + if err != nil { + return appwire.Crew{}, err + } + if agent == ask.Manager { + managerKey = m.Key + } + members = append(members, m) + } + if ask.Manager != "" && managerKey == "" { + return appwire.Crew{}, appwire.Errorf(appwire.CodeBadParams, "the manager must be one of the crew's agents") + } + hue, err := b.askedHue(ask.Hue, ask.Tint) + if err != nil { + return appwire.Crew{}, err + } + id, err := b.newTeam(name, ask.Parent, members, managerKey, hue, ask.Settings, ask.CapUSDDay) + if err != nil { + return appwire.Crew{}, err + } + b.crewExtraOnCreate(id, ask.Manager, ask.Goal, ask.EndsAt) + b.crewReceipt(ask.Manager, id, name) + return b.CrewGet(id) +} + +// askedHue is the hue a create or update asked for: a hue, or a named tint. +func (b *appBackend) askedHue(hue *float64, tint string) (*float64, error) { + if hue != nil { + h := math.Mod(math.Mod(*hue, 360)+360, 360) + return &h, nil + } + if strings.TrimSpace(tint) != "" { + h, ok := appTintHue(tint) + if !ok { + return nil, appwire.Errorf(appwire.CodeBadParams, "a tint is rose, sand, sage, tide or iris") + } + return &h, nil + } + return nil, nil +} + +// newTeam writes a new team, its name unique among open teams, under its +// parent when it has one and the parent may nest. +func (b *appBackend) newTeam(name, parent string, members []teamstore.Member, managerKey string, hue *float64, settings *appwire.CrewSettings, cap *float64) (string, error) { + id := teamstore.NewID() + defaults := teamstore.DefaultsAt(b.crewProfile()) + err := b.crewEdit(func(f *teamstore.File) error { + for _, t := range f.Open() { + if strings.EqualFold(strings.TrimSpace(t.Name), name) { + return appwire.Errorf(appwire.CodeBadParams, "a crew already uses the name %s", name) + } + } + if parent != "" && !f.CanNest(parent, defaults) { + return appwire.Errorf(appwire.CodeBadParams, "a crew cannot go under that one: it is closed or as deep as crews may go") + } + var used []teamstore.HueSpec + for _, t := range f.Teams { + if t.Hued() { + used = append(used, t.HueSpec()) + } + } + team := teamstore.Team{ID: id, Name: name, Made: b.now()} + spec := teamstore.NextHue(used, nil) + if hue != nil { + spec.Hue = *hue + } + team.SetHue(spec) + f.Teams = append(f.Teams, team) + for _, m := range members { + if err := f.AddMember(id, m); err != nil { + return err + } + } + if managerKey != "" { + if err := f.SetManager(id, managerKey); err != nil { + return err + } + } + if parent != "" { + if err := f.SetParent(id, parent); err != nil { + return err + } + } + if settings != nil || cap != nil { + return f.SetSettings(id, func(s *teamstore.Settings) { + if settings != nil { + appApplySettings(s, *settings) + } + if cap != nil { + v := *cap + s.CapUSDDay = &v + } + }) + } + return nil + }) + return id, err +} + +// appApplySettings writes the settings that were given. +func appApplySettings(s *teamstore.Settings, given appwire.CrewSettings) { + if given.QuestionsUp != nil { + s.QuestionsUp = given.QuestionsUp + } + if given.CapUSDDay != nil { + s.CapUSDDay = given.CapUSDDay + } + if given.DepthLimit != nil { + s.DepthLimit = given.DepthLimit + } + if given.SubShare != nil { + s.SubShare = given.SubShare + } + if given.Wake != nil { + s.Wake = given.Wake + } +} + +// crewExtraOnCreate keeps a new crew's goal, end date and the manager's tasks +// as they stood. +func (b *appBackend) crewExtraOnCreate(id, manager, goal string, ends *time.Time) { + var history []string + if manager != "" { + if tasks, err := b.ListTasks(manager); err == nil { + for _, task := range tasks { + history = append(history, task.ID) + } + } + } + if strings.TrimSpace(goal) == "" && ends == nil && len(history) == 0 { + return + } + _ = b.crewLayer().write(func(f *appCrewLayerFile) error { + extra := f.crew(id) + extra.Goal, extra.EndsAt, extra.History = strings.TrimSpace(goal), ends, history + return nil + }) +} + +// crewReceipt says in the manager's conversation that the crew was made. +func (b *appBackend) crewReceipt(manager, id, name string) { + if manager == "" { + return + } + at := b.now() + b.emit.Emit(appwire.EventEntryAdded, appwire.EntryAdded{Entry: appwire.Entry{ + ID: "rcrew:" + id, Agent: manager, Kind: appwire.EntryReceipt, At: at, + Text: "Made the crew " + name + " · you · " + at.Format("15:04"), + }}) +} + +// CrewUpdate renames, recolours or resets a crew, and keeps its goal and end +// date in the app layer. +func (b *appBackend) CrewUpdate(ask appwire.CrewUpdate) (appwire.Crew, error) { + tint := "" + if ask.Tint != nil { + tint = *ask.Tint + } + hue, err := b.askedHue(ask.Hue, tint) + if err != nil { + return appwire.Crew{}, err + } + err = b.crewEdit(func(f *teamstore.File) error { + i := teamstore.Index(f.Teams, ask.ID) + if i < 0 { + return appwire.Errorf(appwire.CodeNotFound, "there is no crew %s", ask.ID) + } + if ask.Name != nil { + name := strings.TrimSpace(*ask.Name) + if name == "" { + return appwire.Errorf(appwire.CodeBadParams, "give the crew a name") + } + for _, t := range f.Open() { + if t.ID != ask.ID && strings.EqualFold(strings.TrimSpace(t.Name), name) { + return appwire.Errorf(appwire.CodeBadParams, "a crew already uses the name %s", name) + } + } + f.Teams[i].Name = name + } + if hue != nil { + f.Teams[i].SetHue(teamstore.HueSpec{Hue: *hue, Tier: f.Teams[i].Tier}) + } + if ask.Settings != nil || len(ask.Clear) > 0 { + return f.SetSettings(ask.ID, func(s *teamstore.Settings) { + if ask.Settings != nil { + appApplySettings(s, *ask.Settings) + } + for _, key := range ask.Clear { + switch key { + case "questions_up": + s.QuestionsUp = nil + case "cap_usd_day": + s.CapUSDDay = nil + case "depth_limit": + s.DepthLimit = nil + case "sub_share": + s.SubShare = nil + case "wake": + s.Wake = nil + } + } + }) + } + return nil + }) + if err != nil { + return appwire.Crew{}, err + } + if ask.Goal != nil || ask.EndsAt != nil { + err := b.crewLayer().write(func(f *appCrewLayerFile) error { + extra := f.crew(ask.ID) + if ask.Goal != nil { + extra.Goal = strings.TrimSpace(*ask.Goal) + } + if ask.EndsAt != nil { + ends := *ask.EndsAt + extra.EndsAt = &ends + if ends.IsZero() { + extra.EndsAt = nil + } + } + return nil + }) + if err != nil { + return appwire.Crew{}, appEngineError(err) + } + } + return b.CrewGet(ask.ID) +} + +// crewTeam is one team as it stands, or not_found. +func (b *appBackend) crewTeam(id string) (teamstore.Team, *teamstore.File, error) { + f, err := teamstore.Load(b.crewProfile()) + if err != nil { + return teamstore.Team{}, nil, appEngineError(err) + } + t, ok := f.Team(id) + if !ok { + return teamstore.Team{}, nil, appwire.Errorf(appwire.CodeNotFound, "there is no crew %s", id) + } + return t, f, nil +} + +// crewMemberOf is an agent's membership of a crew. +func (b *appBackend) crewMemberOf(t teamstore.Team, agent string) (teamstore.Member, bool) { + for _, m := range t.Members { + if appMemberAgent(m) == agent { + return m, true + } + } + return teamstore.Member{}, false +} + +// CrewMessage puts the person's words into a conversation of the crew: the +// manager's, or a member's, which the manager is also told of on the crew's +// log (app layer). +func (b *appBackend) CrewMessage(ask appwire.CrewMessage) (appwire.Entry, error) { + t, _, err := b.crewTeam(ask.Crew) + if err != nil { + return appwire.Entry{}, err + } + target := ask.To + if target == "" || target == teamstore.ToManager { + m, ok := t.Member(t.Manager) + if !ok { + return appwire.Entry{}, appwire.Errorf(appwire.CodeBadParams, "%s has no manager to talk to", t.Name) + } + target = appMemberAgent(m) + } + member, ok := b.crewMemberOf(t, target) + if !ok { + return appwire.Entry{}, appwire.Errorf(appwire.CodeNotFound, "%s is not in %s", target, t.Name) + } + turn, err := b.Send(target, ask.Text, ask.ClientID) + var wire *appwire.Error + if errors.As(err, &wire) && wire.Code == appwire.CodeBusy { + // A MEMBER IN THE MIDDLE OF A TURN IS NOT REFUSED THE PERSON'S WORDS: + // they wait in its queue and go in when the turn in hand ends, the way + // a message typed to a working agent does. Turn 0 says it is waiting. + turn, err = 0, nil + if _, qerr := b.Queue(target, ask.Text, ask.ClientID); qerr != nil { + err = qerr + } + } + if err != nil { + return appwire.Entry{}, err + } + if member.Key != t.Manager && t.Manager != "" { + // THE MANAGER IS TOLD AS A MEMBER'S WORDS REACH IT: one line on the + // crew's log, in its digest at its next step. It does not wake it. + _ = teamstore.AppendTraffic(b.crewProfile(), t.ID, teamstore.Entry{ + Kind: teamstore.KindYou, From: teamstore.FromYou, To: member.Handle, ToKey: member.Key, Member: member.Key, Text: ask.Text, + }) + } + return appwire.Entry{Agent: target, Turn: turn, Kind: appwire.EntryUser, At: b.now(), Text: ask.Text, ClientID: ask.ClientID}, nil +} + +// CrewLog is a crew's log after a cursor, oldest first. +func (b *appBackend) CrewLog(id, after string, limit int) ([]appwire.LogLine, string, error) { + t, _, err := b.crewTeam(id) + if err != nil { + return nil, "", err + } + if limit <= 0 || limit > appCrewLogPage { + limit = appCrewLogPage + } + entries, err := teamstore.ReadTraffic(b.crewProfile(), id, after, limit) + if err != nil { + return nil, "", appEngineError(err) + } + lines := make([]appwire.LogLine, 0, len(entries)) + for _, e := range entries { + lines = append(lines, appLogLine(t, e)) + } + cursor := after + if len(entries) > 0 { + cursor = entries[len(entries)-1].ID + } + return lines, cursor, nil +} + +// CrewDecisions is the decisions waiting (or all of them) for one crew, or +// every decision waiting on the person. +func (b *appBackend) CrewDecisions(id string, all bool) ([]appwire.Decision, error) { + profile := b.crewProfile() + f, err := teamstore.Load(profile) + if err != nil { + return nil, appEngineError(err) + } + var packets []teamstore.Packet + switch { + case id == "": + packets, _, err = teamstore.OpenPackets(profile, teamstore.Person) + case all: + packets, err = teamstore.Packets(profile, id) + default: + var open []teamstore.Packet + open, _, err = teamstore.OpenPackets(profile, teamstore.ScopeAll) + for _, p := range open { + if p.Origin == id || p.Team == id { + packets = append(packets, p) + } + } + } + if err != nil { + return nil, appEngineError(err) + } + out := make([]appwire.Decision, 0, len(packets)) + for _, p := range packets { + out = append(out, appDecisionOf(f, p)) + } + return out, nil +} + +// CrewDecide is the person's decision on a packet; a closing report accepted +// closes the crew it reports on. +func (b *appBackend) CrewDecide(packet, option, text, reason string) (appwire.Decision, error) { + profile := b.crewProfile() + decision := strings.TrimSpace(option) + if decision == "" { + decision = strings.TrimSpace(text) + } else if strings.TrimSpace(text) != "" && strings.TrimSpace(reason) == "" { + reason = text + } + p, err := teamstore.Decide(profile, packet, teamstore.Person, decision, strings.TrimSpace(reason)) + if err != nil { + switch { + case errors.Is(err, teamstore.ErrNoPacket): + return appwire.Decision{}, appwire.Errorf(appwire.CodeNotFound, "there is no decision %s", packet) + case errors.Is(err, teamstore.ErrDecided): + return appwire.Decision{}, appwire.Errorf(appwire.CodeBadParams, "that decision was already made") + } + return appwire.Decision{}, appEngineError(err) + } + if p.Kind == teamstore.PacketClosing && (p.Decision == teamstore.OptionClose || p.Decision == teamstore.OptionCloseNow) { + if _, err := teamstore.AcceptClosing(profile, p); err != nil { + return appwire.Decision{}, appEngineError(err) + } + } + f, err := teamstore.Load(profile) + if err != nil { + return appwire.Decision{}, appEngineError(err) + } + var run *appCrewRun + b.locked(func() { run = b.crewRun }) + if run != nil { + b.crewTick(run, false) + } + return appDecisionOf(f, p), nil +} + +// CrewManager makes a member the crew's manager, or clears it. +func (b *appBackend) CrewManager(id, agent string) (appwire.Crew, error) { + t, _, err := b.crewTeam(id) + if err != nil { + return appwire.Crew{}, err + } + key := "" + if agent != "" { + m, ok := b.crewMemberOf(t, agent) + if !ok { + return appwire.Crew{}, appwire.Errorf(appwire.CodeBadParams, "the manager must be a member of %s", t.Name) + } + key = m.Key + } + err = b.crewEdit(func(f *teamstore.File) error { + if key == "" { + return f.ClearManager(id) + } + return f.SetManager(id, key) + }) + if err != nil { + return appwire.Crew{}, err + } + return b.CrewGet(id) +} + +// CrewMove puts crews under another crew, or at the top with parent "". +func (b *appBackend) CrewMove(ids []string, parent string) error { + defaults := teamstore.DefaultsAt(b.crewProfile()) + return b.crewEdit(func(f *teamstore.File) error { + target := f.MoveTarget(parent) + if block, blocked := f.MoveCheck(ids, target, defaults); blocked { + return appwire.Errorf(appwire.CodeBadParams, "%s", appMoveWords(block)) + } + return f.Move(ids, target) + }) +} + +// appMoveWords is why a move was refused, in the app's words. +func appMoveWords(block teamstore.MoveBlock) string { + switch block.Kind { + case "closed": + return block.Name + " is closed" + case "depth": + return fmt.Sprintf("crews may go %d deep there, and this would make %d", block.Limit, block.Depth+block.Need) + case "here": + return "it is already there" + } + return "that move is not allowed" +} + +// CrewWrapUp asks the crew's manager to wrap up and bring a closing report. +func (b *appBackend) CrewWrapUp(id, text string) error { + t, _, err := b.crewTeam(id) + if err != nil { + return err + } + if t.Manager == "" { + return appwire.Errorf(appwire.CodeBadParams, "%s has no manager to wrap up; close it instead", t.Name) + } + if err := teamstore.AppendTraffic(b.crewProfile(), id, teamstore.WrapUpRequest(text)); err != nil { + return appEngineError(err) + } + return nil +} + +// CrewClose closes a crew and every crew under it. Its conversations keep +// working; the app stops holding them for it. +func (b *appBackend) CrewClose(id string) error { + t, _, err := b.crewTeam(id) + if err != nil { + return err + } + if t.Root { + return appwire.Errorf(appwire.CodeBadParams, "All crews does not close; clear its manager instead") + } + if err := b.crewEdit(func(f *teamstore.File) error { return f.Disband(id, b.now(), "") }); err != nil { + return err + } + _ = teamstore.AppendTraffic(b.crewProfile(), id, teamstore.Entry{Kind: teamstore.KindClose, From: teamstore.FromYou, To: teamstore.ToRoom, Text: "closed"}) + return nil +} + +// CrewReopen opens a closed crew again, with the crews its close closed. +func (b *appBackend) CrewReopen(id string) (appwire.Crew, error) { + if _, _, err := b.crewTeam(id); err != nil { + return appwire.Crew{}, err + } + err := b.crewEdit(func(f *teamstore.File) error { + if err := f.Reopen(id); err != nil { + if errors.Is(err, teamstore.ErrParentClosed) { + return appwire.Errorf(appwire.CodeBadParams, "its parent crew is closed; reopen that first") + } + return err + } + return nil + }) + if err != nil { + return appwire.Crew{}, err + } + _ = teamstore.AppendTraffic(b.crewProfile(), id, teamstore.Entry{Kind: teamstore.KindReopen, From: teamstore.FromYou, To: teamstore.ToRoom, Text: "reopened"}) + return b.CrewGet(id) +} + +// CrewDelete forgets a closed crew and the crews under it. Their +// conversations are never deleted. +func (b *appBackend) CrewDelete(id string) ([]string, error) { + t, _, err := b.crewTeam(id) + if err != nil { + return nil, err + } + if !t.Closed() { + return nil, appwire.Errorf(appwire.CodeBadParams, "close %s before deleting it", t.Name) + } + ids, err := teamstore.Delete(b.crewProfile(), id) + if err != nil { + return nil, appEngineError(err) + } + _ = b.crewLayer().write(func(f *appCrewLayerFile) error { + for _, gone := range ids { + delete(f.Crews, gone) + for key := range f.Members { + if strings.HasPrefix(key, gone+"/") { + delete(f.Members, key) + } + } + } + return nil + }) + return ids, nil +} + +// appDefaults is the five defaults on the wire. +func appDefaults(d teamstore.Defaults) appwire.CrewDefaults { + return appwire.CrewDefaults{QuestionsUp: d.QuestionsUp, CapUSDDay: d.CapUSDDay, DepthLimit: d.DepthLimit, SubShare: d.SubShare, Wake: d.Wake} +} + +// CrewDefaults is the profile's five crew defaults. +func (b *appBackend) CrewDefaults() (appwire.CrewDefaults, error) { + return appDefaults(teamstore.DefaultsAt(b.crewProfile())), nil +} + +// CrewSetDefault writes one default the way the terminal's settings do. +func (b *appBackend) CrewSetDefault(key, value string) (appwire.CrewDefaults, error) { + if err := config.ApplyTeamDefault(b.crewProfile(), key, value); err != nil { + return appwire.CrewDefaults{}, appwire.Errorf(appwire.CodeBadParams, "%s", plainWords(err.Error())) + } + return b.CrewDefaults() +} + +// CrewItems is a crew's linked items (app layer). +func (b *appBackend) CrewItems(id string) ([]appwire.CrewItem, error) { + if _, _, err := b.crewTeam(id); err != nil { + return nil, err + } + var items []appwire.CrewItem + _ = b.crewLayer().read(func(f *appCrewLayerFile) { + if extra := f.Crews[id]; extra != nil { + items = append(items, extra.Items...) + } + }) + return items, nil +} + +// CrewLink links a job, issue, pull request or file to a crew (app layer). +func (b *appBackend) CrewLink(id, kind, ref, title string) (appwire.CrewItem, error) { + if _, _, err := b.crewTeam(id); err != nil { + return appwire.CrewItem{}, err + } + var item appwire.CrewItem + err := b.crewLayer().write(func(f *appCrewLayerFile) error { + item = appwire.CrewItem{ID: f.next("i"), Crew: id, Kind: kind, Ref: strings.TrimSpace(ref), Title: strings.TrimSpace(title), At: b.now()} + extra := f.crew(id) + extra.Items = append(extra.Items, item) + return nil + }) + if err != nil { + return appwire.CrewItem{}, appEngineError(err) + } + return item, nil +} + +// CrewUnlink takes a linked item off a crew. +func (b *appBackend) CrewUnlink(id, item string) error { + found := false + err := b.crewLayer().write(func(f *appCrewLayerFile) error { + extra := f.Crews[id] + if extra == nil { + return nil + } + kept := extra.Items[:0] + for _, it := range extra.Items { + if it.ID == item { + found = true + continue + } + kept = append(kept, it) + } + extra.Items = kept + return nil + }) + if err != nil { + return appEngineError(err) + } + if !found { + return appwire.Errorf(appwire.CodeNotFound, "%s has no item %s", id, item) + } + return nil +} diff --git a/cmd/codeaf/appcrews_test.go b/cmd/codeaf/appcrews_test.go new file mode 100644 index 0000000000..d41788178a --- /dev/null +++ b/cmd/codeaf/appcrews_test.go @@ -0,0 +1,205 @@ +package main + +import ( + "errors" + "os" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + teamstore "github.com/Agent-Field/codeaf/internal/teams" +) + +// crewBackend is a work backend whose crews are the teams of a profile of its +// own, with one team whose manager is the backend's held fake agent and one +// member that is only on disk. +func crewBackend(t *testing.T) (*appBackend, *appRecorder, appwire.Agent, string) { + t.Helper() + backend, rec, agent := workBackend(t, &fakeConversation{}) + backend.profileDir = t.TempDir() + id := teamstore.NewID() + err := teamstore.Update(backend.profileDir, func(f *teamstore.File) error { + f.Teams = append(f.Teams, teamstore.Team{ID: id, Name: "harbor", Made: time.Now()}) + if err := f.AddMember(id, teamstore.Member{Key: appFakeTranscript, File: appFakeTranscript, Where: "/w", Word: "lead", Handle: "lead"}); err != nil { + return err + } + if err := f.AddMember(id, teamstore.Member{Key: "/state/v3/places/proj/b0b0b0b0/transcript.jsonl", File: "/state/v3/places/proj/b0b0b0b0/transcript.jsonl", Where: "/w", Word: "web", Handle: "web"}); err != nil { + return err + } + return f.SetManager(id, appFakeTranscript) + }) + if err != nil { + t.Fatal(err) + } + return backend, rec, agent, id +} + +// A CREW IS ITS TEAM, FIELD FOR FIELD: the same id, name, manager and members +// (by agent id), a tint derived from the team's hue, the settings in force with +// where each comes from, and the app layer's fields kept beside the teams file, +// never in it. +func TestAppCrewIsItsTeamAndTheLayerStaysOutOfTheTeamsFile(t *testing.T) { + backend, _, agent, id := crewBackend(t) + crews, err := backend.CrewList(false) + if err != nil || len(crews) != 1 { + t.Fatalf("crews = %+v, %v", crews, err) + } + crew := crews[0] + if crew.ID != id || crew.Name != "harbor" || crew.Manager != agent.ID || len(crew.Members) != 2 || crew.State == "" { + t.Fatalf("the crew = %+v", crew) + } + if crew.Members[1].Agent != "b0b0b0b0" || crew.Members[1].Handle != "web" { + t.Fatalf("the member = %+v", crew.Members[1]) + } + if crew.Effective.CapFrom.Kind == "" || crew.Effective.DepthLimit == 0 { + t.Fatalf("the settings in force = %+v", crew.Effective) + } + sage := "sage" + goal := "ship the release" + updated, err := backend.CrewUpdate(appwire.CrewUpdate{ID: id, Tint: &sage, Goal: &goal}) + if err != nil { + t.Fatal(err) + } + if updated.Tint != "sage" || updated.Hue != 145 || updated.Goal != goal { + t.Fatalf("after the update = %+v", updated) + } + if _, err := backend.CrewLink(id, "pr", "#1830", "memory"); err != nil { + t.Fatal(err) + } + items, _ := backend.CrewItems(id) + if len(items) != 1 || items[0].Ref != "#1830" { + t.Fatalf("the items = %+v", items) + } + raw, err := os.ReadFile(teamstore.Path(backend.profileDir)) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), goal) || strings.Contains(string(raw), "#1830") { + t.Fatalf("the app layer reached the teams file:\n%s", raw) + } +} + +// A DECISION ON THE PERSON IS AN ASK on the manager that brought it, with the +// path it climbed; answering the ask decides the packet in the store, and the +// ask closes with its receipt. +func TestAppCrewDecisionIsAnAskAndAnsweringItDecides(t *testing.T) { + backend, rec, agent, id := crewBackend(t) + backend.crews() + p, err := teamstore.Raise(backend.profileDir, teamstore.Packet{ + Kind: teamstore.PacketQuestion, Team: teamstore.Person, Origin: id, RaisedBy: teamstore.FromManager, + Question: "Ship on Friday or Monday?", + Options: []teamstore.Option{ + {ID: "fri", Label: "Friday", Consequence: "ships before the weekend"}, + {ID: "mon", Label: "Monday", Consequence: "one more day of tests"}, + }, + Recommendation: &teamstore.Recommendation{Option: "mon", Reason: "the suite is still red"}, + }) + if err != nil { + t.Fatal(err) + } + var run *appCrewRun + backend.locked(func() { run = backend.crewRun }) + backend.crewTick(run, false) + var opened appwire.Ask + rec.waitFor(t, 5*time.Second, func(event string, data any) bool { + o, ok := data.(appwire.AskOpened) + if ok && o.Ask.Subkind == appwire.SubkindCrewDecision { + opened = o.Ask + } + return ok && o.Ask.Subkind == appwire.SubkindCrewDecision + }) + if opened.Agent != agent.ID || opened.ID != appCrewAskPrefix+p.ID || len(opened.Path) != 2 || opened.Path[0] != "harbor" || opened.Path[1] != "you" { + t.Fatalf("the ask = %+v", opened) + } + asks, _ := backend.ListAsks(agent.ID) + if len(asks) == 0 || asks[len(asks)-1].ID != opened.ID { + t.Fatalf("the agent's asks = %+v", asks) + } + if err := backend.AnswerAsk(agent.ID, opened.ID, "mon", ""); err != nil { + t.Fatal(err) + } + decided, err := teamstore.PacketByID(backend.profileDir, p.ID) + if err != nil || decided.State != teamstore.PacketDecided || decided.Decision != "mon" || decided.DecidedBy != teamstore.Person { + t.Fatalf("the packet = %+v, %v", decided, err) + } + rec.waitFor(t, 5*time.Second, func(event string, data any) bool { + c, ok := data.(appwire.AskClosed) + return ok && c.ID == opened.ID + }) + rec.waitFor(t, 5*time.Second, func(event string, data any) bool { + e, ok := data.(appwire.EntryAdded) + return ok && e.Entry.ID == "r"+opened.ID && e.Entry.Kind == appwire.EntryReceipt + }) +} + +// A NEW LINE ON A CREW'S LOG IS SAID AS IT LANDS, and closing a crew keeps it +// readable, closed, until it is reopened as it was. +func TestAppCrewLogLinesAndCloseReopen(t *testing.T) { + backend, rec, _, id := crewBackend(t) + backend.crews() + if err := teamstore.AppendTraffic(backend.profileDir, id, teamstore.Entry{Kind: teamstore.KindNote, From: "web", To: teamstore.ToManager, Text: "tests pass"}); err != nil { + t.Fatal(err) + } + var run *appCrewRun + backend.locked(func() { run = backend.crewRun }) + backend.crewTick(run, false) + rec.waitFor(t, 5*time.Second, func(event string, data any) bool { + l, ok := data.(appwire.CrewLogEvent) + return ok && l.Line.Text == "tests pass" && l.Line.Agent == "b0b0b0b0" && l.Line.Crew == id + }) + lines, cursor, err := backend.CrewLog(id, "", 0) + if err != nil || len(lines) == 0 || cursor == "" { + t.Fatalf("the log = %+v %q %v", lines, cursor, err) + } + if err := backend.CrewClose(id); err != nil { + t.Fatal(err) + } + closed, _ := backend.CrewList(true) + if len(closed) != 1 || closed[0].State != appwire.CrewClosed || closed[0].ClosedAt == nil { + t.Fatalf("the closed crews = %+v", closed) + } + open, _ := backend.CrewList(false) + if len(open) != 0 { + t.Fatalf("a closed crew is still open: %+v", open) + } + again, err := backend.CrewReopen(id) + if err != nil || again.State == appwire.CrewClosed || len(again.Members) != 2 { + t.Fatalf("reopened = %+v, %v", again, err) + } + var wire *appwire.Error + if _, err := backend.CrewDelete(id); !errors.As(err, &wire) || wire.Code != appwire.CodeBadParams { + t.Fatalf("an open crew was deleted: %v", err) + } +} + +// A PROPOSAL IS READ FROM THE AGENT'S ONE JSON BLOCK; a helper without a usable +// handle or a brief is left out, and an answer with none is no proposal. +func TestAppProposalIsReadFromTheAgentsAnswer(t *testing.T) { + p, ok := appParseProposal("Here it is:\n```json\n{\"name\": \"release\", \"goal\": \"ship\", \"helpers\": [{\"handle\": \"@Web\", \"role\": \"frontend\", \"brief\": \"fix the page\"}, {\"handle\": \"x\", \"brief\": \"too short\"}]}\n```") + if !ok || p.Name != "release" || len(p.Helpers) != 1 || p.Helpers[0].Handle != "web" || p.Helpers[0].Role != "frontend" { + t.Fatalf("the proposal = %+v, %v", p, ok) + } + if _, ok := appParseProposal("I would rather do it alone."); ok { + t.Fatal("words with no proposal were read as one") + } +} + +// A TINT IS THE NAMED HUE NEAREST THE TEAM'S, and graphite for the root and an +// uncoloured team. +func TestAppTintIsTheNearestNamedHue(t *testing.T) { + cases := []struct { + hue float64 + want string + }{{10, "rose"}, {350, "rose"}, {80, "sand"}, {150, "sage"}, {220, "tide"}, {290, "iris"}} + for _, c := range cases { + team := teamstore.Team{} + team.SetHue(teamstore.HueSpec{Hue: c.hue}) + if got := appTintOf(team); got != c.want { + t.Fatalf("hue %v = %s, want %s", c.hue, got, c.want) + } + } + if got := appTintOf(teamstore.Team{}); got != appTintGraphite { + t.Fatalf("an uncoloured team = %s", got) + } +} diff --git a/cmd/codeaf/appinbox.go b/cmd/codeaf/appinbox.go index ef8b3c638e..0d7c3d3cb2 100644 --- a/cmd/codeaf/appinbox.go +++ b/cmd/codeaf/appinbox.go @@ -24,6 +24,7 @@ import ( "github.com/Agent-Field/codeaf/internal/appwire" "github.com/Agent-Field/codeaf/internal/guard" + teamstore "github.com/Agent-Field/codeaf/internal/teams" ) const ( @@ -196,13 +197,11 @@ func (b *appBackend) inboxWhere(ask appwire.Ask) appwire.InboxWhere { if taskTitle != "" { where.Path = append(where.Path, taskTitle) } - if crews, err := appReadCrews(); err == nil { - for _, crew := range crews { - for _, member := range crew.Members { - if member.Agent == ask.Agent { - where.Crew = crew.ID - where.Path = append([]string{crew.Name}, where.Path...) - } + if f, err := teamstore.Load(b.crewProfile()); err == nil { + for _, t := range f.Open() { + if _, ok := b.crewMemberOf(t, ask.Agent); ok { + where.Crew = t.ID + where.Path = append([]string{t.Name}, where.Path...) } } } @@ -213,6 +212,10 @@ func (b *appBackend) inboxWhere(ask appwire.Ask) appwire.InboxWhere { } where.Path = append(where.Path[:at], append([]string{title}, where.Path[at:]...)...) } + // A crew's decision names the crews it climbed through to the person. + if len(ask.Path) > 0 { + where.Path = append([]string(nil), ask.Path...) + } return where } diff --git a/cmd/codeaf/appwakes.go b/cmd/codeaf/appwakes.go new file mode 100644 index 0000000000..438c3605bb --- /dev/null +++ b/cmd/codeaf/appwakes.go @@ -0,0 +1,179 @@ +package main + +import ( + "strings" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/guard" + "github.com/Agent-Field/codeaf/internal/session" +) + +// ── turns the conversation starts on its own ──────────────────────────────── +// +// A conversation starts a turn nobody typed when something lands that it must +// answer: a task it handed out finishes, a crew's manager sends it a directive, +// a member's report wakes its manager, a member reads its first brief. The +// engine hands each such turn to whoever watches for them — across a +// connection, as a turn that connection did not start — the same stream a sent +// message gets back, and the terminal draws it as any other turn. The app does the same: a woken turn is a turn on +// the wire, with no message of the person's in front of it. +// +// A MEMBER THE PERSON PAUSED does not take one. The crew's app layer keeps the +// pause (appcrewlayer.go), and a turn woken while it holds is stopped at once; +// what woke it is still waiting on the crew's log for its next turn. + +// appWakeDoor is a conversation held in this process that hands over the +// turns it starts itself. +type appWakeDoor interface { + WatchWakes() (<-chan (<-chan session.Event), func()) +} + +// appTurnElsewhere is one turn the app did not start: the words that opened +// it when another window sent some, and its events. +type appTurnElsewhere struct { + said string + events <-chan session.Event +} + +// watchWakes adopts every turn the conversation starts on its own while it is +// held, and answers the function that stops watching. +// +// A CONVERSATION REACHED ACROSS A CONNECTION — every one the app opens — does +// not hand its wakes over as a local subscription: the engine announces each +// as a turn this connection did not start (internal/remote's wakelane.go), the +// same way it announces a turn another window sent. Both are adopted here, and +// a wake held only in this process is read from the session's own lane. +func (b *appBackend) watchWakes(a *appAgent, live *appLive) func() { + if live.conn.Follow != nil { + turns := live.conn.Follow() + stop := make(chan struct{}) + guard.Go("app/wakes", func() { + for { + select { + case <-stop: + return + case turn := <-turns: + // A replay marker carries no turn: the welcome already + // told the app what was running. + if turn.Events == nil { + continue + } + b.wokenTurn(a, live, appTurnElsewhere{said: turn.Said, events: turn.Events}) + } + } + }) + var once sync.Once + return func() { once.Do(func() { close(stop) }) } + } + door, ok := live.conn.Conv.(appWakeDoor) + if !ok { + return func() {} + } + lanes, stop := door.WatchWakes() + guard.Go("app/wakes", func() { + for events := range lanes { + b.wokenTurn(a, live, appTurnElsewhere{events: events}) + } + }) + return stop +} + +// wokenTurn is one turn the app did not start, pumped like any other once the +// turn before it has said its last. +func (b *appBackend) wokenTurn(a *appAgent, live *appLive, elsewhere appTurnElsewhere) { + events := elsewhere.events + var done chan struct{} + b.locked(func() { done = live.pumpDone }) + if done != nil { + <-done + } + turn, n, id, ok := uint64(0), 0, "", false + transcript := "" + var view appwire.Agent + b.locked(func() { + if a.live != live || live.running || live.closed { + return + } + ok = true + id = a.view.ID + // A woken turn answers under the person's last message, as the + // listing names it: its answers follow the ones already there. A turn + // another window sent is a message of the person's of its own. + n = max(a.users-1, 0) + if elsewhere.said != "" { + n = a.users + a.users = n + 1 + } + transcript = a.transcript + live.running, live.stopping = true, false + turn = b.turns.Add(1) + live.turn, live.steps, live.steers = turn, 0, 0 + live.retries, live.retrySaid, live.retryPhase = 0, false, "" + live.pumpDone = make(chan struct{}) + a.view.State = appwire.StateWorking + a.view.UpdatedAt = b.now() + view = a.view + }) + if !ok { + for range events { + } + return + } + // Within this hold the last turn's counts are known; a conversation just + // opened reads its answers from the transcript, and its live notices + // began with this hold. + known := elsewhere.said != "" + if known { + b.locked(func() { live.msgBase, live.noteBase = 0, 0 }) + } + if !known { + b.locked(func() { + if live.lastN == n && (live.lastMsgs > 0 || live.lastNotices > 0) { + live.msgBase, live.noteBase, known = live.lastMsgs, live.lastNotices, true + } + }) + } + if !known { + msgs := appAnswersUnder(id, transcript, n) + b.locked(func() { live.msgBase, live.noteBase = msgs, 0 }) + } + if b.crewPaused(id) { + // The person paused this member: the turn ends before it does anything, + // and the line that woke it stays on the crew's log. + live.conn.Conv.Interrupt() + } + live.box.Event(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) + live.box.Event(appwire.EventTurnStarted, appwire.TurnStarted{Agent: id, Turn: turn}) + if elsewhere.said != "" { + live.box.Event(appwire.EventEntryAdded, appwire.EntryAdded{Entry: appwire.Entry{ + ID: appUserEntryID(n), Agent: id, Turn: turn, Kind: appwire.EntryUser, At: b.now(), Text: elsewhere.said, + }}) + } + long := b.watchLong(id, live, turn, b.now()) + b.newTurnCalls(a) + tails := b.tickTools(a, id, live) + defer long.Stop() + defer tails() + b.pump(a, id, live, turn, n, elsewhere.said, events) + b.sendQueued(a, live) +} + +// appAnswersUnder counts the answers the transcript already holds under the +// person's message n, so a woken turn's answers are named after them rather +// than over them. +func appAnswersUnder(agent, transcript string, n int) int { + if strings.TrimSpace(transcript) == "" { + return 0 + } + items := appRecordItems(session.ReadTranscript(transcript)) + prefix := strings.TrimSuffix(appAnswerEntryID(n, 0), "0") + answers := 0 + for _, entry := range appEntriesOf(agent, items, time.Time{}, nil) { + if strings.HasPrefix(entry.ID, prefix) { + answers++ + } + } + return answers +} diff --git a/docs/changes/unreleased/0-app-wave5-crews.md b/docs/changes/unreleased/0-app-wave5-crews.md new file mode 100644 index 0000000000..a7ec4adc5e --- /dev/null +++ b/docs/changes/unreleased/0-app-wave5-crews.md @@ -0,0 +1,18 @@ +--- +kind: added +title: codeaf app serves crews as codeaf teams, with an app-only layer beside them +pr: 0 +surface: [engine] +--- + +Was true: the app listed teams as crews with only ids, names and handles, and a +turn a conversation started on its own (a team wake, a task landing) never +reached the app. + +Is true now: `crews.*`, `members.*` and `roles.*` read and write the teams store +itself (teams file, Traffic log, decision packets, caps, close and reopen), and +the app performs a manager's starts and stops for the crews it holds, keeping +open crews' members held. Decisions waiting on the person are asks with the +path they climbed. Goals, end dates, linked items, roles, member budgets, +pauses and agent proposals live in `/app/crews.json`, never in the teams +file. Every woken turn is now a turn on the wire. diff --git a/internal/appwire/crews.go b/internal/appwire/crews.go new file mode 100644 index 0000000000..2e761337c8 --- /dev/null +++ b/internal/appwire/crews.go @@ -0,0 +1,705 @@ +package appwire + +import ( + "encoding/json" + "strings" + "time" +) + +// Wave 5: crews (PROTOCOL.md). A crew IS a codeaf team: its fields are the +// team's, read from the same store, renamed only where the app's words differ. +// The few the design needs and teams do not keep are the app layer's, and are +// commented so here. + +// The crews' events. +const ( + EventCrewLog = "crew.log" + EventDecisionOpened = "decision.opened" + EventDecisionChanged = "decision.changed" + EventMemberChanged = "member.changed" +) + +// A crew's states. +const ( + CrewWorking = "working" + CrewNeedsYou = "needs_you" + CrewIdle = "idle" + CrewClosed = "closed" +) + +// SubkindCrewDecision is a decision packet waiting on the person, as an ask; +// SubkindCrewProposal is an agent's crew proposal (app layer). +const ( + SubkindCrewDecision = "crew_decision" + SubkindCrewProposal = "crew_proposal" +) + +// The answers a crew proposal offers. +const ( + ProposalCreate = "create" + ProposalEdit = "edit" + ProposalKeep = "keep" +) + +// CrewSettings are a crew's own overrides, each nil when it inherits. +type CrewSettings struct { + QuestionsUp *bool `json:"questions_up,omitempty"` + CapUSDDay *float64 `json:"cap_usd_day,omitempty"` + DepthLimit *int `json:"depth_limit,omitempty"` + SubShare *float64 `json:"sub_share,omitempty"` + Wake *bool `json:"wake,omitempty"` +} + +// CrewOrigin is where an effective setting comes from. +type CrewOrigin struct { + Kind string `json:"kind"` + Crew string `json:"crew,omitempty"` + Name string `json:"name,omitempty"` +} + +// CrewEffective is every setting in force, each with its origin. +type CrewEffective struct { + QuestionsUp bool `json:"questions_up"` + QuestionsUpFrom CrewOrigin `json:"questions_up_from"` + CapUSDDay float64 `json:"cap_usd_day"` + CapFrom CrewOrigin `json:"cap_from"` + DepthLimit int `json:"depth_limit"` + DepthFrom CrewOrigin `json:"depth_from"` + SubShare float64 `json:"sub_share"` + SubShareFrom CrewOrigin `json:"sub_share_from"` + Wake bool `json:"wake"` + WakeFrom CrewOrigin `json:"wake_from"` +} + +// CrewDefaults are the five profile defaults every crew inherits from. +type CrewDefaults struct { + QuestionsUp bool `json:"questions_up"` + CapUSDDay float64 `json:"cap_usd_day"` + DepthLimit int `json:"depth_limit"` + SubShare float64 `json:"sub_share"` + Wake bool `json:"wake"` +} + +// CrewSpend is a crew's spending today, every crew under it counted. +type CrewSpend struct { + Day string `json:"day"` + USD float64 `json:"usd"` + Calls int `json:"calls"` + ByMember map[string]float64 `json:"by_member,omitempty"` +} + +// CrewWrap is a wrap-up in progress. +type CrewWrap struct { + Started time.Time `json:"started"` + BoundS int `json:"bound_s"` +} + +// CrewItem is one linked job, issue, pull request or file (app layer). +type CrewItem struct { + ID string `json:"id"` + Crew string `json:"crew"` + Kind string `json:"kind"` + Ref string `json:"ref"` + Title string `json:"title,omitempty"` + At time.Time `json:"at"` +} + +// Crew is one team. +type Crew struct { + ID string `json:"id"` + Name string `json:"name"` + Parent string `json:"parent,omitempty"` + Root bool `json:"root,omitempty"` + Manager string `json:"manager,omitempty"` + Hue float64 `json:"hue"` + Tier int `json:"tier"` + Tint string `json:"tint"` + MadeAt *time.Time `json:"made_at,omitempty"` + State string `json:"state"` + ClosedAt *time.Time `json:"closed_at,omitempty"` + ClosedWith string `json:"closed_with,omitempty"` + Report string `json:"report,omitempty"` + Wrap *CrewWrap `json:"wrap,omitempty"` + Settings CrewSettings `json:"settings"` + Effective CrewEffective `json:"effective"` + Spend CrewSpend `json:"spend"` + Members []CrewMember `json:"members"` + FormerMembers []CrewMember `json:"former_members,omitempty"` + Brief string `json:"brief,omitempty"` + // The app layer's. + Goal string `json:"goal,omitempty"` + EndsAt *time.Time `json:"ends_at,omitempty"` + History []string `json:"history,omitempty"` + Items []CrewItem `json:"items,omitempty"` +} + +// CrewReportsTo is the manager a member answers to. +type CrewReportsTo struct { + Crew string `json:"crew"` + Manager string `json:"manager"` + Distance int `json:"distance"` +} + +// MemberWas is what a member was before its role or crew changed (app layer). +type MemberWas struct { + Role string `json:"role,omitempty"` + Crew string `json:"crew,omitempty"` + At time.Time `json:"at"` +} + +// CrewMember is one conversation in a crew. +type CrewMember struct { + Agent string `json:"agent"` + Crew string `json:"crew,omitempty"` + Handle string `json:"handle,omitempty"` + HandleBy string `json:"handle_by,omitempty"` + Title string `json:"title,omitempty"` + Workspace string `json:"workspace,omitempty"` + JoinedAt *time.Time `json:"joined_at,omitempty"` + Started bool `json:"started,omitempty"` + Home bool `json:"home,omitempty"` + Independent bool `json:"independent,omitempty"` + ReportsTo *CrewReportsTo `json:"reports_to,omitempty"` + AlsoIn []string `json:"also_in,omitempty"` + State string `json:"state,omitempty"` + Line string `json:"line,omitempty"` + SpentUSD float64 `json:"spent_usd"` + Model string `json:"model,omitempty"` + // The app layer's. + Role string `json:"role,omitempty"` + CapUSD *float64 `json:"cap_usd,omitempty"` + Paused bool `json:"paused,omitempty"` + Was *MemberWas `json:"was,omitempty"` +} + +// LogLine is one line of a crew's Traffic, as stored. +type LogLine struct { + ID string `json:"id"` + Crew string `json:"crew"` + At time.Time `json:"at"` + Kind string `json:"kind"` + From string `json:"from"` + To string `json:"to"` + Text string `json:"text"` + Handles []string `json:"handles,omitempty"` + Answers string `json:"answers,omitempty"` + Files []string `json:"files,omitempty"` + Member string `json:"member,omitempty"` + Agent string `json:"agent,omitempty"` + State string `json:"state,omitempty"` + Packet string `json:"packet,omitempty"` + Reply string `json:"reply,omitempty"` + Team string `json:"team,omitempty"` +} + +// DecisionParty is one side of a decision. +type DecisionParty struct { + Agent string `json:"agent,omitempty"` + Handle string `json:"handle,omitempty"` + Crew string `json:"crew,omitempty"` + Context string `json:"context,omitempty"` +} + +// DecisionOption is one answer and what follows from it. +type DecisionOption struct { + ID string `json:"id"` + Label string `json:"label"` + Consequence string `json:"consequence"` +} + +// DecisionPick is the option the raiser would choose, and why. +type DecisionPick struct { + Option string `json:"option"` + Reason string `json:"reason"` +} + +// DecisionHop is one climb of a decision. +type DecisionHop struct { + From string `json:"from"` + To string `json:"to"` + By string `json:"by"` + Reason string `json:"reason,omitempty"` + At time.Time `json:"at"` +} + +// CrewReport is a closing report. +type CrewReport struct { + Done string `json:"done"` + Left string `json:"left,omitempty"` + Files []string `json:"files,omitempty"` + SpendUSD float64 `json:"spend_usd,omitempty"` + Incomplete bool `json:"incomplete,omitempty"` +} + +// CrewCap is a cap decision's facts. +type CrewCap struct { + Crew string `json:"crew"` + Day string `json:"day"` + CapUSD float64 `json:"cap_usd"` + SpentUSD float64 `json:"spent_usd"` + RaiseTo float64 `json:"raise_to"` +} + +// Decision is one decision packet, as stored. +type Decision struct { + ID string `json:"id"` + Crew string `json:"crew"` + WaitsOn string `json:"waits_on"` + Kind string `json:"kind"` + RaisedBy string `json:"raised_by"` + Parties []DecisionParty `json:"parties,omitempty"` + Question string `json:"question"` + Options []DecisionOption `json:"options,omitempty"` + Recommendation *DecisionPick `json:"recommendation,omitempty"` + Report *CrewReport `json:"report,omitempty"` + Cap *CrewCap `json:"cap,omitempty"` + State string `json:"state"` + DecidedBy string `json:"decided_by,omitempty"` + Decision string `json:"decision,omitempty"` + Reason string `json:"reason,omitempty"` + Trail []DecisionHop `json:"trail,omitempty"` + Path []string `json:"path"` + Raised time.Time `json:"raised"` + At time.Time `json:"at"` +} + +// RoleCopy is one member a role is given to. +type RoleCopy struct { + Crew string `json:"crew"` + Agent string `json:"agent"` +} + +// Role is a named set of instructions and a model (app layer). +type Role struct { + ID string `json:"id"` + Name string `json:"name"` + Instructions string `json:"instructions"` + Model string `json:"model,omitempty"` + MadeAt time.Time `json:"made_at"` + Copies []RoleCopy `json:"copies"` +} + +// ProposalHelper is one helper a proposal would start. +type ProposalHelper struct { + Handle string `json:"handle"` + Role string `json:"role,omitempty"` + Brief string `json:"brief"` + Model string `json:"model,omitempty"` +} + +// Proposal is an agent's proposal to become a crew (app layer). +type Proposal struct { + ID string `json:"id"` + Agent string `json:"agent"` + Name string `json:"name"` + Hue *float64 `json:"hue,omitempty"` + Helpers []ProposalHelper `json:"helpers"` + Existing []string `json:"existing,omitempty"` + CapUSDDay *float64 `json:"cap_usd_day,omitempty"` + Goal string `json:"goal,omitempty"` + EndsAt *time.Time `json:"ends_at,omitempty"` + At time.Time `json:"at"` +} + +// The data of the crews' events. +type ( + CrewLogEvent struct { + Line LogLine `json:"line"` + } + DecisionEvent struct { + Decision Decision `json:"decision"` + } + MemberChanged struct { + Member CrewMember `json:"member"` + } +) + +// CrewCreate is crews.create's params: from agents, or from a proposal. +type CrewCreate struct { + From string `json:"from"` + Name string `json:"name"` + Agents []string `json:"agents"` + Manager string `json:"manager"` + Parent string `json:"parent"` + Hue *float64 `json:"hue"` + Tint string `json:"tint"` + Settings *CrewSettings `json:"settings"` + Goal string `json:"goal"` + EndsAt *time.Time `json:"ends_at"` + Proposal string `json:"proposal"` + Edits *Proposal `json:"edits"` + CapUSDDay *float64 `json:"cap_usd_day"` +} + +// CrewUpdate is crews.update's params; a nil field is left alone. +type CrewUpdate struct { + ID string `json:"id"` + Name *string `json:"name"` + Hue *float64 `json:"hue"` + Tint *string `json:"tint"` + Settings *CrewSettings `json:"settings"` + // Clear names settings to set back to inherit. + Clear []string `json:"clear"` + Goal *string `json:"goal"` + EndsAt *time.Time `json:"ends_at"` +} + +// MemberAsk is the params of the member verbs. +type MemberAsk struct { + Crew string `json:"crew"` + Agent string `json:"agent"` + Handle string `json:"handle"` + Brief string `json:"brief"` + Role string `json:"role"` + Model string `json:"model"` + Reason string `json:"reason"` + CapUSD *float64 `json:"cap_usd"` + To string `json:"to"` +} + +// CrewMessage is crews.message's params. +type CrewMessage struct { + Crew string `json:"crew"` + To string `json:"to"` + Text string `json:"text"` + ClientID string `json:"client_id"` +} + +// Crewer is the engine's half of crews. +type Crewer interface { + CrewList(closed bool) ([]Crew, error) + CrewGet(id string) (Crew, error) + CrewCreate(ask CrewCreate) (Crew, error) + CrewUpdate(ask CrewUpdate) (Crew, error) + CrewMessage(ask CrewMessage) (Entry, error) + CrewLog(id, after string, limit int) ([]LogLine, string, error) + CrewDecisions(id string, all bool) ([]Decision, error) + CrewDecide(packet, option, text, reason string) (Decision, error) + CrewManager(id, agent string) (Crew, error) + CrewMove(ids []string, parent string) error + CrewWrapUp(id, text string) error + CrewClose(id string) error + CrewReopen(id string) (Crew, error) + CrewDelete(id string) ([]string, error) + CrewDefaults() (CrewDefaults, error) + CrewSetDefault(key, value string) (CrewDefaults, error) + CrewItems(id string) ([]CrewItem, error) + CrewLink(id, kind, ref, title string) (CrewItem, error) + CrewUnlink(id, item string) error + CrewPropose(agent string) error + Member(verb string, ask MemberAsk) (CrewMember, error) + Roles() ([]Role, error) + RoleUpdate(id string, name, instructions, model *string) (Role, error) +} + +func init() { + for name, m := range crewMethods { + methods[name] = m + } +} + +func crewer(e Engine, name string) (Crewer, error) { + c, ok := e.(Crewer) + if !ok { + return nil, Errorf(CodeUnknownMethod, "this engine does not serve %s", name) + } + return c, nil +} + +// crewCall decodes a method's params into P and answers what call answers. +func crewCall[P any](name string, call func(Crewer, P) (any, error)) method { + return func(e Engine, raw json.RawMessage) (any, error) { + c, err := crewer(e, name) + if err != nil { + return nil, err + } + var p P + if len(raw) > 0 && string(raw) != "null" { + if err := decode(raw, &p); err != nil { + return nil, err + } + } + return call(c, p) + } +} + +type crewID struct { + ID string `json:"id"` +} + +func needID(name, id string) error { + if strings.TrimSpace(id) == "" { + return Errorf(CodeBadParams, "%s needs an id", name) + } + return nil +} + +type crewResult struct { + Crew Crew `json:"crew"` +} + +func crewOf(c Crew, err error) (any, error) { + if err != nil { + return nil, err + } + c.Members = nonNil(c.Members) + return crewResult{c}, nil +} + +func memberOf(m CrewMember, err error) (any, error) { + if err != nil { + return nil, err + } + return struct { + Member CrewMember `json:"member"` + }{m}, nil +} + +// memberVerb is one member verb. +func memberVerb(verb string) method { + return crewCall("members."+verb, func(c Crewer, p MemberAsk) (any, error) { + if strings.TrimSpace(p.Crew) == "" { + return nil, Errorf(CodeBadParams, "members.%s needs a crew", verb) + } + return memberOf(c.Member(verb, p)) + }) +} + +var crewMethods = map[string]method{ + "crews.get": crewCall("crews.get", func(c Crewer, p crewID) (any, error) { + if err := needID("crews.get", p.ID); err != nil { + return nil, err + } + return crewOf(c.CrewGet(p.ID)) + }), + "crews.create": crewCall("crews.create", func(c Crewer, p CrewCreate) (any, error) { + return crewOf(c.CrewCreate(p)) + }), + "crews.update": crewCall("crews.update", func(c Crewer, p CrewUpdate) (any, error) { + if err := needID("crews.update", p.ID); err != nil { + return nil, err + } + return crewOf(c.CrewUpdate(p)) + }), + "crews.message": crewCall("crews.message", func(c Crewer, p CrewMessage) (any, error) { + if strings.TrimSpace(p.Crew) == "" || strings.TrimSpace(p.Text) == "" { + return nil, Errorf(CodeBadParams, "crews.message needs a crew and some text") + } + entry, err := c.CrewMessage(p) + if err != nil { + return nil, err + } + return struct { + Entry Entry `json:"entry"` + }{entry}, nil + }), + "crews.log": crewCall("crews.log", func(c Crewer, p struct { + ID string `json:"id"` + After string `json:"after"` + Limit int `json:"limit"` + }) (any, error) { + if err := needID("crews.log", p.ID); err != nil { + return nil, err + } + lines, cursor, err := c.CrewLog(p.ID, p.After, p.Limit) + if err != nil { + return nil, err + } + return struct { + Lines []LogLine `json:"lines"` + Cursor string `json:"cursor"` + }{nonNil(lines), cursor}, nil + }), + "crews.decisions": crewCall("crews.decisions", func(c Crewer, p struct { + ID string `json:"id"` + All bool `json:"all"` + }) (any, error) { + decisions, err := c.CrewDecisions(p.ID, p.All) + if err != nil { + return nil, err + } + return struct { + Decisions []Decision `json:"decisions"` + }{nonNil(decisions)}, nil + }), + "crews.decide": crewCall("crews.decide", func(c Crewer, p struct { + Packet string `json:"packet"` + Option string `json:"option"` + Text string `json:"text"` + Reason string `json:"reason"` + }) (any, error) { + if strings.TrimSpace(p.Packet) == "" || (strings.TrimSpace(p.Option) == "" && strings.TrimSpace(p.Text) == "") { + return nil, Errorf(CodeBadParams, "crews.decide needs a packet and an option or some words") + } + d, err := c.CrewDecide(p.Packet, p.Option, p.Text, p.Reason) + if err != nil { + return nil, err + } + return struct { + Decision Decision `json:"decision"` + }{d}, nil + }), + "crews.manager": crewCall("crews.manager", func(c Crewer, p struct { + ID string `json:"id"` + Agent string `json:"agent"` + }) (any, error) { + if err := needID("crews.manager", p.ID); err != nil { + return nil, err + } + return crewOf(c.CrewManager(p.ID, p.Agent)) + }), + "crews.move": crewCall("crews.move", func(c Crewer, p struct { + IDs []string `json:"ids"` + Parent string `json:"parent"` + }) (any, error) { + if len(p.IDs) == 0 { + return nil, Errorf(CodeBadParams, "crews.move needs ids") + } + return struct{}{}, c.CrewMove(p.IDs, p.Parent) + }), + "crews.wrap_up": crewCall("crews.wrap_up", func(c Crewer, p struct { + ID string `json:"id"` + Text string `json:"text"` + }) (any, error) { + if err := needID("crews.wrap_up", p.ID); err != nil { + return nil, err + } + return struct{}{}, c.CrewWrapUp(p.ID, p.Text) + }), + "crews.close": crewCall("crews.close", func(c Crewer, p crewID) (any, error) { + if err := needID("crews.close", p.ID); err != nil { + return nil, err + } + return struct{}{}, c.CrewClose(p.ID) + }), + "crews.reopen": crewCall("crews.reopen", func(c Crewer, p crewID) (any, error) { + if err := needID("crews.reopen", p.ID); err != nil { + return nil, err + } + return crewOf(c.CrewReopen(p.ID)) + }), + "crews.delete": crewCall("crews.delete", func(c Crewer, p crewID) (any, error) { + if err := needID("crews.delete", p.ID); err != nil { + return nil, err + } + ids, err := c.CrewDelete(p.ID) + if err != nil { + return nil, err + } + return struct { + IDs []string `json:"ids"` + }{nonNil(ids)}, nil + }), + "crews.defaults": crewCall("crews.defaults", func(c Crewer, _ struct{}) (any, error) { + d, err := c.CrewDefaults() + if err != nil { + return nil, err + } + return struct { + Defaults CrewDefaults `json:"defaults"` + }{d}, nil + }), + "crews.set_default": crewCall("crews.set_default", func(c Crewer, p struct { + Key string `json:"key"` + Value string `json:"value"` + }) (any, error) { + if strings.TrimSpace(p.Key) == "" { + return nil, Errorf(CodeBadParams, "crews.set_default needs a key") + } + d, err := c.CrewSetDefault(p.Key, p.Value) + if err != nil { + return nil, err + } + return struct { + Defaults CrewDefaults `json:"defaults"` + }{d}, nil + }), + "crews.items": crewCall("crews.items", func(c Crewer, p crewID) (any, error) { + if err := needID("crews.items", p.ID); err != nil { + return nil, err + } + items, err := c.CrewItems(p.ID) + if err != nil { + return nil, err + } + return struct { + Items []CrewItem `json:"items"` + }{nonNil(items)}, nil + }), + "crews.link": crewCall("crews.link", func(c Crewer, p struct { + ID string `json:"id"` + Kind string `json:"kind"` + Ref string `json:"ref"` + Title string `json:"title"` + }) (any, error) { + if strings.TrimSpace(p.ID) == "" || strings.TrimSpace(p.Ref) == "" { + return nil, Errorf(CodeBadParams, "crews.link needs an id and a ref") + } + switch p.Kind { + case "job", "issue", "pr", "file": + default: + return nil, Errorf(CodeBadParams, "kind is job, issue, pr or file") + } + item, err := c.CrewLink(p.ID, p.Kind, p.Ref, p.Title) + if err != nil { + return nil, err + } + return struct { + Item CrewItem `json:"item"` + }{item}, nil + }), + "crews.unlink": crewCall("crews.unlink", func(c Crewer, p struct { + ID string `json:"id"` + Item string `json:"item"` + }) (any, error) { + if strings.TrimSpace(p.ID) == "" || strings.TrimSpace(p.Item) == "" { + return nil, Errorf(CodeBadParams, "crews.unlink needs an id and an item") + } + return struct{}{}, c.CrewUnlink(p.ID, p.Item) + }), + "crews.propose": crewCall("crews.propose", func(c Crewer, p struct { + Agent string `json:"agent"` + }) (any, error) { + if strings.TrimSpace(p.Agent) == "" { + return nil, Errorf(CodeBadParams, "crews.propose needs an agent") + } + return struct{}{}, c.CrewPropose(p.Agent) + }), + "members.add": memberVerb("add"), + "members.start": memberVerb("start"), + "members.stop": memberVerb("stop"), + "members.remove": memberVerb("remove"), + "members.handle": memberVerb("handle"), + "members.home": memberVerb("home"), + "members.pause": memberVerb("pause"), + "members.resume": memberVerb("resume"), + "members.cap": memberVerb("cap"), + "members.role": memberVerb("role"), + "members.move": memberVerb("move"), + "roles.list": crewCall("roles.list", func(c Crewer, _ struct{}) (any, error) { + roles, err := c.Roles() + if err != nil { + return nil, err + } + return struct { + Roles []Role `json:"roles"` + }{nonNil(roles)}, nil + }), + "roles.update": crewCall("roles.update", func(c Crewer, p struct { + ID string `json:"id"` + Name *string `json:"name"` + Instructions *string `json:"instructions"` + Model *string `json:"model"` + }) (any, error) { + if err := needID("roles.update", p.ID); err != nil { + return nil, err + } + role, err := c.RoleUpdate(p.ID, p.Name, p.Instructions, p.Model) + if err != nil { + return nil, err + } + return struct { + Role Role `json:"role"` + }{role}, nil + }), +} diff --git a/internal/appwire/server.go b/internal/appwire/server.go index ad4cdfa32d..b931c7d67c 100644 --- a/internal/appwire/server.go +++ b/internal/appwire/server.go @@ -513,7 +513,21 @@ var methods = map[string]method{ }{nonNil(tasks)}, nil }, "crews.list": func(e Engine, raw json.RawMessage) (any, error) { - crews, err := e.ListCrews() + var p struct { + Closed bool `json:"closed"` + } + if len(raw) > 0 && string(raw) != "null" { + if err := decode(raw, &p); err != nil { + return nil, err + } + } + var crews []Crew + var err error + if c, ok := e.(Crewer); ok { + crews, err = c.CrewList(p.Closed) + } else { + crews, err = e.ListCrews() + } if err != nil { return nil, err } diff --git a/internal/appwire/wire.go b/internal/appwire/wire.go index 4c107b9559..a33ca19ddc 100644 --- a/internal/appwire/wire.go +++ b/internal/appwire/wire.go @@ -111,6 +111,8 @@ type Agent struct { Effort string `json:"effort,omitempty"` State string `json:"state"` UpdatedAt time.Time `json:"updated_at"` + // Crews are the crews this agent is a member of, open ones only. + Crews []string `json:"crews,omitempty"` } // Task is one node of an agent's task graph. @@ -118,9 +120,12 @@ type Task struct { ID string `json:"id"` Agent string `json:"agent"` // Parent is the task that asked for this one, and empty for a task the - // agent itself started; Group names the adaptive run a task belongs to. - Parent string `json:"parent,omitempty"` - Group string `json:"group,omitempty"` + // agent itself started; Group names the adaptive run a task belongs to, + // and GroupTitle is that run's own name, the label a person reads over + // the group: the title of the run's own row. + Parent string `json:"parent,omitempty"` + Group string `json:"group,omitempty"` + GroupTitle string `json:"group_title,omitempty"` Title string `json:"title"` State string `json:"state"` UpdatedAt time.Time `json:"updated_at"` @@ -159,14 +164,6 @@ type Task struct { FinishedAt *time.Time `json:"finished_at,omitempty"` } -// Crew is one codeaf team, with its members named by agent id. -type Crew struct { - ID string `json:"id"` - Name string `json:"name"` - Manager string `json:"manager,omitempty"` - Members []CrewMember `json:"members"` -} - // The two kinds of ask, and who closed one. const ( AskApproval = "approval" @@ -204,6 +201,9 @@ type Ask struct { HoldingUp []string `json:"holding_up,omitempty"` SayWhy bool `json:"say_why,omitempty"` Later bool `json:"later,omitempty"` + // Path is the crews a crew's decision climbed through to the person, + // "Backend → Release → you". + Path []string `json:"path,omitempty"` } // The subkinds of an approval, and the option ids a permission offers. @@ -449,12 +449,6 @@ type AskOption struct { Label string `json:"label"` } -// CrewMember is one conversation in a crew. -type CrewMember struct { - Agent string `json:"agent"` - Handle string `json:"handle,omitempty"` -} - // The data of each event, one struct per name so the field spellings live in // one place. type ( diff --git a/internal/manual/chat/running-from-the-terminal.md b/internal/manual/chat/running-from-the-terminal.md index 70dc2b59df..13490a4408 100644 --- a/internal/manual/chat/running-from-the-terminal.md +++ b/internal/manual/chat/running-from-the-terminal.md @@ -375,6 +375,26 @@ the conversation, on a task's page or in the terminal, leaves the Inbox at once with its receipt. An agent with a question open is kept open until the question is answered. +## The desktop app's crews — they are your teams + +A crew in the desktop app is a codeaf team: the same teams, members, handles, +manager, sub-teams, daily caps, team log and decisions you see in the Teams +page, under another name. A crew made in the app is a team in the terminal, +and the other way round. While the app is open it does for a crew what a +terminal window does for a team it holds: it opens the member a manager starts +with `team_start` and stops the one a manager stops, and it keeps every member +of an open crew open, so a member woken by its manager is seen working. + +A question that climbs to you, a cap reached and a closing report arrive in the +app's dock and Inbox as decisions; your answer decides them for the team. +Closing a crew does not stop its conversations. + +A few things exist only in the app and the terminal never shows them: a crew's +goal and end date, linked jobs, issues and pull requests, roles (instructions +and a model sent to a member as a message), a member's own budget, pausing a +member (its own turns are stopped until you resume it), and an agent's +proposal to bring in helpers. + ## Connect from the terminal without opening the chat — codeaf connect and codeaf disconnect `codeaf connect` lists every model provider this profile knows, whether it is connected, From b0c36df16fb585eafbe06f86391357da1684a01a Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Thu, 15 Oct 2026 11:56:11 -0400 Subject: [PATCH 013/459] automations: proposed and stopped, the trust ladder, and runs that ask first An automation can be saved as proposed, which the clock never takes until Store.Ratify; stopped is for good. Work that is not yet trusted parks each run in the new asking phase until the person answers go, skip or not right; five go's make it trusted and one not right resets it. Each automation also carries where its results are said, its crew and what it must not do. --- internal/automation/automation.go | 44 +++++- internal/automation/clock.go | 9 ++ internal/automation/store.go | 31 +++- internal/automation/trust.go | 244 ++++++++++++++++++++++++++++++ internal/automation/trust_test.go | 160 ++++++++++++++++++++ 5 files changed, 482 insertions(+), 6 deletions(-) create mode 100644 internal/automation/trust.go create mode 100644 internal/automation/trust_test.go diff --git a/internal/automation/automation.go b/internal/automation/automation.go index dd6493059d..3ef8b935a2 100644 --- a/internal/automation/automation.go +++ b/internal/automation/automation.go @@ -72,6 +72,13 @@ const ( // StatusFinished is a one-time automation that has run, or a once-watch // that has spoken. It stays in the list with its history. StatusFinished Status = "finished" + // StatusProposed is a standing order an agent proposed and the person has + // not ratified. NOTHING RUNS BEFORE RATIFY: it is in the list and wakes for + // nothing until [Store.Ratify]. + StatusProposed Status = "proposed" + // StatusStopped is one the person stopped for good. It stays in the list + // with its history and wakes for nothing; it is not resumed. + StatusStopped Status = "stopped" ) // Automation is one thing codeaf does on a clock. The top half is what the @@ -114,6 +121,21 @@ type Automation struct { // opaque to everything else. Memo string `json:"memo,omitempty"` + // Trust is how far the person lets it run alone (trust.go): every run + // asked first, earning trust over Approved runs, or trusted. Empty is + // trusted, which every automation saved before the ladder existed is. + Trust Trust `json:"trust,omitempty"` + Approved int `json:"approved,omitempty"` + // Granted is the run the person last said go to, which the clock then + // starts without asking again. + Granted int64 `json:"granted,omitempty"` + // AnswersIn is where its results are said: the chat it was made in, the + // crew, everywhere, or nowhere but its own row. Empty is the chat. + AnswersIn Scope `json:"answersIn,omitempty"` + // Crew is the crew it belongs to, and Wont what it is told never to do. + Crew string `json:"crew,omitempty"` + Wont string `json:"wont,omitempty"` + // Last is the most recent run that ended, filled in by [Store.List] and // [Store.Get] from the runs table so a list can say what each one last came // to without asking once per row. IT IS NEVER STORED IN THE DOCUMENT: the @@ -237,10 +259,20 @@ func (a Automation) Validate() error { return errors.New("a limit cannot be negative") } switch a.Status { - case "", StatusActive, StatusPaused, StatusFinished: + case "", StatusActive, StatusPaused, StatusFinished, StatusProposed, StatusStopped: default: return fmt.Errorf("unknown status %q", a.Status) } + switch a.Trust { + case "", TrustAsksFirst, TrustEarning, TrustTrusted: + default: + return fmt.Errorf("unknown trust %q", a.Trust) + } + switch a.AnswersIn { + case "", ScopeChat, ScopeCrew, ScopeEverywhere, ScopeNotHere: + default: + return fmt.Errorf("unknown place to answer in %q", a.AnswersIn) + } return nil } @@ -253,6 +285,9 @@ const ( PhaseQueued Phase = "queued" PhaseRunning Phase = "running" PhaseOver Phase = "over" + // PhaseAsking is a run waiting for the person's go before it starts, + // because its automation is not yet trusted to run alone (trust.go). + PhaseAsking Phase = "asking" ) // Outcome is what a run came to. The words a person reads are [Outcome.Word]. @@ -275,6 +310,9 @@ const ( // OutcomeUnchecked is a watch whose look or judgment could not be made. // It changes nothing about what the watch has seen. OutcomeUnchecked Outcome = "unchecked" + // OutcomeSkipped is a run the person was asked about and passed over, or + // said was not right. + OutcomeSkipped Outcome = "skipped" ) // Word is the outcome as a person reads it. @@ -292,6 +330,8 @@ func (o Outcome) Word() string { return "stopped" case OutcomeUnchecked: return "couldn't check" + case OutcomeSkipped: + return "skipped" } return "" } @@ -300,7 +340,7 @@ func (o Outcome) Word() string { // its conversation and a notification. A quiet look is the one that is not — // QUIET IS THE DESIGN, and a watch that checked faithfully all day says so on // its row, not in a stream of "nothing new". -func (o Outcome) Delivered() bool { return o != OutcomeQuiet && o != "" } +func (o Outcome) Delivered() bool { return o != OutcomeQuiet && o != OutcomeSkipped && o != "" } // Why says what woke a run. type Why string diff --git a/internal/automation/clock.go b/internal/automation/clock.go index d614aa34a2..688e4cdeca 100644 --- a/internal/automation/clock.go +++ b/internal/automation/clock.go @@ -360,6 +360,15 @@ func (c *Clock) start(ctx context.Context, run Run) { } return } + // WORK NOT YET TRUSTED WAITS FOR THE PERSON'S GO (trust.go), unless this + // is the run they already said go to. + if a.NeedsAsk() && a.Granted != run.ID { + c.mu.Unlock() + if err := c.Store.AskRun(run.ID); err != nil { + c.log("could not ask about a run of " + a.Title + ": " + err.Error()) + } + return + } isWork := a.Action.Do != "" switch { case isWork && c.work >= MaxWork: diff --git a/internal/automation/store.go b/internal/automation/store.go index 6224fd85ee..7a1e5ad71a 100644 --- a/internal/automation/store.go +++ b/internal/automation/store.go @@ -217,7 +217,11 @@ func (s *Store) Create(a Automation) (Automation, error) { if a.ID == "" { a.ID = newID() } - a.Status = StatusActive + // A PROPOSED ONE STAYS PROPOSED: it is saved so it can be shown and + // changed, and nothing runs before the person ratifies it (trust.go). + if a.Status != StatusProposed { + a.Status = StatusActive + } a.Created, a.Updated = now, now a.Revision = 1 a.Seen, a.Memo = "", "" @@ -337,6 +341,10 @@ func (s *Store) Update(a Automation) (Automation, error) { } a.Workspace = workspace a.Status, a.Next, a.Seen, a.Memo = current.Status, current.Next, current.Seen, current.Memo + // THE LADDER IS THE STORE'S TO MOVE, never an edit's: a changed brief + // keeps the trust it had, and only a verdict or the person's own + // trust setting moves it. + a.Trust, a.Approved, a.Granted = current.Trust, current.Approved, current.Granted if lookChanged(current.Look, a.Look) { a.Seen, a.Memo = "", "" } @@ -398,6 +406,13 @@ func (s *Store) SetStatus(id string, status Status) (Automation, error) { saved = a return nil } + // A PROPOSED ONE IS RATIFIED, NEVER RESUMED, and a stopped one is over. + switch a.Status { + case StatusProposed: + return fmt.Errorf("%s has not been agreed to yet", a.Title) + case StatusStopped: + return fmt.Errorf("%s was stopped", a.Title) + } a.Status = status if status == StatusActive { if a.Schedule.Repeats() { @@ -655,12 +670,20 @@ func (s *Store) advance(a Automation, next time.Time) error { // QueueNow asks for a run outside the schedule. Any window may ask; the clock // takes it on its next look, within seconds. -func (s *Store) QueueNow(id string) (Run, error) { +func (s *Store) QueueNow(id string) (Run, error) { return s.queueNow(id, PhaseQueued) } + +// QueueAsking asks for a run outside the schedule that waits for the person's +// go before the clock may start it: the person's own "run it now" on work not +// yet trusted, answered with [Store.Answer] at once. Queued any other way, +// the clock could start it between the two writes. +func (s *Store) QueueAsking(id string) (Run, error) { return s.queueNow(id, PhaseAsking) } + +func (s *Store) queueNow(id string, phase Phase) (Run, error) { if _, err := s.Get(id); err != nil { return Run{}, err } now := s.now() - run := Run{AutomationID: id, Due: now, Why: WhyNow, Phase: PhaseQueued} + run := Run{AutomationID: id, Due: now, Why: WhyNow, Phase: phase} err := s.write(func(tx *sql.Tx, seq int64) error { var waiting int if err := tx.QueryRow(`SELECT count(*) FROM runs WHERE automation_id = ? AND phase != ?`, id, string(PhaseOver)).Scan(&waiting); err != nil { @@ -670,7 +693,7 @@ func (s *Store) QueueNow(id string) (Run, error) { return errors.New("it is already running") } res, err := tx.Exec(`INSERT INTO runs (automation_id, due_ms, why, phase, seq) VALUES (?, ?, ?, ?, ?)`, - id, ms(now), string(WhyNow), string(PhaseQueued), seq) + id, ms(now), string(WhyNow), string(phase), seq) if err != nil { return err } diff --git a/internal/automation/trust.go b/internal/automation/trust.go new file mode 100644 index 0000000000..af8af550f8 --- /dev/null +++ b/internal/automation/trust.go @@ -0,0 +1,244 @@ +package automation + +// trust.go is the standing-order half of an automation: the person ratifies +// one before it ever runs, and then lets it run alone only as far as it has +// earned. +// +// ── THE LAWS ── +// +// - NOTHING RUNS BEFORE RATIFY. An automation an agent proposed is saved as +// [StatusProposed] so it can be listed and changed, and the clock never +// takes it: [Store.Due] reads active ones only. [Store.Ratify] is the one +// door from proposed to active. +// +// - A RUN OF WORK THAT IS NOT TRUSTED ASKS FIRST. When the clock comes to +// start one, it parks the run as [PhaseAsking] instead, and the run waits +// for the person's word ([Store.Answer]). Go starts it; skip passes it +// over; not right passes it over and puts the ladder back to the bottom. +// A reminder is a fixed line and a watch only looks, so neither asks. +// +// - THE LADDER IS COUNTED, NEVER GUESSED. Asks first until the first go, +// earning trust for the next ones, trusted alone after [TrustAfter] runs +// the person said go to. One not right resets it. + +import ( + "database/sql" + "encoding/json" + "errors" + "fmt" + "time" +) + +// Trust is how far the person lets an automation run alone. +type Trust string + +const ( + // TrustAsksFirst asks before every run, and has had no go yet. + TrustAsksFirst Trust = "asks_first" + // TrustEarning asks before every run, and is counting the person's go's. + TrustEarning Trust = "earning" + // TrustTrusted runs alone. Empty reads as this. + TrustTrusted Trust = "trusted" +) + +// TrustAfter is how many runs the person says go to before one runs alone. +const TrustAfter = 5 + +// Scope is where an automation's results are said. +type Scope string + +const ( + ScopeChat Scope = "chat" + ScopeCrew Scope = "crew" + ScopeEverywhere Scope = "everywhere" + ScopeNotHere Scope = "not_here" +) + +// Reply is the person's word on a run that asked first. +type Reply string + +const ( + ReplyGo Reply = "go" + ReplySkip Reply = "skip" + ReplyNotRight Reply = "not_right" +) + +// LineSkipped and LineNotRight are what a passed-over run's row says. +const ( + LineSkipped = "skipped by you" + LineNotRight = "you said it was not right" +) + +// Trusted reads the ladder, with empty as trusted. +func (a Automation) Trusted() bool { return a.Trust == "" || a.Trust == TrustTrusted } + +// NeedsAsk reports whether a run of it waits for the person's go: work that is +// not yet trusted. +func (a Automation) NeedsAsk() bool { return !a.Trusted() && a.Action.Do != "" } + +// rewrite reads one automation inside a write, lets change alter it, and saves +// it as the person's change: a new revision, so a clock holding an older copy +// is refused rather than written over. +func (s *Store) rewrite(id string, change func(a *Automation) error) (Automation, error) { + now := s.now() + var saved Automation + err := s.write(func(tx *sql.Tx, seq int64) error { + a, err := scanAutomation(tx.QueryRow(`SELECT doc, status, next_ms, seen, memo, revision FROM automations WHERE id = ?`, id)) + if errors.Is(err, sql.ErrNoRows) { + return ErrNotFound + } + if err != nil { + return err + } + if err := change(&a); err != nil { + return err + } + a.Revision++ + a.Updated = now + a.Last = nil + doc, err := json.Marshal(a) + if err != nil { + return err + } + _, err = tx.Exec(`UPDATE automations SET doc = ?, status = ?, next_ms = ?, revision = ?, seq = ? WHERE id = ?`, + string(doc), string(a.Status), ms(a.Next), a.Revision, seq, id) + saved = a + return err + }) + return saved, err +} + +// Ratify is the person's yes to a proposed automation: it becomes active and +// wakes on its schedule from now. A one-time moment that passed while it was +// proposed runs once, late. +func (s *Store) Ratify(id string) (Automation, error) { + now := s.now() + return s.rewrite(id, func(a *Automation) error { + if a.Status != StatusProposed { + return fmt.Errorf("%s is not waiting to be agreed to", a.Title) + } + a.Status = StatusActive + if a.Schedule.Repeats() { + if a.Schedule.Anchor.IsZero() && a.Schedule.Interval() > 0 { + a.Schedule.Anchor = now + } + next, err := a.Schedule.Next(now) + if err != nil { + return err + } + a.Next = next + } else { + a.Next = a.Schedule.At + } + return nil + }) +} + +// Stop ends an automation for good. It keeps its history and wakes for +// nothing; the run in hand, if any, is asked to stop. +func (s *Store) Stop(id string) (Automation, error) { + saved, err := s.rewrite(id, func(a *Automation) error { + a.Status = StatusStopped + a.Next = time.Time{} + return nil + }) + if err != nil { + return saved, err + } + active, err := s.queryRuns(`WHERE automation_id = ? AND phase != ?`, id, string(PhaseOver)) + if err != nil { + return saved, err + } + for _, run := range active { + if run.Phase == PhaseAsking { + _ = s.passOver(run, LineSkipped) + continue + } + _ = s.RequestStop(run.ID) + } + return saved, nil +} + +// SetTrust puts the ladder where the person says: trusted at once, or back to +// asking first. +func (s *Store) SetTrust(id string, trust Trust) (Automation, error) { + return s.rewrite(id, func(a *Automation) error { + switch trust { + case TrustAsksFirst: + a.Trust, a.Approved = TrustAsksFirst, 0 + case TrustTrusted: + a.Trust = TrustTrusted + default: + return fmt.Errorf("trust cannot be set to %q", trust) + } + return nil + }) +} + +// AskRun parks a queued run until the person says go. +func (s *Store) AskRun(id int64) error { + return s.write(func(tx *sql.Tx, seq int64) error { + _, err := tx.Exec(`UPDATE runs SET phase = ?, seq = ? WHERE id = ? AND phase = ?`, string(PhaseAsking), seq, id, string(PhaseQueued)) + return err + }) +} + +// Asking is every run waiting for the person's word, oldest first. +func (s *Store) Asking() ([]Run, error) { + return s.queryRuns(`WHERE phase = ? ORDER BY id`, string(PhaseAsking)) +} + +// Answer is the person's word on a run that asked first. Go queues it again +// with the person's grant, so the clock starts it on its next look, and counts +// one more step up the ladder; skip passes it over; not right passes it over +// and resets the ladder. +func (s *Store) Answer(runID int64, reply Reply) (Automation, error) { + run, err := s.Run(runID) + if err != nil { + return Automation{}, err + } + if run.Phase != PhaseAsking { + return Automation{}, errors.New("that run is not waiting for you") + } + switch reply { + case ReplyGo: + saved, err := s.rewrite(run.AutomationID, func(a *Automation) error { + a.Granted = runID + a.Approved++ + switch { + case a.Approved >= TrustAfter: + a.Trust = TrustTrusted + default: + a.Trust = TrustEarning + } + return nil + }) + if err != nil { + return saved, err + } + return saved, s.write(func(tx *sql.Tx, seq int64) error { + _, err := tx.Exec(`UPDATE runs SET phase = ?, seq = ? WHERE id = ? AND phase = ?`, string(PhaseQueued), seq, runID, string(PhaseAsking)) + return err + }) + case ReplySkip: + if err := s.passOver(run, LineSkipped); err != nil { + return Automation{}, err + } + return s.Get(run.AutomationID) + case ReplyNotRight: + if err := s.passOver(run, LineNotRight); err != nil { + return Automation{}, err + } + return s.SetTrust(run.AutomationID, TrustAsksFirst) + } + return Automation{}, fmt.Errorf("%q is not an answer to a run", reply) +} + +// passOver ends a run that asked first without running it. +func (s *Store) passOver(run Run, line string) error { + return s.write(func(tx *sql.Tx, seq int64) error { + _, err := tx.Exec(`UPDATE runs SET phase = ?, finished_ms = ?, outcome = ?, line = ?, seq = ? WHERE id = ? AND phase = ?`, + string(PhaseOver), ms(s.now()), string(OutcomeSkipped), line, seq, run.ID, string(PhaseAsking)) + return err + }) +} diff --git a/internal/automation/trust_test.go b/internal/automation/trust_test.go new file mode 100644 index 0000000000..6c04b85a17 --- /dev/null +++ b/internal/automation/trust_test.go @@ -0,0 +1,160 @@ +package automation + +import ( + "testing" + "time" +) + +// NOTHING RUNS BEFORE RATIFY: a proposed routine is kept and never due, and +// ratifying it makes it wake from now. +func TestAProposedAutomationWakesOnlyAfterRatify(t *testing.T) { + s, now := openStore(t) + a := routine("15m") + a.Status = StatusProposed + saved, err := s.Create(a) + if err != nil || saved.Status != StatusProposed { + t.Fatalf("created %+v, %v", saved, err) + } + if due, _ := s.Due(now.Add(48 * time.Hour)); len(due) != 0 { + t.Fatalf("a proposed routine was due: %+v", due) + } + if _, err := s.SetStatus(saved.ID, StatusActive); err == nil { + t.Fatal("a proposed routine was resumed without being agreed to") + } + ratified, err := s.Ratify(saved.ID) + if err != nil || ratified.Status != StatusActive || !ratified.Next.After(*now) { + t.Fatalf("ratified %+v, %v", ratified, err) + } + if _, err := s.Ratify(saved.ID); err == nil { + t.Fatal("an active routine was ratified twice") + } +} + +// A STOPPED AUTOMATION STAYS LISTED AND IS NOT RESUMED. +func TestAStoppedAutomationIsNotResumed(t *testing.T) { + s, _ := openStore(t) + saved, err := s.Create(routine("15m")) + if err != nil { + t.Fatal(err) + } + stopped, err := s.Stop(saved.ID) + if err != nil || stopped.Status != StatusStopped || !stopped.Next.IsZero() { + t.Fatalf("stopped %+v, %v", stopped, err) + } + if _, err := s.SetStatus(saved.ID, StatusActive); err == nil { + t.Fatal("a stopped routine was resumed") + } + if all, _ := s.List(); len(all) != 1 { + t.Fatalf("listed %d", len(all)) + } +} + +// THE LADDER: work not yet trusted parks each run until the person's go; five +// go's make it trusted, and one not right puts it back to the bottom. +func TestUntrustedWorkAsksAndTheLadderClimbs(t *testing.T) { + r := startClock(t, &fakeRunner{report: Report{Outcome: OutcomeDone, Line: "drafted"}}) + a := routine("1h") + a.Trust = TrustAsksFirst + saved, err := r.store.Create(a) + if err != nil { + t.Fatal(err) + } + waitAsking := func() Run { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + asking, _ := r.store.Asking() + if len(asking) == 1 { + return asking[0] + } + time.Sleep(5 * time.Millisecond) + } + t.Fatal("no run asked first") + return Run{} + } + for i := 1; i <= TrustAfter; i++ { + r.now(t, saved.ID) + run := waitAsking() + if r.runner.worked.Load() != int32(i-1) { + t.Fatalf("work ran before the person's go (%d)", r.runner.worked.Load()) + } + got, err := r.store.Answer(run.ID, ReplyGo) + if err != nil || got.Approved != i { + t.Fatalf("go %d = %+v, %v", i, got, err) + } + r.settle(t, saved.ID, i) + } + got, _ := r.store.Get(saved.ID) + if got.Trust != TrustTrusted || got.NeedsAsk() { + t.Fatalf("after %d go's the trust is %q", TrustAfter, got.Trust) + } + r.now(t, saved.ID) + r.settle(t, saved.ID, TrustAfter+1) + if r.runner.worked.Load() != TrustAfter+1 { + t.Fatalf("a trusted run did not run alone: %d", r.runner.worked.Load()) + } + + if _, err := r.store.SetTrust(saved.ID, TrustAsksFirst); err != nil { + t.Fatal(err) + } + r.now(t, saved.ID) + run := waitAsking() + got, err = r.store.Answer(run.ID, ReplyNotRight) + if err != nil || got.Trust != TrustAsksFirst || got.Approved != 0 { + t.Fatalf("not right = %+v, %v", got, err) + } + runs := r.settle(t, saved.ID, TrustAfter+2) + if runs[0].Outcome != OutcomeSkipped || runs[0].Line != LineNotRight || runs[0].News() { + t.Fatalf("the passed-over run = %+v", runs[0]) + } +} + +// A RUN ASKING FIRST HOLDS ITS AUTOMATION: the slots that come meanwhile are +// passed over, never stacked behind it. +func TestAnAskingRunHoldsItsAutomation(t *testing.T) { + s, now := openStore(t) + a := routine("15m") + a.Trust = TrustAsksFirst + saved, err := s.Create(a) + if err != nil { + t.Fatal(err) + } + run, err := s.QueueNow(saved.ID) + if err != nil { + t.Fatal(err) + } + if err := s.AskRun(run.ID); err != nil { + t.Fatal(err) + } + if _, err := s.QueueNow(saved.ID); err == nil { + t.Fatal("a second run queued behind one that is asking") + } + if _, err := s.Answer(run.ID, ReplySkip); err != nil { + t.Fatal(err) + } + if got, _ := s.Get(saved.ID); got.Trust != TrustAsksFirst || got.Last == nil || got.Last.Outcome != OutcomeSkipped { + t.Fatalf("after a skip = %+v", got) + } + if _, err := s.Answer(run.ID, ReplyGo); err == nil { + t.Fatal("a run already passed over took a go") + } + _ = now +} + +// AN EDIT NEVER MOVES THE LADDER. +func TestAnEditKeepsTheTrustItHad(t *testing.T) { + s, _ := openStore(t) + a := routine("15m") + a.Trust, a.Approved = TrustEarning, 3 + saved, err := s.Create(a) + if err != nil { + t.Fatal(err) + } + saved.Action.Do = "draft it shorter" + saved.Trust, saved.Approved = TrustTrusted, 9 + saved.AnswersIn = ScopeEverywhere + got, err := s.Update(saved) + if err != nil || got.Trust != TrustEarning || got.Approved != 3 || got.AnswersIn != ScopeEverywhere { + t.Fatalf("after the edit = %+v, %v", got, err) + } +} From bbf77536d2cf72b13b385bd9eeff9db51af3b7e6 Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Thu, 15 Oct 2026 11:56:20 -0400 Subject: [PATCH 014/459] app v3: handed-over tasks, quit and resume, clocks, waves 7, 8 and 6 Handed-over runs are app task rows with notes, pause, resume and continue; quitting stops replies and processes (SIGTERM, then SIGKILL after 5 s) and the next start carries them on; the choice clock and its receipt. Wave 7: close, archive, reopen, rename, idle and search.all. Wave 8: every Settings row, always-allow rules, memories, the spending export and pinned models. Wave 6: standing orders are automations, saved as proposed before the card, ratified from the card or orders.ratify, asking before each run until trusted, with order events read from the store; the app holds an open window for its life. These share app.go, wire.go and PROTOCOL.md too closely to split cleanly. --- cmd/codeaf/app.go | 53 +- cmd/codeaf/appconvo.go | 17 + cmd/codeaf/apporders.go | 604 ++++++++++++++++++ cmd/codeaf/apporders_test.go | 168 +++++ cmd/codeaf/appquit.go | 173 +++++ cmd/codeaf/appsettings.go | 298 +++++++++ cmd/codeaf/appsettings_test.go | 111 ++++ cmd/codeaf/appstate.go | 36 ++ cmd/codeaf/apptidy.go | 307 +++++++++ cmd/codeaf/apptidy_test.go | 78 +++ cmd/codeaf/appwork.go | 19 +- cmd/codeaf/engine.go | 3 + .../unreleased/0-app-tasks-quit-clocks.md | 21 + .../unreleased/0-app-wave6-standing-orders.md | 21 + docs/changes/unreleased/0-app-wave7-tidy.md | 16 + .../unreleased/0-app-wave8-settings.md | 16 + internal/appwire/orders.go | 283 ++++++++ internal/appwire/settings8.go | 185 ++++++ internal/appwire/setup.go | 33 + internal/appwire/tidy.go | 212 ++++++ internal/appwire/wire.go | 39 +- internal/appwire/work.go | 3 + .../manual/chat/running-from-the-terminal.md | 78 +++ internal/plandb/store.go | 37 ++ internal/remote/callclass.go | 2 +- internal/remote/server.go | 4 +- internal/remote/wire.go | 11 + internal/remote/wire_app_task.go | 51 ++ internal/run/bashworker.go | 22 + internal/session/agent.go | 6 +- internal/session/appnames.go | 26 + internal/session/automation_contract.go | 2 +- internal/session/automation_orders_test.go | 73 +++ internal/session/automation_tool.go | 64 +- internal/session/principal_structure_test.go | 4 + internal/session/resume_quit.go | 27 + internal/session/session.go | 20 + internal/session/task_app.go | 98 ++- internal/session/task_app_runs.go | 246 +++++++ internal/session/task_run_belt.go | 9 + internal/session/taskname.go | 34 +- internal/session/taskname_test.go | 7 + internal/session/tools_ask.go | 8 + 43 files changed, 3475 insertions(+), 50 deletions(-) create mode 100644 cmd/codeaf/apporders.go create mode 100644 cmd/codeaf/apporders_test.go create mode 100644 cmd/codeaf/appquit.go create mode 100644 cmd/codeaf/appsettings.go create mode 100644 cmd/codeaf/appsettings_test.go create mode 100644 cmd/codeaf/apptidy.go create mode 100644 cmd/codeaf/apptidy_test.go create mode 100644 docs/changes/unreleased/0-app-tasks-quit-clocks.md create mode 100644 docs/changes/unreleased/0-app-wave6-standing-orders.md create mode 100644 docs/changes/unreleased/0-app-wave7-tidy.md create mode 100644 docs/changes/unreleased/0-app-wave8-settings.md create mode 100644 internal/appwire/orders.go create mode 100644 internal/appwire/settings8.go create mode 100644 internal/appwire/tidy.go create mode 100644 internal/session/automation_orders_test.go create mode 100644 internal/session/resume_quit.go create mode 100644 internal/session/task_app_runs.go diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index 6bd273575f..9fbec8df9c 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -55,6 +55,7 @@ import ( "github.com/Agent-Field/codeaf/internal/redact" "github.com/Agent-Field/codeaf/internal/remote" "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/store" teamstore "github.com/Agent-Field/codeaf/internal/teams" "github.com/Agent-Field/codeaf/internal/tui3" ) @@ -114,7 +115,30 @@ func runApp(args []string) error { } // The crews are watched from the start: a manager's start or a member // woken by its manager must not wait for the window to ask for crews. + backend.memoryStore = func() (*store.Store, error) { + proc, err := openEngineProcess() + if err != nil || proc.Memory == nil { + return nil, errors.New("memory is off") + } + return proc.Memory, nil + } + backend.searchWords = func(terms string, limit int) ([]store.ConversationHit, error) { + proc, err := openEngineProcess() + if err != nil || proc.Search == nil { + return nil, errors.New("conversation search is unavailable") + } + return proc.Search.SearchConversations(terms, limit) + } guard.Go("app/crews-start", func() { backend.crews() }) + // THE APP IS AN OPEN WINDOW for as long as it runs, so the clock that runs + // standing orders runs while it is open (apporders.go). + orders, closeOrders := appOrdersStore() + backend.orderStore = orders + defer closeOrders() + defer keepAutomationsWindow("app")() + guard.Go("app/orders", func() { backend.watchOrders(backend.sweepStop) }) + // And what the last quit stopped in the middle of a reply carries on. + guard.Go("app/resume-after-quit", backend.resumeAfterQuit) err := server.Serve(ctx, os.Stdin, appwire.Hello{Version: resolvedVersion(), PID: os.Getpid(), Home: home.Dir()}, backend) if backend.assembled.Load() { // The process half every conversation shared, closed the way the pipe @@ -225,8 +249,8 @@ func appOpenInProcess(ask appOpenAsk) (*appConn, error) { // An app agent keeps the name its first message gave it: the engine's // own naming does not rename it afterwards. // And only the person moves a running reply to a task, and a proposed - // task's clock is the app's. - Launch: &remote.LaunchShape{Interactive: true, DeterministicNames: true, HandOverOnAsk: true, ProposalClockS: &clock, LiveToolOutput: true}, + // task's clock is the app's, as is a suggested choice's. + Launch: &remote.LaunchShape{Interactive: true, DeterministicNames: true, HandOverOnAsk: true, ProposalClockS: &clock, ChoiceClockS: clock, QuitGraceS: appQuitGraceS, LiveToolOutput: true, StandingOrders: true}, } loop, err := remote.Loopback(hello, remote.Options{ Boot: func(h remote.Hello) (*remote.Engine, error) { @@ -246,6 +270,15 @@ func appConnOf(client *remote.Client, release func(), assembled bool) *appConn { // appBackend is the [appwire.Engine] over this process's conversations. type appBackend struct { + // searchWords reads every conversation's words for search.all; nil keeps + // the messages group empty. + searchWords appSearchWords + // memoryStore is the memory store the Settings window lists; nil is memory + // off. + memoryStore appMemoryStore + // orderStore is the automations store standing orders are kept in + // (apporders.go); nil is standing orders off. + orderStore appOrderStore // stopPhases puts the engine's phase news back the way it was. stopPhases func() // profileDir is the profile whose teams are the crews, and the process's @@ -649,6 +682,8 @@ func (b *appBackend) ListAgents(workspace string) ([]appwire.Agent, error) { if !stale.IsZero() && view.State == appwire.StateIdle && !view.UpdatedAt.IsZero() && view.UpdatedAt.Before(stale) && !b.held(view.ID) { view.State = appwire.StateClosed } + view.SpentUSD, view.LastActiveAt = row.Spend, row.At + b.tidyMarks(&view, row.Archived) agents = append(agents, view) } // A conversation this process created and nobody has spoken in yet is not @@ -830,6 +865,8 @@ func (b *appBackend) ensureLocked(a *appAgent) (appwire.Agent, bool, error) { if err != nil { return appwire.Agent{}, false, appEngineError(err) } + // Opening an agent that was closed or put away takes it out again. + b.unmarkTidy(view.ID) if boot != nil && boot.budget != nil && *boot.budget > 0 { if err := conn.Conv.SetSpendRail(*boot.budget); err != nil { conn.Release() @@ -1112,7 +1149,11 @@ func (b *appBackend) questionEvent(a *appAgent, id string, live *appLive, event known := false b.locked(func() { task, known = live.tasks[appTaskID(id, q.ID)] }) if known && !task.Proposed && task.State != appwire.TaskFailed { - words = "Started on its own · " + at.Local().Format("15:04") + // THE RECEIPT SAYS HOW LONG THE CLOCK WAS, so a person reading it + // later knows what they had to answer in. + clock := appProposedClockS + _ = b.state.read(func(f *appStateFile) { clock = f.clock() }) + words = "Started on its own after " + strconv.Itoa(clock) + "s · " + at.Local().Format("15:04") } } live.box.Event(appwire.EventAskClosed, appwire.AskClosed{Agent: id, ID: askID, By: by, Option: option, At: at}) @@ -1785,6 +1826,9 @@ const appAnsweredMark = "app:" // AnswerAsk answers one open ask through the engine's own answer door, the one // the chat's question page uses. Its closing arrives on the question lane. func (b *appBackend) AnswerAsk(id, askID, option, text string) error { + if handled, err := b.answerOrderAsk(askID, option); handled { + return err + } if handled, err := b.answerCrewAsk(id, askID, option, text, ""); handled { return err } @@ -2387,6 +2431,9 @@ func (b *appBackend) Close() error { if already { return nil } + // What was mid-reply and what was running is written down before anything + // stops, so the next start can carry on (appquit.go). + b.markQuit(agents) close(b.sweepStop) <-b.sweepDone if b.stopPhases != nil { diff --git a/cmd/codeaf/appconvo.go b/cmd/codeaf/appconvo.go index f260dac263..00170cb914 100644 --- a/cmd/codeaf/appconvo.go +++ b/cmd/codeaf/appconvo.go @@ -403,6 +403,12 @@ func (b *appBackend) restateBatch(live *appLive, q session.Question) { // them, one call of a batch or all of them, putting it aside for later, or // handing the choice back to the agent's own suggestion. func (b *appBackend) AnswerAskWith(agent, id string, answer appwire.AskAnswer) error { + if handled, err := b.answerOrderAsk(id, answer.Option); handled { + return err + } + if handled, err := b.answerCrewAsk(agent, id, answer.Option, answer.Text, answer.Why); handled { + return err + } a, err := b.find(agent) if err != nil { return err @@ -989,6 +995,17 @@ func (b *appBackend) TaskEntries(id, before string, limit int) ([]appwire.Entry, } } if view, found, err := live.conn.Conv.AppTask(local); err == nil && found { + // A HANDED-OVER TASK KEEPS NO ROOM OF ITS OWN: its workers' steps are + // on its record instead, and they are its log, one line a step. + if len(all) == 0 { + for i, line := range view.WorkLog { + if line = strings.TrimSpace(line); line != "" { + entry := appwire.Entry{ID: id + "/step" + strconv.Itoa(i+1), Agent: agent, Kind: appwire.EntryNotice, At: at, Text: redact.Secrets(line), Quiet: true} + entry.ToolFacts.Task = id + all = append(all, entry) + } + } + } for i, note := range view.Notes { entry := appwire.Entry{ID: id + "/note" + strconv.Itoa(i), Agent: agent, Kind: appwire.EntryNote, At: at, Text: redact.Secrets(note.Text), From: "you", State: appwire.NoteWaiting} if note.Direction != 0 { diff --git a/cmd/codeaf/apporders.go b/cmd/codeaf/apporders.go new file mode 100644 index 0000000000..be0ce77fab --- /dev/null +++ b/cmd/codeaf/apporders.go @@ -0,0 +1,604 @@ +package main + +// ── codeaf app: STANDING ORDERS AND REMINDERS (wave 6) ────────────────────── +// +// AN ORDER IS AN AUTOMATION. There is one store and one clock, the ones the +// terminal's /automations list reads (internal/automation), and the app keeps +// nothing of its own about an order: every method here is a door onto that +// store, and every event is read back out of it. +// +// What the app adds lives in the store too (internal/automation's trust.go): +// an order an agent proposes is saved as proposed and runs nothing until the +// person ratifies it, from the card in its conversation or from the orders +// list; work that is not yet trusted asks before each run; and every order +// says where its results are said. +// +// THE APP HOLDS AN OPEN WINDOW FOR AS LONG AS IT RUNS, the same hold a +// terminal window takes (chatv3_clock.go), so the clock runs while the app is +// open, and what fell due while the Mac was asleep catches up once, late, +// which the app says as order.missed. + +import ( + "errors" + "fmt" + "strconv" + "strings" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/automation" + "github.com/Agent-Field/codeaf/internal/session" +) + +// appOrdersEvery is how often the app reads the store for what changed: a +// run that started, a run waiting for the person, an order changed elsewhere. +const appOrdersEvery = 2 * time.Second + +// appOrderStore is the automations store, or nil where the process has none. +type appOrderStore func() *automation.Store + +// appOrdersStore opens the store under this home the first time it is asked +// for, and answers the same handle after; close lets it go when the app leaves. +// IT IS THE APP'S OWN HANDLE, not the process-wide one conversations propose +// through: that one is opened once for the life of the process, and a test +// binary that starts the app under a throwaway home must not fix it there. +func appOrdersStore() (open appOrderStore, close func()) { + var once sync.Once + var store *automation.Store + open = func() *automation.Store { + once.Do(func() { + opened, err := automation.Open(automationsRoot()) + if err == nil { + store = opened + } + }) + return store + } + close = func() { + once.Do(func() {}) + if store != nil { + _ = store.Close() + } + } + return open, close +} + +// ordersOf is the store, or the refusal that says there is none. +func (b *appBackend) ordersOf() (*automation.Store, error) { + if b.orderStore == nil { + return nil, appwire.Errorf(appwire.CodeEngine, "standing orders are off") + } + store := b.orderStore() + if store == nil { + return nil, appwire.Errorf(appwire.CodeEngine, "standing orders are off") + } + return store, nil +} + +// orderError turns the store's refusals into the app's. +func orderError(err error) error { + if err == nil { + return nil + } + var wire *appwire.Error + if errors.As(err, &wire) { + return err + } + if errors.Is(err, automation.ErrNotFound) { + return appwire.Errorf(appwire.CodeNotFound, "there is no such order") + } + return appwire.Errorf(appwire.CodeBadParams, "%s", err.Error()) +} + +// orderView is one automation as the app draws an order. +func orderView(store *automation.Store, a automation.Automation, now time.Time) appwire.Order { + o := appwire.Order{ + ID: a.ID, + Crew: a.Crew, + Title: a.Title, + Workspace: a.Workspace, + When: automation.Describe(a.Schedule, now), + WhenExact: automation.Exact(a.Schedule), + Wont: a.Wont, + AnswersIn: string(a.AnswersIn), + Trust: string(a.Trust), + ApprovedRuns: a.Approved, + TrustAfter: automation.TrustAfter, + CreatedAt: a.Created, + } + if a.Origin.Transcript != "" { + o.Agent = appAgentID(a.Origin.Transcript) + } else { + o.Agent = a.Origin.SessionID + } + switch { + case a.Look != nil: + o.Kind = appwire.OrderKindWatch + case a.Action.Do != "": + o.Kind = appwire.OrderKindOrder + default: + o.Kind = appwire.OrderKindReminder + } + o.Does = a.Action.Do + if o.Does == "" { + o.Does = a.Action.Say + } + if o.AnswersIn == "" { + o.AnswersIn = appwire.AnswersChat + } + if o.Trust == "" { + o.Trust = appwire.TrustTrusted + } + limits := a.Limits.Effective() + o.BudgetPerRunUSD, o.TimePerRunS = limits.USD, int(limits.Time/time.Second) + switch a.Status { + case automation.StatusProposed: + o.State = appwire.OrderProposed + case automation.StatusPaused: + o.State = appwire.OrderPaused + case automation.StatusStopped: + o.State = appwire.OrderStopped + case automation.StatusFinished: + o.State = appwire.OrderDone + default: + o.State = appwire.OrderActive + } + if !a.Next.IsZero() && o.State == appwire.OrderActive { + at := a.Next + o.FiresAt = &at + } + if a.Last != nil { + last := orderRunView(*a.Last) + o.LastRun = &last + } + if store != nil { + if asking, err := store.Asking(); err == nil { + for _, run := range asking { + if run.AutomationID == a.ID { + view := orderRunView(run) + o.Asking = &view + } + } + } + } + return o +} + +// orderRunView is one run as an order's row says it. +func orderRunView(run automation.Run) appwire.OrderRun { + v := appwire.OrderRun{ID: run.ID, DueAt: run.Due, At: run.Started, Late: run.Why == automation.WhyLate, Line: run.Line, USD: run.USD, Transcript: run.Transcript} + if run.Phase == automation.PhaseAsking { + v.AskID = appwire.OrderAskPrefix + strconv.FormatInt(run.ID, 10) + } + if run.Phase != automation.PhaseOver { + return v + } + if v.At.IsZero() { + v.At = run.Finished + } + switch run.Outcome { + case automation.OutcomeSkipped: + v.Result = appwire.ResultSkipped + if run.Line == automation.LineNotRight { + v.Result = appwire.ResultNotRight + } + case automation.OutcomeQuiet: + v.Result = appwire.ResultQuiet + case automation.OutcomeDone: + v.Result = appwire.ResultOK + default: + v.Result = appwire.ResultIncomplete + } + return v +} + +// Orders is every order, or one agent's. +func (b *appBackend) Orders(agent string) ([]appwire.Order, error) { + store, err := b.ordersOf() + if err != nil { + return nil, err + } + all, err := store.List() + if err != nil { + return nil, appEngineError(err) + } + now := b.now() + var out []appwire.Order + for _, a := range all { + view := orderView(store, a, now) + if agent != "" && view.Agent != agent { + continue + } + out = append(out, view) + } + return out, nil +} + +// OrderRuns is one order's history, newest first. +func (b *appBackend) OrderRuns(id string, limit int) ([]appwire.OrderRun, error) { + store, err := b.ordersOf() + if err != nil { + return nil, err + } + runs, err := store.Runs(id, limit) + if err != nil { + return nil, orderError(err) + } + out := make([]appwire.OrderRun, 0, len(runs)) + for _, run := range runs { + out = append(out, orderRunView(run)) + } + return out, nil +} + +// sayOrder reads an order back and says it as order.changed. +func (b *appBackend) sayOrder(store *automation.Store, id string) (appwire.Order, error) { + a, err := store.Get(id) + if err != nil { + return appwire.Order{}, orderError(err) + } + view := orderView(store, a, b.now()) + b.emit.Emit(appwire.EventOrderChanged, appwire.OrderChanged{Order: view}) + return view, nil +} + +// proposalCard finds the card in a conversation that is asking about a +// proposed order, so the list's answer and the card's are one answer. +func (b *appBackend) proposalCard(id string) (agent, ask string, q session.Question, found bool) { + b.locked(func() { + for agentID, a := range b.agents { + if a.live == nil { + continue + } + for askID, question := range a.live.questions { + if question.Kind == session.QuestionAutomation && question.Subject.Ref == id { + if _, done := a.live.answered[askID]; done { + continue + } + agent, ask, q, found = agentID, askID, question, true + return + } + } + } + }) + return +} + +// RatifyOrder is the person's yes. Edits made on the way are saved first; +// the card in the conversation, if it is still up, is answered with the +// same yes, so the conversation knows. +func (b *appBackend) RatifyOrder(id string, edits *appwire.OrderEdits) (appwire.Order, error) { + store, err := b.ordersOf() + if err != nil { + return appwire.Order{}, err + } + if edits != nil { + if _, err := b.changeOrder(store, id, *edits); err != nil { + return appwire.Order{}, err + } + } + if _, err := store.Ratify(id); err != nil { + return appwire.Order{}, orderError(err) + } + if agent, ask, q, found := b.proposalCard(id); found { + _ = b.AnswerAsk(agent, ask, appOptionOfKey(q, session.AutomationSaveKey), "") + } + return b.sayOrder(store, id) +} + +// NotNowOrder is the person's no to a proposed order: it is taken away, and +// the card, if it is still up, is answered with the same no. +func (b *appBackend) NotNowOrder(id string) error { + store, err := b.ordersOf() + if err != nil { + return err + } + a, err := store.Get(id) + if err != nil { + return orderError(err) + } + if a.Status != automation.StatusProposed { + return appwire.Errorf(appwire.CodeBadParams, "%s is already agreed to; pause or stop it instead", a.Title) + } + if agent, ask, q, found := b.proposalCard(id); found { + _ = b.AnswerAsk(agent, ask, appOptionOfKey(q, session.AutomationNoKey), "") + } + if err := store.Delete(id); err != nil && !errors.Is(err, automation.ErrNotFound) { + return orderError(err) + } + b.emit.Emit(appwire.EventOrderRemoved, appwire.OrderRemoved{ID: id}) + return nil +} + +// ChangeOrder saves the person's edits. +func (b *appBackend) ChangeOrder(id string, edits appwire.OrderEdits) (appwire.Order, error) { + store, err := b.ordersOf() + if err != nil { + return appwire.Order{}, err + } + if _, err := b.changeOrder(store, id, edits); err != nil { + return appwire.Order{}, err + } + return b.sayOrder(store, id) +} + +// changeOrder lays edits over the order and saves it through the store's own +// update, which works a changed schedule out again from now. +func (b *appBackend) changeOrder(store *automation.Store, id string, edits appwire.OrderEdits) (automation.Automation, error) { + a, err := store.Get(id) + if err != nil { + return a, orderError(err) + } + now := b.now() + if edits.When != nil { + line, err := orderWhenLine(id, *edits.When) + if err != nil { + return a, err + } + cmd, err := automation.ParseCommand(line) + if err != nil { + return a, appwire.Errorf(appwire.CodeBadParams, "%s", err.Error()) + } + if a, err = cmd.Apply(a, now); err != nil { + return a, appwire.Errorf(appwire.CodeBadParams, "%s", err.Error()) + } + } + if edits.Title != nil { + a.Title = strings.TrimSpace(*edits.Title) + } + if edits.Does != nil { + does := strings.TrimSpace(*edits.Does) + if a.Action.Do != "" || a.Action.Say == "" { + a.Action.Do = does + } else { + a.Action.Say = does + } + } + if edits.Wont != nil { + a.Wont = strings.TrimSpace(*edits.Wont) + } + if edits.Crew != nil { + a.Crew = strings.TrimSpace(*edits.Crew) + } + if edits.AnswersIn != nil { + a.AnswersIn = automation.Scope(*edits.AnswersIn) + } + if edits.BudgetPerRunUSD != nil { + if *edits.BudgetPerRunUSD <= 0 { + return a, appwire.Errorf(appwire.CodeBadParams, "a budget per run is above zero") + } + a.Limits.USD = *edits.BudgetPerRunUSD + } + if edits.TimePerRunS != nil { + if *edits.TimePerRunS <= 0 { + return a, appwire.Errorf(appwire.CodeBadParams, "a time per run is above zero") + } + a.Limits.Time = time.Duration(*edits.TimePerRunS) * time.Second + } + saved, err := store.Update(a) + if err != nil { + return saved, orderError(err) + } + if edits.Trust != nil { + if saved, err = store.SetTrust(id, automation.Trust(*edits.Trust)); err != nil { + return saved, orderError(err) + } + } + return saved, nil +} + +// orderWhenLine is the typed line that gives an order a new schedule: `every +// 15m`, `every 0 9 * * 1`, `at tomorrow 6pm` or `in 2h`, as the person wrote +// it, with the rhythm quoted so a cron line is one value. +func orderWhenLine(id, when string) (string, error) { + word, rest, _ := strings.Cut(strings.TrimSpace(when), " ") + rest = strings.TrimSpace(rest) + word = strings.ToLower(word) + if rest == "" { + return "", appwire.Errorf(appwire.CodeBadParams, `when is "every 15m", "every 0 9 * * 1", "at 18:00" or "in 2h"`) + } + switch word { + case "at": + return "edit " + id + " at " + rest, nil + case "every", "in": + return "edit " + id + " " + word + " " + strconv.Quote(rest), nil + } + return "", appwire.Errorf(appwire.CodeBadParams, `when starts with every, at or in`) +} + +// SetOrderState pauses, resumes or stops an order. Pausing keeps its trust. +func (b *appBackend) SetOrderState(id, state string) (appwire.Order, error) { + store, err := b.ordersOf() + if err != nil { + return appwire.Order{}, err + } + switch state { + case appwire.OrderStopped: + _, err = store.Stop(id) + case appwire.OrderPaused: + _, err = store.SetStatus(id, automation.StatusPaused) + case appwire.OrderActive: + _, err = store.SetStatus(id, automation.StatusActive) + default: + err = fmt.Errorf("an order cannot be put %s", state) + } + if err != nil { + return appwire.Order{}, orderError(err) + } + return b.sayOrder(store, id) +} + +// RunOrderNow asks for a run outside the schedule. The person asking for it +// is its go: it does not ask again. +func (b *appBackend) RunOrderNow(id string) (appwire.Order, error) { + store, err := b.ordersOf() + if err != nil { + return appwire.Order{}, err + } + a, err := store.Get(id) + if err != nil { + return appwire.Order{}, orderError(err) + } + if a.Status == automation.StatusProposed || a.Status == automation.StatusStopped { + return appwire.Order{}, appwire.Errorf(appwire.CodeBadParams, "%s is not agreed to, so it does not run", a.Title) + } + if !a.NeedsAsk() { + if _, err := store.QueueNow(id); err != nil { + return appwire.Order{}, orderError(err) + } + return b.sayOrder(store, id) + } + run, err := store.QueueAsking(id) + if err != nil { + return appwire.Order{}, orderError(err) + } + if _, err := store.Answer(run.ID, automation.ReplyGo); err != nil { + return appwire.Order{}, orderError(err) + } + return b.sayOrder(store, id) +} + +// OrderVerdict is the person's word on a run that asked first. A run of zero +// is whichever run of the order is asking. +func (b *appBackend) OrderVerdict(id string, run int64, verdict string) (appwire.Order, error) { + store, err := b.ordersOf() + if err != nil { + return appwire.Order{}, err + } + if run == 0 { + asking, err := store.Asking() + if err != nil { + return appwire.Order{}, appEngineError(err) + } + for _, r := range asking { + if r.AutomationID == id { + run = r.ID + } + } + if run == 0 { + return appwire.Order{}, appwire.Errorf(appwire.CodeNotFound, "no run of this order is waiting for you") + } + } + if r, err := store.Run(run); err != nil || r.AutomationID != id { + return appwire.Order{}, appwire.Errorf(appwire.CodeNotFound, "that run is not this order's") + } + if _, err := store.Answer(run, automation.Reply(verdict)); err != nil { + return appwire.Order{}, orderError(err) + } + return b.sayOrder(store, id) +} + +// answerOrderAsk is asks.answer on a run's ask, "order:". +func (b *appBackend) answerOrderAsk(askID, option string) (bool, error) { + raw, ok := strings.CutPrefix(askID, appwire.OrderAskPrefix) + if !ok { + return false, nil + } + run, err := strconv.ParseInt(raw, 10, 64) + if err != nil { + return true, appwire.Errorf(appwire.CodeNotFound, "the ask %s is not open", askID) + } + store, err := b.ordersOf() + if err != nil { + return true, err + } + r, err := store.Run(run) + if err != nil { + return true, appwire.Errorf(appwire.CodeNotFound, "the ask %s is not open", askID) + } + switch option { + case appwire.VerdictGo, appwire.VerdictSkip, appwire.VerdictNotRight: + default: + return true, appwire.Errorf(appwire.CodeBadParams, "the ask %s offers go, skip or not_right", askID) + } + _, err = b.OrderVerdict(r.AutomationID, run, option) + return true, err +} + +// watchOrders reads the store until stop closes and says what changed: +// order.changed for an order that is new or different, order.removed for one +// that is gone, order.asking for a run waiting for the person, order.fired +// for a run that started, and order.missed for one that started late. +func (b *appBackend) watchOrders(stop <-chan struct{}) { + store := b.orderStore() + if store == nil { + return + } + cursor, err := store.Cursor() + if err != nil { + return + } + seen := map[string]string{} + if all, err := store.List(); err == nil { + for _, a := range all { + seen[a.ID] = orderPrint(a) + } + } + ticker := time.NewTicker(appOrdersEvery) + defer ticker.Stop() + for { + select { + case <-stop: + return + case <-ticker.C: + } + cursor = b.ordersOnce(store, cursor, seen) + } +} + +// orderPrint is what makes an order different enough to say again. +func orderPrint(a automation.Automation) string { + last := int64(0) + if a.Last != nil { + last = a.Last.ID + } + return fmt.Sprintf("%d|%s|%d|%d", a.Revision, a.Status, a.Next.UnixMilli(), last) +} + +// ordersOnce is one look at the store. +func (b *appBackend) ordersOnce(store *automation.Store, cursor int64, seen map[string]string) int64 { + now := b.now() + runs, next, err := store.Changes(cursor) + if err == nil { + for _, run := range runs { + a, err := store.Get(run.AutomationID) + if err != nil { + continue + } + view := orderView(store, a, now) + fired := appwire.OrderFired{Order: view, Run: orderRunView(run)} + switch run.Phase { + case automation.PhaseAsking: + b.emit.Emit(appwire.EventOrderAsking, fired) + case automation.PhaseRunning: + b.emit.Emit(appwire.EventOrderFired, fired) + if run.Why == automation.WhyLate { + b.emit.Emit(appwire.EventOrderMissed, appwire.OrderChanged{Order: view}) + } + } + } + cursor = next + } + all, err := store.List() + if err != nil { + return cursor + } + present := map[string]bool{} + for _, a := range all { + present[a.ID] = true + print := orderPrint(a) + if seen[a.ID] == print { + continue + } + seen[a.ID] = print + b.emit.Emit(appwire.EventOrderChanged, appwire.OrderChanged{Order: orderView(store, a, now)}) + } + for id := range seen { + if !present[id] { + delete(seen, id) + b.emit.Emit(appwire.EventOrderRemoved, appwire.OrderRemoved{ID: id}) + } + } + return cursor +} diff --git a/cmd/codeaf/apporders_test.go b/cmd/codeaf/apporders_test.go new file mode 100644 index 0000000000..b25a172d4a --- /dev/null +++ b/cmd/codeaf/apporders_test.go @@ -0,0 +1,168 @@ +package main + +import ( + "errors" + "path/filepath" + "strconv" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/automation" +) + +// ordersBackend is a backend over an automations store of its own. +func ordersBackend(t *testing.T) (*appBackend, *appRecorder, *automation.Store) { + t.Helper() + store, err := automation.Open(filepath.Join(t.TempDir(), "automations")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + rec := newAppRecorder() + backend := newFakeBackend(rec, nil) + t.Cleanup(func() { backend.Close() }) + backend.orderStore = func() *automation.Store { return store } + return backend, rec, store +} + +// proposedOrder is work an agent proposed, waiting for the person. +func proposedOrder(t *testing.T, store *automation.Store) automation.Automation { + t.Helper() + a, err := store.Create(automation.Automation{ + Title: "weekly update", Schedule: automation.Schedule{Every: "1h", Zone: "UTC"}, + Action: automation.Action{Do: "draft the weekly update"}, Workspace: t.TempDir(), + Status: automation.StatusProposed, Trust: automation.TrustAsksFirst, + }) + if err != nil { + t.Fatal(err) + } + return a +} + +// A PROPOSED ORDER IS LISTED AS PROPOSED AND ASKING FIRST, and ratifying it +// from the list makes it active with a time it fires. +func TestAppOrdersListAndRatify(t *testing.T) { + backend, _, store := ordersBackend(t) + a := proposedOrder(t, store) + orders, err := backend.Orders("") + if err != nil || len(orders) != 1 { + t.Fatalf("orders = %+v, %v", orders, err) + } + o := orders[0] + if o.State != appwire.OrderProposed || o.Trust != appwire.TrustAsksFirst || o.Kind != appwire.OrderKindOrder || o.FiresAt != nil || o.AnswersIn != appwire.AnswersChat { + t.Fatalf("the proposed order = %+v", o) + } + every, usd := "every 30m", 2.5 + o, err = backend.RatifyOrder(a.ID, &appwire.OrderEdits{When: &every, BudgetPerRunUSD: &usd}) + if err != nil || o.State != appwire.OrderActive || o.FiresAt == nil || o.BudgetPerRunUSD != 2.5 || o.WhenExact == "" { + t.Fatalf("ratified = %+v, %v", o, err) + } + if _, err := backend.RatifyOrder(a.ID, nil); err == nil { + t.Fatal("an active order was ratified twice") + } +} + +// NOT NOW TAKES A PROPOSED ORDER AWAY, and is refused for one agreed to. +func TestAppOrderNotNow(t *testing.T) { + backend, rec, store := ordersBackend(t) + a := proposedOrder(t, store) + if err := backend.NotNowOrder(a.ID); err != nil { + t.Fatal(err) + } + rec.waitFor(t, time.Second, func(event string, data any) bool { + r, ok := data.(appwire.OrderRemoved) + return ok && r.ID == a.ID + }) + if _, err := store.Get(a.ID); !errors.Is(err, automation.ErrNotFound) { + t.Fatalf("the order outlived not now: %v", err) + } + b := proposedOrder(t, store) + if _, err := store.Ratify(b.ID); err != nil { + t.Fatal(err) + } + if err := backend.NotNowOrder(b.ID); err == nil { + t.Fatal("not now took away an order already agreed to") + } +} + +// A RUN THAT ASKS FIRST IS SAID AS order.asking AND ANSWERED THROUGH ITS ASK: +// not right puts the ladder back to the bottom. +func TestAppOrderRunAsksAndIsAnswered(t *testing.T) { + backend, rec, store := ordersBackend(t) + a := proposedOrder(t, store) + if _, err := store.Ratify(a.ID); err != nil { + t.Fatal(err) + } + cursor, _ := store.Cursor() + run, err := store.QueueAsking(a.ID) + if err != nil { + t.Fatal(err) + } + backend.ordersOnce(store, cursor, map[string]string{}) + rec.waitFor(t, time.Second, func(event string, data any) bool { + f, ok := data.(appwire.OrderFired) + return ok && event == appwire.EventOrderAsking && f.Run.AskID == "order:"+strconv.FormatInt(run.ID, 10) + }) + orders, _ := backend.Orders("") + if len(orders) != 1 || orders[0].Asking == nil || orders[0].Asking.ID != run.ID { + t.Fatalf("the asking order = %+v", orders) + } + if err := backend.AnswerAsk("", "order:"+strconv.FormatInt(run.ID, 10), appwire.VerdictNotRight, ""); err != nil { + t.Fatal(err) + } + orders, _ = backend.Orders("") + if orders[0].Asking != nil || orders[0].LastRun == nil || orders[0].LastRun.Result != appwire.ResultNotRight || orders[0].Trust != appwire.TrustAsksFirst { + t.Fatalf("after not right = %+v", orders[0]) + } +} + +// RUN NOW IS THE PERSON'S GO: work that asks first is queued already granted, +// and counts one step up the ladder. +func TestAppOrderRunNowIsItsOwnGo(t *testing.T) { + backend, _, store := ordersBackend(t) + a := proposedOrder(t, store) + if _, err := backend.RunOrderNow(a.ID); err == nil { + t.Fatal("a proposed order ran") + } + if _, err := store.Ratify(a.ID); err != nil { + t.Fatal(err) + } + o, err := backend.RunOrderNow(a.ID) + if err != nil || o.ApprovedRuns != 1 || o.Trust != appwire.TrustEarning { + t.Fatalf("after run now = %+v, %v", o, err) + } + queued, _ := store.Queued() + if len(queued) != 1 { + t.Fatalf("queued = %+v", queued) + } + if got, _ := store.Get(a.ID); got.Granted != queued[0].ID { + t.Fatalf("the run is not the granted one: %d vs %d", got.Granted, queued[0].ID) + } +} + +// PAUSE KEEPS THE TRUST, AND STOP IS FOR GOOD. +func TestAppOrderPauseResumeStop(t *testing.T) { + backend, _, store := ordersBackend(t) + a := proposedOrder(t, store) + if _, err := store.Ratify(a.ID); err != nil { + t.Fatal(err) + } + trusted := appwire.TrustTrusted + if _, err := backend.ChangeOrder(a.ID, appwire.OrderEdits{Trust: &trusted}); err != nil { + t.Fatal(err) + } + o, err := backend.SetOrderState(a.ID, appwire.OrderPaused) + if err != nil || o.State != appwire.OrderPaused || o.Trust != appwire.TrustTrusted { + t.Fatalf("paused = %+v, %v", o, err) + } + if o, err = backend.SetOrderState(a.ID, appwire.OrderActive); err != nil || o.State != appwire.OrderActive { + t.Fatalf("resumed = %+v, %v", o, err) + } + if o, err = backend.SetOrderState(a.ID, appwire.OrderStopped); err != nil || o.State != appwire.OrderStopped { + t.Fatalf("stopped = %+v, %v", o, err) + } + if _, err := backend.SetOrderState(a.ID, appwire.OrderActive); err == nil { + t.Fatal("a stopped order was resumed") + } +} diff --git a/cmd/codeaf/appquit.go b/cmd/codeaf/appquit.go new file mode 100644 index 0000000000..06399ec0ea --- /dev/null +++ b/cmd/codeaf/appquit.go @@ -0,0 +1,173 @@ +package main + +// ── what the app's quit leaves behind, and what it picks up again ─────────── +// +// QUITTING THE APP PAUSES ITS AGENTS AND STOPS ITS PROCESSES. A reply in the +// middle of its work is stopped where it stands and carries on by itself when +// the app opens again, with "Resumed after quit" in front of it; it is not left +// reading failed or interrupted. A process the model started is sent SIGTERM, +// then SIGKILL after appQuitGraceS seconds (the conversation's own shutdown, +// given the app's grace), and is listed afterwards as "stopped by quit". What +// runs on a schedule is untouched: an automation is the store's, not this +// process's, and fires again once the app is open. +// +// The list of what to pick up is kept in the app's state file, written before +// anything is stopped, so a quit that never finishes still leaves it behind. + +import ( + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/guard" + "github.com/Agent-Field/codeaf/internal/session" +) + +const ( + // appQuitGraceS is how long a running process has between SIGTERM and + // SIGKILL when the app quits. The window waits a little longer than this + // before it ends the app's process. + appQuitGraceS = 5 + // appResumedWord is the line in front of a reply that carries on after a + // quit, and appQuitStoppedWord is a process's reason after one. + appResumedWord = "Resumed after quit" + appQuitStoppedWord = "stopped by quit" +) + +// appResumer is a conversation that can carry on after the app's quit. +type appResumer interface { + ResumeAfterQuit() (bool, error) +} + +// markQuit writes down, before anything stops, which agents were in the +// middle of a reply and which of their processes were running. +func (b *appBackend) markQuit(agents []*appAgent) { + var resume, tasks []string + var stopped []appwire.Process + now := b.now() + for _, a := range agents { + live := b.liveOf(a) + if live == nil || !live.conn.Assembled { + continue + } + id, running := "", false + b.locked(func() { id, running = a.view.ID, live.running }) + b.locked(func() { + for key, task := range live.tasks { + if task.State == appwire.TaskRunning { + tasks = append(tasks, key) + } + } + }) + if running || len(tasks) > 0 { + resume = append(resume, id) + } + rows, err := live.conn.Conv.JobNotices() + if err != nil { + continue + } + for _, row := range rows { + process := appProcessOf(id, row) + if process.State != appwire.ProcessRunning { + continue + } + ended := now + process.State, process.EndedAt, process.Reason = appwire.ProcessStopped, &ended, appQuitStoppedWord + stopped = append(stopped, process) + } + } + if len(resume) == 0 && len(stopped) == 0 { + return + } + // Only a reply in the middle of its work is carried on as a turn; an + // agent listed for its tasks alone has them picked up and nothing said. + var replies []string + for _, id := range resume { + for _, a := range agents { + if live := b.liveOf(a); live != nil { + running := false + b.locked(func() { running = a.view.ID == id && live.running }) + if running { + replies = append(replies, id) + } + } + } + } + _ = b.state.write(func(f *appStateFile) error { + f.ResumeAfterQuit, f.QuitReplies, f.QuitTasks, f.QuitStopped = resume, replies, tasks, stopped + return nil + }) +} + +// resumeAfterQuit carries on every reply the last quit stopped, once. Each is +// opened, told it carries on, and does so as a turn of its own. +func (b *appBackend) resumeAfterQuit() { + var resume, replies, tasks []string + _ = b.state.write(func(f *appStateFile) error { + resume, replies, tasks = f.ResumeAfterQuit, f.QuitReplies, f.QuitTasks + f.ResumeAfterQuit, f.QuitReplies, f.QuitTasks = nil, nil, nil + return nil + }) + for _, id := range resume { + reply := false + var mine []string + for _, r := range replies { + reply = reply || r == id + } + for _, t := range tasks { + if agent, _, ok := appSplitID(t); ok && agent == id { + mine = append(mine, t) + } + } + guard.Go("app/resume", func() { b.resumeOne(id, reply, mine) }) + } +} + +// resumeOne carries on one agent's reply, when it was in the middle of one, +// and every task it had running: a task the quit left with nothing driving it +// is picked up where its record stands. +func (b *appBackend) resumeOne(id string, reply bool, tasks []string) { + _, live, err := b.heldLive(id) + if err != nil { + return + } + for _, task := range tasks { + if _, local, ok := appSplitID(task); ok { + _ = live.conn.Conv.AppTaskAction(session.AppTaskIntent{ID: local, Action: "continue"}) + } + } + door, ok := live.conn.Conv.(appResumer) + if !ok || !reply { + return + } + live.box.Event(appwire.EventEntryAdded, appwire.EntryAdded{Entry: appwire.Entry{ + ID: "resumed-" + b.now().Format("20060102T150405"), Agent: id, Kind: appwire.EntryNotice, At: b.now(), Text: appResumedWord, + }}) + _, _ = door.ResumeAfterQuit() +} + +// quitStopped lays the last quit's stopped processes over an agent's list: +// one the list still names is said as stopped by the quit, and one it no +// longer names is added. +func (b *appBackend) quitStopped(agent string, list []appwire.Process) []appwire.Process { + var stopped []appwire.Process + _ = b.state.read(func(f *appStateFile) { + for _, p := range f.QuitStopped { + if p.Agent == agent { + stopped = append(stopped, p) + } + } + }) + for _, p := range stopped { + found := false + for i := range list { + if list[i].ID == p.ID { + found = true + if list[i].State != appwire.ProcessRunning { + list[i].State, list[i].Reason, list[i].ExitCode = appwire.ProcessStopped, appQuitStoppedWord, nil + } + } + } + if !found { + list = append(list, p) + } + } + return list +} diff --git a/cmd/codeaf/appsettings.go b/cmd/codeaf/appsettings.go new file mode 100644 index 0000000000..8865af6e57 --- /dev/null +++ b/cmd/codeaf/appsettings.go @@ -0,0 +1,298 @@ +package main + +// ── codeaf app: THE SETTINGS WINDOW (wave 8) ──────────────────────────────── +// +// Every row the window draws is read from where it is kept and written back +// through that place's own door. The General rows and the pinned models are +// the app's (its state file); the model rows are the profile's crew rows (the +// checks run on the high tier and plans are made on the mastermind tier, the +// same rows `/crew` writes) and its fallback list; the always-allow rules are +// the profile's approval rows; memories are the memory store; the spending +// export is the usage ledger written out as CSV. + +import ( + "encoding/csv" + "errors" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/store" +) + +// appMemoryStore is the memory store, or an error where the process keeps +// none (memory off, or no engine in a test). +type appMemoryStore func() (*store.Store, error) + +// settingsRow reads one profile row's value, and "" for a row this build lacks. +func settingsRow(profile, key string) string { + row, ok := config.NewSettings(config.SettingsOptions{ProfileDir: profile}).Row(key) + if !ok { + return "" + } + return strings.TrimSpace(row.Value()) +} + +// applyRow writes one profile row, refusing what the row refuses. +func applyRow(profile, key, raw string) error { + row, ok := config.NewSettings(config.SettingsOptions{ProfileDir: profile}).Row(key) + if !ok { + return appwire.Errorf(appwire.CodeEngine, "this build has no %s setting", key) + } + if err := row.Apply(raw); err != nil { + return appwire.Errorf(appwire.CodeBadParams, "%s", err.Error()) + } + return nil +} + +// tierShown is a crew row as the window shows it: the model, or empty where +// the row follows the default or the router's pick. +func tierShown(value string) string { + if value == config.CrewAuto { + return "" + } + return value +} + +// settingsExtra fills the wave 8 rows of settings. +func settingsExtra(settings *appwire.Settings, file appStateFile, profile string) { + settings.Appearance, settings.TextSize = appwire.AppearanceSystem, appwire.TextMedium + if file.Appearance != "" { + settings.Appearance = file.Appearance + } + if file.TextSize != "" { + settings.TextSize = file.TextSize + } + settings.OpenAtLogin = file.OpenAtLogin != nil && *file.OpenAtLogin + settings.DockBadge = file.DockBadge == nil || *file.DockBadge + settings.PinnedModels = append([]string{}, file.PinnedModels...) + settings.ChecksModel = tierShown(settingsRow(profile, config.KeyTierHighModel)) + settings.PlannerModel = tierShown(settingsRow(profile, config.KeyTierMastermindModel)) + if fallbacks := settingsRow(profile, config.KeyModelFallbacks); fallbacks != "" { + first, _, _ := strings.Cut(fallbacks, ",") + settings.FallbackModel = strings.TrimSpace(first) + } +} + +// applyExtra writes the wave 8 rows a patch names: the profile's rows first, +// because they can refuse, then the app's own. +func (b *appBackend) applyExtra(patch appwire.SettingsPatch, profile string) error { + for _, row := range []struct { + key string + value *string + }{ + {config.KeyTierHighModel, patch.ChecksModel}, + {config.KeyTierMastermindModel, patch.PlannerModel}, + {config.KeyModelFallbacks, patch.FallbackModel}, + } { + if row.value == nil { + continue + } + raw := *row.value + if raw == "" && row.key != config.KeyModelFallbacks { + raw = config.CrewAuto + } + if err := applyRow(profile, row.key, raw); err != nil { + return err + } + } + if patch.Appearance == nil && patch.TextSize == nil && patch.OpenAtLogin == nil && patch.DockBadge == nil { + return nil + } + err := b.state.write(func(f *appStateFile) error { + if patch.Appearance != nil { + f.Appearance = *patch.Appearance + } + if patch.TextSize != nil { + f.TextSize = *patch.TextSize + } + if patch.OpenAtLogin != nil { + v := *patch.OpenAtLogin + f.OpenAtLogin = &v + } + if patch.DockBadge != nil { + v := *patch.DockBadge + f.DockBadge = &v + } + return nil + }) + if err != nil { + return appEngineError(err) + } + return nil +} + +// PinModels keeps the models pinned to ⌥⌘1–3, in order. +func (b *appBackend) PinModels(ids []string) (appwire.Settings, error) { + err := b.state.write(func(f *appStateFile) error { + f.PinnedModels = append([]string(nil), ids...) + return nil + }) + if err != nil { + return appwire.Settings{}, appEngineError(err) + } + settings, err := b.Settings() + if err != nil { + return appwire.Settings{}, err + } + b.emit.Emit(appwire.EventSettingsChanged, appwire.SettingsChanged{Settings: settings}) + return settings, nil +} + +// Rules is every always-allow rule: each tool's standing answer, then the +// command patterns in the order they are matched. +func (b *appBackend) Rules() ([]appwire.Rule, error) { + profile := config.ProfileDir() + tools, err := config.ParseToolApprovals(config.ToolApprovalsAt(profile)) + if err != nil { + return nil, appEngineError(err) + } + var rules []appwire.Rule + names := make([]string, 0, len(tools)) + for name := range tools { + names = append(names, name) + } + sort.Strings(names) + for _, name := range names { + rules = append(rules, appwire.Rule{ID: appwire.RuleTool + ":" + name, Kind: appwire.RuleTool, Tool: name, Action: tools[name]}) + } + bash, err := config.ParseBashApprovals(config.BashApprovalsAt(profile)) + if err != nil { + return nil, appEngineError(err) + } + for _, rule := range bash { + rules = append(rules, appwire.Rule{ID: appwire.RuleBash + ":" + rule.Match, Kind: appwire.RuleBash, Tool: "bash", Match: rule.Match, Action: rule.Action}) + } + return rules, nil +} + +// RemoveRule takes one rule out of its row, leaving the rest as written. +func (b *appBackend) RemoveRule(id string) error { + profile := config.ProfileDir() + kind, name, ok := strings.Cut(id, ":") + if !ok || name == "" { + return appwire.Errorf(appwire.CodeBadParams, "%s is not a rule id", id) + } + switch kind { + case appwire.RuleTool: + tools, err := config.ParseToolApprovals(config.ToolApprovalsAt(profile)) + if err != nil { + return appEngineError(err) + } + if _, held := tools[name]; !held { + return appwire.Errorf(appwire.CodeNotFound, "there is no rule for %s", name) + } + delete(tools, name) + names := make([]string, 0, len(tools)) + for n := range tools { + names = append(names, n) + } + sort.Strings(names) + pairs := make([]string, 0, len(names)) + for _, n := range names { + pairs = append(pairs, n+":"+tools[n]) + } + return applyRow(profile, config.KeyToolApprovals, strings.Join(pairs, ", ")) + case appwire.RuleBash: + rules, err := config.ParseBashApprovals(config.BashApprovalsAt(profile)) + if err != nil { + return appEngineError(err) + } + kept := rules[:0] + found := false + for _, rule := range rules { + if rule.Match == name && !found { + found = true + continue + } + kept = append(kept, rule) + } + if !found { + return appwire.Errorf(appwire.CodeNotFound, "there is no rule for %s", name) + } + return applyRow(profile, config.KeyBashApprovals, config.FormatBashApprovals(kept)) + } + return appwire.Errorf(appwire.CodeBadParams, "%s is not a rule id", id) +} + +// Memories is what codeaf remembers, newest first. +func (b *appBackend) Memories(limit int) ([]appwire.Memory, error) { + brain, err := b.memoryOf() + if err != nil { + return nil, err + } + rows, err := brain.ListMemories(nil, limit) + if err != nil { + return nil, appEngineError(err) + } + out := make([]appwire.Memory, 0, len(rows)) + for _, m := range rows { + out = append(out, appwire.Memory{ID: m.ID, Title: m.Title, Text: m.Text, Owner: m.Owner, Project: m.ProjectName, Origin: m.Origin}) + } + return out, nil +} + +// ForgetMemory forgets one memory. +func (b *appBackend) ForgetMemory(id string) error { + brain, err := b.memoryOf() + if err != nil { + return err + } + if err := brain.ForgetMemory(id); err != nil { + return appEngineError(err) + } + return nil +} + +// memoryOf is the memory store, or the refusal that says there is none. +func (b *appBackend) memoryOf() (*store.Store, error) { + if b.memoryStore == nil { + return nil, appwire.Errorf(appwire.CodeEngine, "memory is off") + } + brain, err := b.memoryStore() + if err != nil || brain == nil { + return nil, appwire.Errorf(appwire.CodeEngine, "memory is off") + } + return brain, nil +} + +// ExportSpend writes every call billed in [from, to) as CSV into the app's +// exports folder, and answers the file's path. +func (b *appBackend) ExportSpend(from, to time.Time) (string, error) { + lines, err := session.ReadUsage(session.UsageLedgerPath(), from) + if err != nil { + return "", appEngineError(err) + } + dir := filepath.Join(home.Dir(), appStateFolder, "exports") + if err := os.MkdirAll(dir, appStateFolderMode); err != nil { + return "", appEngineError(err) + } + path := filepath.Join(dir, "spending-"+from.Format("2006-01-02")+"-to-"+to.AddDate(0, 0, -1).Format("2006-01-02")+".csv") + file, err := os.OpenFile(path, os.O_CREATE|os.O_TRUNC|os.O_WRONLY, appStateFileMode) + if err != nil { + return "", appEngineError(err) + } + w := csv.NewWriter(file) + _ = w.Write([]string{"at", "day", "conversation", "model", "role", "seat", "calls", "input_tokens", "output_tokens", "usd"}) + for _, line := range lines { + if !line.At.Before(to) || line.At.Before(from) { + continue + } + _ = w.Write([]string{ + line.At.Format(time.RFC3339), line.Day, line.Session, line.Model, line.Role, string(line.Seat), + strconv.Itoa(line.Calls), strconv.Itoa(line.Input), strconv.Itoa(line.Output), strconv.FormatFloat(line.USD, 'f', -1, 64), + }) + } + w.Flush() + if err := errors.Join(w.Error(), file.Close()); err != nil { + return "", appEngineError(err) + } + return path, nil +} diff --git a/cmd/codeaf/appsettings_test.go b/cmd/codeaf/appsettings_test.go new file mode 100644 index 0000000000..dd4aeb2093 --- /dev/null +++ b/cmd/codeaf/appsettings_test.go @@ -0,0 +1,111 @@ +package main + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/session" +) + +// settingsBackend is a backend over a profile and a home of its own. +func settingsBackend(t *testing.T) *appBackend { + t.Helper() + t.Setenv(config.ProfileDirEnv, t.TempDir()) + t.Setenv(home.EnvVar, t.TempDir()) + backend := newFakeBackend(newAppRecorder(), nil) + t.Cleanup(func() { backend.Close() }) + backend.state = &appState{path: filepath.Join(t.TempDir(), appStateName)} + return backend +} + +// THE GENERAL ROWS READ THEIR DEFAULTS UNTIL SET, and a set row reads back. +func TestAppSettingsGeneralRowsAndPins(t *testing.T) { + backend := settingsBackend(t) + got, err := backend.Settings() + if err != nil { + t.Fatal(err) + } + if got.Appearance != appwire.AppearanceSystem || got.TextSize != appwire.TextMedium || got.OpenAtLogin || !got.DockBadge || got.PinnedModels == nil { + t.Fatalf("the defaults = %+v", got) + } + dark, off := appwire.AppearanceDark, false + got, err = backend.SetSettings(appwire.SettingsPatch{Appearance: &dark, DockBadge: &off}) + if err != nil || got.Appearance != dark || got.DockBadge { + t.Fatalf("after the set = %+v, %v", got, err) + } + got, err = backend.PinModels([]string{"deepseek/deepseek-v4", "qwen/qwen3.5"}) + if err != nil || len(got.PinnedModels) != 2 || got.PinnedModels[1] != "qwen/qwen3.5" { + t.Fatalf("pinned = %+v, %v", got.PinnedModels, err) + } +} + +// AN ALWAYS-ALLOW RULE IS LISTED AND REMOVED FROM ITS OWN ROW, leaving the +// others as they were. +func TestAppRulesListAndRemove(t *testing.T) { + backend := settingsBackend(t) + profile := config.ProfileDir() + if err := config.RememberToolApproval(profile, "read", "allow"); err != nil { + t.Fatal(err) + } + if err := config.RememberBashApproval(profile, "git status*"); err != nil { + t.Fatal(err) + } + rules, err := backend.Rules() + if err != nil || len(rules) != 2 { + t.Fatalf("rules = %+v, %v", rules, err) + } + if err := backend.RemoveRule("bash:git status*"); err != nil { + t.Fatal(err) + } + rules, _ = backend.Rules() + if len(rules) != 1 || rules[0].ID != "tool:read" { + t.Fatalf("after the removal = %+v", rules) + } + var wire *appwire.Error + if err := backend.RemoveRule("tool:write"); err == nil || !errors.As(err, &wire) || wire.Code != appwire.CodeNotFound { + t.Fatalf("removing a rule nobody wrote = %v", err) + } +} + +// THE SPENDING EXPORT IS THE LEDGER'S CALLS IN THE RANGE, as CSV. +func TestAppSpendExportWritesTheRange(t *testing.T) { + backend := settingsBackend(t) + day := time.Date(2026, 10, 14, 12, 0, 0, 0, time.Local) + path := session.UsageLedgerPath() + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + var b strings.Builder + for _, line := range []session.UsageLine{ + {At: day, Day: "2026-10-14", Model: "deepseek/deepseek-v4", Calls: 1, USD: 0.25}, + {At: day.AddDate(0, 0, 3), Day: "2026-10-17", Model: "qwen/qwen3.5", Calls: 1, USD: 1}, + } { + raw, _ := json.Marshal(line) + b.Write(raw) + b.WriteByte('\n') + } + if err := os.WriteFile(path, []byte(b.String()), 0o600); err != nil { + t.Fatal(err) + } + from := time.Date(2026, 10, 14, 0, 0, 0, 0, time.Local) + out, err := backend.ExportSpend(from, from.AddDate(0, 0, 1)) + if err != nil { + t.Fatal(err) + } + raw, err := os.ReadFile(out) + if err != nil { + t.Fatal(err) + } + text := string(raw) + if !strings.Contains(text, "deepseek/deepseek-v4") || strings.Contains(text, "qwen/qwen3.5") || !strings.HasPrefix(text, "at,day,") { + t.Fatalf("the export:\n%s", text) + } +} diff --git a/cmd/codeaf/appstate.go b/cmd/codeaf/appstate.go index 0ef736ab95..86d4079921 100644 --- a/cmd/codeaf/appstate.go +++ b/cmd/codeaf/appstate.go @@ -58,6 +58,25 @@ type appStateFile struct { IdleCloseDays *int `json:"idle_close_days,omitempty"` Budgets map[string]float64 `json:"budgets,omitempty"` Raises map[string]appRaise `json:"raises,omitempty"` + // ResumeAfterQuit is the agents the last quit stopped in the middle of a + // reply, and QuitStopped the processes it stopped (appquit.go). + // QuitReplies is those of them that were in the middle of a reply, and + // QuitTasks the tasks they had running. + ResumeAfterQuit []string `json:"resume_after_quit,omitempty"` + QuitReplies []string `json:"quit_replies,omitempty"` + QuitTasks []string `json:"quit_tasks,omitempty"` + QuitStopped []appwire.Process `json:"quit_stopped,omitempty"` + // Closed and Archived are when the person moved each agent to Closed or + // put it away (apptidy.go). + Closed map[string]time.Time `json:"closed,omitempty"` + Archived map[string]time.Time `json:"archived,omitempty"` + // The Settings window's General rows and pinned models (appsettings.go); + // a nil row is one nobody set, which reads as its default. + Appearance string `json:"appearance,omitempty"` + TextSize string `json:"text_size,omitempty"` + OpenAtLogin *bool `json:"open_at_login,omitempty"` + DockBadge *bool `json:"dock_badge,omitempty"` + PinnedModels []string `json:"pinned_models,omitempty"` } // appState is the file, loaded once and written whole. @@ -162,6 +181,19 @@ func (f appStateFile) clone() appStateFile { for k, v := range f.Raises { out.Raises[k] = v } + out.Closed = make(map[string]time.Time, len(f.Closed)) + for k, v := range f.Closed { + out.Closed[k] = v + } + out.Archived = make(map[string]time.Time, len(f.Archived)) + for k, v := range f.Archived { + out.Archived[k] = v + } + out.ResumeAfterQuit = append([]string(nil), f.ResumeAfterQuit...) + out.QuitReplies = append([]string(nil), f.QuitReplies...) + out.QuitTasks = append([]string(nil), f.QuitTasks...) + out.QuitStopped = append([]appwire.Process(nil), f.QuitStopped...) + out.PinnedModels = append([]string(nil), f.PinnedModels...) return out } @@ -290,6 +322,7 @@ func (b *appBackend) Settings() (appwire.Settings, error) { if rung := config.DefaultEffortAt(profile); rung.Valid() { settings.Effort = rung.String() } + settingsExtra(&settings, file, profile) return settings, nil } @@ -346,6 +379,9 @@ func (b *appBackend) SetSettings(patch appwire.SettingsPatch) (appwire.Settings, return appwire.Settings{}, appEngineError(err) } } + if err := b.applyExtra(patch, profile); err != nil { + return appwire.Settings{}, err + } if patch.WorkFolder != nil && *patch.WorkFolder != "" { if _, err := engineWorkspace(*patch.WorkFolder); err != nil { return appwire.Settings{}, appwire.Errorf(appwire.CodeBadParams, "%s", err.Error()) diff --git a/cmd/codeaf/apptidy.go b/cmd/codeaf/apptidy.go new file mode 100644 index 0000000000..841e90287f --- /dev/null +++ b/cmd/codeaf/apptidy.go @@ -0,0 +1,307 @@ +package main + +// ── codeaf app: CLOSING, ARCHIVING AND FINDING (wave 7) ───────────────────── +// +// A CLOSED AGENT keeps everything and leaves the rail: its conversation is let +// go, its journal stays, and opening it or sending to it opens it again. An +// ARCHIVED one is put away further, through the same mark the terminal's +// conversation list uses (session.SetArchived), and is listed only when asked +// for. When each happened is the app's to keep, in its state file; the archive +// mark itself is the conversation's. +// +// FINDING reads what is already kept: the conversations' rows for agents, the +// teams for crews, each project's task index for tasks, and the search store +// every conversation's words are indexed in for messages. + +import ( + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/store" +) + +// appRenamer is a conversation a person can rename. +type appRenamer interface { + RenameByPerson(title string) error +} + +// tidyMarks lays the app's closed and archived marks over a listed agent. +func (b *appBackend) tidyMarks(view *appwire.Agent, archived bool) { + held := b.held(view.ID) + _ = b.state.read(func(f *appStateFile) { + if at, ok := f.Closed[view.ID]; ok && !held { + at := at + view.ClosedAt = &at + view.State = appwire.StateClosed + } + if at, ok := f.Archived[view.ID]; ok && !held { + at := at + view.ArchivedAt = &at + } + }) + if archived && !held { + view.State = appwire.StateArchived + if view.ArchivedAt == nil { + at := view.UpdatedAt + view.ArchivedAt = &at + } + } + if view.LastActiveAt.IsZero() { + view.LastActiveAt = view.UpdatedAt + } +} + +// ListAgentsIn is agents.list with its state and query. +func (b *appBackend) ListAgentsIn(workspace, state, query string) ([]appwire.Agent, error) { + all, err := b.ListAgents(workspace) + if err != nil { + return nil, err + } + query = strings.ToLower(strings.TrimSpace(query)) + var out []appwire.Agent + for _, a := range all { + closed := a.State == appwire.StateClosed + archived := a.State == appwire.StateArchived + switch state { + case "", appwire.ListOpen: + if closed || archived { + continue + } + case appwire.ListClosed: + if !closed { + continue + } + case appwire.ListArchived: + if !archived { + continue + } + } + if query != "" && !strings.Contains(strings.ToLower(a.Title), query) && !strings.Contains(strings.ToLower(a.Workspace), query) { + continue + } + out = append(out, a) + } + return out, nil +} + +// MoveToClosed lets each agent's conversation go and marks it closed. +func (b *appBackend) MoveToClosed(ids []string) error { + now := b.now() + for _, id := range ids { + a, err := b.find(id) + if err != nil { + return err + } + b.closeAgent(a) + if err := b.state.write(func(f *appStateFile) error { + if f.Closed == nil { + f.Closed = map[string]time.Time{} + } + f.Closed[id] = now + return nil + }); err != nil { + return appEngineError(err) + } + b.sayAgent(a) + } + return nil +} + +// Archive closes each agent and puts it away. +func (b *appBackend) Archive(ids []string) error { + now := b.now() + for _, id := range ids { + a, err := b.find(id) + if err != nil { + return err + } + b.closeAgent(a) + var transcript string + b.locked(func() { transcript = a.transcript }) + if transcript != "" { + if err := session.SetArchived(filepath.Dir(transcript), true); err != nil { + return appEngineError(err) + } + } + if err := b.state.write(func(f *appStateFile) error { + if f.Archived == nil { + f.Archived = map[string]time.Time{} + } + f.Archived[id] = now + return nil + }); err != nil { + return appEngineError(err) + } + b.locked(func() { a.view.State = appwire.StateArchived }) + b.sayAgent(a) + } + return nil +} + +// Reopen takes an agent out of Closed or the archive, exactly as it was. +func (b *appBackend) Reopen(id string) (appwire.Agent, error) { + a, err := b.find(id) + if err != nil { + return appwire.Agent{}, err + } + var transcript string + archived := false + b.locked(func() { transcript, archived = a.transcript, a.view.State == appwire.StateArchived }) + _ = b.state.read(func(f *appStateFile) { + _, marked := f.Archived[id] + archived = archived || marked + }) + if archived && transcript != "" { + if err := session.SetArchived(filepath.Dir(transcript), false); err != nil { + return appwire.Agent{}, appEngineError(err) + } + } + b.unmarkTidy(id) + b.locked(func() { + if a.view.State == appwire.StateClosed || a.view.State == appwire.StateArchived { + a.view.State = appwire.StateIdle + } + }) + return b.sayAgent(a), nil +} + +// unmarkTidy forgets that an agent was closed or archived: it is open again. +func (b *appBackend) unmarkTidy(id string) { + known := false + _ = b.state.read(func(f *appStateFile) { + _, closed := f.Closed[id] + _, archived := f.Archived[id] + known = closed || archived + }) + if !known { + return + } + _ = b.state.write(func(f *appStateFile) error { + delete(f.Closed, id) + delete(f.Archived, id) + return nil + }) +} + +// Rename gives an agent the name the person typed, in its conversation. +func (b *appBackend) Rename(id, title string) (appwire.Agent, error) { + a, live, err := b.heldLive(id) + if err != nil { + return appwire.Agent{}, err + } + door, ok := live.conn.Conv.(appRenamer) + if !ok { + return appwire.Agent{}, appwire.Errorf(appwire.CodeEngine, "this conversation cannot be renamed") + } + if err := door.RenameByPerson(title); err != nil { + return appwire.Agent{}, appEngineError(err) + } + b.locked(func() { a.view.Title = title }) + return b.sayAgent(a), nil +} + +// IdleAgents is every open agent nobody has touched for days (the setting's +// days when none is given), and none that is working or holding a question. +func (b *appBackend) IdleAgents(days int) ([]string, error) { + if days <= 0 { + days = appIdleCloseDays + _ = b.state.read(func(f *appStateFile) { days = f.idleDays() }) + } + if days <= 0 { + return nil, nil + } + cut := b.now().Add(-time.Duration(days) * 24 * time.Hour) + all, err := b.ListAgents("") + if err != nil { + return nil, err + } + var ids []string + for _, a := range all { + if (a.State == appwire.StateIdle || a.State == appwire.StateClosed) && a.ClosedAt == nil && !a.LastActiveAt.IsZero() && a.LastActiveAt.Before(cut) { + ids = append(ids, a.ID) + } + } + return ids, nil +} + +// sayAgent says an agent again as it now stands, and answers that view. +func (b *appBackend) sayAgent(a *appAgent) appwire.Agent { + var view appwire.Agent + b.locked(func() { view = a.view }) + b.tidyMarks(&view, view.State == appwire.StateArchived) + view.Crews = b.crewsOf(view.ID) + b.emit.Emit(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) + return view +} + +// SearchAll finds the query among agents, crews, tasks and what was said. +func (b *appBackend) SearchAll(query string, perGroup int) ([]appwire.HitGroup, error) { + needle := strings.ToLower(query) + agents, err := b.ListAgents("") + if err != nil { + return nil, err + } + titles := map[string]string{} + agentHits := appwire.HitGroup{Kind: appwire.HitAgent} + for _, a := range agents { + titles[a.ID] = a.Title + if len(agentHits.Hits) < perGroup && strings.Contains(strings.ToLower(a.Title), needle) { + at := a.UpdatedAt + agentHits.Hits = append(agentHits.Hits, appwire.Hit{Kind: appwire.HitAgent, ID: a.ID, Agent: a.ID, Title: a.Title, At: &at}) + } + } + crewHits := appwire.HitGroup{Kind: appwire.HitCrew} + if crews, err := b.CrewList(true); err == nil { + for _, c := range crews { + if len(crewHits.Hits) < perGroup && strings.Contains(strings.ToLower(c.Name), needle) { + crewHits.Hits = append(crewHits.Hits, appwire.Hit{Kind: appwire.HitCrew, ID: c.ID, Agent: c.Manager, Title: c.Name}) + } + } + } + taskHits := appwire.HitGroup{Kind: appwire.HitTask, Hits: b.searchTasks(query, perGroup)} + messageHits := appwire.HitGroup{Kind: appwire.HitMessage} + if b.searchWords != nil { + if hits, err := b.searchWords(query, perGroup); err == nil { + for _, h := range hits { + at := h.Time + title := h.Title + if title == "" { + title = titles[h.SessionID] + } + messageHits.Hits = append(messageHits.Hits, appwire.Hit{Kind: appwire.HitMessage, ID: h.SessionID + "#" + strconv.FormatInt(h.Seq, 10), Agent: h.SessionID, Title: title, Excerpt: h.Body, At: &at}) + } + } + } + return []appwire.HitGroup{agentHits, crewHits, taskHits, messageHits}, nil +} + +// searchTasks reads each project's task index once. +func (b *appBackend) searchTasks(query string, limit int) []appwire.Hit { + seen := map[string]bool{} + var rows []session.TaskIndexEntry + for _, row := range session.ReadWorld(session.PlacesRoot()).Sessions() { + if row.Transcript == "" { + continue + } + path := session.TaskIndexPath(row.Transcript) + if seen[path] { + continue + } + seen[path] = true + rows = append(rows, session.ReadTaskIndex(path)...) + } + found := session.SearchTaskIndex(rows, query, limit) + hits := make([]appwire.Hit, 0, len(found)) + for _, entry := range found { + hits = append(hits, appwire.Hit{Kind: appwire.HitTask, ID: entry.SessionID + ":" + entry.ID, Agent: entry.SessionID, Title: entry.Label}) + } + return hits +} + +// appSearchWords is the search store's reading of every conversation's words, +// or nil where the process keeps no store. +type appSearchWords func(terms string, limit int) ([]store.ConversationHit, error) diff --git a/cmd/codeaf/apptidy_test.go b/cmd/codeaf/apptidy_test.go new file mode 100644 index 0000000000..42639d13fd --- /dev/null +++ b/cmd/codeaf/apptidy_test.go @@ -0,0 +1,78 @@ +package main + +import ( + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" +) + +// renamed is the name the fake was last given by a person. +var renamed = map[*fakeConversation]string{} + +func (f *fakeConversation) RenameByPerson(title string) error { + f.mu.Lock() + defer f.mu.Unlock() + renamed[f] = title + return nil +} + +// MOVED TO CLOSED, AN AGENT SAYS SO WITH WHEN, AND REOPENING TAKES IT OUT +// AGAIN: the conversation is let go, the moment is kept in the app's state, +// and a reopen forgets it. +func TestAppMoveToClosedAndReopen(t *testing.T) { + backend, rec, agent := workBackend(t, &fakeConversation{}) + if err := backend.MoveToClosed([]string{agent.ID}); err != nil { + t.Fatal(err) + } + rec.waitFor(t, 2*time.Second, func(event string, data any) bool { + c, ok := data.(appwire.AgentChanged) + return ok && c.Agent.ID == agent.ID && c.Agent.State == appwire.StateClosed && c.Agent.ClosedAt != nil + }) + if backend.held(agent.ID) { + t.Fatal("a closed agent's conversation is still held") + } + again, err := backend.Reopen(agent.ID) + if err != nil || again.State != appwire.StateIdle || again.ClosedAt != nil { + t.Fatalf("reopened = %+v, %v", again, err) + } + closed := false + _ = backend.state.read(func(f *appStateFile) { _, closed = f.Closed[agent.ID] }) + if closed { + t.Fatal("the closed mark outlived the reopen") + } +} + +// A RENAME IS THE PERSON'S NAME, given to the conversation itself. +func TestAppRenameGivesTheConversationThePersonsName(t *testing.T) { + conv := &fakeConversation{} + backend, _, agent := workBackend(t, conv) + view, err := backend.Rename(agent.ID, "release notes") + if err != nil || view.Title != "release notes" { + t.Fatalf("renamed = %+v, %v", view, err) + } + conv.mu.Lock() + got := renamed[conv] + conv.mu.Unlock() + if got != "release notes" { + t.Fatalf("the conversation was given %q", got) + } +} + +// THE LIST TAKES A STATE AND A QUERY: open by default, and a query matches +// the title or the folder. +func TestAppAgentsListFiltersByState(t *testing.T) { + backend, _, agent := workBackend(t, &fakeConversation{}) + open, err := backend.ListAgentsIn("", "", "") + if err != nil || len(open) != 1 || open[0].ID != agent.ID { + t.Fatalf("open = %+v, %v", open, err) + } + closed, _ := backend.ListAgentsIn("", appwire.ListClosed, "") + if len(closed) != 0 { + t.Fatalf("closed = %+v", closed) + } + none, _ := backend.ListAgentsIn("", "", "no such words anywhere") + if len(none) != 0 { + t.Fatalf("a query matched nothing it names: %+v", none) + } +} diff --git a/cmd/codeaf/appwork.go b/cmd/codeaf/appwork.go index 95b640012f..e2e920e912 100644 --- a/cmd/codeaf/appwork.go +++ b/cmd/codeaf/appwork.go @@ -269,6 +269,23 @@ func (b *appBackend) taskNotice(id string, live *appLive, notice *session.TaskNo if task.Group == "" { task.Group = before.Group } + // THE GROUP'S LABEL IS ITS RUN'S OWN ROW'S TITLE: the row with the run's + // id and no parent is the run itself. + if task.Group != "" { + if task.Parent == "" { + task.GroupTitle = task.Title + } else { + for _, other := range live.tasks { + if other.Group == task.Group && other.Parent == "" { + task.GroupTitle = other.Title + break + } + } + } + if task.GroupTitle == "" { + task.GroupTitle = before.GroupTitle + } + } if task.Result == "" && task.State == before.State { task.Result = before.Result } @@ -866,7 +883,7 @@ func (b *appBackend) ListProcesses(agent string) ([]appwire.Process, error) { } out = append(out, process) } - return out, nil + return b.quitStopped(agent, out), nil } // processLog is where one process's output is spooled. diff --git a/cmd/codeaf/engine.go b/cmd/codeaf/engine.go index 282664d2af..c88894a8ab 100644 --- a/cmd/codeaf/engine.go +++ b/cmd/codeaf/engine.go @@ -1048,6 +1048,9 @@ func assembleEngine(hello remote.Hello, workspace, sessionFlag, launchDir string if clock := hello.Launch.ProposalClockS; clock != nil { cfg.TaskAutoApproveSeconds = *clock } + cfg.ChoiceClockSeconds = hello.Launch.ChoiceClockS + cfg.JobQuitGrace = time.Duration(hello.Launch.QuitGraceS) * time.Second + cfg.StandingOrders = hello.Launch.StandingOrders } agent, cfg, notice, err := openV3Agent(cfg, workspace, open) if err != nil { diff --git a/docs/changes/unreleased/0-app-tasks-quit-clocks.md b/docs/changes/unreleased/0-app-tasks-quit-clocks.md new file mode 100644 index 0000000000..020eb0e7a8 --- /dev/null +++ b/docs/changes/unreleased/0-app-tasks-quit-clocks.md @@ -0,0 +1,21 @@ +--- +kind: added +title: codeaf app pauses handed-over tasks, resumes work after a quit, and runs a suggested choice on a clock +pr: 0 +surface: [engine] +--- + +Was true: a handed-over task (a moved reply, a run) answered "no such task" to +the app's pause, page and log; quitting the app left a reply and its tasks +reading interrupted and failed, with processes given two seconds; a choice with +a suggested answer waited forever in the app; a task could be renamed to one +file it touched ("note1.md"). + +Is true now: a handed-over task is listed, paused at its next step and resumed +(a person's hold on the whole run, `plandb.Store.HoldRun`), its log is its +worker's steps and its notes say when they were read. A quit writes down what +was mid-reply and what tasks ran, stops processes with a five-second grace and +lists them "stopped by quit", and the next start carries the reply on +("Resumed after quit") and picks the tasks up. A choice with a suggestion runs +on the app's proposed-task clock, and that clock's receipt names its length. +A single file name is never taken as a task's name. Tasks carry `group_title`. diff --git a/docs/changes/unreleased/0-app-wave6-standing-orders.md b/docs/changes/unreleased/0-app-wave6-standing-orders.md new file mode 100644 index 0000000000..7b7ef2cd12 --- /dev/null +++ b/docs/changes/unreleased/0-app-wave6-standing-orders.md @@ -0,0 +1,21 @@ +--- +kind: added +title: codeaf app serves standing orders, which run nothing before ratify and earn trust +pr: 0 +surface: [engine] +--- + +Was true: `codeaf app` served no `orders.*` methods; an automation was saved +only on the card's yes, and every saved automation ran alone on its schedule; +the app held no window, so no clock ran for it. + +Is true now: under the app's launch (`standingOrders`) the `automation` tool +saves a proposal as `proposed` before its card is up, and nothing runs before +ratify (the card's Save or `orders.ratify`). Work proposed this way asks before +each run (a new `asking` run phase, answered go, skip or not right) until five +go's make it trusted; one not right resets it. `stopped` is a new status that +is never resumed, and an automation now carries `answersIn`, `crew` and +`wont`. The app holds an open window for its whole life and serves +`orders.list/runs/ratify/change/not_now/pause/resume/stop/run` with the events +`order.changed`, `order.removed`, `order.asking`, `order.fired` and +`order.missed`. The terminal saves on the yes, as before. diff --git a/docs/changes/unreleased/0-app-wave7-tidy.md b/docs/changes/unreleased/0-app-wave7-tidy.md new file mode 100644 index 0000000000..37057e027c --- /dev/null +++ b/docs/changes/unreleased/0-app-wave7-tidy.md @@ -0,0 +1,16 @@ +--- +kind: added +title: codeaf app closes, archives, reopens, renames and finds agents +pr: 0 +surface: [engine] +--- + +Was true: the app could only close one agent for the length of the process, +had no archive, no rename and no search, and listed every agent at once. + +Is true now: `agents.move_to_closed`, `agents.archive`, `agents.reopen`, +`agents.rename`, `agents.idle` and `search.all`; `agents.list` takes a state +and a query and lists open agents by default, and an agent carries +`closed_at`, `archived_at`, `last_active_at` and `spent_usd`. Archive is the +conversation's own archive mark; a rename is the person's literal name, which +no namer replaces (`session.Agent.RenameByPerson`). diff --git a/docs/changes/unreleased/0-app-wave8-settings.md b/docs/changes/unreleased/0-app-wave8-settings.md new file mode 100644 index 0000000000..d20e2543ed --- /dev/null +++ b/docs/changes/unreleased/0-app-wave8-settings.md @@ -0,0 +1,16 @@ +--- +kind: added +title: codeaf app serves every row of the Settings window +pr: 0 +surface: [engine] +--- + +Was true: the app's settings covered the model, money, clock, idle days and +work folder only. + +Is true now: settings carry appearance, text size, open at login, the dock +badge, pinned models, the Checks and Planner models (the crew's high and +planner rows) and the fallback model; `rules.list`/`rules.remove` read and edit +the always-allow rules, `memory.list`/`memory.forget` the memory store, +`spend.export` writes the usage ledger as CSV, and `models.pin` keeps three +pinned models. diff --git a/internal/appwire/orders.go b/internal/appwire/orders.go new file mode 100644 index 0000000000..2f32cbcb35 --- /dev/null +++ b/internal/appwire/orders.go @@ -0,0 +1,283 @@ +package appwire + +import ( + "encoding/json" + "strings" + "time" +) + +// Wave 6: standing orders and reminders (PROTOCOL.md). An order IS an +// automation (internal/automation): one store, one clock, the same rows the +// terminal's /automations list reads. The methods below are the app's words +// for what that store already does, plus the standing-order half the store +// keeps beside it: the ratify step, the trust ladder and where results are +// said. + +// The events of wave 6. +const ( + EventOrderChanged = "order.changed" + EventOrderRemoved = "order.removed" + EventOrderFired = "order.fired" + EventOrderMissed = "order.missed" + EventOrderAsking = "order.asking" +) + +// The kinds of an order: a fixed line is a reminder, a brief is an order, and +// a look with a condition is a watch. +const ( + OrderKindOrder = "order" + OrderKindReminder = "reminder" + OrderKindWatch = "watch" +) + +// The states of an order. +const ( + OrderProposed = "proposed" + OrderActive = "active" + OrderPaused = "paused" + OrderStopped = "stopped" + OrderDone = "done" +) + +// The rungs of the trust ladder. +const ( + TrustAsksFirst = "asks_first" + TrustEarning = "earning" + TrustTrusted = "trusted" +) + +// Where an order's results are said. +const ( + AnswersChat = "chat" + AnswersCrew = "crew" + AnswersEverywhere = "everywhere" + AnswersNotHere = "not_here" +) + +// The results a run comes to, as an order's row says them. +const ( + ResultOK = "ok" + ResultQuiet = "quiet" + ResultIncomplete = "incomplete" + ResultSkipped = "skipped" + ResultNotRight = "not_right" +) + +// The answers to a run that asks first; not right is [VerdictNotRight], the +// same word a task's verdict uses. +const ( + VerdictGo = "go" + VerdictSkip = "skip" +) + +// OrderAskPrefix starts the id of a run's ask, "order:", which asks.answer +// also takes with the option go, skip or not_right. +const OrderAskPrefix = "order:" + +// Order is one standing order or reminder. +type Order struct { + ID string `json:"id"` + Agent string `json:"agent,omitempty"` + Crew string `json:"crew,omitempty"` + Kind string `json:"kind"` + Title string `json:"title"` + Workspace string `json:"workspace,omitempty"` + // When is the schedule as a person says it, WhenExact the same exactly. + When string `json:"when"` + WhenExact string `json:"when_exact,omitempty"` + // Does is the brief, or the reminder's line; Wont what it must never do. + Does string `json:"does"` + Wont string `json:"wont,omitempty"` + AnswersIn string `json:"answers_in"` + // Trust is the ladder's rung, ApprovedRuns how many go's it has had, and + // TrustAfter how many make it trusted. + Trust string `json:"trust"` + ApprovedRuns int `json:"approved_runs"` + TrustAfter int `json:"trust_after"` + BudgetPerRunUSD float64 `json:"budget_per_run_usd"` + TimePerRunS int `json:"time_per_run_s"` + State string `json:"state"` + FiresAt *time.Time `json:"fires_at,omitempty"` + LastRun *OrderRun `json:"last_run,omitempty"` + // Asking is the run waiting for the person's go, when one is. + Asking *OrderRun `json:"asking,omitempty"` + CreatedAt time.Time `json:"created_at"` +} + +// OrderRun is one run of an order. +type OrderRun struct { + ID int64 `json:"id"` + // AskID is set on a run waiting for the person: the id asks.answer takes. + AskID string `json:"ask_id,omitempty"` + DueAt time.Time `json:"due_at"` + At time.Time `json:"at,omitempty"` + // Result is set once it is over; Late says it started late, after a slot + // missed while the Mac was asleep or codeaf was closed. + Result string `json:"result,omitempty"` + Late bool `json:"late,omitempty"` + Line string `json:"line,omitempty"` + USD float64 `json:"usd,omitempty"` + // Transcript is the folder of the conversation a piece of work ran in. + Transcript string `json:"transcript,omitempty"` +} + +// OrderEdits is what orders.change changes; an absent field stays as it was. +type OrderEdits struct { + Title *string `json:"title,omitempty"` + When *string `json:"when,omitempty"` + Does *string `json:"does,omitempty"` + Wont *string `json:"wont,omitempty"` + AnswersIn *string `json:"answers_in,omitempty"` + Crew *string `json:"crew,omitempty"` + BudgetPerRunUSD *float64 `json:"budget_per_run_usd,omitempty"` + TimePerRunS *int `json:"time_per_run_s,omitempty"` + Trust *string `json:"trust,omitempty"` +} + +// OrderChanged is order.changed and order.missed. +type OrderChanged struct { + Order Order `json:"order"` +} + +// OrderRemoved is order.removed. +type OrderRemoved struct { + ID string `json:"id"` +} + +// OrderFired is order.fired and order.asking. +type OrderFired struct { + Order Order `json:"order"` + Run OrderRun `json:"run"` +} + +// Orderer is an engine that keeps standing orders. It is asserted, as Crewer +// is. +type Orderer interface { + Orders(agent string) ([]Order, error) + OrderRuns(id string, limit int) ([]OrderRun, error) + RatifyOrder(id string, edits *OrderEdits) (Order, error) + ChangeOrder(id string, edits OrderEdits) (Order, error) + NotNowOrder(id string) error + SetOrderState(id, state string) (Order, error) + RunOrderNow(id string) (Order, error) + OrderVerdict(id string, run int64, verdict string) (Order, error) +} + +func init() { + for name, m := range orderMethods { + methods[name] = m + } +} + +// orderCall decodes a method's params into P and answers what call answers. +func orderCall[P any](name string, call func(Orderer, P) (any, error)) method { + return func(e Engine, raw json.RawMessage) (any, error) { + o, ok := e.(Orderer) + if !ok { + return nil, Errorf(CodeUnknownMethod, "this engine does not serve %s", name) + } + var p P + if len(raw) > 0 && string(raw) != "null" { + if err := decode(raw, &p); err != nil { + return nil, err + } + } + return call(o, p) + } +} + +type orderAt struct { + ID string `json:"id"` + Edits *OrderEdits `json:"edits,omitempty"` +} + +func orderResult(o Order, err error) (any, error) { + if err != nil { + return nil, err + } + return struct { + Order Order `json:"order"` + }{o}, nil +} + +// stateCall is pause, resume and stop, which differ only in the state asked for. +func stateCall(name, state string) method { + return orderCall(name, func(o Orderer, p orderAt) (any, error) { + if err := needID(name, p.ID); err != nil { + return nil, err + } + return orderResult(o.SetOrderState(p.ID, state)) + }) +} + +var orderMethods = map[string]method{ + "orders.list": orderCall("orders.list", func(o Orderer, p struct { + Agent string `json:"agent"` + }) (any, error) { + orders, err := o.Orders(strings.TrimSpace(p.Agent)) + if err != nil { + return nil, err + } + return struct { + Orders []Order `json:"orders"` + }{nonNil(orders)}, nil + }), + "orders.runs": orderCall("orders.runs", func(o Orderer, p struct { + ID string `json:"id"` + Limit int `json:"limit"` + }) (any, error) { + if err := needID("orders.runs", p.ID); err != nil { + return nil, err + } + if p.Limit <= 0 || p.Limit > 100 { + p.Limit = 20 + } + runs, err := o.OrderRuns(p.ID, p.Limit) + if err != nil { + return nil, err + } + return struct { + Runs []OrderRun `json:"runs"` + }{nonNil(runs)}, nil + }), + "orders.ratify": orderCall("orders.ratify", func(o Orderer, p orderAt) (any, error) { + if err := needID("orders.ratify", p.ID); err != nil { + return nil, err + } + return orderResult(o.RatifyOrder(p.ID, p.Edits)) + }), + "orders.change": orderCall("orders.change", func(o Orderer, p orderAt) (any, error) { + if err := needID("orders.change", p.ID); err != nil { + return nil, err + } + if p.Edits == nil { + return nil, Errorf(CodeBadParams, "orders.change needs edits") + } + return orderResult(o.ChangeOrder(p.ID, *p.Edits)) + }), + "orders.not_now": orderCall("orders.not_now", func(o Orderer, p orderAt) (any, error) { + if err := needID("orders.not_now", p.ID); err != nil { + return nil, err + } + return struct{}{}, o.NotNowOrder(p.ID) + }), + "orders.pause": stateCall("orders.pause", OrderPaused), + "orders.resume": stateCall("orders.resume", OrderActive), + "orders.stop": stateCall("orders.stop", OrderStopped), + "orders.run": orderCall("orders.run", func(o Orderer, p struct { + ID string `json:"id"` + Run int64 `json:"run"` + Verdict string `json:"verdict"` + }) (any, error) { + if err := needID("orders.run", p.ID); err != nil { + return nil, err + } + switch p.Verdict { + case "": + return orderResult(o.RunOrderNow(p.ID)) + case VerdictGo, VerdictSkip, VerdictNotRight: + return orderResult(o.OrderVerdict(p.ID, p.Run, p.Verdict)) + } + return nil, Errorf(CodeBadParams, "verdict is go, skip or not_right") + }), +} diff --git a/internal/appwire/settings8.go b/internal/appwire/settings8.go new file mode 100644 index 0000000000..ead9b72bb1 --- /dev/null +++ b/internal/appwire/settings8.go @@ -0,0 +1,185 @@ +package appwire + +import ( + "encoding/json" + "strings" + "time" +) + +// Wave 8: the Settings window (PROTOCOL.md). settings.get and settings.set +// carry every row; the lists a row opens — always-allow rules, memories, the +// spending export, pinned models — have methods of their own. + +// The choices of the General rows. +const ( + AppearanceSystem = "system" + AppearanceLight = "light" + AppearanceDark = "dark" + + TextSmall = "small" + TextMedium = "medium" + TextLarge = "large" +) + +// PinnedModelsMax is how many models can be pinned (⌥⌘1–3). +const PinnedModelsMax = 3 + +// Rule is one always-allow rule: a tool's standing answer, or a command +// pattern's. +type Rule struct { + ID string `json:"id"` + Kind string `json:"kind"` + Tool string `json:"tool,omitempty"` + Match string `json:"match,omitempty"` + Action string `json:"action"` +} + +// The kinds of a rule. +const ( + RuleTool = "tool" + RuleBash = "bash" +) + +// Memory is one thing codeaf remembers: its words, whose it is, and who saved +// it. +type Memory struct { + ID string `json:"id"` + Title string `json:"title,omitempty"` + Text string `json:"text"` + Owner string `json:"owner"` + Project string `json:"project,omitempty"` + Origin string `json:"origin,omitempty"` +} + +// Settler is an engine that serves the Settings window's lists. It is +// asserted, as Crewer is. +type Settler interface { + Rules() ([]Rule, error) + RemoveRule(id string) error + Memories(limit int) ([]Memory, error) + ForgetMemory(id string) error + ExportSpend(from, to time.Time) (string, error) + PinModels(ids []string) (Settings, error) +} + +func init() { + for name, m := range settingsMethods { + methods[name] = m + } +} + +func settler(e Engine, name string) (Settler, error) { + s, ok := e.(Settler) + if !ok { + return nil, Errorf(CodeUnknownMethod, "this engine does not serve %s", name) + } + return s, nil +} + +// settleCall decodes a method's params into P and answers what call answers. +func settleCall[P any](name string, call func(Settler, P) (any, error)) method { + return func(e Engine, raw json.RawMessage) (any, error) { + s, err := settler(e, name) + if err != nil { + return nil, err + } + var p P + if len(raw) > 0 && string(raw) != "null" { + if err := decode(raw, &p); err != nil { + return nil, err + } + } + return call(s, p) + } +} + +// exportDay reads one end of an export's range, a calendar day. +func exportDay(name, day string) (time.Time, error) { + at, err := time.ParseInLocation("2006-01-02", strings.TrimSpace(day), time.Local) + if err != nil { + return time.Time{}, Errorf(CodeBadParams, "%s is a day, as 2026-10-15", name) + } + return at, nil +} + +var settingsMethods = map[string]method{ + "rules.list": settleCall("rules.list", func(s Settler, _ struct{}) (any, error) { + rules, err := s.Rules() + if err != nil { + return nil, err + } + return struct { + Rules []Rule `json:"rules"` + }{nonNil(rules)}, nil + }), + "rules.remove": settleCall("rules.remove", func(s Settler, p crewID) (any, error) { + if err := needID("rules.remove", p.ID); err != nil { + return nil, err + } + return struct{}{}, s.RemoveRule(p.ID) + }), + "memory.list": settleCall("memory.list", func(s Settler, p struct { + Limit int `json:"limit"` + }) (any, error) { + if p.Limit <= 0 || p.Limit > 500 { + p.Limit = 200 + } + memories, err := s.Memories(p.Limit) + if err != nil { + return nil, err + } + return struct { + Memories []Memory `json:"memories"` + }{nonNil(memories)}, nil + }), + "memory.forget": settleCall("memory.forget", func(s Settler, p crewID) (any, error) { + if err := needID("memory.forget", p.ID); err != nil { + return nil, err + } + return struct{}{}, s.ForgetMemory(p.ID) + }), + "spend.export": settleCall("spend.export", func(s Settler, p struct { + From string `json:"from"` + To string `json:"to"` + }) (any, error) { + from, err := exportDay("from", p.From) + if err != nil { + return nil, err + } + to, err := exportDay("to", p.To) + if err != nil { + return nil, err + } + if to.Before(from) { + return nil, Errorf(CodeBadParams, "to is before from") + } + path, err := s.ExportSpend(from, to.AddDate(0, 0, 1)) + if err != nil { + return nil, err + } + return struct { + Path string `json:"path"` + }{path}, nil + }), + "models.pin": settleCall("models.pin", func(s Settler, p struct { + IDs []string `json:"ids"` + }) (any, error) { + if len(p.IDs) > PinnedModelsMax { + return nil, Errorf(CodeBadParams, "at most %d models can be pinned", PinnedModelsMax) + } + var ids []string + for _, id := range p.IDs { + if id = strings.TrimSpace(id); id == "" { + return nil, Errorf(CodeBadParams, "a pinned model needs an id") + } + ids = append(ids, id) + } + settings, err := s.PinModels(ids) + if err != nil { + return nil, err + } + return struct { + Settings Settings `json:"settings"` + }{settings}, nil + }), +} diff --git a/internal/appwire/setup.go b/internal/appwire/setup.go index 38c20c7e1b..261436085f 100644 --- a/internal/appwire/setup.go +++ b/internal/appwire/setup.go @@ -177,6 +177,25 @@ func decodeSettingsPatch(raw json.RawMessage) (SettingsPatch, error) { *into = &n return nil } + flag := func(name string, into **bool) error { + var b bool + if err := json.Unmarshal(fields[name], &b); err != nil { + return Errorf(CodeBadParams, "%s must be true or false", name) + } + *into = &b + return nil + } + oneOf := func(name string, into **string, allowed ...string) error { + if err := text(name, into); err != nil { + return err + } + for _, a := range allowed { + if **into == a { + return nil + } + } + return Errorf(CodeBadParams, "%s is %s", name, strings.Join(allowed, ", ")) + } figure := func(name string, into **Figure) error { if strings.TrimSpace(string(fields[name])) == "null" { *into = &Figure{} @@ -210,6 +229,20 @@ func decodeSettingsPatch(raw json.RawMessage) (SettingsPatch, error) { err = whole(name, &patch.ProposedTaskClockS) case "idle_close_days": err = whole(name, &patch.IdleCloseDays) + case "appearance": + err = oneOf(name, &patch.Appearance, AppearanceSystem, AppearanceLight, AppearanceDark) + case "text_size": + err = oneOf(name, &patch.TextSize, TextSmall, TextMedium, TextLarge) + case "open_at_login": + err = flag(name, &patch.OpenAtLogin) + case "dock_badge": + err = flag(name, &patch.DockBadge) + case "checks_model": + err = text(name, &patch.ChecksModel) + case "planner_model": + err = text(name, &patch.PlannerModel) + case "fallback_model": + err = text(name, &patch.FallbackModel) default: err = Errorf(CodeBadParams, "settings has no field %s", name) } diff --git a/internal/appwire/tidy.go b/internal/appwire/tidy.go new file mode 100644 index 0000000000..0837468a1d --- /dev/null +++ b/internal/appwire/tidy.go @@ -0,0 +1,212 @@ +package appwire + +import ( + "encoding/json" + "strings" + "time" +) + +// Wave 7: closing, archiving, finding (PROTOCOL.md). A closed agent keeps its +// whole conversation and leaves the rail; an archived one is put away further +// and listed only when asked for. Search reaches agents, crews, tasks and the +// words of every conversation. + +// StateArchived is an agent put away: closed, and listed only when asked. +const StateArchived = "archived" + +// The listing states agents.list takes. +const ( + ListOpen = "open" + ListClosed = "closed" + ListArchived = "archived" + ListAll = "all" +) + +// The kinds of a search group. +const ( + HitAgent = "agent" + HitCrew = "crew" + HitTask = "task" + HitMessage = "message" +) + +// Hit is one search result: what it is, the agent it belongs to, its title, +// and for a message the words around the match. +type Hit struct { + Kind string `json:"kind"` + ID string `json:"id"` + Agent string `json:"agent,omitempty"` + Title string `json:"title"` + Excerpt string `json:"excerpt,omitempty"` + At *time.Time `json:"at,omitempty"` +} + +// HitGroup is the hits of one kind. +type HitGroup struct { + Kind string `json:"kind"` + Hits []Hit `json:"hits"` +} + +// Tidier is an engine that closes, archives, reopens, renames and finds. It is +// asserted, as Crewer is, so an engine without it answers unknown_method. +type Tidier interface { + ListAgentsIn(workspace, state, query string) ([]Agent, error) + MoveToClosed(ids []string) error + Archive(ids []string) error + Reopen(id string) (Agent, error) + Rename(id, title string) (Agent, error) + IdleAgents(days int) ([]string, error) + SearchAll(query string, perGroup int) ([]HitGroup, error) +} + +func init() { + for name, m := range tidyMethods { + methods[name] = m + } + // agents.list takes a state and a query when the engine can answer them. + plain := methods["agents.list"] + methods["agents.list"] = func(e Engine, raw json.RawMessage) (any, error) { + t, ok := e.(Tidier) + if !ok { + return plain(e, raw) + } + var p struct { + Workspace string `json:"workspace"` + State string `json:"state"` + Query string `json:"query"` + } + if len(raw) > 0 && string(raw) != "null" { + if err := decode(raw, &p); err != nil { + return nil, err + } + } + switch p.State { + case "", ListOpen, ListClosed, ListArchived, ListAll: + default: + return nil, Errorf(CodeBadParams, "state is open, closed, archived or all") + } + agents, err := t.ListAgentsIn(p.Workspace, p.State, p.Query) + if err != nil { + return nil, err + } + return struct { + Agents []Agent `json:"agents"` + }{nonNil(agents)}, nil + } +} + +func tidier(e Engine, name string) (Tidier, error) { + t, ok := e.(Tidier) + if !ok { + return nil, Errorf(CodeUnknownMethod, "this engine does not serve %s", name) + } + return t, nil +} + +// tidyCall decodes a method's params into P and answers what call answers. +func tidyCall[P any](name string, call func(Tidier, P) (any, error)) method { + return func(e Engine, raw json.RawMessage) (any, error) { + t, err := tidier(e, name) + if err != nil { + return nil, err + } + var p P + if len(raw) > 0 && string(raw) != "null" { + if err := decode(raw, &p); err != nil { + return nil, err + } + } + return call(t, p) + } +} + +type tidyIDs struct { + IDs []string `json:"ids"` +} + +func needIDs(name string, ids []string) error { + if len(ids) == 0 { + return Errorf(CodeBadParams, "%s needs ids", name) + } + for _, id := range ids { + if strings.TrimSpace(id) == "" { + return Errorf(CodeBadParams, "%s has an empty id", name) + } + } + return nil +} + +func agentOf(a Agent, err error) (any, error) { + if err != nil { + return nil, err + } + return agentResult{a}, nil +} + +var tidyMethods = map[string]method{ + "agents.move_to_closed": tidyCall("agents.move_to_closed", func(t Tidier, p tidyIDs) (any, error) { + if err := needIDs("agents.move_to_closed", p.IDs); err != nil { + return nil, err + } + return struct{}{}, t.MoveToClosed(p.IDs) + }), + "agents.archive": tidyCall("agents.archive", func(t Tidier, p tidyIDs) (any, error) { + if err := needIDs("agents.archive", p.IDs); err != nil { + return nil, err + } + return struct{}{}, t.Archive(p.IDs) + }), + "agents.reopen": tidyCall("agents.reopen", func(t Tidier, p crewID) (any, error) { + if err := needID("agents.reopen", p.ID); err != nil { + return nil, err + } + return agentOf(t.Reopen(p.ID)) + }), + "agents.rename": tidyCall("agents.rename", func(t Tidier, p struct { + ID string `json:"id"` + Title string `json:"title"` + }) (any, error) { + if err := needID("agents.rename", p.ID); err != nil { + return nil, err + } + if strings.TrimSpace(p.Title) == "" { + return nil, Errorf(CodeBadParams, "agents.rename needs a title") + } + return agentOf(t.Rename(p.ID, strings.TrimSpace(p.Title))) + }), + "agents.idle": tidyCall("agents.idle", func(t Tidier, p struct { + Days int `json:"days"` + }) (any, error) { + if p.Days < 0 { + return nil, Errorf(CodeBadParams, "days cannot be negative") + } + ids, err := t.IdleAgents(p.Days) + if err != nil { + return nil, err + } + return struct { + IDs []string `json:"ids"` + }{nonNil(ids)}, nil + }), + "search.all": tidyCall("search.all", func(t Tidier, p struct { + Query string `json:"query"` + PerGroup int `json:"limit_per_group"` + }) (any, error) { + if strings.TrimSpace(p.Query) == "" { + return nil, Errorf(CodeBadParams, "search.all needs a query") + } + if p.PerGroup <= 0 || p.PerGroup > 50 { + p.PerGroup = 8 + } + groups, err := t.SearchAll(strings.TrimSpace(p.Query), p.PerGroup) + if err != nil { + return nil, err + } + for i := range groups { + groups[i].Hits = nonNil(groups[i].Hits) + } + return struct { + Groups []HitGroup `json:"groups"` + }{nonNil(groups)}, nil + }), +} diff --git a/internal/appwire/wire.go b/internal/appwire/wire.go index a33ca19ddc..b6527c56a6 100644 --- a/internal/appwire/wire.go +++ b/internal/appwire/wire.go @@ -113,6 +113,13 @@ type Agent struct { UpdatedAt time.Time `json:"updated_at"` // Crews are the crews this agent is a member of, open ones only. Crews []string `json:"crews,omitempty"` + // ClosedAt is when it was moved to Closed and ArchivedAt when it was put + // away; LastActiveAt is when the person last spoke in it, and SpentUSD + // what it has spent in all. + ClosedAt *time.Time `json:"closed_at,omitempty"` + ArchivedAt *time.Time `json:"archived_at,omitempty"` + LastActiveAt time.Time `json:"last_active_at"` + SpentUSD float64 `json:"spent_usd,omitempty"` } // Task is one node of an agent's task graph. @@ -123,12 +130,12 @@ type Task struct { // agent itself started; Group names the adaptive run a task belongs to, // and GroupTitle is that run's own name, the label a person reads over // the group: the title of the run's own row. - Parent string `json:"parent,omitempty"` - Group string `json:"group,omitempty"` - GroupTitle string `json:"group_title,omitempty"` - Title string `json:"title"` - State string `json:"state"` - UpdatedAt time.Time `json:"updated_at"` + Parent string `json:"parent,omitempty"` + Group string `json:"group,omitempty"` + GroupTitle string `json:"group_title,omitempty"` + Title string `json:"title"` + State string `json:"state"` + UpdatedAt time.Time `json:"updated_at"` // Proposed is a task waiting for the person's yes; ClockUntil is when it // starts on its own, absent while the clock is held and always absent // for Irreversible work. YourCall is a finished task waiting for the @@ -330,6 +337,19 @@ type Settings struct { ProposedTaskClockS int `json:"proposed_task_clock_s"` IdleCloseDays int `json:"idle_close_days"` Effort string `json:"effort,omitempty"` + // The General rows the window draws by (Wave 8): kept here so every window + // reads the same answer. + Appearance string `json:"appearance"` + TextSize string `json:"text_size"` + OpenAtLogin bool `json:"open_at_login"` + DockBadge bool `json:"dock_badge"` + // The Models rows: the pinned models (⌥⌘1–3), the model checks run on and + // the one plans are made on (empty follows the default), and the model a + // call falls to when its own cannot answer (empty for none). + PinnedModels []string `json:"pinned_models"` + ChecksModel string `json:"checks_model"` + PlannerModel string `json:"planner_model"` + FallbackModel string `json:"fallback_model"` } // SettingsPatch is settings.set's params: only the fields present change. A @@ -345,6 +365,13 @@ type SettingsPatch struct { ProposedTaskClockS *int IdleCloseDays *int Effort *string + Appearance *string + TextSize *string + OpenAtLogin *bool + DockBadge *bool + ChecksModel *string + PlannerModel *string + FallbackModel *string } // Figure is one money field of a patch: USD is nil for a figure sent as null. diff --git a/internal/appwire/work.go b/internal/appwire/work.go index 9966f720b0..6ce02cfa1e 100644 --- a/internal/appwire/work.go +++ b/internal/appwire/work.go @@ -109,6 +109,9 @@ type Process struct { EndedAt *time.Time `json:"ended_at,omitempty"` ExitCode *int `json:"exit_code,omitempty"` LastLine string `json:"last_line,omitempty"` + // Reason is why a stopped process stopped, when something other than a + // person stopped it: "stopped by quit". + Reason string `json:"reason,omitempty"` } // The data of this wave's events. diff --git a/internal/manual/chat/running-from-the-terminal.md b/internal/manual/chat/running-from-the-terminal.md index 13490a4408..5235e5965d 100644 --- a/internal/manual/chat/running-from-the-terminal.md +++ b/internal/manual/chat/running-from-the-terminal.md @@ -395,6 +395,84 @@ and a model sent to a member as a message), a member's own budget, pausing a member (its own turns are stopped until you resume it), and an agent's proposal to bring in helpers. +## Closing, archiving and renaming agents in the desktop app — and finding one + +In the desktop app, Move to Closed lets an agent's conversation go and takes it +off the rail; nothing in it is deleted, and opening it or sending to it opens +it again. Archive closes it and puts it away with the same archive mark the +terminal's conversation list uses, so it is listed only when you ask for +archived agents; Reopen brings either back exactly as it was. Select idle picks +the open agents nobody has spoken in for the idle days in Settings (seven +unless you change it). Rename gives an agent the name you type, and codeaf never +renames it after that. Search (⌘K) finds agents and crews by name, tasks by +title, and the words of every conversation. + +## The desktop app's Settings window — what each row changes + +The desktop app's Settings window reads and writes the same places the +terminal does wherever there is one. The default model is the profile's chat +model; the Checks and Planner models are the crew's high and planner rows (the +ones /crew changes), and leaving them empty follows the default; the fallback +model is the profile's fallback list. Day cap, per-agent budget and task cap +are the spending limits; the proposed-task clock also times a choice with a +suggested answer. Appearance, text size, open at login, the dock badge and the +three pinned models (⌥⌘1–3) are the app's own. Permissions lists every +always-allow rule, a tool's or a command pattern's, and removing one takes it +out of the profile; Memory lists what codeaf remembers and forgets a line on +request; Export writes a CSV of every billed call between two days into the +app's exports folder. Signing in to GitHub is not in the app yet. + +## Quitting the desktop app — agents pause and resume, processes stop + +Quitting the desktop app does not fail the work in it. An agent in the middle +of a reply is stopped where it stands and carries on by itself the next time +the app opens, with "Resumed after quit" in front of it; a task it had running +is picked up where its record stands. A process the model started is sent +SIGTERM, then SIGKILL five seconds later, and the Processes list says "stopped +by quit". Automations are not touched by a quit and fire again once the app is +open. A conversation the app joined in a terminal window is that window's, and +quitting the app only lets go of it. + +## The desktop app's clocks — a proposed task, and a choice with a suggestion + +In the desktop app a proposed task waits on a clock (twelve seconds unless you +change it in Settings) and starts on its own when it runs out; its receipt +says how long the clock was, as in "Started on its own after 12s". A choice the +agent asks with a suggested answer runs on the same clock and takes the +suggestion when it runs out ("Picks Suggested in 12s"); holding the card or +typing in it stops the clock. A choice with no suggestion, anything +irreversible, and a project that set its own rule for choices never run on it. +In the terminal neither clock applies unless you set one with /autonomy. + +## Pausing a handed-over task in the desktop app + +A reply you moved to a task, and any task codeaf handed over to run on its own, +can be paused from the app like any other task: nothing new starts under it and +the worker stops at its next step until you resume it. Its page shows the +worker's steps as its log, and a note you leave shows when the worker read it. +A task is never renamed after a single file it touches. + +## Standing orders in the desktop app — agree to a reminder or routine before it runs, and how it earns trust + +In the desktop app, a reminder or a piece of scheduled work an agent proposes +is saved as proposed the moment its card appears, and NOTHING RUNS until you +agree: Save on the card, or Ratify in the orders list, makes it active; Don't +save or Not now takes it away; a card left unanswered keeps it proposed. These +are the same automations `/automations` lists in the terminal. + +Scheduled work proposed this way asks before each run: when it comes due it +waits for your Go, Skip or Not right (showing its budget per run), and later +times are passed over while it waits. Five Go's make it trusted, and it runs +alone after that; one Not right puts it back to asking first. Run now counts as +a Go. Reminders and watches never ask. Pause keeps the trust; Stop ends it for +good and keeps its history. Each order says where its results go: this chat, +the crew, everywhere, or not here. + +The app keeps the clock running while it is open. Nothing runs while it is +closed or the Mac sleeps; a time missed then runs once, late, and the app says +it was missed. A run waiting for your Go shows as queued in the terminal's +list, and only the desktop app can answer it. + ## Connect from the terminal without opening the chat — codeaf connect and codeaf disconnect `codeaf connect` lists every model provider this profile knows, whether it is connected, diff --git a/internal/plandb/store.go b/internal/plandb/store.go index d2c0acb733..20a3653d14 100644 --- a/internal/plandb/store.go +++ b/internal/plandb/store.go @@ -404,6 +404,21 @@ func (s *Store) Task(id string) *Task { return task } +// HeldInLineage says a person's hold is on this task or a task above it, the +// same reading the ready frontier makes ([pausedInLineage]). A worker already +// running asks it between its steps, so a hold reaches work in hand at its +// next step and not only work not yet started. An unknown task is not held. +func (s *Store) HeldInLineage(id string) bool { + s.mu.Lock() + defer s.mu.Unlock() + if s.errIfClosed() != nil { + return false + } + s.refresh() + task := s.data.Tasks[strings.TrimPrefix(id, "t-")] + return task != nil && pausedInLineage(s.data, task) +} + // Resolve answers one task for a word the model may have written loosely: // an exact id first, then `t-` + the word, then a unique prefix of either. // Ids fuzzy-match because the doctrine leans on that; a prefix that fits @@ -1092,6 +1107,28 @@ func (s *Store) hold(id string, paused bool) (*Task, error) { }) } +// HoldRun is a PERSON'S hold on the whole run, set on the root, which [Pause] +// refuses to anyone. No worker may hold the run it is part of, and this verb is +// not on the worker's command line; a person may, and the hold reads as every +// other hold does: nothing under the root leaves the ready frontier, and a +// worker already running waits at its next step ([Store.HeldInLineage]). +// Statuses are untouched, so releasing it lets the run carry on as it stood. +func (s *Store) HoldRun(paused bool) (*Task, error) { + s.mu.Lock() + root := s.data.RootID + s.mu.Unlock() + if root == "" { + return nil, errors.New("there is no run to hold") + } + return s.changeTask(root, func(next *state, task *Task, now time.Time) error { + if task.Paused == paused { + return errNoChange + } + task.Paused, task.UpdatedAt = paused, now + return nil + }) +} + // Retry reopens a failed task. The runtime uses it when a node's ending says // the work, not the store, is worth another run. func (s *Store) Retry(id string) (*Task, error) { diff --git a/internal/remote/callclass.go b/internal/remote/callclass.go index 3bcb5231fe..1744c8e06e 100644 --- a/internal/remote/callclass.go +++ b/internal/remote/callclass.go @@ -170,7 +170,7 @@ func classify(method string) callClass { MethodAutomationsList, MethodAutomationsRuns, MethodAutomationsChanges, MethodAutomationsCursor, MethodAutomationsActive, MethodAutomationsWindows: return classGetter - case MethodAppTaskAction, MethodAppQuickTaskAction, MethodAppMoveToTask, MethodProcessRerun, MethodQuestionResolve, MethodQuestionHold, + case MethodAppTaskAction, MethodAppQuickTaskAction, MethodAppMoveToTask, MethodAppResumeAfterQuit, MethodAppRename, MethodProcessRerun, MethodQuestionResolve, MethodQuestionHold, MethodConsent, MethodConsentRemember, MethodHarness, MethodConnect, MethodConnectKey, MethodNoteConnected, diff --git a/internal/remote/server.go b/internal/remote/server.go index 782a7f3137..d20afac1db 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -2076,7 +2076,7 @@ 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, MethodSubmit, MethodFollowUp, MethodUnqueueFollowUp, MethodSteer, MethodQuestionReplace, MethodSubmitImage, MethodSubmitFiles, - MethodTaskSteer, MethodTaskStop, MethodTaskRetry, MethodProcessRerun, MethodAppTaskAction, MethodAppQuickTaskAction, MethodAppMoveToTask: + MethodTaskSteer, MethodTaskStop, MethodTaskRetry, MethodProcessRerun, MethodAppTaskAction, MethodAppQuickTaskAction, MethodAppMoveToTask, MethodAppResumeAfterQuit, MethodAppRename: if err := s.mayDrive(); err != nil { return nil, err } @@ -2085,7 +2085,7 @@ func (s *server) invoke(call Frame) (out json.RawMessage, err error) { switch call.Method { case MethodProcessList, MethodProcessRerun: return serveAppProcesses(agent, call) - case MethodAppTasks, MethodAppTask, MethodAppTaskAction, MethodAppQuickTasks, MethodAppQuickTaskAction, MethodAppMoveToTask: + case MethodAppTasks, MethodAppTask, MethodAppTaskAction, MethodAppQuickTasks, MethodAppQuickTaskAction, MethodAppMoveToTask, MethodAppResumeAfterQuit, MethodAppRename: return dispatchAppTask(agent, call) case MethodTaskRetry: args, err := arg[TaskSetupArgs](call) diff --git a/internal/remote/wire.go b/internal/remote/wire.go index 98ea318e79..f662913c74 100644 --- a/internal/remote/wire.go +++ b/internal/remote/wire.go @@ -818,6 +818,17 @@ type LaunchShape struct { // ProposalClockS, when set, is how many seconds a proposed task waits before // it starts on its own ([session.Config.TaskAutoApproveSeconds]). ProposalClockS *int `json:"proposalClockS,omitempty"` + // ChoiceClockS, when above zero, is how many seconds a choice with a + // suggested answer waits before taking it ([session.Config.ChoiceClockSeconds]). + ChoiceClockS int `json:"choiceClockS,omitempty"` + // QuitGraceS, when above zero, is how many seconds the conversation's running + // processes get between SIGTERM and SIGKILL when it closes + // ([session.Config.JobQuitGrace]). + QuitGraceS int `json:"quitGraceS,omitempty"` + // StandingOrders saves what the `automation` tool proposes before the card + // is up, as proposed and not yet running ([session.Config.StandingOrders]); + // the desktop app sets it. + StandingOrders bool `json:"standingOrders,omitempty"` // LiveToolOutput sends a model's shell output as it arrives // ([session.Config.LiveToolOutput]); the desktop app sets it. LiveToolOutput bool `json:"liveToolOutput,omitempty"` diff --git a/internal/remote/wire_app_task.go b/internal/remote/wire_app_task.go index facd0f172e..cd3a083099 100644 --- a/internal/remote/wire_app_task.go +++ b/internal/remote/wire_app_task.go @@ -17,8 +17,25 @@ const ( // MethodAppMoveToTask asks that the running reply move to a task at its // next step boundary ([session.Agent.MoveTurnToTask]). MethodAppMoveToTask = "App.MoveToTask" + // MethodAppResumeAfterQuit carries on a reply the app was closed in the + // middle of ([session.Agent.ResumeAfterQuit]). + MethodAppResumeAfterQuit = "App.ResumeAfterQuit" + // MethodAppRename gives the conversation the name a person typed + // ([session.Agent.RenameByPerson]). + MethodAppRename = "App.Rename" ) +// appRenameDoor is the conversation a person can rename. +type appRenameDoor interface{ RenameByPerson(title string) error } + +// AppRenameArgs is the name a person typed. +type AppRenameArgs struct { + Title string `json:"title"` +} + +// appQuitDoor is the conversation that can carry on after the app's quit. +type appQuitDoor interface{ ResumeAfterQuit() bool } + type appTaskPageResult struct { Task session.AppTaskView Found bool @@ -39,6 +56,22 @@ func dispatchAppTask(agent any, call Frame) (json.RawMessage, error) { return nil, errors.New("engine: app tasks are unavailable") } switch call.Method { + case MethodAppRename: + rename, ok := agent.(appRenameDoor) + if !ok { + return nil, errors.New("engine: this conversation cannot be renamed") + } + args, err := arg[AppRenameArgs](call) + if err != nil { + return nil, err + } + return nil, rename.RenameByPerson(args.Title) + case MethodAppResumeAfterQuit: + quit, ok := agent.(appQuitDoor) + if !ok { + return nil, errors.New("engine: this conversation cannot carry on by itself") + } + return json.Marshal(quit.ResumeAfterQuit()) case MethodAppMoveToTask: return nil, door.MoveTurnToTask() case MethodAppTasks: @@ -125,3 +158,21 @@ func (a *Agent) MoveTurnToTask() error { _, err := a.c.call(nil, MethodAppMoveToTask, nil) return err } + +// ResumeAfterQuit asks the engine to carry on the reply the app was closed in +// the middle of, and answers whether a turn was queued. +func (a *Agent) ResumeAfterQuit() (bool, error) { + raw, err := a.c.call(nil, MethodAppResumeAfterQuit, nil) + if err != nil { + return false, err + } + var queued bool + err = json.Unmarshal(raw, &queued) + return queued, err +} + +// RenameByPerson gives the conversation the name a person typed. +func (a *Agent) RenameByPerson(title string) error { + _, err := a.c.call(nil, MethodAppRename, AppRenameArgs{Title: title}) + return err +} diff --git a/internal/run/bashworker.go b/internal/run/bashworker.go index 4df984ef94..c2e11ab1a9 100644 --- a/internal/run/bashworker.go +++ b/internal/run/bashworker.go @@ -388,6 +388,12 @@ func (w *BashWorker) Run(ctx context.Context, task plandb.Task) (rep Report, run // not end the task. _ = appendTrajectory(storeDir, task.ID, Step{Kind: trajectoryNotesKind, Notes: had}) } + // A PERSON'S HOLD STOPS THE WORK AT THIS STEP, not only the parts + // not yet started: the worker waits here, between actions, until + // the hold is released or the run is cut ([holdWhileHeld]). + if ending.kind == endingNone && !stalled { + holdWhileHeld(ctx, w.store, task.ID) + } // THE STORE'S OWN ENDING IS DETECTED AFTER THE COMMAND RUNS. A // `plandb done`, or a `plandb wait`, that the worker itself just // ran is the end of the loop: the shim's verb is already in the @@ -1008,3 +1014,19 @@ func planVerbs(command string) []string { func (w *BashWorker) clearLiveStep(taskID string) { _ = w.store.ClearLive(taskID) } + +// heldPoll is how often a held worker looks again for its release. +var heldPoll = time.Second + +// holdWhileHeld waits while a person's hold is on the task or a task above it, +// and answers at once when there is none. The model is not called while it +// waits, so a hold costs nothing, and a cut run ends the wait. +func holdWhileHeld(ctx context.Context, store *plandb.Store, taskID string) { + for store != nil && store.HeldInLineage(taskID) { + select { + case <-ctx.Done(): + return + case <-time.After(heldPoll): + } + } +} diff --git a/internal/session/agent.go b/internal/session/agent.go index d9237ec95e..95f6ebf11e 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -2548,7 +2548,11 @@ func (a *Agent) Close() error { // here is a requested one, so no job reports its own death onto a steering // queue nobody will drain. if a.jobs != nil { - a.jobs.shutdown(jobShutdownGrace) + grace := jobShutdownGrace + if a.config.JobQuitGrace > 0 { + grace = a.config.JobQuitGrace + } + a.jobs.shutdown(grace) } a.finishProcessRecords() diff --git a/internal/session/appnames.go b/internal/session/appnames.go index f749409cc6..eabf47c51c 100644 --- a/internal/session/appnames.go +++ b/internal/session/appnames.go @@ -1,6 +1,7 @@ package session import ( + "errors" "strings" ) @@ -42,3 +43,28 @@ func (a *Agent) stampLiteralName(name string) { meta.Title, meta.ShortTitle, meta.LiteralName = name, "", true }) } + +// RenameByPerson gives the conversation the name a person typed. It is the +// person's word and so outranks every namer: it is written as a literal name to +// the journal's title line, which a resume reads back, and to the folder's +// row, and a namer running beside the turn finds the conversation already +// named ([Agent.setTitleIfUnnamed]) and leaves it. +func (a *Agent) RenameByPerson(title string) error { + title = strings.Join(strings.Fields(title), " ") + if title == "" { + return errors.New("a name needs some words") + } + a.mu.Lock() + if a.closed { + a.mu.Unlock() + return errors.New("this conversation has closed") + } + a.title = title + file := a.file + a.mu.Unlock() + if file != nil { + file.appendNamedTitle(title, true) + } + a.stampLiteralName(title) + return nil +} diff --git a/internal/session/automation_contract.go b/internal/session/automation_contract.go index d74cd3d5d8..862c51667c 100644 --- a/internal/session/automation_contract.go +++ b/internal/session/automation_contract.go @@ -181,7 +181,7 @@ func AutomationQuestion(notice AutomationNotice) Question { Asker: Asker{Kind: AskerModel}, Head: AutomationHead(notice.Automation), Reason: AutomationAskReason, - Subject: SubjectRef{Kind: SubjectOrder, ID: notice.ID, Name: strings.TrimSpace(notice.Automation.Title)}, + Subject: SubjectRef{Kind: SubjectOrder, ID: notice.ID, Ref: notice.Automation.ID, Name: strings.TrimSpace(notice.Automation.Title)}, Options: options, Stakes: StakesReversible, // THE TURN IS STOPPED ON IT: the `automation` call waits for the answer diff --git a/internal/session/automation_orders_test.go b/internal/session/automation_orders_test.go new file mode 100644 index 0000000000..3ccd6b8978 --- /dev/null +++ b/internal/session/automation_orders_test.go @@ -0,0 +1,73 @@ +package session + +import ( + "context" + "testing" + + "github.com/Agent-Field/codeaf/internal/automation" +) + +// A STANDING ORDER IS SAVED AS PROPOSED BEFORE ITS CARD IS UP, named on the +// card, and runs nothing until the yes: the yes ratifies it, and a no takes +// it away again. +func TestAStandingOrderIsProposedBeforeTheCardAndRatifiedByTheYes(t *testing.T) { + for _, yes := range []bool{true, false} { + store := automationStoreFor(t) + completer := &scriptedCompleter{steps: []step{ + automationCall("a1", map[string]any{ + "op": "propose", "title": "weekly update", + "when": map[string]any{"every": "0 9 * * 1"}, "do": "draft the weekly update from the git log", + }), + finalText("done"), + }} + agent := automationAgent(t, completer, store, func(c *Config) { c.StandingOrders = true }) + events, err := agent.Submit(context.Background(), "every Monday at 9, draft the weekly update") + if err != nil { + t.Fatal(err) + } + drainAnsweringAutomation(t, events, func(event Event) { + held, err := store.Get(event.Automation.Automation.ID) + if err != nil || held.Status != automation.StatusProposed || held.Trust != automation.TrustAsksFirst { + t.Errorf("while the card is up the order is %+v, %v", held, err) + } + if q := AutomationQuestion(*event.Automation); q.Subject.Ref != held.ID { + t.Errorf("the card names %q, not the order %q", q.Subject.Ref, held.ID) + } + agent.ResolveAutomation(event.Automation.ID, AutomationAnswer{Save: yes}) + }) + all, _ := store.List() + switch { + case yes && (len(all) != 1 || all[0].Status != automation.StatusActive): + t.Fatalf("after the yes: %+v", all) + case !yes && len(all) != 0: + t.Fatalf("after the no: %+v", all) + } + } +} + +// AN ORDER AGREED TO FROM THE APP'S OWN LIST, while its card is still up, is +// not ratified twice when the card's yes arrives. +func TestAnOrderRatifiedElsewhereIsKeptByTheCardsYes(t *testing.T) { + store := automationStoreFor(t) + completer := &scriptedCompleter{steps: []step{ + automationCall("a1", map[string]any{ + "op": "propose", "title": "leave", "when": map[string]any{"in": "2h"}, "say": "time to leave", + }), + finalText("done"), + }} + agent := automationAgent(t, completer, store, func(c *Config) { c.StandingOrders = true }) + events, err := agent.Submit(context.Background(), "remind me to leave in two hours") + if err != nil { + t.Fatal(err) + } + drainAnsweringAutomation(t, events, func(event Event) { + if _, err := store.Ratify(event.Automation.Automation.ID); err != nil { + t.Error(err) + } + agent.ResolveAutomation(event.Automation.ID, AutomationAnswer{Save: true}) + }) + all, _ := store.List() + if len(all) != 1 || all[0].Status != automation.StatusActive || all[0].Trust != "" { + t.Fatalf("after the yes: %+v", all) + } +} diff --git a/internal/session/automation_tool.go b/internal/session/automation_tool.go index 769ed1c843..6d3ea200ea 100644 --- a/internal/session/automation_tool.go +++ b/internal/session/automation_tool.go @@ -203,7 +203,29 @@ func (a *Agent) automationPropose(ctx context.Context, store *automation.Store, CostWords: a.automationCostWords(item, now), Options: AutomationOptions(item), } + // A STANDING ORDER IS SAVED BEFORE THE CARD IS UP, as proposed: the app + // lists it and can agree to it from its own window, and the card names it. + // Nothing runs before the yes, and the card's no takes it away again. + proposed := "" + if a.config.StandingOrders { + item.Status = automation.StatusProposed + if item.Action.Do != "" && item.Trust == "" { + item.Trust = automation.TrustAsksFirst + } + held, err := store.Create(item) + if err != nil { + return "nothing was saved: " + err.Error(), true, nil + } + proposed, item, notice.Automation = held.ID, held, held + a.emitAutomationUpdate("proposed", held, "") + } answer, err := a.askAutomation(ctx, ¬ice) + if proposed != "" { + answer, err = a.settleProposed(store, proposed, answer, err) + if errors.Is(err, errAutomationUnanswered) { + return "the card was left unanswered — it is kept as proposed, and nothing runs until the person agrees to it", false, nil + } + } if err != nil { out, failed := automationNoAnswer(err, "set up", "saved") return out, failed, nil @@ -214,7 +236,12 @@ func (a *Agent) automationPropose(ctx context.Context, store *automation.Store, } return "nothing was saved: the person said no.", false, nil } - saved, err := store.Create(item) + var saved automation.Automation + if proposed != "" { + saved, err = store.Get(proposed) + } else { + saved, err = store.Create(item) + } if err != nil { return "nothing was saved: " + err.Error(), true, nil } @@ -229,6 +256,41 @@ func (a *Agent) automationPropose(ctx context.Context, store *automation.Store, return line + "\nThey answered the card. Your WHOLE reply is one short line saying what is now set up and when it first runs — no preamble, and do not ask again.", false, nil } +// settleProposed carries the card's answer to an order saved as proposed: a +// yes ratifies it, unless the person already agreed to it from the app's own +// window, and a no or a change takes it away. A card that went unanswered +// leaves the order proposed, listed for the person to agree to later. +func (a *Agent) settleProposed(store *automation.Store, id string, answer AutomationAnswer, err error) (AutomationAnswer, error) { + if err != nil { + current, getErr := store.Get(id) + switch { + case getErr != nil: + case current.Status != automation.StatusProposed: + return AutomationAnswer{Save: true}, nil + case !errors.Is(err, errAutomationUnanswered): + // Nobody could be asked at all, so nothing is left proposed. + _ = store.Delete(id) + } + return answer, err + } + if !answer.Save { + if current, getErr := store.Get(id); getErr == nil && current.Status == automation.StatusProposed { + _ = store.Delete(id) + } + return answer, nil + } + current, getErr := store.Get(id) + if getErr != nil { + return AutomationAnswer{}, getErr + } + if current.Status == automation.StatusProposed { + if _, err := store.Ratify(id); err != nil { + return AutomationAnswer{}, err + } + } + return answer, nil +} + // automationRunNowHandoff hands the action back to the model to do ONCE, now, // in this turn. The person is here, so any approval it needs is asked of them, // and an "always" they give is banked for the unattended runs that follow. diff --git a/internal/session/principal_structure_test.go b/internal/session/principal_structure_test.go index 1e6bf45eef..e889a6633a 100644 --- a/internal/session/principal_structure_test.go +++ b/internal/session/principal_structure_test.go @@ -37,6 +37,10 @@ import ( // the question in words. "It does not need to" is a legitimate answer and two // of these give it. var wakeRoads = map[string]string{ + "ResumeAfterQuit": "the desktop app opening again after its quit stopped a reply part-way " + + "(resume_quit.go). It is addressed to THE CONVERSATION'S OWN MODEL, to finish the answer " + + "to the person's own last message, and it only exists because that person had the app " + + "open, was answered part-way, and has opened it again", "wakeNote": "the constructor itself; it addresses nobody and carries no words of its own", "jobNote": "the registry's background-job ending; the model started this work and its tool " + "contract promised the ending would return instead of being polled", diff --git a/internal/session/resume_quit.go b/internal/session/resume_quit.go new file mode 100644 index 0000000000..755c005378 --- /dev/null +++ b/internal/session/resume_quit.go @@ -0,0 +1,27 @@ +package session + +// ── a reply the app was closed in the middle of ───────────────────────────── +// +// The desktop app's quit does not end a reply: it stops it where it stands and, +// when the app opens again, the conversation carries on by itself. The app +// keeps the list of what it stopped (cmd/codeaf's appquit.go); this is the +// engine's half, the one turn that carries on. +// +// IT IS A WAKE, NOT A MESSAGE. The words that start it are the session's own, +// written in its lane rather than as the person's ([userMessage.authored]), +// and the turn reaches every window the way any turn the conversation starts +// itself does, so a surface draws it as a turn with nothing typed in front of +// it. + +// ResumeAfterQuitNote is what the model reads at the front of the turn that +// carries on after the app was closed in the middle of its reply. +const ResumeAfterQuitNote = "The app was closed while you were in the middle of answering the person's last message, and it is open again. Carry on from where you stopped: do not redo steps that already finished, and finish the answer." + +// ResumeAfterQuit starts the turn that carries on after the app was closed in +// the middle of a reply, and reports whether it was queued. A turn already +// running takes the words at its next step instead, as any wake does. +func (a *Agent) ResumeAfterQuit() bool { + note := wakeNote(ResumeAfterQuitNote) + note.batch = false + return a.enqueueNote(note) +} diff --git a/internal/session/session.go b/internal/session/session.go index fc90621260..c723c30db2 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -2000,6 +2000,23 @@ type Config struct { // straight off the sheet, and a surface counting it down draws the same // number the person typed. TaskAutoApproveSeconds int + // ChoiceClockSeconds, when above zero, puts a clock on a choice the asker + // recommended an answer for, when the project has no row of its own for + // choices: the card shows the suggested answer and takes it when the clock + // runs out, unless the person answers first. Zero leaves a choice waiting, + // which is the terminal's arrangement; the desktop app sets it. + ChoiceClockSeconds int + // JobQuitGrace, when above zero, is how long the conversation's running + // processes are given between SIGTERM and SIGKILL when it closes, in place + // of the two seconds a terminal's quit gives them. The desktop app sets it. + JobQuitGrace time.Duration + // StandingOrders saves what the `automation` tool proposes as a proposed + // standing order before its card is up, so the app can list it and agree + // to it from its own window: the card's yes ratifies it and its no deletes + // it. Nothing runs before the yes. Work saved this way asks before each run + // until the person has trusted it (internal/automation's trust.go). The + // desktop app sets it; the terminal saves on the yes, as it always has. + StandingOrders bool // BashBackgroundAfterSeconds is how long a foreground command stays in the // turn before the same running process is kept as a job (promote.go, @@ -3456,6 +3473,9 @@ type Agent struct { // held. beltMu sync.Mutex beltRun *beltRun + // appHeldRuns is every run row a person holds from the app, by its number, + // so each republish of the row keeps saying it is held (task_app_runs.go). + appHeldRuns sync.Map // runSummaryBusy holds the roots whose card reading is being bought right // now ([Agent.RefreshRunSummary]), so a second surface that asks in the same // moment keeps the last reading rather than paying for a second one. diff --git a/internal/session/task_app.go b/internal/session/task_app.go index 95bda4cc08..6acf947484 100644 --- a/internal/session/task_app.go +++ b/internal/session/task_app.go @@ -146,41 +146,16 @@ func appNodeState(n *TaskNode) string { func (a *Agent) AppTasks() ([]AppTaskView, error) { out := []AppTaskView{} cap, _ := config.CrewTaskSpendCap(a.config.ProfileDir) - graph := a.tasker() graphIDs := map[string]bool{} - if graph != nil { - graph.mu.Lock() - nodes := make([]*TaskNode, 0, len(graph.order)) - for _, id := range graph.order { - nodes = append(nodes, graph.nodes[id]) - } - graph.mu.Unlock() - for _, node := range nodes { - if node == nil || node.spec.quick != nil { - continue - } - notice := node.notice() - _, live, _ := a.taskLiveRead(node.id, taskLiveMaxTail) - graph.mu.Lock() - view := AppTaskView{ID: strconv.FormatUint(node.id, 10), Title: notice.Title, Brief: notice.Brief, State: appNodeState(node), Model: notice.Model, Cap: cap, Spent: notice.CostUSD, Step: live.Steps, Result: notice.Report, Files: append([]string{}, notice.Changed...), Notes: node.appNotesLocked(), WorkLog: live.Lines, Elapsed: int64(notice.Elapsed.Seconds()), Version: node.app.Version + 1, Origin: "proposed"} - if node.app.Brief != "" { - view.Brief = node.app.Brief - } - if node.parent != 0 { - view.Parent = strconv.FormatUint(node.parent, 10) - } - for _, id := range node.dependsOn { - view.Waits = append(view.Waits, strconv.FormatUint(id, 10)) - } - if len(view.Files) == 0 { - view.Files = append([]string{}, node.wrote...) - } - journal := node.journal - graph.mu.Unlock() - view.WorkLog = appTaskWorkLog(view.WorkLog, journal) - graphIDs[view.ID] = true - out = append(out, view) + out = a.appGraphTasks(out, cap, graphIDs) + // A HANDED-OVER TASK is a run's row, not a node (task_app_runs.go). + for _, row := range a.appRunRows() { + id := strconv.FormatUint(row.ID, 10) + if graphIDs[id] { + continue } + graphIDs[id] = true + out = append(out, a.appRunView(row, cap)) } for _, row := range a.PlanTasks() { if graphIDs[row.ID] { @@ -203,6 +178,48 @@ func (a *Agent) AppTasks() ([]AppTaskView, error) { return out, nil } +// appGraphTasks is the task graph's own nodes as the app lists them, each +// marked in ids so the rows after it are not listed twice. +func (a *Agent) appGraphTasks(out []AppTaskView, cap float64, ids map[string]bool) []AppTaskView { + graph := a.tasker() + if graph == nil { + return out + } + graph.mu.Lock() + nodes := make([]*TaskNode, 0, len(graph.order)) + for _, id := range graph.order { + nodes = append(nodes, graph.nodes[id]) + } + graph.mu.Unlock() + for _, node := range nodes { + if node == nil || node.spec.quick != nil { + continue + } + notice := node.notice() + _, live, _ := a.taskLiveRead(node.id, taskLiveMaxTail) + graph.mu.Lock() + view := AppTaskView{ID: strconv.FormatUint(node.id, 10), Title: notice.Title, Brief: notice.Brief, State: appNodeState(node), Model: notice.Model, Cap: cap, Spent: notice.CostUSD, Step: live.Steps, Result: notice.Report, Files: append([]string{}, notice.Changed...), Notes: node.appNotesLocked(), WorkLog: live.Lines, Elapsed: int64(notice.Elapsed.Seconds()), Version: node.app.Version + 1, Origin: "proposed"} + if node.app.Brief != "" { + view.Brief = node.app.Brief + } + if node.parent != 0 { + view.Parent = strconv.FormatUint(node.parent, 10) + } + for _, id := range node.dependsOn { + view.Waits = append(view.Waits, strconv.FormatUint(id, 10)) + } + if len(view.Files) == 0 { + view.Files = append([]string{}, node.wrote...) + } + journal := node.journal + graph.mu.Unlock() + view.WorkLog = appTaskWorkLog(view.WorkLog, journal) + ids[view.ID] = true + out = append(out, view) + } + return out +} + func (a *Agent) AppTask(id string) (AppTaskView, bool, error) { rows, err := a.AppTasks() if err != nil { @@ -261,6 +278,11 @@ func (a *Agent) AppTaskAction(intent AppTaskIntent) error { return err } node := a.taskNode(id) + if numeric == nil && node == nil { + if run, ok := a.appRunRow(id); ok { + return a.appRunAction(run, intent) + } + } if numeric != nil || node == nil { return a.appPlanAction(intent) } @@ -347,6 +369,16 @@ func (a *Agent) appGraphAction(node *TaskNode, intent AppTaskIntent, row AppTask return a.appTaskNote(node, intent) case "decide": return a.appTaskDecide(node.id, intent, row.State) + case "continue": + // A task the app's quit left with nothing driving it goes back on the + // frontier as itself ([Agent.ContinueTask]); any other is left alone. + node.graph.mu.Lock() + interrupted := node.state == TaskInterrupted + node.graph.mu.Unlock() + if !interrupted { + return nil + } + return a.ContinueTask(node.id, "") } return errors.New("unknown task action") } diff --git a/internal/session/task_app_runs.go b/internal/session/task_app_runs.go new file mode 100644 index 0000000000..b1ec967d1e --- /dev/null +++ b/internal/session/task_app_runs.go @@ -0,0 +1,246 @@ +package session + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strconv" + "strings" + + "github.com/Agent-Field/codeaf/internal/plandb" +) + +// ── a handed-over task, as the app reads and moves it ─────────────────────── +// +// A TASK THE CONVERSATION HANDED OVER IS A RUN, and a run's rows are not nodes +// in the graph (orchestrate.go says why), so the app's task list read past +// them: a running hand-over drew on the rail and answered "no such task" to a +// pause, a page and a log. They are read here off the rows the run published, +// which the graph keeps, with the run's own store behind them for what the row +// does not carry: the person's notes and when the worker had each one, the +// steps it took, and whether a person's hold is on it. +// +// A HOLD ON A RUN IS A PERSON'S, ON THE STORE, AND IT REACHES THE STEP IN HAND. +// The run's own task takes the whole-run hold ([plandb.Store.HoldRun]); one of +// its parts takes the ordinary one. Nothing new starts under a hold, and a +// worker already running waits at its next step until it is released. + +// appRunRowState is a run row's state in the app's words, the same words a +// node's state is said in ([appNodeState]). +func appRunRowState(row TaskNotice) string { + if row.Stopped { + return "stopped" + } + if row.Paused && !row.State.settled() { + return "paused" + } + switch row.State { + case TaskQueued: + return "waiting" + case TaskUnverified: + return "your_call" + case TaskInterrupted: + return "paused" + default: + return string(row.State) + } +} + +// appRunRowKey is the store's id for a run row's task: the plan task it was +// published for, or its own number, which is the key a run's rows are filed +// under ([Agent.sayToRunRow]). +func appRunRowKey(row TaskNotice) string { + if key := strings.TrimSpace(row.PlanTask); key != "" { + return planTaskID(key) + } + return strconv.FormatUint(row.ID, 10) +} + +// appRunRows is every row the conversation's runs published, as they last +// stood. +func (a *Agent) appRunRows() []TaskNotice { + graph := a.tasker() + if graph == nil { + return nil + } + graph.mu.Lock() + defer graph.mu.Unlock() + return graph.runRowsLocked() +} + +// appRunRow is one run row by its number. +func (a *Agent) appRunRow(id uint64) (TaskNotice, bool) { + for _, row := range a.appRunRows() { + if row.ID == id { + return row, true + } + } + return TaskNotice{}, false +} + +// appRunView is one run row as the app's task. +func (a *Agent) appRunView(row TaskNotice, cap float64) AppTaskView { + if _, held := a.appHeldRuns.Load(row.ID); held && !row.State.settled() { + row.Paused = true + } + view := AppTaskView{ + ID: strconv.FormatUint(row.ID, 10), Title: row.Title, Brief: row.Brief, State: appRunRowState(row), + Model: row.Model, Cap: cap, Spent: row.CostUSD, Result: row.Report, Files: append([]string{}, row.Changed...), + Elapsed: int64(row.Elapsed.Seconds()), Deadline: row.Deadline, Held: row.Paused, Version: 1, Origin: "asked", + } + if row.Parent != 0 { + view.Parent = strconv.FormatUint(row.Parent, 10) + } + for _, id := range row.DependsOn { + view.Waits = append(view.Waits, strconv.FormatUint(id, 10)) + } + a.appRunDetail(&view, appRunRowKey(row)) + return view +} + +// appRunDetail fills what the row does not carry from the run's store: the +// person's notes with the step each was read at, the worker's steps, and the +// step count. +func (a *Agent) appRunDetail(view *AppTaskView, key string) { + stores, _, closeStores := a.openPlanReadHandles() + defer closeStores() + for _, store := range stores { + task := store.Task(key) + if task == nil { + continue + } + dir := filepath.Dir(store.Path()) + steps, reads := appTrajectoryRead(filepath.Join(plandb.TaskDir(dir, key), planTrajectoryFile)) + view.WorkLog = steps + if view.Step == 0 { + view.Step = len(steps) + } + if view.Brief == "" { + view.Brief = task.Description + } + for _, note := range store.Notes(key, 200) { + if note.From != plandb.NoteFromPerson { + continue + } + seen := AppTaskNote{Text: note.Body} + if step, ok := reads[note.ID]; ok { + seen.ReadAtStep = &step + } + view.Notes = append(view.Notes, seen) + } + return + } +} + +// appTrajectoryRead reads a task's trajectory: the command of every step, in +// order, and for every note the worker was handed, the step it had reached. +// A missing or unreadable file is no steps and no reads. +func appTrajectoryRead(path string) ([]string, map[string]int) { + reads := map[string]int{} + file, err := os.Open(path) + if err != nil { + return nil, reads + } + defer file.Close() + var steps []string + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) + for scanner.Scan() { + var line struct { + Kind string `json:"kind"` + Step int `json:"step"` + Command string `json:"command"` + Notes []string `json:"notes"` + } + if json.Unmarshal(scanner.Bytes(), &line) != nil { + continue + } + switch line.Kind { + case "step": + steps = append(steps, line.Command) + case "notes": + for _, id := range line.Notes { + if _, known := reads[id]; !known { + reads[id] = len(steps) + } + } + } + } + return steps, reads +} + +// appRunAction is a person's verb on a run row. +func (a *Agent) appRunAction(row TaskNotice, intent AppTaskIntent) error { + switch intent.Action { + case "note", "brief": + if strings.TrimSpace(intent.Text) == "" { + return errors.New("words required") + } + _, err := a.SteerTask(row.ID, intent.Text) + return err + case "stop": + _, err := a.cancelTask(row.ID, "") + return err + case "pause", "resume": + return a.appHoldRun(row, intent.Action == "pause") + case "continue": + // A run the app's quit left with nothing driving it is picked up where + // its store stands ([Agent.ContinueRun]); one already going is fine. + if row.State != TaskInterrupted { + return nil + } + _, err := a.ContinueRun(context.Background(), row.ID) + return err + } + return errors.New("that action is unavailable for a handed-over task") +} + +// appHoldRun sets or releases a person's hold on a run row's task in the live +// run's store, and says the row again under its new state. +func (a *Agent) appHoldRun(row TaskNotice, pause bool) error { + if row.State.settled() { + return errors.New("that task has finished") + } + key := appRunRowKey(row) + a.beltMu.Lock() + run := a.beltRun + owned := run != nil && (run.row == row.ID || containsRow(run.joined, row.ID)) + a.beltMu.Unlock() + if !owned || run.store == nil { + return errors.New("that task is not running here any more") + } + var err error + switch { + case key == run.store.RootID() || run.row == row.ID: + _, err = run.store.HoldRun(pause) + case pause: + _, err = run.store.Pause(key) + default: + _, err = run.store.Resume(key) + } + if err != nil { + return err + } + if pause { + a.appHeldRuns.Store(row.ID, true) + } else { + a.appHeldRuns.Delete(row.ID) + } + row.Paused = pause + if g := a.graph(); g != nil { + a.publishRunRow(g, row) + } + return nil +} + +func containsRow(rows []uint64, id uint64) bool { + for _, row := range rows { + if row == id { + return true + } + } + return false +} diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index ea3fbe14fa..573753c7fd 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -1646,6 +1646,15 @@ 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) + // A ROW A PERSON HOLDS STAYS HELD through every republish the run makes, + // until it is released or it settles. + if _, held := a.appHeldRuns.Load(notice.ID); held { + if notice.State.settled() { + a.appHeldRuns.Delete(notice.ID) + } else { + notice.Paused = true + } + } if crew := a.crews.get(notice.ID); crew != nil { crew.persistMu.Lock() defer crew.persistMu.Unlock() diff --git a/internal/session/taskname.go b/internal/session/taskname.go index 9499032aaa..9ea3e519eb 100644 --- a/internal/session/taskname.go +++ b/internal/session/taskname.go @@ -286,12 +286,44 @@ func taskNameNeeded(title string) bool { if title == "" || unusableName(title) { return true } - if strings.ContainsAny(title, "/\\") { + if strings.ContainsAny(title, "/\\") || bareFileName(title) { return true } return len(strings.Fields(title)) > TaskNameWords } +// bareFileName says a title is one file's name and nothing else: a single word +// with an extension that has a letter in it. +// +// IT IS FILING FOR THE SAME REASON A PATH IS. A task asked to write ten notes +// was renamed "note1.md" while it ran — the namer answered with the first file +// the work touches, and the row then named one tenth of the job. A version +// number ("v1.2.3") is not caught, because its last part has no letter. +func bareFileName(title string) bool { + if len(strings.Fields(title)) != 1 { + return false + } + dot := strings.LastIndexByte(title, '.') + if dot <= 0 || dot == len(title)-1 { + return false + } + ext := title[dot+1:] + if len(ext) > 5 { + return false + } + letter := false + for _, r := range ext { + switch { + case r >= 'a' && r <= 'z', r >= 'A' && r <= 'Z': + letter = true + case r >= '0' && r <= '9': + default: + return false + } + } + return letter +} + // taskNameSubject is what the namer reads: the gloss, then the front of the // brief. Both, because they answer different halves of "what is this" — the // gloss says what somebody would call it and the brief says what it actually diff --git a/internal/session/taskname_test.go b/internal/session/taskname_test.go index 8c99daba05..573c9b1038 100644 --- a/internal/session/taskname_test.go +++ b/internal/session/taskname_test.go @@ -63,6 +63,12 @@ func TestTaskNameNeededTellsANameFromASentenceOrAPath(t *testing.T) { {"/var/folders/j7/59f75dnd", true}, {"read /Users/me", true}, {`C:\Users\me`, true}, + // A FILE'S NAME IS FILING TOO: a task writing ten notes was renamed + // after the first of them while it ran. A version number is still a + // name, because its last part has no letter. + {"note1.md", true}, + {"README", false}, + {"v1.2.3", false}, } { if got := taskNameNeeded(c.title); got != c.want { t.Errorf("taskNameNeeded(%q) = %v, want %v", c.title, got, c.want) @@ -87,6 +93,7 @@ func TestCleanTaskNameCutsToThreeWordsAndRefusesWhatIsNotAName(t *testing.T) { {"", ""}, {" ", ""}, {"/var/folders/j7/59f75dnd", ""}, + {"note1.md", ""}, // THE THREE ANSWERS OBSERVED IN #942, and the shapes beside them. A // reasoning namer answered its plan instead of a label and the reader took // the first three words of it. A clause about the speaker is refused BY ITS diff --git a/internal/session/tools_ask.go b/internal/session/tools_ask.go index 4eaee4b8ef..675313e002 100644 --- a/internal/session/tools_ask.go +++ b/internal/session/tools_ask.go @@ -189,6 +189,14 @@ func (a *Agent) executeAsk(ctx context.Context, raw json.RawMessage) (string, bo // its own is waiting and this engine cannot derive that from anything. q.Blocking = Blocking{Turn: waits, Tasks: in.Blocking.Tasks} q.Policy = a.autonomyFor(q.Ask) + // A CHOICE WITH A SUGGESTED ANSWER RUNS ON THE SURFACE'S CLOCK when the + // surface asked for one and the project set nothing for choices itself; a + // choice with no suggestion has nothing for a clock to take, and stays. + if secs := a.config.ChoiceClockSeconds; secs > 0 && q.Ask == AskChoice && q.Pick != nil && q.Policy.Kind == PolicyAsk { + if _, set := a.readAutonomy()[AskChoice]; !set { + q.Policy = Policy{Kind: PolicyRecommendThenAuto, After: time.Duration(secs) * time.Second} + } + } if q.Stakes == StakesIrreversible { q.Policy = Policy{Kind: PolicyAsk} } From eb876ed937cd9dc7938e1d066c3cc8a87ae7c35e Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Thu, 15 Oct 2026 12:05:21 -0400 Subject: [PATCH 015/459] app v3 wave 6: a proposed order's card is a proposal, and a run not started has no time The trace showed the card for an order saved as proposed headed as a change, because it already had an id; it is now headed as the proposal it is. A run still asking carried a zero start time on the wire; it now carries none. --- cmd/codeaf/apporders.go | 11 ++++++++--- internal/appwire/orders.go | 3 ++- internal/session/automation_contract.go | 4 +++- internal/session/automation_orders_test.go | 3 +++ 4 files changed, 16 insertions(+), 5 deletions(-) diff --git a/cmd/codeaf/apporders.go b/cmd/codeaf/apporders.go index be0ce77fab..202cadc42a 100644 --- a/cmd/codeaf/apporders.go +++ b/cmd/codeaf/apporders.go @@ -167,15 +167,20 @@ func orderView(store *automation.Store, a automation.Automation, now time.Time) // orderRunView is one run as an order's row says it. func orderRunView(run automation.Run) appwire.OrderRun { - v := appwire.OrderRun{ID: run.ID, DueAt: run.Due, At: run.Started, Late: run.Why == automation.WhyLate, Line: run.Line, USD: run.USD, Transcript: run.Transcript} + v := appwire.OrderRun{ID: run.ID, DueAt: run.Due, Late: run.Why == automation.WhyLate, Line: run.Line, USD: run.USD, Transcript: run.Transcript} + if !run.Started.IsZero() { + at := run.Started + v.At = &at + } if run.Phase == automation.PhaseAsking { v.AskID = appwire.OrderAskPrefix + strconv.FormatInt(run.ID, 10) } if run.Phase != automation.PhaseOver { return v } - if v.At.IsZero() { - v.At = run.Finished + if v.At == nil && !run.Finished.IsZero() { + at := run.Finished + v.At = &at } switch run.Outcome { case automation.OutcomeSkipped: diff --git a/internal/appwire/orders.go b/internal/appwire/orders.go index 2f32cbcb35..e12cc8f85d 100644 --- a/internal/appwire/orders.go +++ b/internal/appwire/orders.go @@ -110,7 +110,8 @@ type OrderRun struct { // AskID is set on a run waiting for the person: the id asks.answer takes. AskID string `json:"ask_id,omitempty"` DueAt time.Time `json:"due_at"` - At time.Time `json:"at,omitempty"` + // At is when it started, or ended for a run that never started. + At *time.Time `json:"at,omitempty"` // Result is set once it is over; Late says it started late, after a slot // missed while the Mac was asleep or codeaf was closed. Result string `json:"result,omitempty"` diff --git a/internal/session/automation_contract.go b/internal/session/automation_contract.go index 862c51667c..c126c2b4dd 100644 --- a/internal/session/automation_contract.go +++ b/internal/session/automation_contract.go @@ -98,7 +98,9 @@ const AutomationChangeHead = "save this change?" // that tells the two cards apart, on the engine's side of the wire and on every // surface's, without a field of its own to keep in step. func AutomationHead(item automation.Automation) string { - if strings.TrimSpace(item.ID) != "" { + // A standing order saved as proposed before its card has an id and is + // still a proposal, never a change. + if strings.TrimSpace(item.ID) != "" && item.Status != automation.StatusProposed { return AutomationChangeHead } switch item.Kind() { diff --git a/internal/session/automation_orders_test.go b/internal/session/automation_orders_test.go index 3ccd6b8978..4eb326b1a0 100644 --- a/internal/session/automation_orders_test.go +++ b/internal/session/automation_orders_test.go @@ -33,6 +33,9 @@ func TestAStandingOrderIsProposedBeforeTheCardAndRatifiedByTheYes(t *testing.T) if q := AutomationQuestion(*event.Automation); q.Subject.Ref != held.ID { t.Errorf("the card names %q, not the order %q", q.Subject.Ref, held.ID) } + if head := AutomationHead(event.Automation.Automation); head == AutomationChangeHead { + t.Errorf("a proposed order's card is headed as a change: %q", head) + } agent.ResolveAutomation(event.Automation.ID, AutomationAnswer{Save: yes}) }) all, _ := store.List() From 5ea1c6893eb864c2f01c805444b25843c8c45940 Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Thu, 15 Oct 2026 12:21:40 -0400 Subject: [PATCH 016/459] app v3: crews, live lines and working on every agent row; one start per line; process ids apart Every agent.changed and every listing is dressed on its way out with the agent's crews, its live line and working while a turn runs, and an agent is said again when its crews change; a crew member's line is the agent's. A manager's start line is claimed in the teams file, so the app and a terminal holding one manager open one member. Process ids carry the job's start, so a restart's p1 is not the quit's p1. A joining window's launch is applied as far as one conversation carries it for two windows (live command output), and the app looks through every host before refusing to open a conversation another window holds. --- cmd/codeaf/app.go | 46 ++++++++-- cmd/codeaf/appcrews.go | 50 +++++++++- cmd/codeaf/appdress_test.go | 57 ++++++++++++ cmd/codeaf/appinbox.go | 6 ++ cmd/codeaf/apptidy.go | 44 ++++++++- cmd/codeaf/appwork.go | 91 +++++++++++++------ cmd/codeaf/appwork_test.go | 4 +- cmd/codeaf/engine.go | 12 +++ .../changes/unreleased/0-app-limits-closed.md | 25 +++++ internal/appwire/wire.go | 3 + internal/enginehost/enginehost.go | 9 ++ .../manual/chat/running-from-the-terminal.md | 19 ++++ internal/remote/server.go | 21 +++++ internal/session/live_output_join.go | 19 ++++ internal/session/loop.go | 2 +- internal/session/session.go | 4 + internal/teams/startclaim.go | 38 ++++++++ internal/teams/startclaim_test.go | 36 ++++++++ internal/teams/teams.go | 11 ++- internal/tui3/teamtraffic.go | 15 ++- 20 files changed, 464 insertions(+), 48 deletions(-) create mode 100644 cmd/codeaf/appdress_test.go create mode 100644 docs/changes/unreleased/0-app-limits-closed.md create mode 100644 internal/session/live_output_join.go create mode 100644 internal/teams/startclaim.go create mode 100644 internal/teams/startclaim_test.go diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index 9fbec8df9c..6083ae926c 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -32,6 +32,7 @@ import ( "encoding/json" "errors" "fmt" + "net" "net/http" "os" "os/signal" @@ -224,23 +225,49 @@ type appConn struct { type appOpener func(appOpenAsk) (*appConn, error) +// appHostDialTimeout bounds each host dial while looking for the one holding +// a conversation. +const appHostDialTimeout = 500 * time.Millisecond + // appOpenInProcess opens a conversation for the app. A saved conversation that // a window's host already holds is JOINED there, as a second view of the same // conversation, because opening it here as well would be a second writer on its // journal. Everything else is built in this process. func appOpenInProcess(ask appOpenAsk) (*appConn, error) { + clock := ask.ClockS + launch := &remote.LaunchShape{Interactive: true, DeterministicNames: true, HandOverOnAsk: true, ProposalClockS: &clock, ChoiceClockS: clock, QuitGraceS: appQuitGraceS, LiveToolOutput: true, StandingOrders: true} if ask.Transcript != "" && !ask.Minted { + // A JOIN CARRIES THE APP'S LAUNCH, and the engine applies what of it a + // conversation another window opened can carry (engine.go's JoinedWith). + hello := remote.Hello{Workspace: ask.Workspace, Session: ask.Transcript, Join: true, Launch: launch} if socket, err := enginehost.DialStartingHost(ask.Workspace); err == nil { - client, err := remote.Dial(socket, "", remote.Hello{Workspace: ask.Workspace, Session: ask.Transcript, Join: true}) + client, err := remote.Dial(socket, "", hello) if err == nil { return appConnOf(client, func() { _ = client.Close() }, false), nil } _ = socket.Close() } + // HELD, BUT NOT BY THIS WORKSPACE'S HOST: a crew member a manager + // started lives in its manager's host. Every host is asked, and the + // one holding it answers; opening it here as well would be refused by + // its journal's lock, and is never tried while somebody holds it. + if session.InUse(ask.Transcript) { + for _, path := range enginehost.Sockets() { + socket, err := net.DialTimeout("unix", path, appHostDialTimeout) + if err != nil { + continue + } + client, err := remote.Dial(socket, "", hello) + if err == nil { + return appConnOf(client, func() { _ = client.Close() }, false), nil + } + _ = socket.Close() + } + return nil, appwire.Errorf(appwire.CodeConflict, "this conversation is open in another codeaf window that the app cannot reach; close it there, or open it in that window") + } } workspace := ask.Workspace transcript := ask.Transcript - clock := ask.ClockS hello := remote.Hello{ Workspace: workspace, Session: transcript, @@ -250,7 +277,7 @@ func appOpenInProcess(ask appOpenAsk) (*appConn, error) { // own naming does not rename it afterwards. // And only the person moves a running reply to a task, and a proposed // task's clock is the app's, as is a suggested choice's. - Launch: &remote.LaunchShape{Interactive: true, DeterministicNames: true, HandOverOnAsk: true, ProposalClockS: &clock, ChoiceClockS: clock, QuitGraceS: appQuitGraceS, LiveToolOutput: true, StandingOrders: true}, + Launch: launch, } loop, err := remote.Loopback(hello, remote.Options{ Boot: func(h remote.Hello) (*remote.Engine, error) { @@ -347,6 +374,9 @@ type appAgent struct { // failedKind is how that turn failed; a turn refused for money is run // again when the limit is raised. failedKind string + // step is the title of the call the running turn is on, the live line + // a crew's member row and the agent's own row show while it works. + step string // boot is what agents.create asked of a conversation not yet open, taken // by whichever road opens it first; opening says one is being assembled. boot *appBoot @@ -578,14 +608,16 @@ func (b *appBackend) remember(row session.SessionRow) appwire.Agent { if a.transcript == "" { a.transcript = row.Transcript } - if a.live != nil || a.view.State == appwire.StateClosed || a.view.State == appwire.StateStopped { + disk := appRowView(row) + busyElsewhere := a.live == nil && (disk.State == appwire.StateWorking || disk.State == appwire.StateNeedsYou) + if !busyElsewhere && (a.live != nil || a.view.State == appwire.StateClosed || a.view.State == appwire.StateStopped) { view := a.view if view.Title == "" { view.Title = row.Title } return view } - a.view = appRowView(row) + a.view = disk return a.view } @@ -697,7 +729,7 @@ func (b *appBackend) ListAgents(workspace string) ([]appwire.Agent, error) { }) sort.SliceStable(agents, func(i, j int) bool { return agents[i].UpdatedAt.After(agents[j].UpdatedAt) }) for i := range agents { - agents[i].Crews = b.crewsOf(agents[i].ID) + b.dress(&agents[i]) } return agents, nil } @@ -1484,6 +1516,7 @@ func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, n sayReply(appwire.EntryUpdate) title := redact.Secrets(tui3.ToolTitle(event.Tool, event.Args, event.Hint)) titles[event.CallID] = title + b.locked(func() { a.step = title }) row := b.callBegan(a, id, turn, event, title) row.Summary = event.Hint live.box.Event(appwire.EventTurnTool, row) @@ -1581,6 +1614,7 @@ func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, n b.locked(func() { live.running = false live.lastUse = b.now() + a.step = "" // Let go or closed while the turn ran: that road has said the state. if held = a.live == live; held { a.view.State = live.restingState() diff --git a/cmd/codeaf/appcrews.go b/cmd/codeaf/appcrews.go index 2c817fee2c..8736e9e507 100644 --- a/cmd/codeaf/appcrews.go +++ b/cmd/codeaf/appcrews.go @@ -207,6 +207,7 @@ func (b *appBackend) crewTick(run *appCrewRun, first bool) { var changed []appwire.Crew var removed []string var members []appwire.CrewMember + var moved []string run.locked(func() { for id, view := range views { raw, _ := json.Marshal(view) @@ -233,6 +234,9 @@ func (b *appBackend) crewTick(run *appCrewRun, first bool) { removed = append(removed, id) } } + if !first { + moved = appMembershipMoved(run.held, held) + } run.held = held }) sort.Slice(changed, func(i, j int) bool { return changed[i].ID < changed[j].ID }) @@ -245,6 +249,15 @@ func (b *appBackend) crewTick(run *appCrewRun, first bool) { for _, id := range removed { b.emit.Emit(appwire.EventCrewRemoved, appwire.CrewRemoved{ID: id}) } + // AN AGENT WHOSE CREWS CHANGED IS SAID AGAIN, so its row's crews are + // never older than the crews' own. + for _, id := range moved { + var a *appAgent + b.locked(func() { a = b.agents[id] }) + if a != nil { + b.sayAgent(a) + } + } for _, t := range read.file.Teams { if !t.Closed() { b.crewTraffic(run, read, t) @@ -254,6 +267,23 @@ func (b *appBackend) crewTick(run *appCrewRun, first bool) { b.holdMembers(run, held) } +// appMembershipMoved is every agent whose open crews differ between two looks. +func appMembershipMoved(before, after map[string][]string) []string { + var moved []string + for id, crews := range after { + if strings.Join(crews, ",") != strings.Join(before[id], ",") { + moved = append(moved, id) + } + } + for id := range before { + if _, still := after[id]; !still { + moved = append(moved, id) + } + } + sort.Strings(moved) + return moved +} + // ── the view ──────────────────────────────────────────────────────────────── // crewView is one team as a crew. @@ -388,7 +418,7 @@ func (b *appBackend) memberView(read appCrewRead, t teamstore.Team, m teamstore. view.AlsoIn = append(view.AlsoIn, other.ID) } } - view.State, view.Model, view.Title = b.memberLive(agent, view.Title) + view.State, view.Model, view.Title, view.Line = b.memberLive(agent, view.Title) if usd, set := b.agentBudget(agent, b.now()); set { view.CapUSD = &usd } @@ -403,7 +433,7 @@ func (b *appBackend) memberView(read appCrewRead, t teamstore.Team, m teamstore. // memberLive is a member's state, model and title as the app holds its agent: // the engine's member words (running, asking, idle, failed). -func (b *appBackend) memberLive(agent, title string) (state, model, name string) { +func (b *appBackend) memberLive(agent, title string) (state, model, name, line string) { state, name = teamstore.StateIdle, title b.locked(func() { a := b.agents[agent] @@ -422,8 +452,9 @@ func (b *appBackend) memberLive(agent, title string) (state, model, name string) case a.failed != "": state = teamstore.StateFailed } + line = agentLineLocked(a, a.view.State) }) - return state, model, name + return state, model, name, line } // ── the log, and the manager's starts and stops ───────────────────────────── @@ -520,11 +551,20 @@ func (b *appBackend) crewAct(run *appCrewRun, read appCrewRead, t teamstore.Team if !ok || !b.isHeld(appMemberAgent(manager)) { return } - // ANOTHER WINDOW HOLDING THE SAME MANAGER MAY HAVE DONE IT ALREADY: a - // member under the handle that joined after the line is that start. + // ANOTHER WINDOW HOLDING THE SAME MANAGER — a terminal the app joined — + // CARRIES IT OUT INSTEAD when it claims the line first (internal/teams' + // startclaim.go). A member under the handle that joined after the line + // is a start by a window from before the claim. if m, ok := t.ByHandle(e.To); ok && !m.JoinedAt.Before(e.At) { return } + claimed := false + if err := teamstore.Update(b.crewProfile(), func(f *teamstore.File) error { + claimed = f.ClaimStart(t.ID, e.ID) + return nil + }); err != nil || !claimed { + return + } guard.Go("app/crew-start", func() { b.crewStart(t, manager, e) }) } } diff --git a/cmd/codeaf/appdress_test.go b/cmd/codeaf/appdress_test.go new file mode 100644 index 0000000000..fdfb291e20 --- /dev/null +++ b/cmd/codeaf/appdress_test.go @@ -0,0 +1,57 @@ +package main + +import ( + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/session" +) + +// EVERY agent.changed SAYS WORKING WHILE A TURN RUNS, WITH ITS LIVE LINE, +// whichever road said it: here a rename, which knows nothing of the turn. +func TestAgentChangedIsDressedWithWorkingAndItsLine(t *testing.T) { + backend, rec, agent := workBackend(t, &fakeConversation{}) + a, _ := backend.find(agent.ID) + if _, _, err := backend.heldLive(agent.ID); err != nil { + t.Fatal(err) + } + backend.locked(func() { + a.live.running = true + a.step = "Reading main.go" + a.view.State = appwire.StateIdle + }) + backend.sayAgent(a) + rec.waitFor(t, 2*time.Second, func(event string, data any) bool { + c, ok := data.(appwire.AgentChanged) + return ok && c.Agent.ID == agent.ID && c.Agent.State == appwire.StateWorking && c.Agent.Line == "Reading main.go" + }) + listed, err := backend.ListAgents("") + if err != nil { + t.Fatal(err) + } + for _, view := range listed { + if view.ID == agent.ID && (view.State != appwire.StateWorking || view.Line != "Reading main.go") { + t.Fatalf("the list says %+v", view) + } + } +} + +// A PROCESS ID NAMES ONE PROCESS ACROSS RESTARTS: the job's number starts +// again at 1 in a new conversation, and its start time tells them apart. +func TestProcessIDsAreUniqueAcrossRestarts(t *testing.T) { + first := session.JobNotice{ID: 1, Started: time.Date(2026, 10, 15, 9, 0, 0, 0, time.UTC)} + again := session.JobNotice{ID: 1, Started: first.Started.Add(time.Hour)} + one, two := appProcessID("a", first), appProcessID("a", again) + if one == two || !strings.HasPrefix(one, "a:p1.") { + t.Fatalf("ids %q and %q", one, two) + } + agent, job, stamp, ok := appProcessJob(one) + if !ok || agent != "a" || job != 1 || stamp != appProcessStamp(first) { + t.Fatalf("parsed %q, %d, %q, %v", agent, job, stamp, ok) + } + if _, job, stamp, ok := appProcessJob("a:p7"); !ok || job != 7 || stamp != "" { + t.Fatal("an id without a stamp is still read") + } +} diff --git a/cmd/codeaf/appinbox.go b/cmd/codeaf/appinbox.go index 0d7c3d3cb2..54740fe463 100644 --- a/cmd/codeaf/appinbox.go +++ b/cmd/codeaf/appinbox.go @@ -71,6 +71,12 @@ type appInboxTap struct { } func (t *appInboxTap) Emit(event string, data any) { + // EVERY agent.changed IS DRESSED ON ITS WAY OUT, whichever road said it: + // its crews, its live line, and working while a turn runs. + if changed, ok := data.(appwire.AgentChanged); ok { + t.b.dress(&changed.Agent) + data = changed + } t.next.Emit(event, data) t.b.inboxSaw(event, data) } diff --git a/cmd/codeaf/apptidy.go b/cmd/codeaf/apptidy.go index 841e90287f..ea11ef94cf 100644 --- a/cmd/codeaf/apptidy.go +++ b/cmd/codeaf/apptidy.go @@ -31,7 +31,9 @@ type appRenamer interface { // tidyMarks lays the app's closed and archived marks over a listed agent. func (b *appBackend) tidyMarks(view *appwire.Agent, archived bool) { - held := b.held(view.ID) + // A CONVERSATION AT WORK OR WAITING ON THE PERSON IS SAID AS SUCH, whatever + // mark it carries: a terminal may be working in an agent the app closed. + held := b.held(view.ID) || view.State == appwire.StateWorking || view.State == appwire.StateNeedsYou _ = b.state.read(func(f *appStateFile) { if at, ok := f.Closed[view.ID]; ok && !held { at := at @@ -233,7 +235,6 @@ func (b *appBackend) sayAgent(a *appAgent) appwire.Agent { var view appwire.Agent b.locked(func() { view = a.view }) b.tidyMarks(&view, view.State == appwire.StateArchived) - view.Crews = b.crewsOf(view.ID) b.emit.Emit(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) return view } @@ -305,3 +306,42 @@ func (b *appBackend) searchTasks(query string, limit int) []appwire.Hit { // appSearchWords is the search store's reading of every conversation's words, // or nil where the process keeps no store. type appSearchWords func(terms string, limit int) ([]store.ConversationHit, error) + +// dress lays what this process knows now over an agent about to be said: the +// crews it is a member of, its live line, and working while a turn it holds +// is running. A queued agent.changed reaches the window after the moment it +// was made, so it is dressed with the state as it is when it goes out; it is +// only ever moved up from idle, never down from what a road said. +func (b *appBackend) dress(view *appwire.Agent) { + view.Crews = b.crewsOf(view.ID) + b.locked(func() { + a := b.agents[view.ID] + if a == nil { + return + } + live := a.live + if live != nil && live.running && view.State == appwire.StateIdle && !live.needsYou() { + view.State = appwire.StateWorking + } + view.Line = agentLineLocked(a, view.State) + }) +} + +// agentLineLocked is an agent's live one-liner for its state: the step it is +// on, or the question it is waiting on, and nothing when there is nothing to +// say. Callers hold b.mu. +func agentLineLocked(a *appAgent, state string) string { + switch state { + case appwire.StateWorking: + return a.step + case appwire.StateNeedsYou: + if live := a.live; live != nil { + for _, ask := range live.asks { + if !ask.Later { + return ask.Title + } + } + } + } + return "" +} diff --git a/cmd/codeaf/appwork.go b/cmd/codeaf/appwork.go index e2e920e912..8cefbc4ba5 100644 --- a/cmd/codeaf/appwork.go +++ b/cmd/codeaf/appwork.go @@ -54,8 +54,29 @@ const appPromoteWait = 5 * time.Second // appTaskID is a task's or a quick task's id on the wire. func appTaskID(agent string, n uint64) string { return agent + ":" + strconv.FormatUint(n, 10) } -// appProcessID is a process's id on the wire. -func appProcessID(agent string, job int) string { return agent + ":p" + strconv.Itoa(job) } +// appProcessID is a process's id on the wire: its agent, the job's number, +// and when it started. +// +// THE START IS PART OF THE ID because a job's number is only its +// conversation's count, and that count starts again at 1 when the app is +// opened again: the "p1" a quit stopped and the "p1" started after it are two +// processes, and a window holding the first must never stop or rerun the +// second. +func appProcessID(agent string, job session.JobNotice) string { + id := agent + ":p" + strconv.Itoa(job.ID) + if stamp := appProcessStamp(job); stamp != "" { + id += "." + stamp + } + return id +} + +// appProcessStamp is a job's start, as the last part of its id. +func appProcessStamp(job session.JobNotice) string { + if job.Started.IsZero() { + return "" + } + return strconv.FormatInt(job.Started.UnixMilli(), 36) +} // appSplitID is an id's agent and the agent's own number for the thing. func appSplitID(id string) (agent, local string, ok bool) { @@ -66,14 +87,35 @@ func appSplitID(id string) (agent, local string, ok bool) { return id[:i], id[i+1:], true } -// appProcessJob is a process id's agent and job number. -func appProcessJob(id string) (string, int, bool) { +// appProcessJob is a process id's agent, job number and start stamp ("" for +// an id written without one). +func appProcessJob(id string) (string, int, string, bool) { agent, local, ok := appSplitID(id) if !ok || !strings.HasPrefix(local, "p") { - return "", 0, false + return "", 0, "", false + } + number, stamp, _ := strings.Cut(local[1:], ".") + job, err := strconv.Atoi(number) + return agent, job, stamp, err == nil && job > 0 +} + +// processRow is the job a process id names, and none when the conversation's +// job of that number is a different process, started at another time. +func (b *appBackend) processRow(id string) (string, session.JobNotice, error) { + agent, job, stamp, ok := appProcessJob(id) + if !ok { + return "", session.JobNotice{}, appwire.Errorf(appwire.CodeBadParams, "%s is not a process id", id) + } + rows, _, err := b.processRows(agent) + if err != nil { + return "", session.JobNotice{}, err + } + for _, row := range rows { + if row.ID == job && (stamp == "" || appProcessStamp(row) == stamp) { + return agent, row, nil + } } - job, err := strconv.Atoi(local[1:]) - return agent, job, err == nil && job > 0 + return "", session.JobNotice{}, appwire.Errorf(appwire.CodeNotFound, "there is no process %s", id) } // held answers the held conversation for an agent id, opening it if none is. @@ -762,7 +804,7 @@ type appFollow struct { // appProcessOf is one job notice on the wire. func appProcessOf(agent string, job session.JobNotice) appwire.Process { process := appwire.Process{ - ID: appProcessID(agent, job.ID), Agent: agent, Title: redact.Secrets(job.Label()), Command: redact.Secrets(job.Command), + ID: appProcessID(agent, job), Agent: agent, Title: redact.Secrets(job.Label()), Command: redact.Secrets(job.Command), StartedAt: job.Started, LastLine: redact.Secrets(appLastLine(job.LogPath)), } switch job.State { @@ -888,20 +930,11 @@ func (b *appBackend) ListProcesses(agent string) ([]appwire.Process, error) { // processLog is where one process's output is spooled. func (b *appBackend) processLog(id string) (string, error) { - agent, job, ok := appProcessJob(id) - if !ok { - return "", appwire.Errorf(appwire.CodeBadParams, "%s is not a process id", id) - } - rows, _, err := b.processRows(agent) + _, row, err := b.processRow(id) if err != nil { return "", err } - for _, row := range rows { - if row.ID == job { - return row.LogPath, nil - } - } - return "", appwire.Errorf(appwire.CodeNotFound, "there is no process %s", id) + return row.LogPath, nil } // appReadLines reads a spool's complete lines from line from, at most limit, @@ -1026,10 +1059,11 @@ func (b *appBackend) follow(id, path string, from int, f *appFollow) { // StopProcess stops one running process. func (b *appBackend) StopProcess(id string) error { - agent, job, ok := appProcessJob(id) - if !ok { - return appwire.Errorf(appwire.CodeBadParams, "%s is not a process id", id) + agent, row, err := b.processRow(id) + if err != nil { + return err } + job := row.ID _, live, err := b.heldLive(agent) if err != nil { return err @@ -1042,19 +1076,20 @@ func (b *appBackend) StopProcess(id string) error { // RerunProcess runs an ended process's command again, as a new process. func (b *appBackend) RerunProcess(id string) (appwire.Process, error) { - agent, job, ok := appProcessJob(id) - if !ok { - return appwire.Process{}, appwire.Errorf(appwire.CodeBadParams, "%s is not a process id", id) + agent, row, err := b.processRow(id) + if err != nil { + return appwire.Process{}, err } + job := row.ID _, live, err := b.heldLive(agent) if err != nil { return appwire.Process{}, err } - row, err := live.conn.Conv.RerunProcess(job) + again, err := live.conn.Conv.RerunProcess(job) if err != nil { return appwire.Process{}, appEngineError(err) } - return appProcessOf(agent, row), nil + return appProcessOf(agent, again), nil } // ── running long ──────────────────────────────────────────────────────────── @@ -1165,7 +1200,7 @@ func (b *appBackend) Sleep() error { continue } if _, err := live.conn.Conv.Cancel("job:" + strconv.Itoa(row.ID)); err == nil { - slept := appSlept{id: appProcessID(id, row.ID), label: row.Label()} + slept := appSlept{id: appProcessID(id, row), label: row.Label()} b.locked(func() { live.slept = append(live.slept, slept) }) } } diff --git a/cmd/codeaf/appwork_test.go b/cmd/codeaf/appwork_test.go index 36c8a151f3..2ff97ba682 100644 --- a/cmd/codeaf/appwork_test.go +++ b/cmd/codeaf/appwork_test.go @@ -218,7 +218,7 @@ func TestAppProcessesOutputFollowStopAndSleep(t *testing.T) { rec.mu.Lock() process := rec.data[i].(appwire.ProcessChanged).Process rec.mu.Unlock() - id := agent.ID + ":p3" + id := appProcessID(agent.ID, job) if process.ID != id || process.Title != "link checker" || process.State != appwire.ProcessRunning || process.LastLine != "ok /docs" || process.ExitCode != nil { t.Fatalf("the process = %+v", process) } @@ -257,7 +257,7 @@ func TestAppProcessesOutputFollowStopAndSleep(t *testing.T) { t.Fatal(err) } rerun, err := backend.RerunProcess(id) - if err != nil || rerun.ID != agent.ID+":p103" || rerun.State != appwire.ProcessRunning { + if err != nil || !strings.HasPrefix(rerun.ID, agent.ID+":p103") || rerun.State != appwire.ProcessRunning { t.Fatalf("rerun = %+v, %v", rerun, err) } diff --git a/cmd/codeaf/engine.go b/cmd/codeaf/engine.go index c88894a8ab..a198e3adb1 100644 --- a/cmd/codeaf/engine.go +++ b/cmd/codeaf/engine.go @@ -1266,6 +1266,18 @@ func assembleEngine(hello remote.Hello, workspace, sessionFlag, launchDir string // machine's clock running for somebody sitting at another one // ([engineAutomations]). Automations: engineAutomations(), + // A WINDOW THAT JOINS THIS CONVERSATION — the desktop app joining one a + // terminal opened — gets what of its launch one conversation can carry + // for two windows: a shell call's output as it arrives. The rest of a + // launch (its clocks, its hand-over, its naming, saving proposals as + // standing orders) is how the conversation behaves for EVERY window, + // and stays the rules of the window that opened it. + JoinedWith: func(shape remote.LaunchShape) func() { + if !shape.LiveToolOutput { + return func() {} + } + return agent.WantLiveToolOutput() + }, }, nil } diff --git a/docs/changes/unreleased/0-app-limits-closed.md b/docs/changes/unreleased/0-app-limits-closed.md new file mode 100644 index 0000000000..33e0dca7a7 --- /dev/null +++ b/docs/changes/unreleased/0-app-limits-closed.md @@ -0,0 +1,25 @@ +--- +kind: fixed +title: codeaf app says crews, live lines and working everywhere, starts a member once, and keeps process ids apart +pr: 0 +surface: [engine] +--- + +Was true: only some `agent.changed` events carried an agent's crews; a crew +member's `line` was always empty; an agent a terminal was working in read +`closed` when the app had closed it; when the app and a terminal both held one +manager, each could open a member for the same `team_start`; a process id was +`:p`, so after a restart a new `p1` shared the id of the `p1` the +quit stopped; a conversation the app joined from a terminal never streamed a +command's output to the app; and a conversation open in another folder's +terminal could not be joined. + +Is true now: every `agent.changed` and every listing carries `crews` and a +live `line`, and says `working` whenever a turn runs; an agent is said again +when it joins or leaves a crew. A start line is claimed in the teams file +(`starts` on a team), so one window carries it out. Process ids are +`:p.`, and an id naming a process the conversation no longer +runs is answered not found. A joining window's launch is applied as far as one +conversation can carry it for two windows (live command output); the app finds +the host holding a conversation in any folder before refusing to open a second +copy. diff --git a/internal/appwire/wire.go b/internal/appwire/wire.go index b6527c56a6..e74a333b30 100644 --- a/internal/appwire/wire.go +++ b/internal/appwire/wire.go @@ -113,6 +113,9 @@ type Agent struct { UpdatedAt time.Time `json:"updated_at"` // Crews are the crews this agent is a member of, open ones only. Crews []string `json:"crews,omitempty"` + // Line is its live one-liner: the step it is on while it works, or the + // question it is waiting on. + Line string `json:"line,omitempty"` // ClosedAt is when it was moved to Closed and ArchivedAt when it was put // away; LastActiveAt is when the person last spoke in it, and SpentUSD // what it has spent in all. diff --git a/internal/enginehost/enginehost.go b/internal/enginehost/enginehost.go index 8974302f78..1d7654e46c 100644 --- a/internal/enginehost/enginehost.go +++ b/internal/enginehost/enginehost.go @@ -493,3 +493,12 @@ func Splice(in io.Reader, out io.Writer, conn net.Conn) error { } return err } + +// Sockets is the socket of every host on this machine that has ever listened, +// running or not; a caller dials each and takes what answers. It is how a +// window finds the host holding a conversation started in another workspace's +// host — a crew member a manager started, which lives beside its manager. +func Sockets() []string { + matches, _ := filepath.Glob(home.Join("v3", "hosts", "*", socketName)) + return matches +} diff --git a/internal/manual/chat/running-from-the-terminal.md b/internal/manual/chat/running-from-the-terminal.md index 5235e5965d..ab783ddb7e 100644 --- a/internal/manual/chat/running-from-the-terminal.md +++ b/internal/manual/chat/running-from-the-terminal.md @@ -384,6 +384,9 @@ and the other way round. While the app is open it does for a crew what a terminal window does for a team it holds: it opens the member a manager starts with `team_start` and stops the one a manager stops, and it keeps every member of an open crew open, so a member woken by its manager is seen working. +When the app and a terminal both hold one manager, the first to claim a start +opens the member and the other does nothing, so one `team_start` makes one +member. Each member's row shows the step it is on, or the question it waits on. A question that climbs to you, a cap reached and a closing report arrive in the app's dock and Inbox as decisions; your answer decides them for the team. @@ -452,6 +455,22 @@ the worker stops at its next step until you resume it. Its page shows the worker's steps as its log, and a note you leave shows when the worker read it. A task is never renamed after a single file it touches. +## A conversation open in a terminal and in the desktop app at once + +When the app opens an agent a terminal already has open, it joins that +terminal's conversation instead of opening a second copy, even when the +conversation lives in another folder's terminal (a crew member a manager +started). If it cannot reach that terminal it says the conversation is open in +another codeaf window rather than opening it twice. + +A joined conversation keeps the rules of the window that opened it, because +they are how it behaves for everyone: a proposed task's clock, a choice's +clock, moving a running reply to a task, naming, and saving proposed reminders +as standing orders follow the terminal. One thing changes while the app is +joined: a command's output streams as it arrives, in the app and in the +terminal's row for that command. The app still says the agent is working +whenever a turn runs, whoever started it. + ## Standing orders in the desktop app — agree to a reminder or routine before it runs, and how it earns trust In the desktop app, a reminder or a piece of scheduled work an agent proposes diff --git a/internal/remote/server.go b/internal/remote/server.go index d20afac1db..184d42013b 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -280,6 +280,12 @@ type Engine struct { // is automations off on this engine, the welcome says nothing, and every // door answers a refusal rather than an empty list. Automations *EngineAutomations + + // JoinedWith takes the launch a window that JOINED this conversation asked + // for ([Hello.Join] with [Hello.Launch]) and applies what of it can be + // applied to a conversation another window opened, until release. Nil + // applies nothing. + JoinedWith func(shape LaunchShape) (release func()) } // Options is what [Serve] needs, which is one function: how to open the @@ -1715,6 +1721,9 @@ type server struct { // go once, on the way out ([server.letWindowGo]); both happen on the one // goroutine that runs [server.serve], so it needs no lock. window func() + // joinedWith releases what a joining window's launch applied + // ([Engine.JoinedWith]), on the same road out as window. + joinedWith func() } func (s *server) serve(in io.Reader) (err error) { @@ -1735,6 +1744,10 @@ func (s *server) serve(in io.Reader) (err error) { // the conversation, and it holds on every road out — the detach, the // torn pipe, the close, the refusal and the fault alike. s.letWindowGo() + if release := s.joinedWith; release != nil { + s.joinedWith = nil + release() + } }() scan := bufio.NewScanner(in) @@ -1917,6 +1930,14 @@ func (s *server) handshake(line []byte) error { // are open already includes it (automations.go's [server.holdWindow]). A // refusal below lets it go on the way out with everything else. s.holdWindow(hello) + if hello.Join && hello.Launch != nil { + sess.mu.Lock() + apply := sess.engine.JoinedWith + sess.mu.Unlock() + if apply != nil { + s.joinedWith = apply(*hello.Launch) + } + } // The arrival is one act: attached, welcomed, and caught up, with this // connection's writer held throughout so nothing overtakes the welcome. s.write.Lock() diff --git a/internal/session/live_output_join.go b/internal/session/live_output_join.go new file mode 100644 index 0000000000..5764797d9b --- /dev/null +++ b/internal/session/live_output_join.go @@ -0,0 +1,19 @@ +package session + +// WantLiveToolOutput is a window that joined this conversation asking for a +// shell call's output as it arrives, the way the desktop app shows it. It +// holds until release is called, which the window's leaving does. Every +// window sees the output while one wants it: the events go to the +// conversation's one hub, and a terminal draws them as the command's row +// filling in. +func (a *Agent) WantLiveToolOutput() (release func()) { + a.liveOutputWanted.Add(1) + var once bool + return func() { + if once { + return + } + once = true + a.liveOutputWanted.Add(-1) + } +} diff --git a/internal/session/loop.go b/internal/session/loop.go index 3da0949b48..36d50009dd 100644 --- a/internal/session/loop.go +++ b/internal/session/loop.go @@ -3645,7 +3645,7 @@ func (a *Agent) dispatchTool(ctx context.Context, ep *episode, hub *eventHub, ca // It is set at this chokepoint rather than per tool, so the early warm // start carries it exactly as the batch does. callCtx := withCallID(ctx, call.ID) - if hub != nil && a.config.LiveToolOutput { + if hub != nil && (a.config.LiveToolOutput || a.liveOutputWanted.Load() > 0) { callCtx = bare.WithBashOutput(callCtx, &shellOutput{hub: hub, id: call.ID, tool: call.Function.Name, bytes: liveToolOutputBytes, lines: liveToolOutputLines}) } text, isError, err := a.executeTaskTool(callCtx, tool, running, args) diff --git a/internal/session/session.go b/internal/session/session.go index c723c30db2..459d0093ff 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -2393,6 +2393,10 @@ 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 { + // liveOutputWanted counts the windows that joined this conversation asking + // for a shell call's output as it arrives ([Agent.WantLiveToolOutput]), + // beside the window that opened it with [Config.LiveToolOutput]. + liveOutputWanted atomic.Int32 // crewDayHeld is the day's spend crews and their helpers are held to // ([Agent.crewDay]), read once. crewDayOnce sync.Once diff --git a/internal/teams/startclaim.go b/internal/teams/startclaim.go new file mode 100644 index 0000000000..985318d4b2 --- /dev/null +++ b/internal/teams/startclaim.go @@ -0,0 +1,38 @@ +package teams + +// startclaim.go is who carries out a manager's start line. +// +// A START IS CARRIED OUT ONCE, by whichever window claims it first. Every +// window that holds a team's manager reads its Traffic and answers a start by +// opening a new conversation, and a terminal and the desktop app can both +// hold one manager (the app joins the terminal's conversation). Without a +// claim each of them started a member for the same line. The claim is made in +// the teams file under its own lock, so it is one answer on one machine, and +// over --host it crosses the wire with every other edit of the file. + +// keepStarts is how many claimed start lines a team remembers. A start is +// answered within seconds of being written, so only the newest matter. +const keepStarts = 64 + +// ClaimStart claims start line entry of team for the caller, and reports +// whether the caller is the one to carry it out: false when another window +// claimed it first, or the team is not in the file. +func (f *File) ClaimStart(team, entry string) bool { + for i := range f.Teams { + t := &f.Teams[i] + if t.ID != team { + continue + } + for _, claimed := range t.Starts { + if claimed == entry { + return false + } + } + t.Starts = append(t.Starts, entry) + if over := len(t.Starts) - keepStarts; over > 0 { + t.Starts = append([]string(nil), t.Starts[over:]...) + } + return true + } + return false +} diff --git a/internal/teams/startclaim_test.go b/internal/teams/startclaim_test.go new file mode 100644 index 0000000000..4dbd8f9c29 --- /dev/null +++ b/internal/teams/startclaim_test.go @@ -0,0 +1,36 @@ +package teams + +import "testing" + +// TWO WINDOWS HOLDING ONE MANAGER START ONE MEMBER: the first claim wins, the +// second is refused, and the claim survives a write and a read. +func TestAStartLineIsClaimedOnce(t *testing.T) { + profile := t.TempDir() + id := NewID() + if err := Update(profile, func(f *File) error { + f.Teams = append(f.Teams, Team{ID: id, Name: "site"}) + return nil + }); err != nil { + t.Fatal(err) + } + claim := func() bool { + won := false + if err := Update(profile, func(f *File) error { won = f.ClaimStart(id, "e1"); return nil }); err != nil { + t.Fatal(err) + } + return won + } + if !claim() { + t.Fatal("the first claim was refused") + } + if claim() { + t.Fatal("a second window claimed the same start") + } + f, err := Load(profile) + if err != nil { + t.Fatal(err) + } + if team, _ := f.Team(id); len(team.Starts) != 1 { + t.Fatalf("the claim was not kept: %+v", team.Starts) + } +} diff --git a/internal/teams/teams.go b/internal/teams/teams.go index 9311a5af4c..e0d250f69f 100644 --- a/internal/teams/teams.go +++ b/internal/teams/teams.go @@ -91,6 +91,9 @@ type Team struct { // it was given. Nil is none, which is also what a file from before the // field was kept reads as. Wrap *Wrap + // Starts are the manager's start lines a window has claimed ([File.ClaimStart]), + // newest last, so two windows holding one manager start one member. + Starts []string // hued says the team has a colour: the file gave it one or [Team.SetHue] // did. A hue of 0 is a real hue, so absence is kept apart from the value. @@ -114,7 +117,7 @@ var knownFields = map[string]bool{ "hue": true, "tier": true, "made": true, "state": true, "closed_at": true, "closed_with": true, "report": true, "root": true, "questions_up": true, "cap_usd_day": true, "depth_limit": true, "sub_share": true, "wake": true, - "wrap": true, "former_members": true, "former_manager": true, + "wrap": true, "former_members": true, "former_manager": true, "starts": true, } // wireTeam is the stored shape. Hue and Tier are pointers so a team with no @@ -140,6 +143,8 @@ type wireTeam struct { // Wrap is written only while a wrap-up is in progress, so a team with // none is written exactly as before. Wrap *Wrap `json:"wrap,omitempty"` + // Starts is written only once a start line was claimed. + Starts []string `json:"starts,omitempty"` // The overrides are written flat beside the fields above, each only when // set, so a team with none is written exactly as before. Settings @@ -156,7 +161,7 @@ func (t *Team) UnmarshalJSON(raw []byte) error { return err } *t = Team{ID: w.ID, Name: w.Name, Parent: w.Parent, Members: w.Members, FormerMembers: w.FormerMembers, FormerManager: w.FormerManager, Manager: w.Manager, Made: w.Made, - Settings: w.Settings, State: w.State, ClosedWith: w.ClosedWith, Report: w.Report, Root: w.Root, Wrap: w.Wrap} + Settings: w.Settings, State: w.State, ClosedWith: w.ClosedWith, Report: w.Report, Root: w.Root, Wrap: w.Wrap, Starts: w.Starts} if w.ClosedAt != nil { t.ClosedAt = *w.ClosedAt } @@ -182,7 +187,7 @@ func (t *Team) UnmarshalJSON(raw []byte) error { // build wrote, sorted, exactly as it was read. func (t Team) MarshalJSON() ([]byte, error) { w := wireTeam{ID: t.ID, Name: t.Name, Parent: t.Parent, Members: t.Members, FormerMembers: t.FormerMembers, FormerManager: t.FormerManager, Manager: t.Manager, Made: t.Made, - Settings: t.Settings, ClosedWith: t.ClosedWith, Report: t.Report, Root: t.Root, Wrap: t.Wrap} + Settings: t.Settings, ClosedWith: t.ClosedWith, Report: t.Report, Root: t.Root, Wrap: t.Wrap, Starts: t.Starts} // A state this build does not know is written back as it was read, so a // later build's word survives; open is written as nothing. if t.State != "" && t.State != TeamOpen { diff --git a/internal/tui3/teamtraffic.go b/internal/tui3/teamtraffic.go index d8d84d7a05..88952168b8 100644 --- a/internal/tui3/teamtraffic.go +++ b/internal/tui3/teamtraffic.go @@ -488,8 +488,21 @@ func (a *app) trafficStart(t team, e teamstore.Entry) tea.Cmd { posture = dial.ResolvedApprovalPosture() } } - start, where, id := a.start, a.teamWhere(t), t.ID + start, where, id, seam, entry := a.start, a.teamWhere(t), t.ID, a.teamsSeam(), e.ID return a.besideLine(func() func(bool) tea.Cmd { + // ONE WINDOW CARRIES OUT A START: the desktop app may hold this same + // manager, and the claim in the teams file says which of us does + // (internal/teams' startclaim.go). A seam that cannot write starts as + // before. + if seam.Update != nil { + claimed := true + if _, _, err := seam.Update(func(f *teamstore.File) error { + claimed = f.ClaimStart(id, entry) + return nil + }); err == nil && !claimed { + return func(bool) tea.Cmd { return nil } + } + } conv, err := start(where) // The session owns both the gate and its persisted posture. Apply it // off the UI loop, before membership can wake the first turn. From be17c529608d62ddf7999c79c19a55d59e9558ce Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Thu, 15 Oct 2026 12:35:03 -0400 Subject: [PATCH 017/459] app v3: lines meant for the model stay out of entries; asks name their subject; renames stay quick A session-authored line to the model is marked ForModel on its display entry and never becomes an app entry; a reply that is only the withdrawal token is held back live and skipped on reread; a resumed reply is marked by the footer resumed_after_quit. Asks carry subject {kind, ref}. A read-only shell command stays inside a quick task, and the app's launch keeps quick_task beside runs. --- cmd/codeaf/app.go | 56 ++++++++++++++++++- cmd/codeaf/appcrewmembers.go | 1 + cmd/codeaf/appcrews.go | 1 + cmd/codeaf/apphidden_test.go | 49 ++++++++++++++++ cmd/codeaf/appmoney.go | 1 + cmd/codeaf/appquit.go | 9 ++- cmd/codeaf/engine.go | 1 + .../changes/unreleased/0-app-ui-walk-fixes.md | 20 +++++++ internal/appwire/convo.go | 3 + internal/appwire/wire.go | 24 ++++++++ internal/manual/chat/tasks.md | 9 ++- internal/remote/wire.go | 2 + internal/session/agent.go | 14 ++++- internal/session/beltfacts.go | 32 ++++++++++- internal/session/session.go | 4 ++ internal/session/task_quick_files.go | 10 +++- 16 files changed, 224 insertions(+), 12 deletions(-) create mode 100644 cmd/codeaf/apphidden_test.go create mode 100644 docs/changes/unreleased/0-app-ui-walk-fixes.md diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index 6083ae926c..734d1f87c5 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -235,7 +235,7 @@ const appHostDialTimeout = 500 * time.Millisecond // journal. Everything else is built in this process. func appOpenInProcess(ask appOpenAsk) (*appConn, error) { clock := ask.ClockS - launch := &remote.LaunchShape{Interactive: true, DeterministicNames: true, HandOverOnAsk: true, ProposalClockS: &clock, ChoiceClockS: clock, QuitGraceS: appQuitGraceS, LiveToolOutput: true, StandingOrders: true} + launch := &remote.LaunchShape{Interactive: true, DeterministicNames: true, HandOverOnAsk: true, ProposalClockS: &clock, ChoiceClockS: clock, QuitGraceS: appQuitGraceS, LiveToolOutput: true, StandingOrders: true, QuickTasks: true} if ask.Transcript != "" && !ask.Minted { // A JOIN CARRIES THE APP'S LAUNCH, and the engine applies what of it a // conversation another window opened can carry (engine.go's JoinedWith). @@ -1001,9 +1001,30 @@ func appAskOf(agent string, q session.Question) appwire.Ask { for _, option := range appOptionsOf(q) { ask.Options = append(ask.Options, appwire.AskOption{ID: option.id, Label: option.label}) } + ask.Subject = appAskSubject(agent, q.Subject) return ask } +// appAskSubject is what a question is about, in the app's words: an order is +// an automation named by its id, a task node by its app task id. +func appAskSubject(agent string, s session.SubjectRef) *appwire.AskSubject { + switch s.Kind { + case session.SubjectOrder: + return &appwire.AskSubject{Kind: appwire.SubjectAutomation, Ref: s.Ref} + case session.SubjectNode: + return &appwire.AskSubject{Kind: appwire.SubjectTask, Ref: appTaskID(agent, s.ID)} + case session.SubjectCall: + return &appwire.AskSubject{Kind: appwire.SubjectCall, Ref: s.CallID} + case session.SubjectRun: + return &appwire.AskSubject{Kind: appwire.SubjectRun, Ref: s.Ref} + case session.SubjectPage: + return &appwire.AskSubject{Kind: appwire.SubjectPage, Ref: s.Name} + case session.SubjectAccount: + return &appwire.AskSubject{Kind: appwire.SubjectAccount, Ref: s.Ref} + } + return nil +} + // appOption is one answer as the app names it, with the engine key behind it. type appOption struct { id, key, label string @@ -1449,8 +1470,20 @@ func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, n var reply strings.Builder // A MESSAGE BEFORE A TOOL CALL IS AN UPDATE, the agent saying what it // found mid-turn; the last one is the answer. + // withheld says the reply's words so far could still be the withdrawal token + // ([session.NoChangeReply]), so they are kept back from the page until + // they cannot be: a reply that is only the token is never drawn. + withheld := true sayReply := func(kind string) { words := strings.TrimSpace(reply.String()) + defer func() { withheld = true }() + if session.IsNoChangeReply(words) { + reply.Reset() + return + } + if withheld && words != "" { + live.box.Delta(id, turn, msg, appwire.DeltaText, reply.String()) + } if words != "" { b.locked(func() { live.lastAnswer = words }) at := msg @@ -1474,7 +1507,13 @@ func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, n case session.EventTextDelta: endThought() reply.WriteString(event.Text) - live.box.Delta(id, turn, msg, appwire.DeltaText, event.Text) + switch { + case !withheld: + live.box.Delta(id, turn, msg, appwire.DeltaText, event.Text) + case !strings.HasPrefix(session.NoChangeReply, strings.TrimLeft(reply.String(), " \t\r\n")): + withheld = false + live.box.Delta(id, turn, msg, appwire.DeltaText, reply.String()) + } case session.EventReasoning: if thoughtAt.IsZero() { thoughtAt = b.now() @@ -1484,6 +1523,7 @@ func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, n // The attempt is void: what it streamed is dropped, here and on // the page, and a move to another model is said once. reply.Reset() + withheld = true thoughtAt = time.Time{} // A wait already said with its countdown is not said twice; the // attempt is the turn's own count of them either way. @@ -2057,6 +2097,18 @@ func appEntriesOf(agent string, items []session.DisplayEntry, at time.Time, mark // session wrote, but it is the message the person watched stream // under this id; read back as a notice it was that paragraph said // a second time. Every other aside is the session's own dim line. + // A LINE THE SESSION WROTE TO ITS MODEL IS NOT SHOWN: it is the + // model's instruction — a wake, a landing note, the carry-on after a + // quit — and never words to the person. A team's delivery is the + // exception and is the crew's line. + if item.Role == "aside" && item.ForModel && len(item.Team) == 0 { + continue + } + // A REPLY THAT IS ONLY THE WITHDRAWAL TOKEN WITHDREW ITSELF: the + // answer before it stands, and the token is never drawn. + if item.Role == "assistant" && session.IsNoChangeReply(item.Text) { + continue + } if item.Role == "aside" && !item.Interrupted { entry.ID, entry.Kind, entry.Text, entry.Quiet = appNoteEntryID(n, k), appwire.EntryNotice, item.Text, true k++ diff --git a/cmd/codeaf/appcrewmembers.go b/cmd/codeaf/appcrewmembers.go index ccf55dc845..ef0e3e1e42 100644 --- a/cmd/codeaf/appcrewmembers.go +++ b/cmd/codeaf/appcrewmembers.go @@ -491,6 +491,7 @@ func appProposalAskOf(p appwire.Proposal) appwire.Ask { return appwire.Ask{ ID: appProposalAskPrefix + p.ID, Agent: p.Agent, Kind: appwire.AskQuestion, Subkind: appwire.SubkindCrewProposal, Title: "Bring in helpers: " + p.Name, Detail: strings.Join(detail, "\n"), Reason: p.Goal, + Subject: &appwire.AskSubject{Kind: appwire.SubjectCrewProposal, Ref: p.ID}, Options: []appwire.AskOption{ {ID: appwire.ProposalCreate, Label: "Make the crew"}, {ID: appwire.ProposalEdit, Label: "Edit"}, diff --git a/cmd/codeaf/appcrews.go b/cmd/codeaf/appcrews.go index 8736e9e507..d31fd7d66e 100644 --- a/cmd/codeaf/appcrews.go +++ b/cmd/codeaf/appcrews.go @@ -825,6 +825,7 @@ func appDecisionAsk(f *teamstore.File, p teamstore.Packet) appwire.Ask { ask := appwire.Ask{ ID: appCrewAskPrefix + p.ID, Agent: decisionAgent(f, p), Kind: appwire.AskQuestion, Subkind: appwire.SubkindCrewDecision, Title: d.Question, Path: d.Path, SayWhy: true, + Subject: &appwire.AskSubject{Kind: appwire.SubjectCrewDecision, Ref: p.ID}, } if d.Recommendation != nil { ask.Reason = d.Recommendation.Reason diff --git a/cmd/codeaf/apphidden_test.go b/cmd/codeaf/apphidden_test.go new file mode 100644 index 0000000000..7c4ad2308d --- /dev/null +++ b/cmd/codeaf/apphidden_test.go @@ -0,0 +1,49 @@ +package main + +import ( + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/session" +) + +// A LINE THE SESSION WROTE TO ITS MODEL IS NEVER AN ENTRY, and neither is a +// reply that is only the withdrawal token; a team's delivery and the model's +// own words still are. +func TestLinesForTheModelAreNotEntries(t *testing.T) { + items := []session.DisplayEntry{ + {Role: "user", Text: "rename it"}, + {Role: "assistant", Text: "done", Answer: true}, + {Role: "aside", Text: session.ResumeAfterQuitNote, ForModel: true}, + {Role: "assistant", Text: session.NoChangeReply, Answer: true}, + {Role: "aside", Text: "@web: index is ready", ForModel: true, Team: []session.TeamLine{{}}}, + } + entries := appEntriesOf("a", items, time.Now(), nil) + for _, e := range entries { + if e.Text == session.ResumeAfterQuitNote || e.Text == session.NoChangeReply { + t.Fatalf("a line for the model became an entry: %+v", e) + } + } + if len(entries) != 3 { + t.Fatalf("entries = %+v", entries) + } +} + +// AN ASK SAYS WHAT IT IS ABOUT: an automation by its order id, a task by its +// app id, a call by its id. +func TestAnAskCarriesItsSubject(t *testing.T) { + q := session.Question{ID: 4, Kind: session.QuestionAutomation, Head: "wants to schedule work", + Subject: session.SubjectRef{Kind: session.SubjectOrder, ID: 4, Ref: "fd6f"}} + if s := appAskOf("a", q).Subject; s == nil || s.Kind != appwire.SubjectAutomation || s.Ref != "fd6f" { + t.Fatalf("subject = %+v", s) + } + q.Subject = session.SubjectRef{Kind: session.SubjectNode, ID: 7} + if s := appAskOf("a", q).Subject; s == nil || s.Kind != appwire.SubjectTask || s.Ref != "a:7" { + t.Fatalf("subject = %+v", s) + } + q.Subject = session.SubjectRef{} + if s := appAskOf("a", q).Subject; s != nil { + t.Fatalf("an ask about nothing has subject %+v", s) + } +} diff --git a/cmd/codeaf/appmoney.go b/cmd/codeaf/appmoney.go index b91de6339f..6c6b4221eb 100644 --- a/cmd/codeaf/appmoney.go +++ b/cmd/codeaf/appmoney.go @@ -167,6 +167,7 @@ func (b *appBackend) askBudget(a *appAgent, live *appLive, spent, budget float64 func (b *appBackend) openMoneyAsk(a *appAgent, live *appLive, scope string, ask appwire.Ask) { ask.ID = "budget:" + scope + ":" + strconv.FormatUint(appMoneyAsks.Add(1), 10) ask.Kind, ask.Subkind = appwire.AskApproval, appwire.SubkindBudget + ask.Subject = &appwire.AskSubject{Kind: appwire.SubjectBudget, Ref: scope} opened, moved := false, false var view appwire.Agent b.locked(func() { diff --git a/cmd/codeaf/appquit.go b/cmd/codeaf/appquit.go index 06399ec0ea..bb5631235a 100644 --- a/cmd/codeaf/appquit.go +++ b/cmd/codeaf/appquit.go @@ -137,9 +137,12 @@ func (b *appBackend) resumeOne(id string, reply bool, tasks []string) { if !ok || !reply { return } - live.box.Event(appwire.EventEntryAdded, appwire.EntryAdded{Entry: appwire.Entry{ - ID: "resumed-" + b.now().Format("20060102T150405"), Agent: id, Kind: appwire.EntryNotice, At: b.now(), Text: appResumedWord, - }}) + // THE APP IS TOLD BY A MARKER, and the sentence the model is told to carry + // on with is the model's alone: it is a line the session wrote to its model + // and is never one of the conversation's entries. + live.box.Event(appwire.EventEntryAdded, appwire.EntryAdded{Entry: appFooter( + "resumed-"+b.now().Format("20060102T150405"), id, 0, appwire.FooterResumedAfterQuit, appResumedWord, b.now(), + )}) _, _ = door.ResumeAfterQuit() } diff --git a/cmd/codeaf/engine.go b/cmd/codeaf/engine.go index a198e3adb1..370f0fb1c0 100644 --- a/cmd/codeaf/engine.go +++ b/cmd/codeaf/engine.go @@ -1051,6 +1051,7 @@ func assembleEngine(hello remote.Hello, workspace, sessionFlag, launchDir string cfg.ChoiceClockSeconds = hello.Launch.ChoiceClockS cfg.JobQuitGrace = time.Duration(hello.Launch.QuitGraceS) * time.Second cfg.StandingOrders = hello.Launch.StandingOrders + cfg.QuickTasks = hello.Launch.QuickTasks } agent, cfg, notice, err := openV3Agent(cfg, workspace, open) if err != nil { diff --git a/docs/changes/unreleased/0-app-ui-walk-fixes.md b/docs/changes/unreleased/0-app-ui-walk-fixes.md new file mode 100644 index 0000000000..20e35618e1 --- /dev/null +++ b/docs/changes/unreleased/0-app-ui-walk-fixes.md @@ -0,0 +1,20 @@ +--- +kind: fixed +title: codeaf app hides lines meant for the model, names what each ask is about, and keeps small renames quick +pr: 0 +surface: [engine] +--- + +Was true: a reply resumed after quit, a woken turn's note, a checkpoint's +question and a `[no change]` reply showed in the app as notices or answers; an +ask carried no subject, so an automation's card looked like any question; a +quick task asked to become a full task before any shell call, read-only ones +included, and app agents on the run road were never offered `quick_task`. + +Is true now: a session-authored line to the model is marked (`ForModel` on +the display entry) and never becomes an app entry, and a reply that is only +the withdrawal token is never drawn, live or reread; a resumed reply is marked +by the footer `resumed_after_quit`. Asks carry `subject: {kind, ref}` +(automation, task, call, run, page, account, crew_proposal, crew_decision, +budget). A read-only shell command (internal/approval's ReadOnly) stays inside +a quick task, and the app's launch keeps `quick_task` beside runs. diff --git a/internal/appwire/convo.go b/internal/appwire/convo.go index da4a05b68d..968bfa3a87 100644 --- a/internal/appwire/convo.go +++ b/internal/appwire/convo.go @@ -38,6 +38,9 @@ const ( FooterFallback = "fallback" FooterProcessFinished = "process_finished" FooterProcessLimit = "process_limit" + // FooterResumedAfterQuit is in front of a reply that carries on after the + // app was quit in the middle of it. + FooterResumedAfterQuit = "resumed_after_quit" ) // CodeSent refuses an edit to a queued message that has already been sent. diff --git a/internal/appwire/wire.go b/internal/appwire/wire.go index e74a333b30..582f5070d5 100644 --- a/internal/appwire/wire.go +++ b/internal/appwire/wire.go @@ -214,8 +214,32 @@ type Ask struct { // Path is the crews a crew's decision climbed through to the person, // "Backend → Release → you". Path []string `json:"path,omitempty"` + // Subject is what the ask is about, so the app draws that thing's own + // card: an automation waiting to be agreed to, a task, a tool call, a crew + // proposal or decision. Nil is an ask about nothing in particular. + Subject *AskSubject `json:"subject,omitempty"` } +// AskSubject is the thing an ask is about: its kind and the id the app knows +// it by. +type AskSubject struct { + Kind string `json:"kind"` + Ref string `json:"ref,omitempty"` +} + +// The kinds of an ask's subject. +const ( + SubjectAutomation = "automation" + SubjectTask = "task" + SubjectCall = "call" + SubjectRun = "run" + SubjectPage = "page" + SubjectAccount = "account" + SubjectCrewProposal = "crew_proposal" + SubjectCrewDecision = "crew_decision" + SubjectBudget = "budget" +) + // The subkinds of an approval, and the option ids a permission offers. const ( SubkindPermission = "permission" diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index 2b4ac8fc99..8dd52b5099 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -5801,8 +5801,13 @@ restores every original file, including its permissions, and removes files that the quick task created. The bytes and hashes survive an engine restart. Before writing the 31st distinct file, the worker asks to become a full task. -It also asks before **any shell call**, including tests hidden in scripts or -aliases, before workspace restores or merges, and before media creation. Shell effects cannot be counted safely. +It also asks before **any shell call that could change something**, including +tests hidden in scripts or aliases, before workspace restores or merges, and +before media creation. Shell effects cannot be counted safely, so only a +command that cannot change anything — a search, a listing, `git status`, never +a compound line — runs inside a quick task; that is how a rename finds every use +without becoming a full task. In the desktop app quick tasks are always +offered, even where full tasks are runs. There is no clock on this promotion. Promote starts an ordinary task after the quick worker leaves, continuing in the same folder with its existing edits and the blocked action in its brief. Cancel stops the quick worker and diff --git a/internal/remote/wire.go b/internal/remote/wire.go index f662913c74..75d28bbe0b 100644 --- a/internal/remote/wire.go +++ b/internal/remote/wire.go @@ -829,6 +829,8 @@ type LaunchShape struct { // is up, as proposed and not yet running ([session.Config.StandingOrders]); // the desktop app sets it. StandingOrders bool `json:"standingOrders,omitempty"` + // QuickTasks keeps quick tasks offered beside runs ([session.Config.QuickTasks]). + QuickTasks bool `json:"quickTasks,omitempty"` // LiveToolOutput sends a model's shell output as it arrives // ([session.Config.LiveToolOutput]); the desktop app sets it. LiveToolOutput bool `json:"liveToolOutput,omitempty"` diff --git a/internal/session/agent.go b/internal/session/agent.go index 95f6ebf11e..fcd9ce3bed 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -4743,6 +4743,13 @@ type DisplayEntry struct { // who sent it rather than as the aside's first line. Nil on every other // entry; the aside's Text still holds the whole delivery as the model read it. Team []TeamLine + // ForModel marks an "aside" the SESSION wrote to its model — a wake, a + // task's landing note, the carry-on after a quit, a checkpoint's question — + // as opposed to the model's own operational words. The person never typed + // it and it was never addressed to them: it is the model's instruction, and + // a surface that shows a conversation to a person leaves it out (a team's + // delivery, which carries [DisplayEntry.Team], is drawn as its card). + ForModel bool } // SteerMark is what the record keeps about one steer that LANDED: when the @@ -4862,7 +4869,9 @@ func shapeEntries(messages []ai.Message, journal *sessionFile, indexes ...*prese if role == "user" && strings.HasPrefix(messageContentText(msg), summaryNotePrefix) { role = "note" } + forModel := false if role == "user" && journal.isNote(msg) { + forModel = true // A LINE THE SESSION WROTE IS NOT THE PERSON'S. It is user-role in the // transcript because that is the only role the model can be told // something in, and the journal is the only place that remembers the @@ -4915,8 +4924,9 @@ func shapeEntries(messages []ai.Message, journal *sessionFile, indexes ...*prese // INTO the turn above it rather than opening one of its own: the // message itself is an ordinary user message, because that is what the // model has to read it as (steer.go). - Steer: journal.steerMark(msg), - Team: team, + Steer: journal.steerMark(msg), + Team: team, + ForModel: forModel, }) for callIndex := range msg.ToolCalls { call := &msg.ToolCalls[callIndex] diff --git a/internal/session/beltfacts.go b/internal/session/beltfacts.go index 8f5e5b7927..c387ee8c9e 100644 --- a/internal/session/beltfacts.go +++ b/internal/session/beltfacts.go @@ -1,6 +1,7 @@ package session import ( + "strconv" "strings" "github.com/Agent-Field/codeaf/internal/exec" @@ -103,7 +104,13 @@ func (c Config) mayProposeTask() bool { return !c.InTask || (c.mayFanOut() && !c // written as its own predicate rather than as the other one spelled twice // because the page's bullet is about THIS verb, and a fact naming the wrong // predicate is a sentence nobody can check. -func (c Config) mayQuickTask() bool { return c.mayProposeTask() && !c.oneTaskRoad() } +// +// THE DESKTOP APP KEEPS IT ([Config.QuickTasks]) where the terminal's one task +// road takes it off: small work done in place, read and carried on from, is a +// thing the app offers whatever road the full tasks take. +func (c Config) mayQuickTask() bool { + return c.mayProposeTask() && (!c.oneTaskRoad() || c.QuickTasks && !c.InTask) +} // oneTaskRoad says whether this agent is a conversation whose hand-offs are // runs in the plan store: the bash belt is asked for and a run engine is wired. @@ -321,6 +328,28 @@ type beltFact struct { fill func(Config, string) string } +// quickBesideRuns is the one-road wording with quick tasks kept beside it +// ([Config.QuickTasks]): the run road stays the road for work that must be +// checked and landed, and `quick_task` is named for the small work done where +// the model stands. Every other wording is left as it is. +func quickBesideRuns(c Config, text string) string { + if !c.oneTaskRoad() || !c.mayQuickTask() { + return text + } + const one = "You are one mind with a clock, and ONE way to put more minds on the work:\n`propose_task`." + if !strings.HasPrefix(text, one) { + return text + } + return "You are one mind with a clock, and two ways to put more minds on the work.\n" + + "A `quick_task` is a copy of your abilities working where you stand: it starts\n" + + "the instant you ask, reads and writes in this same folder, and its last message\n" + + "comes back to you as a note. It is for small work you will read and carry on\n" + + "with — a rename across files, a handful of edits — and it becomes a full task\n" + + "only when it must run a command that can change things or touches more than " + + strconv.Itoa(QuickTaskFileLimit) + " files.\n" + + "The other is `propose_task`." + strings.TrimPrefix(text, one) +} + // beltFacts is the whole of it, in the order the section reads. var beltFacts = []beltFact{{ tools: []string{"ask", loadCapabilityToolName}, @@ -586,6 +615,7 @@ var handoffFacts = []beltFact{{ "stand, so a sweep across many files, research across many sources or the same\n" + "change over many items is work you open and carry yourself, in the order that\n" + "finishes it.", + fill: quickBesideRuns, }, delegateFact, { tools: []string{"build_harness", loadCapabilityToolName}, holds: Config.mayDesignHarness, diff --git a/internal/session/session.go b/internal/session/session.go index 459d0093ff..7593a6d264 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -2017,6 +2017,10 @@ type Config struct { // until the person has trusted it (internal/automation's trust.go). The // desktop app sets it; the terminal saves on the yes, as it always has. StandingOrders bool + // QuickTasks keeps `quick_task` on a conversation's belt when its full + // tasks are runs in the plan store ([Config.mayQuickTask]). The desktop + // app sets it. + QuickTasks bool // BashBackgroundAfterSeconds is how long a foreground command stays in the // turn before the same running process is kept as a job (promote.go, diff --git a/internal/session/task_quick_files.go b/internal/session/task_quick_files.go index c158bbc2e0..92cefb3828 100644 --- a/internal/session/task_quick_files.go +++ b/internal/session/task_quick_files.go @@ -12,6 +12,7 @@ import ( "strings" "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/approval" "github.com/Agent-Field/codeaf/internal/exec/bare" "github.com/Agent-Field/codeaf/internal/furrow" "github.com/aymanbagabas/go-udiff" @@ -85,8 +86,13 @@ func (a *Agent) executeTaskTool(ctx context.Context, tool bare.Tool, call ai.Too return "this quick task has ended", true, nil } // A shell's effects cannot be bounded from its text. All shell execution asks - // first, which includes wrappers, aliases and scripts that hide a test run. - if quickToolNeedsPromotion(tool.Name) { + // first, which includes wrappers, aliases and scripts that hide a test run — + // EXCEPT A COMMAND THAT CAN CHANGE NOTHING: a search, a listing, `git + // status`, read by the same rule that lifts a prompt off such a call + // (internal/approval's ReadOnly, which never vouches for a compound line). + // A rename across a codebase starts by finding every use; finding them is + // not a reason to become a full task. + if quickToolNeedsPromotion(tool.Name) && !approval.ReadOnly(tool.Name, args) { return a.proposeQuickPromotion(ctx, node, call, "This needs a full task before running a command or creating media") } path, _, writes := a.mutatingPath(call) From 683d65997a988271ab62204b4a1f2138c69cdba3 Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Thu, 15 Oct 2026 12:48:44 -0400 Subject: [PATCH 018/459] codeaf app: per-entry times, started members named by brief, crews nest and move, crew log by handle, cap receipts --- cmd/codeaf/app.go | 31 +++++++ cmd/codeaf/appcrews.go | 92 ++++++++++++++++++- cmd/codeaf/appcrews_test.go | 46 ++++++++++ cmd/codeaf/apphidden_test.go | 16 ++++ .../unreleased/0-app-crew-walk-fixes.md | 8 ++ .../changes/unreleased/0-app-limits-closed.md | 2 +- .../unreleased/0-app-tasks-quit-clocks.md | 2 +- .../changes/unreleased/0-app-ui-walk-fixes.md | 2 +- internal/appwire/crews.go | 1 + internal/session/agent.go | 6 ++ internal/session/entrytime.go | 84 +++++++++++++++++ internal/session/entrytime_test.go | 64 +++++++++++++ internal/session/sessionfile.go | 22 +++++ internal/session/title.go | 19 ++++ internal/session/transcriptread.go | 1 + 15 files changed, 390 insertions(+), 6 deletions(-) create mode 100644 docs/changes/unreleased/0-app-crew-walk-fixes.md create mode 100644 internal/session/entrytime.go create mode 100644 internal/session/entrytime_test.go diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index 734d1f87c5..f3d2ac76b5 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -2080,7 +2080,13 @@ func appEntriesOf(agent string, items []session.DisplayEntry, at time.Time, mark } } for i, item := range items { + // Each entry carries the instant its own line was written; the + // conversation's latest moment stands in only where the record never + // said. entry := appwire.Entry{Agent: agent, At: at} + if !item.At.IsZero() { + entry.At = item.At + } switch item.Role { case appSummaryRole: entry.ID, entry.Kind, entry.Text = "c"+strconv.Itoa(max(n, 0))+".s"+strconv.Itoa(c), appwire.EntrySummary, "Earlier messages summarized" @@ -2111,6 +2117,13 @@ func appEntriesOf(agent string, items []session.DisplayEntry, at time.Time, mark } if item.Role == "aside" && !item.Interrupted { entry.ID, entry.Kind, entry.Text, entry.Quiet = appNoteEntryID(n, k), appwire.EntryNotice, item.Text, true + // A TEAM'S DELIVERY IS ITS LINES. The words around them are the + // session's instruction to its model, and so is any other note + // that rode in the same message; the crew's lines are what the + // person reads. + if len(item.Team) > 0 { + entry.Text = appTeamDelivery(item.Team) + } k++ break } @@ -2634,3 +2647,21 @@ func appEngineError(err error) error { } return &appwire.Error{Code: appwire.CodeEngine, Message: redact.Secrets(plainWords(err.Error()))} } + +// appTeamDelivery is a team delivery's lines as the person reads them: who +// said each, and what. +func appTeamDelivery(lines []session.TeamLine) string { + out := make([]string, 0, len(lines)) + for _, line := range lines { + who := line.From + switch who { + case "you", "manager": + case "system": + who = "codeaf" + default: + who = "@" + who + } + out = append(out, who+": "+strings.TrimSpace(line.Text)) + } + return redact.Secrets(strings.Join(out, "\n")) +} diff --git a/cmd/codeaf/appcrews.go b/cmd/codeaf/appcrews.go index d31fd7d66e..5f5c9744e1 100644 --- a/cmd/codeaf/appcrews.go +++ b/cmd/codeaf/appcrews.go @@ -37,6 +37,7 @@ import ( "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/guard" "github.com/Agent-Field/codeaf/internal/redact" + "github.com/Agent-Field/codeaf/internal/session" teamstore "github.com/Agent-Field/codeaf/internal/teams" ) @@ -417,6 +418,11 @@ func (b *appBackend) memberView(read appCrewRead, t teamstore.Team, m teamstore. if other.ID != t.ID && !other.Closed() && other.Holds(m.Key) { view.AlsoIn = append(view.AlsoIn, other.ID) } + // A MEMBER THAT RUNS A CREW UNDER THIS ONE IS THAT CREW here, not a + // plain member: it answers to this crew's manager for the crew it runs. + if other.Parent == t.ID && !other.Closed() && other.Manager == m.Key { + view.Leads = other.ID + } } view.State, view.Model, view.Title, view.Line = b.memberLive(agent, view.Title) if usd, set := b.agentBudget(agent, b.now()); set { @@ -479,9 +485,38 @@ func appLogLine(t teamstore.Team, e teamstore.Entry) appwire.LogLine { line.Agent = appAgentID(key) } } + // THE LOG NAMES PEOPLE, NOT SEATS. A line is from and to a member by its + // handle, the person ("you"), or everyone; the manager is the member that + // holds the seat. A line codeaf wrote about a member is that member's. + manager := teamstore.FromManager + if m, ok := t.Member(t.Manager); ok && m.Handle != "" { + manager = m.Handle + } + line.From = appLogParty(t, e.From, e.Member, manager) + line.To = appLogParty(t, e.To, "", manager) + // The manager's mark in a line codeaf wrote is the manager's handle. + if e.Kind == teamstore.KindEvent || e.Kind == teamstore.KindPacket { + line.Text = strings.ReplaceAll(line.Text, "◆", "@"+manager) + } return line } +// appLogParty is one end of a log line as the log names it. +func appLogParty(t teamstore.Team, word, member, manager string) string { + switch word { + case teamstore.FromManager: + return manager + case teamstore.ToRoom: + return teamstore.ToEveryone + case teamstore.FromSystem: + if m, ok := t.Member(member); ok && m.Handle != "" { + return m.Handle + } + return "codeaf" + } + return word +} + // crewTraffic says a crew's new log lines and acts on its manager's starts and // stops. The first look at a crew only finds where its log ends. func (b *appBackend) crewTraffic(run *appCrewRun, read appCrewRead, t teamstore.Team) { @@ -595,11 +630,23 @@ func (b *appBackend) crewStart(t teamstore.Team, manager teamstore.Member, e tea b.crewNote(manager, "could not start @"+handle+": "+err.Error()) return } - _, live, err := b.heldLive(agent.ID) + a, live, err := b.heldLive(agent.ID) if err != nil { b.crewNote(manager, "could not start @"+handle+": "+err.Error()) return } + // A STARTED MEMBER IS NAMED BY ITS BRIEF, the way a conversation a person + // opens is named by its first message: the brief is its first message. The + // conversation names its own record the same way when its turn reads the + // brief; this is the name the list shows from the first moment. + if name := session.FirstMessageName(e.Text); name != "" { + b.locked(func() { + if strings.TrimSpace(a.view.Title) == "" { + a.view.Title = name + } + }) + b.sayAgent(a) + } posture := strings.TrimSpace(e.Approval) if posture == "" { if _, mlive, err := b.heldLive(appMemberAgent(manager)); err == nil { @@ -1226,7 +1273,37 @@ func (b *appBackend) CrewUpdate(ask appwire.CrewUpdate) (appwire.Crew, error) { return appwire.Crew{}, appEngineError(err) } } - return b.CrewGet(ask.ID) + crew, err := b.CrewGet(ask.ID) + if err != nil { + return crew, err + } + // A CAP THE PERSON SET IS SAID IN THE MANAGER'S CHAT, where the spending + // it bounds is decided. + cleared := false + for _, key := range ask.Clear { + cleared = cleared || key == "cap_usd_day" + } + if ask.Settings != nil && ask.Settings.CapUSDDay != nil || cleared { + words := crew.Name + " cap cleared" + if !cleared { + words = fmt.Sprintf("%s cap set to $%.2f", crew.Name, *ask.Settings.CapUSDDay) + } + b.crewSettingReceipt(crew.Manager, crew.ID, "cap", words) + } + return crew, nil +} + +// crewSettingReceipt writes a receipt for a crew setting the person changed +// into the manager's chat. +func (b *appBackend) crewSettingReceipt(manager, id, setting, words string) { + if manager == "" { + return + } + at := b.now() + b.emit.Emit(appwire.EventEntryAdded, appwire.EntryAdded{Entry: appwire.Entry{ + ID: "rcrew:" + id + ":" + setting + ":" + strconv.FormatInt(at.UnixNano(), 36), Agent: manager, + Kind: appwire.EntryReceipt, At: at, Text: words + " · you · " + at.Format("15:04"), + }}) } // crewTeam is one team as it stands, or not_found. @@ -1421,7 +1498,8 @@ func (b *appBackend) CrewMove(ids []string, parent string) error { defaults := teamstore.DefaultsAt(b.crewProfile()) return b.crewEdit(func(f *teamstore.File) error { target := f.MoveTarget(parent) - if block, blocked := f.MoveCheck(ids, target, defaults); blocked { + // The check answers whether the move MAY go: false comes with why not. + if block, ok := f.MoveCheck(ids, target, defaults); !ok { return appwire.Errorf(appwire.CodeBadParams, "%s", appMoveWords(block)) } return f.Move(ids, target) @@ -1437,6 +1515,14 @@ func appMoveWords(block teamstore.MoveBlock) string { return fmt.Sprintf("crews may go %d deep there, and this would make %d", block.Limit, block.Depth+block.Need) case "here": return "it is already there" + case "self": + return "a crew cannot go under itself" + case "inside": + return block.Name + " cannot go under a crew inside it" + case "root": + return "All crews stays at the top" + case "gone": + return "that crew is gone" } return "that move is not allowed" } diff --git a/cmd/codeaf/appcrews_test.go b/cmd/codeaf/appcrews_test.go index d41788178a..e92c59772a 100644 --- a/cmd/codeaf/appcrews_test.go +++ b/cmd/codeaf/appcrews_test.go @@ -203,3 +203,49 @@ func TestAppTintIsTheNearestNamedHue(t *testing.T) { t.Fatalf("an uncoloured team = %s", got) } } + +// A CREW MOVES UNDER ANOTHER, AND ONE MADE UNDER A PARENT STAYS THERE. A +// member that runs the crew under its own is that crew in the parent's view, +// not a plain member, and a move the check refuses is refused in words. +func TestAppCrewsNestByMoveAndByCreate(t *testing.T) { + backend, _, _, harbor := crewBackend(t) + web := teamstore.Member{Key: "/state/v3/places/proj/b0b0b0b0/transcript.jsonl", File: "/state/v3/places/proj/b0b0b0b0/transcript.jsonl", Where: "/w", Word: "web", Handle: "web"} + api := teamstore.Member{Key: "/state/v3/places/proj/c0c0c0c0/transcript.jsonl", File: "/state/v3/places/proj/c0c0c0c0/transcript.jsonl", Where: "/w", Word: "api", Handle: "api"} + dock := teamstore.NewID() + err := teamstore.Update(backend.profileDir, func(f *teamstore.File) error { + f.Teams = append(f.Teams, teamstore.Team{ID: dock, Name: "dock", Made: time.Now()}) + if err := f.AddMember(dock, api); err != nil { + return err + } + return f.SetManager(dock, api.Key) + }) + if err != nil { + t.Fatal(err) + } + if err := backend.CrewMove([]string{dock}, harbor); err != nil { + t.Fatalf("the move was refused: %v", err) + } + if err := backend.CrewMove([]string{harbor}, dock); err == nil || !strings.Contains(err.Error(), "inside it") { + t.Fatalf("a crew went under its own child: %v", err) + } + pier, err := backend.newTeam("pier", harbor, []teamstore.Member{web}, web.Key, nil, nil, nil) + if err != nil { + t.Fatalf("a crew made under a parent was refused: %v", err) + } + crew, err := backend.CrewGet(pier) + if err != nil || crew.Parent != harbor { + t.Fatalf("the new crew = %+v, %v", crew, err) + } + parent, err := backend.CrewGet(harbor) + if err != nil { + t.Fatal(err) + } + for _, m := range parent.Members { + if m.Handle == "web" && m.Leads != pier { + t.Fatalf("the member running pier reads as a plain member: %+v", m) + } + if m.Handle == "lead" && m.Leads != "" { + t.Fatalf("the parent's manager leads a child it does not run: %+v", m) + } + } +} diff --git a/cmd/codeaf/apphidden_test.go b/cmd/codeaf/apphidden_test.go index 7c4ad2308d..429bb83b62 100644 --- a/cmd/codeaf/apphidden_test.go +++ b/cmd/codeaf/apphidden_test.go @@ -30,6 +30,22 @@ func TestLinesForTheModelAreNotEntries(t *testing.T) { } } +// A TEAM'S DELIVERY READS AS ITS LINES ALONE. The session's words to its +// model around them, and a nudge that rode in the same message, are never +// shown. +func TestATeamDeliveryShowsOnlyItsLines(t *testing.T) { + text := "Your team's replies started this turn; the person did not speak.\n\n" + + "Team traffic in \"harbor\":\nfrom @web #4: index is ready\n(rule)\n\n[stuck] You have repeated read 3 times." + items := []session.DisplayEntry{{Role: "aside", Text: text, ForModel: true, Team: []session.TeamLine{ + {Team: "harbor", From: "web", Text: "index is ready"}, + {Team: "harbor", From: "manager", Text: "ship it"}, + }}} + entries := appEntriesOf("a", items, time.Now(), nil) + if len(entries) != 1 || entries[0].Text != "@web: index is ready\nmanager: ship it" { + t.Fatalf("entries = %+v", entries) + } +} + // AN ASK SAYS WHAT IT IS ABOUT: an automation by its order id, a task by its // app id, a call by its id. func TestAnAskCarriesItsSubject(t *testing.T) { diff --git a/docs/changes/unreleased/0-app-crew-walk-fixes.md b/docs/changes/unreleased/0-app-crew-walk-fixes.md new file mode 100644 index 0000000000..84fe46fd73 --- /dev/null +++ b/docs/changes/unreleased/0-app-crew-walk-fixes.md @@ -0,0 +1,8 @@ +--- +kind: fixed +title: codeaf app times each entry, names started members, nests crews, and shows crew lines by handle +pr: 0 +surface: [engine] +invalidates: + - "Was true: every entry of a reread conversation carried the conversation's latest moment, a member a manager started was listed untitled, `crews.move` refused every move it should allow (the check's answer was read backwards), a team's delivery showed the session's instruction to its model and any nudge that rode with it, the crew log named seats (`manager`, `room`, `system`) and the manager's mark, and a crew cap changed nowhere in the manager's chat. Is true now: each entry carries the instant its own journal line was written (`DisplayEntry.At`), a started member is named by its brief in the list and in its own record, crews move and nest, a member that runs a child crew carries `leads`, a delivery reads as its lines alone, log lines are from and to handles, `you`, `everyone` or `codeaf`, and a cap change leaves a receipt in the manager's chat." +--- diff --git a/docs/changes/unreleased/0-app-limits-closed.md b/docs/changes/unreleased/0-app-limits-closed.md index 33e0dca7a7..53ee3fa7af 100644 --- a/docs/changes/unreleased/0-app-limits-closed.md +++ b/docs/changes/unreleased/0-app-limits-closed.md @@ -1,6 +1,6 @@ --- kind: fixed -title: codeaf app says crews, live lines and working everywhere, starts a member once, and keeps process ids apart +title: codeaf app says crews and live lines everywhere, starts a member once, keeps process ids apart pr: 0 surface: [engine] --- diff --git a/docs/changes/unreleased/0-app-tasks-quit-clocks.md b/docs/changes/unreleased/0-app-tasks-quit-clocks.md index 020eb0e7a8..bee2ee1e82 100644 --- a/docs/changes/unreleased/0-app-tasks-quit-clocks.md +++ b/docs/changes/unreleased/0-app-tasks-quit-clocks.md @@ -1,6 +1,6 @@ --- kind: added -title: codeaf app pauses handed-over tasks, resumes work after a quit, and runs a suggested choice on a clock +title: codeaf app pauses handed-over tasks, resumes after a quit, runs a suggested choice on a clock pr: 0 surface: [engine] --- diff --git a/docs/changes/unreleased/0-app-ui-walk-fixes.md b/docs/changes/unreleased/0-app-ui-walk-fixes.md index 20e35618e1..55a89beaa2 100644 --- a/docs/changes/unreleased/0-app-ui-walk-fixes.md +++ b/docs/changes/unreleased/0-app-ui-walk-fixes.md @@ -1,6 +1,6 @@ --- kind: fixed -title: codeaf app hides lines meant for the model, names what each ask is about, and keeps small renames quick +title: codeaf app hides lines meant for the model, names what each ask is about, keeps renames quick pr: 0 surface: [engine] --- diff --git a/internal/appwire/crews.go b/internal/appwire/crews.go index 2e761337c8..1340c42be0 100644 --- a/internal/appwire/crews.go +++ b/internal/appwire/crews.go @@ -161,6 +161,7 @@ type CrewMember struct { Independent bool `json:"independent,omitempty"` ReportsTo *CrewReportsTo `json:"reports_to,omitempty"` AlsoIn []string `json:"also_in,omitempty"` + Leads string `json:"leads,omitempty"` // the child crew this member manages State string `json:"state,omitempty"` Line string `json:"line,omitempty"` SpentUSD float64 `json:"spent_usd"` diff --git a/internal/session/agent.go b/internal/session/agent.go index fcd9ce3bed..bcb8eb10fc 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -4750,6 +4750,10 @@ type DisplayEntry struct { // a surface that shows a conversation to a person leaves it out (a team's // delivery, which carries [DisplayEntry.Team], is drawn as its card). ForModel bool + // At is when the message this entry came from was written to the journal, + // and the zero time when the record never said (entrytime.go). A tool entry + // carries its call's message's instant. + At time.Time } // SteerMark is what the record keeps about one steer that LANDED: when the @@ -4927,6 +4931,7 @@ func shapeEntries(messages []ai.Message, journal *sessionFile, indexes ...*prese Steer: journal.steerMark(msg), Team: team, ForModel: forModel, + At: journal.written(msg), }) for callIndex := range msg.ToolCalls { call := &msg.ToolCalls[callIndex] @@ -4954,6 +4959,7 @@ func shapeEntries(messages []ai.Message, journal *sessionFile, indexes ...*prese // the same figure EventToolFinished carried while the window was // open. Zero when the file never recorded one. Took: journal.took(call.ID), + At: journal.written(msg), }) } } diff --git a/internal/session/entrytime.go b/internal/session/entrytime.go new file mode 100644 index 0000000000..65e4fcec13 --- /dev/null +++ b/internal/session/entrytime.go @@ -0,0 +1,84 @@ +package session + +import ( + "sync" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" +) + +// WHEN A MESSAGE WAS WRITTEN IS A FACT OF ITS LINE. +// +// Every journal line carries the instant it was written, and a page that reads +// a conversation back wants that instant on each row: a reply that came an hour +// after the question should not claim the question's minute. The message itself +// has no clock, so the instant is kept beside it, keyed the way the +// presentation marks are, by the message's own first part. +// +// A message the file never stamped, and a message a compaction pass rebuilt, +// answers the zero time, which a surface reads as "not known". + +// stampIndex is when each message's line was written. +type stampIndex struct { + mu sync.Mutex + at map[any]time.Time +} + +// stampKey is the message's identity for the index: its first part, or its +// first call when it carries no words, and nil when it carries neither. +func stampKey(message ai.Message) any { + switch { + case len(message.Content) > 0: + return &message.Content[0] + case len(message.ToolCalls) > 0: + return &message.ToolCalls[0] + } + return nil +} + +// remember records when the message was written. A zero instant is not kept. +func (s *stampIndex) remember(message ai.Message, at time.Time) { + key := stampKey(message) + if s == nil || key == nil || at.IsZero() { + return + } + s.mu.Lock() + defer s.mu.Unlock() + if s.at == nil { + s.at = make(map[any]time.Time) + } + s.at[key] = at +} + +// of answers when the message was written, or the zero time. +func (s *stampIndex) of(message ai.Message) time.Time { + key := stampKey(message) + if s == nil || key == nil { + return time.Time{} + } + s.mu.Lock() + defer s.mu.Unlock() + return s.at[key] +} + +// lineInstant reads a journal line's own timestamp, and the zero time for a +// line that has none or one this build cannot read. +func lineInstant(text string) time.Time { + at, err := time.Parse(time.RFC3339Nano, text) + if err != nil { + return time.Time{} + } + return at +} + +// written is when the message's line was written, through a journal that may +// be nil. +func (s *sessionFile) written(message ai.Message) time.Time { + if s == nil { + return time.Time{} + } + s.mu.Lock() + stamps := s.stamps + s.mu.Unlock() + return stamps.of(message) +} diff --git a/internal/session/entrytime_test.go b/internal/session/entrytime_test.go new file mode 100644 index 0000000000..ca76101931 --- /dev/null +++ b/internal/session/entrytime_test.go @@ -0,0 +1,64 @@ +package session + +import ( + "testing" + "time" +) + +// EACH ENTRY READS BACK WITH THE INSTANT ITS OWN LINE WAS WRITTEN. A reply that +// came minutes after the question carries its own minute, a call carries its +// message's, and a line with no stamp answers the zero time rather than a +// neighbour's. +func TestEntriesReadBackWithTheirOwnInstants(t *testing.T) { + const record = `{"type":"session","version":1,"id":"n1","cwd":"/tmp/lab"} +{"type":"message","role":"user","content":"Fix the nil-map crash","timestamp":"2026-10-15T14:00:00Z"} +{"type":"message","role":"assistant","content":"Looking at the loader.","toolCalls":[{"id":"c1","function":{"name":"read","arguments":"{}"}}],"timestamp":"2026-10-15T14:02:00Z"} +{"type":"message","role":"tool","toolCallId":"c1","content":"189 lines","timestamp":"2026-10-15T14:02:01Z"} +{"type":"message","role":"assistant","content":"Fixed.","timestamp":"2026-10-15T14:09:30Z"} +{"type":"message","role":"user","content":"thanks"} +` + want := map[string]time.Time{ + "Fix the nil-map crash": time.Date(2026, 10, 15, 14, 0, 0, 0, time.UTC), + "Looking at the loader.": time.Date(2026, 10, 15, 14, 2, 0, 0, time.UTC), + "Fixed.": time.Date(2026, 10, 15, 14, 9, 30, 0, time.UTC), + "thanks": {}, + } + entries := ReadTranscriptBytes([]byte(record)).Entries + seen := 0 + for _, entry := range entries { + if entry.Role == "tool" && entry.Tool != "" { + if !entry.At.Equal(want["Looking at the loader."]) { + t.Fatalf("the call read back at %v, not its message's instant", entry.At) + } + continue + } + if entry.Role == "tool" { + continue + } + at, ok := want[entry.Text] + if !ok { + continue + } + seen++ + if !entry.At.Equal(at) { + t.Fatalf("%q read back at %v, want %v", entry.Text, entry.At, at) + } + } + if seen != len(want) { + t.Fatalf("saw %d of %d entries: %#v", seen, len(want), entries) + } +} + +// A MEMBER A MANAGER STARTED IS NAMED BY ITS BRIEF. Its first words are the +// delivery that carried the brief, and the brief, not the delivery's framing, +// is what the conversation is about. +func TestAStartedMembersBriefIsItsNamingQuestion(t *testing.T) { + delivery := "The manager started you.\n" + teamNewsLead + `"backend"` + ":\n" + + teamBriefWord + " #7: Rewrite the lexer so tokens carry spans.\n Keep the tests green.\n" + if got := teamBrief(delivery); got != "Rewrite the lexer so tokens carry spans.\nKeep the tests green." { + t.Fatalf("the brief read back as %q", got) + } + if got := teamBrief("Team traffic in \"backend\":\n◆ from manager #8: status?\n"); got != "" { + t.Fatalf("a note that is not a brief named the conversation %q", got) + } +} diff --git a/internal/session/sessionfile.go b/internal/session/sessionfile.go index cf50843304..7c26610bc7 100644 --- a/internal/session/sessionfile.go +++ b/internal/session/sessionfile.go @@ -1212,6 +1212,9 @@ type sessionFile struct { // no duration, which is the lie a room opened after landing used to tell. tooks map[string]journalTook + // stamps is when each message's line was written (entrytime.go). + stamps *stampIndex + // restored is what this conversation had already spent when the file was // opened: the SUM of its usage lines, replayed once and never updated after. // It is the file's answer to "what did this cost before today", and the agent @@ -1720,6 +1723,10 @@ func openSessionFile(path, cwd, model, id string) (*sessionFile, replayedSession journal.steers = replayed.steers journal.captions = replayed.captions journal.tooks = replayed.tooks + journal.stamps = replayed.stamps + if journal.stamps == nil { + journal.stamps = &stampIndex{} + } journal.restored = replayed.usage if !replayed.existed { @@ -1872,6 +1879,8 @@ func readJournal(reader io.Reader, path string, rebuild bool) (replayedSession, // 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]). presentation := &presentationIndex{} + // And when each message was written, off the same line (entrytime.go). + stamps := &stampIndex{} notes := make(map[string]bool) // And the caption index, for the notes' reason exactly: a `caption` line is // news about a batch that is not carried by any message, so this pass is the @@ -1960,6 +1969,7 @@ func readJournal(reader io.Reader, path string, rebuild bool) (replayedSession, mark = legacyPresentation(entry) } presentation.remember(message, mark) + stamps.remember(message, lineInstant(entry.Timestamp)) rememberParts(images, message, entry.Parts) if entry.Note { rememberNote(notes, message) @@ -2234,6 +2244,7 @@ func readJournal(reader io.Reader, path string, rebuild bool) (replayedSession, images: images, notes: notes, presentation: presentation, + stamps: stamps, replyTags: replyTags, delivered: delivered, noteDeliveries: noteDeliveries, @@ -2437,6 +2448,8 @@ type replayedSession struct { // tooks is how long each call ran, keyed by the call's own id — the index // [sessionFile.tooks] is opened holding. tooks map[string]journalTook + // stamps is when each message's line was written (entrytime.go). + stamps *stampIndex // unread is the 1-based line of the file this reading could not get past, and // zero for a file read to its end. Everything above it is in `messages`; the // number is what lets a caller say WHICH line rather than "something went @@ -2804,6 +2817,15 @@ func (s *sessionFile) appendWithReasoning(message ai.Message, note bool, refs [] Deliveries: deliveries, Timestamp: stamp(), }) + if wrote { + s.mu.Lock() + if s.stamps == nil { + s.stamps = &stampIndex{} + } + stamps := s.stamps + s.mu.Unlock() + stamps.remember(message, time.Now()) + } // The ids are indexed only once the line is really on disk: this index is // what a resume trusts to say a landing has already been told. if wrote && len(deliveries) > 0 { diff --git a/internal/session/title.go b/internal/session/title.go index 01e256104b..95d96285fc 100644 --- a/internal/session/title.go +++ b/internal/session/title.go @@ -87,6 +87,7 @@ import ( "github.com/Agent-Field/agentfield/sdk/go/ai" "github.com/Agent-Field/codeaf/internal/roles" + "github.com/Agent-Field/codeaf/internal/teams" ) // titleSystem is all the system message says, and it is deliberately not the @@ -530,6 +531,13 @@ func (a *Agent) firstExchangeLocked() (string, string) { switch message.Role { case "user": if a.sessionNoteLocked(message) { + // A MEMBER A MANAGER STARTED HAS NO MESSAGE OF THE PERSON'S: its + // first words are the brief it was started with, delivered by + // the session. That brief is what the conversation is about, so + // it names the conversation when nothing the person typed has. + if question == "" && !shellOpening { + question = teamBrief(messageContentText(message)) + } continue } // Do not borrow an answer from a later, unrelated user turn. After @@ -1042,3 +1050,14 @@ func healedTitle(stored string) string { } return stored } + +// teamBrief is the words of the brief a team delivery started this +// conversation with, and "" for a message that carries none. +func teamBrief(text string) string { + for _, line := range teamNewsLines(text) { + if line.Kind == teams.KindStart { + return strings.TrimSpace(line.Text) + } + } + return "" +} diff --git a/internal/session/transcriptread.go b/internal/session/transcriptread.go index 2299fc32a6..bfbc167264 100644 --- a/internal/session/transcriptread.go +++ b/internal/session/transcriptread.go @@ -231,6 +231,7 @@ func transcriptFrom(replayed replayedSession, err error) Record { steers: replayed.steers, captions: replayed.captions, tooks: replayed.tooks, + stamps: replayed.stamps, } overlap := replayed.overlap if overlap > len(replayed.messages) { From f4e164f725b87022c38eddc2d1c7fcb09140f037 Mon Sep 17 00:00:00 2001 From: agentfield-bot Date: Thu, 15 Oct 2026 13:23:34 -0400 Subject: [PATCH 019/459] codeaf app: wire schema from Go types, order runs and proposals as asks, moves answer at once --- THIRD-PARTY-NOTICES.md | 63 ++++ cmd/codeaf/app.go | 14 +- cmd/codeaf/app_test.go | 7 + cmd/codeaf/appcrewmembers.go | 3 +- cmd/codeaf/appcrews.go | 21 ++ cmd/codeaf/appcrews_test.go | 33 ++ cmd/codeaf/apporders.go | 121 ++++++++ cmd/codeaf/apporders_test.go | 45 +++ cmd/codeaf/appwork.go | 86 ++++-- cmd/codeaf/appwork_test.go | 49 +-- docs/changes/unreleased/0-app-wire-schema.md | 8 + go.mod | 2 +- internal/appwire/contract.go | 116 +++++++ internal/appwire/contract_test.go | 157 ++++++++++ internal/appwire/convo.go | 205 ++++++------- internal/appwire/crews.go | 164 +++++----- internal/appwire/events.go | 47 +++ internal/appwire/inbox.go | 80 ++--- internal/appwire/orders.go | 68 +++-- internal/appwire/schema.go | 199 ++++++++++++ internal/appwire/server.go | 303 +++++++++---------- internal/appwire/settings8.go | 58 ++-- internal/appwire/setup.go | 120 +++++--- internal/appwire/tidy.go | 78 ++--- internal/appwire/wire.go | 21 +- internal/appwire/work.go | 192 ++++++------ internal/appwire/worklog.go | 36 +-- internal/session/checkpoint.go | 7 +- internal/session/readhandoff.go | 57 +++- internal/session/route_judge.go | 10 +- internal/session/sessionfile.go | 7 +- 31 files changed, 1648 insertions(+), 729 deletions(-) create mode 100644 docs/changes/unreleased/0-app-wire-schema.md create mode 100644 internal/appwire/contract.go create mode 100644 internal/appwire/contract_test.go create mode 100644 internal/appwire/events.go create mode 100644 internal/appwire/schema.go diff --git a/THIRD-PARTY-NOTICES.md b/THIRD-PARTY-NOTICES.md index fd9c7ca8cd..82002e6016 100644 --- a/THIRD-PARTY-NOTICES.md +++ b/THIRD-PARTY-NOTICES.md @@ -468,6 +468,69 @@ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE SOFTWARE. ``` +## github.com/aymanbagabas/go-udiff + +- Version: v0.4.1 +- Licence: BSD-3-Clause + +### LICENSE-BSD + +```text +Copyright (c) 2009 The Go Authors. All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are +met: + + * Redistributions of source code must retain the above copyright +notice, this list of conditions and the following disclaimer. + * Redistributions in binary form must reproduce the above +copyright notice, this list of conditions and the following disclaimer +in the documentation and/or other materials provided with the +distribution. + * Neither the name of Google Inc. nor the names of its +contributors may be used to endorse or promote products derived from +this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS +"AS IS" AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT +LIMITED TO, THE IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR +A PARTICULAR PURPOSE ARE DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT +OWNER OR CONTRIBUTORS BE LIABLE FOR ANY DIRECT, INDIRECT, INCIDENTAL, +SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES (INCLUDING, BUT NOT +LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; LOSS OF USE, +DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND ON ANY +THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT +(INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. +``` + +### LICENSE-MIT + +```text +MIT License + +Copyright (c) 2023 Ayman Bagabas + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. +``` + ## github.com/bahlo/generic-list-go - Version: v0.2.0 diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index f3d2ac76b5..29f458be37 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -82,11 +82,20 @@ func runApp(args []string) error { flags := commandFlags("app") homeFlag := flags.String("home", "", "codeaf's state folder for this app, instead of CODEAF_HOME or ~/.codeaf") turnLong := flags.Duration("turn-long", appTurnLong, "how long a reply runs before the app is told it is running long") + schema := flags.Bool("schema", false, "print the wire's JSON Schema, generated from its Go types, and exit") if err := parseCommandFlags(flags, args); err != nil { return err } if flags.NArg() > 0 { - return fmt.Errorf("usage: codeaf app [--home dir]") + return fmt.Errorf("usage: codeaf app [--home dir] [--schema]") + } + if *schema { + out, err := appwire.SchemaJSON() + if err != nil { + return err + } + _, err = os.Stdout.Write(out) + return err } if dir := strings.TrimSpace(*homeFlag); dir != "" { abs, err := filepath.Abs(dir) @@ -1890,7 +1899,8 @@ func (b *appBackend) ListAsks(id string) ([]appwire.Ask, error) { asks = append(asks, a.live.asks...) } }) - return append(asks, b.crewAsksOf(id)...), nil + asks = append(asks, b.crewAsksOf(id)...) + return append(asks, b.orderAsksOf(id)...), nil } // appAnsweredMark starts the value [appLive.answered] holds for an ask this app diff --git a/cmd/codeaf/app_test.go b/cmd/codeaf/app_test.go index d6de002442..ef9c31a38e 100644 --- a/cmd/codeaf/app_test.go +++ b/cmd/codeaf/app_test.go @@ -5,11 +5,13 @@ import ( "context" "encoding/json" "errors" + "fmt" "io" "net/http" "net/http/httptest" "os" "path/filepath" + "reflect" "strconv" "strings" "sync" @@ -369,6 +371,11 @@ type appRecorder struct { func newAppRecorder() *appRecorder { return &appRecorder{more: make(chan struct{}, 1)} } func (r *appRecorder) Emit(event string, data any) { + // EVERY EVENT IS ITS CONTRACT'S SHAPE: data of any other type is a wire + // break the app would reject, so a test that provokes one fails here. + if want := appwire.EventShape(event); want == nil || reflect.TypeOf(data) != want { + panic(fmt.Sprintf("event %s carried %T, and its contract is %v", event, data, want)) + } r.mu.Lock() r.events = append(r.events, event) r.data = append(r.data, data) diff --git a/cmd/codeaf/appcrewmembers.go b/cmd/codeaf/appcrewmembers.go index ef0e3e1e42..82d46b6980 100644 --- a/cmd/codeaf/appcrewmembers.go +++ b/cmd/codeaf/appcrewmembers.go @@ -491,7 +491,8 @@ func appProposalAskOf(p appwire.Proposal) appwire.Ask { return appwire.Ask{ ID: appProposalAskPrefix + p.ID, Agent: p.Agent, Kind: appwire.AskQuestion, Subkind: appwire.SubkindCrewProposal, Title: "Bring in helpers: " + p.Name, Detail: strings.Join(detail, "\n"), Reason: p.Goal, - Subject: &appwire.AskSubject{Kind: appwire.SubjectCrewProposal, Ref: p.ID}, + Subject: &appwire.AskSubject{Kind: appwire.SubjectCrewProposal, Ref: p.ID}, + Proposal: &p, Options: []appwire.AskOption{ {ID: appwire.ProposalCreate, Label: "Make the crew"}, {ID: appwire.ProposalEdit, Label: "Edit"}, diff --git a/cmd/codeaf/appcrews.go b/cmd/codeaf/appcrews.go index 5f5c9744e1..5eeab81b15 100644 --- a/cmd/codeaf/appcrews.go +++ b/cmd/codeaf/appcrews.go @@ -136,11 +136,32 @@ func (b *appBackend) crews() *appCrewRun { }) if start { b.crewTick(run, true) + b.reopenProposals(run) guard.Go("app/crews", func() { b.crewLoop(run) }) } return run } +// reopenProposals asks again about every crew proposal the person had not +// answered when the app last closed: a proposal is kept in the app layer, so +// its ask outlives a restart. +func (b *appBackend) reopenProposals(run *appCrewRun) { + var kept []appwire.Proposal + _ = b.crewLayer().read(func(f *appCrewLayerFile) { + for _, p := range f.Proposals { + if p != nil { + kept = append(kept, *p) + } + } + }) + sort.Slice(kept, func(i, j int) bool { return kept[i].ID < kept[j].ID }) + for _, p := range kept { + ask := appProposalAskOf(p) + run.locked(func() { run.asks[ask.ID] = ask }) + b.emit.Emit(appwire.EventAskOpened, appwire.AskOpened{Ask: ask}) + } +} + // crewLoop looks at the crews every beat until the app closes. func (b *appBackend) crewLoop(run *appCrewRun) { defer close(run.done) diff --git a/cmd/codeaf/appcrews_test.go b/cmd/codeaf/appcrews_test.go index e92c59772a..bdedda1986 100644 --- a/cmd/codeaf/appcrews_test.go +++ b/cmd/codeaf/appcrews_test.go @@ -249,3 +249,36 @@ func TestAppCrewsNestByMoveAndByCreate(t *testing.T) { } } } + +// A CREW PROPOSAL OUTLIVES A RESTART: it is kept in the app layer until it is +// answered, and its ask comes back whole, carrying the proposal itself. +func TestAppCrewProposalIsAskedAgainWhole(t *testing.T) { + backend, rec, agent, _ := crewBackend(t) + p := appwire.Proposal{ID: "prop1", Agent: agent.ID, Name: "site", Goal: "ship the site", + Helpers: []appwire.ProposalHelper{{Handle: "builder", Role: "writes pages", Brief: "write index.html"}}} + if err := backend.crewLayer().write(func(f *appCrewLayerFile) error { + if f.Proposals == nil { + f.Proposals = map[string]*appwire.Proposal{} + } + f.Proposals[p.ID] = &p + return nil + }); err != nil { + t.Fatal(err) + } + backend.reopenProposals(backend.crews()) + rec.waitFor(t, time.Second, func(event string, data any) bool { + opened, ok := data.(appwire.AskOpened) + return ok && opened.Ask.Proposal != nil && opened.Ask.Proposal.Helpers[0].Handle == "builder" + }) + asks, err := backend.ListAsks(agent.ID) + if err != nil { + t.Fatal(err) + } + found := false + for _, ask := range asks { + found = found || ask.Subkind == appwire.SubkindCrewProposal && ask.Proposal != nil && ask.Proposal.Name == "site" + } + if !found { + t.Fatalf("the proposal's ask is not listed: %+v", asks) + } +} diff --git a/cmd/codeaf/apporders.go b/cmd/codeaf/apporders.go index 202cadc42a..11fcc3ef7d 100644 --- a/cmd/codeaf/apporders.go +++ b/cmd/codeaf/apporders.go @@ -28,6 +28,7 @@ import ( "github.com/Agent-Field/codeaf/internal/appwire" "github.com/Agent-Field/codeaf/internal/automation" + "github.com/Agent-Field/codeaf/internal/redact" "github.com/Agent-Field/codeaf/internal/session" ) @@ -518,9 +519,122 @@ func (b *appBackend) answerOrderAsk(askID, option string) (bool, error) { return true, appwire.Errorf(appwire.CodeBadParams, "the ask %s offers go, skip or not_right", askID) } _, err = b.OrderVerdict(r.AutomationID, run, option) + if err == nil { + appOrderAnswered.Store(run, option) + } return true, err } +// appOrderAnswered is the runs this app answered, with the option, so the +// ask's closing says it was answered here. +var appOrderAnswered sync.Map + +// appOrderAskID is a run's ask id. A RUN, NOT AN ORDER: an order asks once per +// run, a run's number is never used twice in the store, and an answer is to +// that run and no other. +func appOrderAskID(run int64) string { + return appwire.OrderAskPrefix + strconv.FormatInt(run, 10) +} + +// appOrderRunAsk is a run waiting for the person as an ask, so it is in +// asks.list and the Inbox like every other thing waiting on them. +func appOrderRunAsk(view appwire.Order, run automation.Run) appwire.Ask { + ask := appwire.Ask{ + ID: appOrderAskID(run.ID), Agent: view.Agent, Kind: appwire.AskQuestion, Subkind: appwire.SubkindOrderRun, + Title: "Run " + view.Title + " now?", Detail: view.Does, + Subject: &appwire.AskSubject{Kind: appwire.SubjectAutomation, Ref: view.ID}, + Options: []appwire.AskOption{ + {ID: appwire.VerdictGo, Label: "Go"}, + {ID: appwire.VerdictSkip, Label: "Skip this one"}, + {ID: appwire.VerdictNotRight, Label: "Not right"}, + }, + } + if view.Trust == appwire.TrustAsksFirst || view.Trust == appwire.TrustEarning { + ask.Reason = "it asks before each run until it has earned trust" + } + return ask +} + +// orderAsksOf is the runs waiting on the person for an agent's orders, as +// asks. +func (b *appBackend) orderAsksOf(agent string) []appwire.Ask { + store, err := b.ordersOf() + if err != nil { + return nil + } + runs, err := store.Asking() + if err != nil { + return nil + } + now := b.now() + var out []appwire.Ask + for _, run := range runs { + a, err := store.Get(run.AutomationID) + if err != nil { + continue + } + view := orderView(store, a, now) + if view.Agent == agent { + out = append(out, appOrderRunAsk(view, run)) + } + } + return out +} + +// sayOrderAsks opens an ask for every run newly waiting on the person and +// closes the ask of every run that stopped waiting, against what was open. +func (b *appBackend) sayOrderAsks(store *automation.Store, open map[int64]appwire.Ask) { + runs, err := store.Asking() + if err != nil { + return + } + now := b.now() + waiting := map[int64]bool{} + for _, run := range runs { + waiting[run.ID] = true + if _, known := open[run.ID]; known { + continue + } + a, err := store.Get(run.AutomationID) + if err != nil { + continue + } + ask := appOrderRunAsk(orderView(store, a, now), run) + open[run.ID] = ask + b.emit.Emit(appwire.EventAskOpened, appwire.AskOpened{Ask: ask}) + } + for id, ask := range open { + if waiting[id] { + continue + } + delete(open, id) + closed := appwire.AskClosed{Agent: ask.Agent, ID: ask.ID, By: appwire.ClosedByElsewhere, At: now} + if option, ok := appOrderAnswered.LoadAndDelete(id); ok { + closed.By, closed.Option = appwire.ClosedByApp, option.(string) + } + b.emit.Emit(appwire.EventAskClosed, closed) + } +} + +// sayOrderResult puts a finished run's line in the chat it answers in. +func (b *appBackend) sayOrderResult(view appwire.Order, run automation.Run) { + if view.Agent == "" || !run.News() { + return + } + if view.AnswersIn != appwire.AnswersChat && view.AnswersIn != appwire.AnswersEverywhere { + return + } + result := orderRunView(run) + words := view.Title + " · " + result.Result + if line := strings.TrimSpace(run.Line); line != "" { + words += ": " + line + } + b.emit.Emit(appwire.EventEntryAdded, appwire.EntryAdded{Entry: appwire.Entry{ + ID: "order-run-" + strconv.FormatInt(run.ID, 10), Agent: view.Agent, Kind: appwire.EntryNotice, + At: b.now(), Text: redact.Secrets(words), Ref: appOrderAskID(run.ID), + }}) +} + // watchOrders reads the store until stop closes and says what changed: // order.changed for an order that is new or different, order.removed for one // that is gone, order.asking for a run waiting for the person, order.fired @@ -540,6 +654,10 @@ func (b *appBackend) watchOrders(stop <-chan struct{}) { seen[a.ID] = orderPrint(a) } } + // THE RUNS ALREADY WAITING ARE SAID AT ONCE, so the Inbox holds them from + // the moment the app opens. + open := map[int64]appwire.Ask{} + b.sayOrderAsks(store, open) ticker := time.NewTicker(appOrdersEvery) defer ticker.Stop() for { @@ -549,6 +667,7 @@ func (b *appBackend) watchOrders(stop <-chan struct{}) { case <-ticker.C: } cursor = b.ordersOnce(store, cursor, seen) + b.sayOrderAsks(store, open) } } @@ -581,6 +700,8 @@ func (b *appBackend) ordersOnce(store *automation.Store, cursor int64, seen map[ if run.Why == automation.WhyLate { b.emit.Emit(appwire.EventOrderMissed, appwire.OrderChanged{Order: view}) } + case automation.PhaseOver: + b.sayOrderResult(view, run) } } cursor = next diff --git a/cmd/codeaf/apporders_test.go b/cmd/codeaf/apporders_test.go index b25a172d4a..60f359e88f 100644 --- a/cmd/codeaf/apporders_test.go +++ b/cmd/codeaf/apporders_test.go @@ -166,3 +166,48 @@ func TestAppOrderPauseResumeStop(t *testing.T) { t.Fatal("a stopped order was resumed") } } + +// A RUN WAITING ON THE PERSON IS AN ASK LIKE ANY OTHER: opened for the +// Inbox, listed for its agent under the run's id, and closed as answered here +// once the person answers it. +func TestAppOrderRunIsAnAskEverywhere(t *testing.T) { + backend, rec, store := ordersBackend(t) + transcript := filepath.Join(t.TempDir(), "places", "proj", "abcd1234", "transcript.jsonl") + a, err := store.Create(automation.Automation{ + Title: "weekly update", Schedule: automation.Schedule{Every: "1h", Zone: "UTC"}, + Action: automation.Action{Do: "draft the weekly update"}, Workspace: t.TempDir(), + Status: automation.StatusProposed, Trust: automation.TrustAsksFirst, + Origin: automation.Origin{Transcript: transcript}, + }) + if err != nil { + t.Fatal(err) + } + if _, err := store.Ratify(a.ID); err != nil { + t.Fatal(err) + } + run, err := store.QueueAsking(a.ID) + if err != nil { + t.Fatal(err) + } + agent, id := appAgentID(transcript), "order:"+strconv.FormatInt(run.ID, 10) + open := map[int64]appwire.Ask{} + backend.sayOrderAsks(store, open) + rec.waitFor(t, time.Second, func(event string, data any) bool { + opened, ok := data.(appwire.AskOpened) + return ok && opened.Ask.ID == id && opened.Ask.Agent == agent && opened.Ask.Subkind == appwire.SubkindOrderRun + }) + if asks := backend.orderAsksOf(agent); len(asks) != 1 || asks[0].ID != id || asks[0].Subject == nil || asks[0].Subject.Ref != a.ID { + t.Fatalf("the agent's asks = %+v", asks) + } + if err := backend.AnswerAsk(agent, id, appwire.VerdictGo, ""); err != nil { + t.Fatal(err) + } + backend.sayOrderAsks(store, open) + rec.waitFor(t, time.Second, func(event string, data any) bool { + closed, ok := data.(appwire.AskClosed) + return ok && closed.ID == id && closed.By == appwire.ClosedByApp && closed.Option == appwire.VerdictGo + }) + if asks := backend.orderAsksOf(agent); len(asks) != 0 { + t.Fatalf("an answered run still asks: %+v", asks) + } +} diff --git a/cmd/codeaf/appwork.go b/cmd/codeaf/appwork.go index 8cefbc4ba5..09a45a886d 100644 --- a/cmd/codeaf/appwork.go +++ b/cmd/codeaf/appwork.go @@ -39,6 +39,10 @@ const appTurnLong = 180 * time.Second // the move happens at the reply's next step and writes a brief first. const appMoveWait = 10 * time.Minute +// appMoveQuick is how long agents.move_to_task waits for a reply that moves at +// once before it answers that the move is taken. +const appMoveQuick = 300 * time.Millisecond + // appFollowEvery is how often a followed process's output is looked at. const appFollowEvery = 500 * time.Millisecond @@ -1113,14 +1117,39 @@ func (b *appBackend) watchLong(id string, live *appLive, turn uint64, started ti }) } -// MoveToTask moves the running reply to a task at its next step and answers -// that task. -func (b *appBackend) MoveToTask(agent string, turn uint64) (appwire.Task, error) { +// MoveToTask asks the running reply to move to a task and answers AT ONCE. +// The engine moves a reply at its next step boundary, which can be a long way +// off while a call is in flight; the person's ask is taken now, the answer says +// so, and the task arrives on its own as task.changed with the move's notice. +// A reply that ends before it could move leaves a notice saying so. +func (b *appBackend) MoveToTask(agent string, turn uint64) (appwire.MoveResult, error) { a, err := b.find(agent) if err != nil { - return appwire.Task{}, err + return appwire.MoveResult{}, err } live := b.liveOf(a) + // A TASK THE REPLY ALREADY PROPOSED IS THE TASK IT MOVES TO. While its card + // waits, the reply is parked on the person's answer and reaches no step at + // which it could move, so moving it is saying yes to the card. + var proposed *appwire.Task + b.locked(func() { + if live == nil { + return + } + for _, task := range live.tasks { + if task.Proposed && task.Parent == "" && task.State != appwire.TaskFailed && task.State != appwire.TaskDone && (proposed == nil || task.ID > proposed.ID) { + found := task + proposed = &found + } + } + }) + if proposed != nil { + if err := b.ActOnTask(proposed.ID, "start", "", ""); err != nil { + return appwire.MoveResult{}, err + } + proposed.Proposed = false + return appwire.MoveResult{Task: proposed}, nil + } known := map[string]bool{} running := false b.locked(func() { @@ -1128,22 +1157,24 @@ func (b *appBackend) MoveToTask(agent string, turn uint64) (appwire.Task, error) return } running = live.running && (turn == 0 || live.turn == turn) + if turn == 0 { + turn = live.turn + } for id := range live.tasks { known[id] = true } }) if !running { - return appwire.Task{}, appwire.Errorf(appwire.CodeNotFound, "that reply is not running") + return appwire.MoveResult{}, appwire.Errorf(appwire.CodeNotFound, "that reply is not running") } if err := live.conn.Conv.MoveTurnToTask(); err != nil { - return appwire.Task{}, appEngineError(err) + return appwire.MoveResult{}, appEngineError(err) } - deadline := time.Now().Add(appMoveWait) - for { + moved := func() (*appwire.Task, bool) { var found *appwire.Task still := false b.locked(func() { - still = live.running && (turn == 0 || live.turn == turn) + still = live.running && live.turn == turn for id, task := range live.tasks { if !known[id] && task.Parent == "" { found = &task @@ -1151,17 +1182,36 @@ func (b *appBackend) MoveToTask(agent string, turn uint64) (appwire.Task, error) } } }) - if found != nil { - return *found, nil - } - if !still { - return appwire.Task{}, appwire.Errorf(appwire.CodeNotFound, "the reply finished before it could move to a task") - } - if time.Now().After(deadline) { - return appwire.Task{}, appwire.Errorf(appwire.CodeEngine, "the reply has not moved to a task yet; it moves at its next step") + return found, still + } + // A reply between steps moves at once; the answer carries its task then. + quick := time.Now().Add(appMoveQuick) + for time.Now().Before(quick) { + if task, still := moved(); task != nil { + return appwire.MoveResult{Task: task}, nil + } else if !still { + break } - time.Sleep(100 * time.Millisecond) + time.Sleep(25 * time.Millisecond) } + guard.Go("app/move-to-task", func() { + deadline := time.Now().Add(appMoveWait) + for time.Now().Before(deadline) { + task, still := moved() + if task != nil { + return + } + if !still { + b.emit.Emit(appwire.EventEntryAdded, appwire.EntryAdded{Entry: appwire.Entry{ + ID: "move-" + strconv.FormatUint(turn, 10), Agent: agent, Turn: turn, Kind: appwire.EntryNotice, + At: b.now(), Text: "The reply finished before it could move to a task", Quiet: true, + }}) + return + } + time.Sleep(100 * time.Millisecond) + } + }) + return appwire.MoveResult{Moving: true}, nil } // ── sleep ─────────────────────────────────────────────────────────────────── diff --git a/cmd/codeaf/appwork_test.go b/cmd/codeaf/appwork_test.go index 2ff97ba682..43486f5aaf 100644 --- a/cmd/codeaf/appwork_test.go +++ b/cmd/codeaf/appwork_test.go @@ -309,16 +309,12 @@ func TestAppTurnLongAndMoveToTask(t *testing.T) { t.Fatalf("turn.long = %+v", long) } - moved := make(chan appwire.Task, 1) - failed := make(chan error, 1) - go func() { - task, err := backend.MoveToTask(agent.ID, turn) - if err != nil { - failed <- err - return - } - moved <- task - }() + // THE MOVE ANSWERS AT ONCE, while the step in flight is still running, and + // the task arrives on its own when the reply moves. + answered, err := backend.MoveToTask(agent.ID, turn) + if err != nil || !answered.Moving || answered.Task != nil { + t.Fatalf("the move answered %+v, %v", answered, err) + } deadline := time.Now().Add(2 * time.Second) for { conv.mu.Lock() @@ -333,16 +329,10 @@ func TestAppTurnLongAndMoveToTask(t *testing.T) { time.Sleep(5 * time.Millisecond) } conv.lane(session.Event{Kind: session.EventTaskUpdate, Task: &session.TaskNotice{ID: 11, Title: "refactor everything", State: session.TaskRunning}}) - select { - case task := <-moved: - if task.ID != agent.ID+":11" { - t.Fatalf("moved to %+v", task) - } - case err := <-failed: - t.Fatal(err) - case <-time.After(3 * time.Second): - t.Fatal("the move did not answer") - } + rec.waitFor(t, 3*time.Second, func(event string, data any) bool { + changed, ok := data.(appwire.TaskChanged) + return ok && changed.Task.ID == agent.ID+":11" + }) close(hold) rec.waitFor(t, 2*time.Second, appTurnDone) if _, err := backend.MoveToTask(agent.ID, turn); err == nil { @@ -416,3 +406,22 @@ func TestAppTasksListReadsAConversationsOwnTaskFile(t *testing.T) { t.Fatal("listing tasks opened the conversation") } } + +// MOVING A REPLY WHOSE TASK IS ALREADY PROPOSED STARTS THAT TASK. The reply is +// parked on the card, so it reaches no step to move at; the move answers the +// card with yes and answers with the task. +func TestAppMoveToTaskStartsAPendingProposal(t *testing.T) { + conv := &fakeConversation{} + backend, rec, agent := workBackend(t, conv) + conv.lane(session.Event{Kind: session.EventTaskProposal, Task: &session.TaskNotice{ID: 4, Title: "add a cache", Deadline: time.Now().Add(time.Minute)}}) + taskChanged(rec, t, func(task appwire.Task) bool { return task.Title == "add a cache" && task.Proposed }) + moved, err := backend.MoveToTask(agent.ID, 0) + if err != nil || moved.Task == nil || moved.Task.ID != agent.ID+":4" || moved.Moving { + t.Fatalf("the move answered %+v, %v", moved, err) + } + conv.mu.Lock() + defer conv.mu.Unlock() + if len(conv.intents) != 1 || conv.intents[0].Action != "start" || conv.intents[0].ID != "4" { + t.Fatalf("the card was answered with %+v", conv.intents) + } +} diff --git a/docs/changes/unreleased/0-app-wire-schema.md b/docs/changes/unreleased/0-app-wire-schema.md new file mode 100644 index 0000000000..65a5f4993f --- /dev/null +++ b/docs/changes/unreleased/0-app-wire-schema.md @@ -0,0 +1,8 @@ +--- +kind: added +title: codeaf app --schema prints the wire as JSON Schema from its Go types; moves answer at once +pr: 0 +surface: [engine] +invalidates: + - "Was true: the app's wire was described only by PROTOCOL.md, written by hand, so a field's type could drift from what the server sent (settings sent `dock_badge` as a boolean while a copy of the spec said a word); a standing order's run waiting on the person was not an ask (not in asks.list or the Inbox); a crew proposal's ask carried only words and was lost on restart; `agents.move_to_task` answered only once the step in flight ended, which was minutes when a reading had been handed to a quick task, did nothing while a proposed task's card was up, and left two notices, one saying the move was the harness's idea. Is true now: every method is built from its params and result types and every event has its data type, `codeaf app --schema` prints the JSON Schema generated from them, a test fails when PROTOCOL.md's method and event index and the server disagree, and the test recorder fails any event whose data is not its contract's type; a waiting run is an ask `order:` (subkind `order_run`) everywhere asks are, closed when it stops waiting, and its result line lands in the order's chat; a proposal ask carries `proposal` and is asked again after a restart; a move answers at once (`{moving: true}` or `{task}`), lets a pending reading go, starts a proposed task, and leaves one notice." +--- diff --git a/go.mod b/go.mod index 5a72a86af9..fb1a8050a3 100644 --- a/go.mod +++ b/go.mod @@ -8,6 +8,7 @@ require ( github.com/Agent-Field/agentfield/sdk/go v0.0.0-20260801225427-e6587ade0886 github.com/alecthomas/chroma/v2 v2.27.0 github.com/amp-labs/connectors v0.0.0-20260814231226-e12d95c297eb + github.com/aymanbagabas/go-udiff v0.4.1 github.com/charmbracelet/bubbletea v1.3.10 github.com/charmbracelet/lipgloss v1.1.0 github.com/charmbracelet/ultraviolet v0.0.0-20260703014108-f5a850f9c2b7 @@ -54,7 +55,6 @@ require ( github.com/aws/aws-sdk-go-v2/service/sts v1.45.4 // indirect github.com/aws/smithy-go v1.27.6 // indirect github.com/aymanbagabas/go-osc52/v2 v2.0.1 // indirect - github.com/aymanbagabas/go-udiff v0.4.1 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/buger/jsonparser v1.1.2 // indirect github.com/cespare/xxhash/v2 v2.3.0 // indirect diff --git a/internal/appwire/contract.go b/internal/appwire/contract.go new file mode 100644 index 0000000000..543839cd38 --- /dev/null +++ b/internal/appwire/contract.go @@ -0,0 +1,116 @@ +package appwire + +import ( + "encoding/json" + "reflect" + "sort" + "strings" +) + +// THE WIRE'S SHAPES ARE THE GO TYPES THE SERVER ANSWERS WITH. +// +// Every method is built by [typed] from a function of one params type and one +// result type, so the type the server decodes a request into and the type it +// answers with are the types the schema is generated from: there is no second +// description of a method that could say something the method does not do. +// Every event's data is the type [eventShapes] names, and the test recorders +// check each event they see against it. + +// method answers one request from its params, which are already known to be a +// JSON object, and carries the Go types of both halves. +type method struct { + call func(Engine, json.RawMessage) (any, error) + params reflect.Type + result reflect.Type +} + +// typed is a method from a function of its params and its result. The params +// are decoded into P, and a bad shape answers bad_params. +func typed[P, R any](fn func(Engine, P) (*R, error)) method { + return method{ + call: func(e Engine, raw json.RawMessage) (any, error) { + var p P + if err := decode(raw, &p); err != nil { + return nil, err + } + r, err := fn(e, p) + if err != nil { + return nil, err + } + if r == nil { + var zero R + return zero, nil + } + return r, nil + }, + params: reflect.TypeFor[P](), + result: reflect.TypeFor[R](), + } +} + +// shaped is a method that reads its own params, because how they are read +// matters (a key that is never quoted back, a patch whose fields are each read +// in their own shape); P is the shape they travel in. +func shaped[P, R any](fn func(Engine, json.RawMessage) (*R, error)) method { + return method{ + call: func(e Engine, raw json.RawMessage) (any, error) { + r, err := fn(e, raw) + if err != nil { + return nil, err + } + return r, nil + }, + params: reflect.TypeFor[P](), + result: reflect.TypeFor[R](), + } +} + +// Empty is the answer of a method that has nothing to say but that it was done. +type Empty struct{} + +// done is the empty answer, with the error a call returned. +func done(err error) (*Empty, error) { return &Empty{}, err } + +// IDParams is the params of a method that takes one id. +type IDParams struct { + ID string `json:"id"` +} + +// AgentParams is the params of a method that takes one agent. +type AgentParams struct { + Agent string `json:"agent"` +} + +// required answers the value, or bad_params naming the field when it is empty. +func required(field, value string) (string, error) { + if strings.TrimSpace(value) == "" { + return "", Errorf(CodeBadParams, "%s is required", field) + } + return value, nil +} + +// Methods is every method's name, sorted, with the shutdown the loop answers +// itself. +func Methods() []string { + names := make([]string, 0, len(methods)+1) + for name := range methods { + names = append(names, name) + } + names = append(names, "shutdown") + sort.Strings(names) + return names +} + +// Events is every event's name, sorted. +func Events() []string { + names := make([]string, 0, len(eventShapes)) + for name := range eventShapes { + names = append(names, name) + } + sort.Strings(names) + return names +} + +// EventShape is the Go type an event's data is, and nil for a name that is +// not an event. +func EventShape(name string) reflect.Type { return eventShapes[name] } diff --git a/internal/appwire/contract_test.go b/internal/appwire/contract_test.go new file mode 100644 index 0000000000..26624db1ee --- /dev/null +++ b/internal/appwire/contract_test.go @@ -0,0 +1,157 @@ +package appwire + +import ( + "encoding/json" + "os" + "reflect" + "regexp" + "sort" + "strings" + "testing" +) + +// protocolDoc is the written protocol the app is built against. +const protocolDoc = "../../docs/design/app-v3/PROTOCOL.md" + +// documented reads the protocol's method tables and its event lists: the +// names in the first column of every table headed "method", and the names in +// the event table and in every "Events" line. +func documented(t *testing.T) (methodsInDoc, eventsInDoc map[string]bool) { + t.Helper() + raw, err := os.ReadFile(protocolDoc) + if err != nil { + t.Fatal(err) + } + methodsInDoc, eventsInDoc = map[string]bool{}, map[string]bool{} + tick := regexp.MustCompile("`([a-z_]+(?:\\.[a-z_]+)*)[` ]") + table := "" + for _, line := range strings.Split(string(raw), "\n") { + trimmed := strings.TrimSpace(line) + switch { + case strings.HasPrefix(trimmed, "| method |"): + table = "method" + continue + case strings.HasPrefix(trimmed, "| event |"): + table = "event" + continue + case !strings.HasPrefix(trimmed, "|"): + table = "" + } + if strings.HasPrefix(trimmed, "Events") { + for _, m := range tick.FindAllStringSubmatch(trimmed, -1) { + eventsInDoc[m[1]] = true + } + continue + } + if table == "" || strings.HasPrefix(trimmed, "|---") { + continue + } + cells := strings.Split(trimmed, "|") + if len(cells) < 2 { + continue + } + for _, m := range tick.FindAllStringSubmatch(cells[1]+" ", -1) { + if table == "method" { + methodsInDoc[m[1]] = true + } else { + eventsInDoc[m[1]] = true + } + } + } + return methodsInDoc, eventsInDoc +} + +func missing(want []string, have map[string]bool) []string { + var out []string + for _, name := range want { + if !have[name] { + out = append(out, name) + } + } + return out +} + +// THE WRITTEN PROTOCOL NAMES EXACTLY THE METHODS AND EVENTS THE SERVER HAS. +// A method or event the server serves and the document does not name is one +// the app cannot know about; one the document names and the server does not +// serve is one the app would call into a refusal. +func TestTheProtocolDocumentNamesEveryMethodAndEvent(t *testing.T) { + inDoc, eventsInDoc := documented(t) + if gone := missing(Methods(), inDoc); len(gone) > 0 { + t.Errorf("methods the server serves and PROTOCOL.md does not list: %v", gone) + } + served := map[string]bool{} + for _, name := range Methods() { + served[name] = true + } + var extra []string + for name := range inDoc { + if !served[name] { + extra = append(extra, name) + } + } + sort.Strings(extra) + if len(extra) > 0 { + t.Errorf("methods PROTOCOL.md lists and the server does not serve: %v", extra) + } + if gone := missing(Events(), eventsInDoc); len(gone) > 0 { + t.Errorf("events the server emits and PROTOCOL.md does not list: %v", gone) + } + known := map[string]bool{} + for _, name := range Events() { + known[name] = true + } + var unknown []string + for name := range eventsInDoc { + if !known[name] && !served[name] { + unknown = append(unknown, name) + } + } + sort.Strings(unknown) + if len(unknown) > 0 { + t.Errorf("events PROTOCOL.md lists and the server does not emit: %v", unknown) + } +} + +// EVERY EVENT CONSTANT HAS A SHAPE, and every shape is a struct the schema +// can describe. +func TestEveryEventHasAShape(t *testing.T) { + for name, shape := range eventShapes { + if shape == nil || shape.Kind() != reflect.Struct { + t.Errorf("%s has no struct shape", name) + } + } + for _, m := range methods { + if m.params == nil || m.result == nil || m.call == nil { + t.Fatalf("a method was built without its types") + } + } +} + +// THE SCHEMA IS ONE DOCUMENT THAT NAMES EVERY METHOD AND EVENT, and a field +// the app has broken on before says what it is: dock_badge is a boolean. +func TestTheSchemaIsGeneratedForTheWholeWire(t *testing.T) { + out, err := SchemaJSON() + if err != nil { + t.Fatal(err) + } + var doc struct { + Methods map[string]json.RawMessage `json:"methods"` + Events map[string]json.RawMessage `json:"events"` + Defs map[string]struct { + Properties map[string]map[string]any `json:"properties"` + } `json:"$defs"` + } + if err := json.Unmarshal(out, &doc); err != nil { + t.Fatal(err) + } + if len(doc.Methods) != len(Methods()) || len(doc.Events) != len(Events()) { + t.Fatalf("schema has %d methods and %d events, want %d and %d", len(doc.Methods), len(doc.Events), len(Methods()), len(Events())) + } + if got := doc.Defs["Settings"].Properties["dock_badge"]["type"]; got != "boolean" { + t.Fatalf("settings.dock_badge is %v in the schema", got) + } + if got := doc.Defs["Settings"].Properties["appearance"]["enum"]; got == nil { + t.Fatalf("settings.appearance names no words") + } +} diff --git a/internal/appwire/convo.go b/internal/appwire/convo.go index 968bfa3a87..a901f6c1f5 100644 --- a/internal/appwire/convo.go +++ b/internal/appwire/convo.go @@ -1,7 +1,6 @@ package appwire import ( - "encoding/json" "strings" "time" ) @@ -218,11 +217,7 @@ type sayTo struct { ClientID string `json:"client_id"` } -func decodeSay(raw json.RawMessage, name string) (sayTo, error) { - var p sayTo - if err := decode(raw, &p); err != nil { - return p, err - } +func checkSay(p sayTo, name string) (sayTo, error) { if strings.TrimSpace(p.ID) == "" || strings.TrimSpace(p.Text) == "" { return p, Errorf(CodeBadParams, "%s needs an id and some text", name) } @@ -237,11 +232,7 @@ type queueAt struct { To *int `json:"to"` } -func decodeQueueAt(raw json.RawMessage, name string) (queueAt, error) { - var p queueAt - if err := decode(raw, &p); err != nil { - return p, err - } +func checkQueueAt(p queueAt, name string) (queueAt, error) { if strings.TrimSpace(p.Agent) == "" || strings.TrimSpace(p.ID) == "" { return p, Errorf(CodeBadParams, "%s needs an agent and an id", name) } @@ -250,49 +241,49 @@ func decodeQueueAt(raw json.RawMessage, name string) (queueAt, error) { // talkTask is one task verb that takes only an id, served by the Worker. func talkTaskVerb(action string) method { - return func(e Engine, raw json.RawMessage) (any, error) { + return typed(func(e Engine, p IDParams) (*Empty, error) { w, err := worker(e, "tasks."+action) if err != nil { return nil, err } - id, err := decodeID(raw, "id") + id, err := required("id", p.ID) if err != nil { return nil, err } - return struct{}{}, w.ActOnTask(id, action, "", "") - } + return done(w.ActOnTask(id, action, "", "")) + }) } var talkMethods = map[string]method{ - "agents.steer": func(e Engine, raw json.RawMessage) (any, error) { + "agents.steer": typed(func(e Engine, p sayTo) (*Empty, error) { t, err := talker(e, "agents.steer") if err != nil { return nil, err } - p, err := decodeSay(raw, "agents.steer") + p, err = checkSay(p, "agents.steer") if err != nil { return nil, err } - return struct{}{}, t.Steer(p.ID, p.Text, p.ClientID) - }, - "agents.queue": func(e Engine, raw json.RawMessage) (any, error) { + return done(t.Steer(p.ID, p.Text, p.ClientID)) + }), + "agents.queue": typed(func(e Engine, p sayTo) (*queueResult, error) { t, err := talker(e, "agents.queue") if err != nil { return nil, err } - p, err := decodeSay(raw, "agents.queue") + p, err = checkSay(p, "agents.queue") if err != nil { return nil, err } queued, err := t.Queue(p.ID, p.Text, p.ClientID) - return queueResult{nonNil(queued)}, err - }, - "queue.edit": func(e Engine, raw json.RawMessage) (any, error) { + return &queueResult{nonNil(queued)}, err + }), + "queue.edit": typed(func(e Engine, p queueAt) (*queueResult, error) { t, err := talker(e, "queue.edit") if err != nil { return nil, err } - p, err := decodeQueueAt(raw, "queue.edit") + p, err = checkQueueAt(p, "queue.edit") if err != nil { return nil, err } @@ -300,26 +291,26 @@ var talkMethods = map[string]method{ return nil, Errorf(CodeBadParams, "queue.edit needs the new text; queue.remove takes a message out") } queued, err := t.EditQueued(p.Agent, p.ID, p.Text) - return queueResult{nonNil(queued)}, err - }, - "queue.remove": func(e Engine, raw json.RawMessage) (any, error) { + return &queueResult{nonNil(queued)}, err + }), + "queue.remove": typed(func(e Engine, p queueAt) (*queueResult, error) { t, err := talker(e, "queue.remove") if err != nil { return nil, err } - p, err := decodeQueueAt(raw, "queue.remove") + p, err = checkQueueAt(p, "queue.remove") if err != nil { return nil, err } queued, err := t.RemoveQueued(p.Agent, p.ID) - return queueResult{nonNil(queued)}, err - }, - "queue.move": func(e Engine, raw json.RawMessage) (any, error) { + return &queueResult{nonNil(queued)}, err + }), + "queue.move": typed(func(e Engine, p queueAt) (*queueResult, error) { t, err := talker(e, "queue.move") if err != nil { return nil, err } - p, err := decodeQueueAt(raw, "queue.move") + p, err = checkQueueAt(p, "queue.move") if err != nil { return nil, err } @@ -327,38 +318,30 @@ var talkMethods = map[string]method{ return nil, Errorf(CodeBadParams, "queue.move needs to, a place counted from zero") } queued, err := t.MoveQueued(p.Agent, p.ID, *p.To) - return queueResult{nonNil(queued)}, err - }, - "asks.hold": func(e Engine, raw json.RawMessage) (any, error) { + return &queueResult{nonNil(queued)}, err + }), + "asks.hold": typed(func(e Engine, p struct { + Agent string `json:"agent"` + ID string `json:"id"` + }) (*Empty, error) { t, err := talker(e, "asks.hold") if err != nil { return nil, err } - var p struct { - Agent string `json:"agent"` - ID string `json:"id"` - } - if err := decode(raw, &p); err != nil { - return nil, err - } if strings.TrimSpace(p.Agent) == "" || strings.TrimSpace(p.ID) == "" { return nil, Errorf(CodeBadParams, "asks.hold needs an agent and an id") } - return struct{}{}, t.HoldAsk(p.Agent, p.ID) - }, - "files.find": func(e Engine, raw json.RawMessage) (any, error) { + return done(t.HoldAsk(p.Agent, p.ID)) + }), + "files.find": typed(func(e Engine, p struct { + Agent string `json:"agent"` + Query string `json:"query"` + Limit int `json:"limit"` + }) (*GroupsResult, error) { t, err := talker(e, "files.find") if err != nil { return nil, err } - var p struct { - Agent string `json:"agent"` - Query string `json:"query"` - Limit int `json:"limit"` - } - if err := decode(raw, &p); err != nil { - return nil, err - } if strings.TrimSpace(p.Agent) == "" || p.Limit < 0 { return nil, Errorf(CodeBadParams, "files.find needs an agent and a limit of zero or more") } @@ -369,45 +352,33 @@ var talkMethods = map[string]method{ for i := range groups { groups[i].Files = nonNil(groups[i].Files) } - return struct { - Groups []FileGroup `json:"groups"` - }{nonNil(groups)}, nil - }, - "files.stat": func(e Engine, raw json.RawMessage) (any, error) { + return &GroupsResult{nonNil(groups)}, nil + }), + "files.stat": typed(func(e Engine, p struct { + Agent string `json:"agent"` + Paths []string `json:"paths"` + }) (*FilesResult, error) { t, err := talker(e, "files.stat") if err != nil { return nil, err } - var p struct { - Agent string `json:"agent"` - Paths []string `json:"paths"` - } - if err := decode(raw, &p); err != nil { - return nil, err - } if strings.TrimSpace(p.Agent) == "" || len(p.Paths) == 0 { return nil, Errorf(CodeBadParams, "files.stat needs an agent and some paths") } files, err := t.StatFiles(p.Agent, p.Paths) - return struct { - Files []FileStat `json:"files"` - }{nonNil(files)}, err - }, - "attachments.add": func(e Engine, raw json.RawMessage) (any, error) { + return &FilesResult{nonNil(files)}, err + }), + "attachments.add": typed(func(e Engine, p struct { + Agent string `json:"agent"` + Kind string `json:"kind"` + Path string `json:"path"` + Text string `json:"text"` + URL string `json:"url"` + }) (*AttachmentResult, error) { t, err := talker(e, "attachments.add") if err != nil { return nil, err } - var p struct { - Agent string `json:"agent"` - Kind string `json:"kind"` - Path string `json:"path"` - Text string `json:"text"` - URL string `json:"url"` - } - if err := decode(raw, &p); err != nil { - return nil, err - } switch { case strings.TrimSpace(p.Agent) == "": return nil, Errorf(CodeBadParams, "attachments.add needs an agent") @@ -422,53 +393,43 @@ var talkMethods = map[string]method{ if err != nil { return nil, err } - return struct { - Attachment Attachment `json:"attachment"` - }{attachment}, nil - }, + return &AttachmentResult{attachment}, nil + }), "tasks.pause": talkTaskVerb("pause"), "tasks.resume": talkTaskVerb("resume"), - "tasks.retry": func(e Engine, raw json.RawMessage) (any, error) { + "tasks.retry": typed(func(e Engine, p IDParams) (*Empty, error) { t, err := talker(e, "tasks.retry") if err != nil { return nil, err } - id, err := decodeID(raw, "id") + id, err := required("id", p.ID) if err != nil { return nil, err } - return struct{}{}, t.RetryTask(id) - }, - "tasks.brief": func(e Engine, raw json.RawMessage) (any, error) { + return done(t.RetryTask(id)) + }), + "tasks.brief": typed(func(e Engine, p struct { + ID string `json:"id"` + Text string `json:"text"` + }) (*Empty, error) { w, err := worker(e, "tasks.brief") if err != nil { return nil, err } - var p struct { - ID string `json:"id"` - Text string `json:"text"` - } - if err := decode(raw, &p); err != nil { - return nil, err - } if strings.TrimSpace(p.ID) == "" || strings.TrimSpace(p.Text) == "" { return nil, Errorf(CodeBadParams, "tasks.brief needs an id and the new brief") } - return struct{}{}, w.ActOnTask(p.ID, "brief", "", p.Text) - }, - "tasks.entries": func(e Engine, raw json.RawMessage) (any, error) { + return done(w.ActOnTask(p.ID, "brief", "", p.Text)) + }), + "tasks.entries": typed(func(e Engine, p struct { + ID string `json:"id"` + Before string `json:"before"` + Limit int `json:"limit"` + }) (*EntriesMoreResult, error) { t, err := talker(e, "tasks.entries") if err != nil { return nil, err } - var p struct { - ID string `json:"id"` - Before string `json:"before"` - Limit int `json:"limit"` - } - if err := decode(raw, &p); err != nil { - return nil, err - } if strings.TrimSpace(p.ID) == "" || p.Limit < 0 { return nil, Errorf(CodeBadParams, "tasks.entries needs an id") } @@ -479,14 +440,32 @@ var talkMethods = map[string]method{ if err != nil { return nil, err } - return struct { - Entries []Entry `json:"entries"` - More bool `json:"more"` - }{nonNil(entries), more}, nil - }, + return &EntriesMoreResult{nonNil(entries), more}, nil + }), } // ThinkingSeconds is how long a thought lasted, in whole seconds, at least one. func ThinkingSeconds(d time.Duration) int { return max(1, int(d.Round(time.Second)/time.Second)) } + +// The answers of this file's methods. +type ( + GroupsResult struct { + Groups []FileGroup `json:"groups"` + } + AttachmentResult struct { + Attachment Attachment `json:"attachment"` + } + EntriesMoreResult struct { + Entries []Entry `json:"entries"` + More bool `json:"more"` + } +) + +// The answers of this file's methods. +type ( + FilesResult struct { + Files []FileStat `json:"files"` + } +) diff --git a/internal/appwire/crews.go b/internal/appwire/crews.go index 1340c42be0..9e50fe91e8 100644 --- a/internal/appwire/crews.go +++ b/internal/appwire/crews.go @@ -1,7 +1,6 @@ package appwire import ( - "encoding/json" "strings" "time" ) @@ -32,6 +31,8 @@ const ( const ( SubkindCrewDecision = "crew_decision" SubkindCrewProposal = "crew_proposal" + // SubkindOrderRun is a standing order's run asking before it goes. + SubkindOrderRun = "order_run" ) // The answers a crew proposal offers. @@ -408,20 +409,14 @@ func crewer(e Engine, name string) (Crewer, error) { } // crewCall decodes a method's params into P and answers what call answers. -func crewCall[P any](name string, call func(Crewer, P) (any, error)) method { - return func(e Engine, raw json.RawMessage) (any, error) { +func crewCall[P, R any](name string, call func(Crewer, P) (*R, error)) method { + return typed(func(e Engine, p P) (*R, error) { c, err := crewer(e, name) if err != nil { return nil, err } - var p P - if len(raw) > 0 && string(raw) != "null" { - if err := decode(raw, &p); err != nil { - return nil, err - } - } return call(c, p) - } + }) } type crewID struct { @@ -439,26 +434,29 @@ type crewResult struct { Crew Crew `json:"crew"` } -func crewOf(c Crew, err error) (any, error) { +func crewOf(c Crew, err error) (*crewResult, error) { if err != nil { return nil, err } c.Members = nonNil(c.Members) - return crewResult{c}, nil + return &crewResult{c}, nil +} + +// MemberResult is a member verb's answer. +type MemberResult struct { + Member CrewMember `json:"member"` } -func memberOf(m CrewMember, err error) (any, error) { +func memberOf(m CrewMember, err error) (*MemberResult, error) { if err != nil { return nil, err } - return struct { - Member CrewMember `json:"member"` - }{m}, nil + return &MemberResult{m}, nil } // memberVerb is one member verb. func memberVerb(verb string) method { - return crewCall("members."+verb, func(c Crewer, p MemberAsk) (any, error) { + return crewCall("members."+verb, func(c Crewer, p MemberAsk) (*MemberResult, error) { if strings.TrimSpace(p.Crew) == "" { return nil, Errorf(CodeBadParams, "members.%s needs a crew", verb) } @@ -467,22 +465,22 @@ func memberVerb(verb string) method { } var crewMethods = map[string]method{ - "crews.get": crewCall("crews.get", func(c Crewer, p crewID) (any, error) { + "crews.get": crewCall("crews.get", func(c Crewer, p crewID) (*crewResult, error) { if err := needID("crews.get", p.ID); err != nil { return nil, err } return crewOf(c.CrewGet(p.ID)) }), - "crews.create": crewCall("crews.create", func(c Crewer, p CrewCreate) (any, error) { + "crews.create": crewCall("crews.create", func(c Crewer, p CrewCreate) (*crewResult, error) { return crewOf(c.CrewCreate(p)) }), - "crews.update": crewCall("crews.update", func(c Crewer, p CrewUpdate) (any, error) { + "crews.update": crewCall("crews.update", func(c Crewer, p CrewUpdate) (*crewResult, error) { if err := needID("crews.update", p.ID); err != nil { return nil, err } return crewOf(c.CrewUpdate(p)) }), - "crews.message": crewCall("crews.message", func(c Crewer, p CrewMessage) (any, error) { + "crews.message": crewCall("crews.message", func(c Crewer, p CrewMessage) (*EntryResult, error) { if strings.TrimSpace(p.Crew) == "" || strings.TrimSpace(p.Text) == "" { return nil, Errorf(CodeBadParams, "crews.message needs a crew and some text") } @@ -490,15 +488,13 @@ var crewMethods = map[string]method{ if err != nil { return nil, err } - return struct { - Entry Entry `json:"entry"` - }{entry}, nil + return &EntryResult{entry}, nil }), "crews.log": crewCall("crews.log", func(c Crewer, p struct { ID string `json:"id"` After string `json:"after"` Limit int `json:"limit"` - }) (any, error) { + }) (*LinesCursorResult, error) { if err := needID("crews.log", p.ID); err != nil { return nil, err } @@ -506,29 +502,24 @@ var crewMethods = map[string]method{ if err != nil { return nil, err } - return struct { - Lines []LogLine `json:"lines"` - Cursor string `json:"cursor"` - }{nonNil(lines), cursor}, nil + return &LinesCursorResult{nonNil(lines), cursor}, nil }), "crews.decisions": crewCall("crews.decisions", func(c Crewer, p struct { ID string `json:"id"` All bool `json:"all"` - }) (any, error) { + }) (*DecisionsResult, error) { decisions, err := c.CrewDecisions(p.ID, p.All) if err != nil { return nil, err } - return struct { - Decisions []Decision `json:"decisions"` - }{nonNil(decisions)}, nil + return &DecisionsResult{nonNil(decisions)}, nil }), "crews.decide": crewCall("crews.decide", func(c Crewer, p struct { Packet string `json:"packet"` Option string `json:"option"` Text string `json:"text"` Reason string `json:"reason"` - }) (any, error) { + }) (*DecisionResult, error) { if strings.TrimSpace(p.Packet) == "" || (strings.TrimSpace(p.Option) == "" && strings.TrimSpace(p.Text) == "") { return nil, Errorf(CodeBadParams, "crews.decide needs a packet and an option or some words") } @@ -536,14 +527,12 @@ var crewMethods = map[string]method{ if err != nil { return nil, err } - return struct { - Decision Decision `json:"decision"` - }{d}, nil + return &DecisionResult{d}, nil }), "crews.manager": crewCall("crews.manager", func(c Crewer, p struct { ID string `json:"id"` Agent string `json:"agent"` - }) (any, error) { + }) (*crewResult, error) { if err := needID("crews.manager", p.ID); err != nil { return nil, err } @@ -552,34 +541,34 @@ var crewMethods = map[string]method{ "crews.move": crewCall("crews.move", func(c Crewer, p struct { IDs []string `json:"ids"` Parent string `json:"parent"` - }) (any, error) { + }) (*Empty, error) { if len(p.IDs) == 0 { return nil, Errorf(CodeBadParams, "crews.move needs ids") } - return struct{}{}, c.CrewMove(p.IDs, p.Parent) + return done(c.CrewMove(p.IDs, p.Parent)) }), "crews.wrap_up": crewCall("crews.wrap_up", func(c Crewer, p struct { ID string `json:"id"` Text string `json:"text"` - }) (any, error) { + }) (*Empty, error) { if err := needID("crews.wrap_up", p.ID); err != nil { return nil, err } - return struct{}{}, c.CrewWrapUp(p.ID, p.Text) + return done(c.CrewWrapUp(p.ID, p.Text)) }), - "crews.close": crewCall("crews.close", func(c Crewer, p crewID) (any, error) { + "crews.close": crewCall("crews.close", func(c Crewer, p crewID) (*Empty, error) { if err := needID("crews.close", p.ID); err != nil { return nil, err } - return struct{}{}, c.CrewClose(p.ID) + return done(c.CrewClose(p.ID)) }), - "crews.reopen": crewCall("crews.reopen", func(c Crewer, p crewID) (any, error) { + "crews.reopen": crewCall("crews.reopen", func(c Crewer, p crewID) (*crewResult, error) { if err := needID("crews.reopen", p.ID); err != nil { return nil, err } return crewOf(c.CrewReopen(p.ID)) }), - "crews.delete": crewCall("crews.delete", func(c Crewer, p crewID) (any, error) { + "crews.delete": crewCall("crews.delete", func(c Crewer, p crewID) (*IDsResult, error) { if err := needID("crews.delete", p.ID); err != nil { return nil, err } @@ -587,23 +576,19 @@ var crewMethods = map[string]method{ if err != nil { return nil, err } - return struct { - IDs []string `json:"ids"` - }{nonNil(ids)}, nil + return &IDsResult{nonNil(ids)}, nil }), - "crews.defaults": crewCall("crews.defaults", func(c Crewer, _ struct{}) (any, error) { + "crews.defaults": crewCall("crews.defaults", func(c Crewer, _ struct{}) (*DefaultsResult, error) { d, err := c.CrewDefaults() if err != nil { return nil, err } - return struct { - Defaults CrewDefaults `json:"defaults"` - }{d}, nil + return &DefaultsResult{d}, nil }), "crews.set_default": crewCall("crews.set_default", func(c Crewer, p struct { Key string `json:"key"` Value string `json:"value"` - }) (any, error) { + }) (*DefaultsResult, error) { if strings.TrimSpace(p.Key) == "" { return nil, Errorf(CodeBadParams, "crews.set_default needs a key") } @@ -611,11 +596,9 @@ var crewMethods = map[string]method{ if err != nil { return nil, err } - return struct { - Defaults CrewDefaults `json:"defaults"` - }{d}, nil + return &DefaultsResult{d}, nil }), - "crews.items": crewCall("crews.items", func(c Crewer, p crewID) (any, error) { + "crews.items": crewCall("crews.items", func(c Crewer, p crewID) (*ItemsResult, error) { if err := needID("crews.items", p.ID); err != nil { return nil, err } @@ -623,16 +606,14 @@ var crewMethods = map[string]method{ if err != nil { return nil, err } - return struct { - Items []CrewItem `json:"items"` - }{nonNil(items)}, nil + return &ItemsResult{nonNil(items)}, nil }), "crews.link": crewCall("crews.link", func(c Crewer, p struct { ID string `json:"id"` Kind string `json:"kind"` Ref string `json:"ref"` Title string `json:"title"` - }) (any, error) { + }) (*ItemResult, error) { if strings.TrimSpace(p.ID) == "" || strings.TrimSpace(p.Ref) == "" { return nil, Errorf(CodeBadParams, "crews.link needs an id and a ref") } @@ -645,26 +626,24 @@ var crewMethods = map[string]method{ if err != nil { return nil, err } - return struct { - Item CrewItem `json:"item"` - }{item}, nil + return &ItemResult{item}, nil }), "crews.unlink": crewCall("crews.unlink", func(c Crewer, p struct { ID string `json:"id"` Item string `json:"item"` - }) (any, error) { + }) (*Empty, error) { if strings.TrimSpace(p.ID) == "" || strings.TrimSpace(p.Item) == "" { return nil, Errorf(CodeBadParams, "crews.unlink needs an id and an item") } - return struct{}{}, c.CrewUnlink(p.ID, p.Item) + return done(c.CrewUnlink(p.ID, p.Item)) }), "crews.propose": crewCall("crews.propose", func(c Crewer, p struct { Agent string `json:"agent"` - }) (any, error) { + }) (*Empty, error) { if strings.TrimSpace(p.Agent) == "" { return nil, Errorf(CodeBadParams, "crews.propose needs an agent") } - return struct{}{}, c.CrewPropose(p.Agent) + return done(c.CrewPropose(p.Agent)) }), "members.add": memberVerb("add"), "members.start": memberVerb("start"), @@ -677,21 +656,19 @@ var crewMethods = map[string]method{ "members.cap": memberVerb("cap"), "members.role": memberVerb("role"), "members.move": memberVerb("move"), - "roles.list": crewCall("roles.list", func(c Crewer, _ struct{}) (any, error) { + "roles.list": crewCall("roles.list", func(c Crewer, _ struct{}) (*RolesResult, error) { roles, err := c.Roles() if err != nil { return nil, err } - return struct { - Roles []Role `json:"roles"` - }{nonNil(roles)}, nil + return &RolesResult{nonNil(roles)}, nil }), "roles.update": crewCall("roles.update", func(c Crewer, p struct { ID string `json:"id"` Name *string `json:"name"` Instructions *string `json:"instructions"` Model *string `json:"model"` - }) (any, error) { + }) (*RoleResult, error) { if err := needID("roles.update", p.ID); err != nil { return nil, err } @@ -699,8 +676,41 @@ var crewMethods = map[string]method{ if err != nil { return nil, err } - return struct { - Role Role `json:"role"` - }{role}, nil + return &RoleResult{role}, nil }), } + +// The answers of this file's methods. +type ( + EntryResult struct { + Entry Entry `json:"entry"` + } + LinesCursorResult struct { + Lines []LogLine `json:"lines"` + Cursor string `json:"cursor"` + } + DecisionsResult struct { + Decisions []Decision `json:"decisions"` + } + DecisionResult struct { + Decision Decision `json:"decision"` + } + IDsResult struct { + IDs []string `json:"ids"` + } + DefaultsResult struct { + Defaults CrewDefaults `json:"defaults"` + } + ItemsResult struct { + Items []CrewItem `json:"items"` + } + ItemResult struct { + Item CrewItem `json:"item"` + } + RolesResult struct { + Roles []Role `json:"roles"` + } + RoleResult struct { + Role Role `json:"role"` + } +) diff --git a/internal/appwire/events.go b/internal/appwire/events.go new file mode 100644 index 0000000000..63e9c61499 --- /dev/null +++ b/internal/appwire/events.go @@ -0,0 +1,47 @@ +package appwire + +import "reflect" + +// eventShapes is the type each event's data is. An event emitted with any +// other type is a contract break; the test recorders check every event they +// see against this table, and the schema is generated from it. +var eventShapes = map[string]reflect.Type{ + EventHello: reflect.TypeFor[Hello](), + EventAgentChanged: reflect.TypeFor[AgentChanged](), + EventTurnStarted: reflect.TypeFor[TurnStarted](), + EventTurnDelta: reflect.TypeFor[TurnDelta](), + EventTurnTool: reflect.TypeFor[TurnTool](), + EventTurnDone: reflect.TypeFor[TurnDone](), + EventTurnThinking: reflect.TypeFor[TurnThinking](), + EventTurnThinkingDone: reflect.TypeFor[TurnThinkingDone](), + EventTurnRetrying: reflect.TypeFor[TurnRetrying](), + EventTurnLong: reflect.TypeFor[TurnLong](), + EventAskOpened: reflect.TypeFor[AskOpened](), + EventAskClosed: reflect.TypeFor[AskClosed](), + EventEntryAdded: reflect.TypeFor[EntryAdded](), + EventSpendChanged: reflect.TypeFor[SpendChanged](), + EventTaskChanged: reflect.TypeFor[TaskChanged](), + EventQueueChanged: reflect.TypeFor[QueueChanged](), + EventNoteChanged: reflect.TypeFor[NoteChanged](), + EventQuickChanged: reflect.TypeFor[QuickChangedEvent](), + EventProcessChanged: reflect.TypeFor[ProcessChanged](), + EventProcessOutput: reflect.TypeFor[ProcessOutput](), + EventToolOutput: reflect.TypeFor[ToolOutputEvent](), + EventCrewChanged: reflect.TypeFor[CrewChanged](), + EventCrewRemoved: reflect.TypeFor[CrewRemoved](), + EventCrewLog: reflect.TypeFor[CrewLogEvent](), + EventMemberChanged: reflect.TypeFor[MemberChanged](), + EventDecisionOpened: reflect.TypeFor[DecisionEvent](), + EventDecisionChanged: reflect.TypeFor[DecisionEvent](), + EventInboxAdded: reflect.TypeFor[InboxAddedEvent](), + EventInboxResolved: reflect.TypeFor[InboxResolvedEvent](), + EventInboxCounts: reflect.TypeFor[InboxCounts](), + EventOrderChanged: reflect.TypeFor[OrderChanged](), + EventOrderRemoved: reflect.TypeFor[OrderRemoved](), + EventOrderFired: reflect.TypeFor[OrderFired](), + EventOrderMissed: reflect.TypeFor[OrderChanged](), + EventOrderAsking: reflect.TypeFor[OrderFired](), + EventSettingsChanged: reflect.TypeFor[SettingsChanged](), + EventProviderChanged: reflect.TypeFor[ProviderChanged](), + EventLog: reflect.TypeFor[Log](), +} diff --git a/internal/appwire/inbox.go b/internal/appwire/inbox.go index fef00171cf..05beb5b537 100644 --- a/internal/appwire/inbox.go +++ b/internal/appwire/inbox.go @@ -1,7 +1,6 @@ package appwire import ( - "encoding/json" "strings" "time" ) @@ -101,32 +100,28 @@ func inboxer(e Engine, name string) (Inboxer, error) { // inboxVerb is one Inbox verb that takes only an id. func inboxVerb(name string, act func(Inboxer, string) error) method { - return func(e Engine, raw json.RawMessage) (any, error) { + return typed(func(e Engine, p IDParams) (*Empty, error) { i, err := inboxer(e, name) if err != nil { return nil, err } - id, err := decodeID(raw, "id") + id, err := required("id", p.ID) if err != nil { return nil, err } - return struct{}{}, act(i, id) - } + return done(act(i, id)) + }) } var inboxMethods = map[string]method{ - "inbox.list": func(e Engine, raw json.RawMessage) (any, error) { + "inbox.list": typed(func(e Engine, p struct { + List string `json:"list"` + Query string `json:"query"` + }) (*ItemsCountsResult, error) { i, err := inboxer(e, "inbox.list") if err != nil { return nil, err } - var p struct { - List string `json:"list"` - Query string `json:"query"` - } - if err := decode(raw, &p); err != nil { - return nil, err - } if p.List == "" { p.List = InboxWaiting } @@ -141,25 +136,18 @@ var inboxMethods = map[string]method{ items[k].Context = nonNil(items[k].Context) items[k].Where.Path = nonNil(items[k].Where.Path) } - return struct { - Items []InboxItem `json:"items"` - Counts InboxCounts `json:"counts"` - }{nonNil(items), counts}, nil - }, - "inbox.answer": func(e Engine, raw json.RawMessage) (any, error) { + return &ItemsCountsResult{nonNil(items), counts}, nil + }), + "inbox.answer": typed(func(e Engine, p struct { + ID string `json:"id"` + Option string `json:"option"` + Text string `json:"text"` + Why string `json:"why"` + }) (*UndoUntilResult, error) { i, err := inboxer(e, "inbox.answer") if err != nil { return nil, err } - var p struct { - ID string `json:"id"` - Option string `json:"option"` - Text string `json:"text"` - Why string `json:"why"` - } - if err := decode(raw, &p); err != nil { - return nil, err - } if strings.TrimSpace(p.ID) == "" || (strings.TrimSpace(p.Option) == "" && strings.TrimSpace(p.Text) == "") { return nil, Errorf(CodeBadParams, "inbox.answer needs an id and an option, some text, or both") } @@ -167,19 +155,17 @@ var inboxMethods = map[string]method{ if err != nil { return nil, err } - return struct { - UndoUntil time.Time `json:"undo_until"` - }{until}, nil - }, + return &UndoUntilResult{until}, nil + }), "inbox.undo": inboxVerb("inbox.undo", func(i Inboxer, id string) error { return i.UndoInbox(id) }), "inbox.later": inboxVerb("inbox.later", func(i Inboxer, id string) error { return i.LaterInbox(id, true) }), "inbox.back": inboxVerb("inbox.back", func(i Inboxer, id string) error { return i.LaterInbox(id, false) }), - "inbox.open": func(e Engine, raw json.RawMessage) (any, error) { + "inbox.open": typed(func(e Engine, p IDParams) (*AgentTaskResult, error) { i, err := inboxer(e, "inbox.open") if err != nil { return nil, err } - id, err := decodeID(raw, "id") + id, err := required("id", p.ID) if err != nil { return nil, err } @@ -187,9 +173,25 @@ var inboxMethods = map[string]method{ if err != nil { return nil, err } - return struct { - Agent string `json:"agent"` - Task string `json:"task,omitempty"` - }{agent, task}, nil - }, + return &AgentTaskResult{agent, task}, nil + }), } + +// The answers of this file's methods. +type ( + ItemsCountsResult struct { + Items []InboxItem `json:"items"` + Counts InboxCounts `json:"counts"` + } + AgentTaskResult struct { + Agent string `json:"agent"` + Task string `json:"task,omitempty"` + } +) + +// The answers of this file's methods. +type ( + UndoUntilResult struct { + UndoUntil time.Time `json:"undo_until"` + } +) diff --git a/internal/appwire/orders.go b/internal/appwire/orders.go index e12cc8f85d..bbe038f3c5 100644 --- a/internal/appwire/orders.go +++ b/internal/appwire/orders.go @@ -1,7 +1,6 @@ package appwire import ( - "encoding/json" "strings" "time" ) @@ -79,7 +78,7 @@ type Order struct { ID string `json:"id"` Agent string `json:"agent,omitempty"` Crew string `json:"crew,omitempty"` - Kind string `json:"kind"` + Kind string `json:"kind" enum:"order,reminder,watch"` Title string `json:"title"` Workspace string `json:"workspace,omitempty"` // When is the schedule as a person says it, WhenExact the same exactly. @@ -88,15 +87,15 @@ type Order struct { // Does is the brief, or the reminder's line; Wont what it must never do. Does string `json:"does"` Wont string `json:"wont,omitempty"` - AnswersIn string `json:"answers_in"` + AnswersIn string `json:"answers_in" enum:"chat,crew,everywhere,not_here"` // Trust is the ladder's rung, ApprovedRuns how many go's it has had, and // TrustAfter how many make it trusted. - Trust string `json:"trust"` + Trust string `json:"trust" enum:"asks_first,earning,trusted"` ApprovedRuns int `json:"approved_runs"` TrustAfter int `json:"trust_after"` BudgetPerRunUSD float64 `json:"budget_per_run_usd"` TimePerRunS int `json:"time_per_run_s"` - State string `json:"state"` + State string `json:"state" enum:"proposed,active,paused,stopped,done"` FiresAt *time.Time `json:"fires_at,omitempty"` LastRun *OrderRun `json:"last_run,omitempty"` // Asking is the run waiting for the person's go, when one is. @@ -114,7 +113,7 @@ type OrderRun struct { At *time.Time `json:"at,omitempty"` // Result is set once it is over; Late says it started late, after a slot // missed while the Mac was asleep or codeaf was closed. - Result string `json:"result,omitempty"` + Result string `json:"result,omitempty" enum:"ok,quiet,incomplete,skipped,not_right"` Late bool `json:"late,omitempty"` Line string `json:"line,omitempty"` USD float64 `json:"usd,omitempty"` @@ -171,20 +170,14 @@ func init() { } // orderCall decodes a method's params into P and answers what call answers. -func orderCall[P any](name string, call func(Orderer, P) (any, error)) method { - return func(e Engine, raw json.RawMessage) (any, error) { +func orderCall[P, R any](name string, call func(Orderer, P) (*R, error)) method { + return typed(func(e Engine, p P) (*R, error) { o, ok := e.(Orderer) if !ok { return nil, Errorf(CodeUnknownMethod, "this engine does not serve %s", name) } - var p P - if len(raw) > 0 && string(raw) != "null" { - if err := decode(raw, &p); err != nil { - return nil, err - } - } return call(o, p) - } + }) } type orderAt struct { @@ -192,18 +185,21 @@ type orderAt struct { Edits *OrderEdits `json:"edits,omitempty"` } -func orderResult(o Order, err error) (any, error) { +// OrderResult is the answer of a method that answers one order. +type OrderResult struct { + Order Order `json:"order"` +} + +func orderResult(o Order, err error) (*OrderResult, error) { if err != nil { return nil, err } - return struct { - Order Order `json:"order"` - }{o}, nil + return &OrderResult{o}, nil } // stateCall is pause, resume and stop, which differ only in the state asked for. func stateCall(name, state string) method { - return orderCall(name, func(o Orderer, p orderAt) (any, error) { + return orderCall(name, func(o Orderer, p orderAt) (*OrderResult, error) { if err := needID(name, p.ID); err != nil { return nil, err } @@ -214,19 +210,17 @@ func stateCall(name, state string) method { var orderMethods = map[string]method{ "orders.list": orderCall("orders.list", func(o Orderer, p struct { Agent string `json:"agent"` - }) (any, error) { + }) (*OrdersResult, error) { orders, err := o.Orders(strings.TrimSpace(p.Agent)) if err != nil { return nil, err } - return struct { - Orders []Order `json:"orders"` - }{nonNil(orders)}, nil + return &OrdersResult{nonNil(orders)}, nil }), "orders.runs": orderCall("orders.runs", func(o Orderer, p struct { ID string `json:"id"` Limit int `json:"limit"` - }) (any, error) { + }) (*RunsResult, error) { if err := needID("orders.runs", p.ID); err != nil { return nil, err } @@ -237,17 +231,15 @@ var orderMethods = map[string]method{ if err != nil { return nil, err } - return struct { - Runs []OrderRun `json:"runs"` - }{nonNil(runs)}, nil + return &RunsResult{nonNil(runs)}, nil }), - "orders.ratify": orderCall("orders.ratify", func(o Orderer, p orderAt) (any, error) { + "orders.ratify": orderCall("orders.ratify", func(o Orderer, p orderAt) (*OrderResult, error) { if err := needID("orders.ratify", p.ID); err != nil { return nil, err } return orderResult(o.RatifyOrder(p.ID, p.Edits)) }), - "orders.change": orderCall("orders.change", func(o Orderer, p orderAt) (any, error) { + "orders.change": orderCall("orders.change", func(o Orderer, p orderAt) (*OrderResult, error) { if err := needID("orders.change", p.ID); err != nil { return nil, err } @@ -256,11 +248,11 @@ var orderMethods = map[string]method{ } return orderResult(o.ChangeOrder(p.ID, *p.Edits)) }), - "orders.not_now": orderCall("orders.not_now", func(o Orderer, p orderAt) (any, error) { + "orders.not_now": orderCall("orders.not_now", func(o Orderer, p orderAt) (*Empty, error) { if err := needID("orders.not_now", p.ID); err != nil { return nil, err } - return struct{}{}, o.NotNowOrder(p.ID) + return done(o.NotNowOrder(p.ID)) }), "orders.pause": stateCall("orders.pause", OrderPaused), "orders.resume": stateCall("orders.resume", OrderActive), @@ -269,7 +261,7 @@ var orderMethods = map[string]method{ ID string `json:"id"` Run int64 `json:"run"` Verdict string `json:"verdict"` - }) (any, error) { + }) (*OrderResult, error) { if err := needID("orders.run", p.ID); err != nil { return nil, err } @@ -282,3 +274,13 @@ var orderMethods = map[string]method{ return nil, Errorf(CodeBadParams, "verdict is go, skip or not_right") }), } + +// The answers of this file's methods. +type ( + OrdersResult struct { + Orders []Order `json:"orders"` + } + RunsResult struct { + Runs []OrderRun `json:"runs"` + } +) diff --git a/internal/appwire/schema.go b/internal/appwire/schema.go new file mode 100644 index 0000000000..9baf061476 --- /dev/null +++ b/internal/appwire/schema.go @@ -0,0 +1,199 @@ +package appwire + +import ( + "encoding/json" + "reflect" + "strings" + "time" +) + +// THE SCHEMA IS GENERATED FROM THE TYPES THE SERVER USES, never written by +// hand: a method's params and result are the types [typed] was built with, an +// event's data is the type [eventShapes] names, and every named type they +// reach is one definition under $defs. A field's JSON name, whether it may be +// left out, and whether it may be null come from the same struct tags +// encoding/json reads; the words a string field may hold come from its enum +// tag, which is the only thing a Go type cannot say by itself. + +// Schema is the whole wire as one JSON Schema document. +func Schema() map[string]any { + g := &schemaGen{defs: map[string]any{}} + ms := map[string]any{} + for name, m := range methods { + g.params = true + params := g.of(m.params) + g.params = false + ms[name] = map[string]any{"params": params, "result": g.of(m.result)} + } + ms["shutdown"] = map[string]any{ + "params": map[string]any{"type": "object"}, + "result": map[string]any{"type": "object"}, + } + es := map[string]any{} + for name, t := range eventShapes { + es[name] = g.of(t) + } + return map[string]any{ + "$schema": "https://json-schema.org/draft/2020-12/schema", + "title": "codeaf app wire", + "contract": Contract, + "methods": ms, + "events": es, + "$defs": g.defs, + } +} + +// SchemaJSON is [Schema] encoded, indented, ending in a newline. +func SchemaJSON() ([]byte, error) { + out, err := json.MarshalIndent(Schema(), "", " ") + if err != nil { + return nil, err + } + return append(out, '\n'), nil +} + +type schemaGen struct { + defs map[string]any + // params is set while a method's params are described. A request may + // leave any field out (the method itself says which it needs), so no + // field of params is required, and a type reached from params is defined + // apart from the same type in an answer, under "in.". + params bool +} + +var ( + timeType = reflect.TypeFor[time.Time]() + rawType = reflect.TypeFor[json.RawMessage]() +) + +// of is the schema of one type. +func (g *schemaGen) of(t reflect.Type) map[string]any { + switch { + case t == timeType: + return map[string]any{"type": "string", "format": "date-time"} + case t == rawType: + return map[string]any{} + } + switch t.Kind() { + case reflect.Pointer: + return nullable(g.of(t.Elem())) + case reflect.Interface: + return map[string]any{} + case reflect.String: + return map[string]any{"type": "string"} + case reflect.Bool: + return map[string]any{"type": "boolean"} + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64, + reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return map[string]any{"type": "integer"} + case reflect.Float32, reflect.Float64: + return map[string]any{"type": "number"} + case reflect.Slice, reflect.Array: + if t.Elem().Kind() == reflect.Uint8 { + return map[string]any{"type": "string", "contentEncoding": "base64"} + } + return map[string]any{"type": "array", "items": g.of(t.Elem())} + case reflect.Map: + return map[string]any{"type": "object", "additionalProperties": g.of(t.Elem())} + case reflect.Struct: + if t.Name() == "" { + return g.object(t) + } + name := t.Name() + if g.params { + name = "in." + name + } + if _, seen := g.defs[name]; !seen { + g.defs[name] = nil + g.defs[name] = g.object(t) + } + return map[string]any{"$ref": "#/$defs/" + name} + } + return map[string]any{} +} + +// object is a struct's schema: its fields as encoding/json writes them. +func (g *schemaGen) object(t reflect.Type) map[string]any { + props := map[string]any{} + var need []string + g.fields(t, props, &need) + out := map[string]any{"type": "object", "properties": props, "additionalProperties": false} + if len(need) > 0 { + out["required"] = need + } + return out +} + +// fields adds t's fields, and an embedded struct's fields as its own, the way +// encoding/json flattens them. +func (g *schemaGen) fields(t reflect.Type, props map[string]any, need *[]string) { + for i := 0; i < t.NumField(); i++ { + f := t.Field(i) + tag := f.Tag.Get("json") + if tag == "-" || !f.IsExported() && !f.Anonymous { + continue + } + name, opts, _ := strings.Cut(tag, ",") + if f.Anonymous && name == "" { + inner := f.Type + if inner.Kind() == reflect.Pointer { + inner = inner.Elem() + } + if inner.Kind() == reflect.Struct { + g.fields(inner, props, need) + continue + } + } + if name == "" { + name = f.Name + } + schema := g.of(f.Type) + if words := f.Tag.Get("enum"); words != "" { + schema = withEnum(schema, strings.Split(words, ",")) + } + omit := strings.Contains(","+opts+",", ",omitempty,") || strings.Contains(","+opts+",", ",omitzero,") + // A slice or map that is nil, and is not left out, is written as null. + if !omit && (f.Type.Kind() == reflect.Slice && f.Type.Elem().Kind() != reflect.Uint8 || f.Type.Kind() == reflect.Map) { + schema = nullable(schema) + } + if f.Tag.Get("nullable") == "true" && f.Type.Kind() != reflect.Pointer { + schema = nullable(schema) + } + props[name] = schema + if !omit && !g.params { + *need = append(*need, name) + } + } +} + +// nullable is a schema that also admits null. +func nullable(s map[string]any) map[string]any { + if len(s) == 0 { + return s + } + return map[string]any{"anyOf": []any{s, map[string]any{"type": "null"}}} +} + +// withEnum narrows a string schema, or the string under a nullable one, to +// its words. +func withEnum(s map[string]any, words []string) map[string]any { + if alts, ok := s["anyOf"].([]any); ok && len(alts) == 2 { + inner := withEnum(alts[0].(map[string]any), words) + return map[string]any{"anyOf": []any{inner, alts[1]}} + } + if s["type"] == "array" { + items := withEnum(s["items"].(map[string]any), words) + out := map[string]any{} + for k, v := range s { + out[k] = v + } + out["items"] = items + return out + } + out := map[string]any{} + for k, v := range s { + out[k] = v + } + out["enum"] = words + return out +} diff --git a/internal/appwire/server.go b/internal/appwire/server.go index b931c7d67c..8db36d9ee7 100644 --- a/internal/appwire/server.go +++ b/internal/appwire/server.go @@ -240,7 +240,7 @@ func (s *Server) handle(ctx context.Context, line []byte, engine Engine, infligh inflight.Add(1) go func() { defer inflight.Done() - result, err := method(engine, params) + result, err := method.call(engine, params) if ctx.Err() != nil && err != nil { err = Errorf(CodeClosed, "codeaf is shutting down") } @@ -257,34 +257,31 @@ func (s *Server) handle(ctx context.Context, line []byte, engine Engine, infligh return false } -// method answers one request from its params, which are already known to be -// a JSON object. -type method func(Engine, json.RawMessage) (any, error) - // methods is the dispatch table. shutdown is not in it: it is answered by the // loop itself, because it ends the loop. var methods = map[string]method{ - "ping": func(Engine, json.RawMessage) (any, error) { return struct{}{}, nil }, - "agents.list": func(e Engine, raw json.RawMessage) (any, error) { - var p struct { - Workspace string `json:"workspace"` - } - if err := decode(raw, &p); err != nil { - return nil, err + "ping": typed(func(Engine, struct{}) (*Empty, error) { return &Empty{}, nil }), + "agents.list": typed(func(e Engine, p AgentsListParams) (*AgentsResult, error) { + // A STATE AND A QUERY are answered by an engine that keeps them. + if t, ok := e.(Tidier); ok { + switch p.State { + case "", ListOpen, ListClosed, ListArchived, ListAll: + default: + return nil, Errorf(CodeBadParams, "state is open, closed, archived or all") + } + agents, err := t.ListAgentsIn(p.Workspace, p.State, p.Query) + if err != nil { + return nil, err + } + return &AgentsResult{nonNil(agents)}, nil } agents, err := e.ListAgents(p.Workspace) if err != nil { return nil, err } - return struct { - Agents []Agent `json:"agents"` - }{nonNil(agents)}, nil - }, - "agents.create": func(e Engine, raw json.RawMessage) (any, error) { - var p CreateAsk - if err := decode(raw, &p); err != nil { - return nil, err - } + return &AgentsResult{nonNil(agents)}, nil + }), + "agents.create": typed(func(e Engine, p CreateAsk) (*agentResult, error) { if p.BudgetUSD != nil && *p.BudgetUSD < 0 { return nil, Errorf(CodeBadParams, "budget_usd cannot be negative") } @@ -292,17 +289,9 @@ var methods = map[string]method{ if err != nil { return nil, err } - return agentResult{agent}, nil - }, - "agents.entries": func(e Engine, raw json.RawMessage) (any, error) { - var p struct { - ID string `json:"id"` - Before string `json:"before"` - Limit int `json:"limit"` - } - if err := decode(raw, &p); err != nil { - return nil, err - } + return &agentResult{agent}, nil + }), + "agents.entries": typed(func(e Engine, p EntriesParams) (*EntriesResult, error) { if strings.TrimSpace(p.ID) == "" { return nil, Errorf(CodeBadParams, "agents.entries needs an id") } @@ -316,19 +305,12 @@ var methods = map[string]method{ if err != nil { return nil, err } - return struct { - Entries []Entry `json:"entries"` - More bool `json:"more"` - }{nonNil(entries), more}, nil - }, - "agents.set_model": func(e Engine, raw json.RawMessage) (any, error) { - var p struct { - ID string `json:"id"` - Model string `json:"model"` - } - if err := decode(raw, &p); err != nil { - return nil, err - } + return &EntriesResult{nonNil(entries), more}, nil + }), + "agents.set_model": typed(func(e Engine, p struct { + ID string `json:"id"` + Model string `json:"model"` + }) (*agentResult, error) { if strings.TrimSpace(p.ID) == "" || strings.TrimSpace(p.Model) == "" { return nil, Errorf(CodeBadParams, "agents.set_model needs an id and a model") } @@ -336,16 +318,12 @@ var methods = map[string]method{ if err != nil { return nil, err } - return agentResult{agent}, nil - }, - "agents.set_effort": func(e Engine, raw json.RawMessage) (any, error) { - var p struct { - ID string `json:"id"` - Effort string `json:"effort"` - } - if err := decode(raw, &p); err != nil { - return nil, err - } + return &agentResult{agent}, nil + }), + "agents.set_effort": typed(func(e Engine, p struct { + ID string `json:"id"` + Effort string `json:"effort" enum:"low,medium,high"` + }) (*agentResult, error) { effort := strings.ToLower(strings.TrimSpace(p.Effort)) if strings.TrimSpace(p.ID) == "" { return nil, Errorf(CodeBadParams, "agents.set_effort needs an id") @@ -359,10 +337,10 @@ var methods = map[string]method{ if err != nil { return nil, err } - return agentResult{agent}, nil - }, - "agents.retry": func(e Engine, raw json.RawMessage) (any, error) { - id, err := decodeID(raw, "id") + return &agentResult{agent}, nil + }), + "agents.retry": typed(func(e Engine, p IDParams) (*TurnResult, error) { + id, err := required("id", p.ID) if err != nil { return nil, err } @@ -370,39 +348,31 @@ var methods = map[string]method{ if err != nil { return nil, err } - return struct { - Turn uint64 `json:"turn"` - }{turn}, nil - }, - "models.list": func(e Engine, _ json.RawMessage) (any, error) { + return &TurnResult{turn}, nil + }), + "models.list": typed(func(e Engine, _ struct{}) (*ModelsResult, error) { models, err := e.ListModels() if err != nil { return nil, err } - return struct { - Models []Model `json:"models"` - }{nonNil(models)}, nil - }, - "settings.get": func(e Engine, _ json.RawMessage) (any, error) { + return &ModelsResult{nonNil(models)}, nil + }), + "settings.get": typed(func(e Engine, _ struct{}) (*SettingsResult, error) { settings, err := e.Settings() if err != nil { return nil, err } - return struct { - Settings Settings `json:"settings"` - }{settings}, nil - }, - "spend.today": func(e Engine, _ json.RawMessage) (any, error) { + return &SettingsResult{settings}, nil + }), + "spend.today": typed(func(e Engine, _ struct{}) (*SpendResult, error) { spend, err := e.SpendToday() if err != nil { return nil, err } - return struct { - Spend Spend `json:"spend"` - }{spend}, nil - }, - "agents.open": func(e Engine, raw json.RawMessage) (any, error) { - id, err := decodeID(raw, "id") + return &SpendResult{spend}, nil + }), + "agents.open": typed(func(e Engine, p IDParams) (*agentResult, error) { + id, err := required("id", p.ID) if err != nil { return nil, err } @@ -410,18 +380,14 @@ var methods = map[string]method{ if err != nil { return nil, err } - return agentResult{agent}, nil - }, - "agents.send": func(e Engine, raw json.RawMessage) (any, error) { - var p struct { - ID string `json:"id"` - Text string `json:"text"` - ClientID string `json:"client_id"` - Attachments []string `json:"attachments"` - } - if err := decode(raw, &p); err != nil { - return nil, err - } + return &agentResult{agent}, nil + }), + "agents.send": typed(func(e Engine, p struct { + ID string `json:"id"` + Text string `json:"text"` + ClientID string `json:"client_id"` + Attachments []string `json:"attachments"` + }) (*TurnResult, error) { if strings.TrimSpace(p.ID) == "" { return nil, Errorf(CodeBadParams, "agents.send needs an id") } @@ -438,26 +404,24 @@ var methods = map[string]method{ if err != nil { return nil, err } - return struct { - Turn uint64 `json:"turn"` - }{turn}, nil - }, - "agents.stop": func(e Engine, raw json.RawMessage) (any, error) { - id, err := decodeID(raw, "id") + return &TurnResult{turn}, nil + }), + "agents.stop": typed(func(e Engine, p IDParams) (*Empty, error) { + id, err := required("id", p.ID) if err != nil { return nil, err } - return struct{}{}, e.Stop(id) - }, - "agents.close": func(e Engine, raw json.RawMessage) (any, error) { - id, err := decodeID(raw, "id") + return done(e.Stop(id)) + }), + "agents.close": typed(func(e Engine, p IDParams) (*Empty, error) { + id, err := required("id", p.ID) if err != nil { return nil, err } - return struct{}{}, e.CloseAgent(id) - }, - "asks.list": func(e Engine, raw json.RawMessage) (any, error) { - id, err := decodeID(raw, "agent") + return done(e.CloseAgent(id)) + }), + "asks.list": typed(func(e Engine, p AgentParams) (*AsksResult, error) { + id, err := required("agent", p.Agent) if err != nil { return nil, err } @@ -465,24 +429,18 @@ var methods = map[string]method{ if err != nil { return nil, err } - return struct { - Asks []Ask `json:"asks"` - }{nonNil(asks)}, nil - }, - "asks.answer": func(e Engine, raw json.RawMessage) (any, error) { - var p struct { - Agent string `json:"agent"` - ID string `json:"id"` - Option string `json:"option"` - Text string `json:"text"` - Why string `json:"why"` - BatchItem string `json:"batch_item"` - Later bool `json:"later"` - YouDecide bool `json:"you_decide"` - } - if err := decode(raw, &p); err != nil { - return nil, err - } + return &AsksResult{nonNil(asks)}, nil + }), + "asks.answer": typed(func(e Engine, p struct { + Agent string `json:"agent"` + ID string `json:"id"` + Option string `json:"option"` + Text string `json:"text"` + Why string `json:"why"` + BatchItem string `json:"batch_item"` + Later bool `json:"later"` + YouDecide bool `json:"you_decide"` + }) (*Empty, error) { if strings.TrimSpace(p.Agent) == "" || strings.TrimSpace(p.ID) == "" { return nil, Errorf(CodeBadParams, "asks.answer needs an agent and an id") } @@ -490,17 +448,17 @@ var methods = map[string]method{ return nil, Errorf(CodeBadParams, "asks.answer needs an option, some text, later or you_decide") } if t, ok := e.(Talker); ok { - return struct{}{}, t.AnswerAskWith(p.Agent, p.ID, AskAnswer{ + return done(t.AnswerAskWith(p.Agent, p.ID, AskAnswer{ Option: strings.TrimSpace(p.Option), Text: p.Text, Why: p.Why, BatchItem: p.BatchItem, Later: p.Later, YouDecide: p.YouDecide, - }) + })) } if p.Later || p.YouDecide || p.BatchItem != "" || p.Why != "" { return nil, Errorf(CodeBadParams, "this engine answers with an option and words only") } - return struct{}{}, e.AnswerAsk(p.Agent, p.ID, strings.TrimSpace(p.Option), p.Text) - }, - "tasks.list": func(e Engine, raw json.RawMessage) (any, error) { - id, err := decodeID(raw, "agent") + return done(e.AnswerAsk(p.Agent, p.ID, strings.TrimSpace(p.Option), p.Text)) + }), + "tasks.list": typed(func(e Engine, p AgentParams) (*TasksResult, error) { + id, err := required("agent", p.Agent) if err != nil { return nil, err } @@ -508,19 +466,11 @@ var methods = map[string]method{ if err != nil { return nil, err } - return struct { - Tasks []Task `json:"tasks"` - }{nonNil(tasks)}, nil - }, - "crews.list": func(e Engine, raw json.RawMessage) (any, error) { - var p struct { - Closed bool `json:"closed"` - } - if len(raw) > 0 && string(raw) != "null" { - if err := decode(raw, &p); err != nil { - return nil, err - } - } + return &TasksResult{nonNil(tasks)}, nil + }), + "crews.list": typed(func(e Engine, p struct { + Closed bool `json:"closed"` + }) (*CrewsResult, error) { var crews []Crew var err error if c, ok := e.(Crewer); ok { @@ -534,12 +484,53 @@ var methods = map[string]method{ for i := range crews { crews[i].Members = nonNil(crews[i].Members) } - return struct { - Crews []Crew `json:"crews"` - }{nonNil(crews)}, nil - }, + return &CrewsResult{nonNil(crews)}, nil + }), } +// The answers of the methods above that are not answered by a type of their +// own elsewhere. +type ( + AgentsListParams struct { + Workspace string `json:"workspace"` + State string `json:"state" enum:"open,closed,archived,all"` + Query string `json:"query"` + } + EntriesParams struct { + ID string `json:"id"` + Before string `json:"before"` + Limit int `json:"limit"` + } + AgentsResult struct { + Agents []Agent `json:"agents"` + } + EntriesResult struct { + Entries []Entry `json:"entries"` + More bool `json:"more"` + } + TurnResult struct { + Turn uint64 `json:"turn"` + } + ModelsResult struct { + Models []Model `json:"models"` + } + SettingsResult struct { + Settings Settings `json:"settings"` + } + SpendResult struct { + Spend Spend `json:"spend"` + } + AsksResult struct { + Asks []Ask `json:"asks"` + } + TasksResult struct { + Tasks []Task `json:"tasks"` + } + CrewsResult struct { + Crews []Crew `json:"crews"` + } +) + type agentResult struct { Agent Agent `json:"agent"` } @@ -552,24 +543,6 @@ func decode(raw json.RawMessage, v any) error { return nil } -// decodeID reads the one required string a method takes. -func decodeID(raw json.RawMessage, field string) (string, error) { - var p map[string]json.RawMessage - if err := decode(raw, &p); err != nil { - return "", err - } - var id string - if value, ok := p[field]; ok { - if err := json.Unmarshal(value, &id); err != nil { - return "", Errorf(CodeBadParams, "%s must be a string", field) - } - } - if strings.TrimSpace(id) == "" { - return "", Errorf(CodeBadParams, "%s is required", field) - } - return id, nil -} - // nonNil keeps an empty list a list on the wire, never null. func nonNil[T any](list []T) []T { if list == nil { diff --git a/internal/appwire/settings8.go b/internal/appwire/settings8.go index ead9b72bb1..8586bd3249 100644 --- a/internal/appwire/settings8.go +++ b/internal/appwire/settings8.go @@ -1,7 +1,6 @@ package appwire import ( - "encoding/json" "strings" "time" ) @@ -77,20 +76,14 @@ func settler(e Engine, name string) (Settler, error) { } // settleCall decodes a method's params into P and answers what call answers. -func settleCall[P any](name string, call func(Settler, P) (any, error)) method { - return func(e Engine, raw json.RawMessage) (any, error) { +func settleCall[P, R any](name string, call func(Settler, P) (*R, error)) method { + return typed(func(e Engine, p P) (*R, error) { s, err := settler(e, name) if err != nil { return nil, err } - var p P - if len(raw) > 0 && string(raw) != "null" { - if err := decode(raw, &p); err != nil { - return nil, err - } - } return call(s, p) - } + }) } // exportDay reads one end of an export's range, a calendar day. @@ -103,24 +96,22 @@ func exportDay(name, day string) (time.Time, error) { } var settingsMethods = map[string]method{ - "rules.list": settleCall("rules.list", func(s Settler, _ struct{}) (any, error) { + "rules.list": settleCall("rules.list", func(s Settler, _ struct{}) (*RulesResult, error) { rules, err := s.Rules() if err != nil { return nil, err } - return struct { - Rules []Rule `json:"rules"` - }{nonNil(rules)}, nil + return &RulesResult{nonNil(rules)}, nil }), - "rules.remove": settleCall("rules.remove", func(s Settler, p crewID) (any, error) { + "rules.remove": settleCall("rules.remove", func(s Settler, p crewID) (*Empty, error) { if err := needID("rules.remove", p.ID); err != nil { return nil, err } - return struct{}{}, s.RemoveRule(p.ID) + return done(s.RemoveRule(p.ID)) }), "memory.list": settleCall("memory.list", func(s Settler, p struct { Limit int `json:"limit"` - }) (any, error) { + }) (*MemoriesResult, error) { if p.Limit <= 0 || p.Limit > 500 { p.Limit = 200 } @@ -128,20 +119,18 @@ var settingsMethods = map[string]method{ if err != nil { return nil, err } - return struct { - Memories []Memory `json:"memories"` - }{nonNil(memories)}, nil + return &MemoriesResult{nonNil(memories)}, nil }), - "memory.forget": settleCall("memory.forget", func(s Settler, p crewID) (any, error) { + "memory.forget": settleCall("memory.forget", func(s Settler, p crewID) (*Empty, error) { if err := needID("memory.forget", p.ID); err != nil { return nil, err } - return struct{}{}, s.ForgetMemory(p.ID) + return done(s.ForgetMemory(p.ID)) }), "spend.export": settleCall("spend.export", func(s Settler, p struct { From string `json:"from"` To string `json:"to"` - }) (any, error) { + }) (*PathResult, error) { from, err := exportDay("from", p.From) if err != nil { return nil, err @@ -157,13 +146,11 @@ var settingsMethods = map[string]method{ if err != nil { return nil, err } - return struct { - Path string `json:"path"` - }{path}, nil + return &PathResult{path}, nil }), "models.pin": settleCall("models.pin", func(s Settler, p struct { IDs []string `json:"ids"` - }) (any, error) { + }) (*SettingsResult, error) { if len(p.IDs) > PinnedModelsMax { return nil, Errorf(CodeBadParams, "at most %d models can be pinned", PinnedModelsMax) } @@ -178,8 +165,19 @@ var settingsMethods = map[string]method{ if err != nil { return nil, err } - return struct { - Settings Settings `json:"settings"` - }{settings}, nil + return &SettingsResult{settings}, nil }), } + +// The answers of this file's methods. +type ( + RulesResult struct { + Rules []Rule `json:"rules"` + } + MemoriesResult struct { + Memories []Memory `json:"memories"` + } + PathResult struct { + Path string `json:"path"` + } +) diff --git a/internal/appwire/setup.go b/internal/appwire/setup.go index 261436085f..16301d0f53 100644 --- a/internal/appwire/setup.go +++ b/internal/appwire/setup.go @@ -16,38 +16,40 @@ func init() { } var setupMethods = map[string]method{ - "providers.list": func(e Engine, _ json.RawMessage) (any, error) { + "providers.list": typed(func(e Engine, _ struct{}) (*ProvidersResult, error) { providers, err := e.ListProviders() if err != nil { return nil, err } - return struct { - Providers []Provider `json:"providers"` - }{nonNil(providers)}, nil - }, - "keys.check": func(e Engine, raw json.RawMessage) (any, error) { + return &ProvidersResult{nonNil(providers)}, nil + }), + "keys.check": shaped[KeyParams](func(e Engine, raw json.RawMessage) (*KeyCheck, error) { provider, key, err := decodeKey(raw, "keys.check") if err != nil { return nil, err } - return e.CheckKey(provider, key) - }, - "keys.set": func(e Engine, raw json.RawMessage) (any, error) { + check, err := e.CheckKey(provider, key) + if err != nil { + return nil, err + } + return &check, nil + }), + "keys.set": shaped[KeyParams](func(e Engine, raw json.RawMessage) (*Empty, error) { provider, key, err := decodeKey(raw, "keys.set") if err != nil { return nil, err } - return struct{}{}, e.SetKey(provider, key) - }, - "keys.remove": func(e Engine, raw json.RawMessage) (any, error) { - provider, err := decodeID(raw, "provider") + return done(e.SetKey(provider, key)) + }), + "keys.remove": typed(func(e Engine, p ProviderParams) (*Empty, error) { + provider, err := required("provider", p.Provider) if err != nil { return nil, err } - return struct{}{}, e.RemoveKey(strings.ToLower(strings.TrimSpace(provider))) - }, - "providers.sign_in": func(e Engine, raw json.RawMessage) (any, error) { - provider, err := decodeID(raw, "provider") + return done(e.RemoveKey(strings.ToLower(strings.TrimSpace(provider)))) + }), + "providers.sign_in": typed(func(e Engine, p ProviderParams) (*URLResult, error) { + provider, err := required("provider", p.Provider) if err != nil { return nil, err } @@ -55,22 +57,16 @@ var setupMethods = map[string]method{ if err != nil { return nil, err } - return struct { - URL string `json:"url"` - }{url}, nil - }, - "setup.get": func(e Engine, _ json.RawMessage) (any, error) { + return &URLResult{url}, nil + }), + "setup.get": typed(func(e Engine, _ struct{}) (*setupResult, error) { setup, err := e.Setup() if err != nil { return nil, err } - return setupResult{setup}, nil - }, - "setup.set": func(e Engine, raw json.RawMessage) (any, error) { - var p Setup - if err := decode(raw, &p); err != nil { - return nil, err - } + return &setupResult{setup}, nil + }), + "setup.set": typed(func(e Engine, p Setup) (*setupResult, error) { if p.Step == (SetupStep{}) { return nil, Errorf(CodeBadParams, "setup.set needs a step") } @@ -84,9 +80,9 @@ var setupMethods = map[string]method{ if err != nil { return nil, err } - return setupResult{setup}, nil - }, - "settings.set": func(e Engine, raw json.RawMessage) (any, error) { + return &setupResult{setup}, nil + }), + "settings.set": shaped[SettingsPatchWire](func(e Engine, raw json.RawMessage) (*SettingsResult, error) { patch, err := decodeSettingsPatch(raw) if err != nil { return nil, err @@ -95,15 +91,9 @@ var setupMethods = map[string]method{ if err != nil { return nil, err } - return struct { - Settings Settings `json:"settings"` - }{settings}, nil - }, - "caps.raise": func(e Engine, raw json.RawMessage) (any, error) { - var p CapRaise - if err := decode(raw, &p); err != nil { - return nil, err - } + return &SettingsResult{settings}, nil + }), + "caps.raise": typed(func(e Engine, p CapRaise) (*Empty, error) { switch p.Scope { case CapDay: case CapAgent: @@ -119,8 +109,42 @@ var setupMethods = map[string]method{ if !money(p.ToUSD) { return nil, Errorf(CodeBadParams, "to_usd must be a positive amount") } - return struct{}{}, e.RaiseCap(p) - }, + return done(e.RaiseCap(p)) + }), +} + +// KeyParams is keys.check's and keys.set's params. The key is read by +// [decodeKey], which never puts it into an error's words. +type KeyParams struct { + Provider string `json:"provider"` + Key string `json:"key"` +} + +// ProviderParams is the params of a method that takes one provider. +type ProviderParams struct { + Provider string `json:"provider"` +} + +// SettingsPatchWire is settings.set's params as they travel: every field is +// optional, and a field present is changed. A money field sent as null means +// none. [decodeSettingsPatch] reads exactly these fields and refuses any other. +type SettingsPatchWire struct { + DefaultModel *string `json:"default_model,omitempty"` + AsksBefore *string `json:"asks_before,omitempty"` + WorkFolder *string `json:"work_folder,omitempty"` + Effort *string `json:"effort,omitempty"` + BudgetUSD *float64 `json:"budget_usd,omitempty" nullable:"true"` + DayCapUSD *float64 `json:"day_cap_usd,omitempty" nullable:"true"` + TaskCapUSD *float64 `json:"task_cap_usd,omitempty" nullable:"true"` + ProposedTaskClockS *int `json:"proposed_task_clock_s,omitempty"` + IdleCloseDays *int `json:"idle_close_days,omitempty"` + Appearance *string `json:"appearance,omitempty" enum:"system,light,dark"` + TextSize *string `json:"text_size,omitempty" enum:"small,medium,large"` + OpenAtLogin *bool `json:"open_at_login,omitempty"` + DockBadge *bool `json:"dock_badge,omitempty"` + ChecksModel *string `json:"checks_model,omitempty"` + PlannerModel *string `json:"planner_model,omitempty"` + FallbackModel *string `json:"fallback_model,omitempty"` } type setupResult struct { @@ -252,3 +276,13 @@ func decodeSettingsPatch(raw json.RawMessage) (SettingsPatch, error) { } return patch, nil } + +// The answers of this file's methods. +type ( + ProvidersResult struct { + Providers []Provider `json:"providers"` + } + URLResult struct { + URL string `json:"url"` + } +) diff --git a/internal/appwire/tidy.go b/internal/appwire/tidy.go index 0837468a1d..9b7a811fe1 100644 --- a/internal/appwire/tidy.go +++ b/internal/appwire/tidy.go @@ -1,7 +1,6 @@ package appwire import ( - "encoding/json" "strings" "time" ) @@ -63,36 +62,6 @@ func init() { for name, m := range tidyMethods { methods[name] = m } - // agents.list takes a state and a query when the engine can answer them. - plain := methods["agents.list"] - methods["agents.list"] = func(e Engine, raw json.RawMessage) (any, error) { - t, ok := e.(Tidier) - if !ok { - return plain(e, raw) - } - var p struct { - Workspace string `json:"workspace"` - State string `json:"state"` - Query string `json:"query"` - } - if len(raw) > 0 && string(raw) != "null" { - if err := decode(raw, &p); err != nil { - return nil, err - } - } - switch p.State { - case "", ListOpen, ListClosed, ListArchived, ListAll: - default: - return nil, Errorf(CodeBadParams, "state is open, closed, archived or all") - } - agents, err := t.ListAgentsIn(p.Workspace, p.State, p.Query) - if err != nil { - return nil, err - } - return struct { - Agents []Agent `json:"agents"` - }{nonNil(agents)}, nil - } } func tidier(e Engine, name string) (Tidier, error) { @@ -104,20 +73,14 @@ func tidier(e Engine, name string) (Tidier, error) { } // tidyCall decodes a method's params into P and answers what call answers. -func tidyCall[P any](name string, call func(Tidier, P) (any, error)) method { - return func(e Engine, raw json.RawMessage) (any, error) { +func tidyCall[P, R any](name string, call func(Tidier, P) (*R, error)) method { + return typed(func(e Engine, p P) (*R, error) { t, err := tidier(e, name) if err != nil { return nil, err } - var p P - if len(raw) > 0 && string(raw) != "null" { - if err := decode(raw, &p); err != nil { - return nil, err - } - } return call(t, p) - } + }) } type tidyIDs struct { @@ -136,27 +99,27 @@ func needIDs(name string, ids []string) error { return nil } -func agentOf(a Agent, err error) (any, error) { +func agentOf(a Agent, err error) (*agentResult, error) { if err != nil { return nil, err } - return agentResult{a}, nil + return &agentResult{a}, nil } var tidyMethods = map[string]method{ - "agents.move_to_closed": tidyCall("agents.move_to_closed", func(t Tidier, p tidyIDs) (any, error) { + "agents.move_to_closed": tidyCall("agents.move_to_closed", func(t Tidier, p tidyIDs) (*Empty, error) { if err := needIDs("agents.move_to_closed", p.IDs); err != nil { return nil, err } - return struct{}{}, t.MoveToClosed(p.IDs) + return done(t.MoveToClosed(p.IDs)) }), - "agents.archive": tidyCall("agents.archive", func(t Tidier, p tidyIDs) (any, error) { + "agents.archive": tidyCall("agents.archive", func(t Tidier, p tidyIDs) (*Empty, error) { if err := needIDs("agents.archive", p.IDs); err != nil { return nil, err } - return struct{}{}, t.Archive(p.IDs) + return done(t.Archive(p.IDs)) }), - "agents.reopen": tidyCall("agents.reopen", func(t Tidier, p crewID) (any, error) { + "agents.reopen": tidyCall("agents.reopen", func(t Tidier, p crewID) (*agentResult, error) { if err := needID("agents.reopen", p.ID); err != nil { return nil, err } @@ -165,7 +128,7 @@ var tidyMethods = map[string]method{ "agents.rename": tidyCall("agents.rename", func(t Tidier, p struct { ID string `json:"id"` Title string `json:"title"` - }) (any, error) { + }) (*agentResult, error) { if err := needID("agents.rename", p.ID); err != nil { return nil, err } @@ -176,7 +139,7 @@ var tidyMethods = map[string]method{ }), "agents.idle": tidyCall("agents.idle", func(t Tidier, p struct { Days int `json:"days"` - }) (any, error) { + }) (*IDsResult, error) { if p.Days < 0 { return nil, Errorf(CodeBadParams, "days cannot be negative") } @@ -184,14 +147,12 @@ var tidyMethods = map[string]method{ if err != nil { return nil, err } - return struct { - IDs []string `json:"ids"` - }{nonNil(ids)}, nil + return &IDsResult{nonNil(ids)}, nil }), "search.all": tidyCall("search.all", func(t Tidier, p struct { Query string `json:"query"` PerGroup int `json:"limit_per_group"` - }) (any, error) { + }) (*GroupsResult2, error) { if strings.TrimSpace(p.Query) == "" { return nil, Errorf(CodeBadParams, "search.all needs a query") } @@ -205,8 +166,13 @@ var tidyMethods = map[string]method{ for i := range groups { groups[i].Hits = nonNil(groups[i].Hits) } - return struct { - Groups []HitGroup `json:"groups"` - }{nonNil(groups)}, nil + return &GroupsResult2{nonNil(groups)}, nil }), } + +// The answers of this file's methods. +type ( + GroupsResult2 struct { + Groups []HitGroup `json:"groups"` + } +) diff --git a/internal/appwire/wire.go b/internal/appwire/wire.go index 582f5070d5..082dc38c86 100644 --- a/internal/appwire/wire.go +++ b/internal/appwire/wire.go @@ -109,7 +109,7 @@ type Agent struct { // Effort is how hard the model is asked to think: low, medium or high, // and empty when the conversation runs on the model's own default. Effort string `json:"effort,omitempty"` - State string `json:"state"` + State string `json:"state" enum:"idle,working,needs_you,stopped,closed,archived"` UpdatedAt time.Time `json:"updated_at"` // Crews are the crews this agent is a member of, open ones only. Crews []string `json:"crews,omitempty"` @@ -190,8 +190,8 @@ const ( type Ask struct { ID string `json:"id"` Agent string `json:"agent"` - Kind string `json:"kind"` - Subkind string `json:"subkind,omitempty"` + Kind string `json:"kind" enum:"approval,question"` + Subkind string `json:"subkind,omitempty" enum:"permission,budget,crew_decision,crew_proposal,order_run"` Title string `json:"title"` Detail string `json:"detail,omitempty"` // Options carry the engine's own ids; for a permission they are @@ -218,12 +218,17 @@ type Ask struct { // card: an automation waiting to be agreed to, a task, a tool call, a crew // proposal or decision. Nil is an ask about nothing in particular. Subject *AskSubject `json:"subject,omitempty"` + // Proposal is a crew proposal's whole shape, on its Bring in helpers ask: + // the crew's name, the helpers with their handles and roles, the cap, the + // goal. It is kept in the app layer until it is answered, so the ask is + // asked again after a restart. + Proposal *Proposal `json:"proposal,omitempty"` } // AskSubject is the thing an ask is about: its kind and the id the app knows // it by. type AskSubject struct { - Kind string `json:"kind"` + Kind string `json:"kind" enum:"automation,task,call,run,page,account,crew_proposal,crew_decision,budget"` Ref string `json:"ref,omitempty"` } @@ -265,7 +270,7 @@ type Entry struct { ID string `json:"id"` Agent string `json:"agent"` Turn uint64 `json:"turn,omitempty"` - Kind string `json:"kind"` + Kind string `json:"kind" enum:"user,answer,tool,receipt,notice,error,update,steer,note,summary,footer"` At time.Time `json:"at"` // Text is the words of a user, answer, receipt, notice or error entry. Text string `json:"text,omitempty"` @@ -363,11 +368,11 @@ type Settings struct { TaskCapUSD *float64 `json:"task_cap_usd"` ProposedTaskClockS int `json:"proposed_task_clock_s"` IdleCloseDays int `json:"idle_close_days"` - Effort string `json:"effort,omitempty"` + Effort string `json:"effort,omitempty" enum:"low,medium,high"` // The General rows the window draws by (Wave 8): kept here so every window // reads the same answer. - Appearance string `json:"appearance"` - TextSize string `json:"text_size"` + Appearance string `json:"appearance" enum:"system,light,dark"` + TextSize string `json:"text_size" enum:"small,medium,large"` OpenAtLogin bool `json:"open_at_login"` DockBadge bool `json:"dock_badge"` // The Models rows: the pinned models (⌥⌘1–3), the model checks run on and diff --git a/internal/appwire/work.go b/internal/appwire/work.go index 6ce02cfa1e..b3b8040af8 100644 --- a/internal/appwire/work.go +++ b/internal/appwire/work.go @@ -1,7 +1,6 @@ package appwire import ( - "encoding/json" "strings" "time" ) @@ -148,7 +147,7 @@ type Worker interface { FollowProcess(id string, follow bool) error StopProcess(id string) error RerunProcess(id string) (Process, error) - MoveToTask(agent string, turn uint64) (Task, error) + MoveToTask(agent string, turn uint64) (MoveResult, error) Sleep() error Wake() error } @@ -171,26 +170,26 @@ func worker(e Engine, name string) (Worker, error) { // taskVerb is one card verb that takes only an id. func taskVerb(action string) method { - return func(e Engine, raw json.RawMessage) (any, error) { + return typed(func(e Engine, p IDParams) (*Empty, error) { w, err := worker(e, "tasks."+action) if err != nil { return nil, err } - id, err := decodeID(raw, "id") + id, err := required("id", p.ID) if err != nil { return nil, err } - return struct{}{}, w.ActOnTask(id, action, "", "") - } + return done(w.ActOnTask(id, action, "", "")) + }) } var workMethods = map[string]method{ - "tasks.get": func(e Engine, raw json.RawMessage) (any, error) { + "tasks.get": typed(func(e Engine, p IDParams) (*PageResult, error) { w, err := worker(e, "tasks.get") if err != nil { return nil, err } - id, err := decodeID(raw, "id") + id, err := required("id", p.ID) if err != nil { return nil, err } @@ -199,28 +198,22 @@ var workMethods = map[string]method{ return nil, err } page.Files, page.Log, page.Notes = nonNil(page.Files), nonNil(page.Log), nonNil(page.Notes) - return struct { - Page TaskPage `json:"page"` - }{page}, nil - }, + return &PageResult{page}, nil + }), "tasks.start": taskVerb("start"), "tasks.hold": taskVerb("hold"), "tasks.decline": taskVerb("decline"), "tasks.typing": taskVerb("typing"), "tasks.stop": taskVerb("stop"), - "tasks.note": func(e Engine, raw json.RawMessage) (any, error) { + "tasks.note": typed(func(e Engine, p struct { + ID string `json:"id"` + Text string `json:"text"` + ClientID string `json:"client_id"` + }) (*TaskNoteResult, error) { w, err := worker(e, "tasks.note") if err != nil { return nil, err } - var p struct { - ID string `json:"id"` - Text string `json:"text"` - ClientID string `json:"client_id"` - } - if err := decode(raw, &p); err != nil { - return nil, err - } if strings.TrimSpace(p.ID) == "" || strings.TrimSpace(p.Text) == "" { return nil, Errorf(CodeBadParams, "tasks.note needs an id and some text") } @@ -230,25 +223,19 @@ var workMethods = map[string]method{ if err != nil { return nil, err } - return struct { - Entry Entry `json:"entry"` - }{entry}, nil - } - return struct{}{}, w.ActOnTask(p.ID, "note", "", p.Text) - }, - "tasks.decide": func(e Engine, raw json.RawMessage) (any, error) { + return &TaskNoteResult{&entry}, nil + } + return &TaskNoteResult{}, w.ActOnTask(p.ID, "note", "", p.Text) + }), + "tasks.decide": typed(func(e Engine, p struct { + ID string `json:"id"` + Verdict string `json:"verdict"` + Text string `json:"text"` + }) (*Empty, error) { w, err := worker(e, "tasks.decide") if err != nil { return nil, err } - var p struct { - ID string `json:"id"` - Verdict string `json:"verdict"` - Text string `json:"text"` - } - if err := decode(raw, &p); err != nil { - return nil, err - } if strings.TrimSpace(p.ID) == "" { return nil, Errorf(CodeBadParams, "tasks.decide needs an id") } @@ -260,25 +247,25 @@ var workMethods = map[string]method{ if p.Verdict == VerdictNotRight && strings.TrimSpace(p.Text) == "" { return nil, Errorf(CodeBadParams, "not_right needs the words to tell it") } - return struct{}{}, w.ActOnTask(p.ID, "decide", p.Verdict, p.Text) - }, - "quick.undo": func(e Engine, raw json.RawMessage) (any, error) { + return done(w.ActOnTask(p.ID, "decide", p.Verdict, p.Text)) + }), + "quick.undo": typed(func(e Engine, p IDParams) (*Empty, error) { w, err := worker(e, "quick.undo") if err != nil { return nil, err } - id, err := decodeID(raw, "id") + id, err := required("id", p.ID) if err != nil { return nil, err } - return struct{}{}, w.UndoQuick(id) - }, - "quick.promote": func(e Engine, raw json.RawMessage) (any, error) { + return done(w.UndoQuick(id)) + }), + "quick.promote": typed(func(e Engine, p IDParams) (*taskResult, error) { w, err := worker(e, "quick.promote") if err != nil { return nil, err } - id, err := decodeID(raw, "id") + id, err := required("id", p.ID) if err != nil { return nil, err } @@ -286,14 +273,14 @@ var workMethods = map[string]method{ if err != nil { return nil, err } - return taskResult{task}, nil - }, - "processes.list": func(e Engine, raw json.RawMessage) (any, error) { + return &taskResult{task}, nil + }), + "processes.list": typed(func(e Engine, p AgentParams) (*ProcessesResult, error) { w, err := worker(e, "processes.list") if err != nil { return nil, err } - agent, err := decodeID(raw, "agent") + agent, err := required("agent", p.Agent) if err != nil { return nil, err } @@ -301,22 +288,16 @@ var workMethods = map[string]method{ if err != nil { return nil, err } - return struct { - Processes []Process `json:"processes"` - }{nonNil(list)}, nil - }, - "processes.output": func(e Engine, raw json.RawMessage) (any, error) { + return &ProcessesResult{nonNil(list)}, nil + }), + "processes.output": typed(func(e Engine, p struct { + ID string `json:"id"` + FromLine int `json:"from_line"` + }) (*LinesNextLineResult, error) { w, err := worker(e, "processes.output") if err != nil { return nil, err } - var p struct { - ID string `json:"id"` - FromLine int `json:"from_line"` - } - if err := decode(raw, &p); err != nil { - return nil, err - } if strings.TrimSpace(p.ID) == "" || p.FromLine < 0 { return nil, Errorf(CodeBadParams, "processes.output needs an id and a from_line of zero or more") } @@ -324,20 +305,17 @@ var workMethods = map[string]method{ if err != nil { return nil, err } - return struct { - Lines []string `json:"lines"` - NextLine int `json:"next_line"` - }{nonNil(lines), next}, nil - }, + return &LinesNextLineResult{nonNil(lines), next}, nil + }), "processes.follow": processVerb(func(w Worker, id string) error { return w.FollowProcess(id, true) }), "processes.unfollow": processVerb(func(w Worker, id string) error { return w.FollowProcess(id, false) }), "processes.stop": processVerb(func(w Worker, id string) error { return w.StopProcess(id) }), - "processes.rerun": func(e Engine, raw json.RawMessage) (any, error) { + "processes.rerun": typed(func(e Engine, p IDParams) (*ProcessResult, error) { w, err := worker(e, "processes.rerun") if err != nil { return nil, err } - id, err := decodeID(raw, "id") + id, err := required("id", p.ID) if err != nil { return nil, err } @@ -345,62 +323,92 @@ var workMethods = map[string]method{ if err != nil { return nil, err } - return struct { - Process Process `json:"process"` - }{process}, nil - }, - "agents.move_to_task": func(e Engine, raw json.RawMessage) (any, error) { + return &ProcessResult{process}, nil + }), + "agents.move_to_task": typed(func(e Engine, p struct { + ID string `json:"id"` + Turn uint64 `json:"turn"` + }) (*MoveResult, error) { w, err := worker(e, "agents.move_to_task") if err != nil { return nil, err } - var p struct { - ID string `json:"id"` - Turn uint64 `json:"turn"` - } - if err := decode(raw, &p); err != nil { - return nil, err - } if strings.TrimSpace(p.ID) == "" { return nil, Errorf(CodeBadParams, "agents.move_to_task needs an id") } - task, err := w.MoveToTask(p.ID, p.Turn) + moved, err := w.MoveToTask(p.ID, p.Turn) if err != nil { return nil, err } - return taskResult{task}, nil - }, - "power.sleep": func(e Engine, _ json.RawMessage) (any, error) { + return &moved, nil + }), + "power.sleep": typed(func(e Engine, _ struct{}) (*Empty, error) { w, err := worker(e, "power.sleep") if err != nil { return nil, err } - return struct{}{}, w.Sleep() - }, - "power.wake": func(e Engine, _ json.RawMessage) (any, error) { + return done(w.Sleep()) + }), + "power.wake": typed(func(e Engine, _ struct{}) (*Empty, error) { w, err := worker(e, "power.wake") if err != nil { return nil, err } - return struct{}{}, w.Wake() - }, + return done(w.Wake()) + }), } // processVerb is one process verb that takes only an id. func processVerb(act func(Worker, string) error) method { - return func(e Engine, raw json.RawMessage) (any, error) { + return typed(func(e Engine, p IDParams) (*Empty, error) { w, err := worker(e, "processes") if err != nil { return nil, err } - id, err := decodeID(raw, "id") + id, err := required("id", p.ID) if err != nil { return nil, err } - return struct{}{}, act(w, id) - } + return done(act(w, id)) + }) +} + +// MoveResult is agents.move_to_task's answer, given at once. Task is the task +// the reply became when it had already moved; Moving says the move is taken +// and the reply moves when the step in flight ends, its task arriving as +// task.changed and one notice saying it moved. +type MoveResult struct { + Task *Task `json:"task,omitempty"` + Moving bool `json:"moving,omitempty"` +} + +// TaskNoteResult is tasks.note's answer: the note's entry, from an engine that +// keeps notes as entries. +type TaskNoteResult struct { + Entry *Entry `json:"entry,omitempty"` } type taskResult struct { Task Task `json:"task"` } + +// The answers of this file's methods. +type ( + PageResult struct { + Page TaskPage `json:"page"` + } + ProcessesResult struct { + Processes []Process `json:"processes"` + } + ProcessResult struct { + Process Process `json:"process"` + } +) + +// The answers of this file's methods. +type ( + LinesNextLineResult struct { + Lines []string `json:"lines"` + NextLine int `json:"next_line"` + } +) diff --git a/internal/appwire/worklog.go b/internal/appwire/worklog.go index d9ec85aa01..3fd91b4112 100644 --- a/internal/appwire/worklog.go +++ b/internal/appwire/worklog.go @@ -1,7 +1,6 @@ package appwire import ( - "encoding/json" "strings" "time" ) @@ -92,11 +91,7 @@ type toolCallParams struct { Limit int `json:"limit"` } -func decodeToolCall(raw json.RawMessage, name string) (toolCallParams, error) { - var p toolCallParams - if err := decode(raw, &p); err != nil { - return p, err - } +func checkToolCall(p toolCallParams, name string) (toolCallParams, error) { if strings.TrimSpace(p.Agent) == "" || strings.TrimSpace(p.Call) == "" { return p, Errorf(CodeBadParams, "%s needs an agent and a call", name) } @@ -108,26 +103,26 @@ func decodeToolCall(raw json.RawMessage, name string) (toolCallParams, error) { // toolFollow is tools.follow or tools.unfollow. func toolFollow(name string, follow bool) method { - return func(e Engine, raw json.RawMessage) (any, error) { + return typed(func(e Engine, p toolCallParams) (*Empty, error) { r, err := toolReader(e, name) if err != nil { return nil, err } - p, err := decodeToolCall(raw, name) + p, err = checkToolCall(p, name) if err != nil { return nil, err } - return struct{}{}, r.FollowTool(p.Agent, p.Call, follow) - } + return done(r.FollowTool(p.Agent, p.Call, follow)) + }) } var toolMethods = map[string]method{ - "tools.output": func(e Engine, raw json.RawMessage) (any, error) { + "tools.output": typed(func(e Engine, p toolCallParams) (*LinesNextLineTotalResult, error) { r, err := toolReader(e, "tools.output") if err != nil { return nil, err } - p, err := decodeToolCall(raw, "tools.output") + p, err = checkToolCall(p, "tools.output") if err != nil { return nil, err } @@ -135,12 +130,17 @@ var toolMethods = map[string]method{ if err != nil { return nil, err } - return struct { - Lines []string `json:"lines"` - NextLine int `json:"next_line"` - Total int `json:"total"` - }{nonNil(lines), next, total}, nil - }, + return &LinesNextLineTotalResult{nonNil(lines), next, total}, nil + }), "tools.follow": toolFollow("tools.follow", true), "tools.unfollow": toolFollow("tools.unfollow", false), } + +// The answers of this file's methods. +type ( + LinesNextLineTotalResult struct { + Lines []string `json:"lines"` + NextLine int `json:"next_line"` + Total int `json:"total"` + } +) diff --git a/internal/session/checkpoint.go b/internal/session/checkpoint.go index 9cb5f2d7b2..e52a134fdc 100644 --- a/internal/session/checkpoint.go +++ b/internal/session/checkpoint.go @@ -600,7 +600,7 @@ const checkpointSketchAsk = "[checkpoint] Above is what was asked and what has b const checkpointCeilingNote = "this is running long · moving it to a task that is watched and can split" // checkpointAskedNote is the line a reply the person moved to a task leaves. -const checkpointAskedNote = "moved to a task, as you asked · it is watched and can split" +const checkpointAskedNote = "Moved to a task · the chat is free again" // errNothingToMove is [Agent.MoveTurnToTask]'s answer when no reply is running. var errNothingToMove = errors.New("no reply is running to move") @@ -4637,7 +4637,10 @@ func (a *Agent) handOverRunningTurn(ctx context.Context, hub *eventHub, turn *Us // closing remark of a finished answer. The person's sentence is the one thing // on this road nobody writes, so it is the one thing that cannot come back as // machinery — and the namer improves it a second later anyway (taskname.go). - said, id := a.launchRouteTask(hub, verdict, asked, drawn, ahead) + // IN THE DESKTOP APP THE PERSON MOVED THIS REPLY, and the line above already + // said so; the task's start is not told a second time as though the harness + // had chosen the work. The terminal keeps its two lines. + said, id := a.launchRouteTaskTold(hub, verdict, asked, drawn, ahead, !a.config.HandOverOnAsk) // THE GAP IS SPENT, because the person has just been interrupted by a task and // does not care which of the moments noticed. routeJudgeGap exists so that work diff --git a/internal/session/readhandoff.go b/internal/session/readhandoff.go index 6b960763a6..cf6803712f 100644 --- a/internal/session/readhandoff.go +++ b/internal/session/readhandoff.go @@ -188,7 +188,32 @@ func (a *Agent) handoffReadSweep(ctx context.Context, ep *episode, hub *eventHub if len(rest) > 0 { restResults = a.runToolsWarm(ctx, ep, rest, hub, warm) } - answer, ok := a.awaitQuickAnswer(ctx, id) + answer, ok, moving := a.awaitQuickAnswer(ctx, id) + // A PERSON WHO MOVES THE REPLY DOES NOT WAIT FOR ITS READING. The move is + // taken at the next step boundary, and a batch held on a quick task is + // minutes from one; so the reading is let go, the readers answer that it + // was not done here, and the task the reply becomes reads what it needs. + if moving { + a.cancelTask(id, "the reply moved to a task") + took := time.Since(started) + byID := map[string]toolResult{} + for index, call := range readers { + if hub != nil { + hub.send(Event{Kind: EventToolFinished, Tool: call.Function.Name, Args: rendered[index], CallID: call.ID, Took: took}) + } + byID[call.ID] = a.finishToolResult(ep, call, toolResult{text: sweepMovedWord, harness: true}) + } + for index, call := range rest { + byID[call.ID] = restResults[index] + } + results := make([]toolResult, len(calls)) + for index, call := range calls { + results[index] = byID[call.ID] + } + sweep.targets, sweep.glosses = nil, nil + a.noteCallOutcomes(calls, results) + return results + } if !ok || strings.TrimSpace(answer) == "" { // FALLBACK: The quick task failed, timed out, or returned an empty answer. // Clean up the task node so it does not linger in the graph consuming resources. @@ -259,21 +284,26 @@ func (a *Agent) handoffReadSweep(ctx context.Context, ep *episode, hub *eventHub // the batch runs inline and the task is cancelled. Once started, the wait is // the conversation's patience and the turn's own context: a stopped turn // abandons the wait. -func (a *Agent) awaitQuickAnswer(ctx context.Context, id uint64) (string, bool) { +// +// AND A MOVE ENDS THE WAIT: moving answers true when the person asked for the +// reply to move to a task while it waited. +func (a *Agent) awaitQuickAnswer(ctx context.Context, id uint64) (answer string, ok, moving bool) { timer := time.NewTimer(readSweepWait) defer timer.Stop() startTimer := time.NewTimer(readSweepStartWait) defer startTimer.Stop() + look := time.NewTicker(readSweepMoveLook) + defer look.Stop() for { node := a.graph().node(id) if node == nil { - return "", false + return "", false, false } switch node.stateNow() { case TaskDone: - return node.result().text, true + return node.result().text, true, false case TaskFailed: - return "", false + return "", false, false case TaskQueued: // Verified via startTimer below. } @@ -282,17 +312,28 @@ func (a *Agent) awaitQuickAnswer(ctx context.Context, id uint64) (string, bool) // done is per-attempt and reopened on a retry, so a closed channel // is a reason to look at the state again, never a verdict. case <-ctx.Done(): - return "", false + return "", false, false + case <-look.C: + if a.moveAsked.Load() { + return "", false, true + } case <-startTimer.C: if node.stateNow() == TaskQueued { - return "", false + return "", false, false } case <-timer.C: - return "", false + return "", false, false } } } +// readSweepMoveLook is how often a wait on a reading asks whether the person +// moved the reply. +const readSweepMoveLook = 100 * time.Millisecond + +// sweepMovedWord is what a reading let go for a move answers. +const sweepMovedWord = "not read here: the reply is moving to a task, which reads what it needs" + // sweepBrief is the whole of what the worker knows: the person's question, the // reading already done, and the calls to perform first. A quick task opens // cold — no transcript, no inherit — so the question travels in the brief or diff --git a/internal/session/route_judge.go b/internal/session/route_judge.go index 9b13aec171..c09be03da4 100644 --- a/internal/session/route_judge.go +++ b/internal/session/route_judge.go @@ -1120,6 +1120,12 @@ func (a *Agent) confirmRouteAhead(ctx context.Context, asked string) (routeVerdi // things this function decides — a done-condition, a place on the ground // ladder, a name, a width to arm — are the things a quick node does not have. func (a *Agent) launchRouteTask(hub *eventHub, verdict routeVerdict, title string, drawn drawnDivision, ahead *nameAhead) (string, uint64) { + return a.launchRouteTaskTold(hub, verdict, title, drawn, ahead, true) +} + +// launchRouteTaskTold is [Agent.launchRouteTask], saying the told-after line +// only when tell is set: a hand-over has already said its own. +func (a *Agent) launchRouteTaskTold(hub *eventHub, verdict routeVerdict, title string, drawn drawnDivision, ahead *nameAhead, tell bool) (string, uint64) { // THE LAST LINE OF THE FLOOR (spawnfloor.go). Both roads into this function // already return above on a one-command ask; a reserved id for work that // must not start would be the floor leaking a node number into a conversation @@ -1202,7 +1208,9 @@ func (a *Agent) launchRouteTask(hub *eventHub, verdict routeVerdict, title strin // the rest, on the rail. said := "this looked like work, so task " + strconv.FormatUint(id, 10) + " " + word + ": " + spec.title - hub.send(Event{Kind: EventNotice, Text: said}) + if tell { + hub.send(Event{Kind: EventNotice, Text: said}) + } return said, id } diff --git a/internal/session/sessionfile.go b/internal/session/sessionfile.go index 7c26610bc7..5f15cf9e4d 100644 --- a/internal/session/sessionfile.go +++ b/internal/session/sessionfile.go @@ -2800,6 +2800,7 @@ func (s *sessionFile) appendWithReasoning(message ai.Message, note bool, refs [] if presentation == nil && message.Role == "assistant" { presentation = &messagePresentation{Audience: "human"} } + written := stamp() wrote := s.writeLine(sessionEntry{ Presentation: presentation, Type: "message", @@ -2815,7 +2816,7 @@ func (s *sessionFile) appendWithReasoning(message ai.Message, note bool, refs [] Note: note, ReplyTags: tags, Deliveries: deliveries, - Timestamp: stamp(), + Timestamp: written, }) if wrote { s.mu.Lock() @@ -2824,7 +2825,9 @@ func (s *sessionFile) appendWithReasoning(message ai.Message, note bool, refs [] } stamps := s.stamps s.mu.Unlock() - stamps.remember(message, time.Now()) + // The instant the line carries, read back the way a resume reads it, + // so the live reading and a reopened one are the same reading. + stamps.remember(message, lineInstant(written)) } // The ids are indexed only once the line is really on disk: this index is // what a resume trusts to say a landing has already been told. From 3d7669495753454409ef1f8aab231d3d0c718bcd Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 17:15:13 -0400 Subject: [PATCH 020/459] codeaf app: attachments.remove drops one staged attachment; missing ids are fine Co-Authored-By: Claude Sonnet 5.5 --- cmd/codeaf/appconvo.go | 13 ++++++ cmd/codeaf/appconvo_test.go | 38 +++++++++++++++++ .../unreleased/0-app-attachments-remove.md | 12 ++++++ internal/appwire/convo.go | 24 +++++++++++ internal/appwire/convo_test.go | 42 +++++++++++++++++++ 5 files changed, 129 insertions(+) create mode 100644 docs/changes/unreleased/0-app-attachments-remove.md create mode 100644 internal/appwire/convo_test.go diff --git a/cmd/codeaf/appconvo.go b/cmd/codeaf/appconvo.go index 00170cb914..bda3c20cea 100644 --- a/cmd/codeaf/appconvo.go +++ b/cmd/codeaf/appconvo.go @@ -764,6 +764,17 @@ func (b *appBackend) AddAttachment(agent string, attachment appwire.Attachment, return kept.view, nil } +// RemoveAttachment drops one staged attachment and nothing else. A missing or +// already sent id is fine, so cleaning up after a failed send is harmless. +func (b *appBackend) RemoveAttachment(agent, id string) error { + a, err := b.find(agent) + if err != nil { + return err + } + b.locked(func() { delete(a.attach, id) }) + return nil +} + // SendWith sends a message with its attachments, written into the words the // engine is sent, and keeps them on the person's entry. func (b *appBackend) SendWith(agent, text, clientID string, ids []string) (uint64, error) { @@ -774,6 +785,8 @@ func (b *appBackend) SendWith(agent, text, clientID string, ids []string) (uint6 var kept []appAttach var missing string b.locked(func() { + // Every id is checked before any is consumed, so one missing chip + // leaves the other staged ones for the retry. for _, id := range ids { attach, ok := a.attach[id] if !ok { diff --git a/cmd/codeaf/appconvo_test.go b/cmd/codeaf/appconvo_test.go index 72e97a3bd5..12433825bc 100644 --- a/cmd/codeaf/appconvo_test.go +++ b/cmd/codeaf/appconvo_test.go @@ -315,3 +315,41 @@ func TestAppLongOutputIsTrimmedToItsLast512KB(t *testing.T) { t.Fatalf("trimmed to %d bytes, %v", len(data), err) } } + +// STAGED ATTACHMENT CLEANUP: removing one chip leaves the others, removing it +// again is fine, an unknown agent is refused, a send naming a missing id +// consumes nothing, and a good send consumes exactly what it carried. +func TestAppStagedAttachmentsAreRemovedExactly(t *testing.T) { + conv := &fakeConversation{script: []session.Event{{Kind: session.EventTurnDone}}} + backend, _, agent := workBackend(t, conv) + long := strings.Repeat("--- FAIL: TestLoad\n", 20) + one, err := backend.AddAttachment(agent.ID, appwire.Attachment{Kind: appwire.AttachPaste}, long) + if err != nil { + t.Fatal(err) + } + two, err := backend.AddAttachment(agent.ID, appwire.Attachment{Kind: appwire.AttachPaste}, long) + if err != nil { + t.Fatal(err) + } + if err := backend.RemoveAttachment(agent.ID, one.ID); err != nil { + t.Fatal(err) + } + if err := backend.RemoveAttachment(agent.ID, one.ID); err != nil { + t.Fatalf("a repeat remove failed: %v", err) + } + if err := backend.RemoveAttachment("nobody", one.ID); err == nil { + t.Fatal("an unknown agent was accepted") + } + if _, err := backend.SendWith(agent.ID, "hi", "m-1", []string{two.ID, one.ID}); err == nil { + t.Fatal("a send naming a removed attachment succeeded") + } + if _, err := backend.SendWith(agent.ID, "hi", "m-2", []string{two.ID}); err != nil { + t.Fatalf("the other chip was consumed by the failed send: %v", err) + } + if _, err := backend.SendWith(agent.ID, "again", "m-3", []string{two.ID}); err == nil { + t.Fatal("a sent attachment was still staged") + } + if err := backend.RemoveAttachment(agent.ID, two.ID); err != nil { + t.Fatalf("cleanup after a send failed: %v", err) + } +} diff --git a/docs/changes/unreleased/0-app-attachments-remove.md b/docs/changes/unreleased/0-app-attachments-remove.md new file mode 100644 index 0000000000..4091725df5 --- /dev/null +++ b/docs/changes/unreleased/0-app-attachments-remove.md @@ -0,0 +1,12 @@ +--- +kind: added +title: codeaf app drops a staged attachment it will not send +pr: 0 +surface: [engine] +invalidates: + - "A staged attachment stayed on the agent until a send consumed it, so a canceled draft or a partly staged message left chips behind. attachments.remove {agent, id} now drops exactly one; a missing or already sent id is not an error." +--- + +New method `attachments.remove {agent, id}` → `{}`. An engine that does not +keep staged attachments answers `unknown_method`. A send naming an id that is +not staged still consumes none of the others. diff --git a/internal/appwire/convo.go b/internal/appwire/convo.go index a901f6c1f5..f34783592b 100644 --- a/internal/appwire/convo.go +++ b/internal/appwire/convo.go @@ -190,6 +190,17 @@ type Talker interface { TaskEntries(id, before string, limit int) ([]Entry, bool, error) } +// AttachmentRemover is the optional half of attachments: an engine that keeps +// staged attachments lets the app drop one that will not be sent. It is a +// separate interface so a Talker without staging needs no stub; one that is +// not an AttachmentRemover refuses attachments.remove as unknown. +type AttachmentRemover interface { + // RemoveAttachment drops one staged attachment. It is idempotent: an id + // that is missing or already sent is not an error, so cleanup after a + // failed send is harmless. An unknown agent is still an error. + RemoveAttachment(agent, id string) error +} + func init() { for name, m := range talkMethods { methods[name] = m @@ -395,6 +406,19 @@ var talkMethods = map[string]method{ } return &AttachmentResult{attachment}, nil }), + "attachments.remove": typed(func(e Engine, p struct { + Agent string `json:"agent"` + ID string `json:"id"` + }) (*Empty, error) { + r, ok := e.(AttachmentRemover) + if !ok { + return nil, Errorf(CodeUnknownMethod, "this engine does not serve attachments.remove") + } + if strings.TrimSpace(p.Agent) == "" || strings.TrimSpace(p.ID) == "" { + return nil, Errorf(CodeBadParams, "attachments.remove needs an agent and an id") + } + return done(r.RemoveAttachment(p.Agent, p.ID)) + }), "tasks.pause": talkTaskVerb("pause"), "tasks.resume": talkTaskVerb("resume"), "tasks.retry": typed(func(e Engine, p IDParams) (*Empty, error) { diff --git a/internal/appwire/convo_test.go b/internal/appwire/convo_test.go new file mode 100644 index 0000000000..4291e336b2 --- /dev/null +++ b/internal/appwire/convo_test.go @@ -0,0 +1,42 @@ +package appwire + +import ( + "encoding/json" + "errors" + "testing" +) + +// removerEngine is an Engine that also removes staged attachments, recording +// what it was asked to drop. +type removerEngine struct { + fakeEngine + agent, id string +} + +func (r *removerEngine) RemoveAttachment(agent, id string) error { + r.agent, r.id = agent, id + return nil +} + +// ATTACHMENTS.REMOVE reaches an engine that keeps staged attachments, refuses +// an empty id, and is unknown to an engine that does not. +func TestAttachmentsRemoveRoute(t *testing.T) { + m, ok := methods["attachments.remove"] + if !ok { + t.Fatal("attachments.remove is not registered") + } + eng := &removerEngine{} + if _, err := m.call(eng, json.RawMessage(`{"agent":"a1","id":"at2"}`)); err != nil { + t.Fatal(err) + } + if eng.agent != "a1" || eng.id != "at2" { + t.Fatalf("the engine was asked to drop %q %q", eng.agent, eng.id) + } + var wire *Error + if _, err := m.call(eng, json.RawMessage(`{"agent":"a1"}`)); !errors.As(err, &wire) || wire.Code != CodeBadParams { + t.Fatalf("a missing id answered %v", err) + } + if _, err := m.call(&fakeEngine{}, json.RawMessage(`{"agent":"a1","id":"at2"}`)); !errors.As(err, &wire) || wire.Code != CodeUnknownMethod { + t.Fatalf("an engine without staging answered %v", err) + } +} From 1bf54746ecc95b7aa5ca28bfd52644e8cd9f36ff Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 17:28:32 -0400 Subject: [PATCH 021/459] codeaf app: files.read answers a bounded, read-only file preview Canonical path (links followed), outside judged on the resolved path, text only for UTF-8 without NUL up to 1 MiB, pages of at most 64 KiB cut at a line or character, facts only for binary, too large, folders, non-regular and missing paths. Nothing is ever written. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/appfileread.go | 177 +++++++++++++++++++ cmd/codeaf/appfileread_test.go | 184 ++++++++++++++++++++ docs/changes/unreleased/0-app-files-read.md | 14 ++ internal/appwire/convo_test.go | 38 ++++ internal/appwire/fileread.go | 83 +++++++++ 5 files changed, 496 insertions(+) create mode 100644 cmd/codeaf/appfileread.go create mode 100644 cmd/codeaf/appfileread_test.go create mode 100644 docs/changes/unreleased/0-app-files-read.md create mode 100644 internal/appwire/fileread.go diff --git a/cmd/codeaf/appfileread.go b/cmd/codeaf/appfileread.go new file mode 100644 index 0000000000..6b5c7634da --- /dev/null +++ b/cmd/codeaf/appfileread.go @@ -0,0 +1,177 @@ +package main + +import ( + "bytes" + "errors" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "time" + "unicode/utf8" + + "github.com/Agent-Field/codeaf/internal/appwire" +) + +// ReadFile answers files.read: what the path is now, resolved canonically, and +// one bounded page of it when it is UTF-8 text no larger than +// appwire.FileReadMax. Reading follows the session's own law (taskoutside.go): +// anything on the machine may be read, so a file outside the workspace is +// answered and marked outside, never refused and never writable. Nothing is +// opened that is not a regular file, and nothing over the bound is read at all. +func (b *appBackend) ReadFile(agent, path string, offset int64, limit int) (appwire.FileRead, error) { + a, err := b.find(agent) + if err != nil { + return appwire.FileRead{}, err + } + var workspace string + b.locked(func() { workspace = a.view.Workspace }) + full := path + if !filepath.IsAbs(full) { + if workspace == "" { + return appwire.FileRead{}, appwire.Errorf(appwire.CodeBadParams, "this agent has no workspace, so %s needs a full path", path) + } + full = filepath.Join(workspace, full) + } + full = filepath.Clean(full) + out := appwire.FileRead{Path: path, Resolved: full, Offset: offset} + changed := b.changedInChat(a) + counts := func(keys ...string) { + for _, k := range keys { + if c, ok := changed[k]; ok { + add, del := c.Add, c.Del + out.Add, out.Del = &add, &del + return + } + } + } + counts(full, path) + root := appCanonical(workspace) + + link, err := os.Lstat(full) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + out.Kind = appwire.ReadMissing + } else { + out.Kind = appwire.ReadUnreadable + } + out.Outside = !appWithin(root, appCanonical(full)) + return out, nil + } + out.Symlink = link.Mode()&fs.ModeSymlink != 0 + resolved, err := filepath.EvalSymlinks(full) + if err != nil { + // A link to nothing: what it named is gone. + out.Kind = appwire.ReadMissing + out.Outside = !appWithin(root, appCanonical(full)) + return out, nil + } + out.Resolved = resolved + out.Outside = !appWithin(root, resolved) + counts(resolved) + info, err := os.Stat(resolved) + if err != nil { + out.Kind = appwire.ReadUnreadable + return out, nil + } + out.Modified = info.ModTime().UTC().Format(time.RFC3339) + switch { + case info.IsDir(): + out.Kind = appwire.ReadDirectory + return out, nil + case !info.Mode().IsRegular(): + // A fifo, socket or device: opening it could block or never end. + out.Kind = appwire.ReadUnreadable + return out, nil + } + size := info.Size() + out.Size = &size + if size > appwire.FileReadMax { + out.Kind = appwire.ReadTooLarge + return out, nil + } + file, err := os.Open(resolved) + if err != nil { + out.Kind = appwire.ReadUnreadable + return out, nil + } + data, err := io.ReadAll(io.LimitReader(file, appwire.FileReadMax+1)) + _ = file.Close() + if err != nil { + out.Kind = appwire.ReadUnreadable + return out, nil + } + if len(data) > appwire.FileReadMax { + // It grew past the bound after it was measured. + out.Kind = appwire.ReadTooLarge + return out, nil + } + size = int64(len(data)) + out.Size = &size + if bytes.IndexByte(data, 0) >= 0 || !utf8.Valid(data) { + out.Kind = appwire.ReadBinary + return out, nil + } + out.Kind = appwire.ReadText + lines := bytes.Count(data, []byte{'\n'}) + if len(data) > 0 && data[len(data)-1] != '\n' { + lines++ + } + out.Lines = &lines + if limit <= 0 { + return out, nil + } + if offset > size || (offset < size && !utf8.RuneStart(data[offset])) { + return appwire.FileRead{}, appwire.Errorf(appwire.CodeBadParams, "offset %d is not the start of a page of %s", offset, path) + } + end := min(size, offset+int64(limit)) + if end < size { + // Cut after the last whole line, or else at a character boundary. + if nl := bytes.LastIndexByte(data[offset:end], '\n'); nl >= 0 { + end = offset + int64(nl) + 1 + } else { + for end > offset && !utf8.RuneStart(data[end]) { + end-- + } + } + next := end + out.Next = &next + } + out.Text = string(data[offset:end]) + return out, nil +} + +// appCanonical is path with every link followed, or as cleaned when it (or a +// parent) is not there to follow. +func appCanonical(path string) string { + if path == "" { + return "" + } + if resolved, err := filepath.EvalSymlinks(path); err == nil { + return resolved + } + // Follow the deepest parent that exists, so /tmp/gone under a linked /tmp + // still compares with a linked workspace. + dir, rest := filepath.Clean(path), "" + for dir != filepath.Dir(dir) { + rest = filepath.Join(filepath.Base(dir), rest) + dir = filepath.Dir(dir) + if resolved, err := filepath.EvalSymlinks(dir); err == nil { + return filepath.Join(resolved, rest) + } + } + return filepath.Clean(path) +} + +// appWithin says whether path lies inside root (or is root). An empty root has +// nothing inside it. +func appWithin(root, path string) bool { + if root == "" { + return false + } + rel, err := filepath.Rel(root, path) + return err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel) +} + +var _ appwire.FileReader = (*appBackend)(nil) diff --git a/cmd/codeaf/appfileread_test.go b/cmd/codeaf/appfileread_test.go new file mode 100644 index 0000000000..11b6e3451c --- /dev/null +++ b/cmd/codeaf/appfileread_test.go @@ -0,0 +1,184 @@ +package main + +import ( + "bytes" + "errors" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/session" +) + +func readOK(t *testing.T, b *appBackend, agent, path string, offset int64, limit int) appwire.FileRead { + t.Helper() + got, err := b.ReadFile(agent, path, offset, limit) + if err != nil { + t.Fatalf("files.read %s: %v", path, err) + } + return got +} + +// A FILE CHIP OPENS A BOUNDED, TRUTHFUL, READ-ONLY PREVIEW: a workspace file is +// text with its facts, a file outside is still read but said to be outside, a +// link is followed to where it really is, and a link out of the workspace is +// outside however its path reads. +func TestAppReadFileWorkspaceOutsideAndLinks(t *testing.T) { + backend, _, agent := workBackend(t, &fakeConversation{script: []session.Event{{Kind: session.EventTurnDone}}}) + work := agent.Workspace + if err := os.MkdirAll(filepath.Join(work, "internal", "parse"), 0o755); err != nil { + t.Fatal(err) + } + lexer := filepath.Join(work, "internal", "parse", "lexer.go") + if err := os.WriteFile(lexer, []byte("package parse\n\nfunc Lex() {}\n"), 0o644); err != nil { + t.Fatal(err) + } + got := readOK(t, backend, agent.ID, "internal/parse/lexer.go", 0, appwire.FilePageMax) + if got.Kind != appwire.ReadText || got.Outside || got.Symlink || got.Text != "package parse\n\nfunc Lex() {}\n" || got.Next != nil || got.Lines == nil || *got.Lines != 3 || got.Size == nil || *got.Size != 29 || got.Modified == "" { + t.Fatalf("a workspace file = %+v", got) + } + wantResolved, _ := filepath.EvalSymlinks(lexer) + if got.Resolved != wantResolved { + t.Fatalf("resolved %q, want %q", got.Resolved, wantResolved) + } + // Facts only. + if facts := readOK(t, backend, agent.ID, "internal/parse/lexer.go", 0, 0); facts.Text != "" || facts.Kind != appwire.ReadText || facts.Lines == nil { + t.Fatalf("limit 0 = %+v", facts) + } + + elsewhere := filepath.Join(t.TempDir(), "notes.go") + if err := os.WriteFile(elsewhere, []byte("scratch\n"), 0o644); err != nil { + t.Fatal(err) + } + out := readOK(t, backend, agent.ID, elsewhere, 0, 100) + if out.Kind != appwire.ReadText || !out.Outside || out.Text != "scratch\n" { + t.Fatalf("an absolute path outside = %+v", out) + } + // Climbing out lexically is outside too. + rel, _ := filepath.Rel(work, elsewhere) + if climb := readOK(t, backend, agent.ID, rel, 0, 100); !climb.Outside || climb.Kind != appwire.ReadText { + t.Fatalf("a ../ path = %+v", climb) + } + + // A link inside to a file inside: inside, marked a link, resolved. + if err := os.Symlink(lexer, filepath.Join(work, "lexer-link.go")); err != nil { + t.Fatal(err) + } + in := readOK(t, backend, agent.ID, "lexer-link.go", 0, 100) + if !in.Symlink || in.Outside || in.Resolved != wantResolved || in.Kind != appwire.ReadText { + t.Fatalf("a link inside = %+v", in) + } + // A link inside the workspace to a file outside: outside. + if err := os.Symlink(elsewhere, filepath.Join(work, "escape.go")); err != nil { + t.Fatal(err) + } + esc := readOK(t, backend, agent.ID, "escape.go", 0, 100) + wantElse, _ := filepath.EvalSymlinks(elsewhere) + if !esc.Symlink || !esc.Outside || esc.Resolved != wantElse { + t.Fatalf("a link out = %+v", esc) + } + // A dangling link is missing, not an error. + if err := os.Symlink(filepath.Join(work, "never"), filepath.Join(work, "dangling.go")); err != nil { + t.Fatal(err) + } + if d := readOK(t, backend, agent.ID, "dangling.go", 0, 100); d.Kind != appwire.ReadMissing || !d.Symlink || d.Text != "" { + t.Fatalf("a dangling link = %+v", d) + } +} + +// THE STATES THAT ARE NEVER SHOWN AS TEXT: missing or deleted, a folder, a +// binary, bytes that are not UTF-8, and a file over the bound, which is not +// read at all. An unknown agent is not_found; a leading dash is just a name. +func TestAppReadFileStatesAndBounds(t *testing.T) { + backend, _, agent := workBackend(t, &fakeConversation{script: []session.Event{{Kind: session.EventTurnDone}}}) + work := agent.Workspace + write := func(name string, data []byte) { + t.Helper() + if err := os.WriteFile(filepath.Join(work, name), data, 0o644); err != nil { + t.Fatal(err) + } + } + if got := readOK(t, backend, agent.ID, "gone.go", 0, 100); got.Kind != appwire.ReadMissing || got.Outside || got.Size != nil { + t.Fatalf("missing = %+v", got) + } + write("deleted.go", []byte("x\n")) + if err := os.Remove(filepath.Join(work, "deleted.go")); err != nil { + t.Fatal(err) + } + if got := readOK(t, backend, agent.ID, "deleted.go", 0, 100); got.Kind != appwire.ReadMissing { + t.Fatalf("deleted = %+v", got) + } + if err := os.Mkdir(filepath.Join(work, "src"), 0o755); err != nil { + t.Fatal(err) + } + if got := readOK(t, backend, agent.ID, "src", 0, 100); got.Kind != appwire.ReadDirectory || got.Text != "" { + t.Fatalf("a folder = %+v", got) + } + write("hero.png", []byte("\x89PNG\r\n\x1a\n\x00\x00")) + if got := readOK(t, backend, agent.ID, "hero.png", 0, 100); got.Kind != appwire.ReadBinary || got.Text != "" || got.Size == nil { + t.Fatalf("a binary = %+v", got) + } + write("latin1.txt", []byte("caf\xe9\n")) + if got := readOK(t, backend, agent.ID, "latin1.txt", 0, 100); got.Kind != appwire.ReadBinary || got.Text != "" { + t.Fatalf("invalid UTF-8 became %+v", got) + } + write("huge.log", bytes.Repeat([]byte("a\n"), appwire.FileReadMax/2+1)) + if got := readOK(t, backend, agent.ID, "huge.log", 0, appwire.FilePageMax); got.Kind != appwire.ReadTooLarge || got.Text != "" || got.Size == nil || *got.Size != appwire.FileReadMax+2 || got.Lines != nil { + t.Fatalf("too large = %+v", got) + } + write("-rf", []byte("dash\n")) + if got := readOK(t, backend, agent.ID, "-rf", 0, 100); got.Kind != appwire.ReadText || got.Text != "dash\n" || !filepath.IsAbs(got.Resolved) { + t.Fatalf("a leading dash = %+v", got) + } + var wire *appwire.Error + if _, err := backend.ReadFile("nope", "x", 0, 1); !errors.As(err, &wire) || wire.Code != appwire.CodeNotFound { + t.Fatalf("an unknown agent answered %v", err) + } +} + +// PAGES ARE BOUNDED AND CUT CLEANLY: a page ends after a whole line when it +// can and never inside a character, next names where the following page +// starts, and an offset inside a character is refused rather than mangled. +func TestAppReadFilePagesCutAtLinesAndCharacters(t *testing.T) { + backend, _, agent := workBackend(t, &fakeConversation{script: []session.Event{{Kind: session.EventTurnDone}}}) + work := agent.Workspace + line := strings.Repeat("é", 40) + "\n" // 81 bytes + body := strings.Repeat(line, 2000) // 162,000 bytes: three pages + if err := os.WriteFile(filepath.Join(work, "big.txt"), []byte(body), 0o644); err != nil { + t.Fatal(err) + } + var got strings.Builder + offset, pages := int64(0), 0 + for { + page := readOK(t, backend, agent.ID, "big.txt", offset, appwire.FilePageMax) + pages++ + if len(page.Text) > appwire.FilePageMax || !strings.HasSuffix(page.Text, "\n") { + t.Fatalf("page %d is %d bytes, ends %q", pages, len(page.Text), page.Text[max(0, len(page.Text)-3):]) + } + got.WriteString(page.Text) + if page.Next == nil { + break + } + offset = *page.Next + } + if got.String() != body || pages != 3 { + t.Fatalf("%d pages rebuilt %d of %d bytes", pages, got.Len(), len(body)) + } + // One long line: cut at a character, not inside one. + if err := os.WriteFile(filepath.Join(work, "one.txt"), []byte(strings.Repeat("é", 40000)), 0o644); err != nil { + t.Fatal(err) + } + page := readOK(t, backend, agent.ID, "one.txt", 0, 1001) + if len(page.Text) != 1000 || page.Next == nil || *page.Next != 1000 { + t.Fatalf("a long line page = %d bytes, next %v", len(page.Text), page.Next) + } + var wire *appwire.Error + if _, err := backend.ReadFile(agent.ID, "one.txt", 1, 100); !errors.As(err, &wire) || wire.Code != appwire.CodeBadParams { + t.Fatalf("an offset inside a character answered %v", err) + } + if _, err := backend.ReadFile(agent.ID, "one.txt", 1<<30, 100); !errors.As(err, &wire) || wire.Code != appwire.CodeBadParams { + t.Fatalf("an offset past the end answered %v", err) + } +} diff --git a/docs/changes/unreleased/0-app-files-read.md b/docs/changes/unreleased/0-app-files-read.md new file mode 100644 index 0000000000..7c6fe0d626 --- /dev/null +++ b/docs/changes/unreleased/0-app-files-read.md @@ -0,0 +1,14 @@ +--- +kind: added +title: codeaf app answers a bounded, read-only file preview +pr: 0 +surface: [engine] +invalidates: + - "The app could find and stat files but not show one, so a file chip opened nothing. files.read {agent, path, offset, limit} now answers the file's canonical path, whether it lies outside the workspace, its facts, and one page of at most 64 KiB of UTF-8 text from files up to 1 MiB." +--- + +New method `files.read {agent, path, offset, limit}` → `{file}`. Binary, +non-UTF-8, over 1 MiB, folders, fifos and missing paths answer their kind and +facts with no bytes. Links are followed; `outside` is judged on where the file +really is. Nothing is ever written. An engine that does not read answers +`unknown_method`. diff --git a/internal/appwire/convo_test.go b/internal/appwire/convo_test.go index 4291e336b2..1b9bf5a231 100644 --- a/internal/appwire/convo_test.go +++ b/internal/appwire/convo_test.go @@ -40,3 +40,41 @@ func TestAttachmentsRemoveRoute(t *testing.T) { t.Fatalf("an engine without staging answered %v", err) } } + +// readerEngine is an Engine that reads files, recording what it was asked. +type readerEngine struct { + fakeEngine + agent, path string + offset int64 + limit int +} + +func (r *readerEngine) ReadFile(agent, path string, offset int64, limit int) (FileRead, error) { + r.agent, r.path, r.offset, r.limit = agent, path, offset, limit + return FileRead{Path: path, Kind: ReadText}, nil +} + +// FILES.READ reaches an engine that reads, clamps the page to FilePageMax, +// refuses a missing path or a negative offset, and is unknown elsewhere. +func TestFilesReadRoute(t *testing.T) { + m, ok := methods["files.read"] + if !ok { + t.Fatal("files.read is not registered") + } + eng := &readerEngine{} + if _, err := m.call(eng, json.RawMessage(`{"agent":"a1","path":"-x.go","offset":4,"limit":999999}`)); err != nil { + t.Fatal(err) + } + if eng.agent != "a1" || eng.path != "-x.go" || eng.offset != 4 || eng.limit != FilePageMax { + t.Fatalf("the engine was asked %+v", eng) + } + var wire *Error + for _, bad := range []string{`{"agent":"a1"}`, `{"agent":"a1","path":"x","offset":-1}`, `{"agent":"a1","path":"x","limit":-1}`} { + if _, err := m.call(eng, json.RawMessage(bad)); !errors.As(err, &wire) || wire.Code != CodeBadParams { + t.Fatalf("%s answered %v", bad, err) + } + } + if _, err := m.call(&fakeEngine{}, json.RawMessage(`{"agent":"a1","path":"x"}`)); !errors.As(err, &wire) || wire.Code != CodeUnknownMethod { + t.Fatalf("an engine without reading answered %v", err) + } +} diff --git a/internal/appwire/fileread.go b/internal/appwire/fileread.go new file mode 100644 index 0000000000..2a451c3d6d --- /dev/null +++ b/internal/appwire/fileread.go @@ -0,0 +1,83 @@ +package appwire + +import "strings" + +// FILES.READ: a file chip opens a read-only preview (V63, V70 "File too large"). +// The app never writes through this door and never holds a whole file: a text +// file of at most [FileReadMax] bytes is answered one page of at most +// [FilePageMax] bytes at a time; anything else is answered by its facts alone. + +// The bounds of files.read. +const ( + // FileReadMax is the largest file previewed as text. Larger is too_large: + // facts only, no bytes, whatever page is asked for. + FileReadMax = 1 << 20 + // FilePageMax is the most text one answer carries. + FilePageMax = 64 << 10 +) + +// What a read found. +const ( + ReadText = "text" // UTF-8 text without NUL, at most FileReadMax + ReadBinary = "binary" // a NUL byte or bytes that are not UTF-8; never shown + ReadTooLarge = "too_large" // over FileReadMax; never read + ReadDirectory = "directory" // a folder; never read + ReadMissing = "missing" // nothing there now (deleted, or a dangling link) + ReadUnreadable = "unreadable" // there, but not a regular file, or not readable +) + +// FileRead is one path as files.read finds it, and a page of its text when it +// is text. Resolved is the canonical absolute path, every link followed; +// Outside says it lies outside the agent's workspace once resolved. The +// preview is read-only wherever the file is. +type FileRead struct { + Path string `json:"path"` + Resolved string `json:"resolved,omitempty"` + Kind string `json:"kind"` + Outside bool `json:"outside,omitempty"` + Symlink bool `json:"symlink,omitempty"` + Size *int64 `json:"size,omitempty"` + Modified string `json:"modified,omitempty"` + Add *int `json:"add,omitempty"` + Del *int `json:"del,omitempty"` + Lines *int `json:"lines,omitempty"` + Offset int64 `json:"offset"` + Text string `json:"text"` + Next *int64 `json:"next,omitempty"` +} + +// FileReader is the optional engine half of files.read. One that is not a +// FileReader refuses files.read as unknown. +type FileReader interface { + // ReadFile answers path (relative to the agent's workspace, or absolute) + // and, for text, the page from offset of at most limit bytes, cut at a + // line or character boundary. A limit of zero answers the facts alone. + ReadFile(agent, path string, offset int64, limit int) (FileRead, error) +} + +// FileReadResult is files.read's answer. +type FileReadResult struct { + File FileRead `json:"file"` +} + +func init() { + methods["files.read"] = typed(func(e Engine, p struct { + Agent string `json:"agent"` + Path string `json:"path"` + Offset int64 `json:"offset"` + Limit int `json:"limit"` + }) (*FileReadResult, error) { + r, ok := e.(FileReader) + if !ok { + return nil, Errorf(CodeUnknownMethod, "this engine does not serve files.read") + } + if strings.TrimSpace(p.Agent) == "" || strings.TrimSpace(p.Path) == "" || p.Offset < 0 || p.Limit < 0 { + return nil, Errorf(CodeBadParams, "files.read needs an agent, a path, and an offset and limit of zero or more") + } + file, err := r.ReadFile(p.Agent, p.Path, p.Offset, min(p.Limit, FilePageMax)) + if err != nil { + return nil, err + } + return &FileReadResult{file}, nil + }) +} From 478c8d509ce6ec7986a6f7f0a7db7a93d66a490e Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 17:30:21 -0400 Subject: [PATCH 022/459] codeaf app: reject nonprogressing file pages and enforce read bounds --- cmd/codeaf/appfileread.go | 7 +++++++ cmd/codeaf/appfileread_test.go | 12 ++++++++++++ 2 files changed, 19 insertions(+) diff --git a/cmd/codeaf/appfileread.go b/cmd/codeaf/appfileread.go index 6b5c7634da..a61fa2a952 100644 --- a/cmd/codeaf/appfileread.go +++ b/cmd/codeaf/appfileread.go @@ -21,6 +21,10 @@ import ( // answered and marked outside, never refused and never writable. Nothing is // opened that is not a regular file, and nothing over the bound is read at all. func (b *appBackend) ReadFile(agent, path string, offset int64, limit int) (appwire.FileRead, error) { + if offset < 0 || limit < 0 { + return appwire.FileRead{}, appwire.Errorf(appwire.CodeBadParams, "files.read needs a non-negative offset and page limit") + } + limit = min(limit, appwire.FilePageMax) a, err := b.find(agent) if err != nil { return appwire.FileRead{}, err @@ -135,6 +139,9 @@ func (b *appBackend) ReadFile(agent, path string, offset int64, limit int) (appw end-- } } + if end == offset { + return appwire.FileRead{}, appwire.Errorf(appwire.CodeBadParams, "page limit is too small for the next UTF-8 character") + } next := end out.Next = &next } diff --git a/cmd/codeaf/appfileread_test.go b/cmd/codeaf/appfileread_test.go index 11b6e3451c..b3d723059f 100644 --- a/cmd/codeaf/appfileread_test.go +++ b/cmd/codeaf/appfileread_test.go @@ -175,6 +175,18 @@ func TestAppReadFilePagesCutAtLinesAndCharacters(t *testing.T) { t.Fatalf("a long line page = %d bytes, next %v", len(page.Text), page.Next) } var wire *appwire.Error + for _, p := range []struct { + offset int64 + limit int + }{{0, 1}, {-1, 100}, {0, -1}} { + if _, err := backend.ReadFile(agent.ID, "one.txt", p.offset, p.limit); !errors.As(err, &wire) || wire.Code != appwire.CodeBadParams { + t.Fatalf("invalid page %+v answered %v", p, err) + } + } + bounded := readOK(t, backend, agent.ID, "one.txt", 0, appwire.FilePageMax*2) + if len(bounded.Text) > appwire.FilePageMax || bounded.Next == nil || *bounded.Next <= 0 { + t.Fatalf("direct read bypassed page bound: %d bytes, next %v", len(bounded.Text), bounded.Next) + } if _, err := backend.ReadFile(agent.ID, "one.txt", 1, 100); !errors.As(err, &wire) || wire.Code != appwire.CodeBadParams { t.Fatalf("an offset inside a character answered %v", err) } From d22dc2b7facf6e170f3960f78e934a31cb0be933 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 17:39:10 -0400 Subject: [PATCH 023/459] codeaf app: preserve file modification precision for paged previews --- cmd/codeaf/appfileread.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cmd/codeaf/appfileread.go b/cmd/codeaf/appfileread.go index a61fa2a952..e193fb087b 100644 --- a/cmd/codeaf/appfileread.go +++ b/cmd/codeaf/appfileread.go @@ -79,7 +79,7 @@ func (b *appBackend) ReadFile(agent, path string, offset int64, limit int) (appw out.Kind = appwire.ReadUnreadable return out, nil } - out.Modified = info.ModTime().UTC().Format(time.RFC3339) + out.Modified = info.ModTime().UTC().Format(time.RFC3339Nano) switch { case info.IsDir(): out.Kind = appwire.ReadDirectory From 0c509ab7dd90f7f26d461d003956fb6f44601d98 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 17:49:24 -0400 Subject: [PATCH 024/459] teams: crews.update clear:["hue"] leaves a crew uncoloured, and it stays so across loads Co-Authored-By: Claude Sonnet 5.5 --- cmd/codeaf/appcrews.go | 10 +++++++ cmd/codeaf/appcrews_test.go | 35 +++++++++++++++++++++++ internal/appwire/crews.go | 4 ++- internal/teams/hue_clear_test.go | 49 ++++++++++++++++++++++++++++++++ internal/teams/teams.go | 23 ++++++++++++--- 5 files changed, 116 insertions(+), 5 deletions(-) create mode 100644 internal/teams/hue_clear_test.go diff --git a/cmd/codeaf/appcrews.go b/cmd/codeaf/appcrews.go index 5eeab81b15..996197724e 100644 --- a/cmd/codeaf/appcrews.go +++ b/cmd/codeaf/appcrews.go @@ -1229,6 +1229,13 @@ func (b *appBackend) CrewUpdate(ask appwire.CrewUpdate) (appwire.Crew, error) { if err != nil { return appwire.Crew{}, err } + clearHue := false + for _, key := range ask.Clear { + clearHue = clearHue || key == "hue" + } + if clearHue && (ask.Hue != nil || strings.TrimSpace(tint) != "") { + return appwire.Crew{}, appwire.Errorf(appwire.CodeBadParams, "a crew cannot be given a colour and have it cleared in one update") + } err = b.crewEdit(func(f *teamstore.File) error { i := teamstore.Index(f.Teams, ask.ID) if i < 0 { @@ -1249,6 +1256,9 @@ func (b *appBackend) CrewUpdate(ask appwire.CrewUpdate) (appwire.Crew, error) { if hue != nil { f.Teams[i].SetHue(teamstore.HueSpec{Hue: *hue, Tier: f.Teams[i].Tier}) } + if clearHue { + f.Teams[i].ClearHue() + } if ask.Settings != nil || len(ask.Clear) > 0 { return f.SetSettings(ask.ID, func(s *teamstore.Settings) { if ask.Settings != nil { diff --git a/cmd/codeaf/appcrews_test.go b/cmd/codeaf/appcrews_test.go index bdedda1986..96a10c490b 100644 --- a/cmd/codeaf/appcrews_test.go +++ b/cmd/codeaf/appcrews_test.go @@ -282,3 +282,38 @@ func TestAppCrewProposalIsAskedAgainWhole(t *testing.T) { t.Fatalf("the proposal's ask is not listed: %+v", asks) } } + +// CLEARING A CREW'S HUE LEAVES IT UNCOLOURED ACROSS RELOADS, A LATER TINT +// COLOURS IT AGAIN, AND ASKING FOR BOTH IN ONE UPDATE IS REFUSED. +func TestAppCrewUpdateClearHuePersistsAndConflictsAreRefused(t *testing.T) { + backend, _, _, id := crewBackend(t) + sage, rose := "sage", "rose" + if _, err := backend.CrewUpdate(appwire.CrewUpdate{ID: id, Tint: &sage}); err != nil { + t.Fatal(err) + } + hue := 40.0 + for _, ask := range []appwire.CrewUpdate{ + {ID: id, Hue: &hue, Clear: []string{"hue"}}, + {ID: id, Tint: &rose, Clear: []string{"hue"}}, + } { + if _, err := backend.CrewUpdate(ask); err == nil { + t.Fatalf("%+v was accepted", ask) + } + } + crew, err := backend.CrewUpdate(appwire.CrewUpdate{ID: id, Clear: []string{"hue"}}) + if err != nil || crew.Tint != appTintGraphite { + t.Fatalf("cleared crew = %+v, %v", crew, err) + } + for i := 0; i < 2; i++ { + f, err := teamstore.LoadHued(backend.profileDir, nil) + if err != nil { + t.Fatal(err) + } + if tm := f.Teams[teamstore.Index(f.Teams, id)]; tm.Hued() || !tm.HueCleared() { + t.Fatalf("reload %d: %+v", i, tm) + } + } + if crew, err = backend.CrewUpdate(appwire.CrewUpdate{ID: id, Tint: &rose}); err != nil || crew.Tint != "rose" { + t.Fatalf("recoloured crew = %+v, %v", crew, err) + } +} diff --git a/internal/appwire/crews.go b/internal/appwire/crews.go index 9e50fe91e8..91990ee96f 100644 --- a/internal/appwire/crews.go +++ b/internal/appwire/crews.go @@ -340,7 +340,9 @@ type CrewUpdate struct { Hue *float64 `json:"hue"` Tint *string `json:"tint"` Settings *CrewSettings `json:"settings"` - // Clear names settings to set back to inherit. + // Clear names settings to set back to inherit. The word "hue" clears the + // crew's colour instead; it stays uncoloured until a hue or tint is set, and + // it cannot be sent with one. Clear []string `json:"clear"` Goal *string `json:"goal"` EndsAt *time.Time `json:"ends_at"` diff --git a/internal/teams/hue_clear_test.go b/internal/teams/hue_clear_test.go new file mode 100644 index 0000000000..40f811c1d7 --- /dev/null +++ b/internal/teams/hue_clear_test.go @@ -0,0 +1,49 @@ +package teams + +import "testing" + +// A CLEARED COLOUR STAYS CLEARED ACROSS A LOAD, AND A SET COLOUR (HUE 0 +// INCLUDED) COLOURS THE TEAM AGAIN. +func TestClearedHueSurvivesLoadHuedAndSetHueRestores(t *testing.T) { + dir := t.TempDir() + var id, other string + err := Update(dir, func(f *File) error { + a := Team{ID: NewID(), Name: "plain"} + b := Team{ID: NewID(), Name: "never cleared"} + a.SetHue(HueSpec{Hue: 200, Tier: 1}) + a.ClearHue() + id, other = a.ID, b.ID + f.Teams = append(f.Teams, a, b) + return nil + }) + if err != nil { + t.Fatal(err) + } + for i := 0; i < 2; i++ { + f, err := LoadHued(dir, reservedForTest) + if err != nil { + t.Fatal(err) + } + a, b := f.Teams[Index(f.Teams, id)], f.Teams[Index(f.Teams, other)] + if a.Hued() || !a.HueCleared() || a.Hue != 0 || a.Tier != 0 { + t.Fatalf("load %d: cleared team = %+v", i, a) + } + if !b.Hued() { + t.Fatalf("load %d: a team never cleared must still be coloured: %+v", i, b) + } + } + err = Update(dir, func(f *File) error { + f.Teams[Index(f.Teams, id)].SetHue(HueSpec{Hue: 0}) + return nil + }) + if err != nil { + t.Fatal(err) + } + f, err := LoadHued(dir, reservedForTest) + if err != nil { + t.Fatal(err) + } + if a := f.Teams[Index(f.Teams, id)]; !a.Hued() || a.HueCleared() || a.Hue != 0 { + t.Fatalf("hue 0 after a clear = %+v", a) + } +} diff --git a/internal/teams/teams.go b/internal/teams/teams.go index e0d250f69f..c76ac5c7fd 100644 --- a/internal/teams/teams.go +++ b/internal/teams/teams.go @@ -98,7 +98,10 @@ type Team struct { // hued says the team has a colour: the file gave it one or [Team.SetHue] // did. A hue of 0 is a real hue, so absence is kept apart from the value. // extra is every field a later build wrote that this one does not know. - hued bool + hued bool + // noHue says the colour was cleared on purpose, so [File.Colour] leaves the + // team uncoloured instead of handing it a fresh one on the next load. + noHue bool extra map[string]json.RawMessage } @@ -114,7 +117,7 @@ type File struct { // knownFields is every key [Team] reads itself. var knownFields = map[string]bool{ "id": true, "name": true, "parent": true, "members": true, "manager": true, - "hue": true, "tier": true, "made": true, + "hue": true, "tier": true, "hue_cleared": true, "made": true, "state": true, "closed_at": true, "closed_with": true, "report": true, "root": true, "questions_up": true, "cap_usd_day": true, "depth_limit": true, "sub_share": true, "wake": true, "wrap": true, "former_members": true, "former_manager": true, "starts": true, @@ -132,6 +135,7 @@ type wireTeam struct { FormerManager string `json:"former_manager,omitempty"` Manager string `json:"manager"` Hue *float64 `json:"hue,omitempty"` + HueCleared bool `json:"hue_cleared,omitempty"` Tier *int `json:"tier,omitempty"` Made time.Time `json:"made"` // The lifecycle, written only for a closed team. @@ -171,6 +175,9 @@ func (t *Team) UnmarshalJSON(raw []byte) error { if w.Tier != nil { t.Tier = *w.Tier } + // A colour wins over the marker: a build that predates the marker may have + // coloured the team again, and that colour is the later word. + t.noHue = w.HueCleared && w.Hue == nil for k, v := range all { if knownFields[k] { continue @@ -201,6 +208,7 @@ func (t Team) MarshalJSON() ([]byte, error) { hue, tier := t.Hue, t.Tier w.Hue, w.Tier = &hue, &tier } + w.HueCleared = t.noHue && !t.Hued() raw, err := json.Marshal(w) if err != nil || len(t.extra) == 0 { return raw, err @@ -236,7 +244,14 @@ func (t Team) Hued() bool { return t.hued || t.Hue != 0 || t.Tier != 0 } func (t Team) HueSpec() HueSpec { return HueSpec{Hue: t.Hue, Tier: t.Tier} } // SetHue gives the team a colour. -func (t *Team) SetHue(h HueSpec) { t.Hue, t.Tier, t.hued = h.Hue, h.Tier, true } +func (t *Team) SetHue(h HueSpec) { t.Hue, t.Tier, t.hued, t.noHue = h.Hue, h.Tier, true, false } + +// ClearHue takes the team's colour away and remembers that it was meant, so +// [File.Colour] does not give it another. [Team.SetHue] colours it again. +func (t *Team) ClearHue() { t.Hue, t.Tier, t.hued, t.noHue = 0, 0, false, true } + +// HueCleared reports whether the team's colour was cleared on purpose. +func (t Team) HueCleared() bool { return t.noHue && !t.Hued() } // Holds reports whether key is one of the team's members. func (t Team) Holds(key string) bool { return t.member(key) >= 0 } @@ -607,7 +622,7 @@ func (f *File) Colour(reserved []float64) bool { } changed := false for i := range f.Teams { - if f.Teams[i].Hued() { + if f.Teams[i].Hued() || f.Teams[i].HueCleared() { continue } next := NextHue(used, reserved) From 6e3b7a7a73d3d7630e8f43910005d3f176951817 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 17:54:03 -0400 Subject: [PATCH 025/459] codeaf app: files.diff answers a bounded, read-only diff of one file files.diff {agent, path, base?} compares the working tree (staged and unstaged) with HEAD, or with a named commit resolved once to its hash. A base that names no commit falls back to HEAD and says so; one that does says whether it is in HEAD's history. Untracked, ignored, outside git, no commit yet, missing, binary, non-UTF-8 and too-large sides answer their facts without a body. Git runs with optional locks off, no external diff, textconv or fsmonitor; filtered files are refused. One 3 s deadline per diff, output counted as it arrives and the process killed past 256 KiB or 2,000 lines, and at most two diffs at once. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/appfilediff.go | 575 ++++++++++++++++++++ cmd/codeaf/appfilediff_review_test.go | 40 ++ cmd/codeaf/appfilediff_test.go | 298 ++++++++++ docs/changes/unreleased/0-app-files-diff.md | 18 + internal/appwire/convo_test.go | 36 ++ internal/appwire/filediff.go | 146 +++++ 6 files changed, 1113 insertions(+) create mode 100644 cmd/codeaf/appfilediff.go create mode 100644 cmd/codeaf/appfilediff_review_test.go create mode 100644 cmd/codeaf/appfilediff_test.go create mode 100644 docs/changes/unreleased/0-app-files-diff.md create mode 100644 internal/appwire/filediff.go diff --git a/cmd/codeaf/appfilediff.go b/cmd/codeaf/appfilediff.go new file mode 100644 index 0000000000..969bb65f63 --- /dev/null +++ b/cmd/codeaf/appfilediff.go @@ -0,0 +1,575 @@ +package main + +import ( + "bytes" + "context" + "errors" + "io" + "io/fs" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "time" + "unicode/utf8" + + "github.com/Agent-Field/codeaf/internal/appwire" +) + +// FILES.DIFF IS READ-ONLY GIT, BOUNDED IN TIME, IN OUTPUT AND IN NUMBER. +// +// Every process this file starts is a git that reads: rev-parse, ls-files, +// ls-tree, check-attr, diff-index and diff, told GIT_OPTIONAL_LOCKS=0 so not +// even the index's stat cache is written back. Nothing in the repository's own +// configuration may make one of them run another program: an external diff and +// a textconv are switched off by flag, fsmonitor by -c, and a file whose +// attributes send it through a filter is answered unavailable rather than +// diffed, because diffing a worktree file runs its clean filter. The person's +// global and system configuration are left alone otherwise. +// +// All git processes of one diff share one deadline of FileDiffSeconds. Their +// output is counted as it arrives, and the moment it passes what can be shown +// the process is killed; nothing is collected first and cut afterwards. At +// most FileDiffJobs diffs run at once, and one more is answered busy at once +// rather than queued, so repeated requests cannot pile up processes. + +// appDiffSlots holds one token per diff being read. +var appDiffSlots = make(chan struct{}, appwire.FileDiffJobs) + +// appGitErr is stderr kept from one git process, for the first line of a +// refusal: no more than this is held whatever git writes. +const appGitErr = 4 << 10 + +// DiffFile answers files.diff for path, relative to the agent's workspace or +// absolute, compared with base (HEAD when empty). +func (b *appBackend) DiffFile(agent, path, base string) (appwire.FileDiff, error) { + if !appwire.ValidDiffBase(base) { + return appwire.FileDiff{}, appwire.Errorf(appwire.CodeBadParams, "files.diff base must be a commit or ref name") + } + a, err := b.find(agent) + if err != nil { + return appwire.FileDiff{}, err + } + var workspace string + b.locked(func() { workspace = a.view.Workspace }) + full := path + if !filepath.IsAbs(full) { + if workspace == "" { + return appwire.FileDiff{}, appwire.Errorf(appwire.CodeBadParams, "this agent has no workspace, so %s needs a full path", path) + } + full = filepath.Join(workspace, full) + } + select { + case appDiffSlots <- struct{}{}: + defer func() { <-appDiffSlots }() + default: + return appwire.FileDiff{}, appwire.Errorf(appwire.CodeBusy, "other diffs are still being read; try again in a moment") + } + ctx, cancel := context.WithTimeout(context.Background(), appwire.FileDiffSeconds*time.Second) + defer cancel() + return appDiff(ctx, path, filepath.Clean(full), appCanonical(workspace), base), nil +} + +// appDiff is the whole answer for full (absolute, cleaned) under root, the +// canonical workspace, compared with requested when it names a commit and with +// HEAD otherwise. requested has passed appwire.ValidDiffBase. +func appDiff(ctx context.Context, path, full, root, requested string) appwire.FileDiff { + out := appwire.FileDiff{Path: path, Resolved: full} + resolved, err := filepath.EvalSymlinks(full) + exists := err == nil + if !exists { + // Deleted, or a link to nothing: judged where it was. + resolved = appCanonical(full) + } + out.Resolved = resolved + out.Outside = !appWithin(root, resolved) + if exists { + if info, err := os.Stat(resolved); err == nil && info.IsDir() { + out.State, out.Reason, out.Detail = appwire.DiffUnavailable, appwire.DiffReasonGitFailed, "a folder has no diff of its own" + return out + } + } + dir := filepath.Dir(resolved) + for { + if info, err := os.Stat(dir); err == nil && info.IsDir() { + break + } + up := filepath.Dir(dir) + if up == dir { + break + } + dir = up + } + g := appGit{ctx: ctx} + + top, fail := g.out(dir, 4<<10, "rev-parse", "--show-toplevel") + if fail != nil { + if fail.notRepo() { + out.State, out.Folder = appwire.DiffNotGit, filepath.Dir(resolved) + if !exists { + out.State = appwire.DiffMissing + } + return out + } + return fail.answer(out) + } + top = strings.TrimSuffix(top, "\n") + if canon, err := filepath.EvalSymlinks(top); err == nil { + top = canon + } + rel, err := filepath.Rel(top, resolved) + if err != nil || rel == "." || strings.HasPrefix(rel, ".."+string(filepath.Separator)) || rel == ".." { + out.State, out.Folder = appwire.DiffNotGit, filepath.Dir(resolved) + return out + } + rel = filepath.ToSlash(rel) + out.Repo = top + + head, fail := g.out(top, 256, "rev-parse", "-q", "--verify", "HEAD^{commit}") + if fail != nil { + if fail.exit == 1 { + out.State = appwire.DiffNoCommits + return out + } + return fail.answer(out) + } + base := strings.TrimSpace(head) + out.Base, out.Commit = appwire.DiffBaseHead, base + if requested != "" { + out.Requested = requested + // Resolved to its hash here, and only the hash is handed on, so the + // name is read as a revision exactly once, after --end-of-options. + named, fail := g.out(top, 256, "rev-parse", "-q", "--verify", "--end-of-options", requested+"^{commit}") + switch { + case fail == nil && appHash(strings.TrimSpace(named)): + base = strings.TrimSpace(named) + out.Base, out.Commit = appwire.DiffBaseRequested, base + _, fail := g.out(top, 256, "merge-base", "--is-ancestor", base, strings.TrimSpace(head)) + if fail != nil && fail.exit != 1 { + return fail.answer(out) + } + inHistory := fail == nil + out.InHistory = &inHistory + case fail == nil || fail.exit == 1 || fail.exit == 128: + out.RequestedUnavailable = appwire.DiffRequestedUnknown + default: + return fail.answer(out) + } + } + + inIndex, fail := g.out(top, 64<<10, "ls-files", "-z", "--cached", "--", rel) + if fail != nil { + return fail.answer(out) + } + inBase, fail := g.out(top, 64<<10, "ls-tree", "-z", "-l", base, "--", rel) + if fail != nil { + return fail.answer(out) + } + if inIndex == "" && inBase == "" { + out.Base, out.Commit, out.InHistory = "", "", nil + if !exists { + out.State = appwire.DiffMissing + return out + } + others, fail := g.out(top, 64<<10, "ls-files", "-z", "--others", "--exclude-standard", "--", rel) + if fail != nil { + return fail.answer(out) + } + if others == "" { + out.State = appwire.DiffIgnored + return out + } + return appUntracked(out, resolved) + } + + attrs, fail := g.out(top, 4<<10, "check-attr", "-z", "filter", "--", rel) + if fail != nil { + return fail.answer(out) + } + if f := strings.Split(attrs, "\x00"); len(f) >= 3 && f[2] != "unspecified" && f[2] != "unset" { + out.State, out.Reason, out.Detail = appwire.DiffUnavailable, appwire.DiffReasonFilter, f[2] + return out + } + + paths := []string{rel} + if inBase == "" { + // Added since the base: a staged rename names where it came from. + renames, fail := g.out(top, 256<<10, "diff-index", "--cached", "-M", "-z", "--name-status", "--no-ext-diff", base) + if fail == nil { + if old := appRenamedFrom(renames, rel); old != "" { + out.OldPath, paths = old, []string{old, rel} + } + } else if fail.timedOut { + return fail.answer(out) + } + } + // Bounding git's output does not bound what git reads to produce it, so + // a side over the preview's bound is refused before anything is diffed. + if exists { + if info, err := os.Stat(resolved); err == nil && info.Size() > appwire.FileReadMax { + out.State, out.Reason = appwire.DiffUnavailable, appwire.DiffReasonTooLarge + return out + } + } + if out.OldPath != "" { + if inBase, fail = g.out(top, 64<<10, "ls-tree", "-z", "-l", base, "--", out.OldPath); fail != nil { + return fail.answer(out) + } + } + if appTreeTooLarge(inBase) { + out.State, out.Reason = appwire.DiffUnavailable, appwire.DiffReasonTooLarge + return out + } + args := append([]string{"diff", "--numstat", "-z", "--no-color", "--no-ext-diff", "--no-textconv", "-M", base, "--"}, paths...) + numstat, fail := g.out(top, 64<<10, args...) + if fail != nil { + return fail.answer(out) + } + add, del, binary, found := appNumstat(numstat) + switch { + case !found: + out.State = appwire.DiffUnchanged + return out + case out.OldPath != "": + out.Change = "renamed" + case inBase == "": + out.Change = "added" + case !exists || inIndex == "": + out.Change = "deleted" + default: + out.Change = "modified" + } + out.State = appwire.DiffChanged + if binary { + out.Binary = true + return out + } + out.Add, out.Del = &add, &del + + args = append([]string{"diff", "--no-color", "--no-ext-diff", "--no-textconv", "-M", "--unified=3", base, "--"}, paths...) + hunks, overflow, fail := g.capped(top, appwire.FileDiffMax+16<<10, appwire.FileDiffLines+256, args...) + if fail != nil { + return fail.answer(out) + } + out.Text, out.Lines, out.Truncated = appHunks(hunks) + out.Truncated = out.Truncated || overflow + if !utf8.ValidString(out.Text) { + // Text git took for text but that is not UTF-8 would be altered on the + // way out, so it is answered as binary: facts, no body, no counts. + out.Binary, out.Text, out.Lines, out.Truncated, out.Add, out.Del = true, "", 0, false, nil, nil + } + return out +} + +// appTreeTooLarge says whether a `ls-tree -z -l` entry is a blob over +// FileReadMax. +func appTreeTooLarge(out string) bool { + for _, entry := range strings.Split(out, "\x00") { + meta, _, ok := strings.Cut(entry, "\t") + if !ok { + continue + } + fields := strings.Fields(meta) + if len(fields) == 4 { + if size, err := strconv.ParseInt(fields[3], 10, 64); err == nil && size > appwire.FileReadMax { + return true + } + } + } + return false +} + +// appHash says whether s is a whole SHA-1 or SHA-256 object name. +func appHash(s string) bool { + if len(s) != 40 && len(s) != 64 { + return false + } + for _, r := range s { + if !strings.ContainsRune("0123456789abcdef", r) { + return false + } + } + return true +} + +// appUntracked answers an untracked file as wholly added, from the file itself, +// within the same bounds as files.read: there is no base, so there are no +// hunks to ask git for. +func appUntracked(out appwire.FileDiff, resolved string) appwire.FileDiff { + out.State, out.Base = appwire.DiffUntracked, appwire.DiffBaseNone + info, err := os.Stat(resolved) + if err != nil || !info.Mode().IsRegular() { + return out + } + if info.Size() > appwire.FileReadMax { + out.Reason = appwire.DiffReasonTooLarge + return out + } + file, err := os.Open(resolved) + if err != nil { + return out + } + data, err := io.ReadAll(io.LimitReader(file, appwire.FileReadMax+1)) + _ = file.Close() + if err != nil || len(data) > appwire.FileReadMax { + out.Reason = appwire.DiffReasonTooLarge + return out + } + if bytes.IndexByte(data, 0) >= 0 || !utf8.Valid(data) { + out.Binary = true + return out + } + lines := strings.SplitAfter(string(data), "\n") + if lines[len(lines)-1] == "" { + lines = lines[:len(lines)-1] + } + add, del := len(lines), 0 + out.Add, out.Del = &add, &del + var text strings.Builder + if add > 0 { + text.WriteString("@@ -0,0 +1," + strconv.Itoa(add) + " @@\n") + } + for _, line := range lines { + text.WriteString("+") + text.WriteString(line) + if !strings.HasSuffix(line, "\n") { + text.WriteString("\n\\ No newline at end of file\n") + } + } + out.Text, out.Lines, out.Truncated = appHunks(text.String()) + return out +} + +// appHunks keeps diff output from its first hunk, drops every file header in +// it, and cuts after at most FileDiffLines lines and FileDiffMax bytes. +func appHunks(raw string) (text string, lines int, truncated bool) { + var kept strings.Builder + header := true + for raw != "" { + line := raw + if i := strings.IndexByte(raw, '\n'); i >= 0 { + line, raw = raw[:i+1], raw[i+1:] + } else { + raw = "" + } + switch { + case strings.HasPrefix(line, "diff --git "): + header = true + continue + case strings.HasPrefix(line, "@@"): + header = false + case header: + continue + } + if lines >= appwire.FileDiffLines || kept.Len()+len(line) > appwire.FileDiffMax { + return kept.String(), lines, true + } + kept.WriteString(line) + lines++ + } + return kept.String(), lines, false +} + +// appNumstat totals `diff --numstat -z`; a "-" count is a binary file. +func appNumstat(out string) (add, del int, binary, found bool) { + fields := strings.Split(out, "\x00") + for i := 0; i < len(fields); i++ { + parts := strings.SplitN(fields[i], "\t", 3) + if len(parts) != 3 { + continue + } + found = true + if parts[0] == "-" || parts[1] == "-" { + binary = true + } else { + a, _ := strconv.Atoi(parts[0]) + d, _ := strconv.Atoi(parts[1]) + add, del = add+a, del+d + } + if parts[2] == "" { + // A rename: the two names follow as their own fields. + i += 2 + } + } + return add, del, binary, found +} + +// appRenamedFrom is the source of a rename to rel in `--name-status -z`. +func appRenamedFrom(out, rel string) string { + fields := strings.Split(out, "\x00") + for i := 0; i < len(fields); i++ { + status := fields[i] + switch { + case strings.HasPrefix(status, "R") || strings.HasPrefix(status, "C"): + if i+2 < len(fields) && fields[i+2] == rel && strings.HasPrefix(status, "R") { + return fields[i+1] + } + i += 2 + case status != "": + i++ + } + } + return "" +} + +// appGit runs read-only git under one deadline. +type appGit struct{ ctx context.Context } + +// appGitFail is why one git process did not answer. +type appGitFail struct { + exit int + timedOut bool + missing bool + stderr string +} + +func (f *appGitFail) notRepo() bool { + return f.exit == 128 && (strings.Contains(f.stderr, "not a git repository") || strings.Contains(f.stderr, "must be run in a work tree")) +} + +// answer is the diff unavailable for this failure. +func (f *appGitFail) answer(out appwire.FileDiff) appwire.FileDiff { + out.State = appwire.DiffUnavailable + switch { + case f.timedOut: + out.Reason = appwire.DiffReasonTimeout + case f.missing: + out.Reason = appwire.DiffReasonNoGit + case strings.Contains(f.stderr, "dubious ownership"): + out.Reason = appwire.DiffReasonUnsafe + default: + out.Reason = appwire.DiffReasonGitFailed + out.Detail, _, _ = strings.Cut(strings.TrimSpace(f.stderr), "\n") + } + return out +} + +// out is one git's whole stdout, which must fit in limit bytes. +func (g appGit) out(dir string, limit int, args ...string) (string, *appGitFail) { + text, overflow, fail := g.capped(dir, limit, 0, args...) + if fail == nil && overflow { + return "", &appGitFail{exit: -1, stderr: "git answered more than this preview reads"} + } + return text, fail +} + +// capped runs git in dir and keeps at most limit bytes and, when maxLines is +// above zero, maxLines lines of stdout. Past either the process is killed and +// overflow is true; what was kept is still answered. +func (g appGit) capped(dir string, limit, maxLines int, args ...string) (string, bool, *appGitFail) { + if err := g.ctx.Err(); err != nil { + return "", false, &appGitFail{exit: -1, timedOut: true} + } + ctx, kill := context.WithCancel(g.ctx) + defer kill() + full := append([]string{ + "--no-pager", "--literal-pathspecs", "--no-optional-locks", + "-c", "core.fsmonitor=false", + "-c", "diff.noprefix=false", + "-C", dir, + }, args...) + cmd := exec.CommandContext(ctx, "git", full...) + cmd.Env = appGitEnv() + cmd.Stdin = nil + cmd.WaitDelay = 250 * time.Millisecond + stdout := &appCap{limit: limit, lines: maxLines, kill: kill} + stderr := &appCap{limit: appGitErr} + cmd.Stdout, cmd.Stderr = stdout, stderr + err := cmd.Run() + overflow, text := stdout.held() + if overflow { + return text, true, nil + } + if g.ctx.Err() != nil { + return "", false, &appGitFail{exit: -1, timedOut: true} + } + if err != nil { + fail := &appGitFail{exit: -1, stderr: stderr.String()} + var exitErr *exec.ExitError + switch { + case errors.As(err, &exitErr): + fail.exit = exitErr.ExitCode() + case errors.Is(err, exec.ErrNotFound), errors.Is(err, fs.ErrNotExist): + fail.missing = true + } + return "", false, fail + } + return text, false, nil +} + +// appGitEnv is this process's environment without what would point git at +// another repository or another diff program, and with prompts and optional +// locks off. Configuration locations are the person's own and stay. +func appGitEnv() []string { + drop := map[string]bool{ + "GIT_DIR": true, "GIT_WORK_TREE": true, "GIT_INDEX_FILE": true, "GIT_OBJECT_DIRECTORY": true, + "GIT_ALTERNATE_OBJECT_DIRECTORIES": true, "GIT_COMMON_DIR": true, "GIT_NAMESPACE": true, + "GIT_EXTERNAL_DIFF": true, "GIT_DIFF_OPTS": true, "GIT_PAGER": true, "PAGER": true, + } + env := []string{} + for _, kv := range os.Environ() { + name, _, _ := strings.Cut(kv, "=") + if !drop[name] { + env = append(env, kv) + } + } + return append(env, "GIT_TERMINAL_PROMPT=0", "GIT_OPTIONAL_LOCKS=0", "GIT_PAGER=cat") +} + +// appCap holds at most limit bytes (and lines, when set). Past that it keeps +// nothing more, calls kill once, and still accepts the writes so the copy +// out of the pipe never stalls. +type appCap struct { + mu sync.Mutex + buf bytes.Buffer + limit int + lines int + seen int + over bool + kill func() +} + +func (c *appCap) Write(p []byte) (int, error) { + c.mu.Lock() + defer c.mu.Unlock() + if c.over { + return len(p), nil + } + keep := p + if room := c.limit - c.buf.Len(); len(keep) > room { + keep, c.over = keep[:max(room, 0)], true + } + if c.lines > 0 { + for i, ch := range keep { + if ch == '\n' { + c.seen++ + if c.seen >= c.lines { + keep, c.over = keep[:i+1], true + break + } + } + } + } + c.buf.Write(keep) + if c.over && c.kill != nil { + c.kill() + } + return len(p), nil +} + +// held is what was kept, and whether more was refused. +func (c *appCap) held() (bool, string) { + c.mu.Lock() + defer c.mu.Unlock() + return c.over, c.buf.String() +} + +func (c *appCap) String() string { + c.mu.Lock() + defer c.mu.Unlock() + return c.buf.String() +} + +var _ appwire.FileDiffer = (*appBackend)(nil) diff --git a/cmd/codeaf/appfilediff_review_test.go b/cmd/codeaf/appfilediff_review_test.go new file mode 100644 index 0000000000..e49525f2eb --- /dev/null +++ b/cmd/codeaf/appfilediff_review_test.go @@ -0,0 +1,40 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/appwire" +) + +// A bounded stdout pipe does not bound the memory Git uses while reading a +// very large worktree file or deleted blob. Refuse both before diffing them. +func TestAppDiffReviewLargeTrackedAndDeletedBase(t *testing.T) { + repo := diffRepo(t) + write(t, filepath.Join(repo, "a.txt"), strings.Repeat("x", appwire.FileReadMax+1)) + if d := diffT(t, repo, "a.txt", ""); d.State != appwire.DiffUnavailable || d.Reason != appwire.DiffReasonTooLarge || d.Text != "" { + t.Fatalf("large tracked file was diffed: state=%s reason=%s bytes=%d", d.State, d.Reason, len(d.Text)) + } + gitT(t, repo, "add", "a.txt") + gitT(t, repo, "commit", "-qm", "large base") + if err := os.Remove(filepath.Join(repo, "a.txt")); err != nil { + t.Fatal(err) + } + if d := diffT(t, repo, "a.txt", ""); d.State != appwire.DiffUnavailable || d.Reason != appwire.DiffReasonTooLarge || d.Text != "" { + t.Fatalf("large deleted blob was diffed: state=%s reason=%s bytes=%d", d.State, d.Reason, len(d.Text)) + } +} + +// Git can classify Latin-1 without NUL as text; JSON would silently replace its +// bytes. A preview must refuse that body rather than show altered contents. +func TestAppDiffReviewInvalidUTF8TrackedText(t *testing.T) { + repo := diffRepo(t) + if err := os.WriteFile(filepath.Join(repo, "a.txt"), []byte("caf\xe9\n"), 0o644); err != nil { + t.Fatal(err) + } + if d := diffT(t, repo, "a.txt", ""); !d.Binary || d.Text != "" { + t.Fatalf("invalid UTF-8 became a text diff: binary=%v bytes=%d", d.Binary, len(d.Text)) + } +} diff --git a/cmd/codeaf/appfilediff_test.go b/cmd/codeaf/appfilediff_test.go new file mode 100644 index 0000000000..c6d83cb1bf --- /dev/null +++ b/cmd/codeaf/appfilediff_test.go @@ -0,0 +1,298 @@ +package main + +import ( + "context" + "os" + "os/exec" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/session" +) + +// diffRepo is a fresh repository with one committed file, a.txt, and the +// person's own git configuration kept out of it. +func diffRepo(t *testing.T) string { + t.Helper() + if _, err := exec.LookPath("git"); err != nil { + t.Skip("no git") + } + t.Setenv("GIT_CONFIG_GLOBAL", os.DevNull) + t.Setenv("GIT_CONFIG_NOSYSTEM", "1") + dir := t.TempDir() + gitT(t, dir, "init", "-q", "-b", "main") + write(t, filepath.Join(dir, "a.txt"), "one\ntwo\nthree\n") + gitT(t, dir, "add", "a.txt") + gitT(t, dir, "commit", "-q", "-m", "first") + return dir +} + +func gitT(t *testing.T, dir string, args ...string) string { + t.Helper() + cmd := exec.Command("git", append([]string{"-c", "user.name=t", "-c", "user.email=t@t", "-C", dir}, args...)...) + out, err := cmd.CombinedOutput() + if err != nil { + t.Fatalf("git %v: %v\n%s", args, err, out) + } + return strings.TrimSpace(string(out)) +} + +func write(t *testing.T, path, text string) { + t.Helper() + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, []byte(text), 0o644); err != nil { + t.Fatal(err) + } +} + +func diffT(t *testing.T, repo, rel, base string) appwire.FileDiff { + t.Helper() + ctx, cancel := context.WithTimeout(context.Background(), appwire.FileDiffSeconds*time.Second) + defer cancel() + return appDiff(ctx, rel, filepath.Join(repo, rel), appCanonical(repo), base) +} + +func counts(d appwire.FileDiff) (int, int) { + if d.Add == nil || d.Del == nil { + return -1, -1 + } + return *d.Add, *d.Del +} + +// A TRACKED FILE IS COMPARED WITH HEAD, STAGED AND UNSTAGED TOGETHER, and +// nothing in the repository is written by asking. +func TestAppDiffStagedAndUnstagedAgainstHead(t *testing.T) { + repo := diffRepo(t) + head := gitT(t, repo, "rev-parse", "HEAD") + write(t, filepath.Join(repo, "a.txt"), "one\nTWO\nthree\n") + gitT(t, repo, "add", "a.txt") + write(t, filepath.Join(repo, "a.txt"), "one\nTWO\nthree\nfour\n") + index, _ := os.Stat(filepath.Join(repo, ".git", "index")) + + d := diffT(t, repo, "a.txt", "") + if d.State != appwire.DiffChanged || d.Change != "modified" || d.Base != appwire.DiffBaseHead || d.Commit != head { + t.Fatalf("diff = %+v", d) + } + if a, del := counts(d); a != 2 || del != 1 { + t.Fatalf("counts +%d −%d, want +2 −1", a, del) + } + if !strings.HasPrefix(d.Text, "@@ ") || !strings.Contains(d.Text, "-two\n") || !strings.Contains(d.Text, "+TWO\n") || !strings.Contains(d.Text, "+four\n") || strings.Contains(d.Text, "diff --git") || d.Truncated { + t.Fatalf("text = %q", d.Text) + } + if after, _ := os.Stat(filepath.Join(repo, ".git", "index")); !after.ModTime().Equal(index.ModTime()) { + t.Fatal("the index was written") + } + if same := diffT(t, repo, "a.txt", ""); same.Text != d.Text { + t.Fatal("asking twice answered differently") + } + gitT(t, repo, "commit", "-qam", "second") + if d := diffT(t, repo, "a.txt", ""); d.State != appwire.DiffUnchanged || d.Text != "" { + t.Fatalf("committed = %+v", d) + } +} + +// A REQUESTED BASE IS RESOLVED TO ITS HASH; ONE THAT IS GONE FALLS BACK TO +// HEAD AND SAYS SO; ONE THAT COULD BE READ AS AN OPTION NEVER REACHES GIT. +func TestAppDiffRequestedBase(t *testing.T) { + repo := diffRepo(t) + first := gitT(t, repo, "rev-parse", "HEAD") + write(t, filepath.Join(repo, "a.txt"), "one\ntwo\nthree\nfour\n") + gitT(t, repo, "commit", "-qam", "second") + head := gitT(t, repo, "rev-parse", "HEAD") + + d := diffT(t, repo, "a.txt", first) + if d.Base != appwire.DiffBaseRequested || d.Commit != first || d.InHistory == nil || !*d.InHistory || d.State != appwire.DiffChanged || d.Requested != first { + t.Fatalf("against the first commit = %+v", d) + } + gone := diffT(t, repo, "a.txt", "0123456789abcdef0123456789abcdef01234567") + if gone.Base != appwire.DiffBaseHead || gone.Commit != head || gone.RequestedUnavailable != appwire.DiffRequestedUnknown || gone.State != appwire.DiffUnchanged { + t.Fatalf("a commit that is gone = %+v", gone) + } + // A commit that exists but is not in HEAD's history is still compared. + gitT(t, repo, "checkout", "-q", "-b", "side", first) + write(t, filepath.Join(repo, "a.txt"), "side\n") + gitT(t, repo, "commit", "-qam", "side") + side := gitT(t, repo, "rev-parse", "HEAD") + gitT(t, repo, "checkout", "-q", "main") + if d := diffT(t, repo, "a.txt", side); d.Base != appwire.DiffBaseRequested || d.InHistory == nil || *d.InHistory { + t.Fatalf("a commit off the branch = %+v", d) + } + for _, bad := range []string{"--output=/tmp/x", "-p", "HEAD..main", "a b", "HEAD~1", "x:y", "@{-1}", strings.Repeat("a", 300)} { + if appwire.ValidDiffBase(bad) { + t.Errorf("%q passed as a base", bad) + } + } + for _, good := range []string{"", "main", "v1.2", "refs/heads/main", first} { + if !appwire.ValidDiffBase(good) { + t.Errorf("%q refused as a base", good) + } + } +} + +// DELETED, RENAMED, ADDED, UNTRACKED AND IGNORED FILES, AND A REPOSITORY WITH +// NO COMMIT, EACH ANSWER WHAT THEY ARE. +func TestAppDiffFileStates(t *testing.T) { + repo := diffRepo(t) + write(t, filepath.Join(repo, "b.txt"), "bee\n") + write(t, filepath.Join(repo, ".gitignore"), "*.log\n") + gitT(t, repo, "add", "b.txt", ".gitignore") + gitT(t, repo, "commit", "-qm", "b") + + if err := os.Remove(filepath.Join(repo, "a.txt")); err != nil { + t.Fatal(err) + } + if d := diffT(t, repo, "a.txt", ""); d.State != appwire.DiffChanged || d.Change != "deleted" || !strings.Contains(d.Text, "-three\n") { + t.Fatalf("deleted = %+v", d) + } else if a, del := counts(d); a != 0 || del != 3 { + t.Fatalf("deleted counts +%d −%d", a, del) + } + + gitT(t, repo, "mv", "b.txt", "c.txt") + if d := diffT(t, repo, "c.txt", ""); d.State != appwire.DiffChanged || d.Change != "renamed" || d.OldPath != "b.txt" { + t.Fatalf("renamed = %+v", d) + } + + write(t, filepath.Join(repo, "new.txt"), "x\ny") + if d := diffT(t, repo, "new.txt", ""); d.State != appwire.DiffUntracked || d.Base != appwire.DiffBaseNone || d.Commit != "" || d.Text != "@@ -0,0 +1,2 @@\n+x\n+y\n\\ No newline at end of file\n" { + t.Fatalf("untracked = %+v %q", d, d.Text) + } + gitT(t, repo, "add", "new.txt") + if d := diffT(t, repo, "new.txt", ""); d.State != appwire.DiffChanged || d.Change != "added" { + t.Fatalf("staged new = %+v", d) + } + write(t, filepath.Join(repo, "out.log"), "noise\n") + if d := diffT(t, repo, "out.log", ""); d.State != appwire.DiffIgnored { + t.Fatalf("ignored = %+v", d) + } + if d := diffT(t, repo, "never.txt", ""); d.State != appwire.DiffMissing { + t.Fatalf("missing = %+v", d) + } + write(t, filepath.Join(repo, "blob.bin"), "a\x00b") + gitT(t, repo, "add", "blob.bin") + if d := diffT(t, repo, "blob.bin", ""); d.State != appwire.DiffChanged || !d.Binary || d.Add != nil || d.Text != "" { + t.Fatalf("binary = %+v", d) + } + + empty := t.TempDir() + gitT(t, empty, "init", "-q") + write(t, filepath.Join(empty, "x.txt"), "x\n") + if d := diffT(t, empty, "x.txt", ""); d.State != appwire.DiffNoCommits || d.Text != "" || d.Add != nil { + t.Fatalf("no commit yet = %+v", d) + } +} + +// OUTSIDE GIT THERE IS NO DIFF AND NO COUNT, ONLY WHERE THE FILE IS; A LINK IS +// DIFFED WHERE IT LEADS, AND SAID TO BE OUTSIDE WHEN IT LEADS OUT. +func TestAppDiffOutsideGitAndLinks(t *testing.T) { + plain := t.TempDir() + write(t, filepath.Join(plain, "notes.go"), "scratch\n") + d := diffT(t, plain, "notes.go", "") + if d.State != appwire.DiffNotGit || d.Add != nil || d.Text != "" { + t.Fatalf("outside git = %+v", d) + } + if want, _ := filepath.EvalSymlinks(plain); d.Folder != want { + t.Fatalf("folder %q, want %q", d.Folder, want) + } + + repo := diffRepo(t) + write(t, filepath.Join(repo, "a.txt"), "changed\n") + work := t.TempDir() + if err := os.Symlink(filepath.Join(repo, "a.txt"), filepath.Join(work, "link.txt")); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), time.Second*appwire.FileDiffSeconds) + defer cancel() + l := appDiff(ctx, "link.txt", filepath.Join(work, "link.txt"), appCanonical(work), "") + if !l.Outside || l.State != appwire.DiffChanged || !strings.Contains(l.Text, "+changed\n") { + t.Fatalf("a link out = %+v", l) + } +} + +// NOTHING IN THE REPOSITORY'S CONFIGURATION RUNS A PROGRAM: an external diff +// and a textconv are ignored, and a filtered file is refused, not diffed. +func TestAppDiffRunsNoRepositoryPrograms(t *testing.T) { + repo := diffRepo(t) + mark := filepath.Join(t.TempDir(), "ran") + gitT(t, repo, "config", "diff.external", "touch "+mark) + gitT(t, repo, "config", "diff.conv.textconv", "touch "+mark+";cat") + gitT(t, repo, "config", "filter.evil.clean", "touch "+mark+";cat") + gitT(t, repo, "config", "core.fsmonitor", "touch "+mark+";true") + write(t, filepath.Join(repo, ".gitattributes"), "a.txt diff=conv\n*.f filter=evil\n") + write(t, filepath.Join(repo, "x.f"), "plain\n") + gitT(t, repo, "-c", "filter.evil.clean=cat", "-c", "core.fsmonitor=false", "add", ".gitattributes", "x.f") + gitT(t, repo, "-c", "filter.evil.clean=cat", "-c", "core.fsmonitor=false", "commit", "-qm", "attrs") + write(t, filepath.Join(repo, "a.txt"), "one\n") + write(t, filepath.Join(repo, "x.f"), "changed\n") + + if d := diffT(t, repo, "a.txt", ""); d.State != appwire.DiffChanged || !strings.Contains(d.Text, "-two\n") { + t.Fatalf("textconv file = %+v", d) + } + if d := diffT(t, repo, "x.f", ""); d.State != appwire.DiffUnavailable || d.Reason != appwire.DiffReasonFilter || d.Detail != "evil" { + t.Fatalf("filtered file = %+v", d) + } + if _, err := os.Stat(mark); err == nil { + t.Fatal("a program from the repository's configuration ran") + } +} + +// A DIFF IS CUT AT FileDiffLines AND SAYS SO; the git behind it is killed as +// soon as it has written more than can be shown. +func TestAppDiffBounds(t *testing.T) { + repo := diffRepo(t) + var big strings.Builder + for i := range 3 * appwire.FileDiffLines { + big.WriteString(strings.Repeat("x", 8) + string(rune('a'+i%26)) + "\n") + } + write(t, filepath.Join(repo, "a.txt"), big.String()) + d := diffT(t, repo, "a.txt", "") + if !d.Truncated || d.Lines != appwire.FileDiffLines || strings.Count(d.Text, "\n") != appwire.FileDiffLines || len(d.Text) > appwire.FileDiffMax { + t.Fatalf("lines %d truncated %v bytes %d", d.Lines, d.Truncated, len(d.Text)) + } + if a, _ := counts(d); a != 3*appwire.FileDiffLines { + t.Fatalf("counts are the whole diff's, got +%d", a) + } + + killed := &appCap{limit: 10, kill: func() {}} + _, _ = killed.Write([]byte("0123456789abc")) + if !killed.over || killed.String() != "0123456789" { + t.Fatalf("cap kept %q", killed.String()) + } + text, overflow, fail := appGit{ctx: context.Background()}.capped(repo, 64, 0, "log", "-p") + if fail != nil || !overflow || len(text) != 64 { + t.Fatalf("capped = %d %v %+v", len(text), overflow, fail) + } + done, cancel := context.WithCancel(context.Background()) + cancel() + if _, _, fail := (appGit{ctx: done}).capped(repo, 64, 0, "status"); fail == nil || !fail.timedOut { + t.Fatalf("an expired deadline = %+v", fail) + } +} + +// AT MOST FileDiffJobs DIFFS RUN AT ONCE; ONE MORE IS BUSY, NOT QUEUED. A bad +// base is refused before anything runs. +func TestAppDiffFileThroughTheBackend(t *testing.T) { + backend, _, agent := workBackend(t, &fakeConversation{script: []session.Event{{Kind: session.EventTurnDone}}}) + write(t, filepath.Join(agent.Workspace, "n.txt"), "n\n") + d, err := backend.DiffFile(agent.ID, "n.txt", "") + if err != nil || d.Path != "n.txt" || d.Outside { + t.Fatalf("diff = %+v, %v", d, err) + } + if _, err := backend.DiffFile(agent.ID, "n.txt", "--output=x"); err == nil || !strings.Contains(err.Error(), "base") { + t.Fatalf("an option as a base = %v", err) + } + for range appwire.FileDiffJobs { + appDiffSlots <- struct{}{} + } + _, err = backend.DiffFile(agent.ID, "n.txt", "") + for range appwire.FileDiffJobs { + <-appDiffSlots + } + if err == nil || !strings.Contains(err.Error(), "still being read") { + t.Fatalf("one diff too many = %v", err) + } +} diff --git a/docs/changes/unreleased/0-app-files-diff.md b/docs/changes/unreleased/0-app-files-diff.md new file mode 100644 index 0000000000..2471ae30ba --- /dev/null +++ b/docs/changes/unreleased/0-app-files-diff.md @@ -0,0 +1,18 @@ +--- +kind: added +title: codeaf app answers a bounded, read-only diff of one file +pr: 0 +surface: [engine] +invalidates: + - "The app could read a file but not compare it, so a changed file had no Changes view. files.diff {agent, path, base?} now answers one file's working tree (staged and unstaged) against HEAD or a named commit, with at most 256 KiB and 2,000 lines of hunks." +--- + +New method `files.diff {agent, path, base?}` → `{diff}`. The base is HEAD +unless a commit or ref is named; one that names no commit falls back to HEAD +and says so (`requested_unavailable`), and one that does says whether it is +in HEAD's history. Untracked files answer as wholly added with no base; +ignored, outside git, no commit yet, missing and git refusals each answer +their state. Git runs read-only under one 3 s deadline, never runs a +program from repository configuration (external diff, textconv, fsmonitor, +filters), is killed once its output passes the bound, and at most two diffs +run at once. diff --git a/internal/appwire/convo_test.go b/internal/appwire/convo_test.go index 1b9bf5a231..e2a3601adc 100644 --- a/internal/appwire/convo_test.go +++ b/internal/appwire/convo_test.go @@ -78,3 +78,39 @@ func TestFilesReadRoute(t *testing.T) { t.Fatalf("an engine without reading answered %v", err) } } + +type differEngine struct { + fakeEngine + agent, path, base string +} + +func (d *differEngine) DiffFile(agent, path, base string) (FileDiff, error) { + d.agent, d.path, d.base = agent, path, base + return FileDiff{Path: path, State: DiffUnchanged}, nil +} + +// FILES.DIFF reaches an engine that diffs with an optional base, refuses a +// missing path and a base git could read as an option or a range, and is +// unknown elsewhere. +func TestFilesDiffRoute(t *testing.T) { + m, ok := methods["files.diff"] + if !ok { + t.Fatal("files.diff is not registered") + } + eng := &differEngine{} + if _, err := m.call(eng, json.RawMessage(`{"agent":"a1","path":"-x.go"}`)); err != nil || eng.base != "" || eng.path != "-x.go" { + t.Fatalf("no base: %v, %+v", err, eng) + } + if _, err := m.call(eng, json.RawMessage(`{"agent":"a1","path":"x","base":"main"}`)); err != nil || eng.base != "main" { + t.Fatalf("a base: %v, %+v", err, eng) + } + var wire *Error + for _, bad := range []string{`{"agent":"a1"}`, `{"agent":"a1","path":"x","base":"--output=y"}`, `{"agent":"a1","path":"x","base":"a..b"}`} { + if _, err := m.call(eng, json.RawMessage(bad)); !errors.As(err, &wire) || wire.Code != CodeBadParams { + t.Fatalf("%s answered %v", bad, err) + } + } + if _, err := m.call(&fakeEngine{}, json.RawMessage(`{"agent":"a1","path":"x"}`)); !errors.As(err, &wire) || wire.Code != CodeUnknownMethod { + t.Fatalf("an engine without diffing answered %v", err) + } +} diff --git a/internal/appwire/filediff.go b/internal/appwire/filediff.go new file mode 100644 index 0000000000..e9ffd610c2 --- /dev/null +++ b/internal/appwire/filediff.go @@ -0,0 +1,146 @@ +package appwire + +import "strings" + +// FILES.DIFF: one file compared with a commit, read-only and bounded. The +// working tree is read as it stands, so staged and unstaged edits are both in +// the answer. The base is HEAD unless the caller names a commit; a named one +// that does not resolve to a commit falls back to HEAD and says so +// (RequestedUnavailable), so a caller can tell the difference and word it. +// Nothing here knows who asked or why: the caller owns what a base means. +// At most [FileDiffMax] bytes and [FileDiffLines] lines of hunks are answered, +// with Truncated saying more was there. + +// The bounds of files.diff. +const ( + // FileDiffMax is the most hunk text one answer carries. + FileDiffMax = 256 << 10 + // FileDiffLines is the most hunk lines one answer carries. + FileDiffLines = 2000 + // FileDiffJobs is how many diffs are read at once; one more is busy. + FileDiffJobs = 2 + // FileDiffSeconds is the deadline for every git process of one diff together. + FileDiffSeconds = 3 +) + +// What a diff found. +const ( + DiffChanged = "changed" // differs from the base; Text holds hunks unless Binary + DiffUnchanged = "unchanged" // tracked and the same as the base + DiffUntracked = "untracked" // in a repository, never added; Text is the whole file as added + DiffIgnored = "ignored" // in a repository, ignored by it + DiffNotGit = "not_git" // not inside a git work tree; Folder says where it is + DiffNoCommits = "no_commits" // the repository has no commit yet, so nothing to compare with + DiffMissing = "missing" // not on disk and not known to git + DiffUnavailable = "unavailable" // git could not answer; Reason says why +) + +// What the diff is against. +const ( + DiffBaseHead = "head" // HEAD, the latest commit + DiffBaseRequested = "requested" // the commit the caller named + DiffBaseNone = "none" // nothing: an untracked file is all new +) + +// Why a requested base was not used. +const ( + DiffRequestedUnknown = "unknown_commit" // it does not name a commit in this repository +) + +// Why a diff is unavailable. +const ( + DiffReasonTimeout = "timeout" // git took longer than FileDiffSeconds + DiffReasonNoGit = "no_git" // there is no git program here + DiffReasonFilter = "filter" // the file passes through a git filter, which is never run + DiffReasonUnsafe = "unsafe" // git refuses the repository's ownership + DiffReasonTooLarge = "too_large" // an untracked file over FileReadMax + DiffReasonGitFailed = "git_failed" // any other refusal; Detail carries git's first line +) + +// FileDiff is one path compared with its base. Resolved is the canonical +// absolute path, every link followed; Outside says it lies outside the agent's +// workspace once resolved. Add and Del are absent when git cannot count +// (binary, not in git). Text is unified hunks from the first "@@", with no file +// headers. +type FileDiff struct { + Path string `json:"path"` + Resolved string `json:"resolved,omitempty"` + Outside bool `json:"outside,omitempty"` + State string `json:"state"` + Folder string `json:"folder,omitempty"` + Repo string `json:"repo,omitempty"` + Base string `json:"base,omitempty"` + Commit string `json:"commit,omitempty"` // the full hash compared with + Requested string `json:"requested,omitempty"` + // RequestedUnavailable says why the requested base was not used; Base is + // then head. + RequestedUnavailable string `json:"requested_unavailable,omitempty"` + // InHistory says whether a requested base is an ancestor of HEAD. + InHistory *bool `json:"in_history,omitempty"` + Change string `json:"change,omitempty"` // modified · added · deleted · renamed + OldPath string `json:"old_path,omitempty"` + Add *int `json:"add,omitempty"` + Del *int `json:"del,omitempty"` + Binary bool `json:"binary,omitempty"` + Text string `json:"text"` + Lines int `json:"lines"` + Truncated bool `json:"truncated,omitempty"` + Reason string `json:"reason,omitempty"` + Detail string `json:"detail,omitempty"` +} + +// FileDiffer is the optional engine half of files.diff. One that is not a +// FileDiffer refuses files.diff as unknown. +type FileDiffer interface { + // DiffFile answers path (relative to the agent's workspace, or absolute) + // compared with base (a commit or ref; empty is HEAD), read-only and + // bounded. + DiffFile(agent, path, base string) (FileDiff, error) +} + +// FileDiffResult is files.diff's answer. +type FileDiffResult struct { + Diff FileDiff `json:"diff"` +} + +func init() { + methods["files.diff"] = typed(func(e Engine, p struct { + Agent string `json:"agent"` + Path string `json:"path"` + Base string `json:"base"` + }) (*FileDiffResult, error) { + d, ok := e.(FileDiffer) + if !ok { + return nil, Errorf(CodeUnknownMethod, "this engine does not serve files.diff") + } + if strings.TrimSpace(p.Agent) == "" || strings.TrimSpace(p.Path) == "" { + return nil, Errorf(CodeBadParams, "files.diff needs an agent and a path") + } + if !ValidDiffBase(p.Base) { + return nil, Errorf(CodeBadParams, "files.diff base must be a commit or ref name") + } + diff, err := d.DiffFile(p.Agent, p.Path, p.Base) + if err != nil { + return nil, err + } + return &FileDiffResult{diff}, nil + }) +} + +// ValidDiffBase says whether base may be handed to git as a revision: empty, +// or a name of at most 256 bytes that cannot be read as an option, a range, a +// path or a revision expression. +func ValidDiffBase(base string) bool { + if base == "" { + return true + } + if len(base) > 256 || strings.HasPrefix(base, "-") || strings.Contains(base, "..") || strings.Contains(base, "@{") { + return false + } + for _, r := range base { + if r <= ' ' || r == 0x7f || strings.ContainsRune(":^~?*[\\{}", r) { + return false + } + } + return true +} From 6839a536ba748739c43a2ed74ad83fd6aa5fd0b9 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 17:59:46 -0400 Subject: [PATCH 026/459] codeaf app: refuse diff facts from changing file revisions --- cmd/codeaf/appfilediff.go | 24 +++++++++++++-- cmd/codeaf/appfilediff_review_test.go | 42 +++++++++++++++++++++++++++ internal/appwire/filediff.go | 13 +++++---- 3 files changed, 71 insertions(+), 8 deletions(-) diff --git a/cmd/codeaf/appfilediff.go b/cmd/codeaf/appfilediff.go index 969bb65f63..e1ad63df95 100644 --- a/cmd/codeaf/appfilediff.go +++ b/cmd/codeaf/appfilediff.go @@ -75,8 +75,8 @@ func (b *appBackend) DiffFile(agent, path, base string) (appwire.FileDiff, error // appDiff is the whole answer for full (absolute, cleaned) under root, the // canonical workspace, compared with requested when it names a commit and with // HEAD otherwise. requested has passed appwire.ValidDiffBase. -func appDiff(ctx context.Context, path, full, root, requested string) appwire.FileDiff { - out := appwire.FileDiff{Path: path, Resolved: full} +func appDiff(ctx context.Context, path, full, root, requested string) (out appwire.FileDiff) { + out = appwire.FileDiff{Path: path, Resolved: full} resolved, err := filepath.EvalSymlinks(full) exists := err == nil if !exists { @@ -85,6 +85,17 @@ func appDiff(ctx context.Context, path, full, root, requested string) appwire.Fi } out.Resolved = resolved out.Outside = !appWithin(root, resolved) + before, beforeErr := os.Stat(resolved) + defer func() { + if out.State != appwire.DiffChanged && out.State != appwire.DiffUnchanged && out.State != appwire.DiffUntracked { + return + } + after, afterErr := os.Stat(resolved) + if !appSameDiffSource(before, beforeErr, after, afterErr) { + out.State, out.Reason, out.Detail = appwire.DiffUnavailable, appwire.DiffReasonChanged, "file changed during comparison" + out.Text, out.Lines, out.Truncated, out.Binary, out.Add, out.Del = "", 0, false, false, nil, nil + } + }() if exists { if info, err := os.Stat(resolved); err == nil && info.IsDir() { out.State, out.Reason, out.Detail = appwire.DiffUnavailable, appwire.DiffReasonGitFailed, "a folder has no diff of its own" @@ -263,6 +274,15 @@ func appDiff(ctx context.Context, path, full, root, requested string) appwire.Fi return out } +// Statistics and hunks are separate read operations. Never combine facts from +// different revisions when another process edits, replaces, or removes a file. +func appSameDiffSource(before os.FileInfo, beforeErr error, after os.FileInfo, afterErr error) bool { + if errors.Is(beforeErr, fs.ErrNotExist) && errors.Is(afterErr, fs.ErrNotExist) { + return true + } + return beforeErr == nil && afterErr == nil && os.SameFile(before, after) && before.Mode() == after.Mode() && before.Size() == after.Size() && before.ModTime().Equal(after.ModTime()) +} + // appTreeTooLarge says whether a `ls-tree -z -l` entry is a blob over // FileReadMax. func appTreeTooLarge(out string) bool { diff --git a/cmd/codeaf/appfilediff_review_test.go b/cmd/codeaf/appfilediff_review_test.go index e49525f2eb..be2ff3c2dd 100644 --- a/cmd/codeaf/appfilediff_review_test.go +++ b/cmd/codeaf/appfilediff_review_test.go @@ -38,3 +38,45 @@ func TestAppDiffReviewInvalidUTF8TrackedText(t *testing.T) { t.Fatalf("invalid UTF-8 became a text diff: binary=%v bytes=%d", d.Binary, len(d.Text)) } } + +func TestAppDiffReviewSourceRevisionGuard(t *testing.T) { + path := filepath.Join(t.TempDir(), "source.txt") + _, missing := os.Stat(path) + if !appSameDiffSource(nil, missing, nil, missing) { + t.Fatal("a deleted file that stays deleted changed revision") + } + write(t, path, "original\n") + before, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if !appSameDiffSource(before, nil, before, nil) { + t.Fatal("an unchanged file changed revision") + } + if appSameDiffSource(nil, missing, before, nil) || appSameDiffSource(before, nil, nil, missing) { + t.Fatal("creation or deletion was not detected") + } + write(t, path, "different length\n") + after, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if appSameDiffSource(before, nil, after, nil) { + t.Fatal("a rewritten file was not detected") + } + other := path + ".new" + write(t, other, "original\n") + if err := os.Chtimes(other, before.ModTime(), before.ModTime()); err != nil { + t.Fatal(err) + } + if err := os.Rename(other, path); err != nil { + t.Fatal(err) + } + after, err = os.Stat(path) + if err != nil { + t.Fatal(err) + } + if appSameDiffSource(before, nil, after, nil) { + t.Fatal("atomic replacement with the same size and time was not detected") + } +} diff --git a/internal/appwire/filediff.go b/internal/appwire/filediff.go index e9ffd610c2..e9c21156b9 100644 --- a/internal/appwire/filediff.go +++ b/internal/appwire/filediff.go @@ -49,12 +49,13 @@ const ( // Why a diff is unavailable. const ( - DiffReasonTimeout = "timeout" // git took longer than FileDiffSeconds - DiffReasonNoGit = "no_git" // there is no git program here - DiffReasonFilter = "filter" // the file passes through a git filter, which is never run - DiffReasonUnsafe = "unsafe" // git refuses the repository's ownership - DiffReasonTooLarge = "too_large" // an untracked file over FileReadMax - DiffReasonGitFailed = "git_failed" // any other refusal; Detail carries git's first line + DiffReasonTimeout = "timeout" // git took longer than FileDiffSeconds + DiffReasonNoGit = "no_git" // there is no git program here + DiffReasonFilter = "filter" // the file passes through a git filter, which is never run + DiffReasonUnsafe = "unsafe" // git refuses the repository's ownership + DiffReasonTooLarge = "too_large" // an untracked file over FileReadMax + DiffReasonGitFailed = "git_failed" // any other refusal; Detail carries git's first line + DiffReasonChanged = "changed_during_read" // the file changed between comparison operations ) // FileDiff is one path compared with its base. Resolved is the canonical From 3d03e06c8f5d211f98c10821836c07e2c42bd00f Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 18:07:16 -0400 Subject: [PATCH 027/459] codeaf app: attachments ride on a new agent's first message and on crew messages agents.create takes attachments [{kind file|paste|link, path?, text?, url?}]: checked whole (shape, at most 10, a paste at most 200,000 characters and a message's pastes 400,000, a file or folder that exists) BEFORE anything is minted, so a refusal leaves no agent, folder or event. Attachments alone start the agent; the first entry carries them with ids and the model is sent the same words agents.send writes for staged chips, a file as its path only. crews.message takes staged ids, sent to the resolved target (manager by default, a member exactly); ids staged on another agent are not_found. An attached message to a busy target is refused busy, never queued without its attachments; words alone still queue. SendWith now consumes staged chips only once a turn has started, under the agent's gate, so a busy or failed send keeps every chip and racing sends each carry their own snapshot. The per-agent `sending` side channel is gone: what an entry shows rides into startLocked as a parameter, and a failed turn's retry shows the same attachments. One prepare helper and one words helper serve attachments.add, SendWith and create. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/app.go | 118 ++++-- cmd/codeaf/appattach_test.go | 395 ++++++++++++++++++ cmd/codeaf/appconvo.go | 185 ++++++-- cmd/codeaf/appcrews.go | 50 ++- cmd/codeaf/appcrews_test.go | 8 +- .../unreleased/0-app-message-attachments.md | 24 ++ internal/appwire/attach_test.go | 132 ++++++ internal/appwire/convo.go | 85 ++++ internal/appwire/crews.go | 20 +- internal/appwire/server.go | 15 +- internal/appwire/wire.go | 19 +- 11 files changed, 965 insertions(+), 86 deletions(-) create mode 100644 cmd/codeaf/appattach_test.go create mode 100644 docs/changes/unreleased/0-app-message-attachments.md create mode 100644 internal/appwire/attach_test.go diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index 29f458be37..1b5505f9f7 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -383,6 +383,9 @@ type appAgent struct { // failedKind is how that turn failed; a turn refused for money is run // again when the limit is raised. failedKind string + // failedShown is what that turn's entry showed, when it carried + // attachments: a retry sends the same words and shows the same message. + failedShown *appSending // step is the title of the call the running turn is on, the live line // a crew's member row and the agent's own row show while it works. step string @@ -401,14 +404,13 @@ type appAgent struct { // calls is the work log's record of this agent's tool calls (apptools.go). calls *appCallLog // queue is the person's messages waiting for the turn to end, attach the - // attachments kept for the next message, walk the last walk of the - // workspace's files, and sending the message being sent (appconvo.go). + // attachments kept for the next message, and walk the last walk of the + // workspace's files (appconvo.go). queue []appQueued queueN int attach map[string]appAttach attachN int walk *appWalk - sending *appSending } // appTurnMark is what this process knows of one of the person's messages. @@ -427,6 +429,13 @@ type appBoot struct { budget *float64 } +// appFirst is a created agent's first message: the words the model is sent, +// and, when attachments rode on it, what its entry shows instead. +type appFirst struct { + words, clientID string + shown *appSending +} + // appLive is one conversation the process holds. type appLive struct { conn *appConn @@ -762,11 +771,31 @@ func (b *appBackend) CreateAgent(ask appwire.CreateAsk) (appwire.Agent, error) { if b.isClosing() { return appwire.Agent{}, appwire.Errorf(appwire.CodeClosed, "codeaf is shutting down") } + text := strings.TrimSpace(ask.Text) + // EVERY ATTACHMENT IS READ BEFORE ANYTHING IS MADE: one that is malformed, + // over a bound, or a file that is not there refuses the create whole, so a + // refusal leaves no folder, no agent and no event behind it. It is a stat + // per file, outside every lock. + if err := appwire.CheckAttachSources(ask.Attachments); err != nil { + return appwire.Agent{}, err + } + kept := make([]appAttach, 0, len(ask.Attachments)) + for _, source := range ask.Attachments { + attach, err := b.prepareAttach(resolved, nil, appwire.Attachment{ + Kind: source.Kind, Path: strings.TrimSpace(source.Path), URL: strings.TrimSpace(source.URL), + }, source.Text, true) + if err != nil { + return appwire.Agent{}, err + } + kept = append(kept, attach) + } + if err := appAttachChars(kept); err != nil { + return appwire.Agent{}, err + } transcript, err := b.mint(resolved) if err != nil { return appwire.Agent{}, appEngineError(err) } - text := strings.TrimSpace(ask.Text) model := strings.TrimSpace(ask.Model) a := b.agent(appAgentID(transcript)) if ask.BudgetUSD != nil && *ask.BudgetUSD > 0 { @@ -785,13 +814,24 @@ func (b *appBackend) CreateAgent(ask appwire.CreateAsk) (appwire.Agent, error) { } } var view appwire.Agent + first := appFirst{clientID: ask.ClientID} b.locked(func() { + // The attachments are numbered as staged ones are, so an id on the + // first entry is never one a later chip is given. + for i := range kept { + a.attachN++ + kept[i].view.ID = "at" + strconv.Itoa(a.attachN) + } + first.words, first.shown = appAttachWords(text, kept), appAttachShown(text, kept) a.transcript = transcript a.view.Title = session.FirstMessageName(text) + if a.view.Title == "" && len(kept) > 0 { + a.view.Title = session.FirstMessageName(kept[0].view.Title) + } a.view.Workspace = resolved a.view.Model = model a.view.State = appwire.StateIdle - if text != "" { + if first.words != "" { a.view.State = appwire.StateWorking } a.view.UpdatedAt = b.now() @@ -801,36 +841,37 @@ func (b *appBackend) CreateAgent(ask appwire.CreateAsk) (appwire.Agent, error) { view = a.view }) b.emit.Emit(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) - clientID := ask.ClientID - guard.Go("app/create", func() { b.bootAgent(a, text, clientID) }) + guard.Go("app/create", func() { b.bootAgent(a, first) }) return view, nil } // bootAgent opens a created agent's conversation and starts its first turn. -func (b *appBackend) bootAgent(a *appAgent, text, clientID string) { +func (b *appBackend) bootAgent(a *appAgent, first appFirst) { a.gate.Lock() defer a.gate.Unlock() view, opened, err := b.ensureLocked(a) b.locked(func() { a.opening = false }) if err != nil { - b.bootFailed(a, text, err) + b.bootFailed(a, first, err) return } - if text == "" { + if first.words == "" { if opened { b.emit.Emit(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) } return } // The agent already reads working; the turn's own start says the rest. - if _, err := b.startLocked(a, text, clientID); err != nil { - b.bootFailed(a, text, err) + if _, err := b.startLocked(a, first.words, first.clientID, first.shown); err != nil { + b.bootFailed(a, first, err) } } // bootFailed is a created agent whose conversation could not be opened or // started: a failed turn the person can retry, and an agent that reads stopped. -func (b *appBackend) bootFailed(a *appAgent, text string, err error) { +// The retry sends the same words and shows the same attachments. +func (b *appBackend) bootFailed(a *appAgent, first appFirst, err error) { + text := first.words words := err.Error() var wire *appwire.Error if errors.As(appEngineError(err), &wire) { @@ -839,7 +880,7 @@ func (b *appBackend) bootFailed(a *appAgent, text string, err error) { var view appwire.Agent b.locked(func() { if text != "" { - a.failed, a.failedKind = text, appwire.ErrorOther + a.failed, a.failedKind, a.failedShown = text, appwire.ErrorOther, first.shown } a.boot = nil if a.live == nil { @@ -1310,6 +1351,12 @@ func (b *appBackend) liveOf(a *appAgent) *appLive { // Send starts one turn, opening the conversation first if it is not held. func (b *appBackend) Send(id, text, clientID string) (uint64, error) { + return b.send(id, text, clientID, nil) +} + +// send is [appBackend.Send] with what the person's entry shows, when that is +// not the words sent. +func (b *appBackend) send(id, text, clientID string, shown *appSending) (uint64, error) { a, err := b.find(id) if err != nil { return 0, err @@ -1318,6 +1365,12 @@ func (b *appBackend) Send(id, text, clientID string) (uint64, error) { // opening it and starting the turn. a.gate.Lock() defer a.gate.Unlock() + return b.sendLocked(a, text, clientID, shown) +} + +// sendLocked opens a's conversation if it is not held and starts one turn on +// it. The caller holds a.gate. +func (b *appBackend) sendLocked(a *appAgent, text, clientID string, shown *appSending) (uint64, error) { view, opened, err := b.ensureLocked(a) if err != nil { return 0, err @@ -1325,7 +1378,7 @@ func (b *appBackend) Send(id, text, clientID string) (uint64, error) { if opened { b.emit.Emit(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) } - return b.startLocked(a, text, clientID) + return b.startLocked(a, text, clientID, shown) } // Retry sends the words of the agent's last failed turn again. @@ -1335,15 +1388,18 @@ func (b *appBackend) Retry(id string) (uint64, error) { return 0, err } var text string - b.locked(func() { text = a.failed }) + var shown *appSending + b.locked(func() { text, shown = a.failed, a.failedShown }) if text == "" { return 0, appwire.Errorf(appwire.CodeNotFound, "this agent has no failed turn to run again") } - return b.Send(id, text, "") + return b.send(id, text, "", shown) } // startLocked starts one turn on a held conversation. The caller holds a.gate. -func (b *appBackend) startLocked(a *appAgent, text, clientID string) (uint64, error) { +// shown, when set, is what the person's entry shows instead of text: their +// own words and the attachments the text was written from. +func (b *appBackend) startLocked(a *appAgent, text, clientID string, shown *appSending) (uint64, error) { var id string var live *appLive var refused error @@ -1370,7 +1426,9 @@ func (b *appBackend) startLocked(a *appAgent, text, clientID string) (uint64, er now := b.now() if limit := b.dayCap(now); limit > 0 { if spent := b.spentToday(now); spent >= limit { - return b.dayRefused(a, live, text, spent, limit), nil + turn := b.dayRefused(a, live, text, spent, limit) + b.locked(func() { a.failedShown = shown }) + return turn, nil } } // The agent's budget is bound again, so a raise for today ends at midnight. @@ -1379,7 +1437,7 @@ func (b *appBackend) startLocked(a *appAgent, text, clientID string) (uint64, er } var turn uint64 var n int - shown, attachments := text, []appwire.Attachment(nil) + entryText, attachments := text, []appwire.Attachment(nil) b.locked(func() { if live.running { refused = appwire.Errorf(appwire.CodeBusy, "this agent is already working on a turn") @@ -1395,14 +1453,11 @@ func (b *appBackend) startLocked(a *appAgent, text, clientID string) (uint64, er a.marks = map[int]appTurnMark{} } mark := appTurnMark{turn: turn, clientID: clientID} - if a.sending != nil { - mark.text, mark.attachments = a.sending.text, a.sending.attachments + if shown != nil { + mark.text, mark.attachments = shown.text, shown.attachments + entryText, attachments = shown.text, shown.attachments } a.marks[n] = mark - shown, attachments = text, mark.attachments - if mark.text != "" { - shown = mark.text - } }) if refused != nil { return 0, refused @@ -1426,7 +1481,7 @@ func (b *appBackend) startLocked(a *appAgent, text, clientID string) (uint64, er live.box.Event(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) live.box.Event(appwire.EventTurnStarted, appwire.TurnStarted{Agent: id, Turn: turn}) live.box.Event(appwire.EventEntryAdded, appwire.EntryAdded{Entry: appwire.Entry{ - ID: appUserEntryID(n), Agent: id, Turn: turn, Kind: appwire.EntryUser, At: b.now(), Text: shown, ClientID: clientID, Attachments: attachments, + ID: appUserEntryID(n), Agent: id, Turn: turn, Kind: appwire.EntryUser, At: b.now(), Text: entryText, ClientID: clientID, Attachments: attachments, }}) long := b.watchLong(id, live, turn, b.now()) b.newTurnCalls(a) @@ -1633,9 +1688,12 @@ func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, n if live.stopping { ok, kind, status = false, appwire.ErrorStopped, 0 } - a.failed, a.failedKind = "", "" + a.failed, a.failedKind, a.failedShown = "", "", nil if !ok { a.failed, a.failedKind = text, kind + if mark := a.marks[n]; len(mark.attachments) > 0 { + a.failedShown = &appSending{text: mark.text, attachments: mark.attachments} + } } }) switch { @@ -2165,7 +2223,9 @@ func appEntriesOf(agent string, items []session.DisplayEntry, at time.Time, mark entry.Turn = mark.turn if entry.Kind == appwire.EntryUser { entry.ClientID = mark.clientID - if mark.text != "" { + // A message of attachments alone has no words of its own, + // and still shows as what the person sent. + if mark.text != "" || len(mark.attachments) > 0 { entry.Text, entry.Attachments = mark.text, mark.attachments } } diff --git a/cmd/codeaf/appattach_test.go b/cmd/codeaf/appattach_test.go new file mode 100644 index 0000000000..33c25a995f --- /dev/null +++ b/cmd/codeaf/appattach_test.go @@ -0,0 +1,395 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/session" +) + +// wireCode is the wire code of an engine error, or "". +func wireCode(err error) string { + var wire *appwire.Error + if errors.As(err, &wire) { + return wire.Code + } + return "" +} + +// staged says whether id is still staged on a. +func staged(backend *appBackend, agent, id string) bool { + a := backend.agent(agent) + ok := false + backend.locked(func() { _, ok = a.attach[id] }) + return ok +} + +// sentTexts is every message the fake engine was sent. +func sentTexts(conv *fakeConversation) []string { + conv.mu.Lock() + defer conv.mu.Unlock() + return append([]string(nil), conv.texts...) +} + +// A CREATE WHOSE ATTACHMENTS CANNOT RIDE MAKES NOTHING: no folder minted, no +// conversation opened, no agent remembered and no event said. +func TestAppCreateRefusesBadAttachmentsAndMakesNothing(t *testing.T) { + var mu sync.Mutex + var opens, releases int + var mints atomic.Int32 + rec := newAppRecorder() + backend := newFakeBackend(rec, fakeOpener(&fakeConversation{}, &opens, &releases, &mu)) + defer backend.Close() + backend.mint = func(string) (string, error) { mints.Add(1); return appFakeTranscript, nil } + work := t.TempDir() + half := strings.Repeat("x", appwire.MaxAttachChars/2) + eleven := make([]appwire.AttachSource, appwire.MaxAttachments+1) + for i := range eleven { + eleven[i] = appwire.AttachSource{Kind: appwire.AttachLink, URL: "https://example.com"} + } + for name, sources := range map[string][]appwire.AttachSource{ + "a missing file": {{Kind: appwire.AttachFile, Path: "gone.go"}}, + "a missing file abroad": {{Kind: appwire.AttachFile, Path: filepath.Join(t.TempDir(), "gone.go")}}, + "a link with no host": {{Kind: appwire.AttachLink, URL: "not a link"}}, + "an unknown kind": {{Kind: "image", Path: "a.png"}}, + "a paste with no text": {{Kind: appwire.AttachPaste}}, + "one paste too long": {{Kind: appwire.AttachPaste, Text: strings.Repeat("y", appwire.MaxPasteChars+1)}}, + "pastes too long": {{Kind: appwire.AttachPaste, Text: half}, {Kind: appwire.AttachPaste, Text: half + "x"}}, + "eleven": eleven, + "a good one then a bad": {{Kind: appwire.AttachPaste, Text: "log"}, {Kind: appwire.AttachFile, Path: "gone.go"}}, + } { + _, err := backend.CreateAgent(appwire.CreateAsk{Workspace: work, Text: "look", Attachments: sources}) + if wireCode(err) != appwire.CodeBadParams { + t.Errorf("%s answered %v", name, err) + } + } + mu.Lock() + opened := opens + mu.Unlock() + var held int + backend.locked(func() { held = len(backend.agents) }) + rec.mu.Lock() + said := len(rec.events) + rec.mu.Unlock() + if mints.Load() != 0 || opened != 0 || held != 0 || said != 0 { + t.Fatalf("a refused create left %d mints, %d opens, %d agents and %d events", mints.Load(), opened, held, said) + } +} + +// ATTACHMENTS ALONE ARE A FIRST MESSAGE: the agent answers working, named for +// its first attachment, its first entry shows no words and every attachment, +// and the model is sent exactly their words, once. +func TestAppCreateWithAttachmentsAloneStartsOnTheirWords(t *testing.T) { + conv := &fakeConversation{script: []session.Event{{Kind: session.EventTurnDone}}} + var mu sync.Mutex + var opens, releases int + rec := newAppRecorder() + backend := newFakeBackend(rec, fakeOpener(conv, &opens, &releases, &mu)) + defer backend.Close() + work := t.TempDir() + if err := os.WriteFile(filepath.Join(work, "notes.md"), []byte("# notes\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(work, "docs"), 0o755); err != nil { + t.Fatal(err) + } + agent, err := backend.CreateAgent(appwire.CreateAsk{Workspace: work, ClientID: "c-9", Attachments: []appwire.AttachSource{ + {Kind: appwire.AttachFile, Path: " notes.md "}, + {Kind: appwire.AttachFile, Path: "docs"}, + {Kind: appwire.AttachPaste, Text: "line one\nline two\n"}, + {Kind: appwire.AttachLink, URL: "https://example.com/issue/7"}, + }}) + if err != nil { + t.Fatal(err) + } + if agent.State != appwire.StateWorking || agent.Title != "notes.md" { + t.Fatalf("the created agent = %+v", agent) + } + user := entryWith(rec, t, func(e appwire.Entry) bool { return e.Kind == appwire.EntryUser }) + if user.Text != "" || user.ClientID != "c-9" || user.ID != "u0" || len(user.Attachments) != 4 { + t.Fatalf("the first entry = %+v", user) + } + ids := []string{} + for _, at := range user.Attachments { + ids = append(ids, at.ID) + } + file, dir, paste, link := user.Attachments[0], user.Attachments[1], user.Attachments[2], user.Attachments[3] + if strings.Join(ids, " ") != "at1 at2 at3 at4" || file.Path != "notes.md" || file.State != appwire.FileExists || + dir.State != appwire.FileExists || paste.Lines == nil || *paste.Lines != 2 || link.Title != "example.com" { + t.Fatalf("the attachments = %+v", user.Attachments) + } + rec.waitFor(t, 5*time.Second, appTurnDone) + want := "Attached file: notes.md\n\nAttached file: docs\n\nPasted text:\n```\nline one\nline two\n```\n\nLink: https://example.com/issue/7" + if sent := sentTexts(conv); len(sent) != 1 || sent[0] != want { + t.Fatalf("the engine was sent %q", sent) + } + // A chip staged afterwards is numbered after the first message's. + next, err := backend.AddAttachment(agent.ID, appwire.Attachment{Kind: appwire.AttachLink, URL: "https://example.com"}, "") + if err != nil || next.ID != "at5" { + t.Fatalf("the next chip = %+v %v", next, err) + } +} + +// WORDS AND ATTACHMENTS TOGETHER: the entry shows the words as typed, the +// model is sent them first, and a paste of any length rides (the composer's +// short-paste rule is the composer's). +func TestAppCreateKeepsItsWordsBeforeItsAttachments(t *testing.T) { + conv := &fakeConversation{script: []session.Event{{Kind: session.EventTurnDone}}} + var mu sync.Mutex + var opens, releases int + rec := newAppRecorder() + backend := newFakeBackend(rec, fakeOpener(conv, &opens, &releases, &mu)) + defer backend.Close() + agent, err := backend.CreateAgent(appwire.CreateAsk{Workspace: t.TempDir(), Text: " why does this fail? ", Attachments: []appwire.AttachSource{ + {Kind: appwire.AttachPaste, Text: "--- FAIL: TestLoad\n"}, + }}) + if err != nil || agent.Title != "why does this fail?" { + t.Fatalf("the created agent = %+v %v", agent, err) + } + user := entryWith(rec, t, func(e appwire.Entry) bool { return e.Kind == appwire.EntryUser }) + if user.Text != "why does this fail?" || len(user.Attachments) != 1 || user.Attachments[0].Kind != appwire.AttachPaste { + t.Fatalf("the first entry = %+v", user) + } + rec.waitFor(t, 5*time.Second, appTurnDone) + if sent := sentTexts(conv); len(sent) != 1 || sent[0] != "why does this fail?\n\nPasted text:\n```\n--- FAIL: TestLoad\n```" { + t.Fatalf("the engine was sent %q", sent) + } +} + +// A CREATE THAT CANNOT OPEN KEEPS ITS AGENT, stopped with a failed turn, and +// its retry sends the same words and shows the same attachments. +func TestAppCreateBootFailureRetriesTheSameMessage(t *testing.T) { + conv := &fakeConversation{script: []session.Event{{Kind: session.EventTurnDone}}} + var mu sync.Mutex + var opens, releases int + var tries atomic.Int32 + inner := fakeOpener(conv, &opens, &releases, &mu) + rec := newAppRecorder() + backend := newFakeBackend(rec, func(ask appOpenAsk) (*appConn, error) { + if tries.Add(1) == 1 { + return nil, errors.New("the profile could not be read") + } + return inner(ask) + }) + defer backend.Close() + agent, err := backend.CreateAgent(appwire.CreateAsk{Workspace: t.TempDir(), Text: "look", Attachments: []appwire.AttachSource{ + {Kind: appwire.AttachLink, URL: "https://example.com/x"}, + }}) + if err != nil { + t.Fatal(err) + } + rec.waitFor(t, 5*time.Second, func(event string, data any) bool { + c, ok := data.(appwire.AgentChanged) + return ok && c.Agent.ID == agent.ID && c.Agent.State == appwire.StateStopped + }) + if _, err := backend.Retry(agent.ID); err != nil { + t.Fatal(err) + } + user := entryWith(rec, t, func(e appwire.Entry) bool { return e.Kind == appwire.EntryUser }) + if user.Text != "look" || len(user.Attachments) != 1 || user.Attachments[0].URL != "https://example.com/x" { + t.Fatalf("the retried entry = %+v", user) + } + rec.waitFor(t, 5*time.Second, func(event string, data any) bool { + d, ok := data.(appwire.TurnDone) + return ok && d.OK + }) + if sent := sentTexts(conv); len(sent) != 1 || sent[0] != "look\n\nLink: https://example.com/x" { + t.Fatalf("the engine was sent %q", sent) + } +} + +// A SEND THAT DOES NOT START A TURN CONSUMES NOTHING: busy, an id named twice, +// pastes over the message's bound — every chip stays staged, a chip added +// meanwhile is untouched, and the send goes once the turn ends. +func TestAppAttachedSendThatFailsKeepsItsAttachments(t *testing.T) { + hold := make(chan struct{}) + conv := &fakeConversation{script: []session.Event{{Kind: session.EventTextDelta, Text: "working"}}, hold: hold} + backend, rec, agent := workBackend(t, conv) + long := strings.Repeat("--- FAIL: TestLoad\n", 20) + one, err := backend.AddAttachment(agent.ID, appwire.Attachment{Kind: appwire.AttachPaste}, long) + if err != nil { + t.Fatal(err) + } + if _, err := backend.Send(agent.ID, "first", ""); err != nil { + t.Fatal(err) + } + if _, err := backend.SendWith(agent.ID, "and this", "m-1", []string{one.ID}); wireCode(err) != appwire.CodeBusy { + t.Fatalf("a send to a working agent answered %v", err) + } + two, err := backend.AddAttachment(agent.ID, appwire.Attachment{Kind: appwire.AttachPaste}, long) + if err != nil { + t.Fatal(err) + } + if _, err := backend.SendWith(agent.ID, "x", "", []string{two.ID, two.ID}); wireCode(err) != appwire.CodeBadParams { + t.Fatalf("an id named twice answered %v", err) + } + var huge []string + for i := 0; i < 3; i++ { + big, err := backend.AddAttachment(agent.ID, appwire.Attachment{Kind: appwire.AttachPaste}, strings.Repeat("z", appwire.MaxAttachChars/3+1)) + if err != nil { + t.Fatal(err) + } + huge = append(huge, big.ID) + } + if _, err := backend.SendWith(agent.ID, "x", "", huge); wireCode(err) != appwire.CodeBadParams { + t.Fatalf("pastes over the bound answered %v", err) + } + if _, err := backend.AddAttachment(agent.ID, appwire.Attachment{Kind: appwire.AttachPaste}, strings.Repeat("z", appwire.MaxPasteChars+1)); wireCode(err) != appwire.CodeBadParams { + t.Fatalf("a paste over its bound was staged: %v", err) + } + for _, id := range append([]string{one.ID, two.ID}, huge...) { + if !staged(backend, agent.ID, id) { + t.Fatalf("%s was consumed by a send that did not start", id) + } + } + close(hold) + rec.waitFor(t, 5*time.Second, appTurnDone) + deadline := time.Now().Add(5 * time.Second) + for { + _, err := backend.SendWith(agent.ID, "and this", "m-1", []string{one.ID}) + if err == nil { + break + } + if wireCode(err) != appwire.CodeBusy || time.Now().After(deadline) { + t.Fatal(err) + } + time.Sleep(time.Millisecond) + } + user := entryWith(rec, t, func(e appwire.Entry) bool { return e.Kind == appwire.EntryUser && e.ClientID == "m-1" }) + if user.Text != "and this" || len(user.Attachments) != 1 || user.Attachments[0].ID != one.ID { + t.Fatalf("the sent entry = %+v", user) + } + if staged(backend, agent.ID, one.ID) || !staged(backend, agent.ID, two.ID) { + t.Fatal("the send consumed other than what it carried") + } +} + +// SENDS RACING ON ONE AGENT EACH CARRY THEIR OWN SNAPSHOT: an entry shows +// exactly the chip its send named, never another send's, and a send that lost +// the race keeps its chip. +func TestAppRacingAttachedSendsCarryTheirOwnAttachments(t *testing.T) { + conv := &fakeConversation{script: []session.Event{{Kind: session.EventTurnDone}}} + backend, rec, agent := workBackend(t, conv) + const n = 6 + ids := make([]string, n) + for i := range ids { + at, err := backend.AddAttachment(agent.ID, appwire.Attachment{Kind: appwire.AttachLink, URL: "https://example.com/" + string(rune('a'+i))}, "") + if err != nil { + t.Fatal(err) + } + ids[i] = at.ID + } + errs := make([]error, n) + var wg sync.WaitGroup + for i := range ids { + wg.Add(1) + go func(i int) { + defer wg.Done() + _, errs[i] = backend.SendWith(agent.ID, "", "r-"+ids[i], []string{ids[i]}) + }(i) + } + wg.Wait() + sentOK := 0 + for i, err := range errs { + switch { + case err == nil: + sentOK++ + user := entryWith(rec, t, func(e appwire.Entry) bool { return e.Kind == appwire.EntryUser && e.ClientID == "r-"+ids[i] }) + if len(user.Attachments) != 1 || user.Attachments[0].ID != ids[i] || staged(backend, agent.ID, ids[i]) { + t.Fatalf("send %s showed %+v", ids[i], user.Attachments) + } + case wireCode(err) == appwire.CodeBusy: + if !staged(backend, agent.ID, ids[i]) { + t.Fatalf("a busy send consumed %s", ids[i]) + } + default: + t.Fatal(err) + } + } + if sentOK == 0 { + t.Fatal("no racing send started a turn") + } + for _, sent := range sentTexts(conv) { + if strings.Count(sent, "Link: ") != 1 { + t.Fatalf("a turn carried %q", sent) + } + } +} + +// A CREW MESSAGE CARRIES ATTACHMENTS TO ITS TARGET: the manager by default, +// a member named exactly; ids staged on another agent are refused and stay +// where they are; a busy target refuses an attached message and keeps its +// chips, while words alone still wait in its queue. +func TestAppCrewMessageCarriesAttachmentsToItsTarget(t *testing.T) { + hold := make(chan struct{}) + defer func() { + select { + case <-hold: + default: + close(hold) + } + }() + conv := &fakeConversation{script: []session.Event{{Kind: session.EventTextDelta, Text: "on it"}}, hold: hold} + backend, _, manager, crew := crewBackendOn(t, conv) + long := strings.Repeat("panic: nil map\n", 20) + paste := func(agent string) string { + t.Helper() + at, err := backend.AddAttachment(agent, appwire.Attachment{Kind: appwire.AttachPaste}, long) + if err != nil { + t.Fatal(err) + } + return at.ID + } + + m1 := paste(manager.ID) + entry, err := backend.CrewMessage(appwire.CrewMessage{Crew: crew, Attachments: []string{m1}, ClientID: "k-1"}) + if err != nil || entry.Agent != manager.ID || entry.Turn == 0 || len(entry.Attachments) != 1 || entry.Attachments[0].ID != m1 { + t.Fatalf("the message to the manager = %+v %v", entry, err) + } + + // The manager is now mid-turn. + m2 := paste(manager.ID) + if _, err := backend.CrewMessage(appwire.CrewMessage{Crew: crew, Text: "and this", Attachments: []string{m2}}); wireCode(err) != appwire.CodeBusy { + t.Fatalf("an attached message to a busy manager answered %v", err) + } + if !staged(backend, manager.ID, m2) { + t.Fatal("a refused crew message consumed its attachment") + } + words, err := backend.CrewMessage(appwire.CrewMessage{Crew: crew, Text: "words alone"}) + if err != nil || words.Turn != 0 { + t.Fatalf("words to a busy manager = %+v %v", words, err) + } + if queued := backend.queued(backend.agent(manager.ID)); len(queued) != 1 || queued[0].Text != "words alone" { + t.Fatalf("the manager's queue = %+v", queued) + } + + // A member, named exactly, whose conversation this process can open. + const member = "b0b0b0b0" + a := backend.agent(member) + backend.locked(func() { + a.transcript = "/state/v3/places/proj/b0b0b0b0/transcript.jsonl" + a.view.Workspace = manager.Workspace + }) + if _, err := backend.CrewMessage(appwire.CrewMessage{Crew: crew, To: member, Attachments: []string{m2}}); wireCode(err) != appwire.CodeNotFound { + t.Fatalf("the manager's attachment sent to a member answered %v", err) + } + if !staged(backend, manager.ID, m2) { + t.Fatal("a refused message moved another agent's attachment") + } + w1 := paste(member) + entry, err = backend.CrewMessage(appwire.CrewMessage{Crew: crew, To: member, Attachments: []string{w1}}) + if err != nil || entry.Agent != member || len(entry.Attachments) != 1 || entry.Attachments[0].ID != w1 { + t.Fatalf("the message to the member = %+v %v", entry, err) + } + lines, _, err := backend.CrewLog(crew, "", 0) + if err != nil || len(lines) == 0 || !strings.Contains(lines[len(lines)-1].Text, "Attached: Pasted text") { + t.Fatalf("the manager was told %+v %v", lines, err) + } +} diff --git a/cmd/codeaf/appconvo.go b/cmd/codeaf/appconvo.go index bda3c20cea..00ca5df648 100644 --- a/cmd/codeaf/appconvo.go +++ b/cmd/codeaf/appconvo.go @@ -28,6 +28,7 @@ import ( "strconv" "strings" "time" + "unicode/utf8" "github.com/Agent-Field/codeaf/internal/appwire" "github.com/Agent-Field/codeaf/internal/guard" @@ -64,6 +65,8 @@ type appQueued struct { type appAttach struct { view appwire.Attachment content string + // chars is a paste's length in characters, counted once. + chars int } // appNotePending is a note to a task not yet read. @@ -726,32 +729,130 @@ func (b *appBackend) StatFiles(agent string, paths []string) ([]appwire.FileStat return out, nil } -// AddAttachment keeps a file, a link or a long paste for the next message. -func (b *appBackend) AddAttachment(agent string, attachment appwire.Attachment, text string) (appwire.Attachment, error) { - a, err := b.find(agent) - if err != nil { - return appwire.Attachment{}, err - } - var workspace string - b.locked(func() { workspace = a.view.Workspace }) +// prepareAttach is one attachment made ready to ride on a message: a file +// stat'ed against the workspace, a link parsed, a paste counted. It is the one +// reading of an attachment, shared by a chip staged with attachments.add and a +// source named on agents.create. present says a file must be there now — a +// create has no chip to show a missing file on, so it refuses one instead. +// A file is read no further than its stat: the model is sent its path, and +// reads it with its own tools. +func (b *appBackend) prepareAttach(workspace string, changed map[string]appwire.FileChange, attachment appwire.Attachment, text string, present bool) (appAttach, error) { kept := appAttach{view: attachment} switch attachment.Kind { case appwire.AttachFile: - stat := b.statFile(workspace, b.changedInChat(a), attachment.Path) + stat := b.statFile(workspace, changed, attachment.Path) + if present && stat.State == appwire.FileOutside { + // A path outside the workspace is still a path: it is there when + // it is there. + if _, err := os.Stat(appAttachFull(workspace, attachment.Path)); err != nil { + stat.State = appwire.FileMissing + } + } + if present && stat.State == appwire.FileMissing { + return appAttach{}, appwire.Errorf(appwire.CodeBadParams, "there is no file or folder at %s", attachment.Path) + } kept.view.State, kept.view.Add, kept.view.Del = stat.State, stat.Add, stat.Del kept.view.Title = filepath.Base(attachment.Path) case appwire.AttachLink: parsed, err := url.Parse(strings.TrimSpace(attachment.URL)) if err != nil || parsed.Host == "" { - return appwire.Attachment{}, appwire.Errorf(appwire.CodeBadParams, "that is not a link") + return appAttach{}, appwire.Errorf(appwire.CodeBadParams, "that is not a link") } kept.view.URL, kept.view.Title = parsed.String(), parsed.Host case appwire.AttachPaste: + chars := utf8.RuneCountInString(text) + if chars > appwire.MaxPasteChars { + return appAttach{}, appwire.Errorf(appwire.CodeBadParams, "one paste holds at most %d characters", appwire.MaxPasteChars) + } + lines := strings.Count(strings.TrimRight(text, "\n"), "\n") + 1 + kept.view.Title, kept.view.Lines, kept.content, kept.chars = "Pasted text", &lines, text, chars + default: + return appAttach{}, appwire.Errorf(appwire.CodeBadParams, "kind is file, link or paste") + } + return kept, nil +} + +// appAttachFull is a path as the workspace reads it. +func appAttachFull(workspace, path string) string { + if !filepath.IsAbs(path) { + path = filepath.Join(workspace, path) + } + return filepath.Clean(path) +} + +// appAttachWords is the message the model is sent: the person's words, then +// each attachment as words of its own. It is the one spelling of attachments +// as context, whichever door they came in by. Words that are empty leave no +// blank lines at the top. +func appAttachWords(text string, kept []appAttach) string { + var words strings.Builder + words.WriteString(text) + for _, attach := range kept { + if words.Len() > 0 { + words.WriteString("\n\n") + } + switch attach.view.Kind { + case appwire.AttachFile: + words.WriteString("Attached file: " + attach.view.Path) + case appwire.AttachLink: + words.WriteString("Link: " + attach.view.URL) + case appwire.AttachPaste: + words.WriteString("Pasted text:\n```\n" + strings.TrimRight(attach.content, "\n") + "\n```") + } + } + return words.String() +} + +// appAttachShown is what the person's entry shows for a message with +// attachments: their own words and the attachments, not the words the model +// was sent. No attachments is nil, the entry shows what was sent. +func appAttachShown(text string, kept []appAttach) *appSending { + if len(kept) == 0 { + return nil + } + views := make([]appwire.Attachment, 0, len(kept)) + for _, attach := range kept { + views = append(views, attach.view) + } + return &appSending{text: text, attachments: views} +} + +// appAttachChars is the characters of paste a message carries, checked +// against the one bound a message has whichever door it came in by. +func appAttachChars(kept []appAttach) error { + total := 0 + for _, attach := range kept { + total += attach.chars + } + if total > appwire.MaxAttachChars { + return appwire.Errorf(appwire.CodeBadParams, "the pastes come to %d characters; one message carries at most %d", total, appwire.MaxAttachChars) + } + return nil +} + +// AddAttachment keeps a file, a link or a long paste for the next message. +func (b *appBackend) AddAttachment(agent string, attachment appwire.Attachment, text string) (appwire.Attachment, error) { + a, err := b.find(agent) + if err != nil { + return appwire.Attachment{}, err + } + if attachment.Kind == appwire.AttachPaste { + // A SHORT PASTE IS WORDS, NOT A CARD: the composer puts it into the + // message, and only a long one is kept to the side. lines := strings.Count(strings.TrimRight(text, "\n"), "\n") + 1 if lines <= appPasteLines && len(text) <= appPasteChars { return appwire.Attachment{}, appwire.Errorf(appwire.CodeBadParams, "a paste of %d lines goes into the message as text", lines) } - kept.view.Title, kept.view.Lines, kept.content = "Pasted text", &lines, text + } + var workspace string + b.locked(func() { workspace = a.view.Workspace }) + var changed map[string]appwire.FileChange + if attachment.Kind == appwire.AttachFile { + changed = b.changedInChat(a) + } + kept, err := b.prepareAttach(workspace, changed, attachment, text, false) + if err != nil { + return appwire.Attachment{}, err } b.locked(func() { a.attachN++ @@ -778,15 +879,32 @@ func (b *appBackend) RemoveAttachment(agent, id string) error { // SendWith sends a message with its attachments, written into the words the // engine is sent, and keeps them on the person's entry. func (b *appBackend) SendWith(agent, text, clientID string, ids []string) (uint64, error) { + turn, _, err := b.sendWith(agent, text, clientID, ids) + return turn, err +} + +// sendWith is [appBackend.SendWith], answering the attachments it carried. +// +// STAGED ATTACHMENTS ARE CONSUMED ONLY BY A TURN THAT STARTED. They are read, +// not taken, while the turn is asked for, and dropped once it has begun; a +// send that is refused — busy, a missing id, a conversation that would not +// open — leaves every chip staged for the retry, and a chip added meanwhile is +// a new id nothing here touches. The agent's gate is held from the read to the +// drop, so two sends cannot both carry one chip into two turns, and each turn +// carries exactly the snapshot it read. +func (b *appBackend) sendWith(agent, text, clientID string, ids []string) (uint64, []appwire.Attachment, error) { + if err := appwire.CheckAttachIDs(ids); err != nil { + return 0, nil, err + } a, err := b.find(agent) if err != nil { - return 0, err + return 0, nil, err } + a.gate.Lock() + defer a.gate.Unlock() var kept []appAttach var missing string b.locked(func() { - // Every id is checked before any is consumed, so one missing chip - // leaves the other staged ones for the retry. for _, id := range ids { attach, ok := a.attach[id] if !ok { @@ -795,35 +913,32 @@ func (b *appBackend) SendWith(agent, text, clientID string, ids []string) (uint6 } kept = append(kept, attach) } + }) + if missing != "" { + return 0, nil, appwire.Errorf(appwire.CodeNotFound, "there is no attachment %s", missing) + } + if err := appAttachChars(kept); err != nil { + return 0, nil, err + } + shown := appAttachShown(text, kept) + turn, err := b.sendLocked(a, appAttachWords(text, kept), clientID, shown) + if err != nil { + return 0, nil, err + } + b.locked(func() { for _, id := range ids { delete(a.attach, id) } }) - if missing != "" { - return 0, appwire.Errorf(appwire.CodeNotFound, "there is no attachment %s", missing) - } - var words strings.Builder - words.WriteString(text) var views []appwire.Attachment - for _, attach := range kept { - views = append(views, attach.view) - switch attach.view.Kind { - case appwire.AttachFile: - words.WriteString("\n\nAttached file: " + attach.view.Path) - case appwire.AttachLink: - words.WriteString("\n\nLink: " + attach.view.URL) - case appwire.AttachPaste: - words.WriteString("\n\nPasted text:\n```\n" + strings.TrimRight(attach.content, "\n") + "\n```") - } + if shown != nil { + views = shown.attachments } - b.locked(func() { a.sending = &appSending{text: text, attachments: views} }) - turn, err := b.Send(agent, words.String(), clientID) - b.locked(func() { a.sending = nil }) - return turn, err + return turn, views, nil } -// appSending is the words and attachments of the message being sent, which -// its entry shows instead of the words the engine was given. +// appSending is the words and attachments of a message whose entry shows +// them instead of the words the engine was given. type appSending struct { text string attachments []appwire.Attachment diff --git a/cmd/codeaf/appcrews.go b/cmd/codeaf/appcrews.go index 996197724e..3b7d9bdeaa 100644 --- a/cmd/codeaf/appcrews.go +++ b/cmd/codeaf/appcrews.go @@ -1380,6 +1380,9 @@ func (b *appBackend) CrewMessage(ask appwire.CrewMessage) (appwire.Entry, error) if !ok { return appwire.Entry{}, appwire.Errorf(appwire.CodeNotFound, "%s is not in %s", target, t.Name) } + if len(ask.Attachments) > 0 { + return b.crewMessageWith(t, member, target, ask) + } turn, err := b.Send(target, ask.Text, ask.ClientID) var wire *appwire.Error if errors.As(err, &wire) && wire.Code == appwire.CodeBusy { @@ -1394,16 +1397,49 @@ func (b *appBackend) CrewMessage(ask appwire.CrewMessage) (appwire.Entry, error) if err != nil { return appwire.Entry{}, err } - if member.Key != t.Manager && t.Manager != "" { - // THE MANAGER IS TOLD AS A MEMBER'S WORDS REACH IT: one line on the - // crew's log, in its digest at its next step. It does not wake it. - _ = teamstore.AppendTraffic(b.crewProfile(), t.ID, teamstore.Entry{ - Kind: teamstore.KindYou, From: teamstore.FromYou, To: member.Handle, ToKey: member.Key, Member: member.Key, Text: ask.Text, - }) - } + b.tellManager(t, member, ask.Text) return appwire.Entry{Agent: target, Turn: turn, Kind: appwire.EntryUser, At: b.now(), Text: ask.Text, ClientID: ask.ClientID}, nil } +// crewMessageWith is a crew message carrying attachments staged on its +// target. A MESSAGE WITH ATTACHMENTS IS NEVER QUEUED: the queue keeps words +// only, so queueing it would send the words and lose the attachments. A busy +// target refuses it instead, and every chip stays staged for the person to +// send again when the turn ends. +func (b *appBackend) crewMessageWith(t teamstore.Team, member teamstore.Member, target string, ask appwire.CrewMessage) (appwire.Entry, error) { + turn, views, err := b.sendWith(target, ask.Text, ask.ClientID, ask.Attachments) + var wire *appwire.Error + if errors.As(err, &wire) && wire.Code == appwire.CodeBusy { + return appwire.Entry{}, appwire.Errorf(appwire.CodeBusy, "%s is working on a turn; the message was not sent and its attachments are still attached", member.Handle) + } + if err != nil { + return appwire.Entry{}, err + } + logged := ask.Text + if strings.TrimSpace(logged) == "" { + titles := make([]string, 0, len(views)) + for _, view := range views { + titles = append(titles, view.Title) + } + logged = "Attached: " + strings.Join(titles, ", ") + } + b.tellManager(t, member, logged) + return appwire.Entry{Agent: target, Turn: turn, Kind: appwire.EntryUser, At: b.now(), Text: ask.Text, ClientID: ask.ClientID, Attachments: views}, nil +} + +// tellManager puts the person's words to a member on the crew's log. +// THE MANAGER IS TOLD AS A MEMBER'S WORDS REACH IT: one line on the crew's +// log, in its digest at its next step. It does not wake it. Words to the +// manager itself are not logged; it heard them. +func (b *appBackend) tellManager(t teamstore.Team, member teamstore.Member, text string) { + if member.Key == t.Manager || t.Manager == "" { + return + } + _ = teamstore.AppendTraffic(b.crewProfile(), t.ID, teamstore.Entry{ + Kind: teamstore.KindYou, From: teamstore.FromYou, To: member.Handle, ToKey: member.Key, Member: member.Key, Text: text, + }) +} + // CrewLog is a crew's log after a cursor, oldest first. func (b *appBackend) CrewLog(id, after string, limit int) ([]appwire.LogLine, string, error) { t, _, err := b.crewTeam(id) diff --git a/cmd/codeaf/appcrews_test.go b/cmd/codeaf/appcrews_test.go index 96a10c490b..4454a46940 100644 --- a/cmd/codeaf/appcrews_test.go +++ b/cmd/codeaf/appcrews_test.go @@ -16,7 +16,13 @@ import ( // member that is only on disk. func crewBackend(t *testing.T) (*appBackend, *appRecorder, appwire.Agent, string) { t.Helper() - backend, rec, agent := workBackend(t, &fakeConversation{}) + return crewBackendOn(t, &fakeConversation{}) +} + +// crewBackendOn is [crewBackend] whose conversations are conv. +func crewBackendOn(t *testing.T, conv *fakeConversation) (*appBackend, *appRecorder, appwire.Agent, string) { + t.Helper() + backend, rec, agent := workBackend(t, conv) backend.profileDir = t.TempDir() id := teamstore.NewID() err := teamstore.Update(backend.profileDir, func(f *teamstore.File) error { diff --git a/docs/changes/unreleased/0-app-message-attachments.md b/docs/changes/unreleased/0-app-message-attachments.md new file mode 100644 index 0000000000..00935a181e --- /dev/null +++ b/docs/changes/unreleased/0-app-message-attachments.md @@ -0,0 +1,24 @@ +--- +kind: added +title: codeaf app carries attachments on a new agent's first message and on crew messages +pr: 0 +surface: [engine] +invalidates: + - "agents.create took words only; a first message with files, links or a paste had to create first and send second. It now takes attachments [{kind file|paste|link, path?, text?, url?}], checked whole before anything is made, and attachments alone start the agent." + - "crews.message took words only. It now takes attachments (ids staged on the target), and an attached message to a busy target is refused busy rather than queued — the queue would have sent the words and lost the attachments. Words alone still queue." + - "agents.send consumed its staged attachments before the turn started, so a busy or failed send lost every chip. Chips are now consumed only by a turn that started; a refused send leaves them all staged." + - "agents.send with attachments to an engine without staging sent the words and silently dropped the attachments; it now answers unknown_method. agents.send and crews.message no longer need text when they carry attachments." + - "A paste had no size bound. One paste holds at most 200,000 characters, one message at most 10 attachments and 400,000 characters of paste, at every door; an id named twice is bad_params." +--- + +`agents.create {workspace, text?, attachments: [{kind: "file", path: "notes.md"}, +{kind: "paste", text: "…"}, {kind: "link", url: "https://…"}]}` answers at once +as before. A file or folder must exist (relative to the workspace, or +absolute); any refusal is `bad_params` with no agent, folder or event behind +it. The first user entry carries the attachments with ids; the model is sent +the same words `agents.send` writes for staged ones, a file as its path only. +A boot failure's retry sends the same words and shows the same attachments. + +`crews.message {crew, to?, text?, attachments: ["at3"]}` sends to the manager +by default or to `to` exactly; ids staged on another agent are `not_found` and +stay where they were. diff --git a/internal/appwire/attach_test.go b/internal/appwire/attach_test.go new file mode 100644 index 0000000000..dd3f326c72 --- /dev/null +++ b/internal/appwire/attach_test.go @@ -0,0 +1,132 @@ +package appwire + +import ( + "encoding/json" + "errors" + "strings" + "testing" +) + +// sendEngine is an Engine that is a Talker and a Crewer only as far as a send +// with attachments goes; every other verb of either is left unimplemented and +// would panic if a route reached for it. +type sendEngine struct { + fakeEngine + Talker + Crewer + agent, text string + ids []string + crew CrewMessage +} + +func (s *sendEngine) SendWith(agent, text, clientID string, ids []string) (uint64, error) { + s.agent, s.text, s.ids = agent, text, ids + return 9, nil +} + +func (s *sendEngine) CrewMessage(ask CrewMessage) (Entry, error) { + s.crew = ask + return Entry{Agent: "m1", Kind: EntryUser, Text: ask.Text}, nil +} + +func codeOf(err error) string { + var wire *Error + if errors.As(err, &wire) { + return wire.Code + } + return "" +} + +// A CREATE'S ATTACHMENTS ARE CHECKED WHOLE: a known kind with its own field, +// at most ten, one paste within its bound and all of them within theirs. +func TestAttachSourcesAreCheckedWhole(t *testing.T) { + big := strings.Repeat("é", MaxPasteChars) // characters, not bytes + half := strings.Repeat("x", MaxAttachChars/2) + eleven := make([]AttachSource, MaxAttachments+1) + for i := range eleven { + eleven[i] = AttachSource{Kind: AttachLink, URL: "https://example.com"} + } + for name, sources := range map[string][]AttachSource{ + "an unknown kind": {{Kind: "image", Path: "a.png"}}, + "no kind": {{Path: "a.go"}}, + "a file with no path": {{Kind: AttachFile, Path: " "}}, + "a link with no url": {{Kind: AttachLink}}, + "a paste with no text": {{Kind: AttachPaste, Text: "\n"}}, + "eleven": eleven, + "one paste too long": {{Kind: AttachPaste, Text: big + "x"}}, + "pastes too long": {{Kind: AttachPaste, Text: half}, {Kind: AttachPaste, Text: half + "x"}}, + } { + if err := CheckAttachSources(sources); codeOf(err) != CodeBadParams { + t.Errorf("%s answered %v", name, err) + } + } + ok := []AttachSource{{Kind: AttachPaste, Text: big}, {Kind: AttachFile, Path: "a.go"}, {Kind: AttachLink, URL: "https://example.com"}} + if err := CheckAttachSources(append(ok, eleven[:MaxAttachments-len(ok)]...)); err != nil { + t.Fatalf("ten attachments at the bound were refused: %v", err) + } + if err := CheckAttachSources(nil); err != nil { + t.Fatalf("no attachments were refused: %v", err) + } +} + +// A SEND'S IDS ARE NAMED ONCE EACH, at most ten, none empty. +func TestAttachIDsAreNamedOnce(t *testing.T) { + for name, ids := range map[string][]string{ + "twice": {"at1", "at2", "at1"}, + "empty": {"at1", " "}, + "eleven": strings.Fields("a b c d e f g h i j k"), + } { + if err := CheckAttachIDs(ids); codeOf(err) != CodeBadParams { + t.Errorf("%s answered %v", name, err) + } + } + if err := CheckAttachIDs(strings.Fields("a b c d e f g h i j")); err != nil { + t.Fatal(err) + } +} + +// THE ROUTES: a create with a bad attachment never reaches the engine, a good +// one reaches it whole; attachments alone are a message on agents.send and +// crews.message; an id named twice is refused; and an engine that cannot +// carry attachments refuses rather than sending the words without them. +func TestAttachmentRoutes(t *testing.T) { + create := methods["agents.create"] + eng := &sendEngine{} + if _, err := create.call(eng, json.RawMessage(`{"workspace":"/w","attachments":[{"kind":"image","path":"a.png"}]}`)); codeOf(err) != CodeBadParams { + t.Fatalf("a bad kind answered %v", err) + } + if eng.fakeEngine.created.Workspace != "" { + t.Fatalf("a refused create reached the engine: %+v", eng.fakeEngine.created) + } + if _, err := create.call(eng, json.RawMessage(`{"workspace":"/w","attachments":[{"kind":"paste","text":"log"},{"kind":"file","path":"a.go"}]}`)); err != nil { + t.Fatal(err) + } + if got := eng.fakeEngine.created.Attachments; len(got) != 2 || got[0].Text != "log" || got[1].Path != "a.go" { + t.Fatalf("the engine was asked %+v", eng.fakeEngine.created) + } + + send := methods["agents.send"] + if r, err := send.call(eng, json.RawMessage(`{"id":"a1","attachments":["at1","at2"]}`)); err != nil || r.(*TurnResult).Turn != 9 || eng.text != "" || len(eng.ids) != 2 { + t.Fatalf("an attachments-only send answered %v %v, the engine heard %+v", r, err, eng) + } + if _, err := send.call(eng, json.RawMessage(`{"id":"a1","text":"hi","attachments":["at1","at1"]}`)); codeOf(err) != CodeBadParams { + t.Fatalf("an id named twice answered %v", err) + } + if _, err := send.call(eng, json.RawMessage(`{"id":"a1"}`)); codeOf(err) != CodeBadParams { + t.Fatalf("an empty send answered %v", err) + } + if _, err := send.call(&fakeEngine{}, json.RawMessage(`{"id":"a1","text":"hi","attachments":["at1"]}`)); codeOf(err) != CodeUnknownMethod { + t.Fatalf("an engine without attachments answered %v", err) + } + + message := methods["crews.message"] + if _, err := message.call(eng, json.RawMessage(`{"crew":"c1","attachments":["at3"]}`)); err != nil || len(eng.crew.Attachments) != 1 || eng.crew.Attachments[0] != "at3" { + t.Fatalf("an attachments-only crew message answered %v, the engine heard %+v", err, eng.crew) + } + if _, err := message.call(eng, json.RawMessage(`{"crew":"c1"}`)); codeOf(err) != CodeBadParams { + t.Fatalf("an empty crew message answered %v", err) + } + if _, err := message.call(eng, json.RawMessage(`{"crew":"c1","text":"x","attachments":["at3","at3"]}`)); codeOf(err) != CodeBadParams { + t.Fatalf("a crew message naming an id twice answered %v", err) + } +} diff --git a/internal/appwire/convo.go b/internal/appwire/convo.go index f34783592b..0b9d28be92 100644 --- a/internal/appwire/convo.go +++ b/internal/appwire/convo.go @@ -3,6 +3,7 @@ package appwire import ( "strings" "time" + "unicode/utf8" ) // Wave 3b (PROTOCOL.md, V65–V70): the conversation's own entries and events, @@ -97,6 +98,88 @@ const ( FileOutside = "outside" ) +// AttachSource is one attachment named by what it is rather than by a staged +// id: agents.create carries these, because a conversation that does not exist +// yet has nowhere to stage a chip. A file names a path (relative to the +// workspace, or absolute) that must exist when it is accepted; a link its url; +// a paste its text. Nothing else is read from it. +type AttachSource struct { + Kind string `json:"kind" enum:"file,link,paste"` + Path string `json:"path,omitempty"` + Text string `json:"text,omitempty"` + URL string `json:"url,omitempty"` +} + +// The bounds of what one message may carry. ONE MESSAGE IS ONE TURN'S +// CONTEXT, so the bounds hold the same whether the attachments were staged +// first (agents.send, crews.message) or named on the create itself. +const ( + // MaxAttachments is how many attachments one message may carry. + MaxAttachments = 10 + // MaxPasteChars is the most characters one paste may hold. + MaxPasteChars = 200000 + // MaxAttachChars is the most characters of paste one message may carry. + MaxAttachChars = 400000 +) + +// CheckAttachSources is the shape of a create's attachments, checked before +// anything is made: a known kind with its own field, within the bounds. It +// reads no disk; whether a file is there is the engine's to say. +func CheckAttachSources(sources []AttachSource) error { + if len(sources) > MaxAttachments { + return Errorf(CodeBadParams, "a message carries at most %d attachments, not %d", MaxAttachments, len(sources)) + } + total := 0 + for i, s := range sources { + switch s.Kind { + case AttachFile: + if strings.TrimSpace(s.Path) == "" { + return Errorf(CodeBadParams, "attachment %d is a file and needs a path", i+1) + } + case AttachLink: + if strings.TrimSpace(s.URL) == "" { + return Errorf(CodeBadParams, "attachment %d is a link and needs a url", i+1) + } + case AttachPaste: + if strings.TrimSpace(s.Text) == "" { + return Errorf(CodeBadParams, "attachment %d is a paste and needs its text", i+1) + } + n := utf8.RuneCountInString(s.Text) + if n > MaxPasteChars { + return Errorf(CodeBadParams, "attachment %d is a paste of %d characters; one paste holds at most %d", i+1, n, MaxPasteChars) + } + total += n + default: + return Errorf(CodeBadParams, "attachment %d: kind is file, link or paste", i+1) + } + } + if total > MaxAttachChars { + return Errorf(CodeBadParams, "the pastes come to %d characters; one message carries at most %d", total, MaxAttachChars) + } + return nil +} + +// CheckAttachIDs is the shape of a send's staged attachment ids: within the +// count, none empty, and none named twice. A repeated id is refused rather +// than folded, so the context the model reads is never doubled and the app +// learns its list was wrong. +func CheckAttachIDs(ids []string) error { + if len(ids) > MaxAttachments { + return Errorf(CodeBadParams, "a message carries at most %d attachments, not %d", MaxAttachments, len(ids)) + } + seen := make(map[string]bool, len(ids)) + for _, id := range ids { + if strings.TrimSpace(id) == "" { + return Errorf(CodeBadParams, "an attachment id is empty") + } + if seen[id] { + return Errorf(CodeBadParams, "attachment %s is named twice", id) + } + seen[id] = true + } + return nil +} + // Queued is one message waiting for the turn to end. type Queued struct { ID string `json:"id"` @@ -399,6 +482,8 @@ var talkMethods = map[string]method{ return nil, Errorf(CodeBadParams, "a file needs a path, a link a url, and a paste its text") case p.Kind != AttachFile && p.Kind != AttachLink && p.Kind != AttachPaste: return nil, Errorf(CodeBadParams, "kind is file, link or paste") + case p.Kind == AttachPaste && utf8.RuneCountInString(p.Text) > MaxPasteChars: + return nil, Errorf(CodeBadParams, "one paste holds at most %d characters", MaxPasteChars) } attachment, err := t.AddAttachment(p.Agent, Attachment{Kind: p.Kind, Path: p.Path, URL: p.URL}, p.Text) if err != nil { diff --git a/internal/appwire/crews.go b/internal/appwire/crews.go index 91990ee96f..24ac82b20a 100644 --- a/internal/appwire/crews.go +++ b/internal/appwire/crews.go @@ -361,12 +361,17 @@ type MemberAsk struct { To string `json:"to"` } -// CrewMessage is crews.message's params. +// CrewMessage is crews.message's params. To is empty (or "manager") for the +// crew's manager, or a member's agent id. Attachments are ids staged with +// attachments.add on that same target agent; with them the text may be empty. +// A message with attachments is never queued behind a busy target: it is +// refused busy and its attachments stay staged for the person to send again. type CrewMessage struct { - Crew string `json:"crew"` - To string `json:"to"` - Text string `json:"text"` - ClientID string `json:"client_id"` + Crew string `json:"crew"` + To string `json:"to"` + Text string `json:"text"` + ClientID string `json:"client_id"` + Attachments []string `json:"attachments,omitempty"` } // Crewer is the engine's half of crews. @@ -483,9 +488,12 @@ var crewMethods = map[string]method{ return crewOf(c.CrewUpdate(p)) }), "crews.message": crewCall("crews.message", func(c Crewer, p CrewMessage) (*EntryResult, error) { - if strings.TrimSpace(p.Crew) == "" || strings.TrimSpace(p.Text) == "" { + if strings.TrimSpace(p.Crew) == "" || (strings.TrimSpace(p.Text) == "" && len(p.Attachments) == 0) { return nil, Errorf(CodeBadParams, "crews.message needs a crew and some text") } + if err := CheckAttachIDs(p.Attachments); err != nil { + return nil, err + } entry, err := c.CrewMessage(p) if err != nil { return nil, err diff --git a/internal/appwire/server.go b/internal/appwire/server.go index 8db36d9ee7..aeb4aa539b 100644 --- a/internal/appwire/server.go +++ b/internal/appwire/server.go @@ -285,6 +285,9 @@ var methods = map[string]method{ if p.BudgetUSD != nil && *p.BudgetUSD < 0 { return nil, Errorf(CodeBadParams, "budget_usd cannot be negative") } + if err := CheckAttachSources(p.Attachments); err != nil { + return nil, err + } agent, err := e.CreateAgent(p) if err != nil { return nil, err @@ -391,12 +394,20 @@ var methods = map[string]method{ if strings.TrimSpace(p.ID) == "" { return nil, Errorf(CodeBadParams, "agents.send needs an id") } - if strings.TrimSpace(p.Text) == "" { + // Attachments alone are a message: their words are the turn's. + if strings.TrimSpace(p.Text) == "" && len(p.Attachments) == 0 { return nil, Errorf(CodeBadParams, "agents.send needs some text") } var turn uint64 var err error - if t, ok := e.(Talker); ok && len(p.Attachments) > 0 { + if len(p.Attachments) > 0 { + t, terr := talker(e, "agents.send with attachments") + if terr != nil { + return nil, terr + } + if err := CheckAttachIDs(p.Attachments); err != nil { + return nil, err + } turn, err = t.SendWith(p.ID, p.Text, p.ClientID, p.Attachments) } else { turn, err = e.Send(p.ID, p.Text, p.ClientID) diff --git a/internal/appwire/wire.go b/internal/appwire/wire.go index 082dc38c86..c37177ada1 100644 --- a/internal/appwire/wire.go +++ b/internal/appwire/wire.go @@ -648,15 +648,22 @@ type Engine interface { } // CreateAsk is agents.create's params. An empty workspace is the person's home -// folder; a non-empty Text starts the first turn at once. +// folder; a non-empty Text, or any Attachments, starts the first turn at once. +// +// Attachments ride on that first message exactly as staged ones ride on +// agents.send: the person's entry carries them as [Attachment]s and the model +// is sent their words. They are checked whole — shape, bounds, and that each +// file is there — BEFORE the conversation is made, so a refused create leaves +// no agent behind. With attachments the text may be empty. // // It answers at once: the conversation is assembled behind the answer. type CreateAsk struct { - Workspace string `json:"workspace"` - Model string `json:"model"` - BudgetUSD *float64 `json:"budget_usd"` - Text string `json:"text"` - ClientID string `json:"client_id"` + Workspace string `json:"workspace"` + Model string `json:"model"` + BudgetUSD *float64 `json:"budget_usd"` + Text string `json:"text"` + ClientID string `json:"client_id"` + Attachments []AttachSource `json:"attachments,omitempty"` } // Warmer is an engine with something slow it can start on before anything is From f423188d81296eefa4df7b9f64cc2f82b7bb7d77 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 18:08:01 -0400 Subject: [PATCH 028/459] Advertise additive attachment request features in the handshake --- cmd/codeaf/app.go | 2 +- internal/appwire/wire.go | 10 ++++++---- 2 files changed, 7 insertions(+), 5 deletions(-) diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index 1b5505f9f7..db2f6316df 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -149,7 +149,7 @@ func runApp(args []string) error { guard.Go("app/orders", func() { backend.watchOrders(backend.sweepStop) }) // And what the last quit stopped in the middle of a reply carries on. guard.Go("app/resume-after-quit", backend.resumeAfterQuit) - err := server.Serve(ctx, os.Stdin, appwire.Hello{Version: resolvedVersion(), PID: os.Getpid(), Home: home.Dir()}, backend) + err := server.Serve(ctx, os.Stdin, appwire.Hello{Version: resolvedVersion(), PID: os.Getpid(), Home: home.Dir(), Features: []string{"agents.create.attachments", "crews.message.attachments"}}, backend) if backend.assembled.Load() { // The process half every conversation shared, closed the way the pipe // engine closes it once its conversation is gone (engine.go). diff --git a/internal/appwire/wire.go b/internal/appwire/wire.go index c37177ada1..b749cccf77 100644 --- a/internal/appwire/wire.go +++ b/internal/appwire/wire.go @@ -93,10 +93,12 @@ func Errorf(code, format string, args ...any) *Error { // Hello is the first line codeaf writes, before it reads anything. type Hello struct { - Contract int `json:"contract"` - Version string `json:"version"` - PID int `json:"pid"` - Home string `json:"home"` + // Features are additive request extensions; absent means unsupported. + Features []string `json:"features,omitempty"` + Contract int `json:"contract"` + Version string `json:"version"` + PID int `json:"pid"` + Home string `json:"home"` } // Agent is one conversation as the app sees it. ID is the conversation's From 9ea159f4b0634a81bbc4cb1a2a16b6ce90be0380 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 18:31:20 -0400 Subject: [PATCH 029/459] Sum team ledger pairs deterministically to keep idle snapshots stable --- cmd/codeaf/appcrewspend_test.go | 51 +++++++++++++++++++++++++++++++++ internal/teams/spend.go | 31 ++++++++++++++++---- internal/teams/spend_test.go | 22 ++++++++++++++ 3 files changed, 98 insertions(+), 6 deletions(-) create mode 100644 cmd/codeaf/appcrewspend_test.go diff --git a/cmd/codeaf/appcrewspend_test.go b/cmd/codeaf/appcrewspend_test.go new file mode 100644 index 0000000000..4a1b4902b4 --- /dev/null +++ b/cmd/codeaf/appcrewspend_test.go @@ -0,0 +1,51 @@ +package main + +import ( + "fmt" + "os" + "path/filepath" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + teamstore "github.com/Agent-Field/codeaf/internal/teams" +) + +func TestAppQuietCrewSpendDoesNotEmitChangedSnapshots(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + backend, rec, _ := workBackend(t, &fakeConversation{}) + backend.profileDir = t.TempDir() + const team = "aaaaaaaaaaaa" + ids := []string{"0000000000000001", "0000000000000002", "0000000000000003", "0000000000000004"} + members := []teamstore.Member{} + for i, id := range ids { + key := filepath.Join(t.TempDir(), id, "transcript.jsonl") + members = append(members, teamstore.Member{Key: key, File: key, Handle: fmt.Sprintf("m%d", i)}) + } + if err := teamstore.Save(backend.profileDir, []teamstore.Team{{ID: team, Name: "quiet", Manager: members[0].Key, Members: members, Made: time.Now(), ClosedAt: time.Now()}}); err != nil { + t.Fatal(err) + } + ledger := teamstore.UsageLedgerPath() + if err := os.MkdirAll(filepath.Dir(ledger), 0700); err != nil { + t.Fatal(err) + } + text := "" + for i, cost := range []float64{0.1, 0.2, 0.014895096, 1e-18} { + text += fmt.Sprintf("{\"day\":%q,\"session\":%q,\"usd\":%.18g,\"calls\":1}\n", teamstore.Today(), ids[i], cost) + } + if err := os.WriteFile(ledger, []byte(text), 0600); err != nil { + t.Fatal(err) + } + run := &appCrewRun{prints: map[string]string{}, members: map[string]string{}, cursors: map[string]string{}, acted: map[string]bool{}, packets: map[string]teamstore.Packet{}, asks: map[string]appwire.Ask{}, held: map[string][]string{}, opening: map[string]bool{}} + backend.crewTick(run, true) + for i := 0; i < 256; i++ { + backend.crewTick(run, false) + } + rec.mu.Lock() + defer rec.mu.Unlock() + for _, event := range rec.events { + if event == appwire.EventCrewChanged { + t.Fatal("an unchanged ledger emitted crew.changed") + } + } +} diff --git a/internal/teams/spend.go b/internal/teams/spend.go index 8a369df923..7340cb4c19 100644 --- a/internal/teams/spend.go +++ b/internal/teams/spend.go @@ -6,6 +6,7 @@ import ( "io" "os" "path/filepath" + "sort" "strings" "sync" "time" @@ -94,13 +95,32 @@ func TeamSpendIn(profileDir, ledger, teamID, day string) (Spend, error) { if err != nil { return out, err } - for pair, sum := range totals { - key, ok := ids[pair.session] - if !ok { - key, ok = ids[pair.root] + sumTeamSpend(&out, ids, totals) + return out, nil +} + +// sumTeamSpend orders contributing ledger pairs before adding floats. A map's +// iteration order must not change a quiet team's reported money or wake watchers. +func sumTeamSpend(out *Spend, ids map[string]string, totals map[spendPair]spendSum) { + pairs := make([]spendPair, 0) + for pair := range totals { + if _, ok := ids[pair.session]; ok { + pairs = append(pairs, pair) + } else if _, ok := ids[pair.root]; ok { + pairs = append(pairs, pair) } + } + sort.Slice(pairs, func(i, j int) bool { + if pairs[i].session == pairs[j].session { + return pairs[i].root < pairs[j].root + } + return pairs[i].session < pairs[j].session + }) + for _, pair := range pairs { + sum := totals[pair] + key, ok := ids[pair.session] if !ok { - continue + key = ids[pair.root] } out.USD += sum.usd out.Calls += sum.calls @@ -109,7 +129,6 @@ func TeamSpendIn(profileDir, ledger, teamID, day string) (Spend, error) { } out.ByMember[key] += sum.usd } - return out, nil } // SpendStamp is one stamp over what a team's spend is read from: the teams diff --git a/internal/teams/spend_test.go b/internal/teams/spend_test.go index c8d3cdfeda..cd21659737 100644 --- a/internal/teams/spend_test.go +++ b/internal/teams/spend_test.go @@ -4,6 +4,7 @@ import ( "fmt" "os" "path/filepath" + "reflect" "testing" ) @@ -84,3 +85,24 @@ func (m *spendMemory) bytes() int64 { defer m.mu.Unlock() return m.reads } + +func TestTeamSpendIsBitStableAcrossLedgerMapOrder(t *testing.T) { + pairs := []spendPair{{session: "a"}, {session: "b"}, {session: "c"}, {session: "d"}} + amounts := []float64{0.1, 0.2, 0.014895096, 1e-18} + ids := map[string]string{"a": "member", "b": "member", "c": "member", "d": "member"} + var first Spend + for n := 0; n < 256; n++ { + totals := map[spendPair]spendSum{} + for i := range pairs { + j := (i + n) % len(pairs) + totals[pairs[j]] = spendSum{usd: amounts[j], calls: 1} + } + var got Spend + sumTeamSpend(&got, ids, totals) + if n == 0 { + first = got + } else if !reflect.DeepEqual(first, got) { + t.Fatalf("quiet sum drifted: first=%+v got=%+v", first, got) + } + } +} From a1e0ff870b615479057f9a4c518cc6754f0bde6f Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 18:31:11 -0400 Subject: [PATCH 030/459] codeaf app: an open crew's aged member stays open in the list and is said once when held ListAgents stale-closed a member nobody had spoken to for the idle-close days unless it was already live, and holdMembers opened it without telling the app. The crew's keep now counts as held for the stale rule, and the one agent.changed heldLive would have sent is sent when the crew's look opens the member. Co-Authored-By: Claude Sonnet 5.5 --- cmd/codeaf/app.go | 2 +- cmd/codeaf/appcrews.go | 11 ++++-- cmd/codeaf/appcrews_test.go | 69 +++++++++++++++++++++++++++++++++++++ 3 files changed, 79 insertions(+), 3 deletions(-) diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index db2f6316df..ab606cfbe7 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -729,7 +729,7 @@ func (b *appBackend) ListAgents(workspace string) ([]appwire.Agent, error) { } seen[row.ID] = true view := b.remember(row) - if !stale.IsZero() && view.State == appwire.StateIdle && !view.UpdatedAt.IsZero() && view.UpdatedAt.Before(stale) && !b.held(view.ID) { + if !stale.IsZero() && view.State == appwire.StateIdle && !view.UpdatedAt.IsZero() && view.UpdatedAt.Before(stale) && !b.held(view.ID) && !b.crewKeeps(view.ID) { view.State = appwire.StateClosed } view.SpentUSD, view.LastActiveAt = row.Spend, row.At diff --git a/cmd/codeaf/appcrews.go b/cmd/codeaf/appcrews.go index 3b7d9bdeaa..43ce99e055 100644 --- a/cmd/codeaf/appcrews.go +++ b/cmd/codeaf/appcrews.go @@ -787,8 +787,15 @@ func (b *appBackend) holdMembers(run *appCrewRun, held map[string][]string) { } guard.Go("app/crew-hold", func() { defer run.locked(func() { delete(run.opening, agent) }) - if a, err := b.find(agent); err == nil { - _, _, _ = b.ensure(a) + a, err := b.find(agent) + if err != nil { + return + } + // ONE agent.changed FOR AN AGENT THIS OPENED, as heldLive says it: + // a list the app read while the agent was closed is otherwise + // never told. A conversation already held opens nothing. + if view, opened, err := b.ensure(a); err == nil && opened { + b.emit.Emit(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) } }) } diff --git a/cmd/codeaf/appcrews_test.go b/cmd/codeaf/appcrews_test.go index 4454a46940..503983ca95 100644 --- a/cmd/codeaf/appcrews_test.go +++ b/cmd/codeaf/appcrews_test.go @@ -2,7 +2,9 @@ package main import ( "errors" + "github.com/Agent-Field/codeaf/internal/session" "os" + "path/filepath" "strings" "testing" "time" @@ -323,3 +325,70 @@ func TestAppCrewUpdateClearHuePersistsAndConflictsAreRefused(t *testing.T) { t.Fatalf("recoloured crew = %+v, %v", crew, err) } } + +// AN OPEN CREW'S MEMBER IS NEVER READ CLOSED FOR AGE, AND IS SAID ONCE WHEN THE +// CREW OPENS IT. A member nobody spoke to for longer than the idle-close +// setting lists as closed only while no open crew keeps it; the crew's own look +// opens it, and the app is told with one agent.changed, as heldLive tells it. +func TestAppAgedCrewMemberIsKeptOpenAndSaidOnceWhenHeld(t *testing.T) { + t.Setenv("CODEAF_HOME", filepath.Join(t.TempDir(), "state")) + bucket, err := v3ProjectDir(t.TempDir()) + if err != nil { + t.Fatal(err) + } + dir := writeV3Session(t, bucket, "00000000000000b1", "hello", time.Now().Add(-10*24*time.Hour)) + transcript := session.Place{Dir: dir}.Transcript() + + gate := make(chan struct{}) + rec := newAppRecorder() + backend := newFakeBackend(rec, func(ask appOpenAsk) (*appConn, error) { + <-gate + return &appConn{Conv: &fakeConversation{}, Workspace: ask.Workspace, Transcript: ask.Transcript, Release: func() {}}, nil + }) + t.Cleanup(func() { backend.Close() }) + backend.state = &appState{path: filepath.Join(t.TempDir(), appStateName)} + backend.profileDir = t.TempDir() + err = teamstore.Update(backend.profileDir, func(f *teamstore.File) error { + id := teamstore.NewID() + f.Teams = append(f.Teams, teamstore.Team{ID: id, Name: "harbor", Made: time.Now()}) + return f.AddMember(id, teamstore.Member{Key: transcript, File: transcript, Where: "/w", Word: "old", Handle: "old"}) + }) + if err != nil { + t.Fatal(err) + } + agentState := func() (string, string) { + agents, err := backend.ListAgents("") + if err != nil || len(agents) != 1 { + t.Fatalf("agents = %+v, %v", agents, err) + } + return agents[0].ID, agents[0].State + } + id, state := agentState() + if state != appwire.StateClosed { + t.Fatalf("before any crew keeps it, the aged member reads %q", state) + } + + run := backend.crews() + backend.crewTick(run, false) + // The open is still waiting at the gate: the crew keeps the row open anyway. + if _, state := agentState(); state == appwire.StateClosed { + t.Fatal("an open crew's aged member listed closed") + } + close(gate) + rec.waitFor(t, 5*time.Second, func(event string, data any) bool { + c, ok := data.(appwire.AgentChanged) + return ok && c.Agent.ID == id && c.Agent.State != appwire.StateClosed + }) + backend.crewTick(run, false) + changed := 0 + rec.mu.Lock() + for _, data := range rec.data { + if c, ok := data.(appwire.AgentChanged); ok && c.Agent.ID == id { + changed++ + } + } + rec.mu.Unlock() + if changed != 1 { + t.Fatalf("agent.changed said %d times for the held member, want 1", changed) + } +} From 515f0865e91e50f753bc529287fa4f410f819ae3 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 18:31:57 -0400 Subject: [PATCH 031/459] session: an approved task whose run did not start settles failed An approved proposal whose run the run road refused (the plan store's containment-lineage rule, a copy that would not cut, a program that could not start, another folder's run underway) answered only the model. The card's last word was the approval, so every surface, the app's live task list and tasks.list kept a queued task the graph never had. commitProposalToRun now publishes that row failed with runDidNotStart's sentence as its report through publishRunRow: live to every watcher after the approval, kept on the checkpoint and the task index, read back failed after a reopen. errRunRoadUnavailable, which falls through to the older engine under the same id, is not settled. The app projects a failed task with no result from its report, as the engine's own AppTasks read already did. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/apptask_unstarted_test.go | 95 +++++++++++++ cmd/codeaf/appwork.go | 7 + .../0-approved-task-start-refused.md | 21 +++ internal/manual/chat/worker-harness.md | 4 +- internal/session/task.go | 34 +++++ .../session/task_proposal_unstarted_test.go | 133 ++++++++++++++++++ 6 files changed, 293 insertions(+), 1 deletion(-) create mode 100644 cmd/codeaf/apptask_unstarted_test.go create mode 100644 docs/changes/unreleased/0-approved-task-start-refused.md create mode 100644 internal/session/task_proposal_unstarted_test.go diff --git a/cmd/codeaf/apptask_unstarted_test.go b/cmd/codeaf/apptask_unstarted_test.go new file mode 100644 index 0000000000..da6673653e --- /dev/null +++ b/cmd/codeaf/apptask_unstarted_test.go @@ -0,0 +1,95 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/session" +) + +// AN APPROVED PROPOSAL WHOSE START WAS REFUSED IS LISTED FAILED, NEVER QUEUED. +// +// The engine's order for one id is the card on its clock, the card restated +// approved, and then — because the store refused the run — the run's row +// settled failed with the reason as its report and no result. The live list +// used to stop at the approval and keep a queued task forever; it now ends on +// the failure, says what stopped it, and the list read agrees. +func TestAppAnApprovedProposalThatDidNotStartIsListedFailed(t *testing.T) { + conv := &fakeConversation{} + backend, rec, agent := workBackend(t, conv) + deadline := time.Now().Add(12 * time.Second).Round(time.Second) + reason := `task 2 did not start: task "2" has hard dependency "1" across its containment lineage. Nothing is running for it and nothing was started in its place.` + + conv.lane(session.Event{Kind: session.EventTaskProposal, Task: &session.TaskNotice{ID: 2, Title: "consume the run", Deadline: deadline}}) + taskChanged(rec, t, func(task appwire.Task) bool { return task.ID == agent.ID+":2" && task.Proposed }) + conv.lane(session.Event{Kind: session.EventTaskProposal, Task: &session.TaskNotice{ID: 2, Title: "consume the run", Decided: &session.TaskAnswer{Approved: true}}}) + taskChanged(rec, t, func(task appwire.Task) bool { + return task.ID == agent.ID+":2" && !task.Proposed && task.State == appwire.TaskQueued + }) + conv.lane(session.Event{Kind: session.EventTaskUpdate, Task: &session.TaskNotice{ID: 2, Title: "consume the run", State: session.TaskFailed, Report: reason, EndedAt: time.Now()}}) + failed := taskChanged(rec, t, func(task appwire.Task) bool { + return task.ID == agent.ID+":2" && task.State == appwire.TaskFailed + }) + if failed.Proposed || failed.ClockUntil != nil || failed.Result != reason || failed.StartedAt != nil { + t.Fatalf("the refused task = %+v, want failed with the reason and no start", failed) + } + + list, err := backend.ListTasks(agent.ID) + if err != nil { + t.Fatal(err) + } + found := false + for _, task := range list { + if task.ID != agent.ID+":2" { + continue + } + found = true + if task.State != appwire.TaskFailed || task.Proposed || !strings.Contains(task.Result, "containment lineage") { + t.Fatalf("the listed task = %+v, want failed with the reason", task) + } + } + if !found { + t.Fatalf("the refused task is missing from the list: %+v", list) + } +} + +// A REOPENED CONVERSATION LISTS THE SAME FAILURE from its checkpoint: a run +// row kept failed with only a report reads failed with that report as its +// result, exactly as the engine's own read answers it. +func TestAppAnUnstartedRunRowReadsBackFailedWithItsReason(t *testing.T) { + root := resolvedTempDir(t) + t.Setenv(home.EnvVar, filepath.Join(root, "state")) + t.Setenv(config.ProfileDirEnv, filepath.Join(root, "profile")) + workspace := filepath.Join(root, "work") + if err := os.MkdirAll(workspace, 0o700); err != nil { + t.Fatal(err) + } + transcript, err := appMint(workspace) + if err != nil { + t.Fatal(err) + } + tasks := `{"type":"tasks","version":1,"seq":2,"runs":[ +{"id":2,"title":"consume the run","state":"failed","report":"task 2 did not start: the store refused it. Nothing is running for it and nothing was started in its place.","endedAt":"2026-10-15T09:00:00Z"}]}` + if err := os.WriteFile(filepath.Join(filepath.Dir(transcript), "tasks.json"), []byte(tasks), 0o600); err != nil { + t.Fatal(err) + } + backend := newAppBackend(newAppRecorder(), nil) + defer backend.Close() + id := appAgentID(transcript) + list, err := backend.ListTasks(id) + if err != nil { + t.Fatal(err) + } + if len(list) != 1 { + t.Fatalf("tasks = %+v", list) + } + if task := list[0]; task.ID != id+":2" || task.State != appwire.TaskFailed || !strings.HasPrefix(task.Result, "task 2 did not start") { + t.Fatalf("the reopened task = %+v, want failed with its reason", task) + } +} diff --git a/cmd/codeaf/appwork.go b/cmd/codeaf/appwork.go index 09a45a886d..69b92f8475 100644 --- a/cmd/codeaf/appwork.go +++ b/cmd/codeaf/appwork.go @@ -204,6 +204,13 @@ func appTaskOf(agent string, notice *session.TaskNotice, proposal bool, phase st Irreversible: notice.Irreversible, YourCall: state == appwire.TaskUnverified, Result: redact.Secrets(strings.TrimSpace(notice.Result)), Files: appFiles(notice.Changed), SpentUSD: appFigure(notice.CostUSD), } + // A FAILED TASK SAYS WHAT STOPPED IT. A run row that ended without working + // carries its reason as the report and no result — a hand-off whose run never + // started is the plainest case — and the engine's own read answers the same + // row with the report as its result ([session.Agent.AppTasks]). + if state == appwire.TaskFailed && task.Result == "" { + task.Result = redact.Secrets(strings.TrimSpace(notice.Report)) + } if notice.Parent != 0 && notice.Parent != notice.ID { task.Parent = appTaskID(agent, notice.Parent) } diff --git a/docs/changes/unreleased/0-approved-task-start-refused.md b/docs/changes/unreleased/0-approved-task-start-refused.md new file mode 100644 index 0000000000..4d9355a505 --- /dev/null +++ b/docs/changes/unreleased/0-approved-task-start-refused.md @@ -0,0 +1,21 @@ +--- +kind: fixed +title: an approved task whose run did not start settles failed instead of staying queued +pr: 0 +surface: [chat, engine, docs] +invalidates: + - "An approved proposal whose run the run road refused (the store's lineage rule, a copy that would not cut, a program that could not start, another folder's run underway) told only the model `task N did not start`. The card's last word was the approval, which every surface reads as queued work, so `codeaf app`'s live task list and `tasks.list` kept a queued task the graph never had. The engine now publishes that task's row as `failed` with the `did not start` sentence as its report, live to every watcher, on the checkpoint and the task index, so a reopened conversation lists it failed too." + - "`codeaf app` projected a failed task's `result` from the notice's result only, so a row that carried only a report (an unstarted hand-off, a held run whose preparation failed) read failed with no reason. A failed task with no result now carries its report, as the engine's own `tasks.get` read already did." +--- + +Was true: the clock expiring on a proposal for task 2 that depended on a run +already underway as task 1 restated the card approved, the join made task 2 a +child of task 1, the plan store refused the hard dependency across its own +containment lineage, and nothing after that named task 2. The app heard queued +and kept it forever. + +Is true now: the same refusal settles task 2 `failed` with `task 2 did not +start: task "2" has hard dependency "1" across its containment lineage. Nothing +is running for it and nothing was started in its place.` Whether a dependency on +the run's own root should wait for that run instead of joining it is a separate +question this change does not answer. diff --git a/internal/manual/chat/worker-harness.md b/internal/manual/chat/worker-harness.md index d20e5c891d..b738e4866a 100644 --- a/internal/manual/chat/worker-harness.md +++ b/internal/manual/chat/worker-harness.md @@ -227,7 +227,9 @@ plan could not be opened or its copy could not be cut, the answer is started in its place.` A typed `/task` answers the same sentence; the model, reading it at the proposal door, is told besides to propose it again or tell you what stopped it — words for the model, never shown to you. It reads as a failure, never as `task N started`, -and the task is not quietly put on the older engine's tree. Only a build with no run +and the task is not quietly put on the older engine's tree. The approved task's own +row settles `failed` with that sentence as its reason — it is never left waiting to +begin — and it is still listed failed after the conversation is reopened. Only a build with no run engine at all, or a conversation with nowhere to keep a plan, uses the older engine, because there the run road was never there to take. diff --git a/internal/session/task.go b/internal/session/task.go index 9fcfc82980..b5c925c4a3 100644 --- a/internal/session/task.go +++ b/internal/session/task.go @@ -964,6 +964,7 @@ func (a *Agent) commitProposalToRun(ctx context.Context, p *stagedProposal, spec if spec.via != "" { m, err := a.delegateFor(spec.via) if err != nil { + a.settleUnstartedHandoff(p.id, spec.title, err) return err.Error(), true, true } via = &m @@ -992,6 +993,12 @@ func (a *Agent) commitProposalToRun(ctx context.Context, p *stagedProposal, spec } joined, err := a.startOrJoinTaskRunVia(context.WithoutCancel(ctx), p.id, spec.title, description, spec.dependsOn, stand, question, via, asked...) a.rollbackFailedProgramStart(via, p.id, prior, err) + // AN APPROVED HAND-OFF THAT DID NOT START IS SETTLED WHERE IT IS SEEN. The + // card's last word was the approval ([taskWait.decided]), which every + // surface reads as work waiting to begin; the refusal below reaches only the + // model. So every ending of this door that started nothing publishes the + // row as failed, in the same words, before it answers. + a.settleUnstartedHandoff(p.id, spec.title, err) if refusal := (standsElsewhereError{}); errors.As(err, &refusal) { return refusal.Error(), true, true } @@ -1014,6 +1021,33 @@ func (a *Agent) commitProposalToRun(ctx context.Context, p *stagedProposal, spec return "", false, false } +// settleUnstartedHandoff ends the row of an approved hand-off whose run never +// started: failed, with [runDidNotStart]'s sentence as what stopped it. +// +// WITHOUT IT THE APPROVAL WAS THE LAST THING ANY SURFACE HEARD. The card was +// restated approved with its clock gone ([taskWait.decided]), which a surface +// reads as work queued to begin, and nothing that followed was about this id: +// the store refused it, no run row was published, and the refusal went back to +// the model alone. A window, the app's task list and a turn's replay all kept a +// queued task that nothing would ever start. The row is published the way every +// other ending of a run is ([Agent.publishRunRow]), so it is said live, kept on +// the checkpoint and the index, and read back as failed after a reopen — a true +// account of an attempt, never deleted and never left pending. +// +// A nil error is a run that started, and [errRunRoadUnavailable] is a hand-off +// the older engine is about to admit under the same id; neither is settled. +func (a *Agent) settleUnstartedHandoff(id uint64, title string, err error) { + g := a.graph() + if g == nil || id == 0 || err == nil || errors.Is(err, errRunRoadUnavailable) { + return + } + reason := runDidNotStart(id, err) + a.publishRunRow(g, TaskNotice{ + ID: id, Title: title, State: TaskFailed, + Report: reason, EndedAt: a.taskClockNow(), + }) +} + // namedModel is the model a hand-off named for its work, and nothing when it // named none — a task that names nothing is the crew's to seat. func namedModel(spec taskSpec) string { diff --git a/internal/session/task_proposal_unstarted_test.go b/internal/session/task_proposal_unstarted_test.go new file mode 100644 index 0000000000..e33bf6f651 --- /dev/null +++ b/internal/session/task_proposal_unstarted_test.go @@ -0,0 +1,133 @@ +package session + +import ( + "context" + "path/filepath" + "strconv" + "strings" + "testing" + "time" +) + +// AN APPROVED HAND-OFF THE RUN STORE REFUSED IS SETTLED FAILED, NEVER LEFT +// QUEUED. +// +// The case the owner's screen caught: a run is underway as task 1, and a +// proposal for task 2 that depends on task 1 is approved. Joining the run makes +// task 2 a child of task 1, the store refuses a hard dependency on its own +// ancestor, and nothing starts. The card's last word was the approval, which +// every surface reads as queued work, and nothing after it named task 2 — so +// the app's task list kept a queued task forever that the graph had never +// heard of. Now the refusal publishes the row as failed with the store's own +// reason, live to every watcher after the approval, in the engine's app read, +// and on the checkpoint a reopened conversation reads back. +func TestAnApprovedHandoffTheStoreRefusedSettlesFailed(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + double := newBeltRunDouble("the run did the work") + registerBeltRunEngine(t, double) + dir := t.TempDir() + transcript := filepath.Join(dir, placeTranscript) + agent, _ := newTestAgent(t, beltRunCompleter{text: "the run did the work"}, func(config *Config) { + config.Workspace = newTestRepo(t) + config.Place = Place{Dir: dir} + config.SessionFile = transcript + config.AskConsent = false + config.TaskAutoApproveSeconds = 0 + }) + defer endBeltRun(t, agent, double) + root, _, _, err := agent.StartTask(context.Background(), "open the run", false) + if err != nil { + t.Fatal(err) + } + <-double.entered + lane, stop := agent.WatchTaskUpdates() + defer stop() + + staged := agent.stageTask(context.Background(), beltProposalArgs("Consume the run", "the output uses the run", root)) + proposal, ok := staged.(*stagedProposal) + if !ok { + answer, _, _ := staged.Commit(context.Background()) + t.Fatalf("the proposal was not staged: %q", answer) + } + agent.ResolveTask(proposal.id, TaskAnswer{Approved: true}) + answer, failed, err := proposal.Commit(context.Background()) + if err != nil { + t.Fatalf("Commit: %v", err) + } + if !failed || !strings.Contains(answer, "did not start") || !strings.Contains(answer, "containment lineage") { + t.Fatalf("the refused hand-off's receipt = failed %v %q, want it to say it did not start and why", failed, answer) + } + + // LIVE: the last word any watcher hears about this id is the failure, after + // the approval, with the reason. + var last *TaskNotice + approved := false + deadline := time.After(5 * time.Second) + for last == nil || last.State != TaskFailed { + select { + case event := <-lane: + if event.Task == nil || event.Task.ID != proposal.id { + continue + } + if event.Kind == EventTaskProposal && event.Task.Decided != nil && event.Task.Decided.Approved { + approved = true + } + if event.Kind == EventTaskUpdate { + notice := *event.Task + last = ¬ice + } + case <-deadline: + t.Fatalf("no failed row for task %d reached a watcher; last update %+v", proposal.id, last) + } + } + if !approved { + t.Error("the watcher never heard the approval the failure follows") + } + if !strings.Contains(last.Report, "did not start") || !strings.Contains(last.Report, "containment lineage") { + t.Errorf("the failed row's report = %q, want the store's reason", last.Report) + } + if !last.StartedAt.IsZero() { + t.Errorf("a task that never started carries a start time %v", last.StartedAt) + } + + // THE ENGINE'S OWN READ agrees: the id exists, failed, with the reason. + view, found, err := agent.AppTask(strconv.FormatUint(proposal.id, 10)) + if err != nil || !found { + t.Fatalf("AppTask(%d) found=%v err=%v, want the failed row", proposal.id, found, err) + } + if view.State != string(TaskFailed) || !strings.Contains(view.Result, "containment lineage") { + t.Errorf("AppTask(%d) = state %q result %q, want failed with the reason", proposal.id, view.State, view.Result) + } + + // AND A REOPEN READS IT BACK FAILED, not queued and not gone: the + // checkpoint and the index are what a surface lists a closed conversation's + // tasks from. + var kept *TaskNotice + for _, notice := range ReadSessionTasks(transcript) { + if notice.ID == proposal.id { + notice := notice + kept = ¬ice + } + } + if kept == nil { + t.Fatalf("the checkpoint keeps no row for task %d", proposal.id) + } + if kept.State != TaskFailed || !strings.Contains(kept.Report, "containment lineage") { + t.Errorf("the checkpoint's row for task %d = state %q report %q, want failed with the reason", proposal.id, kept.State, kept.Report) + } + indexed := false + for _, row := range ReadTaskIndex(TaskIndexPath(transcript)) { + if row.ID == strconv.FormatUint(proposal.id, 10) { + indexed = true + if row.Status != string(TaskFailed) { + t.Errorf("the index's row for task %d is %q, want failed", proposal.id, row.Status) + } + } + } + if !indexed { + t.Errorf("the index keeps no row for task %d", proposal.id) + } + if agent.graph().node(proposal.id) != nil { + t.Error("the refused hand-off became a node of the older tree") + } +} From 7125dbbf525115faf61fc4369e943d7e82bab6c1 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 18:30:21 -0400 Subject: [PATCH 032/459] codeaf app: page history before dressing it, and read journals through a bounded record cache Entries told every tool row in a conversation, redacted its title and lines and read its facts, then kept two hundred. The page is now cut first: ids, kinds and the cursor are still counted over the whole record, and only the page's rows are dressed and only its calls' facts read. session.RecordCache keeps the parsed Record of a journal whose device, inode, size, mtime and ctime are unchanged and whose ctime has settled, and gates every reading through two operation slots and a byte budget held until the WithRecord callback returns. A saturated gate answers ErrRecordReadBusy, which the app says as busy. Failed, short, newer-format and non-regular readings are never kept; a named pipe is never opened. Each callback gets its own copy of a kept record. The first open, and the first after any write, is still a whole parse by the one parser; the parser's transient allocation is bounded only by the slot count. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/app.go | 110 +++- cmd/codeaf/appentrypage_test.go | 222 +++++++ internal/session/recordcache.go | 530 ++++++++++++++++ .../session/recordcache_fifo_other_test.go | 7 + .../session/recordcache_fifo_unix_test.go | 7 + internal/session/recordcache_test.go | 567 ++++++++++++++++++ internal/session/recordstamp_darwin.go | 32 + internal/session/recordstamp_linux.go | 32 + internal/session/recordstamp_other.go | 14 + 9 files changed, 1504 insertions(+), 17 deletions(-) create mode 100644 cmd/codeaf/appentrypage_test.go create mode 100644 internal/session/recordcache.go create mode 100644 internal/session/recordcache_fifo_other_test.go create mode 100644 internal/session/recordcache_fifo_unix_test.go create mode 100644 internal/session/recordcache_test.go create mode 100644 internal/session/recordstamp_darwin.go create mode 100644 internal/session/recordstamp_linux.go create mode 100644 internal/session/recordstamp_other.go diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index ab606cfbe7..6fe145b7fa 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -2069,6 +2069,12 @@ func (b *appBackend) rememberAlways(call appConsentCall) bool { return config.RememberToolApproval(profile, tool, "allow") == nil } +// appRecords keeps the readings of journals that have not changed, so paging +// back through a long conversation parses its journal once rather than once a +// page, and bounds how many readings run at once. Its limits are the session +// package's own. +var appRecords = session.NewRecordCache(session.RecordCacheLimits{}) + // Entries reads the agent's transcript off the disk, whether or not a // conversation is held for it: the journal is written as the turn goes. func (b *appBackend) Entries(id, before string, limit int) ([]appwire.Entry, bool, error) { @@ -2088,21 +2094,42 @@ func (b *appBackend) Entries(id, before string, limit int) ([]appwire.Entry, boo if transcript == "" { return []appwire.Entry{}, false, nil } - record := session.ReadTranscript(transcript) - items := appRecordItems(record) - all := appEntriesOf(id, items, at, marks) - // A tool entry carries its call's facts, as its row did live. - calls := map[string]session.DisplayEntry{} - for _, item := range items { - if item.Role == "tool" && item.CallID != "" { - calls[item.CallID] = item - } + var page []appwire.Entry + var more bool + err = appRecords.WithRecord(transcript, func(record session.Record) error { + var err error + page, more, err = appEntryPage(id, record, at, marks, before, limit, func(item session.DisplayEntry) appwire.ToolFacts { + return b.appEntryFacts(a, item) + }) + return err + }) + if err != nil { + return nil, false, appEntriesBusy(err) } - for i := range all { - if item, ok := calls[all[i].Call]; ok && all[i].Kind == appwire.EntryTool { - all[i].ToolFacts = b.appEntryFacts(a, item) - } + return page, more, nil +} + +// appEntriesBusy says a reading refused at the gate as busy, so a client asks +// again rather than drawing an empty conversation; any other error is itself. +func appEntriesBusy(err error) error { + if errors.Is(err, session.ErrRecordReadBusy) { + return appwire.Errorf(appwire.CodeBusy, "too many conversations are being read at once; ask again in a moment") } + return err +} + +// appEntryPage is one page of a record's entries, newest last, and whether +// older entries sit before it. +// +// THE PAGE IS CUT BEFORE ANY TOOL ROW IS DRESSED OR ITS FACTS READ. A tool's +// title and lines are redacted and its facts can open its output file, so +// doing that for every call in a long conversation and then keeping two +// hundred was most of the cost of a page; the entries, their ids and the +// cursor are still told over the WHOLE record, because an id counts the +// person's messages from the first and an update is known only by a later call. +func appEntryPage(id string, record session.Record, at time.Time, marks map[int]appTurnMark, before string, limit int, facts func(session.DisplayEntry) appwire.ToolFacts) ([]appwire.Entry, bool, error) { + items := appRecordItems(record) + all, from := appEntryShapes(id, items, at, marks) if words := strings.TrimSpace(record.Unreadable); words != "" { all = append(all, appwire.Entry{ID: "unreadable", Agent: id, Kind: appwire.EntryNotice, At: at, Text: words}) } @@ -2121,6 +2148,33 @@ func (b *appBackend) Entries(id, before string, limit int) ([]appwire.Entry, boo } start := max(end-limit, 0) page := append([]appwire.Entry{}, all[start:end]...) + for i := range page { + if start+i < len(from) { + appDressTool(&page[i], items[from[start+i]]) + } + } + // A tool entry carries its call's facts, as its row did live. The call a + // page names is the LAST item to carry its id, as it always was. + wanted := map[string]bool{} + for _, entry := range page { + if entry.Kind == appwire.EntryTool && entry.Call != "" { + wanted[entry.Call] = true + } + } + if len(wanted) == 0 { + return page, start > 0, nil + } + calls := make(map[string]session.DisplayEntry, len(wanted)) + for _, item := range items { + if item.Role == "tool" && wanted[item.CallID] { + calls[item.CallID] = item + } + } + for i := range page { + if item, ok := calls[page[i].Call]; ok && page[i].Kind == appwire.EntryTool { + page[i].ToolFacts = facts(item) + } + } return page, start > 0, nil } @@ -2131,7 +2185,31 @@ func (b *appBackend) Entries(id, before string, limit int) ([]appwire.Entry, boo // A message this process sent carries its turn number, and the person's entry // its client id, from marks; one sent before this launch has neither. func appEntriesOf(agent string, items []session.DisplayEntry, at time.Time, marks map[int]appTurnMark) []appwire.Entry { + entries, from := appEntryShapes(agent, items, at, marks) + for i := range entries { + appDressTool(&entries[i], items[from[i]]) + } + return entries +} + +// appDressTool writes a tool entry's title, summary and detail line, which +// are the costly part of an entry: each is redacted. Any other entry is left +// as it is. +func appDressTool(entry *appwire.Entry, item session.DisplayEntry) { + if entry.Kind != appwire.EntryTool { + return + } + entry.Title = redact.Secrets(tui3.ToolTitle(item.Tool, item.Args, item.Hint)) + entry.Summary = redact.Secrets(item.Hint) + entry.Detail = appDetail(redact.Secrets(item.Output)) +} + +// appEntryShapes is the entries with their ids, kinds and words, and for each +// the index of the item it was told from, with tool entries not yet dressed. +// Ids are counted over the whole record, so this is always told whole. +func appEntryShapes(agent string, items []session.DisplayEntry, at time.Time, marks map[int]appTurnMark) ([]appwire.Entry, []int) { entries := make([]appwire.Entry, 0, len(items)) + from := make([]int, 0, len(items)) n, m, k, c := -1, 0, 0, 0 // A MESSAGE FOLLOWED BY A TOOL CALL BEFORE THE PERSON SPOKE AGAIN WAS AN // UPDATE, as the live pump named it. @@ -2207,9 +2285,6 @@ func appEntriesOf(agent string, items []session.DisplayEntry, at time.Time, mark call = "#" + strconv.Itoa(i) } entry.ID, entry.Kind, entry.Call, entry.Name = appToolEntryID(call), appwire.EntryTool, item.CallID, item.Tool - entry.Title = redact.Secrets(tui3.ToolTitle(item.Tool, item.Args, item.Hint)) - entry.Summary = redact.Secrets(item.Hint) - entry.Detail = appDetail(redact.Secrets(item.Output)) case "note": if strings.TrimSpace(item.Text) == "" { continue @@ -2231,8 +2306,9 @@ func appEntriesOf(agent string, items []session.DisplayEntry, at time.Time, mark } } entries = append(entries, entry) + from = append(from, i) } - return entries + return entries, from } // SetModel moves the agent to another model, opening its conversation first if diff --git a/cmd/codeaf/appentrypage_test.go b/cmd/codeaf/appentrypage_test.go new file mode 100644 index 0000000000..2be73c89eb --- /dev/null +++ b/cmd/codeaf/appentrypage_test.go @@ -0,0 +1,222 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + "reflect" + "strconv" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/session" +) + +// appPageRecord is a record of turns, each a question, a narrated call and +// its answer; a call id repeats every hundred turns, the way a provider's +// short ids can, and the record is compacted when compacted is set. +func appPageRecord(turns int, compacted bool) session.Record { + var entries []session.DisplayEntry + for n := range turns { + call := "c" + strconv.Itoa(n%(turns/2+1)) + entries = append(entries, + session.DisplayEntry{Role: "user", Text: "question " + strconv.Itoa(n)}, + session.DisplayEntry{Role: "assistant", Text: "Reading it."}, + session.DisplayEntry{Role: "tool", Tool: "bash", CallID: call, Args: `{"command":"go test"}`, Output: "ok " + strconv.Itoa(n), Answered: true}, + session.DisplayEntry{Role: "assistant", Text: "the loader passes " + strconv.Itoa(n)}, + ) + } + record := session.Record{Entries: entries} + if compacted { + record.Earlier = entries[:len(entries)/2] + record.Entries = append([]session.DisplayEntry{{Role: "note", Text: "summary"}}, entries[len(entries)/2:]...) + record.Floor = 1 + } + return record +} + +// appWholePage is the page as Entries cut it before the facts were read +// page-first: every call's facts, then the slice. It is the oracle. +func appWholePage(id string, record session.Record, at time.Time, marks map[int]appTurnMark, before string, limit int, facts func(session.DisplayEntry) appwire.ToolFacts) ([]appwire.Entry, bool, error) { + items := appRecordItems(record) + all := appEntriesOf(id, items, at, marks) + calls := map[string]session.DisplayEntry{} + for _, item := range items { + if item.Role == "tool" && item.CallID != "" { + calls[item.CallID] = item + } + } + for i := range all { + if item, ok := calls[all[i].Call]; ok && all[i].Kind == appwire.EntryTool { + all[i].ToolFacts = facts(item) + } + } + if words := strings.TrimSpace(record.Unreadable); words != "" { + all = append(all, appwire.Entry{ID: "unreadable", Agent: id, Kind: appwire.EntryNotice, At: at, Text: words}) + } + end := len(all) + if before != "" { + end = -1 + for i, entry := range all { + if entry.ID == before { + end = i + break + } + } + if end < 0 { + return nil, false, appwire.Errorf(appwire.CodeNotFound, "this conversation has no entry %s", before) + } + } + start := max(end-limit, 0) + return append([]appwire.Entry{}, all[start:end]...), start > 0, nil +} + +// appCountedFacts names the item it was asked about, and counts the asking. +func appCountedFacts(count *int) func(session.DisplayEntry) appwire.ToolFacts { + return func(item session.DisplayEntry) appwire.ToolFacts { + *count++ + return appwire.ToolFacts{Tail: []string{item.CallID, item.Output}} + } +} + +// A PAGE READS THE FACTS OF ITS OWN CALLS AND NO OTHERS, and is otherwise the +// page it always was: the same ids, the same facts on the same calls, the same +// cursor and the same "more", from the newest page back to the first, over a +// plain record, a compacted one, one that stopped short and one whose newest +// turn this process sent. +func TestAPageReadsOnlyItsOwnCallsFacts(t *testing.T) { + at := time.Unix(1_800_000_000, 0) + short := appPageRecord(2500, false) + short.Unreadable = "this transcript could not be read past line 9 — everything above it is on this page" + records := map[string]session.Record{ + "plain": appPageRecord(2500, false), + "compacted": appPageRecord(2500, true), + "short": short, + } + marks := map[int]appTurnMark{2499: {turn: 7, clientID: "k1", text: "sent here"}} + for name, record := range records { + t.Run(name, func(t *testing.T) { + var whole []appwire.Entry + before := "" + for { + var paged, oracle int + page, more, err := appEntryPage("a1", record, at, marks, before, appwire.MaxEntries, appCountedFacts(&paged)) + want, wantMore, wantErr := appWholePage("a1", record, at, marks, before, appwire.MaxEntries, appCountedFacts(&oracle)) + if err != nil || wantErr != nil { + t.Fatalf("page before %q: %v, oracle %v", before, err, wantErr) + } + if !reflect.DeepEqual(page, want) || more != wantMore { + t.Fatalf("page before %q differs from the whole-record page", before) + } + tools := 0 + for _, entry := range page { + if entry.Kind == appwire.EntryTool { + tools++ + } + } + if paged != tools || paged > appwire.MaxEntries { + t.Fatalf("page before %q read %d facts for %d calls", before, paged, tools) + } + whole = append(page, whole...) + if !more { + break + } + before = page[0].ID + } + all, _, _ := appWholePage("a1", record, at, marks, "", 1<<30, appCountedFacts(new(int))) + if !reflect.DeepEqual(whole, all) { + t.Fatal("walking the pages back did not tell the conversation once, in order") + } + }) + } + var count int + if _, _, err := appEntryPage("a1", records["plain"], at, nil, "u999999", 10, appCountedFacts(&count)); err == nil || count != 0 { + t.Fatalf("a cursor no entry has = %v after %d facts", err, count) + } +} + +// A CONVERSATION READ TWICE UNCHANGED IS PARSED ONCE, and a turn appended +// between pages is on the next page. +func TestEntriesPagesFromOneParseUntilTheJournalGrows(t *testing.T) { + path := filepath.Join(t.TempDir(), "transcript.jsonl") + var journal strings.Builder + journal.WriteString(`{"type":"session","version":1,"id":"n1","cwd":"/tmp/lab"}` + "\n") + turn := func(n int) { + journal.WriteString(`{"type":"message","role":"user","content":"question ` + strconv.Itoa(n) + `"}` + "\n") + journal.WriteString(`{"type":"message","role":"assistant","content":"answer ` + strconv.Itoa(n) + `"}` + "\n") + } + for n := range 300 { + turn(n) + } + if err := os.WriteFile(path, []byte(journal.String()), 0o600); err != nil { + t.Fatal(err) + } + cache := session.NewRecordCache(session.RecordCacheLimits{Now: func() time.Time { return time.Now().Add(time.Hour) }}) + page := func(before string) []appwire.Entry { + var got []appwire.Entry + err := cache.WithRecord(path, func(record session.Record) error { + var err error + got, _, err = appEntryPage("a1", record, time.Time{}, nil, before, 100, appCountedFacts(new(int))) + return err + }) + if err != nil { + t.Fatal(err) + } + return got + } + newest := page("") + older := page(newest[0].ID) + if older[len(older)-1].ID == newest[0].ID || cache.Stats().Parses != 1 { + t.Fatalf("two pages: %+v", cache.Stats()) + } + turn(300) + if err := os.WriteFile(path, []byte(journal.String()), 0o600); err != nil { + t.Fatal(err) + } + if latest := page(""); latest[len(latest)-1].Text != "answer 300" { + t.Fatalf("the appended turn is missing: %q", latest[len(latest)-1].Text) + } +} + +// A READING REFUSED AT THE GATE IS A BUSY ANSWER, not an empty conversation. +func TestABusyReadingIsSaidAsBusy(t *testing.T) { + err := appEntriesBusy(session.ErrRecordReadBusy) + var wire *appwire.Error + if !errors.As(err, &wire) || wire.Code != appwire.CodeBusy { + t.Fatalf("a refused reading = %v", err) + } +} + +// BenchmarkAppEntryPage is the newest page and an older one, of a record of +// 10k and 100k entries, with the facts read page-first and, for comparison, +// for every call. facts/op is how many calls' facts each page read. +func BenchmarkAppEntryPage(b *testing.B) { + missing := filepath.Join(b.TempDir(), "no-such-output.log") + facts := func(count *int) func(session.DisplayEntry) appwire.ToolFacts { + return func(item session.DisplayEntry) appwire.ToolFacts { + *count++ + // The real facts look for the call's output file; most are gone. + _, _ = os.Stat(missing) + return appwire.ToolFacts{Tail: []string{item.Output}} + } + } + for _, size := range []int{10_000, 100_000} { + record := appPageRecord(size/4, false) + for _, way := range []struct { + name string + page func(string, session.Record, time.Time, map[int]appTurnMark, string, int, func(session.DisplayEntry) appwire.ToolFacts) ([]appwire.Entry, bool, error) + }{{"pagefirst", appEntryPage}, {"whole", appWholePage}} { + b.Run(strconv.Itoa(size/1000)+"k/"+way.name, func(b *testing.B) { + count := 0 + b.ReportAllocs() + for b.Loop() { + newest, _, _ := way.page("a1", record, time.Time{}, nil, "", appwire.MaxEntries, facts(&count)) + _, _, _ = way.page("a1", record, time.Time{}, nil, newest[0].ID, appwire.MaxEntries, facts(&count)) + } + b.ReportMetric(float64(count)/float64(b.N)/2, "facts/page") + }) + } + } +} diff --git a/internal/session/recordcache.go b/internal/session/recordcache.go new file mode 100644 index 0000000000..c2b5c9f9e0 --- /dev/null +++ b/internal/session/recordcache.go @@ -0,0 +1,530 @@ +package session + +// A RECORD READ TWICE UNCHANGED IS PARSED ONCE, AND NO READING IS UNBOUNDED. +// +// A page asking for the older half of a long conversation used to pay for the +// whole journal every time: [ReadTranscript] has to walk the file from its +// first line, because a compaction places everything above it, an entry's id +// counts the person's messages from the first, and an update is known only by a +// call that comes after it. None of that can be read off a tail. So the cache +// below does not shorten the reading; it keeps the finished [Record] of a file +// that has not changed since, and answers the next page from it. +// +// THE FIRST OPEN IS STILL THE WHOLE FILE. Nothing here knows how to resume a +// parse from a checkpoint, and nothing pretends to: a conversation opened for +// the first time, or after any write to its journal, is read whole by the one +// parser this package has. +// +// ── EVERY READING PASSES ONE GATE ────────────────────────────────────────── +// +// A reading is a call to [RecordCache.WithRecord], and the record it hands its +// callback lives exactly as long as the callback. Every reading — one answered +// from a kept record, one the cache parses to keep, one too large to keep — +// first takes one of [RecordCacheLimits.Operations] slots and a reservation of +// bytes, and holds both until the callback returns. A reading that finds every +// slot taken, or the budget spoken for by readings in progress, does not wait +// and does not read anyway: it returns [ErrRecordReadBusy], and the caller says +// so. Two readings of the same unchanged file share one parse: the second waits +// for the first, and is then answered from what was kept. Only one reader waits +// on any parse; the slots bound the rest. +// +// THE BUDGET IS AN ACCOUNT, NOT A CEILING ON THE PROCESS. It counts the records +// kept, the copy each callback is handed, and an estimate for each parse in +// progress, which is replaced by the record's measured size once it is read. +// What it cannot count is the parser's own working memory — the messages and +// indexes a journal is rebuilt through, several times the file at its peak and +// gone when the parse ends. That is bounded only by the slots: at most +// [RecordCacheLimits.Operations] journals are parsed at once. A journal whose +// estimate exceeds the whole budget is still read, whole and correct, but only +// when nothing else holds a reservation, and is never kept; its record can be +// larger than the budget for as long as its callback runs. Nothing is cut short +// to fit: a record is whole or it is not read. +// +// ── WHAT MAKES A KEPT RECORD TRUE ────────────────────────────────────────── +// +// A record is answered from the cache only when the file's stamp — device, +// inode, size, modification time and change time, all to the nanosecond — is +// the stamp the record was read under ([recordStamp]). The change time is the +// one a writer cannot put back: rewriting a journal in place to the same size +// and restoring its modification time still moves it. Where the platform does +// not hand over such a time ([recordStampOf]), nothing is kept at all, and every +// reading is a parse that still passes the gate. +// +// A STAMP IS ONLY AS FINE AS THE CLOCK THAT WROTE IT. A filesystem stamps change +// times off a coarse tick, so two writes inside one tick can leave one stamp. A +// file is therefore kept only once its change time is at least +// [RecordCacheLimits.Settle] old: any write after that lands on a later tick and +// a different stamp. This is the racy-clean rule git applies to its index. +// +// THE FILE MUST STAND STILL WHILE IT IS READ. It is stamped again through the +// open handle before and after the parse, and a reading across a write is +// handed to its callback, exactly as the plain read would have been, and kept +// by nobody. +// +// A READING THAT SAID SOMETHING WAS WRONG IS NEVER KEPT. A record with +// [Record.Unreadable] set, a file that could not be opened, and anything that is +// not a regular file are answered afresh every time, so a fault is reported by +// the reading that met it and never by a memory of one. A path that is not a +// regular file is never opened as one: a named pipe would block the reader on a +// writer that may never come. +// +// ── WHO OWNS WHAT ────────────────────────────────────────────────────────── +// +// A kept record is the cache's and is never handed out. A callback is given its +// own copy ([cloneRecord]) — the entry slices and every slice or pointer inside +// an entry — so a callback that edits what it was given cannot reach another +// reader's page. Strings are shared, because Go does not let anybody write one; +// a kept record evicted while a copy of it is out stays counted until the last +// such callback returns, because the copy still holds its strings. + +import ( + "container/list" + "errors" + "os" + "slices" + "strings" + "sync" + "time" + "unsafe" +) + +// ErrRecordReadBusy is a reading refused because as many as may run at once +// already are. Nothing was read; asking again later is the remedy. +var ErrRecordReadBusy = errors.New("session: too many transcript readings are in progress") + +// recordStamp is everything about a file a kept record was read under. +type recordStamp struct { + dev, ino uint64 + size int64 + mtime, ctime int64 +} + +// RecordCacheLimits bound a [RecordCache]. A zero field takes its default. +type RecordCacheLimits struct { + // Operations is the most readings in progress at once, from the moment + // one is asked for until its callback returns. + Operations int + // Bytes is the budget the kept records, the copies handed out and the + // parses in progress are counted against. + Bytes int64 + // Entries is the most records kept. + Entries int + // FileBytes is the largest journal read for keeping. + FileBytes int64 + // Settle is how old a file's change time must be before its reading is + // kept. + Settle time.Duration + // Now is the clock the change time is measured against. + Now func() time.Time +} + +// The defaults: two readings at once; the largest journal kept, 16MiB, reserves +// 64MiB, so it and its copy fit beside a second reading in progress. +const ( + recordCacheOperations = 2 + recordCacheBytes = 256 << 20 + recordCacheEntries = 32 + recordCacheFileBytes = 16 << 20 + recordCacheSettle = 2 * time.Second +) + +// RecordCacheStats is what a cache holds and has done, for a test or a bench. +type RecordCacheStats struct { + Operations int + Entries int + Retained int64 + Reserved int64 + // Parses are journals read; Hits were answered from a kept record; Shared + // waited on another reading's parse; Busy were refused at the gate. + Parses, Hits, Shared, Busy int64 +} + +// RecordCache keeps the readings of unchanged journals and gates every reading; +// see the top of this file. +type RecordCache struct { + limits RecordCacheLimits + + mu sync.Mutex + operations int + order *list.List // of *recordSlot, most recently used at the front + kept map[string]*list.Element + flights map[string]*recordFlight + retained int64 + reserved int64 + stats RecordCacheStats +} + +// recordSlot is one kept reading. A slot evicted while copies of it are out is +// dropped from the map and still counted until the last one is returned. +type recordSlot struct { + path string + stamp recordStamp + record Record + cost int64 + leases int + dropped bool +} + +// recordFlight is one parse that may be kept, and whether a reader waits on it. +type recordFlight struct { + stamp recordStamp + done chan struct{} + waiter bool +} + +// NewRecordCache is an empty cache within limits. +func NewRecordCache(limits RecordCacheLimits) *RecordCache { + if limits.Operations <= 0 { + limits.Operations = recordCacheOperations + } + if limits.Bytes <= 0 { + limits.Bytes = recordCacheBytes + } + if limits.Entries <= 0 { + limits.Entries = recordCacheEntries + } + if limits.FileBytes <= 0 { + limits.FileBytes = recordCacheFileBytes + } + if limits.Settle <= 0 { + limits.Settle = recordCacheSettle + } + if limits.Now == nil { + limits.Now = time.Now + } + return &RecordCache{ + limits: limits, + order: list.New(), + kept: map[string]*list.Element{}, + flights: map[string]*recordFlight{}, + } +} + +// Stats is the cache as it stands. +func (c *RecordCache) Stats() RecordCacheStats { + c.mu.Lock() + defer c.mu.Unlock() + stats := c.stats + stats.Operations, stats.Entries = c.operations, len(c.kept) + stats.Retained, stats.Reserved = c.retained, c.reserved + return stats +} + +// WithRecord hands use the record [ReadTranscript] would read at path, and +// returns what use returns. The record is the callback's own and lives until +// it returns; it is answered from a kept reading when the file has not changed +// since. When as many readings are in progress as the limits allow, nothing is +// read and the answer is [ErrRecordReadBusy]. +func (c *RecordCache) WithRecord(path string, use func(Record) error) error { + if strings.TrimSpace(path) == "" { + return use(Record{}) + } + c.mu.Lock() + if c.operations >= c.limits.Operations { + c.stats.Busy++ + c.mu.Unlock() + return ErrRecordReadBusy + } + c.operations++ + c.mu.Unlock() + defer func() { + c.mu.Lock() + c.operations-- + c.mu.Unlock() + }() + record, release, err := c.acquire(path) + if err != nil { + return err + } + defer release() + return use(record) +} + +// acquire is the record for one reading that holds an operation slot, and +// what returns its reservation. +func (c *RecordCache) acquire(path string) (Record, func(), error) { + waited := false + for { + info, err := os.Stat(path) + if err != nil { + // Missing, or out of reach: the plain read answers nothing. + return Record{}, func() {}, nil + } + if !info.Mode().IsRegular() { + return Record{Unreadable: unreadRefusedWord}, func() {}, nil + } + stamp, stamped := recordStampOf(info) + + c.mu.Lock() + if stamped { + if element := c.kept[path]; element != nil { + slot := element.Value.(*recordSlot) + if slot.stamp == stamp { + return c.leaseLocked(element) + } + // The file moved on; what was kept for it is no longer the file. + c.dropLocked(element) + } + if flight := c.flights[path]; flight != nil && flight.stamp == stamp && !flight.waiter && !waited { + flight.waiter = true + c.stats.Shared++ + c.mu.Unlock() + <-flight.done + // Asked again: answered from what the parse kept, or read + // afresh if it kept nothing. + waited = true + continue + } + } + return c.parseLocked(path, info.Size(), stamp, stamped) + } +} + +// leaseLocked hands out a copy of a kept record, reserving its own slices. +// It is called with the lock held and returns with it released. +func (c *RecordCache) leaseLocked(element *list.Element) (Record, func(), error) { + slot := element.Value.(*recordSlot) + slot.leases++ // pinned, so making room cannot count it as freed + vectors := recordVectors(slot.record) + if !c.reserveLocked(vectors) { + c.unleaseLocked(slot) + c.stats.Busy++ + c.mu.Unlock() + return Record{}, nil, ErrRecordReadBusy + } + c.order.MoveToFront(element) + c.stats.Hits++ + c.mu.Unlock() + record := cloneRecord(slot.record) + return record, func() { + c.mu.Lock() + c.reserved -= vectors + c.unleaseLocked(slot) + c.mu.Unlock() + }, nil +} + +// parseLocked reads the journal whole, under a reservation, and keeps the +// reading when it is true to keep. It is called with the lock held and +// returns with it released. +func (c *RecordCache) parseLocked(path string, size int64, stamp recordStamp, stamped bool) (Record, func(), error) { + estimate := recordReserve(size) + // A journal estimated past the whole budget is read alone, never kept. + reserve := min(estimate, c.limits.Bytes) + if !c.reserveLocked(reserve) { + c.stats.Busy++ + c.mu.Unlock() + return Record{}, nil, ErrRecordReadBusy + } + var flight *recordFlight + if stamped && c.flights[path] == nil && size <= c.limits.FileBytes && estimate <= c.limits.Bytes && + c.limits.Now().Sub(time.Unix(0, stamp.ctime)) >= c.limits.Settle { + flight = &recordFlight{stamp: stamp, done: make(chan struct{})} + c.flights[path] = flight + } + c.stats.Parses++ + c.mu.Unlock() + + record, settled := readSettled(path, stamp, stamped) + cost := recordCost(record) + + c.mu.Lock() + defer c.mu.Unlock() + if flight != nil { + delete(c.flights, path) + defer close(flight.done) + } + // The estimate gives way to what was read. + c.reserved -= reserve + if flight != nil && settled && record.Unreadable == "" && cost <= reserve { + // Kept, the record is the cache's and the reader is handed a copy. + vectors := recordVectors(record) + for c.order.Len() >= c.limits.Entries { + c.dropLocked(c.order.Back()) + } + if c.reserveLocked(cost + vectors) { + c.reserved -= cost + slot := &recordSlot{path: path, stamp: stamp, record: record, cost: cost, leases: 1} + c.kept[path] = c.order.PushFront(slot) + c.retained += cost + copied := cloneRecord(record) + return copied, func() { + c.mu.Lock() + c.reserved -= vectors + c.unleaseLocked(slot) + c.mu.Unlock() + }, nil + } + } + // Not kept: the reader owns what it read, and it is counted at its size + // until the callback returns, over the budget if it must be. + c.reserved += cost + return record, func() { + c.mu.Lock() + c.reserved -= cost + c.mu.Unlock() + }, nil +} + +// reserveLocked counts n bytes against the budget, evicting kept records that +// no callback holds, least recently used first. It reserves nothing and evicts +// nothing when even an empty cache would not make room. +func (c *RecordCache) reserveLocked(n int64) bool { + var evictable int64 + for element := c.order.Back(); element != nil; element = element.Prev() { + if slot := element.Value.(*recordSlot); slot.leases == 0 { + evictable += slot.cost + } + } + if c.retained-evictable+c.reserved+n > c.limits.Bytes { + return false + } + for element := c.order.Back(); element != nil && c.retained+c.reserved+n > c.limits.Bytes; { + prev := element.Prev() + if element.Value.(*recordSlot).leases == 0 { + c.dropLocked(element) + } + element = prev + } + c.reserved += n + return true +} + +// dropLocked forgets one kept reading; its bytes stay counted while a copy of +// it is out. +func (c *RecordCache) dropLocked(element *list.Element) { + slot := element.Value.(*recordSlot) + c.order.Remove(element) + delete(c.kept, slot.path) + slot.dropped = true + if slot.leases == 0 { + c.retained -= slot.cost + } +} + +// unleaseLocked returns one copy of a kept reading. +func (c *RecordCache) unleaseLocked(slot *recordSlot) { + slot.leases-- + if slot.dropped && slot.leases == 0 { + c.retained -= slot.cost + } +} + +// readSettled is the plain reading of the journal at path, through the one +// parser, and whether the file it read is the one stamped and stood still +// through the reading. +func readSettled(path string, stamp recordStamp, stamped bool) (Record, bool) { + file, err := openRecordFile(path) + if err != nil { + return Record{}, false + } + defer file.Close() + info, err := file.Stat() + if err != nil || !info.Mode().IsRegular() { + return Record{Unreadable: unreadRefusedWord}, false + } + before := stamped && sameStamp(info, stamp) + record := transcriptFrom(readJournal(file, path, false)) + if !before { + return record, false + } + after, err := file.Stat() + return record, err == nil && sameStamp(after, stamp) +} + +// sameStamp is whether a file's stat still carries the stamp. +func sameStamp(info os.FileInfo, stamp recordStamp) bool { + now, ok := recordStampOf(info) + return ok && now == stamp +} + +// recordReserve is what a journal of size bytes is expected to cost once read. +// The record keeps the display text of each line, capped, plus a fixed shape +// of a few hundred bytes per entry, which for a journal of short lines is +// about four times the file. A record that comes out larger than this is read +// but not kept. +func recordReserve(size int64) int64 { + return 4*size + 64<<10 +} + +// recordVectors is what a copy of a record costs: its own slices, with the +// strings still shared. +func recordVectors(record Record) int64 { + cost := int64(len(record.Entries)+len(record.Earlier)) * int64(unsafe.Sizeof(DisplayEntry{})) + cost += int64(len(record.Requests.Recent)) * int64(unsafe.Sizeof(RequestLine{})) + for _, entries := range [][]DisplayEntry{record.Entries, record.Earlier} { + for i := range entries { + entry := &entries[i] + cost += int64(len(entry.ImageRefs))*int64(unsafe.Sizeof("")) + + int64(len(entry.ReplyTags))*int64(unsafe.Sizeof(TaskReplyTag{})) + + int64(len(entry.Team))*int64(unsafe.Sizeof(TeamLine{})) + if entry.Steer != nil { + cost += int64(unsafe.Sizeof(SteerMark{})) + } + } + } + return cost +} + +// recordCost is what a record holds: every entry's shape and every byte its +// strings and slices point at. Strings that share memory are counted once each +// time they appear, which can only overstate it. +func recordCost(record Record) int64 { + cost := int64(unsafe.Sizeof(record)) + cost += entriesCost(record.Entries) + entriesCost(record.Earlier) + cost += int64(len(record.Unreadable)) + cost += int64(cap(record.Requests.Recent)) * int64(unsafe.Sizeof(RequestLine{})) + return cost +} + +func entriesCost(entries []DisplayEntry) int64 { + cost := int64(cap(entries)) * int64(unsafe.Sizeof(DisplayEntry{})) + for i := range entries { + entry := &entries[i] + cost += int64(len(entry.Role) + len(entry.Text) + len(entry.Tool) + len(entry.Hint) + + len(entry.CallID) + len(entry.Args) + len(entry.Output) + len(entry.Caption) + len(entry.CaptionCategory)) + cost += int64(cap(entry.ImageRefs)) * int64(unsafe.Sizeof("")) + for _, ref := range entry.ImageRefs { + cost += int64(len(ref)) + } + cost += int64(cap(entry.ReplyTags)) * int64(unsafe.Sizeof(TaskReplyTag{})) + for _, tag := range entry.ReplyTags { + cost += int64(len(tag.Title) + len(tag.Request) + len(tag.Obligation)) + } + if entry.Steer != nil { + cost += int64(unsafe.Sizeof(SteerMark{})) + int64(len(entry.Steer.Landing)) + } + cost += int64(cap(entry.Team)) * int64(unsafe.Sizeof(TeamLine{})) + for _, line := range entry.Team { + cost += int64(len(line.Team) + len(line.From) + len(line.To) + len(line.Kind) + len(line.Text) + len(line.Thread)) + } + } + return cost +} + +// cloneRecord is a record no other reader can reach through: its own entry +// slices, and its own copy of every slice and pointer inside an entry. +func cloneRecord(record Record) Record { + record.Entries = cloneEntries(record.Entries) + record.Earlier = cloneEntries(record.Earlier) + record.Requests.Recent = slices.Clone(record.Requests.Recent) + return record +} + +func cloneEntries(entries []DisplayEntry) []DisplayEntry { + if entries == nil { + return nil + } + out := slices.Clone(entries) + for i := range out { + entry := &out[i] + entry.ImageRefs = slices.Clone(entry.ImageRefs) + entry.ReplyTags = slices.Clone(entry.ReplyTags) + entry.Team = slices.Clone(entry.Team) + if entry.Steer != nil { + steer := *entry.Steer + entry.Steer = &steer + } + } + return out +} diff --git a/internal/session/recordcache_fifo_other_test.go b/internal/session/recordcache_fifo_other_test.go new file mode 100644 index 0000000000..7ed1c808ad --- /dev/null +++ b/internal/session/recordcache_fifo_other_test.go @@ -0,0 +1,7 @@ +//go:build !linux && !darwin + +package session + +import "errors" + +func mkfifo(string) error { return errors.New("no named pipes on this platform") } diff --git a/internal/session/recordcache_fifo_unix_test.go b/internal/session/recordcache_fifo_unix_test.go new file mode 100644 index 0000000000..1b719e923d --- /dev/null +++ b/internal/session/recordcache_fifo_unix_test.go @@ -0,0 +1,7 @@ +//go:build linux || darwin + +package session + +import "syscall" + +func mkfifo(path string) error { return syscall.Mkfifo(path, 0o600) } diff --git a/internal/session/recordcache_test.go b/internal/session/recordcache_test.go new file mode 100644 index 0000000000..08fa3d16b5 --- /dev/null +++ b/internal/session/recordcache_test.go @@ -0,0 +1,567 @@ +package session + +import ( + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "strconv" + "strings" + "sync" + "testing" + "time" +) + +// recordJournal is a journal of turns, each a question, a call, its result and +// an answer, with a compaction above the last few when compacted is set. +func recordJournal(turns int, compacted bool) string { + var b strings.Builder + b.WriteString(`{"type":"session","version":1,"id":"n1","cwd":"/tmp/lab"}` + "\n") + turn := func(n int, answer string) { + fmt.Fprintf(&b, `{"type":"message","role":"user","content":"question %d about the loader"}`+"\n", n) + fmt.Fprintf(&b, `{"type":"message","role":"assistant","content":"Reading it.","toolCalls":[{"id":"c%d","function":{"name":"bash","arguments":"{\"command\":\"go test ./loader/... -run Test%d\"}"}}]}`+"\n", n, n) + fmt.Fprintf(&b, `{"type":"message","role":"tool","toolCallId":"c%d","content":"ok loader 0.%03ds"}`+"\n", n, n%1000) + fmt.Fprintf(&b, `{"type":"message","role":"assistant","content":"%s %d"}`+"\n", answer, n) + } + for n := range turns { + turn(n, "the loader passes") + } + if compacted && turns > 0 { + b.WriteString(`{"type":"compaction","stubbed":2,"window":2,"tokensBefore":84000,"timestamp":"t"}` + "\n") + b.WriteString(`{"type":"message","role":"user","content":"question folded"}` + "\n") + b.WriteString(`{"type":"message","role":"assistant","content":"[folded]"}` + "\n") + turn(turns, "after the pass") + } + return b.String() +} + +func writeRecord(t testing.TB, path, text string) { + t.Helper() + if err := os.WriteFile(path, []byte(text), 0o600); err != nil { + t.Fatal(err) + } +} + +// settledCache is a cache whose clock stands an hour ahead, so a file the test +// just wrote is old enough to keep. +func settledCache(limits RecordCacheLimits) *RecordCache { + limits.Now = func() time.Time { return time.Now().Add(time.Hour) } + return NewRecordCache(limits) +} + +// read is one reading through the cache, failing the test on a refusal. +func read(t testing.TB, cache *RecordCache, path string) Record { + t.Helper() + var got Record + if err := cache.WithRecord(path, func(record Record) error { got = record; return nil }); err != nil { + t.Fatalf("WithRecord(%s): %v", path, err) + } + return got +} + +// A KEPT READING IS THE READING: entries, the compacted region, its floor and +// the request books are what the one parser makes of the file, and the second +// reading parses nothing. +func TestAKeptRecordIsExactlyThePlainReading(t *testing.T) { + path := filepath.Join(t.TempDir(), "node.jsonl") + writeRecord(t, path, recordJournal(30, true)) + want := ReadTranscript(path) + if len(want.Earlier) == 0 || want.Floor == 0 { + t.Fatalf("the fixture did not compact: earlier %d floor %d", len(want.Earlier), want.Floor) + } + cache := settledCache(RecordCacheLimits{}) + for range 3 { + if got := read(t, cache, path); !reflect.DeepEqual(got, want) { + t.Fatal("the cached reading differs from the plain one") + } + } + if s := cache.Stats(); s.Parses != 1 || s.Hits != 2 || s.Entries != 1 || s.Reserved != 0 || s.Operations != 0 { + t.Fatalf("stats = %+v, want one parse, two hits and nothing held", s) + } +} + +// ANY WRITE IS READ AGAIN: an append, a truncation, a file replaced by rename, +// and an in-place rewrite to the same size with its modification time put back. +func TestEveryChangeToTheJournalIsReadAgain(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "node.jsonl") + writeRecord(t, path, recordJournal(4, false)) + cache := settledCache(RecordCacheLimits{}) + read(t, cache, path) + + check := func(why string) { + t.Helper() + if got, want := read(t, cache, path), ReadTranscript(path); !reflect.DeepEqual(got, want) { + t.Fatalf("after %s the cache answered a stale record", why) + } + } + appended := recordJournal(5, false) + writeRecord(t, path, appended) + check("an append") + + writeRecord(t, path, recordJournal(2, false)) + check("a truncation") + + other := filepath.Join(dir, "next.jsonl") + writeRecord(t, other, recordJournal(3, false)) + if err := os.Rename(other, path); err != nil { + t.Fatal(err) + } + check("a replacement") + + // The same bytes count, the same modification time, other words. + before, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + read(t, cache, path) + stamp, ok := recordStampOf(before) + if !ok { + t.Skip("this platform keeps nothing, so nothing can go stale") + } + // A change time on a coarse tick: move it on before the rewrite, so the + // rewrite is told apart by the stamp and not by luck. + deadline := time.Now().Add(2 * time.Second) + for { + if err := os.Chmod(path, 0o600); err != nil { + t.Fatal(err) + } + info, _ := os.Stat(path) + now, _ := recordStampOf(info) + if now.ctime != stamp.ctime { + break + } + if time.Now().After(deadline) { + t.Fatal("the change time never moved") + } + } + text, _ := os.ReadFile(path) + rewritten := strings.Replace(string(text), "question 0 about", "QUESTION 0 ABOUT", 1) + if len(rewritten) != len(text) || rewritten == string(text) { + t.Fatal("the rewrite is not a same-size change") + } + file, err := os.OpenFile(path, os.O_WRONLY, 0) + if err != nil { + t.Fatal(err) + } + if _, err := file.WriteAt([]byte(rewritten), 0); err != nil { + t.Fatal(err) + } + file.Close() + if err := os.Chtimes(path, before.ModTime(), before.ModTime()); err != nil { + t.Fatal(err) + } + after, _ := os.Stat(path) + if after.Size() != before.Size() || !after.ModTime().Equal(before.ModTime()) || !os.SameFile(before, after) { + t.Fatal("the rewrite changed size, time or identity") + } + got := read(t, cache, path) + if got.Entries[0].Text != "QUESTION 0 ABOUT the loader" { + t.Fatalf("a same-size rewrite read back as %q", got.Entries[0].Text) + } +} + +// A FILE WRITTEN A MOMENT AGO IS NOT KEPT: its change time may share a tick +// with the next write. +func TestAJournalStillBeingWrittenIsNotKept(t *testing.T) { + path := filepath.Join(t.TempDir(), "node.jsonl") + writeRecord(t, path, recordJournal(3, false)) + cache := NewRecordCache(RecordCacheLimits{}) + read(t, cache, path) + read(t, cache, path) + if s := cache.Stats(); s.Entries != 0 || s.Parses != 2 { + t.Fatalf("stats = %+v, want two parses and nothing kept", s) + } +} + +// A READING THAT SAID SOMETHING WAS WRONG IS NOT KEPT, and a fault is never +// answered from memory once the file is mended. +func TestAShortOrRefusedReadingIsNeverKept(t *testing.T) { + dir := t.TempDir() + cache := settledCache(RecordCacheLimits{}) + + broken := filepath.Join(dir, "broken.jsonl") + good := recordJournal(2, false) + writeRecord(t, broken, good+`{"type":"message","role":"user","content":`+strings.Repeat("x", 9<<20)+"\n") + for range 2 { + if got := read(t, cache, broken); got.Unreadable == "" || !reflect.DeepEqual(got, ReadTranscript(broken)) { + t.Fatalf("a short reading came back as %q", got.Unreadable) + } + } + writeRecord(t, broken, good) + if got := read(t, cache, broken); got.Unreadable != "" { + t.Fatal("a mended journal was answered with its old fault") + } + + newer := filepath.Join(dir, "newer.jsonl") + writeRecord(t, newer, `{"type":"session","version":`+strconv.Itoa(sessionFileVersion+1)+`,"id":"n1"}`+"\n") + for range 2 { + if got := read(t, cache, newer); got.Unreadable != unreadNewerWord { + t.Fatalf("a newer journal read as %q", got.Unreadable) + } + } + + if got := read(t, cache, filepath.Join(dir, "missing.jsonl")); !reflect.DeepEqual(got, Record{}) { + t.Fatalf("a missing journal read as %+v", got) + } + if got := read(t, cache, dir); got.Unreadable == "" || len(got.Entries) != 0 { + t.Fatalf("a directory read as %+v", got) + } + if s := cache.Stats(); s.Entries != 1 || s.Reserved != 0 { + t.Fatalf("stats = %+v, want only the mended journal kept", s) + } +} + +// A NAMED PIPE IS NEVER OPENED: no writer is coming, and a reader that opened +// it would hold its slot forever. +func TestANamedPipeIsRefusedWithoutBlocking(t *testing.T) { + path := filepath.Join(t.TempDir(), "pipe.jsonl") + if err := mkfifo(path); err != nil { + t.Skipf("no named pipes here: %v", err) + } + cache := settledCache(RecordCacheLimits{}) + done := make(chan Record, 1) + go func() { done <- read(t, cache, path) }() + select { + case got := <-done: + if got.Unreadable == "" { + t.Fatal("a named pipe read as an empty journal") + } + case <-time.After(5 * time.Second): + t.Fatal("reading a named pipe blocked") + } + if s := cache.Stats(); s.Operations != 0 || s.Entries != 0 { + t.Fatalf("stats = %+v", s) + } +} + +// EVERY READING PASSES THE GATE. With both slots held by callbacks still +// running, a third reading — of a kept record, of a file never seen, of one too +// large to keep — is refused, and nothing is parsed for it. +func TestEveryKindOfReadingIsRefusedWhenTheSlotsAreTaken(t *testing.T) { + dir := t.TempDir() + kept := filepath.Join(dir, "kept.jsonl") + fresh := filepath.Join(dir, "fresh.jsonl") + large := filepath.Join(dir, "large.jsonl") + writeRecord(t, kept, recordJournal(3, false)) + writeRecord(t, fresh, recordJournal(3, false)) + writeRecord(t, large, recordJournal(400, false)) + cache := settledCache(RecordCacheLimits{FileBytes: 4 << 10}) + read(t, cache, kept) + + release := make(chan struct{}) + var held sync.WaitGroup + for _, path := range []string{kept, large} { + entered := make(chan struct{}) + held.Add(1) + go func() { + defer held.Done() + _ = cache.WithRecord(path, func(Record) error { close(entered); <-release; return nil }) + }() + <-entered + } + parses := cache.Stats().Parses + for _, path := range []string{kept, fresh, large} { + err := cache.WithRecord(path, func(Record) error { t.Fatal("a refused reading ran its callback"); return nil }) + if !errors.Is(err, ErrRecordReadBusy) { + t.Fatalf("a third reading of %s = %v, want busy", filepath.Base(path), err) + } + } + if s := cache.Stats(); s.Parses != parses || s.Operations != 2 || s.Busy != 3 { + t.Fatalf("stats = %+v", s) + } + close(release) + held.Wait() + if s := cache.Stats(); s.Operations != 0 || s.Reserved != 0 { + t.Fatalf("after the callbacks returned: %+v", s) + } + // And the large journal, never kept, still reads whole. + if got := read(t, cache, large); !reflect.DeepEqual(got, ReadTranscript(large)) || len(got.Entries) != 2000 { + t.Fatalf("the large journal read %d entries", len(got.Entries)) + } +} + +// THE BUDGET COUNTS WHAT IS OUT. A copy handed to a running callback is +// reserved, and a reading the budget cannot hold beside it is refused rather +// than read anyway. +func TestACopyInUseHoldsItsShareOfTheBudget(t *testing.T) { + dir := t.TempDir() + first := filepath.Join(dir, "first.jsonl") + second := filepath.Join(dir, "second.jsonl") + writeRecord(t, first, recordJournal(40, false)) + writeRecord(t, second, recordJournal(40, false)) + size := int64(len(recordJournal(40, false))) + // Room for one kept record and its copy, and not for a second parse. + cache := settledCache(RecordCacheLimits{Bytes: recordReserve(size) + recordReserve(size)/2}) + read(t, cache, first) + kept := cache.Stats().Retained + + release := make(chan struct{}) + entered := make(chan struct{}) + finished := make(chan struct{}) + go func() { + defer close(finished) + _ = cache.WithRecord(first, func(Record) error { close(entered); <-release; return nil }) + }() + <-entered + s := cache.Stats() + if s.Reserved <= 0 || s.Retained != kept { + t.Fatalf("a copy in use is not counted: %+v", s) + } + if err := cache.WithRecord(second, func(Record) error { return nil }); !errors.Is(err, ErrRecordReadBusy) { + t.Fatalf("a parse past the budget = %v, want busy", err) + } + close(release) + <-finished + // With the copy back, the kept record may be evicted to make room. + if got := read(t, cache, second); len(got.Entries) != 200 { + t.Fatalf("second read %d entries", len(got.Entries)) + } + if s := cache.Stats(); s.Retained > cache.limits.Bytes || s.Reserved != 0 { + t.Fatalf("stats = %+v", s) + } +} + +// AN EVICTED RECORD STAYS COUNTED WHILE A COPY OF IT IS OUT, because the copy +// still holds its strings. +func TestAnEvictedRecordIsCountedUntilItsLastCopyReturns(t *testing.T) { + dir := t.TempDir() + first := filepath.Join(dir, "first.jsonl") + writeRecord(t, first, recordJournal(10, false)) + cache := settledCache(RecordCacheLimits{Entries: 1}) + read(t, cache, first) + cost := cache.Stats().Retained + + release := make(chan struct{}) + entered := make(chan struct{}) + finished := make(chan struct{}) + go func() { + defer close(finished) + _ = cache.WithRecord(first, func(Record) error { close(entered); <-release; return nil }) + }() + <-entered + second := filepath.Join(dir, "second.jsonl") + writeRecord(t, second, recordJournal(10, false)) + read(t, cache, second) + if s := cache.Stats(); s.Entries != 1 || s.Retained != 2*cost { + t.Fatalf("while the copy is out: %+v, want both counted", s) + } + close(release) + <-finished + if s := cache.Stats(); s.Retained != cost || s.Reserved != 0 { + t.Fatalf("after: %+v, want the evicted one gone", s) + } +} + +// READINGS OF ONE UNCHANGED FILE SHARE ONE PARSE. +func TestTwoReadingsOfOneFileShareOneParse(t *testing.T) { + path := filepath.Join(t.TempDir(), "node.jsonl") + writeRecord(t, path, recordJournal(2000, false)) + want := ReadTranscript(path) + cache := settledCache(RecordCacheLimits{}) + var group sync.WaitGroup + for range 2 { + group.Add(1) + go func() { + defer group.Done() + if got := read(t, cache, path); !reflect.DeepEqual(got, want) { + t.Error("a shared reading differs") + } + }() + } + group.Wait() + if s := cache.Stats(); s.Parses != 1 || s.Hits != 1 { + t.Fatalf("stats = %+v, want one parse and one reading answered from it", s) + } +} + +// THE CACHE STAYS WITHIN ITS BOUNDS however many journals pass through it, and +// a journal refused for its size does not stop a smaller one being kept. +func TestTheCacheStaysBoundedAcrossManyJournals(t *testing.T) { + dir := t.TempDir() + size := int64(len(recordJournal(20, false))) + cache := settledCache(RecordCacheLimits{Entries: 4, Bytes: 6 * recordReserve(size), FileBytes: size}) + for n := range 40 { + path := filepath.Join(dir, "j"+strconv.Itoa(n)+".jsonl") + writeRecord(t, path, recordJournal(20, false)) + read(t, cache, path) + if s := cache.Stats(); s.Entries > 4 || s.Retained > 6*recordReserve(size) || s.Reserved != 0 { + t.Fatalf("after %d journals: %+v", n+1, s) + } + } + big := filepath.Join(dir, "big.jsonl") + writeRecord(t, big, recordJournal(21, false)) + read(t, cache, big) + read(t, cache, big) + if s := cache.Stats(); s.Hits != 0 { + t.Fatalf("a journal past FileBytes was kept: %+v", s) + } + writeRecord(t, big, recordJournal(20, false)) + read(t, cache, big) + read(t, cache, big) + if s := cache.Stats(); s.Hits != 1 { + t.Fatalf("a refused journal kept a smaller one out: %+v", s) + } +} + +// A CALLBACK THAT EDITS ITS RECORD REACHES NOBODY ELSE'S, run under -race with +// readers on both sides. +func TestACallerEditingItsRecordCannotReachAnother(t *testing.T) { + path := filepath.Join(t.TempDir(), "node.jsonl") + writeRecord(t, path, recordJournal(50, true)) + want := ReadTranscript(path) + cache := settledCache(RecordCacheLimits{}) + read(t, cache, path) + var group sync.WaitGroup + for range 2 { + group.Add(1) + go func() { + defer group.Done() + for range 20 { + err := cache.WithRecord(path, func(record Record) error { + if !reflect.DeepEqual(record, want) { + t.Error("a reader saw another reader's edit") + } + for i := range record.Entries { + record.Entries[i].Text = "edited" + } + record.Entries = append(record.Entries, DisplayEntry{Role: "user"}) + return nil + }) + if err != nil && !errors.Is(err, ErrRecordReadBusy) { + t.Error(err) + } + } + }() + } + group.Wait() +} + +// EVERY SLICE AND POINTER IN AN ENTRY IS COPIED, AND EVERY BYTE IS COUNTED. The +// law reads the entry's own fields, so a field added later is checked the day +// it lands. +func TestCloneAndCostCoverEveryFieldOfAnEntry(t *testing.T) { + var entry DisplayEntry + fill(reflect.ValueOf(&entry).Elem()) + record := Record{Entries: []DisplayEntry{entry}, Requests: RequestBooks{Recent: []RequestLine{{1, 2}}}} + copied := cloneRecord(record) + if !reflect.DeepEqual(copied, record) { + t.Fatal("the copy differs") + } + aliased(t, "Entries", reflect.ValueOf(record.Entries), reflect.ValueOf(copied.Entries)) + aliased(t, "Requests.Recent", reflect.ValueOf(record.Requests.Recent), reflect.ValueOf(copied.Requests.Recent)) + original, clone := reflect.ValueOf(record.Entries[0]), reflect.ValueOf(copied.Entries[0]) + for i := range original.NumField() { + aliased(t, original.Type().Field(i).Name, original.Field(i), clone.Field(i)) + } + if got, want := entriesCost(record.Entries), deepCost(reflect.ValueOf(record.Entries)); got != want { + t.Fatalf("entriesCost = %d, the fields hold %d", got, want) + } +} + +// fill gives every string, slice and pointer in v something to hold. +func fill(v reflect.Value) { + switch v.Kind() { + case reflect.String: + v.SetString("seven7") + case reflect.Struct: + if v.Type() == reflect.TypeOf(time.Time{}) { + return + } + for i := range v.NumField() { + if v.Type().Field(i).IsExported() { + fill(v.Field(i)) + } + } + case reflect.Slice: + v.Set(reflect.MakeSlice(v.Type(), 1, 1)) + fill(v.Index(0)) + case reflect.Pointer: + v.Set(reflect.New(v.Type().Elem())) + fill(v.Elem()) + case reflect.Map: + panic("a map in an entry: teach cloneEntries and entriesCost to copy and count it") + } +} + +// aliased fails when a slice or pointer of the copy shares the original's +// memory. +func aliased(t *testing.T, name string, a, b reflect.Value) { + t.Helper() + switch a.Kind() { + case reflect.Slice: + if a.Len() > 0 && a.Pointer() == b.Pointer() { + t.Fatalf("%s is shared by the copy", name) + } + case reflect.Pointer: + if !a.IsNil() && a.Pointer() == b.Pointer() { + t.Fatalf("%s is shared by the copy", name) + } + case reflect.Map, reflect.Chan, reflect.Func, reflect.Interface: + t.Fatalf("%s is a %s nothing copies", name, a.Kind()) + } +} + +// deepCost is what v points at, counted the way recordCost counts it. +func deepCost(v reflect.Value) int64 { + switch v.Kind() { + case reflect.String: + return int64(v.Len()) + case reflect.Slice: + cost := int64(v.Cap()) * int64(v.Type().Elem().Size()) + for i := range v.Len() { + cost += deepCost(v.Index(i)) + } + return cost + case reflect.Pointer: + if v.IsNil() { + return 0 + } + return int64(v.Type().Elem().Size()) + deepCost(v.Elem()) + case reflect.Struct: + var cost int64 + for i := range v.NumField() { + cost += deepCost(v.Field(i)) + } + return cost + } + return 0 +} + +// BenchmarkRecordCache is a journal of 10k and 100k entries read the plain way, +// which is also what every first open costs, and answered from a kept record. +// retained-B is what the cache counts for the record it keeps; file-B is the +// journal's size. +func BenchmarkRecordCache(b *testing.B) { + for _, size := range []int{10_000, 100_000} { + path := filepath.Join(b.TempDir(), "node.jsonl") + text := recordJournal(size/5, false) + writeRecord(b, path, text) + name := strconv.Itoa(size/1000) + "k" + b.Run(name+"/plain", func(b *testing.B) { + b.ReportAllocs() + for b.Loop() { + if got := ReadTranscript(path); len(got.Entries) != size { + b.Fatalf("%d entries", len(got.Entries)) + } + } + }) + b.Run(name+"/kept", func(b *testing.B) { + cache := settledCache(RecordCacheLimits{}) + read(b, cache, path) + b.ReportAllocs() + for b.Loop() { + if got := read(b, cache, path); len(got.Entries) != size { + b.Fatalf("%d entries", len(got.Entries)) + } + } + s := cache.Stats() + if s.Parses != 1 { + b.Fatalf("stats = %+v", s) + } + b.ReportMetric(float64(s.Retained), "retained-B") + b.ReportMetric(float64(len(text)), "file-B") + }) + } +} diff --git a/internal/session/recordstamp_darwin.go b/internal/session/recordstamp_darwin.go new file mode 100644 index 0000000000..0ce3d66134 --- /dev/null +++ b/internal/session/recordstamp_darwin.go @@ -0,0 +1,32 @@ +//go:build darwin + +package session + +import ( + "os" + "syscall" +) + +// recordStampOf is a regular file's stamp, read off the stat macOS answers +// with: the status change time is kept to the nanosecond and no ordinary write +// can set it back. +func recordStampOf(info os.FileInfo) (recordStamp, bool) { + st, ok := info.Sys().(*syscall.Stat_t) + if !ok || !info.Mode().IsRegular() { + return recordStamp{}, false + } + return recordStamp{ + dev: uint64(st.Dev), + ino: st.Ino, + size: info.Size(), + mtime: info.ModTime().UnixNano(), + ctime: st.Ctimespec.Nano(), + }, true +} + +// openRecordFile opens a journal without waiting on it: a path swapped for a +// named pipe after it was stat'ed opens at once instead of blocking for a +// writer, and the stamp taken through the handle then refuses it. +func openRecordFile(path string) (*os.File, error) { + return os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0) +} diff --git a/internal/session/recordstamp_linux.go b/internal/session/recordstamp_linux.go new file mode 100644 index 0000000000..da2805ca21 --- /dev/null +++ b/internal/session/recordstamp_linux.go @@ -0,0 +1,32 @@ +//go:build linux + +package session + +import ( + "os" + "syscall" +) + +// recordStampOf is a regular file's stamp, read off the stat Linux answers +// with: the inode change time is kept to the nanosecond and no ordinary write +// can set it back. +func recordStampOf(info os.FileInfo) (recordStamp, bool) { + st, ok := info.Sys().(*syscall.Stat_t) + if !ok || !info.Mode().IsRegular() { + return recordStamp{}, false + } + return recordStamp{ + dev: uint64(st.Dev), + ino: uint64(st.Ino), + size: info.Size(), + mtime: info.ModTime().UnixNano(), + ctime: st.Ctim.Nano(), + }, true +} + +// openRecordFile opens a journal without waiting on it: a path swapped for a +// named pipe after it was stat'ed opens at once instead of blocking for a +// writer, and the stamp taken through the handle then refuses it. +func openRecordFile(path string) (*os.File, error) { + return os.OpenFile(path, os.O_RDONLY|syscall.O_NONBLOCK, 0) +} diff --git a/internal/session/recordstamp_other.go b/internal/session/recordstamp_other.go new file mode 100644 index 0000000000..0398c5bcdf --- /dev/null +++ b/internal/session/recordstamp_other.go @@ -0,0 +1,14 @@ +//go:build !linux && !darwin + +package session + +import "os" + +// recordStampOf has no stamp to give where the change time is not known to be +// kept to the nanosecond and out of a writer's reach, so a [RecordCache] there +// keeps nothing and every read is the plain one. +func recordStampOf(os.FileInfo) (recordStamp, bool) { return recordStamp{}, false } + +// openRecordFile opens a journal; the handle's own stat is checked for a +// regular file before anything is read from it. +func openRecordFile(path string) (*os.File, error) { return os.Open(path) } From 6a9f98b9b946c9e77e965a833555f1389688b930 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 18:35:08 -0400 Subject: [PATCH 033/459] codeaf app: a crew's formation is a durable fact of its founding manager's conversation crewReceipt only emitted entry.added with English copy, so the receipt vanished on restart. The teams file now keeps the manager a crew was made under (founder, additive; older builds keep it as an unknown field), and the formation is a receipt entry with an empty text and an op {kind: crew.formed, crew, name} under the id rcrew: at the team's made time, identical live and after a restart. The app words it. Co-Authored-By: Claude Sonnet 5.5 --- cmd/codeaf/app.go | 13 +++-- cmd/codeaf/appcrews.go | 85 ++++++++++++++++++++++++++++++--- cmd/codeaf/appcrews_test.go | 51 ++++++++++++++++++++ cmd/codeaf/appentrypage_test.go | 42 ++++++++++++++++ internal/appwire/wire.go | 17 +++++++ internal/teams/teams.go | 11 +++-- 6 files changed, 206 insertions(+), 13 deletions(-) diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index 6fe145b7fa..3b53347365 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -2098,9 +2098,9 @@ func (b *appBackend) Entries(id, before string, limit int) ([]appwire.Entry, boo var more bool err = appRecords.WithRecord(transcript, func(record session.Record) error { var err error - page, more, err = appEntryPage(id, record, at, marks, before, limit, func(item session.DisplayEntry) appwire.ToolFacts { + page, more, err = appEntryPageWithFormed(id, record, at, marks, before, limit, func(item session.DisplayEntry) appwire.ToolFacts { return b.appEntryFacts(a, item) - }) + }, b.crewFormedEntries(id)) return err }) if err != nil { @@ -2128,8 +2128,15 @@ func appEntriesBusy(err error) error { // cursor are still told over the WHOLE record, because an id counts the // person's messages from the first and an update is known only by a later call. func appEntryPage(id string, record session.Record, at time.Time, marks map[int]appTurnMark, before string, limit int, facts func(session.DisplayEntry) appwire.ToolFacts) ([]appwire.Entry, bool, error) { + return appEntryPageWithFormed(id, record, at, marks, before, limit, facts, nil) +} + +func appEntryPageWithFormed(id string, record session.Record, at time.Time, marks map[int]appTurnMark, before string, limit int, facts func(session.DisplayEntry) appwire.ToolFacts, formed []appwire.Entry) ([]appwire.Entry, bool, error) { items := appRecordItems(record) all, from := appEntryShapes(id, items, at, marks) + if len(formed) > 0 { + all = appWithFormed(all, formed, at, &from) + } if words := strings.TrimSpace(record.Unreadable); words != "" { all = append(all, appwire.Entry{ID: "unreadable", Agent: id, Kind: appwire.EntryNotice, At: at, Text: words}) } @@ -2149,7 +2156,7 @@ func appEntryPage(id string, record session.Record, at time.Time, marks map[int] start := max(end-limit, 0) page := append([]appwire.Entry{}, all[start:end]...) for i := range page { - if start+i < len(from) { + if start+i < len(from) && from[start+i] >= 0 { appDressTool(&page[i], items[from[start+i]]) } } diff --git a/cmd/codeaf/appcrews.go b/cmd/codeaf/appcrews.go index 43ce99e055..9e6c5698ae 100644 --- a/cmd/codeaf/appcrews.go +++ b/cmd/codeaf/appcrews.go @@ -1135,7 +1135,7 @@ func (b *appBackend) newTeam(name, parent string, members []teamstore.Member, ma used = append(used, t.HueSpec()) } } - team := teamstore.Team{ID: id, Name: name, Made: b.now()} + team := teamstore.Team{ID: id, Name: name, Made: b.now(), Founder: managerKey} spec := teamstore.NextHue(used, nil) if hue != nil { spec.Hue = *hue @@ -1213,16 +1213,87 @@ func (b *appBackend) crewExtraOnCreate(id, manager, goal string, ends *time.Time }) } -// crewReceipt says in the manager's conversation that the crew was made. +// crewReceipt says in the founding manager's conversation that the crew was +// made. It says the fact the teams file keeps ([teamstore.Team.Founder], Made +// and Name) and no words, so a restart reads the same entry back from +// [appBackend.crewFormedEntries] under the same id. func (b *appBackend) crewReceipt(manager, id, name string) { if manager == "" { return } - at := b.now() - b.emit.Emit(appwire.EventEntryAdded, appwire.EntryAdded{Entry: appwire.Entry{ - ID: "rcrew:" + id, Agent: manager, Kind: appwire.EntryReceipt, At: at, - Text: "Made the crew " + name + " · you · " + at.Format("15:04"), - }}) + for _, entry := range b.crewFormedEntries(manager) { + if entry.ID == appCrewFormedID(id) { + b.emit.Emit(appwire.EventEntryAdded, appwire.EntryAdded{Entry: entry}) + return + } + } +} + +// appCrewFormedID is the entry id of a crew's formation fact. +func appCrewFormedID(crew string) string { return "rcrew:" + crew } + +// crewFormedEntries is the crews an agent's conversation founded, each as the +// formation entry the conversation carried when it was made, oldest first. +// One read of the teams file. +func (b *appBackend) crewFormedEntries(agent string) []appwire.Entry { + file, err := teamstore.Load(b.crewProfile()) + if err != nil { + return nil + } + var out []appwire.Entry + for _, t := range file.Teams { + if t.Founder == "" || t.Made.IsZero() { + continue + } + founder := appAgentID(t.Founder) + for _, m := range append(append([]teamstore.Member{}, t.Members...), t.FormerMembers...) { + if m.Key == t.Founder { + founder = appMemberAgent(m) + break + } + } + if founder != agent { + continue + } + out = append(out, appwire.Entry{ + ID: appCrewFormedID(t.ID), Agent: agent, Kind: appwire.EntryReceipt, At: t.Made, + Op: &appwire.EntryOp{Kind: appwire.OpCrewFormed, Crew: t.ID, Name: t.Name}, + }) + } + sort.SliceStable(out, func(i, j int) bool { return out[i].At.Before(out[j].At) }) + return out +} + +// appWithFormed puts formation entries among a transcript's entries by the +// clock: before the first entry whose own instant is later. unknown is the +// instant an entry carries when the record never said, which places nothing. +func appWithFormed(all, formed []appwire.Entry, unknown time.Time, sources ...*[]int) []appwire.Entry { + for _, f := range formed { + at, seen := len(all), false + for i, e := range all { + if e.ID == f.ID { + seen = true + break + } + if at == len(all) && !e.At.Equal(unknown) && e.At.After(f.At) { + at = i + } + } + if seen { + continue + } + all = append(all, appwire.Entry{}) + copy(all[at+1:], all[at:]) + all[at] = f + if len(sources) > 0 && sources[0] != nil { + from := *sources[0] + from = append(from, -1) + copy(from[at+1:], from[at:]) + from[at] = -1 // A formation fact has no transcript item to dress. + *sources[0] = from + } + } + return all } // CrewUpdate renames, recolours or resets a crew, and keeps its goal and end diff --git a/cmd/codeaf/appcrews_test.go b/cmd/codeaf/appcrews_test.go index 503983ca95..05fdfd9cc6 100644 --- a/cmd/codeaf/appcrews_test.go +++ b/cmd/codeaf/appcrews_test.go @@ -5,6 +5,7 @@ import ( "github.com/Agent-Field/codeaf/internal/session" "os" "path/filepath" + "reflect" "strings" "testing" "time" @@ -392,3 +393,53 @@ func TestAppAgedCrewMemberIsKeptOpenAndSaidOnceWhenHeld(t *testing.T) { t.Fatalf("agent.changed said %d times for the held member, want 1", changed) } } + +// A CREW'S FORMATION IS A FACT THE MANAGER'S CONVERSATION KEEPS, said as the +// fact and never as words: the entry the manager got live is, field for field, +// the entry its record gives back after a restart, under one id, once. +func TestAppCrewFormationReceiptIsTheSameFactLiveAndAfterRestart(t *testing.T) { + backend, rec, agent := workBackend(t, &fakeConversation{}) + backend.profileDir = t.TempDir() + crew, err := backend.CrewCreate(appwire.CrewCreate{Name: "harbor", Agents: []string{agent.ID}, Manager: agent.ID}) + if err != nil { + t.Fatal(err) + } + var live []appwire.Entry + rec.mu.Lock() + for _, data := range rec.data { + if added, ok := data.(appwire.EntryAdded); ok && added.Entry.Kind == appwire.EntryReceipt { + live = append(live, added.Entry) + } + } + rec.mu.Unlock() + if len(live) != 1 || live[0].ID != "rcrew:"+crew.ID || live[0].Agent != agent.ID { + t.Fatalf("live receipts = %+v", live) + } + if live[0].Text != "" || live[0].Op == nil || live[0].Op.Kind != appwire.OpCrewFormed || live[0].Op.Crew != crew.ID || live[0].Op.Name != "harbor" { + t.Fatalf("the live receipt is not the bare fact: %+v", live[0]) + } + + // A RESTART: another backend over the same teams file, which has no memory + // of the live event. + fresh := newFakeBackend(newAppRecorder(), nil) + t.Cleanup(func() { fresh.Close() }) + fresh.profileDir = backend.profileDir + back := fresh.crewFormedEntries(agent.ID) + if len(back) != 1 || !reflect.DeepEqual(back[0], live[0]) { + t.Fatalf("after restart = %+v, live = %+v", back, live[0]) + } + // Read among the transcript's own entries it is added once, and a second + // read does not add it again. + user := appwire.Entry{ID: "u0", Kind: appwire.EntryUser, At: live[0].At.Add(time.Minute)} + once := appWithFormed([]appwire.Entry{user}, back, time.Time{}) + if len(once) != 2 || once[0].ID != live[0].ID { + t.Fatalf("placed = %+v", once) + } + if again := appWithFormed(once, back, time.Time{}); len(again) != 2 { + t.Fatalf("read twice = %+v", again) + } + // A crew the conversation did not found is not its fact. + if other := fresh.crewFormedEntries("0123456789abcdef"); len(other) != 0 { + t.Fatalf("another agent's = %+v", other) + } +} diff --git a/cmd/codeaf/appentrypage_test.go b/cmd/codeaf/appentrypage_test.go index 2be73c89eb..278ef8fb72 100644 --- a/cmd/codeaf/appentrypage_test.go +++ b/cmd/codeaf/appentrypage_test.go @@ -220,3 +220,45 @@ func BenchmarkAppEntryPage(b *testing.B) { } } } + +func TestAppFormationIsPagedBeforeToolDressingWithoutMovingToolSources(t *testing.T) { + at := time.Date(2026, 10, 15, 14, 0, 0, 0, time.UTC) + record := appPageRecord(3, false) + for i := range record.Entries { + record.Entries[i].At = at.Add(time.Duration(i) * time.Minute) + } + formed := []appwire.Entry{{ID: "rcrew:one", Agent: "a1", Kind: appwire.EntryReceipt, At: at.Add(350 * time.Second), Op: &appwire.EntryOp{Kind: appwire.OpCrewFormed, Crew: "one", Name: "crew"}}} + items := appRecordItems(record) + want := appWithFormed(appEntriesOf("a1", items, at.Add(time.Hour), nil), formed, at.Add(time.Hour)) + var got []appwire.Entry + before := "" + for { + dressed := 0 + page, more, err := appEntryPageWithFormed("a1", record, at.Add(time.Hour), nil, before, 3, appCountedFacts(&dressed), formed) + if err != nil { + t.Fatal(err) + } + tools := 0 + for _, e := range page { + if e.Kind == appwire.EntryTool { + tools++ + for i := range want { + if want[i].ID == e.ID { + want[i].ToolFacts = e.ToolFacts + } + } + } + } + if dressed != tools { + t.Fatalf("dressed %d facts for %d visible tools", dressed, tools) + } + got = append(page, got...) + if !more { + break + } + before = page[0].ID + } + if !reflect.DeepEqual(got, want) { + t.Fatalf("formation paging changed entry order or tool source: got=%+v want=%+v", got, want) + } +} diff --git a/internal/appwire/wire.go b/internal/appwire/wire.go index b749cccf77..4f2a311f1b 100644 --- a/internal/appwire/wire.go +++ b/internal/appwire/wire.go @@ -309,11 +309,28 @@ type Entry struct { ModelFrom string `json:"model_from,omitempty"` ModelTo string `json:"model_to,omitempty"` Attachments []Attachment `json:"attachments,omitempty"` + // Op is a durable fact the conversation's record keeps beside its + // messages, said as the fact and never as words: the app words it. + Op *EntryOp `json:"op,omitempty"` // A tool entry carries the facts of its call (worklog.go); a note entry // names its task there too. ToolFacts } +// EntryOp is an operation the person or an agent did that the conversation +// remembers. It rides a receipt entry whose Text is empty; the entry's id and +// At are the same live and after a restart. +type EntryOp struct { + Kind string `json:"kind" enum:"crew.formed"` + // Crew is the crew's id and Name its name; the entry's Agent is the + // manager it was made under. + Crew string `json:"crew,omitempty"` + Name string `json:"name,omitempty"` +} + +// OpCrewFormed is a crew made under this conversation's agent as its manager. +const OpCrewFormed = "crew.formed" + // The kinds of entry. const ( EntryUser = "user" diff --git a/internal/teams/teams.go b/internal/teams/teams.go index c76ac5c7fd..7727035e49 100644 --- a/internal/teams/teams.go +++ b/internal/teams/teams.go @@ -66,6 +66,10 @@ type Team struct { // Manager is the conversation key of the member that manages the team, "" // for none. It is always one of Members. Manager string + // Founder is the conversation key of the manager the team was made under, + // "" when it was made with none or before this was written. It is a fact + // of the team's making and never moves with the manager seat. + Founder string // Hue and Tier are the team's colour (hue.go). Hue float64 Tier int @@ -120,7 +124,7 @@ var knownFields = map[string]bool{ "hue": true, "tier": true, "hue_cleared": true, "made": true, "state": true, "closed_at": true, "closed_with": true, "report": true, "root": true, "questions_up": true, "cap_usd_day": true, "depth_limit": true, "sub_share": true, "wake": true, - "wrap": true, "former_members": true, "former_manager": true, "starts": true, + "wrap": true, "former_members": true, "former_manager": true, "starts": true, "founder": true, } // wireTeam is the stored shape. Hue and Tier are pointers so a team with no @@ -133,6 +137,7 @@ type wireTeam struct { Members []Member `json:"members"` FormerMembers []Member `json:"former_members,omitempty"` FormerManager string `json:"former_manager,omitempty"` + Founder string `json:"founder,omitempty"` Manager string `json:"manager"` Hue *float64 `json:"hue,omitempty"` HueCleared bool `json:"hue_cleared,omitempty"` @@ -164,7 +169,7 @@ func (t *Team) UnmarshalJSON(raw []byte) error { if err := json.Unmarshal(raw, &all); err != nil { return err } - *t = Team{ID: w.ID, Name: w.Name, Parent: w.Parent, Members: w.Members, FormerMembers: w.FormerMembers, FormerManager: w.FormerManager, Manager: w.Manager, Made: w.Made, + *t = Team{ID: w.ID, Name: w.Name, Parent: w.Parent, Members: w.Members, FormerMembers: w.FormerMembers, FormerManager: w.FormerManager, Founder: w.Founder, Manager: w.Manager, Made: w.Made, Settings: w.Settings, State: w.State, ClosedWith: w.ClosedWith, Report: w.Report, Root: w.Root, Wrap: w.Wrap, Starts: w.Starts} if w.ClosedAt != nil { t.ClosedAt = *w.ClosedAt @@ -193,7 +198,7 @@ func (t *Team) UnmarshalJSON(raw []byte) error { // MarshalJSON writes the known fields in their order, then any field a later // build wrote, sorted, exactly as it was read. func (t Team) MarshalJSON() ([]byte, error) { - w := wireTeam{ID: t.ID, Name: t.Name, Parent: t.Parent, Members: t.Members, FormerMembers: t.FormerMembers, FormerManager: t.FormerManager, Manager: t.Manager, Made: t.Made, + w := wireTeam{ID: t.ID, Name: t.Name, Parent: t.Parent, Members: t.Members, FormerMembers: t.FormerMembers, FormerManager: t.FormerManager, Founder: t.Founder, Manager: t.Manager, Made: t.Made, Settings: t.Settings, ClosedWith: t.ClosedWith, Report: t.Report, Root: t.Root, Wrap: t.Wrap, Starts: t.Starts} // A state this build does not know is written back as it was read, so a // later build's word survives; open is written as nothing. From 3c31592bd6a865577b91adabf4ddba7d2fcbf3fc Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 18:49:08 -0400 Subject: [PATCH 034/459] processgroup: a recorded group identity a later process can restore A kept-running service has to be found again by the next codeaf, so the leader's pid and start identity travel through a file, with the boot they were recorded in: an identity from another boot restores unknown and is never signalled or read as alive. Co-Authored-By: Claude Opus 5.5 --- internal/processgroup/boot_darwin.go | 19 ++++++++ internal/processgroup/boot_linux.go | 18 +++++++ internal/processgroup/boot_other.go | 8 +++ internal/processgroup/identity.go | 67 ++++++++++++++++++++++++++ internal/processgroup/identity_test.go | 43 +++++++++++++++++ 5 files changed, 155 insertions(+) create mode 100644 internal/processgroup/boot_darwin.go create mode 100644 internal/processgroup/boot_linux.go create mode 100644 internal/processgroup/boot_other.go create mode 100644 internal/processgroup/identity.go create mode 100644 internal/processgroup/identity_test.go diff --git a/internal/processgroup/boot_darwin.go b/internal/processgroup/boot_darwin.go new file mode 100644 index 0000000000..09e8ad6cb3 --- /dev/null +++ b/internal/processgroup/boot_darwin.go @@ -0,0 +1,19 @@ +//go:build darwin + +package processgroup + +import ( + "strconv" + + "golang.org/x/sys/unix" +) + +// readBootID is the second the machine booted, which names one boot as well as +// any identifier would: two boots cannot start in the same second. +func readBootID() string { + tv, err := unix.SysctlTimeval("kern.boottime") + if err != nil { + return "" + } + return strconv.FormatInt(int64(tv.Sec), 10) +} diff --git a/internal/processgroup/boot_linux.go b/internal/processgroup/boot_linux.go new file mode 100644 index 0000000000..b2569d248f --- /dev/null +++ b/internal/processgroup/boot_linux.go @@ -0,0 +1,18 @@ +//go:build linux + +package processgroup + +import ( + "os" + "strings" +) + +// readBootID is the kernel's own name for this boot, which changes every time +// the machine starts. +func readBootID() string { + raw, err := os.ReadFile("/proc/sys/kernel/random/boot_id") + if err != nil { + return "" + } + return strings.TrimSpace(string(raw)) +} diff --git a/internal/processgroup/boot_other.go b/internal/processgroup/boot_other.go new file mode 100644 index 0000000000..dfdc53f4bc --- /dev/null +++ b/internal/processgroup/boot_other.go @@ -0,0 +1,8 @@ +//go:build !linux && !darwin + +package processgroup + +// readBootID has no answer here. The start identity these platforms read is a +// wall-clock second (identity_unix.go), which is already unique across boots, +// and Windows reads none at all. +func readBootID() string { return "" } diff --git a/internal/processgroup/identity.go b/internal/processgroup/identity.go new file mode 100644 index 0000000000..5b254a9ff3 --- /dev/null +++ b/internal/processgroup/identity.go @@ -0,0 +1,67 @@ +package processgroup + +// identity.go lets a recorded group outlive the process that recorded it. +// +// A Group is captured at launch and checked before every signal (group.go). A +// kept-running service is a group that has to be found again by a LATER +// process — codeaf quit or crashed, and the next one opens — so the identity +// has to travel through a file. What travels is exactly what the gate checks: +// the leader's pid and the start identity it had at launch, plus the boot it +// was recorded in. +// +// THE BOOT IS PART OF THE IDENTITY. On Linux the start identity is clock ticks +// since boot, which is unique within one boot and says nothing across two: a +// pid handed out again after a restart can carry the very same tick count. So +// an identity recorded in another boot restores as a group whose identity is +// unknown, and an unknown identity is never signalled and never alive — the +// same one-sided default the rest of this package keeps. + +import "sync" + +// Identity is a recorded group leader in a form a file can hold. +type Identity struct { + PID int `json:"pid"` + Start uint64 `json:"start"` + // Boot names the boot the identity was recorded in, where the platform + // can say one. Empty means the platform cannot, and the start identity is + // then a wall-clock second that is already unique across boots. + Boot string `json:"boot,omitempty"` +} + +// Identity reports what the group was recorded as, and false when no identity +// was readable at launch — a group that can never be proven again. +func (g Group) Identity() (Identity, bool) { + if !g.known || g.pid <= 0 { + return Identity{}, false + } + return Identity{PID: g.pid, Start: g.start, Boot: BootID()}, true +} + +// Restore is a group recorded by an earlier process. It is known only when it +// was recorded in this boot, so every signal and liveness answer below goes +// through the same identity gate a group captured in this process does. +func Restore(id Identity) Group { + if id.PID <= 0 { + return Group{} + } + if id.Boot != BootID() { + return Group{pid: id.PID} + } + return Group{pid: id.PID, start: id.Start, known: true} +} + +// SameBoot reports whether an identity was recorded in the boot this process +// is running in. A platform that names no boot answers false only for an +// identity that did name one. +func SameBoot(id Identity) bool { return id.Boot == BootID() } + +var ( + bootOnce sync.Once + bootID string +) + +// BootID names the running boot, read once, or "" where the platform cannot. +func BootID() string { + bootOnce.Do(func() { bootID = readBootID() }) + return bootID +} diff --git a/internal/processgroup/identity_test.go b/internal/processgroup/identity_test.go new file mode 100644 index 0000000000..3b52a95799 --- /dev/null +++ b/internal/processgroup/identity_test.go @@ -0,0 +1,43 @@ +//go:build !windows + +package processgroup + +import ( + "os/exec" + "testing" +) + +// TestARestoredIdentityIsTheGroupItWasRecordedAs proves the round trip a later +// process depends on: the identity read back from a file gates exactly as the +// captured group did, and an identity from another boot gates nothing. +func TestARestoredIdentityIsTheGroupItWasRecordedAs(t *testing.T) { + cmd := exec.Command("sleep", "30") + ConfigureDetached(cmd) + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = cmd.Process.Kill(); _ = cmd.Wait() }) + id, ok := CaptureGroup(cmd.Process.Pid).Identity() + if !ok { + t.Fatal("a live leader recorded no identity") + } + if !Restore(id).Alive() { + t.Fatal("the restored group is not alive") + } + moved := id + moved.Start++ + if Restore(moved).Alive() { + t.Fatal("a recycled pid with another start identity read as ours") + } + other := id + other.Boot = "another boot" + if Restore(other).Alive() || SameBoot(other) { + t.Fatal("an identity from another boot read as ours") + } + if err := Restore(other).Kill(); err != nil { + t.Fatal(err) + } + if !Restore(id).Alive() { + t.Fatal("a refused signal reached the group") + } +} From 0151593472d72e0414cad6966950a1f55e18397c Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 18:53:30 -0400 Subject: [PATCH 035/459] processgroup: prove boot identity and read precise Darwin start time --- internal/processgroup/boot_darwin.go | 8 ++++--- internal/processgroup/boot_other.go | 5 ++--- internal/processgroup/identity.go | 21 ++++++++++-------- internal/processgroup/identity_darwin.go | 23 ++++++++++++++++++++ internal/processgroup/identity_test.go | 27 +++++++++++++++++++++++- internal/processgroup/identity_unix.go | 2 +- 6 files changed, 69 insertions(+), 17 deletions(-) create mode 100644 internal/processgroup/identity_darwin.go diff --git a/internal/processgroup/boot_darwin.go b/internal/processgroup/boot_darwin.go index 09e8ad6cb3..159b4166d5 100644 --- a/internal/processgroup/boot_darwin.go +++ b/internal/processgroup/boot_darwin.go @@ -8,12 +8,14 @@ import ( "golang.org/x/sys/unix" ) -// readBootID is the second the machine booted, which names one boot as well as -// any identifier would: two boots cannot start in the same second. +// readBootID preserves the kernel's complete boot timestamp. func readBootID() string { tv, err := unix.SysctlTimeval("kern.boottime") if err != nil { return "" } - return strconv.FormatInt(int64(tv.Sec), 10) + if tv.Sec <= 0 || tv.Usec < 0 || tv.Usec >= 1_000_000 { + return "" + } + return strconv.FormatInt(int64(tv.Sec), 10) + ":" + strconv.FormatInt(int64(tv.Usec), 10) } diff --git a/internal/processgroup/boot_other.go b/internal/processgroup/boot_other.go index dfdc53f4bc..6ba46505a0 100644 --- a/internal/processgroup/boot_other.go +++ b/internal/processgroup/boot_other.go @@ -2,7 +2,6 @@ package processgroup -// readBootID has no answer here. The start identity these platforms read is a -// wall-clock second (identity_unix.go), which is already unique across boots, -// and Windows reads none at all. +// readBootID has no answer here, so persisted identities cannot be restored. +// Captured groups still keep their existing in-process signal protection. func readBootID() string { return "" } diff --git a/internal/processgroup/identity.go b/internal/processgroup/identity.go index 5b254a9ff3..46c350ff58 100644 --- a/internal/processgroup/identity.go +++ b/internal/processgroup/identity.go @@ -22,16 +22,15 @@ import "sync" type Identity struct { PID int `json:"pid"` Start uint64 `json:"start"` - // Boot names the boot the identity was recorded in, where the platform - // can say one. Empty means the platform cannot, and the start identity is - // then a wall-clock second that is already unique across boots. + // Boot names the boot the identity was recorded in. Without a boot identity, + // a persisted pid and start count cannot safely prove the original process. Boot string `json:"boot,omitempty"` } // Identity reports what the group was recorded as, and false when no identity // was readable at launch — a group that can never be proven again. func (g Group) Identity() (Identity, bool) { - if !g.known || g.pid <= 0 { + if !g.known || g.pid <= 0 || BootID() == "" { return Identity{}, false } return Identity{PID: g.pid, Start: g.start, Boot: BootID()}, true @@ -44,16 +43,20 @@ func Restore(id Identity) Group { if id.PID <= 0 { return Group{} } - if id.Boot != BootID() { + if id.Start == 0 || id.Boot == "" || BootID() == "" || id.Boot != BootID() { return Group{pid: id.PID} } return Group{pid: id.PID, start: id.Start, known: true} } -// SameBoot reports whether an identity was recorded in the boot this process -// is running in. A platform that names no boot answers false only for an -// identity that did name one. -func SameBoot(id Identity) bool { return id.Boot == BootID() } +// SameBoot reports a proven matching boot. Missing identity proves neither +// that a boot is the same nor that it changed. +func SameBoot(id Identity) bool { return id.Boot != "" && BootID() != "" && id.Boot == BootID() } + +// DifferentBoot proves an intervening machine restart. A caller deciding +// whether to start a saved service must not treat unreadable identity as a +// restart: the original process may still be running. +func DifferentBoot(id Identity) bool { return id.Boot != "" && BootID() != "" && id.Boot != BootID() } var ( bootOnce sync.Once diff --git a/internal/processgroup/identity_darwin.go b/internal/processgroup/identity_darwin.go new file mode 100644 index 0000000000..d4fa616955 --- /dev/null +++ b/internal/processgroup/identity_darwin.go @@ -0,0 +1,23 @@ +//go:build darwin + +package processgroup + +import "golang.org/x/sys/unix" + +// A kernel start timestamp distinguishes recycled pids without launching ps. +// Keep its microseconds: lstart's seconds can identify two different processes +// as the same one if the pid is reused within that second. +func readProcessStart(pid int) (uint64, bool) { + if pid <= 0 { + return 0, false + } + p, err := unix.SysctlKinfoProc("kern.proc.pid", pid) + if err != nil || p == nil || int(p.Proc.P_pid) != pid { + return 0, false + } + t := p.Proc.P_starttime + if t.Sec <= 0 || t.Usec < 0 || t.Usec >= 1_000_000 { + return 0, false + } + return uint64(t.Sec)*1_000_000 + uint64(t.Usec), true +} diff --git a/internal/processgroup/identity_test.go b/internal/processgroup/identity_test.go index 3b52a95799..d77c1ffe5b 100644 --- a/internal/processgroup/identity_test.go +++ b/internal/processgroup/identity_test.go @@ -31,7 +31,7 @@ func TestARestoredIdentityIsTheGroupItWasRecordedAs(t *testing.T) { } other := id other.Boot = "another boot" - if Restore(other).Alive() || SameBoot(other) { + if Restore(other).Alive() || SameBoot(other) || !DifferentBoot(other) { t.Fatal("an identity from another boot read as ours") } if err := Restore(other).Kill(); err != nil { @@ -41,3 +41,28 @@ func TestARestoredIdentityIsTheGroupItWasRecordedAs(t *testing.T) { t.Fatal("a refused signal reached the group") } } + +func TestMissingBootIdentityCannotAuthorizeRestoreOrAutomaticRebootStart(t *testing.T) { + // All package tests are serial. Initialize once, then model a boot read that + // failed: Linux start ticks alone can match an unrelated process after reboot. + previous := BootID() + bootID = "" + t.Cleanup(func() { bootID = previous }) + g := Group{pid: 123, start: 456, known: true} + if _, ok := g.Identity(); ok { + t.Fatal("an unreadable boot produced a restorable identity") + } + for _, id := range []Identity{{PID: 123, Start: 456}, {PID: 123, Start: 456, Boot: previous}} { + if Restore(id).known || SameBoot(id) || DifferentBoot(id) { + t.Fatal("an unreadable boot was treated as proof of process ownership or reboot") + } + } +} + +func TestInvalidPersistedIdentityFailsClosed(t *testing.T) { + for _, id := range []Identity{{PID: -1, Start: 2, Boot: BootID()}, {PID: 123, Boot: BootID()}, {PID: 123, Start: 2}} { + if Restore(id).known { + t.Fatal("an incomplete persisted identity was trusted") + } + } +} diff --git a/internal/processgroup/identity_unix.go b/internal/processgroup/identity_unix.go index 267445a1c6..7b89600fa6 100644 --- a/internal/processgroup/identity_unix.go +++ b/internal/processgroup/identity_unix.go @@ -1,4 +1,4 @@ -//go:build !windows && !linux +//go:build !windows && !linux && !darwin package processgroup From 7d1fbaa568091bdac8caebd3c9ab5e88288dcc64 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 19:07:02 -0400 Subject: [PATCH 036/459] processgroup: skip restore round trip without boot identity --- internal/processgroup/identity_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/internal/processgroup/identity_test.go b/internal/processgroup/identity_test.go index d77c1ffe5b..1bd1909b34 100644 --- a/internal/processgroup/identity_test.go +++ b/internal/processgroup/identity_test.go @@ -4,6 +4,7 @@ package processgroup import ( "os/exec" + "runtime" "testing" ) @@ -11,6 +12,9 @@ import ( // process depends on: the identity read back from a file gates exactly as the // captured group did, and an identity from another boot gates nothing. func TestARestoredIdentityIsTheGroupItWasRecordedAs(t *testing.T) { + if runtime.GOOS != "linux" && runtime.GOOS != "darwin" { + t.Skip("this platform cannot prove persisted boot identity") + } cmd := exec.Command("sleep", "30") ConfigureDetached(cmd) if err := cmd.Start(); err != nil { From b1dac8003d1241c183a730d20188e03818ad5d9f Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 19:05:21 -0400 Subject: [PATCH 037/459] workspace: collections schema 2 with moments, crew and process kinds, cascading deletes and case-blind names A schema-1 file is read as it stands and rebuilt in place by the first write in one immediate transaction. The store gains Delete, Duplicate, multi-ref AddRefs/RemoveRefs with added_at restore, List with counts and holds, and bounded Tree and Expand reads that carry their own cycle guard. codeaf collections gains delete and duplicate. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/collections.go | 32 +- cmd/codeaf/collections_test.go | 27 ++ internal/manual/chat/collections.md | 24 +- internal/workspace/schema2_test.go | 418 +++++++++++++++++++++++ internal/workspace/store.go | 508 ++++++++++++++++++++++------ internal/workspace/views.go | 335 ++++++++++++++++++ internal/workspace/workspace.go | 33 +- 7 files changed, 1258 insertions(+), 119 deletions(-) create mode 100644 internal/workspace/schema2_test.go create mode 100644 internal/workspace/views.go diff --git a/cmd/codeaf/collections.go b/cmd/codeaf/collections.go index 1361d1d8df..70ed21b4e0 100644 --- a/cmd/codeaf/collections.go +++ b/cmd/codeaf/collections.go @@ -18,11 +18,12 @@ const collectionsSummary = ` codeaf collections [list] [--json] organize cha const collectionsUsage = collectionsSummary + ` codeaf collections create | rename + codeaf collections duplicate | delete codeaf collections show codeaf collections add|remove codeaf collections find - kinds: collection, conversation, task, artifact (a file path) - tasks need --session ; all accept --db and --json + kinds: collection, conversation, task, process, crew, artifact (a file path) + tasks and processes need --session ; all accept --db and --json organize references without moving files or starting work` func runCollections(args []string) error { return runCollectionsTo(args, os.Stdout) } @@ -40,7 +41,7 @@ func runCollectionsTo(args []string, output io.Writer) error { rest = []string{"list"} } verb := rest[0] - want := map[string]int{"list": 1, "create": 2, "rename": 3, "show": 2, "add": 4, "remove": 4, "find": 3} + want := map[string]int{"list": 1, "create": 2, "rename": 3, "duplicate": 3, "delete": 2, "show": 2, "add": 4, "remove": 4, "find": 3} if n, ok := want[verb]; !ok || len(rest) != n { return fmt.Errorf("usage: %s", strings.TrimLeft(collectionsUsage, " ")) } @@ -50,12 +51,12 @@ func runCollectionsTo(args []string, output io.Writer) error { if strings.TrimSpace(*database) == "" { return fmt.Errorf("%w: --db needs a collection database path", workspace.ErrInvalid) } - if verb == "rename" || verb == "show" || verb == "add" || verb == "remove" { + if verb == "rename" || verb == "duplicate" || verb == "delete" || verb == "show" || verb == "add" || verb == "remove" { if err := (workspace.Ref{Kind: workspace.CollectionKind, ID: rest[1]}).Validate(); err != nil { return err } } - if verb == "create" || verb == "rename" { + if verb == "create" || verb == "rename" || verb == "duplicate" { if err := workspace.ValidateName(rest[len(rest)-1]); err != nil { return err } @@ -86,7 +87,7 @@ func runCollectionsTo(args []string, output io.Writer) error { return fmt.Errorf("%w: standing items are gone, so a collection cannot gain one", workspace.ErrInvalid) } } else if *sessionID != "" { - return fmt.Errorf("%w: --session belongs to a task reference in add, remove or find", workspace.ErrInvalid) + return fmt.Errorf("%w: --session belongs to a task or process reference in add, remove or find", workspace.ErrInvalid) } path, err := expandHome(*database) if err != nil { @@ -119,6 +120,11 @@ func runCollectionsTo(args []string, output io.Writer) error { case "rename": err = store.Rename(ctx, rest[1], rest[2]) result = workspace.Collection{ID: rest[1], Name: rest[2]} + case "duplicate": + result, err = store.Duplicate(ctx, rest[1], rest[2]) + case "delete": + _, err = store.Delete(ctx, rest[1]) + result = collectionDeleted{ID: rest[1], Deleted: true} case "show": result, err = store.Members(ctx, rest[1]) case "add": @@ -136,6 +142,12 @@ func runCollectionsTo(args []string, output io.Writer) error { return writeCollectionsResult(output, verb, *asJSON, result) } +// collectionDeleted is what delete answers: the collection that is gone. +type collectionDeleted struct { + ID string `json:"id"` + Deleted bool `json:"deleted"` +} + func writeCollectionsResult(output io.Writer, verb string, asJSON bool, result any) error { if asJSON { return json.NewEncoder(output).Encode(result) @@ -144,6 +156,10 @@ func writeCollectionsResult(output io.Writer, verb string, asJSON bool, result a switch value := result.(type) { case workspace.Collection: _, err = fmt.Fprintf(output, "%s %s\n", value.ID, value.Name) + case collectionDeleted: + // Deleting a collection takes its memberships and every membership + // naming it, and never the records any of them referenced. + _, err = fmt.Fprintln(output, "Collection deleted; the records it referenced are unchanged.") case []workspace.Collection: if len(value) == 0 { // `find` coming back empty says this record is in no collection, @@ -187,8 +203,8 @@ func writeCollectionsResult(output io.Writer, verb string, asJSON bool, result a func writeCollectionRef(output io.Writer, ref workspace.Ref) error { var err error - if ref.Kind == workspace.TaskKind { - _, err = fmt.Fprintf(output, "task %s conversation %s\n", ref.ID, ref.SessionID) + if ref.Kind == workspace.TaskKind || ref.Kind == workspace.ProcessKind { + _, err = fmt.Fprintf(output, "%s %s conversation %s\n", ref.Kind, ref.ID, ref.SessionID) } else { _, err = fmt.Fprintf(output, "%s %s\n", ref.Kind, ref.ID) } diff --git a/cmd/codeaf/collections_test.go b/cmd/codeaf/collections_test.go index 40c0eb3ac6..1b1bcabbf7 100644 --- a/cmd/codeaf/collections_test.go +++ b/cmd/codeaf/collections_test.go @@ -327,3 +327,30 @@ func TestCollectionsOnlyLetGoOfStandingItems(t *testing.T) { t.Fatalf("the older row could not be taken out: %v %v", holding, err) } } + +// DUPLICATE COPIES A COLLECTION'S REFERENCES AND DELETE TAKES ONLY THE +// COLLECTION: the membership naming it elsewhere goes with it, and the +// readable answer says the records are unchanged. +func TestCollectionsDuplicateAndDelete(t *testing.T) { + db := filepath.Join(t.TempDir(), "collections.db") + outer, inner := collectionCreate(t, db, "Outer"), collectionCreate(t, db, "Inner") + collectionCommand(t, db, "add", outer.ID, "collection", inner.ID) + collectionCommand(t, db, "add", inner.ID, "process", "chat:p1", "--session", "chat") + var copy workspace.Collection + if err := json.Unmarshal([]byte(collectionCommand(t, db, "duplicate", inner.ID, "Copy")), ©); err != nil || copy.Name != "Copy" { + t.Fatalf("duplicate %+v, %v", copy, err) + } + if got := collectionPlain(t, db, "show", copy.ID); got != "process chat:p1 conversation chat\n" { + t.Fatalf("copy holds %q", got) + } + if got := collectionPlain(t, db, "delete", inner.ID); got != "Collection deleted; the records it referenced are unchanged.\n" { + t.Fatalf("delete said %q", got) + } + if got := collectionCommand(t, db, "show", outer.ID); got != "[]\n" { + t.Fatalf("the membership naming a deleted collection stayed: %s", got) + } + err := runCollectionsTo([]string{"duplicate", copy.ID, "outer", "--db", db}, &bytes.Buffer{}) + if !errors.Is(err, workspace.ErrNameTaken) { + t.Fatalf("a taken name in another case: %v", err) + } +} diff --git a/internal/manual/chat/collections.md b/internal/manual/chat/collections.md index 2ca637cac6..6f3150b2ef 100644 --- a/internal/manual/chat/collections.md +++ b/internal/manual/chat/collections.md @@ -33,6 +33,9 @@ There is no command that lists conversation IDs. Run `codeaf collections` or `codeaf collections list` to list collections. `codeaf collections create "Marketing"` prints the new ID and name. Use that ID with `codeaf collections rename "Launch"`. +Names are unique without regard to case, so `create` or `rename` to a name +another collection already has answers `another collection already has that +name`; renaming a collection to its own name in another case is allowed. `codeaf collections show ` lists that collection's direct references. Order follows creation and insertion, rather than inferred relevance. Reading a fresh home or an existing empty file does not create a database. @@ -64,12 +67,12 @@ belong to collections. ## Adding, removing and finding a chat or work reference Use `codeaf collections add `. -The kinds are `collection`, `conversation`, `task` and `artifact`. There is no kind +The kinds are `collection`, `conversation`, `task`, `process`, `crew` and `artifact`. There is no kind for an automation. `standing` named a standing item, which codeaf no longer has: a row that already holds one can still be found and removed, and adding a new one is refused with `standing items are gone, so a collection cannot gain one`. -A task also requires `--session ` because task numbers repeat -in different conversations. For example, `codeaf collections add +A task, and a process, also requires `--session ` because task +numbers repeat in different conversations. For example, `codeaf collections add task 1 --session ` records that specific task. `artifact` takes a local file path, stored as an absolute path. A relative path @@ -89,6 +92,15 @@ references this record.` `codeaf collections remove ` removes only that membership, answering `This collection no longer references it; the original record is unchanged.` It neither deletes the original record nor stops ongoing work. Repeating add or remove is harmless, and removing -something that was never there is not an error. Collection deletion, automatic -organization, inherited instructions and communication between conversations are -not implemented by these commands. +something that was never there is not an error. Automatic organization, +inherited instructions and communication between conversations are not +implemented by these commands. + +## Deleting or duplicating a collection + +`codeaf collections delete ` deletes the collection, its +memberships, and every membership naming it in another collection, answering +`Collection deleted; the records it referenced are unchanged.` Nothing it +referenced is deleted, moved or stopped. `codeaf collections duplicate + ` makes a new collection holding the same references, in +the same order; the new name follows the same uniqueness rule as `create`. diff --git a/internal/workspace/schema2_test.go b/internal/workspace/schema2_test.go new file mode 100644 index 0000000000..81215afcfa --- /dev/null +++ b/internal/workspace/schema2_test.go @@ -0,0 +1,418 @@ +package workspace + +import ( + "context" + "database/sql" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "testing" + "time" +) + +// legacyDDL is the schema-1 file exactly as the build before schema 2 wrote +// it, so the migration is proved against the bytes people actually have. +const legacyDDL = ` +CREATE TABLE collections ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL +); +CREATE TABLE memberships ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + collection_id TEXT NOT NULL REFERENCES collections(id), + kind TEXT NOT NULL CHECK(kind IN ('collection','conversation','task','standing','artifact')), + ref_id TEXT NOT NULL, + session_id TEXT NOT NULL, + target_collection TEXT REFERENCES collections(id), + CHECK ((kind='collection' AND target_collection IS NOT NULL AND target_collection=ref_id) + OR (kind!='collection' AND target_collection IS NULL)), + UNIQUE(collection_id,kind,ref_id,session_id) +); +CREATE INDEX memberships_reference ON memberships(kind,ref_id,session_id); +PRAGMA application_id=1095123788; PRAGMA user_version=1; +INSERT INTO collections(id,name) VALUES ('aaaa','Product'),('bbbb','Nested'); +INSERT INTO memberships(collection_id,kind,ref_id,session_id,target_collection) VALUES + ('aaaa','conversation','chat-1','',NULL), + ('aaaa','task','3','chat-1',NULL), + ('aaaa','collection','bbbb','','bbbb'), + ('bbbb','artifact','/tmp/report.md','',NULL); +` + +func writeLegacyStore(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "legacy.db") + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + if _, err := db.Exec(legacyDDL); err != nil { + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + return path +} + +func userVersion(t *testing.T, s *Store) int { + t.Helper() + var v int + if err := s.db.QueryRow("PRAGMA user_version").Scan(&v); err != nil { + t.Fatal(err) + } + return v +} + +func fixedClock(s *Store, ms int64) { s.now = func() time.Time { return time.UnixMilli(ms) } } + +// A SCHEMA-1 FILE IS READ AS IT STANDS AND MIGRATED BY THE FIRST WRITE: the +// reads leave every byte alone and say nothing of when anything was added, +// and the first write rebuilds both tables in one transaction, keeping every +// row in its order with added_at unknown. +func TestSchemaOneFileIsReadThenMigratedInPlaceByTheFirstWrite(t *testing.T) { + ctx := context.Background() + path := writeLegacyStore(t) + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + s := openTestStore(t, path) + list, err := s.List(ctx, &Ref{Kind: ConversationKind, ID: "chat-1"}) + if err != nil || len(list) != 2 || list[0].Count != 3 || list[0].LastAddedAt != 0 || list[0].CreatedAt != 0 || !*list[0].Holds || *list[1].Holds { + t.Fatalf("legacy list %+v, %v", list, err) + } + tree, err := s.Tree(ctx, "aaaa", 1, 10) + if err != nil || len(tree.Members) != 3 || tree.Members[0].Kind != CollectionKind || tree.Members[0].Children == nil || len(tree.Members[0].Children.Members) != 1 { + t.Fatalf("legacy tree %+v, %v", tree, err) + } + if after, _ := os.ReadFile(path); string(after) != string(before) { + t.Fatal("a read changed a schema-1 file") + } + if userVersion(t, s) != 1 { + t.Fatal("a read migrated the file") + } + + fixedClock(s, 5000) + added, already, err := s.AddRefs(ctx, "aaaa", []Addition{{Ref: Ref{Kind: CrewKind, ID: "crew-1"}}, {Ref: Ref{Kind: ConversationKind, ID: "chat-1"}}}) + if err != nil || len(added) != 1 || added[0].AddedAt != 5000 || len(already) != 1 { + t.Fatalf("first write %+v %+v %v", added, already, err) + } + if userVersion(t, s) != schemaVersion { + t.Fatal("the first write did not migrate") + } + got, err := s.Members(ctx, "aaaa") + want := []Ref{{Kind: ConversationKind, ID: "chat-1"}, {Kind: TaskKind, ID: "3", SessionID: "chat-1"}, {Kind: CollectionKind, ID: "bbbb"}, {Kind: CrewKind, ID: "crew-1"}} + if err != nil || !reflect.DeepEqual(got, want) { + t.Fatalf("migrated members %v, %v", got, err) + } + // The migrated foreign keys cascade: deleting the nested collection + // takes the membership naming it. + parents, err := s.Delete(ctx, "bbbb") + if err != nil || !reflect.DeepEqual(parents, []string{"aaaa"}) { + t.Fatalf("delete after migration %v, %v", parents, err) + } + if got, _ := s.Members(ctx, "aaaa"); len(got) != 3 { + t.Fatalf("the membership naming a deleted collection stayed: %v", got) + } + // Another handle that saw the file at schema 1 reads it at schema 2 now. + other := openTestStore(t, path) + if list, err := other.List(ctx, nil); err != nil || len(list) != 1 || list[0].LastAddedAt != 5000 { + t.Fatalf("reopened list %+v, %v", list, err) + } +} + +// A handle opened on a schema-1 file keeps reading after another process +// migrated it underneath, and its own first write does not migrate twice. +func TestAStaleLegacyHandleFollowsAPeersMigration(t *testing.T) { + ctx := context.Background() + path := writeLegacyStore(t) + stale, fresh := openTestStore(t, path), openTestStore(t, path) + if err := fresh.Rename(ctx, "aaaa", "Products"); err != nil { + t.Fatal(err) + } + if err := stale.Add(ctx, "aaaa", Ref{Kind: ProcessKind, ID: "chat-1:p1", SessionID: "chat-1"}); err != nil { + t.Fatal(err) + } + list, err := stale.List(ctx, nil) + if err != nil || list[0].Name != "Products" || list[0].Count != 4 { + t.Fatalf("stale list %+v, %v", list, err) + } +} + +// NAMES ARE UNIQUE WITHOUT REGARD TO CASE, Unicode included, and a rename to +// a collection's own name in another case is allowed. +func TestCollectionNamesAreUniqueIgnoringCase(t *testing.T) { + ctx := context.Background() + s := openTestStore(t, filepath.Join(t.TempDir(), "names.db")) + a := createTestCollection(t, s, "Ärger") + b := createTestCollection(t, s, "Launch") + for _, name := range []string{"ärger", "ÄRGER"} { + if _, err := s.Create(ctx, name); !errors.Is(err, ErrNameTaken) { + t.Fatalf("create %q: %v", name, err) + } + } + if err := s.Rename(ctx, b.ID, "äRGER"); !errors.Is(err, ErrNameTaken) { + t.Fatalf("rename onto another's name: %v", err) + } + if _, err := s.Duplicate(ctx, b.ID, "launch"); !errors.Is(err, ErrNameTaken) { + t.Fatalf("duplicate onto a taken name: %v", err) + } + if err := s.Rename(ctx, a.ID, "ÄRGER"); err != nil { + t.Fatalf("rename to its own name in another case: %v", err) + } + if got, _ := s.Describe(ctx, a.ID); got.Name != "ÄRGER" { + t.Fatalf("renamed %+v", got) + } +} + +// DELETE CASCADES: the collection, its own memberships and every membership +// naming it go, and the collections that lost one are answered. +func TestDeleteCascadesAndAnswersTheCollectionsThatChanged(t *testing.T) { + ctx := context.Background() + s := openTestStore(t, filepath.Join(t.TempDir(), "delete.db")) + p1, p2, child, other := createTestCollection(t, s, "P1"), createTestCollection(t, s, "P2"), createTestCollection(t, s, "Child"), createTestCollection(t, s, "Other") + for _, parent := range []Collection{p1, p2} { + if err := s.Add(ctx, parent.ID, Ref{Kind: CollectionKind, ID: child.ID}); err != nil { + t.Fatal(err) + } + } + if err := s.Add(ctx, child.ID, Ref{Kind: ConversationKind, ID: "chat"}); err != nil { + t.Fatal(err) + } + if err := s.Add(ctx, other.ID, Ref{Kind: ConversationKind, ID: "chat"}); err != nil { + t.Fatal(err) + } + parents, err := s.Delete(ctx, child.ID) + if err != nil || !reflect.DeepEqual(parents, []string{p1.ID, p2.ID}) { + t.Fatalf("parents %v, %v", parents, err) + } + var rows int + if err := s.db.QueryRow("SELECT count(*) FROM memberships WHERE collection_id=? OR ref_id=?", child.ID, child.ID).Scan(&rows); err != nil || rows != 0 { + t.Fatalf("%d memberships outlived their collection: %v", rows, err) + } + if got, _ := s.Members(ctx, other.ID); len(got) != 1 { + t.Fatalf("another collection's reference to the same record went: %v", got) + } + if _, err := s.Delete(ctx, child.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("second delete: %v", err) + } +} + +// A DUPLICATE HOLDS THE SAME REFERENCES with the same added_at in the same +// order, and is a collection of its own. +func TestDuplicateCopiesMembershipsWithTheirMoments(t *testing.T) { + ctx := context.Background() + s := openTestStore(t, filepath.Join(t.TempDir(), "dup.db")) + src, nested := createTestCollection(t, s, "Source"), createTestCollection(t, s, "Nested") + refs := []Ref{{Kind: ConversationKind, ID: "chat"}, {Kind: CollectionKind, ID: nested.ID}, {Kind: TaskKind, ID: "2", SessionID: "chat"}} + for i, ref := range refs { + fixedClock(s, int64(1000*(i+1))) + if err := s.Add(ctx, src.ID, ref); err != nil { + t.Fatal(err) + } + } + fixedClock(s, 9000) + copy, err := s.Duplicate(ctx, src.ID, "Copy") + if err != nil { + t.Fatal(err) + } + a, _ := s.Tree(ctx, src.ID, 0, 10) + b, _ := s.Tree(ctx, copy.ID, 0, 10) + if !reflect.DeepEqual(a.Members, b.Members) || len(b.Members) != 3 || b.Members[0].AddedAt != 3000 { + t.Fatalf("source %+v\ncopy %+v", a.Members, b.Members) + } + if b.Collection.CreatedAt != 9000 || b.Collection.Count != 3 || b.Collection.LastAddedAt != 3000 { + t.Fatalf("copy summary %+v", b.Collection) + } + if _, err := s.Duplicate(ctx, "missing", "Other"); !errors.Is(err, ErrNotFound) { + t.Fatalf("duplicate of nothing: %v", err) + } +} + +// AN ADD IS ALL OR NOTHING: one cycle, one missing collection or one bad +// reference refuses every reference in the call. +func TestAddOfManyIsAtomicAndRefusesACycle(t *testing.T) { + ctx := context.Background() + s := openTestStore(t, filepath.Join(t.TempDir(), "add.db")) + a, b, c := createTestCollection(t, s, "A"), createTestCollection(t, s, "B"), createTestCollection(t, s, "C") + if err := s.Add(ctx, b.ID, Ref{Kind: CollectionKind, ID: c.ID}); err != nil { + t.Fatal(err) + } + if err := s.Add(ctx, c.ID, Ref{Kind: CollectionKind, ID: a.ID}); err != nil { + t.Fatal(err) + } + for name, refs := range map[string][]Addition{ + "self": {{Ref: Ref{Kind: ConversationKind, ID: "chat"}}, {Ref: Ref{Kind: CollectionKind, ID: a.ID}}}, + "deep": {{Ref: Ref{Kind: ConversationKind, ID: "chat"}}, {Ref: Ref{Kind: CollectionKind, ID: b.ID}}}, + "missing": {{Ref: Ref{Kind: ConversationKind, ID: "chat"}}, {Ref: Ref{Kind: CollectionKind, ID: "nope"}}}, + "invalid": {{Ref: Ref{Kind: ConversationKind, ID: "chat"}}, {Ref: Ref{Kind: ProcessKind, ID: "p1"}}}, + } { + _, _, err := s.AddRefs(ctx, a.ID, refs) + want := map[string]error{"self": ErrCycle, "deep": ErrCycle, "missing": ErrNotFound, "invalid": ErrInvalid}[name] + if !errors.Is(err, want) { + t.Fatalf("%s: %v", name, err) + } + if got, _ := s.Members(ctx, a.ID); len(got) != 0 { + t.Fatalf("%s left %v behind", name, got) + } + } + added, already, err := s.AddRefs(ctx, a.ID, []Addition{{Ref: Ref{Kind: ConversationKind, ID: "chat"}}, {Ref: Ref{Kind: ConversationKind, ID: "chat"}}, {Ref: Ref{Kind: CrewKind, ID: "crew"}}}) + if err != nil || len(added) != 2 || !reflect.DeepEqual(already, []Ref{{Kind: ConversationKind, ID: "chat"}}) { + t.Fatalf("added %v already %v: %v", added, already, err) + } +} + +// REMOVE ANSWERS WHAT WAS THERE WITH ITS MOMENT, AND ADDING IT BACK WITH +// THAT MOMENT PUTS IT WHERE IT WAS: the Undo of a removal. +func TestRemoveThenRestoreKeepsThePlace(t *testing.T) { + ctx := context.Background() + s := openTestStore(t, filepath.Join(t.TempDir(), "undo.db")) + c := createTestCollection(t, s, "Inbox") + for i, id := range []string{"first", "second", "third"} { + fixedClock(s, int64(100*(i+1))) + if err := s.Add(ctx, c.ID, Ref{Kind: ConversationKind, ID: id}); err != nil { + t.Fatal(err) + } + } + removed, err := s.RemoveRefs(ctx, c.ID, []Ref{{Kind: ConversationKind, ID: "second"}, {Kind: ConversationKind, ID: "never"}}) + if err != nil || !reflect.DeepEqual(removed, []Member{{Ref: Ref{Kind: ConversationKind, ID: "second"}, AddedAt: 200}}) { + t.Fatalf("removed %v, %v", removed, err) + } + fixedClock(s, 999) + added, _, err := s.AddRefs(ctx, c.ID, []Addition{{Ref: removed[0].Ref, AddedAt: removed[0].AddedAt}}) + if err != nil || added[0].AddedAt != 200 { + t.Fatalf("restore %v, %v", added, err) + } + tree, _ := s.Tree(ctx, c.ID, 0, 10) + var order []string + for _, n := range tree.Members { + order = append(order, n.ID) + } + if !reflect.DeepEqual(order, []string{"third", "second", "first"}) { + t.Fatalf("restored order %v", order) + } +} + +// AN EXPANSION is every live-shaped reference once, at its outermost place, +// to its depth, reading each nested collection once. +func TestExpandDedupesAndStopsAtItsDepth(t *testing.T) { + ctx := context.Background() + s := openTestStore(t, filepath.Join(t.TempDir(), "expand.db")) + root, l1, l2, l3 := createTestCollection(t, s, "Root"), createTestCollection(t, s, "L1"), createTestCollection(t, s, "L2"), createTestCollection(t, s, "L3") + add := func(c Collection, refs ...Ref) { + t.Helper() + for _, ref := range refs { + if err := s.Add(ctx, c.ID, ref); err != nil { + t.Fatal(err) + } + } + } + shared := Ref{Kind: ConversationKind, ID: "shared"} + add(root, shared, Ref{Kind: CollectionKind, ID: l1.ID}, Ref{Kind: CollectionKind, ID: l2.ID}) + add(l1, shared, Ref{Kind: ArtifactKind, ID: "/a"}, Ref{Kind: CollectionKind, ID: l2.ID}) + add(l2, Ref{Kind: TaskKind, ID: "1", SessionID: "x"}, Ref{Kind: CollectionKind, ID: l3.ID}) + add(l3, Ref{Kind: CrewKind, ID: "deep"}) + keys := func(e Expansion) []string { + var out []string + for _, m := range e.Refs { + out = append(out, m.ID) + } + return out + } + e, err := s.Expand(ctx, root.ID, 3) + if err != nil { + t.Fatal(err) + } + if got := keys(e); !reflect.DeepEqual(got, []string{"shared", "1", "/a", "deep"}) || e.Collections != 3 || e.More { + t.Fatalf("depth 3: %v, %d collections, more %v", got, e.Collections, e.More) + } + e, _ = s.Expand(ctx, root.ID, 1) + if got := keys(e); !reflect.DeepEqual(got, []string{"shared"}) || e.Collections != 0 { + t.Fatalf("depth 1: %v %d", got, e.Collections) + } + e, _ = s.Expand(ctx, root.ID, 2) + if got := keys(e); !reflect.DeepEqual(got, []string{"shared", "1", "/a"}) || e.Collections != 2 { + t.Fatalf("depth 2: %v %d", got, e.Collections) + } + if _, err := s.Expand(ctx, "missing", 3); !errors.Is(err, ErrNotFound) { + t.Fatalf("missing: %v", err) + } +} + +// A TREE IS BOUNDED: limit per level with more said, depth honoured, and the +// node cap ends it whatever the file holds. +func TestTreeHonoursLimitDepthAndTheNodeCap(t *testing.T) { + ctx := context.Background() + s := openTestStore(t, filepath.Join(t.TempDir(), "tree.db")) + root, mid, leaf := createTestCollection(t, s, "Root"), createTestCollection(t, s, "Mid"), createTestCollection(t, s, "Leaf") + if err := s.Add(ctx, root.ID, Ref{Kind: CollectionKind, ID: mid.ID}); err != nil { + t.Fatal(err) + } + if err := s.Add(ctx, mid.ID, Ref{Kind: CollectionKind, ID: leaf.ID}); err != nil { + t.Fatal(err) + } + if err := s.Add(ctx, leaf.ID, Ref{Kind: ConversationKind, ID: "x"}); err != nil { + t.Fatal(err) + } + many := make([]Addition, 0, 1700) + for i := 0; i < 1700; i++ { + many = append(many, Addition{Ref: Ref{Kind: ConversationKind, ID: fmt.Sprintf("chat-%04d", i)}, AddedAt: int64(i + 1)}) + } + if _, _, err := s.AddRefs(ctx, mid.ID, many); err != nil { + t.Fatal(err) + } + tree, err := s.Tree(ctx, root.ID, 0, 500) + if err != nil || len(tree.Members) != 1 || tree.Members[0].Children != nil || tree.Members[0].Count != 1701 || tree.Members[0].Name != "Mid" { + t.Fatalf("depth 0 %+v, %v", tree.Members, err) + } + tree, _ = s.Tree(ctx, root.ID, 1, 2) + kids := tree.Members[0].Children + if kids == nil || len(kids.Members) != 2 || !kids.More || kids.Members[0].ID != leaf.ID || kids.Members[1].ID != "chat-1699" { + t.Fatalf("depth 1 limit 2 %+v", kids) + } + tree, _ = s.Tree(ctx, root.ID, 2, 500) + total := 0 + var count func(l *Level) + count = func(l *Level) { + for _, n := range l.Members { + total++ + if n.Children != nil { + count(n.Children) + } + } + } + count(&tree.Level) + if total > TreeNodeMax { + t.Fatalf("%d nodes, past the cap", total) + } + // A cycle written by hand (no door writes one) is not followed. + if _, err := s.db.Exec("INSERT INTO memberships(collection_id,kind,ref_id,session_id,target_collection) VALUES (?,?,?,?,?)", leaf.ID, "collection", root.ID, "", root.ID); err != nil { + t.Fatal(err) + } + if _, err := s.Tree(ctx, root.ID, 2, 1); err != nil { + t.Fatal(err) + } + if e, err := s.Expand(ctx, root.ID, 3); err != nil || e.Collections != 2 { + t.Fatalf("expand through a cycle %+v, %v", e, err) + } +} + +// A process carries its conversation like a task; a crew carries nothing. +func TestProcessAndCrewReferencesValidate(t *testing.T) { + for _, tc := range []struct { + ref Ref + ok bool + }{ + {Ref{Kind: ProcessKind, ID: "chat:p1.123", SessionID: "chat"}, true}, + {Ref{Kind: ProcessKind, ID: "chat:p1"}, false}, + {Ref{Kind: CrewKind, ID: "crew-1"}, true}, + {Ref{Kind: CrewKind, ID: "crew-1", SessionID: "chat"}, false}, + } { + if err := tc.ref.Validate(); (err == nil) != tc.ok { + t.Errorf("%+v: %v", tc.ref, err) + } + } +} diff --git a/internal/workspace/store.go b/internal/workspace/store.go index 92fb4aba0c..e631292269 100644 --- a/internal/workspace/store.go +++ b/internal/workspace/store.go @@ -19,7 +19,15 @@ import ( sqlite3 "modernc.org/sqlite/lib" ) -const schemaVersion = 1 +// schemaVersion is the schema this build writes. Schema 2 added when a +// collection was made and when each membership was added, the crew and +// process kinds, and deletes that cascade. legacySchemaVersion is the one +// older schema this build still reads as it is and migrates on its first +// write; anything else claiming to be ours is refused, never misread. +const ( + schemaVersion = 2 + legacySchemaVersion = 1 +) const applicationID = 0x4146434c // AFCL distinguishes this store from optional memory. // LOCK WAITS ARE BOUNDED because every door here is a person's command and a @@ -33,9 +41,21 @@ type schemaState uint8 const ( schemaBlank schemaState = iota schemaReady + schemaLegacy schemaRefused ) +// version is the schema a state reads through: 0 for a blank file. +func (s schemaState) version() int { + switch s { + case schemaReady: + return schemaVersion + case schemaLegacy: + return legacySchemaVersion + } + return 0 +} + // Store owns collection metadata only. Transcripts, task checkpoints, standing // items and artifacts retain their existing owners and persistence formats. // @@ -43,12 +63,16 @@ const ( // is also how `list`, `show` and `find` reach the file, and a read that built a // schema would write to a database nobody asked to change and take the writer // lock to do it — which is what put a plain read behind whichever peer happened -// to be adding a membership. `ready` is that question already answered, and -// `schemaMu` keeps two goroutines on one handle from both answering it. +// to be adding a membership. `version` is that question already answered (0 +// while the file is blank), and `schemaMu` keeps two goroutines on one handle +// from both answering it. The same law covers migration: a schema-1 file is +// read as it stands and rebuilt only by the first write. type Store struct { db *sql.DB schemaMu sync.Mutex - ready bool + version int + // now is the clock created_at and added_at are read from. + now func() time.Time } // Open creates a private collection store or checks an existing one. Unknown @@ -93,7 +117,7 @@ func Open(path string) (*Store, error) { return nil, collectionsOpenFailure(absolute, err) } db.SetMaxOpenConns(1) - s := &Store{db: db} + s := &Store{db: db, now: time.Now} state, err := s.snapshotSchema(context.Background()) if err != nil { _ = db.Close() @@ -105,7 +129,7 @@ func Open(path string) (*Store, error) { } return nil, collectionsOpenFailure(absolute, err) } - s.ready = state == schemaReady + s.version = state.version() return s, nil } @@ -138,8 +162,8 @@ func (s *Store) snapshotSchema(ctx context.Context) (schemaState, error) { } // inspectSchema decides whether the database on the other end of q is ours and -// built, ours and still blank, or somebody else's. It only ever reads; the -// caller decides what view it reads through. +// built (at this schema or the legacy one), ours and still blank, or somebody +// else's. It only ever reads; the caller decides what view it reads through. func inspectSchema(ctx context.Context, q schemaQuerier) (schemaState, error) { if err := ctx.Err(); err != nil { return schemaBlank, err @@ -151,16 +175,24 @@ func inspectSchema(ctx context.Context, q schemaQuerier) (schemaState, error) { if err := q.QueryRowContext(ctx, "PRAGMA user_version").Scan(&version); err != nil { return schemaBlank, err } - if app == applicationID && version == schemaVersion { + if app == applicationID && (version == schemaVersion || version == legacySchemaVersion) { + state := schemaReady + collections := "SELECT seq,id,name,created_at FROM collections LIMIT 0" + memberships := "SELECT seq,collection_id,kind,ref_id,session_id,target_collection,added_at FROM memberships LIMIT 0" + if version == legacySchemaVersion { + state = schemaLegacy + collections = "SELECT seq,id,name FROM collections LIMIT 0" + memberships = "SELECT seq,collection_id,kind,ref_id,session_id,target_collection FROM memberships LIMIT 0" + } // Preparing the actual reads catches missing tables before reporting a // successful open. We never recreate a damaged initialized schema. - if _, err := q.ExecContext(ctx, "SELECT seq,id,name FROM collections LIMIT 0"); err != nil { - return schemaReady, err + if _, err := q.ExecContext(ctx, collections); err != nil { + return state, err } - if _, err := q.ExecContext(ctx, "SELECT seq,collection_id,kind,ref_id,session_id,target_collection FROM memberships LIMIT 0"); err != nil { - return schemaReady, err + if _, err := q.ExecContext(ctx, memberships); err != nil { + return state, err } - return schemaReady, nil + return state, nil } if app != 0 || version != 0 { return schemaRefused, fmt.Errorf("unsupported collections database (application %d, version %d)", app, version) @@ -174,15 +206,73 @@ func inspectSchema(ctx context.Context, q schemaQuerier) (schemaState, error) { return schemaBlank, nil } +// schemaTables is schema 2's two tables under the names given, so the same +// words build a fresh store and the copies a migration fills. +// +// EVERY DELETE CASCADES: a deleted collection takes its own memberships and +// every membership naming it in another collection, and never the records any +// of them referenced. +func schemaTables(collections, memberships string) string { + return fmt.Sprintf(` +CREATE TABLE %[1]s ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE %[2]s ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + collection_id TEXT NOT NULL REFERENCES %[1]s(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK(kind IN ('collection','conversation','task','standing','artifact','crew','process')), + ref_id TEXT NOT NULL, + session_id TEXT NOT NULL, + target_collection TEXT REFERENCES %[1]s(id) ON DELETE CASCADE, + added_at INTEGER NOT NULL DEFAULT 0, + CHECK ((kind='collection' AND target_collection IS NOT NULL AND target_collection=ref_id) + OR (kind!='collection' AND target_collection IS NULL)), + UNIQUE(collection_id,kind,ref_id,session_id) +); +`, collections, memberships) +} + +const schemaIndexes = ` +CREATE INDEX memberships_reference ON memberships(kind,ref_id,session_id); +CREATE INDEX memberships_target ON memberships(target_collection); +` + +// migrateLegacy rebuilds a schema-1 file as schema 2 inside the caller's +// immediate transaction: either every row arrives in the new tables, in the +// same order under the same sequence numbers, or the file is left exactly as +// it was. A row from schema 1 says nothing of when it was made, so its +// created_at and added_at are 0, which every reader takes as unknown. +// +// THE TABLES ARE COPIED, NOT ALTERED, because SQLite cannot change a CHECK or +// a foreign key in place. The old memberships go first so dropping the old +// collections deletes nothing anything still points at, and renaming the copy +// carries the foreign keys that name it along. +func migrateLegacy(ctx context.Context, tx *sql.Tx) error { + _, err := tx.ExecContext(ctx, schemaTables("collections_v2", "memberships_v2")+` +INSERT INTO collections_v2(seq,id,name,created_at) SELECT seq,id,name,0 FROM collections ORDER BY seq; +INSERT INTO memberships_v2(seq,collection_id,kind,ref_id,session_id,target_collection,added_at) + SELECT seq,collection_id,kind,ref_id,session_id,target_collection,0 FROM memberships ORDER BY seq; +DROP TABLE memberships; +DROP TABLE collections; +ALTER TABLE collections_v2 RENAME TO collections; +ALTER TABLE memberships_v2 RENAME TO memberships; +`+schemaIndexes) + return err +} + // ensureSchema serializes initialization on one handle and then takes SQLite's -// immediate transaction so separate processes also agree which one builds it. +// immediate transaction so separate processes also agree which one builds it, +// or which one migrates a schema-1 file to this one. func (s *Store) ensureSchema(ctx context.Context) error { if err := ctx.Err(); err != nil { return storeError(err) } s.schemaMu.Lock() defer s.schemaMu.Unlock() - if s.ready { + if s.version == schemaVersion { return nil } tx, err := s.db.BeginTx(ctx, nil) @@ -194,32 +284,18 @@ func (s *Store) ensureSchema(ctx context.Context) error { if err != nil { return storeError(err) } - if state == schemaReady { + switch state { + case schemaReady: if err := tx.Commit(); err != nil { return storeError(err) } - s.ready = true + s.version = schemaVersion return nil + case schemaLegacy: + err = migrateLegacy(ctx, tx) + default: + _, err = tx.ExecContext(ctx, schemaTables("collections", "memberships")+schemaIndexes) } - _, err = tx.ExecContext(ctx, ` -CREATE TABLE collections ( - seq INTEGER PRIMARY KEY AUTOINCREMENT, - id TEXT NOT NULL UNIQUE, - name TEXT NOT NULL -); -CREATE TABLE memberships ( - seq INTEGER PRIMARY KEY AUTOINCREMENT, - collection_id TEXT NOT NULL REFERENCES collections(id), - kind TEXT NOT NULL CHECK(kind IN ('collection','conversation','task','standing','artifact')), - ref_id TEXT NOT NULL, - session_id TEXT NOT NULL, - target_collection TEXT REFERENCES collections(id), - CHECK ((kind='collection' AND target_collection IS NOT NULL AND target_collection=ref_id) - OR (kind!='collection' AND target_collection IS NULL)), - UNIQUE(collection_id,kind,ref_id,session_id) -); -CREATE INDEX memberships_reference ON memberships(kind,ref_id,session_id); -`) if err != nil { return storeError(err) } @@ -229,47 +305,121 @@ CREATE INDEX memberships_reference ON memberships(kind,ref_id,session_id); if err := tx.Commit(); err != nil { return storeError(err) } - s.ready = true + s.version = schemaVersion return nil } -// readyForRead refreshes a handle that first saw a blank file, because another -// process may have initialized it since. The refresh is still only plain reads. -func (s *Store) readyForRead(ctx context.Context) (bool, error) { +// readyForRead answers the schema a read goes through, and 0 for a blank +// file. A handle that saw a blank or a schema-1 file looks again, because +// another process may have initialized or migrated it since. The look is +// still only plain reads. +func (s *Store) readyForRead(ctx context.Context) (int, error) { if err := ctx.Err(); err != nil { - return false, storeError(err) + return 0, storeError(err) } s.schemaMu.Lock() defer s.schemaMu.Unlock() - if s.ready { - return true, nil + if s.version == schemaVersion { + return s.version, nil } state, err := s.snapshotSchema(ctx) if err != nil { - return false, storeError(err) + return 0, storeError(err) } - s.ready = state == schemaReady - return s.ready, nil + s.version = state.version() + return s.version, nil +} + +// readyForWrite is readyForRead for a change to a collection that must +// already exist: false on a blank file, which such a change cannot find +// anything in and must leave blank, and a schema-1 file migrated first. +func (s *Store) readyForWrite(ctx context.Context) (bool, error) { + version, err := s.readyForRead(ctx) + if err != nil || version == 0 { + return false, err + } + if version != schemaVersion { + if err := s.ensureSchema(ctx); err != nil { + return false, err + } + } + return true, nil +} + +// columns are the expressions a read uses for created_at and added_at: the +// columns themselves at schema 2, and the unknown 0 on a schema-1 file. +func columns(version int) (created, added string) { + if version >= schemaVersion { + return "c.created_at", "m.added_at" + } + return "0", "0" } func (s *Store) Close() error { return s.db.Close() } +// millis is the store's clock in unix milliseconds. +func (s *Store) millis() int64 { return s.now().UnixMilli() } + +// newID is a fresh collection id. +func newID() (string, error) { + var random [16]byte + if _, err := rand.Read(random[:]); err != nil { + return "", err + } + return hex.EncodeToString(random[:]), nil +} + +// nameTaken refuses a name another collection already has, compared without +// regard to case, inside the writer transaction so two writers cannot both +// claim one. except is the collection being renamed, which may take its own +// name in another case. +// +// THE COMPARISON IS UNICODE CASE FOLDING IN GO, not SQLite's NOCASE, which +// folds only ASCII: "Ärger" and "ärger" are one name to a person. +func nameTaken(ctx context.Context, tx *sql.Tx, name, except string) error { + rows, err := tx.QueryContext(ctx, "SELECT id,name FROM collections") + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var id, other string + if err := rows.Scan(&id, &other); err != nil { + return err + } + if id != except && strings.EqualFold(other, name) { + return ErrNameTaken + } + } + return rows.Err() +} + func (s *Store) Create(ctx context.Context, name string) (Collection, error) { if err := ValidateName(name); err != nil { return Collection{}, storeError(err) } - var random [16]byte - if _, err := rand.Read(random[:]); err != nil { + id, err := newID() + if err != nil { return Collection{}, storeError(err) } if err := s.ensureSchema(ctx); err != nil { return Collection{}, storeError(err) } - c := Collection{ID: hex.EncodeToString(random[:]), Name: name} - _, err := s.db.ExecContext(ctx, "INSERT INTO collections(id,name) VALUES (?,?)", c.ID, c.Name) + tx, err := s.db.BeginTx(ctx, nil) if err != nil { return Collection{}, storeError(err) } + defer tx.Rollback() + if err := nameTaken(ctx, tx, name, ""); err != nil { + return Collection{}, storeError(err) + } + c := Collection{ID: id, Name: name} + if _, err := tx.ExecContext(ctx, "INSERT INTO collections(id,name,created_at) VALUES (?,?,?)", c.ID, c.Name, s.millis()); err != nil { + return Collection{}, storeError(err) + } + if err := tx.Commit(); err != nil { + return Collection{}, storeError(err) + } return c, nil } @@ -277,33 +427,124 @@ func (s *Store) Rename(ctx context.Context, id, name string) error { if err := ValidateName(name); err != nil { return storeError(err) } - ready, err := s.readyForRead(ctx) + ready, err := s.readyForWrite(ctx) if err != nil { return storeError(err) } if !ready { return storeError(ErrNotFound) } - result, err := s.db.ExecContext(ctx, "UPDATE collections SET name=? WHERE id=?", name, id) + tx, err := s.db.BeginTx(ctx, nil) if err != nil { return storeError(err) } - n, err := result.RowsAffected() - if err != nil { + defer tx.Rollback() + if err := requireCollection(ctx, tx, id); err != nil { return storeError(err) } - if n == 0 { - return storeError(ErrNotFound) + if err := nameTaken(ctx, tx, name, id); err != nil { + return storeError(err) } - return nil + if _, err := tx.ExecContext(ctx, "UPDATE collections SET name=? WHERE id=?", name, id); err != nil { + return storeError(err) + } + return storeError(tx.Commit()) } -func (s *Store) Collections(ctx context.Context) ([]Collection, error) { - ready, err := s.readyForRead(ctx) +// Delete removes a collection, its memberships, and every membership naming +// it in another collection, and answers those other collections, oldest +// first, because each of them changed too. The records any of it referenced +// are untouched. +func (s *Store) Delete(ctx context.Context, id string) ([]string, error) { + ready, err := s.readyForWrite(ctx) if err != nil { return nil, storeError(err) } if !ready { + return nil, storeError(ErrNotFound) + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return nil, storeError(err) + } + defer tx.Rollback() + if err := requireCollection(ctx, tx, id); err != nil { + return nil, storeError(err) + } + rows, err := tx.QueryContext(ctx, `SELECT c.id FROM collections c WHERE c.id!=? AND EXISTS( + SELECT 1 FROM memberships m WHERE m.collection_id=c.id AND m.kind='collection' AND m.ref_id=?) ORDER BY c.seq`, id, id) + if err != nil { + return nil, storeError(err) + } + parents := make([]string, 0) + for rows.Next() { + var parent string + if err := rows.Scan(&parent); err != nil { + rows.Close() + return nil, storeError(err) + } + parents = append(parents, parent) + } + if err := rows.Close(); err != nil { + return nil, storeError(err) + } + if _, err := tx.ExecContext(ctx, "DELETE FROM collections WHERE id=?", id); err != nil { + return nil, storeError(err) + } + if err := tx.Commit(); err != nil { + return nil, storeError(err) + } + return parents, nil +} + +// Duplicate makes a new collection named name holding the same references as +// id, each with the same added_at and in the same order. A copy holds what +// its source holds and nothing holds the copy, so it can make no cycle. +func (s *Store) Duplicate(ctx context.Context, id, name string) (Collection, error) { + if err := ValidateName(name); err != nil { + return Collection{}, storeError(err) + } + ready, err := s.readyForWrite(ctx) + if err != nil { + return Collection{}, storeError(err) + } + if !ready { + return Collection{}, storeError(ErrNotFound) + } + copyID, err := newID() + if err != nil { + return Collection{}, storeError(err) + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return Collection{}, storeError(err) + } + defer tx.Rollback() + if err := requireCollection(ctx, tx, id); err != nil { + return Collection{}, storeError(err) + } + if err := nameTaken(ctx, tx, name, ""); err != nil { + return Collection{}, storeError(err) + } + if _, err := tx.ExecContext(ctx, "INSERT INTO collections(id,name,created_at) VALUES (?,?,?)", copyID, name, s.millis()); err != nil { + return Collection{}, storeError(err) + } + if _, err := tx.ExecContext(ctx, `INSERT INTO memberships(collection_id,kind,ref_id,session_id,target_collection,added_at) + SELECT ?,kind,ref_id,session_id,target_collection,added_at FROM memberships WHERE collection_id=? ORDER BY seq`, copyID, id); err != nil { + return Collection{}, storeError(err) + } + if err := tx.Commit(); err != nil { + return Collection{}, storeError(err) + } + return Collection{ID: copyID, Name: name}, nil +} + +func (s *Store) Collections(ctx context.Context) ([]Collection, error) { + version, err := s.readyForRead(ctx) + if err != nil { + return nil, storeError(err) + } + if version == 0 { return make([]Collection, 0), nil } rows, err := s.db.QueryContext(ctx, "SELECT id,name FROM collections ORDER BY seq") @@ -336,85 +577,152 @@ func requireCollection(ctx context.Context, tx *sql.Tx, id string) error { return err } +// Addition is a reference to add and, when AddedAt is above zero, the moment +// it was first added, which puts a removed reference back where it was. +type Addition struct { + Ref + AddedAt int64 +} + +// Add files one reference. Adding one already there changes nothing. func (s *Store) Add(ctx context.Context, id string, ref Ref) error { - if err := ref.Validate(); err != nil { - return storeError(err) + _, _, err := s.AddRefs(ctx, id, []Addition{{Ref: ref}}) + return err +} + +// AddRefs files every reference in one writer transaction, all or nothing, +// and answers what it added and what was already there. A collection +// reference that would make a cycle refuses the whole call with ErrCycle; one +// naming a collection that does not exist refuses it with ErrNotFound. +func (s *Store) AddRefs(ctx context.Context, id string, refs []Addition) ([]Member, []Ref, error) { + for _, ref := range refs { + if err := ref.Validate(); err != nil { + return nil, nil, storeError(err) + } + if ref.AddedAt < 0 { + return nil, nil, storeError(fmt.Errorf("%w: added_at cannot be negative", ErrInvalid)) + } } - ready, err := s.readyForRead(ctx) + ready, err := s.readyForWrite(ctx) if err != nil { - return storeError(err) + return nil, nil, storeError(err) } if !ready { - return storeError(ErrNotFound) + return nil, nil, storeError(ErrNotFound) } tx, err := s.db.BeginTx(ctx, nil) if err != nil { - return storeError(err) + return nil, nil, storeError(err) } defer tx.Rollback() if err := requireCollection(ctx, tx, id); err != nil { - return storeError(err) - } - var target any - if ref.Kind == CollectionKind { - if err := requireCollection(ctx, tx, ref.ID); err != nil { - return storeError(err) - } - // THE CYCLE CHECK AND INSERT SHARE THE WRITER TRANSACTION. Two - // processes cannot both approve opposite edges against an old snapshot. - var cycle bool - err := tx.QueryRowContext(ctx, `WITH RECURSIVE descendants(id) AS ( + return nil, nil, storeError(err) + } + now := s.millis() + added, already := make([]Member, 0, len(refs)), make([]Ref, 0) + for _, ref := range refs { + var target any + if ref.Kind == CollectionKind { + if err := requireCollection(ctx, tx, ref.ID); err != nil { + return nil, nil, storeError(err) + } + // THE CYCLE CHECK AND INSERT SHARE THE WRITER TRANSACTION. Two + // processes cannot both approve opposite edges against an old + // snapshot, and an earlier reference of this same call is + // already in the graph the next one is checked against. + var cycle bool + err := tx.QueryRowContext(ctx, `WITH RECURSIVE descendants(id) AS ( VALUES (?) UNION SELECT m.ref_id FROM memberships m JOIN descendants d ON m.collection_id=d.id WHERE m.kind='collection' ) SELECT EXISTS(SELECT 1 FROM descendants WHERE id=?)`, ref.ID, id).Scan(&cycle) + if err != nil { + return nil, nil, storeError(err) + } + if cycle { + return nil, nil, storeError(ErrCycle) + } + target = ref.ID + } + at := ref.AddedAt + if at == 0 { + at = now + } + result, err := tx.ExecContext(ctx, `INSERT INTO memberships(collection_id,kind,ref_id,session_id,target_collection,added_at) + VALUES (?,?,?,?,?,?) ON CONFLICT(collection_id,kind,ref_id,session_id) DO NOTHING`, id, ref.Kind, ref.ID, ref.SessionID, target, at) if err != nil { - return storeError(err) + return nil, nil, storeError(err) + } + n, err := result.RowsAffected() + if err != nil { + return nil, nil, storeError(err) } - if cycle { - return storeError(ErrCycle) + if n == 0 { + already = append(already, ref.Ref) + } else { + added = append(added, Member{Ref: ref.Ref, AddedAt: at}) } - target = ref.ID } - _, err = tx.ExecContext(ctx, `INSERT INTO memberships(collection_id,kind,ref_id,session_id,target_collection) - VALUES (?,?,?,?,?) ON CONFLICT(collection_id,kind,ref_id,session_id) DO NOTHING`, id, ref.Kind, ref.ID, ref.SessionID, target) - if err != nil { - return storeError(err) + if err := tx.Commit(); err != nil { + return nil, nil, storeError(err) } - return storeError(tx.Commit()) + return added, already, nil } // Remove detaches a reference; it never removes or stops the referenced work. func (s *Store) Remove(ctx context.Context, id string, ref Ref) error { - if err := ref.Validate(); err != nil { - return storeError(err) + _, err := s.RemoveRefs(ctx, id, []Ref{ref}) + return err +} + +// RemoveRefs detaches every reference in one writer transaction and answers +// what was there with when it was added, so the removal can be undone in +// place. A reference that was not there is not an error and is not answered. +func (s *Store) RemoveRefs(ctx context.Context, id string, refs []Ref) ([]Member, error) { + for _, ref := range refs { + if err := ref.Validate(); err != nil { + return nil, storeError(err) + } } - ready, err := s.readyForRead(ctx) + ready, err := s.readyForWrite(ctx) if err != nil { - return storeError(err) + return nil, storeError(err) } if !ready { - return storeError(ErrNotFound) + return nil, storeError(ErrNotFound) } tx, err := s.db.BeginTx(ctx, nil) if err != nil { - return storeError(err) + return nil, storeError(err) } defer tx.Rollback() if err := requireCollection(ctx, tx, id); err != nil { - return storeError(err) + return nil, storeError(err) } - if _, err := tx.ExecContext(ctx, "DELETE FROM memberships WHERE collection_id=? AND kind=? AND ref_id=? AND session_id=?", id, ref.Kind, ref.ID, ref.SessionID); err != nil { - return storeError(err) + removed := make([]Member, 0, len(refs)) + for _, ref := range refs { + var at int64 + err := tx.QueryRowContext(ctx, "DELETE FROM memberships WHERE collection_id=? AND kind=? AND ref_id=? AND session_id=? RETURNING added_at", + id, ref.Kind, ref.ID, ref.SessionID).Scan(&at) + if errors.Is(err, sql.ErrNoRows) { + continue + } + if err != nil { + return nil, storeError(err) + } + removed = append(removed, Member{Ref: ref, AddedAt: at}) } - return storeError(tx.Commit()) + if err := tx.Commit(); err != nil { + return nil, storeError(err) + } + return removed, nil } func (s *Store) Members(ctx context.Context, id string) ([]Ref, error) { - ready, err := s.readyForRead(ctx) + version, err := s.readyForRead(ctx) if err != nil { return nil, storeError(err) } - if !ready { + if version == 0 { return nil, storeError(ErrNotFound) } var exists bool @@ -444,11 +752,11 @@ func (s *Store) CollectionsFor(ctx context.Context, ref Ref) ([]Collection, erro if err := ref.Validate(); err != nil { return nil, storeError(err) } - ready, err := s.readyForRead(ctx) + version, err := s.readyForRead(ctx) if err != nil { return nil, storeError(err) } - if !ready { + if version == 0 { return make([]Collection, 0), nil } rows, err := s.db.QueryContext(ctx, `SELECT c.id,c.name FROM collections c JOIN memberships m ON m.collection_id=c.id diff --git a/internal/workspace/views.go b/internal/workspace/views.go new file mode 100644 index 0000000000..50dce3d326 --- /dev/null +++ b/internal/workspace/views.go @@ -0,0 +1,335 @@ +package workspace + +import ( + "context" + "database/sql" + "fmt" +) + +// The bounds of the reads below. Each read is bounded by itself, whatever the +// caller asks for, because a collection can hold collections and a person can +// file thousands of references: nothing here walks or answers without limit. +const ( + // ListMax is the most collections one listing answers. + ListMax = 1000 + // LevelMax is the most members one level of a tree answers. + LevelMax = 500 + // TreeDepthMax is how many levels below the top a tree opens. + TreeDepthMax = 2 + // TreeNodeMax is the most nodes one tree answers in all. + TreeNodeMax = 1500 + // ExpandDepthMax is how many levels an expansion reads. + ExpandDepthMax = 3 + // ExpandMax is the most references one expansion answers, before any + // owner is asked whether they are still there. + ExpandMax = 2000 +) + +// Summary is a collection with what a listing says of it: when it was made +// (0 unknown), how many direct members it has, when the newest was added (0 +// none or unknown), and, when the listing was asked about one reference, +// whether it directly holds it. +type Summary struct { + Collection + CreatedAt int64 + Count int + LastAddedAt int64 + Holds *bool +} + +// List answers every collection, oldest first, at most ListMax. With holds it +// also says whether each directly holds that reference. A blank file is no +// collections, and reading it leaves it blank. +func (s *Store) List(ctx context.Context, holds *Ref) ([]Summary, error) { + if holds != nil { + if err := holds.Validate(); err != nil { + return nil, storeError(err) + } + } + version, err := s.readyForRead(ctx) + if err != nil { + return nil, storeError(err) + } + if version == 0 { + return make([]Summary, 0), nil + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return nil, storeError(err) + } + defer tx.Rollback() + out, err := summaries(ctx, tx, version, holds, "", ListMax) + return out, storeError(err) +} + +// Describe is one collection as List says it. +func (s *Store) Describe(ctx context.Context, id string) (Summary, error) { + version, err := s.readyForRead(ctx) + if err != nil { + return Summary{}, storeError(err) + } + if version == 0 { + return Summary{}, storeError(ErrNotFound) + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return Summary{}, storeError(err) + } + defer tx.Rollback() + out, err := summaries(ctx, tx, version, nil, id, 1) + if err != nil { + return Summary{}, storeError(err) + } + if len(out) == 0 { + return Summary{}, storeError(ErrNotFound) + } + return out[0], nil +} + +// summaries reads collections as Summary through one view: every one, or the +// one named by only. +func summaries(ctx context.Context, tx *sql.Tx, version int, holds *Ref, only string, limit int) ([]Summary, error) { + created, added := columns(version) + held, where := "0", "" + args := []any{} + if holds != nil { + held = "EXISTS(SELECT 1 FROM memberships h WHERE h.collection_id=c.id AND h.kind=? AND h.ref_id=? AND h.session_id=?)" + args = append(args, holds.Kind, holds.ID, holds.SessionID) + } + if only != "" { + where = "WHERE c.id=?" + args = append(args, only) + } + args = append(args, limit) + rows, err := tx.QueryContext(ctx, fmt.Sprintf(`SELECT c.id,c.name,%s, + (SELECT count(*) FROM memberships m WHERE m.collection_id=c.id), + (SELECT coalesce(max(%s),0) FROM memberships m WHERE m.collection_id=c.id), + %s +FROM collections c %s ORDER BY c.seq LIMIT ?`, created, added, held, where), args...) + if err != nil { + return nil, err + } + defer rows.Close() + out := make([]Summary, 0) + for rows.Next() { + var c Summary + var h bool + if err := rows.Scan(&c.ID, &c.Name, &c.CreatedAt, &c.Count, &c.LastAddedAt, &h); err != nil { + return nil, err + } + if holds != nil { + c.Holds = &h + } + out = append(out, c) + } + return out, rows.Err() +} + +// Node is one member of a tree. For a collection reference, Found says the +// collection is still there, Name is its name and Count its direct members; +// Children are its own members when the tree opened it. +type Node struct { + Member + Name string + Count int + Found bool + Children *Level +} + +// Level is one collection's members as a tree answers them, newest added +// first, and whether there were more than it answered. +type Level struct { + Members []Node + More bool +} + +// Tree is a collection and its members, opened to a depth. +type Tree struct { + Collection Summary + Level +} + +// Tree opens a collection: its members newest added first, at most limit per +// level, and the members of each collection member down to depth levels below +// the top (0 opens none), at most TreeNodeMax nodes in all. +// +// A COLLECTION ALREADY ON THE PATH IS NEVER OPENED AGAIN. Writes refuse a +// cycle, so none should exist, but a tree is a read that must end whatever +// the file says, so it carries its own path rather than trusting the writer. +func (s *Store) Tree(ctx context.Context, id string, depth, limit int) (Tree, error) { + depth = max(0, min(depth, TreeDepthMax)) + if limit <= 0 || limit > LevelMax { + limit = LevelMax + } + version, err := s.readyForRead(ctx) + if err != nil { + return Tree{}, storeError(err) + } + if version == 0 { + return Tree{}, storeError(ErrNotFound) + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return Tree{}, storeError(err) + } + defer tx.Rollback() + top, err := summaries(ctx, tx, version, nil, id, 1) + if err != nil { + return Tree{}, storeError(err) + } + if len(top) == 0 { + return Tree{}, storeError(ErrNotFound) + } + budget := TreeNodeMax + path := map[string]bool{id: true} + var open func(id string, level int) (*Level, error) + open = func(id string, level int) (*Level, error) { + nodes, more, err := levelOf(ctx, tx, version, id, min(limit, budget)) + if err != nil { + return nil, err + } + budget -= len(nodes) + out := &Level{Members: nodes, More: more} + if level > depth { + return out, nil + } + for i := range out.Members { + n := &out.Members[i] + if n.Kind != CollectionKind || !n.Found || path[n.ID] || budget <= 0 { + continue + } + path[n.ID] = true + children, err := open(n.ID, level+1) + delete(path, n.ID) + if err != nil { + return nil, err + } + n.Children = children + } + return out, nil + } + level, err := open(id, 1) + if err != nil { + return Tree{}, storeError(err) + } + return Tree{Collection: top[0], Level: *level}, nil +} + +// levelOf reads one collection's members, newest added first, at most limit, +// with the name and count of each collection member, and whether there were +// more. A row with no known added_at sorts after every known one. +func levelOf(ctx context.Context, tx *sql.Tx, version int, id string, limit int) ([]Node, bool, error) { + nodes := make([]Node, 0) + if limit <= 0 { + var held bool + err := tx.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM memberships WHERE collection_id=?)", id).Scan(&held) + return nodes, held, err + } + _, added := columns(version) + // A schema-1 file knows no moments, so its order is insertion alone; a + // constant in ORDER BY would be read as a column number. + order := "m.seq DESC" + if version >= schemaVersion { + order = "m.added_at DESC, m.seq DESC" + } + rows, err := tx.QueryContext(ctx, fmt.Sprintf(`SELECT m.kind,m.ref_id,m.session_id,%s,coalesce(c.name,''),c.id IS NOT NULL, + CASE WHEN m.kind='collection' THEN (SELECT count(*) FROM memberships x WHERE x.collection_id=m.ref_id) ELSE 0 END +FROM memberships m LEFT JOIN collections c ON m.kind='collection' AND c.id=m.ref_id +WHERE m.collection_id=? ORDER BY %s LIMIT ?`, added, order), id, limit+1) + if err != nil { + return nil, false, err + } + defer rows.Close() + more := false + for rows.Next() { + if len(nodes) == limit { + more = true + break + } + var n Node + if err := rows.Scan(&n.Kind, &n.ID, &n.SessionID, &n.AddedAt, &n.Name, &n.Found, &n.Count); err != nil { + return nil, false, err + } + nodes = append(nodes, n) + } + return nodes, more, rows.Err() +} + +// Expansion is every reference a collection holds through its nested +// collections, each once, outer levels first; Collections is how many nested +// collections were read, and More says ExpandMax cut it short. +type Expansion struct { + Collection Summary + Refs []Member + Collections int + More bool +} + +// Expand flattens a collection to depth levels (1 reads only its own +// members): every reference that is not a collection, each once at its +// outermost place, newest added first within a collection. Each nested +// collection is read once however many collections hold it. +func (s *Store) Expand(ctx context.Context, id string, depth int) (Expansion, error) { + depth = max(1, min(depth, ExpandDepthMax)) + version, err := s.readyForRead(ctx) + if err != nil { + return Expansion{}, storeError(err) + } + if version == 0 { + return Expansion{}, storeError(ErrNotFound) + } + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return Expansion{}, storeError(err) + } + defer tx.Rollback() + top, err := summaries(ctx, tx, version, nil, id, 1) + if err != nil { + return Expansion{}, storeError(err) + } + if len(top) == 0 { + return Expansion{}, storeError(ErrNotFound) + } + out := Expansion{Collection: top[0], Refs: make([]Member, 0)} + seen := map[string]bool{} + read := map[string]bool{id: true} + frontier := []string{id} + for level := 1; level <= depth && len(frontier) > 0 && !out.More; level++ { + var next []string + for _, cid := range frontier { + if cid != id { + out.Collections++ + } + nodes, more, err := levelOf(ctx, tx, version, cid, ExpandMax+1) + if err != nil { + return Expansion{}, storeError(err) + } + if more { + out.More = true + } + for _, n := range nodes { + if n.Kind == CollectionKind { + if n.Found && !read[n.ID] && level < depth { + read[n.ID] = true + next = append(next, n.ID) + } + continue + } + if seen[n.key()] { + continue + } + if len(out.Refs) == ExpandMax { + out.More = true + break + } + seen[n.key()] = true + out.Refs = append(out.Refs, n.Member) + } + if out.More { + break + } + } + frontier = next + } + return out, nil +} diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index 82934500dc..ab4a7e9319 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -19,12 +19,17 @@ const ( ConversationKind Kind = "conversation" TaskKind Kind = "task" ArtifactKind Kind = "artifact" + // CrewKind names a crew by its id; ProcessKind names a process a + // conversation started, by its id, and like a task carries that + // conversation as its session. Both arrived with schema 2. + CrewKind Kind = "crew" + ProcessKind Kind = "process" // StandingKind is an older build's reference to a standing item. Standing // items are gone (automations replaced them), so the collections command // refuses a new one, but a row that already names one is still a row: it // can be found and taken out, and it resolves to nothing. The kind stays in - // the schema's own list for the same reason, and because a build that - // changed that list would be refused by every older one on the machine. + // the schema's own list for the same reason: schema 2 grew that list and + // kept every word schema 1 had, so no migrated row is refused by it. StandingKind Kind = "standing" ) @@ -32,6 +37,10 @@ var ( ErrInvalid = errors.New("invalid collection or reference") ErrNotFound = errors.New("collection not found") ErrCycle = errors.New("collection membership would form a cycle") + // ErrNameTaken is a name another collection already has, compared without + // regard to case: two collections a person cannot tell apart by name are + // one too many. + ErrNameTaken = errors.New("another collection already has that name") // ErrBusy is a lock this store waited busyTimeout for and did not get. It is // not corruption and not a refusal: another command is still writing, so // this command left the collections database unchanged and can be retried. @@ -52,17 +61,31 @@ type Collection struct { Name string `json:"name"` } +// Member is a reference as one collection holds it, with when it was added in +// unix milliseconds. Zero is unknown: a row a schema-1 store wrote. +type Member struct { + Ref + AddedAt int64 `json:"added_at,omitempty"` +} + +// key is a reference's identity, the three columns the uniqueness rule reads. +func (r Ref) key() string { return string(r.Kind) + "\x00" + r.ID + "\x00" + r.SessionID } + // Validate checks the reference shape without asking an execution owner to // open it. A temporarily unavailable record must remain discoverable here. func (r Ref) Validate() error { if !validText(r.ID, 4096) { return fmt.Errorf("%w: record id is empty, too long or contains control characters", ErrInvalid) } - if r.Kind != TaskKind && r.SessionID != "" { - return fmt.Errorf("%w: only a task reference has a session id", ErrInvalid) + if r.Kind != TaskKind && r.Kind != ProcessKind && r.SessionID != "" { + return fmt.Errorf("%w: only a task or a process reference has a session id", ErrInvalid) } switch r.Kind { - case CollectionKind, ConversationKind, StandingKind: + case CollectionKind, ConversationKind, StandingKind, CrewKind: + case ProcessKind: + if !validText(r.SessionID, 1024) { + return fmt.Errorf("%w: a process needs the session id of the conversation that started it", ErrInvalid) + } case TaskKind: n, err := strconv.ParseUint(r.ID, 10, 64) if err != nil || n == 0 || strconv.FormatUint(n, 10) != r.ID || !validText(r.SessionID, 1024) { From b5f0213f4f9abc1721ed0a6a0073657c3a4da129 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 19:05:21 -0400 Subject: [PATCH 038/459] codeaf app: serve collections and say what each member is now collections.list/create/rename/duplicate/delete/add/remove/open/expand and collection.changed (PROTOCOL.md wave 9), the hello feature "collections" for an engine that is a Collector, and the code cycle. Members resolve with one listing per owner per call and git facts read from .git without running git; a read never creates the store. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/app.go | 9 + cmd/codeaf/appcollections.go | 709 +++++++++++++++++++ cmd/codeaf/appcollections_test.go | 210 ++++++ docs/changes/unreleased/0-app-collections.md | 16 + internal/appwire/collections.go | 436 ++++++++++++ internal/appwire/collections_test.go | 233 ++++++ internal/appwire/events.go | 2 + internal/appwire/server.go | 1 + internal/appwire/wire.go | 3 + 9 files changed, 1619 insertions(+) create mode 100644 cmd/codeaf/appcollections.go create mode 100644 cmd/codeaf/appcollections_test.go create mode 100644 docs/changes/unreleased/0-app-collections.md create mode 100644 internal/appwire/collections.go create mode 100644 internal/appwire/collections_test.go diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index 3b53347365..936b55093a 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -59,6 +59,7 @@ import ( "github.com/Agent-Field/codeaf/internal/store" teamstore "github.com/Agent-Field/codeaf/internal/teams" "github.com/Agent-Field/codeaf/internal/tui3" + "github.com/Agent-Field/codeaf/internal/workspace" ) // appAgentIdle is how long a conversation the app holds may sit with no turn @@ -366,6 +367,13 @@ type appBackend struct { follows map[string]*appFollow // asleep says the Mac is asleep (power.sleep until power.wake). asleep bool + // collections is the collections store (appcollections.go), opened by + // the first call that finds a file to open and closed with the backend; + // collectionsPath replaces where it is in a test. + collectionsMu sync.Mutex + collections *workspace.Store + collectionsClosed bool + collectionsPath string } // appAgent is every agent this process has heard of: listed off the disk, @@ -2716,6 +2724,7 @@ func (b *appBackend) Close() error { }) } wg.Wait() + b.closeCollections() return nil } diff --git a/cmd/codeaf/appcollections.go b/cmd/codeaf/appcollections.go new file mode 100644 index 0000000000..264626304e --- /dev/null +++ b/cmd/codeaf/appcollections.go @@ -0,0 +1,709 @@ +package main + +// ── codeaf app: COLLECTIONS (wave 9, V75) ─────────────────────────────────── +// +// A collection is a hand-picked list of references, kept in the same store +// `codeaf collections` reads (/v3/collections.db, internal/workspace). +// This file is the app's door into it and nothing more: the store keeps the +// lists, and what each member is NOW is asked of its owner when a collection +// is opened and never written down. +// +// RESOLUTION ASKS EACH OWNER ONCE PER CALL, NEVER ONCE PER MEMBER. A call +// takes one agents listing (every state, closed and archived included), one +// crews listing, one task list and one process list per distinct agent, and +// one look for a git work tree per directory; a collection of a thousand +// files in one repository costs one walk up to its .git. No transcript is +// read to resolve anything, and a member whose owner cannot be asked is +// "unknown", which says nothing about the member itself. + +import ( + "bufio" + "context" + "errors" + "io" + "io/fs" + "os" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/workspace" +) + +// appCollectionsPath is where the collections store is, the CLI's own default. +func (b *appBackend) appCollectionsPath() string { + if b.collectionsPath != "" { + return b.collectionsPath + } + return home.Join("v3", "collections.db") +} + +// collectionStore opens the store once and answers it. With create false a +// missing file answers nil: A READ NEVER CREATES THE DATABASE, the rule the +// CLI keeps, so a fresh home lists no collections and leaves no file behind. +func (b *appBackend) collectionStore(create bool) (*workspace.Store, error) { + b.collectionsMu.Lock() + defer b.collectionsMu.Unlock() + if b.collections != nil { + return b.collections, nil + } + if b.collectionsClosed { + return nil, appwire.Errorf(appwire.CodeClosed, "codeaf is shutting down") + } + path := b.appCollectionsPath() + if !create { + if _, err := os.Stat(path); errors.Is(err, fs.ErrNotExist) { + return nil, nil + } else if err != nil { + return nil, appEngineError(err) + } + } + s, err := workspace.Open(path) + if err != nil { + return nil, appCollectionError(err) + } + b.collections = s + return s, nil +} + +// existingStore is the store for a change to a collection that must already +// exist: a missing file has none, so the answer is not_found. +func (b *appBackend) existingStore() (*workspace.Store, error) { + s, err := b.collectionStore(false) + if err == nil && s == nil { + err = appwire.Errorf(appwire.CodeNotFound, "collection not found") + } + return s, err +} + +// closeCollections closes the store at the backend's end. +func (b *appBackend) closeCollections() { + b.collectionsMu.Lock() + defer b.collectionsMu.Unlock() + b.collectionsClosed = true + if b.collections != nil { + _ = b.collections.Close() + b.collections = nil + } +} + +// appCollectionError is a store refusal in the wire's codes. +func appCollectionError(err error) error { + var wire *appwire.Error + switch { + case err == nil: + return nil + case errors.As(err, &wire): + return wire + case errors.Is(err, workspace.ErrNameTaken): + return appwire.Errorf(appwire.CodeConflict, "%s", workspace.ErrNameTaken.Error()) + case errors.Is(err, workspace.ErrCycle): + return appwire.Errorf(appwire.CodeCycle, "%s", workspace.ErrCycle.Error()) + case errors.Is(err, workspace.ErrNotFound): + return appwire.Errorf(appwire.CodeNotFound, "%s", workspace.ErrNotFound.Error()) + case errors.Is(err, workspace.ErrInvalid): + return appwire.Errorf(appwire.CodeBadParams, "%s", err.Error()) + case errors.Is(err, workspace.ErrBusy): + return appwire.Errorf(appwire.CodeBusy, "%s", err.Error()) + } + return appEngineError(err) +} + +func (b *appBackend) collectionChanged(id string, deleted bool) { + b.emit.Emit(appwire.EventCollectionChanged, appwire.CollectionChanged{ID: id, Deleted: deleted}) +} + +func appStoreRef(r appwire.Ref) workspace.Ref { + return workspace.Ref{Kind: workspace.Kind(r.Kind), ID: r.ID, SessionID: r.SessionID} +} + +func appWireRef(r workspace.Ref) appwire.Ref { + return appwire.Ref{Kind: string(r.Kind), ID: r.ID, SessionID: r.SessionID} +} + +// appMoment is a stored moment on the wire: absent when unknown. +func appMoment(ms int64) *time.Time { + if ms <= 0 { + return nil + } + at := time.UnixMilli(ms).UTC() + return &at +} + +func appWireMember(m workspace.Member) appwire.Member { + return appwire.Member{Ref: appWireRef(m.Ref), AddedAt: appMoment(m.AddedAt)} +} + +func appWireCollection(s workspace.Summary) appwire.Collection { + return appwire.Collection{ID: s.ID, Name: s.Name, CreatedAt: appMoment(s.CreatedAt), Count: s.Count, LastAddedAt: appMoment(s.LastAddedAt), Holds: s.Holds} +} + +// describe is one collection as the wire says it. +func describeCollection(s *workspace.Store, id string) (appwire.Collection, error) { + summary, err := s.Describe(context.Background(), id) + if err != nil { + return appwire.Collection{}, appCollectionError(err) + } + return appWireCollection(summary), nil +} + +// ListCollections is every collection, oldest first. +func (b *appBackend) ListCollections(holds *appwire.Ref) ([]appwire.Collection, error) { + s, err := b.collectionStore(false) + if err != nil || s == nil { + return []appwire.Collection{}, err + } + var ref *workspace.Ref + if holds != nil { + r := appStoreRef(*holds) + ref = &r + } + list, err := s.List(context.Background(), ref) + if err != nil { + return nil, appCollectionError(err) + } + out := make([]appwire.Collection, 0, len(list)) + for _, c := range list { + out = append(out, appWireCollection(c)) + } + return out, nil +} + +// CreateCollection makes a collection, the one call that may create the store. +func (b *appBackend) CreateCollection(name string) (appwire.Collection, error) { + s, err := b.collectionStore(true) + if err != nil { + return appwire.Collection{}, err + } + c, err := s.Create(context.Background(), name) + if err != nil { + return appwire.Collection{}, appCollectionError(err) + } + b.collectionChanged(c.ID, false) + return describeCollection(s, c.ID) +} + +// RenameCollection renames one. +func (b *appBackend) RenameCollection(id, name string) (appwire.Collection, error) { + s, err := b.existingStore() + if err != nil { + return appwire.Collection{}, err + } + if err := s.Rename(context.Background(), id, name); err != nil { + return appwire.Collection{}, appCollectionError(err) + } + b.collectionChanged(id, false) + return describeCollection(s, id) +} + +// DuplicateCollection copies one under a new name. +func (b *appBackend) DuplicateCollection(id, name string) (appwire.Collection, error) { + s, err := b.existingStore() + if err != nil { + return appwire.Collection{}, err + } + c, err := s.Duplicate(context.Background(), id, name) + if err != nil { + return appwire.Collection{}, appCollectionError(err) + } + b.collectionChanged(c.ID, false) + return describeCollection(s, c.ID) +} + +// DeleteCollection deletes one, and says so of every collection that lost a +// membership naming it. +func (b *appBackend) DeleteCollection(id string) error { + s, err := b.existingStore() + if err != nil { + return err + } + parents, err := s.Delete(context.Background(), id) + if err != nil { + return appCollectionError(err) + } + b.collectionChanged(id, true) + for _, parent := range parents { + b.collectionChanged(parent, false) + } + return nil +} + +// AddToCollection files references, all or nothing. +func (b *appBackend) AddToCollection(id string, refs []appwire.Member) (appwire.CollectionAddResult, error) { + s, err := b.existingStore() + if err != nil { + return appwire.CollectionAddResult{}, err + } + additions := make([]workspace.Addition, 0, len(refs)) + for _, r := range refs { + a := workspace.Addition{Ref: appStoreRef(r.Ref)} + if r.AddedAt != nil { + a.AddedAt = r.AddedAt.UnixMilli() + } + additions = append(additions, a) + } + added, already, err := s.AddRefs(context.Background(), id, additions) + if err != nil { + return appwire.CollectionAddResult{}, appCollectionError(err) + } + out := appwire.CollectionAddResult{Added: make([]appwire.Member, 0, len(added)), Already: make([]appwire.Ref, 0, len(already))} + for _, m := range added { + out.Added = append(out.Added, appWireMember(m)) + } + for _, r := range already { + out.Already = append(out.Already, appWireRef(r)) + } + if len(added) > 0 { + b.collectionChanged(id, false) + } + return out, nil +} + +// RemoveFromCollection detaches references and answers what was there. +func (b *appBackend) RemoveFromCollection(id string, refs []appwire.Ref) (appwire.CollectionRemoveResult, error) { + s, err := b.existingStore() + if err != nil { + return appwire.CollectionRemoveResult{}, err + } + want := make([]workspace.Ref, 0, len(refs)) + for _, r := range refs { + want = append(want, appStoreRef(r)) + } + removed, err := s.RemoveRefs(context.Background(), id, want) + if err != nil { + return appwire.CollectionRemoveResult{}, appCollectionError(err) + } + out := appwire.CollectionRemoveResult{Removed: make([]appwire.Member, 0, len(removed))} + for _, m := range removed { + out.Removed = append(out.Removed, appWireMember(m)) + } + if len(removed) > 0 { + b.collectionChanged(id, false) + } + return out, nil +} + +// OpenCollection is a collection's members with what each is now. +func (b *appBackend) OpenCollection(id string, depth, limit int) (appwire.CollectionOpenResult, error) { + s, err := b.existingStore() + if err != nil { + return appwire.CollectionOpenResult{}, err + } + tree, err := s.Tree(context.Background(), id, depth, limit) + if err != nil { + return appwire.CollectionOpenResult{}, appCollectionError(err) + } + r := newCollectionResolver(b) + var nodes func(level workspace.Level) []appwire.Node + nodes = func(level workspace.Level) []appwire.Node { + out := make([]appwire.Node, 0, len(level.Members)) + for _, n := range level.Members { + node := appwire.Node{Member: appWireMember(n.Member)} + if n.Kind == workspace.CollectionKind { + node.Target = collectionTarget(n) + } else { + node.Target = r.target(n.Ref) + } + if n.Children != nil { + node.Children = &appwire.NodeList{Members: nodes(*n.Children), More: n.Children.More} + } + out = append(out, node) + } + return out + } + return appwire.CollectionOpenResult{Collection: appWireCollection(tree.Collection), Members: nodes(tree.Level), More: tree.More}, nil +} + +// collectionTarget is a nested collection as the store found it. +func collectionTarget(n workspace.Node) appwire.Target { + if !n.Found { + return appwire.Target{State: appwire.TargetDeleted} + } + count := n.Count + return appwire.Target{State: appwire.TargetLive, Title: n.Name, Count: &count} +} + +// ExpandCollection is every live reference a collection holds through its +// nested collections, each once, outer levels first. +func (b *appBackend) ExpandCollection(id string, depth, limit int) (appwire.CollectionExpandResult, error) { + s, err := b.existingStore() + if err != nil { + return appwire.CollectionExpandResult{}, err + } + e, err := s.Expand(context.Background(), id, depth) + if err != nil { + return appwire.CollectionExpandResult{}, appCollectionError(err) + } + out := appwire.CollectionExpandResult{Collection: appWireCollection(e.Collection), Refs: []appwire.ExpandedRef{}, Collections: e.Collections, More: e.More} + r := newCollectionResolver(b) + for _, m := range e.Refs { + target := r.target(m.Ref) + switch target.State { + case appwire.TargetDeleted, appwire.TargetMissing: + out.Missing++ + continue + case appwire.TargetUnknown: + continue + } + if len(out.Refs) == limit { + out.More = true + break + } + out.Refs = append(out.Refs, appwire.ExpandedRef{Member: appWireMember(m), Target: target}) + } + return out, nil +} + +// ── resolution ───────────────────────────────────────────────────────────── + +// collectionResolver answers what each member is now, asking each owner at +// most once for the whole call. +type collectionResolver struct { + b *appBackend + + agentsRead bool + agents map[string]appwire.Agent + agentsErr error + + crewsRead bool + crews []appwire.Crew + crewByID map[string]int + crewsErr error + + tasks map[string]appOwned[appwire.Task] + procs map[string]appOwned[appwire.Process] + git map[string]*appGitTree +} + +// appOwned is one agent's tasks or processes by wire id, or why they could +// not be read. +type appOwned[T any] struct { + byID map[string]T + err error +} + +func newCollectionResolver(b *appBackend) *collectionResolver { + return &collectionResolver{b: b, tasks: map[string]appOwned[appwire.Task]{}, procs: map[string]appOwned[appwire.Process]{}, git: map[string]*appGitTree{}} +} + +func (r *collectionResolver) target(ref workspace.Ref) appwire.Target { + switch ref.Kind { + case workspace.ConversationKind: + a, state := r.agent(ref.ID) + if state != "" { + return appwire.Target{State: state} + } + return appwire.Target{State: appwire.TargetLive, Title: a.Title, Status: a.State, In: r.crewPathOf(a.ID)} + case workspace.TaskKind: + a, state := r.agent(ref.SessionID) + if state != "" { + return appwire.Target{State: state} + } + n, err := strconv.ParseUint(ref.ID, 10, 64) + if err != nil { + return appwire.Target{State: appwire.TargetDeleted} + } + task, state := appFindOwned(r.tasks, a.ID, appTaskID(a.ID, n), r.b.ListTasks) + if state != "" { + return appwire.Target{State: state} + } + return appwire.Target{State: appwire.TargetLive, Title: task.Title, Status: task.State, In: append(r.crewPathOf(a.ID), a.Title)} + case workspace.ProcessKind: + a, state := r.agent(ref.SessionID) + if state != "" { + return appwire.Target{State: state} + } + p, state := appFindOwned(r.procs, a.ID, ref.ID, r.b.ListProcesses) + if state != "" { + return appwire.Target{State: state} + } + title := p.Title + if strings.TrimSpace(title) == "" { + title = p.Command + } + return appwire.Target{State: appwire.TargetLive, Title: title, Status: p.State, In: append(r.crewPathOf(a.ID), a.Title)} + case workspace.CrewKind: + if !r.readCrews() { + return appwire.Target{State: appwire.TargetUnknown} + } + i, ok := r.crewByID[ref.ID] + if !ok { + return appwire.Target{State: appwire.TargetDeleted} + } + c := r.crews[i] + count := len(c.Members) + return appwire.Target{State: appwire.TargetLive, Title: c.Name, Status: c.State, Count: &count, In: r.crewPath(c.Parent)} + case workspace.ArtifactKind: + return r.file(ref.ID) + case workspace.CollectionKind: + // A nested collection is found by the store in the same read that + // found it a member; one reaching here was not. + return appwire.Target{State: appwire.TargetUnknown} + } + // A standing item names nothing codeaf runs any more. + return appwire.Target{State: appwire.TargetDeleted} +} + +// agent is one agent off the one listing, or the state a member of it is in +// when there is no such agent ("deleted") or no listing ("unknown"). +func (r *collectionResolver) agent(id string) (appwire.Agent, string) { + if !r.agentsRead { + r.agentsRead = true + list, err := r.b.ListAgents("") + r.agentsErr = err + r.agents = make(map[string]appwire.Agent, len(list)) + for _, a := range list { + r.agents[a.ID] = a + } + } + if r.agentsErr != nil { + return appwire.Agent{}, appwire.TargetUnknown + } + a, ok := r.agents[id] + if !ok { + return appwire.Agent{}, appwire.TargetDeleted + } + return a, "" +} + +// appFindOwned is one task or process of an agent, read with list once per +// agent per call. +func appFindOwned[T any](cache map[string]appOwned[T], agent, id string, list func(string) ([]T, error)) (T, string) { + owned, ok := cache[agent] + if !ok { + items, err := list(agent) + owned = appOwned[T]{byID: make(map[string]T, len(items)), err: err} + for _, item := range items { + owned.byID[appOwnedID(item)] = item + } + cache[agent] = owned + } + var zero T + if owned.err != nil { + var wire *appwire.Error + if errors.As(owned.err, &wire) && wire.Code == appwire.CodeNotFound { + return zero, appwire.TargetDeleted + } + return zero, appwire.TargetUnknown + } + item, ok := owned.byID[id] + if !ok { + return zero, appwire.TargetDeleted + } + return item, "" +} + +func appOwnedID(item any) string { + switch v := item.(type) { + case appwire.Task: + return v.ID + case appwire.Process: + return v.ID + } + return "" +} + +// readCrews takes the one crews listing, open and closed, and says whether +// there is one. +func (r *collectionResolver) readCrews() bool { + if !r.crewsRead { + r.crewsRead = true + open, err := r.b.CrewList(false) + var closed []appwire.Crew + if err == nil { + closed, err = r.b.CrewList(true) + } + r.crewsErr = err + r.crews = append(open, closed...) + r.crewByID = make(map[string]int, len(r.crews)) + for i, c := range r.crews { + if _, dup := r.crewByID[c.ID]; !dup { + r.crewByID[c.ID] = i + } + } + } + return r.crewsErr == nil +} + +// crewPath is the names from the outermost crew down to id, inclusive; empty +// for none. The walk is bounded and never revisits a crew. +func (r *collectionResolver) crewPath(id string) []string { + if id == "" || !r.readCrews() { + return nil + } + var names []string + seen := map[string]bool{} + for id != "" && !seen[id] && len(names) < 32 { + seen[id] = true + i, ok := r.crewByID[id] + if !ok { + break + } + names = append([]string{r.crews[i].Name}, names...) + id = r.crews[i].Parent + } + return names +} + +// crewPathOf is the crew path of the first crew agent is a member of, open +// crews before closed ones; empty when it is in none. +func (r *collectionResolver) crewPathOf(agent string) []string { + if !r.readCrews() { + return nil + } + for _, c := range r.crews { + for _, m := range c.Members { + if m.Agent == agent { + return r.crewPath(c.ID) + } + } + } + return nil +} + +// file is a path as it is now: missing when nothing is there, and inside a +// git work tree its repository, branch and path within it. The file itself is +// never read and a link is never followed. +func (r *collectionResolver) file(path string) appwire.Target { + info, err := os.Lstat(path) + if errors.Is(err, fs.ErrNotExist) || errors.Is(err, syscall.ENOTDIR) { + return appwire.Target{State: appwire.TargetMissing} + } + if err != nil { + return appwire.Target{State: appwire.TargetUnknown} + } + t := appwire.Target{State: appwire.TargetLive, Title: filepath.Base(path)} + dir := filepath.Dir(path) + if info.IsDir() { + dir = path + } + if tree := r.gitTreeOf(dir); tree != nil { + t.Repo, t.Branch = tree.repo, tree.branch + if rel, err := filepath.Rel(tree.root, path); err == nil { + t.Path = filepath.ToSlash(rel) + } + } + return t +} + +// appGitTree is a git work tree: its folder, the origin's owner/name (or the +// folder's name), and the branch checked out (empty when detached). +type appGitTree struct { + root, repo, branch string +} + +// appGitWalkMax is how many folders a look for .git climbs. +const appGitWalkMax = 64 + +// gitTreeOf is the work tree dir is inside, or nil. Every folder the walk +// passes is remembered with the answer, so the next file in the same tree is +// one map look. +func (r *collectionResolver) gitTreeOf(dir string) *appGitTree { + var passed []string + var found *appGitTree + d := filepath.Clean(dir) + for i := 0; i < appGitWalkMax; i++ { + if tree, ok := r.git[d]; ok { + found = tree + break + } + passed = append(passed, d) + dotgit := filepath.Join(d, ".git") + if info, err := os.Lstat(dotgit); err == nil { + found = appReadGitTree(d, dotgit, info) + break + } + parent := filepath.Dir(d) + if parent == d { + break + } + d = parent + } + for _, p := range passed { + r.git[p] = found + } + return found +} + +// appReadGitTree reads what a work tree says of itself: HEAD for the branch, +// the shared config for the origin. A .git file is a linked work tree's +// pointer to its own git folder, whose commondir names the shared one. +func appReadGitTree(root, dotgit string, info fs.FileInfo) *appGitTree { + gitdir := dotgit + if !info.IsDir() { + line := strings.TrimSpace(appSmallFile(dotgit)) + pointed, ok := strings.CutPrefix(line, "gitdir:") + if !ok { + return nil + } + gitdir = strings.TrimSpace(pointed) + if !filepath.IsAbs(gitdir) { + gitdir = filepath.Join(root, gitdir) + } + } + tree := &appGitTree{root: root, repo: filepath.Base(root)} + if ref, ok := strings.CutPrefix(strings.TrimSpace(appSmallFile(filepath.Join(gitdir, "HEAD"))), "ref:"); ok { + tree.branch = strings.TrimPrefix(strings.TrimSpace(ref), "refs/heads/") + } + common := gitdir + if c := strings.TrimSpace(appSmallFile(filepath.Join(gitdir, "commondir"))); c != "" { + if !filepath.IsAbs(c) { + c = filepath.Join(gitdir, c) + } + common = c + } + if repo := appOriginRepo(appSmallFile(filepath.Join(common, "config"))); repo != "" { + tree.repo = repo + } + return tree +} + +// appSmallFile is at most 64 KB of a file, and nothing when it cannot be read. +func appSmallFile(path string) string { + f, err := os.Open(path) + if err != nil { + return "" + } + defer f.Close() + data, _ := io.ReadAll(io.LimitReader(f, 64<<10)) + return string(data) +} + +// appOriginRepo is the origin remote's owner/name out of a git config, or +// nothing when there is no origin or its address has no two parts. +func appOriginRepo(config string) string { + section := "" + scanner := bufio.NewScanner(strings.NewReader(config)) + for scanner.Scan() { + line := strings.TrimSpace(scanner.Text()) + if strings.HasPrefix(line, "[") { + section = strings.Join(strings.Fields(line), " ") + continue + } + if section != `[remote "origin"]` { + continue + } + key, value, ok := strings.Cut(line, "=") + if !ok || strings.TrimSpace(key) != "url" { + continue + } + return appOwnerName(strings.TrimSpace(value)) + } + return "" +} + +// appOwnerName is the last two parts of a remote address, without .git: +// git@github.com:owner/name.git and https://github.com/owner/name are both +// owner/name. +func appOwnerName(url string) string { + url = strings.TrimSuffix(strings.TrimRight(url, "/"), ".git") + parts := strings.FieldsFunc(url, func(r rune) bool { return r == '/' || r == ':' || r == '\\' }) + if len(parts) < 2 { + return "" + } + return parts[len(parts)-2] + "/" + parts[len(parts)-1] +} diff --git a/cmd/codeaf/appcollections_test.go b/cmd/codeaf/appcollections_test.go new file mode 100644 index 0000000000..20362af223 --- /dev/null +++ b/cmd/codeaf/appcollections_test.go @@ -0,0 +1,210 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/appwire" +) + +// collectionsBackend is a work backend whose collections store is a path in +// a fresh folder, not yet made. +func collectionsBackend(t *testing.T) (*appBackend, *appRecorder, appwire.Agent, string) { + t.Helper() + backend, rec, agent := workBackend(t, &fakeConversation{}) + backend.collectionsPath = filepath.Join(t.TempDir(), "v3", "collections.db") + return backend, rec, agent, backend.collectionsPath +} + +// changedEvents is every collection.changed the recorder has seen. +func changedEvents(rec *appRecorder) []appwire.CollectionChanged { + rec.mu.Lock() + defer rec.mu.Unlock() + var out []appwire.CollectionChanged + for _, d := range rec.data { + if c, ok := d.(appwire.CollectionChanged); ok { + out = append(out, c) + } + } + return out +} + +// A READ OF A FRESH HOME CREATES NOTHING, and a change to a collection that +// cannot exist yet is not_found without making the file either. +func TestAppCollectionsReadsDoNotCreateTheStore(t *testing.T) { + backend, _, _, path := collectionsBackend(t) + list, err := backend.ListCollections(nil) + if err != nil || list == nil || len(list) != 0 { + t.Fatalf("fresh list %v, %v", list, err) + } + if _, err := backend.RenameCollection("nope", "x"); wireCode(err) != appwire.CodeNotFound { + t.Fatalf("rename in a fresh home: %v", err) + } + if _, err := backend.OpenCollection("nope", 1, 500); wireCode(err) != appwire.CodeNotFound { + t.Fatalf("open in a fresh home: %v", err) + } + if _, err := os.Stat(filepath.Dir(path)); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("a read made the store's folder: %v", err) + } +} + +// writeGitTree makes a work tree with a branch checked out and, when origin +// is given, an origin remote. +func writeGitTree(t *testing.T, root, branch, origin string) { + t.Helper() + if err := os.MkdirAll(filepath.Join(root, ".git"), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(root, ".git", "HEAD"), []byte("ref: refs/heads/"+branch+"\n"), 0o644); err != nil { + t.Fatal(err) + } + config := "[core]\n\tbare = false\n" + if origin != "" { + config += "[remote \"upstream\"]\n\turl = git@github.com:someone/else.git\n[remote \"origin\"]\n\turl = " + origin + "\n" + } + if err := os.WriteFile(filepath.Join(root, ".git", "config"), []byte(config), 0o644); err != nil { + t.Fatal(err) + } +} + +// OPENING SAYS WHAT EACH MEMBER IS NOW: a conversation that is there is live +// under its title, one that is gone is deleted, a moved file is missing, a +// file in a git work tree names its repository, branch and path, and a task +// or a process of an agent that is gone is deleted with it. +func TestAppOpenCollectionResolvesEachMember(t *testing.T) { + backend, rec, agent, _ := collectionsBackend(t) + repo := filepath.Join(t.TempDir(), "widgets") + writeGitTree(t, repo, "feature/collections", "git@github.com:acme/widgets.git") + source := filepath.Join(repo, "sub", "main.go") + if err := os.MkdirAll(filepath.Dir(source), 0o755); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(source, []byte("package main\n"), 0o644); err != nil { + t.Fatal(err) + } + plain := filepath.Join(t.TempDir(), "notes") + writeGitTree(t, plain, "main", "") + note := filepath.Join(plain, "todo.md") + if err := os.WriteFile(note, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + + c, err := backend.CreateCollection("Launch") + if err != nil || c.CreatedAt == nil || c.Count != 0 { + t.Fatalf("create %+v, %v", c, err) + } + nested, _ := backend.CreateCollection("Nested") + refs := []appwire.Member{ + {Ref: appwire.Ref{Kind: appwire.RefConversation, ID: agent.ID}}, + {Ref: appwire.Ref{Kind: appwire.RefConversation, ID: "0000000000000000"}}, + {Ref: appwire.Ref{Kind: appwire.RefArtifact, ID: filepath.Join(repo, "gone.txt")}}, + {Ref: appwire.Ref{Kind: appwire.RefArtifact, ID: source}}, + {Ref: appwire.Ref{Kind: appwire.RefArtifact, ID: note}}, + {Ref: appwire.Ref{Kind: appwire.RefTask, ID: "4", SessionID: "0000000000000000"}}, + {Ref: appwire.Ref{Kind: appwire.RefTask, ID: "99", SessionID: agent.ID}}, + {Ref: appwire.Ref{Kind: appwire.RefProcess, ID: "0000000000000000:p1", SessionID: "0000000000000000"}}, + {Ref: appwire.Ref{Kind: appwire.RefCollection, ID: nested.ID}}, + {Ref: appwire.Ref{Kind: appwire.RefCrew, ID: "no-such-crew"}}, + } + added, err := backend.AddToCollection(c.ID, refs) + if err != nil || len(added.Added) != len(refs) || len(added.Already) != 0 { + t.Fatalf("add %+v, %v", added, err) + } + open, err := backend.OpenCollection(c.ID, 1, 500) + if err != nil { + t.Fatal(err) + } + if open.Collection.Count != len(refs) || len(open.Members) != len(refs) || open.More { + t.Fatalf("open %+v", open) + } + byID := map[string]appwire.Target{} + for _, n := range open.Members { + byID[n.Kind+":"+n.ID] = n.Target + if n.AddedAt == nil { + t.Fatalf("%s has no added_at", n.ID) + } + } + check := func(key, state string) appwire.Target { + t.Helper() + got := byID[key] + if got.State != state { + t.Fatalf("%s is %+v, want %s", key, got, state) + } + return got + } + if live := check("conversation:"+agent.ID, appwire.TargetLive); live.Title != agent.Title || live.Status == "" { + t.Fatalf("live conversation %+v (agent %+v)", live, agent) + } + check("conversation:0000000000000000", appwire.TargetDeleted) + check("artifact:"+filepath.Join(repo, "gone.txt"), appwire.TargetMissing) + if f := check("artifact:"+source, appwire.TargetLive); f.Repo != "acme/widgets" || f.Branch != "feature/collections" || f.Path != "sub/main.go" || f.Title != "main.go" { + t.Fatalf("file in a repo %+v", f) + } + if f := check("artifact:"+note, appwire.TargetLive); f.Repo != "notes" || f.Branch != "main" || f.Path != "todo.md" { + t.Fatalf("file in a repo with no origin %+v", f) + } + check("task:4", appwire.TargetDeleted) + check("task:99", appwire.TargetDeleted) + check("process:0000000000000000:p1", appwire.TargetDeleted) + check("crew:no-such-crew", appwire.TargetDeleted) + if n := check("collection:"+nested.ID, appwire.TargetLive); n.Title != "Nested" || n.Count == nil || *n.Count != 0 { + t.Fatalf("nested %+v", n) + } + if events := changedEvents(rec); len(events) != 3 || events[2].ID != c.ID { + t.Fatalf("events %+v", events) + } + + e, err := backend.ExpandCollection(c.ID, 3, 200) + if err != nil || e.Missing != 6 || len(e.Refs) != 3 || e.Collections != 1 { + t.Fatalf("expand %+v, %v", e, err) + } +} + +// THE STORE'S REFUSALS ARRIVE IN THE WIRE'S CODES, and delete says so of the +// collection and of every collection that lost a membership naming it. +func TestAppCollectionsRefusalsAndDeleteEvents(t *testing.T) { + backend, rec, _, _ := collectionsBackend(t) + a, _ := backend.CreateCollection("Alpha") + b, _ := backend.CreateCollection("Beta") + if _, err := backend.CreateCollection("ALPHA"); wireCode(err) != appwire.CodeConflict { + t.Fatalf("taken name: %v", err) + } + if _, err := backend.RenameCollection(a.ID, "alpha"); err != nil { + t.Fatalf("own name in another case: %v", err) + } + if _, err := backend.AddToCollection(a.ID, []appwire.Member{{Ref: appwire.Ref{Kind: appwire.RefCollection, ID: b.ID}}}); err != nil { + t.Fatal(err) + } + if _, err := backend.AddToCollection(b.ID, []appwire.Member{{Ref: appwire.Ref{Kind: appwire.RefCollection, ID: a.ID}}}); wireCode(err) != appwire.CodeCycle { + t.Fatalf("cycle: %v", err) + } + dup, err := backend.DuplicateCollection(a.ID, "Gamma") + if err != nil || dup.Count != 1 { + t.Fatalf("duplicate %+v, %v", dup, err) + } + removed, err := backend.RemoveFromCollection(dup.ID, []appwire.Ref{{Kind: appwire.RefCollection, ID: b.ID}}) + if err != nil || len(removed.Removed) != 1 || removed.Removed[0].AddedAt == nil { + t.Fatalf("remove %+v, %v", removed, err) + } + restored, err := backend.AddToCollection(dup.ID, []appwire.Member{removed.Removed[0]}) + if err != nil || !restored.Added[0].AddedAt.Equal(*removed.Removed[0].AddedAt) { + t.Fatalf("restore %+v, %v", restored, err) + } + before := len(changedEvents(rec)) + if err := backend.DeleteCollection(b.ID); err != nil { + t.Fatal(err) + } + events := changedEvents(rec)[before:] + if len(events) != 3 || events[0] != (appwire.CollectionChanged{ID: b.ID, Deleted: true}) || events[1].ID != a.ID || events[2].ID != dup.ID { + t.Fatalf("delete events %+v", events) + } + if err := backend.DeleteCollection(b.ID); wireCode(err) != appwire.CodeNotFound { + t.Fatalf("second delete: %v", err) + } + list, err := backend.ListCollections(&appwire.Ref{Kind: appwire.RefCollection, ID: b.ID}) + if err != nil || len(list) != 2 || list[0].Count != 0 || list[0].Holds == nil || *list[0].Holds { + t.Fatalf("list after delete %+v, %v", list, err) + } +} diff --git a/docs/changes/unreleased/0-app-collections.md b/docs/changes/unreleased/0-app-collections.md new file mode 100644 index 0000000000..f5a99541a9 --- /dev/null +++ b/docs/changes/unreleased/0-app-collections.md @@ -0,0 +1,16 @@ +--- +kind: added +title: codeaf app serves collections, and the collections store moves to schema 2 +pr: 0 +surface: [engine] +invalidates: + - "Collections were reachable only through `codeaf collections`. codeaf app now serves collections.list/create/rename/duplicate/delete/add/remove/open/expand and the collection.changed event, carries the hello feature \"collections\", and answers what each member is now (live, deleted, missing or unknown) without storing it." + - "The collections database was schema 1 and a collection could not be deleted. Schema 2 records when a collection was made and when each membership was added, adds the crew and process kinds, and cascades deletes; a schema-1 file is read as it is and migrated in place by the first write, after which a schema-1 build refuses it as an unsupported collections database." + - "Two collections could share a name. Names are now unique without regard to case (Unicode case folding); a taken name answers conflict on the wire and `another collection already has that name` at the command line." + - "`codeaf collections` had no delete or duplicate verb. It now has both." +--- + +Deleting a collection takes its memberships and every membership naming it, +never the records they referenced. collections.open resolves members with at +most one listing per owner per call, and a file inside a git work tree carries +its repository, branch and path, read from .git without running git. diff --git a/internal/appwire/collections.go b/internal/appwire/collections.go new file mode 100644 index 0000000000..7f22b3a90d --- /dev/null +++ b/internal/appwire/collections.go @@ -0,0 +1,436 @@ +package appwire + +import ( + "path/filepath" + "strconv" + "strings" + "time" + "unicode" +) + +// WAVE 9: COLLECTIONS (PROTOCOL.md, V75). A collection is a hand-picked list +// of references: conversations, tasks, files, crews, processes and other +// collections. It is a list, not a saved query and not memory: nothing is +// copied, nothing is learned, and removing a reference never touches the +// thing. What a member is NOW (there, gone, moved) is found when it is +// opened and never stored. + +// EventCollectionChanged says a collection changed through this door. +const EventCollectionChanged = "collection.changed" + +// FeatureCollections is the hello feature an engine serving collections +// carries. +const FeatureCollections = "collections" + +// The kinds of a reference. RefStanding is legacy: an older build's standing +// item, found and removed, never added. +const ( + RefCollection = "collection" + RefConversation = "conversation" + RefTask = "task" + RefArtifact = "artifact" + RefCrew = "crew" + RefProcess = "process" + RefStanding = "standing" +) + +// The states of a target. +const ( + TargetLive = "live" + TargetDeleted = "deleted" + TargetMissing = "missing" + TargetUnknown = "unknown" +) + +// The bounds of this wave's methods. +const ( + CollectionRefsMax = 200 // refs in one add or remove + CollectionsListMax = 1000 // collections one listing answers + CollectionOpenDepth = 2 // levels below the top open opens + CollectionOpenLimit = 500 // members per level, and expand's most refs + CollectionExpandMax = 3 // levels expand reads + CollectionExpandDefault = 200 // refs expand answers when not told + CollectionNodesMax = 1500 // nodes one open answers in all + collectionNameMax = 256 + collectionIDMax = 4096 +) + +// Ref is one reference: a task and a process carry their agent as SessionID, +// and an artifact is a clean absolute path. +type Ref struct { + Kind string `json:"kind" enum:"collection,conversation,task,artifact,crew,process,standing"` + ID string `json:"id"` + SessionID string `json:"session_id,omitempty"` +} + +// Member is a reference as a collection holds it, with when it was added; +// AddedAt is absent for a row written by a store older than schema 2. In +// collections.add, an AddedAt puts a removed reference back in place. +type Member struct { + Ref + AddedAt *time.Time `json:"added_at,omitempty"` +} + +// Collection is one collection: its direct member count, when it was made and +// when its newest member was added, and, when collections.list was asked +// about a reference, whether it directly holds it. +type Collection struct { + ID string `json:"id"` + Name string `json:"name"` + CreatedAt *time.Time `json:"created_at,omitempty"` + Count int `json:"count"` + LastAddedAt *time.Time `json:"last_added_at,omitempty"` + Holds *bool `json:"holds,omitempty"` +} + +// Target is what a member was found to be now, never stored. In is where it +// lives, outer first; Count is a crew's members or a collection's direct +// members; Repo, Branch and Path place a file inside a git work tree. +type Target struct { + State string `json:"state" enum:"live,deleted,missing,unknown"` + Title string `json:"title,omitempty"` + In []string `json:"in,omitempty"` + Status string `json:"status,omitempty"` + Repo string `json:"repo,omitempty"` + Branch string `json:"branch,omitempty"` + Path string `json:"path,omitempty"` + Count *int `json:"count,omitempty"` +} + +// Node is a member of an opened collection with its target, and for a +// collection opened within the depth asked, its own members. +type Node struct { + Member + Target Target `json:"target"` + Children *NodeList `json:"children,omitempty"` +} + +// NodeList is one collection's members as collections.open answers them, +// newest added first, and whether there were more. +type NodeList struct { + Members []Node `json:"members"` + More bool `json:"more"` +} + +// ExpandedRef is one reference collections.expand answers, with its target. +type ExpandedRef struct { + Member + Target Target `json:"target"` +} + +// The answers of this wave's methods. +type ( + CollectionsResult struct { + Collections []Collection `json:"collections"` + } + CollectionResult struct { + Collection Collection `json:"collection"` + } + CollectionAddResult struct { + Added []Member `json:"added"` + Already []Ref `json:"already"` + } + CollectionRemoveResult struct { + Removed []Member `json:"removed"` + } + CollectionOpenResult struct { + Collection Collection `json:"collection"` + Members []Node `json:"members"` + More bool `json:"more"` + } + CollectionExpandResult struct { + Collection Collection `json:"collection"` + Refs []ExpandedRef `json:"refs"` + Missing int `json:"missing"` + Collections int `json:"collections"` + More bool `json:"more"` + } + // CollectionChanged is said after any change made through this door, once + // per collection changed; Deleted says it is gone. + CollectionChanged struct { + ID string `json:"id"` + Deleted bool `json:"deleted,omitempty"` + } +) + +// Collector is the optional engine half of collections. One that is not a +// Collector answers every collections method unknown_method and carries no +// "collections" feature. Bounds and shapes are checked here before it is +// asked; depth and limit arrive already defaulted. +type Collector interface { + ListCollections(holds *Ref) ([]Collection, error) + CreateCollection(name string) (Collection, error) + RenameCollection(id, name string) (Collection, error) + DuplicateCollection(id, name string) (Collection, error) + DeleteCollection(id string) error + AddToCollection(id string, refs []Member) (CollectionAddResult, error) + RemoveFromCollection(id string, refs []Ref) (CollectionRemoveResult, error) + OpenCollection(id string, depth, limit int) (CollectionOpenResult, error) + ExpandCollection(id string, depth, limit int) (CollectionExpandResult, error) +} + +func collector(e Engine, name string) (Collector, error) { + c, ok := e.(Collector) + if !ok { + return nil, Errorf(CodeUnknownMethod, "this engine does not serve %s", name) + } + return c, nil +} + +// collectCall decodes a method's params into P and answers what call answers. +func collectCall[P, R any](name string, call func(Collector, P) (*R, error)) method { + return typed(func(e Engine, p P) (*R, error) { + c, err := collector(e, name) + if err != nil { + return nil, err + } + return call(c, p) + }) +} + +// withCollectionsFeature is hello carrying "collections" when the engine +// serves it. +func withCollectionsFeature(hello Hello, e Engine) Hello { + if _, ok := e.(Collector); !ok { + return hello + } + for _, f := range hello.Features { + if f == FeatureCollections { + return hello + } + } + hello.Features = append(append([]string(nil), hello.Features...), FeatureCollections) + return hello +} + +// plainText is non-empty text of at most limit bytes with no control +// characters and no surrounding space. +func plainText(s string, limit int) bool { + return s != "" && len(s) <= limit && strings.TrimSpace(s) == s && strings.IndexFunc(s, unicode.IsControl) < 0 +} + +// CheckRef refuses a malformed reference as bad_params. adding refuses the +// legacy standing kind, which can be found and removed and never added. +func CheckRef(r Ref, adding bool) error { + if !plainText(r.ID, collectionIDMax) { + return Errorf(CodeBadParams, "a reference needs an id without surrounding space or control characters") + } + needsSession := r.Kind == RefTask || r.Kind == RefProcess + if needsSession && !plainText(r.SessionID, 1024) { + return Errorf(CodeBadParams, "a %s reference needs the session_id of its agent", r.Kind) + } + if !needsSession && r.SessionID != "" { + return Errorf(CodeBadParams, "only a task or a process reference has a session_id") + } + switch r.Kind { + case RefCollection, RefConversation, RefCrew, RefProcess: + case RefTask: + n, err := strconv.ParseUint(r.ID, 10, 64) + if err != nil || n == 0 || strconv.FormatUint(n, 10) != r.ID { + return Errorf(CodeBadParams, "a task reference's id is its positive number") + } + case RefArtifact: + if !filepath.IsAbs(r.ID) || filepath.Clean(r.ID) != r.ID { + return Errorf(CodeBadParams, "an artifact reference needs a clean absolute path") + } + case RefStanding: + if adding { + return Errorf(CodeBadParams, "standing items are gone, so a collection cannot gain one") + } + default: + return Errorf(CodeBadParams, "unknown reference kind %q", r.Kind) + } + return nil +} + +// collectionName is a name trimmed, or bad_params. +func collectionName(name string) (string, error) { + name = strings.TrimSpace(name) + if !plainText(name, collectionNameMax) { + return "", Errorf(CodeBadParams, "a collection needs a name of 1 to %d bytes without control characters", collectionNameMax) + } + return name, nil +} + +// collectionRefs checks a list of references against its bounds. +func collectionRefs(name string, n int, check func(int) error) error { + if n < 1 || n > CollectionRefsMax { + return Errorf(CodeBadParams, "%s takes 1 to %d refs", name, CollectionRefsMax) + } + for i := 0; i < n; i++ { + if err := check(i); err != nil { + return err + } + } + return nil +} + +// bounded is an optional bound: def when absent, bad_params outside lo–hi. +func bounded(field string, v *int, lo, hi, def int) (int, error) { + if v == nil { + return def, nil + } + if *v < lo || *v > hi { + return 0, Errorf(CodeBadParams, "%s is %d to %d", field, lo, hi) + } + return *v, nil +} + +// collectionOf answers one collection or the error. +func collectionOf(c Collection, err error) (*CollectionResult, error) { + if err != nil { + return nil, err + } + return &CollectionResult{c}, nil +} + +type ( + collectionNameParams struct { + ID string `json:"id"` + Name string `json:"name"` + } + collectionBoundParams struct { + ID string `json:"id"` + Depth *int `json:"depth"` + Limit *int `json:"limit"` + } +) + +func init() { + for name, m := range collectionMethods { + methods[name] = m + } +} + +var collectionMethods = map[string]method{ + "collections.list": collectCall("collections.list", func(c Collector, p struct { + Holds *Ref `json:"holds"` + }) (*CollectionsResult, error) { + if p.Holds != nil { + if err := CheckRef(*p.Holds, false); err != nil { + return nil, err + } + } + list, err := c.ListCollections(p.Holds) + if err != nil { + return nil, err + } + if len(list) > CollectionsListMax { + list = list[:CollectionsListMax] + } + return &CollectionsResult{nonNil(list)}, nil + }), + "collections.create": collectCall("collections.create", func(c Collector, p struct { + Name string `json:"name"` + }) (*CollectionResult, error) { + name, err := collectionName(p.Name) + if err != nil { + return nil, err + } + return collectionOf(c.CreateCollection(name)) + }), + "collections.rename": collectCall("collections.rename", func(c Collector, p collectionNameParams) (*CollectionResult, error) { + if err := needID("collections.rename", p.ID); err != nil { + return nil, err + } + name, err := collectionName(p.Name) + if err != nil { + return nil, err + } + return collectionOf(c.RenameCollection(p.ID, name)) + }), + "collections.duplicate": collectCall("collections.duplicate", func(c Collector, p collectionNameParams) (*CollectionResult, error) { + if err := needID("collections.duplicate", p.ID); err != nil { + return nil, err + } + name, err := collectionName(p.Name) + if err != nil { + return nil, err + } + return collectionOf(c.DuplicateCollection(p.ID, name)) + }), + "collections.delete": collectCall("collections.delete", func(c Collector, p IDParams) (*Empty, error) { + if err := needID("collections.delete", p.ID); err != nil { + return nil, err + } + return done(c.DeleteCollection(p.ID)) + }), + "collections.add": collectCall("collections.add", func(c Collector, p struct { + ID string `json:"id"` + Refs []Member `json:"refs"` + }) (*CollectionAddResult, error) { + if err := needID("collections.add", p.ID); err != nil { + return nil, err + } + err := collectionRefs("collections.add", len(p.Refs), func(i int) error { + if at := p.Refs[i].AddedAt; at != nil && at.UnixMilli() <= 0 { + return Errorf(CodeBadParams, "added_at is a moment after 1970") + } + return CheckRef(p.Refs[i].Ref, true) + }) + if err != nil { + return nil, err + } + r, err := c.AddToCollection(p.ID, p.Refs) + if err != nil { + return nil, err + } + r.Added, r.Already = nonNil(r.Added), nonNil(r.Already) + return &r, nil + }), + "collections.remove": collectCall("collections.remove", func(c Collector, p struct { + ID string `json:"id"` + Refs []Ref `json:"refs"` + }) (*CollectionRemoveResult, error) { + if err := needID("collections.remove", p.ID); err != nil { + return nil, err + } + if err := collectionRefs("collections.remove", len(p.Refs), func(i int) error { return CheckRef(p.Refs[i], false) }); err != nil { + return nil, err + } + r, err := c.RemoveFromCollection(p.ID, p.Refs) + if err != nil { + return nil, err + } + r.Removed = nonNil(r.Removed) + return &r, nil + }), + "collections.open": collectCall("collections.open", func(c Collector, p collectionBoundParams) (*CollectionOpenResult, error) { + if err := needID("collections.open", p.ID); err != nil { + return nil, err + } + depth, err := bounded("depth", p.Depth, 0, CollectionOpenDepth, 1) + if err != nil { + return nil, err + } + limit, err := bounded("limit", p.Limit, 1, CollectionOpenLimit, CollectionOpenLimit) + if err != nil { + return nil, err + } + r, err := c.OpenCollection(p.ID, depth, limit) + if err != nil { + return nil, err + } + r.Members = nonNil(r.Members) + return &r, nil + }), + "collections.expand": collectCall("collections.expand", func(c Collector, p collectionBoundParams) (*CollectionExpandResult, error) { + if err := needID("collections.expand", p.ID); err != nil { + return nil, err + } + depth, err := bounded("depth", p.Depth, 1, CollectionExpandMax, CollectionExpandMax) + if err != nil { + return nil, err + } + limit, err := bounded("limit", p.Limit, 1, CollectionOpenLimit, CollectionExpandDefault) + if err != nil { + return nil, err + } + r, err := c.ExpandCollection(p.ID, depth, limit) + if err != nil { + return nil, err + } + r.Refs = nonNil(r.Refs) + return &r, nil + }), +} diff --git a/internal/appwire/collections_test.go b/internal/appwire/collections_test.go new file mode 100644 index 0000000000..56d31c1e3c --- /dev/null +++ b/internal/appwire/collections_test.go @@ -0,0 +1,233 @@ +package appwire + +import ( + "bufio" + "context" + "encoding/json" + "io" + "testing" + "time" +) + +// fakeCollector is the fake engine with collections: it answers fixed values +// and records what it was asked. +type fakeCollector struct { + *fakeEngine + depth, limit int + added []Member +} + +func (f *fakeCollector) ListCollections(holds *Ref) ([]Collection, error) { + at := time.UnixMilli(1000).UTC() + yes := holds != nil + c := Collection{ID: "c1", Name: "Launch", CreatedAt: &at, Count: 2, LastAddedAt: &at} + if holds != nil { + c.Holds = &yes + } + return []Collection{c}, nil +} +func (f *fakeCollector) CreateCollection(name string) (Collection, error) { + if name == "Taken" { + return Collection{}, Errorf(CodeConflict, "another collection already has that name") + } + return Collection{ID: "c2", Name: name}, nil +} +func (f *fakeCollector) RenameCollection(id, name string) (Collection, error) { + return Collection{ID: id, Name: name}, nil +} +func (f *fakeCollector) DuplicateCollection(id, name string) (Collection, error) { + return Collection{ID: "c3", Name: name, Count: 2}, nil +} +func (f *fakeCollector) DeleteCollection(id string) error { + if id != "c1" { + return Errorf(CodeNotFound, "collection not found") + } + return nil +} +func (f *fakeCollector) AddToCollection(id string, refs []Member) (CollectionAddResult, error) { + for _, r := range refs { + if r.Kind == RefCollection && r.ID == id { + return CollectionAddResult{}, Errorf(CodeCycle, "collection membership would form a cycle") + } + } + f.added = refs + return CollectionAddResult{Added: refs}, nil +} +func (f *fakeCollector) RemoveFromCollection(id string, refs []Ref) (CollectionRemoveResult, error) { + return CollectionRemoveResult{}, nil +} +func (f *fakeCollector) OpenCollection(id string, depth, limit int) (CollectionOpenResult, error) { + f.depth, f.limit = depth, limit + return CollectionOpenResult{Collection: Collection{ID: id, Name: "Launch"}}, nil +} +func (f *fakeCollector) ExpandCollection(id string, depth, limit int) (CollectionExpandResult, error) { + f.depth, f.limit = depth, limit + return CollectionExpandResult{Collection: Collection{ID: id, Name: "Launch"}}, nil +} + +func startCollectorWire(t *testing.T) (*wire, *fakeCollector) { + t.Helper() + engine := &fakeCollector{fakeEngine: &fakeEngine{}} + inR, inW := io.Pipe() + outR, outW := io.Pipe() + served := make(chan error, 1) + server := NewServer(outW) + go func() { + served <- server.Serve(context.Background(), inR, Hello{Version: "test", PID: 42, Home: "/h", Features: []string{"x"}}, engine) + outW.Close() + }() + scanner := bufio.NewScanner(outR) + scanner.Buffer(make([]byte, 1<<20), 1<<20) + t.Cleanup(func() { inW.Close() }) + return &wire{t: t, in: inW, lines: scanner, served: served, engine: engine.fakeEngine}, engine +} + +func features(hello map[string]any) []any { + f, _ := hello["data"].(map[string]any)["features"].([]any) + return f +} + +// THE FEATURE IS CARRIED BY AN ENGINE THAT SERVES IT, and only by one: an +// engine without collections answers its methods unknown_method. +func TestCollectionsFeatureFollowsTheEngine(t *testing.T) { + w, _ := startCollectorWire(t) + if got := features(w.next()); len(got) != 2 || got[0] != "x" || got[1] != FeatureCollections { + t.Fatalf("features = %v", got) + } + plain := startWire(t, &fakeEngine{}) + for _, f := range features(plain.next()) { + if f == FeatureCollections { + t.Fatal("an engine without collections claims them") + } + } + plain.send(`{"id":1,"method":"collections.list"}`) + if got := plain.next(); got["error"].(map[string]any)["code"] != CodeUnknownMethod { + t.Fatalf("collections.list without the engine half = %v", got) + } +} + +// EVERY SHAPE AND BOUND IS CHECKED HERE before the engine is asked. +func TestCollectionsRefuseBadParams(t *testing.T) { + w, _ := startCollectorWire(t) + w.next() + many := `[` + for i := 0; i < CollectionRefsMax+1; i++ { + if i > 0 { + many += "," + } + many += `{"kind":"conversation","id":"c"}` + } + many += `]` + for i, line := range []string{ + `{"id":1,"method":"collections.create","params":{}}`, + `{"id":2,"method":"collections.create","params":{"name":"a\u0007b"}}`, + `{"id":3,"method":"collections.rename","params":{"name":"x"}}`, + `{"id":4,"method":"collections.add","params":{"id":"c1","refs":[]}}`, + `{"id":5,"method":"collections.add","params":{"id":"c1","refs":` + many + `}}`, + `{"id":6,"method":"collections.add","params":{"id":"c1","refs":[{"kind":"task","id":"1"}]}}`, + `{"id":7,"method":"collections.add","params":{"id":"c1","refs":[{"kind":"task","id":"01","session_id":"a"}]}}`, + `{"id":8,"method":"collections.add","params":{"id":"c1","refs":[{"kind":"artifact","id":"rel/path"}]}}`, + `{"id":9,"method":"collections.add","params":{"id":"c1","refs":[{"kind":"standing","id":"s"}]}}`, + `{"id":10,"method":"collections.add","params":{"id":"c1","refs":[{"kind":"crew","id":"k","session_id":"a"}]}}`, + `{"id":11,"method":"collections.add","params":{"id":"c1","refs":[{"kind":"process","id":"p"}]}}`, + `{"id":12,"method":"collections.add","params":{"id":"c1","refs":[{"kind":"memory","id":"m"}]}}`, + `{"id":13,"method":"collections.remove","params":{"id":"c1"}}`, + `{"id":14,"method":"collections.open","params":{"id":"c1","depth":3}}`, + `{"id":15,"method":"collections.open","params":{"id":"c1","limit":0}}`, + `{"id":16,"method":"collections.open","params":{"id":"c1","limit":501}}`, + `{"id":17,"method":"collections.expand","params":{"id":"c1","depth":0}}`, + `{"id":18,"method":"collections.expand","params":{"id":"c1","depth":4}}`, + `{"id":19,"method":"collections.expand","params":{"id":"c1","limit":501}}`, + `{"id":20,"method":"collections.list","params":{"holds":{"kind":"task","id":"x","session_id":"a"}}}`, + `{"id":21,"method":"collections.delete","params":{}}`, + `{"id":22,"method":"collections.add","params":{"id":"c1","refs":[{"kind":"conversation","id":"a","added_at":"1960-01-01T00:00:00Z"}]}}`, + } { + w.send(line) + got := w.next() + if got["id"] != float64(i+1) || got["error"] == nil || got["error"].(map[string]any)["code"] != CodeBadParams { + t.Fatalf("%s = %v", line, got) + } + } +} + +// THE ANSWERS ARE THE SHAPES THE PROTOCOL STATES, the defaults are applied +// before the engine is asked, and engine refusals keep their codes. +func TestCollectionsAnswerTheirShapes(t *testing.T) { + w, engine := startCollectorWire(t) + w.next() + call := func(id int, line string) map[string]any { + t.Helper() + w.send(line) + got := w.next() + if got["id"] != float64(id) { + t.Fatalf("%s = %v", line, got) + } + return got + } + got := call(1, `{"id":1,"method":"collections.list","params":{"holds":{"kind":"conversation","id":"a1"}}}`) + list := got["result"].(map[string]any)["collections"].([]any) + if c := list[0].(map[string]any); c["holds"] != true || c["count"] != float64(2) || c["created_at"] != "1970-01-01T00:00:01Z" { + t.Fatalf("list = %v", got) + } + if got := call(2, `{"id":2,"method":"collections.create","params":{"name":" Launch "}}`); got["result"].(map[string]any)["collection"].(map[string]any)["name"] != "Launch" { + t.Fatalf("create = %v", got) + } + if got := call(3, `{"id":3,"method":"collections.create","params":{"name":"Taken"}}`); got["error"].(map[string]any)["code"] != CodeConflict { + t.Fatalf("taken = %v", got) + } + if got := call(4, `{"id":4,"method":"collections.add","params":{"id":"c1","refs":[{"kind":"collection","id":"c1"}]}}`); got["error"].(map[string]any)["code"] != CodeCycle { + t.Fatalf("cycle = %v", got) + } + got = call(5, `{"id":5,"method":"collections.add","params":{"id":"c1","refs":[{"kind":"process","id":"a1:p1","session_id":"a1","added_at":"2026-10-15T10:00:00Z"}]}}`) + result := got["result"].(map[string]any) + if already, ok := result["already"].([]any); !ok || len(already) != 0 || len(engine.added) != 1 || engine.added[0].AddedAt == nil { + t.Fatalf("add = %v, engine was given %+v", got, engine.added) + } + if got := call(6, `{"id":6,"method":"collections.remove","params":{"id":"c1","refs":[{"kind":"standing","id":"s"}]}}`); got["result"].(map[string]any)["removed"] == nil { + t.Fatalf("remove = %v", got) + } + call(7, `{"id":7,"method":"collections.open","params":{"id":"c1"}}`) + if engine.depth != 1 || engine.limit != CollectionOpenLimit { + t.Fatalf("open defaults %d %d", engine.depth, engine.limit) + } + got = call(8, `{"id":8,"method":"collections.expand","params":{"id":"c1"}}`) + if engine.depth != CollectionExpandMax || engine.limit != CollectionExpandDefault { + t.Fatalf("expand defaults %d %d", engine.depth, engine.limit) + } + if refs, ok := got["result"].(map[string]any)["refs"].([]any); !ok || refs == nil { + t.Fatalf("expand = %v", got) + } + if got := call(9, `{"id":9,"method":"collections.delete","params":{"id":"zz"}}`); got["error"].(map[string]any)["code"] != CodeNotFound { + t.Fatalf("delete = %v", got) + } +} + +// The schema names this wave's methods and its event, and the target's state +// is one of its four words. +func TestTheSchemaDescribesCollections(t *testing.T) { + out, err := SchemaJSON() + if err != nil { + t.Fatal(err) + } + var doc struct { + Methods map[string]json.RawMessage `json:"methods"` + Events map[string]json.RawMessage `json:"events"` + Defs map[string]struct { + Properties map[string]map[string]any `json:"properties"` + } `json:"$defs"` + } + if err := json.Unmarshal(out, &doc); err != nil { + t.Fatal(err) + } + for _, name := range []string{"collections.list", "collections.create", "collections.rename", "collections.duplicate", "collections.delete", "collections.add", "collections.remove", "collections.open", "collections.expand"} { + if doc.Methods[name] == nil { + t.Errorf("schema lacks %s", name) + } + } + if doc.Events[EventCollectionChanged] == nil { + t.Error("schema lacks collection.changed") + } + if words, _ := doc.Defs["Target"].Properties["state"]["enum"].([]any); len(words) != 4 { + t.Errorf("target.state words = %v", words) + } +} diff --git a/internal/appwire/events.go b/internal/appwire/events.go index 63e9c61499..2ed72fba5d 100644 --- a/internal/appwire/events.go +++ b/internal/appwire/events.go @@ -44,4 +44,6 @@ var eventShapes = map[string]reflect.Type{ EventSettingsChanged: reflect.TypeFor[SettingsChanged](), EventProviderChanged: reflect.TypeFor[ProviderChanged](), EventLog: reflect.TypeFor[Log](), + + EventCollectionChanged: reflect.TypeFor[CollectionChanged](), } diff --git a/internal/appwire/server.go b/internal/appwire/server.go index aeb4aa539b..a7de127755 100644 --- a/internal/appwire/server.go +++ b/internal/appwire/server.go @@ -143,6 +143,7 @@ type request struct { // before it returns. func (s *Server) Serve(ctx context.Context, in io.Reader, hello Hello, engine Engine) error { hello.Contract = Contract + hello = withCollectionsFeature(hello, engine) s.Emit(EventHello, hello) if warmer, ok := engine.(Warmer); ok { go warmer.Warm() diff --git a/internal/appwire/wire.go b/internal/appwire/wire.go index 4f2a311f1b..4a4036273c 100644 --- a/internal/appwire/wire.go +++ b/internal/appwire/wire.go @@ -33,6 +33,9 @@ const ( CodeBusy = "busy" CodeEngine = "engine" CodeClosed = "closed" + // CodeCycle refuses a collections.add that would make a collection hold + // itself, at any depth. + CodeCycle = "cycle" ) // The states an agent can be in, as the app draws them. From 4b6f7878b9e4fbe931a9f7e91209898135877a44 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 19:24:51 -0400 Subject: [PATCH 039/459] workspace: page listings and opened collections by cursor, count what expansion leaves out, and relink in place MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A listing stopped at 1000 collections and an opened collection at 500 members with nothing saying where to go next. ListPage and Tree now take an opaque keyset cursor (a listing's on seq, an opened collection's on its added_at and seq, bound to that collection) and answer the next one when more remain, with the total; a walk never repeats or skips a row that existed when it began. The node budget no longer strands the top level, and a child list it cut says more. Expand counts nested collections past its depth (Unread) and nested references to collections that are gone (Deleted). Relink puts one reference in another's row in one writer transaction, keeping its moment and place, with Add's cycle check. Tests: a migration that fails after dropping the old tables leaves the schema-1 file byte for byte; a successful one keeps every row in order; names fold as strings.EqualFold (documented: Σ/σ/ς and the Kelvin sign fold, İ/i, ß/SS and unnormalized accents do not); case-only creates and opposite edges raced from two handles let exactly one win. Co-Authored-By: Claude Opus 5.5 --- internal/workspace/paging_test.go | 497 +++++++++++++++++++++++++++++ internal/workspace/schema2_test.go | 235 +++++++++++++- internal/workspace/store.go | 124 ++++++- internal/workspace/store_test.go | 63 ++++ internal/workspace/views.go | 294 ++++++++++++++--- internal/workspace/workspace.go | 3 + 6 files changed, 1146 insertions(+), 70 deletions(-) create mode 100644 internal/workspace/paging_test.go diff --git a/internal/workspace/paging_test.go b/internal/workspace/paging_test.go new file mode 100644 index 0000000000..10d9d48c9d --- /dev/null +++ b/internal/workspace/paging_test.go @@ -0,0 +1,497 @@ +package workspace + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "reflect" + "testing" +) + +// bulkCollections files n collections in one transaction, named " i", +// with nothing in them; the store's own Create is proved elsewhere, and a +// thousand fsyncs prove nothing more. +func bulkCollections(t *testing.T, s *Store, prefix string, n int) []string { + t.Helper() + if err := s.ensureSchema(context.Background()); err != nil { + t.Fatal(err) + } + tx, err := s.db.Begin() + if err != nil { + t.Fatal(err) + } + ids := make([]string, n) + for i := range ids { + ids[i] = fmt.Sprintf("%s-%05d", prefix, i) + if _, err := tx.Exec("INSERT INTO collections(id,name,created_at) VALUES (?,?,1)", ids[i], fmt.Sprintf("%s %d", prefix, i)); err != nil { + t.Fatal(err) + } + } + if err := tx.Commit(); err != nil { + t.Fatal(err) + } + return ids +} + +// bulkMembers files one conversation reference per moment given, in that +// order, so ties and unknown (0) moments can be laid down on purpose. +func bulkMembers(t *testing.T, s *Store, id, prefix string, moments []int64) { + t.Helper() + tx, err := s.db.Begin() + if err != nil { + t.Fatal(err) + } + for i, at := range moments { + if _, err := tx.Exec("INSERT INTO memberships(collection_id,kind,ref_id,session_id,added_at) VALUES (?,?,?,?,?)", + id, ConversationKind, fmt.Sprintf("%s-%05d", prefix, i), "", at); err != nil { + t.Fatal(err) + } + } + if err := tx.Commit(); err != nil { + t.Fatal(err) + } +} + +func summaryIDs(list []Summary) []string { + out := make([]string, len(list)) + for i, c := range list { + out[i] = c.ID + } + return out +} + +func nodeIDs(nodes []Node) []string { + out := make([]string, len(nodes)) + for i, n := range nodes { + out[i] = n.ID + } + return out +} + +// walkList reads every page of a listing at limit, calling between after +// each page, and answers the ids in the order read. +func walkList(t *testing.T, s *Store, limit int, between func(page int)) []string { + t.Helper() + var ids []string + after := "" + for page := 0; ; page++ { + p, err := s.ListPage(context.Background(), nil, after, limit) + if err != nil { + t.Fatal(err) + } + if len(p.Collections) > max(limit, 0) && limit > 0 { + t.Fatalf("page %d answered %d, more than %d", page, len(p.Collections), limit) + } + ids = append(ids, summaryIDs(p.Collections)...) + if p.Next == "" { + return ids + } + if between != nil { + between(page) + } + after = p.Next + if page > 10000 { + t.Fatal("the walk did not end") + } + } +} + +// A LISTING IS NEVER CUT SILENTLY: more than ListMax collections are all +// reachable, a page that stops short says where the next one starts, total +// counts them all, and pages walked at any size equal one read of the lot. +func TestListPagesReachEveryCollection(t *testing.T) { + ctx := context.Background() + s := openTestStore(t, filepath.Join(t.TempDir(), "list.db")) + ids := bulkCollections(t, s, "c", ListMax+205) + first, err := s.ListPage(ctx, nil, "", 0) + if err != nil || len(first.Collections) != ListMax || first.Next == "" || first.Total != len(ids) { + t.Fatalf("first page %d, next %q, total %d, %v", len(first.Collections), first.Next, first.Total, err) + } + second, err := s.ListPage(ctx, nil, first.Next, ListMax) + if err != nil || len(second.Collections) != 205 || second.Next != "" || second.Total != len(ids) { + t.Fatalf("second page %d, next %q, %v", len(second.Collections), second.Next, err) + } + all, err := s.List(ctx, nil) + if err != nil || !reflect.DeepEqual(summaryIDs(all), ids) { + t.Fatalf("List read %d of %d, %v", len(all), len(ids), err) + } + for _, limit := range []int{1, 7, 500, ListMax} { + if got := walkList(t, s, limit, nil); !reflect.DeepEqual(got, ids) { + t.Fatalf("pages of %d read %d of %d", limit, len(got), len(ids)) + } + } + // A page that ends exactly at the end has no next. + exact, err := s.ListPage(ctx, nil, "", len(ids)-ListMax) + if err != nil { + t.Fatal(err) + } + if last, err := s.ListPage(ctx, nil, encodeCursor(cursor{K: listCursor, S: exact.Collections[len(exact.Collections)-1].seq}), ListMax); err != nil || last.Next != "" { + t.Fatalf("last page %+v, %v", last.Next, err) + } +} + +// A WALK UNDER CONCURRENT CREATES AND DELETES NEVER REPEATS OR SKIPS A +// COLLECTION THAT WAS THERE WHEN IT BEGAN: every one not deleted is read +// exactly once, a deleted one not yet read is not read, and one created +// during the walk, if read at all, is read after every pre-existing one. +func TestListPagesAreStableUnderConcurrentCreatesAndDeletes(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "list.db") + s, peer := openTestStore(t, path), openTestStore(t, path) + ids := bulkCollections(t, s, "c", 300) + deleted := map[string]bool{} + created := map[string]bool{} + got := walkList(t, s, 25, func(page int) { + // Delete one already read and one not yet read, and create two. + for _, id := range []string{ids[page*25], ids[min(len(ids)-1, page*25+40)]} { + if !deleted[id] { + if _, err := peer.Delete(ctx, id); err != nil { + t.Fatal(err) + } + deleted[id] = true + } + } + for i := 0; i < 2; i++ { + c, err := peer.Create(ctx, fmt.Sprintf("new %d %d", page, i)) + if err != nil { + t.Fatal(err) + } + created[c.ID] = true + } + }) + seen := map[string]int{} + lastOld := -1 + for i, id := range got { + seen[id]++ + if !created[id] { + lastOld = i + } + } + for id, n := range seen { + if n != 1 { + t.Fatalf("%s read %d times", id, n) + } + } + for i, id := range ids { + if seen[id] == 0 && !deleted[id] { + t.Fatalf("pre-existing collection %d (%s) was skipped", i, id) + } + } + for i, id := range got { + if created[id] && i < lastOld { + t.Fatalf("a collection created during the walk was read before a pre-existing one") + } + } +} + +// walkTree reads every page of a collection's top level at limit, calling +// between after each page, and answers the ids in the order read. +func walkTree(t *testing.T, s *Store, id string, limit int, between func(page int)) []string { + t.Helper() + var ids []string + after := "" + for page := 0; ; page++ { + tree, err := s.Tree(context.Background(), id, 0, limit, after) + if err != nil { + t.Fatal(err) + } + ids = append(ids, nodeIDs(tree.Members)...) + if tree.Next == "" { + if tree.More { + t.Fatal("a last page says more") + } + return ids + } + if !tree.More { + t.Fatal("a page with a next does not say more") + } + if between != nil { + between(page) + } + after = tree.Next + if page > 10000 { + t.Fatal("the walk did not end") + } + } +} + +// AN OPENED COLLECTION'S PAGES EQUAL ONE READ IN ITS ORDER: newest added +// first, ties broken newest inserted first, rows with no moment (0, a +// migrated schema-1 row) last, and more than LevelMax members all reachable. +func TestOpenPagesWalkTheWholeOrder(t *testing.T) { + s := openTestStore(t, filepath.Join(t.TempDir(), "open.db")) + c := createTestCollection(t, s, "Big") + moments := make([]int64, 0, 1200) + for i := 0; i < 1200; i++ { + switch { + case i < 100: + moments = append(moments, 0) // unknown: last of all + case i%3 == 0: + moments = append(moments, 5000) // a long run of ties + default: + moments = append(moments, int64(1000+i)) + } + } + bulkMembers(t, s, c.ID, "m", moments) + rows, err := s.db.Query("SELECT ref_id FROM memberships WHERE collection_id=? ORDER BY added_at DESC, seq DESC", c.ID) + if err != nil { + t.Fatal(err) + } + var want []string + for rows.Next() { + var id string + if err := rows.Scan(&id); err != nil { + t.Fatal(err) + } + want = append(want, id) + } + rows.Close() + if want[len(want)-1] != "m-00000" { + t.Fatalf("the order the test expects is wrong: %v", want[len(want)-1]) + } + for _, limit := range []int{1, 13, LevelMax} { + if got := walkTree(t, s, c.ID, limit, nil); !reflect.DeepEqual(got, want) { + t.Fatalf("pages of %d read %d of %d", limit, len(got), len(want)) + } + } +} + +// AN OPENED COLLECTION'S WALK IS STABLE UNDER CONCURRENT CHANGE: members +// added during the walk are newer than any cursor and never appear on a +// later page, a member removed before its page is not read, and every other +// pre-existing member is read exactly once. +func TestOpenPagesAreStableUnderConcurrentAddsAndRemoves(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "open.db") + s, peer := openTestStore(t, path), openTestStore(t, path) + c := createTestCollection(t, s, "Busy") + moments := make([]int64, 600) + for i := range moments { + moments[i] = int64(1000 + i/2) // pairs of ties + } + bulkMembers(t, s, c.ID, "m", moments) + fixedClock(peer, 1_000_000) + removed, added := map[string]bool{}, map[string]bool{} + got := walkTree(t, s, c.ID, 40, func(page int) { + id := fmt.Sprintf("new-%d", page) + if err := peer.Add(ctx, c.ID, Ref{Kind: ConversationKind, ID: id}); err != nil { + t.Fatal(err) + } + added[id] = true + // The member 30 places past this page's end in the read order. + victim := fmt.Sprintf("m-%05d", 599-(page*40+40+30)) + if page*40+70 < 600 { + if err := peer.Remove(ctx, c.ID, Ref{Kind: ConversationKind, ID: victim}); err != nil { + t.Fatal(err) + } + removed[victim] = true + } + }) + seen := map[string]int{} + for _, id := range got { + if added[id] { + t.Fatalf("%s was added during the walk and appeared on a later page", id) + } + seen[id]++ + } + for i := 0; i < 600; i++ { + id := fmt.Sprintf("m-%05d", i) + switch { + case removed[id] && seen[id] != 0: + t.Fatalf("%s was removed before its page and still read", id) + case !removed[id] && seen[id] != 1: + t.Fatalf("%s read %d times", id, seen[id]) + } + } +} + +// A CURSOR THIS STORE DID NOT GIVE IS REFUSED, NEVER GUESSED AT: garbage, a +// listing's cursor spent on a collection, one collection's cursor spent on +// another, and an opened collection's spent on the listing. +func TestForeignAndMalformedCursorsAreRefused(t *testing.T) { + ctx := context.Background() + s := openTestStore(t, filepath.Join(t.TempDir(), "cursor.db")) + a, b := createTestCollection(t, s, "A"), createTestCollection(t, s, "B") + bulkMembers(t, s, a.ID, "m", []int64{1, 2, 3}) + tree, err := s.Tree(ctx, a.ID, 0, 1, "") + if err != nil || tree.Next == "" { + t.Fatalf("tree %+v, %v", tree, err) + } + page, err := s.ListPage(ctx, nil, "", 1) + if err != nil || page.Next == "" { + t.Fatalf("page %+v, %v", page, err) + } + for name, try := range map[string]func() error{ + "garbage list": func() error { _, err := s.ListPage(ctx, nil, "!!not a cursor", 1); return err }, + "garbage open": func() error { _, err := s.Tree(ctx, a.ID, 0, 1, "e30"); return err }, + "list cursor on open": func() error { _, err := s.Tree(ctx, a.ID, 0, 1, page.Next); return err }, + "open cursor on list": func() error { _, err := s.ListPage(ctx, nil, tree.Next, 1); return err }, + "another collection's": func() error { _, err := s.Tree(ctx, b.ID, 0, 1, tree.Next); return err }, + "trailing bytes": func() error { + _, err := s.ListPage(ctx, nil, encodeCursor(cursor{K: listCursor, S: 1})+"x", 1) + return err + }, + } { + if err := try(); !errors.Is(err, ErrInvalid) { + t.Errorf("%s: %v", name, err) + } + } +} + +// THE NODE BUDGET NEVER STRANDS THE TOP LEVEL: when it cuts the lists below, +// the tree says more, each cut or unopened child list says more, and the top +// level's cursor still continues exactly after its own last row. +func TestTheNodeBudgetCutsChildrenButTheTopLevelContinues(t *testing.T) { + ctx := context.Background() + s := openTestStore(t, filepath.Join(t.TempDir(), "budget.db")) + root := createTestCollection(t, s, "Root") + kids := bulkCollections(t, s, "k", LevelMax) + tx, err := s.db.Begin() + if err != nil { + t.Fatal(err) + } + for i, kid := range kids { + // Children newest, so the first page is all collections. + if _, err := tx.Exec("INSERT INTO memberships(collection_id,kind,ref_id,session_id,target_collection,added_at) VALUES (?,?,?,?,?,?)", + root.ID, CollectionKind, kid, "", kid, 10_000+i); err != nil { + t.Fatal(err) + } + for j := 0; j < 4; j++ { + if _, err := tx.Exec("INSERT INTO memberships(collection_id,kind,ref_id,session_id,added_at) VALUES (?,?,?,?,?)", + kid, ConversationKind, fmt.Sprintf("chat-%d-%d", i, j), "", 1+j); err != nil { + t.Fatal(err) + } + } + } + if err := tx.Commit(); err != nil { + t.Fatal(err) + } + bulkMembers(t, s, root.ID, "older", []int64{5, 4, 3, 2, 1}) + tree, err := s.Tree(ctx, root.ID, 1, LevelMax, "") + if err != nil { + t.Fatal(err) + } + if len(tree.Members) != LevelMax || !tree.More || tree.Next == "" { + t.Fatalf("first page %d, more %v, next %q", len(tree.Members), tree.More, tree.Next) + } + nodes, opened, unopened := LevelMax, 0, 0 + for _, n := range tree.Members { + if n.Children == nil { + t.Fatalf("%s within depth carries no children at all", n.ID) + } + nodes += len(n.Children.Members) + if len(n.Children.Members) == 4 && !n.Children.More { + opened++ + } else if !n.Children.More { + t.Fatalf("%s's cut list does not say more: %+v", n.ID, n.Children) + } else { + unopened++ + } + } + if nodes > TreeNodeMax || opened == 0 || unopened == 0 { + t.Fatalf("%d nodes, %d opened in full, %d cut", nodes, opened, unopened) + } + next, err := s.Tree(ctx, root.ID, 1, LevelMax, tree.Next) + if err != nil || next.Next != "" || next.More || !reflect.DeepEqual(nodeIDs(next.Members), []string{"older-00000", "older-00001", "older-00002", "older-00003", "older-00004"}) { + t.Fatalf("second page %v, more %v, next %q, %v", nodeIDs(next.Members), next.More, next.Next, err) + } +} + +// EXPANSION COUNTS WHAT IT DOES NOT ANSWER: a nested collection past the +// depth is unread, each once, and a nested reference to a collection that is +// gone is deleted. Cascading deletes mean only a damaged or foreign-written +// file can hold the latter, so the test writes one with foreign keys off. +func TestExpandCountsUnreadAndDeletedNestedCollections(t *testing.T) { + ctx := context.Background() + s := openTestStore(t, filepath.Join(t.TempDir(), "expand.db")) + root, a, b, c := createTestCollection(t, s, "Root"), createTestCollection(t, s, "A"), createTestCollection(t, s, "B"), createTestCollection(t, s, "C") + for _, edge := range [][2]string{{root.ID, a.ID}, {a.ID, b.ID}, {b.ID, c.ID}, {root.ID, b.ID}} { + if err := s.Add(ctx, edge[0], Ref{Kind: CollectionKind, ID: edge[1]}); err != nil { + t.Fatal(err) + } + } + if err := s.Add(ctx, c.ID, Ref{Kind: ConversationKind, ID: "deep"}); err != nil { + t.Fatal(err) + } + for depth, want := range map[int]int{1: 2, 2: 1, 3: 0} { + e, err := s.Expand(ctx, root.ID, depth) + if err != nil || e.Unread != want || e.Deleted != 0 { + t.Fatalf("depth %d: unread %d deleted %d, want %d unread, %v", depth, e.Unread, e.Deleted, want, err) + } + } + if _, err := s.db.Exec("PRAGMA foreign_keys=OFF"); err != nil { + t.Fatal(err) + } + if _, err := s.db.Exec("INSERT INTO memberships(collection_id,kind,ref_id,session_id,target_collection,added_at) VALUES (?,?,?,?,?,1)", + a.ID, CollectionKind, "gone", "", "gone"); err != nil { + t.Fatal(err) + } + if _, err := s.db.Exec("PRAGMA foreign_keys=ON"); err != nil { + t.Fatal(err) + } + if e, err := s.Expand(ctx, root.ID, 3); err != nil || e.Deleted != 1 || e.Unread != 0 || len(e.Refs) != 1 { + t.Fatalf("with a gone nested collection %+v, %v", e, err) + } +} + +// RELINK PUTS ONE REFERENCE IN ANOTHER'S PLACE IN ONE WRITE: the new one +// keeps the old one's moment and place; one already there just loses the +// old; a non-member is ErrNotMember; a collection is checked as Add checks +// one; relinking a member to itself changes nothing. +func TestRelinkKeepsThePlaceAndRefusesWhatAddRefuses(t *testing.T) { + ctx := context.Background() + s := openTestStore(t, filepath.Join(t.TempDir(), "relink.db")) + box, parent, child := createTestCollection(t, s, "Box"), createTestCollection(t, s, "Parent"), createTestCollection(t, s, "Child") + old, other := Ref{Kind: ArtifactKind, ID: "/old/report.md"}, Ref{Kind: ConversationKind, ID: "chat"} + for i, ref := range []Ref{other, old, {Kind: ConversationKind, ID: "last"}} { + fixedClock(s, int64(1000*(i+1))) + if err := s.Add(ctx, box.ID, ref); err != nil { + t.Fatal(err) + } + } + before, _ := s.Tree(ctx, box.ID, 0, 10, "") + moved := Ref{Kind: ArtifactKind, ID: "/new/report.md"} + m, already, err := s.Relink(ctx, box.ID, old, moved) + if err != nil || already || m.Ref != moved || m.AddedAt != 2000 { + t.Fatalf("relink %+v %v %v", m, already, err) + } + after, _ := s.Tree(ctx, box.ID, 0, 10, "") + if after.Members[1].Ref != moved || after.Members[1].AddedAt != 2000 || after.Members[1].seq != before.Members[1].seq { + t.Fatalf("the relinked member moved: %+v", after.Members) + } + if members, _ := s.Members(ctx, box.ID); members[1] != moved { + t.Fatalf("insertion order moved: %v", members) + } + if _, _, err := s.Relink(ctx, box.ID, old, moved); !errors.Is(err, ErrNotMember) { + t.Fatalf("relink of a non-member: %v", err) + } + m, already, err = s.Relink(ctx, box.ID, moved, other) + if err != nil || !already || m.Ref != other || m.AddedAt != 1000 { + t.Fatalf("relink onto a member %+v %v %v", m, already, err) + } + if members, _ := s.Members(ctx, box.ID); len(members) != 2 { + t.Fatalf("relink onto a member left %v", members) + } + if m, already, err := s.Relink(ctx, box.ID, other, other); err != nil || !already || m.AddedAt != 1000 { + t.Fatalf("relink to itself %+v %v %v", m, already, err) + } + if err := s.Add(ctx, child.ID, Ref{Kind: CollectionKind, ID: parent.ID}); err != nil { + t.Fatal(err) + } + if err := s.Add(ctx, parent.ID, Ref{Kind: ConversationKind, ID: "x"}); err != nil { + t.Fatal(err) + } + if _, _, err := s.Relink(ctx, parent.ID, Ref{Kind: ConversationKind, ID: "x"}, Ref{Kind: CollectionKind, ID: child.ID}); !errors.Is(err, ErrCycle) { + t.Fatalf("relink making a cycle: %v", err) + } + if _, _, err := s.Relink(ctx, parent.ID, Ref{Kind: ConversationKind, ID: "x"}, Ref{Kind: CollectionKind, ID: "nope"}); !errors.Is(err, ErrNotFound) { + t.Fatalf("relink to a collection that is not there: %v", err) + } + if members, _ := s.Members(ctx, parent.ID); len(members) != 1 || members[0].ID != "x" || hasCycle(t, s) { + t.Fatalf("a refused relink changed %v", members) + } + if _, _, err := s.Relink(ctx, "nope", other, moved); !errors.Is(err, ErrNotFound) { + t.Fatalf("relink in no collection: %v", err) + } +} diff --git a/internal/workspace/schema2_test.go b/internal/workspace/schema2_test.go index 81215afcfa..a4ab773ab5 100644 --- a/internal/workspace/schema2_test.go +++ b/internal/workspace/schema2_test.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "reflect" + "strings" "testing" "time" ) @@ -68,6 +69,17 @@ func userVersion(t *testing.T, s *Store) int { func fixedClock(s *Store, ms int64) { s.now = func() time.Time { return time.UnixMilli(ms) } } +// withoutPlaces is nodes without the file positions their cursors name, which +// differ between two collections holding the same references. +func withoutPlaces(nodes []Node) []Node { + out := make([]Node, len(nodes)) + for i, n := range nodes { + n.seq = 0 + out[i] = n + } + return out +} + // A SCHEMA-1 FILE IS READ AS IT STANDS AND MIGRATED BY THE FIRST WRITE: the // reads leave every byte alone and say nothing of when anything was added, // and the first write rebuilds both tables in one transaction, keeping every @@ -84,7 +96,7 @@ func TestSchemaOneFileIsReadThenMigratedInPlaceByTheFirstWrite(t *testing.T) { if err != nil || len(list) != 2 || list[0].Count != 3 || list[0].LastAddedAt != 0 || list[0].CreatedAt != 0 || !*list[0].Holds || *list[1].Holds { t.Fatalf("legacy list %+v, %v", list, err) } - tree, err := s.Tree(ctx, "aaaa", 1, 10) + tree, err := s.Tree(ctx, "aaaa", 1, 10, "") if err != nil || len(tree.Members) != 3 || tree.Members[0].Kind != CollectionKind || tree.Members[0].Children == nil || len(tree.Members[0].Children.Members) != 1 { t.Fatalf("legacy tree %+v, %v", tree, err) } @@ -219,9 +231,9 @@ func TestDuplicateCopiesMembershipsWithTheirMoments(t *testing.T) { if err != nil { t.Fatal(err) } - a, _ := s.Tree(ctx, src.ID, 0, 10) - b, _ := s.Tree(ctx, copy.ID, 0, 10) - if !reflect.DeepEqual(a.Members, b.Members) || len(b.Members) != 3 || b.Members[0].AddedAt != 3000 { + a, _ := s.Tree(ctx, src.ID, 0, 10, "") + b, _ := s.Tree(ctx, copy.ID, 0, 10, "") + if !reflect.DeepEqual(withoutPlaces(a.Members), withoutPlaces(b.Members)) || len(b.Members) != 3 || b.Members[0].AddedAt != 3000 { t.Fatalf("source %+v\ncopy %+v", a.Members, b.Members) } if b.Collection.CreatedAt != 9000 || b.Collection.Count != 3 || b.Collection.LastAddedAt != 3000 { @@ -286,7 +298,7 @@ func TestRemoveThenRestoreKeepsThePlace(t *testing.T) { if err != nil || added[0].AddedAt != 200 { t.Fatalf("restore %v, %v", added, err) } - tree, _ := s.Tree(ctx, c.ID, 0, 10) + tree, _ := s.Tree(ctx, c.ID, 0, 10, "") var order []string for _, n := range tree.Members { order = append(order, n.ID) @@ -364,16 +376,16 @@ func TestTreeHonoursLimitDepthAndTheNodeCap(t *testing.T) { if _, _, err := s.AddRefs(ctx, mid.ID, many); err != nil { t.Fatal(err) } - tree, err := s.Tree(ctx, root.ID, 0, 500) + tree, err := s.Tree(ctx, root.ID, 0, 500, "") if err != nil || len(tree.Members) != 1 || tree.Members[0].Children != nil || tree.Members[0].Count != 1701 || tree.Members[0].Name != "Mid" { t.Fatalf("depth 0 %+v, %v", tree.Members, err) } - tree, _ = s.Tree(ctx, root.ID, 1, 2) + tree, _ = s.Tree(ctx, root.ID, 1, 2, "") kids := tree.Members[0].Children if kids == nil || len(kids.Members) != 2 || !kids.More || kids.Members[0].ID != leaf.ID || kids.Members[1].ID != "chat-1699" { t.Fatalf("depth 1 limit 2 %+v", kids) } - tree, _ = s.Tree(ctx, root.ID, 2, 500) + tree, _ = s.Tree(ctx, root.ID, 2, 500, "") total := 0 var count func(l *Level) count = func(l *Level) { @@ -392,7 +404,7 @@ func TestTreeHonoursLimitDepthAndTheNodeCap(t *testing.T) { if _, err := s.db.Exec("INSERT INTO memberships(collection_id,kind,ref_id,session_id,target_collection) VALUES (?,?,?,?,?)", leaf.ID, "collection", root.ID, "", root.ID); err != nil { t.Fatal(err) } - if _, err := s.Tree(ctx, root.ID, 2, 1); err != nil { + if _, err := s.Tree(ctx, root.ID, 2, 1, ""); err != nil { t.Fatal(err) } if e, err := s.Expand(ctx, root.ID, 3); err != nil || e.Collections != 2 { @@ -416,3 +428,208 @@ func TestProcessAndCrewReferencesValidate(t *testing.T) { } } } + +// legacyRows is every row of a schema-1 or schema-2 file in seq order, the +// columns both schemas share, so a migration can be compared row for row. +func legacyRows(t *testing.T, db *sql.DB) (collections, memberships []string) { + t.Helper() + read := func(query string) []string { + rows, err := db.Query(query) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + var out []string + for rows.Next() { + var a, b, c, d, e, f sql.NullString + if err := rows.Scan(&a, &b, &c, &d, &e, &f); err != nil { + t.Fatal(err) + } + out = append(out, fmt.Sprint(a.String, "|", b.String, "|", c.String, "|", d.String, "|", e.String, "|", f.String, "|", f.Valid)) + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + return out + } + return read("SELECT seq,id,name,'','','' FROM collections ORDER BY seq"), + read("SELECT seq,collection_id,kind,ref_id,session_id,target_collection FROM memberships ORDER BY seq") +} + +// A MIGRATION THAT FAILS PARTWAY LEAVES THE SCHEMA-1 FILE AS IT WAS, byte +// for byte, still readable with every row. The failure is a real one, not a +// hook: a view that names the old memberships table lets the old tables be +// dropped and then makes SQLite refuse the rename that follows ("error in +// view ...: no such table: main.memberships"), so the transaction has +// already copied, dropped and half rebuilt when it fails. +func TestAMigrationThatFailsPartwayLeavesTheSchemaOneFileWhole(t *testing.T) { + ctx := context.Background() + path := writeLegacyStore(t) + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + if _, err := db.Exec("CREATE VIEW stale_members AS SELECT kind FROM memberships"); err != nil { + t.Fatal(err) + } + wantCollections, wantMemberships := legacyRows(t, db) + if err := db.Close(); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + s := openTestStore(t, path) + if _, err := s.Create(ctx, "New"); err == nil || !strings.Contains(err.Error(), "main.memberships") { + t.Fatalf("the injected failure did not fire after the old tables were dropped: %v", err) + } + if err := s.Add(ctx, "aaaa", Ref{Kind: ConversationKind, ID: "chat-2"}); err == nil { + t.Fatal("a write migrated past the failure") + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(after) != string(before) { + t.Fatal("a failed migration changed the schema-1 file") + } + if userVersion(t, s) != legacySchemaVersion { + t.Fatal("a failed migration moved the version") + } + gotCollections, gotMemberships := legacyRows(t, s.db) + if !reflect.DeepEqual(gotCollections, wantCollections) || !reflect.DeepEqual(gotMemberships, wantMemberships) { + t.Fatalf("rows after a failed migration\n%v\n%v", gotCollections, gotMemberships) + } + other := openTestStore(t, path) + if list, err := other.List(ctx, nil); err != nil || len(list) != 2 || list[0].Count != 3 { + t.Fatalf("a fresh handle reads %+v, %v", list, err) + } +} + +// A MIGRATION THAT SUCCEEDS KEEPS EVERY ROW: the same seq, collection, kind +// (every schema-1 kind, the legacy standing one included), reference, +// session and target, in the same order, for a file with more than a handful +// of rows. +func TestASuccessfulMigrationKeepsEveryRowInOrder(t *testing.T) { + ctx := context.Background() + path := writeLegacyStore(t) + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + tx, err := db.Begin() + if err != nil { + t.Fatal(err) + } + for i := 0; i < 300; i++ { + var q string + switch i % 5 { + case 0: + q = fmt.Sprintf("INSERT INTO memberships(collection_id,kind,ref_id,session_id) VALUES ('aaaa','conversation','chat-x%d','')", i) + case 1: + q = fmt.Sprintf("INSERT INTO memberships(collection_id,kind,ref_id,session_id) VALUES ('bbbb','task','%d','chat-1')", i) + case 2: + q = fmt.Sprintf("INSERT INTO memberships(collection_id,kind,ref_id,session_id) VALUES ('aaaa','standing','s%d','')", i) + case 3: + q = fmt.Sprintf("INSERT INTO memberships(collection_id,kind,ref_id,session_id) VALUES ('bbbb','artifact','/tmp/f%d','')", i) + case 4: + q = fmt.Sprintf("INSERT INTO collections(id,name) VALUES ('c%d','C %d')", i, i) + } + if _, err := tx.Exec(q); err != nil { + t.Fatal(err) + } + } + if err := tx.Commit(); err != nil { + t.Fatal(err) + } + wantCollections, wantMemberships := legacyRows(t, db) + if err := db.Close(); err != nil { + t.Fatal(err) + } + s := openTestStore(t, path) + legacyTree, err := s.Tree(ctx, "aaaa", 0, LevelMax, "") + if err != nil { + t.Fatal(err) + } + if _, err := s.Create(ctx, "After"); err != nil { + t.Fatal(err) + } + if userVersion(t, s) != schemaVersion { + t.Fatal("the write did not migrate") + } + gotCollections, gotMemberships := legacyRows(t, s.db) + if !reflect.DeepEqual(gotCollections[:len(wantCollections)], wantCollections) || len(gotCollections) != len(wantCollections)+1 { + t.Fatalf("collections after migration differ:\n%v\n%v", gotCollections, wantCollections) + } + if !reflect.DeepEqual(gotMemberships, wantMemberships) { + t.Fatalf("memberships after migration differ") + } + migratedTree, err := s.Tree(ctx, "aaaa", 0, LevelMax, "") + if err != nil || !reflect.DeepEqual(migratedTree.Members, legacyTree.Members) { + t.Fatalf("the opened order moved across the migration: %v", err) + } +} + +// NAMES FOLD AS strings.EqualFold DOES, simple one-rune folding with no +// locale, and these are the cases a person would ask about. Each pair is +// created in a fresh store; taken says the second is refused. +func TestCollectionNamesFoldAsSimpleUnicodeCaseFolding(t *testing.T) { + ctx := context.Background() + for _, c := range []struct { + first, second string + taken bool + }{ + {"Ärger", "ärger", true}, + {"ΣΊΣΥΦΟΣ", "σίσυφος", true}, // the final ς folds with Σ and σ + {"σίσυφοσ", "σίσυφος", true}, // a medial σ written last is the same name + {"Kelvin", "Kelvin", true}, // the Kelvin sign folds to K + {"İstanbul", "istanbul", false}, // dotted İ lowers to two runes: simple folding keeps them apart + {"İstanbul", "İSTANBUL", true}, + {"Straße", "STRASSE", false}, // ß to SS is full folding, which this is not + {"café", "café", false}, // no normalization: precomposed and combining differ + } { + s := openTestStore(t, filepath.Join(t.TempDir(), "fold.db")) + first := createTestCollection(t, s, c.first) + _, err := s.Create(ctx, c.second) + if c.taken != errors.Is(err, ErrNameTaken) || (!c.taken && err != nil) { + t.Errorf("%q then %q: %v, want taken=%v", c.first, c.second, err, c.taken) + } + if c.taken { + if err := s.Rename(ctx, first.ID, c.second); err != nil { + t.Errorf("renaming %q to its own name %q: %v", c.first, c.second, err) + } + } + } +} + +// TWO PROCESSES CREATING NAMES THAT DIFFER ONLY IN CASE, at once, on one +// file: exactly one of each pair wins and the other is told the name is +// taken. +func TestConcurrentCaseOnlyCreatesFromTwoHandlesLetExactlyOneWin(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "race.db") + first, second := openTestStore(t, path), openTestStore(t, path) + createTestCollection(t, first, "seed") + const rounds = 30 + for i := 0; i < rounds; i++ { + names := []string{fmt.Sprintf("Ärger %d", i), fmt.Sprintf("äRGER %d", i)} + start := make(chan struct{}) + errs := make(chan error, 2) + for j, s := range []*Store{first, second} { + go func(name string, s *Store) { + <-start + _, err := s.Create(ctx, name) + errs <- err + }(names[j], s) + } + close(start) + e1, e2 := <-errs, <-errs + if !((e1 == nil && errors.Is(e2, ErrNameTaken)) || (e2 == nil && errors.Is(e1, ErrNameTaken))) { + t.Fatalf("round %d: %v, %v", i, e1, e2) + } + } + if list, err := first.List(ctx, nil); err != nil || len(list) != rounds+1 { + t.Fatalf("%d collections, %v", len(list), err) + } +} diff --git a/internal/workspace/store.go b/internal/workspace/store.go index e631292269..786eac2573 100644 --- a/internal/workspace/store.go +++ b/internal/workspace/store.go @@ -376,6 +376,14 @@ func newID() (string, error) { // // THE COMPARISON IS UNICODE CASE FOLDING IN GO, not SQLite's NOCASE, which // folds only ASCII: "Ärger" and "ärger" are one name to a person. +// +// IT IS strings.EqualFold, WHICH IS SIMPLE FOLDING, ONE RUNE FOR ONE RUNE, +// with no locale and no normalization. So "ΣΊΣΥΦΟΣ" and "σίσυφος" are one +// name (Σ, σ and the final ς fold together), and so are "K" and the Kelvin +// sign. What simple folding cannot see stays apart: "İstanbul" (a dotted +// capital I, whose lower case is two runes) and "istanbul" are two names, as +// are "Straße" and "STRASSE", and a precomposed "é" and an "e" followed by a +// combining accent. schema2_test pins every one of these. func nameTaken(ctx context.Context, tx *sql.Tx, name, except string) error { rows, err := tx.QueryContext(ctx, "SELECT id,name FROM collections") if err != nil { @@ -623,24 +631,9 @@ func (s *Store) AddRefs(ctx context.Context, id string, refs []Addition) ([]Memb for _, ref := range refs { var target any if ref.Kind == CollectionKind { - if err := requireCollection(ctx, tx, ref.ID); err != nil { - return nil, nil, storeError(err) - } - // THE CYCLE CHECK AND INSERT SHARE THE WRITER TRANSACTION. Two - // processes cannot both approve opposite edges against an old - // snapshot, and an earlier reference of this same call is - // already in the graph the next one is checked against. - var cycle bool - err := tx.QueryRowContext(ctx, `WITH RECURSIVE descendants(id) AS ( - VALUES (?) UNION - SELECT m.ref_id FROM memberships m JOIN descendants d ON m.collection_id=d.id WHERE m.kind='collection' -) SELECT EXISTS(SELECT 1 FROM descendants WHERE id=?)`, ref.ID, id).Scan(&cycle) - if err != nil { + if err := nestable(ctx, tx, id, ref.ID); err != nil { return nil, nil, storeError(err) } - if cycle { - return nil, nil, storeError(ErrCycle) - } target = ref.ID } at := ref.AddedAt @@ -668,6 +661,105 @@ func (s *Store) AddRefs(ctx context.Context, id string, refs []Addition) ([]Memb return added, already, nil } +// nestable refuses putting the collection child inside id: ErrNotFound when +// child does not exist, ErrCycle when child already holds id at any depth +// (or is id). +// +// THE CYCLE CHECK AND THE WRITE SHARE THE CALLER'S WRITER TRANSACTION. Two +// processes cannot both approve opposite edges against an old snapshot, and +// an earlier reference of the same call is already in the graph the next one +// is checked against. +func nestable(ctx context.Context, tx *sql.Tx, id, child string) error { + if err := requireCollection(ctx, tx, child); err != nil { + return err + } + var cycle bool + err := tx.QueryRowContext(ctx, `WITH RECURSIVE descendants(id) AS ( + VALUES (?) UNION + SELECT m.ref_id FROM memberships m JOIN descendants d ON m.collection_id=d.id WHERE m.kind='collection' +) SELECT EXISTS(SELECT 1 FROM descendants WHERE id=?)`, child, id).Scan(&cycle) + if err != nil { + return err + } + if cycle { + return ErrCycle + } + return nil +} + +// Relink puts to where from is in collection id, in one writer transaction: +// to takes from's row, so its added_at and its place in every order are +// from's. When to is already a member, from is simply removed and already +// is true, and the member answered is to as it was. from that is not a member +// is ErrNotMember; a collection to is checked as Add checks one (ErrNotFound, +// ErrCycle). Whether a to of any other kind is really there now is its +// owner's question, asked by the caller before this; the store keeps +// references, never the things. +func (s *Store) Relink(ctx context.Context, id string, from, to Ref) (Member, bool, error) { + if err := from.Validate(); err != nil { + return Member{}, false, storeError(err) + } + if err := to.Validate(); err != nil { + return Member{}, false, storeError(err) + } + ready, err := s.readyForWrite(ctx) + if err != nil { + return Member{}, false, storeError(err) + } + if !ready { + return Member{}, false, storeError(ErrNotFound) + } + tx, err := s.db.BeginTx(ctx, nil) + if err != nil { + return Member{}, false, storeError(err) + } + defer tx.Rollback() + if err := requireCollection(ctx, tx, id); err != nil { + return Member{}, false, storeError(err) + } + membership := func(ref Ref) (seq, at int64, err error) { + err = tx.QueryRowContext(ctx, "SELECT seq,added_at FROM memberships WHERE collection_id=? AND kind=? AND ref_id=? AND session_id=?", + id, ref.Kind, ref.ID, ref.SessionID).Scan(&seq, &at) + return seq, at, err + } + seq, at, err := membership(from) + if errors.Is(err, sql.ErrNoRows) { + return Member{}, false, storeError(ErrNotMember) + } + if err != nil { + return Member{}, false, storeError(err) + } + if from.key() == to.key() { + return Member{Ref: to, AddedAt: at}, true, nil + } + if _, toAt, err := membership(to); err == nil { + if _, err := tx.ExecContext(ctx, "DELETE FROM memberships WHERE seq=?", seq); err != nil { + return Member{}, false, storeError(err) + } + if err := tx.Commit(); err != nil { + return Member{}, false, storeError(err) + } + return Member{Ref: to, AddedAt: toAt}, true, nil + } else if !errors.Is(err, sql.ErrNoRows) { + return Member{}, false, storeError(err) + } + var target any + if to.Kind == CollectionKind { + if err := nestable(ctx, tx, id, to.ID); err != nil { + return Member{}, false, storeError(err) + } + target = to.ID + } + if _, err := tx.ExecContext(ctx, "UPDATE memberships SET kind=?,ref_id=?,session_id=?,target_collection=? WHERE seq=?", + to.Kind, to.ID, to.SessionID, target, seq); err != nil { + return Member{}, false, storeError(err) + } + if err := tx.Commit(); err != nil { + return Member{}, false, storeError(err) + } + return Member{Ref: to, AddedAt: at}, false, nil +} + // Remove detaches a reference; it never removes or stops the referenced work. func (s *Store) Remove(ctx context.Context, id string, ref Ref) error { _, err := s.RemoveRefs(ctx, id, []Ref{ref}) diff --git a/internal/workspace/store_test.go b/internal/workspace/store_test.go index 3ff324b598..23908597b2 100644 --- a/internal/workspace/store_test.go +++ b/internal/workspace/store_test.go @@ -269,3 +269,66 @@ func TestBusyWriterReturnsWithoutLosingPriorState(t *testing.T) { t.Fatalf("failed write changed state: %v, %v", members, err) } } + +// hasCycle asks the file itself whether any collection reaches itself. +func hasCycle(t *testing.T, s *Store) bool { + t.Helper() + var cycle bool + err := s.db.QueryRow(`WITH RECURSIVE reach(start, id) AS ( + SELECT collection_id, ref_id FROM memberships WHERE kind='collection' + UNION SELECT r.start, m.ref_id FROM memberships m JOIN reach r ON m.collection_id=r.id WHERE m.kind='collection' +) SELECT EXISTS(SELECT 1 FROM reach WHERE start=id)`).Scan(&cycle) + if err != nil { + t.Fatal(err) + } + return cycle +} + +// THE OPPOSITE-EDGE RACE IS LOST BY EXACTLY ONE SIDE EVERY TIME. Two handles +// (two processes, as far as SQLite can tell) add A→B and B→A at the same +// moment, round after round with the winning edge taken out again between +// rounds: never do both succeed, and the file never holds a cycle. +func TestOppositeEdgesRacedManyTimesNeverBothLand(t *testing.T) { + ctx := context.Background() + path := filepath.Join(t.TempDir(), "race.db") + first, second := openTestStore(t, path), openTestStore(t, path) + a, b := createTestCollection(t, first, "A"), createTestCollection(t, first, "B") + for round := 0; round < 60; round++ { + start := make(chan struct{}) + errs := make([]error, 2) + var wg sync.WaitGroup + for i, s := range []*Store{first, second} { + wg.Add(1) + go func(i int, s *Store) { + defer wg.Done() + from, to := a.ID, b.ID + if i == 1 { + from, to = to, from + } + <-start + errs[i] = s.Add(ctx, from, Ref{Kind: CollectionKind, ID: to}) + }(i, s) + } + close(start) + wg.Wait() + won := -1 + switch { + case errs[0] == nil && errors.Is(errs[1], ErrCycle): + won = 0 + case errs[1] == nil && errors.Is(errs[0], ErrCycle): + won = 1 + default: + t.Fatalf("round %d: %v, %v", round, errs[0], errs[1]) + } + if hasCycle(t, first) { + t.Fatalf("round %d left a cycle in the file", round) + } + from, to := a.ID, b.ID + if won == 1 { + from, to = to, from + } + if err := second.Remove(ctx, from, Ref{Kind: CollectionKind, ID: to}); err != nil { + t.Fatal(err) + } + } +} diff --git a/internal/workspace/views.go b/internal/workspace/views.go index 50dce3d326..1f235ad2c8 100644 --- a/internal/workspace/views.go +++ b/internal/workspace/views.go @@ -1,18 +1,25 @@ package workspace import ( + "bytes" "context" "database/sql" + "encoding/base64" + "encoding/json" "fmt" + "strings" ) // The bounds of the reads below. Each read is bounded by itself, whatever the // caller asks for, because a collection can hold collections and a person can // file thousands of references: nothing here walks or answers without limit. const ( - // ListMax is the most collections one listing answers. + // ListMax is the most collections one page of a listing answers. A + // listing is never cut silently: a page that stops short of the end says + // where the next one starts. ListMax = 1000 - // LevelMax is the most members one level of a tree answers. + // LevelMax is the most members one level of a tree answers; the top + // level of a tree continues from where a page stopped. LevelMax = 500 // TreeDepthMax is how many levels below the top a tree opens. TreeDepthMax = 2 @@ -35,31 +42,143 @@ type Summary struct { Count int LastAddedAt int64 Holds *bool + // seq is the row's place in the file, which a page cursor names. + seq int64 } -// List answers every collection, oldest first, at most ListMax. With holds it -// also says whether each directly holds that reference. A blank file is no -// collections, and reading it leaves it blank. -func (s *Store) List(ctx context.Context, holds *Ref) ([]Summary, error) { +// Page is one page of a listing: at most the limit asked, Next the cursor +// the following page starts from (empty when this page reached the end), and +// Total every collection in the file as the same read saw it. +type Page struct { + Collections []Summary + Next string + Total int +} + +// ── page cursors ─────────────────────────────────────────────────────────── +// +// A CURSOR NAMES THE LAST ROW A PAGE ANSWERED, NEVER AN OFFSET. An offset +// moves under a peer's create or delete, which is how a page repeats a row or +// silently steps over one; a row's own key does not move. A listing's key is +// the collection's seq (AUTOINCREMENT, so never reused and always growing), +// and an opened collection's is the order it is read in, (added_at, seq), +// with the collection it belongs to so a cursor cannot be spent on another. +// The encoding is opaque to callers and checked strictly here: a cursor this +// store did not give is refused as invalid rather than read as a guess. + +const ( + listCursor = "list" + openCursor = "open" + // cursorMax is the longest cursor read; one this store gives is far + // shorter whatever the collection id. + cursorMax = 8192 +) + +type cursor struct { + K string `json:"k"` + C string `json:"c,omitempty"` + A int64 `json:"a,omitempty"` + S int64 `json:"s"` +} + +var errCursor = fmt.Errorf("%w: that page cursor was not given by this listing", ErrInvalid) + +func encodeCursor(c cursor) string { + raw, _ := json.Marshal(c) + return base64.RawURLEncoding.EncodeToString(raw) +} + +// decodeCursor reads a cursor of the kind named, or errCursor. +func decodeCursor(text, kind string) (cursor, error) { + if text == "" || len(text) > cursorMax { + return cursor{}, errCursor + } + raw, err := base64.RawURLEncoding.DecodeString(text) + if err != nil { + return cursor{}, errCursor + } + dec := json.NewDecoder(bytes.NewReader(raw)) + dec.DisallowUnknownFields() + var c cursor + if err := dec.Decode(&c); err != nil || dec.More() { + return cursor{}, errCursor + } + if c.K != kind || c.S <= 0 || c.A < 0 || (kind == listCursor && (c.C != "" || c.A != 0)) || (kind == openCursor && c.C == "") { + return cursor{}, errCursor + } + return c, nil +} + +// ListPage answers one page of collections, oldest first: at most limit +// (ListMax when limit is outside 1–ListMax), after the cursor a previous page +// gave (from the start when after is empty). With holds it also says whether +// each directly holds that reference. A blank file is no collections, and +// reading it leaves it blank. +// +// A WALK OF PAGES NEVER REPEATS OR SKIPS A COLLECTION THAT EXISTED WHEN IT +// BEGAN, whatever peers create or delete meanwhile: one created during the +// walk lands after every page already read, and one deleted is simply not +// there to answer. +func (s *Store) ListPage(ctx context.Context, holds *Ref, after string, limit int) (Page, error) { if holds != nil { if err := holds.Validate(); err != nil { - return nil, storeError(err) + return Page{}, storeError(err) } } + if limit <= 0 || limit > ListMax { + limit = ListMax + } + var from int64 + if after != "" { + c, err := decodeCursor(after, listCursor) + if err != nil { + return Page{}, err + } + from = c.S + } version, err := s.readyForRead(ctx) if err != nil { - return nil, storeError(err) + return Page{}, storeError(err) } if version == 0 { - return make([]Summary, 0), nil + return Page{Collections: make([]Summary, 0)}, nil } tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) if err != nil { - return nil, storeError(err) + return Page{}, storeError(err) } defer tx.Rollback() - out, err := summaries(ctx, tx, version, holds, "", ListMax) - return out, storeError(err) + var page Page + if err := tx.QueryRowContext(ctx, "SELECT count(*) FROM collections").Scan(&page.Total); err != nil { + return Page{}, storeError(err) + } + out, err := summaries(ctx, tx, version, holds, "", from, limit+1) + if err != nil { + return Page{}, storeError(err) + } + if len(out) > limit { + out = out[:limit] + page.Next = encodeCursor(cursor{K: listCursor, S: out[limit-1].seq}) + } + page.Collections = out + return page, nil +} + +// List answers every collection, oldest first, walking ListPage to the end. +func (s *Store) List(ctx context.Context, holds *Ref) ([]Summary, error) { + all := make([]Summary, 0) + after := "" + for { + page, err := s.ListPage(ctx, holds, after, ListMax) + if err != nil { + return nil, err + } + all = append(all, page.Collections...) + if page.Next == "" { + return all, nil + } + after = page.Next + } } // Describe is one collection as List says it. @@ -76,7 +195,7 @@ func (s *Store) Describe(ctx context.Context, id string) (Summary, error) { return Summary{}, storeError(err) } defer tx.Rollback() - out, err := summaries(ctx, tx, version, nil, id, 1) + out, err := summaries(ctx, tx, version, nil, id, 0, 1) if err != nil { return Summary{}, storeError(err) } @@ -86,26 +205,35 @@ func (s *Store) Describe(ctx context.Context, id string) (Summary, error) { return out[0], nil } -// summaries reads collections as Summary through one view: every one, or the -// one named by only. -func summaries(ctx context.Context, tx *sql.Tx, version int, holds *Ref, only string, limit int) ([]Summary, error) { +// summaries reads collections as Summary through one view: every one after +// the seq afterSeq (0 from the start), or the one named by only. +func summaries(ctx context.Context, tx *sql.Tx, version int, holds *Ref, only string, afterSeq int64, limit int) ([]Summary, error) { created, added := columns(version) - held, where := "0", "" + held := "0" args := []any{} if holds != nil { held = "EXISTS(SELECT 1 FROM memberships h WHERE h.collection_id=c.id AND h.kind=? AND h.ref_id=? AND h.session_id=?)" args = append(args, holds.Kind, holds.ID, holds.SessionID) } + var where []string if only != "" { - where = "WHERE c.id=?" + where = append(where, "c.id=?") args = append(args, only) } + if afterSeq > 0 { + where = append(where, "c.seq>?") + args = append(args, afterSeq) + } + clause := "" + if len(where) > 0 { + clause = "WHERE " + strings.Join(where, " AND ") + } args = append(args, limit) - rows, err := tx.QueryContext(ctx, fmt.Sprintf(`SELECT c.id,c.name,%s, + rows, err := tx.QueryContext(ctx, fmt.Sprintf(`SELECT c.seq,c.id,c.name,%s, (SELECT count(*) FROM memberships m WHERE m.collection_id=c.id), (SELECT coalesce(max(%s),0) FROM memberships m WHERE m.collection_id=c.id), %s -FROM collections c %s ORDER BY c.seq LIMIT ?`, created, added, held, where), args...) +FROM collections c %s ORDER BY c.seq LIMIT ?`, created, added, held, clause), args...) if err != nil { return nil, err } @@ -114,7 +242,7 @@ FROM collections c %s ORDER BY c.seq LIMIT ?`, created, added, held, where), arg for rows.Next() { var c Summary var h bool - if err := rows.Scan(&c.ID, &c.Name, &c.CreatedAt, &c.Count, &c.LastAddedAt, &h); err != nil { + if err := rows.Scan(&c.seq, &c.ID, &c.Name, &c.CreatedAt, &c.Count, &c.LastAddedAt, &h); err != nil { return nil, err } if holds != nil { @@ -134,6 +262,8 @@ type Node struct { Count int Found bool Children *Level + // seq is the row's place in the file, which a page cursor names. + seq int64 } // Level is one collection's members as a tree answers them, newest added @@ -143,24 +273,53 @@ type Level struct { More bool } -// Tree is a collection and its members, opened to a depth. +// Tree is a collection and its members, opened to a depth. More says +// something was cut: the top level stopped at its limit, or the node budget +// cut a list below it. Next is the cursor the top level continues from, empty +// when the top level reached its end. type Tree struct { Collection Summary Level + Next string +} + +// levelAfter is where a page of one level starts: after the row with this +// added_at and seq in the level's order. +type levelAfter struct { + added, seq int64 } // Tree opens a collection: its members newest added first, at most limit per // level, and the members of each collection member down to depth levels below -// the top (0 opens none), at most TreeNodeMax nodes in all. +// the top (0 opens none), at most TreeNodeMax nodes in all. after is a cursor +// a previous Tree of the same collection gave as Next, and continues its top +// level; a cursor of another collection, or of a listing, is ErrInvalid. // // A COLLECTION ALREADY ON THE PATH IS NEVER OPENED AGAIN. Writes refuse a // cycle, so none should exist, but a tree is a read that must end whatever // the file says, so it carries its own path rather than trusting the writer. -func (s *Store) Tree(ctx context.Context, id string, depth, limit int) (Tree, error) { +// +// THE NODE BUDGET NEVER STRANDS THE TOP LEVEL. The top level is read first +// and its cursor is its own last row, so a page whose children were cut short +// still continues exactly where its top level stopped. A child list the +// budget cut, or a collection within depth the budget left unopened, says +// More, and so does the tree. +func (s *Store) Tree(ctx context.Context, id string, depth, limit int, after string) (Tree, error) { depth = max(0, min(depth, TreeDepthMax)) if limit <= 0 || limit > LevelMax { limit = LevelMax } + var start *levelAfter + if after != "" { + c, err := decodeCursor(after, openCursor) + if err != nil { + return Tree{}, err + } + if c.C != id { + return Tree{}, errCursor + } + start = &levelAfter{added: c.A, seq: c.S} + } version, err := s.readyForRead(ctx) if err != nil { return Tree{}, storeError(err) @@ -173,7 +332,7 @@ func (s *Store) Tree(ctx context.Context, id string, depth, limit int) (Tree, er return Tree{}, storeError(err) } defer tx.Rollback() - top, err := summaries(ctx, tx, version, nil, id, 1) + top, err := summaries(ctx, tx, version, nil, id, 0, 1) if err != nil { return Tree{}, storeError(err) } @@ -181,13 +340,18 @@ func (s *Store) Tree(ctx context.Context, id string, depth, limit int) (Tree, er return Tree{}, storeError(ErrNotFound) } budget := TreeNodeMax + cut := false path := map[string]bool{id: true} - var open func(id string, level int) (*Level, error) - open = func(id string, level int) (*Level, error) { - nodes, more, err := levelOf(ctx, tx, version, id, min(limit, budget)) + var open func(id string, level int, start *levelAfter) (*Level, error) + open = func(id string, level int, start *levelAfter) (*Level, error) { + want := min(limit, budget) + nodes, more, err := levelOf(ctx, tx, version, id, want, start) if err != nil { return nil, err } + if more && want < limit { + cut = true + } budget -= len(nodes) out := &Level{Members: nodes, More: more} if level > depth { @@ -195,11 +359,17 @@ func (s *Store) Tree(ctx context.Context, id string, depth, limit int) (Tree, er } for i := range out.Members { n := &out.Members[i] - if n.Kind != CollectionKind || !n.Found || path[n.ID] || budget <= 0 { + if n.Kind != CollectionKind || !n.Found || path[n.ID] { + continue + } + if budget <= 0 { + // Within depth and not opened: the list is cut, not empty. + n.Children = &Level{Members: make([]Node, 0), More: n.Count > 0} + cut = cut || n.Count > 0 continue } path[n.ID] = true - children, err := open(n.ID, level+1) + children, err := open(n.ID, level+1, nil) delete(path, n.ID) if err != nil { return nil, err @@ -208,34 +378,51 @@ func (s *Store) Tree(ctx context.Context, id string, depth, limit int) (Tree, er } return out, nil } - level, err := open(id, 1) + level, err := open(id, 1, start) if err != nil { return Tree{}, storeError(err) } - return Tree{Collection: top[0], Level: *level}, nil + tree := Tree{Collection: top[0], Level: *level} + if level.More && len(level.Members) > 0 { + last := level.Members[len(level.Members)-1] + tree.Next = encodeCursor(cursor{K: openCursor, C: id, A: last.AddedAt, S: last.seq}) + } + tree.More = level.More || cut + return tree, nil } // levelOf reads one collection's members, newest added first, at most limit, -// with the name and count of each collection member, and whether there were -// more. A row with no known added_at sorts after every known one. -func levelOf(ctx context.Context, tx *sql.Tx, version int, id string, limit int) ([]Node, bool, error) { +// after start when it is given, with the name and count of each collection +// member, and whether there were more. A row with no known added_at (0) +// sorts after every known one, which is what the plain descending order on +// (added_at, seq) already says. +func levelOf(ctx context.Context, tx *sql.Tx, version int, id string, limit int, start *levelAfter) ([]Node, bool, error) { nodes := make([]Node, 0) + _, added := columns(version) + from, args := "", []any{id} + if start != nil { + // The keyset condition on the same (added_at, seq) the order reads. + // On a schema-1 file added is the constant 0 and so is every + // cursor's, which leaves seq alone deciding, as the order does. + from = fmt.Sprintf(" AND (%[1]s= schemaVersion { order = "m.added_at DESC, m.seq DESC" } - rows, err := tx.QueryContext(ctx, fmt.Sprintf(`SELECT m.kind,m.ref_id,m.session_id,%s,coalesce(c.name,''),c.id IS NOT NULL, + args = append(args, limit+1) + rows, err := tx.QueryContext(ctx, fmt.Sprintf(`SELECT m.seq,m.kind,m.ref_id,m.session_id,%s,coalesce(c.name,''),c.id IS NOT NULL, CASE WHEN m.kind='collection' THEN (SELECT count(*) FROM memberships x WHERE x.collection_id=m.ref_id) ELSE 0 END FROM memberships m LEFT JOIN collections c ON m.kind='collection' AND c.id=m.ref_id -WHERE m.collection_id=? ORDER BY %s LIMIT ?`, added, order), id, limit+1) +WHERE m.collection_id=?%s ORDER BY %s LIMIT ?`, added, from, order), args...) if err != nil { return nil, false, err } @@ -247,7 +434,7 @@ WHERE m.collection_id=? ORDER BY %s LIMIT ?`, added, order), id, limit+1) break } var n Node - if err := rows.Scan(&n.Kind, &n.ID, &n.SessionID, &n.AddedAt, &n.Name, &n.Found, &n.Count); err != nil { + if err := rows.Scan(&n.seq, &n.Kind, &n.ID, &n.SessionID, &n.AddedAt, &n.Name, &n.Found, &n.Count); err != nil { return nil, false, err } nodes = append(nodes, n) @@ -256,12 +443,17 @@ WHERE m.collection_id=? ORDER BY %s LIMIT ?`, added, order), id, limit+1) } // Expansion is every reference a collection holds through its nested -// collections, each once, outer levels first; Collections is how many nested -// collections were read, and More says ExpandMax cut it short. +// collections, each once, outer levels first. Collections is how many nested +// collections were read; Deleted how many nested collection references name +// a collection that is gone (each once); Unread how many nested collections +// sat deeper than the depth asked and were not read (each once); More says +// ExpandMax cut it short. type Expansion struct { Collection Summary Refs []Member Collections int + Deleted int + Unread int More bool } @@ -269,6 +461,10 @@ type Expansion struct { // members): every reference that is not a collection, each once at its // outermost place, newest added first within a collection. Each nested // collection is read once however many collections hold it. +// +// NOTHING IS DROPPED WITHOUT BEING COUNTED. A nested collection that is gone +// is Deleted, one past the depth is Unread, and a cut is More, so a caller can +// always say what it is not showing. func (s *Store) Expand(ctx context.Context, id string, depth int) (Expansion, error) { depth = max(1, min(depth, ExpandDepthMax)) version, err := s.readyForRead(ctx) @@ -283,7 +479,7 @@ func (s *Store) Expand(ctx context.Context, id string, depth int) (Expansion, er return Expansion{}, storeError(err) } defer tx.Rollback() - top, err := summaries(ctx, tx, version, nil, id, 1) + top, err := summaries(ctx, tx, version, nil, id, 0, 1) if err != nil { return Expansion{}, storeError(err) } @@ -293,6 +489,7 @@ func (s *Store) Expand(ctx context.Context, id string, depth int) (Expansion, er out := Expansion{Collection: top[0], Refs: make([]Member, 0)} seen := map[string]bool{} read := map[string]bool{id: true} + gone, unread := map[string]bool{}, map[string]bool{} frontier := []string{id} for level := 1; level <= depth && len(frontier) > 0 && !out.More; level++ { var next []string @@ -300,7 +497,7 @@ func (s *Store) Expand(ctx context.Context, id string, depth int) (Expansion, er if cid != id { out.Collections++ } - nodes, more, err := levelOf(ctx, tx, version, cid, ExpandMax+1) + nodes, more, err := levelOf(ctx, tx, version, cid, ExpandMax+1, nil) if err != nil { return Expansion{}, storeError(err) } @@ -309,9 +506,15 @@ func (s *Store) Expand(ctx context.Context, id string, depth int) (Expansion, er } for _, n := range nodes { if n.Kind == CollectionKind { - if n.Found && !read[n.ID] && level < depth { + switch { + case !n.Found: + gone[n.ID] = true + case read[n.ID]: + case level < depth: read[n.ID] = true next = append(next, n.ID) + default: + unread[n.ID] = true } continue } @@ -331,5 +534,6 @@ func (s *Store) Expand(ctx context.Context, id string, depth int) (Expansion, er } frontier = next } + out.Deleted, out.Unread = len(gone), len(unread) return out, nil } diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index ab4a7e9319..647c004f46 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -37,6 +37,9 @@ var ( ErrInvalid = errors.New("invalid collection or reference") ErrNotFound = errors.New("collection not found") ErrCycle = errors.New("collection membership would form a cycle") + // ErrNotMember is a reference the collection does not hold, where a + // change needs one it does. + ErrNotMember = errors.New("that reference is not in this collection") // ErrNameTaken is a name another collection already has, compared without // regard to case: two collections a person cannot tell apart by name are // one too many. From d36df68d98cddb73556cc74651042f06ef23c044 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 19:29:23 -0400 Subject: [PATCH 040/459] codeaf app: page collections, count every member expand leaves out, say why a target is unknown, and relink in place collections.list takes after and limit and answers next and total; collections.open takes after and answers next, and more stays true when the node budget cut anything. A cursor the engine did not give is bad_params. A target whose owner could not be asked carries a reason naming the owner and why, redacted and with every path said as "a path". collections.expand used to skip such members uncounted; they are now unavailable (never missing), the first 50 named in unavailable_refs, and nested collections past the depth are unread. Every ref is counted past the limit too. collections.relink checks the new target with its owner first (a regular file at that path, or a live conversation, task, process or crew), then puts it in the old one's place and moment in one write, and says collection.changed once. PROTOCOL.md Wave 9, the manual page and the change entry say all of it, including the name folding rule. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/appcollections.go | 236 ++++++++++++++----- cmd/codeaf/appcollections_test.go | 226 +++++++++++++++++- cmd/codeaf/collections_test.go | 65 +++++ docs/changes/unreleased/0-app-collections.md | 3 + internal/appwire/collections.go | 137 ++++++++--- internal/appwire/collections_test.go | 143 ++++++++++- internal/manual/chat/collections.md | 4 + 7 files changed, 719 insertions(+), 95 deletions(-) diff --git a/cmd/codeaf/appcollections.go b/cmd/codeaf/appcollections.go index 264626304e..4471d60792 100644 --- a/cmd/codeaf/appcollections.go +++ b/cmd/codeaf/appcollections.go @@ -14,7 +14,8 @@ package main // one look for a git work tree per directory; a collection of a thousand // files in one repository costs one walk up to its .git. No transcript is // read to resolve anything, and a member whose owner cannot be asked is -// "unknown", which says nothing about the member itself. +// "unknown", which says nothing about the member itself, with a reason that +// names the owner and why it could not be asked. import ( "bufio" @@ -24,6 +25,7 @@ import ( "io/fs" "os" "path/filepath" + "regexp" "strconv" "strings" "syscall" @@ -31,6 +33,7 @@ import ( "github.com/Agent-Field/codeaf/internal/appwire" "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/redact" "github.com/Agent-Field/codeaf/internal/workspace" ) @@ -105,6 +108,8 @@ func appCollectionError(err error) error { return appwire.Errorf(appwire.CodeCycle, "%s", workspace.ErrCycle.Error()) case errors.Is(err, workspace.ErrNotFound): return appwire.Errorf(appwire.CodeNotFound, "%s", workspace.ErrNotFound.Error()) + case errors.Is(err, workspace.ErrNotMember): + return appwire.Errorf(appwire.CodeNotFound, "%s", workspace.ErrNotMember.Error()) case errors.Is(err, workspace.ErrInvalid): return appwire.Errorf(appwire.CodeBadParams, "%s", err.Error()) case errors.Is(err, workspace.ErrBusy): @@ -151,24 +156,28 @@ func describeCollection(s *workspace.Store, id string) (appwire.Collection, erro return appWireCollection(summary), nil } -// ListCollections is every collection, oldest first. -func (b *appBackend) ListCollections(holds *appwire.Ref) ([]appwire.Collection, error) { +// ListCollections is one page of collections, oldest first, with the cursor +// of the next page when more remain and how many there are in all. +func (b *appBackend) ListCollections(holds *appwire.Ref, after string, limit int) (appwire.CollectionsResult, error) { s, err := b.collectionStore(false) - if err != nil || s == nil { - return []appwire.Collection{}, err + if err != nil { + return appwire.CollectionsResult{}, err + } + if s == nil { + return appwire.CollectionsResult{Collections: []appwire.Collection{}}, nil } var ref *workspace.Ref if holds != nil { r := appStoreRef(*holds) ref = &r } - list, err := s.List(context.Background(), ref) + page, err := s.ListPage(context.Background(), ref, after, limit) if err != nil { - return nil, appCollectionError(err) + return appwire.CollectionsResult{}, appCollectionError(err) } - out := make([]appwire.Collection, 0, len(list)) - for _, c := range list { - out = append(out, appWireCollection(c)) + out := appwire.CollectionsResult{Collections: make([]appwire.Collection, 0, len(page.Collections)), Next: page.Next, Total: page.Total} + for _, c := range page.Collections { + out.Collections = append(out.Collections, appWireCollection(c)) } return out, nil } @@ -287,13 +296,14 @@ func (b *appBackend) RemoveFromCollection(id string, refs []appwire.Ref) (appwir return out, nil } -// OpenCollection is a collection's members with what each is now. -func (b *appBackend) OpenCollection(id string, depth, limit int) (appwire.CollectionOpenResult, error) { +// OpenCollection is one page of a collection's members with what each is +// now, continuing after the cursor a previous page gave. +func (b *appBackend) OpenCollection(id string, depth, limit int, after string) (appwire.CollectionOpenResult, error) { s, err := b.existingStore() if err != nil { return appwire.CollectionOpenResult{}, err } - tree, err := s.Tree(context.Background(), id, depth, limit) + tree, err := s.Tree(context.Background(), id, depth, limit, after) if err != nil { return appwire.CollectionOpenResult{}, appCollectionError(err) } @@ -315,7 +325,7 @@ func (b *appBackend) OpenCollection(id string, depth, limit int) (appwire.Collec } return out } - return appwire.CollectionOpenResult{Collection: appWireCollection(tree.Collection), Members: nodes(tree.Level), More: tree.More}, nil + return appwire.CollectionOpenResult{Collection: appWireCollection(tree.Collection), Members: nodes(tree.Level), More: tree.More, Next: tree.Next}, nil } // collectionTarget is a nested collection as the store found it. @@ -328,7 +338,13 @@ func collectionTarget(n workspace.Node) appwire.Target { } // ExpandCollection is every live reference a collection holds through its -// nested collections, each once, outer levels first. +// nested collections, each once, outer levels first, at most limit. +// +// EVERY REFERENCE THE STORE ANSWERED IS ACCOUNTED FOR, past the limit too: a +// live one is answered or cut (more), a gone one is missing (a deleted nested +// collection with them), and one whose owner could not be asked is +// unavailable, the first of them named with the reason. An unavailable ref +// is never missing: nobody knows it is gone. func (b *appBackend) ExpandCollection(id string, depth, limit int) (appwire.CollectionExpandResult, error) { s, err := b.existingStore() if err != nil { @@ -338,26 +354,135 @@ func (b *appBackend) ExpandCollection(id string, depth, limit int) (appwire.Coll if err != nil { return appwire.CollectionExpandResult{}, appCollectionError(err) } - out := appwire.CollectionExpandResult{Collection: appWireCollection(e.Collection), Refs: []appwire.ExpandedRef{}, Collections: e.Collections, More: e.More} + out := appwire.CollectionExpandResult{ + Collection: appWireCollection(e.Collection), Refs: []appwire.ExpandedRef{}, UnavailableRefs: []appwire.ExpandedRef{}, + Missing: e.Deleted, Unread: e.Unread, Collections: e.Collections, More: e.More, + } r := newCollectionResolver(b) for _, m := range e.Refs { target := r.target(m.Ref) switch target.State { case appwire.TargetDeleted, appwire.TargetMissing: out.Missing++ - continue case appwire.TargetUnknown: - continue - } - if len(out.Refs) == limit { - out.More = true - break + out.Unavailable++ + if len(out.UnavailableRefs) < appwire.CollectionUnavailableMax { + out.UnavailableRefs = append(out.UnavailableRefs, appwire.ExpandedRef{Member: appWireMember(m), Target: target}) + } + default: + if len(out.Refs) == limit { + out.More = true + continue + } + out.Refs = append(out.Refs, appwire.ExpandedRef{Member: appWireMember(m), Target: target}) } - out.Refs = append(out.Refs, appwire.ExpandedRef{Member: appWireMember(m), Target: target}) } return out, nil } +// RelinkCollection puts to in from's place, keeping from's added_at and +// place, in one write. to is asked of its owner first and never written +// unless it is there now: a file must be a regular file at that path, any +// other kind must resolve live, and a collection is checked by the store as +// add checks one. +func (b *appBackend) RelinkCollection(id string, from, to appwire.Ref) (appwire.CollectionRelinkResult, error) { + s, err := b.existingStore() + if err != nil { + return appwire.CollectionRelinkResult{}, err + } + if err := b.relinkable(to); err != nil { + return appwire.CollectionRelinkResult{}, err + } + m, already, err := s.Relink(context.Background(), id, appStoreRef(from), appStoreRef(to)) + if err != nil { + return appwire.CollectionRelinkResult{}, appCollectionError(err) + } + // Relinking a member to itself changed nothing, so it says nothing. + if from != to { + b.collectionChanged(id, false) + } + return appwire.CollectionRelinkResult{Member: appWireMember(m), Already: already}, nil +} + +// relinkable is nil when to is there now, not_found when it is not, and the +// engine's failure, with the reason, when its owner could not be asked. +func (b *appBackend) relinkable(to appwire.Ref) error { + switch to.Kind { + case appwire.RefCollection: + return nil + case appwire.RefArtifact: + info, err := os.Stat(to.ID) + if errors.Is(err, fs.ErrNotExist) || errors.Is(err, syscall.ENOTDIR) { + return appwire.Errorf(appwire.CodeNotFound, "nothing is at that path now") + } + if err != nil { + return appwire.Errorf(appwire.CodeEngine, "%s", appUnaskedReason("the file system", err)) + } + if !info.Mode().IsRegular() { + return appwire.Errorf(appwire.CodeNotFound, "nothing is at that path now but a folder or another thing that is not a file") + } + return nil + } + t := newCollectionResolver(b).target(appStoreRef(to)) + switch t.State { + case appwire.TargetLive: + return nil + case appwire.TargetUnknown: + return appwire.Errorf(appwire.CodeEngine, "%s", t.Reason) + } + return appwire.Errorf(appwire.CodeNotFound, "that %s is not there now", to.Kind) +} + +// ── reasons ──────────────────────────────────────────────────────────────── + +// appUnknown is a target whose owner could not be asked, and why. +func appUnknown(owner string, err error) appwire.Target { + return appwire.Target{State: appwire.TargetUnknown, Reason: appUnaskedReason(owner, err)} +} + +// appReasonMax is the longest reason a target carries, in runes. +const appReasonMax = 200 + +// appPathWords finds a path inside a sentence: an absolute or home-relative +// path standing as its own word. +var appPathWords = regexp.MustCompile(`(^|[\s'"(=])~?/[^\s'"()]*`) + +// appUnaskedReason is one short sentence: which owner could not be asked and +// why. A REASON CARRIES NO SECRET AND NO PATH: the operating system's words +// lose the path they name, what is left goes through the secret redactor, +// and any path still standing in it is said as "a path". +func appUnaskedReason(owner string, err error) string { + why := "it did not answer" + if err != nil { + var wire *appwire.Error + var pathErr *fs.PathError + var linkErr *os.LinkError + var sysErr *os.SyscallError + switch { + case errors.As(err, &wire): + why = wire.Message + case errors.As(err, &pathErr) && pathErr.Err != nil: + why = pathErr.Err.Error() + case errors.As(err, &linkErr) && linkErr.Err != nil: + why = linkErr.Err.Error() + case errors.As(err, &sysErr) && sysErr.Err != nil: + why = sysErr.Err.Error() + default: + why = plainWords(err.Error()) + } + why, _, _ = strings.Cut(why, "\n") + why = appPathWords.ReplaceAllString(redact.Secrets(why), "${1}a path") + why = strings.TrimSpace(why) + if r := []rune(why); len(r) > appReasonMax { + why = string(r[:appReasonMax]) + "…" + } + if why == "" { + why = "it did not answer" + } + } + return owner + " could not be asked: " + why +} + // ── resolution ───────────────────────────────────────────────────────────── // collectionResolver answers what each member is now, asking each owner at @@ -393,33 +518,33 @@ func newCollectionResolver(b *appBackend) *collectionResolver { func (r *collectionResolver) target(ref workspace.Ref) appwire.Target { switch ref.Kind { case workspace.ConversationKind: - a, state := r.agent(ref.ID) - if state != "" { - return appwire.Target{State: state} + a, not := r.agent(ref.ID) + if not != nil { + return *not } return appwire.Target{State: appwire.TargetLive, Title: a.Title, Status: a.State, In: r.crewPathOf(a.ID)} case workspace.TaskKind: - a, state := r.agent(ref.SessionID) - if state != "" { - return appwire.Target{State: state} + a, not := r.agent(ref.SessionID) + if not != nil { + return *not } n, err := strconv.ParseUint(ref.ID, 10, 64) if err != nil { return appwire.Target{State: appwire.TargetDeleted} } - task, state := appFindOwned(r.tasks, a.ID, appTaskID(a.ID, n), r.b.ListTasks) - if state != "" { - return appwire.Target{State: state} + task, not := appFindOwned(r.tasks, a.ID, appTaskID(a.ID, n), r.b.ListTasks, "the task list of its conversation") + if not != nil { + return *not } return appwire.Target{State: appwire.TargetLive, Title: task.Title, Status: task.State, In: append(r.crewPathOf(a.ID), a.Title)} case workspace.ProcessKind: - a, state := r.agent(ref.SessionID) - if state != "" { - return appwire.Target{State: state} + a, not := r.agent(ref.SessionID) + if not != nil { + return *not } - p, state := appFindOwned(r.procs, a.ID, ref.ID, r.b.ListProcesses) - if state != "" { - return appwire.Target{State: state} + p, not := appFindOwned(r.procs, a.ID, ref.ID, r.b.ListProcesses, "the process list of its conversation") + if not != nil { + return *not } title := p.Title if strings.TrimSpace(title) == "" { @@ -428,7 +553,7 @@ func (r *collectionResolver) target(ref workspace.Ref) appwire.Target { return appwire.Target{State: appwire.TargetLive, Title: title, Status: p.State, In: append(r.crewPathOf(a.ID), a.Title)} case workspace.CrewKind: if !r.readCrews() { - return appwire.Target{State: appwire.TargetUnknown} + return appUnknown("the crew list", r.crewsErr) } i, ok := r.crewByID[ref.ID] if !ok { @@ -441,16 +566,16 @@ func (r *collectionResolver) target(ref workspace.Ref) appwire.Target { return r.file(ref.ID) case workspace.CollectionKind: // A nested collection is found by the store in the same read that - // found it a member; one reaching here was not. - return appwire.Target{State: appwire.TargetUnknown} + // found it a member; one reaching here was not, so it is gone. + return appwire.Target{State: appwire.TargetDeleted} } // A standing item names nothing codeaf runs any more. return appwire.Target{State: appwire.TargetDeleted} } -// agent is one agent off the one listing, or the state a member of it is in -// when there is no such agent ("deleted") or no listing ("unknown"). -func (r *collectionResolver) agent(id string) (appwire.Agent, string) { +// agent is one agent off the one listing, or the target a member of it is +// when there is no such agent (deleted) or no listing (unknown, and why). +func (r *collectionResolver) agent(id string) (appwire.Agent, *appwire.Target) { if !r.agentsRead { r.agentsRead = true list, err := r.b.ListAgents("") @@ -461,18 +586,20 @@ func (r *collectionResolver) agent(id string) (appwire.Agent, string) { } } if r.agentsErr != nil { - return appwire.Agent{}, appwire.TargetUnknown + t := appUnknown("the conversation list", r.agentsErr) + return appwire.Agent{}, &t } a, ok := r.agents[id] if !ok { - return appwire.Agent{}, appwire.TargetDeleted + return appwire.Agent{}, &appwire.Target{State: appwire.TargetDeleted} } - return a, "" + return a, nil } // appFindOwned is one task or process of an agent, read with list once per -// agent per call. -func appFindOwned[T any](cache map[string]appOwned[T], agent, id string, list func(string) ([]T, error)) (T, string) { +// agent per call, or the target it is when it is not there; owner names the +// list in an unknown target's reason. +func appFindOwned[T any](cache map[string]appOwned[T], agent, id string, list func(string) ([]T, error), owner string) (T, *appwire.Target) { owned, ok := cache[agent] if !ok { items, err := list(agent) @@ -486,15 +613,16 @@ func appFindOwned[T any](cache map[string]appOwned[T], agent, id string, list fu if owned.err != nil { var wire *appwire.Error if errors.As(owned.err, &wire) && wire.Code == appwire.CodeNotFound { - return zero, appwire.TargetDeleted + return zero, &appwire.Target{State: appwire.TargetDeleted} } - return zero, appwire.TargetUnknown + t := appUnknown(owner, owned.err) + return zero, &t } item, ok := owned.byID[id] if !ok { - return zero, appwire.TargetDeleted + return zero, &appwire.Target{State: appwire.TargetDeleted} } - return item, "" + return item, nil } func appOwnedID(item any) string { @@ -574,7 +702,7 @@ func (r *collectionResolver) file(path string) appwire.Target { return appwire.Target{State: appwire.TargetMissing} } if err != nil { - return appwire.Target{State: appwire.TargetUnknown} + return appUnknown("the file system", err) } t := appwire.Target{State: appwire.TargetLive, Title: filepath.Base(path)} dir := filepath.Dir(path) diff --git a/cmd/codeaf/appcollections_test.go b/cmd/codeaf/appcollections_test.go index 20362af223..b837044e40 100644 --- a/cmd/codeaf/appcollections_test.go +++ b/cmd/codeaf/appcollections_test.go @@ -2,8 +2,10 @@ package main import ( "errors" + "fmt" "os" "path/filepath" + "strings" "testing" "github.com/Agent-Field/codeaf/internal/appwire" @@ -35,14 +37,14 @@ func changedEvents(rec *appRecorder) []appwire.CollectionChanged { // cannot exist yet is not_found without making the file either. func TestAppCollectionsReadsDoNotCreateTheStore(t *testing.T) { backend, _, _, path := collectionsBackend(t) - list, err := backend.ListCollections(nil) - if err != nil || list == nil || len(list) != 0 { - t.Fatalf("fresh list %v, %v", list, err) + list, err := backend.ListCollections(nil, "", appwire.CollectionsListMax) + if err != nil || list.Collections == nil || len(list.Collections) != 0 || list.Total != 0 || list.Next != "" { + t.Fatalf("fresh list %+v, %v", list, err) } if _, err := backend.RenameCollection("nope", "x"); wireCode(err) != appwire.CodeNotFound { t.Fatalf("rename in a fresh home: %v", err) } - if _, err := backend.OpenCollection("nope", 1, 500); wireCode(err) != appwire.CodeNotFound { + if _, err := backend.OpenCollection("nope", 1, 500, ""); wireCode(err) != appwire.CodeNotFound { t.Fatalf("open in a fresh home: %v", err) } if _, err := os.Stat(filepath.Dir(path)); !errors.Is(err, os.ErrNotExist) { @@ -112,7 +114,7 @@ func TestAppOpenCollectionResolvesEachMember(t *testing.T) { if err != nil || len(added.Added) != len(refs) || len(added.Already) != 0 { t.Fatalf("add %+v, %v", added, err) } - open, err := backend.OpenCollection(c.ID, 1, 500) + open, err := backend.OpenCollection(c.ID, 1, 500, "") if err != nil { t.Fatal(err) } @@ -203,8 +205,216 @@ func TestAppCollectionsRefusalsAndDeleteEvents(t *testing.T) { if err := backend.DeleteCollection(b.ID); wireCode(err) != appwire.CodeNotFound { t.Fatalf("second delete: %v", err) } - list, err := backend.ListCollections(&appwire.Ref{Kind: appwire.RefCollection, ID: b.ID}) - if err != nil || len(list) != 2 || list[0].Count != 0 || list[0].Holds == nil || *list[0].Holds { - t.Fatalf("list after delete %+v, %v", list, err) + page, err := backend.ListCollections(&appwire.Ref{Kind: appwire.RefCollection, ID: b.ID}, "", appwire.CollectionsListMax) + list := page.Collections + if err != nil || len(list) != 2 || page.Total != 2 || list[0].Count != 0 || list[0].Holds == nil || *list[0].Holds { + t.Fatalf("list after delete %+v, %v", page, err) + } +} + +// A REF WHOSE OWNER CANNOT BE ASKED IS UNAVAILABLE, NEVER MISSING. Open says +// it is unknown with a reason naming the owner and why, without the path; +// expand counts it in unavailable and names it, keeps missing for what is +// really gone, and counts the nested collection it did not read. +func TestAppExpandCountsUnavailableWithAReasonApartFromMissing(t *testing.T) { + backend, _, agent, _ := collectionsBackend(t) + // A name longer than any file system allows: Lstat cannot say whether + // it is there, which is the one unknown a test can make as any user. + unaskable := filepath.Join(t.TempDir(), strings.Repeat("x", 300)) + c, _ := backend.CreateCollection("Launch") + inner, _ := backend.CreateCollection("Inner") + deeper, _ := backend.CreateCollection("Deeper") + if _, err := backend.AddToCollection(inner.ID, []appwire.Member{{Ref: appwire.Ref{Kind: appwire.RefCollection, ID: deeper.ID}}}); err != nil { + t.Fatal(err) + } + if _, err := backend.AddToCollection(c.ID, []appwire.Member{ + {Ref: appwire.Ref{Kind: appwire.RefArtifact, ID: unaskable}}, + {Ref: appwire.Ref{Kind: appwire.RefConversation, ID: "0000000000000000"}}, + {Ref: appwire.Ref{Kind: appwire.RefConversation, ID: agent.ID}}, + {Ref: appwire.Ref{Kind: appwire.RefCollection, ID: inner.ID}}, + }); err != nil { + t.Fatal(err) + } + open, err := backend.OpenCollection(c.ID, 0, 500, "") + if err != nil { + t.Fatal(err) + } + var reason string + for _, n := range open.Members { + if n.ID == unaskable { + if n.Target.State != appwire.TargetUnknown { + t.Fatalf("unaskable file is %+v", n.Target) + } + reason = n.Target.Reason + } else if n.Target.Reason != "" { + t.Fatalf("%s is %s and still carries a reason %q", n.ID, n.Target.State, n.Target.Reason) + } + } + if !strings.HasPrefix(reason, "the file system could not be asked: ") || strings.Contains(reason, "/") || strings.Contains(reason, "xxxx") { + t.Fatalf("reason %q", reason) + } + e, err := backend.ExpandCollection(c.ID, 2, 200) + if err != nil { + t.Fatal(err) + } + if e.Unavailable != 1 || e.Missing != 1 || len(e.Refs) != 1 || e.Unread != 1 || e.Collections != 1 || e.More { + t.Fatalf("expand %+v", e) + } + if len(e.UnavailableRefs) != 1 || e.UnavailableRefs[0].ID != unaskable || e.UnavailableRefs[0].Target.Reason != reason || e.UnavailableRefs[0].AddedAt == nil { + t.Fatalf("unavailable refs %+v", e.UnavailableRefs) + } + // The limit cuts live refs only, and everything past it is still + // counted: the gone one is missing however small the limit. + if e, err := backend.ExpandCollection(c.ID, 1, 1); err != nil || e.Missing != 1 || e.Unavailable != 1 || len(e.Refs) != 1 || e.More || e.Unread != 1 { + t.Fatalf("expand at limit 1 %+v, %v", e, err) + } +} + +// THE APP PAGES THROUGH EVERYTHING: a listing walked by its cursor reaches +// every collection with the total, an opened collection of more than one +// page is walked to its end without a repeat, and a cursor the engine did +// not give is bad_params. +func TestAppCollectionPagesWalkToTheEnd(t *testing.T) { + backend, _, _, _ := collectionsBackend(t) + want := map[string]bool{} + for i := 0; i < 5; i++ { + c, err := backend.CreateCollection(fmt.Sprintf("C%d", i)) + if err != nil { + t.Fatal(err) + } + want[c.ID] = true + } + after, read := "", 0 + for { + page, err := backend.ListCollections(nil, after, 2) + if err != nil || page.Total != 5 || len(page.Collections) > 2 { + t.Fatalf("page %+v, %v", page, err) + } + for _, c := range page.Collections { + if !want[c.ID] { + t.Fatalf("%s read twice or never made", c.ID) + } + delete(want, c.ID) + read++ + } + if page.Next == "" { + break + } + after = page.Next + } + if read != 5 { + t.Fatalf("read %d of 5", read) + } + if _, err := backend.ListCollections(nil, "garbage", 2); wireCode(err) != appwire.CodeBadParams { + t.Fatalf("a garbage cursor: %v", err) + } + big, _ := backend.CreateCollection("Big") + for batch := 0; batch < 3; batch++ { + refs := make([]appwire.Member, 0, appwire.CollectionRefsMax) + for i := 0; i < appwire.CollectionRefsMax; i++ { + refs = append(refs, appwire.Member{Ref: appwire.Ref{Kind: appwire.RefConversation, ID: fmt.Sprintf("c-%d-%d", batch, i)}}) + } + if _, err := backend.AddToCollection(big.ID, refs); err != nil { + t.Fatal(err) + } + } + seen := map[string]bool{} + after = "" + for pages := 0; ; pages++ { + open, err := backend.OpenCollection(big.ID, 0, appwire.CollectionOpenLimit, after) + if err != nil { + t.Fatal(err) + } + for _, n := range open.Members { + if seen[n.ID] { + t.Fatalf("%s on two pages", n.ID) + } + seen[n.ID] = true + } + if open.Next == "" { + if open.More || pages != 1 { + t.Fatalf("last page more %v after %d pages", open.More, pages) + } + break + } + after = open.Next + } + if len(seen) != 3*appwire.CollectionRefsMax { + t.Fatalf("opened %d of %d", len(seen), 3*appwire.CollectionRefsMax) + } + first, _ := backend.CreateCollection("Other") + page, _ := backend.OpenCollection(big.ID, 0, 1, "") + if _, err := backend.OpenCollection(first.ID, 0, 1, page.Next); wireCode(err) != appwire.CodeBadParams { + t.Fatalf("another collection's cursor: %v", err) + } +} + +// RELINK ASKS THE NEW TARGET'S OWNER FIRST AND WRITES ONCE: a file that is +// there takes the old ref's moment and place and says collection.changed +// once; a file that is not there, a folder, a conversation that is gone and +// a non-member are not_found; an owner that cannot be asked is the engine's +// failure with the reason; a cycle is cycle; a ref already there is already. +func TestAppRelinkChecksTheNewTargetBeforeWriting(t *testing.T) { + backend, rec, agent, _ := collectionsBackend(t) + dir := t.TempDir() + old, moved := filepath.Join(dir, "old.md"), filepath.Join(dir, "moved.md") + if err := os.WriteFile(moved, []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + c, _ := backend.CreateCollection("Launch") + parent, _ := backend.CreateCollection("Parent") + added, err := backend.AddToCollection(c.ID, []appwire.Member{ + {Ref: appwire.Ref{Kind: appwire.RefArtifact, ID: old}}, + {Ref: appwire.Ref{Kind: appwire.RefConversation, ID: agent.ID}}, + }) + if err != nil { + t.Fatal(err) + } + if _, err := backend.AddToCollection(c.ID, []appwire.Member{{Ref: appwire.Ref{Kind: appwire.RefCollection, ID: parent.ID}}}); err != nil { + t.Fatal(err) + } + before := len(changedEvents(rec)) + from := appwire.Ref{Kind: appwire.RefArtifact, ID: old} + r, err := backend.RelinkCollection(c.ID, from, appwire.Ref{Kind: appwire.RefArtifact, ID: moved}) + if err != nil || r.Already || r.Member.ID != moved || !r.Member.AddedAt.Equal(*added.Added[0].AddedAt) { + t.Fatalf("relink %+v, %v", r, err) + } + if events := changedEvents(rec)[before:]; len(events) != 1 || events[0].ID != c.ID { + t.Fatalf("relink events %+v", events) + } + from = appwire.Ref{Kind: appwire.RefArtifact, ID: moved} + for name, to := range map[string]appwire.Ref{ + "a file that is not there": {Kind: appwire.RefArtifact, ID: filepath.Join(dir, "nothing.md")}, + "a folder": {Kind: appwire.RefArtifact, ID: dir}, + "a conversation that is gone": {Kind: appwire.RefConversation, ID: "0000000000000000"}, + "a collection that is gone": {Kind: appwire.RefCollection, ID: "nope"}, + } { + if _, err := backend.RelinkCollection(c.ID, from, to); wireCode(err) != appwire.CodeNotFound { + t.Errorf("relink to %s: %v", name, err) + } + } + _, err = backend.RelinkCollection(c.ID, from, appwire.Ref{Kind: appwire.RefArtifact, ID: filepath.Join(dir, "nothing.md")}) + if wire := (*appwire.Error)(nil); !errors.As(err, &wire) || wire.Message != "nothing is at that path now" { + t.Fatalf("missing file says %v", err) + } + _, err = backend.RelinkCollection(c.ID, from, appwire.Ref{Kind: appwire.RefArtifact, ID: filepath.Join(dir, strings.Repeat("y", 300))}) + if wire := (*appwire.Error)(nil); !errors.As(err, &wire) || wire.Code != appwire.CodeEngine || !strings.HasPrefix(wire.Message, "the file system could not be asked: ") { + t.Fatalf("an unaskable file: %v", err) + } + if _, err := backend.RelinkCollection(c.ID, appwire.Ref{Kind: appwire.RefConversation, ID: "never-added"}, appwire.Ref{Kind: appwire.RefArtifact, ID: moved}); wireCode(err) != appwire.CodeNotFound { + t.Fatalf("relink of a non-member: %v", err) + } + if _, err := backend.AddToCollection(parent.ID, []appwire.Member{{Ref: appwire.Ref{Kind: appwire.RefConversation, ID: "x"}}}); err != nil { + t.Fatal(err) + } + if _, err := backend.RelinkCollection(parent.ID, appwire.Ref{Kind: appwire.RefConversation, ID: "x"}, appwire.Ref{Kind: appwire.RefCollection, ID: c.ID}); wireCode(err) != appwire.CodeCycle { + t.Fatalf("relink making a cycle: %v", err) + } + r, err = backend.RelinkCollection(c.ID, from, appwire.Ref{Kind: appwire.RefConversation, ID: agent.ID}) + if err != nil || !r.Already || r.Member.ID != agent.ID { + t.Fatalf("relink onto a member %+v, %v", r, err) + } + if open, _ := backend.OpenCollection(c.ID, 0, 500, ""); len(open.Members) != 2 { + t.Fatalf("after relinking onto a member %+v", open.Members) } } diff --git a/cmd/codeaf/collections_test.go b/cmd/codeaf/collections_test.go index 1b1bcabbf7..3aba04824f 100644 --- a/cmd/codeaf/collections_test.go +++ b/cmd/codeaf/collections_test.go @@ -3,8 +3,10 @@ package main import ( "bytes" "context" + "database/sql" "encoding/json" "errors" + "fmt" "os" "path/filepath" "reflect" @@ -354,3 +356,66 @@ func TestCollectionsDuplicateAndDelete(t *testing.T) { t.Fatalf("a taken name in another case: %v", err) } } + +// LIST AND SHOW PRINT EVERYTHING, past the app's page sizes: more than a +// thousand collections and more than five hundred references each print a +// line, in the words they always had. +func TestCollectionsListAndShowPrintPastEveryPageSize(t *testing.T) { + ctx := context.Background() + db := filepath.Join(t.TempDir(), "collections.db") + store, err := workspace.Open(db) + if err != nil { + t.Fatal(err) + } + big, err := store.Create(ctx, "Big") + if err != nil { + t.Fatal(err) + } + refs := make([]workspace.Addition, 0, workspace.LevelMax+7) + for i := 0; i < workspace.LevelMax+7; i++ { + refs = append(refs, workspace.Addition{Ref: workspace.Ref{Kind: workspace.ConversationKind, ID: fmt.Sprintf("chat-%d", i)}}) + } + if _, _, err := store.AddRefs(ctx, big.ID, refs); err != nil { + t.Fatal(err) + } + if err := store.Close(); err != nil { + t.Fatal(err) + } + // A thousand creates through the door prove nothing more than one, and + // cost a thousand fsyncs; the rows go in as one transaction instead. + raw, err := sql.Open("sqlite", db) + if err != nil { + t.Fatal(err) + } + tx, err := raw.Begin() + if err != nil { + t.Fatal(err) + } + for i := 0; i < workspace.ListMax+3; i++ { + if _, err := tx.Exec("INSERT INTO collections(id,name,created_at) VALUES (?,?,1)", fmt.Sprintf("bulk-%05d", i), fmt.Sprintf("Bulk %d", i)); err != nil { + t.Fatal(err) + } + } + if err := tx.Commit(); err != nil { + t.Fatal(err) + } + if err := raw.Close(); err != nil { + t.Fatal(err) + } + var out bytes.Buffer + if err := runCollectionsTo([]string{"list", "--db", db}, &out); err != nil { + t.Fatal(err) + } + lines := strings.Split(strings.TrimSpace(out.String()), "\n") + if len(lines) != workspace.ListMax+4 || lines[0] != big.ID+" Big" || lines[len(lines)-1] != fmt.Sprintf("bulk-%05d Bulk %d", workspace.ListMax+2, workspace.ListMax+2) { + t.Fatalf("list printed %d lines, first %q, last %q", len(lines), lines[0], lines[len(lines)-1]) + } + out.Reset() + if err := runCollectionsTo([]string{"show", big.ID, "--db", db}, &out); err != nil { + t.Fatal(err) + } + lines = strings.Split(strings.TrimSpace(out.String()), "\n") + if len(lines) != workspace.LevelMax+7 || lines[0] != "conversation chat-0" { + t.Fatalf("show printed %d lines, first %q", len(lines), lines[0]) + } +} diff --git a/docs/changes/unreleased/0-app-collections.md b/docs/changes/unreleased/0-app-collections.md index f5a99541a9..67c736ad7c 100644 --- a/docs/changes/unreleased/0-app-collections.md +++ b/docs/changes/unreleased/0-app-collections.md @@ -8,6 +8,9 @@ invalidates: - "The collections database was schema 1 and a collection could not be deleted. Schema 2 records when a collection was made and when each membership was added, adds the crew and process kinds, and cascades deletes; a schema-1 file is read as it is and migrated in place by the first write, after which a schema-1 build refuses it as an unsupported collections database." - "Two collections could share a name. Names are now unique without regard to case (Unicode case folding); a taken name answers conflict on the wire and `another collection already has that name` at the command line." - "`codeaf collections` had no delete or duplicate verb. It now has both." + - "collections.list stopped at 1000 collections and collections.open at 500 members with nothing saying more remained past the list. Both now page: an opaque `next` cursor passed back as `after`, and collections.list answers `total`; a walk never repeats or skips a row that existed when it began." + - "collections.expand skipped a member whose owner could not be asked without counting it anywhere. It is now counted in `unavailable` (never in `missing`), the first 50 are listed in `unavailable_refs` with a `target.reason`, nested collections past `depth` are counted in `unread`, and a target whose state is unknown carries a `reason` everywhere." + - "A member whose thing moved could only be removed and added again, losing its place. collections.relink puts the new reference in the old one's place and moment in one write, after checking the new one is there now." --- Deleting a collection takes its memberships and every membership naming it, diff --git a/internal/appwire/collections.go b/internal/appwire/collections.go index 7f22b3a90d..8e81c5ae11 100644 --- a/internal/appwire/collections.go +++ b/internal/appwire/collections.go @@ -34,7 +34,7 @@ const ( RefStanding = "standing" ) -// The states of a target. +// The states of a target. An unknown target carries its reason. const ( TargetLive = "live" TargetDeleted = "deleted" @@ -44,15 +44,17 @@ const ( // The bounds of this wave's methods. const ( - CollectionRefsMax = 200 // refs in one add or remove - CollectionsListMax = 1000 // collections one listing answers - CollectionOpenDepth = 2 // levels below the top open opens - CollectionOpenLimit = 500 // members per level, and expand's most refs - CollectionExpandMax = 3 // levels expand reads - CollectionExpandDefault = 200 // refs expand answers when not told - CollectionNodesMax = 1500 // nodes one open answers in all - collectionNameMax = 256 - collectionIDMax = 4096 + CollectionRefsMax = 200 // refs in one add or remove + CollectionsListMax = 1000 // collections one page of a listing answers, and its default + CollectionUnavailableMax = 50 // unavailable refs expand names, of all it counts + collectionCursorMax = 8192 + CollectionOpenDepth = 2 // levels below the top open opens + CollectionOpenLimit = 500 // members per level, and expand's most refs + CollectionExpandMax = 3 // levels expand reads + CollectionExpandDefault = 200 // refs expand answers when not told + CollectionNodesMax = 1500 // nodes one open answers in all + collectionNameMax = 256 + collectionIDMax = 4096 ) // Ref is one reference: a task and a process carry their agent as SessionID, @@ -85,9 +87,12 @@ type Collection struct { // Target is what a member was found to be now, never stored. In is where it // lives, outer first; Count is a crew's members or a collection's direct -// members; Repo, Branch and Path place a file inside a git work tree. +// members; Repo, Branch and Path place a file inside a git work tree. Reason +// is said only of an unknown target: which owner could not be asked and why, +// in words fit for a person, with no secret and no path in them. type Target struct { State string `json:"state" enum:"live,deleted,missing,unknown"` + Reason string `json:"reason,omitempty"` Title string `json:"title,omitempty"` In []string `json:"in,omitempty"` Status string `json:"status,omitempty"` @@ -120,8 +125,13 @@ type ExpandedRef struct { // The answers of this wave's methods. type ( + // CollectionsResult is one page of collections: Next is the cursor the + // following page starts from, present only when more remain, and Total + // every collection there is. CollectionsResult struct { Collections []Collection `json:"collections"` + Next string `json:"next,omitempty"` + Total int `json:"total"` } CollectionResult struct { Collection Collection `json:"collection"` @@ -133,17 +143,37 @@ type ( CollectionRemoveResult struct { Removed []Member `json:"removed"` } + // CollectionOpenResult is a collection opened: More says something was + // cut (the top level or the node budget), and Next, present only when + // the top level has more, is where its next page starts. CollectionOpenResult struct { Collection Collection `json:"collection"` Members []Node `json:"members"` More bool `json:"more"` + Next string `json:"next,omitempty"` } + // CollectionExpandResult is a collection flattened. NOTHING IS LEFT OUT + // UNCOUNTED: Missing is refs whose thing is gone (deleted nested + // collections included), Unavailable refs whose owner could not be asked + // (never in Missing), the first CollectionUnavailableMax of them named in + // UnavailableRefs with their reasons, Unread nested collections deeper + // than the depth asked, and More a cut by the limit or the store's own + // bound. CollectionExpandResult struct { - Collection Collection `json:"collection"` - Refs []ExpandedRef `json:"refs"` - Missing int `json:"missing"` - Collections int `json:"collections"` - More bool `json:"more"` + Collection Collection `json:"collection"` + Refs []ExpandedRef `json:"refs"` + Missing int `json:"missing"` + Unavailable int `json:"unavailable"` + UnavailableRefs []ExpandedRef `json:"unavailable_refs"` + Unread int `json:"unread"` + Collections int `json:"collections"` + More bool `json:"more"` + } + // CollectionRelinkResult is the member that took a ref's place, and + // whether it was already there (the old ref was then only removed). + CollectionRelinkResult struct { + Member Member `json:"member"` + Already bool `json:"already"` } // CollectionChanged is said after any change made through this door, once // per collection changed; Deleted says it is gone. @@ -156,17 +186,22 @@ type ( // Collector is the optional engine half of collections. One that is not a // Collector answers every collections method unknown_method and carries no // "collections" feature. Bounds and shapes are checked here before it is -// asked; depth and limit arrive already defaulted. +// asked; depth and limit arrive already defaulted, and a cursor is only +// checked for its size here: what it means is the engine's, which answers +// one it did not give bad_params. type Collector interface { - ListCollections(holds *Ref) ([]Collection, error) + ListCollections(holds *Ref, after string, limit int) (CollectionsResult, error) CreateCollection(name string) (Collection, error) RenameCollection(id, name string) (Collection, error) DuplicateCollection(id, name string) (Collection, error) DeleteCollection(id string) error AddToCollection(id string, refs []Member) (CollectionAddResult, error) RemoveFromCollection(id string, refs []Ref) (CollectionRemoveResult, error) - OpenCollection(id string, depth, limit int) (CollectionOpenResult, error) + OpenCollection(id string, depth, limit int, after string) (CollectionOpenResult, error) ExpandCollection(id string, depth, limit int) (CollectionExpandResult, error) + // RelinkCollection puts to in from's place, in one write: not_found + // when from is not a member or to is not there now, cycle as add. + RelinkCollection(id string, from, to Ref) (CollectionRelinkResult, error) } func collector(e Engine, name string) (Collector, error) { @@ -276,6 +311,15 @@ func bounded(field string, v *int, lo, hi, def int) (int, error) { return *v, nil } +// collectionCursor refuses a cursor no engine could have given: too long, or +// with spaces or control characters in it. +func collectionCursor(after string) error { + if after != "" && (!plainText(after, collectionCursorMax) || strings.IndexFunc(after, unicode.IsSpace) >= 0) { + return Errorf(CodeBadParams, "after is a cursor a previous page gave") + } + return nil +} + // collectionOf answers one collection or the error. func collectionOf(c Collection, err error) (*CollectionResult, error) { if err != nil { @@ -294,6 +338,12 @@ type ( Depth *int `json:"depth"` Limit *int `json:"limit"` } + collectionOpenParams struct { + ID string `json:"id"` + Depth *int `json:"depth"` + Limit *int `json:"limit"` + After string `json:"after"` + } ) func init() { @@ -304,21 +354,28 @@ func init() { var collectionMethods = map[string]method{ "collections.list": collectCall("collections.list", func(c Collector, p struct { - Holds *Ref `json:"holds"` + Holds *Ref `json:"holds"` + After string `json:"after"` + Limit *int `json:"limit"` }) (*CollectionsResult, error) { if p.Holds != nil { if err := CheckRef(*p.Holds, false); err != nil { return nil, err } } - list, err := c.ListCollections(p.Holds) + if err := collectionCursor(p.After); err != nil { + return nil, err + } + limit, err := bounded("limit", p.Limit, 1, CollectionsListMax, CollectionsListMax) if err != nil { return nil, err } - if len(list) > CollectionsListMax { - list = list[:CollectionsListMax] + r, err := c.ListCollections(p.Holds, p.After, limit) + if err != nil { + return nil, err } - return &CollectionsResult{nonNil(list)}, nil + r.Collections = nonNil(r.Collections) + return &r, nil }), "collections.create": collectCall("collections.create", func(c Collector, p struct { Name string `json:"name"` @@ -395,10 +452,13 @@ var collectionMethods = map[string]method{ r.Removed = nonNil(r.Removed) return &r, nil }), - "collections.open": collectCall("collections.open", func(c Collector, p collectionBoundParams) (*CollectionOpenResult, error) { + "collections.open": collectCall("collections.open", func(c Collector, p collectionOpenParams) (*CollectionOpenResult, error) { if err := needID("collections.open", p.ID); err != nil { return nil, err } + if err := collectionCursor(p.After); err != nil { + return nil, err + } depth, err := bounded("depth", p.Depth, 0, CollectionOpenDepth, 1) if err != nil { return nil, err @@ -407,7 +467,7 @@ var collectionMethods = map[string]method{ if err != nil { return nil, err } - r, err := c.OpenCollection(p.ID, depth, limit) + r, err := c.OpenCollection(p.ID, depth, limit, p.After) if err != nil { return nil, err } @@ -430,7 +490,30 @@ var collectionMethods = map[string]method{ if err != nil { return nil, err } - r.Refs = nonNil(r.Refs) + r.Refs, r.UnavailableRefs = nonNil(r.Refs), nonNil(r.UnavailableRefs) + return &r, nil + }), + "collections.relink": collectCall("collections.relink", func(c Collector, p struct { + ID string `json:"id"` + From *Ref `json:"from"` + To *Ref `json:"to"` + }) (*CollectionRelinkResult, error) { + if err := needID("collections.relink", p.ID); err != nil { + return nil, err + } + if p.From == nil || p.To == nil { + return nil, Errorf(CodeBadParams, "collections.relink needs from and to") + } + if err := CheckRef(*p.From, false); err != nil { + return nil, err + } + if err := CheckRef(*p.To, true); err != nil { + return nil, err + } + r, err := c.RelinkCollection(p.ID, *p.From, *p.To) + if err != nil { + return nil, err + } return &r, nil }), } diff --git a/internal/appwire/collections_test.go b/internal/appwire/collections_test.go index 56d31c1e3c..a334b03fec 100644 --- a/internal/appwire/collections_test.go +++ b/internal/appwire/collections_test.go @@ -14,17 +14,23 @@ import ( type fakeCollector struct { *fakeEngine depth, limit int + after string added []Member + from, to Ref } -func (f *fakeCollector) ListCollections(holds *Ref) ([]Collection, error) { +func (f *fakeCollector) ListCollections(holds *Ref, after string, limit int) (CollectionsResult, error) { + f.after, f.limit = after, limit + if after == "bogus" { + return CollectionsResult{}, Errorf(CodeBadParams, "that page cursor was not given by this listing") + } at := time.UnixMilli(1000).UTC() yes := holds != nil c := Collection{ID: "c1", Name: "Launch", CreatedAt: &at, Count: 2, LastAddedAt: &at} if holds != nil { c.Holds = &yes } - return []Collection{c}, nil + return CollectionsResult{Collections: []Collection{c}, Next: "n1", Total: 3}, nil } func (f *fakeCollector) CreateCollection(name string) (Collection, error) { if name == "Taken" { @@ -56,13 +62,22 @@ func (f *fakeCollector) AddToCollection(id string, refs []Member) (CollectionAdd func (f *fakeCollector) RemoveFromCollection(id string, refs []Ref) (CollectionRemoveResult, error) { return CollectionRemoveResult{}, nil } -func (f *fakeCollector) OpenCollection(id string, depth, limit int) (CollectionOpenResult, error) { - f.depth, f.limit = depth, limit - return CollectionOpenResult{Collection: Collection{ID: id, Name: "Launch"}}, nil +func (f *fakeCollector) OpenCollection(id string, depth, limit int, after string) (CollectionOpenResult, error) { + f.depth, f.limit, f.after = depth, limit, after + return CollectionOpenResult{Collection: Collection{ID: id, Name: "Launch"}, More: true, Next: "o1"}, nil } func (f *fakeCollector) ExpandCollection(id string, depth, limit int) (CollectionExpandResult, error) { f.depth, f.limit = depth, limit - return CollectionExpandResult{Collection: Collection{ID: id, Name: "Launch"}}, nil + return CollectionExpandResult{Collection: Collection{ID: id, Name: "Launch"}, Unavailable: 1, Unread: 2, + UnavailableRefs: []ExpandedRef{{Member: Member{Ref: Ref{Kind: RefCrew, ID: "k"}}, Target: Target{State: TargetUnknown, Reason: "the crew list could not be asked: busy"}}}}, nil +} +func (f *fakeCollector) RelinkCollection(id string, from, to Ref) (CollectionRelinkResult, error) { + f.from, f.to = from, to + if from.ID == "absent" { + return CollectionRelinkResult{}, Errorf(CodeNotFound, "that reference is not in this collection") + } + at := time.UnixMilli(2000).UTC() + return CollectionRelinkResult{Member: Member{Ref: to, AddedAt: &at}}, nil } func startCollectorWire(t *testing.T) (*wire, *fakeCollector) { @@ -231,3 +246,119 @@ func TestTheSchemaDescribesCollections(t *testing.T) { t.Errorf("target.state words = %v", words) } } + +// PAGING, HONEST EXPANSION AND RELINK ARE SHAPES THE WIRE CHECKS: a cursor +// and a list limit are bounded here, relink needs both refs and refuses +// adding the legacy kind, and every new field arrives as PROTOCOL.md says. +func TestCollectionsPagingExpandAndRelinkOnTheWire(t *testing.T) { + w, engine := startCollectorWire(t) + w.next() + call := func(id int, line string) map[string]any { + t.Helper() + w.send(line) + got := w.next() + if got["id"] != float64(id) { + t.Fatalf("%s = %v", line, got) + } + return got + } + code := func(got map[string]any) any { + if e, ok := got["error"].(map[string]any); ok { + return e["code"] + } + return nil + } + for i, line := range []string{ + `{"id":1,"method":"collections.list","params":{"limit":0}}`, + `{"id":2,"method":"collections.list","params":{"limit":1001}}`, + `{"id":3,"method":"collections.list","params":{"after":"has space"}}`, + `{"id":4,"method":"collections.open","params":{"id":"c1","after":"a\u0007"}}`, + `{"id":5,"method":"collections.relink","params":{"id":"c1","from":{"kind":"conversation","id":"a"}}}`, + `{"id":6,"method":"collections.relink","params":{"id":"c1","to":{"kind":"conversation","id":"a"}}}`, + `{"id":7,"method":"collections.relink","params":{"from":{"kind":"conversation","id":"a"},"to":{"kind":"conversation","id":"b"}}}`, + `{"id":8,"method":"collections.relink","params":{"id":"c1","from":{"kind":"conversation","id":"a"},"to":{"kind":"standing","id":"s"}}}`, + `{"id":9,"method":"collections.relink","params":{"id":"c1","from":{"kind":"conversation","id":"a"},"to":{"kind":"artifact","id":"rel"}}}`, + } { + if got := call(i+1, line); code(got) != CodeBadParams { + t.Fatalf("%s = %v", line, got) + } + } + got := call(10, `{"id":10,"method":"collections.list","params":{"after":"c0","limit":2}}`) + result := got["result"].(map[string]any) + if result["next"] != "n1" || result["total"] != float64(3) || engine.after != "c0" || engine.limit != 2 { + t.Fatalf("list page = %v (engine after %q limit %d)", got, engine.after, engine.limit) + } + call(11, `{"id":11,"method":"collections.list"}`) + if engine.after != "" || engine.limit != CollectionsListMax { + t.Fatalf("list defaults after %q limit %d", engine.after, engine.limit) + } + if got := call(12, `{"id":12,"method":"collections.list","params":{"after":"bogus"}}`); code(got) != CodeBadParams { + t.Fatalf("a foreign cursor = %v", got) + } + got = call(13, `{"id":13,"method":"collections.open","params":{"id":"c1","after":"o0"}}`) + if r := got["result"].(map[string]any); r["next"] != "o1" || r["more"] != true || engine.after != "o0" { + t.Fatalf("open page = %v", got) + } + got = call(14, `{"id":14,"method":"collections.expand","params":{"id":"c1"}}`) + r := got["result"].(map[string]any) + unavailable, _ := r["unavailable_refs"].([]any) + if r["unavailable"] != float64(1) || r["unread"] != float64(2) || len(unavailable) != 1 { + t.Fatalf("expand = %v", got) + } + if target := unavailable[0].(map[string]any)["target"].(map[string]any); target["state"] != TargetUnknown || target["reason"] == "" { + t.Fatalf("unavailable target = %v", target) + } + got = call(15, `{"id":15,"method":"collections.relink","params":{"id":"c1","from":{"kind":"standing","id":"s"},"to":{"kind":"conversation","id":"b"}}}`) + r = got["result"].(map[string]any) + if m := r["member"].(map[string]any); m["id"] != "b" || m["added_at"] != "1970-01-01T00:00:02Z" || r["already"] != false || engine.from.Kind != RefStanding { + t.Fatalf("relink = %v", got) + } + if got := call(16, `{"id":16,"method":"collections.relink","params":{"id":"c1","from":{"kind":"conversation","id":"absent"},"to":{"kind":"conversation","id":"b"}}}`); code(got) != CodeNotFound { + t.Fatalf("relink of a non-member = %v", got) + } +} + +// The schema carries this wave's later fields: relink, the page cursor and +// total, a target's reason and expand's unavailable and unread counts. +func TestTheSchemaDescribesCollectionPagingAndRelink(t *testing.T) { + out, err := SchemaJSON() + if err != nil { + t.Fatal(err) + } + var doc struct { + Methods map[string]struct { + Params map[string]any `json:"params"` + Result map[string]any `json:"result"` + } `json:"methods"` + Defs map[string]struct { + Properties map[string]map[string]any `json:"properties"` + Required []string `json:"required"` + } `json:"$defs"` + } + if err := json.Unmarshal(out, &doc); err != nil { + t.Fatal(err) + } + if _, ok := doc.Methods["collections.relink"]; !ok { + t.Fatal("schema lacks collections.relink") + } + for def, fields := range map[string][]string{ + "CollectionsResult": {"collections", "next", "total"}, + "CollectionOpenResult": {"next", "more"}, + "CollectionExpandResult": {"unavailable", "unavailable_refs", "unread", "missing", "more"}, + "CollectionRelinkResult": {"member", "already"}, + "Target": {"reason"}, + } { + for _, f := range fields { + if doc.Defs[def].Properties[f] == nil { + t.Errorf("schema %s lacks %s", def, f) + } + } + } + for _, def := range []string{"CollectionsResult", "CollectionOpenResult"} { + for _, r := range doc.Defs[def].Required { + if r == "next" { + t.Errorf("%s.next is required; it is present only when more remain", def) + } + } + } +} diff --git a/internal/manual/chat/collections.md b/internal/manual/chat/collections.md index 6f3150b2ef..0b878f3181 100644 --- a/internal/manual/chat/collections.md +++ b/internal/manual/chat/collections.md @@ -36,6 +36,10 @@ Use that ID with `codeaf collections rename "Launch"`. Names are unique without regard to case, so `create` or `rename` to a name another collection already has answers `another collection already has that name`; renaming a collection to its own name in another case is allowed. +Case is compared letter by letter without regard to language: `Ärger` and +`ärger` are one name, and so are `ΣΊΣΥΦΟΣ` and `σίσυφος`, but `İstanbul` and +`istanbul`, or `Straße` and `STRASSE`, are two. `list` and `show` print every +collection and every reference, however many there are. `codeaf collections show ` lists that collection's direct references. Order follows creation and insertion, rather than inferred relevance. Reading a fresh home or an existing empty file does not create a database. From 54edf476000e55ee2bd4aca9005252d8705ff830 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 19:18:58 -0400 Subject: [PATCH 041/459] app: serve a started member's brief on demand members.brief reads the manager's start line for one member from the crew's log. A member nobody started, or whose start line is gone, has none; nothing is taken from its conversation. --- cmd/codeaf/appcrewmembers.go | 38 +++++++++++++++++++++++++++ cmd/codeaf/appcrews_test.go | 51 ++++++++++++++++++++++++++++++++++++ internal/appwire/crews.go | 27 +++++++++++++++++++ 3 files changed, 116 insertions(+) diff --git a/cmd/codeaf/appcrewmembers.go b/cmd/codeaf/appcrewmembers.go index 82d46b6980..a7064c0d38 100644 --- a/cmd/codeaf/appcrewmembers.go +++ b/cmd/codeaf/appcrewmembers.go @@ -671,3 +671,41 @@ func (b *appBackend) createFromProposal(ask appwire.CrewCreate) (appwire.Crew, e b.crewTick(run, false) return b.CrewGet(id) } + +// MemberBrief is what a manager started a member with: the text of the start +// line its crew's log holds for that handle, read on demand for one member. A +// member nobody started, or one whose start line is gone, has none; nothing is +// inferred from its conversation. +func (b *appBackend) MemberBrief(crew, agent string) (appwire.MemberBrief, error) { + t, _, err := b.crewTeam(crew) + if err != nil { + return appwire.MemberBrief{}, err + } + m, ok := b.crewMemberOf(t, agent) + if !ok { + return appwire.MemberBrief{}, appwire.Errorf(appwire.CodeNotFound, "%s is not in %s", agent, t.Name) + } + if !m.Started || strings.TrimSpace(m.Handle) == "" { + return appwire.MemberBrief{}, nil + } + entries, err := teamstore.ReadTraffic(b.crewProfile(), t.ID, "", 0) + if err != nil { + return appwire.MemberBrief{}, appEngineError(err) + } + for i := len(entries) - 1; i >= 0; i-- { + e := entries[i] + if e.Kind != teamstore.KindStart || e.From != teamstore.FromManager || e.To != m.Handle { + continue + } + if e.Team != "" && e.Team != t.ID { + continue + } + at := e.At + from := "" + if boss, ok := t.Member(e.FromKey); ok { + from = boss.Handle + } + return appwire.MemberBrief{Text: redact.Secrets(strings.TrimSpace(e.Text)), From: from, At: &at}, nil + } + return appwire.MemberBrief{}, nil +} diff --git a/cmd/codeaf/appcrews_test.go b/cmd/codeaf/appcrews_test.go index 05fdfd9cc6..59e720ebf3 100644 --- a/cmd/codeaf/appcrews_test.go +++ b/cmd/codeaf/appcrews_test.go @@ -443,3 +443,54 @@ func TestAppCrewFormationReceiptIsTheSameFactLiveAndAfterRestart(t *testing.T) { t.Fatalf("another agent's = %+v", other) } } + +// A MEMBER'S BRIEF IS ITS START LINE, never its first user message: a person's +// later message (or a files-only prompt) to the member does not become what its +// manager told it, it survives a restart, and a member nobody started has none. +func TestAppMemberBriefIsTheManagersStartLineAndNothingElse(t *testing.T) { + backend, _, _, id := crewBackend(t) + webAgent := "b0b0b0b0" + // The member was started by its manager with this brief. + if err := teamstore.Update(backend.profileDir, func(f *teamstore.File) error { + for i := range f.Teams { + if f.Teams[i].ID != id { + continue + } + for j := range f.Teams[i].Members { + if f.Teams[i].Members[j].Handle == "web" { + f.Teams[i].Members[j].Started = true + } + } + } + return nil + }); err != nil { + t.Fatal(err) + } + const brief = "Own the web client; BIRCH-9304 is yours." + if err := teamstore.AppendTraffic(backend.profileDir, id, teamstore.Entry{Kind: teamstore.KindStart, From: teamstore.FromManager, FromKey: appFakeTranscript, To: "web", Text: brief}); err != nil { + t.Fatal(err) + } + // Then you message it directly: a later line that is not the brief. + if err := teamstore.AppendTraffic(backend.profileDir, id, teamstore.Entry{Kind: teamstore.KindYou, From: teamstore.FromYou, To: "web", Text: "see the attached screenshot"}); err != nil { + t.Fatal(err) + } + got, err := backend.MemberBrief(id, webAgent) + if err != nil || got.Text != brief || got.From != "lead" { + t.Fatalf("brief = %+v, %v", got, err) + } + // A restart reads the same words from the log. + fresh := newFakeBackend(newAppRecorder(), nil) + t.Cleanup(func() { fresh.Close() }) + fresh.profileDir = backend.profileDir + if again, err := fresh.MemberBrief(id, webAgent); err != nil || again.Text != brief { + t.Fatalf("after restart = %+v, %v", again, err) + } + // The manager was not started by a start line: no brief, none invented. + if none, err := backend.MemberBrief(id, appMemberAgent(teamstore.Member{Key: appFakeTranscript, File: appFakeTranscript})); err != nil || none.Text != "" { + t.Fatalf("manager brief = %+v, %v", none, err) + } + var wire *appwire.Error + if _, err := backend.MemberBrief(id, "ffffffff"); !errors.As(err, &wire) || wire.Code != appwire.CodeNotFound { + t.Fatalf("a stranger has a brief: %v", err) + } +} diff --git a/internal/appwire/crews.go b/internal/appwire/crews.go index 24ac82b20a..e6b2406c32 100644 --- a/internal/appwire/crews.go +++ b/internal/appwire/crews.go @@ -397,6 +397,7 @@ type Crewer interface { CrewUnlink(id, item string) error CrewPropose(agent string) error Member(verb string, ask MemberAsk) (CrewMember, error) + MemberBrief(crew, agent string) (MemberBrief, error) Roles() ([]Role, error) RoleUpdate(id string, name, instructions, model *string) (Role, error) } @@ -449,6 +450,15 @@ func crewOf(c Crew, err error) (*crewResult, error) { return &crewResult{c}, nil } +// MemberBrief is the words a manager started a member with, read from the +// crew's own start line. Text is empty when the record holds none: a member a +// person added, a crew's first manager, or a start older than the log. +type MemberBrief struct { + Text string `json:"text,omitempty"` + From string `json:"from,omitempty"` + At *time.Time `json:"at,omitempty"` +} + // MemberResult is a member verb's answer. type MemberResult struct { Member CrewMember `json:"member"` @@ -655,6 +665,23 @@ var crewMethods = map[string]method{ } return done(c.CrewPropose(p.Agent)) }), + "members.brief": crewCall("members.brief", func(c Crewer, p struct { + Crew string `json:"crew"` + Agent string `json:"agent"` + }) (*struct { + Brief MemberBrief `json:"brief"` + }, error) { + if strings.TrimSpace(p.Crew) == "" || strings.TrimSpace(p.Agent) == "" { + return nil, Errorf(CodeBadParams, "members.brief needs a crew and an agent") + } + brief, err := c.MemberBrief(p.Crew, p.Agent) + if err != nil { + return nil, err + } + return &struct { + Brief MemberBrief `json:"brief"` + }{brief}, nil + }), "members.add": memberVerb("add"), "members.start": memberVerb("start"), "members.stop": memberVerb("stop"), From 457f5efc4547132ac7f09437e7d22bfab1bde5db Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 19:23:00 -0400 Subject: [PATCH 042/459] session, app: state compaction passes as facts and announce a summary while it is written - session.CompactionFact on Event.Compaction and the journal's compaction line; EventSummaryStarted / EventSummaryFailed; failed summaries journaled as compaction_failed; the fact names the model that answered. - Agent.ContextUse (tokens, window, threshold, summarizing; unknown window absent) and Facts.contextWindow/compactAt/summarizing. - Display readings stack Record.Earlier across every marker and place Record.Passes; a live or resumed Agent keeps one hop, a pass counter and the newest summary's provenance only. - Agent.CompactObserved, cut by Interrupt/StopWork/Close; remote streams its events as pass frames. - appwire: Agent.context, Entry.compaction (id cs), agents.compact, compaction.ended; the app no longer calls a mechanical pass a summary. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/app.go | 84 ++++- cmd/codeaf/appcompaction.go | 276 +++++++++++++++ cmd/codeaf/appcompaction_test.go | 257 ++++++++++++++ .../unreleased/app-compaction-facts.md | 13 + internal/appwire/compaction.go | 107 ++++++ internal/appwire/wire.go | 5 + internal/remote/client.go | 6 + internal/remote/compact_test.go | 50 +++ internal/remote/passes.go | 92 +++++ internal/remote/server.go | 3 +- internal/session/agent.go | 18 + internal/session/compact_summary.go | 40 ++- internal/session/compaction_facts.go | 262 ++++++++++++++ internal/session/compaction_facts_test.go | 320 ++++++++++++++++++ .../session/compaction_model_fact_test.go | 67 ++++ internal/session/compaction_stop_fact_test.go | 49 +++ internal/session/facts.go | 11 + internal/session/loop.go | 54 ++- internal/session/recordcache.go | 15 + internal/session/session.go | 31 ++ internal/session/sessionfile.go | 168 +++++++-- internal/session/stopwork.go | 2 + internal/session/transcriptread.go | 4 + internal/session/turnfold.go | 5 +- internal/tui3/salience_test.go | 4 + 25 files changed, 1896 insertions(+), 47 deletions(-) create mode 100644 cmd/codeaf/appcompaction.go create mode 100644 cmd/codeaf/appcompaction_test.go create mode 100644 docs/changes/unreleased/app-compaction-facts.md create mode 100644 internal/appwire/compaction.go create mode 100644 internal/remote/passes.go create mode 100644 internal/session/compaction_facts.go create mode 100644 internal/session/compaction_facts_test.go create mode 100644 internal/session/compaction_model_fact_test.go create mode 100644 internal/session/compaction_stop_fact_test.go diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index 936b55093a..cec6205f47 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -28,6 +28,7 @@ package main // rebuilt transparently by the next send. import ( + "cmp" "context" "encoding/json" "errors" @@ -37,6 +38,7 @@ import ( "os" "os/signal" "path/filepath" + "slices" "sort" "strconv" "strings" @@ -228,6 +230,9 @@ type appConn struct { // conversation held in this process, which hands its own turns over // directly (appwakes.go). Follow func() <-chan remote.Following + // Passes is the compaction passes asked for outside any turn, as their + // events arrive (appcompaction.go); nil where the connection has none. + Passes func() <-chan session.Event // Assembled says this process built the conversation itself, so the shared // engine half must be closed when the app leaves. Assembled bool @@ -302,7 +307,7 @@ func appOpenInProcess(ask appOpenAsk) (*appConn, error) { func appConnOf(client *remote.Client, release func(), assembled bool) *appConn { welcome := client.Welcome() - return &appConn{Conv: client.Agent(), Workspace: welcome.Workspace, Transcript: welcome.SessionFile, Release: release, Follow: client.Follow, Assembled: assembled} + return &appConn{Conv: client.Agent(), Workspace: welcome.Workspace, Transcript: welcome.SessionFile, Release: release, Follow: client.Follow, Passes: client.Passes, Assembled: assembled} } // appBackend is the [appwire.Engine] over this process's conversations. @@ -509,6 +514,10 @@ type appLive struct { msgBase, noteBase int lastN, lastMsgs, lastNotices int stopWakes func() + // stopPasses stops the pass lane's reader, and compacting says a pass + // the app asked for is running (appcompaction.go). + stopPasses func() + compacting bool // retries counts the running turn's waits before a step is asked again, // retryPhase and retryUntil are the last one said, and retrySaid says the // next void attempt was already said with its countdown. @@ -1012,6 +1021,7 @@ func (b *appBackend) hold(a *appAgent, conn *appConn, workspace string) appwire. a.view.Model = model } a.view.State = live.restingState() + a.view.Context = appContextOf(conn.Conv) if a.view.UpdatedAt.IsZero() { a.view.UpdatedAt = b.now() } @@ -1041,6 +1051,7 @@ func (b *appBackend) hold(a *appAgent, conn *appConn, workspace string) appwire. b.died(a, live) }) live.stopWakes = b.watchWakes(a, live) + live.stopPasses = b.watchPasses(a, live) return view } @@ -1619,9 +1630,19 @@ func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, n if announce { live.box.Event(appwire.EventTurnRetrying, retry) } - case session.EventCompacted: - entry(appwire.Entry{ID: "c" + strconv.Itoa(n) + "." + strconv.Itoa(summaries), Kind: appwire.EntrySummary, Text: "Earlier messages summarized"}) - summaries++ + case session.EventCompacted, session.EventSummaryStarted, session.EventSummaryFailed: + // A PASS IS ITS FACTS (appcompaction.go): its entry is named by its + // sequence, so the summarizing entry is replaced by how it ended. + // An engine too old to say them gets the line it always got, said + // only for a pass that summarized. + if pass, ok := appPassEvent(id, event); ok { + pass.Turn = turn + live.box.Event(appwire.EventEntryAdded, appwire.EntryAdded{Entry: pass}) + } else if event.Kind == session.EventCompacted && event.Summarized > 0 { + entry(appwire.Entry{ID: "c" + strconv.Itoa(n) + "." + strconv.Itoa(summaries), Kind: appwire.EntrySummary, Text: appCompactionWords}) + summaries++ + } + b.noteContext(a, live) case session.EventToolBegin: b.locked(func() { live.steps++ }) endThought() @@ -1651,6 +1672,7 @@ func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, n title = redact.Secrets(tui3.ToolTitle(event.Tool, event.Args, "")) } live.box.Event(appwire.EventTurnTool, b.callEnded(a, id, turn, event, title, summary, appDetail(summary), succeeded)) + b.noteContext(a, live) // THE SEVENTEENTH PROCESS is refused by the engine, and the turn // says so once at its end. if !succeeded && strings.Contains(event.Output+summary, "processes are running for this agent.") { @@ -1674,6 +1696,7 @@ func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, n // A turn the person steered ends once per stretch; the last ending // on the stream is the turn's. ok, failure, kind, status = true, "", "", 0 + b.noteContext(a, live) case session.EventTitleChanged: if title := strings.TrimSpace(event.Text); title != "" { changed(func(v *appwire.Agent) { v.Title = title }) @@ -1784,17 +1807,47 @@ func appUserCount(transcript string) int { // appSummaryRole marks where a compaction summarized what came before. const appSummaryRole = "summary" -// appRecordItems is a record told once and whole: the conversation above the -// last compaction, a mark where it was summarized, and the live transcript -// beneath its floor. +// appRecordItems is a record told once and whole: every line above every +// compaction, the live transcript beneath the last one's floor, and a mark +// for each pass at its own place, carrying the pass ([appRecordPass]). func appRecordItems(record session.Record) []session.DisplayEntry { - if len(record.Earlier) == 0 { + if len(record.Earlier) == 0 && len(record.Passes) == 0 { return record.Entries } - items := append([]session.DisplayEntry(nil), record.Earlier...) - items = append(items, session.DisplayEntry{Role: appSummaryRole}) floor := min(max(record.Floor, 0), len(record.Entries)) - return append(items, record.Entries[floor:]...) + if len(record.Passes) == 0 { + // A region with no pass facts — a record told by an engine from + // before they existed — is marked once, where the region ends. + items := append([]session.DisplayEntry(nil), record.Earlier...) + items = append(items, session.DisplayEntry{Role: appSummaryRole}) + return append(items, record.Entries[floor:]...) + } + told := len(record.Earlier) + len(record.Entries) - floor + items := make([]session.DisplayEntry, 0, told+len(record.Passes)) + // In place order; a pass's sequence breaks a tie, so two passes at one + // place stand in the order they were made. + passes := slices.Clone(record.Passes) + slices.SortStableFunc(passes, func(x, y session.CompactionFact) int { return cmp.Compare(x.Entry, y.Entry) }) + mark := func(at int) { + for len(passes) > 0 && passes[0].Entry <= at { + pass := passes[0] + passes = passes[1:] + if pass.Entry < 0 { + continue + } + items = append(items, session.DisplayEntry{Role: appSummaryRole, Pass: &pass}) + } + } + position := 0 + for _, part := range [][]session.DisplayEntry{record.Earlier, record.Entries[floor:]} { + for _, item := range part { + mark(position) + items = append(items, item) + position++ + } + } + mark(told) + return items } // appDetailLimit bounds a tool's short result line. @@ -1935,6 +1988,9 @@ func (b *appBackend) finish(live *appLive) { if live.stopWakes != nil { live.stopWakes() } + if live.stopPasses != nil { + live.stopPasses() + } live.conn.Release() wait := time.After(appReleaseWait) if live.pumpDone != nil { @@ -2250,7 +2306,11 @@ func appEntryShapes(agent string, items []session.DisplayEntry, at time.Time, ma } switch item.Role { case appSummaryRole: - entry.ID, entry.Kind, entry.Text = "c"+strconv.Itoa(max(n, 0))+".s"+strconv.Itoa(c), appwire.EntrySummary, "Earlier messages summarized" + if item.Pass != nil && item.Pass.Seq > 0 { + entry = appCompactionEntry(agent, *item.Pass, appCompactionState(*item.Pass)) + } else { + entry.ID, entry.Kind, entry.Text = "c"+strconv.Itoa(max(n, 0))+".s"+strconv.Itoa(c), appwire.EntrySummary, appCompactionWords + } c++ case "user": n, m, k = n+1, 0, 0 diff --git a/cmd/codeaf/appcompaction.go b/cmd/codeaf/appcompaction.go new file mode 100644 index 0000000000..beaa211245 --- /dev/null +++ b/cmd/codeaf/appcompaction.go @@ -0,0 +1,276 @@ +package main + +// COMPACTION FOR THE APP (V78): the conversation's weight against its window +// on agent.context, each pass as a summary entry's facts, and agents.compact — +// the same pass /compact runs — answered at once with the pass saying itself +// as entries. +// +// Everything here is the engine's facts moved onto the wire. No figure is +// derived from a hint's words, and no word here is for a person: the app +// words every state. + +import ( + "context" + "errors" + "strconv" + "strings" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/guard" + "github.com/Agent-Field/codeaf/internal/redact" + "github.com/Agent-Field/codeaf/internal/session" +) + +// appContextSource is a conversation that can say what it weighs against its +// window: a [remote.Agent] answers from the engine's last stated facts. +type appContextSource interface { + ContextUse() session.ContextUse +} + +// appContextOf is a conversation's context as the wire carries it, and nil +// when it cannot say. +func appContextOf(conv appConversation) *appwire.AgentContext { + source, ok := conv.(appContextSource) + if !ok { + return nil + } + use := source.ContextUse() + return &appwire.AgentContext{Tokens: use.Tokens, Window: use.Window, Threshold: use.Threshold, Summarizing: use.Summarizing} +} + +// appSameContext says two readings would draw the same. +func appSameContext(a, b *appwire.AgentContext) bool { + if a == nil || b == nil { + return a == b + } + return *a == *b +} + +// noteContext reads the conversation's context once and says it when it +// moved. It is called at step and pass boundaries, never per token. +func (b *appBackend) noteContext(a *appAgent, live *appLive) { + if live == nil || live.conn == nil { + return + } + next := appContextOf(live.conn.Conv) + var view appwire.Agent + moved := false + b.locked(func() { + if a.live != live || appSameContext(a.view.Context, next) { + return + } + a.view.Context = next + view, moved = a.view, true + }) + if moved { + live.box.Event(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) + } +} + +// appCompactionEntryID names a pass's entry by its sequence, the same live +// and read back. +func appCompactionEntryID(seq int) string { return "cs" + strconv.Itoa(seq) } + +// appCompactionWords is the text an older app drew on a summary entry. It is +// set only where it is true — a pass that summarized — so a mechanical pass +// is never called a summary even by an app that reads no facts. +const appCompactionWords = "Earlier messages summarized" + +// appCompactionEntry is one pass as a summary entry. +func appCompactionEntry(agent string, fact session.CompactionFact, state string) appwire.Entry { + at := fact.At + if at.IsZero() { + at = time.Now() + } + facts := &appwire.Compaction{ + Seq: fact.Seq, State: state, TokensBefore: fact.TokensBefore, TokensAfter: fact.TokensAfter, + Stubbed: fact.Stubbed, Folded: fact.Folded, Summarized: fact.Summarized, + Model: fact.Model, Turns: fact.Turns, Failed: fact.Failed, Full: fact.Full, + } + if kept := redact.Secrets(fact.Summary); kept != "" { + if len(kept) > appwire.KeptLimit { + kept, facts.KeptClipped = appClipUTF8(kept, appwire.KeptLimit), true + } + facts.Kept = kept + } + if !fact.CoversFrom.IsZero() { + from := fact.CoversFrom + facts.CoversFrom = &from + } + if !fact.CoversUntil.IsZero() { + until := fact.CoversUntil + facts.CoversUntil = &until + } + entry := appwire.Entry{ID: appCompactionEntryID(fact.Seq), Agent: agent, Kind: appwire.EntrySummary, At: at, Compaction: facts} + if state == appwire.CompactionSettled && fact.Summarized > 0 { + entry.Text = appCompactionWords + } + return entry +} + +// appClipUTF8 cuts text to at most limit bytes on a rune boundary. +func appClipUTF8(text string, limit int) string { + if len(text) <= limit { + return text + } + cut := limit + for cut > 0 && text[cut]&0xC0 == 0x80 { + cut-- + } + return text[:cut] +} + +// appCompactionState is the state an ended pass is drawn in. +func appCompactionState(fact session.CompactionFact) string { + if fact.Failed != "" { + return appwire.CompactionFailed + } + return appwire.CompactionSettled +} + +// appPassEvent is one pass event as the entry it makes, and false for any +// other event or a pass from an engine too old to say its facts. +func appPassEvent(agent string, event session.Event) (appwire.Entry, bool) { + if event.Compaction == nil || event.Compaction.Seq <= 0 { + return appwire.Entry{}, false + } + switch event.Kind { + case session.EventSummaryStarted: + return appCompactionEntry(agent, *event.Compaction, appwire.CompactionSummarizing), true + case session.EventCompacted, session.EventSummaryFailed: + return appCompactionEntry(agent, *event.Compaction, appCompactionState(*event.Compaction)), true + } + return appwire.Entry{}, false +} + +// appCompactConv is a conversation that runs a pass when asked. +type appCompactConv interface { + Compact(ctx context.Context) error +} + +// appCompactJobs bounds the passes this process runs for the app at once, +// across every agent; each agent runs at most one ([appLive.compacting]). +const appCompactJobs = 4 + +// appCompactSlots is that bound. +var appCompactSlots = make(chan struct{}, appCompactJobs) + +// watchPasses is the one reader of a connection's pass lane for as long as the +// conversation is held: every pass asked for outside a turn — by this app or +// by another window — becomes the entry it is. It answers the function that +// stops it; a lane the connection closes ends it too. +func (b *appBackend) watchPasses(a *appAgent, live *appLive) func() { + if live.conn.Passes == nil { + return func() {} + } + passes := live.conn.Passes() + if passes == nil { + return func() {} + } + stop := make(chan struct{}) + id := "" + b.locked(func() { id = a.view.ID }) + guard.Go("app/passes", func() { + appReadPasses(stop, passes, func(event session.Event) { + if entry, said := appPassEvent(id, event); said { + live.box.Event(appwire.EventEntryAdded, appwire.EntryAdded{Entry: entry}) + } + b.noteContext(a, live) + }) + }) + var once sync.Once + return func() { once.Do(func() { close(stop) }) } +} + +// appReadPasses hands each pass to take until stop is closed or the lane is: +// a closed lane ends the reading rather than answering zero values forever. +func appReadPasses(stop <-chan struct{}, passes <-chan session.Event, take func(session.Event)) { + for { + select { + case <-stop: + return + case event, ok := <-passes: + if !ok { + return + } + take(event) + } + } +} + +// CompactAgent starts the pass /compact runs and answers at once. It is +// admitted BEFORE anything starts: one pass per agent and [appCompactJobs] +// in the process, refused busy otherwise. One worker owns the call and its +// answer, releases both admissions on every road out, and ends the call on +// compaction.ended; the pass's own events reach the page through the +// connection's pass lane ([appBackend.watchPasses]), ahead of that answer. +func (b *appBackend) CompactAgent(id string) error { + a, live, err := b.heldLive(id) + if err != nil { + return err + } + conv, ok := live.conn.Conv.(appCompactConv) + if !ok { + return appwire.Errorf(appwire.CodeUnknownMethod, "this conversation cannot be compacted from here") + } + admitted := false + b.locked(func() { + if !live.compacting && !live.closed { + live.compacting, admitted = true, true + } + }) + if !admitted { + return appwire.Errorf(appwire.CodeBusy, "a compaction of this conversation is already running") + } + select { + case appCompactSlots <- struct{}{}: + default: + b.locked(func() { live.compacting = false }) + return appwire.Errorf(appwire.CodeBusy, "too many compactions are running; ask again in a moment") + } + guard.Go("app/compact", func() { + defer func() { + <-appCompactSlots + b.locked(func() { live.compacting = false }) + }() + ctx, cancel := context.WithTimeout(context.Background(), session.CompactPatience) + result := conv.Compact(ctx) + cancel() + b.noteContext(a, live) + live.box.Event(appwire.EventCompactionEnded, appCompactEnded(id, result)) + }) + return nil +} + +// appCompactEnded is how one asked-for pass ended. Over a connection an +// engine's error arrives as its words alone, so the two refusals are matched +// by the words their own Error methods say; anything else is an error and is +// never called "nothing to compact". +func appCompactEnded(id string, err error) appwire.CompactionEnded { + ended := appwire.CompactionEnded{Agent: id, Outcome: appwire.CompactionCompacted} + if err == nil { + return ended + } + words := err.Error() + var nothing *session.NothingToCompact + switch { + case errors.Is(err, session.ErrCompactionInFlight) || words == session.ErrCompactionInFlight.Error(): + ended.Outcome = appwire.CompactionBusy + case errors.As(err, ¬hing): + ended.Outcome, words = appwire.CompactionNothing, nothing.Why + case errors.Is(err, session.ErrNothingToCompact) || strings.HasPrefix(words, session.ErrNothingToCompact.Error()): + ended.Outcome = appwire.CompactionNothing + words = strings.TrimPrefix(strings.TrimPrefix(words, session.ErrNothingToCompact.Error()), ": ") + default: + // A pass that changed the conversation but whose summary was skipped + // still compacted; its entry says the summary did not land. + if _, skipped := session.SummarySkippedWhy(err); skipped { + return ended + } + ended.Outcome = appwire.CompactionError + } + ended.Message = redact.Secrets(strings.TrimSpace(words)) + return ended +} diff --git a/cmd/codeaf/appcompaction_test.go b/cmd/codeaf/appcompaction_test.go new file mode 100644 index 0000000000..3b90f0d602 --- /dev/null +++ b/cmd/codeaf/appcompaction_test.go @@ -0,0 +1,257 @@ +package main + +import ( + "context" + "errors" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/session" +) + +// contextConversation is a fake conversation that can say its weight. +type contextConversation struct { + *fakeConversation + use session.ContextUse +} + +func (c *contextConversation) ContextUse() session.ContextUse { return c.use } + +// A PASS IN A TURN IS ITS FACTS ON THE WIRE. The summarizing entry is replaced +// by how it ended under the same id; a pass that only stubbed never says it +// summarized; a failure says why and whether the context is full; and the +// agent's context rides agent.changed. +func TestAppLivePassesAreEntriesNamedByTheirSequence(t *testing.T) { + secret := "sk-or-v1-" + strings.Repeat("a1b2", 16) + conv := &contextConversation{fakeConversation: &fakeConversation{script: []session.Event{ + {Kind: session.EventSummaryStarted, Compaction: &session.CompactionFact{Seq: 1, TokensBefore: 182_000}}, + {Kind: session.EventCompacting, Hint: "compacting ~182k tokens"}, + {Kind: session.EventCompacted, Summarized: 40, Compaction: &session.CompactionFact{ + Seq: 1, TokensBefore: 182_000, TokensAfter: 21_000, Summarized: 40, + Summary: "Goal. Retry webhooks with key " + secret, Model: "deepseek/deepseek-v4.1-flash", Turns: 18, + }}, + {Kind: session.EventCompacted, Compaction: &session.CompactionFact{Seq: 2, TokensBefore: 60_000, TokensAfter: 40_000, Stubbed: 3}}, + {Kind: session.EventSummaryFailed, Compaction: &session.CompactionFact{Seq: 3, TokensBefore: 120_000, TokensAfter: 120_000, Failed: session.SummaryFailedError, Full: true}}, + {Kind: session.EventTurnDone}, + }}, use: session.ContextUse{Tokens: 21_000, Window: 200_000, Threshold: 180_000}} + rec := newAppRecorder() + backend := newFakeBackend(rec, func(ask appOpenAsk) (*appConn, error) { + return &appConn{Conv: conv, Workspace: ask.Workspace, Transcript: appFakeTranscript, Release: func() {}}, nil + }) + defer backend.Close() + agent, err := mustCreate(t, backend, appwire.CreateAsk{Workspace: t.TempDir(), Model: "deepseek/deepseek-v4.1-flash"}) + if err != nil { + t.Fatal(err) + } + if agent.Context == nil || agent.Context.Window != 200_000 || agent.Context.Tokens != 21_000 { + t.Fatalf("held agent context = %+v", agent.Context) + } + if _, err := backend.Send(agent.ID, "go", ""); err != nil { + t.Fatal(err) + } + rec.waitFor(t, 5*time.Second, appTurnDone) + + rec.mu.Lock() + var passes []appwire.Entry + for i, event := range rec.events { + if added, ok := rec.data[i].(appwire.EntryAdded); ok && event == appwire.EventEntryAdded && added.Entry.Kind == appwire.EntrySummary { + passes = append(passes, added.Entry) + } + } + rec.mu.Unlock() + if len(passes) != 4 { + t.Fatalf("summary entries = %d, want summarizing, settled, mechanical, failed", len(passes)) + } + live, settled, mechanical, failed := passes[0], passes[1], passes[2], passes[3] + if live.ID != "cs1" || settled.ID != "cs1" || live.Compaction.State != appwire.CompactionSummarizing || settled.Compaction.State != appwire.CompactionSettled { + t.Fatalf("the live entry is not replaced by its settling: %+v / %+v", live, settled) + } + if live.Text != "" { + t.Fatalf("a summary still being written claims words: %q", live.Text) + } + kept := settled.Compaction.Kept + if strings.Contains(kept, secret) || !strings.HasPrefix(kept, "Goal. Retry webhooks") { + t.Fatalf("kept = %q; want the summary with its secret redacted", kept) + } + if settled.Compaction.TokensBefore != 182_000 || settled.Compaction.TokensAfter != 21_000 || settled.Compaction.Turns != 18 || settled.Compaction.Model != "deepseek/deepseek-v4.1-flash" { + t.Fatalf("settled facts = %+v", settled.Compaction) + } + if mechanical.ID != "cs2" || mechanical.Text != "" || mechanical.Compaction.Summarized != 0 || mechanical.Compaction.Kept != "" { + t.Fatalf("a stub-only pass claims a summary: %+v", mechanical) + } + if failed.ID != "cs3" || failed.Compaction.State != appwire.CompactionFailed || failed.Compaction.Failed != "error" || !failed.Compaction.Full { + t.Fatalf("failure = %+v", failed.Compaction) + } +} + +// THE LISTING TELLS EVERY LINE ONCE AND EVERY PASS IN ITS PLACE, under the +// same ids the live pump used, with a pass whose region could not be placed +// left out rather than piled at the top. +func TestAppListingPlacesEveryPassBetweenTheLinesItFollows(t *testing.T) { + record := session.Record{ + Earlier: []session.DisplayEntry{ + {Role: "user", Text: "q1"}, {Role: "assistant", Text: "a1"}, + {Role: "user", Text: "q2"}, {Role: "assistant", Text: "a2"}, + }, + Entries: []session.DisplayEntry{{Role: "user", Text: "[context compacted] note"}, {Role: "user", Text: "q2"}, {Role: "assistant", Text: "a2"}, {Role: "user", Text: "q3"}}, + Floor: 3, + Passes: []session.CompactionFact{ + {Seq: 1, Entry: 2, Summarized: 2, Summary: "first", TokensBefore: 10, TokensAfter: 5}, + {Seq: 2, Entry: -1, Stubbed: 1}, + {Seq: 3, Entry: 4, Summarized: 4, Summary: "second"}, + {Seq: 4, Entry: 5, Failed: session.SummaryFailedDeclined}, + }, + } + entries := appEntriesOf("a", appRecordItems(record), time.Now(), nil) + var got []string + for _, entry := range entries { + if entry.Kind == appwire.EntrySummary { + got = append(got, entry.ID+":"+entry.Compaction.State) + } else { + got = append(got, entry.ID+":"+entry.Text) + } + } + want := "u0:q1 a0.0:a1 cs1:settled u1:q2 a1.0:a2 cs3:settled u2:q3 cs4:failed" + if strings.Join(got, " ") != want { + t.Fatalf("listing:\n got %s\nwant %s", strings.Join(got, " "), want) + } + for _, entry := range entries { + if entry.ID == "cs3" && entry.Compaction.Kept != "second" { + t.Fatalf("pass 3 kept = %q", entry.Compaction.Kept) + } + } +} + +// A KEPT SUMMARY PAST THE WIRE'S LIMIT IS CUT ON A RUNE AND SAYS SO. +func TestAppKeptSummaryIsBoundedAndSaysWhenCut(t *testing.T) { + long := strings.Repeat("é", appwire.KeptLimit) + entry := appCompactionEntry("a", session.CompactionFact{Seq: 1, Summarized: 3, Summary: long}, appwire.CompactionSettled) + kept := entry.Compaction.Kept + if !entry.Compaction.KeptClipped || len(kept) > appwire.KeptLimit || !strings.HasSuffix(kept, "é") { + t.Fatalf("clipped = %v, %d bytes, ends %q", entry.Compaction.KeptClipped, len(kept), kept[len(kept)-2:]) + } +} + +// EVERY ASKED-FOR PASS ENDS TRUTHFULLY. Over a connection an error is only +// its words, so the two refusals are matched by them; anything else is an +// error and is never called "nothing to compact". +func TestAppCompactEndsTruthfully(t *testing.T) { + cases := []struct { + err error + outcome string + message string + }{ + {nil, appwire.CompactionCompacted, ""}, + {errors.New(session.ErrCompactionInFlight.Error()), appwire.CompactionBusy, ""}, + {&session.NothingToCompact{Why: "only 300 tokens before your latest message"}, appwire.CompactionNothing, "only 300 tokens before your latest message"}, + {errors.New((&session.NothingToCompact{Why: "nothing new since the last summary"}).Error()), appwire.CompactionNothing, "nothing new since the last summary"}, + {&session.SummarySkipped{Why: "the model declined"}, appwire.CompactionCompacted, ""}, + {errors.New("remote: the connection closed"), appwire.CompactionError, "remote: the connection closed"}, + } + for _, c := range cases { + ended := appCompactEnded("a", c.err) + if ended.Outcome != c.outcome || (c.message != "" && ended.Message != c.message) { + t.Errorf("%v → %+v, want %s %q", c.err, ended, c.outcome, c.message) + } + } +} + +// compactConversation is a fake whose pass waits until it is let go. +type compactConversation struct { + *fakeConversation + release chan struct{} + mu sync.Mutex + calls int +} + +func (c *compactConversation) Compact(ctx context.Context) error { + c.mu.Lock() + c.calls++ + c.mu.Unlock() + select { + case <-c.release: + return nil + case <-ctx.Done(): + return ctx.Err() + } +} + +// ONE PASS PER AGENT, ADMITTED BEFORE ANYTHING STARTS, AND EVERY CALL ENDS. +// A second ask while the first runs is refused busy without a second call; +// the first ends on compaction.ended and frees the agent; and a pass lane the +// connection closes stops its reader instead of spinning on it. +func TestAppCompactAdmitsOnePassPerAgentAndAlwaysEnds(t *testing.T) { + conv := &compactConversation{fakeConversation: &fakeConversation{}, release: make(chan struct{})} + lane := make(chan session.Event) + close(lane) + rec := newAppRecorder() + backend := newFakeBackend(rec, func(ask appOpenAsk) (*appConn, error) { + return &appConn{Conv: conv, Workspace: ask.Workspace, Transcript: appFakeTranscript, Release: func() {}, + Passes: func() <-chan session.Event { return lane }}, nil + }) + defer backend.Close() + agent, err := mustCreate(t, backend, appwire.CreateAsk{Workspace: t.TempDir(), Model: "deepseek/deepseek-v4.1-flash"}) + if err != nil { + t.Fatal(err) + } + if err := backend.CompactAgent(agent.ID); err != nil { + t.Fatalf("first compact: %v", err) + } + var refused *appwire.Error + if err := backend.CompactAgent(agent.ID); !errors.As(err, &refused) || refused.Code != appwire.CodeBusy { + t.Fatalf("second compact while the first runs = %v, want busy", err) + } + close(conv.release) + rec.waitFor(t, 5*time.Second, func(event string, data any) bool { + ended, ok := data.(appwire.CompactionEnded) + return event == appwire.EventCompactionEnded && ok && ended.Outcome == appwire.CompactionCompacted && ended.Agent == agent.ID + }) + deadline := time.Now().Add(5 * time.Second) + for { + err := backend.CompactAgent(agent.ID) + if err == nil { + break + } + if time.Now().After(deadline) { + t.Fatalf("the agent was never freed after its pass ended: %v", err) + } + time.Sleep(5 * time.Millisecond) + } + ends := 0 + rec.waitFor(t, 5*time.Second, func(event string, data any) bool { + if ended, ok := data.(appwire.CompactionEnded); ok && event == appwire.EventCompactionEnded && ended.Agent == agent.ID { + ends++ + } + return ends == 2 + }) + conv.mu.Lock() + calls := conv.calls + conv.mu.Unlock() + if calls != 2 { + t.Fatalf("engine passes = %d, want 2 (the refused ask made none)", calls) + } +} + +// A CLOSED PASS LANE ENDS ITS READER; it is never read as endless zero events. +func TestAppPassReaderEndsOnAClosedLane(t *testing.T) { + lane := make(chan session.Event, 1) + lane <- session.Event{Kind: session.EventCompacted, Compaction: &session.CompactionFact{Seq: 1}} + close(lane) + taken := 0 + done := make(chan struct{}) + go func() { + appReadPasses(make(chan struct{}), lane, func(session.Event) { taken++ }) + close(done) + }() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("the reader kept reading a closed lane") + } + if taken != 1 { + t.Fatalf("took %d passes, want the one sent before the close", taken) + } +} diff --git a/docs/changes/unreleased/app-compaction-facts.md b/docs/changes/unreleased/app-compaction-facts.md new file mode 100644 index 0000000000..41d09be9b1 --- /dev/null +++ b/docs/changes/unreleased/app-compaction-facts.md @@ -0,0 +1,13 @@ +--- +kind: changed +title: a compaction pass is stated as facts, and a summary in progress is announced while it is written +pr: 0 +surface: [app, chat, engine] +invalidates: + - "EventCompacting was the only live sign of a pass, and it is sent only after the transcript was edited. EventSummaryStarted now says a summary request is in flight, and EventSummaryFailed ends one that did not land in a pass that changed nothing." + - "A surface learned what a pass did from the hint's English. Event.Compaction now carries the pass as session.CompactionFact (seq, tokens before/after, stubbed/folded/summarized, the spliced summary, the model that ANSWERED, turns covered, failure reason, context full), and the journal's compaction line keeps the same fields; a failed summary is journaled as a `compaction_failed` line." + - "A display reading of a journal (ReadTranscript, the record cache) told only the region above the LAST pass, so after a second summary the person's earlier messages were unreachable. Record.Earlier is now stacked across every marker and Record.Passes places each pass. A live or resumed Agent still holds one hop and no summary bodies." + - "The app listed every compaction as 'Earlier messages summarized', including passes that only stubbed tool output. Summary entries now carry compaction facts under id cs, and the words are set only for a pass that summarized." + - "A /compact asked for outside a turn could not be stopped and said nothing until it ended. Interrupt, StopWork and Close now cut it, the remote engine streams its events as `pass` frames, and the app has agents.compact ending on compaction.ended." + - "session.Facts said only what the conversation weighs. It now also carries contextWindow, compactAt and summarizing; an unknown window is absent, never the package's default." +--- diff --git a/internal/appwire/compaction.go b/internal/appwire/compaction.go new file mode 100644 index 0000000000..e677a4c586 --- /dev/null +++ b/internal/appwire/compaction.go @@ -0,0 +1,107 @@ +package appwire + +import ( + "reflect" + "time" +) + +// COMPACTION ON THE WIRE (PROTOCOL.md): what a conversation weighs against its +// window, and each compaction pass as facts. The app words all of it; nothing +// here is a sentence for a person. + +// AgentContext is what a conversation weighs against the window it runs in. +// Window and Threshold are absent when nothing has said what the window is, +// which the app shows as unknown rather than as room. The whole object is +// absent for a conversation nothing holds. +type AgentContext struct { + Tokens int `json:"tokens"` + Window int `json:"window,omitempty"` + Threshold int `json:"threshold,omitempty"` + // Summarizing says a summary request is in flight right now. + Summarizing bool `json:"summarizing,omitempty"` +} + +// The states of a compaction entry. +const ( + CompactionSummarizing = "summarizing" + CompactionSettled = "settled" + CompactionFailed = "failed" +) + +// KeptLimit bounds the summary text one entry carries. A longer one is cut +// there and KeptClipped is set, so a copy of it can say it is not whole. +const KeptLimit = 64 << 10 + +// Compaction is one compaction pass, on its summary entry. Seq numbers the +// conversation's passes from 1 and is the same live and read back, so the +// entry's id is the same too. State is summarizing while a summary is being +// written, failed when one was asked for and did not land, and settled +// otherwise. Stubbed, Folded and Summarized are what the pass did; a pass with +// Summarized zero wrote no summary. Kept is the summary the model now reads +// in place of the messages it replaced, Model the model that wrote it, Turns +// how many of the person's messages it stands for, between CoversFrom and +// CoversUntil. Failed is why a summary did not land (interrupted, declined, +// empty, changed, not_shorter, error) and Full that the conversation is still +// at or over its window. +type Compaction struct { + Seq int `json:"seq"` + State string `json:"state" enum:"summarizing,settled,failed"` + TokensBefore int `json:"tokens_before,omitempty"` + TokensAfter int `json:"tokens_after,omitempty"` + Stubbed int `json:"stubbed,omitempty"` + Folded int `json:"folded,omitempty"` + Summarized int `json:"summarized,omitempty"` + Kept string `json:"kept,omitempty"` + KeptClipped bool `json:"kept_clipped,omitempty"` + Model string `json:"model,omitempty"` + Turns int `json:"turns,omitempty"` + CoversFrom *time.Time `json:"covers_from,omitempty"` + CoversUntil *time.Time `json:"covers_until,omitempty"` + Failed string `json:"failed,omitempty" enum:"interrupted,declined,empty,changed,not_shorter,error"` + Full bool `json:"full,omitempty"` +} + +// EventCompactionEnded ends every agents.compact the app asked for, once, +// after whatever entries the pass said: compacted (the pass ran; its entries +// say what it did, and an engine too old to say them sends none), busy +// (another pass was already running), nothing (nothing older to compact) or +// error (the pass could not be asked for or did not answer; Message says how). +const EventCompactionEnded = "compaction.ended" + +// The outcomes of an agents.compact. +const ( + CompactionCompacted = "compacted" + CompactionBusy = "busy" + CompactionNothing = "nothing" + CompactionError = "error" +) + +// CompactionEnded is that event's data. +type CompactionEnded struct { + Agent string `json:"agent"` + Outcome string `json:"outcome" enum:"compacted,busy,nothing,error"` + Message string `json:"message,omitempty"` +} + +// Compactor is an engine that compacts a conversation when asked. It is +// asserted, as Tidier is, so an engine without it answers unknown_method. +type Compactor interface { + // CompactAgent starts one compaction pass and answers at once, or + // refuses busy while one it started is still running. The pass says + // itself on entry.added and always ends on compaction.ended. + CompactAgent(id string) error +} + +func init() { + eventShapes[EventCompactionEnded] = reflect.TypeFor[CompactionEnded]() + methods["agents.compact"] = typed(func(e Engine, p crewID) (*Empty, error) { + c, ok := e.(Compactor) + if !ok { + return nil, Errorf(CodeUnknownMethod, "this engine does not serve agents.compact") + } + if err := needID("agents.compact", p.ID); err != nil { + return nil, err + } + return done(c.CompactAgent(p.ID)) + }) +} diff --git a/internal/appwire/wire.go b/internal/appwire/wire.go index 4a4036273c..c112bd10aa 100644 --- a/internal/appwire/wire.go +++ b/internal/appwire/wire.go @@ -128,6 +128,9 @@ type Agent struct { ArchivedAt *time.Time `json:"archived_at,omitempty"` LastActiveAt time.Time `json:"last_active_at"` SpentUSD float64 `json:"spent_usd,omitempty"` + // Context is what the conversation weighs against its window + // (compaction.go); absent when nothing holds the conversation. + Context *AgentContext `json:"context,omitempty"` } // Task is one node of an agent's task graph. @@ -315,6 +318,8 @@ type Entry struct { // Op is a durable fact the conversation's record keeps beside its // messages, said as the fact and never as words: the app words it. Op *EntryOp `json:"op,omitempty"` + // Compaction is a summary entry's pass, as facts (compaction.go). + Compaction *Compaction `json:"compaction,omitempty"` // A tool entry carries the facts of its call (worklog.go); a note entry // names its task there too. ToolFacts diff --git a/internal/remote/client.go b/internal/remote/client.go index e602049099..c8bec39786 100644 --- a/internal/remote/client.go +++ b/internal/remote/client.go @@ -222,6 +222,8 @@ type Client struct { // want it — the events themselves are queued on the stream regardless, and // the transcript is the authority on a turn that ended. following chan Following + // passes is the compaction passes asked for outside any turn (passes.go). + passes chan session.Event // dead is the reason this connection stopped, or nil while it is alive. // Every method reads it first, so a surface driving a corpse gets an error @@ -294,6 +296,7 @@ func newClient(host string, hello Hello) *Client { driver: Driver{Yours: true}, driverWake: make(chan struct{}), following: make(chan Following, followingRoom), + passes: make(chan session.Event, passRoom), } } @@ -884,6 +887,9 @@ func (c *Client) read() { // /status are drawn from, and neither had anything to draw from on // a conversation whose engine is another process (news.go). c.laneNewsFrame(frame.Payload) + case passFrameKind: + // A pass somebody asked for outside any turn (passes.go). + c.passFrame(frame.Payload) case "facts": // The engine stating something nobody asked for. It is taken on the // reader goroutine before the surface is notified of a changed name. diff --git a/internal/remote/compact_test.go b/internal/remote/compact_test.go index 3c5222c235..aed24e49bb 100644 --- a/internal/remote/compact_test.go +++ b/internal/remote/compact_test.go @@ -61,3 +61,53 @@ func TestCompactClientReadsSkippedSummaryResult(t *testing.T) { t.Fatalf("remote compact result = %q, skipped=%v", why, ok) } } + +type observingAgent struct{ *fakeAgent } + +func (a observingAgent) CompactObserved(_ context.Context, observe func(session.Event)) error { + observe(session.Event{Kind: session.EventSummaryStarted, Compaction: &session.CompactionFact{Seq: 4, TokensBefore: 30000}}) + a.mu.Lock() + a.tokens = 2000 + a.mu.Unlock() + observe(session.Event{Kind: session.EventCompacting}) + observe(session.Event{Kind: session.EventCompacted, Compaction: &session.CompactionFact{Seq: 4, TokensBefore: 30000, TokensAfter: 2000, Summarized: 12, Summary: "kept"}}) + return nil +} + +// A PASS ASKED FOR OUTSIDE A TURN IS SAID WHILE IT RUNS. Its started and +// settled events reach the surface as pass frames, each behind fresh facts, +// all ahead of the call's answer; the bare compacting event is not sent. +func TestCompactSaysItsPassAheadOfTheReply(t *testing.T) { + agent := &fakeAgent{model: "test/model", tokens: 30000} + engine := engineOn(agent) + engine.Agent = observingAgent{agent} + link := dialAgent(t, engine) + link.hello(Hello{Version: Version}) + link.write(Frame{Kind: "call", ID: 1, Method: MethodCompact}) + var kinds []string + var passes []session.Event + for { + frame := link.recvAny() + if frame.Kind == "result" && frame.ID == 1 { + if frame.Error != "" { + t.Fatalf("compact: %s", frame.Error) + } + break + } + kinds = append(kinds, frame.Kind) + if frame.Kind == passFrameKind { + passes = append(passes, decode[EventWire](t, frame.Payload).Unwire()) + } + } + if len(passes) != 2 || passes[0].Kind != session.EventSummaryStarted || passes[1].Kind != session.EventCompacted { + t.Fatalf("pass frames = %+v, want started then compacted", passes) + } + if passes[1].Compaction == nil || passes[1].Compaction.Seq != 4 || passes[1].Compaction.Summary != "kept" { + t.Fatalf("settled fact = %+v", passes[1].Compaction) + } + for i, kind := range kinds { + if kind == passFrameKind && (i == 0 || kinds[i-1] != "facts") { + t.Fatalf("frames %v: a pass frame went out without fresh facts ahead of it", kinds) + } + } +} diff --git a/internal/remote/passes.go b/internal/remote/passes.go new file mode 100644 index 0000000000..1bba9efc37 --- /dev/null +++ b/internal/remote/passes.go @@ -0,0 +1,92 @@ +package remote + +// A COMPACTION PASS ASKED FOR OUTSIDE ANY TURN IS SAID TO EVERY WINDOW. +// +// A pass that runs inside a turn says itself on the turn's own stream. One a +// surface asks for with [MethodCompact] has no stream: the call answers when +// the pass is over, which can be two minutes when it writes a summary, and a +// surface waiting on that answer has nothing to draw in the meantime. So the +// pass's own events — [session.EventSummaryStarted] when a summary is being +// written, then [session.EventCompacted] or [session.EventSummaryFailed] — go +// out as `pass` frames to everybody watching, with the facts ahead of each so +// the weight and the summarizing flag a window reads are already the new ones. +// +// An older client ignores the frame kind (client.go's reader), and an older +// engine never sends it, which leaves a surface exactly where it was: the +// call's answer is the end of the pass. + +import ( + "context" + "encoding/json" + + "github.com/Agent-Field/codeaf/internal/session" +) + +// passFrameKind is the frame a pass asked for outside any turn travels as. +const passFrameKind = "pass" + +// passRoom is how many unread passes a client holds before it drops the next: +// a surface that stopped reading is not owed a backlog. +const passRoom = 8 + +// compactSaid runs the pass the surface asked for, saying it as it goes when +// the agent can. +func (sess *Session) compactSaid(agent WrappedAgent) error { + observed, ok := agent.(interface { + CompactObserved(context.Context, func(session.Event)) error + }) + if !ok { + return agent.Compact(context.Background()) + } + return observed.CompactObserved(context.Background(), sess.tellPass) +} + +// tellPass states the facts and then the pass's event to everybody watching. +// It is called on the pass's own goroutine with no session lock held. +func (sess *Session) tellPass(event session.Event) { + switch event.Kind { + case session.EventSummaryStarted, session.EventCompacted, session.EventSummaryFailed: + default: + return + } + sess.announce() + payload, err := json.Marshal(WireEvent(event)) + if err != nil { + return + } + sess.mu.Lock() + if sess.closed { + sess.mu.Unlock() + return + } + watching := sess.watchingLocked() + sess.mu.Unlock() + frame := Frame{Kind: passFrameKind, Payload: payload} + for _, surface := range watching { + _ = surface.send(frame) + } +} + +// Passes is the compaction passes asked for outside any turn, as their events +// arrive. A surface that does not read it loses nothing but those lines: the +// facts carry the weight, and the journal carries the pass. +func (c *Client) Passes() <-chan session.Event { return c.passes } + +// passFrame takes one pass event off the wire. +func (c *Client) passFrame(payload []byte) { + var wire EventWire + if json.Unmarshal(payload, &wire) != nil { + return + } + select { + case c.passes <- wire.Unwire(): + default: + } +} + +// ContextUse is the conversation's weight against its window, read from the +// facts the engine last stated — no round trip. +func (a *Agent) ContextUse() session.ContextUse { + facts := a.c.facts.read() + return session.ContextUse{Tokens: facts.ContextTokens, Window: facts.ContextWindow, Threshold: facts.CompactAt, Summarizing: facts.Summarizing} +} diff --git a/internal/remote/server.go b/internal/remote/server.go index 184d42013b..03efb80187 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -1345,6 +1345,7 @@ func (sess *Session) announce() { func factsMoved(kind session.EventKind) bool { switch kind { case session.EventTurnDone, session.EventError, session.EventTitleChanged, session.EventCompacted, + session.EventSummaryStarted, session.EventSummaryFailed, session.EventConsentRequest, session.EventToolEnd, session.EventToolFailed, session.EventConnectAsk, session.EventConnectDone, session.EventTaskProposal, session.EventTaskUpdate, @@ -2686,7 +2687,7 @@ func (s *server) invoke(call Frame) (out json.RawMessage, err error) { return mustJSON(door.AnswerLaneOffer(yes)), nil case MethodCompact: - err := agent.Compact(context.Background()) + err := sess.compactSaid(agent) if why, skipped := session.SummarySkippedWhy(err); skipped { // An older surface ignores a successful call's optional result and // still reads a true success, while a newer one can show the reason. diff --git a/internal/session/agent.go b/internal/session/agent.go index bcb8eb10fc..ba5bcdcd18 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -296,6 +296,7 @@ func newAgent(config Config, client Completer) (*Agent, error) { // is the one place the two are converted, by doing the shaping. agent.earlier = shapeEntries(replayed.earlier, file) agent.earlierFloor = len(shapeEntries(restored[:replayed.overlap], file)) + agent.restorePasses(replayed.passes) // AND THE PICTURES ARE MADE SAFE HERE RATHER THAN IN THE REPLAY. A // session resumed onto a model without vision — the journal remembers // the model it was written on, the person can start it on another — would @@ -2163,6 +2164,7 @@ func (a *Agent) interruptNamed(door StopDoor, name string) { a.interrupt.begin() a.mu.Lock() cancel := a.cancel + cutPass := a.cutManualPassLocked() a.dropFollowUpsLocked() // AND THE SECOND LOOK AT A YOUNG COMMAND IS RELEASED BEFORE THIS LOCK IS, // not later by the turn's own cleanup. The cancel below is made with the @@ -2175,6 +2177,7 @@ func (a *Agent) interruptNamed(door StopDoor, name string) { // and that is idempotent (steer_grace.go). a.stopSteerGraceLocked() a.mu.Unlock() + cutPass() if cancel != nil { cancel(stopAs(door, name)) } @@ -2364,6 +2367,11 @@ func (f sessionCompleter) FallbackModels(model string) []string { // inside a tool must not hold the process open, and past the grace the journal // simply stops accepting writes rather than writing to a closed descriptor. func (a *Agent) Close() error { + // A pass a surface asked for outside any turn ends with the conversation. + a.mu.Lock() + cutPass := a.cutManualPassLocked() + a.mu.Unlock() + cutPass() // WHAT THE RECORD ALREADY HOLDS IS SETTLED BEFORE THE DOOR SHUTS, so an idle // session that read a landing in its last turn does not re-tell it tomorrow. // What the record does NOT hold stays owed, which is the whole point: a note @@ -4135,6 +4143,9 @@ func textMessage(role, text string) ai.Message { // never saw the events, and starting empty would leave it looking at a session // that is visibly working and saying nothing. type eventHub struct { + // observe, when set, receives every event in place of subscribers: a + // pass a surface asked for outside any turn ([Agent.CompactObserved]). + observe func(Event) replayCursor ReplayCursor mu sync.Mutex subscribers []*eventStream @@ -4347,6 +4358,10 @@ func (h *eventHub) send(event Event) (landed bool) { if h == nil { return false } + if h.observe != nil { + h.observe(event) + return true + } h.mu.Lock() defer h.mu.Unlock() if h.closed { @@ -4610,6 +4625,9 @@ type DisplayEntry struct { // mark, so a line from a file written before the mark existed still arrives as // "user", which is exactly what it always was. Role string + // Pass is a compaction pass a READER placed in a listing it built from a + // [Record] ([Record.Passes]); this package's own shaping never sets it. + Pass *CompactionFact // Answer marks a completed tool-free response or an explicit human update. This // boundary survives replay so a later response cannot demote its message. Answer bool diff --git a/internal/session/compact_summary.go b/internal/session/compact_summary.go index 8fe6fc660c..be1a76a3e7 100644 --- a/internal/session/compact_summary.go +++ b/internal/session/compact_summary.go @@ -466,7 +466,16 @@ func (a *Agent) regionClosedLocked(end int) bool { // time when the region is larger than one request can carry. It is called // with the session lock released. func (a *Agent) writeSummary(ctx context.Context, plan summaryPlan) (string, error) { + summary, _, err := a.writeSummaryAs(ctx, plan) + return summary, err +} + +// writeSummaryAs is [Agent.writeSummary] and the model that actually answered +// its last request — the same name the spending ledger books the call under, +// which a reroute can make different from the model the plan asked for. +func (a *Agent) writeSummaryAs(ctx context.Context, plan summaryPlan) (string, string, error) { summary := plan.previous + answeredBy := "" lines := summaryLines(plan.region) reserve := plan.ceiling if a.config.ReasoningProfile != nil { @@ -478,7 +487,7 @@ func (a *Agent) writeSummary(ctx context.Context, plan summaryPlan) (string, err for len(lines) > 0 { budget := summaryChunkTokens(plan.window, reserve, EstimateTokens(len(summary))) * bytesPerToken if budget <= 0 { - return "", errors.New("session: the summary no longer fits the window") + return "", "", errors.New("session: the summary no longer fits the window") } var chunk strings.Builder taken := 0 @@ -494,13 +503,13 @@ func (a *Agent) writeSummary(ctx context.Context, plan summaryPlan) (string, err taken++ } lines = lines[taken:] - next, err := a.askForSummary(ctx, plan, summary, chunk.String()) + next, answered, err := a.askForSummary(ctx, plan, summary, chunk.String()) if err != nil { - return "", err + return "", "", err } - summary = next + summary, answeredBy = next, answered } - return summary, nil + return summary, answeredBy, nil } // summaryChunkTokens is how much conversation one summary request may carry @@ -512,7 +521,7 @@ func summaryChunkTokens(window, answer, previous int) int { // askForSummary asks the conversation's own model with no tools or stream. An // empty length finish gets one larger request; both calls are billed. -func (a *Agent) askForSummary(ctx context.Context, plan summaryPlan, previous, chunk string) (string, error) { +func (a *Agent) askForSummary(ctx context.Context, plan summaryPlan, previous, chunk string) (string, string, error) { ctx = provider.WithRole(provider.WithoutStream(ctx), lane.RoleAuxiliary) ctx = provider.WithReasoningEffort(ctx, provider.EffortLow) ctx = provider.WithContextBudget(ctx, provider.ContextBudget{Window: plan.window, Reserve: plan.ceiling}) @@ -530,10 +539,14 @@ func (a *Agent) askForSummary(ctx context.Context, plan summaryPlan, previous, c textMessage("system", summaryInstruction(plan.answer)), textMessage("user", ask.String()), } + answeredBy := "" call := func(ceiling int) (*ai.Response, error) { callCtx, cancel := context.WithTimeout(ctx, summaryCallWindow) response, answered, err := a.completeWithNamedModel(callCtx, purposeSummary, messages, plan.model, ai.WithMaxTokens(ceiling)) cancel() + if err == nil && strings.TrimSpace(answered) != "" { + answeredBy = strings.TrimSpace(answered) + } if err != nil { return nil, err } @@ -552,7 +565,7 @@ func (a *Agent) askForSummary(ctx context.Context, plan summaryPlan, previous, c } response, err := call(plan.ceiling) if err != nil { - return "", err + return "", "", err } if provider.EmptyAtCeiling(response, plan.ceiling) { // TWICE THE ALLOWANCE, NOT A SHARE OF THE WINDOW. The provider already @@ -564,11 +577,15 @@ func (a *Agent) askForSummary(ctx context.Context, plan summaryPlan, previous, c if retryCeiling > plan.ceiling { response, err = call(retryCeiling) if err != nil { - return "", err + return "", "", err } } } - return summaryAnswer(response, chunk, plan.ceiling) + text, err := summaryAnswer(response, chunk, plan.ceiling) + if answeredBy == "" { + answeredBy = plan.model + } + return text, answeredBy, err } func summaryAnswer(response *ai.Response, chunk string, ceiling int) (string, error) { @@ -736,12 +753,15 @@ func summaryNoteBody(note string) string { return "" } +// summaryChangedWhy is what a splice says when the region moved under the call. +const summaryChangedWhy = "the conversation changed while the summary was being written" + // spliceSummaryLocked replaces the planned region with the summary note, if // the region is still exactly what was summarized. It reports how many // messages the note replaced, and zero with the reason when nothing changed. func (a *Agent) spliceSummaryLocked(plan summaryPlan, summary string) (int, string) { if len(a.messages) < plan.end || !reflect.DeepEqual(a.messages[1:plan.end], plan.region) { - return 0, "the conversation changed while the summary was being written" + return 0, summaryChangedWhy } note := textMessage("user", summaryNote(summary, plan.pointer)) replaced := 0 diff --git a/internal/session/compaction_facts.go b/internal/session/compaction_facts.go new file mode 100644 index 0000000000..52f90db893 --- /dev/null +++ b/internal/session/compaction_facts.go @@ -0,0 +1,262 @@ +package session + +// A COMPACTION PASS, SAID AS FACTS. +// +// A surface used to learn about a pass from two events and a sentence: the +// Hint on [EventCompacted] ("compacted · stubbed 14 tool results · …"). The +// sentence is for a person, and a surface that wanted the numbers out of it +// would break the first time the wording improved. So a pass now carries what +// it did as fields ([CompactionFact]), the journal keeps the same fields on its +// marker, and a resumed conversation hands back every pass it ever made in +// order ([Record.Passes]) read from the journal, never from memory. +// +// NOTHING HERE IS INFERRED FROM THE MODEL'S PROSE. The summary text a fact +// carries is the summary the pass spliced in, taken at the splice; the counts +// are the pass's own; the window and the threshold are the figures the trigger +// is checked against. + +import ( + "context" + "errors" + "time" + + "github.com/Agent-Field/codeaf/internal/provider" +) + +// CompactionFact is one compaction pass, or one summary that did not land. +type CompactionFact struct { + // Seq numbers the passes of one conversation from 1, in the order they + // were made, failures included. It is the same live and after a resume, + // so a surface can name a pass by it. + Seq int `json:"seq"` + // At is when the pass ended (or, on [EventSummaryStarted], began). + At time.Time `json:"at"` + // Entry is where the pass sits in the conversation told once and whole + // ([Record]): how many display entries of it come before the pass. Set + // only on [Record.Passes]; zero on an event, which a surface places at the + // end of what it has drawn. + Entry int `json:"entry,omitempty"` + // TokensBefore and TokensAfter are what the conversation weighed going in + // and coming out, in the meter's own figure ([Agent.ContextTokens]). + // TokensAfter is zero when it is not known: a summary still being + // written, or a marker journaled before the field existed. + TokensBefore int `json:"tokensBefore,omitempty"` + TokensAfter int `json:"tokensAfter,omitempty"` + // Stubbed, Folded and Summarized are what the pass's three rungs did: + // tool results turned into pointers, assistant messages folded into one + // line, and messages a summary note replaced. All three zero is a summary + // that failed in a pass that changed nothing. + Stubbed int `json:"stubbed,omitempty"` + Folded int `json:"folded,omitempty"` + Summarized int `json:"summarized,omitempty"` + // Summary is the note the model wrote, exactly as it was spliced into the + // conversation, without the note's framing. Empty for a pass that wrote + // none. Model is the model that wrote it. + Summary string `json:"summary,omitempty"` + Model string `json:"model,omitempty"` + // Turns is how many of the person's messages the summary now stands for, + // an earlier summary's own count included (a later summary folds the + // earlier one in). CoversFrom and CoversUntil are when the first and the + // last line it replaced were written; zero when the journal does not say. + Turns int `json:"turns,omitempty"` + CoversFrom time.Time `json:"coversFrom,omitempty"` + CoversUntil time.Time `json:"coversUntil,omitempty"` + // Failed says a summary was asked for and did not land, as one of the + // SummaryFailed* words; empty when no summary was attempted or it landed. + // Full says the conversation was still at or over the window after the + // pass, which is the only case a surface may call the context full. + Failed string `json:"failed,omitempty"` + Full bool `json:"full,omitempty"` +} + +// The reasons a summary did not land, as [CompactionFact.Failed] spells them. +const ( + SummaryFailedInterrupted = "interrupted" + SummaryFailedDeclined = "declined" + SummaryFailedEmpty = "empty" + SummaryFailedChanged = "changed" + SummaryFailedNotShorter = "not_shorter" + SummaryFailedError = "error" +) + +// Mechanical says the pass only stubbed or folded: no model wrote anything. +func (f CompactionFact) Mechanical() bool { return f.Summarized == 0 && f.Failed == "" } + +// summaryFailedWord is the reason a summary did not land, as a word. +func summaryFailedWord(ctx context.Context, err error, spliceWhy string) string { + switch { + case err == nil && spliceWhy == summaryChangedWhy: + return SummaryFailedChanged + case err == nil: + return SummaryFailedNotShorter + case ctx.Err() != nil: + return SummaryFailedInterrupted + case errors.Is(err, errSummaryDeclined): + return SummaryFailedDeclined + case errors.Is(err, errEmptyAnswer): + return SummaryFailedEmpty + default: + return SummaryFailedError + } +} + +// ContextUse is what the conversation weighs against the window it runs in, +// read under one lock so the three figures belong together. +type ContextUse struct { + // Tokens is [Agent.ContextTokens]. + Tokens int `json:"tokens"` + // Window is the model's context window and Threshold the weight at which + // the loop compacts on its own. Both are zero when no catalog, no + // configuration and no endpoint has said what the window is: the + // package's conservative default is a guess and is never reported. + Window int `json:"window,omitempty"` + Threshold int `json:"threshold,omitempty"` + // Summarizing says a summary request is in flight right now. + Summarizing bool `json:"summarizing,omitempty"` +} + +// ContextUse answers what the conversation weighs, the window and the +// threshold. It walks the live transcript's sizes once, under the session +// lock, which is never held across a model call; call it at a step boundary, +// never per streamed token. +func (a *Agent) ContextUse() ContextUse { + window, threshold := 0, 0 + if a.contextWindow.Load() > 0 || a.config.ContextWindow > 0 { + window = a.trustedWindow() + threshold = compactThresholdOf(window) + } + a.mu.Lock() + defer a.mu.Unlock() + return ContextUse{Tokens: a.meterTokensLocked(), Window: window, Threshold: threshold, Summarizing: a.summarizing} +} + +// CompactObserved is [Agent.Compact] with the pass's own events handed to +// observe as they happen — [EventSummaryStarted], then [EventCompacting] and +// [EventCompacted] or [EventSummaryFailed] — for a surface that asked for the +// pass outside any turn and has no turn stream to hear it on. observe is +// called on this goroutine and must not block. +// +// A PASS ASKED FOR OUTSIDE A TURN IS STILL STOPPED BY STOP. It has no turn +// context for [Agent.Interrupt] to cut, so its own cut is held on the agent +// for the length of the pass ([Agent.manualPass]) and Interrupt, StopWork and +// Close cut it too: the model request ends, not only what a surface draws. +func (a *Agent) CompactObserved(ctx context.Context, observe func(Event)) error { + ctx, cancel := context.WithCancel(ctx) + defer cancel() + cut := &manualPassCut{cancel: cancel} + a.mu.Lock() + if a.closed { + a.mu.Unlock() + return errAgentClosed + } + // A pass already running keeps its own cut; this one is refused below + // as in flight before it could need one. + if a.manualPass == nil { + a.manualPass = cut + } + a.mu.Unlock() + defer func() { + a.mu.Lock() + if a.manualPass == cut { + a.manualPass = nil + } + a.mu.Unlock() + }() + var hub *eventHub + if observe != nil { + hub = &eventHub{observe: observe} + } + _, skipped, err := a.compactWithPolicyResult(ctx, hub, a.requestedCompactPolicy()) + if err == nil && skipped != "" { + return &SummarySkipped{Why: skipped} + } + return err +} + +// manualPassCut is the cut of one pass a surface asked for outside a turn. +type manualPassCut struct{ cancel context.CancelFunc } + +// cutManualPassLocked takes the running manual pass's cut, to be called with +// the lock released. +func (a *Agent) cutManualPassLocked() func() { + if a.manualPass == nil { + return func() {} + } + return a.manualPass.cancel +} + +// summaryProvenanceLocked is who wrote a planned summary and what it stands +// for: the person's messages in its region, plus an earlier summary's own. +func (a *Agent) summaryProvenanceLocked(plan summaryPlan) (turns int, from, until time.Time) { + for index := 1; index < plan.end && index < len(a.messages); index++ { + if a.messages[index].Role == "user" && !a.sessionNoteLocked(a.messages[index]) { + turns++ + } + } + if plan.previous != "" { + turns += a.keptTurns + from = a.keptFrom + } + if len(plan.region) > 0 && a.file != nil { + if from.IsZero() { + from = a.file.written(plan.region[0]) + } + until = a.file.written(plan.region[len(plan.region)-1]) + } + return turns, from, until +} + +// contextFullLocked says the conversation does not fit its window with the +// provider's safety allowance taken off: the next request would be refused. +func (a *Agent) contextFullLocked(tokens int) bool { + window := a.trustedWindow() + if window <= 0 || (a.contextWindow.Load() <= 0 && a.config.ContextWindow <= 0) { + return false + } + return tokens >= window-provider.ContextSafetyTokens(window) +} + +// recordPassLocked numbers a pass and remembers the little a LATER pass needs +// of it — the sequence and the newest summary's provenance — and nothing +// else. THE LIVE AGENT KEEPS NO LIST OF PASSES AND NO SUMMARY BODIES: the +// journal holds every pass, and a reader that wants them all reads the file +// ([ReadTranscript], [Record.Passes]). Memory held for a conversation stays +// what compaction made it, however many passes it has had. +func (a *Agent) recordPassLocked(fact CompactionFact) CompactionFact { + a.passSeq++ + fact.Seq = a.passSeq + if fact.At.IsZero() { + fact.At = passInstant() + } + if fact.Summarized > 0 { + a.keptTurns, a.keptFrom = fact.Turns, fact.CoversFrom + } + return fact +} + +// restorePasses takes a replayed journal's passes the same way: their count +// and the newest summary's provenance. +func (a *Agent) restorePasses(passes []CompactionFact) { + a.passSeq = len(passes) + for index := len(passes) - 1; index >= 0; index-- { + if passes[index].Summarized > 0 { + a.keptTurns, a.keptFrom = passes[index].Turns, passes[index].CoversFrom + return + } + } +} + +// passInstant is now as the journal will read it back: UTC, no monotonic +// reading, so a pass is the same fact live and after a resume. +func passInstant() time.Time { return time.Now().UTC().Round(0) } + +// passFact is a pass that edited the transcript, as the fact it is kept as. +func passFact(pass compactionPass, tokensBefore int) CompactionFact { + return CompactionFact{ + At: pass.at, TokensBefore: tokensBefore, TokensAfter: pass.tokensAfter, + Stubbed: pass.stubbed, Folded: pass.folded, Summarized: pass.summarized, + Summary: pass.summary, Model: pass.summaryModel, Turns: pass.turns, + CoversFrom: pass.coversFrom, CoversUntil: pass.coversUntil, + Failed: pass.failed, Full: pass.full, + } +} diff --git a/internal/session/compaction_facts_test.go b/internal/session/compaction_facts_test.go new file mode 100644 index 0000000000..b148a2f535 --- /dev/null +++ b/internal/session/compaction_facts_test.go @@ -0,0 +1,320 @@ +package session + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" +) + +// journaledPersonHeavy is [personHeavy] written through the journal, as a +// real turn writes it, so the original lines are in the file above a marker. +func journaledPersonHeavy(agent *Agent, from, turns, bytes int) { + agent.mu.Lock() + defer agent.mu.Unlock() + for turn := from; turn < from+turns; turn++ { + for _, message := range []ai.Message{ + textMessage("user", fmt.Sprintf("question %d: %s", turn, strings.Repeat("pasted log line ", bytes/16))), + textMessage("assistant", fmt.Sprintf("answer %d", turn)), + } { + agent.messages = append(agent.messages, message) + agent.file.appendMessage(message) + } + } +} + +// observed runs one observed pass and answers what it said, in order. +func observed(t *testing.T, agent *Agent, ctx context.Context) ([]Event, error) { + t.Helper() + var events []Event + err := agent.CompactObserved(ctx, func(event Event) { events = append(events, event) }) + return events, err +} + +func passKinds(events []Event) []EventKind { + out := make([]EventKind, 0, len(events)) + for _, event := range events { + out = append(out, event.Kind) + } + return out +} + +func summaryAgent(t *testing.T, model Completer) (*Agent, string) { + t.Helper() + path := filepath.Join(t.TempDir(), "session.jsonl") + agent, _ := newTestAgent(t, model, func(config *Config) { + config.ContextWindow = 65_536 + config.SessionFile = path + }) + return agent, path +} + +// A SUMMARY IS SAID WHILE IT IS WRITTEN AND KEPT AS FACTS ONCE IT LANDS. The +// started event comes before the request and the settled one carries the +// summary the pass spliced, its model and what it stands for; the journal +// gives the same fact back. +func TestASummaryPassIsAnnouncedAndKeptAsFacts(t *testing.T) { + var startedBeforeRequest bool + var agent *Agent + model := &summarizer{} + agent, path := summaryAgent(t, model) + journaledPersonHeavy(agent, 1, 12, 20_000) + + var events []Event + err := agent.CompactObserved(context.Background(), func(event Event) { + if event.Kind == EventSummaryStarted { + startedBeforeRequest = model.calls() == 0 + if use := agent.ContextUse(); !use.Summarizing { + t.Error("ContextUse does not say a summary is being written") + } + } + events = append(events, event) + }) + if err != nil { + t.Fatalf("CompactObserved: %v", err) + } + got := passKinds(events) + if len(got) != 3 || got[0] != EventSummaryStarted || got[1] != EventCompacting || got[2] != EventCompacted { + t.Fatalf("events = %v, want started, compacting, compacted", got) + } + if !startedBeforeRequest { + t.Fatal("the summary was announced after the request had been made") + } + if agent.ContextUse().Summarizing { + t.Fatal("ContextUse still says summarizing after the pass") + } + fact := events[2].Compaction + if fact == nil || fact.Seq != 1 || events[0].Compaction.Seq != 1 { + t.Fatalf("settled fact = %+v; want sequence 1 on both ends", fact) + } + if fact.Summarized == 0 || !strings.HasPrefix(fact.Summary, summaryWords) || fact.Model != "test/model" || fact.Failed != "" { + t.Fatalf("settled fact = %+v; want the spliced summary and its model", fact) + } + // Questions 10–12 are kept word for word; the summary stands for 1–9. + if fact.Turns != 9 { + t.Fatalf("Turns = %d, want the 9 person messages the summary replaced", fact.Turns) + } + if fact.TokensAfter <= 0 || fact.TokensAfter >= fact.TokensBefore || fact.CoversUntil.IsZero() { + t.Fatalf("fact = %+v; want a real before → after and the region's last line's time", fact) + } + if !strings.Contains(messageText(liveTranscript(agent)[1]), fact.Summary) { + t.Fatal("the fact's summary is not the note the model now reads") + } + + record := ReadTranscript(path) + if len(record.Passes) != 1 { + t.Fatalf("journal passes = %d, want 1", len(record.Passes)) + } + read := record.Passes[0] + if read.Summary != fact.Summary || read.Turns != fact.Turns || read.Model != fact.Model || + read.TokensAfter != fact.TokensAfter || !read.At.Equal(fact.At) { + t.Fatalf("journal fact = %+v\nlive fact = %+v", read, *fact) + } + // The marker sits after every original line the pass replaced. + if read.Entry != countDisplayed(record.Earlier) { + t.Fatalf("marker at %d, want after the %d earlier entries", read.Entry, len(record.Earlier)) + } +} + +func countDisplayed(entries []DisplayEntry) int { return len(entries) } + +// REPEATED SUMMARIES KEEP EVERY ORIGINAL LINE IN THE RECORD AND NOTHING IN +// MEMORY. A reading of the file tells the whole conversation once, each pass +// in its place; the live agent and a resumed one hold only the latest hop. +func TestRepeatedSummariesKeepTheWholeRecordOnDiskOnly(t *testing.T) { + model := &summarizer{} + agent, path := summaryAgent(t, model) + journaledPersonHeavy(agent, 1, 12, 20_000) + if _, err := observed(t, agent, context.Background()); err != nil { + t.Fatal(err) + } + journaledPersonHeavy(agent, 13, 12, 20_000) + events, err := observed(t, agent, context.Background()) + if err != nil { + t.Fatal(err) + } + second := events[len(events)-1].Compaction + if second == nil || second.Seq != 2 || second.Summarized == 0 { + t.Fatalf("second pass = %+v", second) + } + // The second summary folds the first in, so it stands for both regions. + if second.Turns != 9+12 { + t.Fatalf("second summary stands for %d turns, want 21", second.Turns) + } + + record := ReadTranscript(path) + if len(record.Passes) != 2 || record.Passes[0].Seq != 1 || record.Passes[1].Seq != 2 { + t.Fatalf("passes = %+v", record.Passes) + } + told := append(append([]DisplayEntry(nil), record.Earlier...), record.Entries[record.Floor:]...) + for turn := 1; turn <= 24; turn++ { + seen := 0 + for _, entry := range told { + if entry.Role == "user" && strings.HasPrefix(entry.Text, fmt.Sprintf("question %d:", turn)) { + seen++ + } + } + if seen != 1 { + t.Fatalf("question %d is told %d times in the record, want once", turn, seen) + } + } + for _, entry := range told { + if strings.Contains(entry.Text, summaryNotePrefix) { + t.Fatal("a summary note is told as conversation in the record") + } + } + first, last := record.Passes[0].Entry, record.Passes[1].Entry + if !(0 < first && first < last && last <= len(record.Earlier)) { + t.Fatalf("pass places %d, %d in an earlier region of %d", first, last, len(record.Earlier)) + } + + // THE LIVE AGENT HOLDS ONE HOP. Question 1 went into the first summary, + // so the region the second pass replaced opens on that summary's note. + live := agent.EarlierHistory() + for _, entry := range live.Entries { + if strings.HasPrefix(entry.Text, "question 1:") { + t.Fatal("the live agent holds the whole history across passes") + } + } + if err := agent.Close(); err != nil { + t.Fatal(err) + } + resumed, err := newAgent(Config{Workspace: t.TempDir(), Model: "test/model", System: "SYSTEM", SessionFile: path, ContextWindow: 65_536}, &summarizer{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = resumed.Close() }) + if got := resumed.EarlierHistory(); len(got.Entries) != len(live.Entries) { + t.Fatalf("resumed region %d entries, live %d: a resume must hold the same one hop", len(got.Entries), len(live.Entries)) + } + // The sequence and the rolling provenance survive the restart. + journaledPersonHeavy(resumed, 25, 12, 20_000) + events, err = observed(t, resumed, context.Background()) + if err != nil { + t.Fatal(err) + } + third := events[len(events)-1].Compaction + if third.Seq != 3 || third.Turns != 21+12 || !third.CoversFrom.Equal(second.CoversFrom) { + t.Fatalf("third pass after resume = %+v; want sequence 3 standing for 33 turns from %v", third, second.CoversFrom) + } +} + +// A SUMMARY THAT FAILS IN A PASS THAT CHANGED NOTHING IS A FACT, NOT SILENCE. +// The conversation is untouched, the surface hears the failure, and the +// journal keeps it without a marker. +func TestAFailedSummaryIsSaidAndKeptWithoutTouchingTheConversation(t *testing.T) { + model := &summarizer{answer: func(int, []ai.Message) (*ai.Response, error) { + return nil, errors.New("provider unavailable") + }} + agent, path := summaryAgent(t, model) + agent.mu.Lock() + for turn := 1; turn <= 12; turn++ { + // Nothing a fold may take: every assistant line is as long as it + // can be without folding being worth it, so only a summary helps. + for _, message := range []ai.Message{ + textMessage("user", fmt.Sprintf("question %d: %s", turn, strings.Repeat("pasted log line ", 1250))), + } { + agent.messages = append(agent.messages, message) + agent.file.appendMessage(message) + } + } + agent.mu.Unlock() + before := liveTranscript(agent) + + events, err := observed(t, agent, context.Background()) + var nothing *NothingToCompact + if !errors.As(err, ¬hing) { + t.Fatalf("err = %v, want nothing to compact", err) + } + got := passKinds(events) + if len(got) != 2 || got[0] != EventSummaryStarted || got[1] != EventSummaryFailed { + t.Fatalf("events = %v, want started then failed", got) + } + failed := events[1].Compaction + if failed.Failed != SummaryFailedError || failed.Seq != 1 || failed.Summarized != 0 || failed.Summary != "" { + t.Fatalf("failure = %+v", failed) + } + if after := liveTranscript(agent); len(after) != len(before) { + t.Fatalf("a failed summary changed the conversation: %d → %d messages", len(before), len(after)) + } + record := ReadTranscript(path) + if len(record.Passes) != 1 || record.Passes[0].Failed != SummaryFailedError || len(record.Earlier) != 0 { + t.Fatalf("record passes = %+v, earlier %d; want one failure and no marker", record.Passes, len(record.Earlier)) + } + if record.Passes[0].Entry != len(record.Entries) { + t.Fatalf("failure placed at %d, want the end of the %d entries", record.Passes[0].Entry, len(record.Entries)) + } +} + +// AN INTERRUPTED SUMMARY SAYS SO, and the conversation keeps every word. +func TestAnInterruptedSummaryEndsAsInterrupted(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + model := &summarizer{answer: func(int, []ai.Message) (*ai.Response, error) { + cancel() + return nil, context.Canceled + }} + agent, _ := summaryAgent(t, model) + agent.mu.Lock() + for turn := 1; turn <= 12; turn++ { + message := textMessage("user", fmt.Sprintf("question %d: %s", turn, strings.Repeat("pasted log line ", 1250))) + agent.messages = append(agent.messages, message) + } + agent.mu.Unlock() + before := len(liveTranscript(agent)) + events, _ := observed(t, agent, ctx) + last := events[len(events)-1] + if last.Kind != EventSummaryFailed || last.Compaction.Failed != SummaryFailedInterrupted { + t.Fatalf("last event = %v %+v, want an interrupted summary", last.Kind, last.Compaction) + } + if after := len(liveTranscript(agent)); after != before { + t.Fatalf("an interrupted summary changed the conversation: %d → %d", before, after) + } + if agent.ContextUse().Summarizing { + t.Fatal("still summarizing after the interrupt") + } +} + +// A PASS THAT ONLY STUBS OR FOLDS NEVER CLAIMS A SUMMARY. No model was asked, +// so nothing says one is being written and the fact carries no summary. +func TestAMechanicalPassClaimsNoSummary(t *testing.T) { + agent, _ := newTestAgent(t, &refusingCompleter{t: t}, func(config *Config) { + config.ContextWindow = 131_072 + config.SessionFile = filepath.Join(t.TempDir(), "session.jsonl") + }) + grownTranscript(agent, 70, 8_000) + hub := newEventHub() + if changed, err := agent.compact(context.Background(), hub); err != nil || !changed { + t.Fatalf("compact = %v, %v; want a fold", changed, err) + } + for _, event := range hub.backlog { + if event.Kind == EventSummaryStarted { + t.Fatal("a mechanical pass announced a summary") + } + } + fact := lastCompacted(t, hub).Compaction + if fact == nil || !fact.Mechanical() || fact.Summary != "" || fact.Model != "" { + t.Fatalf("mechanical fact = %+v", fact) + } +} + +// AN UNKNOWN WINDOW IS UNKNOWN. With no catalog, no configuration and no +// endpoint saying what the window is, the default the package guards with is +// never reported as the model's window. +func TestContextUseNeverReportsAGuessedWindow(t *testing.T) { + agent, _ := newTestAgent(t, &refusingCompleter{t: t}, nil) + if use := agent.ContextUse(); use.Window != 0 || use.Threshold != 0 { + t.Fatalf("ContextUse = %+v, want an unknown window", use) + } + known, _ := newTestAgent(t, &refusingCompleter{t: t}, func(config *Config) { config.ContextWindow = 65_536 }) + use := known.ContextUse() + if use.Window != 65_536 || use.Threshold <= 0 || use.Threshold >= use.Window { + t.Fatalf("ContextUse = %+v, want the configured window and its threshold", use) + } + if use.Tokens != known.ContextTokens() { + t.Fatalf("ContextUse tokens %d, ContextTokens %d: one figure", use.Tokens, known.ContextTokens()) + } +} diff --git a/internal/session/compaction_model_fact_test.go b/internal/session/compaction_model_fact_test.go new file mode 100644 index 0000000000..b13b33af37 --- /dev/null +++ b/internal/session/compaction_model_fact_test.go @@ -0,0 +1,67 @@ +package session + +import ( + "context" + "path/filepath" + "testing" + "time" + + "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/router" +) + +// A model selected for a summary can be rerouted before the request. Provenance +// must name the answering model, just as the summary spend ledger does. +func TestReroutedSummaryFactNamesAnsweringModel(t *testing.T) { + t.Setenv(config.APIKeyEnv, "") + profile := t.TempDir() + if err := config.WriteAPIKey(profile, "sk-or-v1-test-0123456789"); err != nil { + t.Fatal(err) + } + const chat = "z-ai/glm-5.3-flash" + const worker = "moonshotai/kimi-k3" + oldCatalog, oldHistory, oldGood := config.CrewCatalog, config.CrewRouteHistory, config.CrewLastGood + t.Cleanup(func() { + config.CrewCatalog, config.CrewRouteHistory, config.CrewLastGood = oldCatalog, oldHistory, oldGood + }) + config.CrewCatalog = func() []catalog.Model { + return []catalog.Model{ + {ID: chat, OpenWeights: true, PromptPrice: 1.5e-7, CompletionPrice: 5e-7, IntelligenceIndex: 41.8, CodingIndex: 71.5, AgenticIndex: 50.9, ArenaElo: 1348, ContextLength: 1310720, Parameters: []string{"tools"}}, + {ID: worker, OpenWeights: true, PromptPrice: 3e-6, CompletionPrice: 1.5e-5, IntelligenceIndex: 43.6, CodingIndex: 76.2, AgenticIndex: 50, ArenaElo: 1421, ContextLength: 1048576, Parameters: []string{"tools"}}, + } + } + config.CrewRouteHistory = func(string) []router.CrewRouteOutcome { + return []router.CrewRouteOutcome{{At: time.Now(), Send: chat, Provider: "openrouter", Kind: "forbidden"}} + } + config.CrewLastGood = func(string) *router.CrewRecord { return nil } + if err := config.SetCrewPin(profile, crewroute.Worker, worker); err != nil { + t.Fatal(err) + } + path := filepath.Join(t.TempDir(), "session.jsonl") + agent, _ := newTestAgent(t, &summarizer{}, func(c *Config) { + c.Model = chat + c.ProfileDir = profile + c.RouteCrew = func(config.CrewAsk) (crewroute.Decision, error) { return crewroute.Decision{}, nil } + c.ContextWindow = 65_536 + c.SessionFile = path + }) + if got := agent.healthyModel(context.Background(), purposeSummary, chat); got != worker { + t.Fatalf("fixture did not reroute the summary: %q", got) + } + personHeavy(agent, 8, 20_000) + events, err := observed(t, agent, context.Background()) + if err != nil { + t.Fatal(err) + } + for _, event := range events { + if event.Kind == EventCompacted && event.Compaction != nil && event.Compaction.Summarized > 0 { + if got := event.Compaction.Model; got != worker { + t.Fatalf("summary fact names requested model %q, want actual answering model %q", got, worker) + } + return + } + } + t.Fatal("the summary wrote no structured compaction fact") +} diff --git a/internal/session/compaction_stop_fact_test.go b/internal/session/compaction_stop_fact_test.go new file mode 100644 index 0000000000..699c74217c --- /dev/null +++ b/internal/session/compaction_stop_fact_test.go @@ -0,0 +1,49 @@ +package session + +import ( + "context" + "sync" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" +) + +// A manual retry has no Submit context. Stop must still cancel the real model +// request, rather than only changing what the surface draws while it keeps running. +type stoppableSummaryModel struct { + once sync.Once + started chan struct{} +} + +func (m *stoppableSummaryModel) CompleteWithMessages(ctx context.Context, _ []ai.Message, _ ...ai.Option) (*ai.Response, error) { + m.once.Do(func() { close(m.started) }) + <-ctx.Done() + return nil, ctx.Err() +} + +func TestStopCancelsAManualSummaryRetry(t *testing.T) { + model := &stoppableSummaryModel{started: make(chan struct{})} + agent, _ := summaryAgent(t, model) + journaledPersonHeavy(agent, 1, 12, 20_000) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { done <- agent.CompactObserved(ctx, nil) }() + select { + case <-model.started: + case <-time.After(5 * time.Second): + t.Fatal("manual summary did not reach the provider") + } + agent.Interrupt() + select { + case <-done: + case <-time.After(time.Second): + cancel() + <-done + t.Fatal("Stop left the manual summary request running") + } + if agent.ContextUse().Summarizing { + t.Fatal("manual summary still reports running after Stop") + } +} diff --git a/internal/session/facts.go b/internal/session/facts.go index e0701e7105..875ca85e30 100644 --- a/internal/session/facts.go +++ b/internal/session/facts.go @@ -46,6 +46,13 @@ type Facts struct { // ContextTokens is what the conversation weighs right now // ([Agent.ContextTokens]). ContextTokens int `json:"contextTokens,omitempty"` + // ContextWindow and CompactAt are the window the conversation runs in and + // the weight at which it compacts on its own, and Summarizing says a + // summary request is in flight ([Agent.ContextUse], read with + // ContextTokens under one lock). Absent means unknown — never zero room. + ContextWindow int `json:"contextWindow,omitempty"` + CompactAt int `json:"compactAt,omitempty"` + Summarizing bool `json:"summarizing,omitempty"` // Reasoning is the level held for each model id anybody has dialled, keyed // the way [ReasoningKey] folds an id. // @@ -154,6 +161,10 @@ func FactsOf(source FactSource) Facts { ContextTokens: source.ContextTokens(), Reasoning: source.ReasoningLevels(), } + if door, ok := source.(interface{ ContextUse() ContextUse }); ok { + use := door.ContextUse() + facts.ContextTokens, facts.ContextWindow, facts.CompactAt, facts.Summarizing = use.Tokens, use.Window, use.Threshold, use.Summarizing + } if door, ok := source.(interface{ Remembers() bool }); ok { memory := door.Remembers() facts.Memory = &memory diff --git a/internal/session/loop.go b/internal/session/loop.go index 36d50009dd..0c7717fd00 100644 --- a/internal/session/loop.go +++ b/internal/session/loop.go @@ -4931,6 +4931,19 @@ type compactionPass struct { // summarySkipped explains an attempted summary that did not land after // the free rungs changed the conversation. summarySkipped string + // The pass as facts, journaled on its marker (compaction_facts.go): + // what the conversation weighed after it, the summary it spliced and who + // wrote it, what that summary stands for, and why a summary it asked for + // did not land. + at time.Time + tokensAfter int + summary string + summaryModel string + turns int + coversFrom time.Time + coversUntil time.Time + failed string + full bool // stored says the full record went somewhere a later session can still read // it — the store's thread (chatlog.go). It is what makes the difference // between the two announce lines honest. @@ -5051,22 +5064,38 @@ func (a *Agent) compactWithPolicyResult(ctx context.Context, hub *eventHub, poli why := "" appendedDuringSummary := 0 attempted := false + seq := a.passSeq + 1 if summaryWanted(policy, pass, a.transcriptTokensLocked()) { plan, short, ok := a.planSummaryLocked(policy) why = short if ok { attempted = true lengthBeforeSummary := len(a.messages) + turns, from, until := a.summaryProvenanceLocked(plan) + a.summarizing = true a.mu.Unlock() + // THE ONE PROOF A SUMMARY IS BEING WRITTEN, sent with the lock + // released and before the request: a surface shows the wait for + // as long as it lasts and no longer. + hub.send(Event{Kind: EventSummaryStarted, Compaction: &CompactionFact{Seq: seq, At: passInstant(), TokensBefore: tokensBefore}}) a.tellPhase(provider.PhaseTidying, "summarizing the conversation", time.Now()) - summary, err := a.writeSummary(ctx, plan) + summary, answeredBy, err := a.writeSummaryAs(ctx, plan) a.mu.Lock() + a.summarizing = false appendedDuringSummary = max(0, len(a.messages)-lengthBeforeSummary) if err == nil { pass.summarized, why = a.spliceSummaryLocked(plan, summary) } else { why = summaryFailedWhy(ctx, err) } + if pass.summarized > 0 { + // The model that ANSWERED, which a reroute can make different + // from the one the plan asked for. + pass.summary, pass.summaryModel = summary, answeredBy + pass.turns, pass.coversFrom, pass.coversUntil = turns, from, until + } else { + pass.failed = summaryFailedWord(ctx, err, why) + } } } // ONLY A SUMMARY THAT WAS ASKED FOR AND DID NOT LAND IS "SKIPPED". Too @@ -5083,7 +5112,26 @@ func (a *Agent) compactWithPolicyResult(ctx context.Context, hub *eventHub, poli if pass.empty() { // No eligible material was reduced. A manual pass and a routine pass // protect different tails, so this is not a claim about total size. + // + // A SUMMARY THAT WAS ASKED FOR AND DID NOT LAND IS STILL A FACT. The + // surface that showed it being written has to be told it ended, and + // a resumed conversation has to say the same thing, so the failure is + // numbered, kept and journaled like a pass — with no marker, because + // the transcript did not move. + var failure *CompactionFact + if attempted { + tokens := a.meterTokensLocked() + fact := a.recordPassLocked(CompactionFact{ + TokensBefore: tokensBefore, TokensAfter: tokens, Failed: pass.failed, + Full: a.contextFullLocked(tokens), + }) + a.file.appendCompactionFailed(fact) + failure = &fact + } a.mu.Unlock() + if failure != nil { + hub.send(Event{Kind: EventSummaryFailed, Compaction: failure}) + } return false, "", &NothingToCompact{Why: why} } @@ -5119,6 +5167,8 @@ func (a *Agent) compactWithPolicyResult(ctx context.Context, hub *eventHub, poli a.contextTokens = 0 a.contextBeltTokens = belt tokensAfter := a.meterTokensLocked() + pass.at, pass.tokensAfter, pass.full = passInstant(), tokensAfter, a.contextFullLocked(tokensAfter) + fact := a.recordPassLocked(passFact(pass, tokensBefore)) // The whole rebuilt window is re-journaled behind the marker, not just the // tail: a stub and a fold are edits to messages the file already holds ABOVE // the marker, and replay discards everything above it. Writing the window is @@ -5133,7 +5183,7 @@ func (a *Agent) compactWithPolicyResult(ctx context.Context, hub *eventHub, poli a.mu.Unlock() if hub != nil { - hub.send(Event{Kind: EventCompacted, Hint: compactionHint(pass, tokensBefore, tokensAfter), Summarized: pass.summarized}) + hub.send(Event{Kind: EventCompacted, Hint: compactionHint(pass, tokensBefore, tokensAfter), Summarized: pass.summarized, Compaction: &fact}) } return true, pass.summarySkipped, nil } diff --git a/internal/session/recordcache.go b/internal/session/recordcache.go index c2b5c9f9e0..4fe3680b49 100644 --- a/internal/session/recordcache.go +++ b/internal/session/recordcache.go @@ -452,6 +452,7 @@ func recordReserve(size int64) int64 { func recordVectors(record Record) int64 { cost := int64(len(record.Entries)+len(record.Earlier)) * int64(unsafe.Sizeof(DisplayEntry{})) cost += int64(len(record.Requests.Recent)) * int64(unsafe.Sizeof(RequestLine{})) + cost += int64(len(record.Passes)) * int64(unsafe.Sizeof(CompactionFact{})) for _, entries := range [][]DisplayEntry{record.Entries, record.Earlier} { for i := range entries { entry := &entries[i] @@ -474,6 +475,12 @@ func recordCost(record Record) int64 { cost += entriesCost(record.Entries) + entriesCost(record.Earlier) cost += int64(len(record.Unreadable)) cost += int64(cap(record.Requests.Recent)) * int64(unsafe.Sizeof(RequestLine{})) + // A pass's summary is the one long string it carries; the journal holds + // it on the marker line, so this is bounded by the file like the rest. + cost += int64(cap(record.Passes)) * int64(unsafe.Sizeof(CompactionFact{})) + for i := range record.Passes { + cost += int64(len(record.Passes[i].Summary) + len(record.Passes[i].Model) + len(record.Passes[i].Failed)) + } return cost } @@ -494,6 +501,9 @@ func entriesCost(entries []DisplayEntry) int64 { if entry.Steer != nil { cost += int64(unsafe.Sizeof(SteerMark{})) + int64(len(entry.Steer.Landing)) } + if entry.Pass != nil { + cost += int64(unsafe.Sizeof(CompactionFact{})) + int64(len(entry.Pass.Summary)+len(entry.Pass.Model)+len(entry.Pass.Failed)) + } cost += int64(cap(entry.Team)) * int64(unsafe.Sizeof(TeamLine{})) for _, line := range entry.Team { cost += int64(len(line.Team) + len(line.From) + len(line.To) + len(line.Kind) + len(line.Text) + len(line.Thread)) @@ -508,6 +518,7 @@ func cloneRecord(record Record) Record { record.Entries = cloneEntries(record.Entries) record.Earlier = cloneEntries(record.Earlier) record.Requests.Recent = slices.Clone(record.Requests.Recent) + record.Passes = slices.Clone(record.Passes) return record } @@ -525,6 +536,10 @@ func cloneEntries(entries []DisplayEntry) []DisplayEntry { steer := *entry.Steer entry.Steer = &steer } + if entry.Pass != nil { + pass := *entry.Pass + entry.Pass = &pass + } } return out } diff --git a/internal/session/session.go b/internal/session/session.go index 7593a6d264..7ef9d6c1a9 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -641,6 +641,19 @@ const ( // in this turn — saved, paused, resumed, deleted, asked to run now. It is a // report and never a question. EventAutomationUpdate + // EventSummaryStarted says a compaction pass has asked the conversation's + // model for a summary and is waiting on it, with the session lock released + // (compact_summary.go). It is the one proof a summary is being written: + // [EventCompacting] is sent only after the transcript was edited. + // [Event.Compaction] carries the pass's sequence number and the weight it + // started from. It is always followed by [EventCompacted] or + // [EventSummaryFailed] carrying the same sequence number. + EventSummaryStarted + // EventSummaryFailed ends a summary that did not land in a pass that then + // changed nothing; [Event.Compaction] says why ([CompactionFact.Failed]). + // A pass whose free rungs did change the conversation ends on + // [EventCompacted] instead, with the same Failed reason set on it. + EventSummaryFailed ) // TaskReplyTag is the task identity a surface places beside the answer its @@ -739,6 +752,12 @@ type Event struct { // surface talking to an older engine folds every pass. Summarized int `json:"Summarized,omitempty"` + // Compaction is the pass as facts, on [EventCompacted], + // [EventSummaryStarted] and [EventSummaryFailed] (compaction_facts.go). + // Nil on every other kind and from a peer built before it existed, which + // a surface reads as "unknown" and never as "nothing happened". + Compaction *CompactionFact `json:"Compaction,omitempty"` + // Category is the FAMILY OF WORK an EventCaption's sentence is about — one // word from the closed list in actioncategory.go — and it is zero on every // other kind. @@ -2816,6 +2835,18 @@ type Agent struct { // put in place of the conversation above. A surface draws the region instead // of them, so the conversation is told once ([EarlierHistory]). earlierFloor int + // passSeq is how many compaction passes this conversation has made, + // failures included, and keptTurns and keptFrom the newest summary's + // provenance (compaction_facts.go). Restored from the journal on resume. + // That is all a live agent keeps of its passes: the journal holds them. + passSeq int + keptTurns int + keptFrom time.Time + // summarizing is true while a pass waits on a summary request. + summarizing bool + // manualPass is the cut of a pass a surface asked for outside any turn, + // while it runs ([Agent.CompactObserved]); Stop cuts it with the turn. + manualPass *manualPassCut // personAsk is the last thing THE PERSON typed, kept apart from the // transcript because the transcript cannot answer the question. Every user // message in a.messages is user-role, including the ones the session wrote diff --git a/internal/session/sessionfile.go b/internal/session/sessionfile.go index 5f15cf9e4d..f94aeaf753 100644 --- a/internal/session/sessionfile.go +++ b/internal/session/sessionfile.go @@ -232,6 +232,22 @@ type sessionEntry struct { // Summarized is how many messages a summary note replaced // (compact_summary.go). The note itself is in the window like any line. Summarized int `json:"summarized,omitempty"` + // The rest of the pass as facts (compaction_facts.go): what the + // conversation weighed after it, the summary it spliced (Kept, its body + // without the note's framing) and the model that wrote it, how many of the + // person's messages that summary stands for and when they were written, + // and why a summary it asked for did not land. On a `compaction_failed` + // line only TokensBefore, TokensAfter, Failed and Full are set. All are + // absent from a marker written before they existed, which a reader takes + // as unknown. + TokensAfter int `json:"tokensAfter,omitempty"` + Kept string `json:"kept,omitempty"` + KeptModel string `json:"keptModel,omitempty"` + KeptTurns int `json:"keptTurns,omitempty"` + KeptFrom string `json:"keptFrom,omitempty"` + KeptUntil string `json:"keptUntil,omitempty"` + SummaryFail string `json:"summaryFailed,omitempty"` + ContextFull bool `json:"contextFull,omitempty"` // Window is HOW MANY MESSAGE LINES THE PASS RE-JOURNALED BEHIND THIS MARKER // — the length of the rebuilt window [sessionFile.appendCompaction] writes @@ -1856,18 +1872,22 @@ func replaySessionFile(path string) (replayedSession, error) { // both reading doors, because it is the same fact about the same file. func readJournal(reader io.Reader, path string, rebuild bool) (replayedSession, error) { var ( - messages []ai.Message - reasoning []provider.MessageReasoning - earlier []ai.Message - overlap int - title string - shortTitle string - id string - lines int - scanned int - spent Usage - created []fileChange - requests RequestBooks + messages []ai.Message + reasoning []provider.MessageReasoning + earlier []ai.Message + overlap int + passes []CompactionFact + // earlierRows is countEntries(earlier), kept as the region grows so + // placing a pass never re-counts the conversation above it. + earlierRows int + title string + shortTitle string + id string + lines int + scanned int + spent Usage + created []fileChange + requests RequestBooks ) // The picture index is built as the messages are, because this is the one // pass that holds both halves at once: the reference the journal wrote and @@ -2008,11 +2028,25 @@ func readJournal(reader io.Reader, path string, rebuild bool) (replayedSession, // re-read of the lines above the marker (which would resurrect turns // a rewind took back). // - // The LAST marker wins because each one overwrites the snapshot the - // one before it took. See [replayedSession.earlier] for why the - // nested regions are not stacked. + // A RESUME KEEPS ONE HOP AND A READING KEEPS THE WHOLE. A conversation + // being resumed needs only the region the latest marker replaced — + // the snapshot each marker overwrites — because that is all a live + // agent holds for scrollback, and holding more would undo the + // memory compaction exists to save. A READING for display (rebuild + // false: [ReadTranscript], the record cache behind a page) is + // transient and wants the conversation told once and whole, so its + // region is STACKED: the region the older markers left followed by + // what the transcript held beyond their floor. One hop stopped being + // whole once a pass could summarize the person's own words — after + // a second summary it opened on the first summary's note. asked, stopped = false, "" - earlier = append(earlier[:0], messages...) + if rebuild { + earlier = append(earlier[:0], messages...) + } else { + beyond := messages[min(overlap, len(messages)):] + earlier = append(earlier, beyond...) + earlierRows += countEntries(beyond) + } // Everything before this marker is what the pass replaces. The // name is not a message and survives the cut: a compacted session // is the same session, still called what it was called. @@ -2023,8 +2057,18 @@ func readJournal(reader io.Reader, path string, rebuild bool) (replayedSession, // conversation — a reader that could not say where the copy ends has // nothing it can honestly draw, and drops the region rather than // showing the session to itself twice. + placed := true if overlap = compactionOverlap(entry); overlap < 0 { - earlier, overlap = earlier[:0], 0 + earlier, overlap, earlierRows, placed = earlier[:0], 0, 0, false + } + passes = appendPass(passes, entry, earlierRows, rebuild) + // A REGION THAT CANNOT BE PLACED TAKES ITS PASSES' PLACES WITH IT: + // the conversation they stood in is no longer told, so they are + // kept as facts (their sequence still counts) placed nowhere. + if !placed { + for index := range passes { + passes[index].Entry = -1 + } } messages = append(messages[:0], rebuilt...) reasoning = append(reasoning[:0], make([]provider.MessageReasoning, len(rebuilt))...) @@ -2034,6 +2078,15 @@ func readJournal(reader io.Reader, path string, rebuild bool) (replayedSession, if len(entry.Parts) > 0 && len(rebuilt) > 0 { rememberParts(images, rebuilt[0], entry.Parts) } + case "compaction_failed": + // A summary that did not land in a pass that changed nothing. The + // transcript did not move, so the fact is placed at the end of the + // conversation as it stood then, and nothing is rebuilt. + place := 0 + if !rebuild { + place = earlierRows + countEntries(messages[min(overlap, len(messages)):]) + } + passes = appendPass(passes, entry, place, rebuild) case "caption": // ONE BATCH'S NARRATION. It rebuilds no message — nothing was ever // said to the model here — so it only indexes, and the LAST line for @@ -2238,6 +2291,7 @@ func readJournal(reader io.Reader, path string, rebuild bool) (replayedSession, reasoning: reasoning, earlier: earlier, overlap: overlap, + passes: passes, title: title, shortTitle: shortTitle, id: id, @@ -2402,8 +2456,10 @@ type replayedSession struct { // still holds in full and the model no longer carries. Nil for a journal // that was never compacted, which is nearly all of them. // - // THE REGION IS ONE HOP AND IS NOT STACKED, and that is a deliberate reading - // of a file that could be read the other way. A modern pass RE-JOURNALS THE + // ON A RESUME THE REGION IS ONE HOP; a reading for display stacks it + // (readJournal's `compaction` case says why each), applying every older + // marker's floor so nothing is told twice. What follows is why the one-hop + // reading is safe for a live agent to hold. A modern pass RE-JOURNALS THE // WHOLE REBUILT WINDOW behind its marker ([sessionFile.appendCompaction]), so // the lines after an older marker already contain everything before it, edited // — a walk that ignored the older markers to "reach the raw beginning" would @@ -2420,7 +2476,11 @@ type replayedSession struct { // // Zero whenever earlier is empty, and never anything else: a region that // cannot be placed is not kept. - overlap int + overlap int + // passes is every compaction marker and failed summary the file records, + // in order. A reading for display places each in the conversation told + // once and keeps its summary; a resume keeps neither. + passes []CompactionFact title string shortTitle string // id is the header's session id, empty for a file that has no header yet. @@ -2865,6 +2925,64 @@ func (s *sessionFile) appendWithReasoning(message ai.Message, note bool, refs [] // // The counts ride the marker so a surface reading the file back can say what the // pass did without re-deriving it. Nothing rebuilds from them. +// appendCompactionFailed records a summary that did not land in a pass that +// changed nothing. It is not a marker — the transcript did not move — so a +// reader keeps it as a fact and rebuilds nothing from it. +func (s *sessionFile) appendCompactionFailed(fact CompactionFact) { + if s == nil { + return + } + s.writeLine(sessionEntry{ + Type: "compaction_failed", + TokensBefore: fact.TokensBefore, + TokensAfter: fact.TokensAfter, + SummaryFail: fact.Failed, + ContextFull: fact.Full, + Timestamp: journalInstant(fact.At), + }) +} + +// passStamp is a pass's own instant as its marker's timestamp, or now. +func passStamp(at time.Time) string { + if at.IsZero() { + return stamp() + } + return journalInstant(at) +} + +// appendPass numbers one journaled pass and keeps it. A resume keeps no +// summary bodies ([Agent.restorePasses] takes the count and the newest +// provenance), so a long conversation's notes are not held to be dropped. +func appendPass(passes []CompactionFact, entry sessionEntry, place int, rebuild bool) []CompactionFact { + fact := compactionFactOf(entry, place) + fact.Seq = len(passes) + 1 + if rebuild { + fact.Summary = "" + } + return append(passes, fact) +} + +// journalInstant is a time as the journal spells one, and "" for none. +func journalInstant(at time.Time) string { + if at.IsZero() { + return "" + } + return at.UTC().Format(time.RFC3339Nano) +} + +// compactionFactOf is a journaled marker or failure line as the fact it +// records, placed at entry. +func compactionFactOf(entry sessionEntry, place int) CompactionFact { + return CompactionFact{ + At: lineInstant(entry.Timestamp), Entry: place, + TokensBefore: entry.TokensBefore, TokensAfter: entry.TokensAfter, + Stubbed: entry.Stubbed, Folded: entry.Folded, Summarized: entry.Summarized, + Summary: entry.Kept, Model: entry.KeptModel, Turns: entry.KeptTurns, + CoversFrom: lineInstant(entry.KeptFrom), CoversUntil: lineInstant(entry.KeptUntil), + Failed: entry.SummaryFail, Full: entry.ContextFull, + } +} + func (s *sessionFile) appendCompaction(pass compactionPass, tokensBefore int, window []ai.Message, sidecars ...[]provider.MessageReasoning) { s.writeLine(sessionEntry{ Type: "compaction", @@ -2872,12 +2990,20 @@ func (s *sessionFile) appendCompaction(pass compactionPass, tokensBefore int, wi Stubbed: pass.stubbed, Folded: pass.folded, Summarized: pass.summarized, + TokensAfter: pass.tokensAfter, + Kept: pass.summary, + KeptModel: pass.summaryModel, + KeptTurns: pass.turns, + KeptFrom: journalInstant(pass.coversFrom), + KeptUntil: journalInstant(pass.coversUntil), + SummaryFail: pass.failed, + ContextFull: pass.full, // The length is written BEFORE the window it describes, which is the only // order that survives a crash halfway through: a reader that finds fewer // lines than the number promised has a truncated file and can say so, // where a count written afterwards would simply never arrive. Window: len(window), - Timestamp: stamp(), + Timestamp: passStamp(pass.at), }) for index, message := range window { // Marks belong to the original message, before projecting a fresh diff --git a/internal/session/stopwork.go b/internal/session/stopwork.go index 8b3958ab1d..9041ac7a3a 100644 --- a/internal/session/stopwork.go +++ b/internal/session/stopwork.go @@ -21,6 +21,8 @@ func (a *Agent) StopWork() error { a.dropFollowUpsLocked() a.stopSteerGraceLocked() cancel, done := a.cancel, a.done + // A pass a surface asked for outside any turn is work too. + defer a.cutManualPassLocked()() // Keep completed runs inspectable, and mark cancellation as the person's // decision before cutting their contexts. Close deliberately discards this // registry; stopping a conversation must preserve it. diff --git a/internal/session/transcriptread.go b/internal/session/transcriptread.go index bfbc167264..98ce28139a 100644 --- a/internal/session/transcriptread.go +++ b/internal/session/transcriptread.go @@ -84,6 +84,9 @@ type Record struct { Entries []DisplayEntry Earlier []DisplayEntry Floor int + // Passes is every compaction pass the record holds, oldest first, each + // placed in `Earlier` followed by `Entries[Floor:]` ([CompactionFact.Entry]). + Passes []CompactionFact // Unreadable is what a page must SAY about this reading, and "" for a record // read whole — which is nearly all of them. // @@ -241,6 +244,7 @@ func transcriptFrom(replayed replayedSession, err error) Record { Entries: shapeEntries(replayed.messages, journal), Earlier: shapeEntries(replayed.earlier, journal), Floor: len(shapeEntries(replayed.messages[:overlap], journal)), + Passes: replayed.passes, Unreadable: unreadPastLine(replayed.unread), Requests: replayed.requests, } diff --git a/internal/session/turnfold.go b/internal/session/turnfold.go index febd9b7d15..a6b4f86d3b 100644 --- a/internal/session/turnfold.go +++ b/internal/session/turnfold.go @@ -223,6 +223,9 @@ func (a *Agent) foldTurnOutputs(seenThrough int, consumedReads map[*ai.ToolCall] a.earlier = earlier a.earlierFloor = countEntries(a.messages) pass := compactionPass{stubbed: results, stored: a.chatlog != nil} + pass.at, pass.tokensAfter = passInstant(), a.meterTokensLocked() + pass.full = a.contextFullLocked(pass.tokensAfter) + fact := a.recordPassLocked(passFact(pass, before)) if a.file != nil { window := make([]ai.Message, len(a.messages)-1) copy(window, a.messages[1:]) @@ -232,7 +235,7 @@ func (a *Agent) foldTurnOutputs(seenThrough int, consumedReads map[*ai.ToolCall] if hub != nil { hint := strings.TrimSuffix(strings.TrimPrefix(marker, "["), "]") - hub.send(Event{Kind: EventCompacted, Hint: hint}) + hub.send(Event{Kind: EventCompacted, Hint: hint, Compaction: &fact}) } } diff --git a/internal/tui3/salience_test.go b/internal/tui3/salience_test.go index 778c3e9e13..0103cbb58f 100644 --- a/internal/tui3/salience_test.go +++ b/internal/tui3/salience_test.go @@ -180,6 +180,10 @@ var salienceTable = []salienceCase{ {name: "EventAutomationUpdate", chatOnly: "automations belong to the machine and the conversation that made " + "them, not to one node's work: the line is the conversation's (automation.go)", ev: session.Event{Kind: session.EventAutomationUpdate}}, + {name: "EventSummaryStarted", ev: session.Event{Kind: session.EventSummaryStarted, + Compaction: &session.CompactionFact{Seq: 1, TokensBefore: 120000}}}, + {name: "EventSummaryFailed", ev: session.Event{Kind: session.EventSummaryFailed, + Compaction: &session.CompactionFact{Seq: 1, Failed: session.SummaryFailedError}}}, {name: "EventQuestionDiscussion", chatOnly: "a clarification belongs to the person's pending decision and arrives on the conversation's question subscription", ev: session.Event{Kind: session.EventQuestionDiscussion, Discussion: &session.QuestionDiscussion{ ID: "clarify-1", Seq: 1, Event: &session.Event{Kind: session.EventTextDelta, Text: "the command only reads the report"}, From ebadabeb3af13b575555fdfd3787bba8f89a5b98 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 19:26:32 -0400 Subject: [PATCH 043/459] session: reserve a manual compaction before releasing its cancellation lock --- internal/session/compaction_facts.go | 11 +++++---- internal/session/compaction_stop_fact_test.go | 23 +++++++++++++++++++ 2 files changed, 30 insertions(+), 4 deletions(-) diff --git a/internal/session/compaction_facts.go b/internal/session/compaction_facts.go index 52f90db893..69a3ba3c41 100644 --- a/internal/session/compaction_facts.go +++ b/internal/session/compaction_facts.go @@ -149,11 +149,14 @@ func (a *Agent) CompactObserved(ctx context.Context, observe func(Event)) error a.mu.Unlock() return errAgentClosed } - // A pass already running keeps its own cut; this one is refused below - // as in flight before it could need one. - if a.manualPass == nil { - a.manualPass = cut + // Reserve before releasing the lock: another manual caller must not + // enter the gap before compactWithPolicyResult sets compacting, or its + // request could run while Stop still holds this caller's cancellation. + if a.manualPass != nil { + a.mu.Unlock() + return ErrCompactionInFlight } + a.manualPass = cut a.mu.Unlock() defer func() { a.mu.Lock() diff --git a/internal/session/compaction_stop_fact_test.go b/internal/session/compaction_stop_fact_test.go index 699c74217c..e5109a27bb 100644 --- a/internal/session/compaction_stop_fact_test.go +++ b/internal/session/compaction_stop_fact_test.go @@ -2,6 +2,7 @@ package session import ( "context" + "errors" "sync" "testing" "time" @@ -47,3 +48,25 @@ func TestStopCancelsAManualSummaryRetry(t *testing.T) { t.Fatal("manual summary still reports running after Stop") } } + +// The cancellation reservation exists before the pass takes its compaction +// gate. A second caller must not steal that gap and start uncancellable work. +func TestManualSummaryReservationRejectsAnotherPassBeforeStart(t *testing.T) { + model := &summarizer{} + agent, _ := summaryAgent(t, model) + journaledPersonHeavy(agent, 1, 12, 20_000) + agent.mu.Lock() + agent.manualPass = &manualPassCut{cancel: func() {}} + agent.mu.Unlock() + defer func() { + agent.mu.Lock() + agent.manualPass = nil + agent.mu.Unlock() + }() + if err := agent.CompactObserved(context.Background(), nil); !errors.Is(err, ErrCompactionInFlight) { + t.Fatalf("second manual pass = %v, want ErrCompactionInFlight", err) + } + if n := model.calls(); n != 0 { + t.Fatalf("second caller reached provider %d times before first pass started", n) + } +} From 4bb8847d4d01e5554d373f259db07b7808110905 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 19:31:24 -0400 Subject: [PATCH 044/459] session, app: a compaction pass stands where it began CompactionFact.StartedAt and Arrived, journaled as startedAt/arrived and carried on the app wire as started_at/ended_at. A display reading places a pass before the messages that arrived while its summary was being written, which is where a live surface drew it when it began; the summary entry's `at` is the start. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/appcompaction.go | 10 +++++ .../unreleased/app-compaction-facts.md | 1 + internal/appwire/compaction.go | 5 +++ internal/session/compaction_facts.go | 12 +++-- internal/session/compaction_facts_test.go | 44 +++++++++++++++++++ internal/session/loop.go | 11 ++++- internal/session/sessionfile.go | 22 ++++++++-- internal/session/turnfold.go | 1 + 8 files changed, 98 insertions(+), 8 deletions(-) diff --git a/cmd/codeaf/appcompaction.go b/cmd/codeaf/appcompaction.go index beaa211245..09d659e932 100644 --- a/cmd/codeaf/appcompaction.go +++ b/cmd/codeaf/appcompaction.go @@ -103,6 +103,16 @@ func appCompactionEntry(agent string, fact session.CompactionFact, state string) until := fact.CoversUntil facts.CoversUntil = &until } + // The entry stands where the pass began, so its settling replaces it in + // place rather than at the moment it ended. + if !fact.StartedAt.IsZero() { + started := fact.StartedAt + facts.StartedAt, at = &started, started + } + if state != appwire.CompactionSummarizing && !fact.At.IsZero() { + ended := fact.At + facts.EndedAt = &ended + } entry := appwire.Entry{ID: appCompactionEntryID(fact.Seq), Agent: agent, Kind: appwire.EntrySummary, At: at, Compaction: facts} if state == appwire.CompactionSettled && fact.Summarized > 0 { entry.Text = appCompactionWords diff --git a/docs/changes/unreleased/app-compaction-facts.md b/docs/changes/unreleased/app-compaction-facts.md index 41d09be9b1..b231f4e02c 100644 --- a/docs/changes/unreleased/app-compaction-facts.md +++ b/docs/changes/unreleased/app-compaction-facts.md @@ -9,5 +9,6 @@ invalidates: - "A display reading of a journal (ReadTranscript, the record cache) told only the region above the LAST pass, so after a second summary the person's earlier messages were unreachable. Record.Earlier is now stacked across every marker and Record.Passes places each pass. A live or resumed Agent still holds one hop and no summary bodies." - "The app listed every compaction as 'Earlier messages summarized', including passes that only stubbed tool output. Summary entries now carry compaction facts under id cs, and the words are set only for a pass that summarized." - "A /compact asked for outside a turn could not be stopped and said nothing until it ended. Interrupt, StopWork and Close now cut it, the remote engine streams its events as `pass` frames, and the app has agents.compact ending on compaction.ended." + - "A pass was placed where it ENDED, so a message steered in while its summary was written read back above the marker. CompactionFact.StartedAt and Arrived (journaled as startedAt/arrived, on the wire as started_at) place it where it began, live and after a reload." - "session.Facts said only what the conversation weighs. It now also carries contextWindow, compactAt and summarizing; an unknown window is absent, never the package's default." --- diff --git a/internal/appwire/compaction.go b/internal/appwire/compaction.go index e677a4c586..669ba90699 100644 --- a/internal/appwire/compaction.go +++ b/internal/appwire/compaction.go @@ -59,6 +59,11 @@ type Compaction struct { CoversUntil *time.Time `json:"covers_until,omitempty"` Failed string `json:"failed,omitempty" enum:"interrupted,declined,empty,changed,not_shorter,error"` Full bool `json:"full,omitempty"` + // StartedAt is when the pass began — where it stands in the + // conversation, before anything that arrived while it ran — and EndedAt + // when it settled or failed; absent while it runs. + StartedAt *time.Time `json:"started_at,omitempty"` + EndedAt *time.Time `json:"ended_at,omitempty"` } // EventCompactionEnded ends every agents.compact the app asked for, once, diff --git a/internal/session/compaction_facts.go b/internal/session/compaction_facts.go index 69a3ba3c41..687b6427e3 100644 --- a/internal/session/compaction_facts.go +++ b/internal/session/compaction_facts.go @@ -29,8 +29,14 @@ type CompactionFact struct { // were made, failures included. It is the same live and after a resume, // so a surface can name a pass by it. Seq int `json:"seq"` - // At is when the pass ended (or, on [EventSummaryStarted], began). - At time.Time `json:"at"` + // At is when the pass ended (or, on [EventSummaryStarted], began), and + // StartedAt when it began. Arrived is how many messages joined the + // conversation while its summary was being written: they came AFTER the + // pass began, so the pass is placed before them ([CompactionFact.Entry]), + // live and after a resume alike. + At time.Time `json:"at"` + StartedAt time.Time `json:"startedAt,omitempty"` + Arrived int `json:"arrived,omitempty"` // Entry is where the pass sits in the conversation told once and whole // ([Record]): how many display entries of it come before the pass. Set // only on [Record.Passes]; zero on an event, which a surface places at the @@ -256,7 +262,7 @@ func passInstant() time.Time { return time.Now().UTC().Round(0) } // passFact is a pass that edited the transcript, as the fact it is kept as. func passFact(pass compactionPass, tokensBefore int) CompactionFact { return CompactionFact{ - At: pass.at, TokensBefore: tokensBefore, TokensAfter: pass.tokensAfter, + At: pass.at, StartedAt: pass.startedAt, Arrived: pass.arrived, TokensBefore: tokensBefore, TokensAfter: pass.tokensAfter, Stubbed: pass.stubbed, Folded: pass.folded, Summarized: pass.summarized, Summary: pass.summary, Model: pass.summaryModel, Turns: pass.turns, CoversFrom: pass.coversFrom, CoversUntil: pass.coversUntil, diff --git a/internal/session/compaction_facts_test.go b/internal/session/compaction_facts_test.go index b148a2f535..56d1f1bc2c 100644 --- a/internal/session/compaction_facts_test.go +++ b/internal/session/compaction_facts_test.go @@ -318,3 +318,47 @@ func TestContextUseNeverReportsAGuessedWindow(t *testing.T) { t.Fatalf("ContextUse tokens %d, ContextTokens %d: one figure", use.Tokens, known.ContextTokens()) } } + +// A PASS STANDS WHERE IT BEGAN. A message that arrives while the summary is +// being written came after the pass started, and the record places the pass +// before it — the same place a live surface drew it when it began. +func TestAMessageArrivingDuringASummaryIsPlacedAfterThePass(t *testing.T) { + var agent *Agent + model := &summarizer{answer: func(_ int, _ []ai.Message) (*ai.Response, error) { + // The person steers while the request is out: journaled like any + // message, under the lock the pass released. + journaledPersonHeavy(agent, 13, 1, 64) + return textResponse(summaryWords), nil + }} + agent, path := summaryAgent(t, model) + journaledPersonHeavy(agent, 1, 12, 20_000) + events, err := observed(t, agent, context.Background()) + if err != nil { + t.Fatal(err) + } + started, settled := events[0].Compaction, events[len(events)-1].Compaction + if started.StartedAt.IsZero() || !settled.StartedAt.Equal(started.StartedAt) || settled.Arrived != 2 { + t.Fatalf("started %+v settled %+v; want one start instant and the 2 arrived messages", started, settled) + } + record := ReadTranscript(path) + if len(record.Passes) != 1 || !record.Passes[0].StartedAt.Equal(started.StartedAt) || record.Passes[0].Arrived != 2 { + t.Fatalf("journal pass = %+v", record.Passes) + } + told := append(append([]DisplayEntry(nil), record.Earlier...), record.Entries[record.Floor:]...) + place := record.Passes[0].Entry + if place <= 0 || place >= len(told) { + t.Fatalf("pass placed at %d of %d", place, len(told)) + } + if !strings.HasPrefix(told[place].Text, "question 13:") || !strings.HasPrefix(told[place-2].Text, "question 12:") { + t.Fatalf("pass placed before %.20q after %.20q; want between question 12's turn and the message that arrived", told[place].Text, told[place-2].Text) + } + seen := 0 + for _, entry := range told { + if strings.HasPrefix(entry.Text, "question 13:") { + seen++ + } + } + if seen != 1 { + t.Fatalf("the arrived message is told %d times, want once", seen) + } +} diff --git a/internal/session/loop.go b/internal/session/loop.go index 0c7717fd00..bc725e1413 100644 --- a/internal/session/loop.go +++ b/internal/session/loop.go @@ -4936,6 +4936,8 @@ type compactionPass struct { // wrote it, what that summary stands for, and why a summary it asked for // did not land. at time.Time + startedAt time.Time + arrived int tokensAfter int summary string summaryModel string @@ -5007,6 +5009,9 @@ func (a *Agent) compactWithPolicyResult(ctx context.Context, hub *eventHub, poli // waiting on a lock the pass is holding while the pass waits on the surface // (phasenews.go). Nothing is being claimed here, so an in-flight pass that // is refused below still ends this clock on the way out. + // The instant the pass began: where it stands in the conversation is + // before anything that arrives while it runs ([CompactionFact.StartedAt]). + startedAt := passInstant() a.tellPhase(provider.PhaseTidying, "the conversation", time.Now()) defer a.endPhase() // The definitions are outside the transcript and take the belt's own lock. @@ -5041,7 +5046,7 @@ func (a *Agent) compactWithPolicyResult(ctx context.Context, hub *eventHub, poli // The system message needs no removing — shapeEntries drops it. earlier := shapeEntries(a.messages, a.file, a.presentation) - pass := compactionPass{stored: a.chatlog != nil} + pass := compactionPass{stored: a.chatlog != nil, startedAt: startedAt} if !policy.active { pass.stubbed = a.stubOldOutputsLocked() } @@ -5077,12 +5082,13 @@ func (a *Agent) compactWithPolicyResult(ctx context.Context, hub *eventHub, poli // THE ONE PROOF A SUMMARY IS BEING WRITTEN, sent with the lock // released and before the request: a surface shows the wait for // as long as it lasts and no longer. - hub.send(Event{Kind: EventSummaryStarted, Compaction: &CompactionFact{Seq: seq, At: passInstant(), TokensBefore: tokensBefore}}) + hub.send(Event{Kind: EventSummaryStarted, Compaction: &CompactionFact{Seq: seq, At: startedAt, StartedAt: startedAt, TokensBefore: tokensBefore}}) a.tellPhase(provider.PhaseTidying, "summarizing the conversation", time.Now()) summary, answeredBy, err := a.writeSummaryAs(ctx, plan) a.mu.Lock() a.summarizing = false appendedDuringSummary = max(0, len(a.messages)-lengthBeforeSummary) + pass.arrived = appendedDuringSummary if err == nil { pass.summarized, why = a.spliceSummaryLocked(plan, summary) } else { @@ -5122,6 +5128,7 @@ func (a *Agent) compactWithPolicyResult(ctx context.Context, hub *eventHub, poli if attempted { tokens := a.meterTokensLocked() fact := a.recordPassLocked(CompactionFact{ + StartedAt: startedAt, Arrived: pass.arrived, TokensBefore: tokensBefore, TokensAfter: tokens, Failed: pass.failed, Full: a.contextFullLocked(tokens), }) diff --git a/internal/session/sessionfile.go b/internal/session/sessionfile.go index f94aeaf753..abba72ed5c 100644 --- a/internal/session/sessionfile.go +++ b/internal/session/sessionfile.go @@ -247,6 +247,11 @@ type sessionEntry struct { KeptFrom string `json:"keptFrom,omitempty"` KeptUntil string `json:"keptUntil,omitempty"` SummaryFail string `json:"summaryFailed,omitempty"` + // StartedAt is when the pass began and Arrived how many message lines + // written before this one joined the conversation after it began (while + // its summary was being written); the pass is placed before them. + StartedAt string `json:"startedAt,omitempty"` + Arrived int `json:"arrived,omitempty"` ContextFull bool `json:"contextFull,omitempty"` // Window is HOW MANY MESSAGE LINES THE PASS RE-JOURNALED BEHIND THIS MARKER @@ -2040,10 +2045,16 @@ func readJournal(reader io.Reader, path string, rebuild bool) (replayedSession, // whole once a pass could summarize the person's own words — after // a second summary it opened on the first summary's note. asked, stopped = false, "" + // THE PASS STANDS WHERE IT BEGAN: the messages that arrived while + // its summary was written are journaled above its marker, and are + // placed after it. + start := earlierRows if rebuild { earlier = append(earlier[:0], messages...) } else { beyond := messages[min(overlap, len(messages)):] + before := max(0, len(beyond)-max(entry.Arrived, 0)) + start += countEntries(beyond[:before]) earlier = append(earlier, beyond...) earlierRows += countEntries(beyond) } @@ -2061,7 +2072,7 @@ func readJournal(reader io.Reader, path string, rebuild bool) (replayedSession, if overlap = compactionOverlap(entry); overlap < 0 { earlier, overlap, earlierRows, placed = earlier[:0], 0, 0, false } - passes = appendPass(passes, entry, earlierRows, rebuild) + passes = appendPass(passes, entry, start, rebuild) // A REGION THAT CANNOT BE PLACED TAKES ITS PASSES' PLACES WITH IT: // the conversation they stood in is no longer told, so they are // kept as facts (their sequence still counts) placed nowhere. @@ -2084,7 +2095,8 @@ func readJournal(reader io.Reader, path string, rebuild bool) (replayedSession, // conversation as it stood then, and nothing is rebuilt. place := 0 if !rebuild { - place = earlierRows + countEntries(messages[min(overlap, len(messages)):]) + tail := messages[min(overlap, len(messages)):] + place = earlierRows + countEntries(tail[:max(0, len(tail)-max(entry.Arrived, 0))]) } passes = appendPass(passes, entry, place, rebuild) case "caption": @@ -2938,6 +2950,8 @@ func (s *sessionFile) appendCompactionFailed(fact CompactionFact) { TokensAfter: fact.TokensAfter, SummaryFail: fact.Failed, ContextFull: fact.Full, + StartedAt: journalInstant(fact.StartedAt), + Arrived: fact.Arrived, Timestamp: journalInstant(fact.At), }) } @@ -2974,7 +2988,7 @@ func journalInstant(at time.Time) string { // records, placed at entry. func compactionFactOf(entry sessionEntry, place int) CompactionFact { return CompactionFact{ - At: lineInstant(entry.Timestamp), Entry: place, + At: lineInstant(entry.Timestamp), StartedAt: lineInstant(entry.StartedAt), Arrived: entry.Arrived, Entry: place, TokensBefore: entry.TokensBefore, TokensAfter: entry.TokensAfter, Stubbed: entry.Stubbed, Folded: entry.Folded, Summarized: entry.Summarized, Summary: entry.Kept, Model: entry.KeptModel, Turns: entry.KeptTurns, @@ -2998,6 +3012,8 @@ func (s *sessionFile) appendCompaction(pass compactionPass, tokensBefore int, wi KeptUntil: journalInstant(pass.coversUntil), SummaryFail: pass.failed, ContextFull: pass.full, + StartedAt: journalInstant(pass.startedAt), + Arrived: pass.arrived, // The length is written BEFORE the window it describes, which is the only // order that survives a crash halfway through: a reader that finds fewer // lines than the number promised has a truncated file and can say so, diff --git a/internal/session/turnfold.go b/internal/session/turnfold.go index a6b4f86d3b..2c5fea6d37 100644 --- a/internal/session/turnfold.go +++ b/internal/session/turnfold.go @@ -224,6 +224,7 @@ func (a *Agent) foldTurnOutputs(seenThrough int, consumedReads map[*ai.ToolCall] a.earlierFloor = countEntries(a.messages) pass := compactionPass{stubbed: results, stored: a.chatlog != nil} pass.at, pass.tokensAfter = passInstant(), a.meterTokensLocked() + pass.startedAt = pass.at pass.full = a.contextFullLocked(pass.tokensAfter) fact := a.recordPassLocked(passFact(pass, before)) if a.file != nil { From b85b544ed3335887b610a32a462ad56f06de7805 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 19:38:11 -0400 Subject: [PATCH 045/459] session: join manual summary cancellation before closing its journal --- internal/session/agent.go | 24 +++++-- internal/session/compaction_facts.go | 8 ++- internal/session/compaction_stop_fact_test.go | 62 +++++++++++++++++++ 3 files changed, 88 insertions(+), 6 deletions(-) diff --git a/internal/session/agent.go b/internal/session/agent.go index ba5bcdcd18..a0e16b8c04 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -2445,6 +2445,14 @@ func (a *Agent) Close() error { file := a.file cancel := a.cancel done := a.done + // Closing admission above also closes the gap in which a new manual pass + // could have reserved its cancellation after the early cut. Join the pass + // before closing its journal, on the turn's same bounded grace. + cutPass = a.cutManualPassLocked() + var manualDone <-chan struct{} + if a.manualPass != nil { + manualDone = a.manualPass.done + } // The graph THIS session owns, and nil for every session that does not: a // task worker reaches its parent's graph through config.tasker, and a nil // here is what keeps its close from shutting a door the parent still @@ -2475,6 +2483,7 @@ func (a *Agent) Close() error { // joins from inside its own goroutine. a.cancelOrchestrationsLocked() a.mu.Unlock() + cutPass() // AND THE PROCESS STOPS SAYING IT HOLDS THIS CONVERSATION, before anything // below can take time: a window that is closing is not somebody in front of // this process, and every reader of the registry should stop counting it at @@ -2520,11 +2529,18 @@ func (a *Agent) Close() error { // process between the answer and the append would lose it (title.go). a.waitForTitle() a.closeDiscussions() - if done != nil { + if done != nil || manualDone != nil { timer := time.NewTimer(closeGrace) - select { - case <-done: - case <-timer.C: + join: + for _, ended := range []<-chan struct{}{done, manualDone} { + if ended == nil { + continue + } + select { + case <-ended: + case <-timer.C: + break join + } } timer.Stop() } diff --git a/internal/session/compaction_facts.go b/internal/session/compaction_facts.go index 687b6427e3..064b09c155 100644 --- a/internal/session/compaction_facts.go +++ b/internal/session/compaction_facts.go @@ -149,7 +149,8 @@ func (a *Agent) ContextUse() ContextUse { func (a *Agent) CompactObserved(ctx context.Context, observe func(Event)) error { ctx, cancel := context.WithCancel(ctx) defer cancel() - cut := &manualPassCut{cancel: cancel} + cut := &manualPassCut{cancel: cancel, done: make(chan struct{})} + defer close(cut.done) a.mu.Lock() if a.closed { a.mu.Unlock() @@ -183,7 +184,10 @@ func (a *Agent) CompactObserved(ctx context.Context, observe func(Event)) error } // manualPassCut is the cut of one pass a surface asked for outside a turn. -type manualPassCut struct{ cancel context.CancelFunc } +type manualPassCut struct { + cancel context.CancelFunc + done chan struct{} // closed once the pass has finished journaling +} // cutManualPassLocked takes the running manual pass's cut, to be called with // the lock released. diff --git a/internal/session/compaction_stop_fact_test.go b/internal/session/compaction_stop_fact_test.go index e5109a27bb..249c3b0c63 100644 --- a/internal/session/compaction_stop_fact_test.go +++ b/internal/session/compaction_stop_fact_test.go @@ -70,3 +70,65 @@ func TestManualSummaryReservationRejectsAnotherPassBeforeStart(t *testing.T) { t.Fatalf("second caller reached provider %d times before first pass started", n) } } + +// Cancellation can return before a provider goroutine has handed its error +// back. Close must give that pass the same bounded journal grace as a turn. +type closingSummaryModel struct { + started chan struct{} + cut chan struct{} + release chan struct{} +} + +func (m *closingSummaryModel) CompleteWithMessages(ctx context.Context, _ []ai.Message, _ ...ai.Option) (*ai.Response, error) { + close(m.started) + <-ctx.Done() + close(m.cut) + <-m.release + return nil, ctx.Err() +} + +func TestCloseJoinsAManualSummaryBeforeClosingItsJournal(t *testing.T) { + model := &closingSummaryModel{started: make(chan struct{}), cut: make(chan struct{}), release: make(chan struct{})} + var releaseOnce sync.Once + release := func() { releaseOnce.Do(func() { close(model.release) }) } + defer release() + agent, path := summaryAgent(t, model) + journaledPersonHeavy(agent, 1, 12, 20_000) + compacted := make(chan error, 1) + go func() { compacted <- agent.CompactObserved(context.Background(), nil) }() + select { + case <-model.started: + case <-time.After(5 * time.Second): + t.Fatal("manual summary did not start") + } + closed := make(chan error, 1) + go func() { closed <- agent.Close() }() + select { + case <-model.cut: + case <-time.After(5 * time.Second): + t.Fatal("Close did not cancel the summary request") + } + select { + case err := <-closed: + t.Fatalf("Close returned before the summary could journal its final fact: %v", err) + case <-time.After(30 * time.Millisecond): + } + release() + select { + case err := <-closed: + if err != nil { + t.Fatal(err) + } + case <-time.After(5 * time.Second): + t.Fatal("Close did not join the canceled summary") + } + select { + case <-compacted: + case <-time.After(5 * time.Second): + t.Fatal("manual summary did not finish") + } + record := ReadTranscript(path) + if len(record.Passes) == 0 || record.Passes[len(record.Passes)-1].Failed != SummaryFailedInterrupted { + t.Fatal("the canceled summary's final fact was lost at Close") + } +} From 4a938664ad9804002a175d6ebd1b887eea87c125 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 19:18:05 -0400 Subject: [PATCH 046/459] competence: keep what each model proved by declared checks that ran, and serve it A settled run's declared-check exits (the plan store's VerdictBasis of kind "run", read off each check's own trajectory) are written to an append-only ledger, /v3/checked-results.jsonl, on the one model that answered the run's work. A run answered by more than one model or an unnamed one, a run another approved task joined, and work with no check that ran are no evidence. Nothing historical is inferred. The ledger outlives task and conversation deletion; readers report source_missing. The model is the one that ANSWERED: session.Agent now tallies its banked money per answering model (AnsweredUsage), and internal/run writes one plandb spend row per answering model instead of one row on the seat's built model. The rows sum to the same figures, so price readers are unchanged. internal/competence holds the ledger (bounded, redacted, identity fields refused rather than cut, outcome decided over every check before the shown list is cut) and a bounded one-pass reader: four engine-owned classes (stale after 30 days), exact-kind scopes with stable ids, capped answers with honest omitted counts, stable cursors, and the deadline honoured through grouping. Served as competence.map / competence.evidence on the app wire (absent engine answers unknown_method) and as codeaf competence --json; the CLI's text output is unchanged. taskKindOf/taskKindsMeet now delegate to competence.WorkOf / WorkMeets with identical semantics. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/appcompetence.go | 194 +++++ cmd/codeaf/appcompetence_test.go | 128 +++ cmd/codeaf/competence.go | 24 +- cmd/codeaf/main.go | 4 +- .../0-competence-checked-results.md | 19 + internal/appwire/competence.go | 225 ++++++ internal/competence/ledger.go | 299 +++++++ internal/competence/policy.go | 74 ++ internal/competence/read.go | 759 ++++++++++++++++++ internal/competence/read_test.go | 645 +++++++++++++++ internal/competence/work.go | 95 +++ .../manual/chat/running-from-the-terminal.md | 28 + internal/manual/chat_test.go | 3 + internal/run/bashworker.go | 46 +- internal/run/bashworker_answered_test.go | 66 ++ internal/session/competence.go | 281 +++++++ internal/session/competence_test.go | 203 +++++ internal/session/loop.go | 4 + internal/session/session.go | 4 + internal/session/task_run_belt.go | 3 + internal/session/taskgrade.go | 85 +- 21 files changed, 3095 insertions(+), 94 deletions(-) create mode 100644 cmd/codeaf/appcompetence.go create mode 100644 cmd/codeaf/appcompetence_test.go create mode 100644 docs/changes/unreleased/0-competence-checked-results.md create mode 100644 internal/appwire/competence.go create mode 100644 internal/competence/ledger.go create mode 100644 internal/competence/policy.go create mode 100644 internal/competence/read.go create mode 100644 internal/competence/read_test.go create mode 100644 internal/competence/work.go create mode 100644 internal/run/bashworker_answered_test.go create mode 100644 internal/session/competence.go create mode 100644 internal/session/competence_test.go diff --git a/cmd/codeaf/appcompetence.go b/cmd/codeaf/appcompetence.go new file mode 100644 index 0000000000..d4b760398d --- /dev/null +++ b/cmd/codeaf/appcompetence.go @@ -0,0 +1,194 @@ +package main + +import ( + "context" + "errors" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/competence" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/teams" +) + +// THE COMPETENCE MAP HAS ONE READER, and both doors go through it: the app's +// competence.map and competence.evidence, and `codeaf competence --json`. The +// wire shapes are appwire's; this file only translates the ledger's answer +// into them, so the CLI and the app can never print two different maps. + +// competenceReadWindow bounds one read. A ledger is read once per question and +// the bound is generous; a read that takes longer than this is a machine in +// trouble, and saying so beats a window that never draws. +const competenceReadWindow = 10 * time.Second + +// competenceReader is the ledger as this process's home holds it, with the +// session's answers to "is this folder still worked in" and "is this source +// still there". It makes no model call and writes nothing. +func competenceReader() competence.Reader { + return competence.Reader{ + PlaceAdded: competencePlaces(), + SourceGone: session.CheckedSourceGone, + SourceTitle: session.CheckedSourceTitle, + } +} + +// competencePlaces answers whether codeaf still has a conversation in a +// folder. It looks in that folder's own project bucket — one directory list per +// place, never a walk of every conversation — and finds a conversation folder +// nobody deleted. A folder that IS a conversation's own working folder (under +// the projects root) is worked in while it exists. It creates nothing. +func competencePlaces() func(string) bool { + root := session.PlacesRoot() + return func(place string) bool { + place = filepath.Clean(place) + if strings.HasPrefix(place, root+string(filepath.Separator)) { + _, err := os.Stat(place) + return err == nil + } + entries, err := os.ReadDir(filepath.Join(root, encodeWorkspace(place))) + if err != nil { + return false + } + for _, entry := range entries { + if !entry.IsDir() { + continue + } + if _, err := os.Stat(filepath.Join(root, encodeWorkspace(place), entry.Name(), teams.ConversationDeletedFile)); os.IsNotExist(err) { + return true + } + } + return false + } +} + +// readCompetenceMap is competence.map, for any door. +func readCompetenceMap(reader competence.Reader, model, scope string) (appwire.CompetenceMap, error) { + ctx, done := context.WithTimeout(context.Background(), competenceReadWindow) + defer done() + read, err := reader.Map(ctx, model, scope) + if err != nil { + return appwire.CompetenceMap{}, competenceError(err) + } + return wireCompetenceMap(read), nil +} + +// readCompetenceEvidence is competence.evidence, for any door. +func readCompetenceEvidence(reader competence.Reader, p appwire.CompetenceEvidenceParams) (appwire.CompetenceEvidence, error) { + ctx, done := context.WithTimeout(context.Background(), competenceReadWindow) + defer done() + page, err := reader.Evidence(ctx, p.Model, p.Scope, competence.Outcome(p.Outcome), p.Cursor, p.Limit) + if err != nil { + return appwire.CompetenceEvidence{}, competenceError(err) + } + return wireCompetenceEvidence(page), nil +} + +// competenceError is the wire's word for a read that did not answer. +func competenceError(err error) error { + switch { + case errors.Is(err, competence.ErrUnknownScope): + return appwire.Errorf(appwire.CodeBadParams, "%s", err.Error()) + case errors.Is(err, competence.ErrBadCursor): + return appwire.Errorf(appwire.CodeBadParams, "%s", err.Error()) + case errors.Is(err, context.DeadlineExceeded): + return appwire.Errorf(appwire.CodeEngine, "competence read timed out") + } + return err +} + +func (b *appBackend) CompetenceMap(p appwire.CompetenceMapParams) (appwire.CompetenceMap, error) { + return readCompetenceMap(competenceReader(), p.Model, p.Scope) +} + +func (b *appBackend) CompetenceEvidence(p appwire.CompetenceEvidenceParams) (appwire.CompetenceEvidence, error) { + return readCompetenceEvidence(competenceReader(), p) +} + +func wireCompetenceMap(read competence.Map) appwire.CompetenceMap { + out := appwire.CompetenceMap{ + Model: read.Model, Scope: read.Scope, + Models: make([]appwire.CompetenceModel, 0, len(read.Models)), + Filters: make([]appwire.CompetenceFilter, 0, len(read.Filters)), + Groups: make([]appwire.CompetenceGroup, 0, len(read.Groups)), + Totals: appwire.CompetenceTotals{Scopes: read.Totals.Scopes, Results: read.Totals.Results}, + Policy: appwire.CompetencePolicy{ + StaleAfterDays: read.Policy.StaleAfterDays, StrongAtLeast: read.Policy.StrongAtLeast, + WeakAtMost: read.Policy.WeakAtMost, MinResults: read.Policy.MinResults, + }, + ReadAt: read.ReadAt, Truncated: read.Truncated, + Omitted: appwire.CompetenceOmitted{Scopes: read.Omitted.Scopes, Filters: read.Omitted.Filters, Models: read.Omitted.Models}, + } + for _, model := range read.Models { + out.Models = append(out.Models, appwire.CompetenceModel{ID: model.ID, Results: model.Results, LastAt: model.LastAt}) + } + for _, filter := range read.Filters { + out.Filters = append(out.Filters, appwire.CompetenceFilter{ + ID: filter.ID, Kind: filter.Kind, Path: filter.Path, + PlaceKind: filter.PlaceKind, Removed: filter.Removed, Words: filter.Words, + }) + } + for _, group := range read.Groups { + scopes := make([]appwire.CompetenceScope, 0, len(group.Scopes)) + for _, scope := range group.Scopes { + wire := appwire.CompetenceScope{ + ID: scope.ID, + Place: appwire.CompetencePlace{ + ID: scope.Place.ID, Path: scope.Place.Path, + PlaceKind: scope.Place.Kind, Removed: scope.Place.Removed, + }, + Work: appwire.CompetenceWork{ID: scope.Work.ID, Words: scope.Work.Words}, + Class: string(scope.Class), + Results: scope.Results, Passed: scope.Passed, Failed: scope.Failed, Undone: scope.Undone, + LastAt: scope.LastAt, + } + if !scope.Since.IsZero() { + since := scope.Since + wire.Since = &since + } + scopes = append(scopes, wire) + } + out.Groups = append(out.Groups, appwire.CompetenceGroup{Class: string(group.Class), Scopes: scopes}) + } + return out +} + +func wireCompetenceEvidence(page competence.EvidencePage) appwire.CompetenceEvidence { + out := appwire.CompetenceEvidence{ + Model: page.Model, Scope: page.Scope, NextCursor: page.NextCursor, + Counts: appwire.CompetenceCounts{All: page.Counts.All, Passed: page.Counts.Passed, Failed: page.Counts.Failed, Undone: page.Counts.Undone}, + Items: make([]appwire.CompetenceItem, 0, len(page.Items)), + } + for _, item := range page.Items { + wire := appwire.CompetenceItem{ + ID: item.ID, Title: item.Title, At: item.At, Outcome: string(item.Outcome), + Source: appwire.CompetenceSource{ + Kind: item.Source.Kind, Agent: item.Source.Agent, Turn: item.Source.Turn, Title: item.SourceTitle, + ID: item.Source.Agent, + }, + SourceMissing: item.SourceMissing, + Checks: make([]appwire.CompetenceCheck, 0, len(item.Checks)), + } + // A TASK IS NAMED BY ITS WIRE ID, the one tasks.get and task.changed carry. + if item.Source.Kind == competence.SourceTask { + if number, err := strconv.ParseUint(item.Source.Task, 10, 64); err == nil { + wire.Source.ID = appTaskID(item.Source.Agent, number) + } + } + for _, check := range item.Checks { + wire.Checks = append(wire.Checks, appwire.CompetenceCheck{Command: check.Command, Outcome: string(check.Outcome)}) + } + if item.Check != nil { + wire.Check = &appwire.CompetenceCheck{Command: item.Check.Command, Outcome: string(item.Check.Outcome)} + } + if !item.UndoneAt.IsZero() { + undone := item.UndoneAt + wire.UndoneAt = &undone + } + out.Items = append(out.Items, wire) + } + return out +} diff --git a/cmd/codeaf/appcompetence_test.go b/cmd/codeaf/appcompetence_test.go new file mode 100644 index 0000000000..c7f63687ea --- /dev/null +++ b/cmd/codeaf/appcompetence_test.go @@ -0,0 +1,128 @@ +package main + +import ( + "bytes" + "encoding/json" + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/competence" + "github.com/Agent-Field/codeaf/internal/home" +) + +// competenceHome is a state root with a ledger of the given results, and a +// project bucket for the one place still worked in. +func competenceHome(t *testing.T) (worked, gone string) { + t.Helper() + root := t.TempDir() + t.Setenv(home.EnvVar, filepath.Join(root, "state")) + worked, gone = filepath.Join(root, "api"), filepath.Join(root, "ffi") + conversation := filepath.Join(home.Join("v3", "projects"), encodeWorkspace(worked), "0123456789abcdef") + if err := os.MkdirAll(conversation, 0o700); err != nil { + t.Fatal(err) + } + at := time.Now().Add(-time.Hour) + for index, place := range []string{worked, gone} { + err := competence.Append(competence.LedgerPath(), competence.Record{ + Kind: competence.KindResult, ID: competence.TaskID("0123456789abcdef", string(rune('1'+index))), + At: at, Model: "m/flash", Place: place, Work: "parser changes", Title: "Parser changes", + Source: competence.Source{Kind: competence.SourceTask, Agent: "0123456789abcdef", Task: string(rune('1' + index)), + Transcript: filepath.Join(conversation, "transcript.jsonl")}, + Checks: []competence.CheckRun{{Command: "go test ./...", Outcome: competence.Passed}}, + }) + if err != nil { + t.Fatal(err) + } + } + return worked, gone +} + +// `codeaf competence --json` IS THE APP'S competence.map, byte for byte the +// same shape, and it never needs the resident's store. +func TestCompetenceJSONIsTheAppsMap(t *testing.T) { + worked, gone := competenceHome(t) + var out bytes.Buffer + if err := runCompetenceTo([]string{"--json", "--db", filepath.Join(t.TempDir(), "absent.db")}, &out, time.Now()); err != nil { + t.Fatal(err) + } + var answer appwire.CompetenceMap + if err := json.Unmarshal(out.Bytes(), &answer); err != nil { + t.Fatalf("%v: %s", err, out.String()) + } + if answer.Model != "m/flash" || len(answer.Groups) != 4 || answer.Totals.Scopes != 2 || answer.Totals.Results != 2 { + t.Fatalf("answer %+v", answer) + } + removed := map[string]bool{} + for _, scope := range answer.Groups[1].Scopes { + removed[scope.Place.Path] = scope.Place.Removed + if scope.Since == nil || scope.Class != appwire.ClassFrontier { + t.Fatalf("scope %+v", scope) + } + } + if removed[worked] || !removed[gone] { + t.Fatalf("removed %v", removed) + } + if _, err := os.Stat(filepath.Join(home.Join("v3", "projects"), encodeWorkspace(gone))); !os.IsNotExist(err) { + t.Fatal("asking whether a place is worked in made its bucket") + } + if err := runCompetenceTo([]string{"--scope", "place:x"}, &out, time.Now()); err == nil { + t.Fatal("--scope without --json was accepted") + } +} + +// AN EMPTY MACHINE IS AN EMPTY MAP, said honestly. +func TestCompetenceJSONOnAnEmptyMachine(t *testing.T) { + t.Setenv(home.EnvVar, filepath.Join(t.TempDir(), "state")) + var out bytes.Buffer + if err := runCompetenceTo([]string{"--json"}, &out, time.Now()); err != nil { + t.Fatal(err) + } + var answer appwire.CompetenceMap + if err := json.Unmarshal(out.Bytes(), &answer); err != nil { + t.Fatal(err) + } + if answer.Model != "" || len(answer.Models) != 0 || len(answer.Groups) != 4 || answer.Groups[0].Scopes == nil { + t.Fatalf("empty answer %s", out.String()) + } +} + +// THE APP'S TWO METHODS: a page of evidence with the task's wire id, and a +// bad scope or cursor answered as bad_params. +func TestTheAppReadsCompetenceEvidence(t *testing.T) { + competenceHome(t) + backend := &appBackend{} + answer, err := backend.CompetenceMap(appwire.CompetenceMapParams{}) + if err != nil { + t.Fatal(err) + } + scope := answer.Groups[1].Scopes[0].ID + page, err := backend.CompetenceEvidence(appwire.CompetenceEvidenceParams{Model: "m/flash", Scope: scope, Limit: 1000}) + if err != nil { + t.Fatal(err) + } + if len(page.Items) != 1 || page.Counts.All != 1 || page.NextCursor != "" { + t.Fatalf("page %+v", page) + } + item := page.Items[0] + if item.Source.Kind != "task" || item.Source.Agent != "0123456789abcdef" || item.Source.ID != appTaskID("0123456789abcdef", uint64(item.ID[len(item.ID)-1]-'0')) || + item.SourceMissing != "" || item.Check == nil || item.Outcome != "passed" { + t.Fatalf("item %+v", item) + } + for _, bad := range []appwire.CompetenceEvidenceParams{ + {Model: "m/flash", Scope: "scope:nope"}, + {Model: "m/flash", Scope: scope, Cursor: "not-a-cursor"}, + } { + _, err := backend.CompetenceEvidence(bad) + var wire *appwire.Error + if !errors.As(err, &wire) || wire.Code != appwire.CodeBadParams { + t.Fatalf("%+v answered %v", bad, err) + } + } + if _, err := backend.CompetenceMap(appwire.CompetenceMapParams{Scope: "work:nope"}); err == nil { + t.Fatal("an unknown filter was accepted") + } +} diff --git a/cmd/codeaf/competence.go b/cmd/codeaf/competence.go index 3fc9e69280..a1bd4055db 100644 --- a/cmd/codeaf/competence.go +++ b/cmd/codeaf/competence.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "fmt" "io" "os" @@ -21,12 +22,31 @@ func runCompetence(args []string) error { func runCompetenceTo(args []string, output io.Writer, now time.Time) error { flags := commandFlags("competence") database := flags.String("db", defaultChatDB(), storeFlagHelp) - modelFlag := flags.String("model", "", "working model whose profile buckets to include") + modelFlag := flags.String("model", "", "working model whose profile buckets to include; with --json, the exact model id to map") + jsonFlag := flags.Bool("json", false, "print what each model has proven from its checked results, as the app's competence.map answers it") + scopeFlag := flags.String("scope", "", "with --json, a place, kind-of-work or scope id to narrow the map to") if err := parseCommandFlags(flags, reorder(flags, args)); err != nil { return err } if flags.NArg() != 0 { - return fmt.Errorf("usage: codeaf competence [--db path] [--model slug]") + return fmt.Errorf("usage: codeaf competence [--db path] [--model slug] [--json [--scope id]]") + } + // --json IS A DIFFERENT QUESTION, NOT A DIFFERENT SPELLING. The text below + // is the resident's territory and profile calibration; --json is the + // checked-results ledger (internal/competence), the same map the app draws, + // so it never reads the resident's store and never picks a model for the + // person: an empty --model is the model with the newest evidence. + if *jsonFlag { + read, err := readCompetenceMap(competenceReader(), strings.TrimSpace(*modelFlag), strings.TrimSpace(*scopeFlag)) + if err != nil { + return err + } + encoder := json.NewEncoder(output) + encoder.SetIndent("", " ") + return encoder.Encode(read) + } + if strings.TrimSpace(*scopeFlag) != "" { + return fmt.Errorf("--scope narrows the --json map; add --json") } path, err := expandHome(strings.TrimSpace(*database)) if err != nil { diff --git a/cmd/codeaf/main.go b/cmd/codeaf/main.go index a872e6af3a..798ef1509a 100644 --- a/cmd/codeaf/main.go +++ b/cmd/codeaf/main.go @@ -575,8 +575,8 @@ Housekeeping — changes state on disk or on the network what it has learned, and what it has been corrected on codeaf notebook retract|restore [--db path] ` + collectionsSummary + ` - codeaf competence [--db path] [--model slug] - what it has been measured as good at + codeaf competence [--db path] [--model slug] [--json [--scope id]] + what it has been measured as good at; --json, what each model proved codeaf services [--db path] long-running processes it was asked to keep codeaf services stop [--db path] diff --git a/docs/changes/unreleased/0-competence-checked-results.md b/docs/changes/unreleased/0-competence-checked-results.md new file mode 100644 index 0000000000..3936167f5d --- /dev/null +++ b/docs/changes/unreleased/0-competence-checked-results.md @@ -0,0 +1,19 @@ +--- +kind: added +title: codeaf keeps what each model has proven by declared checks that ran, and serves it as competence.map / competence.evidence and codeaf competence --json +pr: 0 +surface: [engine, chat, docs] +invalidates: + - "No record tied a model, a place and a real check outcome together: the project index's verdictBasis was declared but never filled, a checker's verdict is a model's judgement, and the run road's per-check exits lived only in plandb.db, which deleting the task removes. A settled run's declared-check exits (plandb VerdictBasis kind run) are now written, with the one model that answered its work, to /v3/checked-results.jsonl. Work finished before this has no evidence on it and none is inferred." + - "A run-road worker's plandb spend row named the model the seat was BUILT on, even when its calls were answered by a fallback. internal/run now writes one spend row per model that answered (session.Agent.AnsweredUsage, tallied at the one bank door); the rows sum to the same figures, so every price reader is unchanged." + - "`codeaf competence` had no JSON and only the resident's territory/profile view (unchecked done counted as success, stale after 90 days). `--json [--model id] [--scope id]` now prints the checked-results map the app draws (stale after 30 days, a failed check counts against); the text output is unchanged." + - "Deleting a task or a conversation removed every trace of how its checks came out. Checked results now outlive it and are reported with source_missing." + - "The task kind (`taskKindOf`/`taskKindsMeet`) lived only in internal/session's taskgrade.go. It is now internal/competence's WorkOf/WorkMeets, read by the task ledger unchanged; the competence map groups by the exact kind, not the overlap." +--- + +Not recorded, on purpose: a run another approved task joined (no fact says which +check proved which goal), a run answered by more than one model or by an unnamed +one, and undo — `quick.undo` takes back quick tasks, which run no declared checks. +The app wire is in docs/design/app-v3/PROTOCOL.md under V76; an engine without it +answers `unknown_method`. Both reads are bounded (newest 32 MiB, 10 s, ≤300 scopes) +and call no model. diff --git a/internal/appwire/competence.go b/internal/appwire/competence.go new file mode 100644 index 0000000000..e8e68c4307 --- /dev/null +++ b/internal/appwire/competence.go @@ -0,0 +1,225 @@ +package appwire + +import "time" + +// V76: the competence map — what each model has proven, with evidence. +// +// A result is one finished task whose DECLARED checks ran with a real exit; a +// pass counts for, a failed check or a person's undo counts against. No model's +// judgement of work reaches these shapes, and nothing here changes which model +// runs: both methods are reads. The engine groups and counts; the app draws. + +// The classes a scope is in, in the order a map draws them. +const ( + ClassStrong = "strong" + ClassFrontier = "frontier" + ClassWeak = "weak" + ClassStale = "stale" +) + +// Competencer is an engine that reads the competence ledger. It is asserted, +// as Tidier is, so an engine without it answers unknown_method. +type Competencer interface { + CompetenceMap(p CompetenceMapParams) (CompetenceMap, error) + CompetenceEvidence(p CompetenceEvidenceParams) (CompetenceEvidence, error) +} + +// CompetenceMapParams is competence.map's params: an exact model id (empty is +// the model with the newest evidence) and a filter, place or scope id (empty is +// every scope). +type CompetenceMapParams struct { + Model string `json:"model,omitempty"` + Scope string `json:"scope,omitempty"` +} + +// CompetenceMap is one model's results, grouped by the engine. +type CompetenceMap struct { + // Model is "" when nothing has been proven by any model. + Model string `json:"model"` + Models []CompetenceModel `json:"models"` + Scope string `json:"scope"` + Filters []CompetenceFilter `json:"filters"` + // Groups is always the four classes, strong, frontier, weak, stale. + Groups []CompetenceGroup `json:"groups"` + Totals CompetenceTotals `json:"totals"` + Policy CompetencePolicy `json:"policy"` + ReadAt time.Time `json:"read_at"` + // Truncated says the ledger was longer than one read takes and its oldest + // history was not read. Omitted is how many scopes, filters and models this + // answer left out for size; Totals still count every scope that matched. + Truncated bool `json:"truncated"` + Omitted CompetenceOmitted `json:"omitted"` +} + +// CompetenceOmitted is what a map left out for size. A map carries at most +// 300 scopes, 200 filters and 50 models, newest evidence first. +type CompetenceOmitted struct { + Scopes int `json:"scopes"` + Filters int `json:"filters"` + Models int `json:"models"` +} + +// CompetenceModel is one model with at least one result. +type CompetenceModel struct { + ID string `json:"id"` + Results int `json:"results"` + LastAt time.Time `json:"last_at"` +} + +// CompetenceFilter is one entry of the Scope menu: a place, or a kind of work. +type CompetenceFilter struct { + ID string `json:"id"` + Kind string `json:"kind" enum:"place,work"` + Path string `json:"path,omitempty"` + PlaceKind string `json:"place_kind,omitempty" enum:"repo,folder"` + Removed bool `json:"removed,omitempty"` + Words string `json:"words,omitempty"` +} + +// CompetenceGroup is one class's scopes, newest evidence first. +type CompetenceGroup struct { + Class string `json:"class" enum:"strong,frontier,weak,stale"` + Scopes []CompetenceScope `json:"scopes"` +} + +// CompetenceScope is one place at one kind of work. Since is the start of the +// counted window, and absent on a stale scope, whose counts are all it has. +type CompetenceScope struct { + ID string `json:"id"` + Place CompetencePlace `json:"place"` + Work CompetenceWork `json:"work"` + Class string `json:"class" enum:"strong,frontier,weak,stale"` + Results int `json:"results"` + Passed int `json:"passed"` + Failed int `json:"failed"` + Undone int `json:"undone"` + LastAt time.Time `json:"last_at"` + Since *time.Time `json:"since,omitempty"` +} + +// CompetencePlace is a folder results were earned in; Removed is a folder no +// codeaf conversation works in any more. +type CompetencePlace struct { + ID string `json:"id"` + Path string `json:"path"` + PlaceKind string `json:"place_kind" enum:"repo,folder"` + Removed bool `json:"removed"` +} + +// CompetenceWork is the engine's kind of work, pooled. +type CompetenceWork struct { + ID string `json:"id"` + Words string `json:"words"` +} + +// CompetenceTotals is what the groups shown hold. +type CompetenceTotals struct { + Scopes int `json:"scopes"` + Results int `json:"results"` +} + +// CompetencePolicy is the engine's grouping, echoed so the app never has its +// own copy of a threshold. +type CompetencePolicy struct { + StaleAfterDays int `json:"stale_after_days"` + StrongAtLeast float64 `json:"strong_at_least"` + WeakAtMost float64 `json:"weak_at_most"` + MinResults int `json:"min_results"` +} + +// CompetenceEvidenceParams is competence.evidence's params. Limit is 7 when +// absent and at most 100. +type CompetenceEvidenceParams struct { + Model string `json:"model"` + Scope string `json:"scope"` + Outcome string `json:"outcome,omitempty" enum:"passed,failed,undone"` + Cursor string `json:"cursor,omitempty"` + Limit int `json:"limit,omitempty"` +} + +// CompetenceEvidence is one page of a scope's results, newest first. +type CompetenceEvidence struct { + Model string `json:"model"` + Scope string `json:"scope"` + Counts CompetenceCounts `json:"counts"` + Items []CompetenceItem `json:"items"` + NextCursor string `json:"next_cursor,omitempty"` +} + +// CompetenceCounts is how the scope's results came out, over the same window +// the scope row counts. +type CompetenceCounts struct { + All int `json:"all"` + Passed int `json:"passed"` + Failed int `json:"failed"` + Undone int `json:"undone"` +} + +// CompetenceItem is one result. SourceMissing names what was deleted — the +// task or the whole conversation — and the result still counts. +type CompetenceItem struct { + ID string `json:"id"` + Title string `json:"title"` + At time.Time `json:"at"` + Outcome string `json:"outcome" enum:"passed,failed,undone"` + Source CompetenceSource `json:"source"` + SourceMissing string `json:"source_missing" enum:",task,conversation"` + Check *CompetenceCheck `json:"check,omitempty"` + Checks []CompetenceCheck `json:"checks"` + UndoneAt *time.Time `json:"undone_at,omitempty"` +} + +// CompetenceSource is where a result came from. ID is the task's wire id for a +// task, and the agent's for a conversation; Title is the owning conversation's +// title as it stands now. +type CompetenceSource struct { + Kind string `json:"kind" enum:"task,conversation"` + ID string `json:"id"` + Agent string `json:"agent"` + Turn int `json:"turn,omitempty"` + Title string `json:"title,omitempty"` +} + +// CompetenceCheck is one declared check and how its exit came out. +type CompetenceCheck struct { + Command string `json:"command"` + Outcome string `json:"outcome" enum:"passed,failed"` +} + +func init() { + methods["competence.map"] = typed(func(e Engine, p CompetenceMapParams) (*CompetenceMap, error) { + c, err := competencer(e, "competence.map") + if err != nil { + return nil, err + } + r, err := c.CompetenceMap(p) + return &r, err + }) + methods["competence.evidence"] = typed(func(e Engine, p CompetenceEvidenceParams) (*CompetenceEvidence, error) { + c, err := competencer(e, "competence.evidence") + if err != nil { + return nil, err + } + if _, err := required("model", p.Model); err != nil { + return nil, err + } + if _, err := required("scope", p.Scope); err != nil { + return nil, err + } + switch p.Outcome { + case "", "passed", "failed", "undone": + default: + return nil, Errorf(CodeBadParams, "outcome is passed, failed or undone, not %q", p.Outcome) + } + r, err := c.CompetenceEvidence(p) + return &r, err + }) +} + +func competencer(e Engine, name string) (Competencer, error) { + c, ok := e.(Competencer) + if !ok { + return nil, Errorf(CodeUnknownMethod, "this engine does not serve %s", name) + } + return c, nil +} diff --git a/internal/competence/ledger.go b/internal/competence/ledger.go new file mode 100644 index 0000000000..e0b7a6cefe --- /dev/null +++ b/internal/competence/ledger.go @@ -0,0 +1,299 @@ +// Package competence keeps and reads what each model has PROVEN: the checked +// results of its work, and the undos a person made of it. +// +// ── THE LAW ── +// +// A CHECKED RESULT IS A CHECK THAT RAN. One finished piece of work counts only +// when the verification it declared — its tests, its lint, its build — was +// executed against it and a real exit status came back. A pass is every one of +// those exiting zero; a failure is one of them exiting otherwise. Nothing else +// is a result here: not a checker's verdict, not "done", not a model's account +// of its own work, not a calibration or a rating. The map this package draws +// cannot be argued with by a model, because no model's words reach it. +// +// AN UNDO COUNTS AGAINST. A person taking a change back is the plainest reading +// of it there is, and it is recorded beside the result it reverses rather than +// replacing it, so "passed, then undone" stays both of those facts. +// +// ── WHY A LEDGER OF ITS OWN ── +// +// Nothing written before this file existed carries model, scope and a real +// check outcome together: the project index's verdict basis was never filled, +// the checker's verdict is a model's judgement, and per-task check readings +// lived in memory or in free-text job logs. So history is NO EVIDENCE rather +// than evidence inferred, and the ledger starts empty. +// +// It is one append-only JSONL file under codeaf's home, beside the usage +// ledger, and it is NOT a projection of the task records: deleting a task or a +// whole conversation removes those and leaves this, because what a model proved +// on a Tuesday does not stop being true when the person tidies their list. A +// reader says the source is gone; it never drops the result. +package competence + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + "unicode/utf8" + + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/redact" +) + +// LedgerName is the file, under `/v3/`. +const LedgerName = "checked-results.jsonl" + +// LedgerPath is where the ledger lives for this process's home. +func LedgerPath() string { return home.Join("v3", LedgerName) } + +// The two kinds of row. +const ( + // KindResult is a piece of work's checks, as they ran. The latest result + // row for an id is the one that stands: a reopened task settles again. + KindResult = "result" + // KindUndo is a person undoing that work. It marks the result with the same + // id, or stands alone as evidence against when the work had no checks. + KindUndo = "undo" +) + +// Outcome is how one check, or one piece of work, came out. +type Outcome string + +const ( + Passed Outcome = "passed" + Failed Outcome = "failed" + Undone Outcome = "undone" +) + +// The sources a result can come from. +const ( + SourceTask = "task" + SourceConversation = "conversation" +) + +// The kinds of place work happens in. +const ( + PlaceRepo = "repo" + PlaceFolder = "folder" +) + +// The bounds one row is held to, so one row never becomes a transcript. +// +// AN IDENTITY THAT DOES NOT FIT IS REFUSED, NEVER CUT. Two long model ids or +// paths cut to the same prefix would be two things counted as one, so the +// fields that say WHOSE result this is and WHERE are refused whole when they +// are too long. Only the words a person reads — a title, a command — are cut. +const ( + // MaxChecks is how many checks one row lists. The row's outcome is decided + // over every check that ran BEFORE the list is cut, and the check that + // decided a failure is always one of those kept. + MaxChecks = 8 + maxID = 256 + maxModel = 256 + maxWork = 256 + maxAgent = 128 + maxTask = 64 + maxPath = 4096 + maxTitle = 240 + maxCommand = 400 + // recordVersion is the row format, written on every row. + recordVersion = 1 +) + +// CheckRun is one declared check, run, and what its exit said. +type CheckRun struct { + Command string `json:"command"` + Outcome Outcome `json:"outcome"` +} + +// Source is where a result came from: a task in a conversation, or (reserved) +// a conversation's own turn. Transcript is the owning conversation's journal +// path, kept so a reader can tell later whether the source is gone; it is +// never shown. +type Source struct { + Kind string `json:"kind"` + Agent string `json:"agent"` + Task string `json:"task,omitempty"` + Turn int `json:"turn,omitempty"` + Transcript string `json:"transcript,omitempty"` +} + +// Record is one row of the ledger. +type Record struct { + V int `json:"v"` + Kind string `json:"kind"` + ID string `json:"id"` + At time.Time `json:"at"` + Model string `json:"model"` + Place string `json:"place"` + // PlaceKind is "repo" or "folder", read off the disk when the row was written. + PlaceKind string `json:"place_kind"` + // Work is the kind of work ([WorkOf] of the title). + Work string `json:"work"` + Title string `json:"title"` + Source Source `json:"source"` + // Outcome is the result's whole outcome, decided over every check that + // ran: failed when any did, passed when all did. Ran is how many distinct + // checks that was; Checks lists at most [MaxChecks] of them. + Outcome Outcome `json:"outcome,omitempty"` + Ran int `json:"ran,omitempty"` + Checks []CheckRun `json:"checks,omitempty"` +} + +// TaskID is the ledger id of one task in one conversation. Ids restart with +// every conversation, so the conversation is part of the identity. +func TaskID(agent, task string) string { return "task:" + agent + ":" + task } + +// ErrIncomplete is a row that names no model, place or work. Such a row is +// refused at the door rather than written for a reader to guess about. +var ErrIncomplete = errors.New("a checked result names its model, its place and its work") + +// Append writes one row. It is one write of one whole line with O_APPEND, so +// two processes writing at once interleave rows and never bytes. +func Append(path string, record Record) error { + record, err := normalized(record) + if err != nil { + return err + } + line, err := json.Marshal(record) + if err != nil { + return err + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return err + } + file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + return err + } + if _, err := file.Write(append(line, '\n')); err != nil { + _ = file.Close() + return err + } + return file.Close() +} + +// normalized is a row held to the ledger's own rules: complete, bounded, +// unambiguous, with no check that is not a pass or a failure and no secret in +// a command. +func normalized(record Record) (Record, error) { + record.V = recordVersion + record.ID = strings.TrimSpace(record.ID) + record.Model = strings.TrimSpace(record.Model) + record.Place = strings.TrimSpace(record.Place) + record.Work = strings.TrimSpace(record.Work) + record.Title = clipString(redact.Secrets(strings.TrimSpace(record.Title)), maxTitle) + record.Source.Agent = strings.TrimSpace(record.Source.Agent) + record.Source.Task = strings.TrimSpace(record.Source.Task) + record.Source.Transcript = strings.TrimSpace(record.Source.Transcript) + if record.Model == "" || record.Place == "" || record.Work == "" || record.ID == "" || + record.Source.Agent == "" || record.At.IsZero() { + return Record{}, ErrIncomplete + } + for _, field := range []struct { + name, value string + limit int + }{ + {"id", record.ID, maxID}, {"model", record.Model, maxModel}, {"work", record.Work, maxWork}, + {"agent", record.Source.Agent, maxAgent}, {"task", record.Source.Task, maxTask}, + {"place", record.Place, maxPath}, {"transcript", record.Source.Transcript, maxPath}, + } { + if len(field.value) > field.limit || strings.ContainsAny(field.value, "\x00\n\r") { + return Record{}, fmt.Errorf("a checked result's %s is not one bounded line", field.name) + } + } + if !filepath.IsAbs(record.Place) { + return Record{}, fmt.Errorf("a checked result's place is an absolute path, not %q", record.Place) + } + record.Place = filepath.Clean(record.Place) + switch record.Source.Kind { + case SourceTask, SourceConversation: + default: + return Record{}, fmt.Errorf("a checked result's source is a task or a conversation, not %q", record.Source.Kind) + } + if record.PlaceKind != PlaceRepo { + record.PlaceKind = PlaceFolder + } + record.At = record.At.UTC() + switch record.Kind { + case KindResult: + checks := lastRunOfEach(record.Checks) + // A RESULT WITH NO CHECK IS NOT A CHECKED RESULT, and it is refused here + // so no reader ever has to decide what one would mean. + if len(checks) == 0 { + return Record{}, errors.New("a checked result has at least one check that ran") + } + record.Outcome, record.Ran = Passed, len(checks) + for _, check := range checks { + if check.Outcome == Failed { + record.Outcome = Failed + break + } + } + record.Checks = shownChecks(checks) + case KindUndo: + record.Outcome, record.Ran, record.Checks = "", 0, nil + default: + return Record{}, fmt.Errorf("a ledger row is a result or an undo, not %q", record.Kind) + } + return record, nil +} + +// lastRunOfEach is every distinct check, in the order each was first run, with +// the outcome of its LAST run: a check run again is a later fact about it. +// Commands are redacted before they are compared, so a secret never reaches +// the file and two runs differing only in a secret are one check. +func lastRunOfEach(runs []CheckRun) []CheckRun { + out := make([]CheckRun, 0, len(runs)) + at := make(map[string]int, len(runs)) + for _, run := range runs { + command := clipString(redact.Secrets(strings.Join(strings.Fields(run.Command), " ")), maxCommand) + if command == "" || run.Outcome != Passed && run.Outcome != Failed { + continue + } + if index, seen := at[command]; seen { + out[index].Outcome = run.Outcome + continue + } + at[command] = len(out) + out = append(out, CheckRun{Command: command, Outcome: run.Outcome}) + } + return out +} + +// shownChecks is at most [MaxChecks] of the checks, in the order they ran, and +// ALWAYS the first that failed when one did: the list is cut for size, never +// for news. A decider past the cut takes the last place, which keeps the order +// because it ran after every check before it. +func shownChecks(checks []CheckRun) []CheckRun { + if len(checks) <= MaxChecks { + return checks + } + shown := append([]CheckRun(nil), checks[:MaxChecks]...) + for index, check := range checks { + if check.Outcome != Failed { + continue + } + if index >= MaxChecks { + shown[MaxChecks-1] = check + } + break + } + return shown +} + +// clipString cuts s to at most limit bytes on a rune boundary. +func clipString(s string, limit int) string { + if len(s) <= limit { + return s + } + cut := limit + for cut > 0 && !utf8.RuneStart(s[cut]) { + cut-- + } + return s[:cut] +} diff --git a/internal/competence/policy.go b/internal/competence/policy.go new file mode 100644 index 0000000000..af759d6b50 --- /dev/null +++ b/internal/competence/policy.go @@ -0,0 +1,74 @@ +package competence + +import ( + "errors" + "math" + "time" +) + +// Class is where one scope stands for one model. +type Class string + +const ( + Strong Class = "strong" + Frontier Class = "frontier" + Weak Class = "weak" + Stale Class = "stale" +) + +// Classes is every class, in the order a map draws them. +var Classes = []Class{Strong, Frontier, Weak, Stale} + +// Policy is every boundary the grouping reads. THE THRESHOLDS ARE READ OFF +// CHECKS ALONE: the only inputs are how many results a scope has, how many of +// them passed, and when the last one was. +type Policy struct { + // StaleAfter is how old a scope's newest result may be before the scope is + // stale. Inside it, only results inside it are counted. + StaleAfter time.Duration + // StrongAtLeast and WeakAtMost are the pass-rate bounds, passed over + // results, where an undone result is not a pass. + StrongAtLeast float64 + WeakAtMost float64 + // MinResults is how many results a scope needs before it may be called + // strong or weak; fewer is frontier, because a scope with two results has + // not said enough to be either. + MinResults int +} + +// DefaultPolicy is the engine's grouping: thirty days, four in five, two in five, +// three results. +func DefaultPolicy() Policy { + return Policy{ + StaleAfter: 30 * 24 * time.Hour, + StrongAtLeast: 0.8, + WeakAtMost: 0.4, + MinResults: 3, + } +} + +func (p Policy) validate() error { + rate := func(v float64) bool { return v >= 0 && v <= 1 && !math.IsNaN(v) } + if p.StaleAfter <= 0 || p.MinResults <= 0 || !rate(p.StrongAtLeast) || !rate(p.WeakAtMost) || + p.WeakAtMost >= p.StrongAtLeast { + return errors.New("a competence policy needs a positive staleness and result floor, and weak below strong in [0,1]") + } + return nil +} + +// classify is one scope's class from its counted results. Stale is decided by +// the caller, which is the one that knows whether anything was recent. +func (p Policy) classify(results, passed int) Class { + if results < p.MinResults { + return Frontier + } + rate := float64(passed) / float64(results) + switch { + case rate >= p.StrongAtLeast: + return Strong + case rate <= p.WeakAtMost: + return Weak + default: + return Frontier + } +} diff --git a/internal/competence/read.go b/internal/competence/read.go new file mode 100644 index 0000000000..c0f8c94227 --- /dev/null +++ b/internal/competence/read.go @@ -0,0 +1,759 @@ +package competence + +import ( + "bufio" + "bytes" + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "sort" + "strconv" + "strings" + "time" +) + +// THE READ IS BOUNDED AND IT IS ONE READ. +// +// Every question — the map, a page of evidence — reads the ledger's newest +// [DefaultMaxBytes] once, folds it, and answers. Nothing is cached between +// calls and nothing polls: a surface asks when it opens and when the person +// asks again. A ledger larger than the bound is read from its end, so the +// newest history is what is kept, and the answer says it was cut. + +// DefaultMaxBytes is how much of the ledger one read takes. A row is a few +// hundred bytes, so this is on the order of a hundred thousand results. +const DefaultMaxBytes int64 = 32 << 20 + +// The evidence page bounds. +const ( + DefaultEvidenceLimit = 7 + MaxEvidenceLimit = 100 +) + +// ErrUnknownScope is a filter or scope id the map does not hold for this model. +var ErrUnknownScope = errors.New("no such scope for this model") + +// ErrBadCursor is a cursor that is not this package's, or not for this +// model and scope. +var ErrBadCursor = errors.New("the cursor is not one this list gave out") + +// Reader answers questions about one ledger. +type Reader struct { + // Path is the ledger; empty is [LedgerPath]. + Path string + // Policy is the grouping; the zero value is [DefaultPolicy]. + Policy Policy + // Now is the clock; nil is time.Now. + Now func() time.Time + // MaxBytes bounds the read; zero is [DefaultMaxBytes]. + MaxBytes int64 + // PlaceAdded says whether codeaf still works in a folder. Nil answers yes + // for every folder, so nothing is called removed that nobody checked. + PlaceAdded func(path string) bool + // SourceGone says what of a result's source was deleted: "task", + // "conversation", or "" for nothing or unknown. Nil is always "". + SourceGone func(Source) string + // SourceTitle is the owning conversation's title as it stands now, read + // for the page being answered and never stored: a title is renamed, a + // result is not. Nil, or "", is no title. + SourceTitle func(Source) string +} + +// Place is a folder results were earned in. +type Place struct { + ID string `json:"id"` + Path string `json:"path"` + Kind string `json:"place_kind"` + Removed bool `json:"removed"` +} + +// Work is one kind of work, pooled. +type Work struct { + ID string `json:"id"` + Words string `json:"words"` +} + +// Scope is one model's results in one place at one kind of work. +type Scope struct { + ID string `json:"id"` + Place Place `json:"place"` + Work Work `json:"work"` + Class Class `json:"class"` + Results int `json:"results"` + Passed int `json:"passed"` + Failed int `json:"failed"` + Undone int `json:"undone"` + LastAt time.Time `json:"last_at"` + // Since is the start of the counted window, and zero on a stale scope, + // whose counts are everything it has. + Since time.Time `json:"since,omitzero"` +} + +// ModelSummary is one model that has results. +type ModelSummary struct { + ID string `json:"id"` + Results int `json:"results"` + LastAt time.Time `json:"last_at"` +} + +// Filter is one entry of the scope menu: a place or a kind of work. +type Filter struct { + ID string `json:"id"` + Kind string `json:"kind"` + Path string `json:"path,omitempty"` + PlaceKind string `json:"place_kind,omitempty"` + Removed bool `json:"removed,omitempty"` + Words string `json:"words,omitempty"` +} + +// The filter kinds. +const ( + FilterPlace = "place" + FilterWork = "work" +) + +// Group is one class's scopes. +type Group struct { + Class Class `json:"class"` + Scopes []Scope `json:"scopes"` +} + +// Totals is what the groups shown hold. +type Totals struct { + Scopes int `json:"scopes"` + Results int `json:"results"` +} + +// PolicyView is the policy as a reader is told it. +type PolicyView struct { + StaleAfterDays int `json:"stale_after_days"` + StrongAtLeast float64 `json:"strong_at_least"` + WeakAtMost float64 `json:"weak_at_most"` + MinResults int `json:"min_results"` +} + +// Map is one model's competence, grouped. +type Map struct { + Model string `json:"model"` + Models []ModelSummary `json:"models"` + Scope string `json:"scope"` + Filters []Filter `json:"filters"` + Groups []Group `json:"groups"` + Totals Totals `json:"totals"` + Policy PolicyView `json:"policy"` + ReadAt time.Time `json:"read_at"` + // Truncated says the ledger was longer than one read takes, so the oldest + // history was not read; Omitted says what this answer left out for size. + Truncated bool `json:"truncated"` + Omitted Omitted `json:"omitted"` +} + +// Counts is how a list of evidence came out. +type Counts struct { + All int `json:"all"` + Passed int `json:"passed"` + Failed int `json:"failed"` + Undone int `json:"undone"` +} + +// Evidence is one result as the drawer shows it. +type Evidence struct { + ID string `json:"id"` + Title string `json:"title"` + At time.Time `json:"at"` + Outcome Outcome `json:"outcome"` + Source Source `json:"source"` + SourceMissing string `json:"source_missing"` + // SourceTitle is the owning conversation's current title, when it has one. + SourceTitle string `json:"source_title,omitempty"` + Check *CheckRun `json:"check,omitempty"` + Checks []CheckRun `json:"checks"` + UndoneAt time.Time `json:"undone_at,omitzero"` +} + +// EvidencePage is one page of a scope's evidence. +type EvidencePage struct { + Model string `json:"model"` + Scope string `json:"scope"` + Counts Counts `json:"counts"` + Items []Evidence `json:"items"` + NextCursor string `json:"next_cursor,omitempty"` +} + +// item is one result folded out of the ledger. +type item struct { + id string + model string + place string + placeKind string + work string + title string + source Source + at time.Time + // result is the whole outcome the row recorded, decided over every check + // that ran — never re-derived from the bounded list beside it. + result Outcome + checks []CheckRun + undoneAt time.Time +} + +func (i *item) outcome() Outcome { + if !i.undoneAt.IsZero() { + return Undone + } + return i.result +} + +// folded is one read of the ledger. +type folded struct { + byModel map[string][]*item + truncated bool + now time.Time + policy Policy +} + +func (r Reader) read(ctx context.Context) (*folded, error) { + policy := r.Policy + if policy == (Policy{}) { + policy = DefaultPolicy() + } + if err := policy.validate(); err != nil { + return nil, err + } + now := time.Now + if r.Now != nil { + now = r.Now + } + path := r.Path + if path == "" { + path = LedgerPath() + } + limit := r.MaxBytes + if limit <= 0 { + limit = DefaultMaxBytes + } + out := &folded{byModel: map[string][]*item{}, now: now().UTC(), policy: policy} + // ONE PASS, ROW BY ROW. Each row is folded into its result as it is read, + // so what is held is one entry per result and never the file's rows. + results := map[string]*item{} + undos := map[string]time.Time{} + truncated, err := eachRow(ctx, path, limit, func(record Record) { + key := record.Model + "\x00" + record.ID + switch record.Kind { + case KindResult: + // THE LATEST RESULT STANDS. A task reopened and settled again is a + // later fact about the same work. + if it := results[key]; it != nil && record.At.Before(it.at) { + return + } + it := &item{ + id: record.ID, model: record.Model, place: record.Place, placeKind: record.PlaceKind, + work: record.Work, title: record.Title, source: record.Source, at: record.At, + result: record.Outcome, checks: record.Checks, + } + if it.result != Passed && it.result != Failed { + it.result = Passed + for _, check := range record.Checks { + if check.Outcome == Failed { + it.result = Failed + } + } + } + results[key] = it + case KindUndo: + if record.At.After(undos[key]) { + undos[key] = record.At + } + } + }) + if err != nil { + return nil, err + } + out.truncated = truncated + // AN UNDO MARKS A RESULT; IT IS NEVER ONE. Work that finished with no check + // never counts, and taking it back does not make it count: an undo with no + // checked result beside it is dropped. An undo older than the result + // standing is of earlier work, done again since. + seen := 0 + for key, it := range results { + if seen++; seen%4096 == 0 && ctx.Err() != nil { + return nil, ctx.Err() + } + if at, undone := undos[key]; undone && !at.Before(it.at) { + it.undoneAt = at + } + out.byModel[it.model] = append(out.byModel[it.model], it) + } + return out, nil +} + +// maxRowBytes is the longest line read as a row. The writer's own bounds keep +// every row far under it; a longer line is somebody else's bytes and skipped. +const maxRowBytes = 64 << 10 + +// eachRow hands every well-formed row in the newest limit bytes of the ledger +// to fold, oldest first, and reports whether older bytes were left unread. A +// line cut by the window's start, an over-long line and any line that is not a +// row are skipped: a bad line costs one result, never the read. +func eachRow(ctx context.Context, path string, limit int64, fold func(Record)) (bool, error) { + file, err := os.Open(path) + if errors.Is(err, os.ErrNotExist) { + return false, nil + } + if err != nil { + return false, err + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return false, err + } + truncated := false + if size := info.Size(); size > limit { + truncated = true + if _, err := file.Seek(size-limit, io.SeekStart); err != nil { + return false, err + } + } + reader := bufio.NewReaderSize(io.LimitReader(file, limit), 64<<10) + var line []byte + skip := truncated + for count := 0; ; count++ { + if count%1024 == 0 && ctx.Err() != nil { + return false, ctx.Err() + } + chunk, prefix, err := reader.ReadLine() + if err == io.EOF { + return truncated, nil + } + if err != nil { + return false, err + } + if len(line)+len(chunk) <= maxRowBytes { + line = append(line, chunk...) + } else { + skip = true + } + if prefix { + continue + } + whole := bytes.TrimSpace(line) + line = line[:0] + if skip { + // The window began mid-line, or the line was past any row's size. + skip = false + continue + } + var record Record + if len(whole) == 0 || json.Unmarshal(whole, &record) != nil { + continue + } + if record.Model == "" || record.ID == "" || record.Place == "" || record.Work == "" { + continue + } + if record.Kind == KindResult && len(record.Checks) == 0 { + continue + } + fold(record) + } +} + +// shortID is a stable id for one string. +func shortID(prefix string, parts ...string) string { + sum := sha256.Sum256([]byte(strings.Join(parts, "\x00"))) + return prefix + ":" + hex.EncodeToString(sum[:8]) +} + +// scopeOf is one item's scope id and its parts' ids. +// +// A SCOPE IS A PLACE AND THE ENGINE'S EXACT KIND OF WORK ([WorkOf]), never a +// pooled one. Pooling by overlap would name a scope after whichever kind +// arrived first, so the scope's id would move the day that result left the +// read window; an exact key is the same id for as long as the work exists, and +// grouping by it is one pass. +func scopeOf(it *item) (scope, place, work string) { + return shortID("scope", it.place, it.work), shortID("place", it.place), shortID("work", it.work) +} + +// scopeAcc gathers one scope's items. +type scopeAcc struct { + scope Scope + items []*item +} + +// scopes builds every scope of one model, each with its class and the items +// it counts, newest first. +// +// IT STOPS WHEN ITS CALLER DOES. The deadline a read was given is asked as the +// groups are built, not only while the file is scanned, so a timed-out read +// leaves no work running behind the answer. +func (f *folded) scopes(ctx context.Context, model string) ([]*scopeAcc, error) { + byKey := map[string]*scopeAcc{} + var order []string + for index, it := range f.byModel[model] { + if index%4096 == 0 && ctx.Err() != nil { + return nil, ctx.Err() + } + key := it.place + "\x00" + it.work + acc := byKey[key] + if acc == nil { + id, placeID, workID := scopeOf(it) + acc = &scopeAcc{scope: Scope{ + ID: id, + Place: Place{ID: placeID, Path: it.place, Kind: it.placeKind}, + Work: Work{ID: workID, Words: it.work}, + }} + byKey[key] = acc + order = append(order, key) + } + acc.items = append(acc.items, it) + if it.at.After(acc.scope.LastAt) { + acc.scope.LastAt = it.at + } + } + since := f.now.Add(-f.policy.StaleAfter) + out := make([]*scopeAcc, 0, len(order)) + for index, key := range order { + if index%1024 == 0 && ctx.Err() != nil { + return nil, ctx.Err() + } + acc := byKey[key] + sort.Slice(acc.items, func(a, b int) bool { return newer(acc.items[a], acc.items[b]) }) + if acc.scope.LastAt.Before(since) { + acc.scope.Class = Stale + } else { + acc.scope.Since = since + kept := acc.items[:0] + for _, it := range acc.items { + if !it.at.Before(since) { + kept = append(kept, it) + } + } + acc.items = kept + } + for _, it := range acc.items { + acc.scope.Results++ + switch it.outcome() { + case Passed: + acc.scope.Passed++ + case Failed: + acc.scope.Failed++ + case Undone: + acc.scope.Undone++ + } + } + if acc.scope.Class != Stale { + acc.scope.Class = f.policy.classify(acc.scope.Results, acc.scope.Passed) + } + out = append(out, acc) + } + sort.Slice(out, func(a, b int) bool { + if !out[a].scope.LastAt.Equal(out[b].scope.LastAt) { + return out[a].scope.LastAt.After(out[b].scope.LastAt) + } + return out[a].scope.ID < out[b].scope.ID + }) + return out, ctx.Err() +} + +func newer(a, b *item) bool { + if !a.at.Equal(b.at) { + return a.at.After(b.at) + } + return a.id > b.id +} + +// The answer bounds. A map is drawn whole, so what it may carry is bounded +// as surely as what is read; past a bound the newest are kept, and Omitted +// says how many were left out so an absence is never mistaken for none. +const ( + MaxMapScopes = 300 + MaxMapFilters = 200 + MaxMapModels = 50 +) + +// Omitted is how many of each list a map left out for size. Totals still count +// everything that matched. +type Omitted struct { + Scopes int `json:"scopes"` + Filters int `json:"filters"` + Models int `json:"models"` +} + +// Map answers one model's map, filtered to one scope id, place id or work id +// when scope is not empty. With no model it answers the model whose evidence +// is newest, and with no evidence at all it answers four empty groups. +func (r Reader) Map(ctx context.Context, model, scope string) (Map, error) { + f, err := r.read(ctx) + if err != nil { + return Map{}, err + } + out := Map{ + Scope: strings.TrimSpace(scope), + Models: f.models(), + Filters: []Filter{}, + ReadAt: f.now, + Truncated: f.truncated, + Policy: PolicyView{ + StaleAfterDays: int(f.policy.StaleAfter / (24 * time.Hour)), + StrongAtLeast: f.policy.StrongAtLeast, + WeakAtMost: f.policy.WeakAtMost, + MinResults: f.policy.MinResults, + }, + } + out.Model = strings.TrimSpace(model) + if out.Model == "" && len(out.Models) > 0 { + out.Model = out.Models[0].ID + } + if len(out.Models) > MaxMapModels { + out.Omitted.Models = len(out.Models) - MaxMapModels + out.Models = out.Models[:MaxMapModels] + } + // WHETHER A PLACE IS STILL WORKED IN is asked once per place, and only of + // the places this answer names. + removed := map[string]bool{} + isRemoved := func(path string) bool { + gone, asked := removed[path] + if !asked { + gone = r.PlaceAdded != nil && !r.PlaceAdded(path) + removed[path] = gone + } + return gone + } + groups := map[Class][]Scope{} + seen := map[string]bool{} + known := out.Scope == "" + shown := 0 + all, err := f.scopes(ctx, out.Model) + if err != nil { + return Map{}, err + } + for index, acc := range all { + if index%1024 == 0 && ctx.Err() != nil { + return Map{}, ctx.Err() + } + place, work := acc.scope.Place, acc.scope.Work + for _, filter := range []Filter{ + {ID: place.ID, Kind: FilterPlace, Path: place.Path, PlaceKind: place.Kind}, + {ID: work.ID, Kind: FilterWork, Words: work.Words}, + } { + if seen[filter.ID] { + continue + } + seen[filter.ID] = true + if len(out.Filters) == MaxMapFilters { + out.Omitted.Filters++ + continue + } + if filter.Kind == FilterPlace { + filter.Removed = isRemoved(filter.Path) + } + out.Filters = append(out.Filters, filter) + } + switch out.Scope { + case "", acc.scope.ID, place.ID, work.ID: + known = true + default: + continue + } + out.Totals.Scopes++ + out.Totals.Results += acc.scope.Results + if shown == MaxMapScopes { + out.Omitted.Scopes++ + continue + } + shown++ + acc.scope.Place.Removed = isRemoved(place.Path) + groups[acc.scope.Class] = append(groups[acc.scope.Class], acc.scope) + } + if !known { + return Map{}, ErrUnknownScope + } + for _, class := range Classes { + scopes := groups[class] + if scopes == nil { + scopes = []Scope{} + } + out.Groups = append(out.Groups, Group{Class: class, Scopes: scopes}) + } + return out, nil +} + +// models is every model with a result, newest evidence first. +func (f *folded) models() []ModelSummary { + out := make([]ModelSummary, 0, len(f.byModel)) + for model, list := range f.byModel { + summary := ModelSummary{ID: model, Results: len(list)} + for _, it := range list { + if it.at.After(summary.LastAt) { + summary.LastAt = it.at + } + } + out = append(out, summary) + } + sort.Slice(out, func(a, b int) bool { + if !out[a].LastAt.Equal(out[b].LastAt) { + return out[a].LastAt.After(out[b].LastAt) + } + return out[a].ID < out[b].ID + }) + return out +} + +// Evidence answers one page of one scope's results, newest first, filtered to +// one outcome when outcome is not empty. +func (r Reader) Evidence(ctx context.Context, model, scope string, outcome Outcome, cursor string, limit int) (EvidencePage, error) { + model, scope = strings.TrimSpace(model), strings.TrimSpace(scope) + switch outcome { + case "", Passed, Failed, Undone: + default: + return EvidencePage{}, fmt.Errorf("an outcome is passed, failed or undone, not %q", outcome) + } + if limit <= 0 { + limit = DefaultEvidenceLimit + } + if limit > MaxEvidenceLimit { + limit = MaxEvidenceLimit + } + var after *cursorAt + if cursor != "" { + decoded, err := decodeCursor(cursor, model, scope) + if err != nil { + return EvidencePage{}, err + } + after = &decoded + } + f, err := r.read(ctx) + if err != nil { + return EvidencePage{}, err + } + var found *scopeAcc + all, err := f.scopes(ctx, model) + if err != nil { + return EvidencePage{}, err + } + for _, acc := range all { + if acc.scope.ID == scope { + found = acc + break + } + } + if found == nil { + return EvidencePage{}, ErrUnknownScope + } + page := EvidencePage{Model: model, Scope: scope, Items: []Evidence{}} + // gone remembers what was asked about each source on this page, so a page + // of results from one conversation stats it once. + gone := map[string]string{} + for index, it := range found.items { + if index%1024 == 0 && ctx.Err() != nil { + return EvidencePage{}, ctx.Err() + } + got := it.outcome() + page.Counts.All++ + switch got { + case Passed: + page.Counts.Passed++ + case Failed: + page.Counts.Failed++ + case Undone: + page.Counts.Undone++ + } + if outcome != "" && got != outcome { + continue + } + if after != nil && !after.before(it) { + continue + } + if len(page.Items) == limit { + if page.NextCursor == "" { + last := page.Items[len(page.Items)-1] + page.NextCursor = encodeCursor(model, scope, last.At, last.ID) + } + continue + } + page.Items = append(page.Items, r.evidence(it, got, gone)) + } + return page, nil +} + +func (r Reader) evidence(it *item, got Outcome, gone map[string]string) Evidence { + out := Evidence{ + ID: it.id, Title: it.title, At: it.at, Outcome: got, + Source: Source{Kind: it.source.Kind, Agent: it.source.Agent, Task: it.source.Task, Turn: it.source.Turn}, + Checks: append([]CheckRun{}, it.checks...), + UndoneAt: it.undoneAt, + } + if r.SourceGone != nil { + key := it.source.Transcript + "\x00" + it.source.Task + missing, asked := gone[key] + if !asked { + missing = r.SourceGone(it.source) + gone[key] = missing + } + out.SourceMissing = missing + } + if r.SourceTitle != nil && out.SourceMissing != SourceConversation { + key := "title\x00" + it.source.Transcript + title, asked := gone[key] + if !asked { + title = r.SourceTitle(it.source) + gone[key] = title + } + out.SourceTitle = title + } + // THE DECIDING CHECK is the first that failed, or else the last that ran. + for index := range it.checks { + if it.checks[index].Outcome == Failed { + check := it.checks[index] + out.Check = &check + break + } + } + if out.Check == nil && len(it.checks) > 0 { + check := it.checks[len(it.checks)-1] + out.Check = &check + } + return out +} + +// cursorAt is where a page ended. +type cursorAt struct { + at time.Time + id string +} + +// before reports whether it comes after the cursor in newest-first order. +func (c cursorAt) before(it *item) bool { + if !it.at.Equal(c.at) { + return it.at.Before(c.at) + } + return it.id < c.id +} + +func encodeCursor(model, scope string, at time.Time, id string) string { + raw := strings.Join([]string{"1", model, scope, strconv.FormatInt(at.UnixNano(), 10), id}, "\x00") + return base64.RawURLEncoding.EncodeToString([]byte(raw)) +} + +func decodeCursor(cursor, model, scope string) (cursorAt, error) { + raw, err := base64.RawURLEncoding.DecodeString(cursor) + if err != nil { + return cursorAt{}, ErrBadCursor + } + parts := strings.SplitN(string(raw), "\x00", 5) + if len(parts) != 5 || parts[0] != "1" || parts[1] != model || parts[2] != scope { + return cursorAt{}, ErrBadCursor + } + nanos, err := strconv.ParseInt(parts[3], 10, 64) + if err != nil { + return cursorAt{}, ErrBadCursor + } + return cursorAt{at: time.Unix(0, nanos).UTC(), id: parts[4]}, nil +} diff --git a/internal/competence/read_test.go b/internal/competence/read_test.go new file mode 100644 index 0000000000..0bc2ecb71f --- /dev/null +++ b/internal/competence/read_test.go @@ -0,0 +1,645 @@ +package competence + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +var testNow = time.Date(2026, 10, 15, 14, 2, 0, 0, time.UTC) + +func result(t *testing.T, path, model, place, title, task string, ago time.Duration, outcomes ...Outcome) { + t.Helper() + checks := make([]CheckRun, 0, len(outcomes)) + for index, outcome := range outcomes { + checks = append(checks, CheckRun{Command: fmt.Sprintf("go test ./%d/...", index), Outcome: outcome}) + } + err := Append(path, Record{ + Kind: KindResult, ID: TaskID("conv1", task), At: testNow.Add(-ago), + Model: model, Place: place, PlaceKind: PlaceRepo, Work: WorkOf(title), Title: title, + Source: Source{Kind: SourceTask, Agent: "conv1", Task: task, Transcript: "/t/conv1/transcript.jsonl"}, + Checks: checks, + }) + if err != nil { + t.Fatal(err) + } +} + +func undo(t *testing.T, path, model, place, title, task string, ago time.Duration) { + t.Helper() + err := Append(path, Record{ + Kind: KindUndo, ID: TaskID("conv1", task), At: testNow.Add(-ago), + Model: model, Place: place, Work: WorkOf(title), Title: title, + Source: Source{Kind: SourceTask, Agent: "conv1", Task: task}, + }) + if err != nil { + t.Fatal(err) + } +} + +func reader(path string) Reader { + return Reader{Path: path, Now: func() time.Time { return testNow }} +} + +func scopeIn(t *testing.T, m Map, class Class, words string) Scope { + t.Helper() + for _, group := range m.Groups { + for _, scope := range group.Scopes { + if scope.Work.Words == words { + if group.Class != class || scope.Class != class { + t.Fatalf("%q is %s, want %s", words, scope.Class, class) + } + return scope + } + } + } + t.Fatalf("no scope %q in %+v", words, m.Groups) + return Scope{} +} + +// AN EMPTY LEDGER IS AN EMPTY MAP, said honestly: no model, four empty groups. +func TestAnEmptyLedgerIsFourEmptyGroupsAndNoModel(t *testing.T) { + m, err := reader(filepath.Join(t.TempDir(), "none.jsonl")).Map(context.Background(), "", "") + if err != nil { + t.Fatal(err) + } + if m.Model != "" || len(m.Models) != 0 || len(m.Groups) != 4 || m.Totals != (Totals{}) { + t.Fatalf("empty ledger answered %+v", m) + } + for index, class := range Classes { + if m.Groups[index].Class != class || m.Groups[index].Scopes == nil || len(m.Groups[index].Scopes) != 0 { + t.Fatalf("group %d is %+v", index, m.Groups[index]) + } + } +} + +// ONLY A CHECK THAT RAN IS A RESULT, and a row naming no model is refused +// rather than guessed about. +func TestTheLedgerRefusesUncheckedAndUnattributedResults(t *testing.T) { + path := filepath.Join(t.TempDir(), LedgerName) + base := Record{Kind: KindResult, ID: "task:c:1", At: testNow, Model: "m", Place: "/p", Work: "w", + Source: Source{Kind: SourceTask, Agent: "c"}} + if err := Append(path, base); err == nil { + t.Fatal("a result with no check was written") + } + unread := base + unread.Checks = []CheckRun{{Command: "go test", Outcome: "unread"}} + if err := Append(path, unread); err == nil { + t.Fatal("a result whose only check is neither a pass nor a failure was written") + } + anonymous := base + anonymous.Model = "" + anonymous.Checks = []CheckRun{{Command: "go test", Outcome: Passed}} + if !errors.Is(Append(path, anonymous), ErrIncomplete) { + t.Fatal("a result with no model was written") + } + // And a row that reached the file some other way is skipped on read. + if err := os.WriteFile(path, []byte(`{"v":1,"kind":"result","id":"x","model":"","place":"/p","work":"w","checks":[{"command":"a","outcome":"passed"}]}`+"\n"), 0o600); err != nil { + t.Fatal(err) + } + m, err := reader(path).Map(context.Background(), "", "") + if err != nil || len(m.Models) != 0 { + t.Fatalf("an unattributed row reached the map: %+v %v", m.Models, err) + } +} + +// THE GROUPS: passes for, failures and undos against, thirty days, then stale. +func TestScopesAreGroupedFromChecksAndUndosAlone(t *testing.T) { + path := filepath.Join(t.TempDir(), LedgerName) + day := 24 * time.Hour + // Strong: five passes. + for i := 0; i < 5; i++ { + result(t, path, "m/flash", "/w/api", "unit tests for the rail", fmt.Sprint("s", i), time.Duration(i+1)*day, Passed) + } + // Frontier: three passes, one failure, one passed-then-undone. + for i := 0; i < 3; i++ { + result(t, path, "m/flash", "/w/api", "parser changes", fmt.Sprint("f", i), time.Duration(i+1)*day, Passed, Passed) + } + result(t, path, "m/flash", "/w/api", "parser changes", "f3", 2*day, Passed, Failed) + result(t, path, "m/flash", "/w/api", "parser changes", "f4", 3*day, Passed) + undo(t, path, "m/flash", "/w/api", "parser changes", "f4", 2*day) + // Weak: one pass, three failures. + result(t, path, "m/flash", "/w/infra", "sql migrations", "w0", day, Passed) + for i := 1; i < 4; i++ { + result(t, path, "m/flash", "/w/infra", "sql migrations", fmt.Sprint("w", i), day, Failed) + } + // Stale: everything older than thirty days, counted in full. + result(t, path, "m/flash", "/w/ffi", "rust bindings", "o0", 40*day, Passed) + result(t, path, "m/flash", "/w/ffi", "rust bindings", "o1", 50*day, Failed) + // Another model's results never reach this model's map. + result(t, path, "m/other", "/w/api", "unit tests for the rail", "x0", day, Failed) + + m, err := reader(path).Map(context.Background(), "m/flash", "") + if err != nil { + t.Fatal(err) + } + strong := scopeIn(t, m, Strong, "unit tests for the rail") + if strong.Results != 5 || strong.Passed != 5 || strong.Since.IsZero() { + t.Fatalf("strong scope %+v", strong) + } + frontier := scopeIn(t, m, Frontier, "parser changes") + if frontier.Results != 5 || frontier.Passed != 3 || frontier.Failed != 1 || frontier.Undone != 1 { + t.Fatalf("frontier scope %+v", frontier) + } + weak := scopeIn(t, m, Weak, "sql migrations") + if weak.Passed != 1 || weak.Failed != 3 { + t.Fatalf("weak scope %+v", weak) + } + stale := scopeIn(t, m, Stale, "rust bindings") + if stale.Results != 2 || !stale.Since.IsZero() { + t.Fatalf("stale scope %+v", stale) + } + if m.Totals.Scopes != 4 || m.Totals.Results != 16 { + t.Fatalf("totals %+v", m.Totals) + } + if len(m.Models) != 2 || m.Policy.StaleAfterDays != 30 { + t.Fatalf("models %+v policy %+v", m.Models, m.Policy) + } + + // A shorter policy is a policy, not a fork of the arithmetic. + short := reader(path) + short.Policy = DefaultPolicy() + short.Policy.StaleAfter = 12 * time.Hour + m, err = short.Map(context.Background(), "m/flash", "") + if err != nil { + t.Fatal(err) + } + if len(m.Groups[3].Scopes) != 4 { + t.Fatalf("with a twelve-hour window every scope is stale: %+v", m.Groups) + } +} + +// A FILTER NARROWS THE GROUPS AND NEVER THE MENU, and an unknown one is refused. +func TestTheScopeFilterNarrowsByPlaceWorkOrScope(t *testing.T) { + path := filepath.Join(t.TempDir(), LedgerName) + result(t, path, "m", "/w/api", "unit tests", "1", time.Hour, Passed) + result(t, path, "m", "/w/web", "unit tests", "2", time.Hour, Passed) + result(t, path, "m", "/w/web", "css fixes", "3", time.Hour, Failed) + all, err := reader(path).Map(context.Background(), "m", "") + if err != nil { + t.Fatal(err) + } + if all.Totals.Scopes != 3 || len(all.Filters) != 4 { + t.Fatalf("all scopes %+v filters %+v", all.Totals, all.Filters) + } + var place, work string + for _, filter := range all.Filters { + if filter.Kind == FilterPlace && filter.Path == "/w/web" { + place = filter.ID + } + if filter.Kind == FilterWork && filter.Words == "unit tests" { + work = filter.ID + } + } + for _, test := range []struct { + scope string + want int + }{{place, 2}, {work, 2}, {all.Groups[1].Scopes[0].ID, 1}} { + m, err := reader(path).Map(context.Background(), "m", test.scope) + if err != nil { + t.Fatal(err) + } + if m.Totals.Scopes != test.want || len(m.Filters) != 4 || m.Scope != test.scope { + t.Fatalf("filter %s: %d scopes, %d filters", test.scope, m.Totals.Scopes, len(m.Filters)) + } + } + if _, err := reader(path).Map(context.Background(), "m", "place:nope"); !errors.Is(err, ErrUnknownScope) { + t.Fatalf("an unknown filter answered %v", err) + } +} + +// A SCOPE IS ITS PLACE AND ITS EXACT KIND, and its id does not move when other +// results arrive or older ones leave the read window. +func TestScopeIDsAreStableExactKinds(t *testing.T) { + path := filepath.Join(t.TempDir(), LedgerName) + result(t, path, "m", "/w", "tests for the composer", "2", 2*time.Hour, Passed) + first, err := reader(path).Map(context.Background(), "m", "") + if err != nil { + t.Fatal(err) + } + id := first.Groups[1].Scopes[0].ID + result(t, path, "m", "/w", "tests for the rail", "1", 3*time.Hour, Passed) + result(t, path, "m", "/w", "Tests, for the composer!", "3", time.Hour, Failed) + m, err := reader(path).Map(context.Background(), "m", "") + if err != nil { + t.Fatal(err) + } + if m.Totals.Scopes != 2 { + t.Fatalf("want two exact kinds, got %+v", m.Groups) + } + found := false + for _, scope := range m.Groups[1].Scopes { + if scope.ID == id { + found = scope.Results == 2 && scope.Work.Words == "tests for the composer" + } + } + if !found { + t.Fatalf("the composer scope's id moved or lost its result: %+v", m.Groups[1].Scopes) + } +} + +// THE DEFAULT MODEL IS THE ONE WITH THE NEWEST RESULT; nothing is invented. +func TestNoModelAsksForTheNewestEvidence(t *testing.T) { + path := filepath.Join(t.TempDir(), LedgerName) + result(t, path, "m/old", "/w", "a", "1", 3*time.Hour, Passed) + result(t, path, "m/new", "/w", "a", "2", time.Hour, Passed) + m, err := reader(path).Map(context.Background(), "", "") + if err != nil { + t.Fatal(err) + } + if m.Model != "m/new" || m.Models[0].ID != "m/new" || m.Models[1].ID != "m/old" { + t.Fatalf("model %q models %+v", m.Model, m.Models) + } + // A model nobody has evidence for is an empty map, not somebody else's. + m, err = reader(path).Map(context.Background(), "m/never", "") + if err != nil || m.Model != "m/never" || m.Totals.Results != 0 { + t.Fatalf("unknown model answered %+v %v", m, err) + } +} + +// A REMOVED PLACE IS SAID, AND THE SCOPE STAYS IN ITS GROUP. +func TestARemovedPlaceIsLabelledAndKept(t *testing.T) { + path := filepath.Join(t.TempDir(), LedgerName) + result(t, path, "m", "/w/gone", "rust bindings", "1", time.Hour, Passed) + r := reader(path) + r.PlaceAdded = func(place string) bool { return place != "/w/gone" } + m, err := r.Map(context.Background(), "m", "") + if err != nil { + t.Fatal(err) + } + scope := scopeIn(t, m, Frontier, "rust bindings") + if !scope.Place.Removed || !m.Filters[0].Removed { + t.Fatalf("removed place not said: %+v %+v", scope.Place, m.Filters) + } +} + +// EVIDENCE OUTLIVES ITS SOURCE, pages by a stable cursor, and filters by outcome. +func TestEvidencePagesAndKeepsResultsWhoseSourceIsGone(t *testing.T) { + path := filepath.Join(t.TempDir(), LedgerName) + for i := 0; i < 12; i++ { + outcome := Passed + if i%4 == 0 { + outcome = Failed + } + result(t, path, "m", "/w", "parser changes", fmt.Sprint(i), time.Duration(i+1)*time.Hour, Passed, outcome) + } + undo(t, path, "m", "/w", "parser changes", "1", 30*time.Minute) + r := reader(path) + r.SourceGone = func(source Source) string { + if source.Task == "2" { + return SourceTask + } + return "" + } + r.SourceTitle = func(Source) string { return "Release" } + m, err := r.Map(context.Background(), "m", "") + if err != nil { + t.Fatal(err) + } + scope := m.Groups[1].Scopes[0].ID + + var seen []string + cursor := "" + for pages := 0; ; pages++ { + page, err := r.Evidence(context.Background(), "m", scope, "", cursor, 0) + if err != nil { + t.Fatal(err) + } + if page.Counts != (Counts{All: 12, Passed: 8, Failed: 3, Undone: 1}) { + t.Fatalf("counts %+v", page.Counts) + } + if len(page.Items) > DefaultEvidenceLimit { + t.Fatalf("page of %d", len(page.Items)) + } + for _, item := range page.Items { + seen = append(seen, item.Source.Task) + if item.Source.Task == "2" && item.SourceMissing != SourceTask { + t.Fatalf("a deleted task's result lost its marker: %+v", item) + } + if item.Source.Task == "1" && (item.Outcome != Undone || item.UndoneAt.IsZero() || item.Check.Outcome != Passed) { + t.Fatalf("passed-then-undone reads %+v", item) + } + if item.Source.Task == "0" && (item.Outcome != Failed || item.Check.Outcome != Failed || len(item.Checks) != 2) { + t.Fatalf("a failed result reads %+v", item) + } + if item.Source.Transcript != "" || item.SourceTitle != "Release" { + t.Fatalf("the source said %+v / %q", item.Source, item.SourceTitle) + } + } + if page.NextCursor == "" { + break + } + cursor = page.NextCursor + if pages > 3 { + t.Fatal("the cursor never ended") + } + } + if strings.Join(seen, ",") != "0,1,2,3,4,5,6,7,8,9,10,11" { + t.Fatalf("pages walked %v", seen) + } + + failed, err := r.Evidence(context.Background(), "m", scope, Failed, "", 100) + if err != nil || len(failed.Items) != 3 || failed.NextCursor != "" { + t.Fatalf("failed filter %+v %v", failed, err) + } + if _, err := r.Evidence(context.Background(), "other", scope, "", cursor, 5); !errors.Is(err, ErrBadCursor) { + t.Fatalf("another model's cursor answered %v", err) + } + if _, err := r.Evidence(context.Background(), "m", "scope:nope", "", "", 5); !errors.Is(err, ErrUnknownScope) { + t.Fatalf("unknown scope answered %v", err) + } + big, err := r.Evidence(context.Background(), "m", scope, "", "", 1000) + if err != nil || len(big.Items) != 12 { + t.Fatalf("a limit past the ceiling answered %d %v", len(big.Items), err) + } +} + +// A LATER RESULT FOR THE SAME WORK REPLACES THE EARLIER, and work done again +// after an undo is no longer undone. +func TestTheLatestResultStands(t *testing.T) { + path := filepath.Join(t.TempDir(), LedgerName) + result(t, path, "m", "/w", "a", "1", 3*time.Hour, Failed) + undo(t, path, "m", "/w", "a", "1", 2*time.Hour) + result(t, path, "m", "/w", "a", "1", time.Hour, Passed) + m, err := reader(path).Map(context.Background(), "m", "") + if err != nil { + t.Fatal(err) + } + scope := m.Groups[1].Scopes[0] + if scope.Results != 1 || scope.Passed != 1 { + t.Fatalf("scope %+v", scope) + } +} + +// THE READ IS BOUNDED: a ledger past the window is read from its end and the +// answer says so; a cancelled read answers nothing. +func TestTheReadIsBoundedAndCancellable(t *testing.T) { + path := filepath.Join(t.TempDir(), LedgerName) + for i := 0; i < 50; i++ { + result(t, path, fmt.Sprint("m", i), "/w", "a", fmt.Sprint(i), time.Duration(50-i)*time.Minute, Passed) + } + r := reader(path) + r.MaxBytes = 2048 + m, err := r.Map(context.Background(), "", "") + if err != nil { + t.Fatal(err) + } + if !m.Truncated || len(m.Models) == 0 || len(m.Models) >= 50 || m.Model != "m49" { + t.Fatalf("bounded read: truncated=%v models=%d model=%s", m.Truncated, len(m.Models), m.Model) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := reader(path).Map(ctx, "", ""); !errors.Is(err, context.Canceled) { + t.Fatalf("a cancelled read answered %v", err) + } +} + +// THE READ WRITES NOTHING. +func TestReadingNeverWrites(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, LedgerName) + if _, err := reader(path).Map(context.Background(), "", ""); err != nil { + t.Fatal(err) + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("a read made the ledger: %v", err) + } + result(t, path, "m", "/w", "a", "1", time.Hour, Passed) + before, _ := os.ReadFile(path) + m, _ := reader(path).Map(context.Background(), "m", "") + if _, err := reader(path).Evidence(context.Background(), "m", m.Groups[1].Scopes[0].ID, "", "", 5); err != nil { + t.Fatal(err) + } + after, _ := os.ReadFile(path) + if string(before) != string(after) { + t.Fatal("a read changed the ledger") + } +} + +// THE OUTCOME IS DECIDED OVER EVERY CHECK, BEFORE THE LIST IS CUT. Nine checks +// whose first failed and whose last eight passed are a failure, and the check +// that failed is one of those shown. +func TestAFailureIsNeverCutOutOfAResult(t *testing.T) { + path := filepath.Join(t.TempDir(), LedgerName) + checks := []CheckRun{{Command: "go vet ./...", Outcome: Failed}} + for i := 0; i < 8; i++ { + checks = append(checks, CheckRun{Command: fmt.Sprintf("go test ./p%d", i), Outcome: Passed}) + } + err := Append(path, Record{ + Kind: KindResult, ID: "task:c:1", At: testNow, Model: "m", Place: "/w", Work: "a", + Source: Source{Kind: SourceTask, Agent: "c", Task: "1"}, Checks: checks, + }) + if err != nil { + t.Fatal(err) + } + r := reader(path) + m, err := r.Map(context.Background(), "m", "") + if err != nil { + t.Fatal(err) + } + scope := m.Groups[1].Scopes[0] + if scope.Failed != 1 || scope.Passed != 0 { + t.Fatalf("nine checks with the first red read %+v", scope) + } + page, err := r.Evidence(context.Background(), "m", scope.ID, "", "", 7) + if err != nil { + t.Fatal(err) + } + item := page.Items[0] + if item.Outcome != Failed || len(item.Checks) != MaxChecks || item.Check == nil || item.Check.Command != "go vet ./..." { + t.Fatalf("item %+v", item) + } +} + +// A CHECK RUN AGAIN IS ONE CHECK, AT ITS LAST RUN; and the decider kept past +// the cut is the first that failed. +func TestARepeatedCheckIsItsLastRun(t *testing.T) { + runs := []CheckRun{ + {Command: "go test ./...", Outcome: Failed}, + {Command: "go test ./...", Outcome: Passed}, + } + if got := lastRunOfEach(runs); len(got) != 1 || got[0].Outcome != Passed { + t.Fatalf("a red check run green again reads %+v", got) + } + runs = []CheckRun{{Command: "go test ./...", Outcome: Passed}, {Command: "go test ./...", Outcome: Failed}} + if got := lastRunOfEach(runs); len(got) != 1 || got[0].Outcome != Failed { + t.Fatalf("a green check run red again reads %+v", got) + } + var many []CheckRun + for i := 0; i < 12; i++ { + outcome := Passed + if i == 10 || i == 11 { + outcome = Failed + } + many = append(many, CheckRun{Command: fmt.Sprint("c", i), Outcome: outcome}) + } + shown := shownChecks(many) + if len(shown) != MaxChecks || shown[MaxChecks-1].Command != "c10" || shown[0].Command != "c0" { + t.Fatalf("shown %+v", shown) + } +} + +// AN UNDO MARKS A CHECKED RESULT AND NEVER MAKES ONE. Work that finished with +// no check is not evidence, and taking it back does not make it evidence. +func TestAnUndoWithNoCheckedResultIsNoEvidence(t *testing.T) { + path := filepath.Join(t.TempDir(), LedgerName) + undo(t, path, "m", "/w", "a", "9", time.Hour) + m, err := reader(path).Map(context.Background(), "", "") + if err != nil { + t.Fatal(err) + } + if m.Model != "" || len(m.Models) != 0 || m.Totals.Results != 0 { + t.Fatalf("an undo of unchecked work became evidence: %+v", m) + } + // An undo of OLDER work than the result standing does not mark it. + result(t, path, "m", "/w", "a", "9", 30*time.Minute, Passed) + m, _ = reader(path).Map(context.Background(), "", "") + if scope := m.Groups[1].Scopes[0]; scope.Passed != 1 || scope.Undone != 0 { + t.Fatalf("scope %+v", scope) + } +} + +// AN IDENTITY THAT DOES NOT FIT IS REFUSED, NOT CUT; and a secret never +// reaches the file. +func TestRowsAreBoundedAndCarryNoSecret(t *testing.T) { + path := filepath.Join(t.TempDir(), LedgerName) + good := Record{ + Kind: KindResult, ID: "task:c:1", At: testNow, Model: "m", Place: "/w", Work: "a", + Source: Source{Kind: SourceTask, Agent: "c", Task: "1"}, + Checks: []CheckRun{{Command: "curl -H 'Authorization: Bearer sk-ant-api03-" + strings.Repeat("x", 40) + "' localhost && " + strings.Repeat("y", 2000), Outcome: Passed}}, + } + for name, mutate := range map[string]func(*Record){ + "long model": func(r *Record) { r.Model = strings.Repeat("m", 300) }, + "long place": func(r *Record) { r.Place = "/" + strings.Repeat("p", 5000) }, + "relative place": func(r *Record) { r.Place = "w" }, + "newline id": func(r *Record) { r.ID = "task:c:1\n{}" }, + "long agent": func(r *Record) { r.Source.Agent = strings.Repeat("a", 200) }, + "bad source": func(r *Record) { r.Source.Kind = "turn" }, + } { + bad := good + mutate(&bad) + if Append(path, bad) == nil { + t.Errorf("%s was written", name) + } + } + if err := Append(path, good); err != nil { + t.Fatal(err) + } + raw, _ := os.ReadFile(path) + if strings.Contains(string(raw), "sk-ant-api03-") || len(raw) > 2048 { + t.Fatalf("the row carried a secret or an unbounded command: %d bytes", len(raw)) + } +} + +// THE ANSWER IS BOUNDED, AND SAYS WHAT IT LEFT OUT. +func TestTheMapAnswerIsBoundedAndHonest(t *testing.T) { + path := filepath.Join(t.TempDir(), LedgerName) + file, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + for i := 0; i < MaxMapScopes+20; i++ { + record, _ := normalized(Record{ + Kind: KindResult, ID: fmt.Sprint("task:c:", i), At: testNow.Add(-time.Duration(i) * time.Minute), + Model: "m", Place: fmt.Sprint("/w/", i), Work: fmt.Sprint("work", i), + Source: Source{Kind: SourceTask, Agent: "c"}, Checks: []CheckRun{{Command: "t", Outcome: Passed}}, + }) + line, _ := json.Marshal(record) + file.Write(append(line, '\n')) + } + for i := 0; i < MaxMapModels+5; i++ { + record, _ := normalized(Record{ + Kind: KindResult, ID: "task:c:x", At: testNow.Add(-48 * time.Hour), Model: fmt.Sprint("other", i), + Place: "/w", Work: "a", Source: Source{Kind: SourceTask, Agent: "c"}, Checks: []CheckRun{{Command: "t", Outcome: Passed}}, + }) + line, _ := json.Marshal(record) + file.Write(append(line, '\n')) + } + file.Close() + asked := 0 + r := reader(path) + r.PlaceAdded = func(string) bool { asked++; return true } + m, err := r.Map(context.Background(), "m", "") + if err != nil { + t.Fatal(err) + } + drawn := 0 + for _, group := range m.Groups { + drawn += len(group.Scopes) + } + if drawn != MaxMapScopes || m.Omitted.Scopes != 20 || m.Totals.Scopes != MaxMapScopes+20 { + t.Fatalf("drawn %d omitted %+v totals %+v", drawn, m.Omitted, m.Totals) + } + if len(m.Filters) != MaxMapFilters || m.Omitted.Filters != 2*(MaxMapScopes+20)-MaxMapFilters { + t.Fatalf("filters %d omitted %d", len(m.Filters), m.Omitted.Filters) + } + if len(m.Models) != MaxMapModels || m.Omitted.Models != 6 { + t.Fatalf("models %d omitted %d", len(m.Models), m.Omitted.Models) + } + if asked > MaxMapScopes { + t.Fatalf("asked about %d places for %d drawn", asked, MaxMapScopes) + } +} + +// A READ THAT RUNS OUT OF TIME STOPS, IN THE GROUPING AS WELL AS THE SCAN, and +// a large ledger of unique kinds is grouped in one pass. +func TestALargeLedgerIsGroupedInOnePassAndStopsWhenCancelled(t *testing.T) { + if testing.Short() { + t.Skip("writes a large ledger") + } + path := filepath.Join(t.TempDir(), LedgerName) + file, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + const rows = 50000 + for i := 0; i < rows; i++ { + record, _ := normalized(Record{ + Kind: KindResult, ID: fmt.Sprint("task:c:", i), At: testNow.Add(-time.Duration(i) * time.Second), + Model: "m", Place: "/w", Work: fmt.Sprintf("unique kind %d of work", i), + Source: Source{Kind: SourceTask, Agent: "c"}, Checks: []CheckRun{{Command: "t", Outcome: Passed}}, + }) + line, _ := json.Marshal(record) + file.Write(append(line, '\n')) + } + file.Close() + + started := time.Now() + m, err := reader(path).Map(context.Background(), "m", "") + if err != nil { + t.Fatal(err) + } + if took := time.Since(started); took > 5*time.Second { + t.Fatalf("%d unique kinds took %s", rows, took) + } + drawn := 0 + for _, group := range m.Groups { + drawn += len(group.Scopes) + } + if m.Totals.Scopes != rows || drawn != MaxMapScopes || len(m.Filters) != MaxMapFilters { + t.Fatalf("totals %+v drawn %d filters %d", m.Totals, drawn, len(m.Filters)) + } + + // A deadline that passes during the read ends it with the deadline's error. + ctx, cancel := context.WithDeadline(context.Background(), time.Now().Add(-time.Second)) + defer cancel() + if _, err := reader(path).Map(ctx, "m", ""); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("an expired read answered %v", err) + } + // And a scope built from a folded read stops on cancellation. + f, err := reader(path).read(context.Background()) + if err != nil { + t.Fatal(err) + } + cancelled, stop := context.WithCancel(context.Background()) + stop() + if _, err := f.scopes(cancelled, "m"); !errors.Is(err, context.Canceled) { + t.Fatalf("grouping a cancelled read answered %v", err) + } +} diff --git a/internal/competence/work.go b/internal/competence/work.go new file mode 100644 index 0000000000..b5566dea27 --- /dev/null +++ b/internal/competence/work.go @@ -0,0 +1,95 @@ +package competence + +import ( + "math" + "strings" +) + +// THE KIND OF WORK IS THE ENGINE'S ONE NAME FOR IT. +// +// A task's kind is the lower-case words of its title, in order, capped — the +// key internal/session's task ledger rates models on (taskgrade.go) and the key +// this package groups checked results by. It lives here, and the session reads +// it from here, because two spellings of "which work was this" would drift the +// first time either was tuned, and then a scope on the competence map and a +// class in the ledger would disagree about the same task. + +const ( + // WorkWords bounds a kind. A title is already capped where tasks are named; + // this is the guard for the other doors a node can be named through — a + // person's own `/task` sentence, a shaper that ran long — so that one + // over-long name cannot become a class nothing else ever meets. + WorkWords = 6 + + // WorkOverlap is when two kinds are the same kind-shaped work: half the + // words of the smaller one, or more. + // + // HALF, because a name is three words and two of three in common is a + // genuine family — "tests for the rail" beside "tests for the composer" — + // while one word in common is a shared verb and nothing else. A threshold + // that pooled on one word would have every "write" in the project rating + // one model, which is the exec.leaf mistake arm B paid for. + WorkOverlap = 0.5 +) + +// WorkOf is the kind of a piece of work, from its title: lower case, cut at +// anything that is not a letter or a digit, duplicates dropped, capped at +// [WorkWords]. +// +// THE WORDS KEEP THEIR ORDER, so the class reads as the name somebody wrote. A +// ledger nobody can read is a ledger nobody checks; the set comparison that +// pools two kinds happens at read time and does not need the key sorted. +func WorkOf(title string) string { + fields := strings.FieldsFunc(strings.ToLower(strings.TrimSpace(title)), func(r rune) bool { + switch { + case r >= 'a' && r <= 'z', r >= '0' && r <= '9': + return false + } + return true + }) + seen := make(map[string]bool, len(fields)) + words := make([]string, 0, WorkWords) + for _, field := range fields { + if seen[field] { + continue + } + seen[field] = true + words = append(words, field) + if len(words) == WorkWords { + break + } + } + return strings.Join(words, " ") +} + +// WorkMeets reports whether two kinds are the same kind-shaped work: their +// words overlap by [WorkOverlap] of the smaller one or more. +// +// The smaller side is the denominator on purpose. A three-word name that is +// wholly inside a six-word one is the same work described at two lengths, and +// measuring against the longer side would call that a stranger. +func WorkMeets(one, other string) bool { + first, second := workWordSet(one), workWordSet(other) + if len(first) == 0 || len(second) == 0 { + return false + } + if len(first) > len(second) { + first, second = second, first + } + shared := 0 + for word := range first { + if second[word] { + shared++ + } + } + return float64(shared) >= math.Ceil(WorkOverlap*float64(len(first))) +} + +func workWordSet(kind string) map[string]bool { + words := strings.Fields(kind) + set := make(map[string]bool, len(words)) + for _, word := range words { + set[word] = true + } + return set +} diff --git a/internal/manual/chat/running-from-the-terminal.md b/internal/manual/chat/running-from-the-terminal.md index ab783ddb7e..a9eb9aca98 100644 --- a/internal/manual/chat/running-from-the-terminal.md +++ b/internal/manual/chat/running-from-the-terminal.md @@ -1019,6 +1019,34 @@ machine would run work on. **On a machine with no evidence the whole answer is o No competence evidence yet. ``` +## What has each model proven — checked results, with codeaf competence --json + +`codeaf competence --json` answers a different question from the text above: **which model +passes its checks, and where**. It prints the same map the desktop app's Settings → Models → +Competence draws, read from a ledger of checked results kept beside the spending ledger. + +A checked result is one finished task whose declared checks — the tests, lint or build its +proposal named — actually ran against the work and came back with a real exit status. Every +check exiting zero is a pass; any failing is a failure. It is credited to the one model that +answered the task's work, after any fallback. A task whose work was answered by two models, +or that another task joined while it ran, is not counted, because nobody could say whose it +was. A checker's opinion, a task that finished with no check, a check that never started or +was cut off, and anything a model says about its own work are never counted. Undo does not +appear yet: the only undo there is takes back quick tasks, which run no checks. + +Results are grouped by place (the repository or folder) and kind of work (the words of the +task's title) into `strong`, `frontier`, `weak` and `stale`. A scope with no result in the last +30 days is `stale`. Otherwise four in five passing, with at least three results, is `strong`; +two in five or fewer is `weak`; everything between is `frontier`. + +`--model ` maps that exact model; left alone it is the model with the newest result. +`--scope ` narrows to one place, kind of work or scope from the map's own `filters`. + +Results are kept when the task or the conversation is deleted. **Work finished before this +ledger existed is not on it**, because nothing then recorded which model's checks ran, so a +machine starts with an empty map: `"model": ""` and four empty groups. It is read-only: it +calls no model and changes nothing about which model runs. + ## The Model Pool — what this machine reads from it, with codeaf pool The public Model Pool is a signed index of measured models. It does not seat diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index c0524e43d1..0f1e2513ca 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -3286,6 +3286,9 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"why the setup examples change every three seconds", "getting-started"}, {"asking what a quoted /task command does", "commands"}, {"how to turn telemetry off now", "running-from-the-terminal"}, + // THE CHECKED-RESULTS MAP, asked the way somebody choosing a model asks it. + {"which model passes its tests most often", "running-from-the-terminal"}, + {"what has each model proven with checks", "running-from-the-terminal"}, {"where are my cleared drafts", "commands"}, {"what does /workspace path do", "commands"}, } diff --git a/internal/run/bashworker.go b/internal/run/bashworker.go index c2e11ab1a9..ba019cfb86 100644 --- a/internal/run/bashworker.go +++ b/internal/run/bashworker.go @@ -134,7 +134,7 @@ func (w *BashWorker) Run(ctx context.Context, task plandb.Task) (rep Report, run defer func() { used := settledWorkerUsage(agent, &receipts) rep.USD, rep.TokensIn, rep.TokensOut = used.CostUSD, used.Input, used.Output - w.recordSpend(task.ID, used.CostUSD, used.Input, used.Output) + w.recordSpend(task.ID, used, agent.AnsweredUsage()) }() runCtx, stop := context.WithCancel(provider.WithReceiptPending(ctx, receipts.owe)) defer stop() @@ -796,23 +796,45 @@ func (w *BashWorker) storeEnding(id string) (storeEnding, bool) { return storeEnding{}, false } -// recordSpend writes the task's one spend row: the model this seat was built -// on, the role its shape gave it at the moment the turn ended, and the dollars -// and tokens its calls cost. It is best-effort whole and last, because the money -// is already in the Report the supervisor absorbs and in the session's usage -// ledger; a store that refuses this write leaves the run's own counters true, -// and a row here is a reading of the run rather than the run's book. A turn that -// spent nothing writes nothing rather than a zero row somebody reads as a -// figure. -func (w *BashWorker) recordSpend(taskID string, usd float64, inTokens, outTokens int) { - if usd == 0 && inTokens == 0 && outTokens == 0 { +// recordSpend writes the task's spend rows: one per model that ANSWERED the +// seat's calls — after any fallback, by the response's own name — with the +// role its shape gave it at the moment the turn ended and the dollars and +// tokens those calls cost. The rows sum to what the seat spent, so every +// reader of the task's price reads the figure it always did; what changed is +// that a seat whose model failed over is no longer written down as the model +// it was built on. A call whose answer named no model is a row with no model, +// which a reader takes as unknown. With no breakdown at all the one row names +// the seat's model, as it always has. +// +// It is best-effort whole and last, because the money is already in the Report +// the supervisor absorbs and in the session's usage ledger; a store that +// refuses this write leaves the run's own counters true, and a row here is a +// reading of the run rather than the run's book. A turn that spent nothing +// writes nothing rather than a zero row somebody reads as a figure. +func (w *BashWorker) recordSpend(taskID string, used session.Usage, answered map[string]session.Usage) { + if used.CostUSD == 0 && used.Input == 0 && used.Output == 0 { return } role, err := w.store.RoleOf(taskID) if err != nil { role = plandb.RoleWork } - _ = w.store.AddSpend(taskID, w.model, role, usd, inTokens, outTokens) + if len(answered) == 0 { + _ = w.store.AddSpend(taskID, w.model, role, used.CostUSD, used.Input, used.Output) + return + } + models := make([]string, 0, len(answered)) + for model := range answered { + models = append(models, model) + } + sort.Strings(models) + for _, model := range models { + share := answered[model] + if share.CostUSD == 0 && share.Input == 0 && share.Output == 0 { + continue + } + _ = w.store.AddSpend(taskID, model, role, share.CostUSD, share.Input, share.Output) + } } // stepRecorder appends one worker's step lines, and keeps the two readings diff --git a/internal/run/bashworker_answered_test.go b/internal/run/bashworker_answered_test.go new file mode 100644 index 0000000000..2a5aa221a5 --- /dev/null +++ b/internal/run/bashworker_answered_test.go @@ -0,0 +1,66 @@ +package run + +import ( + "database/sql" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/plandb" + "github.com/Agent-Field/codeaf/internal/session" +) + +// spendRows is the store's spend ledger for one task, model to dollars. +func spendRows(t *testing.T, path, task string) map[string]float64 { + t.Helper() + db, err := sql.Open("sqlite", "file:"+path+"?mode=ro") + if err != nil { + t.Fatal(err) + } + defer db.Close() + rows, err := db.Query(`SELECT model, usd FROM spend WHERE task_id = ?`, task) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + out := map[string]float64{} + for rows.Next() { + var model string + var usd float64 + if err := rows.Scan(&model, &usd); err != nil { + t.Fatal(err) + } + out[model] += usd + } + return out +} + +// A SEAT WHOSE MODEL FAILED OVER IS WRITTEN DOWN AS THE MODELS THAT ANSWERED, +// and the rows still sum to what it spent; with no breakdown the one row names +// the seat's model, as it always did. +func TestSpendIsWrittenUnderTheModelsThatAnswered(t *testing.T) { + store, err := plandb.Open(filepath.Join(t.TempDir(), "plan.db"), "p", "root", "Fix", "Fix it") + if err != nil { + t.Fatal(err) + } + defer store.Close() + worker := &BashWorker{store: store, model: "m/built"} + used := session.Usage{Input: 30, Output: 12, CostUSD: 0.04} + worker.recordSpend("root", used, map[string]session.Usage{ + "m/fallback": {Input: 20, Output: 7, CostUSD: 0.03}, + "m/built": {Input: 10, Output: 5, CostUSD: 0.01}, + }) + rows := spendRows(t, store.Path(), "root") + if len(rows) != 2 || rows["m/fallback"] != 0.03 || rows["m/built"] != 0.01 { + t.Fatalf("rows %+v", rows) + } + + other, err := plandb.Open(filepath.Join(t.TempDir(), "plan.db"), "p", "root", "Fix", "Fix it") + if err != nil { + t.Fatal(err) + } + defer other.Close() + (&BashWorker{store: other, model: "m/built"}).recordSpend("root", used, nil) + if rows := spendRows(t, other.Path(), "root"); len(rows) != 1 || rows["m/built"] != 0.04 { + t.Fatalf("no breakdown wrote %+v", rows) + } +} diff --git a/internal/session/competence.go b/internal/session/competence.go new file mode 100644 index 0000000000..44cf841568 --- /dev/null +++ b/internal/session/competence.go @@ -0,0 +1,281 @@ +package session + +// WHAT A MODEL HAS PROVEN, WRITTEN WHERE IT IS PROVEN. +// +// internal/competence keeps the ledger of checked results; this file is the +// session's one door into it, and the door out a reader uses to ask whether a +// result's source still exists. +// +// THE DOOR IS A RUN'S DECLARED CHECKS, AS THEY RAN ([Agent.recordRunProven]). +// This is the road every approved task takes by default (task.go's +// commitProposalToRun): the run's check seats re-run the declared checks +// through their bash, the plan store records each declared command's physical +// exit off the check's own trajectory ([plandb.VerdictBasis] of kind "run"), +// and when the run settles those exits become one result row on the one model +// that answered the run's work. +// +// THE LEGACY TASK GRAPH WRITES NOTHING. It runs only with the belt switched off +// (CODEAF_TASK_BELT=node), and the one model it could name for a node is the +// one the node was admitted on, not the one that answered — so its work is no +// evidence rather than evidence on a model nobody proved. +// +// NO UNDO IS WRITTEN. The one undo door there is, quick.undo, takes back a +// quick task, and a quick task runs no declared check, so it has no checked +// result to mark; writing it would be a row no reader could ever use. The +// ledger and its reader keep the undo shape for the day a door can reach +// checked work. +// +// WHAT THE CHECKER CONCLUDED IS NEVER READ HERE. A verdict is a model's +// judgement of another model's work; a result row is exit statuses. Work the +// checker accepted with a red declared check is a failed result, and work it +// refused over green checks is a passed one — the map counts what ran. + +import ( + "database/sql" + "os" + "path/filepath" + "sort" + "strconv" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/competence" + "github.com/Agent-Field/codeaf/internal/plandb" +) + +// recordRunProven writes a settled run's declared-check exits as one result, +// under the task row the conversation knows the run by. +// +// THE EXITS ARE THE PLAN STORE'S, read off the check's own trajectory by the +// gate that settled it, never a checker's words: only a check task whose +// basis is a "run" carries them. They are folded in the order the checks +// finished, the last run of a command standing, because every check of one run +// reads the run's one copy and a later run of a command is a later reading of +// the same tree. +// +// THE MODEL IS THE ONE THAT ANSWERED THE WORK — every model the run's work +// seats' calls were answered by, after any fallback, read off its spend ledger +// (internal/run's recordSpend, from [Agent.AnsweredUsage]) — and the run is +// written only when that is exactly one known model. Two is a run nobody can +// attribute; none, or an answer that named no model, is a run nobody can name. +// Neither is guessed, and the model a seat was BUILT on is never read here. A +// program's run is work an outside program did, and is not this ledger's. +func (a *Agent) recordRunProven(run *beltRun) { + if run == nil || run.store == nil || run.delegate != nil || strings.TrimSpace(run.ground) == "" { + return + } + agent := a.journalID() + if agent == "" { + return + } + // A RUN OTHER HAND-OFFS JOINED IS SEVERAL GOALS IN ONE COPY, and nothing + // the store keeps says which check proved which goal. Crediting every check + // to the first goal's row would be a claim about work it did not do, so + // such a run is no evidence. + a.beltMu.Lock() + joined := len(run.joined) > 0 + ended := run.ended + a.beltMu.Unlock() + if joined { + return + } + tasks := runTasks(run.store.Tasks(), run.root) + var checks []*plandb.Task + ids := make([]string, 0, len(tasks)) + for _, task := range tasks { + ids = append(ids, task.ID) + if task.Role == plandb.RoleCheck && task.VerdictBasis.Kind == "run" && len(task.VerdictBasis.Runs) > 0 { + checks = append(checks, task) + } + } + if len(checks) == 0 { + return + } + model, ok := planOneWorkModel(run.store.Path(), ids) + if !ok { + return + } + sort.SliceStable(checks, func(i, j int) bool { return checks[i].CompletedAt.Before(checks[j].CompletedAt) }) + var runs []competence.CheckRun + for _, check := range checks { + for _, ran := range check.VerdictBasis.Runs { + if outcome, read := exitReading(ran.ExitCode); read { + runs = append(runs, competence.CheckRun{Command: ran.Command, Outcome: outcome}) + } + } + } + if ended.IsZero() { + ended = time.Now() + } + task := strconv.FormatUint(run.row, 10) + _ = competence.Append(competence.LedgerPath(), competence.Record{ + Kind: competence.KindResult, ID: competence.TaskID(agent, task), At: ended, + Model: model, Place: run.ground, PlaceKind: placeKindOf(run.ground), + Work: competence.WorkOf(run.title), Title: run.title, + Source: competence.Source{Kind: competence.SourceTask, Agent: agent, Task: task, Transcript: a.config.SessionFile}, + Checks: runs, + }) +} + +// exitReading is what one recorded exit says: zero passed, any other exit the +// check gave failed, and the shell's own "could not run it" codes (126, 127) +// and a negative code are not a reading of the work. +// +// ONE GAP IS UPSTREAM OF THIS READER: a check killed by a signal outside its +// call's own deadline exits -1, which the bare bash answers as a success and +// the trajectory then records as 0. Nothing that reaches this file can tell +// the two apart. +func exitReading(code int) (competence.Outcome, bool) { + switch { + case code == 0: + return competence.Passed, true + case code < 0, code == 126, code == 127: + return "", false + default: + return competence.Failed, true + } +} + +// runTasks is the run's root and everything under it. A conversation's plan +// store may hold more than one run, and only this one's tasks are its. +func runTasks(all []*plandb.Task, root string) []*plandb.Task { + children := make(map[string][]*plandb.Task, len(all)) + var out []*plandb.Task + for _, task := range all { + if task.ID == root { + out = append(out, task) + continue + } + children[task.ParentID] = append(children[task.ParentID], task) + } + queue := []string{root} + for len(queue) > 0 { + id := queue[0] + queue = queue[1:] + for _, child := range children[id] { + out = append(out, child) + queue = append(queue, child.ID) + } + } + return out +} + +// planOneWorkModel is the one model that answered the named tasks' WORK, read +// off the spend ledger on its own read-only connection: rows whose role was +// "work" when the money was spent, so a planner's and a checker's calls are +// never the work's. It is false when the work was answered by no model, by +// more than one, or by a call whose answer named none — an unknown is not the +// seat's model, and a mixed run is not any one model's. +func planOneWorkModel(path string, ids []string) (string, bool) { + if len(ids) == 0 { + return "", false + } + db, err := sql.Open("sqlite", "file:"+path+"?mode=ro") + if err != nil { + return "", false + } + defer db.Close() + marks := strings.TrimSuffix(strings.Repeat("?,", len(ids)), ",") + args := make([]any, 0, len(ids)+1) + args = append(args, plandb.RoleWork) + for _, id := range ids { + args = append(args, id) + } + rows, err := db.Query(`SELECT DISTINCT TRIM(model) FROM spend WHERE role = ? AND task_id IN (`+marks+`) LIMIT 2`, args...) + if err != nil { + return "", false + } + defer rows.Close() + var models []string + for rows.Next() { + var model string + if rows.Scan(&model) != nil { + return "", false + } + models = append(models, model) + } + if rows.Err() != nil || len(models) != 1 || models[0] == "" { + return "", false + } + return models[0], true +} + +func placeKindOf(dir string) string { + if dir == "" { + return competence.PlaceFolder + } + if _, repository := repositoryRoot(dir); repository { + return competence.PlaceRepo + } + return competence.PlaceFolder +} + +// CheckedSourceGone says what of a result's source has been deleted: the whole +// conversation, the task inside it, or nothing. A source with no transcript on +// record is unknown and answers nothing, rather than a deletion nobody saw. +func CheckedSourceGone(source competence.Source) string { + transcript := strings.TrimSpace(source.Transcript) + if transcript == "" { + return "" + } + dir := filepath.Dir(transcript) + if _, err := os.Stat(dir); os.IsNotExist(err) { + return competence.SourceConversation + } + if _, err := os.Stat(filepath.Join(dir, conversationDeletedFile)); err == nil { + return competence.SourceConversation + } + if source.Kind == competence.SourceTask && source.Task != "" && taskDeletions(transcript)[source.Task] { + return competence.SourceTask + } + return "" +} + +// CheckedSourceTitle is the owning conversation's title as its meta.json says +// it now, or "" when it has none or cannot be read. +func CheckedSourceTitle(source competence.Source) string { + transcript := strings.TrimSpace(source.Transcript) + if transcript == "" { + return "" + } + meta, err := LoadMeta(filepath.Dir(transcript)) + if err != nil { + return "" + } + return strings.TrimSpace(meta.Title) +} + +// add folds one banked call into the tally under the model that answered it. +// A nil tally is made on first use; a call that names no model is kept under +// "", which a reader takes as unknown and never as the seat's own model. +func (m *modelShares) add(model string, call Usage) { + if *m == nil { + *m = modelShares{} + } + model = strings.TrimSpace(model) + share := (*m)[model] + share.Input += call.Input + share.Output += call.Output + share.CacheRead += call.CacheRead + share.CacheWrite += call.CacheWrite + share.CostUSD += call.CostUSD + share.Calls += call.Calls + (*m)[model] = share +} + +// AnsweredUsage is this session's money split by the model that answered each +// call — after any fallback, by the response's own name — and nil when nothing +// was banked. Its shares sum to [Agent.Usage]'s tokens and dollars. It routes +// nothing; it is a reading of what already happened. +func (a *Agent) AnsweredUsage() map[string]Usage { + a.mu.Lock() + defer a.mu.Unlock() + if len(a.answered) == 0 { + return nil + } + out := make(map[string]Usage, len(a.answered)) + for model, share := range a.answered { + out[model] = share + } + return out +} diff --git a/internal/session/competence_test.go b/internal/session/competence_test.go new file mode 100644 index 0000000000..3e9d74324a --- /dev/null +++ b/internal/session/competence_test.go @@ -0,0 +1,203 @@ +package session + +import ( + "bufio" + "encoding/json" + "math" + "os" + "path/filepath" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/competence" + "github.com/Agent-Field/codeaf/internal/plandb" +) + +// ledgerRowsFor is every ledger row one conversation wrote. The hermetic home +// is the package's, so other tests' rows are filtered out by transcript. +func ledgerRowsFor(t *testing.T, transcript string) []competence.Record { + t.Helper() + file, err := os.Open(competence.LedgerPath()) + if os.IsNotExist(err) { + return nil + } + if err != nil { + t.Fatal(err) + } + defer file.Close() + var rows []competence.Record + scanner := bufio.NewScanner(file) + for scanner.Scan() { + var row competence.Record + if json.Unmarshal(scanner.Bytes(), &row) == nil && row.Source.Transcript == transcript { + rows = append(rows, row) + } + } + return rows +} + +// journalledAgent is a test conversation WITH a journal on disk, which is what +// makes its work attributable: a conversation with no journal writes nothing. +func journalledAgent(t *testing.T) (*Agent, string) { + t.Helper() + transcript := filepath.Join(t.TempDir(), "conv", "transcript.jsonl") + agent, dir := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { c.SessionFile = transcript }) + if agent.journalID() == "" { + t.Fatal("the fixture's conversation has no journal") + } + return agent, dir +} + +// runFixture is a settled run's plan store: a root, one work task spent on the +// given models, and one check task whose basis is the given exits. +func runFixture(t *testing.T, agent *Agent, ground string, models []string, exits map[string]int) *beltRun { + t.Helper() + store, err := plandb.Open(filepath.Join(t.TempDir(), "plandb.db"), "p", "7", "Fix the parser", "fix it", agent.journalID()) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { store.Close() }) + if _, err := store.AddMany([]plandb.TaskSpec{{ID: "t-work", Title: "the lexer", ParentID: "7"}}); err != nil { + t.Fatal(err) + } + check, err := store.AddReviewCheck(plandb.TaskSpec{ID: "t-check", Title: "check: the lexer", ParentID: "7", Role: plandb.RoleCheck}) + if err != nil { + t.Fatal(err) + } + basis := plandb.VerdictBasis{Kind: "run"} + for command, exit := range exits { + basis.Runs = append(basis.Runs, plandb.VerdictRun{Command: command, ExitCode: exit}) + } + if _, err := store.SetVerdictBasis(check.ID, basis); err != nil { + t.Fatal(err) + } + for _, model := range models { + if err := store.AddSpend("t-work", model, plandb.RoleWork, 0.01, 10, 10); err != nil { + t.Fatal(err) + } + } + // The checker's own spend is never the work's model. + if err := store.AddSpend(check.ID, "careful/checker", plandb.RoleCheck, 0.01, 10, 10); err != nil { + t.Fatal(err) + } + return &beltRun{store: store, root: "7", row: 7, title: "Fix the parser", ground: ground, + ended: time.Date(2026, 10, 15, 9, 0, 0, 0, time.UTC)} +} + +// THE DEFAULT ROAD: A SETTLED RUN'S DECLARED-CHECK EXITS BECOME ONE RESULT, on +// the one model its work was spent through and under the task row the +// conversation knows it by. The shell's could-not-run codes are not readings. +func TestASettledRunWritesItsCheckExitsOnItsWorkModel(t *testing.T) { + agent, dir := journalledAgent(t) + run := runFixture(t, agent, dir, []string{"m/flash"}, map[string]int{"go test ./...": 0, "go vet ./...": 1, "missing-tool": 127}) + agent.recordRunProven(run) + rows := ledgerRowsFor(t, agent.config.SessionFile) + if len(rows) != 1 { + t.Fatalf("rows %+v", rows) + } + row := rows[0] + if row.Model != "m/flash" || row.ID != competence.TaskID(agent.journalID(), "7") || row.Source.Task != "7" || + row.Place != dir || row.Title != "Fix the parser" || row.Outcome != competence.Failed || row.Ran != 2 { + t.Fatalf("row %+v", row) + } + for _, check := range row.Checks { + if check.Command == "missing-tool" { + t.Fatalf("a check the shell could not start was recorded: %+v", row.Checks) + } + } +} + +// A RUN NOBODY CAN ATTRIBUTE IS NO EVIDENCE: two work models, or none, or no +// check that ran. +func TestARunWithNoSingleWorkModelOrNoRunCheckWritesNothing(t *testing.T) { + for name, fixture := range map[string]struct { + models []string + exits map[string]int + }{ + "two models": {[]string{"m/a", "m/b"}, map[string]int{"go test ./...": 0}}, + "no spend": {nil, map[string]int{"go test ./...": 0}}, + "no run": {[]string{"m/a"}, map[string]int{"missing-tool": 127}}, + // An answer that named no model is unknown, never the one beside it. + "unknown answer": {[]string{"m/a", ""}, map[string]int{"go test ./...": 0}}, + } { + agent, dir := journalledAgent(t) + agent.recordRunProven(runFixture(t, agent, dir, fixture.models, fixture.exits)) + if rows := ledgerRowsFor(t, agent.config.SessionFile); len(rows) != 0 { + t.Errorf("%s wrote %+v", name, rows) + } + } +} + +// A DELETED SOURCE IS SAID, and a source nobody recorded is not called deleted. +func TestASourcesDeletionIsReadOffTheRecords(t *testing.T) { + dir := filepath.Join(t.TempDir(), "conv") + if err := os.MkdirAll(dir, 0o700); err != nil { + t.Fatal(err) + } + transcript := filepath.Join(dir, "transcript.jsonl") + source := competence.Source{Kind: competence.SourceTask, Agent: "conv", Task: "3", Transcript: transcript} + if got := CheckedSourceGone(source); got != "" { + t.Fatalf("a present source read %q", got) + } + if err := os.WriteFile(filepath.Join(dir, taskDeletedFile), []byte(`{"3":true}`), 0o600); err != nil { + t.Fatal(err) + } + if got := CheckedSourceGone(source); got != competence.SourceTask { + t.Fatalf("a deleted task read %q", got) + } + if err := os.WriteFile(filepath.Join(dir, conversationDeletedFile), nil, 0o600); err != nil { + t.Fatal(err) + } + if got := CheckedSourceGone(source); got != competence.SourceConversation { + t.Fatalf("a deleted conversation read %q", got) + } + source.Transcript = filepath.Join(t.TempDir(), "gone", "transcript.jsonl") + if got := CheckedSourceGone(source); got != competence.SourceConversation { + t.Fatalf("a missing conversation folder read %q", got) + } + if got := CheckedSourceGone(competence.Source{Kind: competence.SourceTask, Task: "3"}); got != "" { + t.Fatalf("an unrecorded source read %q", got) + } +} + +// A RUN OTHER HAND-OFFS JOINED IS NOBODY'S ONE GOAL, and its checks are not +// credited to the first; a PLANNER'S spend on the root is never the work's. +func TestAJoinedRunIsNoEvidenceAndAPlannerIsNotTheWorker(t *testing.T) { + agent, dir := journalledAgent(t) + run := runFixture(t, agent, dir, []string{"m/flash"}, map[string]int{"go test ./...": 0}) + if err := run.store.AddSpend("7", "m/planner", plandb.RolePlan, 0.02, 10, 10); err != nil { + t.Fatal(err) + } + run.joined = []uint64{8} + agent.recordRunProven(run) + if rows := ledgerRowsFor(t, agent.config.SessionFile); len(rows) != 0 { + t.Fatalf("a joined run wrote %+v", rows) + } + run.joined = nil + agent.recordRunProven(run) + rows := ledgerRowsFor(t, agent.config.SessionFile) + if len(rows) != 1 || rows[0].Model != "m/flash" { + t.Fatalf("the planner's spend moved the work's model: %+v", rows) + } +} + +// THE ANSWERED TALLY IS KEPT UNDER THE MODEL EACH CALL NAMED, and sums to the +// session's own figures. +func TestTheAnsweredTallyFollowsTheAnsweringModel(t *testing.T) { + agent, _ := newTestAgent(t, &scriptedCompleter{}, nil) + if agent.AnsweredUsage() != nil { + t.Fatal("an agent that spent nothing has a tally") + } + agent.bank(bankedCall{used: Usage{Input: 10, Output: 5, CostUSD: 0.01, Calls: 1}, model: "m/primary", turn: true}) + agent.bank(bankedCall{used: Usage{Input: 20, Output: 7, CostUSD: 0.03, Calls: 1}, model: "m/fallback", turn: true}) + agent.bank(bankedCall{used: Usage{Input: 1, Output: 1, CostUSD: 0.001, Calls: 1}, model: "m/fallback"}) + shares := agent.AnsweredUsage() + if len(shares) != 2 || shares["m/fallback"].Input != 21 || shares["m/primary"].Output != 5 { + t.Fatalf("shares %+v", shares) + } + total := agent.Usage() + if shares["m/primary"].Input+shares["m/fallback"].Input != total.Input || + math.Abs(shares["m/primary"].CostUSD+shares["m/fallback"].CostUSD-total.CostUSD) > 1e-12 { + t.Fatalf("shares %+v do not sum to %+v", shares, total) + } +} diff --git a/internal/session/loop.go b/internal/session/loop.go index bc725e1413..a7e3e6ce06 100644 --- a/internal/session/loop.go +++ b/internal/session/loop.go @@ -4243,6 +4243,10 @@ func (a *Agent) bank(call bankedCall) { a.usage.CostUSD += call.used.CostUSD a.usage.Calls += call.used.Calls a.usage.EmptyReflex += call.used.EmptyReflex + // AND UNDER THE MODEL THAT ANSWERED, which is the name this call carries + // rather than the one the seat was built on: a fallback mid-run is a + // different model doing the work (competence.go). + a.answered.add(call.model, call.used) // AND THE RUNNING TURN'S OWN SHARE MOVES ON THE SAME CALL, for this door's // own reason: an abandoned turn never writes the seal that would carry its // cost, so the only honest figure to journal it with is the one accumulated diff --git a/internal/session/session.go b/internal/session/session.go index 7ef9d6c1a9..1e5baf70df 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -3048,6 +3048,10 @@ type Agent struct { // under mu. team teamSeat usage Usage + // answered is the session's money once per model that ANSWERED it, every + // call banked under the name the response itself gave (competence.go's + // [Agent.AnsweredUsage]). It sums to usage's figures call for call. + answered modelShares // principal is WHO THIS SESSION IS WORKING FOR (principal.go), and it is // never nil: a session built with no posture at all gets a [Person], which // answers every question the way this package answered it before the diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index 573753c7fd..353e28402c 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -1989,6 +1989,9 @@ func (a *Agent) driveBeltRun(ctx context.Context, engine RunEngine, run *beltRun } a.deliverBeltRunLanding(run, summary, landing) a.settleBeltRun(run, summary, landing) + // AND WHAT ITS CHECKS PROVED is written while the store is still open + // (competence.go). + a.recordRunProven(run) // THE CREW'S OUTCOME is the TASK's — was the result kept — and never the // route's, which the seats' first calls wrote already (taskcrew.go). A run // that finished and whose landing nobody refused is accepted: merged, in diff --git a/internal/session/taskgrade.go b/internal/session/taskgrade.go index 7ce7b3d89c..f916c5a456 100644 --- a/internal/session/taskgrade.go +++ b/internal/session/taskgrade.go @@ -83,32 +83,16 @@ package session // beside it is the same router.Event every routed attempt writes. import ( - "math" "strconv" "strings" "time" + "github.com/Agent-Field/codeaf/internal/competence" "github.com/Agent-Field/codeaf/internal/provider" "github.com/Agent-Field/codeaf/internal/router" ) const ( - // taskKindWords bounds a kind. A title is already capped at - // [TaskNameWords]; this is the guard for the other doors a node can be - // named through — a person's own `/task` sentence, a shaper that ran long — - // so that one over-long name cannot become a class nothing else ever meets. - taskKindWords = 6 - - // taskKindOverlap is when two kinds are the same kind-shaped work: half the - // words of the smaller one, or more. - // - // HALF, because a name is three words and two of three in common is a - // genuine family — "tests for the rail" beside "tests for the composer" — - // while one word in common is a shared verb and nothing else. A threshold - // that pooled on one word would have every "write" in the project rating - // one model, which is the exec.leaf mistake arm B paid for. - taskKindOverlap = 0.5 - // TaskGradeEvidence is how many checked settles of one kind-shaped work it // takes before the store may move a tier. // @@ -331,68 +315,13 @@ func (g *taskGrades) saysCareful(model, kind string) bool { // ── the kind, and when two of them are one ────────────────────────────────── // taskKindOf is the divider's own name for a piece of work, in the spelling the -// store keys on: lower case, cut at anything that is not a letter or a digit, -// and capped. -// -// THE WORDS KEEP THEIR ORDER, so the class reads as the name somebody wrote — -// `task.node/tests for the rail` on `codeaf models` — for the reason -// internal/router states about its own keys: a ledger nobody can read is a -// ledger nobody checks. The set comparison that pools two kinds happens at read -// time and does not need the key sorted. -func taskKindOf(title string) string { - fields := strings.FieldsFunc(strings.ToLower(strings.TrimSpace(title)), func(r rune) bool { - switch { - case r >= 'a' && r <= 'z', r >= '0' && r <= '9': - return false - } - return true - }) - seen := make(map[string]bool, len(fields)) - words := make([]string, 0, taskKindWords) - for _, field := range fields { - if seen[field] { - continue - } - seen[field] = true - words = append(words, field) - if len(words) == taskKindWords { - break - } - } - return strings.Join(words, " ") -} - -// taskKindsMeet reports whether two kinds are the same kind-shaped work: their -// words overlap by [taskKindOverlap] of the smaller one or more. -// -// The smaller side is the denominator on purpose. A three-word name that is -// wholly inside a six-word one is the same work described at two lengths, and -// measuring against the longer side would call that a stranger. -func taskKindsMeet(one, other string) bool { - first, second := taskKindWordSet(one), taskKindWordSet(other) - if len(first) == 0 || len(second) == 0 { - return false - } - if len(first) > len(second) { - first, second = second, first - } - shared := 0 - for word := range first { - if second[word] { - shared++ - } - } - return float64(shared) >= math.Ceil(taskKindOverlap*float64(len(first))) -} +// store keys on. It is [competence.WorkOf], read from there so the task ledger +// and the competence map can never disagree about which work a task was. +func taskKindOf(title string) string { return competence.WorkOf(title) } -func taskKindWordSet(kind string) map[string]bool { - words := strings.Fields(kind) - set := make(map[string]bool, len(words)) - for _, word := range words { - set[word] = true - } - return set -} +// taskKindsMeet reports whether two kinds are the same kind-shaped work, by the +// one overlap rule both readers share ([competence.WorkMeets]). +func taskKindsMeet(one, other string) bool { return competence.WorkMeets(one, other) } // ── what the settle tells the store ───────────────────────────────────────── From 16ba0a5d8594e2cce5b3a4c3540d84b1331c0197 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 19:27:17 -0400 Subject: [PATCH 047/459] competence: attribute only answered models, and never read a killed command as a success A plan-store spend row now says whether its model ANSWERED: a new `answered` column, 0 for every old row and every AddSpend, 1 only from the new AddAnsweredSpend. Run workers write one answered row per model the responses named (from the session's own tally); money whose answer named no model, and money the tally does not cover, stay unmarked rows for billing on the model the seat was built on. The rows still sum to the seat's spend, so every price reader is unchanged. The competence ledger attributes a run only when every one of its work rows is answered and they name one model, so a configured or pre-upgrade model never becomes a checked model. The bare bash tool answered a process a signal killed (Go's exit -1) as a success, and a run's trajectory then recorded a killed check as exit 0. It now answers the shell's 128+signal as a failure, a process that exited 0 but held its pipes past WaitDelay as 0, and a wait with no exit status at all as a failure. Cancellation and timeouts are unchanged. Docs: the undo comments say what the reader does (an undo with no checked result is dropped), and the protocol, manual and change entry state the reading rules as the code applies them. An exact-wire test pins the app's spelling of competence.map and competence.evidence through the real adapter. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/appcompetence_test.go | 132 ++++++++++++++++++ .../0-competence-checked-results.md | 3 +- internal/competence/ledger.go | 12 +- internal/exec/bare/bash_exit_unix_test.go | 67 +++++++++ internal/exec/bare/tools.go | 44 +++++- .../manual/chat/running-from-the-terminal.md | 5 +- internal/plandb/persist.go | 7 + internal/plandb/spend_answered_test.go | 68 +++++++++ internal/plandb/store.go | 30 +++- internal/run/bashworker.go | 40 ++++-- internal/run/bashworker_answered_test.go | 77 ++++++---- internal/session/competence.go | 32 +++-- internal/session/competence_test.go | 23 ++- 13 files changed, 467 insertions(+), 73 deletions(-) create mode 100644 internal/exec/bare/bash_exit_unix_test.go create mode 100644 internal/plandb/spend_answered_test.go diff --git a/cmd/codeaf/appcompetence_test.go b/cmd/codeaf/appcompetence_test.go index c7f63687ea..fe824aa97e 100644 --- a/cmd/codeaf/appcompetence_test.go +++ b/cmd/codeaf/appcompetence_test.go @@ -6,6 +6,8 @@ import ( "errors" "os" "path/filepath" + "sort" + "strings" "testing" "time" @@ -126,3 +128,133 @@ func TestTheAppReadsCompetenceEvidence(t *testing.T) { t.Fatal("an unknown filter was accepted") } } + +// keysOf is an object's keys, sorted. +func keysOf(t *testing.T, value any) string { + t.Helper() + object, ok := value.(map[string]any) + if !ok { + t.Fatalf("%v is not an object", value) + } + keys := make([]string, 0, len(object)) + for key := range object { + keys = append(keys, key) + } + sort.Strings(keys) + return strings.Join(keys, ",") +} + +// THE WIRE'S EXACT SHAPE, from the real adapter over a fixed ledger and clock. +// It pins the public spelling an app decodes — source.id/agent/title, never +// the ledger's task or source_title — and, with COMPETENCE_WIRE_FIXTURE_DIR +// set, writes the two answers there for an app's own regression. +func TestTheCompetenceWireShapeIsExact(t *testing.T) { + root := t.TempDir() + t.Setenv(home.EnvVar, filepath.Join(root, "state")) + const agent = "0123456789abcdef" + const api, ffi = "/work/acme/api", "/work/acme/ffi" + conversation := filepath.Join(home.Join("v3", "projects"), encodeWorkspace(api), agent) + if err := os.MkdirAll(conversation, 0o700); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(conversation, "meta.json"), []byte(`{"id":"`+agent+`","title":"Release","workspace":"`+api+`"}`), 0o600); err != nil { + t.Fatal(err) + } + transcript := filepath.Join(conversation, "transcript.jsonl") + gone := filepath.Join(root, "deleted", "transcript.jsonl") + now := time.Date(2026, 10, 15, 14, 2, 0, 0, time.UTC) + for _, row := range []struct { + task, place, title, transcript string + ago time.Duration + checks []competence.CheckRun + }{ + {"3", api, "Fix the parser", transcript, 24 * time.Hour, []competence.CheckRun{ + {Command: "go vet ./internal/parse/...", Outcome: competence.Passed}, + {Command: "go test ./internal/parse/...", Outcome: competence.Failed}}}, + {"2", api, "Fix the parser", transcript, 48 * time.Hour, []competence.CheckRun{ + {Command: "go test ./internal/parse/...", Outcome: competence.Passed}}}, + {"1", api, "Fix the parser", gone, 72 * time.Hour, []competence.CheckRun{ + {Command: "go test ./internal/parse/...", Outcome: competence.Passed}}}, + {"9", ffi, "Rust bindings", gone, 40 * 24 * time.Hour, []competence.CheckRun{ + {Command: "cargo test", Outcome: competence.Passed}}}, + } { + err := competence.Append(competence.LedgerPath(), competence.Record{ + Kind: competence.KindResult, ID: competence.TaskID(agent, row.task), At: now.Add(-row.ago), + Model: "deepseek/deepseek-v4-flash", Place: row.place, PlaceKind: competence.PlaceRepo, + Work: competence.WorkOf(row.title), Title: row.title, + Source: competence.Source{Kind: competence.SourceTask, Agent: agent, Task: row.task, Transcript: row.transcript}, + Checks: row.checks, + }) + if err != nil { + t.Fatal(err) + } + } + reader := competenceReader() + reader.Now = func() time.Time { return now } + answer, err := readCompetenceMap(reader, "", "") + if err != nil { + t.Fatal(err) + } + if len(answer.Groups[1].Scopes) != 1 || len(answer.Groups[3].Scopes) != 1 { + t.Fatalf("groups %+v", answer.Groups) + } + parser := answer.Groups[1].Scopes[0] + page, err := readCompetenceEvidence(reader, appwire.CompetenceEvidenceParams{Model: answer.Model, Scope: parser.ID, Limit: 2}) + if err != nil { + t.Fatal(err) + } + mapJSON, _ := json.MarshalIndent(answer, "", " ") + pageJSON, _ := json.MarshalIndent(page, "", " ") + + var wireMap, wirePage map[string]any + if err := json.Unmarshal(mapJSON, &wireMap); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(pageJSON, &wirePage); err != nil { + t.Fatal(err) + } + groups := wireMap["groups"].([]any) + wireScope := groups[1].(map[string]any)["scopes"].([]any)[0] + staleScope := groups[3].(map[string]any)["scopes"].([]any)[0] + items := wirePage["items"].([]any) + for name, test := range map[string]struct { + value any + want string + }{ + "map": {wireMap, "filters,groups,model,models,omitted,policy,read_at,scope,totals,truncated"}, + "scope": {wireScope, "class,failed,id,last_at,passed,place,results,since,undone,work"}, + "stale scope": {staleScope, "class,failed,id,last_at,passed,place,results,undone,work"}, + "place": {wireScope.(map[string]any)["place"], "id,path,place_kind,removed"}, + "page": {wirePage, "counts,items,model,next_cursor,scope"}, + "item": {items[0], "at,check,checks,id,outcome,source,source_missing,title"}, + "source": {items[0].(map[string]any)["source"], "agent,id,kind,title"}, + "check": {items[0].(map[string]any)["check"], "command,outcome"}, + "omitted": {wireMap["omitted"], "filters,models,scopes"}, + } { + if got := keysOf(t, test.value); got != test.want { + t.Errorf("%s keys %s, want %s", name, got, test.want) + } + } + item := page.Items[0] + if item.Source.ID != agent+":3" || item.Source.Agent != agent || item.Source.Title != "Release" || + item.Outcome != "failed" || item.Check.Command != "go test ./internal/parse/..." || item.SourceMissing != "" { + t.Fatalf("item %+v", item) + } + if answer.Groups[3].Scopes[0].Place.Removed != true || parser.Place.Removed { + t.Fatalf("removed: parser %v, bindings %v", parser.Place.Removed, answer.Groups[3].Scopes[0].Place.Removed) + } + next, err := readCompetenceEvidence(reader, appwire.CompetenceEvidenceParams{Model: answer.Model, Scope: parser.ID, Cursor: page.NextCursor, Limit: 2}) + if err != nil || len(next.Items) != 1 || next.Items[0].SourceMissing != "conversation" || next.Items[0].Source.Title != "" || next.NextCursor != "" { + t.Fatalf("second page %+v %v", next, err) + } + if dir := os.Getenv("COMPETENCE_WIRE_FIXTURE_DIR"); dir != "" { + nextJSON, _ := json.MarshalIndent(next, "", " ") + for name, data := range map[string][]byte{ + "competence-map.json": mapJSON, "competence-evidence-page1.json": pageJSON, "competence-evidence-page2.json": nextJSON, + } { + if err := os.WriteFile(filepath.Join(dir, name), append(data, '\n'), 0o644); err != nil { + t.Fatal(err) + } + } + } +} diff --git a/docs/changes/unreleased/0-competence-checked-results.md b/docs/changes/unreleased/0-competence-checked-results.md index 3936167f5d..989e417b2c 100644 --- a/docs/changes/unreleased/0-competence-checked-results.md +++ b/docs/changes/unreleased/0-competence-checked-results.md @@ -5,7 +5,8 @@ pr: 0 surface: [engine, chat, docs] invalidates: - "No record tied a model, a place and a real check outcome together: the project index's verdictBasis was declared but never filled, a checker's verdict is a model's judgement, and the run road's per-check exits lived only in plandb.db, which deleting the task removes. A settled run's declared-check exits (plandb VerdictBasis kind run) are now written, with the one model that answered its work, to /v3/checked-results.jsonl. Work finished before this has no evidence on it and none is inferred." - - "A run-road worker's plandb spend row named the model the seat was BUILT on, even when its calls were answered by a fallback. internal/run now writes one spend row per model that answered (session.Agent.AnsweredUsage, tallied at the one bank door); the rows sum to the same figures, so every price reader is unchanged." + - "A run-road worker's plandb spend row named the model the seat was BUILT on, even when its calls were answered by a fallback, and nothing said so. spend has a new `answered` column (0 for every old row and every AddSpend): internal/run writes one AddAnsweredSpend row per model the responses named (session.Agent.AnsweredUsage, tallied at the one bank door), and money with no named answer stays an unmarked row on the built-on model. The rows sum to the same figures, so every price reader is unchanged." + - "The bare bash tool answered a command killed by a signal (exit -1 from Go) as a SUCCESS, so a run's trajectory recorded a killed check as exit 0. It now answers the shell's 128+signal (137 for SIGKILL) as a failure, a process that exited 0 but held its pipes past WaitDelay as 0, and a wait with no exit status at all as `Command ended without an exit status`, a failure. Background jobs report the same codes." - "`codeaf competence` had no JSON and only the resident's territory/profile view (unchecked done counted as success, stale after 90 days). `--json [--model id] [--scope id]` now prints the checked-results map the app draws (stale after 30 days, a failed check counts against); the text output is unchanged." - "Deleting a task or a conversation removed every trace of how its checks came out. Checked results now outlive it and are reported with source_missing." - "The task kind (`taskKindOf`/`taskKindsMeet`) lived only in internal/session's taskgrade.go. It is now internal/competence's WorkOf/WorkMeets, read by the task ledger unchanged; the competence map groups by the exact kind, not the overlap." diff --git a/internal/competence/ledger.go b/internal/competence/ledger.go index e0b7a6cefe..0ade782688 100644 --- a/internal/competence/ledger.go +++ b/internal/competence/ledger.go @@ -11,9 +11,13 @@ // of its own work, not a calibration or a rating. The map this package draws // cannot be argued with by a model, because no model's words reach it. // -// AN UNDO COUNTS AGAINST. A person taking a change back is the plainest reading -// of it there is, and it is recorded beside the result it reverses rather than -// replacing it, so "passed, then undone" stays both of those facts. +// AN UNDO COUNTS AGAINST A CHECKED RESULT, AND IS NEVER ONE. A person taking a +// change back is the plainest reading of it there is, and it is recorded beside +// the result it reverses rather than replacing it, so "passed, then undone" +// stays both of those facts. An undo of work that has no checked result is +// dropped by every reader: work that finished without a check never counts, +// and taking it back does not make it count. No door writes an undo today — +// the one undo there is takes back quick tasks, which run no declared check. // // ── WHY A LEDGER OF ITS OWN ── // @@ -56,7 +60,7 @@ const ( // row for an id is the one that stands: a reopened task settles again. KindResult = "result" // KindUndo is a person undoing that work. It marks the result with the same - // id, or stands alone as evidence against when the work had no checks. + // id when one stands; with none it is no evidence and is dropped. KindUndo = "undo" ) diff --git a/internal/exec/bare/bash_exit_unix_test.go b/internal/exec/bare/bash_exit_unix_test.go new file mode 100644 index 0000000000..57fd67ff2f --- /dev/null +++ b/internal/exec/bare/bash_exit_unix_test.go @@ -0,0 +1,67 @@ +//go:build !windows + +package bare + +import ( + "context" + "encoding/json" + "errors" + "os/exec" + "strings" + "testing" +) + +func bareBash(t *testing.T) Tool { + t.Helper() + for _, tool := range AllTools(t.TempDir()) { + if tool.Name == "bash" { + return tool + } + } + t.Fatal("no bash tool") + return Tool{} +} + +// A COMMAND A SIGNAL KILLED IS NOT A COMMAND THAT SUCCEEDED. The shell kills +// itself here, outside any deadline the call set, and the answer is the +// shell's own 128+signal — never the success a -1 used to read as. +func TestASignalledCommandIsAFailureWithTheShellsCode(t *testing.T) { + bash := bareBash(t) + for signal, want := range map[string]string{"KILL": "137", "TERM": "143"} { + args, _ := json.Marshal(map[string]string{"command": "echo before; kill -" + signal + " $$; echo after"}) + text, isError, err := bash.Execute(context.Background(), args) + if err != nil { + t.Fatal(err) + } + if !isError || !strings.HasSuffix(strings.TrimSpace(text), "Command exited with code "+want) { + t.Fatalf("SIG%s answered isError=%v %q", signal, isError, text) + } + if !strings.Contains(text, "before") || strings.Contains(text, "after") { + t.Fatalf("SIG%s output %q", signal, text) + } + } + // An ordinary exit keeps its own code, and a clean one is still clean. + args, _ := json.Marshal(map[string]string{"command": "exit 3"}) + if text, isError, _ := bash.Execute(context.Background(), args); !isError || !strings.HasSuffix(strings.TrimSpace(text), "code 3") { + t.Fatalf("exit 3 answered %v %q", isError, text) + } + args, _ = json.Marshal(map[string]string{"command": "true"}) + if _, isError, _ := bash.Execute(context.Background(), args); isError { + t.Fatal("true answered as a failure") + } +} + +// THE WAIT'S OWN ANSWERS: a signal is 128+signal, a WaitDelay is a process that +// exited zero, and anything else is unknown — never zero. +func TestExitCodeFromWaitNeverInventsAZero(t *testing.T) { + cmd := exec.Command("bash", "-c", "kill -KILL $$") + if code := exitCodeFromWait(cmd.Run()); code != 137 { + t.Fatalf("SIGKILL read as %d", code) + } + if code := exitCodeFromWait(exec.ErrWaitDelay); code != 0 { + t.Fatalf("WaitDelay read as %d", code) + } + if code := exitCodeFromWait(errors.New("wait: no child processes")); code != exitUnknown { + t.Fatalf("an unknown wait read as %d", code) + } +} diff --git a/internal/exec/bare/tools.go b/internal/exec/bare/tools.go index 6e0af96223..945307bc7f 100644 --- a/internal/exec/bare/tools.go +++ b/internal/exec/bare/tools.go @@ -16,6 +16,7 @@ import ( "strconv" "strings" "sync" + "syscall" "time" "github.com/Agent-Field/codeaf/internal/guard" @@ -681,8 +682,15 @@ func newBashTool(cwd string, caps Caps) Tool { return commandCutWords(began, text), true, nil } + // AN UNKNOWN ENDING IS NOT A CLEAN ONE. A shell that answered with no + // exit status at all — the wait itself failed — used to fall through + // to success here, and a check whose process nobody saw end was then + // read as a check that passed. exitCode := exitCodeFromWait(waitErr) - if exitCode != 0 && exitCode != -1 { + if exitCode == exitUnknown { + return appendStatus(text, "Command ended without an exit status"), true, nil + } + if exitCode != 0 { return appendStatus(text, fmt.Sprintf("Command exited with code %d", exitCode)), true, nil } @@ -700,16 +708,40 @@ func appendStatus(text, status string) string { return text + "\n\n" + status } -// exitCodeFromWait extracts the exit code from a cmd.Wait() error. Returns -// -1 if the process was killed or the code is unavailable. +// exitUnknown is the code for a wait that produced no exit status at all. +const exitUnknown = -1 + +// exitCodeFromWait is the exit status a cmd.Wait() error carries, in the +// shell's own convention, and never a zero it did not see. +// +// - A PROCESS A SIGNAL KILLED is 128 plus the signal, as a shell reports it +// (137 for SIGKILL). os.ProcessState.ExitCode answers -1 for one, and that +// -1 used to be read as a success by every caller of this tool. +// - exec.ErrWaitDelay is a process that EXITED ZERO whose output pipes a +// grandchild held open past WaitDelay (Go returns an ExitError instead when +// the exit was non-zero), so it is a zero. +// - Anything else is [exitUnknown]: nobody saw the process end. func exitCodeFromWait(err error) int { if err == nil { return 0 } - if exitErr, ok := err.(*exec.ExitError); ok { - return exitErr.ExitCode() + if errors.Is(err, exec.ErrWaitDelay) { + return 0 + } + var exitErr *exec.ExitError + if !errors.As(err, &exitErr) { + return exitUnknown + } + if status, ok := exitErr.Sys().(interface { + Signaled() bool + Signal() syscall.Signal + }); ok && status.Signaled() { + return 128 + int(status.Signal()) + } + if code := exitErr.ExitCode(); code >= 0 { + return code } - return -1 + return exitUnknown } // getShellConfig mirrors pi's shell.js:getShellConfig on Unix: /bin/bash if diff --git a/internal/manual/chat/running-from-the-terminal.md b/internal/manual/chat/running-from-the-terminal.md index a9eb9aca98..6c72ea6f7f 100644 --- a/internal/manual/chat/running-from-the-terminal.md +++ b/internal/manual/chat/running-from-the-terminal.md @@ -1030,8 +1030,9 @@ proposal named — actually ran against the work and came back with a real exit check exiting zero is a pass; any failing is a failure. It is credited to the one model that answered the task's work, after any fallback. A task whose work was answered by two models, or that another task joined while it ran, is not counted, because nobody could say whose it -was. A checker's opinion, a task that finished with no check, a check that never started or -was cut off, and anything a model says about its own work are never counted. Undo does not +was. A checker's opinion, a task that finished with no check, a check that could not start, +and anything a model says about its own work are never counted; a check that was killed +or cut off counts as failed. Undo does not appear yet: the only undo there is takes back quick tasks, which run no checks. Results are grouped by place (the repository or folder) and kind of work (the words of the diff --git a/internal/plandb/persist.go b/internal/plandb/persist.go index 569468ba4e..79c40d8872 100644 --- a/internal/plandb/persist.go +++ b/internal/plandb/persist.go @@ -358,6 +358,13 @@ func migrateColumns(tx *sql.Tx) error { return err } } + // A SPEND ROW SAYS WHETHER ITS MODEL ANSWERED. Rows written before the + // column, and every row [Store.AddSpend] writes, name the model a seat was + // built on and carry 0; only [Store.AddAnsweredSpend] writes 1, for money + // the response itself said its model answered. + if err := ensureColumn(tx, "spend", "answered", "INTEGER", "0"); err != nil { + return err + } return ensureColumn(tx, "notes", "from", "TEXT", "'worker'") } diff --git a/internal/plandb/spend_answered_test.go b/internal/plandb/spend_answered_test.go new file mode 100644 index 0000000000..65bd170120 --- /dev/null +++ b/internal/plandb/spend_answered_test.go @@ -0,0 +1,68 @@ +package plandb + +import ( + "database/sql" + "path/filepath" + "testing" +) + +// A LEDGER FROM BEFORE ANSWERED CHARGES EXISTED READS AS NOT ANSWERED. Its rows +// named the model a seat was built on, and the migration may not promote one. +func TestOldSpendRowsAreNotAnsweredAfterTheMigration(t *testing.T) { + path := filepath.Join(t.TempDir(), "plan.db") + store, err := Open(path, "p", "root", "Fix", "Fix it") + if err != nil { + t.Fatal(err) + } + store.Close() + db, err := sql.Open("sqlite", "file:"+path) + if err != nil { + t.Fatal(err) + } + for _, statement := range []string{ + `ALTER TABLE spend DROP COLUMN answered`, + `INSERT INTO spend (task_id, model, role, usd, in_tokens, out_tokens, at) VALUES ('root', 'm/built', 'work', 0.02, 1, 1, '2026-10-01T00:00:00Z')`, + } { + if _, err := db.Exec(statement); err != nil { + t.Fatalf("%s: %v", statement, err) + } + } + db.Close() + + store, err = Open(path, "p", "root", "Fix", "Fix it") + if err != nil { + t.Fatal(err) + } + defer store.Close() + if err := store.AddAnsweredSpend("root", "m/answered", RoleWork, 0.01, 1, 1); err != nil { + t.Fatal(err) + } + if err := store.AddAnsweredSpend("root", " ", RoleWork, 0.01, 1, 1); err == nil { + t.Fatal("an answered charge with no model was written") + } + if err := store.AddSpend("root", "m/configured", RoleWork, 0.01, 1, 1); err != nil { + t.Fatal(err) + } + read, err := sql.Open("sqlite", "file:"+path+"?mode=ro") + if err != nil { + t.Fatal(err) + } + defer read.Close() + rows, err := read.Query(`SELECT model, answered FROM spend ORDER BY model`) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + got := map[string]int{} + for rows.Next() { + var model string + var answered int + if err := rows.Scan(&model, &answered); err != nil { + t.Fatal(err) + } + got[model] = answered + } + if len(got) != 3 || got["m/built"] != 0 || got["m/configured"] != 0 || got["m/answered"] != 1 { + t.Fatalf("answered marks %v", got) + } +} diff --git a/internal/plandb/store.go b/internal/plandb/store.go index 20a3653d14..eb030b0c60 100644 --- a/internal/plandb/store.go +++ b/internal/plandb/store.go @@ -2906,9 +2906,27 @@ func summarize(value state) Summary { // role it played, the dollars and the token counts. The ledger is append-only // — the summary reads it back and nothing here rewrites it — so the row lives // outside the whole-plan rewrite every other writer performs, and its own -// transaction is all it needs. Nothing in the runtime calls this yet; the -// wiring that spends is a later change. +// transaction is all it needs. +// +// The model it names is NOT a fact about who answered: it is whatever the +// caller had, usually the model a seat was built on. [Store.AddAnsweredSpend] +// is the door for money the answer itself attributed. func (s *Store) AddSpend(taskID, model, role string, usd float64, inTokens, outTokens int) error { + return s.addSpend(taskID, model, role, usd, inTokens, outTokens, false) +} + +// AddAnsweredSpend records one charge exactly as [Store.AddSpend] does, under +// the model that ANSWERED the calls it paid for — the name the responses gave, +// after any fallback — and marks the row so. Every reader of the money reads it +// the same; a reader asking WHO did the work can tell it from a built-on name. +func (s *Store) AddAnsweredSpend(taskID, model, role string, usd float64, inTokens, outTokens int) error { + if strings.TrimSpace(model) == "" { + return errors.New("an answered charge names the model that answered") + } + return s.addSpend(taskID, model, role, usd, inTokens, outTokens, true) +} + +func (s *Store) addSpend(taskID, model, role string, usd float64, inTokens, outTokens int, answered bool) error { s.mu.Lock() defer s.mu.Unlock() tx, err := s.beginWrite() @@ -2916,9 +2934,13 @@ func (s *Store) AddSpend(taskID, model, role string, usd float64, inTokens, outT return err } defer tx.Rollback() - if _, err := tx.Exec(`INSERT INTO spend (task_id, model, role, usd, in_tokens, out_tokens, at) VALUES (?, ?, ?, ?, ?, ?, ?)`, + mark := 0 + if answered { + mark = 1 + } + if _, err := tx.Exec(`INSERT INTO spend (task_id, model, role, usd, in_tokens, out_tokens, at, answered) VALUES (?, ?, ?, ?, ?, ?, ?, ?)`, strings.TrimSpace(strings.TrimPrefix(taskID, "t-")), strings.TrimSpace(model), strings.TrimSpace(role), - usd, inTokens, outTokens, formatTime(s.now().UTC())); err != nil { + usd, inTokens, outTokens, formatTime(s.now().UTC()), mark); err != nil { return err } return tx.Commit() diff --git a/internal/run/bashworker.go b/internal/run/bashworker.go index ba019cfb86..07d234ed76 100644 --- a/internal/run/bashworker.go +++ b/internal/run/bashworker.go @@ -796,15 +796,19 @@ func (w *BashWorker) storeEnding(id string) (storeEnding, bool) { return storeEnding{}, false } -// recordSpend writes the task's spend rows: one per model that ANSWERED the -// seat's calls — after any fallback, by the response's own name — with the -// role its shape gave it at the moment the turn ended and the dollars and -// tokens those calls cost. The rows sum to what the seat spent, so every -// reader of the task's price reads the figure it always did; what changed is -// that a seat whose model failed over is no longer written down as the model -// it was built on. A call whose answer named no model is a row with no model, -// which a reader takes as unknown. With no breakdown at all the one row names -// the seat's model, as it always has. +// recordSpend writes the task's spend rows, with the role its shape gave it at +// the moment the turn ended and the dollars and tokens its calls cost. The rows +// always sum to what the seat spent, so every reader of the task's price reads +// the figure it always did. What they say about WHO answered is kept apart: +// +// - money the responses attributed to a named model is one ANSWERED row per +// model ([plandb.Store.AddAnsweredSpend]), after any fallback; +// - money whose answer named no model is a row with no model; +// - money the breakdown does not cover — none was kept, or the seat's total +// holds figures from before this process tallied them — is one row on the +// model the seat was built on, as it always was, and NOT marked answered. +// +// Only the first is a fact about who did the work; the other two are billing. // // It is best-effort whole and last, because the money is already in the Report // the supervisor absorbs and in the session's usage ledger; a store that @@ -819,21 +823,29 @@ func (w *BashWorker) recordSpend(taskID string, used session.Usage, answered map if err != nil { role = plandb.RoleWork } - if len(answered) == 0 { - _ = w.store.AddSpend(taskID, w.model, role, used.CostUSD, used.Input, used.Output) - return - } models := make([]string, 0, len(answered)) for model := range answered { models = append(models, model) } sort.Strings(models) + rest := used for _, model := range models { share := answered[model] if share.CostUSD == 0 && share.Input == 0 && share.Output == 0 { continue } - _ = w.store.AddSpend(taskID, model, role, share.CostUSD, share.Input, share.Output) + rest.CostUSD -= share.CostUSD + rest.Input -= share.Input + rest.Output -= share.Output + if model == "" { + _ = w.store.AddSpend(taskID, "", role, share.CostUSD, share.Input, share.Output) + continue + } + _ = w.store.AddAnsweredSpend(taskID, model, role, share.CostUSD, share.Input, share.Output) + } + // A cent's billionth is float noise from subtracting the shares, not money. + if rest.CostUSD > 1e-9 || rest.Input > 0 || rest.Output > 0 { + _ = w.store.AddSpend(taskID, w.model, role, max(rest.CostUSD, 0), max(rest.Input, 0), max(rest.Output, 0)) } } diff --git a/internal/run/bashworker_answered_test.go b/internal/run/bashworker_answered_test.go index 2a5aa221a5..ea01e5bddd 100644 --- a/internal/run/bashworker_answered_test.go +++ b/internal/run/bashworker_answered_test.go @@ -2,6 +2,7 @@ package run import ( "database/sql" + "math" "path/filepath" "testing" @@ -9,58 +10,84 @@ import ( "github.com/Agent-Field/codeaf/internal/session" ) -// spendRows is the store's spend ledger for one task, model to dollars. -func spendRows(t *testing.T, path, task string) map[string]float64 { +type spendRow struct { + model string + answered bool +} + +// spendRows is the store's spend ledger for one task: dollars by model and +// whether the row says that model answered. +func spendRows(t *testing.T, path, task string) map[spendRow]float64 { t.Helper() db, err := sql.Open("sqlite", "file:"+path+"?mode=ro") if err != nil { t.Fatal(err) } defer db.Close() - rows, err := db.Query(`SELECT model, usd FROM spend WHERE task_id = ?`, task) + rows, err := db.Query(`SELECT model, answered, usd FROM spend WHERE task_id = ?`, task) if err != nil { t.Fatal(err) } defer rows.Close() - out := map[string]float64{} + out := map[spendRow]float64{} for rows.Next() { - var model string + var row spendRow var usd float64 - if err := rows.Scan(&model, &usd); err != nil { + if err := rows.Scan(&row.model, &row.answered, &usd); err != nil { t.Fatal(err) } - out[model] += usd + out[row] += usd } return out } -// A SEAT WHOSE MODEL FAILED OVER IS WRITTEN DOWN AS THE MODELS THAT ANSWERED, -// and the rows still sum to what it spent; with no breakdown the one row names -// the seat's model, as it always did. -func TestSpendIsWrittenUnderTheModelsThatAnswered(t *testing.T) { +func spendStore(t *testing.T) *plandb.Store { + t.Helper() store, err := plandb.Open(filepath.Join(t.TempDir(), "plan.db"), "p", "root", "Fix", "Fix it") if err != nil { t.Fatal(err) } - defer store.Close() + t.Cleanup(func() { store.Close() }) + return store +} + +// A SEAT WHOSE MODEL FAILED OVER IS WRITTEN DOWN AS THE MODEL THAT ANSWERED, +// marked answered, and the seat's built-on model gets no answered row. +func TestAFallbackIsWrittenUnderTheModelThatAnswered(t *testing.T) { + store := spendStore(t) worker := &BashWorker{store: store, model: "m/built"} - used := session.Usage{Input: 30, Output: 12, CostUSD: 0.04} - worker.recordSpend("root", used, map[string]session.Usage{ - "m/fallback": {Input: 20, Output: 7, CostUSD: 0.03}, - "m/built": {Input: 10, Output: 5, CostUSD: 0.01}, - }) + worker.recordSpend("root", session.Usage{Input: 20, Output: 7, CostUSD: 0.03}, + map[string]session.Usage{"m/fallback": {Input: 20, Output: 7, CostUSD: 0.03}}) rows := spendRows(t, store.Path(), "root") - if len(rows) != 2 || rows["m/fallback"] != 0.03 || rows["m/built"] != 0.01 { + if len(rows) != 1 || rows[spendRow{"m/fallback", true}] != 0.03 { t.Fatalf("rows %+v", rows) } +} + +// THE CONFIGURED MODEL IS NEVER AN ANSWERED ONE: with no breakdown the row +// names the built-on model for billing and is not marked; money the breakdown +// does not cover, and money whose answer named no model, are unmarked too — +// and every row still sums to what the seat spent. +func TestMoneyNoAnswerCoversIsBilledButNeverMarkedAnswered(t *testing.T) { + store := spendStore(t) + (&BashWorker{store: store, model: "m/built"}).recordSpend("root", session.Usage{Input: 30, Output: 12, CostUSD: 0.04}, nil) + if rows := spendRows(t, store.Path(), "root"); len(rows) != 1 || rows[spendRow{"m/built", false}] != 0.04 { + t.Fatalf("no breakdown wrote %+v", rows) + } - other, err := plandb.Open(filepath.Join(t.TempDir(), "plan.db"), "p", "root", "Fix", "Fix it") - if err != nil { - t.Fatal(err) + partial := spendStore(t) + (&BashWorker{store: partial, model: "m/built"}).recordSpend("root", session.Usage{Input: 40, Output: 15, CostUSD: 0.05}, + map[string]session.Usage{ + "m/fallback": {Input: 20, Output: 7, CostUSD: 0.03}, + "": {Input: 5, Output: 3, CostUSD: 0.005}, + }) + rows := spendRows(t, partial.Path(), "root") + total := 0.0 + for _, usd := range rows { + total += usd } - defer other.Close() - (&BashWorker{store: other, model: "m/built"}).recordSpend("root", used, nil) - if rows := spendRows(t, other.Path(), "root"); len(rows) != 1 || rows["m/built"] != 0.04 { - t.Fatalf("no breakdown wrote %+v", rows) + if len(rows) != 3 || rows[spendRow{"m/fallback", true}] != 0.03 || rows[spendRow{"", false}] != 0.005 || + rows[spendRow{"m/built", false}] < 0.0149 || math.Abs(total-0.05) > 1e-9 { + t.Fatalf("partial breakdown wrote %+v", rows) } } diff --git a/internal/session/competence.go b/internal/session/competence.go index 44cf841568..c80bf61c86 100644 --- a/internal/session/competence.go +++ b/internal/session/competence.go @@ -54,9 +54,10 @@ import ( // the same tree. // // THE MODEL IS THE ONE THAT ANSWERED THE WORK — every model the run's work -// seats' calls were answered by, after any fallback, read off its spend ledger -// (internal/run's recordSpend, from [Agent.AnsweredUsage]) — and the run is -// written only when that is exactly one known model. Two is a run nobody can +// seats' calls were answered by, after any fallback, read off its spend +// ledger's ANSWERED rows (internal/run's recordSpend, from +// [Agent.AnsweredUsage]) — and the run is written only when every work row is +// answered and they name exactly one model. Two is a run nobody can // attribute; none, or an answer that named no model, is a run nobody can name. // Neither is guessed, and the model a seat was BUILT on is never read here. A // program's run is work an outside program did, and is not this ledger's. @@ -121,10 +122,9 @@ func (a *Agent) recordRunProven(run *beltRun) { // check gave failed, and the shell's own "could not run it" codes (126, 127) // and a negative code are not a reading of the work. // -// ONE GAP IS UPSTREAM OF THIS READER: a check killed by a signal outside its -// call's own deadline exits -1, which the bare bash answers as a success and -// the trajectory then records as 0. Nothing that reaches this file can tell -// the two apart. +// A check a signal killed arrives as the shell's 128+signal (internal/exec/ +// bare), and one the run cut off or whose ending nobody saw arrives as 1 (the +// run's trajectory): both are failures, and neither is ever a zero. func exitReading(code int) (competence.Outcome, bool) { switch { case code == 0: @@ -163,9 +163,14 @@ func runTasks(all []*plandb.Task, root string) []*plandb.Task { // planOneWorkModel is the one model that answered the named tasks' WORK, read // off the spend ledger on its own read-only connection: rows whose role was // "work" when the money was spent, so a planner's and a checker's calls are -// never the work's. It is false when the work was answered by no model, by -// more than one, or by a call whose answer named none — an unknown is not the -// seat's model, and a mixed run is not any one model's. +// never the work's. +// +// EVERY WORK ROW MUST BE AN ANSWERED ONE ([plandb.Store.AddAnsweredSpend]). A +// row on the model a seat was built on — every row written before answered +// rows existed, a resumed run's earlier seats, money no breakdown covered — +// says what was configured, not what answered, and one such row makes the +// whole run unattributable even when its name matches the answered rows. It is +// false too when the work was answered by no model or by more than one. func planOneWorkModel(path string, ids []string) (string, bool) { if len(ids) == 0 { return "", false @@ -181,7 +186,7 @@ func planOneWorkModel(path string, ids []string) (string, bool) { for _, id := range ids { args = append(args, id) } - rows, err := db.Query(`SELECT DISTINCT TRIM(model) FROM spend WHERE role = ? AND task_id IN (`+marks+`) LIMIT 2`, args...) + rows, err := db.Query(`SELECT DISTINCT TRIM(model), answered FROM spend WHERE role = ? AND task_id IN (`+marks+`) LIMIT 3`, args...) if err != nil { return "", false } @@ -189,12 +194,13 @@ func planOneWorkModel(path string, ids []string) (string, bool) { var models []string for rows.Next() { var model string - if rows.Scan(&model) != nil { + var answered int + if rows.Scan(&model, &answered) != nil || answered != 1 || model == "" { return "", false } models = append(models, model) } - if rows.Err() != nil || len(models) != 1 || models[0] == "" { + if rows.Err() != nil || len(models) != 1 { return "", false } return models[0], true diff --git a/internal/session/competence_test.go b/internal/session/competence_test.go index 3e9d74324a..397b7ff6fe 100644 --- a/internal/session/competence_test.go +++ b/internal/session/competence_test.go @@ -6,6 +6,7 @@ import ( "math" "os" "path/filepath" + "strings" "testing" "time" @@ -49,7 +50,10 @@ func journalledAgent(t *testing.T) (*Agent, string) { } // runFixture is a settled run's plan store: a root, one work task spent on the -// given models, and one check task whose basis is the given exits. +// given models, and one check task whose basis is the given exits. A model is +// an ANSWERED charge; "built:" is a charge on the model a seat was built +// on, the only kind written before answered charges existed; "" is a charge +// whose answer named no model. func runFixture(t *testing.T, agent *Agent, ground string, models []string, exits map[string]int) *beltRun { t.Helper() store, err := plandb.Open(filepath.Join(t.TempDir(), "plandb.db"), "p", "7", "Fix the parser", "fix it", agent.journalID()) @@ -72,7 +76,13 @@ func runFixture(t *testing.T, agent *Agent, ground string, models []string, exit t.Fatal(err) } for _, model := range models { - if err := store.AddSpend("t-work", model, plandb.RoleWork, 0.01, 10, 10); err != nil { + var err error + if built, ok := strings.CutPrefix(model, "built:"); ok || model == "" { + err = store.AddSpend("t-work", built, plandb.RoleWork, 0.01, 10, 10) + } else { + err = store.AddAnsweredSpend("t-work", model, plandb.RoleWork, 0.01, 10, 10) + } + if err != nil { t.Fatal(err) } } @@ -107,8 +117,8 @@ func TestASettledRunWritesItsCheckExitsOnItsWorkModel(t *testing.T) { } } -// A RUN NOBODY CAN ATTRIBUTE IS NO EVIDENCE: two work models, or none, or no -// check that ran. +// A RUN NOBODY CAN ATTRIBUTE IS NO EVIDENCE: two answering models, none, an +// unknown one, a built-on name, or no check that ran. func TestARunWithNoSingleWorkModelOrNoRunCheckWritesNothing(t *testing.T) { for name, fixture := range map[string]struct { models []string @@ -119,6 +129,11 @@ func TestARunWithNoSingleWorkModelOrNoRunCheckWritesNothing(t *testing.T) { "no run": {[]string{"m/a"}, map[string]int{"missing-tool": 127}}, // An answer that named no model is unknown, never the one beside it. "unknown answer": {[]string{"m/a", ""}, map[string]int{"go test ./...": 0}}, + // THE MODEL A SEAT WAS BUILT ON IS NEVER THE CHECKED MODEL: an old + // ledger, or a resumed run's earlier seat, with real exits beside it. + "built model only": {[]string{"built:m/flash"}, map[string]int{"go test ./...": 0}}, + // And one such row spoils a run even when its name matches the answer. + "built beside answered": {[]string{"m/flash", "built:m/flash"}, map[string]int{"go test ./...": 0}}, } { agent, dir := journalledAgent(t) agent.recordRunProven(runFixture(t, agent, dir, fixture.models, fixture.exits)) From d0e2dbd813585e45c9fcc5f3c413fb7d8a2839b4 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 19:35:17 -0400 Subject: [PATCH 048/459] competence: count every answer, free or usage-less, and never record a run with a check that could not run An answer is now counted where each response arrives (Agent.Answers), before the usage check, so a free or usage-less answer from a second model makes the run unattributable instead of vanishing; late receipts bank money but never make an answer. recordSpend writes an answered row per answering model even at zero money, and decides the unmarked remainder by exact call and token coverage rather than a dollar threshold. recordRunProven writes nothing when any declared check exited 126, 127 or negative, instead of folding the rest. Co-Authored-By: Claude Opus 5.5 --- .../0-competence-checked-results.md | 7 +- .../manual/chat/running-from-the-terminal.md | 9 +- internal/run/bashworker.go | 74 ++++++++++------- internal/run/bashworker_answered_test.go | 83 ++++++++++++++++++- internal/session/competence.go | 56 +++++++++++-- internal/session/competence_test.go | 59 +++++++++++-- internal/session/loop.go | 4 + internal/session/session.go | 5 ++ 8 files changed, 242 insertions(+), 55 deletions(-) diff --git a/docs/changes/unreleased/0-competence-checked-results.md b/docs/changes/unreleased/0-competence-checked-results.md index 989e417b2c..c24125b7d2 100644 --- a/docs/changes/unreleased/0-competence-checked-results.md +++ b/docs/changes/unreleased/0-competence-checked-results.md @@ -5,7 +5,7 @@ pr: 0 surface: [engine, chat, docs] invalidates: - "No record tied a model, a place and a real check outcome together: the project index's verdictBasis was declared but never filled, a checker's verdict is a model's judgement, and the run road's per-check exits lived only in plandb.db, which deleting the task removes. A settled run's declared-check exits (plandb VerdictBasis kind run) are now written, with the one model that answered its work, to /v3/checked-results.jsonl. Work finished before this has no evidence on it and none is inferred." - - "A run-road worker's plandb spend row named the model the seat was BUILT on, even when its calls were answered by a fallback, and nothing said so. spend has a new `answered` column (0 for every old row and every AddSpend): internal/run writes one AddAnsweredSpend row per model the responses named (session.Agent.AnsweredUsage, tallied at the one bank door), and money with no named answer stays an unmarked row on the built-on model. The rows sum to the same figures, so every price reader is unchanged." + - "A run-road worker's plandb spend row named the model the seat was BUILT on, even when its calls were answered by a fallback, and nothing said so. spend has a new `answered` column (0 for every old row and every AddSpend): internal/run writes one AddAnsweredSpend row per model that gave an answer (session.Agent.Answers, counted where each response arrives, even one that cost nothing or carried no usage; late receipts add money, never an answer) carrying that model's banked share (session.Agent.AnsweredUsage). Money with no named answer, and calls or tokens the shares do not exactly cover, stay unmarked rows (the remainder on the built-on model). The rows sum to the same figures, so every price reader is unchanged." - "The bare bash tool answered a command killed by a signal (exit -1 from Go) as a SUCCESS, so a run's trajectory recorded a killed check as exit 0. It now answers the shell's 128+signal (137 for SIGKILL) as a failure, a process that exited 0 but held its pipes past WaitDelay as 0, and a wait with no exit status at all as `Command ended without an exit status`, a failure. Background jobs report the same codes." - "`codeaf competence` had no JSON and only the resident's territory/profile view (unchecked done counted as success, stale after 90 days). `--json [--model id] [--scope id]` now prints the checked-results map the app draws (stale after 30 days, a failed check counts against); the text output is unchanged." - "Deleting a task or a conversation removed every trace of how its checks came out. Checked results now outlive it and are reported with source_missing." @@ -13,8 +13,9 @@ invalidates: --- Not recorded, on purpose: a run another approved task joined (no fact says which -check proved which goal), a run answered by more than one model or by an unnamed -one, and undo — `quick.undo` takes back quick tasks, which run no declared checks. +check proved which goal), a run answered by more than one model (a free answer +counts) or by an unnamed one, a run with any declared check the shell could not +start (126/127 or negative — never folded as a pass from the rest), and undo — `quick.undo` takes back quick tasks, which run no declared checks. The app wire is in docs/design/app-v3/PROTOCOL.md under V76; an engine without it answers `unknown_method`. Both reads are bounded (newest 32 MiB, 10 s, ≤300 scopes) and call no model. diff --git a/internal/manual/chat/running-from-the-terminal.md b/internal/manual/chat/running-from-the-terminal.md index 6c72ea6f7f..6024ca1d14 100644 --- a/internal/manual/chat/running-from-the-terminal.md +++ b/internal/manual/chat/running-from-the-terminal.md @@ -1028,11 +1028,10 @@ Competence draws, read from a ledger of checked results kept beside the spending A checked result is one finished task whose declared checks — the tests, lint or build its proposal named — actually ran against the work and came back with a real exit status. Every check exiting zero is a pass; any failing is a failure. It is credited to the one model that -answered the task's work, after any fallback. A task whose work was answered by two models, -or that another task joined while it ran, is not counted, because nobody could say whose it -was. A checker's opinion, a task that finished with no check, a check that could not start, -and anything a model says about its own work are never counted; a check that was killed -or cut off counts as failed. Undo does not +answered the task's work, after any fallback, even a free one. A task answered by two models, +that another task joined, or with a check that could not start, is not counted at all. A +checker's opinion, a task that finished with no check, and anything a model says about its +own work are never counted; a check that was killed or cut off counts as failed. Undo does not appear yet: the only undo there is takes back quick tasks, which run no checks. Results are grouped by place (the repository or folder) and kind of work (the words of the diff --git a/internal/run/bashworker.go b/internal/run/bashworker.go index 07d234ed76..4131328b57 100644 --- a/internal/run/bashworker.go +++ b/internal/run/bashworker.go @@ -134,7 +134,7 @@ func (w *BashWorker) Run(ctx context.Context, task plandb.Task) (rep Report, run defer func() { used := settledWorkerUsage(agent, &receipts) rep.USD, rep.TokensIn, rep.TokensOut = used.CostUSD, used.Input, used.Output - w.recordSpend(task.ID, used, agent.AnsweredUsage()) + w.recordSpend(task.ID, used, agent.AnsweredUsage(), agent.Answers()) }() runCtx, stop := context.WithCancel(provider.WithReceiptPending(ctx, receipts.owe)) defer stop() @@ -801,52 +801,68 @@ func (w *BashWorker) storeEnding(id string) (storeEnding, bool) { // always sum to what the seat spent, so every reader of the task's price reads // the figure it always did. What they say about WHO answered is kept apart: // -// - money the responses attributed to a named model is one ANSWERED row per -// model ([plandb.Store.AddAnsweredSpend]), after any fallback; -// - money whose answer named no model is a row with no model; -// - money the breakdown does not cover — none was kept, or the seat's total -// holds figures from before this process tallied them — is one row on the -// model the seat was built on, as it always was, and NOT marked answered. +// - every model that GAVE AN ANSWER ([session.Agent.Answers]) is one ANSWERED +// row ([plandb.Store.AddAnsweredSpend]) carrying whatever money its calls +// banked — none, for a free or unmetered answer, which is still that model +// doing the work; +// - answers from no named model, and money banked under a model that gave no +// answer here (a late receipt), are unmarked rows; +// - money the per-model shares do not cover — none were kept, or the seat's +// total holds figures from before this process tallied them — is one row on +// the model the seat was built on, as it always was, and NOT marked. // -// Only the first is a fact about who did the work; the other two are billing. +// Only the first is a fact about who did the work; the rest are billing, and any +// one of them leaves the work unattributable. // // It is best-effort whole and last, because the money is already in the Report // the supervisor absorbs and in the session's usage ledger; a store that // refuses this write leaves the run's own counters true, and a row here is a -// reading of the run rather than the run's book. A turn that spent nothing -// writes nothing rather than a zero row somebody reads as a figure. -func (w *BashWorker) recordSpend(taskID string, used session.Usage, answered map[string]session.Usage) { - if used.CostUSD == 0 && used.Input == 0 && used.Output == 0 { +// reading of the run rather than the run's book. A seat that neither spent nor +// was answered writes nothing. +func (w *BashWorker) recordSpend(taskID string, used session.Usage, shares map[string]session.Usage, answers map[string]int) { + if used.CostUSD == 0 && used.Input == 0 && used.Output == 0 && used.Calls == 0 && len(answers) == 0 { return } role, err := w.store.RoleOf(taskID) if err != nil { role = plandb.RoleWork } - models := make([]string, 0, len(answered)) - for model := range answered { + seen := map[string]bool{} + var models []string + for model := range shares { + seen[model] = true models = append(models, model) } + for model := range answers { + if !seen[model] { + models = append(models, model) + } + } sort.Strings(models) - rest := used + var covered session.Usage for _, model := range models { - share := answered[model] - if share.CostUSD == 0 && share.Input == 0 && share.Output == 0 { - continue - } - rest.CostUSD -= share.CostUSD - rest.Input -= share.Input - rest.Output -= share.Output - if model == "" { - _ = w.store.AddSpend(taskID, "", role, share.CostUSD, share.Input, share.Output) - continue + share := shares[model] + covered.CostUSD += share.CostUSD + covered.Input += share.Input + covered.Output += share.Output + covered.Calls += share.Calls + answered := answers[model] > 0 + switch { + case answered && model != "": + _ = w.store.AddAnsweredSpend(taskID, model, role, share.CostUSD, share.Input, share.Output) + case answered || share != (session.Usage{}): + _ = w.store.AddSpend(taskID, model, role, share.CostUSD, share.Input, share.Output) } - _ = w.store.AddAnsweredSpend(taskID, model, role, share.CostUSD, share.Input, share.Output) } - // A cent's billionth is float noise from subtracting the shares, not money. - if rest.CostUSD > 1e-9 || rest.Input > 0 || rest.Output > 0 { - _ = w.store.AddSpend(taskID, w.model, role, max(rest.CostUSD, 0), max(rest.Input, 0), max(rest.Output, 0)) + // THE SHARES COVER THE SEAT WHEN THEY ACCOUNT FOR EVERY CALL AND TOKEN IT + // BANKED — whole numbers, compared exactly. Then any difference in dollars + // is the order the same charges were added in, not money; otherwise the + // difference is real, uncovered spend and is billed whole. + if covered.Calls == used.Calls && covered.Input == used.Input && covered.Output == used.Output { + return } + _ = w.store.AddSpend(taskID, w.model, role, max(used.CostUSD-covered.CostUSD, 0), + max(used.Input-covered.Input, 0), max(used.Output-covered.Output, 0)) } // stepRecorder appends one worker's step lines, and keeps the two readings diff --git a/internal/run/bashworker_answered_test.go b/internal/run/bashworker_answered_test.go index ea01e5bddd..d536889f20 100644 --- a/internal/run/bashworker_answered_test.go +++ b/internal/run/bashworker_answered_test.go @@ -57,7 +57,7 @@ func TestAFallbackIsWrittenUnderTheModelThatAnswered(t *testing.T) { store := spendStore(t) worker := &BashWorker{store: store, model: "m/built"} worker.recordSpend("root", session.Usage{Input: 20, Output: 7, CostUSD: 0.03}, - map[string]session.Usage{"m/fallback": {Input: 20, Output: 7, CostUSD: 0.03}}) + map[string]session.Usage{"m/fallback": {Input: 20, Output: 7, CostUSD: 0.03}}, map[string]int{"m/fallback": 1}) rows := spendRows(t, store.Path(), "root") if len(rows) != 1 || rows[spendRow{"m/fallback", true}] != 0.03 { t.Fatalf("rows %+v", rows) @@ -70,7 +70,7 @@ func TestAFallbackIsWrittenUnderTheModelThatAnswered(t *testing.T) { // and every row still sums to what the seat spent. func TestMoneyNoAnswerCoversIsBilledButNeverMarkedAnswered(t *testing.T) { store := spendStore(t) - (&BashWorker{store: store, model: "m/built"}).recordSpend("root", session.Usage{Input: 30, Output: 12, CostUSD: 0.04}, nil) + (&BashWorker{store: store, model: "m/built"}).recordSpend("root", session.Usage{Input: 30, Output: 12, CostUSD: 0.04}, nil, nil) if rows := spendRows(t, store.Path(), "root"); len(rows) != 1 || rows[spendRow{"m/built", false}] != 0.04 { t.Fatalf("no breakdown wrote %+v", rows) } @@ -80,7 +80,7 @@ func TestMoneyNoAnswerCoversIsBilledButNeverMarkedAnswered(t *testing.T) { map[string]session.Usage{ "m/fallback": {Input: 20, Output: 7, CostUSD: 0.03}, "": {Input: 5, Output: 3, CostUSD: 0.005}, - }) + }, map[string]int{"m/fallback": 1, "": 1}) rows := spendRows(t, partial.Path(), "root") total := 0.0 for _, usd := range rows { @@ -91,3 +91,80 @@ func TestMoneyNoAnswerCoversIsBilledButNeverMarkedAnswered(t *testing.T) { t.Fatalf("partial breakdown wrote %+v", rows) } } + +// AN ANSWER IS A FACT ABOUT WHO WORKED WHETHER OR NOT IT COST ANYTHING. A free +// model's answer, and an answer whose response carried no usage at all, is an +// answered row of no money; a model that banked money here but gave no answer +// (a late receipt) is billing only. +func TestAnAnswerIsWrittenEvenWhenItCostNothing(t *testing.T) { + free := spendStore(t) + (&BashWorker{store: free, model: "m/built"}).recordSpend("root", session.Usage{Calls: 2}, + map[string]session.Usage{"m/free": {Calls: 2}}, map[string]int{"m/free": 2}) + if rows := spendRows(t, free.Path(), "root"); len(rows) != 1 || rows[spendRow{"m/free", true}] != 0 { + t.Fatalf("a free answer wrote %+v", rows) + } + + bare := spendStore(t) + (&BashWorker{store: bare, model: "m/built"}).recordSpend("root", session.Usage{}, nil, map[string]int{"m/quiet": 1}) + if rows := spendRows(t, bare.Path(), "root"); len(rows) != 1 || rows[spendRow{"m/quiet", true}] != 0 { + t.Fatalf("an answer with no usage wrote %+v", rows) + } + + paid := spendStore(t) + (&BashWorker{store: paid, model: "m/built"}).recordSpend("root", session.Usage{Input: 9, Calls: 2, CostUSD: 0.02}, + map[string]session.Usage{"m/a": {Input: 9, Calls: 1, CostUSD: 0.02}, "m/b": {Calls: 1}}, + map[string]int{"m/a": 1, "m/b": 1}) + if rows := spendRows(t, paid.Path(), "root"); len(rows) != 2 || rows[spendRow{"m/a", true}] != 0.02 { + t.Fatalf("a paid and a free answer wrote %+v", rows) + } else if _, ok := rows[spendRow{"m/b", true}]; !ok { + t.Fatalf("the free answer was dropped: %+v", rows) + } + + // B's response carried no usage: no share, but still B's answer. + quiet := spendStore(t) + (&BashWorker{store: quiet, model: "m/built"}).recordSpend("root", session.Usage{Input: 9, Calls: 1, CostUSD: 0.02}, + map[string]session.Usage{"m/a": {Input: 9, Calls: 1, CostUSD: 0.02}}, map[string]int{"m/a": 1, "m/b": 1}) + if rows := spendRows(t, quiet.Path(), "root"); len(rows) != 2 { + t.Fatalf("a paid answer and one with no usage wrote %+v", rows) + } else if _, ok := rows[spendRow{"m/b", true}]; !ok { + t.Fatalf("the answer with no usage was dropped: %+v", rows) + } + + unnamed := spendStore(t) + (&BashWorker{store: unnamed, model: "m/built"}).recordSpend("root", session.Usage{}, nil, map[string]int{"": 1}) + if rows := spendRows(t, unnamed.Path(), "root"); len(rows) != 1 { + t.Fatalf("an unnamed answer wrote %+v", rows) + } else if _, ok := rows[spendRow{"", false}]; !ok { + t.Fatalf("an unnamed answer was marked or dropped: %+v", rows) + } + + late := spendStore(t) + (&BashWorker{store: late, model: "m/built"}).recordSpend("root", session.Usage{Input: 4, Calls: 1, CostUSD: 0.01}, + map[string]session.Usage{"m/late": {Input: 4, Calls: 1, CostUSD: 0.01}}, nil) + if rows := spendRows(t, late.Path(), "root"); len(rows) != 1 || rows[spendRow{"m/late", false}] != 0.01 { + t.Fatalf("a receipt with no answer wrote %+v", rows) + } +} + +// COVERAGE IS COUNTED IN CALLS AND TOKENS, EXACTLY. Calls the shares do not +// account for are written unmarked on the built-on model even when they cost +// nothing; shares that account for every call and token write no residual, +// however the dollars rounded. +func TestUncoveredCallsAreBilledUnmarkedAndCoveredOnesLeaveNoResidual(t *testing.T) { + short := spendStore(t) + (&BashWorker{store: short, model: "m/built"}).recordSpend("root", session.Usage{Calls: 3}, + map[string]session.Usage{"m/a": {Calls: 2}}, map[string]int{"m/a": 2}) + if rows := spendRows(t, short.Path(), "root"); len(rows) != 2 { + t.Fatalf("an uncovered free call wrote %+v", rows) + } else if _, ok := rows[spendRow{"m/built", false}]; !ok { + t.Fatalf("the uncovered call is not billed unmarked: %+v", rows) + } + + whole := spendStore(t) + (&BashWorker{store: whole, model: "m/built"}).recordSpend("root", session.Usage{Input: 3, Calls: 3, CostUSD: 0.1 + 0.2}, + map[string]session.Usage{"m/a": {Input: 1, Calls: 1, CostUSD: 0.2}, "m/b": {Input: 2, Calls: 2, CostUSD: 0.1}}, + map[string]int{"m/a": 1, "m/b": 2}) + if rows := spendRows(t, whole.Path(), "root"); len(rows) != 2 { + t.Fatalf("covered shares wrote a residual: %+v", rows) + } +} diff --git a/internal/session/competence.go b/internal/session/competence.go index c80bf61c86..47e4a081c8 100644 --- a/internal/session/competence.go +++ b/internal/session/competence.go @@ -39,6 +39,8 @@ import ( "strings" "time" + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/competence" "github.com/Agent-Field/codeaf/internal/plandb" ) @@ -97,12 +99,18 @@ func (a *Agent) recordRunProven(run *beltRun) { return } sort.SliceStable(checks, func(i, j int) bool { return checks[i].CompletedAt.Before(checks[j].CompletedAt) }) + // A DECLARED CHECK THAT COULD NOT RUN LEAVES THE RUN UNPROVEN, NOT PROVEN BY + // THE REST. Dropping it and folding the others would turn "the tests passed + // and the linter never started" into a pass on a run whose declared checks + // were not all read, so one such exit means the run is not written at all. var runs []competence.CheckRun for _, check := range checks { for _, ran := range check.VerdictBasis.Runs { - if outcome, read := exitReading(ran.ExitCode); read { - runs = append(runs, competence.CheckRun{Command: ran.Command, Outcome: outcome}) + outcome, read := exitReading(ran.ExitCode) + if !read { + return } + runs = append(runs, competence.CheckRun{Command: ran.Command, Outcome: outcome}) } } if ended.IsZero() { @@ -120,7 +128,8 @@ func (a *Agent) recordRunProven(run *beltRun) { // exitReading is what one recorded exit says: zero passed, any other exit the // check gave failed, and the shell's own "could not run it" codes (126, 127) -// and a negative code are not a reading of the work. +// and a negative code are not a reading of the work — and a run holding one is +// not written ([Agent.recordRunProven]). // // A check a signal killed arrives as the shell's 128+signal (internal/exec/ // bare), and one the run cut off or whose ending nobody saw arrives as 1 (the @@ -269,10 +278,43 @@ func (m *modelShares) add(model string, call Usage) { (*m)[model] = share } -// AnsweredUsage is this session's money split by the model that answered each -// call — after any fallback, by the response's own name — and nil when nothing -// was banked. Its shares sum to [Agent.Usage]'s tokens and dollars. It routes -// nothing; it is a reading of what already happened. +// noteAnswer counts one arrived response under the model that answered it, the +// model the request was actually sent to after any fallback. No response is no +// answer. It is counted whether or not the response carried usage. +func (a *Agent) noteAnswer(response *ai.Response, model string, calls int) { + if response == nil || calls <= 0 { + return + } + a.mu.Lock() + defer a.mu.Unlock() + if a.answers == nil { + a.answers = map[string]int{} + } + a.answers[strings.TrimSpace(model)] += calls +} + +// Answers is how many responses each model gave this session — known models by +// name, an answer from no named model under "" — and nil when none arrived. +// It is the fact of WHO answered, kept apart from [Agent.AnsweredUsage]'s +// money, because an answer with no usage, or a free one, is still work a model +// did. It routes nothing. +func (a *Agent) Answers() map[string]int { + a.mu.Lock() + defer a.mu.Unlock() + if len(a.answers) == 0 { + return nil + } + out := make(map[string]int, len(a.answers)) + for model, count := range a.answers { + out[model] = count + } + return out +} + +// AnsweredUsage is this session's money split by the model each banked call +// carried — after any fallback — and nil when nothing was banked. Its shares +// sum to [Agent.Usage]'s tokens, dollars and calls. It is billing, and says who +// answered only beside [Agent.Answers]. It routes nothing. func (a *Agent) AnsweredUsage() map[string]Usage { a.mu.Lock() defer a.mu.Unlock() diff --git a/internal/session/competence_test.go b/internal/session/competence_test.go index 397b7ff6fe..7bcf7bb2c8 100644 --- a/internal/session/competence_test.go +++ b/internal/session/competence_test.go @@ -10,6 +10,7 @@ import ( "testing" "time" + "github.com/Agent-Field/agentfield/sdk/go/ai" "github.com/Agent-Field/codeaf/internal/competence" "github.com/Agent-Field/codeaf/internal/plandb" ) @@ -52,8 +53,8 @@ func journalledAgent(t *testing.T) (*Agent, string) { // runFixture is a settled run's plan store: a root, one work task spent on the // given models, and one check task whose basis is the given exits. A model is // an ANSWERED charge; "built:" is a charge on the model a seat was built -// on, the only kind written before answered charges existed; "" is a charge -// whose answer named no model. +// on, the only kind written before answered charges existed; "free:" is an +// answer that cost nothing; "" is a charge whose answer named no model. func runFixture(t *testing.T, agent *Agent, ground string, models []string, exits map[string]int) *beltRun { t.Helper() store, err := plandb.Open(filepath.Join(t.TempDir(), "plandb.db"), "p", "7", "Fix the parser", "fix it", agent.journalID()) @@ -79,6 +80,8 @@ func runFixture(t *testing.T, agent *Agent, ground string, models []string, exit var err error if built, ok := strings.CutPrefix(model, "built:"); ok || model == "" { err = store.AddSpend("t-work", built, plandb.RoleWork, 0.01, 10, 10) + } else if free, ok := strings.CutPrefix(model, "free:"); ok { + err = store.AddAnsweredSpend("t-work", free, plandb.RoleWork, 0, 0, 0) } else { err = store.AddAnsweredSpend("t-work", model, plandb.RoleWork, 0.01, 10, 10) } @@ -96,10 +99,10 @@ func runFixture(t *testing.T, agent *Agent, ground string, models []string, exit // THE DEFAULT ROAD: A SETTLED RUN'S DECLARED-CHECK EXITS BECOME ONE RESULT, on // the one model its work was spent through and under the task row the -// conversation knows it by. The shell's could-not-run codes are not readings. +// conversation knows it by. A real non-zero exit beside a pass is a failure. func TestASettledRunWritesItsCheckExitsOnItsWorkModel(t *testing.T) { agent, dir := journalledAgent(t) - run := runFixture(t, agent, dir, []string{"m/flash"}, map[string]int{"go test ./...": 0, "go vet ./...": 1, "missing-tool": 127}) + run := runFixture(t, agent, dir, []string{"m/flash"}, map[string]int{"go test ./...": 0, "go vet ./...": 1}) agent.recordRunProven(run) rows := ledgerRowsFor(t, agent.config.SessionFile) if len(rows) != 1 { @@ -110,13 +113,52 @@ func TestASettledRunWritesItsCheckExitsOnItsWorkModel(t *testing.T) { row.Place != dir || row.Title != "Fix the parser" || row.Outcome != competence.Failed || row.Ran != 2 { t.Fatalf("row %+v", row) } - for _, check := range row.Checks { - if check.Command == "missing-tool" { - t.Fatalf("a check the shell could not start was recorded: %+v", row.Checks) +} + +// A DECLARED CHECK THAT COULD NOT RUN LEAVES THE WHOLE RUN UNWRITTEN: one +// physical pass beside a 126, a 127 or a negative code is not a pass. +func TestARunWithACheckThatCouldNotRunWritesNothing(t *testing.T) { + for _, code := range []int{126, 127, -1} { + agent, dir := journalledAgent(t) + agent.recordRunProven(runFixture(t, agent, dir, []string{"m/flash"}, map[string]int{"go test ./...": 0, "golint ./...": code})) + if rows := ledgerRowsFor(t, agent.config.SessionFile); len(rows) != 0 { + t.Errorf("a pass beside exit %d wrote %+v", code, rows) } } } +// AN ANSWER THAT COST NOTHING IS STILL AN ANSWER: a free model alone is the +// run's model, and a free model beside a paid one is a second model. +func TestAFreeAnswerCountsAsAModel(t *testing.T) { + agent, dir := journalledAgent(t) + agent.recordRunProven(runFixture(t, agent, dir, []string{"free:m/free"}, map[string]int{"go test ./...": 0})) + if rows := ledgerRowsFor(t, agent.config.SessionFile); len(rows) != 1 || rows[0].Model != "m/free" || rows[0].Outcome != competence.Passed { + t.Fatalf("a free model's run wrote %+v", rows) + } + other, otherDir := journalledAgent(t) + other.recordRunProven(runFixture(t, other, otherDir, []string{"m/a", "free:m/b"}, map[string]int{"go test ./...": 0})) + if rows := ledgerRowsFor(t, other.config.SessionFile); len(rows) != 0 { + t.Fatalf("a paid and a free model wrote %+v", rows) + } +} + +// AN ANSWER IS COUNTED WHERE IT ARRIVES, with or without usage on it, under the +// model it was asked of; a missing response is no answer, and a late receipt +// banks money without making an answer of it. +func TestAnswersAreCountedWithoutUsageAndNeverFromReceipts(t *testing.T) { + agent, _ := newTestAgent(t, &scriptedCompleter{}, nil) + var turn Usage + agent.addUsage(&turn, &ai.Response{}, "m/b", "", laneFacts{}) + agent.addUsage(&turn, nil, "m/c", "", laneFacts{}) + agent.bank(bankedCall{used: Usage{Input: 3, CostUSD: 0.01, Calls: 1}, model: "m/late"}) + if answers := agent.Answers(); len(answers) != 1 || answers["m/b"] != 1 { + t.Fatalf("answers %+v", answers) + } + if shares := agent.AnsweredUsage(); shares["m/b"] != (Usage{}) { + t.Fatalf("an answer with no usage was billed: %+v", shares) + } +} + // A RUN NOBODY CAN ATTRIBUTE IS NO EVIDENCE: two answering models, none, an // unknown one, a built-on name, or no check that ran. func TestARunWithNoSingleWorkModelOrNoRunCheckWritesNothing(t *testing.T) { @@ -126,9 +168,10 @@ func TestARunWithNoSingleWorkModelOrNoRunCheckWritesNothing(t *testing.T) { }{ "two models": {[]string{"m/a", "m/b"}, map[string]int{"go test ./...": 0}}, "no spend": {nil, map[string]int{"go test ./...": 0}}, - "no run": {[]string{"m/a"}, map[string]int{"missing-tool": 127}}, + "no run": {[]string{"m/a"}, nil}, // An answer that named no model is unknown, never the one beside it. "unknown answer": {[]string{"m/a", ""}, map[string]int{"go test ./...": 0}}, + "unknown alone": {[]string{""}, map[string]int{"go test ./...": 0}}, // THE MODEL A SEAT WAS BUILT ON IS NEVER THE CHECKED MODEL: an old // ledger, or a resumed run's earlier seat, with real exits beside it. "built model only": {[]string{"built:m/flash"}, map[string]int{"go test ./...": 0}}, diff --git a/internal/session/loop.go b/internal/session/loop.go index a7e3e6ce06..4398047285 100644 --- a/internal/session/loop.go +++ b/internal/session/loop.go @@ -4337,6 +4337,9 @@ func (a *Agent) addUsage(turn *Usage, response *ai.Response, model, served strin // `lane` was read from the answer alone, so what the lane layer learns and // draws is untouched by the declaration. served = a.attributedEndpoint(model, served) + // THE ANSWER IS A FACT BEFORE IT IS A BILL: a response that carried no usage + // still came from a model (competence.go). + a.noteAnswer(response, model, 1) if response == nil || response.Usage == nil { return } @@ -5707,6 +5710,7 @@ func (a *Agent) addDetachedUsageAs(response *ai.Response, model string, calls in // see [Agent.addDetachedUsageAs]. It is variadic so that the thirty callers that // are on a turn's clock say nothing and mean it. func (a *Agent) addUsageAs(response *ai.Response, model string, calls int, role string, ledger, emptyReflex bool, detached ...bool) { + a.noteAnswer(response, model, calls) if response == nil || response.Usage == nil { return } diff --git a/internal/session/session.go b/internal/session/session.go index 1e5baf70df..c6571c5c5d 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -3052,6 +3052,11 @@ type Agent struct { // call banked under the name the response itself gave (competence.go's // [Agent.AnsweredUsage]). It sums to usage's figures call for call. answered modelShares + // answers is how many responses each model gave this session, counted where + // a response arrives whether or not it carried usage — a free or unmetered + // answer is still a model doing the work. A late receipt is money for an + // answer already counted, and adds none ([Agent.Answers]). + answers map[string]int // principal is WHO THIS SESSION IS WORKING FOR (principal.go), and it is // never nil: a session built with no posture at all gets a [Person], which // answers every question the way this package answered it before the From 1b23a86b6fc7bc26843b518ec73b8d158528b158 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 19:37:07 -0400 Subject: [PATCH 049/459] competence: prefer response model facts and retain dollar-only residual spend --- internal/run/bashworker.go | 12 +++++++----- internal/run/bashworker_answered_test.go | 14 ++++++++++++++ internal/session/competence.go | 5 ++++- internal/session/competence_test.go | 11 +++++++++++ 4 files changed, 36 insertions(+), 6 deletions(-) diff --git a/internal/run/bashworker.go b/internal/run/bashworker.go index 4131328b57..f543c34710 100644 --- a/internal/run/bashworker.go +++ b/internal/run/bashworker.go @@ -11,6 +11,7 @@ import ( "encoding/json" "errors" "fmt" + "math" "os" "path/filepath" "sort" @@ -854,11 +855,12 @@ func (w *BashWorker) recordSpend(taskID string, used session.Usage, shares map[s _ = w.store.AddSpend(taskID, model, role, share.CostUSD, share.Input, share.Output) } } - // THE SHARES COVER THE SEAT WHEN THEY ACCOUNT FOR EVERY CALL AND TOKEN IT - // BANKED — whole numbers, compared exactly. Then any difference in dollars - // is the order the same charges were added in, not money; otherwise the - // difference is real, uncovered spend and is billed whole. - if covered.Calls == used.Calls && covered.Input == used.Input && covered.Output == used.Output { + // Integer coverage does not prove dollar coverage: a receipt may report + // cost without tokens. Only a few floating-point rounding units from the + // regrouped sum are noise; preserve every larger residual as unproven spend. + rounding := math.Abs(math.Nextafter(used.CostUSD, math.Inf(1))-used.CostUSD) * float64(len(models)+1) + if covered.Calls == used.Calls && covered.Input == used.Input && covered.Output == used.Output && + math.Abs(used.CostUSD-covered.CostUSD) <= rounding { return } _ = w.store.AddSpend(taskID, w.model, role, max(used.CostUSD-covered.CostUSD, 0), diff --git a/internal/run/bashworker_answered_test.go b/internal/run/bashworker_answered_test.go index d536889f20..ab2435e003 100644 --- a/internal/run/bashworker_answered_test.go +++ b/internal/run/bashworker_answered_test.go @@ -168,3 +168,17 @@ func TestUncoveredCallsAreBilledUnmarkedAndCoveredOnesLeaveNoResidual(t *testing t.Fatalf("covered shares wrote a residual: %+v", rows) } } + +// Dollar-only receipts must not disappear merely because all token and call +// counters were already covered, even below an arbitrary currency threshold. +func TestUncoveredDollarOnlySpendIsRetained(t *testing.T) { + store := spendStore(t) + used := session.Usage{Input: 2, Calls: 1, CostUSD: 0.01 + 1e-12} + (&BashWorker{store: store, model: "m/built"}).recordSpend("root", used, + map[string]session.Usage{"m/served": {Input: 2, Calls: 1, CostUSD: 0.01}}, map[string]int{"m/served": 1}) + rows := spendRows(t, store.Path(), "root") + if len(rows) != 2 || rows[spendRow{"m/built", false}] <= 0 || + math.Abs(rows[spendRow{"m/served", true}]+rows[spendRow{"m/built", false}]-used.CostUSD) > math.SmallestNonzeroFloat64 { + t.Fatalf("a real dollar-only residual was discarded: %+v", rows) + } +} diff --git a/internal/session/competence.go b/internal/session/competence.go index 47e4a081c8..06428b7496 100644 --- a/internal/session/competence.go +++ b/internal/session/competence.go @@ -279,12 +279,15 @@ func (m *modelShares) add(model string, call Usage) { } // noteAnswer counts one arrived response under the model that answered it, the -// model the request was actually sent to after any fallback. No response is no +// response names, or the actual request target after fallback when it names none. No response is no // answer. It is counted whether or not the response carried usage. func (a *Agent) noteAnswer(response *ai.Response, model string, calls int) { if response == nil || calls <= 0 { return } + if answered := strings.TrimSpace(response.Model); answered != "" { + model = answered + } a.mu.Lock() defer a.mu.Unlock() if a.answers == nil { diff --git a/internal/session/competence_test.go b/internal/session/competence_test.go index 7bcf7bb2c8..e4c9158de0 100644 --- a/internal/session/competence_test.go +++ b/internal/session/competence_test.go @@ -259,3 +259,14 @@ func TestTheAnsweredTallyFollowsTheAnsweringModel(t *testing.T) { t.Fatalf("shares %+v do not sum to %+v", shares, total) } } + +// A response's explicit model is stronger evidence than its request target, +// even when the provider supplies no usage at all. +func TestAnAnswerNamesItsActualModelWithoutUsage(t *testing.T) { + agent, _ := newTestAgent(t, &scriptedCompleter{}, nil) + var turn Usage + agent.addUsage(&turn, &ai.Response{Model: "m/served"}, "m/requested", "", laneFacts{}) + if answers := agent.Answers(); len(answers) != 1 || answers["m/served"] != 1 { + t.Fatalf("answered model was replaced by request target: %+v", answers) + } +} From be000451b01855262faefb6d038b0fcb7ab7ba2a Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 19:27:32 -0400 Subject: [PATCH 050/459] competence: a ledger the read cannot take whole is unavailable, never a partial class A ledger over the bounded read is ErrHistoryLimit and a ledger with a corrupt, over-long or unsupported row is ErrUnreadableHistory, before any group, count or evidence is returned. Both reach the wire as an engine error saying competence is unavailable. Co-Authored-By: Claude Sonnet 5.5 --- cmd/codeaf/appcompetence.go | 5 ++ cmd/codeaf/appcompetence_test.go | 18 +++++ internal/appwire/competence.go | 7 +- internal/competence/read.go | 112 +++++++++++++++++++------------ internal/competence/read_test.go | 107 ++++++++++++++++++++++++++--- 5 files changed, 195 insertions(+), 54 deletions(-) diff --git a/cmd/codeaf/appcompetence.go b/cmd/codeaf/appcompetence.go index d4b760398d..ae5a560cd6 100644 --- a/cmd/codeaf/appcompetence.go +++ b/cmd/codeaf/appcompetence.go @@ -94,6 +94,11 @@ func competenceError(err error) error { return appwire.Errorf(appwire.CodeBadParams, "%s", err.Error()) case errors.Is(err, competence.ErrBadCursor): return appwire.Errorf(appwire.CodeBadParams, "%s", err.Error()) + case errors.Is(err, competence.ErrHistoryLimit): + // UNAVAILABLE, NOT PARTIAL: the ledger's own words are the person's. + return appwire.Errorf(appwire.CodeEngine, "checked-result history exceeds this bounded read; competence unavailable") + case errors.Is(err, competence.ErrUnreadableHistory): + return appwire.Errorf(appwire.CodeEngine, "checked-result history has an unreadable row; competence unavailable") case errors.Is(err, context.DeadlineExceeded): return appwire.Errorf(appwire.CodeEngine, "competence read timed out") } diff --git a/cmd/codeaf/appcompetence_test.go b/cmd/codeaf/appcompetence_test.go index fe824aa97e..ede205b5ed 100644 --- a/cmd/codeaf/appcompetence_test.go +++ b/cmd/codeaf/appcompetence_test.go @@ -258,3 +258,21 @@ func TestTheCompetenceWireShapeIsExact(t *testing.T) { } } } + +// A LEDGER THE READER CANNOT ANSWER FOR IS UNAVAILABLE, in the engine's words, +// from both doors: never a map of part of it. +func TestCompetenceUnavailableHistoryIsAnHonestEngineError(t *testing.T) { + for _, test := range []struct { + err error + want string + }{ + {competence.ErrHistoryLimit, "checked-result history exceeds this bounded read; competence unavailable"}, + {competence.ErrUnreadableHistory, "checked-result history has an unreadable row; competence unavailable"}, + } { + var wire *appwire.Error + got := competenceError(test.err) + if !errors.As(got, &wire) || wire.Code != appwire.CodeEngine || wire.Message != test.want { + t.Fatalf("%v became %#v", test.err, got) + } + } +} diff --git a/internal/appwire/competence.go b/internal/appwire/competence.go index e8e68c4307..0752d8a236 100644 --- a/internal/appwire/competence.go +++ b/internal/appwire/competence.go @@ -44,8 +44,11 @@ type CompetenceMap struct { Totals CompetenceTotals `json:"totals"` Policy CompetencePolicy `json:"policy"` ReadAt time.Time `json:"read_at"` - // Truncated says the ledger was longer than one read takes and its oldest - // history was not read. Omitted is how many scopes, filters and models this + // Truncated is always false: a ledger longer than one read takes is the + // engine error "checked-result history exceeds this bounded read; + // competence unavailable" (code "engine"), never a map of its newest part, + // and a ledger with an unreadable row is "checked-result history has an + // unreadable row; competence unavailable". Omitted is how many scopes, filters and models this // answer left out for size; Totals still count every scope that matched. Truncated bool `json:"truncated"` Omitted CompetenceOmitted `json:"omitted"` diff --git a/internal/competence/read.go b/internal/competence/read.go index c0f8c94227..d9f3be15f1 100644 --- a/internal/competence/read.go +++ b/internal/competence/read.go @@ -20,14 +20,21 @@ import ( // THE READ IS BOUNDED AND IT IS ONE READ. // -// Every question — the map, a page of evidence — reads the ledger's newest -// [DefaultMaxBytes] once, folds it, and answers. Nothing is cached between -// calls and nothing polls: a surface asks when it opens and when the person -// asks again. A ledger larger than the bound is read from its end, so the -// newest history is what is kept, and the answer says it was cut. - -// DefaultMaxBytes is how much of the ledger one read takes. A row is a few -// hundred bytes, so this is on the order of a hundred thousand results. +// Every question — the map, a page of evidence — reads the whole ledger once, +// folds it, and answers. Nothing is cached between calls and nothing polls: a +// surface asks when it opens and when the person asks again. +// +// A CLASS IS A CLAIM ABOUT EVERY RESULT IN ITS WINDOW, so a read that did not +// see every result answers NOTHING rather than a class. A ledger larger than +// [DefaultMaxBytes] is [ErrHistoryLimit], and a ledger holding a row that +// cannot be read is [ErrUnreadableHistory]: either way an old failed check may +// be exactly what was missed, and a Strong drawn without it would be false. +// Nothing is skipped, cut, guessed at or rewritten to get an answer; an empty +// or absent ledger is the one honest "no evidence yet". + +// DefaultMaxBytes is the largest ledger one read answers for. A row is a few +// hundred bytes, so this is on the order of a hundred thousand results; past +// it the read is [ErrHistoryLimit], never a partial answer. const DefaultMaxBytes int64 = 32 << 20 // The evidence page bounds. @@ -39,6 +46,17 @@ const ( // ErrUnknownScope is a filter or scope id the map does not hold for this model. var ErrUnknownScope = errors.New("no such scope for this model") +// ErrHistoryLimit is a ledger larger than one bounded read takes. Classifying +// only its newest bytes could call a model strong while an older failed check +// still inside the window went unseen, so no map or evidence is returned. +var ErrHistoryLimit = errors.New("checked-result history exceeds this bounded read; competence unavailable") + +// ErrUnreadableHistory is a ledger holding a row this reader cannot take as a +// checked result: not JSON, over-long, of a format or kind it does not know, +// or missing what a result names. The row may have been a failed check, so no +// map or evidence is returned. The ledger is left exactly as it is. +var ErrUnreadableHistory = errors.New("checked-result history has an unreadable row; competence unavailable") + // ErrBadCursor is a cursor that is not this package's, or not for this // model and scope. var ErrBadCursor = errors.New("the cursor is not one this list gave out") @@ -148,8 +166,9 @@ type Map struct { Totals Totals `json:"totals"` Policy PolicyView `json:"policy"` ReadAt time.Time `json:"read_at"` - // Truncated says the ledger was longer than one read takes, so the oldest - // history was not read; Omitted says what this answer left out for size. + // Truncated is always false on an answer, kept for the wire's shape: a + // ledger past one read is [ErrHistoryLimit], never a map of its newest + // part. Omitted says what this answer left out for size. Truncated bool `json:"truncated"` Omitted Omitted `json:"omitted"` } @@ -213,7 +232,7 @@ func (i *item) outcome() Outcome { // folded is one read of the ledger. type folded struct { byModel map[string][]*item - truncated bool + truncated bool // always false: a ledger past the bound never folds now time.Time policy Policy } @@ -243,7 +262,7 @@ func (r Reader) read(ctx context.Context) (*folded, error) { // so what is held is one entry per result and never the file's rows. results := map[string]*item{} undos := map[string]time.Time{} - truncated, err := eachRow(ctx, path, limit, func(record Record) { + err := eachRow(ctx, path, limit, func(record Record) { key := record.Model + "\x00" + record.ID switch record.Kind { case KindResult: @@ -275,7 +294,6 @@ func (r Reader) read(ctx context.Context) (*folded, error) { if err != nil { return nil, err } - out.truncated = truncated // AN UNDO MARKS A RESULT; IT IS NEVER ONE. Work that finished with no check // never counts, and taking it back does not make it count: an undo with no // checked result beside it is dropped. An undo older than the result @@ -294,71 +312,81 @@ func (r Reader) read(ctx context.Context) (*folded, error) { } // maxRowBytes is the longest line read as a row. The writer's own bounds keep -// every row far under it; a longer line is somebody else's bytes and skipped. +// every row far under it; a longer line is not one of this ledger's rows. const maxRowBytes = 64 << 10 -// eachRow hands every well-formed row in the newest limit bytes of the ledger -// to fold, oldest first, and reports whether older bytes were left unread. A -// line cut by the window's start, an over-long line and any line that is not a -// row are skipped: a bad line costs one result, never the read. -func eachRow(ctx context.Context, path string, limit int64, fold func(Record)) (bool, error) { +// eachRow hands every row of the ledger to fold, oldest first, and answers +// for the WHOLE ledger or for none of it. +// +// A ledger over limit bytes is [ErrHistoryLimit] before a byte of it is +// folded. A line that is over-long, is not JSON, is of a format or kind this +// reader does not know, or lacks what a result names is +// [ErrUnreadableHistory]: skipping it would cost a result silently, and a +// skipped failure flips a class. Only a blank line is ignored, and nothing is +// deleted, repaired or recovered by guessing. +func eachRow(ctx context.Context, path string, limit int64, fold func(Record)) error { file, err := os.Open(path) if errors.Is(err, os.ErrNotExist) { - return false, nil + return nil } if err != nil { - return false, err + return err } defer file.Close() info, err := file.Stat() if err != nil { - return false, err + return err } - truncated := false - if size := info.Size(); size > limit { - truncated = true - if _, err := file.Seek(size-limit, io.SeekStart); err != nil { - return false, err - } + if info.Size() > limit { + return ErrHistoryLimit } + // The reader is bounded too: a ledger that grew since the stat is read as + // it stood, and rows appended after are a later question's. reader := bufio.NewReaderSize(io.LimitReader(file, limit), 64<<10) var line []byte - skip := truncated + overlong := false for count := 0; ; count++ { if count%1024 == 0 && ctx.Err() != nil { - return false, ctx.Err() + return ctx.Err() } chunk, prefix, err := reader.ReadLine() if err == io.EOF { - return truncated, nil + return nil } if err != nil { - return false, err + return err } if len(line)+len(chunk) <= maxRowBytes { line = append(line, chunk...) } else { - skip = true + overlong = true } if prefix { continue } whole := bytes.TrimSpace(line) line = line[:0] - if skip { - // The window began mid-line, or the line was past any row's size. - skip = false + if overlong { + return ErrUnreadableHistory + } + if len(whole) == 0 { continue } var record Record - if len(whole) == 0 || json.Unmarshal(whole, &record) != nil { - continue + if json.Unmarshal(whole, &record) != nil || record.V != recordVersion { + return ErrUnreadableHistory } - if record.Model == "" || record.ID == "" || record.Place == "" || record.Work == "" { - continue + if record.Model == "" || record.ID == "" || record.Place == "" || record.Work == "" || record.At.IsZero() { + return ErrUnreadableHistory } - if record.Kind == KindResult && len(record.Checks) == 0 { - continue + switch record.Kind { + case KindResult: + if len(record.Checks) == 0 { + return ErrUnreadableHistory + } + case KindUndo: + default: + return ErrUnreadableHistory } fold(record) } diff --git a/internal/competence/read_test.go b/internal/competence/read_test.go index 0bc2ecb71f..68f48b6b8a 100644 --- a/internal/competence/read_test.go +++ b/internal/competence/read_test.go @@ -99,13 +99,13 @@ func TestTheLedgerRefusesUncheckedAndUnattributedResults(t *testing.T) { if !errors.Is(Append(path, anonymous), ErrIncomplete) { t.Fatal("a result with no model was written") } - // And a row that reached the file some other way is skipped on read. + // And a row that reached the file some other way makes the history + // unavailable on read: it may have been a failure. if err := os.WriteFile(path, []byte(`{"v":1,"kind":"result","id":"x","model":"","place":"/p","work":"w","checks":[{"command":"a","outcome":"passed"}]}`+"\n"), 0o600); err != nil { t.Fatal(err) } - m, err := reader(path).Map(context.Background(), "", "") - if err != nil || len(m.Models) != 0 { - t.Fatalf("an unattributed row reached the map: %+v %v", m.Models, err) + if _, err := reader(path).Map(context.Background(), "", ""); !errors.Is(err, ErrUnreadableHistory) { + t.Fatalf("an unattributed row was read past: %v", err) } } @@ -377,8 +377,9 @@ func TestTheLatestResultStands(t *testing.T) { } } -// THE READ IS BOUNDED: a ledger past the window is read from its end and the -// answer says so; a cancelled read answers nothing. +// THE READ IS BOUNDED AND NEVER PARTIAL: a ledger past the bound answers +// ErrHistoryLimit for the map and for evidence, with no groups or counts; a +// cancelled read answers nothing. func TestTheReadIsBoundedAndCancellable(t *testing.T) { path := filepath.Join(t.TempDir(), LedgerName) for i := 0; i < 50; i++ { @@ -387,11 +388,11 @@ func TestTheReadIsBoundedAndCancellable(t *testing.T) { r := reader(path) r.MaxBytes = 2048 m, err := r.Map(context.Background(), "", "") - if err != nil { - t.Fatal(err) + if !errors.Is(err, ErrHistoryLimit) || len(m.Groups) != 0 || len(m.Models) != 0 { + t.Fatalf("a ledger past the bound answered %+v %v", m, err) } - if !m.Truncated || len(m.Models) == 0 || len(m.Models) >= 50 || m.Model != "m49" { - t.Fatalf("bounded read: truncated=%v models=%d model=%s", m.Truncated, len(m.Models), m.Model) + if page, err := r.Evidence(context.Background(), "m49", "scope:x", "", "", 5); !errors.Is(err, ErrHistoryLimit) || page.Counts.All != 0 { + t.Fatalf("evidence past the bound answered %+v %v", page, err) } ctx, cancel := context.WithCancel(context.Background()) cancel() @@ -400,6 +401,92 @@ func TestTheReadIsBoundedAndCancellable(t *testing.T) { } } +// AN EARLY FAILURE THE BOUND WOULD HIDE MUST NOT BECOME STRONG. A failed check +// inside the thirty days sits before enough later passes to push it past +// MaxBytes; reading only the tail would class the scope Strong. +func TestAFailureBeyondTheBoundIsNeverClassedAsStrong(t *testing.T) { + path := filepath.Join(t.TempDir(), LedgerName) + day := 24 * time.Hour + result(t, path, "m", "/w", "a", "early", 20*day, Failed) + for i := 0; i < 40; i++ { + result(t, path, "m", "/w", "a", fmt.Sprint("late", i), time.Duration(i+1)*time.Hour, Passed) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + r := reader(path) + r.MaxBytes = info.Size() * 3 / 4 + m, err := r.Map(context.Background(), "m", "") + if !errors.Is(err, ErrHistoryLimit) { + t.Fatalf("map answered %v", err) + } + for _, group := range m.Groups { + if len(group.Scopes) != 0 { + t.Fatalf("a partial classification escaped: %+v", m.Groups) + } + } + if m.Totals != (Totals{}) || m.Truncated { + t.Fatalf("partial counts escaped: %+v", m) + } + if _, err := r.Evidence(context.Background(), "m", "scope:x", "", "", 5); !errors.Is(err, ErrHistoryLimit) { + t.Fatalf("evidence answered %v", err) + } + // Whole, the same ledger counts the failure the bound would have hidden. + whole, err := reader(path).Map(context.Background(), "m", "") + if err != nil { + t.Fatal(err) + } + if scope := scopeIn(t, whole, Strong, "a"); scope.Failed != 1 || scope.Passed != 40 { + t.Fatalf("whole read %+v", scope) + } +} + +// A ROW THAT CANNOT BE READ MAKES THE HISTORY UNAVAILABLE and the file is +// left byte for byte as it was; a blank line is harmless. +func TestAnUnreadableRowMakesTheHistoryUnavailable(t *testing.T) { + good := func(path string) { + result(t, path, "m", "/w", "a", "late", time.Hour, Passed) + } + cases := map[string]string{ + "corrupt failed row": `{"v":1,"kind":"result","id":"task:c:1","model":"m","place":"/w","work":"a","at":"2026-10-14T00:00:00Z","checks":[{"command":"go te`, + "not json": `not a row at all`, + "unknown kind": `{"v":1,"kind":"mystery","id":"x","at":"2026-10-14T00:00:00Z","model":"m","place":"/w","work":"a"}`, + "unknown version": `{"v":2,"kind":"result","id":"x","at":"2026-10-14T00:00:00Z","model":"m","place":"/w","work":"a","checks":[{"command":"a","outcome":"failed"}]}`, + "no checks": `{"v":1,"kind":"result","id":"x","at":"2026-10-14T00:00:00Z","model":"m","place":"/w","work":"a"}`, + "overlong": `{"v":1,"kind":"result","id":"` + strings.Repeat("x", maxRowBytes) + `"}`, + } + for name, row := range cases { + t.Run(name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), LedgerName) + if err := os.WriteFile(path, []byte(row+"\n"), 0o600); err != nil { + t.Fatal(err) + } + good(path) + before, _ := os.ReadFile(path) + m, err := reader(path).Map(context.Background(), "m", "") + if !errors.Is(err, ErrUnreadableHistory) || m.Totals != (Totals{}) || len(m.Groups) != 0 { + t.Fatalf("map answered %+v %v", m, err) + } + if _, err := reader(path).Evidence(context.Background(), "m", "scope:x", "", "", 5); !errors.Is(err, ErrUnreadableHistory) { + t.Fatalf("evidence answered %v", err) + } + if after, _ := os.ReadFile(path); string(after) != string(before) { + t.Fatal("the read rewrote the ledger") + } + }) + } + path := filepath.Join(t.TempDir(), LedgerName) + good(path) + file, _ := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o600) + file.WriteString("\n \n") + file.Close() + m, err := reader(path).Map(context.Background(), "m", "") + if err != nil || m.Totals.Results != 1 { + t.Fatalf("blank lines broke the read: %+v %v", m.Totals, err) + } +} + // THE READ WRITES NOTHING. func TestReadingNeverWrites(t *testing.T) { dir := t.TempDir() From a2f52eccede8b378f2117e01daaf1a3843e3cd3d Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 19:29:05 -0400 Subject: [PATCH 051/459] competence: read the size that was checked, and hold every row to the writer's shape The scan is bounded to the stat'd size so a row appended during the read belongs to the next one. A row with an unknown outcome, a check that is not a pass or failure, an empty command or an unknown source is an unreadable history, never folded as a pass. Co-Authored-By: Claude Sonnet 5.5 --- internal/appwire/competence.go | 6 ++- internal/competence/read.go | 79 ++++++++++++++++++++++++-------- internal/competence/read_test.go | 60 ++++++++++++++++++++++++ 3 files changed, 123 insertions(+), 22 deletions(-) diff --git a/internal/appwire/competence.go b/internal/appwire/competence.go index 0752d8a236..db6a760842 100644 --- a/internal/appwire/competence.go +++ b/internal/appwire/competence.go @@ -47,8 +47,10 @@ type CompetenceMap struct { // Truncated is always false: a ledger longer than one read takes is the // engine error "checked-result history exceeds this bounded read; // competence unavailable" (code "engine"), never a map of its newest part, - // and a ledger with an unreadable row is "checked-result history has an - // unreadable row; competence unavailable". Omitted is how many scopes, filters and models this + // and a ledger with an unreadable row (not JSON, over-long, an unknown + // format, kind, source or outcome, or a check that is not a pass or a + // failure with a command) is "checked-result history has an unreadable + // row; competence unavailable". Omitted is how many scopes, filters and models this // answer left out for size; Totals still count every scope that matched. Truncated bool `json:"truncated"` Omitted CompetenceOmitted `json:"omitted"` diff --git a/internal/competence/read.go b/internal/competence/read.go index d9f3be15f1..9811186b9d 100644 --- a/internal/competence/read.go +++ b/internal/competence/read.go @@ -16,6 +16,7 @@ import ( "strconv" "strings" "time" + "unicode/utf8" ) // THE READ IS BOUNDED AND IT IS ONE READ. @@ -319,11 +320,9 @@ const maxRowBytes = 64 << 10 // for the WHOLE ledger or for none of it. // // A ledger over limit bytes is [ErrHistoryLimit] before a byte of it is -// folded. A line that is over-long, is not JSON, is of a format or kind this -// reader does not know, or lacks what a result names is -// [ErrUnreadableHistory]: skipping it would cost a result silently, and a -// skipped failure flips a class. Only a blank line is ignored, and nothing is -// deleted, repaired or recovered by guessing. +// folded. The read is then bounded to the size that was checked: a row +// appended after the stat belongs to the NEXT read, and is never read as a +// line cut at some other boundary. See [scanRows] for what a row must be. func eachRow(ctx context.Context, path string, limit int64, fold func(Record)) error { file, err := os.Open(path) if errors.Is(err, os.ErrNotExist) { @@ -340,9 +339,16 @@ func eachRow(ctx context.Context, path string, limit int64, fold func(Record)) e if info.Size() > limit { return ErrHistoryLimit } - // The reader is bounded too: a ledger that grew since the stat is read as - // it stood, and rows appended after are a later question's. - reader := bufio.NewReaderSize(io.LimitReader(file, limit), 64<<10) + return scanRows(ctx, file, info.Size(), fold) +} + +// scanRows reads exactly the first size bytes of src as rows. A line that is +// over-long, is not JSON, or fails [validRow] is [ErrUnreadableHistory]: +// skipping it would cost a result silently, and a skipped failure flips a +// class. Only a blank line is ignored, and nothing is deleted, repaired or +// recovered by guessing. +func scanRows(ctx context.Context, src io.Reader, size int64, fold func(Record)) error { + reader := bufio.NewReaderSize(io.LimitReader(src, size), 64<<10) var line []byte overlong := false for count := 0; ; count++ { @@ -373,23 +379,56 @@ func eachRow(ctx context.Context, path string, limit int64, fold func(Record)) e continue } var record Record - if json.Unmarshal(whole, &record) != nil || record.V != recordVersion { + if json.Unmarshal(whole, &record) != nil || !validRow(record) { return ErrUnreadableHistory } - if record.Model == "" || record.ID == "" || record.Place == "" || record.Work == "" || record.At.IsZero() { - return ErrUnreadableHistory + fold(record) + } +} + +// validRow is whether a row is one this reader's writer could have written, +// held to the writer's bounds without repeating its redaction or clipping. +// A row that is anything else may have been a failed check, so the read gives +// up on it rather than fold it as something it was not. +func validRow(r Record) bool { + if r.V != recordVersion || r.At.IsZero() || + r.Model == "" || r.ID == "" || r.Place == "" || r.Work == "" || r.Source.Agent == "" { + return false + } + switch r.Source.Kind { + case SourceTask, SourceConversation: + default: + return false + } + switch r.Kind { + case KindUndo: + return r.Outcome == "" && len(r.Checks) == 0 + case KindResult: + default: + return false + } + // The row's own outcome is passed or failed; an empty one is only the + // reading of a row whose every check is valid, never of a garbled one. + switch r.Outcome { + case "", Passed, Failed: + default: + return false + } + if len(r.Checks) == 0 || len(r.Checks) > MaxChecks { + return false + } + anyFailed := false + for _, check := range r.Checks { + if check.Outcome != Passed && check.Outcome != Failed { + return false } - switch record.Kind { - case KindResult: - if len(record.Checks) == 0 { - return ErrUnreadableHistory - } - case KindUndo: - default: - return ErrUnreadableHistory + if strings.TrimSpace(check.Command) == "" || len(check.Command) > maxCommand*utf8.UTFMax { + return false } - fold(record) + anyFailed = anyFailed || check.Outcome == Failed } + // A pass cannot be recorded beside a check that failed. + return !(r.Outcome == Passed && anyFailed) } // shortID is a stable id for one string. diff --git a/internal/competence/read_test.go b/internal/competence/read_test.go index 68f48b6b8a..30cfba9c7b 100644 --- a/internal/competence/read_test.go +++ b/internal/competence/read_test.go @@ -730,3 +730,63 @@ func TestALargeLedgerIsGroupedInOnePassAndStopsWhenCancelled(t *testing.T) { t.Fatalf("grouping a cancelled read answered %v", err) } } + +// A ROW APPENDED AFTER THE SIZE WAS CHECKED BELONGS TO THE NEXT READ: the scan +// stops at the snapshot, and bytes past it — even half a line — are neither +// read nor an error. +func TestTheScanStopsAtTheSnapshotItWasGiven(t *testing.T) { + row := func(id string) string { + return `{"v":1,"kind":"result","id":"` + id + `","at":"2026-10-14T00:00:00Z","model":"m","place":"/w","work":"a",` + + `"source":{"kind":"task","agent":"c"},"outcome":"passed","checks":[{"command":"go test","outcome":"passed"}]}` + "\n" + } + snapshot := row("one") + row("two") + grown := snapshot + row("three")[:40] + var ids []string + err := scanRows(context.Background(), strings.NewReader(grown), int64(len(snapshot)), func(r Record) { ids = append(ids, r.ID) }) + if err != nil || strings.Join(ids, ",") != "one,two" { + t.Fatalf("snapshot read %v %v", ids, err) + } +} + +// A ROW IS HELD TO WHAT THE WRITER WRITES: an outcome or check that is not a +// pass or a failure, an empty command or an unknown source never folds. +func TestValidRowHoldsARowToTheWriter(t *testing.T) { + good := func() Record { + return Record{V: 1, Kind: KindResult, ID: "task:c:1", At: testNow, Model: "m", Place: "/w", Work: "a", + Source: Source{Kind: SourceTask, Agent: "c"}, Outcome: Failed, + Checks: []CheckRun{{Command: "go test", Outcome: Failed}}} + } + if !validRow(good()) { + t.Fatal("a row the writer would write was refused") + } + legacy := good() + legacy.Outcome = "" + if !validRow(legacy) { + t.Fatal("a row with no outcome and valid checks was refused") + } + undone := Record{V: 1, Kind: KindUndo, ID: "x", At: testNow, Model: "m", Place: "/w", Work: "a", Source: Source{Kind: SourceTask, Agent: "c"}} + if !validRow(undone) { + t.Fatal("an undo was refused") + } + for name, mutate := range map[string]func(*Record){ + "garbage outcome": func(r *Record) { r.Outcome = "garbage" }, + "undone outcome": func(r *Record) { r.Outcome = Undone }, + "unknown check outcome": func(r *Record) { r.Checks[0].Outcome = "unread" }, + "empty check outcome": func(r *Record) { r.Checks[0].Outcome = "" }, + "empty command": func(r *Record) { r.Checks[0].Command = " " }, + "huge command": func(r *Record) { r.Checks[0].Command = strings.Repeat("c", 4*maxCommand+1) }, + "too many checks": func(r *Record) { r.Checks = make([]CheckRun, MaxChecks+1) }, + "passed beside failed": func(r *Record) { r.Outcome = Passed }, + "unknown source": func(r *Record) { r.Source.Kind = "dream" }, + "no agent": func(r *Record) { r.Source.Agent = "" }, + "no time": func(r *Record) { r.At = time.Time{} }, + "unknown version": func(r *Record) { r.V = 2 }, + } { + r := good() + r.Checks = append([]CheckRun(nil), r.Checks...) + mutate(&r) + if validRow(r) { + t.Fatalf("%s was accepted", name) + } + } +} From 3928e0dff0bcfa6f2fa7bbe502e5ff67f7d642fe Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 19:30:03 -0400 Subject: [PATCH 052/459] competence: reject outcomes that contradict the retained check evidence --- internal/competence/read.go | 5 +++-- internal/competence/read_test.go | 1 + 2 files changed, 4 insertions(+), 2 deletions(-) diff --git a/internal/competence/read.go b/internal/competence/read.go index 9811186b9d..02ad7a3284 100644 --- a/internal/competence/read.go +++ b/internal/competence/read.go @@ -427,8 +427,9 @@ func validRow(r Record) bool { } anyFailed = anyFailed || check.Outcome == Failed } - // A pass cannot be recorded beside a check that failed. - return !(r.Outcome == Passed && anyFailed) + // The writer always keeps the deciding failed check, even when it caps + // the shown list. A stated outcome must agree with that retained evidence. + return r.Outcome == "" || (r.Outcome == Failed) == anyFailed } // shortID is a stable id for one string. diff --git a/internal/competence/read_test.go b/internal/competence/read_test.go index 30cfba9c7b..2f93295276 100644 --- a/internal/competence/read_test.go +++ b/internal/competence/read_test.go @@ -777,6 +777,7 @@ func TestValidRowHoldsARowToTheWriter(t *testing.T) { "huge command": func(r *Record) { r.Checks[0].Command = strings.Repeat("c", 4*maxCommand+1) }, "too many checks": func(r *Record) { r.Checks = make([]CheckRun, MaxChecks+1) }, "passed beside failed": func(r *Record) { r.Outcome = Passed }, + "failed beside passed": func(r *Record) { r.Checks[0].Outcome = Passed }, "unknown source": func(r *Record) { r.Source.Kind = "dream" }, "no agent": func(r *Record) { r.Source.Agent = "" }, "no time": func(r *Record) { r.At = time.Time{} }, From f2cd8df8e5d43cf4e2eb6c896e89117b46feca69 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 19:38:46 -0400 Subject: [PATCH 053/459] competence: document unavailable reads instead of partial classifications --- .../unreleased/0-competence-checked-results.md | 5 +++-- internal/manual/chat/running-from-the-terminal.md | 13 ++++++------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/docs/changes/unreleased/0-competence-checked-results.md b/docs/changes/unreleased/0-competence-checked-results.md index c24125b7d2..cd6cbaf46a 100644 --- a/docs/changes/unreleased/0-competence-checked-results.md +++ b/docs/changes/unreleased/0-competence-checked-results.md @@ -17,5 +17,6 @@ check proved which goal), a run answered by more than one model (a free answer counts) or by an unnamed one, a run with any declared check the shell could not start (126/127 or negative — never folded as a pass from the rest), and undo — `quick.undo` takes back quick tasks, which run no declared checks. The app wire is in docs/design/app-v3/PROTOCOL.md under V76; an engine without it -answers `unknown_method`. Both reads are bounded (newest 32 MiB, 10 s, ≤300 scopes) -and call no model. +answers `unknown_method`. Both reads are bounded (complete snapshot ≤32 MiB, 10 s, ≤300 displayed scopes) +and call no model. Oversized or unreadable history is unavailable, never classified +from a partial subset; output omission counts do not imply incomplete scope evidence. diff --git a/internal/manual/chat/running-from-the-terminal.md b/internal/manual/chat/running-from-the-terminal.md index 6024ca1d14..b86ac7d150 100644 --- a/internal/manual/chat/running-from-the-terminal.md +++ b/internal/manual/chat/running-from-the-terminal.md @@ -1021,9 +1021,8 @@ No competence evidence yet. ## What has each model proven — checked results, with codeaf competence --json -`codeaf competence --json` answers a different question from the text above: **which model -passes its checks, and where**. It prints the same map the desktop app's Settings → Models → -Competence draws, read from a ledger of checked results kept beside the spending ledger. +`codeaf competence --json` prints the desktop Competence map: **which model passes checks, +and where**, from the checked-results ledger beside spending. A checked result is one finished task whose declared checks — the tests, lint or build its proposal named — actually ran against the work and came back with a real exit status. Every @@ -1042,10 +1041,10 @@ two in five or fewer is `weak`; everything between is `frontier`. `--model ` maps that exact model; left alone it is the model with the newest result. `--scope ` narrows to one place, kind of work or scope from the map's own `filters`. -Results are kept when the task or the conversation is deleted. **Work finished before this -ledger existed is not on it**, because nothing then recorded which model's checks ran, so a -machine starts with an empty map: `"model": ""` and four empty groups. It is read-only: it -calls no model and changes nothing about which model runs. +Deleting a source keeps its results. **Earlier work is not inferred**: a new ledger starts +with `"model": ""` and four empty groups. No model is called and routing never changes. +A complete read is bounded to 32 MiB and 10 seconds. Oversized or unreadable history is +unavailable, never classified from a partial subset. ## The Model Pool — what this machine reads from it, with codeaf pool From 8a9fee3296aa3510c1ff890b8db328ab8afd28a0 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 19:30:50 -0400 Subject: [PATCH 054/459] App: tasks.model moves one proposed task's model, never its agent's A proposal card's Change model has its own refused-unless-proposed door. It honours the card's state and version, resolves the word through admission's model ladder, and leaves the agent and every other proposal alone. Co-Authored-By: Claude Sonnet 5.5 --- cmd/codeaf/appwork.go | 23 ++++++++++++- internal/appwire/work.go | 24 ++++++++++++++ internal/session/task_app.go | 18 ++++++++-- internal/session/task_app_test.go | 55 +++++++++++++++++++++++++++++++ 4 files changed, 117 insertions(+), 3 deletions(-) diff --git a/cmd/codeaf/appwork.go b/cmd/codeaf/appwork.go index 69b92f8475..52c3ab89a6 100644 --- a/cmd/codeaf/appwork.go +++ b/cmd/codeaf/appwork.go @@ -662,6 +662,27 @@ func (b *appBackend) ActOnTask(id, action, verdict, text string) error { } } +// SetTaskModel is the proposal card's Change model: one proposed task's model, +// never the agent's, with the card's own state and version honoured. +func (b *appBackend) SetTaskModel(id, model, state string, version *uint64) error { + agent, local, ok := appSplitID(id) + if !ok { + return appwire.Errorf(appwire.CodeBadParams, "%s is not a task id", id) + } + _, live, err := b.heldLive(agent) + if err != nil { + return err + } + intent := session.AppTaskIntent{ID: local, Action: session.AppActionProposalModel, Model: strings.TrimSpace(model), Version: version} + if state = strings.TrimSpace(state); state != "" { + intent.State = &state + } + if err := live.conn.Conv.AppTaskAction(intent); err != nil { + return appTaskError(err) + } + return nil +} + // appTaskSettleWait bounds how long an action on a task the app has seen // waits for the engine's list to name it. const appTaskSettleWait = 3 * time.Second @@ -672,7 +693,7 @@ func appTaskError(err error) error { switch { case strings.Contains(words, "no such task"), strings.Contains(words, "no such quick task"): return appwire.Errorf(appwire.CodeNotFound, "%s", plainWords(words)) - case strings.Contains(words, "stale"), strings.Contains(words, "not waiting"), strings.Contains(words, "finished"), strings.Contains(words, "unknown"), strings.Contains(words, "must be"): + case strings.Contains(words, "stale"), strings.Contains(words, "not waiting"), strings.Contains(words, "finished"), strings.Contains(words, "unknown"), strings.Contains(words, "must be"), strings.Contains(words, "choose one"): return appwire.Errorf(appwire.CodeBadParams, "%s", plainWords(words)) } return appEngineError(err) diff --git a/internal/appwire/work.go b/internal/appwire/work.go index b3b8040af8..9fc9897b2b 100644 --- a/internal/appwire/work.go +++ b/internal/appwire/work.go @@ -152,6 +152,15 @@ type Worker interface { Wake() error } +// TaskModeler is the optional half of a Worker that can move one proposed task +// to another model: tasks.model. An engine without it refuses the method as +// unknown, and no other verb changes a task's model. +type TaskModeler interface { + // SetTaskModel names the one proposed task, the model and, optionally, the + // state and version of the card the person saw; a stale card is refused. + SetTaskModel(id, model, state string, version *uint64) error +} + func init() { for name, m := range workMethods { methods[name] = m @@ -249,6 +258,21 @@ var workMethods = map[string]method{ } return done(w.ActOnTask(p.ID, "decide", p.Verdict, p.Text)) }), + "tasks.model": typed(func(e Engine, p struct { + ID string `json:"id"` + Model string `json:"model"` + State string `json:"state"` + Version *uint64 `json:"version"` + }) (*Empty, error) { + m, ok := e.(TaskModeler) + if !ok { + return nil, Errorf(CodeUnknownMethod, "this engine does not serve tasks.model") + } + if strings.TrimSpace(p.ID) == "" || strings.TrimSpace(p.Model) == "" { + return nil, Errorf(CodeBadParams, "tasks.model needs an id and a model") + } + return done(m.SetTaskModel(p.ID, p.Model, p.State, p.Version)) + }), "quick.undo": typed(func(e Engine, p IDParams) (*Empty, error) { w, err := worker(e, "quick.undo") if err != nil { diff --git a/internal/session/task_app.go b/internal/session/task_app.go index 6acf947484..0092723aa0 100644 --- a/internal/session/task_app.go +++ b/internal/session/task_app.go @@ -44,6 +44,12 @@ type AppQuickFile struct { // Added and Removed are this file's own changed lines. Added, Removed int } + +// AppActionProposalModel is the proposal card's Change model: it moves one +// proposed (or held) task's model before it starts and is refused for anything +// else, so it can never retarget work already running or touch the agent. +const AppActionProposalModel = "proposal_model" + type AppTaskIntent struct { ID, Action, Text, Model, Verb string State *string @@ -267,6 +273,10 @@ func (a *Agent) AppTaskAction(intent AppTaskIntent) error { return err } } + if intent.Action == AppActionProposalModel { + // Past its proposal a task's model is RetargetTask's promise, not this card's. + return errors.New("that task must be a proposed task to change its model") + } row, found, err := a.AppTask(intent.ID) if err != nil { return err @@ -292,7 +302,7 @@ func (a *Agent) AppTaskAction(intent AppTaskIntent) error { // A proposal's clock and model are edited under the same lock as its answer. func (a *Agent) appProposalAction(id uint64, intent AppTaskIntent) (bool, error) { var choice taskModelChoice - if intent.Action == "model" { + if intent.Action == "model" || intent.Action == AppActionProposalModel { choice = a.resolveTaskModel(intent.Model) } a.mu.Lock() @@ -314,7 +324,11 @@ func (a *Agent) appProposalAction(id uint64, intent AppTaskIntent) (bool, error) a.mu.Unlock() a.HoldTask(id) return true, nil - case "model": + case "model", AppActionProposalModel: + if intent.Action == AppActionProposalModel && strings.TrimSpace(intent.Model) == "" { + a.mu.Unlock() + return true, errors.New("choose one known model") + } if choice.problem != "" || choice.model == "" { a.mu.Unlock() return true, errors.New("choose one known model") diff --git a/internal/session/task_app_test.go b/internal/session/task_app_test.go index 9dd1041381..89dfd96e6c 100644 --- a/internal/session/task_app_test.go +++ b/internal/session/task_app_test.go @@ -422,3 +422,58 @@ func TestAppQuickStoppedTaskCanUndoItsCapturedWrites(t *testing.T) { t.Fatalf("captured file remains: %v", err) } } + +// The proposal card's Change model moves one proposal's model and nothing else. +func TestAppProposalModelChangesOnlyTheSelectedProposal(t *testing.T) { + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { c.AskConsent = true; c.TaskAutoApproveSeconds = 12 }) + hub := newEventHub() + agent.mu.Lock() + agent.hub = hub + agent.mu.Unlock() + agent.config.TaskModels = func() []string { return []string{"alpha/one", "beta/two"} } + before := agent.Model() + agent.taskTimer = func(time.Duration) (<-chan time.Time, func()) { return make(chan time.Time), func() {} } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + for id := uint64(1); id <= 2; id++ { + if _, err := agent.openTask(ctx, id, taskSpec{title: "Proposal", brief: "work", acceptance: taskPersonAcceptance}, ""); err != nil { + t.Fatal(err) + } + } + if err := agent.AppTaskAction(AppTaskIntent{ID: "1", Action: AppActionProposalModel, Model: "beta/two"}); err != nil { + t.Fatal(err) + } + rows, err := agent.AppTasks() + if err != nil || len(rows) != 2 { + t.Fatalf("rows %+v %v", rows, err) + } + if rows[0].Model != "beta/two" || rows[1].Model == "beta/two" { + t.Fatalf("model moved the wrong proposal: %+v", rows) + } + if agent.Model() != before { + t.Fatalf("agent model changed %q -> %q", before, agent.Model()) + } + // Unknown, empty, stale and not-a-proposal are refused and change nothing. + stale := rows[0].Version + 9 + for name, intent := range map[string]AppTaskIntent{ + "unknown": {ID: "2", Model: "nope/none"}, + "empty": {ID: "2", Model: " "}, + "stale": {ID: "2", Model: "alpha/one", Version: &stale}, + "state": {ID: "2", Model: "alpha/one", State: ptr("running")}, + "missing": {ID: "9", Model: "alpha/one"}, + } { + intent.Action = AppActionProposalModel + if err := agent.AppTaskAction(intent); err == nil { + t.Fatalf("%s: accepted", name) + } + } + rows, _ = agent.AppTasks() + if rows[1].Model == "alpha/one" { + t.Fatalf("a refused change landed: %+v", rows[1]) + } + for _, id := range []uint64{1, 2} { + agent.ResolveTask(id, TaskAnswer{Approved: false}) + } +} + +func ptr[T any](v T) *T { return &v } From 5112521dbc52e4164328cb06deb1fd47014a5976 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 19:45:33 -0400 Subject: [PATCH 055/459] teams: a started member keeps the id of its start line, and members.brief reads that line A brief was found by the member's current handle, so a rename or a reused handle returned another member's words and lost the original. The membership now records the exact start entry (both the app's and the terminal's start), the brief is a targeted read of that one entry, and an older record answers only when the log proves provenance. Co-Authored-By: Claude Sonnet 5.5 --- cmd/codeaf/appcrewmembers.go | 38 ++--- cmd/codeaf/appcrews_test.go | 87 ++++++++++ .../unreleased/0-member-brief-identity.md | 13 ++ internal/teams/member_json.go | 2 +- internal/teams/root.go | 2 +- internal/teams/startfor.go | 153 ++++++++++++++++++ internal/teams/startfor_test.go | 77 +++++++++ internal/teams/teams.go | 7 + internal/tui3/teamtraffic.go | 6 +- internal/tui3/teamtraffic_test.go | 24 +++ 10 files changed, 381 insertions(+), 28 deletions(-) create mode 100644 docs/changes/unreleased/0-member-brief-identity.md create mode 100644 internal/teams/startfor.go create mode 100644 internal/teams/startfor_test.go diff --git a/cmd/codeaf/appcrewmembers.go b/cmd/codeaf/appcrewmembers.go index a7064c0d38..dc5fbbff29 100644 --- a/cmd/codeaf/appcrewmembers.go +++ b/cmd/codeaf/appcrewmembers.go @@ -672,10 +672,12 @@ func (b *appBackend) createFromProposal(ask appwire.CrewCreate) (appwire.Crew, e return b.CrewGet(id) } -// MemberBrief is what a manager started a member with: the text of the start -// line its crew's log holds for that handle, read on demand for one member. A -// member nobody started, or one whose start line is gone, has none; nothing is -// inferred from its conversation. +// MemberBrief is what a manager started a member with: the start line that +// made this very membership, read by its recorded id (teamstore.StartFor), so a +// renamed member keeps its own brief and a later member under its old handle +// does not receive it. A member nobody started, or one whose line cannot be +// proved to be its own, has none; nothing is inferred from its conversation or +// from who manages the crew now. func (b *appBackend) MemberBrief(crew, agent string) (appwire.MemberBrief, error) { t, _, err := b.crewTeam(crew) if err != nil { @@ -685,27 +687,17 @@ func (b *appBackend) MemberBrief(crew, agent string) (appwire.MemberBrief, error if !ok { return appwire.MemberBrief{}, appwire.Errorf(appwire.CodeNotFound, "%s is not in %s", agent, t.Name) } - if !m.Started || strings.TrimSpace(m.Handle) == "" { - return appwire.MemberBrief{}, nil - } - entries, err := teamstore.ReadTraffic(b.crewProfile(), t.ID, "", 0) + e, ok, err := teamstore.StartFor(b.crewProfile(), t, m) if err != nil { return appwire.MemberBrief{}, appEngineError(err) } - for i := len(entries) - 1; i >= 0; i-- { - e := entries[i] - if e.Kind != teamstore.KindStart || e.From != teamstore.FromManager || e.To != m.Handle { - continue - } - if e.Team != "" && e.Team != t.ID { - continue - } - at := e.At - from := "" - if boss, ok := t.Member(e.FromKey); ok { - from = boss.Handle - } - return appwire.MemberBrief{Text: redact.Secrets(strings.TrimSpace(e.Text)), From: from, At: &at}, nil + if !ok { + return appwire.MemberBrief{}, nil + } + at := e.At + from := "" + if boss, ok := t.Member(e.FromKey); ok { + from = boss.Handle } - return appwire.MemberBrief{}, nil + return appwire.MemberBrief{Text: redact.Secrets(strings.TrimSpace(e.Text)), From: from, At: &at}, nil } diff --git a/cmd/codeaf/appcrews_test.go b/cmd/codeaf/appcrews_test.go index 59e720ebf3..3f484cfec3 100644 --- a/cmd/codeaf/appcrews_test.go +++ b/cmd/codeaf/appcrews_test.go @@ -494,3 +494,90 @@ func TestAppMemberBriefIsTheManagersStartLineAndNothingElse(t *testing.T) { t.Fatalf("a stranger has a brief: %v", err) } } + +// A MEMBER'S BRIEF IS ITS OWN START LINE BY IDENTITY, not by handle: after the +// member is renamed and a second member takes the old handle, each reads the +// brief it was started with, across a restart, and a member whose line is gone +// or was never recorded has none rather than a neighbour's. +func TestAppMemberBriefSurvivesRenameAndHandleReuse(t *testing.T) { + backend, _, _, id := crewBackend(t) + profile := backend.profileDir + first := "b0b0b0b0" + const ( + briefOne = "Own the web client; BIRCH-9304 is yours." + briefTwo = "Take the billing export; ASH-2217 is yours." + ) + startOne, err := teamstore.AppendTrafficID(profile, id, teamstore.Entry{Kind: teamstore.KindStart, From: teamstore.FromManager, To: "web", Text: briefOne}) + if err != nil { + t.Fatal(err) + } + if err := teamstore.AppendTraffic(profile, id, teamstore.Entry{Kind: teamstore.KindYou, From: teamstore.FromYou, To: "web", Text: "a later prompt, not a brief"}); err != nil { + t.Fatal(err) + } + secondKey := "/state/v3/places/proj/c1c1c1c1/transcript.jsonl" + secondAgent := appMemberAgent(teamstore.Member{Key: secondKey, File: secondKey}) + // The first member is renamed before anyone else is started under "web". + err = teamstore.Update(profile, func(f *teamstore.File) error { + for i := range f.Teams { + for j := range f.Teams[i].Members { + if f.Teams[i].Members[j].Handle == "web" { + f.Teams[i].Members[j].Started, f.Teams[i].Members[j].StartEntry = true, startOne + } + } + } + return f.SetHandle(id, "/state/v3/places/proj/b0b0b0b0/transcript.jsonl", "api") + }) + if err != nil { + t.Fatal(err) + } + startTwo, err := teamstore.AppendTrafficID(profile, id, teamstore.Entry{Kind: teamstore.KindStart, From: teamstore.FromManager, To: "web", Text: briefTwo}) + if err != nil { + t.Fatal(err) + } + err = teamstore.Update(profile, func(f *teamstore.File) error { + return f.AddMember(id, teamstore.Member{Key: secondKey, File: secondKey, Where: "/w", Word: "billing", Handle: "web", Started: true, StartEntry: startTwo}) + }) + if err != nil { + t.Fatal(err) + } + check := func(b *appBackend, label string) { + t.Helper() + if got, err := b.MemberBrief(id, first); err != nil || got.Text != briefOne || got.From != "lead" { + t.Fatalf("%s: renamed member = %+v, %v", label, got, err) + } + if got, err := b.MemberBrief(id, secondAgent); err != nil || got.Text != briefTwo || got.From != "lead" { + t.Fatalf("%s: reusing member = %+v, %v", label, got, err) + } + } + check(backend, "live") + fresh := newFakeBackend(newAppRecorder(), nil) + t.Cleanup(func() { fresh.Close() }) + fresh.profileDir = profile + check(fresh, "after restart") + + // A recorded line that is gone is unknown: nobody else's words, no manager. + err = teamstore.Update(profile, func(f *teamstore.File) error { + for i := range f.Teams { + for j := range f.Teams[i].Members { + if f.Teams[i].Members[j].Key == secondKey { + f.Teams[i].Members[j].StartEntry = "000000000999" + } + if f.Teams[i].Members[j].Handle == "api" { + f.Teams[i].Members[j].StartEntry, f.Teams[i].Members[j].Handle = "", "web" + } + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } + if got, err := fresh.MemberBrief(id, secondAgent); err != nil || got.Text != "" || got.From != "" || got.At != nil { + t.Fatalf("absent line = %+v, %v", got, err) + } + // The first record kept no id, but the log wrote its key onto the line when + // it was appended: that is exact provenance, and it is still its own words. + if got, err := fresh.MemberBrief(id, first); err != nil || got.Text != briefOne { + t.Fatalf("legacy by recipient key = %+v, %v", got, err) + } +} diff --git a/docs/changes/unreleased/0-member-brief-identity.md b/docs/changes/unreleased/0-member-brief-identity.md new file mode 100644 index 0000000000..faec7fe762 --- /dev/null +++ b/docs/changes/unreleased/0-member-brief-identity.md @@ -0,0 +1,13 @@ +--- +kind: fixed +title: A member's brief follows its own start line, not its handle +pr: 0 +surface: [engine, app] +invalidates: + - "members.brief read every start line addressed to the member's current handle, so after a rename or a handle reused by a later member it could return someone else's brief and lose the original. A membership now keeps the id of the exact start line that made it (`start_entry`, written by both the desktop app's and the terminal's start), and the brief is that one line, read by id from the log." + - "A member started before `start_entry` existed has a brief only when the log proves it: exactly one manager start addressed to its recorded key, or the only start ever addressed to its handle in a log that still begins at its first entry with no former member of that name. Otherwise the brief is absent; nothing is parsed from words, taken from its first message, or credited to the current manager." + - "members.brief no longer reads the whole traffic log of the crew (`ReadTraffic(...,0)`); it reads one entry by id." +--- + +The id lives on the member record in `teams.json` beside the other member +fields and survives builds that do not know it. diff --git a/internal/teams/member_json.go b/internal/teams/member_json.go index 6f029e0f2b..58ebe3b479 100644 --- a/internal/teams/member_json.go +++ b/internal/teams/member_json.go @@ -17,7 +17,7 @@ type wireMember struct { var knownMemberFields = map[string]bool{ "key": true, "file": true, "where": true, "word": true, "handle": true, - "home": true, "independent": true, "joined_at": true, "started": true, "handle_by": true, + "home": true, "independent": true, "joined_at": true, "started": true, "handle_by": true, "start_entry": true, } func (m *Member) UnmarshalJSON(raw []byte) error { diff --git a/internal/teams/root.go b/internal/teams/root.go index 2315515dba..0258d764ec 100644 --- a/internal/teams/root.go +++ b/internal/teams/root.go @@ -150,7 +150,7 @@ func seatTopManagers(teams []Team, rootID string) bool { if !ok { continue } - m.Home, m.Started = false, false + m.Home, m.Started, m.StartEntry = false, false, "" if m.Handle != "" && handleProblem(teams[r], m.Key, m.Handle) != nil { m.Handle = "" } diff --git a/internal/teams/startfor.go b/internal/teams/startfor.go new file mode 100644 index 0000000000..0b1572e9c6 --- /dev/null +++ b/internal/teams/startfor.go @@ -0,0 +1,153 @@ +package teams + +import ( + "bufio" + "bytes" + "errors" + "fmt" + "io" + "os" + "strconv" +) + +// startfor.go answers "what was this member started with" by identity. +// +// A HANDLE IS NOT AN IDENTITY: a person renames a member and a later member +// takes the name, so reading the log for "the start line to @web" can hand one +// member another's brief and lose its own. A membership therefore keeps the id +// of the exact line that made it ([Member.StartEntry]) and this reads that one +// line; it never parses words and never takes a conversation's first message +// for a brief. + +// ReadEntry is the one entry of team teamID's log with id, from either file, +// found without reading the log whole: a page of one past the id before it. +// ok is false when the log holds no such entry (rotated away, or never there). +func ReadEntry(profileDir, teamID, id string) (Entry, bool, error) { + n, err := strconv.ParseInt(id, 10, 64) + if err != nil || n <= 0 { + return Entry{}, false, nil + } + if err := safeTeamID(teamID); err != nil { + return Entry{}, false, err + } + path := TrafficPath(profileDir, teamID) + got, err := forwardTraffic([]string{trafficRotated(path), path}, fmt.Sprintf("%0*d", trafficIDWidth, n-1), 1) + if err != nil || len(got) == 0 || got[0].ID != id { + return Entry{}, false, err + } + return got[0], true, nil +} + +// StartFor is the manager's start line that made member m of team t, and false +// when none can be proved to be theirs. +// +// A membership that kept its line ([Member.StartEntry]) is answered by reading +// that line, and it must be a manager's start that is not addressed to a +// different conversation. Any other membership is answered only when the log +// makes it unambiguous, by the one rule that needs no reading of words: exactly +// one manager start in the whole retained log is addressed to this member's +// key, or (for a start the interface wrote before the member existed) is the +// only one ever addressed to its handle, the log still begins at its first +// entry, and no former member held that handle. Anything else is unknown. +func StartFor(profileDir string, t Team, m Member) (Entry, bool, error) { + if m.StartEntry != "" { + e, ok, err := ReadEntry(profileDir, t.ID, m.StartEntry) + if err != nil || !ok || !isManagerStart(e) || e.ToKey != "" && e.ToKey != m.Key { + return Entry{}, false, err + } + return e, true, nil + } + if m.Key == "" || safeTeamID(t.ID) != nil { + return Entry{}, false, nil + } + path := TrafficPath(profileDir, t.ID) + var ( + byKey, byHandle []Entry + first int64 + seen bool + ) + for _, p := range []string{trafficRotated(path), path} { + err := scanTrafficFile(p, func(id int64, line []byte) { + if !seen { + first, seen = id, true + } + if !bytes.Contains(line, []byte(`"kind":"start"`)) { + return + } + e, ok := parseTrafficLine(line) + if !ok || !isManagerStart(e) { + return + } + switch { + case e.ToKey == m.Key: + byKey = append(byKey, e) + case m.Handle != "" && e.To == m.Handle: + byHandle = append(byHandle, e) // counted so a shared name is seen + } + }) + if err != nil { + return Entry{}, false, err + } + } + if len(byKey) == 1 { + return byKey[0], true, nil + } + if len(byKey) > 0 || len(byHandle) != 1 || !m.Started || byHandle[0].ToKey != "" || !seen || first > 1 { + return Entry{}, false, nil + } + for _, f := range t.FormerMembers { + if f.Handle == m.Handle { + return Entry{}, false, nil + } + } + return byHandle[0], true, nil +} + +func isManagerStart(e Entry) bool { + return e.Kind == KindStart && e.From == FromManager +} + +// scanTrafficFile calls visit with the id and raw bytes of each complete line +// of one file that carries an id, one line in memory at a time. A missing file +// is nothing. It is bounded by the rotation size, never by the team's age. +func scanTrafficFile(path string, visit func(id int64, line []byte)) error { + file, err := os.Open(path) + if errors.Is(err, os.ErrNotExist) { + return nil + } + if err != nil { + return err + } + defer file.Close() + r := bufio.NewReader(file) + for { + line, err := r.ReadBytes('\n') + if len(line) > 0 && line[len(line)-1] == '\n' { + if id, ok := lineID(line); ok { + visit(id, line) + } + } + if err == io.EOF { + return nil + } + if err != nil { + return err + } + } +} + +// lineID reads the leading "id" of an entry line without decoding it; every +// entry is written with its id first. +func lineID(line []byte) (int64, bool) { + const lead = `{"id":"` + if !bytes.HasPrefix(line, []byte(lead)) { + return 0, false + } + rest := line[len(lead):] + end := bytes.IndexByte(rest, '"') + if end <= 0 { + return 0, false + } + n, err := strconv.ParseInt(string(rest[:end]), 10, 64) + return n, err == nil +} diff --git a/internal/teams/startfor_test.go b/internal/teams/startfor_test.go new file mode 100644 index 0000000000..0e111b30e6 --- /dev/null +++ b/internal/teams/startfor_test.go @@ -0,0 +1,77 @@ +package teams + +import ( + "encoding/json" + "testing" +) + +// A MEMBER'S BRIEF FOLLOWS ITS RECORD, NOT ITS HANDLE: a start line is found by +// the id the membership kept, so a rename, and a later member taking the old +// name, change neither member's answer. +func TestStartForFollowsTheRecordedLineThroughRenameAndReuse(t *testing.T) { + dir := t.TempDir() + team := Team{ID: "t1"} + first, err := AppendTrafficID(dir, "t1", Entry{Kind: KindStart, From: FromManager, To: "web", Text: "first brief"}) + must(t, err) + must(t, AppendTraffic(dir, "t1", Entry{Kind: KindYou, From: FromYou, To: "web", Text: "a later person message"})) + second, err := AppendTrafficID(dir, "t1", Entry{Kind: KindStart, From: FromManager, To: "web", Text: "second brief"}) + must(t, err) + + // The first member was renamed; the second took "web". + a := Member{Key: "ka", Handle: "api", Started: true, StartEntry: first} + b := Member{Key: "kb", Handle: "web", Started: true, StartEntry: second} + for _, tc := range []struct { + m Member + want string + }{{a, "first brief"}, {b, "second brief"}} { + e, ok, err := StartFor(dir, team, tc.m) + if err != nil || !ok || e.Text != tc.want || e.ID != tc.m.StartEntry { + t.Fatalf("%s: %+v %v %v", tc.m.Key, e, ok, err) + } + } + // The id survives the teams file's JSON round trip, beside a future field. + raw, _ := json.Marshal(a) + var back Member + must(t, json.Unmarshal([]byte(string(raw[:len(raw)-1])+`,"future":1}`), &back)) + if back.StartEntry != first { + t.Fatalf("start_entry lost: %s", raw) + } + // A recorded line that is not a manager's start, or is addressed to someone + // else, is no brief. + for _, m := range []Member{{Key: "kc", StartEntry: "000000000002"}, {Key: "kc", StartEntry: "000000000099"}} { + if _, ok, _ := StartFor(dir, team, m); ok { + t.Fatalf("%+v returned a brief", m) + } + } +} + +// OLD RECORDS ANSWER ONLY WHEN THE LOG PROVES IT: a lone start to the handle in +// a log that begins at its first entry; two starts to one name are unknown, and +// so is a member nobody started. +func TestStartForLegacyMembershipsAreUnknownUnlessUnambiguous(t *testing.T) { + dir := t.TempDir() + team := Team{ID: "t1"} + only := Member{Key: "k1", Handle: "web", Started: true} + if _, ok, _ := StartFor(dir, team, only); ok { + t.Fatal("an empty log returned a brief") + } + _, err := AppendTrafficID(dir, "t1", Entry{Kind: KindStart, From: FromManager, To: "web", Text: "only brief"}) + must(t, err) + if e, ok, err := StartFor(dir, team, only); err != nil || !ok || e.Text != "only brief" { + t.Fatalf("lone start: %+v %v %v", e, ok, err) + } + if _, ok, _ := StartFor(dir, team, Member{Key: "k2", Handle: "web"}); ok { + t.Fatal("a member nobody started got a brief") + } + // The handle was held by a former member: not provably this one's. + former := Team{ID: "t1", FormerMembers: []Member{{Key: "old", Handle: "web"}}} + if _, ok, _ := StartFor(dir, former, only); ok { + t.Fatal("a reused handle returned a brief") + } + // A second start to the same name makes both ambiguous. + _, err = AppendTrafficID(dir, "t1", Entry{Kind: KindStart, From: FromManager, To: "web", Text: "other brief"}) + must(t, err) + if _, ok, _ := StartFor(dir, team, only); ok { + t.Fatal("two starts to one handle returned a brief") + } +} diff --git a/internal/teams/teams.go b/internal/teams/teams.go index 7727035e49..9790e9184e 100644 --- a/internal/teams/teams.go +++ b/internal/teams/teams.go @@ -41,6 +41,13 @@ type Member struct { // (a [KindStart] the interface carried out), which is the second rule a // home is picked by. Started bool `json:"started,omitempty"` + // StartEntry is the id of the [KindStart] line, in this team's log, that + // the interface carried out to make this membership: the member's brief + // by identity rather than by the handle, which a person can change and a + // later member can take. Empty on a membership nobody started and on every + // one made before it was kept ([StartFor] answers those only when the log + // proves which line is theirs). The recipient is this record's Key. + StartEntry string `json:"start_entry,omitempty"` // HandleBy is who chose Handle: [HandleByWords] for the word list's // instant guess, [HandleByModel] for the title model's word, and // [HandleByTyped] for a handle a person or the manager gave. "" is a diff --git a/internal/tui3/teamtraffic.go b/internal/tui3/teamtraffic.go index 88952168b8..aa2734893b 100644 --- a/internal/tui3/teamtraffic.go +++ b/internal/tui3/teamtraffic.go @@ -518,7 +518,7 @@ func (a *app) trafficStart(t team, e teamstore.Entry) tea.Cmd { err = fmt.Errorf("inherit approvals: %w", err) } } - return func(bool) tea.Cmd { return a.trafficStarted(id, handle, conv, err) } + return func(bool) tea.Cmd { return a.trafficStarted(id, handle, entry, conv, err) } }) } @@ -529,7 +529,7 @@ func (a *app) trafficStartRefused(handle, why string) { // trafficStarted is the start's conversation back from the engine: held // behind, joined to the team under its handle, and said beside the manager. -func (a *app) trafficStarted(id, handle string, conv Conversation, err error) tea.Cmd { +func (a *app) trafficStarted(id, handle, entry string, conv Conversation, err error) tea.Cmd { if err != nil || conv.Agent == nil { why := "the conversation did not open" if err != nil { @@ -549,7 +549,7 @@ func (a *app) trafficStarted(id, handle string, conv Conversation, err error) te a.trafficBehindTop(key) a.chatTabBar = tabBar{} if t.ID != "" { - m := teamMember{Key: key, File: conv.SessionFile, Where: conv.Workspace, Handle: handle} + m := teamMember{Key: key, File: conv.SessionFile, Where: conv.Workspace, Handle: handle, StartEntry: entry} if err := a.teamEdit(func(f *teamstore.File) error { if err := f.AddMember(t.ID, m); err != nil { return err diff --git a/internal/tui3/teamtraffic_test.go b/internal/tui3/teamtraffic_test.go index 4a1fadbd42..ea1e7df509 100644 --- a/internal/tui3/teamtraffic_test.go +++ b/internal/tui3/teamtraffic_test.go @@ -586,3 +586,27 @@ func TestTrafficStartRefusesFailedApprovalInheritance(t *testing.T) { t.Fatal("failed child joined team") } } + +// A MEMBER THE TERMINAL STARTS KEEPS THE ID OF ITS START LINE: the same durable +// identity the desktop app records, so the brief is found by the line and the +// member's key, never by a handle a person can change or a later member take. +func TestTrafficStartedRecordsTheStartLineOnTheMembership(t *testing.T) { + a, harbor, _, _ := trafficApp(t) + first, err := teamstore.AppendTrafficID(a.profileDir, harbor, teamstore.Entry{Kind: teamstore.KindStart, From: teamstore.FromManager, To: "scribe", Text: "write the changelog"}) + if err != nil { + t.Fatal(err) + } + file := filepath.Join(t.TempDir(), "scribe.jsonl") + conv := Conversation{Agent: &fakeAgent{model: "m"}, SessionFile: file, Workspace: "/tmp/lab"} + spend(t, a, a.trafficStarted(harbor, "scribe", first, conv, nil)) + teamsFlush(t, a) + tm := mustTeam(t, a, harbor) + m, ok := tm.ByHandle("scribe") + if !ok || m.StartEntry != first { + t.Fatalf("membership = %+v", m) + } + got, ok, err := teamstore.StartFor(a.profileDir, tm, m) + if err != nil || !ok || got.Text != "write the changelog" || got.ID != first { + t.Fatalf("StartFor = %+v %v %v", got, ok, err) + } +} From a0924e3865688ab4d1b90a45b7384bebe2d977d0 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 19:52:41 -0400 Subject: [PATCH 056/459] teams: the app's start records its start line; bounded legacy scan that errors rather than reads short The previous commit wrote StartEntry only in fixtures: crewStart never set it, so every desktop-started member took the legacy path. crewStart now records the manager start's id, with an integration test through the real crewStart. The legacy lookup keeps one candidate and a count capped at two, and the log scan reads only the size it opened with, refusing a line past 1 MiB with an error instead of an empty answer. A line filed under another team is no brief. Co-Authored-By: Claude Sonnet 5.5 --- cmd/codeaf/appcrews.go | 7 ++ cmd/codeaf/appcrews_test.go | 106 ++++++++++++++++++ .../unreleased/0-member-brief-identity.md | 3 +- internal/teams/startfor.go | 96 +++++++++++----- internal/teams/startfor_test.go | 36 ++++++ 5 files changed, 221 insertions(+), 27 deletions(-) diff --git a/cmd/codeaf/appcrews.go b/cmd/codeaf/appcrews.go index 9e6c5698ae..87c1a22181 100644 --- a/cmd/codeaf/appcrews.go +++ b/cmd/codeaf/appcrews.go @@ -691,6 +691,13 @@ func (b *appBackend) crewStart(t teamstore.Team, manager teamstore.Member, e tea // would hide the brief from it (the session reads nothing older than a // member's joining). The terminal's start writes none either. member.Handle, member.JoinedAt, member.Started = handle, time.Time{}, true + // THE MEMBERSHIP KEEPS THE ID OF THE LINE THAT MADE IT, so its brief is + // that line by identity and survives a rename or a handle reused later. + // Only a manager's start of this crew is recorded; the terminal's start + // writes the same field (internal/tui3/teamtraffic.go). + if e.Kind == teamstore.KindStart && e.From == teamstore.FromManager && (e.Team == "" || e.Team == t.ID) { + member.StartEntry = e.ID + } err = teamstore.Update(b.crewProfile(), func(f *teamstore.File) error { if err := f.AddMember(t.ID, member); err != nil { return err diff --git a/cmd/codeaf/appcrews_test.go b/cmd/codeaf/appcrews_test.go index 3f484cfec3..a323dbc938 100644 --- a/cmd/codeaf/appcrews_test.go +++ b/cmd/codeaf/appcrews_test.go @@ -6,7 +6,9 @@ import ( "os" "path/filepath" "reflect" + "strconv" "strings" + "sync" "testing" "time" @@ -581,3 +583,107 @@ func TestAppMemberBriefSurvivesRenameAndHandleReuse(t *testing.T) { t.Fatalf("legacy by recipient key = %+v, %v", got, err) } } + +// A STARTED MEMBER'S BRIEF SURVIVES RENAME AND HANDLE REUSE THROUGH THE REAL +// START: the membership crewStart itself writes keeps the id of its start line, +// so nothing here records StartEntry by hand. Two members are started under one +// handle, the first is renamed in between, and each reads its own words, live +// and after a restart. +func TestAppCrewStartRecordsTheStartLineAndTheBriefFollowsIt(t *testing.T) { + var ( + mu sync.Mutex + minted int + release int + ) + rec := newAppRecorder() + backend := newFakeBackend(rec, nil) + backend.mint = func(string) (string, error) { + mu.Lock() + defer mu.Unlock() + minted++ + return filepath.Join(t.TempDir(), "conv-"+strconv.Itoa(minted), "transcript.jsonl"), nil + } + backend.open = func(ask appOpenAsk) (*appConn, error) { + if ask.Transcript == "" { + return nil, errors.New("opened without a transcript") + } + return &appConn{Conv: &fakeConversation{}, Workspace: ask.Workspace, Transcript: ask.Transcript, Release: func() { mu.Lock(); release++; mu.Unlock() }}, nil + } + t.Cleanup(func() { backend.Close() }) + backend.state = &appState{path: filepath.Join(t.TempDir(), appStateName)} + backend.profileDir = t.TempDir() + lead, err := mustCreate(t, backend, appwire.CreateAsk{Workspace: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + leadMember, err := backend.agentMember(lead.ID) + if err != nil { + t.Fatal(err) + } + leadMember.Handle = "lead" + id := teamstore.NewID() + err = teamstore.Update(backend.profileDir, func(f *teamstore.File) error { + f.Teams = append(f.Teams, teamstore.Team{ID: id, Name: "harbor", Made: time.Now()}) + if err := f.AddMember(id, leadMember); err != nil { + return err + } + return f.SetManager(id, leadMember.Key) + }) + if err != nil { + t.Fatal(err) + } + start := func(brief string) string { + t.Helper() + sid, err := teamstore.AppendTrafficID(backend.profileDir, id, teamstore.Entry{Kind: teamstore.KindStart, From: teamstore.FromManager, FromKey: leadMember.Key, To: "web", Text: brief}) + if err != nil { + t.Fatal(err) + } + e, ok, err := teamstore.ReadEntry(backend.profileDir, id, sid) + if err != nil || !ok { + t.Fatalf("start line %s: %v %v", sid, ok, err) + } + file, err := teamstore.Load(backend.profileDir) + if err != nil { + t.Fatal(err) + } + team, _ := file.Team(id) + manager, _ := team.Member(team.Manager) + backend.crewStart(team, manager, e) + return sid + } + const ( + briefOne = "Own the web client; BIRCH-9304 is yours." + briefTwo = "Take the billing export; ASH-2217 is yours." + ) + startOne := start(briefOne) + file, _ := teamstore.Load(backend.profileDir) + team, _ := file.Team(id) + first, ok := team.ByHandle("web") + if !ok || first.StartEntry != startOne || !first.Started { + t.Fatalf("crewStart recorded %+v, want start entry %s", first, startOne) + } + if err := teamstore.Update(backend.profileDir, func(f *teamstore.File) error { return f.SetHandle(id, first.Key, "api") }); err != nil { + t.Fatal(err) + } + startTwo := start(briefTwo) + file, _ = teamstore.Load(backend.profileDir) + team, _ = file.Team(id) + second, ok := team.ByHandle("web") + if !ok || second.Key == first.Key || second.StartEntry != startTwo { + t.Fatalf("second member %+v (first %+v), want start entry %s", second, first, startTwo) + } + check := func(b *appBackend, label string) { + t.Helper() + if got, err := b.MemberBrief(id, appMemberAgent(first)); err != nil || got.Text != briefOne { + t.Fatalf("%s: renamed member = %+v, %v", label, got, err) + } + if got, err := b.MemberBrief(id, appMemberAgent(second)); err != nil || got.Text != briefTwo { + t.Fatalf("%s: reusing member = %+v, %v", label, got, err) + } + } + check(backend, "live") + fresh := newFakeBackend(newAppRecorder(), nil) + t.Cleanup(func() { fresh.Close() }) + fresh.profileDir = backend.profileDir + check(fresh, "after restart") +} diff --git a/docs/changes/unreleased/0-member-brief-identity.md b/docs/changes/unreleased/0-member-brief-identity.md index faec7fe762..766d244dfe 100644 --- a/docs/changes/unreleased/0-member-brief-identity.md +++ b/docs/changes/unreleased/0-member-brief-identity.md @@ -4,9 +4,10 @@ title: A member's brief follows its own start line, not its handle pr: 0 surface: [engine, app] invalidates: - - "members.brief read every start line addressed to the member's current handle, so after a rename or a handle reused by a later member it could return someone else's brief and lose the original. A membership now keeps the id of the exact start line that made it (`start_entry`, written by both the desktop app's and the terminal's start), and the brief is that one line, read by id from the log." + - "members.brief read every start line addressed to the member's current handle, so after a rename or a handle reused by a later member it could return someone else's brief and lose the original. A membership now keeps the id of the exact start line that made it (`start_entry`, written by `crewStart` in `cmd/codeaf/appcrews.go` for the desktop app and by the terminal's start), and the brief is that one line, read by id from the log." - "A member started before `start_entry` existed has a brief only when the log proves it: exactly one manager start addressed to its recorded key, or the only start ever addressed to its handle in a log that still begins at its first entry with no former member of that name. Otherwise the brief is absent; nothing is parsed from words, taken from its first message, or credited to the current manager." - "members.brief no longer reads the whole traffic log of the crew (`ReadTraffic(...,0)`); it reads one entry by id." + - "A scan of the log for an older membership holds one candidate and a count of at most two, reads no more than the file's size when it opened, and fails with an error rather than reading as empty when a line is past 1 MiB or a file is far past rotation size." --- The id lives on the member record in `teams.json` beside the other member diff --git a/internal/teams/startfor.go b/internal/teams/startfor.go index 0b1572e9c6..b842f9454a 100644 --- a/internal/teams/startfor.go +++ b/internal/teams/startfor.go @@ -42,17 +42,20 @@ func ReadEntry(profileDir, teamID, id string) (Entry, bool, error) { // when none can be proved to be theirs. // // A membership that kept its line ([Member.StartEntry]) is answered by reading -// that line, and it must be a manager's start that is not addressed to a -// different conversation. Any other membership is answered only when the log -// makes it unambiguous, by the one rule that needs no reading of words: exactly -// one manager start in the whole retained log is addressed to this member's -// key, or (for a start the interface wrote before the member existed) is the -// only one ever addressed to its handle, the log still begins at its first -// entry, and no former member held that handle. Anything else is unknown. +// that line, and it must be a manager's start of THIS team that is not +// addressed to a different conversation; no other line is ever fetched in its +// place. Any other membership is answered only when the log makes it +// unambiguous, by the one rule that needs no reading of words: exactly one +// manager start in the whole retained log is addressed to this member's key, +// or (for a start the interface wrote before the member existed) is the only +// one ever addressed to its handle, the log still begins at its first entry, +// and no former member held that handle. Anything else is unknown, and a log +// that cannot be read through is an error, never an empty answer. func StartFor(profileDir string, t Team, m Member) (Entry, bool, error) { if m.StartEntry != "" { e, ok, err := ReadEntry(profileDir, t.ID, m.StartEntry) - if err != nil || !ok || !isManagerStart(e) || e.ToKey != "" && e.ToKey != m.Key { + if err != nil || !ok || !isManagerStart(e) || e.ID != m.StartEntry || + e.Team != "" && e.Team != t.ID || e.ToKey != "" && e.ToKey != m.Key { return Entry{}, false, err } return e, true, nil @@ -61,38 +64,47 @@ func StartFor(profileDir string, t Team, m Member) (Entry, bool, error) { return Entry{}, false, nil } path := TrafficPath(profileDir, t.ID) + // Only a candidate and a count are kept: two matches are as unknown as + // two hundred, so the scan stops at the second. var ( - byKey, byHandle []Entry + byKey, byHandle Entry + nKey, nHandle int first int64 seen bool ) for _, p := range []string{trafficRotated(path), path} { - err := scanTrafficFile(p, func(id int64, line []byte) { + err := scanTrafficFile(p, func(id int64, line []byte) bool { if !seen { first, seen = id, true } if !bytes.Contains(line, []byte(`"kind":"start"`)) { - return + return true } e, ok := parseTrafficLine(line) - if !ok || !isManagerStart(e) { - return + if !ok || !isManagerStart(e) || e.Team != "" && e.Team != t.ID { + return true } switch { case e.ToKey == m.Key: - byKey = append(byKey, e) + nKey++ + byKey = e case m.Handle != "" && e.To == m.Handle: - byHandle = append(byHandle, e) // counted so a shared name is seen + nHandle++ + byHandle = e // counted so a shared name is seen } + return nKey < 2 && nHandle < 2 }) if err != nil { return Entry{}, false, err } + if nKey >= 2 || nHandle >= 2 { + break + } } - if len(byKey) == 1 { - return byKey[0], true, nil + if nKey == 1 { + return byKey, true, nil } - if len(byKey) > 0 || len(byHandle) != 1 || !m.Started || byHandle[0].ToKey != "" || !seen || first > 1 { + if nKey > 0 || nHandle != 1 || !m.Started || byHandle.ToKey != "" || !seen || first > 1 { return Entry{}, false, nil } for _, f := range t.FormerMembers { @@ -100,17 +112,31 @@ func StartFor(profileDir string, t Team, m Member) (Entry, bool, error) { return Entry{}, false, nil } } - return byHandle[0], true, nil + return byHandle, true, nil } func isManagerStart(e Entry) bool { return e.Kind == KindStart && e.From == FromManager } +// maxScanLine is the longest line one scan will hold. A line past it is +// corruption (an entry is a short record), and a scan that skipped it could +// have skipped the start line it was looking for, so it is an error. +const maxScanLine = 1 << 20 + +// ErrTrafficUnreadable is a log a scan cannot vouch for: a line past +// [maxScanLine], or a file far past the size rotation keeps it under. +var ErrTrafficUnreadable = errors.New("the crew's log cannot be read through") + // scanTrafficFile calls visit with the id and raw bytes of each complete line -// of one file that carries an id, one line in memory at a time. A missing file -// is nothing. It is bounded by the rotation size, never by the team's age. -func scanTrafficFile(path string, visit func(id int64, line []byte)) error { +// of one file that carries an id, one line in memory at a time, until visit +// answers false. A missing file is nothing. +// +// It reads the size the file had when it was opened and not a byte more, so +// an append during the scan is the next scan's, and it holds no line longer +// than [maxScanLine]; both bounds fail with [ErrTrafficUnreadable] instead of +// answering as though the log were short. +func scanTrafficFile(path string, visit func(id int64, line []byte) bool) error { file, err := os.Open(path) if errors.Is(err, os.ErrNotExist) { return nil @@ -119,14 +145,32 @@ func scanTrafficFile(path string, visit func(id int64, line []byte)) error { return err } defer file.Close() - r := bufio.NewReader(file) + info, err := file.Stat() + if err != nil { + return err + } + // Rotation keeps a file near trafficRotateBytes (it can pass it by one + // line); a file several times that was not written by this log. + if info.Size() > 2*trafficRotateBytes+maxScanLine { + return fmt.Errorf("%w: %s is %d bytes", ErrTrafficUnreadable, path, info.Size()) + } + r := bufio.NewReaderSize(io.LimitReader(file, info.Size()), 64<<10) + var line []byte for { - line, err := r.ReadBytes('\n') + chunk, err := r.ReadSlice('\n') + if len(line)+len(chunk) > maxScanLine { + return fmt.Errorf("%w: a line in %s is past %d bytes", ErrTrafficUnreadable, path, maxScanLine) + } + line = append(line, chunk...) + if errors.Is(err, bufio.ErrBufferFull) { + continue + } if len(line) > 0 && line[len(line)-1] == '\n' { - if id, ok := lineID(line); ok { - visit(id, line) + if id, ok := lineID(line); ok && !visit(id, line) { + return nil } } + line = line[:0] if err == io.EOF { return nil } diff --git a/internal/teams/startfor_test.go b/internal/teams/startfor_test.go index 0e111b30e6..8161534d41 100644 --- a/internal/teams/startfor_test.go +++ b/internal/teams/startfor_test.go @@ -2,6 +2,9 @@ package teams import ( "encoding/json" + "errors" + "os" + "strings" "testing" ) @@ -75,3 +78,36 @@ func TestStartForLegacyMembershipsAreUnknownUnlessUnambiguous(t *testing.T) { t.Fatal("two starts to one handle returned a brief") } } + +// A LOG THAT CANNOT BE READ THROUGH IS AN ERROR, NOT A SHORT LOG: a line past +// the scan's bound would otherwise be skipped, and the start line it hid +// reported as absent. +func TestStartForLegacyScanRefusesAnOverlongLine(t *testing.T) { + dir := t.TempDir() + team := Team{ID: "t1"} + _, err := AppendTrafficID(dir, "t1", Entry{Kind: KindStart, From: FromManager, To: "web", Text: "only brief"}) + must(t, err) + f, err := os.OpenFile(TrafficPath(dir, "t1"), os.O_APPEND|os.O_WRONLY, 0o600) + must(t, err) + _, err = f.WriteString(`{"id":"000000000002","kind":"you","text":"` + strings.Repeat("x", maxScanLine+10) + "\"}\n") + must(t, err) + must(t, f.Close()) + _, ok, err := StartFor(dir, team, Member{Key: "k1", Handle: "web", Started: true}) + if ok || !errors.Is(err, ErrTrafficUnreadable) { + t.Fatalf("overlong line: ok=%v err=%v", ok, err) + } +} + +// A start line recorded by id but filed under another team is no brief, and a +// scan stops counting at a second match. +func TestStartForIgnoresAnotherTeamsLine(t *testing.T) { + dir := t.TempDir() + id, err := AppendTrafficID(dir, "t1", Entry{Kind: KindStart, From: FromManager, To: "web", Team: "other", Text: "not ours"}) + must(t, err) + if _, ok, _ := StartFor(dir, Team{ID: "t1"}, Member{Key: "k1", Handle: "web", Started: true, StartEntry: id}); ok { + t.Fatal("a line of another team was a brief by id") + } + if _, ok, _ := StartFor(dir, Team{ID: "t1"}, Member{Key: "k1", Handle: "web", Started: true}); ok { + t.Fatal("a line of another team was a brief by handle") + } +} From bf2061e7258189847ed86205bcddf5baaf511aab Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 20:09:40 -0400 Subject: [PATCH 057/459] session: the held-phase beat interval is an immutable const, shortened per agent under the heart's lock The beat test wrote the package global phaseHeldBeat while an earlier test's beat goroutine read it to build its ticker, a data race. The interval is now a const, captured under phase.mu in tellPhaseUntil and passed to the goroutine by value; tests set the unexported per-agent phaseHeart.beat instead. Adds a regression proving capture at stage start and per-agent independence. Co-Authored-By: Claude Sonnet 5.5 --- internal/session/phasenews.go | 23 ++++++++---- internal/session/phasenews_test.go | 56 +++++++++++++++++++++++++++--- 2 files changed, 68 insertions(+), 11 deletions(-) diff --git a/internal/session/phasenews.go b/internal/session/phasenews.go index 21a57cb3c5..0f27bd52ed 100644 --- a/internal/session/phasenews.go +++ b/internal/session/phasenews.go @@ -348,10 +348,11 @@ func postPhaseNews(news PhaseNews) { // frame, a machine under load, a stop-the-world pause — before anything a person // is reading blinks. // -// It is a var rather than a const for exactly one reason: the tests that prove a -// stage outlasting the window keeps drawing shorten it, and a test that waited -// five real seconds per beat would cost more than the defect it guards. -var phaseHeldBeat = provider.PhaseWindow / 3 +// It is a const so that nothing can write it while a beat goroutine reads it. +// The tests that prove a stage outlasting the window keeps drawing shorten the +// beat PER AGENT through [phaseHeart.beat] instead, and a test that waited five +// real seconds per beat would cost more than the defect it guards. +const phaseHeldBeat = provider.PhaseWindow / 3 // phaseHeart is the one phase an agent is holding open and the beat that keeps // saying it. @@ -369,6 +370,10 @@ type phaseHeart struct { // screen, which is the same defect as a stale phase and harder to see. held PhaseNews stop chan struct{} + // beat overrides [phaseHeldBeat] for this agent when positive. It is a test + // seam read under mu by [Agent.tellPhaseUntil] and handed to the goroutine as + // a value, so a beat never reads shared state after it starts. + beat time.Duration } // tellPhase is how this package says what a turn is doing between requests, and @@ -443,7 +448,11 @@ func (a *Agent) tellPhaseUntil(phase provider.Phase, detail, then string, since, stop := make(chan struct{}) a.phase.held, a.phase.stop = news, stop postPhaseNews(news) - guard.Go("phase beat", func() { a.beatHeldPhase(stop) }) + every := phaseHeldBeat + if a.phase.beat > 0 { + every = a.phase.beat + } + guard.Go("phase beat", func() { a.beatHeldPhase(stop, every) }) } // interruptPhase says a phase over the top of whatever the turn was already @@ -513,8 +522,8 @@ func (a *Agent) dropHeldPhaseLocked() { // beatHeldPhase re-says one held phase until it ends. It is the whole lifetime // of the goroutine [Agent.tellPhase] spawns: it starts with a phase and it // returns when that phase is over, and there is no other exit. -func (a *Agent) beatHeldPhase(stop chan struct{}) { - beat := time.NewTicker(phaseHeldBeat) +func (a *Agent) beatHeldPhase(stop chan struct{}, every time.Duration) { + beat := time.NewTicker(every) defer beat.Stop() for { select { diff --git a/internal/session/phasenews_test.go b/internal/session/phasenews_test.go index 1695a7c929..d190bb5e99 100644 --- a/internal/session/phasenews_test.go +++ b/internal/session/phasenews_test.go @@ -385,11 +385,11 @@ func heldSince(news []PhaseNews, phase provider.Phase) (said int, starts map[tim // neither of those facts is about the length of the interval. func TestAHeldPhaseKeepsSayingItselfAndStopsWhenItEnds(t *testing.T) { log := watchPhases(t) - held := phaseHeldBeat - phaseHeldBeat = 2 * time.Millisecond - t.Cleanup(func() { phaseHeldBeat = held }) - agent, _ := newTestAgent(t, &scriptedCompleter{}, nil) + agent.phase.mu.Lock() + agent.phase.beat = 2 * time.Millisecond + agent.phase.mu.Unlock() + t.Cleanup(agent.endPhase) // A STAGE THAT HAS ALREADY OUTLASTED THE WINDOW. This is the shape the // quarter-hour reading had: begun long ago, still running, and owed a clock // that says how long it has really been. @@ -436,6 +436,54 @@ func TestAHeldPhaseKeepsSayingItselfAndStopsWhenItEnds(t *testing.T) { } } +// TestTheBeatIntervalIsCapturedPerAgentWhenTheStageStarts proves the interval is +// read once, under the heart's lock, at the moment a stage begins: a later +// change to the agent's beat does not touch a running stage, and a second agent +// keeps the production default. +func TestTheBeatIntervalIsCapturedPerAgentWhenTheStageStarts(t *testing.T) { + log := watchPhases(t) + fast, _ := newTestAgent(t, &scriptedCompleter{}, nil) + slow, _ := newTestAgent(t, &scriptedCompleter{}, nil) + fast.phase.mu.Lock() + fast.phase.beat = time.Millisecond + fast.phase.mu.Unlock() + t.Cleanup(fast.endPhase) + t.Cleanup(slow.endPhase) + + slow.tellPhase(provider.PhaseChecking, "slow", time.Time{}) + fast.tellPhase(provider.PhaseChecking, "fast", time.Time{}) + // Rewriting the seam after the stage began must not retime the running beat. + fast.phase.mu.Lock() + fast.phase.beat = time.Hour + fast.phase.mu.Unlock() + + deadline := time.Now().Add(5 * time.Second) + for { + n := 0 + for _, one := range log.all() { + if one.Detail == "fast" { + n++ + } + } + if n >= 4 { + break + } + if time.Now().After(deadline) { + t.Fatalf("the fast agent's captured beat was not used; said %d times", n) + } + time.Sleep(time.Millisecond) + } + slowSaid := 0 + for _, one := range log.all() { + if one.Detail == "slow" { + slowSaid++ + } + } + if slowSaid != 1 { + t.Fatalf("the default-beat agent said itself %d times, want only its start", slowSaid) + } +} + // TestTheBeatIsComfortablyInsideTheWindowASurfaceDrops is the one-source-of-truth // law between two packages, checked rather than commented. // From 664803e6407324c8f04096ec68c373326b01631d Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 19:56:30 -0400 Subject: [PATCH 058/459] taskmodels: the public settings.models contract and the decision's mode, warnings and no-eligible refusal Co-Authored-By: Claude Opus 5.5 --- internal/appwire/taskmodels.go | 440 +++++++++++++++++++++++++++++++ internal/appwire/wire.go | 4 + internal/crewroute/route.go | 7 + internal/crewroute/taskmodels.go | 122 +++++++++ 4 files changed, 573 insertions(+) create mode 100644 internal/appwire/taskmodels.go create mode 100644 internal/crewroute/taskmodels.go diff --git a/internal/appwire/taskmodels.go b/internal/appwire/taskmodels.go new file mode 100644 index 0000000000..a6fc343b52 --- /dev/null +++ b/internal/appwire/taskmodels.go @@ -0,0 +1,440 @@ +package appwire + +import ( + "encoding/json" + "strings" + "time" + "unicode" +) + +// V79: TASK MODELS — how one task gets its worker, planner and checker. +// +// These are the wire's shapes, and the ONLY shapes an app reads: the engine's +// own policy (the profile's rows, the router's candidates, the providers' own +// listings) is turned into these by the engine, and the app draws them. Nothing +// here is a budget — the per-task and per-day caps are settings.get's +// task_cap_usd and day_cap_usd, set through settings.set, and this method +// family never carries a second copy of either. +// +// A role is EXACTLY one of worker, planner, checker; a mode is auto or one. +// A change applies to tasks admitted after it: a task that already started +// keeps the picks it was admitted with. + +// The modes, the roles, and the words a role's warning may be. +const ( + TaskModelsAuto = "auto" + TaskModelsOne = "one" + + RoleWorker = "worker" + RolePlanner = "planner" + RoleChecker = "checker" + + // WarnPinNotAllowed is a role pinned to a model the allowed models no + // longer admit: the pin is kept, and the role runs on Auto until the + // model is allowed again or the pin is removed. + WarnPinNotAllowed = "pin_not_allowed" + // WarnProviderUnavailable is a pin (or the one model) whose provider is + // not connected, turned off, signed out, refused its key or out of credit. + WarnProviderUnavailable = "provider_unavailable" + // WarnPinUnknown is a saved pin naming a model no connected provider + // lists. It is kept as written, never silently deleted. + WarnPinUnknown = "pin_unknown" + // WarnNoEligible is an unpinned role with no allowed model that can do + // it: a task that needs it waits and asks. + WarnNoEligible = "no_eligible" +) + +// The provider states, the one fix the engine can offer, and why a provider +// is unavailable. +const ( + ProviderConnected = "connected" + ProviderExpired = "expired" + ProviderNoKey = "no_key" + ProviderUnavailable = "unavailable" + + FixNone = "none" + FixSignIn = "sign_in" + FixAddKey = "add_key" +) + +// The reasons a model row is not one Auto may pick, and how a row bills. +const ( + ModelNotAllowed = "not_allowed" + ModelNoPrice = "no_price" + ModelProviderUnavailable = "provider_unavailable" + ModelRouteUnavailable = "route_unavailable" + ModelCannotDoRole = "cannot_do_role" +) + +// TaskModelsLister is an engine that serves Task models. It is asserted, as +// [Competencer] is, so an engine without it answers unknown_method. +type TaskModelsLister interface { + TaskModels() (TaskModels, error) + TaskModelsList(p TaskModelsListParams) (TaskModelsPage, error) + SetTaskModels(p TaskModelsPatch) (TaskModels, error) +} + +// ModelRef names one model on one provider's route: the catalog id, and the +// provider id that route goes through. An empty provider is the model on +// whichever connected provider reaches it first. +type ModelRef struct { + ID string `json:"id"` + Provider string `json:"provider,omitempty"` +} + +// TaskModels is settings.models.get's answer, and settings.changed's +// task_models. It is small on purpose: provider headers and counts, never the +// model list, which is settings.models.list's, page by page. +type TaskModels struct { + ReadAt time.Time `json:"read_at"` + // Revision changes whenever any row behind this answer changes; a + // settings.models.set that carries a revision is refused with conflict + // when it no longer matches. + Revision string `json:"revision"` + Mode string `json:"mode" enum:"auto,one"` + // OneModel is the model one-model mode runs, kept while Auto is on so the + // choice comes back; null when none was ever chosen. + OneModel *ModelRef `json:"one_model"` + OneModelWarning *string `json:"one_model_warning" enum:"provider_unavailable,pin_unknown"` + Roles TaskModelRoles `json:"roles"` + Providers []TaskModelsProvider `json:"providers"` +} + +// TaskModelRoles is the three roles, each by its exact key. +type TaskModelRoles struct { + Worker TaskModelRole `json:"worker"` + Planner TaskModelRole `json:"planner"` + Checker TaskModelRole `json:"checker"` +} + +// TaskModelRole is one role: its saved pin (null is Auto), what is wrong with +// it, and how many allowed models Auto could pick for it right now. +type TaskModelRole struct { + Pin *ModelRef `json:"pin"` + Warning *string `json:"warning" enum:"pin_not_allowed,provider_unavailable,pin_unknown,no_eligible"` + EligibleCount int `json:"eligible_count"` +} + +// TaskModelsProvider is one provider's header. A provider with no key is +// listed with no models; a provider whose key or sign-in stopped working keeps +// its models listed (and ticked) and says why, with the one fix the engine can +// start. KeyHint is the key's last four characters and nothing more. +type TaskModelsProvider struct { + ID string `json:"id"` + Name string `json:"name"` + State string `json:"state" enum:"connected,expired,no_key,unavailable"` + Fix string `json:"fix" enum:"none,sign_in,add_key"` + Reason string `json:"reason,omitempty" enum:"key_refused,signed_out,out_of_credit,turned_off"` + KeyHint string `json:"key_hint,omitempty"` + ModelCount int `json:"model_count"` + AllowedCount int `json:"allowed_count"` +} + +// TaskModelsListParams is settings.models.list's params. Limit is 40 when +// absent and at most 100; q is at most 100 characters; a cursor is only good +// for the same filters and the same revision it came from. +type TaskModelsListParams struct { + Provider string `json:"provider,omitempty"` + Q string `json:"q,omitempty"` + AllowedOnly bool `json:"allowed_only,omitempty"` + Role string `json:"role,omitempty" enum:"worker,planner,checker"` + Cursor string `json:"cursor,omitempty"` + Limit int `json:"limit,omitempty"` +} + +// The list's bounds, spelled once. +const ( + TaskModelsPageDefault = 40 + TaskModelsPageMax = 100 + TaskModelsQueryMax = 100 + TaskModelsAllowedMax = 200 + ModelRefIDMax = 200 + ModelRefProviderMax = 64 +) + +// TaskModelsPage is one page of model rows, providers in the header's order, +// then each provider's models in its own listing's order. +type TaskModelsPage struct { + ReadAt time.Time `json:"read_at"` + Revision string `json:"revision"` + Models []TaskModelRow `json:"models"` + Total int `json:"total"` + NextCursor string `json:"next_cursor,omitempty"` +} + +// TaskModelRow is one model on one provider. Price is that PROVIDER'S OWN +// published price per million tokens, and null when that provider published +// none — never another provider's figure, never an estimate. Allowed is the +// row's tick; Available is whether the route answers right now; AutoEligible +// is whether Auto may pick it today, and Reason says why not. +type TaskModelRow struct { + ID string `json:"id"` + Provider string `json:"provider"` + Name string `json:"name"` + Billing string `json:"billing" enum:"metered,plan,local,free"` + Allowed bool `json:"allowed"` + Available bool `json:"available"` + AutoEligible bool `json:"auto_eligible"` + Price *TaskModelPrice `json:"price"` + Reason string `json:"reason,omitempty" enum:"not_allowed,no_price,provider_unavailable,route_unavailable,cannot_do_role"` +} + +// TaskModelPrice is dollars per million tokens in and out. +type TaskModelPrice struct { + InPerM float64 `json:"in_per_m"` + OutPerM float64 `json:"out_per_m"` +} + +// TaskModelsPatch is settings.models.set's params as the engine reads them: +// every part optional, a pin present as null is Auto. Every part is checked +// before anything is written, and the whole patch is one write. +type TaskModelsPatch struct { + Revision string + Mode *string + OneModel *ModelRef + // Pins holds a role only when the patch names it; a nil value is Auto. + Pins map[string]*ModelRef + Allowed []TaskModelAllowed +} + +// TaskModelAllowed is one row ticked or unticked. +type TaskModelAllowed struct { + Model ModelRef `json:"model"` + Allowed bool `json:"allowed"` +} + +// TaskModelsPatchWire is settings.models.set's params as they travel. +type TaskModelsPatchWire struct { + Revision string `json:"revision,omitempty"` + Mode *string `json:"mode,omitempty" enum:"auto,one"` + OneModel *ModelRef `json:"one_model,omitempty"` + Pins *TaskModelPinsWire `json:"pins,omitempty"` + Allowed []TaskModelAllowed `json:"allowed,omitempty"` +} + +// TaskModelPinsWire is a patch's pins: a role left out is unchanged, a role +// sent as null is Auto. +type TaskModelPinsWire struct { + Worker *ModelRef `json:"worker,omitempty" nullable:"true"` + Planner *ModelRef `json:"planner,omitempty" nullable:"true"` + Checker *ModelRef `json:"checker,omitempty" nullable:"true"` +} + +// TaskModelsResult is settings.models.get's and settings.models.set's answer. +type TaskModelsResult struct { + TaskModels TaskModels `json:"task_models"` +} + +func init() { + methods["settings.models.get"] = typed(func(e Engine, _ struct{}) (*TaskModelsResult, error) { + l, err := taskModelsLister(e, "settings.models.get") + if err != nil { + return nil, err + } + got, err := l.TaskModels() + if err != nil { + return nil, err + } + return &TaskModelsResult{got}, nil + }) + methods["settings.models.list"] = typed(func(e Engine, p TaskModelsListParams) (*TaskModelsPage, error) { + l, err := taskModelsLister(e, "settings.models.list") + if err != nil { + return nil, err + } + if p, err = checkTaskModelsList(p); err != nil { + return nil, err + } + page, err := l.TaskModelsList(p) + if err != nil { + return nil, err + } + return &page, nil + }) + methods["settings.models.set"] = shaped[TaskModelsPatchWire](func(e Engine, raw json.RawMessage) (*TaskModelsResult, error) { + l, err := taskModelsLister(e, "settings.models.set") + if err != nil { + return nil, err + } + patch, err := decodeTaskModelsPatch(raw) + if err != nil { + return nil, err + } + got, err := l.SetTaskModels(patch) + if err != nil { + return nil, err + } + return &TaskModelsResult{got}, nil + }) +} + +func taskModelsLister(e Engine, name string) (TaskModelsLister, error) { + l, ok := e.(TaskModelsLister) + if !ok { + return nil, Errorf(CodeUnknownMethod, "this engine does not serve %s", name) + } + return l, nil +} + +// TaskModelRoleKeys is the three role keys, in the order they are read out. +var TaskModelRoleKeys = []string{RoleWorker, RolePlanner, RoleChecker} + +// checkTaskModelsList bounds a list request before the engine sees it. +func checkTaskModelsList(p TaskModelsListParams) (TaskModelsListParams, error) { + switch { + case p.Limit < 0: + return p, Errorf(CodeBadParams, "limit is 1 to %d", TaskModelsPageMax) + case p.Limit == 0: + p.Limit = TaskModelsPageDefault + case p.Limit > TaskModelsPageMax: + return p, Errorf(CodeBadParams, "limit is at most %d", TaskModelsPageMax) + } + p.Q = strings.TrimSpace(p.Q) + if len([]rune(p.Q)) > TaskModelsQueryMax { + return p, Errorf(CodeBadParams, "q is at most %d characters", TaskModelsQueryMax) + } + if strings.IndexFunc(p.Q, unicode.IsControl) >= 0 { + return p, Errorf(CodeBadParams, "q is one line of text") + } + if p.Role != "" && !taskModelRole(p.Role) { + return p, Errorf(CodeBadParams, "role is worker, planner or checker, not %q", p.Role) + } + if p.Provider != "" { + provider, err := checkProviderID(p.Provider) + if err != nil { + return p, err + } + p.Provider = provider + } + if len(p.Cursor) > 512 { + return p, Errorf(CodeBadParams, "cursor is not one this engine gave out") + } + return p, nil +} + +func taskModelRole(word string) bool { + return word == RoleWorker || word == RolePlanner || word == RoleChecker +} + +// checkModelRef bounds and canonicalises one model reference. +func checkModelRef(field string, ref ModelRef) (ModelRef, error) { + id := strings.TrimSpace(ref.ID) + switch { + case id == "": + return ref, Errorf(CodeBadParams, "%s needs a model id", field) + case len(id) > ModelRefIDMax: + return ref, Errorf(CodeBadParams, "%s: a model id is at most %d characters", field, ModelRefIDMax) + case strings.IndexFunc(id, func(r rune) bool { return unicode.IsSpace(r) || unicode.IsControl(r) }) >= 0: + return ref, Errorf(CodeBadParams, "%s: a model id has no spaces", field) + case strings.Contains(id, "@"): + return ref, Errorf(CodeBadParams, "%s: name the provider in provider, not after @", field) + } + out := ModelRef{ID: id} + if strings.TrimSpace(ref.Provider) != "" { + provider, err := checkProviderID(ref.Provider) + if err != nil { + return ref, Errorf(CodeBadParams, "%s: %s", field, err.(*Error).Message) + } + out.Provider = provider + } + return out, nil +} + +// checkProviderID bounds a provider id: lowercase letters, digits, - _ . +func checkProviderID(raw string) (string, error) { + id := strings.ToLower(strings.TrimSpace(raw)) + if id == "" || len(id) > ModelRefProviderMax || strings.IndexFunc(id, func(r rune) bool { + return !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '-' || r == '_' || r == '.') + }) >= 0 { + return "", Errorf(CodeBadParams, "%q is not a provider id", raw) + } + return id, nil +} + +// decodeTaskModelsPatch reads settings.models.set's params: the parts present, +// each checked, nothing it does not know, and no row named twice. +func decodeTaskModelsPatch(raw json.RawMessage) (TaskModelsPatch, error) { + var fields map[string]json.RawMessage + if err := decode(raw, &fields); err != nil { + return TaskModelsPatch{}, err + } + var patch TaskModelsPatch + for name, value := range fields { + switch name { + case "revision": + if json.Unmarshal(value, &patch.Revision) != nil || len(patch.Revision) > 128 { + return patch, Errorf(CodeBadParams, "revision is the string settings.models.get answered") + } + case "mode": + var mode string + if json.Unmarshal(value, &mode) != nil || (mode != TaskModelsAuto && mode != TaskModelsOne) { + return patch, Errorf(CodeBadParams, "mode is auto or one") + } + patch.Mode = &mode + case "one_model": + var ref ModelRef + if string(value) == "null" || json.Unmarshal(value, &ref) != nil { + return patch, Errorf(CodeBadParams, "one_model is {id, provider?}") + } + ref, err := checkModelRef("one_model", ref) + if err != nil { + return patch, err + } + patch.OneModel = &ref + case "pins": + var pins map[string]json.RawMessage + if json.Unmarshal(value, &pins) != nil || pins == nil { + return patch, Errorf(CodeBadParams, "pins is {worker?, planner?, checker?}") + } + patch.Pins = map[string]*ModelRef{} + for role, pin := range pins { + if !taskModelRole(role) { + return patch, Errorf(CodeBadParams, "pins: %q is not a role · worker, planner or checker", role) + } + if string(pin) == "null" { + patch.Pins[role] = nil + continue + } + var ref ModelRef + if json.Unmarshal(pin, &ref) != nil { + return patch, Errorf(CodeBadParams, "pins.%s is {id, provider?} or null", role) + } + ref, err := checkModelRef("pins."+role, ref) + if err != nil { + return patch, err + } + patch.Pins[role] = &ref + } + case "allowed": + var rows []TaskModelAllowed + if json.Unmarshal(value, &rows) != nil { + return patch, Errorf(CodeBadParams, "allowed is [{model: {id, provider}, allowed}]") + } + if len(rows) > TaskModelsAllowedMax { + return patch, Errorf(CodeBadParams, "allowed changes at most %d rows at once", TaskModelsAllowedMax) + } + seen := map[ModelRef]bool{} + for i, row := range rows { + ref, err := checkModelRef("allowed", row.Model) + if err != nil { + return patch, err + } + if ref.Provider == "" { + return patch, Errorf(CodeBadParams, "allowed: %s needs its provider · a row is one model on one provider", ref.ID) + } + if seen[ref] { + return patch, Errorf(CodeBadParams, "allowed names %s on %s twice", ref.ID, ref.Provider) + } + seen[ref] = true + rows[i].Model = ref + } + patch.Allowed = rows + default: + return patch, Errorf(CodeBadParams, "settings.models.set has no %s", name) + } + } + if patch.Mode == nil && patch.OneModel == nil && patch.Pins == nil && patch.Allowed == nil { + return patch, Errorf(CodeBadParams, "settings.models.set needs mode, one_model, pins or allowed") + } + return patch, nil +} diff --git a/internal/appwire/wire.go b/internal/appwire/wire.go index c112bd10aa..c7ff96a5d6 100644 --- a/internal/appwire/wire.go +++ b/internal/appwire/wire.go @@ -603,6 +603,10 @@ type ( } SettingsChanged struct { Settings Settings `json:"settings"` + // TaskModels is settings.models.get's answer as it reads now, sent by + // an engine that serves Task models whenever settings change; absent + // from an engine that does not ([TaskModelsLister]). + TaskModels *TaskModels `json:"task_models,omitempty"` } // ProviderChanged carries the key a browser sign-in produced, once, so // the app can keep it in the keychain and hand it back at the next launch. diff --git a/internal/crewroute/route.go b/internal/crewroute/route.go index 3a6af5229b..7180123f43 100644 --- a/internal/crewroute/route.go +++ b/internal/crewroute/route.go @@ -315,6 +315,13 @@ type Decision struct { // against their estimates ([router.CrewLog.CostFactor]); EstUSD carries // it. Zero is none learned yet. CostFactor float64 `json:",omitempty"` + // Mode is how the roles were chosen at admission ([ModeAuto], [ModeOne]); + // empty on a decision from before modes, which reads as auto. + Mode Mode `json:",omitempty"` + // Warnings are what was wrong with the saved pins at admission: a pin the + // allowed models no longer admit (the role was routed instead), or a pin + // whose provider was unavailable. + Warnings []SeatWarning `json:",omitempty"` } // Unspent is the actual cost a line is drawn with when there is none worth diff --git a/internal/crewroute/taskmodels.go b/internal/crewroute/taskmodels.go new file mode 100644 index 0000000000..1544120baf --- /dev/null +++ b/internal/crewroute/taskmodels.go @@ -0,0 +1,122 @@ +package crewroute + +import ( + "fmt" + "strings" +) + +// WHAT A DECISION SAYS ABOUT THE POLICY IT WAS ADMITTED UNDER. +// +// A decision is read at a task's admission and frozen with it, so whatever a +// person changes afterwards reaches the next task and never the running one. +// Three facts travel with it for whoever draws the task: which mode admitted it +// (the router's pick per role, or one model for every role), what was wrong +// with the person's saved pins at that moment, and — when no decision could be +// formed — which role had nothing to run on and why each model was turned away. +// None of the three is a reading of the settings later: a surface that wants +// to know what a task ran on reads its decision, never today's settings. + +// Mode is how a decision's roles were chosen. +type Mode string + +const ( + // ModeAuto is the router's pick per role, inside the allowed models, with + // any eligible pin kept as written. An empty Mode on a decision written + // before modes existed reads as auto. + ModeAuto Mode = "auto" + // ModeOne is one model for every role, the person's explicit choice: no + // role is picked, and no role moves to another model on its own. + ModeOne Mode = "one" +) + +// WarningKind is what was wrong with a role's saved pin at admission. +type WarningKind string + +const ( + // PinNotAllowed is a pin the allowed models no longer admit. The pin stays + // saved; the role was routed by Auto for this task. + PinNotAllowed WarningKind = "pin_not_allowed" + // PinProviderUnavailable is a pin (or the one model) whose provider is not + // connected, turned off, or refused at the moment of admission. + PinProviderUnavailable WarningKind = "provider_unavailable" +) + +// SeatWarning is one role's warning, with the pin it is about. +type SeatWarning struct { + Seat Seat + Kind WarningKind + Model string + Provider string `json:",omitempty"` +} + +// RejectReason is why one model could not take a role. +type RejectReason string + +const ( + // RejectNotAllowed is a model the allowed models leave out on that route. + RejectNotAllowed RejectReason = "not_allowed" + // RejectNoPrice is a model whose provider published no price: a pin may + // name it, Auto never picks it. + RejectNoPrice RejectReason = "no_price" + // RejectProviderUnavailable is a route whose provider is not connected, + // turned off, signed out, refused its key or out of credit. + RejectProviderUnavailable RejectReason = "provider_unavailable" + // RejectRouteUnavailable is a route resting after failures — cooling down, + // kept out, or not served any more. + RejectRouteUnavailable RejectReason = "route_unavailable" + // RejectCannotDoRole is a model that cannot do the role: no tool calls, + // not text, or too little context. + RejectCannotDoRole RejectReason = "cannot_do_role" +) + +// Rejection is one model on one route turned away from a role. +type Rejection struct { + Model string + Provider string `json:",omitempty"` + Reason RejectReason +} + +// MaxRejections bounds how many rejections one error carries; the rest are +// counted in [NoEligibleError.Omitted]. +const MaxRejections = 20 + +// NoEligibleError is a role with NOTHING ALLOWED TO RUN ON: no eligible pin, +// no allowed model on a reachable route that can do it. The task is not +// started and is not moved onto any other model — not the conversation's own, +// not the last crew's — so whoever admitted it can stop and ask. +type NoEligibleError struct { + Seat Seat + Mode Mode + // Rejected is why each considered model was turned away, at most + // [MaxRejections] of them, allowed models first. + Rejected []Rejection + Omitted int + // Action is the one plain thing a person can do, when the routes say one + // (add credit, reconnect); empty otherwise. + Action string + // Cause is an older error this one stands for, so a caller matching that + // error still finds it ([errors.As]). + Cause error +} + +func (e *NoEligibleError) Error() string { + if e.Action != "" { + return e.Action + } + if e.Mode == ModeOne { + return fmt.Sprintf("the one model for every task cannot run the %s role", e.Seat) + } + return fmt.Sprintf("no allowed model can do the %s role · allow one, or pin one", e.Seat) +} + +// Unwrap is the error this one stands for. +func (e *NoEligibleError) Unwrap() error { return e.Cause } + +// Reject adds one rejection, keeping the bound. +func (e *NoEligibleError) Reject(model, provider string, reason RejectReason) { + if len(e.Rejected) >= MaxRejections { + e.Omitted++ + return + } + e.Rejected = append(e.Rejected, Rejection{Model: model, Provider: strings.ToLower(provider), Reason: reason}) +} From 05bb2728489f6b22f0d359a25825c84c3541113d Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 20:07:02 -0400 Subject: [PATCH 059/459] taskmodels: per-provider ticks, kept pins, one-model mode at admission and the typed no-eligible refusal The allowed rule gains model@provider words, so a tick is one model on one route. A rule that leaves a pin outside is written and the pin kept; the role is routed with a pin_not_allowed warning on the decision. One-model mode is a profile row read at admission. A role with nothing allowed is a typed NoEligibleError and is never rescued onto the chat model or a disallowed one. Task models are read row by row with each provider's own price and written whole, onto the rows they were checked against. Co-Authored-By: Claude Opus 5.5 --- internal/config/budget.go | 23 + internal/config/config.go | 4 + internal/config/crew.go | 132 ++-- internal/config/crew_test.go | 19 +- internal/config/crewhealth.go | 26 +- internal/config/taskmodelrows.go | 627 +++++++++++++++++++ internal/config/taskmodels.go | 209 +++++++ internal/config/taskmodels_test.go | 431 +++++++++++++ internal/config/testdata/profile-keys.ledger | 2 + internal/crewroute/allowed.go | 100 ++- internal/crewroute/allowed_route_test.go | 67 ++ internal/tui3/crew.go | 4 + internal/tui3/crewpanel.go | 5 + 13 files changed, 1586 insertions(+), 63 deletions(-) create mode 100644 internal/config/taskmodelrows.go create mode 100644 internal/config/taskmodels.go create mode 100644 internal/config/taskmodels_test.go create mode 100644 internal/crewroute/allowed_route_test.go diff --git a/internal/config/budget.go b/internal/config/budget.go index f12eced158..2b7ceb34af 100644 --- a/internal/config/budget.go +++ b/internal/config/budget.go @@ -1,7 +1,9 @@ package config import ( + "bytes" "encoding/json" + "errors" "fmt" "maps" "math" @@ -129,6 +131,21 @@ var removeProfileKey = profileKeyRemoval{} type profileKeyRemoval struct{} func writeProfileValues(profileDir string, updates map[string]any) error { + return writeProfileValuesExpecting(profileDir, nil, updates) +} + +// errProfileMoved is a write refused because a row it was decided on changed +// between the read and the write ([writeProfileValuesExpecting]). +var errProfileMoved = errors.New("these settings changed while the change was being made") + +// writeProfileValuesExpecting is [writeProfileValues] that first checks, UNDER +// THE SAME LOCK AS THE WRITE, that every row in expect still holds exactly the +// bytes it was read with (a nil value: still absent), and refuses with +// [errProfileMoved] otherwise. It is how a change computed from several rows — +// a rule edited a tick at a time, a pin judged against that rule — lands on the +// rows it was computed from, or not at all, rather than over a write that came +// in between. +func writeProfileValuesExpecting(profileDir string, expect map[string]json.RawMessage, updates map[string]any) error { if err := refuseAppCredentialUpdates(updates); err != nil { return err } @@ -175,6 +192,12 @@ func writeProfileValues(profileDir string, updates map[string]any) error { if err != nil { return fmt.Errorf("write config: preserve existing file: %w", err) } + for name, want := range expect { + got, present := held[name] + if present != (want != nil) || (present && !bytes.Equal(bytes.TrimSpace(got), bytes.TrimSpace(want))) { + return errProfileMoved + } + } // THE WRITER OWNS ITS OWN MAP. [readProfileConfig] hands back the map its // memo is holding, and adding a row to that map in place would rewrite what // every reader in this package is about to be told the file says — including diff --git a/internal/config/config.go b/internal/config/config.go index 6fb49d5d10..97a9406bd8 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -370,6 +370,10 @@ var nonSettingProfileFields = []string{ KeyCrewRouteWorker, KeyCrewRoutePlanner, KeyCrewRouteChecker, + // Task models' mode and its one model (taskmodels.go), written from the + // app's Task models page and read at every task's admission. + KeyTaskModelsMode, + KeyTaskModelsOne, } // retiredProfileKeys are top-level config.json keys that a shipped version once diff --git a/internal/config/crew.go b/internal/config/crew.go index 8626e24795..ea023b225d 100644 --- a/internal/config/crew.go +++ b/internal/config/crew.go @@ -4,6 +4,7 @@ import ( "encoding/json" "errors" "fmt" + "math" "strings" "time" @@ -284,20 +285,20 @@ func ClearCrewPins(profileDir string) error { } // CrewPinAllowed says why a pin may not be written, or nil. The model must be -// one the allowed rule admits — by its figures when the catalog knows it, by -// name otherwise — and a pinned provider must be connected and must reach it. +// one the allowed rule admits ON THE ROUTE THE PIN RUNS — by its figures when +// the catalog knows it, by name otherwise — and a pinned provider must be +// connected and must reach it. func CrewPinAllowed(profileDir string, pin CrewPin) error { rule := CrewAllowedAt(profileDir) model, known := crewCatalogModel(pin.Model) - switch { - case known && !rule.AdmitsModel(model): + route := resolveCrewPin(pin, CrewProvidersAt(profileDir)).Provider + if !crewPinAdmitted(rule, pin.Model, route) { + if !known && rule.Base != crewroute.BaseAll { + return fmt.Errorf("%s is not in the catalog, so the rule %s cannot admit it by price or licence · name it with /crew models +%s", + pin.Model, rule.String(), pin.Model) + } return fmt.Errorf("%s is outside the models you allow (%s) · widen them with /crew models +%s", pin.Model, rule.String(), crewroute.ShortModel(pin.Model)) - case !known && rule.Base != crewroute.BaseAll && !rule.NamesModel(pin.Model): - return fmt.Errorf("%s is not in the catalog, so the rule %s cannot admit it by price or licence · name it with /crew models +%s", - pin.Model, rule.String(), pin.Model) - case !known && rule.Base == crewroute.BaseAll && !rule.AdmitsModel(crewroute.Model{ID: pin.Model}): - return fmt.Errorf("%s is outside the models you allow (%s)", pin.Model, rule.String()) } if pin.Provider != "" { provider, ok := crewProviderByID(CrewProvidersAt(profileDir), pin.Provider) @@ -317,6 +318,19 @@ func CrewPinAllowed(profileDir string, pin CrewPin) error { return nil } +// crewPinAdmitted is whether the rule admits a pinned model on the route it +// runs. A model the catalog knows is judged by its figures; one it does not +// know cannot be judged by a price or a licence, so under `open` or a price it +// is admitted only by name, and under `all` or a list exactly as any model. +func crewPinAdmitted(rule crewroute.Allowed, id, provider string) bool { + base, _ := roles.SplitEffort(strings.TrimSpace(id)) + model, known := crewCatalogModel(base) + if !known { + model = crewroute.Model{ID: stripCrewRoute(base), PromptPrice: math.Inf(1), CompletionPrice: math.Inf(1)} + } + return rule.AdmitsOn(model, provider) +} + // ── allowed models and the cap ────────────────────────────────────────────── // CrewAllowedAt is the allowed-models rule. A rule that does not parse — a @@ -332,7 +346,7 @@ func CrewAllowedAt(profileDir string) crewroute.Allowed { } // SetCrewAllowed writes the rule in its canonical spelling, refusing one that -// does not parse and one that would leave a pinned seat outside it. +// does not parse. func SetCrewAllowed(profileDir, raw string) error { rule, err := crewroute.ParseAllowed(raw) if err != nil { @@ -352,20 +366,28 @@ func ModifyCrewAllowed(profileDir string, add bool, word string) error { } // writeCrewAllowed is the rule's one writer. +// +// A RULE THAT LEAVES A PINNED ROLE OUTSIDE IS WRITTEN, AND THE PIN IS KEPT. The +// pin is still the person's word about that role and is not deleted behind +// their back; until the model is allowed again or the pin is removed, the role +// is routed like any unpinned one and every task admitted says so +// ([CrewPinBlockedAt], the decision's warnings). Refusing the rule instead left +// a person who unticked one model with a list they could not edit. func writeCrewAllowed(profileDir string, rule crewroute.Allowed) error { - for _, seat := range crewroute.Seats { - pin, ok := CrewPinAt(profileDir, seat) - if !ok { - continue - } - if model, known := crewCatalogModel(pin.Model); (known && !rule.AdmitsModel(model)) || (!known && rule.Base != crewroute.BaseAll && !rule.NamesModel(pin.Model)) { - return fmt.Errorf("your %s is pinned to %s, which that rule leaves out · /crew unpin %s first, or add +%s", - seat, pin.Model, seat, crewroute.ShortModel(pin.Model)) - } - } return writeProfileValue(profileDir, KeyCrewAllowed, rule.String()) } +// CrewPinBlockedAt is whether a role's saved pin is one the allowed models no +// longer admit, which leaves the role routed by Auto. +func CrewPinBlockedAt(profileDir string, seat crewroute.Seat) bool { + pin, ok := CrewPinAt(profileDir, seat) + if !ok { + return false + } + route := resolveCrewPin(pin, CrewProvidersAt(profileDir)).Provider + return !crewPinAdmitted(CrewAllowedAt(profileDir), pin.Model, route) +} + // CrewCapAt is the daily cap on crew spend, zero for none. func CrewCapAt(profileDir string) float64 { value, ok := persistedFloat(profileDir, KeyCrewCap) @@ -951,12 +973,11 @@ func crewCandidatesWith(rule crewroute.Allowed, providers []CrewProvider, facts } var out []crewroute.Candidate for _, m := range models { - if !rule.AdmitsModel(m) { - continue - } var plans, direct, fallback []crewroute.Route for _, p := range providers { - if !rule.AdmitsRoute(p.ID) { + // THE RULE IS READ PER ROUTE: a model may be allowed on one + // provider and not another ([crewroute.Allowed.AdmitsOn]). + if !rule.AdmitsOn(m, p.ID) { continue } r, ok := p.route(m.ID, m, true) @@ -974,7 +995,7 @@ func crewCandidatesWith(rule crewroute.Allowed, providers []CrewProvider, facts } if send, ok := free[crewroute.Lineage(m.ID)]; ok { for _, p := range providers { - if p.ID == modelsource.DefaultID && rule.AdmitsRoute(p.ID) { + if p.ID == modelsource.DefaultID && rule.AdmitsOn(m, p.ID) { plans = append(plans, crewroute.Route{Provider: p.ID, Send: send, Kind: crewroute.Free}) } } @@ -1068,12 +1089,10 @@ func CrewOffersAt(profileDir string) []CrewOffer { // ── end of the seat check ── offer := CrewOffer{Model: c.Model, Routes: c.Routes} offer.Served = len(off.Routes(c.Routes)) > 0 - if rule.AdmitsModel(c.Model) { - for _, r := range off.Routes(c.Routes) { - if rule.AdmitsRoute(r.Provider) { - offer.Allowed = true - break - } + for _, r := range off.Routes(c.Routes) { + if rule.AdmitsOn(c.Model, r.Provider) { + offer.Allowed = true + break } } out = append(out, offer) @@ -1082,9 +1101,7 @@ func CrewOffersAt(profileDir string) []CrewOffer { } // SetCrewAllowedRule writes a rule the panel composed ([crewroute.Allowed]'s -// edits) through the rule's one writer, so a checklist tick is refused for -// exactly the reason the typed form would be — a pinned seat it would leave -// outside. +// edits) through the rule's one writer, the same writer the typed form uses. func SetCrewAllowedRule(profileDir string, rule crewroute.Allowed) error { return writeCrewAllowed(profileDir, rule) } @@ -1139,16 +1156,30 @@ func RestoreCrewState(profileDir string, state CrewState) error { // one-line warning. A pinned seat is judged by its pin alone, read from the // catalog whether or not a route to it is healthy right now, because the pin // is what the seat runs. +// +// A PIN THE ALLOWED MODELS LEAVE OUT IS SAID FIRST, by name: it is kept, and +// the seat is routed until the model is allowed again or the pin is removed — +// the one state of the panel a person could not otherwise see. func CrewGapsAt(profileDir string) []crewroute.Gap { pins := map[crewroute.Seat]crewroute.Model{} - for seat, pin := range CrewPinsAt(profileDir) { + var aside []crewroute.Gap + for _, seat := range crewroute.Seats { + pin, ok := CrewPinAt(profileDir, seat) + if !ok { + continue + } + if CrewPinBlockedAt(profileDir, seat) { + aside = append(aside, crewroute.Gap{Seat: seat, Line: string(seat) + " pinned to " + crewroute.ShortModel(pin.Model) + + ", which the models you allow leave out · routed until you allow it or unpin it"}) + continue + } model, known := crewCatalogModel(pin.Model) if !known { model = crewroute.Model{ID: pin.Model} } pins[seat] = model } - return crewroute.Gaps(CrewCandidatesAt(profileDir), pins) + return append(aside, crewroute.Gaps(CrewCandidatesAt(profileDir), pins)...) } // ── one task's crew ───────────────────────────────────────────────────────── @@ -1332,27 +1363,37 @@ func OffsetKey(repo string, class crewroute.Class) string { } // RouteCrew picks one task's crew on this profile: the seats pinned there or -// for this task run their pins, and every other seat is routed. +// for this task run their pins, and every other seat is routed. It is read at +// the task's ADMISSION, and the decision it answers is what the task runs for +// its whole life, whatever changes on the profile after. +// +// In one-model mode ([TaskModelsModeAt]) every seat is the one model and +// nothing is routed. Otherwise a saved pin the allowed models no longer admit is +// set aside for this task — the seat is routed — and the decision's warnings say +// so; the pin itself stays saved. // // The class is read first, so the learned offset for this repository and this // class of work can move the price of a point before the seats are picked. A // day at its cap still answers with the crew it would run, beside -// [ErrCrewAtCap]; a seat nothing allowed can sit is a [crewroute.NoCandidateError]. +// [ErrCrewAtCap]; a seat nothing allowed can sit is a [*crewroute.NoEligibleError], +// and is never seated on a model the allowed models leave out. func RouteCrew(profileDir string, ask CrewAsk) (crewroute.Decision, error) { + if TaskModelsModeAt(profileDir) == crewroute.ModeOne { + return routeOneModel(profileDir, ask) + } reading := crewroute.Classify(ask.Task) providers := CrewProvidersAt(profileDir) - pins := map[crewroute.Seat]crewroute.Pin{} - for seat, pin := range CrewPinsAt(profileDir) { - pins[seat] = resolveCrewPin(pin, providers) - } + pins, warnings := crewSavedPins(profileDir, providers) + filled := map[crewroute.Seat]bool{} for seat, pin := range ask.Pins { - pins[seat] = resolveCrewPin(pin, providers) + pins[seat], filled[seat] = resolveCrewPin(pin, providers), true } for seat, send := range ask.Sends { if send = strings.TrimSpace(send); send != "" { - pins[seat] = resolveCrewPin(CrewPin{Model: send}, providers) + pins[seat], filled[seat] = resolveCrewPin(CrewPin{Model: send}, providers), true } } + warnings = withoutWarningsFor(warnings, filled) day := CrewDay{} if CrewHistory != nil { day = CrewHistory(profileDir) @@ -1387,7 +1428,7 @@ func RouteCrew(profileDir string, ask CrewAsk) (crewroute.Decision, error) { // NO CREW CAN BE FORMED: the seat nothing can sit goes down the rest of // the ladder — the last crew that worked here, the person's own model // — and with nothing left the answer is the one thing to do. - d, err = crewRescued(profileDir, req, health, ask.ChatModel, err) + d, err = crewRescued(profileDir, req, health, err) } if err != nil { return crewroute.Decision{}, err @@ -1395,6 +1436,7 @@ func RouteCrew(profileDir string, ask CrewAsk) (crewroute.Decision, error) { if notice != "" { d.Note = strings.TrimSpace(strings.TrimPrefix(d.Note+" · "+notice, " · ")) } + d.Mode, d.Warnings = crewroute.ModeAuto, warnings d.Why, d.Sure = reading.Why, reading.Sure d.Redo = ask.Stronger != nil || ask.Again != nil if atCap { diff --git a/internal/config/crew_test.go b/internal/config/crew_test.go index 712f717a16..53a009fe1c 100644 --- a/internal/config/crew_test.go +++ b/internal/config/crew_test.go @@ -154,7 +154,7 @@ func TestTheLadderFlagThenEnvThenPinThenRouter(t *testing.T) { } } -func TestPinsRoundTripAndRefuseWhatTheRuleLeavesOut(t *testing.T) { +func TestPinsRoundTripAndAreKeptOutsideTheRule(t *testing.T) { dir := crewProfile(t) if err := SetCrewPin(dir, crewroute.Worker, "z-ai/glm-5.3-flash@openrouter"); err != nil { t.Fatal(err) @@ -181,9 +181,20 @@ func TestPinsRoundTripAndRefuseWhatTheRuleLeavesOut(t *testing.T) { if err := SetCrewPin(dir, crewroute.Planner, "moonshotai/kimi-k3@fireworks"); err == nil || !strings.Contains(err.Error(), "not a connected provider") { t.Errorf("a pin on an unconnected provider: %v", err) } - // A rule that would strand a pin is refused and names the pin. - if err := SetCrewAllowed(dir, "≤0.1/0.2"); err == nil || !strings.Contains(err.Error(), "pinned to z-ai/glm-5.3-flash") { - t.Errorf("a rule that strands the worker pin: %v", err) + // A RULE THAT LEAVES A PIN OUTSIDE IS WRITTEN AND THE PIN IS KEPT (V79): the + // role reads as set aside, and a task routes it until the person allows the + // model again or unpins it. + if err := SetCrewAllowed(dir, "≤0.1/0.2"); err != nil { + t.Fatalf("a rule that leaves the worker pin outside: %v", err) + } + if pin, ok := CrewPinAt(dir, crewroute.Worker); !ok || pin.Model != "z-ai/glm-5.3-flash" { + t.Fatalf("the pin was not kept: %+v %v", pin, ok) + } + if !CrewPinBlockedAt(dir, crewroute.Worker) { + t.Error("a pin outside the rule does not read as set aside") + } + if err := SetCrewAllowed(dir, "open"); err != nil { + t.Fatal(err) } if err := SetCrewPin(dir, crewroute.Worker, "auto"); err != nil { t.Fatal(err) diff --git a/internal/config/crewhealth.go b/internal/config/crewhealth.go index a237b0e896..c34a81b321 100644 --- a/internal/config/crewhealth.go +++ b/internal/config/crewhealth.go @@ -414,6 +414,7 @@ func LogCrewRoute(profileDir, call string, d crewroute.Decision, repo, title str // ([crewroute.Decision.Ladder]). func CrewRescue(profileDir string, class crewroute.Class, seat crewroute.Seat, chatModel string) []crewroute.Pick { health := crewHealthAt(profileDir) + rule := CrewAllowedAt(profileDir) var out []crewroute.Pick seen := map[string]bool{} add := func(send string) { @@ -435,6 +436,12 @@ func CrewRescue(profileDir string, class crewroute.Class, seat crewroute.Seat, c if model, known := crewCatalogModel(send); known && !crewroute.Seatable(seat, crewroute.Candidate{Model: model, Routes: []crewroute.Route{{Send: pin.Send}}}) { return } + // NO RESCUE OUTSIDE THE ALLOWED MODELS: the last crew that worked and + // the person's own model are rescues only where the rule admits them + // on the route they would run. + if !crewPinAdmitted(rule, send, pin.Provider) { + return + } out = append(out, crewroute.Pick{Seat: seat, Model: pin.Model, Provider: pin.Provider, Send: pin.Send, Kind: pin.Kind}) } if last := CrewLastGood(profileDir); last != nil { @@ -499,9 +506,16 @@ var CrewLastGood = func(profileDir string) *router.CrewRecord { } // crewRescued is a decision the router could not form for want of a seat, -// formed with that seat on its rescue: the last good crew's model, then the -// person's own. Nothing to rescue it with is the one action. -func crewRescued(profileDir string, req crewroute.Request, health crewHealth, chatModel string, cause error) (crewroute.Decision, error) { +// formed with that seat on its rescue: the last good crew's model, then a free +// pool of an allowed model — ONLY EVER A MODEL THE ALLOWED MODELS ADMIT. +// +// THE PERSON'S OWN CHAT MODEL IS NOT A RESCUE AT ADMISSION. A role with nothing +// allowed to run on is a question for the person ("allow a model, or pin +// one"), answered with a typed refusal naming the role and why each model was +// turned away ([crewroute.NoEligibleError]) — not a task quietly started on a +// model they never allowed for it. (Inside a running task the seat's ladder may +// still reach the chat model when the rule admits it: [CrewRescue].) +func crewRescued(profileDir string, req crewroute.Request, health crewHealth, cause error) (crewroute.Decision, error) { var missing crewroute.NoCandidateError if !errors.As(cause, &missing) { return crewroute.Decision{}, cause @@ -512,9 +526,9 @@ func crewRescued(profileDir string, req crewroute.Request, health crewHealth, ch } var rescued []string for tries := 0; tries < len(crewroute.Seats); tries++ { - rescue := CrewRescue(profileDir, req.Class, missing.Seat, chatModel) + rescue := CrewRescue(profileDir, req.Class, missing.Seat, "") if len(rescue) == 0 { - return crewroute.Decision{}, ErrCrewUnreachable{Action: health.crewAction()} + return crewroute.Decision{}, crewNoEligible(profileDir, missing.Seat, ErrCrewUnreachable{Action: health.crewAction()}) } r := rescue[0] pins[missing.Seat] = crewroute.Pin{Model: r.Model, Provider: r.Provider, Send: r.Send, Kind: r.Kind} @@ -535,5 +549,5 @@ func crewRescued(profileDir string, req crewroute.Request, health crewHealth, ch return crewroute.Decision{}, err } } - return crewroute.Decision{}, ErrCrewUnreachable{Action: health.crewAction()} + return crewroute.Decision{}, crewNoEligible(profileDir, missing.Seat, ErrCrewUnreachable{Action: health.crewAction()}) } diff --git a/internal/config/taskmodelrows.go b/internal/config/taskmodelrows.go new file mode 100644 index 0000000000..11b068415d --- /dev/null +++ b/internal/config/taskmodelrows.go @@ -0,0 +1,627 @@ +package config + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "sort" + "strings" + + "github.com/Agent-Field/codeaf/internal/catalog" + "github.com/Agent-Field/codeaf/internal/crewroute" + "github.com/Agent-Field/codeaf/internal/modelsource" +) + +// THE TASK MODELS AS A PERSON CONFIGURES THEM: EVERY MODEL ON EVERY ROUTE. +// +// A row is one model on one connected provider, which is the unit a person +// ticks: GLM 5.3 through OpenRouter and GLM 5.3 through Z.ai are two rows and +// two ticks ([crewroute.Allowed.AdmitsOn]). Every fact on a row is read, never +// estimated: +// +// - THE PRICE IS THAT PROVIDER'S OWN. OpenRouter's rows carry OpenRouter's +// catalog price; a direct provider's carry only what its own listing +// published (a cached listing — nothing is fetched to answer); a plan, a +// local model or a free pool has no per-token price. A row with none says +// none. The router's estimates never appear here. +// - ALLOWED is the rule's verdict on that route, AVAILABLE is whether the +// route answers right now (connected, on, credit, not resting), and AUTO +// ELIGIBLE is whether the router would consider it for a task today. +// - A PROVIDER WHOSE KEY WAS REFUSED, OR WHOSE CREDIT RAN OUT, KEEPS ITS ROWS +// and their ticks: it is unavailable, not forgotten, and comes back by +// itself when it answers again. + +// ModelPrice is a provider's own price, dollars per million tokens. +type ModelPrice struct { + InPerM float64 + OutPerM float64 +} + +// TaskModelRow is one model on one connected provider's route. +type TaskModelRow struct { + Model crewroute.Model + Name string + Provider string + Kind crewroute.RouteKind + Send string + // Price is nil when the provider published none. + Price *ModelPrice + Allowed bool + Available bool + AutoEligible bool + // Reason is why Auto would not pick the row today; empty when it would. + Reason crewroute.RejectReason + // Seats are the roles the model can do on this route. + Seats map[crewroute.Seat]bool +} + +// The provider states and reasons, as a person reads them. +const ( + ProviderStateConnected = "connected" + ProviderStateExpired = "expired" + ProviderStateNoKey = "no_key" + ProviderStateUnavailable = "unavailable" + + ProviderReasonKeyRefused = "key_refused" + ProviderReasonSignedOut = "signed_out" + ProviderReasonOutOfCredit = "out_of_credit" + ProviderReasonTurnedOff = "turned_off" +) + +// The role warnings beyond the decision's own ([crewroute.WarningKind]). +const ( + TaskWarnPinUnknown = "pin_unknown" + TaskWarnNoEligible = "no_eligible" +) + +// TaskModelProvider is one provider's header. +type TaskModelProvider struct { + ID, Name string + Connected bool + Kind crewroute.RouteKind + State string + Reason string + Models int + Allowed int +} + +// TaskModelRole is one role as configured. +type TaskModelRole struct { + Pin *CrewPin + Warning string + Eligible int +} + +// TaskModelsState is the whole configuration, read at one moment. +type TaskModelsState struct { + Revision string + Mode crewroute.Mode + One *CrewPin + OneWarning string + Roles map[crewroute.Seat]TaskModelRole + Providers []TaskModelProvider +} + +// ErrTaskModelsRead is a read that could not finish inside its context. +var ErrTaskModelsRead = errors.New("task models read timed out") + +// taskModelRowCheck is how many rows are built between two looks at the +// context. +const taskModelRowCheck = 256 + +// TaskModelRowsAt is every row, providers in the order the router reads them, +// each provider's models in the catalog's order. +func TaskModelRowsAt(ctx context.Context, profileDir string) ([]TaskModelRow, error) { + return taskModelRows(ctx, profileDir) +} + +// taskModelRows builds the rows; a nil context is never cut. +func taskModelRows(ctx context.Context, profileDir string) ([]TaskModelRow, error) { + if ctx == nil { + ctx = context.Background() + } + providers := CrewProvidersAt(profileDir) + rule := CrewAllowedAt(profileDir) + health := crewHealthAt(profileDir) + facts := crewRouteFacts{free: CrewFreeRoutesAt(profileDir), health: health} + off := CrewProvidersOffAt(profileDir) + eligible := map[string]bool{} + for _, c := range off.Candidates(crewCandidatesWith(rule, providers, facts)) { + for _, r := range c.Routes { + eligible[crewroute.Lineage(c.Model.ID)+"\x00"+r.Provider] = true + } + } + models := taskCatalogModels() + listed := taskProviderListings(profileDir, providers) + var out []TaskModelRow + built := 0 + for _, p := range providers { + if err := ctx.Err(); err != nil { + return nil, ErrTaskModelsRead + } + for _, entry := range models { + if built++; built%taskModelRowCheck == 0 && ctx.Err() != nil { + return nil, ErrTaskModelsRead + } + m := entry.model + r, ok := p.route(m.ID, m, true) + if !ok { + continue + } + row := TaskModelRow{Model: m, Name: entry.name, Provider: p.ID, Kind: r.Kind, Send: r.Send, Seats: map[crewroute.Seat]bool{}} + candidate := crewroute.Candidate{Model: m, Routes: []crewroute.Route{r}} + for _, seat := range crewroute.Seats { + row.Seats[seat] = crewroute.Seatable(seat, candidate) + } + if !row.Seats[crewroute.Worker] && !row.Seats[crewroute.Planner] && !row.Seats[crewroute.Checker] { + // Only what can do a role is a choice: not an image, speech or + // embedding model, not one too small to hold a role's context. + continue + } + row.Price = taskRowPrice(p, r, entry, listed[p.ID]) + row.Allowed = rule.AdmitsOn(m, p.ID) + _, resting := health.blocked[r.Send] + providerDown := !p.On || health.disconnected[p.ID] || (r.Kind == crewroute.Metered && health.unaffordable[p.ID]) + row.Available = !providerDown && !resting + row.AutoEligible = eligible[crewroute.Lineage(m.ID)+"\x00"+p.ID] + switch { + case !row.Allowed: + row.Reason = crewroute.RejectNotAllowed + case !entry.priced: + row.Reason = crewroute.RejectNoPrice + case providerDown: + row.Reason = crewroute.RejectProviderUnavailable + case resting: + row.Reason = crewroute.RejectRouteUnavailable + case !row.AutoEligible: + row.Reason = crewroute.RejectRouteUnavailable + } + if row.Reason != "" { + row.AutoEligible = false + } + out = append(out, row) + } + } + return out, nil +} + +// taskCatalogModel is one model of the catalog, once per lineage. +type taskCatalogModel struct { + model crewroute.Model + name string + priced bool + // in and out are the catalog's own prices, per token: OpenRouter's. + in, out float64 +} + +// taskCatalogModels is every model the catalog lists, once each, PRICED OR +// NOT: a model with no price is a row a person may tick and pin, which the +// router alone never weighs. Floating aliases and free pools are not models of +// their own. +func taskCatalogModels() []taskCatalogModel { + var out []taskCatalogModel + at := map[string]int{} + for _, row := range crewCatalogRows() { + if strings.HasPrefix(row.ID, "~") || crewroute.IsFree(row.ID) { + continue + } + lineage := crewroute.Lineage(row.ID) + entry := taskCatalogModel{model: crewModelOf(row), name: row.Name, priced: !crewUnpriced(row)} + if entry.priced { + entry.in, entry.out = row.PromptPrice, row.CompletionPrice + } + if i, seen := at[lineage]; seen { + // The model's own row wins over a dated snapshot of it, and keeps + // whatever figures the snapshot published. + if strings.EqualFold(row.ID, lineage) { + entry.model = crewIndexesFrom(entry.model, out[i].model) + out[i] = entry + } else { + out[i].model = crewIndexesFrom(out[i].model, entry.model) + } + continue + } + at[lineage] = len(out) + out = append(out, entry) + } + return out +} + +// taskProviderListings is each direct provider's own cached listing, keyed by +// the id that provider lists. NOTHING IS FETCHED: a listing nobody cached is +// no listing, and its rows have no price. +func taskProviderListings(profileDir string, providers []CrewProvider) map[string]map[string]catalog.Model { + set := ResolveSources(profileDir, APIKeyAt(profileDir), DefaultBaseURL) + out := map[string]map[string]catalog.Model{} + for _, p := range providers { + if p.ID == modelsource.DefaultID || p.Kind != crewroute.Metered { + continue + } + service, ok := set.ByID(p.ID) + if !ok { + continue + } + rows := catalog.Recall(CatalogOptionsFor(service, profileDir)).ModelsNow() + if len(rows) == 0 { + continue + } + byID := make(map[string]catalog.Model, len(rows)) + for _, row := range rows { + byID[strings.ToLower(row.ID)] = row + } + out[p.ID] = byID + } + return out +} + +// taskRowPrice is a row's price as its provider published it, nil otherwise. +func taskRowPrice(p CrewProvider, r crewroute.Route, entry taskCatalogModel, listing map[string]catalog.Model) *ModelPrice { + if r.Kind != crewroute.Metered { + return nil + } + if p.ID == modelsource.DefaultID { + if !entry.priced { + return nil + } + return &ModelPrice{InPerM: entry.in * 1e6, OutPerM: entry.out * 1e6} + } + own, ok := listing[strings.ToLower(strings.TrimPrefix(r.Send, p.Written+"/"))] + // A listing row that says nothing about price — or only zeros, which is + // what a listing seeded from model ids alone holds — is no price. + if !ok || own.PriceUnknown || (own.PromptPrice <= 0 && own.CompletionPrice <= 0) { + return nil + } + return &ModelPrice{InPerM: own.PromptPrice * 1e6, OutPerM: own.CompletionPrice * 1e6} +} + +// TaskModelProvidersAt is every provider a person could use for tasks: each +// connected one, then each one this build can connect that has no key yet. +func TaskModelProvidersAt(profileDir string, rows []TaskModelRow) []TaskModelProvider { + health := crewHealthAt(profileDir) + connected := CrewProvidersAt(profileDir) + var out []TaskModelProvider + seen := map[string]bool{} + for _, p := range connected { + view := TaskModelProvider{ID: p.ID, Name: p.Name, Connected: true, Kind: p.Kind, State: ProviderStateConnected} + switch { + case health.disconnected[p.ID] && p.Kind == crewroute.Plan: + view.State, view.Reason = ProviderStateExpired, ProviderReasonSignedOut + case health.disconnected[p.ID]: + view.State, view.Reason = ProviderStateExpired, ProviderReasonKeyRefused + case health.unaffordable[p.ID] && p.Kind == crewroute.Metered: + view.State, view.Reason = ProviderStateUnavailable, ProviderReasonOutOfCredit + case !p.On: + view.State, view.Reason = ProviderStateUnavailable, ProviderReasonTurnedOff + } + for _, row := range rows { + if row.Provider == p.ID { + view.Models++ + if row.Allowed { + view.Allowed++ + } + } + } + out = append(out, view) + seen[p.ID] = true + } + candidates := append([]modelsource.Source{modelsource.DefaultSource(DefaultBaseURL)}, modelsource.Vendored()...) + for _, source := range candidates { + id := strings.ToLower(source.ID) + if seen[id] || source.KeyOptional || modelsource.IsCustomID(source.ID) || id == "codex" { + continue + } + seen[id] = true + out = append(out, TaskModelProvider{ID: id, Name: source.Name, State: ProviderStateNoKey}) + } + return out +} + +// taskModelsKeys are every row the task models are read from — the pins, the +// routes, the rule, the providers turned off, the free routes, the caps, the +// mode and the retired rows a write migrates — so a revision moves when any +// of them does. +func taskModelsKeys() []string { + keys := append(crewStateKeys(), KeyTaskModelsMode, KeyTaskModelsOne, legacyKeyCrew, legacyKeyCrewPick, legacyKeyCrewSource) + sort.Strings(keys) + return keys +} + +// taskModelsSnapshot is those rows' bytes as they stand; an absent row is nil. +func taskModelsSnapshot(profileDir string) map[string]json.RawMessage { + snapshot := map[string]json.RawMessage{} + for _, key := range taskModelsKeys() { + raw, ok := persistedValue(profileDir, key) + if ok { + snapshot[key] = append(json.RawMessage(nil), raw...) + } else { + snapshot[key] = nil + } + } + return snapshot +} + +// taskModelsRevision names a snapshot. +func taskModelsRevision(snapshot map[string]json.RawMessage) string { + h := sha256.New() + for _, key := range taskModelsKeys() { + raw, ok := snapshot[key] + h.Write([]byte(key)) + if ok && raw != nil { + h.Write([]byte{1}) + h.Write([]byte(strings.TrimSpace(string(raw)))) + } + h.Write([]byte{0}) + } + return hex.EncodeToString(h.Sum(nil))[:16] +} + +// TaskModelsRevisionAt is the revision of the rows as they stand. +func TaskModelsRevisionAt(profileDir string) string { + return taskModelsRevision(taskModelsSnapshot(profileDir)) +} + +// TaskModelsAt is the whole configuration with its rows, read inside ctx. +func TaskModelsAt(ctx context.Context, profileDir string) (TaskModelsState, []TaskModelRow, error) { + revision := TaskModelsRevisionAt(profileDir) + rows, err := taskModelRows(ctx, profileDir) + if err != nil { + return TaskModelsState{}, nil, err + } + state := TaskModelsState{Revision: revision, Mode: TaskModelsModeAt(profileDir), Roles: map[crewroute.Seat]TaskModelRole{}} + providers := CrewProvidersAt(profileDir) + rule := CrewAllowedAt(profileDir) + if one, ok := TaskOneModelAt(profileDir); ok { + state.One = &one + state.OneWarning = taskPinWarning(profileDir, providers, rows, one, nil) + } + saved := CrewPinsAt(profileDir) + for _, seat := range crewroute.Seats { + role := TaskModelRole{} + seen := map[string]bool{} + for _, row := range rows { + if row.AutoEligible && row.Seats[seat] && !seen[crewroute.Lineage(row.Model.ID)] { + seen[crewroute.Lineage(row.Model.ID)] = true + role.Eligible++ + } + } + if pin, ok := saved[seat]; ok { + role.Pin = &pin + role.Warning = taskPinWarning(profileDir, providers, rows, pin, &rule) + } + if role.Eligible == 0 && role.Pin == nil { + role.Warning = TaskWarnNoEligible + } + state.Roles[seat] = role + } + state.Providers = TaskModelProvidersAt(profileDir, rows) + return state, rows, nil +} + +// taskPinWarning is what is wrong with a saved pin, or nothing. A rule is +// asked for a role's pin and not for the one model, which the allowed models +// do not govern. +func taskPinWarning(profileDir string, providers []CrewProvider, rows []TaskModelRow, pin CrewPin, rule *crewroute.Allowed) string { + route := resolveCrewPin(pin, providers) + if rule != nil && !crewPinAdmitted(*rule, pin.Model, route.Provider) { + return string(crewroute.PinNotAllowed) + } + if _, ok := crewPinReachable(profileDir, providers, route); !ok { + return string(crewroute.PinProviderUnavailable) + } + if len(rows) > 0 { + if _, ok := taskRowFor(rows, pinBase(pin.Model), route.Provider); !ok { + return TaskWarnPinUnknown + } + if row, _ := taskRowFor(rows, pinBase(pin.Model), route.Provider); !row.Available { + return string(crewroute.PinProviderUnavailable) + } + } + return "" +} + +// taskRowFor finds a model's row on a provider; an empty provider is the +// model's first row. +func taskRowFor(rows []TaskModelRow, id, provider string) (TaskModelRow, bool) { + lineage := crewroute.Lineage(stripCrewRoute(id)) + provider = strings.ToLower(strings.TrimSpace(provider)) + for _, row := range rows { + if crewroute.Lineage(row.Model.ID) == lineage && (provider == "" || row.Provider == provider) { + return row, true + } + } + return TaskModelRow{}, false +} + +// ── one change, written whole ─────────────────────────────────────────────── + +// TaskModelsChange is one change to the task models: any part may be absent. +type TaskModelsChange struct { + // Revision, when set, is the revision the change was made against. + Revision string + Mode *crewroute.Mode + One *CrewPin + // Pins holds a role only when the change names it; nil is Auto. + Pins map[crewroute.Seat]*CrewPin + // Ticks are rows allowed or not, each one model on one provider. + Ticks []TaskModelTick +} + +// TaskModelTick is one row ticked or unticked. +type TaskModelTick struct { + Model string + Provider string + Allowed bool +} + +// ErrTaskModelsStale is a change made against a revision that is no longer +// current. +var ErrTaskModelsStale = errors.New("task models changed since they were read · read them again") + +// TaskModelsRefusal is a change refused for what it asked, with the reason. +type TaskModelsRefusal struct{ Reason string } + +func (e TaskModelsRefusal) Error() string { return e.Reason } + +func refuse(format string, args ...any) error { + return TaskModelsRefusal{Reason: fmt.Sprintf(format, args...)} +} + +// taskModelsWriteTries is how many times a change is recomputed when another +// write lands between its read and its own. +const taskModelsWriteTries = 3 + +// ApplyTaskModels checks EVERY PART of a change before it writes anything, and +// writes the whole change in ONE profile write, onto exactly the rows it was +// checked against: a write that came in between is not overwritten — the +// change is checked again on top of it, or refused as stale when it named the +// revision it was made against. +func ApplyTaskModels(ctx context.Context, profileDir string, change TaskModelsChange) error { + for try := 0; ; try++ { + snapshot := taskModelsSnapshot(profileDir) + if change.Revision != "" && change.Revision != taskModelsRevision(snapshot) { + return ErrTaskModelsStale + } + values, err := taskModelsValues(ctx, profileDir, change) + if err != nil { + return err + } + if len(values) == 0 { + return nil + } + err = writeProfileValuesExpecting(profileDir, snapshot, values) + if !errors.Is(err, errProfileMoved) { + return err + } + if change.Revision != "" { + return ErrTaskModelsStale + } + if try+1 >= taskModelsWriteTries { + return ErrTaskModelsStale + } + } +} + +// taskModelsValues is a change as the rows it writes, every part checked. +func taskModelsValues(ctx context.Context, profileDir string, change TaskModelsChange) (map[string]any, error) { + rows, err := taskModelRows(ctx, profileDir) + if err != nil { + return nil, err + } + providers := CrewProvidersAt(profileDir) + values := map[string]any{} + before := CrewAllowedAt(profileDir) + rule := before + for _, tick := range change.Ticks { + row, ok := taskRowFor(rows, tick.Model, tick.Provider) + if !ok || tick.Provider == "" { + return nil, refuse("%s is not a model %s lists", tick.Model, taskProviderName(providers, tick.Provider)) + } + rule = rule.RowToggled(row.Model, row.Provider, tick.Allowed) + } + if rule.String() != before.String() { + values[KeyCrewAllowed] = rule.String() + } + one, haveOne := TaskOneModelAt(profileDir) + if change.One != nil { + pin, err := taskModelsPin(rows, providers, *change.One, "the one model") + if err != nil { + return nil, err + } + route := resolveCrewPin(pin, providers) + if _, ok := crewPinReachable(profileDir, providers, route); !ok { + return nil, refuse("%s is not reachable on %s · connect it or turn it on first", pin.Model, taskProviderName(providers, route.Provider)) + } + one, haveOne = pin, true + values[KeyTaskModelsOne] = pin.String() + } + if change.Mode != nil { + switch *change.Mode { + case crewroute.ModeOne: + if !haveOne { + return nil, refuse("one model for every task needs the model · send one_model") + } + if _, ok := crewPinReachable(profileDir, providers, resolveCrewPin(one, providers)); !ok { + return nil, refuse("%s is not reachable right now · choose another one model", one.Model) + } + values[KeyTaskModelsMode] = string(crewroute.ModeOne) + case crewroute.ModeAuto: + values[KeyTaskModelsMode] = removeProfileKey + default: + return nil, refuse("mode is auto or one") + } + } + pinned := false + for _, seat := range crewroute.Seats { + ref, named := change.Pins[seat] + if !named { + continue + } + pinned = true + tier, route := tierKeyFor(CrewSeatTier(seat)), crewRouteKey(seat) + if ref == nil { + values[tier], values[route] = removeProfileKey, removeProfileKey + continue + } + pin, err := taskModelsPin(rows, providers, *ref, "the "+string(seat)+" pin") + if err != nil { + return nil, err + } + // A PIN MAY ONLY BE SET TO A MODEL THE ALLOWED MODELS ADMIT, as they + // read after this change: an allowed pin is the person's word, a + // disallowed one is only ever an old pin the rule moved away from. + if !crewPinAdmitted(rule, pin.Model, resolveCrewPin(pin, providers).Provider) { + return nil, refuse("%s is not allowed on %s · allow it before pinning it", pin.Model, taskProviderName(providers, resolveCrewPin(pin, providers).Provider)) + } + values[tier], values[route] = pin.Model, removeProfileKey + if pin.Provider != "" { + values[route] = pin.String() + } + } + if pinned || values[KeyCrewAllowed] != nil { + // A profile carrying retired crew rows is migrated in the same write, + // as every crew writer does. + for key, value := range legacyCrewClearing(profileDir) { + if _, set := values[key]; !set { + values[key] = value + } + } + } + return values, nil +} + +// taskModelsPin is a reference checked against the rows: a model a connected +// provider lists, on the provider named. +func taskModelsPin(rows []TaskModelRow, providers []CrewProvider, ref CrewPin, what string) (CrewPin, error) { + id := strings.TrimSpace(ref.Model) + if err := ValidateTierValue(id); err != nil { + return CrewPin{}, refuse("%s: %v", what, err) + } + provider := strings.ToLower(strings.TrimSpace(ref.Provider)) + if provider != "" { + if _, ok := crewProviderByID(providers, provider); !ok { + return CrewPin{}, refuse("%s: %s is not a connected provider", what, provider) + } + } + row, ok := taskRowFor(rows, pinBase(id), provider) + if !ok { + if provider == "" { + return CrewPin{}, refuse("%s: %s is not a model any connected provider lists", what, id) + } + return CrewPin{}, refuse("%s: %s is not a model %s lists", what, id, taskProviderName(providers, provider)) + } + return CrewPin{Model: row.Model.ID, Provider: provider}, nil +} + +// taskProviderName is a provider as a refusal names it. +func taskProviderName(providers []CrewProvider, id string) string { + if p, ok := crewProviderByID(providers, id); ok && p.Name != "" { + return p.Name + } + if strings.TrimSpace(id) == "" { + return "any connected provider" + } + return id +} diff --git a/internal/config/taskmodels.go b/internal/config/taskmodels.go new file mode 100644 index 0000000000..62361d8339 --- /dev/null +++ b/internal/config/taskmodels.go @@ -0,0 +1,209 @@ +package config + +import ( + "errors" + "strings" + + "github.com/Agent-Field/codeaf/internal/crewroute" + "github.com/Agent-Field/codeaf/internal/roles" +) + +// TASK MODELS: AUTO, OR ONE MODEL FOR EVERY TASK. +// +// Every task is admitted with a decision about its three roles, and that +// decision is frozen with the task — a running task keeps its picks whatever a +// person changes after. The profile says how the decision is made: +// +// - AUTO, the default, is the router's pick per role inside the allowed +// models, with any pin the allowed models still admit kept as written +// (crew.go). A pin they no longer admit is KEPT and the role is routed, +// and the decision carries a warning saying so. +// - ONE MODEL is one explicit model on one explicit route for every role. +// Nothing is picked, the allowed models and the pins are left exactly as +// they are for when Auto comes back, and a one model that cannot be +// reached is a refusal at admission — never another model quietly. +// +// The mode is a row, read when a task is admitted. It is NOT the `--one-model` +// launch flag, which takes the router away from a whole process and is +// untouched by this: a process started with it never asks. + +// The task-models rows, spelled once. PROFILE-ONLY, like every crew row: a +// repository that could set them could send a visitor's work to a model nobody +// on that machine chose. +const ( + // KeyTaskModelsMode is `auto` or `one`; absent is auto. + KeyTaskModelsMode = "models.tasks.mode" + // KeyTaskModelsOne is the one model, `model` or `model@provider`. It is + // kept while the mode is auto, so the choice comes back. + KeyTaskModelsOne = "models.tasks.one" +) + +// TaskModelsModeAt is the profile's mode. Anything but `one` is auto. +func TaskModelsModeAt(profileDir string) crewroute.Mode { + if value, _ := persistedString(profileDir, KeyTaskModelsMode); strings.EqualFold(strings.TrimSpace(value), string(crewroute.ModeOne)) { + return crewroute.ModeOne + } + return crewroute.ModeAuto +} + +// TaskOneModelAt is the saved one model, false when none was chosen or the +// row does not read. +func TaskOneModelAt(profileDir string) (CrewPin, bool) { + value, held := persistedString(profileDir, KeyTaskModelsOne) + if !held { + return CrewPin{}, false + } + pin, auto, err := ParseCrewPin(value) + if auto || err != nil { + return CrewPin{}, false + } + return pin, true +} + +// routeOneModel is one task's decision in one-model mode: the one model on its +// route in every role. Per-task pins the person gave this task still stand. +func routeOneModel(profileDir string, ask CrewAsk) (crewroute.Decision, error) { + one, ok := TaskOneModelAt(profileDir) + if !ok { + return crewroute.Decision{}, &crewroute.NoEligibleError{Seat: crewroute.Worker, Mode: crewroute.ModeOne, + Action: "choose the one model for every task, or switch Task models back to Auto"} + } + providers := CrewProvidersAt(profileDir) + route := resolveCrewPin(one, providers) + if why, ok := crewPinReachable(profileDir, providers, route); !ok { + refusal := &crewroute.NoEligibleError{Seat: crewroute.Worker, Mode: crewroute.ModeOne} + refusal.Reject(route.Model, route.Provider, why) + return crewroute.Decision{}, refusal + } + pins := map[crewroute.Seat]crewroute.Pin{} + for _, seat := range crewroute.Seats { + pins[seat] = route + } + for seat, pin := range ask.Pins { + pins[seat] = resolveCrewPin(pin, providers) + } + for seat, send := range ask.Sends { + if send = strings.TrimSpace(send); send != "" { + pins[seat] = resolveCrewPin(CrewPin{Model: send}, providers) + } + } + reading := crewroute.Classify(ask.Task) + // The figures of the one model — its quality and estimate for the line — + // are read from every reachable model, allowed or not: one-model mode does + // not ask the allowed models. + facts := crewRouteFacts{free: CrewFreeRoutesAt(profileDir), health: crewHealthAt(profileDir).probing()} + figures := crewCandidatesWith(crewroute.Allowed{Base: crewroute.BaseAll}, providers, facts) + d, err := crewroute.Decide(crewroute.Request{ + Task: ask.Task, Class: reading.Class, Reading: &reading, Candidates: figures, Pins: pins, + Stronger: ask.Stronger, Again: ask.Again, + }) + if err != nil { + return crewroute.Decision{}, err + } + d.Why, d.Sure = reading.Why, reading.Sure + d.Redo = ask.Stronger != nil || ask.Again != nil + d.Mode = crewroute.ModeOne + if d.OneOff { + // A stronger redo the person asked for stepped over the one model and + // was picked by the router: that one task is Auto's. + d.Mode = crewroute.ModeAuto + } + day := CrewDay{} + if CrewHistory != nil { + day = CrewHistory(profileDir) + } + if _, atCap := crewroute.Pace(day.SpentUSD, CrewCapAt(profileDir)); atCap { + return d, ErrCrewAtCap + } + return d, nil +} + +// crewPinReachable is whether a resolved pin's route goes through a provider +// that is connected and on, and why not. +func crewPinReachable(profileDir string, providers []CrewProvider, route crewroute.Pin) (crewroute.RejectReason, bool) { + p, ok := crewProviderByID(providers, route.Provider) + if !ok || !p.On { + return crewroute.RejectProviderUnavailable, false + } + return "", true +} + +// crewSavedPins is the profile's pins resolved for routing: each pin the +// allowed models admit on its route, and a warning for every other — a pin +// outside them is not used and the role is routed; a pin whose provider is not +// connected or is off is still used, and said. +func crewSavedPins(profileDir string, providers []CrewProvider) (map[crewroute.Seat]crewroute.Pin, []crewroute.SeatWarning) { + rule := CrewAllowedAt(profileDir) + pins := map[crewroute.Seat]crewroute.Pin{} + var warnings []crewroute.SeatWarning + saved := CrewPinsAt(profileDir) + for _, seat := range crewroute.Seats { + pin, ok := saved[seat] + if !ok { + continue + } + route := resolveCrewPin(pin, providers) + if !crewPinAdmitted(rule, pin.Model, route.Provider) { + warnings = append(warnings, crewroute.SeatWarning{Seat: seat, Kind: crewroute.PinNotAllowed, Model: pin.Model, Provider: pin.Provider}) + continue + } + if _, ok := crewPinReachable(profileDir, providers, route); !ok { + warnings = append(warnings, crewroute.SeatWarning{Seat: seat, Kind: crewroute.PinProviderUnavailable, Model: pin.Model, Provider: route.Provider}) + } + pins[seat] = route + } + return pins, warnings +} + +// withoutWarningsFor drops the warnings about seats a task's own pins filled. +func withoutWarningsFor(warnings []crewroute.SeatWarning, filled map[crewroute.Seat]bool) []crewroute.SeatWarning { + var out []crewroute.SeatWarning + for _, w := range warnings { + if !filled[w.Seat] { + out = append(out, w) + } + } + return out +} + +// crewNoEligible is the typed refusal for a seat nothing allowed can take, +// with why each model on each connected route was turned away — allowed rows +// first, because those are the ones a person did not expect to lose. +func crewNoEligible(profileDir string, seat crewroute.Seat, cause error) *crewroute.NoEligibleError { + refusal := &crewroute.NoEligibleError{Seat: seat, Mode: crewroute.ModeAuto} + var unreachable ErrCrewUnreachable + if errors.As(cause, &unreachable) { + refusal.Cause = unreachable + if !strings.HasPrefix(unreachable.Action, "no allowed model") { + refusal.Action = unreachable.Action + } + } + rows, err := taskModelRows(nil, profileDir) + if err != nil { + return refusal + } + for _, allowedFirst := range []bool{true, false} { + for _, row := range rows { + if row.Allowed != allowedFirst { + continue + } + reason := row.Reason + if reason == "" && !row.Seats[seat] { + reason = crewroute.RejectCannotDoRole + } + if reason == "" { + // Eligible at a glance and still not seated: the route was + // tried and rests, or its model was kept off for failing. + reason = crewroute.RejectRouteUnavailable + } + refusal.Reject(row.Model.ID, row.Provider, reason) + } + } + return refusal +} + +// pinBase is a pin's model without its thinking level. +func pinBase(id string) string { + base, _ := roles.SplitEffort(strings.TrimSpace(id)) + return base +} diff --git a/internal/config/taskmodels_test.go b/internal/config/taskmodels_test.go new file mode 100644 index 0000000000..45bc4b8a88 --- /dev/null +++ b/internal/config/taskmodels_test.go @@ -0,0 +1,431 @@ +package config + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/catalog" + "github.com/Agent-Field/codeaf/internal/crewroute" + "github.com/Agent-Field/codeaf/internal/router" +) + +// The fixtures below are UNIT DATA, not provider facts: the catalog rows are +// crewProfile's, and the second provider is a key written into a temporary +// profile. Nothing here reaches a network or a model. + +// taskModelsWithAiand is crewProfile with a second connected provider that +// serves the same GLM through its own key, so one model has two rows. +func taskModelsWithAiand(t *testing.T) string { + t.Helper() + dir := crewProfile(t) + if err := writeProfileValue(dir, keyModelSources, []PersistedSource{ + {ID: "aiand", Written: "aiand", Key: "sk-aiand-taskmodels-0123456789ab", Order: 1}, + }); err != nil { + t.Fatal(err) + } + return dir +} + +func applyTaskModels(t *testing.T, dir string, change TaskModelsChange) { + t.Helper() + if err := ApplyTaskModels(context.Background(), dir, change); err != nil { + t.Fatalf("apply %+v: %v", change, err) + } +} + +func modePtr(m crewroute.Mode) *crewroute.Mode { return &m } + +// rawRows is the task-model rows' bytes, for "nothing else moved". +func rawRows(t *testing.T, dir string) map[string]string { + t.Helper() + out := map[string]string{} + for key, raw := range taskModelsSnapshot(dir) { + out[key] = string(raw) + } + return out +} + +// ONE MODEL FOR EVERY TASK IS A ROW READ AT ADMISSION. A decision already made +// is what its task runs; the next admission sees the change, and a reopened +// profile reads the same mode. +func TestOneModelModeIsReadAtAdmissionAndADecisionStaysFrozen(t *testing.T) { + dir := crewProfile(t) + running, err := RouteCrew(dir, CrewAsk{Task: crewroute.Task{Text: openTask}}) + if err != nil { + t.Fatal(err) + } + if running.Mode != crewroute.ModeAuto { + t.Fatalf("an untouched profile admits in %q, want auto", running.Mode) + } + frozen, _ := json.Marshal(running) + applyTaskModels(t, dir, TaskModelsChange{Mode: modePtr(crewroute.ModeOne), One: &CrewPin{Model: "deepseek/deepseek-v4-flash", Provider: "openrouter"}}) + if got, _ := json.Marshal(running); string(got) != string(frozen) { + t.Error("the running task's decision moved") + } + if TaskModelsModeAt(dir) != crewroute.ModeOne { + t.Fatal("the mode did not persist") + } + next, err := RouteCrew(dir, CrewAsk{Task: crewroute.Task{Text: openTask}, ChatModel: "anthropic/claude-opus-5"}) + if err != nil { + t.Fatal(err) + } + if next.Mode != crewroute.ModeOne { + t.Errorf("the next task admitted in %q, want one", next.Mode) + } + for _, seat := range crewroute.Seats { + pick := next.Seat(seat) + if pick.Model != "deepseek/deepseek-v4-flash" || pick.Provider != "openrouter" { + t.Errorf("%s ran %s on %s, want the one model on its route", seat, pick.Model, pick.Provider) + } + } + // One-model mode never touches the live process's flag: --one-model is a + // launch flag and this is a row. + if TaskModelsModeAt(t.TempDir()) != crewroute.ModeAuto { + t.Error("a fresh profile is not auto") + } +} + +// SWITCHING MODES KEEPS EVERY PIN AND EVERY TICK, byte for byte, for when the +// person comes back. +func TestAModeFlipKeepsPinsAndTicks(t *testing.T) { + dir := taskModelsWithAiand(t) + applyTaskModels(t, dir, TaskModelsChange{ + Pins: map[crewroute.Seat]*CrewPin{crewroute.Checker: {Model: "deepseek/deepseek-v4-flash"}}, + Ticks: []TaskModelTick{{Model: "z-ai/glm-5.3-flash", Provider: "openrouter", Allowed: false}}, + }) + before := rawRows(t, dir) + applyTaskModels(t, dir, TaskModelsChange{Mode: modePtr(crewroute.ModeOne), One: &CrewPin{Model: "z-ai/glm-5.3-flash", Provider: "aiand"}}) + applyTaskModels(t, dir, TaskModelsChange{Mode: modePtr(crewroute.ModeAuto)}) + after := rawRows(t, dir) + for _, key := range []string{KeyCrewAllowed, KeyTierHighModel, KeyCrewRouteChecker} { + if before[key] != after[key] { + t.Errorf("%s moved across a mode flip: %q → %q", key, before[key], after[key]) + } + } + if one, ok := TaskOneModelAt(dir); !ok || one.String() != "z-ai/glm-5.3-flash@aiand" { + t.Errorf("the one model is not kept under Auto: %+v %v", one, ok) + } +} + +// THE OLD CHECKS AND PLANNER ROWS ARE THE CHECKER AND PLANNER PINS — the same +// rows, a thinking level and a route included — and reading Task models +// rewrites none of them. +func TestOldChecksAndPlannerRowsReadAsPinsUnchanged(t *testing.T) { + dir := crewProfile(t) + if err := writeProfileValues(dir, map[string]any{ + KeyTierHighModel: "deepseek/deepseek-v4-flash:high", + KeyTierMastermindModel: "moonshotai/kimi-k3", + KeyCrewRoutePlanner: "moonshotai/kimi-k3@openrouter", + }); err != nil { + t.Fatal(err) + } + before := rawRows(t, dir) + state, _, err := TaskModelsAt(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + if pin := state.Roles[crewroute.Checker].Pin; pin == nil || pin.String() != "deepseek/deepseek-v4-flash:high" { + t.Errorf("checker pin %+v, want the Checks row as written", pin) + } + if pin := state.Roles[crewroute.Planner].Pin; pin == nil || pin.String() != "moonshotai/kimi-k3@openrouter" { + t.Errorf("planner pin %+v, want the Planner row with its route", pin) + } + if state.Roles[crewroute.Worker].Pin != nil { + t.Error("the worker reads pinned on a profile that never pinned it") + } + // An unrelated change elsewhere on the page leaves both rows alone. + applyTaskModels(t, dir, TaskModelsChange{Ticks: []TaskModelTick{{Model: "anthropic/claude-opus-5", Provider: "openrouter", Allowed: false}}}) + after := rawRows(t, dir) + for _, key := range []string{KeyTierHighModel, KeyTierMastermindModel, KeyCrewRoutePlanner} { + if before[key] != after[key] { + t.Errorf("%s was rewritten: %q → %q", key, before[key], after[key]) + } + } +} + +// A CHANGE WITH ONE BAD PART WRITES NOTHING: the good tick before it is not +// written either. +func TestAFailedChangeLeavesEveryRowAsItWas(t *testing.T) { + dir := taskModelsWithAiand(t) + before := rawRows(t, dir) + err := ApplyTaskModels(context.Background(), dir, TaskModelsChange{ + Ticks: []TaskModelTick{{Model: "z-ai/glm-5.3-flash", Provider: "openrouter", Allowed: false}}, + Mode: modePtr(crewroute.ModeAuto), + Pins: map[crewroute.Seat]*CrewPin{crewroute.Worker: {Model: "nobody/never-listed"}}, + }) + var refusal TaskModelsRefusal + if !errors.As(err, &refusal) { + t.Fatalf("a pin to a model nobody lists: %v, want a refusal", err) + } + if after := rawRows(t, dir); len(after) != len(before) { + t.Fatal("rows changed") + } else { + for key := range before { + if before[key] != after[key] { + t.Errorf("%s moved on a refused change: %q → %q", key, before[key], after[key]) + } + } + } + if err := ApplyTaskModels(context.Background(), dir, TaskModelsChange{Revision: "0000000000000000", Mode: modePtr(crewroute.ModeAuto)}); !errors.Is(err, ErrTaskModelsStale) { + t.Errorf("a stale revision: %v, want ErrTaskModelsStale", err) + } +} + +// A WRITE THAT LANDS BETWEEN A CHANGE'S READ AND ITS WRITE IS NOT OVERWRITTEN. +func TestAWriteBetweenReadAndWriteIsNotClobbered(t *testing.T) { + dir := crewProfile(t) + snapshot := taskModelsSnapshot(dir) + if err := SetCrewAllowed(dir, "open"); err != nil { + t.Fatal(err) + } + err := writeProfileValuesExpecting(dir, snapshot, map[string]any{KeyCrewAllowed: "all -kimi-k3"}) + if !errors.Is(err, errProfileMoved) { + t.Fatalf("a write over a moved row: %v", err) + } + if got := CrewAllowedAt(dir).String(); got != "open" { + t.Errorf("the other write was clobbered: %q", got) + } +} + +// ONE MODEL, TWO PROVIDERS, TWO TICKS: unticking GLM on OpenRouter leaves it +// allowed — and routable — on ai&. +func TestATickTogglesOneProvidersRowOnly(t *testing.T) { + dir := taskModelsWithAiand(t) + applyTaskModels(t, dir, TaskModelsChange{Ticks: []TaskModelTick{{Model: "z-ai/glm-5.3-flash", Provider: "openrouter", Allowed: false}}}) + _, rows, err := TaskModelsAt(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + onOpenRouter, _ := taskRowFor(rows, "z-ai/glm-5.3-flash", "openrouter") + onAiand, ok := taskRowFor(rows, "z-ai/glm-5.3-flash", "aiand") + if !ok || onOpenRouter.Allowed || !onAiand.Allowed { + t.Fatalf("openrouter allowed=%v, aiand allowed=%v (listed %v)", onOpenRouter.Allowed, onAiand.Allowed, ok) + } + for _, c := range CrewCandidatesAt(dir) { + if crewroute.Lineage(c.Model.ID) != "z-ai/glm-5.3-flash" { + continue + } + for _, r := range c.Routes { + if r.Provider == "openrouter" { + t.Errorf("the unticked row is still a route: %+v", r) + } + } + } +} + +// UNTICKING A PINNED MODEL SUCCEEDS, THE PIN STAYS SAVED, and the role is +// routed with a typed warning on the decision. +func TestUntickingAPinnedModelKeepsThePinAndRoutesTheRole(t *testing.T) { + dir := crewProfile(t) + applyTaskModels(t, dir, TaskModelsChange{Pins: map[crewroute.Seat]*CrewPin{crewroute.Worker: {Model: "z-ai/glm-5.3-flash", Provider: "openrouter"}}}) + applyTaskModels(t, dir, TaskModelsChange{Ticks: []TaskModelTick{{Model: "z-ai/glm-5.3-flash", Provider: "openrouter", Allowed: false}}}) + if pin, ok := CrewPinAt(dir, crewroute.Worker); !ok || pin.Model != "z-ai/glm-5.3-flash" { + t.Fatalf("the pin was dropped: %+v %v", pin, ok) + } + state, _, err := TaskModelsAt(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + if got := state.Roles[crewroute.Worker].Warning; got != string(crewroute.PinNotAllowed) { + t.Errorf("worker warning %q, want pin_not_allowed", got) + } + d, err := RouteCrew(dir, CrewAsk{Task: crewroute.Task{Text: fixTask}}) + if err != nil { + t.Fatal(err) + } + worker := d.Seat(crewroute.Worker) + if worker.Pinned || worker.Model == "z-ai/glm-5.3-flash" { + t.Errorf("the worker ran %+v, want Auto's pick off the set-aside pin", worker) + } + if len(d.Warnings) != 1 || d.Warnings[0].Seat != crewroute.Worker || d.Warnings[0].Kind != crewroute.PinNotAllowed { + t.Errorf("decision warnings %+v, want the worker's pin_not_allowed", d.Warnings) + } + // A pin may not be SET to a model that is not allowed. + err = ApplyTaskModels(context.Background(), dir, TaskModelsChange{Pins: map[crewroute.Seat]*CrewPin{crewroute.Planner: {Model: "z-ai/glm-5.3-flash", Provider: "openrouter"}}}) + var refusal TaskModelsRefusal + if !errors.As(err, &refusal) { + t.Errorf("pinning a disallowed model: %v, want a refusal", err) + } + // Allow it again: the pin is back in force. + applyTaskModels(t, dir, TaskModelsChange{Ticks: []TaskModelTick{{Model: "z-ai/glm-5.3-flash", Provider: "openrouter", Allowed: true}}}) + d, err = RouteCrew(dir, CrewAsk{Task: crewroute.Task{Text: fixTask}}) + if err != nil { + t.Fatal(err) + } + if !d.Seat(crewroute.Worker).Pinned || len(d.Warnings) != 0 { + t.Errorf("allowed again: worker %+v warnings %+v", d.Seat(crewroute.Worker), d.Warnings) + } +} + +// AN ELIGIBLE PIN IS NEVER CHANGED BY AUTO, whatever the task or the effort. +func TestAnEligiblePinIsNeverChanged(t *testing.T) { + dir := crewProfile(t) + applyTaskModels(t, dir, TaskModelsChange{Pins: map[crewroute.Seat]*CrewPin{crewroute.Checker: {Model: "deepseek/deepseek-v4-flash"}}}) + for _, effort := range []crewroute.Effort{crewroute.EffortKnee, crewroute.EffortBest, crewroute.EffortCheap} { + for _, text := range []string{fixTask, openTask} { + d, err := RouteCrew(dir, CrewAsk{Task: crewroute.Task{Text: text}, Effort: effort}) + if err != nil { + t.Fatal(err) + } + if c := d.Seat(crewroute.Checker); !c.Pinned || c.Model != "deepseek/deepseek-v4-flash" { + t.Errorf("effort %q: checker %+v, want the pin", effort, c) + } + } + } +} + +// A MODEL WITH NO PRICE IS LISTED, TICKED AND PINNABLE, AND AUTO NEVER PICKS IT. +func TestAnUnpricedModelIsPinnableButNeverAutoPicked(t *testing.T) { + dir := crewProfile(t) + widenCrewCatalog(t, catalog.Model{ID: "z-ai/glm-next", PriceUnknown: true, OpenWeights: true, + IntelligenceIndex: 60, CodingIndex: 90, AgenticIndex: 70, ContextLength: 1048576, Parameters: []string{"tools"}}) + _, rows, err := TaskModelsAt(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + row, ok := taskRowFor(rows, "z-ai/glm-next", "openrouter") + if !ok { + t.Fatal("the unpriced model is not listed") + } + if row.Price != nil || !row.Allowed || row.AutoEligible || row.Reason != crewroute.RejectNoPrice { + t.Errorf("row %+v, want listed, allowed, no price, not Auto's", row) + } + for _, text := range []string{fixTask, openTask} { + d, err := RouteCrew(dir, CrewAsk{Task: crewroute.Task{Text: text}, Effort: crewroute.EffortBest}) + if err != nil { + t.Fatal(err) + } + for _, pick := range d.Crew { + if pick.Model == "z-ai/glm-next" { + t.Errorf("Auto picked the unpriced model for the %s", pick.Seat) + } + } + } + applyTaskModels(t, dir, TaskModelsChange{Pins: map[crewroute.Seat]*CrewPin{crewroute.Worker: {Model: "z-ai/glm-next"}}}) + d, err := RouteCrew(dir, CrewAsk{Task: crewroute.Task{Text: openTask}}) + if err != nil { + t.Fatal(err) + } + if w := d.Seat(crewroute.Worker); w.Model != "z-ai/glm-next" || !w.Pinned { + t.Errorf("the pinned unpriced worker ran %+v", w) + } +} + +// OPENROUTER'S PRICE IS OPENROUTER'S: a direct provider's row carries only what +// its own listing published, and nothing when it published nothing. +func TestARowCarriesOnlyItsOwnProvidersPrice(t *testing.T) { + dir := taskModelsWithAiand(t) + _, rows, err := TaskModelsAt(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + or, _ := taskRowFor(rows, "z-ai/glm-5.3-flash", "openrouter") + if or.Price == nil || or.Price.InPerM < 0.149 || or.Price.InPerM > 0.151 || or.Price.OutPerM < 0.499 || or.Price.OutPerM > 0.501 { + t.Errorf("openrouter's row price %+v, want its own 0.15 / 0.50", or.Price) + } + direct, ok := taskRowFor(rows, "z-ai/glm-5.3-flash", "aiand") + if !ok || direct.Price != nil { + t.Errorf("the direct row %+v carries a price nobody at ai& published", direct.Price) + } +} + +// AN EXPIRED PROVIDER KEEPS ITS ROWS AND THEIR TICKS, says why, and comes back +// by itself. +func TestARefusedKeyKeepsTheProvidersRowsTicked(t *testing.T) { + dir := crewProfile(t) + previous := CrewRouteHistory + t.Cleanup(func() { CrewRouteHistory = previous }) + CrewRouteHistory = func(string) []router.CrewRouteOutcome { + return []router.CrewRouteOutcome{{At: time.Now().Add(-time.Minute), Send: "z-ai/glm-5.3-flash", Provider: "openrouter", Kind: "auth"}} + } + state, rows, err := TaskModelsAt(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + if len(state.Providers) == 0 || state.Providers[0].ID != "openrouter" || state.Providers[0].State != ProviderStateExpired || state.Providers[0].Reason != ProviderReasonKeyRefused { + t.Fatalf("providers %+v, want openrouter expired with its key refused", state.Providers) + } + row, ok := taskRowFor(rows, "z-ai/glm-5.3-flash", "openrouter") + if !ok || !row.Allowed || row.Available || row.Reason != crewroute.RejectProviderUnavailable { + t.Errorf("row %+v (listed %v), want listed, still ticked, unavailable", row, ok) + } + if state.Providers[0].Models == 0 || state.Providers[0].Allowed != state.Providers[0].Models { + t.Errorf("provider counts %+v, want every listed row still allowed", state.Providers[0]) + } +} + +// A PROVIDER WITH NO KEY IS A HEADER WITH NO MODELS. +func TestAProviderWithNoKeyIsListedWithNoModels(t *testing.T) { + dir := crewProfile(t) + state, _, err := TaskModelsAt(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + found := false + for _, p := range state.Providers { + if p.ID == "moonshot" { + found = true + if p.State != ProviderStateNoKey || p.Models != 0 || p.Connected { + t.Errorf("moonshot %+v, want no_key and no models", p) + } + } + } + if !found { + t.Error("a provider this build can connect is missing") + } +} + +// NOTHING ALLOWED FOR A ROLE IS A TYPED REFUSAL NAMING THE ROLE AND THE REASONS +// — never a task started on the chat model or any model left out. +func TestNothingAllowedIsATypedRefusalNotARescue(t *testing.T) { + dir := crewProfile(t) + if err := SetCrewAllowed(dir, "anthropic/claude-opus-5 -claude-opus-5@openrouter"); err != nil { + t.Fatal(err) + } + _, err := RouteCrew(dir, CrewAsk{Task: crewroute.Task{Text: openTask}, ChatModel: "deepseek/deepseek-v4-flash"}) + var refusal *crewroute.NoEligibleError + if !errors.As(err, &refusal) { + t.Fatalf("err %v, want a NoEligibleError", err) + } + if refusal.Mode != crewroute.ModeAuto || refusal.Seat == "" || len(refusal.Rejected) == 0 { + t.Fatalf("refusal %+v", refusal) + } + if first := refusal.Rejected[0]; first.Reason != crewroute.RejectNotAllowed { + t.Errorf("first rejection %+v, want the not-allowed opus", first) + } + var unreachable ErrCrewUnreachable + if !errors.As(err, &unreachable) { + t.Error("the older ErrCrewUnreachable no longer matches") + } +} + +// AN UNREACHABLE ONE MODEL IS A REFUSAL, NEVER ANOTHER MODEL. +func TestAnUnreachableOneModelRefusesRatherThanFallsBack(t *testing.T) { + dir := taskModelsWithAiand(t) + applyTaskModels(t, dir, TaskModelsChange{Mode: modePtr(crewroute.ModeOne), One: &CrewPin{Model: "z-ai/glm-5.3-flash", Provider: "aiand"}}) + if err := writeProfileValue(dir, keyModelSources, []PersistedSource{}); err != nil { + t.Fatal(err) + } + _, err := RouteCrew(dir, CrewAsk{Task: crewroute.Task{Text: openTask}, ChatModel: "deepseek/deepseek-v4-flash"}) + var refusal *crewroute.NoEligibleError + if !errors.As(err, &refusal) || refusal.Mode != crewroute.ModeOne || len(refusal.Rejected) != 1 || refusal.Rejected[0].Reason != crewroute.RejectProviderUnavailable { + t.Fatalf("err %v (%+v), want a one-mode refusal naming the provider", err, refusal) + } +} + +// THE /crew PANEL SAYS A SET-ASIDE PIN BY NAME, so the terminal never hides a +// state the app shows. +func TestTheCrewPanelSaysASetAsidePin(t *testing.T) { + dir := crewProfile(t) + applyTaskModels(t, dir, TaskModelsChange{Pins: map[crewroute.Seat]*CrewPin{crewroute.Checker: {Model: "deepseek/deepseek-v4-flash"}}}) + if err := SetCrewAllowed(dir, "-deepseek-v4-flash"); err != nil { + t.Fatal(err) + } + gaps := CrewGapsAt(dir) + if len(gaps) == 0 || gaps[0].Seat != crewroute.Checker || gaps[0].Line != "checker pinned to deepseek-v4-flash, which the models you allow leave out · routed until you allow it or unpin it" { + t.Errorf("gaps %+v", gaps) + } +} diff --git a/internal/config/testdata/profile-keys.ledger b/internal/config/testdata/profile-keys.ledger index 25fb63b8aa..4d6768e293 100644 --- a/internal/config/testdata/profile-keys.ledger +++ b/internal/config/testdata/profile-keys.ledger @@ -58,6 +58,8 @@ models.crew.task_cap models.fallbacks models.pool.public_key models.roles +models.tasks.mode +models.tasks.one models.tiers.high models.tiers.low models.tiers.mastermind diff --git a/internal/crewroute/allowed.go b/internal/crewroute/allowed.go index 8b92d1c5fd..fc4122d3ab 100644 --- a/internal/crewroute/allowed.go +++ b/internal/crewroute/allowed.go @@ -26,6 +26,12 @@ import ( // let in. A `+x` or `-x` naming a PROVIDER — `-openrouter` — takes that // provider's routes away rather than models, so a model it serves stays // allowed on any other route that reaches it. +// +// A `+x@p` or `-x@p` speaks about ONE MODEL ON ONE PROVIDER — `-glm-5.3@openrouter` +// leaves GLM 5.3 allowed on every other route that reaches it. It is how a +// per-provider checklist row is ticked and unticked, and it reads the same in +// `/crew models` as anywhere else. A build from before it reads the word as a +// model nobody has and so ignores it. // Base is the first word of a rule: what is allowed before any `+x` or `-x`. type Base string @@ -174,7 +180,62 @@ func (a Allowed) With(add bool, word string) Allowed { } // AdmitsModel says whether the rule allows a model, before routes are asked. +// A word about one model on one provider ([Allowed.AdmitsOn]) is not read here. func (a Allowed) AdmitsModel(m Model) bool { + admitted := a.baseAdmits(m) + for _, mod := range a.Mods { + if _, _, qualified := splitQualified(mod.Word); !qualified && matchesModel(mod.Word, m.ID) { + admitted = mod.Add + } + } + return admitted +} + +// AdmitsOn says whether the rule allows a model ON ONE PROVIDER'S ROUTE. The +// words are read left to right and the last word that speaks about this model +// on this provider wins: a model word, a provider word, or a word about the +// two together (`-glm-5.3@openrouter`). +func (a Allowed) AdmitsOn(m Model, provider string) bool { + provider = strings.ToLower(strings.TrimSpace(provider)) + model, route := a.baseAdmits(m), true + var routed *bool + for _, mod := range a.Mods { + add := mod.Add + if word, on, qualified := splitQualified(mod.Word); qualified { + if on == provider && matchesModel(word, m.ID) { + routed = &add + } + continue + } + if matchesModel(mod.Word, m.ID) { + model, routed = add, nil + } + if mod.Word == provider { + route, routed = add, nil + } + } + if routed != nil { + return *routed + } + return model && route +} + +// QualifiedWord is the rule word for one model on one provider. +func QualifiedWord(id, provider string) string { + return strings.ToLower(strings.TrimSpace(id)) + "@" + strings.ToLower(strings.TrimSpace(provider)) +} + +// splitQualified reads a `model@provider` word. +func splitQualified(word string) (model, provider string, ok bool) { + at := strings.LastIndex(word, "@") + if at <= 0 || at == len(word)-1 { + return "", "", false + } + return word[:at], word[at+1:], true +} + +// baseAdmits is the rule's first word alone. +func (a Allowed) baseAdmits(m Model) bool { admitted := false switch a.Base { case BaseAll, "": @@ -192,11 +253,6 @@ func (a Allowed) AdmitsModel(m Model) bool { } } } - for _, mod := range a.Mods { - if matchesModel(mod.Word, m.ID) { - admitted = mod.Add - } - } return admitted } @@ -223,7 +279,7 @@ func (a Allowed) NamesModel(id string) bool { } } for _, mod := range a.Mods { - if mod.Add && matchesModel(mod.Word, id) { + if _, _, qualified := splitQualified(mod.Word); !qualified && mod.Add && matchesModel(mod.Word, id) { return true } } @@ -305,7 +361,7 @@ func (a Allowed) Without(word string) Allowed { func (a Allowed) Toggled(m Model, admit bool) (Allowed, bool) { word := strings.ToLower(strings.TrimSpace(m.ID)) if a.Base == BaseList { - out := a.Without(word) + out := a.Without(word).withoutRoutesOf(m.ID) var members []string for _, member := range a.Members { if !matchesModel(member, m.ID) { @@ -326,13 +382,41 @@ func (a Allowed) Toggled(m Model, admit bool) (Allowed, bool) { } return out, true } - out := a.Without(word) + out := a.Without(word).withoutRoutesOf(m.ID) if out.AdmitsModel(m) != admit { out = out.With(admit, word) } return out, true } +// withoutRoutesOf is the rule with every word about this model on one +// provider taken off: a tick for the model everywhere speaks for every route. +func (a Allowed) withoutRoutesOf(id string) Allowed { + out := a + out.Mods = nil + for _, mod := range a.Mods { + if word, _, qualified := splitQualified(mod.Word); qualified && matchesModel(word, id) { + continue + } + out.Mods = append(out.Mods, mod) + } + return out +} + +// RowToggled is the rule with ONE MODEL ON ONE PROVIDER allowed or not, in the +// shortest spelling: the word about that row is taken off when the rest of the +// rule already says what is wanted, and written when it does not. It is what a +// tick in a per-provider checklist writes, and it never touches the same model +// on another provider. +func (a Allowed) RowToggled(m Model, provider string, admit bool) Allowed { + word := QualifiedWord(m.ID, provider) + out := a.Without(word) + if out.AdmitsOn(m, provider) != admit { + out = out.With(admit, word) + } + return out +} + // RouteToggled is the rule with one provider's routes given back or taken // away, in the shortest spelling, the way [Allowed.Toggled] says a model. func (a Allowed) RouteToggled(provider string, admit bool) Allowed { diff --git a/internal/crewroute/allowed_route_test.go b/internal/crewroute/allowed_route_test.go new file mode 100644 index 0000000000..238d6b04c4 --- /dev/null +++ b/internal/crewroute/allowed_route_test.go @@ -0,0 +1,67 @@ +package crewroute + +import "testing" + +// A TICK IS ONE MODEL ON ONE PROVIDER. Unticking GLM 5.3 on OpenRouter leaves +// the same model allowed on Z.ai, and the rule says so in words `/crew models` +// reads back. +func TestARowTickSpeaksForOneProviderOnly(t *testing.T) { + glm := Model{ID: "z-ai/glm-5.3", PromptPrice: 4e-7, CompletionPrice: 1.6e-6} + for _, base := range []string{"all", "open", "≤1/5", "z-ai/glm-5.3, deepseek"} { + rule, err := ParseAllowed(base) + if err != nil { + t.Fatal(err) + } + if base == "open" { + glm.Open = true + } + off := rule.RowToggled(glm, "openrouter", false) + if off.AdmitsOn(glm, "openrouter") { + t.Errorf("%s: unticked on openrouter, still admitted there: %s", base, off) + } + if !off.AdmitsOn(glm, "z-ai") { + t.Errorf("%s: unticking openrouter's row took z-ai's too: %s", base, off) + } + reread, err := ParseAllowed(off.String()) + if err != nil || reread.String() != off.String() { + t.Fatalf("%s: rule %q does not read back: %v", base, off, err) + } + on := reread.RowToggled(glm, "openrouter", true) + if on.String() != rule.String() { + t.Errorf("%s: ticking back wrote %q, want the rule as it was, %q", base, on, rule) + } + } +} + +// A LATER WORD ABOUT THE WHOLE MODEL OR PROVIDER OUTRANKS AN EARLIER ROW WORD, +// so the last word written is still the verdict. +func TestTheLastWordWinsBetweenRowAndModelWords(t *testing.T) { + glm := Model{ID: "z-ai/glm-5.3"} + rule, _ := ParseAllowed("all -glm-5.3@openrouter -glm-5.3") + if rule.AdmitsOn(glm, "z-ai") || rule.AdmitsOn(glm, "openrouter") { + t.Errorf("a later -glm-5.3 should leave it out everywhere") + } + rule, _ = ParseAllowed("all -glm-5.3 +glm-5.3@z-ai") + if !rule.AdmitsOn(glm, "z-ai") || rule.AdmitsOn(glm, "openrouter") { + t.Errorf("+glm-5.3@z-ai after -glm-5.3 should let it in on z-ai alone") + } + rule, _ = ParseAllowed("all +glm-5.3@openrouter -openrouter") + if rule.AdmitsOn(glm, "openrouter") { + t.Errorf("a later -openrouter should take openrouter's route away") + } + // A row word says nothing about the model as a whole. + if !rule.AdmitsModel(glm) || rule.NamesModel("z-ai/glm-5.3") { + t.Errorf("a row word must not be read as a model word") + } +} + +// A MODEL-WIDE TICK SPEAKS FOR EVERY ROUTE, so it clears the row words for +// that model rather than leaving one provider silently out. +func TestAModelTickClearsItsRowWords(t *testing.T) { + glm := Model{ID: "z-ai/glm-5.3"} + rule, _ := ParseAllowed("all -glm-5.3@openrouter") + out, ok := rule.Toggled(glm, true) + if !ok || out.String() != "all" || !out.AdmitsOn(glm, "openrouter") { + t.Errorf("model tick: %q, want all", out) + } +} diff --git a/internal/tui3/crew.go b/internal/tui3/crew.go index b1799b9fed..376402b5bd 100644 --- a/internal/tui3/crew.go +++ b/internal/tui3/crew.go @@ -477,6 +477,10 @@ func (a *app) crewHint() string { return a.crewSegment() } const ( crewOneModelSegment = "one model" crewOneModelWord = "one model · every call rides the model you are talking to" + // crewTaskOneWord and crewTaskOneTail say the profile's one-model row: a + // row the app's Task models page writes, read at every task's start. + crewTaskOneWord = "one model for every task: " + crewTaskOneTail = " · the seats and allowed models below wait for Auto" ) // refreshSettings rebuilds an open settings panel from the registry. A command diff --git a/internal/tui3/crewpanel.go b/internal/tui3/crewpanel.go index fa8e39272f..f2400bdc57 100644 --- a/internal/tui3/crewpanel.go +++ b/internal/tui3/crewpanel.go @@ -2083,6 +2083,11 @@ func (a *app) crewWarnings() []string { var out []string if a.oneModel { out = append(out, crewOneModelWord) + } else if config.TaskModelsModeAt(a.profileDir) == crewroute.ModeOne { + // THE ONE-MODEL ROW IS SAID HERE TOO: every task runs it, and the seats + // and allowed models below wait, unchanged, for Auto. + one, _ := config.TaskOneModelAt(a.profileDir) + out = append(out, crewTaskOneWord+crewroute.ShortModel(one.Model)+crewTaskOneTail) } if len(p.providers) == 0 { out = append(out, crewNoProviderWord) From 148276764b2c8e52ee4741e5fac047eaeb12a378 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 20:25:19 -0400 Subject: [PATCH 060/459] taskmodels: Auto weighs a direct route only at its own price, and an allowed pin or one model never swaps Direct metered routes are Auto candidates only when the provider's own cached listing published a price, and are weighed at it (catalog.OwnPrices with fetch provenance; Route.Price). An allowed saved pin or the one model on a route known not to answer refuses admission with a typed NoEligibleError; setting such a one model is refused. A task-models read is one consistent observation stamped when it began, and the caps no longer move its revision. Co-Authored-By: Claude Opus 5.5 --- internal/catalog/catalog.go | 23 ++- internal/catalog/ownprice.go | 79 +++++++++ internal/catalog/ownprice_test.go | 46 +++++ internal/config/crew.go | 80 ++++++++- internal/config/crew_test.go | 11 ++ internal/config/crewhealth_test.go | 1 + internal/config/ownprice_fixture_test.go | 39 +++++ internal/config/taskmodelrows.go | 162 +++++++++++------- internal/config/taskmodels.go | 81 +++++++-- .../config/taskmodels_price_review_test.go | 71 ++++++++ internal/config/taskmodels_test.go | 112 ++++++++++++ internal/crewroute/route.go | 47 ++++- internal/manual/chat/models-and-cost.md | 61 ++++++- 13 files changed, 722 insertions(+), 91 deletions(-) create mode 100644 internal/catalog/ownprice.go create mode 100644 internal/catalog/ownprice_test.go create mode 100644 internal/config/ownprice_fixture_test.go create mode 100644 internal/config/taskmodels_price_review_test.go diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go index 2efdc00a25..ec1ac7e4d9 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -232,6 +232,11 @@ type cache struct { Models []Model `json:"models"` Source string `json:"source,omitempty"` Base string `json:"base,omitempty"` + // Priced is set on a cache written from the service's own listing, whose + // rows say what they cost or that they did not say ([Model.PriceUnknown]). + // A cache seeded from model ids alone ([Remember]), and one written before + // this mark existed, knows NO price: its zeros are absences, never free. + Priced bool `json:"priced,omitempty"` } // FetchTimeout bounds one listing request on the default HTTP client. A caller @@ -524,7 +529,7 @@ func load(ctx context.Context, options Options) (*rows, error) { if err == nil { fetchedAt := now().UTC() if path != "" { - _ = writeCache(path, cache{FetchedAt: fetchedAt, Models: models, Source: source, Base: base}) + _ = writeCache(path, cache{FetchedAt: fetchedAt, Models: models, Source: source, Base: base, Priced: true}) } return newRowsAt(models, fetchedAt), nil } @@ -1538,6 +1543,22 @@ func readCache(path, source, base string) (cache, bool) { if err != nil { return cache{}, false } + return decodeCache(raw, source, base) +} + +// decodeCacheAny reads a cache file's rows whatever compartment it names. +func decodeCacheAny(raw []byte) (cache, bool) { + var cached cache + if json.Unmarshal(raw, &cached) != nil || cached.FetchedAt.IsZero() { + return cache{}, false + } + cached.Models = cleanModels(cached.Models) + return cached, true +} + +// decodeCache reads one cache file's bytes as the compartment of one service +// and base, refusing a cache that belongs to another. +func decodeCache(raw []byte, source, base string) (cache, bool) { var cached cache if json.Unmarshal(raw, &cached) != nil || cached.FetchedAt.IsZero() { return cache{}, false diff --git a/internal/catalog/ownprice.go b/internal/catalog/ownprice.go new file mode 100644 index 0000000000..bc34b3f029 --- /dev/null +++ b/internal/catalog/ownprice.go @@ -0,0 +1,79 @@ +package catalog + +import ( + "strings" + + "github.com/Agent-Field/codeaf/internal/filememo" +) + +// A SERVICE'S OWN PRICES, FROM ITS OWN LISTING — AND NOTHING ELSE. +// +// The default service's catalog prices every model it lists, and those are its +// own rates: they say nothing about what another service charges for the same +// model. A connection's own listing is the only evidence of ITS price, and only +// when that listing published one. These facts are read from the listing this +// install already cached for the service — nothing is fetched to answer — and +// a cache file is parsed once per change to it, so a router that asks on every +// task pays a stat. + +// PriceFact is what one service's own listing said about one model's price, +// dollars per token. Known false is "it did not say": never read as free. A +// listing that published an explicit zero is a known price of zero. +type PriceFact struct { + Known bool + Prompt float64 + Completion float64 + Request float64 + CacheRead float64 +} + +// ownPrices is one cache file's price facts, with the compartment it was +// written for so a memo held for one service is never answered for another. +type ownPrices struct { + source, base string + facts map[string]PriceFact +} + +var ownPriceMemo = filememo.New(func(_ string, data []byte, missing bool) (ownPrices, error) { + if missing || len(data) == 0 { + return ownPrices{}, nil + } + return ownPricesOf(data), nil +}) + +// ownPricesOf reads a cache's rows into facts; ownership is checked by the +// caller against the compartment it asked for. +func ownPricesOf(data []byte) ownPrices { + out := ownPrices{facts: map[string]PriceFact{}} + cached, ok := decodeCacheAny(data) + if !ok { + return out + } + out.source, out.base = strings.TrimSpace(cached.Source), normalizeBase(cached.Base) + for _, m := range cached.Models { + fact := PriceFact{} + if cached.Priced && !m.PriceUnknown { + fact = PriceFact{Known: true, Prompt: m.PromptPrice, Completion: m.CompletionPrice, Request: m.RequestPrice, CacheRead: m.CacheReadPrice} + } + out.facts[strings.ToLower(m.ID)] = fact + } + return out +} + +// OwnPrices is one service's own price facts by lower-cased model id, read +// from the listing cached for that service and base. A service with no cached +// listing answers an empty map; a model it lists without a price answers a +// fact that is not Known. +func OwnPrices(options Options) map[string]PriceFact { + source := strings.TrimSpace(options.Source) + base := normalizeBase(options.BaseURL) + path := cachePath(options.Dir, source, base) + if path == "" { + return nil + } + held, _ := ownPriceMemo.Read(path) + if held.facts == nil || held.source != source || held.base != base { + return nil + } + return held.facts +} diff --git a/internal/catalog/ownprice_test.go b/internal/catalog/ownprice_test.go new file mode 100644 index 0000000000..3cf4241270 --- /dev/null +++ b/internal/catalog/ownprice_test.go @@ -0,0 +1,46 @@ +package catalog + +import ( + "context" + "net/http" + "net/http/httptest" + "testing" +) + +// A SEEDED LISTING KNOWS NO PRICE; A FETCHED ONE KNOWS WHAT IT PUBLISHED — +// including an explicit zero, which is free, and an absence, which is not. +func TestOwnPricesKeepFreeApartFromUnknown(t *testing.T) { + dir := t.TempDir() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + _, _ = w.Write([]byte(`{"data":[ + {"id":"lab/paid","pricing":{"prompt":"0.000001","completion":"0.000002"}}, + {"id":"lab/free","pricing":{"prompt":"0","completion":"0"}}, + {"id":"lab/silent"}]}`)) + })) + defer server.Close() + options := Options{Source: "lab", BaseURL: server.URL, Dir: dir} + if err := Remember(options, []Model{{ID: "lab/paid"}, {ID: "lab/free"}}); err != nil { + t.Fatal(err) + } + for id, fact := range OwnPrices(options) { + if fact.Known { + t.Errorf("a seeded listing claims a price for %s: %+v", id, fact) + } + } + if _, err := Refresh(context.Background(), options); err != nil { + t.Fatal(err) + } + facts := OwnPrices(options) + if f := facts["lab/paid"]; !f.Known || f.Prompt != 1e-6 || f.Completion != 2e-6 { + t.Errorf("paid: %+v", f) + } + if f := facts["lab/free"]; !f.Known || f.Prompt != 0 || f.Completion != 0 { + t.Errorf("an explicit zero is a known free price: %+v", f) + } + if f := facts["lab/silent"]; f.Known { + t.Errorf("a row that published nothing claims a price: %+v", f) + } + if other := OwnPrices(Options{Source: "other", BaseURL: server.URL, Dir: dir}); len(other) != 0 { + t.Errorf("another compartment answered %v", other) + } +} diff --git a/internal/config/crew.go b/internal/config/crew.go index ea023b225d..2a83267326 100644 --- a/internal/config/crew.go +++ b/internal/config/crew.go @@ -710,6 +710,19 @@ type CrewProvider struct { // collides lists the catalog vendors whose ids this connection's Written // prefix would capture, so the default route spells them `openrouter/…`. collides map[string]bool + // own is a direct metered connection's OWN price facts, from its own + // cached listing ([catalog.OwnPrices]), keyed by the id it lists. The + // default service's own prices are the catalog's and are not kept here. + own map[string]catalog.PriceFact +} + +// ownPrice is what this connection's own listing published for a send, and +// whether it is a direct metered route that needs one at all. +func (p CrewProvider) ownPrice(r crewroute.Route) (fact catalog.PriceFact, needs bool) { + if p.ID == modelsource.DefaultID || r.Kind != crewroute.Metered { + return catalog.PriceFact{}, false + } + return p.own[strings.ToLower(strings.TrimPrefix(r.Send, p.Written+"/"))], true } // crewVendors maps a direct connection to the CATALOG vendor prefixes it @@ -811,6 +824,9 @@ func CrewProvidersAt(profileDir string) []CrewProvider { if !strings.EqualFold(source.ID, modelsource.DefaultID) && !modelsource.IsCustomID(source.ID) && !strings.EqualFold(source.ID, "ollama") { p.vendors = append([]string{strings.ToLower(source.Written)}, crewVendors[strings.ToLower(source.ID)]...) } + if p.Kind == crewroute.Metered && !strings.EqualFold(source.ID, modelsource.DefaultID) { + p.own = catalog.OwnPrices(CatalogOptionsFor(service, profileDir)) + } out = append(out, p) } return out @@ -984,6 +1000,20 @@ func crewCandidatesWith(rule crewroute.Allowed, providers []CrewProvider, facts if !ok { continue } + // A DIRECT ROUTE IS WEIGHED AT ITS OWN PROVIDER'S PRICE, and one + // whose provider published none is no route the router may pick: + // the catalog's price is another provider's, and says nothing about + // this one's bill. A pin still names it ([resolveCrewPin]). + if fact, needs := p.ownPrice(r); needs { + if !fact.Known { + continue + } + r.Price = &crewroute.RoutePrice{Prompt: fact.Prompt, Completion: fact.Completion, CacheRead: fact.CacheRead} + } else if r.Kind == crewroute.Metered && p.ID == modelsource.DefaultID && (m.PromptPrice > 0 || m.CompletionPrice > 0) { + // Freeze the default service's own published catalog tariff too; + // historical decisions must never reread today's model prices. + r.Price = &crewroute.RoutePrice{Prompt: m.PromptPrice, Completion: m.CompletionPrice, CacheRead: m.CacheReadPrice} + } switch { case r.Kind != crewroute.Metered: plans = append(plans, r) @@ -1192,6 +1222,10 @@ type CrewAsk struct { // Pins are ONE-TASK pins, laid over the profile's: `--pin` and the seat // flags. They never persist. Pins map[crewroute.Seat]CrewPin + // AutoSeats explicitly ignores saved role pins (and one-model policy) for + // these roles for this admission only. Explicit Pins and Sends still win. + // Absent/false keeps saved policy unchanged. + AutoSeats map[crewroute.Seat]bool // Sends are seats a door has already filled with an id to send as it // stands — a flag, a variable — which the router treats as pins. Sends map[crewroute.Seat]string @@ -1378,13 +1412,49 @@ func OffsetKey(repo string, class crewroute.Class) string { // [ErrCrewAtCap]; a seat nothing allowed can sit is a [*crewroute.NoEligibleError], // and is never seated on a model the allowed models leave out. func RouteCrew(profileDir string, ask CrewAsk) (crewroute.Decision, error) { - if TaskModelsModeAt(profileDir) == crewroute.ModeOne { + oneMode := TaskModelsModeAt(profileDir) == crewroute.ModeOne + autoOverride := false + for _, seat := range crewroute.Seats { + autoOverride = autoOverride || ask.AutoSeats[seat] + } + if oneMode && !autoOverride { return routeOneModel(profileDir, ask) } reading := crewroute.Classify(ask.Task) providers := CrewProvidersAt(profileDir) - pins, warnings := crewSavedPins(profileDir, providers) + health := crewHealthAt(profileDir) + pins, warnings, refused := crewSavedPins(profileDir, providers, health) filled := map[crewroute.Seat]bool{} + if oneMode { + // An explicit per-task opt-out routes only its named roles. The + // untouched roles keep the saved one model, even outside Auto's set. + pins = map[crewroute.Seat]crewroute.Pin{} + warnings = nil + refused = map[crewroute.Seat]*crewroute.NoEligibleError{} + one, haveOne := TaskOneModelAt(profileDir) + for _, seat := range crewroute.Seats { + if ask.AutoSeats[seat] { + continue + } + route := resolveCrewPin(one, providers) + if why, reachable := crewPinReachable(providers, route, health); !haveOne || !reachable { + if !haveOne { + why = crewroute.RejectProviderUnavailable + } + refusal := &crewroute.NoEligibleError{Seat: seat, Mode: crewroute.ModeOne} + refusal.Reject(route.Model, route.Provider, why) + refused[seat] = refusal + } + pins[seat] = route + } + } + for _, seat := range crewroute.Seats { + if ask.AutoSeats[seat] { + delete(pins, seat) + delete(refused, seat) + filled[seat] = true + } + } for seat, pin := range ask.Pins { pins[seat], filled[seat] = resolveCrewPin(pin, providers), true } @@ -1394,12 +1464,16 @@ func RouteCrew(profileDir string, ask CrewAsk) (crewroute.Decision, error) { } } warnings = withoutWarningsFor(warnings, filled) + for _, seat := range crewroute.Seats { + if refusal, ok := refused[seat]; ok && !filled[seat] { + return crewroute.Decision{}, refusal + } + } day := CrewDay{} if CrewHistory != nil { day = CrewHistory(profileDir) } pace, atCap := crewroute.Pace(day.SpentUSD, CrewCapAt(profileDir)) - health := crewHealthAt(profileDir) // AN ACCOUNT OUT OF CREDIT, OR A KEY REFUSED, IS PROBED BY THE NEXT TASK: // its paid routes are routed as usual, so the task's first seat call is // the probe. A 200 clears the account on the spot; another refusal moves diff --git a/internal/config/crew_test.go b/internal/config/crew_test.go index 53a009fe1c..bfbf0e2f5a 100644 --- a/internal/config/crew_test.go +++ b/internal/config/crew_test.go @@ -459,6 +459,13 @@ func TestADirectRouterConnectionServesItsCatalogVendorsUnderItsOwnIds(t *testing IntelligenceIndex: 45, CodingIndex: 68, AgenticIndex: 44, ArenaElo: 1300, ContextLength: 262144, Parameters: []string{"tools"}}, ) + // ai&'s own listing publishes its own prices (unit data): a direct route + // is weighed at them, and is no route at all without them. + seedOwnPrices(t, dir, "aiand", map[string][3]float64{ + "deepseek-ai/deepseek-v4-flash": {8.246e-8, 1.6492e-7, 1.6492e-8}, "zai-org/glm-5.3-flash": {1.5e-7, 5e-7, 5e-8}, + "moonshotai/kimi-k3": {3e-6, 1.5e-5, 3e-7}, "qwen/qwen3.8-flash": {2e-7, 6e-7, 0}, + "openai/gpt-oss-120b": {1e-7, 4e-7, 0}, "google/gemma-4-31b-it": {2e-7, 5e-7, 0}, + }) routes := map[string]string{} families := map[string]bool{} for _, c := range CrewCandidatesAt(dir) { @@ -527,6 +534,10 @@ func TestOneMultiFamilyKeyCarriesSeatsOnDifferentLabs(t *testing.T) { widenCrewCatalog(t, catalog.Model{ID: "qwen/qwen3.8-flash", OpenWeights: true, PromptPrice: 2e-7, CompletionPrice: 6e-7, IntelligenceIndex: 40.1, CodingIndex: 70.2, AgenticIndex: 49, ArenaElo: 1330, ContextLength: 1310720, Parameters: []string{"tools"}}) + seedOwnPrices(t, dir, "aiand", map[string][3]float64{ + "deepseek-ai/deepseek-v4-flash": {8.246e-8, 1.6492e-7, 1.6492e-8}, "zai-org/glm-5.3-flash": {1.5e-7, 5e-7, 5e-8}, + "moonshotai/kimi-k3": {3e-6, 1.5e-5, 3e-7}, "qwen/qwen3.8-flash": {2e-7, 6e-7, 0}, + }) decision, err := RouteCrew(dir, CrewAsk{Task: crewroute.Task{Text: openTask}}) if err != nil { t.Fatal(err) diff --git a/internal/config/crewhealth_test.go b/internal/config/crewhealth_test.go index 6fc772f9c8..40fdc11304 100644 --- a/internal/config/crewhealth_test.go +++ b/internal/config/crewhealth_test.go @@ -236,6 +236,7 @@ func TestADirectConnectionIsASecondRouteToTheSameModel(t *testing.T) { if err := writeProfileValue(dir, keyModelSources, []PersistedSource{{ID: "deepseek", Written: "deepseek", Key: "sk-deepseek-0123456789", Order: 1}}); err != nil { t.Fatal(err) } + seedOwnPrices(t, dir, "deepseek", map[string][3]float64{"deepseek-v4-flash": {8.246e-8, 1.6492e-7, 1.6492e-8}}) var history []router.CrewRouteOutcome withRouteHistory(t, &history) var routes []crewroute.Route diff --git a/internal/config/ownprice_fixture_test.go b/internal/config/ownprice_fixture_test.go new file mode 100644 index 0000000000..ac391ac292 --- /dev/null +++ b/internal/config/ownprice_fixture_test.go @@ -0,0 +1,39 @@ +package config + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/catalog" +) + +// seedOwnPrices writes a connected provider's OWN listing into its catalog +// compartment as a fetched, priced listing would be cached. It is UNIT DATA: +// it stands in for what that provider's /models published, keyed by the ids +// that provider lists, so a direct route has its own price to be weighed at: in, out and cache +// read, dollars per token. +func seedOwnPrices(t *testing.T, dir, provider string, prices map[string][3]float64) { + t.Helper() + service, ok := ResolveSources(dir, APIKeyAt(dir), DefaultBaseURL).ByID(provider) + if !ok { + t.Fatalf("%s is not connected", provider) + } + options := CatalogOptionsFor(service, dir) + var models []catalog.Model + for id, p := range prices { + models = append(models, catalog.Model{ID: id, PromptPrice: p[0], CompletionPrice: p[1], CacheReadPrice: p[2]}) + } + raw, err := json.Marshal(map[string]any{ + "fetched_at": time.Now().UTC(), "models": models, "source": options.Source, "base": options.BaseURL, "priced": true, + }) + if err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "model-catalog-"+catalog.CacheKey(options.Source, options.BaseURL)+".json") + if err := os.WriteFile(path, raw, 0o600); err != nil { + t.Fatal(err) + } +} diff --git a/internal/config/taskmodelrows.go b/internal/config/taskmodelrows.go index 11b068415d..2f8cb6ab5a 100644 --- a/internal/config/taskmodelrows.go +++ b/internal/config/taskmodelrows.go @@ -9,8 +9,8 @@ import ( "fmt" "sort" "strings" + "time" - "github.com/Agent-Field/codeaf/internal/catalog" "github.com/Agent-Field/codeaf/internal/crewroute" "github.com/Agent-Field/codeaf/internal/modelsource" ) @@ -97,6 +97,10 @@ type TaskModelRole struct { // TaskModelsState is the whole configuration, read at one moment. type TaskModelsState struct { + // ObservedAt is when the read that produced this state BEGAN, so a slower + // read that started earlier can never carry a later time than a read, or + // a write, that started after it. + ObservedAt time.Time Revision string Mode crewroute.Mode One *CrewPin @@ -108,6 +112,23 @@ type TaskModelsState struct { // ErrTaskModelsRead is a read that could not finish inside its context. var ErrTaskModelsRead = errors.New("task models read timed out") +// ErrTaskModelsMoving is a read the profile kept changing under: every try saw +// a different revision at its end than at its start. No part of any try is +// answered. +var ErrTaskModelsMoving = errors.New("task models kept changing while they were read · read them again") + +// taskModelsReadTries is how many times a read starts again when the rows +// moved under it. +const taskModelsReadTries = 3 + +// taskModelsClock stamps an observation; taskModelsMidRead runs between the +// revision read at the start of a try and the one at its end. Both exist for a +// test to put a write exactly there; production leaves the hook nil. +var ( + taskModelsClock = time.Now + taskModelsMidRead func() +) + // taskModelRowCheck is how many rows are built between two looks at the // context. const taskModelRowCheck = 256 @@ -120,12 +141,16 @@ func TaskModelRowsAt(ctx context.Context, profileDir string) ([]TaskModelRow, er // taskModelRows builds the rows; a nil context is never cut. func taskModelRows(ctx context.Context, profileDir string) ([]TaskModelRow, error) { + return taskModelRowsWith(ctx, profileDir, crewHealthAt(profileDir)) +} + +// taskModelRowsWith is [taskModelRows] under one health reading already made. +func taskModelRowsWith(ctx context.Context, profileDir string, health crewHealth) ([]TaskModelRow, error) { if ctx == nil { ctx = context.Background() } providers := CrewProvidersAt(profileDir) rule := CrewAllowedAt(profileDir) - health := crewHealthAt(profileDir) facts := crewRouteFacts{free: CrewFreeRoutesAt(profileDir), health: health} off := CrewProvidersOffAt(profileDir) eligible := map[string]bool{} @@ -135,7 +160,6 @@ func taskModelRows(ctx context.Context, profileDir string) ([]TaskModelRow, erro } } models := taskCatalogModels() - listed := taskProviderListings(profileDir, providers) var out []TaskModelRow built := 0 for _, p := range providers { @@ -161,7 +185,8 @@ func taskModelRows(ctx context.Context, profileDir string) ([]TaskModelRow, erro // embedding model, not one too small to hold a role's context. continue } - row.Price = taskRowPrice(p, r, entry, listed[p.ID]) + var priced bool + row.Price, priced = taskRowPrice(p, r, entry) row.Allowed = rule.AdmitsOn(m, p.ID) _, resting := health.blocked[r.Send] providerDown := !p.On || health.disconnected[p.ID] || (r.Kind == crewroute.Metered && health.unaffordable[p.ID]) @@ -170,7 +195,7 @@ func taskModelRows(ctx context.Context, profileDir string) ([]TaskModelRow, erro switch { case !row.Allowed: row.Reason = crewroute.RejectNotAllowed - case !entry.priced: + case !priced: row.Reason = crewroute.RejectNoPrice case providerDown: row.Reason = crewroute.RejectProviderUnavailable @@ -230,57 +255,36 @@ func taskCatalogModels() []taskCatalogModel { return out } -// taskProviderListings is each direct provider's own cached listing, keyed by -// the id that provider lists. NOTHING IS FETCHED: a listing nobody cached is -// no listing, and its rows have no price. -func taskProviderListings(profileDir string, providers []CrewProvider) map[string]map[string]catalog.Model { - set := ResolveSources(profileDir, APIKeyAt(profileDir), DefaultBaseURL) - out := map[string]map[string]catalog.Model{} - for _, p := range providers { - if p.ID == modelsource.DefaultID || p.Kind != crewroute.Metered { - continue +// taskRowPrice is a row's price as ITS OWN provider published it, nil when +// it published none, and whether the route has a price Auto may weigh. The +// default service's price is its catalog's; a direct metered route's is its +// own listing's ([CrewProvider.ownPrice]) — a published zero is a price of +// zero, an absence is no price; a plan, a local model or a free pool bills no +// tokens and needs none. +func taskRowPrice(p CrewProvider, r crewroute.Route, entry taskCatalogModel) (*ModelPrice, bool) { + if fact, needs := p.ownPrice(r); needs { + if !fact.Known { + return nil, false } - service, ok := set.ByID(p.ID) - if !ok { - continue - } - rows := catalog.Recall(CatalogOptionsFor(service, profileDir)).ModelsNow() - if len(rows) == 0 { - continue - } - byID := make(map[string]catalog.Model, len(rows)) - for _, row := range rows { - byID[strings.ToLower(row.ID)] = row - } - out[p.ID] = byID + return &ModelPrice{InPerM: fact.Prompt * 1e6, OutPerM: fact.Completion * 1e6}, true } - return out -} - -// taskRowPrice is a row's price as its provider published it, nil otherwise. -func taskRowPrice(p CrewProvider, r crewroute.Route, entry taskCatalogModel, listing map[string]catalog.Model) *ModelPrice { if r.Kind != crewroute.Metered { - return nil - } - if p.ID == modelsource.DefaultID { - if !entry.priced { - return nil - } - return &ModelPrice{InPerM: entry.in * 1e6, OutPerM: entry.out * 1e6} + return nil, entry.priced } - own, ok := listing[strings.ToLower(strings.TrimPrefix(r.Send, p.Written+"/"))] - // A listing row that says nothing about price — or only zeros, which is - // what a listing seeded from model ids alone holds — is no price. - if !ok || own.PriceUnknown || (own.PromptPrice <= 0 && own.CompletionPrice <= 0) { - return nil + if !entry.priced { + return nil, false } - return &ModelPrice{InPerM: own.PromptPrice * 1e6, OutPerM: own.CompletionPrice * 1e6} + return &ModelPrice{InPerM: entry.in * 1e6, OutPerM: entry.out * 1e6}, true } // TaskModelProvidersAt is every provider a person could use for tasks: each // connected one, then each one this build can connect that has no key yet. func TaskModelProvidersAt(profileDir string, rows []TaskModelRow) []TaskModelProvider { - health := crewHealthAt(profileDir) + return taskModelProviders(profileDir, rows, crewHealthAt(profileDir)) +} + +// taskModelProviders is [TaskModelProvidersAt] under one health reading. +func taskModelProviders(profileDir string, rows []TaskModelRow, health crewHealth) []TaskModelProvider { connected := CrewProvidersAt(profileDir) var out []TaskModelProvider seen := map[string]bool{} @@ -320,11 +324,16 @@ func TaskModelProvidersAt(profileDir string, rows []TaskModelRow) []TaskModelPro } // taskModelsKeys are every row the task models are read from — the pins, the -// routes, the rule, the providers turned off, the free routes, the caps, the -// mode and the retired rows a write migrates — so a revision moves when any -// of them does. +// routes, the rule, the providers turned off, the free routes, the mode, the +// one model and the retired rows a write migrates — so a revision moves when +// any of them does. THE CAPS ARE NOT AMONG THEM: they are the Spending +// settings' rows, and a budget change is not a change to the task models. func taskModelsKeys() []string { - keys := append(crewStateKeys(), KeyTaskModelsMode, KeyTaskModelsOne, legacyKeyCrew, legacyKeyCrewPick, legacyKeyCrewSource) + keys := []string{KeyCrewAllowed, KeyCrewProvidersOff, KeyCrewFreeRoutes, KeyTaskModelsMode, KeyTaskModelsOne, + legacyKeyCrew, legacyKeyCrewPick, legacyKeyCrewSource} + for _, seat := range crewroute.Seats { + keys = append(keys, tierKeyFor(CrewSeatTier(seat)), crewRouteKey(seat)) + } sort.Strings(keys) return keys } @@ -363,10 +372,40 @@ func TaskModelsRevisionAt(profileDir string) string { return taskModelsRevision(taskModelsSnapshot(profileDir)) } -// TaskModelsAt is the whole configuration with its rows, read inside ctx. +// TaskModelsAt is the whole configuration with its rows, read inside ctx, as +// ONE CONSISTENT OBSERVATION: the rows' revision is read when a try starts and +// again when it ends, and a try the profile moved under is thrown away whole +// and started again — never answered half old, half new. Route health is read +// once per try, so every row, header and warning of one answer agree. func TaskModelsAt(ctx context.Context, profileDir string) (TaskModelsState, []TaskModelRow, error) { - revision := TaskModelsRevisionAt(profileDir) - rows, err := taskModelRows(ctx, profileDir) + if ctx == nil { + ctx = context.Background() + } + for try := 0; try < taskModelsReadTries; try++ { + if ctx.Err() != nil { + return TaskModelsState{}, nil, ErrTaskModelsRead + } + observed := taskModelsClock() + revision := TaskModelsRevisionAt(profileDir) + state, rows, err := taskModelsOnce(ctx, profileDir, revision) + if err != nil { + return TaskModelsState{}, nil, err + } + if taskModelsMidRead != nil { + taskModelsMidRead() + } + if TaskModelsRevisionAt(profileDir) == revision { + state.ObservedAt = observed + return state, rows, nil + } + } + return TaskModelsState{}, nil, ErrTaskModelsMoving +} + +// taskModelsOnce is one try of [TaskModelsAt]. +func taskModelsOnce(ctx context.Context, profileDir, revision string) (TaskModelsState, []TaskModelRow, error) { + health := crewHealthAt(profileDir) + rows, err := taskModelRowsWith(ctx, profileDir, health) if err != nil { return TaskModelsState{}, nil, err } @@ -375,7 +414,7 @@ func TaskModelsAt(ctx context.Context, profileDir string) (TaskModelsState, []Ta rule := CrewAllowedAt(profileDir) if one, ok := TaskOneModelAt(profileDir); ok { state.One = &one - state.OneWarning = taskPinWarning(profileDir, providers, rows, one, nil) + state.OneWarning = taskPinWarning(providers, rows, one, nil, health) } saved := CrewPinsAt(profileDir) for _, seat := range crewroute.Seats { @@ -389,26 +428,26 @@ func TaskModelsAt(ctx context.Context, profileDir string) (TaskModelsState, []Ta } if pin, ok := saved[seat]; ok { role.Pin = &pin - role.Warning = taskPinWarning(profileDir, providers, rows, pin, &rule) + role.Warning = taskPinWarning(providers, rows, pin, &rule, health) } if role.Eligible == 0 && role.Pin == nil { role.Warning = TaskWarnNoEligible } state.Roles[seat] = role } - state.Providers = TaskModelProvidersAt(profileDir, rows) + state.Providers = taskModelProviders(profileDir, rows, health) return state, rows, nil } // taskPinWarning is what is wrong with a saved pin, or nothing. A rule is // asked for a role's pin and not for the one model, which the allowed models // do not govern. -func taskPinWarning(profileDir string, providers []CrewProvider, rows []TaskModelRow, pin CrewPin, rule *crewroute.Allowed) string { +func taskPinWarning(providers []CrewProvider, rows []TaskModelRow, pin CrewPin, rule *crewroute.Allowed, health crewHealth) string { route := resolveCrewPin(pin, providers) if rule != nil && !crewPinAdmitted(*rule, pin.Model, route.Provider) { return string(crewroute.PinNotAllowed) } - if _, ok := crewPinReachable(profileDir, providers, route); !ok { + if _, ok := crewPinReachable(providers, route, health); !ok { return string(crewroute.PinProviderUnavailable) } if len(rows) > 0 { @@ -506,7 +545,8 @@ func ApplyTaskModels(ctx context.Context, profileDir string, change TaskModelsCh // taskModelsValues is a change as the rows it writes, every part checked. func taskModelsValues(ctx context.Context, profileDir string, change TaskModelsChange) (map[string]any, error) { - rows, err := taskModelRows(ctx, profileDir) + health := crewHealthAt(profileDir) + rows, err := taskModelRowsWith(ctx, profileDir, health) if err != nil { return nil, err } @@ -531,7 +571,7 @@ func taskModelsValues(ctx context.Context, profileDir string, change TaskModelsC return nil, err } route := resolveCrewPin(pin, providers) - if _, ok := crewPinReachable(profileDir, providers, route); !ok { + if _, ok := crewPinReachable(providers, route, health); !ok { return nil, refuse("%s is not reachable on %s · connect it or turn it on first", pin.Model, taskProviderName(providers, route.Provider)) } one, haveOne = pin, true @@ -543,7 +583,7 @@ func taskModelsValues(ctx context.Context, profileDir string, change TaskModelsC if !haveOne { return nil, refuse("one model for every task needs the model · send one_model") } - if _, ok := crewPinReachable(profileDir, providers, resolveCrewPin(one, providers)); !ok { + if _, ok := crewPinReachable(providers, resolveCrewPin(one, providers), health); !ok { return nil, refuse("%s is not reachable right now · choose another one model", one.Model) } values[KeyTaskModelsMode] = string(crewroute.ModeOne) diff --git a/internal/config/taskmodels.go b/internal/config/taskmodels.go index 62361d8339..37f868b50c 100644 --- a/internal/config/taskmodels.go +++ b/internal/config/taskmodels.go @@ -70,8 +70,13 @@ func routeOneModel(profileDir string, ask CrewAsk) (crewroute.Decision, error) { } providers := CrewProvidersAt(profileDir) route := resolveCrewPin(one, providers) - if why, ok := crewPinReachable(profileDir, providers, route); !ok { - refusal := &crewroute.NoEligibleError{Seat: crewroute.Worker, Mode: crewroute.ModeOne} + // THE ONE MODEL IS NOT PUT ON TRIAL. Auto may send a task's first call to + // an account it last saw refuse, because another model is there if it + // refuses again; one-model mode has no other model, so a route known to be + // signed out, out of credit or resting refuses the task with the one fix. + health := crewHealthAt(profileDir) + if why, ok := crewPinReachable(providers, route, health); !ok { + refusal := &crewroute.NoEligibleError{Seat: crewroute.Worker, Mode: crewroute.ModeOne, Action: health.actionFor(route, "choose another one model")} refusal.Reject(route.Model, route.Provider, why) return crewroute.Decision{}, refusal } @@ -118,24 +123,60 @@ func routeOneModel(profileDir string, ask CrewAsk) (crewroute.Decision, error) { return d, nil } -// crewPinReachable is whether a resolved pin's route goes through a provider -// that is connected and on, and why not. -func crewPinReachable(profileDir string, providers []CrewProvider, route crewroute.Pin) (crewroute.RejectReason, bool) { +// crewPinReachable is whether a resolved pin's route is one route health +// expects to answer — a provider connected and on, whose key was not refused, +// whose account is not out of credit for a paid route, on a send that is not +// resting — and why not. +func crewPinReachable(providers []CrewProvider, route crewroute.Pin, health crewHealth) (crewroute.RejectReason, bool) { p, ok := crewProviderByID(providers, route.Provider) - if !ok || !p.On { + switch { + case !ok || !p.On || health.disconnected[p.ID] || (route.Kind == crewroute.Metered && health.unaffordable[p.ID]): return crewroute.RejectProviderUnavailable, false } + if _, resting := health.blocked[route.Send]; resting { + return crewroute.RejectRouteUnavailable, false + } return "", true } -// crewSavedPins is the profile's pins resolved for routing: each pin the -// allowed models admit on its route, and a warning for every other — a pin -// outside them is not used and the role is routed; a pin whose provider is not -// connected or is off is still used, and said. -func crewSavedPins(profileDir string, providers []CrewProvider) (map[crewroute.Seat]crewroute.Pin, []crewroute.SeatWarning) { +// actionFor is the one thing a person can do about one route health holds +// back, followed by the other way out (or), or the other way alone when health +// has nothing to say about the route — a provider not connected, or off. +func (h crewHealth) actionFor(route crewroute.Pin, or string) string { + switch { + case h.disconnected[route.Provider]: + return "reconnect " + route.Provider + " with /connect, or " + or + case route.Kind == crewroute.Metered && h.unaffordable[route.Provider]: + return "add credit on " + route.Provider + ", or " + or + } + if until, resting := h.blocked[route.Send]; resting { + return crewroute.ShortModel(route.Model) + " on " + route.Provider + " is resting until " + until.Local().Format("15:04") + " · try then, or " + or + } + provider := route.Provider + if provider == "" { + provider = "its provider" + } + return "connect or turn on " + provider + ", or " + or +} + +// crewSavedPins is the profile's pins resolved for routing, under two laws: +// +// - A PIN THE ALLOWED MODELS LEAVE OUT IS SET ASIDE: the role is routed by +// Auto for this task and the decision carries a pin_not_allowed warning. +// That is the one exception, because the person said both things — this +// model, and not this model — and the later word is theirs. +// - AN ALLOWED PIN IS NEVER SUBSTITUTED. A pin whose route is known not to +// answer — its provider gone, off, signed out, out of credit, or the send +// resting — is the role's refusal ([crewroute.NoEligibleError]), with the +// one fix, and never a quiet move to another model or another provider. +// +// The refusals are per seat, so a seat this task's own pins fill is not +// refused for a saved pin it does not use. +func crewSavedPins(profileDir string, providers []CrewProvider, health crewHealth) (map[crewroute.Seat]crewroute.Pin, []crewroute.SeatWarning, map[crewroute.Seat]*crewroute.NoEligibleError) { rule := CrewAllowedAt(profileDir) pins := map[crewroute.Seat]crewroute.Pin{} var warnings []crewroute.SeatWarning + refused := map[crewroute.Seat]*crewroute.NoEligibleError{} saved := CrewPinsAt(profileDir) for _, seat := range crewroute.Seats { pin, ok := saved[seat] @@ -147,12 +188,16 @@ func crewSavedPins(profileDir string, providers []CrewProvider) (map[crewroute.S warnings = append(warnings, crewroute.SeatWarning{Seat: seat, Kind: crewroute.PinNotAllowed, Model: pin.Model, Provider: pin.Provider}) continue } - if _, ok := crewPinReachable(profileDir, providers, route); !ok { - warnings = append(warnings, crewroute.SeatWarning{Seat: seat, Kind: crewroute.PinProviderUnavailable, Model: pin.Model, Provider: route.Provider}) + if why, ok := crewPinReachable(providers, route, health); !ok { + refusal := &crewroute.NoEligibleError{Seat: seat, Mode: crewroute.ModeAuto, + Action: health.actionFor(route, "unpin the "+string(seat)+" from "+crewroute.ShortModel(pin.Model))} + refusal.Reject(route.Model, route.Provider, why) + refused[seat] = refusal + continue } pins[seat] = route } - return pins, warnings + return pins, warnings, refused } // withoutWarningsFor drops the warnings about seats a task's own pins filled. @@ -174,9 +219,11 @@ func crewNoEligible(profileDir string, seat crewroute.Seat, cause error) *crewro var unreachable ErrCrewUnreachable if errors.As(cause, &unreachable) { refusal.Cause = unreachable - if !strings.HasPrefix(unreachable.Action, "no allowed model") { - refusal.Action = unreachable.Action - } + refusal.Action = unreachable.Action + } + if refusal.Action == "" || strings.HasPrefix(refusal.Action, "no allowed model") { + // The ordinary case says the role and the one door that fixes it. + refusal.Action = "no allowed model can do the " + string(seat) + " role · widen " + CrewCommand + " models or pin one with " + CrewCommand } rows, err := taskModelRows(nil, profileDir) if err != nil { diff --git a/internal/config/taskmodels_price_review_test.go b/internal/config/taskmodels_price_review_test.go new file mode 100644 index 0000000000..0073fd412f --- /dev/null +++ b/internal/config/taskmodels_price_review_test.go @@ -0,0 +1,71 @@ +package config + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/crewroute" + "github.com/Agent-Field/codeaf/internal/router" +) + +// A price published on one provider is not evidence that another provider +// publishes a price. A person can pin that route, but Auto cannot choose it. +func TestTaskModelsUnknownOwnPriceCannotEnterAuto(t *testing.T) { + dir := taskModelsWithAiand(t) + _, rows, err := TaskModelsAt(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + row, ok := taskRowFor(rows, "z-ai/glm-5.3-flash", "aiand") + if !ok || row.Price != nil { + t.Fatalf("expected direct route with no published price: %+v", row) + } + if row.AutoEligible || row.Reason != crewroute.RejectNoPrice { + t.Fatalf("unpriced direct route offered to Auto: %+v", row) + } + if err := SetCrewAllowed(dir, "+z-ai/glm-5.3-flash@aiand -@openrouter"); err != nil { + t.Fatal(err) + } + d, err := RouteCrew(dir, CrewAsk{Task: crewroute.Task{Text: openTask}}) + if err == nil { + for _, seat := range crewroute.Seats { + pick := d.Seat(seat) + if pick.Provider == "aiand" && !pick.Pinned { + t.Fatalf("Auto actually selected a route with no published own price: %+v", pick) + } + } + } +} + +func TestTaskModelsExpiredOneRouteRefusesAdmission(t *testing.T) { + dir := crewProfile(t) + applyTaskModels(t, dir, TaskModelsChange{Mode: modePtr(crewroute.ModeOne), One: &CrewPin{Model: "z-ai/glm-5.3-flash", Provider: "openrouter"}}) + previous := CrewRouteHistory + t.Cleanup(func() { CrewRouteHistory = previous }) + CrewRouteHistory = func(string) []router.CrewRouteOutcome { + return []router.CrewRouteOutcome{{At: time.Now().Add(-time.Minute), Send: "z-ai/glm-5.3-flash", Provider: "openrouter", Kind: "auth"}} + } + _, err := RouteCrew(dir, CrewAsk{Task: crewroute.Task{Text: openTask}}) + var refusal *crewroute.NoEligibleError + if !errors.As(err, &refusal) || refusal.Mode != crewroute.ModeOne { + t.Fatalf("known expired one-model route must refuse admission, got %v", err) + } +} + +func TestTaskModelsAllowedSavedPinNeverSubstitutesAnExpiredRoute(t *testing.T) { + dir := taskModelsWithAiand(t) + seedOwnPrices(t, dir, "aiand", map[string][3]float64{"zai-org/glm-5.3-flash": {0.00000015, 0.0000005, 0}}) + applyTaskModels(t, dir, TaskModelsChange{Pins: map[crewroute.Seat]*CrewPin{crewroute.Worker: {Model: "z-ai/glm-5.3-flash", Provider: "openrouter"}}}) + previous := CrewRouteHistory + t.Cleanup(func() { CrewRouteHistory = previous }) + CrewRouteHistory = func(string) []router.CrewRouteOutcome { + return []router.CrewRouteOutcome{{At: time.Now().Add(-time.Minute), Send: "z-ai/glm-5.3-flash", Provider: "openrouter", Kind: "auth"}} + } + _, err := RouteCrew(dir, CrewAsk{Task: crewroute.Task{Text: openTask}}) + var refusal *crewroute.NoEligibleError + if !errors.As(err, &refusal) || refusal.Seat != crewroute.Worker { + t.Fatalf("allowed explicit worker pin must ask/fix its expired route, got %v", err) + } +} diff --git a/internal/config/taskmodels_test.go b/internal/config/taskmodels_test.go index 45bc4b8a88..2bfd49708d 100644 --- a/internal/config/taskmodels_test.go +++ b/internal/config/taskmodels_test.go @@ -429,3 +429,115 @@ func TestTheCrewPanelSaysASetAsidePin(t *testing.T) { t.Errorf("gaps %+v", gaps) } } + +// A ONE MODEL KNOWN NOT TO ANSWER IS REFUSED WHEN IT IS CHOSEN, not accepted +// and then refused at every task. +func TestChoosingAKnownExpiredOneModelIsRefused(t *testing.T) { + dir := crewProfile(t) + previous := CrewRouteHistory + t.Cleanup(func() { CrewRouteHistory = previous }) + CrewRouteHistory = func(string) []router.CrewRouteOutcome { + return []router.CrewRouteOutcome{{At: time.Now().Add(-time.Minute), Send: "z-ai/glm-5.3-flash", Provider: "openrouter", Kind: "auth"}} + } + before := rawRows(t, dir) + err := ApplyTaskModels(context.Background(), dir, TaskModelsChange{Mode: modePtr(crewroute.ModeOne), One: &CrewPin{Model: "z-ai/glm-5.3-flash", Provider: "openrouter"}}) + var refusal TaskModelsRefusal + if !errors.As(err, &refusal) { + t.Fatalf("an expired one model: %v, want a refusal", err) + } + for key, raw := range rawRows(t, dir) { + if before[key] != raw { + t.Errorf("%s moved on a refused choice", key) + } + } +} + +// A DIRECT ROUTE WITH ITS OWN PUBLISHED PRICE IS AUTO'S TO WEIGH, AT THAT +// PRICE; the row shows that price and nobody else's. +func TestAKnownOwnPriceMakesADirectRouteEligibleAtThatPrice(t *testing.T) { + dir := taskModelsWithAiand(t) + seedOwnPrices(t, dir, "aiand", map[string][3]float64{"zai-org/glm-5.3-flash": {2e-7, 8e-7, 0}}) + _, rows, err := TaskModelsAt(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + row, _ := taskRowFor(rows, "z-ai/glm-5.3-flash", "aiand") + if row.Price == nil || row.Price.InPerM < 0.199 || row.Price.InPerM > 0.201 || !row.AutoEligible || row.Reason != "" { + t.Errorf("row %+v price %+v, want ai&'s own 0.20 and Auto-eligible", row, row.Price) + } + other, _ := taskRowFor(rows, "deepseek/deepseek-v4-flash", "aiand") + if other.AutoEligible || other.Reason != crewroute.RejectNoPrice { + t.Errorf("a model ai& published no price for: %+v", other) + } + for _, c := range CrewCandidatesAt(dir) { + for _, r := range c.Routes { + if r.Provider == "aiand" && (r.Price == nil || crewroute.Lineage(c.Model.ID) != "z-ai/glm-5.3-flash") { + t.Errorf("an ai& route without its own price is a candidate: %s %+v", c.Model.ID, r) + } + } + } +} + +// A READ THE PROFILE MOVES UNDER IS STARTED AGAIN, AND ANSWERS WHOLE: the +// answer's revision and its contents agree, and its observation began after +// the write it reflects. +func TestAReadDuringAWriteNeverAnswersHalfOld(t *testing.T) { + dir := crewProfile(t) + clock := time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC) + previousClock, previousHook := taskModelsClock, taskModelsMidRead + t.Cleanup(func() { taskModelsClock, taskModelsMidRead = previousClock, previousHook }) + taskModelsClock = func() time.Time { clock = clock.Add(time.Nanosecond); return clock } + wrote := false + taskModelsMidRead = func() { + if wrote { + return + } + wrote = true + if err := SetCrewPin(dir, crewroute.Checker, "deepseek/deepseek-v4-flash"); err != nil { + t.Fatal(err) + } + } + state, _, err := TaskModelsAt(context.Background(), dir) + if err != nil { + t.Fatal(err) + } + if state.Revision != TaskModelsRevisionAt(dir) { + t.Error("the answer's revision is not the profile's") + } + if pin := state.Roles[crewroute.Checker].Pin; pin == nil || pin.Model != "deepseek/deepseek-v4-flash" { + t.Errorf("the answer carries the old checker: %+v", pin) + } + if !state.ObservedAt.Equal(time.Date(2026, 10, 15, 12, 0, 0, 2, time.UTC)) { + t.Errorf("observed at %v, want the start of the try that saw the write", state.ObservedAt) + } + // A profile that never holds still is refused, never answered from a try. + n := 0 + taskModelsMidRead = func() { + n++ + _ = SetCrewAllowed(dir, []string{"all", "open"}[n%2]) + } + if _, _, err := TaskModelsAt(context.Background(), dir); !errors.Is(err, ErrTaskModelsMoving) { + t.Errorf("a read the profile never stops moving: %v", err) + } + cancelled, cancel := context.WithCancel(context.Background()) + cancel() + taskModelsMidRead = nil + if _, _, err := TaskModelsAt(cancelled, dir); !errors.Is(err, ErrTaskModelsRead) { + t.Errorf("a cancelled read: %v", err) + } +} + +// A BUDGET IS NOT A TASK-MODELS CHANGE: the caps are Spending's rows. +func TestACapChangeDoesNotMoveTheRevision(t *testing.T) { + dir := crewProfile(t) + before := TaskModelsRevisionAt(dir) + if err := SetCrewTaskCap(dir, "3"); err != nil { + t.Fatal(err) + } + if err := SetCrewCap(dir, "20"); err != nil { + t.Fatal(err) + } + if TaskModelsRevisionAt(dir) != before { + t.Error("a cap moved the task models' revision") + } +} diff --git a/internal/crewroute/route.go b/internal/crewroute/route.go index 7180123f43..80ee735bae 100644 --- a/internal/crewroute/route.go +++ b/internal/crewroute/route.go @@ -127,6 +127,20 @@ type Route struct { // task's first call, zero when nothing is known — a free route then reads // [freeFailPrior]. FailRate float64 + // Price is what THIS route's own provider publishes for the model, when it + // is not the model's catalog price — a direct connection's own listing. A + // metered route weighs its own price when it has one; nil weighs the + // model's ([Model]'s prices are the catalog's provider's). + Price *RoutePrice `json:",omitempty"` +} + +// RoutePrice is one route's own published price, dollars per token. Zero in +// and zero out is a price of nothing on a paid route, which promises nothing +// about the next call: it is weighed as a model nobody prices is. +type RoutePrice struct { + Prompt float64 + Completion float64 + CacheRead float64 } // Candidate is a model the person allows, with every route a connected @@ -255,7 +269,10 @@ type Pick struct { Send string Kind RouteKind Pinned bool - Quality float64 + // Price is the accepted route's own published tariff at admission. Nil is + // unknown, including checkpoints written before tariff facts were stored. + Price *RoutePrice `json:",omitempty"` + Quality float64 // SD is the standard deviation of Quality under the weights. SD float64 `json:",omitempty"` // Learned is the part of Quality this install's own outcomes moved. @@ -975,7 +992,7 @@ func pickAt(t *table, class Class, seat Seat, c Candidate, a ability) Pick { pick := Pick{Seat: seat, Model: c.Model.ID, Quality: q, SD: sd, Learned: t.learnedOf(class, seat, a), CostUSD: math.Inf(1)} for _, route := range c.Routes { - cost := routeCost(route, metered, fallback) + cost := routeCost(route, t.meteredOn(class, seat, c.Model, route, a, metered), fallback) if unpriced && route.Kind == Free { // A free pool of a model nobody prices is weighed above the // cheapest priced model: its zero is not a price. @@ -983,11 +1000,34 @@ func pickAt(t *table, class Class, seat Seat, c Candidate, a ability) Pick { } if cost < pick.CostUSD-1e-12 { pick.Provider, pick.Send, pick.Kind, pick.CostUSD = route.Provider, route.Send, route.Kind, cost + pick.Price = copyRoutePrice(route.Price) } } return pick } +func copyRoutePrice(price *RoutePrice) *RoutePrice { + if price == nil { + return nil + } + copy := *price + return © +} + +// meteredOn is what a seat bills on one route: the route's own price when its +// provider published one, else the model's (metered). A paid route whose own +// price is zero both ways is weighed at what a priced model as able costs. +func (t *table) meteredOn(class Class, seat Seat, m Model, route Route, a ability, metered float64) float64 { + if route.Kind != Metered || route.Price == nil { + return metered + } + if route.Price.Prompt <= 0 && route.Price.Completion <= 0 { + return t.priceFor(seat, a.U) * t.costScale(class, seat) + } + m.PromptPrice, m.CompletionPrice, m.CacheReadPrice = route.Price.Prompt, route.Price.Completion, route.Price.CacheRead + return t.classCost(class, seat, m) +} + // routeCost is what one more task is EXPECTED to cost on a route: what it // bills — the metered price, nothing on a plan, a local model or a free pool — // plus what its refusals cost: the chance it refuses a first call times the @@ -1029,7 +1069,8 @@ func pinned(t *table, class Class, seat Seat, pin Pin, candidates []Candidate) P for _, route := range c.Routes { if strings.EqualFold(route.Provider, pin.Provider) { found.Provider, found.Send, found.Kind = route.Provider, route.Send, route.Kind - metered := t.classCost(class, seat, c.Model) + found.Price = copyRoutePrice(route.Price) + metered := t.meteredOn(class, seat, c.Model, route, t.abilityOf(c.Model), t.classCost(class, seat, c.Model)) found.CostUSD = routeCost(route, metered, math.Max(metered, t.costFloor(seat)*t.costScale(class, seat))) } } diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index c9defc015a..38f3e3c163 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -734,10 +734,16 @@ open model but DeepSeek's, and `≤1/5 +moonshotai/kimi-k3` is the price rule wi exception let in. `/crew models +kimi-k3` or `/crew models -deepseek` changes the rule in force by one word. A `-x` naming a **provider** — `-openrouter` — takes that provider's routes away rather than any model; to switch a provider off for the crew, use the panel's -**providers** row, which keeps the choice when the rule changes. - -A rule that would leave a pinned seat outside it is refused until you unpin the seat, and -a rule that does not parse is refused with the reason — a typo that silently allowed +**providers** row, which keeps the choice when the rule changes. A word with `@provider` — +`-glm-5.3-flash@openrouter` — speaks about **one model on one provider**: GLM 5.3 Flash stays +allowed through every other provider that serves it. That is how a tick in the app's +per-provider list of allowed models is kept, so `/crew` reads exactly what the app wrote. + +A rule that leaves a pinned seat outside it is **written, and the pin is kept**: until you +allow the model again or unpin the seat, tasks pick that seat as if it were not pinned, and +the panel's `gap` line says `checker pinned to kimi-k3, which the models you allow leave out +· routed until you allow it or unpin it`. Pinning a seat to a model outside the rule is still +refused. A rule that does not parse is refused with the reason — a typo that silently allowed everything would be a setting somebody thinks is protecting them. When the allowed models leave a kind of work without a strong enough checker, the panel says so on a `gap` line. @@ -850,8 +856,9 @@ crew. **A seat whose model cannot start moves, inside the task.** When a seat's first call is refused, the seat goes down its ladder: the same model on its next route, then the next model for the seat at a similar cost, then the model the last good crew here used, then the -model you are talking to — never a rung on an account that has just said it is out of -credit or refused its key. The line says so first — `running on fallback crew · worker +model you are talking to — each of those last two only when your allowed models admit it on +that route, and never a rung on an account that has just said it is out of credit or refused +its key. The line says so first — `running on fallback crew · worker glm-5.3-flash → deepseek-v4-flash (credit unavailable on openrouter)` — and with nothing left the task stops on the one thing to do, which leads its line in place of `/redo stronger` (a stronger crew would meet the same wall): `failed — add credit on openrouter to @@ -876,6 +883,23 @@ not on `/redo stronger`. A free pool can be pinned by name — `/crew pin worker vendor/model:free` — and picking a model's `free` route in a seat's list pins that pool, not the paid route beside it. +### When no allowed model can take a seat, or a pinned seat cannot be reached + +**The task does not start, and nothing is swapped in.** When no model you allow can do a +seat — every one is left out, unpriced, on a provider that is off, signed out or out of +credit, or resting — the task is refused naming the seat and the one fix, such as `no allowed +model can do the checker role · widen /crew models or pin one with /crew`. It is never started +on the model you are talking to, nor on a model you did not allow. The same is true of a seat +**pinned** to a model you allow whose provider is not connected, is turned off, refused its +key or is out of credit: the task asks you to reconnect, add credit or unpin the seat, and +never moves the seat to another model or provider. (A pin you no longer allow is the one +exception: that seat is picked as if unpinned, and says so.) + +**A direct provider's route is weighed at that provider's own price.** A model a direct +connection serves is a route the crew may pick only when that provider's own model list +published a price for it; a price OpenRouter publishes says nothing about another provider's +bill. Without one the route is not picked by the crew — a pin still runs it. + ### Route health **What each route did is kept, and the next pick reads it.** Every seat's first call is @@ -4258,3 +4282,28 @@ the earlier call and its result are carried as readable conversation text. The model can discuss that result, but it cannot make a new tool call itself. Switching back to a tool-capable model restores the original tool-call history on its request. + +## Task models in the app — Auto or one model for every task, pinned roles, allowed models by provider + +The app's **Settings → Models → Task models** page is the same crew `/crew` shows, in other +words: the **worker**, **planner** and **checker** are the three seats, a role set to a model +is a pin, and the allowed models are the same rule. + +- **Auto** (the default) picks each role per task from the models you allow, inside the + per-task and daily caps. A pinned role always runs its pin. +- **One model for every task** runs the worker, planner and checker all on the one model + you choose, on the provider you choose, and picks nothing. Your pins and allowed models + are kept, unchanged, for when you switch back. A one model that cannot be reached when a + task starts stops the task with the fix; it is never replaced by another model. This is a + saved setting read when each task starts — not the `--one-model` launch flag, which makes + every call ride the model you are talking to for that one session. +- **Changes reach tasks that start afterwards.** A running task keeps the models it started + with. +- **Allowed models are listed by provider**, each with that provider's own price per million + tokens in and out, or no price when it published none — such a model can still be ticked + and pinned, and Auto does not pick it. A provider without a key is listed with no models; + one whose key was refused or whose credit ran out keeps its models and their ticks. +- **The caps** on that page are the same per-task and per-day caps as Settings → Spending. +- Unticking a pinned model keeps the pin; the role says it is pinned to a model that is not + allowed, and is picked by Auto until you allow it again or unpin it. + From fbeeaadec6ab02b73ed32099847a5750affc6e7e Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 20:26:08 -0400 Subject: [PATCH 061/459] taskmodels: the reviewers' per-task Auto opt-out and frozen route tariff, with their tests CrewAsk.AutoSeats routes only the named roles for one admission, leaving saved pins and the one model untouched on the rest; Pick.Price freezes the accepted route's own tariff into the decision. The source hunks rode in 73a9ef298; this adds their review tests and the refusal's action on the one-model opt-out path. Co-Authored-By: Claude Opus 5.5 --- internal/config/crew.go | 5 ++- .../config/taskmodels_override_review_test.go | 45 +++++++++++++++++++ internal/crewroute/tariff_history_test.go | 43 ++++++++++++++++++ 3 files changed, 92 insertions(+), 1 deletion(-) create mode 100644 internal/config/taskmodels_override_review_test.go create mode 100644 internal/crewroute/tariff_history_test.go diff --git a/internal/config/crew.go b/internal/config/crew.go index 2a83267326..8a43cc64a9 100644 --- a/internal/config/crew.go +++ b/internal/config/crew.go @@ -1441,7 +1441,10 @@ func RouteCrew(profileDir string, ask CrewAsk) (crewroute.Decision, error) { if !haveOne { why = crewroute.RejectProviderUnavailable } - refusal := &crewroute.NoEligibleError{Seat: seat, Mode: crewroute.ModeOne} + refusal := &crewroute.NoEligibleError{Seat: seat, Mode: crewroute.ModeOne, Action: health.actionFor(route, "choose another one model")} + if !haveOne { + refusal.Action = "choose the one model for every task, or switch Task models back to Auto" + } refusal.Reject(route.Model, route.Provider, why) refused[seat] = refusal } diff --git a/internal/config/taskmodels_override_review_test.go b/internal/config/taskmodels_override_review_test.go new file mode 100644 index 0000000000..740065bd40 --- /dev/null +++ b/internal/config/taskmodels_override_review_test.go @@ -0,0 +1,45 @@ +package config + +import ( + "testing" + + "github.com/Agent-Field/codeaf/internal/crewroute" +) + +func TestExplicitAutoSeatsOnlyBypassesNamedSavedPolicies(t *testing.T) { + for _, one := range []bool{false, true} { + t.Run(map[bool]string{false: "role-pins", true: "one-model"}[one], func(t *testing.T) { + dir := crewProfile(t) + pin := &CrewPin{Model: "deepseek/deepseek-v4-flash", Provider: "openrouter"} + change := TaskModelsChange{Pins: map[crewroute.Seat]*CrewPin{crewroute.Worker: pin, crewroute.Planner: pin, crewroute.Checker: pin}} + if one { + change.Mode, change.One = modePtr(crewroute.ModeOne), pin + } + applyTaskModels(t, dir, change) + before := rawRows(t, dir) + decision, err := RouteCrew(dir, CrewAsk{Task: crewroute.Task{Text: openTask}, Effort: crewroute.EffortBest, AutoSeats: map[crewroute.Seat]bool{crewroute.Worker: true}}) + if err != nil { + t.Fatal(err) + } + if decision.Seat(crewroute.Worker).Pinned || decision.Mode != crewroute.ModeAuto { + t.Fatalf("explicit worker opt-out did not route: %+v", decision.Seat(crewroute.Worker)) + } + for _, seat := range []crewroute.Seat{crewroute.Planner, crewroute.Checker} { + pick := decision.Seat(seat) + if !pick.Pinned || pick.Model != pin.Model || pick.Provider != pin.Provider { + t.Fatalf("unaffected %s moved: %+v", seat, pick) + } + } + for key, value := range rawRows(t, dir) { + if before[key] != value { + t.Fatalf("per-task override persisted %s", key) + } + } + explicit := CrewPin{Model: "z-ai/glm-5.3-flash", Provider: "openrouter"} + decision, err = RouteCrew(dir, CrewAsk{Task: crewroute.Task{Text: openTask}, AutoSeats: map[crewroute.Seat]bool{crewroute.Worker: true}, Pins: map[crewroute.Seat]CrewPin{crewroute.Worker: explicit}}) + if err != nil || !decision.Seat(crewroute.Worker).Pinned || decision.Seat(crewroute.Worker).Model != explicit.Model { + t.Fatalf("explicit per-task pin must win: %+v, %v", decision.Seat(crewroute.Worker), err) + } + }) + } +} diff --git a/internal/crewroute/tariff_history_test.go b/internal/crewroute/tariff_history_test.go new file mode 100644 index 0000000000..a594f741a5 --- /dev/null +++ b/internal/crewroute/tariff_history_test.go @@ -0,0 +1,43 @@ +package crewroute + +import ( + "encoding/json" + "testing" +) + +func TestAcceptedTariffIsFrozenForAutomaticAndPinnedDecisions(t *testing.T) { + for _, pinned := range []bool{false, true} { + t.Run(map[bool]string{false: "automatic", true: "pinned"}[pinned], func(t *testing.T) { + price := &RoutePrice{Prompt: 0.000001, Completion: 0.000003} + candidate := candidateOf(glmFlash) + candidate.Routes[0].Price = price + request := Request{Class: Bugfix, Candidates: []Candidate{candidate}} + if pinned { + request.Pins = map[Seat]Pin{} + for _, seat := range Seats { + route := candidate.Routes[0] + request.Pins[seat] = Pin{Model: candidate.Model.ID, Provider: route.Provider, Send: route.Send, Kind: route.Kind} + } + } + decision, err := Decide(request) + if err != nil { + t.Fatal(err) + } + price.Prompt = 99 // later catalog mutation must not edit admitted facts + data, err := json.Marshal(decision) + if err != nil { + t.Fatal(err) + } + var restored Decision + if err := json.Unmarshal(data, &restored); err != nil { + t.Fatal(err) + } + for _, seat := range Seats { + pick := restored.Seat(seat) + if pick.Price == nil || pick.Price.Prompt != 0.000001 || pick.Price.Completion != 0.000003 { + t.Fatalf("%s lost its admitted tariff: %+v", seat, pick.Price) + } + } + }) + } +} From 6465d83d0385480ca233b6c643fa1300b6d23b26 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 20:26:19 -0400 Subject: [PATCH 062/459] app: settings.models.get, .list and .set over the task-models policy, and task_models on settings.changed A thin door: config reads and writes the policy; the adapter adds a key's last four characters and only the fixes this engine can start. read_at is when the read began; a list is paged by a cursor bound to its filters and revision; a set is one write answered with the whole page. settings.changed carries task_models only when a task-models row changed, so a budget write never waits on it. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/appstate.go | 5 +- cmd/codeaf/apptaskmodels.go | 287 +++++++++++++++++++ cmd/codeaf/apptaskmodels_test.go | 179 ++++++++++++ docs/changes/unreleased/0-app-task-models.md | 15 + internal/appwire/taskmodels_test.go | 81 ++++++ 5 files changed, 566 insertions(+), 1 deletion(-) create mode 100644 cmd/codeaf/apptaskmodels.go create mode 100644 cmd/codeaf/apptaskmodels_test.go create mode 100644 docs/changes/unreleased/0-app-task-models.md create mode 100644 internal/appwire/taskmodels_test.go diff --git a/cmd/codeaf/appstate.go b/cmd/codeaf/appstate.go index 86d4079921..df07221903 100644 --- a/cmd/codeaf/appstate.go +++ b/cmd/codeaf/appstate.go @@ -420,7 +420,10 @@ func (b *appBackend) SetSettings(patch appwire.SettingsPatch) (appwire.Settings, if err != nil { return appwire.Settings{}, err } - b.emit.Emit(appwire.EventSettingsChanged, appwire.SettingsChanged{Settings: settings}) + // THE TASK MODELS RIDE THIS EVENT ONLY WHEN THIS PATCH CHANGED ONE OF + // THEIR ROWS — the checker and planner pins are the Checks and Planner + // rows — so a budget, an appearance or a folder is never held up by them. + b.settingsChanged(settings, patch.ChecksModel != nil || patch.PlannerModel != nil) if patch.DayCapUSD != nil { b.spendSay() } diff --git a/cmd/codeaf/apptaskmodels.go b/cmd/codeaf/apptaskmodels.go new file mode 100644 index 0000000000..c680b06a34 --- /dev/null +++ b/cmd/codeaf/apptaskmodels.go @@ -0,0 +1,287 @@ +package main + +// ── codeaf app: TASK MODELS (V79) ─────────────────────────────────────────── +// +// settings.models.get, .list and .set, and settings.changed's task_models. A +// THIN DOOR: the policy — the mode, the pins, the per-provider ticks, every +// provider's own price — is internal/config's, read and written there; this +// file turns it into the wire's shapes and back, and adds only what the app +// process alone knows: a key's last four characters and which fixes this +// engine can start. + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "strconv" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/crewroute" +) + +// taskModelsReadWindow bounds one read of the task models, the competence +// map's bound: a person is waiting on the page. +const taskModelsReadWindow = 10 * time.Second + +// taskModelsRead reads the whole configuration inside the read window. +func taskModelsRead(profile string) (config.TaskModelsState, []config.TaskModelRow, error) { + ctx, cancel := context.WithTimeout(context.Background(), taskModelsReadWindow) + defer cancel() + state, rows, err := config.TaskModelsAt(ctx, profile) + if err != nil { + return state, nil, taskModelsError(err) + } + return state, rows, nil +} + +// taskModelsError is a config error in the wire's words. +func taskModelsError(err error) error { + var refusal config.TaskModelsRefusal + switch { + case errors.As(err, &refusal): + return appwire.Errorf(appwire.CodeBadParams, "%s", refusal.Reason) + case errors.Is(err, config.ErrTaskModelsStale): + return appwire.Errorf(appwire.CodeConflict, "%s", err.Error()) + case errors.Is(err, config.ErrTaskModelsRead): + return appwire.Errorf(appwire.CodeEngine, "task models read timed out") + case errors.Is(err, config.ErrTaskModelsMoving): + return appwire.Errorf(appwire.CodeBusy, "%s", err.Error()) + } + return appEngineError(err) +} + +// TaskModels is settings.models.get. +func (b *appBackend) TaskModels() (appwire.TaskModels, error) { + profile := config.ProfileDir() + state, _, err := taskModelsRead(profile) + if err != nil { + return appwire.TaskModels{}, err + } + return taskModelsWire(profile, state), nil +} + +// taskModelsWire is the configuration as the wire carries it. +func taskModelsWire(profile string, state config.TaskModelsState) appwire.TaskModels { + out := appwire.TaskModels{ + // READ_AT IS WHEN THE READ BEGAN ([config.TaskModelsState.ObservedAt]), + // never when it finished: a slow read that started first must not + // outrank a read or a write that started after it. + ReadAt: state.ObservedAt.UTC(), Revision: state.Revision, Mode: appwire.TaskModelsAuto, + Providers: make([]appwire.TaskModelsProvider, 0, len(state.Providers)), + } + if state.Mode == crewroute.ModeOne { + out.Mode = appwire.TaskModelsOne + } + if state.One != nil { + out.OneModel = &appwire.ModelRef{ID: state.One.Model, Provider: state.One.Provider} + out.OneModelWarning = wireWord(state.OneWarning) + } + role := func(seat crewroute.Seat) appwire.TaskModelRole { + r := state.Roles[seat] + view := appwire.TaskModelRole{Warning: wireWord(r.Warning), EligibleCount: r.Eligible} + if r.Pin != nil { + view.Pin = &appwire.ModelRef{ID: r.Pin.Model, Provider: r.Pin.Provider} + } + return view + } + out.Roles = appwire.TaskModelRoles{Worker: role(crewroute.Worker), Planner: role(crewroute.Planner), Checker: role(crewroute.Checker)} + sources := config.ResolveSources(profile, config.APIKeyAt(profile), config.DefaultBaseURL) + for _, p := range state.Providers { + view := appwire.TaskModelsProvider{ + ID: p.ID, Name: p.Name, State: p.State, Reason: p.Reason, Fix: appwire.FixNone, + ModelCount: p.Models, AllowedCount: p.Allowed, + } + if service, ok := sources.ByID(p.ID); ok && p.Connected { + view.KeyHint = appKeyHint(service.Key) + } + view.Fix = taskModelsFix(p) + out.Providers = append(out.Providers, view) + } + return out +} + +// taskModelsFix is the one fix THIS ENGINE CAN START for a provider: a browser +// sign-in only where providers.sign_in serves one, a key only where a pasted +// key connects it, and nothing otherwise — a plan whose sign-in ran out is +// signed in again where it was made, which is not here. +func taskModelsFix(p config.TaskModelProvider) string { + connectable, signIn := false, false + if provider, ok := appProviderNamed(p.ID); ok { + connectable, signIn = true, provider.signIn + } + switch { + case p.State == config.ProviderStateNoKey && connectable: + return appwire.FixAddKey + case p.State == config.ProviderStateExpired && p.Reason == config.ProviderReasonKeyRefused && signIn: + return appwire.FixSignIn + case p.State == config.ProviderStateExpired && p.Reason == config.ProviderReasonKeyRefused && connectable: + return appwire.FixAddKey + } + return appwire.FixNone +} + +// wireWord is a warning as the wire carries it: nil for none. +func wireWord(word string) *string { + if word == "" { + return nil + } + return &word +} + +// TaskModelsList is settings.models.list: one page of rows, filtered. +func (b *appBackend) TaskModelsList(p appwire.TaskModelsListParams) (appwire.TaskModelsPage, error) { + if p.Limit <= 0 || p.Limit > appwire.TaskModelsPageMax { + // The wire bounds a request before it gets here; a caller in this + // process gets the same bounds. + p.Limit = appwire.TaskModelsPageDefault + } + profile := config.ProfileDir() + state, rows, err := taskModelsRead(profile) + if err != nil { + return appwire.TaskModelsPage{}, err + } + q := strings.ToLower(p.Q) + var matched []config.TaskModelRow + for _, row := range rows { + switch { + case p.Provider != "" && row.Provider != p.Provider: + continue + case p.AllowedOnly && !row.Allowed: + continue + case p.Role != "" && !row.Seats[crewroute.Seat(p.Role)]: + continue + case q != "" && !strings.Contains(strings.ToLower(row.Model.ID), q) && !strings.Contains(strings.ToLower(row.Name), q): + continue + } + matched = append(matched, row) + } + fingerprint := taskModelsFingerprint(p, state.Revision, len(rows)) + start := 0 + if p.Cursor != "" { + if start, err = taskModelsCursorAt(p.Cursor, fingerprint); err != nil || start > len(matched) { + return appwire.TaskModelsPage{}, appwire.Errorf(appwire.CodeBadParams, "cursor is stale or not from this list · list again from the start") + } + } + end := min(start+p.Limit, len(matched)) + page := appwire.TaskModelsPage{ + ReadAt: state.ObservedAt.UTC(), Revision: state.Revision, Total: len(matched), + Models: make([]appwire.TaskModelRow, 0, end-start), + } + for _, row := range matched[start:end] { + page.Models = append(page.Models, taskModelRowWire(row)) + } + if end < len(matched) { + page.NextCursor = taskModelsCursor(end, fingerprint) + } + return page, nil +} + +// taskModelRowWire is one row as the wire carries it. +func taskModelRowWire(row config.TaskModelRow) appwire.TaskModelRow { + view := appwire.TaskModelRow{ + ID: row.Model.ID, Provider: row.Provider, Name: row.Name, Billing: string(row.Kind), + Allowed: row.Allowed, Available: row.Available, AutoEligible: row.AutoEligible, Reason: string(row.Reason), + } + if view.Name == "" { + view.Name = crewroute.ShortModel(row.Model.ID) + } + if row.Price != nil { + view.Price = &appwire.TaskModelPrice{InPerM: row.Price.InPerM, OutPerM: row.Price.OutPerM} + } + return view +} + +// taskModelsFingerprint names the list a cursor belongs to: its filters, the +// revision and how many rows there were. +func taskModelsFingerprint(p appwire.TaskModelsListParams, revision string, rows int) string { + sum := sha256.Sum256([]byte(strings.Join([]string{p.Provider, p.Q, p.Role, strconv.FormatBool(p.AllowedOnly), revision, strconv.Itoa(rows)}, "\x00"))) + return hex.EncodeToString(sum[:8]) +} + +type taskModelsCursorWire struct { + At int `json:"a"` + F string `json:"f"` +} + +func taskModelsCursor(at int, fingerprint string) string { + raw, _ := json.Marshal(taskModelsCursorWire{At: at, F: fingerprint}) + return base64.RawURLEncoding.EncodeToString(raw) +} + +func taskModelsCursorAt(cursor, fingerprint string) (int, error) { + raw, err := base64.RawURLEncoding.DecodeString(cursor) + if err != nil { + return 0, err + } + var c taskModelsCursorWire + if err := json.Unmarshal(raw, &c); err != nil || c.F != fingerprint || c.At <= 0 { + return 0, errors.New("stale cursor") + } + return c.At, nil +} + +// SetTaskModels is settings.models.set: the patch as one checked change, one +// write, and settings.changed with what everything reads now. +func (b *appBackend) SetTaskModels(p appwire.TaskModelsPatch) (appwire.TaskModels, error) { + profile := config.ProfileDir() + change := config.TaskModelsChange{Revision: p.Revision} + if p.Mode != nil { + mode := crewroute.ModeAuto + if *p.Mode == appwire.TaskModelsOne { + mode = crewroute.ModeOne + } + change.Mode = &mode + } + if p.OneModel != nil { + change.One = &config.CrewPin{Model: p.OneModel.ID, Provider: p.OneModel.Provider} + } + if p.Pins != nil { + change.Pins = map[crewroute.Seat]*config.CrewPin{} + for role, ref := range p.Pins { + if ref == nil { + change.Pins[crewroute.Seat(role)] = nil + continue + } + change.Pins[crewroute.Seat(role)] = &config.CrewPin{Model: ref.ID, Provider: ref.Provider} + } + } + for _, row := range p.Allowed { + change.Ticks = append(change.Ticks, config.TaskModelTick{Model: row.Model.ID, Provider: row.Model.Provider, Allowed: row.Allowed}) + } + ctx, cancel := context.WithTimeout(context.Background(), taskModelsReadWindow) + defer cancel() + if err := config.ApplyTaskModels(ctx, profile, change); err != nil { + return appwire.TaskModels{}, taskModelsError(err) + } + got, err := b.TaskModels() + if err != nil { + return appwire.TaskModels{}, err + } + if settings, err := b.Settings(); err == nil { + // The checker and planner pins are settings' Checks and Planner rows, + // so the settings ride the same event. + b.emit.Emit(appwire.EventSettingsChanged, appwire.SettingsChanged{Settings: settings, TaskModels: &got}) + } + return got, nil +} + +// settingsChanged says settings.changed with the settings and, when the +// change touched a task-models row, the task models as they read now. A +// task-models read that fails leaves the field out — the app keeps what it +// last read and asks again on its own — rather than sending a configuration +// that is not the profile's; the settings themselves are said either way. +func (b *appBackend) settingsChanged(settings appwire.Settings, taskModels bool) { + changed := appwire.SettingsChanged{Settings: settings} + if taskModels { + if got, err := b.TaskModels(); err == nil { + changed.TaskModels = &got + } + } + b.emit.Emit(appwire.EventSettingsChanged, changed) +} diff --git a/cmd/codeaf/apptaskmodels_test.go b/cmd/codeaf/apptaskmodels_test.go new file mode 100644 index 0000000000..cf550ef78f --- /dev/null +++ b/cmd/codeaf/apptaskmodels_test.go @@ -0,0 +1,179 @@ +package main + +import ( + "testing" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/catalog" + "github.com/Agent-Field/codeaf/internal/config" +) + +// taskModelsBackend is settingsBackend over a profile with one OpenRouter key +// and a small catalog. UNIT DATA: the rows and the key are this test's own, +// and nothing reaches a network or a model. +func taskModelsBackend(t *testing.T) (*appBackend, *appRecorder) { + t.Helper() + for _, name := range []string{config.APIKeyEnv, "OPENAI_API_KEY", "DEEPSEEK_API_KEY", "ZHIPU_API_KEY", "MOONSHOT_API_KEY", + "MINIMAX_API_KEY", "DASHSCOPE_API_KEY", "AIAND_API_KEY"} { + t.Setenv(name, "") + } + backend := settingsBackend(t) + rec := newAppRecorder() + backend.emit = rec + if err := config.WriteAPIKey(config.ProfileDir(), "sk-or-v1-taskmodels-wire-6789"); err != nil { + t.Fatal(err) + } + previous := config.CrewCatalog + t.Cleanup(func() { config.CrewCatalog = previous }) + rows := []catalog.Model{ + {ID: "z-ai/glm-5.3-flash", OpenWeights: true, PromptPrice: 1.5e-7, CompletionPrice: 5e-7, IntelligenceIndex: 41.8, CodingIndex: 71.5, AgenticIndex: 50.9, ContextLength: 1310720, Parameters: []string{"tools"}}, + {ID: "moonshotai/kimi-k3", OpenWeights: true, PromptPrice: 3e-6, CompletionPrice: 1.5e-5, IntelligenceIndex: 43.6, CodingIndex: 76.2, AgenticIndex: 50, ContextLength: 1048576, Parameters: []string{"tools"}}, + {ID: "deepseek/deepseek-v4-flash", OpenWeights: true, PromptPrice: 8e-8, CompletionPrice: 1.6e-7, IntelligenceIndex: 24.2, CodingIndex: 56.2, AgenticIndex: 22.2, ContextLength: 1048576, Parameters: []string{"tools"}}, + {ID: "anthropic/claude-opus-5", PromptPrice: 5e-6, CompletionPrice: 2.5e-5, IntelligenceIndex: 50.8, CodingIndex: 78, AgenticIndex: 56.5, ContextLength: 1000000, Parameters: []string{"tools"}}, + {ID: "z-ai/glm-next", PriceUnknown: true, OpenWeights: true, IntelligenceIndex: 60, CodingIndex: 90, AgenticIndex: 70, ContextLength: 1048576, Parameters: []string{"tools"}}, + {ID: "vendor/image-only", PromptPrice: 1e-9, CompletionPrice: 1e-9, Parameters: []string{"temperature"}}, + } + config.CrewCatalog = func() []catalog.Model { return rows } + return backend, rec +} + +// GET IS SMALL AND HONEST: provider headers with counts and a masked key, no +// model list; a provider with no key says the one fix this engine has. +func TestAppTaskModelsGetIsHeadersOnly(t *testing.T) { + backend, _ := taskModelsBackend(t) + got, err := backend.TaskModels() + if err != nil { + t.Fatal(err) + } + if got.Mode != appwire.TaskModelsAuto || got.OneModel != nil || got.ReadAt.IsZero() || got.Revision == "" { + t.Fatalf("got %+v", got) + } + or := got.Providers[0] + if or.ID != "openrouter" || or.State != appwire.ProviderConnected || or.KeyHint != "…6789" || or.ModelCount != 5 || or.AllowedCount != 5 { + t.Errorf("openrouter header %+v", or) + } + for _, p := range got.Providers[1:] { + if p.ID == "moonshot" && (p.State != appwire.ProviderNoKey || p.Fix != appwire.FixAddKey || p.ModelCount != 0 || p.KeyHint != "") { + t.Errorf("moonshot %+v", p) + } + } + if got.Roles.Checker.Pin != nil || got.Roles.Checker.Warning != nil || got.Roles.Checker.EligibleCount == 0 { + t.Errorf("checker %+v", got.Roles.Checker) + } +} + +// EVERY ROW IS REACHABLE BY PAGES, A CURSOR IS ONLY GOOD FOR ITS OWN LIST, and +// a missing price is null, never zero. +func TestAppTaskModelsListPagesEveryRowAndRefusesAStaleCursor(t *testing.T) { + backend, _ := taskModelsBackend(t) + seen := map[string]bool{} + p := appwire.TaskModelsListParams{Limit: 2} + total := -1 + for pages := 0; ; pages++ { + page, err := backend.TaskModelsList(p) + if err != nil { + t.Fatal(err) + } + total = page.Total + for _, row := range page.Models { + if seen[row.ID+"@"+row.Provider] { + t.Errorf("%s listed twice", row.ID) + } + seen[row.ID+"@"+row.Provider] = true + if row.ID == "z-ai/glm-next" && (row.Price != nil || row.AutoEligible || row.Reason != appwire.ModelNoPrice || !row.Allowed) { + t.Errorf("unpriced row %+v", row) + } + if row.ID == "z-ai/glm-5.3-flash" && (row.Price == nil || row.Price.InPerM < 0.149 || row.Price.InPerM > 0.151) { + t.Errorf("openrouter's own price %+v", row.Price) + } + } + if page.NextCursor == "" { + break + } + if pages > 10 { + t.Fatal("the pages never end") + } + p.Cursor = page.NextCursor + } + if total != 5 || len(seen) != total { + t.Fatalf("saw %d of %d rows", len(seen), total) + } + first, _ := backend.TaskModelsList(appwire.TaskModelsListParams{Limit: 2}) + if _, err := backend.TaskModelsList(appwire.TaskModelsListParams{Limit: 2, Q: "glm", Cursor: first.NextCursor}); wireCode(err) != appwire.CodeBadParams { + t.Errorf("a cursor from another filter: %v", err) + } + if _, err := backend.SetTaskModels(appwire.TaskModelsPatch{Allowed: []appwire.TaskModelAllowed{{Model: appwire.ModelRef{ID: "moonshotai/kimi-k3", Provider: "openrouter"}}}}); err != nil { + t.Fatal(err) + } + if _, err := backend.TaskModelsList(appwire.TaskModelsListParams{Limit: 2, Cursor: first.NextCursor}); wireCode(err) != appwire.CodeBadParams { + t.Errorf("a cursor from before a change: %v", err) + } + found, err := backend.TaskModelsList(appwire.TaskModelsListParams{Q: "KIMI"}) + if err != nil || found.Total != 1 || found.Models[0].Allowed { + t.Errorf("search: %+v %v", found, err) + } +} + +// A SET IS ONE CHANGE, ANSWERED WITH THE WHOLE PAGE AND SAID ONCE; the Checks +// row of settings is the checker pin, route and all; a stale revision is a conflict and a pin +// to nothing listed is bad params. +func TestAppTaskModelsSetWritesWholeAndSaysIt(t *testing.T) { + backend, rec := taskModelsBackend(t) + before, _ := backend.TaskModels() + checker := &appwire.ModelRef{ID: "deepseek/deepseek-v4-flash", Provider: "openrouter"} + got, err := backend.SetTaskModels(appwire.TaskModelsPatch{ + Revision: before.Revision, + Pins: map[string]*appwire.ModelRef{appwire.RoleChecker: checker}, + Allowed: []appwire.TaskModelAllowed{{Model: appwire.ModelRef{ID: "anthropic/claude-opus-5", Provider: "openrouter"}}}, + }) + if err != nil { + t.Fatal(err) + } + if pin := got.Roles.Checker.Pin; pin == nil || *pin != *checker || got.Revision == before.Revision { + t.Fatalf("after the set %+v", got.Roles.Checker) + } + if !got.ReadAt.After(before.ReadAt) { + t.Errorf("the answer to a set reads at %v, not after the earlier read at %v", got.ReadAt, before.ReadAt) + } + rec.waitFor(t, 0, func(event string, data any) bool { + changed, ok := data.(appwire.SettingsChanged) + return event == appwire.EventSettingsChanged && ok && changed.TaskModels != nil && changed.TaskModels.Revision == got.Revision && + changed.Settings.ChecksModel == "deepseek/deepseek-v4-flash@openrouter" + }) + if _, err := backend.SetTaskModels(appwire.TaskModelsPatch{Revision: before.Revision, Pins: map[string]*appwire.ModelRef{appwire.RoleChecker: nil}}); wireCode(err) != appwire.CodeConflict { + t.Errorf("a stale revision: %v", err) + } + if _, err := backend.SetTaskModels(appwire.TaskModelsPatch{Pins: map[string]*appwire.ModelRef{appwire.RoleWorker: {ID: "nobody/listed"}}}); wireCode(err) != appwire.CodeBadParams { + t.Errorf("a pin to nothing listed: %v", err) + } + if _, err := backend.SetTaskModels(appwire.TaskModelsPatch{Pins: map[string]*appwire.ModelRef{appwire.RoleWorker: {ID: "anthropic/claude-opus-5", Provider: "openrouter"}}}); wireCode(err) != appwire.CodeBadParams { + t.Errorf("a pin to a model unticked a moment ago: %v", err) + } +} + +// A BUDGET WRITE IS NEVER HELD UP BY THE TASK MODELS: its settings.changed +// carries none; a Checks change carries them, because it is the checker pin. +func TestAppSettingsChangedCarriesTaskModelsOnlyForTheirRows(t *testing.T) { + backend, rec := taskModelsBackend(t) + usd := 7.0 + if _, err := backend.SetSettings(appwire.SettingsPatch{TaskCapUSD: &appwire.Figure{USD: &usd}}); err != nil { + t.Fatal(err) + } + at := rec.waitFor(t, 0, func(event string, _ any) bool { return event == appwire.EventSettingsChanged }) + rec.mu.Lock() + if changed := rec.data[at].(appwire.SettingsChanged); changed.TaskModels != nil || changed.Settings.TaskCapUSD == nil || *changed.Settings.TaskCapUSD != 7 { + t.Errorf("a cap change said %+v", changed) + } + rec.mu.Unlock() + checks := "z-ai/glm-5.3-flash" + if _, err := backend.SetSettings(appwire.SettingsPatch{ChecksModel: &checks}); err != nil { + t.Fatal(err) + } + rec.waitFor(t, 0, func(event string, data any) bool { + changed, ok := data.(appwire.SettingsChanged) + return ok && changed.TaskModels != nil && changed.TaskModels.Roles.Checker.Pin != nil && changed.TaskModels.Roles.Checker.Pin.ID == checks + }) + if got := config.CrewTaskCapAt(config.ProfileDir()); got != 7 { + t.Errorf("the task cap is %v, want the one row Spending writes", got) + } +} diff --git a/docs/changes/unreleased/0-app-task-models.md b/docs/changes/unreleased/0-app-task-models.md new file mode 100644 index 0000000000..8ef16c4650 --- /dev/null +++ b/docs/changes/unreleased/0-app-task-models.md @@ -0,0 +1,15 @@ +--- +kind: added +title: Task models — Auto or one model, per-provider allowed models, kept pins, no silent swaps +pr: 0 +surface: [chat, engine] +invalidates: + - "A rule that left a pinned seat outside the allowed models was refused (`your worker is pinned to …, which that rule leaves out`). It is now written and the pin is kept; that seat is routed by Auto with a pin_not_allowed warning on the decision and a `gap` line on /crew until the model is allowed again or the pin removed. Pinning a seat to a disallowed model is still refused." + - "The allowed rule was by model, or by whole provider. It now also takes `+model@provider` / `-model@provider`, one model on one provider, which is how the app's per-provider ticks are stored. An older build ignores such a word." + - "A seat nothing allowed could sit was rescued at admission onto the last crew's model or the model you are talking to. config.RouteCrew now answers *crewroute.NoEligibleError{Seat, Mode, Rejected, Action} and starts nothing; free pools of allowed models are still a rescue, and mid-task ladder rungs (last crew, chat model) are taken only where the allowed rule admits them. errors.As(err, &config.ErrCrewUnreachable{}) still matches." + - "An allowed saved pin whose provider was gone, off, signed out, out of credit or resting still ran (and was probed). It is now that role's NoEligibleError at admission; it is never moved to another model or provider." + - "A direct metered connection's routes were weighed at the catalog's (OpenRouter's) price. They are now Auto candidates only when that provider's own cached listing published a price (catalog.OwnPrices; a listing seeded from ids alone knows none), and are weighed at it (crewroute.Route.Price). Unpriced direct routes are pinnable, not picked." + - "One model for every task existed only as the --one-model launch flag. A profile row (models.tasks.mode / models.tasks.one) now makes config.RouteCrew answer a decision with Mode \"one\" and the one model in every seat, read at admission; the flag is unchanged." + - "crewroute.Decision had no mode or warnings. It now carries Mode (auto|one; empty on old checkpoints reads auto) and Warnings []SeatWarning." + - "The app had only settings.checks_model / planner_model for task models. New settings.models.get/list/set (internal/appwire/taskmodels.go) and an optional task_models on settings.changed, sent only when a task-models row changed; the caps stay settings.task_cap_usd / day_cap_usd." +--- diff --git a/internal/appwire/taskmodels_test.go b/internal/appwire/taskmodels_test.go new file mode 100644 index 0000000000..1eecc456d0 --- /dev/null +++ b/internal/appwire/taskmodels_test.go @@ -0,0 +1,81 @@ +package appwire + +import ( + "encoding/json" + "strings" + "testing" +) + +// A PATCH IS READ PART BY PART, each bounded, and nothing it does not know. +func TestATaskModelsPatchIsCheckedBeforeTheEngineSeesIt(t *testing.T) { + patch, err := decodeTaskModelsPatch(json.RawMessage(`{"mode":"one","one_model":{"id":"z-ai/glm-5.3","provider":"OpenRouter"}, + "pins":{"checker":null,"worker":{"id":"deepseek/deepseek-v4-flash"}}, + "allowed":[{"model":{"id":"z-ai/glm-5.3","provider":"openrouter"},"allowed":false},{"model":{"id":"z-ai/glm-5.3","provider":"z-ai"},"allowed":true}]}`)) + if err != nil { + t.Fatal(err) + } + if *patch.Mode != TaskModelsOne || patch.OneModel.Provider != "openrouter" || len(patch.Allowed) != 2 { + t.Fatalf("patch %+v", patch) + } + if pin, named := patch.Pins[RoleChecker]; !named || pin != nil { + t.Errorf("checker sent as null must read as Auto, got %+v %v", pin, named) + } + if _, named := patch.Pins[RolePlanner]; named { + t.Error("a role left out must be left alone") + } + for raw, want := range map[string]string{ + `{}`: "needs mode", + `{"mode":"best"}`: "mode is auto or one", + `{"budget_usd":3}`: "has no budget_usd", + `{"pins":{"crew":null}}`: "not a role", + `{"one_model":null}`: "one_model is", + `{"one_model":{"id":"a b"}}`: "no spaces", + `{"one_model":{"id":"a@b"}}`: "not after @", + `{"pins":{"worker":{"id":"` + strings.Repeat("x", ModelRefIDMax+1) + `"}}}`: "at most", + `{"allowed":[{"model":{"id":"a/b"},"allowed":true}]}`: "needs its provider", + `{"allowed":[{"model":{"id":"a/b","provider":"p"},"allowed":true},{"model":{"id":"a/b","provider":"P"},"allowed":false}]}`: "twice", + `{"one_model":{"id":"a/b","provider":"bad provider!"}}`: "not a provider id", + } { + _, err := decodeTaskModelsPatch(json.RawMessage(raw)) + if err == nil || !strings.Contains(err.Error(), want) { + t.Errorf("%s: %v, want %q", raw, err, want) + } + if e, ok := err.(*Error); ok && e.Code != CodeBadParams { + t.Errorf("%s: code %s", raw, e.Code) + } + } + rows := make([]string, TaskModelsAllowedMax+1) + for i := range rows { + rows[i] = `{"model":{"id":"m/` + strings.Repeat("x", i%7+1) + `","provider":"p` + string(rune('a'+i%26)) + `"},"allowed":true}` + } + if _, err := decodeTaskModelsPatch(json.RawMessage(`{"allowed":[` + strings.Join(rows, ",") + `]}`)); err == nil || !strings.Contains(err.Error(), "at most") { + t.Errorf("an oversize allowed list: %v", err) + } +} + +// A LIST REQUEST IS BOUNDED: a default page, a ceiling, a query of one line. +func TestATaskModelsListIsBounded(t *testing.T) { + p, err := checkTaskModelsList(TaskModelsListParams{}) + if err != nil || p.Limit != TaskModelsPageDefault { + t.Fatalf("default: %+v %v", p, err) + } + for _, bad := range []TaskModelsListParams{ + {Limit: TaskModelsPageMax + 1}, {Limit: -1}, {Q: strings.Repeat("q", TaskModelsQueryMax+1)}, + {Q: "a\nb"}, {Role: "crew"}, {Provider: "Open Router"}, {Cursor: strings.Repeat("c", 513)}, + } { + if _, err := checkTaskModelsList(bad); err == nil { + t.Errorf("%+v was accepted", bad) + } + } +} + +// AN ENGINE WITHOUT TASK MODELS ANSWERS unknown_method, as for competence. +func TestAnEngineWithoutTaskModelsSaysSo(t *testing.T) { + w := startWire(t, &fakeEngine{}) + w.next() + w.send(`{"id":1,"method":"settings.models.get","params":{}}`) + got := w.next() + if e, _ := got["error"].(map[string]any); e == nil || e["code"] != CodeUnknownMethod { + t.Fatalf("got %v", got) + } +} From 5a95f695d48901584af1c792a19c401c85e19159 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 20:21:42 -0400 Subject: [PATCH 063/459] app: a tool's result message is not a second step in the entries The shaping keeps a row for each tool result message with no call id and no name; the app listed it as a tool entry (t#N) beside the real call, so a reloaded chat carried a blank step per call that no live event ever named. A tool item is told by its call id or its name, and one with either stays. Co-Authored-By: Claude Sonnet 5.5 --- cmd/codeaf/app.go | 8 ++++++++ cmd/codeaf/apphidden_test.go | 33 +++++++++++++++++++++++++++++++++ 2 files changed, 41 insertions(+) diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index cec6205f47..cf27609f61 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -2355,6 +2355,14 @@ func appEntryShapes(agent string, items []session.DisplayEntry, at time.Time, ma } m++ case "tool": + // A TOOL'S RESULT MESSAGE IS NOT A SECOND CALL. The shaping keeps a + // row for it with no call id and no name; the call it answers is + // the entry above, with its output, so drawn alone it is a blank + // step that no live event ever named. A call is told by its id or + // its name, and one with either stays. + if item.CallID == "" && item.Tool == "" { + continue + } call := item.CallID if call == "" { call = "#" + strconv.Itoa(i) diff --git a/cmd/codeaf/apphidden_test.go b/cmd/codeaf/apphidden_test.go index 429bb83b62..62b6047fa4 100644 --- a/cmd/codeaf/apphidden_test.go +++ b/cmd/codeaf/apphidden_test.go @@ -63,3 +63,36 @@ func TestAnAskCarriesItsSubject(t *testing.T) { t.Fatalf("an ask about nothing has subject %+v", s) } } + +// A TOOL'S RESULT MESSAGE IS NOT A SECOND CALL: the blank row the shaping keeps +// for it (no call id, no name) is not an entry, while every genuine call keeps +// its own stable id, in history order, with no turn an old record never had. +func TestAToolResultMessageIsNotAStep(t *testing.T) { + items := []session.DisplayEntry{ + {Role: "aside", Text: "manager: start", Team: []session.TeamLine{{Team: "t", From: "manager", Text: "start"}}}, + {Role: "tool", Tool: "bash", CallID: "call_1", Hint: "run it"}, + {Role: "tool"}, + {Role: "assistant", Text: "ran once", Answer: false}, + {Role: "tool", Tool: "read", CallID: "call_2"}, + {Role: "tool"}, + {Role: "tool", Tool: "bash"}, + } + var ids []string + for _, e := range appEntriesOf("a", items, time.Now(), nil) { + if e.Kind == appwire.EntryTool { + ids = append(ids, e.ID) + if e.Turn != 0 { + t.Fatalf("an old record's tool was given turn %d", e.Turn) + } + } + } + want := []string{"tcall_1", "tcall_2", "t#6"} + if len(ids) != len(want) { + t.Fatalf("tool entries = %v, want %v", ids, want) + } + for i := range want { + if ids[i] != want[i] { + t.Fatalf("tool entries = %v, want %v", ids, want) + } + } +} From 24168b17559ae22e569df3ed51efc7eac99fe519 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 20:08:38 -0400 Subject: [PATCH 064/459] app: say since when no member of a crew has run An idle or needs_you crew carries idle_since: the latest instant any member stopped running (the end of its last turn, or when the question holding its open turn was put). Absent while any member runs or may run, on a closed crew, and when any member's instant is unknown. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/appcrews.go | 52 ++++++++++++++ cmd/codeaf/appcrews_test.go | 72 +++++++++++++++++++ .../unreleased/0-app-crew-idle-since.md | 8 +++ internal/appwire/crews.go | 7 +- 4 files changed, 138 insertions(+), 1 deletion(-) create mode 100644 docs/changes/unreleased/0-app-crew-idle-since.md diff --git a/cmd/codeaf/appcrews.go b/cmd/codeaf/appcrews.go index 87c1a22181..f07274bbd4 100644 --- a/cmd/codeaf/appcrews.go +++ b/cmd/codeaf/appcrews.go @@ -371,9 +371,61 @@ func (b *appBackend) crewView(read appCrewRead, t teamstore.Team) appwire.Crew { view.Items = append(view.Items, extra.Items...) } view.State = b.crewState(read, t, view) + if view.State == appwire.CrewIdle || view.State == appwire.CrewNeedsYou { + view.IdleSince = b.crewIdleSince(view.Members) + } return view } +// crewIdleSince is the instant since which no member of a crew has run: the +// latest instant any member stopped. A member at rest stopped when its +// conversation last changed (the end of its last turn); a member whose turn is +// open but stopped on a question that holds the turn stopped when the first +// such question still open was put. Nil when any member may still be running +// or any member's instant is unknown. +func (b *appBackend) crewIdleSince(members []appwire.CrewMember) *time.Time { + var last time.Time + known := len(members) > 0 + b.locked(func() { + for _, m := range members { + stopped := b.stoppedLocked(b.agents[m.Agent]) + if stopped.IsZero() { + known = false + return + } + if stopped.After(last) { + last = stopped + } + } + }) + if !known { + return nil + } + return &last +} + +// stoppedLocked is when an agent last stopped running, and the zero time when +// it runs, may run, or is not known. Callers hold b.mu. +func (b *appBackend) stoppedLocked(a *appAgent) time.Time { + if a == nil || a.view.State == appwire.StateWorking { + return time.Time{} + } + live := a.live + if live == nil || !live.running { + return a.view.UpdatedAt + } + // A TURN IS OPEN. It has stopped only on a question that holds the turn, + // and it stopped when the first of those still open was put; anything + // else may be running. + var first time.Time + for _, q := range live.questions { + if q.Blocking.Turn && !q.Asked.IsZero() && (first.IsZero() || q.Asked.Before(first)) { + first = q.Asked + } + } + return first +} + // subCrewBrief is the words a sub-crew's manager was started with: the // parent's start line that named this crew. func (b *appBackend) subCrewBrief(t teamstore.Team) string { diff --git a/cmd/codeaf/appcrews_test.go b/cmd/codeaf/appcrews_test.go index a323dbc938..fe41148a51 100644 --- a/cmd/codeaf/appcrews_test.go +++ b/cmd/codeaf/appcrews_test.go @@ -1,6 +1,7 @@ package main import ( + "encoding/json" "errors" "github.com/Agent-Field/codeaf/internal/session" "os" @@ -687,3 +688,74 @@ func TestAppCrewStartRecordsTheStartLineAndTheBriefFollowsIt(t *testing.T) { fresh.profileDir = backend.profileDir check(fresh, "after restart") } + +// A CREW SAYS SINCE WHEN NO MEMBER HAS RUN, on the fake clock: idle or waiting +// on the person, it is the latest instant a member stopped, a member at rest at +// the end of its last turn and a member whose open turn a question holds when +// that question was put. It is absent while a member runs or may run, and +// while any member's instant is unknown. +func TestAppCrewSaysSinceWhenNoMemberHasRun(t *testing.T) { + hold := make(chan struct{}) + conv := &fakeConversation{script: []session.Event{{Kind: session.EventTurnDone}}, hold: hold} + backend, rec, agent, id := crewBackendOn(t, conv) + var mu sync.Mutex + t0 := time.Date(2030, 1, 2, 3, 4, 5, 0, time.UTC) + clock := t0 + backend.now = func() time.Time { mu.Lock(); defer mu.Unlock(); return clock } + set := func(at time.Time) { mu.Lock(); clock = at; mu.Unlock() } + check := func(state string, want time.Time) appwire.Crew { + t.Helper() + c, err := backend.CrewGet(id) + if err != nil { + t.Fatal(err) + } + if c.State != state || (want.IsZero() != (c.IdleSince == nil)) || (c.IdleSince != nil && !c.IdleSince.Equal(want)) { + t.Fatalf("the crew = %q, idle since %v; want %q, %v", c.State, c.IdleSince, state, want) + } + return c + } + + // The disk-only member's instant is not known: nothing is said. + check(appwire.CrewIdle, time.Time{}) + rested := t0.Add(-time.Hour) + backend.locked(func() { backend.agentLocked("b0b0b0b0").view.UpdatedAt = rested }) + c := check(appwire.CrewIdle, rested) + if raw, _ := json.Marshal(c); !strings.Contains(string(raw), `"idle_since":"2030-01-02T02:04:05Z"`) { + t.Fatalf("the wire = %s", raw) + } + + if _, err := backend.Send(agent.ID, "go", ""); err != nil { + t.Fatal(err) + } + check(appwire.CrewWorking, time.Time{}) + // A question that does not hold the turn: the member may still be running. + free := session.Question{ID: 1, Kind: session.QuestionAsk, Head: "which one?", Asked: t0.Add(time.Minute)} + conv.raise(session.Event{Kind: session.EventQuestion, Question: &free}) + rec.waitFor(t, 5*time.Second, appAskEvent(appwire.EventAskOpened)) + check(appwire.CrewNeedsYou, time.Time{}) + // Questions that hold the turn: it stopped when the first was put. + for n, at := range []time.Duration{3 * time.Minute, 2 * time.Minute} { + q := session.Question{ID: uint64(n + 2), Kind: session.QuestionConsent, Head: "bash", Blocking: session.Blocking{Turn: true}, Asked: t0.Add(at)} + conv.raise(session.Event{Kind: session.EventQuestion, Question: &q}) + } + rec.waitFor(t, 5*time.Second, func(event string, data any) bool { + o, ok := data.(appwire.AskOpened) + return ok && o.Ask.ID == "consent:3" + }) + check(appwire.CrewNeedsYou, t0.Add(2*time.Minute)) + + // The turn ends with its questions open: the member stopped at its end. + ended := t0.Add(10 * time.Minute) + set(ended) + close(hold) + rec.waitFor(t, 5*time.Second, func(event string, data any) bool { + a, ok := data.(appwire.AgentChanged) + return ok && a.Agent.ID == agent.ID && a.Agent.UpdatedAt.Equal(ended) + }) + set(ended.Add(time.Hour)) + check(appwire.CrewNeedsYou, ended) + // The other member stopped later: the crew is idle since the last stop. + later := ended.Add(5 * time.Minute) + backend.locked(func() { backend.agents["b0b0b0b0"].view.UpdatedAt = later }) + check(appwire.CrewNeedsYou, later) +} diff --git a/docs/changes/unreleased/0-app-crew-idle-since.md b/docs/changes/unreleased/0-app-crew-idle-since.md new file mode 100644 index 0000000000..adbb2da133 --- /dev/null +++ b/docs/changes/unreleased/0-app-crew-idle-since.md @@ -0,0 +1,8 @@ +--- +kind: added +title: codeaf app says since when no member of a crew has run +pr: 0 +surface: [engine] +invalidates: + - "Was true: a crew on the wire said it was `idle` but not since when, nor how long a crew waiting on the person had done no work. Is true now: an idle or needs-you crew carries `idle_since`, the instant since which no member has run (the latest member stop: the end of its last turn, or when the question holding its open turn was put), absent while any member runs, on a closed crew, and when any member's instant is unknown." +--- diff --git a/internal/appwire/crews.go b/internal/appwire/crews.go index e6b2406c32..1250fe2db3 100644 --- a/internal/appwire/crews.go +++ b/internal/appwire/crews.go @@ -105,7 +105,11 @@ type CrewItem struct { At time.Time `json:"at"` } -// Crew is one team. +// Crew is one team. IdleSince is the instant since which no member has run, +// on an idle or needs_you crew: the latest instant any member stopped (the end +// of its last turn, or when the question holding its open turn was put). It +// is absent while any member runs, on a closed crew, and when any member's +// instant is unknown. type Crew struct { ID string `json:"id"` Name string `json:"name"` @@ -117,6 +121,7 @@ type Crew struct { Tint string `json:"tint"` MadeAt *time.Time `json:"made_at,omitempty"` State string `json:"state"` + IdleSince *time.Time `json:"idle_since,omitempty"` ClosedAt *time.Time `json:"closed_at,omitempty"` ClosedWith string `json:"closed_with,omitempty"` Report string `json:"report,omitempty"` From 02c115ee60edd97b240f241ac5e9465d37977866 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 20:12:44 -0400 Subject: [PATCH 065/459] app: read what an ask holds up from the task graph, never a model's words holding_up is set only on an ask whose source is a task: that task plus every open task whose waits_on reaches it, nearest first, cycle-safe and bounded, from the same graph Task.holding_up is read from. The asker's Blocking.Tasks names are no longer parsed for it. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/app.go | 1 + cmd/codeaf/appconvo.go | 78 ++++++++++++++++--- cmd/codeaf/appconvo_test.go | 54 ++++++++++++- .../unreleased/0-app-ask-holding-up.md | 8 ++ internal/appwire/wire.go | 6 ++ 5 files changed, 136 insertions(+), 11 deletions(-) create mode 100644 docs/changes/unreleased/0-app-ask-holding-up.md diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index cf27609f61..4dce7e0b68 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -1206,6 +1206,7 @@ func (b *appBackend) questionEvent(a *appAgent, id string, live *appLive, event restated, moved := false, false var view appwire.Agent b.locked(func() { + ask.HoldingUp = askHoldingUpLocked(live, ask) if _, open := live.questions[askID]; open { // The lane replays what is open when it is subscribed again, // and a question restated in place is the same ask. diff --git a/cmd/codeaf/appconvo.go b/cmd/codeaf/appconvo.go index 00ca5df648..10cc9aacc1 100644 --- a/cmd/codeaf/appconvo.go +++ b/cmd/codeaf/appconvo.go @@ -337,11 +337,6 @@ func appAskFacts(agent string, ask *appwire.Ask, q session.Question) { until := q.Deadline ask.ClockUntil = &until } - for _, task := range q.Blocking.Tasks { - if n, err := strconv.ParseUint(strings.TrimSpace(task), 10, 64); err == nil { - ask.HoldingUp = append(ask.HoldingUp, appTaskID(agent, n)) - } - } ask.SayWhy = q.Kind == session.QuestionConsent } @@ -1190,10 +1185,10 @@ func appTaskExtras(tasks []appwire.Task) { done[task.Parent]++ } } - for _, waited := range task.WaitsOn { - if i, ok := byID[waited]; ok && task.State != appwire.TaskDone && task.State != appwire.TaskFailed { - tasks[i].HoldingUp = append(tasks[i].HoldingUp, task.ID) - } + } + for waited, waiters := range appWaiters(tasks) { + if i, ok := byID[waited]; ok { + tasks[i].HoldingUp = waiters } } for i := range tasks { @@ -1204,6 +1199,71 @@ func appTaskExtras(tasks []appwire.Task) { } } +// appTaskOpen is a task that has not ended: neither done nor failed. +func appTaskOpen(task appwire.Task) bool { + return task.State != appwire.TaskDone && task.State != appwire.TaskFailed +} + +// appWaiters is the task graph read backwards, from the engine's own +// dependencies (Task.WaitsOn): for each task, the open tasks that wait on it +// directly, in the order the list gives them. Task.holding_up and +// Ask.holding_up are both read from it. +func appWaiters(tasks []appwire.Task) map[string][]string { + waiters := map[string][]string{} + for _, task := range tasks { + if !appTaskOpen(task) { + continue + } + for _, waited := range task.WaitsOn { + waiters[waited] = append(waiters[waited], task.ID) + } + } + return waiters +} + +// appHoldingUpLimit bounds how many tasks an ask says it holds up. +const appHoldingUpLimit = 256 + +// appAskHoldingUp is what an ask from a task holds up: that task, then every +// open task whose dependencies reach it, nearest first, each once however the +// graph loops, and no more than [appHoldingUpLimit]. It is nil for an ask +// that comes from no task: what a model names as blocked is never read here. +func appAskHoldingUp(source string, tasks []appwire.Task) []string { + if source == "" { + return nil + } + waiters := appWaiters(tasks) + out := []string{source} + seen := map[string]bool{source: true} + for i := 0; i < len(out) && len(out) < appHoldingUpLimit; i++ { + for _, w := range waiters[out[i]] { + if seen[w] { + continue + } + seen[w] = true + out = append(out, w) + if len(out) == appHoldingUpLimit { + break + } + } + } + return out +} + +// askHoldingUpLocked is an ask's holding_up from the agent's task graph as it +// stands. Callers hold b.mu. +func askHoldingUpLocked(live *appLive, ask appwire.Ask) []string { + if ask.Source == nil || ask.Source.Task == "" { + return nil + } + tasks := make([]appwire.Task, 0, len(live.tasks)) + for _, task := range live.tasks { + tasks = append(tasks, task) + } + sort.Slice(tasks, func(i, j int) bool { return tasks[i].ID < tasks[j].ID }) + return appAskHoldingUp(ask.Source.Task, tasks) +} + // ── long output ───────────────────────────────────────────────────────────── // appTrimOutput keeps only the last 512 KB of a file of output, with a line diff --git a/cmd/codeaf/appconvo_test.go b/cmd/codeaf/appconvo_test.go index 12433825bc..e6a42944f4 100644 --- a/cmd/codeaf/appconvo_test.go +++ b/cmd/codeaf/appconvo_test.go @@ -3,6 +3,8 @@ package main import ( "os" "path/filepath" + "reflect" + "strconv" "strings" "testing" "time" @@ -145,13 +147,24 @@ func TestAppQueueIsEditedReorderedAndSentInOrder(t *testing.T) { func TestAppAskCarriesTheDocksFactsAndCanBeHandedBack(t *testing.T) { conv := &fakeConversation{} backend, rec, agent := workBackend(t, conv) + // The engine's graph: 5 waits on 4, 6 on 5, and 7, done, on 4. + backend.locked(func() { + live := backend.agents[agent.ID].live + for _, task := range []appwire.Task{ + {ID: agent.ID + ":5", State: appwire.TaskRunning, WaitsOn: []string{agent.ID + ":4"}}, + {ID: agent.ID + ":6", State: appwire.TaskQueued, WaitsOn: []string{agent.ID + ":5"}}, + {ID: agent.ID + ":7", State: appwire.TaskDone, WaitsOn: []string{agent.ID + ":4"}}, + } { + live.tasks[task.ID] = task + } + }) deadline := time.Now().Add(12 * time.Second).Round(time.Second) q := session.Question{ ID: 9, Kind: session.QuestionAsk, Ask: session.AskChoice, Head: "Should trailing commas be on by default?", Reason: "the parser accepts both", Subject: session.SubjectRef{Kind: session.SubjectNode, ID: 4}, Options: []session.AnswerOption{{Key: "strict", Label: "Keep strict", Body: "Opt in with strict: false."}, {Key: "tolerant", Label: "Tolerant by default"}}, Pick: &session.Pick{Key: "strict"}, Deadline: deadline, - Blocking: session.Blocking{Tasks: []string{"5"}}, + Blocking: session.Blocking{Tasks: []string{"99", "the parser"}}, Attach: []session.Block{{Kind: session.BlockText, Body: "a\nb\nc\nd\ne\nf\ng\nh\ni\nj"}}, } conv.raise(session.Event{Kind: session.EventQuestion, Question: &q}) @@ -165,7 +178,7 @@ func TestAppAskCarriesTheDocksFactsAndCanBeHandedBack(t *testing.T) { if len(ask.Choices) != 2 || !ask.Choices[0].Suggested || ask.Choices[1].Suggested || ask.Choices[0].Detail == "" { t.Fatalf("the choices = %+v", ask.Choices) } - if ask.ClockUntil == nil || !ask.ClockUntil.Equal(deadline) || len(ask.HoldingUp) != 1 || ask.HoldingUp[0] != agent.ID+":5" { + if ask.ClockUntil == nil || !ask.ClockUntil.Equal(deadline) || !reflect.DeepEqual(ask.HoldingUp, []string{agent.ID + ":4", agent.ID + ":5", agent.ID + ":6"}) { t.Fatalf("the clock and what it holds up = %+v", ask) } if ask.Evidence == nil || len(ask.Evidence.Lines) != appEvidenceLines || ask.Evidence.More != 2 { @@ -353,3 +366,40 @@ func TestAppStagedAttachmentsAreRemovedExactly(t *testing.T) { t.Fatalf("cleanup after a send failed: %v", err) } } + +// WHAT AN ASK HOLDS UP IS THE ENGINE'S GRAPH, NEVER A MODEL'S WORDS: the task +// it comes from and every open task whose dependencies reach it, nearest +// first, each once however the graph loops; ended tasks hold nothing up, and +// an ask from no task says nothing. +func TestAppAskHoldingUpIsTheTaskGraph(t *testing.T) { + task := func(id, state string, waits ...string) appwire.Task { + return appwire.Task{ID: id, State: state, WaitsOn: waits} + } + cases := []struct { + name string + source string + tasks []appwire.Task + want []string + }{ + {"a chain", "a", []appwire.Task{task("a", appwire.TaskRunning), task("b", appwire.TaskQueued, "a"), task("c", appwire.TaskQueued, "b")}, []string{"a", "b", "c"}}, + {"a cycle", "a", []appwire.Task{task("a", appwire.TaskRunning, "c"), task("b", appwire.TaskQueued, "a"), task("c", appwire.TaskQueued, "b")}, []string{"a", "b", "c"}}, + {"ended tasks", "a", []appwire.Task{task("a", appwire.TaskRunning), task("b", appwire.TaskDone, "a"), task("c", appwire.TaskQueued, "b"), task("d", appwire.TaskFailed, "a"), task("e", appwire.TaskRunning, "a")}, []string{"a", "e"}}, + {"no source task", "", []appwire.Task{task("b", appwire.TaskQueued, "a")}, nil}, + } + for _, c := range cases { + if got := appAskHoldingUp(c.source, c.tasks); !reflect.DeepEqual(got, c.want) { + t.Errorf("%s: holding up %v, want %v", c.name, got, c.want) + } + } + var wide []appwire.Task + for i := range appHoldingUpLimit * 2 { + wide = append(wide, task(strconv.Itoa(i), appwire.TaskQueued, "root")) + } + if got := appAskHoldingUp("root", wide); len(got) != appHoldingUpLimit { + t.Errorf("a wide graph says %d tasks, want the bound %d", len(got), appHoldingUpLimit) + } + live := &appLive{tasks: map[string]appwire.Task{"b": task("b", appwire.TaskQueued, "a")}} + if got := askHoldingUpLocked(live, appwire.Ask{Source: &appwire.AskSource{Kind: "reply"}}); got != nil { + t.Errorf("an ask from no task holds up %v", got) + } +} diff --git a/docs/changes/unreleased/0-app-ask-holding-up.md b/docs/changes/unreleased/0-app-ask-holding-up.md new file mode 100644 index 0000000000..58532a9a46 --- /dev/null +++ b/docs/changes/unreleased/0-app-ask-holding-up.md @@ -0,0 +1,8 @@ +--- +kind: fixed +title: codeaf app says what an ask holds up from the task graph, never from a model's words +pr: 0 +surface: [engine] +invalidates: + - "Was true: an ask's `holding_up` was the asker's own list of blocked work kept only where an entry happened to be a number, so a model's task names were dropped, a stray number became a task id, and an ask from a task did not count the tasks waiting on it. Is true now: `holding_up` is set only on an ask whose source is a task, and is that task plus every open task whose `waits_on` reaches it (nearest first, each once, at most 256), read from the same dependency graph as `Task.holding_up`; an ask from no task carries none." +--- diff --git a/internal/appwire/wire.go b/internal/appwire/wire.go index c7ff96a5d6..44af03b32d 100644 --- a/internal/appwire/wire.go +++ b/internal/appwire/wire.go @@ -210,6 +210,12 @@ type Ask struct { // the evidence, the choices with the asker's suggestion, the clock, the // calls asked about together, the tasks it holds up, and whether a reason // may go with the answer. Later says the person put it aside. + // + // HoldingUp is set only on an ask whose source is a task: that task's id, + // then every open task whose waits_on reaches it, nearest first, each + // once, at most 256, read from the engine's task dependencies as they + // stood when the ask was raised or restated. It is never read from what a + // model names as blocked, nor from how many tasks are open. Source *AskSource `json:"source,omitempty"` Reason string `json:"reason,omitempty"` Evidence *Evidence `json:"evidence,omitempty"` From d64f24b41ffde8a1e5d83b1a8b02d69aaf9d2b7e Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 20:13:33 -0400 Subject: [PATCH 066/459] app: say which kind of crew decision an ask is A crew_decision ask carries decision_kind, the packet's own kind (question, conflict, cap, judgement, closing); no other ask carries it. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/appcrews.go | 2 +- cmd/codeaf/appcrews_test.go | 2 +- docs/changes/unreleased/0-app-ask-decision-kind.md | 8 ++++++++ internal/appwire/wire.go | 3 +++ 4 files changed, 13 insertions(+), 2 deletions(-) create mode 100644 docs/changes/unreleased/0-app-ask-decision-kind.md diff --git a/cmd/codeaf/appcrews.go b/cmd/codeaf/appcrews.go index f07274bbd4..b20b9ce851 100644 --- a/cmd/codeaf/appcrews.go +++ b/cmd/codeaf/appcrews.go @@ -958,7 +958,7 @@ func appDecisionAsk(f *teamstore.File, p teamstore.Packet) appwire.Ask { d := appDecisionOf(f, p) ask := appwire.Ask{ ID: appCrewAskPrefix + p.ID, Agent: decisionAgent(f, p), Kind: appwire.AskQuestion, Subkind: appwire.SubkindCrewDecision, - Title: d.Question, Path: d.Path, SayWhy: true, + Title: d.Question, Path: d.Path, SayWhy: true, DecisionKind: d.Kind, Subject: &appwire.AskSubject{Kind: appwire.SubjectCrewDecision, Ref: p.ID}, } if d.Recommendation != nil { diff --git a/cmd/codeaf/appcrews_test.go b/cmd/codeaf/appcrews_test.go index fe41148a51..5ab43e19f4 100644 --- a/cmd/codeaf/appcrews_test.go +++ b/cmd/codeaf/appcrews_test.go @@ -121,7 +121,7 @@ func TestAppCrewDecisionIsAnAskAndAnsweringItDecides(t *testing.T) { } return ok && o.Ask.Subkind == appwire.SubkindCrewDecision }) - if opened.Agent != agent.ID || opened.ID != appCrewAskPrefix+p.ID || len(opened.Path) != 2 || opened.Path[0] != "harbor" || opened.Path[1] != "you" { + if opened.Agent != agent.ID || opened.ID != appCrewAskPrefix+p.ID || len(opened.Path) != 2 || opened.Path[0] != "harbor" || opened.Path[1] != "you" || opened.DecisionKind != teamstore.PacketQuestion { t.Fatalf("the ask = %+v", opened) } asks, _ := backend.ListAsks(agent.ID) diff --git a/docs/changes/unreleased/0-app-ask-decision-kind.md b/docs/changes/unreleased/0-app-ask-decision-kind.md new file mode 100644 index 0000000000..a59dfe8e55 --- /dev/null +++ b/docs/changes/unreleased/0-app-ask-decision-kind.md @@ -0,0 +1,8 @@ +--- +kind: added +title: codeaf app says which kind of crew decision an ask is +pr: 0 +surface: [engine] +invalidates: + - "Was true: a crew decision on the person was an ask of subkind `crew_decision` whatever the packet was, so telling a conflict from a question, a cap, a judgement or a closing report meant joining the ask to a `decision.opened` event that is not said for packets already waiting at launch. Is true now: a `crew_decision` ask carries `decision_kind`, the packet's own kind (`question`, `conflict`, `cap`, `judgement`, `closing`); no other ask carries it." +--- diff --git a/internal/appwire/wire.go b/internal/appwire/wire.go index 44af03b32d..1898ea67b0 100644 --- a/internal/appwire/wire.go +++ b/internal/appwire/wire.go @@ -228,6 +228,9 @@ type Ask struct { // Path is the crews a crew's decision climbed through to the person, // "Backend → Release → you". Path []string `json:"path,omitempty"` + // DecisionKind is, on a crew_decision ask only, the decision packet's + // kind: question, conflict, cap, judgement or closing (Decision.Kind). + DecisionKind string `json:"decision_kind,omitempty" enum:"question,conflict,cap,judgement,closing"` // Subject is what the ask is about, so the app draws that thing's own // card: an automation waiting to be agreed to, a task, a tool call, a crew // proposal or decision. Nil is an ask about nothing in particular. From a97230e041528f979c1ef1fe6eb2aef341694322 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 20:49:59 -0400 Subject: [PATCH 067/459] service, app: integrate the V77 supervisor and service host Brings the kept-running services lane (work/ui2-service-health-engine-20261015 at e50775c05) onto this base as one change: the internal/service supervisor (gated launch, fair overdue checks, bounded output pages and tails, close and quit that say what would not stop, an owner being closed starts nothing new), the keep-running ask in session behind a real card, the appwire services contract, the app's one supervisor with its Inbox episodes and kept notices, the bounded notice index, Close and Archive fences that stop and wait for an agent's services, and the ports observer. The processgroup foundations (91857fb28, 87a1dc210, 0d321a259) are already here as 3c31592bd, 015159347 and 7d1fbaa56 and were not picked again. Two merges kept both sides. agents.entries lays a crew's formation facts in first and then the kept service notices by time, through one appEntryPageOf; appEntryPageWithFormed and appEntryPageWith remain as its two narrow doors. EventSummaryStarted and EventSummaryFailed keep EventAutomationUpdate+1 and +2, and EventKeepRunning is pinned at +3, with a test that says so. Source commits, oldest first: efbe0b22d e516e654b 7d31ce421 06d7e79bf 7e4c8fa92 21f633855 3c492ab43 2b8655237 6a04a3788 a21d11aa0 4c657c359 fb8a110be 5e3d1de0a 3352a589d a10717510 c7655357f 634065e1c 73fb3ea70 d4950c22c e50775c05. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/app.go | 161 +- cmd/codeaf/appinbox.go | 5 +- cmd/codeaf/appnotices.go | 618 +++++++ cmd/codeaf/appnotices_test.go | 361 ++++ cmd/codeaf/appports.go | 55 + cmd/codeaf/appservices.go | 924 ++++++++++ cmd/codeaf/appservices_test.go | 669 +++++++ cmd/codeaf/apptidy.go | 61 +- cmd/codeaf/apptidy_services_test.go | 181 ++ cmd/codeaf/appwork.go | 15 +- cmd/codeaf/engine.go | 3 + .../changes/unreleased/0-app-ports-observe.md | 16 + internal/appwire/ports.go | 139 ++ internal/appwire/ports_test.go | 87 + internal/appwire/services.go | 321 ++++ internal/appwire/services_test.go | 118 ++ internal/appwire/wire.go | 8 +- internal/appwire/work.go | 3 + internal/manual/chat/kept-running.md | 64 + internal/manual/chat/what-i-can-do.md | 3 +- internal/manual/chat_test.go | 3 + internal/ports/holder_unix.go | 168 ++ internal/ports/holder_windows.go | 16 + internal/ports/ports.go | 244 +++ internal/ports/ports_test.go | 298 ++++ internal/service/admission_test.go | 42 + internal/service/archive.go | 120 ++ internal/service/exit_platform.go | 25 + internal/service/fairness_test.go | 69 + internal/service/fake_test.go | 385 ++++ internal/service/output.go | 228 +++ internal/service/output_test.go | 176 ++ internal/service/platform_test.go | 387 ++++ internal/service/replacement_test.go | 333 ++++ internal/service/runtime.go | 80 + internal/service/runtime_other.go | 9 + internal/service/runtime_platform.go | 323 ++++ internal/service/safety_test.go | 311 ++++ internal/service/servicetest/runtime.go | 247 +++ internal/service/spec.go | 268 +++ internal/service/state.go | 251 +++ internal/service/store.go | 249 +++ internal/service/supervisor.go | 1569 +++++++++++++++++ internal/service/supervisor_test.go | 623 +++++++ internal/service/wait_darwin.go | 49 + internal/service/wait_linux.go | 18 + internal/session/agent.go | 3 + internal/session/answers.go | 2 + internal/session/keep_running.go | 413 +++++ internal/session/keep_running_test.go | 254 +++ internal/session/prompts/system.md | 4 +- internal/session/question.go | 26 + internal/session/session.go | 21 + internal/session/tools_jobs.go | 17 +- 54 files changed, 11008 insertions(+), 35 deletions(-) create mode 100644 cmd/codeaf/appnotices.go create mode 100644 cmd/codeaf/appnotices_test.go create mode 100644 cmd/codeaf/appports.go create mode 100644 cmd/codeaf/appservices.go create mode 100644 cmd/codeaf/appservices_test.go create mode 100644 cmd/codeaf/apptidy_services_test.go create mode 100644 docs/changes/unreleased/0-app-ports-observe.md create mode 100644 internal/appwire/ports.go create mode 100644 internal/appwire/ports_test.go create mode 100644 internal/appwire/services.go create mode 100644 internal/appwire/services_test.go create mode 100644 internal/manual/chat/kept-running.md create mode 100644 internal/ports/holder_unix.go create mode 100644 internal/ports/holder_windows.go create mode 100644 internal/ports/ports.go create mode 100644 internal/ports/ports_test.go create mode 100644 internal/service/admission_test.go create mode 100644 internal/service/archive.go create mode 100644 internal/service/exit_platform.go create mode 100644 internal/service/fairness_test.go create mode 100644 internal/service/fake_test.go create mode 100644 internal/service/output.go create mode 100644 internal/service/output_test.go create mode 100644 internal/service/platform_test.go create mode 100644 internal/service/replacement_test.go create mode 100644 internal/service/runtime.go create mode 100644 internal/service/runtime_other.go create mode 100644 internal/service/runtime_platform.go create mode 100644 internal/service/safety_test.go create mode 100644 internal/service/servicetest/runtime.go create mode 100644 internal/service/spec.go create mode 100644 internal/service/state.go create mode 100644 internal/service/store.go create mode 100644 internal/service/supervisor.go create mode 100644 internal/service/supervisor_test.go create mode 100644 internal/service/wait_darwin.go create mode 100644 internal/service/wait_linux.go create mode 100644 internal/session/keep_running.go create mode 100644 internal/session/keep_running_test.go diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index 4dce7e0b68..1c6eab2329 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -142,6 +142,10 @@ func runApp(args []string) error { } return proc.Search.SearchConversations(terms, limit) } + // ONE SUPERVISOR FOR EVERY CONVERSATION THE APP BUILDS (appservices.go). + services := openAppServices() + appServiceDoor.Store(services) + backend.attachServices(services) guard.Go("app/crews-start", func() { backend.crews() }) // THE APP IS AN OPEN WINDOW for as long as it runs, so the clock that runs // standing orders runs while it is open (apporders.go). @@ -379,6 +383,12 @@ type appBackend struct { collections *workspace.Store collectionsClosed bool collectionsPath string + // services is the app's one supervisor of kept-running processes + // (appservices.go); nil is the capability absent. + services *appServices + // notices are the episodes' kept notices (appnotices.go), opened once. + notices *appNoticeLog + noticesOnce sync.Once } // appAgent is every agent this process has heard of: listed off the disk, @@ -1067,6 +1077,10 @@ func appAskOf(agent string, q session.Question) appwire.Ask { ask.Kind, ask.Subkind = appwire.AskApproval, appwire.SubkindPermission ask.Irreversible = q.Stakes == session.StakesIrreversible } + if q.Kind == session.QuestionKeep { + ask.Kind, ask.Subkind = appwire.AskApproval, appwire.SubkindKeepRunning + ask.KeepRunning = appKeepFacts(q) + } for _, option := range appOptionsOf(q) { ask.Options = append(ask.Options, appwire.AskOption{ID: option.id, Label: option.label}) } @@ -1108,6 +1122,9 @@ func appOptionsOf(q session.Question) []appOption { for _, option := range q.Options { plain = append(plain, appOption{id: option.Key, key: option.Key, label: option.Label}) } + if keep, ok := appKeepOptions(q); ok { + return keep + } if q.Kind != session.QuestionConsent { return plain } @@ -1171,6 +1188,10 @@ func appReceipt(by, option string, answer *session.Answer, at time.Time) string word = "Denied" case appwire.OptionAlways: word = "Always allowed" + case appwire.OptionAllow: + word = "Kept running" + case appwire.OptionAllowNever: + word = "Kept running, never restarted" } who := "you" switch by { @@ -1947,6 +1968,16 @@ func (b *appBackend) CloseAgent(id string) error { if err != nil { return err } + // CLOSING AN AGENT STOPS WHAT IT KEPT RUNNING, and waits for it to be + // gone; letting it go idle does not. A process that would not stop leaves + // the agent open, its process listed and the error saying which. + var agentID string + b.locked(func() { agentID = a.view.ID }) + // Nothing new starts for it from here until its conversation is let go. + defer b.fenceAgentServices(agentID)() + if err := b.stopAgentServices(agentID); err != nil { + return err + } view := b.closeAgent(a) b.emit.Emit(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) return nil @@ -2163,7 +2194,8 @@ func (b *appBackend) Entries(id, before string, limit int) ([]appwire.Entry, boo var more bool err = appRecords.WithRecord(transcript, func(record session.Record) error { var err error - page, more, err = appEntryPageWithFormed(id, record, at, marks, before, limit, func(item session.DisplayEntry) appwire.ToolFacts { + notes := func() (*appNoticeReader, error) { return b.noticeLog().Reader(id) } + page, more, err = appEntryPageOf(id, record, at, marks, notes, before, limit, func(item session.DisplayEntry) appwire.ToolFacts { return b.appEntryFacts(a, item) }, b.crewFormedEntries(id)) return err @@ -2193,10 +2225,29 @@ func appEntriesBusy(err error) error { // cursor are still told over the WHOLE record, because an id counts the // person's messages from the first and an update is known only by a later call. func appEntryPage(id string, record session.Record, at time.Time, marks map[int]appTurnMark, before string, limit int, facts func(session.DisplayEntry) appwire.ToolFacts) ([]appwire.Entry, bool, error) { - return appEntryPageWithFormed(id, record, at, marks, before, limit, facts, nil) + return appEntryPageOf(id, record, at, marks, nil, before, limit, facts, nil) } +// appEntryPageWithFormed is [appEntryPage] with a crew's formation facts laid +// in at their moments (appcrews.go). func appEntryPageWithFormed(id string, record session.Record, at time.Time, marks map[int]appTurnMark, before string, limit int, facts func(session.DisplayEntry) appwire.ToolFacts, formed []appwire.Entry) ([]appwire.Entry, bool, error) { + return appEntryPageOf(id, record, at, marks, nil, before, limit, facts, formed) +} + +// appEntryPageWith is [appEntryPage] with the agent's kept notices of its +// kept-running processes (appnotices.go) laid in by time. +func appEntryPageWith(id string, record session.Record, at time.Time, marks map[int]appTurnMark, notes func() (*appNoticeReader, error), before string, limit int, facts func(session.DisplayEntry) appwire.ToolFacts) ([]appwire.Entry, bool, error) { + return appEntryPageOf(id, record, at, marks, notes, before, limit, facts, nil) +} + +// appEntryPageOf is one page with both: the crew's formation facts are laid +// into the entries first, so they are cursors like any other entry, and the +// kept notices of its kept-running processes are then laid in by time, each +// after the last entry at or before its moment. The record of notices is read +// backwards from the page's cursor, so a page holds at most limit of them +// however long the record is, and a notice's id is a cursor like any other +// entry's. +func appEntryPageOf(id string, record session.Record, at time.Time, marks map[int]appTurnMark, notes func() (*appNoticeReader, error), before string, limit int, facts func(session.DisplayEntry) appwire.ToolFacts, formed []appwire.Entry) ([]appwire.Entry, bool, error) { items := appRecordItems(record) all, from := appEntryShapes(id, items, at, marks) if len(formed) > 0 { @@ -2205,7 +2256,21 @@ func appEntryPageWithFormed(id string, record session.Record, at time.Time, mark if words := strings.TrimSpace(record.Unreadable); words != "" { all = append(all, appwire.Entry{ID: "unreadable", Agent: id, Kind: appwire.EntryNotice, At: at, Text: words}) } + var reader *appNoticeReader + if notes != nil { + r, err := notes() + if err != nil { + return nil, false, err + } + reader = r + } + if reader != nil { + defer reader.Close() + } end := len(all) + // bound is the moment notices on this page come before; a page cut at a + // transcript entry holds only notices from before it. + var bound *time.Time if before != "" { end = -1 for i, entry := range all { @@ -2214,11 +2279,92 @@ func appEntryPageWithFormed(id string, record session.Record, at time.Time, mark break } } + if end >= 0 { + cut := all[end].At + bound = &cut + } else if reader != nil && strings.HasPrefix(before, "svc") { + // A notice's id: the page is what came before that notice. + for { + n, ok := reader.Prev() + if !ok { + break + } + if n.id() == before { + end = len(all) + for end > 0 && all[end-1].At.After(n.At) { + end-- + } + break + } + } + } if end < 0 { return nil, false, appwire.Errorf(appwire.CodeNotFound, "this conversation has no entry %s", before) } } - start := max(end-limit, 0) + if reader != nil && bound != nil { + for { + n, ok := reader.Peek() + if !ok || n.At.Before(*bound) { + break + } + reader.Prev() + } + } + // Walked back from the cursor: a notice at or after the entry before it + // comes first, and a notice id given once in a page is not given twice. + var notices []appwire.Entry + order := make([]bool, 0, limit) // true is a notice + seen := map[string]bool{} + start := end + for len(order) < limit { + n, haveN := appwire.Entry{}, false + if reader != nil { + if notice, ok := reader.Peek(); ok && (start == 0 || !notice.At.Before(all[start-1].At)) { + reader.Prev() + if seen[notice.id()] { + continue + } + seen[notice.id()] = true + n, haveN = appServiceNoticeEntry(notice), true + } + } + if haveN { + notices = append(notices, n) + order = append(order, true) + continue + } + if start == 0 { + break + } + start-- + order = append(order, false) + } + more := start > 0 + if reader != nil && !more { + _, more = reader.Peek() + } + transcript, err := appDressedPage(items, all, from, start, end, facts) + if err != nil { + return nil, false, err + } + page := make([]appwire.Entry, 0, len(order)) + ti, ni := 0, len(notices)-1 + for i := len(order) - 1; i >= 0; i-- { + if order[i] { + page = append(page, notices[ni]) + ni-- + } else { + page = append(page, transcript[ti]) + ti++ + } + } + return page, more, nil +} + +// appDressedPage is the transcript entries start to end, each tool row +// dressed and given its call's facts, which is only done for a page. +func appDressedPage(items []session.DisplayEntry, all []appwire.Entry, from []int, start, end int, facts func(session.DisplayEntry) appwire.ToolFacts) ([]appwire.Entry, error) { page := append([]appwire.Entry{}, all[start:end]...) for i := range page { if start+i < len(from) && from[start+i] >= 0 { @@ -2234,7 +2380,7 @@ func appEntryPageWithFormed(id string, record session.Record, at time.Time, mark } } if len(wanted) == 0 { - return page, start > 0, nil + return page, nil } calls := make(map[string]session.DisplayEntry, len(wanted)) for _, item := range items { @@ -2247,7 +2393,7 @@ func appEntryPageWithFormed(id string, record session.Record, at time.Time, mark page[i].ToolFacts = facts(item) } } - return page, start > 0, nil + return page, nil } // appEntriesOf is a transcript as the app's entries, named by the rule the @@ -2794,6 +2940,9 @@ func (b *appBackend) Close() error { } wg.Wait() b.closeCollections() + // Kept-running processes stay up through a quit; services.stop_all is + // how a quit stops them (appservices.go). + b.closeServices() return nil } @@ -2862,7 +3011,7 @@ func (b *appBackend) sweepAgent(a *appAgent, now time.Time) { keep = live == nil || live.running || now.Sub(live.lastUse) < appAgentIdle }) // A member of an open crew stays held while the crew is open. - if !keep && !b.liveNeedsYou(live) && !b.crewKeeps(a.view.ID) { + if !keep && !b.liveNeedsYou(live) && !b.crewKeeps(a.view.ID) && !b.serviceKeeps(a.view.ID) { // The state the app reads does not move: the conversation is as // idle on disk as it was in memory. b.release(a) diff --git a/cmd/codeaf/appinbox.go b/cmd/codeaf/appinbox.go index 54740fe463..c22a62e69c 100644 --- a/cmd/codeaf/appinbox.go +++ b/cmd/codeaf/appinbox.go @@ -372,7 +372,7 @@ func (b *appBackend) AnswerInbox(id, option, text, why string) (time.Time, error } // ONE INBOX ITEM IS ONE QUESTION: an ask that came in a batch is // answered alone here, and its siblings stay in the Inbox. - if err := b.AnswerAskWith(agent, ask, appwire.AskAnswer{Option: option, Text: text, Why: why, BatchItem: ask}); err != nil { + if err := b.answerInboxItem(id, agent, ask, appwire.AskAnswer{Option: option, Text: text, Why: why, BatchItem: ask}); err != nil { in.locked(func() { delete(in.held, id) delete(in.from, id) @@ -412,6 +412,9 @@ func (b *appBackend) LaterInbox(id string, later bool) error { if !ok { return appwire.Errorf(appwire.CodeBadParams, "%s is not an Inbox item", id) } + if service, err := b.laterServiceItem(id, askID, later); service { + return err + } a, err := b.find(agent) if err != nil { return err diff --git a/cmd/codeaf/appnotices.go b/cmd/codeaf/appnotices.go new file mode 100644 index 0000000000..8a4a8c3b19 --- /dev/null +++ b/cmd/codeaf/appnotices.go @@ -0,0 +1,618 @@ +package main + +// ── codeaf app: THE ONE NOTICE EACH FAILURE EPISODE LEAVES IN ITS CHAT ───── +// +// A kept-running process's failure episode leaves one notice in the chat of +// the agent that started it (appservices.go). The notice is not in the +// conversation's journal — the journal is the model's transcript, and a line +// written into it between a person's turns is a line the model would then be +// told — so it is kept beside it, in a record of its own: +// +// /service-notices/.jsonl one notice a line, appended +// /service-notices/.index.json how far the record was read, and what it held +// +// THE RECORD IS CANONICAL AND NEVER CUT. A line is appended and synced, and +// nothing ever rewrites or drops a line that was finished, so a chat's history +// shows every episode it ever had, and each notice's link to its process's +// output — kept after a removal (internal/service/archive.go) — still opens. +// The only bytes this file ever takes back are the ones its own failed write +// left after the record's end in that same call; a fragment it finds there +// later — a crash in the middle of a write — is ended with a newline, never +// cut, so a notice that lacked only its newline is kept and anything else is +// one unreadable line a reading passes over. +// +// WHAT IS HELD IS BOUNDED. A page is read backwards from its cursor, one +// chunk at a time (appNoticeReader). An agent's index holds the newest episode +// of at most appNoticeServices of its services, and at most appNoticeOwners +// agents' indexes are held at once; a reading of the record holds one line. +// +// THE INDEX IS ONLY A SHORTCUT, and it says how far it goes: the offset of the +// end of the last line it has counted. Every question first reads whatever +// the record holds past that offset — after a crash between a line and its +// index, an index that could not be written, or a sync that failed after the +// line reached the file — so a line on the disk is always counted before a +// second one could be written. An index that is missing, unreadable, too +// long, or that does not end at a line's end of the record is thrown away and +// the record is read again from its start. A service the index had to let go +// of to stay bounded is looked for in the whole record. Each of those reads is +// linear in the record's length and holds one line at a time; it is paid in +// the caller, which is the supervisor's listener and the app's start. +// +// One app writes these files. Two processes appending to one record at once, +// or another program putting a FIFO where a record is between the look and +// the open, are not defended against. + +import ( + "bufio" + "bytes" + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "regexp" + "strconv" + "strings" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" +) + +const ( + appNoticeFolder = "service-notices" + // appNoticeChunk is how much of a record is read at a time. + appNoticeChunk = 64 << 10 + // appNoticeLine is the longest line kept; a notice's words are a short + // title, and a longer line is not one this file wrote. + appNoticeLine = 8 << 10 + // appNoticeServices is how many services' newest episodes one agent's + // index holds: four times the supervisor's sixteen a person may have + // running (internal/service/spec.go), so only services long removed are + // ever let go of. + appNoticeServices = 64 + // appNoticeOwners is how many agents' indexes are held at once. + appNoticeOwners = 32 + // appNoticeIndexMax is the longest index read; a full one is under 2KiB. + appNoticeIndexMax = 16 << 10 + appNoticeIndexV = 1 +) + +// appNoticeAgent is an agent id that can name a file: a conversation folder's +// own name. +var appNoticeAgent = regexp.MustCompile(`^[A-Za-z0-9][A-Za-z0-9._-]{0,127}$`) + +// appServiceNotice is one episode's notice in its agent's chat. +type appServiceNotice struct { + Agent string `json:"agent"` + Ref string `json:"ref"` + Episode string `json:"episode"` + Text string `json:"text"` + At time.Time `json:"at"` +} + +// id is the notice's entry id, the same live and read back. +func (n appServiceNotice) id() string { return "svc" + n.Ref + "." + n.Episode } + +func appServiceNoticeEntry(n appServiceNotice) appwire.Entry { + footer := appFooter(n.id(), n.Agent, 0, appwire.FooterServiceTrouble, n.Text, n.At) + footer.Ref = n.Ref + return footer +} + +// appEpisodeNumber is an episode id's service and number: "s3.12" is s3, 12. +func appEpisodeNumber(episode string) (string, int, bool) { + local, number, ok := strings.Cut(episode, ".") + n, err := strconv.Atoi(number) + return local, n, ok && err == nil && n > 0 && appServiceLocal.MatchString(local) +} + +// appLocalNumber is a service's own number: s12 is 12. +func appLocalNumber(local string) int { + n, _ := strconv.Atoi(strings.TrimPrefix(local, "s")) + return n +} + +// appNoticeIndex is one agent's shortcut: every finished line before Offset +// is counted in Newest, except the services numbered Floor or below that +// were let go of to keep it bounded. +type appNoticeIndex struct { + V int `json:"v"` + Offset int64 `json:"offset"` + Floor int `json:"floor,omitempty"` + Newest map[string]int `json:"newest"` + used uint64 +} + +func newAppNoticeIndex() *appNoticeIndex { + return &appNoticeIndex{V: appNoticeIndexV, Newest: map[string]int{}} +} + +// valid says a read-back index is one this file could have written. +func (ix *appNoticeIndex) valid() bool { + if ix.V != appNoticeIndexV || ix.Offset < 0 || ix.Floor < 0 || ix.Newest == nil || len(ix.Newest) > appNoticeServices { + return false + } + for local, n := range ix.Newest { + if n <= 0 || !appServiceLocal.MatchString(local) { + return false + } + } + return true +} + +// count takes one episode in. Past the bound, the lowest-numbered other +// service is let go of — service numbers only grow, so that is the one +// longest gone — and the floor rises to it, which sends a later question +// about it to the whole record. +func (ix *appNoticeIndex) count(local string, number int) { + if ix.Newest[local] >= number { + return + } + ix.Newest[local] = number + for len(ix.Newest) > appNoticeServices { + low, lowN := "", 0 + for other := range ix.Newest { + if n := appLocalNumber(other); other != local && (low == "" || n < lowN) { + low, lowN = other, n + } + } + delete(ix.Newest, low) + ix.Floor = max(ix.Floor, lowN) + } +} + +// appLineEpisode is a record line's service and episode number, read without +// its words or its time, and false for a line that is not this agent's notice. +func appLineEpisode(agent string, line []byte) (string, int, bool) { + var n struct{ Agent, Episode string } + if json.Unmarshal(line, &n) != nil || n.Agent != agent { + return "", 0, false + } + return appEpisodeNumber(n.Episode) +} + +// countLine takes one line of the agent's record in, if it is a notice. +func (ix *appNoticeIndex) countLine(agent string, line []byte) { + if local, number, ok := appLineEpisode(agent, line); ok { + ix.count(local, number) + } +} + +// appNoticeLog is the records of every agent, under one folder. +type appNoticeLog struct { + dir string + // mu orders the writers and guards the held indexes. + mu sync.Mutex + owners map[string]*appNoticeIndex + tick uint64 +} + +func newAppNoticeLog(dir string) *appNoticeLog { + return &appNoticeLog{dir: dir, owners: map[string]*appNoticeIndex{}} +} + +func (l *appNoticeLog) paths(agent string) (record, index string, err error) { + if !appNoticeAgent.MatchString(agent) { + return "", "", errors.New("not an agent id") + } + return filepath.Join(l.dir, agent+".jsonl"), filepath.Join(l.dir, agent+".index.json"), nil +} + +// appOpenPlain opens a file only if it is a plain one. It looks before it +// opens, so a FIFO or a device left at the path is never opened (and never +// waited on), and checks after, so what was opened is what was looked at. +func appOpenPlain(path string, flag int) (*os.File, error) { + before, err := os.Lstat(path) + if err == nil && !before.Mode().IsRegular() { + return nil, errors.New("not a plain file: " + path) + } + if err != nil && (!errors.Is(err, os.ErrNotExist) || flag&os.O_CREATE == 0) { + return nil, err + } + file, err := os.OpenFile(path, flag, 0o600) + if err != nil { + return nil, err + } + after, err := file.Stat() + if err == nil && (!after.Mode().IsRegular() || (before != nil && !os.SameFile(before, after))) { + err = errors.New("not a plain file: " + path) + } + if err != nil { + _ = file.Close() + return nil, err + } + return file, nil +} + +// readIndex is an agent's index from its file, or nil. Never more than +// appNoticeIndexMax is read, however large the file is. +func readIndex(path string) *appNoticeIndex { + file, err := appOpenPlain(path, os.O_RDONLY) + if err != nil { + return nil + } + defer file.Close() + raw, err := io.ReadAll(io.LimitReader(file, appNoticeIndexMax+1)) + if err != nil || len(raw) > appNoticeIndexMax { + return nil + } + ix := &appNoticeIndex{} + if json.Unmarshal(raw, ix) != nil || !ix.valid() { + return nil + } + return ix +} + +// heldLocked is an agent's index, held, read from its file, or new; the +// agent least recently asked about is let go of past appNoticeOwners. +func (l *appNoticeLog) heldLocked(agent, indexPath string) *appNoticeIndex { + l.tick++ + ix := l.owners[agent] + if ix == nil { + if ix = readIndex(indexPath); ix == nil { + ix = newAppNoticeIndex() + } + l.owners[agent] = ix + for len(l.owners) > appNoticeOwners { + oldest := "" + for other, held := range l.owners { + if other != agent && (oldest == "" || held.used < l.owners[oldest].used) { + oldest = other + } + } + delete(l.owners, oldest) + } + } + ix.used = l.tick + return ix +} + +// appScanLines calls fn with each finished line in [from, to), one held at a +// time, and answers the offset just past the last newline it read. A line +// longer than appNoticeLine is passed over without being held. +func appScanLines(file *os.File, from, to int64, fn func([]byte)) (int64, error) { + r := bufio.NewReaderSize(io.NewSectionReader(file, from, to-from), appNoticeChunk) + end, at, long := from, from, false + for { + part, err := r.ReadSlice('\n') + at += int64(len(part)) + if errors.Is(err, bufio.ErrBufferFull) { + long = true + continue + } + if err == nil { + if !long && len(part)-1 <= appNoticeLine { + fn(part[:len(part)-1]) + } + end, long = at, false + continue + } + if errors.Is(err, io.EOF) { + return end, nil + } + return end, err + } +} + +// caughtUpLocked opens an agent's record and brings its index up to the +// record's end, or answers a nil file when there is no record. When repair is +// set, an unfinished last line is ended with a newline and counted too, so a +// line appended next starts a line of its own. +// +// BYTES ARE COUNTED ONLY AFTER A SYNC SUCCEEDS, so nothing is called kept +// that the disk was not asked to keep; a sync that fails is the caller's +// error, and nothing is appended past bytes that were not counted. +func (l *appNoticeLog) caughtUpLocked(agent string, repair bool) (*appNoticeIndex, *os.File, error) { + record, indexPath, err := l.paths(agent) + if err != nil { + return nil, nil, err + } + ix := l.heldLocked(agent, indexPath) + flag := os.O_RDWR | os.O_APPEND + if repair { + if err := os.MkdirAll(l.dir, 0o700); err != nil { + return nil, nil, err + } + flag |= os.O_CREATE + } + _, statErr := os.Lstat(record) + file, err := appOpenPlain(record, flag) + if errors.Is(err, os.ErrNotExist) && !repair { + if ix.Offset != 0 || len(ix.Newest) > 0 { + *ix = appNoticeIndex{V: appNoticeIndexV, Newest: map[string]int{}, used: ix.used} + } + return ix, nil, nil + } + if err != nil { + return nil, nil, err + } + if errors.Is(statErr, os.ErrNotExist) { + appSyncDir(l.dir) + } + fail := func(err error) (*appNoticeIndex, *os.File, error) { + _ = file.Close() + return nil, nil, err + } + info, err := file.Stat() + if err != nil { + return fail(err) + } + size := info.Size() + // AN INDEX THAT DOES NOT END AT A LINE'S END OF THIS RECORD is not this + // record's, and the record is counted again from its start. + if ix.Offset > size || (ix.Offset > 0 && !appNewlineAt(file, ix.Offset-1)) { + *ix = appNoticeIndex{V: appNoticeIndexV, Newest: map[string]int{}, used: ix.used} + } + if size == ix.Offset { + return ix, file, nil + } + if err := file.Sync(); err != nil { + return fail(err) + } + end, err := appScanLines(file, ix.Offset, size, func(line []byte) { ix.countLine(agent, line) }) + if err != nil { + return fail(err) + } + ix.Offset = end + if end < size && repair { + // A FRAGMENT AFTER THE LAST NEWLINE is a write that never finished. + // It is ended, not cut: it may be a whole notice short only its + // newline, and it is then counted like any other line. + var fragment []byte + if size-end <= appNoticeLine { + fragment = make([]byte, size-end) + if _, err := file.ReadAt(fragment, end); err != nil { + return fail(err) + } + } + if _, err := file.Write([]byte{'\n'}); err != nil { + return fail(err) + } + if err := file.Sync(); err != nil { + return fail(err) + } + ix.countLine(agent, fragment) + ix.Offset = size + 1 + } + l.writeIndex(indexPath, ix) + return ix, file, nil +} + +// appNewlineAt says the record's byte at offset is a newline. +func appNewlineAt(file *os.File, offset int64) bool { + b := make([]byte, 1) + _, err := file.ReadAt(b, offset) + return err == nil && b[0] == '\n' +} + +// appSyncDir asks the disk to keep a folder's names; best-effort. +func appSyncDir(dir string) { + if d, err := os.Open(dir); err == nil { + _ = d.Sync() + _ = d.Close() + } +} + +// knownLocked says the record already holds this episode, or a later one of +// its service. A service the index let go of is looked for in the record. +func (l *appNoticeLog) knownLocked(agent string, ix *appNoticeIndex, file *os.File, local string, number int) (bool, error) { + if have, ok := ix.Newest[local]; ok || appLocalNumber(local) > ix.Floor { + return have >= number, nil + } + if file == nil { + return false, nil + } + have := 0 + _, err := appScanLines(file, 0, ix.Offset, func(line []byte) { + if l, num, ok := appLineEpisode(agent, line); ok && l == local { + have = max(have, num) + } + }) + if err != nil { + return false, err + } + if have > 0 { + ix.count(local, have) + } + return have >= number, nil +} + +// Noticed says the episode's notice is already in the record. A record that +// cannot be read says no, and the Append that follows says why. +func (l *appNoticeLog) Noticed(n appServiceNotice) bool { + local, number, ok := appEpisodeNumber(n.Episode) + if !ok { + return false + } + l.mu.Lock() + defer l.mu.Unlock() + ix, file, err := l.caughtUpLocked(n.Agent, false) + if err != nil { + return false + } + if file != nil { + defer file.Close() + } + known, err := l.knownLocked(n.Agent, ix, file, local, number) + return err == nil && known +} + +// Append writes one episode's notice, once, and answers nil only when the +// line is on the disk: a notice the record could not take is not called kept, +// and the next look at the episode tries again. A write that fails part-way +// takes its own bytes back; a sync that fails leaves the line uncounted, and +// the next look reads it back from the record before writing anything. +func (l *appNoticeLog) Append(n appServiceNotice) error { + local, number, ok := appEpisodeNumber(n.Episode) + if !ok { + return errors.New("not an episode") + } + raw, err := json.Marshal(n) + if err != nil { + return err + } + if len(raw) >= appNoticeLine { + return errors.New("the notice is too long") + } + l.mu.Lock() + defer l.mu.Unlock() + ix, file, err := l.caughtUpLocked(n.Agent, true) + if err != nil { + return err + } + defer file.Close() + if known, err := l.knownLocked(n.Agent, ix, file, local, number); err != nil || known { + return err + } + line := append(raw, '\n') + if wrote, err := appNoticeWrite(file, line); err != nil { + if wrote > 0 { + _ = file.Truncate(ix.Offset) + } + return err + } + if err := file.Sync(); err != nil { + return err + } + ix.Offset += int64(len(line)) + ix.count(local, number) + _, indexPath, _ := l.paths(n.Agent) + l.writeIndex(indexPath, ix) + return nil +} + +// appNoticeWrite is the one write of a line, a seam a test can fail. +var appNoticeWrite = func(file *os.File, line []byte) (int, error) { return file.Write(line) } + +// writeIndex writes an index whole, beside it and renamed over it, private to +// the person. A failure leaves the old one, which the next question reads +// past. +// +// IT IS NOT SYNCED, and need not be: every line it counts was synced before +// it was written, so it is never ahead of what the disk keeps, and an index a +// crash left empty or torn is refused and the record counted again. Syncing +// it would triple the cost of every notice for nothing a reading relies on. +func (l *appNoticeLog) writeIndex(path string, ix *appNoticeIndex) { + raw, err := json.Marshal(ix) + if err != nil { + return + } + temp, err := os.CreateTemp(l.dir, filepath.Base(path)+".*") + if err != nil { + return + } + _, writeErr := temp.Write(raw) + closeErr := temp.Close() + if errors.Join(writeErr, closeErr) != nil || os.Rename(temp.Name(), path) != nil { + _ = os.Remove(temp.Name()) + } +} + +// appNoticeReader reads one record backwards, a chunk at a time. +type appNoticeReader struct { + file *os.File + pos int64 + carry []byte + // lines are the complete lines of the chunk being read, oldest first. + lines [][]byte + peek *appServiceNotice +} + +// Reader opens an agent's record for reading from its end, or nil when it +// has none. +func (l *appNoticeLog) Reader(agent string) (*appNoticeReader, error) { + record, _, err := l.paths(agent) + if err != nil { + return nil, nil + } + file, err := appOpenPlain(record, os.O_RDONLY) + if errors.Is(err, os.ErrNotExist) { + return nil, nil + } + if err != nil { + return nil, err + } + info, err := file.Stat() + if err != nil { + _ = file.Close() + return nil, err + } + r := &appNoticeReader{file: file, pos: info.Size()} + // A last line still being written is not a line yet. + r.fill(true) + return r, nil +} + +func (r *appNoticeReader) Close() { _ = r.file.Close() } + +// fill reads the chunk before what has been read, until it has a line or the +// record's start. +func (r *appNoticeReader) fill(first bool) { + for len(r.lines) == 0 && (r.pos > 0 || len(r.carry) > 0) { + if r.pos == 0 { + r.lines, r.carry = [][]byte{r.carry}, nil + break + } + start := max(r.pos-appNoticeChunk, 0) + chunk := make([]byte, r.pos-start, int(r.pos-start)+len(r.carry)) + if _, err := r.file.ReadAt(chunk, start); err != nil && !errors.Is(err, io.EOF) { + r.pos, r.carry = 0, nil + return + } + r.pos = start + data := append(chunk, r.carry...) + parts := bytes.Split(data, []byte{'\n'}) + if first { + // What follows the last newline is a line not yet finished. + parts, first = parts[:len(parts)-1], false + } + if r.pos > 0 && len(parts) > 0 { + r.carry, parts = append([]byte(nil), parts[0]...), parts[1:] + if len(r.carry) > appNoticeLine { + r.carry = nil + } + } else { + r.carry = nil + } + for _, p := range parts { + if len(p) > 0 && len(p) <= appNoticeLine { + r.lines = append(r.lines, p) + } + } + } +} + +// Prev is the notice before the last one taken, newest first, and false at +// the record's start. A line that does not read as a notice is passed over. +func (r *appNoticeReader) Prev() (appServiceNotice, bool) { + if r.peek != nil { + n := *r.peek + r.peek = nil + return n, true + } + for { + r.fill(false) + if len(r.lines) == 0 { + return appServiceNotice{}, false + } + line := r.lines[len(r.lines)-1] + r.lines = r.lines[:len(r.lines)-1] + var n appServiceNotice + if json.Unmarshal(line, &n) == nil && n.Ref != "" && n.Episode != "" { + return n, true + } + } +} + +// Peek is Prev without taking it. +func (r *appNoticeReader) Peek() (appServiceNotice, bool) { + if r.peek != nil { + return *r.peek, true + } + n, ok := r.Prev() + if ok { + r.peek = &n + } + return n, ok +} diff --git a/cmd/codeaf/appnotices_test.go b/cmd/codeaf/appnotices_test.go new file mode 100644 index 0000000000..d27a8c0fcc --- /dev/null +++ b/cmd/codeaf/appnotices_test.go @@ -0,0 +1,361 @@ +package main + +import ( + "encoding/json" + "errors" + "os" + "path/filepath" + "runtime" + "strings" + "syscall" + "testing" + "time" +) + +// noticeAt is episode e of service s in agent a's chat. +func noticeAt(a string, s, e int) appServiceNotice { + base := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + local := "s" + itoa(s) + return appServiceNotice{Agent: a, Ref: a + ":" + local, Episode: local + "." + itoa(e), Text: "web is not answering", At: base.Add(time.Duration(s*1000+e) * time.Second)} +} + +// readBack is every notice id the record gives, oldest first. +func readBack(t *testing.T, l *appNoticeLog, agent string) []string { + t.Helper() + r, err := l.Reader(agent) + if err != nil { + t.Fatal(err) + } + if r == nil { + return nil + } + defer r.Close() + var ids []string + for { + n, ok := r.Prev() + if !ok { + break + } + ids = append([]string{n.id()}, ids...) + } + return ids +} + +func mustAppend(t *testing.T, l *appNoticeLog, n appServiceNotice) { + t.Helper() + if err := l.Append(n); err != nil { + t.Fatal(err) + } +} + +func countOf(ids []string, id string) int { + c := 0 + for _, have := range ids { + if have == id { + c++ + } + } + return c +} + +// TestAHugeIndexIsNeverReadWhole: a sparse gigabyte where the index should +// be is not loaded — the first question reads at most a few kilobytes of it, +// throws it away, and counts the record from its start instead. +func TestAHugeIndexIsNeverReadWhole(t *testing.T) { + dir := t.TempDir() + l := newAppNoticeLog(dir) + mustAppend(t, l, noticeAt("a1", 1, 1)) + _, indexPath, _ := l.paths("a1") + f, err := os.Create(indexPath) + if err != nil { + t.Fatal(err) + } + if err := f.Truncate(1 << 30); err != nil { + t.Fatal(err) + } + _ = f.Close() + + var before, after runtime.MemStats + runtime.GC() + runtime.ReadMemStats(&before) + again := newAppNoticeLog(dir) + known := again.Noticed(noticeAt("a1", 1, 1)) + runtime.ReadMemStats(&after) + if !known { + t.Fatal("a broken index hid the episode the record holds") + } + if grew := after.TotalAlloc - before.TotalAlloc; grew > 1<<20 { + t.Fatalf("one question allocated %d bytes over a 1GiB index", grew) + } + mustAppend(t, again, noticeAt("a1", 1, 1)) + if ids := readBack(t, again, "a1"); len(ids) != 1 { + t.Fatalf("the record holds %v", ids) + } +} + +// TestAFIFOIsNeverOpened: a FIFO at the record's or the index's path is +// refused at once, not waited on. +func TestAFIFOIsNeverOpened(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("no FIFOs") + } + dir := t.TempDir() + l := newAppNoticeLog(dir) + record, indexPath, _ := l.paths("a1") + if err := syscall.Mkfifo(record, 0o600); err != nil { + t.Skip(err) + } + if err := syscall.Mkfifo(indexPath, 0o600); err != nil { + t.Skip(err) + } + done := make(chan error, 1) + go func() { + if l.Noticed(noticeAt("a1", 1, 1)) { + done <- errors.New("a FIFO said noticed") + return + } + if err := l.Append(noticeAt("a1", 1, 1)); err == nil { + done <- errors.New("a FIFO took a notice") + return + } + if _, err := l.Reader("a1"); err == nil { + done <- errors.New("a FIFO was read") + return + } + done <- nil + }() + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(5 * time.Second): + t.Fatal("a FIFO was waited on") + } +} + +// TestAnUnfinishedLineIsEndedNotCut: a crash in the middle of a write leaves +// a fragment after the last newline. The next notice starts a line of its +// own, every finished notice before it is still read back, and a fragment that +// was a whole notice short only its newline is kept and not written twice. +func TestAnUnfinishedLineIsEndedNotCut(t *testing.T) { + for _, tc := range []struct { + name string + fragment func(raw string) string + }{ + {"half a line", func(raw string) string { return raw[:len(raw)/2] }}, + {"all but its newline", func(raw string) string { return raw }}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + l := newAppNoticeLog(dir) + for e := 1; e <= 3; e++ { + mustAppend(t, l, noticeAt("a1", 1, e)) + } + record, _, _ := l.paths("a1") + raw, _ := os.ReadFile(record) + lines := strings.SplitAfter(string(raw), "\n") + crashed := noticeAt("a1", 2, 1) + whole := `{"agent":"a1","ref":"a1:s2","episode":"s2.1","text":"web is not answering","at":"` + crashed.At.Format(time.RFC3339Nano) + `"}` + f, _ := os.OpenFile(record, os.O_WRONLY|os.O_APPEND, 0) + _, _ = f.WriteString(tc.fragment(whole)) + _ = f.Close() + + // A question does not write; the append that follows ends the + // fragment, and counts it when it is a whole notice. + again := newAppNoticeLog(dir) + if again.Noticed(crashed) { + t.Fatal("an unfinished line is known as noticed") + } + mustAppend(t, again, crashed) + mustAppend(t, again, noticeAt("a1", 3, 1)) + ids := readBack(t, newAppNoticeLog(dir), "a1") + want := []string{noticeAt("a1", 1, 1).id(), noticeAt("a1", 1, 2).id(), noticeAt("a1", 1, 3).id(), crashed.id(), noticeAt("a1", 3, 1).id()} + if strings.Join(ids, " ") != strings.Join(want, " ") { + t.Fatalf("read back\n %v\nwant\n %v", ids, want) + } + after, _ := os.ReadFile(record) + if !strings.HasPrefix(string(after), strings.Join(lines, "")+tc.fragment(whole)+"\n") { + t.Fatalf("the record's earlier bytes changed:\n%s", after) + } + }) + } +} + +// TestAFailedWriteTakesBackOnlyItsOwnBytes: a write that fails part-way +// leaves the record as it was, says so, and the next try keeps the notice once. +func TestAFailedWriteTakesBackOnlyItsOwnBytes(t *testing.T) { + dir := t.TempDir() + l := newAppNoticeLog(dir) + mustAppend(t, l, noticeAt("a1", 1, 1)) + record, _, _ := l.paths("a1") + before, _ := os.ReadFile(record) + real := appNoticeWrite + t.Cleanup(func() { appNoticeWrite = real }) + appNoticeWrite = func(f *os.File, line []byte) (int, error) { + n, _ := f.Write(line[:len(line)/2]) + return n, errors.New("disk full") + } + if err := l.Append(noticeAt("a1", 1, 2)); err == nil { + t.Fatal("a failed write was called kept") + } + if now, _ := os.ReadFile(record); string(now) != string(before) { + t.Fatalf("a failed write left\n%s", now) + } + if l.Noticed(noticeAt("a1", 1, 2)) { + t.Fatal("a failed write is known as noticed") + } + appNoticeWrite = real + mustAppend(t, l, noticeAt("a1", 1, 2)) + mustAppend(t, l, noticeAt("a1", 1, 2)) + ids := readBack(t, newAppNoticeLog(dir), "a1") + if len(ids) != 2 || ids[1] != noticeAt("a1", 1, 2).id() { + t.Fatalf("read back %v", ids) + } +} + +// TestAQuietOldEpisodeIsNotWrittenTwiceAfterAStaleIndex: a service's episode +// noticed long ago and still open, then eight hundred other notices (far past +// any tail a reading might have stopped at), then a relaunch with an index +// that is behind, missing, or lying about where the record ends. The old +// episode is still known, and its notice is never written a second time. +func TestAQuietOldEpisodeIsNotWrittenTwiceAfterAStaleIndex(t *testing.T) { + for _, tc := range []struct { + name string + index func(path string, stale []byte) + }{ + {"behind", func(path string, stale []byte) { _ = os.WriteFile(path, stale, 0o600) }}, + {"missing", func(path string, _ []byte) { _ = os.Remove(path) }}, + {"ends mid-line", func(path string, _ []byte) { + _ = os.WriteFile(path, []byte(`{"v":1,"offset":7,"newest":{}}`), 0o600) + }}, + {"not an index", func(path string, _ []byte) { _ = os.WriteFile(path, []byte(`{"s1":99}`), 0o600) }}, + } { + t.Run(tc.name, func(t *testing.T) { + dir := t.TempDir() + l := newAppNoticeLog(dir) + quiet := noticeAt("a1", 1, 1) + mustAppend(t, l, quiet) + _, indexPath, _ := l.paths("a1") + stale, err := os.ReadFile(indexPath) + if err != nil { + t.Fatal(err) + } + for e := 1; e <= 800; e++ { + mustAppend(t, l, noticeAt("a1", 2, e)) + } + record, _, _ := l.paths("a1") + if info, _ := os.Stat(record); info.Size() <= appNoticeChunk { + t.Fatalf("the record is only %d bytes", info.Size()) + } + tc.index(indexPath, stale) + + again := newAppNoticeLog(dir) + if !again.Noticed(quiet) { + t.Fatal("the quiet old episode is not known after the relaunch") + } + mustAppend(t, again, quiet) + mustAppend(t, again, noticeAt("a1", 2, 800)) + ids := readBack(t, again, "a1") + if len(ids) != 801 || countOf(ids, quiet.id()) != 1 || ids[0] != quiet.id() { + t.Fatalf("read back %d notices, the quiet one %d times, oldest %s", len(ids), countOf(ids, quiet.id()), ids[0]) + } + }) + } +} + +// TestALineTheIndexMissedIsCounted: the line reached the record but its index +// never did — a crash, or a sync that failed after the write — and the next +// look counts it rather than writing it again. +func TestALineTheIndexMissedIsCounted(t *testing.T) { + dir := t.TempDir() + l := newAppNoticeLog(dir) + mustAppend(t, l, noticeAt("a1", 1, 1)) + record, _, _ := l.paths("a1") + f, _ := os.OpenFile(record, os.O_WRONLY|os.O_APPEND, 0) + _, _ = f.WriteString(`{"agent":"a1","ref":"a1:s1","episode":"s1.2","text":"down","at":"2026-10-01T00:00:00Z"}` + "\n") + _ = f.Close() + // The same log, holding an index that has not seen the line. + if !l.Noticed(noticeAt("a1", 1, 2)) { + t.Fatal("a line past the index is not counted") + } + mustAppend(t, l, noticeAt("a1", 1, 2)) + if ids := readBack(t, l, "a1"); len(ids) != 2 { + t.Fatalf("read back %v", ids) + } +} + +// TestWhatIsHeldStaysBounded: two hundred services in one chat and forty +// chats keep the held indexes within their bounds, and the index file small, +// while the record keeps every notice and a service let go of long ago is +// still known — before and after a relaunch. +func TestWhatIsHeldStaysBounded(t *testing.T) { + dir := t.TempDir() + l := newAppNoticeLog(dir) + for s := 1; s <= 200; s++ { + mustAppend(t, l, noticeAt("a1", s, 1)) + } + for a := 0; a < 40; a++ { + mustAppend(t, l, noticeAt("b"+itoa(a), 1, 1)) + } + if len(l.owners) > appNoticeOwners { + t.Fatalf("%d indexes held", len(l.owners)) + } + for agent, ix := range l.owners { + if len(ix.Newest) > appNoticeServices { + t.Fatalf("%s holds %d services", agent, len(ix.Newest)) + } + } + _, indexPath, _ := l.paths("a1") + if info, err := os.Stat(indexPath); err != nil || info.Size() > 4<<10 { + t.Fatalf("the index: %v, %v", info, err) + } + for _, log := range []*appNoticeLog{l, newAppNoticeLog(dir)} { + for _, s := range []int{1, 100, 200} { + n := noticeAt("a1", s, 1) + if !log.Noticed(n) { + t.Fatalf("s%d is not known", s) + } + mustAppend(t, log, n) + } + if log.Noticed(noticeAt("a1", 1, 2)) || log.Noticed(noticeAt("a1", 201, 1)) { + t.Fatal("an episode never written is known") + } + if ids := readBack(t, log, "a1"); len(ids) != 200 || ids[0] != noticeAt("a1", 1, 1).id() || ids[199] != noticeAt("a1", 200, 1).id() { + t.Fatalf("read back %d notices", len(ids)) + } + } + for a := 0; a < 40; a++ { + if ids := readBack(t, l, "b"+itoa(a)); len(ids) != 1 { + t.Fatalf("b%d holds %v", a, ids) + } + } + if entries, _ := filepath.Glob(filepath.Join(dir, "*.index.json.*")); len(entries) > 0 { + t.Fatalf("index temporaries left behind: %v", entries) + } +} + +// BenchmarkAColdCountOfTenThousandNotices is the price of a first question +// about a chat whose index is gone: the whole record, ten thousand notices +// over forty services, counted one line at a time. +func BenchmarkAColdCountOfTenThousandNotices(b *testing.B) { + dir := b.TempDir() + l := newAppNoticeLog(dir) + record, indexPath, _ := l.paths("a1") + var sb strings.Builder + for i := 1; i <= 10000; i++ { + raw, _ := json.Marshal(noticeAt("a1", i%40+1, i)) + sb.Write(append(raw, '\n')) + } + if err := os.WriteFile(record, []byte(sb.String()), 0o600); err != nil { + b.Fatal(err) + } + b.SetBytes(int64(sb.Len())) + b.ReportAllocs() + for b.Loop() { + _ = os.Remove(indexPath) + if !newAppNoticeLog(dir).Noticed(noticeAt("a1", 1, 40)) { + b.Fatal("not counted") + } + } +} diff --git a/cmd/codeaf/appports.go b/cmd/codeaf/appports.go new file mode 100644 index 0000000000..84304aaa25 --- /dev/null +++ b/cmd/codeaf/appports.go @@ -0,0 +1,55 @@ +package main + +// ── codeaf app: PORT OBSERVATION (V77) ───────────────────────────────────── +// +// ports.inspect and ports.suggest are answered by internal/ports, which binds +// and releases probe sockets and, for a taken port, asks the system lsof for +// the holder. Both are observations: nothing is reserved, and a port held by +// another program is never touched. The calls run on the wire's per-call +// goroutine under a deadline, never on a reader or a UI thread. + +import ( + "context" + "errors" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/ports" +) + +var _ appwire.Porter = (*appBackend)(nil) + +// appPortChecker is the process's one checker, so its concurrency budget is +// shared by every conversation's calls. +var appPortChecker = ports.New() + +func (b *appBackend) InspectPort(ctx context.Context, port int) (appwire.PortInspection, error) { + got, err := appPortChecker.Inspect(ctx, port) + if err != nil { + return appwire.PortInspection{}, appPortError(err) + } + out := appwire.PortInspection{Port: got.Port, State: string(got.State), CheckedAt: got.CheckedAt, Detail: got.Detail} + if got.Holder != nil { + out.Holder = &appwire.PortHolder{PID: got.Holder.PID, Name: got.Holder.Name} + } + return out, nil +} + +func (b *appBackend) SuggestPort(ctx context.Context, after int) (appwire.PortSuggestion, error) { + got, err := appPortChecker.Suggest(ctx, after) + if err != nil { + return appwire.PortSuggestion{}, appPortError(err) + } + return appwire.PortSuggestion{Port: got.Port, CheckedAt: got.CheckedAt, CheckedCount: got.CheckedCount}, nil +} + +func appPortError(err error) error { + switch { + case errors.Is(err, ports.ErrInvalid): + return appwire.ErrPortInvalid + case errors.Is(err, ports.ErrNoCandidate): + return appwire.ErrPortNoCandidate + case errors.Is(err, ports.ErrBusy): + return appwire.ErrPortBusy + } + return err +} diff --git a/cmd/codeaf/appservices.go b/cmd/codeaf/appservices.go new file mode 100644 index 0000000000..a14bd3cf4f --- /dev/null +++ b/cmd/codeaf/appservices.go @@ -0,0 +1,924 @@ +package main + +// ── codeaf app: KEPT-RUNNING SERVICES (V77) ──────────────────────────────── +// +// ONE SUPERVISOR FOR THE WHOLE APP. A command a person agreed to keep running +// belongs to the machine, not to the conversation that asked for it: it keeps +// running while that conversation is let go, while the app is quit, and after +// codeaf crashes. So it is held by one [service.Supervisor] at +// /services, opened once by the app process and handed to every +// conversation the app assembles ([appServiceDoor], read by assembleEngine). A +// second codeaf holding the store already is answered with the capability +// absent here, never with a second supervisor over the same processes. +// +// A service is listed with its agent's other processes, as a process whose +// `service` is set (appwire/services.go), and every change to it is said as +// process.changed. Its id is ":s". +// +// A FAILURE EPISODE IS ONE INBOX LINE AND ONE NOTICE. The supervisor names +// each run of trouble ([service.Row.Episode]); the first time the app hears of +// an open one it adds one Inbox line and leaves one notice in the chat that +// started it, and every later change of that episode updates the same line. +// When the episode closes — healthy again, or stopped — the line moves to done, +// which is the Inbox's Today. Nothing here raises an OS notification: that is +// the window's, reading the same events. +// +// What the app quits with is the person's choice: services.stop_all first +// stops every service, and a quit without it leaves them running for the +// next codeaf to take back. + +import ( + "context" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "regexp" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/guard" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/redact" + "github.com/Agent-Field/codeaf/internal/service" + "github.com/Agent-Field/codeaf/internal/session" +) + +const ( + // appServiceStopAllWait bounds services.stop_all: every grace runs at + // once, so this is one grace, one kill and room to spare. + appServiceStopAllWait = service.StopGrace + 5*time.Second + // appServiceFollowRead is the most of a followed service's output read in + // one look, and appServiceFollowers how many may follow one service. + appServiceFollowRead = 64 << 10 + appServiceFollowers = 8 + // appServiceLineCap is the longest line a follow sends. + appServiceLineCap = 4 << 10 + // appServiceClosedBy and appServiceQuitBy are what a stopped service says + // stopped it when the person closed its agent, or quit with stop all. + appServiceClosedBy = "agent closed" + appServiceQuitBy = "quit" +) + +// appServiceDoor is the app's one supervisor, nil while kept-running +// processes are absent. assembleEngine hands it to every conversation it +// builds in this process. +var appServiceDoor atomic.Pointer[service.Supervisor] + +// appServiceLocal is an agent's own name for a service. +var appServiceLocal = regexp.MustCompile(`^s[1-9][0-9]{0,9}$`) + +// openAppServices opens the app's supervisor, or answers nil — the capability +// absent — when this platform cannot run one or another codeaf holds it. Why +// is written to the app's log only. +func openAppServices() *service.Supervisor { + rt := service.PlatformRuntime() + if rt == nil { + return nil + } + supervisor, err := service.Open(service.Options{Dir: home.Join("services"), Runtime: rt}) + if err != nil { + fmt.Fprintln(os.Stderr, "codeaf app: processes cannot be kept running:", err) + return nil + } + return supervisor +} + +// appServices is the backend's half of the supervisor: what it has said, the +// open Inbox lines and the follows. +type appServices struct { + sup *service.Supervisor + since time.Time + unhear func() + // order puts one service's episode moves in sequence: the listener's + // and the replay of what was open when the app started. + order sync.Mutex + mu sync.Mutex + closed bool + lines map[string]appServiceLine + // noticed is the episode each service's kept notice is for, said the + // one said live; notices is the record they are kept in. + noticed map[string]string + said map[string]string + notices *appNoticeLog + follows map[string]*appServiceFollow +} + +// locked runs fn holding svc.mu, and ordered holding svc.order: every +// critical section here goes through one of them (internal/guard's lock law). +func (svc *appServices) locked(fn func()) { + svc.mu.Lock() + defer svc.mu.Unlock() + fn() +} + +func (svc *appServices) ordered(fn func()) { + svc.order.Lock() + defer svc.order.Unlock() + fn() +} + +// appServiceFollowGate runs between a follow finding the supervisor and +// asking to be admitted; nil but in a test that holds a follow there. +var appServiceFollowGate func() + +// appServiceLine is the one Inbox line of a service's open episode. +type appServiceLine struct { + episode string + item string + ask appwire.Ask +} + +// appServiceFollow is one service's output being followed, by how many. +type appServiceFollow struct { + consumers int + stop chan struct{} + done chan struct{} +} + +// attachServices gives the backend the app's supervisor and starts hearing +// it. A nil supervisor leaves every services.* refused as unknown. +func (b *appBackend) attachServices(sup *service.Supervisor) { + if sup == nil { + return + } + svc := &appServices{sup: sup, since: b.now(), lines: map[string]appServiceLine{}, noticed: map[string]string{}, said: map[string]string{}, notices: b.noticeLog(), follows: map[string]*appServiceFollow{}} + b.locked(func() { b.services = svc }) + svc.unhear = sup.Subscribe(b.serviceSaw) + // WHAT WAS ALREADY IN TROUBLE IS IN THE INBOX FROM THE START, not from + // the next check ten seconds on. + for _, row := range sup.All() { + svc.ordered(func() { b.serviceEpisode(svc, row, appServiceProcess(row, false), false) }) + } +} + +// servicesOf is the backend's supervisor half, or an unknown_method refusal. +func (b *appBackend) servicesOf() (*appServices, error) { + var svc *appServices + b.locked(func() { svc = b.services }) + if svc == nil { + return nil, appwire.Errorf(appwire.CodeUnknownMethod, "processes cannot be kept running here") + } + return svc, nil +} + +// closeServices stops hearing the supervisor, ends every follow and lets the +// supervisor go, leaving every service running ([service.Supervisor.Close]). +func (b *appBackend) closeServices() { + var svc *appServices + b.locked(func() { svc, b.services = b.services, nil }) + if svc == nil { + return + } + svc.unhear() + svc.locked(func() { + svc.closed = true + for id, f := range svc.follows { + close(f.stop) + delete(svc.follows, id) + } + }) + // A TAIL IS TOLD TO GO AND NOT WAITED FOR. One may be inside an Emit the + // wire is not taking, and a quit must not wait on a slow window; it reads + // stop before every send and goes the moment that Emit returns. + svc.sup.Close() + appServiceDoor.CompareAndSwap(svc.sup, nil) +} + +// appServiceID is a wire id's agent and the supervisor's id, for a service. +func appServiceID(id string) (agent, local string, ok bool) { + agent, local, ok = appSplitID(id) + return agent, local, ok && appServiceLocal.MatchString(local) +} + +// appServiceError keeps the supervisor's words and gives them the wire's code. +func appServiceError(err error) error { + switch { + case err == nil: + return nil + case errors.Is(err, service.ErrNotFound): + return appwire.Errorf(appwire.CodeNotFound, "%s", err.Error()) + case errors.Is(err, service.ErrClosed): + return appwire.Errorf(appwire.CodeClosed, "%s", err.Error()) + case errors.Is(err, service.ErrRunning), errors.Is(err, service.ErrStopping), errors.Is(err, service.ErrFull): + return appwire.Errorf(appwire.CodeBusy, "%s", err.Error()) + } + return appwire.Errorf(appwire.CodeEngine, "%s", redact.Secrets(err.Error())) +} + +// appServiceProcess is one service's row as a process on the wire. +func appServiceProcess(row service.Row, removed bool) appwire.Process { + process := appwire.Process{ + ID: row.Owner + ":" + row.ID, Agent: row.Owner, Title: redact.Secrets(row.Name), Command: redact.Secrets(row.Command), + Reason: redact.Secrets(row.Reason), LastLine: redact.Secrets(appLastLine(row.LogPath)), + EndedAt: row.EndedAt, ExitCode: row.ExitCode, + } + if row.StartedAt != nil { + process.StartedAt = *row.StartedAt + } else { + process.StartedAt = row.From.At + } + switch { + case row.Running || row.Stopping || row.State == service.StateStarting || row.State == service.StateRestarting: + // OWNED OR ON ITS WAY IS RUNNING: a service between restarts, or + // whose stop could not prove its process gone, is still active. + process.State, process.EndedAt, process.ExitCode = appwire.ProcessRunning, nil, nil + case row.State == service.StateStopped: + process.State = appwire.ProcessStopped + case row.State == service.StateFailed || row.State == service.StateGaveUp: + process.State = appwire.ProcessFailed + default: + process.State = appwire.ProcessEnded + } + s := &appwire.Service{ + Name: redact.Secrets(row.Name), Dir: row.Dir, Timing: appServiceTiming(), + Health: appwire.ServiceHealth{Kind: string(row.Health.Kind), Port: row.Health.Port, URL: redact.Secrets(row.Health.URL), Cmd: redact.Secrets(row.Health.Cmd), Words: redact.Secrets(row.HealthWords)}, + OpenURL: redact.Secrets(row.OpenURL), Policy: string(row.Policy), Lifecycle: string(row.State), Reason: process.Reason, + Running: row.Running, Stopping: row.Stopping, PID: row.PID, StartedHow: row.StartedHow, + StartedBy: appwire.ServiceOrigin{Label: redact.Secrets(row.From.Label), Turn: row.From.Turn, CallID: row.From.CallID, At: row.From.At}, + Restarts: row.Restarts, RestartLimit: row.RestartLimit, BackoffUntil: row.BackoffUntil, + ReAdopted: row.ReAdopted, ReAdoptedAt: row.ReAdoptedAt, PortConflict: row.PortConflict, + Episode: row.Episode, EpisodeOpen: row.EpisodeOpen, EpisodeReason: redact.Secrets(row.EpisodeReason), + EpisodeStartedAt: row.EpisodeStartedAt, EpisodeResolvedAt: row.EpisodeResolvedAt, + StoppedBy: row.StoppedBy, OutputTruncated: row.OutputTruncated, Removed: removed, + } + if row.Check != nil { + s.Check = &appwire.ServiceCheck{OK: row.Check.OK, At: row.Check.At, Fails: row.Check.Fails, Detail: redact.Secrets(row.Check.Detail)} + } + process.Service = s + return process +} + +// serviceProcesses is one agent's services as processes, for processes.list. +func (b *appBackend) serviceProcesses(agent string) []appwire.Process { + svc, err := b.servicesOf() + if err != nil { + return nil + } + var out []appwire.Process + for _, row := range svc.sup.List(agent) { + out = append(out, appServiceProcess(row, false)) + } + return out +} + +// serviceKeeps says an agent has a service up or on its way, which keeps its +// conversation held through the idle sweep. +func (b *appBackend) serviceKeeps(agent string) bool { + svc, err := b.servicesOf() + if err != nil { + return false + } + for _, row := range svc.sup.List(agent) { + if row.Running || row.Stopping || row.State == service.StateStarting || row.State == service.StateRestarting { + return true + } + } + return false +} + +// fenceAgentServices holds every new start off an agent being closed, until +// the returned func ([service.Supervisor.Fence]). +func (b *appBackend) fenceAgentServices(agent string) func() { + svc, err := b.servicesOf() + if err != nil { + return func() {} + } + return svc.sup.Fence(agent) +} + +// stopAgentServices stops every service of an agent the person closed. +func (b *appBackend) stopAgentServices(agent string) error { + svc, err := b.servicesOf() + if err != nil { + return nil + } + ctx, cancel := context.WithTimeout(context.Background(), appServiceStopAllWait) + defer cancel() + _, err = svc.sup.StopOwnerWait(ctx, agent, appServiceClosedBy) + return appServiceError(err) +} + +// ── what the supervisor says ──────────────────────────────────────────────── + +// serviceSaw says one change on the wire, and keeps its episode's Inbox line +// and notice in step. It runs on the supervisor's listener and returns +// promptly: every write it makes is a queued event. +func (b *appBackend) serviceSaw(event service.Event) { + defer guard.Recover("app/service") + svc, err := b.servicesOf() + if err != nil { + return + } + var closed bool + svc.locked(func() { closed = svc.closed }) + if closed { + return + } + process := appServiceProcess(event.Row, event.Removed) + b.emit.Emit(appwire.EventProcessChanged, appwire.ProcessChanged{Process: process}) + if event.Removed { + b.serviceFollowEnd(svc, process.ID) + } + svc.ordered(func() { b.serviceEpisode(svc, event.Row, process, event.Removed) }) +} + +// serviceEpisode moves one service's Inbox line and notice. +func (b *appBackend) serviceEpisode(svc *appServices, row service.Row, process appwire.Process, removed bool) { + key := process.ID + now := b.now() + // AN EPISODE ENDS WHEN THE SUPERVISOR SAYS IT ENDED, so a late or + // replayed event never moves its line's time to the moment it was heard. + resolved := now + if row.EpisodeResolvedAt != nil { + resolved = *row.EpisodeResolvedAt + } + var line appServiceLine + var lined bool + var said string + svc.locked(func() { + line, lined = svc.lines[key] + said = svc.said[key] + }) + // A line whose episode is over, or replaced by a newer one, is resolved. + if lined && (line.episode != row.Episode || !row.EpisodeOpen || removed) { + by := appwire.ResolvedRecovered + if row.State == service.StateStopped || removed || line.episode != row.Episode { + by = appwire.ResolvedStopped + } + at := now + if line.episode == row.Episode { + at = resolved + } + b.serviceLineResolved(svc, key, line, by, at) + lined = false + } + if row.Episode == "" || removed { + if removed { + svc.locked(func() { + delete(svc.said, key) + delete(svc.noticed, key) + }) + } + return + } + ask := appServiceAsk(row, process) + if row.EpisodeOpen { + b.serviceNotice(svc, row, key, ask.Title) + if lined && line.ask.Title == ask.Title && line.ask.Detail == ask.Detail { + return + } + svc.locked(func() { + svc.lines[key] = appServiceLine{episode: row.Episode, item: appInboxID(row.Owner, ask.ID), ask: ask} + }) + if b.inbox != nil { + b.inboxOpened(ask) + } + return + } + // AN EPISODE THAT OPENED AND CLOSED UNHEARD — a failure that recovered + // between two looks — still gets its one line, straight into done, and its + // one notice. One that closed before this app opened is history, not news. + if row.EpisodeResolvedAt == nil || row.EpisodeResolvedAt.Before(svc.since) { + return + } + b.serviceNotice(svc, row, key, ask.Title) + if said != row.Episode && b.inbox != nil { + b.inboxOpened(ask) + by := appwire.ResolvedRecovered + if row.State == service.StateStopped { + by = appwire.ResolvedStopped + } + b.serviceLineResolved(svc, key, appServiceLine{episode: row.Episode, item: appInboxID(row.Owner, ask.ID), ask: ask}, by, resolved) + } +} + +// serviceNotice leaves an episode's one notice in its chat: said once live, +// and kept in the agent's record (appnotices.go). A notice the record already +// holds — an episode still open when the app was opened again — is in the +// chat's history and is not said again. One the record could not take is said +// live once and written again at the episode's next change, until it is kept. +func (b *appBackend) serviceNotice(svc *appServices, row service.Row, key, text string) { + var kept, said bool + svc.locked(func() { kept, said = svc.noticed[key] == row.Episode, svc.said[key] == row.Episode }) + if kept { + return + } + at := b.now() + if row.EpisodeStartedAt != nil { + at = *row.EpisodeStartedAt + } + notice := appServiceNotice{Agent: row.Owner, Ref: key, Episode: row.Episode, Text: text, At: at} + if svc.notices.Noticed(notice) { + svc.locked(func() { svc.noticed[key], svc.said[key] = row.Episode, row.Episode }) + return + } + if err := svc.notices.Append(notice); err != nil { + fmt.Fprintln(os.Stderr, "codeaf app: a kept-running process's notice is not kept yet:", err) + } else { + svc.locked(func() { svc.noticed[key] = row.Episode }) + } + if !said { + svc.locked(func() { svc.said[key] = row.Episode }) + b.emit.Emit(appwire.EventEntryAdded, appwire.EntryAdded{Entry: appServiceNoticeEntry(notice)}) + } +} + +// appServiceAsk is a service's trouble as the Inbox's line. +func appServiceAsk(row service.Row, process appwire.Process) appwire.Ask { + name := process.Title + title := name + " is not answering" + switch row.State { + case service.StateRestarting: + title = fmt.Sprintf("%s stopped · restarting, %d of %d", name, row.Restarts, row.RestartLimit) + case service.StateGaveUp: + title = fmt.Sprintf("%s stopped · not restarted again after %d tries", name, row.RestartLimit) + case service.StateFailed, service.StateNotRunning: + title = name + " stopped" + case service.StateHealthy: + title = name + " is healthy again" + } + detail := redact.Secrets(row.EpisodeReason) + if row.Check != nil && !row.Check.OK && row.Check.Detail != "" { + detail = redact.Secrets(row.Check.Detail) + } + return appwire.Ask{ + ID: "service:" + row.Episode, Agent: row.Owner, Kind: appwire.AskQuestion, Subkind: appwire.SubkindServiceHealth, + Title: title, Detail: detail, + Options: []appwire.AskOption{{ID: appwire.OptionRestart, Label: "Restart"}, {ID: appwire.OptionStop, Label: "Stop"}}, + Subject: &appwire.AskSubject{Kind: appwire.SubjectService, Ref: process.ID}, + } +} + +// serviceLineResolved moves a service's line to done and says so. +func (b *appBackend) serviceLineResolved(svc *appServices, key string, line appServiceLine, by string, at time.Time) { + svc.locked(func() { + if have, ok := svc.lines[key]; ok && have.item == line.item { + delete(svc.lines, key) + } + }) + in := b.inbox + if in == nil { + return + } + receipt := "Healthy again · " + at.Local().Format("15:04") + if by == appwire.ResolvedStopped { + receipt = "Stopped · " + at.Local().Format("15:04") + } + moved := false + in.locked(func() { + item := in.items[line.item] + if item == nil { + return + } + moved = true + if held := in.held[line.item]; held != nil && !held.sent { + held.timer.Stop() + } + delete(in.held, line.item) + delete(in.items, line.item) + delete(in.closing, line.item) + delete(in.from, line.item) + for i, have := range in.order { + if have == line.item { + in.order = append(in.order[:i], in.order[i+1:]...) + break + } + } + item.List = appwire.InboxDone + item.Answered = &appwire.InboxAnswered{By: by, At: at, Text: receipt} + in.done = append(in.done, *item) + if len(in.done) > appInboxDone { + in.done = in.done[len(in.done)-appInboxDone:] + } + }) + if !moved { + return + } + in.next.Emit(appwire.EventInboxResolved, appwire.InboxResolvedEvent{ID: line.item, By: by, Receipt: receipt}) + b.inboxCounts() +} + +// answerInboxItem sends an Inbox answer once its hold is over: a service's +// line to the supervisor, anything else through the conversation's door. +func (b *appBackend) answerInboxItem(id, agent, ask string, answer appwire.AskAnswer) error { + episode, ok := strings.CutPrefix(ask, "service:") + if !ok { + return b.AnswerAskWith(agent, ask, answer) + } + local, _, _ := strings.Cut(episode, ".") + process := agent + ":" + local + var err error + switch answer.Option { + case appwire.OptionRestart: + _, err = b.RestartService(process) + case appwire.OptionStop: + _, err = b.StopService(process) + default: + err = appwire.Errorf(appwire.CodeBadParams, "a kept-running process is answered restart or stop") + } + // A RESTART LEAVES THE LINE WAITING until the service is healthy again, + // and it may be answered again in the meantime. + if in := b.inbox; in != nil { + in.locked(func() { + delete(in.held, id) + delete(in.from, id) + }) + } + return err +} + +// laterServiceItem puts a service's line aside, or back. It answers false +// for an item that is not a service's. +func (b *appBackend) laterServiceItem(id, ask string, later bool) (bool, error) { + if !strings.HasPrefix(ask, "service:") { + return false, nil + } + in := b.inbox + var item appwire.InboxItem + found := false + in.locked(func() { + if have := in.items[id]; have != nil { + have.Ask.Later = later + have.List = appwire.InboxWaiting + if later { + have.List = appwire.InboxLater + } + item, found = *have, true + } + }) + if !found { + return true, appwire.Errorf(appwire.CodeNotFound, "the Inbox item %s is no longer waiting", id) + } + in.next.Emit(appwire.EventInboxAdded, appwire.InboxAddedEvent{Item: item}) + b.inboxCounts() + return true, nil +} + +// ── appwire.Servicer ──────────────────────────────────────────────────────── + +func (b *appBackend) serviceRow(id string) (*appServices, string, string, error) { + svc, err := b.servicesOf() + if err != nil { + return nil, "", "", err + } + agent, local, ok := appServiceID(id) + if !ok { + return nil, "", "", appwire.Errorf(appwire.CodeBadParams, "%s is not a kept-running process", id) + } + return svc, agent, local, nil +} + +// ListServices is one agent's services, or every agent's. +func (b *appBackend) ListServices(agent string) ([]appwire.Process, error) { + svc, err := b.servicesOf() + if err != nil { + return nil, err + } + rows := svc.sup.All() + if agent != "" { + rows = svc.sup.List(agent) + } + out := make([]appwire.Process, 0, len(rows)) + for _, row := range rows { + out = append(out, appServiceProcess(row, false)) + } + return out, nil +} + +// GetService is one service, or one removed with its output kept. +func (b *appBackend) GetService(id string) (appwire.Process, error) { + svc, agent, local, err := b.serviceRow(id) + if err != nil { + return appwire.Process{}, err + } + row, err := svc.sup.Get(agent, local) + if errors.Is(err, service.ErrNotFound) { + gone, archErr := svc.sup.Archive(agent, local) + if archErr != nil { + return appwire.Process{}, appServiceError(err) + } + removed := gone.Removed + return appwire.Process{ + ID: id, Agent: agent, Title: redact.Secrets(gone.Name), Command: redact.Secrets(gone.Command), + State: appwire.ProcessStopped, EndedAt: &removed, + Service: &appwire.Service{Name: redact.Secrets(gone.Name), Lifecycle: string(service.StateStopped), Removed: true, OutputTruncated: gone.OutputTruncated, Timing: appServiceTiming()}, + }, nil + } + if err != nil { + return appwire.Process{}, appServiceError(err) + } + return appServiceProcess(row, false), nil +} + +func (b *appBackend) serviceAct(id string, act func(*service.Supervisor, string, string) (service.Row, error)) (appwire.Process, error) { + svc, agent, local, err := b.serviceRow(id) + if err != nil { + return appwire.Process{}, err + } + row, err := act(svc.sup, agent, local) + if err != nil && row.ID == "" { + return appwire.Process{}, appServiceError(err) + } + return appServiceProcess(row, false), appServiceError(err) +} + +// SetServicePolicy is the person changing what happens at the next failure. +func (b *appBackend) SetServicePolicy(id, policy string) (appwire.Process, error) { + return b.serviceAct(id, func(s *service.Supervisor, agent, local string) (service.Row, error) { + return s.SetPolicy(agent, local, service.Policy(policy)) + }) +} + +// RestartService is the person's restart: outside the automatic budget. +func (b *appBackend) RestartService(id string) (appwire.Process, error) { + return b.serviceAct(id, (*service.Supervisor).Restart) +} + +// StartService starts a stopped service again from its saved command. +func (b *appBackend) StartService(id string) (appwire.Process, error) { + return b.serviceAct(id, (*service.Supervisor).Start) +} + +// StopService stops one service and keeps its output. +func (b *appBackend) StopService(id string) (appwire.Process, error) { + return b.serviceAct(id, (*service.Supervisor).Stop) +} + +// RemoveService takes a service with no process off the list, keeping its +// output readable by its id. +func (b *appBackend) RemoveService(id string) error { + svc, agent, local, err := b.serviceRow(id) + if err != nil { + return err + } + return appServiceError(svc.sup.Remove(agent, local)) +} + +// StopAllServices stops every service before a quit that asked to, and says +// how many were up. An error names what could not be proved stopped. +func (b *appBackend) StopAllServices() (int, error) { + svc, err := b.servicesOf() + if err != nil { + return 0, err + } + n := svc.sup.Running() + ctx, cancel := context.WithTimeout(context.Background(), appServiceStopAllWait) + defer cancel() + if err := svc.sup.StopAll(ctx, appServiceQuitBy); err != nil { + return n, appServiceError(err) + } + return n, nil +} + +// ServiceOutput is a bounded page of a service's output. +func (b *appBackend) ServiceOutput(id string, from, max int) (appwire.OutputPage, error) { + svc, agent, local, err := b.serviceRow(id) + if err != nil { + return appwire.OutputPage{}, err + } + page, err := svc.sup.Output(agent, local, from, max) + return appServicePage(page), appServiceError(err) +} + +// ServiceTail is the last lines of a service's output. +func (b *appBackend) ServiceTail(id string, lines int) (appwire.OutputPage, error) { + svc, agent, local, err := b.serviceRow(id) + if err != nil { + return appwire.OutputPage{}, err + } + page, err := svc.sup.Tail(agent, local, lines) + return appServicePage(page), appServiceError(err) +} + +func appServicePage(page service.OutputPage) appwire.OutputPage { + lines := make([]string, len(page.Lines)) + for i, line := range page.Lines { + lines[i] = redact.Secrets(line) + } + return appwire.OutputPage{Lines: lines, FirstLine: page.FirstLine, NextLine: page.NextLine, TotalLines: page.TotalLines, Truncated: page.Truncated} +} + +// ── the processes.* doors, for a service ──────────────────────────────────── + +// serviceProcessOutput is processes.output for a service. +func (b *appBackend) serviceProcessOutput(id string, from int) ([]string, int, error) { + page, err := b.ServiceOutput(id, from, 0) + if err != nil { + return nil, 0, err + } + return page.Lines, page.NextLine, nil +} + +// serviceProcessStop is processes.stop for a service. +func (b *appBackend) serviceProcessStop(id string) error { + _, err := b.StopService(id) + return err +} + +// FOLLOWING IS COUNTED. Every view that follows a service's output adds one, +// every unfollow takes one, and the tail stops when none is left — or when the +// service is removed, or the app closes. The tail reads the live log by its +// byte offset, so it costs one stat a look while nothing is written, and a log +// cut back by its rotation is read again from its start. + +// serviceFollow starts or stops one view's follow of a service. +func (b *appBackend) serviceFollow(id string, follow bool) error { + svc, agent, local, err := b.serviceRow(id) + if err != nil { + return err + } + path, err := svc.sup.OutputPath(agent, local) + if err != nil { + return appServiceError(err) + } + if appServiceFollowGate != nil { + appServiceFollowGate() + } + var start *appServiceFollow + refused, closed := false, false + svc.locked(func() { + // ADMISSION IS UNDER THE SAME LOCK THE CLOSE DRAINS UNDER, so a + // follow that found the supervisor just before the quit is refused + // here rather than started after the drain, where nothing would end it. + if closed = svc.closed; closed { + return + } + current := svc.follows[id] + switch { + case !follow && current != nil: + current.consumers-- + if current.consumers <= 0 { + close(current.stop) + delete(svc.follows, id) + } + case follow && current != nil: + if current.consumers >= appServiceFollowers { + refused = true + } else { + current.consumers++ + } + case follow: + start = &appServiceFollow{consumers: 1, stop: make(chan struct{}), done: make(chan struct{})} + svc.follows[id] = start + } + }) + if closed { + if follow { + return appwire.Errorf(appwire.CodeClosed, "codeaf is closing") + } + return nil + } + if refused { + return appwire.Errorf(appwire.CodeBusy, "%d views already follow %s", appServiceFollowers, id) + } + // An unfollow does not wait for the tail either (closeServices says why). + if start != nil { + offset := int64(0) + if info, err := os.Stat(path); err == nil { + offset = info.Size() + } + guard.Go("app/service-follow", func() { b.serviceTail(id, path, offset, start) }) + } + return nil +} + +// serviceFollowEnd ends a removed service's follow, whoever was following. +func (b *appBackend) serviceFollowEnd(svc *appServices, id string) { + svc.locked(func() { + if f := svc.follows[id]; f != nil { + close(f.stop) + delete(svc.follows, id) + } + }) +} + +// serviceTail sends a followed service's new lines as process.output. +func (b *appBackend) serviceTail(id, path string, offset int64, f *appServiceFollow) { + defer close(f.done) + ticker := time.NewTicker(appFollowEvery) + defer ticker.Stop() + buf := make([]byte, appServiceFollowRead) + for { + select { + case <-f.stop: + return + case <-ticker.C: + } + lines, next := appReadFrom(path, offset, buf) + offset = next + if len(lines) == 0 { + continue + } + // Told to go while it read: what it read is not sent. + select { + case <-f.stop: + return + default: + } + b.emit.Emit(appwire.EventProcessOutput, appwire.ProcessOutput{ID: id, Lines: lines}) + } +} + +// appReadFrom reads the complete lines written to path since offset, at most +// len(buf) bytes of them, and answers the offset after the last. A file +// shorter than offset was cut back, and is read from its start. +func appReadFrom(path string, offset int64, buf []byte) ([]string, int64) { + file, err := os.Open(path) + if err != nil { + return nil, offset + } + defer file.Close() + info, err := file.Stat() + if err != nil || !info.Mode().IsRegular() { + return nil, offset + } + if info.Size() < offset { + offset = 0 + } + n, err := file.ReadAt(buf, offset) + if err != nil && !errors.Is(err, io.EOF) { + return nil, offset + } + chunk := buf[:n] + end := strings.LastIndexByte(string(chunk), '\n') + if end < 0 { + if n == len(buf) { + // One line longer than a whole read is sent cut, and skipped. + return []string{redact.Secrets(appCut(string(chunk)))}, offset + int64(n) + } + return nil, offset + } + var lines []string + for _, line := range strings.Split(string(chunk[:end]), "\n") { + lines = append(lines, redact.Secrets(appCut(strings.TrimRight(line, "\r")))) + } + return lines, offset + int64(end) + 1 +} + +func appCut(line string) string { + if len(line) <= appServiceLineCap { + return line + } + return strings.ToValidUTF8(line[:appServiceLineCap], "") + " …[line cut]" +} + +// ── the keep-running ask ──────────────────────────────────────────────────── + +// appKeepOptions is the keep-running card's three answers in the app's names. +func appKeepOptions(q session.Question) ([]appOption, bool) { + if q.Kind != session.QuestionKeep { + return nil, false + } + ids := map[string]string{session.KeepAllowKey: appwire.OptionAllow, session.KeepAllowNeverKey: appwire.OptionAllowNever, session.KeepDenyKey: appwire.OptionDeny} + var out []appOption + for _, option := range q.Options { + id, known := ids[option.Key] + if !known { + return nil, false + } + out = append(out, appOption{id: id, key: option.Key, label: option.Label}) + } + return out, true +} + +// appServiceTiming is the supervisor's fixed rules on the wire, from its own +// constants. +func appServiceTiming() appwire.ServiceTiming { + return appwire.ServiceTiming{ + CheckEverySeconds: int(service.CheckEvery / time.Second), CheckTimeoutSeconds: int(service.CheckTimeout / time.Second), + UnhealthyAfter: service.UnhealthyAfter, FirstBackoffSeconds: int(service.FirstBackoff / time.Second), + RestartLimit: service.RestartLimit, RestartWindowSeconds: int(service.RestartWindow / time.Second), + HealthyResetSeconds: int(service.RestartWindow / time.Second), StopGraceSeconds: int(service.StopGrace / time.Second), + } +} + +// appKeepFacts is a keep-running card's proposal, typed, from the card the +// session put on the question. +func appKeepFacts(q session.Question) *appwire.KeepRunning { + if q.Kind != session.QuestionKeep || q.Keep == nil { + return nil + } + card := q.Keep + return &appwire.KeepRunning{ + Name: redact.Secrets(card.Name), Command: redact.Secrets(card.Command), Dir: card.Dir, + Health: appwire.ServiceHealth{Kind: string(card.Health.Kind), Port: card.Health.Port, URL: redact.Secrets(card.Health.URL), Cmd: redact.Secrets(card.Health.Cmd), Words: redact.Secrets(card.HealthWords)}, + Policy: string(card.Policy), CallID: card.CallID, Timing: appServiceTiming(), + } +} + +// noticeLog is the records of the episodes' notices, beside the app's file. +func (b *appBackend) noticeLog() *appNoticeLog { + b.noticesOnce.Do(func() { + dir := filepath.Dir(appStatePath()) + if b.state != nil { + dir = filepath.Dir(b.state.where()) + } + b.notices = newAppNoticeLog(filepath.Join(dir, appNoticeFolder)) + }) + return b.notices +} diff --git a/cmd/codeaf/appservices_test.go b/cmd/codeaf/appservices_test.go new file mode 100644 index 0000000000..6e67ea24e4 --- /dev/null +++ b/cmd/codeaf/appservices_test.go @@ -0,0 +1,669 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/service" + "github.com/Agent-Field/codeaf/internal/service/servicetest" + "github.com/Agent-Field/codeaf/internal/session" +) + +// svcWorld is the app's backend over a real supervisor on a machine with no +// processes, its clock moved by the test. No model is called. +type svcWorld struct { + backend *appBackend + rec *appRecorder + agent appwire.Agent + sup *service.Supervisor + rt *servicetest.Runtime + clock *servicetest.Clock + dir string + work string +} + +func newSvcWorld(t *testing.T) *svcWorld { + t.Helper() + backend, rec, agent := workBackend(t, &fakeConversation{}) + w := &svcWorld{backend: backend, rec: rec, agent: agent, rt: servicetest.New(), clock: servicetest.NewClock(time.Date(2026, 10, 15, 9, 0, 0, 0, time.UTC)), dir: t.TempDir(), work: t.TempDir()} + w.open(t) + return w +} + +// relaunch is the same machine, supervisor folder and app files opened by a +// new backend, the way the app is opened again after a quit. +func (w *svcWorld) relaunch(t *testing.T, emit func(appwire.Emitter) appwire.Emitter) *svcWorld { + t.Helper() + w.backend.closeServices() + rec := newAppRecorder() + var into appwire.Emitter = rec + if emit != nil { + into = emit(rec) + } + var mu sync.Mutex + var opens, releases int + backend := newFakeBackend(into, fakeOpener(&fakeConversation{}, &opens, &releases, &mu)) + t.Cleanup(func() { backend.Close() }) + backend.state = w.backend.state + agent, err := mustCreate(t, backend, appwire.CreateAsk{Workspace: t.TempDir()}) + if err != nil || agent.ID != w.agent.ID { + t.Fatalf("the relaunched agent %+v %v", agent, err) + } + next := &svcWorld{backend: backend, rec: rec, agent: agent, rt: w.rt, clock: w.clock, dir: w.dir, work: w.work} + next.open(t) + return next +} + +// open opens the supervisor over the world's folder and hands it to the +// backend, the way runApp does. +func (w *svcWorld) open(t *testing.T) { + t.Helper() + sup, err := service.Open(service.Options{Dir: w.dir, Runtime: w.rt, Clock: w.clock}) + if err != nil { + t.Fatal(err) + } + w.sup = sup + w.backend.attachServices(sup) + t.Cleanup(w.backend.closeServices) +} + +func (w *svcWorld) keep(t *testing.T, owner string, policy service.Policy) service.Row { + t.Helper() + row, err := w.sup.Keep(owner, service.Spec{Name: "web dev server", Command: "npm run dev", Dir: w.work, Health: service.Health{Port: 5173}}, policy, service.Provenance{Label: "start the web dev server"}) + if err != nil { + t.Fatal(err) + } + return row +} + +// changed waits for a process.changed of id that match accepts. +func (w *svcWorld) changed(t *testing.T, id string, match func(appwire.Process) bool) appwire.Process { + t.Helper() + i := w.rec.waitFor(t, 5*time.Second, func(_ string, data any) bool { + c, ok := data.(appwire.ProcessChanged) + return ok && c.Process.ID == id && c.Process.Service != nil && match(c.Process) + }) + w.rec.mu.Lock() + defer w.rec.mu.Unlock() + return w.rec.data[i].(appwire.ProcessChanged).Process +} + +// check moves the clock one check on and waits for its answer to be said. +func (w *svcWorld) check(t *testing.T, id string, fails int) appwire.Process { + t.Helper() + w.clock.Advance(service.CheckEvery) + return w.changed(t, id, func(p appwire.Process) bool { return p.Service.Check != nil && p.Service.Check.Fails == fails }) +} + +// all is every event of type T the recorder heard. +func svcAll[T any](rec *appRecorder) []T { + rec.mu.Lock() + defer rec.mu.Unlock() + var out []T + for _, data := range rec.data { + if v, ok := data.(T); ok { + out = append(out, v) + } + } + return out +} + +// TestAServiceIsAProcessAndTheProcessVerbsReachIt: one list, the service's +// own facts on it, and processes.stop / rerun / output acting on it. +func TestAServiceIsAProcessAndTheProcessVerbsReachIt(t *testing.T) { + w := newSvcWorld(t) + row := w.keep(t, w.agent.ID, service.PolicyOnFailure) + id := w.agent.ID + ":" + row.ID + list, err := w.backend.ListProcesses(w.agent.ID) + if err != nil { + t.Fatal(err) + } + var got *appwire.Process + for i := range list { + if list[i].ID == id { + got = &list[i] + } + } + if got == nil || got.State != appwire.ProcessRunning || got.Service == nil || !got.Service.Running || + got.Service.Timing.CheckEverySeconds != 10 || got.Service.Timing.RestartWindowSeconds != 600 || got.Service.Health.Words != "port 5173" { + t.Fatalf("the service in the list: %+v", got) + } + if err := os.WriteFile(row.LogPath, []byte("ready on 5173\nGET /\n"), 0o600); err != nil { + t.Fatal(err) + } + lines, next, err := w.backend.ProcessOutput(id, 0) + if err != nil || len(lines) != 2 || lines[0] != "ready on 5173" || next != 2 { + t.Fatalf("output %q %d %v", lines, next, err) + } + if err := w.backend.StopProcess(id); err != nil { + t.Fatal(err) + } + w.changed(t, id, func(p appwire.Process) bool { return p.State == appwire.ProcessStopped && !p.Service.Running }) + again, err := w.backend.RerunProcess(id) + if err != nil || again.ID != id || again.State != appwire.ProcessRunning { + t.Fatalf("rerun %+v %v", again, err) + } + w.changed(t, id, func(p appwire.Process) bool { return p.Service.Running && p.Service.StartedHow == "person" }) + // The record of the identity comes before the command is let run. + for deadline := time.Now().Add(5 * time.Second); w.rt.Starts() != 2; time.Sleep(time.Millisecond) { + if time.Now().After(deadline) { + t.Fatalf("started %d", w.rt.Starts()) + } + } +} + +// TestOneEpisodeIsOneInboxLineAndOneKeptNotice: three failed checks open one +// line and leave one notice; later checks update the same line; healthy again +// moves it to done at the moment the supervisor says, and the chat's history +// shows the notice after a relaunch without saying it twice. +func TestOneEpisodeIsOneInboxLineAndOneKeptNotice(t *testing.T) { + w := newSvcWorld(t) + row := w.keep(t, w.agent.ID, service.PolicyNever) + id := w.agent.ID + ":" + row.ID + w.rt.Failing(true) + w.check(t, id, 1) + w.check(t, id, 2) + w.check(t, id, 3) + w.changed(t, id, func(p appwire.Process) bool { + return p.Service.Lifecycle == appwire.ServiceUnhealthy && p.Service.EpisodeOpen + }) + w.check(t, id, 4) + w.check(t, id, 5) + + items := map[string]int{} + for _, added := range svcAll[appwire.InboxAddedEvent](w.rec) { + if added.Item.Ask.Subkind == appwire.SubkindServiceHealth { + items[added.Item.ID]++ + } + } + if len(items) != 1 { + t.Fatalf("one episode is %d Inbox lines: %v", len(items), items) + } + var notices []appwire.Entry + for _, e := range svcAll[appwire.EntryAdded](w.rec) { + if e.Entry.Footer == appwire.FooterServiceTrouble { + notices = append(notices, e.Entry) + } + } + if len(notices) != 1 || notices[0].Ref != id { + t.Fatalf("one episode left %d notices: %+v", len(notices), notices) + } + + w.rt.Failing(false) + healthy := w.check(t, id, 0) + healthy = w.changed(t, id, func(p appwire.Process) bool { return !p.Service.EpisodeOpen && p.Service.EpisodeResolvedAt != nil }) + w.rec.waitFor(t, 5*time.Second, func(_ string, data any) bool { + r, ok := data.(appwire.InboxResolvedEvent) + return ok && r.By == appwire.ResolvedRecovered + }) + // The clock moves on before anyone reads the line: its time is the + // supervisor's, not the moment it was heard. + w.clock.Advance(time.Hour) + done, _, err := w.backend.ListInbox(appwire.InboxDone, "") + if err != nil { + t.Fatal(err) + } + if len(done) != 1 || done[0].Answered == nil || !done[0].Answered.At.Equal(*healthy.Service.EpisodeResolvedAt) { + t.Fatalf("done: %+v", done) + } + + // A relaunch: the supervisor and the backend's half are opened again over + // the same files, and nothing is said twice. + w = w.relaunch(t, nil) + entries, _, err := w.backend.Entries(w.agent.ID, "", 200) + if err != nil { + t.Fatal(err) + } + kept := 0 + for _, e := range entries { + if e.Footer == appwire.FooterServiceTrouble && e.ID == notices[0].ID { + kept++ + } + } + if kept != 1 { + t.Fatalf("the history holds the notice %d times: %+v", kept, entries) + } + for _, e := range svcAll[appwire.EntryAdded](w.rec) { + if e.Entry.Footer == appwire.FooterServiceTrouble { + t.Fatalf("the relaunch said the notice again: %+v", e.Entry) + } + } +} + +// TestWhatIsInTroubleAtStartIsInTheInboxAtOnce: a backend opened over an +// episode already open has its line before the next check. +func TestWhatIsInTroubleAtStartIsInTheInboxAtOnce(t *testing.T) { + w := newSvcWorld(t) + row := w.keep(t, w.agent.ID, service.PolicyNever) + id := w.agent.ID + ":" + row.ID + w.rt.Failing(true) + w.check(t, id, 1) + w.check(t, id, 2) + w.check(t, id, 3) + w.changed(t, id, func(p appwire.Process) bool { return p.Service.EpisodeOpen }) + w = w.relaunch(t, nil) + waiting, _, _ := w.backend.ListInbox(appwire.InboxWaiting, "") + found := 0 + for _, item := range waiting { + if item.Ask.Subkind == appwire.SubkindServiceHealth && item.Ask.Subject != nil && item.Ask.Subject.Ref == id { + found++ + } + } + if found != 1 { + t.Fatalf("the open episode is %d lines at start: %+v", found, waiting) + } +} + +// TestClosingAnAgentWaitsForItsProcessesAndKeepsItWhenOneWillNotStop. +func TestClosingAnAgentWaitsForItsProcessesAndKeepsItWhenOneWillNotStop(t *testing.T) { + w := newSvcWorld(t) + row := w.keep(t, w.agent.ID, service.PolicyAlways) + other, err := w.sup.Keep("another-agent", service.Spec{Name: "api", Command: "node api.js", Dir: w.work, Health: service.Health{Port: 4011}}, service.PolicyNever, service.Provenance{}) + if err != nil { + t.Fatal(err) + } + w.rt.Refusing(true) + closed := make(chan error, 1) + go func() { closed <- w.backend.CloseAgent(w.agent.ID) }() + // The stop's grace and its kill are moved through on the test's clock. + var err2 error + for done := false; !done; { + select { + case err2 = <-closed: + done = true + default: + w.clock.Advance(time.Second) + time.Sleep(time.Millisecond) + } + } + if err2 == nil || !strings.Contains(err2.Error(), row.ID) { + t.Fatalf("a close that could not stop its process said %v", err2) + } + a, _ := w.backend.find(w.agent.ID) + var state string + w.backend.locked(func() { state = a.view.State }) + if state == appwire.StateClosed { + t.Fatal("the agent was closed over a process still running") + } + if got, _ := w.sup.Get(w.agent.ID, row.ID); !got.Running { + t.Fatalf("the process is hidden: %+v", got) + } + if got, _ := w.sup.Get("another-agent", other.ID); !got.Running { + t.Fatal("another agent's process was stopped") + } + + w.rt.Refusing(false) + if err := w.backend.CloseAgent(w.agent.ID); err != nil { + t.Fatal(err) + } + if got, _ := w.sup.Get(w.agent.ID, row.ID); got.Running || got.StoppedBy != appServiceClosedBy { + t.Fatalf("after the close: %+v", got) + } + w.backend.locked(func() { state = a.view.State }) + if state != appwire.StateClosed { + t.Fatalf("state %s", state) + } +} + +// TestAFollowThatMeetsTheCloseIsRefused: a follow that found the supervisor +// before the quit drained the follows is refused after it, not started. +func TestAFollowThatMeetsTheCloseIsRefused(t *testing.T) { + w := newSvcWorld(t) + row := w.keep(t, w.agent.ID, service.PolicyNever) + id := w.agent.ID + ":" + row.ID + held, release := make(chan struct{}), make(chan struct{}) + appServiceFollowGate = func() { close(held); <-release } + t.Cleanup(func() { appServiceFollowGate = nil }) + result := make(chan error, 1) + go func() { result <- w.backend.FollowProcess(id, true) }() + <-held + svc, _ := w.backend.servicesOf() + w.backend.closeServices() + release <- struct{}{} + if err := <-result; err == nil || !strings.Contains(err.Error(), "closing") { + t.Fatalf("a follow after the close said %v", err) + } + svc.mu.Lock() + n := len(svc.follows) + svc.mu.Unlock() + if n != 0 { + t.Fatalf("%d follows outlived the close", n) + } +} + +// svcBlockedEmitter holds every process.output until it is let go. +type svcBlockedEmitter struct { + next appwire.Emitter + blocked chan struct{} + release chan struct{} + once sync.Once +} + +func (e *svcBlockedEmitter) Emit(event string, data any) { + if event == appwire.EventProcessOutput { + e.once.Do(func() { close(e.blocked) }) + <-e.release + } + e.next.Emit(event, data) +} + +// TestUnfollowAndCloseDoNotWaitOnAWireThatIsNotTaking: a tail stuck in a send +// the window is not reading neither holds an unfollow nor a quit. +func TestUnfollowAndCloseDoNotWaitOnAWireThatIsNotTaking(t *testing.T) { + first := newSvcWorld(t) + row := first.keep(t, first.agent.ID, service.PolicyNever) + blocked := &svcBlockedEmitter{blocked: make(chan struct{}), release: make(chan struct{})} + w := first.relaunch(t, func(rec appwire.Emitter) appwire.Emitter { blocked.next = rec; return blocked }) + id := w.agent.ID + ":" + row.ID + if err := w.backend.FollowProcess(id, true); err != nil { + t.Fatal(err) + } + // Two views follow; one leaving keeps the tail for the other. + if err := w.backend.FollowProcess(id, true); err != nil { + t.Fatal(err) + } + svc, _ := w.backend.servicesOf() + svc.mu.Lock() + f := svc.follows[id] + svc.mu.Unlock() + if err := os.WriteFile(row.LogPath, []byte("one\n"), 0o600); err != nil { + t.Fatal(err) + } + select { + case <-blocked.blocked: + case <-time.After(5 * time.Second): + t.Fatal("the tail never sent") + } + quick := func(what string, fn func()) { + t.Helper() + done := make(chan struct{}) + go func() { fn(); close(done) }() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatalf("%s waited on the stuck send", what) + } + } + quick("the first unfollow", func() { _ = w.backend.FollowProcess(id, false) }) + svc.mu.Lock() + still := svc.follows[id] == f && f.consumers == 1 + svc.mu.Unlock() + if !still { + t.Fatal("one view's unfollow ended the other's") + } + quick("the quit", w.backend.closeServices) + close(blocked.release) + select { + case <-f.done: + case <-time.After(5 * time.Second): + t.Fatal("the tail did not go once its send returned") + } +} + +// TestARemovedServiceStillOpensItsOutput: removal keeps the output and says +// whether earlier output had been cut. +func TestARemovedServiceStillOpensItsOutput(t *testing.T) { + w := newSvcWorld(t) + row := w.keep(t, w.agent.ID, service.PolicyNever) + id := w.agent.ID + ":" + row.ID + if err := os.WriteFile(row.LogPath, []byte("listening\n"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := w.backend.StopService(id); err != nil { + t.Fatal(err) + } + w.changed(t, id, func(p appwire.Process) bool { return !p.Service.Running && !p.Service.Stopping }) + if err := w.backend.RemoveService(id); err != nil { + t.Fatal(err) + } + list, _ := w.backend.ListServices(w.agent.ID) + if len(list) != 0 { + t.Fatalf("still listed: %+v", list) + } + got, err := w.backend.GetService(id) + if err != nil || got.Service == nil || !got.Service.Removed { + t.Fatalf("the removed service: %+v %v", got, err) + } + lines, _, err := w.backend.ProcessOutput(id, 0) + if err != nil || len(lines) != 1 || lines[0] != "listening" { + t.Fatalf("its output %q %v", lines, err) + } + if _, err := os.Stat(filepath.Clean(row.LogPath)); err != nil { + t.Fatal(err) + } +} + +// TestTheKeepRunningCardOnTheWire: its three answers in the app's names over +// the engine's keys, and its proposal typed from the card. +func TestTheKeepRunningCardOnTheWire(t *testing.T) { + notice := session.KeepNotice{ID: 7, CallID: "k1", Name: "web dev server", Command: "npm run dev", Dir: "/work/site ", Health: service.Health{Kind: service.HealthPort, Port: 5173}, HealthWords: "port 5173", Policy: service.PolicyAlways, PolicyWords: "restart always"} + q := session.KeepQuestion(notice) + ask := appAskOf("a1", q) + if ask.Kind != appwire.AskApproval || ask.Subkind != appwire.SubkindKeepRunning { + t.Fatalf("%+v", ask) + } + want := []string{appwire.OptionAllow, appwire.OptionAllowNever, appwire.OptionDeny} + keys := []string{session.KeepAllowKey, session.KeepAllowNeverKey, session.KeepDenyKey} + options := appOptionsOf(q) + if len(options) != 3 { + t.Fatalf("%+v", options) + } + for i, option := range options { + if option.id != want[i] || option.key != keys[i] { + t.Fatalf("option %d = %+v", i, option) + } + } + facts := ask.KeepRunning + if facts == nil || facts.Command != "npm run dev" || facts.Dir != "/work/site " || facts.Health.Kind != "port" || facts.Health.Port != 5173 || + facts.Policy != "always" || facts.CallID != "k1" || facts.Timing.CheckEverySeconds != 10 || facts.Timing.StopGraceSeconds != 5 { + t.Fatalf("the proposal: %+v", facts) + } + if got := appReceipt("", appwire.OptionAllowNever, nil, time.Now()); !strings.HasPrefix(got, "Kept running, never restarted") { + t.Fatal(got) + } +} + +// TestALiveServiceKeepsItsAgentThroughTheIdleSweep. +func TestALiveServiceKeepsItsAgentThroughTheIdleSweep(t *testing.T) { + w := newSvcWorld(t) + if w.backend.serviceKeeps(w.agent.ID) { + t.Fatal("an agent with nothing kept running is kept") + } + w.keep(t, w.agent.ID, service.PolicyNever) + if !w.backend.serviceKeeps(w.agent.ID) { + t.Fatal("an agent with a live service is let go by the sweep") + } +} + +// TestWithNoSupervisorTheServicesAreAbsent: every door says unknown_method. +func TestWithNoSupervisorTheServicesAreAbsent(t *testing.T) { + backend, _, agent := workBackend(t, &fakeConversation{}) + _, err := backend.ListServices(agent.ID) + var wire *appwire.Error + if !errors.As(err, &wire) || wire.Code != appwire.CodeUnknownMethod { + t.Fatalf("%v", err) + } + if err := backend.CloseAgent(agent.ID); err != nil { + t.Fatalf("closing with no supervisor: %v", err) + } +} + +// TestNothingStartsForAnAgentWhileItCloses: a start asked for while the close +// is waiting out its grace is refused, and the close still succeeds. +func TestNothingStartsForAnAgentWhileItCloses(t *testing.T) { + w := newSvcWorld(t) + row := w.keep(t, w.agent.ID, service.PolicyNever) + id := w.agent.ID + ":" + row.ID + w.rt.Refusing(true) + closed := make(chan error, 1) + go func() { closed <- w.backend.CloseAgent(w.agent.ID) }() + w.changed(t, id, func(p appwire.Process) bool { return p.Service.Stopping }) + if _, err := w.backend.StartService(id); err == nil || !strings.Contains(err.Error(), "closing") { + t.Fatalf("a start during the close said %v", err) + } + if _, err := w.sup.Keep(w.agent.ID, service.Spec{Name: "api", Command: "node api.js", Dir: w.work, Health: service.Health{Port: 4013}}, service.PolicyNever, service.Provenance{}); !errors.Is(err, service.ErrClosing) { + t.Fatalf("a keep during the close said %v", err) + } + w.rt.Refusing(false) + for done := false; !done; { + select { + case err := <-closed: + if err == nil { + // The grace ended on SIGKILL, which now works. + done = true + break + } + // Refused while the test was still refusing: close again. + go func() { closed <- w.backend.CloseAgent(w.agent.ID) }() + default: + w.clock.Advance(time.Second) + time.Sleep(time.Millisecond) + } + } + if got, _ := w.sup.Get(w.agent.ID, row.ID); got.Running { + t.Fatalf("%+v", got) + } + // Reopened later, the agent may start things again. + if _, err := w.backend.StartService(id); err != nil { + t.Fatalf("after the close: %v", err) + } +} + +// TestEveryNoticeIsKeptAndPagedPastFiveHundred: six hundred episodes leave six +// hundred notices on disk; the history pages back from the newest to the +// oldest a page at a time, after a relaunch too, and a replayed episode is +// not written twice. +func TestEveryNoticeIsKeptAndPagedPastFiveHundred(t *testing.T) { + w := newSvcWorld(t) + log := w.backend.noticeLog() + base := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + ref := w.agent.ID + ":s1" + const episodes = 600 + for i := 1; i <= episodes; i++ { + n := appServiceNotice{Agent: w.agent.ID, Ref: ref, Episode: "s1." + itoa(i), Text: "web is not answering", At: base.Add(time.Duration(i) * time.Minute)} + if err := log.Append(n); err != nil { + t.Fatal(err) + } + // The same episode heard again — a replay — writes nothing. + if err := log.Append(n); err != nil { + t.Fatal(err) + } + } + record, _, _ := log.paths(w.agent.ID) + raw, err := os.ReadFile(record) + if err != nil { + t.Fatal(err) + } + if lines := strings.Count(string(raw), "\n"); lines != episodes { + t.Fatalf("the record holds %d lines for %d episodes", lines, episodes) + } + // A new log over the same folder, as after a relaunch: its index is read + // back, and a replay still writes nothing. + again := newAppNoticeLog(log.dir) + if !again.Noticed(appServiceNotice{Agent: w.agent.ID, Ref: ref, Episode: "s1.1"}) { + t.Fatal("the oldest episode is not known as noticed after a relaunch") + } + w = w.relaunch(t, nil) + seen := map[string]bool{} + var oldest, newest string + before, pages := "", 0 + for { + page, more, err := w.backend.Entries(w.agent.ID, before, 100) + if err != nil { + t.Fatal(err) + } + if len(page) > 100 { + t.Fatalf("a page of %d", len(page)) + } + pages++ + for _, e := range page { + if e.Footer != appwire.FooterServiceTrouble { + continue + } + if seen[e.ID] { + t.Fatalf("%s given twice", e.ID) + } + seen[e.ID] = true + } + if pages == 1 && len(page) > 0 { + newest = page[len(page)-1].ID + } + if len(page) > 0 { + oldest = page[0].ID + before = page[0].ID + } + if !more { + break + } + if pages > 20 { + t.Fatal("the pages never end") + } + } + if len(seen) != episodes || newest != "svc"+ref+".s1."+itoa(episodes) || oldest != "svc"+ref+".s1.1" { + t.Fatalf("paged %d notices over %d pages, newest %s, oldest %s", len(seen), pages, newest, oldest) + } +} + +func itoa(n int) string { return strconv.Itoa(n) } + +// TestNoticesLieBetweenTheEntriesTheyCameBetween: paged back in small pages, +// a conversation and its notices come out as one sequence in time order, each +// entry once, whichever kind a page's cursor is. +func TestNoticesLieBetweenTheEntriesTheyCameBetween(t *testing.T) { + base := time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC) + var items []session.DisplayEntry + for i := 0; i < 20; i++ { + items = append(items, session.DisplayEntry{Role: "user", Text: "q" + itoa(i), At: base.Add(time.Duration(i) * time.Minute)}) + } + record := session.Record{Entries: items} + log := newAppNoticeLog(t.TempDir()) + // Notices after every third message, and two after the last. + var want []string + for i := 0; i < 20; i++ { + want = append(want, appUserEntryID(i)) + if i%3 == 0 || i == 19 { + n := appServiceNotice{Agent: "a1", Ref: "a1:s1", Episode: "s1." + itoa(i+1), Text: "down", At: base.Add(time.Duration(i)*time.Minute + 30*time.Second)} + if err := log.Append(n); err != nil { + t.Fatal(err) + } + want = append(want, n.id()) + } + } + tail := appServiceNotice{Agent: "a1", Ref: "a1:s1", Episode: "s1.99", Text: "down", At: base.Add(time.Hour)} + if err := log.Append(tail); err != nil { + t.Fatal(err) + } + want = append(want, tail.id()) + notes := func() (*appNoticeReader, error) { return log.Reader("a1") } + facts := func(session.DisplayEntry) appwire.ToolFacts { return appwire.ToolFacts{} } + var got []string + before := "" + for pages := 0; ; pages++ { + page, more, err := appEntryPageWith("a1", record, base, nil, notes, before, 4, facts) + if err != nil { + t.Fatal(err) + } + ids := make([]string, len(page)) + for i, e := range page { + ids[i] = e.ID + } + got = append(ids, got...) + if !more || len(page) == 0 { + break + } + before = page[0].ID + if pages > 50 { + t.Fatal("the pages never end") + } + } + if strings.Join(got, " ") != strings.Join(want, " ") { + t.Fatalf("paged\n %v\nwant\n %v", got, want) + } +} diff --git a/cmd/codeaf/apptidy.go b/cmd/codeaf/apptidy.go index ea11ef94cf..457bad0ceb 100644 --- a/cmd/codeaf/apptidy.go +++ b/cmd/codeaf/apptidy.go @@ -98,21 +98,42 @@ func (b *appBackend) MoveToClosed(ids []string) error { if err != nil { return err } - b.closeAgent(a) - if err := b.state.write(func(f *appStateFile) error { - if f.Closed == nil { - f.Closed = map[string]time.Time{} + if err := b.closeOne(a, func() error { + if err := b.state.write(func(f *appStateFile) error { + if f.Closed == nil { + f.Closed = map[string]time.Time{} + } + f.Closed[id] = now + return nil + }); err != nil { + return appEngineError(err) } - f.Closed[id] = now return nil }); err != nil { - return appEngineError(err) + return err } b.sayAgent(a) } return nil } +// closeOne is the one way the app lets an agent go from a person's Close or +// Archive: what the agent kept running is fenced and stopped FIRST, and an +// agent whose process would not stop is left exactly as it was, open and +// unmarked, with the error saying which. mark runs after the conversation is +// let go and before the fence is released, so nothing starts in between. +// Only this agent's own services are touched. +func (b *appBackend) closeOne(a *appAgent, mark func() error) error { + var agentID string + b.locked(func() { agentID = a.view.ID }) + defer b.fenceAgentServices(agentID)() + if err := b.stopAgentServices(agentID); err != nil { + return err + } + b.closeAgent(a) + return mark() +} + // Archive closes each agent and puts it away. func (b *appBackend) Archive(ids []string) error { now := b.now() @@ -121,22 +142,26 @@ func (b *appBackend) Archive(ids []string) error { if err != nil { return err } - b.closeAgent(a) - var transcript string - b.locked(func() { transcript = a.transcript }) - if transcript != "" { - if err := session.SetArchived(filepath.Dir(transcript), true); err != nil { - return appEngineError(err) + if err := b.closeOne(a, func() error { + var transcript string + b.locked(func() { transcript = a.transcript }) + if transcript != "" { + if err := session.SetArchived(filepath.Dir(transcript), true); err != nil { + return appEngineError(err) + } } - } - if err := b.state.write(func(f *appStateFile) error { - if f.Archived == nil { - f.Archived = map[string]time.Time{} + if err := b.state.write(func(f *appStateFile) error { + if f.Archived == nil { + f.Archived = map[string]time.Time{} + } + f.Archived[id] = now + return nil + }); err != nil { + return appEngineError(err) } - f.Archived[id] = now return nil }); err != nil { - return appEngineError(err) + return err } b.locked(func() { a.view.State = appwire.StateArchived }) b.sayAgent(a) diff --git a/cmd/codeaf/apptidy_services_test.go b/cmd/codeaf/apptidy_services_test.go new file mode 100644 index 0000000000..14b35f7e75 --- /dev/null +++ b/cmd/codeaf/apptidy_services_test.go @@ -0,0 +1,181 @@ +package main + +import ( + "errors" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/service" + "github.com/Agent-Field/codeaf/internal/session" +) + +// tidyVerbs are the two public doors a person's Close and Archive come through. +var tidyVerbs = map[string]func(b *appBackend, id string) error{ + "close": func(b *appBackend, id string) error { return b.MoveToClosed([]string{id}) }, + "archive": func(b *appBackend, id string) error { return b.Archive([]string{id}) }, +} + +// tidyUntil runs verb in the background and moves the test's clock through the +// stop's grace until it answers; no real sleeping beyond a yield. +func tidyUntil(t *testing.T, w *svcWorld, verb func() error, during func()) error { + t.Helper() + done := make(chan error, 1) + go func() { done <- verb() }() + if during != nil { + during() + } + deadline := time.Now().Add(20 * time.Second) + for { + select { + case err := <-done: + return err + default: + } + if time.Now().After(deadline) { + t.Fatal("the tidy verb never answered") + } + w.clock.Advance(time.Second) + time.Sleep(time.Millisecond) + } +} + +// realTranscript points the agent at a conversation folder that exists, so +// Archive's mark (session.SetArchived) has somewhere to be written. +func (w *svcWorld) realTranscript(t *testing.T) { + t.Helper() + a, err := w.backend.find(w.agent.ID) + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + if err := session.SaveMeta(dir, session.Meta{ID: w.agent.ID}); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "transcript.jsonl") + w.backend.locked(func() { a.transcript = path }) +} + +func (w *svcWorld) tidyState(t *testing.T) (state string, closed, archived bool) { + t.Helper() + a, err := w.backend.find(w.agent.ID) + if err != nil { + t.Fatal(err) + } + w.backend.locked(func() { state = a.view.State }) + _ = w.backend.state.read(func(f *appStateFile) { + _, closed = f.Closed[w.agent.ID] + _, archived = f.Archived[w.agent.ID] + }) + return +} + +// TestClosingOrArchivingStopsTheAgentsOwnProcessesFirst: both verbs stop what +// the agent kept, only that, then hide it, and leave no fence behind. +func TestClosingOrArchivingStopsTheAgentsOwnProcessesFirst(t *testing.T) { + for name, verb := range tidyVerbs { + t.Run(name, func(t *testing.T) { + w := newSvcWorld(t) + w.realTranscript(t) + row := w.keep(t, w.agent.ID, service.PolicyAlways) + other, err := w.sup.Keep("another-agent", service.Spec{Name: "api", Command: "node api.js", Dir: w.work, Health: service.Health{Port: 4011}}, service.PolicyNever, service.Provenance{}) + if err != nil { + t.Fatal(err) + } + if err := tidyUntil(t, w, func() error { return verb(w.backend, w.agent.ID) }, nil); err != nil { + t.Fatal(err) + } + if got, _ := w.sup.Get(w.agent.ID, row.ID); got.Running || got.StoppedBy != appServiceClosedBy { + t.Fatalf("the process after %s: %+v", name, got) + } + if got, _ := w.sup.Get("another-agent", other.ID); !got.Running { + t.Fatal("another agent's process was stopped") + } + state, closed, archived := w.tidyState(t) + want := appwire.StateClosed + if name == "archive" { + want = appwire.StateArchived + } + if state != want || (name == "close") != closed || (name == "archive") != archived { + t.Fatalf("state %s closed=%v archived=%v", state, closed, archived) + } + // No fence is left: the agent may start things once reopened. + if _, err := w.backend.StartService(w.agent.ID + ":" + row.ID); err != nil { + t.Fatalf("a start after %s: %v", name, err) + } + }) + } +} + +// TestARefusedStopLeavesTheAgentOpenAndUnfenced: a process that will not stop +// keeps the agent open and unarchived, says which, and a retry then succeeds. +func TestARefusedStopLeavesTheAgentOpenAndUnfenced(t *testing.T) { + for name, verb := range tidyVerbs { + t.Run(name, func(t *testing.T) { + w := newSvcWorld(t) + w.realTranscript(t) + row := w.keep(t, w.agent.ID, service.PolicyAlways) + id := w.agent.ID + ":" + row.ID + w.rt.Refusing(true) + err := tidyUntil(t, w, func() error { return verb(w.backend, w.agent.ID) }, nil) + if err == nil || !strings.Contains(err.Error(), row.ID) { + t.Fatalf("a %s that could not stop its process said %v", name, err) + } + state, closed, archived := w.tidyState(t) + if state == appwire.StateClosed || state == appwire.StateArchived || closed || archived { + t.Fatalf("hidden over a running process: %s closed=%v archived=%v", state, closed, archived) + } + if got, _ := w.sup.Get(w.agent.ID, row.ID); !got.Running { + t.Fatalf("the process is hidden: %+v", got) + } + // The fence is gone: a keep for the owner is not refused as closing. + w.rt.Refusing(false) + if _, err := w.sup.Keep(w.agent.ID, service.Spec{Name: "api", Command: "node api.js", Dir: w.work, Health: service.Health{Port: 4013}}, service.PolicyNever, service.Provenance{}); errors.Is(err, service.ErrClosing) { + t.Fatalf("a fence leaked past the refusal: %v", err) + } + if err := tidyUntil(t, w, func() error { return verb(w.backend, w.agent.ID) }, nil); err != nil { + t.Fatalf("the retry: %v", err) + } + if got, _ := w.sup.Get(w.agent.ID, row.ID); got.Running { + t.Fatalf("after the retry: %+v", got) + } + _ = id + }) + } +} + +// TestNothingStartsWhileAnAgentIsClosedOrArchived: a start or keep asked for +// during the stop's grace is refused, and the fence is gone afterwards. +func TestNothingStartsWhileAnAgentIsClosedOrArchived(t *testing.T) { + for name, verb := range tidyVerbs { + t.Run(name, func(t *testing.T) { + w := newSvcWorld(t) + w.realTranscript(t) + row := w.keep(t, w.agent.ID, service.PolicyNever) + id := w.agent.ID + ":" + row.ID + w.rt.Refusing(true) + err := tidyUntil(t, w, func() error { return verb(w.backend, w.agent.ID) }, func() { + w.changed(t, id, func(p appwire.Process) bool { return p.Service.Stopping }) + if _, err := w.backend.StartService(id); err == nil || !strings.Contains(err.Error(), "closing") { + t.Errorf("a start during the %s said %v", name, err) + } + if _, err := w.sup.Keep(w.agent.ID, service.Spec{Name: "api", Command: "node api.js", Dir: w.work, Health: service.Health{Port: 4014}}, service.PolicyNever, service.Provenance{}); !errors.Is(err, service.ErrClosing) { + t.Errorf("a keep during the %s said %v", name, err) + } + w.rt.Refusing(false) + }) + if err != nil { + // The refusal outlasted the grace; the retry is the person's. + err = tidyUntil(t, w, func() error { return verb(w.backend, w.agent.ID) }, nil) + } + if err != nil { + t.Fatal(err) + } + if _, err := w.backend.StartService(id); err != nil { + t.Fatalf("after the %s: %v", name, err) + } + }) + } +} diff --git a/cmd/codeaf/appwork.go b/cmd/codeaf/appwork.go index 52c3ab89a6..1a5dd472fa 100644 --- a/cmd/codeaf/appwork.go +++ b/cmd/codeaf/appwork.go @@ -957,7 +957,8 @@ func (b *appBackend) ListProcesses(agent string) ([]appwire.Process, error) { } out = append(out, process) } - return b.quitStopped(agent, out), nil + // One list: the agent's kept-running processes are its processes too. + return append(b.quitStopped(agent, out), b.serviceProcesses(agent)...), nil } // processLog is where one process's output is spooled. @@ -998,6 +999,9 @@ func appReadLines(path string, from, limit int) ([]string, int, error) { // ProcessOutput answers a process's output from a line on. func (b *appBackend) ProcessOutput(id string, from int) ([]string, int, error) { + if _, _, ok := appServiceID(id); ok { + return b.serviceProcessOutput(id, from) + } path, err := b.processLog(id) if err != nil { return nil, 0, err @@ -1022,6 +1026,9 @@ func (b *appBackend) ProcessOutput(id string, from int) ([]string, int, error) { // FollowProcess starts or stops the live tail of one process's output, which // arrives as process.output only while it is followed. func (b *appBackend) FollowProcess(id string, follow bool) error { + if _, _, ok := appServiceID(id); ok { + return b.serviceFollow(id, follow) + } path, err := b.processLog(id) if err != nil { return err @@ -1091,6 +1098,9 @@ func (b *appBackend) follow(id, path string, from int, f *appFollow) { // StopProcess stops one running process. func (b *appBackend) StopProcess(id string) error { + if _, _, ok := appServiceID(id); ok { + return b.serviceProcessStop(id) + } agent, row, err := b.processRow(id) if err != nil { return err @@ -1108,6 +1118,9 @@ func (b *appBackend) StopProcess(id string) error { // RerunProcess runs an ended process's command again, as a new process. func (b *appBackend) RerunProcess(id string) (appwire.Process, error) { + if _, _, ok := appServiceID(id); ok { + return b.StartService(id) + } agent, row, err := b.processRow(id) if err != nil { return appwire.Process{}, err diff --git a/cmd/codeaf/engine.go b/cmd/codeaf/engine.go index 370f0fb1c0..6dae858bd7 100644 --- a/cmd/codeaf/engine.go +++ b/cmd/codeaf/engine.go @@ -980,6 +980,9 @@ func assembleEngine(hello remote.Hello, workspace, sessionFlag, launchDir string // and it is the one fact the shared assembly cannot know for itself. cfg := launch.Config cfg.AskConsent = !hello.Headless + // The app's one supervisor of kept-running processes, when this process + // is the app's (appservices.go); every other engine has none. + cfg.Services = appServiceDoor.Load() // A HELLO THAT ASKED FOR A CONVERSATION OF ITS OWN GETS A SIBLING FOLDER, // through the very pair [remote.Engine.Fresh] below is written from diff --git a/docs/changes/unreleased/0-app-ports-observe.md b/docs/changes/unreleased/0-app-ports-observe.md new file mode 100644 index 0000000000..ef823b7786 --- /dev/null +++ b/docs/changes/unreleased/0-app-ports-observe.md @@ -0,0 +1,16 @@ +--- +kind: added +title: codeaf app can say who holds a port and which port is free +pr: 0 +surface: [engine] +invalidates: + - "The app could only learn a port was in conflict from a service's health check, with no holder and no free port to offer. ports.inspect {port} and ports.suggest {after} now observe the machine: state free, in_use or unknown, the holder's pid and name when proved, and a port that was free when bound. Neither reserves anything." +--- + +New methods `ports.inspect {port}` and `ports.suggest {after}` over the new +generic `internal/ports` package. A check binds and releases the IPv4 and IPv6 +wildcards and loopbacks; only a refused bind that says the address is in use is +`in_use`, and a refusal or deadline is `unknown`, never free. The holder comes +from the system `lsof` with fixed arguments, 500 ms and 64 KiB at most, and is +omitted unless exactly one process is named. Suggestions start at 1024 or +`after+1`, look at no more than 128 ports in about a second and never wrap. diff --git a/internal/appwire/ports.go b/internal/appwire/ports.go new file mode 100644 index 0000000000..d1247df9b5 --- /dev/null +++ b/internal/appwire/ports.go @@ -0,0 +1,139 @@ +package appwire + +import ( + "context" + "errors" + "time" +) + +// Port observation (V77): is this port taken, by whom, and which port past it +// is free. Both answers are OBSERVATIONS at CheckedAt — nothing is reserved, +// so a command that binds later can still lose a race, and the app must say +// so rather than promise the port. + +// The states a port was seen in. unknown is never to be read as free. +const ( + PortFree = "free" + PortInUse = "in_use" + PortUnknown = "unknown" +) + +// PortHolder is the program holding a port: a positive process id and the +// program's name only — never a command line or a path. +type PortHolder struct { + PID int `json:"pid"` + Name string `json:"name"` +} + +// PortInspection is ports.inspect's answer. Holder is present only when the +// engine could prove it; its absence on an in_use port means "not named". +type PortInspection struct { + Port int `json:"port"` + State string `json:"state" enum:"free,in_use,unknown"` + CheckedAt time.Time `json:"checked_at"` + Holder *PortHolder `json:"holder,omitempty"` + Detail string `json:"detail,omitempty"` +} + +// PortSuggestion is ports.suggest's answer: a port that was free when bound +// at CheckedAt, after CheckedCount candidates were looked at. +type PortSuggestion struct { + Port int `json:"port"` + CheckedAt time.Time `json:"checked_at"` + CheckedCount int `json:"checked_count"` +} + +// Porter is the engine's half of port observation. An [Engine] that is not +// one refuses ports.* as unknown_method. The context carries the call's +// deadline; an implementation reports ErrPortInvalid, ErrPortNoCandidate or +// ErrPortBusy (or a context error) and the route turns them into wire errors. +type Porter interface { + InspectPort(ctx context.Context, port int) (PortInspection, error) + SuggestPort(ctx context.Context, after int) (PortSuggestion, error) +} + +// What a Porter answers when a port cannot be had, so the route can pick the +// wire code without knowing the engine's own types. +var ( + ErrPortInvalid = errors.New("port out of range") + ErrPortNoCandidate = errors.New("no free port found") + ErrPortBusy = errors.New("port checks are busy") +) + +// portCallBudget is the most one ports.* call may take, the engine's own +// bounds included. +const portCallBudget = 3 * time.Second + +func init() { + for name, m := range portMethods { + methods[name] = m + } +} + +func porter(e Engine, name string) (Porter, error) { + p, ok := e.(Porter) + if !ok { + return nil, Errorf(CodeUnknownMethod, "this engine does not serve %s", name) + } + return p, nil +} + +func portError(err error) error { + switch { + case errors.Is(err, ErrPortInvalid): + return Errorf(CodeBadParams, "port must be a whole number from 1 to 65535") + case errors.Is(err, ErrPortNoCandidate): + return Errorf(CodeNotFound, "no free port was found after it") + case errors.Is(err, ErrPortBusy): + return Errorf(CodeBusy, "port checks are busy; try again") + case errors.Is(err, context.DeadlineExceeded), errors.Is(err, context.Canceled): + return Errorf(CodeEngine, "the port check ran out of time") + } + return err +} + +var portMethods = map[string]method{ + "ports.inspect": typed(func(e Engine, p struct { + Port int `json:"port"` + }) (*PortInspection, error) { + s, err := porter(e, "ports.inspect") + if err != nil { + return nil, err + } + if p.Port < 1 || p.Port > 65535 { + return nil, portError(ErrPortInvalid) + } + ctx, cancel := context.WithTimeout(context.Background(), portCallBudget) + defer cancel() + out, err := s.InspectPort(ctx, p.Port) + if err != nil { + return nil, portError(err) + } + if out.Holder != nil && (out.Holder.PID <= 0 || out.Holder.Name == "") { + out.Holder = nil + } + return &out, nil + }), + "ports.suggest": typed(func(e Engine, p struct { + After int `json:"after"` + }) (*PortSuggestion, error) { + s, err := porter(e, "ports.suggest") + if err != nil { + return nil, err + } + // 65535 has nothing after it and the scan never wraps. + if p.After < 1 || p.After > 65534 { + return nil, Errorf(CodeBadParams, "after must be a whole number from 1 to 65534") + } + ctx, cancel := context.WithTimeout(context.Background(), portCallBudget) + defer cancel() + out, err := s.SuggestPort(ctx, p.After) + if err != nil { + return nil, portError(err) + } + if out.Port < 1 || out.Port > 65535 { + return nil, Errorf(CodeEngine, "the engine answered a port out of range") + } + return &out, nil + }), +} diff --git a/internal/appwire/ports_test.go b/internal/appwire/ports_test.go new file mode 100644 index 0000000000..37b5ac4b85 --- /dev/null +++ b/internal/appwire/ports_test.go @@ -0,0 +1,87 @@ +package appwire + +import ( + "context" + "encoding/json" + "errors" + "testing" + "time" +) + +type portEngine struct { + fakeEngine + err error + got PortInspection +} + +func (p *portEngine) InspectPort(_ context.Context, port int) (PortInspection, error) { + if p.err != nil { + return PortInspection{}, p.err + } + out := p.got + out.Port = port + return out, nil +} + +func (p *portEngine) SuggestPort(_ context.Context, after int) (PortSuggestion, error) { + if p.err != nil { + return PortSuggestion{}, p.err + } + return PortSuggestion{Port: after + 1, CheckedAt: time.Unix(1, 0), CheckedCount: 1}, nil +} + +func wireCode(t *testing.T, err error) string { + t.Helper() + var w *Error + if !errors.As(err, &w) { + t.Fatalf("not a wire error: %v", err) + } + return w.Code +} + +func TestPortsAbsentWithoutCapability(t *testing.T) { + for name := range portMethods { + _, err := methods[name].call(&fakeEngine{}, json.RawMessage(`{"port":8080,"after":8080}`)) + if wireCode(t, err) != CodeUnknownMethod { + t.Fatalf("%s: %v", name, err) + } + } +} + +func TestPortsValidateAndType(t *testing.T) { + eng := &portEngine{got: PortInspection{State: PortInUse, CheckedAt: time.Unix(1, 0), Holder: &PortHolder{PID: 0, Name: "x"}}} + for _, c := range []struct{ name, params string }{ + {"ports.inspect", `{}`}, {"ports.inspect", `{"port":0}`}, {"ports.inspect", `{"port":65536}`}, {"ports.inspect", `{"port":-1}`}, + {"ports.suggest", `{}`}, {"ports.suggest", `{"after":0}`}, {"ports.suggest", `{"after":65535}`}, + } { + _, err := methods[c.name].call(eng, json.RawMessage(c.params)) + if wireCode(t, err) != CodeBadParams { + t.Fatalf("%s %s: %v", c.name, c.params, err) + } + } + r, err := methods["ports.inspect"].call(eng, json.RawMessage(`{"port":8080}`)) + if err != nil { + t.Fatal(err) + } + if got := r.(*PortInspection); got.Port != 8080 || got.State != PortInUse || got.Holder != nil { + t.Fatalf("a holder with pid 0 must be dropped: %+v", got) + } + r, err = methods["ports.suggest"].call(eng, json.RawMessage(`{"after":65534}`)) + if err != nil || r.(*PortSuggestion).Port != 65535 { + t.Fatalf("%v %v", r, err) + } +} + +func TestPortsErrorCodes(t *testing.T) { + for err, want := range map[error]string{ + ErrPortNoCandidate: CodeNotFound, + ErrPortBusy: CodeBusy, + ErrPortInvalid: CodeBadParams, + context.DeadlineExceeded: CodeEngine, + } { + _, got := methods["ports.suggest"].call(&portEngine{err: err}, json.RawMessage(`{"after":3000}`)) + if wireCode(t, got) != want { + t.Fatalf("%v: %v", err, got) + } + } +} diff --git a/internal/appwire/services.go b/internal/appwire/services.go new file mode 100644 index 0000000000..965aae138d --- /dev/null +++ b/internal/appwire/services.go @@ -0,0 +1,321 @@ +package appwire + +import ( + "strings" + "time" +) + +// Kept-running services (V77): processes a person agreed to keep running +// after a turn, supervised by the engine. A service is listed with the +// agent's other processes — ONE Running/Ended list and one count — as a +// [Process] whose Service carries what only a service has. Its id is +// ":s", which no ordinary process id can be (":p…"). +// +// HOW A SERVICE ROW IS CLASSIFIED. Process.State is "running" while the engine +// owns a live process (Service.Running or Service.Stopping) or while the +// service is starting or restarting with no process yet; it is "stopped" for a +// stopped service, "failed" for failed and gave_up, and "ended" for +// not_running. A service whose stop failed still owns its process: Running is +// true and State is "running", never "ended". + +// The lifecycle of a service. +const ( + ServiceStarting = "starting" + ServiceHealthy = "healthy" + ServiceUnhealthy = "unhealthy" + ServiceRestarting = "restarting" + ServiceGaveUp = "gave_up" + ServiceNotRunning = "not_running" + ServiceStopped = "stopped" + ServiceFailed = "failed" +) + +// The restart policies. +const ( + PolicyNever = "never" + PolicyOnFailure = "on_failure" + PolicyAlways = "always" +) + +// The keep-running ask: its subkind and its three option ids. +const ( + SubkindKeepRunning = "keep_running" + OptionAllow = "allow" + OptionAllowNever = "allow_never" +) + +// A service in trouble: its Inbox line is an Ask of subkind service_health +// about subject service, answered restart or stop; it resolves recovered or +// stopped; and its one notice in the chat is a footer of kind service_trouble. +const ( + SubkindServiceHealth = "service_health" + SubjectService = "service" + OptionRestart = "restart" + ResolvedRecovered = "recovered" + ResolvedStopped = "stopped" + FooterServiceTrouble = "service_trouble" +) + +// KeepRunning is what a keep-running ask proposes, read from the engine's own +// card and never from its words. The command is redacted. +type KeepRunning struct { + Name string `json:"name"` + Command string `json:"command"` + Dir string `json:"dir"` + Health ServiceHealth `json:"health"` + Policy string `json:"policy" enum:"never,on_failure,always"` + CallID string `json:"call_id,omitempty"` + Timing ServiceTiming `json:"timing"` +} + +// ServiceTiming is the supervisor's fixed rules, the same for every service, +// sent so the app says the engine's figures rather than its own: a check +// every check_every_s, each given check_timeout_s; unhealthy after +// unhealthy_after failures in a row; automatic restarts after first_backoff_s, +// doubling, at most restart_limit in any restart_window_s, the count clearing +// after healthy_reset_s healthy; a stop's TERM given stop_grace_s before KILL. +type ServiceTiming struct { + CheckEverySeconds int `json:"check_every_s"` + CheckTimeoutSeconds int `json:"check_timeout_s"` + UnhealthyAfter int `json:"unhealthy_after"` + FirstBackoffSeconds int `json:"first_backoff_s"` + RestartLimit int `json:"restart_limit"` + RestartWindowSeconds int `json:"restart_window_s"` + HealthyResetSeconds int `json:"healthy_reset_s"` + StopGraceSeconds int `json:"stop_grace_s"` +} + +// Service is what a kept-running process carries beyond an ordinary one. +type Service struct { + Name string `json:"name"` + Dir string `json:"dir"` + Health ServiceHealth `json:"health"` + Timing ServiceTiming `json:"timing"` + OpenURL string `json:"open_url,omitempty"` + Policy string `json:"policy" enum:"never,on_failure,always"` + // Lifecycle is the engine's state of the service; Running and Stopping + // are whether it owns a live process right now, which a failed stop can + // make true in any lifecycle. + Lifecycle string `json:"lifecycle" enum:"starting,healthy,unhealthy,restarting,gave_up,not_running,stopped,failed"` + Reason string `json:"reason,omitempty"` + Running bool `json:"running"` + Stopping bool `json:"stopping,omitempty"` + PID int `json:"pid,omitempty"` + StartedHow string `json:"started_how,omitempty" enum:"agent,restart,person,after_restart,re_adopted"` + StartedBy ServiceOrigin `json:"started_by"` + Check *ServiceCheck `json:"check,omitempty"` + // Restarts is the automatic restarts in the last ten minutes, of + // RestartLimit; a person's restart is not one of them. + Restarts int `json:"restarts"` + RestartLimit int `json:"restart_limit"` + BackoffUntil *time.Time `json:"backoff_until,omitempty"` + ReAdopted bool `json:"re_adopted,omitempty"` + ReAdoptedAt *time.Time `json:"re_adopted_at,omitempty"` + PortConflict bool `json:"port_conflict,omitempty"` + // Episode names one run of trouble, from its first failure to being + // healthy again or stopped: one Inbox line, updated in place. Its facts + // stay on the row after it closes. + Episode string `json:"episode,omitempty"` + EpisodeOpen bool `json:"episode_open,omitempty"` + EpisodeReason string `json:"episode_reason,omitempty"` + EpisodeStartedAt *time.Time `json:"episode_started_at,omitempty"` + EpisodeResolvedAt *time.Time `json:"episode_resolved_at,omitempty"` + StoppedBy string `json:"stopped_by,omitempty"` + OutputTruncated bool `json:"output_truncated,omitempty"` + // Removed is a service taken off the list whose output is still kept. + Removed bool `json:"removed,omitempty"` +} + +// ServiceHealth is a service's one check. +type ServiceHealth struct { + Kind string `json:"kind" enum:"port,url,cmd"` + Port int `json:"port,omitempty"` + URL string `json:"url,omitempty"` + Cmd string `json:"cmd,omitempty"` + Words string `json:"words"` +} + +// ServiceOrigin is the request that started a service: the person's words, +// the turn and the call. +type ServiceOrigin struct { + Label string `json:"label,omitempty"` + Turn uint64 `json:"turn,omitempty"` + CallID string `json:"call_id,omitempty"` + At time.Time `json:"at"` +} + +// ServiceCheck is a service's newest check. +type ServiceCheck struct { + OK bool `json:"ok"` + At time.Time `json:"at"` + Fails int `json:"fails"` + Detail string `json:"detail,omitempty"` +} + +// OutputPage is one bounded read of a service's output. +type OutputPage struct { + Lines []string `json:"lines"` + FirstLine int `json:"first_line"` + NextLine int `json:"next_line"` + TotalLines int `json:"total_lines"` + Truncated bool `json:"truncated,omitempty"` +} + +// StopAllResult is services.stop_all's answer. +type StopAllResult struct { + Stopped int `json:"stopped"` +} + +// Servicer is the engine's half of kept-running services. An [Engine] that is +// not one refuses these methods as unknown, which is how an engine with no +// supervisor says the capability is absent. +type Servicer interface { + // ListServices is one agent's services, or every agent's for "". + ListServices(agent string) ([]Process, error) + GetService(id string) (Process, error) + SetServicePolicy(id, policy string) (Process, error) + RestartService(id string) (Process, error) + StartService(id string) (Process, error) + StopService(id string) (Process, error) + RemoveService(id string) error + // StopAllServices stops every agent's services and waits for their + // groups to go, for a quit that was asked to stop them. + StopAllServices() (int, error) + ServiceOutput(id string, from, max int) (OutputPage, error) + ServiceTail(id string, lines int) (OutputPage, error) +} + +func init() { + for name, m := range serviceMethods { + methods[name] = m + } +} + +func servicer(e Engine, name string) (Servicer, error) { + s, ok := e.(Servicer) + if !ok { + return nil, Errorf(CodeUnknownMethod, "this engine does not serve %s", name) + } + return s, nil +} + +// serviceVerb is one service verb that takes only an id and answers the row. +func serviceVerb(name string, act func(Servicer, string) (Process, error)) method { + return typed(func(e Engine, p IDParams) (*ProcessResult, error) { + s, err := servicer(e, name) + if err != nil { + return nil, err + } + id, err := required("id", p.ID) + if err != nil { + return nil, err + } + process, err := act(s, id) + if err != nil { + return nil, err + } + return &ProcessResult{process}, nil + }) +} + +var serviceMethods = map[string]method{ + "services.list": typed(func(e Engine, p struct { + Agent string `json:"agent,omitempty"` + }) (*ProcessesResult, error) { + s, err := servicer(e, "services.list") + if err != nil { + return nil, err + } + list, err := s.ListServices(strings.TrimSpace(p.Agent)) + if err != nil { + return nil, err + } + return &ProcessesResult{nonNil(list)}, nil + }), + "services.get": serviceVerb("services.get", Servicer.GetService), + "services.restart": serviceVerb("services.restart", Servicer.RestartService), + "services.start": serviceVerb("services.start", Servicer.StartService), + "services.stop": serviceVerb("services.stop", Servicer.StopService), + "services.policy": typed(func(e Engine, p struct { + ID string `json:"id"` + Policy string `json:"policy" enum:"never,on_failure,always"` + }) (*ProcessResult, error) { + s, err := servicer(e, "services.policy") + if err != nil { + return nil, err + } + id, err := required("id", p.ID) + if err != nil { + return nil, err + } + switch p.Policy { + case PolicyNever, PolicyOnFailure, PolicyAlways: + default: + return nil, Errorf(CodeBadParams, "policy is never, on_failure or always") + } + process, err := s.SetServicePolicy(id, p.Policy) + if err != nil { + return nil, err + } + return &ProcessResult{process}, nil + }), + "services.remove": typed(func(e Engine, p IDParams) (*Empty, error) { + s, err := servicer(e, "services.remove") + if err != nil { + return nil, err + } + id, err := required("id", p.ID) + if err != nil { + return nil, err + } + return done(s.RemoveService(id)) + }), + "services.stop_all": typed(func(e Engine, _ struct{}) (*StopAllResult, error) { + s, err := servicer(e, "services.stop_all") + if err != nil { + return nil, err + } + n, err := s.StopAllServices() + if err != nil { + return nil, err + } + return &StopAllResult{Stopped: n}, nil + }), + "services.output": typed(func(e Engine, p struct { + ID string `json:"id"` + FromLine int `json:"from_line"` + MaxLines int `json:"max_lines,omitempty"` + }) (*OutputPage, error) { + s, err := servicer(e, "services.output") + if err != nil { + return nil, err + } + if strings.TrimSpace(p.ID) == "" || p.FromLine < 0 || p.MaxLines < 0 { + return nil, Errorf(CodeBadParams, "services.output needs an id, a from_line of zero or more and max_lines of zero or more") + } + page, err := s.ServiceOutput(p.ID, p.FromLine, p.MaxLines) + if err != nil { + return nil, err + } + page.Lines = nonNil(page.Lines) + return &page, nil + }), + "services.tail": typed(func(e Engine, p struct { + ID string `json:"id"` + Lines int `json:"lines,omitempty"` + }) (*OutputPage, error) { + s, err := servicer(e, "services.tail") + if err != nil { + return nil, err + } + if strings.TrimSpace(p.ID) == "" || p.Lines < 0 { + return nil, Errorf(CodeBadParams, "services.tail needs an id and lines of zero or more") + } + page, err := s.ServiceTail(p.ID, p.Lines) + if err != nil { + return nil, err + } + page.Lines = nonNil(page.Lines) + return &page, nil + }), +} diff --git a/internal/appwire/services_test.go b/internal/appwire/services_test.go new file mode 100644 index 0000000000..dc640d5ef4 --- /dev/null +++ b/internal/appwire/services_test.go @@ -0,0 +1,118 @@ +package appwire + +import ( + "encoding/json" + "errors" + "testing" +) + +type servicesEngine struct { + fakeEngine + verb, id, agent, policy string + from, max int +} + +func (s *servicesEngine) ListServices(a string) ([]Process, error) { s.agent = a; return nil, nil } +func (s *servicesEngine) row(verb, id string) (Process, error) { + s.verb, s.id = verb, id + return Process{ID: id, Agent: "a1", State: "running", Service: &Service{Lifecycle: ServiceStopped, Running: true}}, nil +} +func (s *servicesEngine) GetService(id string) (Process, error) { return s.row("get", id) } +func (s *servicesEngine) SetServicePolicy(id, p string) (Process, error) { + s.policy = p + return s.row("policy", id) +} +func (s *servicesEngine) RestartService(id string) (Process, error) { return s.row("restart", id) } +func (s *servicesEngine) StartService(id string) (Process, error) { return s.row("start", id) } +func (s *servicesEngine) StopService(id string) (Process, error) { return s.row("stop", id) } +func (s *servicesEngine) RemoveService(id string) error { s.id = id; s.verb = "remove"; return nil } +func (s *servicesEngine) StopAllServices() (int, error) { s.verb = "stop_all"; return 2, nil } +func (s *servicesEngine) ServiceOutput(id string, from, max int) (OutputPage, error) { + s.id, s.from, s.max = id, from, max + return OutputPage{}, nil +} +func (s *servicesEngine) ServiceTail(id string, n int) (OutputPage, error) { + s.id, s.max = id, n + return OutputPage{}, nil +} + +func TestServicesRoutesRequireCapabilityAndValidArguments(t *testing.T) { + eng := &servicesEngine{} + for name := range serviceMethods { + _, err := methods[name].call(&fakeEngine{}, json.RawMessage(`{"id":"a1:s1","policy":"never"}`)) + var wire *Error + if !errors.As(err, &wire) || wire.Code != CodeUnknownMethod { + t.Fatalf("%s without capability: %v", name, err) + } + } + for _, tc := range []struct{ name, params string }{ + {"services.get", `{}`}, {"services.stop", `{"id":" "}`}, {"services.restart", `{}`}, {"services.start", `{}`}, {"services.remove", `{}`}, + {"services.policy", `{"id":"a1:s1","policy":"sometimes"}`}, {"services.policy", `{"policy":"never"}`}, + {"services.output", `{"id":"a1:s1","from_line":-1}`}, {"services.output", `{"id":"a1:s1","max_lines":-1}`}, + {"services.tail", `{"id":"a1:s1","lines":-1}`}, + } { + _, err := methods[tc.name].call(eng, json.RawMessage(tc.params)) + var wire *Error + if !errors.As(err, &wire) || wire.Code != CodeBadParams { + t.Fatalf("%s %s: %v", tc.name, tc.params, err) + } + } +} + +func TestServicesRoutesPreserveOwnershipAndTypedReplies(t *testing.T) { + eng := &servicesEngine{} + call := func(name, params string) map[string]json.RawMessage { + t.Helper() + v, err := methods[name].call(eng, json.RawMessage(params)) + if err != nil { + t.Fatal(err) + } + b, err := json.Marshal(v) + if err != nil { + t.Fatal(err) + } + var out map[string]json.RawMessage + if err = json.Unmarshal(b, &out); err != nil { + t.Fatal(err) + } + return out + } + out := call("services.list", `{}`) + if eng.agent != "" || string(out["processes"]) != "[]" { + t.Fatalf("all owners/empty array: %s %+v", out["processes"], eng) + } + call("services.list", `{"agent":" a1 "}`) + if eng.agent != "a1" { + t.Fatal("agent owner not forwarded") + } + for _, verb := range []string{"get", "start", "restart", "stop"} { + out = call("services."+verb, `{"id":"a1:s1"}`) + var p Process + if err := json.Unmarshal(out["process"], &p); err != nil { + t.Fatal(err) + } + if eng.verb != verb || eng.id != "a1:s1" || p.ID != "a1:s1" || p.Service == nil || !p.Service.Running || p.State != "running" { + t.Fatalf("%s lost owner or live service: %+v", verb, p) + } + } + call("services.policy", `{"id":"a1:s1","policy":"on_failure"}`) + if eng.id != "a1:s1" || eng.policy != "on_failure" { + t.Fatal("policy target lost") + } + out = call("services.output", `{"id":"a1:s1","from_line":7,"max_lines":12}`) + if eng.from != 7 || eng.max != 12 || string(out["lines"]) != "[]" { + t.Fatal("output cursor or empty array lost") + } + out = call("services.tail", `{"id":"a1:s1","lines":6}`) + if eng.max != 6 || string(out["lines"]) != "[]" { + t.Fatal("tail count or empty array lost") + } + call("services.remove", `{"id":"a1:s1"}`) + if eng.verb != "remove" || eng.id != "a1:s1" { + t.Fatal("remove target lost") + } + out = call("services.stop_all", `{}`) + if string(out["stopped"]) != "2" { + t.Fatal("stop-all count lost") + } +} diff --git a/internal/appwire/wire.go b/internal/appwire/wire.go index 1898ea67b0..fdc95c5980 100644 --- a/internal/appwire/wire.go +++ b/internal/appwire/wire.go @@ -199,7 +199,7 @@ type Ask struct { ID string `json:"id"` Agent string `json:"agent"` Kind string `json:"kind" enum:"approval,question"` - Subkind string `json:"subkind,omitempty" enum:"permission,budget,crew_decision,crew_proposal,order_run"` + Subkind string `json:"subkind,omitempty" enum:"permission,budget,crew_decision,crew_proposal,order_run,keep_running,service_health"` Title string `json:"title"` Detail string `json:"detail,omitempty"` // Options carry the engine's own ids; for a permission they are @@ -240,12 +240,16 @@ type Ask struct { // goal. It is kept in the app layer until it is answered, so the ask is // asked again after a restart. Proposal *Proposal `json:"proposal,omitempty"` + // KeepRunning is a keep-running ask's whole proposal (services.go): the + // exact command and folder, its one check, the proposed policy, and the + // fixed timings it will run under. + KeepRunning *KeepRunning `json:"keep_running,omitempty"` } // AskSubject is the thing an ask is about: its kind and the id the app knows // it by. type AskSubject struct { - Kind string `json:"kind" enum:"automation,task,call,run,page,account,crew_proposal,crew_decision,budget"` + Kind string `json:"kind" enum:"automation,task,call,run,page,account,crew_proposal,crew_decision,budget,service"` Ref string `json:"ref,omitempty"` } diff --git a/internal/appwire/work.go b/internal/appwire/work.go index 9fc9897b2b..705538e4ef 100644 --- a/internal/appwire/work.go +++ b/internal/appwire/work.go @@ -111,6 +111,9 @@ type Process struct { // Reason is why a stopped process stopped, when something other than a // person stopped it: "stopped by quit". Reason string `json:"reason,omitempty"` + // Service is set on a kept-running process (services.go); an ordinary + // process has none. + Service *Service `json:"service,omitempty"` } // The data of this wave's events. diff --git a/internal/manual/chat/kept-running.md b/internal/manual/chat/kept-running.md new file mode 100644 index 0000000000..aed66b84cd --- /dev/null +++ b/internal/manual/chat/kept-running.md @@ -0,0 +1,64 @@ +# Kept-up processes — a dev server that stays up after the turn + +## Keep a dev server up after the turn — Allow, Allow, never restart, Deny + +In the codeaf desktop app, the agent can ask to keep one command up after its turn +ends: a dev server, a database, a watcher. It names the exact command, the folder it runs +in and one health check, and proposes a restart policy. You answer on a card: + +- **Allow** — it stays up, restarted the way it proposed. +- **Allow, never restart** — it stays up and is never restarted. +- **Deny** — "It runs as an ordinary process and stops when this turn ends." + +Your answer is remembered for that exact command in that exact folder with that check, so +the same command is not asked about twice. The agent cannot change the policy you chose; +a different check is asked about again. This is not the `codeaf services` command in a +shell, which lists background processes of its own. + +## How a kept-up process is checked — healthy, unhealthy, restarting + +One check every 10 seconds, the first 10 seconds after it starts, each given 5 seconds: a +port that accepts a connection, a URL that answers 200 (only 200), or a command that exits +0 in its folder. Three failures in a row make it unhealthy; one pass makes it healthy again. + +Under a restart policy it is restarted after 2, then 4, then 8 seconds — at most 3 +automatic restarts in any 10 minutes, the count clearing after 10 healthy minutes. Past +that it stops restarting and says so. A restart you ask for yourself is yours: it is not one +of the 3. A port another program already holds is said as "port N is used by another +program", never started over it. + +Each run of trouble is one line in the Inbox, updated as it changes, and one notice in the +chat that started it; when it is healthy again the line moves to done. + +## Stop, restart, start again or remove a kept-up process + +It is listed with the agent's other processes, in the same list. + +- **Stop** sends SIGTERM, then SIGKILL after 5 seconds. Its output is kept. A process + that would not stop stays listed as up, saying "did not stop". +- **Restart** stops it and starts it again from the same command, outside the automatic + budget. +- **Start again** starts the saved command after a stop. +- **Remove** takes a stopped one off the list. Its output is kept, and the chat's notice + still opens it. + +## Quitting the app or closing an agent with processes kept up + +Quitting the app leaves kept-up processes up unless you choose to stop them; the +next codeaf takes back only a process it can prove is the same one (its process id, its +start time and the machine's boot). After a reboot one is started again only under a +restart policy. + +Closing an agent stops what it kept up and waits for it to be gone. If one will not +stop, the agent stays open and the error names it. Letting an agent go idle stops nothing. + +## Where keeping a process up is not available + +Keeping a process up exists only in the codeaf desktop app on macOS and Linux, and only in a +conversation where you answer cards — not in the terminal chat, not inside a task, and not +when another codeaf already looks after this machine's kept-up processes. There the +agent is told "keep_running is not available here, so nothing was started", and it can run +the command as an ordinary background job instead. + +A kept-up process's output is kept while codeaf is open, in two files of 2 MiB that take +turns; output written while no codeaf is open is not cut back until codeaf next runs. diff --git a/internal/manual/chat/what-i-can-do.md b/internal/manual/chat/what-i-can-do.md index 44d42fd4e9..2ef4871ea6 100644 --- a/internal/manual/chat/what-i-can-do.md +++ b/internal/manual/chat/what-i-can-do.md @@ -186,7 +186,8 @@ Yes. The `bash` tool runs tests, builds and development servers through `/bin/ba in your workspace. A server meant to stay up can start in the background immediately. It streams stdout and stderr together, keeps unexpectedly long commands running as background jobs instead of killing them, and reports a non-zero exit as -`Command exited with code N`. +`Command exited with code N`. In the desktop app it can also ask to keep a server up after +the turn, once you allow it (see "Keep a dev server up after the turn"). ## Run a command or build the project — background after and timeout diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 0f1e2513ca..91915f6ed7 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -3192,6 +3192,9 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"what happens to a running chat when I close its tab", "screen"}, {"what does keep running do", "screen"}, {"what is the difference between keep running and stop work", "screen"}, + {"can my dev server stay up after the turn", "kept-running"}, + {"what does allow never restart mean", "kept-running"}, + {"how often is my dev server health checked", "kept-running"}, {"k doesn't do anything on the close tab card any more", "screen"}, {"how do I close a tab without stopping the work", "screen"}, // ctrl+r in /model (internal/tui3's modelrefresh.go), asked the ways diff --git a/internal/ports/holder_unix.go b/internal/ports/holder_unix.go new file mode 100644 index 0000000000..1c545f03e6 --- /dev/null +++ b/internal/ports/holder_unix.go @@ -0,0 +1,168 @@ +//go:build !windows + +package ports + +import ( + "bytes" + "context" + "errors" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "syscall" + "time" +) + +const ( + // HelperBudget is the longest the lookup helper runs, HelperOutputCap + // the most of its output ever held. + HelperBudget = 500 * time.Millisecond + HelperOutputCap = 64 << 10 + maxNameLen = 64 +) + +// lsofPaths are the only places the helper is looked for: absolute and +// fixed, so no PATH entry or user directory can stand in for it. +var lsofPaths = []string{"/usr/sbin/lsof", "/usr/bin/lsof"} + +func addrInUse(error) bool { return false } + +// lsofHolder asks the system lsof for the one listener on a TCP port. The +// argv is fixed apart from the port, which is an integer. +func lsofHolder(ctx context.Context, port int) *Holder { + if port < MinPort || port > MaxPort { + return nil + } + for _, path := range lsofPaths { + if st, err := os.Stat(path); err != nil || st.IsDir() { + continue + } + out, err := runCapped(ctx, path, []string{"-nP", "+c", "0", "-iTCP:" + strconv.Itoa(port), "-sTCP:LISTEN", "-F", "pc"}, HelperBudget, HelperOutputCap) + if err != nil { + // lsof exits 1 for "nothing found", and an error is not proof. + return nil + } + return ParseLsof(out) + } + return nil +} + +var errOverflow = errors.New("helper output over cap") + +// cappedWriter keeps at most limit bytes and cancels the helper the moment +// one more arrives: output is never held without bound. +type cappedWriter struct { + buf bytes.Buffer + limit int + overflow bool + cancel context.CancelFunc +} + +func (w *cappedWriter) Write(p []byte) (int, error) { + if w.buf.Len()+len(p) > w.limit { + w.overflow = true + w.cancel() + return 0, errOverflow + } + return w.buf.Write(p) +} + +// runCapped runs path with argv and an empty environment, bounded in time and +// output, in its own process group so a helper that spawned anything is +// killed whole. A timeout, an overflow or a non-zero exit is an error. +func runCapped(ctx context.Context, path string, argv []string, budget time.Duration, limit int) ([]byte, error) { + ctx, cancel := context.WithTimeout(ctx, budget) + defer cancel() + w := &cappedWriter{limit: limit, cancel: cancel} + cmd := exec.CommandContext(ctx, path, argv...) + cmd.Env = []string{"LC_ALL=C", "PATH="} + cmd.Stdout = w + cmd.SysProcAttr = &syscall.SysProcAttr{Setpgid: true} + cmd.Cancel = func() error { + if cmd.Process != nil { + _ = syscall.Kill(-cmd.Process.Pid, syscall.SIGKILL) + } + return nil + } + cmd.WaitDelay = 200 * time.Millisecond + err := cmd.Run() + if w.overflow { + return nil, errOverflow + } + if err != nil { + return nil, err + } + if ctx.Err() != nil { + return nil, ctx.Err() + } + return w.buf.Bytes(), nil +} + +// ParseLsof reads lsof -F pc output: "p" then "c" per process. +// It answers a holder only when the text is well formed and names exactly +// one process; two processes, a missing name, a bad pid or an unterminated +// last line are all nil, because a guessed holder is worse than none. +func ParseLsof(out []byte) *Holder { + if len(out) == 0 || out[len(out)-1] != '\n' { + return nil + } + type record struct { + pid int + name string + } + var recs []record + for _, line := range strings.Split(strings.TrimSuffix(string(out), "\n"), "\n") { + if line == "" { + return nil + } + switch line[0] { + case 'p': + n, err := strconv.Atoi(line[1:]) + if err != nil || n <= 0 { + return nil + } + recs = append(recs, record{pid: n}) + case 'c': + if len(recs) == 0 || recs[len(recs)-1].name != "" { + return nil + } + name := cleanName(line[1:]) + if name == "" { + return nil + } + recs[len(recs)-1].name = name + default: + return nil + } + } + if len(recs) == 0 { + return nil + } + for _, r := range recs { + if r.name == "" || r.pid != recs[0].pid || r.name != recs[0].name { + return nil + } + } + return &Holder{PID: recs[0].pid, Name: recs[0].name} +} + +// cleanName is the program's base name: no path, no control bytes, bounded. +func cleanName(s string) string { + s = filepath.Base(strings.TrimSpace(s)) + var b strings.Builder + for _, r := range s { + if r < 0x20 || r == 0x7f { + return "" + } + b.WriteRune(r) + if b.Len() >= maxNameLen { + break + } + } + if b.String() == "." || b.String() == "/" { + return "" + } + return b.String() +} diff --git a/internal/ports/holder_windows.go b/internal/ports/holder_windows.go new file mode 100644 index 0000000000..176bb531a3 --- /dev/null +++ b/internal/ports/holder_windows.go @@ -0,0 +1,16 @@ +//go:build windows + +package ports + +import ( + "context" + "errors" + "syscall" +) + +const wsaEADDRINUSE syscall.Errno = 10048 + +func addrInUse(err error) bool { return errors.Is(err, wsaEADDRINUSE) } + +// lsofHolder is absent on Windows: a holder is simply not named. +func lsofHolder(context.Context, int) *Holder { return nil } diff --git a/internal/ports/ports.go b/internal/ports/ports.go new file mode 100644 index 0000000000..da003fbfe7 --- /dev/null +++ b/internal/ports/ports.go @@ -0,0 +1,244 @@ +// Package ports answers two questions about this machine's TCP ports, as an +// observation and nothing more: is this port taken right now, and by whom, +// and which port past this one is free right now. +// +// OBSERVATION, NEVER RESERVATION. A port reported free was free for the +// instant it was bound and released; nothing holds it afterwards, so a later +// bind can still lose a race and a caller must say so. A port held by another +// program is never touched, killed or reconfigured here. +// +// The package knows no application policy: it opens no connection to any +// host, runs no command a caller supplied, and raises no notice. +package ports + +import ( + "context" + "errors" + "net" + "strconv" + "syscall" + "time" +) + +// The bounds and budgets. They are stated once so the wire document and the +// tests quote them rather than copy them. +const ( + // MinPort and MaxPort are the ports that can be asked about; port 0 asks + // the kernel to choose and is never a port a person's command can name. + MinPort = 1 + MaxPort = 65535 + // FirstSuggested is the lowest port a suggestion returns: the privileged + // range is not offered to a command that is not run as root. + FirstSuggested = 1024 + // MaxCandidates is the most ports one suggestion looks at, and + // SuggestBudget the longest it takes doing so. + MaxCandidates = 128 + SuggestBudget = time.Second + // InspectBudget bounds one inspection, the holder lookup included. + InspectBudget = 1500 * time.Millisecond + // Concurrency is how many inspections or scans run at once; the next + // is refused with ErrBusy rather than queued. + Concurrency = 4 +) + +// State is what one port was seen to be. +type State string + +const ( + StateFree State = "free" + StateInUse State = "in_use" + // StateUnknown is every answer that is not a proof: a refusal, a deadline, + // a platform that would not say. It is never read as free. + StateUnknown State = "unknown" +) + +// Holder is the program holding a port: a positive process id and the +// program's name, never its command line or a path. +type Holder struct { + PID int + Name string +} + +// Inspection is one port as it was seen. +type Inspection struct { + Port int + State State + CheckedAt time.Time + Holder *Holder + // Detail says in a few words why a state is unknown. + Detail string +} + +// Suggestion is a port that was free when the scan bound it. +type Suggestion struct { + Port int + CheckedAt time.Time + CheckedCount int +} + +var ( + // ErrInvalid is a port or starting point outside what can be asked. + ErrInvalid = errors.New("port out of range") + // ErrNoCandidate is a scan that found no free port within its bounds. + ErrNoCandidate = errors.New("no free port found") + // ErrBusy is the concurrency budget being spent. + ErrBusy = errors.New("port checks are busy") +) + +// Binder is one bind attempt, replaceable in tests. It answers nil when the +// address could be bound (and has already been released), and the bind error +// otherwise. +type Binder func(ctx context.Context, network, address string) error + +// HolderFinder names the holder of a listening port, or nil when it cannot be +// proved. It never returns an error: not knowing is a nil holder. +type HolderFinder func(ctx context.Context, port int) *Holder + +// Checker is the engine's port observer. The zero value is not usable; use +// [New]. +type Checker struct { + bind Binder + holder HolderFinder + now func() time.Time + slots chan struct{} +} + +// New is a Checker that binds for real and looks holders up with the +// platform's lsof when there is one. +func New() *Checker { + return &Checker{bind: nativeBind, holder: lsofHolder, now: time.Now, slots: make(chan struct{}, Concurrency)} +} + +// probe is one address that must be free for a port to be free: both +// wildcards, because a wildcard bind is what a server does, and both +// loopbacks, because on some systems a loopback listener does not stop a +// wildcard bind. +var probes = []struct { + network, host string + optional bool // an address family this machine may lack +}{ + {"tcp4", "0.0.0.0", false}, + {"tcp4", "127.0.0.1", false}, + {"tcp6", "::", true}, + {"tcp6", "::1", true}, +} + +func nativeBind(ctx context.Context, network, address string) error { + var lc net.ListenConfig + l, err := lc.Listen(ctx, network, address) + if err != nil { + return err + } + // Closed on every path: a probe never keeps the port. + return l.Close() +} + +func (c *Checker) acquire(ctx context.Context) (func(), error) { + select { + case c.slots <- struct{}{}: + return func() { <-c.slots }, nil + case <-ctx.Done(): + return nil, ctx.Err() + default: + return nil, ErrBusy + } +} + +// observe binds every probe address once and says what it saw. +func (c *Checker) observe(ctx context.Context, port int) (State, string) { + state, detail := StateFree, "" + for _, p := range probes { + if ctx.Err() != nil { + return StateUnknown, "the check ran out of time" + } + err := c.bind(ctx, p.network, net.JoinHostPort(p.host, strconv.Itoa(port))) + switch { + case err == nil: + case errors.Is(err, syscall.EADDRINUSE) || addrInUse(err): + return StateInUse, "" + case p.optional && familyAbsent(err): + // IPv6 missing here says nothing about the port. + case ctx.Err() != nil: + return StateUnknown, "the check ran out of time" + default: + state, detail = StateUnknown, bindDetail(err) + } + } + return state, detail +} + +func bindDetail(err error) string { + if errors.Is(err, syscall.EACCES) || errors.Is(err, syscall.EPERM) { + return "not permitted to check this port" + } + return "this port could not be checked" +} + +func familyAbsent(err error) bool { + for _, e := range []error{syscall.EAFNOSUPPORT, syscall.EPROTONOSUPPORT, syscall.EADDRNOTAVAIL} { + if errors.Is(err, e) { + return true + } + } + return false +} + +// Inspect says whether port is free, and when it is taken, by whom if that +// can be proved. +func (c *Checker) Inspect(ctx context.Context, port int) (Inspection, error) { + if port < MinPort || port > MaxPort { + return Inspection{}, ErrInvalid + } + release, err := c.acquire(ctx) + if err != nil { + return Inspection{}, err + } + defer release() + ctx, cancel := context.WithTimeout(ctx, InspectBudget) + defer cancel() + out := Inspection{Port: port} + out.State, out.Detail = c.observe(ctx, port) + out.CheckedAt = c.now() + if out.State == StateInUse && c.holder != nil { + if h := c.holder(ctx, port); h != nil && h.PID > 0 && h.Name != "" { + out.Holder = h + } + } + return out, nil +} + +// Suggest answers the first port past after that was free when bound. It +// starts at after+1 (never below FirstSuggested), looks at no more than +// MaxCandidates ports in SuggestBudget, and never wraps past MaxPort. A +// candidate that is busy or could not be checked is skipped, never offered. +func (c *Checker) Suggest(ctx context.Context, after int) (Suggestion, error) { + if after < MinPort || after > MaxPort { + return Suggestion{}, ErrInvalid + } + release, err := c.acquire(ctx) + if err != nil { + return Suggestion{}, err + } + defer release() + ctx, cancel := context.WithTimeout(ctx, SuggestBudget) + defer cancel() + start := after + 1 + if start < FirstSuggested { + start = FirstSuggested + } + checked := 0 + for port := start; port <= MaxPort && checked < MaxCandidates; port++ { + if err := ctx.Err(); err != nil { + return Suggestion{}, err + } + checked++ + state, _ := c.observe(ctx, port) + if state == StateFree { + return Suggestion{Port: port, CheckedAt: c.now(), CheckedCount: checked}, nil + } + } + if err := ctx.Err(); err != nil { + return Suggestion{}, err + } + return Suggestion{CheckedCount: checked}, ErrNoCandidate +} diff --git a/internal/ports/ports_test.go b/internal/ports/ports_test.go new file mode 100644 index 0000000000..d7f6346b5f --- /dev/null +++ b/internal/ports/ports_test.go @@ -0,0 +1,298 @@ +package ports + +import ( + "context" + "errors" + "net" + "os" + "path/filepath" + "runtime" + "strconv" + "syscall" + "testing" + "time" +) + +func listen(t *testing.T, host string) (net.Listener, int) { + t.Helper() + l, err := net.Listen("tcp", net.JoinHostPort(host, "0")) + if err != nil { + t.Skipf("cannot listen on %s: %v", host, err) + } + t.Cleanup(func() { l.Close() }) + return l, l.Addr().(*net.TCPAddr).Port +} + +func TestInspectRealOccupiedAndFreePort(t *testing.T) { + c := New() + l, port := listen(t, "0.0.0.0") + got, err := c.Inspect(context.Background(), port) + if err != nil || got.State != StateInUse || got.Port != port || got.CheckedAt.IsZero() { + t.Fatalf("occupied: %+v %v", got, err) + } + if h := got.Holder; h != nil && (h.PID != os.Getpid() || h.Name == "") { + t.Fatalf("holder named wrongly: %+v", h) + } + l.Close() + got, err = c.Inspect(context.Background(), port) + if err != nil || got.State != StateFree || got.Holder != nil { + t.Fatalf("freed: %+v %v", got, err) + } +} + +func TestLoopbackOnlyListenerIsInUse(t *testing.T) { + _, port := listen(t, "127.0.0.1") + got, _ := New().Inspect(context.Background(), port) + if got.State != StateInUse { + t.Fatalf("loopback listener: %+v", got) + } +} + +func TestSuggestSkipsOccupiedNeighbours(t *testing.T) { + a, pa := listen(t, "0.0.0.0") + _ = a + if pa+1 > MaxPort || pa < FirstSuggested { + t.Skip("ephemeral port out of useful range") + } + b, err := net.Listen("tcp", net.JoinHostPort("0.0.0.0", strconv.Itoa(pa+1))) + if err != nil { + t.Skip("neighbour taken") + } + defer b.Close() + got, err := New().Suggest(context.Background(), pa-1) + if err != nil || got.Port < pa+2 || got.CheckedCount < 3 { + t.Fatalf("suggest: %+v %v", got, err) + } + if again, _ := New().Inspect(context.Background(), got.Port); again.State == StateInUse { + t.Fatalf("suggested port busy: %+v", again) + } +} + +func TestRangeNeverWraps(t *testing.T) { + c := New() + for _, p := range []int{0, -1, 65536} { + if _, err := c.Inspect(context.Background(), p); !errors.Is(err, ErrInvalid) { + t.Fatalf("inspect %d: %v", p, err) + } + if _, err := c.Suggest(context.Background(), p); !errors.Is(err, ErrInvalid) { + t.Fatalf("suggest %d: %v", p, err) + } + } + calls := 0 + c.bind = func(context.Context, string, string) error { calls++; return nil } + if _, err := c.Suggest(context.Background(), MaxPort); !errors.Is(err, ErrNoCandidate) || calls != 0 { + t.Fatalf("after 65535: %v, %d binds", err, calls) + } + var first int + c.bind = func(_ context.Context, _, addr string) error { + _, p, _ := net.SplitHostPort(addr) + first, _ = strconv.Atoi(p) + return nil + } + got, _ := c.Suggest(context.Background(), 80) + if got.Port != FirstSuggested || first != FirstSuggested { + t.Fatalf("low after: %+v", got) + } +} + +func fakeChecker(bind Binder) *Checker { + c := New() + c.bind = bind + c.holder = nil + return c +} + +func TestUnknownIsNeverFree(t *testing.T) { + c := fakeChecker(func(context.Context, string, string) error { return syscall.EACCES }) + got, _ := c.Inspect(context.Background(), 8080) + if got.State != StateUnknown || got.Detail == "" { + t.Fatalf("eacces: %+v", got) + } + if _, err := c.Suggest(context.Background(), 8080); !errors.Is(err, ErrNoCandidate) { + t.Fatalf("refusal offered as free: %v", err) + } +} + +func TestIPv6AbsenceDoesNotOccupy(t *testing.T) { + c := fakeChecker(func(_ context.Context, network, _ string) error { + if network == "tcp6" { + return syscall.EAFNOSUPPORT + } + return nil + }) + if got, _ := c.Inspect(context.Background(), 8080); got.State != StateFree { + t.Fatalf("%+v", got) + } +} + +func TestCandidateBusyContinuesAndBoundsHold(t *testing.T) { + seen := map[string]bool{} + c := fakeChecker(func(_ context.Context, _, addr string) error { + _, p, _ := net.SplitHostPort(addr) + seen[p] = true + if p == "3000" || p == "3001" { + return syscall.EADDRINUSE + } + return nil + }) + got, err := c.Suggest(context.Background(), 2999) + if err != nil || got.Port != 3002 || got.CheckedCount != 3 { + t.Fatalf("%+v %v", got, err) + } + all := fakeChecker(func(context.Context, string, string) error { return syscall.EADDRINUSE }) + got, err = all.Suggest(context.Background(), 2000) + if !errors.Is(err, ErrNoCandidate) || got.CheckedCount != MaxCandidates { + t.Fatalf("bound: %+v %v", got, err) + } +} + +func TestCancelAndDeadlineAreNotFree(t *testing.T) { + ctx, cancel := context.WithCancel(context.Background()) + n := 0 + c := fakeChecker(func(context.Context, string, string) error { + n++ + cancel() + return syscall.EADDRINUSE + }) + if _, err := c.Suggest(ctx, 4000); !errors.Is(err, context.Canceled) || n != 1 { + t.Fatalf("cancel: %v after %d", err, n) + } + c = fakeChecker(func(ctx context.Context, _, _ string) error { <-ctx.Done(); return ctx.Err() }) + start := time.Now() + got, _ := c.Inspect(context.Background(), 4000) + if got.State != StateUnknown || time.Since(start) > 3*time.Second { + t.Fatalf("deadline: %+v after %v", got, time.Since(start)) + } +} + +func TestConcurrencyBudgetRefusesAndReleases(t *testing.T) { + hold := make(chan struct{}) + c := fakeChecker(func(context.Context, string, string) error { <-hold; return nil }) + done := make(chan struct{}, Concurrency) + for i := 0; i < Concurrency; i++ { + go func() { c.Inspect(context.Background(), 5000); done <- struct{}{} }() + } + for len(c.slots) < Concurrency { + time.Sleep(time.Millisecond) + } + if _, err := c.Inspect(context.Background(), 5000); !errors.Is(err, ErrBusy) { + t.Fatalf("over budget: %v", err) + } + close(hold) + for i := 0; i < Concurrency; i++ { + <-done + } + if len(c.slots) != 0 { + t.Fatalf("slots leaked: %d", len(c.slots)) + } +} + +func TestProbeListenersAreAllClosed(t *testing.T) { + _, port := listen(t, "0.0.0.0") + c := New() + for i := 0; i < 3; i++ { + c.Inspect(context.Background(), port) + } + l, _ := net.Listen("tcp", ":0") + l.Close() + other, err := net.Listen("tcp", ":0") + if err != nil { + t.Fatal(err) + } + p := other.Addr().(*net.TCPAddr).Port + other.Close() + c.Inspect(context.Background(), p) + again, err := net.Listen("tcp", ":"+strconv.Itoa(p)) + if err != nil { + t.Fatalf("probe kept the port: %v", err) + } + again.Close() +} + +func TestParseLsofFailsClosed(t *testing.T) { + ok := "p42\ncnode\n" + if h := ParseLsof([]byte(ok)); h == nil || h.PID != 42 || h.Name != "node" { + t.Fatalf("%+v", h) + } + if h := ParseLsof([]byte("p42\ncnode\np42\ncnode\n")); h == nil || h.PID != 42 { + t.Fatalf("same process on v4 and v6: %+v", h) + } + for name, in := range map[string]string{ + "empty": "", "truncated": "p42\ncno", "no name": "p42\n", "no pid": "cnode\n", + "two": "p42\ncnode\np43\ncnode\n", "zero": "p0\ncnode\n", "negative": "p-4\ncx\n", + "junk": "p42\ncnode\nxjunk\n", "word": "pabc\ncx\n", "blank line": "p42\n\ncnode\n", + "control": "p42\nc\x1b[31m\n", + } { + if h := ParseLsof([]byte(in)); h != nil { + t.Errorf("%s: held %+v", name, h) + } + } + if h := ParseLsof([]byte("p9\nc/usr/local/bin/my server\n")); h == nil || h.Name != "my server" { + t.Fatalf("name is a base name: %+v", h) + } +} + +func script(t *testing.T, body string) string { + t.Helper() + if runtime.GOOS == "windows" { + t.Skip("unix helper") + } + p := filepath.Join(t.TempDir(), "helper.sh") + if err := os.WriteFile(p, []byte("#!/bin/sh\n"+body), 0o700); err != nil { + t.Fatal(err) + } + return p +} + +func TestHelperTimeoutDoesNotHangOrLeak(t *testing.T) { + marker := filepath.Join(t.TempDir(), "alive") + p := script(t, "(/bin/sleep 1; echo x > "+marker+") &\n/bin/sleep 30\n") + start := time.Now() + _, err := runCapped(context.Background(), p, nil, 200*time.Millisecond, 1024) + if err == nil || time.Since(start) > 2*time.Second { + t.Fatalf("err %v after %v", err, time.Since(start)) + } + time.Sleep(1500 * time.Millisecond) + if _, err := os.Stat(marker); err == nil { + t.Fatal("a descendant of the helper outlived its cancellation") + } +} + +func TestHelperOutputOverCapFailsClosed(t *testing.T) { + p := script(t, "/bin/yes pxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxxx\n") + start := time.Now() + out, err := runCapped(context.Background(), p, nil, 2*time.Second, 4096) + if err == nil || out != nil || time.Since(start) > 1500*time.Millisecond { + t.Fatalf("%d bytes, %v after %v", len(out), err, time.Since(start)) + } +} + +func TestHelperFailureAndTruncationOmitHolder(t *testing.T) { + c := New() + c.bind = func(context.Context, string, string) error { return syscall.EADDRINUSE } + c.holder = func(context.Context, int) *Holder { return nil } + got, _ := c.Inspect(context.Background(), 8080) + if got.State != StateInUse || got.Holder != nil { + t.Fatalf("%+v", got) + } + c.holder = func(context.Context, int) *Holder { return &Holder{PID: 0, Name: "x"} } + if got, _ = c.Inspect(context.Background(), 8080); got.Holder != nil { + t.Fatalf("pid 0 reported: %+v", got.Holder) + } +} + +// A real lsof against a real listener of this very process. It proves the +// lookup on THIS platform only; it says nothing about another one's lsof. +func TestRealLsofNamesThisProcess(t *testing.T) { + if runtime.GOOS == "windows" { + t.Skip("no lsof here") + } + _, port := listen(t, "0.0.0.0") + h := lsofHolder(context.Background(), port) + if h == nil { + t.Skip("lsof absent or not permitted here") + } + if h.PID != os.Getpid() || h.Name == "" || filepath.Base(h.Name) != h.Name { + t.Fatalf("holder %+v, want pid %d", h, os.Getpid()) + } +} diff --git a/internal/service/admission_test.go b/internal/service/admission_test.go new file mode 100644 index 0000000000..bd9fd9d6d2 --- /dev/null +++ b/internal/service/admission_test.go @@ -0,0 +1,42 @@ +package service + +import ( + "errors" + "fmt" + "strings" + "testing" +) + +// A valid shell command can expand sixfold in JSON (HTML escaping). Count +// bounds alone must not let codeaf write a file its next launch cannot read. +func TestMetadataBudgetRejectsBeforeOverwritingTheReadableStore(t *testing.T) { + h := newHarness(t) + accepted := 0 + rejected := false + for n := 0; n < maxServices; n++ { + spec := webSpec(fmt.Sprintf("web-%d", n), h.dir) + spec.Command = fmt.Sprintf("printf '%%s' '%s' # %d", strings.Repeat("<", 4000), n) + _, err := h.s.Keep(fmt.Sprintf("agent-%d", n/maxPerOwner), spec, PolicyNever, Provenance{}) + if err != nil { + if !errors.Is(err, ErrNotSaved) { + t.Fatalf("metadata budget refusal must be actionable: %v", err) + } + rejected = true + } else { + accepted++ + } + image, readErr := readStore(h.dir) + if readErr != nil { + t.Fatalf("an accepted mutation wrote an unreadable store after %d services: %v", accepted, readErr) + } + if len(image.Services) != accepted || h.rt.starts != accepted { + t.Fatalf("refused mutation must conserve prior records and start no process: records=%d starts=%d accepted=%d", len(image.Services), h.rt.starts, accepted) + } + if rejected { + break + } + } + if !rejected || accepted == 0 { + t.Fatalf("fixture did not exercise metadata admission: accepted=%d rejected=%t", accepted, rejected) + } +} diff --git a/internal/service/archive.go b/internal/service/archive.go new file mode 100644 index 0000000000..a03717173d --- /dev/null +++ b/internal/service/archive.go @@ -0,0 +1,120 @@ +package service + +import ( + "encoding/json" + "errors" + "io" + "os" + "path/filepath" + "regexp" + "time" + + "github.com/Agent-Field/codeaf/internal/redact" +) + +// A REMOVED SERVICE LEAVES THE LIST AND KEEPS ITS OUTPUT. The board's word is +// that Remove "drops it from Processes; its output stays in the chat's notice", +// so Remove deletes the record that makes it a service and nothing that it +// wrote. What stays is the log where it always was, and one small descriptor +// beside it naming whose it was: +// +// /archive/.json {id, owner, name, command, removed} +// /logs/.log untouched, and .log.1 before it +// +// Ids are never handed out twice — the allocator is in the store, written and +// its folder synced before an id is used — so a removed service's output can +// never be overwritten by a later service's. A descriptor is read by its id +// alone, never by listing the folder, so however long codeaf runs, opening +// one old notice costs one small file read. +const ( + archiveDir = "archive" + maxArchiveBytes = 16 << 10 +) + +var serviceID = regexp.MustCompile(`^s[1-9][0-9]{0,9}$`) + +// Archived is a removed service, as its output's owner sees it. +type Archived struct { + ID string `json:"id"` + Owner string `json:"owner"` + Name string `json:"name"` + Command string `json:"command"` + Removed time.Time `json:"removed"` + OutputTruncated bool `json:"output_truncated,omitempty"` +} + +func (s *Supervisor) archivePath(id string) string { + return filepath.Join(s.dir, archiveDir, id+".json") +} + +// writeArchive writes one descriptor whole and durably, private to the person. +func (s *Supervisor) writeArchive(a Archived) error { + raw, err := json.Marshal(a) + if err != nil { + return err + } + if len(raw) > maxArchiveBytes { + return errors.New("the removed service's description is too large") + } + dir := filepath.Join(s.dir, archiveDir) + if err := os.MkdirAll(dir, 0o700); err != nil { + return err + } + tmp, err := os.CreateTemp(dir, ".archive-*") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) + if err := tmp.Chmod(0o600); err != nil { + _ = tmp.Close() + return err + } + _, writeErr := tmp.Write(raw) + syncErr := tmp.Sync() + closeErr := tmp.Close() + if err := errors.Join(writeErr, syncErr, closeErr); err != nil { + return err + } + if err := os.Rename(tmp.Name(), s.archivePath(a.ID)); err != nil { + return err + } + return syncDir(dir) +} + +// readArchive reads one descriptor by id and answers it only to its owner. An +// id that is not one this package mints, a path that is not a plain file, a +// file larger than any descriptor and an owner who is not the one recorded +// are all "no such process": a guessed id reveals nothing. +func (s *Supervisor) readArchive(owner, id string) (Archived, error) { + if owner == "" || !serviceID.MatchString(id) { + return Archived{}, ErrNotFound + } + file, err := os.Open(s.archivePath(id)) + if err != nil { + return Archived{}, ErrNotFound + } + defer file.Close() + info, err := file.Stat() + if err != nil || !info.Mode().IsRegular() || info.Size() > maxArchiveBytes { + return Archived{}, ErrNotFound + } + raw, err := io.ReadAll(io.LimitReader(file, maxArchiveBytes)) + if err != nil { + return Archived{}, ErrNotFound + } + var a Archived + if json.Unmarshal(raw, &a) != nil || a.ID != id || a.Owner != owner { + return Archived{}, ErrNotFound + } + return a, nil +} + +// Archive is one removed service of owner's, for the notice that still links +// to its output. +func (s *Supervisor) Archive(owner, id string) (Archived, error) { + return s.readArchive(owner, id) +} + +func archivedOf(r record, now time.Time) Archived { + return Archived{ID: r.ID, Owner: r.Owner, Name: r.Spec.Name, Command: redact.Secrets(r.Spec.Command), Removed: now.UTC(), OutputTruncated: r.Truncated} +} diff --git a/internal/service/exit_platform.go b/internal/service/exit_platform.go new file mode 100644 index 0000000000..c5ab4fcf97 --- /dev/null +++ b/internal/service/exit_platform.go @@ -0,0 +1,25 @@ +//go:build linux || darwin + +package service + +import ( + "errors" + "os/exec" + "syscall" +) + +// exitOf is a reaped leader's exit. A leader ended by a signal exits with +// 128 plus the signal, the shell's own spelling of it. +func exitOf(err error) Exit { + if err == nil { + return Exit{Code: 0, Known: true} + } + var exitErr *exec.ExitError + if errors.As(err, &exitErr) { + if status, ok := exitErr.Sys().(syscall.WaitStatus); ok && status.Signaled() { + return Exit{Code: 128 + int(status.Signal()), Known: true} + } + return Exit{Code: exitErr.ExitCode(), Known: true} + } + return Exit{Code: -1, Known: false} +} diff --git a/internal/service/fairness_test.go b/internal/service/fairness_test.go new file mode 100644 index 0000000000..67fff88d2a --- /dev/null +++ b/internal/service/fairness_test.go @@ -0,0 +1,69 @@ +package service + +import ( + "context" + "errors" + "fmt" + "sync" + "testing" + "time" +) + +type slowChecks struct { + *fakeRuntime + clock *fakeClock + seenMu sync.Mutex + seen map[string]int + calls int +} + +func (r *slowChecks) Probe(ctx context.Context, spec Spec) error { + done := make(chan struct{}) + timer := r.clock.AfterFunc(CheckTimeout, func() { close(done) }) + defer timer.Stop() + r.seenMu.Lock() + r.seen[spec.Name]++ + r.calls++ + r.seenMu.Unlock() + select { + case <-done: + return errors.New("slow health check failed") + case <-ctx.Done(): + return ctx.Err() + } +} + +func waitForChecks(t *testing.T, r *slowChecks, total int) { + t.Helper() + until := time.Now().Add(time.Second) + for time.Now().Before(until) { + r.seenMu.Lock() + n := r.calls + r.seenMu.Unlock() + if n >= total { + return + } + time.Sleep(time.Millisecond) + } + t.Fatalf("health workers never began %d checks", total) +} + +func TestOldSlowServicesCannotPermanentlyStarveNewerServices(t *testing.T) { + h := newHarness(t) + r := &slowChecks{fakeRuntime: h.rt, clock: h.clock, seen: map[string]int{}} + h.s.rt = r + for n := 0; n < 12; n++ { + h.keep(fmt.Sprintf("service-%d", n), PolicyNever) + } + h.clock.fireDue(CheckEvery) + waitForChecks(t, r, h.s.workers) + for wave := 1; wave <= 12; wave++ { + h.clock.fireDue(CheckTimeout) + waitForChecks(t, r, (wave+1)*h.s.workers) + } + r.seenMu.Lock() + defer r.seenMu.Unlock() + if len(r.seen) != 12 { + t.Fatalf("only %d of 12 approved services were ever checked after 60 seconds under bounded load: %v", len(r.seen), r.seen) + } +} diff --git a/internal/service/fake_test.go b/internal/service/fake_test.go new file mode 100644 index 0000000000..d6a3c3112d --- /dev/null +++ b/internal/service/fake_test.go @@ -0,0 +1,385 @@ +package service + +import ( + "context" + "errors" + "sort" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/processgroup" +) + +// fakeClock is time that moves only when a test says so. +type fakeClock struct { + mu sync.Mutex + now time.Time + timers []*fakeTimer +} + +type fakeTimer struct { + clock *fakeClock + at time.Time + f func() + done bool +} + +func newFakeClock() *fakeClock { + return &fakeClock{now: time.Date(2026, 10, 15, 9, 0, 0, 0, time.UTC)} +} + +func (c *fakeClock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.now +} + +func (c *fakeClock) AfterFunc(d time.Duration, f func()) Timer { + c.mu.Lock() + defer c.mu.Unlock() + t := &fakeTimer{clock: c, at: c.now.Add(d), f: f} + c.timers = append(c.timers, t) + return t +} + +func (t *fakeTimer) Stop() bool { + t.clock.mu.Lock() + defer t.clock.mu.Unlock() + was := !t.done + t.done = true + return was +} + +// pending counts timers not yet fired or stopped, which is how a test proves +// the supervisor keeps one timer and not one per row. +func (c *fakeClock) pending() int { + c.mu.Lock() + defer c.mu.Unlock() + n := 0 + for _, t := range c.timers { + if !t.done { + n++ + } + } + return n +} + +// Advance moves time forward, firing each due timer in order, one step at a +// time so work a timer starts can arm the next before time moves past it. +func (c *fakeClock) Advance(t *testing.T, s *Supervisor, d time.Duration) { + t.Helper() + end := c.Now().Add(d) + for { + settle(t, s) + c.mu.Lock() + var due *fakeTimer + live := c.timers[:0] + for _, timer := range c.timers { + if timer.done { + continue + } + live = append(live, timer) + if !timer.at.After(end) && (due == nil || timer.at.Before(due.at)) { + due = timer + } + } + c.timers = live + if due == nil { + c.now = end + c.mu.Unlock() + settle(t, s) + return + } + if due.at.After(c.now) { + c.now = due.at + } + due.done = true + c.mu.Unlock() + due.f() + } +} + +// fireDue moves time forward by d and fires whatever falls due, without +// waiting for the supervisor: for work that is itself waiting on the clock. +func (c *fakeClock) fireDue(d time.Duration) { + c.mu.Lock() + c.now = c.now.Add(d) + var due []*fakeTimer + for _, timer := range c.timers { + if !timer.done && !timer.at.After(c.now) { + timer.done = true + due = append(due, timer) + } + } + c.mu.Unlock() + for _, timer := range due { + timer.f() + } +} + +// settle waits for the supervisor's workers to finish what they are doing +// now. The outcomes the tests assert never depend on how long that takes. +func settle(t *testing.T, s *Supervisor) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for time.Now().Before(deadline) { + s.mu.Lock() + busy := s.free != s.workers + for _, e := range s.entries { + if e.busy || e.probing || e.stopping() { + busy = true + } + } + s.mu.Unlock() + if !busy { + return + } + time.Sleep(time.Millisecond) + } + t.Fatal("the supervisor never settled") +} + +type fakeProc struct { + id processgroup.Identity + alive bool + ignoreTerm bool + committed bool + exited func(processgroup.Identity, Exit) + spec Spec + signals []bool +} + +// fakeRuntime is a machine with no processes on it but the ones it pretends. +type fakeRuntime struct { + mu sync.Mutex + boot string + nextPID int + procs map[int]*fakeProc + // healthy decides each check by the service's name; absent is passing. + failing map[string]bool + portsUsed map[int]bool + // launches counts gates made; starts counts commands let run. + launches int + starts int + startErr error + commitErr error +} + +func newFakeRuntime() *fakeRuntime { + return &fakeRuntime{boot: "boot-1", nextPID: 100, procs: map[int]*fakeProc{}, failing: map[string]bool{}, portsUsed: map[int]bool{}} +} + +func (r *fakeRuntime) Launch(spec Spec, _ string, exited func(processgroup.Identity, Exit)) (Launch, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.startErr != nil { + return nil, r.startErr + } + r.launches++ + r.nextPID++ + id := processgroup.Identity{PID: r.nextPID, Start: uint64(r.nextPID * 7), Boot: r.boot} + r.procs[id.PID] = &fakeProc{id: id, alive: true, exited: exited, spec: spec} + return &fakeLaunch{r: r, id: id}, nil +} + +// fakeLaunch is a gate: the command runs only when it is committed. +type fakeLaunch struct { + r *fakeRuntime + id processgroup.Identity + done bool +} + +func (l *fakeLaunch) Identity() processgroup.Identity { return l.id } + +func (l *fakeLaunch) Commit() error { + l.r.mu.Lock() + defer l.r.mu.Unlock() + p := l.r.procs[l.id.PID] + if l.done || p == nil || !p.alive || l.r.commitErr != nil { + l.done = true + return errors.New("the gate is gone") + } + l.done = true + p.committed = true + l.r.starts++ + return nil +} + +func (l *fakeLaunch) Abort() { + l.r.mu.Lock() + if l.done { + l.r.mu.Unlock() + return + } + l.done = true + p := l.r.procs[l.id.PID] + if p == nil || !p.alive { + l.r.mu.Unlock() + return + } + p.alive = false + exited := p.exited + l.r.mu.Unlock() + go exited(l.id, Exit{Code: 125, Known: true}) +} + +func (r *fakeRuntime) Alive(id processgroup.Identity) bool { + r.mu.Lock() + defer r.mu.Unlock() + p := r.procs[id.PID] + return p != nil && p.alive && p.id == id && id.Boot == r.boot +} + +func (r *fakeRuntime) Signal(id processgroup.Identity, kill bool) error { + r.mu.Lock() + p := r.procs[id.PID] + if p == nil || p.id != id || !p.alive { + r.mu.Unlock() + return nil + } + p.signals = append(p.signals, kill) + if !kill && p.ignoreTerm { + r.mu.Unlock() + return nil + } + p.alive = false + exited := p.exited + r.mu.Unlock() + if exited != nil { + code := 143 + if kill { + code = 137 + } + go exited(p.id, Exit{Code: code, Known: true}) + } + return nil +} + +func (r *fakeRuntime) Probe(_ context.Context, spec Spec) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.failing[spec.Name] { + return errors.New("port 5173 not open") + } + return nil +} + +func (r *fakeRuntime) PortInUse(port int) bool { + r.mu.Lock() + defer r.mu.Unlock() + return r.portsUsed[port] +} + +func (r *fakeRuntime) Boot() string { + r.mu.Lock() + defer r.mu.Unlock() + return r.boot +} + +func (r *fakeRuntime) fail(name string, failing bool) { + r.mu.Lock() + r.failing[name] = failing + r.mu.Unlock() +} + +// exit ends a process on its own, as a crash does. +func (r *fakeRuntime) exit(t *testing.T, s *Supervisor, pid, code int) { + t.Helper() + r.mu.Lock() + p := r.procs[pid] + if p == nil || !p.alive { + r.mu.Unlock() + t.Fatalf("process %d is not running", pid) + } + p.alive = false + exited := p.exited + r.mu.Unlock() + if exited != nil { + exited(p.id, Exit{Code: code, Known: true}) + } + settle(t, s) +} + +func (r *fakeRuntime) proc(pid int) *fakeProc { + r.mu.Lock() + defer r.mu.Unlock() + return r.procs[pid] +} + +func (r *fakeRuntime) running() []int { + r.mu.Lock() + defer r.mu.Unlock() + var pids []int + for pid, p := range r.procs { + if p.alive { + pids = append(pids, pid) + } + } + sort.Ints(pids) + return pids +} + +type harness struct { + t *testing.T + dir string + clock *fakeClock + rt *fakeRuntime + s *Supervisor +} + +func newHarness(t *testing.T) *harness { + t.Helper() + h := &harness{t: t, dir: t.TempDir(), clock: newFakeClock(), rt: newFakeRuntime()} + h.open() + return h +} + +func (h *harness) open() { + h.t.Helper() + s, err := Open(Options{Dir: h.dir, Runtime: h.rt, Clock: h.clock}) + if err != nil { + h.t.Fatal(err) + } + h.s = s + h.t.Cleanup(s.Close) + settle(h.t, s) +} + +func (h *harness) reopen() { + h.t.Helper() + h.s.Close() + h.open() +} + +func (h *harness) advance(d time.Duration) { h.clock.Advance(h.t, h.s, d) } + +func (h *harness) keep(name string, policy Policy) Row { + h.t.Helper() + row, err := h.s.Keep("agent-a", webSpec(name, h.dir), policy, Provenance{Label: "Start the " + name}) + if err != nil { + h.t.Fatal(err) + } + return row +} + +func (h *harness) get(id string) Row { + h.t.Helper() + row, err := h.s.Get("agent-a", id) + if err != nil { + h.t.Fatal(err) + } + return row +} + +func webSpec(name, dir string) Spec { + return Spec{Name: name, Command: "npm run dev -- --name " + name, Dir: dir, Health: Health{Port: 5173}} +} + +// setWrite swaps the store's writer the way the supervisor reads it, under +// the save lock, so a save already running finishes with the writer it had. +func (h *harness) setWrite(write func(dir string, raw []byte) error) { + h.s.saveMu.Lock() + h.s.write = write + h.s.saveMu.Unlock() +} diff --git a/internal/service/output.go b/internal/service/output.go new file mode 100644 index 0000000000..fdf80a5ba0 --- /dev/null +++ b/internal/service/output.go @@ -0,0 +1,228 @@ +package service + +import ( + "bufio" + "errors" + "io" + "os" +) + +// A service's output is read in bounded pages and never whole. +// +// THE STREAM IS THE OLDER CHUNK THEN THE LIVE ONE, at most outputWindow bytes +// of it: .log.1 followed by .log, which is what the log keeps while a +// codeaf runs. A log that grew past that while no codeaf was open is read from +// its last outputWindow bytes, and the page says so ([OutputPage.Truncated]), +// rather than a protocol answer or a screen being handed gigabytes. Lines are +// numbered from 0 within the stream, and a line longer than maxOutputLine is +// cut there and marked. +const ( + outputWindow = 2 * logChunk + maxOutputLine = 4 << 10 + maxOutputLines = 1000 + maxOutputBytes = 256 << 10 + cutLineMark = " …[line cut]" + defaultOutLines = 200 +) + +// OutputPage is one bounded read of a service's output. +type OutputPage struct { + Lines []string `json:"lines"` + // FirstLine is the number of Lines[0]; NextLine the one after the last. + FirstLine int `json:"first_line"` + NextLine int `json:"next_line"` + // TotalLines is how many lines the stream holds now. + TotalLines int `json:"total_lines"` + // Truncated says earlier output is no longer kept: it was cut back while + // codeaf ran, or the log is larger than the window read. + Truncated bool `json:"truncated,omitempty"` +} + +// OutputPath is where one of owner's services — kept, or removed with its +// output kept — writes. +func (s *Supervisor) OutputPath(owner, id string) (string, error) { + s.mu.Lock() + _, err := s.findLocked(owner, id) + s.mu.Unlock() + if err == nil { + return s.logPath(id), nil + } + if _, archErr := s.readArchive(owner, id); archErr != nil { + return "", ErrNotFound + } + return s.logPath(id), nil +} + +// Output is up to max lines of one of owner's services from line from on, and +// never more than maxOutputBytes of them. +func (s *Supervisor) Output(owner, id string, from, max int) (OutputPage, error) { + if from < 0 { + return OutputPage{}, errors.New("from is a line number") + } + path, truncated, err := s.outputOf(owner, id) + if err != nil { + return OutputPage{}, err + } + max = clampLines(max) + page := OutputPage{Lines: []string{}, FirstLine: from, NextLine: from, Truncated: truncated} + bytes := 0 + err = scanOutput(path, &page.Truncated, func(n int, line string) { + page.TotalLines = n + 1 + if n < from || len(page.Lines) >= max || bytes+len(line) > maxOutputBytes { + return + } + page.Lines = append(page.Lines, line) + bytes += len(line) + page.NextLine = n + 1 + }) + return page, err +} + +// Tail is the last n lines of one of owner's services. +func (s *Supervisor) Tail(owner, id string, n int) (OutputPage, error) { + path, truncated, err := s.outputOf(owner, id) + if err != nil { + return OutputPage{}, err + } + n = clampLines(n) + ring := make([]string, n) + used, next := 0, 0 + page := OutputPage{Lines: []string{}, Truncated: truncated} + err = scanOutput(path, &page.Truncated, func(i int, line string) { + page.TotalLines = i + 1 + ring[next] = line + next = (next + 1) % n + if used < n { + used++ + } + }) + // Flatten once after the scan. Shifting on each line multiplies work by + // the requested tail size for logs containing millions of short lines. + ordered := make([]string, used) + start := 0 + if used == n { + start = next + } + for i := range ordered { + ordered[i] = ring[(start+i)%n] + } + ring = ordered + bytes := 0 + for i := len(ring) - 1; i >= 0; i-- { + if bytes+len(ring[i]) > maxOutputBytes { + ring = ring[i+1:] + break + } + bytes += len(ring[i]) + } + page.Lines = append(page.Lines, ring...) + page.FirstLine = page.TotalLines - len(page.Lines) + page.NextLine = page.TotalLines + return page, err +} + +func clampLines(n int) int { + if n <= 0 { + return defaultOutLines + } + if n > maxOutputLines { + return maxOutputLines + } + return n +} + +func (s *Supervisor) outputOf(owner, id string) (string, bool, error) { + path, err := s.OutputPath(owner, id) + if err != nil { + return "", false, err + } + s.mu.Lock() + truncated := false + live := s.entries[id] != nil + if e := s.entries[id]; e != nil { + truncated = e.rec.Truncated + } + s.mu.Unlock() + if !live { + if archived, err := s.readArchive(owner, id); err == nil { + truncated = archived.OutputTruncated + } + } + return path, truncated, nil +} + +// scanOutput walks the stream line by line, holding one line at a time. +func scanOutput(path string, truncated *bool, line func(int, string)) error { + var readers []io.Reader + var budget int64 = outputWindow + var files []*os.File + defer func() { + for _, f := range files { + _ = f.Close() + } + }() + // The live chunk is the newest, so it is taken first from the budget and + // the older one gets what is left. + for _, name := range []string{path, path + ".1"} { + f, err := os.Open(name) + if errors.Is(err, os.ErrNotExist) { + continue + } + if err != nil { + return err + } + files = append(files, f) + info, err := f.Stat() + if err != nil || !info.Mode().IsRegular() { + return errors.New("the output is not a plain file") + } + size := info.Size() + start := int64(0) + if size > budget { + start, *truncated = size-budget, true + } + budget -= size - start + readers = append([]io.Reader{io.NewSectionReader(f, start, size-start)}, readers...) + if budget <= 0 { + if name == path { + if _, err := os.Stat(path + ".1"); err == nil { + *truncated = true + } + } + break + } + } + reader := bufio.NewReaderSize(io.MultiReader(readers...), maxOutputLine) + n := 0 + for { + chunk, err := reader.ReadSlice('\n') + if len(chunk) > 0 { + text, cut := string(chunk), false + for errors.Is(err, bufio.ErrBufferFull) { + cut = true + _, err = reader.ReadSlice('\n') + } + if cut { + text += cutLineMark + } + line(n, trimNewline(text)) + n++ + } + if err == io.EOF { + return nil + } + if err != nil && !errors.Is(err, bufio.ErrBufferFull) { + return err + } + } +} + +func trimNewline(text string) string { + if n := len(text); n > 0 && text[n-1] == '\n' { + text = text[:n-1] + if n := len(text); n > 0 && text[n-1] == '\r' { + text = text[:n-1] + } + } + return text +} diff --git a/internal/service/output_test.go b/internal/service/output_test.go new file mode 100644 index 0000000000..81a96c7c11 --- /dev/null +++ b/internal/service/output_test.go @@ -0,0 +1,176 @@ +package service + +import ( + "errors" + "os" + "strconv" + "strings" + "testing" +) + +// TestOutputIsReadInBoundedPages: pages, a tail, a line cut where it is too +// long, the older chunk before the live one, and a log that outgrew the +// window said to be truncated rather than read whole. +func TestOutputIsReadInBoundedPages(t *testing.T) { + h := newHarness(t) + row := h.keep("web", PolicyOnFailure) + var b strings.Builder + for i := 0; i < 5; i++ { + b.WriteString("old " + strconv.Itoa(i) + "\n") + } + if err := os.WriteFile(row.LogPath+".1", []byte(b.String()), 0o600); err != nil { + t.Fatal(err) + } + b.Reset() + for i := 0; i < 10; i++ { + b.WriteString("new " + strconv.Itoa(i) + "\n") + } + b.WriteString(strings.Repeat("x", 3*maxOutputLine) + "\n") + b.WriteString("last") + if err := os.WriteFile(row.LogPath, []byte(b.String()), 0o600); err != nil { + t.Fatal(err) + } + page, err := h.s.Output("agent-a", row.ID, 3, 4) + if err != nil || page.TotalLines != 17 || page.FirstLine != 3 || page.NextLine != 7 || + strings.Join(page.Lines, "|") != "old 3|old 4|new 0|new 1" { + t.Fatalf("a page: %+v %v", page, err) + } + tail, err := h.s.Tail("agent-a", row.ID, 2) + if err != nil || len(tail.Lines) != 2 || tail.Lines[1] != "last" || tail.FirstLine != 15 || + !strings.HasSuffix(tail.Lines[0], cutLineMark) || len(tail.Lines[0]) > maxOutputLine+len(cutLineMark) { + t.Fatalf("the tail: %+v %v", tail, err) + } + if huge, _ := h.s.Output("agent-a", row.ID, 0, 1_000_000); len(huge.Lines) > maxOutputLines { + t.Fatal("a page was not bounded") + } + if _, err := h.s.Output("agent-a", row.ID, -1, 1); err == nil { + t.Fatal("a negative line was taken") + } + if err := os.WriteFile(row.LogPath, []byte(strings.Repeat("y\n", outputWindow)), 0o600); err != nil { + t.Fatal(err) + } + big, err := h.s.Tail("agent-a", row.ID, 1) + if err != nil || !big.Truncated || big.TotalLines > outputWindow/2 { + t.Fatalf("a log past the window: %+v %v", big, err) + } +} + +// TestARemovalTheArchiveCannotKeepRemovesNothing. +func TestARemovalTheArchiveCannotKeepRemovesNothing(t *testing.T) { + h := newHarness(t) + row := h.keep("web", PolicyOnFailure) + if _, err := h.s.Stop("agent-a", row.ID); err != nil { + t.Fatal(err) + } + settle(t, h.s) + if err := os.WriteFile(h.s.archivePath("x")[:len(h.s.archivePath("x"))-len("x.json")-1], []byte("not a folder"), 0o600); err != nil { + t.Fatal(err) + } + if err := h.s.Remove("agent-a", row.ID); !errors.Is(err, ErrNotSaved) { + t.Fatalf("remove reported %v", err) + } + if _, err := h.s.Get("agent-a", row.ID); err != nil { + t.Fatal("a removal whose archive could not be written removed the record") + } +} + +// TestAnEpisodesResolutionIsWrittenOnce: later healthy checks never move it, +// and a new episode starts with none. +func TestAnEpisodesResolutionIsWrittenOnce(t *testing.T) { + h := newHarness(t) + row := h.keep("web", PolicyOnFailure) + h.rt.exit(t, h.s, row.PID, 1) + if got := h.get(row.ID); got.EpisodeResolvedAt != nil || !got.EpisodeOpen { + t.Fatalf("an open episode has no resolution: %+v", got) + } + h.advance(FirstBackoff) + h.advance(CheckEvery) + resolved := h.get(row.ID).EpisodeResolvedAt + if resolved == nil { + t.Fatal("recovery did not resolve the episode") + } + h.advance(5 * CheckEvery) + if again := h.get(row.ID).EpisodeResolvedAt; again == nil || !again.Equal(*resolved) { + t.Fatalf("healthy checks moved the resolution: %v then %v", resolved, again) + } + h.rt.exit(t, h.s, h.get(row.ID).PID, 1) + if got := h.get(row.ID); got.Episode == row.ID+".1" || got.EpisodeResolvedAt != nil { + t.Fatalf("a new episode: %+v", got) + } +} + +// A large short-line log exercises the full-size tail, including wraparound. +func TestLargeShortLineTailKeepsChronologicalSuffix(t *testing.T) { + h := newHarness(t) + row := h.keep("web", PolicyNever) + var b strings.Builder + const count = 100_000 + for i := 0; i < count; i++ { + b.WriteString(strconv.Itoa(i)) + b.WriteByte('\n') + } + if err := os.WriteFile(row.LogPath, []byte(b.String()), 0o600); err != nil { + t.Fatal(err) + } + page, err := h.s.Tail("agent-a", row.ID, maxOutputLines) + if err != nil { + t.Fatal(err) + } + if page.TotalLines != count || page.FirstLine != count-maxOutputLines || len(page.Lines) != maxOutputLines { + t.Fatalf("incorrect range: %+v", page) + } + for i, line := range page.Lines { + if line != strconv.Itoa(count-maxOutputLines+i) { + t.Fatalf("line %d = %q", i, line) + } + } +} + +func TestRemovedOutputRetainsRotationDisclosureAfterReopen(t *testing.T) { + h := newHarness(t) + row := h.keep("web", PolicyNever) + if err := os.WriteFile(row.LogPath, []byte(strings.Repeat("x\n", logChunk)), 0o600); err != nil { + t.Fatal(err) + } + h.advance(CheckEvery) + if !h.get(row.ID).OutputTruncated { + t.Fatal("fixture did not rotate output") + } + if _, err := h.s.Stop("agent-a", row.ID); err != nil { + t.Fatal(err) + } + settle(t, h.s) + if err := h.s.Remove("agent-a", row.ID); err != nil { + t.Fatal(err) + } + h.reopen() + page, err := h.s.Tail("agent-a", row.ID, 1) + if err != nil || !page.Truncated { + t.Fatalf("removed output lost rotation disclosure: %+v %v", page, err) + } +} + +func BenchmarkServiceTail100kLines(b *testing.B) { + s := &Supervisor{dir: b.TempDir(), entries: map[string]*entry{}} + if err := os.MkdirAll(s.dir+"/logs", 0700); err != nil { + b.Fatal(err) + } + if err := s.writeArchive(Archived{ID: "s1", Owner: "owner"}); err != nil { + b.Fatal(err) + } + var text strings.Builder + for i := 0; i < 100_000; i++ { + text.WriteString(strconv.Itoa(i)) + text.WriteByte('\n') + } + if err := os.WriteFile(s.logPath("s1"), []byte(text.String()), 0600); err != nil { + b.Fatal(err) + } + b.ResetTimer() + for i := 0; i < b.N; i++ { + p, err := s.Tail("owner", "s1", 1000) + if err != nil || len(p.Lines) != 1000 { + b.Fatalf("tail: %v", err) + } + } +} diff --git a/internal/service/platform_test.go b/internal/service/platform_test.go new file mode 100644 index 0000000000..dd38790702 --- /dev/null +++ b/internal/service/platform_test.go @@ -0,0 +1,387 @@ +//go:build linux || darwin + +package service + +import ( + "context" + "errors" + "net" + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "strconv" + "strings" + "sync" + "syscall" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/processgroup" +) + +// These drive real process groups. Each wait below is a bounded look for an +// outcome that has already been caused, never a sleep standing in for one. + +func waitFor(t *testing.T, what string, done func() bool) { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for !done() { + if time.Now().After(deadline) { + t.Fatalf("never: %s", what) + } + time.Sleep(10 * time.Millisecond) + } +} + +// gone reports a pid that no longer exists — reaped, not merely a zombie. +func gone(pid int) bool { + return errors.Is(syscall.Kill(pid, 0), syscall.ESRCH) +} + +func readPID(t *testing.T, path string) int { + t.Helper() + var pid int + waitFor(t, "the child wrote its pid", func() bool { + raw, err := os.ReadFile(path) + if err != nil { + return false + } + pid, err = strconv.Atoi(strings.TrimSpace(string(raw))) + return err == nil && pid > 0 + }) + return pid +} + +// startNow launches spec and lets it run at once. +func startNow(t *testing.T, rt Runtime, spec Spec, logPath string, exits chan Exit) processgroup.Identity { + t.Helper() + launch, err := rt.Launch(spec, logPath, func(_ processgroup.Identity, x Exit) { exits <- x }) + if err != nil { + t.Fatal(err) + } + if err := launch.Commit(); err != nil { + t.Fatal(err) + } + return launch.Identity() +} + +func freePort(t *testing.T) int { + t.Helper() + listener, err := net.Listen("tcp", "127.0.0.1:0") + if err != nil { + t.Fatal(err) + } + defer listener.Close() + return listener.Addr().(*net.TCPAddr).Port +} + +func openReal(t *testing.T, dir string) *Supervisor { + t.Helper() + s, err := Open(Options{Dir: dir, Runtime: PlatformRuntime()}) + if err != nil { + t.Fatal(err) + } + return s +} + +// TestAKeptServiceOutlivesItsSupervisorAndItsWholeGroupStops is the real +// lifecycle: a detached group survives the supervisor closing, the next one +// takes back the same process, and Stop ends the leader and its child. +func TestAKeptServiceOutlivesItsSupervisorAndItsWholeGroupStops(t *testing.T) { + if processgroup.BootID() == "" { + t.Skip("this machine names no boot, so nothing can be kept") + } + store, work := t.TempDir(), t.TempDir() + childFile := filepath.Join(work, "child.pid") + spec := Spec{Name: "pair", Command: "sleep 300 & echo $! > child.pid; echo up; wait", Dir: work, Health: Health{Port: freePort(t)}} + + s := openReal(t, store) + row, err := s.Keep("agent-a", spec, PolicyOnFailure, Provenance{Label: "start it"}) + if err != nil { + t.Fatal(err) + } + child := readPID(t, childFile) + t.Cleanup(func() { _ = syscall.Kill(-row.PID, syscall.SIGKILL) }) + s.Close() + + if gone(row.PID) || gone(child) { + t.Fatal("closing the supervisor ended the service") + } + s = openReal(t, store) + defer s.Close() + got, err := s.Get("agent-a", row.ID) + if err != nil || !got.Running || got.PID != row.PID || !got.ReAdopted { + t.Fatalf("the next supervisor did not take the same process back: %+v %v", got, err) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if err := s.StopAll(ctx, "quit"); err != nil { + t.Fatal(err) + } + waitFor(t, "the leader and its child ended", func() bool { return gone(row.PID) && gone(child) }) + if body, _ := os.ReadFile(got.LogPath); !strings.Contains(string(body), "up") { + t.Fatal("the service's output was not kept") + } +} + +// TestAStartedServiceThatExitsTakesItsGroupWithIt: the leader exits, the child +// it left behind is swept before the leader is reaped. +func TestAStartedServiceThatExitsTakesItsGroupWithIt(t *testing.T) { + if processgroup.BootID() == "" { + t.Skip("this machine names no boot") + } + work := t.TempDir() + rt := PlatformRuntime() + exits := make(chan Exit, 1) + startNow(t, rt, Spec{Command: "sleep 300 & echo $! > child.pid; exit 7", Dir: work, Health: Health{Port: 1}}, + filepath.Join(work, "log"), exits) + child := readPID(t, filepath.Join(work, "child.pid")) + select { + case x := <-exits: + if !x.Known || x.Code != 7 { + t.Fatalf("exit %+v", x) + } + case <-time.After(10 * time.Second): + t.Fatal("no exit reported") + } + waitFor(t, "the orphaned child was swept", func() bool { return gone(child) }) +} + +// TestATimedOutCheckTakesItsWholeGroup. +func TestATimedOutCheckTakesItsWholeGroup(t *testing.T) { + work := t.TempDir() + ctx, cancel := context.WithTimeout(context.Background(), 300*time.Millisecond) + defer cancel() + err := runCheckCommand(ctx, Spec{Dir: work, Health: Health{Kind: HealthCmd, Cmd: "sleep 300 & echo $! > child.pid; wait"}}) + if err == nil || err.Error() != "took over 5s" { + t.Fatalf("a check past its timeout: %v", err) + } + child := readPID(t, filepath.Join(work, "child.pid")) + waitFor(t, "the check's child ended", func() bool { return gone(child) }) + if err := runCheckCommand(context.Background(), Spec{Dir: work, Health: Health{Kind: HealthCmd, Cmd: "exit 3"}}); err == nil || err.Error() != "exits 3" { + t.Fatalf("a failing check: %v", err) + } + if err := runCheckCommand(context.Background(), Spec{Dir: work, Health: Health{Kind: HealthCmd, Cmd: "test -d ."}}); err != nil { + t.Fatalf("a passing check: %v", err) + } +} + +// TestALeaderIsNeverSignalledAfterItsReap: the one sweep happens before the +// reap, and every signal after it sends nothing at all — the pid may already +// be somebody else's. +func TestALeaderIsNeverSignalledAfterItsReap(t *testing.T) { + cmd := exec.Command("true") + processgroup.ConfigureDetached(cmd) + if err := cmd.Start(); err != nil { + t.Fatal(err) + } + l, _ := newLeader(cmd.Process) + var mu sync.Mutex + var sent []bool + l.send = func(_ processgroup.Group, kill bool) error { + mu.Lock() + defer mu.Unlock() + if l.reaped { + t.Error("a signal was sent after the reap") + } + sent = append(sent, kill) + return nil + } + l.reap(cmd) + _ = l.signal(false) + _ = l.signal(true) + mu.Lock() + defer mu.Unlock() + if len(sent) > 1 { + t.Fatalf("signals after the reap: %v", sent) + } +} + +// TestAURLCheckRunsNoShell: whatever a URL holds, it is a request, never a +// command line. +func TestAURLCheckRunsNoShell(t *testing.T) { + work := t.TempDir() + marker := filepath.Join(work, "pwned") + health, err := Health{URL: "http://127.0.0.1:1/$(touch%20" + marker + ")"}.Check() + if err != nil { + t.Fatal(err) + } + _ = PlatformRuntime().Probe(context.Background(), Spec{Dir: work, Health: health}) + if _, err := os.Stat(marker); err == nil { + t.Fatal("a URL check ran a shell") + } + for _, bad := range []string{"http://x/ ; touch y", "file:///etc/passwd", "http://user:pw@localhost:1/", "localhost:5173"} { + if _, err := (Health{URL: bad}).Check(); err == nil { + t.Fatalf("%q was taken as a health URL", bad) + } + } +} + +// TestAURLCheckIsStrictAndBounded: 200 means 200, a certificate is verified, +// and an endless body is not read. +func TestAURLCheckIsStrictAndBounded(t *testing.T) { + rt := PlatformRuntime() + probe := func(url string) error { + return rt.Probe(context.Background(), Spec{Health: Health{Kind: HealthURL, URL: url}}) + } + ok := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {})) + defer ok.Close() + if err := probe(ok.URL); err != nil { + t.Fatalf("a 200: %v", err) + } + moved := httptest.NewServer(http.RedirectHandler("/elsewhere", http.StatusFound)) + defer moved.Close() + if err := probe(moved.URL); err == nil || err.Error() != "answered 302" { + t.Fatalf("a redirect is not a 200: %v", err) + } + secure := httptest.NewTLSServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) {})) + defer secure.Close() + if err := probe(secure.URL); err == nil { + t.Fatal("an unverifiable certificate passed the check") + } + stop := make(chan struct{}) + endless := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + chunk := []byte(strings.Repeat("x", 32<<10)) + for { + select { + case <-stop: + return + default: + } + if _, err := w.Write(chunk); err != nil { + return + } + } + })) + defer endless.Close() + defer close(stop) + begun := time.Now() + if err := probe(endless.URL); err != nil { + t.Fatalf("an endless 200: %v", err) + } + if time.Since(begun) > 4*time.Second { + t.Fatal("the check read the endless body") + } +} + +// TestALogIsCutBackToTwoChunks. +func TestALogIsCutBackToTwoChunks(t *testing.T) { + path := filepath.Join(t.TempDir(), "s1.log") + if err := os.WriteFile(path, []byte(strings.Repeat("y", 3*logChunk/2)), 0o600); err != nil { + t.Fatal(err) + } + cut, err := rotateLog(path) + if err != nil || !cut { + t.Fatalf("not cut: %v", err) + } + live, _ := os.Stat(path) + backup, _ := os.Stat(path + ".1") + if live.Size() != 0 || backup.Size() != logChunk || backup.Mode().Perm() != 0o600 { + t.Fatalf("live %d, backup %d %v", live.Size(), backup.Size(), backup.Mode()) + } + if cut, _ := rotateLog(path); cut { + t.Fatal("a small log was cut") + } +} + +// TestAServiceRunsInAFolderEndingInASpace. +func TestAServiceRunsInAFolderEndingInASpace(t *testing.T) { + if processgroup.BootID() == "" { + t.Skip("this machine names no boot") + } + work := filepath.Join(t.TempDir(), "web ") + if err := os.Mkdir(work, 0o700); err != nil { + t.Fatal(err) + } + spec, err := Spec{Command: "pwd > where", Dir: work, Health: Health{Port: 1}}.Check() + if err != nil { + t.Fatal(err) + } + exits := make(chan Exit, 1) + startNow(t, PlatformRuntime(), spec, filepath.Join(work, "log"), exits) + <-exits + raw, _ := os.ReadFile(filepath.Join(work, "where")) + resolved, _ := filepath.EvalSymlinks(work) + if got := strings.TrimSuffix(string(raw), "\n"); got != work && got != resolved { + t.Fatalf("ran in %q, not %q", got, work) + } +} + +// TestAGateRunsNothingUntilItIsCommitted: the group exists and its identity is +// known, and the approved command has not run; let go, it never does. +func TestAGateRunsNothingUntilItIsCommitted(t *testing.T) { + if processgroup.BootID() == "" { + t.Skip("this machine names no boot") + } + work := t.TempDir() + rt := PlatformRuntime() + spec := Spec{Command: "touch ran", Dir: work, Health: Health{Port: 1}} + exits := make(chan Exit, 1) + launch, err := rt.Launch(spec, filepath.Join(work, "log"), func(_ processgroup.Identity, x Exit) { exits <- x }) + if err != nil { + t.Fatal(err) + } + if !rt.Alive(launch.Identity()) { + t.Fatal("the gate is not a live group") + } + if _, err := os.Stat(filepath.Join(work, "ran")); err == nil { + t.Fatal("the command ran before its commit") + } + launch.Abort() + select { + case x := <-exits: + if x.Code != 125 { + t.Fatalf("an aborted gate exits 125, not %+v", x) + } + case <-time.After(10 * time.Second): + t.Fatal("an aborted gate never exited") + } + if _, err := os.Stat(filepath.Join(work, "ran")); err == nil { + t.Fatal("an aborted gate ran the command") + } + if err := launch.Commit(); err == nil { + t.Fatal("a let-go gate took a commit") + } + + committed := Spec{Command: "touch ran", Dir: work, Health: Health{Port: 1}} + startNow(t, rt, committed, filepath.Join(work, "log"), exits) + <-exits + if _, err := os.Stat(filepath.Join(work, "ran")); err != nil { + t.Fatal("a committed gate did not run the command") + } +} + +// gateParentEnv makes this test binary the parent that dies before its +// commit (TestAGateWhoseParentDiesRunsNothing). +const gateParentEnv = "CODEAF_SERVICE_GATE_PARENT" + +// TestAGateWhoseParentDiesRunsNothing is the crash: a real parent launches a +// gate and exits without committing it. The gate reads end-of-file and exits, +// and the command never runs. +func TestAGateWhoseParentDiesRunsNothing(t *testing.T) { + if dir := os.Getenv(gateParentEnv); dir != "" { + launch, err := PlatformRuntime().Launch(Spec{Command: "touch ran", Dir: dir, Health: Health{Port: 1}}, + filepath.Join(dir, "log"), func(processgroup.Identity, Exit) {}) + if err != nil { + os.Exit(2) + } + _ = os.WriteFile(filepath.Join(dir, "gate.pid"), []byte(strconv.Itoa(launch.Identity().PID)), 0o600) + os.Exit(0) + } + if processgroup.BootID() == "" { + t.Skip("this machine names no boot") + } + work := t.TempDir() + parent := exec.Command(os.Args[0], "-test.run=^TestAGateWhoseParentDiesRunsNothing$") + parent.Env = append(os.Environ(), gateParentEnv+"="+work) + if out, err := parent.CombinedOutput(); err != nil { + t.Fatalf("the parent: %v %s", err, out) + } + gate := readPID(t, filepath.Join(work, "gate.pid")) + waitFor(t, "the orphaned gate exited", func() bool { return gone(gate) }) + if _, err := os.Stat(filepath.Join(work, "ran")); err == nil { + t.Fatal("a gate whose parent died ran the command") + } +} diff --git a/internal/service/replacement_test.go b/internal/service/replacement_test.go new file mode 100644 index 0000000000..5eaac50f77 --- /dev/null +++ b/internal/service/replacement_test.go @@ -0,0 +1,333 @@ +package service + +import ( + "context" + "errors" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/processgroup" +) + +type refusingSignals struct{ *fakeRuntime } + +func (r refusingSignals) Signal(processgroup.Identity, bool) error { + return errors.New("signal refused") +} + +func awaitGrace(t *testing.T, c *fakeClock) { + t.Helper() + until := time.Now().Add(time.Second) + for time.Now().Before(until) { + c.mu.Lock() + ready := false + for _, timer := range c.timers { + if !timer.done && timer.at.Equal(c.now.Add(StopGrace)) { + ready = true + } + } + c.mu.Unlock() + if ready { + return + } + time.Sleep(time.Millisecond) + } + t.Fatal("stop never reached its grace barrier") +} + +func TestRestartCannotLaunchASecondProcessWhenTheOldOneCannotStop(t *testing.T) { + h := newHarness(t) + h.s.rt = refusingSignals{h.rt} + row := h.keep("owned", PolicyOnFailure) + if _, err := h.s.Restart("agent-a", row.ID); err != nil { + t.Fatal(err) + } + awaitGrace(t, h.clock) + h.clock.fireDue(StopGrace) + settle(t, h.s) + if h.rt.starts != 1 { + t.Fatalf("restart launched %d processes while the original group was still alive", h.rt.starts) + } + got := h.get(row.ID) + if !got.Running || got.PID != row.PID { + t.Fatalf("a failed stop lost ownership of the original process: %+v", got) + } +} + +// refuse swaps in signals that are never delivered, under the lock the +// supervisor reads its runtime with. +func refuse(h *harness) { + h.s.mu.Lock() + h.s.rt = refusingSignals{h.rt} + h.s.mu.Unlock() +} + +// TestAnAutomaticRestartThatCannotStopRestsAndKeepsTheProcess: three failed +// checks, a backoff, a stop that cannot end it — the policy gives up rather +// than signal forever, nothing second is launched, and the old process's own +// listener is never called "another program". +func TestAnAutomaticRestartThatCannotStopRestsAndKeepsTheProcess(t *testing.T) { + h := newHarness(t) + row := h.keep("web", PolicyOnFailure) + refuse(h) + h.rt.portsUsed[5173] = true + h.rt.fail("web", true) + h.advance(30 * time.Second) + if got := h.get(row.ID); got.State != StateRestarting { + t.Fatalf("%+v", got) + } + h.clock.fireDue(FirstBackoff) + awaitGrace(t, h.clock) + h.clock.fireDue(StopGrace) + settle(t, h.s) + got := h.get(row.ID) + if h.rt.launches != 1 || !got.Running || got.PID != row.PID || got.State != StateGaveUp || got.PortConflict || + strings.Contains(got.Reason, "another program") || !strings.Contains(got.Reason, "could not stop it to restart") { + t.Fatalf("launches %d, row %+v", h.rt.launches, got) + } + starts := h.rt.launches + h.advance(time.Hour) + if h.rt.launches != starts { + t.Fatal("a restart that could not stop its process kept trying") + } +} + +// TestTheOriginalsExitStillCountsAfterAFailedReplacement: the process a +// failed restart kept is still the row's, so its later exit is seen. +func TestTheOriginalsExitStillCountsAfterAFailedReplacement(t *testing.T) { + h := newHarness(t) + row := h.keep("web", PolicyNever) + refuse(h) + if _, err := h.s.Restart("agent-a", row.ID); err != nil { + t.Fatal(err) + } + awaitGrace(t, h.clock) + h.clock.fireDue(StopGrace) + settle(t, h.s) + if got := h.get(row.ID); !got.Running || got.State != StateStarting { + t.Fatalf("the failed restart put back what it found: %+v", got) + } + h.rt.exit(t, h.s, row.PID, 2) + if got := h.get(row.ID); got.Running || got.State != StateFailed || got.ExitCode == nil || *got.ExitCode != 2 { + t.Fatalf("the original's exit was ignored: %+v", got) + } +} + +// TestAStopThatCannotEndItKeepsItOwned: the row stays running and says so, +// and a stop can be asked for again. +func TestAStopThatCannotEndItKeepsItOwned(t *testing.T) { + h := newHarness(t) + row := h.keep("web", PolicyAlways) + refuse(h) + if _, err := h.s.Stop("agent-a", row.ID); err != nil { + t.Fatal(err) + } + awaitGrace(t, h.clock) + h.clock.fireDue(StopGrace) + settle(t, h.s) + got := h.get(row.ID) + if !got.Running || got.PID != row.PID || got.State != StateStopped || !strings.Contains(got.Reason, "did not stop") { + t.Fatalf("%+v", got) + } + if err := h.s.Remove("agent-a", row.ID); !errors.Is(err, ErrRunning) { + t.Fatalf("a process that did not stop was removable: %v", err) + } + h.s.mu.Lock() + h.s.rt = h.rt + h.s.mu.Unlock() + if _, err := h.s.Stop("agent-a", row.ID); err != nil { + t.Fatal(err) + } + settle(t, h.s) + if got := h.get(row.ID); got.Running { + t.Fatalf("the second stop did not end it: %+v", got) + } +} + +// TestAReopenKeepsAProcessThatIsStillOursInAnyState: a gave-up row whose +// process could not be stopped is taken back running, not forgotten. +func TestAReopenKeepsAProcessThatIsStillOursInAnyState(t *testing.T) { + h := newHarness(t) + row := h.keep("web", PolicyOnFailure) + refuse(h) + h.rt.fail("web", true) + h.advance(30 * time.Second) + h.clock.fireDue(FirstBackoff) + awaitGrace(t, h.clock) + h.clock.fireDue(StopGrace) + settle(t, h.s) + if got := h.get(row.ID); got.State != StateGaveUp || !got.Running { + t.Fatalf("%+v", got) + } + h.reopen() + got := h.get(row.ID) + if !got.Running || got.PID != row.PID || !got.ReAdopted || h.rt.launches != 1 { + t.Fatalf("a live owned process was forgotten on reopen: %+v", got) + } +} + +// TestCloseRacingEveryVerbLeavesNothingBehind: whatever interleaving wins, +// no work starts after Close, no write lands after it, and every verb after +// it says closed. +func TestCloseRacingEveryVerbLeavesNothingBehind(t *testing.T) { + for i := 0; i < 20; i++ { + h := newHarness(t) + row := h.keep("web", PolicyOnFailure) + var wg sync.WaitGroup + wg.Add(5) + go func() { defer wg.Done(); _, _ = h.s.Stop("agent-a", row.ID) }() + go func() { defer wg.Done(); _, _ = h.s.Restart("agent-a", row.ID) }() + go func() { defer wg.Done(); _, _ = h.s.StopOwner("agent-a", "agent closed") }() + go func() { defer wg.Done(); _, _ = h.s.SetPolicy("agent-a", row.ID, PolicyNever) }() + go func() { defer wg.Done(); h.s.Close() }() + wg.Wait() + h.s.Close() + if _, err := h.s.Stop("agent-a", row.ID); !errors.Is(err, ErrClosed) { + t.Fatalf("stop after close: %v", err) + } + if _, err := h.s.Restart("agent-a", row.ID); !errors.Is(err, ErrClosed) { + t.Fatalf("restart after close: %v", err) + } + if err := h.s.save(); !errors.Is(err, ErrClosed) { + t.Fatalf("a write after close: %v", err) + } + } +} + +// TestStopAllSaysWhatItCouldNotStop: a quit asked to stop everything is told, +// by name, which process is still running, rather than told all is well. +func TestStopAllSaysWhatItCouldNotStop(t *testing.T) { + h := newHarness(t) + row := h.keep("web", PolicyAlways) + refuse(h) + result := make(chan error, 1) + go func() { result <- h.s.StopAll(context.Background(), "quit") }() + awaitGrace(t, h.clock) + h.clock.fireDue(StopGrace) + err := <-result + if !errors.Is(err, ErrNotStopped) || !strings.Contains(err.Error(), row.ID) { + t.Fatalf("stop all said %v", err) + } + if got := h.get(row.ID); !got.Running { + t.Fatalf("the process it could not stop is no longer listed as running: %+v", got) + } +} + +// TestStopOwnerWaitProvesItsOwnersGoneAndLeavesOthers: closing one agent +// waits for its processes to be gone, says which one would not go, and never +// stops another agent's. +func TestStopOwnerWaitProvesItsOwnersGoneAndLeavesOthers(t *testing.T) { + h := newHarness(t) + mine := h.keep("web", PolicyAlways) + theirs, err := h.s.Keep("agent-b", Spec{Name: "api", Command: "node api.js", Dir: h.dir, Health: Health{Port: 4011}}, PolicyNever, Provenance{}) + if err != nil { + t.Fatal(err) + } + refuse(h) + type result struct { + n int + err error + } + done := make(chan result, 1) + go func() { + n, err := h.s.StopOwnerWait(context.Background(), "agent-a", "agent closed") + done <- result{n, err} + }() + awaitGrace(t, h.clock) + h.clock.fireDue(StopGrace) + got := <-done + if got.n != 1 || !errors.Is(got.err, ErrNotStopped) || !strings.Contains(got.err.Error(), mine.ID) { + t.Fatalf("a refused close said %d, %v", got.n, got.err) + } + if row := h.get(mine.ID); !row.Running { + t.Fatalf("the process that would not stop is hidden: %+v", row) + } + if row, _ := h.s.Get("agent-b", theirs.ID); !row.Running || row.State == StateStopped { + t.Fatalf("another agent's service was stopped: %+v", row) + } + + // With signals that work, the same close waits for the exit and is clean. + h.s.mu.Lock() + h.s.rt = h.rt + h.s.mu.Unlock() + n, err := h.s.StopOwnerWait(context.Background(), "agent-a", "agent closed") + if err != nil || n != 1 || h.get(mine.ID).Running { + t.Fatalf("a working close said %d, %v: %+v", n, err, h.get(mine.ID)) + } +} + +// TestStopOwnerWaitEndsWithItsContext: a close that runs out of time says so +// rather than claiming the processes are gone. +func TestStopOwnerWaitEndsWithItsContext(t *testing.T) { + h := newHarness(t) + mine := h.keep("web", PolicyAlways) + refuse(h) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := h.s.StopOwnerWait(ctx, "agent-a", "agent closed"); !errors.Is(err, ErrNotStopped) { + t.Fatalf("an expired wait said %v", err) + } + // The process then goes by itself, which ends the grace still running. + h.rt.exit(t, h.s, mine.PID, 0) + if got := h.get(mine.ID); got.Running { + t.Fatalf("%+v", got) + } +} + +// TestAFencedOwnerStartsNothingNew: while an agent's close waits, neither the +// agent nor the person starts anything for it; other agents are not fenced. +func TestAFencedOwnerStartsNothingNew(t *testing.T) { + h := newHarness(t) + row := h.keep("web", PolicyNever) + if _, err := h.s.Stop("agent-a", row.ID); err != nil { + t.Fatal(err) + } + settle(t, h.s) + release := h.s.Fence("agent-a") + if _, err := h.s.Start("agent-a", row.ID); !errors.Is(err, ErrClosing) { + t.Fatalf("start under a fence: %v", err) + } + if _, err := h.s.Restart("agent-a", row.ID); !errors.Is(err, ErrClosing) { + t.Fatalf("restart under a fence: %v", err) + } + if _, err := h.s.Keep("agent-a", Spec{Name: "api", Command: "node api.js", Dir: h.dir, Health: Health{Port: 4012}}, PolicyNever, Provenance{}); !errors.Is(err, ErrClosing) { + t.Fatalf("keep under a fence: %v", err) + } + if _, err := h.s.Keep("agent-b", Spec{Name: "api", Command: "node api.js", Dir: h.dir, Health: Health{Port: 4012}}, PolicyNever, Provenance{}); err != nil { + t.Fatalf("another agent was fenced: %v", err) + } + release() + release() + if _, err := h.s.Start("agent-a", row.ID); err != nil { + t.Fatalf("after the fence: %v", err) + } +} + +// TestTheLastRowAfterAStopIsNotStopping: a process that exits on SIGTERM is +// followed by a row that says neither running nor stopping. +func TestTheLastRowAfterAStopIsNotStopping(t *testing.T) { + h := newHarness(t) + row := h.keep("web", PolicyNever) + var mu sync.Mutex + var last Event + h.s.Subscribe(func(e Event) { mu.Lock(); last = e; mu.Unlock() }) + if _, err := h.s.Stop("agent-a", row.ID); err != nil { + t.Fatal(err) + } + settle(t, h.s) + deadline := time.Now().Add(5 * time.Second) + for { + mu.Lock() + got := last + mu.Unlock() + if !got.Row.Running && !got.Row.Stopping && got.Row.ID == row.ID { + return + } + if time.Now().After(deadline) { + t.Fatalf("the last row said %+v", got.Row) + } + time.Sleep(time.Millisecond) + } +} diff --git a/internal/service/runtime.go b/internal/service/runtime.go new file mode 100644 index 0000000000..72bfbf2c6d --- /dev/null +++ b/internal/service/runtime.go @@ -0,0 +1,80 @@ +package service + +import ( + "context" + "time" + + "github.com/Agent-Field/codeaf/internal/processgroup" +) + +// Exit is how a service's process ended. Known is false for a process this +// codeaf did not start, whose exit status went to somebody else. +type Exit struct { + Code int + Known bool +} + +// Runtime is everything the supervisor does to the machine. The platform's is +// [PlatformRuntime]; a test hands in a scripted one, which is how the policy, +// the budget and the clock are proven without a real process or a real wait. +type Runtime interface { + // Launch makes the spec's process group and stops it at a gate, BEFORE the + // approved command runs: the identity is known and nothing of the person's + // command has happened yet. [Launch.Commit] lets it run; [Launch.Abort], + // or this codeaf ending before either, makes the gate exit without running + // it. exited is called exactly once, from a goroutine of its own, with the + // identity, when the leader ends — whether or not it was ever committed. + Launch(spec Spec, logPath string, exited func(processgroup.Identity, Exit)) (Launch, error) + // Alive reports whether the recorded group still runs AND is still the one + // recorded. An identity that cannot be proven is not alive. + Alive(id processgroup.Identity) bool + // Signal sends SIGTERM, or SIGKILL when kill is set, to the recorded group, + // and sends nothing when its identity no longer matches. + Signal(id processgroup.Identity, kill bool) error + // Probe runs the spec's one check, within ctx. + Probe(ctx context.Context, spec Spec) error + // PortInUse reports whether something already accepts connections on the + // local port. It is asked only BEFORE a service starts, which is the one + // moment a listener there is proven not to be the service's own. + PortInUse(port int) bool + // Boot names the running boot, or "" where the platform cannot. A store + // written in another boot holds no process that can still be running. + Boot() string +} + +// Launch is a process group held at its gate. +// +// THE GATE IS WHAT MAKES A KEPT PROCESS CRASH-SAFE. A detached command that ran +// before its identity was on disk would, if codeaf died in between, be a +// process no later codeaf could ever find or prove — the orphan this package +// exists to prevent. So the group is made first, its identity written down, +// and only then is the command allowed to run; a codeaf that dies before the +// commit leaves a gate that reads end-of-file and exits having run nothing. +type Launch interface { + Identity() processgroup.Identity + // Commit lets the approved command run. An error means the gate is gone + // and the command did not run; its exit is still reported. + Commit() error + // Abort makes the gate exit without running the command. + Abort() +} + +// Clock is the supervisor's time: the one timer it keeps and the stop grace. +type Clock interface { + Now() time.Time + AfterFunc(d time.Duration, f func()) Timer +} + +// Timer is a pending AfterFunc. +type Timer interface { + Stop() bool +} + +type realClock struct{} + +func (realClock) Now() time.Time { return time.Now() } + +func (realClock) AfterFunc(d time.Duration, f func()) Timer { return time.AfterFunc(d, f) } + +// RealClock is the wall clock. +func RealClock() Clock { return realClock{} } diff --git a/internal/service/runtime_other.go b/internal/service/runtime_other.go new file mode 100644 index 0000000000..dd08eaba27 --- /dev/null +++ b/internal/service/runtime_other.go @@ -0,0 +1,9 @@ +//go:build !linux && !darwin + +package service + +// PlatformRuntime is nil where the platform names no boot or reads no start +// identity a later codeaf could prove (internal/processgroup): a kept-running +// service there could never be told apart from a stranger holding its pid, so +// the capability is absent rather than half-working. +func PlatformRuntime() Runtime { return nil } diff --git a/internal/service/runtime_platform.go b/internal/service/runtime_platform.go new file mode 100644 index 0000000000..0e90a49891 --- /dev/null +++ b/internal/service/runtime_platform.go @@ -0,0 +1,323 @@ +//go:build linux || darwin + +package service + +import ( + "context" + "errors" + "fmt" + "io" + "net" + "net/http" + "os" + "os/exec" + "strconv" + "sync" + "syscall" + "time" + + "github.com/Agent-Field/codeaf/internal/exec/bare" + "github.com/Agent-Field/codeaf/internal/processgroup" +) + +// probeBodyLimit is how much of a health URL's answer is read before the +// connection is closed. The status is the whole verdict; the body is drained a +// little only so a server is not cut off mid-write, and never held. +const probeBodyLimit = 4 << 10 + +type platformRuntime struct { + client *http.Client + + mu sync.Mutex + // own is every leader this codeaf started and has not reaped yet, by pid. + // A signal to one of them goes through its leader, which is what makes a + // signal and the reap of the same pid impossible to interleave. + own map[int]*leader +} + +// PlatformRuntime is this machine's runtime. +func PlatformRuntime() Runtime { + transport := &http.Transport{ + // A check is one request to one address. No proxy: a local server + // reached through somebody's proxy is not a check of the local server. + Proxy: nil, + DisableKeepAlives: true, + TLSHandshakeTimeout: CheckTimeout, + ResponseHeaderTimeout: CheckTimeout, + // THE CERTIFICATE IS VERIFIED. The person approved a check of an + // address; an https answer from something that cannot prove it is that + // address is a failed check, not a passing one. + } + return &platformRuntime{ + own: map[int]*leader{}, + client: &http.Client{ + Transport: transport, + Timeout: CheckTimeout, + // 200 MEANS 200. A redirect is an answer, and following it would + // check some other address than the one the person approved. + CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }, + }, + } +} + +// leader is one process this codeaf started, with the group it leads. +// +// EVERY SIGNAL TO IT IS SENT BEFORE IT IS REAPED, OR NOT AT ALL. Until the +// leader is reaped its pid — and so its group number — cannot be handed to +// anybody else, and the identity gate underneath (internal/processgroup) can +// still prove the group is ours. The reap and every signal take the same lock, +// and the reap marks the leader reaped inside it, so no signal can land in +// the instant after the number was let go. +type leader struct { + mu sync.Mutex + group processgroup.Group + id processgroup.Identity + reaped bool + // proven says the group's identity was read at launch. A group that could + // not be proven is never signalled as a group; its leader alone is ended + // through Go's own handle, which cannot outlive the reap either. + proven bool + proc *os.Process + // send is the group's signal, a seam a test replaces to prove what is sent. + send func(g processgroup.Group, kill bool) error +} + +func newLeader(proc *os.Process) (*leader, bool) { + group := processgroup.CaptureGroup(proc.Pid) + id, ok := group.Identity() + return &leader{group: group, id: id, proven: ok, proc: proc, send: sendGroup}, ok +} + +func sendGroup(g processgroup.Group, kill bool) error { + if kill { + return g.Kill() + } + return g.Terminate() +} + +func (l *leader) signal(kill bool) error { + l.mu.Lock() + defer l.mu.Unlock() + if l.reaped { + return nil + } + if !l.proven { + if kill { + return l.proc.Kill() + } + return l.proc.Signal(syscall.SIGTERM) + } + return l.send(l.group, kill) +} + +// reap waits for the leader, ends whatever it left in its group while the +// leader is still an unreaped zombie, and only then reaps it. +// +// A dev server whose shell died must not leave its compiler holding the port +// the restart is about to need, and a check that forked a watcher must not +// leave it behind every ten seconds. +func (l *leader) reap(cmd *exec.Cmd) Exit { + waitExited(cmd.Process.Pid) + l.mu.Lock() + defer l.mu.Unlock() + if l.proven { + _ = l.send(l.group, true) + } + l.reaped = true + return exitOf(cmd.Wait()) +} + +func (r *platformRuntime) Boot() string { return processgroup.BootID() } + +// gateScript is the gate every kept process starts behind, and it is a +// constant: it reads one line from descriptor 3 and, only when that line is +// the commit word, closes the descriptor and replaces itself with the approved +// command, which arrives as separate arguments ("$0" "$@") and is never +// spliced into this text. End-of-file — codeaf gone, or the launch aborted — +// or any other line exits 125 having run nothing. +const gateScript = `IFS= read -r word <&3 || exit 125; [ "$word" = run ] || exit 125; exec 3<&-; exec "$0" "$@"` + +// gateWord is what Commit writes. +const gateWord = "run\n" + +// gateShell is the fixed shell the gate runs in. +const gateShell = "/bin/sh" + +type platformLaunch struct { + id processgroup.Identity + commit *os.File + once sync.Once +} + +func (l *platformLaunch) Identity() processgroup.Identity { return l.id } + +func (l *platformLaunch) Commit() error { + err := errors.New("the launch was already let go") + l.once.Do(func() { + _, err = l.commit.Write([]byte(gateWord)) + if closeErr := l.commit.Close(); err == nil { + err = closeErr + } + }) + return err +} + +func (l *platformLaunch) Abort() { + l.once.Do(func() { _ = l.commit.Close() }) +} + +func (r *platformRuntime) Launch(spec Spec, logPath string, exited func(processgroup.Identity, Exit)) (Launch, error) { + if info, err := os.Stat(spec.Dir); err != nil || !info.IsDir() { + return nil, fmt.Errorf("the folder %s is not there", spec.Dir) + } + logFile, err := os.OpenFile(logPath, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) + if err != nil { + return nil, err + } + defer logFile.Close() + // The gate's end of the pipe is the child's descriptor 3 and nothing + // else's; codeaf's end is close-on-exec, so no other child of codeaf + // holds it open and codeaf ending is end-of-file at the gate. + gateEnd, commitEnd, err := os.Pipe() + if err != nil { + return nil, err + } + // The command runs whole, as the person approved it, through the same + // streaming shell and the same scrubbed environment as every command a + // conversation runs: no provider key, no way into codeaf's tmux. + shell, args := bare.StreamingShell(spec.Command) + cmd := exec.Command(gateShell, append([]string{"-c", gateScript, shell}, args...)...) + cmd.Dir = spec.Dir + cmd.Env = bare.StreamingEnv() + cmd.ExtraFiles = []*os.File{gateEnd} + // The child writes to the file itself, not through a pipe codeaf reads, so + // nothing breaks when codeaf goes: the service keeps writing its log. + cmd.Stdout, cmd.Stderr = logFile, logFile + processgroup.ConfigureDetached(cmd) + err = cmd.Start() + _ = gateEnd.Close() + if err != nil { + _ = commitEnd.Close() + return nil, fmt.Errorf("could not start the command: %w", err) + } + l, ok := newLeader(cmd.Process) + if !ok { + // A SERVICE THAT CANNOT BE PROVEN LATER IS NOT KEPT. Closing the + // commit end lets the gate exit having run nothing, and the reap takes + // only our own unreaped child. + _ = commitEnd.Close() + _ = cmd.Wait() + return nil, errors.New("could not record the process's identity, so it was not kept") + } + r.mu.Lock() + r.own[l.id.PID] = l + r.mu.Unlock() + go func() { + exit := l.reap(cmd) + r.mu.Lock() + if r.own[l.id.PID] == l { + delete(r.own, l.id.PID) + } + r.mu.Unlock() + exited(l.id, exit) + }() + return &platformLaunch{id: l.id, commit: commitEnd}, nil +} + +func (r *platformRuntime) Alive(id processgroup.Identity) bool { + return processgroup.Restore(id).Alive() +} + +// Signal sends through the leader when this codeaf started the process, and +// through the persisted identity's gate when it re-adopted one. +func (r *platformRuntime) Signal(id processgroup.Identity, kill bool) error { + r.mu.Lock() + l := r.own[id.PID] + r.mu.Unlock() + if l != nil && l.id == id { + return l.signal(kill) + } + return sendGroup(processgroup.Restore(id), kill) +} + +func (r *platformRuntime) PortInUse(port int) bool { + dialer := net.Dialer{Timeout: time.Second} + conn, err := dialer.Dial("tcp", net.JoinHostPort("localhost", strconv.Itoa(port))) + if err != nil { + return false + } + _ = conn.Close() + return true +} + +func (r *platformRuntime) Probe(ctx context.Context, spec Spec) error { + ctx, cancel := context.WithTimeout(ctx, CheckTimeout) + defer cancel() + switch spec.Health.Kind { + case HealthPort: + var dialer net.Dialer + conn, err := dialer.DialContext(ctx, "tcp", net.JoinHostPort("localhost", strconv.Itoa(spec.Health.Port))) + if err != nil { + return fmt.Errorf("port %d not open", spec.Health.Port) + } + return conn.Close() + case HealthURL: + return r.probeURL(ctx, spec.Health.URL) + case HealthCmd: + return runCheckCommand(ctx, spec) + } + return fmt.Errorf("unknown check %q", spec.Health.Kind) +} + +func (r *platformRuntime) probeURL(ctx context.Context, address string) error { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, address, nil) + if err != nil { + return err + } + response, err := r.client.Do(request) + if err != nil { + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return errors.New("no answer in 5s") + } + return errors.New("no answer") + } + _, _ = io.CopyN(io.Discard, response.Body, probeBodyLimit) + _ = response.Body.Close() + if response.StatusCode != http.StatusOK { + return fmt.Errorf("answered %d", response.StatusCode) + } + return nil +} + +// runCheckCommand runs the approved check command whole, in the service's +// folder, in a process group of its own. A check that outlives ctx is ended +// with its whole group — through the leader, before it is reaped — and so is +// anything a finished check left behind. +func runCheckCommand(ctx context.Context, spec Spec) error { + shell, args := bare.StreamingShell(spec.Health.Cmd) + cmd := exec.Command(shell, args...) + cmd.Dir = spec.Dir + cmd.Env = bare.StreamingEnv() + // What a check prints goes nowhere: its exit status is the verdict, and + // no pipe means no descendant can hold the check open by holding one. + cmd.Stdout, cmd.Stderr = nil, nil + processgroup.ConfigureDetached(cmd) + if err := cmd.Start(); err != nil { + return errors.New("could not run") + } + l, _ := newLeader(cmd.Process) + done := make(chan Exit, 1) + go func() { done <- l.reap(cmd) }() + var exit Exit + select { + case exit = <-done: + case <-ctx.Done(): + _ = l.signal(true) + <-done + return errors.New("took over 5s") + } + if !exit.Known || exit.Code != 0 { + return fmt.Errorf("exits %d", exit.Code) + } + return nil +} diff --git a/internal/service/safety_test.go b/internal/service/safety_test.go new file mode 100644 index 0000000000..53d10efca0 --- /dev/null +++ b/internal/service/safety_test.go @@ -0,0 +1,311 @@ +package service + +import ( + "errors" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +// TestAStopDuringTheGraceIsTheSameStop is the double stop: a second Stop while +// the first is still waiting out SIGTERM must not orphan the first's cleanup, +// so the row ends not running and Start again starts a new process. +func TestAStopDuringTheGraceIsTheSameStop(t *testing.T) { + h := newHarness(t) + row := h.keep("stubborn", PolicyOnFailure) + h.rt.proc(row.PID).ignoreTerm = true + if _, err := h.s.Stop("agent-a", row.ID); err != nil { + t.Fatal(err) + } + h.clock.fireDue(2 * time.Second) + second, err := h.s.Stop("agent-a", row.ID) + if err != nil || !second.Stopping || second.State != StateStopped { + t.Fatalf("the second stop is the first one, still in its grace: %+v %v", second, err) + } + for i := 0; i < 50 && h.get(row.ID).Stopping; i++ { + h.clock.fireDue(time.Second) + time.Sleep(time.Millisecond) + } + settle(t, h.s) + got := h.get(row.ID) + if got.Running || got.Stopping || got.PID != 0 { + t.Fatalf("a dead process is still on the row: %+v", got) + } + if signals := h.rt.proc(row.PID).signals; len(signals) != 2 || signals[0] || !signals[1] { + t.Fatalf("one TERM and one KILL, whatever the second stop did: %v", signals) + } + if _, err := h.s.Start("agent-a", row.ID); err != nil { + t.Fatal(err) + } + settle(t, h.s) + if again := h.get(row.ID); !again.Running || again.PID == row.PID { + t.Fatalf("start again did not start: %+v", again) + } +} + +// failingWrites makes the store's writer fail from the nth write on. +func failingWrites(h *harness, from int) *int { + count := new(int) + var mu sync.Mutex + h.setWrite(func(dir string, raw []byte) error { + mu.Lock() + *count++ + n := *count + mu.Unlock() + if n >= from { + return errors.New("disk full") + } + return writeStore(dir, raw) + }) + return count +} + +// TestAYesTheFileCannotKeepStartsNothing: when the record cannot be written +// before the launch, nothing runs, nothing is banked and nothing is listed. +func TestAYesTheFileCannotKeepStartsNothing(t *testing.T) { + h := newHarness(t) + failingWrites(h, 1) + spec := webSpec("web", h.dir) + if _, err := h.s.Keep("agent-a", spec, PolicyOnFailure, Provenance{}); !errors.Is(err, ErrNotSaved) { + t.Fatalf("keep reported %v", err) + } + if h.rt.starts != 0 || len(h.rt.running()) != 0 { + t.Fatal("a process started that the file does not name") + } + if _, ok := h.s.Approved(spec); ok { + t.Fatal("a yes the file could not keep is banked") + } + if rows := h.s.List("agent-a"); len(rows) != 0 { + t.Fatalf("a record the file could not keep is listed: %+v", rows) + } + h.setWrite(writeStore) + h.reopen() + if rows := h.s.List("agent-a"); len(rows) != 0 { + t.Fatal("the next codeaf found a record") + } +} + +// TestALaunchTheFileCannotRecordRunsNothing: the record was written and the +// gate made, and the gate's identity could not be written. The gate is let go, +// so the approved command never runs; the row keeps the gate until its exit is +// seen, says why, and the next codeaf still says why. +func TestALaunchTheFileCannotRecordRunsNothing(t *testing.T) { + h := newHarness(t) + failingWrites(h, 2) + row, err := h.s.Keep("agent-a", webSpec("web", h.dir), PolicyOnFailure, Provenance{}) + if !errors.Is(err, ErrNotSaved) { + t.Fatalf("keep reported %v", err) + } + if row.State != StateFailed || !strings.Contains(row.Reason, "could not write") { + t.Fatalf("the row says why it is not running: %+v", row) + } + if h.rt.launches != 1 || h.rt.starts != 0 { + t.Fatalf("the approved command ran %d times from %d gates", h.rt.starts, h.rt.launches) + } + deadline := time.Now().Add(5 * time.Second) + for h.get(row.ID).Running { + if time.Now().After(deadline) { + t.Fatal("the let-go gate never left the row") + } + time.Sleep(time.Millisecond) + } + if len(h.rt.running()) != 0 { + t.Fatalf("still running: %v", h.rt.running()) + } + h.setWrite(writeStore) + h.reopen() + if got := h.get(row.ID); got.Running || got.State != StateFailed || len(h.rt.running()) != 0 { + t.Fatalf("the next codeaf: %+v", got) + } +} + +// TestAPolicyChangeTheFileCannotKeepIsUndone, and a removal likewise. +func TestAChangeTheFileCannotKeepIsUndone(t *testing.T) { + h := newHarness(t) + row := h.keep("web", PolicyOnFailure) + if _, err := h.s.Stop("agent-a", row.ID); err != nil { + t.Fatal(err) + } + settle(t, h.s) + failingWrites(h, 1) + got, err := h.s.SetPolicy("agent-a", row.ID, PolicyAlways) + if !errors.Is(err, ErrNotSaved) || got.Policy != PolicyOnFailure { + t.Fatalf("a policy the file could not keep was reported: %+v %v", got, err) + } + if policy, _ := h.s.Approved(webSpec("web", h.dir)); policy != PolicyOnFailure { + t.Fatal("the banked yes moved with an unsaved policy") + } + if err := os.WriteFile(row.LogPath, []byte("kept"), 0o600); err != nil { + t.Fatal(err) + } + if err := h.s.Remove("agent-a", row.ID); !errors.Is(err, ErrNotSaved) { + t.Fatalf("remove reported %v", err) + } + if _, err := h.s.Get("agent-a", row.ID); err != nil { + t.Fatal("a removal the file could not keep removed the record") + } + if body, _ := os.ReadFile(row.LogPath); string(body) != "kept" { + t.Fatal("a removal the file could not keep removed the output") + } +} + +// TestAListenerThatClosesOrBlocksDoesNotHoldClose. +func TestAListenerThatClosesOrBlocksDoesNotHoldClose(t *testing.T) { + h := newHarness(t) + closed := make(chan struct{}) + h.s.Subscribe(func(Event) { + h.s.Close() + select { + case <-closed: + default: + close(closed) + } + }) + h.keep("web", PolicyOnFailure) + select { + case <-closed: + case <-time.After(5 * time.Second): + t.Fatal("Close from inside a listener never returned") + } + + other := newHarness(t) + block := make(chan struct{}) + defer close(block) + other.s.Subscribe(func(Event) { <-block }) + other.keep("web", PolicyOnFailure) + done := make(chan struct{}) + go func() { other.s.Close(); close(done) }() + select { + case <-done: + case <-time.After(5 * time.Second): + t.Fatal("a blocked listener held Close") + } +} + +// TestARowNeverAliasesTheRecord. +func TestARowNeverAliasesTheRecord(t *testing.T) { + h := newHarness(t) + row := h.keep("web", PolicyNever) + h.rt.exit(t, h.s, row.PID, 3) + got := h.get(row.ID) + *got.ExitCode = 0 + got.Check = nil + if again := h.get(row.ID); again.ExitCode == nil || *again.ExitCode != 3 { + t.Fatal("writing into a row changed the record") + } +} + +// TestAnUnreadableBootProvesNoReboot: a boot that cannot be read is neither +// the same boot nor another one, so a saved service is not started beside a +// copy that may still be running. +func TestAnUnreadableBootProvesNoReboot(t *testing.T) { + for name, boots := range map[string][2]string{ + "unreadable now": {"boot-1", ""}, + "unreadable before": {"", "boot-2"}, + } { + t.Run(name, func(t *testing.T) { + h := newHarness(t) + h.rt.boot = boots[0] + row := h.keep("web", PolicyAlways) + h.s.Close() + h.rt.mu.Lock() + h.rt.boot = boots[1] + h.rt.procs = map[int]*fakeProc{} + h.rt.mu.Unlock() + h.open() + if got := h.get(row.ID); got.Running || got.State != StateNotRunning || h.rt.starts != 1 { + t.Fatalf("an unproven reboot started it again: %+v", got) + } + }) + } +} + +// TestAFileCodeafDidNotWriteIsRefused: corrupt, oversized, newer or holding a +// spec that was never validated — Open says so, starts nothing, and lets go of +// the lock so the person can repair it. +func TestAFileCodeafDidNotWriteIsRefused(t *testing.T) { + for name, body := range map[string]string{ + "corrupt": "{", + "newer": `{"version":2}`, + "unvalidated": `{"version":1,"seq":1,"services":[{"id":"s1","owner":"a","spec":{"name":"x","command":"","dir":"/tmp","health":{"kind":"port","port":1}},"policy":"always","state":"healthy"}]}`, + "bad identity": `{"version":1,"seq":1,"services":[{"id":"s1","owner":"a","spec":{"name":"x","command":"x","dir":"/tmp","health":{"kind":"port","port":1}},"policy":"always","state":"healthy","identity":{"pid":1,"start":0}}]}`, + "bad consent": `{"version":1,"consent":{"zz":{"policy":"always","health":{"kind":"port","port":1}}}}`, + "unknown state": `{"version":1,"seq":1,"services":[{"id":"s1","owner":"a","spec":{"name":"x","command":"x","dir":"/tmp","health":{"kind":"port","port":1}},"policy":"always","state":"zombie"}]}`, + "oversized": `{"version":1,"boot":"` + strings.Repeat("x", maxStoreBytes) + `"}`, + } { + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + if err := os.WriteFile(filepath.Join(dir, storeFile), []byte(body), 0o600); err != nil { + t.Fatal(err) + } + rt := newFakeRuntime() + if s, err := Open(Options{Dir: dir, Runtime: rt, Clock: newFakeClock()}); err == nil || errors.Is(err, ErrBusy) { + if s != nil { + s.Close() + } + t.Fatalf("opened: %v", err) + } + if rt.starts != 0 { + t.Fatal("a refused file started something") + } + if err := os.Remove(filepath.Join(dir, storeFile)); err != nil { + t.Fatal(err) + } + s, err := Open(Options{Dir: dir, Runtime: rt, Clock: newFakeClock()}) + if err != nil { + t.Fatalf("the refusal kept the lock: %v", err) + } + s.Close() + }) + } +} + +// TestAFolderIsKeptExactlyAsItIsOnDisk, trailing space included. +func TestAFolderIsKeptExactlyAsItIsOnDisk(t *testing.T) { + dir := filepath.Join(t.TempDir(), "web ") + spec, err := Spec{Command: "npm run dev", Dir: dir, Health: Health{Port: 5173}}.Check() + if err != nil || spec.Dir != dir { + t.Fatalf("the folder %q became %q: %v", dir, spec.Dir, err) + } + if other := (Spec{Command: "npm run dev", Dir: strings.TrimSpace(dir), Health: Health{Port: 5173}}); other.ConsentKey() == spec.ConsentKey() { + t.Fatal("two folders shared one yes") + } +} + +// TestAFoldedEpisodeStillSaysItHappened: a failure that recovered before +// anybody read it leaves its episode on the healthy row. +func TestAFoldedEpisodeStillSaysItHappened(t *testing.T) { + h := newHarness(t) + row := h.keep("web", PolicyOnFailure) + h.rt.exit(t, h.s, row.PID, 1) + h.advance(2 * time.Second) + h.advance(10 * time.Second) + got := h.get(row.ID) + if got.State != StateHealthy || got.Episode != row.ID+".1" || got.EpisodeOpen || + got.EpisodeReason != "exited 1" || got.EpisodeStartedAt == nil || got.Restarts != 1 { + t.Fatalf("the recovered row lost its episode: %+v", got) + } + h.reopen() + if again := h.get(row.ID); again.Episode != got.Episode || again.EpisodeReason != got.EpisodeReason { + t.Fatal("the episode did not survive a restart of codeaf") + } +} + +// TestAChangedCheckUnderAYesIsNotSlippedIn: the same command with a new check +// is not approved, and while the first is running it is refused rather than +// swapped in. +func TestAChangedCheckUnderAYesIsNotSlippedIn(t *testing.T) { + h := newHarness(t) + h.keep("web", PolicyOnFailure) + changed := webSpec("web", h.dir) + changed.Health = Health{Cmd: "curl -fsS localhost:5173 && rm -rf ~"} + if _, ok := h.s.Approved(changed); ok { + t.Fatal("a new check command rode an old yes") + } + if _, err := h.s.Keep("agent-a", changed, PolicyOnFailure, Provenance{}); !errors.Is(err, ErrRunning) { + t.Fatalf("a new check was swapped into a running service: %v", err) + } +} diff --git a/internal/service/servicetest/runtime.go b/internal/service/servicetest/runtime.go new file mode 100644 index 0000000000..464409e84a --- /dev/null +++ b/internal/service/servicetest/runtime.go @@ -0,0 +1,247 @@ +// Package servicetest is a machine with no processes on it, for tests of +// what sits above the supervisor: a conversation's keep-running card, the +// app's rows. Nothing it does forks, sleeps or listens. +package servicetest + +import ( + "context" + "errors" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/processgroup" + "github.com/Agent-Field/codeaf/internal/service" +) + +// Runtime pretends every start succeeds and every check passes, and says what +// it was asked to do. +type Runtime struct { + mu sync.Mutex + nextPID int + procs map[int]*proc + // Started is every spec whose command was let run, in order. + Started []service.Spec + // PortsUsed are ports another program answers on. + PortsUsed map[int]bool + // failing makes every check fail; refusing makes every signal fail and + // leaves the process up. + failing bool + refusing bool +} + +// Failing makes every check fail, or pass again. +func (r *Runtime) Failing(on bool) { + r.mu.Lock() + r.failing = on + r.mu.Unlock() +} + +// Refusing makes every signal fail and leaves its process up, or not. +func (r *Runtime) Refusing(on bool) { + r.mu.Lock() + r.refusing = on + r.mu.Unlock() +} + +// Exit ends one running process by itself, as though it exited with code. +func (r *Runtime) Exit(pid, code int) bool { + r.mu.Lock() + p := r.procs[pid] + if p == nil || !p.alive { + r.mu.Unlock() + return false + } + p.alive = false + r.mu.Unlock() + p.exited(p.id, service.Exit{Code: code, Known: true}) + return true +} + +type proc struct { + id processgroup.Identity + alive bool + exited func(processgroup.Identity, service.Exit) +} + +// New is an empty machine. +func New() *Runtime { + return &Runtime{nextPID: 1000, procs: map[int]*proc{}, PortsUsed: map[int]bool{}} +} + +const boot = "servicetest-boot" + +func (r *Runtime) Launch(spec service.Spec, _ string, exited func(processgroup.Identity, service.Exit)) (service.Launch, error) { + r.mu.Lock() + defer r.mu.Unlock() + r.nextPID++ + id := processgroup.Identity{PID: r.nextPID, Start: uint64(r.nextPID), Boot: boot} + r.procs[id.PID] = &proc{id: id, alive: true, exited: exited} + return &launch{r: r, id: id, spec: spec}, nil +} + +type launch struct { + r *Runtime + id processgroup.Identity + spec service.Spec + done bool +} + +func (l *launch) Identity() processgroup.Identity { return l.id } + +func (l *launch) Commit() error { + l.r.mu.Lock() + defer l.r.mu.Unlock() + l.done = true + l.r.Started = append(l.r.Started, l.spec) + return nil +} + +func (l *launch) Abort() { + l.r.mu.Lock() + p := l.r.procs[l.id.PID] + if l.done || p == nil || !p.alive { + l.r.mu.Unlock() + return + } + l.done = true + p.alive = false + l.r.mu.Unlock() + go p.exited(l.id, service.Exit{Code: 125, Known: true}) +} + +func (r *Runtime) Alive(id processgroup.Identity) bool { + r.mu.Lock() + defer r.mu.Unlock() + p := r.procs[id.PID] + return p != nil && p.alive && p.id == id +} + +func (r *Runtime) Signal(id processgroup.Identity, _ bool) error { + r.mu.Lock() + p := r.procs[id.PID] + if p == nil || p.id != id || !p.alive { + r.mu.Unlock() + return nil + } + if r.refusing { + r.mu.Unlock() + return errors.New("operation not permitted") + } + p.alive = false + r.mu.Unlock() + go p.exited(p.id, service.Exit{Code: 143, Known: true}) + return nil +} + +func (r *Runtime) Probe(context.Context, service.Spec) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.failing { + return errors.New("not answering") + } + return nil +} + +func (r *Runtime) PortInUse(port int) bool { + r.mu.Lock() + defer r.mu.Unlock() + return r.PortsUsed[port] +} + +func (r *Runtime) Boot() string { return boot } + +// Running counts the processes still up. +func (r *Runtime) Running() int { + r.mu.Lock() + defer r.mu.Unlock() + n := 0 + for _, p := range r.procs { + if p.alive { + n++ + } + } + return n +} + +// Starts counts the specs started. +func (r *Runtime) Starts() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.Started) +} + +// Clock is a clock that moves only when it is told to, for a supervisor whose +// checks, backoffs and graces a test steps through. +type Clock struct { + mu sync.Mutex + now time.Time + timers []*timer +} + +type timer struct { + at time.Time + f func() + done bool + c *Clock +} + +// NewClock is a clock standing at now. +func NewClock(now time.Time) *Clock { return &Clock{now: now} } + +func (c *Clock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.now +} + +func (c *Clock) AfterFunc(d time.Duration, f func()) service.Timer { + c.mu.Lock() + defer c.mu.Unlock() + t := &timer{at: c.now.Add(d), f: f, c: c} + c.timers = append(c.timers, t) + return t +} + +func (t *timer) Stop() bool { + t.c.mu.Lock() + defer t.c.mu.Unlock() + was := !t.done + t.done = true + return was +} + +// Advance moves the clock on by d and runs every timer then due, in order. +func (c *Clock) Advance(d time.Duration) { + c.mu.Lock() + c.now = c.now.Add(d) + var due []*timer + kept := c.timers[:0] + for _, t := range c.timers { + switch { + case t.done: + case !t.at.After(c.now): + t.done = true + due = append(due, t) + default: + kept = append(kept, t) + } + } + c.timers = kept + c.mu.Unlock() + for _, t := range due { + t.f() + } +} + +// Pending counts the timers not yet run or stopped. +func (c *Clock) Pending() int { + c.mu.Lock() + defer c.mu.Unlock() + n := 0 + for _, t := range c.timers { + if !t.done { + n++ + } + } + return n +} diff --git a/internal/service/spec.go b/internal/service/spec.go new file mode 100644 index 0000000000..f4996e16c0 --- /dev/null +++ b/internal/service/spec.go @@ -0,0 +1,268 @@ +// Package service keeps the processes a person agreed to keep running. +// +// A kept-running service is one exact shell command in one exact folder, with +// one health check, that a person said yes to. It runs in its own detached +// process group so it outlives the conversation that asked for it, the codeaf +// that started it and that codeaf crashing. A later codeaf takes it back only +// when it can PROVE the process is the one that was started — the leader's +// pid, its start identity and the boot it was recorded in +// (internal/processgroup) — and otherwise says it is not running and does +// nothing to whatever holds that pid now. +// +// The package is the engine's and nothing else's: it knows commands, folders, +// checks, policies and owners, and it does not know conversations, models, +// cards or windows. A conversation proposes a [Spec] and a person's answer +// decides its [Policy]; the [Supervisor] is the one owner of the timer, the +// probes, the restarts and the file, for every service in this codeaf. +// +// The resident's services (internal/resident) are a different product with a +// different store. The two share only the process-group primitives underneath. +package service + +import ( + "crypto/sha256" + "encoding/hex" + "errors" + "fmt" + "net/url" + "path/filepath" + "strconv" + "strings" + "time" + "unicode/utf8" +) + +// The rhythm every service is checked on. They are the board's numbers and +// they are not settings: a check the agent could tune is a check it could make +// lenient. +const ( + // CheckEvery is how often a running service is checked, and how long after + // a start the first check comes. + CheckEvery = 10 * time.Second + // CheckTimeout is how long one check may take before it counts as failed. + CheckTimeout = 5 * time.Second + // UnhealthyAfter is how many failed checks in a row make a service + // unhealthy. One passing check makes it healthy again. + UnhealthyAfter = 3 + // RestartLimit is how many automatic restarts fit in RestartWindow. The + // next failure inside the window gives up. + RestartLimit = 3 + // RestartWindow is the sliding window restarts are counted in, and how + // long a service has to stay healthy for its count to start again at 0. + RestartWindow = 10 * time.Minute + // StopGrace is how long SIGTERM has before SIGKILL follows. + StopGrace = 5 * time.Second + // FirstBackoff is the wait before the first automatic restart; each later + // one in the window doubles it: 2s, 4s, 8s. + FirstBackoff = 2 * time.Second +) + +// The bounds of what a service may say about itself. They keep the file, the +// rows and the events small whatever the agent proposed. +const ( + maxCommand = 4096 + maxHealthCmd = 1024 + maxURL = 2048 + maxName = 64 + maxProvenance = 160 + maxReason = 200 + maxOwner = 128 + maxServices = 64 + maxPerOwner = 16 + maxConsent = 256 + maxProbeDetail = 160 +) + +// Policy is what happens when a service fails. Only a person sets it. +type Policy string + +const ( + // PolicyNever restarts nothing: a failure shows its state and one line. + PolicyNever Policy = "never" + // PolicyOnFailure restarts after a non-zero exit or UnhealthyAfter failed + // checks in a row, up to RestartLimit times in RestartWindow. + PolicyOnFailure Policy = "on_failure" + // PolicyAlways is PolicyOnFailure that also restarts after exit 0. + PolicyAlways Policy = "always" +) + +// Valid reports whether p is one of the three policies. +func (p Policy) Valid() bool { + return p == PolicyNever || p == PolicyOnFailure || p == PolicyAlways +} + +// ParsePolicy reads a policy, with "" meaning on_failure — the board's default +// proposal. +func ParsePolicy(text string) (Policy, error) { + switch p := Policy(strings.TrimSpace(text)); { + case p == "": + return PolicyOnFailure, nil + case p.Valid(): + return p, nil + } + return "", fmt.Errorf("restart policy is never, on_failure or always, not %q", text) +} + +// HealthKind is which of the three checks a service has. +type HealthKind string + +const ( + HealthPort HealthKind = "port" + HealthURL HealthKind = "url" + HealthCmd HealthKind = "cmd" +) + +// Health is a service's one check: a port that accepts a connection, a URL that +// answers 200, or a command that exits 0 in the service's folder. Exactly one +// of Port, URL and Cmd is set, and Kind says which. +type Health struct { + Kind HealthKind `json:"kind"` + Port int `json:"port,omitempty"` + URL string `json:"url,omitempty"` + Cmd string `json:"cmd,omitempty"` +} + +// Words is the check as the row says it: "port 5173", "URL http://…", +// "command pg_isready". +func (h Health) Words() string { + switch h.Kind { + case HealthPort: + return "port " + strconv.Itoa(h.Port) + case HealthURL: + return "URL " + h.URL + case HealthCmd: + return "command " + h.Cmd + } + return "" +} + +// OpenURL is the address a person may open for this check: the URL itself, a +// port's local address, and nothing for a command check, whose service may not +// speak HTTP at all. +func (h Health) OpenURL() string { + switch h.Kind { + case HealthPort: + return "http://localhost:" + strconv.Itoa(h.Port) + "/" + case HealthURL: + return h.URL + } + return "" +} + +// Check validates one health check. The fields are typed and checked one by +// one, so nothing the agent wrote is ever concatenated into a shell line: a +// port is a number, a URL is parsed and must be plain http(s) with no +// credentials in it, and a command is run whole as the person approved it. +func (h Health) Check() (Health, error) { + set := 0 + if h.Port != 0 { + set++ + } + if strings.TrimSpace(h.URL) != "" { + set++ + } + if strings.TrimSpace(h.Cmd) != "" { + set++ + } + if set != 1 { + return Health{}, errors.New("a kept-running process has exactly one health check: a port, a URL or a command") + } + switch { + case h.Port != 0: + if h.Port < 1 || h.Port > 65535 { + return Health{}, fmt.Errorf("port %d is not a port", h.Port) + } + return Health{Kind: HealthPort, Port: h.Port}, nil + case strings.TrimSpace(h.URL) != "": + raw := strings.TrimSpace(h.URL) + if len(raw) > maxURL || strings.ContainsAny(raw, "\x00\r\n\t ") { + return Health{}, errors.New("the health URL is not one plain address") + } + parsed, err := url.Parse(raw) + if err != nil || (parsed.Scheme != "http" && parsed.Scheme != "https") || parsed.Host == "" { + return Health{}, errors.New("the health URL must be an http or https address") + } + // A credential in the URL would ride every row, event and log line that + // names the check, so a URL carrying one is refused rather than kept. + if parsed.User != nil { + return Health{}, errors.New("the health URL may not carry a user name or password") + } + return Health{Kind: HealthURL, URL: parsed.String()}, nil + default: + cmd := strings.TrimSpace(h.Cmd) + if len(cmd) > maxHealthCmd || strings.ContainsRune(cmd, 0) || !utf8.ValidString(cmd) { + return Health{}, errors.New("the health command is too long or not text") + } + return Health{Kind: HealthCmd, Cmd: cmd}, nil + } +} + +// Spec is what an agent proposes: the exact command, the exact folder, a short +// name and one check. +type Spec struct { + Name string `json:"name"` + Command string `json:"command"` + Dir string `json:"dir"` + Health Health `json:"health"` +} + +// Check validates a spec and returns it in its one canonical spelling, which +// is what consent is keyed on and what is run. +func (s Spec) Check() (Spec, error) { + command := strings.TrimSpace(s.Command) + if command == "" { + return Spec{}, errors.New("a kept-running process needs a command") + } + if len(command) > maxCommand || strings.ContainsRune(command, 0) || !utf8.ValidString(command) { + return Spec{}, errors.New("the command is too long or not text") + } + // THE FOLDER IS THE PATH EXACTLY AS IT IS ON DISK. A folder name may end + // in a space, and trimming one would run the command somewhere else — or + // nowhere. Clean only folds separators and dot segments. + dir := s.Dir + if dir == "" || !filepath.IsAbs(dir) || strings.ContainsRune(dir, 0) || len(dir) > maxCommand { + return Spec{}, errors.New("a kept-running process needs an absolute folder") + } + health, err := s.Health.Check() + if err != nil { + return Spec{}, err + } + name := oneLine(s.Name, maxName) + if name == "" { + name = oneLine(firstWords(command), maxName) + } + return Spec{Name: name, Command: command, Dir: filepath.Clean(dir), Health: health}, nil +} + +// ConsentKey names what a person is asked about once: this command, in this +// folder. The yes banked under it also holds the check and the policy they +// saw ([Supervisor.Approved]); a proposal that changes the check is asked +// about again, because a check command is a second command run every ten +// seconds and a moved port or URL is a different promise about the service. +func (s Spec) ConsentKey() string { + sum := sha256.New() + sum.Write([]byte(s.Command)) + sum.Write([]byte{0}) + sum.Write([]byte(s.Dir)) + return hex.EncodeToString(sum.Sum(nil)) +} + +// oneLine is text cut to one line of at most limit runes. +func oneLine(text string, limit int) string { + text = strings.Join(strings.Fields(text), " ") + if utf8.RuneCountInString(text) <= limit { + return text + } + runes := []rune(text) + return strings.TrimSpace(string(runes[:limit-1])) + "…" +} + +// firstWords is a name for a command that came with none: its first two words. +func firstWords(command string) string { + line, _, _ := strings.Cut(command, "\n") + fields := strings.Fields(line) + if len(fields) > 2 { + fields = fields[:2] + } + return strings.Join(fields, " ") +} diff --git a/internal/service/state.go b/internal/service/state.go new file mode 100644 index 0000000000..9f42da933c --- /dev/null +++ b/internal/service/state.go @@ -0,0 +1,251 @@ +package service + +import ( + "strconv" + "time" + + "github.com/Agent-Field/codeaf/internal/processgroup" + "github.com/Agent-Field/codeaf/internal/redact" +) + +// State is where a service is in its life. It is the engine's word for the row, +// and a surface draws it; the engine owns every transition. +type State string + +const ( + // StateStarting is a service started and not yet seen passing a check. + StateStarting State = "starting" + // StateHealthy is a service whose last check passed. + StateHealthy State = "healthy" + // StateUnhealthy is a service still running that failed UnhealthyAfter + // checks in a row and that its policy does not restart. + StateUnhealthy State = "unhealthy" + // StateRestarting is a service waiting out its backoff, or being started + // again by its policy. + StateRestarting State = "restarting" + // StateGaveUp is a service whose policy wanted another restart after + // RestartLimit in RestartWindow, and got none. + StateGaveUp State = "gave_up" + // StateNotRunning is a service this codeaf could not prove is running — + // after codeaf or the machine restarted — or one that exited 0 under a + // policy that does not restart a clean exit. Nothing restarts it on its own. + StateNotRunning State = "not_running" + // StateStopped is a service a person stopped. Nothing restarts it. + StateStopped State = "stopped" + // StateFailed is a service that exited non-zero under PolicyNever, or one + // that could not be started at all. + StateFailed State = "failed" +) + +func (s State) valid() bool { + switch s { + case StateStarting, StateHealthy, StateUnhealthy, StateRestarting, StateGaveUp, + StateNotRunning, StateStopped, StateFailed: + return true + } + return false +} + +// How a service's current process came to be. +const ( + StartedByAgent = "agent" + StartedByRestart = "restart" + StartedByPerson = "person" + StartedAfterRestart = "after_restart" + ReAdopted = "re_adopted" +) + +// Provenance is where a service came from: the conversation's own reference to +// the call that asked, and the line that says it. +type Provenance struct { + // Label is the started-by line, "Start the web dev server…". + Label string `json:"label,omitempty"` + // Turn and CallID name the turn and the tool call that asked. + Turn uint64 `json:"turn,omitempty"` + CallID string `json:"call_id,omitempty"` + At time.Time `json:"at"` +} + +// Check is a service's newest check. +type Check struct { + OK bool `json:"ok"` + At time.Time `json:"at"` + // Fails is how many checks in a row have failed, 0 after a pass. + Fails int `json:"fails"` + // Detail is what the failing check found: "port 5173 not open". + Detail string `json:"detail,omitempty"` +} + +// record is one service as the file keeps it. It holds the exact command, so +// the file is the person's own and private; nothing that leaves the package +// carries it unredacted ([Row]). +type record struct { + ID string `json:"id"` + Owner string `json:"owner"` + Spec Spec `json:"spec"` + Policy Policy `json:"policy"` + From Provenance `json:"from"` + Created time.Time `json:"created"` + + State State `json:"state"` + Reason string `json:"reason,omitempty"` + + Identity *processgroup.Identity `json:"identity,omitempty"` + StartedAt time.Time `json:"started_at,omitempty"` + StartedHow string `json:"started_how,omitempty"` + EndedAt time.Time `json:"ended_at,omitempty"` + ExitCode *int `json:"exit_code,omitempty"` + + Check *Check `json:"check,omitempty"` + HealthySince time.Time `json:"healthy_since,omitempty"` + Restarts []time.Time `json:"restarts,omitempty"` + BackoffUntil time.Time `json:"backoff_until,omitempty"` + ReAdoptedAt time.Time `json:"readopted_at,omitempty"` + PortConflict bool `json:"port_conflict,omitempty"` + + Episode int `json:"episode,omitempty"` + EpisodeOpen bool `json:"episode_open,omitempty"` + EpisodeAt time.Time `json:"episode_at,omitempty"` + EpisodeEnded time.Time `json:"episode_ended,omitempty"` + EpisodeReason string `json:"episode_reason,omitempty"` + + StoppedBy string `json:"stopped_by,omitempty"` + Truncated bool `json:"truncated,omitempty"` + Updated time.Time `json:"updated"` +} + +// Row is one service as a surface sees it. The command and the check command +// are redacted: a row travels in events and to other processes, and a token +// someone pasted into a command line must not ride with it. +type Row struct { + ID string `json:"id"` + Owner string `json:"owner"` + Name string `json:"name"` + Command string `json:"command"` + Dir string `json:"dir"` + Health Health `json:"health"` + HealthWords string `json:"health_words"` + OpenURL string `json:"open_url,omitempty"` + Policy Policy `json:"policy"` + From Provenance `json:"from"` + + State State `json:"state"` + Reason string `json:"reason,omitempty"` + // Running says a process this codeaf can prove is the service is up. + Running bool `json:"running"` + Stopping bool `json:"stopping,omitempty"` + PID int `json:"pid,omitempty"` + StartedAt *time.Time `json:"started_at,omitempty"` + StartedHow string `json:"started_how,omitempty"` + EndedAt *time.Time `json:"ended_at,omitempty"` + ExitCode *int `json:"exit_code,omitempty"` + + Check *Check `json:"check,omitempty"` + Restarts int `json:"restarts"` + RestartLimit int `json:"restart_limit"` + BackoffUntil *time.Time `json:"backoff_until,omitempty"` + ReAdopted bool `json:"re_adopted,omitempty"` + ReAdoptedAt *time.Time `json:"re_adopted_at,omitempty"` + PortConflict bool `json:"port_conflict,omitempty"` + + // Episode names one run of trouble — from the first failure to being + // healthy again — so a surface keeps one line per episode and updates it + // in place. EpisodeOpen is false once it is over. + // + // THE EPISODE OUTLIVES ITS TROUBLE. A failure that recovered before a + // listener heard it is still on the row that says healthy: its id, when it + // began and what first went wrong stay until the next episode replaces + // them, so a surface that only ever saw the recovered row can still write + // the one line about it. + Episode string `json:"episode,omitempty"` + EpisodeOpen bool `json:"episode_open,omitempty"` + EpisodeStartedAt *time.Time `json:"episode_started_at,omitempty"` + // EpisodeResolvedAt is when the episode closed — healthy again, or the + // person stopped it — set once and never moved by later checks. + EpisodeResolvedAt *time.Time `json:"episode_resolved_at,omitempty"` + EpisodeReason string `json:"episode_reason,omitempty"` + + StoppedBy string `json:"stopped_by,omitempty"` + LogPath string `json:"log_path"` + OutputTruncated bool `json:"output_truncated,omitempty"` + Updated time.Time `json:"updated"` +} + +// Event is one change to one service. +type Event struct { + Row Row `json:"row"` + // Change is what moved: "started", "check", "state", "policy", "stopped", + // "removed", "re_adopted". + Change string `json:"change"` + // Removed is set when the record is gone; Row is its last state. + Removed bool `json:"removed,omitempty"` +} + +func timeOrNil(t time.Time) *time.Time { + if t.IsZero() { + return nil + } + t = t.UTC() + return &t +} + +// row is a record as a surface sees it, at now. +func (r *record) row(now time.Time, running, stopping bool, logPath string) Row { + health := r.Spec.Health + health.Cmd = redact.Secrets(health.Cmd) + health.URL = redact.Secrets(health.URL) + out := Row{ + ID: r.ID, Owner: r.Owner, Name: r.Spec.Name, + Command: redact.Secrets(r.Spec.Command), Dir: r.Spec.Dir, + Health: health, HealthWords: health.Words(), OpenURL: r.Spec.Health.OpenURL(), + Policy: r.Policy, From: r.From, + State: r.State, Reason: r.Reason, + Running: running, Stopping: stopping, + StartedAt: timeOrNil(r.StartedAt), StartedHow: r.StartedHow, + EndedAt: timeOrNil(r.EndedAt), + Restarts: len(restartsWithin(r.Restarts, now)), RestartLimit: RestartLimit, + ReAdopted: r.StartedHow == ReAdopted, ReAdoptedAt: timeOrNil(r.ReAdoptedAt), + PortConflict: r.PortConflict, EpisodeOpen: r.EpisodeOpen, + StoppedBy: r.StoppedBy, LogPath: logPath, OutputTruncated: r.Truncated, + Updated: r.Updated.UTC(), + } + if out.OpenURL != "" { + out.OpenURL = redact.Secrets(out.OpenURL) + } + if running && r.Identity != nil { + out.PID = r.Identity.PID + } + // Nothing in a row aliases the record: a caller that writes into what it + // was handed changes its copy, never the supervisor's. + if r.Check != nil { + check := *r.Check + out.Check = &check + } + if r.ExitCode != nil { + code := *r.ExitCode + out.ExitCode = &code + } + if r.State == StateRestarting { + out.BackoffUntil = timeOrNil(r.BackoffUntil) + } + if r.Episode > 0 { + out.Episode = r.ID + "." + strconv.Itoa(r.Episode) + out.EpisodeStartedAt = timeOrNil(r.EpisodeAt) + if !r.EpisodeOpen { + out.EpisodeResolvedAt = timeOrNil(r.EpisodeEnded) + } + out.EpisodeReason = redact.Secrets(r.EpisodeReason) + } + return out +} + +// restartsWithin is the automatic restarts still inside the sliding window. +func restartsWithin(restarts []time.Time, now time.Time) []time.Time { + kept := restarts[:0:0] + for _, at := range restarts { + if now.Sub(at) < RestartWindow { + kept = append(kept, at) + } + } + return kept +} diff --git a/internal/service/store.go b/internal/service/store.go new file mode 100644 index 0000000000..cb4b93b6fe --- /dev/null +++ b/internal/service/store.go @@ -0,0 +1,249 @@ +package service + +import ( + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/filelock" +) + +// The store is one small JSON file beside one lock and one logs folder: +// +// /services.json every record and every consent, written whole +// /lock held by the one supervisor of this home +// /logs/.log a service's output, and .log.1 before it +// +// THE FILE IS WRITTEN WHOLE AND RENAMED INTO PLACE, private to the person: +// a crash mid-write leaves the previous file, never half of one, and the exact +// commands in it are readable by nobody else. It is bounded by construction — +// maxServices records and maxConsent consents, each field capped where it is +// made — so it stays a few tens of kilobytes however long codeaf runs. +const ( + storeFile = "services.json" + lockFile = "lock" + logsDir = "logs" + storeVersion = 1 + // maxStoreBytes is the most of the services file that is ever read. A + // file the bounds below can produce is far smaller; one larger than this + // is not one this package wrote, and it is refused rather than read. + maxStoreBytes = 1 << 20 + // logChunk caps a service's live log. Past it the live log is copied to + // .1 and cut back to empty, so one service keeps at most two chunks. + logChunk = 2 << 20 +) + +// ErrBusy is Open's answer when another codeaf already supervises this home. +// The caller leaves the capability off rather than run a second supervisor +// that would restart the first one's services. +var ErrBusy = errors.New("another codeaf is already keeping these services") + +// consent is one banked yes: the policy the person chose and the check they +// saw, for one command in one folder. +type consent struct { + Policy Policy `json:"policy"` + Health Health `json:"health"` + At time.Time `json:"at"` +} + +type storeImage struct { + Version int `json:"version"` + Boot string `json:"boot,omitempty"` + Seq int `json:"seq"` + Services []record `json:"services"` + Consent map[string]consent `json:"consent,omitempty"` +} + +func lockStore(dir string) (*os.File, error) { + if err := os.MkdirAll(filepath.Join(dir, logsDir), 0o700); err != nil { + return nil, err + } + _ = os.Chmod(dir, 0o700) + file, err := os.OpenFile(filepath.Join(dir, lockFile), os.O_CREATE|os.O_RDWR, 0o600) + if err != nil { + return nil, err + } + if err := filelock.Lock(file, true, true); err != nil { + _ = file.Close() + if filelock.IsBusy(err) { + return nil, ErrBusy + } + return nil, err + } + return file, nil +} + +// readStore reads the file back, bounded, and refuses one it could not have +// written. A refusal is Open's error, said to the caller: there is no quiet +// fallback to an empty store, which would be a second set of services beside +// the first one's still-running processes. +func readStore(dir string) (storeImage, error) { + file, err := os.Open(filepath.Join(dir, storeFile)) + if errors.Is(err, os.ErrNotExist) { + return storeImage{Version: storeVersion}, nil + } + if err != nil { + return storeImage{}, err + } + defer file.Close() + raw, err := io.ReadAll(io.LimitReader(file, maxStoreBytes+1)) + if err != nil { + return storeImage{}, err + } + if len(raw) > maxStoreBytes { + return storeImage{}, errors.New("the services file is larger than codeaf ever writes it") + } + var image storeImage + if err := json.Unmarshal(raw, &image); err != nil { + return storeImage{}, fmt.Errorf("the services file is not readable: %w", err) + } + if err := image.check(); err != nil { + return storeImage{}, fmt.Errorf("the services file is not one codeaf wrote: %w", err) + } + return image, nil +} + +// check holds a read image to every bound the package keeps when it writes +// one, so nothing on disk can carry a spec that was never validated, a field +// larger than its cap or more records than the store holds. +func (image storeImage) check() error { + if image.Version != storeVersion { + return fmt.Errorf("version %d", image.Version) + } + if len(image.Services) > maxServices || len(image.Consent) > maxConsent || image.Seq < 0 || len(image.Boot) > 128 { + return errors.New("more than it holds") + } + seen := map[string]bool{} + for _, rec := range image.Services { + n, err := strconv.Atoi(strings.TrimPrefix(rec.ID, "s")) + if !strings.HasPrefix(rec.ID, "s") || err != nil || n <= 0 || n > image.Seq || seen[rec.ID] { + return fmt.Errorf("service id %q", rec.ID) + } + seen[rec.ID] = true + if err := rec.check(); err != nil { + return fmt.Errorf("service %s: %w", rec.ID, err) + } + } + for key, c := range image.Consent { + if _, err := hex.DecodeString(key); err != nil || len(key) != 64 || !c.Policy.Valid() { + return errors.New("a consent") + } + if health, err := c.Health.Check(); err != nil || health != c.Health { + return errors.New("a consent's check") + } + } + return nil +} + +func (r record) check() error { + spec, err := r.Spec.Check() + if err != nil { + return err + } + if spec != r.Spec { + return errors.New("its spec is not in the form codeaf writes") + } + if r.Owner == "" || len(r.Owner) > maxOwner || !r.Policy.Valid() || !r.State.valid() { + return errors.New("its owner, policy or state") + } + if len(r.Restarts) > RestartLimit || r.Episode < 0 || len(r.Reason) > 4*maxReason || + len(r.From.Label) > 4*maxProvenance || len(r.From.CallID) > 4*maxProvenance || + len(r.StoppedBy) > 4*maxName || len(r.StartedHow) > maxName || len(r.EpisodeReason) > 4*maxReason { + return errors.New("a field larger than its cap") + } + if r.Check != nil && len(r.Check.Detail) > 4*maxProbeDetail { + return errors.New("its check") + } + if r.Identity != nil && (r.Identity.PID <= 0 || r.Identity.Start == 0 || len(r.Identity.Boot) > 128) { + return errors.New("its identity") + } + return nil +} + +func writeStore(dir string, raw []byte) error { + if len(raw) > maxStoreBytes { + return errors.New("kept-running service metadata is full; remove an unused service before adding another") + } + tmp, err := os.CreateTemp(dir, ".services-*") + if err != nil { + return err + } + defer os.Remove(tmp.Name()) + if err := tmp.Chmod(0o600); err != nil { + _ = tmp.Close() + return err + } + _, writeErr := tmp.Write(raw) + syncErr := tmp.Sync() + closeErr := tmp.Close() + if err := errors.Join(writeErr, syncErr, closeErr); err != nil { + return err + } + if err := os.Rename(tmp.Name(), filepath.Join(dir, storeFile)); err != nil { + return err + } + return syncDir(dir) +} + +// syncDir makes a rename in dir durable. Without it a machine that loses +// power can come back with the previous file — and with it a previous id +// allocator, which would hand an archived service's output id out again. +func syncDir(dir string) error { + d, err := os.Open(dir) + if err != nil { + return err + } + defer d.Close() + if err := d.Sync(); err != nil && !errors.Is(err, os.ErrInvalid) { + return err + } + return nil +} + +func (s *Supervisor) logPath(id string) string { + return filepath.Join(s.dir, logsDir, id+".log") +} + +// rotateLog keeps a live log under logChunk. The service holds its log open +// for appending, so the file is copied aside and cut back IN PLACE — renaming +// it would leave the service writing into the old copy. What the service +// writes in the instant between the copy and the cut is lost, which is the +// price of a bound that needs no cooperation from the service. +// +// THE BOUND HOLDS ONLY WHILE A CODEAF IS RUNNING. The cut happens on each +// check and when a supervisor opens; while no codeaf is open nothing cuts the +// log, and it grows by whatever the service writes until the next one opens +// and cuts it back first. A service that writes fast for days with codeaf +// closed can fill the disk, and this is said rather than promised away: a +// strict bound with codeaf gone needs a relay process of its own between the +// service and the file, which this package does not have. +func rotateLog(path string) (bool, error) { + info, err := os.Stat(path) + if err != nil || info.Size() <= logChunk { + return false, nil + } + live, err := os.OpenFile(path, os.O_RDWR, 0) + if err != nil { + return false, err + } + defer live.Close() + backup, err := os.OpenFile(path+".1", os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o600) + if err != nil { + return false, err + } + // Only the newest chunk is copied, so the backup is bounded too. + start := info.Size() - logChunk + _, copyErr := io.Copy(backup, io.NewSectionReader(live, start, logChunk)) + closeErr := backup.Close() + if err := errors.Join(copyErr, closeErr); err != nil { + return false, err + } + return true, live.Truncate(0) +} diff --git a/internal/service/supervisor.go b/internal/service/supervisor.go new file mode 100644 index 0000000000..278a2828c7 --- /dev/null +++ b/internal/service/supervisor.go @@ -0,0 +1,1569 @@ +package service + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "os" + "sort" + "strconv" + "strings" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/filelock" + "github.com/Agent-Field/codeaf/internal/processgroup" +) + +// The supervisor's refusals. +var ( + ErrNotFound = errors.New("no such kept-running process") + ErrClosed = errors.New("codeaf is closing; nothing new is kept running") + ErrRunning = errors.New("it is still running; stop it first") + ErrStopping = errors.New("it is still stopping") + ErrFull = fmt.Errorf("%d processes are already kept running for this agent; remove one first", maxPerOwner) + // ErrNotSaved wraps a write of the services file that failed. A change it + // is returned for was either rolled back or, for a stop, carried out with + // the file still saying what it said before. + ErrNotSaved = errors.New("could not write the services file") + // ErrNotStopped is a stop whose process could not be proved gone: it is + // still running, and still this codeaf's to stop. + ErrNotStopped = errors.New("did not stop") + // ErrClosing is a start asked for an agent that is being closed. + ErrClosing = errors.New("its agent is closing; nothing new is started for it") +) + +// defaultWorkers bounds how many checks and starts run at once, whatever the +// number of services: a slow check holds one slot, never the timer, the lock +// or anybody's request. +const defaultWorkers = 4 + +// stopPoll is how often a stop looks again at a process it did not start, +// whose exit nothing reports. It is bounded by StopGrace, so one stop polls at +// most fifty times and then sends SIGKILL. +const stopPoll = 100 * time.Millisecond + +// Options open a [Supervisor]. +type Options struct { + // Dir is the store's folder, private to the person. + Dir string + Runtime Runtime + // Clock is the wall clock when nil. + Clock Clock + // Workers bounds concurrent checks and automatic starts; 4 when zero. + Workers int +} + +// Supervisor is the one owner of every kept-running service in this codeaf: +// one timer, a bounded set of workers, one file. It holds no goroutine per +// service beyond the wait on a process it started, which is the operating +// system's exit report and not a poll. +// +// ITS LOCK IS NEVER HELD ACROSS ANYTHING SLOW. A check, a start and a stop's +// grace all run in a worker with the lock released, and come back under it with +// the generation they started on; a result whose generation has moved — the +// person stopped it, restarted it, removed it — is dropped. That is what lets a +// stop take effect the instant it is asked for, while a five-second check of +// the same service is still running. +type Supervisor struct { + dir string + rt Runtime + clock Clock + lock *os.File + base context.Context + stop context.CancelFunc + // write is the file's one writer, a seam a test replaces to fail it. + write func(dir string, raw []byte) error + + mu sync.Mutex + closed bool + seq int + entries map[string]*entry + consent map[string]consent + timer Timer + timerAt time.Time + free int + workers int + wg sync.WaitGroup + dirty uint64 + + listeners map[int]func(Event) + // fences are the owners being closed: nothing new starts for one while + // its close waits ([Supervisor.Fence]). + fences map[string]int + nextListener int + pending map[string]Event + pendingOrder []string + kick chan struct{} + quit chan struct{} + + saveMu sync.Mutex + saved uint64 + wrote bool + // released is set once Close let go of the store; nothing writes after. + released bool +} + +type entry struct { + rec record + // gen moves on every start, stop and restart. Work carries the gen it + // began on and is dropped when it no longer matches. + gen uint64 + // busy is a start in flight; probing is a check in flight. + busy bool + probing bool + // stopDone is a stop's grace in flight, closed when it is over. A second + // stop during the grace is answered by the same one. + stopDone chan struct{} + // before is the state a person's restart found, put back if the process + // it had to end would not stop. + before State + // abandoned is a gate let go after its identity could not be written: + // still ours until its exit is seen, and not a failure of the service. + abandoned bool + // gone is closed when a process this codeaf started exits. It is nil for a + // re-adopted process, whose exit nobody reports. + gone chan struct{} + nextCheck time.Time +} + +func (e *entry) stopping() bool { return e.stopDone != nil } + +// exitedOwn says a process this codeaf started has already exited, its report +// on the way. +func (e *entry) exitedOwn() bool { + if e.gone == nil { + return false + } + select { + case <-e.gone: + return true + default: + return false + } +} + +// Open takes the store at opts.Dir, takes back what it can prove still runs, +// and starts the one timer. It answers [ErrBusy] when another codeaf holds the +// store, and a plain error when the file cannot be read — never an empty store +// in its place. +func Open(opts Options) (*Supervisor, error) { + if opts.Runtime == nil { + return nil, errors.New("kept-running processes are not supported on this platform") + } + if strings.TrimSpace(opts.Dir) == "" { + return nil, errors.New("no folder for kept-running processes") + } + clock := opts.Clock + if clock == nil { + clock = RealClock() + } + workers := opts.Workers + if workers <= 0 { + workers = defaultWorkers + } + lock, err := lockStore(opts.Dir) + if err != nil { + return nil, err + } + image, err := readStore(opts.Dir) + if err != nil { + _ = filelock.Unlock(lock) + _ = lock.Close() + return nil, err + } + base, cancel := context.WithCancel(context.Background()) + s := &Supervisor{ + dir: opts.Dir, rt: opts.Runtime, clock: clock, lock: lock, base: base, stop: cancel, write: writeStore, + seq: image.Seq, entries: map[string]*entry{}, consent: image.Consent, free: workers, workers: workers, + listeners: map[int]func(Event){}, pending: map[string]Event{}, fences: map[string]int{}, + kick: make(chan struct{}, 1), quit: make(chan struct{}), + } + if s.consent == nil { + s.consent = map[string]consent{} + } + go s.notifyLoop() + // A log that grew while no codeaf was open to cut it is cut back first. + for i := range image.Services { + if truncated, _ := rotateLog(s.logPath(image.Services[i].ID)); truncated { + image.Services[i].Truncated = true + } + } + s.mu.Lock() + // A REBOOT IS PROVEN, NEVER INFERRED. Both boots have to be known and + // differ; a boot that cannot be read proves neither that the machine + // restarted nor that it did not, and starting a saved service on that + // guess could run a second copy beside the first. + current := s.rt.Boot() + rebooted := image.Boot != "" && current != "" && image.Boot != current + for _, rec := range image.Services { + e := &entry{rec: rec} + s.entries[rec.ID] = e + s.adoptLocked(e, rebooted) + } + s.pumpLocked() + s.mu.Unlock() + if err := s.save(); err != nil { + // Nothing was started that is not on disk: an adoption changes only + // words, and a reboot's starts are written down after they launch, by + // the start itself, which rolls back what it cannot record. + s.Close() + return nil, err + } + return s, nil +} + +// adoptLocked decides, once, what a record read from the file is now. +// +// ONLY A PROVEN PROCESS IS TAKEN BACK. The pid, its start identity and its +// boot must all match a live group (internal/processgroup); anything less is a +// process this codeaf cannot tell apart from a stranger that was handed the +// same number, and it is left alone and said to be not running. After a proven +// reboot nothing can still be running, and a service whose policy restarts it +// is started again in its folder; one whose policy is never is not. +func (s *Supervisor) adoptLocked(e *entry, rebooted bool) { + now := s.clock.Now() + identity := e.rec.Identity + ended := e.rec.State == StateStopped || e.rec.State == StateFailed || e.rec.State == StateNotRunning || + (e.rec.State == StateGaveUp && identity == nil) + if ended { + if identity != nil && s.rt.Alive(*identity) { + if e.rec.State == StateStopped { + // A stop that was in its grace when codeaf went, or one that + // could not end it: finish it. + s.stopLocked(e, e.rec.StoppedBy) + return + } + // A PROCESS THAT IS STILL OURS STAYS OURS, whatever state the file + // left it in — a launch whose write failed, a restart that could + // not stop it. It is taken back and shown running; nothing is + // restarted on its account. + e.rec.StartedHow, e.rec.ReAdoptedAt = ReAdopted, now + e.nextCheck = now.Add(CheckEvery) + s.changedLocked(e, "re_adopted") + return + } + e.rec.Identity = nil + return + } + if identity != nil && s.rt.Alive(*identity) { + e.rec.StartedHow, e.rec.ReAdoptedAt = ReAdopted, now + e.nextCheck = now.Add(CheckEvery) + if e.rec.State == StateRestarting && e.rec.BackoffUntil.IsZero() { + e.rec.BackoffUntil = now + } + s.changedLocked(e, "re_adopted") + return + } + e.rec.Identity = nil + e.rec.BackoffUntil = time.Time{} + if rebooted && e.rec.Policy != PolicyNever { + e.gen++ + e.busy = true + e.rec.State, e.rec.Reason = StateStarting, "" + s.changedLocked(e, "state") + gen := e.gen + s.goWork(func() { _ = s.startWork(e.rec.ID, gen, StartedAfterRestart, false) }) + return + } + s.openEpisodeLocked(e, "not running after restart") + e.rec.State, e.rec.Reason = StateNotRunning, "not running after restart" + e.rec.EndedAt = now + s.changedLocked(e, "state") +} + +// Close lets the supervisor go and leaves every service running: a quit is +// not a stop. It waits for work already under way — at most one check's +// timeout or one stop's grace — so a stop a person asked for still ends with +// SIGKILL if it needs one. +// +// IT NEVER WAITS FOR A LISTENER. Listeners are called on the supervisor's own +// goroutine, and one that blocks, or that calls Close from inside its own +// callback, must not hold the store's lock or a quit hostage: the goroutine is +// told to go and finishes on its own after the listener returns. +func (s *Supervisor) Close() { + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return + } + s.closed = true + if s.timer != nil { + s.timer.Stop() + s.timer = nil + } + s.stop() + s.mu.Unlock() + s.wg.Wait() + _ = s.save() + s.saveMu.Lock() + s.released = true + s.saveMu.Unlock() + close(s.quit) + _ = filelock.Unlock(s.lock) + _ = s.lock.Close() +} + +// Subscribe hears every change, on one goroutine of the supervisor's own, in +// order per service. Changes to one service that arrive faster than a listener +// takes them are folded into the newest row, which still carries the episode +// they belonged to ([Row.Episode]). A listener must return promptly: it is not +// joined by Close, and while it blocks every other listener waits. The returned +// func stops it. +func (s *Supervisor) Subscribe(listen func(Event)) func() { + s.mu.Lock() + defer s.mu.Unlock() + s.nextListener++ + id := s.nextListener + s.listeners[id] = listen + return func() { + s.mu.Lock() + delete(s.listeners, id) + s.mu.Unlock() + } +} + +// Approved reports the policy a person already chose for this exact command +// in this exact folder with this exact check. A proposal that matches is not +// asked about again and keeps the person's policy, whatever it proposed; one +// that changes the check is asked about again. +func (s *Supervisor) Approved(spec Spec) (Policy, bool) { + spec, err := spec.Check() + if err != nil { + return "", false + } + s.mu.Lock() + defer s.mu.Unlock() + c, ok := s.consent[spec.ConsentKey()] + if !ok || c.Health != spec.Health { + return "", false + } + return c.Policy, true +} + +// Keep is a person's yes, carried out: it banks the yes for this exact spec, +// starts the spec as a kept-running service for owner, and answers its row +// once the process is launched — or once it is proven it cannot be. When the +// same spec was already approved, the person's policy stands whatever policy +// says. +// +// NOTHING RUNS THAT THE FILE DOES NOT KNOW ABOUT. The yes and the record are +// written before the process starts, and the process's identity right after; +// a write that fails before the start undoes the yes and starts nothing, and +// one that fails after it ends the process it could not record. A kept process +// no later codeaf could find is exactly the orphan this package exists to +// prevent. +// +// The same spec already running for the same owner is answered as it is, so +// asking twice never starts two. +func (s *Supervisor) Keep(owner string, spec Spec, policy Policy, from Provenance) (Row, error) { + spec, err := spec.Check() + if err != nil { + return Row{}, err + } + if owner == "" || len(owner) > maxOwner || strings.TrimSpace(owner) != owner { + return Row{}, errors.New("a kept-running process needs an owner") + } + if !policy.Valid() { + return Row{}, fmt.Errorf("restart policy is never, on_failure or always, not %q", policy) + } + from.Label = oneLine(from.Label, maxProvenance) + from.CallID = oneLine(from.CallID, maxProvenance) + now := s.clock.Now() + from.At = now + + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return Row{}, ErrClosed + } + if s.fences[owner] > 0 { + s.mu.Unlock() + return Row{}, ErrClosing + } + key := spec.ConsentKey() + prior, hadPrior := s.consent[key] + if hadPrior && prior.Health == spec.Health { + policy = prior.Policy + } + var e *entry + for _, candidate := range s.entries { + if candidate.rec.Owner == owner && candidate.rec.Spec.ConsentKey() == key { + e = candidate + break + } + } + if e != nil && (e.rec.Identity != nil || e.busy || e.stopping()) && e.rec.Spec.Health == spec.Health { + row := s.rowLocked(e) + s.mu.Unlock() + return row, nil + } + if e != nil && (e.rec.Identity != nil || e.busy || e.stopping()) { + s.mu.Unlock() + return Row{}, ErrRunning + } + created := e == nil + var before record + if created { + owned := 0 + for _, other := range s.entries { + if other.rec.Owner == owner { + owned++ + } + } + if owned >= maxPerOwner || len(s.entries) >= maxServices { + s.mu.Unlock() + return Row{}, ErrFull + } + s.seq++ + id := "s" + strconv.Itoa(s.seq) + e = &entry{rec: record{ID: id, Owner: owner, Created: now}} + s.entries[id] = e + } else { + before = e.rec + } + s.consent[key] = consent{Policy: policy, Health: spec.Health, At: now} + s.trimConsentLocked() + e.rec.Spec, e.rec.Policy, e.rec.From = spec, policy, from + e.rec.StoppedBy = "" + e.gen++ + gen := e.gen + e.busy = true + e.before = "" + e.rec.State, e.rec.Reason = StateStarting, "" + s.changedLocked(e, "state") + s.wg.Add(1) + s.mu.Unlock() + defer s.wg.Done() + + if err := s.save(); err != nil { + s.mu.Lock() + if hadPrior { + s.consent[key] = prior + } else { + delete(s.consent, key) + } + e.busy = false + e.gen++ + if created { + delete(s.entries, e.rec.ID) + s.dirty++ + s.enqueueLocked(Event{Row: s.rowLocked(e), Change: "removed", Removed: true}) + } else { + e.rec = before + s.changedLocked(e, "state") + } + s.mu.Unlock() + return Row{}, err + } + if err := s.startWork(e.rec.ID, gen, StartedByAgent, false); err != nil { + row, _ := s.Get(owner, e.rec.ID) + return row, err + } + return s.Get(owner, e.rec.ID) +} + +// List is owner's services, oldest first. One agent never sees another's. +func (s *Supervisor) List(owner string) []Row { + s.mu.Lock() + defer s.mu.Unlock() + rows := make([]Row, 0, len(s.entries)) + for _, e := range s.entries { + if e.rec.Owner == owner { + rows = append(rows, s.rowLocked(e)) + } + } + sort.Slice(rows, func(i, j int) bool { return idLess(rows[i].ID, rows[j].ID) }) + return rows +} + +// All is every owner's services, oldest first: the host's view, for a quit +// that counts what stays up. A conversation never reads it ([List] is its). +func (s *Supervisor) All() []Row { + s.mu.Lock() + defer s.mu.Unlock() + rows := make([]Row, 0, len(s.entries)) + for _, e := range s.entries { + rows = append(rows, s.rowLocked(e)) + } + sort.Slice(rows, func(i, j int) bool { return idLess(rows[i].ID, rows[j].ID) }) + return rows +} + +// Running counts the services every owner has with a process up or starting, +// which is what a quit says will stay up. +func (s *Supervisor) Running() int { + s.mu.Lock() + defer s.mu.Unlock() + n := 0 + for _, e := range s.entries { + if e.rec.Identity != nil || e.busy { + n++ + } + } + return n +} + +// Get is one of owner's services. +func (s *Supervisor) Get(owner, id string) (Row, error) { + s.mu.Lock() + defer s.mu.Unlock() + e, err := s.findLocked(owner, id) + if err != nil { + return Row{}, err + } + return s.rowLocked(e), nil +} + +// LogPath is where one of owner's services writes. +func (s *Supervisor) LogPath(owner, id string) (string, error) { + s.mu.Lock() + defer s.mu.Unlock() + if _, err := s.findLocked(owner, id); err != nil { + return "", err + } + return s.logPath(id), nil +} + +// SetPolicy is the person changing what happens at the next failure. It +// changes nothing about the process now, and the banked yes follows it, so the +// agent asking again later gets the person's newest word. A change the file +// could not keep is undone and answered with [ErrNotSaved]. +func (s *Supervisor) SetPolicy(owner, id string, policy Policy) (Row, error) { + if !policy.Valid() { + return Row{}, fmt.Errorf("restart policy is never, on_failure or always, not %q", policy) + } + s.mu.Lock() + e, err := s.findLocked(owner, id) + if err == nil && s.closed { + err = ErrClosed + } + if err != nil { + s.mu.Unlock() + return Row{}, err + } + if e.rec.Policy == policy { + row := s.rowLocked(e) + s.mu.Unlock() + return row, nil + } + was := e.rec.Policy + key := e.rec.Spec.ConsentKey() + prior, hadPrior := s.consent[key] + e.rec.Policy = policy + if hadPrior && prior.Health == e.rec.Spec.Health { + s.consent[key] = consent{Policy: policy, Health: prior.Health, At: s.clock.Now()} + } + s.changedLocked(e, "policy") + s.mu.Unlock() + if err := s.save(); err != nil { + s.mu.Lock() + if s.entries[id] == e && e.rec.Policy == policy { + e.rec.Policy = was + if hadPrior { + s.consent[key] = prior + } + s.changedLocked(e, "policy") + } + row := s.rowLocked(e) + s.mu.Unlock() + return row, err + } + return s.Get(owner, id) +} + +// Restart is the person's restart: the process ends and starts again now, with +// no backoff. It is theirs, so it is outside the automatic budget — it neither +// spends a restart nor refills one, and the automatic history it found is +// kept as it was. +func (s *Supervisor) Restart(owner, id string) (Row, error) { + return s.personStart(owner, id, true) +} + +// Start is "start again" for a service with no process: its saved command, in +// its saved folder, with its saved check and policy, asking nothing. A service +// already running or already starting is answered as it is. +func (s *Supervisor) Start(owner, id string) (Row, error) { + return s.personStart(owner, id, false) +} + +func (s *Supervisor) personStart(owner, id string, restart bool) (Row, error) { + s.mu.Lock() + e, err := s.findLocked(owner, id) + if err == nil && s.closed { + err = ErrClosed + } + if err == nil && s.fences[owner] > 0 { + err = ErrClosing + } + if err != nil { + s.mu.Unlock() + return Row{}, err + } + if e.stopping() { + s.mu.Unlock() + return Row{}, ErrStopping + } + if e.busy || (!restart && e.rec.Identity != nil) { + row := s.rowLocked(e) + s.mu.Unlock() + return row, nil + } + e.gen++ + gen := e.gen + e.busy = true + e.before = e.rec.State + e.rec.StoppedBy, e.rec.BackoffUntil, e.rec.Reason = "", time.Time{}, "" + if restart && e.rec.Identity != nil { + e.rec.State = StateRestarting + } else { + e.rec.State = StateStarting + } + s.changedLocked(e, "state") + row := s.rowLocked(e) + s.goWork(func() { _ = s.startWork(id, gen, StartedByPerson, false) }) + s.mu.Unlock() + return row, nil +} + +// Stop ends a service and keeps its output: SIGTERM to its group, SIGKILL +// after StopGrace. Nothing restarts it — the state is stopped the instant this +// returns, and the grace runs in the background. Stopping a stopped service, +// or one already in its grace, is answered as it is. +// +// A stop is always carried out. When the file cannot say so, the error says +// that instead, and a later codeaf reading the older file finds the process +// gone and says it is not running. +func (s *Supervisor) Stop(owner, id string) (Row, error) { + s.mu.Lock() + e, err := s.findLocked(owner, id) + if err == nil && s.closed { + err = ErrClosed + } + if err != nil { + s.mu.Unlock() + return Row{}, err + } + s.stopLocked(e, StartedByPerson) + row := s.rowLocked(e) + s.mu.Unlock() + return row, s.save() +} + +// StopOwner stops every service owner has, the way closing an agent does. The +// graces run in the background; it reports how many it stopped. +func (s *Supervisor) StopOwner(owner, by string) (int, error) { + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return 0, ErrClosed + } + n := 0 + for _, e := range s.entries { + if e.rec.Owner != owner { + continue + } + if e.rec.Identity != nil || e.busy { + n++ + } + s.stopLocked(e, by) + } + s.mu.Unlock() + return n, s.save() +} + +// Fence keeps anything new from starting for owner — no Keep, no Start, no +// Restart — until release is called, so a close that waits for owner's +// processes to go cannot be overtaken by one started behind it. Fences nest; +// release is safe to call more than once. +func (s *Supervisor) Fence(owner string) (release func()) { + s.mu.Lock() + s.fences[owner]++ + s.mu.Unlock() + var once sync.Once + return func() { + once.Do(func() { + s.mu.Lock() + if s.fences[owner]--; s.fences[owner] <= 0 { + delete(s.fences, owner) + } + s.mu.Unlock() + }) + } +} + +// StopOwnerWait stops every service owner has and waits, until ctx ends, for +// each group to be PROVEN gone — the way an explicit close of the agent that +// owns them does, so that close never hides an agent whose process is still +// up. It touches no other owner's services. It reports how many it stopped, +// and [ErrNotStopped] naming any it could not prove gone; those stay listed as +// running, still this codeaf's to stop again. +func (s *Supervisor) StopOwnerWait(ctx context.Context, owner, by string) (int, error) { + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return 0, ErrClosed + } + n := 0 + var waits []chan struct{} + for _, e := range s.entries { + if e.rec.Owner != owner { + continue + } + if e.rec.Identity != nil || e.busy { + n++ + } + if done := s.stopLocked(e, by); done != nil { + waits = append(waits, done) + } + } + s.mu.Unlock() + saveErr := s.save() + if err := waitAll(ctx, waits); err != nil { + return n, err + } + return n, errors.Join(saveErr, s.stillRunning(func(e *entry) bool { return e.rec.Owner == owner })) +} + +// waitAll waits for every stop's grace to be over, or for ctx. +func waitAll(ctx context.Context, waits []chan struct{}) error { + for _, done := range waits { + select { + case <-done: + case <-ctx.Done(): + return fmt.Errorf("%w: still stopping when the wait ran out", ErrNotStopped) + } + } + return nil +} + +// stillRunning is [ErrNotStopped] naming every service matched that still owns +// a process, or nil. +func (s *Supervisor) stillRunning(match func(*entry) bool) error { + s.mu.Lock() + var left []string + for id, e := range s.entries { + if match(e) && e.rec.Identity != nil { + left = append(left, id) + } + } + s.mu.Unlock() + if len(left) == 0 { + return nil + } + sort.Slice(left, func(i, j int) bool { return idLess(left[i], left[j]) }) + return fmt.Errorf("%w: %s", ErrNotStopped, strings.Join(left, ", ")) +} + +// StopAll stops every service of every owner — a quit that was asked to stop +// them — and waits until each group is gone or ctx ends. +func (s *Supervisor) StopAll(ctx context.Context, by string) error { + s.mu.Lock() + if s.closed { + s.mu.Unlock() + return ErrClosed + } + var waits []chan struct{} + for _, e := range s.entries { + if done := s.stopLocked(e, by); done != nil { + waits = append(waits, done) + } + } + s.mu.Unlock() + saveErr := s.save() + if err := waitAll(ctx, waits); err != nil { + return err + } + // A STOP THAT COULD NOT PROVE ITS PROCESS GONE IS AN ERROR HERE, because + // the caller is a quit deciding whether it may say everything stopped. + return errors.Join(saveErr, s.stillRunning(func(*entry) bool { return true })) +} + +// Remove drops a service with no process from the list, and keeps its output: +// the log stays where it is and an archive descriptor names whose it is, so a +// notice that links to the output still opens it, after a restart of codeaf +// too ([Supervisor.Output]). It signals nothing, ever — a service still running +// is refused — and removing one that is already gone is not an error. If +// either write fails, nothing is removed. +func (s *Supervisor) Remove(owner, id string) error { + s.mu.Lock() + e, err := s.findLocked(owner, id) + if errors.Is(err, ErrNotFound) { + s.mu.Unlock() + return nil + } + if s.closed { + s.mu.Unlock() + return ErrClosed + } + if e.rec.Identity != nil || e.busy { + s.mu.Unlock() + return ErrRunning + } + if e.stopping() { + s.mu.Unlock() + return ErrStopping + } + archived := archivedOf(e.rec, s.clock.Now()) + s.mu.Unlock() + + if err := s.writeArchive(archived); err != nil { + return fmt.Errorf("%w: %v", ErrNotSaved, err) + } + s.mu.Lock() + if s.entries[id] != e || e.rec.Identity != nil || e.busy || e.stopping() || s.closed { + // It started again while the descriptor was written; the record + // stays, and so does the harmless descriptor its live entry outranks. + s.mu.Unlock() + return ErrRunning + } + e.gen++ + row := s.rowLocked(e) + delete(s.entries, id) + s.dirty++ + s.mu.Unlock() + if err := s.save(); err != nil { + s.mu.Lock() + if _, taken := s.entries[id]; !taken { + s.entries[id] = e + s.dirty++ + } + s.mu.Unlock() + return err + } + s.mu.Lock() + s.enqueueLocked(Event{Row: row, Change: "removed", Removed: true}) + s.mu.Unlock() + return nil +} + +// ── the lifecycle ─────────────────────────────────────────────────────────── + +// stopLocked marks e stopped and, when it has a process, starts the grace. It +// returns the channel the grace closes, or nil when there was nothing to end. +// +// A STOP DURING A STOP IS THE SAME STOP. The second is answered with the +// first's grace, and the grace writes down the end of the very process it +// signalled, matched by identity — so nothing a second stop does can leave the +// row claiming a dead process, or forget a live one. +func (s *Supervisor) stopLocked(e *entry, by string) chan struct{} { + if e.stopping() { + return e.stopDone + } + hadProcess := e.rec.Identity != nil + if e.rec.State == StateStopped && !hadProcess && !e.busy { + return nil + } + e.gen++ + e.busy = false + e.rec.State, e.rec.StoppedBy, e.rec.Reason = StateStopped, oneLine(by, maxName), "" + e.rec.BackoffUntil = time.Time{} + s.closeEpisodeLocked(e) + if !hadProcess { + // A start in flight finds its generation moved and lets its gate go + // without committing ([Supervisor.startWork]). + e.rec.EndedAt = s.clock.Now() + s.changedLocked(e, "stopped") + return nil + } + identity, gone := *e.rec.Identity, e.gone + done := make(chan struct{}) + e.stopDone = done + s.changedLocked(e, "stopped") + s.goWork(func() { + err := s.terminate(identity, gone) + s.mu.Lock() + e.stopDone = nil + if e.rec.Identity != nil && *e.rec.Identity == identity { + if err != nil { + // STILL OURS, AND SAID SO. The row keeps the process it could + // not end, so the person sees it running and can stop it again. + e.rec.Reason = oneLine("did not stop: "+err.Error(), maxReason) + } else { + e.rec.Identity, e.gone = nil, nil + e.rec.EndedAt = s.clock.Now() + } + } + // THE GRACE'S END IS ALWAYS SAID, even when the process's own exit + // already cleared it: a listener's last row must not say stopping. + if s.entries[e.rec.ID] == e { + s.changedLocked(e, "stopped") + } + s.pumpLocked() + s.mu.Unlock() + _ = s.save() + close(done) + }) + return done +} + +// killConfirm is how long SIGKILL has to show it worked. A kill that was sent +// is the kernel's to carry out, so this is a bound on noticing, not a grace. +const killConfirm = 2 * time.Second + +// terminate ends one group — SIGTERM, then SIGKILL once StopGrace has passed — +// and answers nil only once the process is PROVEN gone. A process this codeaf +// started reports its own exit; one it re-adopted is looked at every stopPoll. +// Anything else is an error, and the caller still owns the process. +func (s *Supervisor) terminate(identity processgroup.Identity, gone chan struct{}) error { + if !s.aliveNow(identity, gone) { + return nil + } + termErr := s.rt.Signal(identity, false) + if s.waitGone(identity, gone, StopGrace) { + return nil + } + killErr := s.rt.Signal(identity, true) + if killErr == nil && s.waitGone(identity, gone, killConfirm) { + return nil + } + if !s.aliveNow(identity, gone) { + return nil + } + switch { + case killErr != nil: + return fmt.Errorf("SIGKILL could not be sent: %v", killErr) + case termErr != nil: + return fmt.Errorf("SIGTERM could not be sent (%v) and SIGKILL did not end it", termErr) + } + return errors.New("it outlived SIGTERM and SIGKILL") +} + +// aliveNow is one look: a process this codeaf started is alive until its exit +// is reported; any other is asked about through its identity. +func (s *Supervisor) aliveNow(identity processgroup.Identity, gone chan struct{}) bool { + if gone != nil { + select { + case <-gone: + return false + default: + } + } + return s.rt.Alive(identity) +} + +// waitGone waits up to within for the process to go, and reports whether it +// did. +func (s *Supervisor) waitGone(identity processgroup.Identity, gone chan struct{}, within time.Duration) bool { + if gone != nil { + grace := make(chan struct{}) + timer := s.clock.AfterFunc(within, func() { close(grace) }) + select { + case <-gone: + timer.Stop() + return true + case <-grace: + return !s.aliveNow(identity, gone) + } + } + deadline := s.clock.Now().Add(within) + for s.rt.Alive(identity) { + if !s.clock.Now().Before(deadline) { + return false + } + s.sleep(stopPoll) + } + return true +} + +func (s *Supervisor) sleep(d time.Duration) { + woke := make(chan struct{}) + s.clock.AfterFunc(d, func() { close(woke) }) + <-woke +} + +// startWork launches e's process for generation gen, after ending the process +// it replaces, and writes the result down. It runs with the lock released, and +// its error is one the caller can act on. +// +// THE ORDER IS THE SAFETY. The process being replaced must be PROVEN gone +// before anything else: one that would not stop is still ours, stays on the +// row, and nothing is launched beside it — a second copy is not a restart, and +// its port answering is not "another program". Then the new group is made and +// held at its gate, its identity is written down, and only then is the +// approved command let run. A write that fails lets the gate go, so the command +// never ran, and the row keeps the gate's identity until its exit is seen. +func (s *Supervisor) startWork(id string, gen uint64, how string, auto bool) error { + s.mu.Lock() + e := s.entries[id] + if e == nil || e.gen != gen { + s.mu.Unlock() + return nil + } + spec, old, oldGone, before := e.rec.Spec, e.rec.Identity, e.gone, e.before + logPath := s.logPath(id) + s.mu.Unlock() + + if old != nil { + if err := s.terminate(*old, oldGone); err != nil { + s.mu.Lock() + if e = s.entries[id]; e != nil && e.gen == gen { + e.busy = false + reason := oneLine("could not stop it to restart: "+err.Error(), maxReason) + s.openEpisodeLocked(e, reason) + if auto || before == "" { + // AN AUTOMATIC RESTART THAT CANNOT STOP ITS PROCESS RESTS. Trying + // again on every backoff would be signals in a loop forever. + before = StateGaveUp + } + e.rec.State, e.rec.Reason, e.rec.BackoffUntil = before, reason, time.Time{} + s.changedLocked(e, "state") + s.pumpLocked() + } + s.mu.Unlock() + return errors.Join(err, s.save()) + } + s.mu.Lock() + if e.rec.Identity != nil && *e.rec.Identity == *old { + e.rec.Identity, e.gone = nil, nil + } + s.mu.Unlock() + } + // A PORT ALREADY ANSWERING BEFORE THE SERVICE STARTS IS PROVEN TO BE + // SOMEBODY ELSE'S: the service's own process is proven gone above, and + // the new one has not run. It is said rather than started over, because + // the check would pass against the other program and call this service + // healthy while it never ran. + conflict := spec.Health.Kind == HealthPort && s.rt.PortInUse(spec.Health.Port) + var launch Launch + var launchErr error + truncated := false + gone := make(chan struct{}) + if !conflict { + truncated, _ = rotateLog(logPath) + launch, launchErr = s.rt.Launch(spec, logPath, func(identity processgroup.Identity, exit Exit) { + close(gone) + s.exited(id, identity, exit) + }) + } + + s.mu.Lock() + e = s.entries[id] + if e == nil || e.gen != gen { + // Stopped, restarted or removed while this was starting: the gate is + // let go and the command never runs. + s.mu.Unlock() + if launch != nil { + launch.Abort() + } + return nil + } + now := s.clock.Now() + e.rec.Truncated = e.rec.Truncated || truncated + e.rec.ExitCode = nil + e.rec.PortConflict = false + if conflict || launchErr != nil { + reason := "port " + strconv.Itoa(spec.Health.Port) + " is used by another program" + if !conflict { + reason = oneLine(launchErr.Error(), maxReason) + } + e.busy = false + s.openEpisodeLocked(e, reason) + e.rec.State, e.rec.PortConflict, e.rec.Reason = StateFailed, conflict, reason + e.rec.EndedAt = now + s.changedLocked(e, "state") + s.pumpLocked() + s.mu.Unlock() + return s.save() + } + identity := launch.Identity() + e.rec.Identity, e.gone = &identity, gone + e.rec.StartedAt, e.rec.StartedHow, e.rec.EndedAt = now, how, time.Time{} + e.rec.State, e.rec.Reason = StateStarting, "" + s.changedLocked(e, "state") + s.mu.Unlock() + + if err := s.save(); err != nil { + launch.Abort() + s.mu.Lock() + if s.entries[id] == e && e.gen == gen { + e.busy = false + // The gate's identity stays on the row until its exit is seen, so + // the process is never out of reach while it might still exist. + e.abandoned = e.rec.Identity != nil && *e.rec.Identity == identity + e.rec.State, e.rec.Reason = StateFailed, oneLine(err.Error(), maxReason) + s.openEpisodeLocked(e, e.rec.Reason) + s.changedLocked(e, "state") + } + s.mu.Unlock() + return err + } + + s.mu.Lock() + defer func() { + s.pumpLocked() + s.mu.Unlock() + _ = s.save() + }() + if s.entries[id] != e || e.gen != gen { + // A stop took the gate while it was being written down; it ends it. + launch.Abort() + return nil + } + e.busy = false + if e.rec.Identity == nil || *e.rec.Identity != identity { + // The gate ended on its own before it was let run. + launch.Abort() + e.rec.State, e.rec.Reason = StateFailed, "the process ended before it could run" + s.openEpisodeLocked(e, e.rec.Reason) + s.changedLocked(e, "state") + return errors.New(e.rec.Reason) + } + // The commit is a few bytes into an empty pipe and never blocks, so it is + // made under the lock: nothing can stop the service between the last look + // and the command being let run. + if err := launch.Commit(); err != nil { + e.abandoned = true + e.rec.State, e.rec.Reason = StateFailed, "the process ended before it could run" + s.openEpisodeLocked(e, e.rec.Reason) + if !s.aliveNow(identity, gone) { + e.rec.Identity, e.gone = nil, nil + e.abandoned = false + } + s.changedLocked(e, "state") + return err + } + e.rec.BackoffUntil, e.rec.HealthySince = time.Time{}, time.Time{} + if e.rec.Check != nil { + e.rec.Check.Fails = 0 + } + if auto { + e.rec.Restarts = append(restartsWithin(e.rec.Restarts, now), now) + } + e.nextCheck = now.Add(CheckEvery) + s.changedLocked(e, "started") + return nil +} + +// exited is a started process's exit report, from its reaper, matched to the +// record by the identity it ended with — never by a generation, which a +// failed replacement may have moved past while this process was still ours. +func (s *Supervisor) exited(id string, identity processgroup.Identity, exit Exit) { + s.mu.Lock() + e := s.entries[id] + if s.closed || e == nil || e.rec.Identity == nil || *e.rec.Identity != identity { + s.mu.Unlock() + return + } + if e.busy || e.stopping() { + // The process being replaced, or the one a stop is ending: whoever is + // doing that writes down what comes next. + e.rec.Identity, e.gone = nil, nil + e.rec.EndedAt = s.clock.Now() + s.mu.Unlock() + return + } + s.exitLocked(e, exit) + s.pumpLocked() + s.mu.Unlock() + _ = s.save() +} + +// exitLocked settles a process that ended on its own. +func (s *Supervisor) exitLocked(e *entry, exit Exit) { + now := s.clock.Now() + e.rec.Identity, e.gone = nil, nil + e.rec.EndedAt = now + e.rec.ExitCode = nil + if e.abandoned || e.rec.State == StateStopped { + // A gate let go after a failed write, or a stopped service's process + // whose report beat its stop: it is gone, and that is all. + e.abandoned = false + s.changedLocked(e, "state") + return + } + words := "exited" + if exit.Known { + code := exit.Code + e.rec.ExitCode = &code + words = "exited " + strconv.Itoa(code) + } + if exit.Known && exit.Code == 0 && e.rec.Policy != PolicyAlways { + e.rec.State, e.rec.Reason = StateNotRunning, words + e.rec.HealthySince = time.Time{} + s.changedLocked(e, "state") + return + } + s.failLocked(e, words, true) +} + +// failLocked is one failure — an exit, or UnhealthyAfter failed checks in a +// row — meeting the policy the person chose, as it is NOW. +func (s *Supervisor) failLocked(e *entry, reason string, exited bool) { + now := s.clock.Now() + s.openEpisodeLocked(e, reason) + e.rec.HealthySince = time.Time{} + if e.rec.Policy == PolicyNever { + if exited { + e.rec.State, e.rec.Reason = StateFailed, reason+" · not restarted (policy: never)" + } else { + e.rec.State, e.rec.Reason = StateUnhealthy, reason + } + s.changedLocked(e, "state") + return + } + recent := restartsWithin(e.rec.Restarts, now) + e.rec.Restarts = recent + if len(recent) >= RestartLimit { + e.rec.State = StateGaveUp + e.rec.Reason = fmt.Sprintf("stopped restarting after %d failures in 10 minutes", RestartLimit) + s.changedLocked(e, "state") + return + } + e.rec.State, e.rec.Reason = StateRestarting, reason + e.rec.BackoffUntil = now.Add(FirstBackoff << len(recent)) + s.changedLocked(e, "state") +} + +// openEpisodeLocked starts a new run of trouble unless one is already open, so +// unhealthy, restarting and gave up are one episode — one Inbox line that +// changes its words — and not three. What first went wrong is kept with it. +func (s *Supervisor) openEpisodeLocked(e *entry, reason string) { + if !e.rec.EpisodeOpen { + e.rec.Episode++ + e.rec.EpisodeOpen = true + e.rec.EpisodeAt = s.clock.Now() + e.rec.EpisodeEnded = time.Time{} + e.rec.EpisodeReason = oneLine(reason, maxReason) + } +} + +// closeEpisodeLocked ends the open episode, once: the moment it ended is +// written when it ends and never again, so a surface's "recovered at" does not +// move with every passing check after it. +func (s *Supervisor) closeEpisodeLocked(e *entry) { + if e.rec.EpisodeOpen { + e.rec.EpisodeOpen = false + e.rec.EpisodeEnded = s.clock.Now() + } +} + +// probeWork runs one check of e for generation gen. +func (s *Supervisor) probeWork(id string, gen uint64) { + s.mu.Lock() + e := s.entries[id] + if e == nil || e.gen != gen || e.rec.Identity == nil { + if e != nil { + e.probing = false + } + s.mu.Unlock() + return + } + spec, identity, own := e.rec.Spec, *e.rec.Identity, e.gone != nil + exitedOwn := e.exitedOwn() + began := s.clock.Now() + s.mu.Unlock() + + // A re-adopted process reports no exit, so its check is also where its end + // is noticed. A process this codeaf started reports its own, and one whose + // report is on its way is not checked as though it ran. + alive := !exitedOwn && (own || s.rt.Alive(identity)) + var err error + if alive { + err = s.rt.Probe(s.base, spec) + } + truncated, _ := rotateLog(s.logPath(id)) + + s.mu.Lock() + defer func() { + s.mu.Unlock() + _ = s.save() + }() + e.probing = false + if truncated { + e.rec.Truncated = true + } + if s.closed || s.entries[id] != e || e.gen != gen || e.rec.Identity == nil || e.stopping() || e.busy || exitedOwn { + return + } + if !alive { + s.exitLocked(e, Exit{}) + return + } + now := s.clock.Now() + e.nextCheck = began.Add(CheckEvery) + if e.nextCheck.Before(now) { + e.nextCheck = now + } + if err == nil { + e.rec.Check = &Check{OK: true, At: now} + if e.rec.State != StateHealthy { + e.rec.State, e.rec.Reason = StateHealthy, "" + e.rec.HealthySince = now + s.closeEpisodeLocked(e) + s.changedLocked(e, "state") + return + } + if !e.rec.HealthySince.IsZero() && now.Sub(e.rec.HealthySince) >= RestartWindow && len(e.rec.Restarts) > 0 { + e.rec.Restarts = nil + } + s.changedLocked(e, "check") + return + } + fails := 1 + if e.rec.Check != nil && !e.rec.Check.OK { + fails = e.rec.Check.Fails + 1 + } + detail := oneLine(err.Error(), maxProbeDetail) + e.rec.Check = &Check{OK: false, At: now, Fails: fails, Detail: detail} + failing := e.rec.State == StateStarting || e.rec.State == StateHealthy || + (e.rec.State == StateUnhealthy && e.rec.Policy != PolicyNever) + if fails >= UnhealthyAfter && failing { + s.failLocked(e, detail+" · "+strconv.Itoa(fails)+" checks", false) + return + } + s.changedLocked(e, "check") +} + +// ── the timer ─────────────────────────────────────────────────────────────── + +// pumpLocked hands every piece of due work a free worker and arms the one +// timer for the earliest piece that is not yet due. Work that found no free +// worker stays due and is handed out when a worker comes back. +func (s *Supervisor) pumpLocked() { + if s.closed { + return + } + now := s.clock.Now() + var next time.Time + soonest := func(at time.Time) { + if next.IsZero() || at.Before(next) { + next = at + } + } + ids := make([]string, 0, len(s.entries)) + for id := range s.entries { + ids = append(ids, id) + } + // Oldest overdue work goes first. Stable id ordering alone lets eight slow + // checks consume every worker repeatedly while newer services never run. + due := func(e *entry) time.Time { + if e.rec.State == StateRestarting { + return e.rec.BackoffUntil + } + return e.nextCheck + } + sort.Slice(ids, func(i, j int) bool { + a, b := due(s.entries[ids[i]]), due(s.entries[ids[j]]) + if !a.Equal(b) { + return a.Before(b) + } + return idLess(ids[i], ids[j]) + }) + for _, id := range ids { + e := s.entries[id] + if e.busy || e.stopping() { + continue + } + switch { + case e.rec.State == StateRestarting: + if now.Before(e.rec.BackoffUntil) { + soonest(e.rec.BackoffUntil) + continue + } + if s.free == 0 { + continue + } + s.free-- + e.gen++ + e.busy = true + e.before = "" + gen := e.gen + s.wg.Add(1) + go func() { + defer s.workerDone() + _ = s.startWork(id, gen, StartedByRestart, true) + }() + case e.rec.Identity != nil && !e.probing && e.rec.State != StateStopped && !e.abandoned: + if now.Before(e.nextCheck) { + soonest(e.nextCheck) + continue + } + if s.free == 0 { + continue + } + s.free-- + e.probing = true + gen := e.gen + s.wg.Add(1) + go func() { + defer s.workerDone() + s.probeWork(id, gen) + }() + } + } + if next.IsZero() { + if s.timer != nil { + s.timer.Stop() + s.timer = nil + } + return + } + if s.timer != nil && s.timerAt.Equal(next) { + return + } + if s.timer != nil { + s.timer.Stop() + } + s.timerAt = next + s.timer = s.clock.AfterFunc(next.Sub(now), s.tick) +} + +func (s *Supervisor) workerDone() { + s.mu.Lock() + s.free++ + s.pumpLocked() + s.mu.Unlock() + s.wg.Done() +} + +func (s *Supervisor) tick() { + s.mu.Lock() + s.timer = nil + s.pumpLocked() + s.mu.Unlock() +} + +// goWork runs one piece of a person's or an adoption's work outside the worker +// bound: there is at most one per service at a time, so they are bounded by +// the store's own bound, and a person's stop never queues behind checks. +// +// IT IS CALLED UNDER THE LOCK, and refuses once Close has begun: Close waits +// for the work it can see, and work added behind that wait would run against +// a store another codeaf may already hold. +func (s *Supervisor) goWork(work func()) { + if s.closed { + return + } + s.wg.Add(1) + go func() { + defer s.wg.Done() + work() + }() +} + +// ── rows, events, the file ────────────────────────────────────────────────── + +func (s *Supervisor) findLocked(owner, id string) (*entry, error) { + e := s.entries[id] + if e == nil || e.rec.Owner != owner || owner == "" { + return nil, ErrNotFound + } + return e, nil +} + +func (s *Supervisor) rowLocked(e *entry) Row { + return e.rec.row(s.clock.Now(), e.rec.Identity != nil, e.stopping(), s.logPath(e.rec.ID)) +} + +func (s *Supervisor) changedLocked(e *entry, change string) { + e.rec.Updated = s.clock.Now() + s.dirty++ + s.enqueueLocked(Event{Row: s.rowLocked(e), Change: change}) +} + +func (s *Supervisor) enqueueLocked(event Event) { + if len(s.listeners) == 0 { + return + } + id := event.Row.ID + if _, waiting := s.pending[id]; !waiting { + s.pendingOrder = append(s.pendingOrder, id) + } + s.pending[id] = event + select { + case s.kick <- struct{}{}: + default: + } +} + +func (s *Supervisor) notifyLoop() { + for { + select { + case <-s.kick: + case <-s.quit: + s.deliver() + return + } + s.deliver() + } +} + +func (s *Supervisor) deliver() { + s.mu.Lock() + order, pending := s.pendingOrder, s.pending + s.pendingOrder, s.pending = nil, map[string]Event{} + listeners := make([]func(Event), 0, len(s.listeners)) + ids := make([]int, 0, len(s.listeners)) + for id := range s.listeners { + ids = append(ids, id) + } + sort.Ints(ids) + for _, id := range ids { + listeners = append(listeners, s.listeners[id]) + } + s.mu.Unlock() + for _, id := range order { + for _, listen := range listeners { + func() { + defer func() { _ = recover() }() + listen(pending[id]) + }() + } + } +} + +func (s *Supervisor) trimConsentLocked() { + for len(s.consent) > maxConsent { + var oldest string + var at time.Time + for key, c := range s.consent { + if oldest == "" || c.At.Before(at) { + oldest, at = key, c.At + } + } + delete(s.consent, oldest) + } +} + +// save writes the newest state with the lock released, and says when it +// could not. Two saves racing write the newer of the two and skip the older; +// a save that finds the newest state already on disk writes nothing. +func (s *Supervisor) save() error { + s.mu.Lock() + gen := s.dirty + image := storeImage{Version: storeVersion, Boot: s.rt.Boot(), Seq: s.seq, Consent: s.consent} + for _, e := range s.entries { + image.Services = append(image.Services, e.rec) + } + sort.Slice(image.Services, func(i, j int) bool { return idLess(image.Services[i].ID, image.Services[j].ID) }) + raw, err := json.Marshal(image) + s.mu.Unlock() + if err != nil { + return fmt.Errorf("%w: %v", ErrNotSaved, err) + } + s.saveMu.Lock() + defer s.saveMu.Unlock() + if s.released { + return ErrClosed + } + if s.wrote && gen <= s.saved { + return nil + } + if err := s.write(s.dir, raw); err != nil { + return fmt.Errorf("%w: %v", ErrNotSaved, err) + } + s.saved, s.wrote = gen, true + return nil +} + +// idLess orders "s2" before "s10". +func idLess(a, b string) bool { + na, _ := strconv.Atoi(strings.TrimPrefix(a, "s")) + nb, _ := strconv.Atoi(strings.TrimPrefix(b, "s")) + if na != nb { + return na < nb + } + return a < b +} diff --git a/internal/service/supervisor_test.go b/internal/service/supervisor_test.go new file mode 100644 index 0000000000..7b69906302 --- /dev/null +++ b/internal/service/supervisor_test.go @@ -0,0 +1,623 @@ +package service + +import ( + "context" + "errors" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/processgroup" +) + +// TestThreeFailedChecksRestartAndOnePassRecovers walks the board's main story: +// the first check ten seconds after start, three failures in a row make it +// fail, the policy restarts it after 2s, and one passing check makes it +// healthy again — all one episode. +func TestThreeFailedChecksRestartAndOnePassRecovers(t *testing.T) { + h := newHarness(t) + row := h.keep("web", PolicyOnFailure) + if row.State != StateStarting || !row.Running || row.PID == 0 { + t.Fatalf("a kept service starts running: %+v", row) + } + first := row.PID + h.advance(9 * time.Second) + if h.get(row.ID).Check != nil { + t.Fatal("a check ran before ten seconds") + } + h.advance(time.Second) + if got := h.get(row.ID); got.State != StateHealthy || got.Check == nil || !got.Check.OK { + t.Fatalf("the first check at 10s should pass: %+v", got) + } + h.rt.fail("web", true) + h.advance(20 * time.Second) + if got := h.get(row.ID); got.State != StateHealthy || got.Check.Fails != 2 { + t.Fatalf("two failures are not yet a failure: %+v", got) + } + h.advance(10 * time.Second) + got := h.get(row.ID) + if got.State != StateRestarting || got.BackoffUntil == nil || got.Restarts != 0 || !got.EpisodeOpen { + t.Fatalf("three failures in a row restart it: %+v", got) + } + episode := got.Episode + if want := h.clock.Now().Add(FirstBackoff); !got.BackoffUntil.Equal(want) { + t.Fatalf("first backoff is 2s: %v, want %v", got.BackoffUntil, want) + } + h.rt.fail("web", false) + h.advance(2 * time.Second) + got = h.get(row.ID) + if got.State != StateStarting || got.PID == first || got.Restarts != 1 || got.StartedHow != StartedByRestart { + t.Fatalf("the restart starts a new process and counts 1 of 3: %+v", got) + } + if h.rt.proc(first).alive { + t.Fatal("the unhealthy process was not ended before the restart") + } + h.advance(10 * time.Second) + got = h.get(row.ID) + if got.State != StateHealthy || got.EpisodeOpen || got.Episode != episode { + t.Fatalf("one pass is healthy again and closes the same episode: %+v", got) + } +} + +// TestTheBudgetIsThreeRestartsInTenMinutes is 2s/4s/8s, then giving up, and the +// window sliding open again. +func TestTheBudgetIsThreeRestartsInTenMinutes(t *testing.T) { + h := newHarness(t) + row := h.keep("api", PolicyOnFailure) + for i, backoff := range []time.Duration{2 * time.Second, 4 * time.Second, 8 * time.Second} { + pid := h.get(row.ID).PID + h.rt.exit(t, h.s, pid, 1) + got := h.get(row.ID) + if got.State != StateRestarting || got.Reason != "exited 1" || got.Restarts != i { + t.Fatalf("crash %d: %+v", i+1, got) + } + h.advance(backoff - time.Millisecond) + if h.get(row.ID).State != StateRestarting { + t.Fatalf("crash %d restarted before its %s backoff", i+1, backoff) + } + h.advance(time.Millisecond) + if got := h.get(row.ID); got.State != StateStarting || got.Restarts != i+1 { + t.Fatalf("crash %d: not restarted after %s: %+v", i+1, backoff, got) + } + } + h.rt.exit(t, h.s, h.get(row.ID).PID, 1) + got := h.get(row.ID) + if got.State != StateGaveUp || got.Running || got.Reason != "stopped restarting after 3 failures in 10 minutes" { + t.Fatalf("the fourth failure in ten minutes gives up: %+v", got) + } + starts := h.rt.starts + h.advance(time.Hour) + if h.rt.starts != starts { + t.Fatal("a service that gave up was started again on its own") + } + if h.get(row.ID).Restarts != 0 { + t.Fatal("the window did not slide") + } +} + +// TestTenHealthyMinutesStartTheCountAgain resets the budget after a long +// healthy run, which the window alone would also do; the reset is measured +// from healthy, not from the restart. +func TestTenHealthyMinutesStartTheCountAgain(t *testing.T) { + h := newHarness(t) + row := h.keep("web", PolicyOnFailure) + h.rt.exit(t, h.s, h.get(row.ID).PID, 1) + h.advance(2 * time.Second) + h.advance(10 * time.Second) + if got := h.get(row.ID); got.State != StateHealthy || got.Restarts != 1 { + t.Fatalf("%+v", got) + } + h.advance(RestartWindow) + if got := h.get(row.ID); got.Restarts != 0 { + t.Fatalf("ten healthy minutes leave the count at %d", got.Restarts) + } +} + +// TestAPersonsRestartNeitherSpendsNorRefillsTheBudget is the board's "counts +// as yours, not toward 1 of 3". +func TestAPersonsRestartNeitherSpendsNorRefillsTheBudget(t *testing.T) { + h := newHarness(t) + row := h.keep("web", PolicyOnFailure) + for _, backoff := range []time.Duration{2 * time.Second, 4 * time.Second} { + h.rt.exit(t, h.s, h.get(row.ID).PID, 1) + h.advance(backoff) + } + before := h.get(row.ID) + if before.Restarts != 2 { + t.Fatalf("%+v", before) + } + got, err := h.s.Restart("agent-a", row.ID) + if err != nil { + t.Fatal(err) + } + settle(t, h.s) + got = h.get(row.ID) + if got.PID == before.PID || got.Restarts != 2 || got.StartedHow != StartedByPerson { + t.Fatalf("a person's restart is a new process that counts nothing: %+v", got) + } + h.rt.exit(t, h.s, got.PID, 1) + if got := h.get(row.ID); got.State != StateRestarting || !got.BackoffUntil.Equal(h.clock.Now().Add(8*time.Second)) { + t.Fatalf("the next automatic restart counts on from 2: %+v", got) + } + h.advance(8 * time.Second) + h.rt.exit(t, h.s, h.get(row.ID).PID, 1) + if got := h.get(row.ID); got.State != StateGaveUp { + t.Fatalf("the budget was refilled by a person's restart: %+v", got) + } +} + +// TestStopEndsItAndNothingRestartsIt is SIGTERM, the row stopped at once, +// the output kept, no restart after it, and Start again asking nothing. +func TestStopEndsItAndNothingRestartsIt(t *testing.T) { + h := newHarness(t) + row := h.keep("web", PolicyAlways) + pid := row.PID + if err := os.WriteFile(row.LogPath, []byte("ready\n"), 0o600); err != nil { + t.Fatal(err) + } + stopped, err := h.s.Stop("agent-a", row.ID) + if err != nil || stopped.State != StateStopped || stopped.StoppedBy != StartedByPerson { + t.Fatalf("stop: %+v %v", stopped, err) + } + settle(t, h.s) + if h.rt.proc(pid).alive || len(h.rt.proc(pid).signals) != 1 || h.rt.proc(pid).signals[0] { + t.Fatalf("stop sends one SIGTERM: %+v", h.rt.proc(pid)) + } + starts := h.rt.starts + h.advance(time.Hour) + got := h.get(row.ID) + if h.rt.starts != starts || got.State != StateStopped || got.Running { + t.Fatalf("a stopped service came back: %+v", got) + } + if body, _ := os.ReadFile(got.LogPath); string(body) != "ready\n" { + t.Fatal("stop did not keep the output") + } + if again, err := h.s.Stop("agent-a", row.ID); err != nil || again.State != StateStopped { + t.Fatal("stopping a stopped service is not idempotent") + } + if _, err := h.s.Start("agent-a", row.ID); err != nil { + t.Fatal(err) + } + settle(t, h.s) + if got := h.get(row.ID); got.State != StateStarting || !got.Running || got.Policy != PolicyAlways { + t.Fatalf("start again uses the saved spec and policy: %+v", got) + } +} + +// TestAStopIgnoredSendsSigkillAfterTheGrace keeps the grace on the clock. +func TestAStopIgnoredSendsSigkillAfterTheGrace(t *testing.T) { + h := newHarness(t) + row := h.keep("stubborn", PolicyOnFailure) + h.rt.proc(row.PID).ignoreTerm = true + if _, err := h.s.Stop("agent-a", row.ID); err != nil { + t.Fatal(err) + } + deadline := time.Now().Add(5 * time.Second) + for h.get(row.ID).Stopping && time.Now().Before(deadline) { + h.clock.fireDue(time.Second) + time.Sleep(time.Millisecond) + } + p := h.rt.proc(row.PID) + if p.alive || len(p.signals) != 2 || p.signals[0] || !p.signals[1] { + t.Fatalf("TERM then KILL: %+v", p.signals) + } + if elapsed := h.clock.Now().Sub(row.Updated); elapsed < StopGrace { + t.Fatalf("SIGKILL came after %s, before the %s grace", elapsed, StopGrace) + } +} + +// TestPolicyNeverShowsTheStateAndRestartsNothing covers both kinds of failure. +func TestPolicyNeverShowsTheStateAndRestartsNothing(t *testing.T) { + h := newHarness(t) + row := h.keep("mock", PolicyNever) + h.rt.fail("mock", true) + h.advance(30 * time.Second) + got := h.get(row.ID) + if got.State != StateUnhealthy || !got.Running || got.Reason != "port 5173 not open · 3 checks" { + t.Fatalf("never: three failed checks are unhealthy and it keeps running: %+v", got) + } + h.rt.exit(t, h.s, got.PID, 1) + got = h.get(row.ID) + if got.State != StateFailed || got.Reason != "exited 1 · not restarted (policy: never)" || *got.ExitCode != 1 { + t.Fatalf("never: exit 1 fails: %+v", got) + } + if h.rt.starts != 1 { + t.Fatal("policy never restarted something") + } +} + +// TestACleanExitRestartsOnlyUnderAlways is the one difference between the two +// restarting policies. +func TestACleanExitRestartsOnlyUnderAlways(t *testing.T) { + h := newHarness(t) + onFailure := h.keep("build", PolicyOnFailure) + always := h.keep("watch", PolicyAlways) + h.rt.exit(t, h.s, onFailure.PID, 0) + h.rt.exit(t, h.s, always.PID, 0) + if got := h.get(onFailure.ID); got.State != StateNotRunning || got.Reason != "exited 0" { + t.Fatalf("on_failure leaves a clean exit alone: %+v", got) + } + if got := h.get(always.ID); got.State != StateRestarting { + t.Fatalf("always restarts a clean exit: %+v", got) + } +} + +// TestAPolicyChangeAppliesAtTheNextFailure and the agent cannot replace it. +func TestAPolicyChangeAppliesAtTheNextFailure(t *testing.T) { + h := newHarness(t) + row := h.keep("web", PolicyOnFailure) + changed, err := h.s.SetPolicy("agent-a", row.ID, PolicyNever) + if err != nil || changed.Policy != PolicyNever || changed.State != StateStarting || !changed.Running { + t.Fatalf("a policy change touches nothing now: %+v %v", changed, err) + } + h.rt.exit(t, h.s, row.PID, 1) + if got := h.get(row.ID); got.State != StateFailed { + t.Fatalf("the next failure follows the new policy: %+v", got) + } + again, err := h.s.Keep("agent-a", webSpec("web", h.dir), PolicyAlways, Provenance{}) + if err != nil || again.Policy != PolicyNever { + t.Fatalf("the agent asking again replaced the person's policy: %+v %v", again, err) + } + if _, err := h.s.SetPolicy("agent-a", row.ID, "sometimes"); err == nil { + t.Fatal("an unknown policy was taken") + } +} + +// TestConsentIsForTheExactCommandFolderAndCheck: one yes covers one command +// in one folder with the check the person saw. A changed check — a new check +// command above all, which is a second command run every ten seconds — is +// asked about again, and the yes survives a restart of codeaf. +func TestConsentIsForTheExactCommandFolderAndCheck(t *testing.T) { + h := newHarness(t) + spec := Spec{Command: "docker compose up db", Dir: h.dir, Health: Health{Cmd: "pg_isready"}} + if _, ok := h.s.Approved(spec); ok { + t.Fatal("approved before anybody said yes") + } + if _, err := h.s.Keep("agent-a", spec, PolicyOnFailure, Provenance{}); err != nil { + t.Fatal(err) + } + if policy, ok := h.s.Approved(spec); !ok || policy != PolicyOnFailure { + t.Fatal("the yes was not banked") + } + for name, other := range map[string]Spec{ + "command": {Command: "docker compose up", Dir: h.dir, Health: Health{Cmd: "pg_isready"}}, + "folder": {Command: spec.Command, Dir: filepath.Join(h.dir, "other"), Health: Health{Cmd: "pg_isready"}}, + "check command": {Command: spec.Command, Dir: h.dir, Health: Health{Cmd: "pg_isready; curl evil"}}, + "check kind": {Command: spec.Command, Dir: h.dir, Health: Health{Port: 5432}}, + } { + if _, ok := h.s.Approved(other); ok { + t.Fatalf("a different %s rode the same yes", name) + } + } + h.reopen() + if _, ok := h.s.Approved(spec); !ok { + t.Fatal("the yes did not survive a restart of codeaf") + } +} + +// TestOneAgentNeverSeesOrTouchesAnothersServices. +func TestOneAgentNeverSeesOrTouchesAnothersServices(t *testing.T) { + h := newHarness(t) + row := h.keep("web", PolicyOnFailure) + if rows := h.s.List("agent-b"); len(rows) != 0 { + t.Fatalf("agent b sees %d services", len(rows)) + } + for name, act := range map[string]func() error{ + "get": func() error { _, err := h.s.Get("agent-b", row.ID); return err }, + "stop": func() error { _, err := h.s.Stop("agent-b", row.ID); return err }, + "restart": func() error { _, err := h.s.Restart("agent-b", row.ID); return err }, + "policy": func() error { _, err := h.s.SetPolicy("agent-b", row.ID, PolicyNever); return err }, + } { + if err := act(); !errors.Is(err, ErrNotFound) { + t.Fatalf("%s from another agent: %v", name, err) + } + } + if n, _ := h.s.StopOwner("agent-b", "agent closed"); n != 0 || !h.get(row.ID).Running { + t.Fatal("closing another agent stopped this one's service") + } + if n, _ := h.s.StopOwner("agent-a", "agent closed"); n != 1 { + t.Fatalf("closing the agent stopped %d", n) + } + settle(t, h.s) + if got := h.get(row.ID); got.Running || got.StoppedBy != "agent closed" { + t.Fatalf("%+v", got) + } +} + +// TestAQuitLeavesItRunningAndTheNextCodeafTakesItBack is re-adoption of the +// exact process, nothing restarted. +func TestAQuitLeavesItRunningAndTheNextCodeafTakesItBack(t *testing.T) { + h := newHarness(t) + row := h.keep("web", PolicyOnFailure) + h.advance(10 * time.Second) + h.reopen() + got := h.get(row.ID) + if !got.Running || got.PID != row.PID || !got.ReAdopted || got.State != StateHealthy || h.rt.starts != 1 { + t.Fatalf("re-adopted the same process, starting nothing: %+v", got) + } + // A re-adopted process reports no exit; its check notices instead. + h.rt.mu.Lock() + h.rt.procs[row.PID].alive = false + h.rt.mu.Unlock() + h.advance(10 * time.Second) + if got := h.get(row.ID); got.State != StateRestarting || got.Reason != "exited" { + t.Fatalf("a re-adopted process that vanished is a failure: %+v", got) + } +} + +// TestARecycledPIDIsNeverTakenBackOrSignalled is the identity gate after a +// restart of codeaf: the same pid with another start identity is somebody +// else, left alone, and the service says it is not running. +func TestARecycledPIDIsNeverTakenBackOrSignalled(t *testing.T) { + h := newHarness(t) + row := h.keep("web", PolicyAlways) + h.s.Close() + h.rt.mu.Lock() + stranger := &fakeProc{id: processgroup.Identity{PID: row.PID, Start: 999999, Boot: h.rt.boot}, alive: true} + h.rt.procs[row.PID] = stranger + h.rt.mu.Unlock() + h.open() + got := h.get(row.ID) + if got.Running || got.State != StateNotRunning || got.Reason != "not running after restart" || !got.EpisodeOpen { + t.Fatalf("a recycled pid was taken back: %+v", got) + } + if h.rt.starts != 1 { + t.Fatal("not running after a codeaf restart was started again on its own") + } + if _, err := h.s.Stop("agent-a", row.ID); err != nil { + t.Fatal(err) + } + if err := h.s.Remove("agent-a", row.ID); err != nil { + t.Fatal(err) + } + settle(t, h.s) + if !stranger.alive || len(stranger.signals) != 0 { + t.Fatal("the stranger holding the recycled pid was signalled") + } +} + +// TestMissingIdentityIsNotRunning is a record with no identity at all. +func TestMissingIdentityIsNotRunning(t *testing.T) { + h := newHarness(t) + row := h.keep("web", PolicyOnFailure) + h.s.Close() + h.rt.mu.Lock() + delete(h.rt.procs, row.PID) + h.rt.mu.Unlock() + h.open() + if got := h.get(row.ID); got.State != StateNotRunning || got.Running { + t.Fatalf("%+v", got) + } +} + +// TestAfterARebootOnlyARestartingPolicyStartsAgain. +func TestAfterARebootOnlyARestartingPolicyStartsAgain(t *testing.T) { + h := newHarness(t) + restarting := h.keep("web", PolicyOnFailure) + never := h.keep("db", PolicyNever) + stopped := h.keep("seed", PolicyAlways) + if _, err := h.s.Stop("agent-a", stopped.ID); err != nil { + t.Fatal(err) + } + settle(t, h.s) + h.s.Close() + h.rt.mu.Lock() + h.rt.boot = "boot-2" + h.rt.procs = map[int]*fakeProc{} + h.rt.mu.Unlock() + h.open() + if got := h.get(restarting.ID); !got.Running || got.StartedHow != StartedAfterRestart { + t.Fatalf("on_failure after a reboot is started again in its folder: %+v", got) + } + if got := h.get(never.ID); got.Running || got.State != StateNotRunning { + t.Fatalf("never after a reboot is not started: %+v", got) + } + if got := h.get(stopped.ID); got.Running || got.State != StateStopped { + t.Fatalf("a stopped service stays stopped after a reboot: %+v", got) + } +} + +// TestAPortAlreadyAnsweringIsSaidAndNothingIsStarted. +func TestAPortAlreadyAnsweringIsSaidAndNothingIsStarted(t *testing.T) { + h := newHarness(t) + h.rt.portsUsed[5173] = true + row := h.keep("web", PolicyOnFailure) + if row.State != StateFailed || !row.PortConflict || row.Running || row.Reason != "port 5173 is used by another program" { + t.Fatalf("%+v", row) + } + if h.rt.starts != 0 { + t.Fatal("a service was started onto a port another program holds") + } + h.rt.portsUsed[5173] = false + if _, err := h.s.Start("agent-a", row.ID); err != nil { + t.Fatal(err) + } + settle(t, h.s) + if got := h.get(row.ID); !got.Running || got.PortConflict { + t.Fatalf("%+v", got) + } +} + +// TestRemoveDropsOnlyAServiceWithNoProcess, and keeps its output: the board's +// "Remove drops it from Processes; its output stays in the chat's notice". +func TestRemoveDropsOnlyAServiceWithNoProcess(t *testing.T) { + h := newHarness(t) + row := h.keep("web", PolicyOnFailure) + if err := h.s.Remove("agent-a", row.ID); !errors.Is(err, ErrRunning) { + t.Fatalf("removing a running service: %v", err) + } + if err := os.WriteFile(row.LogPath, []byte("ready\nserving\n"), 0o600); err != nil { + t.Fatal(err) + } + if _, err := h.s.Stop("agent-a", row.ID); err != nil { + t.Fatal(err) + } + settle(t, h.s) + if err := h.s.Remove("agent-a", row.ID); err != nil { + t.Fatal(err) + } + if len(h.s.List("agent-a")) != 0 { + t.Fatal("the record survived remove") + } + if err := h.s.Remove("agent-a", row.ID); err != nil { + t.Fatal("removing twice is not idempotent") + } + page, err := h.s.Output("agent-a", row.ID, 0, 10) + if err != nil || len(page.Lines) != 2 || page.Lines[1] != "serving" { + t.Fatalf("the removed service's output: %+v %v", page, err) + } + h.reopen() + if page, err := h.s.Output("agent-a", row.ID, 0, 10); err != nil || len(page.Lines) != 2 { + t.Fatalf("the output after a restart of codeaf: %+v %v", page, err) + } + if a, err := h.s.Archive("agent-a", row.ID); err != nil || a.Name != "web" { + t.Fatalf("the archive: %+v %v", a, err) + } + for _, wrong := range [][2]string{{"agent-b", row.ID}, {"agent-a", "s99"}, {"agent-a", "../services"}, {"agent-a", "s1/../../x"}, {"", row.ID}} { + if _, err := h.s.Output(wrong[0], wrong[1], 0, 10); !errors.Is(err, ErrNotFound) { + t.Fatalf("%v read someone's output: %v", wrong, err) + } + } + // The id is never handed out again, so nothing writes over that output. + next := h.keep("api", PolicyNever) + if next.ID == row.ID || next.LogPath == row.LogPath { + t.Fatalf("a removed service's id was reused: %s", next.ID) + } +} + +// TestTheStoreIsPrivateAndHasOneSupervisor. +func TestTheStoreIsPrivateAndHasOneSupervisor(t *testing.T) { + h := newHarness(t) + h.keep("web", PolicyOnFailure) + info, err := os.Stat(filepath.Join(h.dir, storeFile)) + if err != nil || info.Mode().Perm() != 0o600 { + t.Fatalf("the services file is %v %v", info.Mode(), err) + } + dir, _ := os.Stat(h.dir) + if dir.Mode().Perm() != 0o700 { + t.Fatalf("the services folder is %v", dir.Mode()) + } + if _, err := Open(Options{Dir: h.dir, Runtime: h.rt, Clock: h.clock}); !errors.Is(err, ErrBusy) { + t.Fatalf("a second supervisor opened the same store: %v", err) + } +} + +// TestTheSupervisorKeepsOneTimerForEveryService. +func TestTheSupervisorKeepsOneTimerForEveryService(t *testing.T) { + h := newHarness(t) + for _, name := range []string{"a", "b", "c", "d", "e", "f"} { + h.keep(name, PolicyOnFailure) + } + for i := 0; i < 30; i++ { + h.advance(10 * time.Second) + if n := h.clock.pending(); n > 1 { + t.Fatalf("%d timers pending for six services", n) + } + } + if _, err := h.s.Keep("agent-a", Spec{Command: "x", Dir: h.dir, Health: Health{Port: 1}}, PolicyNever, Provenance{}); err != nil { + t.Fatal(err) + } + for i := 0; i < maxPerOwner; i++ { + _, _ = h.s.Keep("agent-a", Spec{Command: "y" + string(rune('a'+i)), Dir: h.dir, Health: Health{Port: 2}}, PolicyNever, Provenance{}) + } + if len(h.s.List("agent-a")) != maxPerOwner { + t.Fatalf("one agent kept %d services", len(h.s.List("agent-a"))) + } +} + +// TestConcurrentActionsSettleConsistently runs every verb at once, many times. +// It is a -race test as much as an outcome test: whatever interleaving wins, +// a stopped service has no process and a running one has exactly one. +func TestConcurrentActionsSettleConsistently(t *testing.T) { + h := newHarness(t) + row := h.keep("web", PolicyOnFailure) + var wg sync.WaitGroup + for i := 0; i < 20; i++ { + wg.Add(5) + go func() { defer wg.Done(); _, _ = h.s.Restart("agent-a", row.ID) }() + go func() { defer wg.Done(); _, _ = h.s.Stop("agent-a", row.ID) }() + go func() { defer wg.Done(); _, _ = h.s.Start("agent-a", row.ID) }() + go func() { defer wg.Done(); _, _ = h.s.SetPolicy("agent-a", row.ID, PolicyAlways) }() + go func() { defer wg.Done(); _ = h.s.List("agent-a"); _ = h.s.Remove("agent-a", row.ID) }() + } + wg.Wait() + settle(t, h.s) + h.advance(time.Minute) + rows := h.s.List("agent-a") + running := h.rt.running() + switch { + case len(rows) == 0: + if len(running) != 0 { + t.Fatalf("a removed service left %v running", running) + } + case rows[0].Running: + if len(running) != 1 || running[0] != rows[0].PID { + t.Fatalf("one running service, processes %v, row %+v", running, rows[0]) + } + default: + if len(running) != 0 { + t.Fatalf("a service with no process left %v running: %+v", running, rows[0]) + } + } +} + +// TestCloseLeavesServicesRunningAndStopAllEndsThem is quit's two answers. +func TestCloseLeavesServicesRunningAndStopAllEndsThem(t *testing.T) { + h := newHarness(t) + a := h.keep("a", PolicyOnFailure) + b := h.keep("b", PolicyNever) + if h.s.Running() != 2 { + t.Fatal(h.s.Running()) + } + h.reopen() + if len(h.rt.running()) != 2 { + t.Fatal("a quit stopped services") + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := h.s.StopAll(ctx, "quit"); err != nil { + t.Fatal(err) + } + if len(h.rt.running()) != 0 || h.get(a.ID).State != StateStopped || h.get(b.ID).StoppedBy != "quit" { + t.Fatal("stop all left something running") + } +} + +// TestEventsCarryNoCredentialAndOneLinePerEpisode. +func TestEventsCarryNoCredentialAndOneLinePerEpisode(t *testing.T) { + h := newHarness(t) + var mu sync.Mutex + var events []Event + h.s.Subscribe(func(e Event) { mu.Lock(); events = append(events, e); mu.Unlock() }) + secret := "sk-or-v1-" + "0123456789abcdef0123456789abcdef0123456789abcdef" + row, err := h.s.Keep("agent-a", Spec{Name: "api", Command: "OPENROUTER_API_KEY=" + secret + " node api.js", Dir: h.dir, Health: Health{Port: 4010}}, PolicyOnFailure, Provenance{}) + if err != nil { + t.Fatal(err) + } + h.rt.exit(t, h.s, row.PID, 1) + h.advance(2 * time.Second) + h.rt.exit(t, h.s, h.get(row.ID).PID, 1) + h.advance(4 * time.Second) + h.advance(10 * time.Second) + h.s.Close() + mu.Lock() + defer mu.Unlock() + episodes := map[string]bool{} + for _, e := range events { + if strings.Contains(e.Row.Command, secret) { + t.Fatal("an event carried the command's credential") + } + if e.Row.Episode != "" { + episodes[e.Row.Episode] = true + } + } + if len(episodes) != 1 { + t.Fatalf("two crashes and a recovery were %d episodes", len(episodes)) + } + if last := events[len(events)-1].Row; last.State != StateHealthy || last.EpisodeOpen || last.Restarts != 2 { + t.Fatalf("the last event is the recovery, restarted 2 of 3: %+v", last) + } +} diff --git a/internal/service/wait_darwin.go b/internal/service/wait_darwin.go new file mode 100644 index 0000000000..871f890cb3 --- /dev/null +++ b/internal/service/wait_darwin.go @@ -0,0 +1,49 @@ +//go:build darwin + +package service + +import ( + "time" + + "golang.org/x/sys/unix" +) + +// sZomb is Darwin's p_stat for an exited, unreaped process. +const sZomb = 5 + +// waitExited blocks until pid has exited WITHOUT reaping it, through the +// kernel's exit note: Darwin has no waitid(WNOWAIT) that Go can reach, and +// kqueue reports the exit while the leader is still an unreaped zombie holding +// its pid and its start identity. A leader that exited before the note was +// registered answers ESRCH — it is a zombie already, and nobody but this +// process reaps it, so the number is still held. +// +// IT NEVER RETURNS WHILE THE LEADER RUNS, because its caller reaps under the +// lock every signal takes. A kqueue that cannot be had at all — descriptors +// exhausted — falls back to looking at the process's state until it is a +// zombie, which is slower and still never reaps. +func waitExited(pid int) { + if kq, err := unix.Kqueue(); err == nil { + defer unix.Close(kq) + var change unix.Kevent_t + unix.SetKevent(&change, pid, unix.EVFILT_PROC, unix.EV_ADD|unix.EV_ONESHOT) + change.Fflags = unix.NOTE_EXIT + events := make([]unix.Kevent_t, 1) + for { + _, err := unix.Kevent(kq, []unix.Kevent_t{change}, events, nil) + if err == nil || err == unix.ESRCH { + return + } + if err != unix.EINTR { + break + } + } + } + for { + p, err := unix.SysctlKinfoProc("kern.proc.pid", pid) + if err != nil || p == nil || int(p.Proc.P_pid) != pid || p.Proc.P_stat == sZomb { + return + } + time.Sleep(200 * time.Millisecond) + } +} diff --git a/internal/service/wait_linux.go b/internal/service/wait_linux.go new file mode 100644 index 0000000000..e3051be0cb --- /dev/null +++ b/internal/service/wait_linux.go @@ -0,0 +1,18 @@ +//go:build linux + +package service + +import "golang.org/x/sys/unix" + +// waitExited blocks until pid has exited WITHOUT reaping it. The leader stays a +// zombie that still holds its pid and its start identity, so its group can be +// swept through the identity gate before anything lets the number go. +func waitExited(pid int) { + var info unix.Siginfo + for { + err := unix.Waitid(unix.P_PID, pid, &info, unix.WEXITED|unix.WNOWAIT, nil) + if err != unix.EINTR { + return + } + } +} diff --git a/internal/session/agent.go b/internal/session/agent.go index a0e16b8c04..f940cbea08 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -1868,6 +1868,9 @@ func (a *Agent) startTurnLocked(ctx context.Context, user userMessage, watcher * // it is safe to run twice — a disowned turn hands back nothing, because // the turn that replaced it has already handed back whatever was owed. a.handBackUnsettled() + // A process the person declined to keep running ends with the turn + // that started it (keep_running.go). + a.endTurnJobs(seq) a.mu.Lock() // A DISOWNED TURN CLEANS UP NOTHING. [Agent.Abandon] has already done // every act below — drained the queues, cleared running, closed the diff --git a/internal/session/answers.go b/internal/session/answers.go index 9fb475b456..3cc5e79804 100644 --- a/internal/session/answers.go +++ b/internal/session/answers.go @@ -254,6 +254,8 @@ func AnswerOptions(kind QuestionKind) []AnswerOption { {Key: "2", Label: "finish on what is done", Consequence: "it writes up what it already has"}, {Key: "3", Label: "stop", Consequence: "the run ends where it stands"}, } + case QuestionKeep: + return KeepOptions() case QuestionLanding, QuestionConflict: // THE LANDING KEYS ARE TASK-STATES' OWN, LETTER FOR LETTER // (docs/design/task-states/DESIGN.md): `[a] · [n] · [s] tell diff --git a/internal/session/keep_running.go b/internal/session/keep_running.go new file mode 100644 index 0000000000..40a117554a --- /dev/null +++ b/internal/session/keep_running.go @@ -0,0 +1,413 @@ +package session + +// keep_running.go is the one door through which a conversation keeps a +// process running after its turn: bash's keep_running argument, the card that +// asks the person, and what each answer does. +// +// A BACKGROUND JOB ENDS WITH ITS CONVERSATION; A KEPT SERVICE DOES NOT. The +// difference is a person's yes, and nothing else may stand in for one. So the +// argument is a proposal — the exact command, the folder it runs in, one +// health check and a restart policy — and the card puts exactly that in front +// of the person: +// +// - Allow keeps it under the policy the agent proposed. +// - Allow, never restart keeps it under never. +// - Deny runs it as an ordinary background job that ends with this turn. +// +// THE YES IS ASKED ONCE PER COMMAND AND FOLDER, AND IT IS THE PERSON'S. The +// supervisor banks it with the check and the policy they saw +// ([service.Supervisor.Approved]); the same proposal again starts without a +// card and under their policy, whatever policy the agent proposes this time, +// and a proposal that changes the check is asked about again. Only the person +// changes a policy afterwards, from the row. +// +// A CAPABILITY THAT CANNOT WORK IS ABSENT. With no supervisor, no conversation +// folder to own the service, nobody to ask, or inside a task, bash has no +// keep_running argument at all ([Agent.keepAvailable]). + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "path/filepath" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/redact" + "github.com/Agent-Field/codeaf/internal/service" +) + +// QuestionKeep is a keep-running card: may this command keep running after +// this turn? +const QuestionKeep QuestionKind = "keep_running" + +// The keys a keep-running card answers with. They never move, and the no is +// every card's outright no ([DeclineKey]). +const ( + KeepAllowKey = "1" + KeepAllowNeverKey = "2" + KeepDenyKey = DeclineKey +) + +// The answers, as a restated card and a receipt spell them. +const ( + KeepAllowed = "allow" + KeepAllowedNever = "allow_never" + KeepDenied = "deny" +) + +// KeepNotice is one keep-running card: what the agent proposed, and — once the +// card is restated with its answer — what the person said and what came of it. +type KeepNotice struct { + // ID names the card while it waits. + ID uint64 `json:"id,omitempty"` + // CallID is the bash call that asked. + CallID string `json:"callId,omitempty"` + Asked time.Time `json:"asked"` + Name string `json:"name"` + // Command is redacted: a card travels to every surface. + Command string `json:"command"` + Dir string `json:"dir"` + Health service.Health `json:"health"` + HealthWords string `json:"healthWords"` + // Policy is what the agent proposed; PolicyWords says it. + Policy service.Policy `json:"policy"` + PolicyWords string `json:"policyWords"` + // Decided is KeepAllowed, KeepAllowedNever or KeepDenied on the receipt. + Decided string `json:"decided,omitempty"` + // Withdrawn says why a card stopped waiting with no answer. + Withdrawn string `json:"withdrawn,omitempty"` + // Service is the kept service's row after an allow. + Service *service.Row `json:"service,omitempty"` + // Job is the ordinary job a deny started, ending with this turn. + Job int `json:"job,omitempty"` +} + +// keepRunningArgs is bash's keep_running argument. +type keepRunningArgs struct { + Name string `json:"name"` + Health struct { + Port int `json:"port"` + URL string `json:"url"` + Cmd string `json:"cmd"` + } `json:"health"` + Restart string `json:"restart"` +} + +type keepAsk struct { + answers chan string + notice KeepNotice +} + +// KeepPolicyWords is a policy as the card says it. +func KeepPolicyWords(policy service.Policy) string { + switch policy { + case service.PolicyNever: + return "never restart" + case service.PolicyAlways: + return fmt.Sprintf("always restart, up to %d times in 10 minutes", service.RestartLimit) + } + return fmt.Sprintf("restart on failure, up to %d times in 10 minutes", service.RestartLimit) +} + +// KeepOptions are a card's three answers. +func KeepOptions() []AnswerOption { + return []AnswerOption{ + {Key: KeepAllowKey, Label: "Allow", Consequence: "It keeps running after this turn, restarting as proposed."}, + {Key: KeepAllowNeverKey, Label: "Allow, never restart", Consequence: "It keeps running after this turn and is not restarted."}, + {Key: KeepDenyKey, Label: "Deny", Consequence: "It runs as an ordinary process and stops when this turn ends.", Safe: true}, + } +} + +// KeepHead is the card's first line. +func KeepHead(notice KeepNotice) string { + return "keep " + firstLine(notice.Command) + " running after this turn?" +} + +// KeepQuestion is one card as a question on the block — the ONE construction +// of it, which the engine publishes and a surface draws. +func KeepQuestion(notice KeepNotice) Question { + card := notice + card.Service = nil + return Question{ + ID: notice.ID, + Kind: QuestionKeep, + Ask: AskPermission, + Form: FormCard, + Asker: Asker{Kind: AskerModel}, + Head: KeepHead(notice), + Reason: notice.HealthWords + " · every " + service.CheckEvery.String() + " · " + notice.PolicyWords + " · folder " + notice.Dir, + Subject: SubjectRef{Kind: SubjectCall, CallID: notice.CallID, Name: notice.Name}, + Options: KeepOptions(), + Stakes: StakesReversible, + // THE TURN IS STOPPED ON IT: the bash call waits for the answer and the + // card carries no clock, because silence is not a yes. + Blocking: Blocking{Turn: true}, + Scope: []AnswerScope{ScopeOnce}, + Asked: notice.Asked, + Keep: &card, + } +} + +// ResolveKeep delivers the person's answer to the card waiting on it, and +// reports whether the key was one of its answers and a card was waiting. +func (a *Agent) ResolveKeep(id uint64, key string) bool { + switch key { + case KeepAllowKey, KeepAllowNeverKey, KeepDenyKey: + default: + return false + } + a.mu.Lock() + ask, waiting := a.keepAsks[id] + if waiting { + delete(a.keepAsks, id) + } + a.mu.Unlock() + if waiting { + ask.answers <- key + } + return waiting +} + +// keepOwner is who owns this conversation's services: its folder's name, +// which is the agent id every surface already addresses it by. +func (a *Agent) keepOwner() string { + file := a.SessionPath() + if file == "" { + return "" + } + return filepath.Base(filepath.Dir(file)) +} + +// keepAvailable says whether bash carries keep_running at all. +func (a *Agent) keepAvailable() bool { + return a.config.Services != nil && a.keepOwner() != "" && a.config.AskConsent && !a.config.InTask +} + +// keepRunningProperty is bash's keep_running schema, its figure the +// supervisor's own. +var keepRunningProperty = strings.ReplaceAll(keepRunningShape, "{every}", service.CheckEvery.String()) + +const keepRunningShape = `{"type":"object","description":"Keep this command running after the turn as a supervised service. The person is asked first, once per command and folder; the command then runs in the current folder, is checked every {every}, and is restarted under the policy the person keeps. Give exactly one health check.","properties":{"name":{"type":"string","description":"a short name for it, e.g. web dev server"},"health":{"type":"object","description":"exactly one: a port that accepts connections, a URL that answers 200, or a command that exits 0","properties":{"port":{"type":"integer"},"url":{"type":"string"},"cmd":{"type":"string"}}},"restart":{"type":"string","enum":["on_failure","always","never"],"description":"the restart policy to propose; the person decides"}},"required":["health"]}` + +// keepSentence is the bash description's line about it. +func (a *Agent) keepSentence() string { + if !a.keepAvailable() { + return "" + } + return " To keep a server or watcher running after this turn, pass keep_running with one health check; the person is asked, and nohup or & are not a way around that." +} + +// schemaWithKeep adds keep_running to bash's schema where it can work. +func (a *Agent) schemaWithKeep(schema json.RawMessage) json.RawMessage { + if !a.keepAvailable() { + return schema + } + var root map[string]json.RawMessage + if err := json.Unmarshal(schema, &root); err != nil { + return schema + } + var properties map[string]json.RawMessage + if err := json.Unmarshal(root["properties"], &properties); err != nil { + return schema + } + properties["keep_running"] = json.RawMessage(keepRunningProperty) + encoded, err := json.Marshal(properties) + if err != nil { + return schema + } + root["properties"] = encoded + out, err := json.Marshal(root) + if err != nil { + return schema + } + return out +} + +// keepRunning is bash's keep_running road. +func (a *Agent) keepRunning(ctx context.Context, command string, args keepRunningArgs) (string, bool, error) { + if !a.keepAvailable() { + return "keep_running is not available here, so nothing was started. Run it with background:true for this conversation only.", true, nil + } + a.jobs.mu.Lock() + dir := a.jobs.workspace + a.jobs.mu.Unlock() + proposed, err := service.ParsePolicy(args.Restart) + if err != nil { + return err.Error() + "; nothing was started.", true, nil + } + spec, err := service.Spec{ + Name: args.Name, Command: command, Dir: dir, + Health: service.Health{Port: args.Health.Port, URL: args.Health.URL, Cmd: args.Health.Cmd}, + }.Check() + if err != nil { + return err.Error() + "; nothing was started.", true, nil + } + callID := callIDFrom(ctx) + a.mu.Lock() + asked, turn := a.personAsk, a.turnSeq + a.mu.Unlock() + // The started-by line is the person's own request, redacted like + // everything else a row carries. + from := service.Provenance{Label: redact.Secrets(firstLine(asked)), Turn: turn, CallID: callID} + if policy, ok := a.config.Services.Approved(spec); ok { + text, isError, _ := a.keepStarted(spec, policy, from, true) + return text, isError, nil + } + + notice := KeepNotice{ + CallID: callID, Asked: time.Now().UTC(), Name: spec.Name, Command: redact.Secrets(spec.Command), Dir: spec.Dir, + Health: redactedHealth(spec.Health), HealthWords: redact.Secrets(spec.Health.Words()), + Policy: proposed, PolicyWords: KeepPolicyWords(proposed), + } + key, err := a.askKeep(ctx, ¬ice) + if err != nil { + return "Not kept running: " + err.Error() + ". Nothing was started.", true, nil + } + switch key { + case KeepAllowKey, KeepAllowNeverKey: + policy, decided := proposed, KeepAllowed + if key == KeepAllowNeverKey { + policy, decided = service.PolicyNever, KeepAllowedNever + } + text, isError, row := a.keepStarted(spec, policy, from, false) + if row.ID != "" { + notice.Service = &row + } + a.keepReceipt(notice, decided) + return text, isError, nil + default: + started, err := a.jobs.start(command) + if err != nil { + a.keepReceipt(notice, KeepDenied) + return "The person chose not to keep it running, and it could not be started as an ordinary job: " + err.Error(), true, nil + } + a.mu.Lock() + if a.turnJobs == nil { + a.turnJobs = map[int]uint64{} + } + a.turnJobs[started.id] = a.turnSeq + a.mu.Unlock() + notice.Job = started.id + a.keepReceipt(notice, KeepDenied) + return fmt.Sprintf("The person chose not to keep it running. It runs as job %d for this turn only and stops when this turn ends; log at %s", started.id, started.logPath), false, nil + } +} + +// keepStarted keeps spec under policy and says what came of it, with the row. +func (a *Agent) keepStarted(spec service.Spec, policy service.Policy, from service.Provenance, remembered bool) (string, bool, service.Row) { + row, err := a.config.Services.Keep(a.keepOwner(), spec, policy, from) + if err != nil && row.ID == "" { + return "Not kept running: " + err.Error() + ".", true, row + } + if row.PortConflict { + return fmt.Sprintf("Not started: %s. Nothing of the other program was touched. Tell the person, or propose the command on another port.", row.Reason), true, row + } + if !row.Running { + return fmt.Sprintf("Kept as %s, but it is not running: %s.", row.ID, row.Reason), true, row + } + lead := "Kept running" + if remembered { + lead = "Kept running (the person already approved this command in this folder)" + } + return fmt.Sprintf("%s as %s (%s) · checks %s every %s · %s · log at %s. It keeps running after this turn; only the person changes its restart policy.", + lead, row.ID, row.Name, row.HealthWords, service.CheckEvery, KeepPolicyWords(row.Policy), row.LogPath), false, row +} + +// askKeep raises one card and waits for the person for as long as that takes. +// A conversation nobody is watching gets no card; the turn ending is the card +// going unanswered, and an unanswered card starts nothing. +func (a *Agent) askKeep(ctx context.Context, notice *KeepNotice) (string, error) { + a.mu.Lock() + if a.closed { + a.mu.Unlock() + return "", errAgentClosed + } + hub := a.hub + if hub == nil { + a.mu.Unlock() + return "", errors.New("nobody is here to ask") + } + a.keepSeq++ + notice.ID = a.keepSeq + answers := make(chan string, 1) + if a.keepAsks == nil { + a.keepAsks = map[uint64]*keepAsk{} + } + a.keepAsks[notice.ID] = &keepAsk{answers: answers, notice: *notice} + a.mu.Unlock() + + card := *notice + defer a.presenceAskingWhole(KeepQuestion(card), func() { + hub.send(Event{Kind: EventKeepRunning, Tool: "bash", CallID: card.CallID, Keep: &card}) + })() + select { + case key := <-answers: + return key, nil + case <-ctx.Done(): + a.mu.Lock() + delete(a.keepAsks, notice.ID) + a.mu.Unlock() + return "", errors.New("the turn ended before an answer") + } +} + +// keepReceipt restates the card with its answer — the receipt a surface draws +// in place of the card, and what a replay carries instead of an open question. +func (a *Agent) keepReceipt(notice KeepNotice, decided string) { + a.mu.Lock() + hub := a.hub + a.mu.Unlock() + if hub == nil { + return + } + notice.Decided = decided + hub.send(Event{Kind: EventKeepRunning, Tool: "bash", CallID: notice.CallID, Keep: ¬ice}) +} + +// endTurnJobs ends the jobs a deny left for the turn that started them. The +// kill waits out its grace off the turn's own cleanup, so ending a turn never +// waits on a process. +func (a *Agent) endTurnJobs(turn uint64) { + a.mu.Lock() + var ending []int + for id, started := range a.turnJobs { + if started == turn { + ending = append(ending, id) + delete(a.turnJobs, id) + } + } + a.mu.Unlock() + for _, id := range ending { + go a.jobs.kill(context.Background(), id) + } +} + +// keptRunningList is the `jobs list` lines for this conversation's kept +// services, so the model can see them after the turn that started them. +func (a *Agent) keptRunningList() string { + if a.config.Services == nil || a.keepOwner() == "" { + return "" + } + rows := a.config.Services.List(a.keepOwner()) + if len(rows) == 0 { + return "" + } + var b strings.Builder + b.WriteString("\nKept running (the person controls these):") + for _, row := range rows { + fmt.Fprintf(&b, "\n%s · %s · %s · %s · %s · restarted %d of %d", + row.ID, row.Name, row.State, row.HealthWords, KeepPolicyWords(row.Policy), row.Restarts, row.RestartLimit) + if row.Reason != "" { + b.WriteString(" · " + row.Reason) + } + } + return b.String() +} + +func redactedHealth(h service.Health) service.Health { + h.URL = redact.Secrets(h.URL) + h.Cmd = redact.Secrets(h.Cmd) + return h +} diff --git a/internal/session/keep_running_test.go b/internal/session/keep_running_test.go new file mode 100644 index 0000000000..4edaa287fb --- /dev/null +++ b/internal/session/keep_running_test.go @@ -0,0 +1,254 @@ +package session + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/service" + "github.com/Agent-Field/codeaf/internal/service/servicetest" +) + +// keepCall is one scripted bash call asking to keep a command running. +func keepCall(id, command string, keep map[string]any) step { + raw, _ := json.Marshal(map[string]any{"command": command, "keep_running": keep}) + return func(context.Context, []ai.Message) (*ai.Response, error) { + return toolResponse(id, "bash", string(raw)), nil + } +} + +// keepWorld is a supervisor over a machine with no processes, shared by the +// conversations of one test the way one codeaf shares it. +func keepWorld(t *testing.T) (*service.Supervisor, *servicetest.Runtime) { + t.Helper() + rt := servicetest.New() + supervisor, err := service.Open(service.Options{Dir: t.TempDir(), Runtime: rt}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(supervisor.Close) + return supervisor, rt +} + +// keepAgent is a conversation somebody is in, owning its folder "owner". +func keepAgent(t *testing.T, completer Completer, supervisor *service.Supervisor, owner string) *Agent { + t.Helper() + folder := filepath.Join(t.TempDir(), owner) + if err := os.MkdirAll(folder, 0o700); err != nil { + t.Fatal(err) + } + agent, _ := newTestAgent(t, completer, func(config *Config) { + config.AskConsent = true + config.Services = supervisor + config.SessionFile = filepath.Join(folder, "transcript.jsonl") + }) + return agent +} + +// drainAnsweringKeep drains a turn, answering every keep card with key through +// the one question door, and every other approval with a one-time yes. +func drainAnsweringKeep(t *testing.T, agent *Agent, events <-chan Event, key string, seen func([]Question)) []Event { + t.Helper() + var collected []Event + deadline := time.After(10 * time.Second) + for { + select { + case event, open := <-events: + if !open { + return collected + } + collected = append(collected, event) + switch { + case event.Kind == EventConsentRequest: + agent.ResolveConsentRemember(event.ID, true, ConsentOnce) + case event.Kind == EventKeepRunning && event.Keep != nil && event.Keep.Decided == "": + if seen != nil { + seen(agent.OpenQuestions()) + } + if err := agent.ResolveQuestion(Answer{Kind: QuestionKeep, ID: event.Keep.ID, Key: key, DecidedBy: DecidedByPerson}); err != nil { + t.Errorf("answering the card: %v", err) + } + } + case <-deadline: + t.Fatal("the turn never ended") + } + } +} + +func toolResultText(t *testing.T, agent *Agent, callID string) string { + t.Helper() + agent.mu.Lock() + messages := append([]ai.Message(nil), agent.messages...) + agent.mu.Unlock() + for _, message := range messages { + if message.Role == "tool" && message.ToolCallID == callID { + return messageText(message) + } + } + t.Fatalf("no result for %s", callID) + return "" +} + +// TestAllowKeepsItRunningUnderTheProposedPolicy is the board's first ask: a +// real card on the question door, three answers, a receipt, a kept service. +func TestAllowKeepsItRunningUnderTheProposedPolicy(t *testing.T) { + supervisor, rt := keepWorld(t) + completer := &scriptedCompleter{steps: []step{ + keepCall("k1", "npm run dev", map[string]any{"name": "web dev server", "health": map[string]any{"port": 5173}, "restart": "always"}), + finalText("it is running"), + }} + agent := keepAgent(t, completer, supervisor, "api-docs") + events, err := agent.Submit(context.Background(), "start the web dev server and keep it up") + if err != nil { + t.Fatal(err) + } + var open []Question + collected := drainAnsweringKeep(t, agent, events, KeepAllowKey, func(q []Question) { open = q }) + if len(open) != 1 || open[0].Kind != QuestionKeep || len(open[0].Options) != 3 || !open[0].Blocking.Turn { + t.Fatalf("the card is one open question with three answers: %+v", open) + } + if card := open[0].Keep; card == nil || card.Command != "npm run dev" || card.Health.Port != 5173 || card.Policy != service.PolicyAlways || card.Name != "web dev server" || card.Dir == "" { + t.Fatalf("the card does not carry its proposal: %+v", card) + } + if rt.Starts() != 1 { + t.Fatalf("started %d", rt.Starts()) + } + rows := supervisor.List("api-docs") + if len(rows) != 1 || rows[0].Policy != service.PolicyAlways || !rows[0].Running || rows[0].Name != "web dev server" || + rows[0].From.Label != "start the web dev server and keep it up" || rows[0].From.CallID != "k1" { + t.Fatalf("kept: %+v", rows) + } + var receipt *KeepNotice + for _, event := range collected { + if event.Kind == EventKeepRunning && event.Keep.Decided != "" { + receipt = event.Keep + } + } + if receipt == nil || receipt.Decided != KeepAllowed || receipt.Service == nil || receipt.Service.ID != rows[0].ID { + t.Fatalf("the receipt: %+v", receipt) + } + if text := toolResultText(t, agent, "k1"); !strings.Contains(text, "Kept running as "+rows[0].ID) { + t.Fatalf("the model was told %q", text) + } + if len(agent.OpenQuestions()) != 0 { + t.Fatal("an answered card is still open") + } +} + +// TestTheSameCommandIsAskedOnceAndThePersonsPolicyStands. +func TestTheSameCommandIsAskedOnceAndThePersonsPolicyStands(t *testing.T) { + supervisor, rt := keepWorld(t) + keep := map[string]any{"health": map[string]any{"port": 5173}, "restart": "always"} + completer := &scriptedCompleter{steps: []step{ + keepCall("k1", "npm run dev", keep), finalText("kept"), + keepCall("k2", "npm run dev", keep), finalText("again"), + }} + agent := keepAgent(t, completer, supervisor, "api-docs") + events, _ := agent.Submit(context.Background(), "keep it up") + drainAnsweringKeep(t, agent, events, KeepAllowNeverKey, nil) + rows := supervisor.List("api-docs") + if len(rows) != 1 || rows[0].Policy != service.PolicyNever { + t.Fatalf("allow, never restart: %+v", rows) + } + if _, err := supervisor.Stop("api-docs", rows[0].ID); err != nil { + t.Fatal(err) + } + events, _ = agent.Submit(context.Background(), "start it again") + collected := drainAnsweringKeep(t, agent, events, KeepDenyKey, nil) + if _, asked := firstOfKind(collected, EventKeepRunning); asked { + t.Fatal("the same command in the same folder was asked about twice") + } + rows = supervisor.List("api-docs") + if rt.Starts() != 2 || len(rows) != 1 || !rows[0].Running || rows[0].Policy != service.PolicyNever { + t.Fatalf("the agent's always replaced the person's never: %+v", rows) + } +} + +// TestDenyRunsAnOrdinaryJobThatEndsWithTheTurn. +func TestDenyRunsAnOrdinaryJobThatEndsWithTheTurn(t *testing.T) { + supervisor, rt := keepWorld(t) + completer := &scriptedCompleter{steps: []step{ + keepCall("k1", "sleep 300", map[string]any{"health": map[string]any{"cmd": "true"}}), + finalText("ok"), + }} + agent := keepAgent(t, completer, supervisor, "api-docs") + events, _ := agent.Submit(context.Background(), "run it") + collected := drainAnsweringKeep(t, agent, events, KeepDenyKey, nil) + if rt.Starts() != 0 || len(supervisor.List("api-docs")) != 0 { + t.Fatal("a denied command was kept") + } + if _, ok := supervisor.Approved(service.Spec{Command: "sleep 300", Dir: agent.config.Workspace, Health: service.Health{Cmd: "true"}}); ok { + t.Fatal("a no was banked as a yes") + } + var job int + for _, event := range collected { + if event.Kind == EventKeepRunning && event.Keep.Decided == KeepDenied { + job = event.Keep.Job + } + } + if job == 0 { + t.Fatal("the deny receipt names no job") + } + deadline := time.Now().Add(10 * time.Second) + for { + found := agent.jobs.find(job) + if found != nil && !found.running() { + break + } + if time.Now().After(deadline) { + t.Fatal("the denied job outlived its turn") + } + time.Sleep(10 * time.Millisecond) + } + if text := toolResultText(t, agent, "k1"); !strings.Contains(text, "stops when this turn ends") { + t.Fatalf("the model was told %q", text) + } +} + +// TestWithNoSupervisorThereIsNoKeepRunning: absent, not broken. +func TestWithNoSupervisorThereIsNoKeepRunning(t *testing.T) { + completer := &scriptedCompleter{steps: []step{ + keepCall("k1", "sleep 300", map[string]any{"health": map[string]any{"port": 1}}), + finalText("ok"), + }} + agent := keepAgent(t, completer, nil, "api-docs") + for _, tool := range agent.belt() { + if tool.Name == "bash" && strings.Contains(string(tool.Schema), "keep_running") { + t.Fatal("bash offers keep_running with nothing behind it") + } + } + events, _ := agent.Submit(context.Background(), "run it") + drainAnsweringKeep(t, agent, events, KeepAllowKey, nil) + if text := toolResultText(t, agent, "k1"); !strings.Contains(text, "not available here") { + t.Fatalf("the model was told %q", text) + } + if len(agent.jobs.all()) != 0 { + t.Fatal("something was started") + } +} + +// TestOneConversationNeverListsAnothersServices. +func TestOneConversationNeverListsAnothersServices(t *testing.T) { + supervisor, _ := keepWorld(t) + if _, err := supervisor.Keep("other-agent", service.Spec{Command: "make serve", Dir: t.TempDir(), Health: service.Health{Port: 8080}}, service.PolicyNever, service.Provenance{}); err != nil { + t.Fatal(err) + } + agent := keepAgent(t, &scriptedCompleter{}, supervisor, "api-docs") + if listed := agent.keptRunningList(); listed != "" { + t.Fatalf("another agent's service is listed here: %q", listed) + } +} + +// TestEventOrdinalsAfterAutomationUpdateDoNotMove pins the persisted event +// numbers: a summary's start and failure keep the two ordinals after +// EventAutomationUpdate, and the keep-running ask the third. +func TestEventOrdinalsAfterAutomationUpdateDoNotMove(t *testing.T) { + if EventSummaryStarted != EventAutomationUpdate+1 || EventSummaryFailed != EventAutomationUpdate+2 || EventKeepRunning != EventAutomationUpdate+3 { + t.Fatalf("ordinals moved: started %d failed %d keep %d after %d", EventSummaryStarted, EventSummaryFailed, EventKeepRunning, EventAutomationUpdate) + } +} diff --git a/internal/session/prompts/system.md b/internal/session/prompts/system.md index a132a8d776..0a1726edaf 100644 --- a/internal/session/prompts/system.md +++ b/internal/session/prompts/system.md @@ -169,7 +169,9 @@ AUTOMATION_FACTS # Interrupts and steering A mid-turn message cuts generation: keep partial work in the transcript and fold corrections into the SAME turn. A long bash may continue as a background -job; its result names the job. Session news arrives only at boundaries. An +job; its result names the job. Only when bash's own schema offers +`keep_running` may a server outlive the turn: the person is asked, and only they +set its restart policy. Session news arrives only at boundaries. An interruption ends the turn: stop cleanly and preserve finished work. A turn can start with nobody having typed. Such a message says so in its own diff --git a/internal/session/question.go b/internal/session/question.go index 7ac7d14b01..3f9e160a33 100644 --- a/internal/session/question.go +++ b/internal/session/question.go @@ -792,6 +792,10 @@ type Question struct { Attach []Block `json:"attach,omitempty"` // Asked is when it was put. Asked time.Time `json:"asked,omitzero"` + // Keep is a keep-running card's whole proposal (keep_running.go), so a + // surface draws its command, folder and check from the card itself rather + // than from Head and Reason. Nil on every other question. + Keep *KeepNotice `json:"keep,omitempty"` // Deadline is when a clock takes the question, and it is ZERO ON EVERY // QUESTION THAT HAS NO CLOCK — which is all of them but the task proposal // today. A WAIT THAT ENDED IS NOT A NO (consent.go): what a deadline does is @@ -1460,6 +1464,13 @@ func questionAsked(event Event) (string, bool) { } return questionToken(QuestionAutomation, strconv.FormatUint(event.Automation.ID, 10)), true + case EventKeepRunning: + // A restated card carries its answer and asks nothing ([KeepNotice]). + if event.Keep == nil || event.Keep.ID == 0 || event.Keep.Decided != "" || event.Keep.Withdrawn != "" { + return "", false + } + return questionToken(QuestionKeep, strconv.FormatUint(event.Keep.ID, 10)), true + case EventHarnessOffer: if event.ID == 0 { return "", false @@ -2231,6 +2242,14 @@ func (a *Agent) applyToLane(answer Answer) error { case QuestionFuel: _, err := a.ResolveOrchestrate(answer.Ref, fuelAnswer(key, words)) return err + case QuestionKeep: + // A KEEP-RUNNING CARD IS ANSWERED BY ITS KEY AND NOTHING ELSE: words are + // not a policy, and a person who wants something different says so in + // a message once the card is answered. + if !a.ResolveKeep(answer.ID, key) { + return errAnswerEmpty + } + return nil } return errAnswerUnknownLane } @@ -2401,6 +2420,10 @@ func (a *Agent) OpenQuestions() []Question { for id := range a.automationAnswers { automations = append(automations, id) } + keeps := make([]KeepNotice, 0, len(a.keepAsks)) + for _, ask := range a.keepAsks { + keeps = append(keeps, ask.notice) + } proposals := make(map[uint64]TaskNotice, len(a.taskAnswers)) for id, proposal := range a.taskAnswers { if proposal != nil { @@ -2436,6 +2459,9 @@ func (a *Agent) OpenQuestions() []Question { for _, id := range consent { open = append(open, a.consentQuestion(id)) } + for _, notice := range keeps { + open = append(open, KeepQuestion(notice)) + } for id, ask := range connects { open = append(open, a.connectQuestion(id, ask)) } diff --git a/internal/session/session.go b/internal/session/session.go index c6571c5c5d..25eec109cc 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -39,6 +39,7 @@ import ( "github.com/Agent-Field/codeaf/internal/provider" "github.com/Agent-Field/codeaf/internal/roles" "github.com/Agent-Field/codeaf/internal/search" + "github.com/Agent-Field/codeaf/internal/service" "github.com/Agent-Field/codeaf/internal/store" "github.com/Agent-Field/codeaf/internal/subharness" "github.com/Agent-Field/codeaf/internal/taxonomy" @@ -654,6 +655,12 @@ const ( // A pass whose free rungs did change the conversation ends on // [EventCompacted] instead, with the same Failed reason set on it. EventSummaryFailed + // EventKeepRunning asks the person whether to keep one command running + // after the turn, and, restated with its answer, is the receipt of what + // they said (keep_running.go). Keep carries the card. + // IT IS PINNED AT EventAutomationUpdate + 3, after the two summary + // ordinals above; persisted event numbers must not move. + EventKeepRunning = EventAutomationUpdate + 3 ) // TaskReplyTag is the task identity a surface places beside the answer its @@ -990,6 +997,10 @@ type Event struct { // payload (automation_tool.go). It is nil on every other kind. Automation *AutomationNotice `json:",omitempty"` + // Keep carries one EventKeepRunning's card (keep_running.go). It is nil on + // every other kind. + Keep *KeepNotice `json:",omitempty"` + // Subharness carries one EventSubharnessProposal's intake card // (subharness_contract.go). It is nil on every other kind, and the ID beside // it is the token a surface hands back to [Agent.ResolveSubharness]. @@ -2029,6 +2040,10 @@ type Config struct { // processes are given between SIGTERM and SIGKILL when it closes, in place // of the two seconds a terminal's quit gives them. The desktop app sets it. JobQuitGrace time.Duration + // Services is the door to the processes a person agreed to keep running + // (internal/service), shared by every conversation in this codeaf. Nil is + // off: bash then has no keep_running argument at all (keep_running.go). + Services *service.Supervisor // StandingOrders saves what the `automation` tool proposes as a proposed // standing order before its card is up, so the app can list it and agree // to it from its own window: the card's yes ratifies it and its no deletes @@ -3552,6 +3567,12 @@ type Agent struct { // cards (automation_tool.go). automationAnswers map[uint64]chan AutomationAnswer automationSeq uint64 + // keepAsks and keepSeq are the same wait, for keep-running cards, and + // turnJobs are the jobs a "deny" left to end with the turn that started + // them, by job id and turn (keep_running.go). + keepAsks map[uint64]*keepAsk + keepSeq uint64 + turnJobs map[int]uint64 // questionWords is THE WORDS of the questions this session has put, keyed by // lane and token (question.go's [questionToken]). // diff --git a/internal/session/tools_jobs.go b/internal/session/tools_jobs.go index ba97d32255..698b552a29 100644 --- a/internal/session/tools_jobs.go +++ b/internal/session/tools_jobs.go @@ -185,12 +185,19 @@ const backgroundProperty = `{"type":"boolean","description":"Spawn the command i func (a *Agent) backgroundBash(inner bare.Tool) bare.Tool { return bare.Tool{ Name: inner.Name, - Description: inner.Description + backgroundSentence + timeoutSentence(a.config.BashBackgroundAfterSeconds), - Schema: schemaWithBackground(inner.Schema), + Description: inner.Description + backgroundSentence + timeoutSentence(a.config.BashBackgroundAfterSeconds) + a.keepSentence(), + Schema: a.schemaWithKeep(schemaWithBackground(inner.Schema)), Execute: func(ctx context.Context, args json.RawMessage) (string, bool, error) { var parsed struct { - Command string `json:"command"` - Background bool `json:"background"` + Command string `json:"command"` + Background bool `json:"background"` + KeepRunning *keepRunningArgs `json:"keep_running"` + } + // A command to keep running is its own road: a person is asked + // first, and it is started as a service or not at all + // (keep_running.go). + if err := decodeToolArguments(args, &parsed); err == nil && parsed.KeepRunning != nil { + return a.keepRunning(ctx, parsed.Command, *parsed.KeepRunning) } // A call whose arguments do not parse belongs to bare: it owns the // wording of every other bash error, and a second parser reporting @@ -315,7 +322,7 @@ func (a *Agent) jobsTool() bare.Tool { } switch parsed.Action { case "list": - return a.jobs.list(), false, nil + return a.jobs.list() + a.keptRunningList(), false, nil case "output": if parsed.ID == nil { return "Invalid arguments: id is required for output", true, nil From 8115c329e754cd57b15753567d8884f51dd920f7 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 20:19:35 -0400 Subject: [PATCH 068/459] taskrouting: one task's recorded models, per-role spend, and the person's changes tasks.routing.get reads a task's worker, planner and checker from its own routed crew record: mode, source (auto, saved pin, task pin, one model), used/pending/not-run/not-used, per-role spend and calls, the router's estimate (null when a price is unknown), alternatives, failed routes, and a bounded change history with who, when and why. Older tasks with no routing record read as one model and nothing about planner or checker. Task rows carry a small models chip so the table needs no per-task read. tasks.routing.act changes one task: not started keeps the choice for its routing; running moves a role at its next call, by role, so two roles that share a model can part; finished only reruns. A change over the task cap is refused with the cap it needs unless the person names one at least that high. A pin the router would not seat is refused, never run as Auto. A pinned or one-model role whose route fails is held on its model and the history says so. A role with no eligible model parks the hand-off as a waiting task with the typed refusal; retry starts it, stop ends it. Spend: a role's spend is booked whether or not it has a ceiling, and each call's cost and certainty are booked and checkpointed together. A cost nobody said is counted unknown, never $0; a route priced at nothing and a provider's own cost of 0 are known. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/app.go | 2 + cmd/codeaf/app_test.go | 20 + cmd/codeaf/apptaskrouting.go | 270 +++++++ cmd/codeaf/apptaskrouting_test.go | 99 +++ cmd/codeaf/appwork.go | 5 + internal/appwire/taskrouting.go | 369 +++++++++ internal/appwire/wire.go | 3 + internal/remote/callclass.go | 4 +- internal/remote/server.go | 6 +- internal/remote/wire_app_task.go | 83 ++ internal/session/session.go | 4 + internal/session/spendguard.go | 111 ++- internal/session/task.go | 8 +- internal/session/task_app.go | 10 +- internal/session/task_app_runs.go | 14 + internal/session/taskcrew.go | 92 ++- internal/session/taskcrew_record.go | 48 +- internal/session/taskrouting.go | 1102 ++++++++++++++++++++++++++ internal/session/taskrouting_test.go | 520 ++++++++++++ 19 files changed, 2727 insertions(+), 43 deletions(-) create mode 100644 cmd/codeaf/apptaskrouting.go create mode 100644 cmd/codeaf/apptaskrouting_test.go create mode 100644 internal/appwire/taskrouting.go create mode 100644 internal/session/taskrouting.go create mode 100644 internal/session/taskrouting_test.go diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index 1c6eab2329..0df875485b 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -196,6 +196,8 @@ type appConversation interface { AppTasks() ([]session.AppTaskView, error) AppTask(id string) (session.AppTaskView, bool, error) AppTaskAction(intent session.AppTaskIntent) error + TaskRouting(id string, changes int) (session.TaskRoutingView, error) + TaskRoutingAct(ctx context.Context, id string, act session.TaskRoutingAction) (session.TaskRoutingResult, error) AppQuickTasks() ([]session.AppQuickTaskView, error) AppQuickTaskAction(intent session.AppTaskIntent) ([]string, []string, error) MoveTurnToTask() error diff --git a/cmd/codeaf/app_test.go b/cmd/codeaf/app_test.go index ef9c31a38e..7638b77c01 100644 --- a/cmd/codeaf/app_test.go +++ b/cmd/codeaf/app_test.go @@ -22,6 +22,7 @@ import ( "github.com/Agent-Field/codeaf/internal/appwire" "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/home" "github.com/Agent-Field/codeaf/internal/provider" "github.com/Agent-Field/codeaf/internal/session" @@ -135,6 +136,9 @@ func TestAppCommandAnswersHelloPingCrewsAndUnknownMethod(t *testing.T) { // fakeConversation plays a scripted turn and counts what is asked of it. type fakeConversation struct { mu sync.Mutex + routing session.TaskRoutingView + routingErr error + routingActs []session.TaskRoutingAction script []session.Event hold chan struct{} submits int @@ -278,6 +282,22 @@ func (f *fakeConversation) AppTaskAction(intent session.AppTaskIntent) error { f.intents = append(f.intents, intent) return nil } +func (f *fakeConversation) TaskRouting(id string, changes int) (session.TaskRoutingView, error) { + f.mu.Lock() + defer f.mu.Unlock() + view := f.routing + view.Task = id + return view, nil +} +func (f *fakeConversation) TaskRoutingAct(_ context.Context, id string, act session.TaskRoutingAction) (session.TaskRoutingResult, error) { + f.mu.Lock() + defer f.mu.Unlock() + f.routingActs = append(f.routingActs, act) + if f.routingErr != nil { + return session.TaskRoutingResult{}, f.routingErr + } + return session.TaskRoutingResult{View: f.routing, Applied: []crewroute.Seat{crewroute.Worker}}, nil +} func (f *fakeConversation) AppQuickTasks() ([]session.AppQuickTaskView, error) { f.mu.Lock() defer f.mu.Unlock() diff --git a/cmd/codeaf/apptaskrouting.go b/cmd/codeaf/apptaskrouting.go new file mode 100644 index 0000000000..363566eec9 --- /dev/null +++ b/cmd/codeaf/apptaskrouting.go @@ -0,0 +1,270 @@ +package main + +import ( + "context" + "errors" + "strings" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/crewroute" + "github.com/Agent-Field/codeaf/internal/session" +) + +// ── a task's models (V80): tasks.routing.get and tasks.routing.act ───────── +// +// Both forward to the engine's one task-routing door (internal/session's +// taskrouting.go) and turn its record into the wire's shapes. Nothing here +// decides a model, prices one, or reads today's settings. + +// TaskRouting answers one task's models as recorded. +func (b *appBackend) TaskRouting(id string, changes int) (appwire.TaskRouting, error) { + agent, local, ok := appSplitID(id) + if !ok { + return appwire.TaskRouting{}, appwire.Errorf(appwire.CodeBadParams, "%s is not a task id", id) + } + _, live, err := b.heldLive(agent) + if err != nil { + return appwire.TaskRouting{}, err + } + view, err := live.conn.Conv.TaskRouting(local, changes) + if err != nil { + return appwire.TaskRouting{}, appRoutingError(err) + } + return appRoutingOf(id, view), nil +} + +// ActOnTaskRouting is a person's change to one task's models. A refusal the +// person can act on is an answer, not an error. +func (b *appBackend) ActOnTaskRouting(p appwire.TaskRoutingActParams) (appwire.TaskRoutingActResult, error) { + agent, local, ok := appSplitID(p.ID) + if !ok { + return appwire.TaskRoutingActResult{}, appwire.Errorf(appwire.CodeBadParams, "%s is not a task id", p.ID) + } + _, live, err := b.heldLive(agent) + if err != nil { + return appwire.TaskRoutingActResult{}, err + } + act := session.TaskRoutingAction{Kind: p.Action, Rerun: p.Rerun, RaiseCapUSD: p.RaiseCapUSD, ChangeCount: p.ChangeCount} + if len(p.Picks) > 0 { + act.Picks = map[crewroute.Seat]string{} + for role, ref := range p.Picks { + act.Picks[crewroute.Seat(role)] = appSendOf(ref) + } + } + got, err := live.conn.Conv.TaskRoutingAct(context.Background(), local, act) + var refusal *session.TaskRoutingRefusal + if errors.As(err, &refusal) && appRoutingRefusalAnswered(refusal.Code) { + view, readErr := live.conn.Conv.TaskRouting(local, 0) + if readErr != nil { + return appwire.TaskRoutingActResult{}, appRoutingError(readErr) + } + return appwire.TaskRoutingActResult{Routing: appRoutingOf(p.ID, view), Refused: appRefusedOf(refusal)}, nil + } + if err != nil { + return appwire.TaskRoutingActResult{}, appRoutingError(err) + } + out := appwire.TaskRoutingActResult{Routing: appRoutingOf(p.ID, got.View)} + for _, seat := range got.Applied { + out.Applied = append(out.Applied, string(seat)) + } + if got.Rerun != 0 { + out.RerunTaskID = appTaskID(agent, got.Rerun) + } + return out, nil +} + +// appSendOf is the id the engine routes a model reference by: the model on +// the provider's route when one is named. +func appSendOf(ref *appwire.ModelRef) string { + if ref == nil { + return "" + } + return ref.ID +} + +// appRoutingRefusalAnswered is whether a refusal is the person's to act on. +func appRoutingRefusalAnswered(code string) bool { + switch code { + case session.TaskRoutingNotFound, session.TaskRoutingBadAction, session.TaskRoutingUnavailable: + return false + } + return true +} + +// appRoutingError sorts an engine refusal into the wire's codes. +func appRoutingError(err error) error { + var refusal *session.TaskRoutingRefusal + if errors.As(err, &refusal) { + switch refusal.Code { + case session.TaskRoutingNotFound: + return appwire.Errorf(appwire.CodeNotFound, "%s", refusal.Message) + case session.TaskRoutingBadAction: + return appwire.Errorf(appwire.CodeBadParams, "%s", refusal.Message) + } + return appwire.Errorf(appwire.CodeEngine, "%s", refusal.Message) + } + return appEngineError(err) +} + +func appRefusedOf(r *session.TaskRoutingRefusal) *appwire.TaskRoutingRefused { + out := &appwire.TaskRoutingRefused{Code: r.Code, Message: r.Message, Role: string(r.Seat)} + if r.Code == session.TaskRoutingOverCap { + required, capUSD := r.RequiredUSD, r.CapUSD + out.RequiredCapUSD, out.CapUSD = &required, &capUSD + } + if r.Blocked != nil { + out.Blocked = appBlockedOf(*r.Blocked) + } + return out +} + +func appRef(model, provider string) appwire.ModelRef { + return appwire.ModelRef{ID: model, Provider: strings.ToLower(provider)} +} + +func appRefPtr(model, provider string) *appwire.ModelRef { + if model == "" { + return nil + } + ref := appRef(model, provider) + return &ref +} + +func appBlockedOf(b session.TaskRoutingBlock) *appwire.TaskRoutingWait { + mode := string(b.Mode) + if mode == "" { + mode = appwire.TaskModelsAuto + } + out := &appwire.TaskRoutingWait{Role: string(b.Seat), Mode: mode, Omitted: b.Omitted, Action: b.Action, At: b.At, Rejected: []appwire.TaskModelRejected{}} + for _, r := range b.Rejected { + out.Rejected = append(out.Rejected, appwire.TaskModelRejected{Model: appRef(r.Model, r.Provider), Reason: string(r.Reason)}) + } + return out +} + +// appRoutingOf is the engine's record as the wire's shape. +func appRoutingOf(id string, v session.TaskRoutingView) appwire.TaskRouting { + out := appwire.TaskRouting{ + TaskID: id, State: v.State, Mode: string(v.Mode), Running: v.Running, Settled: v.Settled, + Restored: v.Restored, EstimateUSD: v.EstimateUSD, SpentUSD: v.SpentUSD, Calls: v.Calls, UnknownCalls: v.UnknownCalls, + HelperUSD: v.HelperUSD, Class: v.Class, StoppedAction: v.Stopped, ChangeCount: v.ChangeCount, + } + if v.Effort == string(crewroute.EffortBest) || v.Effort == string(crewroute.EffortCheap) { + out.Effort = v.Effort + } + if v.TaskCapUSD > 0 { + capUSD := v.TaskCapUSD + out.TaskCapUSD = &capUSD + } + if v.Model != "" { + out.Model = appRefPtr(v.Model, "") + } + for _, r := range v.Roles { + role := appwire.TaskRoutingRole{Role: string(r.Seat), Model: appRef(r.Model, r.Provider), Billing: string(r.Kind), Source: r.Source, Status: r.Status, + Quality: r.Quality, EstimateUSD: r.EstimateUSD, SpentUSD: r.SpentUSD, Calls: r.Calls, UnknownCalls: r.UnknownCalls} + for _, alt := range r.Alternatives { + role.Alternatives = append(role.Alternatives, appwire.TaskRoutingOption{Model: appRef(alt.Model, alt.Provider), Billing: string(alt.Kind), Quality: alt.Quality, EstimateUSD: alt.EstimateUSD}) + } + for _, rej := range r.Rejected { + out := appwire.TaskRoutingRejection{Model: appRef(rej.Model, rej.Provider), Reason: rej.Reason} + if !rej.Until.IsZero() { + until := rej.Until + out.Until = &until + } + role.Rejected = append(role.Rejected, out) + } + out.Roles = append(out.Roles, role) + } + for _, w := range v.Warnings { + out.Warnings = append(out.Warnings, appwire.TaskRoutingWarning{Role: string(w.Seat), Kind: string(w.Kind), Model: appRef(w.Model, w.Provider)}) + } + for _, c := range v.Changes { + change := appwire.TaskRoutingChange{At: c.At, Role: string(c.Seat), From: appRefPtr(c.From, c.FromProvider), To: appRefPtr(c.To, c.ToProvider), By: c.By, Kind: c.Kind, Reason: c.Why} + if c.CapUSD > 0 { + capUSD := c.CapUSD + change.CapUSD = &capUSD + } + out.Changes = append(out.Changes, change) + } + if v.Blocked != nil { + out.Blocked = appBlockedOf(*v.Blocked) + } + if len(v.Requested) > 0 { + picks := &appwire.TaskRoutingPicks{} + for seat, model := range v.Requested { + ref := appRef(model, "") + switch seat { + case crewroute.Worker: + picks.Worker = &ref + case crewroute.Planner: + picks.Planner = &ref + case crewroute.Checker: + picks.Checker = &ref + } + } + out.Requested = picks + } + return out +} + +// appModelsChip is a task row's models in brief, from what the engine +// published for it. +func appModelsChip(state string, d *crewroute.Decision, pins map[crewroute.Seat]bool, model string) *appwire.TaskModelsChip { + switch state { + case session.TaskRoutingPicking, session.TaskRoutingBlocked: + return &appwire.TaskModelsChip{State: state} + case session.TaskRoutingRouted: + if d == nil { + return nil + } + default: + if model == "" { + return nil + } + return &appwire.TaskModelsChip{State: session.TaskRoutingSingle, Roles: appwire.TaskRoutingPicks{Worker: appRefPtr(model, "")}, Distinct: 1} + } + mode := string(d.Mode) + if mode == "" { + mode = appwire.TaskModelsAuto + } + chip := &appwire.TaskModelsChip{State: session.TaskRoutingRouted, Mode: mode} + distinct := map[string]bool{} + for _, seat := range crewroute.Seats { + pick := d.Seat(seat) + if pick.Model == "" { + continue + } + distinct[pick.Model] = true + if pick.Pinned || pins[seat] { + chip.Pinned++ + } + ref := appRefPtr(pick.Model, pick.Provider) + switch seat { + case crewroute.Worker: + chip.Roles.Worker = ref + case crewroute.Planner: + chip.Roles.Planner = ref + case crewroute.Checker: + chip.Roles.Checker = ref + } + } + chip.Distinct = len(distinct) + return chip +} + +// appNoticeModels is the chip a live task update carries. +func appNoticeModels(notice *session.TaskNotice, proposed bool) *appwire.TaskModelsChip { + switch { + case proposed: + return appModelsChip(session.TaskRoutingPicking, nil, nil, "") + case notice.Waiting == session.TaskWaitingNoModel: + return appModelsChip(session.TaskRoutingBlocked, nil, nil, "") + case notice.Crew != nil: + var pins map[crewroute.Seat]bool + if notice.CrewState != nil { + pins = notice.CrewState.TaskPins + } + return appModelsChip(session.TaskRoutingRouted, notice.Crew, pins, "") + } + return nil +} diff --git a/cmd/codeaf/apptaskrouting_test.go b/cmd/codeaf/apptaskrouting_test.go new file mode 100644 index 0000000000..67824b1c84 --- /dev/null +++ b/cmd/codeaf/apptaskrouting_test.go @@ -0,0 +1,99 @@ +package main + +import ( + "encoding/json" + "testing" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/crewroute" + "github.com/Agent-Field/codeaf/internal/session" +) + +// tasks.routing.get and .act forward to the engine's one door with the +// agent's own task number and turn its record into the wire's shapes; a +// budget refusal is an answer carrying the cap it needs, never an error. +func TestTaskRoutingReachesTheEngineAndRefusalsAreAnswers(t *testing.T) { + est := 0.12 + conv := &fakeConversation{routing: session.TaskRoutingView{ + State: session.TaskRoutingRouted, Mode: crewroute.ModeAuto, Running: true, EstimateUSD: &est, SpentUSD: 0.07, Calls: 3, UnknownCalls: 1, ChangeCount: 1, + Roles: []session.TaskRoutingRole{ + {Seat: crewroute.Worker, Model: "z-ai/glm-5.3", Provider: "OpenRouter", Kind: crewroute.Metered, Source: session.TaskRoleAuto, Status: session.TaskRoleUsed, SpentUSD: 0.06, Calls: 2}, + {Seat: crewroute.Planner, Model: "ds-flash", Provider: "openrouter", Source: session.TaskRoleAuto, Status: session.TaskRoleUsed}, + {Seat: crewroute.Checker, Model: "ds-flash", Provider: "openrouter", Source: session.TaskRolePinned, Status: session.TaskRoleNotRunYet}, + }, + Changes: []session.TaskCrewChange{{Seat: crewroute.Worker, From: "glm-flash", FromProvider: "openrouter", To: "z-ai/glm-5.3", ToProvider: "openrouter", By: session.TaskCrewByEngine, Kind: session.TaskCrewChangeFallback, Why: "limit"}}, + }} + backend, _, agent := workBackend(t, conv) + id := agent.ID + ":4" + got, err := backend.TaskRouting(id, 20) + if err != nil { + t.Fatal(err) + } + if got.TaskID != id || got.Roles[0].Model.Provider != "openrouter" || got.Roles[0].Source != "auto" || got.Roles[2].Status != "not_run_yet" || *got.EstimateUSD != 0.12 || got.UnknownCalls != 1 { + t.Fatalf("routing %+v", got) + } + if got.Changes[0].From.ID != "glm-flash" || got.Changes[0].By != "engine" || got.Changes[0].Reason != "limit" { + t.Fatalf("change %+v", got.Changes[0]) + } + raw, _ := json.Marshal(got) + for _, key := range []string{`"estimate_usd":0.12`, `"unknown_calls":1`, `"task_cap_usd":null`} { + if !json.Valid(raw) || !contains(string(raw), key) { + t.Fatalf("wire lacks %s: %s", key, raw) + } + } + + conv.mu.Lock() + conv.routingErr = &session.TaskRoutingRefusal{Code: session.TaskRoutingOverCap, Message: "over", RequiredUSD: 3, CapUSD: 2} + conv.mu.Unlock() + answer, err := backend.ActOnTaskRouting(appwire.TaskRoutingActParams{ID: id, Action: appwire.TaskRoutingBest}) + if err != nil || answer.Refused == nil || answer.Refused.Code != "over_cap" || *answer.Refused.RequiredCapUSD != 3 || *answer.Refused.CapUSD != 2 { + t.Fatalf("refusal %+v %v", answer.Refused, err) + } + conv.mu.Lock() + conv.routingErr = nil + conv.mu.Unlock() + answer, err = backend.ActOnTaskRouting(appwire.TaskRoutingActParams{ID: id, Action: appwire.TaskRoutingPick, Picks: map[string]*appwire.ModelRef{"checker": {ID: "anthropic/claude-sonnet-4.6", Provider: "openrouter"}, "planner": nil}, RaiseCapUSD: 3}) + if err != nil || len(answer.Applied) != 1 || answer.Applied[0] != "worker" { + t.Fatalf("applied %+v %v", answer, err) + } + conv.mu.Lock() + act := conv.routingActs[len(conv.routingActs)-1] + conv.mu.Unlock() + if act.Kind != "pick" || act.Picks[crewroute.Checker] != "anthropic/claude-sonnet-4.6" || act.Picks[crewroute.Planner] != "" || act.RaiseCapUSD != 3 { + t.Fatalf("engine was asked %+v", act) + } + conv.mu.Lock() + conv.routingErr = &session.TaskRoutingRefusal{Code: session.TaskRoutingNotFound, Message: "no such task"} + conv.mu.Unlock() + if _, err = backend.ActOnTaskRouting(appwire.TaskRoutingActParams{ID: id, Action: appwire.TaskRoutingAuto}); err == nil { + t.Fatal("not found was answered as a refusal") + } +} + +// The task row carries its models in brief from the published decision. +func TestTheTaskRowCarriesItsModelsChip(t *testing.T) { + d := &crewroute.Decision{Mode: crewroute.ModeAuto, Crew: []crewroute.Pick{ + {Seat: crewroute.Worker, Model: "glm-5.3", Provider: "openrouter"}, + {Seat: crewroute.Planner, Model: "ds-flash", Provider: "openrouter"}, + {Seat: crewroute.Checker, Model: "ds-flash", Provider: "openrouter", Pinned: true}, + }} + chip := appModelsChip(session.TaskRoutingRouted, d, nil, "") + if chip.Mode != "auto" || chip.Distinct != 2 || chip.Pinned != 1 || chip.Roles.Checker.ID != "ds-flash" { + t.Fatalf("chip %+v", chip) + } + if chip := appModelsChip(session.TaskRoutingSingle, nil, nil, "glm-5.3"); chip.State != "single" || chip.Roles.Worker.ID != "glm-5.3" || chip.Roles.Planner != nil { + t.Fatalf("single %+v", chip) + } + if chip := appNoticeModels(&session.TaskNotice{Waiting: session.TaskWaitingNoModel}, false); chip.State != "blocked" { + t.Fatalf("blocked %+v", chip) + } +} + +func contains(s, sub string) bool { + for i := 0; i+len(sub) <= len(s); i++ { + if s[i:i+len(sub)] == sub { + return true + } + } + return false +} diff --git a/cmd/codeaf/appwork.go b/cmd/codeaf/appwork.go index 1a5dd472fa..dd06ed41a3 100644 --- a/cmd/codeaf/appwork.go +++ b/cmd/codeaf/appwork.go @@ -245,6 +245,7 @@ func appTaskOf(agent string, notice *session.TaskNotice, proposal bool, phase st if state == appwire.TaskRunning { task.Line = appTaskLine(notice, phase) } + task.Models = appNoticeModels(notice, task.Proposed) return task, true } @@ -278,6 +279,7 @@ func appTaskOfView(agent string, view session.AppTaskView) appwire.Task { task.State = appwire.TaskFailed } task.Paused = view.State == "paused" || view.Held + task.Models = appModelsChip(view.RoutingState, view.Crew, view.TaskPins, view.Model) task.Model, task.StartedBy = view.Model, view.Origin task.Instructions = redact.Secrets(strings.TrimSpace(view.Brief)) task.CostUSD = task.SpentUSD @@ -316,6 +318,9 @@ func (b *appBackend) taskNotice(id string, live *appLive, notice *session.TaskNo if task.Title == "" { task.Title = before.Title } + if task.Models == nil { + task.Models = before.Models + } if task.Parent == "" { task.Parent = before.Parent } diff --git a/internal/appwire/taskrouting.go b/internal/appwire/taskrouting.go new file mode 100644 index 0000000000..b046af34e6 --- /dev/null +++ b/internal/appwire/taskrouting.go @@ -0,0 +1,369 @@ +package appwire + +import ( + "encoding/json" + "strings" + "time" +) + +// V80: TASK MODELS ON A TASK — which model plans, works and checks one task. +// +// These are facts the engine recorded for the task: the routed picks, how each +// was chosen, what each role spent, every change and who made it. Nothing is +// read from today's settings and nothing here is wording: the app draws its own +// words from these values. A task never changes models by being read. +// +// tasks.routing.get is one task, bounded: at most [TaskRoutingChangesMax] +// changes and [TaskRoutingAlternativesMax] alternatives per role. The task +// list carries only the small [TaskModelsChip] on each row. + +// The routing states of a task. +const ( + TaskRoutingRouted = "routed" + TaskRoutingPicking = "picking" + TaskRoutingBlocked = "blocked" + TaskRoutingSingle = "single" +) + +// The actions tasks.routing.act takes. +const ( + TaskRoutingStronger = "stronger_worker" + TaskRoutingBest = "best" + TaskRoutingPick = "pick" + TaskRoutingAuto = "auto" + TaskRoutingSame = "same" + TaskRoutingRetry = "retry" +) + +// The bounds of one read. +const ( + TaskRoutingChangesDefault = 20 + TaskRoutingChangesMax = 64 + TaskRoutingAlternativesMax = 5 +) + +// TaskRouter is an engine that serves a task's models. It is asserted, so an +// engine without it answers unknown_method. +type TaskRouter interface { + TaskRouting(id string, changes int) (TaskRouting, error) + ActOnTaskRouting(p TaskRoutingActParams) (TaskRoutingActResult, error) +} + +// TaskRouting is one task's models as the engine recorded them. +type TaskRouting struct { + TaskID string `json:"task_id"` + State string `json:"state" enum:"routed,picking,blocked,single"` + // Mode is how the roles were chosen at admission; empty before routing. + Mode string `json:"mode,omitempty" enum:"auto,one"` + Running bool `json:"running"` + Settled bool `json:"settled"` + // Restored is every time the task's picks came back after a restart. + Restored []time.Time `json:"restored"` + Roles []TaskRoutingRole `json:"roles"` + // Model is a single-state task's one recorded model; nothing is known + // about its planner or checker. + Model *ModelRef `json:"model,omitempty"` + // EstimateUSD is the router's own estimate for the crew as it stands, null + // when a role's price is unknown. + EstimateUSD *float64 `json:"estimate_usd"` + // SpentUSD is what calls with a known cost spent; UnknownCalls is how many + // answered calls had no known cost and are NOT in SpentUSD. + SpentUSD float64 `json:"spent_usd"` + Calls int `json:"calls"` + UnknownCalls int `json:"unknown_calls"` + HelperUSD float64 `json:"helper_usd"` + TaskCapUSD *float64 `json:"task_cap_usd"` + // Class is what the router read the task as. + Class string `json:"class,omitempty"` + Effort string `json:"effort,omitempty" enum:"best,cheap"` + Warnings []TaskRoutingWarning `json:"warnings"` + // StoppedAction is the one action a task stopped on when a role had + // nowhere left to go (add credit, reconnect). + StoppedAction string `json:"stopped_action,omitempty"` + Changes []TaskRoutingChange `json:"changes"` + ChangeCount int `json:"change_count"` + Blocked *TaskRoutingWait `json:"blocked,omitempty"` + // Requested is a not-started or waiting task's own per-role choice. + Requested *TaskRoutingPicks `json:"requested,omitempty"` +} + +// TaskRoutingRole is one role of a routed task. +type TaskRoutingRole struct { + Role string `json:"role" enum:"worker,planner,checker"` + Model ModelRef `json:"model"` + Billing string `json:"billing,omitempty" enum:"metered,plan,local,free"` + // Source is how the role got its model: Auto, a saved pin, a pin for this + // task only, or one model for every role. + Source string `json:"source" enum:"auto,pinned,task_pin,one_model"` + // Status is whether the role has answered: pending is a person's change + // not yet answered at the next step. + Status string `json:"status" enum:"used,pending,not_run_yet,not_used"` + Quality float64 `json:"quality"` + EstimateUSD *float64 `json:"estimate_usd"` + SpentUSD float64 `json:"spent_usd"` + Calls int `json:"calls"` + UnknownCalls int `json:"unknown_calls"` + // Alternatives are the models the router lined up behind this role, in + // order; Rejected are routes that failed on this task. + Alternatives []TaskRoutingOption `json:"alternatives"` + Rejected []TaskRoutingRejection `json:"rejected"` +} + +// TaskRoutingOption is one model lined up behind a role. +type TaskRoutingOption struct { + Model ModelRef `json:"model"` + Billing string `json:"billing,omitempty" enum:"metered,plan,local,free"` + Quality float64 `json:"quality"` + EstimateUSD *float64 `json:"estimate_usd"` +} + +// TaskRoutingRejection is one route that failed a role on this task; Reason +// is the failure kind (payment, auth, limit, unavailable, ...). +type TaskRoutingRejection struct { + Model ModelRef `json:"model"` + Reason string `json:"reason"` + Until *time.Time `json:"until,omitempty"` +} + +// TaskRoutingWarning is what was wrong with a role's saved pin at admission. +type TaskRoutingWarning struct { + Role string `json:"role" enum:"worker,planner,checker"` + Kind string `json:"kind" enum:"pin_not_allowed,provider_unavailable"` + Model ModelRef `json:"model"` +} + +// TaskRoutingChange is one change to a task's models. +type TaskRoutingChange struct { + At time.Time `json:"at"` + Role string `json:"role,omitempty" enum:"worker,planner,checker"` + From *ModelRef `json:"from,omitempty"` + To *ModelRef `json:"to,omitempty"` + By string `json:"by" enum:"engine,person"` + Kind string `json:"kind" enum:"fallback,held,stronger_worker,best,pick,auto,cap,restored"` + Reason string `json:"reason,omitempty"` + CapUSD *float64 `json:"cap_usd,omitempty"` +} + +// TaskRoutingWait is why a task waits: a role with no model allowed. +type TaskRoutingWait struct { + Role string `json:"role" enum:"worker,planner,checker"` + Mode string `json:"mode" enum:"auto,one"` + Rejected []TaskModelRejected `json:"rejected"` + Omitted int `json:"omitted"` + Action string `json:"action,omitempty"` + At time.Time `json:"at"` +} + +// TaskModelRejected is one model turned away from a role at admission. +type TaskModelRejected struct { + Model ModelRef `json:"model"` + Reason string `json:"reason" enum:"not_allowed,no_price,provider_unavailable,route_unavailable,cannot_do_role"` +} + +// TaskRoutingPicks is a per-role choice; a role left out is unchanged, and a +// role sent as null is Auto. +type TaskRoutingPicks struct { + Worker *ModelRef `json:"worker,omitempty" nullable:"true"` + Planner *ModelRef `json:"planner,omitempty" nullable:"true"` + Checker *ModelRef `json:"checker,omitempty" nullable:"true"` +} + +// TaskRoutingActParams is tasks.routing.act's params as the engine reads them. +type TaskRoutingActParams struct { + ID string + Action string + // Picks holds a role only when the call names it; nil is Auto. + Picks map[string]*ModelRef + Rerun bool + RaiseCapUSD float64 + ChangeCount *int +} + +// TaskRoutingActWire is tasks.routing.act's params as they travel. +type TaskRoutingActWire struct { + ID string `json:"id"` + Action string `json:"action" enum:"stronger_worker,best,pick,auto,same,retry"` + Picks *TaskRoutingPicks `json:"picks,omitempty"` + Rerun bool `json:"rerun,omitempty"` + RaiseCapUSD *float64 `json:"raise_cap_usd,omitempty"` + ChangeCount *int `json:"change_count,omitempty"` +} + +// TaskRoutingRefused is an action the engine did not take, as an answer +// rather than an error: the app offers what it can do next from Code. +type TaskRoutingRefused struct { + Code string `json:"code" enum:"over_cap,day_cap,no_eligible,not_allowed,route_failed,unchanged,strongest,settled,busy,stale,not_routed"` + Message string `json:"message"` + Role string `json:"role,omitempty" enum:"worker,planner,checker"` + // RequiredCapUSD is the task cap the change needs; CapUSD the cap it met. + RequiredCapUSD *float64 `json:"required_cap_usd,omitempty"` + CapUSD *float64 `json:"cap_usd,omitempty"` + Blocked *TaskRoutingWait `json:"blocked,omitempty"` +} + +// TaskRoutingActResult is tasks.routing.act's answer: the task as it now +// stands, the roles that move at their next step, or the refusal. +type TaskRoutingActResult struct { + Routing TaskRouting `json:"routing"` + Applied []string `json:"applied"` + RerunTaskID string `json:"rerun_task_id,omitempty"` + Refused *TaskRoutingRefused `json:"refused,omitempty"` +} + +// TaskRoutingResult is tasks.routing.get's answer. +type TaskRoutingResult struct { + Routing TaskRouting `json:"routing"` +} + +// TaskModelsChip is the task list's small summary of a task's models: its +// state, mode and each role's model, read from the row the engine published. +type TaskModelsChip struct { + State string `json:"state" enum:"routed,picking,blocked,single"` + Mode string `json:"mode,omitempty" enum:"auto,one"` + Roles TaskRoutingPicks `json:"roles"` + // Pinned counts roles a person pinned; Distinct the distinct models. + Pinned int `json:"pinned"` + Distinct int `json:"distinct"` +} + +func init() { + methods["tasks.routing.get"] = typed(func(e Engine, p struct { + ID string `json:"id"` + Changes int `json:"changes"` + }) (*TaskRoutingResult, error) { + r, err := taskRouter(e, "tasks.routing.get") + if err != nil { + return nil, err + } + id, err := required("id", p.ID) + if err != nil { + return nil, err + } + if p.Changes < 0 || p.Changes > TaskRoutingChangesMax { + return nil, Errorf(CodeBadParams, "changes is 1 to %d", TaskRoutingChangesMax) + } + if p.Changes == 0 { + p.Changes = TaskRoutingChangesDefault + } + got, err := r.TaskRouting(id, p.Changes) + if err != nil { + return nil, err + } + return &TaskRoutingResult{normalTaskRouting(got)}, nil + }) + methods["tasks.routing.act"] = shaped[TaskRoutingActWire](func(e Engine, raw json.RawMessage) (*TaskRoutingActResult, error) { + r, err := taskRouter(e, "tasks.routing.act") + if err != nil { + return nil, err + } + p, err := decodeTaskRoutingAct(raw) + if err != nil { + return nil, err + } + got, err := r.ActOnTaskRouting(p) + if err != nil { + return nil, err + } + got.Routing = normalTaskRouting(got.Routing) + got.Applied = nonNil(got.Applied) + return &got, nil + }) +} + +func taskRouter(e Engine, name string) (TaskRouter, error) { + r, ok := e.(TaskRouter) + if !ok { + return nil, Errorf(CodeUnknownMethod, "this engine does not serve %s", name) + } + return r, nil +} + +// normalTaskRouting answers empty lists, never null. +func normalTaskRouting(t TaskRouting) TaskRouting { + t.Restored, t.Roles, t.Warnings, t.Changes = nonNil(t.Restored), nonNil(t.Roles), nonNil(t.Warnings), nonNil(t.Changes) + for i := range t.Roles { + t.Roles[i].Alternatives, t.Roles[i].Rejected = nonNil(t.Roles[i].Alternatives), nonNil(t.Roles[i].Rejected) + } + if t.Blocked != nil { + t.Blocked.Rejected = nonNil(t.Blocked.Rejected) + } + return t +} + +// decodeTaskRoutingAct reads tasks.routing.act's params: an id, one action, +// picks only for pick, nothing it does not know. +func decodeTaskRoutingAct(raw json.RawMessage) (TaskRoutingActParams, error) { + var fields map[string]json.RawMessage + if err := decode(raw, &fields); err != nil { + return TaskRoutingActParams{}, err + } + var p TaskRoutingActParams + for name, value := range fields { + switch name { + case "id": + if json.Unmarshal(value, &p.ID) != nil { + return p, Errorf(CodeBadParams, "id is a task id") + } + case "action": + if json.Unmarshal(value, &p.Action) != nil { + return p, Errorf(CodeBadParams, "action is a word") + } + case "picks": + var picks map[string]json.RawMessage + if json.Unmarshal(value, &picks) != nil || picks == nil { + return p, Errorf(CodeBadParams, "picks is {worker?, planner?, checker?}") + } + p.Picks = map[string]*ModelRef{} + for role, pick := range picks { + if !taskModelRole(role) { + return p, Errorf(CodeBadParams, "picks: %q is not a role · worker, planner or checker", role) + } + if string(pick) == "null" { + p.Picks[role] = nil + continue + } + var ref ModelRef + if json.Unmarshal(pick, &ref) != nil { + return p, Errorf(CodeBadParams, "picks.%s is {id, provider?} or null", role) + } + ref, err := checkModelRef("picks."+role, ref) + if err != nil { + return p, err + } + p.Picks[role] = &ref + } + case "rerun": + if json.Unmarshal(value, &p.Rerun) != nil { + return p, Errorf(CodeBadParams, "rerun is true or false") + } + case "raise_cap_usd": + if json.Unmarshal(value, &p.RaiseCapUSD) != nil || p.RaiseCapUSD <= 0 || p.RaiseCapUSD > 1e6 { + return p, Errorf(CodeBadParams, "raise_cap_usd is a dollar figure above zero") + } + case "change_count": + var n int + if json.Unmarshal(value, &n) != nil || n < 0 { + return p, Errorf(CodeBadParams, "change_count is the count tasks.routing.get answered") + } + p.ChangeCount = &n + default: + return p, Errorf(CodeBadParams, "tasks.routing.act has no %s", name) + } + } + if p.ID = strings.TrimSpace(p.ID); p.ID == "" { + return p, Errorf(CodeBadParams, "tasks.routing.act needs an id") + } + switch p.Action { + case TaskRoutingStronger, TaskRoutingBest, TaskRoutingAuto, TaskRoutingSame, TaskRoutingRetry: + if p.Picks != nil { + return p, Errorf(CodeBadParams, "picks go with action pick") + } + case TaskRoutingPick: + if len(p.Picks) == 0 { + return p, Errorf(CodeBadParams, "pick needs picks for at least one role") + } + default: + return p, Errorf(CodeBadParams, "action is stronger_worker, best, pick, auto, same or retry") + } + return p, nil +} diff --git a/internal/appwire/wire.go b/internal/appwire/wire.go index fdc95c5980..9fd0488b96 100644 --- a/internal/appwire/wire.go +++ b/internal/appwire/wire.go @@ -180,6 +180,9 @@ type Task struct { Children *int `json:"children,omitempty"` ChildrenDone *int `json:"children_done,omitempty"` FinishedAt *time.Time `json:"finished_at,omitempty"` + // Models is the task's models in brief (V80); tasks.routing.get has the + // rest. Absent from an engine that does not route tasks. + Models *TaskModelsChip `json:"models,omitempty"` } // The two kinds of ask, and who closed one. diff --git a/internal/remote/callclass.go b/internal/remote/callclass.go index 1744c8e06e..d1707af474 100644 --- a/internal/remote/callclass.go +++ b/internal/remote/callclass.go @@ -146,7 +146,7 @@ const lateCallTail = " did not answer in time" func classify(method string) callClass { switch method { - case MethodAppTasks, MethodAppTask, MethodAppQuickTasks, MethodPing, + case MethodAppTasks, MethodAppTask, MethodAppTaskRouting, MethodAppQuickTasks, MethodPing, MethodModel, MethodTitle, MethodUsage, MethodContextTokens, MethodTranscript, MethodEarlier, MethodRewindPoints, MethodPlanSpend, MethodPlanTasks, MethodPlanTaskPage, MethodPlanTaskWork, MethodPlanRunSummary, MethodRefreshRunSummary, @@ -170,7 +170,7 @@ func classify(method string) callClass { MethodAutomationsList, MethodAutomationsRuns, MethodAutomationsChanges, MethodAutomationsCursor, MethodAutomationsActive, MethodAutomationsWindows: return classGetter - case MethodAppTaskAction, MethodAppQuickTaskAction, MethodAppMoveToTask, MethodAppResumeAfterQuit, MethodAppRename, MethodProcessRerun, MethodQuestionResolve, MethodQuestionHold, + case MethodAppTaskRoutingAct, MethodAppTaskAction, MethodAppQuickTaskAction, MethodAppMoveToTask, MethodAppResumeAfterQuit, MethodAppRename, MethodProcessRerun, MethodQuestionResolve, MethodQuestionHold, MethodConsent, MethodConsentRemember, MethodHarness, MethodConnect, MethodConnectKey, MethodNoteConnected, diff --git a/internal/remote/server.go b/internal/remote/server.go index 03efb80187..5ae098b718 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -2098,7 +2098,8 @@ 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, MethodSubmit, MethodFollowUp, MethodUnqueueFollowUp, MethodSteer, MethodQuestionReplace, MethodSubmitImage, MethodSubmitFiles, - MethodTaskSteer, MethodTaskStop, MethodTaskRetry, MethodProcessRerun, MethodAppTaskAction, MethodAppQuickTaskAction, MethodAppMoveToTask, MethodAppResumeAfterQuit, MethodAppRename: + MethodTaskSteer, MethodTaskStop, MethodTaskRetry, MethodProcessRerun, MethodAppTaskAction, MethodAppQuickTaskAction, MethodAppMoveToTask, MethodAppResumeAfterQuit, MethodAppRename, + MethodAppTaskRoutingAct: if err := s.mayDrive(); err != nil { return nil, err } @@ -2107,7 +2108,8 @@ func (s *server) invoke(call Frame) (out json.RawMessage, err error) { switch call.Method { case MethodProcessList, MethodProcessRerun: return serveAppProcesses(agent, call) - case MethodAppTasks, MethodAppTask, MethodAppTaskAction, MethodAppQuickTasks, MethodAppQuickTaskAction, MethodAppMoveToTask, MethodAppResumeAfterQuit, MethodAppRename: + case MethodAppTasks, MethodAppTask, MethodAppTaskAction, MethodAppQuickTasks, MethodAppQuickTaskAction, MethodAppMoveToTask, MethodAppResumeAfterQuit, MethodAppRename, + MethodAppTaskRouting, MethodAppTaskRoutingAct: return dispatchAppTask(agent, call) case MethodTaskRetry: args, err := arg[TaskSetupArgs](call) diff --git a/internal/remote/wire_app_task.go b/internal/remote/wire_app_task.go index cd3a083099..d660dbbd23 100644 --- a/internal/remote/wire_app_task.go +++ b/internal/remote/wire_app_task.go @@ -1,6 +1,7 @@ package remote import ( + "context" "encoding/json" "errors" @@ -23,8 +24,31 @@ const ( // MethodAppRename gives the conversation the name a person typed // ([session.Agent.RenameByPerson]). MethodAppRename = "App.Rename" + // MethodAppTaskRouting reads one task's models and MethodAppTaskRoutingAct + // changes them ([session.Agent.TaskRouting], [session.Agent.TaskRoutingAct]). + MethodAppTaskRouting = "App.TaskRouting" + MethodAppTaskRoutingAct = "App.TaskRoutingAct" ) +// appRoutingDoor is the conversation whose tasks' models can be read and moved. +type appRoutingDoor interface { + TaskRouting(id string, changes int) (session.TaskRoutingView, error) + TaskRoutingAct(ctx context.Context, id string, act session.TaskRoutingAction) (session.TaskRoutingResult, error) +} + +// AppTaskRoutingArgs is one task's routing read or change. +type AppTaskRoutingArgs struct { + ID string `json:"id"` + Changes int `json:"changes,omitempty"` + Act *session.TaskRoutingAction `json:"act,omitempty"` +} + +// appRoutingActResult carries a typed refusal across the socket intact. +type appRoutingActResult struct { + Result session.TaskRoutingResult + Refusal *session.TaskRoutingRefusal `json:",omitempty"` +} + // appRenameDoor is the conversation a person can rename. type appRenameDoor interface{ RenameByPerson(title string) error } @@ -56,6 +80,30 @@ func dispatchAppTask(agent any, call Frame) (json.RawMessage, error) { return nil, errors.New("engine: app tasks are unavailable") } switch call.Method { + case MethodAppTaskRouting, MethodAppTaskRoutingAct: + routing, ok := agent.(appRoutingDoor) + if !ok { + return nil, errors.New("engine: task models are unavailable") + } + args, err := arg[AppTaskRoutingArgs](call) + if err != nil { + return nil, err + } + if call.Method == MethodAppTaskRouting { + view, err := routing.TaskRouting(args.ID, args.Changes) + if err != nil { + return appRoutingFailure(err) + } + return json.Marshal(appRoutingActResult{Result: session.TaskRoutingResult{View: view}}) + } + if args.Act == nil { + return nil, errors.New("engine: a task models change needs an action") + } + result, err := routing.TaskRoutingAct(context.Background(), args.ID, *args.Act) + if err != nil { + return appRoutingFailure(err) + } + return json.Marshal(appRoutingActResult{Result: result}) case MethodAppRename: rename, ok := agent.(appRenameDoor) if !ok { @@ -176,3 +224,38 @@ func (a *Agent) RenameByPerson(title string) error { _, err := a.c.call(nil, MethodAppRename, AppRenameArgs{Title: title}) return err } + +// appRoutingFailure answers a typed refusal as a value, any other error as one. +func appRoutingFailure(err error) (json.RawMessage, error) { + var refusal *session.TaskRoutingRefusal + if errors.As(err, &refusal) { + return json.Marshal(appRoutingActResult{Refusal: refusal}) + } + return nil, err +} + +func (a *Agent) routingCall(method string, args AppTaskRoutingArgs) (session.TaskRoutingResult, error) { + raw, err := a.c.call(nil, method, args) + if err != nil { + return session.TaskRoutingResult{}, err + } + var result appRoutingActResult + if err := json.Unmarshal(raw, &result); err != nil { + return session.TaskRoutingResult{}, err + } + if result.Refusal != nil { + return session.TaskRoutingResult{}, result.Refusal + } + return result.Result, nil +} + +// TaskRouting reads one task's models. +func (a *Agent) TaskRouting(id string, changes int) (session.TaskRoutingView, error) { + result, err := a.routingCall(MethodAppTaskRouting, AppTaskRoutingArgs{ID: id, Changes: changes}) + return result.View, err +} + +// TaskRoutingAct is a person's change to one task's models. +func (a *Agent) TaskRoutingAct(_ context.Context, id string, act session.TaskRoutingAction) (session.TaskRoutingResult, error) { + return a.routingCall(MethodAppTaskRoutingAct, AppTaskRoutingArgs{ID: id, Act: &act}) +} diff --git a/internal/session/session.go b/internal/session/session.go index 25eec109cc..f4b76ea5b2 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -3552,6 +3552,10 @@ type Agent struct { // crews is every task's crew this conversation routed, by row: what its // log row settles with and what `/redo stronger` escalates (taskcrew.go). crews crewBook + // taskRouting is this conversation's per-task model choices made before a + // task started, and the tasks waiting because a role had no model + // (taskrouting.go). + taskRouting taskRoutingBook // taskAnswers is the proposals a person owes an answer to, keyed by the id // the EventTaskProposal carried. It is consent's pending-id machinery for a // question whose CLOCK can be held: the wait ends on an answer, on an active diff --git a/internal/session/spendguard.go b/internal/session/spendguard.go index 293f81c32e..a25a37775b 100644 --- a/internal/session/spendguard.go +++ b/internal/session/spendguard.go @@ -178,6 +178,9 @@ type SpendGuard struct { mu sync.Mutex modelSpent map[string]float64 seatSpent map[crewroute.Seat]*SpendTask + // bookMu is held across one call's task and role booking, and by a + // checkpoint reading them, so the two always agree. + bookMu sync.Mutex } // SpendTask is what one task has spent and holds in flight, across every @@ -186,6 +189,52 @@ type SpendTask struct { mu sync.Mutex spent float64 held float64 + // calls is every answered call booked here, and unknown those whose cost + // nobody could say — no price for the route and no cost from the provider. + // A known nothing (a free pool, a plan, a provider's own cost of 0) is a + // call with a price; an unknown one is NOT counted as $0 spent. + calls, unknown int +} + +// Counts is how many answered calls were booked and how many of them had no +// known cost. +func (t *SpendTask) Counts() (calls, unknown int) { + if t == nil { + return 0, 0 + } + t.mu.Lock() + defer t.mu.Unlock() + return t.calls, t.unknown +} + +// book releases a hold and books one admitted call in one step: its cost +// and whether that cost is known, so a reader never sees the dollars of a +// call without its certainty or the other way round. +func (t *SpendTask) book(held, usd float64, known bool) { + t.mu.Lock() + defer t.mu.Unlock() + t.held -= held + if t.held < 0 { + t.held = 0 + } + if usd > 0 { + t.spent += usd + } + t.calls++ + if !known { + t.unknown++ + } +} + +// snapshot is what was spent and how many calls, and how many of unknown +// cost, read together. +func (t *SpendTask) snapshot() (spent float64, calls, unknown int) { + if t == nil { + return 0, 0, 0 + } + t.mu.Lock() + defer t.mu.Unlock() + return t.spent, t.calls, t.unknown } // Total is what the task has spent. @@ -361,29 +410,49 @@ func (g *SpendGuard) after(ctx context.Context, model string, response *ai.Respo return } task := g.tally() - if response == nil || response.Usage == nil { - g.Day.settle(model, held, 0) - task.settle(held, 0) - if seat := crewSeatOf(ctx); g.SeatCeilings[seat] > 0 { - g.seatTally(seat).settle(held, 0) - } - return - } - usd := 0.0 - if response.Usage.Cost != nil { - usd = *response.Usage.Cost - } else if g.Price != nil { - if prompt, completion, cacheRead, ok := g.Price(model); ok { - cached := float64(response.Usage.CacheReadTokens()) - usd = (float64(response.Usage.PromptTokens)-cached)*prompt + cached*cacheRead + - float64(response.Usage.CompletionTokens)*completion - } + // A SEAT'S SPEND IS BOOKED WHETHER OR NOT IT HAS A CEILING: the ceiling + // decides what a call may hold beforehand, never whether what it cost is + // counted against the role that made it. + seat := crewSeatOf(ctx) + seatHeld := 0.0 + if g.SeatCeilings[seat] > 0 { + seatHeld = held + } + // WHAT A CALL COST IS KNOWN ONLY WHEN SOMEBODY SAID IT: the provider's own + // cost (0 included), or this route's published price with the usage it + // reported. A route priced at nothing is known free with or without usage. + // An answer with no usage on a priced or unpriced route, and a call that + // failed, cost something nobody said; it is counted as unknown, never $0. + usd, known := 0.0, false + prompt, completion, cacheRead, priced := 0.0, 0.0, 0.0, false + if g.Price != nil { + prompt, completion, cacheRead, priced = g.Price(model) + } + free := priced && prompt <= 0 && completion <= 0 && cacheRead <= 0 + switch { + case response != nil && response.Usage != nil && response.Usage.Cost != nil: + usd, known = *response.Usage.Cost, true + case response != nil && response.Usage != nil && priced: + cached := float64(response.Usage.CacheReadTokens()) + usd = (float64(response.Usage.PromptTokens)-cached)*prompt + cached*cacheRead + + float64(response.Usage.CompletionTokens)*completion + known = true + case free: + known = true } g.Day.settle(model, held, usd) - task.settle(held, usd) - if seat := crewSeatOf(ctx); g.SeatCeilings[seat] > 0 { - g.seatTally(seat).settle(held, usd) - } + // The seat's tally is fetched before bookMu: a checkpoint takes g.mu and + // then bookMu, so this side never takes them the other way round. + var role *SpendTask + if seat != "" { + role = g.seatTally(seat) + } + g.bookMu.Lock() + task.book(held, usd, known) + if role != nil { + role.book(seatHeld, usd, known) + } + g.bookMu.Unlock() if usd <= 0 { return } diff --git a/internal/session/task.go b/internal/session/task.go index b5c925c4a3..263851552b 100644 --- a/internal/session/task.go +++ b/internal/session/task.go @@ -917,7 +917,7 @@ func (p *stagedProposal) Commit(ctx context.Context) (string, bool, error) { // and why ([runDidNotStart]), and reads as a failure. Only a run road that // is not there at all (no engine linked, no place for a store) leaves this // door for the older one ([Agent.commitProposalToRun]). - ctx = withCrewWish(ctx, crewWish{effort: spec.crewEffort, worker: namedModel(spec)}) + ctx = withCrewWish(ctx, a.proposalCrewWish(p.id, crewWish{effort: spec.crewEffort, worker: namedModel(spec)})) if answer, refused, handled := a.commitProposalToRun(ctx, p, spec, elsewhere); handled { return answer, refused, nil } @@ -993,6 +993,12 @@ func (a *Agent) commitProposalToRun(ctx context.Context, p *stagedProposal, spec } joined, err := a.startOrJoinTaskRunVia(context.WithoutCancel(ctx), p.id, spec.title, description, spec.dependsOn, stand, question, via, asked...) a.rollbackFailedProgramStart(via, p.id, prior, err) + // A ROLE WITH NOTHING ALLOWED TO RUN ON IS A QUESTION, NOT A FAILURE: the + // task waits, asks, and starts from here once a model is allowed + // (taskrouting.go). Nothing ran and nothing was moved onto another model. + if receipt, parked := a.parkUnroutedHandoff(ctx, p, spec, elsewhere, via, err); parked { + return receipt, false, true + } // AN APPROVED HAND-OFF THAT DID NOT START IS SETTLED WHERE IT IS SEEN. The // card's last word was the approval ([taskWait.decided]), which every // surface reads as work waiting to begin; the refusal below reaches only the diff --git a/internal/session/task_app.go b/internal/session/task_app.go index 0092723aa0..501c1359d7 100644 --- a/internal/session/task_app.go +++ b/internal/session/task_app.go @@ -9,6 +9,7 @@ import ( "time" "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/crewroute" ) // AppTaskView is an engine read, independent of the app's JSON contract. @@ -25,6 +26,11 @@ type AppTaskView struct { WorkLog []string Elapsed int64 Version uint64 + // RoutingState and Crew are the task's models in brief (taskrouting.go): + // the published decision, never a settings read. + RoutingState string + Crew *crewroute.Decision + TaskPins map[crewroute.Seat]bool } type AppTaskNote struct { Direction uint64 `json:"direction,omitempty"` @@ -204,7 +210,7 @@ func (a *Agent) appGraphTasks(out []AppTaskView, cap float64, ids map[string]boo notice := node.notice() _, live, _ := a.taskLiveRead(node.id, taskLiveMaxTail) graph.mu.Lock() - view := AppTaskView{ID: strconv.FormatUint(node.id, 10), Title: notice.Title, Brief: notice.Brief, State: appNodeState(node), Model: notice.Model, Cap: cap, Spent: notice.CostUSD, Step: live.Steps, Result: notice.Report, Files: append([]string{}, notice.Changed...), Notes: node.appNotesLocked(), WorkLog: live.Lines, Elapsed: int64(notice.Elapsed.Seconds()), Version: node.app.Version + 1, Origin: "proposed"} + view := AppTaskView{RoutingState: TaskRoutingSingle, ID: strconv.FormatUint(node.id, 10), Title: notice.Title, Brief: notice.Brief, State: appNodeState(node), Model: notice.Model, Cap: cap, Spent: notice.CostUSD, Step: live.Steps, Result: notice.Report, Files: append([]string{}, notice.Changed...), Notes: node.appNotesLocked(), WorkLog: live.Lines, Elapsed: int64(notice.Elapsed.Seconds()), Version: node.app.Version + 1, Origin: "proposed"} if node.app.Brief != "" { view.Brief = node.app.Brief } @@ -623,7 +629,7 @@ func (a *Agent) appProposalViews(cap float64) []AppTaskView { if question.held { state = "held" } - view := AppTaskView{ID: strconv.FormatUint(id, 10), Title: notice.Title, Brief: notice.Brief, State: state, Model: notice.Model, Deadline: notice.Deadline, Held: question.held, Cap: cap, Origin: "proposed", Version: question.appVersion + 1} + view := AppTaskView{RoutingState: TaskRoutingPicking, ID: strconv.FormatUint(id, 10), Title: notice.Title, Brief: notice.Brief, State: state, Model: notice.Model, Deadline: notice.Deadline, Held: question.held, Cap: cap, Origin: "proposed", Version: question.appVersion + 1} for _, depends := range notice.DependsOn { view.Waits = append(view.Waits, strconv.FormatUint(depends, 10)) } diff --git a/internal/session/task_app_runs.go b/internal/session/task_app_runs.go index b1ec967d1e..08e76183f5 100644 --- a/internal/session/task_app_runs.go +++ b/internal/session/task_app_runs.go @@ -98,6 +98,17 @@ func (a *Agent) appRunView(row TaskNotice, cap float64) AppTaskView { view.Waits = append(view.Waits, strconv.FormatUint(id, 10)) } a.appRunDetail(&view, appRunRowKey(row)) + switch { + case a.taskRouting.isParked(row.ID): + view.RoutingState = TaskRoutingBlocked + case row.Crew != nil: + view.RoutingState, view.Crew = TaskRoutingRouted, row.Crew + if row.CrewState != nil { + view.TaskPins = row.CrewState.TaskPins + } + default: + view.RoutingState = TaskRoutingSingle + } return view } @@ -182,6 +193,9 @@ func (a *Agent) appRunAction(row TaskNotice, intent AppTaskIntent) error { _, err := a.SteerTask(row.ID, intent.Text) return err case "stop": + if a.DropParkedTask(row.ID) { + return nil + } _, err := a.cancelTask(row.ID, "") return err case "pause", "resume": diff --git a/internal/session/taskcrew.go b/internal/session/taskcrew.go index a7fa5eb51b..2ccb532eea 100644 --- a/internal/session/taskcrew.go +++ b/internal/session/taskcrew.go @@ -52,13 +52,18 @@ type crewWish struct { // worker is a model the hand-off named for this task, which seats the // worker as a one-task pin ([Agent.routeTaskCrew]). worker string + // sends are one-task pins a person chose per role for this task — a rerun + // from Pick models — laid over the worker named above (taskrouting.go). + sends map[crewroute.Seat]string + // taskCap is a task cap a person explicitly raised for this one task. + taskCap float64 } type crewWishKey struct{} // withCrewWish hands a start door the crew wish; an empty wish is no value. func withCrewWish(ctx context.Context, wish crewWish) context.Context { - if wish.effort == "" && wish.stronger == nil && wish.again == nil && wish.worker == "" { + if wish.effort == "" && wish.stronger == nil && wish.again == nil && wish.worker == "" && len(wish.sends) == 0 && wish.taskCap == 0 { return ctx } return context.WithValue(ctx, crewWishKey{}, wish) @@ -121,6 +126,21 @@ type taskCrew struct { // this task's line through the day's own figure. day *SpendDay dayAtStart float64 + + // changes are this task's model changes, oldest first and bounded + // ([taskCrewChangesKept]); changeCount counts every one ever written. + changes []TaskCrewChange + changeCount int + // taskPins are the roles a person pinned for this task alone; pending + // are roles whose person-made change has not answered a call yet. + taskPins map[crewroute.Seat]bool + pending map[crewroute.Seat]bool + // restored is every time this crew came back from its checkpoint. + restored []time.Time + // seatSends are roles a person moved on this task, by role rather than by + // the send id the engine asks for, so two roles that share a model can + // part ([Agent.TaskRoutingAct]). + seatSends map[crewroute.Seat]string } // current is the crew as it stands, fallbacks taken. @@ -196,6 +216,14 @@ func (a *Agent) routeTaskCrew(ctx context.Context, row uint64, title, brief stri if worker := a.namedTaskWorker(wish.worker); worker != "" { ask.Sends = map[crewroute.Seat]string{crewroute.Worker: worker} } + for seat, send := range wish.sends { + if send = strings.TrimSpace(send); send != "" { + if ask.Sends == nil { + ask.Sends = map[crewroute.Seat]string{} + } + ask.Sends[seat] = send + } + } decision, err := route(ask) if errors.Is(err, config.ErrCrewAtCap) { spent, capUSD := config.CrewHistory(a.config.ProfileDir).SpentUSD, config.CrewCapAt(a.config.ProfileDir) @@ -214,10 +242,23 @@ func (a *Agent) routeTaskCrew(ctx context.Context, row uint64, title, brief stri crew.original[pick.Seat] = pick.Send } crew.rescue = map[crewroute.Seat][]crewroute.Pick{} - for _, seat := range crewroute.Seats { - crew.rescue[seat] = config.CrewRescue(a.config.ProfileDir, decision.Class, seat, a.Model()) + // ONE MODEL FOR EVERY ROLE IS THE PERSON'S CHOICE, and no role moves off + // it on its own: no rescue rung is kept for a decision admitted in it. + if decision.Mode != crewroute.ModeOne { + for _, seat := range crewroute.Seats { + crew.rescue[seat] = config.CrewRescue(a.config.ProfileDir, decision.Class, seat, a.Model()) + } + } + for seat := range wish.sends { + if crew.taskPins == nil { + crew.taskPins = map[crewroute.Seat]bool{} + } + crew.taskPins[seat] = true } crew.guard = crewSpendGuard(a.config.ProfileDir, decision, true) + if wish.taskCap > crew.guard.TaskCap && crew.guard.TaskCap > 0 { + crew.guard.TaskCap = wish.taskCap + } crew.guard.Day = a.crewDay() crew.day, crew.dayAtStart = a.crewDay(), a.crewDay().Total() a.crews.put(row, crew) @@ -391,8 +432,9 @@ func (c crewSeatCompleter) CompleteWithMessages(ctx context.Context, messages [] func (c crewSeatCompleter) completeSeat(ctx context.Context, key string, messages []ai.Message, options []ai.Option) (*ai.Response, error) { crew := c.run.crew retried := false + seat := crewSeatOf(ctx) for { - current := crew.sendFor(key) + current := crew.sendForSeat(seat, key) if failed, ok := crew.failedHere(current); ok { // A SEND THAT FAILED ON THIS TASK IS NOT ASKED AGAIN, by this seat // or any other: a pool that answered 429 with an hour's @@ -764,6 +806,18 @@ func (c *taskCrew) sendFor(key string) string { return key } +// sendForSeat is [taskCrew.sendFor] for a call a role made: where a person +// moved that role, else where its send id has moved. +func (c *taskCrew) sendForSeat(seat crewroute.Seat, key string) string { + c.mu.Lock() + to := c.seatSends[seat] + c.mu.Unlock() + if to != "" && seat != "" && c.original[seat] == key { + return to + } + return c.sendFor(key) +} + // hasStarted is whether a send id has answered a call on this task. func (c *taskCrew) hasStarted(send string) bool { c.mu.Lock() @@ -792,6 +846,11 @@ func (a *Agent) crewAnswered(run *beltRun, send string) { } crew.started[send] = true decision := crew.decision + for _, seat := range crewroute.Seats { + if decision.Seat(seat).Send == send { + delete(crew.pending, seat) + } + } crew.mu.Unlock() for _, seat := range crewroute.Seats { if pick := decision.Seat(seat); pick.Send == send { @@ -808,9 +867,20 @@ func (a *Agent) moveCrewSeat(run *beltRun, key, current string, kind provider.Ro crew.mu.Lock() provided := a.crewFailedLocked(crew, current, kind, until) decision, next := crew.decision, crewroute.Pick{} + shared := false for _, seat := range crewroute.Seats { pick := decision.Seat(seat) - if pick.Send != current || pick.Pinned || crew.original[seat] != key { + if pick.Send != current || crew.original[seat] != key { + continue + } + if pick.Pinned || decision.Mode == crewroute.ModeOne { + // A PINNED ROLE — a saved pin, a pin for this task, one model for + // every role — HOLDS ITS EXACT MODEL when its route fails. Only a + // person moves it; the history says it failed, when and why. + if !crew.heldLocked(seat, pick.Send, string(kind)) { + crew.noteChangeLocked(TaskCrewChange{At: time.Now(), Seat: seat, From: pick.Model, FromProvider: pick.Provider, FromSend: pick.Send, + By: TaskCrewByEngine, Kind: TaskCrewChangeHeld, Why: string(kind)}) + } continue } rescue := config.CrewRescue(a.config.ProfileDir, decision.Class, seat, a.Model()) @@ -822,6 +892,13 @@ func (a *Agent) moveCrewSeat(run *beltRun, key, current string, kind provider.Ro continue } decision = withFreeNotice(decision.WithRung(seat, rung, crewWhy(kind, provided)), rung) + crew.noteChangeLocked(TaskCrewChange{Seat: seat, From: pick.Model, FromProvider: pick.Provider, FromSend: pick.Send, To: rung.Model, ToProvider: rung.Provider, ToSend: rung.Send, + By: TaskCrewByEngine, Kind: TaskCrewChangeFallback, Why: string(kind)}) + if crew.seatSends[seat] != "" { + crew.seatSends[seat] = rung.Send + } else { + shared = true + } if next.Send == "" { next = rung } @@ -845,7 +922,10 @@ func (a *Agent) moveCrewSeat(run *beltRun, key, current string, kind provider.Ro if crew.swaps == nil { crew.swaps = map[string]string{} } - crew.swaps[key], crew.decision = next.Send, decision + if shared { + crew.swaps[key] = next.Send + } + crew.decision = decision crew.mu.Unlock() a.republishCrew(run, decision, fmt.Sprintf("%s could not start (%s) · moved to %s", current, crewWhy(kind, provided), next.Send)) return "", true diff --git a/internal/session/taskcrew_record.go b/internal/session/taskcrew_record.go index 5032f6e6a4..369380a594 100644 --- a/internal/session/taskcrew_record.go +++ b/internal/session/taskcrew_record.go @@ -32,6 +32,16 @@ type TaskCrewRecord struct { HelperUSD, DayAtStart float64 Guard *crewGuardRecord InFlight int + // Changes are the task's model changes, bounded; ChangeCount counts them + // all. TaskPins and Pending are the roles a person pinned for this task + // and those whose change has not answered yet. Restored is every time the + // crew came back from this checkpoint. All are absent on older rows. + Changes []TaskCrewChange `json:",omitempty"` + ChangeCount int `json:",omitempty"` + TaskPins map[crewroute.Seat]bool `json:",omitempty"` + Pending map[crewroute.Seat]bool `json:",omitempty"` + Restored []time.Time `json:",omitempty"` + SeatSends map[crewroute.Seat]string `json:",omitempty"` } type crewFailureRecord struct { @@ -46,9 +56,13 @@ type crewGuardRecord struct { ModelSpent map[string]float64 TaskSpent float64 SeatSpent map[crewroute.Seat]float64 - Day string - DaySpent float64 - DayLast map[string]float64 + // TaskCalls/TaskUnknown and SeatCalls/SeatUnknown count answered calls + // and those whose cost was unknown; absent on older rows. + TaskCalls, TaskUnknown int `json:",omitempty"` + SeatCalls, SeatUnknown map[crewroute.Seat]int `json:",omitempty"` + Day string + DaySpent float64 + DayLast map[string]float64 } func (a *Agent) unroutedCrewRecord() *TaskCrewRecord { @@ -70,7 +84,8 @@ func (c *taskCrew) record() *TaskCrewRecord { defer c.mu.Unlock() r := &TaskCrewRecord{Version: 1, Routed: true, Call: c.call, Title: c.title, Brief: c.brief, Repo: c.repo, Decision: c.decision, Original: c.original, Ladders: c.ladders, Rescue: c.rescue, Started: c.started, Bad: c.bad, Broke: c.broke, Gone: c.gone, - Swaps: c.swaps, FreeTried: c.freeTried, HelperUSD: c.helperUSD, DayAtStart: c.dayAtStart, InFlight: c.inFlight, Failed: map[string]crewFailureRecord{}} + Swaps: c.swaps, FreeTried: c.freeTried, HelperUSD: c.helperUSD, DayAtStart: c.dayAtStart, InFlight: c.inFlight, Failed: map[string]crewFailureRecord{}, + Changes: c.changes, ChangeCount: c.changeCount, TaskPins: c.taskPins, Pending: c.pending, Restored: c.restored, SeatSends: c.seatSends} for send, f := range c.failed { msg := "" if f.err != nil { @@ -81,10 +96,21 @@ func (c *taskCrew) record() *TaskCrewRecord { if g := c.guard; g != nil { g.mu.Lock() r.Guard = &crewGuardRecord{Cap: g.Cap, TaskCap: g.TaskCap, CapAction: g.CapAction, TaskAction: g.TaskAction, CeilingAction: g.CeilingAction, - SeatCeilings: maps.Clone(g.SeatCeilings), ModelSpent: maps.Clone(g.modelSpent), TaskSpent: g.Task.Total(), SeatSpent: map[crewroute.Seat]float64{}, Day: time.Now().Format("2006-01-02")} + SeatCeilings: maps.Clone(g.SeatCeilings), ModelSpent: maps.Clone(g.modelSpent), SeatSpent: map[crewroute.Seat]float64{}, Day: time.Now().Format("2006-01-02")} + // The task's and the roles' figures are read as one booking left them. + g.bookMu.Lock() + r.Guard.TaskSpent, r.Guard.TaskCalls, r.Guard.TaskUnknown = g.Task.snapshot() for seat, tally := range g.seatSpent { - r.Guard.SeatSpent[seat] = tally.Total() + spent, calls, unknown := tally.snapshot() + r.Guard.SeatSpent[seat] = spent + if calls > 0 { + if r.Guard.SeatCalls == nil { + r.Guard.SeatCalls, r.Guard.SeatUnknown = map[crewroute.Seat]int{}, map[crewroute.Seat]int{} + } + r.Guard.SeatCalls[seat], r.Guard.SeatUnknown[seat] = calls, unknown + } } + g.bookMu.Unlock() if g.Day != nil { g.Day.mu.Lock() g.Day.refreshLocked() @@ -139,7 +165,11 @@ func (a *Agent) restoreTaskCrew(row uint64, r *TaskCrewRecord) (*taskCrew, error } r = &saved c := &taskCrew{call: r.Call, title: r.Title, brief: r.Brief, repo: r.Repo, decision: r.Decision, original: r.Original, ladders: r.Ladders, rescue: r.Rescue, - started: r.Started, bad: r.Bad, broke: r.Broke, gone: r.Gone, swaps: r.Swaps, freeTried: r.FreeTried, helperUSD: r.HelperUSD, dayAtStart: r.DayAtStart, failed: map[string]crewFailure{}} + started: r.Started, bad: r.Bad, broke: r.Broke, gone: r.Gone, swaps: r.Swaps, freeTried: r.FreeTried, helperUSD: r.HelperUSD, dayAtStart: r.DayAtStart, failed: map[string]crewFailure{}, + changes: r.Changes, changeCount: r.ChangeCount, taskPins: r.TaskPins, pending: r.Pending, seatSends: r.SeatSends} + // THE RESTART IS A FACT OF THE TASK, said once in its history and kept: + // the picks below are the ones it was admitted with, not a new decision. + c.noteRestored(a.taskClockNow(), r.Restored) for send, f := range r.Failed { c.failed[send] = crewFailure{kind: f.Kind, until: f.Until, err: errors.New(f.Message)} } @@ -149,9 +179,9 @@ func (a *Agent) restoreTaskCrew(row uint64, r *TaskCrewRecord) (*taskCrew, error restoreCrewDay(day, s) c.day = day c.guard = &SpendGuard{Price: config.CrewCallPriceAt(a.config.ProfileDir), Day: day, Cap: s.Cap, TaskCap: s.TaskCap, CapAction: s.CapAction, TaskAction: s.TaskAction, - CeilingAction: s.CeilingAction, SeatCeilings: s.SeatCeilings, modelSpent: s.ModelSpent, Task: &SpendTask{spent: s.TaskSpent}, seatSpent: map[crewroute.Seat]*SpendTask{}} + CeilingAction: s.CeilingAction, SeatCeilings: s.SeatCeilings, modelSpent: s.ModelSpent, Task: &SpendTask{spent: s.TaskSpent, calls: s.TaskCalls, unknown: s.TaskUnknown}, seatSpent: map[crewroute.Seat]*SpendTask{}} for seat, spent := range s.SeatSpent { - c.guard.seatSpent[seat] = &SpendTask{spent: spent} + c.guard.seatSpent[seat] = &SpendTask{spent: spent, calls: s.SeatCalls[seat], unknown: s.SeatUnknown[seat]} } a.bindCrewCheckpoint(row, c) a.crews.put(row, c) diff --git a/internal/session/taskrouting.go b/internal/session/taskrouting.go new file mode 100644 index 0000000000..0b37559bb7 --- /dev/null +++ b/internal/session/taskrouting.go @@ -0,0 +1,1102 @@ +package session + +import ( + "context" + "errors" + "fmt" + "math" + "strconv" + "strings" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/crewroute" + "github.com/Agent-Field/codeaf/internal/delegate" +) + +// ONE TASK'S MODELS, AS THE ENGINE RECORDED THEM, AND THE ONE DOOR THAT MOVES +// THEM. +// +// A task's worker, planner and checker are its routed crew (taskcrew.go): the +// decision admitted with it, every seat that moved since and why, and what each +// seat has spent. [Agent.TaskRouting] reads exactly that — never today's +// settings, never a guess from the model names — and [Agent.TaskRoutingAct] is +// the only way a person changes it for one task: +// +// - a task not started yet keeps the person's choice and is routed with it +// when it starts; +// - a running task's role moves at its NEXT CALL, never mid-call, and the +// history says who moved it, when, and from what; +// - a finished task is never changed: the same choices start a rerun. +// +// THE ENGINE NEVER CHOOSES A STRONGER OR DEARER MODEL ON ITS OWN. A change +// whose estimate crosses the task cap is refused with the cap it needs, and +// goes ahead only when the person names a cap at least that high for this task. +// +// A ROLE WITH NOTHING ALLOWED TO RUN ON IS A QUESTION. The task is parked +// waiting, with the router's typed refusal, and starts from where it stood when +// a model is allowed and the person says retry; nothing runs in the meantime +// and nothing is moved onto another model. + +// Who moved a role, and how. +const ( + TaskCrewByEngine = "engine" + TaskCrewByPerson = "person" + + TaskCrewChangeFallback = "fallback" + TaskCrewChangeStronger = "stronger_worker" + TaskCrewChangeBest = "best" + TaskCrewChangePick = "pick" + TaskCrewChangeAuto = "auto" + TaskCrewChangeCap = "cap" + TaskCrewChangeRestored = "restored" + // TaskCrewChangeHeld is a pinned role whose route failed and that was + // held on its model rather than moved. + TaskCrewChangeHeld = "held" +) + +// taskCrewChangesKept bounds the history one task carries; ChangeCount still +// counts every change. +const taskCrewChangesKept = 64 + +// taskCrewRestoresKept bounds the restore marks one task carries. +const taskCrewRestoresKept = 16 + +// TaskCrewChange is one change to a task's models: a role that moved, a cap a +// person raised for the task, or the crew coming back after a restart. +type TaskCrewChange struct { + At time.Time + Seat crewroute.Seat `json:",omitempty"` + From string `json:",omitempty"` + FromProvider string `json:",omitempty"` + FromSend string `json:",omitempty"` + To string `json:",omitempty"` + ToProvider string `json:",omitempty"` + ToSend string `json:",omitempty"` + By string + Kind string + // Why is the failure kind for a fallback; empty for a person's change. + Why string `json:",omitempty"` + CapUSD float64 `json:",omitempty"` +} + +// noteChangeLocked appends one change, keeping the bound. The caller holds c.mu. +func (c *taskCrew) noteChangeLocked(change TaskCrewChange) { + if change.At.IsZero() { + change.At = time.Now() + } + c.changes = append(c.changes, change) + if over := len(c.changes) - taskCrewChangesKept; over > 0 { + c.changes = append([]TaskCrewChange(nil), c.changes[over:]...) + } + c.changeCount++ +} + +// heldLocked is whether the role's newest change already says it was held +// on send for the same failure. The caller holds c.mu. +func (c *taskCrew) heldLocked(seat crewroute.Seat, send, why string) bool { + for i := len(c.changes) - 1; i >= 0; i-- { + if change := c.changes[i]; change.Seat == seat { + return change.Kind == TaskCrewChangeHeld && change.FromSend == send && change.Why == why + } + } + return false +} + +// noteRestored marks one restart of the task's crew, durably once persisted. +func (c *taskCrew) noteRestored(at time.Time, prior []time.Time) { + c.mu.Lock() + defer c.mu.Unlock() + c.restored = append(append([]time.Time(nil), prior...), at) + if over := len(c.restored) - taskCrewRestoresKept; over > 0 { + c.restored = c.restored[over:] + } + c.noteChangeLocked(TaskCrewChange{At: at, By: TaskCrewByEngine, Kind: TaskCrewChangeRestored}) +} + +// ── the read ──────────────────────────────────────────────────────────────── + +// The states a task's routing can be in. +const ( + // TaskRoutingRouted is a task with a routed crew. + TaskRoutingRouted = "routed" + // TaskRoutingPicking is a task not started yet: nothing is decided. + TaskRoutingPicking = "picking" + // TaskRoutingBlocked is a task waiting because a role had no model. + TaskRoutingBlocked = "blocked" + // TaskRoutingSingle is a task with no routing record: one recorded model, + // and nothing known about planner or checker. + TaskRoutingSingle = "single" +) + +// The sources of a role's pick. +const ( + TaskRoleAuto = "auto" + TaskRolePinned = "pinned" + TaskRoleTaskPin = "task_pin" + TaskRoleOneModel = "one_model" +) + +// The states of one role. +const ( + TaskRoleUsed = "used" + TaskRolePending = "pending" + TaskRoleNotRunYet = "not_run_yet" + TaskRoleNotUsed = "not_used" +) + +// TaskRoutingView is one task's models as recorded. +type TaskRoutingView struct { + Task string + State string + Mode crewroute.Mode + Running bool + Settled bool + // Restored is every time the crew came back from its checkpoint. + Restored []time.Time + Roles []TaskRoutingRole + // Model is the one recorded model of a [TaskRoutingSingle] task. + Model string + // EstimateUSD is the router's estimate for the crew as it stands; nil + // when a seat's price is unknown. + EstimateUSD *float64 + // SpentUSD is what answered calls with a known cost spent, helpers + // included; Calls and UnknownCalls count the answered calls and those + // whose cost nobody could say, which SpentUSD does not include. + SpentUSD float64 + Calls, UnknownCalls int + HelperUSD float64 + TaskCapUSD float64 + Class, Why, Effort string + Note, Stopped string + Considered int + Warnings []crewroute.SeatWarning + // Changes are the newest changes, oldest first; ChangeCount is every one. + Changes []TaskCrewChange + ChangeCount int + Blocked *TaskRoutingBlock + // Requested is a not-started task's per-role choice; Effort "best" there + // is a not-started task asked for the best crew. + Requested map[crewroute.Seat]string +} + +// TaskRoutingRole is one role of a routed task. +type TaskRoutingRole struct { + Seat crewroute.Seat + Model, Provider string + Send string + Kind crewroute.RouteKind + Source string + Status string + Quality float64 + EstimateUSD *float64 + SpentUSD float64 + Calls, UnknownCalls int + Alternatives []TaskRoutingOption + Rejected []TaskRoutingRejection +} + +// TaskRoutingOption is one model the router lined up behind a role. +type TaskRoutingOption struct { + Model, Provider string + Kind crewroute.RouteKind + Quality float64 + EstimateUSD *float64 +} + +// TaskRoutingRejection is one route that failed this task's role. +type TaskRoutingRejection struct { + Model, Provider string + Reason string + Until time.Time +} + +// TaskRoutingBlock is why a task waits: the role with no model, and why each +// considered model was turned away. +type TaskRoutingBlock struct { + Seat crewroute.Seat + Mode crewroute.Mode + Rejected []crewroute.Rejection + Omitted int + Action string + Message string + At time.Time +} + +// taskRoutingAlternatives bounds the options one role lists. +const taskRoutingAlternatives = 5 + +// TaskRouting reads one task's models. changes bounds the history returned +// (1..64; 0 is 20). +func (a *Agent) TaskRouting(id string, changes int) (TaskRoutingView, error) { + n, err := strconv.ParseUint(strings.TrimSpace(id), 10, 64) + if err != nil || n == 0 { + return TaskRoutingView{}, &TaskRoutingRefusal{Code: TaskRoutingNotFound, Message: "no such task"} + } + if changes <= 0 { + changes = 20 + } + if changes > taskCrewChangesKept { + changes = taskCrewChangesKept + } + if block, ok := a.taskRouting.parkedBlock(n); ok { + return TaskRoutingView{Task: id, State: TaskRoutingBlocked, Mode: block.Mode, Blocked: &block, Requested: a.taskRouting.requested(n)}, nil + } + a.mu.Lock() + _, proposed := a.taskAnswers[n] + a.mu.Unlock() + if proposed { + view := TaskRoutingView{Task: id, State: TaskRoutingPicking, Requested: a.taskRouting.requested(n)} + if wish, ok := a.taskRouting.wish(n); ok { + view.Effort = string(wish.effort) + } + return view, nil + } + if row, ok := a.appRunRow(n); ok { + var record *TaskCrewRecord + if crew := a.crews.get(n); crew != nil { + crew.persistMu.Lock() + record = crew.record() + crew.persistMu.Unlock() + } else if row.CrewState != nil { + record = row.CrewState + } else if row.Crew != nil { + record = &TaskCrewRecord{Version: 1, Routed: true, Decision: *row.Crew} + } + settled := row.State.settled() + if record == nil || !record.Routed { + return TaskRoutingView{Task: id, State: TaskRoutingSingle, Model: row.Model, Running: !settled, Settled: settled}, nil + } + return taskRoutingOf(id, record, settled, changes), nil + } + if node := a.taskNode(n); node != nil { + notice := node.notice() + settled := notice.State.settled() + return TaskRoutingView{Task: id, State: TaskRoutingSingle, Model: notice.Model, Running: !settled, Settled: settled}, nil + } + return TaskRoutingView{}, &TaskRoutingRefusal{Code: TaskRoutingNotFound, Message: "no such task"} +} + +// taskRoutingOf is a routed task's view from its crew record. +func taskRoutingOf(id string, r *TaskCrewRecord, settled bool, changes int) TaskRoutingView { + d := r.Decision + mode := d.Mode + if mode == "" { + mode = crewroute.ModeAuto + } + view := TaskRoutingView{ + Task: id, State: TaskRoutingRouted, Mode: mode, Running: !settled, Settled: settled, + Restored: append([]time.Time(nil), r.Restored...), HelperUSD: r.HelperUSD, + Class: string(d.Class), Why: d.Why, Effort: string(d.Effort), Note: d.Note, Stopped: d.Stopped, + Considered: d.Considered, Warnings: append([]crewroute.SeatWarning(nil), d.Warnings...), ChangeCount: r.ChangeCount, + } + if from := len(r.Changes) - changes; from > 0 { + view.Changes = append([]TaskCrewChange(nil), r.Changes[from:]...) + } else { + view.Changes = append([]TaskCrewChange(nil), r.Changes...) + } + g := r.Guard + if g != nil { + view.TaskCapUSD = g.TaskCap + view.SpentUSD = g.TaskSpent + view.Calls, view.UnknownCalls = g.TaskCalls, g.TaskUnknown + } + view.SpentUSD += r.HelperUSD + known, total := true, 0.0 + for _, seat := range crewroute.Seats { + pick := d.Seat(seat) + role := TaskRoutingRole{Seat: seat, Model: pick.Model, Provider: pick.Provider, Send: pick.Send, Kind: pick.Kind, Quality: pick.Quality, EstimateUSD: pickEstimate(pick)} + switch { + case mode == crewroute.ModeOne: + role.Source = TaskRoleOneModel + case r.TaskPins[seat]: + role.Source = TaskRoleTaskPin + case pick.Pinned: + role.Source = TaskRolePinned + default: + role.Source = TaskRoleAuto + } + if role.EstimateUSD == nil { + known = false + } else { + total += *role.EstimateUSD + } + if g != nil { + role.SpentUSD = g.SeatSpent[seat] + role.Calls, role.UnknownCalls = g.SeatCalls[seat], g.SeatUnknown[seat] + } + used := role.Calls > 0 || role.SpentUSD > 0 || (pick.Send != "" && r.Started[pick.Send]) + switch { + case r.Pending[seat]: + role.Status = TaskRolePending + case used: + role.Status = TaskRoleUsed + case settled: + role.Status = TaskRoleNotUsed + default: + role.Status = TaskRoleNotRunYet + } + for _, rung := range r.Ladders[seat] { + if len(role.Alternatives) == taskRoutingAlternatives { + break + } + if rung.Send == pick.Send { + continue + } + role.Alternatives = append(role.Alternatives, TaskRoutingOption{Model: rung.Model, Provider: rung.Provider, Kind: rung.Kind, Quality: rung.Quality, EstimateUSD: pickEstimate(rung)}) + } + sends := map[string]bool{r.Original[seat]: true} + for _, change := range r.Changes { + if change.Seat == seat { + sends[change.FromSend], sends[change.ToSend] = true, true + } + } + for _, send := range crewKeys(sends) { + if failed, ok := r.Failed[send]; ok && send != "" { + model, provider := send, "" + for _, change := range r.Changes { + if change.FromSend == send { + model, provider = change.From, change.FromProvider + } + } + role.Rejected = append(role.Rejected, TaskRoutingRejection{Model: model, Provider: provider, Reason: string(failed.Kind), Until: failed.Until}) + } + } + view.Roles = append(view.Roles, role) + } + if known { + if d.EstUSD > 0 && d.CostFactor > 0 { + total = d.EstUSD + } + view.EstimateUSD = &total + } + return view +} + +// pickEstimate is one pick's expected cost: nothing on a plan, a local model +// or a free pool, the router's figure on a priced route, and unknown (nil) +// on a metered route nobody priced. +func pickEstimate(pick crewroute.Pick) *float64 { + zero := 0.0 + switch pick.Kind { + case crewroute.Plan, crewroute.Local, crewroute.Free: + return &zero + } + switch { + case pick.EstUSD > 0: + v := pick.EstUSD + return &v + case pick.CostUSD > 0 && !math.IsInf(pick.CostUSD, 0): + v := pick.CostUSD + return &v + } + return nil +} + +// ── the act ───────────────────────────────────────────────────────────────── + +// The kinds of change a person can ask for. +const ( + TaskRoutingStronger = "stronger_worker" + TaskRoutingBest = "best" + TaskRoutingPick = "pick" + TaskRoutingAuto = "auto" + TaskRoutingSame = "same" + TaskRoutingRetry = "retry" +) + +// The refusal codes. +const ( + TaskRoutingNotFound = "not_found" + TaskRoutingBadAction = "bad_action" + TaskRoutingOverCap = "over_cap" + TaskRoutingDayCap = "day_cap" + TaskRoutingNoEligible = "no_eligible" + TaskRoutingNotAllowed = "not_allowed" + TaskRoutingRouteFailed = "route_failed" + TaskRoutingUnchanged = "unchanged" + TaskRoutingStrongest = "strongest" + TaskRoutingSettled = "settled" + TaskRoutingBusy = "busy" + TaskRoutingStale = "stale" + TaskRoutingUnavailable = "unavailable" + TaskRoutingNotRouted = "not_routed" +) + +// TaskRoutingAction is one change a person asked for on one task. +type TaskRoutingAction struct { + Kind string + // Picks are per-role models for Kind pick; an empty model is Auto for + // that role. + Picks map[crewroute.Seat]string + // Rerun starts a finished task again with this choice. + Rerun bool + // RaiseCapUSD is a task cap the person explicitly allows for this task. + RaiseCapUSD float64 + // ChangeCount, when set, is the history count the person saw; a task that + // changed since is refused as stale. + ChangeCount *int +} + +// TaskRoutingResult is what an action did. +type TaskRoutingResult struct { + View TaskRoutingView + Applied []crewroute.Seat + // Rerun is the id of the task a rerun started. + Rerun uint64 +} + +// TaskRoutingRefusal is an action the engine did not take, typed. +type TaskRoutingRefusal struct { + Code string + Message string + Seat crewroute.Seat + // RequiredUSD is the cap the change needs and CapUSD the cap it met. + RequiredUSD, CapUSD float64 + Blocked *TaskRoutingBlock +} + +func (e *TaskRoutingRefusal) Error() string { return e.Message } + +func refuse(code, format string, args ...any) error { + return &TaskRoutingRefusal{Code: code, Message: fmt.Sprintf(format, args...)} +} + +// TaskRoutingAct is a person's change to one task's models. +func (a *Agent) TaskRoutingAct(ctx context.Context, id string, act TaskRoutingAction) (TaskRoutingResult, error) { + n, err := strconv.ParseUint(strings.TrimSpace(id), 10, 64) + if err != nil || n == 0 { + return TaskRoutingResult{}, refuse(TaskRoutingNotFound, "no such task") + } + switch act.Kind { + case TaskRoutingStronger, TaskRoutingBest, TaskRoutingAuto, TaskRoutingSame, TaskRoutingRetry: + case TaskRoutingPick: + if len(act.Picks) == 0 { + return TaskRoutingResult{}, refuse(TaskRoutingBadAction, "pick needs at least one role") + } + for seat := range act.Picks { + if seat != crewroute.Worker && seat != crewroute.Planner && seat != crewroute.Checker { + return TaskRoutingResult{}, refuse(TaskRoutingBadAction, "%q is not a role · worker, planner or checker", seat) + } + } + default: + return TaskRoutingResult{}, refuse(TaskRoutingBadAction, "%q is not a change · stronger_worker, best, pick, auto, same or retry", act.Kind) + } + if act.RaiseCapUSD < 0 || math.IsNaN(act.RaiseCapUSD) || math.IsInf(act.RaiseCapUSD, 0) { + return TaskRoutingResult{}, refuse(TaskRoutingBadAction, "a raised cap is a dollar figure above zero") + } + var applied []crewroute.Seat + var rerun uint64 + switch { + case a.taskRouting.isParked(n): + err = a.actOnParked(ctx, n, act) + case a.isProposed(n): + err = a.actOnProposal(n, act) + default: + row, ok := a.appRunRow(n) + if !ok { + return TaskRoutingResult{}, refuse(TaskRoutingNotFound, "no such task") + } + crew := a.crews.get(n) + switch { + case act.Rerun || row.State.settled(): + if !act.Rerun { + return TaskRoutingResult{}, refuse(TaskRoutingSettled, "this task has finished · its models are kept as they ran; rerun it to choose again") + } + if !row.State.settled() { + return TaskRoutingResult{}, refuse(TaskRoutingBusy, "this task is still running · stop it first, then rerun it") + } + rerun, err = a.rerunWithRouting(ctx, row, crew, act) + case crew == nil: + return TaskRoutingResult{}, refuse(TaskRoutingNotRouted, "this task runs on one recorded model and has no roles to change") + default: + applied, err = a.actOnLiveCrew(n, crew, act) + } + } + if err != nil { + return TaskRoutingResult{}, err + } + view, err := a.TaskRouting(id, 0) + if err != nil { + return TaskRoutingResult{}, err + } + return TaskRoutingResult{View: view, Applied: applied, Rerun: rerun}, nil +} + +func (a *Agent) isProposed(n uint64) bool { + a.mu.Lock() + defer a.mu.Unlock() + _, ok := a.taskAnswers[n] + return ok +} + +// actOnProposal keeps a not-started task's choice for the moment it is +// routed. Nothing is decided yet, so there is nothing to be stronger than. +func (a *Agent) actOnProposal(n uint64, act TaskRoutingAction) error { + if act.Rerun { + return refuse(TaskRoutingBadAction, "this task has not run · there is nothing to rerun") + } + switch act.Kind { + case TaskRoutingStronger, TaskRoutingSame, TaskRoutingRetry: + return refuse(TaskRoutingBadAction, "this task has not started · choose its models with pick, best or auto") + } + a.taskRouting.change(n, func(w *crewWish) { + switch act.Kind { + case TaskRoutingAuto: + w.sends, w.effort = nil, "" + case TaskRoutingBest: + w.sends, w.effort = nil, crewroute.EffortBest + case TaskRoutingPick: + if w.sends == nil { + w.sends = map[crewroute.Seat]string{} + } + for seat, model := range act.Picks { + if model = strings.TrimSpace(model); model == "" { + delete(w.sends, seat) + } else { + w.sends[seat] = model + } + } + } + if act.RaiseCapUSD > 0 { + w.taskCap = act.RaiseCapUSD + } + }) + return nil +} + +// crewTaskText is the words a crew was routed on. +func crewTaskText(title, brief string) crewroute.Task { + return crewroute.Task{Text: strings.TrimSpace(title + "\n\n" + brief)} +} + +// routeRefusal is a router error as an action's typed refusal. +func routeRefusal(err error) error { + var none *crewroute.NoEligibleError + switch { + case errors.As(err, &none): + block := blockOf(none, time.Now()) + return &TaskRoutingRefusal{Code: TaskRoutingNoEligible, Message: none.Error(), Seat: none.Seat, Blocked: &block} + case errors.Is(err, config.ErrCrewAtCap): + return refuse(TaskRoutingDayCap, "today's spend has reached the daily cap · nothing was changed") + case errors.Is(err, crewroute.ErrStrongest): + return refuse(TaskRoutingStrongest, "this is already the strongest model the allowed models make for that role") + } + return refuse(TaskRoutingUnavailable, "the models could not be chosen: %v", err) +} + +func blockOf(none *crewroute.NoEligibleError, at time.Time) TaskRoutingBlock { + return TaskRoutingBlock{Seat: none.Seat, Mode: none.Mode, Rejected: append([]crewroute.Rejection(nil), none.Rejected...), + Omitted: none.Omitted, Action: none.Action, Message: none.Error(), At: at} +} + +// actOnLiveCrew moves a running task's roles at their next call. +func (a *Agent) actOnLiveCrew(n uint64, crew *taskCrew, act TaskRoutingAction) ([]crewroute.Seat, error) { + route := a.config.RouteCrew + if route == nil { + return nil, refuse(TaskRoutingUnavailable, "this conversation has no model router") + } + if act.Kind == TaskRoutingSame || act.Kind == TaskRoutingRetry { + return nil, refuse(TaskRoutingBadAction, "%s is for a rerun or a waiting task", act.Kind) + } + crew.mu.Lock() + cur, count := crew.decision, crew.changeCount + pinned := map[crewroute.Seat]bool{} + for seat := range crew.taskPins { + pinned[seat] = true + } + for seat := range crew.seatSends { + pinned[seat] = true + } + crew.mu.Unlock() + if act.ChangeCount != nil && *act.ChangeCount != count { + return nil, refuse(TaskRoutingStale, "this task's models changed · open it again") + } + ask := config.CrewAsk{Task: crewTaskText(crew.title, crew.brief), Repo: crew.repo, ChatModel: a.Model(), Sends: map[crewroute.Seat]string{}} + keep := func(seats ...crewroute.Seat) { + for _, seat := range seats { + if send := cur.Seat(seat).Send; send != "" { + ask.Sends[seat] = send + } + } + } + var targets []crewroute.Seat + switch act.Kind { + case TaskRoutingStronger: + keep(crewroute.Planner, crewroute.Checker) + ask.Stronger = &cur + targets = []crewroute.Seat{crewroute.Worker} + case TaskRoutingBest: + ask.Effort = crewroute.EffortBest + targets = crewroute.Seats + case TaskRoutingPick: + for _, seat := range crewroute.Seats { + model, named := act.Picks[seat] + switch { + case !named: + keep(seat) + case strings.TrimSpace(model) != "": + ask.Sends[seat] = strings.TrimSpace(model) + targets = append(targets, seat) + default: + targets = append(targets, seat) + } + } + case TaskRoutingAuto: + for _, seat := range crewroute.Seats { + if pinned[seat] { + targets = append(targets, seat) + } else { + keep(seat) + } + } + if len(targets) == 0 { + return nil, refuse(TaskRoutingUnchanged, "no role of this task was chosen for it · it is already on Auto") + } + } + if len(ask.Sends) == 0 { + ask.Sends = nil + } + decision, err := route(ask) + if err != nil { + return nil, routeRefusal(err) + } + next := cur + next.Crew = append([]crewroute.Pick(nil), cur.Crew...) + var changed []crewroute.Seat + for _, seat := range targets { + pick := decision.Seat(seat) + if pick.Send == "" { + continue + } + if act.Kind == TaskRoutingPick { + if want := strings.TrimSpace(act.Picks[seat]); want != "" { + if pick.Send != want && pick.Model != want { + return nil, &TaskRoutingRefusal{Code: TaskRoutingNotAllowed, Seat: seat, + Message: fmt.Sprintf("%s cannot take the %s role here · %s", want, seat, warningFor(decision, seat))} + } + pick.Pinned = true + } + } + pick.Seat = seat + if pick.Send == cur.Seat(seat).Send && pick.Pinned == cur.Seat(seat).Pinned { + if act.Kind != TaskRoutingAuto && act.Kind != TaskRoutingPick { + continue + } + } + if _, failed := crew.failedHere(pick.Send); failed { + return nil, &TaskRoutingRefusal{Code: TaskRoutingRouteFailed, Seat: seat, + Message: fmt.Sprintf("%s already failed to start on this task", pick.Model)} + } + for i := range next.Crew { + if next.Crew[i].Seat == seat { + next.Crew[i] = pick + } + } + changed = append(changed, seat) + } + if len(changed) == 0 { + if act.Kind == TaskRoutingStronger { + return nil, refuse(TaskRoutingStrongest, "this is already the strongest worker the allowed models make") + } + return nil, refuse(TaskRoutingUnchanged, "the models this asks for are the ones the task runs on") + } + estimate, known := crewEstimate(next) + capUSD := 0.0 + if crew.guard != nil { + crew.guard.mu.Lock() + capUSD = crew.guard.TaskCap + crew.guard.mu.Unlock() + } + raise := 0.0 + if known && capUSD > 0 && estimate > capUSD+1e-9 { + if act.RaiseCapUSD+1e-9 < estimate { + return nil, &TaskRoutingRefusal{Code: TaskRoutingOverCap, RequiredUSD: estimate, CapUSD: capUSD, + Message: fmt.Sprintf("this needs more than the %s task cap · it needs %s", crewroute.Money(capUSD), crewroute.Money(estimate))} + } + raise = act.RaiseCapUSD + } + at := a.taskClockNow() + kind := act.Kind + crew.mu.Lock() + if crew.changeCount != count { + crew.mu.Unlock() + return nil, refuse(TaskRoutingStale, "this task's models changed · open it again") + } + for _, seat := range changed { + was, pick := crew.decision.Seat(seat), next.Seat(seat) + if crew.seatSends == nil { + crew.seatSends = map[crewroute.Seat]string{} + } + crew.seatSends[seat] = pick.Send + if crew.ladders == nil { + crew.ladders = map[crewroute.Seat][]crewroute.Pick{} + } + if pick.Pinned { + delete(crew.ladders, seat) + } else { + crew.ladders[seat] = decision.Ladder[seat] + } + if crew.pending == nil { + crew.pending = map[crewroute.Seat]bool{} + } + if !crew.started[pick.Send] { + crew.pending[seat] = true + } else { + delete(crew.pending, seat) + } + if kind == TaskRoutingPick && strings.TrimSpace(act.Picks[seat]) != "" { + if crew.taskPins == nil { + crew.taskPins = map[crewroute.Seat]bool{} + } + crew.taskPins[seat] = true + } else { + delete(crew.taskPins, seat) + } + crew.noteChangeLocked(TaskCrewChange{At: at, Seat: seat, From: was.Model, FromProvider: was.Provider, FromSend: was.Send, + To: pick.Model, ToProvider: pick.Provider, ToSend: pick.Send, By: TaskCrewByPerson, Kind: kind}) + } + next.Retried, next.Ladder = crew.decision.Retried, crew.ladders + if known { + next.EstUSD = estimate + } + crew.decision = next + if raise > 0 { + crew.noteChangeLocked(TaskCrewChange{At: at, By: TaskCrewByPerson, Kind: TaskCrewChangeCap, CapUSD: raise}) + } + crew.mu.Unlock() + if raise > 0 && crew.guard != nil { + crew.guard.mu.Lock() + crew.guard.TaskCap = raise + crew.guard.mu.Unlock() + } + if crew.persist != nil { + if err := crew.persist(); err != nil { + return changed, err + } + } + var lines []string + for _, seat := range changed { + lines = append(lines, fmt.Sprintf("%s changed %s → %s", seat, cur.Seat(seat).Model, next.Seat(seat).Model)) + } + a.republishCrewRow(n, next, "you changed this task's models · "+strings.Join(lines, " · ")+" · from its next step") + return changed, nil +} + +// warningFor is what the router said about a role it did not seat as asked. +func warningFor(d crewroute.Decision, seat crewroute.Seat) string { + for _, w := range d.Warnings { + if w.Seat == seat { + return string(w.Kind) + } + } + if d.Mode == crewroute.ModeOne { + return "one model runs every role" + } + return "not allowed" +} + +// crewEstimate is a crew's estimate, and false when a seat's price is unknown. +func crewEstimate(d crewroute.Decision) (float64, bool) { + total := 0.0 + for _, seat := range crewroute.Seats { + pick := d.Seat(seat) + if pick.Send == "" { + continue + } + est := pickEstimate(pick) + if est == nil { + return 0, false + } + total += *est + } + if d.CostFactor > 1 { + total *= d.CostFactor + } + return total, true +} + +// republishCrewRow puts a crew on its row and says the change on the plan. +func (a *Agent) republishCrewRow(row uint64, decision crewroute.Decision, note string) { + g := a.graph() + if g == nil { + return + } + for _, kept := range g.runRows(row) { + if kept.ID != row { + continue + } + kept.Crew = &decision + kept.Model = decision.Seat(crewroute.Worker).Model + a.publishRunRow(g, kept) + break + } + g.planNote(note) +} + +// rerunWithRouting starts a finished task again with the person's choice. The +// finished task's record is never changed. +func (a *Agent) rerunWithRouting(ctx context.Context, row TaskNotice, crew *taskCrew, act TaskRoutingAction) (uint64, error) { + var prior *crewroute.Decision + brief, title := "", row.Title + switch { + case crew != nil: + d := crew.current() + prior, brief, title = &d, crew.brief, crew.title + case row.CrewState != nil && row.CrewState.Routed: + d := row.CrewState.Decision + prior, brief, title = &d, row.CrewState.Brief, row.CrewState.Title + } + if brief == "" { + brief = row.Brief + } + if strings.TrimSpace(brief) == "" { + return 0, refuse(TaskRoutingNotRouted, "this task's brief was not kept · it cannot be rerun from here") + } + a.beltMu.Lock() + live := a.beltRun != nil + a.beltMu.Unlock() + if live { + return 0, refuse(TaskRoutingBusy, "other work is still running · rerun this task when it has finished") + } + wish := crewWish{} + sendsOf := func(seats ...crewroute.Seat) map[crewroute.Seat]string { + out := map[crewroute.Seat]string{} + for _, seat := range seats { + if prior != nil && prior.Seat(seat).Send != "" { + out[seat] = prior.Seat(seat).Send + } + } + return out + } + switch act.Kind { + case TaskRoutingSame: + if prior == nil { + return 0, refuse(TaskRoutingNotRouted, "this task has no recorded roles to run again") + } + wish.sends = sendsOf(crewroute.Seats...) + case TaskRoutingStronger: + if prior == nil { + return 0, refuse(TaskRoutingNotRouted, "this task has no recorded worker to be stronger than") + } + wish.sends, wish.stronger = sendsOf(crewroute.Planner, crewroute.Checker), prior + case TaskRoutingBest: + wish.effort = crewroute.EffortBest + case TaskRoutingPick: + wish.sends = map[crewroute.Seat]string{} + for seat, model := range act.Picks { + if model = strings.TrimSpace(model); model != "" { + wish.sends[seat] = model + } + } + case TaskRoutingRetry: + return 0, refuse(TaskRoutingBadAction, "retry is for a waiting task") + } + if route := a.config.RouteCrew; route != nil { + // THE RERUN'S ESTIMATE IS READ BEFORE ANYTHING STARTS, so a choice + // over the task cap is refused with the cap it needs. + ask := config.CrewAsk{Task: crewTaskText(title, brief), Effort: wish.effort, Stronger: wish.stronger, Sends: wish.sends, + Repo: canonicalPath(a.config.Workspace), ChatModel: a.Model()} + if len(ask.Sends) == 0 { + ask.Sends = nil + } + decision, err := route(ask) + if err != nil { + return 0, routeRefusal(err) + } + capUSD, _ := config.CrewTaskSpendCap(a.config.ProfileDir) + if estimate, known := crewEstimate(decision); known && capUSD > 0 && estimate > capUSD+1e-9 { + if act.RaiseCapUSD+1e-9 < estimate { + return 0, &TaskRoutingRefusal{Code: TaskRoutingOverCap, RequiredUSD: estimate, CapUSD: capUSD, + Message: fmt.Sprintf("this needs more than the %s task cap · it needs %s", crewroute.Money(capUSD), crewroute.Money(estimate))} + } + wish.taskCap = act.RaiseCapUSD + } + } + id, _, _, err := a.startTaskRun(withCrewWish(ctx, wish), brief, true, "") + if err != nil { + var none *crewroute.NoEligibleError + if errors.As(err, &none) || errors.Is(err, config.ErrCrewAtCap) || errors.Is(err, crewroute.ErrStrongest) { + return 0, routeRefusal(err) + } + return 0, err + } + return id, nil +} + +// ── choices kept before a task starts, and tasks waiting on a model ───────── + +// TaskWaitingNoModel is a task parked because a role had no model allowed. +const TaskWaitingNoModel = "no model allowed" + +// taskRoutingBook is the conversation's per-task choices for tasks not +// started yet, and its parked hand-offs. +type taskRoutingBook struct { + mu sync.Mutex + wishes map[uint64]crewWish + parked map[uint64]*parkedHandoff +} + +// parkedHandoff is an approved hand-off that waits on a model. +type parkedHandoff struct { + ctx context.Context + p *stagedProposal + spec taskSpec + elsewhere string + block TaskRoutingBlock +} + +func (b *taskRoutingBook) change(id uint64, edit func(*crewWish)) { + b.mu.Lock() + defer b.mu.Unlock() + if b.wishes == nil { + b.wishes = map[uint64]crewWish{} + } + w := b.wishes[id] + edit(&w) + b.wishes[id] = w +} + +func (b *taskRoutingBook) wish(id uint64) (crewWish, bool) { + b.mu.Lock() + defer b.mu.Unlock() + w, ok := b.wishes[id] + return w, ok +} + +func (b *taskRoutingBook) forget(id uint64) { + b.mu.Lock() + defer b.mu.Unlock() + delete(b.wishes, id) + delete(b.parked, id) +} + +func (b *taskRoutingBook) requested(id uint64) map[crewroute.Seat]string { + w, ok := b.wish(id) + if !ok || len(w.sends) == 0 { + return nil + } + out := map[crewroute.Seat]string{} + for seat, send := range w.sends { + out[seat] = send + } + return out +} + +func (b *taskRoutingBook) isParked(id uint64) bool { + b.mu.Lock() + defer b.mu.Unlock() + return b.parked[id] != nil +} + +func (b *taskRoutingBook) parkedBlock(id uint64) (TaskRoutingBlock, bool) { + b.mu.Lock() + defer b.mu.Unlock() + if p := b.parked[id]; p != nil { + return p.block, true + } + return TaskRoutingBlock{}, false +} + +func (b *taskRoutingBook) take(id uint64) *parkedHandoff { + b.mu.Lock() + defer b.mu.Unlock() + p := b.parked[id] + delete(b.parked, id) + return p +} + +// proposalCrewWish is base with what the person chose for this task before +// it started. +func (a *Agent) proposalCrewWish(id uint64, base crewWish) crewWish { + w, ok := a.taskRouting.wish(id) + if !ok { + return base + } + if w.effort != "" { + base.effort = w.effort + } + if len(w.sends) > 0 { + base.sends = w.sends + if w.sends[crewroute.Worker] != "" { + base.worker = "" + } + } + if w.taskCap > 0 { + base.taskCap = w.taskCap + } + return base +} + +// parkUnroutedHandoff parks an approved hand-off a role had no model for, and +// answers the receipt. Anything else is not parked. +func (a *Agent) parkUnroutedHandoff(ctx context.Context, p *stagedProposal, spec taskSpec, elsewhere string, via *delegate.Delegate, err error) (string, bool) { + var none *crewroute.NoEligibleError + if err == nil { + a.taskRouting.forget(p.id) + return "", false + } + if via != nil || !errors.As(err, &none) { + return "", false + } + g := a.graph() + if g == nil { + return "", false + } + block := blockOf(none, a.taskClockNow()) + a.taskRouting.mu.Lock() + if a.taskRouting.parked == nil { + a.taskRouting.parked = map[uint64]*parkedHandoff{} + } + a.taskRouting.parked[p.id] = &parkedHandoff{ctx: context.WithoutCancel(ctx), p: p, spec: spec, elsewhere: elsewhere, block: block} + a.taskRouting.mu.Unlock() + a.publishRunRow(g, TaskNotice{ID: p.id, Title: spec.title, Brief: spec.brief, State: TaskQueued, Waiting: TaskWaitingNoModel, Report: block.Message}) + return fmt.Sprintf("task %d is waiting: %s. It has asked the person and starts when a model is allowed for the %s role; nothing is running for it.", p.id, block.Message, none.Seat), true +} + +// actOnParked answers a waiting task: a new choice, then a retry, or a retry. +func (a *Agent) actOnParked(ctx context.Context, n uint64, act TaskRoutingAction) error { + if act.Rerun { + return refuse(TaskRoutingBadAction, "this task has not run · there is nothing to rerun") + } + if act.Kind != TaskRoutingRetry { + if err := a.actOnProposal(n, act); err != nil { + return err + } + } + return a.RetryParkedTask(n) +} + +// RetryParkedTask starts a waiting task from where it stood. Still no model +// keeps it waiting, with the new reason. +func (a *Agent) RetryParkedTask(n uint64) error { + parked := a.taskRouting.take(n) + if parked == nil { + return refuse(TaskRoutingNotFound, "that task is not waiting on a model") + } + spec := parked.spec + ctx := withCrewWish(parked.ctx, a.proposalCrewWish(n, crewWish{effort: spec.crewEffort, worker: namedModel(spec)})) + answer, refused, handled := a.commitProposalToRun(ctx, parked.p, spec, parked.elsewhere) + if block, waiting := a.taskRouting.parkedBlock(n); waiting { + return &TaskRoutingRefusal{Code: TaskRoutingNoEligible, Message: block.Message, Seat: block.Seat, Blocked: &block} + } + if !handled { + return refuse(TaskRoutingUnavailable, "the task could not start here") + } + if refused { + return refuse(TaskRoutingUnavailable, "%s", answer) + } + return nil +} + +// DropParkedTask ends a waiting task as not started. +func (a *Agent) DropParkedTask(n uint64) bool { + parked := a.taskRouting.take(n) + if parked == nil { + return false + } + a.taskRouting.forget(n) + a.settleUnstartedHandoff(n, parked.spec.title, errors.New(parked.block.Message)) + return true +} diff --git a/internal/session/taskrouting_test.go b/internal/session/taskrouting_test.go new file mode 100644 index 0000000000..0d79adeb9a --- /dev/null +++ b/internal/session/taskrouting_test.go @@ -0,0 +1,520 @@ +package session + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/crewroute" + "github.com/Agent-Field/codeaf/internal/provider" +) + +func seatCtx(seat crewroute.Seat) context.Context { + return context.WithValue(context.Background(), crewSeatContextKey{}, seat) +} + +func usage(cost *float64) *ai.Response { + return &ai.Response{Usage: &ai.Usage{PromptTokens: 1000, CompletionTokens: 100, Cost: cost}} +} + +func usd(v float64) *float64 { return &v } + +// A ROLE'S SPEND IS BOOKED WITH NO CEILING SET, two roles on one model stay +// apart, and an unknown cost is counted as unknown — never as $0 spent — +// while a provider's own cost of 0 and a priced free route are known nothing. +func TestRoleSpendIsBookedWithoutCeilingsAndUnknownIsNotZero(t *testing.T) { + priced := map[string]bool{"priced/model": true, "free/model": true} + g := &SpendGuard{Price: func(model string) (float64, float64, float64, bool) { + if !priced[model] { + return 0, 0, 0, false + } + if model == "free/model" { + return 0, 0, 0, true + } + return 1e-6, 2e-6, 0, true + }} + g.after(seatCtx(crewroute.Worker), "shared/model", usage(usd(0.25)), 0) + g.after(seatCtx(crewroute.Planner), "shared/model", usage(usd(0.05)), 0) + g.after(seatCtx(crewroute.Checker), "unpriced/model", usage(nil), 0) + g.after(seatCtx(crewroute.Checker), "unpriced/model", usage(usd(0)), 0) + g.after(seatCtx(crewroute.Planner), "free/model", usage(nil), 0) + g.after(seatCtx(crewroute.Worker), "priced/model", usage(nil), 0) + g.after(seatCtx(crewroute.Worker), "priced/model", &ai.Response{}, 0) + + want := map[crewroute.Seat]struct { + spent float64 + calls, unknown int + }{ + crewroute.Worker: {0.25 + 1000*1e-6 + 100*2e-6, 3, 1}, + crewroute.Planner: {0.05, 2, 0}, + crewroute.Checker: {0, 2, 1}, + } + for seat, w := range want { + tally := g.seatTally(seat) + calls, unknown := tally.Counts() + if got := tally.Total(); got < w.spent-1e-12 || got > w.spent+1e-12 || calls != w.calls || unknown != w.unknown { + t.Errorf("%s: spent %v calls %d unknown %d, want %+v", seat, got, calls, unknown, w) + } + } + calls, unknown := g.Task.Counts() + if calls != 7 || unknown != 2 { + t.Fatalf("task calls %d unknown %d, want 7 and 2", calls, unknown) + } + + // The checkpoint keeps the role figures and the counts across a restart. + crew := &taskCrew{decision: liveDecision(), original: originals(liveDecision()), guard: g} + record := crew.record() + if record.Guard.SeatSpent[crewroute.Planner] != 0.05 || record.Guard.SeatCalls[crewroute.Checker] != 2 || record.Guard.SeatUnknown[crewroute.Checker] != 1 || record.Guard.TaskUnknown != 2 { + t.Fatalf("checkpoint lost role accounting: %+v", record.Guard) + } + record.Routed = true + a := &Agent{config: Config{Workspace: t.TempDir(), ProfileDir: t.TempDir()}} + back, err := a.restoreTaskCrew(9, record) + if err != nil { + t.Fatal(err) + } + calls, unknown = back.guard.seatTally(crewroute.Checker).Counts() + if back.guard.seatTally(crewroute.Worker).Total() != g.seatTally(crewroute.Worker).Total() || calls != 2 || unknown != 1 { + t.Fatalf("reload lost role accounting: calls %d unknown %d", calls, unknown) + } + view := taskRoutingOf("9", back.record(), false, 20) + if view.UnknownCalls != 2 || view.Roles[2].UnknownCalls != 1 || view.Roles[1].SpentUSD != 0.05 { + t.Fatalf("view: %+v", view) + } + if len(view.Restored) != 1 || view.Changes[len(view.Changes)-1].Kind != TaskCrewChangeRestored { + t.Fatalf("restart was not marked: %+v", view.Changes) + } +} + +// The cap and ceiling reservations are unchanged: a seat with a ceiling holds +// its estimate and releases it, one with none holds nothing. +func TestCeilingReservationsAreUnchanged(t *testing.T) { + g := &SpendGuard{Price: func(string) (float64, float64, float64, bool) { return 1e-3, 1e-3, 0, true }, + SeatCeilings: map[crewroute.Seat]float64{crewroute.Checker: 100}, TaskCap: 100} + held, err := g.before(seatCtx(crewroute.Checker), "m", nil, nil) + if err != nil || held <= 0 { + t.Fatalf("held %v err %v", held, err) + } + if g.seatTally(crewroute.Checker).held != held { + t.Fatalf("checker ceiling did not hold the estimate") + } + g.after(seatCtx(crewroute.Checker), "m", usage(usd(0.5)), held) + if g.seatTally(crewroute.Checker).held != 0 || g.Task.held != 0 { + t.Fatalf("holds not released") + } + held, _ = g.before(seatCtx(crewroute.Worker), "m", nil, nil) + if g.seatTally(crewroute.Worker).held != 0 { + t.Fatalf("a seat with no ceiling held an estimate") + } + g.after(seatCtx(crewroute.Worker), "m", usage(usd(0.5)), held) + if g.seatTally(crewroute.Worker).Total() != 0.5 || g.Task.Total() != 1.0 { + t.Fatalf("spend not booked: %v %v", g.seatTally(crewroute.Worker).Total(), g.Task.Total()) + } +} + +func liveDecision() crewroute.Decision { + return crewroute.Decision{Class: crewroute.Bugfix, Mode: crewroute.ModeAuto, Crew: []crewroute.Pick{ + {Seat: crewroute.Worker, Model: "glm-flash", Send: "glm-flash", Provider: "openrouter", Kind: crewroute.Metered, EstUSD: 0.06}, + {Seat: crewroute.Planner, Model: "ds-flash", Send: "ds-flash", Provider: "openrouter", Kind: crewroute.Metered, EstUSD: 0.01}, + {Seat: crewroute.Checker, Model: "ds-flash", Send: "ds-flash", Provider: "openrouter", Kind: crewroute.Metered, EstUSD: 0.01, Pinned: true}, + }} +} + +func originals(d crewroute.Decision) map[crewroute.Seat]string { + out := map[crewroute.Seat]string{} + for _, p := range d.Crew { + out[p.Seat] = p.Send + } + return out +} + +func liveCrew(capUSD float64) *taskCrew { + d := liveDecision() + return &taskCrew{title: "Update fixtures", brief: "update every fixture", decision: d, original: originals(d), + ladders: map[crewroute.Seat][]crewroute.Pick{crewroute.Worker: {{Seat: crewroute.Worker, Model: "glm-5.3", Send: "glm-5.3", Provider: "openrouter", Kind: crewroute.Metered, EstUSD: 0.4}}}, + guard: &SpendGuard{TaskCap: capUSD}} +} + +func routerAgent(t *testing.T, route func(config.CrewAsk) (crewroute.Decision, error)) *Agent { + return &Agent{config: Config{Workspace: t.TempDir(), ProfileDir: t.TempDir(), RouteCrew: route}, model: "chat/model"} +} + +// Use a stronger worker moves the worker alone, at its next call, recorded as +// the person's — even though planner and checker share one model — and the +// planner keeps asking its own model. +func TestStrongerWorkerMovesOnlyTheWorkerAtItsNextCall(t *testing.T) { + var asked config.CrewAsk + a := routerAgent(t, func(ask config.CrewAsk) (crewroute.Decision, error) { + asked = ask + d := liveDecision() + d.Crew[0] = crewroute.Pick{Seat: crewroute.Worker, Model: "sonnet", Send: "sonnet", Provider: "openrouter", Kind: crewroute.Metered, EstUSD: 0.5} + return d, nil + }) + crew := liveCrew(0) + changed, err := a.actOnLiveCrew(1, crew, TaskRoutingAction{Kind: TaskRoutingStronger}) + if err != nil || len(changed) != 1 || changed[0] != crewroute.Worker { + t.Fatalf("changed %v err %v", changed, err) + } + if asked.Stronger == nil || asked.Sends[crewroute.Planner] != "ds-flash" || asked.Sends[crewroute.Checker] != "ds-flash" || asked.Sends[crewroute.Worker] != "" { + t.Fatalf("router was asked %+v", asked) + } + if got := crew.sendForSeat(crewroute.Worker, "glm-flash"); got != "sonnet" { + t.Fatalf("worker asks %s", got) + } + if got := crew.sendForSeat(crewroute.Planner, "ds-flash"); got != "ds-flash" { + t.Fatalf("planner moved to %s", got) + } + view := taskRoutingOf("1", crew.record(), false, 20) + if view.Roles[0].Status != TaskRolePending || view.Roles[0].Model != "sonnet" || view.Roles[0].Source != TaskRoleAuto { + t.Fatalf("worker role %+v", view.Roles[0]) + } + if view.Roles[2].Status != TaskRoleNotRunYet || view.Roles[2].Source != TaskRolePinned { + t.Fatalf("checker role %+v", view.Roles[2]) + } + last := view.Changes[len(view.Changes)-1] + if last.By != TaskCrewByPerson || last.Kind != TaskCrewChangeStronger || last.From != "glm-flash" || last.To != "sonnet" || last.At.IsZero() { + t.Fatalf("change %+v", last) + } + if view.EstimateUSD == nil || *view.EstimateUSD < 0.519 || *view.EstimateUSD > 0.521 { + t.Fatalf("estimate %v", view.EstimateUSD) + } + // The worker's first answer clears pending. + a.crewAnswered(&beltRun{row: 1, crew: crew}, "sonnet") + if view := taskRoutingOf("1", crew.record(), false, 20); view.Roles[0].Status != TaskRoleUsed { + t.Fatalf("worker after answer %+v", view.Roles[0]) + } +} + +// A per-role pick parts two roles that share one model: the checker moves, +// the planner stays, both asking through the same send id. +func TestPickPartsRolesThatShareAModel(t *testing.T) { + a := routerAgent(t, func(ask config.CrewAsk) (crewroute.Decision, error) { + d := liveDecision() + for i := range d.Crew { + if send := ask.Sends[d.Crew[i].Seat]; send != "" && send != d.Crew[i].Send { + d.Crew[i] = crewroute.Pick{Seat: d.Crew[i].Seat, Model: send, Send: send, Provider: "openrouter", Kind: crewroute.Metered, EstUSD: 0.2, Pinned: true} + } + } + return d, nil + }) + crew := liveCrew(0) + if _, err := a.actOnLiveCrew(1, crew, TaskRoutingAction{Kind: TaskRoutingPick, Picks: map[crewroute.Seat]string{crewroute.Checker: "sonnet"}}); err != nil { + t.Fatal(err) + } + if crew.sendForSeat(crewroute.Checker, "ds-flash") != "sonnet" || crew.sendForSeat(crewroute.Planner, "ds-flash") != "ds-flash" { + t.Fatalf("checker %s planner %s", crew.sendForSeat(crewroute.Checker, "ds-flash"), crew.sendForSeat(crewroute.Planner, "ds-flash")) + } + view := taskRoutingOf("1", crew.record(), false, 20) + if view.Roles[2].Source != TaskRoleTaskPin { + t.Fatalf("checker source %s", view.Roles[2].Source) + } + // Back to Auto drops the task's pin through the router. + if _, err := a.actOnLiveCrew(1, crew, TaskRoutingAction{Kind: TaskRoutingAuto}); err != nil { + t.Fatal(err) + } + view = taskRoutingOf("1", crew.record(), false, 20) + if view.Roles[2].Source == TaskRoleTaskPin || view.Roles[2].Model != "ds-flash" || view.ChangeCount != 2 { + t.Fatalf("after auto %+v (changes %d)", view.Roles[2], view.ChangeCount) + } +} + +// A choice over the task cap is refused with the cap it needs, and goes +// ahead only with an explicit raise at least that high. +func TestBudgetRefusalNamesTheCapAndNeedsAnExplicitRaise(t *testing.T) { + a := routerAgent(t, func(ask config.CrewAsk) (crewroute.Decision, error) { + if ask.Effort != crewroute.EffortBest { + t.Fatalf("best asked %q", ask.Effort) + } + d := liveDecision() + for i := range d.Crew { + d.Crew[i].Model, d.Crew[i].Send, d.Crew[i].EstUSD = "opus-"+string(d.Crew[i].Seat), "opus-"+string(d.Crew[i].Seat), 1.0 + } + return d, nil + }) + crew := liveCrew(2) + _, err := a.actOnLiveCrew(1, crew, TaskRoutingAction{Kind: TaskRoutingBest}) + var refusal *TaskRoutingRefusal + if !errors.As(err, &refusal) || refusal.Code != TaskRoutingOverCap || refusal.RequiredUSD != 3 || refusal.CapUSD != 2 { + t.Fatalf("refusal %+v", err) + } + if crew.current().Seat(crewroute.Worker).Send != "glm-flash" || crew.changeCount != 0 { + t.Fatal("a refused change moved the crew") + } + if _, err = a.actOnLiveCrew(1, crew, TaskRoutingAction{Kind: TaskRoutingBest, RaiseCapUSD: 2.5}); !errors.As(err, &refusal) || refusal.Code != TaskRoutingOverCap { + t.Fatalf("a raise below the need was taken: %v", err) + } + if _, err = a.actOnLiveCrew(1, crew, TaskRoutingAction{Kind: TaskRoutingBest, RaiseCapUSD: 3}); err != nil { + t.Fatal(err) + } + if crew.guard.TaskCap != 3 { + t.Fatalf("task cap %v", crew.guard.TaskCap) + } + view := taskRoutingOf("1", crew.record(), false, 20) + if last := view.Changes[len(view.Changes)-1]; last.Kind != TaskCrewChangeCap || last.CapUSD != 3 { + t.Fatalf("cap raise not recorded: %+v", last) + } +} + +// No eligible model, a refused pin, a route already failed here and a stale +// card are each refused typed, and nothing moves. +func TestActionRefusalsAreTypedAndChangeNothing(t *testing.T) { + none := &crewroute.NoEligibleError{Seat: crewroute.Checker, Mode: crewroute.ModeAuto} + none.Reject("haiku-4.5", "anthropic", crewroute.RejectNotAllowed) + a := routerAgent(t, func(config.CrewAsk) (crewroute.Decision, error) { return crewroute.Decision{}, none }) + crew := liveCrew(0) + _, err := a.actOnLiveCrew(1, crew, TaskRoutingAction{Kind: TaskRoutingBest}) + var refusal *TaskRoutingRefusal + if !errors.As(err, &refusal) || refusal.Code != TaskRoutingNoEligible || refusal.Blocked == nil || refusal.Blocked.Rejected[0].Reason != crewroute.RejectNotAllowed { + t.Fatalf("no eligible: %+v", err) + } + a = routerAgent(t, func(config.CrewAsk) (crewroute.Decision, error) { + d := liveDecision() + d.Warnings = []crewroute.SeatWarning{{Seat: crewroute.Worker, Kind: crewroute.PinNotAllowed, Model: "gpt-5.2"}} + return d, nil + }) + if _, err = a.actOnLiveCrew(1, crew, TaskRoutingAction{Kind: TaskRoutingPick, Picks: map[crewroute.Seat]string{crewroute.Worker: "gpt-5.2"}}); !errors.As(err, &refusal) || refusal.Code != TaskRoutingNotAllowed { + t.Fatalf("refused pin: %v", err) + } + a = routerAgent(t, func(config.CrewAsk) (crewroute.Decision, error) { + d := liveDecision() + d.Crew[0].Model, d.Crew[0].Send = "glm-5.3", "glm-5.3" + return d, nil + }) + crew.noteFailed("glm-5.3", crewFailure{kind: provider.RouteUnavailable, err: errors.New("gone")}) + if _, err = a.actOnLiveCrew(1, crew, TaskRoutingAction{Kind: TaskRoutingStronger}); !errors.As(err, &refusal) || refusal.Code != TaskRoutingRouteFailed { + t.Fatalf("failed route: %v", err) + } + stale := 4 + if _, err = a.actOnLiveCrew(1, crew, TaskRoutingAction{Kind: TaskRoutingStronger, ChangeCount: &stale}); !errors.As(err, &refusal) || refusal.Code != TaskRoutingStale { + t.Fatalf("stale: %v", err) + } + if crew.changeCount != 0 || crew.current().Seat(crewroute.Worker).Send != "glm-flash" { + t.Fatal("a refusal moved the crew") + } +} + +// A provider failure moves an Auto role down its ladder with the engine's +// reason in the history; in one-model mode no role moves on its own. +func TestFallbackIsRecordedAndOneModelNeverMoves(t *testing.T) { + a := &Agent{config: Config{Workspace: t.TempDir(), ProfileDir: t.TempDir()}, model: "chat/model"} + crew := liveCrew(0) + run := &beltRun{row: 1, crew: crew, recoveredCrew: &TaskCrewRecord{}} + if _, moved := a.moveCrewSeat(run, "glm-flash", "glm-flash", provider.RouteUnavailable, time.Time{}); !moved { + t.Fatal("worker did not fall back") + } + view := taskRoutingOf("1", crew.record(), false, 20) + last := view.Changes[0] + if view.Roles[0].Model != "glm-5.3" || last.By != TaskCrewByEngine || last.Kind != TaskCrewChangeFallback || last.Why != string(provider.RouteUnavailable) { + t.Fatalf("fallback %+v / %+v", view.Roles[0], last) + } + + one := liveCrew(0) + one.decision.Mode = crewroute.ModeOne + run = &beltRun{row: 2, crew: one, recoveredCrew: &TaskCrewRecord{}} + if _, moved := a.moveCrewSeat(run, "glm-flash", "glm-flash", provider.RouteUnavailable, time.Time{}); moved { + t.Fatal("one-model mode moved a role") + } + if one.changeCount != 1 || one.changes[0].Kind != TaskCrewChangeHeld || one.changes[0].To != "" { + t.Fatalf("one-model mode recorded %+v, want the role held", one.changes) + } + if view := taskRoutingOf("2", one.record(), false, 20); view.Mode != crewroute.ModeOne || view.Roles[0].Source != TaskRoleOneModel { + t.Fatalf("one-model view %+v", view) + } +} + +// A finished task freezes: its roles read not used where they never ran, an +// unpriced pin leaves the estimate unknown, and an older task with no +// routing record reads its one model and nothing about the other roles. +func TestFinishedUnpricedAndOlderHistoryReadTruthfully(t *testing.T) { + crew := liveCrew(0) + crew.decision.Crew[2] = crewroute.Pick{Seat: crewroute.Checker, Model: "unpriced", Send: "unpriced", Kind: crewroute.Metered, Pinned: true} + crew.guard.after(seatCtx(crewroute.Worker), "glm-flash", usage(usd(0.07)), 0) + view := taskRoutingOf("1", crew.record(), true, 20) + if view.EstimateUSD != nil || view.Roles[2].EstimateUSD != nil { + t.Fatalf("an unpriced pin produced an estimate: %v", view.EstimateUSD) + } + if view.Roles[0].Status != TaskRoleUsed || view.Roles[1].Status != TaskRoleNotUsed || view.Roles[2].Status != TaskRoleNotUsed || !view.Settled { + t.Fatalf("roles %+v", view.Roles) + } + if view.SpentUSD != 0.07 || view.Roles[0].SpentUSD != 0.07 { + t.Fatalf("spent %v", view.SpentUSD) + } + + a, _ := newTestAgent(t, &recoveryModelRecorder{}, nil) + g := a.graph() + a.publishRunRow(g, TaskNotice{ID: 7, Title: "old", State: TaskDone, Model: "glm-5.3"}) + got, err := a.TaskRouting("7", 0) + if err != nil || got.State != TaskRoutingSingle || got.Model != "glm-5.3" || len(got.Roles) != 0 { + t.Fatalf("older history %+v %v", got, err) + } + if _, err := a.TaskRoutingAct(context.Background(), "7", TaskRoutingAction{Kind: TaskRoutingBest}); err == nil { + t.Fatal("a finished task changed without a rerun") + } +} + +// A role with no model parks the hand-off as a waiting ask with the typed +// reason; the engine starts nothing, and stop ends it as not started. +func TestNoEligibleParksTheTaskAsAWaitingAsk(t *testing.T) { + a, _ := newTestAgent(t, &recoveryModelRecorder{}, nil) + none := &crewroute.NoEligibleError{Seat: crewroute.Checker, Mode: crewroute.ModeAuto, Action: ""} + none.Reject("haiku-4.5", "anthropic", crewroute.RejectNotAllowed) + none.Reject("gpt-5.2", "openai", crewroute.RejectProviderUnavailable) + p := &stagedProposal{id: 12} + receipt, parked := a.parkUnroutedHandoff(context.Background(), p, taskSpec{title: "Update fixtures", brief: "b"}, "", nil, errors.Join(errors.New("route"), none)) + if !parked || receipt == "" { + t.Fatal("no-eligible was not parked") + } + view, err := a.TaskRouting("12", 0) + if err != nil || view.State != TaskRoutingBlocked || view.Blocked.Seat != crewroute.Checker || len(view.Blocked.Rejected) != 2 { + t.Fatalf("blocked view %+v %v", view, err) + } + row, ok := a.appRunRow(12) + if !ok || row.State != TaskQueued || row.Waiting != TaskWaitingNoModel { + t.Fatalf("row %+v", row) + } + if _, parked = a.parkUnroutedHandoff(context.Background(), &stagedProposal{id: 13}, taskSpec{}, "", nil, errors.New("other")); parked { + t.Fatal("another failure was parked") + } + if !a.DropParkedTask(12) { + t.Fatal("stop did not end the waiting task") + } + row, _ = a.appRunRow(12) + if row.State != TaskFailed { + t.Fatalf("stopped waiting task is %s", row.State) + } +} + +// A choice made before a task starts reaches the router as one-task pins. +func TestAChoiceBeforeStartReachesTheRouter(t *testing.T) { + var asked config.CrewAsk + a := routerAgent(t, func(ask config.CrewAsk) (crewroute.Decision, error) { asked = ask; return liveDecision(), nil }) + if err := a.actOnProposal(3, TaskRoutingAction{Kind: TaskRoutingPick, Picks: map[crewroute.Seat]string{crewroute.Checker: "sonnet"}}); err != nil { + t.Fatal(err) + } + ctx := withCrewWish(context.Background(), a.proposalCrewWish(3, crewWish{worker: "named"})) + crew, err := a.routeTaskCrew(ctx, 3, "t", "b") + if err != nil { + t.Fatal(err) + } + if asked.Sends[crewroute.Checker] != "sonnet" || asked.Sends[crewroute.Worker] != "named" || !crew.taskPins[crewroute.Checker] { + t.Fatalf("asked %+v pins %v", asked.Sends, crew.taskPins) + } +} + +// THE PIN LAW AT RUN TIME: a pinned role (a saved pin, a pin for this task, +// one model for every role) whose route fails holds its exact model — it is +// never moved down a ladder or onto a rescue — and the history says it +// failed, when and why, once per failure. +func TestAPinnedRoleHoldsItsModelWhenItsRouteFails(t *testing.T) { + a := &Agent{config: Config{Workspace: t.TempDir(), ProfileDir: t.TempDir()}, model: "chat/model"} + crew := liveCrew(0) + crew.decision.Crew[0].Pinned = true + crew.ladders[crewroute.Worker] = []crewroute.Pick{{Seat: crewroute.Worker, Model: "rescue", Send: "rescue", Kind: crewroute.Metered}} + run := &beltRun{row: 1, crew: crew, recoveredCrew: &TaskCrewRecord{}} + for range 3 { + if _, moved := a.moveCrewSeat(run, "glm-flash", "glm-flash", provider.RoutePayment, time.Time{}); moved { + t.Fatal("a pinned worker was moved") + } + } + if got := crew.sendForSeat(crewroute.Worker, "glm-flash"); got != "glm-flash" { + t.Fatalf("pinned worker asks %s", got) + } + view := taskRoutingOf("1", crew.record(), false, 20) + if view.ChangeCount != 1 || view.Changes[0].Kind != TaskCrewChangeHeld || view.Changes[0].Why != string(provider.RoutePayment) || view.Changes[0].At.IsZero() { + t.Fatalf("held history %+v", view.Changes) + } + // A person's explicit pick still moves it, recorded as theirs. + b := routerAgent(t, func(ask config.CrewAsk) (crewroute.Decision, error) { + d := liveDecision() + d.Crew[0] = crewroute.Pick{Seat: crewroute.Worker, Model: ask.Sends[crewroute.Worker], Send: ask.Sends[crewroute.Worker], Kind: crewroute.Metered, EstUSD: 0.1, Pinned: true} + return d, nil + }) + if _, err := b.actOnLiveCrew(1, crew, TaskRoutingAction{Kind: TaskRoutingPick, Picks: map[crewroute.Seat]string{crewroute.Worker: "glm-5.3"}}); err != nil { + t.Fatal(err) + } + if last := taskRoutingOf("1", crew.record(), false, 20).Changes; last[len(last)-1].By != TaskCrewByPerson || crew.sendForSeat(crewroute.Worker, "glm-flash") != "glm-5.3" { + t.Fatalf("person's pick %+v", last) + } +} + +// A person's per-task pin the router would not seat as asked — a model the +// allowed list leaves out, a provider that is not reachable — is refused +// typed; it never runs as Auto in its place. +func TestAPinTheRouterWouldNotSeatIsRefusedNotRunAsAuto(t *testing.T) { + for _, kind := range []crewroute.WarningKind{crewroute.PinNotAllowed, crewroute.PinProviderUnavailable} { + a := routerAgent(t, func(config.CrewAsk) (crewroute.Decision, error) { + d := liveDecision() + d.Warnings = []crewroute.SeatWarning{{Seat: crewroute.Checker, Kind: kind, Model: "claude-sonnet"}} + return d, nil + }) + crew := liveCrew(0) + _, err := a.actOnLiveCrew(1, crew, TaskRoutingAction{Kind: TaskRoutingPick, Picks: map[crewroute.Seat]string{crewroute.Checker: "claude-sonnet"}}) + var refusal *TaskRoutingRefusal + if !errors.As(err, &refusal) || refusal.Code != TaskRoutingNotAllowed || refusal.Seat != crewroute.Checker { + t.Fatalf("%s: %v", kind, err) + } + if crew.changeCount != 0 || crew.sendForSeat(crewroute.Checker, "ds-flash") != "ds-flash" { + t.Fatalf("%s: a refused pin moved the checker", kind) + } + } +} + +// What a call cost is known only when somebody said it: an answer with no +// usage on a route priced at nothing is known free; a failed call and a +// usage-less answer on a priced route are unknown, never a proven $0. +func TestCallCertaintyEdges(t *testing.T) { + g := &SpendGuard{Price: func(model string) (float64, float64, float64, bool) { + switch model { + case "free/pool": + return 0, 0, 0, true + case "priced": + return 1e-6, 1e-6, 0, true + } + return 0, 0, 0, false + }} + g.after(seatCtx(crewroute.Worker), "free/pool", &ai.Response{}, 0) + g.after(seatCtx(crewroute.Worker), "free/pool", nil, 0) + g.after(seatCtx(crewroute.Planner), "priced", &ai.Response{}, 0) + g.after(seatCtx(crewroute.Planner), "priced", nil, 0) + g.after(seatCtx(crewroute.Checker), "unpriced", usage(usd(0)), 0) + if _, calls, unknown := g.seatTally(crewroute.Worker).snapshot(); calls != 2 || unknown != 0 { + t.Fatalf("free pool: calls %d unknown %d", calls, unknown) + } + if _, calls, unknown := g.seatTally(crewroute.Planner).snapshot(); calls != 2 || unknown != 2 { + t.Fatalf("priced route without usage: calls %d unknown %d", calls, unknown) + } + if spent, calls, unknown := g.seatTally(crewroute.Checker).snapshot(); spent != 0 || calls != 1 || unknown != 0 { + t.Fatalf("explicit provider cost 0: %v %d %d", spent, calls, unknown) + } +} + +// A checkpoint taken while calls are booked always sees a task's figures and +// its roles' figures from the same bookings. +func TestCheckpointSeesCoherentBookings(t *testing.T) { + g := &SpendGuard{} + crew := &taskCrew{decision: liveDecision(), original: originals(liveDecision()), guard: g} + done := make(chan struct{}) + go func() { + defer close(done) + for range 2000 { + g.after(seatCtx(crewroute.Worker), "m", usage(usd(0.001)), 0) + } + }() + for { + select { + case <-done: + return + default: + } + r := crew.record() + if r == nil || r.Guard == nil { + continue + } + if r.Guard.TaskCalls != r.Guard.SeatCalls[crewroute.Worker] { + t.Fatalf("task saw %d calls, worker %d", r.Guard.TaskCalls, r.Guard.SeatCalls[crewroute.Worker]) + } + } +} From aa9947a1043fe97bd70acb33e19d4fae58c62dd5 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 20:38:10 -0400 Subject: [PATCH 069/459] taskrouting: per-role facts, bounded reads, and the engine's own change previews A role is used only when its own call answered: the seat a call was made for is recorded with it, so one model in every role no longer marks the checker as run when the worker answers. A crew records that it keeps these facts; an older checkpoint without them reads its roles as unknown and an unrecorded role spend as unknown, never as unused or a proven $0. One task's read finds its row where it is kept rather than copying every task, and a live crew's read takes the three picks, the first fallback rungs, the relevant failures, the newest changes and one coherent spend snapshot, never the whole checkpoint. tasks.routing.options previews Use a stronger worker and Best for every role with the same policy as tasks.routing.act and moves nothing: the roles it would move, their admitted tariffs, the estimate and the cap it meets, or the refusal it would get. Best and Stronger name their roles for Auto this once (AutoSeats), explicit picks still win, Back to Auto names none. Each role and fallback rung carries its frozen admission tariff; nil is unknown. Restarts are counted in full beside the kept marks. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/app.go | 1 + cmd/codeaf/app_test.go | 4 + cmd/codeaf/apptaskrouting.go | 56 ++- cmd/codeaf/apptaskrouting_test.go | 12 +- internal/appwire/taskrouting.go | 94 +++- internal/remote/callclass.go | 2 +- internal/remote/server.go | 2 +- internal/remote/wire_app_task.go | 32 +- internal/session/task_app_runs.go | 14 +- internal/session/task_run.go | 17 + internal/session/taskcrew.go | 55 ++- internal/session/taskcrew_record.go | 29 +- internal/session/taskrouting.go | 497 +++++++++++++++++---- internal/session/taskrouting_roles_test.go | 189 ++++++++ internal/session/taskrouting_test.go | 4 +- 15 files changed, 863 insertions(+), 145 deletions(-) create mode 100644 internal/session/taskrouting_roles_test.go diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index 0df875485b..4129c86433 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -198,6 +198,7 @@ type appConversation interface { AppTaskAction(intent session.AppTaskIntent) error TaskRouting(id string, changes int) (session.TaskRoutingView, error) TaskRoutingAct(ctx context.Context, id string, act session.TaskRoutingAction) (session.TaskRoutingResult, error) + TaskRoutingOptions(id string) (session.TaskRoutingOptions, error) AppQuickTasks() ([]session.AppQuickTaskView, error) AppQuickTaskAction(intent session.AppTaskIntent) ([]string, []string, error) MoveTurnToTask() error diff --git a/cmd/codeaf/app_test.go b/cmd/codeaf/app_test.go index 7638b77c01..e65cc62156 100644 --- a/cmd/codeaf/app_test.go +++ b/cmd/codeaf/app_test.go @@ -298,6 +298,10 @@ func (f *fakeConversation) TaskRoutingAct(_ context.Context, id string, act sess } return session.TaskRoutingResult{View: f.routing, Applied: []crewroute.Seat{crewroute.Worker}}, nil } +func (f *fakeConversation) TaskRoutingOptions(id string) (session.TaskRoutingOptions, error) { + return session.TaskRoutingOptions{Task: id, Scope: session.TaskRoutingNextStep, Pick: true, + Stronger: &session.TaskRoutingPreview{Moves: []session.TaskRoutingMove{{Seat: crewroute.Worker, From: "glm-flash", To: "sonnet", ToPrice: &crewroute.RoutePrice{Prompt: 3e-6, Completion: 15e-6}}}}}, nil +} func (f *fakeConversation) AppQuickTasks() ([]session.AppQuickTaskView, error) { f.mu.Lock() defer f.mu.Unlock() diff --git a/cmd/codeaf/apptaskrouting.go b/cmd/codeaf/apptaskrouting.go index 363566eec9..ef1bd20842 100644 --- a/cmd/codeaf/apptaskrouting.go +++ b/cmd/codeaf/apptaskrouting.go @@ -148,6 +148,7 @@ func appRoutingOf(id string, v session.TaskRoutingView) appwire.TaskRouting { TaskID: id, State: v.State, Mode: string(v.Mode), Running: v.Running, Settled: v.Settled, Restored: v.Restored, EstimateUSD: v.EstimateUSD, SpentUSD: v.SpentUSD, Calls: v.Calls, UnknownCalls: v.UnknownCalls, HelperUSD: v.HelperUSD, Class: v.Class, StoppedAction: v.Stopped, ChangeCount: v.ChangeCount, + RestoreCount: v.RestoreCount, RoleFacts: v.RoleFacts, } if v.Effort == string(crewroute.EffortBest) || v.Effort == string(crewroute.EffortCheap) { out.Effort = v.Effort @@ -161,9 +162,13 @@ func appRoutingOf(id string, v session.TaskRoutingView) appwire.TaskRouting { } for _, r := range v.Roles { role := appwire.TaskRoutingRole{Role: string(r.Seat), Model: appRef(r.Model, r.Provider), Billing: string(r.Kind), Source: r.Source, Status: r.Status, - Quality: r.Quality, EstimateUSD: r.EstimateUSD, SpentUSD: r.SpentUSD, Calls: r.Calls, UnknownCalls: r.UnknownCalls} + Quality: r.Quality, EstimateUSD: r.EstimateUSD, Price: appTariff(r.Price), Calls: r.Calls, UnknownCalls: r.UnknownCalls} + if r.SpentKnown { + spent := r.SpentUSD + role.SpentUSD = &spent + } for _, alt := range r.Alternatives { - role.Alternatives = append(role.Alternatives, appwire.TaskRoutingOption{Model: appRef(alt.Model, alt.Provider), Billing: string(alt.Kind), Quality: alt.Quality, EstimateUSD: alt.EstimateUSD}) + role.Alternatives = append(role.Alternatives, appwire.TaskRoutingOption{Model: appRef(alt.Model, alt.Provider), Billing: string(alt.Kind), Quality: alt.Quality, EstimateUSD: alt.EstimateUSD, Price: appTariff(alt.Price)}) } for _, rej := range r.Rejected { out := appwire.TaskRoutingRejection{Model: appRef(rej.Model, rej.Provider), Reason: rej.Reason} @@ -268,3 +273,50 @@ func appNoticeModels(notice *session.TaskNotice, proposed bool) *appwire.TaskMod } return nil } + +// appTariff is a route's per-token tariff as dollars per million tokens; nil +// stays unknown. +func appTariff(p *crewroute.RoutePrice) *appwire.TaskModelPrice { + if p == nil { + return nil + } + return &appwire.TaskModelPrice{InPerM: p.Prompt * 1e6, OutPerM: p.Completion * 1e6} +} + +// TaskRoutingOptions answers what Change models… offers one task, each change +// previewed by the engine. +func (b *appBackend) TaskRoutingOptions(id string) (appwire.TaskRoutingOptionsView, error) { + agent, local, ok := appSplitID(id) + if !ok { + return appwire.TaskRoutingOptionsView{}, appwire.Errorf(appwire.CodeBadParams, "%s is not a task id", id) + } + _, live, err := b.heldLive(agent) + if err != nil { + return appwire.TaskRoutingOptionsView{}, err + } + got, err := live.conn.Conv.TaskRoutingOptions(local) + if err != nil { + return appwire.TaskRoutingOptionsView{}, appRoutingError(err) + } + return appwire.TaskRoutingOptionsView{TaskID: id, Scope: got.Scope, Stronger: appPreview(got.Stronger), Best: appPreview(got.Best), + Pick: got.Pick, Auto: got.Auto, Retry: got.Retry}, nil +} + +func appPreview(p *session.TaskRoutingPreview) *appwire.TaskRoutingPreview { + if p == nil { + return nil + } + out := &appwire.TaskRoutingPreview{EstimateUSD: p.EstimateUSD, OverCap: p.OverCap, Moves: []appwire.TaskRoutingMove{}} + if p.CapUSD > 0 { + capUSD := p.CapUSD + out.CapUSD = &capUSD + } + if p.Refusal != nil { + out.Refused = appRefusedOf(p.Refusal) + } + for _, m := range p.Moves { + out.Moves = append(out.Moves, appwire.TaskRoutingMove{Role: string(m.Seat), From: appRef(m.From, m.FromProvider), To: appRef(m.To, m.ToProvider), + ToPrice: appTariff(m.ToPrice), ToEstimateUSD: m.ToEstimateUSD}) + } + return out +} diff --git a/cmd/codeaf/apptaskrouting_test.go b/cmd/codeaf/apptaskrouting_test.go index 67824b1c84..5a0de8ff75 100644 --- a/cmd/codeaf/apptaskrouting_test.go +++ b/cmd/codeaf/apptaskrouting_test.go @@ -17,7 +17,8 @@ func TestTaskRoutingReachesTheEngineAndRefusalsAreAnswers(t *testing.T) { conv := &fakeConversation{routing: session.TaskRoutingView{ State: session.TaskRoutingRouted, Mode: crewroute.ModeAuto, Running: true, EstimateUSD: &est, SpentUSD: 0.07, Calls: 3, UnknownCalls: 1, ChangeCount: 1, Roles: []session.TaskRoutingRole{ - {Seat: crewroute.Worker, Model: "z-ai/glm-5.3", Provider: "OpenRouter", Kind: crewroute.Metered, Source: session.TaskRoleAuto, Status: session.TaskRoleUsed, SpentUSD: 0.06, Calls: 2}, + {Seat: crewroute.Worker, Model: "z-ai/glm-5.3", Provider: "OpenRouter", Kind: crewroute.Metered, Source: session.TaskRoleAuto, Status: session.TaskRoleUsed, SpentUSD: 0.06, SpentKnown: true, Calls: 2, + Price: &crewroute.RoutePrice{Prompt: 4e-7, Completion: 1.6e-6}}, {Seat: crewroute.Planner, Model: "ds-flash", Provider: "openrouter", Source: session.TaskRoleAuto, Status: session.TaskRoleUsed}, {Seat: crewroute.Checker, Model: "ds-flash", Provider: "openrouter", Source: session.TaskRolePinned, Status: session.TaskRoleNotRunYet}, }, @@ -36,7 +37,14 @@ func TestTaskRoutingReachesTheEngineAndRefusalsAreAnswers(t *testing.T) { t.Fatalf("change %+v", got.Changes[0]) } raw, _ := json.Marshal(got) - for _, key := range []string{`"estimate_usd":0.12`, `"unknown_calls":1`, `"task_cap_usd":null`} { + if got.Roles[0].Price == nil || got.Roles[0].Price.InPerM < 0.399 || got.Roles[0].Price.InPerM > 0.401 || *got.Roles[0].SpentUSD != 0.06 || got.Roles[1].SpentUSD != nil { + t.Fatalf("role price/spend %+v %+v", got.Roles[0], got.Roles[1]) + } + opts, err := backend.TaskRoutingOptions(id) + if err != nil || opts.Scope != "next_step" || opts.Stronger.Moves[0].To.ID != "sonnet" || opts.Stronger.Moves[0].ToPrice.InPerM != 3 { + t.Fatalf("options %+v %v", opts, err) + } + for _, key := range []string{`"estimate_usd":0.12`, `"unknown_calls":1`, `"task_cap_usd":null`, `"spent_usd":null`} { if !json.Valid(raw) || !contains(string(raw), key) { t.Fatalf("wire lacks %s: %s", key, raw) } diff --git a/internal/appwire/taskrouting.go b/internal/appwire/taskrouting.go index b046af34e6..f9d56f8ce6 100644 --- a/internal/appwire/taskrouting.go +++ b/internal/appwire/taskrouting.go @@ -47,6 +47,7 @@ const ( type TaskRouter interface { TaskRouting(id string, changes int) (TaskRouting, error) ActOnTaskRouting(p TaskRoutingActParams) (TaskRoutingActResult, error) + TaskRoutingOptions(id string) (TaskRoutingOptionsView, error) } // TaskRouting is one task's models as the engine recorded them. @@ -82,7 +83,12 @@ type TaskRouting struct { StoppedAction string `json:"stopped_action,omitempty"` Changes []TaskRoutingChange `json:"changes"` ChangeCount int `json:"change_count"` - Blocked *TaskRoutingWait `json:"blocked,omitempty"` + // RestoreCount is every restart; Restored keeps the newest of them. + RestoreCount int `json:"restore_count"` + // RoleFacts is false on a task from before per-role facts were kept: + // its role use is unknown and its spend may include unpriced calls. + RoleFacts bool `json:"role_facts"` + Blocked *TaskRoutingWait `json:"blocked,omitempty"` // Requested is a not-started or waiting task's own per-role choice. Requested *TaskRoutingPicks `json:"requested,omitempty"` } @@ -97,24 +103,31 @@ type TaskRoutingRole struct { Source string `json:"source" enum:"auto,pinned,task_pin,one_model"` // Status is whether the role has answered: pending is a person's change // not yet answered at the next step. - Status string `json:"status" enum:"used,pending,not_run_yet,not_used"` - Quality float64 `json:"quality"` - EstimateUSD *float64 `json:"estimate_usd"` - SpentUSD float64 `json:"spent_usd"` + // unknown is a role of an older task that kept no per-role facts. + Status string `json:"status" enum:"used,pending,not_run_yet,not_used,unknown"` + Quality float64 `json:"quality"` + EstimateUSD *float64 `json:"estimate_usd"` + // Price is the provider's own tariff the role was admitted at; null is + // unknown (an older decision, or a route that published none). + Price *TaskModelPrice `json:"price"` + // SpentUSD is null when the role's spend was never recorded. + SpentUSD *float64 `json:"spent_usd"` Calls int `json:"calls"` UnknownCalls int `json:"unknown_calls"` - // Alternatives are the models the router lined up behind this role, in - // order; Rejected are routes that failed on this task. + // Alternatives are the router's fallback rungs for the role, in order — + // where it goes when its route fails, not a stronger pick; Rejected are + // routes that failed on this task. Alternatives []TaskRoutingOption `json:"alternatives"` Rejected []TaskRoutingRejection `json:"rejected"` } // TaskRoutingOption is one model lined up behind a role. type TaskRoutingOption struct { - Model ModelRef `json:"model"` - Billing string `json:"billing,omitempty" enum:"metered,plan,local,free"` - Quality float64 `json:"quality"` - EstimateUSD *float64 `json:"estimate_usd"` + Model ModelRef `json:"model"` + Billing string `json:"billing,omitempty" enum:"metered,plan,local,free"` + Quality float64 `json:"quality"` + EstimateUSD *float64 `json:"estimate_usd"` + Price *TaskModelPrice `json:"price"` } // TaskRoutingRejection is one route that failed a role on this task; Reason @@ -367,3 +380,62 @@ func decodeTaskRoutingAct(raw json.RawMessage) (TaskRoutingActParams, error) { } return p, nil } + +// TaskRoutingMove is one role a change would move. +type TaskRoutingMove struct { + Role string `json:"role" enum:"worker,planner,checker"` + From ModelRef `json:"from"` + To ModelRef `json:"to"` + ToPrice *TaskModelPrice `json:"to_price"` + ToEstimateUSD *float64 `json:"to_estimate_usd"` +} + +// TaskRoutingPreview is what one change would do, worked out by the engine +// and moving nothing; Refused is the refusal the change would meet. +type TaskRoutingPreview struct { + Moves []TaskRoutingMove `json:"moves"` + EstimateUSD *float64 `json:"estimate_usd"` + CapUSD *float64 `json:"cap_usd"` + OverCap bool `json:"over_cap"` + Refused *TaskRoutingRefused `json:"refused,omitempty"` +} + +// TaskRoutingOptionsView is tasks.routing.options's answer: what Change +// models… (or Rerun with…) offers this task, each with the engine's preview. +type TaskRoutingOptionsView struct { + TaskID string `json:"task_id"` + Scope string `json:"scope" enum:"next_step,rerun,before_start,waiting,none"` + Stronger *TaskRoutingPreview `json:"stronger,omitempty"` + Best *TaskRoutingPreview `json:"best,omitempty"` + Pick bool `json:"pick"` + Auto bool `json:"auto"` + Retry bool `json:"retry"` +} + +// TaskRoutingOptionsResult wraps the answer. +type TaskRoutingOptionsResult struct { + Options TaskRoutingOptionsView `json:"options"` +} + +func init() { + methods["tasks.routing.options"] = typed(func(e Engine, p IDParams) (*TaskRoutingOptionsResult, error) { + r, err := taskRouter(e, "tasks.routing.options") + if err != nil { + return nil, err + } + id, err := required("id", p.ID) + if err != nil { + return nil, err + } + got, err := r.TaskRoutingOptions(id) + if err != nil { + return nil, err + } + for _, pv := range []*TaskRoutingPreview{got.Stronger, got.Best} { + if pv != nil { + pv.Moves = nonNil(pv.Moves) + } + } + return &TaskRoutingOptionsResult{got}, nil + }) +} diff --git a/internal/remote/callclass.go b/internal/remote/callclass.go index d1707af474..1a2763b554 100644 --- a/internal/remote/callclass.go +++ b/internal/remote/callclass.go @@ -146,7 +146,7 @@ const lateCallTail = " did not answer in time" func classify(method string) callClass { switch method { - case MethodAppTasks, MethodAppTask, MethodAppTaskRouting, MethodAppQuickTasks, MethodPing, + case MethodAppTasks, MethodAppTask, MethodAppTaskRouting, MethodAppTaskRoutingOptions, MethodAppQuickTasks, MethodPing, MethodModel, MethodTitle, MethodUsage, MethodContextTokens, MethodTranscript, MethodEarlier, MethodRewindPoints, MethodPlanSpend, MethodPlanTasks, MethodPlanTaskPage, MethodPlanTaskWork, MethodPlanRunSummary, MethodRefreshRunSummary, diff --git a/internal/remote/server.go b/internal/remote/server.go index 5ae098b718..8ae5fdcf99 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -2109,7 +2109,7 @@ func (s *server) invoke(call Frame) (out json.RawMessage, err error) { case MethodProcessList, MethodProcessRerun: return serveAppProcesses(agent, call) case MethodAppTasks, MethodAppTask, MethodAppTaskAction, MethodAppQuickTasks, MethodAppQuickTaskAction, MethodAppMoveToTask, MethodAppResumeAfterQuit, MethodAppRename, - MethodAppTaskRouting, MethodAppTaskRoutingAct: + MethodAppTaskRouting, MethodAppTaskRoutingAct, MethodAppTaskRoutingOptions: return dispatchAppTask(agent, call) case MethodTaskRetry: args, err := arg[TaskSetupArgs](call) diff --git a/internal/remote/wire_app_task.go b/internal/remote/wire_app_task.go index d660dbbd23..2c477f59ee 100644 --- a/internal/remote/wire_app_task.go +++ b/internal/remote/wire_app_task.go @@ -28,12 +28,15 @@ const ( // changes them ([session.Agent.TaskRouting], [session.Agent.TaskRoutingAct]). MethodAppTaskRouting = "App.TaskRouting" MethodAppTaskRoutingAct = "App.TaskRoutingAct" + // MethodAppTaskRoutingOptions previews what each change would do. + MethodAppTaskRoutingOptions = "App.TaskRoutingOptions" ) // appRoutingDoor is the conversation whose tasks' models can be read and moved. type appRoutingDoor interface { TaskRouting(id string, changes int) (session.TaskRoutingView, error) TaskRoutingAct(ctx context.Context, id string, act session.TaskRoutingAction) (session.TaskRoutingResult, error) + TaskRoutingOptions(id string) (session.TaskRoutingOptions, error) } // AppTaskRoutingArgs is one task's routing read or change. @@ -45,6 +48,7 @@ type AppTaskRoutingArgs struct { // appRoutingActResult carries a typed refusal across the socket intact. type appRoutingActResult struct { + Options *session.TaskRoutingOptions `json:",omitempty"` Result session.TaskRoutingResult Refusal *session.TaskRoutingRefusal `json:",omitempty"` } @@ -80,7 +84,7 @@ func dispatchAppTask(agent any, call Frame) (json.RawMessage, error) { return nil, errors.New("engine: app tasks are unavailable") } switch call.Method { - case MethodAppTaskRouting, MethodAppTaskRoutingAct: + case MethodAppTaskRouting, MethodAppTaskRoutingAct, MethodAppTaskRoutingOptions: routing, ok := agent.(appRoutingDoor) if !ok { return nil, errors.New("engine: task models are unavailable") @@ -89,6 +93,13 @@ func dispatchAppTask(agent any, call Frame) (json.RawMessage, error) { if err != nil { return nil, err } + if call.Method == MethodAppTaskRoutingOptions { + options, err := routing.TaskRoutingOptions(args.ID) + if err != nil { + return appRoutingFailure(err) + } + return json.Marshal(appRoutingActResult{Options: &options}) + } if call.Method == MethodAppTaskRouting { view, err := routing.TaskRouting(args.ID, args.Changes) if err != nil { @@ -259,3 +270,22 @@ func (a *Agent) TaskRouting(id string, changes int) (session.TaskRoutingView, er func (a *Agent) TaskRoutingAct(_ context.Context, id string, act session.TaskRoutingAction) (session.TaskRoutingResult, error) { return a.routingCall(MethodAppTaskRoutingAct, AppTaskRoutingArgs{ID: id, Act: &act}) } + +// TaskRoutingOptions previews what each change would do to one task. +func (a *Agent) TaskRoutingOptions(id string) (session.TaskRoutingOptions, error) { + raw, err := a.c.call(nil, MethodAppTaskRoutingOptions, AppTaskRoutingArgs{ID: id}) + if err != nil { + return session.TaskRoutingOptions{}, err + } + var result appRoutingActResult + if err := json.Unmarshal(raw, &result); err != nil { + return session.TaskRoutingOptions{}, err + } + if result.Refusal != nil { + return session.TaskRoutingOptions{}, result.Refusal + } + if result.Options == nil { + return session.TaskRoutingOptions{}, errors.New("engine: no task models options came back") + } + return *result.Options, nil +} diff --git a/internal/session/task_app_runs.go b/internal/session/task_app_runs.go index 08e76183f5..56afbeb115 100644 --- a/internal/session/task_app_runs.go +++ b/internal/session/task_app_runs.go @@ -71,14 +71,16 @@ func (a *Agent) appRunRows() []TaskNotice { return graph.runRowsLocked() } -// appRunRow is one run row by its number. +// appRunRow is one run row by its number, found where it is kept: nothing +// else is copied to find it. func (a *Agent) appRunRow(id uint64) (TaskNotice, bool) { - for _, row := range a.appRunRows() { - if row.ID == id { - return row, true - } + graph := a.tasker() + if graph == nil { + return TaskNotice{}, false } - return TaskNotice{}, false + graph.mu.Lock() + defer graph.mu.Unlock() + return graph.runRowLocked(id) } // appRunView is one run row as the app's task. diff --git a/internal/session/task_run.go b/internal/session/task_run.go index b9c8668dfc..33c867be2b 100644 --- a/internal/session/task_run.go +++ b/internal/session/task_run.go @@ -1241,6 +1241,23 @@ func (g *TaskGraph) runRowsLocked() []TaskNotice { return out } +// runRowLocked is one row of a run by its number, with the same visibility +// as [TaskGraph.runRowsLocked]: a deleted row, or one under a deleted parent, +// is not found. +func (g *TaskGraph) runRowLocked(id uint64) (TaskNotice, bool) { + for _, root := range g.runRuns { + for i := range g.runs[root] { + if row := &g.runs[root][i]; row.ID == id { + if g.deleted[row.ID] || g.deleted[row.Parent] { + return TaskNotice{}, false + } + return *row, true + } + } + } + return TaskNotice{}, false +} + func newTaskGraph() *TaskGraph { // A GRAPH IS ALONE IN ITS PROCESS UNTIL IT IS TOLD OTHERWISE. The caller // that knows several conversations share one machine says so by putting its diff --git a/internal/session/taskcrew.go b/internal/session/taskcrew.go index 2ccb532eea..c19dad33ec 100644 --- a/internal/session/taskcrew.go +++ b/internal/session/taskcrew.go @@ -57,13 +57,16 @@ type crewWish struct { sends map[crewroute.Seat]string // taskCap is a task cap a person explicitly raised for this one task. taskCap float64 + // autoSeats are roles a person asked Auto for, this task only, over a + // saved pin or one-model mode (Best, a stronger worker). + autoSeats map[crewroute.Seat]bool } type crewWishKey struct{} // withCrewWish hands a start door the crew wish; an empty wish is no value. func withCrewWish(ctx context.Context, wish crewWish) context.Context { - if wish.effort == "" && wish.stronger == nil && wish.again == nil && wish.worker == "" && len(wish.sends) == 0 && wish.taskCap == 0 { + if wish.effort == "" && wish.stronger == nil && wish.again == nil && wish.worker == "" && len(wish.sends) == 0 && wish.taskCap == 0 && len(wish.autoSeats) == 0 { return ctx } return context.WithValue(ctx, crewWishKey{}, wish) @@ -136,7 +139,14 @@ type taskCrew struct { taskPins map[crewroute.Seat]bool pending map[crewroute.Seat]bool // restored is every time this crew came back from its checkpoint. - restored []time.Time + restored []time.Time + restoreCount int + // seatStarted are the roles that have had a call answered on this task, + // marked by the role that made it. roleFacts is a crew whose per-role + // facts (these, and the role tallies) were kept from its admission; a crew + // restored from an older checkpoint has none, and its role use is unknown. + seatStarted map[crewroute.Seat]bool + roleFacts bool // seatSends are roles a person moved on this task, by role rather than by // the send id the engine asks for, so two roles that share a model can // part ([Agent.TaskRoutingAct]). @@ -205,7 +215,7 @@ func (a *Agent) routeTaskCrew(ctx context.Context, row uint64, title, brief stri repo := canonicalPath(a.config.Workspace) ask := config.CrewAsk{ Task: crewroute.Task{Text: strings.TrimSpace(title + "\n\n" + brief)}, - Effort: wish.effort, Stronger: wish.stronger, Again: wish.again, Repo: repo, + Effort: wish.effort, Stronger: wish.stronger, Again: wish.again, Repo: repo, AutoSeats: wish.autoSeats, ChatModel: a.Model(), } // A MODEL NAMED FOR THE TASK SEATS ITS WORKER, as a one-task pin: the one @@ -234,7 +244,8 @@ func (a *Agent) routeTaskCrew(ctx context.Context, row uint64, title, brief stri return nil, err } crew := &taskCrew{ - call: router.CrewCallID(a.journalID() + ":" + strconv.FormatUint(row, 10)), decision: decision, + roleFacts: true, + call: router.CrewCallID(a.journalID() + ":" + strconv.FormatUint(row, 10)), decision: decision, title: title, brief: brief, repo: repo, original: map[crewroute.Seat]string{}, ladders: decision.Ladder, } @@ -459,7 +470,7 @@ func (c crewSeatCompleter) completeSeat(ctx context.Context, key string, message response, err := c.agent.completeWithModel(provider.WithoutPatientRateLimits(asCrewSeatCall(callCtx)), purposeInherited, messages, current, options...) crew.guard.after(ctx, current, response, held) if err == nil { - c.agent.crewAnswered(c.run, current) + c.agent.crewAnswered(c.run, current, seat) return response, nil } if ctx.Err() != nil || crew.hasStarted(current) { @@ -832,26 +843,32 @@ func (c *taskCrew) anyStarted() bool { return len(c.started) > 0 } -// crewAnswered notes that a send id answered its first call on this task, and -// writes the route's success to the router's log once. -func (a *Agent) crewAnswered(run *beltRun, send string) { +// crewAnswered notes that a role's call answered on send: the role has run — +// that role only, never every role on the same model — and the first answer +// on send writes the route's success to the router's log once. An empty seat +// is a call no role was marked for, which marks no role. +func (a *Agent) crewAnswered(run *beltRun, send string, seat crewroute.Seat) { crew := run.crew crew.mu.Lock() - if crew.started[send] { - crew.mu.Unlock() - return - } - if crew.started == nil { - crew.started = map[string]bool{} + if seat != "" { + if crew.seatStarted == nil { + crew.seatStarted = map[crewroute.Seat]bool{} + } + crew.seatStarted[seat] = true + delete(crew.pending, seat) } - crew.started[send] = true - decision := crew.decision - for _, seat := range crewroute.Seats { - if decision.Seat(seat).Send == send { - delete(crew.pending, seat) + first := !crew.started[send] + if first { + if crew.started == nil { + crew.started = map[string]bool{} } + crew.started[send] = true } + decision := crew.decision crew.mu.Unlock() + if !first { + return + } for _, seat := range crewroute.Seats { if pick := decision.Seat(seat); pick.Send == send { config.LogCrewRoute(a.config.ProfileDir, crew.call, decision, crew.repo, crew.title, seat, pick, "", time.Time{}) diff --git a/internal/session/taskcrew_record.go b/internal/session/taskcrew_record.go index 369380a594..8c95ca387a 100644 --- a/internal/session/taskcrew_record.go +++ b/internal/session/taskcrew_record.go @@ -36,12 +36,20 @@ type TaskCrewRecord struct { // all. TaskPins and Pending are the roles a person pinned for this task // and those whose change has not answered yet. Restored is every time the // crew came back from this checkpoint. All are absent on older rows. - Changes []TaskCrewChange `json:",omitempty"` - ChangeCount int `json:",omitempty"` - TaskPins map[crewroute.Seat]bool `json:",omitempty"` - Pending map[crewroute.Seat]bool `json:",omitempty"` - Restored []time.Time `json:",omitempty"` - SeatSends map[crewroute.Seat]string `json:",omitempty"` + Changes []TaskCrewChange `json:",omitempty"` + ChangeCount int `json:",omitempty"` + TaskPins map[crewroute.Seat]bool `json:",omitempty"` + Pending map[crewroute.Seat]bool `json:",omitempty"` + Restored []time.Time `json:",omitempty"` + // RestoreCount counts every restart; Restored keeps the newest 16. + RestoreCount int `json:",omitempty"` + SeatSends map[crewroute.Seat]string `json:",omitempty"` + // RoleFacts is 1 on a crew whose per-role facts — SeatStarted and the + // role tallies with their call counts — were kept since its admission. + // Absent (0) is an older checkpoint: which role ran, and whether a zero + // role spend was a real zero, is unknown. + RoleFacts int `json:",omitempty"` + SeatStarted map[crewroute.Seat]bool `json:",omitempty"` } type crewFailureRecord struct { @@ -85,7 +93,10 @@ func (c *taskCrew) record() *TaskCrewRecord { r := &TaskCrewRecord{Version: 1, Routed: true, Call: c.call, Title: c.title, Brief: c.brief, Repo: c.repo, Decision: c.decision, Original: c.original, Ladders: c.ladders, Rescue: c.rescue, Started: c.started, Bad: c.bad, Broke: c.broke, Gone: c.gone, Swaps: c.swaps, FreeTried: c.freeTried, HelperUSD: c.helperUSD, DayAtStart: c.dayAtStart, InFlight: c.inFlight, Failed: map[string]crewFailureRecord{}, - Changes: c.changes, ChangeCount: c.changeCount, TaskPins: c.taskPins, Pending: c.pending, Restored: c.restored, SeatSends: c.seatSends} + Changes: c.changes, ChangeCount: c.changeCount, TaskPins: c.taskPins, Pending: c.pending, Restored: c.restored, RestoreCount: c.restoreCount, SeatSends: c.seatSends, SeatStarted: c.seatStarted} + if c.roleFacts { + r.RoleFacts = 1 + } for send, f := range c.failed { msg := "" if f.err != nil { @@ -166,9 +177,11 @@ func (a *Agent) restoreTaskCrew(row uint64, r *TaskCrewRecord) (*taskCrew, error r = &saved c := &taskCrew{call: r.Call, title: r.Title, brief: r.Brief, repo: r.Repo, decision: r.Decision, original: r.Original, ladders: r.Ladders, rescue: r.Rescue, started: r.Started, bad: r.Bad, broke: r.Broke, gone: r.Gone, swaps: r.Swaps, freeTried: r.FreeTried, helperUSD: r.HelperUSD, dayAtStart: r.DayAtStart, failed: map[string]crewFailure{}, - changes: r.Changes, changeCount: r.ChangeCount, taskPins: r.TaskPins, pending: r.Pending, seatSends: r.SeatSends} + changes: r.Changes, changeCount: r.ChangeCount, taskPins: r.TaskPins, pending: r.Pending, seatSends: r.SeatSends, + seatStarted: r.SeatStarted, roleFacts: r.RoleFacts >= 1} // THE RESTART IS A FACT OF THE TASK, said once in its history and kept: // the picks below are the ones it was admitted with, not a new decision. + c.restoreCount = r.RestoreCount c.noteRestored(a.taskClockNow(), r.Restored) for send, f := range r.Failed { c.failed[send] = crewFailure{kind: f.Kind, until: f.Until, err: errors.New(f.Message)} diff --git a/internal/session/taskrouting.go b/internal/session/taskrouting.go index 0b37559bb7..e789443688 100644 --- a/internal/session/taskrouting.go +++ b/internal/session/taskrouting.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "maps" "math" "strconv" "strings" @@ -109,6 +110,10 @@ func (c *taskCrew) noteRestored(at time.Time, prior []time.Time) { c.mu.Lock() defer c.mu.Unlock() c.restored = append(append([]time.Time(nil), prior...), at) + if c.restoreCount < len(prior) { + c.restoreCount = len(prior) // an older row counted only what it kept + } + c.restoreCount++ if over := len(c.restored) - taskCrewRestoresKept; over > 0 { c.restored = c.restored[over:] } @@ -144,6 +149,8 @@ const ( TaskRolePending = "pending" TaskRoleNotRunYet = "not_run_yet" TaskRoleNotUsed = "not_used" + // TaskRoleUnknown is a role of an older task that kept no per-role facts. + TaskRoleUnknown = "unknown" ) // TaskRoutingView is one task's models as recorded. @@ -175,7 +182,14 @@ type TaskRoutingView struct { // Changes are the newest changes, oldest first; ChangeCount is every one. Changes []TaskCrewChange ChangeCount int - Blocked *TaskRoutingBlock + // RestoreCount is every restart; Restored keeps the newest of them. + RestoreCount int + // RoleFacts is whether per-role facts — which role ran, and each role's + // spend with its call counts — were kept since admission. A task from an + // older build has none: its role use is unknown and a zero role spend is + // not a proven zero, and its SpentUSD may include calls of unknown cost. + RoleFacts bool + Blocked *TaskRoutingBlock // Requested is a not-started task's per-role choice; Effort "best" there // is a not-started task asked for the best crew. Requested map[crewroute.Seat]string @@ -183,18 +197,26 @@ type TaskRoutingView struct { // TaskRoutingRole is one role of a routed task. type TaskRoutingRole struct { - Seat crewroute.Seat - Model, Provider string - Send string - Kind crewroute.RouteKind - Source string - Status string - Quality float64 - EstimateUSD *float64 + Seat crewroute.Seat + Model, Provider string + Send string + Kind crewroute.RouteKind + Source string + Status string + Quality float64 + EstimateUSD *float64 + // Price is the provider's own tariff the role was admitted at, per token; + // nil is unknown (an older decision, or a route that published none). + Price *crewroute.RoutePrice + // SpentKnown is false when the role's spend was never recorded (an older + // task with nothing on the role): SpentUSD is then not a proven zero. SpentUSD float64 + SpentKnown bool Calls, UnknownCalls int - Alternatives []TaskRoutingOption - Rejected []TaskRoutingRejection + // Alternatives are the router's fallback rungs for the role — where it + // goes if its route fails — not a stronger pick. + Alternatives []TaskRoutingOption + Rejected []TaskRoutingRejection } // TaskRoutingOption is one model the router lined up behind a role. @@ -203,6 +225,7 @@ type TaskRoutingOption struct { Kind crewroute.RouteKind Quality float64 EstimateUSD *float64 + Price *crewroute.RoutePrice } // TaskRoutingRejection is one route that failed this task's role. @@ -256,9 +279,7 @@ func (a *Agent) TaskRouting(id string, changes int) (TaskRoutingView, error) { if row, ok := a.appRunRow(n); ok { var record *TaskCrewRecord if crew := a.crews.get(n); crew != nil { - crew.persistMu.Lock() - record = crew.record() - crew.persistMu.Unlock() + record = crew.routingRecord(changes) } else if row.CrewState != nil { record = row.CrewState } else if row.Crew != nil { @@ -303,10 +324,12 @@ func taskRoutingOf(id string, r *TaskCrewRecord, settled bool, changes int) Task view.Calls, view.UnknownCalls = g.TaskCalls, g.TaskUnknown } view.SpentUSD += r.HelperUSD + facts := r.RoleFacts >= 1 + view.RoleFacts, view.RestoreCount = facts, r.RestoreCount known, total := true, 0.0 for _, seat := range crewroute.Seats { pick := d.Seat(seat) - role := TaskRoutingRole{Seat: seat, Model: pick.Model, Provider: pick.Provider, Send: pick.Send, Kind: pick.Kind, Quality: pick.Quality, EstimateUSD: pickEstimate(pick)} + role := TaskRoutingRole{Seat: seat, Model: pick.Model, Provider: pick.Provider, Send: pick.Send, Kind: pick.Kind, Quality: pick.Quality, EstimateUSD: pickEstimate(pick), Price: clonePrice(pick.Price)} switch { case mode == crewroute.ModeOne: role.Source = TaskRoleOneModel @@ -326,12 +349,18 @@ func taskRoutingOf(id string, r *TaskCrewRecord, settled bool, changes int) Task role.SpentUSD = g.SeatSpent[seat] role.Calls, role.UnknownCalls = g.SeatCalls[seat], g.SeatUnknown[seat] } - used := role.Calls > 0 || role.SpentUSD > 0 || (pick.Send != "" && r.Started[pick.Send]) + // A ROLE RAN WHEN ITS OWN CALL ANSWERED — never because another role + // on the same model did. Without per-role facts (an older task) a role + // with nothing recorded is unknown, not unused and not free. + used := r.SeatStarted[seat] || role.Calls > 0 || role.SpentUSD > 0 + role.SpentKnown = facts || role.SpentUSD > 0 switch { case r.Pending[seat]: role.Status = TaskRolePending case used: role.Status = TaskRoleUsed + case !facts: + role.Status = TaskRoleUnknown case settled: role.Status = TaskRoleNotUsed default: @@ -344,7 +373,7 @@ func taskRoutingOf(id string, r *TaskCrewRecord, settled bool, changes int) Task if rung.Send == pick.Send { continue } - role.Alternatives = append(role.Alternatives, TaskRoutingOption{Model: rung.Model, Provider: rung.Provider, Kind: rung.Kind, Quality: rung.Quality, EstimateUSD: pickEstimate(rung)}) + role.Alternatives = append(role.Alternatives, TaskRoutingOption{Model: rung.Model, Provider: rung.Provider, Kind: rung.Kind, Quality: rung.Quality, EstimateUSD: pickEstimate(rung), Price: clonePrice(rung.Price)}) } sends := map[string]bool{r.Original[seat]: true} for _, change := range r.Changes { @@ -544,9 +573,10 @@ func (a *Agent) actOnProposal(n uint64, act TaskRoutingAction) error { a.taskRouting.change(n, func(w *crewWish) { switch act.Kind { case TaskRoutingAuto: - w.sends, w.effort = nil, "" + w.sends, w.effort, w.autoSeats = nil, "", nil case TaskRoutingBest: w.sends, w.effort = nil, crewroute.EffortBest + w.autoSeats = map[crewroute.Seat]bool{crewroute.Worker: true, crewroute.Planner: true, crewroute.Checker: true} case TaskRoutingPick: if w.sends == nil { w.sends = map[crewroute.Seat]string{} @@ -591,17 +621,37 @@ func blockOf(none *crewroute.NoEligibleError, at time.Time) TaskRoutingBlock { Omitted: none.Omitted, Action: none.Action, Message: none.Error(), At: at} } -// actOnLiveCrew moves a running task's roles at their next call. -func (a *Agent) actOnLiveCrew(n uint64, crew *taskCrew, act TaskRoutingAction) ([]crewroute.Seat, error) { +// routingPlan is what one change would do to a running crew, worked out by +// the same policy whether it is then applied or only shown. +type routingPlan struct { + cur, next crewroute.Decision + // routed is the router's answer, whose ladders the moved roles take. + routed crewroute.Decision + changed []crewroute.Seat + estimate float64 + known bool + capUSD float64 + count int +} + +// overCap is whether the plan's estimate is over the task cap. +func (p routingPlan) overCap() bool { return p.known && p.capUSD > 0 && p.estimate > p.capUSD+1e-9 } + +// planLiveChange asks the router for a running task's change without moving +// anything: Best and Stronger name their roles for Auto this once +// (AutoSeats), so a saved pin or one-model mode does not hold them; an explicit +// pick wins; Back to Auto names nothing and gets the saved policy back. +func (a *Agent) planLiveChange(crew *taskCrew, act TaskRoutingAction) (routingPlan, error) { route := a.config.RouteCrew if route == nil { - return nil, refuse(TaskRoutingUnavailable, "this conversation has no model router") + return routingPlan{}, refuse(TaskRoutingUnavailable, "this conversation has no model router") } if act.Kind == TaskRoutingSame || act.Kind == TaskRoutingRetry { - return nil, refuse(TaskRoutingBadAction, "%s is for a rerun or a waiting task", act.Kind) + return routingPlan{}, refuse(TaskRoutingBadAction, "%s is for a rerun or a waiting task", act.Kind) } crew.mu.Lock() cur, count := crew.decision, crew.changeCount + cur.Crew = append([]crewroute.Pick(nil), cur.Crew...) pinned := map[crewroute.Seat]bool{} for seat := range crew.taskPins { pinned[seat] = true @@ -611,7 +661,7 @@ func (a *Agent) actOnLiveCrew(n uint64, crew *taskCrew, act TaskRoutingAction) ( } crew.mu.Unlock() if act.ChangeCount != nil && *act.ChangeCount != count { - return nil, refuse(TaskRoutingStale, "this task's models changed · open it again") + return routingPlan{}, refuse(TaskRoutingStale, "this task's models changed · open it again") } ask := config.CrewAsk{Task: crewTaskText(crew.title, crew.brief), Repo: crew.repo, ChatModel: a.Model(), Sends: map[crewroute.Seat]string{}} keep := func(seats ...crewroute.Seat) { @@ -626,9 +676,11 @@ func (a *Agent) actOnLiveCrew(n uint64, crew *taskCrew, act TaskRoutingAction) ( case TaskRoutingStronger: keep(crewroute.Planner, crewroute.Checker) ask.Stronger = &cur + ask.AutoSeats = map[crewroute.Seat]bool{crewroute.Worker: true} targets = []crewroute.Seat{crewroute.Worker} case TaskRoutingBest: ask.Effort = crewroute.EffortBest + ask.AutoSeats = map[crewroute.Seat]bool{crewroute.Worker: true, crewroute.Planner: true, crewroute.Checker: true} targets = crewroute.Seats case TaskRoutingPick: for _, seat := range crewroute.Seats { @@ -652,7 +704,7 @@ func (a *Agent) actOnLiveCrew(n uint64, crew *taskCrew, act TaskRoutingAction) ( } } if len(targets) == 0 { - return nil, refuse(TaskRoutingUnchanged, "no role of this task was chosen for it · it is already on Auto") + return routingPlan{}, refuse(TaskRoutingUnchanged, "no role of this task was chosen for it · it is already on Auto") } } if len(ask.Sends) == 0 { @@ -660,11 +712,10 @@ func (a *Agent) actOnLiveCrew(n uint64, crew *taskCrew, act TaskRoutingAction) ( } decision, err := route(ask) if err != nil { - return nil, routeRefusal(err) + return routingPlan{}, routeRefusal(err) } - next := cur - next.Crew = append([]crewroute.Pick(nil), cur.Crew...) - var changed []crewroute.Seat + plan := routingPlan{cur: cur, routed: decision, count: count, next: cur} + plan.next.Crew = append([]crewroute.Pick(nil), cur.Crew...) for _, seat := range targets { pick := decision.Seat(seat) if pick.Send == "" { @@ -673,7 +724,7 @@ func (a *Agent) actOnLiveCrew(n uint64, crew *taskCrew, act TaskRoutingAction) ( if act.Kind == TaskRoutingPick { if want := strings.TrimSpace(act.Picks[seat]); want != "" { if pick.Send != want && pick.Model != want { - return nil, &TaskRoutingRefusal{Code: TaskRoutingNotAllowed, Seat: seat, + return routingPlan{}, &TaskRoutingRefusal{Code: TaskRoutingNotAllowed, Seat: seat, Message: fmt.Sprintf("%s cannot take the %s role here · %s", want, seat, warningFor(decision, seat))} } pick.Pinned = true @@ -686,34 +737,43 @@ func (a *Agent) actOnLiveCrew(n uint64, crew *taskCrew, act TaskRoutingAction) ( } } if _, failed := crew.failedHere(pick.Send); failed { - return nil, &TaskRoutingRefusal{Code: TaskRoutingRouteFailed, Seat: seat, + return routingPlan{}, &TaskRoutingRefusal{Code: TaskRoutingRouteFailed, Seat: seat, Message: fmt.Sprintf("%s already failed to start on this task", pick.Model)} } - for i := range next.Crew { - if next.Crew[i].Seat == seat { - next.Crew[i] = pick + for i := range plan.next.Crew { + if plan.next.Crew[i].Seat == seat { + plan.next.Crew[i] = pick } } - changed = append(changed, seat) + plan.changed = append(plan.changed, seat) } - if len(changed) == 0 { + if len(plan.changed) == 0 { if act.Kind == TaskRoutingStronger { - return nil, refuse(TaskRoutingStrongest, "this is already the strongest worker the allowed models make") + return routingPlan{}, refuse(TaskRoutingStrongest, "this is already the strongest worker the allowed models make") } - return nil, refuse(TaskRoutingUnchanged, "the models this asks for are the ones the task runs on") + return routingPlan{}, refuse(TaskRoutingUnchanged, "the models this asks for are the ones the task runs on") } - estimate, known := crewEstimate(next) - capUSD := 0.0 + plan.estimate, plan.known = crewEstimate(plan.next) if crew.guard != nil { crew.guard.mu.Lock() - capUSD = crew.guard.TaskCap + plan.capUSD = crew.guard.TaskCap crew.guard.mu.Unlock() } + return plan, nil +} + +// actOnLiveCrew moves a running task's roles at their next call. +func (a *Agent) actOnLiveCrew(n uint64, crew *taskCrew, act TaskRoutingAction) ([]crewroute.Seat, error) { + plan, err := a.planLiveChange(crew, act) + if err != nil { + return nil, err + } + cur, next, decision, changed, count, estimate, known := plan.cur, plan.next, plan.routed, plan.changed, plan.count, plan.estimate, plan.known raise := 0.0 - if known && capUSD > 0 && estimate > capUSD+1e-9 { + if plan.overCap() { if act.RaiseCapUSD+1e-9 < estimate { - return nil, &TaskRoutingRefusal{Code: TaskRoutingOverCap, RequiredUSD: estimate, CapUSD: capUSD, - Message: fmt.Sprintf("this needs more than the %s task cap · it needs %s", crewroute.Money(capUSD), crewroute.Money(estimate))} + return nil, &TaskRoutingRefusal{Code: TaskRoutingOverCap, RequiredUSD: estimate, CapUSD: plan.capUSD, + Message: fmt.Sprintf("this needs more than the %s task cap · it needs %s", crewroute.Money(plan.capUSD), crewroute.Money(estimate))} } raise = act.RaiseCapUSD } @@ -837,84 +897,110 @@ func (a *Agent) republishCrewRow(row uint64, decision crewroute.Decision, note s // rerunWithRouting starts a finished task again with the person's choice. The // finished task's record is never changed. -func (a *Agent) rerunWithRouting(ctx context.Context, row TaskNotice, crew *taskCrew, act TaskRoutingAction) (uint64, error) { - var prior *crewroute.Decision - brief, title := "", row.Title +// rerunPlan is a finished task's rerun worked out without starting it: its +// wish, its brief, and the router's answer for it. +type rerunPlan struct { + wish crewWish + title, brief string + prior *crewroute.Decision + routed crewroute.Decision + estimate float64 + known bool + capUSD float64 +} + +func (a *Agent) planRerun(row TaskNotice, crew *taskCrew, act TaskRoutingAction) (rerunPlan, error) { + p := rerunPlan{title: row.Title} switch { case crew != nil: d := crew.current() - prior, brief, title = &d, crew.brief, crew.title + p.prior, p.brief, p.title = &d, crew.brief, crew.title case row.CrewState != nil && row.CrewState.Routed: d := row.CrewState.Decision - prior, brief, title = &d, row.CrewState.Brief, row.CrewState.Title + p.prior, p.brief, p.title = &d, row.CrewState.Brief, row.CrewState.Title } - if brief == "" { - brief = row.Brief + if p.brief == "" { + p.brief = row.Brief } - if strings.TrimSpace(brief) == "" { - return 0, refuse(TaskRoutingNotRouted, "this task's brief was not kept · it cannot be rerun from here") + if strings.TrimSpace(p.brief) == "" { + return p, refuse(TaskRoutingNotRouted, "this task's brief was not kept · it cannot be rerun from here") } - a.beltMu.Lock() - live := a.beltRun != nil - a.beltMu.Unlock() - if live { - return 0, refuse(TaskRoutingBusy, "other work is still running · rerun this task when it has finished") - } - wish := crewWish{} sendsOf := func(seats ...crewroute.Seat) map[crewroute.Seat]string { out := map[crewroute.Seat]string{} for _, seat := range seats { - if prior != nil && prior.Seat(seat).Send != "" { - out[seat] = prior.Seat(seat).Send + if p.prior != nil && p.prior.Seat(seat).Send != "" { + out[seat] = p.prior.Seat(seat).Send } } return out } switch act.Kind { case TaskRoutingSame: - if prior == nil { - return 0, refuse(TaskRoutingNotRouted, "this task has no recorded roles to run again") + if p.prior == nil { + return p, refuse(TaskRoutingNotRouted, "this task has no recorded roles to run again") } - wish.sends = sendsOf(crewroute.Seats...) + p.wish.sends = sendsOf(crewroute.Seats...) case TaskRoutingStronger: - if prior == nil { - return 0, refuse(TaskRoutingNotRouted, "this task has no recorded worker to be stronger than") + if p.prior == nil { + return p, refuse(TaskRoutingNotRouted, "this task has no recorded worker to be stronger than") } - wish.sends, wish.stronger = sendsOf(crewroute.Planner, crewroute.Checker), prior + p.wish.sends, p.wish.stronger = sendsOf(crewroute.Planner, crewroute.Checker), p.prior + p.wish.autoSeats = map[crewroute.Seat]bool{crewroute.Worker: true} case TaskRoutingBest: - wish.effort = crewroute.EffortBest + p.wish.effort = crewroute.EffortBest + p.wish.autoSeats = map[crewroute.Seat]bool{crewroute.Worker: true, crewroute.Planner: true, crewroute.Checker: true} case TaskRoutingPick: - wish.sends = map[crewroute.Seat]string{} + p.wish.sends = map[crewroute.Seat]string{} for seat, model := range act.Picks { if model = strings.TrimSpace(model); model != "" { - wish.sends[seat] = model + p.wish.sends[seat] = model } } case TaskRoutingRetry: - return 0, refuse(TaskRoutingBadAction, "retry is for a waiting task") - } - if route := a.config.RouteCrew; route != nil { - // THE RERUN'S ESTIMATE IS READ BEFORE ANYTHING STARTS, so a choice - // over the task cap is refused with the cap it needs. - ask := config.CrewAsk{Task: crewTaskText(title, brief), Effort: wish.effort, Stronger: wish.stronger, Sends: wish.sends, - Repo: canonicalPath(a.config.Workspace), ChatModel: a.Model()} - if len(ask.Sends) == 0 { - ask.Sends = nil - } - decision, err := route(ask) - if err != nil { - return 0, routeRefusal(err) - } - capUSD, _ := config.CrewTaskSpendCap(a.config.ProfileDir) - if estimate, known := crewEstimate(decision); known && capUSD > 0 && estimate > capUSD+1e-9 { - if act.RaiseCapUSD+1e-9 < estimate { - return 0, &TaskRoutingRefusal{Code: TaskRoutingOverCap, RequiredUSD: estimate, CapUSD: capUSD, - Message: fmt.Sprintf("this needs more than the %s task cap · it needs %s", crewroute.Money(capUSD), crewroute.Money(estimate))} - } - wish.taskCap = act.RaiseCapUSD + return p, refuse(TaskRoutingBadAction, "retry is for a waiting task") + } + route := a.config.RouteCrew + if route == nil { + return p, refuse(TaskRoutingUnavailable, "this conversation has no model router") + } + ask := config.CrewAsk{Task: crewTaskText(p.title, p.brief), Effort: p.wish.effort, Stronger: p.wish.stronger, Sends: p.wish.sends, + AutoSeats: p.wish.autoSeats, Repo: canonicalPath(a.config.Workspace), ChatModel: a.Model()} + if len(ask.Sends) == 0 { + ask.Sends = nil + } + decision, err := route(ask) + if err != nil { + return p, routeRefusal(err) + } + p.routed = decision + p.estimate, p.known = crewEstimate(decision) + p.capUSD, _ = config.CrewTaskSpendCap(a.config.ProfileDir) + return p, nil +} + +// rerunWithRouting starts a finished task again with the person's choice. The +// finished task's record is never changed; a choice over the task cap is +// refused before anything starts, with the cap it needs. +func (a *Agent) rerunWithRouting(ctx context.Context, row TaskNotice, crew *taskCrew, act TaskRoutingAction) (uint64, error) { + a.beltMu.Lock() + live := a.beltRun != nil + a.beltMu.Unlock() + if live { + return 0, refuse(TaskRoutingBusy, "other work is still running · rerun this task when it has finished") + } + p, err := a.planRerun(row, crew, act) + if err != nil { + return 0, err + } + wish := p.wish + if p.known && p.capUSD > 0 && p.estimate > p.capUSD+1e-9 { + if act.RaiseCapUSD+1e-9 < p.estimate { + return 0, &TaskRoutingRefusal{Code: TaskRoutingOverCap, RequiredUSD: p.estimate, CapUSD: p.capUSD, + Message: fmt.Sprintf("this needs more than the %s task cap · it needs %s", crewroute.Money(p.capUSD), crewroute.Money(p.estimate))} } + wish.taskCap = act.RaiseCapUSD } - id, _, _, err := a.startTaskRun(withCrewWish(ctx, wish), brief, true, "") + id, _, _, err := a.startTaskRun(withCrewWish(ctx, wish), p.brief, true, "") if err != nil { var none *crewroute.NoEligibleError if errors.As(err, &none) || errors.Is(err, config.ErrCrewAtCap) || errors.Is(err, crewroute.ErrStrongest) { @@ -1026,6 +1112,9 @@ func (a *Agent) proposalCrewWish(id uint64, base crewWish) crewWish { if w.taskCap > 0 { base.taskCap = w.taskCap } + if len(w.autoSeats) > 0 { + base.autoSeats = w.autoSeats + } return base } @@ -1100,3 +1189,227 @@ func (a *Agent) DropParkedTask(n uint64) bool { a.settleUnstartedHandoff(n, parked.spec.title, errors.New(parked.block.Message)) return true } + +func clonePrice(p *crewroute.RoutePrice) *crewroute.RoutePrice { + if p == nil { + return nil + } + out := *p + return &out +} + +// routingRecord is the bounded part of a live crew one read draws: three +// picks, each role's first fallback rungs, the failures on its sends, the +// newest changes and the spend snapshot — never the whole checkpoint, and +// never under the checkpoint's lock. +func (c *taskCrew) routingRecord(changes int) *TaskCrewRecord { + c.mu.Lock() + d := c.decision + r := &TaskCrewRecord{Version: 1, Routed: true, HelperUSD: c.helperUSD, ChangeCount: c.changeCount, RestoreCount: c.restoreCount, + Decision: crewroute.Decision{Class: d.Class, Why: d.Why, Effort: d.Effort, Mode: d.Mode, Note: d.Note, Stopped: d.Stopped, + Considered: d.Considered, EstUSD: d.EstUSD, CostFactor: d.CostFactor, + Crew: append([]crewroute.Pick(nil), d.Crew...), Warnings: append([]crewroute.SeatWarning(nil), d.Warnings...)}, + Original: maps.Clone(c.original), TaskPins: maps.Clone(c.taskPins), Pending: maps.Clone(c.pending), + SeatStarted: maps.Clone(c.seatStarted), Restored: append([]time.Time(nil), c.restored...), + Ladders: map[crewroute.Seat][]crewroute.Pick{}, Failed: map[string]crewFailureRecord{}} + if c.roleFacts { + r.RoleFacts = 1 + } + from := len(c.changes) - changes + if from < 0 { + from = 0 + } + r.Changes = append([]TaskCrewChange(nil), c.changes[from:]...) + sends := map[string]bool{} + for seat, send := range c.original { + sends[send] = true + rungs := c.ladders[seat] + if len(rungs) > taskRoutingAlternatives+1 { + rungs = rungs[:taskRoutingAlternatives+1] + } + r.Ladders[seat] = append([]crewroute.Pick(nil), rungs...) + } + for _, change := range r.Changes { + sends[change.FromSend], sends[change.ToSend] = true, true + } + for send := range sends { + if f, ok := c.failed[send]; ok { + msg := "" + if f.err != nil { + msg = f.err.Error() + } + r.Failed[send] = crewFailureRecord{f.kind, f.until, msg} + } + } + g := c.guard + c.mu.Unlock() + if g != nil { + g.mu.Lock() + r.Guard = &crewGuardRecord{TaskCap: g.TaskCap, SeatSpent: map[crewroute.Seat]float64{}} + task, seats := g.Task, maps.Clone(g.seatSpent) + g.mu.Unlock() + // One booking's task and role figures, read together. + g.bookMu.Lock() + r.Guard.TaskSpent, r.Guard.TaskCalls, r.Guard.TaskUnknown = task.snapshot() + for seat, tally := range seats { + spent, calls, unknown := tally.snapshot() + r.Guard.SeatSpent[seat] = spent + if r.Guard.SeatCalls == nil { + r.Guard.SeatCalls, r.Guard.SeatUnknown = map[crewroute.Seat]int{}, map[crewroute.Seat]int{} + } + r.Guard.SeatCalls[seat], r.Guard.SeatUnknown[seat] = calls, unknown + } + g.bookMu.Unlock() + } + return r +} + +// ── what Change models… offers, worked out by the engine ──────────────────── + +// TaskRoutingMove is one role a change would move. +type TaskRoutingMove struct { + Seat crewroute.Seat + From, FromProvider string + To, ToProvider string + ToPrice *crewroute.RoutePrice + ToEstimateUSD *float64 +} + +// TaskRoutingPreview is what one change would do: the roles it moves, the +// estimate after it and the cap that estimate meets — or the refusal the +// change would meet. It moves nothing. +type TaskRoutingPreview struct { + Moves []TaskRoutingMove + EstimateUSD *float64 + CapUSD float64 + OverCap bool + Refusal *TaskRoutingRefusal +} + +// TaskRoutingOptions is what Change models… (or Rerun with…) offers a task. +type TaskRoutingOptions struct { + Task string + // Scope is when a change applies: next_step on a running task, rerun on a + // finished one, before_start on one not started, waiting on one parked; + // none on a task with no roles to change. + Scope string + Stronger, Best *TaskRoutingPreview + Pick, Auto, Retry bool +} + +// The scopes of a change. +const ( + TaskRoutingNextStep = "next_step" + TaskRoutingRerunScope = "rerun" + TaskRoutingBeforeStart = "before_start" + TaskRoutingWaiting = "waiting" + TaskRoutingNone = "none" +) + +// TaskRoutingOptions works out what each change would do for one task by the +// same policy [Agent.TaskRoutingAct] applies — the router asked once per +// offered change, nothing moved, no model called. A change the engine cannot +// work out ahead (a not-started task's Best) carries no preview. +func (a *Agent) TaskRoutingOptions(id string) (TaskRoutingOptions, error) { + n, err := strconv.ParseUint(strings.TrimSpace(id), 10, 64) + if err != nil || n == 0 { + return TaskRoutingOptions{}, refuse(TaskRoutingNotFound, "no such task") + } + out := TaskRoutingOptions{Task: id, Scope: TaskRoutingNone} + switch { + case a.taskRouting.isParked(n): + out.Scope, out.Pick, out.Retry = TaskRoutingWaiting, true, true + return out, nil + case a.isProposed(n): + w, _ := a.taskRouting.wish(n) + out.Scope, out.Pick, out.Auto = TaskRoutingBeforeStart, true, len(w.sends) > 0 || w.effort != "" + out.Best = &TaskRoutingPreview{} + return out, nil + } + row, ok := a.appRunRow(n) + if !ok { + if a.taskNode(n) != nil { + return out, nil + } + return TaskRoutingOptions{}, refuse(TaskRoutingNotFound, "no such task") + } + crew := a.crews.get(n) + if row.State.settled() { + if (crew == nil && (row.CrewState == nil || !row.CrewState.Routed)) || a.config.RouteCrew == nil { + return out, nil + } + out.Scope, out.Pick = TaskRoutingRerunScope, true + out.Stronger = a.rerunPreview(row, crew, TaskRoutingStronger) + out.Best = a.rerunPreview(row, crew, TaskRoutingBest) + return out, nil + } + if crew == nil { + return out, nil + } + out.Scope, out.Pick = TaskRoutingNextStep, true + crew.mu.Lock() + out.Auto = len(crew.taskPins) > 0 || len(crew.seatSends) > 0 + crew.mu.Unlock() + out.Stronger = a.livePreview(crew, TaskRoutingStronger) + out.Best = a.livePreview(crew, TaskRoutingBest) + return out, nil +} + +func previewRefusal(err error) *TaskRoutingPreview { + var refusal *TaskRoutingRefusal + if !errors.As(err, &refusal) { + refusal = &TaskRoutingRefusal{Code: TaskRoutingUnavailable, Message: err.Error()} + } + return &TaskRoutingPreview{Refusal: refusal} +} + +func movesOf(from, to crewroute.Decision, seats []crewroute.Seat) []TaskRoutingMove { + var out []TaskRoutingMove + for _, seat := range seats { + was, now := from.Seat(seat), to.Seat(seat) + if now.Send == "" || (was.Send == now.Send && was.Pinned == now.Pinned) { + continue + } + out = append(out, TaskRoutingMove{Seat: seat, From: was.Model, FromProvider: was.Provider, To: now.Model, ToProvider: now.Provider, + ToPrice: clonePrice(now.Price), ToEstimateUSD: pickEstimate(now)}) + } + return out +} + +func (a *Agent) livePreview(crew *taskCrew, kind string) *TaskRoutingPreview { + plan, err := a.planLiveChange(crew, TaskRoutingAction{Kind: kind}) + if err != nil { + return previewRefusal(err) + } + out := &TaskRoutingPreview{Moves: movesOf(plan.cur, plan.next, plan.changed), CapUSD: plan.capUSD, OverCap: plan.overCap()} + if plan.known { + estimate := plan.estimate + out.EstimateUSD = &estimate + } + return out +} + +func (a *Agent) rerunPreview(row TaskNotice, crew *taskCrew, kind string) *TaskRoutingPreview { + plan, err := a.planRerun(row, crew, TaskRoutingAction{Kind: kind}) + if err != nil { + return previewRefusal(err) + } + seats := crewroute.Seats + if kind == TaskRoutingStronger { + seats = []crewroute.Seat{crewroute.Worker} + } + from := crewroute.Decision{} + if plan.prior != nil { + from = *plan.prior + } + out := &TaskRoutingPreview{Moves: movesOf(from, plan.routed, seats), CapUSD: plan.capUSD, + OverCap: plan.known && plan.capUSD > 0 && plan.estimate > plan.capUSD+1e-9} + if plan.known { + estimate := plan.estimate + out.EstimateUSD = &estimate + } + if kind == TaskRoutingStronger && len(out.Moves) == 0 { + return &TaskRoutingPreview{Refusal: &TaskRoutingRefusal{Code: TaskRoutingStrongest, Message: "this is already the strongest worker the allowed models make"}} + } + return out +} diff --git a/internal/session/taskrouting_roles_test.go b/internal/session/taskrouting_roles_test.go new file mode 100644 index 0000000000..95f93ee9dd --- /dev/null +++ b/internal/session/taskrouting_roles_test.go @@ -0,0 +1,189 @@ +package session + +import ( + "context" + "encoding/json" + "net/http" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/crewroute" +) + +func flashDecision() crewroute.Decision { + d := crewroute.Decision{Mode: crewroute.ModeAuto} + for _, seat := range crewroute.Seats { + d.Crew = append(d.Crew, crewroute.Pick{Seat: seat, Model: "flash", Send: "flash", Provider: "openrouter", Kind: crewroute.Metered, EstUSD: 0.01, + Price: &crewroute.RoutePrice{Prompt: 1e-7, Completion: 4e-7}}) + } + return d +} + +func statuses(v TaskRoutingView) map[crewroute.Seat]string { + out := map[crewroute.Seat]string{} + for _, r := range v.Roles { + out[r.Seat] = r.Status + } + return out +} + +// ONE MODEL IN EVERY ROLE, THE WORKER ALONE HAS RUN: only the worker reads as +// used — through the real seat call, marked by the role that made it — the +// checker is not run yet while the task runs and not used once it settled +// without running, and a later checker call marks the checker alone. The +// facts survive a checkpoint and a restart. +func TestASharedModelMarksOnlyTheRoleThatRan(t *testing.T) { + stub := newCrewStub(t, http.StatusOK, map[string]int{}) + stub.cost = 0.002 + agent, _ := crewStubAgent(t, stub) + agent.config.RouteCrew = func(config.CrewAsk) (crewroute.Decision, error) { return flashDecision(), nil } + crew, err := agent.routeTaskCrew(t.Context(), 1, "fix the parser", "it crashes") + if err != nil { + t.Fatal(err) + } + run := &beltRun{row: 1, crew: crew} + call := func(seat crewroute.Seat) { + t.Helper() + ctx := context.WithValue(t.Context(), crewSeatContextKey{}, seat) + if _, err := (crewSeatCompleter{agent: agent, run: run}).CompleteWithMessages(ctx, []ai.Message{textMessage("user", "go")}, ai.WithModel("flash")); err != nil { + t.Fatalf("%s call: %v (asked %v)", seat, err, stub.models()) + } + } + call(crewroute.Worker) + got := statuses(taskRoutingOf("1", crew.routingRecord(20), false, 20)) + want := map[crewroute.Seat]string{crewroute.Worker: TaskRoleUsed, crewroute.Planner: TaskRoleNotRunYet, crewroute.Checker: TaskRoleNotRunYet} + for seat, w := range want { + if got[seat] != w { + t.Fatalf("worker only: %s is %s, want %s", seat, got[seat], w) + } + } + if got := statuses(taskRoutingOf("1", crew.routingRecord(20), true, 20)); got[crewroute.Checker] != TaskRoleNotUsed { + t.Fatalf("settled without a checker call: %s", got[crewroute.Checker]) + } + call(crewroute.Checker) + view := taskRoutingOf("1", crew.routingRecord(20), false, 20) + got = statuses(view) + if got[crewroute.Checker] != TaskRoleUsed || got[crewroute.Planner] != TaskRoleNotRunYet { + t.Fatalf("after the checker: %v", got) + } + if view.Roles[1].SpentUSD != 0 || !view.Roles[1].SpentKnown || view.Roles[0].SpentUSD <= 0 || view.Roles[2].SpentUSD <= 0 { + t.Fatalf("role spend %+v", view.Roles) + } + if p := view.Roles[0].Price; p == nil || p.Prompt != 1e-7 { + t.Fatalf("the admitted tariff was not kept: %+v", p) + } + + // Checkpoint and restart keep the per-role facts. + record := crew.record() + record.Routed = true + back, err := (&Agent{config: Config{Workspace: t.TempDir(), ProfileDir: t.TempDir()}}).restoreTaskCrew(1, record) + if err != nil { + t.Fatal(err) + } + got = statuses(taskRoutingOf("1", back.routingRecord(20), true, 20)) + if got[crewroute.Worker] != TaskRoleUsed || got[crewroute.Checker] != TaskRoleUsed || got[crewroute.Planner] != TaskRoleNotUsed { + t.Fatalf("after reload: %v", got) + } +} + +// AN OLDER CHECKPOINT KEPT NO PER-ROLE FACTS: a model-wide start never makes +// a role used, a role with nothing recorded is unknown — not unused, and not a +// proven $0 — and a role spend it did record is shown as recorded. +func TestAnOlderCheckpointInventsNoRoleFacts(t *testing.T) { + var legacy TaskCrewRecord + raw := `{"version":1,"routed":true,"Decision":{"Crew":[{"Seat":"worker","Model":"flash","Send":"flash"},{"Seat":"planner","Model":"flash","Send":"flash"},{"Seat":"checker","Model":"flash","Send":"flash"}]}, + "Original":{"worker":"flash","planner":"flash","checker":"flash"},"Started":{"flash":true}, + "Guard":{"TaskSpent":0.03,"SeatSpent":{"worker":0.03}}}` + if err := json.Unmarshal([]byte(raw), &legacy); err != nil { + t.Fatal(err) + } + view := taskRoutingOf("1", &legacy, true, 20) + if view.RoleFacts { + t.Fatal("an older checkpoint claimed per-role facts") + } + got := statuses(view) + if got[crewroute.Worker] != TaskRoleUsed || got[crewroute.Planner] != TaskRoleUnknown || got[crewroute.Checker] != TaskRoleUnknown { + t.Fatalf("legacy roles %v", got) + } + if view.Roles[1].SpentKnown || !view.Roles[0].SpentKnown { + t.Fatalf("legacy spend certainty %+v", view.Roles) + } + if view.Roles[0].Price != nil { + t.Fatal("an older pick was given a tariff") + } +} + +// One task's read finds its row where it is kept: its cost does not grow with +// every other task in the conversation. +func TestOneTaskReadDoesNotCopyTheWholeGraph(t *testing.T) { + read := func(rows int) float64 { + a, _ := newTestAgent(t, &recoveryModelRecorder{}, nil) + g := a.graph() + g.mu.Lock() + if g.runs == nil { + g.runs = map[uint64][]TaskNotice{} + } + for i := 1; i <= rows; i++ { + id := uint64(i) + g.runRuns = append(g.runRuns, id) + g.runs[id] = []TaskNotice{{ID: id, Title: "t", State: TaskDone, Model: "flash"}} + } + g.mu.Unlock() + last := uint64(rows) + return testing.AllocsPerRun(20, func() { + if _, ok := a.appRunRow(last); !ok { + t.Fatal("row not found") + } + }) + } + small, large := read(10), read(4000) + if large > small || large > 2 { + t.Fatalf("a row lookup allocates %v with 4000 tasks, %v with 10", large, small) + } +} + +// Change models… is the engine's own preview, by the same policy as the +// change: Stronger names the worker for Auto this once and shows the model +// and tariff it would move to; Best names all three and shows the cap it +// would meet; neither moves anything. +func TestTheOptionsPreviewIsTheEnginesOwnAndMovesNothing(t *testing.T) { + var asks []config.CrewAsk + a := routerAgent(t, func(ask config.CrewAsk) (crewroute.Decision, error) { + asks = append(asks, ask) + d := liveDecision() + for i := range d.Crew { + if ask.AutoSeats[d.Crew[i].Seat] { + d.Crew[i] = crewroute.Pick{Seat: d.Crew[i].Seat, Model: "sonnet", Send: "sonnet", Provider: "openrouter", Kind: crewroute.Metered, EstUSD: 1.0, + Price: &crewroute.RoutePrice{Prompt: 3e-6, Completion: 15e-6}} + } + } + return d, nil + }) + crew := liveCrew(2) + stronger := a.livePreview(crew, TaskRoutingStronger) + if stronger.Refusal != nil || len(stronger.Moves) != 1 || stronger.Moves[0].To != "sonnet" || stronger.Moves[0].From != "glm-flash" || stronger.Moves[0].ToPrice.Prompt != 3e-6 { + t.Fatalf("stronger preview %+v", stronger) + } + if !asks[0].AutoSeats[crewroute.Worker] || asks[0].AutoSeats[crewroute.Checker] || asks[0].Sends[crewroute.Checker] != "ds-flash" { + t.Fatalf("stronger asked %+v", asks[0]) + } + best := a.livePreview(crew, TaskRoutingBest) + if !best.OverCap || best.EstimateUSD == nil || *best.EstimateUSD != 3 || best.CapUSD != 2 || len(best.Moves) != 3 { + t.Fatalf("best preview %+v", best) + } + if len(asks[1].AutoSeats) != 3 { + t.Fatalf("best asked %+v", asks[1].AutoSeats) + } + if crew.changeCount != 0 || crew.sendForSeat(crewroute.Worker, "glm-flash") != "glm-flash" { + t.Fatal("a preview moved the crew") + } + // Back to Auto names no role for Auto: the saved policy comes back. + crew.taskPins = map[crewroute.Seat]bool{crewroute.Checker: true} + if _, err := a.actOnLiveCrew(1, crew, TaskRoutingAction{Kind: TaskRoutingAuto}); err != nil { + t.Fatal(err) + } + if last := asks[len(asks)-1]; len(last.AutoSeats) != 0 { + t.Fatalf("back to auto asked %+v", last.AutoSeats) + } +} diff --git a/internal/session/taskrouting_test.go b/internal/session/taskrouting_test.go index 0d79adeb9a..f09508956c 100644 --- a/internal/session/taskrouting_test.go +++ b/internal/session/taskrouting_test.go @@ -133,7 +133,7 @@ func originals(d crewroute.Decision) map[crewroute.Seat]string { func liveCrew(capUSD float64) *taskCrew { d := liveDecision() - return &taskCrew{title: "Update fixtures", brief: "update every fixture", decision: d, original: originals(d), + return &taskCrew{roleFacts: true, title: "Update fixtures", brief: "update every fixture", decision: d, original: originals(d), ladders: map[crewroute.Seat][]crewroute.Pick{crewroute.Worker: {{Seat: crewroute.Worker, Model: "glm-5.3", Send: "glm-5.3", Provider: "openrouter", Kind: crewroute.Metered, EstUSD: 0.4}}}, guard: &SpendGuard{TaskCap: capUSD}} } @@ -182,7 +182,7 @@ func TestStrongerWorkerMovesOnlyTheWorkerAtItsNextCall(t *testing.T) { t.Fatalf("estimate %v", view.EstimateUSD) } // The worker's first answer clears pending. - a.crewAnswered(&beltRun{row: 1, crew: crew}, "sonnet") + a.crewAnswered(&beltRun{row: 1, crew: crew}, "sonnet", crewroute.Worker) if view := taskRoutingOf("1", crew.record(), false, 20); view.Roles[0].Status != TaskRoleUsed { t.Fatalf("worker after answer %+v", view.Roles[0]) } From 27a0d0eb00a4875025e7ab3c3529514577b6e3a6 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 20:45:09 -0400 Subject: [PATCH 070/459] taskrouting: a role starts when its call leaves, and helpers count once A role is marked started the moment its admitted call is about to leave, not when the provider answers, so a worker streaming its first call reads as used while the checker on the same model still reads not run yet; a call the cap refuses never starts its role. The answered-route bookkeeping stays separate. The task's spent figure is its tally, which already holds helper calls; the helpers' part is a breakout, no longer added on top. A record that kept no task tally reads its spend as unknown. A bounded read takes the guard's lock and then the booking lock, the checkpoint's order, so a role's first booking is seen with exactly the task figure that includes it. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/apptaskrouting.go | 6 +- cmd/codeaf/apptaskrouting_test.go | 2 +- internal/appwire/taskrouting.go | 2 +- internal/session/taskcrew.go | 18 +++ internal/session/taskrouting.go | 35 +++-- internal/session/taskrouting_live_test.go | 165 ++++++++++++++++++++++ 6 files changed, 211 insertions(+), 17 deletions(-) create mode 100644 internal/session/taskrouting_live_test.go diff --git a/cmd/codeaf/apptaskrouting.go b/cmd/codeaf/apptaskrouting.go index ef1bd20842..6b05bac867 100644 --- a/cmd/codeaf/apptaskrouting.go +++ b/cmd/codeaf/apptaskrouting.go @@ -146,13 +146,17 @@ func appBlockedOf(b session.TaskRoutingBlock) *appwire.TaskRoutingWait { func appRoutingOf(id string, v session.TaskRoutingView) appwire.TaskRouting { out := appwire.TaskRouting{ TaskID: id, State: v.State, Mode: string(v.Mode), Running: v.Running, Settled: v.Settled, - Restored: v.Restored, EstimateUSD: v.EstimateUSD, SpentUSD: v.SpentUSD, Calls: v.Calls, UnknownCalls: v.UnknownCalls, + Restored: v.Restored, EstimateUSD: v.EstimateUSD, Calls: v.Calls, UnknownCalls: v.UnknownCalls, HelperUSD: v.HelperUSD, Class: v.Class, StoppedAction: v.Stopped, ChangeCount: v.ChangeCount, RestoreCount: v.RestoreCount, RoleFacts: v.RoleFacts, } if v.Effort == string(crewroute.EffortBest) || v.Effort == string(crewroute.EffortCheap) { out.Effort = v.Effort } + if v.SpentKnown { + spent := v.SpentUSD + out.SpentUSD = &spent + } if v.TaskCapUSD > 0 { capUSD := v.TaskCapUSD out.TaskCapUSD = &capUSD diff --git a/cmd/codeaf/apptaskrouting_test.go b/cmd/codeaf/apptaskrouting_test.go index 5a0de8ff75..3348f206bb 100644 --- a/cmd/codeaf/apptaskrouting_test.go +++ b/cmd/codeaf/apptaskrouting_test.go @@ -15,7 +15,7 @@ import ( func TestTaskRoutingReachesTheEngineAndRefusalsAreAnswers(t *testing.T) { est := 0.12 conv := &fakeConversation{routing: session.TaskRoutingView{ - State: session.TaskRoutingRouted, Mode: crewroute.ModeAuto, Running: true, EstimateUSD: &est, SpentUSD: 0.07, Calls: 3, UnknownCalls: 1, ChangeCount: 1, + State: session.TaskRoutingRouted, Mode: crewroute.ModeAuto, Running: true, EstimateUSD: &est, SpentUSD: 0.07, SpentKnown: true, Calls: 3, UnknownCalls: 1, ChangeCount: 1, Roles: []session.TaskRoutingRole{ {Seat: crewroute.Worker, Model: "z-ai/glm-5.3", Provider: "OpenRouter", Kind: crewroute.Metered, Source: session.TaskRoleAuto, Status: session.TaskRoleUsed, SpentUSD: 0.06, SpentKnown: true, Calls: 2, Price: &crewroute.RoutePrice{Prompt: 4e-7, Completion: 1.6e-6}}, diff --git a/internal/appwire/taskrouting.go b/internal/appwire/taskrouting.go index f9d56f8ce6..64afb937b9 100644 --- a/internal/appwire/taskrouting.go +++ b/internal/appwire/taskrouting.go @@ -69,7 +69,7 @@ type TaskRouting struct { EstimateUSD *float64 `json:"estimate_usd"` // SpentUSD is what calls with a known cost spent; UnknownCalls is how many // answered calls had no known cost and are NOT in SpentUSD. - SpentUSD float64 `json:"spent_usd"` + SpentUSD *float64 `json:"spent_usd"` Calls int `json:"calls"` UnknownCalls int `json:"unknown_calls"` HelperUSD float64 `json:"helper_usd"` diff --git a/internal/session/taskcrew.go b/internal/session/taskcrew.go index c19dad33ec..3b401ac134 100644 --- a/internal/session/taskcrew.go +++ b/internal/session/taskcrew.go @@ -462,6 +462,9 @@ func (c crewSeatCompleter) completeSeat(ctx context.Context, key string, message // says which line it met — never "/redo stronger". return nil, c.agent.stopCrew(c.run, err) } + // THE ROLE HAS STARTED the moment its call is admitted and about to + // leave — not when the provider answers — and only that role. + crew.seatStarting(seat) // A SEAT NEVER WAITS OUT A LIMIT: a 429 goes back at once, and the // seat moves to its next route or model ([provider.WithoutPatientRateLimits]). callCtx := provider.WithDiscardedUsage(ctx, func(_ string, _ string, response *ai.Response) { @@ -843,6 +846,21 @@ func (c *taskCrew) anyStarted() bool { return len(c.started) > 0 } +// seatStarting marks a role started at its admitted call and clears its +// pending change. An unmarked call (no seat) marks nothing. +func (c *taskCrew) seatStarting(seat crewroute.Seat) { + if seat == "" { + return + } + c.mu.Lock() + defer c.mu.Unlock() + if c.seatStarted == nil { + c.seatStarted = map[crewroute.Seat]bool{} + } + c.seatStarted[seat] = true + delete(c.pending, seat) +} + // crewAnswered notes that a role's call answered on send: the role has run — // that role only, never every role on the same model — and the first answer // on send writes the route's success to the router's log once. An empty seat diff --git a/internal/session/taskrouting.go b/internal/session/taskrouting.go index e789443688..3addf5cabe 100644 --- a/internal/session/taskrouting.go +++ b/internal/session/taskrouting.go @@ -171,14 +171,17 @@ type TaskRoutingView struct { // SpentUSD is what answered calls with a known cost spent, helpers // included; Calls and UnknownCalls count the answered calls and those // whose cost nobody could say, which SpentUSD does not include. + // SpentKnown is false on a record that kept no task tally. SpentUSD float64 + SpentKnown bool Calls, UnknownCalls int - HelperUSD float64 - TaskCapUSD float64 - Class, Why, Effort string - Note, Stopped string - Considered int - Warnings []crewroute.SeatWarning + // HelperUSD is the part of SpentUSD helper calls spent. + HelperUSD float64 + TaskCapUSD float64 + Class, Why, Effort string + Note, Stopped string + Considered int + Warnings []crewroute.SeatWarning // Changes are the newest changes, oldest first; ChangeCount is every one. Changes []TaskCrewChange ChangeCount int @@ -317,13 +320,16 @@ func taskRoutingOf(id string, r *TaskCrewRecord, settled bool, changes int) Task } else { view.Changes = append([]TaskCrewChange(nil), r.Changes...) } + // THE TASK'S TALLY ALREADY HOLDS ITS HELPERS: a helper call is guarded on + // the same task tally the roles are ([Agent.helperGuard]), so HelperUSD is + // a breakout of SpentUSD, never added to it. A record with no guard kept + // no task tally, and its spend is unknown. g := r.Guard if g != nil { view.TaskCapUSD = g.TaskCap - view.SpentUSD = g.TaskSpent + view.SpentUSD, view.SpentKnown = g.TaskSpent, true view.Calls, view.UnknownCalls = g.TaskCalls, g.TaskUnknown } - view.SpentUSD += r.HelperUSD facts := r.RoleFacts >= 1 view.RoleFacts, view.RestoreCount = facts, r.RestoreCount known, total := true, 0.0 @@ -1244,14 +1250,16 @@ func (c *taskCrew) routingRecord(changes int) *TaskCrewRecord { g := c.guard c.mu.Unlock() if g != nil { + // g.mu then bookMu, the checkpoint's order: a role's first booking + // makes its tally under g.mu before it books under bookMu, so the + // roles read here are exactly the ones the task figure includes. g.mu.Lock() + defer g.mu.Unlock() r.Guard = &crewGuardRecord{TaskCap: g.TaskCap, SeatSpent: map[crewroute.Seat]float64{}} - task, seats := g.Task, maps.Clone(g.seatSpent) - g.mu.Unlock() - // One booking's task and role figures, read together. g.bookMu.Lock() - r.Guard.TaskSpent, r.Guard.TaskCalls, r.Guard.TaskUnknown = task.snapshot() - for seat, tally := range seats { + defer g.bookMu.Unlock() + r.Guard.TaskSpent, r.Guard.TaskCalls, r.Guard.TaskUnknown = g.Task.snapshot() + for seat, tally := range g.seatSpent { spent, calls, unknown := tally.snapshot() r.Guard.SeatSpent[seat] = spent if r.Guard.SeatCalls == nil { @@ -1259,7 +1267,6 @@ func (c *taskCrew) routingRecord(changes int) *TaskCrewRecord { } r.Guard.SeatCalls[seat], r.Guard.SeatUnknown[seat] = calls, unknown } - g.bookMu.Unlock() } return r } diff --git a/internal/session/taskrouting_live_test.go b/internal/session/taskrouting_live_test.go new file mode 100644 index 0000000000..6fe20a9fd6 --- /dev/null +++ b/internal/session/taskrouting_live_test.go @@ -0,0 +1,165 @@ +package session + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/crewroute" +) + +// A ROLE HAS STARTED WHEN ITS CALL LEAVES, not when the provider answers: +// with the worker's first call held open on a provider shared by every role, +// the worker reads used and the checker on the same model not run yet; then +// the checker's call starts and only the checker moves. +func TestARoleStartsWhenItsCallLeaves(t *testing.T) { + arrived, release := make(chan struct{}, 4), make(chan struct{}) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !answersChatOnly(w, r) { + return + } + arrived <- struct{}{} + <-release + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(map[string]any{"model": "flash", "choices": []any{map[string]any{ + "index": 0, "finish_reason": "stop", "message": map[string]any{"role": "assistant", "content": "done"}}}}) + })) + t.Cleanup(srv.Close) + t.Cleanup(func() { + defer func() { _ = recover() }() + close(release) + }) + agent, _ := crewStubAgent(t, &crewStub{Server: srv}) + agent.config.RouteCrew = func(config.CrewAsk) (crewroute.Decision, error) { return flashDecision(), nil } + crew, err := agent.routeTaskCrew(t.Context(), 1, "fix the parser", "it crashes") + if err != nil { + t.Fatal(err) + } + run := &beltRun{row: 1, crew: crew} + done := make(chan error, 2) + call := func(seat crewroute.Seat) { + ctx := context.WithValue(context.Background(), crewSeatContextKey{}, seat) + _, err := (crewSeatCompleter{agent: agent, run: run}).CompleteWithMessages(ctx, []ai.Message{textMessage("user", "go")}, ai.WithModel("flash")) + done <- err + } + go call(crewroute.Worker) + select { + case <-arrived: + case <-time.After(10 * time.Second): + t.Fatal("the worker's call never reached the provider") + } + got := statuses(taskRoutingOf("1", crew.routingRecord(20), false, 20)) + if got[crewroute.Worker] != TaskRoleUsed || got[crewroute.Checker] != TaskRoleNotRunYet || got[crewroute.Planner] != TaskRoleNotRunYet { + t.Fatalf("worker call in flight: %v", got) + } + go call(crewroute.Checker) + select { + case <-arrived: + case <-time.After(10 * time.Second): + t.Fatal("the checker's call never reached the provider") + } + got = statuses(taskRoutingOf("1", crew.routingRecord(20), false, 20)) + if got[crewroute.Checker] != TaskRoleUsed || got[crewroute.Planner] != TaskRoleNotRunYet { + t.Fatalf("checker call in flight: %v", got) + } + close(release) + for range 2 { + if err := <-done; err != nil { + t.Fatal(err) + } + } +} + +// A call the task cap refuses before it leaves never starts its role. +func TestARefusedCallDoesNotStartItsRole(t *testing.T) { + stub := newCrewStub(t, http.StatusOK, map[string]int{}) + agent, _ := crewStubAgent(t, stub) + agent.config.RouteCrew = func(config.CrewAsk) (crewroute.Decision, error) { return flashDecision(), nil } + crew, err := agent.routeTaskCrew(t.Context(), 1, "fix the parser", "it crashes") + if err != nil { + t.Fatal(err) + } + crew.guard.TaskCap = 0.01 + crew.guard.Task = &SpendTask{spent: 0.02} + ctx := context.WithValue(t.Context(), crewSeatContextKey{}, crewroute.Checker) + _, err = (crewSeatCompleter{agent: agent, run: &beltRun{row: 1, crew: crew}}).CompleteWithMessages(ctx, []ai.Message{textMessage("user", "go")}, ai.WithModel("flash")) + var stopped crewStopped + if !errors.As(err, &stopped) { + t.Fatalf("the cap did not stop the call: %v", err) + } + if got := statuses(taskRoutingOf("1", crew.routingRecord(20), false, 20)); got[crewroute.Checker] != TaskRoleNotRunYet { + t.Fatalf("a refused call started the checker: %v", got) + } + if len(stub.models()) != 0 { + t.Fatalf("a refused call reached the provider: %v", stub.models()) + } +} + +// HELPERS ARE IN THE TASK'S TALLY ONCE: a helper call is booked on the task +// tally it shares with the roles, and its breakout is never added again — +// known cost, unknown cost and free alike, before and after a reload. +func TestHelperSpendIsCountedOnce(t *testing.T) { + a := &Agent{config: Config{Workspace: t.TempDir(), ProfileDir: t.TempDir()}, model: "chat/model"} + crew := liveCrew(0) + crew.guard.after(seatCtx(crewroute.Worker), "glm-flash", usage(usd(0.05)), 0) + helper := a.helperGuard(crew) + known := usage(usd(0.03)) + helper.after(context.Background(), "helper/model", known, 0) + crew.addHelperSpend(known) + helper.after(context.Background(), "helper/unpriced", &ai.Response{}, 0) + crew.addHelperSpend(&ai.Response{}) + check := func(label string, view TaskRoutingView) { + t.Helper() + if !view.SpentKnown || view.SpentUSD < 0.0799 || view.SpentUSD > 0.0801 || view.HelperUSD != 0.03 || view.Calls != 3 || view.UnknownCalls != 1 { + t.Fatalf("%s: spent %v (known %v) helper %v calls %d unknown %d", label, view.SpentUSD, view.SpentKnown, view.HelperUSD, view.Calls, view.UnknownCalls) + } + } + check("live", taskRoutingOf("1", crew.routingRecord(20), false, 20)) + record := crew.record() + record.Routed = true + check("checkpoint", taskRoutingOf("1", record, false, 20)) + back, err := a.restoreTaskCrew(1, record) + if err != nil { + t.Fatal(err) + } + check("reload", taskRoutingOf("1", back.routingRecord(20), false, 20)) + // A record that kept no task tally does not claim a spend. + record.Guard = nil + if view := taskRoutingOf("1", record, true, 20); view.SpentKnown { + t.Fatalf("no tally, yet spent %v is claimed", view.SpentUSD) + } +} + +// A role's first booking racing a read: every read sees the task figure and +// exactly the roles it includes. +func TestABoundedReadSeesANewRoleWithItsBooking(t *testing.T) { + for range 50 { + crew := liveCrew(0) + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + for _, seat := range crewroute.Seats { + crew.guard.after(seatCtx(seat), "m", usage(usd(0.25)), 0) + } + }() + for i := 0; i < 20; i++ { + g := crew.routingRecord(20).Guard + sum := 0.0 + for _, v := range g.SeatSpent { + sum += v + } + if sum != g.TaskSpent { + t.Fatalf("task %v but roles sum to %v", g.TaskSpent, sum) + } + } + wg.Wait() + } +} From 53b1deb211ad64f2597c02ee28f897659fa5c49f Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 20:49:42 -0400 Subject: [PATCH 071/459] service, session: repair a failed kept service in place, on its own row A kept service that failed (a port conflict, a bad command) could only be fixed by keeping the changed command, which made a second row beside the failed one. Supervisor.Repair runs a changed command, folder or check on the SAME row under the same id and log, and Supervisor.Repairable asks the same question with no side effect so a caller refuses before it asks anybody. Both refuse an id that is not the owner's (ErrNotFound, same as unknown), a service still running or starting (ErrRunning) or stopping (ErrStopping), and a change that would duplicate another of the owner's rows (ErrDuplicate). A repair never signals, never allocates an id, banks the yes for the NEW spec only (the old command's yes is never carried), and writes the row before the gated launch; a failed write puts the row and every banked yes back. The automatic restart budget is reset only when the command, folder or check changed. The open episode keeps its id and closes on the repaired spec's first passing check. bash's keep_running takes an optional service_id. The changed command gets a fresh card ("in place of s1 (web)") unless that exact spec was already approved. A deny is the ordinary turn job and leaves the row untouched. The repair is reached through an asserted interface, so a Services door without it refuses service_id instead of half-working. Co-Authored-By: Claude Opus 5.5 --- internal/service/repair.go | 223 ++++++++++++++++ internal/service/repair_test.go | 249 ++++++++++++++++++ internal/session/keep_running.go | 122 ++++++++- internal/session/keep_running_repair_test.go | 261 +++++++++++++++++++ 4 files changed, 847 insertions(+), 8 deletions(-) create mode 100644 internal/service/repair.go create mode 100644 internal/service/repair_test.go create mode 100644 internal/session/keep_running_repair_test.go diff --git a/internal/service/repair.go b/internal/service/repair.go new file mode 100644 index 0000000000..69087299ce --- /dev/null +++ b/internal/service/repair.go @@ -0,0 +1,223 @@ +package service + +// repair.go is the one way a kept-running service changes what it runs: its +// command, its folder or its check, in place, on the same row and under the +// same id. Keeping the changed command instead ([Supervisor.Keep]) makes a +// second row beside the failed one, and the person is left with a service that +// says failed for ever and a new one they never connected to it. +// +// A REPAIR IS A NEW YES, NEVER AN OLD ONE STRETCHED. The yes a person gave the +// old command was for that command in that folder with that check; nothing of +// it carries to the replacement. Repair banks the yes for the NEW spec exactly +// as Keep does, and a caller asks the person first unless that exact spec was +// already approved ([Supervisor.Approved]). +// +// A REPAIR NEVER ENDS A PROCESS. Only a service with no process — failed, gave +// up, not running, stopped — is repaired; one that is running, starting or +// still stopping is refused with nothing written and nothing signalled, and +// the person stops it first. That is what makes a duplicate impossible: the +// replacement can never run beside the process it replaces. + +import ( + "errors" + "fmt" + "strings" + "time" +) + +// ErrDuplicate is a repair whose new command and folder another of the same +// agent's services already runs. Two rows of one owner under one consent key +// would make every later Keep of that spec pick one of them at random. +var ErrDuplicate = errors.New("another kept-running process of this agent already has that command in that folder") + +// Repairable answers, with no side effect at all, whether owner's service id +// could be repaired to spec now, with the row as it is. It is what a caller +// asks BEFORE it raises a card, so a refusal costs the person nothing; the +// same refusals are made again by [Supervisor.Repair], because the answer can +// change while the card waits. +// +// An id that is not owner's is [ErrNotFound], exactly as an id that does not +// exist: one agent never learns another's ids. +func (s *Supervisor) Repairable(owner, id string, spec Spec) (Row, error) { + spec, err := spec.Check() + if err != nil { + return Row{}, err + } + s.mu.Lock() + defer s.mu.Unlock() + e, err := s.repairRefusalLocked(owner, id, spec) + if err != nil { + return Row{}, err + } + return s.rowLocked(e), nil +} + +// repairRefusalLocked is every reason owner's service id cannot be repaired +// to spec now, or its entry. +func (s *Supervisor) repairRefusalLocked(owner, id string, spec Spec) (*entry, error) { + if s.closed { + return nil, ErrClosed + } + e, err := s.findLocked(owner, id) + if err != nil { + return nil, err + } + if s.fences[owner] > 0 { + return nil, ErrClosing + } + if e.stopping() { + return nil, ErrStopping + } + if e.rec.Identity != nil || e.busy { + return nil, ErrRunning + } + key := spec.ConsentKey() + for _, other := range s.entries { + if other != e && other.rec.Owner == owner && other.rec.Spec.ConsentKey() == key { + return nil, ErrDuplicate + } + } + return e, nil +} + +// Repair is a person's yes to a changed spec for one of owner's services that +// has no process, carried out on that same row: the yes is banked for the new +// spec, the row takes the new command, folder, name, check and policy and the +// new provenance, and the new command is started under the row's own id and +// log. When the new spec was already approved with this check, the person's +// policy stands whatever policy says, as for [Supervisor.Keep]. +// +// What the row keeps, and why: +// +// - The id, the log and the output in it. The log is the row's, cut back to +// its usual bound before the new command writes after it, so what the old +// command said about its own failure is still there to read. +// - The open episode, under the same id. The trouble a person was told about +// is over when the service is healthy again, not when somebody tried +// something, so the episode closes on the repaired spec's first passing +// check — and if the repair fails too, it is still the one line, saying +// what first went wrong. +// +// What it does not keep: +// +// - The automatic restart budget, when the command, folder or check changed. +// Those restarts were spent on a spec nobody runs any more; the repaired +// spec is a new thing a person approved and starts with its whole budget. +// A repair that changes only the name is the same spec started again, and +// keeps the budget it had, so asking again is never a way to refill it. +// - The old check's last result, for the same reason. +// +// NOTHING IS ALLOCATED AND NOTHING IS SIGNALLED. The id counter does not move, +// no row is added, and a row with no process has nothing to end. A write that +// fails before the start puts the row and every banked yes back as they were +// and starts nothing. +func (s *Supervisor) Repair(owner, id string, spec Spec, policy Policy, from Provenance) (Row, error) { + spec, err := spec.Check() + if err != nil { + return Row{}, err + } + if owner == "" || len(owner) > maxOwner || strings.TrimSpace(owner) != owner { + return Row{}, errors.New("a kept-running process needs an owner") + } + if !policy.Valid() { + return Row{}, fmt.Errorf("restart policy is never, on_failure or always, not %q", policy) + } + from.Label = oneLine(from.Label, maxProvenance) + from.CallID = oneLine(from.CallID, maxProvenance) + now := s.clock.Now() + from.At = now + + s.mu.Lock() + e, err := s.repairRefusalLocked(owner, id, spec) + if err != nil { + s.mu.Unlock() + return Row{}, err + } + key := spec.ConsentKey() + prior, hadPrior := s.consent[key] + if hadPrior && prior.Health == spec.Health { + policy = prior.Policy + } + changed := e.rec.Spec.ConsentKey() != key || e.rec.Spec.Health != spec.Health + before := e.rec + s.consent[key] = consent{Policy: policy, Health: spec.Health, At: now} + evicted := s.trimConsentKeepingLocked(key) + e.rec.Spec, e.rec.Policy, e.rec.From = spec, policy, from + e.rec.StoppedBy, e.rec.Reason = "", "" + e.rec.BackoffUntil = time.Time{} + if changed { + e.rec.Restarts, e.rec.Check, e.rec.HealthySince = nil, nil, time.Time{} + } + e.gen++ + gen := e.gen + e.busy = true + e.before = "" + e.rec.State = StateStarting + s.changedLocked(e, "state") + s.wg.Add(1) + s.mu.Unlock() + defer s.wg.Done() + + if err := s.save(); err != nil { + s.mu.Lock() + if hadPrior { + s.consent[key] = prior + } else { + delete(s.consent, key) + } + for evictedKey, c := range evicted { + if _, taken := s.consent[evictedKey]; !taken { + s.consent[evictedKey] = c + } + } + if s.entries[id] == e { + if e.gen == gen { + e.busy = false + e.gen++ + e.rec = before + } else { + // A stop overtook the write and its state stands; what this + // repair put on the row goes back, so the row never names a + // spec the file and the banked yes do not. + e.rec.Spec, e.rec.Policy, e.rec.From = before.Spec, before.Policy, before.From + e.rec.Restarts, e.rec.Check, e.rec.HealthySince = before.Restarts, before.Check, before.HealthySince + } + s.changedLocked(e, "state") + } + s.mu.Unlock() + return Row{}, err + } + if err := s.startWork(id, gen, StartedByAgent, false); err != nil { + row, _ := s.Get(owner, id) + return row, err + } + return s.Get(owner, id) +} + +// trimConsentKeepingLocked trims the banked yeses to their bound as +// trimConsentLocked does, never evicting keep, and answers what it evicted so +// a write that fails can put them back. +func (s *Supervisor) trimConsentKeepingLocked(keep string) map[string]consent { + var evicted map[string]consent + for len(s.consent) > maxConsent { + var oldest string + var at time.Time + for key, c := range s.consent { + if key == keep { + continue + } + if oldest == "" || c.At.Before(at) { + oldest, at = key, c.At + } + } + if oldest == "" { + break + } + if evicted == nil { + evicted = map[string]consent{} + } + evicted[oldest] = s.consent[oldest] + delete(s.consent, oldest) + } + return evicted +} diff --git a/internal/service/repair_test.go b/internal/service/repair_test.go new file mode 100644 index 0000000000..15a4d05918 --- /dev/null +++ b/internal/service/repair_test.go @@ -0,0 +1,249 @@ +package service + +import ( + "errors" + "strconv" + "sync" + "testing" + "time" +) + +// portSpec is the web dev server on port, the command saying the port the way +// an agent's fix of a port conflict changes it. +func portSpec(dir string, port int) Spec { + return Spec{Name: "web", Command: "npm run dev -- --port " + strconv.Itoa(port), Dir: dir, Health: Health{Port: port}} +} + +// conflicted is a service kept onto a port another program holds: failed, its +// episode open, nothing started. +func conflicted(t *testing.T, h *harness) Row { + t.Helper() + h.rt.portsUsed[5173] = true + row, err := h.s.Keep("agent-a", portSpec(h.dir, 5173), PolicyOnFailure, Provenance{Label: "Start the web dev server"}) + if err != nil { + t.Fatal(err) + } + if row.State != StateFailed || !row.PortConflict || !row.EpisodeOpen || row.Episode != row.ID+".1" { + t.Fatalf("a port conflict is a failed row with its episode open: %+v", row) + } + return row +} + +// TestARepairRunsTheChangedCommandOnTheSameRow is the whole road: a port +// conflict, the agent's changed command approved, ONE row under the original +// id, started once, healthy, and the one episode the person was told about +// closing on that same id when — and only when — the repaired service passes +// its check. +func TestARepairRunsTheChangedCommandOnTheSameRow(t *testing.T) { + h := newHarness(t) + var mu sync.Mutex + var events []Event + h.s.Subscribe(func(e Event) { mu.Lock(); events = append(events, e); mu.Unlock() }) + failed := conflicted(t, h) + seq := h.s.seq + + fixed := portSpec(h.dir, 5174) + if _, ok := h.s.Approved(fixed); ok { + t.Fatal("the changed command was approved before anybody said yes to it") + } + if _, err := h.s.Repairable("agent-a", failed.ID, fixed); err != nil { + t.Fatal(err) + } + row, err := h.s.Repair("agent-a", failed.ID, fixed, PolicyAlways, Provenance{Label: "use another port", CallID: "c2"}) + if err != nil { + t.Fatal(err) + } + if row.ID != failed.ID || !row.Running || row.PortConflict || row.Command != fixed.Command || row.Health.Port != 5174 || + row.Policy != PolicyAlways || row.From.CallID != "c2" || row.LogPath != failed.LogPath { + t.Fatalf("the repair is the same row running the new command: %+v", row) + } + if !row.EpisodeOpen || row.Episode != failed.Episode { + t.Fatalf("the episode closes when it is healthy, not when it is tried: %+v", row) + } + if h.rt.starts != 1 || h.s.seq != seq || len(h.s.List("agent-a")) != 1 { + t.Fatalf("one start, no new id, one row: starts %d seq %d→%d rows %d", h.rt.starts, seq, h.s.seq, len(h.s.List("agent-a"))) + } + if policy, ok := h.s.Approved(fixed); !ok || policy != PolicyAlways { + t.Fatal("the yes to the changed command was not banked") + } + + h.advance(CheckEvery) + got := h.get(failed.ID) + if got.State != StateHealthy || got.EpisodeOpen || got.Episode != failed.Episode || got.EpisodeResolvedAt == nil || got.EpisodeReason != failed.EpisodeReason { + t.Fatalf("the original episode closes on the repaired service's first pass: %+v", got) + } + h.s.Close() + mu.Lock() + defer mu.Unlock() + for _, e := range events { + if e.Row.ID != failed.ID || e.Removed || (e.Row.Episode != "" && e.Row.Episode != failed.Episode) { + t.Fatalf("every event is the one row and its one episode: %+v", e) + } + } +} + +// TestARepairIsRefusedBeforeAnythingHappens: a foreign id, an unknown id, a +// live service, one still stopping and a duplicate are each refused by the +// side-effect-free question and by the repair itself, with nothing launched, +// nothing signalled, no yes banked and no row changed. +func TestARepairIsRefusedBeforeAnythingHappens(t *testing.T) { + h := newHarness(t) + failed := conflicted(t, h) + h.rt.portsUsed[5173] = false + live := h.keep("live", PolicyOnFailure) + other, err := h.s.Keep("agent-b", portSpec(h.dir, 6000), PolicyOnFailure, Provenance{}) + if err != nil { + t.Fatal(err) + } + starts := h.rt.starts + cases := []struct { + name, owner, id string + spec Spec + want error + }{ + {"foreign", "agent-a", other.ID, portSpec(h.dir, 5175), ErrNotFound}, + {"unknown", "agent-a", "s99", portSpec(h.dir, 5175), ErrNotFound}, + {"another owner's view", "agent-b", failed.ID, portSpec(h.dir, 5175), ErrNotFound}, + {"live", "agent-a", live.ID, portSpec(h.dir, 5175), ErrRunning}, + {"duplicate", "agent-a", failed.ID, webSpec("live", h.dir), ErrDuplicate}, + } + for _, c := range cases { + if _, err := h.s.Repairable(c.owner, c.id, c.spec); !errors.Is(err, c.want) { + t.Errorf("%s: Repairable said %v, want %v", c.name, err, c.want) + } + if _, err := h.s.Repair(c.owner, c.id, c.spec, PolicyOnFailure, Provenance{}); !errors.Is(err, c.want) { + t.Errorf("%s: Repair said %v, want %v", c.name, err, c.want) + } + if _, ok := h.s.Approved(c.spec); ok && c.want != ErrDuplicate { + t.Errorf("%s: a refused repair banked a yes", c.name) + } + } + settle(t, h.s) + if h.rt.starts != starts { + t.Fatal("a refused repair started something") + } + if p := h.rt.proc(live.PID); !p.alive || len(p.signals) != 0 { + t.Fatalf("a refused repair signalled the live service: %+v", p.signals) + } + if got := h.get(failed.ID); got.Command != failed.Command || got.State != StateFailed { + t.Fatalf("a refused repair changed the row: %+v", got) + } + + h.rt.proc(live.PID).ignoreTerm = true + if _, err := h.s.Stop("agent-a", live.ID); err != nil { + t.Fatal(err) + } + if _, err := h.s.Repair("agent-a", live.ID, portSpec(h.dir, 5176), PolicyOnFailure, Provenance{}); !errors.Is(err, ErrStopping) { + t.Fatalf("a service in its stop grace was repaired: %v", err) + } + if signals := h.rt.proc(live.PID).signals; len(signals) != 1 || signals[0] { + t.Fatalf("a refused repair sent signals of its own: %+v", signals) + } + // The grace runs out on the test's clock, so the stop finishes before + // Close waits for it. + for deadline := time.Now().Add(5 * time.Second); h.get(live.ID).Stopping && time.Now().Before(deadline); { + h.clock.fireDue(time.Second) + time.Sleep(time.Millisecond) + } +} + +// TestARepairTheFileCannotKeepPutsEverythingBack: the row, its spec, and the +// banked yeses are as they were, and nothing ran. +func TestARepairTheFileCannotKeepPutsEverythingBack(t *testing.T) { + h := newHarness(t) + failed := conflicted(t, h) + h.rt.portsUsed[5173] = false + h.setWrite(func(string, []byte) error { return errors.New("disk full") }) + fixed := portSpec(h.dir, 5174) + if _, err := h.s.Repair("agent-a", failed.ID, fixed, PolicyAlways, Provenance{}); !errors.Is(err, ErrNotSaved) { + t.Fatalf("an unsaved repair answered %v", err) + } + settle(t, h.s) + got := h.get(failed.ID) + if got.Command != failed.Command || got.Health != failed.Health || got.State != StateFailed || got.Policy != failed.Policy || !got.EpisodeOpen { + t.Fatalf("the row was not put back: %+v", got) + } + if _, ok := h.s.Approved(fixed); ok { + t.Fatal("an unsaved repair left its yes banked") + } + if _, ok := h.s.Approved(portSpec(h.dir, 5173)); !ok { + t.Fatal("an unsaved repair lost the original yes") + } + if h.rt.launches != 0 || h.rt.starts != 0 { + t.Fatal("an unsaved repair launched something") + } +} + +// TestOnlyAChangedSpecStartsWithAWholeBudget: the restarts spent on the old +// command are not the new command's, and a repair to the very same spec is no +// way to refill them. +func TestOnlyAChangedSpecStartsWithAWholeBudget(t *testing.T) { + h := newHarness(t) + failed := conflicted(t, h) + h.rt.portsUsed[5173] = false + spend := func() { + h.s.mu.Lock() + now := h.clock.Now() + h.s.entries[failed.ID].rec.Restarts = []time.Time{now, now, now} + h.s.entries[failed.ID].rec.State = StateGaveUp + h.s.mu.Unlock() + } + spend() + same := portSpec(h.dir, 5173) + same.Name = "renamed" + row, err := h.s.Repair("agent-a", failed.ID, same, PolicyOnFailure, Provenance{}) + if err != nil { + t.Fatal(err) + } + if row.Restarts != RestartLimit || row.Name != "renamed" { + t.Fatalf("a repair to the same spec refilled the budget: %+v", row) + } + if _, err := h.s.Stop("agent-a", failed.ID); err != nil { + t.Fatal(err) + } + settle(t, h.s) + spend() + row, err = h.s.Repair("agent-a", failed.ID, portSpec(h.dir, 5174), PolicyOnFailure, Provenance{}) + if err != nil { + t.Fatal(err) + } + if row.Restarts != 0 || !row.Running { + t.Fatalf("a changed spec did not start with its whole budget: %+v", row) + } +} + +// TestARepairKeepsThePersonsPolicyForAnAlreadyApprovedSpec, as Keep does, and +// the old command's yes is never the new command's. +func TestARepairKeepsThePersonsPolicyForAnAlreadyApprovedSpec(t *testing.T) { + h := newHarness(t) + fixed := portSpec(h.dir, 5174) + if _, err := h.s.Keep("agent-b", fixed, PolicyNever, Provenance{}); err != nil { + t.Fatal(err) + } + failed := conflicted(t, h) + row, err := h.s.Repair("agent-a", failed.ID, fixed, PolicyAlways, Provenance{}) + if err != nil { + t.Fatal(err) + } + if row.Policy != PolicyNever { + t.Fatalf("the person's policy for this exact spec did not stand: %s", row.Policy) + } +} + +// TestARepairedServiceIsTakenBackAsRepaired: the file a repair wrote names the +// new spec under the old id, and the next codeaf finds it. +func TestARepairedServiceIsTakenBackAsRepaired(t *testing.T) { + h := newHarness(t) + failed := conflicted(t, h) + if _, err := h.s.Repair("agent-a", failed.ID, portSpec(h.dir, 5174), PolicyOnFailure, Provenance{}); err != nil { + t.Fatal(err) + } + h.reopen() + rows := h.s.List("agent-a") + if len(rows) != 1 || rows[0].ID != failed.ID || !rows[0].Running || rows[0].Health.Port != 5174 || !rows[0].ReAdopted { + t.Fatalf("%+v", rows) + } + if next := h.keep("other", PolicyOnFailure); next.ID != "s2" { + t.Fatalf("the repair moved the id counter: %s", next.ID) + } +} diff --git a/internal/session/keep_running.go b/internal/session/keep_running.go index 40a117554a..2affb2a8ed 100644 --- a/internal/session/keep_running.go +++ b/internal/session/keep_running.go @@ -21,6 +21,16 @@ package session // and a proposal that changes the check is asked about again. Only the person // changes a policy afterwards, from the row. // +// A KEPT SERVICE THAT FAILED IS MENDED IN PLACE. keep_running.service_id names +// one of this conversation's own services that has no process, and the changed +// command, folder or check replaces it on that same row, under that same id, +// instead of becoming a second row beside the failed one +// ([service.Supervisor.Repair]). It is the same card asked the same way — the +// old command's yes is never the new one's — and it is refused before any card +// for an id that is not this conversation's, a service still running or +// stopping, or a change that would duplicate another of its services. A deny +// leaves the failed row exactly as it was. +// // A CAPABILITY THAT CANNOT WORK IS ABSENT. With no supervisor, no conversation // folder to own the service, nobody to ask, or inside a task, bash has no // keep_running argument at all ([Agent.keepAvailable]). @@ -82,6 +92,10 @@ type KeepNotice struct { Service *service.Row `json:"service,omitempty"` // Job is the ordinary job a deny started, ending with this turn. Job int `json:"job,omitempty"` + // Replaces is the id of the kept service an allow repairs in place, and + // ReplacesName its name; both are empty for a new service. + Replaces string `json:"replaces,omitempty"` + ReplacesName string `json:"replacesName,omitempty"` } // keepRunningArgs is bash's keep_running argument. @@ -93,6 +107,23 @@ type keepRunningArgs struct { Cmd string `json:"cmd"` } `json:"health"` Restart string `json:"restart"` + // ServiceID names one of this conversation's kept services to repair in + // place rather than keep a new one. + ServiceID string `json:"service_id"` +} + +// serviceRepairer is the supervisor's in-place repair. It is asserted rather +// than assumed so a Services door without it leaves service_id refused, never +// half-working. +type serviceRepairer interface { + Repairable(owner, id string, spec service.Spec) (service.Row, error) + Repair(owner, id string, spec service.Spec, policy service.Policy, from service.Provenance) (service.Row, error) +} + +// keepRepairer is the Services door's repair, when it has one. +func (a *Agent) keepRepairer() (serviceRepairer, bool) { + repairer, ok := any(a.config.Services).(serviceRepairer) + return repairer, ok && a.config.Services != nil } type keepAsk struct { @@ -122,9 +153,21 @@ func KeepOptions() []AnswerOption { // KeepHead is the card's first line. func KeepHead(notice KeepNotice) string { + if notice.Replaces != "" { + return "keep " + firstLine(notice.Command) + " running after this turn, in place of " + keepServiceWords(notice.Replaces, notice.ReplacesName) + "?" + } return "keep " + firstLine(notice.Command) + " running after this turn?" } +// keepServiceWords is a kept service as a card or a result names it: "s3 (web +// dev server)". +func keepServiceWords(id, name string) string { + if name == "" { + return id + } + return id + " (" + name + ")" +} + // KeepQuestion is one card as a question on the block — the ONE construction // of it, which the engine publishes and a surface draws. func KeepQuestion(notice KeepNotice) Question { @@ -189,7 +232,7 @@ func (a *Agent) keepAvailable() bool { // supervisor's own. var keepRunningProperty = strings.ReplaceAll(keepRunningShape, "{every}", service.CheckEvery.String()) -const keepRunningShape = `{"type":"object","description":"Keep this command running after the turn as a supervised service. The person is asked first, once per command and folder; the command then runs in the current folder, is checked every {every}, and is restarted under the policy the person keeps. Give exactly one health check.","properties":{"name":{"type":"string","description":"a short name for it, e.g. web dev server"},"health":{"type":"object","description":"exactly one: a port that accepts connections, a URL that answers 200, or a command that exits 0","properties":{"port":{"type":"integer"},"url":{"type":"string"},"cmd":{"type":"string"}}},"restart":{"type":"string","enum":["on_failure","always","never"],"description":"the restart policy to propose; the person decides"}},"required":["health"]}` +const keepRunningShape = `{"type":"object","description":"Keep this command running after the turn as a supervised service. The person is asked first, once per command and folder; the command then runs in the current folder, is checked every {every}, and is restarted under the policy the person keeps. Give exactly one health check.","properties":{"name":{"type":"string","description":"a short name for it, e.g. web dev server"},"health":{"type":"object","description":"exactly one: a port that accepts connections, a URL that answers 200, or a command that exits 0","properties":{"port":{"type":"integer"},"url":{"type":"string"},"cmd":{"type":"string"}}},"restart":{"type":"string","enum":["on_failure","always","never"],"description":"the restart policy to propose; the person decides"},"service_id":{"type":"string","description":"to fix one of your kept-running processes that failed or stopped (for example a port conflict) instead of adding another: its id, like s3. Its row then runs this command, folder and check under the same id; the person is asked again for the changed command. Refused while it is still running or stopping: the person stops it first."}},"required":["health"]}` // keepSentence is the bash description's line about it. func (a *Agent) keepSentence() string { @@ -251,8 +294,19 @@ func (a *Agent) keepRunning(ctx context.Context, command string, args keepRunnin // The started-by line is the person's own request, redacted like // everything else a row carries. from := service.Provenance{Label: redact.Secrets(firstLine(asked)), Turn: turn, CallID: callID} + // A REPAIR IS REFUSED BEFORE ANYBODY IS ASKED. An id that is not this + // conversation's, a service still running or stopping, or a duplicate is + // said now, with no card raised and nothing started or signalled. + var repairing service.Row + if id := strings.TrimSpace(args.ServiceID); id != "" { + row, refusal := a.keepRepairable(id, spec) + if refusal != "" { + return refusal, true, nil + } + repairing = row + } if policy, ok := a.config.Services.Approved(spec); ok { - text, isError, _ := a.keepStarted(spec, policy, from, true) + text, isError, _ := a.keepStarted(spec, policy, from, true, repairing.ID) return text, isError, nil } @@ -260,6 +314,7 @@ func (a *Agent) keepRunning(ctx context.Context, command string, args keepRunnin CallID: callID, Asked: time.Now().UTC(), Name: spec.Name, Command: redact.Secrets(spec.Command), Dir: spec.Dir, Health: redactedHealth(spec.Health), HealthWords: redact.Secrets(spec.Health.Words()), Policy: proposed, PolicyWords: KeepPolicyWords(proposed), + Replaces: repairing.ID, ReplacesName: repairing.Name, } key, err := a.askKeep(ctx, ¬ice) if err != nil { @@ -271,7 +326,7 @@ func (a *Agent) keepRunning(ctx context.Context, command string, args keepRunnin if key == KeepAllowNeverKey { policy, decided = service.PolicyNever, KeepAllowedNever } - text, isError, row := a.keepStarted(spec, policy, from, false) + text, isError, row := a.keepStarted(spec, policy, from, false, repairing.ID) if row.ID != "" { notice.Service = &row } @@ -291,13 +346,61 @@ func (a *Agent) keepRunning(ctx context.Context, command string, args keepRunnin a.mu.Unlock() notice.Job = started.id a.keepReceipt(notice, KeepDenied) - return fmt.Sprintf("The person chose not to keep it running. It runs as job %d for this turn only and stops when this turn ends; log at %s", started.id, started.logPath), false, nil + unchanged := "" + if repairing.ID != "" { + unchanged = " " + repairing.ID + " is unchanged." + } + return fmt.Sprintf("The person chose not to keep it running. It runs as job %d for this turn only and stops when this turn ends; log at %s.%s", started.id, started.logPath, unchanged), false, nil } } -// keepStarted keeps spec under policy and says what came of it, with the row. -func (a *Agent) keepStarted(spec service.Spec, policy service.Policy, from service.Provenance, remembered bool) (string, bool, service.Row) { - row, err := a.config.Services.Keep(a.keepOwner(), spec, policy, from) +// keepRepairable answers the row service_id names when it can be repaired to +// spec, or the refusal the model reads in place of a card. +func (a *Agent) keepRepairable(id string, spec service.Spec) (service.Row, string) { + repairer, ok := a.keepRepairer() + if !ok { + return service.Row{}, "service_id is not available here, so nothing was started or changed. Propose it without service_id to keep it as a new process." + } + // The id is said back, so one the model made up is bounded before it is. + shown := id + if len(shown) > 16 { + shown = shown[:16] + "…" + } + row, err := repairer.Repairable(a.keepOwner(), id, spec) + switch { + case err == nil: + return row, "" + case errors.Is(err, service.ErrNotFound): + return service.Row{}, fmt.Sprintf("%s is not one of this conversation's kept-running processes; jobs list shows yours. Nothing was started or changed.", shown) + case errors.Is(err, service.ErrRunning): + return service.Row{}, fmt.Sprintf("%s is still running, and a replacement never runs beside it. Nothing was started or changed. Ask the person to stop it from its row, then propose the change again.", shown) + case errors.Is(err, service.ErrStopping): + return service.Row{}, fmt.Sprintf("%s is still stopping. Nothing was started or changed; propose the change again once it has stopped.", shown) + case errors.Is(err, service.ErrDuplicate): + return service.Row{}, "another of this conversation's kept-running processes already has that command in this folder. Nothing was started or changed." + } + return service.Row{}, "Not kept running: " + err.Error() + ". Nothing was started or changed." +} + +// keepStarted keeps spec under policy — repairing the service repairs names in +// place, when it names one — and says what came of it, with the row. +func (a *Agent) keepStarted(spec service.Spec, policy service.Policy, from service.Provenance, remembered bool, repairs string) (string, bool, service.Row) { + var row service.Row + var err error + if repairs != "" { + repairer, ok := a.keepRepairer() + if !ok { + return "Not kept running: service_id is not available here.", true, row + } + row, err = repairer.Repair(a.keepOwner(), repairs, spec, policy, from) + if err != nil && row.ID == "" { + // The card was answered; what changed while it waited is said, and + // the row is as it was. + return fmt.Sprintf("Not kept running: %s. %s is unchanged and nothing was started.", err.Error(), repairs), true, row + } + } else { + row, err = a.config.Services.Keep(a.keepOwner(), spec, policy, from) + } if err != nil && row.ID == "" { return "Not kept running: " + err.Error() + ".", true, row } @@ -308,8 +411,11 @@ func (a *Agent) keepStarted(spec service.Spec, policy service.Policy, from servi return fmt.Sprintf("Kept as %s, but it is not running: %s.", row.ID, row.Reason), true, row } lead := "Kept running" + if repairs != "" { + lead = "Replaced its command and kept it running" + } if remembered { - lead = "Kept running (the person already approved this command in this folder)" + lead += " (the person already approved this command in this folder)" } return fmt.Sprintf("%s as %s (%s) · checks %s every %s · %s · log at %s. It keeps running after this turn; only the person changes its restart policy.", lead, row.ID, row.Name, row.HealthWords, service.CheckEvery, KeepPolicyWords(row.Policy), row.LogPath), false, row diff --git a/internal/session/keep_running_repair_test.go b/internal/session/keep_running_repair_test.go new file mode 100644 index 0000000000..b8847f56c1 --- /dev/null +++ b/internal/session/keep_running_repair_test.go @@ -0,0 +1,261 @@ +package session + +import ( + "context" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/service" + "github.com/Agent-Field/codeaf/internal/service/servicetest" +) + +// repairWorld is keepWorld on a clock the test moves, so a repaired service's +// first check can be had without waiting ten seconds. +func repairWorld(t *testing.T) (*service.Supervisor, *servicetest.Runtime, *servicetest.Clock) { + t.Helper() + rt := servicetest.New() + clock := servicetest.NewClock(time.Date(2026, 10, 15, 9, 0, 0, 0, time.UTC)) + supervisor, err := service.Open(service.Options{Dir: t.TempDir(), Runtime: rt, Clock: clock}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(supervisor.Close) + return supervisor, rt, clock +} + +// conflictedRow keeps the web dev server onto a port another program holds, +// through a real card, and answers its failed row. +func conflictedRow(t *testing.T, agent *Agent, supervisor *service.Supervisor, rt *servicetest.Runtime) service.Row { + t.Helper() + rt.PortsUsed[5173] = true + events, err := agent.Submit(context.Background(), "start the web dev server and keep it up") + if err != nil { + t.Fatal(err) + } + drainAnsweringKeep(t, agent, events, KeepAllowKey, nil) + rows := supervisor.List(agent.keepOwner()) + if len(rows) != 1 || !rows[0].PortConflict || rows[0].State != service.StateFailed || !rows[0].EpisodeOpen { + t.Fatalf("the port conflict is one failed row: %+v", rows) + } + return rows[0] +} + +func cardsIn(events []Event) []*KeepNotice { + var cards []*KeepNotice + for _, event := range events { + if event.Kind == EventKeepRunning && event.Keep != nil && event.Keep.Decided == "" { + cards = append(cards, event.Keep) + } + } + return cards +} + +// TestAFailedServiceIsRepairedOnItsOwnRow is the agent fixing a port conflict +// the way a person would: the changed command is asked about afresh, and the +// yes runs it on the SAME row, under the original id, whose one episode closes +// when the repaired service first passes its check. +func TestAFailedServiceIsRepairedOnItsOwnRow(t *testing.T) { + supervisor, rt, clock := repairWorld(t) + var mu sync.Mutex + var heard []service.Event + supervisor.Subscribe(func(e service.Event) { mu.Lock(); heard = append(heard, e); mu.Unlock() }) + completer := &scriptedCompleter{steps: []step{ + keepCall("k1", "npm run dev", map[string]any{"name": "web dev server", "health": map[string]any{"port": 5173}}), + finalText("port is taken"), + keepCall("k2", "npm run dev -- --port 5174", map[string]any{"name": "web dev server", "health": map[string]any{"port": 5174}, "service_id": "s1"}), + finalText("moved it"), + }} + agent := keepAgent(t, completer, supervisor, "api-docs") + failed := conflictedRow(t, agent, supervisor, rt) + + events, _ := agent.Submit(context.Background(), "use another port") + collected := drainAnsweringKeep(t, agent, events, KeepAllowKey, nil) + cards := cardsIn(collected) + if len(cards) != 1 || cards[0].Replaces != failed.ID || cards[0].Command != "npm run dev -- --port 5174" || + !strings.Contains(KeepHead(*cards[0]), "in place of "+failed.ID+" (web dev server)") { + t.Fatalf("the changed command was not asked about afresh, naming what it replaces: %+v", cards) + } + rows := supervisor.List("api-docs") + if len(rows) != 1 || rows[0].ID != failed.ID || !rows[0].Running || rows[0].Health.Port != 5174 || rows[0].Episode != failed.Episode { + t.Fatalf("the repair is the one original row: %+v", rows) + } + if rt.Starts() != 1 { + t.Fatalf("started %d", rt.Starts()) + } + if text := toolResultText(t, agent, "k2"); !strings.Contains(text, "Replaced its command and kept it running as "+failed.ID) { + t.Fatalf("the model was told %q", text) + } + + clock.Advance(service.CheckEvery) + deadline := time.Now().Add(5 * time.Second) + for { + row, _ := supervisor.Get("api-docs", failed.ID) + if row.State == service.StateHealthy { + if row.EpisodeOpen || row.Episode != failed.Episode || row.EpisodeResolvedAt == nil { + t.Fatalf("the original episode did not close on the healthy row: %+v", row) + } + break + } + if time.Now().After(deadline) { + t.Fatalf("the repaired service never passed its check: %+v", row) + } + time.Sleep(time.Millisecond) + } + supervisor.Close() + mu.Lock() + defer mu.Unlock() + for _, e := range heard { + if e.Row.ID != failed.ID || e.Removed || (e.Row.Episode != "" && e.Row.Episode != failed.Episode) { + t.Fatalf("a listener heard something other than the one row and its one episode: %+v", e) + } + } +} + +// TestADeniedRepairLeavesTheFailedRowAsItWas: the deny is the ordinary job it +// always is, and the kept row, its yes and its episode are untouched. +func TestADeniedRepairLeavesTheFailedRowAsItWas(t *testing.T) { + supervisor, rt, _ := repairWorld(t) + completer := &scriptedCompleter{steps: []step{ + keepCall("k1", "npm run dev", map[string]any{"health": map[string]any{"port": 5173}}), + finalText("port is taken"), + keepCall("k2", "sleep 300", map[string]any{"health": map[string]any{"port": 5174}, "service_id": "s1"}), + finalText("ok"), + }} + agent := keepAgent(t, completer, supervisor, "api-docs") + failed := conflictedRow(t, agent, supervisor, rt) + events, _ := agent.Submit(context.Background(), "use another port") + collected := drainAnsweringKeep(t, agent, events, KeepDenyKey, nil) + if len(cardsIn(collected)) != 1 { + t.Fatal("the repair was not asked about") + } + rows := supervisor.List("api-docs") + if len(rows) != 1 || rows[0].Command != failed.Command || rows[0].State != service.StateFailed || rows[0].Episode != failed.Episode || !rows[0].EpisodeOpen { + t.Fatalf("a deny changed the kept row: %+v", rows) + } + if _, ok := supervisor.Approved(service.Spec{Command: "sleep 300", Dir: agent.config.Workspace, Health: service.Health{Port: 5174}}); ok { + t.Fatal("a no was banked as a yes") + } + if text := toolResultText(t, agent, "k2"); !strings.Contains(text, "stops when this turn ends") || !strings.Contains(text, failed.ID+" is unchanged") { + t.Fatalf("the model was told %q", text) + } +} + +// TestACancelledRepairChangesNothing: the turn ending under the card is no +// answer, and an unanswered card starts and changes nothing. +func TestACancelledRepairChangesNothing(t *testing.T) { + supervisor, rt, _ := repairWorld(t) + completer := &scriptedCompleter{steps: []step{ + keepCall("k1", "npm run dev", map[string]any{"health": map[string]any{"port": 5173}}), + finalText("port is taken"), + keepCall("k2", "npm run dev -- --port 5174", map[string]any{"health": map[string]any{"port": 5174}, "service_id": "s1"}), + finalText("ok"), + }} + agent := keepAgent(t, completer, supervisor, "api-docs") + failed := conflictedRow(t, agent, supervisor, rt) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + events, _ := agent.Submit(ctx, "use another port") + deadline := time.After(10 * time.Second) + for open := true; open; { + select { + case event, more := <-events: + open = more + if more && event.Kind == EventKeepRunning && event.Keep != nil && event.Keep.Decided == "" { + cancel() + } + case <-deadline: + t.Fatal("the turn never ended") + } + } + rows := supervisor.List("api-docs") + if len(rows) != 1 || rows[0].Command != failed.Command || rows[0].State != service.StateFailed || rt.Starts() != 0 { + t.Fatalf("a cancelled card changed the kept row: %+v", rows) + } + if _, ok := supervisor.Approved(service.Spec{Command: "npm run dev -- --port 5174", Dir: agent.config.Workspace, Health: service.Health{Port: 5174}}); ok { + t.Fatal("an unanswered card banked a yes") + } +} + +// TestARepairIsRefusedBeforeTheCard: another conversation's id, an unknown +// one, a service still running and a duplicate are each said to the model with +// no card raised, nothing started and nothing signalled. +func TestARepairIsRefusedBeforeTheCard(t *testing.T) { + supervisor, rt, _ := repairWorld(t) + foreign, err := supervisor.Keep("other-agent", service.Spec{Command: "make serve", Dir: t.TempDir(), Health: service.Health{Port: 8080}}, service.PolicyNever, service.Provenance{}) + if err != nil { + t.Fatal(err) + } + cases := []struct { + name string + keep map[string]any + want string + }{ + {"foreign", map[string]any{"health": map[string]any{"port": 5174}, "service_id": foreign.ID}, "is not one of this conversation's"}, + {"unknown", map[string]any{"health": map[string]any{"port": 5174}, "service_id": "s99"}, "is not one of this conversation's"}, + {"live", map[string]any{"health": map[string]any{"port": 5174}, "service_id": "s2"}, "is still running"}, + {"duplicate", map[string]any{"health": map[string]any{"port": 5175}, "service_id": "s3"}, "already has that command"}, + } + var steps []step + for i, c := range cases { + command := "npm run dev -- --port 5174" + if c.name == "duplicate" { + command = "npm start" + } + steps = append(steps, keepCall("r"+string(rune('0'+i)), command, c.keep), finalText("refused")) + } + agent := keepAgent(t, &scriptedCompleter{steps: steps}, supervisor, "api-docs") + dir := agent.config.Workspace + live, err := supervisor.Keep("api-docs", service.Spec{Command: "npm start", Dir: dir, Health: service.Health{Port: 3000}}, service.PolicyOnFailure, service.Provenance{}) + if err != nil || live.ID != "s2" || !live.Running { + t.Fatalf("%+v %v", live, err) + } + stopped, err := supervisor.Keep("api-docs", service.Spec{Command: "npm run docs", Dir: dir, Health: service.Health{Port: 4000}}, service.PolicyOnFailure, service.Provenance{}) + if err != nil || stopped.ID != "s3" { + t.Fatalf("%+v %v", stopped, err) + } + if _, err := supervisor.Stop("api-docs", stopped.ID); err != nil { + t.Fatal(err) + } + for deadline := time.Now().Add(5 * time.Second); ; time.Sleep(time.Millisecond) { + if row, _ := supervisor.Get("api-docs", stopped.ID); !row.Running && !row.Stopping { + break + } + if time.Now().After(deadline) { + t.Fatal("s3 never stopped") + } + } + starts, running := rt.Starts(), rt.Running() + for i, c := range cases { + events, _ := agent.Submit(context.Background(), "fix it") + collected := drainAnsweringKeep(t, agent, events, KeepAllowKey, nil) + if cards := cardsIn(collected); len(cards) != 0 { + t.Errorf("%s: a refused repair raised a card", c.name) + } + if text := toolResultText(t, agent, "r"+string(rune('0'+i))); !strings.Contains(text, c.want) || !strings.Contains(text, "Nothing was started or changed") { + t.Errorf("%s: the model was told %q", c.name, text) + } + } + if rt.Starts() != starts || rt.Running() != running { + t.Fatal("a refused repair started or ended something") + } + if rows := supervisor.List("api-docs"); len(rows) != 2 || rows[1].Command != "npm run docs" { + t.Fatalf("a refused repair changed a row: %+v", rows) + } + if row, _ := supervisor.Get("other-agent", foreign.ID); row.Command != "make serve" || !row.Running { + t.Fatalf("another conversation's service was touched: %+v", row) + } +} + +// TestBashOffersServiceIDWithKeepRunning: the argument rides keep_running and +// is absent with it. +func TestBashOffersServiceIDWithKeepRunning(t *testing.T) { + supervisor, _, _ := repairWorld(t) + agent := keepAgent(t, &scriptedCompleter{}, supervisor, "api-docs") + for _, tool := range agent.belt() { + if tool.Name == "bash" && !strings.Contains(string(tool.Schema), `"service_id"`) { + t.Fatal("bash's keep_running has no service_id") + } + } +} From a2d6fe79bfb5de2b267d864308e2fef49513b2ef Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 20:52:01 -0400 Subject: [PATCH 072/459] Document repairing a kept process on its existing row --- internal/manual/chat/kept-running.md | 12 ++++++++++++ internal/session/prompts/system.md | 6 +++++- 2 files changed, 17 insertions(+), 1 deletion(-) diff --git a/internal/manual/chat/kept-running.md b/internal/manual/chat/kept-running.md index aed66b84cd..77bb94e4c0 100644 --- a/internal/manual/chat/kept-running.md +++ b/internal/manual/chat/kept-running.md @@ -42,6 +42,18 @@ It is listed with the agent's other processes, in the same list. - **Remove** takes a stopped one off the list. Its output is kept, and the chat's notice still opens it. +## Repair a failed process on the same row + +When a port is taken, tell the agent which port to use. It can repair its stopped or +failed service by naming that service in `bash`'s `keep_running.service_id`, such as +`s3`, with the changed command and health check. The same row, output and service id +remain. The changed command and check need your approval; denying or cancelling leaves +the old saved service alone. A running or stopping process must be stopped first. + +A changed command, folder or check starts with a new automatic restart budget after +you approve it. Changing only its name does not refill the budget. Its existing trouble +line resolves only when the repaired service passes a health check. + ## Quitting the app or closing an agent with processes kept up Quitting the app leaves kept-up processes up unless you choose to stop them; the diff --git a/internal/session/prompts/system.md b/internal/session/prompts/system.md index 0a1726edaf..29c1d01182 100644 --- a/internal/session/prompts/system.md +++ b/internal/session/prompts/system.md @@ -171,7 +171,11 @@ A mid-turn message cuts generation: keep partial work in the transcript and fold corrections into the SAME turn. A long bash may continue as a background job; its result names the job. Only when bash's own schema offers `keep_running` may a server outlive the turn: the person is asked, and only they -set its restart policy. Session news arrives only at boundaries. An +set its restart policy. To repair your failed or stopped kept service, use its +`keep_running.service_id` with the changed command and check, keeping its row; +never add a second service for that repair. A running service must be stopped +first, and a changed command needs fresh approval. Session news arrives only at +boundaries. An interruption ends the turn: stop cleanly and preserve finished work. A turn can start with nobody having typed. Such a message says so in its own From e33bac87f4114463ea6b342cc869f8045e5adae4 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 20:51:27 -0400 Subject: [PATCH 073/459] test: hold the carried receipt until the run says it is waiting for the price The fake funnel settled its receipt after a fixed 300ms, so a slow scheduler under -race let it land before the shell run reached its wait and the stderr line was never written. The receipt now waits on a gate the test closes when the run itself says "waiting up to", bounded by a patience so a run that never waits cannot leak the goroutine. Assertions are unchanged. Co-Authored-By: Claude Sonnet 5.5 --- cmd/codeaf/carried_money_test.go | 43 +++++++++++++++++++++++++++----- 1 file changed, 37 insertions(+), 6 deletions(-) diff --git a/cmd/codeaf/carried_money_test.go b/cmd/codeaf/carried_money_test.go index 8ec2b424ab..403770255e 100644 --- a/cmd/codeaf/carried_money_test.go +++ b/cmd/codeaf/carried_money_test.go @@ -6,6 +6,7 @@ import ( "context" "path/filepath" "strings" + "sync" "testing" "time" @@ -16,9 +17,15 @@ import ( ) // carriedOwingFunnel answers at once with no usage block and owes the call's -// receipt, which lands after a delay — the provider's own order: owed before -// the fetch, answered after the sink has the money. -type carriedOwingFunnel struct{ late time.Duration } +// receipt, which lands only once release is closed — the provider's own order: +// owed before the fetch, answered after the sink has the money. The test closes +// release when the run itself says it is waiting for the price, so the receipt +// can never settle ahead of the wait however the scheduler runs; patience bounds +// the goroutine so a run that never waits cannot leak it. +type carriedOwingFunnel struct { + release <-chan struct{} + patience time.Duration +} func (f carriedOwingFunnel) completerFor(string) modelapi.Completer { return f } @@ -31,7 +38,10 @@ func (f carriedOwingFunnel) CompleteWithMessages(ctx context.Context, _ []ai.Mes sink := provider.ReconcileSinkFrom(ctx) go func() { defer done() - time.Sleep(f.late) + select { + case <-f.release: + case <-time.After(f.patience): + } sink(provider.Reconciled{Billed: provider.Billed{Model: request.Model, PromptTokens: 52139, CompletionTokens: 4895, Cost: 0.058188488}, Found: true}) }() return &ai.Response{Model: request.Model, Choices: []ai.Choice{{ @@ -40,6 +50,23 @@ func (f carriedOwingFunnel) CompleteWithMessages(ctx context.Context, _ []ai.Mes }}}, nil } +// settlingTrigger is a stderr that lets go of its gate the first time the run +// says it is waiting for a price: the one event that proves the run is parked +// on the receipt. +type settlingTrigger struct { + *lockedBuffer + once sync.Once + gate chan struct{} +} + +func (s *settlingTrigger) Write(p []byte) (int, error) { + n, err := s.lockedBuffer.Write(p) + if strings.Contains(string(p), "waiting up to") { + s.once.Do(func() { close(s.gate) }) + } + return n, err +} + // A SHELL RUN WAITS FOR THE PRICE OF ITS LAST CALL, SAYS SO, AND FILES IT. The // call's receipt lands after the program has exited; the run waits for it // before its last lines and before this process's ledger closes, tells the @@ -47,10 +74,14 @@ func (f carriedOwingFunnel) CompleteWithMessages(ctx context.Context, _ []ai.Mes // own clock in its record folder. func TestAShellRunWaitsForItsLastCallsPriceAndKeepsItsClock(t *testing.T) { _, printed := hostWithRealChild(t, 0) + gate := make(chan struct{}) + told := &settlingTrigger{lockedBuffer: &lockedBuffer{}, gate: gate} + // The receipt is held until the run says it is waiting; the patience only + // frees the goroutine if it never does, and the assertions below then fail. + owing := carriedOwingFunnel{release: gate, patience: 30 * time.Second} carriedModels = func() (carriedRoad, error) { - return carriedRoad{completerFor: carriedOwingFunnel{late: 300 * time.Millisecond}.completerFor, seat: "seat/model"}, nil + return carriedRoad{completerFor: owing.completerFor, seat: "seat/model"}, nil } - told := &lockedBuffer{} previous := carriedStderr carriedStderr = told t.Cleanup(func() { carriedStderr = previous }) From 3e670c04bfda57d770cbfe101c1fd3a5be7e567f Mon Sep 17 00:00:00 2001 From: Santosh Date: Thu, 15 Oct 2026 20:48:52 -0400 Subject: [PATCH 074/459] app: keep a member's answer an answer, and say crew deliveries and posts as facts A reopened conversation called a message an update whenever any later tool call came before the person's next line, so an answer (BIRCH-9304) became an update once a manager's directive opened the next turn. A message is now an update only when its own calls ran after it, as the live pump names it. A crew delivery's notice carries its lines as facts (team: from, to, kind, post), and a team_post call carries handoff (crew, recipient, the log entry it became, what it answers; delivered false with the engine's words when refused). session.ReadTeamPost reads back the one sentence team_post answers with; both writers compose it in teampost.go, so the reader cannot drift. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/app.go | 71 +++++++-- cmd/codeaf/appmemberchat_test.go | 72 ++++++++++ cmd/codeaf/apptools.go | 5 + .../unreleased/0-app-member-chat-facts.md | 14 ++ internal/appwire/wire.go | 18 +++ internal/appwire/worklog.go | 23 +++ internal/session/team_nest.go | 2 +- internal/session/teampost.go | 135 ++++++++++++++++++ internal/session/teampost_test.go | 54 +++++++ internal/session/tools_team.go | 10 +- 10 files changed, 379 insertions(+), 25 deletions(-) create mode 100644 cmd/codeaf/appmemberchat_test.go create mode 100644 docs/changes/unreleased/0-app-member-chat-facts.md create mode 100644 internal/session/teampost.go create mode 100644 internal/session/teampost_test.go diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index 4129c86433..da29c024cb 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -2432,20 +2432,7 @@ func appEntryShapes(agent string, items []session.DisplayEntry, at time.Time, ma entries := make([]appwire.Entry, 0, len(items)) from := make([]int, 0, len(items)) n, m, k, c := -1, 0, 0, 0 - // A MESSAGE FOLLOWED BY A TOOL CALL BEFORE THE PERSON SPOKE AGAIN WAS AN - // UPDATE, as the live pump named it. - update := make([]bool, len(items)) - called := false - for i := len(items) - 1; i >= 0; i-- { - switch items[i].Role { - case "user": - called = false - case "tool": - called = true - case "assistant": - update[i] = called - } - } + update := appUpdates(items) for i, item := range items { // Each entry carries the instant its own line was written; the // conversation's latest moment stands in only where the record never @@ -2493,7 +2480,7 @@ func appEntryShapes(agent string, items []session.DisplayEntry, at time.Time, ma // that rode in the same message; the crew's lines are what the // person reads. if len(item.Team) > 0 { - entry.Text = appTeamDelivery(item.Team) + entry.Text, entry.Team = appTeamDelivery(item.Team), appTeamLines(item.Team) } k++ break @@ -2544,6 +2531,22 @@ func appEntryShapes(agent string, items []session.DisplayEntry, at time.Time, ma return entries, from } +// appUpdates marks each assistant message that was an UPDATE, as the live pump +// named it: a message whose own tool calls ran after it (the pump says the +// reply before a tool begins as an update, and the turn's last reply as its +// answer). The shaping writes a message's calls straight after it, so the +// mark is the very next item being a call. A later turn's calls never reach +// back: a turn a team delivery or a wake opened has no person's line in +// between, and its work once turned the previous turn's answer into an update. +func appUpdates(items []session.DisplayEntry) []bool { + update := make([]bool, len(items)) + for i := 0; i+1 < len(items); i++ { + next := items[i+1] + update[i] = items[i].Role == "assistant" && next.Role == "tool" && (next.CallID != "" || next.Tool != "") + } + return update +} + // SetModel moves the agent to another model, opening its conversation first if // none is held: the choice is the conversation's own and is saved with it. func (b *appBackend) SetModel(id, model string) (appwire.Agent, error) { @@ -3031,6 +3034,44 @@ func appEngineError(err error) error { return &appwire.Error{Code: appwire.CodeEngine, Message: redact.Secrets(plainWords(err.Error()))} } +// appTeamLines is a team delivery's lines as facts, words redacted as the +// notice's own text is. +func appTeamLines(lines []session.TeamLine) []appwire.TeamLine { + out := make([]appwire.TeamLine, 0, len(lines)) + for _, line := range lines { + out = append(out, appwire.TeamLine{CrewName: line.Team, From: line.From, To: line.To, Kind: line.Kind, Text: redact.Secrets(strings.TrimSpace(line.Text)), Post: line.Thread}) + } + return out +} + +// appHandoff is what a finished team_post call delivered, read from its own +// answer (session.ReadTeamPost); nil for any other call, one with no answer, +// and one whose words named no recipient. +func appHandoff(tool, args, answer string) *appwire.Handoff { + if tool != "team_post" || strings.TrimSpace(answer) == "" { + return nil + } + if post, ok := session.ReadTeamPost(answer); ok { + h := &appwire.Handoff{CrewName: post.Team, To: post.To, Post: post.Post, Answers: post.Answers, Delivered: true, Up: post.Up} + if post.Up { + h.FromCrew = post.From + } + return h + } + var asked struct{ To string } + if json.Unmarshal([]byte(args), &asked) != nil { + return nil + } + to := strings.TrimPrefix(strings.ToLower(strings.TrimSpace(asked.To)), "@") + switch to { + case "": + return nil + case "everyone": + to = "room" + } + return &appwire.Handoff{To: to, Delivered: false, Reason: redact.Secrets(strings.TrimSpace(answer))} +} + // appTeamDelivery is a team delivery's lines as the person reads them: who // said each, and what. func appTeamDelivery(lines []session.TeamLine) string { diff --git a/cmd/codeaf/appmemberchat_test.go b/cmd/codeaf/appmemberchat_test.go new file mode 100644 index 0000000000..4911fe4047 --- /dev/null +++ b/cmd/codeaf/appmemberchat_test.go @@ -0,0 +1,72 @@ +package main + +import ( + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/session" +) + +// A TURN'S LAST MESSAGE IS ITS ANSWER EVEN WHEN A LATER TURN WORKED. A member +// answered "BIRCH-9304" with no call; the manager's directive then opened a +// turn of its own with no person's line between, and its calls once turned +// that answer into an update. A message is an update only when its own calls +// ran after it. +func TestListingKeepsAnAnswerBeforeADeliveryOpenedTurn(t *testing.T) { + items := []session.DisplayEntry{ + {Role: "user", Text: "Read the attached file and reply only with its verification token."}, + {Role: "assistant", Text: "Reading it."}, + {Role: "tool", Tool: "read", CallID: "c1", Answered: true}, + {Role: "tool"}, + {Role: "assistant", Text: "BIRCH-9304"}, + {Role: "aside", ForModel: true, Text: "Team traffic in \"Verification\" for you (@review). These are the team's messages, not the person's words:\n◆ directive from manager #8: Run the suite twice.\n(Reply with team_post.)", + Team: []session.TeamLine{{Team: "Verification", From: "manager", Kind: "directive", Text: "Run the suite twice.", Thread: "000000000008"}}}, + {Role: "assistant", Text: "Run 1 was 62 tests / OK. Running it a second time now."}, + {Role: "tool", Tool: "bash", CallID: "c2", Answered: true}, + {Role: "assistant", Text: "Both runs passed."}, + } + entries := appEntriesOf("a", items, time.Now(), nil) + var said []string + for _, e := range entries { + said = append(said, e.Kind+":"+e.Text) + } + want := []string{ + "user:Read the attached file and reply only with its verification token.", + "update:Reading it.", + "tool:", + "answer:BIRCH-9304", + "notice:manager: Run the suite twice.", + "update:Run 1 was 62 tests / OK. Running it a second time now.", + "tool:", + "answer:Both runs passed.", + } + if strings.Join(said, "|") != strings.Join(want, "|") { + t.Fatalf("entries\n%v\nwant\n%v", said, want) + } + notice := entries[4] + if len(notice.Team) != 1 || notice.Team[0] != (appwire.TeamLine{CrewName: "Verification", From: "manager", Kind: "directive", Text: "Run the suite twice.", Post: "000000000008"}) { + t.Fatalf("the delivery's facts = %+v", notice.Team) + } +} + +// A TEAM POST'S CALL CARRIES WHAT IT DELIVERED, read from the engine's own +// answer: the log entry it became and what it answers; a refused post says +// it was not delivered in the engine's words; any other call carries none. +func TestTeamPostCallCarriesItsHandoff(t *testing.T) { + ok := appHandoff("team_post", `{"to":"manager","text":"LEAF-READY"}`, "Posted to the manager in \"Verification\" as #3, answering #2. It arrives at the start of their next step.") + if ok == nil || !ok.Delivered || ok.To != "manager" || ok.CrewName != "Verification" || strings.TrimLeft(ok.Post, "0") != "3" || strings.TrimLeft(ok.Answers, "0") != "2" || ok.Reason != "" { + t.Fatalf("delivered = %+v", ok) + } + refused := appHandoff("team_post", `{"to":"@Release","text":"done"}`, "The team \"Release\" is closed.") + if refused == nil || refused.Delivered || refused.To != "release" || refused.Reason != "The team \"Release\" is closed." { + t.Fatalf("refused = %+v", refused) + } + if h := appHandoff("bash", `{"to":"manager"}`, "Posted to the manager in \"V\". It arrives at the start of their next step."); h != nil { + t.Fatalf("a bash call carried %+v", h) + } + if h := appHandoff("team_post", `{"to":"manager"}`, ""); h != nil { + t.Fatalf("a running post carried %+v", h) + } +} diff --git a/cmd/codeaf/apptools.go b/cmd/codeaf/apptools.go index 84840baabe..719986df30 100644 --- a/cmd/codeaf/apptools.go +++ b/cmd/codeaf/apptools.go @@ -393,6 +393,7 @@ func (b *appBackend) callEnded(a *appAgent, agent string, turn uint64, event ses c.title = title } c.summary, c.detail, c.ok = summary, detail, &ok + c.facts.Handoff = appHandoff(event.Tool, event.Args, event.Output) if c.facts.EndedAt == nil { at := b.now() c.facts.EndedAt = &at @@ -580,8 +581,12 @@ func (b *appBackend) appEntryFacts(a *appAgent, item session.DisplayEntry) appwi } }) if known { + if facts.Handoff == nil { + facts.Handoff = appHandoff(item.Tool, item.Args, item.Output) + } return facts } + facts.Handoff = appHandoff(item.Tool, item.Args, item.Output) var workspace string b.locked(func() { workspace = a.view.Workspace }) facts.Kind = appToolKind(item.Tool, "") diff --git a/docs/changes/unreleased/0-app-member-chat-facts.md b/docs/changes/unreleased/0-app-member-chat-facts.md new file mode 100644 index 0000000000..52c1363edf --- /dev/null +++ b/docs/changes/unreleased/0-app-member-chat-facts.md @@ -0,0 +1,14 @@ +--- +kind: fixed +title: codeaf app keeps a member's answer an answer, and says its deliveries and posts as facts +pr: 0 +surface: [engine] +invalidates: + - "A reopened conversation called a message an update when ANY later tool call came before the person's next line, so a member's answer (\"BIRCH-9304\") became an update once a manager's directive opened the next turn. Now a message is an update only when its own calls ran after it, as the live pump says." + - "A crew delivery reached the app only as worded text (\"manager: …\"). Its notice now carries `team` lines: from, to, kind (note · directive · start) and the log entry." + - "A team_post call said where it went only in its answer's words. Its tool entry and turn.tool now carry `handoff`: crew, recipient, the log entry it became, what it answers, and delivered false with the engine's words when it was refused." +--- + +`appEntryShapes` names updates per message (`appUpdates`). `session.ReadTeamPost` +reads back the sentence `team_post` answers with; the two writers now compose it +through the same file (`teampost.go`), so the reader cannot drift from them. diff --git a/internal/appwire/wire.go b/internal/appwire/wire.go index 9fd0488b96..93c7447ebb 100644 --- a/internal/appwire/wire.go +++ b/internal/appwire/wire.go @@ -336,11 +336,29 @@ type Entry struct { Op *EntryOp `json:"op,omitempty"` // Compaction is a summary entry's pass, as facts (compaction.go). Compaction *Compaction `json:"compaction,omitempty"` + // Team is, on a notice that is a crew's delivery, its lines as facts: who + // sent each, to whom, its kind and its log entry. The notice's Text is the + // same lines worded. + Team []TeamLine `json:"team,omitempty"` // A tool entry carries the facts of its call (worklog.go); a note entry // names its task there too. ToolFacts } +// TeamLine is one line of a crew's delivery to a member. From is "manager", +// "you", "system" or a member's handle; To is "", "room", "everyone", +// "manager" or a handle; Kind is note, directive or start (the brief the +// member was started with); Post is the line's log entry, "" when the +// delivery did not number it. +type TeamLine struct { + CrewName string `json:"crew_name,omitempty"` + From string `json:"from"` + To string `json:"to,omitempty"` + Kind string `json:"kind"` + Text string `json:"text"` + Post string `json:"post,omitempty"` +} + // EntryOp is an operation the person or an agent did that the conversation // remembers. It rides a receipt entry whose Text is empty; the entry's id and // At are the same live and after a restart. diff --git a/internal/appwire/worklog.go b/internal/appwire/worklog.go index 3fd91b4112..0165b7a19c 100644 --- a/internal/appwire/worklog.go +++ b/internal/appwire/worklog.go @@ -44,6 +44,29 @@ type ToolFacts struct { // to twelve once it has ended. Lines is how many it wrote in all. Tail []string `json:"tail,omitempty"` Lines *int `json:"lines,omitempty"` + // Handoff is what a team_post call delivered, read from the call's own + // answer, and absent on every other call and one still running. + Handoff *Handoff `json:"handoff,omitempty"` +} + +// Handoff is one team_post as facts: the crew log it was written to, who it +// was for, and the log entry it became. Delivered false is a post the engine +// refused or could not write; Reason is then the engine's own words. +type Handoff struct { + // CrewName is the name of the crew whose log holds the line. + CrewName string `json:"crew_name,omitempty"` + // To is "manager", "room" or a member's handle (no @). + To string `json:"to,omitempty"` + // Post is the log entry the line was written as and Answers the entry it + // answers (LogLine ids); "" when the answer named none. + Post string `json:"post,omitempty"` + Answers string `json:"answers,omitempty"` + Delivered bool `json:"delivered"` + Reason string `json:"reason,omitempty"` + // Up is a post to the manager of the crew above (the member's crew has + // no manager of its own); FromCrew is the member's crew then. + Up bool `json:"up,omitempty"` + FromCrew string `json:"from_crew,omitempty"` } // ToolInput is what a call was asked to do: the command, query or path, and diff --git a/internal/session/team_nest.go b/internal/session/team_nest.go index ebc802935a..370046e281 100644 --- a/internal/session/team_nest.go +++ b/internal/session/team_nest.go @@ -702,5 +702,5 @@ func (a *Agent) teamPostUp(team teams.Team, role teamRole, entry teams.Entry) (s } } } - return fmt.Sprintf("Posted to the manager of %q, which %q answers to while it has no manager of its own. It arrives at the start of its next step.", role.bossName, team.Name), false, nil + return teamPostUpSaid(role.bossName, team.Name), false, nil } diff --git a/internal/session/teampost.go b/internal/session/teampost.go new file mode 100644 index 0000000000..3401358ae2 --- /dev/null +++ b/internal/session/teampost.go @@ -0,0 +1,135 @@ +package session + +// WHAT A TEAM POST SAID IT DID, AS FACTS. +// +// team_post answers its model with one sentence (tools_team.go, team_nest.go): +// where the line went, in which team, the number it was written as and the +// number it answers. That sentence is the only record a conversation keeps of +// the post, so a surface that wants to say "reported to the manager" from the +// fact rather than from the agent's own prose reads it back here, next to the +// two writers that compose it, which are the only code that knows the shape. + +import ( + "fmt" + "strconv" + "strings" + + "github.com/Agent-Field/codeaf/internal/teams" +) + +// TeamPost is one delivered team_post as its answer recorded it. +type TeamPost struct { + // Team is the name of the team whose log the line was written to. + Team string + // To is "manager", "room" or a teammate's handle (no @). + To string + // Post is the traffic entry the line was written as and Answers the one it + // answers, both entry ids; "" when the answer named none (a post up to a + // parent team's manager is not numbered). + Post string + Answers string + // Up is a post from a team with no manager of its own to the manager of + // the team above; Team is then that team, From the team it came from. + Up bool + From string +} + +const ( + teamPostLead = "Posted to " + teamPostArrive = ". It arrives at the start of their next step." + teamPostUpFmt = "Posted to the manager of %q, which %q answers to while it has no manager of its own. It arrives at the start of its next step." +) + +// teamPostSaid is a delivered post's answer: where is "the room", "the +// manager" or "@handle"; own and answers are entry ids or "". +func teamPostSaid(where, team, own, answers string) string { + said := teamPostLead + where + " in " + strconv.Quote(team) + if own != "" { + said += " as " + teams.ThreadNumber(own) + } + if answers != "" { + said += ", answering " + teams.ThreadNumber(answers) + } + return said + teamPostArrive +} + +// teamPostUpSaid is a post's answer when it went up to the manager of the +// team above. +func teamPostUpSaid(boss, team string) string { + return fmt.Sprintf(teamPostUpFmt, boss, team) +} + +// ReadTeamPost is the post a team_post answer records, and false for any +// other answer (a refusal, a failed write, an answer from an older build). +func ReadTeamPost(answer string) (TeamPost, bool) { + answer = strings.TrimSpace(answer) + var boss, from string + if n, err := fmt.Sscanf(answer, "Posted to the manager of %q, which %q answers", &boss, &from); err == nil && n == 2 { + if teamPostUpSaid(boss, from) == answer { + return TeamPost{Team: boss, To: teams.ToManager, Up: true, From: from}, true + } + } + rest, ok := strings.CutPrefix(answer, teamPostLead) + if !ok { + return TeamPost{}, false + } + rest, ok = strings.CutSuffix(rest, teamPostArrive) + if !ok { + return TeamPost{}, false + } + where, rest, ok := strings.Cut(rest, " in ") + if !ok { + return TeamPost{}, false + } + post := TeamPost{} + switch { + case where == "the room": + post.To = teams.ToRoom + case where == "the manager": + post.To = teams.ToManager + case strings.HasPrefix(where, "@") && len(where) > 1 && !strings.Contains(where, " "): + post.To = where[1:] + default: + return TeamPost{}, false + } + quoted, err := strconv.QuotedPrefix(rest) + if err != nil { + return TeamPost{}, false + } + post.Team, _ = strconv.Unquote(quoted) + rest = rest[len(quoted):] + if after, ok := strings.CutPrefix(rest, " as "); ok { + number, tail, _ := strings.Cut(after, ",") + id, ok := teams.ThreadID(number) + if !ok { + return TeamPost{}, false + } + post.Post, rest = id, "" + if tail != "" { + rest = "," + tail + } + } + if after, ok := strings.CutPrefix(rest, ", answering "); ok { + id, ok := teams.ThreadID(after) + if !ok { + return TeamPost{}, false + } + post.Answers, rest = id, "" + } + if rest != "" { + return TeamPost{}, false + } + // The sentence is read back only when writing the facts says it again. + where = "the room" + switch post.To { + case teams.ToManager: + where = "the manager" + case teams.ToRoom: + default: + where = "@" + post.To + } + if teamPostSaid(where, post.Team, post.Post, post.Answers) != answer { + return TeamPost{}, false + } + return post, true +} diff --git a/internal/session/teampost_test.go b/internal/session/teampost_test.go new file mode 100644 index 0000000000..9af26607f6 --- /dev/null +++ b/internal/session/teampost_test.go @@ -0,0 +1,54 @@ +package session + +import "testing" + +// A POST'S ANSWER READS BACK AS THE FACTS THAT WROTE IT, for every shape the +// two writers make, and nothing else reads as a post. +func TestTeamPostAnswerReadsBackAsItsFacts(t *testing.T) { + cases := []struct { + said string + want TeamPost + }{ + {teamPostSaid("the manager", "Verification", "000000000003", "000000000002"), TeamPost{Team: "Verification", To: "manager", Post: "000000000003", Answers: "000000000002"}}, + {teamPostSaid("the room", `Re "lease", in`, "000000000010", ""), TeamPost{Team: `Re "lease", in`, To: "room", Post: "000000000010"}}, + {teamPostSaid("@backend", "Release", "", ""), TeamPost{Team: "Release", To: "backend"}}, + {teamPostUpSaid("Release", "Docs"), TeamPost{Team: "Release", To: "manager", Up: true, From: "Docs"}}, + } + for _, c := range cases { + got, ok := ReadTeamPost(c.said) + if !ok { + t.Fatalf("%q did not read back", c.said) + } + c.want.Post, c.want.Answers = trimID(c.want.Post), trimID(c.want.Answers) + got.Post, got.Answers = trimID(got.Post), trimID(got.Answers) + if got != c.want { + t.Fatalf("%q read %+v, want %+v", c.said, got, c.want) + } + } + for _, refused := range []string{ + "", + "No teammate in \"Release\" has the handle \"x\". Post to room, manager, or one of @a.", + "The post could not be written to the team's traffic: disk full", + "Posted to the manager in \"Verification\" as #3, answering #2. Something else.", + "Posted to somebody in \"Verification\". It arrives at the start of their next step.", + } { + if post, ok := ReadTeamPost(refused); ok { + t.Fatalf("%q read as a post %+v", refused, post) + } + } +} + +// The exact sentence the native fixture recorded reads as post #3 answering #2. +func TestTeamPostRecordedSentenceReads(t *testing.T) { + post, ok := ReadTeamPost("Posted to the manager in \"Verification\" as #3, answering #2. It arrives at the start of their next step.") + if !ok || post.To != "manager" || post.Team != "Verification" || trimID(post.Post) != "3" || trimID(post.Answers) != "2" { + t.Fatalf("read %+v %v", post, ok) + } +} + +func trimID(id string) string { + for len(id) > 1 && id[0] == '0' { + id = id[1:] + } + return id +} diff --git a/internal/session/tools_team.go b/internal/session/tools_team.go index 5ae9424eb7..a46e6e04b6 100644 --- a/internal/session/tools_team.go +++ b/internal/session/tools_team.go @@ -838,13 +838,5 @@ func (a *Agent) teamPostTool(ctx context.Context, args json.RawMessage) (string, a.teamRouse(a.config.teamProfile(), team, role.wakes, []teams.Member{manager}, "") } } - answered := "" - if thread != "" { - answered = ", answering " + teams.ThreadNumber(thread) - } - as := "" - if own != "" { - as = " as " + teams.ThreadNumber(own) - } - return "Posted to " + where + " in " + strconv.Quote(team.Name) + as + answered + ". It arrives at the start of their next step.", false, nil + return teamPostSaid(where, team.Name, own, thread), false, nil } From 4bfde5c490b54e1d387ea6f24bd8f1c2e701f36b Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 20:58:05 -0400 Subject: [PATCH 075/459] Test real kept-service grace and adoption after original process exit --- internal/service/stop_grace_platform_test.go | 111 +++++++++++++++++++ 1 file changed, 111 insertions(+) create mode 100644 internal/service/stop_grace_platform_test.go diff --git a/internal/service/stop_grace_platform_test.go b/internal/service/stop_grace_platform_test.go new file mode 100644 index 0000000000..d2ea8b3561 --- /dev/null +++ b/internal/service/stop_grace_platform_test.go @@ -0,0 +1,111 @@ +//go:build linux || darwin + +package service + +import ( + "context" + "encoding/json" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/processgroup" +) + +// A real group that ignores TERM must survive the grace, then its leader and +// descendant must both disappear. Fake-runtime timing cannot prove OS teardown. +func TestATermIgnoringServiceAndItsChildStopAfterTheRealGrace(t *testing.T) { + if processgroup.BootID() == "" { + t.Skip("this machine names no boot") + } + work := t.TempDir() + s := openReal(t, t.TempDir()) + defer s.Close() + row, err := s.Keep("grace-owner", Spec{Command: "trap '' TERM; sleep 300 & echo $! > child.pid; wait", Dir: work, Health: Health{Cmd: "true"}}, PolicyNever, Provenance{}) + if err != nil { + t.Fatal(err) + } + group := processgroup.CaptureGroup(row.PID) + t.Cleanup(func() { _ = group.Kill() }) + child := readPID(t, filepath.Join(work, "child.pid")) + t.Logf("owned leader=%d child=%d group=%d", row.PID, child, row.PID) + started := time.Now() + ctx, cancel := context.WithTimeout(context.Background(), 2*StopGrace+5*time.Second) + defer cancel() + if err := s.StopAll(ctx, "grace-test"); err != nil { + t.Fatal(err) + } + elapsed := time.Since(started) + if elapsed < StopGrace-100*time.Millisecond { + t.Fatalf("TERM-ignoring group ended before grace: %s", elapsed) + } + waitFor(t, "the TERM-ignoring leader and child were reaped", func() bool { return gone(row.PID) && gone(child) }) + got, err := s.Get("grace-owner", row.ID) + if err != nil || got.Running || got.Stopping || got.StoppedBy != "grace-test" { + t.Fatalf("final row: %+v %v", got, err) + } + t.Logf("TERM then KILL completed in %s; both owned PIDs absent", elapsed) +} + +// The original OS process really exits, so its Wait goroutine cannot help the +// next supervisor reap or identify the kept group. +func TestAServiceIsAdoptedAfterItsOriginalProcessQuits(t *testing.T) { + const helperEnv = "CODEAF_SERVICE_QUIT_TEST_DIR" + if dir := os.Getenv(helperEnv); dir != "" { + s := openReal(t, filepath.Join(dir, "store")) + row, err := s.Keep("quit-owner", Spec{Command: "sleep 300 & echo $! > child.pid; wait", Dir: dir, Health: Health{Cmd: "true"}}, PolicyNever, Provenance{}) + if err != nil { + t.Fatal(err) + } + group := processgroup.CaptureGroup(row.PID) + t.Cleanup(func() { _ = group.Kill() }) + _ = readPID(t, filepath.Join(dir, "child.pid")) + raw, err := json.Marshal(row) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "row.json"), raw, 0600); err != nil { + t.Fatal(err) + } + s.Close() + os.Exit(0) + } + if processgroup.BootID() == "" { + t.Skip("this machine names no boot") + } + dir := t.TempDir() + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + helper := exec.CommandContext(ctx, os.Args[0], "-test.run=^TestAServiceIsAdoptedAfterItsOriginalProcessQuits$") + helper.Env = append(os.Environ(), helperEnv+"="+dir) + if out, err := helper.CombinedOutput(); err != nil { + t.Fatalf("original process: %v %s", err, out) + } + raw, err := os.ReadFile(filepath.Join(dir, "row.json")) + if err != nil { + t.Fatal(err) + } + var before Row + if err := json.Unmarshal(raw, &before); err != nil { + t.Fatal(err) + } + group := processgroup.CaptureGroup(before.PID) + t.Cleanup(func() { _ = group.Kill() }) + child := readPID(t, filepath.Join(dir, "child.pid")) + if gone(before.PID) || gone(child) { + t.Fatal("the original process quitting stopped its kept group") + } + s := openReal(t, filepath.Join(dir, "store")) + defer s.Close() + row, err := s.Get("quit-owner", before.ID) + if err != nil || !row.Running || row.PID != before.PID || !row.ReAdopted { + t.Fatalf("adopted row: %+v %v", row, err) + } + t.Logf("original process=%d exited; adopted leader=%d child=%d group=%d", helper.Process.Pid, row.PID, child, row.PID) + if err := s.StopAll(ctx, "new-owner-quit"); err != nil { + t.Fatal(err) + } + waitFor(t, "the re-adopted leader and child were reaped", func() bool { return gone(row.PID) && gone(child) }) +} From 8e9857d1c380ebac1631dbeedfb27b8d973b424c Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 20:44:33 -0400 Subject: [PATCH 076/459] session: a person's own words and attachments are kept on their message's line MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The app wrote attachments into the words the model reads and kept the person's own words and chips only in process memory (appTurnMark), so a restarted app read a message back as the path-expanded prose with no attachments. session.Agent.SubmitMessage takes a Submission — the model's text, the person's own words (nil vs explicit empty) and bounded attachment descriptors (id, kind, name, path, url, bytes, lines; no contents) — and records the mark on the message's own journal line through the existing presentation field (audience "person"). A refused mark records nothing. The live transcript, ReadTranscript/RecordCache and a reopened agent read it back as DisplayEntry.Authored and DisplayEntry.Attachments; a line with no mark is unchanged and never has attachments read out of its words. Steers and compaction re-journaling (including the skills-block rewrite) keep the mark. The model is sent exactly the text it was sent before. remote SubmitArgs carries said/attachments additively on Submit; an engine whose agent lacks SubmitMessage runs the words alone. The app's startLocked hands the staged descriptors to SubmitMessage on the same call that accepts the turn, so create, send, crew messages and an explicit crew target all record through one road; listings read the record and appTurnMark keeps only the turn and client id. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/app.go | 50 +-- cmd/codeaf/app_test.go | 10 + cmd/codeaf/appattach_history_test.go | 305 ++++++++++++++++++ cmd/codeaf/appconvo.go | 51 ++- cmd/codeaf/appentrypage_test.go | 2 +- cmd/codeaf/appwakes.go | 2 +- .../0-app-user-attachments-history.md | 14 + internal/remote/client.go | 7 + internal/remote/server.go | 13 +- internal/remote/submitmessage_test.go | 57 ++++ internal/remote/wire.go | 7 + internal/session/agent.go | 71 +++- internal/session/presentation.go | 27 +- internal/session/sessionfile.go | 19 +- internal/session/submission.go | 164 ++++++++++ internal/session/submission_test.go | 278 ++++++++++++++++ 16 files changed, 1041 insertions(+), 36 deletions(-) create mode 100644 cmd/codeaf/appattach_history_test.go create mode 100644 docs/changes/unreleased/0-app-user-attachments-history.md create mode 100644 internal/remote/submitmessage_test.go create mode 100644 internal/session/submission.go create mode 100644 internal/session/submission_test.go diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index da29c024cb..15741573e6 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -170,6 +170,9 @@ func runApp(args []string) error { // [remote.Agent] is one; a test's fake is another. type appConversation interface { Submit(ctx context.Context, text string) (<-chan session.Event, error) + // SubmitMessage is Submit with the person's own words and the attachments + // that rode on them, kept on the message's line in the record. + SubmitMessage(ctx context.Context, message session.Submission) (<-chan session.Event, error) Interrupt() Model() string Title() string @@ -439,14 +442,15 @@ type appAgent struct { walk *appWalk } -// appTurnMark is what this process knows of one of the person's messages. +// appTurnMark is what this process knows of one of the person's messages: the +// turn it opened and the client's own id for it, which no record keeps. +// +// IT IS NOT WHERE A MESSAGE'S WORDS OR ATTACHMENTS COME FROM. Those are on the +// message's own line in the record, written with it ([appSubmission]), so a +// listing reads them the same before a restart and after one. type appTurnMark struct { turn uint64 clientID string - // text and attachments are the person's own words and the files and - // links that rode with them, where the engine was sent both as words. - text string - attachments []appwire.Attachment } // appBoot is agents.create's ask, kept for the background open. @@ -1496,17 +1500,24 @@ func (b *appBackend) startLocked(a *appAgent, text, clientID string, shown *appS if a.marks == nil { a.marks = map[int]appTurnMark{} } - mark := appTurnMark{turn: turn, clientID: clientID} + a.marks[n] = appTurnMark{turn: turn, clientID: clientID} if shown != nil { - mark.text, mark.attachments = shown.text, shown.attachments entryText, attachments = shown.text, shown.attachments } - a.marks[n] = mark }) if refused != nil { return 0, refused } - events, err := live.conn.Conv.Submit(context.Background(), text) + // THE MESSAGE AND WHAT IT CARRIED ARE ACCEPTED TOGETHER: one call, one line + // in the record. A refused call writes neither, and a message with nothing + // riding on it is the plain Submit it always was. + var events <-chan session.Event + var err error + if shown != nil { + events, err = live.conn.Conv.SubmitMessage(context.Background(), appSubmission(text, shown)) + } else { + events, err = live.conn.Conv.Submit(context.Background(), text) + } if err != nil { b.locked(func() { live.running = false }) return 0, appEngineError(err) @@ -1533,7 +1544,7 @@ func (b *appBackend) startLocked(a *appAgent, text, clientID string, shown *appS guard.Go("app/turn", func() { defer long.Stop() defer tails() - b.pump(a, id, live, turn, n, text, events) + b.pump(a, id, live, turn, n, text, shown, events) b.sendQueued(a, live) }) return turn, nil @@ -1542,7 +1553,9 @@ func (b *appBackend) startLocked(a *appAgent, text, clientID string, shown *appS // pump maps one turn's session events onto the wire. It never waits on the // writer: everything goes into the agent's outbox. n is the turn's place among // the person's messages, which every entry id of the turn is counted from. -func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, n int, text string, events <-chan session.Event) { +// shown, when set, is what the person's entry showed, which a failed turn's +// retry shows again. +func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, n int, text string, shown *appSending, events <-chan session.Event) { defer close(live.pumpDone) ok, failure := true, "" kind, status := "", 0 @@ -1746,10 +1759,7 @@ func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, n } a.failed, a.failedKind, a.failedShown = "", "", nil if !ok { - a.failed, a.failedKind = text, kind - if mark := a.marks[n]; len(mark.attachments) > 0 { - a.failedShown = &appSending{text: mark.text, attachments: mark.attachments} - } + a.failed, a.failedKind, a.failedShown = text, kind, shown } }) switch { @@ -2451,7 +2461,10 @@ func appEntryShapes(agent string, items []session.DisplayEntry, at time.Time, ma c++ case "user": n, m, k = n+1, 0, 0 - entry.ID, entry.Kind, entry.Text = appUserEntryID(n), appwire.EntryUser, item.Text + // The words are the person's own and the attachments the ones that + // rode on them, as the record keeps them on the message's line; a + // line without that mark is its words, and has no attachments. + entry.ID, entry.Kind, entry.Text, entry.Attachments = appUserEntryID(n), appwire.EntryUser, item.Text, appAttachmentsOf(item.Attachments) case "assistant", "aside": if strings.TrimSpace(item.Text) == "" { continue @@ -2518,11 +2531,6 @@ func appEntryShapes(agent string, items []session.DisplayEntry, at time.Time, ma entry.Turn = mark.turn if entry.Kind == appwire.EntryUser { entry.ClientID = mark.clientID - // A message of attachments alone has no words of its own, - // and still shows as what the person sent. - if mark.text != "" || len(mark.attachments) > 0 { - entry.Text, entry.Attachments = mark.text, mark.attachments - } } } entries = append(entries, entry) diff --git a/cmd/codeaf/app_test.go b/cmd/codeaf/app_test.go index e65cc62156..d512690fdc 100644 --- a/cmd/codeaf/app_test.go +++ b/cmd/codeaf/app_test.go @@ -153,6 +153,7 @@ type fakeConversation struct { rail float64 remembered []session.ConsentScope texts []string + submissions []session.Submission cost float64 // The task, quick task and process doors' scripted answers, and what was // asked of them. @@ -230,6 +231,15 @@ func (f *fakeConversation) Submit(ctx context.Context, text string) (<-chan sess }() return events, nil } + +// SubmitMessage is Submit with what the door said about the message kept +// beside the words, so a test can read what the engine was handed. +func (f *fakeConversation) SubmitMessage(ctx context.Context, message session.Submission) (<-chan session.Event, error) { + f.mu.Lock() + f.submissions = append(f.submissions, message) + f.mu.Unlock() + return f.Submit(ctx, message.Text) +} func (f *fakeConversation) Interrupt() { f.mu.Lock(); f.interrupted++; f.mu.Unlock() } func (f *fakeConversation) Model() string { f.mu.Lock() diff --git a/cmd/codeaf/appattach_history_test.go b/cmd/codeaf/appattach_history_test.go new file mode 100644 index 0000000000..705a62ea29 --- /dev/null +++ b/cmd/codeaf/appattach_history_test.go @@ -0,0 +1,305 @@ +package main + +import ( + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/session" + teamstore "github.com/Agent-Field/codeaf/internal/teams" +) + +// appTurnDoneFor waits for one turn's end and says whether it ended well. +func appTurnDoneFor(t *testing.T, rec *appRecorder, turn uint64) { + t.Helper() + i := rec.waitFor(t, 30*time.Second, func(event string, data any) bool { + done, ok := data.(appwire.TurnDone) + return ok && done.Turn == turn + }) + rec.mu.Lock() + done := rec.data[i].(appwire.TurnDone) + rec.mu.Unlock() + if !done.OK { + t.Fatalf("turn %d = %+v", turn, done) + } +} + +// appSendWithWhenIdle sends once the agent's last turn has let go of it. +func appSendWithWhenIdle(t *testing.T, backend *appBackend, agent, text, clientID string, ids []string) uint64 { + t.Helper() + deadline := time.Now().Add(30 * time.Second) + for { + turn, err := backend.SendWith(agent, text, clientID, ids) + if err == nil { + return turn + } + if wireCode(err) != appwire.CodeBusy || time.Now().After(deadline) { + t.Fatalf("send %q = %v", text, err) + } + time.Sleep(2 * time.Millisecond) + } +} + +// appUsersOf is the person's entries of a listing, oldest first. +func appUsersOf(entries []appwire.Entry) []appwire.Entry { + var users []appwire.Entry + for _, entry := range entries { + if entry.Kind == appwire.EntryUser { + users = append(users, entry) + } + } + return users +} + +// appAttachIDs is an entry's attachment ids, joined. +func appAttachIDs(entry appwire.Entry) string { + ids := make([]string, 0, len(entry.Attachments)) + for _, at := range entry.Attachments { + ids = append(ids, at.ID) + } + return strings.Join(ids, " ") +} + +// appModelRequests is every request the scripted model has been sent so far. +func appModelRequests(model *recordingModel) []recordedRequest { + model.mu.Lock() + defer model.mu.Unlock() + return append([]recordedRequest(nil), model.requests...) +} + +// appTranscriptOf is where an agent's record is. +func appTranscriptOf(backend *appBackend, agent string) string { + a := backend.agent(agent) + var transcript string + backend.locked(func() { transcript = a.transcript }) + return transcript +} + +// WHAT A PERSON SENT IS WHAT A REOPENED CONVERSATION SHOWS. A real +// conversation, built in this process against a scripted model, is created +// with words and attachments, sent more with attachments, sent attachments +// alone, and sent plain words that merely mention a path. The backend is let +// go and a NEW one — nothing of the first process's memory — reads the record +// off the disk: each message is the person's own words with exactly the +// attachments that rode on it, once; the attachments alone are attachments +// with no words; the plain words stay words with none invented out of them. +// The model was sent the attachments as words, and still is after the reopen. +func TestAppAttachmentsSurviveAReopenedRecord(t *testing.T) { + root := resolvedTempDir(t) + model, workspace := wakeEnvironment(t, root) + if err := os.WriteFile(filepath.Join(workspace, "notes.md"), []byte("# notes\n"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(workspace, "main.go"), []byte("package main\n"), 0o644); err != nil { + t.Fatal(err) + } + rec := newAppRecorder() + first := newAppBackend(rec, appOpenInProcess) + closed := false + defer func() { + if !closed { + first.Close() + } + }() + agent, err := first.CreateAgent(appwire.CreateAsk{Workspace: workspace, Text: "why does this fail?", ClientID: "c-0", Attachments: []appwire.AttachSource{ + {Kind: appwire.AttachFile, Path: "notes.md"}, + {Kind: appwire.AttachPaste, Text: "--- FAIL: TestLoad\nexpected 1\n"}, + {Kind: appwire.AttachLink, URL: "https://example.com/issue/7"}, + }}) + if err != nil { + t.Fatal(err) + } + rec.waitFor(t, 30*time.Second, appTurnDone) + file, err := first.AddAttachment(agent.ID, appwire.Attachment{Kind: appwire.AttachFile, Path: "main.go"}, "") + if err != nil { + t.Fatal(err) + } + appTurnDoneFor(t, rec, appSendWithWhenIdle(t, first, agent.ID, "and look at this", "c-1", []string{file.ID})) + long := strings.Repeat("panic: nil map\n", 20) + paste, err := first.AddAttachment(agent.ID, appwire.Attachment{Kind: appwire.AttachPaste}, long) + if err != nil { + t.Fatal(err) + } + appTurnDoneFor(t, rec, appSendWithWhenIdle(t, first, agent.ID, "", "c-2", []string{paste.ID})) + // A SEND OF AN ID ALREADY SENT IS REFUSED AND RECORDS NOTHING: the chip + // went with the message it rode on. + if _, err := first.SendWith(agent.ID, "again", "c-x", []string{paste.ID}); wireCode(err) != appwire.CodeNotFound { + t.Fatalf("a send of a sent attachment answered %v", err) + } + plain := "plain words that mention Attached file: notes.md" + appTurnDoneFor(t, rec, appSendWithWhenIdle(t, first, agent.ID, plain, "c-3", nil)) + transcript := appTranscriptOf(first, agent.ID) + if err := first.Close(); err != nil { + t.Fatal(err) + } + closed = true + + rec2 := newAppRecorder() + second := newAppBackend(rec2, appOpenInProcess) + defer second.Close() + entries, _, err := second.Entries(agent.ID, "", appwire.MaxEntries) + if err != nil { + t.Fatal(err) + } + users := appUsersOf(entries) + if len(users) != 4 { + t.Fatalf("the reopened record holds %d messages of the person's: %+v", len(users), users) + } + created, sent, alone, words := users[0], users[1], users[2], users[3] + if created.Text != "why does this fail?" || appAttachIDs(created) != "at1 at2 at3" { + t.Fatalf("the created message reopened as %+v", created) + } + if at := created.Attachments; at[0].Kind != appwire.AttachFile || at[0].Path != "notes.md" || at[0].Title != "notes.md" || + at[1].Kind != appwire.AttachPaste || at[1].Lines == nil || *at[1].Lines != 2 || at[1].Title != "Pasted text" || + at[2].Kind != appwire.AttachLink || at[2].URL != "https://example.com/issue/7" || at[2].Title != "example.com" { + t.Fatalf("the created message's attachments = %+v", at) + } + if sent.Text != "and look at this" || appAttachIDs(sent) != file.ID || sent.Attachments[0].Path != "main.go" { + t.Fatalf("the sent message reopened as %+v", sent) + } + if alone.Text != "" || appAttachIDs(alone) != paste.ID || alone.Attachments[0].Kind != appwire.AttachPaste { + t.Fatalf("the attachments alone reopened as %+v", alone) + } + if words.Text != plain || len(words.Attachments) != 0 { + t.Fatalf("the plain words reopened as %+v", words) + } + for _, user := range users[:3] { + if strings.Contains(user.Text, "Attached file:") || strings.Contains(user.Text, "Pasted text:") || strings.Contains(user.Text, "Link:") { + t.Fatalf("a message reopened showing the words the model was sent: %q", user.Text) + } + } + + // The record's own reading says the same, with what the app measured. + var described []session.DisplayEntry + for _, item := range session.ReadTranscript(transcript).Entries { + if item.Role == "user" { + described = append(described, item) + } + } + if len(described) != 4 || !described[2].Authored || described[2].Text != "" || described[3].Authored || described[3].Attachments != nil { + t.Fatalf("the record reads %+v", described) + } + if got := described[2].Attachments; len(got) != 1 || got[0].Bytes != int64(len(long)) || got[0].Lines != 20 { + t.Fatalf("the paste's description = %+v", got) + } + if got := described[0].Attachments; got[1].Bytes != int64(len("--- FAIL: TestLoad\nexpected 1\n")) || got[0].Bytes != 0 { + t.Fatalf("the created message's descriptions = %+v", got) + } + + // AND THE MODEL STILL READS WHAT IT READ: the attachments as words, in the + // history a turn on the reopened conversation sends. + before := len(appModelRequests(model)) + turn, err := second.Send(agent.ID, "and now?", "c-4") + if err != nil { + t.Fatal(err) + } + appTurnDoneFor(t, rec2, turn) + replayed := false + for _, request := range appModelRequests(model)[before:] { + all := strings.Join(request.texts(), "\n") + if strings.Contains(all, "why does this fail?\n\nAttached file: notes.md") && strings.Contains(all, "Pasted text:\n```\n"+strings.TrimRight(long, "\n")) { + replayed = true + } + } + if !replayed { + t.Fatal("the reopened conversation did not send the model the attachments it was sent before") + } +} + +// A CREW'S MESSAGES ARE KEPT ON THE CONVERSATION THEY WENT TO. Two real +// conversations make a crew; a message to the manager and one to the member, +// each with attachments staged on its own target, and one naming the +// manager's chip to the member, which is refused. Read back by a new process, +// each record holds its own message with its own attachments, the refused one +// is nowhere, and the manager's chip is still the manager's to send. +func TestAppCrewAttachmentsAreKeptOnTheirTarget(t *testing.T) { + root := resolvedTempDir(t) + _, workspace := wakeEnvironment(t, root) + profile := filepath.Join(root, "crew-profile") + rec := newAppRecorder() + first := newAppBackend(rec, appOpenInProcess) + first.profileDir = profile + closed := false + defer func() { + if !closed { + first.Close() + } + }() + manager, err := mustCreate(t, first, appwire.CreateAsk{Workspace: workspace}) + if err != nil { + t.Fatal(err) + } + member, err := mustCreate(t, first, appwire.CreateAsk{Workspace: workspace}) + if err != nil { + t.Fatal(err) + } + lead, web := appTranscriptOf(first, manager.ID), appTranscriptOf(first, member.ID) + crew := teamstore.NewID() + err = teamstore.Update(profile, func(f *teamstore.File) error { + f.Teams = append(f.Teams, teamstore.Team{ID: crew, Name: "harbor", Made: time.Now()}) + if err := f.AddMember(crew, teamstore.Member{Key: lead, File: lead, Where: workspace, Word: "lead", Handle: "lead"}); err != nil { + return err + } + if err := f.AddMember(crew, teamstore.Member{Key: web, File: web, Where: workspace, Word: "web", Handle: "web"}); err != nil { + return err + } + return f.SetManager(crew, lead) + }) + if err != nil { + t.Fatal(err) + } + stage := func(agent, url string) string { + t.Helper() + at, err := first.AddAttachment(agent, appwire.Attachment{Kind: appwire.AttachLink, URL: url}, "") + if err != nil { + t.Fatal(err) + } + return at.ID + } + toLead := stage(manager.ID, "https://example.com/lead") + kept := stage(manager.ID, "https://example.com/kept") + toWeb := stage(member.ID, "https://example.com/web") + + entry, err := first.CrewMessage(appwire.CrewMessage{Crew: crew, Text: "for the lead", Attachments: []string{toLead}, ClientID: "k-1"}) + if err != nil || entry.Agent != manager.ID { + t.Fatalf("the message to the manager = %+v %v", entry, err) + } + appTurnDoneFor(t, rec, entry.Turn) + if _, err := first.CrewMessage(appwire.CrewMessage{Crew: crew, To: member.ID, Text: "not yours", Attachments: []string{kept}}); wireCode(err) != appwire.CodeNotFound { + t.Fatalf("the manager's attachment sent to the member answered %v", err) + } + entry, err = first.CrewMessage(appwire.CrewMessage{Crew: crew, To: member.ID, Text: "for the web", Attachments: []string{toWeb}, ClientID: "k-2"}) + if err != nil || entry.Agent != member.ID { + t.Fatalf("the message to the member = %+v %v", entry, err) + } + appTurnDoneFor(t, rec, entry.Turn) + if !staged(first, manager.ID, kept) || staged(first, member.ID, kept) { + t.Fatal("a refused message moved the manager's attachment") + } + if err := first.Close(); err != nil { + t.Fatal(err) + } + closed = true + + second := newAppBackend(newAppRecorder(), appOpenInProcess) + second.profileDir = profile + defer second.Close() + for _, want := range []struct { + agent, text, ids, url string + }{ + {manager.ID, "for the lead", toLead, "https://example.com/lead"}, + {member.ID, "for the web", toWeb, "https://example.com/web"}, + } { + entries, _, err := second.Entries(want.agent, "", appwire.MaxEntries) + if err != nil { + t.Fatal(err) + } + users := appUsersOf(entries) + if len(users) != 1 || users[0].Text != want.text || appAttachIDs(users[0]) != want.ids || users[0].Attachments[0].URL != want.url { + t.Fatalf("%s reopened with %+v", want.agent, users) + } + } +} diff --git a/cmd/codeaf/appconvo.go b/cmd/codeaf/appconvo.go index 10cc9aacc1..8e6d584858 100644 --- a/cmd/codeaf/appconvo.go +++ b/cmd/codeaf/appconvo.go @@ -174,7 +174,7 @@ func (b *appBackend) adoptTurn(a *appAgent, live *appLive, text, clientID string tails := b.tickTools(a, id, live) defer long.Stop() defer tails() - b.pump(a, id, live, turn, n, text, events) + b.pump(a, id, live, turn, n, text, nil, events) b.sendQueued(a, live) } @@ -806,10 +806,54 @@ func appAttachShown(text string, kept []appAttach) *appSending { return nil } views := make([]appwire.Attachment, 0, len(kept)) + described := make([]session.MessageAttachment, 0, len(kept)) for _, attach := range kept { views = append(views, attach.view) + described = append(described, appDescribed(attach)) } - return &appSending{text: text, attachments: views} + return &appSending{text: text, attachments: views, described: described} +} + +// appDescribed is one attachment as the record keeps it: what it is, what it +// is called and where it is, and its size where this side measured one — a +// paste, whose words the model is sent whole. Never its contents, and never +// a fact that changes after the send, such as whether a file has changed. +func appDescribed(attach appAttach) session.MessageAttachment { + described := session.MessageAttachment{ + ID: attach.view.ID, Kind: attach.view.Kind, Name: attach.view.Title, Path: attach.view.Path, URL: attach.view.URL, + } + if attach.view.Kind == appwire.AttachPaste { + described.Bytes = int64(len(attach.content)) + if attach.view.Lines != nil { + described.Lines = *attach.view.Lines + } + } + return described +} + +// appAttachmentsOf is a recorded message's attachments as an entry shows them, +// nil for a message the record keeps none for. +func appAttachmentsOf(described []session.MessageAttachment) []appwire.Attachment { + if len(described) == 0 { + return nil + } + views := make([]appwire.Attachment, 0, len(described)) + for _, d := range described { + view := appwire.Attachment{ID: d.ID, Kind: d.Kind, Path: d.Path, URL: d.URL, Title: d.Name} + if d.Lines > 0 { + lines := d.Lines + view.Lines = &lines + } + views = append(views, view) + } + return views +} + +// appSubmission is the message the engine is asked to run and keep: the words +// the model is sent, and the person's own words and attachments beside them. +func appSubmission(text string, shown *appSending) session.Submission { + said := shown.text + return session.Submission{Text: text, Said: &said, Attachments: shown.described} } // appAttachChars is the characters of paste a message carries, checked @@ -937,6 +981,9 @@ func (b *appBackend) sendWith(agent, text, clientID string, ids []string) (uint6 type appSending struct { text string attachments []appwire.Attachment + // described is the same attachments as the record keeps them + // ([appDescribed]), in the same order. + described []session.MessageAttachment } // ── tasks as conversations ────────────────────────────────────────────────── diff --git a/cmd/codeaf/appentrypage_test.go b/cmd/codeaf/appentrypage_test.go index 278ef8fb72..727db50af3 100644 --- a/cmd/codeaf/appentrypage_test.go +++ b/cmd/codeaf/appentrypage_test.go @@ -95,7 +95,7 @@ func TestAPageReadsOnlyItsOwnCallsFacts(t *testing.T) { "compacted": appPageRecord(2500, true), "short": short, } - marks := map[int]appTurnMark{2499: {turn: 7, clientID: "k1", text: "sent here"}} + marks := map[int]appTurnMark{2499: {turn: 7, clientID: "k1"}} for name, record := range records { t.Run(name, func(t *testing.T) { var whole []appwire.Entry diff --git a/cmd/codeaf/appwakes.go b/cmd/codeaf/appwakes.go index 438c3605bb..3eb75e1a8e 100644 --- a/cmd/codeaf/appwakes.go +++ b/cmd/codeaf/appwakes.go @@ -156,7 +156,7 @@ func (b *appBackend) wokenTurn(a *appAgent, live *appLive, elsewhere appTurnElse tails := b.tickTools(a, id, live) defer long.Stop() defer tails() - b.pump(a, id, live, turn, n, elsewhere.said, events) + b.pump(a, id, live, turn, n, elsewhere.said, nil, events) b.sendQueued(a, live) } diff --git a/docs/changes/unreleased/0-app-user-attachments-history.md b/docs/changes/unreleased/0-app-user-attachments-history.md new file mode 100644 index 0000000000..f1a17b6a6b --- /dev/null +++ b/docs/changes/unreleased/0-app-user-attachments-history.md @@ -0,0 +1,14 @@ +--- +kind: fixed +title: a person's own words and attachments are kept on their message's line and survive a reopen +pr: 0 +surface: [engine, remote] +invalidates: + - "The app's attachment chips and the person's own words lived only in the process that sent them (appTurnMark), so after a restart a message read back as the path-expanded words the model was sent, with no attachments. session.Agent.SubmitMessage now records the person's words and bounded attachment descriptors (id, kind, name, path, url, bytes, lines) on the message's own journal line, and DisplayEntry carries them as Authored and Attachments." + - "appTurnMark carried a message's text and attachments for listings. It now carries only the turn and client id; agents.entries reads words and attachments from the record, and a line without the mark has no attachments, whatever paths its words mention." + - "remote SubmitArgs carried words alone. It now also carries said and attachments on Submit; an engine without SubmitMessage runs the words and keeps no mark." +--- + +The mark rides the line's existing `presentation` field (audience `person`), so a +compaction pass, a steer and the record cache read it with the line and nothing +keeps a second history. The model is sent exactly the words it was sent before. diff --git a/internal/remote/client.go b/internal/remote/client.go index c8bec39786..0e07ffe934 100644 --- a/internal/remote/client.go +++ b/internal/remote/client.go @@ -1736,6 +1736,13 @@ func (a *Agent) Submit(ctx context.Context, text string) (<-chan session.Event, return a.open(ctx, MethodSubmit, SubmitArgs{Text: text}) } +// SubmitMessage is Submit with what the door knows about the message beyond its +// words ([session.Agent.SubmitMessage]): it travels on the same call, so the +// engine records the words and the mark on one line or neither. +func (a *Agent) SubmitMessage(ctx context.Context, message session.Submission) (<-chan session.Event, error) { + return a.open(ctx, MethodSubmit, SubmitArgs{Text: message.Text, Said: message.Said, Attachments: message.Attachments}) +} + // SubmitBash explicitly runs the person's command; ordinary Submit never // interprets message content as executable shell syntax. func (a *Agent) SubmitBash(ctx context.Context, text string) (<-chan session.Event, error) { diff --git a/internal/remote/server.go b/internal/remote/server.go index 8ae5fdcf99..15a160ace5 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -2550,7 +2550,18 @@ func (s *server) invoke(call Frame) (out json.RawMessage, err error) { if err != nil { return nil, err } - events, err := agent.Submit(context.Background(), args.Text) + // A MESSAGE WITH A MARK IS RECORDED WITH IT, by the agent's own door for + // one. An agent without that door runs the words alone, which is what + // every message was before the mark existed. + var events <-chan session.Event + door, marked := agent.(interface { + SubmitMessage(context.Context, session.Submission) (<-chan session.Event, error) + }) + if marked && (args.Said != nil || len(args.Attachments) > 0) { + events, err = door.SubmitMessage(context.Background(), session.Submission{Text: args.Text, Said: args.Said, Attachments: args.Attachments}) + } else { + events, err = agent.Submit(context.Background(), args.Text) + } return s.stream(MethodSubmit, args.Text, events, err) case MethodQuestionReplace: diff --git a/internal/remote/submitmessage_test.go b/internal/remote/submitmessage_test.go new file mode 100644 index 0000000000..681929b531 --- /dev/null +++ b/internal/remote/submitmessage_test.go @@ -0,0 +1,57 @@ +package remote + +import ( + "context" + "io" + "reflect" + "testing" + + "github.com/Agent-Field/codeaf/internal/session" +) + +// markedAgent is the scripted agent with the door a mark is recorded through. +type markedAgent struct { + *fakeAgent + submissions []session.Submission +} + +func (m *markedAgent) SubmitMessage(ctx context.Context, message session.Submission) (<-chan session.Event, error) { + m.mu.Lock() + m.submissions = append(m.submissions, message) + m.mu.Unlock() + return m.fakeAgent.Submit(ctx, message.Text) +} + +// A MESSAGE'S MARK CROSSES THE WIRE ON THE SAME CALL AS ITS WORDS. Submit with +// the person's words and attachments reaches the agent's own door for them, +// whole; Submit without them is the plain Submit it always was; and an agent +// with no such door — an engine from before the mark — runs the words alone. +func TestSubmitCarriesTheMessagesMarkToTheAgentsDoor(t *testing.T) { + said := "" + attachments := []session.MessageAttachment{{ID: "at1", Kind: "file", Name: "notes.md", Path: "notes.md"}, {ID: "at2", Kind: "paste", Name: "Pasted text", Bytes: 12, Lines: 2}} + marked := &markedAgent{fakeAgent: &fakeAgent{model: "m"}} + s := &server{out: io.Discard, session: NewSession(&Engine{Agent: marked}, false)} + if _, err := s.invoke(Frame{Kind: "call", ID: 1, Method: MethodSubmit, Payload: mustJSON(SubmitArgs{Text: "Attached file: notes.md", Said: &said, Attachments: attachments})}); err != nil { + t.Fatal(err) + } + s.pending = nil + if _, err := s.invoke(Frame{Kind: "call", ID: 2, Method: MethodSubmit, Payload: mustJSON(SubmitArgs{Text: "plain words"})}); err != nil { + t.Fatal(err) + } + if len(marked.submissions) != 1 || marked.submissions[0].Text != "Attached file: notes.md" || marked.submissions[0].Said == nil || *marked.submissions[0].Said != "" || + !reflect.DeepEqual(marked.submissions[0].Attachments, attachments) { + t.Fatalf("the marked door was handed %+v", marked.submissions) + } + if !reflect.DeepEqual(marked.sent, []string{"Attached file: notes.md", "plain words"}) { + t.Fatalf("the agent was sent %q", marked.sent) + } + + plain := &fakeAgent{model: "m"} + s = &server{out: io.Discard, session: NewSession(&Engine{Agent: plain}, false)} + if _, err := s.invoke(Frame{Kind: "call", ID: 1, Method: MethodSubmit, Payload: mustJSON(SubmitArgs{Text: "Attached file: notes.md", Said: &said, Attachments: attachments})}); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(plain.sent, []string{"Attached file: notes.md"}) { + t.Fatalf("an agent with no door for the mark was sent %q", plain.sent) + } +} diff --git a/internal/remote/wire.go b/internal/remote/wire.go index 75d28bbe0b..4db1e5a384 100644 --- a/internal/remote/wire.go +++ b/internal/remote/wire.go @@ -1448,6 +1448,13 @@ type InterruptArgs struct { type SubmitArgs struct { Text string `json:"text"` + // Said and Attachments are what a door that knows more than the words said + // about a person's message (internal/session's [session.Submission]), on + // Submit alone. Both are absent from every other call, and an engine built + // before them reads the words and nothing else — the message still runs, it + // is only kept without its mark. + Said *string `json:"said,omitempty"` + Attachments []session.MessageAttachment `json:"attachments,omitempty"` } // UnqueueArgs names the queued follow-up a surface wants taken back out: the diff --git a/internal/session/agent.go b/internal/session/agent.go index f940cbea08..894fea5048 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -961,6 +961,14 @@ func (a *Agent) SetContextWindow(tokens int) { // surface holding both channels sees each event on each, which is what a // per-message caller wants and what a whole-session reader must de-duplicate. func (a *Agent) Submit(ctx context.Context, text string) (<-chan Event, error) { + return a.submitText(ctx, text, nil) +} + +// submitText is Submit's body with the PERSON'S MARK beside the words: what a +// door that knows more than the words said about the message +// ([Agent.SubmitMessage]), nil for every other door. The mark rides the message +// to the moment it is recorded and changes nothing the model is sent. +func (a *Agent) submitText(ctx context.Context, text string, person *messagePresentation) (<-chan Event, error) { text = strings.TrimSpace(text) if text == "" { return nil, errors.New("session: empty message") @@ -980,12 +988,14 @@ func (a *Agent) Submit(ctx context.Context, text string) (<-chan Event, error) { if digest := a.planDigest(); digest != "" { user := planDigested(digest, text) user.said = said + user.person = person return a.submitUser(ctx, user) } user := userText(text) if text != said { user.said = said } + user.person = person return a.submitUser(ctx, user) } @@ -1183,7 +1193,13 @@ func (a *Agent) attachReplayLocked() (entries []DisplayEntry, events <-chan Even // references and journals exactly as it always did. type userMessage struct { // bash is set only by the person's SubmitBash door; model output cannot enter it. - bash string + bash string + // person is what the door that sent this message said about it beyond its + // words — the person's own sentence and the attachments that rode on it + // ([Agent.SubmitMessage]) — and nil on every other message. It is recorded + // on the message's own line by [Agent.recordUserLocked] and read by nothing + // the model is sent. + person *messagePresentation message ai.Message refs []journalPart // replyTags names finished tasks whose reports this message carries. It is @@ -2697,6 +2713,7 @@ func (a *Agent) recordUserLocked(user userMessage) { // nothing outlives them, because a replay is a reading of the conversation // and that paragraph was never part of one. kept := user.journaled() + a.rememberPersonLocked(user, kept) // The store's copy is taken before the journal's early return: a session // with no file still has a conversation worth keeping, and the person's own // words are the last thing that should depend on which layout they opened in. @@ -2737,6 +2754,32 @@ func (a *Agent) recordUserLocked(user userMessage) { a.stampUserLocked(messageContentText(kept)) } +// rememberPersonLocked marks the person's message with what its door said about +// it, on the copy the model reads and on the copy the record keeps — two copies +// whenever the session wrote beside the words ([userMessage.journaled]). The +// journal writes the mark off the record's copy, on the same line as the words, +// and a reading of the live transcript finds it on the model's copy. +// +// A skills block the session already marked on the model's copy keeps its +// provenance: the two marks are one mark on one message. +func (a *Agent) rememberPersonLocked(user userMessage, kept ai.Message) { + mark := user.person + if mark == nil || len(user.message.Content) == 0 { + return + } + if a.presentation == nil { + a.presentation = &presentationIndex{} + } + live := *mark + if prior := a.presentation.of(user.message); prior != nil { + live.SkillsBlock = prior.SkillsBlock + } + a.presentation.remember(user.message, &live) + if len(kept.Content) > 0 && &kept.Content[0] != &user.message.Content[0] { + a.presentation.remember(kept, mark.person()) + } +} + func (a *Agent) record(message ai.Message) { a.mu.Lock() a.recordLocked(message) @@ -4747,6 +4790,18 @@ type DisplayEntry struct { // the paths are the JOURNAL's record, and a conversation that lives only in // memory never wrote one. ImageRefs []string + + // Authored says Text is the PERSON'S OWN WORDS as the door that sent the + // message recorded them apart from what the model read + // ([Agent.SubmitMessage]) — empty Text included, which is a message of + // attachments alone. False on every other entry, whose Text is the line's + // own words exactly as they always were. + Authored bool + // Attachments are what that door said rode on the person's message, in the + // order it carried them, each a copy of the record's own descriptor. Nil on + // every message no such door sent, however many paths its words mention: + // nothing here is ever read out of the words. + Attachments []MessageAttachment // ReplyTags label the assistant entry that answers finished task notes. They // are nil on every ordinary reply. ReplyTags []TaskReplyTag @@ -4926,8 +4981,20 @@ func shapeEntries(messages []ai.Message, journal *sessionFile, indexes ...*prese } displayText := messageContentText(msg) // personWords removes only this message's recorded skills injection. + var authored bool + var attachments []MessageAttachment if role == "user" { displayText = presentation.personWords(msg) + // AND WHAT THE PERSON SENT IS SHOWN AS THEY SENT IT, where the door + // that sent it said so on the line itself: their own words in place + // of the paragraph the model was handed, and the attachments that + // rode on it. A line with no such mark is shown as it always was. + if mark := presentation.of(msg).person(); mark != nil { + if mark.Text != nil { + displayText, authored = *mark.Text, true + } + attachments = cloneAttachments(mark.Attachments) + } } interrupted, explicitlyHuman := false, false if mark := presentation.of(msg); role == "assistant" && mark != nil { @@ -4960,6 +5027,8 @@ func shapeEntries(messages []ai.Message, journal *sessionFile, indexes ...*prese Interrupted: interrupted, Text: displayText, ImageRefs: journal.imageRefs(msg), + Authored: authored, + Attachments: attachments, ReplyTags: tags, // The journal is the only thing that remembers a user line was typed // INTO the turn above it rather than opening one of its own: the diff --git a/internal/session/presentation.go b/internal/session/presentation.go index 4bb1b59e00..f0d0df08ec 100644 --- a/internal/session/presentation.go +++ b/internal/session/presentation.go @@ -19,11 +19,30 @@ import ( // every word. It is memory only: new journal writes keep the typed words, so // those restored messages need no mark. An older compacted journal can still // carry the saved block without a mark. +// +// On a PERSON'S line (Audience [audiencePerson]) Text is their own words where +// the door that sent the message said them apart from what the model reads, +// and Attachments are what rode on it ([Agent.SubmitMessage]). Neither changes +// the content; both are absent from every line no such door wrote. type messagePresentation struct { - Audience string `json:"audience"` - Text *string `json:"text,omitempty"` - Interrupted bool `json:"interrupted,omitempty"` - SkillsBlock string `json:"-"` + Audience string `json:"audience"` + Text *string `json:"text,omitempty"` + Interrupted bool `json:"interrupted,omitempty"` + Attachments []MessageAttachment `json:"attachments,omitempty"` + SkillsBlock string `json:"-"` +} + +// person is the part of a mark a person's message carries into the record: the +// mark without the memory-only skills provenance, or nil when there is nothing +// of the person's to keep. It is what a line is written with and what a +// re-journaled copy of the line is marked with again. +func (m *messagePresentation) person() *messagePresentation { + if m == nil || m.Audience != audiencePerson { + return nil + } + kept := *m + kept.SkillsBlock = "" + return &kept } // Message identity follows its immutable content allocation, as reasoning repair diff --git a/internal/session/sessionfile.go b/internal/session/sessionfile.go index abba72ed5c..6022f5323d 100644 --- a/internal/session/sessionfile.go +++ b/internal/session/sessionfile.go @@ -2781,11 +2781,14 @@ func (s *sessionFile) appendSteer(message ai.Message, mark SteerMark) bool { rememberSteer(s.steers, message, mark) s.mu.Unlock() return s.writeLine(sessionEntry{ - Type: "message", - Role: message.Role, - Content: messageContentText(message), - Steer: &journalSteer{At: steerStamp(mark.At), Consumed: mark.Consumed, Landing: mark.Landing}, - Timestamp: stamp(), + // A correction the person sent with attachments keeps them on its own + // line, as every other message of theirs does ([Agent.SubmitMessage]). + Presentation: s.presentation.of(message).person(), + Type: "message", + Role: message.Role, + Content: messageContentText(message), + Steer: &journalSteer{At: steerStamp(mark.At), Consumed: mark.Consumed, Landing: mark.Landing}, + Timestamp: stamp(), }) } @@ -3040,6 +3043,12 @@ func (s *sessionFile) appendCompaction(pass compactionPass, tokensBefore int, wi } if message.Role == "user" && presentation != nil && presentation.SkillsBlock != "" { message = textMessage("user", s.presentation.personWords(message)) + // The copy without the block is a new message, and what the person + // said about it goes with it, or the next resume reads their line + // back without its attachments. + if kept := presentation.person(); kept != nil { + s.presentation.remember(message, kept) + } } // AND SO IS A SPLICED ONE, for the same reason: a steer re-written without // its mark would come back from the next resume as a question of its own, diff --git a/internal/session/submission.go b/internal/session/submission.go new file mode 100644 index 0000000000..17b41faf92 --- /dev/null +++ b/internal/session/submission.go @@ -0,0 +1,164 @@ +package session + +import ( + "context" + "errors" + "fmt" + "strings" + "unicode/utf8" +) + +// ── what a person's message carried, kept beside the message ─────────────── +// +// A door that writes attachments INTO the words the model reads — a path, a +// link, a paste fenced into the message — leaves the record holding only those +// words. Reopened, the person's own sentence and the files that rode on it are +// gone: what comes back is one paragraph the person never typed, and no way to +// tell a path they attached from a path they mentioned. +// +// So the door says both, once, at the moment the message is accepted +// ([Agent.SubmitMessage]), and the record keeps both on THE SAME LINE as the +// message — the model's words in the content, the person's words and the +// attachments' descriptors in its presentation mark ([messagePresentation]). +// There is no second history: a reader of the line reads both or neither, a +// compaction that re-journals the line re-journals the mark, and a line written +// before this existed reads exactly as it always did. +// +// THE DESCRIPTORS ARE FACTS THE DOOR KNEW, NEVER BYTES. A file is named by its +// path and never inlined (the model reads it with its own tools); a paste's +// words are already in the content the model reads, and the descriptor says +// only how big it was. NOTHING IS EVER DERIVED FROM THE WORDS: a line with no +// mark has no attachments, however many paths it mentions. + +// MessageAttachment is one file, link or paste that rode on a person's message, +// as the door that sent the message described it. Every field is optional but +// Kind, and an empty field is "the door did not say", never a zero fact. +type MessageAttachment struct { + // ID is the door's own name for the attachment, unique within the message. + ID string `json:"id,omitempty"` + // Kind is what it is, in the door's own vocabulary ("file", "link", + // "paste"); the session neither interprets nor restricts it. + Kind string `json:"kind"` + // Name is how the attachment is labelled to a person. + Name string `json:"name,omitempty"` + // Path is where a file was, as the door named it. + Path string `json:"path,omitempty"` + // URL is a link's address. + URL string `json:"url,omitempty"` + // Bytes is the attachment's size, where the door measured it. + Bytes int64 `json:"bytes,omitempty"` + // Lines is how many lines it held, where the door counted them. + Lines int `json:"lines,omitempty"` +} + +// Submission is a person's message with what the door knows about it beyond +// the words the model reads: their own words, when those are not the whole of +// Text, and the attachments that rode on it. +type Submission struct { + // Text is what the model reads, exactly as [Agent.Submit] takes it. + Text string + // Said is the person's own words, when Text is more than them. It may be + // empty — a message of attachments alone — which is a different answer + // from nil, where Text is taken as theirs. + Said *string + // Attachments are what rode on the message, in the order it carried them. + Attachments []MessageAttachment +} + +// The bounds of what one message's mark may hold. THE MARK IS WRITTEN ON EVERY +// LINE THAT CARRIES ONE AND READ ON EVERY PAGE THAT SHOWS IT, so it is small and +// fixed: a door with more to say than this is saying it somewhere other than a +// description. +const ( + // MaxMessageAttachments is how many attachments one message's mark names. + MaxMessageAttachments = 32 + // maxAttachmentField is the longest single field of one descriptor, in + // bytes — a path or a URL well past any a filesystem or browser keeps. + maxAttachmentField = 4096 + // maxSaidBytes is the longest the person's own words may be. Their words + // are a part of Text in every door that has a reason to say them apart, + // so the bound is the content's own scale rather than a separate one. + maxSaidBytes = 1 << 20 +) + +// errSubmissionMark is every refusal of a mark that does not fit, so a door +// can tell a description it got wrong from a session that would not take a turn. +var errSubmissionMark = errors.New("session: the message's description does not fit") + +// SubmitMessage is [Agent.Submit] for a door that knows more about the message +// than its words: the person's own sentence, and the attachments the words were +// written from. The model reads Text and nothing else — the mark changes no +// request, no routing and no context — and the record keeps the mark on the +// message's own line, so a conversation reopened tomorrow shows what the person +// sent rather than what the model was told. +// +// A MARK THAT DOES NOT FIT REFUSES THE MESSAGE WHOLE, before anything is +// recorded, so a refusal leaves nothing behind. A submission with no mark — +// Said nil and no attachments — is exactly [Agent.Submit]. +func (a *Agent) SubmitMessage(ctx context.Context, message Submission) (<-chan Event, error) { + mark, err := message.mark() + if err != nil { + return nil, err + } + return a.submitText(ctx, message.Text, mark) +} + +// mark is the submission's presentation mark, checked and copied, and nil when +// it says nothing beyond the words. The copy is the record's own: nothing the +// caller does to its slices afterwards reaches a line or a reading. +func (m Submission) mark() (*messagePresentation, error) { + if m.Said == nil && len(m.Attachments) == 0 { + return nil, nil + } + if len(m.Attachments) > MaxMessageAttachments { + return nil, fmt.Errorf("%w: %d attachments, and one message names at most %d", errSubmissionMark, len(m.Attachments), MaxMessageAttachments) + } + mark := &messagePresentation{Audience: audiencePerson} + // Text stays nil when the door did not say the person's words apart: the + // line's own content is theirs, and a copy of it here would be the same + // words written twice on one line. + if m.Said != nil { + said := strings.TrimSpace(*m.Said) + if len(said) > maxSaidBytes || !utf8.ValidString(said) { + return nil, fmt.Errorf("%w: the person's words are not text of at most %d bytes", errSubmissionMark, maxSaidBytes) + } + mark.Text = &said + } + if len(m.Attachments) > 0 { + mark.Attachments = make([]MessageAttachment, 0, len(m.Attachments)) + } + for index, attachment := range m.Attachments { + attachment.ID = strings.TrimSpace(attachment.ID) + attachment.Kind = strings.TrimSpace(attachment.Kind) + attachment.Name = strings.TrimSpace(attachment.Name) + attachment.Path = strings.TrimSpace(attachment.Path) + attachment.URL = strings.TrimSpace(attachment.URL) + if attachment.Kind == "" { + return nil, fmt.Errorf("%w: attachment %d has no kind", errSubmissionMark, index+1) + } + for _, field := range []string{attachment.ID, attachment.Kind, attachment.Name, attachment.Path, attachment.URL} { + if len(field) > maxAttachmentField || !utf8.ValidString(field) { + return nil, fmt.Errorf("%w: attachment %d has a field that is not text of at most %d bytes", errSubmissionMark, index+1, maxAttachmentField) + } + } + if attachment.Bytes < 0 || attachment.Lines < 0 { + return nil, fmt.Errorf("%w: attachment %d has a negative size", errSubmissionMark, index+1) + } + mark.Attachments = append(mark.Attachments, attachment) + } + return mark, nil +} + +// audiencePerson is the audience a person's own message is marked with. It is +// the one mark a user-role line carries, and the only one [shapeEntries] reads +// a person's words and attachments from. +const audiencePerson = "person" + +// cloneAttachments is a reading's own copy of a mark's descriptors, so a +// [DisplayEntry] handed to a caller shares nothing with the index behind it. +func cloneAttachments(attachments []MessageAttachment) []MessageAttachment { + if len(attachments) == 0 { + return nil + } + return append([]MessageAttachment(nil), attachments...) +} diff --git a/internal/session/submission_test.go b/internal/session/submission_test.go new file mode 100644 index 0000000000..bc19b1a484 --- /dev/null +++ b/internal/session/submission_test.go @@ -0,0 +1,278 @@ +package session + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" +) + +// answering is a completer that answers every request "done". +func answering() *scriptedCompleter { + answer := func(context.Context, []ai.Message) (*ai.Response, error) { return textResponse("done"), nil } + return &scriptedCompleter{steps: []step{answer, answer, answer, answer, answer, answer}} +} + +// personEntries is a reading's entries of the person's own. +func personEntries(entries []DisplayEntry) []DisplayEntry { + var users []DisplayEntry + for _, entry := range entries { + if entry.Role == "user" { + users = append(users, entry) + } + } + return users +} + +// journalMarks is every message line's presentation mark, in file order, nil +// where the line carries none. +func journalMarks(t *testing.T, path string) []map[string]any { + t.Helper() + file, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer file.Close() + var marks []map[string]any + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 1<<20), 1<<24) + for scanner.Scan() { + var line struct { + Type string `json:"type"` + Role string `json:"role"` + Presentation map[string]any `json:"presentation"` + } + if json.Unmarshal(scanner.Bytes(), &line) == nil && line.Type == "message" && line.Role == "user" { + marks = append(marks, line.Presentation) + } + } + return marks +} + +var ( + submittedFiles = []MessageAttachment{ + {ID: "at1", Kind: "file", Name: "notes.md", Path: "notes.md"}, + {ID: "at2", Kind: "paste", Name: "Pasted text", Bytes: 31, Lines: 2}, + } + submittedAlone = []MessageAttachment{{ID: "at3", Kind: "link", Name: "example.com", URL: "https://example.com/7"}} +) + +// assertSubmitted is the three messages of the round-trip test, as any reading +// of them must say them. +func assertSubmitted(t *testing.T, where string, entries []DisplayEntry) { + t.Helper() + users := personEntries(entries) + if len(users) != 3 { + t.Fatalf("%s: %d messages of the person's: %#v", where, len(users), users) + } + if users[0].Text != "why does this fail?" || !users[0].Authored || !reflect.DeepEqual(users[0].Attachments, submittedFiles) { + t.Fatalf("%s: the message with words = %#v", where, users[0]) + } + if users[1].Text != "" || !users[1].Authored || !reflect.DeepEqual(users[1].Attachments, submittedAlone) { + t.Fatalf("%s: the attachments alone = %#v", where, users[1]) + } + if users[2].Text != "see Attached file: other.go" || users[2].Authored || users[2].Attachments != nil { + t.Fatalf("%s: the plain words = %#v", where, users[2]) + } +} + +// WHAT A DOOR SAID ABOUT A MESSAGE IS KEPT ON THE MESSAGE'S OWN LINE. Two +// messages are sent with marks — words and attachments, then attachments alone +// with no words — and one is plain words that mention a path. The model reads +// exactly the text it was handed. The live transcript, the record read at +// rest, and the conversation reopened from the file all say the person's words +// and attachments; the plain words are words, with nothing read out of them; +// and only the two marked lines carry a mark, holding no message bytes. +func TestASubmittedMessageKeepsThePersonsWordsAndAttachmentsOnItsLine(t *testing.T) { + path := filepath.Join(t.TempDir(), "session.jsonl") + completer := answering() + agent, _ := newTestAgent(t, completer, func(c *Config) { c.SessionFile = path }) + said, none := "why does this fail?", "" + sent := "why does this fail?\n\nAttached file: notes.md\n\nPasted text:\n```\n--- FAIL: TestLoad\nexpected 1\n```" + files := append([]MessageAttachment(nil), submittedFiles...) + events, err := agent.SubmitMessage(context.Background(), Submission{Text: sent, Said: &said, Attachments: files}) + if err != nil { + t.Fatal(err) + } + collect(t, events) + // The caller's slice is its own: changing it afterwards reaches no record. + files[0].Name = "changed" + events, err = agent.SubmitMessage(context.Background(), Submission{Text: "Link: https://example.com/7", Said: &none, Attachments: submittedAlone}) + if err != nil { + t.Fatal(err) + } + collect(t, events) + events, err = agent.Submit(context.Background(), "see Attached file: other.go") + if err != nil { + t.Fatal(err) + } + collect(t, events) + + completer.mu.Lock() + first := completer.seen[0] + completer.mu.Unlock() + if got := messageContentText(first[len(first)-1]); got != sent { + t.Fatalf("the model was sent %q, want %q", got, sent) + } + live := agent.Transcript() + assertSubmitted(t, "live", live) + // A reading's attachments are its own: changing them changes no later one. + personEntries(live)[0].Attachments[0].Name = "scribbled" + assertSubmitted(t, "read again", agent.Transcript()) + if err := agent.Close(); err != nil { + t.Fatal(err) + } + + assertSubmitted(t, "at rest", ReadTranscript(path).Entries) + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + assertSubmitted(t, "bytes", ReadTranscriptBytes(data).Entries) + marks := journalMarks(t, path) + if len(marks) != 3 || marks[0] == nil || marks[1] == nil || marks[2] != nil { + t.Fatalf("the person's lines carry marks %v", marks) + } + for _, mark := range marks[:2] { + for key := range mark { + if key != "audience" && key != "text" && key != "attachments" { + t.Fatalf("a mark carries %q: %v", key, mark) + } + } + } + + reopened, _ := newTestAgent(t, answering(), func(c *Config) { c.SessionFile = path }) + assertSubmitted(t, "reopened", reopened.Transcript()) + reopened.mu.Lock() + replayed := messageContentText(reopened.messages[1]) + reopened.mu.Unlock() + if replayed != sent { + t.Fatalf("the reopened conversation reads %q to the model, want %q", replayed, sent) + } +} + +// A MARK THAT DOES NOT FIT REFUSES THE MESSAGE WHOLE: nothing is recorded, +// nothing is asked of the model, and the session takes the next message. +func TestASubmissionThatDoesNotFitRecordsNothing(t *testing.T) { + path := filepath.Join(t.TempDir(), "session.jsonl") + completer := answering() + agent, _ := newTestAgent(t, completer, func(c *Config) { c.SessionFile = path }) + words := "words" + many := make([]MessageAttachment, MaxMessageAttachments+1) + for i := range many { + many[i] = MessageAttachment{Kind: "link"} + } + for name, message := range map[string]Submission{ + "too many": {Text: "x", Attachments: many}, + "no kind": {Text: "x", Said: &words, Attachments: []MessageAttachment{{Name: "a"}}}, + "negative size": {Text: "x", Attachments: []MessageAttachment{{Kind: "paste", Bytes: -1}}}, + "long field": {Text: "x", Attachments: []MessageAttachment{{Kind: "file", Path: strings.Repeat("p", maxAttachmentField+1)}}}, + } { + if _, err := agent.SubmitMessage(context.Background(), message); !errors.Is(err, errSubmissionMark) { + t.Fatalf("%s: SubmitMessage = %v", name, err) + } + } + completer.mu.Lock() + asked := len(completer.seen) + completer.mu.Unlock() + if asked != 0 || len(personEntries(agent.Transcript())) != 0 { + t.Fatalf("a refused message reached the model %d times or the record", asked) + } + events, err := agent.SubmitMessage(context.Background(), Submission{Text: "plain"}) + if err != nil { + t.Fatal(err) + } + collect(t, events) + users := personEntries(agent.Transcript()) + if len(users) != 1 || users[0].Text != "plain" || users[0].Authored || users[0].Attachments != nil { + t.Fatalf("a submission with no mark read back as %#v", users) + } + if err := agent.Close(); err != nil { + t.Fatal(err) + } + if marks := journalMarks(t, path); len(marks) != 1 || marks[0] != nil { + t.Fatalf("the journal holds %v", marks) + } +} + +// A LINE WRITTEN BEFORE THE MARK, OR WITHOUT ONE, HAS NO ATTACHMENTS, however +// many paths and attachment-shaped lines its words hold. +func TestALegacyLineWithPathsHasNoAttachments(t *testing.T) { + text := "Attached file: notes.md\n\nLink: https://example.com\n\nPasted text:\n```\nx\n```" + data, _ := json.Marshal(sessionEntry{Type: "message", Role: "user", Content: text}) + record := ReadTranscriptBytes(append(data, '\n')) + if len(record.Entries) != 1 || record.Entries[0].Text != text || record.Entries[0].Authored || record.Entries[0].Attachments != nil { + t.Fatalf("the legacy line reads %#v", record.Entries) + } +} + +// A MARK SURVIVES EVERY OTHER WAY A PERSON'S LINE IS WRITTEN: a correction +// spliced into a running turn, and a compaction pass re-journaling the window +// — including a message whose model copy carries the skills block, which the +// pass re-writes as a new message without it. +func TestAMarkSurvivesSteeringAndCompaction(t *testing.T) { + path := filepath.Join(t.TempDir(), "session.jsonl") + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { c.SessionFile = path }) + mark := func(said string, attachments ...MessageAttachment) *messagePresentation { + m, err := Submission{Said: &said, Attachments: attachments}.mark() + if err != nil { + t.Fatal(err) + } + return m + } + plain := userText("look\n\nAttached file: a.go") + plain.person = mark("look", MessageAttachment{ID: "at1", Kind: "file", Path: "a.go"}) + block := "\n\nuse them" + skilled := userText("check\n\nAttached file: b.go" + block) + skilled.said = "check\n\nAttached file: b.go" + skilled.person = mark("check", MessageAttachment{ID: "at2", Kind: "file", Path: "b.go"}) + agent.presentation.remember(skilled.message, &messagePresentation{SkillsBlock: block}) + steer := userText("also\n\nAttached file: c.go") + steer.person = mark("also", MessageAttachment{ID: "at3", Kind: "file", Path: "c.go"}) + steer.crossed = &SteerMark{Consumed: true, Landing: "read at the next step"} + agent.mu.Lock() + agent.recordUserLocked(plain) + agent.recordUserLocked(skilled) + agent.recordUserLocked(steer) + agent.mu.Unlock() + assert := func(where string, entries []DisplayEntry) { + t.Helper() + users := personEntries(entries) + if len(users) != 3 { + t.Fatalf("%s: %#v", where, users) + } + for i, want := range []struct{ text, path string }{{"look", "a.go"}, {"check", "b.go"}, {"also", "c.go"}} { + if users[i].Text != want.text || !users[i].Authored || len(users[i].Attachments) != 1 || users[i].Attachments[0].Path != want.path { + t.Fatalf("%s: message %d = %#v", where, i, users[i]) + } + } + if users[2].Steer == nil { + t.Fatalf("%s: the correction lost its mark", where) + } + } + assert("live", agent.Transcript()) + if got := agent.presentation.of(skilled.message); got == nil || got.SkillsBlock != block { + t.Fatal("the person's mark took the skills block's provenance off the model's copy") + } + assert("at rest", ReadTranscript(path).Entries) + window := append([]ai.Message(nil), agent.snapshot()[1:]...) + agent.file.appendCompaction(compactionPass{}, 0, window) + if err := agent.Close(); err != nil { + t.Fatal(err) + } + replayed, err := replaySessionFile(path) + if err != nil { + t.Fatal(err) + } + if len(replayed.messages) != 3 || strings.Contains(messageContentText(replayed.messages[1]), "") { + t.Fatalf("the re-journaled window = %#v", replayed.messages) + } + assert("after compaction", shapeEntries(replayed.messages, &sessionFile{presentation: replayed.presentation, steers: replayed.steers})) +} From d80e3381da1bda383bca9b5795b7df70ac6743c3 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 20:53:36 -0400 Subject: [PATCH 077/459] session: a mark is let go with its message; bounds are on the bytes given MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Review gates on the persisted attachment marks: - The presentation index is keyed by a message's first content part, so a mark kept the whole part array — a person's message can carry hundreds of KB of paste — alive after a compaction folded it or a rewind cut it. presentationIndex.retain cuts the index back to the transcript and the steering, ambient and follow-up queues after every compaction pass (both the cross-turn pass and the in-turn fold), every rewind, and at open once the earlier region is shaped. The record's own copy of a person's line and the compaction's skills-free re-journal copy are forgotten as soon as their line is written. The journal's marks are untouched; a reopen reads them from the lines. - Submission bounds are checked on the raw bytes before trimming, and kept fields and words are fresh copies, so padding cannot pass a bound and a short kept field cannot hold a large caller string alive. - MessageAttachment.Bytes and Lines are pointers: nil is unmeasured, a value (zero included) is measured. The app measures pastes only; a file's size stays unsaid. Tests: repeated compaction rounds plus a rewind keep the index exactly the transcript's marks (fails with the prune disabled) while the record still reads every message once; padded-bound and copy checks; known zero vs unknown sizes through the journal. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/appattach_history_test.go | 4 +- cmd/codeaf/appconvo.go | 16 +- .../0-app-user-attachments-history.md | 5 +- internal/remote/submitmessage_test.go | 3 +- internal/session/agent.go | 36 +++- internal/session/loop.go | 3 + internal/session/presentation.go | 58 ++++++ internal/session/rewind.go | 3 + internal/session/sessionfile.go | 2 + internal/session/submission.go | 80 ++++++--- internal/session/submission_test.go | 168 +++++++++++++++++- internal/session/turnfold.go | 2 + 12 files changed, 341 insertions(+), 39 deletions(-) diff --git a/cmd/codeaf/appattach_history_test.go b/cmd/codeaf/appattach_history_test.go index 705a62ea29..ba9a3f9b05 100644 --- a/cmd/codeaf/appattach_history_test.go +++ b/cmd/codeaf/appattach_history_test.go @@ -182,10 +182,10 @@ func TestAppAttachmentsSurviveAReopenedRecord(t *testing.T) { if len(described) != 4 || !described[2].Authored || described[2].Text != "" || described[3].Authored || described[3].Attachments != nil { t.Fatalf("the record reads %+v", described) } - if got := described[2].Attachments; len(got) != 1 || got[0].Bytes != int64(len(long)) || got[0].Lines != 20 { + if got := described[2].Attachments; len(got) != 1 || got[0].Bytes == nil || *got[0].Bytes != int64(len(long)) || got[0].Lines == nil || *got[0].Lines != 20 { t.Fatalf("the paste's description = %+v", got) } - if got := described[0].Attachments; got[1].Bytes != int64(len("--- FAIL: TestLoad\nexpected 1\n")) || got[0].Bytes != 0 { + if got := described[0].Attachments; got[1].Bytes == nil || *got[1].Bytes != int64(len("--- FAIL: TestLoad\nexpected 1\n")) || got[0].Bytes != nil || got[0].Lines != nil { t.Fatalf("the created message's descriptions = %+v", got) } diff --git a/cmd/codeaf/appconvo.go b/cmd/codeaf/appconvo.go index 8e6d584858..5d7e7f25b0 100644 --- a/cmd/codeaf/appconvo.go +++ b/cmd/codeaf/appconvo.go @@ -816,16 +816,20 @@ func appAttachShown(text string, kept []appAttach) *appSending { // appDescribed is one attachment as the record keeps it: what it is, what it // is called and where it is, and its size where this side measured one — a -// paste, whose words the model is sent whole. Never its contents, and never -// a fact that changes after the send, such as whether a file has changed. +// paste, whose words the model is sent whole. A file's size is left unsaid: +// this side reads no further than its stat's state, and an unmeasured size is +// absent rather than zero. Never its contents, and never a fact that changes +// after the send, such as whether a file has changed. func appDescribed(attach appAttach) session.MessageAttachment { described := session.MessageAttachment{ ID: attach.view.ID, Kind: attach.view.Kind, Name: attach.view.Title, Path: attach.view.Path, URL: attach.view.URL, } if attach.view.Kind == appwire.AttachPaste { - described.Bytes = int64(len(attach.content)) + bytes := int64(len(attach.content)) + described.Bytes = &bytes if attach.view.Lines != nil { - described.Lines = *attach.view.Lines + lines := *attach.view.Lines + described.Lines = &lines } } return described @@ -840,8 +844,8 @@ func appAttachmentsOf(described []session.MessageAttachment) []appwire.Attachmen views := make([]appwire.Attachment, 0, len(described)) for _, d := range described { view := appwire.Attachment{ID: d.ID, Kind: d.Kind, Path: d.Path, URL: d.URL, Title: d.Name} - if d.Lines > 0 { - lines := d.Lines + if d.Lines != nil { + lines := *d.Lines view.Lines = &lines } views = append(views, view) diff --git a/docs/changes/unreleased/0-app-user-attachments-history.md b/docs/changes/unreleased/0-app-user-attachments-history.md index f1a17b6a6b..642984da52 100644 --- a/docs/changes/unreleased/0-app-user-attachments-history.md +++ b/docs/changes/unreleased/0-app-user-attachments-history.md @@ -4,7 +4,7 @@ title: a person's own words and attachments are kept on their message's line and pr: 0 surface: [engine, remote] invalidates: - - "The app's attachment chips and the person's own words lived only in the process that sent them (appTurnMark), so after a restart a message read back as the path-expanded words the model was sent, with no attachments. session.Agent.SubmitMessage now records the person's words and bounded attachment descriptors (id, kind, name, path, url, bytes, lines) on the message's own journal line, and DisplayEntry carries them as Authored and Attachments." + - "The app's attachment chips and the person's own words lived only in the process that sent them (appTurnMark), so after a restart a message read back as the path-expanded words the model was sent, with no attachments. session.Agent.SubmitMessage now records the person's words and bounded attachment descriptors (id, kind, name, path, url, and bytes/lines as pointers where nil is unmeasured and zero is a measured zero) on the message's own journal line, and DisplayEntry carries them as Authored and Attachments." - "appTurnMark carried a message's text and attachments for listings. It now carries only the turn and client id; agents.entries reads words and attachments from the record, and a line without the mark has no attachments, whatever paths its words mention." - "remote SubmitArgs carried words alone. It now also carries said and attachments on Submit; an engine without SubmitMessage runs the words and keeps no mark." --- @@ -12,3 +12,6 @@ invalidates: The mark rides the line's existing `presentation` field (audience `person`), so a compaction pass, a steer and the record cache read it with the line and nothing keeps a second history. The model is sent exactly the words it was sent before. +A compaction pass, a rewind and an open now cut the in-memory presentation index +back to the messages the session still holds, so a mark never keeps a folded or +cut message's paste alive; the marks stay on the journal's lines. diff --git a/internal/remote/submitmessage_test.go b/internal/remote/submitmessage_test.go index 681929b531..c7adb29bfb 100644 --- a/internal/remote/submitmessage_test.go +++ b/internal/remote/submitmessage_test.go @@ -28,7 +28,8 @@ func (m *markedAgent) SubmitMessage(ctx context.Context, message session.Submiss // with no such door — an engine from before the mark — runs the words alone. func TestSubmitCarriesTheMessagesMarkToTheAgentsDoor(t *testing.T) { said := "" - attachments := []session.MessageAttachment{{ID: "at1", Kind: "file", Name: "notes.md", Path: "notes.md"}, {ID: "at2", Kind: "paste", Name: "Pasted text", Bytes: 12, Lines: 2}} + pasteBytes, pasteLines := int64(12), 2 + attachments := []session.MessageAttachment{{ID: "at1", Kind: "file", Name: "notes.md", Path: "notes.md"}, {ID: "at2", Kind: "paste", Name: "Pasted text", Bytes: &pasteBytes, Lines: &pasteLines}} marked := &markedAgent{fakeAgent: &fakeAgent{model: "m"}} s := &server{out: io.Discard, session: NewSession(&Engine{Agent: marked}, false)} if _, err := s.invoke(Frame{Kind: "call", ID: 1, Method: MethodSubmit, Payload: mustJSON(SubmitArgs{Text: "Attached file: notes.md", Said: &said, Attachments: attachments})}); err != nil { diff --git a/internal/session/agent.go b/internal/session/agent.go index 894fea5048..25d0f227dd 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -296,6 +296,9 @@ func newAgent(config Config, client Completer) (*Agent, error) { // is the one place the two are converted, by doing the shaping. agent.earlier = shapeEntries(replayed.earlier, file) agent.earlierFloor = len(shapeEntries(restored[:replayed.overlap], file)) + // The region above the last pass is shaped and let go of, so its marks + // go with it ([presentationIndex.retain]). + agent.presentation.retain(agent.messages) agent.restorePasses(replayed.passes) // AND THE PICTURES ARE MADE SAFE HERE RATHER THAN IN THE REPLAY. A // session resumed onto a model without vision — the journal remembers @@ -2713,7 +2716,7 @@ func (a *Agent) recordUserLocked(user userMessage) { // nothing outlives them, because a replay is a reading of the conversation // and that paragraph was never part of one. kept := user.journaled() - a.rememberPersonLocked(user, kept) + defer a.rememberPersonLocked(user, kept)() // The store's copy is taken before the journal's early return: a session // with no file still has a conversation worth keeping, and the person's own // words are the last thing that should depend on which layout they opened in. @@ -2762,10 +2765,14 @@ func (a *Agent) recordUserLocked(user userMessage) { // // A skills block the session already marked on the model's copy keeps its // provenance: the two marks are one mark on one message. -func (a *Agent) rememberPersonLocked(user userMessage, kept ai.Message) { +// +// It answers the release of the record's copy, which the caller defers past the +// journal write: that copy is read by the write and by nothing after it, and a +// mark left on it would keep its words alive for the life of the session. +func (a *Agent) rememberPersonLocked(user userMessage, kept ai.Message) func() { mark := user.person if mark == nil || len(user.message.Content) == 0 { - return + return func() {} } if a.presentation == nil { a.presentation = &presentationIndex{} @@ -2775,9 +2782,28 @@ func (a *Agent) rememberPersonLocked(user userMessage, kept ai.Message) { live.SkillsBlock = prior.SkillsBlock } a.presentation.remember(user.message, &live) - if len(kept.Content) > 0 && &kept.Content[0] != &user.message.Content[0] { - a.presentation.remember(kept, mark.person()) + if a.file == nil || len(kept.Content) == 0 || &kept.Content[0] == &user.message.Content[0] { + return func() {} + } + a.presentation.remember(kept, mark.person()) + return func() { a.presentation.forget(kept) } +} + +// retainPresentationLocked cuts the presentation marks back to the messages +// this session still holds — the transcript and every queue of messages not yet +// in it — after a boundary that let messages go ([presentationIndex.retain]). +func (a *Agent) retainPresentationLocked() { + queued := make([]ai.Message, 0, len(a.steering)+len(a.ambient)+len(a.followups)) + for _, user := range a.steering { + queued = append(queued, user.message) + } + for _, user := range a.ambient { + queued = append(queued, user.message) + } + for _, follow := range a.followups { + queued = append(queued, follow.message.message) } + a.presentation.retain(a.messages, queued) } func (a *Agent) record(message ai.Message) { diff --git a/internal/session/loop.go b/internal/session/loop.go index 4398047285..e90bfa4a99 100644 --- a/internal/session/loop.go +++ b/internal/session/loop.go @@ -5194,6 +5194,9 @@ func (a *Agent) compactWithPolicyResult(ctx context.Context, hub *eventHub, poli windowReasoning := append([]provider.MessageReasoning(nil), a.messageReasoning[1:]...) a.file.appendCompaction(pass, tokensBefore, window, windowReasoning) } + // AND WHAT THE PASS LET GO OF IS LET GO OF: the marks of messages it folded + // away are the last thing holding their words ([presentationIndex.retain]). + a.retainPresentationLocked() a.mu.Unlock() if hub != nil { diff --git a/internal/session/presentation.go b/internal/session/presentation.go index f0d0df08ec..48cc83fb6e 100644 --- a/internal/session/presentation.go +++ b/internal/session/presentation.go @@ -66,6 +66,64 @@ func (p *presentationIndex) remember(message ai.Message, mark *messagePresentati p.entries[&message.Content[0]] = *mark } +// forget drops one message's mark. It is for a copy whose only reader has +// already read it — the record's own copy of a person's line, once the line is +// written ([Agent.recordUserLocked]). +func (p *presentationIndex) forget(message ai.Message) { + if p == nil || len(message.Content) == 0 { + return + } + p.mu.Lock() + defer p.mu.Unlock() + delete(p.entries, &message.Content[0]) +} + +// retain keeps the marks of the messages given and drops every other one. +// +// A MARK IS KEYED BY ITS MESSAGE'S FIRST PART, AND THE KEY KEEPS THE WHOLE PART +// ARRAY ALIVE. A message a compaction folded away or a rewind cut is gone from +// the conversation, but its mark would hold its content — a person's message +// can carry hundreds of kilobytes of paste the model was sent — for as long as +// the session lives. So at each boundary that lets messages go, the index is +// cut back to the messages something still holds. The record is untouched: a +// line's mark is on the line, and a reopen reads it from there. +// +// It is one walk of what is live and one of the index, at a boundary that has +// already walked the transcript; nothing per token or per message pays for it. +func (p *presentationIndex) retain(live ...[]ai.Message) { + if p == nil { + return + } + p.mu.Lock() + defer p.mu.Unlock() + if len(p.entries) == 0 { + return + } + kept := make(map[*ai.ContentPart]messagePresentation) + for _, messages := range live { + for index := range messages { + if len(messages[index].Content) == 0 { + continue + } + key := &messages[index].Content[0] + if mark, ok := p.entries[key]; ok { + kept[key] = mark + } + } + } + p.entries = kept +} + +// size is how many marks the index holds. +func (p *presentationIndex) size() int { + if p == nil { + return 0 + } + p.mu.Lock() + defer p.mu.Unlock() + return len(p.entries) +} + func (p *presentationIndex) of(message ai.Message) *messagePresentation { if p == nil || len(message.Content) == 0 { return nil diff --git a/internal/session/rewind.go b/internal/session/rewind.go index 3d8778c1d4..5359266d9e 100644 --- a/internal/session/rewind.go +++ b/internal/session/rewind.go @@ -189,6 +189,9 @@ func (a *Agent) cutLocked(index int) []DisplayEntry { } a.messages = a.messages[:index] a.messageReasoning = a.messageReasoning[:index] + // And so are their marks, which would hold the same bytes + // ([presentationIndex.retain]). + a.retainPresentationLocked() return removed } diff --git a/internal/session/sessionfile.go b/internal/session/sessionfile.go index 6022f5323d..27a647c28d 100644 --- a/internal/session/sessionfile.go +++ b/internal/session/sessionfile.go @@ -3048,6 +3048,8 @@ func (s *sessionFile) appendCompaction(pass compactionPass, tokensBefore int, wi // back without its attachments. if kept := presentation.person(); kept != nil { s.presentation.remember(message, kept) + // That copy is read by the write below and by nothing after it. + defer s.presentation.forget(message) } } // AND SO IS A SPLICED ONE, for the same reason: a steer re-written without diff --git a/internal/session/submission.go b/internal/session/submission.go index 17b41faf92..fb5000c1c4 100644 --- a/internal/session/submission.go +++ b/internal/session/submission.go @@ -32,7 +32,9 @@ import ( // MessageAttachment is one file, link or paste that rode on a person's message, // as the door that sent the message described it. Every field is optional but -// Kind, and an empty field is "the door did not say", never a zero fact. +// Kind. An empty string is "the door did not say". A size is a pointer so that +// an unmeasured size and a measured zero are two answers: nil was never +// measured, and a value — zero included — is what the door measured. type MessageAttachment struct { // ID is the door's own name for the attachment, unique within the message. ID string `json:"id,omitempty"` @@ -45,10 +47,11 @@ type MessageAttachment struct { Path string `json:"path,omitempty"` // URL is a link's address. URL string `json:"url,omitempty"` - // Bytes is the attachment's size, where the door measured it. - Bytes int64 `json:"bytes,omitempty"` - // Lines is how many lines it held, where the door counted them. - Lines int `json:"lines,omitempty"` + // Bytes is the attachment's size in bytes, nil where the door did not + // measure it. + Bytes *int64 `json:"bytes,omitempty"` + // Lines is how many lines it held, nil where the door did not count them. + Lines *int `json:"lines,omitempty"` } // Submission is a person's message with what the door knows about it beyond @@ -110,6 +113,10 @@ func (m Submission) mark() (*messagePresentation, error) { if m.Said == nil && len(m.Attachments) == 0 { return nil, nil } + // EVERY BOUND IS CHECKED ON THE BYTES AS GIVEN, before anything is trimmed: + // a trimmed field is a slice of the caller's string, so a bound checked + // after trimming could be passed by padding, and a short kept field could + // hold a huge string alive behind it. What is kept is a fresh copy. if len(m.Attachments) > MaxMessageAttachments { return nil, fmt.Errorf("%w: %d attachments, and one message names at most %d", errSubmissionMark, len(m.Attachments), MaxMessageAttachments) } @@ -118,33 +125,44 @@ func (m Submission) mark() (*messagePresentation, error) { // line's own content is theirs, and a copy of it here would be the same // words written twice on one line. if m.Said != nil { - said := strings.TrimSpace(*m.Said) - if len(said) > maxSaidBytes || !utf8.ValidString(said) { + if len(*m.Said) > maxSaidBytes || !utf8.ValidString(*m.Said) { return nil, fmt.Errorf("%w: the person's words are not text of at most %d bytes", errSubmissionMark, maxSaidBytes) } + // Trimmed as [Agent.Submit] trims the words it is given. + said := strings.Clone(strings.TrimSpace(*m.Said)) mark.Text = &said } if len(m.Attachments) > 0 { mark.Attachments = make([]MessageAttachment, 0, len(m.Attachments)) } - for index, attachment := range m.Attachments { - attachment.ID = strings.TrimSpace(attachment.ID) - attachment.Kind = strings.TrimSpace(attachment.Kind) - attachment.Name = strings.TrimSpace(attachment.Name) - attachment.Path = strings.TrimSpace(attachment.Path) - attachment.URL = strings.TrimSpace(attachment.URL) - if attachment.Kind == "" { - return nil, fmt.Errorf("%w: attachment %d has no kind", errSubmissionMark, index+1) - } - for _, field := range []string{attachment.ID, attachment.Kind, attachment.Name, attachment.Path, attachment.URL} { + for index, given := range m.Attachments { + for _, field := range []string{given.ID, given.Kind, given.Name, given.Path, given.URL} { if len(field) > maxAttachmentField || !utf8.ValidString(field) { return nil, fmt.Errorf("%w: attachment %d has a field that is not text of at most %d bytes", errSubmissionMark, index+1, maxAttachmentField) } } - if attachment.Bytes < 0 || attachment.Lines < 0 { + if (given.Bytes != nil && *given.Bytes < 0) || (given.Lines != nil && *given.Lines < 0) { return nil, fmt.Errorf("%w: attachment %d has a negative size", errSubmissionMark, index+1) } - mark.Attachments = append(mark.Attachments, attachment) + kept := MessageAttachment{ + ID: keptField(given.ID), + Kind: keptField(given.Kind), + Name: keptField(given.Name), + Path: keptField(given.Path), + URL: keptField(given.URL), + } + if kept.Kind == "" { + return nil, fmt.Errorf("%w: attachment %d has no kind", errSubmissionMark, index+1) + } + if given.Bytes != nil { + bytes := *given.Bytes + kept.Bytes = &bytes + } + if given.Lines != nil { + lines := *given.Lines + kept.Lines = &lines + } + mark.Attachments = append(mark.Attachments, kept) } return mark, nil } @@ -154,11 +172,29 @@ func (m Submission) mark() (*messagePresentation, error) { // a person's words and attachments from. const audiencePerson = "person" -// cloneAttachments is a reading's own copy of a mark's descriptors, so a -// [DisplayEntry] handed to a caller shares nothing with the index behind it. +// keptField is one descriptor field as the record keeps it: trimmed, and a copy +// of its own rather than a window onto the caller's string. +func keptField(field string) string { + return strings.Clone(strings.TrimSpace(field)) +} + +// cloneAttachments is a reading's own copy of a mark's descriptors, sizes +// included, so a [DisplayEntry] handed to a caller shares nothing with the +// index behind it. func cloneAttachments(attachments []MessageAttachment) []MessageAttachment { if len(attachments) == 0 { return nil } - return append([]MessageAttachment(nil), attachments...) + out := append([]MessageAttachment(nil), attachments...) + for index := range out { + if size := out[index].Bytes; size != nil { + bytes := *size + out[index].Bytes = &bytes + } + if count := out[index].Lines; count != nil { + lines := *count + out[index].Lines = &lines + } + } + return out } diff --git a/internal/session/submission_test.go b/internal/session/submission_test.go index bc19b1a484..ddc51f2a5f 100644 --- a/internal/session/submission_test.go +++ b/internal/session/submission_test.go @@ -10,6 +10,7 @@ import ( "reflect" "strings" "testing" + "unsafe" "github.com/Agent-Field/agentfield/sdk/go/ai" ) @@ -59,7 +60,7 @@ func journalMarks(t *testing.T, path string) []map[string]any { var ( submittedFiles = []MessageAttachment{ {ID: "at1", Kind: "file", Name: "notes.md", Path: "notes.md"}, - {ID: "at2", Kind: "paste", Name: "Pasted text", Bytes: 31, Lines: 2}, + {ID: "at2", Kind: "paste", Name: "Pasted text", Bytes: ptr(int64(31)), Lines: ptr(2)}, } submittedAlone = []MessageAttachment{{ID: "at3", Kind: "link", Name: "example.com", URL: "https://example.com/7"}} ) @@ -172,7 +173,7 @@ func TestASubmissionThatDoesNotFitRecordsNothing(t *testing.T) { for name, message := range map[string]Submission{ "too many": {Text: "x", Attachments: many}, "no kind": {Text: "x", Said: &words, Attachments: []MessageAttachment{{Name: "a"}}}, - "negative size": {Text: "x", Attachments: []MessageAttachment{{Kind: "paste", Bytes: -1}}}, + "negative size": {Text: "x", Attachments: []MessageAttachment{{Kind: "paste", Bytes: ptr(int64(-1))}}}, "long field": {Text: "x", Attachments: []MessageAttachment{{Kind: "file", Path: strings.Repeat("p", maxAttachmentField+1)}}}, } { if _, err := agent.SubmitMessage(context.Background(), message); !errors.Is(err, errSubmissionMark) { @@ -276,3 +277,166 @@ func TestAMarkSurvivesSteeringAndCompaction(t *testing.T) { } assert("after compaction", shapeEntries(replayed.messages, &sessionFile{presentation: replayed.presentation, steers: replayed.steers})) } + +// indexHeld is what a presentation index holds alive: its marks, and the bytes +// of the first part each mark's key keeps reachable. +func indexHeld(p *presentationIndex) (marks, bytes int) { + p.mu.Lock() + defer p.mu.Unlock() + for key := range p.entries { + bytes += len(key.Text) + } + return len(p.entries), bytes +} + +// liveMarked is how many of the transcript's messages carry a mark, and their +// first parts' bytes. +func liveMarked(agent *Agent) (marks, bytes int) { + for _, message := range agent.messages { + if agent.presentation.of(message) != nil { + marks++ + bytes += len(message.Content[0].Text) + } + } + return marks, bytes +} + +// A SESSION HOLDS THE MARKS OF WHAT IT HOLDS, AND NO MORE. Round after round of +// marked messages carrying large pastes is compacted away, and one more is cut +// by a rewind; after every boundary the index holds exactly the marks of +// messages still in the transcript — never the folded or cut ones, whose +// pastes it would otherwise keep alive for the life of the session — and the +// record, read again, still holds every message's own words and attachments. +func TestMarksAreLetGoWithTheMessagesTheyDescribe(t *testing.T) { + path := filepath.Join(t.TempDir(), "session.jsonl") + agent, _ := newTestAgent(t, &summarizer{}, func(c *Config) { c.SessionFile = path; c.ContextWindow = 16_384 }) + paste := strings.Repeat("pasted log line ", 24_000/16) + send := func(said string) { + t.Helper() + user := userText(said + "\n\nPasted text:\n```\n" + paste + "\n```") + var err error + user.person, err = Submission{Said: &said, Attachments: []MessageAttachment{{ID: said, Kind: "paste", Bytes: ptr(int64(len(paste)))}}}.mark() + if err != nil { + t.Fatal(err) + } + agent.mu.Lock() + agent.recordUserLocked(user) + agent.recordLocked(textMessage("assistant", "answer to "+said)) + agent.mu.Unlock() + } + bounded := func(when string) { + t.Helper() + agent.mu.Lock() + defer agent.mu.Unlock() + marks, bytes := indexHeld(agent.presentation) + live, liveBytes := liveMarked(agent) + if marks != live || bytes != liveBytes { + t.Fatalf("%s: the index holds %d marks over %d bytes; the transcript holds %d over %d", when, marks, bytes, live, liveBytes) + } + } + var sent []string + const rounds = 5 + for round := 1; round <= rounds; round++ { + for i := 1; i <= 5; i++ { + said := "question " + string(rune('0'+round)) + "." + string(rune('0'+i)) + send(said) + sent = append(sent, said) + } + before, _ := indexHeld(agent.presentation) + if err := agent.Compact(context.Background()); err != nil { + t.Fatalf("round %d: compact = %v", round, err) + } + if after, _ := indexHeld(agent.presentation); after >= before { + t.Fatalf("round %d: the pass let go of no marks (%d before, %d after)", round, before, after) + } + bounded("after compaction") + } + send("cut") + if _, err := agent.Rewind(); err != nil { + t.Fatal(err) + } + bounded("after a rewind") + if marks, _ := indexHeld(agent.presentation); marks > 5 { + t.Fatalf("after %d rounds the index still holds %d marks", rounds, marks) + } + if err := agent.Close(); err != nil { + t.Fatal(err) + } + + record := ReadTranscript(path) + told := append(append([]DisplayEntry(nil), record.Earlier...), record.Entries[record.Floor:]...) + seen := map[string]int{} + for _, entry := range personEntries(told) { + if !entry.Authored || len(entry.Attachments) != 1 || entry.Attachments[0].ID != entry.Text || entry.Attachments[0].Bytes == nil || *entry.Attachments[0].Bytes != int64(len(paste)) { + t.Fatalf("a message read back as %#v", entry) + } + seen[entry.Text]++ + } + for _, said := range sent { + if seen[said] != 1 { + t.Fatalf("%q was read back %d times; the record holds %v", said, seen[said], seen) + } + } + reopened, _ := newTestAgent(t, answering(), func(c *Config) { c.SessionFile = path }) + reopened.mu.Lock() + marks, _ := indexHeld(reopened.presentation) + live, _ := liveMarked(reopened) + reopened.mu.Unlock() + if marks != live { + t.Fatalf("a reopened session holds %d marks for %d messages it carries", marks, live) + } +} + +// A BOUND IS A BOUND ON WHAT WAS GIVEN. Padding cannot carry a field or the +// person's words past their bound by being trimmed off, and what is kept is a +// copy that holds nothing of the caller's string alive. +func TestAMarksBoundsAreOnTheBytesGiven(t *testing.T) { + padded := strings.Repeat(" ", maxAttachmentField) + "a.go" + if _, err := (Submission{Attachments: []MessageAttachment{{Kind: "file", Path: padded}}}).mark(); !errors.Is(err, errSubmissionMark) { + t.Fatalf("a padded path = %v", err) + } + said := strings.Repeat("\n", maxSaidBytes) + "words" + if _, err := (Submission{Said: &said}).mark(); !errors.Is(err, errSubmissionMark) { + t.Fatalf("padded words = %v", err) + } + path := " " + strings.Repeat("d/", 100) + "a.go " + words := " hello " + mark, err := Submission{Said: &words, Attachments: []MessageAttachment{{Kind: " file ", Path: path}}}.mark() + if err != nil { + t.Fatal(err) + } + kept := mark.Attachments[0].Path + given := unsafe.StringData(path) + at := uintptr(unsafe.Pointer(unsafe.StringData(kept))) + if kept != strings.TrimSpace(path) || mark.Attachments[0].Kind != "file" || *mark.Text != "hello" || + (at >= uintptr(unsafe.Pointer(given)) && at < uintptr(unsafe.Pointer(given))+uintptr(len(path))) { + t.Fatalf("the kept fields = %#v, %q", mark.Attachments[0], *mark.Text) + } +} + +// A MEASURED ZERO IS NOT AN UNMEASURED SIZE. An empty file the door measured +// reads back as zero bytes, and one it did not measure reads back with no size. +func TestAMeasuredZeroSizeIsKeptApartFromAnUnknownOne(t *testing.T) { + path := filepath.Join(t.TempDir(), "session.jsonl") + agent, _ := newTestAgent(t, answering(), func(c *Config) { c.SessionFile = path }) + said := "two files" + events, err := agent.SubmitMessage(context.Background(), Submission{Text: "two files\n\nAttached file: empty\n\nAttached file: big", Said: &said, Attachments: []MessageAttachment{ + {Kind: "file", Path: "empty", Bytes: ptr(int64(0)), Lines: ptr(0)}, + {Kind: "file", Path: "big"}, + }}) + if err != nil { + t.Fatal(err) + } + collect(t, events) + if err := agent.Close(); err != nil { + t.Fatal(err) + } + users := personEntries(ReadTranscript(path).Entries) + if len(users) != 1 || len(users[0].Attachments) != 2 { + t.Fatalf("read back %#v", users) + } + empty, big := users[0].Attachments[0], users[0].Attachments[1] + if empty.Bytes == nil || *empty.Bytes != 0 || empty.Lines == nil || *empty.Lines != 0 || big.Bytes != nil || big.Lines != nil { + t.Fatalf("the sizes read back as %#v and %#v", empty, big) + } +} diff --git a/internal/session/turnfold.go b/internal/session/turnfold.go index 2c5fea6d37..b36b53a861 100644 --- a/internal/session/turnfold.go +++ b/internal/session/turnfold.go @@ -232,6 +232,8 @@ func (a *Agent) foldTurnOutputs(seenThrough int, consumedReads map[*ai.ToolCall] copy(window, a.messages[1:]) a.file.appendCompaction(pass, before, window) } + // The stubbed results' marks go with the results ([presentationIndex.retain]). + a.retainPresentationLocked() a.mu.Unlock() if hub != nil { From eff65bb46f9d47113d96a5f194ccb6da8a616de8 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 21:05:40 -0400 Subject: [PATCH 078/459] session: a person's mark is one pointer on an entry, cloned and costed by the record cache The record cache's clone shared the new descriptor slice between readers, and its cost did not count it (TestCloneAndCostCoverEveryField OfAnEntry). Two fields on DisplayEntry also grew every entry from 288 to 320 bytes, which pushed a journal of short lines past the cache's reserve so the record was no longer kept (TestTwoReadingsOfOneFileShareOneParse). DisplayEntry now carries DisplayEntry.Person *PersonMark (Authored, Attachments), nil on every entry no door marked; ForModel moves beside the other bools so the pointer is paid for by reclaimed padding and an entry is 288 bytes again. cloneEntries deep-copies the mark and its measured sizes; recordVectors and entriesCost count them. A size pin guards the layout on 64-bit. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/app.go | 2 +- cmd/codeaf/appattach_history_test.go | 6 +- cmd/codeaf/appconvo.go | 8 +-- .../0-app-user-attachments-history.md | 2 +- internal/session/agent.go | 64 +++++++++++-------- internal/session/recordcache.go | 30 +++++++++ internal/session/submission_test.go | 45 ++++++++++--- 7 files changed, 111 insertions(+), 46 deletions(-) diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index 15741573e6..4e5d29dd5e 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -2464,7 +2464,7 @@ func appEntryShapes(agent string, items []session.DisplayEntry, at time.Time, ma // The words are the person's own and the attachments the ones that // rode on them, as the record keeps them on the message's line; a // line without that mark is its words, and has no attachments. - entry.ID, entry.Kind, entry.Text, entry.Attachments = appUserEntryID(n), appwire.EntryUser, item.Text, appAttachmentsOf(item.Attachments) + entry.ID, entry.Kind, entry.Text, entry.Attachments = appUserEntryID(n), appwire.EntryUser, item.Text, appAttachmentsOf(item.Person) case "assistant", "aside": if strings.TrimSpace(item.Text) == "" { continue diff --git a/cmd/codeaf/appattach_history_test.go b/cmd/codeaf/appattach_history_test.go index ba9a3f9b05..d01cbaa3fd 100644 --- a/cmd/codeaf/appattach_history_test.go +++ b/cmd/codeaf/appattach_history_test.go @@ -179,13 +179,13 @@ func TestAppAttachmentsSurviveAReopenedRecord(t *testing.T) { described = append(described, item) } } - if len(described) != 4 || !described[2].Authored || described[2].Text != "" || described[3].Authored || described[3].Attachments != nil { + if len(described) != 4 || described[2].Person == nil || !described[2].Person.Authored || described[2].Text != "" || described[3].Person != nil { t.Fatalf("the record reads %+v", described) } - if got := described[2].Attachments; len(got) != 1 || got[0].Bytes == nil || *got[0].Bytes != int64(len(long)) || got[0].Lines == nil || *got[0].Lines != 20 { + if got := described[2].Person.Attachments; len(got) != 1 || got[0].Bytes == nil || *got[0].Bytes != int64(len(long)) || got[0].Lines == nil || *got[0].Lines != 20 { t.Fatalf("the paste's description = %+v", got) } - if got := described[0].Attachments; got[1].Bytes == nil || *got[1].Bytes != int64(len("--- FAIL: TestLoad\nexpected 1\n")) || got[0].Bytes != nil || got[0].Lines != nil { + if got := described[0].Person.Attachments; got[1].Bytes == nil || *got[1].Bytes != int64(len("--- FAIL: TestLoad\nexpected 1\n")) || got[0].Bytes != nil || got[0].Lines != nil { t.Fatalf("the created message's descriptions = %+v", got) } diff --git a/cmd/codeaf/appconvo.go b/cmd/codeaf/appconvo.go index 5d7e7f25b0..f51e189a62 100644 --- a/cmd/codeaf/appconvo.go +++ b/cmd/codeaf/appconvo.go @@ -837,12 +837,12 @@ func appDescribed(attach appAttach) session.MessageAttachment { // appAttachmentsOf is a recorded message's attachments as an entry shows them, // nil for a message the record keeps none for. -func appAttachmentsOf(described []session.MessageAttachment) []appwire.Attachment { - if len(described) == 0 { +func appAttachmentsOf(person *session.PersonMark) []appwire.Attachment { + if person == nil || len(person.Attachments) == 0 { return nil } - views := make([]appwire.Attachment, 0, len(described)) - for _, d := range described { + views := make([]appwire.Attachment, 0, len(person.Attachments)) + for _, d := range person.Attachments { view := appwire.Attachment{ID: d.ID, Kind: d.Kind, Path: d.Path, URL: d.URL, Title: d.Name} if d.Lines != nil { lines := *d.Lines diff --git a/docs/changes/unreleased/0-app-user-attachments-history.md b/docs/changes/unreleased/0-app-user-attachments-history.md index 642984da52..473f7f4af2 100644 --- a/docs/changes/unreleased/0-app-user-attachments-history.md +++ b/docs/changes/unreleased/0-app-user-attachments-history.md @@ -4,7 +4,7 @@ title: a person's own words and attachments are kept on their message's line and pr: 0 surface: [engine, remote] invalidates: - - "The app's attachment chips and the person's own words lived only in the process that sent them (appTurnMark), so after a restart a message read back as the path-expanded words the model was sent, with no attachments. session.Agent.SubmitMessage now records the person's words and bounded attachment descriptors (id, kind, name, path, url, and bytes/lines as pointers where nil is unmeasured and zero is a measured zero) on the message's own journal line, and DisplayEntry carries them as Authored and Attachments." + - "The app's attachment chips and the person's own words lived only in the process that sent them (appTurnMark), so after a restart a message read back as the path-expanded words the model was sent, with no attachments. session.Agent.SubmitMessage now records the person's words and bounded attachment descriptors (id, kind, name, path, url, and bytes/lines as pointers where nil is unmeasured and zero is a measured zero) on the message's own journal line, and DisplayEntry carries them as one nil-able Person pointer (Authored, Attachments), so an entry stays 288 bytes." - "appTurnMark carried a message's text and attachments for listings. It now carries only the turn and client id; agents.entries reads words and attachments from the record, and a line without the mark has no attachments, whatever paths its words mention." - "remote SubmitArgs carried words alone. It now also carries said and attachments on Submit; an engine without SubmitMessage runs the words and keeps no mark." --- diff --git a/internal/session/agent.go b/internal/session/agent.go index 25d0f227dd..dafcd80cfe 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -4723,9 +4723,20 @@ type DisplayEntry struct { // completion. Interrupted updates remain readable without claiming success. Addressed bool Interrupted bool - Text string - Tool string // set when the entry is one call in a batch - Hint string // the call's gloss, as the tool cluster rendered it + // ForModel marks an "aside" the SESSION wrote to its model — a wake, a + // task's landing note, the carry-on after a quit, a checkpoint's question — + // as opposed to the model's own operational words. The person never typed + // it and it was never addressed to them: it is the model's instruction, and + // a surface that shows a conversation to a person leaves it out (a team's + // delivery, which carries [DisplayEntry.Team], is drawn as its card). + // + // It sits beside the other marks rather than at the foot of the entry + // because a record holds one entry per line and a bool alone between two + // words of alignment costs eight bytes on every one of them. + ForModel bool + Text string + Tool string // set when the entry is one call in a batch + Hint string // the call's gloss, as the tool cluster rendered it // CallID is the provider's own identity for a tool entry's call, exactly as // the record holds it, and "" for every entry that is not a call. @@ -4817,17 +4828,12 @@ type DisplayEntry struct { // memory never wrote one. ImageRefs []string - // Authored says Text is the PERSON'S OWN WORDS as the door that sent the - // message recorded them apart from what the model read - // ([Agent.SubmitMessage]) — empty Text included, which is a message of - // attachments alone. False on every other entry, whose Text is the line's - // own words exactly as they always were. - Authored bool - // Attachments are what that door said rode on the person's message, in the - // order it carried them, each a copy of the record's own descriptor. Nil on - // every message no such door sent, however many paths its words mention: - // nothing here is ever read out of the words. - Attachments []MessageAttachment + // Person is what the door that sent a person's message said about it + // beyond its words ([Agent.SubmitMessage]), and nil on every entry no such + // door sent — however many paths its words mention: nothing here is ever + // read out of the words. It is one pointer because nearly every entry has + // none, and a record holds an entry for every line. + Person *PersonMark // ReplyTags label the assistant entry that answers finished task notes. They // are nil on every ordinary reply. ReplyTags []TaskReplyTag @@ -4861,19 +4867,25 @@ type DisplayEntry struct { // who sent it rather than as the aside's first line. Nil on every other // entry; the aside's Text still holds the whole delivery as the model read it. Team []TeamLine - // ForModel marks an "aside" the SESSION wrote to its model — a wake, a - // task's landing note, the carry-on after a quit, a checkpoint's question — - // as opposed to the model's own operational words. The person never typed - // it and it was never addressed to them: it is the model's instruction, and - // a surface that shows a conversation to a person leaves it out (a team's - // delivery, which carries [DisplayEntry.Team], is drawn as its card). - ForModel bool // At is when the message this entry came from was written to the journal, // and the zero time when the record never said (entrytime.go). A tool entry // carries its call's message's instant. At time.Time } +// PersonMark is what a person's message carried beyond the words a reading +// shows, as the door that sent it recorded it on the message's own line. +type PersonMark struct { + // Authored says the entry's Text is the person's own words as that door + // recorded them apart from what the model read — empty Text included, + // which is a message of attachments alone. False when the door named no + // words apart, and the Text is the line's own words. + Authored bool + // Attachments are what rode on the message, in the order it carried them, + // each a copy of the record's own descriptor. + Attachments []MessageAttachment +} + // SteerMark is what the record keeps about one steer that LANDED: when the // person sent it, and — always true here — that the turn it was typed into // carried it to the model. @@ -5007,8 +5019,7 @@ func shapeEntries(messages []ai.Message, journal *sessionFile, indexes ...*prese } displayText := messageContentText(msg) // personWords removes only this message's recorded skills injection. - var authored bool - var attachments []MessageAttachment + var person *PersonMark if role == "user" { displayText = presentation.personWords(msg) // AND WHAT THE PERSON SENT IS SHOWN AS THEY SENT IT, where the door @@ -5016,10 +5027,10 @@ func shapeEntries(messages []ai.Message, journal *sessionFile, indexes ...*prese // of the paragraph the model was handed, and the attachments that // rode on it. A line with no such mark is shown as it always was. if mark := presentation.of(msg).person(); mark != nil { + person = &PersonMark{Attachments: cloneAttachments(mark.Attachments)} if mark.Text != nil { - displayText, authored = *mark.Text, true + displayText, person.Authored = *mark.Text, true } - attachments = cloneAttachments(mark.Attachments) } } interrupted, explicitlyHuman := false, false @@ -5053,8 +5064,7 @@ func shapeEntries(messages []ai.Message, journal *sessionFile, indexes ...*prese Interrupted: interrupted, Text: displayText, ImageRefs: journal.imageRefs(msg), - Authored: authored, - Attachments: attachments, + Person: person, ReplyTags: tags, // The journal is the only thing that remembers a user line was typed // INTO the turn above it rather than opening one of its own: the diff --git a/internal/session/recordcache.go b/internal/session/recordcache.go index 4fe3680b49..a9507ebbc8 100644 --- a/internal/session/recordcache.go +++ b/internal/session/recordcache.go @@ -459,6 +459,13 @@ func recordVectors(record Record) int64 { cost += int64(len(entry.ImageRefs))*int64(unsafe.Sizeof("")) + int64(len(entry.ReplyTags))*int64(unsafe.Sizeof(TaskReplyTag{})) + int64(len(entry.Team))*int64(unsafe.Sizeof(TeamLine{})) + if entry.Person != nil { + cost += int64(unsafe.Sizeof(PersonMark{})) + + int64(len(entry.Person.Attachments))*int64(unsafe.Sizeof(MessageAttachment{})) + for _, attachment := range entry.Person.Attachments { + cost += attachmentSizes(attachment) + } + } if entry.Steer != nil { cost += int64(unsafe.Sizeof(SteerMark{})) } @@ -508,6 +515,25 @@ func entriesCost(entries []DisplayEntry) int64 { for _, line := range entry.Team { cost += int64(len(line.Team) + len(line.From) + len(line.To) + len(line.Kind) + len(line.Text) + len(line.Thread)) } + if entry.Person != nil { + cost += int64(unsafe.Sizeof(PersonMark{})) + int64(cap(entry.Person.Attachments))*int64(unsafe.Sizeof(MessageAttachment{})) + for _, attachment := range entry.Person.Attachments { + cost += int64(len(attachment.ID)+len(attachment.Kind)+len(attachment.Name)+len(attachment.Path)+len(attachment.URL)) + + attachmentSizes(attachment) + } + } + } + return cost +} + +// attachmentSizes is what one descriptor's measured sizes hold beside it. +func attachmentSizes(attachment MessageAttachment) int64 { + var cost int64 + if attachment.Bytes != nil { + cost += int64(unsafe.Sizeof(*attachment.Bytes)) + } + if attachment.Lines != nil { + cost += int64(unsafe.Sizeof(*attachment.Lines)) } return cost } @@ -532,6 +558,10 @@ func cloneEntries(entries []DisplayEntry) []DisplayEntry { entry.ImageRefs = slices.Clone(entry.ImageRefs) entry.ReplyTags = slices.Clone(entry.ReplyTags) entry.Team = slices.Clone(entry.Team) + if entry.Person != nil { + person := PersonMark{Authored: entry.Person.Authored, Attachments: cloneAttachments(entry.Person.Attachments)} + entry.Person = &person + } if entry.Steer != nil { steer := *entry.Steer entry.Steer = &steer diff --git a/internal/session/submission_test.go b/internal/session/submission_test.go index ddc51f2a5f..142b1832e6 100644 --- a/internal/session/submission_test.go +++ b/internal/session/submission_test.go @@ -21,6 +21,17 @@ func answering() *scriptedCompleter { return &scriptedCompleter{steps: []step{answer, answer, answer, answer, answer, answer}} } +// authored says an entry's Text is the person's own words as their door said them. +func authored(entry DisplayEntry) bool { return entry.Person != nil && entry.Person.Authored } + +// attachmentsOf is what an entry says rode on the person's message. +func attachmentsOf(entry DisplayEntry) []MessageAttachment { + if entry.Person == nil { + return nil + } + return entry.Person.Attachments +} + // personEntries is a reading's entries of the person's own. func personEntries(entries []DisplayEntry) []DisplayEntry { var users []DisplayEntry @@ -73,13 +84,13 @@ func assertSubmitted(t *testing.T, where string, entries []DisplayEntry) { if len(users) != 3 { t.Fatalf("%s: %d messages of the person's: %#v", where, len(users), users) } - if users[0].Text != "why does this fail?" || !users[0].Authored || !reflect.DeepEqual(users[0].Attachments, submittedFiles) { + if users[0].Text != "why does this fail?" || !authored(users[0]) || !reflect.DeepEqual(attachmentsOf(users[0]), submittedFiles) { t.Fatalf("%s: the message with words = %#v", where, users[0]) } - if users[1].Text != "" || !users[1].Authored || !reflect.DeepEqual(users[1].Attachments, submittedAlone) { + if users[1].Text != "" || !authored(users[1]) || !reflect.DeepEqual(attachmentsOf(users[1]), submittedAlone) { t.Fatalf("%s: the attachments alone = %#v", where, users[1]) } - if users[2].Text != "see Attached file: other.go" || users[2].Authored || users[2].Attachments != nil { + if users[2].Text != "see Attached file: other.go" || authored(users[2]) || users[2].Person != nil { t.Fatalf("%s: the plain words = %#v", where, users[2]) } } @@ -125,7 +136,7 @@ func TestASubmittedMessageKeepsThePersonsWordsAndAttachmentsOnItsLine(t *testing live := agent.Transcript() assertSubmitted(t, "live", live) // A reading's attachments are its own: changing them changes no later one. - personEntries(live)[0].Attachments[0].Name = "scribbled" + personEntries(live)[0].Person.Attachments[0].Name = "scribbled" assertSubmitted(t, "read again", agent.Transcript()) if err := agent.Close(); err != nil { t.Fatal(err) @@ -192,7 +203,7 @@ func TestASubmissionThatDoesNotFitRecordsNothing(t *testing.T) { } collect(t, events) users := personEntries(agent.Transcript()) - if len(users) != 1 || users[0].Text != "plain" || users[0].Authored || users[0].Attachments != nil { + if len(users) != 1 || users[0].Text != "plain" || authored(users[0]) || users[0].Person != nil { t.Fatalf("a submission with no mark read back as %#v", users) } if err := agent.Close(); err != nil { @@ -209,7 +220,7 @@ func TestALegacyLineWithPathsHasNoAttachments(t *testing.T) { text := "Attached file: notes.md\n\nLink: https://example.com\n\nPasted text:\n```\nx\n```" data, _ := json.Marshal(sessionEntry{Type: "message", Role: "user", Content: text}) record := ReadTranscriptBytes(append(data, '\n')) - if len(record.Entries) != 1 || record.Entries[0].Text != text || record.Entries[0].Authored || record.Entries[0].Attachments != nil { + if len(record.Entries) != 1 || record.Entries[0].Text != text || record.Entries[0].Person != nil { t.Fatalf("the legacy line reads %#v", record.Entries) } } @@ -250,7 +261,7 @@ func TestAMarkSurvivesSteeringAndCompaction(t *testing.T) { t.Fatalf("%s: %#v", where, users) } for i, want := range []struct{ text, path string }{{"look", "a.go"}, {"check", "b.go"}, {"also", "c.go"}} { - if users[i].Text != want.text || !users[i].Authored || len(users[i].Attachments) != 1 || users[i].Attachments[0].Path != want.path { + if users[i].Text != want.text || !authored(users[i]) || len(attachmentsOf(users[i])) != 1 || attachmentsOf(users[i])[0].Path != want.path { t.Fatalf("%s: message %d = %#v", where, i, users[i]) } } @@ -367,7 +378,7 @@ func TestMarksAreLetGoWithTheMessagesTheyDescribe(t *testing.T) { told := append(append([]DisplayEntry(nil), record.Earlier...), record.Entries[record.Floor:]...) seen := map[string]int{} for _, entry := range personEntries(told) { - if !entry.Authored || len(entry.Attachments) != 1 || entry.Attachments[0].ID != entry.Text || entry.Attachments[0].Bytes == nil || *entry.Attachments[0].Bytes != int64(len(paste)) { + if !authored(entry) || len(attachmentsOf(entry)) != 1 || attachmentsOf(entry)[0].ID != entry.Text || attachmentsOf(entry)[0].Bytes == nil || *attachmentsOf(entry)[0].Bytes != int64(len(paste)) { t.Fatalf("a message read back as %#v", entry) } seen[entry.Text]++ @@ -432,11 +443,25 @@ func TestAMeasuredZeroSizeIsKeptApartFromAnUnknownOne(t *testing.T) { t.Fatal(err) } users := personEntries(ReadTranscript(path).Entries) - if len(users) != 1 || len(users[0].Attachments) != 2 { + if len(users) != 1 || len(attachmentsOf(users[0])) != 2 { t.Fatalf("read back %#v", users) } - empty, big := users[0].Attachments[0], users[0].Attachments[1] + empty, big := attachmentsOf(users[0])[0], attachmentsOf(users[0])[1] if empty.Bytes == nil || *empty.Bytes != 0 || empty.Lines == nil || *empty.Lines != 0 || big.Bytes != nil || big.Lines != nil { t.Fatalf("the sizes read back as %#v and %#v", empty, big) } } + +// AN ENTRY DOES NOT GROW FOR A MARK MOST ENTRIES NEVER CARRY. A record holds +// one entry per journal line and the record cache's reserve is a few times the +// file, so eight bytes more on every entry is a journal of short lines that +// stops being kept. The mark is one pointer, paid for by the padding the bools +// beside it used to waste. +func TestAPersonsMarkCostsAnEntryNothing(t *testing.T) { + if unsafe.Sizeof(uintptr(0)) != 8 { + t.Skip("the figure is a 64-bit layout") + } + if size := unsafe.Sizeof(DisplayEntry{}); size > 288 { + t.Fatalf("DisplayEntry is %d bytes; it was 288 before the person's mark", size) + } +} From 5057dbf3cd077f1added31f12639486250e8079e Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 21:04:51 -0400 Subject: [PATCH 079/459] crewhealth: a refused key or empty account recovers on a new credential or fresh credit MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit An allowed pin or the one model is never sent to a route health holds back, so a refusal only a later answered call could lift never lifted. Three facts now lift it, and no timer does: - a dispatch print (CrewDispatchFor/Of, LogCrewRouteSent, router fail_cred) that is not the credential in force; LogCrewRoute no longer guesses the credential from settings at response time; - for an unprinted refusal (old builds, callers without a dispatch fact), a credential change recorded after it in crew-credentials.json — written by the credential writers themselves and on health reads, first sighting a baseline, prints only, bounded per provider; - for payment, a positive balance read after the refusal (credits ReadAt, CrewAccountState.ReadAt). The same credential written again lifts nothing. Session dispatch hunks are handed to V80, not applied. Co-Authored-By: Claude Opus 5.5 --- .../unreleased/0-crew-refusal-recovery.md | 10 + internal/config/apikey.go | 4 +- internal/config/appcredentials.go | 27 +- internal/config/crewcred.go | 331 ++++++++++++++++ .../config/crewcred_repair_review_test.go | 28 ++ internal/config/crewcred_test.go | 353 ++++++++++++++++++ internal/config/crewhealth.go | 44 ++- internal/config/sources.go | 8 +- internal/manual/chat/models-and-cost.md | 28 +- internal/manual/chat_test.go | 2 + internal/router/crew.go | 19 +- 11 files changed, 836 insertions(+), 18 deletions(-) create mode 100644 docs/changes/unreleased/0-crew-refusal-recovery.md create mode 100644 internal/config/crewcred.go create mode 100644 internal/config/crewcred_repair_review_test.go create mode 100644 internal/config/crewcred_test.go diff --git a/docs/changes/unreleased/0-crew-refusal-recovery.md b/docs/changes/unreleased/0-crew-refusal-recovery.md new file mode 100644 index 0000000000..9a08364093 --- /dev/null +++ b/docs/changes/unreleased/0-crew-refusal-recovery.md @@ -0,0 +1,10 @@ +--- +kind: fixed +title: A pinned or one model held back by a refused key or empty account recovers on a new key or credit +pr: 0 +surface: [chat, engine] +invalidates: + - "A crew route refused for its key or for payment was held back until a later call on it answered. A pinned role or the one model is never sent to such a route, so reconnecting or adding credit changed nothing. Now a refusal is lifted by a different credential than the one refused (a print taken at dispatch, CrewDispatchFor / LogCrewRouteSent; router CrewRecord.fail_cred), by a credential change after an unprinted refusal (profile crew-credentials.json: print and since per provider, written by WriteAPIKey, WriteSources, ConnectCodex and the app's keys, and when route health reads; a first sighting is a baseline), or for payment by a positive balance read after the refusal. The same key written again lifts nothing; there is no timer." + - "config.LogCrewRoute names no credential and never guesses one from the settings at response time; a caller that took a CrewDispatch when it sent the call uses LogCrewRouteSent." + - "config.CrewAccountState had no read time. It now has ReadAt; a positive balance with no ReadAt, or read before a payment refusal, lifts nothing." +--- diff --git a/internal/config/apikey.go b/internal/config/apikey.go index c66441dcec..0c0c18a09d 100644 --- a/internal/config/apikey.go +++ b/internal/config/apikey.go @@ -157,7 +157,9 @@ func APIKeySourceForModel(profileDir string, sources modelsource.Set, model stri // (writeProfileValues), which is the property [EnsurePersistedAPIKey] tightens // after the fact on a file that predated any secret in it. func WriteAPIKey(profileDir, key string) error { - return writeProfileValue(profileDir, KeyAPIKey, strings.TrimSpace(key)) + return crewCredentialWrite(profileDir, func() error { + return writeProfileValue(profileDir, KeyAPIKey, strings.TrimSpace(key)) + }) } // LooksLikeAPIKey is the shape check the first-run setup applies to a pasted diff --git a/internal/config/appcredentials.go b/internal/config/appcredentials.go index 57aecd4849..635609dcfe 100644 --- a/internal/config/appcredentials.go +++ b/internal/config/appcredentials.go @@ -53,16 +53,31 @@ func (c *AppCredentials) Set(provider, key string) { provider, key = strings.ToLower(strings.TrimSpace(provider)), strings.TrimSpace(key) trace.Secret(key) redact.Register(key) - c.mu.Lock() - defer c.mu.Unlock() - c.keys[provider] = key + c.change(func() { c.keys[provider] = key }) } // Remove disconnects one provider for the rest of this launch. func (c *AppCredentials) Remove(provider string) { - c.mu.Lock() - defer c.mu.Unlock() - c.keys[strings.ToLower(strings.TrimSpace(provider))] = "" + c.change(func() { c.keys[strings.ToLower(strings.TrimSpace(provider))] = "" }) +} + +// change makes one change to the keys held. A key handed over is a credential +// change: every cached reading of the profile must see it, and, for the keys +// in force, route health records it as it is made ([crewCredentialWrite]), +// because it holds refusals against credentials. +func (c *AppCredentials) change(edit func()) { + apply := func() error { + c.mu.Lock() + edit() + c.mu.Unlock() + return nil + } + if appCredentials.Load() == c { + _ = crewCredentialWrite(ProfileDir(), apply) + } else { + _ = apply() + } + bumpSettingsGeneration() } // Key answers one provider's key and whether the app has said anything about diff --git a/internal/config/crewcred.go b/internal/config/crewcred.go new file mode 100644 index 0000000000..dce0689888 --- /dev/null +++ b/internal/config/crewcred.go @@ -0,0 +1,331 @@ +package config + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "strings" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/codexauth" + "github.com/Agent-Field/codeaf/internal/modelsource" + "github.com/Agent-Field/codeaf/internal/router" +) + +// A REFUSAL BELONGS TO THE CREDENTIAL THAT WAS REFUSED. +// +// Route health holds a provider back after a call on it was refused: its key +// (auth) or its account's credit (payment). Until now only a later call that +// answered could lift that, and a seat the person pinned — or the one model for +// every task — is never sent to a route health holds back, so the lift could +// never come: reconnecting, or topping up, changed nothing. Three FACTS lift it +// now, and nothing else does (no timer, no flag the app sets, no success that +// did not happen): +// +// - A DIFFERENT CREDENTIAL THAN THE ONE REFUSED. A call takes the print of +// the credential it is sent with when it is sent ([CrewDispatchFor]); a +// print is sixteen hex digits of a hash, never the credential. A refusal +// written with a print that is not the credential in force now — a new +// key, a fresh sign-in — says nothing about it. The same credential +// written again is the same print, and its refusal stands. +// - A CREDENTIAL CHANGED AFTER AN UNPRINTED REFUSAL. A refusal written +// before prints existed, or by a caller that took no dispatch fact, does +// not say which credential it refused. It is lifted only when the +// credential in force was seen to change after it was written +// ([crewCredentialsSeen]): the first print this install records for a +// provider is a baseline, never a change, so a credential nobody touched +// stays held back. +// - A POSITIVE CREDIT READING TAKEN AFTER A PAYMENT REFUSAL. An account read +// as having credit, at a time after it refused for want of it +// ([CreditsLowAt]'s record for the key in force, or [CrewAccounts] with its +// ReadAt). An older reading, or one of none, keeps it held back. +// +// All three are durable and bounded: prints ride the router's own log, which +// is already bounded by its window; the changes are one small record of a +// print and a time per provider; the credit reading is the record the balance +// read already keeps. + +// crewCredentialPrint is the print of one connection's credential in force, +// "" for a connection that has none. +func crewCredentialPrint(profileDir string, service modelsource.Connected) string { + id := strings.ToLower(strings.TrimSpace(service.Source.ID)) + secret := strings.TrimSpace(service.Key) + if id == "codex" { + // A sign-in's credential is the account and its refresh token: a new + // sign-in, or a refresh that succeeded, is a different credential. + home := service.Home + if home == "" { + home = profileDir + } + tokens, err := codexauth.Load(home) + if err != nil { + return "" + } + secret = tokens.AccountID + "\x00" + tokens.RefreshToken + } + if secret == "" { + return "" + } + sum := sha256.Sum256([]byte("crew-credential\x00" + id + "\x00" + secret)) + return hex.EncodeToString(sum[:8]) +} + +// CrewDispatch is the fact a crew call is sent under: the print of the +// credential it carries. A caller takes it as the call is SENT and hands it to +// [LogCrewRouteSent] with the call's outcome, so a credential changed while the +// call was in flight is never blamed for the old one's refusal. +type CrewDispatch struct { + Cred string +} + +// CrewDispatchFor is the dispatch fact of a call about to be sent to provider +// with the credential in force now. +func CrewDispatchFor(profileDir, provider string) CrewDispatch { + return CrewDispatch{Cred: crewCredentialPrintAt(profileDir, provider)} +} + +// CrewDispatchOf is the dispatch fact of a call about to be sent through one +// resolved connection — the one its client holds, which is the credential the +// request will carry even when the settings have moved since it was built. +func CrewDispatchOf(profileDir string, service modelsource.Connected) CrewDispatch { + return CrewDispatch{Cred: crewCredentialPrint(profileDir, service)} +} + +// crewCredentialPrintAt is the print of a provider's credential in force. +func crewCredentialPrintAt(profileDir, provider string) string { + service, ok := ResolveSources(profileDir, APIKeyAt(profileDir), DefaultBaseURL).ByID(provider) + if !ok { + return "" + } + return crewCredentialPrint(profileDir, service) +} + +// crewCredentialRefusal is a refusal kind that belongs to a credential. +func crewCredentialRefusal(kind string) bool { return kind == "auth" || kind == "payment" } + +// crewHeldAgainstNow is the history without the refusals that no longer hold: +// one written with a credential that is not the one in force, one unprinted +// and older than a change of credential, and a payment refusal older than a +// reading that found credit. Everything else — every success, every other kind +// of failure, every refusal of the credential in force — is kept as it was. +func crewHeldAgainstNow(profileDir string, history []router.CrewRouteOutcome, now time.Time) []router.CrewRouteOutcome { + var facts crewCredentialFacts + asked := false + out := history + copied := false + for i, o := range history { + drop := false + if crewCredentialRefusal(o.Kind) { + if !asked { + facts, asked = crewCredentialFactsFor(profileDir, history, now), true + } + provider := strings.ToLower(o.Provider) + seen := facts.seen[provider] + switch { + case o.Cred != "" && o.Cred != facts.prints[provider]: + drop = true + case o.Cred == "" && !seen.Since.IsZero() && o.At.Before(seen.Since): + drop = true + } + if at, ok := facts.credit[provider]; ok && o.Kind == "payment" && at.After(o.At) { + drop = true + } + } + if drop && !copied { + out, copied = append([]router.CrewRouteOutcome(nil), history[:i]...), true + } + if copied && !drop { + out = append(out, o) + } + } + return out +} + +// crewCredentialFacts are, for each provider a refusal in history names, the +// print of its credential in force, when that credential was first seen in +// force, and when its account was last read as having credit. +type crewCredentialFacts struct { + prints map[string]string + seen map[string]crewCredentialSeen + credit map[string]time.Time +} + +// crewCredentialFactsFor reads the facts. Only the providers a refusal names +// are asked, and a change of their credential is recorded on the way. +func crewCredentialFactsFor(profileDir string, history []router.CrewRouteOutcome, now time.Time) crewCredentialFacts { + facts := crewCredentialFacts{prints: map[string]string{}, credit: map[string]time.Time{}} + set := ResolveSources(profileDir, APIKeyAt(profileDir), DefaultBaseURL) + var providers []string + for _, o := range history { + provider := strings.ToLower(o.Provider) + if !crewCredentialRefusal(o.Kind) { + continue + } + if _, asked := facts.prints[provider]; asked { + continue + } + providers = append(providers, provider) + facts.prints[provider] = "" + if service, ok := set.ByID(provider); ok { + facts.prints[provider] = crewCredentialPrint(profileDir, service) + } + if provider == modelsource.DefaultID { + if r := creditsAt(profileDir); r.Known && !r.Low && !r.Expired && r.Key == CreditsKeyPrint(APIKeyAt(profileDir)) && !r.ReadAt.IsZero() { + facts.credit[provider] = r.ReadAt + } + } + if CrewAccounts != nil { + // A balance with no time it was read is no evidence about a + // refusal: it could be the balance from before it. + if state, ok := CrewAccounts(profileDir, provider); ok && state.CreditKnown && state.CreditUSD > 0 && !state.ReadAt.IsZero() { + if at, held := facts.credit[provider]; !held || state.ReadAt.After(at) { + facts.credit[provider] = state.ReadAt + } + } + } + } + facts.seen = crewCredentialsNoted(profileDir, facts.prints, now) + return facts +} + +// THE CREDENTIAL CHANGES THIS INSTALL HAS SEEN, one entry per provider: the +// print in force and when it was first seen in force. The first entry for a +// provider is a BASELINE (Since zero) — the credential was already there, and +// nothing says it changed. A later reading of another print is a change, dated +// when it was seen. It is written as a credential is written — a key pasted, a +// connection's row, a sign-in, a key the app hands over ([crewCredentialWrite]) +// — and read where route health already reads the refusals it would lift, never +// on a timer. It holds no credential. Bounded by [crewCredentialsMax] providers. + +// crewCredentialsFile is where the changes are kept, beside the profile. +const crewCredentialsFile = "crew-credentials.json" + +// crewCredentialsMax bounds the record: more providers than any install +// connects. The oldest-changed entries are dropped past it. +const crewCredentialsMax = 64 + +// crewCredentialSeen is one provider's entry. +type crewCredentialSeen struct { + Print string `json:"print"` + Since time.Time `json:"since,omitzero"` +} + +// crewCredentialsMu serializes this process's read-and-write of the record. +var crewCredentialsMu sync.Mutex + +// crewCredentialsSeen records the credentials in force now for providers and +// answers the record. +func crewCredentialsSeen(profileDir string, providers []string, now time.Time) map[string]crewCredentialSeen { + prints := map[string]string{} + set := ResolveSources(profileDir, APIKeyAt(profileDir), DefaultBaseURL) + for _, provider := range providers { + provider = strings.ToLower(strings.TrimSpace(provider)) + if provider == "" { + continue + } + prints[provider] = "" + if service, ok := set.ByID(provider); ok { + prints[provider] = crewCredentialPrint(profileDir, service) + } + } + return crewCredentialsNoted(profileDir, prints, now) +} + +// crewCredentialWrite runs write — a change of the profile's credentials — and +// records what it changed, so a reconnect is a fact the moment it is made, not +// only once something reads route health: the prints in force before are +// recorded first (as the baseline, where none was), then those after, and any +// that moved is a change dated now. Writing the same credential again records +// nothing. +func crewCredentialWrite(profileDir string, write func() error) error { + crewCredentialsNoted(profileDir, crewCredentialPrintsNow(profileDir), time.Time{}) + err := write() + crewCredentialsNoted(profileDir, crewCredentialPrintsNow(profileDir), time.Now()) + return err +} + +// crewCredentialPrintsNow is the print of every connected provider's +// credential in force. +func crewCredentialPrintsNow(profileDir string) map[string]string { + prints := map[string]string{} + for _, service := range ResolveSources(profileDir, APIKeyAt(profileDir), DefaultBaseURL).All() { + if print := crewCredentialPrint(profileDir, service); print != "" { + prints[strings.ToLower(strings.TrimSpace(service.Source.ID))] = print + } + } + return prints +} + +// crewCredentialsNoted folds prints read now into the record, writes it when +// anything moved, and answers it. A provider with no credential now is not +// written: disconnecting is not a new credential, and connecting the refused +// one again must not look like one. +func crewCredentialsNoted(profileDir string, prints map[string]string, now time.Time) map[string]crewCredentialSeen { + crewCredentialsMu.Lock() + defer crewCredentialsMu.Unlock() + path := ProfilePath(profileDir, crewCredentialsFile) + record := map[string]crewCredentialSeen{} + if data, err := os.ReadFile(path); err == nil { + _ = json.Unmarshal(data, &record) + } + moved := false + for provider, print := range prints { + if print == "" { + continue + } + was, held := record[provider] + switch { + case !held: + record[provider] = crewCredentialSeen{Print: print} + moved = true + case was.Print != print: + record[provider] = crewCredentialSeen{Print: print, Since: now.UTC()} + moved = true + } + } + if moved { + for len(record) > crewCredentialsMax { + oldest := "" + for provider, seen := range record { + if oldest == "" || seen.Since.Before(record[oldest].Since) { + oldest = provider + } + } + delete(record, oldest) + } + writeCrewCredentials(path, record) + } + return record +} + +// writeCrewCredentials replaces the record atomically. A write that fails +// leaves the old record, which only ever holds more back, never less. +func writeCrewCredentials(path string, record map[string]crewCredentialSeen) { + data, err := json.Marshal(record) + if err != nil { + return + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + return + } + tmp, err := os.CreateTemp(filepath.Dir(path), ".crew-credentials-*.json") + if err != nil { + return + } + defer os.Remove(tmp.Name()) + if err := tmp.Chmod(0o600); err != nil { + tmp.Close() + return + } + if _, err := tmp.Write(append(data, '\n')); err != nil { + tmp.Close() + return + } + if tmp.Close() != nil { + return + } + _ = os.Rename(tmp.Name(), path) +} diff --git a/internal/config/crewcred_repair_review_test.go b/internal/config/crewcred_repair_review_test.go new file mode 100644 index 0000000000..17f08a2900 --- /dev/null +++ b/internal/config/crewcred_repair_review_test.go @@ -0,0 +1,28 @@ +package config + +import "testing" + +// Repair may be the first operation after upgrading: opening Settings must not +// be required to seed an old-key baseline before a real key change can work. +func TestLegacyRefusalCanBeRepairedBeforeTheFirstHealthRead(t *testing.T) { + for _, appKey := range []bool{false, true} { + t.Run(map[bool]string{false: "profile-key", true: "launch-key"}[appKey], func(t *testing.T) { + dir := crewProfile(t) + t.Setenv(ProfileDirEnv, dir) + pinWorkerOnOpenRouter(t, dir) + refuseAsAnOldBuild(t, dir, "auth") + // Deliberately no health/model/admission read after the old failure. + if appKey { + keys := NewAppCredentials() + t.Cleanup(UseAppCredentials(keys)) + keys.Set("openrouter", crewNewKey) + } else if err := WriteAPIKey(dir, crewNewKey); err != nil { + t.Fatal(err) + } + reopen() + if !pinnedWorkerAdmits(t, dir) { + t.Fatal("a real credential change before the first health read left the old legacy refusal stuck") + } + }) + } +} diff --git a/internal/config/crewcred_test.go b/internal/config/crewcred_test.go new file mode 100644 index 0000000000..8e6073edc2 --- /dev/null +++ b/internal/config/crewcred_test.go @@ -0,0 +1,353 @@ +package config + +import ( + "errors" + "os" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/credits" + "github.com/Agent-Field/codeaf/internal/crewroute" + "github.com/Agent-Field/codeaf/internal/router" +) + +// The fixtures here are UNIT DATA: keys are this test's own strings, refusals +// are written into the temporary profile's own router log as a refused seat +// call writes them, and nothing reaches a network or a model. + +const ( + crewOldKey = "sk-or-v1-crewtest-0123456789" + crewNewKey = "sk-or-v1-crewtest-reconnected-9876543210" +) + +// glmOnOpenRouter is the pinned worker's pick, as a decision carries it. +func glmOnOpenRouter() (crewroute.Decision, crewroute.Pick) { + pick := crewroute.Pick{Seat: crewroute.Worker, Model: "z-ai/glm-5.3-flash", Provider: "openrouter", Send: "z-ai/glm-5.3-flash", Kind: crewroute.Metered} + return crewroute.Decision{Class: crewroute.OpenEnded, Crew: []crewroute.Pick{pick}}, pick +} + +// refuseOnOpenRouter writes one refused first call on openrouter's GLM route, +// as a task's seat does: the dispatch fact taken as the call is sent, the +// refusal written with it. +func refuseOnOpenRouter(t *testing.T, dir, kind string) { + t.Helper() + refuseSent(t, dir, kind, CrewDispatchFor(dir, "openrouter")) +} + +// refuseSent writes a refusal of a call sent under the dispatch fact sent. +func refuseSent(t *testing.T, dir, kind string, sent CrewDispatch) { + t.Helper() + d, pick := glmOnOpenRouter() + LogCrewRouteSent(dir, "call-"+kind+"-"+time.Now().Format("150405.000000000"), d, dir, "fixture", crewroute.Worker, pick, kind, time.Time{}, sent) +} + +// refuseAsAnOldBuild writes the refusal exactly as a build before prints wrote +// it: straight into the router's log, with no credential named. +func refuseAsAnOldBuild(t *testing.T, dir, kind string) { + t.Helper() + d, pick := glmOnOpenRouter() + router.LogCrewRoute(ProfilePath(dir, ""), "old-"+kind+"-"+time.Now().Format("150405.000000000"), CrewRecordOf(d, dir, "fixture"), router.CrewRouteOutcome{ + Seat: string(crewroute.Worker), Send: pick.Send, Provider: pick.Provider, Paid: true, Kind: kind, + }) +} + +// reopen forgets everything this process keeps of the profile between reads, +// as closing codeaf and opening it again does. What survives is on disk. +func reopen() { forgetCrewHealth() } + +// pinnedWorkerAdmits answers whether a task admits with the worker pinned to +// GLM on openrouter, and fails the test if it admitted on anything else. +func pinnedWorkerAdmits(t *testing.T, dir string) bool { + t.Helper() + d, err := RouteCrew(dir, CrewAsk{Task: crewroute.Task{Text: openTask}}) + var refusal *crewroute.NoEligibleError + if errors.As(err, &refusal) { + if refusal.Seat != crewroute.Worker { + t.Fatalf("refused the %s, want the pinned worker", refusal.Seat) + } + return false + } + if err != nil { + t.Fatal(err) + } + if w := d.Seat(crewroute.Worker); !w.Pinned || w.Model != "z-ai/glm-5.3-flash" || w.Provider != "openrouter" { + t.Fatalf("the pinned worker was substituted: %+v", w) + } + return true +} + +func pinWorkerOnOpenRouter(t *testing.T, dir string) { + t.Helper() + applyTaskModels(t, dir, TaskModelsChange{Pins: map[crewroute.Seat]*CrewPin{crewroute.Worker: {Model: "z-ai/glm-5.3-flash", Provider: "openrouter"}}}) +} + +// A REFUSED KEY HOLDS A PIN BACK UNTIL A DIFFERENT KEY IS CONNECTED — the +// same key written again changes nothing — and a refusal of the new key holds +// it back again. Read back from disk, the lift stands. +func TestARefusedKeyHoldsAPinUntilANewCredentialIsConnected(t *testing.T) { + dir := crewProfile(t) + pinWorkerOnOpenRouter(t, dir) + if !pinnedWorkerAdmits(t, dir) { + t.Fatal("a healthy pin was refused") + } + refuseOnOpenRouter(t, dir, "auth") + if pinnedWorkerAdmits(t, dir) { + t.Fatal("a pin whose key was just refused admitted") + } + if err := WriteAPIKey(dir, crewOldKey); err != nil { + t.Fatal(err) + } + if pinnedWorkerAdmits(t, dir) { + t.Fatal("the refused key, written again, lifted its own refusal") + } + if err := WriteAPIKey(dir, crewNewKey); err != nil { + t.Fatal(err) + } + if !pinnedWorkerAdmits(t, dir) { + t.Fatal("a new key did not lift the old key's refusal") + } + forgetCrewHealth() + if !pinnedWorkerAdmits(t, dir) || !CrewRouteAnswers(dir, "z-ai/glm-5.3-flash") { + t.Fatal("the lift did not survive reading the profile again") + } + // The log holds prints, never a key. + raw, err := os.ReadFile(ProfilePath(dir, "router-events.jsonl")) + if err == nil && (strings.Contains(string(raw), crewOldKey) || strings.Contains(string(raw), crewNewKey)) { + t.Fatal("the router log carries a key") + } + refuseOnOpenRouter(t, dir, "auth") + if pinnedWorkerAdmits(t, dir) { + t.Fatal("a refusal of the new key did not hold the pin back") + } +} + +// A KEY THE APP HANDS OVER IS A NEW CREDENTIAL TOO, seen at once even by the +// cached reading auxiliary calls use. +func TestAnAppHandedKeyLiftsTheOldKeysRefusal(t *testing.T) { + dir := crewProfile(t) + pinWorkerOnOpenRouter(t, dir) + refuseOnOpenRouter(t, dir, "auth") + if CrewRouteAnswers(dir, "z-ai/glm-5.3-flash") || pinnedWorkerAdmits(t, dir) { + t.Fatal("a refused key still answers") + } + keys := NewAppCredentials() + t.Cleanup(UseAppCredentials(keys)) + keys.Set("openrouter", crewNewKey) + if !CrewRouteAnswers(dir, "z-ai/glm-5.3-flash") || !pinnedWorkerAdmits(t, dir) { + t.Fatal("the app's new key did not lift the refusal") + } +} + +// A POSITIVE CREDIT READING LIFTS AN EARLIER PAYMENT REFUSAL; a fresh reading +// of no credit keeps it, and a payment refusal after the reading holds again. +func TestAPositiveCreditReadingLiftsAnEarlierPaymentRefusal(t *testing.T) { + dir := crewProfile(t) + pinWorkerOnOpenRouter(t, dir) + refuseOnOpenRouter(t, dir, "payment") + if pinnedWorkerAdmits(t, dir) { + t.Fatal("an account out of credit admitted its pin") + } + if err := WriteCreditsReading(dir, crewOldKey, credits.Reading{Known: true, Low: true}); err != nil { + t.Fatal(err) + } + if pinnedWorkerAdmits(t, dir) { + t.Fatal("a fresh reading of no credit lifted the refusal") + } + if err := WriteCreditsReading(dir, crewNewKey, credits.Reading{Known: true}); err != nil { + t.Fatal(err) + } + if pinnedWorkerAdmits(t, dir) { + t.Fatal("credit read on another key lifted this key's refusal") + } + time.Sleep(time.Millisecond) + if err := WriteCreditsReading(dir, crewOldKey, credits.Reading{Known: true}); err != nil { + t.Fatal(err) + } + if !pinnedWorkerAdmits(t, dir) { + t.Fatal("a positive reading did not lift the earlier payment refusal") + } + time.Sleep(time.Millisecond) + refuseOnOpenRouter(t, dir, "payment") + if pinnedWorkerAdmits(t, dir) { + t.Fatal("a payment refusal after the reading did not hold") + } +} + +// ONE MODEL FOR EVERY TASK RECOVERS THE SAME WAY. +func TestAOneModelRecoversWhenANewKeyIsConnected(t *testing.T) { + dir := crewProfile(t) + applyTaskModels(t, dir, TaskModelsChange{Mode: modePtr(crewroute.ModeOne), One: &CrewPin{Model: "z-ai/glm-5.3-flash", Provider: "openrouter"}}) + refuseOnOpenRouter(t, dir, "auth") + if _, err := RouteCrew(dir, CrewAsk{Task: crewroute.Task{Text: openTask}}); err == nil { + t.Fatal("a refused one model admitted") + } + if err := WriteAPIKey(dir, crewNewKey); err != nil { + t.Fatal(err) + } + d, err := RouteCrew(dir, CrewAsk{Task: crewroute.Task{Text: openTask}}) + if err != nil || d.Mode != crewroute.ModeOne || d.Seat(crewroute.Checker).Model != "z-ai/glm-5.3-flash" { + t.Fatalf("after a new key: %+v, %v", d.Seat(crewroute.Checker), err) + } +} + +// A KEY CHANGED WHILE A CALL WAS IN FLIGHT IS NOT BLAMED FOR THE OLD KEY'S +// REFUSAL. The call was sent with the old key; the person connected a new one +// before the refusal came back. The refusal is the old key's — the dispatch +// fact says so — and the same pin, on the same route, is admitted at once on +// the new key; a refusal of a call sent with the new key holds it back. +func TestAKeyChangedWhileACallWasInFlightKeepsTheNewKeyEligible(t *testing.T) { + dir := crewProfile(t) + pinWorkerOnOpenRouter(t, dir) + sent := CrewDispatchFor(dir, "openrouter") + if sent.Cred == "" || strings.Contains(sent.Cred, crewOldKey) { + t.Fatalf("dispatch print %q: want a print, never the key", sent.Cred) + } + if err := WriteAPIKey(dir, crewNewKey); err != nil { + t.Fatal(err) + } + refuseSent(t, dir, "auth", sent) + if !pinnedWorkerAdmits(t, dir) { + t.Fatal("the old key's in-flight refusal held back the new key") + } + reopen() + if !pinnedWorkerAdmits(t, dir) || !CrewRouteAnswers(dir, "z-ai/glm-5.3-flash") { + t.Fatal("after reopening, the old key's refusal held back the new key") + } + refuseOnOpenRouter(t, dir, "auth") + if pinnedWorkerAdmits(t, dir) { + t.Fatal("a refusal of a call sent with the new key did not hold") + } +} + +// AN OLD BUILD'S REFUSAL, WHICH NAMES NO CREDENTIAL, is lifted by a credential +// change after it and by nothing else: written again, the same key stays held +// back across reopening; a new key lifts it, and the lift survives reopening. +// The record of changes carries prints, never a key. +func TestAnOldBuildsRefusalIsLiftedByAReconnectAfterIt(t *testing.T) { + dir := crewProfile(t) + pinWorkerOnOpenRouter(t, dir) + refuseAsAnOldBuild(t, dir, "auth") + reopen() + if pinnedWorkerAdmits(t, dir) { + t.Fatal("an old build's refusal of the key in force was dropped") + } + if err := WriteAPIKey(dir, crewOldKey); err != nil { + t.Fatal(err) + } + reopen() + if pinnedWorkerAdmits(t, dir) { + t.Fatal("the refused key, written again, lifted an old build's refusal") + } + if err := WriteAPIKey(dir, crewNewKey); err != nil { + t.Fatal(err) + } + reopen() + if !pinnedWorkerAdmits(t, dir) { + t.Fatal("a new key did not lift an old build's refusal") + } + reopen() + if !pinnedWorkerAdmits(t, dir) || !CrewRouteAnswers(dir, "z-ai/glm-5.3-flash") { + t.Fatal("the lift did not survive reopening") + } + raw, err := os.ReadFile(ProfilePath(dir, crewCredentialsFile)) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), crewOldKey) || strings.Contains(string(raw), crewNewKey) || strings.Contains(string(raw), "crewtest") { + t.Fatalf("the record of changes carries a key: %s", raw) + } + // A refusal of the new key, from this build or an old one, holds again. + refuseAsAnOldBuild(t, dir, "auth") + reopen() + if pinnedWorkerAdmits(t, dir) { + t.Fatal("an unprinted refusal after the change did not hold") + } +} + +// THE FIRST CREDENTIAL AN INSTALL RECORDS IS NOT A CHANGE: an old build's +// refusal met by a profile that has never been read since holds, read after +// read, however long the record has existed. +func TestTheFirstCredentialRecordedIsNotAReconnect(t *testing.T) { + dir := crewProfile(t) + pinWorkerOnOpenRouter(t, dir) + refuseAsAnOldBuild(t, dir, "auth") + for range 3 { + reopen() + if pinnedWorkerAdmits(t, dir) { + t.Fatal("an untouched credential's refusal was lifted") + } + } + keys := NewAppCredentials() + t.Cleanup(UseAppCredentials(keys)) + keys.Set("openrouter", crewOldKey) + if pinnedWorkerAdmits(t, dir) { + t.Fatal("the refused key handed over again lifted its refusal") + } + keys.Set("openrouter", crewNewKey) + if !pinnedWorkerAdmits(t, dir) { + t.Fatal("a new key the app handed over did not lift an old build's refusal") + } +} + +// A CREDIT READING OLDER THAN A PAYMENT REFUSAL LIFTS NOTHING: credit was +// read, then the account refused for want of it. A fresh reading of none keeps +// it held back; only a positive reading after the refusal lifts it. +func TestAStalePositiveCreditReadingCannotLiftANewerPaymentRefusal(t *testing.T) { + dir := crewProfile(t) + pinWorkerOnOpenRouter(t, dir) + if err := WriteCreditsReading(dir, crewOldKey, credits.Reading{Known: true}); err != nil { + t.Fatal(err) + } + time.Sleep(time.Millisecond) + refuseOnOpenRouter(t, dir, "payment") + reopen() + if pinnedWorkerAdmits(t, dir) { + t.Fatal("a positive reading from before the refusal lifted it") + } + time.Sleep(time.Millisecond) + if err := WriteCreditsReading(dir, crewOldKey, credits.Reading{Known: true, Low: true}); err != nil { + t.Fatal(err) + } + reopen() + if pinnedWorkerAdmits(t, dir) { + t.Fatal("a fresh reading of no credit lifted the refusal") + } + time.Sleep(time.Millisecond) + if err := WriteCreditsReading(dir, crewOldKey, credits.Reading{Known: true}); err != nil { + t.Fatal(err) + } + reopen() + if !pinnedWorkerAdmits(t, dir) { + t.Fatal("a positive reading after the refusal did not lift it") + } +} + +// AN ACCOUNT ADAPTER'S BALANCE LIFTS A PAYMENT REFUSAL ONLY WITH A TIME AFTER +// IT: a balance with no time, or read before the refusal, is no evidence; a +// fresh balance of none holds it back. +func TestAnAccountBalanceLiftsAPaymentRefusalOnlyWhenReadAfterIt(t *testing.T) { + dir := crewProfile(t) + pinWorkerOnOpenRouter(t, dir) + refuseOnOpenRouter(t, dir, "payment") + refused := time.Now() + previous := CrewAccounts + t.Cleanup(func() { CrewAccounts = previous }) + for _, c := range []struct { + name string + state CrewAccountState + lifts bool + }{ + {"a balance with no time", CrewAccountState{CreditKnown: true, CreditUSD: 5}, false}, + {"a balance read before the refusal", CrewAccountState{CreditKnown: true, CreditUSD: 5, ReadAt: refused.Add(-time.Hour)}, false}, + {"a fresh balance of none", CrewAccountState{CreditKnown: true, CreditUSD: 0, ReadAt: refused.Add(time.Second)}, false}, + {"a balance read after the refusal", CrewAccountState{CreditKnown: true, CreditUSD: 5, ReadAt: refused.Add(time.Second)}, true}, + } { + state := c.state + CrewAccounts = func(_, provider string) (CrewAccountState, bool) { return state, provider == "openrouter" } + reopen() + if got := pinnedWorkerAdmits(t, dir); got != c.lifts { + t.Fatalf("%s: admitted %v, want %v", c.name, got, c.lifts) + } + } +} diff --git a/internal/config/crewhealth.go b/internal/config/crewhealth.go index c34a81b321..9c6a8b5890 100644 --- a/internal/config/crewhealth.go +++ b/internal/config/crewhealth.go @@ -65,6 +65,10 @@ type CrewAccountState struct { CreditKnown bool CreditUSD float64 ResetAt time.Time + // ReadAt is when the account was read. A positive balance lifts a payment + // refusal only when it was read after that refusal; a reading with no + // time lifts nothing. + ReadAt time.Time } // CrewAccounts is the OPTIONAL adapter that primes route health for providers @@ -112,7 +116,10 @@ func crewHealthAt(profileDir string) crewHealth { if CrewRouteHistory != nil { history = CrewRouteHistory(profileDir) } - h := crewHealthOf(history, time.Now()) + now := time.Now() + // A refusal of a credential no longer in force, or of credit since found, + // holds nothing back ([crewHeldAgainstNow]). + h := crewHealthOf(crewHeldAgainstNow(profileDir, history, now), now) // AN OPENROUTER BALANCE KNOWN TO BE LOW ([CreditsLowAt]) is an account out // of credit before any call is made: the seats reach for free routes and // the crew line says so, instead of a first paid call being refused. @@ -273,10 +280,12 @@ func forgetCrewHealth() { func crewHealthCached(profileDir string) crewHealth { crewHealthCache.mu.Lock() defer crewHealthCache.mu.Unlock() - if crewHealthCache.dir == profileDir && time.Since(crewHealthCache.at) < crewHealthFresh { + generation := SettingsGeneration() + if crewHealthCache.dir == profileDir && crewHealthCache.generation == generation && time.Since(crewHealthCache.at) < crewHealthFresh { return crewHealthCache.health } crewHealthCache.dir, crewHealthCache.at, crewHealthCache.health = profileDir, time.Now(), crewHealthAt(profileDir) + crewHealthCache.generation = generation return crewHealthCache.health } @@ -288,6 +297,9 @@ var crewHealthCache struct { dir string at time.Time health crewHealth + // generation is the settings generation it was read at: a key written, a + // key the app handed over, a credit reading, is seen by the next call. + generation uint64 } // usable is the routes this health leaves, with their learned failure rates. @@ -395,16 +407,40 @@ func (e ErrCrewUnreachable) Error() string { return e.Action } // LogCrewRoute writes one seat's first-call outcome on its route: answered // (kind empty), or failed with a route-failure kind and when the route said it // may be asked again. +// +// IT KNOWS NOTHING OF THE CREDENTIAL THE CALL WAS SENT WITH, and does not guess +// it from the settings in force now: a key changed while the call was in +// flight would be blamed for the old key's refusal. Its refusals are written +// unprinted, and lifted only by a credential change recorded after them +// ([crewCredentialsSeen]); a caller that took the [CrewDispatch] fact when it +// sent the call writes it with [LogCrewRouteSent]. func LogCrewRoute(profileDir, call string, d crewroute.Decision, repo, title string, seat crewroute.Seat, pick crewroute.Pick, kind string, until time.Time) { + LogCrewRouteSent(profileDir, call, d, repo, title, seat, pick, kind, until, CrewDispatch{}) +} + +// LogCrewRouteSent is [LogCrewRoute] for a call whose [CrewDispatch] fact was +// taken when it was sent: a refusal is written against the print of the +// credential that call carried, and holds nothing against any other. +func LogCrewRouteSent(profileDir, call string, d crewroute.Decision, repo, title string, seat crewroute.Seat, pick crewroute.Pick, kind string, until time.Time, sent CrewDispatch) { if kind != "" { // A FAILURE IS SEEN BY THE VERY NEXT CALL, not a few seconds later: // the cache that spares every request a read of the log is dropped. forgetCrewHealth() } - router.LogCrewRoute(ProfilePath(profileDir, ""), call, CrewRecordOf(d, repo, title), router.CrewRouteOutcome{ + outcome := router.CrewRouteOutcome{ Seat: string(seat), Send: pick.Send, Provider: pick.Provider, Paid: pick.Kind == crewroute.Metered, Kind: kind, Until: until, - }) + } + if crewCredentialRefusal(kind) { + // THE REFUSAL IS WRITTEN AGAINST THE CREDENTIAL IT REFUSED — the print + // taken when the call was sent, never the credential itself. Before + // it is written, a change of credential since the last reading is + // recorded, so it is dated before this refusal and never mistaken for + // a reconnect after it. + outcome.Cred = sent.Cred + crewCredentialsSeen(profileDir, []string{pick.Provider}, time.Now()) + } + router.LogCrewRoute(ProfilePath(profileDir, ""), call, CrewRecordOf(d, repo, title), outcome) } // CrewRescue is the last of a seat's ladder, after everything the router diff --git a/internal/config/sources.go b/internal/config/sources.go index f8d22daa66..b4b3b1a85c 100644 --- a/internal/config/sources.go +++ b/internal/config/sources.go @@ -76,6 +76,12 @@ func persistedSourcesFrom(values map[string]json.RawMessage) []PersistedSource { // WriteSources atomically replaces the non-default service rows while keeping // every unrelated profile setting. func WriteSources(profileDir string, rows []PersistedSource) error { + // A row's key is a credential: what it changes is recorded as it is + // written ([crewCredentialWrite]). + return crewCredentialWrite(profileDir, func() error { return writeSources(profileDir, rows) }) +} + +func writeSources(profileDir string, rows []PersistedSource) error { cleaned := make([]PersistedSource, 0, len(rows)) for _, row := range rows { row.ID = strings.TrimSpace(row.ID) @@ -283,7 +289,7 @@ func ConnectCodex(ctx context.Context, profileDir string, tokens codexauth.Token if err := ctx.Err(); err != nil { return modelsource.Outcome{}, err } - if err := codexauth.Save(profileDir, tokens); err != nil { + if err := crewCredentialWrite(profileDir, func() error { return codexauth.Save(profileDir, tokens) }); err != nil { return modelsource.Outcome{}, err } listed := true diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index 38f3e3c163..36010e836f 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -864,7 +864,8 @@ the task stops on the one thing to do, which leads its line in place of `/redo s (a stronger crew would meet the same wall): `failed — add credit on openrouter to continue`, `reconnect openrouter with /connect`, or when the limit resets. What each route did is kept: a route that refused a model is left out for a week, one at its limit rests until its reset, an -account out of credit is skipped until a paid call on it answers again. Free routes are +account out of credit is skipped until a paid call on it answers again, a positive balance +is read after the refusal, or a different key is connected. Free routes are off unless you turn them on; when every paid route is out of reach they are used anyway, and the line says `free routes in use (may log prompts)`. A rescue never takes a model whose name says it was tuned for one domain (finance, medicine, law) or is too small for @@ -1329,6 +1330,31 @@ answer get **20s** and **30s**. What this replaced was the ordinary five-to-fifteen-minute bound a completion carries, which is right for your own turn and absurd for eight words of title. +## I reconnected or added credit and my pinned model still will not start — when a refused key or an empty account is tried again + +A pinned role, or the one model for every task, is never sent to a provider whose key was +refused or whose account ran out of credit: the task is refused at the start with the one +fix — `reconnect openrouter with /connect, or unpin the worker from glm-5.3-flash`, or +`add credit on openrouter, or …`. What lifts that refusal is a fact, never a timer: + +- **A different key or sign-in.** A refusal is remembered against a short one-way print of + the key the refused call was sent with — never the key itself. Connecting a new key with + `/connect`, a new key from the desktop app, or signing in again, is a different key, and + the next task is admitted on it. Pasting the **same** key again changes nothing: it is + the key that was refused, and the refusal stands. +- **A key changed while the call was in flight** is not blamed: the refusal belongs to the + key the call carried when it was sent. +- **Credit read after the refusal.** For a payment refusal, a balance read that finds + credit, taken after the refusal, lifts it. A balance read before it does not, and a + fresh reading of none keeps it. + +A refusal written by an older build named no key. It is lifted when the key is seen to +change after it. The first key this build sees is where it starts counting, so a key you +changed **before** updating is not known to be new and the refusal still holds it: unpin +the role, or switch Task models back to Auto, and the next call that answers on it clears +it. Every refusal also leaves the record after fourteen days. Closing and reopening codeaf +keeps all of this. + ## Use one model for everything for one run — `--one-model`, and why a run spent money on a model I did not pick `codeaf chat --one-model` and `codeaf resume --one-model` run **every text call on the model diff --git a/internal/manual/chat_test.go b/internal/manual/chat_test.go index 91915f6ed7..1764fd7815 100644 --- a/internal/manual/chat_test.go +++ b/internal/manual/chat_test.go @@ -34,6 +34,8 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) { {"I changed or unset my API key in the shell but codeaf still uses the old one", "starting-codeaf"}, {"your key was not accepted for this model", "starting-codeaf"}, {"codeaf ignores my OPENAI_API_KEY", "starting-codeaf"}, + {"I reconnected my key but my pinned model still will not start", "models-and-cost"}, + {"I added credit and the one model is still refused", "models-and-cost"}, {"what can you do", "what-i-can-do"}, {"why does a wrapped help line stay under its key", "keys"}, diff --git a/internal/router/crew.go b/internal/router/crew.go index 6863c7e63e..7b54bfb4b6 100644 --- a/internal/router/crew.go +++ b/internal/router/crew.go @@ -73,10 +73,15 @@ type CrewRecord struct { // failure, the provider of the route and when it said it may be asked // again. Started is seat → the id whose first call answered. Both are // ROUTE outcomes — did the call work — and never the task's. - Failed map[string]string `json:"failed,omitempty"` - FailKind string `json:"fail_kind,omitempty"` - FailRoute string `json:"fail_route,omitempty"` - FailUntil time.Time `json:"fail_until,omitempty"` + Failed map[string]string `json:"failed,omitempty"` + FailKind string `json:"fail_kind,omitempty"` + FailRoute string `json:"fail_route,omitempty"` + FailUntil time.Time `json:"fail_until,omitempty"` + // FailCred names the credential the failed call was made with — a short + // one-way print, never the credential — so a refusal is held against that + // credential and not against the one a person connects after it. Empty on + // rows written before it existed. + FailCred string `json:"fail_cred,omitempty"` Started map[string]string `json:"started,omitempty"` StartedPaid bool `json:"started_paid,omitempty"` // Redo is a row of a task that was itself a redo: its acceptance is the @@ -140,6 +145,9 @@ type CrewRouteOutcome struct { Paid bool Kind string Until time.Time + // Cred is the print of the credential a failed call was made with + // ([CrewRecord.FailCred]); empty on a success and on an older row. + Cred string } // LogCrewRoute appends one route outcome for a task's crew. It is not the @@ -156,6 +164,7 @@ func LogCrewRoute(dir, call string, record CrewRecord, outcome CrewRouteOutcome) } else { record.Failed = map[string]string{outcome.Seat: outcome.Send} record.FailKind, record.FailRoute, record.FailUntil = outcome.Kind, outcome.Provider, outcome.Until + record.FailCred = outcome.Cred } events.Append(Event{Call: call, Run: record.Repo, Class: CrewClass, Shape: record.TaskClass, Model: outcome.Send, Crew: &record}) @@ -352,7 +361,7 @@ func ReadCrewLog(dir string, now time.Time) CrewLog { if now.Sub(row.At) < crewOutcomesFor { for seat, send := range row.Crew.Failed { out.Routes = append(out.Routes, CrewRouteOutcome{At: row.At, Seat: seat, Send: send, - Provider: row.Crew.FailRoute, Kind: row.Crew.FailKind, Until: row.Crew.FailUntil}) + Provider: row.Crew.FailRoute, Kind: row.Crew.FailKind, Until: row.Crew.FailUntil, Cred: row.Crew.FailCred}) } for seat, send := range row.Crew.Started { out.Routes = append(out.Routes, CrewRouteOutcome{At: row.At, Seat: seat, Send: send, From 1215a42cafcc37dbae2d32e2d709b16ffe050554 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 21:11:42 -0400 Subject: [PATCH 080/459] Expose refusal repair facts and date balance reads before requests --- cmd/codeaf/chatv3_credits.go | 7 +- cmd/codeaf/chatv3_credits_test.go | 39 ++++++ .../unreleased/0-crew-refusal-recovery.md | 2 + internal/config/credits.go | 28 ++++- internal/config/crewcred.go | 24 +++- internal/config/crewcred_test.go | 114 ++++++++++++++++++ .../config/crewcred_unknown_review_test.go | 17 +++ internal/manual/chat/models-and-cost.md | 4 +- 8 files changed, 226 insertions(+), 9 deletions(-) create mode 100644 internal/config/crewcred_unknown_review_test.go diff --git a/cmd/codeaf/chatv3_credits.go b/cmd/codeaf/chatv3_credits.go index 81aa990ed8..c74efb568e 100644 --- a/cmd/codeaf/chatv3_credits.go +++ b/cmd/codeaf/chatv3_credits.go @@ -17,7 +17,12 @@ import ( var errCreditsSuperseded = errors.New("credit read superseded by a changed key") // v3ReadAndStoreCredits commits only a reading for the key still in force. +// +// THE READING IS DATED WHEN THE READ BEGAN, taken before the request goes out: +// a balance that answers after a payment refusal was looked up before it, and +// must not lift it. func v3ReadAndStoreCredits(ctx context.Context, profileDir, base, key string) (credits.Reading, error) { + observedAt := time.Now() reading, err := credits.Read(ctx, nil, base, key) if err != nil { return credits.Reading{}, err @@ -25,7 +30,7 @@ func v3ReadAndStoreCredits(ctx context.Context, profileDir, base, key string) (c if key != config.APIKeyAt(profileDir) { return credits.Reading{}, errCreditsSuperseded } - if err := config.WriteCreditsReading(profileDir, key, reading); err != nil { + if err := config.WriteCreditsReadingAt(profileDir, key, reading, observedAt); err != nil { return credits.Reading{}, err } return reading, nil diff --git a/cmd/codeaf/chatv3_credits_test.go b/cmd/codeaf/chatv3_credits_test.go index 315bfd2d7f..f9f2d3131f 100644 --- a/cmd/codeaf/chatv3_credits_test.go +++ b/cmd/codeaf/chatv3_credits_test.go @@ -15,6 +15,7 @@ import ( "github.com/Agent-Field/codeaf/internal/credits" "github.com/Agent-Field/codeaf/internal/provider" "github.com/Agent-Field/codeaf/internal/remote" + "github.com/Agent-Field/codeaf/internal/router" "github.com/Agent-Field/codeaf/internal/session" "github.com/Agent-Field/codeaf/internal/tui3" ) @@ -353,3 +354,41 @@ func TestAFreshConversationOpensOnTheDefaultTheBalanceAnswersNow(t *testing.T) { t.Fatalf("a topped-up profile's new conversation opened on %q with window %d", got.Model, got.Config.ContextWindow) } } + +// A BALANCE READ IS DATED WHEN IT BEGAN. A read that went out before a payment +// refusal and answered after it, with credit, does not lift that refusal; the +// next read, begun after the refusal, does. Fixture server only. +func TestACreditReadThatBeganBeforeAPaymentRefusalDoesNotLiftIt(t *testing.T) { + var refusal router.CrewRouteOutcome + refuseDuring := true + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/credits" { + if refuseDuring { + // The account refuses a paid call while this read is out. + refusal = router.CrewRouteOutcome{At: time.Now(), Send: "z-ai/glm-5.3-flash", Provider: "openrouter", Paid: true, Kind: "payment"} + refuseDuring = false + } + fmt.Fprint(w, `{"data":{"total_credits":5,"total_usage":0}}`) + return + } + fmt.Fprint(w, `{"data":{"limit_remaining":null}}`) + })) + defer server.Close() + dir := t.TempDir() + if err := config.WriteAPIKey(dir, "key-A"); err != nil { + t.Fatal(err) + } + if _, err := v3ReadAndStoreCredits(context.Background(), dir, server.URL, "key-A"); err != nil { + t.Fatal(err) + } + refusal.Cred = config.CrewDispatchFor(dir, "openrouter").Cred + if !config.CrewRefusalApplies(dir, refusal) { + t.Fatal("credit read before the refusal, answered after it, lifted the refusal") + } + if _, err := v3ReadAndStoreCredits(context.Background(), dir, server.URL, "key-A"); err != nil { + t.Fatal(err) + } + if config.CrewRefusalApplies(dir, refusal) { + t.Fatal("credit read after the refusal did not lift it") + } +} diff --git a/docs/changes/unreleased/0-crew-refusal-recovery.md b/docs/changes/unreleased/0-crew-refusal-recovery.md index 9a08364093..d175c9cce7 100644 --- a/docs/changes/unreleased/0-crew-refusal-recovery.md +++ b/docs/changes/unreleased/0-crew-refusal-recovery.md @@ -7,4 +7,6 @@ invalidates: - "A crew route refused for its key or for payment was held back until a later call on it answered. A pinned role or the one model is never sent to such a route, so reconnecting or adding credit changed nothing. Now a refusal is lifted by a different credential than the one refused (a print taken at dispatch, CrewDispatchFor / LogCrewRouteSent; router CrewRecord.fail_cred), by a credential change after an unprinted refusal (profile crew-credentials.json: print and since per provider, written by WriteAPIKey, WriteSources, ConnectCodex and the app's keys, and when route health reads; a first sighting is a baseline), or for payment by a positive balance read after the refusal. The same key written again lifts nothing; there is no timer." - "config.LogCrewRoute names no credential and never guesses one from the settings at response time; a caller that took a CrewDispatch when it sent the call uses LogCrewRouteSent." - "config.CrewAccountState had no read time. It now has ReadAt; a positive balance with no ReadAt, or read before a payment refusal, lifts nothing." + - "A balance read was dated when it finished, so a read that began before a payment refusal and answered after it lifted the refusal. config.WriteCreditsReadingAt(profileDir, key, reading, observedAt) now dates it; the v3 credit reader passes the time it began; a zero time lifts nothing and a time after now is refused (ErrCreditsObservedAhead). WriteCreditsReading is WriteCreditsReadingAt at now, for readings that were not just fetched." + - "Only route health could judge whether a refusal still held. config.CrewRefusalApplies(profileDir, failure) answers it for one router.CrewRouteOutcome with the same facts; failures other than auth and payment always apply, and a failure with no time is lifted only by its print." --- diff --git a/internal/config/credits.go b/internal/config/credits.go index 7f837a26bb..2a32bf59cf 100644 --- a/internal/config/credits.go +++ b/internal/config/credits.go @@ -4,6 +4,7 @@ import ( "crypto/sha256" "encoding/hex" "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -72,15 +73,36 @@ func CreditsNeedRead(profileDir, key string) bool { return r.Key != CreditsKeyPrint(key) || r.Low || r.Expired } -// WriteCreditsReading atomically replaces machine state. Dollars and the key -// stay out of the file; a successful rename wakes the live crew's generation. +// WriteCreditsReading is [WriteCreditsReadingAt] observed now — for a reading +// that was not just read from the provider. A read of the provider's balance +// writes with the time the read began. func WriteCreditsReading(profileDir, key string, reading credits.Reading) error { + return WriteCreditsReadingAt(profileDir, key, reading, time.Now()) +} + +// ErrCreditsObservedAhead is a reading dated after the moment it is written: +// a time nothing has reached yet cannot be when a balance was seen. +var ErrCreditsObservedAhead = errors.New("credits reading is dated in the future") + +// WriteCreditsReadingAt atomically replaces machine state with a reading +// OBSERVED AT observedAt — when the read that produced it began, not when it +// finished. A balance read that began before a payment refusal and answered +// after it says nothing about the refusal, and must not be dated as if it +// did ([crewHeldAgainstNow] lifts a payment refusal only on credit read after +// it). A zero time is kept as unknown, and lifts nothing; a time after now is +// refused with [ErrCreditsObservedAhead] and nothing is written. Dollars and +// the key stay out of the file; a successful rename wakes the live crew's +// generation. +func WriteCreditsReadingAt(profileDir, key string, reading credits.Reading, observedAt time.Time) error { + if observedAt.After(time.Now()) { + return ErrCreditsObservedAhead + } path := ProfilePath(profileDir, "credits.json") if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { return err } value := creditsRecord{Low: reading.Known && reading.Low, Known: reading.Known, Expired: reading.Known && reading.Expired, - Key: CreditsKeyPrint(key), ReadAt: time.Now().UTC()} + Key: CreditsKeyPrint(key), ReadAt: observedAt.UTC()} data, err := json.Marshal(value) if err != nil { return err diff --git a/internal/config/crewcred.go b/internal/config/crewcred.go index dce0689888..34518c4e52 100644 --- a/internal/config/crewcred.go +++ b/internal/config/crewcred.go @@ -124,13 +124,16 @@ func crewHeldAgainstNow(profileDir string, history []router.CrewRouteOutcome, no } provider := strings.ToLower(o.Provider) seen := facts.seen[provider] + // A refusal with no time it was made cannot be older than + // anything: only its print, which needs no time, can lift it. + dated := !o.At.IsZero() switch { - case o.Cred != "" && o.Cred != facts.prints[provider]: + case o.Cred != "" && facts.prints[provider] != "" && o.Cred != facts.prints[provider]: drop = true - case o.Cred == "" && !seen.Since.IsZero() && o.At.Before(seen.Since): + case o.Cred == "" && dated && !seen.Since.IsZero() && o.At.Before(seen.Since): drop = true } - if at, ok := facts.credit[provider]; ok && o.Kind == "payment" && at.After(o.At) { + if at, ok := facts.credit[provider]; ok && dated && o.Kind == "payment" && at.After(o.At) { drop = true } } @@ -144,6 +147,21 @@ func crewHeldAgainstNow(profileDir string, history []router.CrewRouteOutcome, no return out } +// CrewRefusalApplies is whether one route failure still holds against the +// route now, by the same facts route health reads ([crewHeldAgainstNow]): an +// auth or payment refusal does not when a known credential in force is not the one +// it refused, when the credential changed after an unprinted one, or, for +// payment, when credit was read after it. Every other kind of failure — a rate +// limit, a route gone, a timeout — still applies: no fact here says otherwise. +// A failure with no time it was made is lifted only by its print. It reads only +// the profile's facts about failure's own provider, and keeps nothing. +func CrewRefusalApplies(profileDir string, failure router.CrewRouteOutcome) bool { + if !crewCredentialRefusal(failure.Kind) { + return true + } + return len(crewHeldAgainstNow(profileDir, []router.CrewRouteOutcome{failure}, time.Now())) == 1 +} + // crewCredentialFacts are, for each provider a refusal in history names, the // print of its credential in force, when that credential was first seen in // force, and when its account was last read as having credit. diff --git a/internal/config/crewcred_test.go b/internal/config/crewcred_test.go index 8e6073edc2..9d4ffa6370 100644 --- a/internal/config/crewcred_test.go +++ b/internal/config/crewcred_test.go @@ -351,3 +351,117 @@ func TestAnAccountBalanceLiftsAPaymentRefusalOnlyWhenReadAfterIt(t *testing.T) { } } } + +// glmFailure is one failure on the pinned worker's route, as the router's log +// reads it back. +func glmFailure(kind string, at time.Time, sent CrewDispatch) router.CrewRouteOutcome { + return router.CrewRouteOutcome{At: at, Seat: string(crewroute.Worker), Send: "z-ai/glm-5.3-flash", Provider: "openrouter", Paid: true, Kind: kind, Cred: sent.Cred} +} + +// ONE FAILURE IS JUDGED BY THE SAME FACTS AS ROUTE HEALTH: a key refusal stops +// applying when a different key is in force and goes on applying when the +// same key is written again; an unprinted one is lifted only by a change after +// it, and one with no time is never lifted by a change at all. +func TestCrewRefusalAppliesFollowsTheCredentialInForce(t *testing.T) { + dir := crewProfile(t) + at := time.Now().Add(-time.Minute) + printed := glmFailure("auth", at, CrewDispatchFor(dir, "openrouter")) + unprinted := glmFailure("auth", at, CrewDispatch{}) + undated := glmFailure("auth", time.Time{}, CrewDispatch{}) + for _, f := range []router.CrewRouteOutcome{printed, unprinted, undated} { + if !CrewRefusalApplies(dir, f) { + t.Fatalf("a refusal of the key in force does not apply: %+v", f) + } + } + if err := WriteAPIKey(dir, crewOldKey); err != nil { + t.Fatal(err) + } + reopen() + if !CrewRefusalApplies(dir, printed) || !CrewRefusalApplies(dir, unprinted) { + t.Fatal("the refused key, written again, lifted its refusal") + } + if err := WriteAPIKey(dir, crewNewKey); err != nil { + t.Fatal(err) + } + reopen() + if CrewRefusalApplies(dir, printed) || CrewRefusalApplies(dir, unprinted) { + t.Fatal("a new key did not lift the old key's refusal") + } + if !CrewRefusalApplies(dir, undated) { + t.Fatal("a change was taken to be after a refusal with no time") + } + if !CrewRefusalApplies(dir, glmFailure("auth", time.Time{}, CrewDispatchFor(dir, "openrouter"))) { + t.Fatal("a refusal printed with the key in force does not apply") + } +} + +// ONLY A PAYMENT OR KEY REFUSAL CAN STOP APPLYING: a limit, a route gone, a +// timeout apply whatever the credentials and the credit say. +func TestCrewRefusalAppliesKeepsEveryOtherFailure(t *testing.T) { + dir := crewProfile(t) + at := time.Now().Add(-time.Minute) + if err := WriteAPIKey(dir, crewNewKey); err != nil { + t.Fatal(err) + } + if err := WriteCreditsReadingAt(dir, crewNewKey, credits.Reading{Known: true}, time.Now()); err != nil { + t.Fatal(err) + } + for _, kind := range []string{"rate_limit", "unavailable", "transient", "model", ""} { + if !CrewRefusalApplies(dir, glmFailure(kind, at, CrewDispatch{Cred: "0000000000000000"})) { + t.Fatalf("a %q failure stopped applying", kind) + } + } +} + +// CREDIT LIFTS A PAYMENT REFUSAL ONLY WHEN IT WAS OBSERVED AFTER IT: a read +// that began before the refusal — however late it answered — a reading of no +// credit, and a reading with no time all leave it applying; so does any +// reading against a refusal with no time. +func TestCrewRefusalAppliesUntilCreditIsObservedAfterIt(t *testing.T) { + dir := crewProfile(t) + refused := time.Now().Add(-time.Minute) + failure := glmFailure("payment", refused, CrewDispatchFor(dir, "openrouter")) + undated := glmFailure("payment", time.Time{}, CrewDispatchFor(dir, "openrouter")) + for _, c := range []struct { + name string + reading credits.Reading + observed time.Time + lifts bool + }{ + {"credit observed before the refusal", credits.Reading{Known: true}, refused.Add(-time.Second), false}, + {"credit observed with no time", credits.Reading{Known: true}, time.Time{}, false}, + {"no credit observed after the refusal", credits.Reading{Known: true, Low: true}, refused.Add(time.Second), false}, + {"an unknown balance after the refusal", credits.Reading{}, refused.Add(time.Second), false}, + {"credit observed after the refusal", credits.Reading{Known: true}, refused.Add(time.Second), true}, + } { + if err := WriteCreditsReadingAt(dir, crewOldKey, c.reading, c.observed); err != nil { + t.Fatal(err) + } + reopen() + if got := !CrewRefusalApplies(dir, failure); got != c.lifts { + t.Fatalf("%s: lifted %v, want %v", c.name, got, c.lifts) + } + if !CrewRefusalApplies(dir, undated) { + t.Fatalf("%s: lifted a payment refusal with no time", c.name) + } + } +} + +// A READING DATED AFTER NOW IS REFUSED and leaves the record as it was: it +// would otherwise outrank refusals that have not happened yet. +func TestACreditReadingFromTheFutureIsRefused(t *testing.T) { + dir := crewProfile(t) + if err := WriteCreditsReadingAt(dir, crewOldKey, credits.Reading{Known: true, Low: true}, time.Now()); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(ProfilePath(dir, "credits.json")) + if err != nil { + t.Fatal(err) + } + if err := WriteCreditsReadingAt(dir, crewOldKey, credits.Reading{Known: true}, time.Now().Add(time.Hour)); !errors.Is(err, ErrCreditsObservedAhead) { + t.Fatalf("a future reading wrote with %v", err) + } + if after, _ := os.ReadFile(ProfilePath(dir, "credits.json")); string(after) != string(before) { + t.Fatal("a refused future reading changed the record") + } +} diff --git a/internal/config/crewcred_unknown_review_test.go b/internal/config/crewcred_unknown_review_test.go new file mode 100644 index 0000000000..bf28378d81 --- /dev/null +++ b/internal/config/crewcred_unknown_review_test.go @@ -0,0 +1,17 @@ +package config + +import ( + "github.com/Agent-Field/codeaf/internal/router" + "testing" + "time" +) + +// A missing connection is no evidence that a new credential can retry. This +// also protects a task's cached failure when source resolution is unavailable. +func TestAnUnknownCurrentCredentialCannotProveARefusalRepaired(t *testing.T) { + dir := t.TempDir() + failure := router.CrewRouteOutcome{Provider: "not-connected", Kind: "auth", Cred: "known-old-dispatch-print", At: time.Now().Add(-time.Minute)} + if !CrewRefusalApplies(dir, failure) { + t.Fatal("unknown/disconnected route was treated as a proven new credential") + } +} diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index 36010e836f..43612611ca 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -1345,8 +1345,8 @@ fix — `reconnect openrouter with /connect, or unpin the worker from glm-5.3-fl - **A key changed while the call was in flight** is not blamed: the refusal belongs to the key the call carried when it was sent. - **Credit read after the refusal.** For a payment refusal, a balance read that finds - credit, taken after the refusal, lifts it. A balance read before it does not, and a - fresh reading of none keeps it. + credit, begun after the refusal, lifts it. A read begun before it does not, even if it + answered after, and a fresh reading of none keeps it. A refusal written by an older build named no key. It is lifted when the key is seen to change after it. The first key this build sees is where it starts counting, so a key you From aa461964a14985ef4793a09a4bbc798e9bd84f55 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 21:22:10 -0400 Subject: [PATCH 081/459] Keep service tool guidance within the existing prompt budget --- internal/session/keep_running.go | 4 ++-- internal/session/prompts/system.md | 25 +++++++++---------------- 2 files changed, 11 insertions(+), 18 deletions(-) diff --git a/internal/session/keep_running.go b/internal/session/keep_running.go index 2affb2a8ed..735d19730f 100644 --- a/internal/session/keep_running.go +++ b/internal/session/keep_running.go @@ -232,14 +232,14 @@ func (a *Agent) keepAvailable() bool { // supervisor's own. var keepRunningProperty = strings.ReplaceAll(keepRunningShape, "{every}", service.CheckEvery.String()) -const keepRunningShape = `{"type":"object","description":"Keep this command running after the turn as a supervised service. The person is asked first, once per command and folder; the command then runs in the current folder, is checked every {every}, and is restarted under the policy the person keeps. Give exactly one health check.","properties":{"name":{"type":"string","description":"a short name for it, e.g. web dev server"},"health":{"type":"object","description":"exactly one: a port that accepts connections, a URL that answers 200, or a command that exits 0","properties":{"port":{"type":"integer"},"url":{"type":"string"},"cmd":{"type":"string"}}},"restart":{"type":"string","enum":["on_failure","always","never"],"description":"the restart policy to propose; the person decides"},"service_id":{"type":"string","description":"to fix one of your kept-running processes that failed or stopped (for example a port conflict) instead of adding another: its id, like s3. Its row then runs this command, folder and check under the same id; the person is asked again for the changed command. Refused while it is still running or stopping: the person stops it first."}},"required":["health"]}` +const keepRunningShape = `{"type":"object","description":"Run after this turn with consent, in the current folder; checks every {every}. The user owns policy.","properties":{"name":{"type":"string","description":"Short display name."},"health":{"type":"object","description":"Exactly one: TCP port, HTTP URL returning 200, or command exiting 0.","properties":{"port":{"type":"integer"},"url":{"type":"string"},"cmd":{"type":"string"}}},"restart":{"type":"string","enum":["on_failure","always","never"],"description":"Proposed; the user decides."},"service_id":{"type":"string","description":"Repair your stopped/failed service (e.g. s3) on its same row. Changed command/check needs approval; stop it first if running."}},"required":["health"]}` // keepSentence is the bash description's line about it. func (a *Agent) keepSentence() string { if !a.keepAvailable() { return "" } - return " To keep a server or watcher running after this turn, pass keep_running with one health check; the person is asked, and nohup or & are not a way around that." + return " Use keep_running to ask before keeping servers/watchers after this turn; never bypass consent with nohup or &." } // schemaWithKeep adds keep_running to bash's schema where it can work. diff --git a/internal/session/prompts/system.md b/internal/session/prompts/system.md index 29c1d01182..ae464ff226 100644 --- a/internal/session/prompts/system.md +++ b/internal/session/prompts/system.md @@ -145,12 +145,9 @@ Say what you started in one line, answer whatever was words, and end there. The test is the critical path, not the size, and a hand-off says its estimate out loud ("several workers, about a dollar") before it spends. -THE QUESTION IS ASKED AGAIN WHILE YOU WORK. Material tells you its size only -once you are inside it, so the answer you gave before you started goes stale: -the moment you can NAME the scale in front of you — parts where you expected -one thing, a sweep whose end you cannot see, a grind that will outlast this -answer — is the moment to hand it over. Not once it is finished, and not when -they ask why it is taking so long. +Reassess scale while working. Once you can name unexpected parts, a sweep +whose end you cannot see, or work that will outlast this answer, hand it over +then: do not wait until it is finished or somebody asks about the delay. AND WHAT YOU HAVE ALREADY LEARNED GOES WITH IT. The brief is the dowry: what you found, the shape of the material as you now know it, what you have ruled @@ -167,16 +164,12 @@ material shows the width is real. AUTOMATION_FACTS # Interrupts and steering -A mid-turn message cuts generation: keep partial work in the transcript and -fold corrections into the SAME turn. A long bash may continue as a background -job; its result names the job. Only when bash's own schema offers -`keep_running` may a server outlive the turn: the person is asked, and only they -set its restart policy. To repair your failed or stopped kept service, use its -`keep_running.service_id` with the changed command and check, keeping its row; -never add a second service for that repair. A running service must be stopped -first, and a changed command needs fresh approval. Session news arrives only at -boundaries. An -interruption ends the turn: stop cleanly and preserve finished work. +Mid-turn messages cut generation: preserve partial work and fold corrections +into this turn. Bash may return a background job ID. Only schema-offered +`keep_running` keeps servers after the turn, with user consent and policy. +Repair a stopped/failed service with `keep_running.service_id`: keep its row, +ask approval for a new command/check, and stop live services first. News +arrives at boundaries; interruption ends the turn, preserving finished work. A turn can start with nobody having typed. Such a message says so in its own first words and carries its own reading instruction; when it is thin, `read` the From ad5e8394fead073484f00fd3c9373fc8def4e555 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 21:28:30 -0400 Subject: [PATCH 082/459] taskrouting: keep a task waiting on a model across a restart The refusal, the role, and the pin chosen for that task ride on the run row, so a reopened conversation still waits and starts only on an explicit retry or stop. --- cmd/codeaf/apptaskrouting_test.go | 31 +++ internal/manual/chat/models-and-cost.md | 6 +- internal/session/cancel.go | 6 + internal/session/stoplaw_test.go | 2 + internal/session/task_app_runs.go | 2 +- internal/session/task_contract.go | 5 + internal/session/task_run_belt.go | 3 + internal/session/task_store.go | 14 ++ internal/session/taskrouting.go | 177 ++++++++++++++++- internal/session/taskrouting_park_test.go | 220 ++++++++++++++++++++++ 10 files changed, 455 insertions(+), 11 deletions(-) create mode 100644 internal/session/taskrouting_park_test.go diff --git a/cmd/codeaf/apptaskrouting_test.go b/cmd/codeaf/apptaskrouting_test.go index 3348f206bb..c2e0207463 100644 --- a/cmd/codeaf/apptaskrouting_test.go +++ b/cmd/codeaf/apptaskrouting_test.go @@ -2,6 +2,7 @@ package main import ( "encoding/json" + "strings" "testing" "github.com/Agent-Field/codeaf/internal/appwire" @@ -97,6 +98,36 @@ func TestTheTaskRowCarriesItsModelsChip(t *testing.T) { } } +// A task waiting on a model reaches the wire as that wait: the role, the +// router's reasons, and the pin chosen for the task. The bytes are model names +// and provider ids; a credential is not a field here. +func TestAWaitingRefusalReachesTheWireAsTheRoleAndItsReasons(t *testing.T) { + got := appRoutingOf("agent:9", session.TaskRoutingView{ + State: session.TaskRoutingBlocked, Mode: crewroute.ModeAuto, + Blocked: &session.TaskRoutingBlock{ + Seat: crewroute.Checker, Mode: crewroute.ModeAuto, Omitted: 6, Action: "reconnect anthropic", + Rejected: []crewroute.Rejection{{Model: "claude-sonnet", Provider: "anthropic", Reason: crewroute.RejectProviderUnavailable}}, + }, + Requested: map[crewroute.Seat]string{crewroute.Planner: "ds-flash@deepseek"}, + }) + raw, err := json.Marshal(got) + if err != nil { + t.Fatal(err) + } + text := string(raw) + for _, secret := range []string{"api_key", "apiKey", "token", "sk-"} { + if strings.Contains(text, secret) { + t.Fatalf("the wire carried %q: %s", secret, text) + } + } + if got.State != "blocked" || got.Blocked == nil || got.Blocked.Role != "checker" || got.Blocked.Omitted != 6 || + len(got.Blocked.Rejected) != 1 || got.Blocked.Rejected[0].Reason != "provider_unavailable" || + got.Blocked.Rejected[0].Model.ID != "claude-sonnet" || got.Blocked.Rejected[0].Model.Provider != "anthropic" || + got.Requested == nil || got.Requested.Planner == nil || got.Requested.Planner.ID != "ds-flash@deepseek" { + t.Fatalf("wire %+v", got) + } +} + func contains(s, sub string) bool { for i := 0; i+len(sub) <= len(s); i++ { if s[i:i+len(sub)] == sub { diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index 43612611ca..bba46290eb 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -894,7 +894,11 @@ on the model you are talking to, nor on a model you did not allow. The same is t **pinned** to a model you allow whose provider is not connected, is turned off, refused its key or is out of credit: the task asks you to reconnect, add credit or unpin the seat, and never moves the seat to another model or provider. (A pin you no longer allow is the one -exception: that seat is picked as if unpinned, and says so.) +exception: that seat is picked as if unpinned, and says so.) Closing the conversation +does not start the task and does not drop it. Opened again, it is still waiting on +that same role, with the same reasons and the same pin, and it starts only when you +retry it; stop ends it as not started. Nothing is sent to a model while it waits, and +a pin whose provider is still out of reach stays that pin. **A direct provider's route is weighed at that provider's own price.** A model a direct connection serves is a route the crew may pick only when that provider's own model list diff --git a/internal/session/cancel.go b/internal/session/cancel.go index b5ad644421..2a8ea4ddd4 100644 --- a/internal/session/cancel.go +++ b/internal/session/cancel.go @@ -133,6 +133,12 @@ func (a *Agent) cancelTask(id uint64, why string) (string, error) { // A RUN'S ROWS WEAR TASK NUMBERS AND ARE NOT IN THE GRAPH, so the run is // asked first (stoprun.go). A number that is not a run's goes on to the // graph exactly as it always did. + // A TASK WAITING ON A MODEL HAS NOTHING RUNNING TO CUT: the stop ends it as + // not started, the way the app's Stop on the same row does (taskrouting.go). + // It is asked first because its queued row is a run's row in shape only. + if a.DropParkedTask(id) { + return fmt.Sprintf("task %d stopped · it had not started", id), nil + } if line, owned, err := a.stopBeltRow(id, why); owned { return line, err } diff --git a/internal/session/stoplaw_test.go b/internal/session/stoplaw_test.go index d28a125a8e..b54ca27a7a 100644 --- a/internal/session/stoplaw_test.go +++ b/internal/session/stoplaw_test.go @@ -40,6 +40,8 @@ var stoppableRowPublishers = map[string]struct{ kind, proof string }{ "sayForming": {CancelRun, "TestCancelStopsAnAdaptiveRun"}, "pauseRun": {CancelRun, "TestCancelStopsAnAdaptiveRun"}, "rename": {CancelRun, "TestCancelStopsAnAdaptiveRun"}, + "parkUnroutedHandoff": {CancelTask, "TestAStopReachesATaskWaitingOnAModel"}, + "retryRestoredPark": {CancelTask, "TestAStopReachesATaskWaitingOnAModel"}, } // stoppableStates are the states a stop means something in, by the names this diff --git a/internal/session/task_app_runs.go b/internal/session/task_app_runs.go index 56afbeb115..e8b7560998 100644 --- a/internal/session/task_app_runs.go +++ b/internal/session/task_app_runs.go @@ -101,7 +101,7 @@ func (a *Agent) appRunView(row TaskNotice, cap float64) AppTaskView { } a.appRunDetail(&view, appRunRowKey(row)) switch { - case a.taskRouting.isParked(row.ID): + case row.park != nil || a.taskRouting.isParked(row.ID): view.RoutingState = TaskRoutingBlocked case row.Crew != nil: view.RoutingState, view.Crew = TaskRoutingRouted, row.Crew diff --git a/internal/session/task_contract.go b/internal/session/task_contract.go index df4104dd67..6724ca3ef3 100644 --- a/internal/session/task_contract.go +++ b/internal/session/task_contract.go @@ -711,6 +711,11 @@ type TaskNotice struct { // starting and a hold ending are both news that arrives without the state // moving, so an update carrying only this is still one a surface folds in. Waiting string + // park is what a hand-off WAITING ON A MODEL needs to start again, on the + // row and nowhere else (taskrouting.go's [taskParkRecord]): the row is + // what the checkpoint keeps, so a waiting task comes back waiting after a + // restart rather than interrupted. Unexported: no surface reads it. + park *taskParkRecord // Paused says this row is HELD AT A GATE only a person can open: an adaptive // run that has spent its tank and stopped launching, waiting to be topped up, // finished or stopped ([EventOrchestratePause], [Agent.ResolveOrchestrate]). diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index 353e28402c..e4c24b6354 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -1686,6 +1686,9 @@ func carryRunRow(g *TaskGraph, notice TaskNotice) TaskNotice { continue } notice = carryRunIdentity(notice, kept) + if notice.park == nil && notice.State == TaskQueued && notice.Waiting == TaskWaitingNoModel { + notice.park = kept.park + } break } } diff --git a/internal/session/task_store.go b/internal/session/task_store.go index 93432df15f..462bf07b89 100644 --- a/internal/session/task_store.go +++ b/internal/session/task_store.go @@ -767,6 +767,11 @@ type runRecord struct { // loud rather than repairing. Copy *TaskCopyRecord `json:"copy,omitempty"` PendingRun *PendingRunRecord `json:"pendingRun,omitempty"` + // Park is A HAND-OFF WAITING ON A MODEL (taskrouting.go): why no role + // could be seated and what starting it again needs. A queued row that + // carries it comes back waiting, its Ask standing, rather than interrupted; + // absent on every other row and on every record written before it existed. + Park *taskParkRecord `json:"park,omitempty"` // PlanTask is WHICH TASK OF THE PLAN STORE THIS ROW IS // ([TaskNotice.PlanTask]), carried across a restart for the same reason the @@ -1141,6 +1146,7 @@ func runRowRecord(notice TaskNotice) runRecord { EndedAt: notice.EndedAt, Copy: notice.Copy, PendingRun: notice.PendingRun, + Park: notice.park.bounded(), Ending: notice.Ending, Branch: notice.Branch, Merge: notice.Merge, @@ -1199,6 +1205,14 @@ func runRowNotice(record runRecord) TaskNotice { decision := record.CrewState.Decision notice.Crew = &decision } + // A TASK WAITING ON A MODEL WAS NEVER RUNNING, so nothing about it stopped + // when the process did: it comes back waiting, with the same reason and + // the same Ask, and starts only when a person says so ([Agent.RetryParkedTask]). + if record.Park != nil && record.State == TaskQueued { + notice.park = record.Park.bounded() + notice.Waiting, notice.Report = TaskWaitingNoModel, record.Park.Message + return notice + } if !notice.State.settled() { // WORK NOTHING IS DRIVING IS INTERRUPTED, NOT FAILED. This row was live // when the process that held it went away, and that is a fact about the diff --git a/internal/session/taskrouting.go b/internal/session/taskrouting.go index 3addf5cabe..c8e882f176 100644 --- a/internal/session/taskrouting.go +++ b/internal/session/taskrouting.go @@ -6,6 +6,7 @@ import ( "fmt" "maps" "math" + "slices" "strconv" "strings" "sync" @@ -266,6 +267,7 @@ func (a *Agent) TaskRouting(id string, changes int) (TaskRoutingView, error) { if changes > taskCrewChangesKept { changes = taskCrewChangesKept } + a.loadParked(n) if block, ok := a.taskRouting.parkedBlock(n); ok { return TaskRoutingView{Task: id, State: TaskRoutingBlocked, Mode: block.Mode, Blocked: &block, Requested: a.taskRouting.requested(n)}, nil } @@ -523,6 +525,7 @@ func (a *Agent) TaskRoutingAct(ctx context.Context, id string, act TaskRoutingAc } var applied []crewroute.Seat var rerun uint64 + a.loadParked(n) switch { case a.taskRouting.isParked(n): err = a.actOnParked(ctx, n, act) @@ -1030,13 +1033,82 @@ type taskRoutingBook struct { parked map[uint64]*parkedHandoff } -// parkedHandoff is an approved hand-off that waits on a model. +// parkedHandoff is an approved hand-off that waits on a model. One parked in +// this process holds its proposal; one that came back from the checkpoint +// holds only its record, which is everything starting it needs. type parkedHandoff struct { ctx context.Context p *stagedProposal spec taskSpec elsewhere string block TaskRoutingBlock + title string + record *taskParkRecord +} + +// taskParkRecord is A HAND-OFF WAITING ON A MODEL as the checkpoint keeps it, +// on its run row ([runRecord.Park]): why the role could not be seated — the +// router's own typed refusal, at most [crewroute.MaxRejections] of it — and +// what starting the same work again needs. Model names and provider ids +// only; no key, token or credential is ever in it. +// +// THE PERSON'S CHOICE RIDES WITH IT. A pin chosen for this task while it +// waited is the choice its retry routes on, after a restart as before one; +// nothing here is read from settings, so a saved pin stays the pin it was. +type taskParkRecord struct { + Seat crewroute.Seat `json:"seat"` + Mode crewroute.Mode `json:"mode,omitempty"` + Rejected []crewroute.Rejection `json:"rejected,omitempty"` + Omitted int `json:"omitted,omitempty"` + Action string `json:"action,omitempty"` + Message string `json:"message"` + At time.Time `json:"at,omitzero"` + + Brief string `json:"brief"` + DependsOn []uint64 `json:"dependsOn,omitempty"` + Dir string `json:"dir"` + StandMode TaskMode `json:"standMode,omitempty"` + Asked []string `json:"asked,omitempty"` + Effort crewroute.Effort `json:"effort,omitempty"` + Worker string `json:"worker,omitempty"` + Elsewhere string `json:"elsewhere,omitempty"` + + Sends map[crewroute.Seat]string `json:"sends,omitempty"` + WishEffort crewroute.Effort `json:"wishEffort,omitempty"` + TaskCap float64 `json:"taskCap,omitempty"` + AutoSeats map[crewroute.Seat]bool `json:"autoSeats,omitempty"` +} + +// wish is the person's choice for the task the record kept. +func (r *taskParkRecord) wish() crewWish { + return crewWish{sends: maps.Clone(r.Sends), effort: r.WishEffort, taskCap: r.TaskCap, autoSeats: maps.Clone(r.AutoSeats)} +} + +// bounded is the record cut to what one row keeps. +func (r *taskParkRecord) bounded() *taskParkRecord { + if r == nil { + return nil + } + out := *r + if n := len(out.Rejected); n > crewroute.MaxRejections { + out.Omitted += n - crewroute.MaxRejections + out.Rejected = out.Rejected[:crewroute.MaxRejections] + } + out.Rejected = slices.Clone(out.Rejected) + out.DependsOn, out.Asked = slices.Clone(out.DependsOn), slices.Clone(out.Asked) + out.Sends, out.AutoSeats = maps.Clone(out.Sends), maps.Clone(out.AutoSeats) + return &out +} + +func (r *taskParkRecord) block() TaskRoutingBlock { + return TaskRoutingBlock{Seat: r.Seat, Mode: r.Mode, Rejected: slices.Clone(r.Rejected), Omitted: r.Omitted, Action: r.Action, Message: r.Message, At: r.At} +} + +// withBlock is the record with a new refusal and the wish as it stands now. +func (r taskParkRecord) withBlock(b TaskRoutingBlock, w crewWish) *taskParkRecord { + r.Seat, r.Mode, r.Rejected, r.Omitted, r.Action, r.Message, r.At = b.Seat, b.Mode, b.Rejected, b.Omitted, b.Action, b.Message, b.At + r.Sends, r.WishEffort, r.TaskCap, r.AutoSeats = w.sends, w.effort, w.taskCap, w.autoSeats + return r.bounded() } func (b *taskRoutingBook) change(id uint64, edit func(*crewWish)) { @@ -1091,6 +1163,57 @@ func (b *taskRoutingBook) parkedBlock(id uint64) (TaskRoutingBlock, bool) { return TaskRoutingBlock{}, false } +func (b *taskRoutingBook) park(id uint64, p *parkedHandoff) { + b.mu.Lock() + defer b.mu.Unlock() + if b.parked == nil { + b.parked = map[uint64]*parkedHandoff{} + } + b.parked[id] = p +} + +// loadParked puts a task that came back from the checkpoint waiting on a +// model into the book, with the choice it waited with, the first time it is +// asked about. A task the book already holds, or one whose row is not +// waiting, is left as it is: nothing here routes, starts or calls a model. +func (a *Agent) loadParked(n uint64) { + if n == 0 || a.taskRouting.isParked(n) { + return + } + g := a.graph() + if g == nil { + return + } + for _, row := range g.runRows(n) { + if row.ID == n && row.park != nil && row.State == TaskQueued { + rec := row.park.bounded() + a.taskRouting.adopt(n, &parkedHandoff{block: rec.block(), title: row.Title, elsewhere: rec.Elsewhere, record: rec}, rec.wish()) + return + } + } +} + +// adopt takes a restored waiting task into the book unless it already holds +// one, with the choice it waited with unless a newer one was made. +func (b *taskRoutingBook) adopt(id uint64, p *parkedHandoff, w crewWish) { + b.mu.Lock() + defer b.mu.Unlock() + if b.parked[id] != nil { + return + } + if b.parked == nil { + b.parked = map[uint64]*parkedHandoff{} + } + b.parked[id] = p + if _, chosen := b.wishes[id]; chosen || (len(w.sends) == 0 && w.effort == "" && w.taskCap == 0 && len(w.autoSeats) == 0) { + return + } + if b.wishes == nil { + b.wishes = map[uint64]crewWish{} + } + b.wishes[id] = w +} + func (b *taskRoutingBook) take(id uint64) *parkedHandoff { b.mu.Lock() defer b.mu.Unlock() @@ -1140,13 +1263,11 @@ func (a *Agent) parkUnroutedHandoff(ctx context.Context, p *stagedProposal, spec return "", false } block := blockOf(none, a.taskClockNow()) - a.taskRouting.mu.Lock() - if a.taskRouting.parked == nil { - a.taskRouting.parked = map[uint64]*parkedHandoff{} - } - a.taskRouting.parked[p.id] = &parkedHandoff{ctx: context.WithoutCancel(ctx), p: p, spec: spec, elsewhere: elsewhere, block: block} - a.taskRouting.mu.Unlock() - a.publishRunRow(g, TaskNotice{ID: p.id, Title: spec.title, Brief: spec.brief, State: TaskQueued, Waiting: TaskWaitingNoModel, Report: block.Message}) + w, _ := a.taskRouting.wish(p.id) + record := taskParkRecord{Brief: proposalBrief(spec, nil), DependsOn: spec.dependsOn, Dir: p.stand.dir, StandMode: p.stand.mode, + Asked: programAsked(spec), Effort: spec.crewEffort, Worker: namedModel(spec), Elsewhere: elsewhere}.withBlock(block, w) + a.taskRouting.park(p.id, &parkedHandoff{ctx: context.WithoutCancel(ctx), p: p, spec: spec, elsewhere: elsewhere, block: block, title: spec.title, record: record}) + a.publishRunRow(g, TaskNotice{ID: p.id, Title: spec.title, Brief: spec.brief, State: TaskQueued, Waiting: TaskWaitingNoModel, Report: block.Message, park: record}) return fmt.Sprintf("task %d is waiting: %s. It has asked the person and starts when a model is allowed for the %s role; nothing is running for it.", p.id, block.Message, none.Seat), true } @@ -1166,10 +1287,14 @@ func (a *Agent) actOnParked(ctx context.Context, n uint64, act TaskRoutingAction // RetryParkedTask starts a waiting task from where it stood. Still no model // keeps it waiting, with the new reason. func (a *Agent) RetryParkedTask(n uint64) error { + a.loadParked(n) parked := a.taskRouting.take(n) if parked == nil { return refuse(TaskRoutingNotFound, "that task is not waiting on a model") } + if parked.p == nil { + return a.retryRestoredPark(n, parked) + } spec := parked.spec ctx := withCrewWish(parked.ctx, a.proposalCrewWish(n, crewWish{effort: spec.crewEffort, worker: namedModel(spec)})) answer, refused, handled := a.commitProposalToRun(ctx, parked.p, spec, parked.elsewhere) @@ -1185,14 +1310,47 @@ func (a *Agent) RetryParkedTask(n uint64) error { return nil } +// retryRestoredPark starts a task that came back from the checkpoint waiting +// on a model, through the same start door and the same router as any other +// hand-off. Still no model keeps it waiting with the router's new reason; a +// start that fails for any other reason settles it as not started. +func (a *Agent) retryRestoredPark(n uint64, parked *parkedHandoff) error { + rec := parked.record + a.mu.Lock() + question := questionAtTaskHandoff(a.owedAsks) + a.mu.Unlock() + ctx := withCrewWish(context.Background(), a.proposalCrewWish(n, crewWish{effort: rec.Effort, worker: rec.Worker})) + _, err := a.startOrJoinTaskRunVia(ctx, n, parked.title, rec.Brief, rec.DependsOn, taskStand{dir: rec.Dir, mode: rec.StandMode}, question, nil, rec.Asked...) + var none *crewroute.NoEligibleError + switch { + case err == nil: + a.taskRouting.forget(n) + return nil + case errors.As(err, &none): + block := blockOf(none, a.taskClockNow()) + w, _ := a.taskRouting.wish(n) + next := rec.withBlock(block, w) + a.taskRouting.park(n, &parkedHandoff{block: block, title: parked.title, elsewhere: parked.elsewhere, record: next}) + if g := a.graph(); g != nil { + a.publishRunRow(g, TaskNotice{ID: n, Title: parked.title, State: TaskQueued, Waiting: TaskWaitingNoModel, Report: block.Message, park: next}) + } + return &TaskRoutingRefusal{Code: TaskRoutingNoEligible, Message: block.Message, Seat: block.Seat, Blocked: &block} + default: + a.taskRouting.forget(n) + a.settleUnstartedHandoff(n, parked.title, err) + return refuse(TaskRoutingUnavailable, "%s", runDidNotStart(n, err)) + } +} + // DropParkedTask ends a waiting task as not started. func (a *Agent) DropParkedTask(n uint64) bool { + a.loadParked(n) parked := a.taskRouting.take(n) if parked == nil { return false } a.taskRouting.forget(n) - a.settleUnstartedHandoff(n, parked.spec.title, errors.New(parked.block.Message)) + a.settleUnstartedHandoff(n, parked.title, errors.New(parked.block.Message)) return true } @@ -1323,6 +1481,7 @@ func (a *Agent) TaskRoutingOptions(id string) (TaskRoutingOptions, error) { return TaskRoutingOptions{}, refuse(TaskRoutingNotFound, "no such task") } out := TaskRoutingOptions{Task: id, Scope: TaskRoutingNone} + a.loadParked(n) switch { case a.taskRouting.isParked(n): out.Scope, out.Pick, out.Retry = TaskRoutingWaiting, true, true diff --git a/internal/session/taskrouting_park_test.go b/internal/session/taskrouting_park_test.go new file mode 100644 index 0000000000..473fd4150d --- /dev/null +++ b/internal/session/taskrouting_park_test.go @@ -0,0 +1,220 @@ +package session + +import ( + "context" + "errors" + "path/filepath" + "strconv" + "sync" + "testing" + + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/crewroute" +) + +// parkRouter is the router a conversation consults, scripted: it refuses the +// checker while the saved pin's provider is unreachable, and seats the crew +// once it is reachable again. Every ask it was given is kept. +type parkRouter struct { + mu sync.Mutex + reachable bool + asks []config.CrewAsk +} + +func (r *parkRouter) route(ask config.CrewAsk) (crewroute.Decision, error) { + r.mu.Lock() + defer r.mu.Unlock() + r.asks = append(r.asks, ask) + if !r.reachable { + none := &crewroute.NoEligibleError{Seat: crewroute.Checker, Mode: crewroute.ModeAuto} + // The saved pin is allowed; its provider is not reachable. THE PIN IS + // HELD: the router names it and seats nothing in its place. + none.Reject("claude-sonnet", "anthropic", crewroute.RejectProviderUnavailable) + return crewroute.Decision{}, none + } + return liveDecision(), nil +} + +func (r *parkRouter) count() int { + r.mu.Lock() + defer r.mu.Unlock() + return len(r.asks) +} + +func (r *parkRouter) set(reachable bool) { + r.mu.Lock() + r.reachable = reachable + r.mu.Unlock() +} + +// A TASK WAITING ON A MODEL COMES BACK WAITING. Parked, the conversation +// closed and was opened again from its checkpoint: the row is still the +// waiting Ask (not interrupted), with the router's own role and reasons and +// the person's choice for the task; reading it routes nothing and calls no +// model. Retry while the pin's provider is still down keeps it waiting on the +// same exact pin, never on another model; once it is reachable, the same +// retry starts the work. +func TestAWaitingTaskSurvivesARestartAndRetriesOnlyWhenAsked(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + double := newBeltRunDouble("done") + registerBeltRunEngine(t, double) + router := &parkRouter{} + dir := t.TempDir() + repo := newTestRepo(t) + completer := &scriptedCompleter{} + mutate := func(c *Config) { + c.Workspace = repo + c.Place = Place{Dir: dir} + c.SessionFile = filepath.Join(dir, placeTranscript) + c.AskConsent = false + c.RouteCrew = router.route + } + first, _ := newTestAgent(t, completer, mutate) + + none := &crewroute.NoEligibleError{Seat: crewroute.Checker, Mode: crewroute.ModeAuto} + none.Reject("claude-sonnet", "anthropic", crewroute.RejectProviderUnavailable) + for i := range 25 { + none.Reject("other-"+string(rune('a'+i)), "openrouter", crewroute.RejectNotAllowed) + } + // The id is the graph's own, as every hand-off's is. + id := first.graph().reserve() + sid := strconv.FormatUint(id, 10) + // The person chose a planner for this task before it was parked. + if err := first.actOnProposal(id, TaskRoutingAction{Kind: TaskRoutingPick, Picks: map[crewroute.Seat]string{crewroute.Planner: "ds-flash@deepseek"}}); err != nil { + t.Fatal(err) + } + p := &stagedProposal{id: id, stand: taskStand{dir: repo, mode: TaskModeWorktree}} + if _, parked := first.parkUnroutedHandoff(context.Background(), p, taskSpec{title: "Update fixtures", brief: "update every fixture"}, "", nil, none); !parked { + t.Fatal("no-eligible was not parked") + } + first.graph().checkpoint() + if err := first.Close(); err != nil { + t.Fatal(err) + } + + again, _ := newTestAgent(t, completer, mutate) + row, ok := again.appRunRow(id) + if !ok || row.State != TaskQueued || row.Waiting != TaskWaitingNoModel || row.Report != none.Error() { + t.Fatalf("restored row %+v", row) + } + view, err := again.TaskRouting(sid, 0) + if err != nil || view.State != TaskRoutingBlocked || view.Blocked == nil { + t.Fatalf("restored view %+v %v", view, err) + } + b := view.Blocked + if b.Seat != crewroute.Checker || len(b.Rejected) != crewroute.MaxRejections || b.Omitted != 26-crewroute.MaxRejections || + b.Rejected[0] != (crewroute.Rejection{Model: "claude-sonnet", Provider: "anthropic", Reason: crewroute.RejectProviderUnavailable}) { + t.Fatalf("restored refusal %+v", b) + } + if view.Requested[crewroute.Planner] != "ds-flash@deepseek" { + t.Fatalf("the person's choice was lost: %v", view.Requested) + } + options, err := again.TaskRoutingOptions(sid) + if err != nil || options.Scope != TaskRoutingWaiting || !options.Retry || !options.Pick { + t.Fatalf("restored options %+v %v", options, err) + } + if router.count() != 0 || completer.requests() != 0 || double.didRun() { + t.Fatal("reading a restored waiting task routed, called a model or ran") + } + + // Retry with the provider still down: still waiting, same pin, nothing run. + _, err = again.TaskRoutingAct(context.Background(), sid, TaskRoutingAction{Kind: TaskRoutingRetry}) + var refusal *TaskRoutingRefusal + if !errors.As(err, &refusal) || refusal.Code != TaskRoutingNoEligible || refusal.Blocked.Rejected[0].Model != "claude-sonnet" { + t.Fatalf("retry while unreachable: %v", err) + } + if router.count() != 1 { + t.Fatalf("router asked %d times", router.count()) + } + ask := router.asks[0] + if len(ask.AutoSeats) != 0 || ask.Sends[crewroute.Planner] != "ds-flash@deepseek" || ask.Sends[crewroute.Checker] != "" { + t.Fatalf("the retry changed what was asked: %+v", ask) + } + if row, _ := again.appRunRow(id); row.State != TaskQueued || row.Waiting != TaskWaitingNoModel || double.didRun() || completer.requests() != 0 { + t.Fatalf("still-blocked retry left %+v", row) + } + + // The person repaired the provider (a separate settings concern); the same + // retry now starts the task. + router.set(true) + if _, err := again.TaskRoutingAct(context.Background(), sid, TaskRoutingAction{Kind: TaskRoutingRetry}); err != nil { + t.Fatalf("retry once reachable: %v", err) + } + if again.taskRouting.isParked(id) { + t.Fatal("a started task is still parked") + } + beltRunWaitFor(t, "the run to start", double.didRun) + if row, _ := again.appRunRow(id); row.Waiting == TaskWaitingNoModel || row.park != nil { + t.Fatalf("started row still waiting: %+v", row) + } + endBeltRun(t, again, double) +} + +// Stop on a task that came back waiting ends it as not started, and it stays +// ended across the next restart. +func TestStoppingARestoredWaitingTaskEndsIt(t *testing.T) { + router := &parkRouter{} + dir := t.TempDir() + mutate := func(c *Config) { + c.Place = Place{Dir: dir} + c.SessionFile = filepath.Join(dir, placeTranscript) + c.RouteCrew = router.route + } + first, _ := newTestAgent(t, &recoveryModelRecorder{}, mutate) + none := &crewroute.NoEligibleError{Seat: crewroute.Worker, Mode: crewroute.ModeOne} + none.Reject("glm-5.3", "z-ai", crewroute.RejectRouteUnavailable) + id := first.graph().reserve() + first.parkUnroutedHandoff(context.Background(), &stagedProposal{id: id, stand: taskStand{dir: dir}}, taskSpec{title: "t"}, "", nil, none) + first.graph().checkpoint() + _ = first.Close() + + again, _ := newTestAgent(t, &recoveryModelRecorder{}, mutate) + if !again.DropParkedTask(id) { + t.Fatal("stop did not find the restored waiting task") + } + again.graph().checkpoint() + _ = again.Close() + third, _ := newTestAgent(t, &recoveryModelRecorder{}, mutate) + row, _ := third.appRunRow(id) + if row.State != TaskFailed || row.Waiting != "" { + t.Fatalf("stopped waiting task came back %+v", row) + } + if view, _ := third.TaskRouting(strconv.FormatUint(id, 10), 0); view.State == TaskRoutingBlocked { + t.Fatal("a stopped task reads as waiting") + } + if router.count() != 0 { + t.Fatal("stop routed") + } +} + +// THE STOP EVERY SURFACE SENDS — task:N — reaches a task waiting on a model, +// before a restart and after one, and ends it as not started. +func TestAStopReachesATaskWaitingOnAModel(t *testing.T) { + dir := t.TempDir() + mutate := func(c *Config) { + c.Place = Place{Dir: dir} + c.SessionFile = filepath.Join(dir, placeTranscript) + } + a, _ := newTestAgent(t, &recoveryModelRecorder{}, mutate) + none := &crewroute.NoEligibleError{Seat: crewroute.Planner, Mode: crewroute.ModeAuto} + none.Reject("ds-flash", "deepseek", crewroute.RejectProviderUnavailable) + live := a.graph().reserve() + a.parkUnroutedHandoff(context.Background(), &stagedProposal{id: live, stand: taskStand{dir: dir}}, taskSpec{title: "live"}, "", nil, none) + restored := a.graph().reserve() + a.parkUnroutedHandoff(context.Background(), &stagedProposal{id: restored, stand: taskStand{dir: dir}}, taskSpec{title: "restored"}, "", nil, none) + if _, err := a.CancelWithReason("task:"+strconv.FormatUint(live, 10), ""); err != nil { + t.Fatalf("stop on a waiting task: %v", err) + } + if row, _ := a.appRunRow(live); row.State != TaskFailed { + t.Fatalf("stopped waiting task is %s", row.State) + } + a.graph().checkpoint() + _ = a.Close() + b, _ := newTestAgent(t, &recoveryModelRecorder{}, mutate) + if _, err := b.CancelWithReason(strconv.FormatUint(restored, 10), ""); err != nil { + t.Fatalf("stop on a restored waiting task: %v", err) + } + if row, _ := b.appRunRow(restored); row.State != TaskFailed || b.taskRouting.isParked(restored) { + t.Fatalf("stopped restored task is %+v", row) + } +} From 4a4170b40cc0a758561dea41c5a4c387d2f746de Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 21:37:40 -0400 Subject: [PATCH 083/459] Expose and persist the actual individual conversation budget --- cmd/codeaf/app.go | 1 + cmd/codeaf/appbudget.go | 256 +++++++++++++++ cmd/codeaf/appbudget_test.go | 420 ++++++++++++++++++++++++ cmd/codeaf/appmoney.go | 31 +- cmd/codeaf/appstate.go | 8 + cmd/codeaf/apptidy.go | 5 + internal/appwire/budget.go | 185 +++++++++++ internal/appwire/budget_test.go | 105 ++++++ internal/appwire/wire.go | 3 + internal/manual/chat/models-and-cost.md | 12 + internal/remote/callclass.go | 2 +- internal/remote/client.go | 18 + internal/remote/server.go | 7 + internal/remote/spend_rail_test.go | 13 + internal/remote/wire.go | 1 + internal/session/rail.go | 7 + 16 files changed, 1063 insertions(+), 11 deletions(-) create mode 100644 cmd/codeaf/appbudget.go create mode 100644 cmd/codeaf/appbudget_test.go create mode 100644 internal/appwire/budget.go create mode 100644 internal/appwire/budget_test.go diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index 4e5d29dd5e..b3d1bbed75 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -874,6 +874,7 @@ func (b *appBackend) CreateAgent(ask appwire.CreateAsk) (appwire.Agent, error) { a.users, a.counted = 0, true view = a.view }) + view = b.stampAgentBudget(a) b.emit.Emit(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) guard.Go("app/create", func() { b.bootAgent(a, first) }) return view, nil diff --git a/cmd/codeaf/appbudget.go b/cmd/codeaf/appbudget.go new file mode 100644 index 0000000000..c732aa7a81 --- /dev/null +++ b/cmd/codeaf/appbudget.go @@ -0,0 +1,256 @@ +package main + +// ── codeaf app: ONE AGENT'S OWN BUDGET, READ AND SET ──────────────────────── +// +// THE RECORD IS THE APP'S, THE CEILING IS THE ENGINE'S. An agent's own figure +// is kept in the app's file (appstate.go's Budgets, and Unlimited for an agent +// the person said has none), because the engine's figure lives only as long as +// the open conversation; bindBudget hands it to the engine at every open and +// every turn (appmoney.go). So a reopened agent, after a quit or a restart, is +// bound the same figure it had — never today's default in its place. +// +// AN AGENT WITH NOTHING OF ITS OWN IS NOT GUESSED AT. It runs on the +// conversation limit its engine read when it opened, which only that engine can +// say; with nothing holding the conversation, or an engine too old to answer, +// it is unknown and is said as unknown. settings.budget_usd is the default a +// conversation is built with, not this. +// +// A SET IS WRITTEN BEFORE IT IS BOUND, AND UNDONE IF IT CANNOT BE. A write the +// disk refuses changes nothing anywhere. A write the open conversation then +// refuses is rolled back on disk, so the record and the ceiling in force never +// disagree about a figure the person was told did not land. + +import ( + "math" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" +) + +// The app's backend serves agents.budget.*. +var _ appwire.Budgeter = (*appBackend)(nil) + +// appSpendRailReader is an open conversation that can say the limit it +// checks its next turn against; remote.Agent is one, and an engine too old to +// know the call answers an error. +type appSpendRailReader interface { + SpendRail() (float64, error) +} + +// agentBudgetAt is what the app's file says one agent's budget is at now. +// It is the one reading of the record: bindBudget binds what this says. +func (f *appStateFile) agentBudgetAt(id string, now time.Time) appwire.AgentBudget { + out := appwire.AgentBudget{Scope: appwire.BudgetScopeAgent, From: appwire.BudgetFromRecord} + if _, none := f.Unlimited[id]; none { + out.Source, out.Limit = appwire.BudgetUnlimited, appwire.LimitUnlimited + return out + } + // A figure of zero or less is not a figure: an older file may hold one + // a raise for today wrote over a profile with no limit, and it was never + // bound, so it is read as nothing of the agent's own. + if base, ok := f.Budgets[id]; ok && base > 0 { + usd := f.raised("agent:"+id, base, now) + out.Source, out.Limit = appwire.BudgetSet, appwire.LimitLimited + out.BaseUSD, out.USD = &base, &usd + if usd > base { + until := f.Raises["agent:"+id].Until + out.RaisedUntil = &until + } + return out + } + out.Source, out.Limit = appwire.BudgetInherited, appwire.LimitUnknown + return out +} + +// budgetRecord is one agent's budget as the app's file says it now. +func (b *appBackend) budgetRecord(id string) (appwire.AgentBudget, error) { + var record appwire.AgentBudget + now := b.now() + err := b.state.read(func(f *appStateFile) { record = f.agentBudgetAt(id, now) }) + return record, err +} + +// budgetOf is the record with the open conversation's own reading beside it, +// when the conversation is held and can answer. An inherited budget is then +// the engine's figure, said as the engine's. +func (b *appBackend) budgetOf(id string, live *appLive) (appwire.AgentBudget, error) { + budget, err := b.budgetRecord(id) + if err != nil { + return appwire.AgentBudget{}, appEngineError(err) + } + if live == nil { + return budget, nil + } + reader, ok := live.conn.Conv.(appSpendRailReader) + if !ok { + return budget, nil + } + rail, err := reader.SpendRail() + if err != nil || rail < 0 || math.IsNaN(rail) || math.IsInf(rail, 0) { + return budget, nil + } + engine := &appwire.AgentBudgetEngine{Limit: appwire.LimitUnlimited, SpentUSD: live.conn.Conv.Usage().CostUSD} + if rail > 0 { + engine.Limit, engine.USD = appwire.LimitLimited, &rail + } + budget.Engine = engine + if budget.Source == appwire.BudgetInherited { + budget.Limit, budget.USD, budget.From = engine.Limit, engine.USD, appwire.BudgetFromEngine + } + return budget, nil +} + +// stampAgentBudget carries a changed persisted cap on the normal agent fact. +// No engine RPC runs on this event path; all windows see the same change once. +func (b *appBackend) stampAgentBudget(a *appAgent) appwire.Agent { + var id string + b.locked(func() { id = a.view.ID }) + budget, err := b.budgetRecord(id) + var view appwire.Agent + b.locked(func() { + if err == nil { + a.view.Budget = &budget + } + view = a.view + }) + return view +} + +// AgentBudget answers agents.budget.get. +func (b *appBackend) AgentBudget(id string) (appwire.AgentBudget, error) { + a, err := b.find(id) + if err != nil { + return appwire.AgentBudget{}, err + } + return b.budgetOf(id, b.liveOf(a)) +} + +// SetAgentBudget answers agents.budget.set: a positive figure for good, or +// nil for none. It is written first, bound to the open conversation second, +// and rolled back when the conversation refuses it. +func (b *appBackend) SetAgentBudget(set appwire.AgentBudgetSet) (appwire.AgentBudget, error) { + if set.USD != nil && (*set.USD <= 0 || math.IsNaN(*set.USD) || math.IsInf(*set.USD, 0)) { + return appwire.AgentBudget{}, appwire.Errorf(appwire.CodeBadParams, "usd must be a positive amount, or null for none") + } + a, err := b.find(set.ID) + if err != nil { + return appwire.AgentBudget{}, err + } + if b.isClosing() { + return appwire.AgentBudget{}, appwire.Errorf(appwire.CodeClosed, "codeaf is shutting down") + } + // The gate keeps a conversation from opening between the write and the + // bind with the figure it read before the write. + a.gate.Lock() + defer a.gate.Unlock() + id, scope := set.ID, "agent:"+set.ID + var oldRail *float64 + if live := b.liveOf(a); live != nil { + if reader, ok := live.conn.Conv.(appSpendRailReader); ok { + if value, err := reader.SpendRail(); err == nil && value >= 0 && !math.IsNaN(value) && !math.IsInf(value, 0) { + oldRail = &value + } + } + } + var before appBudgetEntries + err = b.state.write(func(f *appStateFile) error { + before = appBudgetEntriesOf(f, id) + if f.Budgets == nil { + f.Budgets = map[string]float64{} + } + if f.Unlimited == nil { + f.Unlimited = map[string]time.Time{} + } + // A FIGURE SET IS SET FOR GOOD, as caps.raise for good is: a raise + // for today over the old figure ends with it. + delete(f.Raises, scope) + if set.USD == nil { + delete(f.Budgets, id) + f.Unlimited[id] = b.now() + return nil + } + delete(f.Unlimited, id) + f.Budgets[id] = *set.USD + return nil + }) + if err != nil { + return appwire.AgentBudget{}, appEngineError(err) + } + live := b.liveOf(a) + if live != nil { + if err := b.bindBudget(id, live.conn.Conv); err != nil { + // The record goes back to what the engine still holds, and the + // old figure is bound again in case the refusal came after it + // was taken. + if undo := b.state.write(func(f *appStateFile) error { before.restore(f, id); return nil }); undo != nil { + return appwire.AgentBudget{}, appwire.Errorf(appwire.CodeEngine, "the budget update could not be confirmed and its saved value could not be restored: %s; read this agent's budget again", appEngineError(undo).Error()) + } + var restore error + if oldRail != nil { + restore = live.conn.Conv.SetSpendRail(*oldRail) + } else { + if _, set := b.agentBudget(id, b.now()); !set { + return appwire.AgentBudget{}, appwire.Errorf(appwire.CodeEngine, "the saved budget was restored but the conversation's prior limit could not be confirmed; read this agent's budget again") + } + restore = b.bindBudget(id, live.conn.Conv) + } + if restore != nil { + return appwire.AgentBudget{}, appwire.Errorf(appwire.CodeEngine, "the saved budget was restored but its live limit could not be confirmed: %s; read this agent's budget again", appEngineError(restore).Error()) + } + return appwire.AgentBudget{}, appwire.Errorf(appwire.CodeEngine, "the conversation did not take the new budget, so it keeps the one it had: %s", appEngineError(err).Error()) + } + } + receipt := "Budget set to no limit" + if set.USD != nil { + receipt = "Budget set to " + appUSD(*set.USD) + } + if b.closeMoneyAsks(a, appwire.CapAgent, appwire.OptionRaise, receipt) { + b.retryRefused(a) + } + view := b.stampAgentBudget(a) + b.emit.Emit(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) + return b.budgetOf(id, live) +} + +// appBudgetEntries is one agent's three entries in the app's file as they +// stood, so a set the conversation refused puts them back exactly. +type appBudgetEntries struct { + budget float64 + hasBudget bool + none time.Time + hasNone bool + raise appRaise + hasRaise bool +} + +func appBudgetEntriesOf(f *appStateFile, id string) appBudgetEntries { + var e appBudgetEntries + e.budget, e.hasBudget = f.Budgets[id] + e.none, e.hasNone = f.Unlimited[id] + e.raise, e.hasRaise = f.Raises["agent:"+id] + return e +} + +func (e appBudgetEntries) restore(f *appStateFile, id string) { + delete(f.Budgets, id) + delete(f.Unlimited, id) + delete(f.Raises, "agent:"+id) + if e.hasBudget { + if f.Budgets == nil { + f.Budgets = map[string]float64{} + } + f.Budgets[id] = e.budget + } + if e.hasNone { + if f.Unlimited == nil { + f.Unlimited = map[string]time.Time{} + } + f.Unlimited[id] = e.none + } + if e.hasRaise { + if f.Raises == nil { + f.Raises = map[string]appRaise{} + } + f.Raises["agent:"+id] = e.raise + } +} diff --git a/cmd/codeaf/appbudget_test.go b/cmd/codeaf/appbudget_test.go new file mode 100644 index 0000000000..37f3264df4 --- /dev/null +++ b/cmd/codeaf/appbudget_test.go @@ -0,0 +1,420 @@ +package main + +import ( + "errors" + "os" + "path/filepath" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/config" +) + +// railConv is a fake conversation whose engine can say its limit, and can be +// told to refuse one figure. +type railConv struct { + *fakeConversation + refuse float64 + readErr error + refusals int +} + +func (r *railConv) SetSpendRail(usd float64) error { + if r.refuse != 0 && usd == r.refuse { + r.mu.Lock() + r.refusals++ + r.mu.Unlock() + return errors.New("the engine went away") + } + return r.fakeConversation.SetSpendRail(usd) +} + +func (r *railConv) SpendRail() (float64, error) { + if r.readErr != nil { + return 0, r.readErr + } + r.mu.Lock() + defer r.mu.Unlock() + return r.rail, nil +} + +func (r *railConv) railNow() float64 { + r.mu.Lock() + defer r.mu.Unlock() + return r.rail +} + +// budgetBackend is a backend over conv with its own app file and profile. +func budgetBackend(t *testing.T, conv appConversation) *appBackend { + t.Helper() + backend, _ := budgetRecordedBackend(t, conv) + return backend +} +func budgetRecordedBackend(t *testing.T, conv appConversation) (*appBackend, *appRecorder) { + t.Helper() + t.Setenv(config.ProfileDirEnv, t.TempDir()) + var mu sync.Mutex + var opens, releases int + open := func(ask appOpenAsk) (*appConn, error) { + mu.Lock() + opens++ + mu.Unlock() + return &appConn{Conv: conv, Workspace: ask.Workspace, Transcript: appFakeTranscript, Release: func() { mu.Lock(); releases++; mu.Unlock() }}, nil + } + rec := newAppRecorder() + backend := newFakeBackend(rec, open) + t.Cleanup(func() { _ = backend.Close() }) + backend.state = &appState{path: filepath.Join(t.TempDir(), appStateName)} + return backend, rec +} + +func budgetFigure(b appwire.AgentBudget) float64 { + if b.USD == nil { + return -1 + } + return *b.USD +} + +// A CUSTOM BUDGET IS THE AGENT'S, NOT THE DEFAULT'S. An agent made with $5 +// reads $5 of its own on every Agent the engine sends, its engine is bound +// $5, and changing settings.budget_usd moves neither. +func TestAgentBudgetCreatedCustomIsReadAsItsOwn(t *testing.T) { + conv := &railConv{fakeConversation: &fakeConversation{cost: 1.25}} + backend := budgetBackend(t, conv) + five := 5.0 + agent, err := mustCreate(t, backend, appwire.CreateAsk{Workspace: t.TempDir(), BudgetUSD: &five}) + if err != nil { + t.Fatal(err) + } + two := 2.0 + if _, err := backend.SetSettings(appwire.SettingsPatch{BudgetUSD: &appwire.Figure{USD: &two}}); err != nil { + t.Fatal(err) + } + listed, err := backend.ListAgents("") + if err != nil || len(listed) != 1 || listed[0].Budget == nil { + t.Fatalf("listed %+v, %v", listed, err) + } + if got := *listed[0].Budget; got.Source != appwire.BudgetSet || got.Limit != appwire.LimitLimited || budgetFigure(got) != 5 || *got.BaseUSD != 5 || got.Engine != nil || got.Scope != appwire.BudgetScopeAgent { + t.Fatalf("the list says %+v", got) + } + got, err := backend.AgentBudget(agent.ID) + if err != nil { + t.Fatal(err) + } + if got.From != appwire.BudgetFromRecord || budgetFigure(got) != 5 || got.Engine == nil || got.Engine.Limit != appwire.LimitLimited || *got.Engine.USD != 5 || got.Engine.SpentUSD != 1.25 { + t.Fatalf("get says %+v (engine %+v)", got, got.Engine) + } + if _, err := backend.AgentBudget("nobody"); err == nil { + t.Fatal("a budget for an agent that is not there") + } +} + +// SET, THEN NONE, THEN A FIGURE AGAIN: each is written, bound to the open +// conversation, read back the same, and a raise for today ends with a set. +func TestAgentBudgetSetBindsAndPersists(t *testing.T) { + conv := &railConv{fakeConversation: &fakeConversation{}} + backend := budgetBackend(t, conv) + five := 5.0 + agent, err := mustCreate(t, backend, appwire.CreateAsk{Workspace: t.TempDir(), BudgetUSD: &five}) + if err != nil { + t.Fatal(err) + } + if err := backend.RaiseCap(appwire.CapRaise{Scope: appwire.CapAgent, ID: agent.ID, ToUSD: 7, For: appwire.RaiseToday}); err != nil { + t.Fatal(err) + } + raised, _ := backend.AgentBudget(agent.ID) + if budgetFigure(raised) != 7 || *raised.BaseUSD != 5 || raised.RaisedUntil == nil || !raised.RaisedUntil.Equal(appMidnight(backend.now())) || conv.railNow() != 7 { + t.Fatalf("raised for today: %+v, rail %v", raised, conv.railNow()) + } + + nine := 9.0 + got, err := backend.SetAgentBudget(appwire.AgentBudgetSet{ID: agent.ID, USD: &nine}) + if err != nil { + t.Fatal(err) + } + if got.Source != appwire.BudgetSet || budgetFigure(got) != 9 || got.RaisedUntil != nil || conv.railNow() != 9 || *got.Engine.USD != 9 { + t.Fatalf("set 9: %+v, rail %v", got, conv.railNow()) + } + + got, err = backend.SetAgentBudget(appwire.AgentBudgetSet{ID: agent.ID}) + if err != nil { + t.Fatal(err) + } + if got.Source != appwire.BudgetUnlimited || got.Limit != appwire.LimitUnlimited || got.USD != nil || got.BaseUSD != nil || conv.railNow() != 0 || got.Engine.Limit != appwire.LimitUnlimited { + t.Fatalf("set none: %+v, rail %v", got, conv.railNow()) + } + if usd, set := backend.agentBudget(agent.ID, backend.now()); !set || usd != 0 { + t.Fatalf("an unlimited agent binds %v (%v)", usd, set) + } + // NOTHING TO RAISE FOR TODAY on an agent with no limit, and the refusal + // leaves it unlimited. + err = backend.RaiseCap(appwire.CapRaise{Scope: appwire.CapAgent, ID: agent.ID, ToUSD: 7, For: appwire.RaiseToday}) + if code := appwireCode(err); code != appwire.CodeBadParams { + t.Fatalf("raise today on none: %v", err) + } + if again, _ := backend.AgentBudget(agent.ID); again.Source != appwire.BudgetUnlimited || conv.railNow() != 0 { + t.Fatalf("the refused raise moved it: %+v", again) + } + // A raise for good is a figure the person chose. + if err := backend.RaiseCap(appwire.CapRaise{Scope: appwire.CapAgent, ID: agent.ID, ToUSD: 6, For: appwire.RaiseGood}); err != nil { + t.Fatal(err) + } + if again, _ := backend.AgentBudget(agent.ID); again.Source != appwire.BudgetSet || budgetFigure(again) != 6 || conv.railNow() != 6 { + t.Fatalf("raised for good from none: %+v", again) + } + + // Defence in depth behind the wire's own check. + for _, bad := range []float64{0, -1} { + usd := bad + if _, err := backend.SetAgentBudget(appwire.AgentBudgetSet{ID: agent.ID, USD: &usd}); appwireCode(err) != appwire.CodeBadParams { + t.Fatalf("set %v: %v", bad, err) + } + } + if again, _ := backend.AgentBudget(agent.ID); budgetFigure(again) != 6 { + t.Fatalf("a refused set moved it: %+v", again) + } +} + +// REOPENING RESTORES THE SAME CAP, NEVER TODAY'S DEFAULT. A conversation let +// go and opened again is bound the record's figure; a new process reading the +// same file reads and binds the same figures for a set and an unlimited agent. +func TestAgentBudgetSurvivesReopenAndRestart(t *testing.T) { + conv := &railConv{fakeConversation: &fakeConversation{}} + backend := budgetBackend(t, conv) + five := 5.0 + agent, err := mustCreate(t, backend, appwire.CreateAsk{Workspace: t.TempDir(), BudgetUSD: &five}) + if err != nil { + t.Fatal(err) + } + eight := 8.0 + if _, err := backend.SetAgentBudget(appwire.AgentBudgetSet{ID: agent.ID, USD: &eight}); err != nil { + t.Fatal(err) + } + three := 3.0 + if _, err := backend.SetSettings(appwire.SettingsPatch{BudgetUSD: &appwire.Figure{USD: &three}}); err != nil { + t.Fatal(err) + } + a, _ := backend.find(agent.ID) + backend.letGo(a) + if backend.liveOf(a) != nil { + t.Fatal("the conversation is still held") + } + if listed, _ := backend.AgentBudget(agent.ID); listed.Engine != nil || budgetFigure(listed) != 8 { + t.Fatalf("let go, it reads %+v", listed) + } + _ = conv.fakeConversation.SetSpendRail(123) + if _, err := backend.OpenAgent(agent.ID); err != nil { + t.Fatal(err) + } + if conv.railNow() != 8 { + t.Fatalf("reopened, its engine is bound %v", conv.railNow()) + } + + // A new process: the same file, a fresh engine with the default on it. + restarted := &appState{path: backend.state.where()} + b2 := budgetBackendAt(t, restarted) + fresh := &railConv{fakeConversation: &fakeConversation{rail: 3}} + if err := b2.bindBudget(agent.ID, fresh); err != nil || fresh.railNow() != 8 { + t.Fatalf("restarted, it binds %v (%v)", fresh.railNow(), err) + } + if _, err := backend.SetAgentBudget(appwire.AgentBudgetSet{ID: agent.ID}); err != nil { + t.Fatal(err) + } + b3 := budgetBackendAt(t, &appState{path: backend.state.where()}) + if err := b3.bindBudget(agent.ID, fresh); err != nil || fresh.railNow() != 0 { + t.Fatalf("restarted unlimited, it binds %v (%v)", fresh.railNow(), err) + } + record, _ := b3.budgetRecord(agent.ID) + if record.Source != appwire.BudgetUnlimited { + t.Fatalf("restarted, the record says %+v", record) + } +} + +func budgetBackendAt(t *testing.T, state *appState) *appBackend { + t.Helper() + b := newFakeBackend(newAppRecorder(), func(appOpenAsk) (*appConn, error) { return nil, errors.New("not opened") }) + t.Cleanup(func() { _ = b.Close() }) + b.state = state + return b +} + +// NOTHING OF ITS OWN IS NOT A GUESS. A legacy agent with no figure — or the +// zero an older file may hold — reads inherited and unknown, never the +// default; held, it reads its engine's own figure, said as the engine's; and +// an engine that cannot say leaves it unknown. +func TestAgentBudgetLegacyIsUnknownUntilTheEngineSays(t *testing.T) { + conv := &railConv{fakeConversation: &fakeConversation{rail: 4}} + backend := budgetBackend(t, conv) + agent, err := mustCreate(t, backend, appwire.CreateAsk{Workspace: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + two := 2.0 + if _, err := backend.SetSettings(appwire.SettingsPatch{BudgetUSD: &appwire.Figure{USD: &two}}); err != nil { + t.Fatal(err) + } + if err := backend.state.write(func(f *appStateFile) error { + f.Budgets = map[string]float64{agent.ID: 0} + return nil + }); err != nil { + t.Fatal(err) + } + record, _ := backend.budgetRecord(agent.ID) + if record.Source != appwire.BudgetInherited || record.Limit != appwire.LimitUnknown || record.USD != nil || record.From != appwire.BudgetFromRecord { + t.Fatalf("the record says %+v", record) + } + got, _ := backend.AgentBudget(agent.ID) + if got.Source != appwire.BudgetInherited || got.From != appwire.BudgetFromEngine || got.Limit != appwire.LimitLimited || budgetFigure(got) != 4 { + t.Fatalf("held, it says %+v", got) + } + conv.readErr = errors.New("no such method") + got, _ = backend.AgentBudget(agent.ID) + if got.Limit != appwire.LimitUnknown || got.USD != nil || got.Engine != nil { + t.Fatalf("an engine that cannot say: %+v", got) + } + // A conversation with no reading at all is the same. + plain := budgetBackend(t, &fakeConversation{rail: 4}) + other, err := mustCreate(t, plain, appwire.CreateAsk{Workspace: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + if got, _ := plain.AgentBudget(other.ID); got.Limit != appwire.LimitUnknown || got.Engine != nil { + t.Fatalf("no reader: %+v", got) + } +} + +// A REFUSED SET CHANGES NOTHING. One the disk will not take leaves the file +// and the engine as they were; one the conversation will not take is rolled +// back, the old figure still bound. +func TestAgentBudgetRefusedSetConserves(t *testing.T) { + conv := &railConv{fakeConversation: &fakeConversation{}, refuse: 9} + backend := budgetBackend(t, conv) + five := 5.0 + agent, err := mustCreate(t, backend, appwire.CreateAsk{Workspace: t.TempDir(), BudgetUSD: &five}) + if err != nil { + t.Fatal(err) + } + if err := backend.RaiseCap(appwire.CapRaise{Scope: appwire.CapAgent, ID: agent.ID, ToUSD: 6, For: appwire.RaiseToday}); err != nil { + t.Fatal(err) + } + before, _ := os.ReadFile(backend.state.where()) + + nine := 9.0 + _, err = backend.SetAgentBudget(appwire.AgentBudgetSet{ID: agent.ID, USD: &nine}) + if appwireCode(err) != appwire.CodeEngine { + t.Fatalf("a refused bind answered %v", err) + } + after, _ := os.ReadFile(backend.state.where()) + got, _ := backend.AgentBudget(agent.ID) + if conv.railNow() != 6 || budgetFigure(got) != 6 || *got.BaseUSD != 5 || got.RaisedUntil == nil || string(after) != string(before) { + t.Fatalf("rolled back to %+v, rail %v\nbefore %s\nafter %s", got, conv.railNow(), before, after) + } + + dir := filepath.Dir(backend.state.where()) + if err := os.Chmod(dir, 0o500); err != nil { + t.Fatal(err) + } + defer os.Chmod(dir, 0o700) + if probe, err := os.Create(filepath.Join(dir, "probe")); err == nil { + probe.Close() + t.Skip("this user can write a read-only folder") + } + seven := 7.0 + if _, err := backend.SetAgentBudget(appwire.AgentBudgetSet{ID: agent.ID, USD: &seven}); err == nil { + t.Fatal("a set the disk refused was answered") + } + if _, err := backend.SetAgentBudget(appwire.AgentBudgetSet{ID: agent.ID}); err == nil { + t.Fatal("a none the disk refused was answered") + } + still, _ := os.ReadFile(backend.state.where()) + got, _ = backend.AgentBudget(agent.ID) + if conv.railNow() != 6 || budgetFigure(got) != 6 || string(still) != string(before) { + t.Fatalf("a refused write moved it: %+v, rail %v", got, conv.railNow()) + } +} + +// A SET ANSWERS THE OPEN BUDGET ASK, and the agent.changed it says carries +// the new figure. +func TestAgentBudgetSetClosesTheAskAndSaysIt(t *testing.T) { + conv := &railConv{fakeConversation: &fakeConversation{cost: 4.5}} + t.Setenv(config.ProfileDirEnv, t.TempDir()) + rec := newAppRecorder() + backend := newFakeBackend(rec, func(ask appOpenAsk) (*appConn, error) { + return &appConn{Conv: conv, Workspace: ask.Workspace, Transcript: appFakeTranscript, Release: func() {}}, nil + }) + defer backend.Close() + backend.state = &appState{path: filepath.Join(t.TempDir(), appStateName)} + backend.today = func(time.Time) float64 { return 0 } + five := 5.0 + agent, err := mustCreate(t, backend, appwire.CreateAsk{Workspace: t.TempDir(), BudgetUSD: &five}) + if err != nil { + t.Fatal(err) + } + if _, err := backend.Send(agent.ID, "go on", ""); err != nil { + t.Fatal(err) + } + rec.waitFor(t, 5*time.Second, func(event string, data any) bool { + opened, ok := data.(appwire.AskOpened) + return ok && opened.Ask.Subkind == appwire.SubkindBudget + }) + twenty := 20.0 + if _, err := backend.SetAgentBudget(appwire.AgentBudgetSet{ID: agent.ID, USD: &twenty}); err != nil { + t.Fatal(err) + } + rec.waitFor(t, time.Second, func(event string, data any) bool { + closed, ok := data.(appwire.AskClosed) + return ok && closed.Option == appwire.OptionRaise + }) + rec.waitFor(t, time.Second, func(event string, data any) bool { + changed, ok := data.(appwire.AgentChanged) + return ok && changed.Agent.Budget != nil && budgetFigure(*changed.Agent.Budget) == 20 + }) + if asks, _ := backend.ListAsks(agent.ID); len(asks) != 0 { + t.Fatalf("asks still open: %+v", asks) + } +} + +func appwireCode(err error) string { + var w *appwire.Error + if errors.As(err, &w) { + return w.Code + } + return "" +} + +// A successful cap change is a real shared fact, not just the requesting +// window's RPC result. Ordinary later agent events retain that same cap. +func TestAgentBudgetChangedFactsReachOtherConsumers(t *testing.T) { + conv := &railConv{fakeConversation: &fakeConversation{}} + backend, rec := budgetRecordedBackend(t, conv) + five := 5.0 + agent, err := mustCreate(t, backend, appwire.CreateAsk{Workspace: t.TempDir(), BudgetUSD: &five}) + if err != nil { + t.Fatal(err) + } + if agent.Budget == nil || budgetFigure(*agent.Budget) != 5 { + t.Fatalf("created cap: %+v", agent.Budget) + } + eight := 8.0 + if _, err := backend.SetAgentBudget(appwire.AgentBudgetSet{ID: agent.ID, USD: &eight}); err != nil { + t.Fatal(err) + } + rec.waitFor(t, time.Second, func(event string, data any) bool { + changed, ok := data.(appwire.AgentChanged) + return ok && changed.Agent.ID == agent.ID && changed.Agent.Budget != nil && budgetFigure(*changed.Agent.Budget) == 8 + }) + if err := backend.RaiseCap(appwire.CapRaise{Scope: appwire.CapAgent, ID: agent.ID, ToUSD: 10, For: appwire.RaiseToday}); err != nil { + t.Fatal(err) + } + rec.waitFor(t, time.Second, func(event string, data any) bool { + changed, ok := data.(appwire.AgentChanged) + return ok && changed.Agent.ID == agent.ID && changed.Agent.Budget != nil && budgetFigure(*changed.Agent.Budget) == 10 && changed.Agent.Budget.RaisedUntil != nil + }) + a, _ := backend.find(agent.ID) + view := backend.sayAgent(a) + if view.Budget == nil || budgetFigure(*view.Budget) != 10 { + t.Fatalf("later agent event lost changed cap: %+v", view.Budget) + } +} diff --git a/cmd/codeaf/appmoney.go b/cmd/codeaf/appmoney.go index 6c6b4221eb..0baec36cfc 100644 --- a/cmd/codeaf/appmoney.go +++ b/cmd/codeaf/appmoney.go @@ -63,17 +63,17 @@ func appParseUSD(text string) (float64, bool) { } // agentBudget is the budget in force for one agent at now: the app's figure -// with any raise for today over it, or the profile's own conversation limit -// when the app never set one. set says the figure is the app's. +// with any raise for today over it, zero for an agent the person said has no +// limit, or the profile's own conversation limit when the app holds neither. +// set says the figure is the app's. func (b *appBackend) agentBudget(id string, now time.Time) (usd float64, set bool) { - _ = b.state.read(func(f *appStateFile) { - base, ok := f.Budgets[id] - if ok && base > 0 { - usd, set = f.raised("agent:"+id, base, now), true - } - }) - if set { - return usd, true + var record appwire.AgentBudget + _ = b.state.read(func(f *appStateFile) { record = f.agentBudgetAt(id, now) }) + switch record.Source { + case appwire.BudgetSet: + return *record.USD, true + case appwire.BudgetUnlimited: + return 0, true } return config.SpendRailUSDAt(config.ProfileDir()), false } @@ -368,6 +368,15 @@ func (b *appBackend) RaiseCap(raise appwire.CapRaise) error { if f.Budgets == nil { f.Budgets = map[string]float64{} } + if _, none := f.Unlimited[raise.ID]; none { + // AN AGENT WITH NO LIMIT HAS NOTHING TO RAISE for today: a + // figure until midnight would be a cap it does not have now. + // For good, the person has chosen a figure. + if raise.For == appwire.RaiseToday { + return appwire.Errorf(appwire.CodeBadParams, "this agent has no budget to raise; set one with agents.budget.set") + } + delete(f.Unlimited, raise.ID) + } if raise.For == appwire.RaiseToday { if _, ok := f.Budgets[raise.ID]; !ok { f.Budgets[raise.ID] = config.SpendRailUSDAt(config.ProfileDir()) @@ -390,6 +399,8 @@ func (b *appBackend) RaiseCap(raise appwire.CapRaise) error { return appEngineError(err) } } + view := b.stampAgentBudget(a) + b.emit.Emit(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) b.closeMoneyAsks(a, appwire.CapAgent, appwire.OptionRaise, "Budget raised to "+appUSD(raise.ToUSD)) b.retryRefused(a) return nil diff --git a/cmd/codeaf/appstate.go b/cmd/codeaf/appstate.go index df07221903..61be9b3279 100644 --- a/cmd/codeaf/appstate.go +++ b/cmd/codeaf/appstate.go @@ -58,6 +58,10 @@ type appStateFile struct { IdleCloseDays *int `json:"idle_close_days,omitempty"` Budgets map[string]float64 `json:"budgets,omitempty"` Raises map[string]appRaise `json:"raises,omitempty"` + // Unlimited is the agents a person said have no budget of their own, and + // when (appbudget.go). A zero in Budgets never meant that, so it is kept + // apart rather than read into a figure an older file already holds. + Unlimited map[string]time.Time `json:"budgets_unlimited,omitempty"` // ResumeAfterQuit is the agents the last quit stopped in the middle of a // reply, and QuitStopped the processes it stopped (appquit.go). // QuitReplies is those of them that were in the middle of a reply, and @@ -181,6 +185,10 @@ func (f appStateFile) clone() appStateFile { for k, v := range f.Raises { out.Raises[k] = v } + out.Unlimited = make(map[string]time.Time, len(f.Unlimited)) + for k, v := range f.Unlimited { + out.Unlimited[k] = v + } out.Closed = make(map[string]time.Time, len(f.Closed)) for k, v := range f.Closed { out.Closed[k] = v diff --git a/cmd/codeaf/apptidy.go b/cmd/codeaf/apptidy.go index 457bad0ceb..01e31e232f 100644 --- a/cmd/codeaf/apptidy.go +++ b/cmd/codeaf/apptidy.go @@ -339,6 +339,11 @@ type appSearchWords func(terms string, limit int) ([]store.ConversationHit, erro // only ever moved up from idle, never down from what a road said. func (b *appBackend) dress(view *appwire.Agent) { view.Crews = b.crewsOf(view.ID) + // Its own budget as the app's file keeps it (appbudget.go); the open + // conversation's own reading is agents.budget.get's, never a list's. + if budget, err := b.budgetRecord(view.ID); err == nil { + view.Budget = &budget + } b.locked(func() { a := b.agents[view.ID] if a == nil { diff --git a/internal/appwire/budget.go b/internal/appwire/budget.go new file mode 100644 index 0000000000..6118379efe --- /dev/null +++ b/internal/appwire/budget.go @@ -0,0 +1,185 @@ +package appwire + +import ( + "encoding/json" + "strings" + "time" +) + +// ONE AGENT'S OWN BUDGET, said as the engine holds it. +// +// settings.budget_usd is the profile's DEFAULT conversation limit, the figure +// a conversation with nothing of its own is built with. It is not any one +// agent's cap: an agent made with a budget, or raised through caps.raise, has +// its own figure, kept by the engine beside the conversation and bound again +// every time the conversation opens. [AgentBudget] is that figure, and where it +// came from, so a client never reads the default as an agent's cap. +// +// THREE SOURCES AND THREE LIMITS, NEVER CONFLATED. An agent's cap is its own +// (set), explicitly none (unlimited), or nothing of its own (inherited) — and +// what is in force is a figure, none, or not known. An inherited cap is the +// figure the conversation's engine read when it opened, which only that engine +// can say; with nothing holding the conversation it is unknown, and a client +// says so rather than guessing from today's default. +// +// THE CAP IS THE AGENT'S AND NOTHING ELSE'S. It is measured against the +// conversation's own spend over its whole life — the journaled figure, kept +// across reopens — and checked before each turn. A task's cap (task_cap_usd), +// the day cap and a task's role models are other limits with other doors. + +// The scope every [AgentBudget] is in. +const BudgetScopeAgent = "agent" + +// Where an agent's budget comes from. +const ( + BudgetSet = "set" + BudgetUnlimited = "unlimited" + BudgetInherited = "inherited" +) + +// What is in force: a figure, none, or not known. +const ( + LimitLimited = "limited" + LimitUnlimited = "unlimited" + LimitUnknown = "unknown" +) + +// Which reading Limit and USD were taken from. +const ( + BudgetFromRecord = "record" + BudgetFromEngine = "engine" +) + +// AgentBudget is one agent's own budget. USD is present only when Limit is +// limited, BaseUSD only when Source is set, and RaisedUntil only while a raise +// for today lifts USD above BaseUSD. Engine is the open conversation's own +// reading, and only agents.budget.get and agents.budget.set carry it. +type AgentBudget struct { + Scope string `json:"scope" enum:"agent"` + Source string `json:"source" enum:"set,unlimited,inherited"` + Limit string `json:"limit" enum:"limited,unlimited,unknown"` + USD *float64 `json:"usd,omitempty"` + BaseUSD *float64 `json:"base_usd,omitempty"` + RaisedUntil *time.Time `json:"raised_until,omitempty"` + From string `json:"from" enum:"record,engine"` + Engine *AgentBudgetEngine `json:"engine,omitempty"` +} + +// AgentBudgetEngine is what the open conversation checks its next turn +// against, and the spend it measures that against. +type AgentBudgetEngine struct { + Limit string `json:"limit" enum:"limited,unlimited"` + USD *float64 `json:"usd,omitempty"` + SpentUSD float64 `json:"spent_usd"` +} + +// AgentBudgetSet is agents.budget.set as the engine is handed it: a positive +// figure, or nil for explicitly none. +type AgentBudgetSet struct { + ID string + USD *float64 +} + +// AgentBudgetSetWire is agents.budget.set's params as they travel. usd is +// required: a positive amount, or null for none. Zero is refused rather than +// read as none, because a client that meant none can say so. +type AgentBudgetSetWire struct { + ID string `json:"id"` + USD *float64 `json:"usd" nullable:"true"` +} + +// AgentBudgetResult is both methods' answer. +type AgentBudgetResult struct { + Budget AgentBudget `json:"budget"` +} + +// Budgeter is the engine's half of an agent's own budget. An [Engine] that is +// not one refuses agents.budget.* as unknown_method. +type Budgeter interface { + AgentBudget(id string) (AgentBudget, error) + SetAgentBudget(set AgentBudgetSet) (AgentBudget, error) +} + +func init() { + for name, m := range budgetMethods { + methods[name] = m + } +} + +func budgeter(e Engine, name string) (Budgeter, error) { + b, ok := e.(Budgeter) + if !ok { + return nil, Errorf(CodeUnknownMethod, "this engine does not serve %s", name) + } + return b, nil +} + +// decodeBudgetSet reads agents.budget.set's params, which is the one place +// a missing usd and a null one differ. +func decodeBudgetSet(raw json.RawMessage) (AgentBudgetSet, error) { + var fields map[string]json.RawMessage + if err := decode(raw, &fields); err != nil { + return AgentBudgetSet{}, err + } + var set AgentBudgetSet + for name, value := range fields { + switch name { + case "id": + if err := json.Unmarshal(value, &set.ID); err != nil { + return AgentBudgetSet{}, Errorf(CodeBadParams, "id must be a string") + } + case "usd": + default: + return AgentBudgetSet{}, Errorf(CodeBadParams, "agents.budget.set takes id and usd, not %s", name) + } + } + if strings.TrimSpace(set.ID) == "" { + return AgentBudgetSet{}, Errorf(CodeBadParams, "id is required") + } + value, ok := fields["usd"] + if !ok { + return AgentBudgetSet{}, Errorf(CodeBadParams, "usd is required: a positive amount, or null for none") + } + if strings.TrimSpace(string(value)) == "null" { + return set, nil + } + var usd float64 + if err := json.Unmarshal(value, &usd); err != nil || !money(usd) { + return AgentBudgetSet{}, Errorf(CodeBadParams, "usd must be a positive amount, or null for none") + } + set.USD = &usd + return set, nil +} + +var budgetMethods = map[string]method{ + "agents.budget.get": typed(func(e Engine, p IDParams) (*AgentBudgetResult, error) { + b, err := budgeter(e, "agents.budget.get") + if err != nil { + return nil, err + } + id, err := required("id", p.ID) + if err != nil { + return nil, err + } + budget, err := b.AgentBudget(id) + if err != nil { + return nil, err + } + return &AgentBudgetResult{budget}, nil + }), + "agents.budget.set": shaped[AgentBudgetSetWire](func(e Engine, raw json.RawMessage) (*AgentBudgetResult, error) { + b, err := budgeter(e, "agents.budget.set") + if err != nil { + return nil, err + } + set, err := decodeBudgetSet(raw) + if err != nil { + return nil, err + } + budget, err := b.SetAgentBudget(set) + if err != nil { + return nil, err + } + return &AgentBudgetResult{budget}, nil + }), +} diff --git a/internal/appwire/budget_test.go b/internal/appwire/budget_test.go new file mode 100644 index 0000000000..7078bf99b9 --- /dev/null +++ b/internal/appwire/budget_test.go @@ -0,0 +1,105 @@ +package appwire + +import ( + "encoding/json" + "strings" + "testing" +) + +type budgetEngine struct { + fakeEngine + got []AgentBudgetSet +} + +func (b *budgetEngine) AgentBudget(id string) (AgentBudget, error) { + return AgentBudget{Scope: BudgetScopeAgent, Source: BudgetInherited, Limit: LimitUnknown, From: BudgetFromRecord}, nil +} + +func (b *budgetEngine) SetAgentBudget(set AgentBudgetSet) (AgentBudget, error) { + b.got = append(b.got, set) + out := AgentBudget{Scope: BudgetScopeAgent, Source: BudgetUnlimited, Limit: LimitUnlimited, From: BudgetFromRecord} + if set.USD != nil { + out.Source, out.Limit, out.USD, out.BaseUSD = BudgetSet, LimitLimited, set.USD, set.USD + } + return out, nil +} + +// AN ENGINE WITHOUT THE CAPABILITY DOES NOT HAVE THE VERB. +func TestAgentBudgetAbsentWithoutCapability(t *testing.T) { + for name := range budgetMethods { + _, err := methods[name].call(&fakeEngine{}, json.RawMessage(`{"id":"a","usd":5}`)) + if wireCode(t, err) != CodeUnknownMethod { + t.Fatalf("%s: %v", name, err) + } + } +} + +// ZERO IS NOT NONE, AND A MISSING FIGURE IS NOT NONE EITHER: only null clears +// the cap, and every other shape that is not a positive amount is refused +// before the engine is asked. +func TestAgentBudgetSetValidates(t *testing.T) { + eng := &budgetEngine{} + for _, params := range []string{ + `{}`, `{"usd":5}`, `{"id":" ","usd":5}`, `{"id":"a"}`, `{"id":"a","usd":0}`, `{"id":"a","usd":-1}`, + `{"id":"a","usd":"8"}`, `{"id":"a","usd":true}`, `{"id":"a","usd":5,"for":"today"}`, `{"id":7,"usd":5}`, + } { + _, err := methods["agents.budget.set"].call(eng, json.RawMessage(params)) + if wireCode(t, err) != CodeBadParams { + t.Fatalf("%s: %v", params, err) + } + } + if len(eng.got) != 0 { + t.Fatalf("a refused set reached the engine: %+v", eng.got) + } + if _, err := methods["agents.budget.get"].call(eng, json.RawMessage(`{}`)); wireCode(t, err) != CodeBadParams { + t.Fatalf("a get with no id: %v", err) + } + + out, err := methods["agents.budget.set"].call(eng, json.RawMessage(`{"id":"a","usd":null}`)) + if err != nil || len(eng.got) != 1 || eng.got[0].ID != "a" || eng.got[0].USD != nil { + t.Fatalf("null = %+v, %v", eng.got, err) + } + if got := out.(*AgentBudgetResult).Budget; got.Limit != LimitUnlimited || got.USD != nil { + t.Fatalf("null answered %+v", got) + } + out, err = methods["agents.budget.set"].call(eng, json.RawMessage(`{"id":"a","usd":7.5}`)) + if err != nil || len(eng.got) != 2 || eng.got[1].USD == nil || *eng.got[1].USD != 7.5 { + t.Fatalf("7.5 = %+v, %v", eng.got, err) + } + raw, _ := json.Marshal(out) + if string(raw) != `{"budget":{"scope":"agent","source":"set","limit":"limited","usd":7.5,"base_usd":7.5,"from":"record"}}` { + t.Fatalf("the answer travels as %s", raw) + } +} + +// The schema carries both methods, the Agent's optional budget, and its enums. +func TestAgentBudgetInTheSchema(t *testing.T) { + raw, err := SchemaJSON() + if err != nil { + t.Fatal(err) + } + var doc struct { + Methods map[string]json.RawMessage `json:"methods"` + Defs map[string]json.RawMessage `json:"$defs"` + } + if err := json.Unmarshal(raw, &doc); err != nil { + t.Fatal(err) + } + for _, name := range []string{"agents.budget.get", "agents.budget.set"} { + if doc.Methods[name] == nil { + t.Errorf("schema lacks %s", name) + } + } + if params := string(doc.Defs["in.AgentBudgetSetWire"]); !strings.Contains(params, `"null"`) { + t.Errorf("agents.budget.set's usd is not nullable: %s", params) + } + if agent := string(doc.Defs["Agent"]); !strings.Contains(agent, `"budget"`) { + t.Errorf("Agent lacks budget: %s", agent) + } + budget := string(doc.Defs["AgentBudget"]) + for _, word := range []string{`"inherited"`, `"unknown"`, `"engine"`} { + if !strings.Contains(budget, word) { + t.Errorf("AgentBudget lacks %s: %s", word, budget) + } + } +} diff --git a/internal/appwire/wire.go b/internal/appwire/wire.go index 93c7447ebb..51568f0588 100644 --- a/internal/appwire/wire.go +++ b/internal/appwire/wire.go @@ -131,6 +131,9 @@ type Agent struct { // Context is what the conversation weighs against its window // (compaction.go); absent when nothing holds the conversation. Context *AgentContext `json:"context,omitempty"` + // Budget is this agent's own spend limit (budget.go), which is never + // settings.budget_usd; absent from an engine that does not say. + Budget *AgentBudget `json:"budget,omitempty"` } // Task is one node of an agent's task graph. diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index bba46290eb..c0fbcd3e90 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -4337,3 +4337,15 @@ is a pin, and the allowed models are the same rule. - Unticking a pinned model keeps the pin; the role says it is pinned to a model that is not allowed, and is picked by Auto until you allow it again or unpin it. + +## One conversation's budget + +An individual conversation limit measures its lifetime spend, including earlier turns restored +from its journal; it is separate from the daily budget and a task's cap. In the desktop app, +Agent → Set Budget changes that agent's limit, or explicitly sets no limit. The saved choice +survives closing and reopening the agent. Work already running is not cut short; the next turn +checks the updated limit. A raise for today lasts until the next local midnight. + +The Agent panel shows a recorded or observed individual cap, never the current global default +as a guess. If an older conversation has no saved individual limit and is not open, its cap +can remain unknown until its engine answers. Changing the default is a separate Settings action. diff --git a/internal/remote/callclass.go b/internal/remote/callclass.go index 1a2763b554..9eef67497e 100644 --- a/internal/remote/callclass.go +++ b/internal/remote/callclass.go @@ -147,7 +147,7 @@ const lateCallTail = " did not answer in time" func classify(method string) callClass { switch method { case MethodAppTasks, MethodAppTask, MethodAppTaskRouting, MethodAppTaskRoutingOptions, MethodAppQuickTasks, MethodPing, - MethodModel, MethodTitle, MethodUsage, MethodContextTokens, + MethodModel, MethodTitle, MethodUsage, MethodContextTokens, MethodSpendRail, MethodTranscript, MethodEarlier, MethodRewindPoints, MethodPlanSpend, MethodPlanTasks, MethodPlanTaskPage, MethodPlanTaskWork, MethodPlanRunSummary, MethodRefreshRunSummary, MethodReasoningFor, MethodEffort, MethodResolvedEffort, MethodResolvedApproval, diff --git a/internal/remote/client.go b/internal/remote/client.go index 0e07ffe934..076b076e74 100644 --- a/internal/remote/client.go +++ b/internal/remote/client.go @@ -2004,6 +2004,24 @@ func (a *Agent) SetSpendRail(usd float64) error { return err } +// SpendRail reads the conversation limit the engine checks its next turn +// against, zero for none. An engine too old to say answers an error, which a +// caller reports as not knowing rather than as no limit. +func (a *Agent) SpendRail() (float64, error) { + raw, err := a.c.call(nil, MethodSpendRail, nil) + if err != nil { + return 0, err + } + var usd *float64 + if err := json.Unmarshal(raw, &usd); err != nil { + return 0, err + } + if usd == nil || *usd < 0 { + return 0, errors.New("conversation limit response is not a non-negative amount") + } + return *usd, nil +} + // SetContextWindow is deliberately a no-op here. The surface's catalog belongs // to the laptop; SetModel makes the engine consult its own catalog and move its // own compaction point. The method remains on the interface for local agents diff --git a/internal/remote/server.go b/internal/remote/server.go index 15a160ace5..752abfcece 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -2766,6 +2766,13 @@ func (s *server) invoke(call Frame) (out json.RawMessage, err error) { } return nil, binder.SetSpendRail(usd) + case MethodSpendRail: + reader, ok := agent.(interface{ SpendRail() float64 }) + if !ok { + return nil, errors.New("conversation limit cannot be read here") + } + return json.Marshal(reader.SpendRail()) + case MethodSetContext: tokens, err := arg[int](call) if err != nil { diff --git a/internal/remote/spend_rail_test.go b/internal/remote/spend_rail_test.go index 05038d96d5..256f4cb5f7 100644 --- a/internal/remote/spend_rail_test.go +++ b/internal/remote/spend_rail_test.go @@ -14,6 +14,8 @@ func (a *spendRailAgent) SetSpendRail(usd float64) error { return nil } +func (a *spendRailAgent) SpendRail() float64 { return a.rail } + // The normal engine road sends the live limit through its real protocol before // the surface can report that the conversation now holds it. func TestOpenEngineRoadBindsConversationSpendRail(t *testing.T) { @@ -31,4 +33,15 @@ func TestOpenEngineRoadBindsConversationSpendRail(t *testing.T) { if far.rail != 1.5 { t.Fatalf("engine holds $%.2f, want $1.50", far.rail) } + value, err := loop.Client.Agent().SpendRail() + if err != nil || value != 1.5 { + t.Fatalf("live read %v: %v", value, err) + } + if err := loop.Client.Agent().SetSpendRail(0); err != nil { + t.Fatal(err) + } + value, err = loop.Client.Agent().SpendRail() + if err != nil || value != 0 { + t.Fatalf("unlimited read %v: %v", value, err) + } } diff --git a/internal/remote/wire.go b/internal/remote/wire.go index 4db1e5a384..ddb10b3b72 100644 --- a/internal/remote/wire.go +++ b/internal/remote/wire.go @@ -520,6 +520,7 @@ const ( MethodModel = "Model" // nothing → string MethodSetModel = "SetModel" // string → nothing MethodSetSpendRail = "SetSpendRail" // dollars → nothing + MethodSpendRail = "SpendRail" // nothing → dollars, zero for none; an older engine answers "no such method" MethodSetContext = "SetContextWindow" // legacy version-5 hint; current remote surfaces do not send it MethodReasoningFor = "ReasoningFor" // string → string MethodSetReasoningFor = "SetReasoningFor" // ReasoningArgs → nothing diff --git a/internal/session/rail.go b/internal/session/rail.go index d88568011c..0bd7204124 100644 --- a/internal/session/rail.go +++ b/internal/session/rail.go @@ -44,6 +44,13 @@ func (a *Agent) SetSpendRail(usd float64) error { return nil } +// SpendRail is the conversation limit this session checks its next turn +// against, and zero for none: the figure a surface last bound, or the one the +// session was built with. It is the engine's own fact, so a surface that keeps +// a record of its own can say what is actually in force rather than what it +// meant to bind. +func (a *Agent) SpendRail() float64 { return a.spendRailUSD() } + func (a *Agent) spendRailUSD() float64 { if a.liveSpendRailSet.Load() { return math.Float64frombits(a.liveSpendRail.Load()) From eb0669e2d77c68f7ad11ba7be8310f9693db5892 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 21:20:54 -0400 Subject: [PATCH 084/459] session: a person's message keeps the reads its words were written from A door that expands a reference (the app's collections.expand of a picked collection) knew at send time how many references came back, how many were gone, unaskable, unread or cut, and the first few it returned. The record kept none of it, and asking the collection again tomorrow answers today's question. Submission.Reads []MessageRead carries those observed facts: Source and Items are MessageReference{Kind, ID, Name, Owner, Path, URL} descriptors, never content; Count, Missing, Unavailable, Unread, More and Complete are the door's own. They ride the existing presentation mark ("reads") on the message's own line, so steers, compaction re-journaling and reopen keep them with no new line type. PersonMark.Reads reads them back, deep-copied by every shaping and by the record cache's clone, and counted by its cost. DisplayEntry stays 288 bytes. Bounds are checked on the bytes given and refuse the message whole before anything is queued or journaled (ErrMessageMark, now exported): 16 reads, 8 items and 8 warnings per read, 4096-byte fields, 512-byte warnings, counts 0..1e6, items <= count, 128 KiB of read strings, and no Complete while anything was unavailable, unread or cut. Missing alone may be complete. Nothing is trimmed to fit. SteerMessage and FollowUpMessage are Steer and FollowUp with the same mark; a steer that falls through now carries its mark into the message it becomes. The model is sent exactly the text it was sent before. Co-Authored-By: Claude Opus 5.5 --- internal/session/agent.go | 35 ++- internal/session/messageread_test.go | 354 +++++++++++++++++++++++++++ internal/session/presentation.go | 6 +- internal/session/recordcache.go | 24 +- internal/session/steer.go | 30 ++- internal/session/submission.go | 221 ++++++++++++++++- internal/session/submission_test.go | 6 +- 7 files changed, 653 insertions(+), 23 deletions(-) create mode 100644 internal/session/messageread_test.go diff --git a/internal/session/agent.go b/internal/session/agent.go index dafcd80cfe..4b630d38f8 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -2036,6 +2036,24 @@ func (a *Agent) startTurnLocked(ctx context.Context, user userMessage, watcher * // turn end coming to drain it, and a message that sat in a queue until the // person happened to ask something else would be a silent hold. func (a *Agent) FollowUp(text string) (<-chan Event, error) { + return a.followUpMarked(text, nil) +} + +// FollowUpMessage is [Agent.FollowUp] for a door that knows more about the +// message than its words, as [Agent.SubmitMessage] is [Agent.Submit]: the +// model reads Text when its turn comes, and the record keeps the mark on the +// message's own line. A mark that does not fit refuses the message whole, +// before anything is queued. +func (a *Agent) FollowUpMessage(message Submission) (<-chan Event, error) { + mark, err := message.mark() + if err != nil { + return nil, err + } + return a.followUpMarked(message.Text, mark) +} + +// followUpMarked is FollowUp's body with the person's mark beside the words. +func (a *Agent) followUpMarked(text string, person *messagePresentation) (<-chan Event, error) { text = strings.TrimSpace(text) if text == "" { return nil, errors.New("session: empty message") @@ -2050,14 +2068,16 @@ func (a *Agent) FollowUp(text string) (<-chan Event, error) { return nil, errWorkStopping } stream := newEventStream() + user := userText(text) + user.person = person if a.running { - a.followups = append(a.followups, followUp{message: userText(text), stream: stream}) + a.followups = append(a.followups, followUp{message: user, stream: stream}) return stream.out, nil } if err := a.railBlockLocked(); err != nil { return refuseOn(stream, err), nil } - return a.startTurnLocked(context.Background(), userText(text), stream), nil + return a.startTurnLocked(context.Background(), user, stream), nil } // followUp is one queued message and the stream its turn will speak on. The @@ -4884,6 +4904,10 @@ type PersonMark struct { // Attachments are what rode on the message, in the order it carried them, // each a copy of the record's own descriptor. Attachments []MessageAttachment + // Reads are the reads of references the message's words were written + // from, as the door observed them when it sent the message — never looked + // up again — each a copy of the record's own. + Reads []MessageRead } // SteerMark is what the record keeps about one steer that LANDED: when the @@ -5024,10 +5048,11 @@ func shapeEntries(messages []ai.Message, journal *sessionFile, indexes ...*prese displayText = presentation.personWords(msg) // AND WHAT THE PERSON SENT IS SHOWN AS THEY SENT IT, where the door // that sent it said so on the line itself: their own words in place - // of the paragraph the model was handed, and the attachments that - // rode on it. A line with no such mark is shown as it always was. + // of the paragraph the model was handed, the attachments that rode + // on it and the reads its words were written from. A line with no + // such mark is shown as it always was. if mark := presentation.of(msg).person(); mark != nil { - person = &PersonMark{Attachments: cloneAttachments(mark.Attachments)} + person = &PersonMark{Attachments: cloneAttachments(mark.Attachments), Reads: cloneReads(mark.Reads)} if mark.Text != nil { displayText, person.Authored = *mark.Text, true } diff --git a/internal/session/messageread_test.go b/internal/session/messageread_test.go new file mode 100644 index 0000000000..e028014e02 --- /dev/null +++ b/internal/session/messageread_test.go @@ -0,0 +1,354 @@ +package session + +import ( + "context" + "errors" + "os" + "path/filepath" + "reflect" + "strconv" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/provider" +) + +// readsOf is what an entry says its words were written from. +func readsOf(entry DisplayEntry) []MessageRead { + if entry.Person == nil { + return nil + } + return entry.Person.Reads +} + +// wholeRead is a read of two hundred references with one gone: complete, as a +// read that counted what was gone is, and naming the first eight it returned. +func wholeRead() MessageRead { + read := MessageRead{ + Source: MessageReference{Kind: "collection", ID: "c1", Name: "Specs"}, + Count: 200, + Missing: 1, + Complete: true, + Warnings: []string{"one reference is gone"}, + } + for i := 1; i <= MaxReadItems; i++ { + n := strconv.Itoa(i) + read.Items = append(read.Items, MessageReference{Kind: "file", ID: "r" + n, Name: "spec" + n + ".md", Owner: "fs", Path: "docs/spec" + n + ".md"}) + } + return read +} + +// partialRead is a read that could not ask about two references and was cut. +func partialRead() MessageRead { + return MessageRead{ + Source: MessageReference{Kind: "collection", ID: "c2", Name: "Links"}, + Count: 3, + Unavailable: 2, + Unread: 1, + More: true, + Items: []MessageReference{{Kind: "link", URL: "https://example.com/a"}}, + } +} + +// WHAT A DOOR READ TO WRITE A MESSAGE IS KEPT ON THE MESSAGE'S OWN LINE, AS IT +// READ IT. A message written from two reads — one whole, one partial — sends the +// model exactly its text; the live transcript, the record at rest, the record's +// cache and the conversation reopened from the file all say the person's words +// and both reads with every count the door observed; and no reading shares a +// slice with any other, so a caller's scribble reaches no later one. +func TestAMessagesReadsAreKeptOnItsLineAndReadBackAfterReopen(t *testing.T) { + path := filepath.Join(t.TempDir(), "session.jsonl") + completer := answering() + agent, _ := newTestAgent(t, completer, func(c *Config) { c.SessionFile = path }) + said := "compare these specs" + sent := "compare these specs\n\nSpecs:\n- docs/spec1.md\n- docs/spec2.md" + given := []MessageRead{wholeRead(), partialRead()} + events, err := agent.SubmitMessage(context.Background(), Submission{Text: sent, Said: &said, Reads: given}) + if err != nil { + t.Fatal(err) + } + collect(t, events) + // The caller's reads are its own: changing them afterwards reaches no record. + given[0].Count, given[0].Items[0].Name, given[0].Warnings[0] = 7, "changed", "changed" + + want := []MessageRead{wholeRead(), partialRead()} + assert := func(where string, entries []DisplayEntry) { + t.Helper() + users := personEntries(entries) + if len(users) != 1 || users[0].Text != said || !authored(users[0]) { + t.Fatalf("%s: the person's messages = %#v", where, users) + } + if got := readsOf(users[0]); !reflect.DeepEqual(got, want) { + t.Fatalf("%s: reads = %#v, want %#v", where, got, want) + } + } + completer.mu.Lock() + first := completer.seen[0] + completer.mu.Unlock() + if got := messageContentText(first[len(first)-1]); got != sent { + t.Fatalf("the model was sent %q, want %q", got, sent) + } + live := agent.Transcript() + assert("live", live) + scribble := func(entries []DisplayEntry) { + reads := readsOf(personEntries(entries)[0]) + reads[0].Count, reads[0].Items[0].Name, reads[0].Warnings[0] = 9, "scribbled", "scribbled" + } + scribble(live) + assert("read again", agent.Transcript()) + if err := agent.Close(); err != nil { + t.Fatal(err) + } + + atRest := ReadTranscript(path) + assert("at rest", atRest.Entries) + scribble(atRest.Entries) + assert("from the record's cache", ReadTranscript(path).Entries) + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + assert("bytes", ReadTranscriptBytes(data).Entries) + marks := journalMarks(t, path) + if len(marks) != 1 || marks[0] == nil || marks[0]["reads"] == nil || marks[0]["attachments"] != nil { + t.Fatalf("the person's line carries %v", marks) + } + + reopened, _ := newTestAgent(t, answering(), func(c *Config) { c.SessionFile = path }) + assert("reopened", reopened.Transcript()) + reopened.mu.Lock() + replayed := messageContentText(reopened.messages[1]) + reopened.mu.Unlock() + if replayed != sent { + t.Fatalf("the reopened conversation reads %q to the model, want %q", replayed, sent) + } +} + +// A READ THAT DOES NOT FIT REFUSES THE MESSAGE WHOLE, and nothing of it is +// trimmed to fit: no line is written, the model is asked nothing, and the +// session takes the next message. A whole read that counted references that +// were gone is not one of them. +func TestReadsThatDoNotFitRefuseTheMessageWhole(t *testing.T) { + path := filepath.Join(t.TempDir(), "session.jsonl") + completer := answering() + agent, _ := newTestAgent(t, completer, func(c *Config) { c.SessionFile = path }) + with := func(change func(*MessageRead)) []MessageRead { + read := wholeRead() + change(&read) + return []MessageRead{read} + } + many := make([]MessageRead, MaxMessageReads+1) + for i := range many { + many[i] = partialRead() + } + heavy := make([]MessageRead, MaxMessageReads) + for i := range heavy { + heavy[i] = MessageRead{Source: MessageReference{Kind: "collection", Path: strings.Repeat("p", maxAttachmentField)}, Count: 2, + Items: []MessageReference{{Kind: "file", Path: strings.Repeat("q", maxAttachmentField)}, {Kind: "file", Path: strings.Repeat("r", maxAttachmentField)}}} + } + for name, reads := range map[string][]MessageRead{ + "too many reads": many, + "too many items": with(func(r *MessageRead) { r.Items = append(r.Items, r.Items[0]) }), + "too many warnings": with(func(r *MessageRead) { r.Warnings = make([]string, MaxReadWarnings+1); r.Warnings[0] = "w" }), + "a negative count": with(func(r *MessageRead) { r.Missing = -1 }), + "a count past bound": with(func(r *MessageRead) { r.Count = MaxReadCount + 1 }), + "more items than read": with(func(r *MessageRead) { r.Count = 3 }), + "complete, unavail": with(func(r *MessageRead) { r.Unavailable = 1 }), + "complete, unread": with(func(r *MessageRead) { r.Unread = 1 }), + "complete, cut": with(func(r *MessageRead) { r.More = true }), + "a source of no kind": with(func(r *MessageRead) { r.Source.Kind = " " }), + "a nameless source": with(func(r *MessageRead) { r.Source = MessageReference{Kind: "collection", Owner: "me"} }), + "a nameless item": with(func(r *MessageRead) { r.Items[3] = MessageReference{Kind: "file"} }), + "a long field": with(func(r *MessageRead) { r.Items[0].Path = strings.Repeat("p", maxAttachmentField+1) }), + "a padded field": with(func(r *MessageRead) { r.Source.Name = strings.Repeat(" ", maxAttachmentField) + "Specs" }), + "a long warning": with(func(r *MessageRead) { r.Warnings[0] = strings.Repeat("w", maxReadWarning+1) }), + "an empty warning": with(func(r *MessageRead) { r.Warnings[0] = " " }), + "a warning not text": with(func(r *MessageRead) { r.Warnings[0] = "\xff" }), + "too many bytes": heavy, + } { + if _, err := agent.SubmitMessage(context.Background(), Submission{Text: "x", Reads: reads}); !errors.Is(err, ErrMessageMark) { + t.Fatalf("%s: SubmitMessage = %v", name, err) + } + if _, err := agent.FollowUpMessage(Submission{Text: "x", Reads: reads}); !errors.Is(err, ErrMessageMark) { + t.Fatalf("%s: FollowUpMessage = %v", name, err) + } + if _, err := agent.SteerMessage(Submission{Text: "x", Reads: reads}); !errors.Is(err, ErrMessageMark) { + t.Fatalf("%s: SteerMessage = %v", name, err) + } + } + completer.mu.Lock() + asked := len(completer.seen) + completer.mu.Unlock() + if asked != 0 || len(personEntries(agent.Transcript())) != 0 { + t.Fatalf("a refused message reached the model %d times or the record", asked) + } + events, err := agent.SubmitMessage(context.Background(), Submission{Text: "x", Reads: []MessageRead{wholeRead()}}) + if err != nil { + t.Fatalf("a whole read that counted gone references = %v", err) + } + collect(t, events) + if err := agent.Close(); err != nil { + t.Fatal(err) + } + marks := journalMarks(t, path) + if len(marks) != 1 || marks[0]["reads"] == nil { + t.Fatalf("the journal holds %v", marks) + } + users := personEntries(ReadTranscript(path).Entries) + // Reads alone do not make the words someone else's: Text was theirs. + if len(users) != 1 || users[0].Text != "x" || authored(users[0]) || !reflect.DeepEqual(readsOf(users[0]), []MessageRead{wholeRead()}) { + t.Fatalf("read back %#v", users) + } +} + +// A READ SURVIVES EVERY OTHER WAY A PERSON'S LINE IS WRITTEN: a correction +// spliced into a running turn, a correction that fell through and became a +// message of its own, a follow-up, and a compaction pass re-journaling the +// window — including the skills-free copy the pass writes in a message's place. +func TestReadsSurviveSteeringFallThroughFollowUpAndCompaction(t *testing.T) { + path := filepath.Join(t.TempDir(), "session.jsonl") + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { c.SessionFile = path }) + read := func(id string) []MessageRead { + return []MessageRead{{Source: MessageReference{Kind: "collection", ID: id}, Count: 1, Complete: true, Items: []MessageReference{{Kind: "file", Path: id + ".go"}}}} + } + mark := func(said, id string) *messagePresentation { + m, err := Submission{Said: &said, Reads: read(id)}.mark() + if err != nil { + t.Fatal(err) + } + return m + } + block := "\n\nuse them" + skilled := userText("check a.go" + block) + skilled.said = "check a.go" + skilled.person = mark("check", "a") + agent.presentation.remember(skilled.message, &messagePresentation{SkillsBlock: block}) + steer := userText("also b.go") + steer.person = mark("also", "b") + steer.crossed = &SteerMark{Consumed: true, Landing: "read at the next step"} + + // The fallen-through correction is lifted out of the steering queue the + // way a finished turn lifts it, and drained as the follow-up it becomes. + fell := steerMessage(&turnSteer{note: SteerNote{ID: 1, Words: "then c.go"}, stream: newEventStream()}) + fell.person = mark("then", "c") + agent.mu.Lock() + agent.recordUserLocked(skilled) + agent.recordUserLocked(steer) + agent.steering = append(agent.steering, fell) + agent.liftSteersLocked(newEventHub()) + if len(agent.followups) != 1 || agent.followups[0].message.person == nil { + agent.mu.Unlock() + t.Fatalf("the fallen-through correction became %#v", agent.followups) + } + became := agent.followups[0].message + agent.followups = nil + agent.recordUserLocked(became) + agent.mu.Unlock() + + want := []struct{ text, id string }{{"check", "a"}, {"also", "b"}, {"then", "c"}} + assert := func(where string, entries []DisplayEntry) { + t.Helper() + users := personEntries(entries) + if len(users) != len(want) { + t.Fatalf("%s: %#v", where, users) + } + for i, w := range want { + if users[i].Text != w.text || !authored(users[i]) || !reflect.DeepEqual(readsOf(users[i]), read(w.id)) { + t.Fatalf("%s: message %d = %#v", where, i, users[i]) + } + } + if users[1].Steer == nil { + t.Fatalf("%s: the correction lost its steer mark", where) + } + } + assert("live", agent.Transcript()) + assert("at rest", ReadTranscript(path).Entries) + window := append([]ai.Message(nil), agent.snapshot()[1:]...) + agent.file.appendCompaction(compactionPass{}, 0, window) + if err := agent.Close(); err != nil { + t.Fatal(err) + } + replayed, err := replaySessionFile(path) + if err != nil { + t.Fatal(err) + } + if len(replayed.messages) != len(want) || strings.Contains(messageContentText(replayed.messages[0]), "") { + t.Fatalf("the re-journaled window = %#v", replayed.messages) + } + assert("after compaction", shapeEntries(replayed.messages, &sessionFile{presentation: replayed.presentation, steers: replayed.steers})) + reopened, _ := newTestAgent(t, answering(), func(c *Config) { c.SessionFile = path }) + assert("reopened after compaction", reopened.Transcript()) +} + +// A FOLLOW-UP SENT WITH READS KEEPS THEM, through the real door: an idle +// session starts its turn at once and records the mark on the message's line. +func TestAFollowUpMessageKeepsItsReads(t *testing.T) { + path := filepath.Join(t.TempDir(), "session.jsonl") + agent, _ := newTestAgent(t, answering(), func(c *Config) { c.SessionFile = path }) + said := "next" + events, err := agent.FollowUpMessage(Submission{Text: "next\n\n- a.go", Said: &said, Reads: []MessageRead{partialRead()}}) + if err != nil { + t.Fatal(err) + } + collect(t, events) + if err := agent.Close(); err != nil { + t.Fatal(err) + } + users := personEntries(ReadTranscript(path).Entries) + if len(users) != 1 || users[0].Text != said || !reflect.DeepEqual(readsOf(users[0]), []MessageRead{partialRead()}) { + t.Fatalf("read back %#v", users) + } +} + +// A CORRECTION SENT WITH READS INTO A RUNNING TURN KEEPS THEM on its steer +// line, through the real door: the reply is cut, the words land at the +// boundary as the model's, and the record shows the person's words and reads. +func TestASteerMessageKeepsItsReadsOnItsSteerLine(t *testing.T) { + path := filepath.Join(t.TempDir(), "session.jsonl") + started := make(chan struct{}) + completer := &scriptedCompleter{steps: []step{ + func(ctx context.Context, _ []ai.Message) (*ai.Response, error) { + provider.Emit(ctx, provider.StreamDelta, "starting") + close(started) + <-ctx.Done() + return &ai.Response{}, ctx.Err() + }, + func(context.Context, []ai.Message) (*ai.Response, error) { return textResponse("done"), nil }, + }} + agent, _ := newTestAgent(t, completer, func(c *Config) { c.SessionFile = path }) + turn := mustSubmit(t, agent, "start") + <-started + said := "use these" + sent := "use these\n\n- spec1.md" + steered, err := agent.SteerMessage(Submission{Text: sent, Said: &said, Reads: []MessageRead{wholeRead()}}) + if err != nil { + t.Fatal(err) + } + collect(t, turn) + collect(t, steered) + if got := userLines(completer.request(1)); len(got) != 2 || got[1] != sent { + t.Fatalf("the model was sent %q", got) + } + if err := agent.Close(); err != nil { + t.Fatal(err) + } + users := personEntries(ReadTranscript(path).Entries) + if len(users) != 2 || users[1].Steer == nil || users[1].Text != said || !reflect.DeepEqual(readsOf(users[1]), []MessageRead{wholeRead()}) { + t.Fatalf("read back %#v", users) + } +} + +// A READING'S READS ARE ITS OWN, in the record cache's copy as in a shaping: +// no slice of a clone is the original's. +func TestACloneOfAnEntrySharesNoRead(t *testing.T) { + entries := []DisplayEntry{{Role: "user", Person: &PersonMark{Reads: []MessageRead{wholeRead()}}}} + clone := cloneEntries(entries) + original, copied := entries[0].Person.Reads, clone[0].Person.Reads + if !reflect.DeepEqual(original, copied) { + t.Fatal("the clone differs") + } + if &original[0] == &copied[0] || &original[0].Items[0] == &copied[0].Items[0] || &original[0].Warnings[0] == &copied[0].Warnings[0] { + t.Fatal("the clone shares a read's slices") + } +} diff --git a/internal/session/presentation.go b/internal/session/presentation.go index 48cc83fb6e..075b1a2faf 100644 --- a/internal/session/presentation.go +++ b/internal/session/presentation.go @@ -22,13 +22,15 @@ import ( // // On a PERSON'S line (Audience [audiencePerson]) Text is their own words where // the door that sent the message said them apart from what the model reads, -// and Attachments are what rode on it ([Agent.SubmitMessage]). Neither changes -// the content; both are absent from every line no such door wrote. +// Attachments are what rode on it, and Reads are the reads of references its +// words were written from ([Agent.SubmitMessage]). None of them changes the +// content; all are absent from every line no such door wrote. type messagePresentation struct { Audience string `json:"audience"` Text *string `json:"text,omitempty"` Interrupted bool `json:"interrupted,omitempty"` Attachments []MessageAttachment `json:"attachments,omitempty"` + Reads []MessageRead `json:"reads,omitempty"` SkillsBlock string `json:"-"` } diff --git a/internal/session/recordcache.go b/internal/session/recordcache.go index a9507ebbc8..803c6ac5b4 100644 --- a/internal/session/recordcache.go +++ b/internal/session/recordcache.go @@ -465,6 +465,11 @@ func recordVectors(record Record) int64 { for _, attachment := range entry.Person.Attachments { cost += attachmentSizes(attachment) } + cost += int64(len(entry.Person.Reads)) * int64(unsafe.Sizeof(MessageRead{})) + for _, read := range entry.Person.Reads { + cost += int64(len(read.Items))*int64(unsafe.Sizeof(MessageReference{})) + + int64(len(read.Warnings))*int64(unsafe.Sizeof("")) + } } if entry.Steer != nil { cost += int64(unsafe.Sizeof(SteerMark{})) @@ -521,11 +526,28 @@ func entriesCost(entries []DisplayEntry) int64 { cost += int64(len(attachment.ID)+len(attachment.Kind)+len(attachment.Name)+len(attachment.Path)+len(attachment.URL)) + attachmentSizes(attachment) } + cost += int64(cap(entry.Person.Reads)) * int64(unsafe.Sizeof(MessageRead{})) + for _, read := range entry.Person.Reads { + cost += referenceCost(read.Source) + cost += int64(cap(read.Items)) * int64(unsafe.Sizeof(MessageReference{})) + for _, item := range read.Items { + cost += referenceCost(item) + } + cost += int64(cap(read.Warnings)) * int64(unsafe.Sizeof("")) + for _, warning := range read.Warnings { + cost += int64(len(warning)) + } + } } } return cost } +// referenceCost is the bytes one reference's strings hold. +func referenceCost(ref MessageReference) int64 { + return int64(len(ref.Kind) + len(ref.ID) + len(ref.Name) + len(ref.Owner) + len(ref.Path) + len(ref.URL)) +} + // attachmentSizes is what one descriptor's measured sizes hold beside it. func attachmentSizes(attachment MessageAttachment) int64 { var cost int64 @@ -559,7 +581,7 @@ func cloneEntries(entries []DisplayEntry) []DisplayEntry { entry.ReplyTags = slices.Clone(entry.ReplyTags) entry.Team = slices.Clone(entry.Team) if entry.Person != nil { - person := PersonMark{Authored: entry.Person.Authored, Attachments: cloneAttachments(entry.Person.Attachments)} + person := PersonMark{Authored: entry.Person.Authored, Attachments: cloneAttachments(entry.Person.Attachments), Reads: cloneReads(entry.Person.Reads)} entry.Person = &person } if entry.Steer != nil { diff --git a/internal/session/steer.go b/internal/session/steer.go index 393eb41dd4..3126c44c9d 100644 --- a/internal/session/steer.go +++ b/internal/session/steer.go @@ -605,6 +605,26 @@ func steerMessage(steer *turnSteer) userMessage { // copy of that assembly, and it is not cheap enough to carry along. A surface // with a picture to add sends it that way, and it lands at the same boundary. func (a *Agent) Steer(words string) (<-chan Event, error) { + return a.steerMarked(words, nil) +} + +// SteerMessage is [Agent.Steer] for a door that knows more about the +// correction than its words, as [Agent.SubmitMessage] is [Agent.Submit]: the +// model reads Text, and the record keeps the person's own words, attachments +// and reads on the correction's own line — on its steer line when a boundary +// carries it, on the message it becomes when it falls through. A mark that does +// not fit refuses the correction whole, before anything is queued. +func (a *Agent) SteerMessage(message Submission) (<-chan Event, error) { + mark, err := message.mark() + if err != nil { + return nil, err + } + return a.steerMarked(message.Text, mark) +} + +// steerMarked is Steer's body with the person's mark beside the words, nil for +// a door that said nothing beyond them. +func (a *Agent) steerMarked(words string, person *messagePresentation) (<-chan Event, error) { words = strings.TrimSpace(words) if words == "" { return nil, errors.New("session: empty message") @@ -623,7 +643,9 @@ func (a *Agent) Steer(words string) (<-chan Event, error) { note: SteerNote{ID: a.nextSteerID(), Words: words, At: time.Now()}, stream: newEventStream(), } - a.steering = append(a.steering, steerMessage(steer)) + queued := steerMessage(steer) + queued.person = person + a.steering = append(a.steering, queued) hub := a.hub // A QUESTION THE PERSON TALKED PAST IS LET GO OF BEFORE ANYTHING ELSE IS // DECIDED. `ask` parks the turn on the person, so a correction queued behind @@ -890,8 +912,12 @@ func (a *Agent) liftSteersLocked(hub *eventHub) { // seam. [eventHub.drop] is the wrong door for this — it ends the reader — // and it is the only other way off a hub. hub.release(steer.stream) + // What the person said about the correction goes with it, or the + // message it becomes is kept without it. + follow := userText(steer.note.Words) + follow.person = message.person a.followups = append(a.followups, followUp{ - message: userText(steer.note.Words), + message: follow, stream: steer.stream, }) } diff --git a/internal/session/submission.go b/internal/session/submission.go index fb5000c1c4..a441f25eb9 100644 --- a/internal/session/submission.go +++ b/internal/session/submission.go @@ -4,6 +4,7 @@ import ( "context" "errors" "fmt" + "slices" "strings" "unicode/utf8" ) @@ -66,8 +67,85 @@ type Submission struct { Said *string // Attachments are what rode on the message, in the order it carried them. Attachments []MessageAttachment + // Reads are the reads of references the door performed to write Text, in + // the order it performed them, as it found them at that moment. + Reads []MessageRead } +// ── what the door read to write the message ───────────────────────────────── +// +// A door that expands a reference — a collection the person picked, flattened +// to the links the model is handed — knows at that moment what the read found: +// how many references came back, how many were gone, how many it could not +// ask about, whether it was cut. Tomorrow it knows none of that: the +// collection has moved on, and asking it again answers today's question. +// +// So the door says what it read on the message itself, as it says what was +// attached, and the record keeps it on the same line. A READ IS A FACT THE +// DOOR OBSERVED, NEVER ONE THE SESSION WORKS OUT: nothing here is looked up +// again, nothing is derived from the words, and a line without reads has +// none, however many links its words carry. The model is sent nothing of it. + +// MessageReference names one thing a read was of, or one thing it returned, +// in the door's own vocabulary. Kind is required; an empty field is "the door +// did not say". It is a descriptor and never content: a file is named by its +// path, a link by its address. +type MessageReference struct { + // Kind is what it is ("collection", "file", "link", ...); the session + // neither interprets nor restricts it. + Kind string `json:"kind,omitempty"` + // ID is the door's own identity for it. + ID string `json:"id,omitempty"` + // Name is how it is labelled to a person. + Name string `json:"name,omitempty"` + // Owner is who holds it, in the door's terms, where that is not the door. + Owner string `json:"owner,omitempty"` + // Path is where a file was, as the door named it. + Path string `json:"path,omitempty"` + // URL is a link's address. + URL string `json:"url,omitempty"` +} + +// MessageRead is one read of a reference the door performed to write a +// person's message, with the counts it observed. Count is exactly how many +// references the read returned; Items are the first few of them, never more +// than [MaxReadItems] and never more than Count. Missing, Unavailable and +// Unread count what the read could not return — gone, unaskable, deeper than +// it went — and More says it was cut. Complete is the door's own answer to +// whether the read was whole, and cannot be true while anything was +// unavailable, unread or cut; a reference that was gone was counted rather +// than lost, so Missing alone does not make a read partial. +type MessageRead struct { + Source MessageReference `json:"source"` + Count int `json:"count"` + Missing int `json:"missing,omitempty"` + Unavailable int `json:"unavailable,omitempty"` + Unread int `json:"unread,omitempty"` + More bool `json:"more,omitempty"` + Complete bool `json:"complete,omitempty"` + Warnings []string `json:"warnings,omitempty"` + Items []MessageReference `json:"items,omitempty"` +} + +// The bounds of what one message's reads may hold. A count past MaxReadCount is +// not a read anybody performed at a keyboard's pace, and the bytes bound is on +// every string of every read together, because sixteen reads at their field +// bounds would otherwise be megabytes on one line. +const ( + // MaxMessageReads is how many reads one message's mark names. + MaxMessageReads = 16 + // MaxReadItems is how many returned references one read names. + MaxReadItems = 8 + // MaxReadWarnings is how many warnings one read carries. + MaxReadWarnings = 8 + // MaxReadCount is the largest any one count of a read may be. + MaxReadCount = 1_000_000 + // maxReadWarning is the longest one warning may be, in bytes. + maxReadWarning = 512 + // maxReadsBytes is the most all of a message's reads' strings may hold. + maxReadsBytes = 128 << 10 +) + // The bounds of what one message's mark may hold. THE MARK IS WRITTEN ON EVERY // LINE THAT CARRIES ONE AND READ ON EVERY PAGE THAT SHOWS IT, so it is small and // fixed: a door with more to say than this is saying it somewhere other than a @@ -84,9 +162,9 @@ const ( maxSaidBytes = 1 << 20 ) -// errSubmissionMark is every refusal of a mark that does not fit, so a door -// can tell a description it got wrong from a session that would not take a turn. -var errSubmissionMark = errors.New("session: the message's description does not fit") +// ErrMessageMark is every refusal of a mark that does not fit, so a door can +// tell a description it got wrong from a session that would not take a turn. +var ErrMessageMark = errors.New("session: the message's description does not fit") // SubmitMessage is [Agent.Submit] for a door that knows more about the message // than its words: the person's own sentence, and the attachments the words were @@ -97,7 +175,7 @@ var errSubmissionMark = errors.New("session: the message's description does not // // A MARK THAT DOES NOT FIT REFUSES THE MESSAGE WHOLE, before anything is // recorded, so a refusal leaves nothing behind. A submission with no mark — -// Said nil and no attachments — is exactly [Agent.Submit]. +// Said nil, no attachments and no reads — is exactly [Agent.Submit]. func (a *Agent) SubmitMessage(ctx context.Context, message Submission) (<-chan Event, error) { mark, err := message.mark() if err != nil { @@ -110,15 +188,19 @@ func (a *Agent) SubmitMessage(ctx context.Context, message Submission) (<-chan E // it says nothing beyond the words. The copy is the record's own: nothing the // caller does to its slices afterwards reaches a line or a reading. func (m Submission) mark() (*messagePresentation, error) { - if m.Said == nil && len(m.Attachments) == 0 { + if m.Said == nil && len(m.Attachments) == 0 && len(m.Reads) == 0 { return nil, nil } + reads, err := keptReads(m.Reads) + if err != nil { + return nil, err + } // EVERY BOUND IS CHECKED ON THE BYTES AS GIVEN, before anything is trimmed: // a trimmed field is a slice of the caller's string, so a bound checked // after trimming could be passed by padding, and a short kept field could // hold a huge string alive behind it. What is kept is a fresh copy. if len(m.Attachments) > MaxMessageAttachments { - return nil, fmt.Errorf("%w: %d attachments, and one message names at most %d", errSubmissionMark, len(m.Attachments), MaxMessageAttachments) + return nil, fmt.Errorf("%w: %d attachments, and one message names at most %d", ErrMessageMark, len(m.Attachments), MaxMessageAttachments) } mark := &messagePresentation{Audience: audiencePerson} // Text stays nil when the door did not say the person's words apart: the @@ -126,7 +208,7 @@ func (m Submission) mark() (*messagePresentation, error) { // words written twice on one line. if m.Said != nil { if len(*m.Said) > maxSaidBytes || !utf8.ValidString(*m.Said) { - return nil, fmt.Errorf("%w: the person's words are not text of at most %d bytes", errSubmissionMark, maxSaidBytes) + return nil, fmt.Errorf("%w: the person's words are not text of at most %d bytes", ErrMessageMark, maxSaidBytes) } // Trimmed as [Agent.Submit] trims the words it is given. said := strings.Clone(strings.TrimSpace(*m.Said)) @@ -138,11 +220,11 @@ func (m Submission) mark() (*messagePresentation, error) { for index, given := range m.Attachments { for _, field := range []string{given.ID, given.Kind, given.Name, given.Path, given.URL} { if len(field) > maxAttachmentField || !utf8.ValidString(field) { - return nil, fmt.Errorf("%w: attachment %d has a field that is not text of at most %d bytes", errSubmissionMark, index+1, maxAttachmentField) + return nil, fmt.Errorf("%w: attachment %d has a field that is not text of at most %d bytes", ErrMessageMark, index+1, maxAttachmentField) } } if (given.Bytes != nil && *given.Bytes < 0) || (given.Lines != nil && *given.Lines < 0) { - return nil, fmt.Errorf("%w: attachment %d has a negative size", errSubmissionMark, index+1) + return nil, fmt.Errorf("%w: attachment %d has a negative size", ErrMessageMark, index+1) } kept := MessageAttachment{ ID: keptField(given.ID), @@ -152,7 +234,7 @@ func (m Submission) mark() (*messagePresentation, error) { URL: keptField(given.URL), } if kept.Kind == "" { - return nil, fmt.Errorf("%w: attachment %d has no kind", errSubmissionMark, index+1) + return nil, fmt.Errorf("%w: attachment %d has no kind", ErrMessageMark, index+1) } if given.Bytes != nil { bytes := *given.Bytes @@ -164,9 +246,128 @@ func (m Submission) mark() (*messagePresentation, error) { } mark.Attachments = append(mark.Attachments, kept) } + mark.Reads = reads return mark, nil } +// keptReads is the record's own copy of a message's reads, every bound checked +// on the bytes as given, and nil when there are none. NOTHING IS TRIMMED TO +// FIT: a count is the door's observation, so a read that does not fit refuses +// the message rather than being kept as a smaller read that never happened. +func keptReads(given []MessageRead) ([]MessageRead, error) { + if len(given) == 0 { + return nil, nil + } + if len(given) > MaxMessageReads { + return nil, fmt.Errorf("%w: %d reads, and one message names at most %d", ErrMessageMark, len(given), MaxMessageReads) + } + total := 0 + reads := make([]MessageRead, 0, len(given)) + for index, read := range given { + n := index + 1 + if len(read.Items) > MaxReadItems { + return nil, fmt.Errorf("%w: read %d names %d references, and one read names at most %d", ErrMessageMark, n, len(read.Items), MaxReadItems) + } + if len(read.Warnings) > MaxReadWarnings { + return nil, fmt.Errorf("%w: read %d carries %d warnings, and one read carries at most %d", ErrMessageMark, n, len(read.Warnings), MaxReadWarnings) + } + for _, count := range []int{read.Count, read.Missing, read.Unavailable, read.Unread} { + if count < 0 || count > MaxReadCount { + return nil, fmt.Errorf("%w: read %d has a count outside 0 to %d", ErrMessageMark, n, MaxReadCount) + } + } + if len(read.Items) > read.Count { + return nil, fmt.Errorf("%w: read %d names %d references of the %d it returned", ErrMessageMark, n, len(read.Items), read.Count) + } + // A WHOLE READ LEFT NOTHING OUT. A reference that was gone was counted, + // which a whole read may do; one it could not ask about, one deeper + // than it went, or a cut, is a read that is not whole. + if read.Complete && (read.Unavailable > 0 || read.Unread > 0 || read.More) { + return nil, fmt.Errorf("%w: read %d says it is complete and left references unread", ErrMessageMark, n) + } + kept := MessageRead{ + Count: read.Count, Missing: read.Missing, Unavailable: read.Unavailable, Unread: read.Unread, + More: read.More, Complete: read.Complete, + } + source, size, err := keptReference(read.Source) + if err != nil { + return nil, fmt.Errorf("%w: read %d's source %s", ErrMessageMark, n, err) + } + kept.Source, total = source, total+size + if len(read.Items) > 0 { + kept.Items = make([]MessageReference, 0, len(read.Items)) + } + for at, item := range read.Items { + ref, size, err := keptReference(item) + if err != nil { + return nil, fmt.Errorf("%w: read %d's reference %d %s", ErrMessageMark, n, at+1, err) + } + kept.Items, total = append(kept.Items, ref), total+size + } + if len(read.Warnings) > 0 { + kept.Warnings = make([]string, 0, len(read.Warnings)) + } + for at, warning := range read.Warnings { + if len(warning) > maxReadWarning || !utf8.ValidString(warning) { + return nil, fmt.Errorf("%w: read %d's warning %d is not text of at most %d bytes", ErrMessageMark, n, at+1, maxReadWarning) + } + said := keptField(warning) + if said == "" { + return nil, fmt.Errorf("%w: read %d's warning %d says nothing", ErrMessageMark, n, at+1) + } + kept.Warnings, total = append(kept.Warnings, said), total+len(warning) + } + if total > maxReadsBytes { + return nil, fmt.Errorf("%w: the reads come to more than %d bytes", ErrMessageMark, maxReadsBytes) + } + reads = append(reads, kept) + } + return reads, nil +} + +// keptReference is one reference as the record keeps it, and the bytes it was +// given with. It names its kind and at least one thing a person could find it +// by, or it names nothing. +func keptReference(given MessageReference) (MessageReference, int, error) { + size := 0 + for _, field := range []string{given.Kind, given.ID, given.Name, given.Owner, given.Path, given.URL} { + if len(field) > maxAttachmentField || !utf8.ValidString(field) { + return MessageReference{}, 0, fmt.Errorf("has a field that is not text of at most %d bytes", maxAttachmentField) + } + size += len(field) + } + kept := MessageReference{ + Kind: keptField(given.Kind), + ID: keptField(given.ID), + Name: keptField(given.Name), + Owner: keptField(given.Owner), + Path: keptField(given.Path), + URL: keptField(given.URL), + } + if kept.Kind == "" { + return MessageReference{}, 0, errors.New("has no kind") + } + if kept.ID == "" && kept.Name == "" && kept.Path == "" && kept.URL == "" { + return MessageReference{}, 0, errors.New("names nothing") + } + return kept, size, nil +} + +// cloneReads is a reading's own copy of a mark's reads, so a [DisplayEntry] +// handed to a caller shares no slice with the index behind it. The strings are +// shared, which a string cannot be changed through. +func cloneReads(reads []MessageRead) []MessageRead { + if len(reads) == 0 { + return nil + } + out := append([]MessageRead(nil), reads...) + for index := range out { + out[index].Items = slices.Clone(out[index].Items) + out[index].Warnings = slices.Clone(out[index].Warnings) + } + return out +} + // audiencePerson is the audience a person's own message is marked with. It is // the one mark a user-role line carries, and the only one [shapeEntries] reads // a person's words and attachments from. diff --git a/internal/session/submission_test.go b/internal/session/submission_test.go index 142b1832e6..ff274c9d5a 100644 --- a/internal/session/submission_test.go +++ b/internal/session/submission_test.go @@ -187,7 +187,7 @@ func TestASubmissionThatDoesNotFitRecordsNothing(t *testing.T) { "negative size": {Text: "x", Attachments: []MessageAttachment{{Kind: "paste", Bytes: ptr(int64(-1))}}}, "long field": {Text: "x", Attachments: []MessageAttachment{{Kind: "file", Path: strings.Repeat("p", maxAttachmentField+1)}}}, } { - if _, err := agent.SubmitMessage(context.Background(), message); !errors.Is(err, errSubmissionMark) { + if _, err := agent.SubmitMessage(context.Background(), message); !errors.Is(err, ErrMessageMark) { t.Fatalf("%s: SubmitMessage = %v", name, err) } } @@ -403,11 +403,11 @@ func TestMarksAreLetGoWithTheMessagesTheyDescribe(t *testing.T) { // copy that holds nothing of the caller's string alive. func TestAMarksBoundsAreOnTheBytesGiven(t *testing.T) { padded := strings.Repeat(" ", maxAttachmentField) + "a.go" - if _, err := (Submission{Attachments: []MessageAttachment{{Kind: "file", Path: padded}}}).mark(); !errors.Is(err, errSubmissionMark) { + if _, err := (Submission{Attachments: []MessageAttachment{{Kind: "file", Path: padded}}}).mark(); !errors.Is(err, ErrMessageMark) { t.Fatalf("a padded path = %v", err) } said := strings.Repeat("\n", maxSaidBytes) + "words" - if _, err := (Submission{Said: &said}).mark(); !errors.Is(err, errSubmissionMark) { + if _, err := (Submission{Said: &said}).mark(); !errors.Is(err, ErrMessageMark) { t.Fatalf("padded words = %v", err) } path := " " + strings.Repeat("d/", 100) + "a.go " From ad8b89b5079f3d9b4a43afc09a55d4723e308e35 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 21:21:26 -0400 Subject: [PATCH 085/459] remote: a message's reads cross the wire on the same Submit as its words SubmitArgs gains reads ([]session.MessageRead), sent by the client's SubmitMessage and handed whole to the agent's SubmitMessage door; reads alone are a mark. An engine without that door still runs the words alone. Co-Authored-By: Claude Opus 5.5 --- internal/remote/client.go | 2 +- internal/remote/server.go | 4 ++-- internal/remote/submitmessage_test.go | 15 +++++++++++++++ internal/remote/wire.go | 11 ++++++----- 4 files changed, 24 insertions(+), 8 deletions(-) diff --git a/internal/remote/client.go b/internal/remote/client.go index 076b076e74..efb8c8b346 100644 --- a/internal/remote/client.go +++ b/internal/remote/client.go @@ -1740,7 +1740,7 @@ func (a *Agent) Submit(ctx context.Context, text string) (<-chan session.Event, // words ([session.Agent.SubmitMessage]): it travels on the same call, so the // engine records the words and the mark on one line or neither. func (a *Agent) SubmitMessage(ctx context.Context, message session.Submission) (<-chan session.Event, error) { - return a.open(ctx, MethodSubmit, SubmitArgs{Text: message.Text, Said: message.Said, Attachments: message.Attachments}) + return a.open(ctx, MethodSubmit, SubmitArgs{Text: message.Text, Said: message.Said, Attachments: message.Attachments, Reads: message.Reads}) } // SubmitBash explicitly runs the person's command; ordinary Submit never diff --git a/internal/remote/server.go b/internal/remote/server.go index 752abfcece..6e2eca5ebd 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -2557,8 +2557,8 @@ func (s *server) invoke(call Frame) (out json.RawMessage, err error) { door, marked := agent.(interface { SubmitMessage(context.Context, session.Submission) (<-chan session.Event, error) }) - if marked && (args.Said != nil || len(args.Attachments) > 0) { - events, err = door.SubmitMessage(context.Background(), session.Submission{Text: args.Text, Said: args.Said, Attachments: args.Attachments}) + if marked && (args.Said != nil || len(args.Attachments) > 0 || len(args.Reads) > 0) { + events, err = door.SubmitMessage(context.Background(), session.Submission{Text: args.Text, Said: args.Said, Attachments: args.Attachments, Reads: args.Reads}) } else { events, err = agent.Submit(context.Background(), args.Text) } diff --git a/internal/remote/submitmessage_test.go b/internal/remote/submitmessage_test.go index c7adb29bfb..1ba2b6a3b1 100644 --- a/internal/remote/submitmessage_test.go +++ b/internal/remote/submitmessage_test.go @@ -47,6 +47,21 @@ func TestSubmitCarriesTheMessagesMarkToTheAgentsDoor(t *testing.T) { t.Fatalf("the agent was sent %q", marked.sent) } + // Reads alone are a mark: they reach the door whole, every count intact. + reads := []session.MessageRead{{ + Source: session.MessageReference{Kind: "collection", ID: "c1", Name: "Specs"}, + Count: 200, Missing: 1, Unavailable: 2, Unread: 3, More: true, + Warnings: []string{"two could not be asked"}, + Items: []session.MessageReference{{Kind: "file", ID: "r1", Name: "a.md", Owner: "fs", Path: "docs/a.md", URL: "https://x/a"}}, + }} + s.pending = nil + if _, err := s.invoke(Frame{Kind: "call", ID: 3, Method: MethodSubmit, Payload: mustJSON(SubmitArgs{Text: "read these", Reads: reads})}); err != nil { + t.Fatal(err) + } + if len(marked.submissions) != 2 || marked.submissions[1].Said != nil || !reflect.DeepEqual(marked.submissions[1].Reads, reads) { + t.Fatalf("the marked door was handed %+v", marked.submissions) + } + plain := &fakeAgent{model: "m"} s = &server{out: io.Discard, session: NewSession(&Engine{Agent: plain}, false)} if _, err := s.invoke(Frame{Kind: "call", ID: 1, Method: MethodSubmit, Payload: mustJSON(SubmitArgs{Text: "Attached file: notes.md", Said: &said, Attachments: attachments})}); err != nil { diff --git a/internal/remote/wire.go b/internal/remote/wire.go index ddb10b3b72..2cc08ce312 100644 --- a/internal/remote/wire.go +++ b/internal/remote/wire.go @@ -1449,13 +1449,14 @@ type InterruptArgs struct { type SubmitArgs struct { Text string `json:"text"` - // Said and Attachments are what a door that knows more than the words said - // about a person's message (internal/session's [session.Submission]), on - // Submit alone. Both are absent from every other call, and an engine built - // before them reads the words and nothing else — the message still runs, it - // is only kept without its mark. + // Said, Attachments and Reads are what a door that knows more than the + // words said about a person's message (internal/session's + // [session.Submission]), on Submit alone. All are absent from every other + // call, and an engine built before them reads the words and nothing else — + // the message still runs, it is only kept without its mark. Said *string `json:"said,omitempty"` Attachments []session.MessageAttachment `json:"attachments,omitempty"` + Reads []session.MessageRead `json:"reads,omitempty"` } // UnqueueArgs names the queued follow-up a surface wants taken back out: the From 304272bcd81951440f4e09613b6742acc2d318dd Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 21:29:03 -0400 Subject: [PATCH 086/459] app: agents.send and crews.message keep the person's words and the reads they were written from The app's composer expands a picked collection into the words the model reads, and the person saw that expansion as their message after a restart, with no record of what the read found. agents.send and crews.message take said (the person's own words, never replaced by the expanded text) and reads (appwire.MessageRead: source, count, missing, unavailable, unread, more, complete, warnings, items, snake_case, counts always present). A send carrying either goes through the optional appwire.MessageSender.SendMessage(agent, AgentMessage); an engine without it answers unknown_method, and hello names messages.reads when it has it. A send with neither goes by Send/SendWith as before, and no existing Engine fake changes. The backend checks reads whole first (session.KeptReads, bad_params), then sends through the same sendMessage road as staged attachments, so the model is sent the text plus attachment words, the client id and attachments are kept, and session.SubmitMessage records said, attachments and reads on one line. Entry.reads is on the live user entry and on every history page; a message sent without reads has none. A crew message carrying said or reads is never queued behind a busy target; the manager is told the person's words, not the expansion. ErrMessageMark from the engine maps to bad_params. agents.steer, agents.queue and queue.edit still take words only: the app's queue is in-memory and editable, so a receipt on it would go stale. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/app.go | 12 +- cmd/codeaf/appconvo.go | 108 ++++++++- cmd/codeaf/appcrews.go | 36 ++- cmd/codeaf/appreads_history_test.go | 210 ++++++++++++++++++ .../unreleased/0-message-reference-reads.md | 19 ++ internal/appwire/collections.go | 9 +- internal/appwire/convo.go | 67 ++++++ internal/appwire/crews.go | 14 +- internal/appwire/reads_test.go | 102 +++++++++ internal/appwire/server.go | 25 ++- internal/appwire/wire.go | 4 + internal/session/submission.go | 6 + 12 files changed, 574 insertions(+), 38 deletions(-) create mode 100644 cmd/codeaf/appreads_history_test.go create mode 100644 docs/changes/unreleased/0-message-reference-reads.md create mode 100644 internal/appwire/reads_test.go diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index b3d1bbed75..3c900f1256 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -1486,7 +1486,7 @@ func (b *appBackend) startLocked(a *appAgent, text, clientID string, shown *appS } var turn uint64 var n int - entryText, attachments := text, []appwire.Attachment(nil) + entryText, attachments, reads := text, []appwire.Attachment(nil), []appwire.MessageRead(nil) b.locked(func() { if live.running { refused = appwire.Errorf(appwire.CodeBusy, "this agent is already working on a turn") @@ -1503,7 +1503,7 @@ func (b *appBackend) startLocked(a *appAgent, text, clientID string, shown *appS } a.marks[n] = appTurnMark{turn: turn, clientID: clientID} if shown != nil { - entryText, attachments = shown.text, shown.attachments + entryText, attachments, reads = shown.text, shown.attachments, appReadsOf(shown.reads) } }) if refused != nil { @@ -1537,7 +1537,7 @@ func (b *appBackend) startLocked(a *appAgent, text, clientID string, shown *appS live.box.Event(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) live.box.Event(appwire.EventTurnStarted, appwire.TurnStarted{Agent: id, Turn: turn}) live.box.Event(appwire.EventEntryAdded, appwire.EntryAdded{Entry: appwire.Entry{ - ID: appUserEntryID(n), Agent: id, Turn: turn, Kind: appwire.EntryUser, At: b.now(), Text: entryText, ClientID: clientID, Attachments: attachments, + ID: appUserEntryID(n), Agent: id, Turn: turn, Kind: appwire.EntryUser, At: b.now(), Text: entryText, ClientID: clientID, Attachments: attachments, Reads: reads, }}) long := b.watchLong(id, live, turn, b.now()) b.newTurnCalls(a) @@ -2466,6 +2466,7 @@ func appEntryShapes(agent string, items []session.DisplayEntry, at time.Time, ma // rode on them, as the record keeps them on the message's line; a // line without that mark is its words, and has no attachments. entry.ID, entry.Kind, entry.Text, entry.Attachments = appUserEntryID(n), appwire.EntryUser, item.Text, appAttachmentsOf(item.Person) + entry.Reads = appPersonReads(item.Person) case "assistant", "aside": if strings.TrimSpace(item.Text) == "" { continue @@ -3040,6 +3041,11 @@ func appEngineError(err error) error { if errors.As(err, &wire) { return wire } + // A description of the message the engine would not keep is the app's to + // correct, not the engine failing. + if errors.Is(err, session.ErrMessageMark) { + return &appwire.Error{Code: appwire.CodeBadParams, Message: redact.Secrets(plainWords(err.Error()))} + } return &appwire.Error{Code: appwire.CodeEngine, Message: redact.Secrets(plainWords(err.Error()))} } diff --git a/cmd/codeaf/appconvo.go b/cmd/codeaf/appconvo.go index f51e189a62..5cdbc1184f 100644 --- a/cmd/codeaf/appconvo.go +++ b/cmd/codeaf/appconvo.go @@ -854,10 +854,65 @@ func appAttachmentsOf(person *session.PersonMark) []appwire.Attachment { } // appSubmission is the message the engine is asked to run and keep: the words -// the model is sent, and the person's own words and attachments beside them. +// the model is sent, and the person's own words, attachments and reads beside +// them. func appSubmission(text string, shown *appSending) session.Submission { said := shown.text - return session.Submission{Text: text, Said: &said, Attachments: shown.described} + return session.Submission{Text: text, Said: &said, Attachments: shown.described, Reads: shown.reads} +} + +// appKeptReads is a message's reads as the record will keep them, or the +// refusal the engine would answer, as bad_params before anything is done. +func appKeptReads(reads []appwire.MessageRead) ([]session.MessageRead, error) { + if len(reads) == 0 { + return nil, nil + } + given := make([]session.MessageRead, 0, len(reads)) + for _, read := range reads { + items := make([]session.MessageReference, 0, len(read.Items)) + for _, item := range read.Items { + items = append(items, session.MessageReference(item)) + } + given = append(given, session.MessageRead{ + Source: session.MessageReference(read.Source), Count: read.Count, Missing: read.Missing, + Unavailable: read.Unavailable, Unread: read.Unread, More: read.More, Complete: read.Complete, + Warnings: read.Warnings, Items: items, + }) + } + kept, err := session.KeptReads(given) + if err != nil { + return nil, appwire.Errorf(appwire.CodeBadParams, "%s", plainWords(err.Error())) + } + return kept, nil +} + +// appReadsOf is a message's reads as an entry shows them, nil for a message +// sent without any. +func appReadsOf(reads []session.MessageRead) []appwire.MessageRead { + if len(reads) == 0 { + return nil + } + out := make([]appwire.MessageRead, 0, len(reads)) + for _, read := range reads { + var items []appwire.MessageReference + for _, item := range read.Items { + items = append(items, appwire.MessageReference(item)) + } + out = append(out, appwire.MessageRead{ + Source: appwire.MessageReference(read.Source), Count: read.Count, Missing: read.Missing, + Unavailable: read.Unavailable, Unread: read.Unread, More: read.More, Complete: read.Complete, + Warnings: append([]string(nil), read.Warnings...), Items: items, + }) + } + return out +} + +// appPersonReads is a recorded message's reads as an entry shows them. +func appPersonReads(person *session.PersonMark) []appwire.MessageRead { + if person == nil { + return nil + } + return appReadsOf(person.Reads) } // appAttachChars is the characters of paste a message carries, checked @@ -922,11 +977,26 @@ func (b *appBackend) RemoveAttachment(agent, id string) error { // SendWith sends a message with its attachments, written into the words the // engine is sent, and keeps them on the person's entry. func (b *appBackend) SendWith(agent, text, clientID string, ids []string) (uint64, error) { - turn, _, err := b.sendWith(agent, text, clientID, ids) + turn, _, err := b.sendMessage(agent, appwire.AgentMessage{Text: text, ClientID: clientID, Attachments: ids}) return turn, err } -// sendWith is [appBackend.SendWith], answering the attachments it carried. +// SendMessage sends a message with what the app knows about it beyond the words +// the model reads: the person's own words, which the entry and the record show +// in place of Text, the staged attachments, and the reads Text was written +// from. +func (b *appBackend) SendMessage(agent string, message appwire.AgentMessage) (uint64, error) { + turn, _, err := b.sendMessage(agent, message) + return turn, err +} + +// sendMessage is [appBackend.SendMessage], answering what the person's entry +// shows, nil when that is the words sent. +// +// THE PERSON'S WORDS ARE THEIRS: Said, when the app gave it, is what the entry +// and the record show, and never the expanded Text the model is sent. Reads are +// checked whole before anything else, and kept exactly as the record will keep +// them, so the live entry and every later reading say the same. // // STAGED ATTACHMENTS ARE CONSUMED ONLY BY A TURN THAT STARTED. They are read, // not taken, while the turn is asked for, and dropped once it has begun; a @@ -935,10 +1005,15 @@ func (b *appBackend) SendWith(agent, text, clientID string, ids []string) (uint6 // a new id nothing here touches. The agent's gate is held from the read to the // drop, so two sends cannot both carry one chip into two turns, and each turn // carries exactly the snapshot it read. -func (b *appBackend) sendWith(agent, text, clientID string, ids []string) (uint64, []appwire.Attachment, error) { +func (b *appBackend) sendMessage(agent string, message appwire.AgentMessage) (uint64, *appSending, error) { + ids := message.Attachments if err := appwire.CheckAttachIDs(ids); err != nil { return 0, nil, err } + reads, err := appKeptReads(message.Reads) + if err != nil { + return 0, nil, err + } a, err := b.find(agent) if err != nil { return 0, nil, err @@ -963,8 +1038,18 @@ func (b *appBackend) sendWith(agent, text, clientID string, ids []string) (uint6 if err := appAttachChars(kept); err != nil { return 0, nil, err } - shown := appAttachShown(text, kept) - turn, err := b.sendLocked(a, appAttachWords(text, kept), clientID, shown) + said := message.Text + if message.Said != nil { + said = *message.Said + } + shown := appAttachShown(said, kept) + if shown == nil && (message.Said != nil || len(reads) > 0) { + shown = &appSending{text: said} + } + if shown != nil { + shown.reads = reads + } + turn, err := b.sendLocked(a, appAttachWords(message.Text, kept), message.ClientID, shown) if err != nil { return 0, nil, err } @@ -973,11 +1058,7 @@ func (b *appBackend) sendWith(agent, text, clientID string, ids []string) (uint6 delete(a.attach, id) } }) - var views []appwire.Attachment - if shown != nil { - views = shown.attachments - } - return turn, views, nil + return turn, shown, nil } // appSending is the words and attachments of a message whose entry shows @@ -988,6 +1069,9 @@ type appSending struct { // described is the same attachments as the record keeps them // ([appDescribed]), in the same order. described []session.MessageAttachment + // reads are the reads the words were written from, as the record keeps + // them ([appKeptReads]). + reads []session.MessageRead } // ── tasks as conversations ────────────────────────────────────────────────── diff --git a/cmd/codeaf/appcrews.go b/cmd/codeaf/appcrews.go index b20b9ce851..c005105693 100644 --- a/cmd/codeaf/appcrews.go +++ b/cmd/codeaf/appcrews.go @@ -1517,7 +1517,7 @@ func (b *appBackend) CrewMessage(ask appwire.CrewMessage) (appwire.Entry, error) if !ok { return appwire.Entry{}, appwire.Errorf(appwire.CodeNotFound, "%s is not in %s", target, t.Name) } - if len(ask.Attachments) > 0 { + if len(ask.Attachments) > 0 || ask.Said != nil || len(ask.Reads) > 0 { return b.crewMessageWith(t, member, target, ask) } turn, err := b.Send(target, ask.Text, ask.ClientID) @@ -1539,29 +1539,43 @@ func (b *appBackend) CrewMessage(ask appwire.CrewMessage) (appwire.Entry, error) } // crewMessageWith is a crew message carrying attachments staged on its -// target. A MESSAGE WITH ATTACHMENTS IS NEVER QUEUED: the queue keeps words -// only, so queueing it would send the words and lose the attachments. A busy -// target refuses it instead, and every chip stays staged for the person to -// send again when the turn ends. +// target, the person's own words apart from the text, or reads. A MESSAGE +// CARRYING ANY OF THEM IS NEVER QUEUED: the queue keeps words only, so +// queueing it would send the words and lose the rest. A busy target refuses it +// instead, and every chip stays staged for the person to send again when the +// turn ends. func (b *appBackend) crewMessageWith(t teamstore.Team, member teamstore.Member, target string, ask appwire.CrewMessage) (appwire.Entry, error) { - turn, views, err := b.sendWith(target, ask.Text, ask.ClientID, ask.Attachments) + turn, shown, err := b.sendMessage(target, appwire.AgentMessage{Text: ask.Text, Said: ask.Said, ClientID: ask.ClientID, Attachments: ask.Attachments, Reads: ask.Reads}) var wire *appwire.Error if errors.As(err, &wire) && wire.Code == appwire.CodeBusy { - return appwire.Entry{}, appwire.Errorf(appwire.CodeBusy, "%s is working on a turn; the message was not sent and its attachments are still attached", member.Handle) + if len(ask.Attachments) > 0 { + return appwire.Entry{}, appwire.Errorf(appwire.CodeBusy, "%s is working on a turn; the message was not sent and its attachments are still attached", member.Handle) + } + return appwire.Entry{}, appwire.Errorf(appwire.CodeBusy, "%s is working on a turn; the message was not sent", member.Handle) } if err != nil { return appwire.Entry{}, err } - logged := ask.Text - if strings.TrimSpace(logged) == "" { + // The manager is told what the person said, never the expanded words the + // member's model was sent. + text := ask.Text + var views []appwire.Attachment + var reads []appwire.MessageRead + if shown != nil { + text, views, reads = shown.text, shown.attachments, appReadsOf(shown.reads) + } + logged := text + if strings.TrimSpace(logged) == "" && len(views) > 0 { titles := make([]string, 0, len(views)) for _, view := range views { titles = append(titles, view.Title) } logged = "Attached: " + strings.Join(titles, ", ") } - b.tellManager(t, member, logged) - return appwire.Entry{Agent: target, Turn: turn, Kind: appwire.EntryUser, At: b.now(), Text: ask.Text, ClientID: ask.ClientID, Attachments: views}, nil + if strings.TrimSpace(logged) != "" { + b.tellManager(t, member, logged) + } + return appwire.Entry{Agent: target, Turn: turn, Kind: appwire.EntryUser, At: b.now(), Text: text, ClientID: ask.ClientID, Attachments: views, Reads: reads}, nil } // tellManager puts the person's words to a member on the crew's log. diff --git a/cmd/codeaf/appreads_history_test.go b/cmd/codeaf/appreads_history_test.go new file mode 100644 index 0000000000..13b52b7e1c --- /dev/null +++ b/cmd/codeaf/appreads_history_test.go @@ -0,0 +1,210 @@ +package main + +import ( + "os" + "path/filepath" + "reflect" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + teamstore "github.com/Agent-Field/codeaf/internal/teams" +) + +// appSendMessageWhenIdle sends once the agent's last turn has let go of it. +func appSendMessageWhenIdle(t *testing.T, backend *appBackend, agent string, message appwire.AgentMessage) uint64 { + t.Helper() + deadline := time.Now().Add(30 * time.Second) + for { + turn, err := backend.SendMessage(agent, message) + if err == nil { + return turn + } + if wireCode(err) != appwire.CodeBusy || time.Now().After(deadline) { + t.Fatalf("send %q = %v", message.Text, err) + } + time.Sleep(2 * time.Millisecond) + } +} + +// appLiveUser is the user entry the backend said live for one client id. +func appLiveUser(t *testing.T, rec *appRecorder, clientID string) appwire.Entry { + t.Helper() + i := rec.waitFor(t, 30*time.Second, func(event string, data any) bool { + added, ok := data.(appwire.EntryAdded) + return ok && added.Entry.Kind == appwire.EntryUser && added.Entry.ClientID == clientID + }) + rec.mu.Lock() + defer rec.mu.Unlock() + return rec.data[i].(appwire.EntryAdded).Entry +} + +// appSpecsRead is a whole read of two hundred references, one of them gone. +func appSpecsRead() appwire.MessageRead { + return appwire.MessageRead{ + Source: appwire.MessageReference{Kind: "collection", ID: "c1", Name: "Specs"}, + Count: 200, Missing: 1, Complete: true, + Warnings: []string{"one reference is gone"}, + Items: []appwire.MessageReference{ + {Kind: "file", ID: "r1", Name: "a.md", Path: "docs/a.md"}, + {Kind: "link", ID: "r2", URL: "https://example.com/b"}, + }, + } +} + +// appCutRead is a read that could not ask about two references and was cut. +func appCutRead() appwire.MessageRead { + return appwire.MessageRead{ + Source: appwire.MessageReference{Kind: "collection", ID: "c2", Name: "Links"}, + Count: 1, Unavailable: 2, Unread: 1, More: true, + Items: []appwire.MessageReference{{Kind: "link", URL: "https://example.com/c"}}, + } +} + +// WHAT THE APP READ TO WRITE A MESSAGE IS WHAT A REOPENED CONVERSATION SHOWS. +// A real conversation against a scripted model is sent the composer's expanded +// words with the person's own sentence, a read and an attachment; then a read +// alone with no words of theirs; then plain words. The live entry shows their +// sentence, the read and the attachment; the model is sent the expanded words; +// a read that says it is whole while cut is refused and records nothing. A NEW +// backend reading the record off the disk shows exactly the same. +func TestAppReadsSurviveAReopenedRecord(t *testing.T) { + root := resolvedTempDir(t) + model, workspace := wakeEnvironment(t, root) + if err := os.WriteFile(filepath.Join(workspace, "notes.md"), []byte("# notes\n"), 0o644); err != nil { + t.Fatal(err) + } + rec := newAppRecorder() + first := newAppBackend(rec, appOpenInProcess) + closed := false + defer func() { + if !closed { + first.Close() + } + }() + agent, err := mustCreate(t, first, appwire.CreateAsk{Workspace: workspace}) + if err != nil { + t.Fatal(err) + } + file, err := first.AddAttachment(agent.ID, appwire.Attachment{Kind: appwire.AttachFile, Path: "notes.md"}, "") + if err != nil { + t.Fatal(err) + } + said, none := "compare these specs", "" + expanded := "compare these specs\n\nSpecs:\n- docs/a.md\n- https://example.com/b" + before := len(appModelRequests(model)) + turn := appSendMessageWhenIdle(t, first, agent.ID, appwire.AgentMessage{ + Text: expanded, Said: &said, ClientID: "r-1", Attachments: []string{file.ID}, Reads: []appwire.MessageRead{appSpecsRead(), appCutRead()}, + }) + live := appLiveUser(t, rec, "r-1") + if live.Text != said || appAttachIDs(live) != file.ID || !reflect.DeepEqual(live.Reads, []appwire.MessageRead{appSpecsRead(), appCutRead()}) { + t.Fatalf("the live entry = %+v", live) + } + appTurnDoneFor(t, rec, turn) + sent := false + for _, request := range appModelRequests(model)[before:] { + if strings.Contains(strings.Join(request.texts(), "\n"), expanded+"\n\nAttached file: notes.md") { + sent = true + } + } + if !sent { + t.Fatal("the model was not sent the expanded words and the attachment") + } + + appTurnDoneFor(t, rec, appSendMessageWhenIdle(t, first, agent.ID, appwire.AgentMessage{ + Text: "Links:\n- https://example.com/c", Said: &none, ClientID: "r-2", Reads: []appwire.MessageRead{appCutRead()}, + })) + if live := appLiveUser(t, rec, "r-2"); live.Text != "" || !reflect.DeepEqual(live.Reads, []appwire.MessageRead{appCutRead()}) { + t.Fatalf("the read alone went live as %+v", live) + } + lying := appCutRead() + lying.Complete = true + asked := len(appModelRequests(model)) + if _, err := first.SendMessage(agent.ID, appwire.AgentMessage{Text: "x", Said: &said, ClientID: "r-x", Reads: []appwire.MessageRead{lying}}); wireCode(err) != appwire.CodeBadParams { + t.Fatalf("a read that says it is whole while cut answered %v", err) + } + if got := len(appModelRequests(model)); got != asked { + t.Fatalf("a refused message asked the model %d times", got-asked) + } + appTurnDoneFor(t, rec, appSendMessageWhenIdle(t, first, agent.ID, appwire.AgentMessage{Text: "plain words", ClientID: "r-3"})) + if err := first.Close(); err != nil { + t.Fatal(err) + } + closed = true + + second := newAppBackend(newAppRecorder(), appOpenInProcess) + defer second.Close() + entries, _, err := second.Entries(agent.ID, "", appwire.MaxEntries) + if err != nil { + t.Fatal(err) + } + users := appUsersOf(entries) + if len(users) != 3 { + t.Fatalf("the reopened record holds %d messages of the person's: %+v", len(users), users) + } + if users[0].Text != said || appAttachIDs(users[0]) != file.ID || !reflect.DeepEqual(users[0].Reads, []appwire.MessageRead{appSpecsRead(), appCutRead()}) { + t.Fatalf("the message with reads reopened as %+v", users[0]) + } + if users[1].Text != "" || !reflect.DeepEqual(users[1].Reads, []appwire.MessageRead{appCutRead()}) { + t.Fatalf("the read alone reopened as %+v", users[1]) + } + if users[2].Text != "plain words" || users[2].Reads != nil { + t.Fatalf("the plain words reopened as %+v", users[2]) + } +} + +// A CREW MESSAGE'S READS ARE KEPT ON THE CONVERSATION IT WENT TO, the person's +// own words with them, and its entry says both; a reopened record says the same. +func TestAppCrewReadsAreKeptOnTheirTarget(t *testing.T) { + root := resolvedTempDir(t) + _, workspace := wakeEnvironment(t, root) + profile := filepath.Join(root, "crew-profile") + rec := newAppRecorder() + first := newAppBackend(rec, appOpenInProcess) + first.profileDir = profile + closed := false + defer func() { + if !closed { + first.Close() + } + }() + manager, err := mustCreate(t, first, appwire.CreateAsk{Workspace: workspace}) + if err != nil { + t.Fatal(err) + } + lead := appTranscriptOf(first, manager.ID) + crew := teamstore.NewID() + err = teamstore.Update(profile, func(f *teamstore.File) error { + f.Teams = append(f.Teams, teamstore.Team{ID: crew, Name: "harbor", Made: time.Now()}) + if err := f.AddMember(crew, teamstore.Member{Key: lead, File: lead, Where: workspace, Word: "lead", Handle: "lead"}); err != nil { + return err + } + return f.SetManager(crew, lead) + }) + if err != nil { + t.Fatal(err) + } + said := "read the specs" + entry, err := first.CrewMessage(appwire.CrewMessage{Crew: crew, Text: "read the specs\n\n- docs/a.md", Said: &said, ClientID: "k-1", Reads: []appwire.MessageRead{appSpecsRead()}}) + if err != nil || entry.Agent != manager.ID || entry.Text != said || !reflect.DeepEqual(entry.Reads, []appwire.MessageRead{appSpecsRead()}) { + t.Fatalf("the message to the manager = %+v %v", entry, err) + } + appTurnDoneFor(t, rec, entry.Turn) + if err := first.Close(); err != nil { + t.Fatal(err) + } + closed = true + + second := newAppBackend(newAppRecorder(), appOpenInProcess) + second.profileDir = profile + defer second.Close() + entries, _, err := second.Entries(manager.ID, "", appwire.MaxEntries) + if err != nil { + t.Fatal(err) + } + users := appUsersOf(entries) + if len(users) != 1 || users[0].Text != said || !reflect.DeepEqual(users[0].Reads, []appwire.MessageRead{appSpecsRead()}) { + t.Fatalf("the manager reopened with %+v", users) + } +} diff --git a/docs/changes/unreleased/0-message-reference-reads.md b/docs/changes/unreleased/0-message-reference-reads.md new file mode 100644 index 0000000000..47459052a0 --- /dev/null +++ b/docs/changes/unreleased/0-message-reference-reads.md @@ -0,0 +1,19 @@ +--- +kind: added +title: a person's message keeps the reads of references its words were written from +pr: 0 +surface: [engine, remote] +invalidates: + - "A message the app wrote by expanding a collection kept only the expanded words: what the read found at send time (how many references, how many gone, unaskable, unread or cut) was lost, and asking the collection again later answers a different question. session.Submission now carries Reads []MessageRead (Source and Items as MessageReference{Kind, ID, Name, Owner, Path, URL}; Count, Missing, Unavailable, Unread, More, Complete as the door observed them), recorded on the message's own line in the existing presentation mark and read back as DisplayEntry.Person.Reads. DisplayEntry is still 288 bytes." + - "session.Agent.Steer and FollowUp took words only, and a steer that fell through became a plain message. SteerMessage and FollowUpMessage take a Submission, and a fallen-through steer carries its mark into the message it becomes. The unexported errSubmissionMark is now session.ErrMessageMark." + - "agents.send and crews.message took text, client_id and staged attachment ids only, and the entry showed the words the app typed. They now also take said (the person's own words, never replaced by the expanded text) and reads; an engine that is not an appwire.MessageSender answers such a send unknown_method, and hello names messages.reads when it is. Entry.reads carries the reads on the live user entry and on every history page. agents.steer, agents.queue and queue.edit still take words only." + - "remote SubmitArgs carried said and attachments. It now also carries reads; an engine without SubmitMessage still runs the words alone and keeps no mark." +--- + +A read is a fact the door observed, never one the engine works out: nothing is +looked up again, nothing is derived from the words, and the model is sent +exactly the text it was sent before. Bounds refuse the whole message before +anything is queued or journaled (16 reads, 8 items and 8 warnings per read, +4096-byte fields, 512-byte warnings, counts 0 to 1,000,000, 128 KiB of read +strings, no `complete` while anything was unavailable, unread or cut); nothing +is trimmed to fit. diff --git a/internal/appwire/collections.go b/internal/appwire/collections.go index 8e81c5ae11..579d05e7a6 100644 --- a/internal/appwire/collections.go +++ b/internal/appwire/collections.go @@ -229,12 +229,17 @@ func withCollectionsFeature(hello Hello, e Engine) Hello { if _, ok := e.(Collector); !ok { return hello } + return withFeature(hello, FeatureCollections) +} + +// withFeature is hello carrying feature once, on a list of its own. +func withFeature(hello Hello, feature string) Hello { for _, f := range hello.Features { - if f == FeatureCollections { + if f == feature { return hello } } - hello.Features = append(append([]string(nil), hello.Features...), FeatureCollections) + hello.Features = append(append([]string(nil), hello.Features...), feature) return hello } diff --git a/internal/appwire/convo.go b/internal/appwire/convo.go index 0b9d28be92..4ec0825e75 100644 --- a/internal/appwire/convo.go +++ b/internal/appwire/convo.go @@ -86,6 +86,73 @@ type Attachment struct { State string `json:"state,omitempty"` } +// MessageRead is one read of a reference the app performed to write a +// person's message — a collection it expanded at send time — as it found it +// at that moment. The engine keeps it on the message's line and reads it back +// on the message's entry; it never reads the reference again, and the model +// is sent none of it. Count is exactly how many references the read returned, +// Items the first few of them (descriptors, never contents); Missing, +// Unavailable and Unread count what it could not return and More says it was +// cut. Complete is the app's own answer, and may not be true while anything +// was unavailable, unread or cut. The counts and flags are always present. +type MessageRead struct { + Source MessageReference `json:"source"` + Count int `json:"count"` + Missing int `json:"missing"` + Unavailable int `json:"unavailable"` + Unread int `json:"unread"` + More bool `json:"more"` + Complete bool `json:"complete"` + Warnings []string `json:"warnings,omitempty"` + Items []MessageReference `json:"items,omitempty"` +} + +// MessageReference names what a read was of, or one thing it returned, in the +// app's own words. Kind is required, with at least one of id, name, path or +// url. +type MessageReference struct { + Kind string `json:"kind"` + ID string `json:"id,omitempty"` + Name string `json:"name,omitempty"` + Owner string `json:"owner,omitempty"` + Path string `json:"path,omitempty"` + URL string `json:"url,omitempty"` +} + +// AgentMessage is a person's message with what the app knows about it beyond +// the words the model reads: Said, when set, is the person's own words (empty +// for a message of attachments or reads alone), never replaced by Text; Reads +// are the reads Text was written from. Attachments are staged ids, as +// agents.send takes them. +type AgentMessage struct { + Text string + Said *string + ClientID string + Attachments []string + Reads []MessageRead +} + +// MessageSender is the optional engine half of agents.send and crews.message +// carrying said or reads. One that is not a MessageSender answers such a send +// unknown_method rather than running it without what it carried; a send with +// neither goes by Send or SendWith exactly as before. +type MessageSender interface { + SendMessage(agent string, message AgentMessage) (uint64, error) +} + +// FeatureMessageReads is hello's word for an engine that is a [MessageSender]: +// agents.send and crews.message keep said and reads on the person's message. +const FeatureMessageReads = "messages.reads" + +// withMessageReadsFeature is hello carrying [FeatureMessageReads] when the +// engine serves it. +func withMessageReadsFeature(hello Hello, e Engine) Hello { + if _, ok := e.(MessageSender); !ok { + return hello + } + return withFeature(hello, FeatureMessageReads) +} + // The attachment kinds and a file's states. const ( AttachFile = "file" diff --git a/internal/appwire/crews.go b/internal/appwire/crews.go index 1250fe2db3..4170b0c736 100644 --- a/internal/appwire/crews.go +++ b/internal/appwire/crews.go @@ -371,12 +371,16 @@ type MemberAsk struct { // attachments.add on that same target agent; with them the text may be empty. // A message with attachments is never queued behind a busy target: it is // refused busy and its attachments stay staged for the person to send again. +// Said and Reads are agents.send's ([AgentMessage]), and a message carrying +// either is likewise never queued: it is refused busy. type CrewMessage struct { - Crew string `json:"crew"` - To string `json:"to"` - Text string `json:"text"` - ClientID string `json:"client_id"` - Attachments []string `json:"attachments,omitempty"` + Crew string `json:"crew"` + To string `json:"to"` + Text string `json:"text"` + Said *string `json:"said,omitempty"` + ClientID string `json:"client_id"` + Attachments []string `json:"attachments,omitempty"` + Reads []MessageRead `json:"reads,omitempty"` } // Crewer is the engine's half of crews. diff --git a/internal/appwire/reads_test.go b/internal/appwire/reads_test.go new file mode 100644 index 0000000000..030d5dcadd --- /dev/null +++ b/internal/appwire/reads_test.go @@ -0,0 +1,102 @@ +package appwire + +import ( + "encoding/json" + "reflect" + "strings" + "testing" +) + +// messageEngine is a send engine that is also a MessageSender. +type messageEngine struct { + sendEngine + sentAgent string + message AgentMessage +} + +func (m *messageEngine) SendMessage(agent string, message AgentMessage) (uint64, error) { + m.sentAgent, m.message = agent, message + return 11, nil +} + +// A SEND THAT CARRIES SAID OR READS REACHES THE ENGINE'S DOOR FOR THEM WHOLE, +// or is refused: the words, the person's own words (an explicit empty one +// included), the staged ids, the client id and every count of every read. A +// send with neither goes by the road it always went by, and an engine with no +// door for them refuses rather than running the words without them. +func TestASendWithSaidOrReadsReachesTheMessageDoorWhole(t *testing.T) { + send := methods["agents.send"] + eng := &messageEngine{} + params := `{"id":"a1","text":"compare\n\n- docs/a.md","said":"compare","client_id":"c9","attachments":["at1"],"reads":[` + + `{"source":{"kind":"collection","id":"c1","name":"Specs"},"count":200,"missing":1,"unavailable":0,"unread":0,"more":false,"complete":true,` + + `"warnings":["one is gone"],"items":[{"kind":"file","id":"r1","name":"a.md","owner":"fs","path":"docs/a.md","url":"https://x/a"}]}]}` + r, err := send.call(eng, json.RawMessage(params)) + if err != nil || r.(*TurnResult).Turn != 11 { + t.Fatalf("the send answered %v %v", r, err) + } + said := "compare" + want := AgentMessage{Text: "compare\n\n- docs/a.md", Said: &said, ClientID: "c9", Attachments: []string{"at1"}, Reads: []MessageRead{{ + Source: MessageReference{Kind: "collection", ID: "c1", Name: "Specs"}, Count: 200, Missing: 1, Complete: true, + Warnings: []string{"one is gone"}, + Items: []MessageReference{{Kind: "file", ID: "r1", Name: "a.md", Owner: "fs", Path: "docs/a.md", URL: "https://x/a"}}, + }}} + if eng.sentAgent != "a1" || !reflect.DeepEqual(eng.message, want) { + t.Fatalf("the engine heard %q %+v", eng.sentAgent, eng.message) + } + if _, err := send.call(eng, json.RawMessage(`{"id":"a1","text":"x","said":""}`)); err != nil || eng.message.Said == nil || *eng.message.Said != "" { + t.Fatalf("an explicit empty said answered %v, the engine heard %+v", err, eng.message) + } + eng.message = AgentMessage{} + if _, err := send.call(eng, json.RawMessage(`{"id":"a1","text":"plain"}`)); err != nil || eng.message.Text != "" { + t.Fatalf("a plain send went by the message door: %v %+v", err, eng.message) + } + if _, err := send.call(eng, json.RawMessage(`{"id":"a1","text":"x","said":"x","attachments":["at1","at1"]}`)); codeOf(err) != CodeBadParams { + t.Fatalf("an id named twice answered %v", err) + } + if _, err := send.call(&sendEngine{}, json.RawMessage(`{"id":"a1","text":"x","reads":[{"source":{"kind":"collection","id":"c1"},"count":1}]}`)); codeOf(err) != CodeUnknownMethod { + t.Fatalf("an engine without the door answered %v", err) + } + + message := methods["crews.message"] + if _, err := message.call(eng, json.RawMessage(`{"crew":"c1","text":"x","said":"","reads":[{"source":{"kind":"collection","id":"c1"},"count":1}]}`)); err != nil || + eng.crew.Said == nil || *eng.crew.Said != "" || len(eng.crew.Reads) != 1 || eng.crew.Reads[0].Count != 1 { + t.Fatalf("a crew message answered %v, the engine heard %+v", err, eng.crew) + } +} + +// A READ'S COUNTS AND FLAGS ARE ALWAYS SAID ON THE WIRE, zero and false +// included, and an entry with no reads says nothing of them. +func TestAReadSaysEveryCountAndAnEntryWithoutReadsSaysNone(t *testing.T) { + out, err := json.Marshal(Entry{ID: "u0", Kind: EntryUser, Reads: []MessageRead{{Source: MessageReference{Kind: "collection", ID: "c1"}}}}) + if err != nil { + t.Fatal(err) + } + for _, key := range []string{`"count":0`, `"missing":0`, `"unavailable":0`, `"unread":0`, `"more":false`, `"complete":false`, `"source":{"kind":"collection","id":"c1"}`} { + if !strings.Contains(string(out), key) { + t.Fatalf("%s is not in %s", key, out) + } + } + plain, _ := json.Marshal(Entry{ID: "u0", Kind: EntryUser}) + if strings.Contains(string(plain), "reads") { + t.Fatalf("an entry without reads says %s", plain) + } +} + +// HELLO SAYS THE ENGINE KEEPS READS exactly when it is a MessageSender. +func TestHelloNamesMessageReadsOnlyForAnEngineThatKeepsThem(t *testing.T) { + has := func(hello Hello) bool { + for _, f := range hello.Features { + if f == FeatureMessageReads { + return true + } + } + return false + } + if !has(withMessageReadsFeature(Hello{}, &messageEngine{})) || has(withMessageReadsFeature(Hello{}, &sendEngine{})) { + t.Fatal("hello names messages.reads for the wrong engine") + } + twice := withMessageReadsFeature(Hello{Features: []string{FeatureMessageReads}}, &messageEngine{}) + if len(twice.Features) != 1 { + t.Fatalf("hello names it %d times", len(twice.Features)) + } +} diff --git a/internal/appwire/server.go b/internal/appwire/server.go index a7de127755..23b637e088 100644 --- a/internal/appwire/server.go +++ b/internal/appwire/server.go @@ -144,6 +144,7 @@ type request struct { func (s *Server) Serve(ctx context.Context, in io.Reader, hello Hello, engine Engine) error { hello.Contract = Contract hello = withCollectionsFeature(hello, engine) + hello = withMessageReadsFeature(hello, engine) s.Emit(EventHello, hello) if warmer, ok := engine.(Warmer); ok { go warmer.Warm() @@ -387,10 +388,12 @@ var methods = map[string]method{ return &agentResult{agent}, nil }), "agents.send": typed(func(e Engine, p struct { - ID string `json:"id"` - Text string `json:"text"` - ClientID string `json:"client_id"` - Attachments []string `json:"attachments"` + ID string `json:"id"` + Text string `json:"text"` + Said *string `json:"said"` + ClientID string `json:"client_id"` + Attachments []string `json:"attachments"` + Reads []MessageRead `json:"reads"` }) (*TurnResult, error) { if strings.TrimSpace(p.ID) == "" { return nil, Errorf(CodeBadParams, "agents.send needs an id") @@ -401,7 +404,19 @@ var methods = map[string]method{ } var turn uint64 var err error - if len(p.Attachments) > 0 { + if p.Said != nil || len(p.Reads) > 0 { + // WHAT THE MESSAGE CARRIED IS SENT WITH IT OR NOT AT ALL: an engine + // with no door for it refuses, rather than running the words and + // keeping a message the person did not send. + s, ok := e.(MessageSender) + if !ok { + return nil, Errorf(CodeUnknownMethod, "this engine does not serve agents.send with said or reads") + } + if err := CheckAttachIDs(p.Attachments); err != nil { + return nil, err + } + turn, err = s.SendMessage(p.ID, AgentMessage{Text: p.Text, Said: p.Said, ClientID: p.ClientID, Attachments: p.Attachments, Reads: p.Reads}) + } else if len(p.Attachments) > 0 { t, terr := talker(e, "agents.send with attachments") if terr != nil { return nil, terr diff --git a/internal/appwire/wire.go b/internal/appwire/wire.go index 51568f0588..00e6e08b5c 100644 --- a/internal/appwire/wire.go +++ b/internal/appwire/wire.go @@ -334,6 +334,10 @@ type Entry struct { ModelFrom string `json:"model_from,omitempty"` ModelTo string `json:"model_to,omitempty"` Attachments []Attachment `json:"attachments,omitempty"` + // Reads, on a person's message, are the reads of references its words + // were written from, as the app found them when it sent it (convo.go's + // [MessageRead]); absent on every message sent without them. + Reads []MessageRead `json:"reads,omitempty"` // Op is a durable fact the conversation's record keeps beside its // messages, said as the fact and never as words: the app words it. Op *EntryOp `json:"op,omitempty"` diff --git a/internal/session/submission.go b/internal/session/submission.go index a441f25eb9..469b6ef465 100644 --- a/internal/session/submission.go +++ b/internal/session/submission.go @@ -250,6 +250,12 @@ func (m Submission) mark() (*messagePresentation, error) { return mark, nil } +// KeptReads is reads as the record will keep them — trimmed, and copies of +// their own — or the refusal [Agent.SubmitMessage] would answer them with, so a +// door can refuse a message before it does anything else for it, and show the +// person exactly what a reading of the record will show them later. +func KeptReads(reads []MessageRead) ([]MessageRead, error) { return keptReads(reads) } + // keptReads is the record's own copy of a message's reads, every bound checked // on the bytes as given, and nil when there are none. NOTHING IS TRIMMED TO // FIT: a count is the door's observation, so a read that does not fit refuses From 92839022a6627eabc337f6cceeba864343a6fc84 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 21:39:52 -0400 Subject: [PATCH 087/459] Refuse marked messages before an older host can lose their facts --- internal/remote/client.go | 8 ++++++++ internal/remote/server.go | 19 +++++++++++++++---- internal/remote/submitmessage_test.go | 23 +++++++++++++++++++---- internal/remote/wire.go | 4 ++++ 4 files changed, 46 insertions(+), 8 deletions(-) diff --git a/internal/remote/client.go b/internal/remote/client.go index efb8c8b346..4f1977bc01 100644 --- a/internal/remote/client.go +++ b/internal/remote/client.go @@ -1740,6 +1740,14 @@ func (a *Agent) Submit(ctx context.Context, text string) (<-chan session.Event, // words ([session.Agent.SubmitMessage]): it travels on the same call, so the // engine records the words and the mark on one line or neither. func (a *Agent) SubmitMessage(ctx context.Context, message session.Submission) (<-chan session.Event, error) { + if message.Said != nil || len(message.Attachments) > 0 || len(message.Reads) > 0 { + a.c.mu.Lock() + known := a.c.welcome.MessageMarks + a.c.mu.Unlock() + if !known { + return nil, errors.New("engine: this engine cannot keep a message's description; update it before sending") + } + } return a.open(ctx, MethodSubmit, SubmitArgs{Text: message.Text, Said: message.Said, Attachments: message.Attachments, Reads: message.Reads}) } diff --git a/internal/remote/server.go b/internal/remote/server.go index 6e2eca5ebd..fe337ece28 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -1202,6 +1202,13 @@ func memoryCommandsKnown(agent any) bool { return ok && door.Remembers() } +func messageMarksKnown(agent any) bool { + _, ok := agent.(interface { + SubmitMessage(context.Context, session.Submission) (<-chan session.Event, error) + }) + return ok +} + func (sess *Session) welcomeLocked(s *server) Welcome { // A HOSTED START MUST READ THE ENGINE'S FILE, not the surface's. Carrying // this reading in the welcome is what makes an old persistent engine say @@ -1214,6 +1221,7 @@ func (sess *Session) welcomeLocked(s *server) Welcome { note += newer } return Welcome{ + MessageMarks: messageMarksKnown(sess.agent), Version: Version, Workspace: sess.engine.Workspace, SessionFile: sess.engine.SessionFile, @@ -2550,14 +2558,17 @@ func (s *server) invoke(call Frame) (out json.RawMessage, err error) { if err != nil { return nil, err } - // A MESSAGE WITH A MARK IS RECORDED WITH IT, by the agent's own door for - // one. An agent without that door runs the words alone, which is what - // every message was before the mark existed. + // A MESSAGE WITH A MARK IS RECORDED WITH IT OR REFUSED WHOLE. Running + // only its words would silently lose what the person attached or read. var events <-chan session.Event door, marked := agent.(interface { SubmitMessage(context.Context, session.Submission) (<-chan session.Event, error) }) - if marked && (args.Said != nil || len(args.Attachments) > 0 || len(args.Reads) > 0) { + hasMark := args.Said != nil || len(args.Attachments) > 0 || len(args.Reads) > 0 + if hasMark && !marked { + return nil, errors.New("engine: this session cannot keep a message's description; update the engine before sending it") + } + if hasMark { events, err = door.SubmitMessage(context.Background(), session.Submission{Text: args.Text, Said: args.Said, Attachments: args.Attachments, Reads: args.Reads}) } else { events, err = agent.Submit(context.Background(), args.Text) diff --git a/internal/remote/submitmessage_test.go b/internal/remote/submitmessage_test.go index 1ba2b6a3b1..627058e0e2 100644 --- a/internal/remote/submitmessage_test.go +++ b/internal/remote/submitmessage_test.go @@ -25,7 +25,7 @@ func (m *markedAgent) SubmitMessage(ctx context.Context, message session.Submiss // A MESSAGE'S MARK CROSSES THE WIRE ON THE SAME CALL AS ITS WORDS. Submit with // the person's words and attachments reaches the agent's own door for them, // whole; Submit without them is the plain Submit it always was; and an agent -// with no such door — an engine from before the mark — runs the words alone. +// with no such door refuses the whole marked message. func TestSubmitCarriesTheMessagesMarkToTheAgentsDoor(t *testing.T) { said := "" pasteBytes, pasteLines := int64(12), 2 @@ -64,10 +64,25 @@ func TestSubmitCarriesTheMessagesMarkToTheAgentsDoor(t *testing.T) { plain := &fakeAgent{model: "m"} s = &server{out: io.Discard, session: NewSession(&Engine{Agent: plain}, false)} - if _, err := s.invoke(Frame{Kind: "call", ID: 1, Method: MethodSubmit, Payload: mustJSON(SubmitArgs{Text: "Attached file: notes.md", Said: &said, Attachments: attachments})}); err != nil { - t.Fatal(err) + if _, err := s.invoke(Frame{Kind: "call", ID: 1, Method: MethodSubmit, Payload: mustJSON(SubmitArgs{Text: "Attached file: notes.md", Said: &said, Attachments: attachments})}); err == nil { + t.Fatal("an engine without the marked door accepted a marked message") } - if !reflect.DeepEqual(plain.sent, []string{"Attached file: notes.md"}) { + if len(plain.sent) != 0 { t.Fatalf("an agent with no door for the mark was sent %q", plain.sent) } } + +func TestAnOlderHostIsRefusedBeforeAMarkedMessageCrossesTheWire(t *testing.T) { + client := &Client{welcome: Welcome{Version: Version}} + agent := &Agent{c: client} + reads := []session.MessageRead{{Source: session.MessageReference{Kind: "collection", ID: "c1"}, Count: 1}} + if _, err := agent.SubmitMessage(t.Context(), session.Submission{Text: "read these", Reads: reads}); err == nil { + t.Fatal("an older host was sent a marked message") + } + if client.made.Load() != 0 { + t.Fatal("a refused message crossed the wire") + } + if !messageMarksKnown(&markedAgent{fakeAgent: &fakeAgent{}}) || messageMarksKnown(&fakeAgent{}) { + t.Fatal("the welcome does not distinguish engines that keep a mark") + } +} diff --git a/internal/remote/wire.go b/internal/remote/wire.go index 2cc08ce312..0655a382d9 100644 --- a/internal/remote/wire.go +++ b/internal/remote/wire.go @@ -1040,6 +1040,10 @@ type StreamCursor struct { // Welcome is the server's answer ("welcome"): the facts a surface needs before // its first frame, which are the same facts newApp reads off a local agent. type Welcome struct { + // MessageMarks says this engine records a submission's authored words, + // attachments and reference-read facts together. Absence is an older + // engine: a marked submission must be refused before any words are sent. + MessageMarks bool `json:"messageMarks,omitempty"` // SessionInstance identifies this live conversation owner, not its durable // transcript. A replaced engine can reopen the same file with stream IDs // starting over, so a returning surface must forget the previous owner IDs. From a9d67fdb0161c8aa25020e10d5ac5c7d2d907eba Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 21:51:23 -0400 Subject: [PATCH 088/459] Give billed execution steps immutable run identities without guessing legacy attribution --- internal/session/agent.go | 2 + internal/session/loop.go | 14 ++-- internal/session/run_identity.go | 29 +++++++++ internal/session/run_identity_test.go | 92 +++++++++++++++++++++++++++ internal/session/usage_ledger.go | 5 ++ 5 files changed, 138 insertions(+), 4 deletions(-) create mode 100644 internal/session/run_identity.go create mode 100644 internal/session/run_identity_test.go diff --git a/internal/session/agent.go b/internal/session/agent.go index 4b630d38f8..dc86df80f0 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -1795,6 +1795,7 @@ func (a *Agent) startTurnLocked(ctx context.Context, user userMessage, watcher * // the next reply. a.moveAsked.Store(false) turnCtx, cancel := context.WithCancelCause(ctx) + turnCtx = withRunIdentity(turnCtx) a.cancel = cancel // AND THE TURN CARRIES ITS OWN SECOND STAGE (abandon.go). The signal rides on // the context because the waits that need it are several frames down inside @@ -2011,6 +2012,7 @@ func (a *Agent) startTurnLocked(ctx context.Context, user userMessage, watcher * // turn opens empty and reads its note off the steering queue, and a // matcher that went looking in the transcript would score the last thing // somebody typed against a turn they did not start. + a.file.appendRunIdentity(turnCtx) completed = a.runTurn(turnCtx, hub, user) }() return events diff --git a/internal/session/loop.go b/internal/session/loop.go index e90bfa4a99..9f8b654f87 100644 --- a/internal/session/loop.go +++ b/internal/session/loop.go @@ -1041,7 +1041,7 @@ func (a *Agent) runTurn(ctx context.Context, hub *eventHub, user userMessage) bo // THE LANE IS READ BEFORE THE MONEY IS BANKED, because the row the // money writes is this call's and the witness is what measured it. facts := a.turnLane.answered(readHedge(hedge, served.Name()), responseOutput(response)) - a.addUsage(&turn, response, model, served.Name(), facts) + a.addUsage(&turn, response, model, served.Name(), facts, runIdentity(ctx)) a.tellLaneNews(model, facts, hedge) droppedCall := forming.any() || warm.anyAnnounced() a.keepSteeredPartial(partial, reasoning, droppedCall, hub, cutDroppedCallNote(err)) @@ -1097,7 +1097,7 @@ func (a *Agent) runTurn(ctx context.Context, hub *eventHub, user userMessage) bo // ledger line carries ([Agent.addUsage]) rather than something held back // for the turn's seal to collect. facts := a.turnLane.answered(readHedge(hedge, served.Name()), responseOutput(response)) - a.addUsage(&turn, response, model, served.Name(), facts) + a.addUsage(&turn, response, model, served.Name(), facts, runIdentity(ctx)) // AND THE SURFACE IS TOLD WHO ANSWERED, on the same line and at the same // grain (lanenews.go). It is the push half of a seam whose pull half // cannot exist: a status line cannot see a stream, and the arrow between @@ -4192,6 +4192,8 @@ 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 { + // run is captured from the call's immutable execution context, never the current turn. + run string used Usage model string role string @@ -4331,7 +4333,7 @@ func (a *Agent) reconciled(receipt provider.Reconciled) { // standing (runTurn re-latches on an answer from further down the chain), and // `served` is the endpoint the router says answered, "" when nothing said. // `lane` is what the witness measured about THIS request and nothing else. -func (a *Agent) addUsage(turn *Usage, response *ai.Response, model, served string, lane laneFacts) { +func (a *Agent) addUsage(turn *Usage, response *ai.Response, model, served string, lane laneFacts, runIDs ...string) { // The row's endpoint is the answer's own naming or, for a service that // declared the one machine it is, that declared name ([Agent.attributedEndpoint]). // `lane` was read from the answer alone, so what the lane layer learns and @@ -4389,7 +4391,11 @@ func (a *Agent) addUsage(turn *Usage, response *ai.Response, model, served strin // It is the same fact the model-call log's tag carries (loop.go), read from // the one config row that states it; an ordinary conversation or worker // leaves it empty and the row names no role, which is what it always did. - a.bank(bankedCall{used: call, model: answered, lane: lane, ledger: true, turn: true, context: context, role: string(a.config.crewRole)}) + run := "" + if len(runIDs) > 0 { + run = runIDs[0] + } + a.bank(bankedCall{used: call, model: answered, lane: lane, ledger: true, turn: true, context: context, role: string(a.config.crewRole), run: run}) // AND THE TURN KEEPS THE SAME SHARE PER ANSWERING MODEL, under the very // name the row above banks, so a turn that hopped seals one usage line per diff --git a/internal/session/run_identity.go b/internal/session/run_identity.go new file mode 100644 index 0000000000..0ec61693bb --- /dev/null +++ b/internal/session/run_identity.go @@ -0,0 +1,29 @@ +package session + +import ( + "context" + "crypto/rand" + "time" +) + +type runIdentityKey struct{} + +// withRunIdentity gives one execution an immutable identity. It never reads the +// agent's current turn after dispatch, so abandoned work cannot charge its successor. +func withRunIdentity(ctx context.Context) context.Context { + return context.WithValue(ctx, runIdentityKey{}, rand.Text()) +} +func runIdentity(ctx context.Context) string { + id, _ := ctx.Value(runIdentityKey{}).(string) + return id +} +func (s *sessionFile) appendRunIdentity(ctx context.Context) { + if s == nil || runIdentity(ctx) == "" { + return + } + s.writeLine(struct { + Type string `json:"type"` + ID string `json:"id"` + Timestamp string `json:"timestamp"` + }{Type: "usage_run", ID: runIdentity(ctx), Timestamp: journalInstant(time.Now())}) +} diff --git a/internal/session/run_identity_test.go b/internal/session/run_identity_test.go new file mode 100644 index 0000000000..e569374add --- /dev/null +++ b/internal/session/run_identity_test.go @@ -0,0 +1,92 @@ +package session + +import ( + "context" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" +) + +func TestRealTurnsPersistDistinctRunIdentityWithEveryBilledStep(t *testing.T) { + dir := t.TempDir() + ledger := filepath.Join(dir, UsageLedgerName) + journal := filepath.Join(dir, "session.jsonl") + ids := []string{} + answer := func(ctx context.Context, _ []ai.Message) (*ai.Response, error) { + id := runIdentity(ctx) + if id == "" { + t.Error("provider received no immutable run identity") + } + ids = append(ids, id) + return textResponse("done"), nil + } + agent, _ := newTestAgent(t, &scriptedCompleter{steps: []step{answer, answer}}, func(c *Config) { c.usageLedger = ledger; c.SessionFile = journal }) + collect(t, mustSubmit(t, agent, "first")) + collect(t, mustSubmit(t, agent, "second")) + FlushUsage() + rows, err := ReadUsage(ledger, time.Time{}) + if err != nil { + t.Fatal(err) + } + if len(rows) != 2 || len(ids) != 2 || ids[0] == ids[1] { + t.Fatalf("rows=%+v ids=%v", rows, ids) + } + for i, row := range rows { + if row.Run != ids[i] { + t.Fatalf("row %d attributed to %q instead of %q", i, row.Run, ids[i]) + } + } + raw, err := os.ReadFile(journal) + if err != nil { + t.Fatal(err) + } + runs := []string{} + for _, line := range strings.Split(string(raw), "\n") { + var v struct { + Type string `json:"type"` + ID string `json:"id"` + } + _ = json.Unmarshal([]byte(line), &v) + if v.Type == "usage_run" { + runs = append(runs, v.ID) + } + } + if len(runs) != 2 || runs[0] != ids[0] || runs[1] != ids[1] { + t.Fatalf("journal identities %v do not match actual turns %v", runs, ids) + } +} + +func TestNewExecutionDoesNotInheritItsParentsRunIdentity(t *testing.T) { + parent := withRunIdentity(context.Background()) + child := withRunIdentity(parent) + if runIdentity(parent) == "" || runIdentity(child) == "" || runIdentity(parent) == runIdentity(child) { + t.Fatal("new execution inherited old identity") + } + if runIdentity(context.Background()) != "" { + t.Fatal("legacy execution guessed an identity") + } +} + +func TestLateUnattributedMoneyIsNotAssignedToTheCurrentRun(t *testing.T) { + ledger := filepath.Join(t.TempDir(), UsageLedgerName) + a, _ := ledgerAgent(t, ledger) + old := withRunIdentity(context.Background()) + newer := withRunIdentity(old) + var turn Usage + a.addUsage(&turn, billedCall("test/model", 3, 1, .1), "test/model", "", laneFacts{}, runIdentity(old)) + a.bank(bankedCall{used: Usage{Input: 2, Output: 1, CostUSD: .2, Calls: 1}, model: "test/model", ledger: true, late: true}) + a.addUsage(&turn, billedCall("test/model", 4, 2, .3), "test/model", "", laneFacts{}, runIdentity(newer)) + FlushUsage() + rows, err := ReadUsage(ledger, time.Time{}) + if err != nil { + t.Fatal(err) + } + if len(rows) != 3 || rows[0].Run != runIdentity(old) || rows[1].Run != "" || rows[2].Run != runIdentity(newer) { + t.Fatalf("incorrect run attribution %+v", rows) + } +} diff --git a/internal/session/usage_ledger.go b/internal/session/usage_ledger.go index c2b97a1301..846bc93cca 100644 --- a/internal/session/usage_ledger.go +++ b/internal/session/usage_ledger.go @@ -131,6 +131,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 { + // Run is the immutable execution that made this call, when its caller + // supplied one. Older, auxiliary, or late receipt rows may have no identity; + // absence must never be read as a single aggregate run. + Run string `json:"run,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 @@ -965,6 +969,7 @@ func (a *Agent) recordUsageLine(call bankedCall) { Reconciled: call.reconciled, Session: session, + Run: call.run, // The node this row belongs to: the node this agent IS, or — for a // checker, which is not the node — the node it CHECKS // ([Config.checksNode]). The two are never both set, and the figure is From 36aff3f5190d9b47502ce578ba9fd3ab1accde30 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 21:45:17 -0400 Subject: [PATCH 089/459] taskrouting: split the crew doors along the policy they already had A routed read, a live change, a rerun, and a seat that falls back were each one function past the ceiling. The estimate, the cap, who moves, and who holds a pin are now their own functions, and the doors only sequence them. --- internal/session/task.go | 89 +- internal/session/taskcrew.go | 101 ++- internal/session/taskrouting.go | 859 +++++++++++++------- internal/session/taskrouting_policy_test.go | 143 ++++ 4 files changed, 834 insertions(+), 358 deletions(-) create mode 100644 internal/session/taskrouting_policy_test.go diff --git a/internal/session/task.go b/internal/session/task.go index 263851552b..f21eec72f8 100644 --- a/internal/session/task.go +++ b/internal/session/task.go @@ -954,33 +954,57 @@ func proposalBrief(spec taskSpec, via *delegate.Delegate) string { // of its own on the brief, which is not what was asked for, so its failure is // answered as a refusal. func (a *Agent) commitProposalToRun(ctx context.Context, p *stagedProposal, spec taskSpec, elsewhere string) (string, bool, bool) { - if !(bashBeltAsked() || spec.via != "") || chatRunEngine == nil || a.config.InTask { + if !a.runRoadOwns(spec) { return "", false, false } a.mu.Lock() question := questionAtTaskHandoff(a.owedAsks) a.mu.Unlock() - var via *delegate.Delegate - if spec.via != "" { - m, err := a.delegateFor(spec.via) - if err != nil { - a.settleUnstartedHandoff(p.id, spec.title, err) - return err.Error(), true, true - } - via = &m - } - description := proposalBrief(spec, via) - // THE RUN OUTLIVES THE TURN THAT LAUNCHED IT, AND NOT THE CONVERSATION. - // This context is the turn's, and the turn cancels it on its way out - // (agent.go, `defer cancel(nil)`); a run driven under it would be stopped - // the moment the model finished its sentence. The values ride along, the - // cancellation does not. What ends it instead is the conversation: the - // person's stop (stoprun.go) or the room closing ([Agent.cutBeltRun]). - // - // WHETHER IT JOINED is the start door's answer and not a look taken - // before it: in a batch committed at one moment none of the hand-offs - // could see a live run beforehand, and the one that opened the run is - // decided under the start lock ([Agent.startOrJoinTaskRunVia]). + via, err := a.handoffDelegate(spec) + if err != nil { + a.settleUnstartedHandoff(p.id, spec.title, err) + return err.Error(), true, true + } + joined, stand, asked, err := a.startApprovedHandoff(ctx, p, spec, via, question) + // A ROLE WITH NOTHING ALLOWED TO RUN ON IS A QUESTION, NOT A FAILURE: the + // task waits, asks, and starts from here once a model is allowed + // (taskrouting.go). Nothing ran and nothing was moved onto another model. + if receipt, parked := a.parkUnroutedHandoff(ctx, p, spec, elsewhere, via, err); parked { + return receipt, false, true + } + return a.handoffAnswer(p, spec, elsewhere, via, asked, stand, joined, err) +} + +// runRoadOwns is an approved hand-off this door starts as a run: the bash +// belt, or a named program. A session inside a task, or one with no run +// engine, leaves the hand-off to the older road. +func (a *Agent) runRoadOwns(spec taskSpec) bool { + if chatRunEngine == nil || a.config.InTask { + return false + } + return bashBeltAsked() || spec.via != "" +} + +// handoffDelegate is the program a hand-off named, or nil when it named none. +// A name this session cannot resolve is the error, and the hand-off does not start. +func (a *Agent) handoffDelegate(spec taskSpec) (*delegate.Delegate, error) { + if spec.via == "" { + return nil, nil + } + m, err := a.delegateFor(spec.via) + if err != nil { + return nil, err + } + return &m, nil +} + +// startApprovedHandoff registers the run. The context it is given outlives +// the turn that launched it: that turn cancels its own context on the way out +// (agent.go, `defer cancel(nil)`), and a run driven under it would stop when +// the model finished its sentence. What ends the run is the conversation, the +// person's stop or the room closing. Whether it joined a run already underway +// is this door's answer, decided under the start lock. +func (a *Agent) startApprovedHandoff(ctx context.Context, p *stagedProposal, spec taskSpec, via *delegate.Delegate, question string) (bool, taskStand, []string, error) { stand := p.stand if via != nil { stand = delegateStand(stand.dir) @@ -991,19 +1015,16 @@ func (a *Agent) commitProposalToRun(ctx context.Context, p *stagedProposal, spec prior = a.keepProgramAttempt(p.id, a.programAttemptOf()) ctx = withProgramWish(ctx, programWish{thinking: spec.thinking, carry: a.programCarryOf(prior, via.Name)}) } - joined, err := a.startOrJoinTaskRunVia(context.WithoutCancel(ctx), p.id, spec.title, description, spec.dependsOn, stand, question, via, asked...) + joined, err := a.startOrJoinTaskRunVia(context.WithoutCancel(ctx), p.id, spec.title, proposalBrief(spec, via), spec.dependsOn, stand, question, via, asked...) a.rollbackFailedProgramStart(via, p.id, prior, err) - // A ROLE WITH NOTHING ALLOWED TO RUN ON IS A QUESTION, NOT A FAILURE: the - // task waits, asks, and starts from here once a model is allowed - // (taskrouting.go). Nothing ran and nothing was moved onto another model. - if receipt, parked := a.parkUnroutedHandoff(ctx, p, spec, elsewhere, via, err); parked { - return receipt, false, true - } - // AN APPROVED HAND-OFF THAT DID NOT START IS SETTLED WHERE IT IS SEEN. The - // card's last word was the approval ([taskWait.decided]), which every - // surface reads as work waiting to begin; the refusal below reaches only the - // model. So every ending of this door that started nothing publishes the - // row as failed, in the same words, before it answers. + return joined, stand, asked, err +} + +// handoffAnswer is what an approved hand-off that was this door's to start +// says back. A start that failed is settled as failed before the model hears +// it, except a run road that is not there at all, which the older engine +// still admits under the same id. +func (a *Agent) handoffAnswer(p *stagedProposal, spec taskSpec, elsewhere string, via *delegate.Delegate, asked []string, stand taskStand, joined bool, err error) (string, bool, bool) { a.settleUnstartedHandoff(p.id, spec.title, err) if refusal := (standsElsewhereError{}); errors.As(err, &refusal) { return refusal.Error(), true, true diff --git a/internal/session/taskcrew.go b/internal/session/taskcrew.go index 3b401ac134..a00d95e968 100644 --- a/internal/session/taskcrew.go +++ b/internal/session/taskcrew.go @@ -908,46 +908,21 @@ func (a *Agent) moveCrewSeat(run *beltRun, key, current string, kind provider.Ro if pick.Send != current || crew.original[seat] != key { continue } - if pick.Pinned || decision.Mode == crewroute.ModeOne { - // A PINNED ROLE — a saved pin, a pin for this task, one model for - // every role — HOLDS ITS EXACT MODEL when its route fails. Only a - // person moves it; the history says it failed, when and why. - if !crew.heldLocked(seat, pick.Send, string(kind)) { - crew.noteChangeLocked(TaskCrewChange{At: time.Now(), Seat: seat, From: pick.Model, FromProvider: pick.Provider, FromSend: pick.Send, - By: TaskCrewByEngine, Kind: TaskCrewChangeHeld, Why: string(kind)}) - } + // A PINNED ROLE — a saved pin, a pin for this task, one model for + // every role — HOLDS ITS EXACT MODEL when its route fails. Only a + // person moves it; the history says it failed, when and why. + if seatHoldsItsModel(pick, decision.Mode) { + noteHeldFailureLocked(crew, seat, pick, kind) continue } - rescue := config.CrewRescue(a.config.ProfileDir, decision.Class, seat, a.Model()) - if run.recoveredCrew != nil { - rescue = crew.rescue[seat] - } - rung, ok := crew.takeRungLocked(seat, append(append([]crewroute.Pick(nil), crew.ladders[seat]...), rescue...)) + rung, ok := crew.takeRungLocked(seat, rescueLadderLocked(a, run, crew, decision, seat)) if !ok { continue } - decision = withFreeNotice(decision.WithRung(seat, rung, crewWhy(kind, provided)), rung) - crew.noteChangeLocked(TaskCrewChange{Seat: seat, From: pick.Model, FromProvider: pick.Provider, FromSend: pick.Send, To: rung.Model, ToProvider: rung.Provider, ToSend: rung.Send, - By: TaskCrewByEngine, Kind: TaskCrewChangeFallback, Why: string(kind)}) - if crew.seatSends[seat] != "" { - crew.seatSends[seat] = rung.Send - } else { - shared = true - } - if next.Send == "" { - next = rung - } + decision, next, shared = fallBackSeatLocked(crew, decision, seat, pick, rung, kind, provided, next, shared) } if next.Send == "" { - action := crew.actionLocked(kind, provided, until) - stopped := crewStopsOn(kind) || len(crew.broke) > 0 || len(crew.gone) > 0 - if stopped { - // THE CAUSE IS KNOWN, SO THE LINE SAYS THE ONE THING TO DO — and - // not "/redo stronger", which no stronger crew on the same - // account could make work. - decision.Stopped = action - crew.decision = decision - } + action, stopped, decision := nowhereLeftLocked(crew, decision, kind, provided, until) crew.mu.Unlock() if stopped { a.republishCrew(run, decision, current+" could not start · "+action) @@ -966,6 +941,66 @@ func (a *Agent) moveCrewSeat(run *beltRun, key, current string, kind provider.Ro return "", true } +// seatHoldsItsModel is a role a failure must not move: a pin, or one model +// for every role. Only a person changes it. +func seatHoldsItsModel(pick crewroute.Pick, mode crewroute.Mode) bool { + return pick.Pinned || mode == crewroute.ModeOne +} + +// noteHeldFailureLocked records that a held role's route failed, once per +// send and reason. The caller holds crew.mu. +func noteHeldFailureLocked(crew *taskCrew, seat crewroute.Seat, pick crewroute.Pick, kind provider.RouteFailure) { + if crew.heldLocked(seat, pick.Send, string(kind)) { + return + } + crew.noteChangeLocked(TaskCrewChange{At: time.Now(), Seat: seat, From: pick.Model, FromProvider: pick.Provider, FromSend: pick.Send, + By: TaskCrewByEngine, Kind: TaskCrewChangeHeld, Why: string(kind)}) +} + +// rescueLadderLocked is the rungs a seat may fall to: its own ladder, then +// the rescue list. A crew restored from a checkpoint uses the rescue it kept, +// not today's settings. The caller holds crew.mu. +func rescueLadderLocked(a *Agent, run *beltRun, crew *taskCrew, decision crewroute.Decision, seat crewroute.Seat) []crewroute.Pick { + rescue := config.CrewRescue(a.config.ProfileDir, decision.Class, seat, a.Model()) + if run.recoveredCrew != nil { + rescue = crew.rescue[seat] + } + return append(append([]crewroute.Pick(nil), crew.ladders[seat]...), rescue...) +} + +// fallBackSeatLocked moves one seat onto rung and remembers the first such +// rung as where the shared send goes. A seat that had its own send keeps +// that send pointed at the rung; a shared send is swapped once, outside. +// The caller holds crew.mu. +func fallBackSeatLocked(crew *taskCrew, decision crewroute.Decision, seat crewroute.Seat, pick, rung crewroute.Pick, kind provider.RouteFailure, provided string, next crewroute.Pick, shared bool) (crewroute.Decision, crewroute.Pick, bool) { + decision = withFreeNotice(decision.WithRung(seat, rung, crewWhy(kind, provided)), rung) + crew.noteChangeLocked(TaskCrewChange{Seat: seat, From: pick.Model, FromProvider: pick.Provider, FromSend: pick.Send, To: rung.Model, ToProvider: rung.Provider, ToSend: rung.Send, + By: TaskCrewByEngine, Kind: TaskCrewChangeFallback, Why: string(kind)}) + if crew.seatSends[seat] != "" { + crew.seatSends[seat] = rung.Send + } else { + shared = true + } + if next.Send == "" { + next = rung + } + return decision, next, shared +} + +// nowhereLeftLocked is the one action when no seat could move. A cause a +// person can fix — credit, a key, a limit — stops the task on that action, +// rather than offering a stronger crew on the same account. The caller holds +// crew.mu and unlocks after. +func nowhereLeftLocked(crew *taskCrew, decision crewroute.Decision, kind provider.RouteFailure, provided string, until time.Time) (string, bool, crewroute.Decision) { + action := crew.actionLocked(kind, provided, until) + stopped := crewStopsOn(kind) || len(crew.broke) > 0 || len(crew.gone) > 0 + if stopped { + decision.Stopped = action + crew.decision = decision + } + return action, stopped, decision +} + // crewFailedLocked writes a failed first call to the router's log for every // seat on current, and marks what this task must not ask again: the route, an // account out of credit, a provider whose key was refused. It answers the diff --git a/internal/session/taskrouting.go b/internal/session/taskrouting.go index c8e882f176..df0371b006 100644 --- a/internal/session/taskrouting.go +++ b/internal/session/taskrouting.go @@ -304,8 +304,29 @@ func (a *Agent) TaskRouting(id string, changes int) (TaskRoutingView, error) { return TaskRoutingView{}, &TaskRoutingRefusal{Code: TaskRoutingNotFound, Message: "no such task"} } -// taskRoutingOf is a routed task's view from its crew record. +// taskRoutingOf is a routed task's view from its crew record: the header, then +// one projection per role. The estimate is the router's own figure when it +// published one, and the sum of the roles otherwise. func taskRoutingOf(id string, r *TaskCrewRecord, settled bool, changes int) TaskRoutingView { + view := routingViewHead(id, r, settled, changes) + known, total := true, 0.0 + for _, seat := range crewroute.Seats { + role := routingRole(r, seat, view.Mode, view.RoleFacts, settled) + if role.EstimateUSD == nil { + known = false + } else { + total += *role.EstimateUSD + } + view.Roles = append(view.Roles, role) + } + view.EstimateUSD = routedEstimate(r.Decision, total, known) + return view +} + +// routingViewHead is the task-level half of a read: identity, the newest +// changes, and the task tally. A record with no guard kept no tally, so its +// spend stays unknown rather than a proven zero. +func routingViewHead(id string, r *TaskCrewRecord, settled bool, changes int) TaskRoutingView { d := r.Decision mode := d.Mode if mode == "" { @@ -316,99 +337,144 @@ func taskRoutingOf(id string, r *TaskCrewRecord, settled bool, changes int) Task Restored: append([]time.Time(nil), r.Restored...), HelperUSD: r.HelperUSD, Class: string(d.Class), Why: d.Why, Effort: string(d.Effort), Note: d.Note, Stopped: d.Stopped, Considered: d.Considered, Warnings: append([]crewroute.SeatWarning(nil), d.Warnings...), ChangeCount: r.ChangeCount, - } - if from := len(r.Changes) - changes; from > 0 { - view.Changes = append([]TaskCrewChange(nil), r.Changes[from:]...) - } else { - view.Changes = append([]TaskCrewChange(nil), r.Changes...) + Changes: boundedRoutingChanges(r.Changes, changes), } // THE TASK'S TALLY ALREADY HOLDS ITS HELPERS: a helper call is guarded on // the same task tally the roles are ([Agent.helperGuard]), so HelperUSD is - // a breakout of SpentUSD, never added to it. A record with no guard kept - // no task tally, and its spend is unknown. - g := r.Guard - if g != nil { + // a breakout of SpentUSD, never added to it. + if g := r.Guard; g != nil { view.TaskCapUSD = g.TaskCap view.SpentUSD, view.SpentKnown = g.TaskSpent, true view.Calls, view.UnknownCalls = g.TaskCalls, g.TaskUnknown } - facts := r.RoleFacts >= 1 - view.RoleFacts, view.RestoreCount = facts, r.RestoreCount - known, total := true, 0.0 - for _, seat := range crewroute.Seats { - pick := d.Seat(seat) - role := TaskRoutingRole{Seat: seat, Model: pick.Model, Provider: pick.Provider, Send: pick.Send, Kind: pick.Kind, Quality: pick.Quality, EstimateUSD: pickEstimate(pick), Price: clonePrice(pick.Price)} - switch { - case mode == crewroute.ModeOne: - role.Source = TaskRoleOneModel - case r.TaskPins[seat]: - role.Source = TaskRoleTaskPin - case pick.Pinned: - role.Source = TaskRolePinned - default: - role.Source = TaskRoleAuto - } - if role.EstimateUSD == nil { - known = false - } else { - total += *role.EstimateUSD - } - if g != nil { - role.SpentUSD = g.SeatSpent[seat] - role.Calls, role.UnknownCalls = g.SeatCalls[seat], g.SeatUnknown[seat] - } - // A ROLE RAN WHEN ITS OWN CALL ANSWERED — never because another role - // on the same model did. Without per-role facts (an older task) a role - // with nothing recorded is unknown, not unused and not free. - used := r.SeatStarted[seat] || role.Calls > 0 || role.SpentUSD > 0 - role.SpentKnown = facts || role.SpentUSD > 0 - switch { - case r.Pending[seat]: - role.Status = TaskRolePending - case used: - role.Status = TaskRoleUsed - case !facts: - role.Status = TaskRoleUnknown - case settled: - role.Status = TaskRoleNotUsed - default: - role.Status = TaskRoleNotRunYet + view.RoleFacts, view.RestoreCount = r.RoleFacts >= 1, r.RestoreCount + return view +} + +// boundedRoutingChanges is the newest limit changes, oldest first. The count +// on the view stays the whole history. +func boundedRoutingChanges(changes []TaskCrewChange, limit int) []TaskCrewChange { + if from := len(changes) - limit; from > 0 { + return append([]TaskCrewChange(nil), changes[from:]...) + } + return append([]TaskCrewChange(nil), changes...) +} + +// routedEstimate is the crew's estimate: the router's factored figure when it +// published one, the sum of the roles when every role was priced, and nothing +// when a role's price is unknown. +func routedEstimate(d crewroute.Decision, summed float64, known bool) *float64 { + if !known { + return nil + } + if d.EstUSD > 0 && d.CostFactor > 0 { + summed = d.EstUSD + } + return &summed +} + +// routingRole is one seat as this task recorded it: how it was chosen, what +// that seat itself spent and ran, the rungs behind it, and the routes that +// failed it. Another seat on the same model does not make this one used. +func routingRole(r *TaskCrewRecord, seat crewroute.Seat, mode crewroute.Mode, facts, settled bool) TaskRoutingRole { + pick := r.Decision.Seat(seat) + role := TaskRoutingRole{Seat: seat, Model: pick.Model, Provider: pick.Provider, Send: pick.Send, Kind: pick.Kind, Quality: pick.Quality, EstimateUSD: pickEstimate(pick), Price: clonePrice(pick.Price)} + role.Source = roleSource(mode, r.TaskPins[seat], pick.Pinned) + if g := r.Guard; g != nil { + role.SpentUSD = g.SeatSpent[seat] + role.Calls, role.UnknownCalls = g.SeatCalls[seat], g.SeatUnknown[seat] + } + role.SpentKnown = facts || role.SpentUSD > 0 + role.Status = roleStatus(r.Pending[seat], roleHasRun(r.SeatStarted[seat], role.Calls, role.SpentUSD), facts, settled) + role.Alternatives = roleAlternatives(r.Ladders[seat], pick.Send) + role.Rejected = roleRejections(r, seat) + return role +} + +// roleSource is how the role got its model. A pin for this task outranks a +// saved pin; one model for every role outranks both. +func roleSource(mode crewroute.Mode, taskPin, savedPin bool) string { + switch { + case mode == crewroute.ModeOne: + return TaskRoleOneModel + case taskPin: + return TaskRoleTaskPin + case savedPin: + return TaskRolePinned + default: + return TaskRoleAuto + } +} + +// roleHasRun is whether THIS seat's own call answered. Spend or a start on +// the seat counts; a sibling seat on the same model does not. +func roleHasRun(started bool, calls int, spent float64) bool { + return started || calls > 0 || spent > 0 +} + +// roleStatus is whether the role has answered. Without per-role facts an +// older task's role is unknown, not unused and not free. +func roleStatus(pending, used, facts, settled bool) string { + switch { + case pending: + return TaskRolePending + case used: + return TaskRoleUsed + case !facts: + return TaskRoleUnknown + case settled: + return TaskRoleNotUsed + default: + return TaskRoleNotRunYet + } +} + +// roleAlternatives is the router's fallback rungs for the role, in order, +// skipping the rung it is on and stopping at the read's bound. +func roleAlternatives(ladder []crewroute.Pick, current string) []TaskRoutingOption { + var out []TaskRoutingOption + for _, rung := range ladder { + if len(out) == taskRoutingAlternatives { + break } - for _, rung := range r.Ladders[seat] { - if len(role.Alternatives) == taskRoutingAlternatives { - break - } - if rung.Send == pick.Send { - continue - } - role.Alternatives = append(role.Alternatives, TaskRoutingOption{Model: rung.Model, Provider: rung.Provider, Kind: rung.Kind, Quality: rung.Quality, EstimateUSD: pickEstimate(rung), Price: clonePrice(rung.Price)}) + if rung.Send == current { + continue } - sends := map[string]bool{r.Original[seat]: true} - for _, change := range r.Changes { - if change.Seat == seat { - sends[change.FromSend], sends[change.ToSend] = true, true - } + out = append(out, TaskRoutingOption{Model: rung.Model, Provider: rung.Provider, Kind: rung.Kind, Quality: rung.Quality, EstimateUSD: pickEstimate(rung), Price: clonePrice(rung.Price)}) + } + return out +} + +// roleRejections is every route that failed this seat on this task: the send +// it started on, and every send a change moved it from or to. +func roleRejections(r *TaskCrewRecord, seat crewroute.Seat) []TaskRoutingRejection { + sends := map[string]bool{r.Original[seat]: true} + for _, change := range r.Changes { + if change.Seat == seat { + sends[change.FromSend], sends[change.ToSend] = true, true } - for _, send := range crewKeys(sends) { - if failed, ok := r.Failed[send]; ok && send != "" { - model, provider := send, "" - for _, change := range r.Changes { - if change.FromSend == send { - model, provider = change.From, change.FromProvider - } - } - role.Rejected = append(role.Rejected, TaskRoutingRejection{Model: model, Provider: provider, Reason: string(failed.Kind), Until: failed.Until}) - } + } + var out []TaskRoutingRejection + for _, send := range crewKeys(sends) { + failed, ok := r.Failed[send] + if ok && send != "" { + model, provider := rejectionNames(send, r.Changes) + out = append(out, TaskRoutingRejection{Model: model, Provider: provider, Reason: string(failed.Kind), Until: failed.Until}) } - view.Roles = append(view.Roles, role) } - if known { - if d.EstUSD > 0 && d.CostFactor > 0 { - total = d.EstUSD + return out +} + +// rejectionNames is the model a failed send was, from the change that left +// it, or the send itself when the history never named one. +func rejectionNames(send string, changes []TaskCrewChange) (model, provider string) { + model = send + for _, change := range changes { + if change.FromSend == send { + model, provider = change.From, change.FromProvider } - view.EstimateUSD = &total } - return view + return model, provider } // pickEstimate is one pick's expected cost: nothing on a plan, a local model @@ -500,28 +566,16 @@ func refuse(code, format string, args ...any) error { return &TaskRoutingRefusal{Code: code, Message: fmt.Sprintf(format, args...)} } -// TaskRoutingAct is a person's change to one task's models. +// TaskRoutingAct is a person's change to one task's models. The kind is +// checked, then the task's scope — waiting, not started, or a recorded run — +// applies it, and the answer is the task as it stands after. func (a *Agent) TaskRoutingAct(ctx context.Context, id string, act TaskRoutingAction) (TaskRoutingResult, error) { n, err := strconv.ParseUint(strings.TrimSpace(id), 10, 64) if err != nil || n == 0 { return TaskRoutingResult{}, refuse(TaskRoutingNotFound, "no such task") } - switch act.Kind { - case TaskRoutingStronger, TaskRoutingBest, TaskRoutingAuto, TaskRoutingSame, TaskRoutingRetry: - case TaskRoutingPick: - if len(act.Picks) == 0 { - return TaskRoutingResult{}, refuse(TaskRoutingBadAction, "pick needs at least one role") - } - for seat := range act.Picks { - if seat != crewroute.Worker && seat != crewroute.Planner && seat != crewroute.Checker { - return TaskRoutingResult{}, refuse(TaskRoutingBadAction, "%q is not a role · worker, planner or checker", seat) - } - } - default: - return TaskRoutingResult{}, refuse(TaskRoutingBadAction, "%q is not a change · stronger_worker, best, pick, auto, same or retry", act.Kind) - } - if act.RaiseCapUSD < 0 || math.IsNaN(act.RaiseCapUSD) || math.IsInf(act.RaiseCapUSD, 0) { - return TaskRoutingResult{}, refuse(TaskRoutingBadAction, "a raised cap is a dollar figure above zero") + if err := routingActionKind(act); err != nil { + return TaskRoutingResult{}, err } var applied []crewroute.Seat var rerun uint64 @@ -536,21 +590,7 @@ func (a *Agent) TaskRoutingAct(ctx context.Context, id string, act TaskRoutingAc if !ok { return TaskRoutingResult{}, refuse(TaskRoutingNotFound, "no such task") } - crew := a.crews.get(n) - switch { - case act.Rerun || row.State.settled(): - if !act.Rerun { - return TaskRoutingResult{}, refuse(TaskRoutingSettled, "this task has finished · its models are kept as they ran; rerun it to choose again") - } - if !row.State.settled() { - return TaskRoutingResult{}, refuse(TaskRoutingBusy, "this task is still running · stop it first, then rerun it") - } - rerun, err = a.rerunWithRouting(ctx, row, crew, act) - case crew == nil: - return TaskRoutingResult{}, refuse(TaskRoutingNotRouted, "this task runs on one recorded model and has no roles to change") - default: - applied, err = a.actOnLiveCrew(n, crew, act) - } + applied, rerun, err = a.actOnRecordedRun(ctx, n, row, a.crews.get(n), act) } if err != nil { return TaskRoutingResult{}, err @@ -562,6 +602,52 @@ func (a *Agent) TaskRoutingAct(ctx context.Context, id string, act TaskRoutingAc return TaskRoutingResult{View: view, Applied: applied, Rerun: rerun}, nil } +// routingActionKind refuses a change this door does not take, a pick that +// names no role or a role that is not one, and a cap that is not a dollar +// figure. It moves nothing. +func routingActionKind(act TaskRoutingAction) error { + switch act.Kind { + case TaskRoutingStronger, TaskRoutingBest, TaskRoutingAuto, TaskRoutingSame, TaskRoutingRetry: + case TaskRoutingPick: + if len(act.Picks) == 0 { + return refuse(TaskRoutingBadAction, "pick needs at least one role") + } + for seat := range act.Picks { + if seat != crewroute.Worker && seat != crewroute.Planner && seat != crewroute.Checker { + return refuse(TaskRoutingBadAction, "%q is not a role · worker, planner or checker", seat) + } + } + default: + return refuse(TaskRoutingBadAction, "%q is not a change · stronger_worker, best, pick, auto, same or retry", act.Kind) + } + if act.RaiseCapUSD < 0 || math.IsNaN(act.RaiseCapUSD) || math.IsInf(act.RaiseCapUSD, 0) { + return refuse(TaskRoutingBadAction, "a raised cap is a dollar figure above zero") + } + return nil +} + +// actOnRecordedRun applies a change to a task that already has a run row: a +// rerun of a finished one, or a move of a live crew. A finished task is never +// edited in place, and a row with no crew has no roles to move. +func (a *Agent) actOnRecordedRun(ctx context.Context, n uint64, row TaskNotice, crew *taskCrew, act TaskRoutingAction) ([]crewroute.Seat, uint64, error) { + switch { + case act.Rerun || row.State.settled(): + if !act.Rerun { + return nil, 0, refuse(TaskRoutingSettled, "this task has finished · its models are kept as they ran; rerun it to choose again") + } + if !row.State.settled() { + return nil, 0, refuse(TaskRoutingBusy, "this task is still running · stop it first, then rerun it") + } + rerun, err := a.rerunWithRouting(ctx, row, crew, act) + return nil, rerun, err + case crew == nil: + return nil, 0, refuse(TaskRoutingNotRouted, "this task runs on one recorded model and has no roles to change") + default: + applied, err := a.actOnLiveCrew(n, crew, act) + return applied, 0, err + } +} + func (a *Agent) isProposed(n uint64) bool { a.mu.Lock() defer a.mu.Unlock() @@ -651,14 +737,42 @@ func (p routingPlan) overCap() bool { return p.known && p.capUSD > 0 && p.estima // (AutoSeats), so a saved pin or one-model mode does not hold them; an explicit // pick wins; Back to Auto names nothing and gets the saved policy back. func (a *Agent) planLiveChange(crew *taskCrew, act TaskRoutingAction) (routingPlan, error) { - route := a.config.RouteCrew - if route == nil { - return routingPlan{}, refuse(TaskRoutingUnavailable, "this conversation has no model router") + if err := liveChangeShape(a.config.RouteCrew != nil, act.Kind); err != nil { + return routingPlan{}, err + } + cur, pinned, count := heldTaskPins(crew) + if err := liveChangeStale(act.ChangeCount, count); err != nil { + return routingPlan{}, err } - if act.Kind == TaskRoutingSame || act.Kind == TaskRoutingRetry { - return routingPlan{}, refuse(TaskRoutingBadAction, "%s is for a rerun or a waiting task", act.Kind) + ask, targets, err := liveChangeIntent(&cur, pinned, act) + if err != nil { + return routingPlan{}, err } + ask.Task, ask.Repo, ask.ChatModel = crewTaskText(crew.title, crew.brief), crew.repo, a.Model() + decision, err := a.config.RouteCrew(ask) + if err != nil { + return routingPlan{}, routeRefusal(err) + } + return acceptedLivePlan(cur, decision, count, targets, act, crew) +} + +// liveChangeShape refuses a live change this door does not take: no router, +// or a kind that belongs to a rerun or a task waiting on a model. +func liveChangeShape(hasRouter bool, kind string) error { + if !hasRouter { + return refuse(TaskRoutingUnavailable, "this conversation has no model router") + } + if kind == TaskRoutingSame || kind == TaskRoutingRetry { + return refuse(TaskRoutingBadAction, "%s is for a rerun or a waiting task", kind) + } + return nil +} + +// heldTaskPins is the crew as it stands and every role a person pinned for +// this task, including a send chosen on the task that has not been released. +func heldTaskPins(crew *taskCrew) (crewroute.Decision, map[crewroute.Seat]bool, int) { crew.mu.Lock() + defer crew.mu.Unlock() cur, count := crew.decision, crew.changeCount cur.Crew = append([]crewroute.Pick(nil), cur.Crew...) pinned := map[crewroute.Seat]bool{} @@ -668,23 +782,30 @@ func (a *Agent) planLiveChange(crew *taskCrew, act TaskRoutingAction) (routingPl for seat := range crew.seatSends { pinned[seat] = true } - crew.mu.Unlock() - if act.ChangeCount != nil && *act.ChangeCount != count { - return routingPlan{}, refuse(TaskRoutingStale, "this task's models changed · open it again") - } - ask := config.CrewAsk{Task: crewTaskText(crew.title, crew.brief), Repo: crew.repo, ChatModel: a.Model(), Sends: map[crewroute.Seat]string{}} - keep := func(seats ...crewroute.Seat) { - for _, seat := range seats { - if send := cur.Seat(seat).Send; send != "" { - ask.Sends[seat] = send - } - } + return cur, pinned, count +} + +// liveChangeStale refuses a change whose history count is not the one the +// person saw. A nil count is a caller that did not claim one. +func liveChangeStale(seen *int, count int) error { + if seen != nil && *seen != count { + return refuse(TaskRoutingStale, "this task's models changed · open it again") } + return nil +} + +// liveChangeIntent is what one live change asks the router: which roles stay +// on their current send, which are seated again, and which come back as Auto. +// Stronger aliases cur, so a router that reads the crew it is stronger than +// reads the decision the plan will keep. +func liveChangeIntent(cur *crewroute.Decision, pinned map[crewroute.Seat]bool, act TaskRoutingAction) (config.CrewAsk, []crewroute.Seat, error) { + ask := config.CrewAsk{Sends: map[crewroute.Seat]string{}} var targets []crewroute.Seat + var err error switch act.Kind { case TaskRoutingStronger: - keep(crewroute.Planner, crewroute.Checker) - ask.Stronger = &cur + ask.Sends = keptSends(*cur, crewroute.Planner, crewroute.Checker) + ask.Stronger = cur ask.AutoSeats = map[crewroute.Seat]bool{crewroute.Worker: true} targets = []crewroute.Seat{crewroute.Worker} case TaskRoutingBest: @@ -692,75 +813,86 @@ func (a *Agent) planLiveChange(crew *taskCrew, act TaskRoutingAction) (routingPl ask.AutoSeats = map[crewroute.Seat]bool{crewroute.Worker: true, crewroute.Planner: true, crewroute.Checker: true} targets = crewroute.Seats case TaskRoutingPick: - for _, seat := range crewroute.Seats { - model, named := act.Picks[seat] - switch { - case !named: - keep(seat) - case strings.TrimSpace(model) != "": - ask.Sends[seat] = strings.TrimSpace(model) - targets = append(targets, seat) - default: - targets = append(targets, seat) - } - } + ask.Sends, targets = pickChangeSends(*cur, act.Picks) case TaskRoutingAuto: - for _, seat := range crewroute.Seats { - if pinned[seat] { - targets = append(targets, seat) - } else { - keep(seat) - } - } - if len(targets) == 0 { - return routingPlan{}, refuse(TaskRoutingUnchanged, "no role of this task was chosen for it · it is already on Auto") - } + ask.Sends, targets, err = autoChangeSends(*cur, pinned) + } + if err != nil { + return config.CrewAsk{}, nil, err } if len(ask.Sends) == 0 { ask.Sends = nil } - decision, err := route(ask) - if err != nil { - return routingPlan{}, routeRefusal(err) - } - plan := routingPlan{cur: cur, routed: decision, count: count, next: cur} - plan.next.Crew = append([]crewroute.Pick(nil), cur.Crew...) - for _, seat := range targets { - pick := decision.Seat(seat) - if pick.Send == "" { - continue + return ask, targets, nil +} + +// keptSends is the current send of each named role, dropping a role that has +// none. Those roles are not asked to move. +func keptSends(cur crewroute.Decision, seats ...crewroute.Seat) map[crewroute.Seat]string { + out := map[crewroute.Seat]string{} + for _, seat := range seats { + if send := cur.Seat(seat).Send; send != "" { + out[seat] = send } - if act.Kind == TaskRoutingPick { - if want := strings.TrimSpace(act.Picks[seat]); want != "" { - if pick.Send != want && pick.Model != want { - return routingPlan{}, &TaskRoutingRefusal{Code: TaskRoutingNotAllowed, Seat: seat, - Message: fmt.Sprintf("%s cannot take the %s role here · %s", want, seat, warningFor(decision, seat))} - } - pick.Pinned = true + } + return out +} + +// pickChangeSends is an explicit pick: a named model is that role's send, an +// empty model is Auto for that role, and a role the pick does not mention +// keeps its current send. +func pickChangeSends(cur crewroute.Decision, picks map[crewroute.Seat]string) (map[crewroute.Seat]string, []crewroute.Seat) { + sends := map[crewroute.Seat]string{} + var targets []crewroute.Seat + for _, seat := range crewroute.Seats { + model, named := picks[seat] + switch { + case !named: + if send := cur.Seat(seat).Send; send != "" { + sends[seat] = send } + case strings.TrimSpace(model) != "": + sends[seat] = strings.TrimSpace(model) + targets = append(targets, seat) + default: + targets = append(targets, seat) } - pick.Seat = seat - if pick.Send == cur.Seat(seat).Send && pick.Pinned == cur.Seat(seat).Pinned { - if act.Kind != TaskRoutingAuto && act.Kind != TaskRoutingPick { - continue - } + } + return sends, targets +} + +// autoChangeSends releases every role this task pinned and keeps the rest. +// A task with nothing pinned is already on Auto, which is not a change. +func autoChangeSends(cur crewroute.Decision, pinned map[crewroute.Seat]bool) (map[crewroute.Seat]string, []crewroute.Seat, error) { + sends := map[crewroute.Seat]string{} + var targets []crewroute.Seat + for _, seat := range crewroute.Seats { + if pinned[seat] { + targets = append(targets, seat) + continue } - if _, failed := crew.failedHere(pick.Send); failed { - return routingPlan{}, &TaskRoutingRefusal{Code: TaskRoutingRouteFailed, Seat: seat, - Message: fmt.Sprintf("%s already failed to start on this task", pick.Model)} + if send := cur.Seat(seat).Send; send != "" { + sends[seat] = send } - for i := range plan.next.Crew { - if plan.next.Crew[i].Seat == seat { - plan.next.Crew[i] = pick - } + } + if len(targets) == 0 { + return nil, nil, refuse(TaskRoutingUnchanged, "no role of this task was chosen for it · it is already on Auto") + } + return sends, targets, nil +} + +// acceptedLivePlan is the crew the router returned, with each targeted role +// taken or refused. Nothing is written onto the live crew here. +func acceptedLivePlan(cur, decision crewroute.Decision, count int, targets []crewroute.Seat, act TaskRoutingAction, crew *taskCrew) (routingPlan, error) { + plan := routingPlan{cur: cur, routed: decision, count: count, next: cur} + plan.next.Crew = append([]crewroute.Pick(nil), cur.Crew...) + for _, seat := range targets { + if err := acceptChangedSeat(&plan, decision, seat, act, crew); err != nil { + return routingPlan{}, err } - plan.changed = append(plan.changed, seat) } if len(plan.changed) == 0 { - if act.Kind == TaskRoutingStronger { - return routingPlan{}, refuse(TaskRoutingStrongest, "this is already the strongest worker the allowed models make") - } - return routingPlan{}, refuse(TaskRoutingUnchanged, "the models this asks for are the ones the task runs on") + return routingPlan{}, unchangedLiveRefusal(act.Kind) } plan.estimate, plan.known = crewEstimate(plan.next) if crew.guard != nil { @@ -771,86 +903,201 @@ func (a *Agent) planLiveChange(crew *taskCrew, act TaskRoutingAction) (routingPl return plan, nil } -// actOnLiveCrew moves a running task's roles at their next call. +// acceptChangedSeat takes one role the change targeted. An empty seat is +// skipped. A non-empty pick must be the model the router seated, and a role +// that did not move is recorded only for Auto and pick — those release or +// restate a choice even when the model stays. +func acceptChangedSeat(plan *routingPlan, decision crewroute.Decision, seat crewroute.Seat, act TaskRoutingAction, crew *taskCrew) error { + pick := decision.Seat(seat) + if pick.Send == "" { + return nil + } + if act.Kind == TaskRoutingPick { + var err error + pick, err = pinnedPick(pick, seat, act.Picks[seat], decision) + if err != nil { + return err + } + } + if sameSeatPick(pick, plan.cur.Seat(seat)) && keepsUnchangedSeat(act.Kind) { + return nil + } + if _, failed := crew.failedHere(pick.Send); failed { + return &TaskRoutingRefusal{Code: TaskRoutingRouteFailed, Seat: seat, + Message: fmt.Sprintf("%s already failed to start on this task", pick.Model)} + } + pick.Seat = seat + plan.next.Crew = replaceSeat(plan.next.Crew, pick) + plan.changed = append(plan.changed, seat) + return nil +} + +// pinnedPick is an explicit model for a role: the router must have seated +// that model, and the role is then pinned for this task. An empty model is +// Auto and pins nothing. +func pinnedPick(pick crewroute.Pick, seat crewroute.Seat, want string, decision crewroute.Decision) (crewroute.Pick, error) { + want = strings.TrimSpace(want) + if want == "" { + return pick, nil + } + if pick.Send != want && pick.Model != want { + return pick, &TaskRoutingRefusal{Code: TaskRoutingNotAllowed, Seat: seat, + Message: fmt.Sprintf("%s cannot take the %s role here · %s", want, seat, warningFor(decision, seat))} + } + pick.Pinned = true + return pick, nil +} + +// sameSeatPick is a role whose send and pin flag did not move. +func sameSeatPick(pick, cur crewroute.Pick) bool { + return pick.Send == cur.Send && pick.Pinned == cur.Pinned +} + +// keepsUnchangedSeat is a change that does not record a role it left as it +// was. Auto and pick do record it: Auto released a pin, and a pick restated +// a choice. +func keepsUnchangedSeat(kind string) bool { + return kind != TaskRoutingAuto && kind != TaskRoutingPick +} + +// replaceSeat writes pick over the role it names. +func replaceSeat(crew []crewroute.Pick, pick crewroute.Pick) []crewroute.Pick { + for i := range crew { + if crew[i].Seat == pick.Seat { + crew[i] = pick + } + } + return crew +} + +// unchangedLiveRefusal is the answer when a live change moved no role. +// Stronger that cannot move is already the strongest worker; anything else +// asked for the models the task already runs on. +func unchangedLiveRefusal(kind string) error { + if kind == TaskRoutingStronger { + return refuse(TaskRoutingStrongest, "this is already the strongest worker the allowed models make") + } + return refuse(TaskRoutingUnchanged, "the models this asks for are the ones the task runs on") +} + +// actOnLiveCrew moves a running task's roles at their next call. The plan is +// already the router's answer; this applies it, raises the cap only when the +// person named one high enough, and says the move. func (a *Agent) actOnLiveCrew(n uint64, crew *taskCrew, act TaskRoutingAction) ([]crewroute.Seat, error) { plan, err := a.planLiveChange(crew, act) if err != nil { return nil, err } - cur, next, decision, changed, count, estimate, known := plan.cur, plan.next, plan.routed, plan.changed, plan.count, plan.estimate, plan.known - raise := 0.0 - if plan.overCap() { - if act.RaiseCapUSD+1e-9 < estimate { - return nil, &TaskRoutingRefusal{Code: TaskRoutingOverCap, RequiredUSD: estimate, CapUSD: plan.capUSD, - Message: fmt.Sprintf("this needs more than the %s task cap · it needs %s", crewroute.Money(plan.capUSD), crewroute.Money(estimate))} + raise, err := raisedTaskCap(plan.known, plan.estimate, plan.capUSD, act.RaiseCapUSD) + if err != nil { + return nil, err + } + next, err := a.applyLivePlan(crew, plan, act, raise) + if err != nil { + return nil, err + } + if raise > 0 && crew.guard != nil { + crew.guard.mu.Lock() + crew.guard.TaskCap = raise + crew.guard.mu.Unlock() + } + if crew.persist != nil { + if err := crew.persist(); err != nil { + return plan.changed, err } - raise = act.RaiseCapUSD } + a.republishCrewRow(n, next, liveChangeNote(plan.cur, next, plan.changed)) + return plan.changed, nil +} + +// raisedTaskCap is the cap a change may run under. Under the current cap it +// is zero and no raise is written. Over it, the person must have named a cap +// at least the estimate; otherwise the change is refused with both figures. +func raisedTaskCap(known bool, estimate, capUSD, offered float64) (float64, error) { + if !known || capUSD <= 0 || estimate <= capUSD+1e-9 { + return 0, nil + } + if offered+1e-9 < estimate { + return 0, &TaskRoutingRefusal{Code: TaskRoutingOverCap, RequiredUSD: estimate, CapUSD: capUSD, + Message: fmt.Sprintf("this needs more than the %s task cap · it needs %s", crewroute.Money(capUSD), crewroute.Money(estimate))} + } + return offered, nil +} + +// applyLivePlan writes a planned change onto the live crew. A history count +// that moved since the plan was read is stale and writes nothing. +func (a *Agent) applyLivePlan(crew *taskCrew, plan routingPlan, act TaskRoutingAction, raise float64) (crewroute.Decision, error) { at := a.taskClockNow() - kind := act.Kind crew.mu.Lock() - if crew.changeCount != count { - crew.mu.Unlock() - return nil, refuse(TaskRoutingStale, "this task's models changed · open it again") + defer crew.mu.Unlock() + if crew.changeCount != plan.count { + return crewroute.Decision{}, refuse(TaskRoutingStale, "this task's models changed · open it again") } - for _, seat := range changed { - was, pick := crew.decision.Seat(seat), next.Seat(seat) - if crew.seatSends == nil { - crew.seatSends = map[crewroute.Seat]string{} - } - crew.seatSends[seat] = pick.Send - if crew.ladders == nil { - crew.ladders = map[crewroute.Seat][]crewroute.Pick{} - } - if pick.Pinned { - delete(crew.ladders, seat) - } else { - crew.ladders[seat] = decision.Ladder[seat] - } - if crew.pending == nil { - crew.pending = map[crewroute.Seat]bool{} - } - if !crew.started[pick.Send] { - crew.pending[seat] = true - } else { - delete(crew.pending, seat) - } - if kind == TaskRoutingPick && strings.TrimSpace(act.Picks[seat]) != "" { - if crew.taskPins == nil { - crew.taskPins = map[crewroute.Seat]bool{} - } - crew.taskPins[seat] = true - } else { - delete(crew.taskPins, seat) - } - crew.noteChangeLocked(TaskCrewChange{At: at, Seat: seat, From: was.Model, FromProvider: was.Provider, FromSend: was.Send, - To: pick.Model, ToProvider: pick.Provider, ToSend: pick.Send, By: TaskCrewByPerson, Kind: kind}) + for _, seat := range plan.changed { + applySeatChangeLocked(crew, plan.next, plan.routed, seat, act.Kind, act.Picks, at) } + next := plan.next next.Retried, next.Ladder = crew.decision.Retried, crew.ladders - if known { - next.EstUSD = estimate + if plan.known { + next.EstUSD = plan.estimate } crew.decision = next if raise > 0 { crew.noteChangeLocked(TaskCrewChange{At: at, By: TaskCrewByPerson, Kind: TaskCrewChangeCap, CapUSD: raise}) } - crew.mu.Unlock() - if raise > 0 && crew.guard != nil { - crew.guard.mu.Lock() - crew.guard.TaskCap = raise - crew.guard.mu.Unlock() + return next, nil +} + +// applySeatChangeLocked records one role's next model. A pinned pick drops +// the fallback ladder; a send that has already answered is not pending. The +// caller holds crew.mu. +func applySeatChangeLocked(crew *taskCrew, next, routed crewroute.Decision, seat crewroute.Seat, kind string, picks map[crewroute.Seat]string, at time.Time) { + was, pick := crew.decision.Seat(seat), next.Seat(seat) + if crew.seatSends == nil { + crew.seatSends = map[crewroute.Seat]string{} } - if crew.persist != nil { - if err := crew.persist(); err != nil { - return changed, err + crew.seatSends[seat] = pick.Send + if crew.ladders == nil { + crew.ladders = map[crewroute.Seat][]crewroute.Pick{} + } + if pick.Pinned { + delete(crew.ladders, seat) + } else { + crew.ladders[seat] = routed.Ladder[seat] + } + if crew.pending == nil { + crew.pending = map[crewroute.Seat]bool{} + } + if !crew.started[pick.Send] { + crew.pending[seat] = true + } else { + delete(crew.pending, seat) + } + noteTaskPinLocked(crew, seat, kind, picks[seat]) + crew.noteChangeLocked(TaskCrewChange{At: at, Seat: seat, From: was.Model, FromProvider: was.Provider, FromSend: was.Send, + To: pick.Model, ToProvider: pick.Provider, ToSend: pick.Send, By: TaskCrewByPerson, Kind: kind}) +} + +// noteTaskPinLocked pins a role the person named a model for, and releases +// the pin on every other change. The caller holds crew.mu. +func noteTaskPinLocked(crew *taskCrew, seat crewroute.Seat, kind, model string) { + if kind == TaskRoutingPick && strings.TrimSpace(model) != "" { + if crew.taskPins == nil { + crew.taskPins = map[crewroute.Seat]bool{} } + crew.taskPins[seat] = true + return } + delete(crew.taskPins, seat) +} + +// liveChangeNote is the plan line for a change a person made, one role a clause. +func liveChangeNote(cur, next crewroute.Decision, changed []crewroute.Seat) string { var lines []string for _, seat := range changed { lines = append(lines, fmt.Sprintf("%s changed %s → %s", seat, cur.Seat(seat).Model, next.Seat(seat).Model)) } - a.republishCrewRow(n, next, "you changed this task's models · "+strings.Join(lines, " · ")+" · from its next step") - return changed, nil + return "you changed this task's models · " + strings.Join(lines, " · ") + " · from its next step" } // warningFor is what the router said about a role it did not seat as asked. @@ -919,6 +1166,37 @@ type rerunPlan struct { } func (a *Agent) planRerun(row TaskNotice, crew *taskCrew, act TaskRoutingAction) (rerunPlan, error) { + p := rerunPrior(row, crew) + if strings.TrimSpace(p.brief) == "" { + return p, refuse(TaskRoutingNotRouted, "this task's brief was not kept · it cannot be rerun from here") + } + wish, err := rerunWish(act, p.prior) + if err != nil { + return p, err + } + p.wish = wish + if a.config.RouteCrew == nil { + return p, refuse(TaskRoutingUnavailable, "this conversation has no model router") + } + ask := config.CrewAsk{Task: crewTaskText(p.title, p.brief), Effort: p.wish.effort, Stronger: p.wish.stronger, Sends: p.wish.sends, + AutoSeats: p.wish.autoSeats, Repo: canonicalPath(a.config.Workspace), ChatModel: a.Model()} + if len(ask.Sends) == 0 { + ask.Sends = nil + } + decision, err := a.config.RouteCrew(ask) + if err != nil { + return p, routeRefusal(err) + } + p.routed = decision + p.estimate, p.known = crewEstimate(decision) + p.capUSD, _ = config.CrewTaskSpendCap(a.config.ProfileDir) + return p, nil +} + +// rerunPrior is the crew a finished task would run again from: the live crew +// when this process still holds it, otherwise the crew the checkpoint kept. +// A row with neither still carries its own brief. +func rerunPrior(row TaskNotice, crew *taskCrew) rerunPlan { p := rerunPlan{title: row.Title} switch { case crew != nil: @@ -931,60 +1209,59 @@ func (a *Agent) planRerun(row TaskNotice, crew *taskCrew, act TaskRoutingAction) if p.brief == "" { p.brief = row.Brief } - if strings.TrimSpace(p.brief) == "" { - return p, refuse(TaskRoutingNotRouted, "this task's brief was not kept · it cannot be rerun from here") - } - sendsOf := func(seats ...crewroute.Seat) map[crewroute.Seat]string { - out := map[crewroute.Seat]string{} - for _, seat := range seats { - if p.prior != nil && p.prior.Seat(seat).Send != "" { - out[seat] = p.prior.Seat(seat).Send - } - } - return out - } + return p +} + +// rerunWish is the person's choice for a rerun. Same freezes every recorded +// send. Stronger freezes the planner and checker and asks for a new worker. +// Best and pick ask fresh. Retry is a waiting task's verb. +func rerunWish(act TaskRoutingAction, prior *crewroute.Decision) (crewWish, error) { + var wish crewWish switch act.Kind { case TaskRoutingSame: - if p.prior == nil { - return p, refuse(TaskRoutingNotRouted, "this task has no recorded roles to run again") + if prior == nil { + return wish, refuse(TaskRoutingNotRouted, "this task has no recorded roles to run again") } - p.wish.sends = sendsOf(crewroute.Seats...) + wish.sends = frozenSeatSends(prior, crewroute.Seats...) case TaskRoutingStronger: - if p.prior == nil { - return p, refuse(TaskRoutingNotRouted, "this task has no recorded worker to be stronger than") + if prior == nil { + return wish, refuse(TaskRoutingNotRouted, "this task has no recorded worker to be stronger than") } - p.wish.sends, p.wish.stronger = sendsOf(crewroute.Planner, crewroute.Checker), p.prior - p.wish.autoSeats = map[crewroute.Seat]bool{crewroute.Worker: true} + wish.sends = frozenSeatSends(prior, crewroute.Planner, crewroute.Checker) + wish.stronger = prior + wish.autoSeats = map[crewroute.Seat]bool{crewroute.Worker: true} case TaskRoutingBest: - p.wish.effort = crewroute.EffortBest - p.wish.autoSeats = map[crewroute.Seat]bool{crewroute.Worker: true, crewroute.Planner: true, crewroute.Checker: true} + wish.effort = crewroute.EffortBest + wish.autoSeats = map[crewroute.Seat]bool{crewroute.Worker: true, crewroute.Planner: true, crewroute.Checker: true} case TaskRoutingPick: - p.wish.sends = map[crewroute.Seat]string{} - for seat, model := range act.Picks { - if model = strings.TrimSpace(model); model != "" { - p.wish.sends[seat] = model - } - } + wish.sends = pickedRerunSends(act.Picks) case TaskRoutingRetry: - return p, refuse(TaskRoutingBadAction, "retry is for a waiting task") - } - route := a.config.RouteCrew - if route == nil { - return p, refuse(TaskRoutingUnavailable, "this conversation has no model router") + return wish, refuse(TaskRoutingBadAction, "retry is for a waiting task") } - ask := config.CrewAsk{Task: crewTaskText(p.title, p.brief), Effort: p.wish.effort, Stronger: p.wish.stronger, Sends: p.wish.sends, - AutoSeats: p.wish.autoSeats, Repo: canonicalPath(a.config.Workspace), ChatModel: a.Model()} - if len(ask.Sends) == 0 { - ask.Sends = nil + return wish, nil +} + +// frozenSeatSends is the recorded send of each named role. A rerun that says +// Same, or Stronger for the roles it does not move, asks for these again. +func frozenSeatSends(prior *crewroute.Decision, seats ...crewroute.Seat) map[crewroute.Seat]string { + out := map[crewroute.Seat]string{} + for _, seat := range seats { + if prior != nil && prior.Seat(seat).Send != "" { + out[seat] = prior.Seat(seat).Send + } } - decision, err := route(ask) - if err != nil { - return p, routeRefusal(err) + return out +} + +// pickedRerunSends is a rerun's explicit models. An empty model names no send. +func pickedRerunSends(picks map[crewroute.Seat]string) map[crewroute.Seat]string { + sends := map[crewroute.Seat]string{} + for seat, model := range picks { + if model = strings.TrimSpace(model); model != "" { + sends[seat] = model + } } - p.routed = decision - p.estimate, p.known = crewEstimate(decision) - p.capUSD, _ = config.CrewTaskSpendCap(a.config.ProfileDir) - return p, nil + return sends } // rerunWithRouting starts a finished task again with the person's choice. The @@ -1002,12 +1279,12 @@ func (a *Agent) rerunWithRouting(ctx context.Context, row TaskNotice, crew *task return 0, err } wish := p.wish - if p.known && p.capUSD > 0 && p.estimate > p.capUSD+1e-9 { - if act.RaiseCapUSD+1e-9 < p.estimate { - return 0, &TaskRoutingRefusal{Code: TaskRoutingOverCap, RequiredUSD: p.estimate, CapUSD: p.capUSD, - Message: fmt.Sprintf("this needs more than the %s task cap · it needs %s", crewroute.Money(p.capUSD), crewroute.Money(p.estimate))} - } - wish.taskCap = act.RaiseCapUSD + raise, err := raisedTaskCap(p.known, p.estimate, p.capUSD, act.RaiseCapUSD) + if err != nil { + return 0, err + } + if raise > 0 { + wish.taskCap = raise } id, _, _, err := a.startTaskRun(withCrewWish(ctx, wish), p.brief, true, "") if err != nil { diff --git a/internal/session/taskrouting_policy_test.go b/internal/session/taskrouting_policy_test.go new file mode 100644 index 0000000000..81b5f369ed --- /dev/null +++ b/internal/session/taskrouting_policy_test.go @@ -0,0 +1,143 @@ +package session + +import ( + "testing" + + "github.com/Agent-Field/codeaf/internal/crewroute" +) + +// THE PROJECTION OF ONE ROLE. How it was chosen and whether it ran are facts +// about that seat, read off the record, with no router and no settings. +func TestRoleProjectionNamesSourceAndWhetherThatSeatRan(t *testing.T) { + if roleSource(crewroute.ModeOne, true, true) != TaskRoleOneModel { + t.Fatal("one model for every role outranks a pin") + } + if roleSource(crewroute.ModeAuto, true, true) != TaskRoleTaskPin { + t.Fatal("a pin for this task outranks a saved pin") + } + if roleSource(crewroute.ModeAuto, false, true) != TaskRolePinned { + t.Fatal("a saved pin") + } + if roleSource(crewroute.ModeAuto, false, false) != TaskRoleAuto { + t.Fatal("auto") + } + if !roleHasRun(true, 0, 0) || !roleHasRun(false, 1, 0) || !roleHasRun(false, 0, 0.01) || roleHasRun(false, 0, 0) { + t.Fatal("a role ran when its own call started, answered, or spent") + } + if roleStatus(true, true, true, true) != TaskRolePending { + t.Fatal("pending outranks used") + } + if roleStatus(false, true, false, true) != TaskRoleUsed { + t.Fatal("used") + } + if roleStatus(false, false, false, true) != TaskRoleUnknown { + t.Fatal("an older task with no per-role facts is unknown") + } + if roleStatus(false, false, true, true) != TaskRoleNotUsed { + t.Fatal("settled and unused") + } + if roleStatus(false, false, true, false) != TaskRoleNotRunYet { + t.Fatal("not run yet") + } +} + +// THE CREW ESTIMATE. Unknown stays unknown. A factored router figure replaces +// the sum of the roles; without one the sum is the estimate. +func TestRoutedEstimateKeepsTheRoutersFigureAndUnknownApart(t *testing.T) { + if routedEstimate(crewroute.Decision{}, 1, false) != nil { + t.Fatal("an unpriced role makes the crew estimate unknown") + } + factored := routedEstimate(crewroute.Decision{EstUSD: 0.4, CostFactor: 2}, 0.1, true) + if factored == nil || *factored != 0.4 { + t.Fatalf("factored estimate %v", factored) + } + summed := routedEstimate(crewroute.Decision{EstUSD: 0.4}, 0.1, true) + if summed == nil || *summed != 0.1 { + t.Fatalf("summed estimate %v", summed) + } +} + +// A CHANGE OVER THE CAP needs a cap the person named at least that high. +// Under the cap, nothing is raised. +func TestRaisedTaskCapRefusesOnlyAnEstimateOverTheCap(t *testing.T) { + raise, err := raisedTaskCap(true, 0.2, 1, 0) + if err != nil || raise != 0 { + t.Fatalf("under the cap: %v %v", raise, err) + } + if _, err := raisedTaskCap(true, 2, 1, 0); err == nil { + t.Fatal("over the cap with no raise was allowed") + } + raise, err = raisedTaskCap(true, 2, 1, 2) + if err != nil || raise != 2 { + t.Fatalf("a named cap at the estimate: %v %v", raise, err) + } + plan := routingPlan{known: true, estimate: 2, capUSD: 1} + if !plan.overCap() { + t.Fatal("overCap and raisedTaskCap disagree") + } +} + +// SAME FREEZES THE RECORDED SENDS. Stronger freezes the planner and checker +// and asks for a new worker. Retry is not a rerun. +func TestRerunWishFreezesSameAndAsksFreshForBest(t *testing.T) { + prior := crewroute.Decision{Crew: []crewroute.Pick{ + {Seat: crewroute.Worker, Send: "worker-send"}, + {Seat: crewroute.Planner, Send: "planner-send"}, + {Seat: crewroute.Checker, Send: "checker-send"}, + }} + same, err := rerunWish(TaskRoutingAction{Kind: TaskRoutingSame}, &prior) + if err != nil || same.sends[crewroute.Worker] != "worker-send" || same.sends[crewroute.Checker] != "checker-send" { + t.Fatalf("same: %+v %v", same, err) + } + stronger, err := rerunWish(TaskRoutingAction{Kind: TaskRoutingStronger}, &prior) + if err != nil || stronger.sends[crewroute.Worker] != "" || stronger.sends[crewroute.Planner] != "planner-send" || !stronger.autoSeats[crewroute.Worker] || stronger.stronger == nil { + t.Fatalf("stronger: %+v %v", stronger, err) + } + best, err := rerunWish(TaskRoutingAction{Kind: TaskRoutingBest}, nil) + if err != nil || best.effort != crewroute.EffortBest || !best.autoSeats[crewroute.Checker] { + t.Fatalf("best: %+v %v", best, err) + } + if _, err := rerunWish(TaskRoutingAction{Kind: TaskRoutingRetry}, &prior); err == nil { + t.Fatal("retry was accepted as a rerun") + } +} + +// A LIVE CHANGE NAMES WHO MOVES. Stronger holds the planner and checker. +// Auto with nothing pinned is not a change. An empty pick is Auto for that role. +func TestLiveChangeIntentNamesWhoMoves(t *testing.T) { + cur := crewroute.Decision{Crew: []crewroute.Pick{ + {Seat: crewroute.Worker, Send: "worker-send"}, + {Seat: crewroute.Planner, Send: "planner-send"}, + {Seat: crewroute.Checker, Send: "checker-send"}, + }} + stronger, targets, err := liveChangeIntent(&cur, nil, TaskRoutingAction{Kind: TaskRoutingStronger}) + if err != nil || stronger.Sends[crewroute.Planner] != "planner-send" || stronger.Sends[crewroute.Worker] != "" || stronger.Stronger == nil || len(targets) != 1 || targets[0] != crewroute.Worker { + t.Fatalf("stronger intent: %+v %v %v", stronger, targets, err) + } + if _, _, err := liveChangeIntent(&cur, nil, TaskRoutingAction{Kind: TaskRoutingAuto}); err == nil { + t.Fatal("auto with nothing pinned was a change") + } + pinned := map[crewroute.Seat]bool{crewroute.Checker: true} + auto, targets, err := liveChangeIntent(&cur, pinned, TaskRoutingAction{Kind: TaskRoutingAuto}) + if err != nil || len(targets) != 1 || targets[0] != crewroute.Checker || auto.Sends[crewroute.Worker] != "worker-send" { + t.Fatalf("auto intent: %+v %v %v", auto, targets, err) + } + pick, targets, err := liveChangeIntent(&cur, nil, TaskRoutingAction{Kind: TaskRoutingPick, Picks: map[crewroute.Seat]string{crewroute.Worker: ""}}) + if err != nil || pick.Sends[crewroute.Worker] != "" || pick.Sends[crewroute.Planner] != "planner-send" || len(targets) != 1 { + t.Fatalf("empty pick: %+v %v %v", pick, targets, err) + } +} + +// A PIN AND ONE MODEL FOR EVERY ROLE hold. Auto and an explicit pick record a +// role even when its model stays; stronger does not. +func TestAHeldSeatAndAnUnchangedStrongerStayPut(t *testing.T) { + if !seatHoldsItsModel(crewroute.Pick{Pinned: true}, crewroute.ModeAuto) || !seatHoldsItsModel(crewroute.Pick{}, crewroute.ModeOne) { + t.Fatal("a pin and one-model mode hold") + } + if seatHoldsItsModel(crewroute.Pick{}, crewroute.ModeAuto) { + t.Fatal("an unpinned auto role holds") + } + if !keepsUnchangedSeat(TaskRoutingStronger) || keepsUnchangedSeat(TaskRoutingAuto) || keepsUnchangedSeat(TaskRoutingPick) { + t.Fatal("only stronger and best drop a role that did not move") + } +} From 29cbc18243a39cc1f616134314565b1efd41e59f Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 21:48:48 -0400 Subject: [PATCH 090/459] Carry a marked correction and follow-up to a hosted engine's own doors Welcome gains SteerMarks and FollowUpMarks, each set only when the hosted agent has that door; a Submit door says nothing about them. Steer and FollowUp carry said, reads and attachments on the same calls, the server refuses a marked one before the turn or the queue when the door is absent, and the client refuses it with no call made when the welcome did not say so. A marked follow-up's stream keeps its take-back receipt. --- internal/remote/client.go | 68 ++++++++++-- internal/remote/server.go | 66 +++++++++++- internal/remote/steermarks_test.go | 168 +++++++++++++++++++++++++++++ internal/remote/wire.go | 23 ++++ 4 files changed, 312 insertions(+), 13 deletions(-) create mode 100644 internal/remote/steermarks_test.go diff --git a/internal/remote/client.go b/internal/remote/client.go index 4f1977bc01..707b17ae47 100644 --- a/internal/remote/client.go +++ b/internal/remote/client.go @@ -1740,15 +1740,60 @@ func (a *Agent) Submit(ctx context.Context, text string) (<-chan session.Event, // words ([session.Agent.SubmitMessage]): it travels on the same call, so the // engine records the words and the mark on one line or neither. func (a *Agent) SubmitMessage(ctx context.Context, message session.Submission) (<-chan session.Event, error) { - if message.Said != nil || len(message.Attachments) > 0 || len(message.Reads) > 0 { - a.c.mu.Lock() - known := a.c.welcome.MessageMarks - a.c.mu.Unlock() - if !known { - return nil, errors.New("engine: this engine cannot keep a message's description; update it before sending") - } + if argsOf(message).marked() && !a.MessageMarksKnown() { + return nil, errors.New("engine: this engine cannot keep a message's description; update it before sending") + } + return a.open(ctx, MethodSubmit, argsOf(message)) +} + +// SteerMessage is Steer with what the door knows about the correction beyond +// its words ([session.Agent.SteerMessage]). An engine that has not said it keeps +// a correction's mark is refused BEFORE ANYTHING IS SENT: run as its words +// alone it would lose what the person read, and the correction would already be +// in the turn. A submission with no mark is Steer. +func (a *Agent) SteerMessage(message session.Submission) (<-chan session.Event, error) { + if !argsOf(message).marked() { + return a.Steer(message.Text) + } + if !a.SteerMarksKnown() { + return nil, errors.New("engine: this engine cannot keep a correction's description; update it before sending") + } + return a.open(nil, MethodSteer, argsOf(message)) +} + +// FollowUpMessage is FollowUp with what the door knows about the message beyond +// its words ([session.Agent.FollowUpMessage]), refused before anything is sent +// to an engine that has not said it keeps a follow-up's mark. Its stream is +// remembered for the take-back exactly as a plain follow-up's is. +func (a *Agent) FollowUpMessage(message session.Submission) (<-chan session.Event, error) { + if !argsOf(message).marked() { + return a.FollowUp(message.Text) + } + if !a.FollowUpMarksKnown() { + return nil, errors.New("engine: this engine cannot keep a follow-up's description; update it before sending") } - return a.open(ctx, MethodSubmit, SubmitArgs{Text: message.Text, Said: message.Said, Attachments: message.Attachments, Reads: message.Reads}) + return a.followUp(argsOf(message)) +} + +// MessageMarksKnown, SteerMarksKnown and FollowUpMarksKnown are what the +// engine said in its welcome about keeping a mark on Submit, Steer and +// FollowUp, so a surface can refuse a marked message before it queues it. +func (a *Agent) MessageMarksKnown() bool { + return a.welcomeSays(func(w Welcome) bool { return w.MessageMarks }) +} + +func (a *Agent) SteerMarksKnown() bool { + return a.welcomeSays(func(w Welcome) bool { return w.SteerMarks }) +} + +func (a *Agent) FollowUpMarksKnown() bool { + return a.welcomeSays(func(w Welcome) bool { return w.FollowUpMarks }) +} + +func (a *Agent) welcomeSays(fact func(Welcome) bool) bool { + a.c.mu.Lock() + defer a.c.mu.Unlock() + return fact(a.c.welcome) } // SubmitBash explicitly runs the person's command; ordinary Submit never @@ -1777,7 +1822,12 @@ func (a *Agent) SubmitImage(ctx context.Context, text string, images []session.I // because the take-back ([Agent.UnqueueFollowUp]) names the stream and the wire // needs the id that channel stands for. func (a *Agent) FollowUp(text string) (<-chan session.Event, error) { - payload, err := a.c.call(nil, MethodFollowUp, SubmitArgs{Text: text}) + return a.followUp(SubmitArgs{Text: text}) +} + +// followUp is the FollowUp call's one body, plain or marked. +func (a *Agent) followUp(args SubmitArgs) (<-chan session.Event, error) { + payload, err := a.c.call(nil, MethodFollowUp, args) if err != nil { return nil, err } diff --git a/internal/remote/server.go b/internal/remote/server.go index fe337ece28..bf5b9694d8 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -1209,6 +1209,39 @@ func messageMarksKnown(agent any) bool { return ok } +// markedDoor is the agent's door for a marked Steer or FollowUp. +type markedDoor = func(session.Submission) (<-chan session.Event, error) + +func steerMarksDoor(agent any) (markedDoor, bool) { + door, ok := agent.(interface { + SteerMessage(session.Submission) (<-chan session.Event, error) + }) + if !ok { + return nil, false + } + return door.SteerMessage, true +} + +func followUpMarksDoor(agent any) (markedDoor, bool) { + door, ok := agent.(interface { + FollowUpMessage(session.Submission) (<-chan session.Event, error) + }) + if !ok { + return nil, false + } + return door.FollowUpMessage, true +} + +func steerMarksKnown(agent any) bool { + _, ok := steerMarksDoor(agent) + return ok +} + +func followUpMarksKnown(agent any) bool { + _, ok := followUpMarksDoor(agent) + return ok +} + func (sess *Session) welcomeLocked(s *server) Welcome { // A HOSTED START MUST READ THE ENGINE'S FILE, not the surface's. Carrying // this reading in the welcome is what makes an old persistent engine say @@ -1222,6 +1255,8 @@ func (sess *Session) welcomeLocked(s *server) Welcome { } return Welcome{ MessageMarks: messageMarksKnown(sess.agent), + SteerMarks: steerMarksKnown(sess.agent), + FollowUpMarks: followUpMarksKnown(sess.agent), Version: Version, Workspace: sess.engine.Workspace, SessionFile: sess.engine.SessionFile, @@ -2564,12 +2599,12 @@ func (s *server) invoke(call Frame) (out json.RawMessage, err error) { door, marked := agent.(interface { SubmitMessage(context.Context, session.Submission) (<-chan session.Event, error) }) - hasMark := args.Said != nil || len(args.Attachments) > 0 || len(args.Reads) > 0 + hasMark := args.marked() if hasMark && !marked { return nil, errors.New("engine: this session cannot keep a message's description; update the engine before sending it") } if hasMark { - events, err = door.SubmitMessage(context.Background(), session.Submission{Text: args.Text, Said: args.Said, Attachments: args.Attachments, Reads: args.Reads}) + events, err = door.SubmitMessage(context.Background(), args.submission()) } else { events, err = agent.Submit(context.Background(), args.Text) } @@ -2593,7 +2628,19 @@ func (s *server) invoke(call Frame) (out json.RawMessage, err error) { if err != nil { return nil, err } - events, err := agent.FollowUp(args.Text) + // A MARKED FOLLOW-UP IS QUEUED WITH ITS MARK OR REFUSED before anything + // is queued; its stream is registered for the take-back below exactly as + // a plain one's is. + var events <-chan session.Event + if args.marked() { + door, ok := followUpMarksDoor(agent) + if !ok { + return nil, errors.New("engine: this session cannot keep a follow-up's description; update the engine before sending it") + } + events, err = door(args.submission()) + } else { + events, err = agent.FollowUp(args.Text) + } return s.stream(MethodFollowUp, args.Text, events, err) case MethodUnqueueFollowUp: @@ -2629,7 +2676,18 @@ func (s *server) invoke(call Frame) (out json.RawMessage, err error) { if err != nil { return nil, err } - events, err := agent.Steer(args.Text) + // A MARKED CORRECTION IS SPLICED WITH ITS MARK OR REFUSED before it + // reaches the turn. + var events <-chan session.Event + if args.marked() { + door, ok := steerMarksDoor(agent) + if !ok { + return nil, errors.New("engine: this session cannot keep a correction's description; update the engine before sending it") + } + events, err = door(args.submission()) + } else { + events, err = agent.Steer(args.Text) + } return s.stream(MethodSteer, args.Text, events, err) case MethodSubmitImage: diff --git a/internal/remote/steermarks_test.go b/internal/remote/steermarks_test.go new file mode 100644 index 0000000000..280b559a5a --- /dev/null +++ b/internal/remote/steermarks_test.go @@ -0,0 +1,168 @@ +package remote + +import ( + "context" + "reflect" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/session" +) + +// steeringAgent is the scripted agent with marked doors for Steer and +// FollowUp, and not for Submit: each door is its own fact. +type steeringAgent struct { + *fakeAgent + steers, follows []session.Submission +} + +func (m *steeringAgent) SteerMessage(message session.Submission) (<-chan session.Event, error) { + m.mu.Lock() + m.steers = append(m.steers, message) + m.mu.Unlock() + return m.fakeAgent.Steer(message.Text) +} + +func (m *steeringAgent) FollowUpMessage(message session.Submission) (<-chan session.Event, error) { + m.mu.Lock() + m.follows = append(m.follows, message) + m.mu.Unlock() + return m.fakeAgent.FollowUp(message.Text) +} + +// markedReads is one read the way a door observed it. +func markedReads() []session.MessageRead { + return []session.MessageRead{{ + Source: session.MessageReference{Kind: "collection", ID: "c1", Name: "Specs"}, + Count: 200, Missing: 1, Unavailable: 2, More: true, + Warnings: []string{"two could not be asked"}, + Items: []session.MessageReference{{Kind: "file", ID: "r1", Path: "docs/a.md"}}, + }} +} + +// A MARKED CORRECTION AND A MARKED FOLLOW-UP CROSS THE WIRE WHOLE, on the same +// Steer and FollowUp calls a plain one makes: the far agent's own doors get the +// words, the person's words and every count; plain calls go by the plain doors; +// and a marked follow-up's stream is the take-back's receipt exactly as a plain +// one's is. The welcome says the two doors are there and the Submit door is not. +func TestAMarkedSteerAndFollowUpReachTheHostedAgentsOwnDoors(t *testing.T) { + agent := &steeringAgent{fakeAgent: &fakeAgent{model: "m"}} + l := dialAgent(t, &Engine{Agent: agent}) + frame := l.hello(Hello{Version: Version}) + if frame.Kind != "welcome" { + t.Fatalf("handshake: %s", frame.Error) + } + welcome := decode[Welcome](t, frame.Payload) + if !welcome.SteerMarks || !welcome.FollowUpMarks || welcome.MessageMarks { + t.Fatalf("the welcome says %+v", welcome) + } + said := "use these" + steer := SubmitArgs{Text: "use these\n\n- docs/a.md", Said: &said, Reads: markedReads()} + l.ok(1, MethodSteer, steer) + l.ok(2, MethodSteer, SubmitArgs{Text: "plain correction"}) + follow := SubmitArgs{Text: "next\n\n- docs/a.md", Reads: markedReads()} + ref := decode[StreamRef](t, l.ok(3, MethodFollowUp, follow).Payload) + queued := agent.stream(2) + + agent.mu.Lock() + steers, follows := agent.steers, agent.follows + steered, plainFollows := agent.fakeAgent.steered, agent.fakeAgent.follows + agent.mu.Unlock() + if len(steers) != 1 || !reflect.DeepEqual(steers[0], steer.submission()) { + t.Fatalf("the marked steer door was handed %+v", steers) + } + if len(follows) != 1 || follows[0].Said != nil || !reflect.DeepEqual(follows[0].Reads, markedReads()) { + t.Fatalf("the marked follow-up door was handed %+v", follows) + } + if !reflect.DeepEqual(steered, []string{steer.Text, "plain correction"}) || !reflect.DeepEqual(plainFollows, []string{follow.Text}) { + t.Fatalf("the agent heard steers %q and follow-ups %q", steered, plainFollows) + } + if answered := decode[bool](t, l.ok(4, MethodUnqueueFollowUp, UnqueueArgs{Stream: ref.Stream}).Payload); !answered { + t.Fatal("a marked follow-up's stream could not be taken back") + } + select { + case _, ok := <-queued: + if ok { + t.Fatal("the taken-back follow-up streamed an event") + } + case <-time.After(5 * time.Second): + t.Fatal("the taken-back follow-up's stream never closed") + } +} + +// AN ENGINE WITHOUT THE DOOR REFUSES A MARKED CORRECTION OR FOLLOW-UP BEFORE IT +// REACHES THE TURN OR THE QUEUE, and still takes plain words; a door for one +// says nothing about the other, and neither about Submit. +func TestAnEngineWithoutTheDoorRefusesAMarkedSteerOrFollowUpBeforeAnything(t *testing.T) { + plain := &fakeAgent{model: "m"} + l := dialAgent(t, engineOn(plain)) + welcome := decode[Welcome](t, l.hello(Hello{Version: Version}).Payload) + if welcome.SteerMarks || welcome.FollowUpMarks || welcome.MessageMarks { + t.Fatalf("a plain engine's welcome says %+v", welcome) + } + said := "" + for id, call := range []struct { + method string + args SubmitArgs + }{ + {MethodSteer, SubmitArgs{Text: "x", Said: &said}}, + {MethodSteer, SubmitArgs{Text: "x", Reads: markedReads()}}, + {MethodFollowUp, SubmitArgs{Text: "x", Attachments: []session.MessageAttachment{{Kind: "file", Path: "a.go"}}}}, + {MethodFollowUp, SubmitArgs{Text: "x", Reads: markedReads()}}, + } { + if frame := l.call(uint64(id+1), call.method, call.args); frame.Error == "" { + t.Fatalf("%s %+v was not refused", call.method, call.args) + } + } + l.ok(10, MethodSteer, SubmitArgs{Text: "plain correction"}) + l.ok(11, MethodFollowUp, SubmitArgs{Text: "plain follow-up"}) + plain.mu.Lock() + defer plain.mu.Unlock() + if !reflect.DeepEqual(plain.steered, []string{"plain correction"}) || !reflect.DeepEqual(plain.follows, []string{"plain follow-up"}) { + t.Fatalf("the plain agent heard steers %q and follow-ups %q", plain.steered, plain.follows) + } + + submitOnly := &markedAgent{fakeAgent: &fakeAgent{}} + if !messageMarksKnown(submitOnly) || steerMarksKnown(submitOnly) || followUpMarksKnown(submitOnly) { + t.Fatal("a Submit door was taken for a Steer or FollowUp door") + } +} + +// AN OLDER HOST IS NEVER SENT A MARKED CORRECTION OR FOLLOW-UP: the client +// refuses it with no call made, whatever else the welcome said, and each flag +// opens only its own door. +func TestAnOlderHostIsRefusedAMarkedSteerOrFollowUpWithNoCallMade(t *testing.T) { + said := "mine" + marked := session.Submission{Text: "x\n\n- a.md", Said: &said, Reads: markedReads()} + for _, welcome := range []Welcome{ + {Version: Version}, + {Version: Version, MessageMarks: true}, + {Version: Version, FollowUpMarks: true}, + } { + client := &Client{welcome: welcome} + agent := &Agent{c: client} + if _, err := agent.SteerMessage(marked); err == nil { + t.Fatalf("a host saying %+v was sent a marked steer", welcome) + } + if !welcome.FollowUpMarks { + if _, err := agent.FollowUpMessage(marked); err == nil { + t.Fatalf("a host saying %+v was sent a marked follow-up", welcome) + } + } + if client.made.Load() != 0 { + t.Fatalf("a refused message crossed the wire to a host saying %+v", welcome) + } + } + steerOnly := &Agent{c: &Client{welcome: Welcome{Version: Version, SteerMarks: true}}} + if !steerOnly.SteerMarksKnown() || steerOnly.FollowUpMarksKnown() || steerOnly.MessageMarksKnown() { + t.Fatal("a steer flag answered for another door") + } + if _, err := steerOnly.FollowUpMessage(marked); err == nil || steerOnly.c.made.Load() != 0 { + t.Fatal("a steer-only host was sent a marked follow-up") + } +} + +// markedAgent's Submit door says nothing about Steer and FollowUp. +var _ interface { + SubmitMessage(context.Context, session.Submission) (<-chan session.Event, error) +} = (*markedAgent)(nil) diff --git a/internal/remote/wire.go b/internal/remote/wire.go index 0655a382d9..e1d3e67219 100644 --- a/internal/remote/wire.go +++ b/internal/remote/wire.go @@ -1044,6 +1044,12 @@ type Welcome struct { // attachments and reference-read facts together. Absence is an older // engine: a marked submission must be refused before any words are sent. MessageMarks bool `json:"messageMarks,omitempty"` + // SteerMarks and FollowUpMarks say the same of Steer and FollowUp: the + // engine keeps a correction's or a follow-up's mark on the line the words + // land on. Each is its own fact — an engine that keeps a Submit's mark need + // not keep a Steer's — and absence refuses a marked call before it is sent. + SteerMarks bool `json:"steerMarks,omitempty"` + FollowUpMarks bool `json:"followUpMarks,omitempty"` // SessionInstance identifies this live conversation owner, not its durable // transcript. A replaced engine can reopen the same file with stream IDs // starting over, so a returning surface must forget the previous owner IDs. @@ -1463,6 +1469,23 @@ type SubmitArgs struct { Reads []session.MessageRead `json:"reads,omitempty"` } +// marked is whether the call carries a mark beside its words. Submit, Steer +// and FollowUp each take one; a marked call is kept with its mark or refused +// whole, and never run as its words alone. +func (a SubmitArgs) marked() bool { + return a.Said != nil || len(a.Attachments) > 0 || len(a.Reads) > 0 +} + +// submission is the call as the agent's own door for a mark takes it. +func (a SubmitArgs) submission() session.Submission { + return session.Submission{Text: a.Text, Said: a.Said, Attachments: a.Attachments, Reads: a.Reads} +} + +// argsOf is a submission as the wire carries it. +func argsOf(message session.Submission) SubmitArgs { + return SubmitArgs{Text: message.Text, Said: message.Said, Attachments: message.Attachments, Reads: message.Reads} +} + // UnqueueArgs names the queued follow-up a surface wants taken back out: the // stream id the FollowUp call answered with ([MethodUnqueueFollowUp]). It is // the stream and not a position or the text, because the stream is the receipt From c222d8049a530f98fbe02566f461b5c4ef325297 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 21:50:58 -0400 Subject: [PATCH 091/459] Let agents.steer, agents.queue and queue.edit carry said and reads Each goes to an optional engine door (MessageSteerer, MessageQueuer) and is refused unknown_method without one; hello says agents.steer.reads and agents.queue.reads only for a door that is there. Attachments on any of the three were silently dropped by the decoder and are now bad_params. Queued gains reads, and its text is the person's words. --- internal/appwire/convo.go | 133 +++++++++++++++--- internal/appwire/steerqueue_reads_test.go | 160 ++++++++++++++++++++++ 2 files changed, 275 insertions(+), 18 deletions(-) create mode 100644 internal/appwire/steerqueue_reads_test.go diff --git a/internal/appwire/convo.go b/internal/appwire/convo.go index 4ec0825e75..7f72e1076c 100644 --- a/internal/appwire/convo.go +++ b/internal/appwire/convo.go @@ -144,13 +144,47 @@ type MessageSender interface { // agents.send and crews.message keep said and reads on the person's message. const FeatureMessageReads = "messages.reads" -// withMessageReadsFeature is hello carrying [FeatureMessageReads] when the -// engine serves it. +// MessageSteerer is the optional engine half of agents.steer carrying said or +// reads. One that is not answers such a correction unknown_method, before the +// turn hears anything; a correction with neither goes by Steer as before. +// Attachments is always empty: a correction carries none. +type MessageSteerer interface { + SteerMessage(agent string, message AgentMessage) error +} + +// MessageQueuer is the optional engine half of agents.queue and queue.edit +// carrying said or reads. The queue keeps the whole message — the words the +// model will read, said and reads — and shows the person's own words; one +// that is not a MessageQueuer answers such a call unknown_method with the +// queue as it was. Attachments is always empty: a queued message carries none. +type MessageQueuer interface { + QueueMessage(agent string, message AgentMessage) ([]Queued, error) + // EditQueuedMessage replaces one queued message whole, checking the + // replacement before anything changes; an id already sent is CodeSent. + EditQueuedMessage(agent, id string, message AgentMessage) ([]Queued, error) +} + +// FeatureSteerReads and FeatureQueueReads are hello's words for an engine +// that is a [MessageSteerer] or a [MessageQueuer]. Each says only its own: an +// engine that keeps a sent message's reads need not keep a queued one's. +const ( + FeatureSteerReads = "agents.steer.reads" + FeatureQueueReads = "agents.queue.reads" +) + +// withMessageReadsFeature is hello carrying [FeatureMessageReads], +// [FeatureSteerReads] and [FeatureQueueReads] for each door the engine has. func withMessageReadsFeature(hello Hello, e Engine) Hello { - if _, ok := e.(MessageSender); !ok { - return hello + if _, ok := e.(MessageSender); ok { + hello = withFeature(hello, FeatureMessageReads) + } + if _, ok := e.(MessageSteerer); ok { + hello = withFeature(hello, FeatureSteerReads) } - return withFeature(hello, FeatureMessageReads) + if _, ok := e.(MessageQueuer); ok { + hello = withFeature(hello, FeatureQueueReads) + } + return hello } // The attachment kinds and a file's states. @@ -247,10 +281,13 @@ func CheckAttachIDs(ids []string) error { return nil } -// Queued is one message waiting for the turn to end. +// Queued is one message waiting for the turn to end. Text is the person's own +// words — said, when the message carried it — and never the body the model +// will read; Reads are the reads that body was written from. type Queued struct { - ID string `json:"id"` - Text string `json:"text"` + ID string `json:"id"` + Text string `json:"text"` + Reads []MessageRead `json:"reads,omitempty"` } // FileGroup is one group of files.find's answer. @@ -373,24 +410,54 @@ type queueResult struct { // sayTo is agents.steer and agents.queue's params. type sayTo struct { - ID string `json:"id"` - Text string `json:"text"` - ClientID string `json:"client_id"` + ID string `json:"id"` + Text string `json:"text"` + Said *string `json:"said"` + ClientID string `json:"client_id"` + Attachments []string `json:"attachments"` + Reads []MessageRead `json:"reads"` } func checkSay(p sayTo, name string) (sayTo, error) { if strings.TrimSpace(p.ID) == "" || strings.TrimSpace(p.Text) == "" { return p, Errorf(CodeBadParams, "%s needs an id and some text", name) } - return p, nil + return p, refuseAttachments(p.Attachments, name) +} + +// refuseAttachments is a correction or a queued message carrying +// attachments, refused rather than run without them: neither road keeps one. +func refuseAttachments(ids []string, name string) error { + if len(ids) > 0 { + return Errorf(CodeBadParams, "%s does not carry attachments; send them with agents.send", name) + } + return nil +} + +// marked is whether a message carries anything beyond its words. +func marked(said *string, reads []MessageRead) bool { + return said != nil || len(reads) > 0 } // queueAt is the queue methods' params. type queueAt struct { - Agent string `json:"agent"` - ID string `json:"id"` - Text string `json:"text"` - To *int `json:"to"` + Agent string `json:"agent"` + ID string `json:"id"` + Text string `json:"text"` + Said *string `json:"said"` + Attachments []string `json:"attachments"` + Reads []MessageRead `json:"reads"` + To *int `json:"to"` +} + +// queuer is the engine's door for a queued message's said and reads, or a +// refusal that leaves the queue as it was. +func queuer(e Engine, name string) (MessageQueuer, error) { + q, ok := e.(MessageQueuer) + if !ok { + return nil, Errorf(CodeUnknownMethod, "this engine does not serve %s with said or reads", name) + } + return q, nil } func checkQueueAt(p queueAt, name string) (queueAt, error) { @@ -425,6 +492,13 @@ var talkMethods = map[string]method{ if err != nil { return nil, err } + if marked(p.Said, p.Reads) { + s, ok := e.(MessageSteerer) + if !ok { + return nil, Errorf(CodeUnknownMethod, "this engine does not serve agents.steer with said or reads") + } + return done(s.SteerMessage(p.ID, AgentMessage{Text: p.Text, Said: p.Said, ClientID: p.ClientID, Reads: p.Reads})) + } return done(t.Steer(p.ID, p.Text, p.ClientID)) }), "agents.queue": typed(func(e Engine, p sayTo) (*queueResult, error) { @@ -436,7 +510,16 @@ var talkMethods = map[string]method{ if err != nil { return nil, err } - queued, err := t.Queue(p.ID, p.Text, p.ClientID) + var queued []Queued + if marked(p.Said, p.Reads) { + q, qerr := queuer(e, "agents.queue") + if qerr != nil { + return nil, qerr + } + queued, err = q.QueueMessage(p.ID, AgentMessage{Text: p.Text, Said: p.Said, ClientID: p.ClientID, Reads: p.Reads}) + } else { + queued, err = t.Queue(p.ID, p.Text, p.ClientID) + } return &queueResult{nonNil(queued)}, err }), "queue.edit": typed(func(e Engine, p queueAt) (*queueResult, error) { @@ -451,7 +534,21 @@ var talkMethods = map[string]method{ if strings.TrimSpace(p.Text) == "" { return nil, Errorf(CodeBadParams, "queue.edit needs the new text; queue.remove takes a message out") } - queued, err := t.EditQueued(p.Agent, p.ID, p.Text) + if err := refuseAttachments(p.Attachments, "queue.edit"); err != nil { + return nil, err + } + // A PLAIN EDIT REPLACES THE WHOLE MESSAGE with plain words, the old + // said and reads included; only an edit that carries them keeps any. + var queued []Queued + if marked(p.Said, p.Reads) { + q, qerr := queuer(e, "queue.edit") + if qerr != nil { + return nil, qerr + } + queued, err = q.EditQueuedMessage(p.Agent, p.ID, AgentMessage{Text: p.Text, Said: p.Said, Reads: p.Reads}) + } else { + queued, err = t.EditQueued(p.Agent, p.ID, p.Text) + } return &queueResult{nonNil(queued)}, err }), "queue.remove": typed(func(e Engine, p queueAt) (*queueResult, error) { diff --git a/internal/appwire/steerqueue_reads_test.go b/internal/appwire/steerqueue_reads_test.go new file mode 100644 index 0000000000..c34b9f042f --- /dev/null +++ b/internal/appwire/steerqueue_reads_test.go @@ -0,0 +1,160 @@ +package appwire + +import ( + "encoding/json" + "reflect" + "slices" + "testing" +) + +// talkEngine is a Talker whose plain steer, queue and edit are recorded. +type talkEngine struct { + sendEngine + plain []string +} + +func (t *talkEngine) Steer(agent, text, clientID string) error { + t.plain = append(t.plain, "steer:"+text) + return nil +} + +func (t *talkEngine) Queue(agent, text, clientID string) ([]Queued, error) { + t.plain = append(t.plain, "queue:"+text) + return []Queued{{ID: "q1", Text: text}}, nil +} + +func (t *talkEngine) EditQueued(agent, id, text string) ([]Queued, error) { + t.plain = append(t.plain, "edit:"+id+":"+text) + return []Queued{{ID: id, Text: text}}, nil +} + +// markedTalkEngine has the doors for a correction's and a queued message's +// said and reads. +type markedTalkEngine struct { + talkEngine + steered, queued []AgentMessage + edited map[string]AgentMessage +} + +func (m *markedTalkEngine) SteerMessage(agent string, message AgentMessage) error { + m.steered = append(m.steered, message) + return nil +} + +func (m *markedTalkEngine) QueueMessage(agent string, message AgentMessage) ([]Queued, error) { + m.queued = append(m.queued, message) + return []Queued{{ID: "q1", Text: *message.Said, Reads: message.Reads}}, nil +} + +func (m *markedTalkEngine) EditQueuedMessage(agent, id string, message AgentMessage) ([]Queued, error) { + if m.edited == nil { + m.edited = map[string]AgentMessage{} + } + m.edited[id] = message + return []Queued{{ID: id, Text: "edited", Reads: message.Reads}}, nil +} + +const oneRead = `[{"source":{"kind":"collection","id":"c1","name":"Specs"},"count":3,"missing":1,"unavailable":0,"unread":0,"more":false,"complete":false,` + + `"items":[{"kind":"file","path":"docs/a.md"}]}]` + +// A CORRECTION, A QUEUED MESSAGE AND A QUEUED EDIT CARRY SAID AND READS TO THE +// ENGINE'S OWN DOORS WHOLE, and plain ones go by the roads they always went by: +// a plain edit included, which replaces the old mark rather than keeping it. +func TestASteerQueueAndEditCarrySaidAndReadsToTheirDoors(t *testing.T) { + eng := &markedTalkEngine{} + call := func(name, params string) (any, error) { + return methods[name].call(eng, json.RawMessage(params)) + } + reads := []MessageRead{{Source: MessageReference{Kind: "collection", ID: "c1", Name: "Specs"}, Count: 3, Missing: 1, + Items: []MessageReference{{Kind: "file", Path: "docs/a.md"}}}} + + if _, err := call("agents.steer", `{"id":"a1","text":"use\n\n- docs/a.md","said":"use","client_id":"c1","reads":`+oneRead+`}`); err != nil { + t.Fatal(err) + } + said := "use" + if want := (AgentMessage{Text: "use\n\n- docs/a.md", Said: &said, ClientID: "c1", Reads: reads}); len(eng.steered) != 1 || !reflect.DeepEqual(eng.steered[0], want) { + t.Fatalf("the steer door heard %+v", eng.steered) + } + r, err := call("agents.queue", `{"id":"a1","text":"later\n\n- docs/a.md","said":"later","client_id":"c2","reads":`+oneRead+`}`) + if err != nil { + t.Fatal(err) + } + if got := r.(*queueResult).Queued; len(got) != 1 || got[0].Text != "later" || !reflect.DeepEqual(got[0].Reads, reads) { + t.Fatalf("the queue answered %+v", got) + } + if len(eng.queued) != 1 || eng.queued[0].Text != "later\n\n- docs/a.md" || eng.queued[0].ClientID != "c2" { + t.Fatalf("the queue door heard %+v", eng.queued) + } + if _, err := call("queue.edit", `{"agent":"a1","id":"q1","text":"body","said":"","reads":`+oneRead+`}`); err != nil { + t.Fatal(err) + } + if got := eng.edited["q1"]; got.Text != "body" || got.Said == nil || *got.Said != "" || !reflect.DeepEqual(got.Reads, reads) { + t.Fatalf("the edit door heard %+v", got) + } + + for _, plain := range [][2]string{ + {"agents.steer", `{"id":"a1","text":"plain"}`}, + {"agents.queue", `{"id":"a1","text":"plain"}`}, + {"queue.edit", `{"agent":"a1","id":"q1","text":"plain"}`}, + } { + if _, err := call(plain[0], plain[1]); err != nil { + t.Fatalf("%s: %v", plain[0], err) + } + } + if want := []string{"steer:plain", "queue:plain", "edit:q1:plain"}; !reflect.DeepEqual(eng.plain, want) { + t.Fatalf("the plain roads heard %q", eng.plain) + } + if len(eng.steered) != 1 || len(eng.queued) != 1 || len(eng.edited) != 1 { + t.Fatal("a plain call went by a marked door") + } +} + +// ATTACHMENTS ON A CORRECTION OR A QUEUED MESSAGE ARE REFUSED, never dropped, +// plain or marked; and said or reads to an engine without the door are +// refused as unknown before anything is steered, queued or edited. +func TestASteerOrQueueRefusesAttachmentsAndAnAbsentDoor(t *testing.T) { + for _, eng := range []Engine{&talkEngine{}, &markedTalkEngine{}} { + for _, c := range [][2]string{ + {"agents.steer", `{"id":"a1","text":"x","attachments":["at1"]}`}, + {"agents.queue", `{"id":"a1","text":"x","said":"x","attachments":["at1"]}`}, + {"queue.edit", `{"agent":"a1","id":"q1","text":"x","attachments":["at1"]}`}, + } { + if _, err := methods[c[0]].call(eng, json.RawMessage(c[1])); codeOf(err) != CodeBadParams { + t.Fatalf("%T %s with attachments answered %v", eng, c[0], err) + } + } + } + plain := &talkEngine{} + for _, c := range [][2]string{ + {"agents.steer", `{"id":"a1","text":"x","said":"x"}`}, + {"agents.queue", `{"id":"a1","text":"x","reads":` + oneRead + `}`}, + {"queue.edit", `{"agent":"a1","id":"q1","text":"x","said":""}`}, + } { + if _, err := methods[c[0]].call(plain, json.RawMessage(c[1])); codeOf(err) != CodeUnknownMethod { + t.Fatalf("%s to an engine without the door answered %v", c[0], err) + } + } + if len(plain.plain) != 0 { + t.Fatalf("a refused call reached the engine: %q", plain.plain) + } +} + +// HELLO SAYS EACH DOOR ON ITS OWN: a send door says nothing of a queue's or a +// correction's, and a queued message's text and reads are said on the wire. +func TestHelloSaysEachMessageDoorOnItsOwn(t *testing.T) { + sendOnly := withMessageReadsFeature(Hello{}, &messageEngine{}).Features + if !slices.Contains(sendOnly, FeatureMessageReads) || slices.Contains(sendOnly, FeatureSteerReads) || slices.Contains(sendOnly, FeatureQueueReads) { + t.Fatalf("a send-only engine said %q", sendOnly) + } + both := withMessageReadsFeature(Hello{}, &markedTalkEngine{}).Features + if slices.Contains(both, FeatureMessageReads) || !slices.Contains(both, FeatureSteerReads) || !slices.Contains(both, FeatureQueueReads) { + t.Fatalf("a steer and queue engine said %q", both) + } + out, _ := json.Marshal(Queued{ID: "q1", Text: "mine", Reads: []MessageRead{{Source: MessageReference{Kind: "collection", ID: "c1"}}}}) + if string(out) != `{"id":"q1","text":"mine","reads":[{"source":{"kind":"collection","id":"c1"},"count":0,"missing":0,"unavailable":0,"unread":0,"more":false,"complete":false}]}` { + t.Fatalf("a queued message is %s", out) + } + if plain, _ := json.Marshal(Queued{ID: "q1", Text: "mine"}); string(plain) != `{"id":"q1","text":"mine"}` { + t.Fatalf("a plain queued message is %s", plain) + } +} From d16478451c50516366af1aba19902fc3842c648a Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 22:03:21 -0400 Subject: [PATCH 092/459] Keep said and reads on the app's corrections and queued messages SteerMessage, QueueMessage and EditQueuedMessage take the expanded words with the person's own and the reads. A queued record keeps all three, its own bounded copy, and shows the person's words; it is sent by the marked door when the turn ends, and its accepted user entry shows them. A plain edit drops the old mark; a marked edit is checked whole before the queue changes; an edit after the send answers sent. A correction's steer entry shows the mark, and so does the user entry of the turn it falls into. A conversation whose host said it keeps no mark is refused unknown_method before anything is steered, queued or sent. --- cmd/codeaf/app.go | 6 + cmd/codeaf/appconvo.go | 173 ++++++++- cmd/codeaf/appsteerqueue_history_test.go | 214 +++++++++++ cmd/codeaf/appsteerqueue_reads_test.go | 358 ++++++++++++++++++ .../unreleased/0-message-reference-reads.md | 5 +- 5 files changed, 737 insertions(+), 19 deletions(-) create mode 100644 cmd/codeaf/appsteerqueue_history_test.go create mode 100644 cmd/codeaf/appsteerqueue_reads_test.go diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index 3c900f1256..6ab578259a 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -1464,6 +1464,12 @@ func (b *appBackend) startLocked(a *appAgent, text, clientID string, shown *appS if refused != nil { return 0, refused } + // A HOST THAT CANNOT KEEP THE MESSAGE'S MARK IS NOT SENT THE MESSAGE: the + // refusal is the app's to read before the turn starts, and the person's + // draft and chips stay as they were. + if shown != nil && !appKeepsQueuedMarks(live.conn.Conv) { + return 0, appwire.Errorf(appwire.CodeUnknownMethod, "this conversation cannot keep a message's said, reads or attachments; update its engine before sending") + } // A conversation joined on a window's host can have heard from the person // in that window too, so its count is read again before it is used. if !live.conn.Assembled && live.conn.Transcript != "" { diff --git a/cmd/codeaf/appconvo.go b/cmd/codeaf/appconvo.go index 5cdbc1184f..feb8f14d5f 100644 --- a/cmd/codeaf/appconvo.go +++ b/cmd/codeaf/appconvo.go @@ -33,6 +33,7 @@ import ( "github.com/Agent-Field/codeaf/internal/appwire" "github.com/Agent-Field/codeaf/internal/guard" "github.com/Agent-Field/codeaf/internal/redact" + "github.com/Agent-Field/codeaf/internal/remote" "github.com/Agent-Field/codeaf/internal/session" "github.com/Agent-Field/codeaf/internal/tui3" ) @@ -56,9 +57,13 @@ const ( // appTrimmedLine is the line that stands where earlier output was dropped. const appTrimmedLine = "… earlier output trimmed to the last 512 KB" -// appQueued is one message of the person's waiting for the turn to end. +// appQueued is one message of the person's waiting for the turn to end. text +// is the words the model will be sent; shown, when set, is the person's own +// words and the reads text was written from, which the queue and the entry +// show instead of it. shown is this message's own copy, never shared. type appQueued struct { id, text, clientID string + shown *appSending } // appAttach is one attachment the composer added, with what it carries. @@ -82,6 +87,70 @@ type appNotePending struct { // steer the turn ends before reading falls through, and becomes the next // message, as the engine does it. func (b *appBackend) Steer(agent, text, clientID string) error { + return b.steer(agent, text, clientID, nil) +} + +// SteerMessage is [appBackend.Steer] with the person's own words and the reads +// text was written from, kept on the correction's line in the record and on +// the turn's message if it falls through. +func (b *appBackend) SteerMessage(agent string, message appwire.AgentMessage) error { + shown, err := appMarkOf(message, "a correction") + if err != nil { + return err + } + return b.steer(agent, message.Text, message.ClientID, shown) +} + +var ( + _ appwire.MessageSender = (*appBackend)(nil) + _ appwire.MessageSteerer = (*appBackend)(nil) + _ appwire.MessageQueuer = (*appBackend)(nil) + _ appSteerer = (*remote.Agent)(nil) + _ appMarkProbe = (*remote.Agent)(nil) +) + +// appSteerer is a conversation's door for a correction that carries the +// person's mark. A [remote.Agent] is one; whether its host keeps the mark is +// [appMarkProbe]'s to say. +type appSteerer interface { + SteerMessage(message session.Submission) (<-chan session.Event, error) +} + +// appMarkProbe is a conversation that can say, before anything is asked of +// it, whether its host keeps a marked message, a marked correction and a +// marked follow-up. A conversation that cannot say is held in this process +// and has the door it was built with. +type appMarkProbe interface { + MessageMarksKnown() bool + SteerMarksKnown() bool +} + +// appMarkOf is a correction's or a queued message's mark, checked whole and +// kept as the record will keep it: the person's own words and the reads. +// Attachments are refused: neither road carries one. +func appMarkOf(message appwire.AgentMessage, what string) (*appSending, error) { + if len(message.Attachments) > 0 { + return nil, appwire.Errorf(appwire.CodeBadParams, "%s does not carry attachments; send them as a message", what) + } + reads, err := appKeptReads(message.Reads) + if err != nil { + return nil, err + } + said := message.Text + if message.Said != nil { + said = *message.Said + } + return &appSending{text: said, reads: reads}, nil +} + +// appKeepsQueuedMarks is whether conv's host keeps a queued message's mark +// when the queue sends it, which goes by SubmitMessage. +func appKeepsQueuedMarks(conv appConversation) bool { + probe, ok := conv.(appMarkProbe) + return !ok || probe.MessageMarksKnown() +} + +func (b *appBackend) steer(agent, text, clientID string, shown *appSending) error { a, err := b.find(agent) if err != nil { return err @@ -94,23 +163,40 @@ func (b *appBackend) Steer(agent, text, clientID string) error { if !running { return appwire.Errorf(appwire.CodeBusy, "nothing is running to steer; send it as a message") } - events, err := live.conn.Conv.Steer(text) + // A CORRECTION'S MARK IS KEPT OR THE CORRECTION IS NOT MADE: a host that + // cannot keep it is refused before the turn hears a word, and the person's + // draft stays theirs. + var events <-chan session.Event + if shown != nil { + steerer, ok := live.conn.Conv.(appSteerer) + if probe, known := live.conn.Conv.(appMarkProbe); !ok || (known && !probe.SteerMarksKnown()) { + return appwire.Errorf(appwire.CodeUnknownMethod, "this conversation cannot keep a correction's said or reads; send it as a message") + } + events, err = steerer.SteerMessage(appSubmission(text, shown)) + } else { + events, err = live.conn.Conv.Steer(text) + } if err != nil { return appEngineError(err) } + entryText, reads := text, []appwire.MessageRead(nil) + if shown != nil { + entryText, reads = shown.text, appReadsOf(shown.reads) + } var entry appwire.Entry b.locked(func() { live.steers++ - entry = appwire.Entry{ID: "st" + strconv.FormatUint(live.turn, 10) + "." + strconv.Itoa(live.steers), Agent: agent, Turn: live.turn, Kind: appwire.EntrySteer, At: b.now(), Text: text, ClientID: clientID, State: appwire.SteerPending} + entry = appwire.Entry{ID: "st" + strconv.FormatUint(live.turn, 10) + "." + strconv.Itoa(live.steers), Agent: agent, Turn: live.turn, Kind: appwire.EntrySteer, At: b.now(), Text: entryText, ClientID: clientID, State: appwire.SteerPending, Reads: reads} }) live.box.Event(appwire.EventEntryAdded, appwire.EntryAdded{Entry: entry}) - guard.Go("app/steer", func() { b.followSteer(a, live, entry, events) }) + guard.Go("app/steer", func() { b.followSteer(a, live, entry, text, shown, events) }) return nil } // followSteer reads one steer's own stream: consumed at a step, or fallen -// through, after which the same stream is the turn its words start. -func (b *appBackend) followSteer(a *appAgent, live *appLive, entry appwire.Entry, events <-chan session.Event) { +// through, after which the same stream is the turn its words start. text is +// the words the model was sent and shown the person's mark, if any. +func (b *appBackend) followSteer(a *appAgent, live *appLive, entry appwire.Entry, text string, shown *appSending, events <-chan session.Event) { for event := range events { switch event.Kind { case session.EventSteerConsumed: @@ -124,15 +210,16 @@ func (b *appBackend) followSteer(a *appAgent, live *appLive, entry appwire.Entry entry.State, entry.At = appwire.SteerFellThrough, b.now() b.locked(func() { live.adopting++ }) live.box.Event(appwire.EventEntryAdded, appwire.EntryAdded{Entry: entry}) - b.adoptTurn(a, live, entry.Text, entry.ClientID, events) + b.adoptTurn(a, live, text, entry.ClientID, shown, events) return } } } // adoptTurn makes the rest of a fallen-through steer's stream the agent's -// next turn, once the turn it missed has said its last. -func (b *appBackend) adoptTurn(a *appAgent, live *appLive, text, clientID string, events <-chan session.Event) { +// next turn, once the turn it missed has said its last. Its user entry shows +// the person's mark, as the record's line for it does. +func (b *appBackend) adoptTurn(a *appAgent, live *appLive, text, clientID string, shown *appSending, events <-chan session.Event) { var done chan struct{} b.locked(func() { done = live.pumpDone }) if done != nil { @@ -165,16 +252,20 @@ func (b *appBackend) adoptTurn(a *appAgent, live *appLive, text, clientID string } return } + entryText, reads := text, []appwire.MessageRead(nil) + if shown != nil { + entryText, reads = shown.text, appReadsOf(shown.reads) + } live.box.Event(appwire.EventTurnStarted, appwire.TurnStarted{Agent: id, Turn: turn}) live.box.Event(appwire.EventEntryAdded, appwire.EntryAdded{Entry: appwire.Entry{ - ID: appUserEntryID(n), Agent: id, Turn: turn, Kind: appwire.EntryUser, At: b.now(), Text: text, ClientID: clientID, + ID: appUserEntryID(n), Agent: id, Turn: turn, Kind: appwire.EntryUser, At: b.now(), Text: entryText, ClientID: clientID, Reads: reads, }}) long := b.watchLong(id, live, turn, b.now()) b.newTurnCalls(a) tails := b.tickTools(a, id, live) defer long.Stop() defer tails() - b.pump(a, id, live, turn, n, text, nil, events) + b.pump(a, id, live, turn, n, text, shown, events) b.sendQueued(a, live) } @@ -183,6 +274,21 @@ func (b *appBackend) adoptTurn(a *appAgent, live *appLive, text, clientID string // Queue keeps a message for when the running turn ends, or sends it now when // nothing is running. func (b *appBackend) Queue(agent, text, clientID string) ([]appwire.Queued, error) { + return b.queue(agent, text, clientID, nil) +} + +// QueueMessage is [appBackend.Queue] with the person's own words and the reads +// text was written from. The queue keeps all three and shows the person's +// words; the message, once sent, is an ordinary one carrying them. +func (b *appBackend) QueueMessage(agent string, message appwire.AgentMessage) ([]appwire.Queued, error) { + shown, err := appMarkOf(message, "a queued message") + if err != nil { + return nil, err + } + return b.queue(agent, message.Text, message.ClientID, shown) +} + +func (b *appBackend) queue(agent, text, clientID string, shown *appSending) ([]appwire.Queued, error) { a, err := b.find(agent) if err != nil { return nil, err @@ -193,25 +299,37 @@ func (b *appBackend) Queue(agent, text, clientID string) ([]appwire.Queued, erro b.locked(func() { busy = live.running || live.adopting > 0 }) } if !busy { - if _, err := b.Send(agent, text, clientID); err != nil { + if _, err := b.send(agent, text, clientID, shown); err != nil { return nil, err } return b.queued(a), nil } + // A QUEUED MARK IS ONE THE HOST WILL KEEP, or nothing is queued: the + // refusal comes now, with the draft still the person's, and never as a + // message that failed when the turn ended. + if shown != nil && !appKeepsQueuedMarks(live.conn.Conv) { + return nil, appwire.Errorf(appwire.CodeUnknownMethod, "this conversation cannot keep a queued message's said or reads; send it when the turn ends") + } var queued []appwire.Queued b.locked(func() { a.queueN++ - a.queue = append(a.queue, appQueued{id: "q" + strconv.Itoa(a.queueN), text: text, clientID: clientID}) + a.queue = append(a.queue, appQueued{id: "q" + strconv.Itoa(a.queueN), text: text, clientID: clientID, shown: shown}) queued = appQueuedOf(a.queue) }) b.emit.Emit(appwire.EventQueueChanged, appwire.QueueChanged{Agent: agent, Queued: queued}) return queued, nil } +// appQueuedOf is the queue as the app sees it: the person's own words, never +// the words the model will be sent, and a copy of each message's reads. func appQueuedOf(queue []appQueued) []appwire.Queued { out := make([]appwire.Queued, 0, len(queue)) for _, q := range queue { - out = append(out, appwire.Queued{ID: q.id, Text: q.text}) + view := appwire.Queued{ID: q.id, Text: q.text} + if q.shown != nil { + view.Text, view.Reads = q.shown.text, appReadsOf(q.shown.reads) + } + out = append(out, view) } return out } @@ -248,9 +366,30 @@ func (b *appBackend) changeQueue(agent, id string, change func(a *appAgent, at i return queued, nil } -// EditQueued changes a queued message's words. +// EditQueued changes a queued message's words. A PLAIN EDIT REPLACES THE WHOLE +// MESSAGE: the said and reads it carried are dropped with the old words, so +// nothing is shown or kept beside words that were not written from it. func (b *appBackend) EditQueued(agent, id, text string) ([]appwire.Queued, error) { - return b.changeQueue(agent, id, func(a *appAgent, at int) { a.queue[at].text = text }) + return b.changeQueue(agent, id, func(a *appAgent, at int) { a.queue[at].text, a.queue[at].shown = text, nil }) +} + +// EditQueuedMessage replaces a queued message with another carrying its own +// said and reads. The whole replacement is checked before the queue is +// touched, so a refusal leaves every queued message as it was; a message +// already sent answers sent and nothing is queued. +func (b *appBackend) EditQueuedMessage(agent, id string, message appwire.AgentMessage) ([]appwire.Queued, error) { + shown, err := appMarkOf(message, "a queued message") + if err != nil { + return nil, err + } + a, err := b.find(agent) + if err != nil { + return nil, err + } + if live := b.liveOf(a); live != nil && !appKeepsQueuedMarks(live.conn.Conv) { + return nil, appwire.Errorf(appwire.CodeUnknownMethod, "this conversation cannot keep a queued message's said or reads") + } + return b.changeQueue(agent, id, func(a *appAgent, at int) { a.queue[at].text, a.queue[at].shown = message.Text, shown }) } // RemoveQueued takes a queued message out. @@ -286,7 +425,7 @@ func (b *appBackend) sendQueued(a *appAgent, live *appLive) { return } b.emit.Emit(appwire.EventQueueChanged, appwire.QueueChanged{Agent: agent, Queued: queued}) - if _, err := b.Send(agent, next.text, next.clientID); err != nil { + if _, err := b.send(agent, next.text, next.clientID, next.shown); err != nil { live.box.Event(appwire.EventEntryAdded, appwire.EntryAdded{Entry: appwire.Entry{ ID: "qx" + next.id, Agent: agent, Kind: appwire.EntryError, At: b.now(), Text: "the queued message was not sent: " + err.Error(), }}) diff --git a/cmd/codeaf/appsteerqueue_history_test.go b/cmd/codeaf/appsteerqueue_history_test.go new file mode 100644 index 0000000000..aab79268c3 --- /dev/null +++ b/cmd/codeaf/appsteerqueue_history_test.go @@ -0,0 +1,214 @@ +package main + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" +) + +// heldModel is a scripted model whose first turn is held open until the test +// lets it answer, so a correction and a queued message can arrive while the +// turn is still running. Every turn's words are kept. +type heldModel struct { + server *httptest.Server + release chan struct{} + arrived chan struct{} + + mu sync.Mutex + turns int + requests []recordedRequest +} + +func newHeldModel(t *testing.T) *heldModel { + t.Helper() + model := &heldModel{release: make(chan struct{}), arrived: make(chan struct{})} + model.server = httptest.NewServer(http.HandlerFunc(func(writer http.ResponseWriter, request *http.Request) { + if !strings.HasSuffix(request.URL.Path, "/chat/completions") { + http.Error(writer, "no catalog for a test", http.StatusServiceUnavailable) + return + } + raw, _ := io.ReadAll(request.Body) + var envelope recordedRequest + if err := json.Unmarshal(raw, &envelope); err != nil { + http.Error(writer, err.Error(), http.StatusBadRequest) + return + } + held := false + model.mu.Lock() + model.requests = append(model.requests, envelope) + if len(envelope.Tools) > 0 { + model.turns++ + held = model.turns == 1 + } + model.mu.Unlock() + if held { + close(model.arrived) + select { + case <-model.release: + case <-request.Context().Done(): + return + } + } + // A summary is asked for in prose and has to come back as prose. + answer := "done" + if strings.Contains(strings.Join(envelope.texts(), "\n"), "Write the summary now.") { + answer = "The person opened a review with its background, then shared a set of specs and a set of links, and the agent acknowledged each message without deciding anything." + } + quoted, _ := json.Marshal(answer) + if envelope.Stream { + writer.Header().Set("Content-Type", "text/event-stream") + _, _ = writer.Write([]byte(`data: {"id":"held","choices":[{"index":0,"delta":{"role":"assistant","content":` + string(quoted) + `},"finish_reason":"stop"}]}` + "\n\ndata: [DONE]\n\n")) + return + } + _, _ = writer.Write([]byte(`{"choices":[{"index":0,"message":{"role":"assistant","content":` + string(quoted) + `},"finish_reason":"stop"}]}`)) + })) + t.Cleanup(model.server.Close) + return model +} + +// appLiveSteer is the steer entry the backend said live for one client id +// once the turn read it. +func appLiveSteer(t *testing.T, rec *appRecorder, clientID string) appwire.Entry { + t.Helper() + i := rec.waitFor(t, 30*time.Second, func(event string, data any) bool { + added, ok := data.(appwire.EntryAdded) + return ok && added.Entry.Kind == appwire.EntrySteer && added.Entry.ClientID == clientID && added.Entry.State == appwire.SteerConsumed + }) + rec.mu.Lock() + defer rec.mu.Unlock() + return rec.data[i].(appwire.EntryAdded).Entry +} + +// sentWords is whether some turn's request carried words. +func (m *heldModel) sentWords(words string) bool { + m.mu.Lock() + defer m.mu.Unlock() + for _, request := range m.requests { + if len(request.Tools) > 0 && strings.Contains(strings.Join(request.texts(), "\n"), words) { + return true + } + } + return false +} + +// A CORRECTION AND A QUEUED MESSAGE KEEP THEIR MARK ON THE HOSTED ROAD AND IN +// THE RECORD. A real conversation, hosted in this process over the engine's +// own wire, is held mid-turn; the person sends a marked correction and queues +// a marked message. The turn reads the correction at its next step and the +// queued message is sent when it ends. The model is sent the expanded words of +// both; the steer entry and the queued message's user entry show the person's +// words and reads live; the record keeps each as a message of theirs; a +// compaction that summarizes them leaves them; and a NEW backend reading the record off the disk +// shows exactly the same on the same lines. +func TestAppSteerAndQueueReadsSurviveTheHostedRoadAndAReopen(t *testing.T) { + root := resolvedTempDir(t) + _, workspace := wakeEnvironment(t, root) + model := newHeldModel(t) + t.Setenv("CODEAF_BASE_URL", model.server.URL) + rec := newAppRecorder() + first := newAppBackend(rec, appOpenInProcess) + closed := false + defer func() { + if !closed { + first.Close() + } + }() + agent, err := mustCreate(t, first, appwire.CreateAsk{Workspace: workspace}) + if err != nil { + t.Fatal(err) + } + // The first message is long enough that a compaction has something to + // summarize once the marked messages are behind the last three. + opening := "start the review\n\n" + strings.TrimSpace(strings.Repeat("background on the review, one line of it\n", 160)) + if _, err := first.Send(agent.ID, opening, "h-0"); err != nil { + t.Fatal(err) + } + select { + case <-model.arrived: + case <-time.After(30 * time.Second): + t.Fatal("the first turn never reached the model") + } + + steerSaid, steerBody := "use the specs", "use the specs\n\nSpecs:\n- docs/a.md\n- https://example.com/b" + if err := first.SteerMessage(agent.ID, appwire.AgentMessage{Text: steerBody, Said: &steerSaid, ClientID: "h-1", Reads: []appwire.MessageRead{appSpecsRead()}}); err != nil { + t.Fatal(err) + } + none, queueBody := "", "Links:\n- https://example.com/c" + queued, err := first.QueueMessage(agent.ID, appwire.AgentMessage{Text: queueBody, Said: &none, ClientID: "h-2", Reads: []appwire.MessageRead{appCutRead()}}) + if err != nil { + t.Fatal(err) + } + if len(queued) != 1 || queued[0].Text != "" || !reflect.DeepEqual(queued[0].Reads, []appwire.MessageRead{appCutRead()}) { + t.Fatalf("the queue shows %+v", queued) + } + close(model.release) + + steered := appLiveSteer(t, rec, "h-1") + if steered.Text != steerSaid || !reflect.DeepEqual(steered.Reads, []appwire.MessageRead{appSpecsRead()}) { + t.Fatalf("the consumed correction went live as %+v", steered) + } + sent := appLiveUser(t, rec, "h-2") + if sent.Text != "" || !reflect.DeepEqual(sent.Reads, []appwire.MessageRead{appCutRead()}) { + t.Fatalf("the queued message went live as %+v", sent) + } + appTurnDoneFor(t, rec, sent.Turn) + if !model.sentWords(steerBody) || !model.sentWords(queueBody) { + t.Fatal("the model was not sent the expanded words of both") + } + + // A compaction summarizes what is before the last three messages, so three + // plain ones put the marked ones inside what it summarizes. + for _, words := range []string{"one", "two", "three"} { + appTurnDoneFor(t, rec, appSendMessageWhenIdle(t, first, agent.ID, appwire.AgentMessage{Text: words, ClientID: "p-" + words})) + } + want := func(t *testing.T, users []appwire.Entry, when string) { + t.Helper() + if len(users) != 6 || users[5].Text != "three" || users[5].Reads != nil || users[0].Text != opening || users[0].Reads != nil || + users[1].Text != steerSaid || !reflect.DeepEqual(users[1].Reads, []appwire.MessageRead{appSpecsRead()}) || + users[2].Text != "" || !reflect.DeepEqual(users[2].Reads, []appwire.MessageRead{appCutRead()}) { + t.Fatalf("%s the person's messages are %+v", when, users) + } + } + entries, _, err := first.Entries(agent.ID, "", appwire.MaxEntries) + if err != nil { + t.Fatal(err) + } + want(t, appUsersOf(entries), "before a compaction") + + if err := first.CompactAgent(agent.ID); err != nil { + t.Fatal(err) + } + ended := rec.waitFor(t, 30*time.Second, func(event string, data any) bool { + _, ok := data.(appwire.CompactionEnded) + return ok + }) + rec.mu.Lock() + outcome := rec.data[ended].(appwire.CompactionEnded) + rec.mu.Unlock() + if outcome.Outcome != appwire.CompactionCompacted { + t.Fatalf("the compaction ended %+v", outcome) + } + if entries, _, err = first.Entries(agent.ID, "", appwire.MaxEntries); err != nil { + t.Fatal(err) + } + want(t, appUsersOf(entries), "after a compaction") + if err := first.Close(); err != nil { + t.Fatal(err) + } + closed = true + + second := newAppBackend(newAppRecorder(), appOpenInProcess) + defer second.Close() + if entries, _, err = second.Entries(agent.ID, "", appwire.MaxEntries); err != nil { + t.Fatal(err) + } + want(t, appUsersOf(entries), "after a reopen") +} diff --git a/cmd/codeaf/appsteerqueue_reads_test.go b/cmd/codeaf/appsteerqueue_reads_test.go new file mode 100644 index 0000000000..fa826b36eb --- /dev/null +++ b/cmd/codeaf/appsteerqueue_reads_test.go @@ -0,0 +1,358 @@ +package main + +import ( + "reflect" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/session" +) + +// steeringConversation is the fake conversation with a door for a marked +// correction, as a hosted engine that says it keeps one has. +type steeringConversation struct { + *fakeConversation + steers []session.Submission +} + +func (s *steeringConversation) SteerMessage(message session.Submission) (<-chan session.Event, error) { + s.mu.Lock() + s.steers = append(s.steers, message) + s.mu.Unlock() + return s.fakeConversation.Steer(message.Text) +} + +// olderHostConversation is a conversation whose host said in its welcome that +// it keeps no mark on any door. +type olderHostConversation struct{ *steeringConversation } + +func (olderHostConversation) MessageMarksKnown() bool { return false } +func (olderHostConversation) SteerMarksKnown() bool { return false } + +// markBackend is workBackend over any conversation. +func markBackend(t *testing.T, conv appConversation) (*appBackend, *appRecorder, appwire.Agent) { + t.Helper() + rec := newAppRecorder() + backend := newFakeBackend(rec, func(ask appOpenAsk) (*appConn, error) { + return &appConn{Conv: conv, Workspace: ask.Workspace, Transcript: appFakeTranscript, Release: func() {}}, nil + }) + t.Cleanup(func() { backend.Close() }) + agent, err := mustCreate(t, backend, appwire.CreateAsk{Workspace: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + return backend, rec, agent +} + +// appSubmissionsOf is what the fake's Submit door was handed, copied. +func appSubmissionsOf(conv *fakeConversation) ([]session.Submission, []string) { + conv.mu.Lock() + defer conv.mu.Unlock() + return append([]session.Submission(nil), conv.submissions...), append([]string(nil), conv.texts...) +} + +// appMarkedSubmission is the submission a message with said and reads is. +func appMarkedSubmission(t *testing.T, text, said string, reads ...appwire.MessageRead) session.Submission { + t.Helper() + kept, err := appKeptReads(reads) + if err != nil { + t.Fatal(err) + } + return session.Submission{Text: text, Said: &said, Reads: kept} +} + +// A QUEUED MESSAGE KEEPS ITS WHOLE MARK AND SHOWS ONLY THE PERSON'S WORDS. The +// queue shows the person's own words and a copy of the reads, never the +// expanded words; a refused replacement — a read that lies, an attachment — +// leaves the queue exactly as it was; a marked edit replaces the whole mark; a +// plain edit drops it. When the turn ends the marked message goes by the +// marked door with the expanded words, the plain one by Submit, and the +// accepted user entry shows the person's words and reads. +func TestAppQueuedMessageKeepsItsMarkAndShowsThePersonsWords(t *testing.T) { + hold := make(chan struct{}) + conv := &fakeConversation{hold: hold} + backend, rec, agent := workBackend(t, conv) + if _, err := backend.Send(agent.ID, "first", ""); err != nil { + t.Fatal(err) + } + said := "then the specs" + body := "then the specs\n\nSpecs:\n- docs/a.md\n- https://example.com/b" + queued, err := backend.QueueMessage(agent.ID, appwire.AgentMessage{Text: body, Said: &said, ClientID: "q-1", Reads: []appwire.MessageRead{appSpecsRead()}}) + if err != nil { + t.Fatal(err) + } + if len(queued) != 1 || queued[0].Text != said || !reflect.DeepEqual(queued[0].Reads, []appwire.MessageRead{appSpecsRead()}) { + t.Fatalf("the queue shows %+v", queued) + } + first := queued[0].ID + queued[0].Reads[0].Items[0].Path = "changed by the app" + queued[0].Reads[0].Warnings[0] = "changed by the app" + if again := backend.queued(backend.agent(agent.ID)); !reflect.DeepEqual(again[0].Reads, []appwire.MessageRead{appSpecsRead()}) { + t.Fatalf("the queue's reads were the app's to change: %+v", again[0].Reads) + } + queued, err = backend.Queue(agent.ID, "and tag it", "q-2") + if err != nil { + t.Fatal(err) + } + second := queued[1].ID + before := backend.queued(backend.agent(agent.ID)) + + lying := appCutRead() + lying.Complete = true + for _, refused := range []appwire.AgentMessage{ + {Text: "x", Said: &said, Reads: []appwire.MessageRead{lying}}, + {Text: "x", Said: &said, Attachments: []string{"at1"}}, + {Text: "x", Reads: make([]appwire.MessageRead, session.MaxMessageReads+1)}, + } { + if _, err := backend.EditQueuedMessage(agent.ID, first, refused); wireCode(err) != appwire.CodeBadParams { + t.Fatalf("a refused replacement answered %v", err) + } + if _, err := backend.QueueMessage(agent.ID, refused); wireCode(err) != appwire.CodeBadParams { + t.Fatalf("a refused queued message answered %v", err) + } + } + if after := backend.queued(backend.agent(agent.ID)); !reflect.DeepEqual(after, before) { + t.Fatalf("a refusal changed the queue from %+v to %+v", before, after) + } + + none := "" + links := "Links:\n- https://example.com/c" + if queued, err = backend.EditQueuedMessage(agent.ID, second, appwire.AgentMessage{Text: links, Said: &none, Reads: []appwire.MessageRead{appCutRead()}}); err != nil { + t.Fatal(err) + } + if queued[1].Text != "" || !reflect.DeepEqual(queued[1].Reads, []appwire.MessageRead{appCutRead()}) { + t.Fatalf("the marked edit shows %+v", queued[1]) + } + if queued, err = backend.EditQueued(agent.ID, first, "then the README"); err != nil { + t.Fatal(err) + } + if queued[0].Text != "then the README" || queued[0].Reads != nil { + t.Fatalf("a plain edit kept the old mark: %+v", queued[0]) + } + + conv.mu.Lock() + conv.hold = nil + conv.mu.Unlock() + close(hold) + marked := appLiveUser(t, rec, "q-2") + if marked.Text != "" || !reflect.DeepEqual(marked.Reads, []appwire.MessageRead{appCutRead()}) { + t.Fatalf("the queued message went live as %+v", marked) + } + submissions, texts := appSubmissionsOf(conv) + if want := []string{"first", "then the README", links}; !reflect.DeepEqual(texts, want) { + t.Fatalf("the engine was sent %q", texts) + } + if want := appMarkedSubmission(t, links, "", appCutRead()); len(submissions) != 1 || !reflect.DeepEqual(submissions[0], want) { + t.Fatalf("the marked door was handed %+v", submissions) + } + if _, err := backend.EditQueuedMessage(agent.ID, second, appwire.AgentMessage{Text: "too late", Said: &none, Reads: []appwire.MessageRead{appCutRead()}}); wireCode(err) != appwire.CodeSent { + t.Fatalf("an edit after the send answered %v", err) + } + if left := backend.queued(backend.agent(agent.ID)); len(left) != 0 { + t.Fatalf("an edit after the send queued %+v", left) + } +} + +// AN EDIT RACING THE TURN'S END IS ONE OF TWO THINGS AND NEVER BOTH: it lands +// before the queue sends, and the replacement is what is sent; or the message +// was already sent, the edit answers sent, and the original is what went. No +// race sends two messages, a mix of the two, or leaves one behind. +func TestAppQueuedEditRacingTheSendIsEitherSentOrRefused(t *testing.T) { + landed, refused := 0, 0 + for i := range 24 { + hold := make(chan struct{}) + conv := &fakeConversation{hold: hold} + backend, rec, agent := workBackend(t, conv) + if _, err := backend.Send(agent.ID, "first", ""); err != nil { + t.Fatal(err) + } + oldSaid, newSaid := "old", "new" + queued, err := backend.QueueMessage(agent.ID, appwire.AgentMessage{Text: "old body", Said: &oldSaid, ClientID: "q", Reads: []appwire.MessageRead{appSpecsRead()}}) + if err != nil { + t.Fatal(err) + } + conv.mu.Lock() + conv.hold = nil + conv.mu.Unlock() + var wg sync.WaitGroup + wg.Add(1) + go func() { + defer wg.Done() + close(hold) + }() + _, editErr := backend.EditQueuedMessage(agent.ID, queued[0].ID, appwire.AgentMessage{Text: "new body", Said: &newSaid, Reads: []appwire.MessageRead{appCutRead()}}) + wg.Wait() + live := appLiveUser(t, rec, "q") + submissions, texts := appSubmissionsOf(conv) + switch { + case editErr == nil: + landed++ + if want := appMarkedSubmission(t, "new body", "new", appCutRead()); len(submissions) != 1 || !reflect.DeepEqual(submissions[0], want) || live.Text != "new" { + t.Fatalf("run %d: the edit landed and %+v was sent, %+v went live", i, submissions, live) + } + case wireCode(editErr) == appwire.CodeSent: + refused++ + if want := appMarkedSubmission(t, "old body", "old", appSpecsRead()); len(submissions) != 1 || !reflect.DeepEqual(submissions[0], want) || live.Text != "old" { + t.Fatalf("run %d: the edit was refused and %+v was sent, %+v went live", i, submissions, live) + } + default: + t.Fatalf("run %d: the racing edit answered %v", i, editErr) + } + if len(texts) != 2 || len(backend.queued(backend.agent(agent.ID))) != 0 { + t.Fatalf("run %d: the engine was sent %q and the queue holds %+v", i, texts, backend.queued(backend.agent(agent.ID))) + } + } + t.Logf("%d edits landed before the send, %d were refused as sent", landed, refused) +} + +// A MARKED CORRECTION GOES BY ITS OWN DOOR AND SHOWS THE PERSON'S WORDS: the +// steer entry, pending and consumed, carries said and reads; one that falls +// through starts a turn whose user entry carries the same; the engine is +// handed the expanded words. A conversation with no door for a marked +// correction refuses it before the turn hears anything. +func TestAppMarkedSteerIsConsumedOrFallsThroughWithItsMark(t *testing.T) { + hold := make(chan struct{}) + conv := &steeringConversation{fakeConversation: &fakeConversation{script: []session.Event{{Kind: session.EventToolBegin, CallID: "c1", Tool: "bash", Args: `{"command":"go test ./..."}`}}, hold: hold}} + backend, rec, agent := markBackend(t, conv) + said := "use the specs" + body := "use the specs\n\nSpecs:\n- docs/a.md" + if err := backend.SteerMessage(agent.ID, appwire.AgentMessage{Text: body, Said: &said, Reads: []appwire.MessageRead{appSpecsRead()}}); wireCode(err) != appwire.CodeBusy { + t.Fatalf("a correction with nothing running answered %v", err) + } + if _, err := backend.Send(agent.ID, "run the suite", ""); err != nil { + t.Fatal(err) + } + rec.waitFor(t, 5*time.Second, func(event string, data any) bool { _, ok := data.(appwire.TurnTool); return ok }) + if err := backend.SteerMessage(agent.ID, appwire.AgentMessage{Text: body, Said: &said, Attachments: []string{"at1"}}); wireCode(err) != appwire.CodeBadParams { + t.Fatalf("a correction with attachments answered %v", err) + } + + first := make(chan session.Event, 4) + conv.mu.Lock() + conv.steer = first + conv.mu.Unlock() + if err := backend.SteerMessage(agent.ID, appwire.AgentMessage{Text: body, Said: &said, ClientID: "s-1", Reads: []appwire.MessageRead{appSpecsRead()}}); err != nil { + t.Fatal(err) + } + pending := entryWith(rec, t, func(e appwire.Entry) bool { return e.Kind == appwire.EntrySteer && e.ClientID == "s-1" }) + if pending.State != appwire.SteerPending || pending.Text != said || !reflect.DeepEqual(pending.Reads, []appwire.MessageRead{appSpecsRead()}) { + t.Fatalf("the pending correction = %+v", pending) + } + first <- session.Event{Kind: session.EventSteerConsumed} + close(first) + consumed := entryWith(rec, t, func(e appwire.Entry) bool { return e.ID == pending.ID && e.State == appwire.SteerConsumed }) + if consumed.Text != said || !reflect.DeepEqual(consumed.Reads, []appwire.MessageRead{appSpecsRead()}) { + t.Fatalf("the consumed correction = %+v", consumed) + } + + late := make(chan session.Event, 8) + conv.mu.Lock() + conv.steer = late + conv.mu.Unlock() + none := "" + links := "Links:\n- https://example.com/c" + if err := backend.SteerMessage(agent.ID, appwire.AgentMessage{Text: links, Said: &none, ClientID: "s-2", Reads: []appwire.MessageRead{appCutRead()}}); err != nil { + t.Fatal(err) + } + late <- session.Event{Kind: session.EventSteerFellThrough} + late <- session.Event{Kind: session.EventTextDelta, Text: "Read them."} + late <- session.Event{Kind: session.EventTurnDone} + close(late) + close(hold) + user := entryWith(rec, t, func(e appwire.Entry) bool { return e.Kind == appwire.EntryUser && e.ClientID == "s-2" }) + if user.Text != "" || user.Turn == 0 || !reflect.DeepEqual(user.Reads, []appwire.MessageRead{appCutRead()}) { + t.Fatalf("the fallen-through correction = %+v", user) + } + conv.mu.Lock() + steers, steered := append([]session.Submission(nil), conv.steers...), append([]string(nil), conv.steered...) + conv.mu.Unlock() + want := []session.Submission{appMarkedSubmission(t, body, said, appSpecsRead()), appMarkedSubmission(t, links, "", appCutRead())} + if !reflect.DeepEqual(steers, want) || !reflect.DeepEqual(steered, []string{body, links}) { + t.Fatalf("the engine was handed %+v and heard %q", steers, steered) + } + + doorless := &fakeConversation{hold: make(chan struct{})} + plainBackend, _, plainAgent := workBackend(t, doorless) + if _, err := plainBackend.Send(plainAgent.ID, "run", ""); err != nil { + t.Fatal(err) + } + doorless.mu.Lock() + doorless.steer = make(chan session.Event) + doorless.mu.Unlock() + if err := plainBackend.SteerMessage(plainAgent.ID, appwire.AgentMessage{Text: body, Said: &said}); wireCode(err) != appwire.CodeUnknownMethod { + t.Fatalf("a conversation without the door answered %v", err) + } + doorless.mu.Lock() + heard := len(doorless.steered) + doorless.mu.Unlock() + if heard != 0 { + t.Fatal("a refused correction reached the turn") + } + close(doorless.hold) +} + +// A HOST THAT SAID IT KEEPS NO MARK IS NEVER ASKED TO: a marked correction, a +// marked queued message, a marked edit and a marked send are refused as +// unknown before the engine, the queue or the turn changes, and plain words +// still go every one of those roads. +func TestAppOlderHostIsRefusedEveryMarkedMessageBeforeAnything(t *testing.T) { + hold := make(chan struct{}) + inner := &fakeConversation{hold: hold} + conv := olderHostConversation{&steeringConversation{fakeConversation: inner}} + backend, rec, agent := markBackend(t, conv) + if _, err := backend.Send(agent.ID, "first", ""); err != nil { + t.Fatal(err) + } + inner.mu.Lock() + inner.steer = make(chan session.Event, 1) + inner.mu.Unlock() + said := "mine" + marked := appwire.AgentMessage{Text: "mine\n\n- docs/a.md", Said: &said, ClientID: "m", Reads: []appwire.MessageRead{appSpecsRead()}} + if err := backend.SteerMessage(agent.ID, marked); wireCode(err) != appwire.CodeUnknownMethod { + t.Fatalf("a marked correction answered %v", err) + } + if _, err := backend.QueueMessage(agent.ID, marked); wireCode(err) != appwire.CodeUnknownMethod { + t.Fatalf("a marked queued message answered %v", err) + } + queued, err := backend.Queue(agent.ID, "plain words", "p") + if err != nil { + t.Fatal(err) + } + if _, err := backend.EditQueuedMessage(agent.ID, queued[0].ID, marked); wireCode(err) != appwire.CodeUnknownMethod { + t.Fatalf("a marked edit answered %v", err) + } + if after := backend.queued(backend.agent(agent.ID)); len(after) != 1 || after[0].Text != "plain words" || after[0].Reads != nil { + t.Fatalf("a refused edit changed the queue to %+v", after) + } + if err := backend.Steer(agent.ID, "plain correction", ""); err != nil { + t.Fatal(err) + } + inner.mu.Lock() + steers, steered := len(conv.steers), append([]string(nil), inner.steered...) + inner.hold = nil + inner.mu.Unlock() + if steers != 0 || !reflect.DeepEqual(steered, []string{"plain correction"}) { + t.Fatalf("the older host was handed %d marked corrections and heard %q", steers, steered) + } + close(hold) + inner.mu.Lock() + close(inner.steer) + inner.mu.Unlock() + appLiveUser(t, rec, "p") + deadline := time.Now().Add(30 * time.Second) + for { + _, err := backend.SendMessage(agent.ID, marked) + if wireCode(err) == appwire.CodeUnknownMethod { + break + } + if wireCode(err) != appwire.CodeBusy || time.Now().After(deadline) { + t.Fatalf("a marked send answered %v", err) + } + time.Sleep(2 * time.Millisecond) + } + if submissions, texts := appSubmissionsOf(inner); len(submissions) != 0 || !reflect.DeepEqual(texts, []string{"first", "plain words"}) { + t.Fatalf("the older host was handed %+v and sent %q", submissions, texts) + } +} diff --git a/docs/changes/unreleased/0-message-reference-reads.md b/docs/changes/unreleased/0-message-reference-reads.md index 47459052a0..b7fcb112f7 100644 --- a/docs/changes/unreleased/0-message-reference-reads.md +++ b/docs/changes/unreleased/0-message-reference-reads.md @@ -6,8 +6,9 @@ surface: [engine, remote] invalidates: - "A message the app wrote by expanding a collection kept only the expanded words: what the read found at send time (how many references, how many gone, unaskable, unread or cut) was lost, and asking the collection again later answers a different question. session.Submission now carries Reads []MessageRead (Source and Items as MessageReference{Kind, ID, Name, Owner, Path, URL}; Count, Missing, Unavailable, Unread, More, Complete as the door observed them), recorded on the message's own line in the existing presentation mark and read back as DisplayEntry.Person.Reads. DisplayEntry is still 288 bytes." - "session.Agent.Steer and FollowUp took words only, and a steer that fell through became a plain message. SteerMessage and FollowUpMessage take a Submission, and a fallen-through steer carries its mark into the message it becomes. The unexported errSubmissionMark is now session.ErrMessageMark." - - "agents.send and crews.message took text, client_id and staged attachment ids only, and the entry showed the words the app typed. They now also take said (the person's own words, never replaced by the expanded text) and reads; an engine that is not an appwire.MessageSender answers such a send unknown_method, and hello names messages.reads when it is. Entry.reads carries the reads on the live user entry and on every history page. agents.steer, agents.queue and queue.edit still take words only." - - "remote SubmitArgs carried said and attachments. It now also carries reads; an engine without SubmitMessage still runs the words alone and keeps no mark." + - "agents.send and crews.message took text, client_id and staged attachment ids only, and the entry showed the words the app typed. They now also take said (the person's own words, never replaced by the expanded text) and reads; an engine that is not an appwire.MessageSender answers such a send unknown_method, and hello names messages.reads when it is. Entry.reads carries the reads on the live user entry and on every history page." + - "agents.steer, agents.queue and queue.edit took words only, and silently dropped an attachments field. They now also take said and reads, through the optional appwire.MessageSteerer and appwire.MessageQueuer (hello agents.steer.reads and agents.queue.reads, each said only for its own door), and refuse attachments as bad_params. Queued gains reads, and Queued.text is the person's words, never the expanded text the queue will send. A plain queue.edit drops the old said and reads; a marked one is checked whole before the queue changes; an edit after the send answers sent. A conversation whose hosted engine cannot keep the mark answers unknown_method before anything is steered, queued or sent." + - "remote SubmitArgs carried said and attachments. It now also carries reads, on Steer and FollowUp as well as Submit. Welcome.SteerMarks and Welcome.FollowUpMarks say a hosted agent keeps a correction's and a follow-up's mark, separately from MessageMarks; the client refuses a marked SteerMessage or FollowUpMessage with no call made when the welcome did not say so, and the server refuses one before the turn or the queue when the agent has no door." --- A read is a fact the door observed, never one the engine works out: nothing is From b6d098ce5b9c6a977625fc5e77742edebc13c547 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 21:41:33 -0400 Subject: [PATCH 093/459] session: stamp a crew refusal with the connection it was sent on, and let a pinned role go when that credential is answered The client door now tells a crew seat the print of the exact connection its pooled adapter carries before the request leaves, so a refusal is logged with LogCrewRouteSent and blamed on the key the call carried, not on the settings at response time. Codex stays unprinted. On the next seat call TaskCrew asks CrewRefusalApplies and the connection the pool would send, and drops only the auth/payment refusals those facts answer; gone/broke stand while another unanswered refusal on the provider remains, and spend stops and other failures are untouched. Nothing is sent because a refusal was lifted. The checkpoint carries FailedSent so a carried-on task keeps those facts. --- .../unreleased/0-crew-refusal-runtime.md | 11 + internal/manual/chat/models-and-cost.md | 3 + internal/session/clientdoor.go | 152 +++++- internal/session/taskcrew.go | 300 +++++++++++- internal/session/taskcrew_credential_test.go | 454 ++++++++++++++++++ internal/session/taskcrew_record.go | 50 +- 6 files changed, 945 insertions(+), 25 deletions(-) create mode 100644 docs/changes/unreleased/0-crew-refusal-runtime.md create mode 100644 internal/session/taskcrew_credential_test.go diff --git a/docs/changes/unreleased/0-crew-refusal-runtime.md b/docs/changes/unreleased/0-crew-refusal-runtime.md new file mode 100644 index 0000000000..1e48162d11 --- /dev/null +++ b/docs/changes/unreleased/0-crew-refusal-runtime.md @@ -0,0 +1,11 @@ +--- +kind: fixed +title: A running task's pinned role recovers on a new key or credit; a refusal names the key it carried +pr: 0 +surface: [chat, engine] +invalidates: + - "A task's own record of a refused key or empty account (taskCrew failed/bad/gone/broke and a decision.Stopped set from them) held the pinned role for the rest of the task, even after route health lifted the refusal. Now the next seat call checks config.CrewRefusalApplies and the connection the conversation's pool would actually send, and lets go only the auth/payment refusals those facts answer; rate limits, gone routes, caps, spend stops and unanswered refusals on the same provider stand, and nothing is sent because a refusal was lifted." + - "A crew seat's refusal was written to the router log with no credential print. The client door now tells the seat the print of the exact connection its adapter carries (the pool's account, not the settings) before the request leaves, and the refusal is logged with config.LogCrewRouteSent. A key rotated while the call was in flight is not blamed." + - "A Codex sign-in is never printed at dispatch: its transport loads and refreshes tokens per request, so its refusals stay unprinted and are lifted only by a recorded sign-in after them." + - "TaskCrewRecord carries FailedSent (failure time, provider, credential print per failed send) so a carried-on task keeps those facts; older checkpoints have none and their auth/payment refusals stay held." +--- diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index c0fbcd3e90..6c8a67d73c 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -1351,6 +1351,9 @@ fix — `reconnect openrouter with /connect, or unpin the worker from glm-5.3-fl - **Credit read after the refusal.** For a payment refusal, a balance read that finds credit, begun after the refusal, lifts it. A read begun before it does not, even if it answered after, and a fresh reading of none keeps it. +- **A task already running, or carried on,** recovers the same way: its next call on the + pinned model is sent on the new key or the topped-up account, on the same model. Nothing + is sent just because the key changed, and other failures on the task still stand. A refusal written by an older build named no key. It is lifted when the key is seen to change after it. The first key this build sees is where it starts counting, so a key you diff --git a/internal/session/clientdoor.go b/internal/session/clientdoor.go index 52ad4a121d..9593707419 100644 --- a/internal/session/clientdoor.go +++ b/internal/session/clientdoor.go @@ -119,23 +119,46 @@ func (p *modelClientPool) setSeat(model string) { // connection, so holding the lock prevents two simultaneous children from // minting two adapters and, more importantly, two competing lane-prober seams. func (p *modelClientPool) clientFor(model string) (Completer, string, string, modelAccount, error) { + client, wire, called, want, _, err := p.clientWithService(model) + return client, wire, called, want, err +} + +// clientWithService is [modelClientPool.clientFor] with the connection the +// account was read from. The account is the pool's key, bearer included, so +// the adapter answered here — cached or new — carries exactly that +// connection's credential: it is the one fact about a request's credential +// that does not have to be guessed from the settings afterwards. +func (p *modelClientPool) clientWithService(model string) (Completer, string, string, modelAccount, modelsource.Connected, error) { if p == nil { - return nil, "", "", modelAccount{}, errNoCompleter + return nil, "", "", modelAccount{}, modelsource.Connected{}, errNoCompleter } p.mu.Lock() defer p.mu.Unlock() model = p.seatedModel(model) configured := p.config.clientConfig(model, providerTimeout) - want := accountFor(p.config, model) + service := p.config.serviceFor(model) + want := accountForService(service) if client := p.clients[want]; client != nil { - return client, configured.Model, model, want, nil + return client, configured.Model, model, want, service, nil } client, err := newProviderClient(p.config, model) if err != nil { - return nil, "", model, modelAccount{}, err + return nil, "", model, modelAccount{}, modelsource.Connected{}, err } p.clients[want] = client - return client, configured.Model, model, want, nil + return client, configured.Model, model, want, service, nil +} + +// serviceNext is the connection the pool's next call on model would be made +// through, read from the same snapshot and seat [modelClientPool.clientFor] +// reads, and constructing nothing. +func (p *modelClientPool) serviceNext(model string) modelsource.Connected { + if p == nil { + return modelsource.Connected{} + } + p.mu.Lock() + defer p.mu.Unlock() + return p.config.serviceFor(p.seatedModel(model)) } // setSources moves the pool to a freshly resolved profile and forgets every @@ -459,30 +482,126 @@ func (a *Agent) rebindClientLocked(model string) { // override changes only the slug in the request; choosing the client here first // is what changes the address and bearer with it. func (a *Agent) completerFor(model string) (Completer, string, string, error) { + client, wire, called, _, err := a.completerWithService(model) + return client, wire, called, err +} + +// completerWithService is [Agent.completerFor] with the connection the chosen +// adapter's account was resolved from, in the same locked step that chose it. +// An unmanaged session (the scripted-completer seam) has no account, and +// answers the zero connection: nothing is known about its credential. +func (a *Agent) completerWithService(model string) (Completer, string, string, modelsource.Connected, error) { if a == nil { - return nil, "", "", errNoCompleter + return nil, "", "", modelsource.Connected{}, errNoCompleter } a.mu.Lock() defer a.mu.Unlock() if a.client == nil { - return nil, "", "", errNoCompleter + return nil, "", "", modelsource.Connected{}, errNoCompleter } if !a.managedClient { configured := a.config.clientConfig(model, providerTimeout) - return a.client, configured.Model, model, nil + return a.client, configured.Model, model, modelsource.Connected{}, nil } - inner, wire, called, want, err := a.clientPool.clientFor(model) + inner, wire, called, want, service, err := a.clientPool.clientWithService(model) if err != nil { - return nil, "", called, err + return nil, "", called, modelsource.Connected{}, err } if want == a.clientAccount { - return a.client, wire, called, nil + return a.client, wire, called, service, nil } if wrapper, ok := a.client.(sessionCompleter); ok { wrapper.inner = inner - return wrapper, wire, called, nil + return wrapper, wire, called, service, nil + } + return inner, wire, called, service, nil +} + +// ── THE CREDENTIAL A REQUEST WAS SENT WITH ────────────────────────────────── +// +// A refusal of a key belongs to the key that was refused, and the only place +// that knows which key a request carries is this door, at the moment it hands +// the request to the adapter: the pool's account is its key, bearer included +// ([modelClientPool.clientWithService]). Reading the settings at that moment +// instead could name a key the pool has not built a client for yet, and +// reading them when the refusal comes back names whatever was connected while +// the request was in flight — the defect that blamed a new key for an old +// key's 401 (config.CrewDispatch says the rest). + +// sentConnection is one request's dispatch fact: the provider its account is +// on and the one-way print of the credential its adapter carries. An empty +// print is UNPRINTED — this door cannot say which credential will travel — +// and a caller treats it as naming none, never as naming "no key". +type sentConnection struct { + provider string + sent account.CrewDispatch +} + +// sentConnectionOf is the dispatch fact of a request made through service. +// +// A SIGN-IN IS NEVER PRINTED HERE. Codex's transport loads and refreshes its +// tokens per request ([codexauth.Client]), so the tokens on disk at this +// moment are not provably the ones the request will carry; the refusal stays +// unprinted, and only a recorded sign-in after it can lift it. A request that +// names a bearer of its own ([ai.Request.APIKeyOverride]) is not carrying the +// account's credential either. +func sentConnectionOf(profileDir string, service modelsource.Connected, options []ai.Option) sentConnection { + id := strings.ToLower(strings.TrimSpace(service.Source.ID)) + if id == "" { + return sentConnection{} + } + out := sentConnection{provider: id} + if id == "codex" { + return out + } + var request ai.Request + for _, option := range options { + if option(&request) != nil { + return out + } + } + if strings.TrimSpace(request.APIKeyOverride) != "" { + return out + } + out.sent = account.CrewDispatchOf(profileDir, service) + return out +} + +// nextSentConnection is the dispatch fact the next request on model would be +// made with: the pool's own account for it, which is the adapter the next call +// would be handed — not the profile's settings, which can have moved ahead of +// a pool that was never told. An unmanaged session knows nothing. +func (a *Agent) nextSentConnection(model string) sentConnection { + if a == nil { + return sentConnection{} + } + a.mu.Lock() + managed, pool, profileDir := a.managedClient, a.clientPool, a.config.ProfileDir + a.mu.Unlock() + if !managed || pool == nil { + return sentConnection{} } - return inner, wire, called, nil + return sentConnectionOf(profileDir, pool.serviceNext(model), nil) +} + +// dispatchObserverKey carries the one callback a crew seat's call is told its +// dispatch fact through ([withDispatchObserver]). +type dispatchObserverKey struct{} + +// withDispatchObserver asks the door to tell observe the dispatch fact of the +// request made under ctx, on the calling goroutine, after the adapter is +// chosen and before the request is handed to it. It is a seat's own narrow +// seam: nothing but the fact crosses it, never the adapter or its key. +func withDispatchObserver(ctx context.Context, observe func(sentConnection)) context.Context { + if observe == nil { + return ctx + } + return context.WithValue(ctx, dispatchObserverKey{}, observe) +} + +func dispatchObserverOf(ctx context.Context) func(sentConnection) { + observe, _ := ctx.Value(dispatchObserverKey{}).(func(sentConnection)) + return observe } // ── EVERY REQUEST SAYS WHAT IT IS FOR ─────────────────────────────────────── @@ -626,10 +745,15 @@ func (a *Agent) completeWithNamedModel(ctx context.Context, purpose callPurpose, ctx = provider.WithoutPatientRateLimits(ctx) } } - client, wire, called, err := a.completerFor(model) + client, wire, called, service, err := a.completerWithService(model) if err != nil { return nil, called, err } + // THE DISPATCH FACT IS TAKEN FROM THE ACCOUNT THAT CHOSE THIS ADAPTER, in + // the step that chose it — never from the settings, and never after. + if observe := dispatchObserverOf(ctx); observe != nil { + observe(sentConnectionOf(a.config.ProfileDir, service, options)) + } // THE PURPOSE BECOMES THE TAG, on the one line every request in this package // passes over — through [withPurpose], which is the only function in // internal/session that spells it, here and for the three roads that carry a diff --git a/internal/session/taskcrew.go b/internal/session/taskcrew.go index a00d95e968..b221dafdb8 100644 --- a/internal/session/taskcrew.go +++ b/internal/session/taskcrew.go @@ -446,6 +446,10 @@ func (c crewSeatCompleter) completeSeat(ctx context.Context, key string, message seat := crewSeatOf(ctx) for { current := crew.sendForSeat(seat, key) + // A REFUSAL ITS CREDENTIAL HAS SINCE ANSWERED is let go here, at the + // call that would ask it again, and nowhere else: this is the explicit + // ask, and nothing is sent because a refusal was lifted. + c.agent.liftAnsweredRefusals(c.run, current) if failed, ok := crew.failedHere(current); ok { // A SEND THAT FAILED ON THIS TASK IS NOT ASKED AGAIN, by this seat // or any other: a pool that answered 429 with an hour's @@ -470,7 +474,18 @@ func (c crewSeatCompleter) completeSeat(ctx context.Context, key string, message callCtx := provider.WithDiscardedUsage(ctx, func(_ string, _ string, response *ai.Response) { crew.guard.after(ctx, current, response, 0) }) + // THIS CALL'S OWN DISPATCH FACT, told by the door as it hands this + // request to its adapter ([withDispatchObserver]). A retry is a new + // call and is told its own. + var sent sentConnection + told := false + callCtx = withDispatchObserver(callCtx, func(fact sentConnection) { + if !told { + sent, told = fact, true + } + }) response, err := c.agent.completeWithModel(provider.WithoutPatientRateLimits(asCrewSeatCall(callCtx)), purposeInherited, messages, current, options...) + failedAt := time.Now() crew.guard.after(ctx, current, response, held) if err == nil { c.agent.crewAnswered(c.run, current, seat) @@ -488,7 +503,7 @@ func (c crewSeatCompleter) completeSeat(ctx context.Context, key string, message continue } retried = false - crew.noteFailed(current, crewFailure{kind: kind, until: until, err: err}) + crew.noteFailed(current, crew.failureSent(current, crewFailure{kind: kind, until: until, err: err, at: failedAt}, sent)) if action, moved := c.agent.moveCrewSeat(c.run, key, current, kind, until); !moved { if action == "" { return response, err @@ -518,6 +533,283 @@ type crewFailure struct { kind provider.RouteFailure until time.Time err error + // at is when the failure came back, and provider the provider the send + // was routed to. sent is the print of the credential the failed call was + // SENT with, as the client door told it before the request left + // ([withDispatchObserver]); empty when the door could not say. A failure + // restored from an older checkpoint has none of the three, and an auth or + // payment refusal without them is never lifted by this task. + at time.Time + provider string + sent config.CrewDispatch +} + +// failureSent is failure with what is known about the call that failed: the +// provider send is routed to and, when the door's fact is about that same +// provider, the credential print it was sent with. A fact about another +// provider's account — a seat the pool moved onto another service — names +// nothing about this provider's credential, and is left unprinted. +func (c *taskCrew) failureSent(send string, failure crewFailure, fact sentConnection) crewFailure { + c.mu.Lock() + failure.provider = c.providerOfLocked(send) + c.mu.Unlock() + if failure.provider == "" { + failure.provider = fact.provider + } + if fact.provider != "" && fact.provider == failure.provider { + failure.sent = fact.sent + } + return failure +} + +// providerOfLocked is the provider send is routed to on this crew: a seat's +// pick, a rung of its ladder or rescue, or the provider a change recorded it +// moving from. Lowercased, as the router names providers; "" when this crew +// never named one for it. The caller holds c.mu. +func (c *taskCrew) providerOfLocked(send string) string { + named := func(pick crewroute.Pick) bool { return pick.Send == send && pick.Provider != "" } + for _, pick := range c.decision.Crew { + if named(pick) { + return strings.ToLower(pick.Provider) + } + } + for _, rungs := range []map[crewroute.Seat][]crewroute.Pick{c.ladders, c.rescue} { + for _, seat := range crewroute.Seats { + for _, rung := range rungs[seat] { + if named(rung) { + return strings.ToLower(rung.Provider) + } + } + } + } + for i := len(c.changes) - 1; i >= 0; i-- { + switch change := c.changes[i]; { + case change.FromSend == send && change.FromProvider != "": + return strings.ToLower(change.FromProvider) + case change.ToSend == send && change.ToProvider != "": + return strings.ToLower(change.ToProvider) + } + } + return "" +} + +// crewCredentialRefusal is a failure that belongs to the credential it was +// made with: a refused key, or an account out of credit. +func crewCredentialRefusal(kind provider.RouteFailure) bool { + return kind == provider.RouteAuth || kind == provider.RoutePayment +} + +// ── A REFUSAL ITS CREDENTIAL HAS ANSWERED ────────────────────────────────── +// +// A send refused for its key or its credit was held for the rest of the task: +// failed, bad, and its provider gone or broke — and a pinned role, or the one +// model, never moves, so reconnecting or adding credit could not reach a task +// already running or carried on. Route health had learned to lift such a +// refusal on a fact (internal/config's crewcred.go); this task's own record of +// it did not, and held the pin regardless. +// +// NOW THE SAME FACTS LIFT IT HERE, judged at the call that would ask the send +// again and never on a timer or in the background: +// +// - route health no longer holds the refusal against the profile +// ([config.CrewRefusalApplies] — a different known credential than the +// one it was sent with, a credential change after an unprinted one, or +// credit read after a payment refusal), AND +// - the very connection the next call would be handed — the pool's own, +// which can lag the profile ([Agent.nextSentConnection]) — carries the +// credential in force, with a credential that is there; and for a +// refused key, it is not the key that was refused. +// +// ONLY THE REFUSALS THOSE FACTS ANSWER ARE LET GO: a rate limit, a route gone, +// a cap, another provider's refusal, and a refusal on the same provider the +// facts do not answer (an older checkpoint's, with no time and no print) all +// stand, and while one stands its provider stays gone or broke. The pick is +// not changed, nothing is moved onto Auto or a rescue, the history, the call +// counts and the spend are kept exactly as they were, and no success is +// written: the next call either answers on the repaired credential, or fails +// and is held again on its own fact. + +// liftAnsweredRefusals lets go of the auth and payment refusals on send's +// provider that its credential has since answered, when send's own is one of +// them. The facts are read with crew.mu released — they are files — and the +// failures are let go only if they are still the ones read. +func (a *Agent) liftAnsweredRefusals(run *beltRun, send string) { + crew := run.crew + on, asked := crew.credentialRefusalsOn(send) + if len(asked) == 0 { + return + } + answered, why := a.answeredRefusals(on, send, asked) + if why == "" { + return + } + crew.mu.Lock() + before := crew.decision.Stopped + crew.liftLocked(on, answered) + decision := crew.decision + crew.mu.Unlock() + if decision.Stopped != before { + a.republishCrew(run, decision, send+" may be asked again · "+why) + } +} + +// credentialRefusalsOn is, when send's own failure is an auth or payment +// refusal on a provider it names, that provider and every such refusal on it +// this task holds — send's included — copied out from under c.mu. A refusal +// that names no provider, an older checkpoint's, is answered by nothing here. +func (c *taskCrew) credentialRefusalsOn(send string) (string, map[string]crewFailure) { + c.mu.Lock() + defer c.mu.Unlock() + failure, ok := c.failed[send] + if !ok || c.started[send] || !crewCredentialRefusal(failure.kind) || failure.provider == "" { + return "", nil + } + asked := map[string]crewFailure{} + for other, f := range c.failed { + if f.provider == failure.provider && !c.started[other] && crewCredentialRefusal(f.kind) { + asked[other] = f + } + } + return failure.provider, asked +} + +// answeredRefusals judges each refusal in asked on its own facts, and answers +// the ones that no longer hold with the fact that answered send's — "" when +// send's own still holds. +func (a *Agent) answeredRefusals(on, send string, asked map[string]crewFailure) (map[string]crewFailure, string) { + var inForce config.CrewDispatch + if on != "codex" { + inForce = config.CrewDispatchFor(a.config.ProfileDir, on) + } + answered := map[string]crewFailure{} + why := "" + for other, f := range asked { + because := a.refusalAnswered(other, on, f, inForce) + if because == "" { + continue + } + answered[other] = f + if other == send { + why = because + } + } + return answered, why +} + +// refusalAnswered is the fact that answers one auth or payment refusal of send +// on provider on, for the call that would be made next — "" while it holds. +func (a *Agent) refusalAnswered(send, on string, failure crewFailure, inForce config.CrewDispatch) string { + if config.CrewRefusalApplies(a.config.ProfileDir, router.CrewRouteOutcome{ + At: failure.at, Send: send, Provider: on, Kind: string(failure.kind), Cred: failure.sent.Cred, + }) { + return "" + } + next := a.nextSentConnection(send) + if next.provider != on { + return "" + } + if next.sent.Cred == "" { + // AN UNPRINTED NEXT CONNECTION is a sign-in whose transport carries + // the tokens of the moment ([sentConnectionOf]), which the profile's + // own facts describe; on any other provider it is a connection with + // no credential at all, and a disconnected key repairs nothing. + if on != "codex" { + return "" + } + return on + " was signed in again after it refused" + } + if next.sent != inForce { + // The pool would send a credential the profile no longer holds: the + // facts that lifted the refusal are about another credential. + return "" + } + switch { + case failure.sent.Cred == "": + return "the credential on " + on + " changed after it refused" + case next.sent != failure.sent: + return "a different credential is connected on " + on + case failure.kind == provider.RoutePayment: + return "credit on " + on + " was read after it refused" + } + return "" +} + +// liftLocked lets go of the answered refusals that are still as they were +// read, and keeps on gone or broke while any other refusal of that kind on it +// stands. A stop the refused credential set is said again from what is left, +// and dropped when nothing is; any other stop — a spend line met — is +// untouched. The caller holds c.mu. +func (c *taskCrew) liftLocked(on string, answered map[string]crewFailure) { + credentialStop := c.stoppedOnCredentialLocked() + if !c.dropAnsweredLocked(answered) { + return + } + gone, broke := c.stillRefusedLocked(on) + if !gone { + forgetProvider(c.gone, on) + } + if !broke { + forgetProvider(c.broke, on) + } + if credentialStop { + c.decision.Stopped = "" + if len(c.broke) > 0 || len(c.gone) > 0 { + c.decision.Stopped = c.actionLocked("", "", time.Time{}) + } + } +} + +// stoppedOnCredentialLocked is whether the crew's stop is the one its refused +// keys and empty accounts say ([taskCrew.actionLocked]). The caller holds c.mu. +func (c *taskCrew) stoppedOnCredentialLocked() bool { + if c.decision.Stopped == "" || (len(c.broke) == 0 && len(c.gone) == 0) { + return false + } + return c.decision.Stopped == c.actionLocked("", "", time.Time{}) +} + +// dropAnsweredLocked forgets each answered refusal that is still the failure +// that was judged — a newer failure of the send is its own fact — and answers +// whether any went. The caller holds c.mu. +func (c *taskCrew) dropAnsweredLocked(answered map[string]crewFailure) bool { + dropped := false + for send, was := range answered { + now, ok := c.failed[send] + if !ok || now.kind != was.kind || !now.at.Equal(was.at) || now.sent != was.sent || now.provider != was.provider { + continue + } + delete(c.failed, send) + delete(c.bad, send) + dropped = true + } + return dropped +} + +// stillRefusedLocked is whether a key refusal, and an out-of-credit refusal, +// still stand on provider on — counting a refusal whose provider this crew +// cannot name, which may be on. The caller holds c.mu. +func (c *taskCrew) stillRefusedLocked(on string) (gone, broke bool) { + for send, f := range c.failed { + p := f.provider + if p == "" { + p = c.providerOfLocked(send) + } + if p != "" && p != on { + continue + } + gone = gone || f.kind == provider.RouteAuth + broke = broke || f.kind == provider.RoutePayment + } + return gone, broke +} + +// forgetProvider drops on from a set of providers, as they were written. +func forgetProvider(set map[string]bool, on string) { + for provided := range set { + if strings.EqualFold(provided, on) { + delete(set, provided) + } + } } // noteFailed remembers how send failed on this task. @@ -1015,7 +1307,11 @@ func (a *Agent) crewFailedLocked(crew *taskCrew, current string, kind provider.R if again { continue } - config.LogCrewRoute(a.config.ProfileDir, crew.call, crew.decision, crew.repo, crew.title, seat, pick, string(kind), until) + // THE REFUSAL IS WRITTEN AGAINST THE CREDENTIAL THE CALL WAS SENT + // WITH ([crewFailure.sent], noted before this), never the one in + // force now — a key connected while the call was in flight is + // not blamed for it. + config.LogCrewRouteSent(a.config.ProfileDir, crew.call, crew.decision, crew.repo, crew.title, seat, pick, string(kind), until, crew.failed[current].sent) } } if crew.bad == nil { diff --git a/internal/session/taskcrew_credential_test.go b/internal/session/taskcrew_credential_test.go new file mode 100644 index 0000000000..4bcc0d0646 --- /dev/null +++ b/internal/session/taskcrew_credential_test.go @@ -0,0 +1,454 @@ +package session + +import ( + "encoding/json" + "errors" + "io" + "net/http" + "net/http/httptest" + "os" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/catalog" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/credits" + "github.com/Agent-Field/codeaf/internal/crewroute" + "github.com/Agent-Field/codeaf/internal/modelsource" + "github.com/Agent-Field/codeaf/internal/provider" +) + +// The fixtures here are UNIT DATA: the keys are this file's own strings, the +// provider is a real HTTP stub on loopback that answers by bearer, and nothing +// reaches a network or a model. + +const ( + credKeyA = "sk-or-v1-crewcred-key-a-0123456789" + credKeyB = "sk-or-v1-crewcred-key-b-9876543210" + credPinned = "z-ai/glm-5.3-flash" +) + +// bearerAsk is one request the stub received: the bearer and the model. +type bearerAsk struct{ bearer, model string } + +// bearerStub answers each key with the status the test sets for it — 200, +// 401 a refused key, 402 an account out of credit — and runs during, when +// set, before it answers: the moment a request is in flight. +type bearerStub struct { + *httptest.Server + mu sync.Mutex + status map[string]int + asked []bearerAsk + during func(bearer string) +} + +func newBearerStub(t *testing.T, status map[string]int) *bearerStub { + t.Helper() + stub := &bearerStub{status: status} + stub.Server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !answersChatOnly(w, r) { + return + } + raw, _ := io.ReadAll(r.Body) + var body struct { + Model string `json:"model"` + } + _ = json.Unmarshal(raw, &body) + bearer := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + stub.mu.Lock() + stub.asked = append(stub.asked, bearerAsk{bearer, body.Model}) + during := stub.during + stub.mu.Unlock() + if during != nil { + during(bearer) + } + stub.mu.Lock() + status, ok := stub.status[bearer] + stub.mu.Unlock() + if !ok { + status = http.StatusUnauthorized + } + w.Header().Set("Content-Type", "application/json") + switch status { + case http.StatusOK: + _ = json.NewEncoder(w).Encode(map[string]any{"model": body.Model, "choices": []any{map[string]any{ + "index": 0, "finish_reason": "stop", "message": map[string]any{"role": "assistant", "content": "done"}}}}) + case http.StatusPaymentRequired: + w.WriteHeader(status) + _, _ = io.WriteString(w, `{"error":{"message":"Insufficient credits. Add more using https://openrouter.ai/settings/credits","code":402}}`) + default: + w.WriteHeader(http.StatusUnauthorized) + _, _ = io.WriteString(w, `{"error":{"message":"No auth credentials found","code":401}}`) + } + })) + t.Cleanup(stub.Close) + return stub +} + +func (s *bearerStub) requests() []bearerAsk { + s.mu.Lock() + defer s.mu.Unlock() + return append([]bearerAsk(nil), s.asked...) +} + +func (s *bearerStub) set(bearer string, status int) { + s.mu.Lock() + s.status[bearer] = status + s.mu.Unlock() +} + +// onStub is the default service on the stub, carrying key. +func onStub(stub *bearerStub, key string) modelsource.Set { + return modelsource.NewSet(modelsource.Connected{Source: modelsource.DefaultSource(stub.URL), Key: key, Address: stub.URL}) +} + +// credPrint is the dispatch print of key on the stub's default service. +func credPrint(stub *bearerStub, dir, key string) string { + return config.CrewDispatchOf(dir, modelsource.Connected{Source: modelsource.DefaultSource(stub.URL), Key: key}).Cred +} + +// bearerStubAgent is a managed conversation on the stub over a profile whose +// key is key A, with the crew routed over that profile's catalog. +func bearerStubAgent(t *testing.T, stub *bearerStub) (*Agent, string) { + t.Helper() + t.Setenv("OPENROUTER_API_KEY", "") + t.Setenv("OPENAI_API_KEY", "") + t.Setenv("CODEAF_BASE_URL", "") + dir := t.TempDir() + if err := config.WriteAPIKey(dir, credKeyA); err != nil { + t.Fatal(err) + } + previous := config.CrewCatalog + t.Cleanup(func() { config.CrewCatalog = previous }) + rows := []catalog.Model{ + {ID: credPinned, OpenWeights: true, PromptPrice: 1.5e-7, CompletionPrice: 5e-7, + IntelligenceIndex: 41.8, CodingIndex: 71.5, AgenticIndex: 50.9, ContextLength: 1310720, Parameters: []string{"tools"}}, + {ID: credPinned + ":free", OpenWeights: true, + IntelligenceIndex: 41.8, CodingIndex: 71.5, AgenticIndex: 50.9, ContextLength: 1310720, Parameters: []string{"tools"}}, + } + config.CrewCatalog = func() []catalog.Model { return rows } + agent, err := New(Config{ + Workspace: t.TempDir(), Model: "somelab/the-chat-model", ProfileDir: dir, Sources: onStub(stub, credKeyA), + RouteCrew: func(ask config.CrewAsk) (crewroute.Decision, error) { return config.RouteCrew(dir, ask) }, + }) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = agent.Close() }) + return agent, dir +} + +// pinnedRun routes one task with every role pinned to GLM on openrouter for +// this task, so no role sharing the worker's model walks a ladder and the +// worker's own send is the only thing the facts decide. +func pinnedRun(t *testing.T, agent *Agent) *beltRun { + t.Helper() + sends := map[crewroute.Seat]string{crewroute.Worker: credPinned, crewroute.Planner: credPinned, crewroute.Checker: credPinned} + crew, err := agent.routeTaskCrew(withCrewWish(t.Context(), crewWish{sends: sends}), 1, "fix: the parser crashes on empty input", "Traceback: ValueError") + if err != nil { + t.Fatal(err) + } + for _, seat := range crewroute.Seats { + if pick := crew.current().Seat(seat); !pick.Pinned || pick.Send != credPinned || pick.Provider != "openrouter" { + t.Fatalf("the %s was not pinned to %s on openrouter: %+v", seat, credPinned, pick) + } + } + return &beltRun{row: 1, crew: crew} +} + +// askPinned is the run engine's next call on the pinned worker. +func askPinned(t *testing.T, agent *Agent, run *beltRun) error { + t.Helper() + _, err := crewSeatCompleter{agent: agent, run: run}.CompleteWithMessages(seatCtx(crewroute.Worker), + []ai.Message{textMessage("user", "fix it")}, ai.WithModel(credPinned)) + return err +} + +func wantStopped(t *testing.T, err error, action string) { + t.Helper() + var stopped crewStopped + if !errors.As(err, &stopped) || stopped.action != action { + t.Fatalf("the call ended %v, want %q", err, action) + } +} + +func failedOf(run *beltRun, send string) (crewFailure, bool) { + run.crew.mu.Lock() + defer run.crew.mu.Unlock() + f, ok := run.crew.failed[send] + return f, ok +} + +// A KEY ROTATED WHILE THE CALL WAS IN FLIGHT IS NOT BLAMED FOR THE OLD KEY'S +// 401, and the same pin answers on it. The request leaves with key A; while +// it is in flight the person connects key B — the profile and the +// conversation's pool both move — and A's refusal comes back. The failure, +// and the router's log row, name A's print and never B's; the next explicit +// call on the same pinned worker reaches the stub on B, for the same model, +// and nothing else is asked. +func TestAKeyRotatedInFlightIsNotBlamedAndTheSamePinAnswersOnIt(t *testing.T) { + stub := newBearerStub(t, map[string]int{credKeyA: http.StatusUnauthorized, credKeyB: http.StatusOK}) + agent, dir := bearerStubAgent(t, stub) + var once sync.Once + stub.during = func(bearer string) { + if bearer != credKeyA { + return + } + once.Do(func() { + if err := config.WriteAPIKey(dir, credKeyB); err != nil { + t.Error(err) + } + agent.SetSources(onStub(stub, credKeyB)) + }) + } + run := pinnedRun(t, agent) + wantStopped(t, askPinned(t, agent, run), "reconnect openrouter with /connect") + + printA, printB := credPrint(stub, dir, credKeyA), credPrint(stub, dir, credKeyB) + if printA == "" || printA == printB || config.CrewDispatchFor(dir, "openrouter").Cred != printB { + t.Fatalf("prints A %q B %q in force %q: the fixture did not rotate", printA, printB, config.CrewDispatchFor(dir, "openrouter").Cred) + } + failure, ok := failedOf(run, credPinned) + if !ok || failure.kind != provider.RouteAuth || failure.sent.Cred != printA || failure.provider != "openrouter" || failure.at.IsZero() { + t.Fatalf("the failure %+v, want an auth refusal stamped with A's print %q", failure, printA) + } + raw, err := os.ReadFile(config.ProfilePath(dir, "router-events.jsonl")) + if err != nil { + t.Fatal(err) + } + log := string(raw) + if !strings.Contains(log, `"fail_cred":"`+printA+`"`) || strings.Contains(log, printB) { + t.Fatalf("the router log does not blame A's print alone:\n%s", log) + } + if strings.Contains(log, credKeyA) || strings.Contains(log, credKeyB) { + t.Fatal("the router log carries a key") + } + run.crew.mu.Lock() + changes, changeCount := len(run.crew.changes), run.crew.changeCount + run.crew.mu.Unlock() + + if err := askPinned(t, agent, run); err != nil { + t.Fatalf("the explicit retry on B: %v (asked %+v)", err, stub.requests()) + } + got := stub.requests() + if len(got) != 2 || got[0] != (bearerAsk{credKeyA, credPinned}) || got[1] != (bearerAsk{credKeyB, credPinned}) { + t.Fatalf("the stub was asked %+v, want A then B, both on %s", got, credPinned) + } + d := run.crew.current() + if w := d.Seat(crewroute.Worker); d.Stopped != "" || !w.Pinned || w.Send != credPinned { + t.Fatalf("after the repair the decision is %+v (worker %+v)", d, w) + } + run.crew.mu.Lock() + defer run.crew.mu.Unlock() + if len(run.crew.gone) != 0 || run.crew.bad[credPinned] || run.crew.failed[credPinned].kind != "" || !run.crew.started[credPinned] { + t.Fatalf("after the repair gone %v bad %v failed %v started %v", run.crew.gone, run.crew.bad, run.crew.failed, run.crew.started) + } + if len(run.crew.changes) != changes || run.crew.changeCount != changeCount { + t.Fatalf("the repair rewrote the history: %d/%d changes, want %d/%d", len(run.crew.changes), run.crew.changeCount, changes, changeCount) + } +} + +// A POOL THAT STILL HOLDS THE OLD KEY STAMPS THE OLD KEY. The profile already +// says B, but the conversation was never told and its pool sends A: the +// failure is A's, which the settings in force would have mislabelled B. And +// while the pool would send A again, the explicit retry asks nothing — the +// profile's new key says nothing about the client that would carry the call. +func TestAPoolStillHoldingTheOldKeyIsStampedAndHeldOnIt(t *testing.T) { + stub := newBearerStub(t, map[string]int{credKeyA: http.StatusUnauthorized, credKeyB: http.StatusOK}) + agent, dir := bearerStubAgent(t, stub) + run := pinnedRun(t, agent) + if err := config.WriteAPIKey(dir, credKeyB); err != nil { + t.Fatal(err) + } + wantStopped(t, askPinned(t, agent, run), "reconnect openrouter with /connect") + if failure, _ := failedOf(run, credPinned); failure.sent.Cred != credPrint(stub, dir, credKeyA) || failure.sent == config.CrewDispatchFor(dir, "openrouter") { + t.Fatalf("the failure was stamped %q, want A's print, not the profile's", failure.sent.Cred) + } + wantStopped(t, askPinned(t, agent, run), "reconnect openrouter with /connect") + if got := stub.requests(); len(got) != 1 || got[0].bearer != credKeyA { + t.Fatalf("the stub was asked %+v, want A's one refused call", got) + } + // Told, the pool moves to B, and the same pin answers there. + agent.SetSources(onStub(stub, credKeyB)) + if err := askPinned(t, agent, run); err != nil { + t.Fatalf("the retry once the pool holds B: %v", err) + } + if got := stub.requests(); len(got) != 2 || got[1] != (bearerAsk{credKeyB, credPinned}) { + t.Fatalf("the stub was asked %+v, want B on %s second", got, credPinned) + } +} + +// THE SAME KEY, OR NO KEY, REPAIRS NOTHING. The refused key written again is +// the key that was refused; a profile and pool with no key in force are not a +// new credential. The pinned worker stays held on its action, and the stub is +// asked nothing more. +func TestTheSameKeyOrNoKeyLeavesTheRefusalHeldAndAsksNothing(t *testing.T) { + stub := newBearerStub(t, map[string]int{credKeyA: http.StatusUnauthorized}) + agent, dir := bearerStubAgent(t, stub) + run := pinnedRun(t, agent) + wantStopped(t, askPinned(t, agent, run), "reconnect openrouter with /connect") + + if err := config.WriteAPIKey(dir, credKeyA); err != nil { + t.Fatal(err) + } + agent.SetSources(onStub(stub, credKeyA)) + wantStopped(t, askPinned(t, agent, run), "reconnect openrouter with /connect") + + if err := config.WriteAPIKey(dir, ""); err != nil { + t.Fatal(err) + } + agent.SetSources(onStub(stub, "")) + wantStopped(t, askPinned(t, agent, run), "reconnect openrouter with /connect") + if got := stub.requests(); len(got) != 1 { + t.Fatalf("the stub was asked %+v, want only the first refused call", got) + } + if d := run.crew.current(); d.Stopped != "reconnect openrouter with /connect" { + t.Fatalf("the held crew stopped on %q", d.Stopped) + } +} + +// CREDIT READ BEFORE A PAYMENT REFUSAL DOES NOT LIFT IT; credit read after it +// does. The account refuses for want of credit; a balance read that began +// before the refusal leaves the pin held and asks nothing. A positive balance +// read after it lets the same pin, on the same key, be asked once more. +func TestOnlyCreditReadAfterAPaymentRefusalAsksTheSamePinAgain(t *testing.T) { + stub := newBearerStub(t, map[string]int{credKeyA: http.StatusPaymentRequired}) + agent, dir := bearerStubAgent(t, stub) + run := pinnedRun(t, agent) + before := time.Now() + wantStopped(t, askPinned(t, agent, run), "add credit on openrouter to continue") + + if err := config.WriteCreditsReadingAt(dir, credKeyA, credits.Reading{Known: true}, before); err != nil { + t.Fatal(err) + } + wantStopped(t, askPinned(t, agent, run), "add credit on openrouter to continue") + if got := stub.requests(); len(got) != 1 { + t.Fatalf("an old reading asked the stub again: %+v", got) + } + + stub.set(credKeyA, http.StatusOK) + if err := config.WriteCreditsReadingAt(dir, credKeyA, credits.Reading{Known: true}, time.Now()); err != nil { + t.Fatal(err) + } + if err := askPinned(t, agent, run); err != nil { + t.Fatalf("the retry after credit was read: %v", err) + } + if got := stub.requests(); len(got) != 2 || got[1] != (bearerAsk{credKeyA, credPinned}) { + t.Fatalf("the stub was asked %+v, want the same pin on the same key", got) + } + run.crew.mu.Lock() + defer run.crew.mu.Unlock() + if len(run.crew.broke) != 0 || run.crew.decision.Stopped != "" { + t.Fatalf("after credit: broke %v stopped %q", run.crew.broke, run.crew.decision.Stopped) + } +} + +// ONLY THE ANSWERED REFUSAL IS LET GO. On one provider the pinned worker's +// refusal is answered by a new key, beside an older checkpoint's refusal that +// names no time and no key, a limit on another send, and a spend stop. The +// worker's own failure goes; the unanswered refusal keeps its provider gone +// and the stop said; the limit stands; a spend stop is never touched. +func TestOnlyTheAnsweredRefusalIsLetGo(t *testing.T) { + for _, c := range []struct { + name string + legacy bool + stopped string + wantGone bool + wantStopped string + }{ + {"an unanswered refusal on the same provider", true, "reconnect openrouter with /connect", true, "reconnect openrouter with /connect"}, + {"nothing else refused on it", false, "reconnect openrouter with /connect", false, ""}, + {"a spend stop", false, "raise the task limit to continue", false, "raise the task limit to continue"}, + } { + t.Run(c.name, func(t *testing.T) { + stub := newBearerStub(t, map[string]int{credKeyB: http.StatusOK}) + agent, dir := bearerStubAgent(t, stub) + run := pinnedRun(t, agent) + crew := run.crew + crew.mu.Lock() + crew.failed = map[string]crewFailure{ + credPinned: {kind: provider.RouteAuth, at: time.Now(), provider: "openrouter", sent: config.CrewDispatch{Cred: credPrint(stub, dir, credKeyA)}}, + "vendor/cap": {kind: provider.RouteQuota, at: time.Now(), provider: "openrouter"}, + } + crew.bad = map[string]bool{credPinned: true, "vendor/cap": true} + if c.legacy { + crew.failed["vendor/old"] = crewFailure{kind: provider.RouteAuth, provider: "openrouter"} + crew.bad["vendor/old"] = true + } + crew.gone, crew.broke = map[string]bool{"openrouter": true}, map[string]bool{} + crew.decision.Stopped = c.stopped + crew.mu.Unlock() + + if err := config.WriteAPIKey(dir, credKeyB); err != nil { + t.Fatal(err) + } + agent.SetSources(onStub(stub, credKeyB)) + agent.liftAnsweredRefusals(run, credPinned) + + crew.mu.Lock() + defer crew.mu.Unlock() + if _, held := crew.failed[credPinned]; held || crew.bad[credPinned] { + t.Fatalf("the answered refusal is still held: %v %v", crew.failed, crew.bad) + } + if _, held := crew.failed["vendor/cap"]; !held || !crew.bad["vendor/cap"] { + t.Fatal("a limit was let go with the refused key") + } + if _, held := crew.failed["vendor/old"]; c.legacy && (!held || !crew.bad["vendor/old"]) { + t.Fatal("a refusal no fact answers was let go") + } + if crew.gone["openrouter"] != c.wantGone || crew.decision.Stopped != c.wantStopped { + t.Fatalf("gone %v stopped %q, want gone %v stopped %q", crew.gone, crew.decision.Stopped, c.wantGone, c.wantStopped) + } + if len(stub.requests()) != 0 { + t.Fatalf("letting a refusal go asked the stub: %+v", stub.requests()) + } + }) + } +} + +// A CARRIED-ON TASK KEEPS WHAT ITS FAILURE WAS SENT WITH. The checkpoint +// keeps the failure's time, provider and print — never the key — and the +// crew restored from it lets the refusal go on the same facts. +func TestACheckpointKeepsTheFailuresDispatchFacts(t *testing.T) { + stub := newBearerStub(t, map[string]int{credKeyA: http.StatusUnauthorized, credKeyB: http.StatusOK}) + agent, dir := bearerStubAgent(t, stub) + run := pinnedRun(t, agent) + wantStopped(t, askPinned(t, agent, run), "reconnect openrouter with /connect") + failure, _ := failedOf(run, credPinned) + + record := run.crew.record() + raw, err := json.Marshal(record) + if err != nil { + t.Fatal(err) + } + if strings.Contains(string(raw), credKeyA) { + t.Fatal("the checkpoint carries a key") + } + if sent := record.FailedSent[credPinned]; sent.Cred != failure.sent.Cred || sent.Provider != "openrouter" || !sent.At.Equal(failure.at) { + t.Fatalf("the checkpoint kept %+v, want %+v", sent, failure) + } + restored, err := agent.restoreTaskCrew(2, record) + if err != nil { + t.Fatal(err) + } + // The fixture has no durable run row to checkpoint into; the restored + // policy is what is under test. + restored.persist = nil + again := &beltRun{row: 2, crew: restored, recoveredCrew: record} + if f, _ := failedOf(again, credPinned); f.sent != failure.sent || f.provider != failure.provider || !f.at.Equal(failure.at) { + t.Fatalf("the restored failure %+v, want %+v", f, failure) + } + if err := config.WriteAPIKey(dir, credKeyB); err != nil { + t.Fatal(err) + } + agent.SetSources(onStub(stub, credKeyB)) + if err := askPinned(t, agent, again); err != nil { + t.Fatalf("the carried-on task on B: %v", err) + } + if got := stub.requests(); len(got) != 2 || got[1] != (bearerAsk{credKeyB, credPinned}) { + t.Fatalf("the stub was asked %+v", got) + } +} diff --git a/internal/session/taskcrew_record.go b/internal/session/taskcrew_record.go index 8c95ca387a..cb737a1b5a 100644 --- a/internal/session/taskcrew_record.go +++ b/internal/session/taskcrew_record.go @@ -50,6 +50,12 @@ type TaskCrewRecord struct { // role spend was a real zero, is unknown. RoleFacts int `json:",omitempty"` SeatStarted map[crewroute.Seat]bool `json:",omitempty"` + // FailedSent is, for a send in Failed, when it failed, the provider it was + // routed to and the print of the credential it was sent with — what lets + // a refusal its credential has since answered go when the task is carried + // on. One entry per failed send, never a credential; absent on older + // rows, whose auth and payment refusals then stay held. + FailedSent map[string]crewFailureSent `json:",omitempty"` } type crewFailureRecord struct { @@ -57,6 +63,13 @@ type crewFailureRecord struct { Until time.Time Message string } + +// crewFailureSent is a failed send's dispatch facts ([crewFailure]). +type crewFailureSent struct { + At time.Time `json:",omitzero"` + Provider string `json:",omitempty"` + Cred string `json:",omitempty"` +} type crewGuardRecord struct { Cap, TaskCap float64 CapAction, TaskAction, CeilingAction string @@ -92,18 +105,12 @@ func (c *taskCrew) record() *TaskCrewRecord { defer c.mu.Unlock() r := &TaskCrewRecord{Version: 1, Routed: true, Call: c.call, Title: c.title, Brief: c.brief, Repo: c.repo, Decision: c.decision, Original: c.original, Ladders: c.ladders, Rescue: c.rescue, Started: c.started, Bad: c.bad, Broke: c.broke, Gone: c.gone, - Swaps: c.swaps, FreeTried: c.freeTried, HelperUSD: c.helperUSD, DayAtStart: c.dayAtStart, InFlight: c.inFlight, Failed: map[string]crewFailureRecord{}, + Swaps: c.swaps, FreeTried: c.freeTried, HelperUSD: c.helperUSD, DayAtStart: c.dayAtStart, InFlight: c.inFlight, Changes: c.changes, ChangeCount: c.changeCount, TaskPins: c.taskPins, Pending: c.pending, Restored: c.restored, RestoreCount: c.restoreCount, SeatSends: c.seatSends, SeatStarted: c.seatStarted} if c.roleFacts { r.RoleFacts = 1 } - for send, f := range c.failed { - msg := "" - if f.err != nil { - msg = f.err.Error() - } - r.Failed[send] = crewFailureRecord{f.kind, f.until, msg} - } + r.Failed, r.FailedSent = c.failureRecordsLocked() if g := c.guard; g != nil { g.mu.Lock() r.Guard = &crewGuardRecord{Cap: g.Cap, TaskCap: g.TaskCap, CapAction: g.CapAction, TaskAction: g.TaskAction, CeilingAction: g.CeilingAction, @@ -145,6 +152,29 @@ func (c *taskCrew) record() *TaskCrewRecord { return &frozen } +// failureRecordsLocked is the crew's failures as its checkpoint keeps them: +// how each send failed, and, apart, what is known of the call that failed — +// absent for a failure that knows nothing of it. The caller holds c.mu. +func (c *taskCrew) failureRecordsLocked() (map[string]crewFailureRecord, map[string]crewFailureSent) { + failed := map[string]crewFailureRecord{} + var sent map[string]crewFailureSent + for send, f := range c.failed { + msg := "" + if f.err != nil { + msg = f.err.Error() + } + failed[send] = crewFailureRecord{f.kind, f.until, msg} + if f.at.IsZero() && f.provider == "" && f.sent.Cred == "" { + continue + } + if sent == nil { + sent = map[string]crewFailureSent{} + } + sent[send] = crewFailureSent{At: f.at, Provider: f.provider, Cred: f.sent.Cred} + } + return failed, sent +} + // restoreTaskCrew never asks the router again: a restarted run has already // accepted its seats, fallback history and spend ceilings. func (a *Agent) restoreTaskCrew(row uint64, r *TaskCrewRecord) (*taskCrew, error) { @@ -184,7 +214,9 @@ func (a *Agent) restoreTaskCrew(row uint64, r *TaskCrewRecord) (*taskCrew, error c.restoreCount = r.RestoreCount c.noteRestored(a.taskClockNow(), r.Restored) for send, f := range r.Failed { - c.failed[send] = crewFailure{kind: f.Kind, until: f.Until, err: errors.New(f.Message)} + sent := r.FailedSent[send] + c.failed[send] = crewFailure{kind: f.Kind, until: f.Until, err: errors.New(f.Message), + at: sent.At, provider: sent.Provider, sent: config.CrewDispatch{Cred: sent.Cred}} } c.decision.Ladder = r.Ladders s := r.Guard From db5f3c601411dd42448abd242060465df7e854c0 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 22:07:12 -0400 Subject: [PATCH 094/459] provider: stamp a crew refusal from the bearer its header was written with The seat's credential print was taken at the client door from the account the adapter was chosen for. A key handed to the live adapter between that moment and the header (setDefaultKey re-keys it in place) travelled instead, so a refused new key could be stamped with the old key's print and asked again on every call. provider.Client.ObserveRequestCredential now reports a one-way CredentialDigest of the exact bearer newHTTPRequestAt snapshots for each attempt's Authorization header, bound to that adapter; the door passes config.CrewCredentialDomain and the seat keeps the last attempt's fact. Crew prints are CredentialDigest of that domain with unchanged bytes. Codex and APIKeyOverride requests stay unprinted. --- .../unreleased/0-crew-refusal-runtime.md | 5 +- internal/config/crewcred.go | 17 ++- internal/config/crewcred_digest_test.go | 29 ++++ internal/provider/client.go | 4 +- internal/provider/requestcred.go | 79 +++++++++++ internal/provider/requestcred_test.go | 130 ++++++++++++++++++ internal/session/clientdoor.go | 111 +++++++++------ internal/session/taskcrew.go | 41 ++++-- internal/session/taskcrew_credential_test.go | 119 ++++++++++++++++ 9 files changed, 470 insertions(+), 65 deletions(-) create mode 100644 internal/config/crewcred_digest_test.go create mode 100644 internal/provider/requestcred.go create mode 100644 internal/provider/requestcred_test.go diff --git a/docs/changes/unreleased/0-crew-refusal-runtime.md b/docs/changes/unreleased/0-crew-refusal-runtime.md index 1e48162d11..121774bc21 100644 --- a/docs/changes/unreleased/0-crew-refusal-runtime.md +++ b/docs/changes/unreleased/0-crew-refusal-runtime.md @@ -5,7 +5,8 @@ pr: 0 surface: [chat, engine] invalidates: - "A task's own record of a refused key or empty account (taskCrew failed/bad/gone/broke and a decision.Stopped set from them) held the pinned role for the rest of the task, even after route health lifted the refusal. Now the next seat call checks config.CrewRefusalApplies and the connection the conversation's pool would actually send, and lets go only the auth/payment refusals those facts answer; rate limits, gone routes, caps, spend stops and unanswered refusals on the same provider stand, and nothing is sent because a refusal was lifted." - - "A crew seat's refusal was written to the router log with no credential print. The client door now tells the seat the print of the exact connection its adapter carries (the pool's account, not the settings) before the request leaves, and the refusal is logged with config.LogCrewRouteSent. A key rotated while the call was in flight is not blamed." - - "A Codex sign-in is never printed at dispatch: its transport loads and refreshes tokens per request, so its refusals stay unprinted and are lifted only by a recorded sign-in after them." + - "A crew seat's refusal was written to the router log with no credential print. It is now stamped with the print of the bearer the refused request's own Authorization header was written from: provider.Client.ObserveRequestCredential reports a one-way provider.CredentialDigest per attempt, at the line that snapshots the key for the header, and the seat keeps the last attempt's, logged with config.LogCrewRouteSent. Neither the pool's account (a key handed to the live adapter after the door chose it travels instead) nor the settings are read for it; a key rotated before or while the call was in flight is not blamed." + - "config.CrewCredentialDomain(id) is the domain crew prints are hashed under, and crew prints are provider.CredentialDigest of it; the bytes are unchanged, so prints already recorded still match." + - "A Codex sign-in is never printed at dispatch: its transport replaces the header with tokens it loads and refreshes per request, so its refusals stay unprinted and are lifted only by a recorded sign-in after them. A request carrying its own APIKeyOverride is unprinted too." - "TaskCrewRecord carries FailedSent (failure time, provider, credential print per failed send) so a carried-on task keeps those facts; older checkpoints have none and their auth/payment refusals stay held." --- diff --git a/internal/config/crewcred.go b/internal/config/crewcred.go index 34518c4e52..5d564c5cce 100644 --- a/internal/config/crewcred.go +++ b/internal/config/crewcred.go @@ -1,8 +1,6 @@ package config import ( - "crypto/sha256" - "encoding/hex" "encoding/json" "os" "path/filepath" @@ -12,6 +10,7 @@ import ( "github.com/Agent-Field/codeaf/internal/codexauth" "github.com/Agent-Field/codeaf/internal/modelsource" + "github.com/Agent-Field/codeaf/internal/provider" "github.com/Agent-Field/codeaf/internal/router" ) @@ -66,11 +65,15 @@ func crewCredentialPrint(profileDir string, service modelsource.Connected) strin } secret = tokens.AccountID + "\x00" + tokens.RefreshToken } - if secret == "" { - return "" - } - sum := sha256.Sum256([]byte("crew-credential\x00" + id + "\x00" + secret)) - return hex.EncodeToString(sum[:8]) + return provider.CredentialDigest(CrewCredentialDomain(id), secret) +} + +// CrewCredentialDomain is the domain a provider's crew credential is printed +// under. An adapter that digests the bearer it actually wrote into a request +// with it ([provider.Client.ObserveRequestCredential]) reports the same print +// [CrewDispatchOf] takes from a connection carrying that key. +func CrewCredentialDomain(id string) string { + return "crew-credential\x00" + strings.ToLower(strings.TrimSpace(id)) } // CrewDispatch is the fact a crew call is sent under: the print of the diff --git a/internal/config/crewcred_digest_test.go b/internal/config/crewcred_digest_test.go new file mode 100644 index 0000000000..f2185e07b2 --- /dev/null +++ b/internal/config/crewcred_digest_test.go @@ -0,0 +1,29 @@ +package config + +import ( + "crypto/sha256" + "encoding/hex" + "testing" + + "github.com/Agent-Field/codeaf/internal/modelsource" + "github.com/Agent-Field/codeaf/internal/provider" +) + +// A PRINT TAKEN FROM A CONNECTION AND A DIGEST TAKEN FROM A HEADER ARE ONE +// STRING, and it is the string prints have always been. A print written before +// the digest moved into the adapter is the same bytes, so a refusal recorded +// then is still recognised as the same credential and never read as a change. +func TestAHeaderDigestIsTheConnectionsPrint(t *testing.T) { + const key = "fixture-crew-digest-key" + for _, id := range []string{"openrouter", "OpenRouter "} { + service := modelsource.Connected{Source: modelsource.Source{ID: id}, Key: key} + old := sha256.Sum256([]byte("crew-credential\x00openrouter\x00" + key)) + want := hex.EncodeToString(old[:8]) + if got := CrewDispatchOf(t.TempDir(), service).Cred; got != want { + t.Fatalf("%q printed %q, want the print it always had %q", id, got, want) + } + if got := provider.CredentialDigest(CrewCredentialDomain(id), key); got != want { + t.Fatalf("%q digested %q in its domain, want %q", id, got, want) + } + } +} diff --git a/internal/provider/client.go b/internal/provider/client.go index f9ad8ae1c3..caf62b05ec 100644 --- a/internal/provider/client.go +++ b/internal/provider/client.go @@ -2446,7 +2446,8 @@ func (c *Client) newHTTPRequestAt(ctx context.Context, request *ai.Request, body return nil, fmt.Errorf("create request: %w", err) } apiKey, err := c.apiKeyNow() - if override := strings.TrimSpace(request.APIKeyOverride); override != "" { + override := strings.TrimSpace(request.APIKeyOverride) + if override != "" { apiKey, err = override, nil } if err != nil { @@ -2457,6 +2458,7 @@ func (c *Client) newHTTPRequestAt(ctx context.Context, request *ai.Request, body if apiKey != "" { httpRequest.Header.Set("Authorization", "Bearer "+apiKey) } + c.noteRequestCredential(ctx, apiKey, override != "") if stream { httpRequest.Header.Set("Accept", "text/event-stream") } diff --git a/internal/provider/requestcred.go b/internal/provider/requestcred.go new file mode 100644 index 0000000000..cb7e46270b --- /dev/null +++ b/internal/provider/requestcred.go @@ -0,0 +1,79 @@ +package provider + +import ( + "context" + "crypto/sha256" + "encoding/hex" +) + +// ── THE CREDENTIAL A REQUEST HEADER WAS BUILT FROM ────────────────────────── +// +// A refusal of a key belongs to the key the refused request carried, and the +// one place that knows it for certain is the line that writes the request's +// Authorization header ([Client.newHTTPRequestAt]). The bearer is snapshotted +// there, once per attempt, under the adapter's own lock, and that snapshot is +// the value the service receives. Anything read before it — the session's +// account for the adapter, the profile — can be overtaken by a key handed to +// the live adapter between the two ([Client.SetAPIKey]), and a refusal blamed +// on the new key would hold a good key back for as long as the task runs. So +// the observation is taken from the snapshot itself, and an attempt the +// dispatcher makes again takes its own. +// +// NOTHING BUT A ONE-WAY DIGEST LEAVES THIS PACKAGE. The observer names a +// domain, the adapter hashes the bearer under it, and only the digest is +// reported; the bearer is never copied into the report, logged or kept. + +// RequestCredential is what one request this adapter built was sent with. +type RequestCredential struct { + // Digest is [CredentialDigest] of the observer's domain and the bearer the + // request's Authorization header was written from, or "" when the request + // carried no bearer or the observer named no domain. + Digest string + // Override is true when that bearer was the request's own + // [ai.Request.APIKeyOverride] rather than the adapter's key. + Override bool +} + +type requestCredentialKey struct{} + +// requestCredentialWatch is one observer, bound to the adapter it watches: a +// request another adapter builds under the same context carries another key +// and is not reported to it. +type requestCredentialWatch struct { + client *Client + domain string + observe func(RequestCredential) +} + +// ObserveRequestCredential asks c to tell observe, on the goroutine building +// it, what every request it builds under ctx carries — each attempt's own, in +// the order they are built, so the last one told is the last one sent. +func (c *Client) ObserveRequestCredential(ctx context.Context, domain string, observe func(RequestCredential)) context.Context { + if c == nil || observe == nil { + return ctx + } + return context.WithValue(ctx, requestCredentialKey{}, requestCredentialWatch{client: c, domain: domain, observe: observe}) +} + +// CredentialDigest is sixteen hex digits of a hash of secret under domain, "" +// for no secret or no domain. It is the print config's crew credentials are +// spelled in, so a digest taken here and a print taken from a connection with +// the same key and domain are the same string. The secret is hashed as given: +// both callers write the bearer already trimmed. +func CredentialDigest(domain, secret string) string { + if domain == "" || secret == "" { + return "" + } + sum := sha256.Sum256([]byte(domain + "\x00" + secret)) + return hex.EncodeToString(sum[:8]) +} + +// noteRequestCredential reports bearer, the value just written into one +// request's header, to the observer watching c under ctx. +func (c *Client) noteRequestCredential(ctx context.Context, bearer string, override bool) { + watch, ok := ctx.Value(requestCredentialKey{}).(requestCredentialWatch) + if !ok || watch.client != c { + return + } + watch.observe(RequestCredential{Digest: CredentialDigest(watch.domain, bearer), Override: override}) +} diff --git a/internal/provider/requestcred_test.go b/internal/provider/requestcred_test.go new file mode 100644 index 0000000000..85007c5a26 --- /dev/null +++ b/internal/provider/requestcred_test.go @@ -0,0 +1,130 @@ +package provider + +import ( + "context" + "net/http" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" +) + +// The keys here are this file's own literals and the service is an in-process +// handler: nothing leaves the test. + +const ( + requestCredDomain = "crew-credential\x00openrouter" + requestCredKeyA = "fixture-request-cred-a" + requestCredKeyB = "fixture-request-cred-b" + requestCredOwn = "fixture-request-cred-own" +) + +// requestCredClient is a direct adapter on handler with key A, whose retry +// wait passes no time, and the bearer of every request handler received. +func requestCredClient(t *testing.T, handler func(attempt int, bearer string, w http.ResponseWriter)) (*Client, func() []string) { + t.Helper() + oldLimiter := sharedLimiter + sharedLimiter = newAdaptiveLimiter() + t.Cleanup(func() { sharedLimiter = oldLimiter }) + var mu sync.Mutex + var bearers []string + client, err := NewClient(Config{ + APIKey: requestCredKeyA, BaseURL: "https://direct.example/v1", Model: "glm-5.3-flash", Direct: true, + HTTPClient: handlerClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + bearer := strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ") + mu.Lock() + bearers = append(bearers, bearer) + attempt := len(bearers) + mu.Unlock() + handler(attempt, bearer, w) + })), + }) + if err != nil { + t.Fatal(err) + } + client.wait = func(context.Context, time.Duration) error { return nil } + return client, func() []string { + mu.Lock() + defer mu.Unlock() + return append([]string(nil), bearers...) + } +} + +// EVERY ATTEMPT IS TOLD THE BEARER ITS OWN HEADER CARRIED. The first attempt +// leaves with key A and is paced; while it is in flight the live adapter is +// handed key B; the dispatcher's next attempt is refused. The observer is told +// one digest per attempt, in order, each the digest of the bearer that +// attempt's header actually carried — so the last one told is B's, the key the +// refused request was sent with, never A's from when the call began. +func TestEachAttemptIsToldTheBearerItsHeaderCarried(t *testing.T) { + var client *Client + client, received := requestCredClient(t, func(attempt int, _ string, w http.ResponseWriter) { + if attempt == 1 { + if err := client.SetAPIKey(requestCredKeyB); err != nil { + t.Error(err) + } + w.WriteHeader(http.StatusTooManyRequests) + _, _ = w.Write([]byte(`{"message":"too many requests"}`)) + return + } + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":{"message":"No auth credentials found","code":401}}`)) + }) + var told []RequestCredential + ctx := client.ObserveRequestCredential(context.Background(), requestCredDomain, func(seen RequestCredential) { + told = append(told, seen) + }) + if _, err := client.CompleteWithMessages(ctx, userMessages("hello")); err == nil { + t.Fatal("the refused attempt answered") + } + bearers := received() + if len(bearers) != 2 || bearers[0] != requestCredKeyA || bearers[1] != requestCredKeyB { + t.Fatalf("the service received %d requests; the fixture did not rotate between them", len(bearers)) + } + if len(told) != len(bearers) { + t.Fatalf("told %d attempts for %d requests", len(told), len(bearers)) + } + for i, bearer := range bearers { + if want := CredentialDigest(requestCredDomain, bearer); told[i].Digest != want || told[i].Override { + t.Fatalf("attempt %d was told %+v, want the digest of its own header %q", i+1, told[i], want) + } + } + if last := told[len(told)-1].Digest; last == CredentialDigest(requestCredDomain, requestCredKeyA) || last == "" { + t.Fatalf("the refused attempt was told %q, the key the call began with", last) + } +} + +// A BEARER THE REQUEST NAMED ITSELF IS SAID TO BE ITS OWN, and a request +// another adapter writes under the same context is not reported at all. +func TestAnOverrideIsMarkedAndAnotherAdapterIsNotReported(t *testing.T) { + ok := func(_ int, _ string, w http.ResponseWriter) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"choices":[{"index":0,"finish_reason":"stop","message":{"role":"assistant","content":"ok"}}]}`)) + } + client, received := requestCredClient(t, ok) + other, _ := requestCredClient(t, ok) + var told []RequestCredential + ctx := client.ObserveRequestCredential(context.Background(), requestCredDomain, func(seen RequestCredential) { + told = append(told, seen) + }) + if _, err := client.CompleteWithMessages(ctx, userMessages("hello"), ai.WithAPIKey(requestCredOwn)); err != nil { + t.Fatal(err) + } + if bearers := received(); len(bearers) != 1 || bearers[0] != requestCredOwn { + t.Fatal("the request did not carry its own bearer") + } + if len(told) != 1 || !told[0].Override || told[0].Digest != CredentialDigest(requestCredDomain, requestCredOwn) { + t.Fatalf("the override was told %+v", told) + } + if _, err := other.CompleteWithMessages(ctx, userMessages("hello")); err != nil { + t.Fatal(err) + } + if len(told) != 1 { + t.Fatalf("another adapter's request was reported: %+v", told) + } + if CredentialDigest("", requestCredKeyA) != "" || CredentialDigest(requestCredDomain, "") != "" { + t.Fatal("a digest with no domain or no secret named something") + } +} diff --git a/internal/session/clientdoor.go b/internal/session/clientdoor.go index 9593707419..1317c6ac42 100644 --- a/internal/session/clientdoor.go +++ b/internal/session/clientdoor.go @@ -124,10 +124,11 @@ func (p *modelClientPool) clientFor(model string) (Completer, string, string, mo } // clientWithService is [modelClientPool.clientFor] with the connection the -// account was read from. The account is the pool's key, bearer included, so -// the adapter answered here — cached or new — carries exactly that -// connection's credential: it is the one fact about a request's credential -// that does not have to be guessed from the settings afterwards. +// account was read from. The account names the key the adapter was chosen +// for, but a key handed to the live adapter afterwards +// ([modelClientPool.setDefaultKey]) is the one its next header carries, so it +// says which provider a request is on and never which credential it carried +// ([observeSentCredential]). func (p *modelClientPool) clientWithService(model string) (Completer, string, string, modelAccount, modelsource.Connected, error) { if p == nil { return nil, "", "", modelAccount{}, modelsource.Connected{}, errNoCompleter @@ -519,51 +520,77 @@ func (a *Agent) completerWithService(model string) (Completer, string, string, m // ── THE CREDENTIAL A REQUEST WAS SENT WITH ────────────────────────────────── // -// A refusal of a key belongs to the key that was refused, and the only place -// that knows which key a request carries is this door, at the moment it hands -// the request to the adapter: the pool's account is its key, bearer included -// ([modelClientPool.clientWithService]). Reading the settings at that moment -// instead could name a key the pool has not built a client for yet, and -// reading them when the refusal comes back names whatever was connected while -// the request was in flight — the defect that blamed a new key for an old -// key's 401 (config.CrewDispatch says the rest). +// A refusal of a key belongs to the key that was refused, and the only value +// that says which key a request carried is the bearer its adapter wrote into +// the request's header. The pool's account names the key the adapter was +// chosen for, but a key handed to that live adapter afterwards +// ([modelClientPool.setDefaultKey]) travels in the header instead; reading the +// settings names whatever was connected while the request was in flight. Both +// blamed a new key for an old key's 401 and held the new key back for the rest +// of the task. So the stamp is taken by the adapter, from the bearer it +// snapshotted for each attempt's header ([provider.Client.ObserveRequestCredential]), +// and this door only tells the adapter which domain to print it in and hands +// the print on (config.CrewDispatch says the rest). // sentConnection is one request's dispatch fact: the provider its account is -// on and the one-way print of the credential its adapter carries. An empty -// print is UNPRINTED — this door cannot say which credential will travel — -// and a caller treats it as naming none, never as naming "no key". +// on and the one-way print of the credential it carried. An empty print is +// UNPRINTED — nothing can say which credential travelled — and a caller treats +// it as naming none, never as naming "no key". type sentConnection struct { provider string sent account.CrewDispatch } -// sentConnectionOf is the dispatch fact of a request made through service. +// observeSentCredential is ctx with observe told the dispatch fact of every +// request client writes for this call, each attempt's own and in order, so +// the last one told is the last one sent. Only an adapter that can report +// its own header is asked; any other leaves the call unprinted. // -// A SIGN-IN IS NEVER PRINTED HERE. Codex's transport loads and refreshes its -// tokens per request ([codexauth.Client]), so the tokens on disk at this -// moment are not provably the ones the request will carry; the refusal stays -// unprinted, and only a recorded sign-in after it can lift it. A request that -// names a bearer of its own ([ai.Request.APIKeyOverride]) is not carrying the -// account's credential either. -func sentConnectionOf(profileDir string, service modelsource.Connected, options []ai.Option) sentConnection { +// A SIGN-IN IS NEVER PRINTED. Codex's transport replaces the header with +// tokens it loads and refreshes per request ([codexauth.Client]), so neither +// the bearer this adapter wrote nor the tokens on disk are provably the ones +// that travelled; the refusal stays unprinted, and only a recorded sign-in +// after it can lift it. A request that names a bearer of its own +// ([ai.Request.APIKeyOverride]) did not carry the account's credential, and +// is unprinted too rather than named against the account. +// +// AND A CALL NESTED INSIDE THIS ONE IS NOT THIS CALL. The observer is taken +// off the context handed on, and the adapter reports only its own requests. +func observeSentCredential(ctx context.Context, client Completer, service modelsource.Connected, observe func(sentConnection)) context.Context { + ctx = context.WithValue(ctx, dispatchObserverKey{}, (func(sentConnection))(nil)) id := strings.ToLower(strings.TrimSpace(service.Source.ID)) - if id == "" { - return sentConnection{} + adapter, ok := unwrapCompleter(client).(interface { + ObserveRequestCredential(context.Context, string, func(provider.RequestCredential)) context.Context + }) + if id == "" || !ok { + return ctx } - out := sentConnection{provider: id} + domain := account.CrewCredentialDomain(id) if id == "codex" { - return out + domain = "" } - var request ai.Request - for _, option := range options { - if option(&request) != nil { - return out + return adapter.ObserveRequestCredential(ctx, domain, func(seen provider.RequestCredential) { + fact := sentConnection{provider: id} + if !seen.Override { + fact.sent = account.CrewDispatch{Cred: seen.Digest} } + observe(fact) + }) +} + +// sentConnectionOf is the dispatch fact a request through service WOULD carry: +// the print of the account's own key, in the domain the adapter prints its +// header in. It predicts the next call; it never stamps a sent one. A sign-in +// is unprinted here for the same reason it is at the header. +func sentConnectionOf(profileDir string, service modelsource.Connected) sentConnection { + id := strings.ToLower(strings.TrimSpace(service.Source.ID)) + if id == "" { + return sentConnection{} } - if strings.TrimSpace(request.APIKeyOverride) != "" { - return out + out := sentConnection{provider: id} + if id != "codex" { + out.sent = account.CrewDispatchOf(profileDir, service) } - out.sent = account.CrewDispatchOf(profileDir, service) return out } @@ -581,17 +608,17 @@ func (a *Agent) nextSentConnection(model string) sentConnection { if !managed || pool == nil { return sentConnection{} } - return sentConnectionOf(profileDir, pool.serviceNext(model), nil) + return sentConnectionOf(profileDir, pool.serviceNext(model)) } // dispatchObserverKey carries the one callback a crew seat's call is told its // dispatch fact through ([withDispatchObserver]). type dispatchObserverKey struct{} -// withDispatchObserver asks the door to tell observe the dispatch fact of the -// request made under ctx, on the calling goroutine, after the adapter is -// chosen and before the request is handed to it. It is a seat's own narrow -// seam: nothing but the fact crosses it, never the adapter or its key. +// withDispatchObserver asks the door to tell observe the dispatch fact of each +// request the adapter it chooses writes for the call made under ctx, from the +// header itself ([observeSentCredential]). It is a seat's own narrow seam: +// nothing but the fact crosses it, never the adapter or its key. func withDispatchObserver(ctx context.Context, observe func(sentConnection)) context.Context { if observe == nil { return ctx @@ -749,10 +776,10 @@ func (a *Agent) completeWithNamedModel(ctx context.Context, purpose callPurpose, if err != nil { return nil, called, err } - // THE DISPATCH FACT IS TAKEN FROM THE ACCOUNT THAT CHOSE THIS ADAPTER, in - // the step that chose it — never from the settings, and never after. + // THE DISPATCH FACT IS TAKEN FROM THE HEADER THIS ADAPTER WRITES, never + // from the account that chose it, the settings, or the answer. if observe := dispatchObserverOf(ctx); observe != nil { - observe(sentConnectionOf(a.config.ProfileDir, service, options)) + ctx = observeSentCredential(ctx, client, service, observe) } // THE PURPOSE BECOMES THE TAG, on the one line every request in this package // passes over — through [withPurpose], which is the only function in diff --git a/internal/session/taskcrew.go b/internal/session/taskcrew.go index b221dafdb8..f80027c3ed 100644 --- a/internal/session/taskcrew.go +++ b/internal/session/taskcrew.go @@ -474,16 +474,11 @@ func (c crewSeatCompleter) completeSeat(ctx context.Context, key string, message callCtx := provider.WithDiscardedUsage(ctx, func(_ string, _ string, response *ai.Response) { crew.guard.after(ctx, current, response, 0) }) - // THIS CALL'S OWN DISPATCH FACT, told by the door as it hands this - // request to its adapter ([withDispatchObserver]). A retry is a new - // call and is told its own. - var sent sentConnection - told := false - callCtx = withDispatchObserver(callCtx, func(fact sentConnection) { - if !told { - sent, told = fact, true - } - }) + // THIS CALL'S OWN DISPATCH FACT, told from the header each attempt was + // written with ([withDispatchObserver]): the LAST told is the attempt + // that failed, whichever key an earlier attempt carried. + sent := new(lastSent) + callCtx = withDispatchObserver(callCtx, sent.tell) response, err := c.agent.completeWithModel(provider.WithoutPatientRateLimits(asCrewSeatCall(callCtx)), purposeInherited, messages, current, options...) failedAt := time.Now() crew.guard.after(ctx, current, response, held) @@ -503,7 +498,7 @@ func (c crewSeatCompleter) completeSeat(ctx context.Context, key string, message continue } retried = false - crew.noteFailed(current, crew.failureSent(current, crewFailure{kind: kind, until: until, err: err, at: failedAt}, sent)) + crew.noteFailed(current, crew.failureSent(current, crewFailure{kind: kind, until: until, err: err, at: failedAt}, sent.read())) if action, moved := c.agent.moveCrewSeat(c.run, key, current, kind, until); !moved { if action == "" { return response, err @@ -513,6 +508,26 @@ func (c crewSeatCompleter) completeSeat(ctx context.Context, key string, message } } +// lastSent is the dispatch fact of the newest request one seat call wrote. A +// streamed request is written on the stream's own goroutine, so it is read +// under its own lock. +type lastSent struct { + mu sync.Mutex + fact sentConnection +} + +func (l *lastSent) tell(fact sentConnection) { + l.mu.Lock() + l.fact = fact + l.mu.Unlock() +} + +func (l *lastSent) read() sentConnection { + l.mu.Lock() + defer l.mu.Unlock() + return l.fact +} + // seatKey is the send id a seat call names — the seat as routed — or the // conversation's model when it names none. func (c crewSeatCompleter) seatKey(options []ai.Option) (string, error) { @@ -535,8 +550,8 @@ type crewFailure struct { err error // at is when the failure came back, and provider the provider the send // was routed to. sent is the print of the credential the failed call was - // SENT with, as the client door told it before the request left - // ([withDispatchObserver]); empty when the door could not say. A failure + // SENT with, as the adapter printed it from the header of the last attempt + // it wrote ([withDispatchObserver]); empty when nothing could say. A failure // restored from an older checkpoint has none of the three, and an auth or // payment refusal without them is never lifted by this task. at time.Time diff --git a/internal/session/taskcrew_credential_test.go b/internal/session/taskcrew_credential_test.go index 4bcc0d0646..4cbe9c96e1 100644 --- a/internal/session/taskcrew_credential_test.go +++ b/internal/session/taskcrew_credential_test.go @@ -1,6 +1,7 @@ package session import ( + "context" "encoding/json" "errors" "io" @@ -28,6 +29,7 @@ import ( const ( credKeyA = "sk-or-v1-crewcred-key-a-0123456789" credKeyB = "sk-or-v1-crewcred-key-b-9876543210" + credKeyC = "sk-or-v1-crewcred-key-c-5555555555" credPinned = "z-ai/glm-5.3-flash" ) @@ -251,6 +253,123 @@ func TestAKeyRotatedInFlightIsNotBlamedAndTheSamePinAnswersOnIt(t *testing.T) { } } +// pausedAdapter is the conversation's own provider adapter, held once at the +// moment it is handed a request: after the door chose it and the account it +// was chosen for, before it snapshots a bearer for the header. Everything +// else — its key, its header, what it reports — is the adapter's own. +type pausedAdapter struct { + *provider.Client + once sync.Once + reached, resume chan struct{} +} + +func (p *pausedAdapter) CompleteWithMessages(ctx context.Context, messages []ai.Message, options ...ai.Option) (*ai.Response, error) { + p.once.Do(func() { + close(p.reached) + <-p.resume + }) + return p.Client.CompleteWithMessages(ctx, messages, options...) +} + +// pauseTheAdapter puts a pause in front of the adapter the pinned send is +// answered by, wherever the agent keeps it. +func pauseTheAdapter(t *testing.T, agent *Agent) *pausedAdapter { + t.Helper() + pool := agent.clientPool + client, _, _, want, _, err := pool.clientWithService(credPinned) + if err != nil { + t.Fatal(err) + } + inner, ok := unwrapCompleter(client).(*provider.Client) + if !ok { + t.Fatalf("the pinned send is answered by %T, not a provider adapter", client) + } + paused := &pausedAdapter{Client: inner, reached: make(chan struct{}), resume: make(chan struct{})} + pool.mu.Lock() + pool.clients[want] = paused + pool.mu.Unlock() + agent.mu.Lock() + if wrapper, ok := agent.client.(sessionCompleter); ok && agent.clientAccount == want { + wrapper.inner = paused + agent.client = wrapper + } + agent.mu.Unlock() + return paused +} + +// A KEY HANDED TO THE LIVE ADAPTER AFTER THE DOOR CHOSE IT IS THE KEY THE +// REFUSAL IS STAMPED WITH, because that is the key the header carried. The +// door resolves the pinned send to the adapter on key A; before the adapter +// writes the request, key B is handed to it — the request leaves on B and B +// is refused. The stamp is the print of the bearer the stub actually +// received, which is B's: the account the adapter was chosen for would have +// said A, and the next call, on B again, would have been asked forever. Held +// on B, the retry asks nothing; once C is connected, the same pin is asked +// on C and answers. +func TestARefusalIsStampedWithTheBearerItsHeaderCarried(t *testing.T) { + stub := newBearerStub(t, map[string]int{credKeyA: http.StatusOK, credKeyB: http.StatusUnauthorized, credKeyC: http.StatusOK}) + agent, dir := bearerStubAgent(t, stub) + run := pinnedRun(t, agent) + paused := pauseTheAdapter(t, agent) + + ended := make(chan error, 1) + go func() { ended <- askPinned(t, agent, run) }() + select { + case <-paused.reached: + case err := <-ended: + t.Fatalf("the call ended before it reached its adapter: %v", err) + } + if err := agent.SetAPIKey(credKeyB); err != nil { + t.Fatal(err) + } + close(paused.resume) + wantStopped(t, <-ended, "reconnect openrouter with /connect") + + got := stub.requests() + if len(got) != 1 || got[0].model != credPinned { + t.Fatalf("the stub was asked %d requests, want the one pinned call", len(got)) + } + header := credPrint(stub, dir, got[0].bearer) + printA, printB := credPrint(stub, dir, credKeyA), credPrint(stub, dir, credKeyB) + if header != printB { + t.Fatalf("the header carried print %q, want B's %q: the fixture did not rotate the live adapter", header, printB) + } + failure, ok := failedOf(run, credPinned) + if !ok || failure.kind != provider.RouteAuth || failure.provider != "openrouter" || failure.sent.Cred != header { + t.Fatalf("the refusal was stamped %q (A is %q), want the header's print %q", failure.sent.Cred, printA, header) + } + raw, err := os.ReadFile(config.ProfilePath(dir, "router-events.jsonl")) + if err != nil { + t.Fatal(err) + } + if log := string(raw); !strings.Contains(log, `"fail_cred":"`+header+`"`) || strings.Contains(log, printA) || + strings.Contains(log, credKeyA) || strings.Contains(log, credKeyB) { + t.Fatal("the router log does not blame the header's print alone, or carries a key") + } + + if err := config.WriteAPIKey(dir, credKeyB); err != nil { + t.Fatal(err) + } + wantStopped(t, askPinned(t, agent, run), "reconnect openrouter with /connect") + if n := len(stub.requests()); n != 1 { + t.Fatalf("the refused key was asked again: %d requests", n) + } + + if err := config.WriteAPIKey(dir, credKeyC); err != nil { + t.Fatal(err) + } + if err := agent.SetAPIKey(credKeyC); err != nil { + t.Fatal(err) + } + if err := askPinned(t, agent, run); err != nil { + t.Fatalf("the explicit retry on C: %v", err) + } + got = stub.requests() + if len(got) != 2 || got[1].model != credPinned || credPrint(stub, dir, got[1].bearer) != credPrint(stub, dir, credKeyC) { + t.Fatalf("the stub was asked %d requests; want the same pin, on C, second", len(got)) + } +} + // A POOL THAT STILL HOLDS THE OLD KEY STAMPS THE OLD KEY. The profile already // says B, but the conversation was never told and its pool sends A: the // failure is A's, which the settings in force would have mislabelled B. And From d259870bb3e11ebdeca98d3f3a6780de8dd8cce4 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 22:03:27 -0400 Subject: [PATCH 095/459] Change a member's brief in place and open a chat's crew log line by id members.brief.set changes a member's brief as the person: a brief line in the crew log, read at its next step, waking nobody, restarting nothing, posting nothing in its conversation, and leaving its role alone (the brief replaces the start line). The membership keeps the newest 16 revisions (who, when, before, after, the log entry) across restarts, storing each brief's words once. A stale base is a conflict, a closed crew is closed, a member that left is not_found, and a brief is bounded to what a member is delivered. crews.log.at answers the crew whose log holds a line a chat's own record names (agent, post id, crew name), with its neighbours, by seeking to the id: never matched by words or time, refused when it left the log. --- cmd/codeaf/appbrief_test.go | 252 ++++++++++++++++++ cmd/codeaf/appcrewmembers.go | 115 +++++++- cmd/codeaf/appcrews.go | 67 +++++ cmd/codeaf/appmemberchat_test.go | 58 ++++ .../unreleased/0-app-member-brief-set.md | 18 ++ internal/appwire/crews.go | 94 ++++++- internal/session/team.go | 3 + internal/session/teambrief_test.go | 40 +++ internal/session/teamevent.go | 31 ++- internal/session/teamshape.go | 23 +- internal/teams/around.go | 61 +++++ internal/teams/brief.go | 183 +++++++++++++ internal/teams/brief_test.go | 183 +++++++++++++ internal/teams/member_json.go | 2 +- internal/teams/teams.go | 5 + internal/teams/traffic.go | 4 + 16 files changed, 1118 insertions(+), 21 deletions(-) create mode 100644 cmd/codeaf/appbrief_test.go create mode 100644 docs/changes/unreleased/0-app-member-brief-set.md create mode 100644 internal/session/teambrief_test.go create mode 100644 internal/teams/around.go create mode 100644 internal/teams/brief.go create mode 100644 internal/teams/brief_test.go diff --git a/cmd/codeaf/appbrief_test.go b/cmd/codeaf/appbrief_test.go new file mode 100644 index 0000000000..66540210ba --- /dev/null +++ b/cmd/codeaf/appbrief_test.go @@ -0,0 +1,252 @@ +package main + +import ( + "testing" + + "github.com/Agent-Field/codeaf/internal/appwire" + teamstore "github.com/Agent-Field/codeaf/internal/teams" +) + +// startedWeb is the crew of crewBackendOn with web started by the manager's +// start line, and web's agent id. +func startedWeb(t *testing.T, conv *fakeConversation, start string) (*appBackend, string, string) { + t.Helper() + backend, _, _, id := crewBackendOn(t, conv) + if err := teamstore.Update(backend.profileDir, func(f *teamstore.File) error { + for i := range f.Teams { + for j := range f.Teams[i].Members { + if f.Teams[i].Members[j].Handle == "web" { + f.Teams[i].Members[j].Started = true + } + } + } + return nil + }); err != nil { + t.Fatal(err) + } + if err := teamstore.AppendTraffic(backend.profileDir, id, teamstore.Entry{Kind: teamstore.KindStart, From: teamstore.FromManager, FromKey: appFakeTranscript, To: "web", Text: start}); err != nil { + t.Fatal(err) + } + return backend, id, "b0b0b0b0" +} + +// A MEMBER'S BRIEF CHANGES IN PLACE: members.brief.set records who, when, and +// the words before and after, the member is told by one delivered line it reads +// at its next step, nothing is said in its conversation and nothing restarts, +// and a restart reads the same history. A brief never changed has no revisions +// (no edit date is ever invented), and the manager's own seat has no brief here. +func TestAppMemberBriefSetIsDurableAndPostsNothing(t *testing.T) { + conv := &fakeConversation{} + const start = "Own the web client." + backend, id, web := startedWeb(t, conv, start) + before, err := backend.MemberBrief(id, web) + if err != nil || before.Text != start || before.From != "lead" || len(before.Revisions) != 0 { + t.Fatalf("before = %+v, %v", before, err) + } + conv.mu.Lock() + submits, texts := conv.submits, len(conv.texts) + conv.mu.Unlock() + + got, err := backend.MemberBriefSet(id, web, "Own the web client and skip the e2e suite.", nil) + if err != nil { + t.Fatal(err) + } + if got.Text != "Own the web client and skip the e2e suite." || got.From != "lead" || len(got.Revisions) != 1 { + t.Fatalf("after = %+v", got) + } + r := got.Revisions[0] + if r.Before != start || r.By != "you" || r.At.IsZero() || r.Post == "" { + t.Fatalf("revision = %+v", r) + } + // Nothing reached its conversation as a message. + conv.mu.Lock() + if conv.submits != submits || len(conv.texts) != texts { + t.Fatalf("a message was posted: %d submits, %v", conv.submits, conv.texts) + } + conv.mu.Unlock() + // The crew log holds the delivered line, as a brief to web. + lines, _, err := backend.CrewLog(id, "", 100) + if err != nil { + t.Fatal(err) + } + found := false + for _, l := range lines { + if l.ID == r.Post && l.Kind == teamstore.KindBrief && l.To == "web" && l.From == "you" && l.Agent == web { + found = true + } + } + if !found { + t.Fatalf("no brief line %s in %+v", r.Post, lines) + } + // A restart reads the same. + fresh := newFakeBackend(newAppRecorder(), nil) + t.Cleanup(func() { fresh.Close() }) + fresh.profileDir = backend.profileDir + again, err := fresh.MemberBrief(id, web) + if err != nil || again.Text != got.Text || len(again.Revisions) != 1 || again.Revisions[0] != r { + t.Fatalf("after restart = %+v, %v", again, err) + } + // The same words again change nothing; the manager has no brief to change. + if _, err := backend.MemberBriefSet(id, web, got.Text, nil); wireCode(err) != appwire.CodeBadParams { + t.Fatalf("unchanged = %v", err) + } + lead := appMemberAgent(teamstore.Member{Key: appFakeTranscript, File: appFakeTranscript}) + if _, err := backend.MemberBriefSet(id, lead, "manage", nil); wireCode(err) != appwire.CodeBadParams { + t.Fatalf("manager = %v", err) + } + if none, err := backend.MemberBrief(id, lead); err != nil || none.Text != "" || len(none.Revisions) != 0 { + t.Fatalf("manager brief = %+v %v", none, err) + } +} + +// A BRIEF IS NOT A ROLE: a member carrying a role keeps it word for word when +// its brief changes, the change replaces the start line (not the role's +// words), and nothing is queued into its conversation. +func TestAppMemberBriefSetLeavesTheRoleAlone(t *testing.T) { + conv := &fakeConversation{} + const start = "Own the web client." + backend, id, web := startedWeb(t, conv, start) + var roleID string + if err := backend.crewLayer().write(func(f *appCrewLayerFile) error { + if f.Roles == nil { + f.Roles = map[string]*appwire.Role{} + } + r := &appwire.Role{ID: f.next("role"), Name: "reviewer", Instructions: "Review diffs; never change code."} + f.Roles[r.ID] = r + f.member(id, web).Role = r.ID + roleID = r.ID + return nil + }); err != nil { + t.Fatal(err) + } + got, err := backend.MemberBriefSet(id, web, "Own the web client; skip e2e.", nil) + if err != nil { + t.Fatal(err) + } + if got.Revisions[0].Before != start { + t.Fatalf("before = %q, want the start line", got.Revisions[0].Before) + } + roles, err := backend.Roles() + if err != nil || len(roles) != 1 || roles[0].ID != roleID || roles[0].Instructions != "Review diffs; never change code." || len(roles[0].Copies) != 1 || roles[0].Copies[0].Agent != web { + t.Fatalf("roles = %+v %v", roles, err) + } + conv.mu.Lock() + defer conv.mu.Unlock() + if conv.submits != 0 || len(conv.texts) != 0 { + t.Fatalf("its conversation was written: %v", conv.texts) + } +} + +// TWO EDITORS, ONE BRIEF: a save made from a brief that changed since is a +// conflict and writes nothing; a closed crew's briefs are read-only; a member +// that left is not_found. +func TestAppMemberBriefSetRefusesStaleClosedAndGone(t *testing.T) { + backend, id, web := startedWeb(t, &fakeConversation{}, "Own the web client.") + none := "" + first, err := backend.MemberBriefSet(id, web, "first", &none) + if err != nil { + t.Fatal(err) + } + if _, err := backend.MemberBriefSet(id, web, "second", &none); wireCode(err) != appwire.CodeConflict { + t.Fatalf("stale = %v", err) + } + base := first.Revisions[0].Post + if _, err := backend.MemberBriefSet(id, web, "second", &base); err != nil { + t.Fatalf("current base = %v", err) + } + if got, _ := backend.MemberBrief(id, web); got.Text != "second" || len(got.Revisions) != 2 { + t.Fatalf("after = %+v", got) + } + if _, err := backend.MemberBriefSet(id, "nobody", "x", nil); wireCode(err) != appwire.CodeNotFound { + t.Fatalf("gone = %v", err) + } + if err := teamstore.Update(backend.profileDir, func(f *teamstore.File) error { + f.Teams[0].State = teamstore.TeamClosed + return nil + }); err != nil { + t.Fatal(err) + } + if _, err := backend.MemberBriefSet(id, web, "third", nil); wireCode(err) != appwire.CodeClosed { + t.Fatalf("closed = %v", err) + } + if got, err := backend.MemberBrief(id, web); err != nil || got.Text != "second" { + t.Fatalf("a closed crew's brief reads = %+v %v", got, err) + } +} + +// A RECEIPT OPENS ITS OWN LINE: two handoffs with the same words get their own +// ids, and the anchor answers the crew and the window of the very id asked — +// never the one with the same words, the newest, or a guess by time. A line +// the agent neither wrote nor received, a crew it is not in, and an id the log +// does not hold are refused. +func TestAppCrewLogAtIsTheExactLine(t *testing.T) { + backend, id, web := startedWeb(t, &fakeConversation{}, "Own the web client.") + post := func(text string) string { + got, err := teamstore.AppendTrafficID(backend.profileDir, id, teamstore.Entry{Kind: teamstore.KindNote, From: "web", To: teamstore.FromManager, Text: text}) + if err != nil { + t.Fatal(err) + } + return got + } + first := post("LEAF-READY") + post("between") + second := post("LEAF-READY") + post("after") + for _, want := range []string{first, second} { + a, err := backend.CrewLogAt(appwire.LogAnchorAsk{Agent: web, Post: want, CrewName: "harbor", Around: 1}) + if err != nil || a.Crew != id || a.Closed || len(a.Lines) != 3 || a.Lines[1].ID != want || a.Lines[1].Text != "LEAF-READY" { + t.Fatalf("anchor %s = %+v %v", want, a, err) + } + } + // "#n" as a person writes it reads the same line. + if a, err := backend.CrewLogAt(appwire.LogAnchorAsk{Agent: web, Post: teamstore.ThreadNumber(second)}); err != nil || !hasLine(a.Lines, second) { + t.Fatalf("by number = %+v %v", a, err) + } + // The start line is the manager's to web: web received it, so it is web's too. + if _, err := backend.CrewLogAt(appwire.LogAnchorAsk{Agent: web, Post: "1"}); err != nil { + t.Fatalf("a line to web = %v", err) + } + for name, ask := range map[string]appwire.LogAnchorAsk{ + "another crew's name": {Agent: web, Post: first, CrewName: "elsewhere"}, + "not a member": {Agent: "ffffffff", Post: first}, + "not in the log": {Agent: web, Post: "999"}, + } { + if _, err := backend.CrewLogAt(ask); wireCode(err) != appwire.CodeNotFound { + t.Fatalf("%s = %v", name, err) + } + } + if _, err := backend.CrewLogAt(appwire.LogAnchorAsk{Agent: web, Post: "LEAF-READY"}); wireCode(err) != appwire.CodeBadParams { + t.Fatalf("words for an id = %v", err) + } + // A closed crew's line still opens, as history. + if err := teamstore.Update(backend.profileDir, func(f *teamstore.File) error { + f.Teams[0].State = teamstore.TeamClosed + return nil + }); err != nil { + t.Fatal(err) + } + if a, err := backend.CrewLogAt(appwire.LogAnchorAsk{Agent: web, Post: second}); err != nil || !a.Closed || !hasLine(a.Lines, second) { + t.Fatalf("closed = %+v %v", a, err) + } +} + +// A LINE BETWEEN TWO OTHER MEMBERS IS NOT THIS AGENT'S: the anchor refuses it. +func TestAppCrewLogAtRefusesALineThatIsNotTheAgents(t *testing.T) { + backend, id, web := startedWeb(t, &fakeConversation{}, "Own the web client.") + other, err := teamstore.AppendTrafficID(backend.profileDir, id, teamstore.Entry{Kind: teamstore.KindNote, From: teamstore.FromManager, To: "lead", Text: "to itself"}) + if err != nil { + t.Fatal(err) + } + if _, err := backend.CrewLogAt(appwire.LogAnchorAsk{Agent: web, Post: other}); wireCode(err) != appwire.CodeNotFound { + t.Fatalf("someone else's line = %v", err) + } +} + +func hasLine(lines []appwire.LogLine, id string) bool { + for _, l := range lines { + if l.ID == id { + return true + } + } + return false +} diff --git a/cmd/codeaf/appcrewmembers.go b/cmd/codeaf/appcrewmembers.go index dc5fbbff29..9dc83a5a5d 100644 --- a/cmd/codeaf/appcrewmembers.go +++ b/cmd/codeaf/appcrewmembers.go @@ -20,6 +20,7 @@ package main import ( "encoding/json" + "errors" "strings" "github.com/Agent-Field/codeaf/internal/appwire" @@ -672,12 +673,13 @@ func (b *appBackend) createFromProposal(ask appwire.CrewCreate) (appwire.Crew, e return b.CrewGet(id) } -// MemberBrief is what a manager started a member with: the start line that -// made this very membership, read by its recorded id (teamstore.StartFor), so a -// renamed member keeps its own brief and a later member under its old handle -// does not receive it. A member nobody started, or one whose line cannot be -// proved to be its own, has none; nothing is inferred from its conversation or -// from who manages the crew now. +// MemberBrief is a member's brief: the start line that made this very +// membership, read by its recorded id (teamstore.StartFor), so a renamed member +// keeps its own brief and a later member under its old handle does not receive +// it; or, when it was changed since, the newest change, with every change kept +// on the membership (teamstore.ReviseBrief). A member nobody started and +// nobody briefed has none; nothing is inferred from its conversation or from +// who manages the crew now. func (b *appBackend) MemberBrief(crew, agent string) (appwire.MemberBrief, error) { t, _, err := b.crewTeam(crew) if err != nil { @@ -687,17 +689,106 @@ func (b *appBackend) MemberBrief(crew, agent string) (appwire.MemberBrief, error if !ok { return appwire.MemberBrief{}, appwire.Errorf(appwire.CodeNotFound, "%s is not in %s", agent, t.Name) } + return b.memberBrief(t, m) +} + +func (b *appBackend) memberBrief(t teamstore.Team, m teamstore.Member) (appwire.MemberBrief, error) { + var out appwire.MemberBrief e, ok, err := teamstore.StartFor(b.crewProfile(), t, m) if err != nil { return appwire.MemberBrief{}, appEngineError(err) } + if ok { + at := e.At + if boss, ok := t.Member(e.FromKey); ok { + out.From = boss.Handle + } + out.Text, out.At = redact.Secrets(strings.TrimSpace(e.Text)), &at + } + for _, r := range m.BriefRevisions() { + out.Revisions = append(out.Revisions, appwire.BriefRevision{ + Text: redact.Secrets(r.Text), Before: redact.Secrets(r.Before), By: appBriefAuthor(t, r), At: r.At, Post: r.Entry, + }) + } + if cur, ok := m.CurrentBrief(); ok { + at := cur.At + out.Text, out.At = redact.Secrets(cur.Text), &at + } + return out, nil +} + +// appBriefAuthor names who changed a brief: "you", or the handle the changer +// has in this crew now (the manager's seat by the manager's handle). +func appBriefAuthor(t teamstore.Team, r teamstore.BriefRevision) string { + if r.By == teamstore.FromYou { + return "you" + } + if r.ByKey != "" { + if m, ok := t.Member(r.ByKey); ok && m.Handle != "" { + return m.Handle + } + } + if r.By == teamstore.FromManager { + if m, ok := t.Member(t.Manager); ok && m.Handle != "" { + return m.Handle + } + } + return r.By +} + +// MemberBriefSet changes a member's brief in place, as the person: the member +// reads the new words at its next step. Nothing is posted to its conversation, +// nothing is restarted, and a role the member carries is left as it is: the +// brief replaces the one it was started with, never its role. base, when set, +// is the revision the person edited (its post, "" for a brief never changed); +// a brief changed since is refused as a conflict and nothing is written. +func (b *appBackend) MemberBriefSet(crew, agent, text string, base *string) (appwire.MemberBrief, error) { + t, _, err := b.crewTeam(crew) + if err != nil { + return appwire.MemberBrief{}, err + } + if t.Closed() { + return appwire.MemberBrief{}, appwire.Errorf(appwire.CodeClosed, "%s is closed; its briefs are read-only", t.Name) + } + m, ok := b.crewMemberOf(t, agent) if !ok { - return appwire.MemberBrief{}, nil + return appwire.MemberBrief{}, appwire.Errorf(appwire.CodeNotFound, "%s is not in %s", agent, t.Name) } - at := e.At - from := "" - if boss, ok := t.Member(e.FromKey); ok { - from = boss.Handle + if m.Key == t.Manager { + return appwire.MemberBrief{}, appwire.Errorf(appwire.CodeBadParams, "%s manages %s; a manager has no brief to change here", m.Handle, t.Name) + } + // The words it replaces, when it was never changed: its start line. + before := "" + if _, has := m.CurrentBrief(); !has { + e, ok, err := teamstore.StartFor(b.crewProfile(), t, m) + if err != nil { + return appwire.MemberBrief{}, appEngineError(err) + } + if ok { + before = e.Text + } + } + change := teamstore.BriefChange{By: teamstore.FromYou, Text: redact.Secrets(text), Before: before, Base: base, At: b.now()} + if _, err := teamstore.ReviseBrief(b.crewProfile(), t.ID, m.Key, change); err != nil { + switch { + case errors.Is(err, teamstore.ErrBriefUnchanged), errors.Is(err, teamstore.ErrBriefTooLong): + return appwire.MemberBrief{}, appwire.Errorf(appwire.CodeBadParams, "%s", err.Error()) + case errors.Is(err, teamstore.ErrBriefStale): + return appwire.MemberBrief{}, appwire.Errorf(appwire.CodeConflict, "%s's brief was changed since you opened it; read it again", m.Handle) + case errors.Is(err, teamstore.ErrClosed): + return appwire.MemberBrief{}, appwire.Errorf(appwire.CodeClosed, "%s is closed; its briefs are read-only", t.Name) + case errors.Is(err, teamstore.ErrBriefNoMember): + return appwire.MemberBrief{}, appwire.Errorf(appwire.CodeNotFound, "%s is not in %s", agent, t.Name) + } + return appwire.MemberBrief{}, appEngineError(err) + } + t, _, err = b.crewTeam(crew) + if err != nil { + return appwire.MemberBrief{}, err + } + m, ok = b.crewMemberOf(t, agent) + if !ok { + return appwire.MemberBrief{}, appwire.Errorf(appwire.CodeNotFound, "%s is not in %s", agent, t.Name) } - return appwire.MemberBrief{Text: redact.Secrets(strings.TrimSpace(e.Text)), From: from, At: &at}, nil + return b.memberBrief(t, m) } diff --git a/cmd/codeaf/appcrews.go b/cmd/codeaf/appcrews.go index c005105693..0240650719 100644 --- a/cmd/codeaf/appcrews.go +++ b/cmd/codeaf/appcrews.go @@ -1615,6 +1615,73 @@ func (b *appBackend) CrewLog(id, after string, limit int) ([]appwire.LogLine, st return lines, cursor, nil } +// appLogAnchorAround is how many neighbours an anchor reads on each side when +// the ask names none. +const appLogAnchorAround = 2 + +// CrewLogAt is one crew log line a conversation's own record points at (a +// handoff's post, a delivery's post), with its neighbours. THE LINE IS FOUND +// BY FACTS, NEVER BY WORDS OR TIME: a crew the agent is a member of, with the +// name the record gave, whose log holds that very id written by or to that +// member. Two crews that both qualify are refused rather than guessed between, +// and a line rotated out of the log is not_found, never a nearby one. +func (b *appBackend) CrewLogAt(ask appwire.LogAnchorAsk) (appwire.LogAnchor, error) { + post, ok := teamstore.ThreadID(ask.Post) + if !ok { + return appwire.LogAnchor{}, appwire.Errorf(appwire.CodeBadParams, "%q is not a crew log line", ask.Post) + } + around := ask.Around + if around == 0 { + around = appLogAnchorAround + } + profile := b.crewProfile() + f, err := teamstore.Load(profile) + if err != nil { + return appwire.LogAnchor{}, appEngineError(err) + } + var ( + found []appwire.LogAnchor + inCrews int + ) + for _, t := range f.Teams { + if ask.CrewName != "" && t.Name != ask.CrewName { + continue + } + m, ok := b.crewMemberOf(t, ask.Agent) + if !ok { + continue + } + inCrews++ + entries, ok, err := teamstore.ReadAround(profile, t.ID, post, around) + if err != nil { + return appwire.LogAnchor{}, appEngineError(err) + } + if !ok { + continue + } + var mine bool + lines := make([]appwire.LogLine, 0, len(entries)) + for _, e := range entries { + if e.ID == post { + mine = teamstore.Involves(e, m) + } + lines = append(lines, appLogLine(t, e)) + } + if mine { + found = append(found, appwire.LogAnchor{Crew: t.ID, Closed: t.Closed(), Lines: lines}) + } + } + switch { + case len(found) == 1: + return found[0], nil + case len(found) > 1: + return appwire.LogAnchor{}, appwire.Errorf(appwire.CodeConflict, "line %s is in more than one of this agent's crews", teamstore.ThreadNumber(post)) + case inCrews == 0: + return appwire.LogAnchor{}, appwire.Errorf(appwire.CodeNotFound, "this agent is no longer in a crew named %q", ask.CrewName) + } + return appwire.LogAnchor{}, appwire.Errorf(appwire.CodeNotFound, "line %s is no longer in the crew's log", teamstore.ThreadNumber(post)) +} + // CrewDecisions is the decisions waiting (or all of them) for one crew, or // every decision waiting on the person. func (b *appBackend) CrewDecisions(id string, all bool) ([]appwire.Decision, error) { diff --git a/cmd/codeaf/appmemberchat_test.go b/cmd/codeaf/appmemberchat_test.go index 4911fe4047..20a35cecd0 100644 --- a/cmd/codeaf/appmemberchat_test.go +++ b/cmd/codeaf/appmemberchat_test.go @@ -70,3 +70,61 @@ func TestTeamPostCallCarriesItsHandoff(t *testing.T) { t.Fatalf("a running post carried %+v", h) } } + +// THE RECORD ITSELF, READ BACK: a real journal (a person's question, an answer, +// then a manager's directive the session journaled as its own note, work, a +// team_post and the turn's last message) shaped by the session and listed by +// the app. The answer before the directive stays an answer, the delivery says +// its sender and kind as facts, the post says where it went, and the turn's +// last message is its answer. +func TestJournalReadBackKeepsAnswersAndTypedDeliveryFacts(t *testing.T) { + const record = `{"type":"session","version":1,"id":"n1","cwd":"/tmp/lab"} +{"type":"message","role":"user","content":"Read the attached file and reply only with its verification token.","timestamp":"2026-10-15T23:06:41Z"} +{"type":"message","role":"assistant","content":"","toolCalls":[{"id":"c1","function":{"name":"read","arguments":"{\"path\":\"crew-attachment-check.txt\"}"}}],"timestamp":"2026-10-15T23:06:42Z"} +{"type":"message","role":"tool","toolCallId":"c1","content":"The nested crew attachment token is BIRCH-9304.","timestamp":"2026-10-15T23:06:42Z"} +{"type":"message","role":"assistant","content":"BIRCH-9304","timestamp":"2026-10-15T23:06:43Z"} +{"type":"message","role":"user","note":true,"content":"Team traffic in \"Verification\" for you (@observer). These are the team's messages, not the person's words:\n◆ directive from manager #8: Job A (read-only validation). Run the suite twice.\n(The person's own words in this conversation outrank the manager. Reply with team_post.)","timestamp":"2026-10-15T23:46:44Z"} +{"type":"message","role":"assistant","content":"Run 1 was 62 tests / OK. Running it a second time now.","toolCalls":[{"id":"c2","function":{"name":"bash","arguments":"{\"command\":\"make test\"}"}}],"timestamp":"2026-10-15T23:46:49Z"} +{"type":"message","role":"tool","toolCallId":"c2","content":"OK","timestamp":"2026-10-15T23:46:51Z"} +{"type":"message","role":"assistant","content":"Both runs matched. Reporting now.","toolCalls":[{"id":"c3","function":{"name":"team_post","arguments":"{\"to\":\"manager\",\"text\":\"Job A done, both runs match.\"}"}}],"timestamp":"2026-10-15T23:46:55Z"} +{"type":"message","role":"tool","toolCallId":"c3","content":"Posted to the manager in \"Verification\" as #10, answering #8. It arrives at the start of their next step.","timestamp":"2026-10-15T23:46:55Z"} +{"type":"message","role":"assistant","content":"Job A done and reported to the manager.","timestamp":"2026-10-15T23:46:56Z"} +` + items := session.ReadTranscriptBytes([]byte(record)).Entries + entries := appEntriesOf("a", items, time.Now(), nil) + var kinds []string + for _, e := range entries { + if e.Kind != appwire.EntryTool { + kinds = append(kinds, e.Kind+":"+e.Text) + } + } + want := []string{ + "user:Read the attached file and reply only with its verification token.", + "answer:BIRCH-9304", + "notice:manager: Job A (read-only validation). Run the suite twice.", + "update:Run 1 was 62 tests / OK. Running it a second time now.", + "update:Both runs matched. Reporting now.", + "answer:Job A done and reported to the manager.", + } + if strings.Join(kinds, "|") != strings.Join(want, "|") { + t.Fatalf("kinds\n%v\nwant\n%v", kinds, want) + } + var notice appwire.Entry + for _, e := range entries { + if e.Kind == appwire.EntryNotice { + notice = e + } + } + if len(notice.Team) != 1 || notice.Team[0].From != "manager" || notice.Team[0].Kind != "directive" || strings.TrimLeft(notice.Team[0].Post, "0") != "8" || notice.Team[0].CrewName != "Verification" { + t.Fatalf("delivery facts = %+v", notice.Team) + } + var post *appwire.Handoff + for _, item := range items { + if item.Tool == "team_post" { + post = appHandoff(item.Tool, item.Args, item.Output) + } + } + if post == nil || !post.Delivered || post.To != "manager" || strings.TrimLeft(post.Post, "0") != "10" || strings.TrimLeft(post.Answers, "0") != "8" { + t.Fatalf("handoff = %+v", post) + } +} diff --git a/docs/changes/unreleased/0-app-member-brief-set.md b/docs/changes/unreleased/0-app-member-brief-set.md new file mode 100644 index 0000000000..ff3b222509 --- /dev/null +++ b/docs/changes/unreleased/0-app-member-brief-set.md @@ -0,0 +1,18 @@ +--- +kind: added +title: a member's brief changes in place, with its history kept +pr: 0 +surface: [engine] +invalidates: + - "A member's brief could not be changed: the app reposted new instructions as a message in the member's conversation. members.brief.set {crew, agent, text} now changes it in place: a `brief` line in the crew log, read by the member at its next step, waking nobody and restarting nothing." + - "A receipt or delivery in a member's chat had no way to open its own crew log line: crews.log.at {agent, post, crew_name?, around?} answers the crew and the line with its neighbours, found by the agent's membership and the line's id (never words or time)." + - "members.brief answered only the start line. It now answers the brief in force and `revisions` (who, when, before and after, the log entry), kept on the membership in the teams file (teams.ReviseBrief, Member.Briefs), so the history survives a restart." +--- + +`teams.KindBrief` is a new traffic kind; `teams.ReviseBrief` is the one writer +(log line and revision under one lock), refusing a stale `base`, a closed team, +and a brief longer than the 12000 characters a member is delivered. Only the +oldest kept revision stores its `before`; the rest read it from the revision +before. A membership with no revisions was never changed here; nothing is +inferred for older records. `teams.ReadAround` reads a line with its neighbours +by seeking to its id. diff --git a/internal/appwire/crews.go b/internal/appwire/crews.go index 4170b0c736..5692fe425b 100644 --- a/internal/appwire/crews.go +++ b/internal/appwire/crews.go @@ -391,6 +391,7 @@ type Crewer interface { CrewUpdate(ask CrewUpdate) (Crew, error) CrewMessage(ask CrewMessage) (Entry, error) CrewLog(id, after string, limit int) ([]LogLine, string, error) + CrewLogAt(ask LogAnchorAsk) (LogAnchor, error) CrewDecisions(id string, all bool) ([]Decision, error) CrewDecide(packet, option, text, reason string) (Decision, error) CrewManager(id, agent string) (Crew, error) @@ -407,6 +408,7 @@ type Crewer interface { CrewPropose(agent string) error Member(verb string, ask MemberAsk) (CrewMember, error) MemberBrief(crew, agent string) (MemberBrief, error) + MemberBriefSet(crew, agent, text string, base *string) (MemberBrief, error) Roles() ([]Role, error) RoleUpdate(id string, name, instructions, model *string) (Role, error) } @@ -459,13 +461,53 @@ func crewOf(c Crew, err error) (*crewResult, error) { return &crewResult{c}, nil } -// MemberBrief is the words a manager started a member with, read from the -// crew's own start line. Text is empty when the record holds none: a member a -// person added, a crew's first manager, or a start older than the log. +// LogAnchorAsk names one crew log line from a conversation's own record: the +// agent whose chat holds it (the receipt's sender, the delivery's addressee), +// the line's id (a handoff's or a delivery's post) and the crew name the record +// gave. Around is how many lines on each side to read with it (at most +// [LogAnchorAroundMax]). +type LogAnchorAsk struct { + Agent string `json:"agent"` + Post string `json:"post"` + CrewName string `json:"crew_name,omitempty"` + Around int `json:"around,omitempty"` +} + +// LogAnchorAroundMax bounds a log anchor's neighbours on each side. +const LogAnchorAroundMax = 10 + +// LogAnchor is the crew whose log holds an anchored line and that line with its +// neighbours, oldest first, read by id (never by words or time). Closed says +// the crew is closed: its log is read-only history. +type LogAnchor struct { + Crew string `json:"crew"` + Closed bool `json:"closed,omitempty"` + Lines []LogLine `json:"lines"` +} + +// MemberBrief is a member's brief: the words its manager started it with, or +// the newest change made to them since (members.brief.set). Text is empty when +// the record holds neither: a member a person added, a crew's first manager, +// or a start older than the log. From is who started it (a handle); At when +// the brief now in force was written. Revisions are the changes, oldest first +// (at most 16); none means the brief was never changed — nothing is inferred +// for a brief from before changes were kept. type MemberBrief struct { - Text string `json:"text,omitempty"` - From string `json:"from,omitempty"` - At *time.Time `json:"at,omitempty"` + Text string `json:"text,omitempty"` + From string `json:"from,omitempty"` + At *time.Time `json:"at,omitempty"` + Revisions []BriefRevision `json:"revisions,omitempty"` +} + +// BriefRevision is one change to a member's brief: who made it ("you" or a +// member's handle), when, the words before and after, and the crew log entry +// that delivered it (a LogLine id), which the member read at its next step. +type BriefRevision struct { + Text string `json:"text"` + Before string `json:"before,omitempty"` + By string `json:"by"` + At time.Time `json:"at"` + Post string `json:"post,omitempty"` } // MemberResult is a member verb's answer. @@ -533,6 +575,24 @@ var crewMethods = map[string]method{ } return &LinesCursorResult{nonNil(lines), cursor}, nil }), + "crews.log.at": crewCall("crews.log.at", func(c Crewer, p LogAnchorAsk) (*struct { + Anchor LogAnchor `json:"anchor"` + }, error) { + if strings.TrimSpace(p.Agent) == "" || strings.TrimSpace(p.Post) == "" { + return nil, Errorf(CodeBadParams, "crews.log.at needs an agent and a post") + } + if p.Around < 0 || p.Around > LogAnchorAroundMax { + return nil, Errorf(CodeBadParams, "crews.log.at reads at most %d lines on each side", LogAnchorAroundMax) + } + anchor, err := c.CrewLogAt(p) + if err != nil { + return nil, err + } + anchor.Lines = nonNil(anchor.Lines) + return &struct { + Anchor LogAnchor `json:"anchor"` + }{anchor}, nil + }), "crews.decisions": crewCall("crews.decisions", func(c Crewer, p struct { ID string `json:"id"` All bool `json:"all"` @@ -691,6 +751,28 @@ var crewMethods = map[string]method{ Brief MemberBrief `json:"brief"` }{brief}, nil }), + "members.brief.set": crewCall("members.brief.set", func(c Crewer, p struct { + Crew string `json:"crew"` + Agent string `json:"agent"` + Text string `json:"text"` + Base *string `json:"base"` + }) (*struct { + Brief MemberBrief `json:"brief"` + }, error) { + if strings.TrimSpace(p.Crew) == "" || strings.TrimSpace(p.Agent) == "" { + return nil, Errorf(CodeBadParams, "members.brief.set needs a crew and an agent") + } + if strings.TrimSpace(p.Text) == "" { + return nil, Errorf(CodeBadParams, "a brief needs words") + } + brief, err := c.MemberBriefSet(p.Crew, p.Agent, p.Text, p.Base) + if err != nil { + return nil, err + } + return &struct { + Brief MemberBrief `json:"brief"` + }{brief}, nil + }), "members.add": memberVerb("add"), "members.start": memberVerb("start"), "members.stop": memberVerb("stop"), diff --git a/internal/session/team.go b/internal/session/team.go index 9ebde11827..899bb6a09d 100644 --- a/internal/session/team.go +++ b/internal/session/team.go @@ -802,6 +802,9 @@ func teamLine(role teamRole, entry teams.Entry) string { } return "" } + if entry.Kind == teams.KindBrief { + return teamBriefChangedLine(role, entry) + } if entry.Kind != teams.KindNote && entry.Kind != teams.KindDirective { return "" } diff --git a/internal/session/teambrief_test.go b/internal/session/teambrief_test.go new file mode 100644 index 0000000000..68a845692b --- /dev/null +++ b/internal/session/teambrief_test.go @@ -0,0 +1,40 @@ +package session + +import ( + "testing" + + "github.com/Agent-Field/codeaf/internal/teams" +) + +// A BRIEF CHANGED IN PLACE REACHES ONLY ITS MEMBER, AT ITS NEXT STEP: it is a +// delivered line that wakes nobody, the manager never reads it back, and the +// surface reads it as a brief line naming who changed it and its log entry. +func TestBriefChangedIsDeliveredToItsMemberAndWakesNobody(t *testing.T) { + member := teamRole{id: "t", name: "Release", handle: "review", managed: true} + other := teamRole{id: "t", name: "Release", handle: "backend", managed: true} + manager := teamRole{id: "t", name: "Release", handle: "release", manager: true, managed: true} + entry := teams.Entry{ID: "000000000012", Kind: teams.KindBrief, From: teams.FromYou, To: "review", ToKey: "krev", Text: "Check the retry backoff.\nSkip the e2e suite."} + line := teamLine(member, entry) + if line != "◆ brief changed by the person #12: Check the retry backoff.\n Skip the e2e suite." { + t.Fatalf("line = %q", line) + } + if teamLine(other, entry) != "" || teamLine(manager, entry) != "" { + t.Fatal("a brief reached someone else") + } + for _, r := range []teamRole{member, manager} { + if teamWakes(r, entry) { + t.Fatalf("%s was woken by a brief", r.handle) + } + } + // What the surface reads back from the delivery. + got := teamNewsLines(teamNewsGroup(member, []string{line})) + want := TeamLine{Team: "Release", From: teams.FromYou, Kind: teams.KindBrief, Text: "Check the retry backoff.\nSkip the e2e suite.", Thread: "000000000012"} + if len(got) != 1 || got[0] != want { + t.Fatalf("read back %+v, want %+v", got, want) + } + byManager := entry + byManager.From = teams.FromManager + if got := teamNewsLines(teamNewsGroup(member, []string{teamLine(member, byManager)})); len(got) != 1 || got[0].From != teams.FromManager || got[0].Kind != teams.KindBrief { + t.Fatalf("manager's change read back %+v", got) + } +} diff --git a/internal/session/teamevent.go b/internal/session/teamevent.go index fbe8314e5d..5bdaf913bb 100644 --- a/internal/session/teamevent.go +++ b/internal/session/teamevent.go @@ -356,7 +356,7 @@ func recentOf(log []teams.Entry) []teams.Entry { // teamBriefText is how many characters of a brief are delivered. A brief is // the whole assignment, so it is allowed more than a message. -const teamBriefText = 12000 +const teamBriefText = teams.BriefTextMax // teamBriefLine is a [teams.KindStart] entry as the member it started is told // it: the manager's brief, marked as the manager's, on the new conversation's @@ -378,6 +378,35 @@ func teamBriefLine(role teamRole, entry teams.Entry) string { // "◆ directive from manager". const teamBriefWord = "◆ brief from manager" +// teamBriefChangedLine is a member's brief replaced in place (teams' brief.go), +// told to that member only: "◆ brief changed by the person #12: …". It is the +// whole new brief, which stands in place of the one it was started with. +func teamBriefChangedLine(role teamRole, entry teams.Entry) string { + if role.manager || role.handle == "" || entry.To != role.handle { + return "" + } + text := strings.TrimSpace(entry.Text) + if text == "" { + return "" + } + return teamBriefChangedWord + teamBriefChanger(entry.From) + teamNumber(entry) + ": " + indentAfterFirst(cutRunesTeam(text, teamBriefText)) +} + +// teamBriefChangedWord opens a changed brief's line; teamBriefChanger names who. +const teamBriefChangedWord = "◆ brief changed by " + +func teamBriefChanger(from string) string { + switch from { + case teams.FromYou: + return "the person" + case teams.FromManager: + return "manager" + case teams.FromSystem: + return "codeaf" + } + return "@" + strings.TrimPrefix(from, "@") +} + // teamCursorBefore is the cursor that reads id itself next: the id one below // it, zero-padded the way [teams.AppendTraffic] pads. func teamCursorBefore(id string) string { diff --git a/internal/session/teamshape.go b/internal/session/teamshape.go index 4b6e733a9a..e74377f4a2 100644 --- a/internal/session/teamshape.go +++ b/internal/session/teamshape.go @@ -28,7 +28,8 @@ type TeamLine struct { // To is where the line was aimed: "room", "everyone", "manager" or a // member's handle, and "" for the conversation reading it. To string - // Kind is [teams.KindNote], [teams.KindDirective], or [teams.KindStart] for + // Kind is [teams.KindNote], [teams.KindDirective], [teams.KindBrief] for a + // brief changed in place (teams' brief.go), or [teams.KindStart] for // the brief a manager started this conversation with. Kind string // Text is the line's words, whole lines kept. @@ -90,6 +91,26 @@ func teamLineParts(line string) (TeamLine, bool) { } parsed := TeamLine{Kind: teams.KindNote, Text: text} switch { + case strings.HasPrefix(head, teamBriefChangedWord): + who, rest, _ := strings.Cut(strings.TrimPrefix(head, teamBriefChangedWord), " #") + switch who { + case "the person": + parsed.From = teams.FromYou + case "manager": + parsed.From = teams.FromManager + case "codeaf": + parsed.From = teams.FromSystem + default: + handle, ok := strings.CutPrefix(who, "@") + if !ok || handle == "" || strings.Contains(handle, " ") { + return TeamLine{}, false + } + parsed.From = handle + } + parsed.Kind, head = teams.KindBrief, "" + if rest != "" { + head = " #" + rest + } case strings.HasPrefix(head, teamBriefWord): parsed.From, parsed.Kind, head = teams.FromManager, teams.KindStart, strings.TrimPrefix(head, teamBriefWord) case strings.HasPrefix(head, "◆ directive from manager"): diff --git a/internal/teams/around.go b/internal/teams/around.go new file mode 100644 index 0000000000..09d82e0fc2 --- /dev/null +++ b/internal/teams/around.go @@ -0,0 +1,61 @@ +package teams + +import ( + "fmt" + "strconv" +) + +// ReadAround is entry id of a team's log with up to around entries on each +// side, oldest first, and false when id is not in the retained log. It seeks +// to just before them and reads forward ([forwardTraffic]); the log is never +// read whole. +func ReadAround(profileDir, teamID, id string, around int) ([]Entry, bool, error) { + n, err := strconv.ParseInt(id, 10, 64) + if err != nil || n <= 0 || around < 0 { + return nil, false, nil + } + if err := safeTeamID(teamID); err != nil { + return nil, false, err + } + from := n - 1 - int64(around) + if from < 0 { + from = 0 + } + path := TrafficPath(profileDir, teamID) + got, err := forwardTraffic([]string{trafficRotated(path), path}, fmt.Sprintf("%0*d", trafficIDWidth, from), 2*around+1) + if err != nil { + return nil, false, err + } + last := fmt.Sprintf("%0*d", trafficIDWidth, n+int64(around)) + window, found := got[:0], false + for _, e := range got { + if e.ID > last { + break + } + found = found || e.ID == id + window = append(window, e) + } + if !found { + return nil, false, nil + } + return window, true, nil +} + +// Involves says whether entry e was written by or to the member m: its sender, +// its addressee, the member it is about, or one of the room it went to. +func Involves(e Entry, m Member) bool { + if m.Key != "" && (e.FromKey == m.Key || e.ToKey == m.Key || e.Member == m.Key) { + return true + } + switch e.To { + case ToRoom, ToEveryone: + return true + case ToSeveral: + for _, h := range e.Handles { + if h == m.Handle { + return true + } + } + } + return false +} diff --git a/internal/teams/brief.go b/internal/teams/brief.go new file mode 100644 index 0000000000..6aa0270e73 --- /dev/null +++ b/internal/teams/brief.go @@ -0,0 +1,183 @@ +package teams + +// brief.go is a member's brief changed in place. +// +// A MEMBER'S BRIEF IS ITS STANDING ASSIGNMENT, and a person or its manager may +// rewrite it without a restart and without a message in its conversation. The +// change is two writes under the teams lock: a [KindBrief] line in the team's +// log, addressed to the member, which the member's session reads at the start +// of its next step like any delivered line (it wakes nobody); and a +// [BriefRevision] on the membership, which is the change's durable record — +// who, when, the words before and after — so any reader (the terminal, the +// app, a report) can say "edited by" and show the change after a restart +// without reading the log. A membership with no revisions was never edited +// here; nothing is ever inferred for one. +// +// The brief replaces the one the member was started with, never a role it +// carries: a role is told to the member on its own and stays in force. + +import ( + "errors" + "fmt" + "strings" + "time" + "unicode/utf8" +) + +// BriefRevisionsKept is how many changes a membership keeps, newest last. +const BriefRevisionsKept = 16 + +// BriefTextMax is the most characters a brief may have: all of it is what the +// member reads (the session delivers a brief up to this length, never cut). +const BriefTextMax = 12000 + +// BriefRevision is one change to a member's brief. +type BriefRevision struct { + // Entry is the [KindBrief] line in the team's log that delivered it. + Entry string `json:"entry,omitempty"` + // Text is the brief from this change on; Before what it replaced ("" when + // the brief it replaced was not known: a member nobody started). On disk + // only the oldest kept revision stores Before; every later one's is the + // revision before it ([Member.BriefRevisions] fills them in). + Text string `json:"text"` + Before string `json:"before,omitempty"` + // By is who changed it: [FromYou], [FromManager], or a member's handle; + // ByKey that party's conversation key when it has one. + By string `json:"by"` + ByKey string `json:"by_key,omitempty"` + At time.Time `json:"at"` +} + +var ( + // ErrBriefUnchanged is a revision with the words the brief already has. + ErrBriefUnchanged = errors.New("the brief already says that") + // ErrBriefStale is a change made from a brief that has changed since. + ErrBriefStale = errors.New("the brief was changed since it was read; read it again") + // ErrBriefTooLong is a brief longer than [BriefTextMax]. + ErrBriefTooLong = fmt.Errorf("a brief can be at most %d characters", BriefTextMax) + // ErrBriefNoMember is a change for a conversation that is not a named member. + ErrBriefNoMember = errors.New("that conversation is not a named member of the team") +) + +// BriefChange is one change asked of a member's brief. +type BriefChange struct { + // By and ByKey are who changes it (see [BriefRevision]). + By, ByKey string + Text string + // Before is the brief as the caller read it, used only when the + // membership has no revision yet (the start line's words); a membership + // with revisions knows its own. + Before string + // Base, when set, is the revision the change was made from: the newest + // revision's Entry, or "" for a brief never changed. A brief that changed + // since is [ErrBriefStale] and nothing is written. + Base *string + At time.Time +} + +// ReviseBrief replaces the brief of member key in team and answers the +// revision recorded. +func ReviseBrief(profileDir, team, key string, c BriefChange) (BriefRevision, error) { + text := strings.TrimSpace(c.Text) + if text == "" { + return BriefRevision{}, errors.New("a brief needs words") + } + if utf8.RuneCountInString(text) > BriefTextMax { + return BriefRevision{}, ErrBriefTooLong + } + at := c.At + if at.IsZero() { + at = time.Now() + } + var rev BriefRevision + err := withLock(profileDir, lockWait, func() error { + f, _, _, err := read(profileDir) + if err != nil { + return err + } + if f == nil { + return errors.New("there are no teams") + } + t, ok := f.Team(team) + if !ok { + return errors.New("there is no team " + team) + } + if t.Closed() { + return ErrClosed + } + m, ok := t.Member(key) + if !ok || m.Handle == "" { + return ErrBriefNoMember + } + cur, changed := m.CurrentBrief() + if c.Base != nil && *c.Base != cur.Entry { + return ErrBriefStale + } + before := strings.TrimSpace(c.Before) + if changed { + before = cur.Text + } + if before == text { + return ErrBriefUnchanged + } + id, err := appendTrafficIDLocked(profileDir, team, Entry{At: at, Kind: KindBrief, From: c.By, FromKey: c.ByKey, To: m.Handle, ToKey: m.Key, Member: m.Key, Text: text}) + if err != nil { + return err + } + rev = BriefRevision{Entry: id, Text: text, Before: before, By: c.By, ByKey: c.ByKey, At: at.UTC()} + _, err = updateLocked(profileDir, func(f *File) error { + for i := range f.Teams { + if f.Teams[i].ID != team { + continue + } + for j := range f.Teams[i].Members { + mm := &f.Teams[i].Members[j] + if mm.Key != key { + continue + } + stored := rev + if len(mm.Briefs) > 0 { + stored.Before = "" + } + mm.Briefs = append(mm.Briefs, stored) + if over := len(mm.Briefs) - BriefRevisionsKept; over > 0 { + kept := append([]BriefRevision(nil), mm.Briefs[over:]...) + kept[0].Before = mm.Briefs[over-1].Text + mm.Briefs = kept + } + return nil + } + } + return ErrBriefNoMember + }) + return err + }) + return rev, err +} + +// BriefRevisions are m's brief changes, oldest first, each with the words it +// replaced; nil when it never changed. +func (m Member) BriefRevisions() []BriefRevision { + if len(m.Briefs) == 0 { + return nil + } + out := append([]BriefRevision(nil), m.Briefs...) + for i := 1; i < len(out); i++ { + out[i].Before = out[i-1].Text + } + return out +} + +// CurrentBrief is the newest revision of m's brief, and false when it was +// never changed (its brief is still its start line, [StartFor]). +func (m Member) CurrentBrief() (BriefRevision, bool) { + n := len(m.Briefs) + if n == 0 { + return BriefRevision{}, false + } + cur := m.Briefs[n-1] + if n > 1 { + cur.Before = m.Briefs[n-2].Text + } + return cur, true +} diff --git a/internal/teams/brief_test.go b/internal/teams/brief_test.go new file mode 100644 index 0000000000..ab815a343d --- /dev/null +++ b/internal/teams/brief_test.go @@ -0,0 +1,183 @@ +package teams + +import ( + "encoding/json" + "errors" + "strconv" + "strings" + "testing" + "time" +) + +func briefTeam(t *testing.T) string { + t.Helper() + dir := t.TempDir() + must(t, Update(dir, func(f *File) error { + f.Teams = append(f.Teams, Team{ID: "t1", Name: "Release", Manager: "kmgr", Members: []Member{{Key: "kmgr", Handle: "release"}, {Key: "krev", Handle: "review"}}}) + return nil + })) + return dir +} + +func revise(dir, key, by, byKey, text, before string, at time.Time) (BriefRevision, error) { + return ReviseBrief(dir, "t1", key, BriefChange{By: by, ByKey: byKey, Text: text, Before: before, At: at}) +} + +func briefsOf(t *testing.T, dir, key string) Member { + t.Helper() + f, err := Load(dir) + must(t, err) + team, _ := f.Team("t1") + m, _ := team.Member(key) + return m +} + +// A BRIEF CHANGED IN PLACE IS A DURABLE FACT: the membership keeps who, when, +// the words before and after, and the log line that delivered it, and a reopened +// file reads the same; an unchanged brief writes nothing. +func TestReviseBriefIsDurableAndDeliveredAsALogLine(t *testing.T) { + dir := briefTeam(t) + at := time.Date(2026, 10, 15, 14, 25, 0, 0, time.UTC) + rev, err := revise(dir, "krev", FromYou, "", " Check the retry backoff and skip the e2e suite. ", "Check the retry backoff.", at) + must(t, err) + if rev.Text != "Check the retry backoff and skip the e2e suite." || rev.Before != "Check the retry backoff." || rev.By != FromYou || !rev.At.Equal(at) || rev.Entry == "" { + t.Fatalf("revision = %+v", rev) + } + // The log line is addressed to the member, by handle and key, kind brief. + e, ok, err := ReadEntry(dir, "t1", rev.Entry) + if err != nil || !ok || e.Kind != KindBrief || e.To != "review" || e.ToKey != "krev" || e.From != FromYou || e.Text != rev.Text { + t.Fatalf("log line = %+v %v %v", e, ok, err) + } + // Reopened from disk: the same revision; the second one's before is the first's after. + if cur, ok := briefsOf(t, dir, "krev").CurrentBrief(); !ok || cur != rev { + t.Fatalf("reopened = %+v %v", cur, ok) + } + second, err := revise(dir, "krev", FromManager, "kmgr", "Only the backoff.", "ignored: the membership knows its own", at.Add(time.Minute)) + must(t, err) + if second.Before != rev.Text || second.ByKey != "kmgr" { + t.Fatalf("second = %+v", second) + } + if _, err := revise(dir, "krev", FromYou, "", "Only the backoff.", "", at); !errors.Is(err, ErrBriefUnchanged) { + t.Fatalf("unchanged = %v", err) + } + if _, err := revise(dir, "kzzz", FromYou, "", "x", "", at); !errors.Is(err, ErrBriefNoMember) { + t.Fatalf("a stranger's brief = %v", err) + } + // The manager's own membership was untouched. + if mgr := briefsOf(t, dir, "kmgr"); len(mgr.Briefs) != 0 { + t.Fatalf("manager briefs = %+v", mgr.Briefs) + } +} + +// A MEMBERSHIP KEEPS ITS NEWEST CHANGES ONLY, each still saying what it +// replaced, while the file stores each brief's words once; its revisions +// survive the file's JSON beside a field from a later build. +func TestReviseBriefKeepsTheNewestAndRoundTrips(t *testing.T) { + dir := briefTeam(t) + for i := 0; i < BriefRevisionsKept+4; i++ { + before := "" + if i == 0 { + before = "start" + } + _, err := revise(dir, "krev", FromYou, "", "brief "+strconv.Itoa(i), before, time.Time{}) + must(t, err) + } + m := briefsOf(t, dir, "krev") + if len(m.Briefs) != BriefRevisionsKept || m.Briefs[0].Text != "brief 4" || m.Briefs[len(m.Briefs)-1].Text != "brief 19" { + t.Fatalf("kept %d: first %q", len(m.Briefs), m.Briefs[0].Text) + } + // Stored: only the oldest kept says its before (the dropped one's words). + if m.Briefs[0].Before != "brief 3" { + t.Fatalf("oldest kept before = %q", m.Briefs[0].Before) + } + for _, r := range m.Briefs[1:] { + if r.Before != "" { + t.Fatalf("a later revision stored its before again: %+v", r) + } + } + // Read: every revision says what it replaced. + revs := m.BriefRevisions() + for i, r := range revs { + if want := "brief " + strconv.Itoa(i+3); r.Before != want { + t.Fatalf("revision %d before = %q, want %q", i, r.Before, want) + } + } + if cur, _ := m.CurrentBrief(); cur.Before != "brief 18" || cur.Text != "brief 19" { + t.Fatalf("current = %+v", cur) + } + raw, _ := json.Marshal(m) + var back Member + must(t, json.Unmarshal([]byte(string(raw[:len(raw)-1])+`,"future":1}`), &back)) + again, _ := json.Marshal(back) + if len(back.Briefs) != BriefRevisionsKept || string(again) != string(raw[:len(raw)-1])+`,"future":1}` { + t.Fatalf("round trip lost a field:\n%s\n%s", raw, again) + } +} + +// A CHANGE MADE FROM AN OLD READ IS REFUSED, and so are a closed team's, an +// over-long brief, and a member that left: nothing is written for any of them. +func TestReviseBriefRefusesStaleClosedLongAndGone(t *testing.T) { + dir := briefTeam(t) + none := "" + first, err := ReviseBrief(dir, "t1", "krev", BriefChange{By: FromYou, Text: "one", Base: &none}) + must(t, err) + // Two editors read the same brief; the first to save wins, the second is stale. + base := first.Entry + _, err = ReviseBrief(dir, "t1", "krev", BriefChange{By: FromYou, Text: "two", Base: &base}) + must(t, err) + if _, err := ReviseBrief(dir, "t1", "krev", BriefChange{By: FromYou, Text: "three", Base: &base}); !errors.Is(err, ErrBriefStale) { + t.Fatalf("stale = %v", err) + } + if _, err := ReviseBrief(dir, "t1", "krev", BriefChange{By: FromYou, Text: "three", Base: &none}); !errors.Is(err, ErrBriefStale) { + t.Fatalf("a never-changed base on a changed brief = %v", err) + } + if _, err := revise(dir, "krev", FromYou, "", strings.Repeat("x", BriefTextMax+1), "", time.Time{}); !errors.Is(err, ErrBriefTooLong) { + t.Fatalf("too long = %v", err) + } + if _, err := revise(dir, "krev", FromYou, "", strings.Repeat("é", BriefTextMax), "", time.Time{}); err != nil { + t.Fatalf("a brief of exactly the limit in characters = %v", err) + } + must(t, Update(dir, func(f *File) error { + for i := range f.Teams { + f.Teams[i].State = TeamClosed + } + return nil + })) + if _, err := revise(dir, "krev", FromYou, "", "after close", "", time.Time{}); !errors.Is(err, ErrClosed) { + t.Fatalf("closed = %v", err) + } + m := briefsOf(t, dir, "krev") + if len(m.Briefs) != 3 || m.Briefs[1].Text != "two" { + t.Fatalf("written = %+v", m.Briefs) + } +} + +// A LINE IS READ WITH ITS NEIGHBOURS BY ITS ID: the window is the id's own, a +// line the log no longer holds is false (never a nearby one), and who a line +// involves is read from its keys and its address, never its words. +func TestReadAroundIsTheIDsOwnWindow(t *testing.T) { + dir := briefTeam(t) + var ids []string + for i := 0; i < 9; i++ { + id, err := AppendTrafficID(dir, "t1", Entry{Kind: KindNote, From: "review", To: FromManager, Text: "same words"}) + must(t, err) + ids = append(ids, id) + } + got, ok, err := ReadAround(dir, "t1", ids[4], 2) + if err != nil || !ok || len(got) != 5 || got[0].ID != ids[2] || got[2].ID != ids[4] || got[4].ID != ids[6] { + t.Fatalf("window = %+v %v %v", got, ok, err) + } + if got, ok, _ := ReadAround(dir, "t1", ids[0], 2); !ok || got[0].ID != ids[0] || len(got) != 3 { + t.Fatalf("first line's window = %+v", got) + } + if _, ok, _ := ReadAround(dir, "t1", "000000000099", 2); ok { + t.Fatal("a line the log does not hold was found") + } + rev := Member{Key: "krev", Handle: "review"} + if !Involves(got[2], rev) || Involves(got[2], Member{Key: "kweb", Handle: "web"}) { + t.Fatalf("involves = %+v", got[2]) + } + if !Involves(Entry{To: ToSeveral, Handles: []string{"web", "review"}}, rev) || Involves(Entry{To: "web"}, rev) { + t.Fatal("involves by address") + } +} diff --git a/internal/teams/member_json.go b/internal/teams/member_json.go index 58ebe3b479..dc3a30870f 100644 --- a/internal/teams/member_json.go +++ b/internal/teams/member_json.go @@ -17,7 +17,7 @@ type wireMember struct { var knownMemberFields = map[string]bool{ "key": true, "file": true, "where": true, "word": true, "handle": true, - "home": true, "independent": true, "joined_at": true, "started": true, "handle_by": true, "start_entry": true, + "home": true, "independent": true, "joined_at": true, "started": true, "handle_by": true, "start_entry": true, "briefs": true, } func (m *Member) UnmarshalJSON(raw []byte) error { diff --git a/internal/teams/teams.go b/internal/teams/teams.go index 9790e9184e..3173e93ddd 100644 --- a/internal/teams/teams.go +++ b/internal/teams/teams.go @@ -54,6 +54,11 @@ type Member struct { // handle written before this was kept, and is read as the word list's // ([Member.HandleDerived]). HandleBy string `json:"handle_by,omitempty"` + // Briefs are the changes made to this member's brief after it started, + // oldest first, the newest [BriefRevisionsKept] (brief.go). Empty on a + // membership whose brief never changed and on every one written before + // they were kept: nobody's edit is ever inferred. + Briefs []BriefRevision `json:"briefs,omitempty"` // Unknown membership fields survive whole-file updates from this build. extra map[string]json.RawMessage } diff --git a/internal/teams/traffic.go b/internal/teams/traffic.go index 67e1c8dfe6..cdba1828b9 100644 --- a/internal/teams/traffic.go +++ b/internal/teams/traffic.go @@ -42,6 +42,9 @@ const ( KindPacket = "packet" // a decision packet was raised, decided or escalated KindClose = "close" // the team was closed KindReopen = "reopen" // the team was reopened + // KindBrief replaces a member's brief in place (brief.go): the member + // reads it at its next step; it wakes nobody and restarts nothing. + KindBrief = "brief" ) // Addresses that are not a member's handle. @@ -62,6 +65,7 @@ const ( var kinds = map[string]bool{ KindEvent: true, KindNote: true, KindDirective: true, KindStop: true, KindStart: true, KindYou: true, KindQuestion: true, KindAnswer: true, KindPacket: true, KindClose: true, KindReopen: true, + KindBrief: true, } // Entry is one line of the Traffic log. This shape is the contract between From 91686608a55e019ad4bd217fbeb8e4c4b707989a Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 22:13:01 -0400 Subject: [PATCH 096/459] taskrouting: a pinned role stays on its model when another role falls back A shared swap remembered only the first unpinned fallback, so a held role on that same send called the fallback next. Dispatch now keeps a saved pin, a task pin, and one-model mode on the send the decision records. --- .../changes/unreleased/0-pinned-seat-exact.md | 8 + internal/session/taskcrew.go | 90 +++++- internal/session/taskcrew_pindispatch_test.go | 293 ++++++++++++++++++ 3 files changed, 384 insertions(+), 7 deletions(-) create mode 100644 docs/changes/unreleased/0-pinned-seat-exact.md create mode 100644 internal/session/taskcrew_pindispatch_test.go diff --git a/docs/changes/unreleased/0-pinned-seat-exact.md b/docs/changes/unreleased/0-pinned-seat-exact.md new file mode 100644 index 0000000000..6bec3bbe38 --- /dev/null +++ b/docs/changes/unreleased/0-pinned-seat-exact.md @@ -0,0 +1,8 @@ +--- +kind: fixed +title: A pinned role stays on its model when another role on that model falls back +pr: 0 +surface: [engine] +invalidates: + - "A role that stayed on its model while another role sharing that model fell back was sent to the fallback on its next call. A saved pin, a pin for this task, and one model for every role stay on the model the task records for that role." +--- diff --git a/internal/session/taskcrew.go b/internal/session/taskcrew.go index f80027c3ed..36bf132157 100644 --- a/internal/session/taskcrew.go +++ b/internal/session/taskcrew.go @@ -1117,26 +1117,102 @@ func (c *taskCrew) seats(key string) bool { } // sendFor is the send id a request for key goes to now: key, or where the -// seat has moved. +// seat has moved. A call that names no role keeps this meaning. func (c *taskCrew) sendFor(key string) string { c.mu.Lock() defer c.mu.Unlock() + return c.sendForLocked(key) +} + +// sendForLocked is [taskCrew.sendFor] for a caller that already holds c.mu. +func (c *taskCrew) sendForLocked(key string) string { if to := c.swaps[key]; to != "" { return to } return key } -// sendForSeat is [taskCrew.sendFor] for a call a role made: where a person -// moved that role, else where its send id has moved. +// sendForSeat is where one role's call for key goes now. +// +// A SAVED PIN, A PIN FOR THIS TASK, AND ONE MODEL FOR EVERY ROLE hold the +// exact send the decision records for that role. An unpinned role that shares +// the original key and falls back writes one global swap, and that swap +// remembers only the first role's destination. It must not drag a role that +// is held, and it must not drag a role whose own recorded move is a different +// model. An unpinned role with no move of its own still follows the shared +// swap. A person who named a send for the role keeps that send. +// +// A call with no seat is [taskCrew.sendFor]. Every map this reads is read +// under c.mu. The walk is this one role — three seats at most — and nothing +// here reads the profile. func (c *taskCrew) sendForSeat(seat crewroute.Seat, key string) string { c.mu.Lock() - to := c.seatSends[seat] - c.mu.Unlock() - if to != "" && seat != "" && c.original[seat] == key { + defer c.mu.Unlock() + if seat == "" { + return c.sendForLocked(key) + } + return c.dispatchSeatLocked(seat, key) +} + +// dispatchSeatLocked resolves one role's send. The caller holds c.mu. +func (c *taskCrew) dispatchSeatLocked(seat crewroute.Seat, key string) string { + if to, ok := c.explicitSeatSendLocked(seat, key); ok { return to } - return c.sendFor(key) + if c.original[seat] == key && c.seatHoldsExactLocked(seat) { + return c.recordedSendLocked(seat, key) + } + if moved := c.ownFallbackLocked(seat, key); moved != "" { + return moved + } + return c.sendForLocked(key) +} + +// explicitSeatSendLocked is the send a person named for this role, when this +// call is for the role's original key. The caller holds c.mu. +func (c *taskCrew) explicitSeatSendLocked(seat crewroute.Seat, key string) (string, bool) { + to := c.seatSends[seat] + if to == "" || c.original[seat] != key { + return "", false + } + return to, true +} + +// seatHoldsExactLocked is whether this role stays on the send the decision +// records: a saved pin, a pin for this task, or one model for every role. +// The caller holds c.mu. +func (c *taskCrew) seatHoldsExactLocked(seat crewroute.Seat) bool { + if c.decision.Mode == crewroute.ModeOne { + return true + } + if c.taskPins[seat] { + return true + } + return c.decision.Seat(seat).Pinned +} + +// recordedSendLocked is the send the decision records for seat, or key when +// the decision has none. The caller holds c.mu. +func (c *taskCrew) recordedSendLocked(seat crewroute.Seat, key string) string { + if pick := c.decision.Seat(seat).Send; pick != "" { + return pick + } + return key +} + +// ownFallbackLocked is this unpinned role's own recorded move off key: the +// rung [Agent.moveCrewSeat] wrote onto the decision. Empty when the role is +// still on key, so the shared swap — another role's destination — is not +// what a role with its own move calls. The caller holds c.mu. +func (c *taskCrew) ownFallbackLocked(seat crewroute.Seat, key string) string { + if c.original[seat] != key { + return "" + } + pick := c.decision.Seat(seat).Send + if pick == "" || pick == key { + return "" + } + return pick } // hasStarted is whether a send id has answered a call on this task. diff --git a/internal/session/taskcrew_pindispatch_test.go b/internal/session/taskcrew_pindispatch_test.go new file mode 100644 index 0000000000..0f8c328847 --- /dev/null +++ b/internal/session/taskcrew_pindispatch_test.go @@ -0,0 +1,293 @@ +package session + +import ( + "context" + "errors" + "net/http" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/crewroute" + "github.com/Agent-Field/codeaf/internal/provider" +) + +// The three sends of the mixed-role regression. heldSend is the original +// every role was routed on. The planner and the checker leave it for +// different models; a held worker must not follow either. +const ( + heldSend = "role/held" + planSend = "role/planner-fallback" + checkSend = "role/checker-fallback" +) + +func clearLiveKeys(t *testing.T) { + t.Helper() + for _, key := range []string{"OPENROUTER_API_KEY", "OPENAI_API_KEY", "ANTHROPIC_API_KEY", "CODEAF_API_KEY"} { + t.Setenv(key, "") + } +} + +// mixedCrew is three roles on one original send. pin shapes the worker's hold. +// The planner and the checker stay unpinned and keep different ladders. +func mixedCrew(pin func(*taskCrew)) *taskCrew { + pick := func(seat crewroute.Seat, send string) crewroute.Pick { + return crewroute.Pick{Seat: seat, Model: send, Send: send, Provider: "openrouter", Kind: crewroute.Metered, EstUSD: 0.02} + } + d := crewroute.Decision{Class: crewroute.Bugfix, Mode: crewroute.ModeAuto, Crew: []crewroute.Pick{ + pick(crewroute.Worker, heldSend), + pick(crewroute.Planner, heldSend), + pick(crewroute.Checker, heldSend), + }} + crew := &taskCrew{ + roleFacts: true, title: "hold the worker", decision: d, original: originals(d), + ladders: map[crewroute.Seat][]crewroute.Pick{ + crewroute.Planner: {pick(crewroute.Planner, planSend)}, + crewroute.Checker: {pick(crewroute.Checker, checkSend)}, + }, + guard: &SpendGuard{}, + } + if pin != nil { + pin(crew) + } + return crew +} + +func dispatchRun(crew *taskCrew) *beltRun { + return &beltRun{row: 1, crew: crew, recoveredCrew: &TaskCrewRecord{}} +} + +func callOriginal(t *testing.T, agent *Agent, run *beltRun, seat crewroute.Seat) error { + t.Helper() + ctx := context.WithValue(t.Context(), crewSeatContextKey{}, seat) + _, err := (crewSeatCompleter{agent: agent, run: run}).CompleteWithMessages(ctx, + []ai.Message{textMessage("user", "go")}, ai.WithModel(heldSend)) + return err +} + +func holdStub(t *testing.T) (*crewStub, *Agent) { + t.Helper() + clearLiveKeys(t) + stub := newCrewStub(t, http.StatusOK, nil) + stub.mu.Lock() + stub.only = map[string]int{heldSend: http.StatusTooManyRequests} + stub.cost = 0.02 + stub.mu.Unlock() + agent, _ := crewStubAgent(t, stub) + return stub, agent +} + +func roleBySeat(view TaskRoutingView) map[crewroute.Seat]TaskRoutingRole { + out := map[crewroute.Seat]TaskRoutingRole{} + for _, role := range view.Roles { + out[role.Seat] = role + } + return out +} + +// THE MIXED-ROLE PIN: only the worker is pinned. The planner and the checker +// share its original send and are not pinned. The held route answers 429; each +// unpinned role has its own rung that answers. The worker's call is what moves +// them. The worker stays on the held send — recorded and dispatched — and is +// never sent to either fallback. +func TestAPinnedWorkerIsNotDraggedOntoAnotherRolesFallback(t *testing.T) { + stub, agent := holdStub(t) + crew := mixedCrew(func(c *taskCrew) { + c.decision.Crew[0].Pinned = true + // A rung the worker must not take, even though it would answer. + c.ladders[crewroute.Worker] = []crewroute.Pick{{Seat: crewroute.Worker, Model: "role/worker-ladder", Send: "role/worker-ladder", Provider: "openrouter", Kind: crewroute.Metered}} + }) + run := dispatchRun(crew) + + err := callOriginal(t, agent, run, crewroute.Worker) + var stopped crewStopped + if !errors.As(err, &stopped) { + t.Fatalf("pinned worker ended %v, asked %v", err, stub.models()) + } + if got := stub.models(); len(got) != 1 || got[0] != heldSend { + t.Fatalf("pinned worker asked %v, want only %s", got, heldSend) + } + if crew.current().Seat(crewroute.Worker).Send != heldSend || crew.sendForSeat(crewroute.Worker, heldSend) != heldSend { + t.Fatalf("worker recorded %s dispatched %s", crew.current().Seat(crewroute.Worker).Send, crew.sendForSeat(crewroute.Worker, heldSend)) + } + if crew.current().Seat(crewroute.Planner).Send != planSend || crew.sendForSeat(crewroute.Planner, heldSend) != planSend { + t.Fatalf("planner recorded %s dispatched %s", crew.current().Seat(crewroute.Planner).Send, crew.sendForSeat(crewroute.Planner, heldSend)) + } + if crew.current().Seat(crewroute.Checker).Send != checkSend || crew.sendForSeat(crewroute.Checker, heldSend) != checkSend { + t.Fatalf("checker recorded %s dispatched %s", crew.current().Seat(crewroute.Checker).Send, crew.sendForSeat(crewroute.Checker, heldSend)) + } + // A call that names no role still follows the one shared swap. + if crew.sendFor(heldSend) != planSend || crew.sendForSeat("", heldSend) != planSend { + t.Fatalf("seatless sendFor %s sendForSeat %s, want %s", crew.sendFor(heldSend), crew.sendForSeat("", heldSend), planSend) + } + kinds := map[crewroute.Seat]string{} + for _, change := range crew.changes { + kinds[change.Seat] = change.Kind + } + if kinds[crewroute.Worker] != TaskCrewChangeHeld || kinds[crewroute.Planner] != TaskCrewChangeFallback || kinds[crewroute.Checker] != TaskCrewChangeFallback { + t.Fatalf("changes %+v", crew.changes) + } + mid := roleBySeat(taskRoutingOf("1", crew.record(), false, 20)) + if mid[crewroute.Worker].Status != TaskRoleUsed || mid[crewroute.Planner].Status != TaskRoleNotRunYet || mid[crewroute.Checker].Status != TaskRoleNotRunYet { + t.Fatalf("after the worker's call: worker %s planner %s checker %s", mid[crewroute.Worker].Status, mid[crewroute.Planner].Status, mid[crewroute.Checker].Status) + } + if mid[crewroute.Worker].Source != TaskRolePinned || mid[crewroute.Worker].Model != heldSend { + t.Fatalf("worker role %+v", mid[crewroute.Worker]) + } + + if err := callOriginal(t, agent, run, crewroute.Planner); err != nil { + t.Fatalf("planner: %v (asked %v)", err, stub.models()) + } + if err := callOriginal(t, agent, run, crewroute.Checker); err != nil { + t.Fatalf("checker: %v (asked %v)", err, stub.models()) + } + if got := stub.models(); len(got) != 3 || got[0] != heldSend || got[1] != planSend || got[2] != checkSend { + t.Fatalf("asked %v, want %s then %s then %s", got, heldSend, planSend, checkSend) + } + done := roleBySeat(taskRoutingOf("1", crew.record(), false, 20)) + if done[crewroute.Worker].SpentUSD != 0 || done[crewroute.Planner].SpentUSD != 0.02 || done[crewroute.Checker].SpentUSD != 0.02 { + t.Fatalf("spend worker %v planner %v checker %v", done[crewroute.Worker].SpentUSD, done[crewroute.Planner].SpentUSD, done[crewroute.Checker].SpentUSD) + } + if done[crewroute.Planner].Status != TaskRoleUsed || done[crewroute.Checker].Status != TaskRoleUsed || done[crewroute.Worker].Model != heldSend { + t.Fatalf("roles worker %+v planner %+v checker %+v", done[crewroute.Worker], done[crewroute.Planner], done[crewroute.Checker]) + } +} + +// A pin for this task, with the pick's own pin flag left unset and no ladder +// of its own. The unpinned roles still fall back. The worker's recorded send +// stays the original, so the shared swap must not become its next call. +func TestAPerTaskPinHoldsWhenAnUnpinnedRoleFallsBack(t *testing.T) { + stub, agent := holdStub(t) + crew := mixedCrew(func(c *taskCrew) { + c.taskPins = map[crewroute.Seat]bool{crewroute.Worker: true} + }) + if crew.decision.Seat(crewroute.Worker).Pinned { + t.Fatal("this case pins through the task pin, not the pick flag") + } + run := dispatchRun(crew) + err := callOriginal(t, agent, run, crewroute.Worker) + var stopped crewStopped + if !errors.As(err, &stopped) { + t.Fatalf("task-pinned worker ended %v, asked %v", err, stub.models()) + } + if got := stub.models(); len(got) != 1 || got[0] != heldSend { + t.Fatalf("task-pinned worker asked %v, want only %s", got, heldSend) + } + if crew.sendForSeat(crewroute.Worker, heldSend) != heldSend || crew.current().Seat(crewroute.Worker).Send != heldSend { + t.Fatalf("worker recorded %s dispatched %s", crew.current().Seat(crewroute.Worker).Send, crew.sendForSeat(crewroute.Worker, heldSend)) + } + view := roleBySeat(taskRoutingOf("1", crew.record(), false, 20)) + if view[crewroute.Worker].Source != TaskRoleTaskPin || view[crewroute.Planner].Model != planSend || view[crewroute.Checker].Model != checkSend { + t.Fatalf("worker %+v planner %+v checker %+v", view[crewroute.Worker], view[crewroute.Planner], view[crewroute.Checker]) + } + if err := callOriginal(t, agent, run, crewroute.Planner); err != nil { + t.Fatalf("planner: %v (asked %v)", err, stub.models()) + } + if err := callOriginal(t, agent, run, crewroute.Checker); err != nil { + t.Fatalf("checker: %v (asked %v)", err, stub.models()) + } + if got := stub.models(); len(got) != 3 || got[1] != planSend || got[2] != checkSend { + t.Fatalf("asked %v, want the held send then each role's own fallback", got) + } +} + +// One model for every role: the route fails, nobody moves, and the call is +// not retried on a rung that would have answered. +func TestOneModelHoldsEveryRoleWhenTheRouteFails(t *testing.T) { + stub, agent := holdStub(t) + crew := mixedCrew(func(c *taskCrew) { + c.decision.Mode = crewroute.ModeOne + c.ladders[crewroute.Worker] = []crewroute.Pick{{Seat: crewroute.Worker, Model: planSend, Send: planSend, Provider: "openrouter", Kind: crewroute.Metered}} + }) + run := dispatchRun(crew) + err := callOriginal(t, agent, run, crewroute.Worker) + var stopped crewStopped + if !errors.As(err, &stopped) { + t.Fatalf("one model ended %v, asked %v", err, stub.models()) + } + if got := stub.models(); len(got) != 1 || got[0] != heldSend { + t.Fatalf("one model asked %v, want only %s", got, heldSend) + } + for _, seat := range crewroute.Seats { + if crew.current().Seat(seat).Send != heldSend || crew.sendForSeat(seat, heldSend) != heldSend { + t.Fatalf("%s recorded %s dispatched %s", seat, crew.current().Seat(seat).Send, crew.sendForSeat(seat, heldSend)) + } + } + view := roleBySeat(taskRoutingOf("1", crew.record(), false, 20)) + if view[crewroute.Worker].Source != TaskRoleOneModel || view[crewroute.Planner].Model != heldSend { + t.Fatalf("one model view worker %+v planner %+v", view[crewroute.Worker], view[crewroute.Planner]) + } +} + +// answeredCrew is the router behind a live pick, Back to Auto, and Stronger. +// A worker named for Auto this once becomes role/stronger. An explicit other +// send is a pin. Anything else keeps the send the ask already resolved. +func answeredCrew(ask config.CrewAsk) crewroute.Decision { + worker, pinned := heldSend, false + switch { + case ask.AutoSeats[crewroute.Worker]: + worker = "role/stronger" + case ask.Sends[crewroute.Worker] != "" && ask.Sends[crewroute.Worker] != heldSend: + worker, pinned = ask.Sends[crewroute.Worker], true + case ask.Sends[crewroute.Worker] != "": + worker = ask.Sends[crewroute.Worker] + } + send := func(seat crewroute.Seat, fallback string) string { + if got := ask.Sends[seat]; got != "" { + return got + } + return fallback + } + pick := func(seat crewroute.Seat, model string, pin bool) crewroute.Pick { + return crewroute.Pick{Seat: seat, Model: model, Send: model, Provider: "openrouter", Kind: crewroute.Metered, EstUSD: 0.02, Pinned: pin} + } + return crewroute.Decision{Mode: crewroute.ModeAuto, Crew: []crewroute.Pick{ + pick(crewroute.Worker, worker, pinned), + pick(crewroute.Planner, send(crewroute.Planner, planSend), false), + pick(crewroute.Checker, send(crewroute.Checker, checkSend), false), + }} +} + +// After the unpinned roles have fallen back, a person's pick, Back to Auto, +// and Stronger (the worker named for Auto this once) still decide the worker. +// None of them is the planner's fallback. +func TestExplicitPickAndBackToAutoStillNameTheWorker(t *testing.T) { + var asks []config.CrewAsk + agent := routerAgent(t, func(ask config.CrewAsk) (crewroute.Decision, error) { + asks = append(asks, ask) + return answeredCrew(ask), nil + }) + crew := mixedCrew(func(c *taskCrew) { c.decision.Crew[0].Pinned = true }) + run := dispatchRun(crew) + if _, moved := agent.moveCrewSeat(run, heldSend, heldSend, provider.RouteUnavailable, time.Time{}); !moved { + t.Fatal("unpinned roles did not fall back") + } + if crew.sendForSeat(crewroute.Worker, heldSend) != heldSend || crew.sendFor(heldSend) != planSend { + t.Fatalf("after the move worker %s seatless %s", crew.sendForSeat(crewroute.Worker, heldSend), crew.sendFor(heldSend)) + } + if _, err := agent.actOnLiveCrew(1, crew, TaskRoutingAction{Kind: TaskRoutingPick, Picks: map[crewroute.Seat]string{crewroute.Worker: "role/person"}}); err != nil { + t.Fatal(err) + } + if crew.sendForSeat(crewroute.Worker, heldSend) != "role/person" || crew.current().Seat(crewroute.Worker).Send != "role/person" { + t.Fatalf("pick recorded %s dispatched %s", crew.current().Seat(crewroute.Worker).Send, crew.sendForSeat(crewroute.Worker, heldSend)) + } + if _, err := agent.actOnLiveCrew(1, crew, TaskRoutingAction{Kind: TaskRoutingAuto}); err != nil { + t.Fatal(err) + } + if crew.sendForSeat(crewroute.Worker, heldSend) != heldSend || crew.current().Seat(crewroute.Worker).Pinned || crew.taskPins[crewroute.Worker] { + t.Fatalf("back to auto dispatched %s pinned %v task pin %v", crew.sendForSeat(crewroute.Worker, heldSend), crew.current().Seat(crewroute.Worker).Pinned, crew.taskPins[crewroute.Worker]) + } + if _, err := agent.actOnLiveCrew(1, crew, TaskRoutingAction{Kind: TaskRoutingStronger}); err != nil { + t.Fatal(err) + } + if len(asks) == 0 || !asks[len(asks)-1].AutoSeats[crewroute.Worker] { + t.Fatalf("stronger asked %+v", asks) + } + if crew.sendForSeat(crewroute.Worker, heldSend) != "role/stronger" { + t.Fatalf("stronger dispatched %s", crew.sendForSeat(crewroute.Worker, heldSend)) + } + if crew.sendForSeat(crewroute.Planner, heldSend) != planSend || crew.sendForSeat(crewroute.Checker, heldSend) != checkSend { + t.Fatalf("planner %s checker %s", crew.sendForSeat(crewroute.Planner, heldSend), crew.sendForSeat(crewroute.Checker, heldSend)) + } +} From 401f1a029d607bd790e576c55d7056ef213678c8 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 22:07:17 -0400 Subject: [PATCH 097/459] app: spend.ledger pages the usage ledger by run, spend.ledger.export saves it whole Adds a bounded, streaming query over the usage ledger and two optional appwire methods beside the unchanged spend.export. - session.QueryUsageLedger / EachUsageLedgerEntry read usage.jsonl a 64 KiB-bounded line at a time (malformed, oversized and in-flight tail lines handled as ReadUsage handles them), group calls by UsageLine.Run into one row per run, and keep every run-less call as its own row. Totals are exact and page-independent; runs and unknown-run records are counted apart; unpriced calls are counted, not free. Memory is a page plus the runs in range under an explicit budget that refuses, never truncates; at most two scans run at once, a third is busy. - appwire: SpendLedgerer capability, spend.ledger (keyset cursor bound to its filter, limit 1-500, default 200) and spend.ledger.export (absolute destination, overwrite opt-in); unknown fields refused; unknown_method when the engine lacks the capability. CSV columns time, kind, name, models, tokens_in, tokens_out, cost_usd, by, then grain and ids. - host: joins conversation, task (by root) and automation titles the app already holds; writes the export to a 0600 sibling temp file, syncs, and links or renames it into place, leaving the destination untouched on any failure. Consumes UsageLine.Run from f0f42e31c. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/appledger.go | 344 +++++++++ cmd/codeaf/appledger_test.go | 326 ++++++++ docs/changes/unreleased/0-app-spend-ledger.md | 23 + internal/appwire/spend_ledger.go | 349 +++++++++ internal/appwire/spend_ledger_test.go | 231 ++++++ internal/session/usage_query.go | 698 ++++++++++++++++++ internal/session/usage_query_test.go | 443 +++++++++++ 7 files changed, 2414 insertions(+) create mode 100644 cmd/codeaf/appledger.go create mode 100644 cmd/codeaf/appledger_test.go create mode 100644 docs/changes/unreleased/0-app-spend-ledger.md create mode 100644 internal/appwire/spend_ledger.go create mode 100644 internal/appwire/spend_ledger_test.go create mode 100644 internal/session/usage_query.go create mode 100644 internal/session/usage_query_test.go diff --git a/cmd/codeaf/appledger.go b/cmd/codeaf/appledger.go new file mode 100644 index 0000000000..c09666e39c --- /dev/null +++ b/cmd/codeaf/appledger.go @@ -0,0 +1,344 @@ +package main + +// ── codeaf app: THE SPENDING LEDGER, READ A PAGE AT A TIME AND SAVED WHOLE ── +// +// spend.ledger and spend.ledger.export (internal/appwire/spend_ledger.go) are +// served from the machine's usage ledger by session.QueryUsageLedger and +// session.EachUsageLedgerEntry, which read it as a bounded stream; this file +// adds only what the app knows and the ledger does not — the titles of the +// things the money was spent on — and the one safe way to put a file where the +// person asked for it. +// +// A NAME IS JOINED, NEVER INFERRED. A conversation's title is the one its +// journal settled on; a task's is the index row with that id IN THAT +// CONVERSATION (an id alone is not unique, so a line with no root names no +// task); an automation's is the store's. Anything none of them holds has no +// name, and the client — or the CSV — falls back to the id the ledger wrote. +// +// THE FILE IS WHOLE OR IT IS NOT THERE. It is written beside the destination +// under a temporary name, synced, and only then moved into place; any failure +// removes the temporary file and leaves whatever was at the destination exactly +// as it was. An existing file is replaced only when the caller said overwrite. + +import ( + "bufio" + "encoding/csv" + "errors" + "fmt" + "io" + "io/fs" + "os" + "path/filepath" + "strings" + "syscall" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/session" +) + +// The app's backend serves spend.ledger and spend.ledger.export. +var _ appwire.SpendLedgerer = (*appBackend)(nil) + +// appLedgerPath and appLedgerZone are the ledger the app reads and the zone +// its calendar days are in; a test points them elsewhere. +var ( + appLedgerPath = session.UsageLedgerPath + appLedgerZone = func() *time.Location { return time.Local } +) + +// appLedgerQuery is the wire's filter as the ledger asks it. +func appLedgerQuery(f appwire.SpendLedgerFilter) (session.UsageQuery, error) { + from, to, err := session.UsageDayRange(f.From, f.To, appLedgerZone()) + if err != nil { + return session.UsageQuery{}, err + } + kind := f.Kind + if kind == appwire.SpendKindAgent { + kind = session.SubjectConversation + } + return session.UsageQuery{ + From: from, To: to, Model: f.Model, Kind: kind, Subject: f.Subject, + Session: f.Session, Root: f.Root, Task: f.Task, Automation: f.Automation, Run: f.Run, Workspace: f.Workspace, + }, nil +} + +// appLedgerError is a ledger refusal in the wire's words: what the caller can +// fix is bad_params, too many readers at once is busy. +func appLedgerError(err error) error { + switch { + case errors.Is(err, session.ErrUsageScanBusy): + return appwire.Errorf(appwire.CodeBusy, "%s", err.Error()) + case errors.Is(err, session.ErrUsageQuery), errors.Is(err, session.ErrUsageCursor), errors.Is(err, session.ErrUsageLedgerTooLarge): + return appwire.Errorf(appwire.CodeBadParams, "%s", err.Error()) + } + return appEngineError(err) +} + +// SpendLedger answers one page of the ledger. +func (b *appBackend) SpendLedger(ask appwire.SpendLedgerAsk) (appwire.SpendLedgerResult, error) { + query, err := appLedgerQuery(ask.Filter) + if err != nil { + return appwire.SpendLedgerResult{}, appLedgerError(err) + } + page, err := session.QueryUsageLedger(appLedgerPath(), query, ask.Cursor, ask.Limit, session.DefaultUsageLedgerBudget) + if err != nil { + return appwire.SpendLedgerResult{}, appLedgerError(err) + } + var names appLedgerNames + if len(page.Entries) > 0 { + names = b.ledgerNames() + } + zone := appLedgerZone() + rows := make([]appwire.SpendLedgerRow, 0, len(page.Entries)) + for _, entry := range page.Entries { + rows = append(rows, appLedgerRow(entry, names, zone)) + } + return appwire.SpendLedgerResult{ + From: ask.Filter.From, To: ask.Filter.To, + Totals: appLedgerTotals(page.Totals), Rows: rows, Next: page.Next, + Skipped: appwire.SpendLedgerSkipped{Malformed: page.Stats.Malformed, Oversized: page.Stats.Oversized}, + }, nil +} + +// ExportSpendLedger writes every row the filter matches to the destination as +// CSV, whole or not at all. +func (b *appBackend) ExportSpendLedger(ask appwire.SpendLedgerExportAsk) (appwire.SpendLedgerExportResult, error) { + query, err := appLedgerQuery(ask.Filter) + if err != nil { + return appwire.SpendLedgerExportResult{}, appLedgerError(err) + } + names := b.ledgerNames() + zone := appLedgerZone() + var ( + totals session.UsageLedgerTotals + stats session.UsageScanStats + rows int + ) + written, err := appWriteWhole(ask.Destination, ask.Overwrite, func(w io.Writer) error { + out := csv.NewWriter(w) + if err := appwire.NewSpendLedgerCSV(out); err != nil { + return err + } + var err error + totals, stats, err = session.EachUsageLedgerEntry(appLedgerPath(), query, session.DefaultUsageLedgerBudget, func(entry session.UsageEntry) error { + rows++ + if err := out.Write(appwire.SpendLedgerCSVRecord(appLedgerRow(entry, names, zone))); err != nil { + return err + } + return nil + }) + if err != nil { + return err + } + out.Flush() + return out.Error() + }) + if err != nil { + return appwire.SpendLedgerExportResult{}, appLedgerError(err) + } + return appwire.SpendLedgerExportResult{ + Path: ask.Destination, Rows: rows, Bytes: written, Totals: appLedgerTotals(totals), + Skipped: appwire.SpendLedgerSkipped{Malformed: stats.Malformed, Oversized: stats.Oversized}, + }, nil +} + +// appLedgerRow is one ledger row on the wire. +func appLedgerRow(entry session.UsageEntry, names appLedgerNames, zone *time.Location) appwire.SpendLedgerRow { + row := appwire.SpendLedgerRow{ + Grain: entry.Grain, Key: entry.Key, Run: entry.Run, + Time: entry.Start.In(zone), End: entry.End.In(zone), + Kind: appLedgerKind(entry.Kind), Subject: entry.Subject, + Session: entry.Session, Root: entry.Root, Task: entry.Task, Automation: entry.Automation, Workspace: entry.Workspace, + Models: append([]string{}, entry.Models...), UnnamedModelRecords: entry.UnnamedModel, + Records: entry.Records, Calls: entry.Calls, TokensIn: entry.Input, TokensOut: entry.Output, + UnpricedRecords: entry.Unpriced, UnbilledRecords: entry.Unbilled, + } + if entry.Priced() { + usd := entry.USD + row.CostUSD = &usd + } + row.Name = names.of(entry) + return row +} + +// appLedgerKind is the ledger's subject word in the wire's. +func appLedgerKind(kind string) string { + switch kind { + case session.SubjectConversation: + return appwire.SpendKindAgent + case session.SubjectTask: + return appwire.SpendKindTask + case session.SubjectAutomation: + return appwire.SpendKindAutomation + } + return "" +} + +// appLedgerTotals is the totals on the wire; a figure no record carried is +// absent rather than zero. +func appLedgerTotals(t session.UsageLedgerTotals) appwire.SpendLedgerTotals { + figure := func(usd float64, priced bool) *float64 { + if !priced { + return nil + } + return &usd + } + return appwire.SpendLedgerTotals{ + Rows: t.Entries, Runs: t.Runs, UnknownRunRecords: t.UnknownRunRecords, + Records: t.Records, Calls: t.Calls, TokensIn: t.Input, TokensOut: t.Output, + CostUSD: figure(t.USD, t.Records > t.Unpriced), + RunCostUSD: figure(t.RunUSD, t.RunUSD > 0), + UnknownRunCostUSD: figure(t.UnknownRunUSD, t.UnknownRunUSD > 0), + UnpricedRecords: t.Unpriced, UnbilledRecords: t.Unbilled, + } +} + +// appLedgerNames is the titles the app holds, by what they name. +type appLedgerNames struct { + conversations map[string]string + // tasks is keyed by conversation and then task id. + tasks map[string]map[string]string + automations map[string]string +} + +// ledgerNames reads the titles once: every conversation and task the places +// walk holds, and every automation in the store when standing orders are on. +func (b *appBackend) ledgerNames() appLedgerNames { + names := appLedgerNames{conversations: map[string]string{}, tasks: map[string]map[string]string{}, automations: map[string]string{}} + for _, row := range session.ReadWorld(session.PlacesRoot()).Sessions() { + if title := strings.TrimSpace(row.Title); title != "" { + names.conversations[row.ID] = title + } + for _, entry := range row.Tasks.Rows { + title, conversation := strings.TrimSpace(entry.Title), strings.TrimSpace(entry.SessionID) + if title == "" || conversation == "" { + continue + } + if names.tasks[conversation] == nil { + names.tasks[conversation] = map[string]string{} + } + names.tasks[conversation][strings.TrimSpace(entry.ID)] = title + } + } + if b.orderStore != nil { + if store := b.orderStore(); store != nil { + if list, err := store.List(); err == nil { + for _, a := range list { + if title := strings.TrimSpace(a.Title); title != "" { + names.automations[a.ID] = title + } + } + } + } + } + return names +} + +// of is the title of what the row's money was spent on, or "". +func (n appLedgerNames) of(entry session.UsageEntry) string { + switch entry.Kind { + case session.SubjectConversation: + return n.conversations[entry.Subject] + case session.SubjectAutomation: + return n.automations[entry.Subject] + case session.SubjectTask: + if entry.Root == "" { + return "" + } + return n.tasks[entry.Root][entry.Subject] + } + return "" +} + +// appWriteWhole writes what fill writes to path, whole or not at all, and +// answers the bytes written. +// +// THE TEMPORARY FILE IS A SIBLING, so the last step is a rename within one +// directory, which a reader sees as the old file or the new one and never part +// of either. It is made 0600, written, synced and closed before it moves; on +// any failure it is removed. Without overwrite the move is a hard link, which +// refuses an existing destination atomically — a file someone else wrote there +// a moment ago is not replaced — and only a filesystem that has no links falls +// back to checking first. +func appWriteWhole(path string, overwrite bool, fill func(io.Writer) error) (int64, error) { + dir := filepath.Dir(path) + if info, err := os.Stat(dir); err != nil || !info.IsDir() { + return 0, appwire.Errorf(appwire.CodeBadParams, "the destination's folder does not exist") + } + if info, err := os.Lstat(path); err == nil { + if !info.Mode().IsRegular() { + return 0, appwire.Errorf(appwire.CodeBadParams, "the destination is not a file") + } + if !overwrite { + return 0, appwire.Errorf(appwire.CodeBadParams, "the destination already exists; pass overwrite to replace it") + } + } else if !errors.Is(err, fs.ErrNotExist) { + return 0, appEngineError(err) + } + temp, err := os.CreateTemp(dir, ".codeaf-ledger-*.tmp") + if err != nil { + return 0, appEngineError(err) + } + tempPath := temp.Name() + keep := false + defer func() { + if !keep { + _ = temp.Close() + _ = os.Remove(tempPath) + } + }() + if err := temp.Chmod(appStateFileMode); err != nil { + return 0, err + } + counter := &appCountingWriter{w: bufio.NewWriterSize(temp, 64<<10)} + if err := fill(counter); err != nil { + return 0, err + } + if err := counter.w.Flush(); err != nil { + return 0, err + } + if err := temp.Sync(); err != nil { + return 0, err + } + if err := temp.Close(); err != nil { + return 0, err + } + if overwrite { + err = os.Rename(tempPath, path) + } else { + err = os.Link(tempPath, path) + switch { + case err == nil: + _ = os.Remove(tempPath) + case errors.Is(err, fs.ErrExist): + return 0, appwire.Errorf(appwire.CodeBadParams, "the destination already exists; pass overwrite to replace it") + case errors.Is(err, syscall.EPERM), errors.Is(err, syscall.ENOTSUP), errors.Is(err, syscall.EXDEV), errors.Is(err, syscall.EMLINK): + if _, statErr := os.Lstat(path); statErr == nil { + return 0, appwire.Errorf(appwire.CodeBadParams, "the destination already exists; pass overwrite to replace it") + } + err = os.Rename(tempPath, path) + } + } + if err != nil { + return 0, fmt.Errorf("the export could not be put in place: %w", err) + } + keep = true + if handle, err := os.Open(dir); err == nil { + _ = handle.Sync() + _ = handle.Close() + } + return counter.n, nil +} + +// appCountingWriter counts what passes through it. +type appCountingWriter struct { + w *bufio.Writer + n int64 +} + +func (c *appCountingWriter) Write(p []byte) (int, error) { + n, err := c.w.Write(p) + c.n += int64(n) + return n, err +} diff --git a/cmd/codeaf/appledger_test.go b/cmd/codeaf/appledger_test.go new file mode 100644 index 0000000000..b5f1114188 --- /dev/null +++ b/cmd/codeaf/appledger_test.go @@ -0,0 +1,326 @@ +package main + +import ( + "encoding/csv" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/session" +) + +// ledgerAt points the app at a ledger of these lines, in this zone, for one +// test. +func ledgerAt(t *testing.T, zone *time.Location, lines []session.UsageLine, extra string) string { + t.Helper() + var b strings.Builder + for _, line := range lines { + data, err := json.Marshal(line) + if err != nil { + t.Fatal(err) + } + b.Write(data) + b.WriteByte('\n') + } + b.WriteString(extra) + path := filepath.Join(t.TempDir(), session.UsageLedgerName) + if err := os.WriteFile(path, []byte(b.String()), 0o600); err != nil { + t.Fatal(err) + } + oldPath, oldZone := appLedgerPath, appLedgerZone + appLedgerPath = func() string { return path } + appLedgerZone = func() *time.Location { return zone } + t.Cleanup(func() { appLedgerPath, appLedgerZone = oldPath, oldZone }) + return path +} + +// appLedgerFixture is twelve thousand calls across two local days: 40 runs +// that each use two models, and 8 000 calls with no run, a third of them +// unpriced, with a workspace whose name needs quoting. +func appLedgerFixture(zone *time.Location) []session.UsageLine { + base := time.Date(2026, 10, 14, 6, 0, 0, 0, zone) + var lines []session.UsageLine + for r := 0; r < 40; r++ { + for c := 0; c < 100; c++ { + at := base.Add(time.Duration(r)*30*time.Minute + time.Duration(c)*time.Second) + lines = append(lines, session.UsageLine{ + Run: fmt.Sprintf("run-%02d", r), At: at, Day: at.Format("2006-01-02"), + Model: []string{"m/one", "m/two"}[c%2], Calls: 1, Input: 4, Output: 2, USD: 1.0 / 256, + Session: "conv0000000000001", Workspace: "/work/a \"quoted\",\nfolder", + }) + } + } + for i := 0; i < 8000; i++ { + at := base.Add(time.Duration(i) * 15 * time.Second) + line := session.UsageLine{At: at, Day: at.Format("2006-01-02"), Model: "m/aux", Calls: 1, Input: 1, Output: 1, Session: "conv0000000000002", Role: "title"} + if i%3 != 0 { + line.USD = 1.0 / 512 + } else { + line.Unbilled = true + } + lines = append(lines, line) + } + return lines +} + +func readCSV(t *testing.T, path string) [][]string { + t.Helper() + file, err := os.Open(path) + if err != nil { + t.Fatal(err) + } + defer file.Close() + records, err := csv.NewReader(file).ReadAll() + if err != nil { + t.Fatal(err) + } + return records +} + +// THE PREVIEW AND THE FILE ARE ONE QUESTION: the same filter pages through the +// same rows the export writes, and both say the same totals — runs one row +// each, every unattributed call its own row, the unpriced counted not free. +func TestAppSpendLedgerPreviewAndExportAgree(t *testing.T) { + zone := time.FixedZone("EDT", -4*3600) + ledgerAt(t, zone, appLedgerFixture(zone), "") + b := &appBackend{} + for _, filter := range []appwire.SpendLedgerFilter{ + {From: "2026-10-14", To: "2026-10-15"}, + {From: "2026-10-14", To: "2026-10-14", Model: "m/two"}, + {From: "2026-10-14", To: "2026-10-15", Kind: appwire.SpendKindAgent, Subject: "conv0000000000002"}, + } { + var keys []string + var totals appwire.SpendLedgerTotals + cursor := "" + for { + page, err := b.SpendLedger(appwire.SpendLedgerAsk{Filter: filter, Limit: 500, Cursor: cursor}) + if err != nil { + t.Fatal(err) + } + totals = page.Totals + for _, row := range page.Rows { + keys = append(keys, row.Key) + if row.Grain == appwire.SpendGrainRun && (row.Records != 100 && filter.Model == "" || len(row.Models) == 0) { + t.Fatalf("run row %+v", row) + } + if _, offset := row.Time.Zone(); offset != -4*3600 { + t.Fatalf("a row's time is not in the asker's zone: %v", row.Time) + } + } + if page.Next == "" { + break + } + cursor = page.Next + } + dest := filepath.Join(t.TempDir(), "spend.csv") + out, err := b.ExportSpendLedger(appwire.SpendLedgerExportAsk{Filter: filter, Destination: dest}) + if err != nil { + t.Fatal(err) + } + records := readCSV(t, dest) + if out.Rows != len(records)-1 || out.Rows != len(keys) || out.Rows != totals.Rows { + t.Fatalf("%+v: export %d rows (%d records), pages %d, totals %d", filter, out.Rows, len(records)-1, len(keys), totals.Rows) + } + if a, _ := json.Marshal(out.Totals); string(a) != string(ledgerJSON(json.Marshal(totals))) { + t.Fatalf("%+v: export totals %+v, page totals %+v", filter, out.Totals, totals) + } + info, _ := os.Stat(dest) + if info.Mode().Perm() != 0o600 || info.Size() != out.Bytes { + t.Fatalf("mode %v size %d answered %d", info.Mode(), info.Size(), out.Bytes) + } + col := map[string]int{} + for i, name := range records[0] { + col[name] = i + } + exported := map[string]bool{} + for _, record := range records[1:] { + exported[record[col["key"]]] = true + if record[col["grain"]] == "call" && record[col["unpriced_records"]] == "1" && record[col["cost_usd"]] != "" { + t.Fatalf("an unpriced call has a cost: %q", record) + } + if record[col["grain"]] == "run" && record[col["workspace"]] != "/work/a \"quoted\",\nfolder" { + t.Fatalf("a quoted workspace came back as %q", record[col["workspace"]]) + } + if _, err := time.Parse(time.RFC3339, record[col["time"]]); err != nil || !strings.HasSuffix(record[col["time"]], "-04:00") { + t.Fatalf("time %q", record[col["time"]]) + } + } + for _, key := range keys { + if !exported[key] { + t.Fatalf("%+v: %s is in the preview and not the file", filter, key) + } + } + } + all, err := b.SpendLedger(appwire.SpendLedgerAsk{Filter: appwire.SpendLedgerFilter{From: "2026-10-14", To: "2026-10-15"}, Limit: 1}) + if err != nil { + t.Fatal(err) + } + if tot := all.Totals; tot.Runs != 40 || tot.UnknownRunRecords != 8000 || tot.Records != 12000 || tot.UnpricedRecords != 2667 || tot.UnbilledRecords != 2667 || + tot.RunCostUSD == nil || *tot.RunCostUSD != 4000.0/256 || tot.UnknownRunCostUSD == nil || *tot.UnknownRunCostUSD != 5333.0/512 { + t.Fatalf("totals %+v", tot) + } +} + +// THE FILE IS WHOLE OR NOT THERE: an existing file is kept unless the caller +// said overwrite, a reading that fails leaves the destination's bytes as they +// were with no temporary file beside them, and an overwrite replaces it whole. +func TestAppSpendLedgerExportIsAtomic(t *testing.T) { + zone := time.UTC + ledger := ledgerAt(t, zone, appLedgerFixture(zone)[:500], "") + b := &appBackend{} + dir := t.TempDir() + dest := filepath.Join(dir, "spend.csv") + if err := os.WriteFile(dest, []byte("the person's own file\n"), 0o644); err != nil { + t.Fatal(err) + } + ask := appwire.SpendLedgerExportAsk{Filter: appwire.SpendLedgerFilter{From: "2026-10-14", To: "2026-10-14"}, Destination: dest} + _, err := b.ExportSpendLedger(ask) + if wireErrorCode(err) != appwire.CodeBadParams || !strings.Contains(err.Error(), "overwrite") { + t.Fatalf("an existing file without overwrite: %v", err) + } + keptIntact(t, dir, dest, "the person's own file\n") + + // A ledger that cannot be read — a folder where the file should be. + appLedgerPath = func() string { return dir } + ask.Overwrite = true + if _, err := b.ExportSpendLedger(ask); err == nil { + t.Fatal("an unreadable ledger exported") + } + keptIntact(t, dir, dest, "the person's own file\n") + + // Too many readers at once. + appLedgerPath = func() string { return ledger } + slots := fillScanSlots(t) + _, err = b.ExportSpendLedger(ask) + slots() + if wireErrorCode(err) != appwire.CodeBusy { + t.Fatalf("busy: %v", err) + } + keptIntact(t, dir, dest, "the person's own file\n") + + out, err := b.ExportSpendLedger(ask) + if err != nil || out.Rows == 0 { + t.Fatalf("%v %+v", err, out) + } + records := readCSV(t, dest) + if len(records) != out.Rows+1 || records[0][0] != "time" { + t.Fatalf("the replaced file holds %d records", len(records)) + } + if entries, _ := os.ReadDir(dir); len(entries) != 1 { + t.Fatalf("the folder holds %d entries after an export", len(entries)) + } + + for _, bad := range []string{filepath.Join(dir, "missing", "spend.csv"), dir} { + _, err := b.ExportSpendLedger(appwire.SpendLedgerExportAsk{Filter: ask.Filter, Destination: bad, Overwrite: true}) + if wireErrorCode(err) != appwire.CodeBadParams { + t.Fatalf("%s: %v", bad, err) + } + } +} + +// fillScanSlots takes every ledger scan slot by running scans that block in +// their callback, and answers the function that lets them go. +func fillScanSlots(t *testing.T) func() { + t.Helper() + release := make(chan struct{}) + started := make(chan struct{}, 2) + done := make(chan struct{}, 2) + for range 2 { + go func() { + _, _, _ = session.EachUsageLedgerEntry(appLedgerPath(), session.UsageQuery{}, session.DefaultUsageLedgerBudget, func(session.UsageEntry) error { + select { + case started <- struct{}{}: + default: + } + <-release + return errors.New("released") + }) + done <- struct{}{} + }() + } + <-started + <-started + return func() { + close(release) + <-done + <-done + } +} + +func keptIntact(t *testing.T, dir, dest, want string) { + t.Helper() + got, err := os.ReadFile(dest) + if err != nil || string(got) != want { + t.Fatalf("the destination changed: %q %v", got, err) + } + entries, _ := os.ReadDir(dir) + for _, e := range entries { + if e.Name() != filepath.Base(dest) { + t.Fatalf("%s was left beside the destination", e.Name()) + } + } +} + +func wireErrorCode(err error) string { + var wire *appwire.Error + if errors.As(err, &wire) { + return wire.Code + } + return "" +} + +// WHAT THE ASKER CAN FIX IS bad_params: a cursor from another question, and a +// range holding more runs than one answer may hold. +func TestAppSpendLedgerRefusals(t *testing.T) { + ledgerAt(t, time.UTC, appLedgerFixture(time.UTC), "") + b := &appBackend{} + page, err := b.SpendLedger(appwire.SpendLedgerAsk{Filter: appwire.SpendLedgerFilter{From: "2026-10-14", To: "2026-10-15"}, Limit: 5}) + if err != nil || page.Next == "" { + t.Fatalf("%v %q", err, page.Next) + } + _, err = b.SpendLedger(appwire.SpendLedgerAsk{Filter: appwire.SpendLedgerFilter{From: "2026-10-14", To: "2026-10-14"}, Limit: 5, Cursor: page.Next}) + if wireErrorCode(err) != appwire.CodeBadParams { + t.Fatalf("another range's cursor: %v", err) + } + if wireErrorCode(appLedgerError(session.ErrUsageLedgerTooLarge)) != appwire.CodeBadParams { + t.Fatal("too large is not the asker's to fix") + } +} + +// A NAME IS JOINED, NEVER GUESSED: a task's title needs the conversation it +// ran in, and a thing nothing holds a title for has none. +func TestAppSpendLedgerNamesAreJoinedNotInferred(t *testing.T) { + names := appLedgerNames{ + conversations: map[string]string{"c1": "Release notes"}, + tasks: map[string]map[string]string{"c1": {"7": "Fix the build"}}, + automations: map[string]string{"nightly": "Nightly triage"}, + } + for _, c := range []struct { + entry session.UsageEntry + want string + }{ + {session.UsageEntry{Kind: session.SubjectConversation, Subject: "c1"}, "Release notes"}, + {session.UsageEntry{Kind: session.SubjectTask, Subject: "7", Root: "c1"}, "Fix the build"}, + {session.UsageEntry{Kind: session.SubjectTask, Subject: "7"}, ""}, + {session.UsageEntry{Kind: session.SubjectTask, Subject: "7", Root: "c2"}, ""}, + {session.UsageEntry{Kind: session.SubjectAutomation, Subject: "nightly"}, "Nightly triage"}, + {session.UsageEntry{Subject: "c1"}, ""}, + } { + if got := names.of(c.entry); got != c.want { + t.Fatalf("%+v named %q, want %q", c.entry, got, c.want) + } + } +} + +func ledgerJSON(data []byte, err error) []byte { + if err != nil { + panic(err) + } + return data +} diff --git a/docs/changes/unreleased/0-app-spend-ledger.md b/docs/changes/unreleased/0-app-spend-ledger.md new file mode 100644 index 0000000000..d4ee3310b9 --- /dev/null +++ b/docs/changes/unreleased/0-app-spend-ledger.md @@ -0,0 +1,23 @@ +--- +kind: added +title: codeaf app reads and saves the spending ledger by run, with exact totals +pr: 0 +surface: [engine] +--- + +Was true: the app's only door onto the usage ledger was `spend.export`, which +writes every raw call in a range to a CSV under `/app/exports/`; nothing +paged the ledger, grouped it by execution, filtered it, or wrote where the +person asked. + +Is true now: `spend.ledger` answers a page (default 200, at most 500) of the +ledger for local calendar days, with the whole range's totals, and +`spend.ledger.export` writes the same filtered rows as CSV to an absolute +destination — atomically, mode 0600, never over an existing file without +`overwrite`. A row is a run (every call one execution made, recorded since +`UsageLine.run` existed) or a single call that carries no run; older, +auxiliary and late calls are never grouped into a pretend run, and unpriced +calls are counted, not shown as $0. Both methods are optional: an engine +without them answers `unknown_method`. `spend.export` is unchanged. Every +request reads the ledger linearly from the start; a range with more than +100 000 runs is refused, and a third concurrent reading answers `busy`. diff --git a/internal/appwire/spend_ledger.go b/internal/appwire/spend_ledger.go new file mode 100644 index 0000000000..61567e891d --- /dev/null +++ b/internal/appwire/spend_ledger.go @@ -0,0 +1,349 @@ +package appwire + +// ── THE SPENDING LEDGER: one filter, a page to read, a file to keep ───────── +// +// spend.export answers every call in a range as a raw CSV in the app's own +// folder, and it stays exactly as it is. spend.ledger and spend.ledger.export +// are the generic pair beside it: ONE FILTER, read twice — a page of rows with +// the whole range's totals, and a CSV of the same rows written where the caller +// says. A client that reads a preview and then saves it asks the same question +// both times, and the file holds what the preview counted at the moment of the +// save (a later save is a later reading; the ledger only grows). +// +// THE GRAIN IS SAID ON EVERY ROW. A "run" row is every call one immutable +// execution made, added up whatever models it used; a "call" row is one ledger +// line that carries no run — an older build's, an auxiliary call's, a late +// receipt's — and it is never grouped with anything. The totals count the two +// apart (runs, unknown_run_records) and every figure includes both. +// +// UNKNOWN IS ABSENT. A row's name is present only where the engine holds a +// title for the thing the money was spent on; kind, subject and the ids are +// present only where the ledger wrote them (and, on a run, where every call +// agreed). A cost nobody could price is absent with unpriced_records saying +// so — it is not $0. +// +// AN ENGINE WITHOUT THE CAPABILITY DOES NOT HAVE THE VERB: both methods answer +// unknown_method from an engine that is not a [SpendLedgerer]. + +import ( + "bytes" + "encoding/csv" + "encoding/json" + "path/filepath" + "strconv" + "strings" + "time" +) + +// The two grains a ledger row has. +const ( + SpendGrainRun = "run" + SpendGrainCall = "call" +) + +// The three things money is spent on, in the wire's words: a conversation is +// an agent here, as everywhere on this wire. +const ( + SpendKindAgent = "agent" + SpendKindTask = "task" + SpendKindAutomation = "automation" +) + +// The page sizes spend.ledger takes. +const ( + SpendLedgerLimitDefault = 200 + SpendLedgerLimitMax = 500 +) + +// SpendLedgerFilter is the one question both methods ask. from and to are +// local calendar days, both included, and required. Every other field is an +// exact match on what the ledger wrote; subject needs kind. +type SpendLedgerFilter struct { + From string `json:"from"` + To string `json:"to"` + Model string `json:"model,omitempty"` + Kind string `json:"kind,omitempty" enum:"agent,task,automation"` + Subject string `json:"subject,omitempty"` + Session string `json:"session,omitempty"` + Root string `json:"root,omitempty"` + Task string `json:"task,omitempty"` + Automation string `json:"automation,omitempty"` + Run string `json:"run,omitempty"` + Workspace string `json:"workspace,omitempty"` +} + +// SpendLedgerParams is spend.ledger's params: the filter, a page size +// (default 200, at most 500) and the cursor the last page answered. +type SpendLedgerParams struct { + SpendLedgerFilter + Limit int `json:"limit,omitempty"` + Cursor string `json:"cursor,omitempty"` +} + +// SpendLedgerExportParams is spend.ledger.export's params: the filter, an +// absolute destination, and whether an existing file there may be replaced. +type SpendLedgerExportParams struct { + SpendLedgerFilter + Destination string `json:"destination"` + Overwrite bool `json:"overwrite,omitempty"` +} + +// SpendLedgerRow is one row at its grain. time is the first call's instant and +// end the last's, both with the engine's local offset. +type SpendLedgerRow struct { + Grain string `json:"grain" enum:"run,call"` + Key string `json:"key"` + Run string `json:"run,omitempty"` + Time time.Time `json:"time"` + End time.Time `json:"end"` + Kind string `json:"kind,omitempty" enum:"agent,task,automation"` + Subject string `json:"subject,omitempty"` + Name string `json:"name,omitempty"` + Session string `json:"session,omitempty"` + Root string `json:"root,omitempty"` + Task string `json:"task,omitempty"` + Automation string `json:"automation,omitempty"` + Workspace string `json:"workspace,omitempty"` + Models []string `json:"models"` + // UnnamedModelRecords is calls whose model nobody wrote down. + UnnamedModelRecords int `json:"unnamed_model_records,omitempty"` + Records int `json:"records"` + Calls int `json:"calls"` + TokensIn int64 `json:"tokens_in"` + TokensOut int64 `json:"tokens_out"` + CostUSD *float64 `json:"cost_usd,omitempty"` + UnpricedRecords int `json:"unpriced_records,omitempty"` + UnbilledRecords int `json:"unbilled_records,omitempty"` +} + +// SpendLedgerTotals is the whole filtered range, whatever the page. rows is +// runs plus unknown_run_records; cost_usd is every priced record, and +// run_cost_usd and unknown_run_cost_usd its two halves. +type SpendLedgerTotals struct { + Rows int `json:"rows"` + Runs int `json:"runs"` + UnknownRunRecords int `json:"unknown_run_records"` + Records int `json:"records"` + Calls int `json:"calls"` + TokensIn int64 `json:"tokens_in"` + TokensOut int64 `json:"tokens_out"` + CostUSD *float64 `json:"cost_usd,omitempty"` + RunCostUSD *float64 `json:"run_cost_usd,omitempty"` + UnknownRunCostUSD *float64 `json:"unknown_run_cost_usd,omitempty"` + UnpricedRecords int `json:"unpriced_records"` + UnbilledRecords int `json:"unbilled_records"` +} + +// SpendLedgerSkipped is the ledger lines a reading passed over: lines that did +// not parse, and lines too long to hold. +type SpendLedgerSkipped struct { + Malformed int `json:"malformed"` + Oversized int `json:"oversized"` +} + +// SpendLedgerResult is spend.ledger's answer. next is absent on the last page. +type SpendLedgerResult struct { + From string `json:"from"` + To string `json:"to"` + Totals SpendLedgerTotals `json:"totals"` + Rows []SpendLedgerRow `json:"rows"` + Next string `json:"next,omitempty"` + Skipped SpendLedgerSkipped `json:"skipped"` +} + +// SpendLedgerExportResult is spend.ledger.export's answer: where the file is, +// how many rows and bytes it holds, and the totals of exactly those rows. +type SpendLedgerExportResult struct { + Path string `json:"path"` + Rows int `json:"rows"` + Bytes int64 `json:"bytes"` + Totals SpendLedgerTotals `json:"totals"` + Skipped SpendLedgerSkipped `json:"skipped"` +} + +// SpendLedgerAsk is spend.ledger as the engine is handed it: a checked filter, +// a page size already defaulted, and the cursor as it arrived. +type SpendLedgerAsk struct { + Filter SpendLedgerFilter + Limit int + Cursor string +} + +// SpendLedgerExportAsk is spend.ledger.export as the engine is handed it. +type SpendLedgerExportAsk struct { + Filter SpendLedgerFilter + Destination string + Overwrite bool +} + +// SpendLedgerer is the engine's half of the ledger. Both methods are optional: +// an [Engine] that is not one refuses them as unknown_method. +type SpendLedgerer interface { + SpendLedger(ask SpendLedgerAsk) (SpendLedgerResult, error) + ExportSpendLedger(ask SpendLedgerExportAsk) (SpendLedgerExportResult, error) +} + +func init() { + for name, m := range spendLedgerMethods { + methods[name] = m + } +} + +func spendLedgerer(e Engine, name string) (SpendLedgerer, error) { + l, ok := e.(SpendLedgerer) + if !ok { + return nil, Errorf(CodeUnknownMethod, "this engine does not serve %s", name) + } + return l, nil +} + +// decodeStrict reads params into v and refuses a field it does not know. A +// filter whose name was misspelled would otherwise be a filter silently not +// applied, and a file saved with every row in it. +func decodeStrict(name string, raw json.RawMessage, v any) error { + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(v); err != nil { + return Errorf(CodeBadParams, "%s: the params could not be read: %v", name, err) + } + return nil +} + +// checkSpendLedgerFilter trims the filter and refuses what cannot be asked. +// The days are checked as calendar days here; the engine reads them in its own +// zone. +func checkSpendLedgerFilter(f SpendLedgerFilter) (SpendLedgerFilter, error) { + for _, field := range []*string{&f.From, &f.To, &f.Model, &f.Kind, &f.Subject, &f.Session, &f.Root, &f.Task, &f.Automation, &f.Run, &f.Workspace} { + *field = strings.TrimSpace(*field) + if strings.ContainsAny(*field, "\x00\r\n") { + return f, Errorf(CodeBadParams, "a filter is one line") + } + } + from, err := time.Parse("2006-01-02", f.From) + if err != nil { + return f, Errorf(CodeBadParams, "from is a day, as 2026-10-15") + } + to, err := time.Parse("2006-01-02", f.To) + if err != nil { + return f, Errorf(CodeBadParams, "to is a day, as 2026-10-15") + } + if to.Before(from) { + return f, Errorf(CodeBadParams, "to is before from") + } + switch f.Kind { + case "", SpendKindAgent, SpendKindTask, SpendKindAutomation: + default: + return f, Errorf(CodeBadParams, "kind is agent, task or automation") + } + if f.Subject != "" && f.Kind == "" { + return f, Errorf(CodeBadParams, "subject needs its kind") + } + return f, nil +} + +var spendLedgerMethods = map[string]method{ + "spend.ledger": shaped[SpendLedgerParams](func(e Engine, raw json.RawMessage) (*SpendLedgerResult, error) { + l, err := spendLedgerer(e, "spend.ledger") + if err != nil { + return nil, err + } + var p SpendLedgerParams + if err := decodeStrict("spend.ledger", raw, &p); err != nil { + return nil, err + } + filter, err := checkSpendLedgerFilter(p.SpendLedgerFilter) + if err != nil { + return nil, err + } + switch { + case p.Limit == 0: + p.Limit = SpendLedgerLimitDefault + case p.Limit < 0 || p.Limit > SpendLedgerLimitMax: + return nil, Errorf(CodeBadParams, "limit is 1 to %d", SpendLedgerLimitMax) + } + if len(p.Cursor) > 1024 { + return nil, Errorf(CodeBadParams, "cursor is the next an earlier page answered") + } + result, err := l.SpendLedger(SpendLedgerAsk{Filter: filter, Limit: p.Limit, Cursor: strings.TrimSpace(p.Cursor)}) + if err != nil { + return nil, err + } + result.Rows = nonNil(result.Rows) + return &result, nil + }), + "spend.ledger.export": shaped[SpendLedgerExportParams](func(e Engine, raw json.RawMessage) (*SpendLedgerExportResult, error) { + l, err := spendLedgerer(e, "spend.ledger.export") + if err != nil { + return nil, err + } + var p SpendLedgerExportParams + if err := decodeStrict("spend.ledger.export", raw, &p); err != nil { + return nil, err + } + filter, err := checkSpendLedgerFilter(p.SpendLedgerFilter) + if err != nil { + return nil, err + } + dest := p.Destination + if strings.TrimSpace(dest) == "" || strings.ContainsAny(dest, "\x00\r\n") || !filepath.IsAbs(dest) || filepath.Clean(dest) != dest { + return nil, Errorf(CodeBadParams, "destination is an absolute, clean path to the file to write") + } + result, err := l.ExportSpendLedger(SpendLedgerExportAsk{Filter: filter, Destination: dest, Overwrite: p.Overwrite}) + if err != nil { + return nil, err + } + return &result, nil + }), +} + +// ── the file ──────────────────────────────────────────────────────────────── + +// SpendLedgerCSVHeader is spend.ledger.export's columns, in order. The first +// eight are the ones a person reads; by is always empty, because nothing in +// the ledger records who asked for a call, and the rest say the grain and the +// ids a row can be traced back by. +var SpendLedgerCSVHeader = []string{ + "time", "kind", "name", "models", "tokens_in", "tokens_out", "cost_usd", "by", + "grain", "run", "key", "subject", "session", "root", "task", "automation", "workspace", + "end_time", "records", "calls", "unpriced_records", "unbilled_records", "unnamed_model_records", +} + +// SpendLedgerCSVRecord is one row as its CSV record. name falls back to the +// subject's own id, and then to the run's, when nothing holds a title; a cost +// nobody could price is an empty cell. Every text cell that a spreadsheet +// would read as a formula is written with a leading apostrophe. +func SpendLedgerCSVRecord(row SpendLedgerRow) []string { + name := row.Name + if name == "" { + name = row.Subject + } + if name == "" { + name = row.Run + } + cost := "" + if row.CostUSD != nil { + cost = strconv.FormatFloat(*row.CostUSD, 'f', -1, 64) + } + count := func(n int) string { return strconv.Itoa(n) } + return []string{ + row.Time.Format(time.RFC3339), row.Kind, csvText(name), csvText(strings.Join(row.Models, ";")), + strconv.FormatInt(row.TokensIn, 10), strconv.FormatInt(row.TokensOut, 10), cost, "", + row.Grain, csvText(row.Run), row.Key, csvText(row.Subject), csvText(row.Session), csvText(row.Root), + csvText(row.Task), csvText(row.Automation), csvText(row.Workspace), + row.End.Format(time.RFC3339), count(row.Records), count(row.Calls), + count(row.UnpricedRecords), count(row.UnbilledRecords), count(row.UnnamedModelRecords), + } +} + +// csvText keeps a cell from being read as a formula by a spreadsheet. +func csvText(s string) string { + if s != "" && strings.ContainsRune("=+-@\t\r", rune(s[0])) { + return "'" + s + } + return s +} + +// NewSpendLedgerCSV is a CSV writer with the header already written. +func NewSpendLedgerCSV(w *csv.Writer) error { + return w.Write(SpendLedgerCSVHeader) +} diff --git a/internal/appwire/spend_ledger_test.go b/internal/appwire/spend_ledger_test.go new file mode 100644 index 0000000000..ec3d8ce718 --- /dev/null +++ b/internal/appwire/spend_ledger_test.go @@ -0,0 +1,231 @@ +package appwire + +import ( + "bufio" + "bytes" + "context" + "encoding/csv" + "encoding/json" + "io" + "strings" + "sync" + "testing" + "time" +) + +// ledgerEngine is an engine that serves the ledger, and remembers what it was +// asked. +type ledgerEngine struct { + fakeEngine + mu sync.Mutex + asks []SpendLedgerAsk + exports []SpendLedgerExportAsk +} + +func (l *ledgerEngine) SpendLedger(ask SpendLedgerAsk) (SpendLedgerResult, error) { + l.mu.Lock() + l.asks = append(l.asks, ask) + l.mu.Unlock() + if ask.Cursor == "stale" { + return SpendLedgerResult{}, Errorf(CodeBadParams, "the cursor does not belong to this query") + } + usd := 0.5 + return SpendLedgerResult{ + From: ask.Filter.From, To: ask.Filter.To, + Totals: SpendLedgerTotals{Rows: 2, Runs: 1, UnknownRunRecords: 1, Records: 3, Calls: 3, CostUSD: &usd, UnpricedRecords: 1}, + Rows: []SpendLedgerRow{ + {Grain: SpendGrainRun, Key: "run:r1", Run: "r1", Time: time.Date(2026, 10, 15, 9, 0, 0, 0, time.FixedZone("", -4*3600)), Models: []string{"a", "b"}, Records: 2, Calls: 2, CostUSD: &usd}, + {Grain: SpendGrainCall, Key: "call:0000000000000000", Models: []string{}, Records: 1, Calls: 1, UnpricedRecords: 1}, + }, + }, nil +} + +func (l *ledgerEngine) ExportSpendLedger(ask SpendLedgerExportAsk) (SpendLedgerExportResult, error) { + l.mu.Lock() + l.exports = append(l.exports, ask) + l.mu.Unlock() + return SpendLedgerExportResult{Path: ask.Destination, Rows: 2, Bytes: 10}, nil +} + +// serveOver is a running server over two pipes for any engine, as the app +// holds it; it answers one request line with the response that carries id 1. +func serveOver(t *testing.T, engine Engine) func(line string) map[string]any { + t.Helper() + inR, inW := io.Pipe() + outR, outW := io.Pipe() + go func() { + _ = NewServer(outW).Serve(context.Background(), inR, Hello{Version: "test"}, engine) + outW.Close() + }() + t.Cleanup(func() { inW.Close() }) + lines := bufio.NewScanner(outR) + lines.Buffer(make([]byte, 1<<20), 1<<20) + return func(line string) map[string]any { + t.Helper() + if _, err := io.WriteString(inW, line+"\n"); err != nil { + t.Fatal(err) + } + got := make(chan map[string]any, 1) + go func() { + for lines.Scan() { + var frame map[string]any + if json.Unmarshal(lines.Bytes(), &frame) == nil && frame["id"] == float64(1) { + got <- frame + return + } + } + got <- nil + }() + select { + case frame := <-got: + if frame == nil { + t.Fatal("the output ended") + } + return frame + case <-time.After(5 * time.Second): + t.Fatal("no answer arrived") + return nil + } + } +} + +func frameCode(frame map[string]any) string { + e, _ := frame["error"].(map[string]any) + code, _ := e["code"].(string) + return code +} + +// AN ENGINE WITHOUT THE CAPABILITY DOES NOT HAVE THE VERB, said on the wire +// exactly as an absent method is. +func TestSpendLedgerIsUnknownMethodWithoutTheCapability(t *testing.T) { + call := serveOver(t, &fakeEngine{}) + for _, name := range []string{"spend.ledger", "spend.ledger.export"} { + frame := call(`{"id":1,"method":"` + name + `","params":{"from":"2026-10-01","to":"2026-10-15","destination":"/tmp/x.csv"}}`) + if frameCode(frame) != CodeUnknownMethod { + t.Fatalf("%s: %v", name, frame) + } + } +} + +// THE PAGE ARRIVES AS THE WIRE SAYS: totals beside the rows, a cost nobody +// priced absent rather than zero, the default page size handed to the engine, +// and an empty list a list. +func TestSpendLedgerOnTheWire(t *testing.T) { + engine := &ledgerEngine{} + call := serveOver(t, engine) + frame := call(`{"id":1,"method":"spend.ledger","params":{"from":" 2026-10-01 ","to":"2026-10-15","kind":"task","subject":"7","root":"abc"}}`) + result, ok := frame["result"].(map[string]any) + if !ok { + t.Fatalf("no result: %v", frame) + } + if len(engine.asks) != 1 || engine.asks[0].Limit != SpendLedgerLimitDefault || engine.asks[0].Filter.From != "2026-10-01" || engine.asks[0].Filter.Kind != SpendKindTask { + t.Fatalf("the engine was asked %+v", engine.asks) + } + rows := result["rows"].([]any) + run, call0 := rows[0].(map[string]any), rows[1].(map[string]any) + if run["grain"] != "run" || run["cost_usd"] != 0.5 || run["time"] != "2026-10-15T09:00:00-04:00" { + t.Fatalf("run row %v", run) + } + if _, priced := call0["cost_usd"]; priced || call0["unpriced_records"] != float64(1) { + t.Fatalf("an unpriced call row %v", call0) + } + if _, named := call0["name"]; named { + t.Fatalf("a row nobody named has a name: %v", call0) + } + totals := result["totals"].(map[string]any) + if totals["runs"] != float64(1) || totals["unknown_run_records"] != float64(1) { + t.Fatalf("totals %v", totals) + } + if _, ok := result["next"]; ok { + t.Fatalf("a last page has a next: %v", result) + } + if code := frameCode(call(`{"id":1,"method":"spend.ledger","params":{"from":"2026-10-01","to":"2026-10-15","cursor":"stale"}}`)); code != CodeBadParams { + t.Fatalf("a stale cursor answered %q", code) + } +} + +// WHAT CANNOT BE ASKED IS REFUSED BEFORE THE ENGINE IS ASKED, and a field the +// method does not know is refused rather than ignored — a misspelled filter +// must not save every row. +func TestSpendLedgerRefusesBadParams(t *testing.T) { + engine := &ledgerEngine{} + for _, params := range []string{ + `{"to":"2026-10-15"}`, + `{"from":"2026-10-15","to":"2026-10-14"}`, + `{"from":"2026-02-30","to":"2026-03-01"}`, + `{"from":"15/10/2026","to":"2026-10-15"}`, + `{"from":"2026-10-01","to":"2026-10-15","limit":501}`, + `{"from":"2026-10-01","to":"2026-10-15","limit":-1}`, + `{"from":"2026-10-01","to":"2026-10-15","modle":"x"}`, + `{"from":"2026-10-01","to":"2026-10-15","kind":"chat"}`, + `{"from":"2026-10-01","to":"2026-10-15","subject":"7"}`, + `{"from":"2026-10-01","to":"2026-10-15","model":"a\nb"}`, + `{"from":"2026-10-01","to":"2026-10-15","cursor":"` + strings.Repeat("a", 1025) + `"}`, + } { + _, err := methods["spend.ledger"].call(engine, json.RawMessage(params)) + if wireCode(t, err) != CodeBadParams { + t.Fatalf("%s: %v", params, err) + } + } + for _, params := range []string{ + `{"from":"2026-10-01","to":"2026-10-15"}`, + `{"from":"2026-10-01","to":"2026-10-15","destination":"spend.csv"}`, + `{"from":"2026-10-01","to":"2026-10-15","destination":"/tmp/../tmp/spend.csv"}`, + `{"from":"2026-10-01","to":"2026-10-15","destination":"/tmp/spend.csv","limit":5}`, + `{"from":"2026-10-01","to":"2026-10-15","destination":"/tmp/sp\u0000end.csv"}`, + } { + _, err := methods["spend.ledger.export"].call(engine, json.RawMessage(params)) + if wireCode(t, err) != CodeBadParams { + t.Fatalf("export %s: %v", params, err) + } + } + if len(engine.asks) != 0 || len(engine.exports) != 0 { + t.Fatalf("the engine was asked %+v %+v", engine.asks, engine.exports) + } + out, err := methods["spend.ledger.export"].call(engine, json.RawMessage(`{"from":"2026-10-01","to":"2026-10-15","model":"m","destination":"/tmp/spend.csv","overwrite":true}`)) + if err != nil || out.(*SpendLedgerExportResult).Path != "/tmp/spend.csv" || !engine.exports[0].Overwrite || engine.exports[0].Filter.Model != "m" { + t.Fatalf("%v %+v", err, engine.exports) + } +} + +// A CELL IS ONE CELL whatever is in it — a quote, a comma, a newline — and a +// name a spreadsheet would run as a formula is written so it reads as text. +func TestSpendLedgerCSVRecord(t *testing.T) { + usd := 0.25 + rows := []SpendLedgerRow{ + {Grain: SpendGrainRun, Key: "run:r", Run: "r", Kind: SpendKindAgent, Subject: "abc", Name: "fix \"the\" thing,\nnow", Models: []string{"a", "b"}, TokensIn: 5, TokensOut: 6, CostUSD: &usd, Records: 2, Calls: 2}, + {Grain: SpendGrainCall, Key: "call:7", Name: "=HYPERLINK(\"x\")", Workspace: "-w", Records: 1, Calls: 1, UnpricedRecords: 1}, + {Grain: SpendGrainRun, Key: "run:q", Run: "q", Records: 1, Calls: 1}, + } + var buf bytes.Buffer + w := csv.NewWriter(&buf) + if err := NewSpendLedgerCSV(w); err != nil { + t.Fatal(err) + } + for _, row := range rows { + if err := w.Write(SpendLedgerCSVRecord(row)); err != nil { + t.Fatal(err) + } + } + w.Flush() + records, err := csv.NewReader(&buf).ReadAll() + if err != nil { + t.Fatal(err) + } + if len(records) != 4 || len(records[0]) != len(SpendLedgerCSVHeader) { + t.Fatalf("%d records of %d cells", len(records), len(records[0])) + } + col := map[string]int{} + for i, name := range records[0] { + col[name] = i + } + if records[1][col["name"]] != "fix \"the\" thing,\nnow" || records[1][col["models"]] != "a;b" || records[1][col["cost_usd"]] != "0.25" || records[1][col["kind"]] != "agent" { + t.Fatalf("row 1 %q", records[1]) + } + if records[2][col["name"]] != "'=HYPERLINK(\"x\")" || records[2][col["workspace"]] != "'-w" || records[2][col["cost_usd"]] != "" || records[2][col["unpriced_records"]] != "1" { + t.Fatalf("row 2 %q", records[2]) + } + if records[3][col["name"]] != "q" || records[3][col["by"]] != "" { + t.Fatalf("row 3 falls back to its run: %q", records[3]) + } +} diff --git a/internal/session/usage_query.go b/internal/session/usage_query.go new file mode 100644 index 0000000000..2d9741c6a7 --- /dev/null +++ b/internal/session/usage_query.go @@ -0,0 +1,698 @@ +package session + +// ── THE LEDGER, ASKED A QUESTION: one filter, exact totals, a page of rows ── +// +// [ReadUsage] answers every line since an instant, held in one slice, which is +// right for a page that sums the last two days and wrong for a person asking +// what a year cost: a year of calls is millions of rows, and the slice is all of +// them at once. This file is the other door. It READS THE FILE AS A STREAM — +// one bounded line at a time — and keeps only what the answer needs: the +// totals, the runs the range contains, and the one page of rows the caller +// asked for. +// +// THE GRAIN IS SAID, NEVER IMPLIED. A row of this ledger is one of two things: +// +// - a RUN: every call made by one immutable execution ([UsageLine.Run]), +// added up into one row whatever models it used; +// - a CALL: one ledger line that carries no run, kept as its own row. +// +// A line without a run is an older build's line, an auxiliary call made beside +// the turn, or a receipt that arrived late. Nothing on it says which execution +// it belonged to, so it is NEVER folded into a run and NEVER folded with other +// such lines into a pretend "legacy" run: each one is its own record, counted +// apart from the runs, and its money is in every total. +// +// MONEY NOBODY COULD PRICE IS NOT FREE. A line whose figure is zero is a line +// nobody could price ([UsageLine.USD]), so a row counts those apart and a row +// made only of them has no cost at all rather than a cost of nothing. +// +// IT IS LINEAR AND SAYS SO. Every question reads the file from its first byte; +// there is no index and no cache, so nothing grows with the history except the +// time a scan takes. What it HOLDS is bounded: the runs in the range, under an +// explicit budget ([UsageLedgerBudget]) that refuses — asking for a shorter +// range — rather than answering with less than the range holds; and a page. + +import ( + "bufio" + "bytes" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "hash/fnv" + "io" + "io/fs" + "os" + "sort" + "strconv" + "strings" + "time" +) + +// The two grains a ledger row can have. +const ( + UsageGrainRun = "run" + UsageGrainCall = "call" +) + +// The page sizes a ledger question may ask for: the default when it names +// none, and the most it may name. +const ( + UsageLedgerPageDefault = 200 + UsageLedgerPageMax = 500 +) + +// usageScanSlots is how many full ledger scans may run at once in this +// process. A scan reads the whole file, so a third one beside two is a third +// pass over the same disk that answers nobody sooner; it is refused at once, +// with [ErrUsageScanBusy], rather than queued behind the others. +var usageScanSlots = make(chan struct{}, 2) + +var ( + // ErrUsageScanBusy is a scan refused because enough are already running. + ErrUsageScanBusy = errors.New("the spending ledger is already being read; try again in a moment") + // ErrUsageLedgerTooLarge is a range holding more runs than the budget lets + // one answer hold. It is refused rather than truncated. + ErrUsageLedgerTooLarge = errors.New("this range holds too many runs to answer at once; ask for a shorter date range") + // ErrUsageCursor is a cursor this query did not make. + ErrUsageCursor = errors.New("the cursor does not belong to this query; start again without one") + // ErrUsageQuery is a query that cannot be asked as written. + ErrUsageQuery = errors.New("the spending query is not valid") +) + +// UsageQuery is one question to the ledger. Every field is optional and every +// filter is EXACT — the value as the ledger wrote it, after trimming — because +// a filter that matched loosely would make an export say something different +// from the preview it was saved from. +type UsageQuery struct { + // From and To are the instants a line's [UsageLine.At] must fall in, + // [From, To). A zero bound is open. [UsageDayRange] makes them from local + // calendar days. + From, To time.Time + // Model is the provider's model id. + Model string + // Kind and Subject are what the money was spent on, classified as + // [UsageBySubject] classifies it: Kind is one of [SubjectTask], + // [SubjectAutomation], [SubjectConversation], and Subject — which needs a + // Kind — is that thing's id. + Kind, Subject string + // The rest match one ledger field each. + Session, Root, Task, Automation, Run, Workspace string +} + +// UsageLedgerBudget bounds what one answer may hold beside its page. +type UsageLedgerBudget struct { + // MaxRuns is the most distinct runs a range may hold. + MaxRuns int + // MaxBytes is an estimate of the memory those runs may take. + MaxBytes int64 +} + +// DefaultUsageLedgerBudget is the budget a caller with no reason to choose +// another uses. A run row is a few hundred bytes, so this is tens of megabytes +// at the very most, and far more runs than a year of ordinary use makes. +var DefaultUsageLedgerBudget = UsageLedgerBudget{MaxRuns: 100_000, MaxBytes: 64 << 20} + +// UsageEntry is one row of the ledger at its stated grain. +type UsageEntry struct { + // Grain is [UsageGrainRun] or [UsageGrainCall]. + Grain string + // Key is the row's stable identity: "run:" and the run's id, or "call:" and + // the line's byte offset in the ledger, which never moves because the + // ledger is only ever appended to. + Key string + // Run is the execution's id, and empty on a call row. + Run string + // Offset is a call row's byte offset, and -1 on a run row. + Offset int64 + // Start and End are the earliest and latest call instants in the row. + Start, End time.Time + // Kind and Subject are what the money was spent on ([UsageBySubject]'s + // classification). On a run row these, and the ids below, are set only + // where every call in the run agrees; a field the calls disagree on is + // left empty rather than chosen. + Kind, Subject string + Session, Root, Task, Automation, Workspace string + // Models are the distinct model ids that answered, sorted; a call that + // named no model is counted in UnnamedModel instead. + Models []string + UnnamedModel int + // Records is ledger lines; Calls is provider requests, where a line that + // does not say counts as the one request every line is at least. + Records, Calls int + Input, Output int64 + // USD is the sum of the priced lines. Unpriced counts the lines with no + // figure, and Unbilled those marked as a missing receipt (also unpriced). + USD float64 + Unpriced, Unbilled int + + bytes int64 + emitted bool +} + +// Priced reports whether any line in the row carried a figure: a row with +// none has no cost, which is not the same as a cost of nothing. +func (e UsageEntry) Priced() bool { return e.Records > e.Unpriced } + +// UsageLedgerTotals is the whole filtered range, exactly, whatever the page. +type UsageLedgerTotals struct { + // Entries is the rows the range holds: Runs plus UnknownRunRecords. + Entries int + // Runs is distinct executions; UnknownRunRecords is lines that carry none, + // each its own row. + Runs, UnknownRunRecords int + Records, Calls int + Input, Output int64 + // USD is every priced line; RunUSD and UnknownRunUSD are its two halves. + USD, RunUSD, UnknownRunUSD float64 + Unpriced, Unbilled int +} + +// UsageScanStats is what the reader passed over to give its answer. +type UsageScanStats struct { + // Bytes is the whole lines read; a line still being written is not one. + Bytes int64 + // Malformed is lines that did not parse or had no instant; Oversized is + // lines longer than the reader will hold. Both are skipped, as + // [ReadUsage] skips them. + Malformed, Oversized int +} + +// UsageLedgerPage is one question's answer. +type UsageLedgerPage struct { + Totals UsageLedgerTotals + Entries []UsageEntry + // Next is the cursor for the rows after these, and empty on the last page. + Next string + Stats UsageScanStats +} + +// UsageDayRange is the instants of the local calendar days from and to, both +// included: midnight of from up to midnight after to, in loc. It is computed +// from the calendar rather than by adding hours, so a day that is 23 or 25 +// hours long is still exactly that day. +func UsageDayRange(from, to string, loc *time.Location) (time.Time, time.Time, error) { + if loc == nil { + loc = time.Local + } + start, err := time.ParseInLocation(usageDayLayout, strings.TrimSpace(from), loc) + if err != nil { + return time.Time{}, time.Time{}, fmt.Errorf("%w: from is a day, as 2026-10-15", ErrUsageQuery) + } + last, err := time.ParseInLocation(usageDayLayout, strings.TrimSpace(to), loc) + if err != nil { + return time.Time{}, time.Time{}, fmt.Errorf("%w: to is a day, as 2026-10-15", ErrUsageQuery) + } + if last.Before(start) { + return time.Time{}, time.Time{}, fmt.Errorf("%w: to is before from", ErrUsageQuery) + } + end := time.Date(last.Year(), last.Month(), last.Day()+1, 0, 0, 0, 0, loc) + return start, end, nil +} + +// normalized trims every filter and checks the query can be asked. +func (q UsageQuery) normalized() (UsageQuery, error) { + for _, field := range []*string{&q.Model, &q.Kind, &q.Subject, &q.Session, &q.Root, &q.Task, &q.Automation, &q.Run, &q.Workspace} { + *field = strings.TrimSpace(*field) + } + switch q.Kind { + case "", SubjectTask, SubjectAutomation, SubjectConversation: + default: + return q, fmt.Errorf("%w: kind is task, automation or conversation", ErrUsageQuery) + } + if q.Subject != "" && q.Kind == "" { + return q, fmt.Errorf("%w: a subject needs its kind", ErrUsageQuery) + } + if !q.From.IsZero() && !q.To.IsZero() && !q.To.After(q.From) { + return q, fmt.Errorf("%w: the range is empty", ErrUsageQuery) + } + return q, nil +} + +// fingerprint is the query, hashed, so a cursor can say which query made it. +func (q UsageQuery) fingerprint() string { + h := fnv.New64a() + for _, part := range []string{ + strconv.FormatInt(q.From.UnixNano(), 10), strconv.FormatInt(q.To.UnixNano(), 10), + q.Model, q.Kind, q.Subject, q.Session, q.Root, q.Task, q.Automation, q.Run, q.Workspace, + } { + h.Write([]byte(part)) + h.Write([]byte{0}) + } + return strconv.FormatUint(h.Sum64(), 36) +} + +// usageSubject is what a line's money was spent on, by [UsageBySubject]'s +// precedence: automation (or the standing item it replaced), then task, then +// the conversation the work was rooted in, then the line's own conversation. +// A test holds the two to the same answer on every fixture. +func usageSubject(line UsageLine) (kind, id string) { + switch { + case strings.TrimSpace(line.Automation) != "": + return SubjectAutomation, strings.TrimSpace(line.Automation) + case strings.TrimSpace(line.Standing) != "": + return SubjectAutomation, strings.TrimSpace(line.Standing) + case strings.TrimSpace(line.Task) != "": + return SubjectTask, strings.TrimSpace(line.Task) + case strings.TrimSpace(line.Root) != "": + return SubjectConversation, strings.TrimSpace(line.Root) + case strings.TrimSpace(line.Session) != "": + return SubjectConversation, strings.TrimSpace(line.Session) + } + return "", "" +} + +// matches is the one filter every reader of a query applies. +func (q UsageQuery) matches(line UsageLine) bool { + if !q.From.IsZero() && line.At.Before(q.From) { + return false + } + if !q.To.IsZero() && !line.At.Before(q.To) { + return false + } + exact := func(want, have string) bool { return want == "" || want == strings.TrimSpace(have) } + if !exact(q.Model, line.Model) || !exact(q.Session, line.Session) || !exact(q.Root, line.Root) || + !exact(q.Task, line.Task) || !exact(q.Automation, line.Automation) || !exact(q.Run, line.Run) || + !exact(q.Workspace, line.Workspace) { + return false + } + if q.Kind != "" { + kind, id := usageSubject(line) + if kind != q.Kind || (q.Subject != "" && id != q.Subject) { + return false + } + } + return true +} + +// ── reading the file a bounded line at a time ─────────────────────────────── + +// scanUsageLines calls fn with every whole, parseable line of r and its byte +// offset, reading no further than limit bytes when limit is not negative. +// +// IT HOLDS ONE LINE AT A TIME AND NEVER MORE THAN [usageLineBytes] OF IT. A line +// longer than that is read past in pieces and skipped, but its bytes are still +// counted as consumed — [scanUsage]'s rule, so one absurd row costs its own +// record and never the rows after it. A last line with no newline is a write in +// flight and is neither parsed nor counted, as there. +func scanUsageLines(r io.Reader, limit int64, fn func(offset int64, line UsageLine) error) (UsageScanStats, error) { + var stats UsageScanStats + if limit >= 0 { + r = io.LimitReader(r, limit) + } + buffered := bufio.NewReaderSize(r, usageLineBytes) + for { + chunk, err := buffered.ReadSlice('\n') + if errors.Is(err, bufio.ErrBufferFull) { + // Longer than the reader will hold: read past the rest of it. + size := int64(len(chunk)) + for errors.Is(err, bufio.ErrBufferFull) { + chunk, err = buffered.ReadSlice('\n') + size += int64(len(chunk)) + } + if err != nil { + return stats, usageTailError(err) + } + stats.Bytes += size + stats.Oversized++ + continue + } + if err != nil { + return stats, usageTailError(err) + } + offset := stats.Bytes + stats.Bytes += int64(len(chunk)) + var line UsageLine + if json.Unmarshal(bytes.TrimSpace(chunk), &line) != nil || line.At.IsZero() { + stats.Malformed++ + continue + } + if err := fn(offset, line); err != nil { + return stats, err + } + } +} + +// usageTailError is the end of the reading: the end of the file (with or +// without a partial line after the last whole one) is no error at all. +func usageTailError(err error) error { + if errors.Is(err, io.EOF) { + return nil + } + return err +} + +// openUsageScan takes a scan slot and opens the ledger. A ledger that is not +// there is a machine that has spent nothing, and answers a nil file. +func openUsageScan(path string) (*os.File, func(), error) { + select { + case usageScanSlots <- struct{}{}: + default: + return nil, nil, ErrUsageScanBusy + } + release := func() { <-usageScanSlots } + if strings.TrimSpace(path) == "" { + return nil, release, nil + } + file, err := os.Open(path) + if errors.Is(err, fs.ErrNotExist) { + return nil, release, nil + } + if err != nil { + release() + return nil, nil, err + } + return file, func() { file.Close(); release() }, nil +} + +// ── folding lines into rows ───────────────────────────────────────────────── + +// usageRunBytes is the estimate of one run row's own weight, before its +// strings: the struct, its map slot, its key. +const usageRunBytes = 256 + +// add folds one line into the row. +func (e *UsageEntry) add(line UsageLine) { + kind, subject := usageSubject(line) + first := e.Records == 0 + agree := func(have *string, value string) { + if first { + *have = value + } else if *have != value { + *have = "" + } + } + agree(&e.Kind, kind) + agree(&e.Subject, subject) + agree(&e.Session, strings.TrimSpace(line.Session)) + agree(&e.Root, strings.TrimSpace(line.Root)) + agree(&e.Task, strings.TrimSpace(line.Task)) + agree(&e.Automation, strings.TrimSpace(line.Automation)) + agree(&e.Workspace, strings.TrimSpace(line.Workspace)) + if first || line.At.Before(e.Start) { + e.Start = line.At + } + if first || line.At.After(e.End) { + e.End = line.At + } + if model := strings.TrimSpace(line.Model); model == "" { + e.UnnamedModel++ + } else if i := sort.SearchStrings(e.Models, model); i == len(e.Models) || e.Models[i] != model { + e.Models = append(e.Models, "") + copy(e.Models[i+1:], e.Models[i:]) + e.Models[i] = model + e.bytes += int64(len(model)) + 16 + } + e.Records++ + e.Calls += max(line.Calls, 1) + e.Input += int64(line.Input) + e.Output += int64(line.Output) + if line.USD > 0 { + e.USD += line.USD + } else { + e.Unpriced++ + } + if line.Unbilled { + e.Unbilled++ + } + if first { + e.bytes += usageRunBytes + int64(len(e.Key)+len(e.Session)+len(e.Root)+len(e.Task)+len(e.Automation)+len(e.Workspace)+len(e.Subject)) + } +} + +// count adds one line to the totals. +func (t *UsageLedgerTotals) count(line UsageLine) { + t.Records++ + t.Calls += max(line.Calls, 1) + t.Input += int64(line.Input) + t.Output += int64(line.Output) + if line.USD > 0 { + t.USD += line.USD + if line.Run != "" { + t.RunUSD += line.USD + } else { + t.UnknownRunUSD += line.USD + } + } else { + t.Unpriced++ + } + if line.Unbilled { + t.Unbilled++ + } + if line.Run == "" { + t.UnknownRunRecords++ + } +} + +func usageCallEntry(offset int64, line UsageLine) UsageEntry { + e := UsageEntry{Grain: UsageGrainCall, Key: fmt.Sprintf("call:%016d", offset), Offset: offset} + e.add(line) + return e +} + +// usageRuns folds the runs a scan meets, under the budget. +type usageRuns struct { + budget UsageLedgerBudget + rows map[string]*UsageEntry + bytes int64 +} + +func newUsageRuns(budget UsageLedgerBudget) *usageRuns { + if budget.MaxRuns <= 0 { + budget.MaxRuns = DefaultUsageLedgerBudget.MaxRuns + } + if budget.MaxBytes <= 0 { + budget.MaxBytes = DefaultUsageLedgerBudget.MaxBytes + } + return &usageRuns{budget: budget, rows: map[string]*UsageEntry{}} +} + +func (r *usageRuns) add(line UsageLine) error { + row := r.rows[line.Run] + if row == nil { + if len(r.rows) >= r.budget.MaxRuns { + return fmt.Errorf("%w (more than %d runs)", ErrUsageLedgerTooLarge, r.budget.MaxRuns) + } + row = &UsageEntry{Grain: UsageGrainRun, Key: "run:" + line.Run, Run: line.Run, Offset: -1} + r.rows[line.Run] = row + } + before := row.bytes + row.add(line) + r.bytes += row.bytes - before + if r.bytes > r.budget.MaxBytes { + return fmt.Errorf("%w (more than %d bytes of runs)", ErrUsageLedgerTooLarge, r.budget.MaxBytes) + } + return nil +} + +// ── the page ──────────────────────────────────────────────────────────────── + +// usageBefore is the page order: newest start first, then key, so two rows +// that started in the same instant still have one order every page agrees on. +func usageBefore(a, b UsageEntry) bool { + if !a.Start.Equal(b.Start) { + return a.Start.After(b.Start) + } + return a.Key < b.Key +} + +// usageCursor is where a page ended: the last row's start and key, and the +// query that made it. +type usageCursor struct { + V int `json:"v"` + Start int64 `json:"s"` + Key string `json:"k"` + Query string `json:"q"` +} + +func encodeUsageCursor(q UsageQuery, last UsageEntry) string { + raw, _ := json.Marshal(usageCursor{V: 1, Start: last.Start.UnixNano(), Key: last.Key, Query: q.fingerprint()}) + return base64.RawURLEncoding.EncodeToString(raw) +} + +func decodeUsageCursor(q UsageQuery, cursor string) (*UsageEntry, error) { + cursor = strings.TrimSpace(cursor) + if cursor == "" { + return nil, nil + } + if len(cursor) > 1024 { + return nil, ErrUsageCursor + } + raw, err := base64.RawURLEncoding.DecodeString(cursor) + if err != nil { + return nil, ErrUsageCursor + } + var c usageCursor + decoder := json.NewDecoder(bytes.NewReader(raw)) + decoder.DisallowUnknownFields() + if decoder.Decode(&c) != nil || c.V != 1 || c.Key == "" || c.Query != q.fingerprint() { + return nil, ErrUsageCursor + } + if _, err := decoder.Token(); !errors.Is(err, io.EOF) { + return nil, ErrUsageCursor + } + if !strings.HasPrefix(c.Key, "run:") && !strings.HasPrefix(c.Key, "call:") { + return nil, ErrUsageCursor + } + return &UsageEntry{Start: time.Unix(0, c.Start), Key: c.Key}, nil +} + +// QueryUsageLedger answers one page of the ledger at path: the totals of +// everything the query matches, exactly, and up to limit rows after cursor in +// page order (newest first). A limit of zero is the default page; one past the +// maximum is refused. +// +// THE TOTALS DO NOT DEPEND ON THE PAGE. They are summed from every matching +// line on the way past, so a page of one and a page of five hundred say the +// same totals, and paging to the end visits exactly Totals.Entries rows. +// +// A PAGE IS READ FRESH. The ledger grows while a person reads it; a later page +// is a later reading, and the cursor — a position in the order, not an offset +// into a list — keeps the rows already seen from being seen again. +func QueryUsageLedger(path string, q UsageQuery, cursor string, limit int, budget UsageLedgerBudget) (UsageLedgerPage, error) { + q, err := q.normalized() + if err != nil { + return UsageLedgerPage{}, err + } + switch { + case limit == 0: + limit = UsageLedgerPageDefault + case limit < 0 || limit > UsageLedgerPageMax: + return UsageLedgerPage{}, fmt.Errorf("%w: limit is 1 to %d", ErrUsageQuery, UsageLedgerPageMax) + } + after, err := decodeUsageCursor(q, cursor) + if err != nil { + return UsageLedgerPage{}, err + } + file, done, err := openUsageScan(path) + if err != nil { + return UsageLedgerPage{}, err + } + defer done() + + var page UsageLedgerPage + runs := newUsageRuns(budget) + // THE CALL ROWS ARE NOT HELD, only the best limit+1 of them after the + // cursor: a buffer that is trimmed back whenever it doubles, so a range of + // a million unattributed calls costs a page's worth of memory. + var calls []UsageEntry + callsAfter := 0 + keep := limit + 1 + trim := func() { + sort.Slice(calls, func(i, j int) bool { return usageBefore(calls[i], calls[j]) }) + if len(calls) > keep { + calls = calls[:keep] + } + } + if file != nil { + page.Stats, err = scanUsageLines(file, -1, func(offset int64, line UsageLine) error { + line.Run = strings.TrimSpace(line.Run) + if !q.matches(line) { + return nil + } + page.Totals.count(line) + if line.Run != "" { + return runs.add(line) + } + entry := usageCallEntry(offset, line) + if after != nil && !usageBefore(*after, entry) { + return nil + } + callsAfter++ + calls = append(calls, entry) + if len(calls) >= 2*keep { + trim() + } + return nil + }) + if err != nil { + return UsageLedgerPage{}, err + } + } + page.Totals.Runs = len(runs.rows) + page.Totals.Entries = page.Totals.Runs + page.Totals.UnknownRunRecords + + candidates := calls + remaining := callsAfter + for _, row := range runs.rows { + if after != nil && !usageBefore(*after, *row) { + continue + } + remaining++ + candidates = append(candidates, *row) + } + sort.Slice(candidates, func(i, j int) bool { return usageBefore(candidates[i], candidates[j]) }) + if len(candidates) > limit { + candidates = candidates[:limit] + } + page.Entries = candidates + if remaining > len(candidates) && len(candidates) > 0 { + page.Next = encodeUsageCursor(q, candidates[len(candidates)-1]) + } + return page, nil +} + +// EachUsageLedgerEntry hands fn every row the query matches, in LEDGER ORDER — +// the order the calls were recorded in, a run placed at its first call — and +// answers the totals of exactly the rows it handed over. +// +// IT IS TWO PASSES OVER ONE PREFIX OF THE FILE. The first adds up the runs, +// because a run's last call may be the file's last line; the second walks the +// same bytes again — no further, though the ledger may have grown since — +// handing each unattributed call over as it is met and each run where it +// begins. So what is written is exactly what was counted, and nothing but the +// runs is ever held. +func EachUsageLedgerEntry(path string, q UsageQuery, budget UsageLedgerBudget, fn func(UsageEntry) error) (UsageLedgerTotals, UsageScanStats, error) { + q, err := q.normalized() + if err != nil { + return UsageLedgerTotals{}, UsageScanStats{}, err + } + file, done, err := openUsageScan(path) + if err != nil { + return UsageLedgerTotals{}, UsageScanStats{}, err + } + defer done() + var totals UsageLedgerTotals + if file == nil { + return totals, UsageScanStats{}, nil + } + runs := newUsageRuns(budget) + stats, err := scanUsageLines(file, -1, func(_ int64, line UsageLine) error { + line.Run = strings.TrimSpace(line.Run) + if !q.matches(line) { + return nil + } + totals.count(line) + if line.Run != "" { + return runs.add(line) + } + return nil + }) + if err != nil { + return UsageLedgerTotals{}, stats, err + } + totals.Runs = len(runs.rows) + totals.Entries = totals.Runs + totals.UnknownRunRecords + if _, err := file.Seek(0, io.SeekStart); err != nil { + return UsageLedgerTotals{}, stats, err + } + _, err = scanUsageLines(file, stats.Bytes, func(offset int64, line UsageLine) error { + line.Run = strings.TrimSpace(line.Run) + if !q.matches(line) { + return nil + } + if line.Run == "" { + return fn(usageCallEntry(offset, line)) + } + row := runs.rows[line.Run] + if row == nil || row.emitted { + return nil + } + row.emitted = true + return fn(*row) + }) + if err != nil { + return UsageLedgerTotals{}, stats, err + } + return totals, stats, nil +} diff --git a/internal/session/usage_query_test.go b/internal/session/usage_query_test.go new file mode 100644 index 0000000000..3f60e2d841 --- /dev/null +++ b/internal/session/usage_query_test.go @@ -0,0 +1,443 @@ +package session + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + _ "time/tzdata" +) + +// writeLedger writes lines to a fresh ledger file, one JSON object each, and +// answers its path. tail, when not empty, is appended without a newline — a +// write caught in flight. +func writeLedger(t *testing.T, lines []UsageLine, raw []string, tail string) string { + t.Helper() + var b strings.Builder + for _, line := range lines { + data, err := json.Marshal(line) + if err != nil { + t.Fatal(err) + } + b.Write(data) + b.WriteByte('\n') + } + for _, r := range raw { + b.WriteString(r) + b.WriteByte('\n') + } + b.WriteString(tail) + path := filepath.Join(t.TempDir(), UsageLedgerName) + if err := os.WriteFile(path, []byte(b.String()), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +// A unit of money every sum below can hold exactly, whatever the order it is +// added in: a power of two. +const cent = 1.0 / 1024 + +// bigLedger is 5 000 calls in 50 runs that alternate two models, and 6 000 +// calls with no run — some auxiliary and unpriced, some unbilled, some late — +// spread over three local days. +func bigLedger(t *testing.T, zone *time.Location) ([]UsageLine, string) { + t.Helper() + base := time.Date(2026, 10, 12, 8, 0, 0, 0, zone) + var lines []UsageLine + for r := 0; r < 50; r++ { + for c := 0; c < 100; c++ { + at := base.Add(time.Duration(r)*time.Hour + time.Duration(c)*time.Second) + lines = append(lines, UsageLine{ + Run: fmt.Sprintf("run%02d", r), At: at, Day: at.Format(usageDayLayout), + Model: []string{"m/alpha", "m/beta"}[c%2], Calls: 1, Input: 10, Output: 3, USD: cent, + Session: fmt.Sprintf("s%02d", r%5), Workspace: "/w", + }) + } + } + for i := 0; i < 6000; i++ { + at := base.Add(time.Duration(i) * 30 * time.Second) + line := UsageLine{At: at, Day: at.Format(usageDayLayout), Model: "m/aux", Calls: 1, Input: 1, Output: 1, Session: "s00", Role: "title"} + switch i % 3 { + case 0: + line.USD = 2 * cent + case 1: + line.Unbilled = true + case 2: + line.Task, line.Root, line.USD = "7", "s01", cent + } + lines = append(lines, line) + } + return lines, writeLedger(t, lines, nil, "") +} + +func allPages(t *testing.T, path string, q UsageQuery, limit int) ([]UsageEntry, []UsageLedgerTotals) { + t.Helper() + var rows []UsageEntry + var totals []UsageLedgerTotals + cursor := "" + for { + page, err := QueryUsageLedger(path, q, cursor, limit, DefaultUsageLedgerBudget) + if err != nil { + t.Fatal(err) + } + rows = append(rows, page.Entries...) + totals = append(totals, page.Totals) + if page.Next == "" { + return rows, totals + } + if len(page.Entries) != limit { + t.Fatalf("a page with a next held %d rows, not %d", len(page.Entries), limit) + } + cursor = page.Next + } +} + +// A RUN IS ONE ROW WHATEVER MODELS IT USED, AND A CALL WITH NO RUN IS ALWAYS ITS +// OWN: eleven thousand calls are 50 run rows and 6 000 call rows, with every +// figure exact and the unpriced calls counted, not called free. +func TestLedgerGroupsRunsAndKeepsUnknownCallsApart(t *testing.T) { + lines, path := bigLedger(t, time.UTC) + page, err := QueryUsageLedger(path, UsageQuery{}, "", 500, DefaultUsageLedgerBudget) + if err != nil { + t.Fatal(err) + } + tot := page.Totals + if tot.Runs != 50 || tot.UnknownRunRecords != 6000 || tot.Entries != 6050 || tot.Records != len(lines) || tot.Calls != len(lines) { + t.Fatalf("totals %+v", tot) + } + if tot.RunUSD != 5000*cent || tot.UnknownRunUSD != 6000*cent || tot.USD != 11000*cent { + t.Fatalf("money %+v", tot) + } + if tot.Unpriced != 2000 || tot.Unbilled != 2000 { + t.Fatalf("unpriced %d unbilled %d", tot.Unpriced, tot.Unbilled) + } + if tot.Input != 50*100*10+6000 || tot.Output != 50*100*3+6000 { + t.Fatalf("tokens %d/%d", tot.Input, tot.Output) + } + rows, _ := allPages(t, path, UsageQuery{}, 500) + runs, calls := 0, 0 + for _, row := range rows { + switch row.Grain { + case UsageGrainRun: + runs++ + if row.Records != 100 || row.USD != 100*cent || len(row.Models) != 2 || row.Models[0] != "m/alpha" || row.Models[1] != "m/beta" { + t.Fatalf("run row %+v", row) + } + if row.End.Sub(row.Start) != 99*time.Second || row.Session == "" || row.Kind != SubjectConversation { + t.Fatalf("run row span or identity %+v", row) + } + case UsageGrainCall: + calls++ + if row.Records != 1 || row.Run != "" { + t.Fatalf("call row %+v", row) + } + if row.Unbilled == 1 && (row.Priced() || row.Unpriced != 1) { + t.Fatalf("an unbilled call reads as priced: %+v", row) + } + } + } + if runs != 50 || calls != 6000 { + t.Fatalf("%d runs and %d calls", runs, calls) + } +} + +// THE TOTALS DO NOT DEPEND ON THE PAGE, and paging visits every row once in +// one order whatever the page size. +func TestLedgerPagesAreIndependentOfTheirSize(t *testing.T) { + _, path := bigLedger(t, time.UTC) + var want []string + for _, limit := range []int{500, 137} { + rows, totals := allPages(t, path, UsageQuery{}, limit) + for _, tot := range totals { + if tot != totals[0] { + t.Fatalf("limit %d: totals moved between pages: %+v vs %+v", limit, tot, totals[0]) + } + } + var keys []string + seen := map[string]bool{} + for i, row := range rows { + if seen[row.Key] { + t.Fatalf("limit %d: %s twice", limit, row.Key) + } + seen[row.Key] = true + keys = append(keys, row.Key) + if i > 0 && !usageBefore(rows[i-1], row) { + t.Fatalf("limit %d: out of order at %d", limit, i) + } + } + if len(keys) != totals[0].Entries { + t.Fatalf("limit %d: %d rows for %d entries", limit, len(keys), totals[0].Entries) + } + if want == nil { + want = keys + } else if strings.Join(want, ",") != strings.Join(keys, ",") { + t.Fatalf("limit %d visits a different order", limit) + } + } + // And a page of one, on a small ledger. + small := writeLedger(t, []UsageLine{ + {Run: "a", At: time.Unix(100, 0), USD: cent}, {At: time.Unix(100, 0)}, {At: time.Unix(100, 0)}, {Run: "a", At: time.Unix(50, 0)}, + }, nil, "") + rows, _ := allPages(t, small, UsageQuery{}, 1) + if len(rows) != 3 { + t.Fatalf("a page of one visited %d rows", len(rows)) + } +} + +// THE FILE EXPORT AND THE PAGE ASK ONE QUESTION: every filter the page applies, +// the walk applies, and the two answer the same rows and the same totals. +func TestLedgerFiltersAreTheSameForPagesAndTheWholeWalk(t *testing.T) { + _, path := bigLedger(t, time.UTC) + for _, q := range []UsageQuery{ + {Model: "m/beta"}, + {Model: "m/aux"}, + {Kind: SubjectTask, Subject: "7"}, + {Kind: SubjectConversation, Subject: "s00"}, + {Root: "s01"}, + {Run: "run07"}, + {Session: " s03 "}, + {Workspace: "/w", From: time.Date(2026, 10, 13, 0, 0, 0, 0, time.UTC), To: time.Date(2026, 10, 14, 0, 0, 0, 0, time.UTC)}, + } { + rows, totals := allPages(t, path, q, 500) + var walked []UsageEntry + walkTotals, _, err := EachUsageLedgerEntry(path, q, DefaultUsageLedgerBudget, func(e UsageEntry) error { + walked = append(walked, e) + return nil + }) + if err != nil { + t.Fatal(err) + } + if walkTotals != totals[0] || len(walked) != len(rows) || len(rows) == 0 { + t.Fatalf("%+v: walk %+v (%d rows), pages %+v (%d rows)", q, walkTotals, len(walked), totals[0], len(rows)) + } + byKey := map[string]UsageEntry{} + for _, e := range rows { + byKey[e.Key] = e + } + for _, e := range walked { + p, ok := byKey[e.Key] + if !ok || p.Records != e.Records || p.USD != e.USD || p.Start != e.Start { + t.Fatalf("%+v: walked row %s differs from its page row", q, e.Key) + } + } + } + // A model filter on a run keeps that run's other model out of it. + page, err := QueryUsageLedger(path, UsageQuery{Model: "m/beta", Run: "run03"}, "", 0, DefaultUsageLedgerBudget) + if err != nil { + t.Fatal(err) + } + if len(page.Entries) != 1 || page.Entries[0].Records != 50 || len(page.Entries[0].Models) != 1 { + t.Fatalf("filtered run %+v", page.Entries) + } +} + +// THE WALK HANDS EVERY ROW OVER ONCE, IN LEDGER ORDER, a run where it begins. +func TestLedgerWalkIsInLedgerOrder(t *testing.T) { + path := writeLedger(t, []UsageLine{ + {Run: "b", At: time.Unix(10, 0)}, + {At: time.Unix(11, 0)}, + {Run: "a", At: time.Unix(12, 0)}, + {Run: "b", At: time.Unix(13, 0)}, + {At: time.Unix(5, 0)}, // a late receipt: its own row, where it was written + }, nil, "") + var keys []string + if _, _, err := EachUsageLedgerEntry(path, UsageQuery{}, DefaultUsageLedgerBudget, func(e UsageEntry) error { + keys = append(keys, e.Grain+":"+e.Run) + return nil + }); err != nil { + t.Fatal(err) + } + if got := strings.Join(keys, " "); got != "run:b call: run:a call:" { + t.Fatalf("walk order %q", got) + } +} + +// A LOCAL DAY IS MIDNIGHT TO MIDNIGHT IN THE ASKER'S ZONE, both days included — +// including a day that is twenty-five hours long. +func TestLedgerDayRangeIsLocalCalendarDays(t *testing.T) { + zone := time.FixedZone("EDT", -4*3600) + at := func(s string) time.Time { + v, err := time.Parse(time.RFC3339, s) + if err != nil { + t.Fatal(err) + } + return v + } + path := writeLedger(t, []UsageLine{ + {At: at("2026-10-14T23:59:59-04:00"), USD: 1}, + {At: at("2026-10-15T00:00:00-04:00"), USD: 2}, + {At: at("2026-10-16T03:59:59Z"), USD: 4}, // 23:59:59 on the 15th, local + {At: at("2026-10-16T04:00:00Z"), USD: 8}, // midnight on the 16th, local + }, nil, "") + from, to, err := UsageDayRange("2026-10-15", "2026-10-15", zone) + if err != nil { + t.Fatal(err) + } + page, err := QueryUsageLedger(path, UsageQuery{From: from, To: to}, "", 0, DefaultUsageLedgerBudget) + if err != nil { + t.Fatal(err) + } + if page.Totals.USD != 6 || page.Totals.Records != 2 { + t.Fatalf("one local day held %+v", page.Totals) + } + + toronto, err := time.LoadLocation("America/Toronto") + if err != nil { + t.Fatal(err) + } + from, to, err = UsageDayRange("2026-11-01", "2026-11-01", toronto) + if err != nil { + t.Fatal(err) + } + if to.Sub(from) != 25*time.Hour { + t.Fatalf("the day the clocks go back is %s long", to.Sub(from)) + } + for _, bad := range [][2]string{{"2026-10-15", "2026-10-14"}, {"2026-13-01", "2026-13-02"}, {"yesterday", "2026-10-14"}, {"2026-10-15", ""}} { + if _, _, err := UsageDayRange(bad[0], bad[1], zone); !errors.Is(err, ErrUsageQuery) { + t.Fatalf("%v: %v", bad, err) + } + } +} + +// WHAT CANNOT BE ASKED IS REFUSED, BY NAME: a page size out of bounds, a kind +// the ledger has no word for, a subject without its kind, an empty range, and a +// cursor this query did not make. +func TestLedgerRefusesWhatCannotBeAsked(t *testing.T) { + _, path := bigLedger(t, time.UTC) + for _, limit := range []int{-1, UsageLedgerPageMax + 1} { + if _, err := QueryUsageLedger(path, UsageQuery{}, "", limit, DefaultUsageLedgerBudget); !errors.Is(err, ErrUsageQuery) { + t.Fatalf("limit %d: %v", limit, err) + } + } + for _, q := range []UsageQuery{ + {Kind: "chat"}, + {Subject: "s00"}, + {From: time.Unix(10, 0), To: time.Unix(10, 0)}, + } { + if _, err := QueryUsageLedger(path, q, "", 0, DefaultUsageLedgerBudget); !errors.Is(err, ErrUsageQuery) { + t.Fatalf("%+v: %v", q, err) + } + if _, _, err := EachUsageLedgerEntry(path, q, DefaultUsageLedgerBudget, func(UsageEntry) error { return nil }); !errors.Is(err, ErrUsageQuery) { + t.Fatalf("walk %+v: %v", q, err) + } + } + page, err := QueryUsageLedger(path, UsageQuery{Model: "m/aux"}, "", 10, DefaultUsageLedgerBudget) + if err != nil || page.Next == "" { + t.Fatalf("%v %q", err, page.Next) + } + for _, cursor := range []string{"not a cursor", "eyJ2IjoxfQ", page.Next + "x", strings.Repeat("a", 2000)} { + if _, err := QueryUsageLedger(path, UsageQuery{Model: "m/aux"}, cursor, 10, DefaultUsageLedgerBudget); !errors.Is(err, ErrUsageCursor) { + t.Fatalf("cursor %.20q: %v", cursor, err) + } + } + if _, err := QueryUsageLedger(path, UsageQuery{Model: "m/beta"}, page.Next, 10, DefaultUsageLedgerBudget); !errors.Is(err, ErrUsageCursor) { + t.Fatalf("another query's cursor: %v", err) + } +} + +// A RANGE TOO LARGE TO HOLD IS REFUSED, NEVER TRUNCATED, and a third reader +// beside two is told to wait rather than queued. +func TestLedgerRefusesOverBudgetAndWhenBusy(t *testing.T) { + _, path := bigLedger(t, time.UTC) + small := UsageLedgerBudget{MaxRuns: 10, MaxBytes: 1 << 20} + if _, err := QueryUsageLedger(path, UsageQuery{}, "", 0, small); !errors.Is(err, ErrUsageLedgerTooLarge) { + t.Fatalf("over runs: %v", err) + } + if _, _, err := EachUsageLedgerEntry(path, UsageQuery{}, UsageLedgerBudget{MaxRuns: 1000, MaxBytes: 2048}, func(UsageEntry) error { return nil }); !errors.Is(err, ErrUsageLedgerTooLarge) { + t.Fatalf("over bytes: %v", err) + } + if _, err := QueryUsageLedger(path, UsageQuery{Run: "run01"}, "", 0, small); err != nil { + t.Fatalf("a narrower question fits: %v", err) + } + for range cap(usageScanSlots) { + usageScanSlots <- struct{}{} + } + _, err := QueryUsageLedger(path, UsageQuery{}, "", 0, DefaultUsageLedgerBudget) + for range cap(usageScanSlots) { + <-usageScanSlots + } + if !errors.Is(err, ErrUsageScanBusy) { + t.Fatalf("busy: %v", err) + } + if _, err := QueryUsageLedger(path, UsageQuery{}, "", 0, DefaultUsageLedgerBudget); err != nil { + t.Fatalf("slots were not given back: %v", err) + } +} + +// THE READER SKIPS WHAT [ReadUsage] SKIPS AND STOPS WHERE IT STOPS: a line that +// does not parse, a line with no instant and a line too long to hold each cost +// their own record, and a last line still being written is not read at all. +func TestLedgerReaderMatchesReadUsageOnABadFile(t *testing.T) { + good := []UsageLine{{At: time.Unix(1, 0), USD: cent}, {At: time.Unix(2, 0), Run: "r"}, {At: time.Unix(3, 0)}} + huge := `{"at":"2026-10-15T00:00:00Z","model":"` + strings.Repeat("x", usageLineBytes) + `"}` + exact := `{"at":"2026-10-15T00:00:00Z","model":"` // padded to exactly the limit, newline included + exact += strings.Repeat("y", usageLineBytes-len(exact)-3) + `"}` + path := writeLedger(t, good, []string{"not json", `{"model":"no instant"}`, huge, exact, "{\"at\":\"2026-10-15T00:00:01Z\"}\r"}, `{"at":"2026-10-15T00:00:02Z","usd":5}`) + read, err := ReadUsage(path, time.Time{}) + if err != nil { + t.Fatal(err) + } + page, err := QueryUsageLedger(path, UsageQuery{}, "", 0, DefaultUsageLedgerBudget) + if err != nil { + t.Fatal(err) + } + if page.Totals.Records != len(read) || page.Totals.Records != 5 { + t.Fatalf("read %d lines, ReadUsage %d", page.Totals.Records, len(read)) + } + if page.Stats.Malformed != 2 || page.Stats.Oversized != 1 { + t.Fatalf("stats %+v", page.Stats) + } + info, _ := os.Stat(path) + tail := int64(len(`{"at":"2026-10-15T00:00:02Z","usd":5}`)) + if page.Stats.Bytes != info.Size()-tail { + t.Fatalf("consumed %d of %d with a %d-byte tail", page.Stats.Bytes, info.Size(), tail) + } + if page.Totals.USD != cent { + t.Fatalf("the partial tail was counted: %v", page.Totals.USD) + } + // An oversized line with no end is a tail too, not a skipped record. + path = writeLedger(t, good, nil, huge) + page, err = QueryUsageLedger(path, UsageQuery{}, "", 0, DefaultUsageLedgerBudget) + if err != nil || page.Stats.Oversized != 0 || page.Totals.Records != 3 { + t.Fatalf("%v %+v %+v", err, page.Stats, page.Totals) + } + // And no ledger at all is a machine that has spent nothing. + page, err = QueryUsageLedger(filepath.Join(t.TempDir(), "absent.jsonl"), UsageQuery{}, "", 0, DefaultUsageLedgerBudget) + if err != nil || page.Totals.Entries != 0 || page.Next != "" { + t.Fatalf("%v %+v", err, page) + } +} + +// THE LEDGER'S SUBJECTS ARE THE SPEND PAGE'S: the same line is spent on the +// same thing whichever door asks. +func TestLedgerSubjectsAgreeWithUsageBySubject(t *testing.T) { + lines := []UsageLine{ + {At: time.Unix(1, 0), Session: "a", USD: 1}, + {At: time.Unix(2, 0), Session: "node", Task: "3", Root: "a", USD: 2}, + {At: time.Unix(3, 0), Session: "hand", Root: "a", USD: 4}, + {At: time.Unix(4, 0), Session: "x", Automation: "nightly", Task: "9", USD: 8}, + {At: time.Unix(5, 0), Session: "y", Standing: "old", USD: 16}, + } + want := map[string]float64{} + for _, row := range UsageBySubject(lines) { + want[row.Kind+"/"+row.ID] += row.USD + } + got := map[string]float64{} + for _, line := range lines { + kind, id := usageSubject(line) + got[kind+"/"+id] += line.USD + } + if fmt.Sprint(want) != fmt.Sprint(got) { + t.Fatalf("UsageBySubject %v, ledger %v", want, got) + } + // WHERE THEY PART IS A LINE THAT NAMES NOTHING: the spend page draws it as a + // chat with no id, and the ledger says what is true — nobody knows. + if kind, id := usageSubject(UsageLine{At: time.Unix(6, 0), USD: 32}); kind != "" || id != "" { + t.Fatalf("a line naming nothing was spent on %s %q", kind, id) + } +} From d9d7c95137deda1346b59d46f255212ded3b7074 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 22:22:22 -0400 Subject: [PATCH 098/459] app: bound spend.ledger's names, page, reply, figures and cursor; refuse unsafe no-clobber Review fixes for 0d7a5a785, ledger files only. - Names: no ReadWorld walk. A page names only its own rows, and an export names each row as it is written, from b.agents (title) and the held conversation's live.tasks (by appTaskID). Automations are read one by one through store.Get, with a cache capped at 256. A name the app does not hold is absent. - Write: without overwrite the only placement is a hard link. A folder that cannot link is refused as bad_params with what to do instead; the Lstat+Rename fallback is gone. The temp file is always removed unless a rename made it the destination. - Page: runs pass through the same limit+1 trim buffer as calls; the page is copied to an exact-length slice. Models are collected unsorted (set past 8) and sorted once when a row is handed out, at 64 B per model of budget estimate. 100k runs, limit 1: page cap 120422 -> 1, 282 MB -> 119 MB allocated per query, 316 -> 279 ms/op. - Reply: spend.ledger answers over 2 MiB of JSON are refused with a smaller-limit/narrower-filter message, never cut. - Figures: negative tokens, int overflow or a non-finite USD sum refuse with ErrUsageLedgerFigures on both doors; a refused export leaves the destination intact. - Cursor v2 pins the first page's byte length and a hash of the prefix's first and last 4 KiB; later pages read only that prefix, so appends never move a paging and totals stay fixed. Shorter or changed prefix -> ErrUsageLedgerChanged. Same-length rewrites with both ends kept are not detected (documented). - CSV cost stays full precision; the reason is documented instead of rounding to 4 places. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/appledger.go | 180 +++++++----- cmd/codeaf/appledger_test.go | 171 ++++++++++- docs/changes/unreleased/0-app-spend-ledger.md | 12 + internal/appwire/spend_ledger.go | 34 +++ internal/appwire/spend_ledger_test.go | 44 +++ internal/session/usage_query.go | 271 ++++++++++++++---- internal/session/usage_query_test.go | 187 ++++++++++++ 7 files changed, 760 insertions(+), 139 deletions(-) diff --git a/cmd/codeaf/appledger.go b/cmd/codeaf/appledger.go index c09666e39c..6fa7b5a1ee 100644 --- a/cmd/codeaf/appledger.go +++ b/cmd/codeaf/appledger.go @@ -9,11 +9,20 @@ package main // things the money was spent on — and the one safe way to put a file where the // person asked for it. // -// A NAME IS JOINED, NEVER INFERRED. A conversation's title is the one its -// journal settled on; a task's is the index row with that id IN THAT -// CONVERSATION (an id alone is not unique, so a line with no root names no -// task); an automation's is the store's. Anything none of them holds has no -// name, and the client — or the CSV — falls back to the id the ledger wrote. +// A NAME IS JOINED, NEVER INFERRED. A conversation's title is the one the app +// holds for that agent; a task's is the task with that id IN THAT +// CONVERSATION as the open conversation last said it (an id alone is not +// unique, so a line with no root names no task); an automation's is the +// store's. Anything none of them holds has no name, and the client — or the +// CSV — falls back to the id the ledger wrote. +// +// A NAME IS LOOKED UP FOR THE ROWS BEING ANSWERED, AND NOTHING ELSE IS READ. A +// page asks for the names of its own rows and an export for each row as it is +// written: a map lookup in what the app already holds, and one store read per +// automation, remembered in a cache that stops growing at +// [appLedgerNameCache]. No conversation's journal is opened and no list of +// every conversation is walked, so the cost of naming does not grow with the +// history — only with the rows the question answers. // // THE FILE IS WHOLE OR IT IS NOT THERE. It is written beside the destination // under a temporary name, synced, and only then moved into place; any failure @@ -29,6 +38,7 @@ import ( "io/fs" "os" "path/filepath" + "strconv" "strings" "syscall" "time" @@ -69,7 +79,8 @@ func appLedgerError(err error) error { switch { case errors.Is(err, session.ErrUsageScanBusy): return appwire.Errorf(appwire.CodeBusy, "%s", err.Error()) - case errors.Is(err, session.ErrUsageQuery), errors.Is(err, session.ErrUsageCursor), errors.Is(err, session.ErrUsageLedgerTooLarge): + case errors.Is(err, session.ErrUsageQuery), errors.Is(err, session.ErrUsageCursor), errors.Is(err, session.ErrUsageLedgerTooLarge), + errors.Is(err, session.ErrUsageLedgerChanged), errors.Is(err, session.ErrUsageLedgerFigures): return appwire.Errorf(appwire.CodeBadParams, "%s", err.Error()) } return appEngineError(err) @@ -85,10 +96,7 @@ func (b *appBackend) SpendLedger(ask appwire.SpendLedgerAsk) (appwire.SpendLedge if err != nil { return appwire.SpendLedgerResult{}, appLedgerError(err) } - var names appLedgerNames - if len(page.Entries) > 0 { - names = b.ledgerNames() - } + names := b.ledgerNamer() zone := appLedgerZone() rows := make([]appwire.SpendLedgerRow, 0, len(page.Entries)) for _, entry := range page.Entries { @@ -108,7 +116,7 @@ func (b *appBackend) ExportSpendLedger(ask appwire.SpendLedgerExportAsk) (appwir if err != nil { return appwire.SpendLedgerExportResult{}, appLedgerError(err) } - names := b.ledgerNames() + names := b.ledgerNamer() zone := appLedgerZone() var ( totals session.UsageLedgerTotals @@ -144,7 +152,7 @@ func (b *appBackend) ExportSpendLedger(ask appwire.SpendLedgerExportAsk) (appwir } // appLedgerRow is one ledger row on the wire. -func appLedgerRow(entry session.UsageEntry, names appLedgerNames, zone *time.Location) appwire.SpendLedgerRow { +func appLedgerRow(entry session.UsageEntry, names *appLedgerNamer, zone *time.Location) appwire.SpendLedgerRow { row := appwire.SpendLedgerRow{ Grain: entry.Grain, Key: entry.Key, Run: entry.Run, Time: entry.Start.In(zone), End: entry.End.In(zone), @@ -194,73 +202,114 @@ func appLedgerTotals(t session.UsageLedgerTotals) appwire.SpendLedgerTotals { } } -// appLedgerNames is the titles the app holds, by what they name. -type appLedgerNames struct { - conversations map[string]string - // tasks is keyed by conversation and then task id. - tasks map[string]map[string]string +// appLedgerNameCache is the most automation titles one answer remembers. +// Past it each further one is read from the store when its row is met and not +// kept, so a range of very many automations costs reads, never memory. +const appLedgerNameCache = 256 + +// appLedgerNamer names one answer's rows from what the app already holds. +type appLedgerNamer struct { + // conversation and task answer a held agent's title and a held task's, + // by the agent's id and by the conversation and the task's id there. + conversation func(id string) string + task func(conversation, id string) string + // automation reads one automation's title from the store. + automation func(id string) string automations map[string]string } -// ledgerNames reads the titles once: every conversation and task the places -// walk holds, and every automation in the store when standing orders are on. -func (b *appBackend) ledgerNames() appLedgerNames { - names := appLedgerNames{conversations: map[string]string{}, tasks: map[string]map[string]string{}, automations: map[string]string{}} - for _, row := range session.ReadWorld(session.PlacesRoot()).Sessions() { - if title := strings.TrimSpace(row.Title); title != "" { - names.conversations[row.ID] = title - } - for _, entry := range row.Tasks.Rows { - title, conversation := strings.TrimSpace(entry.Title), strings.TrimSpace(entry.SessionID) - if title == "" || conversation == "" { - continue - } - if names.tasks[conversation] == nil { - names.tasks[conversation] = map[string]string{} +// ledgerNamer is the namer over this backend's agents and automations. +func (b *appBackend) ledgerNamer() *appLedgerNamer { + n := &appLedgerNamer{ + conversation: func(id string) (title string) { + b.locked(func() { + if a := b.agents[id]; a != nil { + title = a.view.Title + } + }) + return strings.TrimSpace(title) + }, + task: func(conversation, id string) (title string) { + n, err := strconv.ParseUint(id, 10, 64) + if err != nil { + return "" } - names.tasks[conversation][strings.TrimSpace(entry.ID)] = title - } + b.locked(func() { + if a := b.agents[conversation]; a != nil && a.live != nil { + title = a.live.tasks[appTaskID(conversation, n)].Title + } + }) + return strings.TrimSpace(title) + }, + automation: func(string) string { return "" }, + automations: map[string]string{}, } if b.orderStore != nil { - if store := b.orderStore(); store != nil { - if list, err := store.List(); err == nil { - for _, a := range list { - if title := strings.TrimSpace(a.Title); title != "" { - names.automations[a.ID] = title - } - } + n.automation = func(id string) string { + store := b.orderStore() + if store == nil { + return "" } + a, err := store.Get(id) + if err != nil { + return "" + } + return strings.TrimSpace(a.Title) } } - return names + return n } // of is the title of what the row's money was spent on, or "". -func (n appLedgerNames) of(entry session.UsageEntry) string { +func (n *appLedgerNamer) of(entry session.UsageEntry) string { + if entry.Subject == "" { + return "" + } switch entry.Kind { case session.SubjectConversation: - return n.conversations[entry.Subject] + return n.conversation(entry.Subject) case session.SubjectAutomation: - return n.automations[entry.Subject] + if title, known := n.automations[entry.Subject]; known { + return title + } + title := n.automation(entry.Subject) + if len(n.automations) < appLedgerNameCache { + n.automations[entry.Subject] = title + } + return title case session.SubjectTask: if entry.Root == "" { return "" } - return n.tasks[entry.Root][entry.Subject] + return n.task(entry.Root, entry.Subject) } return "" } +// appLinkFile and appRemoveFile are the two filesystem steps that place an +// export without overwrite; a test replaces them. +var ( + appLinkFile = os.Link + appRemoveFile = os.Remove +) + // appWriteWhole writes what fill writes to path, whole or not at all, and // answers the bytes written. // // THE TEMPORARY FILE IS A SIBLING, so the last step is a rename within one // directory, which a reader sees as the old file or the new one and never part -// of either. It is made 0600, written, synced and closed before it moves; on -// any failure it is removed. Without overwrite the move is a hard link, which -// refuses an existing destination atomically — a file someone else wrote there -// a moment ago is not replaced — and only a filesystem that has no links falls -// back to checking first. +// of either. It is made 0600, written, synced and closed before it moves. +// +// WITHOUT OVERWRITE THE MOVE IS A HARD LINK AND NOTHING ELSE. A link refuses an +// existing destination atomically, so a file someone else wrote there a moment +// ago is not replaced. A folder that cannot take a link (some network and +// removable filesystems) is REFUSED, with what to do instead: the only other +// way would be to look first and rename after, and a file created between the +// look and the rename would be destroyed without a word. +// +// THE TEMPORARY FILE IS ALWAYS REMOVED unless it became the destination by +// rename — after a failure, and after a link has placed it (the link is the +// destination's own name for the same bytes). func appWriteWhole(path string, overwrite bool, fill func(io.Writer) error) (int64, error) { dir := filepath.Dir(path) if info, err := os.Stat(dir); err != nil || !info.IsDir() { @@ -281,11 +330,11 @@ func appWriteWhole(path string, overwrite bool, fill func(io.Writer) error) (int return 0, appEngineError(err) } tempPath := temp.Name() - keep := false + renamed := false defer func() { - if !keep { - _ = temp.Close() - _ = os.Remove(tempPath) + _ = temp.Close() + if !renamed { + _ = appRemoveFile(tempPath) } }() if err := temp.Chmod(appStateFileMode); err != nil { @@ -305,25 +354,20 @@ func appWriteWhole(path string, overwrite bool, fill func(io.Writer) error) (int return 0, err } if overwrite { - err = os.Rename(tempPath, path) - } else { - err = os.Link(tempPath, path) + if err := os.Rename(tempPath, path); err != nil { + return 0, fmt.Errorf("the export could not be put in place: %w", err) + } + renamed = true + } else if err := appLinkFile(tempPath, path); err != nil { switch { - case err == nil: - _ = os.Remove(tempPath) case errors.Is(err, fs.ErrExist): return 0, appwire.Errorf(appwire.CodeBadParams, "the destination already exists; pass overwrite to replace it") - case errors.Is(err, syscall.EPERM), errors.Is(err, syscall.ENOTSUP), errors.Is(err, syscall.EXDEV), errors.Is(err, syscall.EMLINK): - if _, statErr := os.Lstat(path); statErr == nil { - return 0, appwire.Errorf(appwire.CodeBadParams, "the destination already exists; pass overwrite to replace it") - } - err = os.Rename(tempPath, path) + case errors.Is(err, syscall.EPERM), errors.Is(err, syscall.ENOTSUP), errors.Is(err, syscall.EOPNOTSUPP), + errors.Is(err, syscall.EXDEV), errors.Is(err, syscall.EMLINK), errors.Is(err, syscall.ENOSYS): + return 0, appwire.Errorf(appwire.CodeBadParams, "this folder cannot place a new file without the risk of replacing one created there at the same moment; choose another folder, or pass overwrite to replace whatever is at the destination") } - } - if err != nil { return 0, fmt.Errorf("the export could not be put in place: %w", err) } - keep = true if handle, err := os.Open(dir); err == nil { _ = handle.Sync() _ = handle.Close() diff --git a/cmd/codeaf/appledger_test.go b/cmd/codeaf/appledger_test.go index b5f1114188..9a80e00e94 100644 --- a/cmd/codeaf/appledger_test.go +++ b/cmd/codeaf/appledger_test.go @@ -5,9 +5,11 @@ import ( "encoding/json" "errors" "fmt" + "math" "os" "path/filepath" "strings" + "syscall" "testing" "time" @@ -288,27 +290,33 @@ func TestAppSpendLedgerRefusals(t *testing.T) { if wireErrorCode(err) != appwire.CodeBadParams { t.Fatalf("another range's cursor: %v", err) } - if wireErrorCode(appLedgerError(session.ErrUsageLedgerTooLarge)) != appwire.CodeBadParams { - t.Fatal("too large is not the asker's to fix") + for _, err := range []error{session.ErrUsageLedgerTooLarge, session.ErrUsageLedgerChanged, session.ErrUsageLedgerFigures} { + if wireErrorCode(appLedgerError(err)) != appwire.CodeBadParams { + t.Fatalf("%v is not the asker's to fix", err) + } } } -// A NAME IS JOINED, NEVER GUESSED: a task's title needs the conversation it -// ran in, and a thing nothing holds a title for has none. +// A NAME IS JOINED, NEVER GUESSED: a conversation's title is the held +// agent's, a task's needs the conversation it ran in and that conversation +// held open, and a thing nothing holds a title for has none. func TestAppSpendLedgerNamesAreJoinedNotInferred(t *testing.T) { - names := appLedgerNames{ - conversations: map[string]string{"c1": "Release notes"}, - tasks: map[string]map[string]string{"c1": {"7": "Fix the build"}}, - automations: map[string]string{"nightly": "Nightly triage"}, - } + b := &appBackend{agents: map[string]*appAgent{ + "c1": {view: appwire.Agent{ID: "c1", Title: "Release notes"}, live: &appLive{tasks: map[string]appwire.Task{appTaskID("c1", 7): {Title: "Fix the build"}}}}, + "c2": {view: appwire.Agent{ID: "c2", Title: "Closed one"}}, + }} + names := b.ledgerNamer() + names.automation = func(id string) string { return map[string]string{"nightly": "Nightly triage"}[id] } for _, c := range []struct { entry session.UsageEntry want string }{ {session.UsageEntry{Kind: session.SubjectConversation, Subject: "c1"}, "Release notes"}, + {session.UsageEntry{Kind: session.SubjectConversation, Subject: "c9"}, ""}, {session.UsageEntry{Kind: session.SubjectTask, Subject: "7", Root: "c1"}, "Fix the build"}, {session.UsageEntry{Kind: session.SubjectTask, Subject: "7"}, ""}, {session.UsageEntry{Kind: session.SubjectTask, Subject: "7", Root: "c2"}, ""}, + {session.UsageEntry{Kind: session.SubjectTask, Subject: "x", Root: "c1"}, ""}, {session.UsageEntry{Kind: session.SubjectAutomation, Subject: "nightly"}, "Nightly triage"}, {session.UsageEntry{Subject: "c1"}, ""}, } { @@ -318,9 +326,154 @@ func TestAppSpendLedgerNamesAreJoinedNotInferred(t *testing.T) { } } +// NAMING IS PAID PER ROW ANSWERED, NEVER PER THING THE MACHINE HOLDS: a page +// of one asks one name, an export asks each automation once until the cache +// is full and never keeps more than it, and nothing else is read. +func TestAppSpendLedgerNamesAreBoundedLookups(t *testing.T) { + zone := time.UTC + at := time.Date(2026, 10, 14, 6, 0, 0, 0, zone) + var lines []session.UsageLine + for i := 0; i < 3*appLedgerNameCache; i++ { + for range 2 { + lines = append(lines, session.UsageLine{At: at.Add(time.Duration(i) * time.Second), Automation: fmt.Sprintf("auto%04d", i), USD: 1.0 / 1024}) + } + } + ledgerAt(t, zone, lines, "") + b := &appBackend{} + asked := map[string]int{} + namer := b.ledgerNamer() + namer.automation = func(id string) string { asked[id]++; return "title " + id } + filter := appwire.SpendLedgerFilter{From: "2026-10-14", To: "2026-10-14"} + query, err := appLedgerQuery(filter) + if err != nil { + t.Fatal(err) + } + page, err := session.QueryUsageLedger(appLedgerPath(), query, "", 1, session.DefaultUsageLedgerBudget) + if err != nil { + t.Fatal(err) + } + if row := appLedgerRow(page.Entries[0], namer, zone); row.Name != "title "+row.Subject || len(asked) != 1 { + t.Fatalf("a page of one asked %d names: %+v", len(asked), row) + } + clear(asked) + namer = b.ledgerNamer() + namer.automation = func(id string) string { asked[id]++; return "title " + id } + rows := 0 + if _, _, err := session.EachUsageLedgerEntry(appLedgerPath(), query, session.DefaultUsageLedgerBudget, func(e session.UsageEntry) error { + rows++ + if appLedgerRow(e, namer, zone).Name != "title "+e.Subject { + t.Fatalf("row %s unnamed", e.Key) + } + return nil + }); err != nil { + t.Fatal(err) + } + if len(namer.automations) != appLedgerNameCache || rows != len(lines) { + t.Fatalf("the cache holds %d after %d rows", len(namer.automations), rows) + } + for id, n := range asked { + cached := id < fmt.Sprintf("auto%04d", appLedgerNameCache) + if cached && n != 1 || !cached && n != 2 { + t.Fatalf("%s asked %d times", id, n) + } + } +} func ledgerJSON(data []byte, err error) []byte { if err != nil { panic(err) } return data } + +// A FILE CREATED AT THE DESTINATION WHILE THE EXPORT WAS WRITING SURVIVES IT, +// on a folder that takes links and on one that does not: the first refuses as +// "already exists", the second refuses outright rather than look and rename, +// and neither leaves its temporary file behind. +func TestAppSpendLedgerExportNeverReplacesAConcurrentFile(t *testing.T) { + ledgerAt(t, time.UTC, appLedgerFixture(time.UTC)[:200], "") + b := &appBackend{} + filter := appwire.SpendLedgerFilter{From: "2026-10-14", To: "2026-10-14"} + oldLink := appLinkFile + t.Cleanup(func() { appLinkFile = oldLink }) + for name, link := range map[string]func(string, string) error{ + "links": os.Link, + "no links": func(string, string) error { return &os.LinkError{Op: "link", Err: syscall.ENOTSUP} }, + } { + dir := t.TempDir() + dest := filepath.Join(dir, "spend.csv") + appLinkFile = func(from, to string) error { + if err := os.WriteFile(to, []byte("theirs\n"), 0o644); err != nil { + t.Fatal(err) + } + return link(from, to) + } + _, err := b.ExportSpendLedger(appwire.SpendLedgerExportAsk{Filter: filter, Destination: dest}) + if wireErrorCode(err) != appwire.CodeBadParams { + t.Fatalf("%s: %v", name, err) + } + if name == "no links" && !strings.Contains(err.Error(), "choose another folder") { + t.Fatalf("%s: the refusal does not say what to do: %v", name, err) + } + keptIntact(t, dir, dest, "theirs\n") + } + + // On a folder without links and nothing at the destination, it is still + // refused, and nothing is there after. + dir := t.TempDir() + dest := filepath.Join(dir, "spend.csv") + appLinkFile = func(string, string) error { return &os.LinkError{Op: "link", Err: syscall.EXDEV} } + if _, err := b.ExportSpendLedger(appwire.SpendLedgerExportAsk{Filter: filter, Destination: dest}); wireErrorCode(err) != appwire.CodeBadParams { + t.Fatalf("no links, nothing there: %v", err) + } + if entries, _ := os.ReadDir(dir); len(entries) != 0 { + t.Fatalf("a refused export left %d entries", len(entries)) + } +} + +// THE TEMPORARY FILE IS REMOVED AFTER A LINK PLACES THE EXPORT, and a removal +// that fails does not undo the export it followed. +func TestAppSpendLedgerExportRemovesItsTemporaryName(t *testing.T) { + ledgerAt(t, time.UTC, appLedgerFixture(time.UTC)[:200], "") + b := &appBackend{} + oldRemove := appRemoveFile + t.Cleanup(func() { appRemoveFile = oldRemove }) + var removed []string + appRemoveFile = func(path string) error { + removed = append(removed, path) + os.Remove(path) + return errors.New("refused") + } + dir := t.TempDir() + dest := filepath.Join(dir, "spend.csv") + out, err := b.ExportSpendLedger(appwire.SpendLedgerExportAsk{Filter: appwire.SpendLedgerFilter{From: "2026-10-14", To: "2026-10-14"}, Destination: dest}) + if err != nil || out.Rows == 0 { + t.Fatalf("%v %+v", err, out) + } + if len(removed) != 1 || !strings.HasPrefix(filepath.Base(removed[0]), ".codeaf-ledger-") { + t.Fatalf("removed %q", removed) + } + if entries, _ := os.ReadDir(dir); len(entries) != 1 { + t.Fatalf("the folder holds %d entries", len(entries)) + } +} + +// A RANGE WHOSE FIGURES CANNOT BE ADDED UP IS REFUSED BY BOTH DOORS, by name, +// and the export's destination is left exactly as it was. +func TestAppSpendLedgerRefusesFiguresItCannotAdd(t *testing.T) { + at := time.Date(2026, 10, 14, 6, 0, 0, 0, time.UTC) + ledgerAt(t, time.UTC, []session.UsageLine{{At: at, USD: math.MaxFloat64}, {At: at, Run: "r", USD: math.MaxFloat64}}, "") + b := &appBackend{} + filter := appwire.SpendLedgerFilter{From: "2026-10-14", To: "2026-10-14"} + if _, err := b.SpendLedger(appwire.SpendLedgerAsk{Filter: filter, Limit: 10}); wireErrorCode(err) != appwire.CodeBadParams || !strings.Contains(err.Error(), "too large") { + t.Fatalf("page: %v", err) + } + dir := t.TempDir() + dest := filepath.Join(dir, "spend.csv") + if err := os.WriteFile(dest, []byte("kept\n"), 0o644); err != nil { + t.Fatal(err) + } + if _, err := b.ExportSpendLedger(appwire.SpendLedgerExportAsk{Filter: filter, Destination: dest, Overwrite: true}); wireErrorCode(err) != appwire.CodeBadParams { + t.Fatalf("export: %v", err) + } + keptIntact(t, dir, dest, "kept\n") +} diff --git a/docs/changes/unreleased/0-app-spend-ledger.md b/docs/changes/unreleased/0-app-spend-ledger.md index d4ee3310b9..781948f6ee 100644 --- a/docs/changes/unreleased/0-app-spend-ledger.md +++ b/docs/changes/unreleased/0-app-spend-ledger.md @@ -21,3 +21,15 @@ calls are counted, not shown as $0. Both methods are optional: an engine without them answers `unknown_method`. `spend.export` is unchanged. Every request reads the ledger linearly from the start; a range with more than 100 000 runs is refused, and a third concurrent reading answers `busy`. + +A page holds the page: the runs are added up under that budget (an estimate, +not a resident-memory promise) and the page is chosen from them without +sorting them all. One answer is at most 2 MiB of JSON and a larger one is +refused, asking for a smaller limit or narrower filter, never cut. Figures that +would overflow or leave the finite floats are refused. A cursor pins the bytes +the first page read, so appends never move a paging, and a ledger rotated, cut +or rewritten under it refuses the cursor. Names come only from what the app +holds for the rows answered, so an older conversation the app is not holding is +unnamed. Without `overwrite`, a folder that cannot make hard links is refused +rather than checked and renamed. CSV costs are full precision, not rounded to +four places. diff --git a/internal/appwire/spend_ledger.go b/internal/appwire/spend_ledger.go index 61567e891d..281f5e7276 100644 --- a/internal/appwire/spend_ledger.go +++ b/internal/appwire/spend_ledger.go @@ -55,6 +55,14 @@ const ( SpendLedgerLimitMax = 500 ) +// SpendLedgerReplyMax is the most bytes one spend.ledger answer may be, as +// JSON. A page of 500 rows is not a bound by itself: one run may have met a +// hundred thousand models, and every text field may be as long as a ledger line. +// AN ANSWER PAST IT IS REFUSED, NEVER CUT: dropping models or rows to fit would +// show a preview that disagrees with the file saved from it. The export is not +// held to it, because it streams to disk a row at a time. +const SpendLedgerReplyMax = 2 << 20 + // SpendLedgerFilter is the one question both methods ask. from and to are // local calendar days, both included, and required. Every other field is an // exact match on what the ledger wrote; subject needs kind. @@ -269,6 +277,9 @@ var spendLedgerMethods = map[string]method{ return nil, err } result.Rows = nonNil(result.Rows) + if err := checkSpendLedgerReply(result, p.Limit); err != nil { + return nil, err + } return &result, nil }), "spend.ledger.export": shaped[SpendLedgerExportParams](func(e Engine, raw json.RawMessage) (*SpendLedgerExportResult, error) { @@ -296,6 +307,23 @@ var spendLedgerMethods = map[string]method{ }), } +// checkSpendLedgerReply refuses an answer too large to carry, saying what to +// ask for instead. +func checkSpendLedgerReply(result SpendLedgerResult, limit int) error { + data, err := json.Marshal(result) + if err != nil { + return Errorf(CodeEngine, "spend.ledger: the page could not be written as JSON: %v", err) + } + if len(data) <= SpendLedgerReplyMax { + return nil + } + fewer := "ask for fewer rows with a smaller limit, or " + if limit <= 1 { + fewer = "" + } + return Errorf(CodeBadParams, "spend.ledger: this page is %d bytes, more than the %d one answer may carry; %snarrow the filter — a model, a run, or a shorter range — because a single run that used very many models is one row however small the page", len(data), SpendLedgerReplyMax, fewer) +} + // ── the file ──────────────────────────────────────────────────────────────── // SpendLedgerCSVHeader is spend.ledger.export's columns, in order. The first @@ -312,6 +340,12 @@ var SpendLedgerCSVHeader = []string{ // subject's own id, and then to the run's, when nothing holds a title; a cost // nobody could price is an empty cell. Every text cell that a spreadsheet // would read as a formula is written with a leading apostrophe. +// +// THE COST IS WRITTEN AT ITS FULL PRECISION, NOT ROUNDED TO FOUR PLACES. Most +// calls cost less than a hundredth of a cent, so a column of four-decimal +// figures would sum to a different bill from the one the totals state — +// money lost in the file without a word. The shortest decimal that reads back +// as the same number is written; a spreadsheet can round it for display. func SpendLedgerCSVRecord(row SpendLedgerRow) []string { name := row.Name if name == "" { diff --git a/internal/appwire/spend_ledger_test.go b/internal/appwire/spend_ledger_test.go index ec3d8ce718..6cfdcea0fe 100644 --- a/internal/appwire/spend_ledger_test.go +++ b/internal/appwire/spend_ledger_test.go @@ -7,6 +7,7 @@ import ( "encoding/csv" "encoding/json" "io" + "slices" "strings" "sync" "testing" @@ -229,3 +230,46 @@ func TestSpendLedgerCSVRecord(t *testing.T) { t.Fatalf("row 3 falls back to its run: %q", records[3]) } } + +// hugeLedgerEngine answers one run row that met a hundred thousand models. +type hugeLedgerEngine struct{ ledgerEngine } + +func (h *hugeLedgerEngine) SpendLedger(ask SpendLedgerAsk) (SpendLedgerResult, error) { + models := make([]string, 100_000) + for i := range models { + models[i] = "provider/model-" + strings.Repeat("x", 8) + "-" + string(rune('a'+i%26)) + } + return SpendLedgerResult{From: ask.Filter.From, To: ask.Filter.To, Rows: []SpendLedgerRow{{Grain: SpendGrainRun, Key: "run:r", Run: "r", Models: models, Records: 100_000, Calls: 100_000}}}, nil +} + +// AN ANSWER TOO LARGE TO CARRY IS REFUSED, SAYING WHAT TO ASK INSTEAD, and +// never arrives with its models cut to fit. +func TestSpendLedgerRefusesAnAnswerTooLargeToCarry(t *testing.T) { + call := serveOver(t, &hugeLedgerEngine{}) + for limit, want := range map[string]string{"1": "narrow the filter", "50": "smaller limit"} { + frame := call(`{"id":1,"method":"spend.ledger","params":{"from":"2026-10-01","to":"2026-10-15","limit":` + limit + `}}`) + e, _ := frame["error"].(map[string]any) + message, _ := e["message"].(string) + if frameCode(frame) != CodeBadParams || !strings.Contains(message, want) || !strings.Contains(message, "2097152") { + t.Fatalf("limit %s: %v", limit, frame) + } + if _, answered := frame["result"]; answered { + t.Fatalf("limit %s: a cut answer arrived", limit) + } + } + // The ordinary page is under it. + if frame := serveOver(t, &ledgerEngine{})(`{"id":1,"method":"spend.ledger","params":{"from":"2026-10-01","to":"2026-10-15"}}`); frame["result"] == nil { + t.Fatalf("an ordinary page was refused: %v", frame) + } +} + +// THE FILE'S COST IS THE LEDGER'S, NOT A ROUNDING OF IT: a five-hundredth of a +// cent is written whole, so the column sums to the stated bill. +func TestSpendLedgerCSVCostKeepsItsPrecision(t *testing.T) { + usd := 1.0 / 512 + record := SpendLedgerCSVRecord(SpendLedgerRow{Grain: SpendGrainCall, Key: "call:1", CostUSD: &usd, Models: []string{}}) + cost := record[slices.Index(SpendLedgerCSVHeader, "cost_usd")] + if cost != "0.001953125" { + t.Fatalf("cost %q", cost) + } +} diff --git a/internal/session/usage_query.go b/internal/session/usage_query.go index 2d9741c6a7..83bd4ea020 100644 --- a/internal/session/usage_query.go +++ b/internal/session/usage_query.go @@ -30,7 +30,16 @@ package session // there is no index and no cache, so nothing grows with the history except the // time a scan takes. What it HOLDS is bounded: the runs in the range, under an // explicit budget ([UsageLedgerBudget]) that refuses — asking for a shorter -// range — rather than answering with less than the range holds; and a page. +// range — rather than answering with less than the range holds; and a page, +// chosen from them by a buffer of twice its size rather than by sorting them +// all. The budget is an ESTIMATE of the runs' weight, used to refuse early; it +// is not a promise about the process's resident memory. +// +// A FIGURE THAT CANNOT BE ADDED UP IS REFUSED, NEVER WRAPPED. A ledger line with +// negative tokens, or sums that would overflow an integer or leave the finite +// floats, is an answer this reader cannot give truthfully; it says so +// ([ErrUsageLedgerFigures]) rather than answering a wrapped count or a cost of +// infinity that no reply could carry. import ( "bufio" @@ -42,7 +51,9 @@ import ( "hash/fnv" "io" "io/fs" + "math" "os" + "slices" "sort" "strconv" "strings" @@ -76,6 +87,13 @@ var ( ErrUsageLedgerTooLarge = errors.New("this range holds too many runs to answer at once; ask for a shorter date range") // ErrUsageCursor is a cursor this query did not make. ErrUsageCursor = errors.New("the cursor does not belong to this query; start again without one") + // ErrUsageLedgerChanged is a cursor whose ledger was replaced, cut + // shorter or rewritten since the first page was read: continuing would + // page through a different file as though it were the same one. + ErrUsageLedgerChanged = errors.New("the spending ledger was replaced or rewritten since the first page; start again without a cursor") + // ErrUsageLedgerFigures is a range whose figures cannot be added up + // truthfully: a negative count, or sums past what a number can hold. + ErrUsageLedgerFigures = errors.New("this range holds usage figures too large or invalid to add up; ask for a shorter date range or a narrower filter") // ErrUsageQuery is a query that cannot be asked as written. ErrUsageQuery = errors.New("the spending query is not valid") ) @@ -148,6 +166,28 @@ type UsageEntry struct { bytes int64 emitted bool + // modelSet is Models as a set once a row has met more than + // [usageModelSlice] of them. UNTIL A ROW IS FINISHED ([UsageEntry.finished]) + // Models is in the order the models were met: inserting each one in sorted + // place would be quadratic in a row with a hundred thousand of them. + modelSet map[string]struct{} +} + +// usageModelSlice is how many distinct models a row looks for by walking its +// list; past it, a set. Ordinary runs use one or two. +const usageModelSlice = 8 + +// usageModelBytes is the estimate one distinct model adds to its row beside +// its own bytes: a slice slot and, past [usageModelSlice], a set entry. +const usageModelBytes = 64 + +// finished is the row as it is handed out: its models sorted in a slice of +// their own, and no set. +func (e UsageEntry) finished() UsageEntry { + models := slices.Clone(e.Models) + slices.Sort(models) + e.Models, e.modelSet = models, nil + return e } // Priced reports whether any line in the row carried a figure: a row with @@ -397,11 +437,17 @@ func (e *UsageEntry) add(line UsageLine) { } if model := strings.TrimSpace(line.Model); model == "" { e.UnnamedModel++ - } else if i := sort.SearchStrings(e.Models, model); i == len(e.Models) || e.Models[i] != model { - e.Models = append(e.Models, "") - copy(e.Models[i+1:], e.Models[i:]) - e.Models[i] = model - e.bytes += int64(len(model)) + 16 + } else if !e.hasModel(model) { + e.Models = append(e.Models, model) + if e.modelSet != nil { + e.modelSet[model] = struct{}{} + } else if len(e.Models) > usageModelSlice { + e.modelSet = make(map[string]struct{}, len(e.Models)*2) + for _, m := range e.Models { + e.modelSet[m] = struct{}{} + } + } + e.bytes += int64(len(model)) + usageModelBytes } e.Records++ e.Calls += max(line.Calls, 1) @@ -420,10 +466,32 @@ func (e *UsageEntry) add(line UsageLine) { } } -// count adds one line to the totals. -func (t *UsageLedgerTotals) count(line UsageLine) { +// hasModel reports whether the row has already met model. +func (e *UsageEntry) hasModel(model string) bool { + if e.modelSet != nil { + _, ok := e.modelSet[model] + return ok + } + return slices.Contains(e.Models, model) +} + +// count adds one line to the totals, refusing a figure it cannot add. +// +// THE TOTALS ARE CHECKED AND THE ROWS ARE NOT, because every figure a row adds +// is non-negative once this has passed and a row is a part of the totals: a +// sum the totals could hold, every row's share of it can too. +func (t *UsageLedgerTotals) count(line UsageLine) error { + calls := max(line.Calls, 1) + if line.Input < 0 || line.Output < 0 || math.IsNaN(line.USD) || math.IsInf(line.USD, 0) || + t.Records == math.MaxInt || t.Calls > math.MaxInt-calls || + t.Input > math.MaxInt64-int64(line.Input) || t.Output > math.MaxInt64-int64(line.Output) { + return ErrUsageLedgerFigures + } + if line.USD > 0 && math.IsInf(t.USD+line.USD, 0) { + return ErrUsageLedgerFigures + } t.Records++ - t.Calls += max(line.Calls, 1) + t.Calls += calls t.Input += int64(line.Input) t.Output += int64(line.Output) if line.USD > 0 { @@ -442,6 +510,7 @@ func (t *UsageLedgerTotals) count(line UsageLine) { if line.Run == "" { t.UnknownRunRecords++ } + return nil } func usageCallEntry(offset int64, line UsageLine) UsageEntry { @@ -496,45 +565,79 @@ func usageBefore(a, b UsageEntry) bool { return a.Key < b.Key } -// usageCursor is where a page ended: the last row's start and key, and the -// query that made it. +// usageCursor is where a page ended — the last row's start and key, and the +// query that made it — and WHICH BYTES the first page read: the length of the +// ledger it read (always a line boundary) and a hash of that prefix's ends. type usageCursor struct { V int `json:"v"` Start int64 `json:"s"` Key string `json:"k"` Query string `json:"q"` + Floor int64 `json:"n"` + Ends string `json:"h"` } -func encodeUsageCursor(q UsageQuery, last UsageEntry) string { - raw, _ := json.Marshal(usageCursor{V: 1, Start: last.Start.UnixNano(), Key: last.Key, Query: q.fingerprint()}) +// usageCursorVersion is the cursor's shape; an older shape is refused. +const usageCursorVersion = 2 + +// usageEndBytes is how much of each end of the read prefix a cursor's hash +// covers: enough to tell another file, and a fixed cost however long the +// ledger is. +const usageEndBytes = 4096 + +// usageReading is the prefix of the ledger one paging reads, said by a cursor. +type usageReading struct { + floor int64 + ends string +} + +// usagePrefixEnds hashes the first and last [usageEndBytes] of the first floor +// bytes of file. It reads at fixed offsets, so a scan's position is untouched +// and an append made meanwhile is not read. +func usagePrefixEnds(file *os.File, floor int64) (string, error) { + h := fnv.New64a() + if file != nil && floor > 0 { + buf := make([]byte, min(floor, usageEndBytes)) + for _, at := range []int64{0, floor - int64(len(buf))} { + if _, err := file.ReadAt(buf, at); err != nil { + return "", err + } + h.Write(buf) + } + } + return strconv.FormatInt(floor, 36) + "." + strconv.FormatUint(h.Sum64(), 36), nil +} + +func encodeUsageCursor(q UsageQuery, last UsageEntry, reading usageReading) string { + raw, _ := json.Marshal(usageCursor{V: usageCursorVersion, Start: last.Start.UnixNano(), Key: last.Key, Query: q.fingerprint(), Floor: reading.floor, Ends: reading.ends}) return base64.RawURLEncoding.EncodeToString(raw) } -func decodeUsageCursor(q UsageQuery, cursor string) (*UsageEntry, error) { +func decodeUsageCursor(q UsageQuery, cursor string) (*UsageEntry, *usageReading, error) { cursor = strings.TrimSpace(cursor) if cursor == "" { - return nil, nil + return nil, nil, nil } if len(cursor) > 1024 { - return nil, ErrUsageCursor + return nil, nil, ErrUsageCursor } raw, err := base64.RawURLEncoding.DecodeString(cursor) if err != nil { - return nil, ErrUsageCursor + return nil, nil, ErrUsageCursor } var c usageCursor decoder := json.NewDecoder(bytes.NewReader(raw)) decoder.DisallowUnknownFields() - if decoder.Decode(&c) != nil || c.V != 1 || c.Key == "" || c.Query != q.fingerprint() { - return nil, ErrUsageCursor + if decoder.Decode(&c) != nil || c.V != usageCursorVersion || c.Key == "" || c.Query != q.fingerprint() || c.Floor < 0 || c.Ends == "" { + return nil, nil, ErrUsageCursor } if _, err := decoder.Token(); !errors.Is(err, io.EOF) { - return nil, ErrUsageCursor + return nil, nil, ErrUsageCursor } if !strings.HasPrefix(c.Key, "run:") && !strings.HasPrefix(c.Key, "call:") { - return nil, ErrUsageCursor + return nil, nil, ErrUsageCursor } - return &UsageEntry{Start: time.Unix(0, c.Start), Key: c.Key}, nil + return &UsageEntry{Start: time.Unix(0, c.Start), Key: c.Key}, &usageReading{floor: c.Floor, ends: c.Ends}, nil } // QueryUsageLedger answers one page of the ledger at path: the totals of @@ -546,9 +649,16 @@ func decodeUsageCursor(q UsageQuery, cursor string) (*UsageEntry, error) { // line on the way past, so a page of one and a page of five hundred say the // same totals, and paging to the end visits exactly Totals.Entries rows. // -// A PAGE IS READ FRESH. The ledger grows while a person reads it; a later page -// is a later reading, and the cursor — a position in the order, not an offset -// into a list — keeps the rows already seen from being seen again. +// EVERY PAGE OF ONE PAGING READS THE SAME BYTES. The first page reads the +// whole ledger and its cursor carries how far that was; a later page reads +// that prefix again and no further. So the ledger growing meanwhile — the one +// change it ever has — leaves every page and every total exactly as the first +// page said them, and what was recorded since is the next paging's, from no +// cursor. A ledger shorter than that prefix, or whose prefix no longer has the +// same first and last bytes, is another file (rotated, restored, rewritten) +// and the cursor is refused with [ErrUsageLedgerChanged] rather than +// continued through it. A rewrite that keeps the length and both ends is not +// seen; nothing in codeaf rewrites the ledger. func QueryUsageLedger(path string, q UsageQuery, cursor string, limit int, budget UsageLedgerBudget) (UsageLedgerPage, error) { q, err := q.normalized() if err != nil { @@ -560,7 +670,7 @@ func QueryUsageLedger(path string, q UsageQuery, cursor string, limit int, budge case limit < 0 || limit > UsageLedgerPageMax: return UsageLedgerPage{}, fmt.Errorf("%w: limit is 1 to %d", ErrUsageQuery, UsageLedgerPageMax) } - after, err := decodeUsageCursor(q, cursor) + after, reading, err := decodeUsageCursor(q, cursor) if err != nil { return UsageLedgerPage{}, err } @@ -570,39 +680,70 @@ func QueryUsageLedger(path string, q UsageQuery, cursor string, limit int, budge } defer done() + readLimit := int64(-1) + if reading != nil { + if file == nil && reading.floor > 0 { + return UsageLedgerPage{}, ErrUsageLedgerChanged + } + if file != nil { + info, err := file.Stat() + if err != nil { + return UsageLedgerPage{}, err + } + if info.Size() < reading.floor { + return UsageLedgerPage{}, ErrUsageLedgerChanged + } + } + ends, err := usagePrefixEnds(file, reading.floor) + if err != nil { + return UsageLedgerPage{}, err + } + if ends != reading.ends { + return UsageLedgerPage{}, ErrUsageLedgerChanged + } + readLimit = reading.floor + } + var page UsageLedgerPage runs := newUsageRuns(budget) - // THE CALL ROWS ARE NOT HELD, only the best limit+1 of them after the - // cursor: a buffer that is trimmed back whenever it doubles, so a range of - // a million unattributed calls costs a page's worth of memory. - var calls []UsageEntry - callsAfter := 0 + // THE PAGE IS CHOSEN, NOT SORTED OUT OF EVERYTHING. Only the best limit+1 + // rows after the cursor are held: a buffer trimmed back whenever it + // doubles, so a range of a million unattributed calls costs a page's worth + // of memory — and the runs, once the scan has added them up, pass through + // the same buffer rather than being copied out of their map and sorted. + var best []UsageEntry + remaining := 0 keep := limit + 1 trim := func() { - sort.Slice(calls, func(i, j int) bool { return usageBefore(calls[i], calls[j]) }) - if len(calls) > keep { - calls = calls[:keep] + sort.Slice(best, func(i, j int) bool { return usageBefore(best[i], best[j]) }) + if len(best) > keep { + clear(best[keep:]) + best = best[:keep] + } + } + offer := func(entry UsageEntry) { + if after != nil && !usageBefore(*after, entry) { + return + } + remaining++ + best = append(best, entry) + if len(best) >= 2*keep { + trim() } } if file != nil { - page.Stats, err = scanUsageLines(file, -1, func(offset int64, line UsageLine) error { + page.Stats, err = scanUsageLines(file, readLimit, func(offset int64, line UsageLine) error { line.Run = strings.TrimSpace(line.Run) if !q.matches(line) { return nil } - page.Totals.count(line) + if err := page.Totals.count(line); err != nil { + return err + } if line.Run != "" { return runs.add(line) } - entry := usageCallEntry(offset, line) - if after != nil && !usageBefore(*after, entry) { - return nil - } - callsAfter++ - calls = append(calls, entry) - if len(calls) >= 2*keep { - trim() - } + offer(usageCallEntry(offset, line)) return nil }) if err != nil { @@ -611,23 +752,27 @@ func QueryUsageLedger(path string, q UsageQuery, cursor string, limit int, budge } page.Totals.Runs = len(runs.rows) page.Totals.Entries = page.Totals.Runs + page.Totals.UnknownRunRecords - - candidates := calls - remaining := callsAfter for _, row := range runs.rows { - if after != nil && !usageBefore(*after, *row) { - continue - } - remaining++ - candidates = append(candidates, *row) + offer(*row) } - sort.Slice(candidates, func(i, j int) bool { return usageBefore(candidates[i], candidates[j]) }) - if len(candidates) > limit { - candidates = candidates[:limit] + trim() + + // The page is copied into a slice of exactly its length, so nothing the + // caller keeps holds the buffer, the runs, or the sets they counted + // models in. + page.Entries = make([]UsageEntry, min(limit, len(best))) + for i := range page.Entries { + page.Entries[i] = best[i].finished() } - page.Entries = candidates - if remaining > len(candidates) && len(candidates) > 0 { - page.Next = encodeUsageCursor(q, candidates[len(candidates)-1]) + if remaining > len(page.Entries) && len(page.Entries) > 0 { + if reading == nil { + ends, err := usagePrefixEnds(file, page.Stats.Bytes) + if err != nil { + return UsageLedgerPage{}, err + } + reading = &usageReading{floor: page.Stats.Bytes, ends: ends} + } + page.Next = encodeUsageCursor(q, page.Entries[len(page.Entries)-1], *reading) } return page, nil } @@ -662,7 +807,9 @@ func EachUsageLedgerEntry(path string, q UsageQuery, budget UsageLedgerBudget, f if !q.matches(line) { return nil } - totals.count(line) + if err := totals.count(line); err != nil { + return err + } if line.Run != "" { return runs.add(line) } @@ -682,14 +829,14 @@ func EachUsageLedgerEntry(path string, q UsageQuery, budget UsageLedgerBudget, f return nil } if line.Run == "" { - return fn(usageCallEntry(offset, line)) + return fn(usageCallEntry(offset, line).finished()) } row := runs.rows[line.Run] if row == nil || row.emitted { return nil } row.emitted = true - return fn(*row) + return fn(row.finished()) }) if err != nil { return UsageLedgerTotals{}, stats, err diff --git a/internal/session/usage_query_test.go b/internal/session/usage_query_test.go index 3f60e2d841..661c6e4ce5 100644 --- a/internal/session/usage_query_test.go +++ b/internal/session/usage_query_test.go @@ -1,11 +1,14 @@ package session import ( + "bytes" "encoding/json" "errors" "fmt" + "math" "os" "path/filepath" + "slices" "strings" "testing" "time" @@ -441,3 +444,187 @@ func TestLedgerSubjectsAgreeWithUsageBySubject(t *testing.T) { t.Fatalf("a line naming nothing was spent on %s %q", kind, id) } } + +// manyRuns writes n runs of one call each, a second apart, and answers the path. +func manyRuns(t testing.TB, n int) string { + t.Helper() + var b strings.Builder + base := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + for i := 0; i < n; i++ { + data, err := json.Marshal(UsageLine{Run: fmt.Sprintf("run%06d", i), At: base.Add(time.Duration(i) * time.Second), Model: "m/a", Calls: 1, Input: 1, Output: 1, USD: cent}) + if err != nil { + t.Fatal(err) + } + b.Write(data) + b.WriteByte('\n') + } + path := filepath.Join(t.TempDir(), UsageLedgerName) + if err := os.WriteFile(path, []byte(b.String()), 0o600); err != nil { + t.Fatal(err) + } + return path +} + +// A PAGE HOLDS ITS PAGE AND NOTHING MORE: a hundred thousand runs answered a +// row at a time hand back a slice of exactly one, with the totals and the next +// page exact. +func TestLedgerPageOfOneFromAHundredThousandRuns(t *testing.T) { + path := manyRuns(t, 100_000) + page, err := QueryUsageLedger(path, UsageQuery{}, "", 1, DefaultUsageLedgerBudget) + if err != nil { + t.Fatal(err) + } + if len(page.Entries) != 1 || cap(page.Entries) != 1 || page.Entries[0].Run != "run099999" || page.Next == "" { + t.Fatalf("page of %d (cap %d): %+v next %q", len(page.Entries), cap(page.Entries), page.Entries, page.Next) + } + if page.Totals.Runs != 100_000 || page.Totals.Entries != 100_000 || page.Totals.USD != 100_000*cent { + t.Fatalf("totals %+v", page.Totals) + } + next, err := QueryUsageLedger(path, UsageQuery{}, page.Next, 3, DefaultUsageLedgerBudget) + if err != nil { + t.Fatal(err) + } + if len(next.Entries) != 3 || cap(next.Entries) != 3 || next.Entries[0].Run != "run099998" || next.Entries[2].Run != "run099996" || next.Totals != page.Totals { + t.Fatalf("second page %+v", next.Entries) + } +} + +// BenchmarkLedgerQueryHundredThousandRuns is one page of the first scan over a +// hundred thousand runs: linear in the file, a page held. +func BenchmarkLedgerQueryHundredThousandRuns(b *testing.B) { + path := manyRuns(b, 100_000) + b.ReportAllocs() + b.ResetTimer() + for range b.N { + page, err := QueryUsageLedger(path, UsageQuery{}, "", 1, DefaultUsageLedgerBudget) + if err != nil || cap(page.Entries) != 1 { + b.Fatal(err, cap(page.Entries)) + } + } +} + +// A RUN THAT MET TEN THOUSAND MODELS IS STILL ONE ROW, its models every one +// and sorted, counted against the budget, and met without sorting each in. +func TestLedgerRunWithTenThousandModels(t *testing.T) { + var lines []UsageLine + at := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + for i := 10_000; i > 0; i-- { + lines = append(lines, UsageLine{Run: "r", At: at, Model: fmt.Sprintf("m/%05d", i), USD: cent}) + lines = append(lines, UsageLine{Run: "r", At: at, Model: fmt.Sprintf("m/%05d", i)}) + } + path := writeLedger(t, lines, nil, "") + page, err := QueryUsageLedger(path, UsageQuery{}, "", 1, DefaultUsageLedgerBudget) + if err != nil { + t.Fatal(err) + } + row := page.Entries[0] + if len(row.Models) != 10_000 || !slices.IsSorted(row.Models) || row.Models[0] != "m/00001" || row.Records != 20_000 || row.modelSet != nil { + t.Fatalf("%d models, sorted %v, first %q", len(row.Models), slices.IsSorted(row.Models), row.Models[0]) + } + var walked UsageEntry + if _, _, err := EachUsageLedgerEntry(path, UsageQuery{}, DefaultUsageLedgerBudget, func(e UsageEntry) error { walked = e; return nil }); err != nil { + t.Fatal(err) + } + if !slices.Equal(walked.Models, row.Models) { + t.Fatal("the walk's models differ from the page's") + } + if _, err := QueryUsageLedger(path, UsageQuery{}, "", 1, UsageLedgerBudget{MaxRuns: 10, MaxBytes: 256 << 10}); !errors.Is(err, ErrUsageLedgerTooLarge) { + t.Fatalf("ten thousand models were not counted against the budget: %v", err) + } +} + +// A FIGURE THAT CANNOT BE ADDED UP IS REFUSED: costs whose sum leaves the +// finite floats, counts whose sum would wrap, and a negative token count are +// each an error, never an infinity, a wrapped number or a zero. +func TestLedgerRefusesFiguresItCannotAdd(t *testing.T) { + at := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + for name, lines := range map[string][]UsageLine{ + "cost": {{At: at, USD: math.MaxFloat64}, {Run: "r", At: at, USD: math.MaxFloat64}}, + "calls": {{At: at, Calls: math.MaxInt}, {At: at, Calls: 2}}, + "tokens": {{At: at, Input: math.MaxInt}, {Run: "r", At: at, Input: math.MaxInt}, {At: at, Input: 2}}, + "negative": {{At: at, Output: -5, USD: cent}}, + } { + path := writeLedger(t, lines, nil, "") + if _, err := QueryUsageLedger(path, UsageQuery{}, "", 0, DefaultUsageLedgerBudget); !errors.Is(err, ErrUsageLedgerFigures) { + t.Fatalf("%s: page %v", name, err) + } + if _, _, err := EachUsageLedgerEntry(path, UsageQuery{}, DefaultUsageLedgerBudget, func(UsageEntry) error { return nil }); !errors.Is(err, ErrUsageLedgerFigures) { + t.Fatalf("%s: walk %v", name, err) + } + } + // One of the two huge figures alone is a figure, and is answered. + path := writeLedger(t, []UsageLine{{At: at, USD: math.MaxFloat64}}, nil, "") + if page, err := QueryUsageLedger(path, UsageQuery{}, "", 0, DefaultUsageLedgerBudget); err != nil || page.Totals.USD != math.MaxFloat64 { + t.Fatalf("%v %+v", err, page.Totals) + } +} + +// EVERY PAGE OF ONE PAGING READS THE BYTES THE FIRST ONE READ: what is +// appended meanwhile is on no page and in no total until the next paging, and +// a ledger replaced, cut shorter or rewritten under a cursor refuses it. +func TestLedgerCursorReadsTheFirstPagesBytes(t *testing.T) { + lines, path := bigLedger(t, time.UTC) + first, err := QueryUsageLedger(path, UsageQuery{}, "", 100, DefaultUsageLedgerBudget) + if err != nil || first.Next == "" { + t.Fatalf("%v %q", err, first.Next) + } + original, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + appended := original + for i := 0; i < 50; i++ { + data, _ := json.Marshal(UsageLine{Run: fmt.Sprintf("late%02d", i), At: lines[0].At.Add(-time.Hour), USD: cent}) + appended = append(append(appended, data...), '\n') + } + appended = append(appended, `{"at":"2026-10-1`...) // and a write in flight + if err := os.WriteFile(path, appended, 0o600); err != nil { + t.Fatal(err) + } + seen := map[string]bool{} + for _, e := range first.Entries { + seen[e.Key] = true + } + cursor := first.Next + for cursor != "" { + page, err := QueryUsageLedger(path, UsageQuery{}, cursor, 500, DefaultUsageLedgerBudget) + if err != nil { + t.Fatal(err) + } + if page.Totals != first.Totals { + t.Fatalf("an append moved the totals of a paging: %+v vs %+v", page.Totals, first.Totals) + } + for _, e := range page.Entries { + if seen[e.Key] || strings.HasPrefix(e.Run, "late") { + t.Fatalf("%s twice, or from after the first page", e.Key) + } + seen[e.Key] = true + } + cursor = page.Next + } + if len(seen) != first.Totals.Entries { + t.Fatalf("visited %d rows of %d", len(seen), first.Totals.Entries) + } + if fresh, err := QueryUsageLedger(path, UsageQuery{}, "", 1, DefaultUsageLedgerBudget); err != nil || fresh.Totals.Runs != first.Totals.Runs+50 { + t.Fatalf("a new paging does not see the appended runs: %v %+v", err, fresh.Totals) + } + + for name, data := range map[string][]byte{ + "shorter": original[:len(original)/2], + "rewritten": append([]byte(`{"at":"2026-10-12T08:00:00Z","usd":1}`+"\n"), original[38:]...), + "replaced": append(bytes.Repeat([]byte(" "), len(original)), '\n'), + } { + if err := os.WriteFile(path, data, 0o600); err != nil { + t.Fatal(err) + } + if _, err := QueryUsageLedger(path, UsageQuery{}, first.Next, 10, DefaultUsageLedgerBudget); !errors.Is(err, ErrUsageLedgerChanged) { + t.Fatalf("%s: %v", name, err) + } + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if _, err := QueryUsageLedger(path, UsageQuery{}, first.Next, 10, DefaultUsageLedgerBudget); !errors.Is(err, ErrUsageLedgerChanged) { + t.Fatalf("removed: %v", err) + } +} From 60e1c7a3aee47c02496643b6ec13d23ecc06f792 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 22:33:01 -0400 Subject: [PATCH 099/459] Refuse oversized ledger strings before allocating their JSON copies --- internal/appwire/spend_ledger.go | 60 +++++++++++++++++++++++++-- internal/appwire/spend_ledger_test.go | 19 +++++++++ 2 files changed, 75 insertions(+), 4 deletions(-) diff --git a/internal/appwire/spend_ledger.go b/internal/appwire/spend_ledger.go index 281f5e7276..8ceabc8538 100644 --- a/internal/appwire/spend_ledger.go +++ b/internal/appwire/spend_ledger.go @@ -33,6 +33,7 @@ import ( "strconv" "strings" "time" + "unicode/utf8" ) // The two grains a ledger row has. @@ -99,10 +100,11 @@ type SpendLedgerExportParams struct { // SpendLedgerRow is one row at its grain. time is the first call's instant and // end the last's, both with the engine's local offset. type SpendLedgerRow struct { - Grain string `json:"grain" enum:"run,call"` - Key string `json:"key"` - Run string `json:"run,omitempty"` - Time time.Time `json:"time"` + Grain string `json:"grain" enum:"run,call"` + Key string `json:"key"` + Run string `json:"run,omitempty"` + Time time.Time `json:"time" + "unicode/utf8"` End time.Time `json:"end"` Kind string `json:"kind,omitempty" enum:"agent,task,automation"` Subject string `json:"subject,omitempty"` @@ -310,6 +312,13 @@ var spendLedgerMethods = map[string]method{ // checkSpendLedgerReply refuses an answer too large to carry, saying what to // ask for instead. func checkSpendLedgerReply(result SpendLedgerResult, limit int) error { + if !spendLedgerFitsBeforeEncoding(result) { + fewer := "ask for fewer rows with a smaller limit, or " + if limit <= 1 { + fewer = "" + } + return Errorf(CodeBadParams, "spend.ledger: this page exceeds the %d byte encoding budget; %snarrow the filter — a model, run or shorter range", SpendLedgerReplyMax, fewer) + } data, err := json.Marshal(result) if err != nil { return Errorf(CodeEngine, "spend.ledger: the page could not be written as JSON: %v", err) @@ -324,6 +333,49 @@ func checkSpendLedgerReply(result SpendLedgerResult, limit int) error { return Errorf(CodeBadParams, "spend.ledger: this page is %d bytes, more than the %d one answer may carry; %snarrow the filter — a model, a run, or a shorter range — because a single run that used very many models is one row however small the page", len(data), SpendLedgerReplyMax, fewer) } +// Bound strings BEFORE json.Marshal allocates its complete encoded buffer. A +// page may name a very long held title even though its usage rows were small. +// Reserve a conservative fixed allowance for field names, numbers and times; +// string escaping is counted without copying the strings. +func spendLedgerFitsBeforeEncoding(result SpendLedgerResult) bool { + if len(result.Rows) > SpendLedgerLimitMax { + return false + } + remaining := SpendLedgerReplyMax - (len(result.Rows)+1)*1024 + take := func(s string) bool { + remaining -= 2 + for len(s) > 0 && remaining >= 0 { + r, n := utf8.DecodeRuneInString(s) + cost := n + switch { + case r == '"' || r == '\\' || r == '\b' || r == '\f' || r == '\n' || r == '\r' || r == '\t': + cost = 2 + case r < 0x20 || r == '<' || r == '>' || r == '&' || r == 0x2028 || r == 0x2029 || (r == utf8.RuneError && n == 1): + cost = 6 + } + remaining -= cost + s = s[n:] + } + return remaining >= 0 + } + if !take(result.From) || !take(result.To) || !take(result.Next) { + return false + } + for _, row := range result.Rows { + for _, s := range []string{row.Grain, row.Key, row.Run, row.Kind, row.Subject, row.Name, row.Session, row.Root, row.Task, row.Automation, row.Workspace} { + if !take(s) { + return false + } + } + for _, model := range row.Models { + if !take(model) { + return false + } + } + } + return remaining >= 0 +} + // ── the file ──────────────────────────────────────────────────────────────── // SpendLedgerCSVHeader is spend.ledger.export's columns, in order. The first diff --git a/internal/appwire/spend_ledger_test.go b/internal/appwire/spend_ledger_test.go index 6cfdcea0fe..ceedfebb3d 100644 --- a/internal/appwire/spend_ledger_test.go +++ b/internal/appwire/spend_ledger_test.go @@ -273,3 +273,22 @@ func TestSpendLedgerCSVCostKeepsItsPrecision(t *testing.T) { t.Fatalf("cost %q", cost) } } + +func TestSpendLedgerLargeNamesAreRefusedBeforeJSONCopies(t *testing.T) { + result := SpendLedgerResult{Rows: []SpendLedgerRow{{Name: strings.Repeat("<", SpendLedgerReplyMax/3)}}} + if checkSpendLedgerReply(result, 1) == nil { + t.Fatal("large escaped title was accepted") + } + measured := testing.Benchmark(func(b *testing.B) { + for range b.N { + _ = checkSpendLedgerReply(result, 1) + } + }) + if measured.AllocedBytesPerOp() > 64<<10 { + t.Fatalf("oversized title was copied before refusal: %d bytes per check", measured.AllocedBytesPerOp()) + } + small := SpendLedgerResult{Rows: []SpendLedgerRow{{Name: "A \"quoted\" model & title"}}} + if err := checkSpendLedgerReply(small, 1); err != nil { + t.Fatal(err) + } +} From 6b0def5cf280a450905950fae3747932a531ed61 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 22:39:08 -0400 Subject: [PATCH 100/459] Bound optional ledger display names before retaining response metadata --- cmd/codeaf/appledger.go | 24 ++++++++++++++++++------ cmd/codeaf/appledger_test.go | 13 +++++++++++++ 2 files changed, 31 insertions(+), 6 deletions(-) diff --git a/cmd/codeaf/appledger.go b/cmd/codeaf/appledger.go index 6fa7b5a1ee..fcda792403 100644 --- a/cmd/codeaf/appledger.go +++ b/cmd/codeaf/appledger.go @@ -207,6 +207,18 @@ func appLedgerTotals(t session.UsageLedgerTotals) appwire.SpendLedgerTotals { // kept, so a range of very many automations costs reads, never memory. const appLedgerNameCache = 256 +// Titles are optional display metadata, not billing evidence. Omit an oversized +// title rather than copying it into every page/CSV row; the actual subject id +// remains. Clone small names so whitespace trim cannot retain a huge backing string. +const appLedgerTitleMax = 4096 + +func appLedgerTitle(title string) string { + if len(title) > appLedgerTitleMax { + return "" + } + return strings.Clone(strings.TrimSpace(title)) +} + // appLedgerNamer names one answer's rows from what the app already holds. type appLedgerNamer struct { // conversation and task answer a held agent's title and a held task's, @@ -227,7 +239,7 @@ func (b *appBackend) ledgerNamer() *appLedgerNamer { title = a.view.Title } }) - return strings.TrimSpace(title) + return title }, task: func(conversation, id string) (title string) { n, err := strconv.ParseUint(id, 10, 64) @@ -239,7 +251,7 @@ func (b *appBackend) ledgerNamer() *appLedgerNamer { title = a.live.tasks[appTaskID(conversation, n)].Title } }) - return strings.TrimSpace(title) + return title }, automation: func(string) string { return "" }, automations: map[string]string{}, @@ -254,7 +266,7 @@ func (b *appBackend) ledgerNamer() *appLedgerNamer { if err != nil { return "" } - return strings.TrimSpace(a.Title) + return a.Title } } return n @@ -267,12 +279,12 @@ func (n *appLedgerNamer) of(entry session.UsageEntry) string { } switch entry.Kind { case session.SubjectConversation: - return n.conversation(entry.Subject) + return appLedgerTitle(n.conversation(entry.Subject)) case session.SubjectAutomation: if title, known := n.automations[entry.Subject]; known { return title } - title := n.automation(entry.Subject) + title := appLedgerTitle(n.automation(entry.Subject)) if len(n.automations) < appLedgerNameCache { n.automations[entry.Subject] = title } @@ -281,7 +293,7 @@ func (n *appLedgerNamer) of(entry session.UsageEntry) string { if entry.Root == "" { return "" } - return n.task(entry.Root, entry.Subject) + return appLedgerTitle(n.task(entry.Root, entry.Subject)) } return "" } diff --git a/cmd/codeaf/appledger_test.go b/cmd/codeaf/appledger_test.go index 9a80e00e94..5356c19550 100644 --- a/cmd/codeaf/appledger_test.go +++ b/cmd/codeaf/appledger_test.go @@ -477,3 +477,16 @@ func TestAppSpendLedgerRefusesFiguresItCannotAdd(t *testing.T) { } keptIntact(t, dir, dest, "kept\n") } + +func TestAppSpendLedgerLargeNamesDoNotEscapeTheirDisplayBudget(t *testing.T) { + huge := strings.Repeat(" ", appLedgerTitleMax) + "actual title" + n := &appLedgerNamer{conversation: func(string) string { return huge }, task: func(string, string) string { return huge }, automation: func(string) string { return huge }, automations: map[string]string{}} + for _, kind := range []string{session.SubjectConversation, session.SubjectTask, session.SubjectAutomation} { + if got := n.of(session.UsageEntry{Kind: kind, Subject: "kept-id", Root: "root"}); got != "" { + t.Fatalf("oversized %s title retained", kind) + } + } + if got := appLedgerTitle(" Real title "); got != "Real title" { + t.Fatalf("valid title changed: %q", got) + } +} From 51251dd3fd11863813509870d33ab4f0f3f9fa1c Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 22:27:57 -0400 Subject: [PATCH 101/459] Name the line a post up became, and open it by its crew's id A member of a sub-team with no manager posting to the manager above was answered without the line's number, so its handoff could not be opened. teamPostUp now keeps the id AppendTrafficID gives the line, and its answer names the team above by name and id and the line as #N. ReadTeamPost reads the new sentence and the older one, which carries no line and opens nothing. The app's handoff carries that crew id. crews.log.at takes it: the crew above is read for a member of a crew under it, and only a line naming that member's own membership; a direct member keeps the existing rule. The name path is unchanged. The TUI scopes a post up's number to the team above (by id), so a jump in the member's own team never lands on it. --- cmd/codeaf/app.go | 2 +- cmd/codeaf/appbrief_test.go | 121 ++++++++++++++++++ cmd/codeaf/appcrews.go | 44 +++++++ docs/changes/unreleased/0-app-post-up-line.md | 12 ++ internal/appwire/crews.go | 6 +- internal/appwire/worklog.go | 7 +- internal/session/team_nest.go | 7 +- internal/session/team_nest_test.go | 11 ++ internal/session/teampost.go | 88 +++++++++++-- internal/session/teampost_test.go | 15 ++- internal/tui3/teamjump.go | 7 + internal/tui3/teamjump_test.go | 19 +++ 12 files changed, 320 insertions(+), 19 deletions(-) create mode 100644 docs/changes/unreleased/0-app-post-up-line.md diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index 6ab578259a..28e4fb88f1 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -3073,7 +3073,7 @@ func appHandoff(tool, args, answer string) *appwire.Handoff { return nil } if post, ok := session.ReadTeamPost(answer); ok { - h := &appwire.Handoff{CrewName: post.Team, To: post.To, Post: post.Post, Answers: post.Answers, Delivered: true, Up: post.Up} + h := &appwire.Handoff{CrewName: post.Team, Crew: post.TeamID, To: post.To, Post: post.Post, Answers: post.Answers, Delivered: true, Up: post.Up} if post.Up { h.FromCrew = post.From } diff --git a/cmd/codeaf/appbrief_test.go b/cmd/codeaf/appbrief_test.go index 66540210ba..c39d18e7f2 100644 --- a/cmd/codeaf/appbrief_test.go +++ b/cmd/codeaf/appbrief_test.go @@ -1,9 +1,14 @@ package main import ( + "encoding/json" + "fmt" + "strings" "testing" + "time" "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/session" teamstore "github.com/Agent-Field/codeaf/internal/teams" ) @@ -250,3 +255,119 @@ func hasLine(lines []appwire.LogLine, id string) bool { } return false } + +// A POST UP OPENS ITS OWN LINE IN THE CREW ABOVE. A member of a sub-crew with +// no manager posts to the manager of the crew above; the engine's answer names +// that crew by id and the line it became, so the journal read back (as after a +// restart) opens that very line, never its twin with the same words. The crew +// above is read only for a member of a crew under it, and only its own line +// there; an older answer with no line opens nothing; an unrelated crew, a +// crew gone, a member gone and a room line up there are refused; the name path +// is not widened; a closed crew above still opens as history. +func TestAppCrewLogAtOpensAPostUpByItsCrewAndID(t *testing.T) { + backend, harbor, web := startedWeb(t, &fakeConversation{}, "Own the web client.") + const lonerKey = "/state/v3/places/proj/c0c0c0c0/transcript.jsonl" + const loner = "c0c0c0c0" + dock, elsewhere := teamstore.NewID(), teamstore.NewID() + if err := teamstore.Update(backend.profileDir, func(f *teamstore.File) error { + f.Teams = append(f.Teams, teamstore.Team{ID: dock, Name: "dock", Made: time.Now()}, teamstore.Team{ID: elsewhere, Name: "harbor", Made: time.Now()}) + if err := f.SetParent(dock, harbor); err != nil { + return err + } + for _, id := range []string{dock, elsewhere} { + if err := f.AddMember(id, teamstore.Member{Key: lonerKey + id, File: lonerKey, Where: "/w", Word: "loner", Handle: "loner"}); err != nil { + return err + } + } + return nil + }); err != nil { + t.Fatal(err) + } + up := func(team, text string) string { + id, err := teamstore.AppendTrafficID(backend.profileDir, team, teamstore.Entry{Kind: teamstore.KindNote, From: "loner", To: teamstore.ToManager, Member: lonerKey + dock, Text: `(from "dock", a team under yours with no manager of its own) ` + text}) + if err != nil { + t.Fatal(err) + } + return id + } + first := up(harbor, "LEAF-READY") + room, err := teamstore.AppendTrafficID(backend.profileDir, harbor, teamstore.Entry{Kind: teamstore.KindNote, From: "web", To: teamstore.ToRoom, Text: "harbor's own room"}) + if err != nil { + t.Fatal(err) + } + second := up(harbor, "LEAF-READY") + theirs := up(elsewhere, "LEAF-READY") + + journal := func(answer string) *appwire.Handoff { + content, _ := json.Marshal(answer) + record := `{"type":"session","version":1,"id":"n1","cwd":"/w"} +{"type":"message","role":"user","content":"Report up.","timestamp":"2026-10-15T23:06:41Z"} +{"type":"message","role":"assistant","content":"","toolCalls":[{"id":"c1","function":{"name":"team_post","arguments":"{\"to\":\"manager\",\"text\":\"LEAF-READY\"}"}}],"timestamp":"2026-10-15T23:06:42Z"} +{"type":"message","role":"tool","toolCallId":"c1","content":` + string(content) + `,"timestamp":"2026-10-15T23:06:42Z"} +` + for _, item := range session.ReadTranscriptBytes([]byte(record)).Entries { + if item.Tool == "team_post" { + return appHandoff(item.Tool, item.Args, item.Output) + } + } + return nil + } + said := func(post string) string { + return fmt.Sprintf(`Posted to the manager of "harbor" (team %s) as %s, which "dock" answers to while it has no manager of its own. It arrives at the start of its next step.`, harbor, teamstore.ThreadNumber(post)) + } + for _, want := range []string{first, second} { + h := journal(said(want)) + if h == nil || !h.Delivered || !h.Up || h.Crew != harbor || h.CrewName != "harbor" || h.FromCrew != "dock" || h.Post != want { + t.Fatalf("handoff read back = %+v", h) + } + a, err := backend.CrewLogAt(appwire.LogAnchorAsk{Agent: loner, Post: h.Post, CrewName: h.CrewName, Crew: h.Crew, Around: 1}) + if err != nil || a.Crew != harbor || a.Closed || a.Lines[1].ID != want || !strings.HasSuffix(a.Lines[1].Text, "LEAF-READY") { + t.Fatalf("anchor %s = %+v %v", want, a, err) + } + } + old := journal(`Posted to the manager of "harbor", which "dock" answers to while it has no manager of its own. It arrives at the start of its next step.`) + if old == nil || !old.Delivered || !old.Up || old.Post != "" || old.Crew != "" { + t.Fatalf("an older post up = %+v", old) + } + for name, ask := range map[string]appwire.LogAnchorAsk{ + "a crew of the same name it is not under": {Agent: loner, Post: theirs, Crew: harbor}, + "harbor's room line, not its own": {Agent: loner, Post: room, Crew: harbor}, + "a crew gone": {Agent: loner, Post: first, Crew: teamstore.NewID()}, + "an agent in neither": {Agent: "ffffffff", Post: first, Crew: harbor}, + "the name path is not widened": {Agent: loner, Post: first, CrewName: "harbor"}, + } { + if a, err := backend.CrewLogAt(ask); wireCode(err) != appwire.CodeNotFound { + t.Fatalf("%s = %+v %v", name, a, err) + } + } + // A member of harbor itself still reads harbor's lines to and from it. + if a, err := backend.CrewLogAt(appwire.LogAnchorAsk{Agent: web, Post: room, Crew: harbor}); err != nil || !hasLine(a.Lines, room) { + t.Fatalf("harbor's own member = %+v %v", a, err) + } + if err := teamstore.Update(backend.profileDir, func(f *teamstore.File) error { + for i := range f.Teams { + if f.Teams[i].ID == harbor { + f.Teams[i].State = teamstore.TeamClosed + } + } + return nil + }); err != nil { + t.Fatal(err) + } + if a, err := backend.CrewLogAt(appwire.LogAnchorAsk{Agent: loner, Post: second, Crew: harbor}); err != nil || !a.Closed || !hasLine(a.Lines, second) { + t.Fatalf("a closed crew above = %+v %v", a, err) + } + if err := teamstore.Update(backend.profileDir, func(f *teamstore.File) error { + for i := range f.Teams { + if f.Teams[i].ID == dock { + f.Teams[i].Members = nil + } + } + return nil + }); err != nil { + t.Fatal(err) + } + if _, err := backend.CrewLogAt(appwire.LogAnchorAsk{Agent: loner, Post: second, Crew: harbor}); wireCode(err) != appwire.CodeNotFound { + t.Fatalf("a member gone from the crew below = %v", err) + } +} diff --git a/cmd/codeaf/appcrews.go b/cmd/codeaf/appcrews.go index 0240650719..419c030525 100644 --- a/cmd/codeaf/appcrews.go +++ b/cmd/codeaf/appcrews.go @@ -1639,6 +1639,9 @@ func (b *appBackend) CrewLogAt(ask appwire.LogAnchorAsk) (appwire.LogAnchor, err if err != nil { return appwire.LogAnchor{}, appEngineError(err) } + if ask.Crew != "" { + return b.crewLogAtCrew(profile, f, ask.Crew, ask.Agent, post, around) + } var ( found []appwire.LogAnchor inCrews int @@ -1682,6 +1685,47 @@ func (b *appBackend) CrewLogAt(ask appwire.LogAnchorAsk) (appwire.LogAnchor, err return appwire.LogAnchor{}, appwire.Errorf(appwire.CodeNotFound, "line %s is no longer in the crew's log", teamstore.ThreadNumber(post)) } +// crewLogAtCrew is CrewLogAt for a line whose record named its crew by id (a +// post up to the crew above). The agent must be in that crew, where the line +// may be any written by or to it ([teamstore.Involves]), or in a crew under it, +// where it must be the agent's own: the line names that membership +// (Member), so the room's lines up there stay that crew's. +func (b *appBackend) crewLogAtCrew(profile string, f *teamstore.File, crew, agent, post string, around int) (appwire.LogAnchor, error) { + t, ok := f.Team(crew) + if !ok { + return appwire.LogAnchor{}, appwire.Errorf(appwire.CodeNotFound, "the crew line %s was written to is gone", teamstore.ThreadNumber(post)) + } + direct, in := b.crewMemberOf(t, agent) + var below []teamstore.Member + for _, d := range f.Descendants(t.ID) { + if m, ok := b.crewMemberOf(d, agent); ok && m.Key != "" { + below = append(below, m) + } + } + if !in && len(below) == 0 { + return appwire.LogAnchor{}, appwire.Errorf(appwire.CodeNotFound, "this agent is not in %q or a crew under it", t.Name) + } + entries, ok, err := teamstore.ReadAround(profile, t.ID, post, around) + if err != nil { + return appwire.LogAnchor{}, appEngineError(err) + } + mine := false + lines := make([]appwire.LogLine, 0, len(entries)) + for _, e := range entries { + if e.ID == post { + mine = in && teamstore.Involves(e, direct) + for _, m := range below { + mine = mine || e.Member == m.Key + } + } + lines = append(lines, appLogLine(t, e)) + } + if !ok || !mine { + return appwire.LogAnchor{}, appwire.Errorf(appwire.CodeNotFound, "line %s is no longer in the crew's log", teamstore.ThreadNumber(post)) + } + return appwire.LogAnchor{Crew: t.ID, Closed: t.Closed(), Lines: lines}, nil +} + // CrewDecisions is the decisions waiting (or all of them) for one crew, or // every decision waiting on the person. func (b *appBackend) CrewDecisions(id string, all bool) ([]appwire.Decision, error) { diff --git a/docs/changes/unreleased/0-app-post-up-line.md b/docs/changes/unreleased/0-app-post-up-line.md new file mode 100644 index 0000000000..23b9d3c581 --- /dev/null +++ b/docs/changes/unreleased/0-app-post-up-line.md @@ -0,0 +1,12 @@ +--- +kind: fixed +title: A post to the crew above names the line it became, so its receipt opens it +pr: 0 +surface: [engine] +invalidates: + - "A member of a sub-crew with no manager posting to the manager above was answered without the line's number or the crew's id, so its handoff had no `post` and nothing could open it. The answer now says the crew above by name and id and the line as `#N`; the handoff carries `crew` and `post`." + - "`crews.log.at` read only crews the agent is a member of, by name. With `crew` it also reads the crew above for a member of a crew under it, and only that member's own line there." +--- + +`teamPostUp` keeps the id `AppendTrafficID` gives the line; `ReadTeamPost` reads +the new sentence and the older one (which opens nothing). diff --git a/internal/appwire/crews.go b/internal/appwire/crews.go index 5692fe425b..4aefe4d078 100644 --- a/internal/appwire/crews.go +++ b/internal/appwire/crews.go @@ -470,7 +470,11 @@ type LogAnchorAsk struct { Agent string `json:"agent"` Post string `json:"post"` CrewName string `json:"crew_name,omitempty"` - Around int `json:"around,omitempty"` + // Crew is the crew id the record names (a post up to the crew above): + // then that crew's log is read, if the agent is in it or in a crew under + // it, and CrewName is not matched (a rename keeps the line). + Crew string `json:"crew,omitempty"` + Around int `json:"around,omitempty"` } // LogAnchorAroundMax bounds a log anchor's neighbours on each side. diff --git a/internal/appwire/worklog.go b/internal/appwire/worklog.go index 0165b7a19c..46c50d986c 100644 --- a/internal/appwire/worklog.go +++ b/internal/appwire/worklog.go @@ -53,12 +53,15 @@ type ToolFacts struct { // was for, and the log entry it became. Delivered false is a post the engine // refused or could not write; Reason is then the engine's own words. type Handoff struct { - // CrewName is the name of the crew whose log holds the line. + // CrewName is the name of the crew whose log holds the line, and Crew its + // id when the engine's answer named it (a post up to the crew above). CrewName string `json:"crew_name,omitempty"` + Crew string `json:"crew,omitempty"` // To is "manager", "room" or a member's handle (no @). To string `json:"to,omitempty"` // Post is the log entry the line was written as and Answers the entry it - // answers (LogLine ids); "" when the answer named none. + // answers (LogLine ids); "" when the answer named none (an older build's + // post up): such a line has nothing to open. Post string `json:"post,omitempty"` Answers string `json:"answers,omitempty"` Delivered bool `json:"delivered"` diff --git a/internal/session/team_nest.go b/internal/session/team_nest.go index 370046e281..720ca1f1a8 100644 --- a/internal/session/team_nest.go +++ b/internal/session/team_nest.go @@ -691,7 +691,10 @@ func (a *Agent) teamPostUp(team teams.Team, role teamRole, entry teams.Entry) (s profile := a.config.teamProfile() entry.Member = role.key entry.Text = fmt.Sprintf("(from %q, a team under yours with no manager of its own) %s", team.Name, entry.Text) - if err := teams.AppendTraffic(profile, role.boss, entry); err != nil { + // THE LINE'S OWN NUMBER AND ITS TEAM'S ID ARE IN THE ANSWER, as for a post + // in the member's own team: the one record that can take somebody to it. + own, err := teams.AppendTrafficID(profile, role.boss, entry) + if err != nil { return "The post could not be written to the traffic of " + strconv.Quote(role.bossName) + ": " + err.Error(), true, nil } file, _, _, _ := a.teamSnapshot(profile) @@ -702,5 +705,5 @@ func (a *Agent) teamPostUp(team teams.Team, role teamRole, entry teams.Entry) (s } } } - return teamPostUpSaid(role.bossName, team.Name), false, nil + return teamPostUpSaid(role.bossName, role.boss, own, entry.Answers, team.Name), false, nil } diff --git a/internal/session/team_nest_test.go b/internal/session/team_nest_test.go index 0aa9191f99..3d76796870 100644 --- a/internal/session/team_nest_test.go +++ b/internal/session/team_nest_test.go @@ -498,6 +498,17 @@ func TestAnUnmanagedSubTeamsMemberAnswersToTheManagerAbove(t *testing.T) { if post := last(log); post.From != "loner" || post.To != teams.ToManager || !strings.Contains(post.Text, `(from "dock"`) { t.Fatalf("harbor's log: %+v", post) } + // Its answer names the line it became in harbor's log and harbor by id; + // the same words posted again are another line, read back as that one. + read, ok := ReadTeamPost(said) + if !ok || !read.Up || read.TeamID != harbor || read.Team != "harbor" || read.From != "dock" || read.Post != last(log).ID { + t.Fatalf("the answer %q read back as %+v %v (log line %s)", said, read, ok, last(log).ID) + } + again, _ := callTool(t, loner.teamPostTool, `{"to":"manager","text":"the dock is done"}`) + log, _ = teams.ReadTraffic(n.fixture.profile, harbor, "", 0) + if second, ok := ReadTeamPost(again); !ok || second.Post == read.Post || second.Post != last(log).ID || second.TeamID != harbor { + t.Fatalf("the same words again read back as %+v %v (log line %s, first %s)", second, ok, last(log).ID, read.Post) + } if news := boss.teamBoundary(); !strings.Contains(news, "from @loner") || !strings.Contains(news, "the dock is done") { t.Fatalf("harbor's manager was not handed the post:\n%s", news) } diff --git a/internal/session/teampost.go b/internal/session/teampost.go index 3401358ae2..87ce5034de 100644 --- a/internal/session/teampost.go +++ b/internal/session/teampost.go @@ -10,7 +10,6 @@ package session // two writers that compose it, which are the only code that knows the shape. import ( - "fmt" "strconv" "strings" @@ -23,9 +22,12 @@ type TeamPost struct { Team string // To is "manager", "room" or a teammate's handle (no @). To string + // TeamID is the id of the team whose log holds the line, for a post up + // (the one answer that names it); "" for every other answer. + TeamID string // Post is the traffic entry the line was written as and Answers the one it - // answers, both entry ids; "" when the answer named none (a post up to a - // parent team's manager is not numbered). + // answers, both entry ids; "" when the answer named none (an older build's + // post up to a parent team's manager was not numbered). Post string Answers string // Up is a post from a team with no manager of its own to the manager of @@ -37,7 +39,8 @@ type TeamPost struct { const ( teamPostLead = "Posted to " teamPostArrive = ". It arrives at the start of their next step." - teamPostUpFmt = "Posted to the manager of %q, which %q answers to while it has no manager of its own. It arrives at the start of its next step." + teamPostUpLead = "Posted to the manager of " + teamPostUpTail = " answers to while it has no manager of its own. It arrives at the start of its next step." ) // teamPostSaid is a delivered post's answer: where is "the room", "the @@ -54,20 +57,81 @@ func teamPostSaid(where, team, own, answers string) string { } // teamPostUpSaid is a post's answer when it went up to the manager of the -// team above. -func teamPostUpSaid(boss, team string) string { - return fmt.Sprintf(teamPostUpFmt, boss, team) +// team above: that team by name and id, the entry the line was written as in +// its log and the one it answers (ids or ""), and the team it came from. An +// older build said only the two names. +func teamPostUpSaid(boss, bossID, own, answers, team string) string { + said := teamPostUpLead + strconv.Quote(boss) + if bossID != "" { + said += " (team " + bossID + ")" + } + if own != "" { + said += " as " + teams.ThreadNumber(own) + } + if answers != "" { + said += ", answering " + teams.ThreadNumber(answers) + } + return said + ", which " + strconv.Quote(team) + teamPostUpTail +} + +// readTeamPostUp is ReadTeamPost for a post up; false for any other answer. +func readTeamPostUp(answer string) (TeamPost, bool) { + rest, ok := strings.CutPrefix(answer, teamPostUpLead) + if !ok { + return TeamPost{}, false + } + boss, err := strconv.QuotedPrefix(rest) + if err != nil { + return TeamPost{}, false + } + post := TeamPost{To: teams.ToManager, Up: true} + post.Team, _ = strconv.Unquote(boss) + rest = rest[len(boss):] + if after, ok := strings.CutPrefix(rest, " (team "); ok { + id, tail, ok := strings.Cut(after, ")") + if !ok || id == "" { + return TeamPost{}, false + } + post.TeamID, rest = id, tail + } + if after, ok := strings.CutPrefix(rest, " as "); ok { + number, tail, _ := strings.Cut(after, ",") + id, ok := teams.ThreadID(number) + if !ok { + return TeamPost{}, false + } + post.Post, rest = id, ","+tail + } + if after, ok := strings.CutPrefix(rest, ", answering "); ok { + number, tail, _ := strings.Cut(after, ",") + id, ok := teams.ThreadID(number) + if !ok { + return TeamPost{}, false + } + post.Answers, rest = id, ","+tail + } + from, ok := strings.CutPrefix(rest, ", which ") + if !ok { + return TeamPost{}, false + } + quoted, err := strconv.QuotedPrefix(from) + if err != nil { + return TeamPost{}, false + } + post.From, _ = strconv.Unquote(quoted) + // The sentence is read back only when writing the facts says it again. + if teamPostUpSaid(post.Team, post.TeamID, post.Post, post.Answers, post.From) != answer { + return TeamPost{}, false + } + return post, true } // ReadTeamPost is the post a team_post answer records, and false for any // other answer (a refusal, a failed write, an answer from an older build). func ReadTeamPost(answer string) (TeamPost, bool) { answer = strings.TrimSpace(answer) - var boss, from string - if n, err := fmt.Sscanf(answer, "Posted to the manager of %q, which %q answers", &boss, &from); err == nil && n == 2 { - if teamPostUpSaid(boss, from) == answer { - return TeamPost{Team: boss, To: teams.ToManager, Up: true, From: from}, true - } + if post, ok := readTeamPostUp(answer); ok { + return post, true } rest, ok := strings.CutPrefix(answer, teamPostLead) if !ok { diff --git a/internal/session/teampost_test.go b/internal/session/teampost_test.go index 9af26607f6..8c8767b2f7 100644 --- a/internal/session/teampost_test.go +++ b/internal/session/teampost_test.go @@ -12,7 +12,8 @@ func TestTeamPostAnswerReadsBackAsItsFacts(t *testing.T) { {teamPostSaid("the manager", "Verification", "000000000003", "000000000002"), TeamPost{Team: "Verification", To: "manager", Post: "000000000003", Answers: "000000000002"}}, {teamPostSaid("the room", `Re "lease", in`, "000000000010", ""), TeamPost{Team: `Re "lease", in`, To: "room", Post: "000000000010"}}, {teamPostSaid("@backend", "Release", "", ""), TeamPost{Team: "Release", To: "backend"}}, - {teamPostUpSaid("Release", "Docs"), TeamPost{Team: "Release", To: "manager", Up: true, From: "Docs"}}, + {teamPostUpSaid("Release", "3fa9c1d2e4b0", "000000000042", "000000000040", "Docs, east"), TeamPost{Team: "Release", TeamID: "3fa9c1d2e4b0", To: "manager", Up: true, From: "Docs, east", Post: "000000000042", Answers: "000000000040"}}, + {teamPostUpSaid(`Re "lease"`, "3fa9c1d2e4b0", "000000000042", "", "Docs"), TeamPost{Team: `Re "lease"`, TeamID: "3fa9c1d2e4b0", To: "manager", Up: true, From: "Docs", Post: "000000000042"}}, } for _, c := range cases { got, ok := ReadTeamPost(c.said) @@ -31,6 +32,9 @@ func TestTeamPostAnswerReadsBackAsItsFacts(t *testing.T) { "The post could not be written to the team's traffic: disk full", "Posted to the manager in \"Verification\" as #3, answering #2. Something else.", "Posted to somebody in \"Verification\". It arrives at the start of their next step.", + "Posted to the manager of \"Release\" (team ) as #4, which \"Docs\" answers to while it has no manager of its own. It arrives at the start of its next step.", + "Posted to the manager of \"Release\" (team 3fa9c1d2e4b0) as #x, which \"Docs\" answers to while it has no manager of its own. It arrives at the start of its next step.", + "Posted to the manager of \"Release\" (team 3fa9c1d2e4b0) as #4, which \"Docs\" answers to while it has no manager of its own.", } { if post, ok := ReadTeamPost(refused); ok { t.Fatalf("%q read as a post %+v", refused, post) @@ -46,6 +50,15 @@ func TestTeamPostRecordedSentenceReads(t *testing.T) { } } +// AN OLDER BUILD'S POST UP named no line and no team id: it still reads as a +// delivered post up, with nothing to open, never a number taken from elsewhere. +func TestAnOlderPostUpReadsWithNoLine(t *testing.T) { + post, ok := ReadTeamPost("Posted to the manager of \"Release\", which \"Docs\" answers to while it has no manager of its own. It arrives at the start of its next step.") + if !ok || post != (TeamPost{Team: "Release", To: "manager", Up: true, From: "Docs"}) { + t.Fatalf("read %+v %v", post, ok) + } +} + func trimID(id string) string { for len(id) > 1 && id[0] == '0' { id = id[1:] diff --git a/internal/tui3/teamjump.go b/internal/tui3/teamjump.go index 91a7807cc3..e792bca872 100644 --- a/internal/tui3/teamjump.go +++ b/internal/tui3/teamjump.go @@ -304,6 +304,13 @@ func sentElsewhere(e *entry, shown team) bool { // A shared conversation can post to one team and receive another team's // message with the same number. The receipt's team name disambiguates it. func postedElsewhere(e *entry, shown team) bool { + // A post up says the team above by name and id and its line's number there. + if post, ok := session.ReadTeamPost(e.detail.Output); ok && post.Up { + if post.TeamID != "" { + return post.TeamID != shown.ID + } + return post.Team != shown.Name && post.Team != shown.ID + } _, rest, named := strings.Cut(e.detail.Output, " in ") if !named { return false diff --git a/internal/tui3/teamjump_test.go b/internal/tui3/teamjump_test.go index 4de8fe2219..342561bc6d 100644 --- a/internal/tui3/teamjump_test.go +++ b/internal/tui3/teamjump_test.go @@ -404,3 +404,22 @@ func TestTrafficJumpNeverLandsOnAnotherTeamsSameNumber(t *testing.T) { t.Fatalf("this team's send landed on entry %d, want its own team_send at 1", got) } } + +// A POST UP IS THE TEAM ABOVE'S LINE: its ` as #N` is that team's number, so a +// jump scoped to the member's own team never lands on it, and one scoped to +// the team above does. +func TestAPostUpIsScopedToTheTeamAbove(t *testing.T) { + up := &entry{kind: entryTool, tool: "team_post", detail: toolDetail{Output: `Posted to the manager of "harbor" (team 3fa9c1d2e4b0) as #2, which "dock" answers to while it has no manager of its own. It arrives at the start of its next step.`}} + harbor, dock := team{ID: "3fa9c1d2e4b0", Name: "harbor"}, team{ID: "0d0c0d0c0d0c", Name: "dock"} + if !postedElsewhere(up, dock) || postedElsewhere(up, harbor) { + t.Fatalf("a post up's scope: elsewhere from dock %v, from harbor %v", postedElsewhere(up, dock), postedElsewhere(up, harbor)) + } + // A team above renamed since: the id still places it. + if postedElsewhere(up, team{ID: "3fa9c1d2e4b0", Name: "port"}) { + t.Fatal("a renamed team above lost its post up") + } + in := &entry{kind: entryTool, tool: "team_post", detail: toolDetail{Output: `Posted to the manager in "dock" as #2. It arrives at the start of their next step.`}} + if postedElsewhere(in, dock) || !postedElsewhere(in, harbor) { + t.Fatal("a post in the member's own team changed scope") + } +} From 4b5b22f0fe19105c23f14fc887f96e9573619da1 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 22:27:58 -0400 Subject: [PATCH 102/459] Prove a role and a changed brief are both in a member's request The role is the person's own line in its chat; the start brief and a change in place are team traffic under the delivery's authority sentence. After a change both are in the request, the role untouched and first. --- internal/session/teambrief_test.go | 67 ++++++++++++++++++++++++++++++ 1 file changed, 67 insertions(+) diff --git a/internal/session/teambrief_test.go b/internal/session/teambrief_test.go index 68a845692b..a49ae7bb9e 100644 --- a/internal/session/teambrief_test.go +++ b/internal/session/teambrief_test.go @@ -1,8 +1,12 @@ package session import ( + "context" + "strings" "testing" + "time" + "github.com/Agent-Field/agentfield/sdk/go/ai" "github.com/Agent-Field/codeaf/internal/teams" ) @@ -38,3 +42,66 @@ func TestBriefChangedIsDeliveredToItsMemberAndWakesNobody(t *testing.T) { t.Fatalf("manager's change read back %+v", got) } } + +// THE REQUEST A MEMBER WITH A ROLE AND A CHANGED BRIEF ACTUALLY SENDS. The app +// gives a role as cmd/codeaf's tellRole does: the member's own message, the +// person's line. The brief comes as team traffic, the start and then the change +// in place, each under the delivery's authority sentence (not the person's +// words; the person's own words outrank the manager). After the change both +// are in the request: the role's words untouched as the person's line, before +// the change; the start brief still in the history; the change as a team line. +// Nothing withdraws the role or ranks the brief above it. +func TestARoleAndAChangedBriefAreBothInTheMembersRequest(t *testing.T) { + fixture := newTeamFixture(t, true) + appendTraffic(t, fixture, teams.Entry{Kind: teams.KindStart, From: teams.FromManager, To: "parser", Text: "Rewrite the lexer."}) + time.Sleep(2 * time.Millisecond) + completer := &scriptedCompleter{steps: []step{ + func(context.Context, []ai.Message) (*ai.Response, error) { return textResponse("on it"), nil }, + func(context.Context, []ai.Message) (*ai.Response, error) { + return textResponse("on the new brief"), nil + }, + }} + parser := teamAgent(t, fixture, fixture.parser, completer, nil) + const role = "Your role in harbor is reviewer.\n\nReview diffs; never change code." + events, err := parser.Submit(context.Background(), role) + if err != nil { + t.Fatal(err) + } + collect(t, events) + if _, err := teams.ReviseBrief(fixture.profile, fixture.teamID, convKeyOf(t, fixture.parser), teams.BriefChange{By: teams.FromYou, Text: "Rewrite the lexer; skip the e2e suite."}); err != nil { + t.Fatal(err) + } + events, err = parser.Submit(context.Background(), "carry on") + if err != nil { + t.Fatal(err) + } + collect(t, events) + + at := func(messages []ai.Message, has string) (int, string) { + for i, m := range messages { + if text := messageText(m); m.Role == "user" && strings.Contains(text, has) { + return i, text + } + } + return -1, "" + } + const authority = "The person's own words in this conversation outrank the manager" + for n, request := range [][]ai.Message{completer.request(0), completer.request(1)} { + i, said := at(request, "Review diffs; never change code.") + if i < 0 || strings.Contains(said, "Team traffic") || strings.Contains(said, "not the person's words") { + t.Fatalf("request %d: the role is not the person's own line:\n%q", n, said) + } + if j, start := at(request, teamBriefWord+" #1: Rewrite the lexer."); j < 0 || !strings.Contains(start, "not the person's words") || !strings.Contains(start, authority) { + t.Fatalf("request %d: the start brief is not team traffic under the authority sentence:\n%q", n, start) + } + } + request := completer.request(1) + role0, _ := at(request, "Review diffs; never change code.") + k, change := at(request, teamBriefChangedWord+"the person #") + if k < 0 || !strings.Contains(change, ": Rewrite the lexer; skip the e2e suite.") || !strings.Contains(change, "not the person's words") || !strings.Contains(change, authority) { + t.Fatalf("the change is not a team line under the authority sentence:\n%q", change) + } + if role0 > k { + t.Fatalf("the role (%d) came after the change (%d)", role0, k) + } +} From 3ea53f683653e19213f7f1e910dd70c9bdf10c23 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 22:45:31 -0400 Subject: [PATCH 103/459] Reuse immutable title analysis during conversation enumeration --- internal/session/place.go | 9 +++++---- internal/session/title.go | 33 +++++++++++++++++++++++---------- 2 files changed, 28 insertions(+), 14 deletions(-) diff --git a/internal/session/place.go b/internal/session/place.go index 634a9a2fe5..4d8dc3da9b 100644 --- a/internal/session/place.go +++ b/internal/session/place.go @@ -310,11 +310,12 @@ func LoadMeta(dir string) (Meta, error) { // before the name arrived. Only a folder that actually holds one of these // pays for that read ([openingPlaceholder]), and the next thing said in the // session stamps the words back onto the file ([Agent.stampUserLocked]). - if !meta.LiteralName && strings.TrimSpace(meta.Title) != "" && healedTitle(meta.Title) == "" { - meta.Title = openingPlaceholder(dir) - } if !meta.LiteralName { - meta.Title = healedTitle(meta.Title) + title := healedTitle(meta.Title) + if strings.TrimSpace(meta.Title) != "" && title == "" { + title = healedTitle(openingPlaceholder(dir)) + } + meta.Title = title } meta.ShortTitle = "" // Old tab labels no longer participate in naming. return meta, nil diff --git a/internal/session/title.go b/internal/session/title.go index 95d96285fc..d386c6ab90 100644 --- a/internal/session/title.go +++ b/internal/session/title.go @@ -721,9 +721,11 @@ func unusableName(name string) bool { // same rule in a second place. The underscore is deliberately left alone — it // is a character inside identifiers a person may genuinely have named, and // [cleanTitle] already unwelds the one case where it is a separator. +var titleMarkup = strings.NewReplacer("*", "", "`", "") + func stripMarkup(title string) string { title = strings.TrimLeft(title, "#> \t") - title = strings.NewReplacer("*", "", "`", "").Replace(title) + title = titleMarkup.Replace(title) title = stripListMarker(title) return strings.TrimSpace(title) } @@ -760,12 +762,23 @@ func stripListMarker(title string) string { // of them appearing anywhere in an answer means the model handed the // instruction back rather than doing what it said. They are matched against the // normalized answer, so case, quotes and punctuation do not hide an echo. -var instructionPhrases = []string{ - "name this session", - "name this conversation twice", - "name this piece of work", - "what is this work trying to find out", -} +var instructionPhrases = func() []string { + phrases := []string{"name this session", "name this conversation twice", "name this piece of work", "what is this work trying to find out"} + for i, phrase := range phrases { + phrases[i] = " " + strings.Join(normalizedWords(phrase), " ") + " " + } + return phrases +}() + +// Immutable prompt vocabulary is shared by saved-title readers. +var instructionVocabulary = func() [][]string { + prompts := []string{titlePrompt, legacyTitlePrompt, taskNamePrompt, jobNamePrompt, captionPrompt} + words := make([][]string, len(prompts)) + for i, prompt := range prompts { + words[i] = uniqueWords(normalizedWords(prompt)) + } + return words +}() // instructionEchoDivisor sets how much of a prompt an answer may borrow: an // answer holding one in every two of a prompt's distinct words — HALF of them — @@ -788,7 +801,7 @@ func namesTheInstruction(name string) bool { } joined := " " + strings.Join(said, " ") + " " for _, phrase := range instructionPhrases { - if strings.Contains(joined, " "+strings.Join(normalizedWords(phrase), " ")+" ") { + if strings.Contains(joined, phrase) { return true } } @@ -796,10 +809,10 @@ func namesTheInstruction(name string) bool { for _, word := range said { spoken[word] = true } - for _, prompt := range []string{titlePrompt, legacyTitlePrompt, taskNamePrompt, jobNamePrompt, captionPrompt} { + for _, vocabulary := range instructionVocabulary { asked := 0 shared := 0 - for _, word := range uniqueWords(normalizedWords(prompt)) { + for _, word := range vocabulary { asked++ if spoken[word] { shared++ From f1358b408f32f5b9493b37ed286e2c750a6116ca Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 22:51:19 -0400 Subject: [PATCH 104/459] Repair ledger timestamp field JSON tag --- internal/appwire/spend_ledger.go | 9 ++++----- 1 file changed, 4 insertions(+), 5 deletions(-) diff --git a/internal/appwire/spend_ledger.go b/internal/appwire/spend_ledger.go index 8ceabc8538..75dbb3a3bb 100644 --- a/internal/appwire/spend_ledger.go +++ b/internal/appwire/spend_ledger.go @@ -100,11 +100,10 @@ type SpendLedgerExportParams struct { // SpendLedgerRow is one row at its grain. time is the first call's instant and // end the last's, both with the engine's local offset. type SpendLedgerRow struct { - Grain string `json:"grain" enum:"run,call"` - Key string `json:"key"` - Run string `json:"run,omitempty"` - Time time.Time `json:"time" - "unicode/utf8"` + Grain string `json:"grain" enum:"run,call"` + Key string `json:"key"` + Run string `json:"run,omitempty"` + Time time.Time `json:"time"` End time.Time `json:"end"` Kind string `json:"kind,omitempty" enum:"agent,task,automation"` Subject string `json:"subject,omitempty"` From c1e40d0378f02da6a64ba77474b12dfabc3edb54 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 22:49:32 -0400 Subject: [PATCH 105/459] app: spend.report adds the usage ledger up by local day, model and subject, bounded and unfolded Co-Authored-By: Claude Sonnet 5.5 --- cmd/codeaf/appspendreport.go | 78 +++++ cmd/codeaf/appspendreport_test.go | 111 +++++++ docs/changes/unreleased/0-app-spend-report.md | 22 ++ internal/appwire/spend_report.go | 216 +++++++++++++ internal/appwire/spend_report_test.go | 122 +++++++ internal/session/usage_report.go | 253 +++++++++++++++ internal/session/usage_report_test.go | 301 ++++++++++++++++++ 7 files changed, 1103 insertions(+) create mode 100644 cmd/codeaf/appspendreport.go create mode 100644 cmd/codeaf/appspendreport_test.go create mode 100644 docs/changes/unreleased/0-app-spend-report.md create mode 100644 internal/appwire/spend_report.go create mode 100644 internal/appwire/spend_report_test.go create mode 100644 internal/session/usage_report.go create mode 100644 internal/session/usage_report_test.go diff --git a/cmd/codeaf/appspendreport.go b/cmd/codeaf/appspendreport.go new file mode 100644 index 0000000000..30f88ca5a6 --- /dev/null +++ b/cmd/codeaf/appspendreport.go @@ -0,0 +1,78 @@ +package main + +// ── codeaf app: THE SPENDING REPORT, THE LEDGER ADDED UP BY DAY AND MODEL ─── +// +// spend.report (internal/appwire/spend_report.go) is served from the machine's +// usage ledger by session.QueryUsageReport. Unlike the ledger's rows, a cell +// has no title to join: nothing here asks the app what an agent, task or +// automation is called now, and nothing infers a crew, a provider or an outcome +// — the ledger does not hold them, and the wire says so. This file only turns +// the filter into a query, and the cells into the wire's words. + +import ( + "errors" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/session" +) + +// The app's backend serves spend.report. +var _ appwire.SpendReporter = (*appBackend)(nil) + +// appReportError is a report refusal in the wire's words: a range too heavy to +// report is the caller's to narrow, as every other ledger refusal is. +func appReportError(err error) error { + if errors.Is(err, session.ErrUsageReportTooLarge) { + return appwire.Errorf(appwire.CodeBadParams, "%s", err.Error()) + } + return appLedgerError(err) +} + +// SpendReport answers the ledger added up by stored local day, model and +// subject. +func (b *appBackend) SpendReport(ask appwire.SpendReportAsk) (appwire.SpendReportResult, error) { + query, err := appLedgerQuery(ask.Filter) + if err != nil { + return appwire.SpendReportResult{}, appReportError(err) + } + report, err := session.QueryUsageReport(appLedgerPath(), query, appLedgerZone(), session.UsageReportBudget{ + MaxCells: appwire.SpendReportCellsMax, MaxBytes: session.UsageReportMaxBytes, + }) + if err != nil { + return appwire.SpendReportResult{}, appReportError(err) + } + return appReportResult(ask.Filter, report, session.UsageDrops()), nil +} + +// appReportResult is the report on the wire. A figure no line carried is +// absent rather than zero, and a cell's kind is the wire's word for it. +func appReportResult(f appwire.SpendLedgerFilter, report session.UsageReport, dropped int64) appwire.SpendReportResult { + figure := func(usd float64, priced bool) *float64 { + if !priced { + return nil + } + return &usd + } + t := report.Totals + cells := make([]appwire.SpendReportCell, 0, len(report.Cells)) + for _, c := range report.Cells { + cells = append(cells, appwire.SpendReportCell{ + Day: c.Day, Model: c.Model, Kind: appLedgerKind(c.Kind), Subject: c.Subject, Root: c.Root, + Records: c.Records, Calls: c.Calls, TokensIn: c.Input, TokensOut: c.Output, + CostUSD: figure(c.USD, c.Priced()), UnpricedRecords: c.Unpriced, UnbilledRecords: c.Unbilled, + }) + } + return appwire.SpendReportResult{ + From: f.From, To: f.To, + Totals: appwire.SpendReportTotals{ + Cells: len(cells), Records: t.Records, Calls: t.Calls, TokensIn: t.Input, TokensOut: t.Output, + CostUSD: figure(t.USD, t.Records > t.Unpriced), + RunlessRecords: t.UnknownRunRecords, UnpricedRecords: t.Unpriced, UnbilledRecords: t.Unbilled, + }, + Cells: cells, Models: append([]string{}, report.Models...), + Gaps: appwire.SpendReportGaps{ + DroppedSinceStart: dropped, Malformed: report.Stats.Malformed, Oversized: report.Stats.Oversized, + DaysDerived: report.DaysDerived, + }, + } +} diff --git a/cmd/codeaf/appspendreport_test.go b/cmd/codeaf/appspendreport_test.go new file mode 100644 index 0000000000..ccbd96d24c --- /dev/null +++ b/cmd/codeaf/appspendreport_test.go @@ -0,0 +1,111 @@ +package main + +import ( + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/session" +) + +// The wire and the engine state their caps in two packages; they are one law. +func TestAppSpendReportCapsAreOneLaw(t *testing.T) { + if appwire.SpendReportCellsMax != session.UsageReportMaxCells || appwire.SpendReportDaysMax != session.UsageReportMaxDays || + appwire.SpendReportReplyMax != session.UsageReportMaxBytes { + t.Fatalf("wire (%d cells, %d days, %d bytes) disagrees with the engine (%d, %d, %d)", + appwire.SpendReportCellsMax, appwire.SpendReportDaysMax, appwire.SpendReportReplyMax, + session.UsageReportMaxCells, session.UsageReportMaxDays, session.UsageReportMaxBytes) + } +} + +// THE REPORT AND THE LEDGER ARE THE SAME BILL: the report's totals, and the sum +// of its cells, are what spend.ledger says for the same filter. +func TestAppSpendReportAgreesWithTheLedger(t *testing.T) { + zone := time.FixedZone("EDT", -4*3600) + ledgerAt(t, zone, appLedgerFixture(zone), "") + b := &appBackend{} + for _, filter := range []appwire.SpendLedgerFilter{ + {From: "2026-10-14", To: "2026-10-15"}, + {From: "2026-10-14", To: "2026-10-14", Model: "m/aux"}, + {From: "2026-10-14", To: "2026-10-15", Kind: appwire.SpendKindAgent, Subject: "conv0000000000002"}, + } { + report, err := b.SpendReport(appwire.SpendReportAsk{Filter: filter}) + if err != nil { + t.Fatal(err) + } + page, err := b.SpendLedger(appwire.SpendLedgerAsk{Filter: filter, Limit: 1}) + if err != nil { + t.Fatal(err) + } + lt, rt := page.Totals, report.Totals + if rt.Records != lt.Records || rt.Calls != lt.Calls || rt.TokensIn != lt.TokensIn || rt.TokensOut != lt.TokensOut || + rt.UnpricedRecords != lt.UnpricedRecords || rt.UnbilledRecords != lt.UnbilledRecords || rt.RunlessRecords != lt.UnknownRunRecords || + (rt.CostUSD == nil) != (lt.CostUSD == nil) || (rt.CostUSD != nil && *rt.CostUSD != *lt.CostUSD) { + t.Fatalf("%+v: report totals %+v, ledger totals %+v", filter, rt, lt) + } + var records, calls int + var usd float64 + days := map[string]bool{} + for _, c := range report.Cells { + records += c.Records + calls += c.Calls + days[c.Day] = true + if c.CostUSD != nil { + usd += *c.CostUSD + } + if c.Kind != "" && c.Kind != appwire.SpendKindAgent { + t.Fatalf("kind %q on the wire", c.Kind) + } + } + if records != rt.Records || calls != rt.Calls || len(report.Cells) != rt.Cells || (rt.CostUSD != nil && usd != *rt.CostUSD) { + t.Fatalf("%+v: cells add to %d/%d/%v, totals %+v", filter, records, calls, usd, rt) + } + if report.From != filter.From || report.To != filter.To { + t.Fatalf("range echoed as %s..%s", report.From, report.To) + } + if filter.Model == "" && (!days["2026-10-14"] || !days["2026-10-15"]) { + t.Fatalf("days = %v", days) + } + } +} + +func TestAppSpendReportRefusesInTheWiresWords(t *testing.T) { + zone := time.FixedZone("EDT", -4*3600) + ledgerAt(t, zone, appLedgerFixture(zone), "") + b := &appBackend{} + // A report with no bounded range is a query error, not an all-history scan. + if _, err := b.SpendReport(appwire.SpendReportAsk{Filter: appwire.SpendLedgerFilter{From: "2000-01-01", To: "2026-10-15"}}); wireErrorCode(err) != appwire.CodeBadParams { + t.Fatalf("all history: %v", err) + } + // Too many groups is refused as bad_params, not cut. + var lines []session.UsageLine + base := time.Date(2026, 10, 14, 6, 0, 0, 0, zone) + for i := range appwire.SpendReportCellsMax + 1 { + lines = append(lines, session.UsageLine{At: base, Day: "2026-10-14", Model: "m/" + time.Duration(i).String(), Calls: 1, USD: 1.0 / 256}) + } + ledgerAt(t, zone, lines, "") + _, err := b.SpendReport(appwire.SpendReportAsk{Filter: appwire.SpendLedgerFilter{From: "2026-10-14", To: "2026-10-14"}}) + if wireErrorCode(err) != appwire.CodeBadParams { + t.Fatalf("too many groups: %v", err) + } + // A third reader beside two is busy. + release := fillScanSlots(t) + defer release() + if _, err := b.SpendReport(appwire.SpendReportAsk{Filter: appwire.SpendLedgerFilter{From: "2026-10-14", To: "2026-10-14"}}); wireErrorCode(err) != appwire.CodeBusy { + t.Fatalf("busy: %v", err) + } +} + +func TestAppSpendReportOmitsWhatNoLineCarried(t *testing.T) { + zone := time.FixedZone("EDT", -4*3600) + at := time.Date(2026, 10, 14, 6, 0, 0, 0, zone) + ledgerAt(t, zone, []session.UsageLine{{At: at, Day: "2026-10-14", Calls: 1, Unbilled: true}}, "") + report, err := (&appBackend{}).SpendReport(appwire.SpendReportAsk{Filter: appwire.SpendLedgerFilter{From: "2026-10-14", To: "2026-10-14"}}) + if err != nil || len(report.Cells) != 1 { + t.Fatalf("%+v %v", report, err) + } + c := report.Cells[0] + if c.CostUSD != nil || c.Model != "" || c.Kind != "" || c.Subject != "" || c.UnbilledRecords != 1 || report.Totals.CostUSD != nil || len(report.Models) != 0 { + t.Fatalf("an unpriced, unnamed, unattributed line must stay absent: %+v / %+v", c, report.Totals) + } +} diff --git a/docs/changes/unreleased/0-app-spend-report.md b/docs/changes/unreleased/0-app-spend-report.md new file mode 100644 index 0000000000..0dd2cb184d --- /dev/null +++ b/docs/changes/unreleased/0-app-spend-report.md @@ -0,0 +1,22 @@ +--- +kind: added +title: codeaf app reports spending by local day, model and subject +pr: 0 +surface: [engine] +--- + +Was true: the only ways to see the usage ledger were `spend.export` (raw +calls) and `spend.ledger` (rows by run); a calendar or model breakdown had to +page every row and add them up itself. + +Is true now: `spend.report` takes `spend.ledger`'s filter (both days required, +at most 366 apart) and answers cells — one per stored local day, model and +subject, with a task's conversation — plus exact totals, the distinct models, +and `gaps` (records this process dropped, lines skipped, days derived). The +engine folds nothing. The ledger records no billing provider, task outcome or +crew, so no cell carries one and `not_recorded` says so; an unnamed model has +no model, an unpriced cell no cost. More than 8 192 cells, an estimated 4 MiB, +or a 4 MiB reply is refused as `bad_params`, never cut. It is one linear pass +over the ledger's length at the start, with no cache; the byte figure is an +estimate, not a memory promise. `spend.ledger` is unchanged. Engines without +the capability answer `unknown_method`. diff --git a/internal/appwire/spend_report.go b/internal/appwire/spend_report.go new file mode 100644 index 0000000000..44bc895d7b --- /dev/null +++ b/internal/appwire/spend_report.go @@ -0,0 +1,216 @@ +package appwire + +// ── THE SPENDING REPORT: the ledger added up by day, model and subject ────── +// +// spend.report is the generic aggregate beside spend.ledger: the same filter +// (local calendar days, both required, plus the same exact matches), answered +// not as rows but as CELLS — one per local day, model and subject — with the +// range's totals. A calendar, a model flow and a sparkline are all folds of +// those cells; THE ENGINE FOLDS NOTHING. There is no top three and no "Other" +// here, and a range with more cells than [SpendReportCellsMax] is refused, +// never cut. +// +// UNKNOWN IS ABSENT. The ledger does not record which provider billed a call, +// whether the task it served landed, or which crew owned it, and the engine +// does not join those from what is true today: a crew or an outcome as it is +// now is not what it was when the money was spent. A cell therefore has no such +// field, and not_recorded says so on every answer. A call that named no model +// has no model on its cell — an empty model is "nobody said", not a model +// called Unknown. A cost nobody could price is absent with unpriced_records +// saying so; it is not $0. +// +// THE TOTALS DO NOT CLAIM A COMPLETE BILL. gaps carries what the engine knows +// it is missing: records this process failed to write since it started, ledger +// lines that did not parse or were too long to hold, and lines placed by +// their instant because they carried no valid day. None of them is range +// specific except the last three, and an answer with every gap at zero is not +// proof that none exist — a record lost by an earlier run of the program left +// no mark. +// +// AN ENGINE WITHOUT THE CAPABILITY DOES NOT HAVE THE VERB: spend.report answers +// unknown_method from an engine that is not a [SpendReporter]. + +import ( + "encoding/json" + "time" + "unicode/utf8" +) + +// The most a spend.report may hold or span. The reply is checked against +// SpendReportReplyMax before it is encoded and refused, never cut. +const ( + SpendReportCellsMax = 8192 + SpendReportDaysMax = 366 + SpendReportReplyMax = 4 << 20 +) + +// SpendReportNotRecorded are the dimensions a spend screen may want and the +// ledger does not hold, said on every answer so no client has to guess. +var SpendReportNotRecorded = []string{"provider", "billing_source", "outcome", "crew"} + +// SpendReportParams is spend.report's params: the ledger's filter, with from +// and to required and at most 366 days apart. There is no paging. +type SpendReportParams struct { + SpendLedgerFilter +} + +// SpendReportCell is everything the ledger wrote for one local day, model and +// subject. day is the day the ledger stored on the line. root is present only +// on a task cell, because a task id alone repeats in every conversation. +type SpendReportCell struct { + Day string `json:"day"` + Model string `json:"model,omitempty"` + Kind string `json:"kind,omitempty" enum:"agent,task,automation"` + Subject string `json:"subject,omitempty"` + Root string `json:"root,omitempty"` + Records int `json:"records"` + Calls int `json:"calls"` + TokensIn int64 `json:"tokens_in"` + TokensOut int64 `json:"tokens_out"` + CostUSD *float64 `json:"cost_usd,omitempty"` + UnpricedRecords int `json:"unpriced_records,omitempty"` + UnbilledRecords int `json:"unbilled_records,omitempty"` +} + +// SpendReportTotals is the whole filtered range, exactly. calls counts provider +// requests and records counts ledger lines; they are not the same number. +// runless_records is the lines that carry no run identity. +type SpendReportTotals struct { + Cells int `json:"cells"` + Records int `json:"records"` + Calls int `json:"calls"` + TokensIn int64 `json:"tokens_in"` + TokensOut int64 `json:"tokens_out"` + CostUSD *float64 `json:"cost_usd,omitempty"` + RunlessRecords int `json:"runless_records"` + UnpricedRecords int `json:"unpriced_records"` + UnbilledRecords int `json:"unbilled_records"` +} + +// SpendReportGaps is what the engine knows the totals are missing. +type SpendReportGaps struct { + // DroppedSinceStart is spending records this process failed to write; it is + // not about the range and not about earlier runs of the program. + DroppedSinceStart int64 `json:"dropped_since_start"` + Malformed int `json:"malformed"` + Oversized int `json:"oversized"` + // DaysDerived is lines that carried no valid day and were placed by their + // instant in the engine's zone. + DaysDerived int `json:"days_derived"` +} + +// SpendReportLimits are the bounds the answer was made under. +type SpendReportLimits struct { + MaxCells int `json:"max_cells"` + MaxBytes int64 `json:"max_bytes"` +} + +// SpendReportResult is spend.report's answer. cells are sorted by day, model, +// kind, subject, root. models are the distinct named models in the cells. +type SpendReportResult struct { + From string `json:"from"` + To string `json:"to"` + Totals SpendReportTotals `json:"totals"` + Cells []SpendReportCell `json:"cells"` + Models []string `json:"models"` + Gaps SpendReportGaps `json:"gaps"` + NotRecorded []string `json:"not_recorded"` + Limits SpendReportLimits `json:"limits"` +} + +// SpendReportAsk is spend.report as the engine is handed it: a checked filter. +type SpendReportAsk struct { + Filter SpendLedgerFilter +} + +// SpendReporter is the engine's half of the report. It is optional: an +// [Engine] that is not one refuses spend.report as unknown_method. +type SpendReporter interface { + SpendReport(ask SpendReportAsk) (SpendReportResult, error) +} + +func init() { + methods["spend.report"] = spendReportMethod +} + +var spendReportMethod = shaped[SpendReportParams](func(e Engine, raw json.RawMessage) (*SpendReportResult, error) { + r, ok := e.(SpendReporter) + if !ok { + return nil, Errorf(CodeUnknownMethod, "this engine does not serve %s", "spend.report") + } + var p SpendReportParams + if err := decodeStrict("spend.report", raw, &p); err != nil { + return nil, err + } + filter, err := checkSpendLedgerFilter(p.SpendLedgerFilter) + if err != nil { + return nil, err + } + from, _ := time.Parse("2006-01-02", filter.From) + to, _ := time.Parse("2006-01-02", filter.To) + if int(to.Sub(from).Hours()/24)+1 > SpendReportDaysMax { + return nil, Errorf(CodeBadParams, "spend.report covers at most %d days; ask for a shorter range", SpendReportDaysMax) + } + result, err := r.SpendReport(SpendReportAsk{Filter: filter}) + if err != nil { + return nil, err + } + result.Cells = nonNil(result.Cells) + result.Models = nonNil(result.Models) + result.NotRecorded = SpendReportNotRecorded + result.Limits = SpendReportLimits{MaxCells: SpendReportCellsMax, MaxBytes: SpendReportReplyMax} + if err := checkSpendReportReply(result); err != nil { + return nil, err + } + return &result, nil +}) + +// checkSpendReportReply refuses an answer too large to carry. The strings are +// counted before json.Marshal allocates its buffer, with a fixed allowance per +// cell for field names, numbers and punctuation. +func checkSpendReportReply(result SpendReportResult) error { + tooLarge := Errorf(CodeBadParams, "spend.report: this answer exceeds the %d byte encoding budget; narrow the filter — a model, a subject or a shorter range", SpendReportReplyMax) + if len(result.Cells) > SpendReportCellsMax { + return tooLarge + } + remaining := SpendReportReplyMax - (len(result.Cells)+1)*512 + take := func(s string) bool { + remaining -= 2 + for len(s) > 0 && remaining >= 0 { + r, n := utf8.DecodeRuneInString(s) + cost := n + switch { + case r == '"' || r == '\\' || r == '\b' || r == '\f' || r == '\n' || r == '\r' || r == '\t': + cost = 2 + case r < 0x20 || r == '<' || r == '>' || r == '&' || r == 0x2028 || r == 0x2029 || (r == utf8.RuneError && n == 1): + cost = 6 + } + remaining -= cost + s = s[n:] + } + return remaining >= 0 + } + for _, cell := range result.Cells { + for _, s := range []string{cell.Day, cell.Model, cell.Kind, cell.Subject, cell.Root} { + if !take(s) { + return tooLarge + } + } + } + for _, model := range result.Models { + if !take(model) { + return tooLarge + } + } + if remaining < 0 { + return tooLarge + } + data, err := json.Marshal(result) + if err != nil { + return Errorf(CodeEngine, "spend.report: the answer could not be written as JSON: %v", err) + } + if len(data) > SpendReportReplyMax { + return tooLarge + } + return nil +} diff --git a/internal/appwire/spend_report_test.go b/internal/appwire/spend_report_test.go new file mode 100644 index 0000000000..58a2d8729d --- /dev/null +++ b/internal/appwire/spend_report_test.go @@ -0,0 +1,122 @@ +package appwire + +import ( + "strings" + "sync" + "testing" +) + +// reportEngine serves the report and remembers what it was asked. +type reportEngine struct { + fakeEngine + mu sync.Mutex + asks []SpendReportAsk + result SpendReportResult +} + +func (r *reportEngine) SpendReport(ask SpendReportAsk) (SpendReportResult, error) { + r.mu.Lock() + r.asks = append(r.asks, ask) + r.mu.Unlock() + return r.result, nil +} + +func TestSpendReportIsUnknownMethodWithoutTheCapability(t *testing.T) { + frame := serveOver(t, &fakeEngine{})(`{"id":1,"method":"spend.report","params":{"from":"2026-10-01","to":"2026-10-31"}}`) + if frameCode(frame) != CodeUnknownMethod { + t.Fatalf("%v", frame) + } +} + +func TestSpendReportOnTheWire(t *testing.T) { + usd := 0.25 + engine := &reportEngine{result: SpendReportResult{ + From: "2026-10-01", To: "2026-10-31", + Totals: SpendReportTotals{Cells: 2, Records: 3, Calls: 3}, + Cells: []SpendReportCell{ + {Day: "2026-10-14", Model: "m/a", Records: 2, Calls: 2, CostUSD: &usd}, + {Day: "2026-10-14", Records: 1, Calls: 1, UnpricedRecords: 1}, + }, + }} + frame := serveOver(t, engine)(`{"id":1,"method":"spend.report","params":{"from":" 2026-10-01 ","to":"2026-10-31","kind":"task","subject":"7","root":"abc"}}`) + result, ok := frame["result"].(map[string]any) + if !ok { + t.Fatalf("no result: %v", frame) + } + if len(engine.asks) != 1 || engine.asks[0].Filter.From != "2026-10-01" || engine.asks[0].Filter.Kind != SpendKindTask || engine.asks[0].Filter.Root != "abc" { + t.Fatalf("the engine was asked %+v", engine.asks) + } + cells := result["cells"].([]any) + priced, unpriced := cells[0].(map[string]any), cells[1].(map[string]any) + if priced["cost_usd"] != 0.25 { + t.Fatalf("priced cell %v", priced) + } + for _, absent := range []string{"cost_usd", "model", "kind", "subject", "root"} { + if _, there := unpriced[absent]; there { + t.Fatalf("a cell nobody could fill has %q: %v", absent, unpriced) + } + } + if unpriced["unpriced_records"] != float64(1) { + t.Fatalf("unpriced cell %v", unpriced) + } + // Unknown dimensions are named on every answer, and no cell carries them. + if got := result["not_recorded"].([]any); len(got) != len(SpendReportNotRecorded) { + t.Fatalf("not_recorded %v", got) + } + for _, name := range []string{"provider", "outcome", "crew", "billing_source"} { + if _, there := priced[name]; there { + t.Fatalf("a cell claims %s", name) + } + } + if result["limits"].(map[string]any)["max_cells"] != float64(SpendReportCellsMax) { + t.Fatalf("limits %v", result["limits"]) + } + if models, ok := result["models"].([]any); !ok || len(models) != 0 { + t.Fatalf("an empty list is a list: %v", result["models"]) + } +} + +func TestSpendReportRefusesWhatCannotBeAsked(t *testing.T) { + call := serveOver(t, &reportEngine{}) + for name, params := range map[string]string{ + "no range": `{}`, + "no to": `{"from":"2026-10-01"}`, + "bad day": `{"from":"2026-13-01","to":"2026-10-01"}`, + "reversed": `{"from":"2026-10-02","to":"2026-10-01"}`, + "all history": `{"from":"2000-01-01","to":"2026-10-01"}`, + "367 days": `{"from":"2025-10-01","to":"2026-10-02"}`, + "unknown field": `{"from":"2026-10-01","to":"2026-10-02","crew":"x"}`, + "cursor": `{"from":"2026-10-01","to":"2026-10-02","cursor":"x"}`, + "limit": `{"from":"2026-10-01","to":"2026-10-02","limit":5}`, + "subject no kind": `{"from":"2026-10-01","to":"2026-10-02","subject":"x"}`, + "newline": `{"from":"2026-10-01","to":"2026-10-02","model":"a\nb"}`, + } { + frame := call(`{"id":1,"method":"spend.report","params":` + params + `}`) + if frameCode(frame) != CodeBadParams { + t.Errorf("%s: %v", name, frame) + } + } + // 366 inclusive days is the longest; the engine is reached. + engine := &reportEngine{} + frame := serveOver(t, engine)(`{"id":1,"method":"spend.report","params":{"from":"2025-10-02","to":"2026-10-02"}}`) + if _, ok := frame["result"]; !ok || len(engine.asks) != 1 { + t.Fatalf("366 days: %v", frame) + } +} + +func TestSpendReportReplyIsRefusedNotCut(t *testing.T) { + big := strings.Repeat("m", 300<<10) + var cells []SpendReportCell + for range 20 { + cells = append(cells, SpendReportCell{Day: "2026-10-01", Model: big, Records: 1, Calls: 1}) + } + frame := serveOver(t, &reportEngine{result: SpendReportResult{Cells: cells}})(`{"id":1,"method":"spend.report","params":{"from":"2026-10-01","to":"2026-10-02"}}`) + if frameCode(frame) != CodeBadParams { + t.Fatalf("an answer past the budget: %v", frame) + } + over := make([]SpendReportCell, SpendReportCellsMax+1) + frame = serveOver(t, &reportEngine{result: SpendReportResult{Cells: over}})(`{"id":1,"method":"spend.report","params":{"from":"2026-10-01","to":"2026-10-02"}}`) + if frameCode(frame) != CodeBadParams { + t.Fatalf("an engine that returned more cells than the cap: %v", frame) + } +} diff --git a/internal/session/usage_report.go b/internal/session/usage_report.go new file mode 100644 index 0000000000..484b13f44e --- /dev/null +++ b/internal/session/usage_report.go @@ -0,0 +1,253 @@ +package session + +// ── THE LEDGER, ADDED UP BY DAY AND MODEL: a generic report, never a guess ─── +// +// [QueryUsageLedger] answers rows (runs and calls) and [UsageBySubject] answers +// one grouping; a calendar, a model flow and a sparkline all want the same +// thing a third way: what was spent on each LOCAL DAY, on each MODEL, on each +// KIND OF THING. This file is that one aggregate and nothing more. It is +// GENERIC — it names no screen — and the screen folds, ranks and colours it. +// +// THE CELLS ARE ONLY WHAT THE LEDGER WROTE. A cell is (day, model, kind, +// subject) and, for a task, the conversation it ran in, because a task id alone +// restarts with every conversation and would merge two different tasks. The day +// is the line's STORED [UsageLine.Day] — the day the writer's own clock gave it +// — and only a line with no valid stored day is placed by its instant in the +// query's zone (counted in [UsageReport.DaysDerived]). A call that named no +// model has an empty model, and an empty model is "nobody said", never a named +// "Unknown". The ledger does not record the provider that BILLED a call, the +// outcome of the task it served, or the crew that owned it; nothing here joins +// those from today's membership or today's task states, because a crew or an +// outcome as it is NOW is not what it was when the money was spent. +// +// NOTHING IS FOLDED AND NOTHING IS CUT. There is no "top three" and no "Other" +// here. A range that would need more cells than [UsageReportMaxCells], or more +// estimated bytes than [UsageReportMaxBytes], is REFUSED with +// [ErrUsageReportTooLarge] asking for a narrower filter, rather than answered +// with fewer cells than the range holds. The bound is checked BEFORE a cell is +// inserted and before its key strings are copied, so a ledger line with an +// absurd id costs the check and not the copy. The byte figure is an ESTIMATE of +// the cells' weight used to refuse early; it is not a promise about resident +// memory, and the scan is NOT constant-time: it is one linear pass over the +// ledger's first byte to the end of the prefix it fixed at the start. +// +// ONE PASS, ONE PREFIX, NO CACHE. The file's length is read once, the scan +// reads no further than that (a line appended meanwhile is the next report's), +// and the report is built in a map as it goes. Nothing is remembered between +// calls, so there is nothing to go stale. +// +// THE TOTALS ARE ADDED UP BY THE SAME CHECKED HELPER as the ledger's +// ([UsageLedgerTotals.count]): a negative count or a sum past what a number +// holds refuses with [ErrUsageLedgerFigures]. The cells' figures are parts of +// those totals, so they cannot overflow where the totals did not. + +import ( + "cmp" + "errors" + "fmt" + "slices" + "strings" + "time" +) + +// The most a report may hold. +const ( + // UsageReportMaxCells is the most cells one report may hold. + UsageReportMaxCells = 8192 + // UsageReportMaxBytes is the estimate of the cells' weight past which a + // report is refused. + UsageReportMaxBytes = 4 << 20 + // UsageReportMaxDays is the longest range, in calendar days, a report is + // asked for. A report needs a range: there is no "all history". + UsageReportMaxDays = 366 +) + +// usageReportCellBytes is the estimate of one cell's own weight, before its +// strings: the struct, its map slot and its slice slot. +const usageReportCellBytes = 256 + +// ErrUsageReportTooLarge is a range holding more cells, or heavier ones, than +// one report may. It is refused rather than answered with fewer. +var ErrUsageReportTooLarge = errors.New("this range holds too many day, model and subject groups to report at once; ask for a shorter date range or a narrower filter") + +// UsageReportBudget bounds what one report may hold. A zero field is the +// default; a larger one than the default is not honoured. +type UsageReportBudget struct { + MaxCells int + MaxBytes int64 +} + +func (b UsageReportBudget) bounded() UsageReportBudget { + if b.MaxCells <= 0 || b.MaxCells > UsageReportMaxCells { + b.MaxCells = UsageReportMaxCells + } + if b.MaxBytes <= 0 || b.MaxBytes > UsageReportMaxBytes { + b.MaxBytes = UsageReportMaxBytes + } + return b +} + +// UsageReportCell is everything the ledger wrote for one day, model and +// subject, added up. +type UsageReportCell struct { + // Day is the stored local calendar day, "2006-01-02". + Day string + // Model is the provider's model id, and empty where nobody said. + Model string + // Kind and Subject are [UsageBySubject]'s classification, and both empty + // for a line that belonged to nothing. Root is the conversation a TASK ran + // in, set only on a task cell: a task id means nothing without it. + Kind, Subject, Root string + // Records is ledger lines; Calls is provider requests, counted apart from + // the lines because an older line may cover many. + Records, Calls int + Input, Output int64 + // USD is the sum of the priced lines; Unpriced counts the lines with no + // figure and Unbilled those marked as a missing receipt (also unpriced). + USD float64 + Unpriced, Unbilled int +} + +// Priced reports whether any line in the cell carried a figure; a cell with +// none has no cost, which is not a cost of nothing. +func (c UsageReportCell) Priced() bool { return c.Records > c.Unpriced } + +// UsageReport is one question's answer: the whole range's totals, exactly, and +// every cell that makes them up. +type UsageReport struct { + Totals UsageLedgerTotals + // Cells are sorted by day, model, kind, subject, then root. + Cells []UsageReportCell + // Models are the distinct named models in the cells, sorted. + Models []string + // DaysDerived is lines with no valid stored day, placed by their instant. + DaysDerived int + // Stats is what the reader passed over; Stats.Bytes is the length of the + // ledger prefix the report is of. + Stats UsageScanStats +} + +type usageReportKey struct{ day, model, kind, subject, root string } + +// checkReportRange refuses a query that is not a bounded range. +func (q UsageQuery) checkReportRange() error { + if q.From.IsZero() || q.To.IsZero() { + return fmt.Errorf("%w: a report needs both a from and a to day", ErrUsageQuery) + } + if q.To.After(q.From.AddDate(0, 0, UsageReportMaxDays)) { + return fmt.Errorf("%w: a report covers at most %d days", ErrUsageQuery, UsageReportMaxDays) + } + return nil +} + +// validStoredDay reports whether day is a calendar day as the ledger writes it. +func validStoredDay(day string) bool { + if len(day) != len(usageDayLayout) { + return false + } + _, err := time.Parse(usageDayLayout, day) + return err == nil +} + +// QueryUsageReport adds up every ledger line the query matches into day, model +// and subject cells. The query must name both ends of a range of at most +// [UsageReportMaxDays] days; loc places a line that has no stored day. +func QueryUsageReport(path string, q UsageQuery, loc *time.Location, budget UsageReportBudget) (UsageReport, error) { + q, err := q.normalized() + if err != nil { + return UsageReport{}, err + } + if err := q.checkReportRange(); err != nil { + return UsageReport{}, err + } + if loc == nil { + loc = time.Local + } + budget = budget.bounded() + file, done, err := openUsageScan(path) + if err != nil { + return UsageReport{}, err + } + defer done() + + var report UsageReport + if file == nil { + return report, nil + } + info, err := file.Stat() + if err != nil { + return UsageReport{}, err + } + cells := map[usageReportKey]*UsageReportCell{} + var weight int64 + lastDay, lastDayValid := "", false + report.Stats, err = scanUsageLines(file, info.Size(), func(_ int64, line UsageLine) error { + line.Run = strings.TrimSpace(line.Run) + if !q.matches(line) { + return nil + } + if err := report.Totals.count(line); err != nil { + return err + } + day := line.Day + if day != lastDay { + lastDay, lastDayValid = day, validStoredDay(day) + } + if !lastDayValid { + day = line.At.In(loc).Format(usageDayLayout) + report.DaysDerived++ + } + kind, subject := usageSubject(line) + root := "" + if kind == SubjectTask { + root = strings.TrimSpace(line.Root) + } + key := usageReportKey{day: day, model: strings.TrimSpace(line.Model), kind: kind, subject: subject, root: root} + cell := cells[key] + if cell == nil { + cost := int64(usageReportCellBytes + len(key.day) + len(key.model) + len(key.kind) + len(key.subject) + len(key.root)) + if len(cells) >= budget.MaxCells || weight+cost > budget.MaxBytes { + return fmt.Errorf("%w (more than %d groups or %d estimated bytes)", ErrUsageReportTooLarge, budget.MaxCells, budget.MaxBytes) + } + // Only now are the strings copied, so a line's large id is never + // retained by a map it was refused from. + key = usageReportKey{strings.Clone(key.day), strings.Clone(key.model), key.kind, strings.Clone(key.subject), strings.Clone(key.root)} + cell = &UsageReportCell{Day: key.day, Model: key.model, Kind: key.kind, Subject: key.subject, Root: key.root} + cells[key] = cell + weight += cost + } + cell.Records++ + cell.Calls += max(line.Calls, 1) + cell.Input += int64(line.Input) + cell.Output += int64(line.Output) + if line.USD > 0 { + cell.USD += line.USD + } else { + cell.Unpriced++ + } + if line.Unbilled { + cell.Unbilled++ + } + return nil + }) + if err != nil { + return UsageReport{}, err + } + report.Cells = make([]UsageReportCell, 0, len(cells)) + seen := map[string]struct{}{} + for _, cell := range cells { + report.Cells = append(report.Cells, *cell) + if cell.Model != "" { + if _, ok := seen[cell.Model]; !ok { + seen[cell.Model] = struct{}{} + report.Models = append(report.Models, cell.Model) + } + } + } + slices.SortFunc(report.Cells, func(a, b UsageReportCell) int { + return cmp.Or(strings.Compare(a.Day, b.Day), strings.Compare(a.Model, b.Model), strings.Compare(a.Kind, b.Kind), + strings.Compare(a.Subject, b.Subject), strings.Compare(a.Root, b.Root)) + }) + slices.Sort(report.Models) + return report, nil +} diff --git a/internal/session/usage_report_test.go b/internal/session/usage_report_test.go new file mode 100644 index 0000000000..aba5d04f6a --- /dev/null +++ b/internal/session/usage_report_test.go @@ -0,0 +1,301 @@ +package session + +import ( + "errors" + "fmt" + "math" + "os" + "strings" + "testing" + "time" +) + +var reportZone = time.FixedZone("EDT", -4*3600) + +func reportQuery(t testing.TB, from, to string) UsageQuery { + t.Helper() + start, end, err := UsageDayRange(from, to, reportZone) + if err != nil { + t.Fatal(err) + } + return UsageQuery{From: start, To: end} +} + +func reportLine(day, hour int, model string, usd float64) UsageLine { + at := time.Date(2026, 10, day, hour, 0, 0, 0, reportZone) + return UsageLine{At: at, Day: at.Format(usageDayLayout), Model: model, Calls: 1, Input: 10, Output: 3, USD: usd} +} + +func cellOf(t *testing.T, r UsageReport, day, model, kind, subject, root string) UsageReportCell { + t.Helper() + for _, c := range r.Cells { + if c.Day == day && c.Model == model && c.Kind == kind && c.Subject == subject && c.Root == root { + return c + } + } + t.Fatalf("no cell %s %q %s %q %q in %+v", day, model, kind, subject, root, r.Cells) + return UsageReportCell{} +} + +func TestReportAddsUpByDayModelAndSubject(t *testing.T) { + task1 := reportLine(15, 12, "m/b", cent) + task1.Task, task1.Root = "7", "convA" + task2 := reportLine(15, 12, "m/b", cent) + task2.Task, task2.Root = "7", "convB" // the same task id in another conversation + old := reportLine(15, 13, "m/b", 2*cent) + old.Calls = 41 // an older line covers a whole turn + unbilled := reportLine(15, 14, "m/b", 0) + unbilled.Unbilled = true + auto := reportLine(15, 15, "m/b", cent) + auto.Automation, auto.Run = "auto1", "run1" + standing := reportLine(15, 16, "m/b", cent) + standing.Standing = "auto1" + lines := []UsageLine{ + reportLine(14, 9, "m/a", cent), reportLine(14, 10, "m/a", cent), + reportLine(14, 11, "m/b", 0), // unpriced: not free + reportLine(15, 9, "", 2*cent), // nobody named the model + reportLine(15, 23, "m/a", cent), // late on the 15th + reportLine(16, 1, "m/a", cent), // outside the range + reportLine(13, 23, "m/a", cent), // outside the range + task1, task2, old, unbilled, auto, standing, + } + path := writeLedger(t, lines, []string{"not json", `{"at":"0001-01-01T00:00:00Z"}`}, `{"partial`) + report, err := QueryUsageReport(path, reportQuery(t, "2026-10-14", "2026-10-15"), reportZone, UsageReportBudget{}) + if err != nil { + t.Fatal(err) + } + if report.Stats.Malformed != 2 { + t.Fatalf("malformed = %d, want 2 visible", report.Stats.Malformed) + } + a14 := cellOf(t, report, "2026-10-14", "m/a", "", "", "") + if a14.Records != 2 || a14.Calls != 2 || a14.USD != 2*cent || a14.Input != 20 || a14.Output != 6 { + t.Fatalf("m/a on the 14th = %+v", a14) + } + if b := cellOf(t, report, "2026-10-14", "m/b", "", "", ""); b.Priced() || b.Unpriced != 1 { + t.Fatalf("an unpriced cell must not read as a cost of nothing: %+v", b) + } + if u := cellOf(t, report, "2026-10-15", "", "", "", ""); u.Model != "" || u.USD != 2*cent { + t.Fatalf("unnamed model cell = %+v", u) + } + if c := cellOf(t, report, "2026-10-15", "m/b", SubjectTask, "7", "convA"); c.Records != 1 { + t.Fatalf("task in convA: %+v", c) + } + if c := cellOf(t, report, "2026-10-15", "m/b", SubjectTask, "7", "convB"); c.Records != 1 { + t.Fatalf("task in convB: %+v", c) + } + // Calls and records stay apart: the old line is one record of 41 requests. + c := cellOf(t, report, "2026-10-15", "m/b", "", "", "") + if c.Records != 2 || c.Calls != 42 || c.Unbilled != 1 || c.Unpriced != 1 { + t.Fatalf("legacy and unbilled lines: %+v", c) + } + // An automation, and a standing id read as one, are the same subject. + if c := cellOf(t, report, "2026-10-15", "m/b", SubjectAutomation, "auto1", ""); c.Records != 2 { + t.Fatalf("automation cell = %+v", c) + } + var cells, records, calls int + var usd float64 + for _, c := range report.Cells { + cells++ + records += c.Records + calls += c.Calls + usd += c.USD + } + if records != report.Totals.Records || calls != report.Totals.Calls || usd != report.Totals.USD { + t.Fatalf("cells (%d, %d, %v) disagree with totals %+v", records, calls, usd, report.Totals) + } + if report.Totals.Unpriced != 2 || report.Totals.Unbilled != 1 || report.Totals.UnknownRunRecords != 10 { + t.Fatalf("totals = %+v", report.Totals) + } + if strings.Join(report.Models, ",") != "m/a,m/b" { + t.Fatalf("models = %v", report.Models) + } + for i := 1; i < len(report.Cells); i++ { + p, q := report.Cells[i-1], report.Cells[i] + if p.Day > q.Day || (p.Day == q.Day && p.Model > q.Model) { + t.Fatalf("cells are not sorted at %d: %+v then %+v", i, p, q) + } + } + // The same filters the ledger takes narrow the report. + q := reportQuery(t, "2026-10-14", "2026-10-15") + q.Model = "m/a" + narrow, err := QueryUsageReport(path, q, reportZone, UsageReportBudget{}) + if err != nil || narrow.Totals.Records != 3 || len(narrow.Models) != 1 { + t.Fatalf("model filter: %+v %v", narrow.Totals, err) + } +} + +func TestReportUsesTheStoredDayAndSaysWhenItDidNot(t *testing.T) { + stored := reportLine(14, 9, "m/a", cent) + stored.Day = "2026-10-15" // the writer's zone called it the 15th + blank := reportLine(14, 10, "m/a", cent) + blank.Day = "" + junk := reportLine(14, 11, "m/a", cent) + junk.Day = "yesterday" + path := writeLedger(t, []UsageLine{stored, blank, junk}, nil, "") + report, err := QueryUsageReport(path, reportQuery(t, "2026-10-14", "2026-10-14"), reportZone, UsageReportBudget{}) + if err != nil { + t.Fatal(err) + } + if report.DaysDerived != 2 { + t.Fatalf("days derived = %d", report.DaysDerived) + } + if cellOf(t, report, "2026-10-15", "m/a", "", "", "").Records != 1 || cellOf(t, report, "2026-10-14", "m/a", "", "", "").Records != 2 { + t.Fatalf("cells = %+v", report.Cells) + } +} + +func TestReportNeedsABoundedRange(t *testing.T) { + path := writeLedger(t, nil, nil, "") + day := reportQuery(t, "2026-01-01", "2026-01-01") + cases := map[string]UsageQuery{ + "no bounds": {}, + "no from": {To: day.To}, + "no to": {From: day.From}, + "too long": reportQuery(t, "2025-01-01", "2026-01-02"), + "bad kind": {From: day.From, To: day.To, Kind: "crew"}, + "empty span": {From: day.To, To: day.From}, + } + for name, q := range cases { + if _, err := QueryUsageReport(path, q, reportZone, UsageReportBudget{}); !errors.Is(err, ErrUsageQuery) { + t.Errorf("%s: err = %v, want ErrUsageQuery", name, err) + } + } + // 366 inclusive days is the longest, across a leap year. + if _, err := QueryUsageReport(path, reportQuery(t, "2024-01-01", "2024-12-31"), reportZone, UsageReportBudget{}); err != nil { + t.Fatalf("a 366 day range: %v", err) + } +} + +func TestReportRefusesGroupsPastItsCapInsteadOfTruncating(t *testing.T) { + var lines []UsageLine + for i := range 40 { + lines = append(lines, reportLine(14, 9, fmt.Sprintf("m/%02d", i), cent)) + } + path := writeLedger(t, lines, nil, "") + q := reportQuery(t, "2026-10-14", "2026-10-14") + if r, err := QueryUsageReport(path, q, reportZone, UsageReportBudget{MaxCells: 40}); err != nil || len(r.Cells) != 40 { + t.Fatalf("exactly at the cap must answer whole: %d cells, %v", len(r.Cells), err) + } + r, err := QueryUsageReport(path, q, reportZone, UsageReportBudget{MaxCells: 39}) + if !errors.Is(err, ErrUsageReportTooLarge) || len(r.Cells) != 0 || r.Totals.Records != 0 { + t.Fatalf("one past the cap must refuse and answer nothing: %+v %v", r, err) + } + // A larger budget than the ceiling is not honoured. + if got := (UsageReportBudget{MaxCells: 1 << 30, MaxBytes: 1 << 40}).bounded(); got.MaxCells != UsageReportMaxCells || got.MaxBytes != UsageReportMaxBytes { + t.Fatalf("budget = %+v", got) + } +} + +func TestReportRefusesHugeKeysBeforeCopyingThem(t *testing.T) { + huge := reportLine(14, 9, strings.Repeat("m", 60<<10), cent) + path := writeLedger(t, []UsageLine{huge}, nil, "") + q := reportQuery(t, "2026-10-14", "2026-10-14") + if _, err := QueryUsageReport(path, q, reportZone, UsageReportBudget{MaxBytes: 32 << 10}); !errors.Is(err, ErrUsageReportTooLarge) { + t.Fatalf("a 60 KiB model id under a 32 KiB budget: %v", err) + } + // Under a roomy budget the same line is reported whole, not clipped. + r, err := QueryUsageReport(path, q, reportZone, UsageReportBudget{}) + if err != nil || len(r.Cells) != 1 || len(r.Cells[0].Model) != 60<<10 { + t.Fatalf("a long id must be kept whole or refused: %v", err) + } + // Many heavy groups exhaust the byte budget before the cell cap. + var lines []UsageLine + for i := range 100 { + lines = append(lines, reportLine(14, 9, fmt.Sprintf("%s%03d", strings.Repeat("x", 50<<10), i), cent)) + } + path = writeLedger(t, lines, nil, "") + if _, err := QueryUsageReport(path, q, reportZone, UsageReportBudget{}); !errors.Is(err, ErrUsageReportTooLarge) { + t.Fatalf("100 groups of 50 KiB each: %v", err) + } +} + +func TestReportRefusesFiguresItCannotAdd(t *testing.T) { + q := reportQuery(t, "2026-10-14", "2026-10-14") + negative := reportLine(14, 9, "m/a", cent) + negative.Input = -1 + huge := reportLine(14, 9, "m/a", math.MaxFloat64) + for name, lines := range map[string][]UsageLine{ + "negative tokens": {negative}, + "cost overflow": {huge, huge}, + } { + path := writeLedger(t, lines, nil, "") + if _, err := QueryUsageReport(path, q, reportZone, UsageReportBudget{}); !errors.Is(err, ErrUsageLedgerFigures) { + t.Errorf("%s: %v", name, err) + } + } + // A cost the JSON decoder cannot read is a malformed line, counted, not a number. + path := writeLedger(t, nil, []string{`{"at":"2026-10-14T09:00:00-04:00","day":"2026-10-14","usd":NaN}`, `{"at":"2026-10-14T09:00:00-04:00","day":"2026-10-14","usd":1e999}`}, "") + r, err := QueryUsageReport(path, q, reportZone, UsageReportBudget{}) + if err != nil || r.Stats.Malformed != 2 || len(r.Cells) != 0 { + t.Fatalf("non-finite costs: %+v %v", r.Stats, err) + } +} + +func TestReportReadsOnePrefixAndMissingLedgerIsEmpty(t *testing.T) { + q := reportQuery(t, "2026-10-14", "2026-10-14") + r, err := QueryUsageReport(t.TempDir()+"/absent.jsonl", q, reportZone, UsageReportBudget{}) + if err != nil || len(r.Cells) != 0 || r.Stats.Bytes != 0 { + t.Fatalf("absent ledger: %+v %v", r, err) + } + path := writeLedger(t, []UsageLine{reportLine(14, 9, "m/a", cent)}, nil, "") + first, err := QueryUsageReport(path, q, reportZone, UsageReportBudget{}) + if err != nil { + t.Fatal(err) + } + // What is appended after the report is the next report's. + f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0) + if err != nil { + t.Fatal(err) + } + if _, err := f.WriteString(`{"at":"2026-10-14T10:00:00-04:00","day":"2026-10-14","model":"m/a","usd":0.5}` + "\n"); err != nil { + t.Fatal(err) + } + f.Close() + second, err := QueryUsageReport(path, q, reportZone, UsageReportBudget{}) + if err != nil || first.Totals.Records != 1 || second.Totals.Records != 2 || second.Stats.Bytes <= first.Stats.Bytes { + t.Fatalf("first %+v second %+v %v", first.Totals, second.Totals, err) + } + // It is refused, not queued, when the scan slots are taken. + usageScanSlots <- struct{}{} + usageScanSlots <- struct{}{} + defer func() { <-usageScanSlots; <-usageScanSlots }() + if _, err := QueryUsageReport(path, q, reportZone, UsageReportBudget{}); !errors.Is(err, ErrUsageScanBusy) { + t.Fatalf("busy: %v", err) + } +} + +// reportRows writes n lines spread over 30 days, 12 models and 40 subjects. +func reportRows(tb testing.TB, n int) string { + tb.Helper() + var b strings.Builder + base := time.Date(2026, 10, 1, 0, 0, 0, 0, reportZone) + for i := range n { + at := base.Add(time.Duration(i%(30*24)) * time.Hour).Add(time.Duration(i) * time.Millisecond) + fmt.Fprintf(&b, `{"at":%q,"day":%q,"model":"m/%02d","calls":1,"in":10,"out":3,"usd":0.0009765625,"session":"s%02d","task":"%d","root":"conv%02d"}`+"\n", + at.Format(time.RFC3339Nano), at.Format(usageDayLayout), i%12, i%40, i%40, i%40) + } + path := tb.TempDir() + "/" + UsageLedgerName + if err := os.WriteFile(path, []byte(b.String()), 0o600); err != nil { + tb.Fatal(err) + } + return path +} + +func benchmarkReport(b *testing.B, n int) { + path := reportRows(b, n) + q := reportQuery(b, "2026-10-01", "2026-10-31") + info, _ := os.Stat(path) + b.SetBytes(info.Size()) + b.ReportAllocs() + b.ResetTimer() + for range b.N { + r, err := QueryUsageReport(path, q, reportZone, UsageReportBudget{}) + if err != nil || r.Totals.Records != n { + b.Fatalf("records %d, want %d: %v", r.Totals.Records, n, err) + } + } +} + +// The scan is linear in the ledger; these two sizes show the slope, not a bound. +func BenchmarkQueryUsageReport10kRows(b *testing.B) { benchmarkReport(b, 10_000) } +func BenchmarkQueryUsageReport100kRows(b *testing.B) { benchmarkReport(b, 100_000) } From fa356771c7caf1d32ae836503fe3da3c96fdfd3f Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 22:56:20 -0400 Subject: [PATCH 106/459] Preserve recorded task session identity when a report has no root --- cmd/codeaf/appspendreport.go | 2 +- internal/appwire/spend_report.go | 3 ++- internal/session/usage_report.go | 19 ++++++++++++------- internal/session/usage_report_test.go | 23 +++++++++++++++++++++++ 4 files changed, 38 insertions(+), 9 deletions(-) diff --git a/cmd/codeaf/appspendreport.go b/cmd/codeaf/appspendreport.go index 30f88ca5a6..1c731091fe 100644 --- a/cmd/codeaf/appspendreport.go +++ b/cmd/codeaf/appspendreport.go @@ -57,7 +57,7 @@ func appReportResult(f appwire.SpendLedgerFilter, report session.UsageReport, dr cells := make([]appwire.SpendReportCell, 0, len(report.Cells)) for _, c := range report.Cells { cells = append(cells, appwire.SpendReportCell{ - Day: c.Day, Model: c.Model, Kind: appLedgerKind(c.Kind), Subject: c.Subject, Root: c.Root, + Day: c.Day, Model: c.Model, Kind: appLedgerKind(c.Kind), Subject: c.Subject, Root: c.Root, Session: c.Session, Records: c.Records, Calls: c.Calls, TokensIn: c.Input, TokensOut: c.Output, CostUSD: figure(c.USD, c.Priced()), UnpricedRecords: c.Unpriced, UnbilledRecords: c.Unbilled, }) diff --git a/internal/appwire/spend_report.go b/internal/appwire/spend_report.go index 44bc895d7b..118b2b5fcc 100644 --- a/internal/appwire/spend_report.go +++ b/internal/appwire/spend_report.go @@ -63,6 +63,7 @@ type SpendReportCell struct { Kind string `json:"kind,omitempty" enum:"agent,task,automation"` Subject string `json:"subject,omitempty"` Root string `json:"root,omitempty"` + Session string `json:"session,omitempty"` Records int `json:"records"` Calls int `json:"calls"` TokensIn int64 `json:"tokens_in"` @@ -191,7 +192,7 @@ func checkSpendReportReply(result SpendReportResult) error { return remaining >= 0 } for _, cell := range result.Cells { - for _, s := range []string{cell.Day, cell.Model, cell.Kind, cell.Subject, cell.Root} { + for _, s := range []string{cell.Day, cell.Model, cell.Kind, cell.Subject, cell.Root, cell.Session} { if !take(s) { return tooLarge } diff --git a/internal/session/usage_report.go b/internal/session/usage_report.go index 484b13f44e..4ce8681969 100644 --- a/internal/session/usage_report.go +++ b/internal/session/usage_report.go @@ -98,6 +98,8 @@ type UsageReportCell struct { // for a line that belonged to nothing. Root is the conversation a TASK ran // in, set only on a task cell: a task id means nothing without it. Kind, Subject, Root string + // Session disambiguates task rows whose older writer did not record Root. + Session string // Records is ledger lines; Calls is provider requests, counted apart from // the lines because an older line may cover many. Records, Calls int @@ -127,7 +129,7 @@ type UsageReport struct { Stats UsageScanStats } -type usageReportKey struct{ day, model, kind, subject, root string } +type usageReportKey struct{ day, model, kind, subject, root, session string } // checkReportRange refuses a query that is not a bounded range. func (q UsageQuery) checkReportRange() error { @@ -198,21 +200,24 @@ func QueryUsageReport(path string, q UsageQuery, loc *time.Location, budget Usag report.DaysDerived++ } kind, subject := usageSubject(line) - root := "" + root, session := "", "" if kind == SubjectTask { root = strings.TrimSpace(line.Root) + if root == "" { + session = strings.TrimSpace(line.Session) + } } - key := usageReportKey{day: day, model: strings.TrimSpace(line.Model), kind: kind, subject: subject, root: root} + key := usageReportKey{day: day, model: strings.TrimSpace(line.Model), kind: kind, subject: subject, root: root, session: session} cell := cells[key] if cell == nil { - cost := int64(usageReportCellBytes + len(key.day) + len(key.model) + len(key.kind) + len(key.subject) + len(key.root)) + cost := int64(usageReportCellBytes + len(key.day) + len(key.model) + len(key.kind) + len(key.subject) + len(key.root) + len(key.session)) if len(cells) >= budget.MaxCells || weight+cost > budget.MaxBytes { return fmt.Errorf("%w (more than %d groups or %d estimated bytes)", ErrUsageReportTooLarge, budget.MaxCells, budget.MaxBytes) } // Only now are the strings copied, so a line's large id is never // retained by a map it was refused from. - key = usageReportKey{strings.Clone(key.day), strings.Clone(key.model), key.kind, strings.Clone(key.subject), strings.Clone(key.root)} - cell = &UsageReportCell{Day: key.day, Model: key.model, Kind: key.kind, Subject: key.subject, Root: key.root} + key = usageReportKey{strings.Clone(key.day), strings.Clone(key.model), key.kind, strings.Clone(key.subject), strings.Clone(key.root), strings.Clone(key.session)} + cell = &UsageReportCell{Day: key.day, Model: key.model, Kind: key.kind, Subject: key.subject, Root: key.root, Session: key.session} cells[key] = cell weight += cost } @@ -246,7 +251,7 @@ func QueryUsageReport(path string, q UsageQuery, loc *time.Location, budget Usag } slices.SortFunc(report.Cells, func(a, b UsageReportCell) int { return cmp.Or(strings.Compare(a.Day, b.Day), strings.Compare(a.Model, b.Model), strings.Compare(a.Kind, b.Kind), - strings.Compare(a.Subject, b.Subject), strings.Compare(a.Root, b.Root)) + strings.Compare(a.Subject, b.Subject), strings.Compare(a.Root, b.Root), strings.Compare(a.Session, b.Session)) }) slices.Sort(report.Models) return report, nil diff --git a/internal/session/usage_report_test.go b/internal/session/usage_report_test.go index aba5d04f6a..a2bbd6b038 100644 --- a/internal/session/usage_report_test.go +++ b/internal/session/usage_report_test.go @@ -299,3 +299,26 @@ func benchmarkReport(b *testing.B, n int) { // The scan is linear in the ledger; these two sizes show the slope, not a bound. func BenchmarkQueryUsageReport10kRows(b *testing.B) { benchmarkReport(b, 10_000) } func BenchmarkQueryUsageReport100kRows(b *testing.B) { benchmarkReport(b, 100_000) } + +func TestReportTaskIDsWithoutRootKeepRecordedSessionIdentity(t *testing.T) { + a, b := reportLine(15, 12, "m/a", .01), reportLine(15, 13, "m/a", .02) + a.Task, b.Task = "7", "7" + a.Session, b.Session = "conversation-a", "conversation-b" + path := writeLedger(t, []UsageLine{a, b}, nil, "") + result, err := QueryUsageReport(path, reportQuery(t, "2026-10-15", "2026-10-15"), reportZone, UsageReportBudget{}) + if err != nil { + t.Fatal(err) + } + if len(result.Cells) != 2 { + t.Fatalf("different recorded sessions merged: %+v", result.Cells) + } + if result.Cells[0].Session != a.Session || result.Cells[1].Session != b.Session { + t.Fatalf("missing deterministic session identity: %+v", result.Cells) + } + if result.Cells[0].Root != "" || result.Cells[1].Root != "" { + t.Fatal("invented an absent historical root") + } + if result.Totals.USD != .03 { + t.Fatalf("cost changed: %v", result.Totals.USD) + } +} From a52c00d70cf5673822ff95664f8f81eb0f4fc773 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 22:57:24 -0400 Subject: [PATCH 107/459] Preserve registered handoff teaching within the fixed prefix budget --- internal/session/prompts/system.md | 7 ++++--- 1 file changed, 4 insertions(+), 3 deletions(-) diff --git a/internal/session/prompts/system.md b/internal/session/prompts/system.md index ae464ff226..e49cf4df39 100644 --- a/internal/session/prompts/system.md +++ b/internal/session/prompts/system.md @@ -145,9 +145,10 @@ Say what you started in one line, answer whatever was words, and end there. The test is the critical path, not the size, and a hand-off says its estimate out loud ("several workers, about a dollar") before it spends. -Reassess scale while working. Once you can name unexpected parts, a sweep -whose end you cannot see, or work that will outlast this answer, hand it over -then: do not wait until it is finished or somebody asks about the delay. +THE QUESTION IS ASKED AGAIN WHILE YOU WORK. Material reveals its size inside it: +the moment you can NAME the scale in front of you — unexpected parts, an endless +sweep, or work outlasting this answer — hand it over. Never wait for completion +or a complaint. AND WHAT YOU HAVE ALREADY LEARNED GOES WITH IT. The brief is the dowry: what you found, the shape of the material as you now know it, what you have ruled From 724e72d9112377415e90b5ec2e0b1f9301d0a5f0 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 22:15:03 -0400 Subject: [PATCH 108/459] app: a task's work log carries its real steps, and a note says who left it MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit A native task that ran 62 tests in 22 s over 7 steps drew an empty work log and a footer with no step count. Both ends of that were true at once: the engine had no typed step record for a task at all — `taskLive` kept only the sentences it had composed — and `GetTask` wrapped each of those sentences in `EntryNotice`, so a surface sorting a log the way it sorts a conversation's, by `kind == "tool"`, correctly found nothing in it. Add `AppTaskStep`, the engine's own typed step, projected from whichever of the three genuine records answers for the task: the live ring while it runs, the node's journal after a restart, a handed-over run's trajectory. Every fact on it comes off that record and appears only where it was recorded — no end without a recorded end, no exit code without a reported exit, no outcome for a step still running or one a killed session left unsettled. `tasks.get` dresses each as a tool entry with its call's facts, so a task's log groups, folds and times by exactly the rules a conversation's does. A record that kept a command and no tool payload stays the quiet line it is. The gates here check that a name is mentioned, never that the claim around it is true, and a row dressed as a tool its record never named would be asserting one on no evidence. Two more things were being thrown away on the way out. `appTrajectoryRead` read a run's trajectory for its `command` alone, dropping the tool, clock, exit and output on the same line; it now reads the line whole and composes the sentence back out of the facts. And `appwire.TaskNote` had no origin field while `AppTask`'s plan fallback dropped `PlanTaskNote.Person`, so a worker's handoff and its account of the work arrived unlabelled and were drawn in the person's own bubble as words they never typed. Notes now carry `from`; an absent origin is unknown and not theirs. `task.steps` is sent when a record counted them, including a counted zero, and omitted when nothing did, so "took no steps" and "nobody counted" stop being the same row. The typed record is bounded like the ring it rides, and a durable journal is reshaped only when the file itself moves — a size-and-mtime fingerprint caches the parse so a per-second poll does not re-read a large journal. The live ring is preferred over the journal and the two are never merged: a restarted task's steps are in both. --- cmd/codeaf/appwork.go | 163 ++++- cmd/codeaf/appworksteps_test.go | 257 +++++++ .../0-app-task-steps-and-note-origin.md | 20 + internal/appwire/work.go | 30 +- internal/manual/chat/tasks.md | 31 +- internal/session/task_app.go | 187 ++++- internal/session/task_app_runs.go | 69 +- internal/session/task_app_steps.go | 668 ++++++++++++++++++ internal/session/task_app_steps_test.go | 351 +++++++++ internal/session/task_live.go | 22 +- 10 files changed, 1735 insertions(+), 63 deletions(-) create mode 100644 cmd/codeaf/appworksteps_test.go create mode 100644 docs/changes/unreleased/0-app-task-steps-and-note-origin.md create mode 100644 internal/session/task_app_steps.go create mode 100644 internal/session/task_app_steps_test.go diff --git a/cmd/codeaf/appwork.go b/cmd/codeaf/appwork.go index dd06ed41a3..0128f859af 100644 --- a/cmd/codeaf/appwork.go +++ b/cmd/codeaf/appwork.go @@ -283,7 +283,13 @@ func appTaskOfView(agent string, view session.AppTaskView) appwire.Task { task.Model, task.StartedBy = view.Model, view.Origin task.Instructions = redact.Secrets(strings.TrimSpace(view.Brief)) task.CostUSD = task.SpentUSD - if view.Step > 0 { + // THE STEP COUNT IS SENT WHEN IT IS KNOWN AND OMITTED WHEN IT IS NOT, which + // includes a known zero: a record that counted no steps is a fact, and a task + // with no record of its work is an absence, and they were both arriving as + // the same missing field. A page that cannot tell them apart either says + // "0 steps" about unknown history or says nothing about a task that really + // has taken none. + if view.StepsKnown || view.Step > 0 { steps := view.Step task.Steps = &steps } @@ -616,15 +622,162 @@ func (b *appBackend) GetTask(id string) (appwire.TaskPage, error) { }) page := appwire.TaskPage{Task: task, Result: task.Result, Files: task.Files, Brief: view.Brief} at := b.now() + page.Log = appTaskLog(agent, task.ID, view, at) + for i, note := range view.Notes { + page.Notes = append(page.Notes, appwire.TaskNote{ID: task.ID + ".n" + strconv.Itoa(i), + Text: note.Text, At: at, From: appNoteFrom(note.From), ReadAtStep: note.ReadAtStep}) + } + return page, nil +} + +// appNoteFrom is a note's origin on the wire. The engine's own word is carried +// through; anything it does not label stays unlabelled, because "unknown" and +// "the person said it" are not the same claim (appwire's [appwire.TaskNote]). +func appNoteFrom(from session.AppNoteFrom) string { + switch from { + case session.AppNoteFromPerson: + return appwire.NoteFromYou + case session.AppNoteFromTask: + return appwire.NoteFromTask + } + return "" +} + +// appTaskLog is a task's work as the page's log: ONE TOOL ENTRY PER STEP, +// carrying the facts of its call. +// +// THIS IS WHERE THE PAGE USED TO LOSE EVERY STEP IT HAD. The engine answered +// with worded lines and this loop wrapped each of them in a quiet NOTICE, so an +// app that filters its work log to `kind == "tool"` — which it must, because +// that is the only kind carrying a kind, a clock and an exit code — found none, +// and a task that really ran sixty-two tests over seven steps drew a blank log +// under a footer that said nothing. The engine now answers with facts +// ([session.AppTaskStep]) and they are dressed as the tool entries they are. +// +// A LEGACY STEP IS STILL A NOTICE, and deliberately. A record that kept a +// command and no tool cannot support a claim about which hands were used, so +// such a step keeps its own words on a dim line rather than being classified +// into a kind it never declared. A page shows it; nothing pretends it is proof. +func appTaskLog(agent, id string, view session.AppTaskView, at time.Time) []appwire.Entry { + var log []appwire.Entry + for i, step := range view.Steps { + if step.Legacy { + if line := strings.TrimSpace(step.Text); line != "" { + log = append(log, appwire.Entry{ID: appStepEntryID(id, step, i), Agent: agent, + Kind: appwire.EntryNotice, At: at, Text: redact.Secrets(line), Quiet: true}) + } + continue + } + log = append(log, appTaskStepEntry(agent, id, step, i, at)) + } + if len(log) > 0 { + return log + } + // NO TYPED STEPS IS NOT NO WORK. A task from an engine lane that has not + // filled Steps, or one whose only record is the worded tail, still has lines + // a person can read, and they are said as the lines they are. for i, line := range view.WorkLog { if line = strings.TrimSpace(line); line != "" { - page.Log = append(page.Log, appwire.Entry{ID: task.ID + ".l" + strconv.Itoa(i), Agent: agent, Kind: appwire.EntryNotice, At: at, Text: redact.Secrets(line), Quiet: true}) + log = append(log, appwire.Entry{ID: id + ".l" + strconv.Itoa(i), Agent: agent, + Kind: appwire.EntryNotice, At: at, Text: redact.Secrets(line), Quiet: true}) } } - for i, note := range view.Notes { - page.Notes = append(page.Notes, appwire.TaskNote{ID: task.ID + ".n" + strconv.Itoa(i), Text: note.Text, At: at, ReadAtStep: note.ReadAtStep}) + return log +} + +// appStepEntryID is one step's id on the page, stable across reads of the same +// record: the engine's own stable id for the step, scoped to the task so two +// tasks of one agent cannot collide. +func appStepEntryID(task string, step session.AppTaskStep, at int) string { + if id := strings.TrimSpace(step.ID); id != "" { + return task + ".s" + id } - return page, nil + return task + ".s" + strconv.Itoa(at) +} + +// appTaskStepEntry is one typed step as its tool entry. +// +// EVERY FIELD IS CARRIED OR LEFT OUT; none is defaulted. A duration appears only +// where a clock was recorded, an exit code only where a command reported one, an +// outcome only where the record settled it — so a reader can tell "it passed" +// from "nobody wrote down how it went", which is the whole point of the change. +func appTaskStepEntry(agent, task string, step session.AppTaskStep, at int, now time.Time) appwire.Entry { + entry := appwire.Entry{ + ID: appStepEntryID(task, step, at), + Agent: agent, + Kind: appwire.EntryTool, + At: now, + Call: step.Call, + Name: step.Tool, + Title: redact.Secrets(appStepTitle(step)), + Detail: appDetail(redact.Secrets(step.Detail)), + OK: step.OK, + } + if !step.StartedAt.IsZero() { + entry.At = step.StartedAt + } + entry.ToolFacts = appStepFacts(step) + entry.ToolFacts.Task = task + return entry +} + +// appStepTitle is the one line the row shows for a step: what it was asked to +// do where the record kept that, and the call's own gloss otherwise. +func appStepTitle(step session.AppTaskStep) string { + for _, said := range []string{step.Command, step.Target, step.Text} { + if said = strings.TrimSpace(said); said != "" { + return said + } + } + return step.Tool +} + +// appStepFacts is a step's facts in the work log's own shape. The KIND comes +// from the engine's family for the tool through the one door every other call +// uses ([appToolKind]), so a task's steps and a conversation's steps are sorted +// by exactly the same rule and cannot drift apart. +func appStepFacts(step session.AppTaskStep) appwire.ToolFacts { + facts := appwire.ToolFacts{ + Kind: appToolKind(step.Tool, step.Service), + Batch: step.Batch, + Connector: step.ServiceName, + } + if facts.Kind == appwire.ToolConnector && facts.Connector == "" { + facts.Connector = step.Service + } + if !step.StartedAt.IsZero() { + started := step.StartedAt + facts.StartedAt = &started + } + // AN END IS SAID ONLY WHERE ONE WAS RECORDED. A step whose record kept a + // duration but no instants has a real duration and no real end, and + // manufacturing one from "now" would let a page draw a wall-clock bar for + // work that finished days ago. + if !step.EndedAt.IsZero() { + ended := step.EndedAt + facts.EndedAt = &ended + } + if step.ExitCode != nil { + code := *step.ExitCode + facts.ExitCode = &code + } + if len(step.Tail) > 0 { + facts.Tail = append([]string(nil), step.Tail...) + for i := range facts.Tail { + facts.Tail[i] = redact.Secrets(facts.Tail[i]) + } + } + if step.Lines != nil { + lines := *step.Lines + facts.Lines = &lines + } + if input := appToolInput(facts.Kind, step.Tool, step.Args, step.Target, ""); input != nil { + facts.Input = input + } + if path, adds, dels, ok := tui3.ToolFileChange(step.Tool, step.Args); ok { + facts.Files = []appwire.FileChange{{Path: path, Add: adds, Del: dels}} + } + return facts } // ActOnTask is every verb on a task, through the engine's one task door. diff --git a/cmd/codeaf/appworksteps_test.go b/cmd/codeaf/appworksteps_test.go new file mode 100644 index 0000000000..dc2f19fa44 --- /dev/null +++ b/cmd/codeaf/appworksteps_test.go @@ -0,0 +1,257 @@ +package main + +import ( + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/session" +) + +// A TASK'S WORK REACHES THE PAGE AS TOOL ENTRIES WITH THEIR FACTS. +// +// This is the defect in one test. A real task — a normal native one, seven steps +// and sixty-two tests in twenty-two seconds — reached tasks.get as seven QUIET +// NOTICES, because the page's log wrapped the engine's worded lines in +// [appwire.EntryNotice] whatever they were. An app that reads its work log by +// `kind == "tool"` therefore found nothing: no steps, a blank log, and a footer +// with nothing to count. Every fact needed to group, time and sort those steps +// was in the engine and was being spent on composing a sentence. +func TestATasksStepsReachThePageAsToolEntriesCarryingTheirFacts(t *testing.T) { + started := time.Date(2026, 10, 15, 14, 20, 0, 0, time.UTC) + exit := 0 + ran := session.AppTaskStep{ + ID: "c1", Call: "c1", Tool: "bash", Category: session.ActionRun, + Command: "go test ./internal/reconciler/...", Args: `{"command":"go test ./internal/reconciler/..."}`, + Detail: "ok internal/reconciler 22.004s\n62 tests passed", Tail: []string{"62 tests passed"}, + StartedAt: started, EndedAt: started.Add(22 * time.Second), Took: 22 * time.Second, + ExitCode: &exit, OK: boolAt(true), Batch: "c1", Source: session.AppStepLive, + } + read := session.AppTaskStep{ + ID: "c2", Call: "c2", Tool: "read", Category: session.ActionRead, + Target: "read internal/retry/backoff.go", Args: `{"path":"internal/retry/backoff.go"}`, + StartedAt: started.Add(23 * time.Second), OK: boolAt(true), Source: session.AppStepLive, + } + conv := &fakeConversation{appTasks: []session.AppTaskView{{ + ID: "1", Title: "Fix the reconciler", State: "done", Result: "62 tests pass", + Steps: []session.AppTaskStep{ran, read}, Step: 7, StepsKnown: true, + }}} + backend, _, agent := workBackend(t, conv) + + page, err := backend.GetTask(agent.ID + ":1") + if err != nil { + t.Fatal(err) + } + tools := entriesOfKind(page.Log, appwire.EntryTool) + if len(tools) != 2 { + t.Fatalf("the page carries %d tool entries out of %d log rows, want both steps: %+v", + len(tools), len(page.Log), page.Log) + } + if notices := entriesOfKind(page.Log, appwire.EntryNotice); len(notices) != 0 { + t.Fatalf("a typed step was drawn as a quiet notice: %+v", notices) + } + command := tools[0] + if command.ToolFacts.Kind != appwire.ToolRun { + t.Fatalf("the command's kind = %q, want the engine's run family", command.ToolFacts.Kind) + } + if command.Name != "bash" || command.Title != "go test ./internal/reconciler/..." { + t.Fatalf("the command's row = name %q title %q", command.Name, command.Title) + } + if command.Call != "c1" || command.Batch != "c1" { + t.Fatalf("the command lost its call id: %+v", command) + } + if command.StartedAt == nil || !command.StartedAt.Equal(started) || + command.EndedAt == nil || command.EndedAt.Sub(*command.StartedAt) != 22*time.Second { + t.Fatalf("the command's clock = %v..%v, want the recorded 22s", command.StartedAt, command.EndedAt) + } + if command.ExitCode == nil || *command.ExitCode != 0 || command.OK == nil || !*command.OK { + t.Fatalf("the command's outcome = exit %v ok %v", command.ExitCode, command.OK) + } + if command.Input == nil || command.Input.Text != "go test ./internal/reconciler/..." { + t.Fatalf("the command's input = %+v", command.Input) + } + if len(command.Tail) != 1 || command.Tail[0] != "62 tests passed" { + t.Fatalf("the command's tail = %q", command.Tail) + } + if command.ToolFacts.Task != agent.ID+":1" { + t.Fatalf("a step names task %q, want its own task", command.ToolFacts.Task) + } + // A STEP WITH NO RECORDED END CLAIMS NONE. The second step was never + // settled with a clock, and "now" must not be written in where the record + // kept nothing. + if tools[1].ToolFacts.Kind != appwire.ToolRead { + t.Fatalf("the read's kind = %q, want the engine's read family", tools[1].ToolFacts.Kind) + } + if tools[1].EndedAt != nil { + t.Fatalf("a step with no recorded end was given %s", tools[1].EndedAt) + } + if tools[1].ExitCode != nil { + t.Fatalf("a read was given exit code %d; only a command reports one", *tools[1].ExitCode) + } + // AND THE FOOTER COUNTS THE TASK'S OWN STEPS, which is the figure the engine + // recorded and not however many rows survived a filter. + if page.Task.Steps == nil || *page.Task.Steps != 7 { + t.Fatalf("the page's step count = %v, want the seven the engine recorded", page.Task.Steps) + } + // Two reads of the same record give a row the same id, so a polling page + // does not grow a second copy of a step it already has. + again, err := backend.GetTask(agent.ID + ":1") + if err != nil { + t.Fatal(err) + } + if ids(again.Log) != ids(page.Log) { + t.Fatalf("a second read renamed the steps: %s then %s", ids(page.Log), ids(again.Log)) + } +} + +// A STEP WHOSE RECORD HAS NO TOOL PAYLOAD IS STILL A LINE, drawn as the line it +// is and never classified into a kind it never declared. The gates check that a +// kind is present; nothing checks that a claim around it is true, so a legacy +// command dressed as a tool row would be a page asserting which hands were used +// on no evidence at all. +func TestALegacyStepStaysALineAndIsNeverDressedAsAToolRow(t *testing.T) { + conv := &fakeConversation{appTasks: []session.AppTaskView{{ + ID: "1", Title: "An older run", State: "done", StepsKnown: true, Step: 2, + Steps: []session.AppTaskStep{ + {ID: "trajectory:1", Command: "$ echo one", Text: "$ echo one", Step: 1, + Legacy: true, Source: session.AppStepTrajectory}, + {ID: "trajectory:2", Tool: "bash", Category: session.ActionRun, Command: "go vet ./...", + Step: 2, Source: session.AppStepTrajectory}, + }, + }}} + backend, _, agent := workBackend(t, conv) + page, err := backend.GetTask(agent.ID + ":1") + if err != nil { + t.Fatal(err) + } + if len(page.Log) != 2 { + t.Fatalf("the page drew %d rows, want both steps: %+v", len(page.Log), page.Log) + } + if page.Log[0].Kind != appwire.EntryNotice || !page.Log[0].Quiet { + t.Fatalf("a legacy step was dressed as %q", page.Log[0].Kind) + } + if page.Log[0].Kind == appwire.EntryNotice && page.Log[0].ToolFacts.Kind != "" { + t.Fatalf("a legacy line was given the kind %q it never declared", page.Log[0].ToolFacts.Kind) + } + if !strings.Contains(page.Log[0].Text, "echo one") { + t.Fatalf("the legacy line lost its words: %q", page.Log[0].Text) + } + if page.Log[1].Kind != appwire.EntryTool || page.Log[1].ToolFacts.Kind != appwire.ToolRun { + t.Fatalf("the typed step beside it = %+v", page.Log[1]) + } + // NO RECORDED EXIT IS NOT A PASS, and neither step had one. + if page.Log[1].OK != nil || page.Log[1].ExitCode != nil { + t.Fatalf("a step with no recorded outcome was settled: %+v", page.Log[1]) + } +} + +// A TASK WHOSE WORK WAS NEVER COUNTED SAYS NOTHING ABOUT IT, and one that really +// took no steps says zero. They were arriving as the same missing field, so a +// page either claimed "0 steps" about unknown history or stayed silent about a +// task that genuinely has taken none. +func TestAnUncountedTasksStepsAreAbsentAndACountedZeroIsSaid(t *testing.T) { + conv := &fakeConversation{appTasks: []session.AppTaskView{ + {ID: "1", Title: "Unknown history", State: "done"}, + {ID: "2", Title: "Nothing done yet", State: "running", StepsKnown: true, Step: 0}, + }} + backend, _, agent := workBackend(t, conv) + unknown, err := backend.GetTask(agent.ID + ":1") + if err != nil { + t.Fatal(err) + } + if unknown.Task.Steps != nil { + t.Fatalf("a task with no record of its work claims %d steps", *unknown.Task.Steps) + } + counted, err := backend.GetTask(agent.ID + ":2") + if err != nil { + t.Fatal(err) + } + if counted.Task.Steps == nil || *counted.Task.Steps != 0 { + t.Fatalf("a task counted at no steps answers %v, want a said zero", counted.Task.Steps) + } +} + +// WHO LEFT A NOTE TRAVELS WITH IT. Every note was reaching the app with no +// origin at all, so a surface defaulting to "yours" put a worker's own handoff — +// and the account of the work it handed back — into the person's bubble as words +// they never typed. +func TestATaskNoteCarriesWhoSaidItAndAnUnlabelledOneClaimsNobody(t *testing.T) { + conv := &fakeConversation{appTasks: []session.AppTaskView{{ + ID: "1", Title: "Fix the reconciler", State: "done", Result: "62 tests pass", + Notes: []session.AppTaskNote{ + {Text: "keep it small", From: session.AppNoteFromPerson}, + {Text: "Both runs passed: 62 tests, exit 0.", From: session.AppNoteFromTask}, + {Text: "a line from a record that never said"}, + }, + }}} + backend, _, agent := workBackend(t, conv) + page, err := backend.GetTask(agent.ID + ":1") + if err != nil { + t.Fatal(err) + } + if len(page.Notes) != 3 { + t.Fatalf("the page carries %d notes, want all three: %+v", len(page.Notes), page.Notes) + } + if page.Notes[0].From != appwire.NoteFromYou { + t.Fatalf("the person's own note reads from %q", page.Notes[0].From) + } + if page.Notes[1].From != appwire.NoteFromTask { + t.Fatalf("the work's own line reads from %q, want the task", page.Notes[1].From) + } + // AN ABSENT ORIGIN IS UNKNOWN AND NOT YOURS. + if page.Notes[2].From != "" { + t.Fatalf("an unlabelled note was attributed to %q", page.Notes[2].From) + } + // The result is the task's ONE ANSWER and is not repeated as a note. + if page.Result != "62 tests pass" { + t.Fatalf("the page's result = %q", page.Result) + } + for _, note := range page.Notes { + if strings.TrimSpace(note.Text) == page.Result { + t.Fatalf("the answer was said a second time as a note: %+v", note) + } + } +} + +// AN ENGINE LANE THAT HAS NOT FILLED THE TYPED STEPS STILL HAS LINES A PERSON +// CAN READ, and they are said as the lines they are rather than dropped. +func TestATaskWithOnlyWordedLinesStillDrawsThem(t *testing.T) { + conv := &fakeConversation{appTasks: []session.AppTaskView{{ + ID: "1", Title: "An older engine", State: "done", + WorkLog: []string{"read cache.go", "", "edit cache.go"}, + }}} + backend, _, agent := workBackend(t, conv) + page, err := backend.GetTask(agent.ID + ":1") + if err != nil { + t.Fatal(err) + } + if len(page.Log) != 2 { + t.Fatalf("the page drew %d rows, want the two lines with words: %+v", len(page.Log), page.Log) + } + for _, entry := range page.Log { + if entry.Kind != appwire.EntryNotice || !entry.Quiet { + t.Fatalf("a worded line was dressed as %q", entry.Kind) + } + } +} + +func boolAt(v bool) *bool { return &v } + +func entriesOfKind(log []appwire.Entry, kind string) []appwire.Entry { + var out []appwire.Entry + for _, entry := range log { + if entry.Kind == kind { + out = append(out, entry) + } + } + return out +} + +func ids(log []appwire.Entry) string { + out := make([]string, 0, len(log)) + for _, entry := range log { + out = append(out, entry.ID) + } + return strings.Join(out, " ") +} diff --git a/docs/changes/unreleased/0-app-task-steps-and-note-origin.md b/docs/changes/unreleased/0-app-task-steps-and-note-origin.md new file mode 100644 index 0000000000..09cf2668a9 --- /dev/null +++ b/docs/changes/unreleased/0-app-task-steps-and-note-origin.md @@ -0,0 +1,20 @@ +--- +kind: fixed +title: a task's work log carries its real steps, and a note says who left it +pr: 0 +surface: [engine] +invalidates: + - "Was true: `tasks.get` sent a task's work log as worded lines, every one of them a `notice`, because `AppTaskView` only ever carried the sentences `taskLive` composed and `GetTask` wrapped each in `EntryNotice`. A surface that sorts a log by `kind == \"tool\"` — which is how a conversation's log is read — found nothing in a task's, so a task that ran sixty-two tests over seven steps drew an empty work log and a footer that said nothing about it. Is true now: `AppTaskView` carries `Steps []AppTaskStep`, a typed record projected from whichever of the three genuine records answered (the live ring, the node's journal, a handed-over run's trajectory), and `tasks.get` sends one tool `Entry` per step with its call's own facts — tool name, action family, command, start, end, duration, exit code, outcome, output tail. Each fact appears ONLY where it was recorded: no `ended_at` without a recorded end, no `exit_code` without a reported exit, no `ok` for a step still running or one a killed session left unsettled." + - "Was true: a step from an older record that kept a command and nothing else about it was indistinguishable from a step the engine understood, because both arrived as the same notice. Is true now: a record with no tool payload stays a quiet notice carrying its own words and is never given a tool kind its record never declared. Classifying one would be asserting a tool on no evidence." + - "Was true: `appTrajectoryRead` read a handed-over run's trajectory for its `command` alone, dropping the tool, the clock, the exit and the output that the same line already carried. Is true now: `appTrajectorySteps` reads the whole line into a typed step and the worded line is composed back out of those facts, so the sentence and the row cannot disagree." + - "Was true: `appwire.TaskNote` had no origin field, and `AppTask`'s plan-task fallback read `PlanTaskNote.Body` while discarding its `Person` flag. Every note arrived unlabelled, so an app that read the absence as the person's drew a worker's handoff and its account of the work in their own bubble, as words they never typed — the same report appearing twice on the page, once as the answer and once as a thing they had said. Is true now: a note carries `from` (`you` or `task`) from the engine's own record. AN ABSENT ORIGIN IS UNKNOWN AND NOT YOURS: the field is `omitempty`, so a record that never labelled a note serialises exactly as before and a surface must not attribute it." + - "Was true: `task.steps` was sent whenever the view's step counter was above zero, which made a task nobody counted and a task that genuinely took no steps the same row. Is true now: `AppTaskView.StepsKnown` says whether any record kept a count, and `steps` is sent when known INCLUDING A KNOWN ZERO and omitted when not. A footer can then drop the step word rather than claim `0 steps` about history it does not have." +--- + +The typed record is bounded the way the live ring is (the last 200 steps, 12 tail +lines and 2000 characters of text each), and a durable journal or trajectory is +reshaped only when the file itself moves: a size-and-mtime fingerprint caches the +parse across the per-second polls, so opening a task does not re-read a large +journal on every tick. The live ring is preferred over the journal and the two +are never merged, because a restarted task's steps are in both and a merge would +draw them twice. diff --git a/internal/appwire/work.go b/internal/appwire/work.go index 705538e4ef..869cc84b90 100644 --- a/internal/appwire/work.go +++ b/internal/appwire/work.go @@ -57,14 +57,34 @@ type FileChange struct { Del int `json:"del"` } -// TaskNote is one note the person sent a task, and the step it was read at. +// TaskNote is one note on a task's page, who said it, and the step it was read +// at. type TaskNote struct { - ID string `json:"id"` - Text string `json:"text"` - At time.Time `json:"at"` - ReadAtStep *int `json:"read_at_step,omitempty"` + ID string `json:"id"` + Text string `json:"text"` + At time.Time `json:"at"` + // From is WHO SAID IT: [NoteFromYou] or [NoteFromTask], carried from the + // engine's own record of each note's origin and never read back out of its + // words. + // + // AN ABSENT ORIGIN IS UNKNOWN AND NOT YOURS. It is omitempty so a note from + // a record that never labelled one serialises exactly as it did before this + // field existed; an app that reads the absence as the person's would be + // making the claim this field was added to stop. A surface with no origin + // draws the note without attributing it. + From string `json:"from,omitempty" enum:"you,task"` + ReadAtStep *int `json:"read_at_step,omitempty"` } +// Who a task note is from. +const ( + // NoteFromYou is the person at a keyboard: a note typed into the task. + NoteFromYou = "you" + // NoteFromTask is the work's own line, or another agent's coordination with + // it — anything the person did not type. + NoteFromTask = "task" +) + // TaskPage is everything one task's page draws. type TaskPage struct { Task Task `json:"task"` diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index 8dd52b5099..7c5365c1b6 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -5771,11 +5771,32 @@ is two separate objects. A stale expected state or version refuses with ## App task notes, Change the brief, Pause, and your call A note reaches a running task through its existing steering lane. Its -`read_at_step` stays absent until a model request carries those words. An -opened task shows the result, changed file paths, work log, notes and brief. -Change the brief sends new instructions to the same task; the original task -contract remains on its record. Plan tasks use their existing note and brief -writers; they omit a step receipt when the engine has no such fact. +`read_at_step` stays absent until a model request carries those words. Each +note says who left it — you or the task — from the engine's own record, and a +note from a record that never said stays unlabelled rather than being given to +you. An opened task shows the result, changed file paths, work log, notes and +brief. Change the brief sends new instructions to the same task; the original +task contract remains on its record. Plan tasks use their existing note and +brief writers; they omit a step receipt when the engine has no such fact. + +## What a task's work log knows about each step + +The work log is one row per step, carrying that call's own facts: the tool, the +command, when it started, how long it took, its exit code and the tail of its +output. They are read back from whichever record the task has — the live ring +while it runs, its journal after a restart, a handed-over run's trajectory — +and a fact appears only where it was recorded. A step still running has no +duration and no outcome; a step a killed session left unsettled has neither +either; a step whose record kept no exit code is not reported as having passed. + +A step from an older record that kept a command and nothing else about it stays +one plain line with its own words. It is not given a tool name, because nothing +on that record says which tool ran. + +A task says how many steps it took when some record counted them, and says +nothing at all when none did. "Took no steps" and "nobody counted" are +different facts and are kept apart; a surface showing the second as `0 steps` +would be claiming a count that was never made. Pause lets the current request or action finish and holds the next request or action. Resume releases it. Stop keeps the work already written. The per-task diff --git a/internal/session/task_app.go b/internal/session/task_app.go index 501c1359d7..596f85eca2 100644 --- a/internal/session/task_app.go +++ b/internal/session/task_app.go @@ -24,8 +24,19 @@ type AppTaskView struct { Files []string Notes []AppTaskNote WorkLog []string - Elapsed int64 - Version uint64 + // Steps is the task's work AS FACTS, oldest first and bounded + // (task_app_steps.go). WorkLog stays beside it as the worded tail a model + // reads; a surface that groups, folds and times steps reads this, because + // every fact it needs was spent composing those sentences. + Steps []AppTaskStep + // StepsKnown says whether Step is a COUNT or an absence. A task whose record + // never counted its steps and a task that took none put the same zero in + // Step, and a page that read that zero as a count said "0 steps" about work + // it simply had no record of. UNKNOWN IS NOT ZERO, so the two are separated + // here rather than guessed at by whoever draws them. + StepsKnown bool + Elapsed int64 + Version uint64 // RoutingState and Crew are the task's models in brief (taskrouting.go): // the published decision, never a settings read. RoutingState string @@ -33,10 +44,48 @@ type AppTaskView struct { TaskPins map[crewroute.Seat]bool } type AppTaskNote struct { - Direction uint64 `json:"direction,omitempty"` - Text string + Direction uint64 `json:"direction,omitempty"` + Text string + // From is WHO SAID IT, carried from the record's own label and never read + // back out of the words ([directionFrom], plandb's note origins). + // + // IT IS THE DIFFERENCE BETWEEN A PERSON AND A MACHINE. A page draws the + // person's own notes as their messages; before this field existed every note + // reached the app with no origin at all, so a surface defaulting to "yours" + // put a running task's own lines — and the words a checker handed back — in + // the person's mouth. The engine knows which is which at the moment each + // arrives, so it says so. + // + // "" is a note from a record that never labelled one, which a reader takes as + // unknown and must not take as the person's. + From AppNoteFrom ReadAtStep *int } + +// AppNoteFrom is a note's origin, in the engine's own words. +type AppNoteFrom string + +const ( + // AppNoteFromPerson is the person at a keyboard: a steer typed into a task, + // the words sent with a continue. Only these may revise an assignment + // (assignment.go's authority note). + AppNoteFromPerson AppNoteFrom = "person" + // AppNoteFromTask is the task's own line, or another agent's coordination + // with it — anything delivered to or by the work rather than typed by the + // person. + AppNoteFromTask AppNoteFrom = "task" +) + +// appNoteFrom is the one mapping from the assignment's record of who spoke to +// the word a page is given. Anything this build does not recognise reads as the +// task's, which is the reading that claims nothing about a person. +func appNoteFrom(from directionFrom) AppNoteFrom { + if from == directionFromPerson { + return AppNoteFromPerson + } + return AppNoteFromTask +} + type AppQuickTaskView struct { // Grew is why the quick work asked to become a full task, while it waits // for that answer; State is then "grew". @@ -226,6 +275,7 @@ func (a *Agent) appGraphTasks(out []AppTaskView, cap float64, ids map[string]boo journal := node.journal graph.mu.Unlock() view.WorkLog = appTaskWorkLog(view.WorkLog, journal) + appTaskSteps(&view, live, journal) ids[view.ID] = true out = append(out, view) } @@ -247,13 +297,30 @@ func (a *Agent) AppTask(id string) (AppTaskView, bool, error) { row.Result = page.Result } if len(row.Notes) == 0 { + // WHO LEFT EACH NOTE IS ON THE RECORD AND WAS BEING THROWN + // AWAY HERE. A plan task's page carries its worker's own + // handoffs beside the person's questions, each already marked + // ([PlanTaskNote.Person]); dropping the mark left a surface + // with nothing to attribute by, so every default put the + // worker's lines — the handoff, the account of the work — + // into the person's own bubble as words they never typed. for _, note := range page.Notes { - row.Notes = append(row.Notes, AppTaskNote{Text: note.Body}) + from := AppNoteFromTask + if note.Person { + from = AppNoteFromPerson + } + row.Notes = append(row.Notes, AppTaskNote{Text: note.Body, From: from}) } } + if len(row.Steps) == 0 { + appPlanTaskSteps(&row, page) + } if len(row.WorkLog) == 0 { - for _, step := range page.Steps { - row.WorkLog = append(row.WorkLog, step.Command) + row.WorkLog = appTrajectoryLines(row.Steps) + if len(row.WorkLog) == 0 { + for _, step := range page.Steps { + row.WorkLog = append(row.WorkLog, step.Command) + } } } } @@ -566,13 +633,23 @@ func (n *TaskNode) appNotesLocked() []AppTaskNote { known := map[uint64]bool{} for i := range notes { known[notes[i].Direction] = true + // EVERY NOTE THE APP'S OWN DOOR WROTE IS THE PERSON'S, because that door + // is the person's ([Agent.appTaskNote] is reached from a note they typed). + // It is stated here rather than left to whoever draws it, and the + // assignment's own label wins wherever the record kept one. + notes[i].From = AppNoteFromPerson if said, ok := n.assignment.direction(notes[i].Direction); ok { notes[i].ReadAtStep = said.readAtStep + notes[i].From = appNoteFrom(said.from) } } + // Only the person's own directions are added from the assignment, as they + // always were: another agent's coordination with running work is a delivery + // to the worker and not a line in the person's own thread with the task. for _, said := range n.assignment.directions { if said.from == directionFromPerson && !known[said.id] { - notes = append(notes, AppTaskNote{Direction: said.id, Text: said.words, ReadAtStep: said.readAtStep}) + notes = append(notes, AppTaskNote{Direction: said.id, Text: said.words, + From: appNoteFrom(said.from), ReadAtStep: said.readAtStep}) } } return notes @@ -639,6 +716,100 @@ func (a *Agent) appProposalViews(cap float64) []AppTaskView { return out } +// appTaskSteps fills a graph node's work as facts, and says honestly how many +// steps it took. +// +// THE LIVE RING IS PREFERRED AND THE JOURNAL IS THE FLOOR, for the same reason +// [appTaskWorkLog] prefers it: a running node's present is in the recorder and +// nowhere else, and a restored node has no recorder at all. They are not merged. +// A node that has both is a node whose ring already holds this life's calls; a +// merge would draw a restarted task's steps twice, once from each record, with +// no field able to say which copy is which. +// +// THE COUNT COMES FROM WHATEVER RECORD ANSWERED. [taskLive.steps] counts ends +// and is exact while a node runs, but a restored node's recorder has counted +// none, so its zero is an absence and the journal's own step count is the fact. +// A node with neither leaves StepsKnown false, and a page says nothing at all +// rather than a reassuring zero. +func appTaskSteps(view *AppTaskView, live taskLiveState, journal string) { + if len(live.Calls) > 0 || live.Steps > 0 { + view.Steps, view.StepsKnown = live.Calls, true + if live.Steps > 0 { + view.Step = live.Steps + } else { + view.Step = appStepsFinished(live.Calls) + } + return + } + steps := appJournalSteps(journal, appTaskStepsMax) + if len(steps) == 0 { + // A node with no record of its work says nothing about its work. Step is + // left exactly as its notice set it, and StepsKnown stays false unless + // that notice genuinely counted something. + view.StepsKnown = view.Step > 0 + return + } + view.Steps, view.StepsKnown = steps, true + view.Step = len(steps) +} + +// appPlanTaskSteps fills a plan task's work as facts. +// +// IT READS THE TRAJECTORY ITSELF WHERE THE ROW NAMES ONE. [PlanStep] mirrors +// internal/run's step line but was written before the tool name and the recorded +// exit were fields of it, so a page built from the mirror alone cannot say which +// tool ran or how a command came out. The row carries the path of the record, +// and the record has both. +// +// THE MIRROR IS THE FLOOR AND STAYS TRUTHFUL. Where there is no readable +// trajectory the page's own steps are used, and each is marked legacy: it has a +// command, the engine's facts about whether anything was attempted, and no claim +// at all about which hands were used, which is exactly what that record holds. +func appPlanTaskSteps(row *AppTaskView, page PlanTaskPage) { + if path := strings.TrimSpace(page.Row.TrajectoryPath); path != "" { + if steps, _ := appTrajectorySteps(path, appTaskStepsMax); len(steps) > 0 { + row.Steps, row.StepsKnown = steps, true + if row.Step == 0 { + row.Step = len(steps) + } + return + } + } + if len(page.Steps) == 0 { + return + } + for _, step := range page.Steps { + row.Steps = append(row.Steps, AppTaskStep{ + ID: AppStepTrajectory.prefix() + strconv.Itoa(step.Step), + Command: appStepText(step.Command), + Detail: appStepText(step.Observation), + Step: step.Step, + Source: AppStepTrajectory, + HarnessMade: step.NotRun, + Refused: step.Refused, + Legacy: true, + Text: appStepText(step.Command), + }) + } + row.StepsKnown = true + if row.Step == 0 { + row.Step = len(page.Steps) + } +} + +// appStepsFinished is how many of a projection's steps the record has settled. +// It is the figure a page may call a step count; a step still in flight has not +// been taken yet. +func appStepsFinished(steps []AppTaskStep) int { + n := 0 + for _, step := range steps { + if !step.Running { + n++ + } + } + return n +} + // A restored node reads its existing journal when no live work-log tail remains. func appTaskWorkLog(live []string, journal string) []string { if len(live) > 0 || journal == "" { diff --git a/internal/session/task_app_runs.go b/internal/session/task_app_runs.go index e8b7560998..cdcfc80bdb 100644 --- a/internal/session/task_app_runs.go +++ b/internal/session/task_app_runs.go @@ -1,11 +1,8 @@ package session import ( - "bufio" "context" - "encoding/json" "errors" - "os" "path/filepath" "strconv" "strings" @@ -126,19 +123,25 @@ func (a *Agent) appRunDetail(view *AppTaskView, key string) { continue } dir := filepath.Dir(store.Path()) - steps, reads := appTrajectoryRead(filepath.Join(plandb.TaskDir(dir, key), planTrajectoryFile)) - view.WorkLog = steps - if view.Step == 0 { - view.Step = len(steps) + steps, reads := appTrajectorySteps(filepath.Join(plandb.TaskDir(dir, key), planTrajectoryFile), appTaskStepsMax) + view.Steps = steps + view.WorkLog = appTrajectoryLines(steps) + if len(steps) > 0 { + view.StepsKnown = true + if view.Step == 0 { + view.Step = len(steps) + } + } else { + view.StepsKnown = view.Step > 0 } if view.Brief == "" { view.Brief = task.Description } - for _, note := range store.Notes(key, 200) { + for _, note := range store.Notes(key, appTaskStepsMax) { if note.From != plandb.NoteFromPerson { continue } - seen := AppTaskNote{Text: note.Body} + seen := AppTaskNote{Text: note.Body, From: AppNoteFromPerson} if step, ok := reads[note.ID]; ok { seen.ReadAtStep = &step } @@ -148,41 +151,29 @@ func (a *Agent) appRunDetail(view *AppTaskView, key string) { } } -// appTrajectoryRead reads a task's trajectory: the command of every step, in -// order, and for every note the worker was handed, the step it had reached. -// A missing or unreadable file is no steps and no reads. -func appTrajectoryRead(path string) ([]string, map[string]int) { - reads := map[string]int{} - file, err := os.Open(path) - if err != nil { - return nil, reads +// appTrajectoryLines is a run's steps as the worded tail a model reads, kept +// beside the facts so [AppTaskView.WorkLog] still answers what it always did. +// It composes the sentence FROM the facts rather than the other way round, +// which is the direction that loses nothing. +func appTrajectoryLines(steps []AppTaskStep) []string { + if len(steps) == 0 { + return nil } - defer file.Close() - var steps []string - scanner := bufio.NewScanner(file) - scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) - for scanner.Scan() { - var line struct { - Kind string `json:"kind"` - Step int `json:"step"` - Command string `json:"command"` - Notes []string `json:"notes"` + lines := make([]string, 0, len(steps)) + for _, step := range steps { + said := step.Command + if said == "" { + said = step.Target } - if json.Unmarshal(scanner.Bytes(), &line) != nil { - continue + if said == "" { + said = step.Text } - switch line.Kind { - case "step": - steps = append(steps, line.Command) - case "notes": - for _, id := range line.Notes { - if _, known := reads[id]; !known { - reads[id] = len(steps) - } - } + if said = strings.TrimSpace(said); said == "" { + continue } + lines = append(lines, said) } - return steps, reads + return lines } // appRunAction is a person's verb on a run row. diff --git a/internal/session/task_app_steps.go b/internal/session/task_app_steps.go new file mode 100644 index 0000000000..aadb14c9a0 --- /dev/null +++ b/internal/session/task_app_steps.go @@ -0,0 +1,668 @@ +package session + +// ── A TASK'S WORK, AS FACTS ──────────────────────────────────────────────── +// +// A task's page used to be handed its work as [AppTaskView.WorkLog]: a list of +// already-worded lines ("· bash go test ./…", "✗ bash: exit status 1"). Every +// fact a reader needs about a step — which tool ran, what it was asked, how long +// it took, whether it failed and with what code — was spent composing that +// sentence and then unrecoverable, so the one surface that groups and folds +// steps by kind received prose and drew a dim notice per line. A real task that +// ran sixty-two tests over seven steps arrived as seven notices and a page that +// said nothing about any of them (cmd/codeaf's appwork.go). +// +// So the engine answers with the facts and nothing else. This file is the one +// place those facts are gathered, from the three records that genuinely hold +// them, and NOTHING HERE INVENTS ONE. A duration is present only where a clock +// was recorded; an exit code only where a command reported one; a tool kind only +// where the record names the tool. A reader distinguishes "unknown" from "zero" +// because an absent fact is absent here rather than defaulted. +// +// THE THREE RECORDS, and what each of them knows: +// +// - the LIVE ring ([taskLive]), written from the child's own event stream, is +// the only record of a step while it is still running. It knows the tool, the +// arguments, the gloss, the clock, the exit code the result reported, and +// whether a door refused the call. +// - the node JOURNAL, the durable session file a restored node still has, knows +// everything the live ring did except who refused what: the call's id, its +// tool, its arguments, its result and — since the `took` line — its duration. +// - a handed-over run's TRAJECTORY (internal/run's Step), which knows the step +// number, the tool, the command as the model spelled it, the head of what came +// back, the recorded exit code, and whether the step ran at all. +// +// A LINE WITH NO TOOL PAYLOAD STAYS A LINE. The oldest journals and the oldest +// trajectories recorded a command and nothing else, and a step read out of one is +// marked [AppTaskStep.Legacy] and carries its words in Text. It is shown as the +// line it is. Classifying it would mean guessing a kind from a string, which is +// how a page comes to assert a fact its record never held. + +import ( + "bufio" + "encoding/json" + "os" + "sort" + "strconv" + "strings" + "sync" + "time" +) + +const ( + // appTaskStepsMax bounds every projection in this file. It is [taskLiveRing] + // said again on purpose: "the steps of one task" is one idea and must not + // have two budgets, and a page that asked for more would be asking the live + // ring for lines it already dropped. + appTaskStepsMax = taskLiveRing + // appStepTailMax is how many lines of one step's output a step carries. The + // whole output stays where it was recorded; this is the tail a row shows. + appStepTailMax = 12 + // appStepTextMax clips ONE recorded string — a command, a gloss, a legacy + // line. A step whose command is a minified payload must not put a screenful + // into a page on the strength of being one step. + appStepTextMax = 2000 + // appStepFilesKept bounds how many fingerprinted records this process keeps + // a parsed projection for. A task page polls, and re-reading a long journal + // on every poll is the cost this cache exists to avoid. + appStepFilesKept = 64 +) + +// AppStepSource is WHICH RECORD a step was read out of. It is carried rather +// than flattened away because the three records do not know the same things: a +// surface that is told a duration is absent can say nothing about it, but a +// surface that is told WHY it is absent can say whether asking again would help. +type AppStepSource string + +const ( + // AppStepLive is the running node's own event stream. + AppStepLive AppStepSource = "live" + // AppStepJournal is the node's durable session file. + AppStepJournal AppStepSource = "journal" + // AppStepTrajectory is a handed-over run's recorded trajectory. + AppStepTrajectory AppStepSource = "trajectory" +) + +// AppTaskStep is ONE step of a task's work as the record holds it. +// +// Every optional field is a pointer or a zero that means UNKNOWN, never a +// default that means a figure. The engine's own vocabulary is used throughout — +// the registered tool name, the action family from actioncategory.go — and no +// surface's labels appear here: which icon or which group word a kind draws is +// the surface's business, and an engine that spelled them would own a decision it +// cannot see the consequences of. +type AppTaskStep struct { + // ID is this step's identity, stable across reads of the same record so a + // page redrawn mid-poll does not grow a second copy of a row it already has. + // It is the provider's call id where the record kept one, and the record's + // own address for the step otherwise ("journal:4", "trajectory:7"). + ID string + // Call is the provider's id for the call, and "" where the record never had + // one. A live end pairs on it. + Call string + // Tool is the engine's REGISTERED tool name ("bash", "read", "edit"), and "" + // where the record does not name one. A surface reads the kind off this + // through the engine's own family and never off the words of a gloss. + Tool string + // Category is the family of work the engine sorts this tool into + // ([ActionCategoryForTool]). It is [ActionWork] — the zero — for a tool the + // engine has no family for and for every step whose tool is unknown. + Category ActionCategory + // Command is what the step was ASKED to do, as it was spelled: a shell + // command as the model wrote it, a path, a query. Target is the call's gloss + // as the tool cluster rendered it, which is what a row says when there is no + // command to say instead. + Command string + Target string + // Args is the call's arguments exactly as the record holds them — one line, + // capped, parseable whenever the wire arguments were ([Event.Args]). It is + // what lets a reader name the files an edit touched without this file + // guessing at them. "" where the record kept none. + Args string + // Detail is the head of what came back, for display only and never the + // result the model read. + Detail string + // Tail is the last recorded lines of the step's output, oldest first, and + // Lines how many it wrote in all where that was counted. Lines is nil when + // nothing counted them, which is not the same as a step that printed nothing. + Tail []string + Lines *int + // StartedAt and EndedAt are the step's own clock, and they are ZERO WHERE NO + // CLOCK WAS RECORDED. Took is the duration the record kept, which is not + // always EndedAt minus StartedAt: a journal keeps the duration of the call's + // own work without keeping either instant. Zero means unknown. + StartedAt time.Time + EndedAt time.Time + Took time.Duration + // ExitCode is the code the command itself reported, nil where none was. A + // nil here is NOT a zero: a step that was never run and a step that exited + // cleanly are different facts and a page speaks about them differently. + ExitCode *int + // OK is whether the step succeeded, and nil while it is still running or + // where the record never settled it. + OK *bool + // Running is a step this process can still see in flight. It is false for + // every step read out of a durable record, including one a killed session + // left unfinished — that step is unsettled, not running. + Running bool + // Batch is the same on every step the model asked for together, so a surface + // can draw parallel calls as one row. "" where the record kept none. + Batch string + // Service and ServiceName are the account a connector step acted through: + // its id, and the word a person reads. + Service, ServiceName string + // Refused says a DOOR said no to something the step ATTEMPTED, and + // HarnessMade that the harness wrote the answer rather than the world the + // model reached for ([Event.Refused], [Event.HarnessMade]). They are the + // fields and not the words of a failure: a reader must not sort a refusal by + // reading the sentence it was refused with. + Refused, HarnessMade bool + // Step is the step's number within its task where the record counted one + // (a trajectory does), and 0 where none did. + Step int + // Source is which record this came out of. + Source AppStepSource + // Legacy marks a step the record holds with NO TOOL PAYLOAD: a command or a + // line and nothing else. Text is then its words. A legacy step is shown as + // the line it is and is never classified, because its record cannot support a + // claim about which hands were used. + Legacy bool + Text string +} + +// Settled is whether the record says how this step came out. An unsettled step +// is one still running, or one a session's death left with no result. +func (s AppTaskStep) Settled() bool { return s.OK != nil } + +// Failed is whether the record says this step did not succeed. It reads the +// recorded outcome and never the words of a result. +func (s AppTaskStep) Failed() bool { return s.OK != nil && !*s.OK } + +// appStepText clips one recorded string to [appStepTextMax] and trims it. +func appStepText(text string) string { return clip(strings.TrimSpace(text), appStepTextMax) } + +// appStepTail keeps at most [appStepTailMax] lines, newest kept. +func appStepTail(lines []string) []string { + if len(lines) == 0 { + return nil + } + if len(lines) > appStepTailMax { + lines = lines[len(lines)-appStepTailMax:] + } + out := make([]string, 0, len(lines)) + for _, line := range lines { + out = append(out, clip(line, taskLiveLineLimit)) + } + return out +} + +// appStepsFrom is the tail of a projection, bounded, so every reader of this +// file is bounded by construction rather than by remembering to be. +func appStepsFrom(steps []AppTaskStep, limit int) []AppTaskStep { + if limit <= 0 || limit > appTaskStepsMax { + limit = appTaskStepsMax + } + if len(steps) > limit { + steps = steps[len(steps)-limit:] + } + return steps +} + +// ── the live ring ────────────────────────────────────────────────────────── + +// liveStep is one step as the recorder holds it while the node runs. It is the +// typed record beside the ring of worded lines, written from the same events: a +// surface that groups by kind needs the facts, and a model asking "what is it +// doing" needs the sentence, and composing one from the other in either +// direction loses something. +type liveStep struct { + step AppTaskStep + began time.Time +} + +// recordStep folds one tool lifecycle event into the typed ring. It is called +// from [taskLive.record] with l.mu held, so it never takes a lock of its own. +func (l *taskLive) recordStep(event Event, at time.Time) { + switch event.Kind { + case EventToolBegin: + l.openStep(event, at) + case EventToolFinished: + // The call's own work is over and the engine knows how long it took, + // which it says BEFORE the batch around the call ends. + if s := l.findStep(event.CallID, event.Tool); s != nil && event.Took > 0 { + s.step.Took = event.Took + } + case EventToolEnd, EventToolFailed: + l.closeStep(event, at) + } +} + +// openStep starts a step's typed record. A call whose begin was never seen — +// an event stream joined late — is opened by its end instead (see closeStep), +// so a step is recorded once however the recorder came by it. +func (l *taskLive) openStep(event Event, at time.Time) { + if l.stepAt == nil { + l.stepAt = map[string]int{} + } + step := AppTaskStep{ + ID: appLiveStepID(event, len(l.calls)), + Call: event.CallID, + Tool: strings.TrimSpace(event.Tool), + Category: ActionCategoryForTool(event.Tool), + Target: appStepText(event.Hint), + Args: event.Args, + Running: true, + Batch: event.Batch, + Service: event.Service, + ServiceName: event.ServiceName, + StartedAt: at, + Source: AppStepLive, + } + step.Command = appStepCommand(event.Tool, event.Args, event.Hint) + l.calls = append(l.calls, liveStep{step: step, began: at}) + if event.CallID != "" { + l.stepAt[event.CallID] = len(l.calls) - 1 + } + l.trimSteps() +} + +// findStep is the open record for one call: by its provider id where it has +// one, and otherwise the newest unfinished step of the same tool, which is the +// best a stream with no ids can pair on. Callers hold l.mu. +func (l *taskLive) findStep(call, tool string) *liveStep { + if call != "" { + if at, known := l.stepAt[call]; known && at >= 0 && at < len(l.calls) { + return &l.calls[at] + } + } + tool = strings.TrimSpace(tool) + for i := len(l.calls) - 1; i >= 0; i-- { + if l.calls[i].step.Running && (tool == "" || l.calls[i].step.Tool == tool) { + return &l.calls[i] + } + } + return nil +} + +// closeStep settles a step with what its end reported. +func (l *taskLive) closeStep(event Event, at time.Time) { + s := l.findStep(event.CallID, event.Tool) + if s == nil { + // An end with no begin: the step is still a step, and the only thing + // unknown about it is when it started, which stays zero. + l.openStep(event, time.Time{}) + s = l.findStep(event.CallID, event.Tool) + if s == nil { + return + } + s.step.StartedAt = time.Time{} + s.began = time.Time{} + } + ok := event.Kind == EventToolEnd + s.step.OK = &ok + s.step.Running = false + s.step.EndedAt = at + if s.step.Took == 0 && !s.began.IsZero() { + s.step.Took = at.Sub(s.began) + } + if event.Exit != nil { + code := *event.Exit + s.step.ExitCode = &code + } + s.step.Refused, s.step.HarnessMade = event.Refused, event.HarnessMade + if detail := appStepText(event.Output); detail != "" { + s.step.Detail = detail + s.step.Tail = appStepTail(strings.Split(strings.TrimRight(detail, "\n"), "\n")) + } + if hint := appStepText(event.Hint); hint != "" && event.Kind == EventToolFailed { + // A FAILURE'S HINT IS THE FAILURE, not a gloss of the call (loop.go puts + // the result's first line there), so it is kept as the detail rather than + // written over the target the begin recorded. + if s.step.Detail == "" { + s.step.Detail = hint + } + } +} + +// trimSteps drops the oldest typed steps once the ring is full, keeping the +// index of what remains correct. Callers hold l.mu. +func (l *taskLive) trimSteps() { + if len(l.calls) <= appTaskStepsMax { + return + } + // Copied down rather than resliced, for [taskLive.push]'s own reason: a + // slice that only ever moves its start keeps the whole backing array alive + // for the life of the node. + drop := len(l.calls) - appTaskStepsMax + copy(l.calls, l.calls[drop:]) + l.calls = l.calls[:appTaskStepsMax] + for call, at := range l.stepAt { + if at-drop < 0 { + delete(l.stepAt, call) + continue + } + l.stepAt[call] = at - drop + } +} + +// appLiveStepID is a live step's stable identity: the provider's call id where +// there is one, and the ring's own position otherwise. +func appLiveStepID(event Event, at int) string { + if call := strings.TrimSpace(event.CallID); call != "" { + return call + } + return AppStepLive.prefix() + strconv.Itoa(at) +} + +func (s AppStepSource) prefix() string { return string(s) + ":" } + +// liveSteps copies the typed ring out, bounded. Callers hold l.mu. +func (l *taskLive) liveSteps(limit int) []AppTaskStep { + out := make([]AppTaskStep, 0, len(l.calls)) + for _, held := range l.calls { + step := held.step + step.Tail = append([]string(nil), held.step.Tail...) + out = append(out, step) + } + return appStepsFrom(out, limit) +} + +// ── the durable records ──────────────────────────────────────────────────── + +// appStepCommand is what a call was ASKED to do, read out of the arguments the +// record kept: the command field where the call has one, and otherwise nothing. +// +// IT READS ONE NAMED FIELD AND GUESSES AT NOTHING. A gloss is already carried in +// Target, and a reader that wants the path an edit touched has the arguments +// themselves. Inventing a command out of a sentence is the move that puts words +// in a record's mouth. +func appStepCommand(tool, args, hint string) string { + var fields map[string]json.RawMessage + if json.Unmarshal([]byte(args), &fields) != nil { + return "" + } + raw, ok := fields["command"] + if !ok { + return "" + } + var command string + if json.Unmarshal(raw, &command) != nil { + return "" + } + return appStepText(command) +} + +// appJournalSteps is a node's steps as its durable session file holds them. +// +// IT IS THE REASON A REOPENED TASK READS AS ITSELF. A node restored after a +// restart has no live ring at all, and before this its page was handed the +// journal's lines glued together ("read internal/parse/lex.go the file is …") +// with every fact about every call already spent on the gluing. +func appJournalSteps(path string, limit int) []AppTaskStep { + if strings.TrimSpace(path) == "" { + return nil + } + return appStepsFrom(appStepCache.read(path, func() []AppTaskStep { + return appStepsOfRecord(ReadTranscript(path)) + }), limit) +} + +// appStepsOfRecord is the shaping, over a record already read. It is separate so +// a reader holding a record's BYTES — a hosted page's journal tail — shapes it +// the same way, and so the shaping is testable without a file. +func appStepsOfRecord(record Record) []AppTaskStep { + var out []AppTaskStep + for _, items := range [][]DisplayEntry{record.Earlier, record.Entries} { + for _, item := range items { + if item.Role != "tool" { + continue + } + // A TOOL'S RESULT MESSAGE IS NOT A SECOND CALL. The shaping keeps a + // row for the result with no id and no name; the call it answers is + // the entry above it, carrying that result as its output. Taken as a + // step of its own it is a blank the record never named — which is + // exactly the rule cmd/codeaf's own transcript shaping already + // applies, said here so one reading of a record cannot disagree + // with the other about how many steps it holds. + if item.CallID == "" && strings.TrimSpace(item.Tool) == "" { + continue + } + out = append(out, appStepOfEntry(item, len(out))) + } + } + return out +} + +// appStepOfEntry is one journaled call as a step. +func appStepOfEntry(item DisplayEntry, at int) AppTaskStep { + step := AppTaskStep{ + ID: item.CallID, + Call: item.CallID, + Tool: strings.TrimSpace(item.Tool), + Category: ActionCategoryForTool(item.Tool), + Target: appStepText(item.Hint), + Args: item.Args, + Took: item.Took, + Source: AppStepJournal, + } + if step.ID == "" { + step.ID = AppStepJournal.prefix() + strconv.Itoa(at) + } + step.Command = appStepCommand(item.Tool, item.Args, item.Hint) + // A JOURNAL KEEPS A DURATION WITHOUT KEEPING EITHER INSTANT. The `took` line + // is the call's own work from begin to end of its Execute; the message's + // instant is when the ASKING was written. So the start is what the record + // genuinely holds and the end is derived from it ONLY where both halves were + // recorded — never from a duration alone, which would place a step at an + // instant the file never claimed. + if !item.At.IsZero() { + step.StartedAt = item.At + if item.Took > 0 { + step.EndedAt = item.At.Add(item.Took) + } + } + // AN UNANSWERED CALL IS UNSETTLED, NOT FAILED. The assistant message is + // journaled before its batch runs, so a session read while a call was in + // flight — or one killed mid-batch — names the asking and nothing else. + if item.Answered { + ok := true + step.OK = &ok + if detail := appStepText(item.Output); detail != "" { + step.Detail = detail + step.Tail = appStepTail(strings.Split(strings.TrimRight(detail, "\n"), "\n")) + } + } + // A CALL WITH NO TOOL IS A LINE. Nothing in the record says which hands were + // used, so nothing here claims to. + if step.Tool == "" { + step.Legacy = true + step.Text = appStepText(strings.TrimSpace(item.Hint + " " + item.Text)) + } + return step +} + +// appTrajectorySteps is a handed-over run's steps as its trajectory holds them, +// with the step each of the task's notes was handed at. +// +// The run's recorder writes a line per step with the tool, the command as the +// model spelled it, the head of what came back, and the exit the command +// reported (internal/run's Step). Before this, the one field read back was the +// command — so a run's page lost which tool had been used, whether the step ran +// at all, and what it exited with, on every read. +func appTrajectorySteps(path string, limit int) ([]AppTaskStep, map[string]int) { + reads := map[string]int{} + file, err := os.Open(path) + if err != nil { + return nil, reads + } + defer file.Close() + var steps []AppTaskStep + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 0, 64*1024), 4*1024*1024) + for scanner.Scan() { + var line struct { + Kind string `json:"kind"` + Step int `json:"step"` + Tool string `json:"tool"` + Command string `json:"command"` + Observation string `json:"observation"` + ExitCode *int `json:"exit_code"` + NotRun bool `json:"not_run"` + Refused bool `json:"refused"` + Notes []string `json:"notes"` + } + if json.Unmarshal(scanner.Bytes(), &line) != nil { + continue + } + switch line.Kind { + case "step": + steps = append(steps, appTrajectoryStep(line.Step, len(steps), line.Tool, line.Command, + line.Observation, line.ExitCode, line.NotRun, line.Refused)) + case "notes": + for _, id := range line.Notes { + if _, known := reads[id]; !known { + reads[id] = len(steps) + } + } + } + } + return appStepsFrom(steps, limit), reads +} + +// appTrajectoryStep is one recorded trajectory line as a step. +func appTrajectoryStep(number, at int, tool, command, observation string, exit *int, notRun, refused bool) AppTaskStep { + step := AppTaskStep{ + ID: AppStepTrajectory.prefix() + strconv.Itoa(number), + Tool: strings.TrimSpace(tool), + Category: ActionCategoryForTool(tool), + Command: appStepText(command), + Step: number, + Source: AppStepTrajectory, + } + if number == 0 { + step.ID = AppStepTrajectory.prefix() + strconv.Itoa(at) + } + if exit != nil { + code := *exit + step.ExitCode = &code + } + // A STEP THE HARNESS ANSWERED IS NOT A STEP THE WORLD ANSWERED. The recorder + // establishes that at the event, where an attempted action is known; the + // answer's own words are never read back for it. + step.HarnessMade, step.Refused = notRun, refused + switch { + case refused: + // A door refused something the worker tried: that is a settled failure. + failed := false + step.OK = &failed + case notRun: + // Nothing was attempted on the world, so there is no outcome to claim. + case exit != nil: + ok := *exit == 0 + step.OK = &ok + default: + // NO RECORDED EXIT IS NOT A PASS. A record written before exits were + // recorded says the step happened and nothing about how it came out. + } + if detail := appStepText(observation); detail != "" { + step.Detail = detail + step.Tail = appStepTail(strings.Split(strings.TrimRight(detail, "\n"), "\n")) + } + // A trajectory written before the tool field existed holds a command and + // nothing else: it stays the line it is. + if step.Tool == "" { + step.Legacy = true + step.Text = step.Command + } + return step +} + +// ── the fingerprint cache ────────────────────────────────────────────────── +// +// A TASK PAGE POLLS, AND A JOURNAL IS NOT SMALL. Reading and reshaping a whole +// session file once a second for a page that has not changed is the cost that +// turns a correct projection into an unusable one, so a parse is kept against +// the file's own fingerprint — its size and the instant it was last written — +// and reused until the file moves underneath it. +// +// THE FINGERPRINT IS WHAT THE FILESYSTEM SAYS, NOT WHAT WE HOPE. A file that +// cannot be stat-ed has no fingerprint, and is read every time rather than +// served from a parse that may be about a file that no longer exists. + +type appStepFingerprint struct { + size int64 + modTime time.Time +} + +type appStepParse struct { + print appStepFingerprint + steps []AppTaskStep + used time.Time +} + +type appStepStore struct { + mu sync.Mutex + parse map[string]appStepParse + now func() time.Time +} + +var appStepCache = &appStepStore{parse: map[string]appStepParse{}} + +func (s *appStepStore) clock() time.Time { + if s.now != nil { + return s.now() + } + return time.Now() +} + +// read answers the parse of one file, reshaping it only when its fingerprint +// has moved. parse is called with no lock held: it reads a file, and holding a +// mutex across that would make every task page wait on the slowest journal. +func (s *appStepStore) read(path string, parse func() []AppTaskStep) []AppTaskStep { + print, known := appStepPrintOf(path) + if known { + s.mu.Lock() + held, cached := s.parse[path] + if cached && held.print == print { + held.used = s.clock() + s.parse[path] = held + steps := held.steps + s.mu.Unlock() + return steps + } + s.mu.Unlock() + } + steps := parse() + if !known { + return steps + } + s.mu.Lock() + defer s.mu.Unlock() + s.parse[path] = appStepParse{print: print, steps: steps, used: s.clock()} + s.forgetLocked() + return steps +} + +// forgetLocked keeps the store bounded, dropping the least recently used +// parses. Callers hold s.mu. +func (s *appStepStore) forgetLocked() { + if len(s.parse) <= appStepFilesKept { + return + } + paths := make([]string, 0, len(s.parse)) + for path := range s.parse { + paths = append(paths, path) + } + sort.Slice(paths, func(i, j int) bool { return s.parse[paths[i]].used.Before(s.parse[paths[j]].used) }) + for _, path := range paths[:len(s.parse)-appStepFilesKept] { + delete(s.parse, path) + } +} + +// appStepPrintOf is one file's fingerprint, and whether it has one at all. +func appStepPrintOf(path string) (appStepFingerprint, bool) { + info, err := os.Stat(path) + if err != nil || info.IsDir() { + return appStepFingerprint{}, false + } + return appStepFingerprint{size: info.Size(), modTime: info.ModTime()}, true +} diff --git a/internal/session/task_app_steps_test.go b/internal/session/task_app_steps_test.go new file mode 100644 index 0000000000..03dc7bbb79 --- /dev/null +++ b/internal/session/task_app_steps_test.go @@ -0,0 +1,351 @@ +package session + +import ( + "encoding/json" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" +) + +// A RUNNING NODE'S STEPS ARRIVE AS FACTS, NOT AS SENTENCES. This is the whole +// defect: the recorder kept worded lines, so the one surface that groups and +// times steps was handed prose and could say nothing about any of them. The +// tool, the family it belongs to, the command as it was spelled, the clock and +// the exit code all have to be readable off the step itself. +func TestALiveNodesStepsCarryTheirToolCommandClockAndExit(t *testing.T) { + live := &taskLive{} + started := time.Now() + live.record(Event{Kind: EventToolBegin, Tool: "bash", CallID: "c1", Batch: "c1", + Hint: "bash go test ./internal/reconciler/…", Args: `{"command":"go test ./internal/reconciler/..."}`}) + live.record(Event{Kind: EventToolFinished, Tool: "bash", CallID: "c1", Took: 22 * time.Second}) + exit := 0 + live.record(Event{Kind: EventToolEnd, Tool: "bash", CallID: "c1", Exit: &exit, + Output: "ok \tinternal/reconciler\t22.004s\n62 tests passed\n"}) + + state := live.state(taskLiveMaxTail) + if len(state.Calls) != 1 { + t.Fatalf("the recorder kept %d typed steps, want the one call: %+v", len(state.Calls), state.Calls) + } + step := state.Calls[0] + if step.Tool != "bash" || step.Category != ActionRun { + t.Fatalf("the step's tool and family = %q/%q, want bash as a run", step.Tool, step.Category) + } + if step.Command != "go test ./internal/reconciler/..." { + t.Fatalf("the command = %q, want the one the model spelled", step.Command) + } + if step.Call != "c1" || step.ID != "c1" || step.Batch != "c1" { + t.Fatalf("the step's identity = %+v, want the provider's own call id", step) + } + if step.Took != 22*time.Second { + t.Fatalf("the duration = %s, want the 22s the engine reported", step.Took) + } + if step.OK == nil || !*step.OK || step.Running { + t.Fatalf("a call that ended cleanly reads ok=%v running=%v", step.OK, step.Running) + } + if step.ExitCode == nil || *step.ExitCode != 0 { + t.Fatalf("exit code = %v, want the zero the result reported", step.ExitCode) + } + if step.StartedAt.Before(started) || step.EndedAt.Before(step.StartedAt) { + t.Fatalf("the step's clock = %s..%s, want one that moves forward from %s", step.StartedAt, step.EndedAt, started) + } + if len(step.Tail) == 0 || !strings.Contains(strings.Join(step.Tail, "\n"), "62 tests passed") { + t.Fatalf("the tail = %q, want what the command printed", step.Tail) + } + if step.Source != AppStepLive { + t.Fatalf("the step says it came from %q, want the live ring", step.Source) + } + // AND THE WORDED TAIL IS STILL THERE. A model asking what a task is doing + // reads sentences; both records are written from the same events. + if len(state.Lines) == 0 { + t.Fatal("the worded ring lost its lines when the typed one was added") + } +} + +// A STEP STILL RUNNING ASSERTS NOTHING ABOUT HOW IT WENT, and a refused one is +// a settled failure the record names as refused rather than as a sentence. +func TestARunningStepIsUnsettledAndARefusedOneIsNamed(t *testing.T) { + live := &taskLive{} + live.record(Event{Kind: EventToolBegin, Tool: "read", CallID: "c1", Hint: "read internal/retry/backoff.go"}) + running := live.state(taskLiveMaxTail).Calls + if len(running) != 1 || !running[0].Running || running[0].OK != nil || running[0].Settled() { + t.Fatalf("a running step reads %+v, want no outcome at all", running) + } + if running[0].ExitCode != nil { + t.Fatalf("a running step was given exit code %v; unknown is not zero", *running[0].ExitCode) + } + + live.record(Event{Kind: EventToolFailed, Tool: "bash", CallID: "c2", + Hint: `denied by the person: critical command "rm*"`, HarnessMade: true, Refused: true}) + steps := live.state(taskLiveMaxTail).Calls + if len(steps) != 2 { + t.Fatalf("the recorder kept %d steps, want both calls", len(steps)) + } + refused := steps[1] + if !refused.Refused || !refused.HarnessMade { + t.Fatalf("the refusal lost its marks: %+v", refused) + } + if !refused.Failed() { + t.Fatalf("a refused call reads ok=%v, want a settled failure", refused.OK) + } + if refused.ExitCode != nil { + t.Fatal("a call a door refused was given an exit code it never earned") + } +} + +// THE RING IS BOUNDED AND ITS INDEX STAYS CORRECT. A node that takes thousands +// of steps must not hold them all, and the step that falls off the front must +// not leave its call id pointing at somebody else's row. +func TestTheTypedStepRingIsBoundedAndKeepsItsIndexTrue(t *testing.T) { + live := &taskLive{} + for i := 0; i < appTaskStepsMax+50; i++ { + id := "c" + strconv.Itoa(i) + live.record(Event{Kind: EventToolBegin, Tool: "read", CallID: id, Hint: "read a.go"}) + live.record(Event{Kind: EventToolEnd, Tool: "read", CallID: id, Output: "a"}) + } + steps := live.state(taskLiveMaxTail).Calls + if len(steps) != appTaskStepsMax { + t.Fatalf("the ring holds %d steps, want its bound of %d", len(steps), appTaskStepsMax) + } + if steps[len(steps)-1].ID != "c"+strconv.Itoa(appTaskStepsMax+49) { + t.Fatalf("the newest step is %q, want the last call", steps[len(steps)-1].ID) + } + live.mu.Lock() + for call, at := range live.stepAt { + if at < 0 || at >= len(live.calls) { + t.Fatalf("call %s indexes step %d, outside the %d the ring holds", call, at, len(live.calls)) + } + if live.calls[at].step.Call != call { + t.Fatalf("call %s indexes a row belonging to %s", call, live.calls[at].step.Call) + } + } + live.mu.Unlock() +} + +// A RESTORED NODE READS ITS OWN JOURNAL AS FACTS. This is what a task page has +// after a restart: no recorder at all, and a durable file that has kept the +// call's tool, arguments, result and duration all along. +func TestAJournaledCallComesBackAsAStepAndNotAsAGluedLine(t *testing.T) { + at := time.Now().Truncate(time.Second) + record := Record{Entries: []DisplayEntry{ + {Role: "assistant", Text: "I will run the tests."}, + {Role: "tool", Tool: "bash", CallID: "c1", Hint: "bash go test ./…", At: at, Took: 22 * time.Second, + Args: `{"command":"go test ./..."}`, Output: "62 tests passed\n", Answered: true}, + {Role: "tool", Tool: "read", CallID: "c2", Hint: "read a.go", At: at, Args: `{"path":"a.go"}`}, + }} + steps := appStepsOfRecord(record) + if len(steps) != 2 { + t.Fatalf("the record shaped %d steps, want its two calls", len(steps)) + } + ran := steps[0] + if ran.Tool != "bash" || ran.Command != "go test ./..." || ran.Took != 22*time.Second { + t.Fatalf("the journaled command = %+v, want its tool, command and duration", ran) + } + if ran.OK == nil || !*ran.OK { + t.Fatalf("an answered call reads ok=%v, want the result the file holds", ran.OK) + } + if !ran.StartedAt.Equal(at) || !ran.EndedAt.Equal(at.Add(22*time.Second)) { + t.Fatalf("the journaled clock = %s..%s, want it derived from the instant the file kept", ran.StartedAt, ran.EndedAt) + } + if ran.Source != AppStepJournal { + t.Fatalf("the step says it came from %q, want the journal", ran.Source) + } + // AN UNANSWERED CALL IS UNSETTLED, NOT FAILED, and it claims no end. + unanswered := steps[1] + if unanswered.Settled() { + t.Fatalf("a call with no result in the file reads ok=%v, want nothing", unanswered.OK) + } + if !unanswered.EndedAt.IsZero() { + t.Fatalf("a call with no duration was given the end %s", unanswered.EndedAt) + } +} + +// A CALL WITH NO TOOL IS A LINE AND STAYS ONE. The oldest records kept words and +// nothing else; classifying one would mean guessing which hands were used from a +// sentence, which is how a page comes to assert what its record never held. +// +// AND A RESULT MESSAGE IS NOT A STEP. The shaping keeps a row for a call's +// result with no id and no name; counted as a step it doubles every call a +// record holds. +func TestARecordWithNoToolPayloadStaysTheLineItIs(t *testing.T) { + steps := appStepsOfRecord(Record{Entries: []DisplayEntry{ + {Role: "tool", CallID: "c1", Hint: "ran the suite", Text: "all green", Answered: true}, + {Role: "tool", Text: "all green"}, + }}) + if len(steps) != 1 { + t.Fatalf("the record shaped %d steps, want its one line and not its result row", len(steps)) + } + if !steps[0].Legacy { + t.Fatalf("a call with no tool was classified: %+v", steps[0]) + } + if steps[0].Category != ActionWork || steps[0].Tool != "" { + t.Fatalf("a legacy line was given the tool %q and family %q", steps[0].Tool, steps[0].Category) + } + if !strings.Contains(steps[0].Text, "ran the suite") { + t.Fatalf("the legacy line lost its words: %q", steps[0].Text) + } +} + +// A HANDED-OVER RUN'S TRAJECTORY KEEPS MORE THAN A COMMAND, and the read now +// takes all of it: before this, the one field read back was the command, so a +// run's page lost which tool was used, whether the step ran, and its exit. +func TestARunsTrajectoryIsReadBackWholeAndNeverInvented(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "trajectory.jsonl") + lines := strings.Join([]string{ + `{"kind":"begin","exits_recorded":true}`, + `{"kind":"step","step":1,"tool":"bash","command":"go test ./...","observation":"62 tests passed","exit_code":0}`, + `{"kind":"notes","notes":["n1"]}`, + `{"kind":"step","step":2,"tool":"bash","command":"rm -rf dist","not_run":true,"refused":true}`, + `{"kind":"step","step":3,"tool":"bash","command":"go vet ./..."}`, + `{"kind":"step","step":4,"command":"an older record's command"}`, + `{"kind":"end","steps":4,"result":"done"}`, + }, "\n") + if err := os.WriteFile(path, []byte(lines), 0o600); err != nil { + t.Fatal(err) + } + steps, reads := appTrajectorySteps(path, appTaskStepsMax) + if len(steps) != 4 { + t.Fatalf("the trajectory read %d steps, want its four: %+v", len(steps), steps) + } + if steps[0].Tool != "bash" || steps[0].Command != "go test ./..." || steps[0].Step != 1 { + t.Fatalf("the first step = %+v, want its tool, command and number", steps[0]) + } + if steps[0].ExitCode == nil || *steps[0].ExitCode != 0 || steps[0].OK == nil || !*steps[0].OK { + t.Fatalf("a recorded zero exit read as %+v", steps[0]) + } + if !steps[1].Refused || !steps[1].Failed() { + t.Fatalf("a refused step read as %+v, want a settled failure", steps[1]) + } + // NO RECORDED EXIT IS NOT A PASS. A record written before exits were kept + // says the step happened and nothing about how it came out. + if steps[2].Settled() || steps[2].ExitCode != nil { + t.Fatalf("a step with no recorded exit was settled as %+v", steps[2]) + } + if !steps[3].Legacy || steps[3].Text != "an older record's command" { + t.Fatalf("a trajectory line with no tool was classified: %+v", steps[3]) + } + if reads["n1"] != 1 { + t.Fatalf("the note was read at step %d, want 1", reads["n1"]) + } +} + +// THE PROJECTION IS CACHED AGAINST THE FILE'S OWN FINGERPRINT. A task page +// polls, and reshaping a whole journal every second for a page that has not +// changed is the cost that makes a correct projection unusable. +func TestAJournalIsReshapedOnlyWhenTheFileItselfMoves(t *testing.T) { + path := filepath.Join(t.TempDir(), "journal.jsonl") + if err := os.WriteFile(path, []byte("{}\n"), 0o600); err != nil { + t.Fatal(err) + } + store := &appStepStore{parse: map[string]appStepParse{}} + parses := 0 + read := func() []AppTaskStep { + return store.read(path, func() []AppTaskStep { + parses++ + return []AppTaskStep{{ID: "only"}} + }) + } + for i := 0; i < 5; i++ { + if steps := read(); len(steps) != 1 { + t.Fatalf("the cached read answered %+v", steps) + } + } + if parses != 1 { + t.Fatalf("an unchanged file was reshaped %d times, want once", parses) + } + // The file moves underneath it: the parse is not reused. + if err := os.WriteFile(path, []byte("{}\n{}\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(path, time.Now().Add(time.Second), time.Now().Add(time.Second)); err != nil { + t.Fatal(err) + } + read() + if parses != 2 { + t.Fatalf("a changed file was reshaped %d times, want a second pass", parses) + } + // A FILE WITH NO FINGERPRINT IS NEVER SERVED FROM A PARSE. Nothing may hand + // back a reading about a file it cannot see. + missing := filepath.Join(t.TempDir(), "gone.jsonl") + for i := 0; i < 2; i++ { + store.read(missing, func() []AppTaskStep { parses++; return nil }) + } + if parses != 4 { + t.Fatalf("a file with no fingerprint was cached: %d parses", parses) + } +} + +// THE NODE'S VIEW PREFERS ITS LIVE RING AND FALLS BACK TO ITS JOURNAL, and the +// two are never merged: a restarted task whose steps were counted twice would +// have no field able to say which copy is which. +func TestATasksStepCountSaysKnownOrNothingAndNeverAFalseZero(t *testing.T) { + // A node with no record of its work at all. + var view AppTaskView + appTaskSteps(&view, taskLiveState{}, "") + if view.StepsKnown || view.Step != 0 || len(view.Steps) != 0 { + t.Fatalf("a task with no record claims %+v, want nothing at all", view) + } + + // A running node: the recorder's own count, which is exact. + live := taskLiveState{Steps: 7, Calls: []AppTaskStep{{ID: "c1", Tool: "bash", Source: AppStepLive}}} + view = AppTaskView{} + appTaskSteps(&view, live, "") + if !view.StepsKnown || view.Step != 7 || len(view.Steps) != 1 { + t.Fatalf("a running node reads %+v, want its recorder's seven", view) + } + + // A restored node: no recorder, and a journal that holds the work. + path := filepath.Join(t.TempDir(), "journal.jsonl") + writeStepJournal(t, path) + view = AppTaskView{} + appTaskSteps(&view, taskLiveState{}, path) + if !view.StepsKnown || view.Step != 2 || len(view.Steps) != 2 { + t.Fatalf("a restored node reads %+v, want the two steps its file holds", view) + } + if view.Steps[0].Source != AppStepJournal { + t.Fatalf("a restored node's steps came from %q, want its journal", view.Steps[0].Source) + } +} + +// writeStepJournal writes a two-call session file in the SHAPE THE PRODUCT +// WRITES, through the journal's own line type rather than a JSON string spelled +// out here, so the fixture cannot drift away from the format the reader reads. +// It asserts its own reading, because a fixture the reader finds empty would +// make every test below it pass by saying nothing. +func writeStepJournal(t *testing.T, path string) { + t.Helper() + at := time.Now().UTC().Format(time.RFC3339Nano) + ask := func(call, tool, args string) sessionEntry { + return sessionEntry{Type: "message", Role: "assistant", Timestamp: at, + ToolCalls: []ai.ToolCall{{ID: call, Type: "function", + Function: ai.ToolCallFunction{Name: tool, Arguments: args}}}} + } + answer := func(call, text string) sessionEntry { + return sessionEntry{Type: "message", Role: "tool", ToolCallID: call, Content: text, Timestamp: at} + } + var body strings.Builder + for _, entry := range []sessionEntry{ + ask("c1", "bash", `{"command":"go test ./..."}`), + answer("c1", "62 tests passed"), + ask("c2", "read", `{"path":"a.go"}`), + answer("c2", "package main"), + } { + raw, err := json.Marshal(entry) + if err != nil { + t.Fatal(err) + } + body.Write(raw) + body.WriteByte('\n') + } + if err := os.WriteFile(path, []byte(body.String()), 0o600); err != nil { + t.Fatal(err) + } + if steps := appJournalSteps(path, appTaskStepsMax); len(steps) != 2 { + t.Fatalf("the written journal shaped %d steps, want two — the fixture is wrong, not the reader", len(steps)) + } +} diff --git a/internal/session/task_live.go b/internal/session/task_live.go index a46993f3b6..ffebc67d4c 100644 --- a/internal/session/task_live.go +++ b/internal/session/task_live.go @@ -89,6 +89,13 @@ type taskLive struct { // a sentence is a node saying something. lines []string partial string + // calls is the TYPED record of the same steps the ring words, and stepAt + // indexes it by the provider's call id (task_app_steps.go). It is written + // from the same events, because a model asking "what is it doing" wants the + // sentence and a surface that groups steps by kind wants the facts, and + // neither can be composed from the other without losing something. + calls []liveStep + stepAt map[string]int } // taskLiveState is the recorder copied out, so nothing renders under its lock. @@ -102,6 +109,12 @@ type taskLiveState struct { Steps int // Lines is the tail asked for, oldest first. Lines []string + // Calls is the same work as FACTS, oldest first and bounded the same way + // ([AppTaskStep]). It is what a task page's work log is built from; Lines + // stays the worded tail a model reads. It is named for CALLS and not for + // steps because Steps above is the finished-call COUNT, which is the node's + // own threshold unit and must go on meaning exactly that. + Calls []AppTaskStep } // record folds one of the child's events in. It is the whole write side. @@ -111,6 +124,12 @@ func (l *taskLive) record(event Event) { } l.mu.Lock() defer l.mu.Unlock() + // ONE READING OF THE CLOCK SERVES BOTH RECORDS. The typed record of a step + // (task_app_steps.go) is folded in beside the worded one, and two readings a + // moment apart would let a step's duration and the line that announced it + // disagree about when it happened. + at := time.Now() + l.recordStep(event, at) switch event.Kind { case EventTextDelta: l.write(event.Text) @@ -118,7 +137,7 @@ func (l *taskLive) record(event Event) { // Named, not clocked: see [taskLive.began]. l.call, l.began = taskLiveCall(event), time.Time{} case EventToolBegin: - l.call, l.began = taskLiveCall(event), time.Now() + l.call, l.began = taskLiveCall(event), at l.push("· " + l.call) case EventToolEnd: l.finished() @@ -244,6 +263,7 @@ func (l *taskLive) state(tail int) taskLiveState { } out := taskLiveState{Call: l.call, Since: l.began, Last: l.last, Steps: l.steps} out.Lines = append(out.Lines, lines...) + out.Calls = l.liveSteps(tail) return out } From 6ac876ed6a29e6fbfae14cd545f95c573b2d9472 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 23:00:18 -0400 Subject: [PATCH 109/459] Verify durable run metadata in transcript round trip --- internal/session/sessionfile_test.go | 31 +++++++++++++++++++++++++--- 1 file changed, 28 insertions(+), 3 deletions(-) diff --git a/internal/session/sessionfile_test.go b/internal/session/sessionfile_test.go index af09fdf7d0..93e61fe84c 100644 --- a/internal/session/sessionfile_test.go +++ b/internal/session/sessionfile_test.go @@ -20,11 +20,14 @@ func TestSessionFileRoundTrip(t *testing.T) { directory := t.TempDir() path := filepath.Join(directory, "nested", "session.jsonl") + var callRuns []string writer := &scriptedCompleter{steps: []step{ - func(context.Context, []ai.Message) (*ai.Response, error) { + func(ctx context.Context, _ []ai.Message) (*ai.Response, error) { + callRuns = append(callRuns, runIdentity(ctx)) return toolResponse("call-1", "ls", `{"path":"."}`), nil }, - func(context.Context, []ai.Message) (*ai.Response, error) { + func(ctx context.Context, _ []ai.Message) (*ai.Response, error) { + callRuns = append(callRuns, runIdentity(ctx)) return textResponse("nothing in there yet"), nil }, }} @@ -154,7 +157,29 @@ func TestSessionFileRoundTrip(t *testing.T) { paces++ } } - if got, want := len(lines)-titles-used-called-tooks-paces, 1+len(want)-1; got != want { + // Verify the durable identity joins both foreground calls before counting + // that record as metadata rather than as a conversation message. + runs := 0 + for _, line := range lines { + var row struct{ Type, ID, Timestamp string } + if err := json.Unmarshal([]byte(line), &row); err != nil { + t.Fatal(err) + } + if row.Type != "usage_run" { + continue + } + runs++ + if row.ID == "" || len(callRuns) != 2 || callRuns[0] != row.ID || callRuns[1] != row.ID { + t.Fatalf("durable run %q does not identify both calls: %v", row.ID, callRuns) + } + if _, err := time.Parse(time.RFC3339Nano, row.Timestamp); err != nil { + t.Fatalf("run timestamp: %v", err) + } + } + if runs != 1 { + t.Fatalf("journal has %d execution identity records, want exactly one", runs) + } + if got, want := len(lines)-titles-used-called-tooks-paces-runs, 1+len(want)-1; got != want { t.Fatalf("journal has %d message lines, want %d (header + every message but system)", got, want) } } From e208f425204de81da7d4dcd8eac6b8e1dc184666 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 23:02:35 -0400 Subject: [PATCH 110/459] Make vanished engine fixture close before delivering its last reply --- cmd/codeaf/engine_stale_test.go | 9 ++++++++- 1 file changed, 8 insertions(+), 1 deletion(-) diff --git a/cmd/codeaf/engine_stale_test.go b/cmd/codeaf/engine_stale_test.go index 73d4542e8a..82ec32bb31 100644 --- a/cmd/codeaf/engine_stale_test.go +++ b/cmd/codeaf/engine_stale_test.go @@ -143,9 +143,16 @@ func (h *standInHost) answer(conn net.Conn) { h.retired = true h.mu.Unlock() } + // A vanish fixture has already left before its answer reaches the caller. + // The accepted connection survives closing the listener, so its final reply + // still arrives; a follow-up stand-down cannot race ahead of the disappearance. + vanished := h.opts.vanishAfter > 0 && count >= h.opts.vanishAfter + if vanished { + h.close() + } answer, _ := json.Marshal(remote.Frame{Kind: "whoami", Payload: mustStandInJSON(self)}) _, _ = conn.Write(append(answer, '\n')) - if self.Retiring || (h.opts.vanishAfter > 0 && count >= h.opts.vanishAfter) { + if self.Retiring && !vanished { h.close() } } From 2c1c7a9f583e6fbbde470ff927b025da3e087d82 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 23:08:35 -0400 Subject: [PATCH 111/459] Filter spending by recorded owning agent across chat and tasks --- cmd/codeaf/appledger.go | 2 +- cmd/codeaf/appspendreport.go | 2 +- internal/appwire/spend_ledger.go | 3 ++- internal/appwire/spend_report.go | 5 +++-- internal/session/usage_query.go | 22 +++++++++++++++++++- internal/session/usage_report.go | 13 ++++++------ internal/session/usage_report_test.go | 29 +++++++++++++++++++++++++++ 7 files changed, 64 insertions(+), 12 deletions(-) diff --git a/cmd/codeaf/appledger.go b/cmd/codeaf/appledger.go index fcda792403..a75b8932a8 100644 --- a/cmd/codeaf/appledger.go +++ b/cmd/codeaf/appledger.go @@ -69,7 +69,7 @@ func appLedgerQuery(f appwire.SpendLedgerFilter) (session.UsageQuery, error) { } return session.UsageQuery{ From: from, To: to, Model: f.Model, Kind: kind, Subject: f.Subject, - Session: f.Session, Root: f.Root, Task: f.Task, Automation: f.Automation, Run: f.Run, Workspace: f.Workspace, + Session: f.Session, Root: f.Root, Task: f.Task, Automation: f.Automation, Run: f.Run, Workspace: f.Workspace, Agent: f.Agent, }, nil } diff --git a/cmd/codeaf/appspendreport.go b/cmd/codeaf/appspendreport.go index 1c731091fe..fe45974531 100644 --- a/cmd/codeaf/appspendreport.go +++ b/cmd/codeaf/appspendreport.go @@ -57,7 +57,7 @@ func appReportResult(f appwire.SpendLedgerFilter, report session.UsageReport, dr cells := make([]appwire.SpendReportCell, 0, len(report.Cells)) for _, c := range report.Cells { cells = append(cells, appwire.SpendReportCell{ - Day: c.Day, Model: c.Model, Kind: appLedgerKind(c.Kind), Subject: c.Subject, Root: c.Root, Session: c.Session, + Day: c.Day, Model: c.Model, Kind: appLedgerKind(c.Kind), Subject: c.Subject, Root: c.Root, Session: c.Session, Agent: c.Agent, Records: c.Records, Calls: c.Calls, TokensIn: c.Input, TokensOut: c.Output, CostUSD: figure(c.USD, c.Priced()), UnpricedRecords: c.Unpriced, UnbilledRecords: c.Unbilled, }) diff --git a/internal/appwire/spend_ledger.go b/internal/appwire/spend_ledger.go index 75dbb3a3bb..bb358500ec 100644 --- a/internal/appwire/spend_ledger.go +++ b/internal/appwire/spend_ledger.go @@ -79,6 +79,7 @@ type SpendLedgerFilter struct { Automation string `json:"automation,omitempty"` Run string `json:"run,omitempty"` Workspace string `json:"workspace,omitempty"` + Agent string `json:"agent,omitempty"` } // SpendLedgerParams is spend.ledger's params: the filter, a page size @@ -222,7 +223,7 @@ func decodeStrict(name string, raw json.RawMessage, v any) error { // The days are checked as calendar days here; the engine reads them in its own // zone. func checkSpendLedgerFilter(f SpendLedgerFilter) (SpendLedgerFilter, error) { - for _, field := range []*string{&f.From, &f.To, &f.Model, &f.Kind, &f.Subject, &f.Session, &f.Root, &f.Task, &f.Automation, &f.Run, &f.Workspace} { + for _, field := range []*string{&f.From, &f.To, &f.Model, &f.Kind, &f.Subject, &f.Session, &f.Root, &f.Task, &f.Automation, &f.Run, &f.Workspace, &f.Agent} { *field = strings.TrimSpace(*field) if strings.ContainsAny(*field, "\x00\r\n") { return f, Errorf(CodeBadParams, "a filter is one line") diff --git a/internal/appwire/spend_report.go b/internal/appwire/spend_report.go index 118b2b5fcc..2029dac682 100644 --- a/internal/appwire/spend_report.go +++ b/internal/appwire/spend_report.go @@ -23,7 +23,7 @@ package appwire // it is missing: records this process failed to write since it started, ledger // lines that did not parse or were too long to hold, and lines placed by // their instant because they carried no valid day. None of them is range -// specific except the last three, and an answer with every gap at zero is not +// specific except days_derived, and an answer with every gap at zero is not // proof that none exist — a record lost by an earlier run of the program left // no mark. // @@ -64,6 +64,7 @@ type SpendReportCell struct { Subject string `json:"subject,omitempty"` Root string `json:"root,omitempty"` Session string `json:"session,omitempty"` + Agent string `json:"agent,omitempty"` Records int `json:"records"` Calls int `json:"calls"` TokensIn int64 `json:"tokens_in"` @@ -192,7 +193,7 @@ func checkSpendReportReply(result SpendReportResult) error { return remaining >= 0 } for _, cell := range result.Cells { - for _, s := range []string{cell.Day, cell.Model, cell.Kind, cell.Subject, cell.Root, cell.Session} { + for _, s := range []string{cell.Day, cell.Model, cell.Kind, cell.Subject, cell.Root, cell.Session, cell.Agent} { if !take(s) { return tooLarge } diff --git a/internal/session/usage_query.go b/internal/session/usage_query.go index 83bd4ea020..99be8260b1 100644 --- a/internal/session/usage_query.go +++ b/internal/session/usage_query.go @@ -116,6 +116,8 @@ type UsageQuery struct { Kind, Subject string // The rest match one ledger field each. Session, Root, Task, Automation, Run, Workspace string + // Agent matches the recorded root conversation, or Session when Root is absent. + Agent string } // UsageLedgerBudget bounds what one answer may hold beside its page. @@ -252,7 +254,7 @@ func UsageDayRange(from, to string, loc *time.Location) (time.Time, time.Time, e // normalized trims every filter and checks the query can be asked. func (q UsageQuery) normalized() (UsageQuery, error) { - for _, field := range []*string{&q.Model, &q.Kind, &q.Subject, &q.Session, &q.Root, &q.Task, &q.Automation, &q.Run, &q.Workspace} { + for _, field := range []*string{&q.Model, &q.Kind, &q.Subject, &q.Session, &q.Root, &q.Task, &q.Automation, &q.Run, &q.Workspace, &q.Agent} { *field = strings.TrimSpace(*field) } switch q.Kind { @@ -279,6 +281,12 @@ func (q UsageQuery) fingerprint() string { h.Write([]byte(part)) h.Write([]byte{0}) } + if q.Agent != "" { + h.Write([]byte("agent")) + h.Write([]byte{0}) + h.Write([]byte(q.Agent)) + h.Write([]byte{0}) + } return strconv.FormatUint(h.Sum64(), 36) } @@ -302,6 +310,15 @@ func usageSubject(line UsageLine) (kind, id string) { return "", "" } +// UsageOwner is the recorded conversation responsible for a call. It never +// consults current crew membership or assigns an absent historical identity. +func UsageOwner(line UsageLine) string { + if root := strings.TrimSpace(line.Root); root != "" { + return root + } + return strings.TrimSpace(line.Session) +} + // matches is the one filter every reader of a query applies. func (q UsageQuery) matches(line UsageLine) bool { if !q.From.IsZero() && line.At.Before(q.From) { @@ -316,6 +333,9 @@ func (q UsageQuery) matches(line UsageLine) bool { !exact(q.Workspace, line.Workspace) { return false } + if q.Agent != "" && q.Agent != UsageOwner(line) { + return false + } if q.Kind != "" { kind, id := usageSubject(line) if kind != q.Kind || (q.Subject != "" && id != q.Subject) { diff --git a/internal/session/usage_report.go b/internal/session/usage_report.go index 4ce8681969..74ad28fc6c 100644 --- a/internal/session/usage_report.go +++ b/internal/session/usage_report.go @@ -100,6 +100,7 @@ type UsageReportCell struct { Kind, Subject, Root string // Session disambiguates task rows whose older writer did not record Root. Session string + Agent string // Records is ledger lines; Calls is provider requests, counted apart from // the lines because an older line may cover many. Records, Calls int @@ -129,7 +130,7 @@ type UsageReport struct { Stats UsageScanStats } -type usageReportKey struct{ day, model, kind, subject, root, session string } +type usageReportKey struct{ day, model, kind, subject, root, session, agent string } // checkReportRange refuses a query that is not a bounded range. func (q UsageQuery) checkReportRange() error { @@ -207,17 +208,17 @@ func QueryUsageReport(path string, q UsageQuery, loc *time.Location, budget Usag session = strings.TrimSpace(line.Session) } } - key := usageReportKey{day: day, model: strings.TrimSpace(line.Model), kind: kind, subject: subject, root: root, session: session} + key := usageReportKey{day: day, model: strings.TrimSpace(line.Model), kind: kind, subject: subject, root: root, session: session, agent: UsageOwner(line)} cell := cells[key] if cell == nil { - cost := int64(usageReportCellBytes + len(key.day) + len(key.model) + len(key.kind) + len(key.subject) + len(key.root) + len(key.session)) + cost := int64(usageReportCellBytes + len(key.day) + len(key.model) + len(key.kind) + len(key.subject) + len(key.root) + len(key.session) + len(key.agent)) if len(cells) >= budget.MaxCells || weight+cost > budget.MaxBytes { return fmt.Errorf("%w (more than %d groups or %d estimated bytes)", ErrUsageReportTooLarge, budget.MaxCells, budget.MaxBytes) } // Only now are the strings copied, so a line's large id is never // retained by a map it was refused from. - key = usageReportKey{strings.Clone(key.day), strings.Clone(key.model), key.kind, strings.Clone(key.subject), strings.Clone(key.root), strings.Clone(key.session)} - cell = &UsageReportCell{Day: key.day, Model: key.model, Kind: key.kind, Subject: key.subject, Root: key.root, Session: key.session} + key = usageReportKey{strings.Clone(key.day), strings.Clone(key.model), key.kind, strings.Clone(key.subject), strings.Clone(key.root), strings.Clone(key.session), strings.Clone(key.agent)} + cell = &UsageReportCell{Day: key.day, Model: key.model, Kind: key.kind, Subject: key.subject, Root: key.root, Session: key.session, Agent: key.agent} cells[key] = cell weight += cost } @@ -251,7 +252,7 @@ func QueryUsageReport(path string, q UsageQuery, loc *time.Location, budget Usag } slices.SortFunc(report.Cells, func(a, b UsageReportCell) int { return cmp.Or(strings.Compare(a.Day, b.Day), strings.Compare(a.Model, b.Model), strings.Compare(a.Kind, b.Kind), - strings.Compare(a.Subject, b.Subject), strings.Compare(a.Root, b.Root), strings.Compare(a.Session, b.Session)) + strings.Compare(a.Subject, b.Subject), strings.Compare(a.Root, b.Root), strings.Compare(a.Session, b.Session), strings.Compare(a.Agent, b.Agent)) }) slices.Sort(report.Models) return report, nil diff --git a/internal/session/usage_report_test.go b/internal/session/usage_report_test.go index a2bbd6b038..c87610b258 100644 --- a/internal/session/usage_report_test.go +++ b/internal/session/usage_report_test.go @@ -322,3 +322,32 @@ func TestReportTaskIDsWithoutRootKeepRecordedSessionIdentity(t *testing.T) { t.Fatalf("cost changed: %v", result.Totals.USD) } } + +func TestReportRecordedAgentFilterIncludesChatAndOwnedTaskCharges(t *testing.T) { + chat, task, other := reportLine(15, 12, "m/a", .01), reportLine(15, 13, "m/a", .02), reportLine(15, 14, "m/a", .04) + chat.Session = "owner-a" + task.Session, task.Root, task.Task = "worker-session", "owner-a", "7" + other.Session = "owner-b" + path := writeLedger(t, []UsageLine{chat, task, other}, nil, "") + query := reportQuery(t, "2026-10-15", "2026-10-15") + query.Agent = "owner-a" + report, err := QueryUsageReport(path, query, reportZone, UsageReportBudget{}) + if err != nil { + t.Fatal(err) + } + page, err := QueryUsageLedger(path, query, "", 100, DefaultUsageLedgerBudget) + if err != nil { + t.Fatal(err) + } + if report.Totals.USD != .03 || report.Totals.USD != page.Totals.USD || report.Totals.Records != 2 { + t.Fatalf("agent filter lost chat/task money: report=%+v ledger=%+v", report.Totals, page.Totals) + } + for _, cell := range report.Cells { + if cell.Agent != "owner-a" { + t.Fatalf("invented recorded owner: %+v", cell) + } + } + if query.fingerprint() == (UsageQuery{From: query.From, To: query.To}).fingerprint() { + t.Fatal("owner filter does not fence cursors") + } +} From b8e485b1b899bdcc327ec7f7056f9b24bb10f357 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 23:22:06 -0400 Subject: [PATCH 112/459] app: providers.balance reports the amounts a provider itself reports credits.ReadDetail is now Read's one implementation and keeps the account remaining, the key's own cap and their lesser; Read's verdicts are unchanged. A credits.Book holds one in-memory entry per account: one request in flight, one a minute, dropped on a key change, last good figures kept through a transient failure. Only OpenRouter reports a balance; other providers answer unsupported with no request. Co-Authored-By: Claude Sonnet 5.5 --- cmd/codeaf/app.go | 9 +- cmd/codeaf/appproviderbalance.go | 167 +++++++ cmd/codeaf/appproviderbalance_test.go | 344 ++++++++++++++ .../unreleased/0-app-provider-balance.md | 21 + internal/appwire/provider_balance.go | 167 +++++++ internal/appwire/provider_balance_test.go | 80 ++++ internal/credits/balance.go | 309 +++++++++++++ internal/credits/balance_test.go | 433 ++++++++++++++++++ internal/credits/credits.go | 133 ++++-- 9 files changed, 1635 insertions(+), 28 deletions(-) create mode 100644 cmd/codeaf/appproviderbalance.go create mode 100644 cmd/codeaf/appproviderbalance_test.go create mode 100644 docs/changes/unreleased/0-app-provider-balance.md create mode 100644 internal/appwire/provider_balance.go create mode 100644 internal/appwire/provider_balance_test.go create mode 100644 internal/credits/balance.go create mode 100644 internal/credits/balance_test.go diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index 28e4fb88f1..54a95d2dac 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -365,6 +365,9 @@ type appBackend struct { keys *config.AppCredentials keyClient *http.Client keyAddress func(provider string) string + // balances is providers.balance's memory and lifetime (appproviderbalance.go), + // made by the first ask. + balances *appBalances // signIn is the browser sign-in waiting for the person, if any. signIn *openrouterauth.Flow // state is the app's own file: setup, its settings, budgets and raises. @@ -2907,12 +2910,13 @@ func (b *appBackend) Close() error { var crews *appCrewRun var agents []*appAgent var warmed chan struct{} + var balances *appBalances b.locked(func() { if already = b.closing; already { return } b.closing = true - crews, warmed = b.crewRun, b.warmed + crews, warmed, balances = b.crewRun, b.warmed, b.balances if b.signIn != nil { b.signIn.Cancel() b.signIn = nil @@ -2928,6 +2932,9 @@ func (b *appBackend) Close() error { if already { return nil } + if balances != nil { + balances.stop() + } // What was mid-reply and what was running is written down before anything // stops, so the next start can carry on (appquit.go). b.markQuit(agents) diff --git a/cmd/codeaf/appproviderbalance.go b/cmd/codeaf/appproviderbalance.go new file mode 100644 index 0000000000..633145fd82 --- /dev/null +++ b/cmd/codeaf/appproviderbalance.go @@ -0,0 +1,167 @@ +package main + +// ── codeaf app: PROVIDER BALANCE, ONLY WHERE THE PROVIDER REPORTS ONE ─────── +// +// providers.balance (internal/appwire/provider_balance.go) is served from +// [credits.Book]: one entry per account in this process's memory, one request +// at a time, one a minute, dropped when the key changes. The capability is a +// fact about a provider in the catalog, never a guess from a label: today only +// the default service, OpenRouter, reports an account balance, and the request +// is made only for the service the sources resolve under that exact id with +// its own key. A custom connection, a subscription sign-in and every other +// provider answer unsupported without a request, and a plan is never read as +// money. + +import ( + "context" + "errors" + "net/http" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/credits" + "github.com/Agent-Field/codeaf/internal/modelsource" +) + +// The app's backend serves providers.balance. +var _ appwire.ProviderBalancer = (*appBackend)(nil) + +// appBalances is the backend's balance state. stop ends every request in +// flight and is the backend's Close. +type appBalances struct { + book *credits.Book + stop context.CancelFunc + ctx context.Context + // client and address replace the network and the provider's address in a + // test; nil is the real thing. + client *http.Client + address func(provider string) string +} + +// balanceState makes the backend's balance state on first use. +func (b *appBackend) balanceState() *appBalances { + var state *appBalances + b.locked(func() { + if b.balances == nil { + ctx, stop := context.WithCancel(context.Background()) + b.balances = &appBalances{book: credits.NewBook(ctx, b.now), stop: stop, ctx: ctx} + if b.closing { + stop() + } + } + state = b.balances + }) + return state +} + +// ProviderBalance answers what the provider reports for the account its key +// reaches, from memory when it was asked inside the last minute. +func (b *appBackend) ProviderBalance(ask appwire.ProviderBalanceAsk) (appwire.ProviderBalance, error) { + id := ask.Provider + name := "" + if p, ok := appProviderNamed(id); ok { + name = p.name + } + if id != modelsource.DefaultID { + return appwire.ProviderBalance{ + Provider: id, Name: name, Status: appwire.BalanceUnavailable, ErrorKind: "unsupported", + }, nil + } + state := b.balanceState() + profile := config.ProfileDir() + sources := config.ResolveSources(profile, config.APIKeyAt(profile), config.DefaultBaseURL) + service, ok := sources.ByID(id) + key := strings.TrimSpace(service.Key) + if !ok || key == "" || service.Source.ID != modelsource.DefaultID || modelsource.IsCustomID(service.Source.ID) { + state.book.Drop(id) + return appwire.ProviderBalance{ + Provider: id, Name: name, Supported: true, Status: appwire.BalanceUnavailable, ErrorKind: "not_connected", + }, nil + } + address := strings.TrimSpace(service.Address) + if address == "" { + address = config.DefaultBaseURL + } + if state.address != nil { + address = state.address(id) + } + // A REDIRECT MUST NEVER TURN THIS CALL INTO A KEY SENT ELSEWHERE. + local := http.Client{} + if state.client != nil { + local = *state.client + } + local.CheckRedirect = func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse } + fetch := func(ctx context.Context) (credits.Detail, error) { + return credits.ReadDetail(ctx, &local, address, key) + } + snap, err := state.book.Get(state.ctx, credits.NewIdentity(id, address, key), fetch) + if err != nil && errors.Is(err, context.Canceled) { + return appwire.ProviderBalance{}, appwire.Errorf(appwire.CodeClosed, "codeaf is closing") + } + return appBalanceReply(id, name, snap), nil +} + +// appBalanceMessageRejected and appBalanceMessageExpired are the only words the +// engine adds: the two failures a person can do something about. +const ( + appBalanceMessageRejected = "The provider rejected this key. Reconnect it." + appBalanceMessageExpired = "This key has expired. Replace it." +) + +// appBalanceReply is a book's snapshot as the wire's answer. Figures come only +// from a reading, or from the last good one kept through a transient failure. +func appBalanceReply(id, name string, snap credits.Snapshot) appwire.ProviderBalance { + r := appwire.ProviderBalance{Provider: id, Name: name, Supported: true, Busy: snap.Busy} + if !snap.LastSuccess.IsZero() { + r.LastSuccessAt = snap.LastSuccess.UTC().Format(time.RFC3339) + } + if !snap.LastError.IsZero() { + r.LastErrorAt = snap.LastError.UTC().Format(time.RFC3339) + } + if snap.RetryAfter > 0 { + r.RetryAfterMS = (snap.RetryAfter + time.Millisecond - 1).Milliseconds() + } + figures := func(d credits.Detail) { + remaining, _ := d.Remaining() + r.RemainingUSD = &remaining + if d.AccountRemaining != nil { + v := *d.AccountRemaining + r.AccountRemainingUSD = &v + } + if d.KeyLimitRemaining != nil { + v := *d.KeyLimitRemaining + r.KeyLimitRemainingUSD = &v + } + } + switch { + case !snap.Attempted: + r.Status, r.ErrorKind, r.Busy = appwire.BalanceUnavailable, "busy", true + case snap.Err != nil: + kind := credits.Classify(snap.Err) + r.ErrorKind = string(kind) + switch kind { + case credits.KindKeyRejected: + r.Status, r.Message = appwire.BalanceUnauthorized, appBalanceMessageRejected + case credits.KindBadResponse, credits.KindUnexpected: + r.Status = appwire.BalanceInvalid + default: + r.Status = appwire.BalanceUnreachable + } + if snap.HasGood { + figures(snap.Good) + r.Stale = true + } + case snap.Detail.Expired: + r.Status, r.ErrorKind, r.Message = appwire.BalanceUnauthorized, string(credits.KindKeyExpired), appBalanceMessageExpired + default: + if _, ok := snap.Detail.Remaining(); ok { + r.Status = appwire.BalanceKnown + figures(snap.Detail) + } else { + r.Status, r.ErrorKind = appwire.BalanceUnavailable, "not_reported" + } + } + return r +} diff --git a/cmd/codeaf/appproviderbalance_test.go b/cmd/codeaf/appproviderbalance_test.go new file mode 100644 index 0000000000..1ee190550a --- /dev/null +++ b/cmd/codeaf/appproviderbalance_test.go @@ -0,0 +1,344 @@ +package main + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/credits" +) + +const balanceKeyA, balanceKeyB = "sk-or-balance-key-a1a1a1", "sk-or-balance-key-b2b2b2" + +// balanceFake is the provider, with no socket: it records who asked with which +// key and answers by path. +type balanceFake struct { + mu sync.Mutex + asks []string + keys []string + reply func(r *http.Request) (*http.Response, error) +} + +func (f *balanceFake) RoundTrip(r *http.Request) (*http.Response, error) { + f.mu.Lock() + f.asks = append(f.asks, r.URL.Path) + f.keys = append(f.keys, strings.TrimPrefix(r.Header.Get("Authorization"), "Bearer ")) + reply := f.reply + f.mu.Unlock() + return reply(r) +} + +func (f *balanceFake) count() int { f.mu.Lock(); defer f.mu.Unlock(); return len(f.asks) } + +func balanceBody(status int, body string) (*http.Response, error) { + return &http.Response{StatusCode: status, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(body))}, nil +} + +func healthy(r *http.Request) (*http.Response, error) { + if strings.HasSuffix(r.URL.Path, "/key") { + return balanceBody(200, `{"data":{"limit_remaining":4}}`) + } + return balanceBody(200, `{"data":{"total_credits":10,"total_usage":2.5}}`) +} + +type balanceClock struct { + mu sync.Mutex + t time.Time +} + +func (c *balanceClock) now() time.Time { c.mu.Lock(); defer c.mu.Unlock(); return c.t } +func (c *balanceClock) add(d time.Duration) { + c.mu.Lock() + c.t = c.t.Add(d) + c.mu.Unlock() +} + +// balanceBackend is a backend with a key for OpenRouter and the fake for a +// network, answering on a clock the test moves. +func balanceBackend(t *testing.T, reply func(*http.Request) (*http.Response, error)) (*appBackend, *balanceFake, *balanceClock) { + t.Helper() + t.Setenv(config.ProfileDirEnv, t.TempDir()) + for _, name := range []string{"OPENROUTER_API_KEY", "OPENAI_API_KEY", "DEEPSEEK_API_KEY"} { + t.Setenv(name, "") + } + restore := config.UseAppCredentials(config.NewAppCredentials()) + t.Cleanup(restore) + backend := newFakeBackend(newAppRecorder(), nil) + t.Cleanup(func() { backend.Close() }) + fake := &balanceFake{reply: reply} + clock := &balanceClock{t: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)} + ctx, stop := context.WithCancel(context.Background()) + backend.locked(func() { + backend.balances = &appBalances{ + book: credits.NewBook(ctx, clock.now), stop: stop, ctx: ctx, + client: &http.Client{Transport: fake}, address: func(string) string { return "https://balance.invalid/api/v1" }, + } + }) + if err := backend.SetKey("openrouter", balanceKeyA); err != nil { + t.Fatal(err) + } + return backend, fake, clock +} + +func askBalance(t *testing.T, b *appBackend, provider string) appwire.ProviderBalance { + t.Helper() + r, err := b.ProviderBalance(appwire.ProviderBalanceAsk{Provider: provider}) + if err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(r) + if len(raw) > appwire.ProviderBalanceReplyMax { + t.Fatalf("reply is %d bytes", len(raw)) + } + for _, secret := range []string{balanceKeyA, balanceKeyB, "SECRET-BODY", "balance.invalid"} { + if strings.Contains(string(raw), secret) { + t.Fatalf("the reply carries %q: %s", secret, raw) + } + } + return r +} + +func TestBalanceKnownCarriesBothFiguresAndTheLesser(t *testing.T) { + b, fake, _ := balanceBackend(t, healthy) + r := askBalance(t, b, "openrouter") + if r.Status != appwire.BalanceKnown || !r.Supported || r.Name != "OpenRouter" || r.Stale || r.ErrorKind != "" { + t.Fatalf("%+v", r) + } + if *r.AccountRemainingUSD != 7.5 || *r.KeyLimitRemainingUSD != 4 || *r.RemainingUSD != 4 { + t.Fatalf("%v %v %v", *r.AccountRemainingUSD, *r.KeyLimitRemainingUSD, *r.RemainingUSD) + } + if r.LastSuccessAt != "2026-10-15T12:00:00Z" || r.LastErrorAt != "" || fake.count() != 2 { + t.Fatalf("%+v asks %d", r, fake.count()) + } + if fake.keys[0] != balanceKeyA { + t.Fatal("the request did not carry the connected key") + } +} + +func TestBalanceNeverFabricatesAnAmountTheProviderDidNotSend(t *testing.T) { + b, _, _ := balanceBackend(t, func(r *http.Request) (*http.Response, error) { + if strings.HasSuffix(r.URL.Path, "/key") { + return balanceBody(200, `{"data":{"limit_remaining":null}}`) + } + return balanceBody(403, "SECRET-BODY") + }) + r := askBalance(t, b, "openrouter") + if r.Status != appwire.BalanceUnavailable || r.ErrorKind != "not_reported" || r.RemainingUSD != nil || r.AccountRemainingUSD != nil || r.KeyLimitRemainingUSD != nil { + t.Fatalf("%+v", r) + } + b2, _, _ := balanceBackend(t, func(r *http.Request) (*http.Response, error) { + if strings.HasSuffix(r.URL.Path, "/key") { + return balanceBody(200, `{"data":{"limit_remaining":0.75}}`) + } + return balanceBody(404, "") + }) + r = askBalance(t, b2, "openrouter") + if r.Status != appwire.BalanceKnown || r.AccountRemainingUSD != nil || *r.RemainingUSD != 0.75 { + t.Fatalf("%+v", r) + } +} + +func TestBalanceNegativeIsHonestAndInvalidIsInvalid(t *testing.T) { + b, _, _ := balanceBackend(t, func(r *http.Request) (*http.Response, error) { + if strings.HasSuffix(r.URL.Path, "/key") { + return balanceBody(200, `{"data":{}}`) + } + return balanceBody(200, `{"data":{"total_credits":1,"total_usage":3.25}}`) + }) + if r := askBalance(t, b, "openrouter"); *r.RemainingUSD != -2.25 || r.Status != appwire.BalanceKnown { + t.Fatalf("%+v", r) + } + b2, _, _ := balanceBackend(t, func(r *http.Request) (*http.Response, error) { + return balanceBody(200, `{"data": SECRET-BODY`) + }) + r := askBalance(t, b2, "openrouter") + if r.Status != appwire.BalanceInvalid || r.ErrorKind != "bad_response" || r.RemainingUSD != nil || r.LastErrorAt == "" { + t.Fatalf("%+v", r) + } +} + +func TestBalanceOtherProvidersAreUnsupportedWithoutARequest(t *testing.T) { + b, fake, _ := balanceBackend(t, healthy) + if err := b.SetKey("deepseek", "sk-deepseek-balance-9f9f9f"); err != nil { + t.Fatal(err) + } + for _, id := range []string{"deepseek", "codex", "custom", "custom-openrouter", "openrouter.ai", "nowhere"} { + r := askBalance(t, b, id) + if r.Supported || r.Status != appwire.BalanceUnavailable || r.ErrorKind != "unsupported" || r.RemainingUSD != nil { + t.Fatalf("%s: %+v", id, r) + } + } + if fake.count() != 0 { + t.Fatalf("%d requests for providers that report nothing", fake.count()) + } +} + +func TestBalanceWithoutAKeyIsNotConnectedAndAsksNothing(t *testing.T) { + b, fake, _ := balanceBackend(t, healthy) + askBalance(t, b, "openrouter") + before := fake.count() + if err := b.RemoveKey("openrouter"); err != nil { + t.Fatal(err) + } + r := askBalance(t, b, "openrouter") + if r.Status != appwire.BalanceUnavailable || r.ErrorKind != "not_connected" || !r.Supported || r.RemainingUSD != nil || fake.count() != before { + t.Fatalf("%+v", r) + } + if b.balances.book.Len() != 0 { + t.Fatal("a disconnected provider's balance is still held") + } +} + +func TestBalanceIsAskedOnceAMinutePerAccountAndSaysWhenNext(t *testing.T) { + b, fake, clock := balanceBackend(t, healthy) + askBalance(t, b, "openrouter") + clock.add(45 * time.Second) + r := askBalance(t, b, "openrouter") + if fake.count() != 2 || r.RetryAfterMS != 15000 || r.Status != appwire.BalanceKnown { + t.Fatalf("asks %d %+v", fake.count(), r) + } + clock.add(15 * time.Second) + if r := askBalance(t, b, "openrouter"); fake.count() != 4 || r.RetryAfterMS != 60000 { + t.Fatalf("asks %d %+v", fake.count(), r) + } +} + +func TestBalanceConcurrentAsksShareOneRequest(t *testing.T) { + release := make(chan struct{}) + b, fake, _ := balanceBackend(t, func(r *http.Request) (*http.Response, error) { + <-release + return healthy(r) + }) + var wg sync.WaitGroup + for i := 0; i < 6; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if r := askBalance(t, b, "openrouter"); r.Status != appwire.BalanceKnown { + t.Errorf("%+v", r) + } + }() + } + for fake.count() == 0 { + time.Sleep(time.Millisecond) + } + time.Sleep(30 * time.Millisecond) + close(release) + wg.Wait() + if fake.count() != 2 { + t.Fatalf("%d requests: the key row and the credits row, once", fake.count()) + } +} + +func TestBalanceKeepsLastGoodThroughATransientFailureButNotARejectedKey(t *testing.T) { + var fail atomicStatus + b, _, clock := balanceBackend(t, func(r *http.Request) (*http.Response, error) { + switch fail.get() { + case 0: + return healthy(r) + case 503: + return balanceBody(503, "SECRET-BODY") + } + return balanceBody(401, `{"error":{"message":"SECRET-BODY"}}`) + }) + askBalance(t, b, "openrouter") + fail.set(503) + clock.add(time.Minute) + r := askBalance(t, b, "openrouter") + if r.Status != appwire.BalanceUnreachable || r.ErrorKind != "provider_error" || !r.Stale || *r.RemainingUSD != 4 || r.LastSuccessAt == "" || r.LastErrorAt == "" { + t.Fatalf("%+v", r) + } + fail.set(401) + clock.add(time.Minute) + r = askBalance(t, b, "openrouter") + if r.Status != appwire.BalanceUnauthorized || r.ErrorKind != "key_rejected" || r.RemainingUSD != nil || r.Stale || r.Message == "" { + t.Fatalf("%+v", r) + } + fail.set(503) + clock.add(time.Minute) + if r := askBalance(t, b, "openrouter"); r.RemainingUSD != nil { + t.Fatalf("a rejected key's old figures came back: %+v", r) + } +} + +type atomicStatus struct { + mu sync.Mutex + v int +} + +func (a *atomicStatus) get() int { a.mu.Lock(); defer a.mu.Unlock(); return a.v } +func (a *atomicStatus) set(v int) { a.mu.Lock(); a.v = v; a.mu.Unlock() } + +func TestBalanceExpiredKeyIsUnauthorizedWithNoFigures(t *testing.T) { + b, _, _ := balanceBackend(t, func(*http.Request) (*http.Response, error) { + return balanceBody(401, `{"error":{"message":"API key expired."}}`) + }) + r := askBalance(t, b, "openrouter") + if r.Status != appwire.BalanceUnauthorized || r.ErrorKind != "key_expired" || r.RemainingUSD != nil || r.LastSuccessAt == "" { + t.Fatalf("%+v", r) + } +} + +func TestBalanceAChangedKeyIsAskedAtOnceAndNeverShowsTheOldAccount(t *testing.T) { + b, fake, _ := balanceBackend(t, func(r *http.Request) (*http.Response, error) { + if strings.Contains(r.Header.Get("Authorization"), "b2b2") { + return balanceBody(500, "") + } + return healthy(r) + }) + if r := askBalance(t, b, "openrouter"); *r.RemainingUSD != 4 { + t.Fatalf("%+v", r) + } + if err := b.SetKey("openrouter", balanceKeyB); err != nil { + t.Fatal(err) + } + // No time has passed, and the minute does not protect a new account. + r := askBalance(t, b, "openrouter") + if fake.keys[len(fake.keys)-1] != balanceKeyB || r.RemainingUSD != nil || r.Stale || r.LastSuccessAt != "" || r.Status != appwire.BalanceUnreachable { + t.Fatalf("%+v keys %v", r, fake.keys) + } + if b.balances.book.Len() != 1 { + t.Fatal("two accounts held for one provider") + } +} + +func TestBalanceRedirectIsNeverFollowedWithTheKey(t *testing.T) { + b, fake, _ := balanceBackend(t, func(r *http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 302, Header: http.Header{"Location": {"https://elsewhere.invalid/key"}}, Body: io.NopCloser(strings.NewReader(""))}, nil + }) + r := askBalance(t, b, "openrouter") + if fake.count() != 1 || r.Status != appwire.BalanceInvalid || r.ErrorKind != "unexpected_status" { + t.Fatalf("asks %v %+v", fake.asks, r) + } +} + +func TestBalanceCloseEndsARequestInFlight(t *testing.T) { + started := make(chan struct{}) + b, _, _ := balanceBackend(t, func(r *http.Request) (*http.Response, error) { + close(started) + <-r.Context().Done() + return nil, r.Context().Err() + }) + done := make(chan error, 1) + go func() { + _, err := b.ProviderBalance(appwire.ProviderBalanceAsk{Provider: "openrouter"}) + done <- err + }() + <-started + b.Close() + select { + case err := <-done: + if err == nil { + t.Fatal("a closed backend answered a balance") + } + case <-time.After(3 * time.Second): + t.Fatal("Close left a balance request running") + } +} diff --git a/docs/changes/unreleased/0-app-provider-balance.md b/docs/changes/unreleased/0-app-provider-balance.md new file mode 100644 index 0000000000..1646d20c88 --- /dev/null +++ b/docs/changes/unreleased/0-app-provider-balance.md @@ -0,0 +1,21 @@ +--- +kind: added +title: codeaf app asks a provider what it reports an account has left +pr: 0 +surface: [engine] +--- + +Was true: the engine read OpenRouter's balance only to decide whether it was +low or the key expired, and threw the amounts away; the app had no way to ask +for them. + +Is true now: `providers.balance` answers the account's remaining credit, the +key's own cap left and the lesser of the two, each only when the provider sent +it, with `status`, `last_success_at`, `last_error_at` and a stable `error_kind`. +Only OpenRouter reports a balance; every other provider answers +`supported: false` with no request, and a plan or subscription is never read as +a balance. The engine asks an account at most once a minute, shares one request +between concurrent callers, keeps the last good figures through a transient +failure and drops them when the key changes. Nothing is stored on disk. Engines +without the capability answer `unknown_method`. The existing low and expired +checks read through the same single implementation, unchanged. diff --git a/internal/appwire/provider_balance.go b/internal/appwire/provider_balance.go new file mode 100644 index 0000000000..cb3a8c3975 --- /dev/null +++ b/internal/appwire/provider_balance.go @@ -0,0 +1,167 @@ +package appwire + +// ── PROVIDER BALANCE: what a provider itself says an account has left ─────── +// +// providers.balance asks the engine for the balance a provider REPORTS for the +// account a provider's key reaches. It is a capability of some providers, not +// of the app: a provider that reports none answers supported:false and the +// engine makes no request for it. The engine never estimates, never reads a +// plan or a subscription as a balance, and never answers a zero it was not +// told. +// +// THE ENGINE, NOT THE SCREEN, DECIDES HOW OFTEN THE PROVIDER IS ASKED. At most +// one request is made for an account in any sixty seconds, whoever asks and +// however often, and concurrent asks share one request. An ask inside the +// minute is answered from memory with retry_after_ms. There is no force. +// A key that changes (or a provider that is signed out and in again) is a +// different account: its first ask is made at once, and nothing of the earlier +// account's figures is ever returned for it. +// +// Nothing is written to disk. Amounts, times and error kinds live in memory +// per account for the life of the engine process. +// +// AN ENGINE WITHOUT THE CAPABILITY DOES NOT HAVE THE VERB: providers.balance +// answers unknown_method from an engine that is not a [ProviderBalancer]. + +import ( + "encoding/json" + "math" + "strings" + "unicode/utf8" +) + +// ProviderBalanceReplyMax bounds an encoded reply; a longer one is refused. +const ProviderBalanceReplyMax = 4 << 10 + +// ProviderBalanceStringMax bounds each string a reply carries, in bytes. +const ProviderBalanceStringMax = 256 + +// ProviderBalanceIDMax bounds the provider id a request may carry. +const ProviderBalanceIDMax = 64 + +// The statuses of a balance answer. Status describes the LATEST attempt (or, +// for a provider that reports nothing, the provider). +const ( + BalanceKnown = "known" + BalanceUnavailable = "unavailable" + BalanceUnauthorized = "unauthorized" + BalanceUnreachable = "unreachable" + BalanceInvalid = "invalid" +) + +// ProviderBalanceParams is providers.balance's params. The provider is an id +// as providers.list names it: lowercase letters, digits, '.', '_' and '-'. +type ProviderBalanceParams struct { + Provider string `json:"provider"` +} + +// ProviderBalance is providers.balance's answer. +// +// remaining_usd is the effective balance: the lesser of account_remaining_usd +// and key_limit_remaining_usd when both are reported, otherwise the one that +// is. Each is absent when the provider did not report it; none is ever +// invented. A negative figure is the provider's own and is not clamped. +// +// When the latest attempt failed in a way that may pass (unreachable, invalid) +// and an earlier attempt for the same key succeeded, the earlier figures are +// kept and stale is true; last_success_at says when they were read. A key the +// provider rejected or reported expired, or an answer without figures, keeps +// no figures. +type ProviderBalance struct { + Provider string `json:"provider"` + Name string `json:"name,omitempty"` + Supported bool `json:"supported"` + Status string `json:"status" enum:"known,unavailable,unauthorized,unreachable,invalid"` + RemainingUSD *float64 `json:"remaining_usd,omitempty"` + AccountRemainingUSD *float64 `json:"account_remaining_usd,omitempty"` + KeyLimitRemainingUSD *float64 `json:"key_limit_remaining_usd,omitempty"` + Stale bool `json:"stale,omitempty"` + LastSuccessAt string `json:"last_success_at,omitempty"` + LastErrorAt string `json:"last_error_at,omitempty"` + // ErrorKind is stable: unsupported, not_connected, not_reported, + // key_rejected, key_expired, timeout, unreachable, provider_error, + // bad_response, unexpected_status, busy. + ErrorKind string `json:"error_kind,omitempty"` + // Message is present only when there is something to do about it. + Message string `json:"message,omitempty"` + // RetryAfterMS is how long until the engine will ask the provider again. + RetryAfterMS int64 `json:"retry_after_ms,omitempty"` + // Busy says this answer is what the engine already held because another + // request was in flight or the engine was at capacity. + Busy bool `json:"busy,omitempty"` +} + +// ProviderBalanceAsk is providers.balance as the engine is handed it: a +// checked provider id. The wire carries no context, so the engine bounds the +// request itself: a deadline of its own and its own lifetime. +type ProviderBalanceAsk struct { + Provider string +} + +// ProviderBalancer is the engine's half. It is optional. +type ProviderBalancer interface { + ProviderBalance(ask ProviderBalanceAsk) (ProviderBalance, error) +} + +func init() { + methods["providers.balance"] = providerBalanceMethod +} + +var providerBalanceMethod = shaped[ProviderBalanceParams, ProviderBalance](func(e Engine, raw json.RawMessage) (*ProviderBalance, error) { + b, ok := e.(ProviderBalancer) + if !ok { + return nil, Errorf(CodeUnknownMethod, "this engine does not serve %s", "providers.balance") + } + var p ProviderBalanceParams + if err := decodeStrict("providers.balance", raw, &p); err != nil { + return nil, err + } + id, err := CheckProviderBalanceID(p.Provider) + if err != nil { + return nil, err + } + reply, err := b.ProviderBalance(ProviderBalanceAsk{Provider: id}) + if err != nil { + return nil, err + } + if err := checkProviderBalance(reply); err != nil { + return nil, err + } + return &reply, nil +}) + +// CheckProviderBalanceID trims and lowercases a provider id and refuses what +// cannot be one. +func CheckProviderBalanceID(id string) (string, error) { + id = strings.ToLower(strings.TrimSpace(id)) + if id == "" || len(id) > ProviderBalanceIDMax { + return "", Errorf(CodeBadParams, "provider is an id of 1 to %d characters", ProviderBalanceIDMax) + } + for _, r := range id { + if !(r >= 'a' && r <= 'z' || r >= '0' && r <= '9' || r == '.' || r == '_' || r == '-') { + return "", Errorf(CodeBadParams, "provider is lowercase letters, digits, '.', '_' and '-'") + } + } + return id, nil +} + +// checkProviderBalance refuses an engine's answer that would break the wire: +// a figure that cannot be encoded, a string over its bound, a reply over 4 KiB. +// It never cuts one. +func checkProviderBalance(r ProviderBalance) error { + for _, f := range []*float64{r.RemainingUSD, r.AccountRemainingUSD, r.KeyLimitRemainingUSD} { + if f != nil && (math.IsNaN(*f) || math.IsInf(*f, 0)) { + return Errorf(CodeEngine, "the engine answered a balance that is not a number") + } + } + for _, s := range []string{r.Provider, r.Name, r.Status, r.LastSuccessAt, r.LastErrorAt, r.ErrorKind, r.Message} { + if len(s) > ProviderBalanceStringMax || !utf8.ValidString(s) { + return Errorf(CodeEngine, "the engine answered a balance with a string out of bounds") + } + } + out, err := json.Marshal(r) + if err != nil || len(out) > ProviderBalanceReplyMax { + return Errorf(CodeEngine, "the engine answered a balance over %d bytes", ProviderBalanceReplyMax) + } + return nil +} diff --git a/internal/appwire/provider_balance_test.go b/internal/appwire/provider_balance_test.go new file mode 100644 index 0000000000..5614356de7 --- /dev/null +++ b/internal/appwire/provider_balance_test.go @@ -0,0 +1,80 @@ +package appwire + +import ( + "math" + "strings" + "testing" +) + +type balanceEngine struct { + fakeEngine + asks []ProviderBalanceAsk + reply ProviderBalance +} + +func (b *balanceEngine) ProviderBalance(ask ProviderBalanceAsk) (ProviderBalance, error) { + b.asks = append(b.asks, ask) + return b.reply, nil +} + +func TestProviderBalanceIsUnknownMethodWithoutTheCapability(t *testing.T) { + frame := serveOver(t, &fakeEngine{})(`{"id":1,"method":"providers.balance","params":{"provider":"openrouter"}}`) + if frameCode(frame) != CodeUnknownMethod { + t.Fatalf("%v", frame) + } +} + +func TestProviderBalanceOnTheWireOmitsWhatWasNotReported(t *testing.T) { + remaining := 2.5 + engine := &balanceEngine{reply: ProviderBalance{Provider: "openrouter", Supported: true, Status: BalanceKnown, RemainingUSD: &remaining}} + frame := serveOver(t, engine)(`{"id":1,"method":"providers.balance","params":{"provider":" OpenRouter "}}`) + result, ok := frame["result"].(map[string]any) + if !ok || len(engine.asks) != 1 || engine.asks[0].Provider != "openrouter" { + t.Fatalf("%v %+v", frame, engine.asks) + } + if result["remaining_usd"] != 2.5 || result["supported"] != true || result["status"] != "known" { + t.Fatalf("%v", result) + } + for _, absent := range []string{"account_remaining_usd", "key_limit_remaining_usd", "last_error_at", "error_kind", "stale", "busy", "retry_after_ms", "message"} { + if _, there := result[absent]; there { + t.Fatalf("%q present: %v", absent, result) + } + } +} + +func TestProviderBalanceParamsAreStrict(t *testing.T) { + engine := &balanceEngine{reply: ProviderBalance{Provider: "x", Status: BalanceUnavailable}} + call := serveOver(t, engine) + for name, params := range map[string]string{ + "missing": `{}`, + "blank": `{"provider":" "}`, + "unknown field": `{"provider":"openrouter","force":true}`, + "path": `{"provider":"../x"}`, + "space": `{"provider":"open router"}`, + "too long": `{"provider":"` + strings.Repeat("a", ProviderBalanceIDMax+1) + `"}`, + "not a string": `{"provider":7}`, + } { + frame := call(`{"id":1,"method":"providers.balance","params":` + params + `}`) + if frameCode(frame) != CodeBadParams { + t.Errorf("%s: %v", name, frame) + } + } + if len(engine.asks) != 0 { + t.Fatalf("the engine heard a refused ask: %+v", engine.asks) + } +} + +func TestProviderBalanceRefusesAnAnswerThatBreaksTheWire(t *testing.T) { + nan := math.NaN() + for name, reply := range map[string]ProviderBalance{ + "nan": {Provider: "p", Status: BalanceKnown, RemainingUSD: &nan}, + "long": {Provider: "p", Status: BalanceInvalid, Message: strings.Repeat("x", ProviderBalanceStringMax+1)}, + "bad utf8": {Provider: "p", Status: BalanceInvalid, Message: "\xff"}, + "long title": {Provider: "p", Status: BalanceKnown, Name: strings.Repeat("n", ProviderBalanceStringMax+1)}, + } { + frame := serveOver(t, &balanceEngine{reply: reply})(`{"id":1,"method":"providers.balance","params":{"provider":"p"}}`) + if frameCode(frame) != CodeEngine { + t.Errorf("%s: %v", name, frame) + } + } +} diff --git a/internal/credits/balance.go b/internal/credits/balance.go new file mode 100644 index 0000000000..af97dc7877 --- /dev/null +++ b/internal/credits/balance.go @@ -0,0 +1,309 @@ +package credits + +// ── THE BALANCE BOOK: what the service last reported, per account, in memory ─ +// +// A [Book] answers "what does the provider say this account has left" without +// letting a screen decide how often the provider is asked. It keeps ONE entry +// per account identity, never on disk, and enforces four laws: +// +// - ONE REQUEST IN FLIGHT PER ACCOUNT. A second caller waits for it (at most +// [WaitersMax] of them, each leaving on its own context) or, past that, is +// told the book is busy and handed what is already known. Nothing queues +// without bound and nothing runs outside the caller's context. +// - AT MOST ONE REQUEST A MINUTE PER ACCOUNT. An attempt, failed or not, is +// not repeated until [MinGap] has passed; an earlier ask is handed the +// entry with how long to wait. There is no way to force one. +// - A CHANGED CREDENTIAL IS A DIFFERENT ACCOUNT. An identity names the +// provider, its address and a digest of the key as read at the call. The +// moment a provider is asked about under another identity the old entry is +// dropped and its request cancelled, so a balance is never shown for an +// account it did not come from, and the minute does not protect a new key. +// - A TRANSIENT FAILURE KEEPS THE LAST GOOD FIGURES. A rejected key, an +// expired key or a reply with no figures clears them; an unreachable +// service, a timeout or a malformed answer does not. + +import ( + "context" + "crypto/sha256" + "errors" + "net" + "sync" + "time" +) + +// The book's bounds. +const ( + // MinGap is the least time between two requests for one account. + MinGap = 60 * time.Second + // BookMax is how many accounts a book holds at once. + BookMax = 8 + // WaitersMax is how many callers may wait on one request in flight. + WaitersMax = 8 +) + +// Identity names an account without holding its key: the key is kept only as a +// digest. +type Identity struct { + Provider string + Address string + key [sha256.Size]byte +} + +// NewIdentity digests key into an identity. Pass the key as read at the call. +func NewIdentity(provider, address, key string) Identity { + return Identity{Provider: provider, Address: address, key: sha256.Sum256([]byte(key))} +} + +// Kind is the stable name of why a read failed. +type Kind string + +// The kinds Classify answers. They are wire words: do not respell them. +const ( + KindKeyRejected Kind = "key_rejected" + KindKeyExpired Kind = "key_expired" + KindTimeout Kind = "timeout" + KindUnreachable Kind = "unreachable" + KindProvider Kind = "provider_error" + KindBadResponse Kind = "bad_response" + KindUnexpected Kind = "unexpected_status" + KindCanceled Kind = "canceled" +) + +// Classify names a failed read's cause from its type alone: the words of an +// error may hold an address and are never inspected or passed on. +func Classify(err error) Kind { + var status *StatusError + var netErr net.Error + switch { + case err == nil: + return "" + case errors.As(err, &status): + return status.Kind + case errors.Is(err, ErrTooLarge), errors.Is(err, ErrInvalid), errors.Is(err, errDecode): + return KindBadResponse + case errors.Is(err, context.Canceled): + return KindCanceled + case errors.Is(err, context.DeadlineExceeded): + return KindTimeout + case errors.As(err, &netErr) && netErr.Timeout(): + return KindTimeout + } + return KindUnreachable +} + +// Snapshot is what the book knows about one account when it answered. +type Snapshot struct { + // Attempted is false until a request has been made for this account. + Attempted bool + // Detail is the last attempt's reading when Err is nil. + Detail Detail + // Err is the last attempt's failure; its words are never shown. + Err error + // Good holds the figures of the last success that carried any; HasGood says + // whether it does, and GoodAt when. + Good Detail + HasGood bool + GoodAt time.Time + // LastSuccess and LastError are the times of the last successful and the + // last failed attempt, zero when there has been none. + LastSuccess, LastError time.Time + // Busy says a request is in flight that this caller did not wait for, the + // book is full of requests in flight, or the request it waited on was + // abandoned: what is held is all there is for now. + Busy bool + // RetryAfter is how long until the account may be asked again, zero when it + // may be asked now. + RetryAfter time.Duration +} + +type flight struct { + done chan struct{} + aborted bool +} + +type entry struct { + seq uint64 + flight *flight + waiting int + cancel context.CancelFunc + + attempted bool + attemptAt time.Time + detail Detail + err error + good Detail + hasGood bool + goodAt time.Time + lastSuccess time.Time + lastError time.Time +} + +func (e *entry) snapshot(now time.Time) Snapshot { + s := Snapshot{ + Attempted: e.attempted, Detail: e.detail, Err: e.err, Good: e.good, HasGood: e.hasGood, + GoodAt: e.goodAt, LastSuccess: e.lastSuccess, LastError: e.lastError, + } + if e.attempted { + if left := MinGap - now.Sub(e.attemptAt); left > 0 { + s.RetryAfter = left + } + } + return s +} + +// Book holds the entries. The zero value is not usable; see [NewBook]. +type Book struct { + base context.Context + now func() time.Time + + mu sync.Mutex + entries map[Identity]*entry + seq uint64 +} + +// NewBook makes a book whose requests are all cancelled when base is. A nil +// now is the wall clock. +func NewBook(base context.Context, now func() time.Time) *Book { + if now == nil { + now = time.Now + } + return &Book{base: base, now: now, entries: map[Identity]*entry{}} +} + +// Drop forgets every entry for a provider and cancels its requests, as when +// its key is gone. +func (b *Book) Drop(provider string) { + b.mu.Lock() + defer b.mu.Unlock() + b.dropOthersLocked(Identity{Provider: provider}, false) +} + +func (b *Book) dropOthersLocked(keep Identity, keepIt bool) { + for id, e := range b.entries { + if id.Provider != keep.Provider || (keepIt && id == keep) { + continue + } + if e.cancel != nil { + e.cancel() + } + delete(b.entries, id) + } +} + +// Len is how many accounts the book holds. +func (b *Book) Len() int { + b.mu.Lock() + defer b.mu.Unlock() + return len(b.entries) +} + +// Get answers what is known about id, asking the service through fetch only +// when no request is in flight and the minute since the last has passed. fetch +// runs on the caller's goroutine under ctx, so a caller that gives up takes its +// request with it; that attempt is then not counted. The error is ctx's, and +// only that. +func (b *Book) Get(ctx context.Context, id Identity, fetch func(context.Context) (Detail, error)) (Snapshot, error) { + b.mu.Lock() + b.dropOthersLocked(id, true) + e := b.entries[id] + if e == nil { + if len(b.entries) >= BookMax && !b.evictLocked() { + b.mu.Unlock() + return Snapshot{Busy: true}, nil + } + e = &entry{} + b.entries[id] = e + } + b.seq++ + e.seq = b.seq + if f := e.flight; f != nil { + if e.waiting >= WaitersMax { + s := e.snapshot(b.now()) + s.Busy = true + b.mu.Unlock() + return s, nil + } + e.waiting++ + b.mu.Unlock() + select { + case <-f.done: + case <-ctx.Done(): + } + b.mu.Lock() + e.waiting-- + s := Snapshot{Busy: true} + if b.entries[id] == e { + s = e.snapshot(b.now()) + s.Busy = f.aborted + } + b.mu.Unlock() + return s, ctx.Err() + } + now := b.now() + if e.attempted && now.Sub(e.attemptAt) < MinGap { + s := e.snapshot(now) + b.mu.Unlock() + return s, nil + } + fctx, cancel := context.WithCancel(ctx) + stop := context.AfterFunc(b.base, cancel) + f := &flight{done: make(chan struct{})} + e.flight, e.cancel = f, cancel + b.mu.Unlock() + + detail, err := fetch(fctx) + stop() + abandoned := fctx.Err() != nil + cancel() + + b.mu.Lock() + defer b.mu.Unlock() + e.flight, e.cancel = nil, nil + defer close(f.done) + if b.entries[id] != e || abandoned { + // The credential changed, or the caller or the process left: this + // answer is for nobody and the attempt does not count. + f.aborted = true + if b.entries[id] != e { + return Snapshot{Busy: true}, ctx.Err() + } + s := e.snapshot(b.now()) + s.Busy = true + return s, ctx.Err() + } + done := b.now() + e.attempted, e.attemptAt = true, now + e.detail, e.err = detail, err + switch { + case err != nil: + e.lastError = done + if Classify(err) == KindKeyRejected { + e.hasGood = false + } + default: + e.lastSuccess = done + if _, ok := detail.Remaining(); ok { + e.good, e.hasGood, e.goodAt = detail, true, done + } else { + e.hasGood = false + } + } + return e.snapshot(done), nil +} + +// evictLocked frees one slot by dropping the least recently asked entry with +// nothing in flight, and says whether it could. +func (b *Book) evictLocked() bool { + var oldest Identity + var found *entry + for id, e := range b.entries { + if e.flight == nil && (found == nil || e.seq < found.seq) { + oldest, found = id, e + } + } + if found == nil { + return false + } + delete(b.entries, oldest) + return true +} diff --git a/internal/credits/balance_test.go b/internal/credits/balance_test.go new file mode 100644 index 0000000000..fe779cc03a --- /dev/null +++ b/internal/credits/balance_test.go @@ -0,0 +1,433 @@ +package credits + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +// fakeHTTP answers by path with no socket. A path not in the table is a test +// failure's 599. +type fakeHTTP struct { + mu sync.Mutex + paths []string + reply map[string]func(*http.Request) (*http.Response, error) +} + +func (f *fakeHTTP) RoundTrip(r *http.Request) (*http.Response, error) { + f.mu.Lock() + f.paths = append(f.paths, r.URL.Path) + h := f.reply[r.URL.Path] + f.mu.Unlock() + if h == nil { + return text(599, "no such path"), nil + } + return h(r) +} + +func text(status int, body string) *http.Response { + return &http.Response{StatusCode: status, Body: io.NopCloser(strings.NewReader(body)), Header: http.Header{}} +} + +func answer(status int, body string) func(*http.Request) (*http.Response, error) { + return func(*http.Request) (*http.Response, error) { return text(status, body), nil } +} + +func detailOf(t *testing.T, f *fakeHTTP) (Detail, error) { + t.Helper() + return ReadDetail(context.Background(), &http.Client{Transport: f}, "https://fake.invalid/api/v1/", "k") +} + +func TestReadDetailKeepsAccountAndKeyLimitApart(t *testing.T) { + f := &fakeHTTP{reply: map[string]func(*http.Request) (*http.Response, error){ + "/api/v1/key": answer(200, `{"data":{"limit_remaining":3.5}}`), + "/api/v1/credits": answer(200, `{"data":{"total_credits":10,"total_usage":2.25}}`), + }} + d, err := detailOf(t, f) + if err != nil || d.AccountRemaining == nil || *d.AccountRemaining != 7.75 || d.KeyLimitRemaining == nil || *d.KeyLimitRemaining != 3.5 { + t.Fatalf("%+v %v", d, err) + } + if got, ok := d.Remaining(); !ok || got != 3.5 { + t.Fatalf("effective %v %v, want the lesser, 3.5", got, ok) + } + if len(f.paths) != 2 { + t.Fatalf("one implementation, two GETs: %v", f.paths) + } +} + +func TestReadDetailNeverFabricatesAMissingNumber(t *testing.T) { + for name, credits := range map[string]string{ + "no usage": `{"data":{"total_credits":10}}`, + "no total": `{"data":{"total_usage":1}}`, + "null total": `{"data":{"total_credits":null,"total_usage":1}}`, + "forbidden": "", + } { + status := 200 + if credits == "" { + status = 403 + } + f := &fakeHTTP{reply: map[string]func(*http.Request) (*http.Response, error){ + "/api/v1/key": answer(200, `{"data":{"limit_remaining":null}}`), + "/api/v1/credits": answer(status, credits), + }} + d, err := detailOf(t, f) + if err != nil || d.Known() { + t.Fatalf("%s: %+v %v, want nothing known and no error", name, d, err) + } + } +} + +func TestReadDetailKeepsANegativeBalanceAsReported(t *testing.T) { + f := &fakeHTTP{reply: map[string]func(*http.Request) (*http.Response, error){ + "/api/v1/key": answer(200, `{"data":{"limit_remaining":-0.5}}`), + "/api/v1/credits": answer(200, `{"data":{"total_credits":1,"total_usage":3}}`), + }} + d, _ := detailOf(t, f) + if *d.AccountRemaining != -2 || *d.KeyLimitRemaining != -0.5 { + t.Fatalf("%+v", d) + } + if got, _ := d.Remaining(); got != -2 { + t.Fatalf("effective %v", got) + } +} + +func TestReadDetailRefusesNonFiniteAndMalformedFigures(t *testing.T) { + for name, body := range map[string]string{ + "overflow subtraction": `{"data":{"total_credits":1.7e308,"total_usage":-1.7e308}}`, + "not a number": `{"data":{"total_credits":"NaN","total_usage":1}}`, + "not json": ``, + } { + f := &fakeHTTP{reply: map[string]func(*http.Request) (*http.Response, error){ + "/api/v1/key": answer(200, `{"data":{}}`), + "/api/v1/credits": answer(200, body), + }} + if d, err := detailOf(t, f); err == nil || d.Known() || Classify(err) != KindBadResponse { + t.Fatalf("%s: %+v %v (%s)", name, d, err, Classify(err)) + } + } +} + +func TestReadDetailBodyCapIsABadResponse(t *testing.T) { + f := &fakeHTTP{reply: map[string]func(*http.Request) (*http.Response, error){ + "/api/v1/key": answer(200, strings.Repeat("x", 64<<10+1)), + }} + if _, err := detailOf(t, f); !errors.Is(err, ErrTooLarge) || Classify(err) != KindBadResponse { + t.Fatalf("%v", err) + } + exact := `{"data":{"limit_remaining":1}}` + f.reply["/api/v1/key"] = answer(200, exact+strings.Repeat(" ", 64<<10-len(exact))) + f.reply["/api/v1/credits"] = answer(403, "") + if d, err := detailOf(t, f); err != nil || d.KeyLimitRemaining == nil { + t.Fatalf("a body of exactly the cap is read: %+v %v", d, err) + } +} + +func TestReadDetailExpiredKeyAndRejectedKey(t *testing.T) { + f := &fakeHTTP{reply: map[string]func(*http.Request) (*http.Response, error){ + "/api/v1/key": answer(401, `{"error":{"message":"API key expired."}}`), + }} + if d, err := detailOf(t, f); err != nil || !d.Expired || !d.Known() { + t.Fatalf("%+v %v", d, err) + } + f.reply["/api/v1/key"] = answer(401, `{"error":{"message":"No auth credentials found"}}`) + _, err := detailOf(t, f) + if Classify(err) != KindKeyRejected || err.Error() != "key credits answered 401" { + t.Fatalf("%v %s", err, Classify(err)) + } + // The old verdict API says the same things it always did. + got, err := Read(context.Background(), &http.Client{Transport: &fakeHTTP{reply: map[string]func(*http.Request) (*http.Response, error){ + "/api/v1/key": answer(401, `API key expired`), + }}}, "https://fake.invalid/api/v1", "k") + if err != nil || got != (Reading{Known: true, Expired: true}) { + t.Fatalf("%+v %v", got, err) + } +} + +func TestReadDetailTimeoutAndCancelAreNamed(t *testing.T) { + hang := &fakeHTTP{reply: map[string]func(*http.Request) (*http.Response, error){ + "/api/v1/key": func(r *http.Request) (*http.Response, error) { <-r.Context().Done(); return nil, r.Context().Err() }, + }} + ctx, cancel := context.WithCancel(context.Background()) + go func() { time.Sleep(20 * time.Millisecond); cancel() }() + _, err := ReadDetail(ctx, &http.Client{Transport: hang}, "https://fake.invalid/api/v1", "k") + if Classify(err) != KindCanceled { + t.Fatalf("%v", err) + } + dctx, dcancel := context.WithDeadline(context.Background(), time.Now().Add(20*time.Millisecond)) + defer dcancel() + _, err = ReadDetail(dctx, &http.Client{Transport: hang}, "https://fake.invalid/api/v1", "k") + if Classify(err) != KindTimeout { + t.Fatalf("%v", err) + } + if Classify(newStatusError(503, "x")) != KindProvider || Classify(newStatusError(400, "x")) != KindUnexpected || Classify(errors.New("dial")) != KindUnreachable { + t.Fatal("classification") + } +} + +// ── the book ──────────────────────────────────────────────────────────────── + +type clock struct { + mu sync.Mutex + t time.Time +} + +func (c *clock) now() time.Time { c.mu.Lock(); defer c.mu.Unlock(); return c.t } +func (c *clock) add(d time.Duration) { + c.mu.Lock() + c.t = c.t.Add(d) + c.mu.Unlock() +} + +func usd(v float64) *float64 { return &v } + +func newTestBook() (*Book, *clock) { + c := &clock{t: time.Date(2026, 10, 15, 12, 0, 0, 0, time.UTC)} + return NewBook(context.Background(), c.now), c +} + +func TestBookSingleflightsConcurrentCallers(t *testing.T) { + b, _ := newTestBook() + id := NewIdentity("openrouter", "a", "k1") + var calls atomic.Int32 + release := make(chan struct{}) + fetch := func(context.Context) (Detail, error) { + calls.Add(1) + <-release + return Detail{AccountRemaining: usd(5)}, nil + } + var wg sync.WaitGroup + snaps := make([]Snapshot, 6) + for i := range snaps { + wg.Add(1) + go func() { + defer wg.Done() + snaps[i], _ = b.Get(context.Background(), id, fetch) + }() + } + for calls.Load() == 0 { + time.Sleep(time.Millisecond) + } + time.Sleep(20 * time.Millisecond) + close(release) + wg.Wait() + if calls.Load() != 1 { + t.Fatalf("%d requests for one account", calls.Load()) + } + for _, s := range snaps { + if !s.HasGood || *s.Good.AccountRemaining != 5 { + t.Fatalf("%+v", s) + } + } +} + +func TestBookWaitersAreBoundedAndLeaveOnTheirOwnContext(t *testing.T) { + b, _ := newTestBook() + id := NewIdentity("openrouter", "a", "k1") + release := make(chan struct{}) + started := make(chan struct{}) + var once sync.Once + fetch := func(context.Context) (Detail, error) { + once.Do(func() { close(started) }) + <-release + return Detail{AccountRemaining: usd(1)}, nil + } + var wg sync.WaitGroup + wg.Add(1) + go func() { defer wg.Done(); _, _ = b.Get(context.Background(), id, fetch) }() + <-started + ctx, cancel := context.WithCancel(context.Background()) + var waiters sync.WaitGroup + for i := 0; i < WaitersMax; i++ { + waiters.Add(1) + go func() { defer waiters.Done(); _, _ = b.Get(ctx, id, fetch) }() + } + for { + b.mu.Lock() + n := b.entries[id].waiting + b.mu.Unlock() + if n == WaitersMax { + break + } + time.Sleep(time.Millisecond) + } + s, err := b.Get(context.Background(), id, fetch) + if err != nil || !s.Busy { + t.Fatalf("the ninth caller waited: %+v %v", s, err) + } + cancel() + waiters.Wait() + close(release) + wg.Wait() +} + +func TestBookRateLimitsPerAccountEvenAfterAFailure(t *testing.T) { + b, c := newTestBook() + id := NewIdentity("openrouter", "a", "k1") + var calls int + outcome := func(context.Context) (Detail, error) { + calls++ + return Detail{}, errors.New("dial") + } + s, _ := b.Get(context.Background(), id, outcome) + if calls != 1 || s.Err == nil || s.LastError.IsZero() { + t.Fatalf("%+v", s) + } + c.add(59 * time.Second) + s, _ = b.Get(context.Background(), id, outcome) + if calls != 1 || s.RetryAfter != time.Second { + t.Fatalf("calls %d, retry %v", calls, s.RetryAfter) + } + c.add(time.Second) + b.Get(context.Background(), id, outcome) + if calls != 2 { + t.Fatalf("calls %d after the minute", calls) + } +} + +func TestBookKeepsLastGoodThroughATransientFailureOnly(t *testing.T) { + b, c := newTestBook() + id := NewIdentity("openrouter", "a", "k1") + ok := func(context.Context) (Detail, error) { return Detail{AccountRemaining: usd(4)}, nil } + b.Get(context.Background(), id, ok) + c.add(MinGap) + s, _ := b.Get(context.Background(), id, func(context.Context) (Detail, error) { return Detail{}, newStatusError(503, "x") }) + if !s.HasGood || *s.Good.AccountRemaining != 4 || s.Err == nil || s.LastSuccess.IsZero() || s.LastError.IsZero() { + t.Fatalf("transient failure lost the last good: %+v", s) + } + c.add(MinGap) + s, _ = b.Get(context.Background(), id, func(context.Context) (Detail, error) { return Detail{}, newStatusError(401, "x") }) + if s.HasGood || Classify(s.Err) != KindKeyRejected { + t.Fatalf("a rejected key kept its old figures: %+v", s) + } + c.add(MinGap) + b.Get(context.Background(), id, ok) + c.add(MinGap) + s, _ = b.Get(context.Background(), id, func(context.Context) (Detail, error) { return Detail{}, nil }) + if s.HasGood { + t.Fatalf("a reply with no figures kept old ones: %+v", s) + } +} + +func TestBookCredentialChangeDropsTheOldAccountAtOnce(t *testing.T) { + b, _ := newTestBook() + old := NewIdentity("openrouter", "a", "k1") + b.Get(context.Background(), old, func(context.Context) (Detail, error) { return Detail{AccountRemaining: usd(9)}, nil }) + // No minute has passed; a new key is asked immediately and sees nothing of the old. + var saw bool + s, _ := b.Get(context.Background(), NewIdentity("openrouter", "a", "k2"), func(context.Context) (Detail, error) { + saw = true + return Detail{}, errors.New("dial") + }) + if !saw || s.HasGood || b.Len() != 1 { + t.Fatalf("saw=%v len=%d %+v", saw, b.Len(), s) + } + // A request in flight for the old key is cancelled and its answer discarded. + b2, _ := newTestBook() + started := make(chan struct{}) + res := make(chan Snapshot, 1) + go func() { + s, _ := b2.Get(context.Background(), old, func(ctx context.Context) (Detail, error) { + close(started) + <-ctx.Done() + return Detail{AccountRemaining: usd(9)}, nil + }) + res <- s + }() + <-started + b2.Get(context.Background(), NewIdentity("openrouter", "a", "k2"), func(context.Context) (Detail, error) { return Detail{}, nil }) + if s := <-res; s.HasGood || !s.Busy { + t.Fatalf("the old account's answer escaped: %+v", s) + } +} + +func TestBookIsBoundedAndEvictsOnlyIdleEntries(t *testing.T) { + b, _ := newTestBook() + ok := func(context.Context) (Detail, error) { return Detail{AccountRemaining: usd(1)}, nil } + for i := 0; i < BookMax+5; i++ { + b.Get(context.Background(), NewIdentity(string(rune('a'+i)), "a", "k"), ok) + if b.Len() > BookMax { + t.Fatalf("book holds %d", b.Len()) + } + } + // Every slot busy: a new account is refused, not queued. + full, _ := newTestBook() + release := make(chan struct{}) + started := make(chan struct{}, BookMax) + var wg sync.WaitGroup + for i := 0; i < BookMax; i++ { + wg.Add(1) + go func() { + defer wg.Done() + full.Get(context.Background(), NewIdentity(string(rune('a'+i)), "a", "k"), func(context.Context) (Detail, error) { + started <- struct{}{} + <-release + return Detail{}, nil + }) + }() + } + for i := 0; i < BookMax; i++ { + <-started + } + s, err := full.Get(context.Background(), NewIdentity("zz", "a", "k"), ok) + if err != nil || !s.Busy || full.Len() != BookMax { + t.Fatalf("%+v %v len %d", s, err, full.Len()) + } + close(release) + wg.Wait() +} + +func TestBookCancelledCallerTakesItsRequestAndDoesNotSpendTheMinute(t *testing.T) { + b, _ := newTestBook() + id := NewIdentity("openrouter", "a", "k1") + ctx, cancel := context.WithCancel(context.Background()) + _, err := b.Get(ctx, id, func(c context.Context) (Detail, error) { + cancel() + <-c.Done() + return Detail{}, c.Err() + }) + if !errors.Is(err, context.Canceled) { + t.Fatalf("%v", err) + } + var calls int + s, _ := b.Get(context.Background(), id, func(context.Context) (Detail, error) { calls++; return Detail{AccountRemaining: usd(2)}, nil }) + if calls != 1 || !s.HasGood { + t.Fatalf("an abandoned attempt used up the minute: %d %+v", calls, s) + } +} + +func TestBookBaseCancelEndsRequestsInFlight(t *testing.T) { + base, stop := context.WithCancel(context.Background()) + b := NewBook(base, nil) + started := make(chan struct{}) + done := make(chan struct{}) + go func() { + defer close(done) + b.Get(context.Background(), NewIdentity("openrouter", "a", "k"), func(c context.Context) (Detail, error) { + close(started) + <-c.Done() + return Detail{}, c.Err() + }) + }() + <-started + stop() + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("a closed process left a request running") + } +} + +func TestBookDropForgetsAProvider(t *testing.T) { + b, _ := newTestBook() + b.Get(context.Background(), NewIdentity("openrouter", "a", "k"), func(context.Context) (Detail, error) { return Detail{}, nil }) + b.Drop("openrouter") + if b.Len() != 0 { + t.Fatal("kept") + } +} diff --git a/internal/credits/credits.go b/internal/credits/credits.go index 05c4ce411d..a298f76974 100644 --- a/internal/credits/credits.go +++ b/internal/credits/credits.go @@ -5,6 +5,7 @@ package credits import ( "context" "encoding/json" + "errors" "fmt" "io" "math" @@ -31,11 +32,98 @@ type Reading struct{ Known, Low, Expired bool } // answered by the turn's own auth failure. const expiredWord = "expired" +// Detail is what the service reported, as numbers. A figure the service did +// not send is nil, never zero; a figure that is negative is the account's real +// state and is kept as sent. +type Detail struct { + // Expired is the service refusing the key as expired; no figure comes with it. + Expired bool + // AccountRemaining is total_credits minus total_usage, only when the service + // reported both. + AccountRemaining *float64 + // KeyLimitRemaining is the key's own spending cap left, only when the key + // has one and the service said how much of it remains. + KeyLimitRemaining *float64 +} + +// Known reports whether the service said anything about the balance: a figure +// or an expired key. +func (d Detail) Known() bool { + return d.Expired || d.AccountRemaining != nil || d.KeyLimitRemaining != nil +} + +// Remaining is the effective balance: the lesser of the account's and the key's +// when both are reported, whichever is reported when only one is, and false +// when neither is. +func (d Detail) Remaining() (float64, bool) { + switch { + case d.AccountRemaining != nil && d.KeyLimitRemaining != nil: + return math.Min(*d.AccountRemaining, *d.KeyLimitRemaining), true + case d.AccountRemaining != nil: + return *d.AccountRemaining, true + case d.KeyLimitRemaining != nil: + return *d.KeyLimitRemaining, true + } + return 0, false +} + +// StatusError is a service answer that was neither a reading nor a refusal the +// reader understands. Its words are the ones Read has always used. +type StatusError struct { + Status int + // Kind is what the answer means for a balance, decided where it was read. + Kind Kind + what string +} + +func (e *StatusError) Error() string { return fmt.Sprintf("%s answered %d", e.what, e.Status) } + +// newStatusError names what a status means to a balance read, once, here where +// the status is read: a refused key, a service that is failing or limiting, or +// an answer this reader does not know. +func newStatusError(code int, what string) *StatusError { + kind := KindUnexpected + switch { + case code == http.StatusUnauthorized: + kind = KindKeyRejected + case code == http.StatusRequestTimeout, code == http.StatusTooManyRequests, code >= 500: + kind = KindProvider + } + return &StatusError{Status: code, Kind: kind, what: what} +} + +// Errors a reader names so a caller can tell a bad answer from a missing one +// without parsing words. +var ( + ErrTooLarge = errors.New("credit response too large") + ErrInvalid = errors.New("invalid credit balance") + errDecode = errors.New("credit response could not be decoded") +) + // Read asks for the account balance and the key's own cap. An absent number is // unknown; a failed request is an error and must not change a previous reading. // A 401 that says the key has expired is not a failed request: it is a reading, // and the one fact the account can still tell us. func Read(ctx context.Context, client *http.Client, baseURL, key string) (Reading, error) { + d, err := ReadDetail(ctx, client, baseURL, key) + if err != nil { + return Reading{}, err + } + if d.Expired { + return Reading{Known: true, Expired: true}, nil + } + remaining, ok := d.Remaining() + if !ok { + return Reading{}, nil + } + return Reading{Known: true, Low: remaining <= LowCreditsUSD}, nil +} + +// ReadDetail is Read's one implementation, answering the amounts instead of the +// verdict: at most the key's own row and the account's credits, each read with +// a 64 KiB cap, under one ten-second deadline. A reported figure that is not +// finite makes the whole answer invalid rather than a guess. +func ReadDetail(ctx context.Context, client *http.Client, baseURL, key string) (Detail, error) { ctx, cancel := context.WithTimeout(ctx, 10*time.Second) defer cancel() if client == nil { @@ -59,24 +147,24 @@ func Read(ctx context.Context, client *http.Client, baseURL, key string) (Readin return nil, 0, err } if len(body) > 64<<10 { - return nil, 0, fmt.Errorf("credit response too large") + return nil, 0, ErrTooLarge } return body, resp.StatusCode, nil } keyBody, keyStatus, err := get("/key") if err != nil { - return Reading{}, err + return Detail{}, err } if keyStatus == http.StatusNotFound { keyBody, keyStatus, err = get("/auth/key") if err != nil { - return Reading{}, err + return Detail{}, err } } if keyExpired(keyStatus, keyBody) { - return Reading{Known: true, Expired: true}, nil + return Detail{Expired: true}, nil } - var capValue *float64 + var detail Detail switch keyStatus { case http.StatusOK: var row struct { @@ -85,21 +173,20 @@ func Read(ctx context.Context, client *http.Client, baseURL, key string) (Readin } `json:"data"` } if err := json.Unmarshal(keyBody, &row); err != nil { - return Reading{}, fmt.Errorf("decode key credits: %w", err) + return Detail{}, fmt.Errorf("decode key credits: %w", errors.Join(errDecode, err)) } - capValue = row.Data.Remaining + detail.KeyLimitRemaining = row.Data.Remaining case http.StatusForbidden, http.StatusNotFound: default: - return Reading{}, fmt.Errorf("key credits answered %d", keyStatus) + return Detail{}, newStatusError(keyStatus, "key credits") } creditsBody, creditsStatus, err := get("/credits") if err != nil { - return Reading{}, err + return Detail{}, err } if keyExpired(creditsStatus, creditsBody) { - return Reading{Known: true, Expired: true}, nil + return Detail{Expired: true}, nil } - var account *float64 switch creditsStatus { case http.StatusOK: var row struct { @@ -109,30 +196,22 @@ func Read(ctx context.Context, client *http.Client, baseURL, key string) (Readin } `json:"data"` } if err := json.Unmarshal(creditsBody, &row); err != nil { - return Reading{}, fmt.Errorf("decode account credits: %w", err) + return Detail{}, fmt.Errorf("decode account credits: %w", errors.Join(errDecode, err)) } if row.Data.Total != nil && row.Data.Usage != nil { remaining := *row.Data.Total - *row.Data.Usage - account = &remaining + detail.AccountRemaining = &remaining } case http.StatusForbidden, http.StatusNotFound: default: - return Reading{}, fmt.Errorf("account credits answered %d", creditsStatus) - } - if account == nil && capValue == nil { - return Reading{}, nil + return Detail{}, newStatusError(creditsStatus, "account credits") } - remaining := math.Inf(1) - if account != nil { - remaining = *account - } - if capValue != nil { - remaining = math.Min(remaining, *capValue) - } - if math.IsNaN(remaining) || math.IsInf(remaining, 0) { - return Reading{}, fmt.Errorf("invalid credit balance") + for _, f := range []*float64{detail.AccountRemaining, detail.KeyLimitRemaining} { + if f != nil && (math.IsNaN(*f) || math.IsInf(*f, 0)) { + return Detail{}, ErrInvalid + } } - return Reading{Known: true, Low: remaining <= LowCreditsUSD}, nil + return detail, nil } // keyExpired reports whether an answer is the service refusing the key as From 65a01a7b90c6c75c2888c33add05ee05beee4e31 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 23:24:29 -0400 Subject: [PATCH 113/459] app: spend.calls.export saves every matching ledger call as a twelve-column CSV Co-Authored-By: Claude Sonnet 5.5 --- cmd/codeaf/appspendcalls.go | 103 ++++ cmd/codeaf/appspendcalls_test.go | 542 ++++++++++++++++++ .../unreleased/0-app-spend-calls-export.md | 22 + internal/appwire/spend_calls.go | 172 ++++++ internal/appwire/spend_calls_test.go | 173 ++++++ internal/session/usage_calls.go | 133 +++++ internal/session/usage_calls_test.go | 350 +++++++++++ 7 files changed, 1495 insertions(+) create mode 100644 cmd/codeaf/appspendcalls.go create mode 100644 cmd/codeaf/appspendcalls_test.go create mode 100644 docs/changes/unreleased/0-app-spend-calls-export.md create mode 100644 internal/appwire/spend_calls.go create mode 100644 internal/appwire/spend_calls_test.go create mode 100644 internal/session/usage_calls.go create mode 100644 internal/session/usage_calls_test.go diff --git a/cmd/codeaf/appspendcalls.go b/cmd/codeaf/appspendcalls.go new file mode 100644 index 0000000000..7df25fdd6a --- /dev/null +++ b/cmd/codeaf/appspendcalls.go @@ -0,0 +1,103 @@ +package main + +// ── codeaf app: THE SPENDING CALLS, EVERY MATCHING CALL SAVED AS ONE CSV ──── +// +// spend.calls.export (internal/appwire/spend_calls.go) is served from the +// machine's usage ledger by session.EachUsageCall, which reads it as a stream +// and holds one line at a time. This file turns the wire's filter into the same +// query spend.ledger and spend.report use (appLedgerQuery), each call into its +// twelve cells, and puts the file where the person asked for it with the same +// whole-or-not-at-all writer as the ledger's export (appWriteWhole): a sibling +// 0600 temporary file, synced, then linked into place without overwrite or +// renamed with it — never a look-then-rename — and removed on any failure, +// leaving whatever was at the destination exactly as it was. +// +// NOTHING IS JOINED. The agent column is the recorded root conversation, else +// the line's own session; the task and the model are the ids the ledger wrote. +// No title is looked up, so nothing is read from the app's agents, tasks or +// automations and the cost of the file does not depend on them. A crew, a +// provider and an outcome are not recorded, so they are blank on every row, and +// the answer's not_recorded says so. + +import ( + "encoding/csv" + "io" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/session" +) + +// The app's backend serves spend.calls.export. +var _ appwire.SpendCallsExporter = (*appBackend)(nil) + +// appSpendCallsRun is what one pass over the ledger wrote. +type appSpendCallsRun struct { + rows int + totals session.UsageCallTotals + stats session.UsageScanStats +} + +// appSpendCallsFill is the pass that writes the file's header and one row per +// call to w, recording what it wrote in run. It is the whole of the CSV and is +// kept apart from the file so a test can hand it a writer that fails. +func appSpendCallsFill(ledger string, query session.UsageQuery, zone *time.Location, run *appSpendCallsRun) func(io.Writer) error { + return func(w io.Writer) error { + out := csv.NewWriter(w) + if err := appwire.NewSpendCallsCSV(out); err != nil { + return err + } + var err error + run.rows = 0 + run.totals, run.stats, err = session.EachUsageCall(ledger, query, func(call session.UsageCall) error { + run.rows++ + return out.Write(appwire.SpendCallsCSVRecord(appSpendCall(call, zone))) + }) + if err != nil { + return err + } + out.Flush() + return out.Error() + } +} + +// appSpendCall is one ledger record as a row of the file. The date and time are +// the call's instant in the zone the filter's days were read in. +func appSpendCall(call session.UsageCall, zone *time.Location) appwire.SpendCall { + row := appwire.SpendCall{ + Time: call.At.In(zone), Agent: call.Owner, Task: call.Task, Model: call.Model, + Requests: call.Requests, TokensIn: call.Input, TokensOut: call.Output, + } + if call.Priced { + usd := call.USD + row.CostUSD = &usd + } + return row +} + +// ExportSpendCalls writes every call the filter matches to the destination as +// CSV, whole or not at all. +func (b *appBackend) ExportSpendCalls(ask appwire.SpendCallsExportAsk) (appwire.SpendCallsExportResult, error) { + query, err := appLedgerQuery(ask.Filter) + if err != nil { + return appwire.SpendCallsExportResult{}, appLedgerError(err) + } + var run appSpendCallsRun + written, err := appWriteWhole(ask.Destination, ask.Overwrite, appSpendCallsFill(appLedgerPath(), query, appLedgerZone(), &run)) + if err != nil { + return appwire.SpendCallsExportResult{}, appLedgerError(err) + } + t := run.totals + totals := appwire.SpendCallsTotals{ + Records: t.Records, Calls: t.Requests, TokensIn: t.Input, TokensOut: t.Output, + UnpricedRecords: t.Unpriced, UnbilledRecords: t.Unbilled, + } + if t.Priced() { + usd := t.USD + totals.CostUSD = &usd + } + return appwire.SpendCallsExportResult{ + Path: ask.Destination, Rows: run.rows, Bytes: written, Totals: totals, + Skipped: appwire.SpendLedgerSkipped{Malformed: run.stats.Malformed, Oversized: run.stats.Oversized}, + }, nil +} diff --git a/cmd/codeaf/appspendcalls_test.go b/cmd/codeaf/appspendcalls_test.go new file mode 100644 index 0000000000..daefdf68a6 --- /dev/null +++ b/cmd/codeaf/appspendcalls_test.go @@ -0,0 +1,542 @@ +package main + +import ( + "bytes" + "errors" + "fmt" + "io" + "math" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "syscall" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/session" +) + +const callsCent = 1.0 / 1024 + +// callsFixture is two agents' day: chatA's own lines and its task's lines +// (the task's session is a node's, rooted at chatA), chatB's, an older line +// covering 41 requests, an unpriced one, an unbilled one, a call a minute +// before local midnight and one just after. +func callsFixture(zone *time.Location) []session.UsageLine { + at := func(d, h, m int) time.Time { return time.Date(2026, 10, d, h, m, 0, 0, zone) } + line := func(l session.UsageLine) session.UsageLine { l.Day = l.At.Format("2006-01-02"); return l } + return []session.UsageLine{ + line(session.UsageLine{At: at(14, 8, 0), Session: "chatA", Model: "m/one", Calls: 1, Input: 10, Output: 2, USD: 0.0123456789012345, Run: "r1"}), + line(session.UsageLine{At: at(14, 8, 5), Session: "node1", Root: "chatA", Task: "3", Model: "m/two", Calls: 1, Input: 7, Output: 1, USD: callsCent, Run: "r2"}), + line(session.UsageLine{At: at(14, 8, 6), Session: "chatB", Model: "m/one", Calls: 1, Input: 5, Output: 5, USD: 2 * callsCent}), + line(session.UsageLine{At: at(14, 9, 0), Session: "chatA", Model: "m/one", Calls: 41, Input: 400, Output: 40, USD: 4 * callsCent}), + line(session.UsageLine{At: at(14, 9, 1), Session: "chatA", Calls: 1, Input: 1, Output: 1}), + line(session.UsageLine{At: at(14, 9, 2), Session: "node1", Root: "chatA", Task: "3", Model: "m/two", Calls: 1, Input: 2, Output: 2, Unbilled: true}), + line(session.UsageLine{At: at(14, 23, 59), Session: "chatB", Model: "m/two", Input: 3, Output: 3, USD: callsCent}), + line(session.UsageLine{At: at(15, 0, 0), Session: "chatA", Model: "m/one", Calls: 1, Input: 1, Output: 1, USD: callsCent}), + } +} + +func callsFilter() appwire.SpendLedgerFilter { + return appwire.SpendLedgerFilter{From: "2026-10-14", To: "2026-10-14"} +} + +func sumColumn(t *testing.T, rows [][]string, col int) (sum float64, blanks int) { + t.Helper() + for _, r := range rows[1:] { + if r[col] == "" { + blanks++ + continue + } + v, err := strconv.ParseFloat(r[col], 64) + if err != nil { + t.Fatal(err) + } + sum += v + } + return sum, blanks +} + +func sumInts(t *testing.T, rows [][]string, col int) (sum int64) { + t.Helper() + for _, r := range rows[1:] { + v, err := strconv.ParseInt(r[col], 10, 64) + if err != nil { + t.Fatal(err) + } + sum += v + } + return sum +} + +// THE FILE IS WHAT THE FILTER SAYS, in the order it was recorded: twelve +// columns, the agent's chat and task lines together and not the other +// agent's, the date and time in the engine's zone, the amount at full +// precision and blank where nobody priced it, the older line's 41 requests +// kept as 41, and crew, provider and outcome blank on every row. +func TestAppSpendCallsExportWritesTheFilteredCalls(t *testing.T) { + zone := time.FixedZone("Toronto", -4*3600) + ledgerAt(t, zone, callsFixture(zone), "") + b := &appBackend{} + dir := t.TempDir() + dest := filepath.Join(dir, "codeaf-spending-2026-10.csv") + filter := callsFilter() + filter.Agent = "chatA" + out, err := b.ExportSpendCalls(appwire.SpendCallsExportAsk{Filter: filter, Destination: dest}) + if err != nil { + t.Fatal(err) + } + rows := readCSV(t, dest) + if got := strings.Join(rows[0], ","); got != "date,time,crew,agent,task,model,provider,outcome,amount_usd,requests,tokens_in,tokens_out" { + t.Fatalf("header %q", got) + } + if len(rows) != 6 || out.Rows != 5 || out.Totals.Records != 5 { + t.Fatalf("%d rows, answer %+v", len(rows), out) + } + want := [][]string{ + {"2026-10-14", "08:00:00", "", "chatA", "", "m/one", "", "", "0.0123456789012345", "1", "10", "2"}, + {"2026-10-14", "08:05:00", "", "chatA", "3", "m/two", "", "", "0.0009765625", "1", "7", "1"}, + {"2026-10-14", "09:00:00", "", "chatA", "", "m/one", "", "", "0.00390625", "41", "400", "40"}, + {"2026-10-14", "09:01:00", "", "chatA", "", "", "", "", "", "1", "1", "1"}, + {"2026-10-14", "09:02:00", "", "chatA", "3", "m/two", "", "", "", "1", "2", "2"}, + } + for i, w := range want { + if strings.Join(rows[i+1], "|") != strings.Join(w, "|") { + t.Fatalf("row %d:\n got %q\nwant %q", i, rows[i+1], w) + } + } + usd, blank := sumColumn(t, rows, 8) + if out.Totals.CostUSD == nil || *out.Totals.CostUSD != usd || out.Totals.UnpricedRecords != blank || blank != 2 || + out.Totals.UnbilledRecords != 1 || out.Totals.Calls != int(sumInts(t, rows, 9)) || out.Totals.Calls != 45 || + out.Totals.TokensIn != sumInts(t, rows, 10) || out.Totals.TokensOut != sumInts(t, rows, 11) { + t.Fatalf("the answer and the file disagree: %+v (usd %v, blanks %d)", out.Totals, usd, blank) + } + if info, err := os.Stat(dest); err != nil || info.Size() != out.Bytes || info.Mode().Perm() != 0o600 { + t.Fatalf("%v %v bytes %d", info, err, out.Bytes) + } + if out.Path != dest || out.Skipped != (appwire.SpendLedgerSkipped{}) { + t.Fatalf("%+v", out) + } + if entries, _ := os.ReadDir(dir); len(entries) != 1 { + t.Fatalf("%d entries beside the export", len(entries)) + } + + // chatB is the other agent's; the whole day is both, and the midnight edge + // is the engine's local midnight, not UTC's. + for agent, count := range map[string]int{"chatB": 2, "": 7, "nobody": 0} { + f := callsFilter() + f.Agent = agent + res, err := b.ExportSpendCalls(appwire.SpendCallsExportAsk{Filter: f, Destination: filepath.Join(dir, "x-"+agent+".csv")}) + if err != nil || res.Rows != count || len(readCSV(t, res.Path)) != count+1 { + t.Fatalf("agent %q: %v %+v", agent, err, res) + } + } +} + +// EVERY FILTER IS THE LEDGER'S AND THE REPORT'S: the file's sums are the same +// figures spend.ledger and spend.report state for the same filter. +func TestAppSpendCallsExportAgreesWithLedgerAndReport(t *testing.T) { + zone := time.FixedZone("Toronto", -4*3600) + ledgerAt(t, zone, callsFixture(zone), "") + b := &appBackend{} + dir := t.TempDir() + for name, mutate := range map[string]func(*appwire.SpendLedgerFilter){ + "day": func(*appwire.SpendLedgerFilter) {}, + "agent": func(f *appwire.SpendLedgerFilter) { f.Agent = "chatA" }, + "model": func(f *appwire.SpendLedgerFilter) { f.Model = "m/two" }, + "task": func(f *appwire.SpendLedgerFilter) { f.Kind, f.Subject, f.Root = appwire.SpendKindTask, "3", "chatA" }, + "run": func(f *appwire.SpendLedgerFilter) { f.Run = "r1" }, + "two days": func(f *appwire.SpendLedgerFilter) { + f.To = "2026-10-15" + }, + } { + filter := callsFilter() + mutate(&filter) + out, err := b.ExportSpendCalls(appwire.SpendCallsExportAsk{Filter: filter, Destination: filepath.Join(dir, name+".csv")}) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + page, err := b.SpendLedger(appwire.SpendLedgerAsk{Filter: filter, Limit: appwire.SpendLedgerLimitMax}) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + report, err := b.SpendReport(appwire.SpendReportAsk{Filter: filter}) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + lt, rt, ct := page.Totals, report.Totals, out.Totals + if ct.Records != lt.Records || ct.Records != rt.Records || ct.Calls != lt.Calls || ct.Calls != rt.Calls || + ct.TokensIn != lt.TokensIn || ct.TokensIn != rt.TokensIn || ct.TokensOut != lt.TokensOut || ct.TokensOut != rt.TokensOut || + ct.UnpricedRecords != lt.UnpricedRecords || ct.UnpricedRecords != rt.UnpricedRecords || + ct.UnbilledRecords != lt.UnbilledRecords || ct.UnbilledRecords != rt.UnbilledRecords { + t.Fatalf("%s: file %+v, ledger %+v, report %+v", name, ct, lt, rt) + } + if (ct.CostUSD == nil) != (lt.CostUSD == nil) || (ct.CostUSD == nil) != (rt.CostUSD == nil) || + (ct.CostUSD != nil && (math.Abs(*ct.CostUSD-*lt.CostUSD) > 1e-12 || math.Abs(*ct.CostUSD-*rt.CostUSD) > 1e-12)) { + t.Fatalf("%s: cost file %v, ledger %v, report %v", name, ct.CostUSD, lt.CostUSD, rt.CostUSD) + } + rows := readCSV(t, out.Path) + if usd, _ := sumColumn(t, rows, 8); (ct.CostUSD != nil && usd != *ct.CostUSD) || (ct.CostUSD == nil && usd != 0) { + t.Fatalf("%s: the file sums to %v, the answer says %v", name, usd, ct.CostUSD) + } + } +} + +// THE EXACT BYTES OF AWKWARD TEXT SURVIVE: an id with a comma, quotes, a +// newline and multibyte text, and a destination with spaces, a quote, multibyte +// text and a TRAILING SPACE are used as given. (A model id is trimmed, as the +// filters trim, so its trailing space is the one thing not kept.) +func TestAppSpendCallsExportKeepsAwkwardTextAndPathsExact(t *testing.T) { + at := time.Date(2026, 10, 14, 12, 0, 0, 0, time.UTC) + odd := "mødel, \"q\"\nnew line é日本 " + ledgerAt(t, time.UTC, []session.UsageLine{ + {At: at, Day: "2026-10-14", Session: "chat é", Task: "t,1", Model: odd, Calls: 1, Input: 1, Output: 1, USD: callsCent}, + {At: at, Day: "2026-10-14", Session: "=chat", Model: "-m", Calls: 1, Input: 1, Output: 1}, + }, "") + b := &appBackend{} + dir := t.TempDir() + dest := filepath.Join(dir, " spend é \"q\", 日本.csv ") + if _, err := b.ExportSpendCalls(appwire.SpendCallsExportAsk{Filter: callsFilter(), Destination: dest}); err != nil { + t.Fatal(err) + } + entries, _ := os.ReadDir(dir) + if len(entries) != 1 || entries[0].Name() != filepath.Base(dest) { + t.Fatalf("the file is %q, not %q", entries[0].Name(), filepath.Base(dest)) + } + rows := readCSV(t, dest) + if rows[1][3] != "chat é" || rows[1][4] != "t,1" || rows[1][5] != strings.TrimSpace(odd) { + t.Fatalf("%q", rows[1]) + } + // A cell a spreadsheet would run is written inert, as the ledger's export does. + if rows[2][3] != "'=chat" || rows[2][5] != "'-m" || rows[2][8] != "" { + t.Fatalf("%q", rows[2]) + } +} + +// AN EMPTY RANGE IS A VALID FILE: the header alone, no cost, no rows — also +// from a machine that has never spent anything. +func TestAppSpendCallsExportOfNothingIsJustTheHeader(t *testing.T) { + zone := time.UTC + for name, ledger := range map[string]func(){ + "empty range": func() { ledgerAt(t, zone, callsFixture(zone), "") }, + "no ledger file": func() { appLedgerPath = func() string { return filepath.Join(t.TempDir(), "absent.jsonl") } }, + } { + ledger() + dir := t.TempDir() + dest := filepath.Join(dir, "spend.csv") + filter := appwire.SpendLedgerFilter{From: "2025-01-01", To: "2025-01-02"} + if name == "no ledger file" { + filter = callsFilter() + } + out, err := (&appBackend{}).ExportSpendCalls(appwire.SpendCallsExportAsk{Filter: filter, Destination: dest}) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + raw, _ := os.ReadFile(dest) + const header = "date,time,crew,agent,task,model,provider,outcome,amount_usd,requests,tokens_in,tokens_out\n" + if string(raw) != header || out.Rows != 0 || out.Bytes != int64(len(header)) || out.Totals != (appwire.SpendCallsTotals{}) { + t.Fatalf("%s: %q %+v", name, raw, out) + } + if len(readCSV(t, dest)) != 1 { + t.Fatalf("%s: not a CSV of one record", name) + } + } +} + +// NO OVERWRITE IS NEVER A REPLACEMENT: an existing file is kept, a file created +// between the check and the move is kept (by a link that cannot replace, or by +// a refusal where links are not served), and nothing of ours is left behind. +func TestAppSpendCallsExportNeverReplacesAnExistingOrConcurrentFile(t *testing.T) { + ledgerAt(t, time.UTC, callsFixture(time.UTC), "") + b := &appBackend{} + ask := func(dest string, overwrite bool) appwire.SpendCallsExportAsk { + return appwire.SpendCallsExportAsk{Filter: callsFilter(), Destination: dest, Overwrite: overwrite} + } + dir := t.TempDir() + dest := filepath.Join(dir, "spend.csv") + if err := os.WriteFile(dest, []byte("the person's own file\n"), 0o644); err != nil { + t.Fatal(err) + } + _, err := b.ExportSpendCalls(ask(dest, false)) + if wireErrorCode(err) != appwire.CodeBadParams || !strings.Contains(err.Error(), "overwrite") { + t.Fatalf("%v", err) + } + keptIntact(t, dir, dest, "the person's own file\n") + + oldLink := appLinkFile + t.Cleanup(func() { appLinkFile = oldLink }) + for name, link := range map[string]func(string, string) error{ + "links": os.Link, + "no links": func(string, string) error { return &os.LinkError{Op: "link", Err: syscall.ENOTSUP} }, + } { + dir := t.TempDir() + dest := filepath.Join(dir, "spend.csv") + appLinkFile = func(from, to string) error { + if err := os.WriteFile(to, []byte("theirs\n"), 0o644); err != nil { + t.Fatal(err) + } + return link(from, to) + } + if _, err := b.ExportSpendCalls(ask(dest, false)); wireErrorCode(err) != appwire.CodeBadParams { + t.Fatalf("%s: %v", name, err) + } + keptIntact(t, dir, dest, "theirs\n") + } + + // Where links are not served and nothing is there, it is refused, not + // written by looking first and renaming after. + dir = t.TempDir() + dest = filepath.Join(dir, "spend.csv") + appLinkFile = func(string, string) error { return &os.LinkError{Op: "link", Err: syscall.EXDEV} } + if _, err := b.ExportSpendCalls(ask(dest, false)); wireErrorCode(err) != appwire.CodeBadParams { + t.Fatalf("%v", err) + } + if entries, _ := os.ReadDir(dir); len(entries) != 0 { + t.Fatalf("%d entries left by a refused export", len(entries)) + } + appLinkFile = oldLink + + // With overwrite the old file is replaced whole. + dest = filepath.Join(t.TempDir(), "spend.csv") + if err := os.WriteFile(dest, []byte("old\n"), 0o644); err != nil { + t.Fatal(err) + } + out, err := b.ExportSpendCalls(ask(dest, true)) + if err != nil || len(readCSV(t, dest)) != out.Rows+1 || out.Rows != 7 { + t.Fatalf("%v %+v", err, out) + } + // A destination that is not a file, or has no folder, is the asker's to fix. + for _, bad := range []string{filepath.Join(dir, "missing", "spend.csv"), dir} { + if _, err := b.ExportSpendCalls(ask(bad, true)); wireErrorCode(err) != appwire.CodeBadParams { + t.Fatalf("%s: %v", bad, err) + } + } +} + +// A figure the ledger cannot add up is refused by name after the file was +// begun: the part is removed and the destination is exactly as it was. +func TestAppSpendCallsExportRefusalCleansItsPartAndKeepsTheDestination(t *testing.T) { + at := time.Date(2026, 10, 14, 6, 0, 0, 0, time.UTC) + good := session.UsageLine{At: at, Session: "s", Input: 1, USD: callsCent} + for name, bad := range map[string]session.UsageLine{ + "cost overflow": {At: at, Session: "s", USD: math.MaxFloat64}, + "tokens overflow": {At: at, Session: "s", Input: math.MaxInt}, + "negative tokens": {At: at, Session: "s", Output: -1}, + "negative cost": {At: at, Session: "s", Input: 1, USD: -1}, + } { + lines := []session.UsageLine{good, bad} + switch name { + case "cost overflow": + lines = []session.UsageLine{bad, bad} + case "tokens overflow": + lines = []session.UsageLine{bad, bad} + } + ledgerAt(t, time.UTC, lines, "") + dir := t.TempDir() + dest := filepath.Join(dir, "spend.csv") + if err := os.WriteFile(dest, []byte("kept\n"), 0o644); err != nil { + t.Fatal(err) + } + _, err := (&appBackend{}).ExportSpendCalls(appwire.SpendCallsExportAsk{Filter: callsFilter(), Destination: dest, Overwrite: true}) + if wireErrorCode(err) != appwire.CodeBadParams || !errors.Is(err, session.ErrUsageLedgerFigures) && !strings.Contains(err.Error(), "invalid") { + t.Fatalf("%s: %v", name, err) + } + keptIntact(t, dir, dest, "kept\n") + } +} + +// failingWriter accepts n bytes and then fails, as a full disk does. +type failingWriter struct { + w io.Writer + n int +} + +var errDiskFull = errors.New("no space left on device") + +func (f *failingWriter) Write(p []byte) (int, error) { + if len(p) > f.n { + wrote := f.n + f.n = 0 + if wrote > 0 { + _, _ = f.w.Write(p[:wrote]) + } + return wrote, errDiskFull + } + f.n -= len(p) + return f.w.Write(p) +} + +// A WRITE THAT FAILS PART WAY, through the real whole-file writer and the real +// pass: the error is the writer's, the part is removed, the old destination is +// untouched — whether the failure comes before the header, in the middle, or at +// the final flush. +func TestAppSpendCallsExportWriterFailureCleansUp(t *testing.T) { + zone := time.UTC + var lines []session.UsageLine + for i := range 400 { + at := time.Date(2026, 10, 14, 6, 0, 0, 0, zone).Add(time.Duration(i) * time.Second) + lines = append(lines, session.UsageLine{At: at, Session: "chatA", Task: strconv.Itoa(i), Model: "m/some-long-model-name", Calls: 1, Input: i + 1, Output: 1, USD: callsCent}) + } + ledgerAt(t, zone, lines, "") + query, err := appLedgerQuery(callsFilter()) + if err != nil { + t.Fatal(err) + } + // How long the whole file is, so the failures can be spread across it. + var whole bytes.Buffer + var probe appSpendCallsRun + if err := appSpendCallsFill(appLedgerPath(), query, zone, &probe)(&whole); err != nil || probe.rows != 400 { + t.Fatalf("%v %d", err, probe.rows) + } + for _, at := range []int{0, 3, 100, 4096, whole.Len() / 2, whole.Len() - 1} { + dir := t.TempDir() + dest := filepath.Join(dir, "spend.csv") + if err := os.WriteFile(dest, []byte("kept\n"), 0o644); err != nil { + t.Fatal(err) + } + var run appSpendCallsRun + fill := appSpendCallsFill(appLedgerPath(), query, zone, &run) + _, err := appWriteWhole(dest, true, func(w io.Writer) error { return fill(&failingWriter{w: w, n: at}) }) + if !errors.Is(err, errDiskFull) { + t.Fatalf("fails at %d: %v", at, err) + } + keptIntact(t, dir, dest, "kept\n") + // And with nothing at the destination, nothing is left at all. + fresh := filepath.Join(dir, "fresh.csv") + _, err = appWriteWhole(fresh, false, func(w io.Writer) error { return fill(&failingWriter{w: w, n: at}) }) + if !errors.Is(err, errDiskFull) { + t.Fatalf("fails at %d: %v", at, err) + } + keptIntact(t, dir, dest, "kept\n") + if _, err := os.Stat(fresh); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("a failed export left %s", fresh) + } + } + // A pass that is stopped by its caller is the same failure. + stop := errors.New("stopped") + dir := t.TempDir() + dest := filepath.Join(dir, "spend.csv") + _, err = appWriteWhole(dest, false, func(w io.Writer) error { + out := appSpendCallsFill(appLedgerPath(), query, zone, &appSpendCallsRun{}) + if err := out(&failingWriter{w: w, n: 1 << 30}); err != nil { + return err + } + return stop + }) + if !errors.Is(err, stop) { + t.Fatalf("%v", err) + } + if entries, _ := os.ReadDir(dir); len(entries) != 0 { + t.Fatalf("%d entries left", len(entries)) + } +} + +// ONE PAIR OF SCAN SLOTS FOR EVERY LEDGER READER: busy is the asker's retry, the +// destination is untouched, and the slots are all free afterwards. +func TestAppSpendCallsExportIsBusyWhenTheSlotsAreTaken(t *testing.T) { + ledgerAt(t, time.UTC, callsFixture(time.UTC), "") + dir := t.TempDir() + dest := filepath.Join(dir, "spend.csv") + if err := os.WriteFile(dest, []byte("kept\n"), 0o644); err != nil { + t.Fatal(err) + } + before := runtime.NumGoroutine() + release := fillScanSlots(t) + _, err := (&appBackend{}).ExportSpendCalls(appwire.SpendCallsExportAsk{Filter: callsFilter(), Destination: dest, Overwrite: true}) + release() + if wireErrorCode(err) != appwire.CodeBusy { + t.Fatalf("%v", err) + } + keptIntact(t, dir, dest, "kept\n") + if _, err := (&appBackend{}).ExportSpendCalls(appwire.SpendCallsExportAsk{Filter: callsFilter(), Destination: dest, Overwrite: true}); err != nil { + t.Fatalf("after release: %v", err) + } + if after := runtime.NumGoroutine(); after > before+1 { + t.Fatalf("goroutines %d -> %d", before, after) + } +} + +// THE RUN-GRAIN EXPORT IS UNTOUCHED: same eight-plus-fifteen columns, same +// rows, and this file adds a method without changing it. +func TestAppSpendCallsLeavesTheLedgerExportAlone(t *testing.T) { + ledgerAt(t, time.UTC, callsFixture(time.UTC), "") + dest := filepath.Join(t.TempDir(), "ledger.csv") + out, err := (&appBackend{}).ExportSpendLedger(appwire.SpendLedgerExportAsk{Filter: callsFilter(), Destination: dest}) + if err != nil { + t.Fatal(err) + } + rows := readCSV(t, dest) + if len(rows[0]) != 23 || rows[0][0] != "time" || rows[0][7] != "by" || len(rows) != out.Rows+1 || len(appwire.SpendLedgerCSVHeader) != 23 { + t.Fatalf("%q", rows[0]) + } + // Two runs (r1, r2) are one row each and five lines carry no run: 7 rows. + if out.Rows != 7 { + t.Fatalf("the run-grain export changed: %d rows", out.Rows) + } +} + +func BenchmarkAppSpendCallsExport(b *testing.B) { + for _, n := range []int{10_000, 100_000} { + b.Run(fmt.Sprintf("%dk", n/1000), func(b *testing.B) { + var sb strings.Builder + base := time.Date(2026, 10, 14, 0, 0, 0, 0, time.UTC) + for i := range n { + at := base.Add(time.Duration(i) * 400 * time.Millisecond) + fmt.Fprintf(&sb, `{"at":%q,"day":"2026-10-14","model":"m/%d","calls":1,"in":3,"out":2,"usd":0.0009765625,"session":"s%04d","root":"a%03d","task":"%d"}`+"\n", + at.Format(time.RFC3339Nano), i%7, i%1000, i%250, i%9) + } + ledger := filepath.Join(b.TempDir(), session.UsageLedgerName) + if err := os.WriteFile(ledger, []byte(sb.String()), 0o600); err != nil { + b.Fatal(err) + } + sb.Reset() + oldPath, oldZone := appLedgerPath, appLedgerZone + appLedgerPath = func() string { return ledger } + appLedgerZone = func() *time.Location { return time.UTC } + b.Cleanup(func() { appLedgerPath, appLedgerZone = oldPath, oldZone }) + dest := filepath.Join(b.TempDir(), "spend.csv") + info, _ := os.Stat(ledger) + b.ReportAllocs() + b.SetBytes(info.Size()) + var peak uint64 + for i := 0; i < b.N; i++ { + // Sample the LIVE heap (after a forced collection) from inside the + // pass, through the real writer, ten times; the CSV writer writes in + // 4 KiB pieces, so the samples are spread by write, not by row. + var m runtime.MemStats + runtime.GC() + runtime.ReadMemStats(&m) + baseline := m.HeapAlloc + writes := 0 + query, _ := appLedgerQuery(callsFilter()) + var run appSpendCallsRun + fill := appSpendCallsFill(appLedgerPath(), query, time.UTC, &run) + _, err := appWriteWhole(dest, true, func(w io.Writer) error { + return fill(sampler{w: w, tick: func() { + writes++ + if writes%(n/200) == 0 { + runtime.GC() + runtime.ReadMemStats(&m) + peak = max(peak, m.HeapAlloc-min(baseline, m.HeapAlloc)) + } + }}) + }) + if err != nil || run.rows != n { + b.Fatalf("%v %d", err, run.rows) + } + } + b.ReportMetric(float64(peak)/(1<<20), "live-heap-growth-MiB") + }) + } +} + +// sampler calls tick on every write. +type sampler struct { + w io.Writer + tick func() +} + +func (s sampler) Write(p []byte) (int, error) { s.tick(); return s.w.Write(p) } diff --git a/docs/changes/unreleased/0-app-spend-calls-export.md b/docs/changes/unreleased/0-app-spend-calls-export.md new file mode 100644 index 0000000000..18870e0111 --- /dev/null +++ b/docs/changes/unreleased/0-app-spend-calls-export.md @@ -0,0 +1,22 @@ +--- +kind: added +title: codeaf app saves every matching spending call as a twelve-column CSV +pr: 0 +surface: [engine] +--- + +Was true: the only per-call file was `spend.export` (the app's own folder, no +filter) and `spend.ledger.export` saved run-grain rows with its own 23 columns; +nothing saved the filtered calls in the Spending page's column order. + +Is true now: `spend.calls.export` takes `spend.ledger.export`'s params and +writes one row per ledger record with `date,time,crew,agent,task,model, +provider,outcome,amount_usd,requests,tokens_in,tokens_out`. `agent` is the +recorded root conversation, else the session, so a chat and its tasks both +match an agent filter. A record covering many requests stays one row with +those requests; an unpriced call has a blank amount. The ledger records no +crew, provider or outcome, so those three columns are blank on every row and +`not_recorded` says so. The answer has no run count: the pass keeps nothing +but the line it is on. Same whole-file, no-overwrite writer as the ledger +export. `spend.ledger.export` is unchanged; engines without the capability +answer `unknown_method`. diff --git a/internal/appwire/spend_calls.go b/internal/appwire/spend_calls.go new file mode 100644 index 0000000000..2c96ca76c8 --- /dev/null +++ b/internal/appwire/spend_calls.go @@ -0,0 +1,172 @@ +package appwire + +// ── THE SPENDING CALLS: every call the filter matches, one CSV row each ───── +// +// spend.calls.export is the generic per-call file beside spend.ledger.export. +// The ledger's export is run grain — a run is one row, and its columns say what +// the ledger holds about a run — and it stays exactly as it is. This one is the +// file a spending screen's "Export CSV" saves: THE SAME FILTER the ledger and +// the report take (local days, both included and required, plus the same exact +// matches, agent among them), written as twelve fixed columns, one row per +// ledger record, in the order the calls were recorded. +// +// ONE ROW IS ONE RECORD. A ledger line from an older build may cover many +// provider requests; its row says so in requests and is never split into +// invented single calls. The date and time are the call's instant in the +// engine's zone, the zone the filter's days were read in, so a row is always +// inside the range that selected it. +// +// UNKNOWN IS BLANK, AND THE ANSWER SAYS WHICH COLUMNS ARE ALWAYS BLANK. The +// ledger records no crew, no billing provider and no task outcome, and the +// engine does not join them from today's crews, today's routing or today's task +// states: what a crew or an outcome is now is not what it was when the money was +// spent. Those three columns are empty on every row and not_recorded names them, +// so a client never has to guess whether "empty" meant "none" or "unknown". A +// call nobody could price has a blank amount_usd — it is not $0 — and the +// answer's unpriced_records counts it. A future ledger that records one of these +// facts is a different version of this file, not a quiet change to this one. +// +// THE ANSWER CARRIES NO RUN COUNT. The file is written in one pass that keeps no +// set of the runs it meets, so it cannot say how many there were, and it does +// not say a number it did not compute. +// +// AN ENGINE WITHOUT THE CAPABILITY DOES NOT HAVE THE VERB: spend.calls.export +// answers unknown_method from an engine that is not a [SpendCallsExporter]. + +import ( + "encoding/csv" + "encoding/json" + "path/filepath" + "strconv" + "strings" + "time" +) + +// SpendCallsNotRecorded are the columns of the calls file the ledger does not +// hold, blank on every row. +var SpendCallsNotRecorded = []string{"crew", "provider", "outcome"} + +// SpendCallsCSVHeader is the calls file's twelve columns, in order. +var SpendCallsCSVHeader = []string{ + "date", "time", "crew", "agent", "task", "model", "provider", "outcome", + "amount_usd", "requests", "tokens_in", "tokens_out", +} + +// SpendCallsExportParams is spend.calls.export's params: the ledger's filter, +// an absolute destination, and whether an existing file there may be replaced. +type SpendCallsExportParams struct { + SpendLedgerFilter + Destination string `json:"destination"` + Overwrite bool `json:"overwrite,omitempty"` +} + +// SpendCallsExportAsk is spend.calls.export as the engine is handed it: a +// checked filter and a checked destination. +type SpendCallsExportAsk struct { + Filter SpendLedgerFilter + Destination string + Overwrite bool +} + +// SpendCallsTotals is exactly the rows written. records equals rows; requests +// are provider requests, which a record from an older build may carry many of. +// cost_usd is absent where no record carried a price. +type SpendCallsTotals struct { + Records int `json:"records"` + Calls int `json:"calls"` + TokensIn int64 `json:"tokens_in"` + TokensOut int64 `json:"tokens_out"` + CostUSD *float64 `json:"cost_usd,omitempty"` + UnpricedRecords int `json:"unpriced_records"` + UnbilledRecords int `json:"unbilled_records"` +} + +// SpendCallsExportResult is spend.calls.export's answer: where the file is, how +// many rows and bytes it holds, and the totals of exactly those rows. +type SpendCallsExportResult struct { + Path string `json:"path"` + Rows int `json:"rows"` + Bytes int64 `json:"bytes"` + Totals SpendCallsTotals `json:"totals"` + Skipped SpendLedgerSkipped `json:"skipped"` + NotRecorded []string `json:"not_recorded"` +} + +// SpendCallsExporter is the engine's half of the calls file. It is optional: an +// [Engine] that is not one refuses spend.calls.export as unknown_method. +type SpendCallsExporter interface { + ExportSpendCalls(ask SpendCallsExportAsk) (SpendCallsExportResult, error) +} + +// SpendCall is one ledger record as a row of the file. Time carries the zone +// the file is written in; every id is the one the ledger wrote. +type SpendCall struct { + Time time.Time + Agent string + Task string + Model string + CostUSD *float64 + Requests int + TokensIn int64 + TokensOut int64 +} + +// SpendCallsCSVRecord is one call as its twelve cells. crew, provider and +// outcome are empty because the ledger records none of them. The amount is +// written at its full precision — the shortest decimal that reads back as the +// same number — so a column of amounts adds up to the total the answer states; +// a call nobody could price is an empty cell. Every text cell that a +// spreadsheet would read as a formula is written with a leading apostrophe. +func SpendCallsCSVRecord(c SpendCall) []string { + amount := "" + if c.CostUSD != nil { + amount = strconv.FormatFloat(*c.CostUSD, 'f', -1, 64) + } + return []string{ + c.Time.Format("2006-01-02"), c.Time.Format("15:04:05"), "", csvText(c.Agent), csvText(c.Task), csvText(c.Model), "", "", + amount, strconv.Itoa(c.Requests), strconv.FormatInt(c.TokensIn, 10), strconv.FormatInt(c.TokensOut, 10), + } +} + +// NewSpendCallsCSV is a CSV writer with the header already written. +func NewSpendCallsCSV(w *csv.Writer) error { + return w.Write(SpendCallsCSVHeader) +} + +// checkSpendCallsDestination refuses a destination that is not the exact +// absolute path of the file to write. The path is used as given, trailing +// spaces and all: it is not trimmed, cleaned or rewritten. +func checkSpendCallsDestination(dest string) error { + if strings.TrimSpace(dest) == "" || strings.ContainsAny(dest, "\x00\r\n") || !filepath.IsAbs(dest) || filepath.Clean(dest) != dest { + return Errorf(CodeBadParams, "destination is an absolute, clean path to the file to write") + } + return nil +} + +func init() { + methods["spend.calls.export"] = spendCallsExportMethod +} + +var spendCallsExportMethod = shaped[SpendCallsExportParams](func(e Engine, raw json.RawMessage) (*SpendCallsExportResult, error) { + x, ok := e.(SpendCallsExporter) + if !ok { + return nil, Errorf(CodeUnknownMethod, "this engine does not serve %s", "spend.calls.export") + } + var p SpendCallsExportParams + if err := decodeStrict("spend.calls.export", raw, &p); err != nil { + return nil, err + } + filter, err := checkSpendLedgerFilter(p.SpendLedgerFilter) + if err != nil { + return nil, err + } + if err := checkSpendCallsDestination(p.Destination); err != nil { + return nil, err + } + result, err := x.ExportSpendCalls(SpendCallsExportAsk{Filter: filter, Destination: p.Destination, Overwrite: p.Overwrite}) + if err != nil { + return nil, err + } + result.NotRecorded = SpendCallsNotRecorded + return &result, nil +}) diff --git a/internal/appwire/spend_calls_test.go b/internal/appwire/spend_calls_test.go new file mode 100644 index 0000000000..162b86494a --- /dev/null +++ b/internal/appwire/spend_calls_test.go @@ -0,0 +1,173 @@ +package appwire + +import ( + "bytes" + "encoding/csv" + "strings" + "sync" + "testing" + "time" +) + +// callsEngine serves the calls file and remembers what it was asked. It is not +// a SpendLedgerer: the two optional capabilities are independent. +type callsEngine struct { + fakeEngine + mu sync.Mutex + asks []SpendCallsExportAsk + result SpendCallsExportResult +} + +func (c *callsEngine) ExportSpendCalls(ask SpendCallsExportAsk) (SpendCallsExportResult, error) { + c.mu.Lock() + c.asks = append(c.asks, ask) + c.mu.Unlock() + return c.result, nil +} + +func TestSpendCallsExportIsUnknownMethodWithoutTheCapability(t *testing.T) { + frame := serveOver(t, &fakeEngine{})(`{"id":1,"method":"spend.calls.export","params":{"from":"2026-10-01","to":"2026-10-15","destination":"/tmp/x.csv"}}`) + if frameCode(frame) != CodeUnknownMethod { + t.Fatalf("%v", frame) + } + // An engine with only the capability serves only it: the ledger's pair is + // still unknown there. + frame = serveOver(t, &callsEngine{})(`{"id":1,"method":"spend.ledger.export","params":{"from":"2026-10-01","to":"2026-10-15","destination":"/tmp/x.csv"}}`) + if frameCode(frame) != CodeUnknownMethod { + t.Fatalf("%v", frame) + } +} + +func TestSpendCallsExportOnTheWire(t *testing.T) { + usd := 0.5 + engine := &callsEngine{result: SpendCallsExportResult{ + Path: "/tmp/codeaf-spending-2026-10.csv", Rows: 2, Bytes: 99, + Totals: SpendCallsTotals{Records: 2, Calls: 3, TokensIn: 4, TokensOut: 5, CostUSD: &usd, UnpricedRecords: 1}, + }} + dest := "/tmp/dir with space/codeaf spending.csv " + frame := serveOver(t, engine)(`{"id":1,"method":"spend.calls.export","params":{"from":" 2026-10-01 ","to":"2026-10-31","agent":"chatA","model":"m/one","destination":"` + dest + `","overwrite":true}}`) + result, ok := frame["result"].(map[string]any) + if !ok { + t.Fatalf("no result: %v", frame) + } + if len(engine.asks) != 1 { + t.Fatalf("asked %d times", len(engine.asks)) + } + ask := engine.asks[0] + if ask.Filter.From != "2026-10-01" || ask.Filter.Agent != "chatA" || ask.Filter.Model != "m/one" || !ask.Overwrite { + t.Fatalf("the engine was asked %+v", ask) + } + if ask.Destination != dest { + t.Fatalf("the destination was rewritten: %q", ask.Destination) + } + totals := result["totals"].(map[string]any) + if totals["cost_usd"] != 0.5 || totals["calls"] != float64(3) || totals["records"] != float64(2) { + t.Fatalf("totals %v", totals) + } + // Nothing the pass did not compute is on the wire, and nothing is invented. + for _, absent := range []string{"runs", "unknown_run_records", "run_cost_usd", "unknown_run_cost_usd"} { + if _, there := totals[absent]; there { + t.Fatalf("totals claim %s: %v", absent, totals) + } + } + got := result["not_recorded"].([]any) + if len(got) != 3 || got[0] != "crew" || got[1] != "provider" || got[2] != "outcome" { + t.Fatalf("not_recorded %v", got) + } + if result["skipped"] == nil || result["path"] != "/tmp/codeaf-spending-2026-10.csv" { + t.Fatalf("%v", result) + } + + // No priced record, no cost: absent, not zero. + empty := &callsEngine{} + frame = serveOver(t, empty)(`{"id":1,"method":"spend.calls.export","params":{"from":"2026-10-01","to":"2026-10-31","destination":"/tmp/x.csv"}}`) + totals = frame["result"].(map[string]any)["totals"].(map[string]any) + if _, there := totals["cost_usd"]; there { + t.Fatalf("a range with no price has a cost: %v", totals) + } +} + +func TestSpendCallsExportRefusesWhatCannotBeAsked(t *testing.T) { + engine := &callsEngine{} + call := serveOver(t, engine) + for name, params := range map[string]string{ + "no range": `{"destination":"/tmp/x.csv"}`, + "no to": `{"from":"2026-10-01","destination":"/tmp/x.csv"}`, + "bad day": `{"from":"2026-13-01","to":"2026-10-01","destination":"/tmp/x.csv"}`, + "reversed": `{"from":"2026-10-02","to":"2026-10-01","destination":"/tmp/x.csv"}`, + "no destination": `{"from":"2026-10-01","to":"2026-10-02"}`, + "blank": `{"from":"2026-10-01","to":"2026-10-02","destination":" "}`, + "relative": `{"from":"2026-10-01","to":"2026-10-02","destination":"x.csv"}`, + "unclean": `{"from":"2026-10-01","to":"2026-10-02","destination":"/tmp/../x.csv"}`, + "newline": `{"from":"2026-10-01","to":"2026-10-02","destination":"/tmp/a\nb.csv"}`, + "nul": `{"from":"2026-10-01","to":"2026-10-02","destination":"/tmp/a\u0000b.csv"}`, + "unknown field": `{"from":"2026-10-01","to":"2026-10-02","destination":"/tmp/x.csv","crew":"x"}`, + "misspelt filter": `{"from":"2026-10-01","to":"2026-10-02","destination":"/tmp/x.csv","modle":"x"}`, + "cursor": `{"from":"2026-10-01","to":"2026-10-02","destination":"/tmp/x.csv","cursor":"x"}`, + "bad kind": `{"from":"2026-10-01","to":"2026-10-02","destination":"/tmp/x.csv","kind":"crew"}`, + "subject no kind": `{"from":"2026-10-01","to":"2026-10-02","destination":"/tmp/x.csv","subject":"x"}`, + "filter newline": `{"from":"2026-10-01","to":"2026-10-02","destination":"/tmp/x.csv","agent":"a\nb"}`, + "overwrite type": `{"from":"2026-10-01","to":"2026-10-02","destination":"/tmp/x.csv","overwrite":"yes"}`, + } { + frame := call(`{"id":1,"method":"spend.calls.export","params":` + params + `}`) + if frameCode(frame) != CodeBadParams { + t.Errorf("%s: %v", name, frame) + } + } + if len(engine.asks) != 0 { + t.Fatalf("a refused ask reached the engine: %+v", engine.asks) + } +} + +// THE FILE'S TWELVE COLUMNS, EXACTLY, and the three the ledger cannot fill are +// blank on every row. +func TestSpendCallsCSVHasTheTwelveColumns(t *testing.T) { + want := "date,time,crew,agent,task,model,provider,outcome,amount_usd,requests,tokens_in,tokens_out" + if strings.Join(SpendCallsCSVHeader, ",") != want || len(SpendCallsCSVHeader) != 12 { + t.Fatalf("%v", SpendCallsCSVHeader) + } + zone := time.FixedZone("Toronto", -4*3600) + usd := 0.0123456789012345 + small := 0.0000001 + var buf bytes.Buffer + w := csv.NewWriter(&buf) + if err := NewSpendCallsCSV(w); err != nil { + t.Fatal(err) + } + at := time.Date(2026, 10, 14, 23, 59, 58, 123, time.UTC).In(zone) + for _, c := range []SpendCall{ + {Time: at, Agent: "chatA", Task: "3", Model: "m/one", CostUSD: &usd, Requests: 41, TokensIn: 400, TokensOut: 40}, + {Time: at, Agent: "=cmd", Model: "weird, \"model\"\nname é ", CostUSD: &small, Requests: 1}, + {Time: at, Requests: 1, TokensIn: 1, TokensOut: 1}, + } { + if err := w.Write(SpendCallsCSVRecord(c)); err != nil { + t.Fatal(err) + } + } + w.Flush() + rows, err := csv.NewReader(&buf).ReadAll() + if err != nil { + t.Fatal(err) + } + if len(rows) != 4 { + t.Fatalf("%v", rows) + } + first := strings.Join(rows[1], "|") + if first != "2026-10-14|19:59:58||chatA|3|m/one|||0.0123456789012345|41|400|40" { + t.Fatalf("%q", first) + } + for i, row := range rows { + if len(row) != 12 { + t.Fatalf("row %d has %d cells", i, len(row)) + } + if i > 0 && (row[2] != "" || row[6] != "" || row[7] != "") { + t.Fatalf("a row claims a crew, provider or outcome: %v", row) + } + } + if rows[2][3] != "'=cmd" || rows[2][5] != "weird, \"model\"\nname é " || rows[2][8] != "0.0000001" { + t.Fatalf("%q", rows[2]) + } + if rows[3][8] != "" || rows[3][4] != "" || rows[3][5] != "" { + t.Fatalf("an unpriced call has an amount or a model: %q", rows[3]) + } +} diff --git a/internal/session/usage_calls.go b/internal/session/usage_calls.go new file mode 100644 index 0000000000..bce04e5ac0 --- /dev/null +++ b/internal/session/usage_calls.go @@ -0,0 +1,133 @@ +package session + +// ── THE LEDGER, ONE CALL AT A TIME: a visitor over exactly the lines a query matches ── +// +// [QueryUsageLedger] answers runs and calls, [QueryUsageReport] answers cells; +// a per-call export wants neither. It wants every ledger LINE the query matches, +// once, in the order they were recorded, with nothing grouped and nothing held. +// [EachUsageCall] is that and nothing more: one pass over a fixed-length prefix +// of the file, through the same [scanUsageLines] reader, the same +// [UsageQuery.matches] filter and the same checked [UsageLedgerTotals.count] +// that the page and the report use — so the same filter cannot select one set of +// lines here and another there. +// +// ONE LINE IS ONE CALL RECORD. A line from an older build may cover many +// provider requests ([UsageLine.Calls]); it stays one record whose Requests is +// what the line said, and it is never split into invented single calls. +// +// WHAT IT HOLDS IS ONE LINE. There is no slice of calls, no map of runs, and no +// set of anything it has met: the totals are a handful of counters, so the +// memory beside the scan does not grow with the history, only the time does. +// (That is a claim about what this function retains, not about the process's +// resident memory.) +// +// A FIGURE THAT CANNOT BE ADDED UP IS REFUSED, NEVER WRAPPED OR CUT. Besides +// everything [UsageLedgerTotals.count] refuses, a negative cost or a negative +// request count is refused here: the ledger totals would read the first as +// "unpriced" and the second as one request, and a per-call file must not print +// a figure its own line never held. +// +// IT TAKES THE SAME TWO SCAN SLOTS AS EVERY OTHER LEDGER READER, and a third +// reader is refused at once with [ErrUsageScanBusy] rather than queued. + +import ( + "fmt" + "math" + "strings" + "time" +) + +// UsageCall is one ledger line as a call record. Its strings are the line's own, +// trimmed; nothing is joined to it from today's agents, tasks or crews. +type UsageCall struct { + // Offset is the line's byte offset in the ledger, which never moves because + // the ledger is only ever appended to. + Offset int64 + // At is the instant the call was journaled. + At time.Time + // Owner is the recorded root conversation, else the line's own session + // ([UsageOwner]); empty where the line recorded neither. + Owner string + // Session, Root, Task and Run are the line's own ids. + Session, Root, Task, Run string + // Model is the provider's model id, and empty where nobody said. + Model string + // Requests is how many provider requests the line says it covers, never + // less than one. + Requests int + // Input and Output are the line's tokens. + Input, Output int64 + // Priced reports whether the line carried a figure. USD is meaningful only + // then: a line with none was not free, it was not priced. + Priced bool + USD float64 + // Unbilled marks a missing provider receipt. + Unbilled bool +} + +// UsageCallTotals is exactly the calls [EachUsageCall] handed over. It carries +// no run count: the pass does not keep the runs it meets, so it cannot say how +// many there were. +type UsageCallTotals struct { + Records, Requests int + Input, Output int64 + // USD is every priced line; it is meaningful only where Priced. + USD float64 + Unpriced, Unbilled int +} + +// Priced reports whether any line in the range carried a figure. +func (t UsageCallTotals) Priced() bool { return t.Records > t.Unpriced } + +// EachUsageCall hands fn every ledger line the query matches, in LEDGER ORDER, +// and answers the totals of exactly the lines it handed over. +// +// The pass reads no further than the ledger's length when it opened, so a line +// appended meanwhile is the next question's. An error from fn, or a figure that +// cannot be added up, ends the pass at once and answers no totals. A ledger that +// is not there is a machine that has spent nothing: no calls, empty totals. +func EachUsageCall(path string, q UsageQuery, fn func(UsageCall) error) (UsageCallTotals, UsageScanStats, error) { + q, err := q.normalized() + if err != nil { + return UsageCallTotals{}, UsageScanStats{}, err + } + file, done, err := openUsageScan(path) + if err != nil { + return UsageCallTotals{}, UsageScanStats{}, err + } + defer done() + if file == nil { + return UsageCallTotals{}, UsageScanStats{}, nil + } + info, err := file.Stat() + if err != nil { + return UsageCallTotals{}, UsageScanStats{}, err + } + var counted UsageLedgerTotals + stats, err := scanUsageLines(file, info.Size(), func(offset int64, line UsageLine) error { + line.Run = strings.TrimSpace(line.Run) + if !q.matches(line) { + return nil + } + if line.USD < 0 || line.Calls < 0 || math.IsNaN(line.USD) { + return fmt.Errorf("%w (a call at byte %d has a negative or invalid figure)", ErrUsageLedgerFigures, offset) + } + if err := counted.count(line); err != nil { + return err + } + return fn(UsageCall{ + Offset: offset, At: line.At, Owner: UsageOwner(line), + Session: strings.TrimSpace(line.Session), Root: strings.TrimSpace(line.Root), + Task: strings.TrimSpace(line.Task), Run: line.Run, Model: strings.TrimSpace(line.Model), + Requests: max(line.Calls, 1), Input: int64(line.Input), Output: int64(line.Output), + Priced: line.USD > 0, USD: line.USD, Unbilled: line.Unbilled, + }) + }) + if err != nil { + return UsageCallTotals{}, stats, err + } + return UsageCallTotals{ + Records: counted.Records, Requests: counted.Calls, Input: counted.Input, Output: counted.Output, + USD: counted.USD, Unpriced: counted.Unpriced, Unbilled: counted.Unbilled, + }, stats, nil +} diff --git a/internal/session/usage_calls_test.go b/internal/session/usage_calls_test.go new file mode 100644 index 0000000000..a9fd45f291 --- /dev/null +++ b/internal/session/usage_calls_test.go @@ -0,0 +1,350 @@ +package session + +import ( + "encoding/json" + "errors" + "fmt" + "math" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" +) + +// callFixture is one local day of calls across two agents: a chat's own lines +// (Session only), the same agent's task lines (a node session with Root set), +// another agent's, a run-less late receipt, an older line covering many +// requests, and an unpriced and an unbilled one. +func callFixture(zone *time.Location) []UsageLine { + at := func(h, m int) time.Time { return time.Date(2026, 10, 14, h, m, 0, 0, zone) } + day := func(t time.Time) string { return t.Format(usageDayLayout) } + line := func(l UsageLine) UsageLine { l.Day = day(l.At); return l } + return []UsageLine{ + line(UsageLine{At: at(8, 0), Session: "chatA", Model: "m/one", Calls: 1, Input: 10, Output: 2, USD: 0.0123456789012345, Run: "r1"}), + line(UsageLine{At: at(8, 5), Session: "node1", Root: "chatA", Task: "3", Model: "m/two", Calls: 1, Input: 7, Output: 1, USD: cent, Run: "r2"}), + line(UsageLine{At: at(8, 6), Session: "chatB", Model: "m/one", Calls: 1, Input: 5, Output: 5, USD: 2 * cent}), + line(UsageLine{At: at(9, 0), Session: "chatA", Model: "m/one", Calls: 41, Input: 400, Output: 40, USD: 4 * cent}), + line(UsageLine{At: at(9, 1), Session: "chatA", Calls: 1, Input: 1, Output: 1}), + line(UsageLine{At: at(9, 2), Session: "node1", Root: "chatA", Task: "3", Model: "m/two", Calls: 1, Input: 2, Output: 2, Unbilled: true}), + line(UsageLine{At: at(23, 59), Session: "chatB", Root: "", Model: "m/two", Calls: 0, Input: 3, Output: 3, USD: cent}), + // The next local day: outside a one-day range. + line(UsageLine{At: time.Date(2026, 10, 15, 0, 0, 0, 0, zone), Session: "chatA", Model: "m/one", Calls: 1, Input: 1, Output: 1, USD: cent}), + } +} + +type collected struct { + calls []UsageCall + totals UsageCallTotals + stats UsageScanStats +} + +func collectCalls(t *testing.T, path string, q UsageQuery) collected { + t.Helper() + var c collected + var err error + c.totals, c.stats, err = EachUsageCall(path, q, func(call UsageCall) error { + c.calls = append(c.calls, call) + return nil + }) + if err != nil { + t.Fatal(err) + } + return c +} + +func dayQuery(t *testing.T, zone *time.Location, from, to string) UsageQuery { + t.Helper() + start, end, err := UsageDayRange(from, to, zone) + if err != nil { + t.Fatal(err) + } + return UsageQuery{From: start, To: end} +} + +// THE VISITOR AND THE LEDGER AND THE REPORT ARE ONE QUESTION: for every filter +// the same lines are selected, so records, requests, tokens, cost, unpriced and +// unbilled add up identically — and the agent filter takes the chat's own +// lines AND its tasks' lines, never the other agent's. +func TestSessionCallExportAgreesWithLedgerAndReport(t *testing.T) { + zone := time.FixedZone("Toronto", -4*3600) + path := writeLedger(t, callFixture(zone), nil, "") + base := dayQuery(t, zone, "2026-10-14", "2026-10-14") + for name, mutate := range map[string]func(UsageQuery) UsageQuery{ + "range": func(q UsageQuery) UsageQuery { return q }, + "agent": func(q UsageQuery) UsageQuery { q.Agent = "chatA"; return q }, + "other": func(q UsageQuery) UsageQuery { q.Agent = "chatB"; return q }, + "model": func(q UsageQuery) UsageQuery { q.Model = "m/two"; return q }, + "task": func(q UsageQuery) UsageQuery { q.Kind, q.Subject, q.Root = SubjectTask, "3", "chatA"; return q }, + "run": func(q UsageQuery) UsageQuery { q.Run = "r1"; return q }, + "agent+mod": func(q UsageQuery) UsageQuery { q.Agent, q.Model = "chatA", "m/two"; return q }, + "no matches": func(q UsageQuery) UsageQuery { q.Agent = "nobody"; return q }, + } { + q := mutate(base) + got := collectCalls(t, path, q) + page, err := QueryUsageLedger(path, q, "", UsageLedgerPageMax, DefaultUsageLedgerBudget) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + report, err := QueryUsageReport(path, q, zone, UsageReportBudget{}) + if err != nil { + t.Fatalf("%s: %v", name, err) + } + g := got.totals + for label, totals := range map[string]UsageLedgerTotals{"ledger": page.Totals, "report": report.Totals} { + if g.Records != totals.Records || g.Requests != totals.Calls || g.Input != totals.Input || g.Output != totals.Output || + g.USD != totals.USD || g.Unpriced != totals.Unpriced || g.Unbilled != totals.Unbilled { + t.Fatalf("%s vs %s: calls %+v, totals %+v", name, label, g, totals) + } + } + if len(got.calls) != g.Records { + t.Fatalf("%s: %d calls handed over for %d records", name, len(got.calls), g.Records) + } + var usd float64 + var requests int + for i, call := range got.calls { + if i > 0 && call.Offset <= got.calls[i-1].Offset { + t.Fatalf("%s: not in ledger order", name) + } + if call.Priced { + usd += call.USD + } + requests += call.Requests + } + if usd != g.USD || requests != g.Requests { + t.Fatalf("%s: rows sum to %v/%d, totals say %v/%d", name, usd, requests, g.USD, g.Requests) + } + } + + agent := collectCalls(t, path, func() UsageQuery { q := base; q.Agent = "chatA"; return q }()) + if len(agent.calls) != 5 { + t.Fatalf("chatA's chat and task lines: %d", len(agent.calls)) + } + for _, call := range agent.calls { + if call.Owner != "chatA" { + t.Fatalf("a line of %q came with chatA", call.Owner) + } + } + if all := collectCalls(t, path, base); len(all.calls) != 7 { + t.Fatalf("the day holds %d, want 7 (the next day is outside the range)", len(all.calls)) + } +} + +// A LINE THAT SAYS MANY REQUESTS STAYS ONE RECORD, and a line that says none is +// the one request every line is at least. An unpriced line is not free. +func TestSessionCallExportKeepsLegacyRequestsAndUnpriced(t *testing.T) { + zone := time.UTC + got := collectCalls(t, writeLedger(t, callFixture(zone), nil, ""), dayQuery(t, zone, "2026-10-14", "2026-10-14")) + requests := map[int]int{} + for _, c := range got.calls { + requests[c.Requests]++ + if c.Offset < 0 { + t.Fatal("no offset") + } + } + if requests[41] != 1 || requests[1] != 6 { + t.Fatalf("requests per record: %v", requests) + } + if got.totals.Requests != 41+6 || got.totals.Records != 7 { + t.Fatalf("%+v", got.totals) + } + var unpriced, unbilled int + for _, c := range got.calls { + if !c.Priced { + unpriced++ + if c.USD != 0 { + t.Fatalf("an unpriced call carries %v", c.USD) + } + } + if c.Unbilled { + unbilled++ + } + } + if unpriced != 2 || unbilled != 1 || got.totals.Unpriced != 2 || got.totals.Unbilled != 1 || !got.totals.Priced() { + t.Fatalf("unpriced %d unbilled %d %+v", unpriced, unbilled, got.totals) + } + // A range of unpriced lines has no cost at all. + only := collectCalls(t, writeLedger(t, []UsageLine{{At: time.Now(), Session: "s", Input: 1}}, nil, ""), UsageQuery{}) + if only.totals.Priced() || only.totals.Records != 1 { + t.Fatalf("%+v", only.totals) + } +} + +// The full precision of a cost reaches the visitor untouched. +func TestSessionCallExportCostIsFullPrecision(t *testing.T) { + got := collectCalls(t, writeLedger(t, callFixture(time.UTC), nil, ""), dayQuery(t, time.UTC, "2026-10-14", "2026-10-14")) + if got.calls[0].USD != 0.0123456789012345 { + t.Fatalf("%v", got.calls[0].USD) + } +} + +// A figure that cannot be added up ends the pass with the ledger's own +// refusal and no totals, and so does an error from the caller. +func TestSessionCallExportRefusesFiguresItCannotAdd(t *testing.T) { + at := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + for name, lines := range map[string][]UsageLine{ + "cost": {{At: at, USD: math.MaxFloat64}, {At: at, USD: math.MaxFloat64}}, + "calls": {{At: at, Calls: math.MaxInt}, {At: at, Calls: 2}}, + "tokens": {{At: at, Input: math.MaxInt}, {At: at, Input: math.MaxInt}, {At: at, Input: 2}}, + "negative": {{At: at, Output: -5, USD: cent}}, + "negusd": {{At: at, Input: 1, USD: -cent}}, + "negcalls": {{At: at, Input: 1, Calls: -3}}, + } { + path := writeLedger(t, lines, nil, "") + totals, _, err := EachUsageCall(path, UsageQuery{}, func(UsageCall) error { return nil }) + if !errors.Is(err, ErrUsageLedgerFigures) || totals != (UsageCallTotals{}) { + t.Fatalf("%s: %v %+v", name, err, totals) + } + } + // A huge figure alone is a figure. + path := writeLedger(t, []UsageLine{{At: at, USD: math.MaxFloat64}}, nil, "") + if totals, _, err := EachUsageCall(path, UsageQuery{}, func(UsageCall) error { return nil }); err != nil || totals.USD != math.MaxFloat64 { + t.Fatalf("%v %+v", err, totals) + } + // A hand-written line with a number JSON cannot hold as a float is malformed, + // not wrapped: it is skipped and counted, as everywhere. + path = writeLedger(t, []UsageLine{{At: at, Input: 1, USD: cent}}, []string{`{"at":"2026-01-01T00:00:00Z","usd":1e999}`, `{"at":"2026-01-01T00:00:00Z","in":1e30}`}, "") + totals, stats, err := EachUsageCall(path, UsageQuery{}, func(UsageCall) error { return nil }) + if err != nil || stats.Malformed != 2 || totals.Records != 1 { + t.Fatalf("%v %+v %+v", err, stats, totals) + } + + boom := errors.New("boom") + totals, _, err = EachUsageCall(path, UsageQuery{}, func(UsageCall) error { return boom }) + if !errors.Is(err, boom) || totals != (UsageCallTotals{}) { + t.Fatalf("%v %+v", err, totals) + } +} + +// AN UNASKABLE QUERY, A MISSING LEDGER, A WRITE IN FLIGHT AND A LEDGER THAT +// GROWS MID-PASS: the pass reads whole lines of the length the file had when it +// opened, and nothing else. +func TestSessionCallExportReadsAFixedPrefix(t *testing.T) { + if _, _, err := EachUsageCall("", UsageQuery{Subject: "x"}, func(UsageCall) error { return nil }); !errors.Is(err, ErrUsageQuery) { + t.Fatalf("%v", err) + } + for _, path := range []string{"", filepath.Join(t.TempDir(), "absent.jsonl")} { + totals, stats, err := EachUsageCall(path, UsageQuery{}, func(UsageCall) error { t.Fatal("a call from nothing"); return nil }) + if err != nil || totals != (UsageCallTotals{}) || stats != (UsageScanStats{}) { + t.Fatalf("%q: %v %+v", path, err, totals) + } + } + at := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) + line := UsageLine{At: at, Session: "s", Input: 1, USD: cent} + partial := `{"at":"2026-01-01T00:00:00Z","in":9` + path := writeLedger(t, []UsageLine{line, line, line}, []string{"not json"}, partial) + appended := false + var seen int + totals, stats, err := EachUsageCall(path, UsageQuery{}, func(UsageCall) error { + seen++ + if !appended { + appended = true + data, _ := json.Marshal(line) + f, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0) + if err != nil { + t.Fatal(err) + } + defer f.Close() + if _, err := f.Write(append(data, '\n')); err != nil { + t.Fatal(err) + } + } + return nil + }) + if err != nil || seen != 3 || totals.Records != 3 || stats.Malformed != 1 { + t.Fatalf("%v seen %d %+v %+v", err, seen, totals, stats) + } + // The appended line landed after the half line, so the two are one malformed + // line: the next pass counts it and neither is a call. + if again := collectCalls(t, path, UsageQuery{}); again.totals.Records != 3 || again.stats.Malformed != 2 { + t.Fatalf("%+v %+v", again.totals, again.stats) + } +} + +// ONE SCAN SLOT PAIR FOR EVERY LEDGER READER: with both taken the visitor is +// refused at once, takes no slot of its own, and leaks nothing. +func TestSessionCallExportSharesTheScanSlots(t *testing.T) { + path := writeLedger(t, callFixture(time.UTC), nil, "") + before := runtime.NumGoroutine() + usageScanSlots <- struct{}{} + usageScanSlots <- struct{}{} + _, _, err := EachUsageCall(path, UsageQuery{}, func(UsageCall) error { t.Fatal("ran while busy"); return nil }) + if !errors.Is(err, ErrUsageScanBusy) { + t.Fatalf("%v", err) + } + <-usageScanSlots + <-usageScanSlots + if len(usageScanSlots) != 0 { + t.Fatalf("%d slots held", len(usageScanSlots)) + } + // Both slots are free again, and a failed pass gives its slot back. + for range 5 { + _, _, _ = EachUsageCall(path, UsageQuery{}, func(UsageCall) error { return errors.New("stop") }) + } + if len(usageScanSlots) != 0 { + t.Fatalf("%d slots leaked", len(usageScanSlots)) + } + if after := runtime.NumGoroutine(); after > before+1 { + t.Fatalf("goroutines %d -> %d", before, after) + } +} + +// callsLedger writes n priced one-request lines over many agents and tasks. +func callsLedger(tb testing.TB, n int) string { + tb.Helper() + var b strings.Builder + base := time.Date(2026, 10, 1, 0, 0, 0, 0, time.UTC) + for i := 0; i < n; i++ { + at := base.Add(time.Duration(i) * time.Second) + data, err := json.Marshal(UsageLine{At: at, Day: at.Format(usageDayLayout), Model: fmt.Sprintf("m/%d", i%7), Calls: 1, Input: 3, Output: 2, USD: cent, + Session: fmt.Sprintf("s%04d", i%1000), Root: fmt.Sprintf("a%03d", i%250), Task: fmt.Sprint(i % 9)}) + if err != nil { + tb.Fatal(err) + } + b.Write(data) + b.WriteByte('\n') + } + path := filepath.Join(tb.TempDir(), UsageLedgerName) + if err := os.WriteFile(path, []byte(b.String()), 0o600); err != nil { + tb.Fatal(err) + } + return path +} + +// BenchmarkSessionCallExportVisitor shows the visitor's time is linear in the +// ledger and that it keeps nothing: the LIVE heap (after a forced collection, +// sampled ten times through the pass) beyond what it was before the pass is +// reported beside the time, and does not grow with the ledger. It claims +// nothing about the process's resident memory. +func BenchmarkSessionCallExportVisitor(b *testing.B) { + for _, n := range []int{10_000, 100_000} { + b.Run(fmt.Sprintf("%dk", n/1000), func(b *testing.B) { + path := callsLedger(b, n) + info, _ := os.Stat(path) + b.ReportAllocs() + b.SetBytes(info.Size()) + var peak uint64 + for i := 0; i < b.N; i++ { + var m runtime.MemStats + runtime.GC() + runtime.ReadMemStats(&m) + baseline := m.HeapAlloc + visited := 0 + totals, _, err := EachUsageCall(path, UsageQuery{}, func(UsageCall) error { + visited++ + if visited%(n/10) == 0 { + runtime.GC() + runtime.ReadMemStats(&m) + peak = max(peak, m.HeapAlloc-min(baseline, m.HeapAlloc)) + } + return nil + }) + if err != nil || totals.Records != n || visited != n { + b.Fatalf("%v %d %d", err, totals.Records, visited) + } + } + b.ReportMetric(float64(peak)/(1<<20), "live-heap-growth-MiB") + }) + } +} From 1dcc1ebb1afd56b31ab33d604657735174473d2d Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 23:16:31 -0400 Subject: [PATCH 114/459] Record each usage row's id, billing road and price provenance Every row written through RecordUsage now carries an immutable id, and rows carry billing/billing_basis only where the bound billing door or the opted-in plan overflow states it. The fact is captured per attempt on the call trace, rides the answer to the session through the served slot, an errand note, or the late receipt itself, and is never re-read from the client at bank time. Price provenance (provider/receipt/list) is written where the writer knows it. Additive only: nothing is admitted or refused. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/poolrecord.go | 7 + .../unreleased/0-usage-row-billing-facts.md | 23 ++ internal/config/billingfact_test.go | 49 ++++ internal/config/config.go | 21 ++ internal/manual/chat/models-and-cost.md | 21 ++ internal/provider/billing.go | 24 +- internal/provider/billingfact.go | 197 ++++++++++++++ internal/provider/billingfact_test.go | 202 +++++++++++++++ internal/provider/calllog.go | 6 + internal/provider/client.go | 16 +- internal/provider/dispatch.go | 2 + internal/provider/receipt.go | 7 +- internal/provider/velocity.go | 4 + internal/session/automation_run.go | 1 + internal/session/auxiliary.go | 4 + internal/session/checkpoint.go | 2 +- internal/session/competence_test.go | 8 +- internal/session/loop.go | 26 +- internal/session/run_identity_test.go | 6 +- internal/session/usage_billing.go | 133 ++++++++++ internal/session/usage_billing_test.go | 240 ++++++++++++++++++ internal/session/usage_ledger.go | 30 +++ internal/session/usage_ledger_test.go | 2 +- internal/session/usagejournal_test.go | 8 +- 24 files changed, 1012 insertions(+), 27 deletions(-) create mode 100644 docs/changes/unreleased/0-usage-row-billing-facts.md create mode 100644 internal/config/billingfact_test.go create mode 100644 internal/provider/billingfact.go create mode 100644 internal/provider/billingfact_test.go create mode 100644 internal/session/usage_billing.go create mode 100644 internal/session/usage_billing_test.go diff --git a/cmd/codeaf/poolrecord.go b/cmd/codeaf/poolrecord.go index feaf921b31..067990eb5e 100644 --- a/cmd/codeaf/poolrecord.go +++ b/cmd/codeaf/poolrecord.go @@ -377,10 +377,17 @@ func recordPoolUsage(settings config.Config, model string, used *ai.Usage) { } if used != nil { line.Input, line.Output = used.PromptTokens, used.CompletionTokens + // AND THE ROW SAYS WHICH OF THE TWO IT IS, so a provider's charge and a + // figure multiplied here are never read as the same kind of fact. if used.Cost != nil { line.USD = *used.Cost + line.Price = session.UsagePriceProvider } else if prompt, completion, known := settings.ClientConfig(model).ModelPrice(model); known { line.USD = float64(used.PromptTokens)*prompt + float64(used.CompletionTokens)*completion + line.Price = session.UsagePriceList + } + if line.USD <= 0 { + line.Price = "" } } session.RecordUsage(session.UsageLedgerPath(), line) diff --git a/docs/changes/unreleased/0-usage-row-billing-facts.md b/docs/changes/unreleased/0-usage-row-billing-facts.md new file mode 100644 index 0000000000..1754a936ec --- /dev/null +++ b/docs/changes/unreleased/0-usage-row-billing-facts.md @@ -0,0 +1,23 @@ +--- +kind: added +title: every usage row names itself and says what kind of money it was, where that is stated +pr: 0 +surface: [engine] +--- + +Was true: a `usage.jsonl` row said how much and on what, but not which row it +was, whether the call was metered or covered by a plan, or where its dollar +figure came from. Nothing could tell a process's own notice of a call from the +same call read back off disk, and nothing in the ledger distinguished a plan +door's call from a pay-as-you-go one. + +Is true now: `RecordUsage` stamps every row with an `id` once and never +rewrites one a writer supplied. A row carries `billing` (`metered` or +`included`) and `billing_basis` only when the billing door the call went out +through states it — a bound door's `Metered` bit, or the plan overflow the +person opted into, captured per attempt — and carries nothing otherwise; a +zero cost, a direct key, a `:free` name or a sign-in is never read as +included. A late receipt keeps the billing of the attempt it belongs to. `price` +says `provider`, `receipt` or `list` where the writer knows. All fields are +additive and omitted when absent; old rows decode unchanged. Nothing is +enforced yet: no call is admitted, refused or priced differently. diff --git a/internal/config/billingfact_test.go b/internal/config/billingfact_test.go new file mode 100644 index 0000000000..8aa3c91706 --- /dev/null +++ b/internal/config/billingfact_test.go @@ -0,0 +1,49 @@ +package config + +import ( + "testing" + + "github.com/Agent-Field/codeaf/internal/modelsource" + "github.com/Agent-Field/codeaf/internal/provider" +) + +// TestTheClientDoorStatesBillingOnlyFromTheBoundDoor: a named door's own +// Metered bit is the only billing statement a client is built with. A service +// with no named door, a direct key and a codex sign-in all state nothing. +func TestTheClientDoorStatesBillingOnlyFromTheBoundDoor(t *testing.T) { + zai := modelsource.Source{ID: "z-ai", Written: "z-ai"} + cases := map[string]struct { + service modelsource.Connected + want provider.BillingFact + }{ + "plan door": { + modelsource.Connected{Source: zai, Key: "k", Door: modelsource.Door{ID: "coding-plan", Name: "coding plan"}}, + provider.IncludedDoorFact("z-ai", "coding-plan"), + }, + "metered door": { + modelsource.Connected{Source: zai, Key: "k", Door: modelsource.Door{ID: "metered", Name: "pay-as-you-go", Metered: true}}, + provider.MeteredDoorFact("z-ai", "metered"), + }, + "one-door direct key": { + modelsource.Connected{Source: zai, Key: "k"}, + provider.BillingFact{}, + }, + "default router": { + modelsource.Connected{Source: modelsource.DefaultSource(""), Key: "k"}, + provider.BillingFact{}, + }, + "codex sign-in, even on a named plan door": { + modelsource.Connected{Source: modelsource.Source{ID: "codex", Written: "codex"}, Door: modelsource.Door{ID: "plus"}}, + provider.BillingFact{}, + }, + } + for name, c := range cases { + got := ClientConfigFor(modelsource.NewSet(c.service), "some/model").Billing + if got != c.want { + t.Errorf("%s: billing = %+v, want %+v", name, got, c.want) + } + if got.Included() != (c.want.Kind == provider.BillingIncluded) { + t.Errorf("%s: included = %t", name, got.Included()) + } + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 97a9406bd8..bb95439aca 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -998,12 +998,16 @@ func ClientConfigFor(sources modelsource.Set, model string) provider.Config { Direct: !strings.EqualFold(strings.TrimSpace(service.Source.ID), modelsource.DefaultID), KeyOptional: service.Source.KeyOptional, BillingDoor: service.Door.Name, + Billing: billingFactOf(service), Effort: effort, } if strings.EqualFold(strings.TrimSpace(service.Source.ID), "codex") { configured.APIKey = codexauth.Sentinel configured.HTTPClient = codexauth.Client(service.Home) configured.BillingDoor = "" + // A sign-in is not a plan statement: until the plan claim is read and + // verified, a codex call's billing is nobody's word. + configured.Billing = provider.BillingFact{} configured.ModelPrice = func(string) (float64, float64, bool) { return 0, 0, false } } if service.Overflow != nil { @@ -1014,6 +1018,23 @@ func ClientConfigFor(sources modelsource.Set, model string) provider.Config { return configured } +// billingFactOf is the billing statement the connected service's bound door +// makes, and nothing else. A named door says metered or included in so many +// words (`modelsource.Door.Metered`, proved when it was connected); a service +// with no named door — the default router, a one-door vendor, a custom +// endpoint, a local runner — states nothing, and the client's fact stays +// unknown rather than being inferred from a price, a key shape or a label. +func billingFactOf(service modelsource.Connected) provider.BillingFact { + door := strings.TrimSpace(service.Door.ID) + if door == "" { + return provider.BillingFact{} + } + if service.Door.Metered { + return provider.MeteredDoorFact(service.Source.ID, door) + } + return provider.IncludedDoorFact(service.Source.ID, door) +} + // WithoutSeatPin is the settings a raw request path takes: the same account, // the same model, and the seat's thinking level dropped. // diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index 6c8a67d73c..3e39f9e32f 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -2538,6 +2538,27 @@ The status line shows **this conversation and its task family**, combining the c ledger reading with the conversation meter. `/cost` prints that same scope with its breakdown; `/spend` opens the ledger for the whole machine. +## Does the usage file say whether a call was paid for or included in my plan + +Only where the road that answered said so. Each line in `~/.codeaf/v3/usage.jsonl` written by +this build also carries: + +- `id` — the line's own name, given once when the line is written and never changed, so the + same call read twice is still one call. Older lines have none. +- `billing` — `metered` or `included`, written **only** when the billing door you connected + through states it: a provider's plan door says `included`, its pay-as-you-go door says + `metered`, and a paused plan that you allowed to overflow onto pay-as-you-go writes + `metered` for the calls that went that way. `billing_basis` names the door it came from. + A service with one road, the default router, a custom endpoint and a ChatGPT sign-in state + nothing, so their lines carry no `billing` at all. A zero cost, a direct key or a `:free` + model name is never read as included. +- `price` — where the cost came from: `provider` for the charge on the answer itself, + `receipt` for a cut stream's later receipt, `list` for counted tokens times the model's + published price. A line with no cost has no `price`. + +A late receipt keeps the billing of the request it belongs to, even if the connection was +changed before the receipt arrived. Nothing here stops or limits a call; it is a record. + ## The spend place — what days and models cost, and what the money was for `alt+5` opens it. It reads the machine-wide ledger above when you walk in and again on the diff --git a/internal/provider/billing.go b/internal/provider/billing.go index feee7921e6..27d6dcb23a 100644 --- a/internal/provider/billing.go +++ b/internal/provider/billing.go @@ -56,6 +56,11 @@ type Billed struct { CompletionTokens int CachedTokens int Cost float64 + // Billing is the fact the answered attempt went out under, captured at + // that attempt (billingfact.go). It rides a late receipt unchanged, so a + // receipt is billed as the road that was charged rather than as whatever + // the client says when the receipt arrives. + Billing BillingFact } // Empty reports a response the adapter cannot bill: the provider sent no usage @@ -208,9 +213,17 @@ func reconcileFrom(ctx context.Context) ReconcileSink { // reads it: the slot's model unless the routing lane recorded a served rung, // because a row naming the model somebody asked for is a row that cannot be // summed per model after an escalation. -func (c *Client) bill(ctx context.Context, model string, response *ai.Response) { +func (c *Client) bill(ctx context.Context, model string, response *ai.Response, traces ...*callTrace) { + if response == nil || response.Usage == nil { + return + } + // THE CALLER'S SLOT HEARS THE ROAD BEFORE ANY SINK IS ASKED FOR, because a + // session banks the answer itself and arms no sink: the fact has to reach + // it beside the answer or not at all. + fact := c.answeredBilling(traces...) + noteAnsweredBilling(ctx, fact) sink := billingFrom(ctx) - if sink == nil || response == nil || response.Usage == nil { + if sink == nil { return } if served := CallFrom(ctx).Model(); served != "" { @@ -222,6 +235,7 @@ func (c *Client) bill(ctx context.Context, model string, response *ai.Response) PromptTokens: response.Usage.PromptTokens, CompletionTokens: response.Usage.CompletionTokens, CachedTokens: response.Usage.CacheReadTokens(), + Billing: fact, } if response.Usage.Cost != nil { billed.Cost = *response.Usage.Cost @@ -235,12 +249,12 @@ func (c *Client) bill(ctx context.Context, model string, response *ai.Response) // billAnswered bills an answer that arrived whole. One with no usage block is // settled like a cut one when the work asked for that ([WithUnmeteredReceipts]), // and billed the ordinary way — which banks nothing for it — otherwise. -func (c *Client) billAnswered(ctx context.Context, model string, response *ai.Response, answerBytes int) { +func (c *Client) billAnswered(ctx context.Context, model string, response *ai.Response, answerBytes int, traces ...*callTrace) { if response != nil && response.Usage == nil && unmeteredReceiptsFrom(ctx) { - c.settle(ctx, model, response, receiptUnmeteredReason, answerBytes) + c.settle(ctx, model, response, receiptUnmeteredReason, answerBytes, traces...) return } - c.bill(ctx, model, response) + c.bill(ctx, model, response, traces...) } // BillingSinkFrom and CallNodeFrom read back what a leaf's context was armed diff --git a/internal/provider/billingfact.go b/internal/provider/billingfact.go new file mode 100644 index 0000000000..24556728f6 --- /dev/null +++ b/internal/provider/billingfact.go @@ -0,0 +1,197 @@ +package provider + +import ( + "context" + "strings" + "sync" +) + +// WHAT KIND OF MONEY ONE ANSWER WAS, SAID ONLY BY WHOEVER KNOWS. +// +// A spending cap has to tell a call that was paid for by the call from a call a +// fixed plan already covered, and every guess this program could make about +// that is wrong somewhere. A zero cost is a router that did not price the call +// as often as it is a free one; a direct key is a metered vendor as often as it +// is a subscription; a `:free` suffix is a catalog's word, and an OAuth token +// says who signed in, not who pays. So THE FACT IS WRITTEN DOWN ONLY WHERE IT +// IS STATED: on the billing road the person connected through +// (`modelsource.Door.Metered`, proved at connect time) and on the one switch +// this transport makes between roads — the plan overflow the person opted into. +// Everywhere else the answer is [BillingUnknown], and a cap treats an unknown +// call as one it may have to stop. +// +// THE FACT BELONGS TO THE ATTEMPT THAT WAS ANSWERED, NOT TO THE CLIENT. A plan +// client can send its last attempt down the metered overflow road, so the fact +// is stamped per attempt ([Client.send], beside the attempt's address) and +// carried with that answer to whoever banks it. Reading the client's settings +// again at bank time would read whatever the account says NOW, which is the +// mutable-state read a bill must never depend on. + +// BillingKind is the closed vocabulary of what one attempt cost the person. +type BillingKind string + +const ( + // BillingMetered is a road whose use creates a charge outside any plan. + BillingMetered BillingKind = "metered" + // BillingIncluded is a road the bound billing door explicitly states is + // covered by a fixed plan (`Door.Metered == false` on a named door). + BillingIncluded BillingKind = "included" + // BillingUnknown is every road nobody stated a billing fact for. It is the + // zero value's meaning as well: an absent fact is never read as included. + BillingUnknown BillingKind = "unknown" +) + +// BillingFact is one attempt's billing, with the statement it came from. +// +// Basis names the source of the statement — `door:/` for a bound +// door, `overflow:` for the plan overflow — and is empty exactly when the +// kind is unknown. The zero value is "nobody said". +type BillingFact struct { + Kind BillingKind + Basis string +} + +// Known reports whether somebody stated this fact. The zero value and an +// explicit unknown both answer false. +func (f BillingFact) Known() bool { + return f.Kind == BillingMetered || f.Kind == BillingIncluded +} + +// Included reports the one fact that may ever exempt a call from a money cap. +func (f BillingFact) Included() bool { return f.Kind == BillingIncluded } + +// normalized answers the fact a reader may rely on: a kind outside the closed +// vocabulary, or a stated kind with no basis, is unknown. +func (f BillingFact) normalized() BillingFact { + f.Basis = strings.TrimSpace(f.Basis) + if !f.Known() || f.Basis == "" { + return BillingFact{Kind: BillingUnknown} + } + return f +} + +// MeteredDoorFact and IncludedDoorFact are the two statements a bound door can +// make. They are spelled here, once, so the basis a row carries is one string +// whichever package built the client. +func MeteredDoorFact(source, door string) BillingFact { + return doorFact(BillingMetered, source, door) +} + +// IncludedDoorFact is a named door's explicit plan statement. +func IncludedDoorFact(source, door string) BillingFact { + return doorFact(BillingIncluded, source, door) +} + +func doorFact(kind BillingKind, source, door string) BillingFact { + source, door = strings.TrimSpace(source), strings.TrimSpace(door) + if door == "" { + return BillingFact{Kind: BillingUnknown} + } + return BillingFact{Kind: kind, Basis: "door:" + source + "/" + door} +} + +// overflowFact is the plan overflow road: metered by its own definition, which +// is the whole reason a person had to opt into it. +func overflowFact(door string) BillingFact { + door = strings.TrimSpace(door) + if door == "" { + door = "metered" + } + return BillingFact{Kind: BillingMetered, Basis: "overflow:" + door} +} + +// attemptBilling is the fact one attempt of this client goes out under. +// +// The overflow road is metered whatever the bound door says. Every other +// attempt carries the client's own statement, normalized so a half-stated fact +// reads as unknown rather than as whatever its kind claimed. +func (c *Client) attemptBilling(overflow bool) BillingFact { + if overflow { + return overflowFact(c.config.PlanOverflowDoor) + } + return c.config.Billing.normalized() +} + +// answeredBilling is the fact the answer in hand was produced under. With the +// call's trace it is exact. Without one it is the client's own statement — +// UNLESS this client may switch to a metered overflow, in which case nobody can +// say which road answered and the honest answer is unknown. +func (c *Client) answeredBilling(traces ...*callTrace) BillingFact { + if len(traces) > 0 && traces[0] != nil && traces[0].attemptBilled { + return traces[0].attemptBilling + } + if c.config.OverflowOnPlanPause && strings.TrimSpace(c.config.PlanOverflow) != "" { + return BillingFact{Kind: BillingUnknown} + } + return c.config.Billing.normalized() +} + +// ── what the caller's served slot saw ─────────────────────────────────────── + +// billingSeen is the billing half of a [ServedEndpoint]: every fact noted +// since the last [ServedEndpoint.TakeBilling], folded so that the answer can +// only err toward a charge. A slot is shared by a turn's sequential calls and, +// on a hedge, by two arms at once; folding rather than overwriting is what +// keeps a late-landing arm from relabelling the winner's money as a plan's. +type billingSeen struct { + mu sync.Mutex + fact BillingFact + seen bool +} + +func (b *billingSeen) note(fact BillingFact) { + fact = fact.normalized() + b.mu.Lock() + defer b.mu.Unlock() + if !b.seen { + b.fact, b.seen = fact, true + return + } + b.fact = foldBilling(b.fact, fact) +} + +func (b *billingSeen) take() BillingFact { + b.mu.Lock() + defer b.mu.Unlock() + fact := b.fact + b.fact, b.seen = BillingFact{}, false + return fact +} + +// foldBilling combines two facts about answers banked as one: equal facts are +// that fact, any metered fact makes the pair metered, and anything else — +// included beside unknown — is unknown. It never yields included unless both +// halves said so. +func foldBilling(a, b BillingFact) BillingFact { + switch { + case a == b: + return a + case a.Kind == BillingMetered: + return a + case b.Kind == BillingMetered: + return b + default: + return BillingFact{Kind: BillingUnknown} + } +} + +// TakeBilling answers the billing of every answer this slot saw since the last +// take, and forgets it. The zero fact means no answer through this transport +// was noted here at all — which a bank reads as "nobody said". +func (s *ServedEndpoint) TakeBilling() BillingFact { + if s == nil { + return BillingFact{} + } + return s.billing.take() +} + +// noteAnsweredBilling hands one answer's billing to the caller's slot, if the +// caller opened one. +func noteAnsweredBilling(ctx context.Context, fact BillingFact) { + if ctx == nil { + return + } + if slot, _ := ctx.Value(servedEndpointContextKey{}).(*ServedEndpoint); slot != nil { + slot.billing.note(fact) + } +} diff --git a/internal/provider/billingfact_test.go b/internal/provider/billingfact_test.go new file mode 100644 index 0000000000..1158ac84f3 --- /dev/null +++ b/internal/provider/billingfact_test.go @@ -0,0 +1,202 @@ +package provider + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/modelsource/sourcestub" +) + +// pricedServer answers every completion — whole or streamed — with a usage +// block carrying a provider cost, which is the one shape a billing fact rides +// on through [Client.bill]. +func pricedServer(t *testing.T) *httptest.Server { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var envelope struct { + Stream bool `json:"stream"` + } + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &envelope) + usage := `"usage":{"prompt_tokens":11,"completion_tokens":5,"total_tokens":16,"cost":0.02}` + if envelope.Stream { + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprintf(w, "data: {\"id\":\"g-1\",\"model\":\"m/x\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"done\"},\"finish_reason\":\"stop\"}],%s}\n\ndata: [DONE]\n\n", usage) + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"id":"g-1","model":"m/x","choices":[{"index":0,"finish_reason":"stop","message":{"role":"assistant","content":"done"}}],%s}`, usage) + })) + t.Cleanup(server.Close) + return server +} + +// billingProbe arms the two doors a billing fact reaches a banker through: the +// caller's served slot and a billing sink. +func billingProbe(ctx context.Context) (context.Context, *ServedEndpoint, func() []Billed) { + slot := &ServedEndpoint{} + var mu sync.Mutex + var billed []Billed + ctx = WithBilling(WithServedEndpoint(ctx, slot), func(b Billed) { + mu.Lock() + defer mu.Unlock() + billed = append(billed, b) + }) + return ctx, slot, func() []Billed { + mu.Lock() + defer mu.Unlock() + return append([]Billed(nil), billed...) + } +} + +// TestABoundDoorBillsItsAnswerAsItsOwnRoad: a plan door's own statement rides +// its answer to both doors, exactly once — a second take of the slot is empty. +func TestABoundDoorBillsItsAnswerAsItsOwnRoad(t *testing.T) { + server := pricedServer(t) + plan := IncludedDoorFact("z-ai", "coding-plan") + client, err := NewClient(Config{ + APIKey: "k", BaseURL: server.URL, Model: "m/x", Direct: true, + HTTPClient: server.Client(), Billing: plan, + }) + if err != nil { + t.Fatal(err) + } + ctx, slot, billed := billingProbe(t.Context()) + if _, err := client.CompleteWithMessages(ctx, userMessages("hello")); err != nil { + t.Fatal(err) + } + if got := billed(); len(got) != 1 || got[0].Billing != plan || got[0].Cost != 0.02 { + t.Fatalf("billed = %+v, want one $0.02 answer on %+v", got, plan) + } + if got := slot.TakeBilling(); got != plan { + t.Fatalf("slot billing = %+v, want %+v", got, plan) + } + if got := slot.TakeBilling(); got != (BillingFact{}) { + t.Fatalf("second take = %+v, want nothing: a take forgets", got) + } +} + +// TestAnOverflowAnswerIsMeteredWhateverTheDoorSays: the plan door refuses with +// its pause, the person's opted-in overflow answers, and the answer is billed +// as the metered overflow road — never as the plan the client was built for. +func TestAnOverflowAnswerIsMeteredWhateverTheDoorSays(t *testing.T) { + oldLimiter := sharedLimiter + sharedLimiter = newAdaptiveLimiter() + defer func() { sharedLimiter = oldLimiter }() + + plan := sourcestub.New() + defer plan.Close() + plan.RefuseCompletion(http.StatusTooManyRequests, `{"code":"1316","message":"Insufficient balance for extra usage"}`) + metered := pricedServer(t) + client, err := NewClient(Config{ + APIKey: "k", BaseURL: plan.URL(), Model: "glm-5.3-flash", Direct: true, + BillingDoor: "coding plan", Billing: IncludedDoorFact("z-ai", "coding-plan"), + PlanOverflow: metered.URL, PlanOverflowDoor: "pay-as-you-go", OverflowOnPlanPause: true, + }) + if err != nil { + t.Fatal(err) + } + ctx, slot, billed := billingProbe(t.Context()) + if _, err := client.CompleteWithMessages(ctx, userMessages("hello")); err != nil { + t.Fatal(err) + } + want := BillingFact{Kind: BillingMetered, Basis: "overflow:pay-as-you-go"} + if got := billed(); len(got) != 1 || got[0].Billing != want { + t.Fatalf("billed = %+v, want one answer on %+v", got, want) + } + if got := slot.TakeBilling(); got != want { + t.Fatalf("slot billing = %+v, want %+v", got, want) + } +} + +// TestALateReceiptKeepsTheRoadItsAttemptWentOutOn: a cut stream's receipt is +// owed under the attempt's road, and a client whose statement changes before +// the receipt lands does not relabel money already spent. +func TestALateReceiptKeepsTheRoadItsAttemptWentOutOn(t *testing.T) { + defer shortenStallBounds(t, time.Second, 30*time.Millisecond)() + releaseReceipt := make(chan struct{}) + var releaseOnce sync.Once + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/chat/completions": + w.Header().Set("Content-Type", "text/event-stream") + w.WriteHeader(http.StatusOK) + fmt.Fprint(w, `data: {"id":"generation-b1","choices":[{"index":0,"delta":{"content":"begun"}}]}`+"\n\n") + w.(http.Flusher).Flush() + <-r.Context().Done() + case "/generation": + <-releaseReceipt + w.Header().Set("Content-Type", "application/json") + fmt.Fprint(w, `{"data":{"total_cost":0.41,"tokens_prompt":30,"tokens_completion":9}}`) + default: + http.NotFound(w, r) + } + })) + t.Cleanup(func() { + releaseOnce.Do(func() { close(releaseReceipt) }) + server.Close() + }) + client := receiptTestClient(t, server) + road := MeteredDoorFact("openrouter", "credits") + client.config.Billing = road + results := make(chan Reconciled, 1) + ctx := WithReconcile(WithStreamObserver(t.Context(), func(StreamEvent) {}), func(result Reconciled) { + results <- result + }) + if _, err := client.CompleteWithMessages(ctx, userMessages("hello")); err == nil { + t.Fatal("the stream did not reach its cut") + } + // The statement moves while the receipt is still owed. + client.config.Billing = IncludedDoorFact("openrouter", "plan") + releaseOnce.Do(func() { close(releaseReceipt) }) + result := receiptResult(t, results) + if !result.Found || result.Cost != 0.41 || result.Billing != road { + t.Fatalf("reconciled = %+v, want the $0.41 receipt on %+v", result, road) + } +} + +// TestAnUnstatedRoadIsNeverIncluded pins the vocabulary's floor: nothing that +// was not stated in so many words reads as included, and folding answers +// together can only err toward a charge. +func TestAnUnstatedRoadIsNeverIncluded(t *testing.T) { + for name, fact := range map[string]BillingFact{ + "zero": {}, + "door with no id": IncludedDoorFact("z-ai", " "), + "included, no basis": {Kind: BillingIncluded}, + "foreign kind": {Kind: "free", Basis: "door:x/y"}, + } { + if got := fact.normalized(); got.Included() || got.Kind != BillingUnknown { + t.Errorf("%s normalized = %+v, want unknown", name, got) + } + } + plan := IncludedDoorFact("z-ai", "coding-plan") + meter := MeteredDoorFact("z-ai", "metered") + unknown := BillingFact{Kind: BillingUnknown} + for _, fold := range []struct{ a, b, want BillingFact }{ + {plan, plan, plan}, + {plan, meter, meter}, + {meter, plan, meter}, + {plan, unknown, unknown}, + {unknown, plan, unknown}, + } { + if got := foldBilling(fold.a, fold.b); got != fold.want { + t.Errorf("fold(%+v, %+v) = %+v, want %+v", fold.a, fold.b, got, fold.want) + } + } + // A client that may switch to a metered overflow cannot say, without the + // attempt's own trace, which road answered. + client := &Client{config: Config{Billing: plan, PlanOverflow: "http://meter", OverflowOnPlanPause: true}} + if got := client.answeredBilling(); got.Kind != BillingUnknown { + t.Fatalf("untraced answer on an overflow-armed plan = %+v, want unknown", got) + } + if got := client.answeredBilling(&callTrace{attemptBilled: true, attemptBilling: plan}); got != plan { + t.Fatalf("traced answer = %+v, want %+v", got, plan) + } +} diff --git a/internal/provider/calllog.go b/internal/provider/calllog.go index d85cf9f7d0..ccc0dc7a26 100644 --- a/internal/provider/calllog.go +++ b/internal/provider/calllog.go @@ -124,6 +124,12 @@ type callTrace struct { // attemptBase is the address this attempt actually used, including an // explicitly authorized plan overflow. It never leaves the process. attemptBase string + // attemptBilling is the billing fact this attempt went out under — the + // client's own road, or metered once the plan overflow was taken — set in + // the same breath as attemptBase so the answer is billed as the road that + // served it (billingfact.go). attemptBilled says it was set at all. + attemptBilling BillingFact + attemptBilled bool // open is the attempt that has a start row on the wire and nothing under it // yet, and nil whenever the log is square. // diff --git a/internal/provider/client.go b/internal/provider/client.go index caf62b05ec..96e7c17bd5 100644 --- a/internal/provider/client.go +++ b/internal/provider/client.go @@ -48,6 +48,12 @@ type Config struct { // 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 + // Billing is what the bound road states about money: metered, included in + // a plan, or nobody said ([BillingFact]). It is set ONLY by the config + // door from the connected service's billing door, and every attempt that + // goes out on this client's own road carries it (billingfact.go). The zero + // value is unknown and is never read as included. + Billing BillingFact // PlanOverflow is the separately billed road a paused subscription may use. // It is inert unless OverflowOnPlanPause is true, which is never the default. PlanOverflow string @@ -1170,7 +1176,7 @@ func (c *Client) completionInOnePiece( // for it an answer with no usage block is priced by its receipt // ([Client.billAnswered]). The bare door banks nothing without usage, so // that charge reached no book at all. - c.billAnswered(ctx, c.modelFor(request), &response, len(responseText(&response))) + c.billAnswered(ctx, c.modelFor(request), &response, len(responseText(&response)), knobs.trace) return nil, false, cut } reasonWord, servedWell := answerOutcome(&response) @@ -1193,7 +1199,7 @@ func (c *Client) completionInOnePiece( }) // The money, banked at the same instant the log row is written and for the // same reason: this is where the fact is known. See billing.go. - c.billAnswered(ctx, c.modelFor(request), &response, len(responseText(&response))) + c.billAnswered(ctx, c.modelFor(request), &response, len(responseText(&response)), knobs.trace) return &response, len(relearned) > 0, nil } @@ -2250,7 +2256,7 @@ func (c *Client) completeWithMessagesStreaming( ttft: firstTokenAfter(began, firstToken), }) // Through the answered door, for the reason the whole-body twin gives. - c.billAnswered(ctx, c.modelFor(request), response, content.Len()) + c.billAnswered(ctx, c.modelFor(request), response, content.Len(), knobs.trace) return nil, false, cut } // A RESCUE IS NOT THE TURN UNTIL IT READS AS LANGUAGE. The hedge used @@ -2266,7 +2272,7 @@ func (c *Client) completeWithMessagesStreaming( }) // A rescue that is not language was still paid for, so it goes through // the answered door too. - c.billAnswered(ctx, c.modelFor(request), response, content.Len()) + c.billAnswered(ctx, c.modelFor(request), response, content.Len(), knobs.trace) return nil, false, cut } // PAST EVERY GUARD, SO THIS LANE SERVED — the recovery half of the quality @@ -2296,7 +2302,7 @@ func (c *Client) completeWithMessagesStreaming( // Both paths or neither, exactly as the learning above: a streamed answer // is billed by the provider the same way a whole-body one is, and a ledger // blind to one of the two transports is a ledger nobody can reconcile. - c.billAnswered(ctx, c.modelFor(request), response, content.Len()) + c.billAnswered(ctx, c.modelFor(request), response, content.Len(), knobs.trace) finished = true observer(StreamEvent{Kind: StreamFinished, Session: session}) return response, relearned, nil diff --git a/internal/provider/dispatch.go b/internal/provider/dispatch.go index f400f56ff5..db6bdc344f 100644 --- a/internal/provider/dispatch.go +++ b/internal/provider/dispatch.go @@ -469,6 +469,8 @@ func (c *Client) send(ctx context.Context, request *ai.Request, knobs callKnobs, // this loop with a new body and the same trace. if knobs.trace != nil { knobs.trace.attemptBase = currentBase + knobs.trace.attemptBilling = c.attemptBilling(usingOverflow) + knobs.trace.attemptBilled = true } knobs.trace.begin() if attempt > 0 && switchedDoor { diff --git a/internal/provider/receipt.go b/internal/provider/receipt.go index 5afd976c7f..21c9bd93f3 100644 --- a/internal/provider/receipt.go +++ b/internal/provider/receipt.go @@ -167,7 +167,7 @@ func (c *Client) settle(ctx context.Context, model string, response *ai.Response base = traces[0].attemptBase } if response != nil && response.Usage != nil { - c.bill(ctx, model, response) + c.bill(ctx, model, response, traces...) return } // A DIRECT SERVICE HAS NO OPENROUTER GENERATION ROUTE TO ASK. Its missing @@ -185,7 +185,7 @@ func (c *Client) settle(ctx context.Context, model string, response *ai.Response model = served } result := Reconciled{ - Billed: Billed{Node: callNode(ctx), Model: model}, + Billed: Billed{Node: callNode(ctx), Model: model, Billing: c.answeredBilling(traces...)}, Reason: strings.TrimSpace(reason), Hedged: hedgeLaneFrom(ctx) != "", } @@ -296,6 +296,9 @@ func (c *Client) reconcile(work receiptWork) { if found { billed.Node = result.Node billed.Model = result.Model + // The road is the attempt's, captured when the receipt was owed; + // the receipt document says nothing about it. + billed.Billing = result.Billing result.Billed = billed result.Found = true // Recovery is reached only when the stream had no usage block, so diff --git a/internal/provider/velocity.go b/internal/provider/velocity.go index 4345a5ad9e..b4c5cadd63 100644 --- a/internal/provider/velocity.go +++ b/internal/provider/velocity.go @@ -1025,6 +1025,10 @@ type ServedEndpoint struct { // hops counts how many times the answering endpoint CHANGED under this slot. // It starts at zero for the first answer, which is an arrival and not a move. hops int + // billing is what the answers banked through this slot were billed as + // (billingfact.go). It has its own lock because it is folded, not + // overwritten, and taken by the bank rather than read. + billing billingSeen } // Name is the endpoint that answered most recently, empty when nothing has diff --git a/internal/session/automation_run.go b/internal/session/automation_run.go index 8b3a4985e6..00ff36b1a8 100644 --- a/internal/session/automation_run.go +++ b/internal/session/automation_run.go @@ -473,6 +473,7 @@ func (r *automationRunner) Judge(ctx context.Context, item automation.Automation // the standing sentinel it replaces never reached the usage ledger at all. RecordUsage(UsageLedgerPath(), UsageLine{ Model: model, Role: string(roles.RoleSentinel), Calls: 1, Input: in, Output: out, USD: usd, + Price: usagePrice(usd, usagePriceProvider), Automation: item.ID, Workspace: item.Workspace, }) word, line := automationVerdictWords(response.Text()) diff --git a/internal/session/auxiliary.go b/internal/session/auxiliary.go index 1a2863b425..94878178d1 100644 --- a/internal/session/auxiliary.go +++ b/internal/session/auxiliary.go @@ -292,6 +292,10 @@ func (a *Agent) callRoleChecked(ctx context.Context, role roles.Role, sessionDef } releaseRung() if callErr == nil && response != nil { + // THE ROAD THIS ANSWER CAME DOWN goes with the answer to whichever + // caller banks it ([Agent.addUsageAs]); the slot is this attempt's + // own, so nothing else's road is in it. + noteAnswerBilling(response, served.TakeBilling()) // AND THE ERRAND WRITES ITS OWN CALL LINE, exactly as a step of the // turn does (loop.go's [Agent.addUsage]). Without it the journal's // call lines covered only the conversation's own requests, and a diff --git a/internal/session/checkpoint.go b/internal/session/checkpoint.go index e52a134fdc..9b1782465c 100644 --- a/internal/session/checkpoint.go +++ b/internal/session/checkpoint.go @@ -5170,7 +5170,7 @@ func (a *Agent) checkpointBrief(ctx context.Context, turn *Usage, model string) // measurement of one request written down about another (usage_ledger.go's // emptiness law for the five lane keys). turn.Turns++ - a.addUsage(turn, response, model, provider.ServedEndpointFrom(ctx).Name(), laneFacts{}) + a.addUsage(turn, response, model, provider.ServedEndpointFrom(ctx).Name(), laneFacts{}, provider.ServedEndpointFrom(ctx).TakeBilling()) brief := strings.TrimSpace(response.Text()) // THE REMAINS CONTRACT IS READ FIRST, because it is the only answer here that diff --git a/internal/session/competence_test.go b/internal/session/competence_test.go index e4c9158de0..5c5d74b931 100644 --- a/internal/session/competence_test.go +++ b/internal/session/competence_test.go @@ -10,6 +10,8 @@ import ( "testing" "time" + "github.com/Agent-Field/codeaf/internal/provider" + "github.com/Agent-Field/agentfield/sdk/go/ai" "github.com/Agent-Field/codeaf/internal/competence" "github.com/Agent-Field/codeaf/internal/plandb" @@ -148,8 +150,8 @@ func TestAFreeAnswerCountsAsAModel(t *testing.T) { func TestAnswersAreCountedWithoutUsageAndNeverFromReceipts(t *testing.T) { agent, _ := newTestAgent(t, &scriptedCompleter{}, nil) var turn Usage - agent.addUsage(&turn, &ai.Response{}, "m/b", "", laneFacts{}) - agent.addUsage(&turn, nil, "m/c", "", laneFacts{}) + agent.addUsage(&turn, &ai.Response{}, "m/b", "", laneFacts{}, provider.BillingFact{}) + agent.addUsage(&turn, nil, "m/c", "", laneFacts{}, provider.BillingFact{}) agent.bank(bankedCall{used: Usage{Input: 3, CostUSD: 0.01, Calls: 1}, model: "m/late"}) if answers := agent.Answers(); len(answers) != 1 || answers["m/b"] != 1 { t.Fatalf("answers %+v", answers) @@ -265,7 +267,7 @@ func TestTheAnsweredTallyFollowsTheAnsweringModel(t *testing.T) { func TestAnAnswerNamesItsActualModelWithoutUsage(t *testing.T) { agent, _ := newTestAgent(t, &scriptedCompleter{}, nil) var turn Usage - agent.addUsage(&turn, &ai.Response{Model: "m/served"}, "m/requested", "", laneFacts{}) + agent.addUsage(&turn, &ai.Response{Model: "m/served"}, "m/requested", "", laneFacts{}, provider.BillingFact{}) if answers := agent.Answers(); len(answers) != 1 || answers["m/served"] != 1 { t.Fatalf("answered model was replaced by request target: %+v", answers) } diff --git a/internal/session/loop.go b/internal/session/loop.go index 9f8b654f87..5513a6048b 100644 --- a/internal/session/loop.go +++ b/internal/session/loop.go @@ -1041,7 +1041,7 @@ func (a *Agent) runTurn(ctx context.Context, hub *eventHub, user userMessage) bo // THE LANE IS READ BEFORE THE MONEY IS BANKED, because the row the // money writes is this call's and the witness is what measured it. facts := a.turnLane.answered(readHedge(hedge, served.Name()), responseOutput(response)) - a.addUsage(&turn, response, model, served.Name(), facts, runIdentity(ctx)) + a.addUsage(&turn, response, model, served.Name(), facts, served.TakeBilling(), runIdentity(ctx)) a.tellLaneNews(model, facts, hedge) droppedCall := forming.any() || warm.anyAnnounced() a.keepSteeredPartial(partial, reasoning, droppedCall, hub, cutDroppedCallNote(err)) @@ -1097,7 +1097,7 @@ func (a *Agent) runTurn(ctx context.Context, hub *eventHub, user userMessage) bo // ledger line carries ([Agent.addUsage]) rather than something held back // for the turn's seal to collect. facts := a.turnLane.answered(readHedge(hedge, served.Name()), responseOutput(response)) - a.addUsage(&turn, response, model, served.Name(), facts, runIdentity(ctx)) + a.addUsage(&turn, response, model, served.Name(), facts, served.TakeBilling(), runIdentity(ctx)) // AND THE SURFACE IS TOLD WHO ANSWERED, on the same line and at the same // grain (lanenews.go). It is the push half of a seam whose pull half // cannot exist: a status line cannot see a stream, and the arrow between @@ -4213,6 +4213,13 @@ type bankedCall struct { // honest number the compaction threshold prefers over an estimate — and // zero where nobody said. context int + // billing is the road the answered attempt went out under, captured by the + // transport at that attempt and handed here with the answer — never read + // back from the client or the turn at bank time (usage_billing.go). + billing provider.BillingFact + // price is where the money figure came from, when the caller knows it + // ([UsageLine.Price]); empty is nobody said. + price string } // bank is THE ONE DOOR MONEY GOES THROUGH, and it is the fix for issue #269. @@ -4301,6 +4308,7 @@ func (a *Agent) reconciled(receipt provider.Reconciled) { a.bank(bankedCall{ used: used, model: receipt.Model, lane: lane, ledger: true, late: true, reconciled: true, + billing: receipt.Billing, price: usagePriceReceipt, }) // AND THE REQUEST LEAVES ITS OWN LINE, for [journalCall]'s law said another // way: EVERY request this session makes writes one, and a request whose @@ -4333,7 +4341,7 @@ func (a *Agent) reconciled(receipt provider.Reconciled) { // standing (runTurn re-latches on an answer from further down the chain), and // `served` is the endpoint the router says answered, "" when nothing said. // `lane` is what the witness measured about THIS request and nothing else. -func (a *Agent) addUsage(turn *Usage, response *ai.Response, model, served string, lane laneFacts, runIDs ...string) { +func (a *Agent) addUsage(turn *Usage, response *ai.Response, model, served string, lane laneFacts, billing provider.BillingFact, runIDs ...string) { // The row's endpoint is the answer's own naming or, for a service that // declared the one machine it is, that declared name ([Agent.attributedEndpoint]). // `lane` was read from the answer alone, so what the lane layer learns and @@ -4395,7 +4403,7 @@ func (a *Agent) addUsage(turn *Usage, response *ai.Response, model, served strin if len(runIDs) > 0 { run = runIDs[0] } - a.bank(bankedCall{used: call, model: answered, lane: lane, ledger: true, turn: true, context: context, role: string(a.config.crewRole), run: run}) + a.bank(bankedCall{used: call, model: answered, lane: lane, ledger: true, turn: true, context: context, role: string(a.config.crewRole), run: run, billing: billing, price: usagePriceProvider}) // AND THE TURN KEEPS THE SAME SHARE PER ANSWERING MODEL, under the very // name the row above banks, so a turn that hopped seals one usage line per @@ -5740,7 +5748,15 @@ func (a *Agent) addUsageAs(response *ai.Response, model string, calls int, role // AN ERRAND'S ROW CARRIES NO LANE. Nothing timed this call — the witness // watches the turn's own stream — and the turn's figures on this row would be // a measurement of one request filed against another. - a.bank(bankedCall{used: aux, model: model, role: role, ledger: ledger, late: len(detached) > 0 && detached[0]}) + // AN ERRAND'S ROAD WAS NOTED BY ITS OWN LADDER beside the answer it + // returned ([noteAnswerBilling]); an answer nobody noted is banked with no + // billing and no price word, which is "nobody said". + billing, noted := takeAnswerBilling(response) + price := "" + if noted { + price = usagePriceProvider + } + a.bank(bankedCall{used: aux, model: model, role: role, ledger: ledger, late: len(detached) > 0 && detached[0], billing: billing, price: price}) // The write is outside the lock for the reason [Agent.sealTurn]'s is: the // file has its own, and holding the agent's across a disk write would put // every reader of the session's totals behind it. diff --git a/internal/session/run_identity_test.go b/internal/session/run_identity_test.go index e569374add..91b948c8ce 100644 --- a/internal/session/run_identity_test.go +++ b/internal/session/run_identity_test.go @@ -9,6 +9,8 @@ import ( "testing" "time" + "github.com/Agent-Field/codeaf/internal/provider" + "github.com/Agent-Field/agentfield/sdk/go/ai" ) @@ -78,9 +80,9 @@ func TestLateUnattributedMoneyIsNotAssignedToTheCurrentRun(t *testing.T) { old := withRunIdentity(context.Background()) newer := withRunIdentity(old) var turn Usage - a.addUsage(&turn, billedCall("test/model", 3, 1, .1), "test/model", "", laneFacts{}, runIdentity(old)) + a.addUsage(&turn, billedCall("test/model", 3, 1, .1), "test/model", "", laneFacts{}, provider.BillingFact{}, runIdentity(old)) a.bank(bankedCall{used: Usage{Input: 2, Output: 1, CostUSD: .2, Calls: 1}, model: "test/model", ledger: true, late: true}) - a.addUsage(&turn, billedCall("test/model", 4, 2, .3), "test/model", "", laneFacts{}, runIdentity(newer)) + a.addUsage(&turn, billedCall("test/model", 4, 2, .3), "test/model", "", laneFacts{}, provider.BillingFact{}, runIdentity(newer)) FlushUsage() rows, err := ReadUsage(ledger, time.Time{}) if err != nil { diff --git a/internal/session/usage_billing.go b/internal/session/usage_billing.go new file mode 100644 index 0000000000..1fdde49f8b --- /dev/null +++ b/internal/session/usage_billing.go @@ -0,0 +1,133 @@ +package session + +import ( + "crypto/rand" + "encoding/hex" + "sync" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/provider" +) + +// ── THE FACTS A LEDGER ROW CARRIES ABOUT ITS OWN MONEY ────────────────────── +// +// A row has always said how much; a spending cap also needs to know WHICH ROW +// (so a process's own notice of a call and the same call read back off disk are +// one call and not two), WHAT KIND of money it was (so a plan's included calls +// are not stopped by a dollar cap, and nothing else is let through on a guess), +// and WHERE THE FIGURE CAME FROM (so a provider's charge and a figure somebody +// multiplied are never mistaken for each other). Each is written once, at the +// moment it is known, by whoever knows it — and absent everywhere else. + +// The words [UsageLine.Price] may carry. They are a closed vocabulary spelled +// once, here, so a reader can switch on them. +const ( + usagePriceProvider = "provider" + usagePriceReceipt = "receipt" + // UsagePriceList is a figure computed from counted tokens and the model's + // published price — a record at the provider's price, never a forecast. It + // is exported for the command-line writers that compute one. + UsagePriceList = "list" + // UsagePriceProvider is the provider's own charge, for the command-line + // writers that bank a provider's usage block themselves. + UsagePriceProvider = usagePriceProvider +) + +// usagePrice states where a row's money came from, and states nothing for a +// row with no money on it: a provenance for a figure that is not there would +// be a word about nothing. +func usagePrice(usd float64, price string) string { + if usd <= 0 { + return "" + } + return price +} + +// withUsageBilling writes a transport's billing statement onto a row, and +// writes nothing for a fact nobody stated — so the row stays exactly as wide as +// it was, and "absent" keeps meaning "nobody said". +func withUsageBilling(line UsageLine, fact provider.BillingFact) UsageLine { + if !fact.Known() { + return line + } + line.Billing, line.BillingBasis = string(fact.Kind), fact.Basis + return line +} + +// newUsageRowID is a fresh row name: twelve random bytes in hex. A collision +// would merge two calls in a dedupe, so it is long enough that one never +// happens on any ledger a person could accumulate. +func newUsageRowID() string { + var raw [12]byte + _, _ = rand.Read(raw[:]) + return hex.EncodeToString(raw[:]) +} + +// ── an errand's road, from its ladder to its bank ─────────────────────────── +// +// An errand ([Agent.callRole]) gets its answer on a slot of its own and hands +// the ANSWER back to a caller, who banks it later through [Agent.addUsageAs]. +// The road the answer came down is known only at the ladder; the bank is a +// dozen call sites away. So the ladder notes the road against the answer it is +// returning, and the bank takes it back by the same answer. +// +// IT IS BOUNDED, AND WHAT IT DROPS IS ONLY A STATEMENT, NEVER MONEY. An answer +// its caller decided not to bank stays noted until newer notes push it out; an +// answer whose note was pushed out is banked exactly as before, with no billing +// on its row — which reads as "nobody said", the conservative reading, never as +// a plan's included call. + +// answerBillingNotes is how many errand answers may be waiting at once. An +// errand is banked in the breath after it returns, so a handful is the +// ordinary depth; the bound only keeps a caller that never banks from holding +// answers alive. +const answerBillingNotes = 64 + +var answerBillings = struct { + mu sync.Mutex + notes map[*ai.Response]provider.BillingFact + ring [answerBillingNotes]*ai.Response + next int +}{notes: map[*ai.Response]provider.BillingFact{}} + +// noteAnswerBilling remembers which road one returned answer came down. +func noteAnswerBilling(response *ai.Response, fact provider.BillingFact) { + if response == nil { + return + } + answerBillings.mu.Lock() + defer answerBillings.mu.Unlock() + if _, held := answerBillings.notes[response]; held { + answerBillings.notes[response] = fact + return + } + if old := answerBillings.ring[answerBillings.next]; old != nil { + delete(answerBillings.notes, old) + } + answerBillings.ring[answerBillings.next] = response + answerBillings.next = (answerBillings.next + 1) % answerBillingNotes + answerBillings.notes[response] = fact +} + +// takeAnswerBilling hands back, once, the road one answer was noted with. The +// second answer says the answer was noted at all — that it came off the +// transport through an errand's ladder rather than being assembled here. +func takeAnswerBilling(response *ai.Response) (provider.BillingFact, bool) { + if response == nil { + return provider.BillingFact{}, false + } + answerBillings.mu.Lock() + defer answerBillings.mu.Unlock() + fact, held := answerBillings.notes[response] + if !held { + return provider.BillingFact{}, false + } + delete(answerBillings.notes, response) + for i, waiting := range answerBillings.ring { + if waiting == response { + answerBillings.ring[i] = nil + break + } + } + return fact, true +} diff --git a/internal/session/usage_billing_test.go b/internal/session/usage_billing_test.go new file mode 100644 index 0000000000..0ba8f9c1df --- /dev/null +++ b/internal/session/usage_billing_test.go @@ -0,0 +1,240 @@ +package session + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/http/httptest" + "os" + "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/roles" +) + +// pricedProvider is a real provider client on a fake endpoint that answers +// every completion with a priced usage block, built with the billing statement +// a bound door would give it. +func pricedProvider(t *testing.T, billing provider.BillingFact) *provider.Client { + t.Helper() + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var envelope struct { + Stream bool `json:"stream"` + } + body, _ := io.ReadAll(r.Body) + _ = json.Unmarshal(body, &envelope) + usage := `"usage":{"prompt_tokens":40,"completion_tokens":7,"total_tokens":47,"cost":0.013}` + if envelope.Stream { + w.Header().Set("Content-Type", "text/event-stream") + fmt.Fprintf(w, "data: {\"id\":\"g-1\",\"model\":\"test/model\",\"choices\":[{\"index\":0,\"delta\":{\"role\":\"assistant\",\"content\":\"done\"},\"finish_reason\":\"stop\"}],%s}\n\ndata: [DONE]\n\n", usage) + return + } + w.Header().Set("Content-Type", "application/json") + fmt.Fprintf(w, `{"id":"g-1","model":"test/model","choices":[{"index":0,"finish_reason":"stop","message":{"role":"assistant","content":"done"}}],%s}`, usage) + })) + t.Cleanup(server.Close) + client, err := provider.NewClient(provider.Config{ + APIKey: "test-key", BaseURL: server.URL, Model: "test/model", + HTTPClient: server.Client(), Billing: billing, + }) + if err != nil { + t.Fatal(err) + } + return client +} + +// rawLedgerRows reads the ledger back as the JSON objects on disk, so a test +// can see which keys a row carries and not only what the struct decodes to. +func rawLedgerRows(t *testing.T, path string) []map[string]any { + t.Helper() + FlushUsage() + data, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + var rows []map[string]any + for _, raw := range strings.Split(strings.TrimSpace(string(data)), "\n") { + if raw == "" { + continue + } + var row map[string]any + if err := json.Unmarshal([]byte(raw), &row); err != nil { + t.Fatalf("row %q: %v", raw, err) + } + rows = append(rows, row) + } + return rows +} + +// TestATurnsRowCarriesTheRoadItsAnswerCameDown drives a real turn through a +// real provider client: the row names itself, says the plan door stated the +// call was included, and says the money is the provider's own figure. +func TestATurnsRowCarriesTheRoadItsAnswerCameDown(t *testing.T) { + ledger := filepath.Join(t.TempDir(), UsageLedgerName) + plan := provider.IncludedDoorFact("z-ai", "coding-plan") + agent, _ := newTestAgent(t, pricedProvider(t, plan), func(config *Config) { + config.usageLedger = ledger + config.SessionFile = filepath.Join(t.TempDir(), "session.jsonl") + }) + collect(t, mustSubmit(t, agent, "hello")) + // The turn's own call, and the title errand the first turn starts beside + // it on its own detached road: both came down the same plan door. + rows := rawLedgerRows(t, ledger) + seats := map[any]bool{} + ids := map[any]bool{} + for _, row := range rows { + seats[row["seat"]] = true + if row["billing"] != "included" || row["billing_basis"] != "door:z-ai/coding-plan" || row["price"] != "provider" { + t.Fatalf("row = %v, want included on door:z-ai/coding-plan priced by the provider", row) + } + id, _ := row["id"].(string) + if len(id) != 24 || ids[id] { + t.Fatalf("row id = %v, want a fresh 24-hex row name", row["id"]) + } + ids[id] = true + if row["usd"] != 0.013 { + t.Fatalf("row usd = %v, want the provider's 0.013 unchanged", row["usd"]) + } + } + if !seats["talk"] { + t.Fatalf("no row for the turn's own call: %v", rows) + } +} + +// TestAnUnstatedRoadLeavesTheRowSayingNothing: a client nobody stated a +// billing fact for writes a row with no billing key at all — never "included", +// never "unknown" spelled as though it were a statement. +func TestAnUnstatedRoadLeavesTheRowSayingNothing(t *testing.T) { + ledger := filepath.Join(t.TempDir(), UsageLedgerName) + agent, _ := newTestAgent(t, pricedProvider(t, provider.BillingFact{}), func(config *Config) { + config.usageLedger = ledger + config.SessionFile = filepath.Join(t.TempDir(), "session.jsonl") + }) + collect(t, mustSubmit(t, agent, "hello")) + rows := rawLedgerRows(t, ledger) + if len(rows) == 0 { + t.Fatal("the turn wrote no row") + } + for _, row := range rows { + if _, said := row["billing"]; said { + t.Fatalf("unstated road wrote billing: %v", row) + } + if _, said := row["billing_basis"]; said { + t.Fatalf("unstated road wrote a basis: %v", row) + } + } +} + +// TestADetachedErrandsRowCarriesItsOwnRoad: an errand run on the real +// transport and banked later through the detached door — the shape a title or +// a task name takes — carries the road its own ladder saw. +func TestADetachedErrandsRowCarriesItsOwnRoad(t *testing.T) { + ledger := filepath.Join(t.TempDir(), UsageLedgerName) + meter := provider.MeteredDoorFact("z-ai", "metered") + agent, _ := newTestAgent(t, pricedProvider(t, meter), func(config *Config) { + config.usageLedger = ledger + config.SessionFile = filepath.Join(t.TempDir(), "session.jsonl") + }) + response, called, err := agent.callRole(t.Context(), roles.RoleTaskName, "test/model", + []ai.Message{textMessage("user", "name this")}) + if err != nil || response == nil { + t.Fatalf("errand = %v, %v", response, err) + } + agent.addDetachedUsageAs(response, called, 1, auxRoleTaskName) + rows := rawLedgerRows(t, ledger) + if len(rows) != 1 { + t.Fatalf("ledger rows = %d, want the errand's one", len(rows)) + } + if rows[0]["billing"] != "metered" || rows[0]["billing_basis"] != "door:z-ai/metered" || rows[0]["price"] != "provider" { + t.Fatalf("errand row = %v, want metered on door:z-ai/metered priced by the provider", rows[0]) + } + // AND THE NOTE IS SPENT: the same answer banked again is nobody's word. + if _, noted := takeAnswerBilling(response); noted { + t.Fatal("an errand's road was handed out twice") + } +} + +// TestALateReceiptsRowCarriesTheRoadItWasOwedUnder: the receipt's captured +// road — not the agent's or the client's current one — is what the late row +// says, and its money is marked as the receipt's. +func TestALateReceiptsRowCarriesTheRoadItWasOwedUnder(t *testing.T) { + ledger := filepath.Join(t.TempDir(), UsageLedgerName) + agent, _ := ledgerAgent(t, ledger) + road := provider.BillingFact{Kind: provider.BillingMetered, Basis: "overflow:pay-as-you-go"} + agent.reconciled(provider.Reconciled{ + Billed: provider.Billed{Model: "test/model", PromptTokens: 30, CompletionTokens: 9, Cost: 0.41, Billing: road}, + Ref: "generation-late", Found: true, + }) + rows := rawLedgerRows(t, ledger) + if len(rows) != 1 { + t.Fatalf("ledger rows = %d, want the receipt's one", len(rows)) + } + row := rows[0] + if row["billing"] != "metered" || row["billing_basis"] != "overflow:pay-as-you-go" || row["price"] != "receipt" || row["reconciled"] != true { + t.Fatalf("late row = %v, want the overflow road priced by its receipt", row) + } +} + +// TestARowIsNamedOnceAndKeepsItsName: the ledger's one door names a row that +// has no name, never renames one that has, gives two rows two names, and an +// old row with none of the new keys still decodes. +func TestARowIsNamedOnceAndKeepsItsName(t *testing.T) { + path := filepath.Join(t.TempDir(), UsageLedgerName) + at := time.Now() + recordUsage(t, path, UsageLine{At: at, Model: "a", Calls: 1, USD: 0.1}) + recordUsage(t, path, UsageLine{At: at, Model: "b", Calls: 1, USD: 0.2}) + recordUsage(t, path, UsageLine{ID: "kept-by-its-writer", At: at, Model: "c", Calls: 1, USD: 0.3}) + // A row a build before these fields wrote. + old := `{"at":"2026-09-01T10:00:00Z","day":"2026-09-01","model":"old","calls":1,"usd":0.5}` + "\n" + file, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o600) + if err != nil { + t.Fatal(err) + } + if _, err := file.WriteString(old); err != nil { + t.Fatal(err) + } + _ = file.Close() + lines, err := ReadUsage(path, time.Time{}) + if err != nil || len(lines) != 4 { + t.Fatalf("read = %d rows, %v", len(lines), err) + } + if lines[0].ID == "" || lines[1].ID == "" || lines[0].ID == lines[1].ID { + t.Fatalf("ids = %q, %q, want two distinct names", lines[0].ID, lines[1].ID) + } + if lines[2].ID != "kept-by-its-writer" { + t.Fatalf("a named row was renamed to %q", lines[2].ID) + } + if lines[3].ID != "" || lines[3].Billing != "" || lines[3].Price != "" || lines[3].USD != 0.5 { + t.Fatalf("old row = %+v, want its figures and none of the new keys", lines[3]) + } +} + +// TestAnErrandNoteGivesWayWithoutTouchingMoney: past the bound, the oldest +// note is forgotten and the answer it belonged to is banked as "nobody said"; +// a note still inside the bound is kept. +func TestAnErrandNoteGivesWayWithoutTouchingMoney(t *testing.T) { + plan := provider.IncludedDoorFact("z-ai", "coding-plan") + first := &ai.Response{} + noteAnswerBilling(first, plan) + var last *ai.Response + for i := 0; i < answerBillingNotes; i++ { + last = &ai.Response{} + noteAnswerBilling(last, plan) + } + if _, noted := takeAnswerBilling(first); noted { + t.Fatal("a note past the bound was still held") + } + if fact, noted := takeAnswerBilling(last); !noted || fact != plan { + t.Fatalf("the newest note = %+v, %t, want %+v", fact, noted, plan) + } + for i := range answerBillings.ring { + if waiting := answerBillings.ring[i]; waiting != nil { + takeAnswerBilling(waiting) + } + } +} diff --git a/internal/session/usage_ledger.go b/internal/session/usage_ledger.go index 846bc93cca..9d5c084e0b 100644 --- a/internal/session/usage_ledger.go +++ b/internal/session/usage_ledger.go @@ -131,6 +131,29 @@ 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 { + // ID names this one row, once, so two readers that meet the same row — a + // process's own notice of what it just banked and the same row read back + // off the disk — can count it once. It is stamped by [RecordUsage] when the + // row is first handed over and is NEVER rewritten: a row already carrying + // one keeps it. Rows written before this field existed have none, and + // every reader counts them by position, exactly as it always did. + ID string `json:"id,omitempty"` + // Billing is what kind of money this call was — "metered", "included" in a + // plan — and is written ONLY where the road that answered stated it + // (internal/provider's billingfact.go): a bound billing door, or the plan + // overflow a person opted into. Absent means nobody said, which a cap + // reads as money it may have to stop and a page reads as unknown. It is + // never inferred from a zero price, a direct key, a label or a sign-in. + Billing string `json:"billing,omitempty"` + // BillingBasis is the statement Billing came from — `door:/` + // or `overflow:` — and is present exactly when Billing is. + BillingBasis string `json:"billing_basis,omitempty"` + // Price is where USD came from: "provider" for the charge the provider's + // own usage block carried, "receipt" for its later receipt, "list" for + // counted tokens times the model's published price. Absent is nobody said, + // which is every row before this field and every row whose writer did not + // state it; USD itself keeps its old meaning either way. + Price string `json:"price,omitempty"` // Run is the immutable execution that made this call, when its caller // supplied one. Older, auxiliary, or late receipt rows may have no identity; // absence must never be read as a single aggregate run. @@ -822,6 +845,11 @@ func RecordUsage(path string, line UsageLine) { if strings.TrimSpace(line.Day) == "" { line.Day = line.At.Local().Format(usageDayLayout) } + // THE ROW IS NAMED HERE, the one door every ledger row passes, so no + // writer can forget to and none can name it twice ([UsageLine.ID]). + if strings.TrimSpace(line.ID) == "" { + line.ID = newUsageRowID() + } payload, err := json.Marshal(line) if err != nil { dropUsageRow() @@ -967,6 +995,7 @@ func (a *Agent) recordUsageLine(call bankedCall) { USD: used.CostUSD, Empty: used.EmptyReflex > 0, Reconciled: call.reconciled, + Price: usagePrice(used.CostUSD, call.price), Session: session, Run: call.run, @@ -997,6 +1026,7 @@ func (a *Agent) recordUsageLine(call bankedCall) { seat, _ = SeatOfAgent(a.agentKind()) } line = TagUsage(line, roles.Role(call.role), seat) + line = withUsageBilling(line, call.billing) // 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 diff --git a/internal/session/usage_ledger_test.go b/internal/session/usage_ledger_test.go index 4684cc3cf7..6106bd1933 100644 --- a/internal/session/usage_ledger_test.go +++ b/internal/session/usage_ledger_test.go @@ -37,7 +37,7 @@ func billedCall(model string, in, out int, usd float64) *ai.Response { // place a ledger row for a turn's own call comes from, so a test that wants a // row makes a call rather than sealing a turn. func bankCall(agent *Agent, turn *Usage, model string, in, out int, usd float64, lane laneFacts) { - agent.addUsage(turn, billedCall(model, in, out, usd), model, "", lane) + agent.addUsage(turn, billedCall(model, in, out, usd), model, "", lane, provider.BillingFact{}) } // ledgerAgent is an agent writing to a ledger of its own, which is what every diff --git a/internal/session/usagejournal_test.go b/internal/session/usagejournal_test.go index 4c16af3bda..60ed5307b5 100644 --- a/internal/session/usagejournal_test.go +++ b/internal/session/usagejournal_test.go @@ -7,6 +7,8 @@ import ( "testing" "time" + "github.com/Agent-Field/codeaf/internal/provider" + "github.com/Agent-Field/agentfield/sdk/go/ai" ) @@ -309,15 +311,15 @@ func TestATurnThatHoppedModelsSealsOneUsageLinePerModel(t *testing.T) { var turn Usage before := pricedResponse("a step", 0.25) before.Model = "z-ai/glm-5.3" - agent.addUsage(&turn, before, "z-ai/glm-5.3", "", laneFacts{}) + agent.addUsage(&turn, before, "z-ai/glm-5.3", "", laneFacts{}, provider.BillingFact{}) before2 := pricedResponse("another step", 0.5) before2.Model = "z-ai/glm-5.3" before2.Usage.PromptTokens = 20 - agent.addUsage(&turn, before2, "z-ai/glm-5.3", "", laneFacts{}) + agent.addUsage(&turn, before2, "z-ai/glm-5.3", "", laneFacts{}, provider.BillingFact{}) after := pricedResponse("the hop's step", 0.75) after.Model = "deepseek/deepseek-v4.1-flash" after.Usage.PromptTokens = 30 - agent.addUsage(&turn, after, "deepseek/deepseek-v4.1-flash", "", laneFacts{}) + agent.addUsage(&turn, after, "deepseek/deepseek-v4.1-flash", "", laneFacts{}, provider.BillingFact{}) started := time.Now().Add(-time.Minute) agent.sealTurn(turn, started, "deepseek/deepseek-v4.1-flash") From c9f32bf99e27a7274ab2b133effdd73bfced252a Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 23:21:00 -0400 Subject: [PATCH 115/459] Keep billing notes without retaining answer payloads --- internal/session/usage_billing.go | 30 +++++++++++++++----------- internal/session/usage_billing_test.go | 20 ++++++++++++++++- 2 files changed, 36 insertions(+), 14 deletions(-) diff --git a/internal/session/usage_billing.go b/internal/session/usage_billing.go index 1fdde49f8b..92c002367f 100644 --- a/internal/session/usage_billing.go +++ b/internal/session/usage_billing.go @@ -4,6 +4,7 @@ import ( "crypto/rand" "encoding/hex" "sync" + "weak" "github.com/Agent-Field/agentfield/sdk/go/ai" "github.com/Agent-Field/codeaf/internal/provider" @@ -80,15 +81,16 @@ func newUsageRowID() string { // answerBillingNotes is how many errand answers may be waiting at once. An // errand is banked in the breath after it returns, so a handful is the // ordinary depth; the bound only keeps a caller that never banks from holding -// answers alive. +// answers alive. Keys are weak: even one unbanked response may hold a large +// payload, so a count bound alone would not bound retained answer memory. const answerBillingNotes = 64 var answerBillings = struct { mu sync.Mutex - notes map[*ai.Response]provider.BillingFact - ring [answerBillingNotes]*ai.Response + notes map[weak.Pointer[ai.Response]]provider.BillingFact + ring [answerBillingNotes]weak.Pointer[ai.Response] next int -}{notes: map[*ai.Response]provider.BillingFact{}} +}{notes: map[weak.Pointer[ai.Response]]provider.BillingFact{}} // noteAnswerBilling remembers which road one returned answer came down. func noteAnswerBilling(response *ai.Response, fact provider.BillingFact) { @@ -97,16 +99,17 @@ func noteAnswerBilling(response *ai.Response, fact provider.BillingFact) { } answerBillings.mu.Lock() defer answerBillings.mu.Unlock() - if _, held := answerBillings.notes[response]; held { - answerBillings.notes[response] = fact + key := weak.Make(response) + if _, held := answerBillings.notes[key]; held { + answerBillings.notes[key] = fact return } - if old := answerBillings.ring[answerBillings.next]; old != nil { + if old := answerBillings.ring[answerBillings.next]; old != (weak.Pointer[ai.Response]{}) { delete(answerBillings.notes, old) } - answerBillings.ring[answerBillings.next] = response + answerBillings.ring[answerBillings.next] = key answerBillings.next = (answerBillings.next + 1) % answerBillingNotes - answerBillings.notes[response] = fact + answerBillings.notes[key] = fact } // takeAnswerBilling hands back, once, the road one answer was noted with. The @@ -118,14 +121,15 @@ func takeAnswerBilling(response *ai.Response) (provider.BillingFact, bool) { } answerBillings.mu.Lock() defer answerBillings.mu.Unlock() - fact, held := answerBillings.notes[response] + key := weak.Make(response) + fact, held := answerBillings.notes[key] if !held { return provider.BillingFact{}, false } - delete(answerBillings.notes, response) + delete(answerBillings.notes, key) for i, waiting := range answerBillings.ring { - if waiting == response { - answerBillings.ring[i] = nil + if waiting == key { + answerBillings.ring[i] = weak.Pointer[ai.Response]{} break } } diff --git a/internal/session/usage_billing_test.go b/internal/session/usage_billing_test.go index 0ba8f9c1df..a260a41237 100644 --- a/internal/session/usage_billing_test.go +++ b/internal/session/usage_billing_test.go @@ -8,6 +8,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "runtime" "strings" "testing" "time" @@ -233,8 +234,25 @@ func TestAnErrandNoteGivesWayWithoutTouchingMoney(t *testing.T) { t.Fatalf("the newest note = %+v, %t, want %+v", fact, noted, plan) } for i := range answerBillings.ring { - if waiting := answerBillings.ring[i]; waiting != nil { + if waiting := answerBillings.ring[i].Value(); waiting != nil { takeAnswerBilling(waiting) } } } + +func TestAnUnbankedBillingNoteDoesNotRetainItsAnswer(t *testing.T) { + collected := func() <-chan struct{} { + done := make(chan struct{}) + response := &ai.Response{} + runtime.AddCleanup(response, func(ch chan struct{}) { close(ch) }, done) + noteAnswerBilling(response, provider.IncludedDoorFact("test", "plan")) + runtime.KeepAlive(response) + return done + }() + runtime.GC() + select { + case <-collected: + case <-time.After(10 * time.Second): + t.Fatal("billing metadata retained an unbanked answer") + } +} From 0c5bc0c34afad9f90ff397586ed82ac2ec66fc40 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 23:37:36 -0400 Subject: [PATCH 116/459] Reject negative cost and request counts consistently in spend queries --- internal/session/usage_query.go | 2 +- internal/session/usage_query_test.go | 13 +++++++++---- 2 files changed, 10 insertions(+), 5 deletions(-) diff --git a/internal/session/usage_query.go b/internal/session/usage_query.go index 99be8260b1..dd4ac42757 100644 --- a/internal/session/usage_query.go +++ b/internal/session/usage_query.go @@ -502,7 +502,7 @@ func (e *UsageEntry) hasModel(model string) bool { // sum the totals could hold, every row's share of it can too. func (t *UsageLedgerTotals) count(line UsageLine) error { calls := max(line.Calls, 1) - if line.Input < 0 || line.Output < 0 || math.IsNaN(line.USD) || math.IsInf(line.USD, 0) || + if line.Input < 0 || line.Output < 0 || line.USD < 0 || line.Calls < 0 || math.IsNaN(line.USD) || math.IsInf(line.USD, 0) || t.Records == math.MaxInt || t.Calls > math.MaxInt-calls || t.Input > math.MaxInt64-int64(line.Input) || t.Output > math.MaxInt64-int64(line.Output) { return ErrUsageLedgerFigures diff --git a/internal/session/usage_query_test.go b/internal/session/usage_query_test.go index 661c6e4ce5..3d78b3069a 100644 --- a/internal/session/usage_query_test.go +++ b/internal/session/usage_query_test.go @@ -539,10 +539,12 @@ func TestLedgerRunWithTenThousandModels(t *testing.T) { func TestLedgerRefusesFiguresItCannotAdd(t *testing.T) { at := time.Date(2026, 1, 1, 0, 0, 0, 0, time.UTC) for name, lines := range map[string][]UsageLine{ - "cost": {{At: at, USD: math.MaxFloat64}, {Run: "r", At: at, USD: math.MaxFloat64}}, - "calls": {{At: at, Calls: math.MaxInt}, {At: at, Calls: 2}}, - "tokens": {{At: at, Input: math.MaxInt}, {Run: "r", At: at, Input: math.MaxInt}, {At: at, Input: 2}}, - "negative": {{At: at, Output: -5, USD: cent}}, + "cost": {{At: at, USD: math.MaxFloat64}, {Run: "r", At: at, USD: math.MaxFloat64}}, + "calls": {{At: at, Calls: math.MaxInt}, {At: at, Calls: 2}}, + "tokens": {{At: at, Input: math.MaxInt}, {Run: "r", At: at, Input: math.MaxInt}, {At: at, Input: 2}}, + "negative": {{At: at, Output: -5, USD: cent}}, + "negative cost": {{At: at, USD: -cent}}, + "negative calls": {{At: at, Calls: -1, USD: cent}}, } { path := writeLedger(t, lines, nil, "") if _, err := QueryUsageLedger(path, UsageQuery{}, "", 0, DefaultUsageLedgerBudget); !errors.Is(err, ErrUsageLedgerFigures) { @@ -551,6 +553,9 @@ func TestLedgerRefusesFiguresItCannotAdd(t *testing.T) { if _, _, err := EachUsageLedgerEntry(path, UsageQuery{}, DefaultUsageLedgerBudget, func(UsageEntry) error { return nil }); !errors.Is(err, ErrUsageLedgerFigures) { t.Fatalf("%s: walk %v", name, err) } + if _, err := QueryUsageReport(path, UsageQuery{From: at, To: at.AddDate(0, 0, 1)}, time.UTC, UsageReportBudget{}); !errors.Is(err, ErrUsageLedgerFigures) { + t.Fatalf("%s: report %v", name, err) + } } // One of the two huge figures alone is a figure, and is answered. path := writeLedger(t, []UsageLine{{At: at, USD: math.MaxFloat64}}, nil, "") From 3da32b2a47e7a40e332b2b68350d5aee27cba688 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 23:40:32 -0400 Subject: [PATCH 117/459] provider: refuse an oversize media reply instead of saving it truncated Co-Authored-By: Claude Sonnet 5.5 --- .../unreleased/0-media-oversize-refused.md | 8 ++ internal/provider/media.go | 63 +++++++-- internal/provider/media_bounds_test.go | 130 ++++++++++++++++++ internal/provider/music.go | 33 ++++- internal/provider/sse.go | 14 ++ 5 files changed, 232 insertions(+), 16 deletions(-) create mode 100644 docs/changes/unreleased/0-media-oversize-refused.md create mode 100644 internal/provider/media_bounds_test.go diff --git a/docs/changes/unreleased/0-media-oversize-refused.md b/docs/changes/unreleased/0-media-oversize-refused.md new file mode 100644 index 0000000000..72c446329f --- /dev/null +++ b/docs/changes/unreleased/0-media-oversize-refused.md @@ -0,0 +1,8 @@ +--- +kind: fixed +title: a generated image, speech, music or video reply over its size limit is refused instead of saved cut short +pr: 0 +surface: [engine] +invalidates: + - "Was true: the media client read a reply through a limit reader with no check past the limit, so a video over 512 MB or an image/speech reply over 128 MB came back as a successful, silently truncated file that would not play, and a music stream with no message separator grew its buffer without end. Is true now: a reply one byte over its limit (or declaring a longer length up front) is an error naming the limit, no file is written, and a music event or the clip it adds up to is bounded by the same ceiling." +--- diff --git a/internal/provider/media.go b/internal/provider/media.go index 2d7cac3acd..90b98cc483 100644 --- a/internal/provider/media.go +++ b/internal/provider/media.go @@ -29,6 +29,49 @@ const ( var ErrVideoTimeout = errors.New("video generation timed out") +// ErrMediaTooLarge is what a response over its byte ceiling is refused with. +// A body is NEVER cut at the ceiling and handed on: a truncated video or clip +// is a file that looks saved and does not play. +var ErrMediaTooLarge = errors.New("media response is over the size limit") + +// readMediaBody reads one media response body whole, under a hard ceiling. +// +// THE CEILING IS CHECKED ONE BYTE PAST ITSELF. io.LimitReader(body, limit) +// alone cannot tell a body of exactly limit bytes from a longer one cut off at +// limit, so an oversize reply used to come back as a "successful" truncated +// artifact. Reading limit+1 and refusing on the extra byte is the whole fix, +// and a declared Content-Length over the limit is refused before a byte is +// read, so the usual oversize reply allocates nothing. +// +// An error status is read through the small error peek instead: its body is +// diagnosis text, truncating it is harmless, and a hostile 4xx must not be +// able to make the client buffer the ceiling. Every failure is reported to the +// call log here, so callers return the error as it comes. +func readMediaBody(response *http.Response, limit int64, what string) ([]byte, error) { + failed := response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices + if failed { + limit = maxErrorPeek + } else if response.ContentLength > limit { + err := fmt.Errorf("%s is %d MB, over the %d MB limit: %w", what, response.ContentLength>>20, limit>>20, ErrMediaTooLarge) + finishMediaResponse(response, response.StatusCode, err, nil) + return nil, err + } + payload, err := io.ReadAll(io.LimitReader(response.Body, limit+1)) + if err != nil { + finishMediaResponse(response, response.StatusCode, err, nil) + return nil, fmt.Errorf("read %s: %w", what, err) + } + if int64(len(payload)) > limit { + if failed { + return payload[:limit], nil + } + err := fmt.Errorf("%s is over the %d MB limit and was discarded: %w", what, limit>>20, ErrMediaTooLarge) + finishMediaResponse(response, response.StatusCode, err, nil) + return nil, err + } + return payload, nil +} + // ImageRequest is OpenRouter's non-streaming image generation request. // // InputReferences carries the SAME envelope the video endpoint takes and not a @@ -208,10 +251,9 @@ func (c *MediaClient) Speak(ctx context.Context, request SpeechRequest) (*Speech return nil, err } defer response.Body.Close() - payload, err := io.ReadAll(io.LimitReader(response.Body, maxMediaResponseBytes)) + payload, err := readMediaBody(response, maxMediaResponseBytes, "speech response") if err != nil { - finishMediaResponse(response, response.StatusCode, err, nil) - return nil, fmt.Errorf("read speech response: %w", err) + return nil, err } if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { err := apiError(response.StatusCode, payload) @@ -329,10 +371,9 @@ func (c *MediaClient) downloadVideo(ctx context.Context, job videoJob, model str return nil, err } defer response.Body.Close() - payload, err := io.ReadAll(io.LimitReader(response.Body, maxVideoResponseBytes)) + payload, err := readMediaBody(response, maxVideoResponseBytes, "video response") if err != nil { - finishMediaResponse(response, response.StatusCode, err, nil) - return nil, fmt.Errorf("read video response: %w", err) + return nil, err } if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { err := apiError(response.StatusCode, payload) @@ -358,10 +399,9 @@ func (c *MediaClient) postJSON(ctx context.Context, path string, request any, ta return nil, err } defer response.Body.Close() - payload, err := io.ReadAll(io.LimitReader(response.Body, maxMediaResponseBytes)) + payload, err := readMediaBody(response, maxMediaResponseBytes, "media response") if err != nil { - finishMediaResponse(response, response.StatusCode, err, nil) - return nil, fmt.Errorf("read media response: %w", err) + return nil, err } if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { err := apiError(response.StatusCode, payload) @@ -390,10 +430,9 @@ func (c *MediaClient) getJSON(ctx context.Context, endpoint string, target any, return err } defer response.Body.Close() - payload, err := io.ReadAll(io.LimitReader(response.Body, maxMediaResponseBytes)) + payload, err := readMediaBody(response, maxMediaResponseBytes, "media response") if err != nil { - finishMediaResponse(response, response.StatusCode, err, nil) - return fmt.Errorf("read media response: %w", err) + return err } if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { err := apiError(response.StatusCode, payload) diff --git a/internal/provider/media_bounds_test.go b/internal/provider/media_bounds_test.go new file mode 100644 index 0000000000..ae379d08ab --- /dev/null +++ b/internal/provider/media_bounds_test.go @@ -0,0 +1,130 @@ +package provider + +import ( + "bytes" + "context" + "errors" + "fmt" + "io" + "net/http" + "strings" + "testing" +) + +func bodyResponse(status int, length int64, body io.Reader) *http.Response { + return &http.Response{StatusCode: status, ContentLength: length, Body: io.NopCloser(body)} +} + +// A body of exactly the ceiling is a complete file; one byte more is a file +// that would have been cut, and it must never come back as a success. +func TestReadMediaBodyRefusesOneByteOverAndKeepsExactlyAtTheLimit(t *testing.T) { + const limit = 1024 + got, err := readMediaBody(bodyResponse(200, -1, bytes.NewReader(make([]byte, limit))), limit, "video response") + if err != nil || len(got) != limit { + t.Fatalf("exact-limit body = %d bytes, %v; want it whole", len(got), err) + } + got, err = readMediaBody(bodyResponse(200, -1, bytes.NewReader(make([]byte, limit+1))), limit, "video response") + if !errors.Is(err, ErrMediaTooLarge) || got != nil { + t.Fatalf("limit+1 body = %d bytes, %v; want ErrMediaTooLarge and no bytes", len(got), err) + } +} + +// A declared length over the ceiling is refused before the body is touched. +func TestReadMediaBodyRefusesADeclaredOversizeWithoutReading(t *testing.T) { + reader := &tripReader{} + _, err := readMediaBody(bodyResponse(200, 4096, reader), 1024, "speech response") + if !errors.Is(err, ErrMediaTooLarge) { + t.Fatalf("err = %v, want ErrMediaTooLarge", err) + } + if reader.reads != 0 { + t.Fatalf("body was read %d times before the refusal", reader.reads) + } +} + +type tripReader struct{ reads int } + +func (r *tripReader) Read([]byte) (int, error) { r.reads++; return 0, io.EOF } + +// An error status is diagnosis text: it is read through the small peek, may be +// truncated, and is never reported as oversize. +func TestReadMediaBodyTruncatesAnErrorBodyWithoutRefusing(t *testing.T) { + got, err := readMediaBody(bodyResponse(502, -1, strings.NewReader(strings.Repeat("x", maxErrorPeek*3))), 1<<30, "media response") + if err != nil || len(got) != maxErrorPeek { + t.Fatalf("error body = %d bytes, %v; want %d, nil", len(got), err, maxErrorPeek) + } +} + +type roundTrip func(*http.Request) (*http.Response, error) + +func (f roundTrip) RoundTrip(r *http.Request) (*http.Response, error) { return f(r) } + +// The three downloads that return raw bytes refuse an oversize reply as an +// error, and the response body is closed on the way out. +func TestSpeechAndVideoDownloadRefuseOversizeAndCloseTheBody(t *testing.T) { + var closed int + oversize := func(limit int64) *http.Client { + return &http.Client{Transport: roundTrip(func(r *http.Request) (*http.Response, error) { + body := &closeCounter{Reader: io.LimitReader(zeros{}, limit+1), closed: &closed} + return &http.Response{StatusCode: 200, ContentLength: -1, Body: body, Header: http.Header{}, Request: r}, nil + })} + } + // Streams limit+1 zero bytes: the only cost is the test's own read of them. + speak, _ := NewMediaClient(Config{APIKey: "k", BaseURL: "https://x.example/api/v1", HTTPClient: oversize(maxMediaResponseBytes)}) + if _, err := speak.Speak(context.Background(), SpeechRequest{Model: "m", Input: "hi"}); !errors.Is(err, ErrMediaTooLarge) { + t.Fatalf("Speak err = %v, want ErrMediaTooLarge", err) + } + video, _ := NewMediaClient(Config{APIKey: "k", BaseURL: "https://x.example/api/v1", HTTPClient: oversize(maxVideoResponseBytes)}) + _, err := video.downloadVideo(context.Background(), videoJob{ID: "j", UnsignedURLs: []string{"https://x.example/api/v1/videos/j/content"}}, "m") + if !errors.Is(err, ErrMediaTooLarge) { + t.Fatalf("downloadVideo err = %v, want ErrMediaTooLarge", err) + } + if closed != 2 { + t.Fatalf("closed %d bodies, want 2", closed) + } +} + +type zeros struct{} + +func (zeros) Read(p []byte) (int, error) { clear(p); return len(p), nil } + +type closeCounter struct { + io.Reader + closed *int +} + +func (c *closeCounter) Close() error { *c.closed++; return nil } + +func musicEvent(data string) string { + return fmt.Sprintf("data: {\"choices\":[{\"delta\":{\"audio\":{\"data\":%q}}}]}\n\n", data) +} + +// The ceiling is on the CUMULATIVE decoded clip across events, not per event. +func TestMusicStreamRefusesACumulativeOversizeClipInsteadOfTruncatingIt(t *testing.T) { + chunk := strings.Repeat("AAAA", 8) // 24 decoded bytes per event + stream := musicEvent(chunk) + musicEvent(chunk) + musicEvent(chunk) + "data: [DONE]\n\n" + if _, err := readMusicStreamUnder(strings.NewReader(stream), 72); err != nil { + t.Fatalf("clip of exactly the ceiling: %v", err) + } + _, err := readMusicStreamUnder(strings.NewReader(stream), 71) + if !errors.Is(err, ErrMediaTooLarge) { + t.Fatalf("clip over the ceiling err = %v, want ErrMediaTooLarge", err) + } +} + +// One event with no separator cannot grow the stitch buffer past the limit. +func TestMusicStreamBoundsASingleEndlessMessage(t *testing.T) { + endless := io.MultiReader(strings.NewReader("data: {\"x\":\""), io.LimitReader(zerosOf('a'), 1<<20)) + _, err := readMusicStreamUnder(endless, 1024) + if !errors.Is(err, ErrMediaTooLarge) { + t.Fatalf("endless message err = %v, want ErrMediaTooLarge", err) + } +} + +type zerosOf byte + +func (b zerosOf) Read(p []byte) (int, error) { + for i := range p { + p[i] = byte(b) + } + return len(p), nil +} diff --git a/internal/provider/music.go b/internal/provider/music.go index 824d275070..e0bbf949d1 100644 --- a/internal/provider/music.go +++ b/internal/provider/music.go @@ -44,6 +44,10 @@ import ( // response must not be able to take the process with it. const maxMusicResponseBytes = 128 << 20 +// musicEnvelopeSlack is the room one event's JSON framing (ids, usage, format) +// gets beyond the base64 of a full-size clip. +const musicEnvelopeSlack = 64 << 10 + // MusicRequest is a composition brief. There is deliberately NO DURATION and no // format: the endpoint takes neither, a call returns whatever length the model // decides to write, and a field that was accepted and ignored would be a knob @@ -136,7 +140,7 @@ func (c *MediaClient) GenerateMusic(ctx context.Context, request MusicRequest) ( } defer response.Body.Close() if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { - payload, _ := io.ReadAll(io.LimitReader(response.Body, maxMediaResponseBytes)) + payload, _ := readMediaBody(response, maxMediaResponseBytes, "music error response") return nil, apiError(response.StatusCode, payload) } return readMusicStream(response.Body) @@ -155,7 +159,17 @@ func (c *MediaClient) GenerateMusic(ctx context.Context, request MusicRequest) ( // boundaries and a split that happened to land elsewhere would corrupt the file // in a way nothing here could detect. func readMusicStream(stream io.Reader) (*MusicResponse, error) { + return readMusicStreamUnder(stream, maxMusicResponseBytes) +} + +// readMusicStreamUnder is [readMusicStream] with the decoded-clip ceiling as a +// parameter, so a test can prove the refusal with kilobytes instead of 128 MB. +func readMusicStreamUnder(stream io.Reader, maxClip int) (*MusicResponse, error) { + maxEncoded := base64.StdEncoding.EncodedLen(maxClip) decoder := newSSEDecoder(stream) + // One event may carry the whole clip, so a single message is allowed the + // encoded size of the largest clip plus the JSON around it, and no more. + decoder.limit = maxEncoded + musicEnvelopeSlack var ( encoded bytes.Buffer format string @@ -167,6 +181,9 @@ func readMusicStream(stream io.Reader) (*MusicResponse, error) { break } if err != nil { + if errors.Is(err, errSSEMessageTooLarge) { + return nil, fmt.Errorf("music generation returned a clip over the %d byte limit; ask for a shorter piece: %w", maxClip, ErrMediaTooLarge) + } return nil, fmt.Errorf("read music stream: %w", err) } var chunk musicChunk @@ -195,8 +212,11 @@ func readMusicStream(stream io.Reader) (*MusicResponse, error) { if format == "" { format = strings.ToLower(strings.TrimSpace(audio.Format)) } - if encoded.Len()+len(audio.Data) > maxMusicResponseBytes { - return nil, fmt.Errorf("music generation returned more than %d bytes", maxMusicResponseBytes) + // The cumulative ENCODED size is what is held, so it is what is + // bounded: base64 of the largest allowed clip. Anything past it + // is refused rather than cut, so no clip is ever truncated. + if encoded.Len()+len(audio.Data) > maxEncoded { + return nil, fmt.Errorf("music generation returned a clip over the %d byte limit; ask for a shorter piece: %w", maxClip, ErrMediaTooLarge) } encoded.WriteString(audio.Data) } @@ -207,7 +227,12 @@ func readMusicStream(stream io.Reader) (*MusicResponse, error) { // and never sends an audio delta at all. return nil, fmt.Errorf("the model returned no audio — it may not be a music model") } - clip, err := base64.StdEncoding.DecodeString(encoded.String()) + clip := make([]byte, base64.StdEncoding.DecodedLen(encoded.Len())) + n, err := base64.StdEncoding.Decode(clip, encoded.Bytes()) + clip = clip[:n] + if len(clip) > maxClip { + return nil, fmt.Errorf("music generation returned a clip over the %d byte limit; ask for a shorter piece: %w", maxClip, ErrMediaTooLarge) + } if err != nil || len(clip) == 0 { return nil, fmt.Errorf("music generation returned audio that could not be decoded") } diff --git a/internal/provider/sse.go b/internal/provider/sse.go index 55b28eaeee..5fe8af9f36 100644 --- a/internal/provider/sse.go +++ b/internal/provider/sse.go @@ -42,6 +42,11 @@ type sseDecoder struct { // speaking — the stall watch — would never hear it (streamguard.go says // what it buys). It is called from the read loop, so it must be cheap. alive func() + // limit, when positive, bounds one message's bytes. A reader that expects + // one big payload (a music clip) sets it so a stream that never sends a + // separator cannot grow the stitch buffer without end. Zero is unbounded, + // which is what every chat stream has always been. + limit int // chunk and wire are the decode targets, held here rather than declared per // event: a streamed answer is thousands of events, and the address of a // local one escapes into json.Unmarshal every single time. @@ -61,6 +66,9 @@ type sseDecoder struct { // traffic. const sseReadBuffer = 64 << 10 +// errSSEMessageTooLarge is returned when one message passes the decoder's limit. +var errSSEMessageTooLarge = errors.New("stream message is over the size limit") + func newSSEDecoder(reader io.Reader) *sseDecoder { return &sseDecoder{reader: bufio.NewReaderSize(reader, sseReadBuffer)} } @@ -110,6 +118,9 @@ func (d *sseDecoder) next() ([]byte, error) { // be megabytes. A piece that does not fit the read buffer carries // no '\n' at all, so it is stitched onto the message and cannot be // mistaken for the blank line below. + if d.limit > 0 && len(d.message)+len(line) > d.limit { + return nil, errSSEMessageTooLarge + } d.message = append(d.message, line...) continue } @@ -138,6 +149,9 @@ func (d *sseDecoder) next() ([]byte, error) { } continue } + if d.limit > 0 && len(d.message)+len(line) > d.limit { + return nil, errSSEMessageTooLarge + } d.message = append(d.message, line...) } if err != nil { From 3684a8cdc41d053ea0977ecc582b587ea3d89b96 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 23:51:37 -0400 Subject: [PATCH 118/459] Keep music byte bounds inside the established single reader --- internal/provider/media_bounds_test.go | 6 +++--- internal/provider/music.go | 15 +++++++-------- 2 files changed, 10 insertions(+), 11 deletions(-) diff --git a/internal/provider/media_bounds_test.go b/internal/provider/media_bounds_test.go index ae379d08ab..04f192c585 100644 --- a/internal/provider/media_bounds_test.go +++ b/internal/provider/media_bounds_test.go @@ -102,10 +102,10 @@ func musicEvent(data string) string { func TestMusicStreamRefusesACumulativeOversizeClipInsteadOfTruncatingIt(t *testing.T) { chunk := strings.Repeat("AAAA", 8) // 24 decoded bytes per event stream := musicEvent(chunk) + musicEvent(chunk) + musicEvent(chunk) + "data: [DONE]\n\n" - if _, err := readMusicStreamUnder(strings.NewReader(stream), 72); err != nil { + if _, err := readMusicStream(strings.NewReader(stream), 72); err != nil { t.Fatalf("clip of exactly the ceiling: %v", err) } - _, err := readMusicStreamUnder(strings.NewReader(stream), 71) + _, err := readMusicStream(strings.NewReader(stream), 71) if !errors.Is(err, ErrMediaTooLarge) { t.Fatalf("clip over the ceiling err = %v, want ErrMediaTooLarge", err) } @@ -114,7 +114,7 @@ func TestMusicStreamRefusesACumulativeOversizeClipInsteadOfTruncatingIt(t *testi // One event with no separator cannot grow the stitch buffer past the limit. func TestMusicStreamBoundsASingleEndlessMessage(t *testing.T) { endless := io.MultiReader(strings.NewReader("data: {\"x\":\""), io.LimitReader(zerosOf('a'), 1<<20)) - _, err := readMusicStreamUnder(endless, 1024) + _, err := readMusicStream(endless, 1024) if !errors.Is(err, ErrMediaTooLarge) { t.Fatalf("endless message err = %v, want ErrMediaTooLarge", err) } diff --git a/internal/provider/music.go b/internal/provider/music.go index e0bbf949d1..23d705e730 100644 --- a/internal/provider/music.go +++ b/internal/provider/music.go @@ -143,7 +143,7 @@ func (c *MediaClient) GenerateMusic(ctx context.Context, request MusicRequest) ( payload, _ := readMediaBody(response, maxMediaResponseBytes, "music error response") return nil, apiError(response.StatusCode, payload) } - return readMusicStream(response.Body) + return readMusicStream(response.Body, maxMusicResponseBytes) } // readMusicStream drains the event stream and returns the assembled clip. @@ -158,13 +158,12 @@ func (c *MediaClient) GenerateMusic(ctx context.Context, request MusicRequest) ( // would still be wrong, because base64 is only decodable on 4-character // boundaries and a split that happened to land elsewhere would corrupt the file // in a way nothing here could detect. -func readMusicStream(stream io.Reader) (*MusicResponse, error) { - return readMusicStreamUnder(stream, maxMusicResponseBytes) -} - -// readMusicStreamUnder is [readMusicStream] with the decoded-clip ceiling as a -// parameter, so a test can prove the refusal with kilobytes instead of 128 MB. -func readMusicStreamUnder(stream io.Reader, maxClip int) (*MusicResponse, error) { +// +// maxClip is the decoded-clip ceiling, a parameter so a test can prove the +// refusal with kilobytes instead of 128 MB; production passes +// [maxMusicResponseBytes]. (The funnel law pins this function by name as the one +// music reader, so there is deliberately no wrapper around it.) +func readMusicStream(stream io.Reader, maxClip int) (*MusicResponse, error) { maxEncoded := base64.StdEncoding.EncodedLen(maxClip) decoder := newSSEDecoder(stream) // One event may carry the whole clip, so a single message is allowed the From b292d11be2c72ce3689a884684b50c2147061274 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 23:18:18 -0400 Subject: [PATCH 119/459] Archive a closed crew as its own act, separate from close and delete A team gains an archived_at mark that only a closed team can take. File.Archive marks that one team: sub-teams are not archived, nothing is closed, stopped or deleted, and the roster, Traffic and packets stay as they were. Reopen clears the mark on every team it reopens. crews.archive returns the crew; crews.list gains archived, and the closed list leaves archived crews out while the terminal's ClosedTeams still holds every closed team. Co-Authored-By: Claude Sonnet 5.5 --- cmd/codeaf/appcrews.go | 54 +++++++++++- cmd/codeaf/appcrews_test.go | 58 +++++++++++++ internal/appwire/crews.go | 37 +++++--- internal/appwire/server.go | 7 +- internal/teams/archive_test.go | 154 +++++++++++++++++++++++++++++++++ internal/teams/lifecycle.go | 57 +++++++++++- internal/teams/teams.go | 15 +++- 7 files changed, 363 insertions(+), 19 deletions(-) create mode 100644 internal/teams/archive_test.go diff --git a/cmd/codeaf/appcrews.go b/cmd/codeaf/appcrews.go index 419c030525..c4f70a4aa2 100644 --- a/cmd/codeaf/appcrews.go +++ b/cmd/codeaf/appcrews.go @@ -324,6 +324,10 @@ func (b *appBackend) crewView(read appCrewRead, t teamstore.Team) appwire.Crew { closed := t.ClosedAt view.ClosedAt = &closed } + if t.Archived() { + archived := t.ArchivedAt + view.ArchivedAt = &archived + } if t.Wrap != nil { view.Wrap = &appwire.CrewWrap{Started: t.Wrap.Started, BoundS: int(t.Wrap.Bound.Seconds())} } @@ -1071,7 +1075,7 @@ func (b *appBackend) answerCrewAsk(agent, id, option, text, why string) (bool, e // ── the methods ───────────────────────────────────────────────────────────── -// CrewList is every open crew, or every closed one. +// CrewList is every open crew, or every closed one that is not archived. func (b *appBackend) CrewList(closed bool) ([]appwire.Crew, error) { b.crews() read, err := b.crewRead() @@ -1080,7 +1084,12 @@ func (b *appBackend) CrewList(closed bool) ([]appwire.Crew, error) { } teams := read.file.Open() if closed { - teams = read.file.ClosedTeams() + teams = nil + for _, t := range read.file.ClosedTeams() { + if !t.Archived() { + teams = append(teams, t) + } + } } out := make([]appwire.Crew, 0, len(teams)) for _, t := range teams { @@ -1089,6 +1098,21 @@ func (b *appBackend) CrewList(closed bool) ([]appwire.Crew, error) { return out, nil } +// CrewListArchived is every crew put away, the newest first. +func (b *appBackend) CrewListArchived() ([]appwire.Crew, error) { + b.crews() + read, err := b.crewRead() + if err != nil { + return nil, appEngineError(err) + } + teams := read.file.ArchivedTeams() + out := make([]appwire.Crew, 0, len(teams)) + for _, t := range teams { + out = append(out, b.crewView(read, t)) + } + return out, nil +} + // ListCrews is the open crews. func (b *appBackend) ListCrews() ([]appwire.Crew, error) { return b.CrewList(false) } @@ -1909,6 +1933,32 @@ func (b *appBackend) CrewReopen(id string) (appwire.Crew, error) { return b.CrewGet(id) } +// CrewArchive puts a closed crew away. It marks that crew only: the crews under +// it are not archived, and nothing is closed, stopped or deleted. An open crew +// is refused; closing is its own act. +func (b *appBackend) CrewArchive(id string) (appwire.Crew, error) { + t, _, err := b.crewTeam(id) + if err != nil { + return appwire.Crew{}, err + } + if t.Root { + return appwire.Crew{}, appwire.Errorf(appwire.CodeBadParams, "All crews does not archive") + } + err = b.crewEdit(func(f *teamstore.File) error { + if err := f.Archive(id, b.now()); err != nil { + if errors.Is(err, teamstore.ErrNotClosed) { + return appwire.Errorf(appwire.CodeBadParams, "close %s before archiving it", t.Name) + } + return err + } + return nil + }) + if err != nil { + return appwire.Crew{}, err + } + return b.CrewGet(id) +} + // CrewDelete forgets a closed crew and the crews under it. Their // conversations are never deleted. func (b *appBackend) CrewDelete(id string) ([]string, error) { diff --git a/cmd/codeaf/appcrews_test.go b/cmd/codeaf/appcrews_test.go index 5ab43e19f4..6b6915d123 100644 --- a/cmd/codeaf/appcrews_test.go +++ b/cmd/codeaf/appcrews_test.go @@ -185,6 +185,64 @@ func TestAppCrewLogLinesAndCloseReopen(t *testing.T) { } } +// ARCHIVING A CREW IS THE ENGINE'S OWN ACT, separate from close and delete: an +// open crew is refused, a closed one is marked, leaves the closed list, shows in +// the archived list and in get with archived_at, keeps its members, survives a +// fresh backend on the same store, and reopens as it was. +func TestAppCrewArchiveIsSeparateFromCloseAndDelete(t *testing.T) { + backend, rec, _, id := crewBackend(t) + backend.crews() + var wire *appwire.Error + if _, err := backend.CrewArchive(id); !errors.As(err, &wire) || wire.Code != appwire.CodeBadParams { + t.Fatalf("an open crew was archived: %v", err) + } + if open, _ := backend.CrewList(false); len(open) != 1 || open[0].ArchivedAt != nil { + t.Fatalf("a refused archive changed the crew: %+v", open) + } + if _, err := backend.CrewArchive("nope"); !errors.As(err, &wire) || wire.Code != appwire.CodeNotFound { + t.Fatalf("an unknown crew: %v", err) + } + if err := backend.CrewClose(id); err != nil { + t.Fatal(err) + } + crew, err := backend.CrewArchive(id) + if err != nil || crew.State != appwire.CrewClosed || crew.ArchivedAt == nil || len(crew.Members) != 2 { + t.Fatalf("archived = %+v, %v", crew, err) + } + if closed, _ := backend.CrewList(true); len(closed) != 0 { + t.Fatalf("an archived crew is still in the closed list: %+v", closed) + } + archived, err := backend.CrewListArchived() + if err != nil || len(archived) != 1 || archived[0].ID != id || archived[0].ClosedAt == nil { + t.Fatalf("the archived crews = %+v, %v", archived, err) + } + if got, err := backend.CrewGet(id); err != nil || got.ArchivedAt == nil { + t.Fatalf("get = %+v, %v", got, err) + } + var run *appCrewRun + backend.locked(func() { run = backend.crewRun }) + backend.crewTick(run, false) + rec.waitFor(t, 5*time.Second, func(event string, data any) bool { + c, ok := data.(appwire.CrewChanged) + return ok && c.Crew.ID == id && c.Crew.ArchivedAt != nil + }) + // The store itself, read fresh, holds the archive. + f, err := teamstore.Load(backend.profileDir) + if err != nil { + t.Fatal(err) + } + if team, _ := f.Team(id); !team.Archived() || len(team.Members) != 2 { + t.Fatalf("the store after reload = %+v", team) + } + back, err := backend.CrewReopen(id) + if err != nil || back.State == appwire.CrewClosed || back.ArchivedAt != nil || len(back.Members) != 2 { + t.Fatalf("reopened = %+v, %v", back, err) + } + if list, _ := backend.CrewListArchived(); len(list) != 0 { + t.Fatalf("a reopened crew is still archived: %+v", list) + } +} + // A PROPOSAL IS READ FROM THE AGENT'S ONE JSON BLOCK; a helper without a usable // handle or a brief is left out, and an answer with none is no proposal. func TestAppProposalIsReadFromTheAgentsAnswer(t *testing.T) { diff --git a/internal/appwire/crews.go b/internal/appwire/crews.go index 4aefe4d078..2572295248 100644 --- a/internal/appwire/crews.go +++ b/internal/appwire/crews.go @@ -111,18 +111,21 @@ type CrewItem struct { // is absent while any member runs, on a closed crew, and when any member's // instant is unknown. type Crew struct { - ID string `json:"id"` - Name string `json:"name"` - Parent string `json:"parent,omitempty"` - Root bool `json:"root,omitempty"` - Manager string `json:"manager,omitempty"` - Hue float64 `json:"hue"` - Tier int `json:"tier"` - Tint string `json:"tint"` - MadeAt *time.Time `json:"made_at,omitempty"` - State string `json:"state"` - IdleSince *time.Time `json:"idle_since,omitempty"` - ClosedAt *time.Time `json:"closed_at,omitempty"` + ID string `json:"id"` + Name string `json:"name"` + Parent string `json:"parent,omitempty"` + Root bool `json:"root,omitempty"` + Manager string `json:"manager,omitempty"` + Hue float64 `json:"hue"` + Tier int `json:"tier"` + Tint string `json:"tint"` + MadeAt *time.Time `json:"made_at,omitempty"` + State string `json:"state"` + IdleSince *time.Time `json:"idle_since,omitempty"` + ClosedAt *time.Time `json:"closed_at,omitempty"` + // ArchivedAt is when a closed crew was put away; absent otherwise. The + // crew's state stays closed: archived is closed and listed only when asked. + ArchivedAt *time.Time `json:"archived_at,omitempty"` ClosedWith string `json:"closed_with,omitempty"` Report string `json:"report,omitempty"` Wrap *CrewWrap `json:"wrap,omitempty"` @@ -399,6 +402,10 @@ type Crewer interface { CrewWrapUp(id, text string) error CrewClose(id string) error CrewReopen(id string) (Crew, error) + // CrewArchive puts a closed crew away (its own mark; no crew under it is + // archived). CrewListArchived is the crews put away, newest first. + CrewArchive(id string) (Crew, error) + CrewListArchived() ([]Crew, error) CrewDelete(id string) ([]string, error) CrewDefaults() (CrewDefaults, error) CrewSetDefault(key, value string) (CrewDefaults, error) @@ -661,6 +668,12 @@ var crewMethods = map[string]method{ } return crewOf(c.CrewReopen(p.ID)) }), + "crews.archive": crewCall("crews.archive", func(c Crewer, p crewID) (*crewResult, error) { + if err := needID("crews.archive", p.ID); err != nil { + return nil, err + } + return crewOf(c.CrewArchive(p.ID)) + }), "crews.delete": crewCall("crews.delete", func(c Crewer, p crewID) (*IDsResult, error) { if err := needID("crews.delete", p.ID); err != nil { return nil, err diff --git a/internal/appwire/server.go b/internal/appwire/server.go index 23b637e088..d4e7f0e335 100644 --- a/internal/appwire/server.go +++ b/internal/appwire/server.go @@ -497,10 +497,15 @@ var methods = map[string]method{ }), "crews.list": typed(func(e Engine, p struct { Closed bool `json:"closed"` + // Archived asks for the crews put away instead (crews.archive); it + // wins over Closed, and a closed list leaves them out. + Archived bool `json:"archived"` }) (*CrewsResult, error) { var crews []Crew var err error - if c, ok := e.(Crewer); ok { + if c, ok := e.(Crewer); ok && p.Archived { + crews, err = c.CrewListArchived() + } else if ok { crews, err = c.CrewList(p.Closed) } else { crews, err = e.ListCrews() diff --git a/internal/teams/archive_test.go b/internal/teams/archive_test.go new file mode 100644 index 0000000000..4a22d73076 --- /dev/null +++ b/internal/teams/archive_test.go @@ -0,0 +1,154 @@ +package teams + +import ( + "errors" + "os" + "strings" + "testing" + "time" +) + +const ( + archP = "pppppppppppp" + archA = "aaaaaaaaaaaa" + archB = "bbbbbbbbbbbb" +) + +// family is a parent with two sub-teams, saved under dir. +func family(t *testing.T, dir string) { + t.Helper() + must(t, Save(dir, []Team{ + {ID: archP, Name: "harbor", Manager: "pm", Members: []Member{{Key: "pm", Handle: "boss"}}}, + {ID: archA, Name: "front", Parent: archP, Members: []Member{{Key: "web", Handle: "web"}}}, + {ID: archB, Name: "back", Parent: archP, Members: []Member{{Key: "api", Handle: "api"}}}, + })) +} + +// ONLY A CLOSED TEAM IS ARCHIVED: an open one is refused and stays open, the +// root is refused, an unknown id is an error. +func TestArchiveRefusesWhatIsNotClosed(t *testing.T) { + f := &File{Teams: []Team{ + {ID: archP, Name: "harbor", Members: []Member{{Key: "pm"}}}, + {ID: archA, Name: "all", Root: true}, + }} + if err := f.Archive(archP, time.Unix(5, 0)); !errors.Is(err, ErrNotClosed) { + t.Fatalf("archiving an open team = %v", err) + } + if p, _ := f.Team(archP); p.Closed() || p.Archived() { + t.Fatalf("a refused archive changed the team: %+v", p) + } + if err := f.Archive(archA, time.Unix(5, 0)); !errors.Is(err, ErrRoot) { + t.Fatalf("archiving the root = %v", err) + } + if err := f.Archive("missing", time.Unix(5, 0)); err == nil { + t.Fatal("an unknown team was archived") + } +} + +// ARCHIVING MARKS THE ONE TEAM. Its closed sub-teams are not archived with it, +// its record is unchanged, and archiving again keeps the first time. +func TestArchiveMarksOnlyTheTargetAndKeepsTheRecord(t *testing.T) { + dir := t.TempDir() + family(t, dir) + must(t, AppendTraffic(dir, archP, Entry{Kind: KindNote, From: "pm", To: ToRoom, Text: "kept"})) + must(t, Update(dir, func(f *File) error { return f.Disband(archP, time.Unix(100, 0), "rep") })) + // The sub-team, not the parent, is the selected row. + must(t, Update(dir, func(f *File) error { return f.Archive(archA, time.Unix(200, 0)) })) + f, err := Load(dir) + must(t, err) + front, _ := f.Team(archA) + parent, _ := f.Team(archP) + back, _ := f.Team(archB) + if !front.Archived() || !front.ArchivedAt.Equal(time.Unix(200, 0)) || !front.Closed() || front.ClosedWith != archP { + t.Fatalf("the target = %+v", front) + } + if parent.Archived() || back.Archived() || !parent.Closed() || !back.Closed() { + t.Fatalf("the family was touched: parent %+v back %+v", parent, back) + } + must(t, Update(dir, func(f *File) error { return f.Archive(archA, time.Unix(900, 0)) })) + f, _ = Load(dir) + if again, _ := f.Team(archA); !again.ArchivedAt.Equal(time.Unix(200, 0)) { + t.Fatalf("archiving again moved the time: %v", again.ArchivedAt) + } + if got := f.ArchivedTeams(); len(got) != 1 || got[0].ID != archA { + t.Fatalf("archived teams = %+v", got) + } + if got := f.ClosedTeams(); len(got) != 3 { + t.Fatalf("the terminal's closed list must still hold every closed team: %d", len(got)) + } + if tail, _ := ReadTraffic(dir, archP, "", 0); len(tail) == 0 || tail[len(tail)-1].Text != "kept" { + t.Fatalf("the traffic changed: %+v", tail) + } + if rep := parent.Report; rep != "rep" || len(parent.Members) != 1 { + t.Fatalf("the parent's record changed: %+v", parent) + } +} + +// THE MARK IS ON DISK, SURVIVES A RELOAD AND AN UNKNOWN FIELD BESIDE IT, AND +// REOPENING CLEARS IT. A sub-team cannot reopen under a closed parent. +func TestArchivePersistsAndReopenClearsIt(t *testing.T) { + dir := t.TempDir() + family(t, dir) + must(t, Update(dir, func(f *File) error { + for _, id := range []string{archP, archA} { + if err := f.Disband(id, time.Unix(100, 0), ""); err != nil { + return err + } + } + must(t, f.Archive(archP, time.Unix(300, 0))) + return f.Archive(archA, time.Unix(300, 0)) + })) + raw, err := os.ReadFile(Path(dir)) + must(t, err) + if strings.Count(string(raw), `"archived_at"`) != 2 { + t.Fatalf("archived_at is not on disk for both: %s", raw) + } + f, err := Load(dir) + must(t, err) + if p, _ := f.Team(archP); !p.Archived() || !p.ArchivedAt.Equal(time.Unix(300, 0)) { + t.Fatalf("not archived after reload: %+v", p) + } + if err := Update(dir, func(f *File) error { return f.Reopen(archA) }); !errors.Is(err, ErrParentClosed) { + t.Fatalf("a child reopened under a closed parent: %v", err) + } + if a, _ := mustLoad(t, dir).Team(archA); !a.Archived() { + t.Fatal("a refused reopen cleared the archive mark") + } + must(t, Update(dir, func(f *File) error { return f.Reopen(archP) })) + f = mustLoad(t, dir) + for _, id := range []string{archP, archA} { + if team, _ := f.Team(id); team.Closed() || team.Archived() || !team.ArchivedAt.IsZero() { + t.Fatalf("%s after reopen: %+v", id, team) + } + } + if raw, _ := os.ReadFile(Path(dir)); strings.Contains(string(raw), "archived_at") { + t.Fatalf("a reopened team still carries the mark: %s", raw) + } +} + +// A FILE A LATER BUILD WROTE keeps the fields this one does not know when a +// team is archived beside them. +func TestArchiveKeepsUnknownFields(t *testing.T) { + dir := t.TempDir() + family(t, dir) + must(t, Update(dir, func(f *File) error { return f.Disband(archB, time.Unix(1, 0), "") })) + raw, err := os.ReadFile(Path(dir)) + must(t, err) + patched := strings.Replace(string(raw), `"id": "`+archB+`"`, `"id": "`+archB+`","future":{"x":1}`, 1) + if patched == string(raw) { + patched = strings.Replace(string(raw), `"id":"`+archB+`"`, `"id":"`+archB+`","future":{"x":1}`, 1) + } + must(t, os.WriteFile(Path(dir), []byte(patched), 0o600)) + must(t, Update(dir, func(f *File) error { return f.Archive(archB, time.Unix(2, 0)) })) + after, _ := os.ReadFile(Path(dir)) + if !strings.Contains(string(after), `"future"`) || !strings.Contains(string(after), `"archived_at"`) { + t.Fatalf("a field was lost: %s", after) + } +} + +func mustLoad(t *testing.T, dir string) *File { + t.Helper() + f, err := Load(dir) + must(t, err) + return f +} diff --git a/internal/teams/lifecycle.go b/internal/teams/lifecycle.go index d193b84c55..1b6728b44c 100644 --- a/internal/teams/lifecycle.go +++ b/internal/teams/lifecycle.go @@ -26,6 +26,10 @@ var ErrClosed = errors.New("teams: this team has been disbanded; its history is // ErrOpen is [Delete] asked to delete a team that is not closed. var ErrOpen = errors.New("teams: only a closed team can be deleted; close it first") +// ErrNotClosed is [File.Archive] asked to archive a team that is not closed. +// Archiving puts a closed team away; it never closes one. +var ErrNotClosed = errors.New("teams: only a closed team can be archived; close it first") + // ErrParentClosed is [File.Reopen] asked to reopen a team whose parent is // closed. var ErrParentClosed = errors.New("teams: its parent team is closed; reopen that first") @@ -33,6 +37,9 @@ var ErrParentClosed = errors.New("teams: its parent team is closed; reopen that // Closed reports whether the team is closed. func (t Team) Closed() bool { return t.State == TeamClosed } +// Archived reports whether the team is closed and put away. +func (t Team) Archived() bool { return t.Closed() && !t.ArchivedAt.IsZero() } + // Descendants is every team under id, at any depth, parents before children. func (f *File) Descendants(id string) []Team { var out []Team @@ -118,7 +125,7 @@ func (f *File) Reopen(id string) error { } reopen := func(j int) { t := &f.Teams[j] - t.State, t.ClosedAt, t.ClosedWith = "", time.Time{}, "" + t.State, t.ClosedAt, t.ClosedWith, t.ArchivedAt = "", time.Time{}, "", time.Time{} } for _, d := range f.Descendants(id) { if d.Closed() && d.ClosedWith == id { @@ -129,6 +136,50 @@ func (f *File) Reopen(id string) error { return nil } +// Archive puts the closed team id away: it leaves the closed list and is read +// only when archived teams are asked for. It marks that one team. The teams +// under it, closed with it or not, are not touched, and nothing is closed, +// stopped or deleted: the roster, Traffic and packets stay as they were and +// [File.Reopen] brings the team back. A team that is open, or the root, is +// refused; one already archived keeps its first time. +func (f *File) Archive(id string, at time.Time) error { + i, err := f.at(id) + if err != nil { + return err + } + t := &f.Teams[i] + if t.Root { + return ErrRoot + } + if !t.Closed() { + return ErrNotClosed + } + if at.IsZero() { + at = time.Now() + } + if t.ArchivedAt.IsZero() { + t.ArchivedAt = at + } + return nil +} + +// ArchivedTeams is the closed teams that were put away, most recently +// archived first. +func (f *File) ArchivedTeams() []Team { + var out []Team + for _, t := range f.Teams { + if t.Archived() { + out = append(out, t) + } + } + for i := 1; i < len(out); i++ { + for j := i; j > 0 && out[j].ArchivedAt.After(out[j-1].ArchivedAt); j-- { + out[j], out[j-1] = out[j-1], out[j] + } + } + return out +} + // Open is the teams that are open, in stored order. func (f *File) Open() []Team { var out []Team @@ -140,8 +191,8 @@ func (f *File) Open() []Team { return out } -// ClosedTeams is the teams that are closed, most recently closed first, for -// the folded `Closed · N` section. +// ClosedTeams is the teams that are closed, archived ones included, most +// recently closed first, for the folded `Closed · N` section. func (f *File) ClosedTeams() []Team { var out []Team for _, t := range f.Teams { diff --git a/internal/teams/teams.go b/internal/teams/teams.go index 3173e93ddd..40abee2ef7 100644 --- a/internal/teams/teams.go +++ b/internal/teams/teams.go @@ -95,6 +95,11 @@ type Team struct { ClosedAt time.Time ClosedWith string Report string + // ArchivedAt is when a closed team was put away ([File.Archive]); zero for + // a team that is not archived. Archiving is a mark on a closed team: its + // roster, Traffic and packets stay exactly as they were, and [File.Reopen] + // clears it. + ArchivedAt time.Time // Root marks the one team that holds every other (root.go): the `All // teams` row, made when the person gives it a manager. Root bool @@ -134,7 +139,7 @@ type File struct { var knownFields = map[string]bool{ "id": true, "name": true, "parent": true, "members": true, "manager": true, "hue": true, "tier": true, "hue_cleared": true, "made": true, - "state": true, "closed_at": true, "closed_with": true, "report": true, "root": true, + "state": true, "closed_at": true, "closed_with": true, "archived_at": true, "report": true, "root": true, "questions_up": true, "cap_usd_day": true, "depth_limit": true, "sub_share": true, "wake": true, "wrap": true, "former_members": true, "former_manager": true, "starts": true, "founder": true, } @@ -159,6 +164,7 @@ type wireTeam struct { State string `json:"state,omitempty"` ClosedAt *time.Time `json:"closed_at,omitempty"` ClosedWith string `json:"closed_with,omitempty"` + ArchivedAt *time.Time `json:"archived_at,omitempty"` Report string `json:"report,omitempty"` Root bool `json:"root,omitempty"` // Wrap is written only while a wrap-up is in progress, so a team with @@ -186,6 +192,9 @@ func (t *Team) UnmarshalJSON(raw []byte) error { if w.ClosedAt != nil { t.ClosedAt = *w.ClosedAt } + if w.ArchivedAt != nil { + t.ArchivedAt = *w.ArchivedAt + } if w.Hue != nil { t.Hue, t.hued = *w.Hue, true } @@ -221,6 +230,10 @@ func (t Team) MarshalJSON() ([]byte, error) { at := t.ClosedAt w.ClosedAt = &at } + if !t.ArchivedAt.IsZero() { + at := t.ArchivedAt + w.ArchivedAt = &at + } if t.Hued() { hue, tier := t.Hue, t.Tier w.Hue, w.Tier = &hue, &tier From 438bdaa20011ab2183be8683c07395dd3e78025f Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 23:32:16 -0400 Subject: [PATCH 120/459] Serve the archived crews as crews.archived, not a crews.list flag An older host ignores an unknown crews.list argument and answers the open crews; a method of its own answers unknown_method there, so a client can tell archive is unsupported instead of reading open crews as archived ones. Adds a closed-child test (archived alone, reopen refused under a closed parent, no loss) and a wire test for the unserved method. Co-Authored-By: Claude Sonnet 5.5 --- cmd/codeaf/appcrews_test.go | 70 +++++++++++++++++++++++++++++++++ internal/appwire/crews.go | 13 ++++++ internal/appwire/server.go | 7 +--- internal/appwire/server_test.go | 22 +++++++++++ 4 files changed, 106 insertions(+), 6 deletions(-) diff --git a/cmd/codeaf/appcrews_test.go b/cmd/codeaf/appcrews_test.go index 6b6915d123..9e63979d6c 100644 --- a/cmd/codeaf/appcrews_test.go +++ b/cmd/codeaf/appcrews_test.go @@ -243,6 +243,76 @@ func TestAppCrewArchiveIsSeparateFromCloseAndDelete(t *testing.T) { } } +// A SELECTED CLOSED CHILD IS ARCHIVED ALONE: its closed parent and sibling stay +// in the closed list, nothing is deleted, the child's record is whole, it cannot +// be reopened under the closed parent, and reopening the parent brings the family +// back with the mark cleared. +func TestAppCrewArchiveOfAClosedChildTouchesOnlyTheChild(t *testing.T) { + backend, _, _, parent := crewBackend(t) + child, sibling := teamstore.NewID(), teamstore.NewID() + err := teamstore.Update(backend.profileDir, func(f *teamstore.File) error { + for _, id := range []string{child, sibling} { + f.Teams = append(f.Teams, teamstore.Team{ID: id, Name: "sub-" + id[:4], Parent: parent, Made: time.Now()}) + if err := f.AddMember(id, teamstore.Member{Key: "/k/" + id, File: "/k/" + id, Where: "/w", Word: "w", Handle: "w" + id[:3]}); err != nil { + return err + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } + backend.crews() + var wire *appwire.Error + if _, err := backend.CrewArchive(child); !errors.As(err, &wire) || wire.Code != appwire.CodeBadParams { + t.Fatalf("an open child was archived: %v", err) + } + if err := backend.CrewClose(parent); err != nil { + t.Fatal(err) + } + got, err := backend.CrewArchive(child) + if err != nil || got.ID != child || got.ArchivedAt == nil || len(got.Members) != 1 { + t.Fatalf("archived child = %+v, %v", got, err) + } + closed, _ := backend.CrewList(true) + ids := map[string]bool{} + for _, c := range closed { + ids[c.ID] = true + if c.ArchivedAt != nil { + t.Fatalf("a closed-list crew is archived: %+v", c) + } + } + if len(closed) != 2 || !ids[parent] || !ids[sibling] || ids[child] { + t.Fatalf("the closed list = %v", ids) + } + if archived, _ := backend.CrewListArchived(); len(archived) != 1 || archived[0].ID != child { + t.Fatalf("the archived list = %+v", archived) + } + if _, err := backend.CrewArchive(child); err != nil { + t.Fatalf("archiving twice: %v", err) + } + if again, _ := backend.CrewGet(child); again.ArchivedAt == nil || !again.ArchivedAt.Equal(*got.ArchivedAt) { + t.Fatalf("archiving twice moved the time: %+v", again.ArchivedAt) + } + if _, err := backend.CrewReopen(child); !errors.As(err, &wire) || wire.Code != appwire.CodeBadParams { + t.Fatalf("a child reopened under a closed parent: %v", err) + } + if still, _ := backend.CrewGet(child); still.ArchivedAt == nil || still.State != appwire.CrewClosed { + t.Fatalf("the refused reopen changed the child: %+v", still) + } + if _, err := backend.CrewReopen(parent); err != nil { + t.Fatal(err) + } + if back, _ := backend.CrewGet(child); back.State == appwire.CrewClosed || back.ArchivedAt != nil || len(back.Members) != 1 { + t.Fatalf("the child after the parent reopened = %+v", back) + } + for _, id := range []string{parent, child, sibling} { + if _, err := backend.CrewGet(id); err != nil { + t.Fatalf("crew %s was lost: %v", id, err) + } + } +} + // A PROPOSAL IS READ FROM THE AGENT'S ONE JSON BLOCK; a helper without a usable // handle or a brief is left out, and an answer with none is no proposal. func TestAppProposalIsReadFromTheAgentsAnswer(t *testing.T) { diff --git a/internal/appwire/crews.go b/internal/appwire/crews.go index 2572295248..3134ca19e5 100644 --- a/internal/appwire/crews.go +++ b/internal/appwire/crews.go @@ -668,6 +668,19 @@ var crewMethods = map[string]method{ } return crewOf(c.CrewReopen(p.ID)) }), + // The archived crews have a method of their own, never a flag on crews.list: + // a host without it answers unknown_method, where a flag would be ignored and + // answer the open crews. + "crews.archived": crewCall("crews.archived", func(c Crewer, _ struct{}) (*CrewsResult, error) { + crews, err := c.CrewListArchived() + if err != nil { + return nil, err + } + for i := range crews { + crews[i].Members = nonNil(crews[i].Members) + } + return &CrewsResult{nonNil(crews)}, nil + }), "crews.archive": crewCall("crews.archive", func(c Crewer, p crewID) (*crewResult, error) { if err := needID("crews.archive", p.ID); err != nil { return nil, err diff --git a/internal/appwire/server.go b/internal/appwire/server.go index d4e7f0e335..23b637e088 100644 --- a/internal/appwire/server.go +++ b/internal/appwire/server.go @@ -497,15 +497,10 @@ var methods = map[string]method{ }), "crews.list": typed(func(e Engine, p struct { Closed bool `json:"closed"` - // Archived asks for the crews put away instead (crews.archive); it - // wins over Closed, and a closed list leaves them out. - Archived bool `json:"archived"` }) (*CrewsResult, error) { var crews []Crew var err error - if c, ok := e.(Crewer); ok && p.Archived { - crews, err = c.CrewListArchived() - } else if ok { + if c, ok := e.(Crewer); ok { crews, err = c.CrewList(p.Closed) } else { crews, err = e.ListCrews() diff --git a/internal/appwire/server_test.go b/internal/appwire/server_test.go index 0f8d26e78b..a516a9b632 100644 --- a/internal/appwire/server_test.go +++ b/internal/appwire/server_test.go @@ -517,3 +517,25 @@ func TestSetEffortTakesThreeWords(t *testing.T) { t.Fatalf("set_effort max = %v", got) } } + +// THE ARCHIVED CREWS ARE ASKED BY A METHOD OF THEIR OWN. A host that does not +// serve it answers unknown_method, so a client never reads the open crews as +// archived ones; an engine with no crews refuses it rather than answering []. +func TestArchivedCrewsAreAMethodOfTheirOwn(t *testing.T) { + w := startWire(t, &fakeEngine{}) + w.next() + w.send(`{"id":1,"method":"crews.archived"}`) + got := w.next() + if failure, _ := got["error"].(map[string]any); failure["code"] != CodeUnknownMethod { + t.Fatalf("an engine without crews answered crews.archived with %v", got) + } + w.send(`{"id":2,"method":"crews.archive","params":{"id":"x"}}`) + if failure, _ := w.next()["error"].(map[string]any); failure["code"] != CodeUnknownMethod { + t.Fatalf("crews.archive on an engine without crews succeeded") + } + w.send(`{"id":3,"method":"crews.archived_old"}`) + failure, _ := w.next()["error"].(map[string]any) + if failure["code"] != CodeUnknownMethod { + t.Fatalf("an unserved method = %v", failure) + } +} From 3d5dea90d967fc8817912913dba3d446e454ed97 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 22:33:06 -0400 Subject: [PATCH 121/459] workgraph: the general vocabulary of work that waits on other work (types only) --- internal/workgraph/types.go | 593 ++++++++++++++++++++++++++++++++++++ 1 file changed, 593 insertions(+) create mode 100644 internal/workgraph/types.go diff --git a/internal/workgraph/types.go b/internal/workgraph/types.go new file mode 100644 index 0000000000..3d937fc0b9 --- /dev/null +++ b/internal/workgraph/types.go @@ -0,0 +1,593 @@ +// Package workgraph is the engine's GENERAL account of work that waits on +// other work: tasks, who owns them, which task waits on which, what state each +// is in, when that state was entered, what each changed and what it cost. +// +// WHAT IT IS NOT. Nothing here knows how a surface draws anything. There are no +// columns, no coordinates, no colours, no cards, no boards and no chain: a +// surface computes every one of those from the facts below. The package +// imports neither the session nor the app wire, so the same facts can be asked +// of any producer that can name its tasks and its links. The session's reader +// (internal/session/workgraph_source.go) and the app's door (internal/appwire's +// work.graph) are two consumers of it. +// +// THE LAWS. +// +// - A LINK IS A STORED, ENFORCED COMMAND AND NOTHING ELSE. An edge exists +// because a producer recorded that one task waits on another and its +// scheduler honoured it. No edge is ever inferred from a title, a body, the +// order tasks were made in, or how a group is shaped. +// - DIRECTION IS ALWAYS DEPENDENT → PREREQUISITE. [GraphEdge.From] is the task +// that waits; [GraphEdge.To] is the task it waits on. +// - NO ID IS FABRICATED. Every edge names a real task by its [TaskRef]. A +// target that is not among the nodes of the scope is said to be external (a +// real task elsewhere, with its state when readable) or missing (the record +// names a task the owner no longer has) — never silently dropped, never +// invented. +// - UNKNOWN IS NOT ZERO. A fact nobody recorded is absent, and what it was +// counted against is said beside every sum ([GraphFigure]). +// - EVERY ANSWER IS BOUNDED. A page holds at most [MaxNodes] nodes, at most +// [MaxEdges] edges and strings cut to [MaxTitleRunes] / [MaxTextRunes]; the +// true totals are always said, so a short page is never mistaken for a +// whole scope. +// - THE READ IS READ-ONLY. It takes no lock the work holds, writes nothing, +// and answers from immutable snapshots, so a caller cannot reach into +// another caller's answer. +package workgraph + +import ( + "strings" + "time" +) + +// Contract is the version of this vocabulary. It moves only when a field +// changes meaning; a new optional field does not move it. +const Contract = 1 + +// The bounds of one answer. They are constants, not settings: a surface that +// needs more asks again with the cursor. +const ( + // DefaultNodes is how many nodes a page holds when the request names none. + DefaultNodes = 200 + // MaxNodes is the most nodes one page holds, whatever was asked. + MaxNodes = 500 + // MaxEdges is the most edges one page holds. A page ends early rather than + // pass it, and says so through its cursor. + MaxEdges = 4096 + // MaxEdgesPerNode is the most waits-on edges one node's row carries; the + // node's WaitsOnTotal is the true count and WaitsOnCut says the list is cut. + MaxEdgesPerNode = 64 + // MaxFilesPerNode is the most changed paths one node carries. + MaxFilesPerNode = 50 + // MaxClusters is the most clusters one page holds. + MaxClusters = 200 + // MaxMembers is the most people one answer holds. + MaxMembers = 500 + // MaxCycles is the most cycles an answer names, each of at most + // [MaxCycleNodes] tasks; CyclesTotal is the true count. + MaxCycles = 16 + MaxCycleNodes = 32 + // MaxFocusDepth and MaxFocusNodes bound a neighbourhood walk. + MaxFocusDepth = 6 + MaxFocusNodes = MaxNodes + // MaxTitleRunes and MaxTextRunes cut the words a node carries. + MaxTitleRunes = 160 + MaxTextRunes = 1000 + // MaxScopeNodes is the most tasks one scope is built from. A scope with more + // says Coverage.Truncated and is built from the first of them in order. + MaxScopeNodes = 200_000 +) + +// ScopeKind is what a request is about. +type ScopeKind string + +// The three scopes. +const ( + ScopeAgent ScopeKind = "agent" + ScopeCrew ScopeKind = "crew" + ScopeTask ScopeKind = "task" +) + +// GraphScope names what a request is about. For an agent it is the agent's +// own id; for a crew the crew's id; for a task the task's [TaskRef.ID], whose +// scope is that task's own sub-tasks at every depth. +type GraphScope struct { + Kind ScopeKind `json:"kind" enum:"agent,crew,task"` + ID string `json:"id"` +} + +// TaskRef is a task's immutable identity: the conversation that owns it and the +// number that conversation minted for it. Two conversations both have a task 3, +// so the pair — never the number alone — names a task. +type TaskRef struct { + // Owner is the owning conversation's id (its folder's name, stable across + // launches). Task is that conversation's own number for the task, decimal. + Owner string `json:"owner"` + Task string `json:"task"` +} + +// ID is the task's id on every wire that names a task: ":". It is +// collision-safe across owners because Task is decimal and has no colon. +func (r TaskRef) ID() string { return r.Owner + ":" + r.Task } + +// Valid reports whether the reference names something: an owner and a decimal +// number without leading zeros or a sign. +func (r TaskRef) Valid() bool { + if r.Owner == "" || r.Task == "" || r.Task == "0" || r.Task[0] == '0' { + return false + } + return strings.Trim(r.Task, "0123456789") == "" +} + +// ParseID is the reference an id names, and false for a string that is not +// ":". +func ParseID(id string) (TaskRef, bool) { + i := strings.LastIndex(id, ":") + if i <= 0 { + return TaskRef{}, false + } + ref := TaskRef{Owner: id[:i], Task: id[i+1:]} + return ref, ref.Valid() +} + +// The views of a request. +const ( + // ViewNodes pages the tasks and, with them, the edges whose dependent is on + // the page. It is the default. + ViewNodes = "nodes" + // ViewClusters pages the groupings: sub-crews and task groups, each with its + // true member count and a count by state. + ViewClusters = "clusters" + // ViewPeople answers the crews and the members that own the scope's work. + ViewPeople = "people" + // ViewMetrics answers the scope's figures, each with what it was counted + // against. + ViewMetrics = "metrics" +) + +// The kinds of cluster. +const ( + ClusterCrew = "crew" + ClusterGroup = "group" +) + +// The directions a focus walk follows. +const ( + // FocusPrerequisites follows waits-on links: what the focus waits on. + FocusPrerequisites = "prerequisites" + // FocusDependents follows them backwards: what waits on the focus. + FocusDependents = "dependents" + FocusBoth = "both" +) + +// GraphRequest is work.graph's params. Every field but Scope may be left out. +type GraphRequest struct { + Scope GraphScope `json:"scope"` + View string `json:"view,omitempty" enum:"nodes,clusters,people,metrics"` + // Cluster, with ViewNodes, restricts the page to one cluster's members: a + // sub-crew (and every crew under it) or a task group. + Cluster *ClusterRef `json:"cluster,omitempty"` + // Focus, with ViewNodes, answers one task's neighbourhood instead of a page + // of the scope: the tasks reachable from it along waits-on links, nearest + // first, to Depth steps. It has no cursor and says Cut when it stopped + // early. + Focus *FocusRef `json:"focus,omitempty"` + // Cursor is the Next of the page before; Limit is how many nodes (or + // clusters) to answer, bounded by [MaxNodes]. + Cursor string `json:"cursor,omitempty"` + Limit int `json:"limit,omitempty"` + // IfRevision is the Revision the caller already holds. When it is still + // current the answer is only Unchanged: no page is built or sent. + IfRevision string `json:"if_revision,omitempty"` +} + +// ClusterRef names one cluster. +type ClusterRef struct { + Kind string `json:"kind" enum:"crew,group"` + ID string `json:"id"` +} + +// FocusRef names the task a neighbourhood is walked from. +type FocusRef struct { + Task string `json:"task"` + Direction string `json:"direction,omitempty" enum:"prerequisites,dependents,both"` + Depth int `json:"depth,omitempty"` +} + +// GraphResult is work.graph's answer. Which of the sections is filled depends on +// the view; Scope, Revision, Totals and Coverage are always there unless the +// answer is Unchanged. +type GraphResult struct { + Contract int `json:"contract"` + View string `json:"view"` + // Revision names the exact source facts the answer was built from. It + // changes when, and only when, a source the scope reads changed: a task + // moved, a link was recorded, a member joined. A caller holds it and sends + // it back as IfRevision; the events that say something moved are the ones + // the wire already has (task.changed, member.changed, crew events), so no + // caller polls. + Revision string `json:"revision"` + Unchanged bool `json:"unchanged,omitempty"` + + Scope GraphScope `json:"scope"` + About ScopeFacts `json:"about"` + Totals GraphTotals `json:"totals"` + Coverage Coverage `json:"coverage"` + + Nodes []GraphNode `json:"nodes,omitempty"` + Edges []GraphEdge `json:"edges,omitempty"` + Clusters []GraphCluster `json:"clusters,omitempty"` + Crews []CrewFact `json:"crews,omitempty"` + Members []MemberFact `json:"members,omitempty"` + Metrics *GraphMetrics `json:"metrics,omitempty"` + + Diagnostics Diagnostics `json:"diagnostics"` + // Next is the cursor of the following page, absent on the last. Returned is + // how many nodes (or clusters) this page holds and Cut says a focus walk + // stopped at a bound. + Next string `json:"next_cursor,omitempty"` + Returned int `json:"returned"` + Cut bool `json:"cut,omitempty"` +} + +// ScopeFacts is what the scope itself is, as the producer knew it. +type ScopeFacts struct { + Title string `json:"title,omitempty"` + // State is "open" or "closed" for a crew or an agent; empty when the + // producer does not say. A closed scope is ReadOnly: nothing in it moves. + State string `json:"state,omitempty" enum:"open,closed"` + ClosedAt *time.Time `json:"closed_at,omitempty"` + ReadOnly bool `json:"read_only,omitempty"` + // Root is the task a task scope is about, with its own facts. + Root *GraphNode `json:"root,omitempty"` + // Brief is what the scope was asked to do, with where the words came from: + // a task's instructions, a crew's goal, an agent's first request. Absent + // when the producer has none. + Brief *GraphText `json:"brief,omitempty"` +} + +// GraphText is words cut to a bound and the place they were read from. +type GraphText struct { + // Source is the recorded field the text is: "task_brief" (the person's + // edit), "admitted_brief" (what the task was admitted with), "request" (the + // person's own message), "crew_goal" or "first_request". + Source string `json:"source"` + Text string `json:"text"` + Cut bool `json:"cut,omitempty"` +} + +// GraphTotals are the scope's true sizes, whatever the page holds. +type GraphTotals struct { + Nodes int `json:"nodes"` + // Edges counts every waits-on link whose dependent is in the scope, + // including those to external and missing targets. + Edges int `json:"edges"` + Clusters int `json:"clusters,omitempty"` + Crews int `json:"crews,omitempty"` + Members int `json:"members,omitempty"` + // ByState counts nodes by their engine state word. + ByState map[string]int `json:"by_state,omitempty"` +} + +// Coverage says how much of the truth the answer is built from, so a missing +// fact is never read as a zero. Every count is of nodes. +type Coverage struct { + // Complete is true when every owner in scope was read and nothing was cut at + // [MaxScopeNodes]. + Complete bool `json:"complete"` + Truncated bool `json:"truncated,omitempty"` + // Owners is how many conversations the scope reads; Unread names those that + // could not be read, each with a reason, at most 16. + Owners int `json:"owners"` + Unread []OwnerIssue `json:"unread,omitempty"` + Links LinkCoverage `json:"links"` + Clock ClockCoverage `json:"clock"` + // Excluded names the kinds of work the scope's producer does not put in this + // graph, so their absence is a statement and not a silence. + Excluded []string `json:"excluded,omitempty"` +} + +// OwnerIssue is one conversation the scope could not read. +type OwnerIssue struct { + Owner string `json:"owner"` + Reason string `json:"reason" enum:"missing,unreadable,too_large"` +} + +// LinkCoverage counts nodes by whether their waits-on links were recorded. +// Recorded says the node's links are on its record — zero links is then a true +// "waits on nothing". Unrecorded says the record a node came from never carried +// links (a handed-over run's row), so zero links there means unknown. +type LinkCoverage struct { + Recorded int `json:"recorded"` + Unrecorded int `json:"unrecorded"` +} + +// ClockCoverage counts nodes by which instants their record carries. +type ClockCoverage struct { + Admitted int `json:"admitted"` + Started int `json:"started"` + State int `json:"state"` + Ended int `json:"ended"` +} + +// The engine's task state words, as persisted. +const ( + StateQueued = "queued" + StateRunning = "running" + StateDone = "done" + StateFailed = "failed" + StateUnverified = "unverified" + StateInterrupted = "interrupted" +) + +// The kinds of link and the stores one is read from. +const ( + // KindWaitsOn is the only kind: From does not start until To is done. + KindWaitsOn = "waits_on" + // ViaRecord is a link between two tasks of one conversation, written when + // the later one was admitted and enforced by that conversation's frontier. + ViaRecord = "record" + // ViaExternal is a link to a task of another conversation, recorded by + // WaitOnTask and enforced by the same frontier before the task starts. + ViaExternal = "external" +) + +// How an edge's target stands against the scope. +const ( + // TargetNode: the target is a node of this scope (it may be on another page). + TargetNode = "node" + // TargetExternal: the target is a real task outside the scope; ToState says + // its state when it could be read. + TargetExternal = "external" + // TargetMissing: the record names a task its owner does not have. + TargetMissing = "missing" +) + +// GraphNode is one task. +type GraphNode struct { + // ID is the task's wire id, Ref its identity and Seq its owner's admission + // number (the order the owner made its tasks in). + ID string `json:"id"` + Ref TaskRef `json:"ref"` + Seq uint64 `json:"seq"` + // Kind is the engine's kind word: "task" for ordinary work, "quick", + // "run" for a handed-over task's row, and the producer's own words for the + // rest (job, harness, subharness). + Kind string `json:"kind"` + Title string `json:"title"` + // State is one of the engine state words (queued, running, done, failed, + // unverified, interrupted). Paused, Stopped and Declined are the person's + // holds and endings, which the state word alone does not carry. Ending is + // why a failed task stopped, in the engine's word. + State string `json:"state" enum:"queued,running,done,failed,unverified,interrupted"` + Paused bool `json:"paused,omitempty"` + Stopped bool `json:"stopped,omitempty"` + Declined bool `json:"declined,omitempty"` + Ending string `json:"ending,omitempty"` + // Parent is the task that handed this one out (its wire id), empty at the + // top of a family. ParentState says whether that parent is among the nodes: + // "root" (none), "node", or "missing" (the record names a parent its owner + // no longer has). Depth is the family depth the owner recorded, 1 for a + // conversation's own work. + Parent string `json:"parent,omitempty"` + ParentState string `json:"parent_state" enum:"root,node,missing"` + Depth int `json:"depth,omitempty"` + // Group is the adaptive run this task belongs to, when it belongs to one. + Group *GroupRef `json:"group,omitempty"` + // Crew is the sub-crew the task's owner sits in, inside the scope; absent + // outside a crew scope and for an owner in no crew of it. + Crew *CrewPlace `json:"crew,omitempty"` + + Clock Clock `json:"clock"` + Instructions *GraphText `json:"instructions,omitempty"` + Model string `json:"model,omitempty"` + Landing *Landing `json:"landing,omitempty"` + Files FileSet `json:"files"` + Spend *TaskSpend `json:"spend,omitempty"` + Checks *TaskChecks `json:"checks,omitempty"` + + // Links says whether this task's waits-on links were recorded ("recorded") + // or the record never carried them ("unrecorded"). WaitsOnTotal is the true + // number of tasks it waits on and WaitedByTotal the true number of tasks in + // the scope that wait on it; WaitsOnCut says the page's edges for it are cut + // at [MaxEdgesPerNode]. InCycle says it is on a waits-on cycle. + Links string `json:"links" enum:"recorded,unrecorded"` + WaitsOnTotal int `json:"waits_on_total"` + WaitedByTotal int `json:"waited_by_total"` + WaitsOnCut bool `json:"waits_on_cut,omitempty"` + InCycle bool `json:"in_cycle,omitempty"` +} + +// GroupRef names the adaptive run a task belongs to and carries the run's own +// name, which is the title of the run's own row. +type GroupRef struct { + ID string `json:"id"` + Title string `json:"title,omitempty"` +} + +// CrewPlace is a task owner's place in a crew scope: the sub-crew it sits in +// and that crew's ancestry inside the scope, outermost first. Also lists the +// other crews of the scope the owner sits in, at most 8. +type CrewPlace struct { + Crew string `json:"crew"` + Path []string `json:"path"` + AlsoIn []string `json:"also_in,omitempty"` +} + +// Clock is the instants a task's record carries. Each is absent when nothing +// recorded it — never the read time, never a guess. +// +// - AdmittedAt: when the owner made the task. +// - StartedAt: when it began running. +// - StateAt: when it entered the state it is in. +// - EndedAt: when it landed or stopped. +type Clock struct { + AdmittedAt *time.Time `json:"admitted_at,omitempty"` + StartedAt *time.Time `json:"started_at,omitempty"` + StateAt *time.Time `json:"state_at,omitempty"` + EndedAt *time.Time `json:"ended_at,omitempty"` +} + +// Landing is how a finished task's work came home, as the record says. +type Landing struct { + // Outcome is "merged", "kept" (left on its branch), "conflicted" or + // "inplace" (written in the person's own folder). + Outcome string `json:"outcome"` + // At is when it landed, absent for a record that never carried the instant. + At *time.Time `json:"at,omitempty"` +} + +// FileSet is the paths a task changed, workspace-relative and slash-spelled. +// Total is the true count; Paths is at most [MaxFilesPerNode] of them. Source +// is "changed" (the landing's own list) or "wrote" (what a running task has +// written so far); Omitted counts recorded paths withheld as outside the +// workspace. +type FileSet struct { + Paths []string `json:"paths,omitempty"` + Total int `json:"total"` + Source string `json:"source,omitempty" enum:"changed,wrote"` + Omitted int `json:"omitted,omitempty"` +} + +// TaskSpend is what a task spent, as recorded. Basis is "priced" (the record +// carries dollars), "unpriced" (it carries tokens and no price, so the dollars +// are unknown) — a task with neither carries no TaskSpend at all. +type TaskSpend struct { + USD float64 `json:"usd,omitempty"` + Tokens int `json:"tokens,omitempty"` + Basis string `json:"basis" enum:"priced,unpriced"` +} + +// TaskChecks is the declared checks' recorded runs for a task: how many passed +// (exit 0) and how many did not. Absent when no run was recorded. +type TaskChecks struct { + Passed int `json:"passed"` + Failed int `json:"failed"` +} + +// GraphEdge is one waits-on link. From waits on To. +type GraphEdge struct { + From string `json:"from"` + To string `json:"to"` + ToRef TaskRef `json:"to_ref"` + Kind string `json:"kind" enum:"waits_on"` + Via string `json:"via" enum:"record,external"` + // Target says how To stands against the scope; ToState is the prerequisite's + // state word when it is known, including for an external target that could + // be read. InCycle says the edge is on a waits-on cycle. + Target string `json:"target" enum:"node,external,missing"` + ToState string `json:"to_state,omitempty"` + InCycle bool `json:"in_cycle,omitempty"` +} + +// GraphCluster is one grouping of the scope's tasks: a sub-crew or a task group. +// Total is the true member count (a crew cluster counts every crew beneath it) +// and ByState counts them by state word. +type GraphCluster struct { + Kind string `json:"kind" enum:"crew,group"` + ID string `json:"id"` + Title string `json:"title,omitempty"` + Parent string `json:"parent,omitempty"` + Total int `json:"total"` + // Direct counts the tasks that sit in this cluster itself and not in one + // beneath it. + Direct int `json:"direct"` + ByState map[string]int `json:"by_state,omitempty"` +} + +// CrewFact is one crew of a crew scope, and the scope's own crew first. +type CrewFact struct { + ID string `json:"id"` + Name string `json:"name"` + Parent string `json:"parent,omitempty"` + Manager string `json:"manager,omitempty"` + Hue *float64 `json:"hue,omitempty"` + State string `json:"state,omitempty" enum:"open,closed"` + ClosedAt *time.Time `json:"closed_at,omitempty"` + Members int `json:"members"` +} + +// MemberFact is one conversation in a crew of the scope, with what an org chart +// needs: where it sits, who it answers to and what it leads, and how its work +// stands. +type MemberFact struct { + Agent string `json:"agent"` + Handle string `json:"handle,omitempty"` + Title string `json:"title,omitempty"` + Crew string `json:"crew"` + Manager bool `json:"manager,omitempty"` + ReportsTo string `json:"reports_to,omitempty"` + // Leads is the sub-crew this member manages. + Leads string `json:"leads,omitempty"` + JoinedAt *time.Time `json:"joined_at,omitempty"` + // Tasks counts this member's tasks in the scope by state word; Total is the + // true count. + Tasks MemberTasks `json:"tasks"` +} + +// MemberTasks counts one member's tasks. +type MemberTasks struct { + Total int `json:"total"` + ByState map[string]int `json:"by_state,omitempty"` +} + +// GraphFigure is one number with what it was counted against. Value is the sum +// of what is known; Counted is how many of Of tasks contributed. Known is +// Counted > 0 and Partial is Counted < Of, so an unknown never reads as zero and +// a sum over some of the work never reads as the whole. +type GraphFigure struct { + Value float64 `json:"value"` + Known bool `json:"known"` + Partial bool `json:"partial,omitempty"` + Counted int `json:"counted"` + Of int `json:"of"` +} + +// GraphMetrics are the scope's figures, every one an engine fact and none an +// estimate: counted from recorded state, never forecast and never priced from +// today's catalogue. +type GraphMetrics struct { + // Tasks is every task in the scope; Subtasks the ones handed out by another + // task; Members the conversations of a crew scope (absent for the others). + Tasks GraphFigure `json:"tasks"` + Subtasks GraphFigure `json:"subtasks"` + Members *GraphFigure `json:"members,omitempty"` + // LandedTasks counts tasks whose work came home (merged or in place). + // LandedFiles counts the distinct paths those tasks changed; it is partial + // when a landed task's record carries no path list. + LandedTasks GraphFigure `json:"landed_tasks"` + LandedFiles GraphFigure `json:"landed_files"` + // ChecksPassed counts declared checks with a recorded exit of 0, over the + // declared checks that have a recorded run. + ChecksPassed GraphFigure `json:"checks_passed"` + // Spend is the dollars the recorded bills add up to, over the tasks that + // carry a bill. Unpriced counts tasks with tokens and no price. + Spend GraphFigure `json:"spend"` + Unpriced int `json:"unpriced,omitempty"` + // From and To bound the work in time: the earliest recorded admission or + // start and the latest recorded end. Absent when no task recorded one. + From *time.Time `json:"from,omitempty"` + To *time.Time `json:"to,omitempty"` +} + +// Diagnostics are what the build found wrong with the facts, bounded. +type Diagnostics struct { + // Cycles are waits-on cycles, at most [MaxCycles], each at most + // [MaxCycleNodes] tasks; CyclesTotal is the true count. A cycle is reported + // and left in place: the engine refuses to write one, so one here means a + // record that was not written by it. + Cycles []Cycle `json:"cycles,omitempty"` + CyclesTotal int `json:"cycles_total,omitempty"` + // MissingParents counts tasks whose recorded parent is not in their owner's + // graph; ParentCycles counts tasks on a family loop. + MissingParents int `json:"missing_parents,omitempty"` + ParentCycles int `json:"parent_cycles,omitempty"` + // MissingTargets and ExternalTargets count edges by how their target stands. + MissingTargets int `json:"missing_targets,omitempty"` + ExternalTargets int `json:"external_targets,omitempty"` +} + +// Cycle is the tasks of one waits-on cycle. +type Cycle struct { + Tasks []string `json:"tasks"` + Total int `json:"total"` +} From cadcc7dfc4299b643020f3bda14b1b862675fa5f Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 22:53:15 -0400 Subject: [PATCH 122/459] workgraph: build, cache, page, cycle and metric engine over producer-stated facts Co-Authored-By: Claude Sonnet 5.5 --- internal/workgraph/answer.go | 383 ++++++++++++++++ internal/workgraph/build.go | 479 ++++++++++++++++++++ internal/workgraph/build_test.go | 614 ++++++++++++++++++++++++++ internal/workgraph/cache.go | 164 +++++++ internal/workgraph/complexity_test.go | 62 +++ internal/workgraph/crews.go | 272 ++++++++++++ internal/workgraph/cursor.go | 72 +++ internal/workgraph/cycles.go | 170 +++++++ internal/workgraph/input.go | 156 +++++++ internal/workgraph/metrics.go | 164 +++++++ internal/workgraph/types.go | 97 +++- 11 files changed, 2621 insertions(+), 12 deletions(-) create mode 100644 internal/workgraph/answer.go create mode 100644 internal/workgraph/build.go create mode 100644 internal/workgraph/build_test.go create mode 100644 internal/workgraph/cache.go create mode 100644 internal/workgraph/complexity_test.go create mode 100644 internal/workgraph/crews.go create mode 100644 internal/workgraph/cursor.go create mode 100644 internal/workgraph/cycles.go create mode 100644 internal/workgraph/input.go create mode 100644 internal/workgraph/metrics.go diff --git a/internal/workgraph/answer.go b/internal/workgraph/answer.go new file mode 100644 index 0000000000..4814dbcd62 --- /dev/null +++ b/internal/workgraph/answer.go @@ -0,0 +1,383 @@ +package workgraph + +import ( + "context" + "fmt" + "sort" +) + +// ValidateRequest refuses a request that cannot be answered as asked, before +// anything is read or built for it. +func ValidateRequest(req GraphRequest) error { + switch req.Scope.Kind { + case ScopeAgent, ScopeCrew, ScopeTask: + default: + return badf("scope.kind is agent, crew or task") + } + if req.Scope.ID == "" || len(req.Scope.ID) > 1024 { + return badf("scope.id is required") + } + if req.Scope.Kind == ScopeTask { + if _, ok := ParseID(req.Scope.ID); !ok { + return badf("a task scope's id is \":\"") + } + } + switch req.View { + case "", ViewNodes, ViewClusters, ViewPeople, ViewMetrics: + default: + return badf("view is nodes, clusters, people or metrics") + } + if req.Limit < 0 { + return badf("limit cannot be negative") + } + if len(req.IfRevision) > 128 || len(req.Cursor) > 1024 { + return badf("if_revision or cursor is too long") + } + return validateShape(req) +} + +func validateShape(req GraphRequest) error { + view := req.View + if view == "" { + view = ViewNodes + } + if (req.Cluster != nil || req.Focus != nil) && view != ViewNodes { + return badf("cluster and focus belong to the nodes view") + } + if req.Cluster != nil && req.Focus != nil { + return badf("cluster and focus cannot be asked together") + } + if c := req.Cluster; c != nil && (c.ID == "" || c.Kind != ClusterCrew && c.Kind != ClusterGroup) { + return badf("cluster needs a kind (crew or group) and an id") + } + if req.Focus != nil { + return validateFocus(req.Focus, req.Cursor) + } + return nil +} + +func validateFocus(f *FocusRef, cursor string) error { + if _, ok := ParseID(f.Task); !ok || f.Depth < 0 { + return badf("focus needs a task id and a depth of 0 or more") + } + switch f.Direction { + case "", FocusPrerequisites, FocusDependents, FocusBoth: + default: + return badf("focus direction is prerequisites, dependents or both") + } + if cursor != "" { + return badf("a focus has no cursor") + } + return nil +} + +func scopeKey(s GraphScope) string { return string(s.Kind) + ":" + s.ID } + +func filterKey(req GraphRequest) string { + if req.Cluster != nil { + return "cluster:" + clusterKey(req.Cluster.Kind, req.Cluster.ID) + } + return "" +} + +// Answer is the snapshot's reply to one request. It is read-only, bounded and +// looks at ctx while it works; what it returns is a copy. +func (s *Snapshot) Answer(ctx context.Context, req GraphRequest) (*GraphResult, error) { + if err := ValidateRequest(req); err != nil { + return nil, err + } + if scopeKey(req.Scope) != scopeKey(s.scope) { + return nil, badf("the request is for a different scope than this snapshot") + } + view := req.View + if view == "" { + view = ViewNodes + } + res := s.base(view) + if req.IfRevision != "" && req.IfRevision == s.revision { + return &GraphResult{Contract: Contract, View: view, Revision: s.revision, AsOf: copyTime(s.asOf), Unchanged: true, Scope: s.scope}, nil + } + var err error + switch view { + case ViewNodes: + err = s.answerNodes(ctx, res, req) + case ViewClusters: + err = s.answerClusters(res, req) + case ViewPeople: + s.answerPeople(res) + default: + m := cloneMetrics(s.metrics) + res.Metrics = &m + } + if err != nil { + return nil, err + } + return res, nil +} + +// base is the part of every answer that does not depend on the page. +func (s *Snapshot) base(view string) *GraphResult { + about := s.about + if about.Root != nil { + about.Root = cloneNode(about.Root) + } + if about.Brief != nil { + b := *about.Brief + about.Brief = &b + } + about.ClosedAt = copyTime(about.ClosedAt) + totals := s.totals + totals.ByState = copyCounts(s.totals.ByState) + cov := s.coverage + cov.Unread = append([]OwnerIssue(nil), s.coverage.Unread...) + cov.Excluded = append([]string(nil), s.coverage.Excluded...) + return &GraphResult{ + Contract: Contract, View: view, Revision: s.revision, AsOf: copyTime(s.asOf), Scope: s.scope, + About: about, Totals: totals, Coverage: cov, Diagnostics: cloneDiagnostics(s.diag), + } +} + +func cloneDiagnostics(d Diagnostics) Diagnostics { + out := d + out.Cycles = make([]Cycle, len(d.Cycles)) + for i, c := range d.Cycles { + out.Cycles[i] = Cycle{Total: c.Total, Tasks: append([]string(nil), c.Tasks...)} + } + if len(out.Cycles) == 0 { + out.Cycles = nil + } + return out +} + +func cloneMetrics(m GraphMetrics) GraphMetrics { + out := m + if m.Members != nil { + f := *m.Members + out.Members = &f + } + out.From, out.To = copyTime(m.From), copyTime(m.To) + return out +} + +func (s *Snapshot) answerNodes(ctx context.Context, res *GraphResult, req GraphRequest) error { + limit := req.Limit + if limit == 0 { + limit = DefaultNodes + } + limit = min(limit, MaxNodes) + if req.Focus != nil { + return s.answerFocus(ctx, res, req, limit) + } + var list []int32 + if c := req.Cluster; c != nil { + if !s.hasCluster(c.Kind, c.ID) { + return fmt.Errorf("%w: %s cluster %s", ErrNotFound, c.Kind, c.ID) + } + list = s.members[clusterKey(c.Kind, c.ID)] + if list == nil { + list = []int32{} + } + } + want := cursorBody{Scope: scopeKey(s.scope), View: ViewNodes, Filter: filterKey(req)} + start, err := s.startAt(req.Cursor, want, list) + if err != nil { + return err + } + s.fill(ctx, res, list, start, limit, want) + return ctx.Err() +} + +func (s *Snapshot) hasCluster(kind, id string) bool { + i := sort.Search(len(s.clusters), func(i int) bool { + c := s.clusters[i] + return c.Kind > kind || c.Kind == kind && c.ID >= id + }) + return i < len(s.clusters) && s.clusters[i].Kind == kind && s.clusters[i].ID == id +} + +// at is the node at a position of a list, where a nil list is every node. +func at(list []int32, pos int) int { + if list == nil { + return pos + } + return int(list[pos]) +} + +func size(s *Snapshot, list []int32) int { + if list == nil { + return len(s.nodes) + } + return len(list) +} + +// startAt is the position a cursor names within a list: the first node after +// the one the cursor ended on, found by binary search over creation keys. +func (s *Snapshot) startAt(raw string, want cursorBody, list []int32) (int, error) { + if raw == "" { + return 0, nil + } + got, err := decodeCursor(raw, want) + if err != nil { + return 0, err + } + after := orderKey{at: got.At, owner: got.Owner, seq: got.Seq, task: got.Task} + n := size(s, list) + return sort.Search(n, func(i int) bool { return after.less(s.keys[at(list, i)]) }), nil +} + +// fill puts nodes from a position onward on a page, with the edges of each, and +// stops at the node limit or before the page would pass [MaxEdges] — always +// taking at least one node, so a page can never be empty with more to come. +func (s *Snapshot) fill(ctx context.Context, res *GraphResult, list []int32, start, limit int, want cursorBody) { + n := size(s, list) + pos := start + for ; pos < n && len(res.Nodes) < limit; pos++ { + if ctx.Err() != nil { + return + } + i := at(list, pos) + cut := min(s.nodes[i].WaitsOnTotal, MaxEdgesPerNode) + if len(res.Edges)+cut > MaxEdges && len(res.Nodes) > 0 { + break + } + s.take(res, i) + } + res.Returned = len(res.Nodes) + if pos < n && len(res.Nodes) > 0 { + k := s.keys[at(list, pos-1)] + want.At, want.Owner, want.Seq, want.Task = k.at, k.owner, k.seq, k.task + res.Next = encodeCursor(want) + } +} + +// take copies node i and its first [MaxEdgesPerNode] edges onto the page. +func (s *Snapshot) take(res *GraphResult, i int) { + res.Nodes = append(res.Nodes, *cloneNode(&s.nodes[i])) + lo, hi := s.edgeStart[i], s.edgeStart[i+1] + if hi-lo > MaxEdgesPerNode { + hi = lo + MaxEdgesPerNode + } + res.Edges = append(res.Edges, s.edges[lo:hi]...) +} + +// answerFocus walks outward from one task along the waits-on links, nearest +// first, within a depth and a node bound, and answers what it met. +func (s *Snapshot) answerFocus(ctx context.Context, res *GraphResult, req GraphRequest, limit int) error { + root, ok := s.idx[req.Focus.Task] + if !ok { + return fmt.Errorf("%w: task %s", ErrNotFound, req.Focus.Task) + } + depth := req.Focus.Depth + if depth == 0 { + depth = 3 + } + depth = min(depth, MaxFocusDepth) + limit = min(limit, MaxFocusNodes) + order, cut, err := s.walk(ctx, root, req.Focus.Direction, depth, limit) + if err != nil { + return err + } + for _, i := range order { + s.take(res, int(i)) + if len(res.Edges) > MaxEdges { + res.Edges, cut = res.Edges[:MaxEdges], true + } + } + res.Returned, res.Cut = len(res.Nodes), cut + return nil +} + +// walk is a breadth-first search over the waits-on links in the asked +// direction, bounded by depth and node count. cut says it stopped with more to +// find. +func (s *Snapshot) walk(ctx context.Context, root int32, direction string, depth, limit int) ([]int32, bool, error) { + seen := map[int32]bool{root: true} + order := []int32{root} + level := []int32{root} + for d := 0; d < depth && len(level) > 0; d++ { + var next []int32 + for _, v := range level { + if err := ctx.Err(); err != nil { + return nil, false, err + } + for _, w := range s.neighbours(v, direction) { + if seen[w] { + continue + } + if len(order) >= limit { + return order, true, nil + } + seen[w] = true + order = append(order, w) + next = append(next, w) + } + } + level = next + } + return order, len(level) > 0, nil +} + +func (s *Snapshot) neighbours(v int32, direction string) []int32 { + var out []int32 + if direction != FocusDependents { + for e := s.edgeStart[v]; e < s.edgeStart[v+1]; e++ { + if to := s.edgeTo[e]; to >= 0 { + out = append(out, to) + } + } + } + if direction == FocusDependents || direction == FocusBoth { + out = append(out, s.revList[s.revStart[v]:s.revStart[v+1]]...) + } + return out +} + +func (s *Snapshot) answerClusters(res *GraphResult, req GraphRequest) error { + limit := req.Limit + if limit == 0 { + limit = min(DefaultNodes, MaxClusters) + } + limit = min(limit, MaxClusters) + want := cursorBody{Scope: scopeKey(s.scope), View: ViewClusters} + start := 0 + if req.Cursor != "" { + got, err := decodeCursor(req.Cursor, want) + if err != nil { + return err + } + start = sort.Search(len(s.clusters), func(i int) bool { return got.Key < clusterKey(s.clusters[i].Kind, s.clusters[i].ID) }) + } + end := min(start+limit, len(s.clusters)) + for _, c := range s.clusters[start:end] { + c.ByState = copyCounts(c.ByState) + res.Clusters = append(res.Clusters, c) + } + res.Returned = len(res.Clusters) + if end < len(s.clusters) && end > start { + last := s.clusters[end-1] + want.Key = clusterKey(last.Kind, last.ID) + res.Next = encodeCursor(want) + } + return nil +} + +func (s *Snapshot) answerPeople(res *GraphResult) { + res.Crews = append([]CrewFact(nil), s.crews...) + for i := range res.Crews { + res.Crews[i].ClosedAt = copyTime(res.Crews[i].ClosedAt) + if h := res.Crews[i].Hue; h != nil { + hue := *h + res.Crews[i].Hue = &hue + } + } + n := min(len(s.people), MaxMembers) + res.Members = make([]MemberFact, n) + for i := 0; i < n; i++ { + m := s.people[i] + m.JoinedAt = copyTime(m.JoinedAt) + m.Tasks.ByState = copyCounts(m.Tasks.ByState) + res.Members[i] = m + } + res.Returned = n + res.Cut = len(s.people) > n +} diff --git a/internal/workgraph/build.go b/internal/workgraph/build.go new file mode 100644 index 0000000000..0ba13354ee --- /dev/null +++ b/internal/workgraph/build.go @@ -0,0 +1,479 @@ +package workgraph + +import ( + "context" + "errors" + "fmt" + "sort" + "time" +) + +// ErrNotFound is a request about a task, crew or agent the snapshot does not +// hold; ErrBadRequest is one that cannot be answered as asked. The app door +// maps them to not_found and bad_params. +var ( + ErrNotFound = errors.New("workgraph: not found") + ErrBadRequest = errors.New("workgraph: bad request") +) + +func badf(format string, args ...any) error { + return fmt.Errorf("%w: %s", ErrBadRequest, fmt.Sprintf(format, args...)) +} + +// tickEvery is how many items a loop handles between looks at its context. +const tickEvery = 1024 + +// Snapshot is the whole of one scope's facts, built once from one [Input] and +// never changed afterwards. Every answer is a copy out of it, so two callers — +// or one caller twice — never share a slice, and a cache may hold it freely. +type Snapshot struct { + scope GraphScope + about ScopeFacts + revision string + asOf *time.Time + + nodes []GraphNode + keys []orderKey + idx map[string]int32 + + // edgeStart is a CSR index: node i's edges are edges[edgeStart[i]:edgeStart[i+1]]. + edgeStart []int32 + edges []GraphEdge + edgeTo []int32 + // revStart and revList are the same index backwards: the nodes that wait on i. + revStart []int32 + revList []int32 + + clusters []GraphCluster + members map[string][]int32 + crews []CrewFact + people []MemberFact + + metrics GraphMetrics + totals GraphTotals + coverage Coverage + diag Diagnostics +} + +// Revision is the revision the snapshot was built for. +func (s *Snapshot) Revision() string { return s.revision } + +// Len is how many nodes the snapshot holds. +func (s *Snapshot) Len() int { return len(s.nodes) } + +// builder carries one build's working state. +type builder struct { + ctx context.Context + in Input + snap *Snapshot + all []Fact + allIx map[string]int + // kept are the indexes into all that are in scope, in order. + kept []int + owners map[string]bool + diag Diagnostics +} + +func (b *builder) tick(i int) error { + if i%tickEvery == tickEvery-1 { + return b.ctx.Err() + } + return nil +} + +// Build makes one snapshot, in O(N+E) over the facts, looking at ctx every +// [tickEvery] items. It reports a cancelled context and a task scope whose +// root is not among the facts; it never reports a fact it finds odd — odd facts +// are classified and counted in the snapshot's diagnostics. +func Build(ctx context.Context, in Input) (*Snapshot, error) { + b := &builder{ctx: ctx, in: in, snap: &Snapshot{scope: in.Scope, about: in.About, revision: in.Revision, asOf: stamp(in.AsOf)}} + steps := []func() error{b.prepare, b.selectScope, b.makeNodes, b.makeEdges, b.findCycles, b.placeCrews, b.makeClusters, b.makePeople, b.measure, b.finish} + for _, step := range steps { + if err := ctx.Err(); err != nil { + return nil, err + } + if err := step(); err != nil { + return nil, err + } + } + return b.snap, nil +} + +// prepare orders the facts and indexes them, dropping a task listed twice. +func (b *builder) prepare() error { + facts := make([]Fact, 0, len(b.in.Facts)) + for i, f := range b.in.Facts { + if err := b.tick(i); err != nil { + return err + } + if f.Node.Ref.Valid() { + facts = append(facts, f) + } + } + sortFacts(facts) + b.allIx = make(map[string]int, len(facts)) + b.owners = map[string]bool{} + for _, f := range facts { + id := f.Node.Ref.ID() + if _, dup := b.allIx[id]; dup { + continue + } + b.allIx[id] = len(b.all) + b.all = append(b.all, f) + b.owners[f.Node.Ref.Owner] = true + } + return nil +} + +// selectScope keeps the scope's tasks: all of them, or for a task scope the +// root's sub-tasks at every depth, reached through the family links with a +// visited set so a family that loops cannot run away. +func (b *builder) selectScope() error { + loops := b.parentLoops() + b.diag.ParentCycles = loops + if b.in.Scope.Kind != ScopeTask { + b.kept = make([]int, len(b.all)) + for i := range b.all { + b.kept[i] = i + } + return nil + } + root, ok := b.allIx[b.in.Root] + if !ok { + return fmt.Errorf("%w: task %s", ErrNotFound, b.in.Root) + } + return b.descend(root) +} + +// parentOf is the index of a fact's parent among all facts, or -1. +func (b *builder) parentOf(i int) int { + p := b.all[i].Node.Parent + if p == "" { + return -1 + } + if j, ok := b.allIx[p]; ok { + return j + } + return -1 +} + +// parentLoops counts the tasks on a family loop. Every task has at most one +// parent, so the family is a functional graph and one coloured walk finds every +// loop in O(N). +func (b *builder) parentLoops() int { + color := make([]uint8, len(b.all)) + loops := 0 + for i := range b.all { + if color[i] != 0 { + continue + } + var path []int + cur := i + for cur != -1 && color[cur] == 0 { + color[cur] = 1 + path = append(path, cur) + cur = b.parentOf(cur) + } + if cur != -1 && color[cur] == 1 { + loops += loopLength(path, cur) + } + for _, p := range path { + color[p] = 2 + } + } + return loops +} + +// loopLength is how many tasks of path lie on the loop that closes at cur. +func loopLength(path []int, cur int) int { + for at, p := range path { + if p == cur { + return len(path) - at + } + } + return 0 +} + +// descend keeps root's descendants in creation order. +func (b *builder) descend(root int) error { + children := make(map[int][]int) + for i := range b.all { + if err := b.tick(i); err != nil { + return err + } + if p := b.parentOf(i); p >= 0 { + children[p] = append(children[p], i) + } + } + seen := map[int]bool{root: true} + queue := []int{root} + var keep []int + for head := 0; head < len(queue); head++ { + if err := b.tick(head); err != nil { + return err + } + for _, c := range children[queue[head]] { + if !seen[c] { + seen[c] = true + queue = append(queue, c) + keep = append(keep, c) + } + } + } + sort.Ints(keep) + b.kept = keep + return b.rootFacts(root) +} + +// rootFacts puts the task scope's own task in About.Root. +func (b *builder) rootFacts(root int) error { + n := b.all[root].Node + n.ParentState = b.parentState(root) + n.WaitsOnTotal = len(dedupeWaits(b.all[root].Waits)) + id := n.ID + for i, f := range b.all { + if err := b.tick(i); err != nil { + return err + } + for _, w := range dedupeWaits(f.Waits) { + if w.On.ID() == id { + n.WaitedByTotal++ + } + } + } + n.Crew = nil + b.snap.about.Root = cloneNode(&n) + return nil +} + +// parentState classifies a fact's recorded parent against the facts. +func (b *builder) parentState(i int) string { + switch { + case b.all[i].Node.Parent == "": + return "root" + case b.parentOf(i) >= 0: + return "node" + } + return "missing" +} + +// dedupeWaits drops a link recorded twice to one task, keeping the first. +func dedupeWaits(in []Wait) []Wait { + if len(in) < 2 { + return in + } + seen := make(map[string]bool, len(in)) + out := make([]Wait, 0, len(in)) + for _, w := range in { + id := w.On.ID() + if !w.On.Valid() || seen[id] { + continue + } + seen[id] = true + out = append(out, w) + } + return out +} + +// makeNodes copies the kept facts into the snapshot's own nodes. +func (b *builder) makeNodes() error { + s := b.snap + s.nodes = make([]GraphNode, len(b.kept)) + s.keys = make([]orderKey, len(b.kept)) + s.idx = make(map[string]int32, len(b.kept)) + for at, i := range b.kept { + if err := b.tick(at); err != nil { + return err + } + n := *cloneNode(&b.all[i].Node) + n.ParentState = b.parentState(i) + if n.ParentState == "missing" { + b.diag.MissingParents++ + } + if n.Links == "" { + n.Links = "recorded" + } + s.nodes[at], s.keys[at] = n, keyOf(&n) + s.idx[n.ID] = int32(at) + } + return nil +} + +// target classifies one link's target against the scope and the facts. +func (b *builder) target(w Wait) (kind, state string, node int32) { + id := w.On.ID() + if j, ok := b.snap.idx[id]; ok { + return TargetNode, b.snap.nodes[j].State, j + } + if k, ok := b.allIx[id]; ok { + return TargetExternal, b.all[k].Node.State, -1 + } + if b.owners[w.On.Owner] { + return TargetMissing, "", -1 + } + if b.in.Resolve == nil { + return TargetExternal, "", -1 + } + res := b.in.Resolve(w.On) + switch res.Standing { + case StandingFound: + return TargetExternal, res.State, -1 + case StandingAbsent: + return TargetMissing, "", -1 + } + return TargetExternal, "", -1 +} + +// makeEdges turns every stored link of a kept task into an edge, keeping the +// whole list (the page cuts it) and the in/out degrees. +func (b *builder) makeEdges() error { + s := b.snap + s.edgeStart = make([]int32, len(s.nodes)+1) + waitedBy := make([]int32, len(s.nodes)) + for at, i := range b.kept { + if err := b.tick(at); err != nil { + return err + } + s.edgeStart[at] = int32(len(s.edges)) + for _, w := range dedupeWaits(b.all[i].Waits) { + e, to := b.edge(s.nodes[at].ID, w) + s.edges = append(s.edges, e) + s.edgeTo = append(s.edgeTo, to) + if to >= 0 { + waitedBy[to]++ + } + } + s.nodes[at].WaitsOnTotal = len(s.edges) - int(s.edgeStart[at]) + s.nodes[at].WaitsOnCut = s.nodes[at].WaitsOnTotal > MaxEdgesPerNode + } + s.edgeStart[len(s.nodes)] = int32(len(s.edges)) + for at := range s.nodes { + s.nodes[at].WaitedByTotal = int(waitedBy[at]) + } + b.reverse(waitedBy) + return nil +} + +func (b *builder) edge(from string, w Wait) (GraphEdge, int32) { + kind, state, to := b.target(w) + switch kind { + case TargetMissing: + b.diag.MissingTargets++ + case TargetExternal: + b.diag.ExternalTargets++ + } + via := w.Via + if via == "" { + via = ViaRecord + } + return GraphEdge{From: from, To: w.On.ID(), ToRef: w.On, Kind: KindWaitsOn, Via: via, Target: kind, ToState: state}, to +} + +// reverse builds the dependents index from the edges. +func (b *builder) reverse(waitedBy []int32) { + s := b.snap + s.revStart = make([]int32, len(s.nodes)+1) + for i := range s.nodes { + s.revStart[i+1] = s.revStart[i] + waitedBy[i] + } + s.revList = make([]int32, s.revStart[len(s.nodes)]) + fill := make([]int32, len(s.nodes)) + for from := range s.nodes { + for e := s.edgeStart[from]; e < s.edgeStart[from+1]; e++ { + if to := s.edgeTo[e]; to >= 0 { + s.revList[s.revStart[to]+fill[to]] = int32(from) + fill[to]++ + } + } + } +} + +func (b *builder) finish() error { + s := b.snap + s.diag = b.diag + s.totals.Nodes = len(s.nodes) + s.totals.Edges = len(s.edges) + s.totals.Clusters = len(s.clusters) + s.totals.Crews = len(s.crews) + s.totals.ByState = map[string]int{} + for i := range s.nodes { + s.totals.ByState[s.nodes[i].State]++ + } + s.coverage = b.coverage() + return nil +} + +func (b *builder) coverage() Coverage { + s := b.snap + c := Coverage{ + Complete: len(b.in.Unread) == 0 && !b.in.Truncated, + Truncated: b.in.Truncated, + Owners: b.in.Owners, + Unread: append([]OwnerIssue(nil), b.in.Unread...), + Excluded: append([]string(nil), b.in.Excluded...), + } + if len(c.Unread) > 16 { + c.Unread = c.Unread[:16] + } + for i := range s.nodes { + n := &s.nodes[i] + if n.Links == "unrecorded" { + c.Links.Unrecorded++ + } else { + c.Links.Recorded++ + } + c.Clock = addClock(c.Clock, n.Clock) + } + return c +} + +func addClock(c ClockCoverage, k Clock) ClockCoverage { + if k.AdmittedAt != nil { + c.Admitted++ + } + if k.StartedAt != nil { + c.Started++ + } + if k.StateAt != nil { + c.State++ + } + if k.EndedAt != nil { + c.Ended++ + } + return c +} + +// cloneNode is a deep copy of a node, so no answer aliases the snapshot. +func cloneNode(n *GraphNode) *GraphNode { + c := *n + if n.Group != nil { + g := *n.Group + c.Group = &g + } + if n.Crew != nil { + p := *n.Crew + p.Path = append([]string(nil), n.Crew.Path...) + p.AlsoIn = append([]string(nil), n.Crew.AlsoIn...) + c.Crew = &p + } + c.Clock = Clock{AdmittedAt: copyTime(n.Clock.AdmittedAt), StartedAt: copyTime(n.Clock.StartedAt), StateAt: copyTime(n.Clock.StateAt), EndedAt: copyTime(n.Clock.EndedAt)} + if n.Instructions != nil { + t := *n.Instructions + c.Instructions = &t + } + if n.Landing != nil { + l := *n.Landing + l.At = copyTime(n.Landing.At) + c.Landing = &l + } + c.Files.Paths = append([]string(nil), n.Files.Paths...) + if n.Spend != nil { + sp := *n.Spend + c.Spend = &sp + } + if n.Checks != nil { + ck := *n.Checks + c.Checks = &ck + } + return &c +} diff --git a/internal/workgraph/build_test.go b/internal/workgraph/build_test.go new file mode 100644 index 0000000000..ae78e9efff --- /dev/null +++ b/internal/workgraph/build_test.go @@ -0,0 +1,614 @@ +package workgraph + +import ( + "context" + "errors" + "fmt" + "strings" + "testing" + "time" +) + +var epoch = time.Date(2026, 10, 15, 9, 0, 0, 0, time.UTC) + +// at is epoch plus n minutes. +func minute(n int) *time.Time { + t := epoch.Add(time.Duration(n) * time.Minute) + return &t +} + +type opt func(*Fact) + +func parent(id string) opt { return func(f *Fact) { f.Node.Parent = id } } +func state(s string) opt { return func(f *Fact) { f.Node.State = s } } +func kind(k string) opt { return func(f *Fact) { f.Node.Kind = k } } +func admitted(m int) opt { return func(f *Fact) { f.Node.Clock.AdmittedAt = minute(m) } } +func unrecorded() opt { return func(f *Fact) { f.Node.Links = "unrecorded" } } +func spend(t TaskSpend) opt { return func(f *Fact) { f.Node.Spend = &t } } +func landing(o string) opt { return func(f *Fact) { f.Node.Landing = &Landing{Outcome: o} } } +func checks(p, x int) opt { return func(f *Fact) { f.Node.Checks = &TaskChecks{Passed: p, Failed: x} } } +func files(paths ...string) opt { + return func(f *Fact) { f.Node.Files = FileSet{Paths: paths, Total: len(paths), Source: "changed"} } +} +func waits(owner, task string) opt { + return func(f *Fact) { f.Waits = append(f.Waits, Wait{On: TaskRef{Owner: owner, Task: task}, Via: ViaRecord}) } +} +func waitsExt(owner, task string) opt { + return func(f *Fact) { + f.Waits = append(f.Waits, Wait{On: TaskRef{Owner: owner, Task: task}, Via: ViaExternal}) + } +} + +// task is one fact: owner "a" task number n, queued unless an option says so. +func task(owner string, n int, opts ...opt) Fact { + ref := TaskRef{Owner: owner, Task: fmt.Sprint(n)} + f := Fact{Node: GraphNode{ID: ref.ID(), Ref: ref, Seq: uint64(n), Kind: "task", Title: "task " + ref.Task, State: StateQueued, Links: "recorded"}} + f.Node.Clock.AdmittedAt = minute(n) + for _, o := range opts { + o(&f) + } + return f +} + +func input(scope GraphScope, facts ...Fact) Input { + return Input{Scope: scope, Facts: facts, Revision: "r1", Owners: 1} +} + +var agentA = GraphScope{Kind: ScopeAgent, ID: "a"} + +func mustBuild(t *testing.T, in Input) *Snapshot { + t.Helper() + s, err := Build(context.Background(), in) + if err != nil { + t.Fatalf("Build: %v", err) + } + return s +} + +func answer(t *testing.T, s *Snapshot, req GraphRequest) *GraphResult { + t.Helper() + res, err := s.Answer(context.Background(), req) + if err != nil { + t.Fatalf("Answer: %v", err) + } + return res +} + +func edgeSet(res *GraphResult) map[string]GraphEdge { + out := map[string]GraphEdge{} + for _, e := range res.Edges { + out[e.From+">"+e.To] = e + } + return out +} + +func TestEdgesRunDependentToPrerequisiteAndAreNeverInferred(t *testing.T) { + // Three tasks in a row with titles that read like a chain: ONLY the stored + // link makes an edge. Order, titles and a shared parent make none. + in := input(agentA, + task("a", 1, func(f *Fact) { f.Node.Title = "step one" }), + task("a", 2, func(f *Fact) { f.Node.Title = "step two after step one" }, waits("a", "1")), + task("a", 3, func(f *Fact) { f.Node.Title = "step three after step two" }), + ) + res := answer(t, mustBuild(t, in), GraphRequest{Scope: agentA}) + edges := edgeSet(res) + if len(edges) != 1 { + t.Fatalf("edges = %+v, want exactly the stored one", res.Edges) + } + e, ok := edges["a:2>a:1"] + if !ok || e.Kind != KindWaitsOn || e.Via != ViaRecord || e.Target != TargetNode { + t.Fatalf("edge = %+v", e) + } + if res.Nodes[1].WaitsOnTotal != 1 || res.Nodes[0].WaitedByTotal != 1 || res.Nodes[2].WaitsOnTotal != 0 { + t.Fatalf("degrees = %+v", res.Nodes) + } +} + +func TestDependencyDepthAndFocusWalkNearestFirst(t *testing.T) { + var facts []Fact + facts = append(facts, task("a", 1)) + for n := 2; n <= 8; n++ { + facts = append(facts, task("a", n, waits("a", fmt.Sprint(n-1)))) + } + s := mustBuild(t, input(agentA, facts...)) + res := answer(t, s, GraphRequest{Scope: agentA, Focus: &FocusRef{Task: "a:8", Direction: FocusPrerequisites, Depth: 3}}) + var got []string + for _, n := range res.Nodes { + got = append(got, n.ID) + } + if strings.Join(got, ",") != "a:8,a:7,a:6,a:5" || !res.Cut { + t.Fatalf("focus = %v cut=%v", got, res.Cut) + } + res = answer(t, s, GraphRequest{Scope: agentA, Focus: &FocusRef{Task: "a:1", Direction: FocusDependents, Depth: 6}}) + // Six steps from the first task reach the seventh and stop short of the eighth. + if len(res.Nodes) != 7 || !res.Cut { + t.Fatalf("dependents = %d cut=%v", len(res.Nodes), res.Cut) + } + res = answer(t, s, GraphRequest{Scope: agentA, Focus: &FocusRef{Task: "a:4", Direction: FocusBoth, Depth: 6}}) + if len(res.Nodes) != 8 || res.Cut { + t.Fatalf("both = %d cut=%v", len(res.Nodes), res.Cut) + } +} + +func TestCyclesAreReportedAndLeftInPlace(t *testing.T) { + s := mustBuild(t, input(agentA, + task("a", 1, waits("a", "3")), + task("a", 2, waits("a", "1")), + task("a", 3, waits("a", "2")), + task("a", 4, waits("a", "4")), + task("a", 5), + )) + res := answer(t, s, GraphRequest{Scope: agentA}) + if res.Diagnostics.CyclesTotal != 2 || len(res.Diagnostics.Cycles) != 2 { + t.Fatalf("diagnostics = %+v", res.Diagnostics) + } + for _, n := range res.Nodes { + want := n.Ref.Task != "5" + if n.InCycle != want { + t.Fatalf("%s in_cycle = %v", n.ID, n.InCycle) + } + } + for _, e := range res.Edges { + if !e.InCycle { + t.Fatalf("edge %s>%s is on a cycle", e.From, e.To) + } + } +} + +func TestExternalAndMissingTargetsAreExplicitAndNeverFabricated(t *testing.T) { + in := input(agentA, + task("a", 1, waitsExt("b", "7"), waitsExt("b", "9"), waitsExt("c", "1"), waits("a", "99")), + ) + in.Resolve = func(r TaskRef) Resolution { + switch r.ID() { + case "b:7": + return Resolution{Standing: StandingFound, State: StateRunning} + case "b:9": + return Resolution{Standing: StandingAbsent} + } + return Resolution{Standing: StandingUnknown} + } + res := answer(t, mustBuild(t, in), GraphRequest{Scope: agentA}) + e := edgeSet(res) + if x := e["a:1>b:7"]; x.Target != TargetExternal || x.ToState != StateRunning || x.Via != ViaExternal { + t.Fatalf("b:7 = %+v", x) + } + if x := e["a:1>b:9"]; x.Target != TargetMissing { + t.Fatalf("b:9 = %+v", x) + } + // An owner nobody could read says nothing about the task: it is external with + // no state, not "missing". + if x := e["a:1>c:1"]; x.Target != TargetExternal || x.ToState != "" { + t.Fatalf("c:1 = %+v", x) + } + if x := e["a:1>a:99"]; x.Target != TargetMissing { + t.Fatalf("a:99 = %+v", x) + } + if d := res.Diagnostics; d.MissingTargets != 2 || d.ExternalTargets != 2 { + t.Fatalf("diagnostics = %+v", d) + } + for _, n := range res.Nodes { + if n.ID == "b:7" || n.ID == "b:9" { + t.Fatalf("a target outside the scope became a node: %+v", n) + } + } +} + +func TestSameTaskNumberUnderDifferentOwnersNeverCollides(t *testing.T) { + scope := GraphScope{Kind: ScopeCrew, ID: "crew"} + in := input(scope, + task("a", 1), task("b", 1, waits("a", "1")), task("c", 1, waitsExt("b", "1")), + ) + in.Owners = 3 + s := mustBuild(t, in) + res := answer(t, s, GraphRequest{Scope: scope}) + if len(res.Nodes) != 3 { + t.Fatalf("nodes = %d", len(res.Nodes)) + } + e := edgeSet(res) + if e["b:1>a:1"].Target != TargetNode || e["c:1>b:1"].Target != TargetNode { + t.Fatalf("edges = %+v", res.Edges) + } +} + +func TestADuplicateFactIsDroppedAndAnInvalidRefIsNeverAdopted(t *testing.T) { + bad := task("a", 3) + bad.Node.Ref.Owner = "../etc" + res := answer(t, mustBuild(t, input(agentA, task("a", 1), task("a", 1), bad)), GraphRequest{Scope: agentA}) + if len(res.Nodes) != 1 { + t.Fatalf("nodes = %+v", res.Nodes) + } +} + +func TestNestedFamilyClosedMissingAndLoopingParentsAreClassified(t *testing.T) { + task1 := task("a", 1) + in := input(agentA, + task1, + task("a", 2, parent("a:1")), + task("a", 3, parent("a:2")), + task("a", 4, parent("a:77")), // a parent nobody holds + task("a", 5, parent("a:6")), // a family loop + task("a", 6, parent("a:5")), + ) + res := answer(t, mustBuild(t, in), GraphRequest{Scope: agentA}) + by := map[string]GraphNode{} + for _, n := range res.Nodes { + by[n.ID] = n + } + if by["a:1"].ParentState != "root" || by["a:2"].ParentState != "node" || by["a:4"].ParentState != "missing" { + t.Fatalf("parent states = %+v", by) + } + if d := res.Diagnostics; d.MissingParents != 1 || d.ParentCycles != 2 { + t.Fatalf("diagnostics = %+v", d) + } + // A task scope keeps the root's subtree at every depth and nothing else, and + // does not run away on the loop. + scope := GraphScope{Kind: ScopeTask, ID: "a:1"} + in.Scope, in.Root = scope, "a:1" + got := answer(t, mustBuild(t, in), GraphRequest{Scope: scope}) + // The root is what the scope is about (About.Root); the page holds its + // sub-tasks at every depth and nothing else. + if len(got.Nodes) != 2 || got.About.Root == nil || got.About.Root.ID != "a:1" { + t.Fatalf("task scope nodes = %d root=%v", len(got.Nodes), got.About.Root) + } + in.Scope, in.Root = GraphScope{Kind: ScopeTask, ID: "a:5"}, "a:5" + if got := answer(t, mustBuild(t, in), GraphRequest{Scope: in.Scope}); len(got.Nodes) != 1 { + t.Fatalf("looping family nodes = %d", len(got.Nodes)) + } + in.Scope, in.Root = GraphScope{Kind: ScopeTask, ID: "a:50"}, "a:50" + if _, err := Build(context.Background(), in); !errors.Is(err, ErrNotFound) { + t.Fatalf("missing root err = %v", err) + } +} + +func TestSubcrewOwnershipAncestryAndClusterExpansion(t *testing.T) { + scope := GraphScope{Kind: ScopeCrew, ID: "top"} + in := input(scope, task("a", 1), task("b", 1), task("c", 1), task("d", 1)) + in.Owners = 4 + in.Crews = []CrewInput{ + {CrewFact{ID: "top", Name: "Top"}}, + {CrewFact{ID: "mid", Name: "Mid", Parent: "top"}}, + {CrewFact{ID: "leaf", Name: "Leaf", Parent: "mid"}}, + {CrewFact{ID: "loop1", Name: "Loop", Parent: "loop2"}}, + {CrewFact{ID: "loop2", Name: "Loop", Parent: "loop1"}}, + } + in.Members = []MemberInput{ + {MemberFact: MemberFact{Agent: "a", Crew: "top", Manager: true}, Home: true}, + {MemberFact: MemberFact{Agent: "b", Crew: "mid"}}, + {MemberFact: MemberFact{Agent: "c", Crew: "leaf"}}, + {MemberFact: MemberFact{Agent: "c", Crew: "mid"}}, + {MemberFact: MemberFact{Agent: "d", Crew: "loop1"}}, + } + s := mustBuild(t, in) + res := answer(t, s, GraphRequest{Scope: scope}) + by := map[string]GraphNode{} + for _, n := range res.Nodes { + by[n.Ref.Owner] = n + } + if p := by["c"].Crew; p == nil || p.Crew != "leaf" || strings.Join(p.Path, ">") != "top>mid>leaf" || len(p.AlsoIn) != 1 || p.AlsoIn[0] != "mid" { + t.Fatalf("c place = %+v", by["c"].Crew) + } + if p := by["d"].Crew; p == nil || strings.Join(p.Path, ">") != "loop2>loop1" { + t.Fatalf("looping ancestry must be cut where it repeats: %+v", p) + } + // Expanding the mid crew yields its members and every crew under it. + got := answer(t, s, GraphRequest{Scope: scope, Cluster: &ClusterRef{Kind: ClusterCrew, ID: "mid"}}) + var owners []string + for _, n := range got.Nodes { + owners = append(owners, n.Ref.Owner) + } + if strings.Join(owners, ",") != "b,c" { + t.Fatalf("mid cluster = %v", owners) + } + if _, err := s.Answer(context.Background(), GraphRequest{Scope: scope, Cluster: &ClusterRef{Kind: ClusterCrew, ID: "nope"}}); !errors.Is(err, ErrNotFound) { + t.Fatalf("unknown cluster err = %v", err) + } + people := answer(t, s, GraphRequest{Scope: scope, View: ViewPeople}) + if len(people.Crews) < 3 || len(people.Members) != 5 { + t.Fatalf("people = %d crews %d members", len(people.Crews), len(people.Members)) + } +} + +func TestClosedCrewKeepsItsActualState(t *testing.T) { + scope := GraphScope{Kind: ScopeCrew, ID: "top"} + in := input(scope, task("a", 1, state(StateDone))) + in.Crews = []CrewInput{{CrewFact{ID: "top", Name: "Top", State: "closed", ClosedAt: minute(30)}}} + in.Members = []MemberInput{{MemberFact: MemberFact{Agent: "a", Crew: "top"}, Home: true}} + res := answer(t, mustBuild(t, in), GraphRequest{Scope: scope, View: ViewPeople}) + if len(res.Crews) != 1 || res.Crews[0].State != "closed" || res.Crews[0].ClosedAt == nil || !res.Crews[0].ClosedAt.Equal(*minute(30)) { + t.Fatalf("crews = %+v", res.Crews) + } +} + +func TestOldRecordsWithoutLinksAreUnknownNotEmpty(t *testing.T) { + in := input(agentA, task("a", 1), task("a", 2, unrecorded())) + res := answer(t, mustBuild(t, in), GraphRequest{Scope: agentA}) + if res.Coverage.Links.Recorded != 1 || res.Coverage.Links.Unrecorded != 1 { + t.Fatalf("link coverage = %+v", res.Coverage.Links) + } + if res.Nodes[0].Links != "recorded" || res.Nodes[1].Links != "unrecorded" { + t.Fatalf("links = %q %q", res.Nodes[0].Links, res.Nodes[1].Links) + } + // A task with a link-less record that nonetheless is waited on stays + // honest: the in-edges are real, the out-edges are unknown. + in = input(agentA, task("a", 1, unrecorded()), task("a", 2, waits("a", "1"))) + res = answer(t, mustBuild(t, in), GraphRequest{Scope: agentA}) + if res.Nodes[0].WaitedByTotal != 1 || res.Nodes[0].Links != "unrecorded" { + t.Fatalf("node = %+v", res.Nodes[0]) + } +} + +func TestClockCoverageCountsOnlyRecordedInstants(t *testing.T) { + in := input(agentA, + task("a", 1, func(f *Fact) { f.Node.Clock = Clock{} }), + task("a", 2, func(f *Fact) { f.Node.Clock.StartedAt = minute(3); f.Node.Clock.StateAt = minute(4) }), + ) + res := answer(t, mustBuild(t, in), GraphRequest{Scope: agentA}) + if c := res.Coverage.Clock; c.Admitted != 1 || c.Started != 1 || c.State != 1 || c.Ended != 0 { + t.Fatalf("clock coverage = %+v", c) + } + // An unknown admission time sorts first, so creation order stays stable. + if res.Nodes[0].Ref.Task != "1" { + t.Fatalf("order = %s", res.Nodes[0].Ref.Task) + } +} + +func TestMetricsKnownZeroFollowsSourceCoverage(t *testing.T) { + // An empty scope that was read whole has exactly zero tasks and zero + // landed work: those are KNOWN zeros. + res := answer(t, mustBuild(t, input(agentA)), GraphRequest{Scope: agentA, View: ViewMetrics}) + m := res.Metrics + if !m.Tasks.Known || m.Tasks.Value != 0 || m.Tasks.Partial || !m.LandedTasks.Known || !m.ChecksPassed.Known { + t.Fatalf("empty known scope = %+v", m) + } + if m.Spend.Known { + t.Fatalf("no task carries a bill, so no spend is known: %+v", m.Spend) + } + // The same empty scope with an unread owner knows nothing. + in := input(agentA) + in.Unread = []OwnerIssue{{Owner: "a", Reason: "unreadable"}} + m = answer(t, mustBuild(t, in), GraphRequest{Scope: agentA, View: ViewMetrics}).Metrics + if m.Tasks.Known || !m.Tasks.Partial || m.LandedTasks.Known || m.ChecksPassed.Known || m.Spend.Known { + t.Fatalf("unread scope claimed knowledge: %+v", m) + } + // A truncated scope is a lower bound, not a zero. + in = input(agentA) + in.Truncated = true + if m = answer(t, mustBuild(t, in), GraphRequest{Scope: agentA, View: ViewMetrics}).Metrics; m.Tasks.Known { + t.Fatalf("truncated empty scope claimed zero: %+v", m.Tasks) + } +} + +func TestMetricsCountOnlyWhatRecordsEstablish(t *testing.T) { + in := input(agentA, + task("a", 1, state(StateDone), landing("merged"), checks(2, 1), files("x.go", "y.go"), spend(TaskSpend{USD: 0.5, Basis: "priced"})), + task("a", 2, state(StateDone), landing("discarded"), files("z.go")), + task("a", 3, state(StateDone)), + task("a", 4, state(StateDone), landing("inplace"), checks(1, 0), files("x.go"), spend(TaskSpend{Tokens: 90, Basis: "unpriced"})), + task("a", 5, state(StateRunning), parent("a:1")), + task("a", 6, kind(KindQuick), state(StateDone)), + ) + m := answer(t, mustBuild(t, in), GraphRequest{Scope: agentA, View: ViewMetrics}).Metrics + // A quick lookup is not a task and does not enter the landing or check counts. + figure(t, "tasks", m.Tasks, 5, 5, 5, true, false) + figure(t, "subtasks", m.Subtasks, 1, 1, 1, true, false) + // Of four done tasks, three carry a landing outcome; two of those landed. + figure(t, "landed", m.LandedTasks, 2, 3, 4, true, true) + figure(t, "landed files", m.LandedFiles, 2, 2, 2, true, false) + figure(t, "checks", m.ChecksPassed, 3, 2, 4, true, true) + // Only the priced bill adds to spend; the unpriced one is counted apart and + // is never a zero. + figure(t, "spend", m.Spend, 0.5, 1, 6, true, true) + if m.Unpriced != 1 { + t.Fatalf("unpriced = %d", m.Unpriced) + } + if m.From == nil || m.To != nil && m.To.Before(*m.From) { + t.Fatalf("span = %v..%v", m.From, m.To) + } +} + +func figure(t *testing.T, name string, f GraphFigure, value float64, counted, of int, known, partial bool) { + t.Helper() + if f.Value != value || f.Counted != counted || f.Of != of || f.Known != known || f.Partial != partial { + t.Errorf("%s = %+v, want value %v counted %d of %d known %v partial %v", name, f, value, counted, of, known, partial) + } +} + +func TestAFreeZeroBillIsNotKnownPricedUnlessTheSourceSaysSo(t *testing.T) { + // A producer that cannot establish the price marks the bill unpriced, and a + // recorded 0.00 under that basis stays unknown rather than becoming a free + // $0.00. + in := input(agentA, task("a", 1, state(StateDone), spend(TaskSpend{USD: 0, Basis: "unpriced"}))) + m := answer(t, mustBuild(t, in), GraphRequest{Scope: agentA, View: ViewMetrics}).Metrics + if m.Spend.Known || m.Spend.Value != 0 || m.Unpriced != 1 { + t.Fatalf("spend = %+v unpriced=%d", m.Spend, m.Unpriced) + } + in = input(agentA, task("a", 1, state(StateDone), spend(TaskSpend{USD: 0, Basis: "priced"}))) + if m = answer(t, mustBuild(t, in), GraphRequest{Scope: agentA, View: ViewMetrics}).Metrics; !m.Spend.Known { + t.Fatalf("a priced zero is a known zero: %+v", m.Spend) + } +} + +func TestPagingIsKeysetBoundedAndSurvivesAppends(t *testing.T) { + var facts []Fact + for n := 1; n <= 25; n++ { + facts = append(facts, task("a", n)) + } + s := mustBuild(t, input(agentA, facts...)) + req := GraphRequest{Scope: agentA, Limit: 10} + var seen []string + var cursor string + for pages := 0; ; pages++ { + req.Cursor = cursor + res := answer(t, s, req) + if len(res.Nodes) > 10 || res.Totals.Nodes != 25 { + t.Fatalf("page %d: %d nodes, total %d", pages, len(res.Nodes), res.Totals.Nodes) + } + for _, n := range res.Nodes { + seen = append(seen, n.ID) + } + if cursor = res.Next; cursor == "" { + break + } + if pages > 5 { + t.Fatal("paging does not end") + } + } + if len(seen) != 25 || seen[0] != "a:1" || seen[24] != "a:25" { + t.Fatalf("seen = %v", seen) + } + // The cursor from the first page keeps meaning after tasks are appended: a + // new snapshot continues from the same position, and the appended tasks are + // reached later, not skipped or repeated. + first := answer(t, s, GraphRequest{Scope: agentA, Limit: 10}) + more := append(facts, task("a", 26), task("a", 27)) + s2 := mustBuild(t, Input{Scope: agentA, Facts: more, Revision: "r2", Owners: 1}) + next := answer(t, s2, GraphRequest{Scope: agentA, Limit: 10, Cursor: first.Next}) + if next.Nodes[0].ID != "a:11" { + t.Fatalf("after an append the next page starts at %s", next.Nodes[0].ID) + } +} + +func TestLimitsAreCappedAndRefused(t *testing.T) { + var facts []Fact + for n := 1; n <= MaxNodes+50; n++ { + facts = append(facts, task("a", n)) + } + s := mustBuild(t, input(agentA, facts...)) + if res := answer(t, s, GraphRequest{Scope: agentA, Limit: 100000}); len(res.Nodes) != MaxNodes || res.Next == "" { + t.Fatalf("limit not capped: %d", len(res.Nodes)) + } + for _, bad := range []GraphRequest{ + {Scope: GraphScope{Kind: "planet", ID: "a"}}, + {Scope: agentA, View: "canvas"}, + {Scope: agentA, Limit: -1}, + {Scope: GraphScope{Kind: ScopeTask, ID: "no-number"}}, + {Scope: agentA, View: ViewPeople, Focus: &FocusRef{Task: "a:1"}}, + {Scope: agentA, IfRevision: strings.Repeat("x", 200)}, + } { + if _, err := s.Answer(context.Background(), bad); !errors.Is(err, ErrBadRequest) { + t.Fatalf("%+v err = %v", bad, err) + } + } +} + +func TestACursorThatWasEditedForeignOrForAnotherRequestIsRefused(t *testing.T) { + var facts []Fact + for n := 1; n <= 30; n++ { + facts = append(facts, task("a", n)) + } + s := mustBuild(t, input(agentA, facts...)) + page := answer(t, s, GraphRequest{Scope: agentA, Limit: 10}) + tampered := page.Next[:len(page.Next)-2] + "AA" + other := GraphScope{Kind: ScopeAgent, ID: "z"} + for name, req := range map[string]GraphRequest{ + "tampered": {Scope: agentA, Limit: 10, Cursor: tampered}, + "garbage": {Scope: agentA, Cursor: "not-a-cursor"}, + "other view": {Scope: agentA, View: ViewClusters, Cursor: page.Next}, + "other filter": {Scope: agentA, Cluster: &ClusterRef{Kind: ClusterGroup, ID: "g"}, Cursor: page.Next}, + } { + if _, err := s.Answer(context.Background(), req); err == nil { + t.Fatalf("%s: cursor accepted", name) + } + } + s2 := mustBuild(t, Input{Scope: other, Facts: nil, Revision: "r", Owners: 1}) + if _, err := s2.Answer(context.Background(), GraphRequest{Scope: other, Cursor: page.Next}); !errors.Is(err, ErrBadRequest) { + t.Fatalf("cursor for another scope: %v", err) + } +} + +func TestIfRevisionAnswersUnchangedWithoutAPage(t *testing.T) { + s := mustBuild(t, input(agentA, task("a", 1))) + res := answer(t, s, GraphRequest{Scope: agentA, IfRevision: "r1"}) + if !res.Unchanged || len(res.Nodes) != 0 || res.Revision != "r1" { + t.Fatalf("res = %+v", res) + } + if res = answer(t, s, GraphRequest{Scope: agentA, IfRevision: "stale"}); res.Unchanged || len(res.Nodes) != 1 { + t.Fatalf("stale revision must answer in full: %+v", res) + } +} + +func TestCancellationStopsBuildAndAnswer(t *testing.T) { + var facts []Fact + for n := 1; n <= 5000; n++ { + facts = append(facts, task("a", n)) + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if _, err := Build(ctx, input(agentA, facts...)); !errors.Is(err, context.Canceled) { + t.Fatalf("Build err = %v", err) + } + s := mustBuild(t, input(agentA, facts...)) + if _, err := s.Answer(ctx, GraphRequest{Scope: agentA, Limit: 500}); !errors.Is(err, context.Canceled) { + t.Fatalf("Answer err = %v", err) + } +} + +func TestAnswersAreCopiesAndNeverAliasTheSnapshot(t *testing.T) { + in := input(agentA, + task("a", 1, files("x.go"), spend(TaskSpend{USD: 1, Basis: "priced"}), landing("merged"), checks(1, 0)), + task("a", 2, waits("a", "1")), + ) + in.Facts[0].Node.Instructions = &GraphText{Text: "do it"} + s := mustBuild(t, in) + first := answer(t, s, GraphRequest{Scope: agentA}) + first.Nodes[0].Title = "mutated" + first.Nodes[0].Files.Paths[0] = "mutated" + first.Nodes[0].Spend.USD = 99 + first.Nodes[0].Landing.Outcome = "mutated" + first.Nodes[0].Instructions.Text = "mutated" + first.Nodes[0].Clock.AdmittedAt = minute(999) + first.Edges[0].To = "mutated" + first.Totals.ByState["queued"] = 99 + // The input the producer still holds is no alias either. + in.Facts[0].Node.Title = "producer-mutated" + second := answer(t, s, GraphRequest{Scope: agentA}) + n := second.Nodes[0] + if n.Title != "task 1" || n.Files.Paths[0] != "x.go" || n.Spend.USD != 1 || n.Landing.Outcome != "merged" || n.Instructions.Text != "do it" || !n.Clock.AdmittedAt.Equal(*minute(1)) { + t.Fatalf("snapshot was reached through an answer: %+v", n) + } + if second.Edges[0].To != "a:1" || second.Totals.ByState["queued"] == 99 { + t.Fatalf("edges or totals aliased: %+v %+v", second.Edges, second.Totals) + } +} + +func TestBuildIsDeterministicWhateverOrderTheFactsArriveIn(t *testing.T) { + var facts []Fact + for n := 1; n <= 40; n++ { + facts = append(facts, task("a", n, waits("a", fmt.Sprint(max(n-1, 1)))), task("b", n)) + } + reversed := make([]Fact, len(facts)) + for i, f := range facts { + reversed[len(facts)-1-i] = f + } + scope := GraphScope{Kind: ScopeCrew, ID: "c"} + a := answer(t, mustBuild(t, input(scope, facts...)), GraphRequest{Scope: scope, Limit: 500}) + b := answer(t, mustBuild(t, input(scope, reversed...)), GraphRequest{Scope: scope, Limit: 500}) + for i := range a.Nodes { + if a.Nodes[i].ID != b.Nodes[i].ID { + t.Fatalf("order differs at %d: %s vs %s", i, a.Nodes[i].ID, b.Nodes[i].ID) + } + } +} + +func TestPlanRowsKeepTheirOwnIdentityAndOrderDeterministically(t *testing.T) { + plan := func(id string, opts ...opt) Fact { + f := task("a", 1, opts...) + f.Node.Ref.Task, f.Node.ID, f.Node.Kind, f.Node.Seq = id, "a:"+id, "plan", 0 + f.Node.Clock.AdmittedAt = minute(5) + return f + } + in := input(agentA, task("a", 1), plan("t-bb", parent("a:1")), plan("t-aa", parent("a:1"), waits("a", "t-bb"))) + res := answer(t, mustBuild(t, in), GraphRequest{Scope: agentA}) + if len(res.Nodes) != 3 || res.Nodes[1].Ref.Task != "t-aa" || res.Nodes[2].Ref.Task != "t-bb" { + t.Fatalf("nodes = %+v", res.Nodes) + } + if e := edgeSet(res)["a:t-aa>a:t-bb"]; e.Target != TargetNode { + t.Fatalf("plan edge = %+v", e) + } + page := answer(t, mustBuild(t, in), GraphRequest{Scope: agentA, Limit: 2}) + rest := answer(t, mustBuild(t, in), GraphRequest{Scope: agentA, Limit: 2, Cursor: page.Next}) + if len(rest.Nodes) != 1 || rest.Nodes[0].Ref.Task != "t-bb" { + t.Fatalf("second page = %+v", rest.Nodes) + } +} diff --git a/internal/workgraph/cache.go b/internal/workgraph/cache.go new file mode 100644 index 0000000000..9227033e2d --- /dev/null +++ b/internal/workgraph/cache.go @@ -0,0 +1,164 @@ +package workgraph + +import ( + "container/list" + "context" + "sync" + "sync/atomic" + "time" +) + +// BuildTimeout bounds one snapshot build, whoever is waiting for it. +const BuildTimeout = 10 * time.Second + +// Cache keeps built snapshots keyed by (scope, revision). THE REVISION IS THE +// PRODUCER'S CLAIM ABOUT ITS SOURCES — stat stamps of the files a snapshot reads, +// computed before any of them is read — so an unchanged source is one map +// lookup, and a streaming token that touches no task source never rebuilds a +// graph. Three bounds hold it: the entry count, the total nodes held, and one +// build in flight per key. +// +// IO happens only inside the build function, which runs with no lock of this +// cache held, so a slow disk never blocks a lookup of another scope. +type Cache struct { + mu sync.Mutex + maxEntries int + maxNodes int + nodes int + lru *list.List + byKey map[string]*list.Element + flights map[string]*flight + + builds atomic.Int64 + hits atomic.Int64 +} + +type entry struct { + key string + scope string + snap *Snapshot +} + +type flight struct { + done chan struct{} + cancel context.CancelFunc + waiters int + snap *Snapshot + err error +} + +// NewCache is a cache of at most maxEntries snapshots holding at most maxNodes +// nodes between them. A single snapshot larger than maxNodes is still served, +// and is the only one kept. +func NewCache(maxEntries, maxNodes int) *Cache { + return &Cache{ + maxEntries: max(maxEntries, 1), maxNodes: max(maxNodes, 1), + lru: list.New(), byKey: map[string]*list.Element{}, flights: map[string]*flight{}, + } +} + +// Stats is how many snapshots were built and how many lookups were served from +// memory, for tests and for the app's own diagnostics. +func (c *Cache) Stats() (builds, hits int64) { return c.builds.Load(), c.hits.Load() } + +// Builder makes the [Input] for one scope. It runs without any cache lock and +// must honour ctx. +type Builder func(ctx context.Context) (Input, error) + +// Get is the snapshot for scope at revision: the cached one, the one another +// caller is building right now, or a fresh one built by build. A caller whose +// own ctx ends stops waiting; the build is abandoned only when nobody waits. +func (c *Cache) Get(ctx context.Context, scope GraphScope, revision string, build Builder) (*Snapshot, error) { + key := scopeKey(scope) + "\x00" + revision + c.mu.Lock() + if el, ok := c.byKey[key]; ok { + c.lru.MoveToFront(el) + snap := el.Value.(*entry).snap + c.mu.Unlock() + c.hits.Add(1) + return snap, nil + } + f, ok := c.flights[key] + if !ok { + f = c.start(ctx, key, scope, revision, build) + } + f.waiters++ + c.mu.Unlock() + return c.wait(ctx, key, f) +} + +// start begins a build in its own goroutine. The build's context is NOT the +// first caller's: a caller that goes away must not fail the callers that joined +// it. Called with c.mu held. +func (c *Cache) start(ctx context.Context, key string, scope GraphScope, revision string, build Builder) *flight { + bctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), BuildTimeout) + f := &flight{done: make(chan struct{}), cancel: cancel} + c.flights[key] = f + go func() { + defer cancel() + in, err := build(bctx) + var snap *Snapshot + if err == nil { + in.Scope, in.Revision = scope, revision + snap, err = Build(bctx, in) + } + c.builds.Add(1) + c.mu.Lock() + f.snap, f.err = snap, err + delete(c.flights, key) + if err == nil { + c.store(key, scopeKey(scope), snap) + } + c.mu.Unlock() + close(f.done) + }() + return f +} + +// wait blocks for a flight or for the caller's own ctx, and when the last +// waiter leaves a build nobody wants any more, cancels it. +func (c *Cache) wait(ctx context.Context, key string, f *flight) (*Snapshot, error) { + select { + case <-f.done: + return f.snap, f.err + case <-ctx.Done(): + c.mu.Lock() + f.waiters-- + if f.waiters == 0 && c.flights[key] == f { + f.cancel() + } + c.mu.Unlock() + return nil, ctx.Err() + } +} + +// store keeps a snapshot, first dropping what it replaces (an older revision of +// the same scope) and then the least recently used until the bounds hold. +// Called with c.mu held. +func (c *Cache) store(key, scope string, snap *Snapshot) { + for el := c.lru.Back(); el != nil; { + prev := el.Prev() + if e := el.Value.(*entry); e.scope == scope { + c.drop(el) + } + el = prev + } + c.byKey[key] = c.lru.PushFront(&entry{key: key, scope: scope, snap: snap}) + c.nodes += snap.Len() + for c.lru.Len() > 1 && (c.lru.Len() > c.maxEntries || c.nodes > c.maxNodes) { + c.drop(c.lru.Back()) + } +} + +func (c *Cache) drop(el *list.Element) { + e := c.lru.Remove(el).(*entry) + delete(c.byKey, e.key) + c.nodes -= e.snap.Len() +} + +// Held is how many snapshots and nodes the cache holds right now. +func (c *Cache) Held() (entries, nodes int) { + c.mu.Lock() + defer c.mu.Unlock() + return c.lru.Len(), c.nodes +} diff --git a/internal/workgraph/complexity_test.go b/internal/workgraph/complexity_test.go new file mode 100644 index 0000000000..841c3758c1 --- /dev/null +++ b/internal/workgraph/complexity_test.go @@ -0,0 +1,62 @@ +package workgraph + +import ( + "go/ast" + "go/parser" + "go/token" + "path/filepath" + "testing" +) + +// maxComplexity is the repository's strict ceiling on a function's cyclomatic +// complexity, counted the way gocyclo counts it. +const maxComplexity = 15 + +// complexity is 1 plus every branch point in a function: if, for, range, a case +// or comm clause that is not the default, and each && or ||. +func complexity(fn *ast.FuncDecl) int { + n := 1 + ast.Inspect(fn, func(node ast.Node) bool { + switch x := node.(type) { + case *ast.IfStmt, *ast.ForStmt, *ast.RangeStmt: + n++ + case *ast.CaseClause: + if x.List != nil { + n++ + } + case *ast.CommClause: + if x.Comm != nil { + n++ + } + case *ast.BinaryExpr: + if x.Op == token.LAND || x.Op == token.LOR { + n++ + } + } + return true + }) + return n +} + +// TestNoFunctionPassesTheComplexityCeiling holds this package to the ceiling +// without a waiver: a function that grows past it is split, not excused. +func TestNoFunctionPassesTheComplexityCeiling(t *testing.T) { + files, err := filepath.Glob("*.go") + if err != nil || len(files) == 0 { + t.Fatalf("no sources: %v", err) + } + fset := token.NewFileSet() + for _, name := range files { + file, err := parser.ParseFile(fset, name, nil, 0) + if err != nil { + t.Fatal(err) + } + for _, decl := range file.Decls { + if fn, ok := decl.(*ast.FuncDecl); ok && fn.Body != nil { + if c := complexity(fn); c > maxComplexity { + t.Errorf("%s: %s has complexity %d, the ceiling is %d", name, fn.Name.Name, c, maxComplexity) + } + } + } + } +} diff --git a/internal/workgraph/crews.go b/internal/workgraph/crews.go new file mode 100644 index 0000000000..00cda238b2 --- /dev/null +++ b/internal/workgraph/crews.go @@ -0,0 +1,272 @@ +package workgraph + +import "sort" + +// maxAlsoIn is how many other in-scope crews one task's place names. +const maxAlsoIn = 8 + +// crewPaths is every crew's ancestry inside the scope, outermost first and the +// crew itself last. A parent the scope does not hold ends the climb there, and +// a parent chain that loops is cut where it would repeat, so a bad record +// shortens a path and never runs away. +func crewPaths(crews []CrewInput) map[string][]string { + known := make(map[string]CrewInput, len(crews)) + for _, c := range crews { + if _, dup := known[c.ID]; !dup { + known[c.ID] = c + } + } + paths := make(map[string][]string, len(known)) + for id := range known { + paths[id] = climb(id, known) + } + return paths +} + +func climb(id string, known map[string]CrewInput) []string { + seen := map[string]bool{} + var up []string + for cur := id; cur != "" && !seen[cur]; { + c, ok := known[cur] + if !ok { + break + } + seen[cur] = true + up = append(up, cur) + cur = c.Parent + } + for i, j := 0, len(up)-1; i < j; i, j = i+1, j-1 { + up[i], up[j] = up[j], up[i] + } + return up +} + +// place is where one conversation sits among the scope's crews. +type place struct { + crew string + also []string + member MemberInput +} + +// placeCrews gives every kept task its owner's place in the scope's crews: the +// membership that names the owner's manager if there is one inside the scope, +// otherwise the deepest crew, ties by crew id; the other memberships are +// AlsoIn. +func (b *builder) placeCrews() error { + if len(b.in.Crews) == 0 { + return nil + } + paths := crewPaths(b.in.Crews) + places := b.choosePlaces(paths) + for i := range b.snap.nodes { + if err := b.tick(i); err != nil { + return err + } + p, ok := places[b.snap.nodes[i].Ref.Owner] + if !ok { + continue + } + b.snap.nodes[i].Crew = &CrewPlace{Crew: p.crew, Path: append([]string(nil), paths[p.crew]...), AlsoIn: p.also} + } + b.snap.crews = b.crewFacts(paths) + return nil +} + +func (b *builder) choosePlaces(paths map[string][]string) map[string]place { + byAgent := map[string][]MemberInput{} + for _, m := range b.in.Members { + if _, ok := paths[m.Crew]; ok { + byAgent[m.Agent] = append(byAgent[m.Agent], m) + } + } + places := make(map[string]place, len(byAgent)) + for agent, list := range byAgent { + sort.Slice(list, func(i, j int) bool { return better(list[i], list[j], paths) }) + p := place{crew: list[0].Crew, member: list[0]} + for _, m := range list[1:] { + if m.Crew != p.crew && len(p.also) < maxAlsoIn { + p.also = append(p.also, m.Crew) + } + } + places[agent] = p + } + return places +} + +// better orders two memberships of one conversation: the home one first, then +// the deeper crew, then the lower crew id. +func better(a, b MemberInput, paths map[string][]string) bool { + if a.Home != b.Home { + return a.Home + } + if da, db := len(paths[a.Crew]), len(paths[b.Crew]); da != db { + return da > db + } + return a.Crew < b.Crew +} + +// crewFacts is the scope's crews with their direct member counts, outermost +// first. +func (b *builder) crewFacts(paths map[string][]string) []CrewFact { + direct := map[string]int{} + seen := map[[2]string]bool{} + for _, m := range b.in.Members { + key := [2]string{m.Crew, m.Agent} + if _, ok := paths[m.Crew]; ok && !seen[key] { + seen[key] = true + direct[m.Crew]++ + } + } + var out []CrewFact + done := map[string]bool{} + for _, c := range b.in.Crews { + if done[c.ID] { + continue + } + done[c.ID] = true + f := c.CrewFact + f.Members = direct[c.ID] + out = append(out, f) + } + sort.SliceStable(out, func(i, j int) bool { + if di, dj := len(paths[out[i].ID]), len(paths[out[j].ID]); di != dj { + return di < dj + } + return out[i].ID < out[j].ID + }) + return out +} + +func clusterKey(kind, id string) string { return kind + "\x00" + id } + +// makeClusters groups the kept tasks by sub-crew and by task group and keeps, +// for each cluster, its members' indexes in creation order, so a cluster's +// members are a page away rather than a scan. +func (b *builder) makeClusters() error { + s := b.snap + s.members = map[string][]int32{} + index := map[string]*GraphCluster{} + for _, c := range s.crews { + g := &GraphCluster{Kind: ClusterCrew, ID: c.ID, Title: c.Name, Parent: c.Parent, ByState: map[string]int{}} + index[clusterKey(ClusterCrew, c.ID)] = g + } + for i := range s.nodes { + if err := b.tick(i); err != nil { + return err + } + n := &s.nodes[i] + if n.Crew != nil { + addToCrews(s, index, n, int32(i)) + } + if n.Group != nil { + g := groupCluster(index, n.Group) + count(g, n.State, true) + s.members[clusterKey(ClusterGroup, g.ID)] = append(s.members[clusterKey(ClusterGroup, g.ID)], int32(i)) + } + } + s.clusters = collect(index) + return nil +} + +func addToCrews(s *Snapshot, index map[string]*GraphCluster, n *GraphNode, at int32) { + for depth, id := range n.Crew.Path { + g := index[clusterKey(ClusterCrew, id)] + if g == nil { + continue + } + count(g, n.State, depth == len(n.Crew.Path)-1) + s.members[clusterKey(ClusterCrew, id)] = append(s.members[clusterKey(ClusterCrew, id)], at) + } +} + +func groupCluster(index map[string]*GraphCluster, ref *GroupRef) *GraphCluster { + key := clusterKey(ClusterGroup, ref.ID) + g := index[key] + if g == nil { + g = &GraphCluster{Kind: ClusterGroup, ID: ref.ID, ByState: map[string]int{}} + index[key] = g + } + if g.Title == "" { + g.Title = ref.Title + } + return g +} + +func count(g *GraphCluster, state string, direct bool) { + g.Total++ + g.ByState[state]++ + if direct { + g.Direct++ + } +} + +func collect(index map[string]*GraphCluster) []GraphCluster { + out := make([]GraphCluster, 0, len(index)) + for _, g := range index { + out = append(out, *g) + } + sort.Slice(out, func(i, j int) bool { + if out[i].Kind != out[j].Kind { + return out[i].Kind < out[j].Kind + } + return out[i].ID < out[j].ID + }) + return out +} + +// makePeople answers each membership with how its owner's work in the scope +// stands. A conversation in two crews appears once per crew, with the same +// counts: the work is the conversation's, not the crew's. +func (b *builder) makePeople() error { + if len(b.in.Members) == 0 { + return nil + } + tasks := map[string]*MemberTasks{} + for i := range b.snap.nodes { + if err := b.tick(i); err != nil { + return err + } + n := &b.snap.nodes[i] + t := tasks[n.Ref.Owner] + if t == nil { + t = &MemberTasks{ByState: map[string]int{}} + tasks[n.Ref.Owner] = t + } + t.Total++ + t.ByState[n.State]++ + } + seen := map[[2]string]bool{} + var people []MemberFact + for _, m := range b.in.Members { + key := [2]string{m.Crew, m.Agent} + if seen[key] { + continue + } + seen[key] = true + f := m.MemberFact + if t := tasks[m.Agent]; t != nil { + f.Tasks = MemberTasks{Total: t.Total, ByState: copyCounts(t.ByState)} + } + people = append(people, f) + } + sort.SliceStable(people, func(i, j int) bool { + if people[i].Crew != people[j].Crew { + return people[i].Crew < people[j].Crew + } + return people[i].Agent < people[j].Agent + }) + b.snap.people = people + b.snap.totals.Members = len(people) + return nil +} + +func copyCounts(in map[string]int) map[string]int { + if in == nil { + return nil + } + out := make(map[string]int, len(in)) + for k, v := range in { + out[k] = v + } + return out +} diff --git a/internal/workgraph/cursor.go b/internal/workgraph/cursor.go new file mode 100644 index 0000000000..61f2be1644 --- /dev/null +++ b/internal/workgraph/cursor.go @@ -0,0 +1,72 @@ +package workgraph + +import ( + "crypto/hmac" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/json" + "strings" +) + +// A CURSOR IS A POSITION, NOT AN OFFSET, AND IT IS SIGNED. It names the creation +// key of the last task a page ended on (or the last cluster), together with the +// scope, the view and the filter it was issued for, so tasks appended between +// two pages cannot shift what the next page holds. It is signed with a key made +// when the process started: a cursor that was edited, or issued by another +// process, or issued for another request, is refused rather than followed to a +// position nobody chose. +var cursorKey = func() []byte { + key := make([]byte, 32) + if _, err := rand.Read(key); err != nil { + panic("workgraph: no randomness for the cursor key") + } + return key +}() + +// cursorBody is what a cursor says. +type cursorBody struct { + Scope string `json:"s"` + View string `json:"v"` + Filter string `json:"f,omitempty"` + At int64 `json:"a,omitempty"` + Owner string `json:"o,omitempty"` + Seq uint64 `json:"q,omitempty"` + Task string `json:"t,omitempty"` + Key string `json:"k,omitempty"` +} + +func mac(payload []byte) []byte { + h := hmac.New(sha256.New, cursorKey) + h.Write(payload) + return h.Sum(nil)[:16] +} + +func encodeCursor(b cursorBody) string { + payload, _ := json.Marshal(b) + enc := base64.RawURLEncoding + return enc.EncodeToString(payload) + "." + enc.EncodeToString(mac(payload)) +} + +// decodeCursor is the position a cursor names, or [ErrBadRequest] when it was +// not issued by this process for this very request. +func decodeCursor(raw string, want cursorBody) (cursorBody, error) { + enc := base64.RawURLEncoding + body, sig, ok := strings.Cut(raw, ".") + if !ok || len(raw) > 1024 { + return cursorBody{}, badf("the cursor is not one this engine issued") + } + payload, err1 := enc.DecodeString(body) + given, err2 := enc.DecodeString(sig) + if err1 != nil || err2 != nil || !hmac.Equal(mac(payload), given) { + return cursorBody{}, badf("the cursor is not one this engine issued") + } + var got cursorBody + if err := json.Unmarshal(payload, &got); err != nil { + return cursorBody{}, badf("the cursor is not one this engine issued") + } + if got.Scope != want.Scope || got.View != want.View || got.Filter != want.Filter { + return cursorBody{}, badf("the cursor was issued for a different request") + } + return got, nil +} diff --git a/internal/workgraph/cycles.go b/internal/workgraph/cycles.go new file mode 100644 index 0000000000..6cae664936 --- /dev/null +++ b/internal/workgraph/cycles.go @@ -0,0 +1,170 @@ +package workgraph + +import ( + "sort" + "time" +) + +func copyTime(t *time.Time) *time.Time { + if t == nil { + return nil + } + c := *t + return &c +} + +// tarjan is the strongly connected components of the waits-on graph, found +// without recursion: a loop of ten thousand tasks must not be ten thousand +// stack frames. It looks at its context as it goes. +type tarjan struct { + b *builder + order []int32 // discovery number + 1, 0 = unseen + low []int32 + on []bool + stack []int32 + comp []int32 // component id, -1 when the task is on no cycle + next int32 + steps int + // cycles are the components that are cycles, as indexes in creation order. + cycles [][]int32 + err error +} + +type callFrame struct { + v int32 + ei int32 +} + +func (b *builder) findCycles() error { + n := len(b.snap.nodes) + t := &tarjan{b: b, order: make([]int32, n), low: make([]int32, n), on: make([]bool, n), comp: make([]int32, n)} + for i := range t.comp { + t.comp[i] = -1 + } + for v := 0; v < n && t.err == nil; v++ { + if t.order[v] == 0 { + t.run(int32(v)) + } + } + if t.err != nil { + return t.err + } + b.markCycles(t) + return nil +} + +// run walks everything reachable from root with an explicit call stack. +func (t *tarjan) run(root int32) { + s := t.b.snap + calls := []callFrame{{v: root, ei: s.edgeStart[root]}} + t.discover(root) + for len(calls) > 0 { + if t.steps++; t.steps%tickEvery == 0 { + if t.err = t.b.ctx.Err(); t.err != nil { + return + } + } + top := &calls[len(calls)-1] + if top.ei >= s.edgeStart[top.v+1] { + calls = calls[:len(calls)-1] + t.finish(top.v, calls) + continue + } + to := s.edgeTo[top.ei] + top.ei++ + if to < 0 { + continue + } + if t.order[to] == 0 { + t.discover(to) + calls = append(calls, callFrame{v: to, ei: s.edgeStart[to]}) + } else if t.on[to] && t.order[to] < t.low[top.v] { + t.low[top.v] = t.order[to] + } + } +} + +func (t *tarjan) discover(v int32) { + t.next++ + t.order[v], t.low[v] = t.next, t.next + t.stack = append(t.stack, v) + t.on[v] = true +} + +// finish closes a task's visit: it is the head of a component when nothing +// below it reached further back, and it lends its low-link to its caller. +func (t *tarjan) finish(v int32, calls []callFrame) { + if t.low[v] == t.order[v] { + t.pop(v) + } + if len(calls) > 0 { + parent := calls[len(calls)-1].v + if t.low[v] < t.low[parent] { + t.low[parent] = t.low[v] + } + } +} + +// pop takes one component off the stack, and keeps it when it is a cycle: more +// than one task, or one task that waits on itself. +func (t *tarjan) pop(head int32) { + var members []int32 + for { + w := t.stack[len(t.stack)-1] + t.stack = t.stack[:len(t.stack)-1] + t.on[w] = false + members = append(members, w) + if w == head { + break + } + } + if len(members) == 1 && !t.selfLoop(head) { + return + } + sort.Slice(members, func(i, j int) bool { return members[i] < members[j] }) + id := int32(len(t.cycles)) + for _, m := range members { + t.comp[m] = id + } + t.cycles = append(t.cycles, members) +} + +func (t *tarjan) selfLoop(v int32) bool { + s := t.b.snap + for e := s.edgeStart[v]; e < s.edgeStart[v+1]; e++ { + if s.edgeTo[e] == v { + return true + } + } + return false +} + +// markCycles flags the tasks and edges on cycles and names the first few. +func (b *builder) markCycles(t *tarjan) { + s := b.snap + for v := range s.nodes { + if t.comp[v] >= 0 { + s.nodes[v].InCycle = true + } + for e := s.edgeStart[v]; e < s.edgeStart[v+1]; e++ { + if to := s.edgeTo[e]; to >= 0 && t.comp[v] >= 0 && t.comp[v] == t.comp[to] { + s.edges[e].InCycle = true + } + } + } + sort.Slice(t.cycles, func(i, j int) bool { return t.cycles[i][0] < t.cycles[j][0] }) + b.diag.CyclesTotal = len(t.cycles) + for _, members := range t.cycles { + if len(b.diag.Cycles) == MaxCycles { + break + } + cycle := Cycle{Total: len(members)} + for _, m := range members { + if len(cycle.Tasks) == MaxCycleNodes { + break + } + cycle.Tasks = append(cycle.Tasks, s.nodes[m].ID) + } + b.diag.Cycles = append(b.diag.Cycles, cycle) + } +} diff --git a/internal/workgraph/input.go b/internal/workgraph/input.go new file mode 100644 index 0000000000..838c84c1ef --- /dev/null +++ b/internal/workgraph/input.go @@ -0,0 +1,156 @@ +package workgraph + +import ( + "sort" + "strings" + "time" +) + +// What a producer hands [Build]: the tasks it knows, the links it stored, the +// crews and members around them, and a way to look at a task outside them. The +// builder trusts none of it — a link to a task nobody listed is classified, not +// believed — but it invents none of it either. + +// Wait is one stored link: the task it belongs to waits on On. Via is +// [ViaRecord] or [ViaExternal]. +type Wait struct { + On TaskRef + Via string +} + +// Fact is one task as a producer states it. Node carries everything the +// producer knows; the builder fills what only the whole graph can say +// (ParentState, Crew, the in/out degrees, InCycle) and overwrites nothing else. +type Fact struct { + Node GraphNode + Waits []Wait +} + +// Standing is whether a target outside the scope exists. +type Standing int + +// The three answers a resolver gives about a task outside the scope. +const ( + // StandingUnknown: the target's owner could not be read, so nothing is said + // about the task — it is not called missing. + StandingUnknown Standing = iota + // StandingFound: the task exists; State is its state word. + StandingFound + // StandingAbsent: the owner was read and has no such task. + StandingAbsent +) + +// Resolution is a resolver's answer for one target. +type Resolution struct { + Standing Standing + State string +} + +// CrewInput is one crew a crew scope is made of; the first is the scope's own. +type CrewInput struct { + CrewFact +} + +// MemberInput is one membership: a conversation in a crew. Home marks the +// membership that names the manager the conversation reports to. +type MemberInput struct { + MemberFact + Home bool +} + +// Input is everything one snapshot is built from. +type Input struct { + Scope GraphScope + About ScopeFacts + // Root is the task a task scope is about; the snapshot keeps its sub-tasks. + Root string + // Facts are the tasks, in any order. + Facts []Fact + Crews []CrewInput + Members []MemberInput + // Resolve answers about a target the facts do not list. Nil means every such + // target is unknown. + Resolve func(TaskRef) Resolution + // Revision names the source facts; Owners, Unread, Truncated and Excluded + // are the producer's own account of how much it read. + Revision string + AsOf time.Time + Owners int + Unread []OwnerIssue + Truncated bool + Excluded []string +} + +// orderKey is where a task sits in creation order. +type orderKey struct { + at int64 + owner string + seq uint64 + // task breaks the last tie: plan rows carry no number, and two of them can + // share an instant. + task string +} + +func keyOf(n *GraphNode) orderKey { + k := orderKey{owner: n.Ref.Owner, seq: n.Seq, task: n.Ref.Task} + if n.Clock.AdmittedAt != nil { + k.at = n.Clock.AdmittedAt.UnixNano() + } + return k +} + +func (a orderKey) less(b orderKey) bool { + if a.at != b.at { + return a.at < b.at + } + if a.owner != b.owner { + return a.owner < b.owner + } + if a.seq != b.seq { + return a.seq < b.seq + } + return a.task < b.task +} + +func sortFacts(facts []Fact) { + sort.SliceStable(facts, func(i, j int) bool { + return keyOf(&facts[i].Node).less(keyOf(&facts[j].Node)) + }) +} + +// Cut shortens s to at most limit runes and says whether it did. It never +// splits a rune, and it is ALLOCATION-BOUNDED: it scans at most limit+1 rune +// boundaries (never the whole of an oversized input), copies only the bytes it +// keeps, and returns a string that owns them. A tiny result therefore never +// pins the huge string it was cut from. Bytes that are not valid UTF-8 are +// replaced by U+FFFD, the way the wire would spell them, so what is returned +// is always valid text. +func Cut(s string, limit int) (string, bool) { + if limit <= 0 { + return "", s != "" + } + count := 0 + end, cut := len(s), false + for i := range s { + if count == limit { + end, cut = i, true + break + } + count++ + } + kept := strings.ToValidUTF8(s[:end], "\uFFFD") + return strings.Clone(kept), cut +} + +// stamp is a recorded instant, or nil for the zero time. +func stamp(t time.Time) *time.Time { + if t.IsZero() { + return nil + } + u := t.UTC() + return &u +} + +// Stamp is the pointer a [Clock] field holds for an instant the record carries, +// and nil for one it does not. +func Stamp(t time.Time) *time.Time { return stamp(t) } diff --git a/internal/workgraph/metrics.go b/internal/workgraph/metrics.go new file mode 100644 index 0000000000..c7a476068f --- /dev/null +++ b/internal/workgraph/metrics.go @@ -0,0 +1,164 @@ +package workgraph + +import "time" + +// The merge outcomes that mean the work came home. +func landed(l *Landing) bool { + return l != nil && (l.Outcome == "merged" || l.Outcome == "inplace") +} + +func terminal(state string) bool { + return state == StateDone || state == StateFailed || state == StateUnverified +} + +// tally accumulates one figure. +type tally struct { + value float64 + counted int + of int +} + +func (t *tally) add(of bool, counted bool, v float64) { + if of { + t.of++ + } + if counted { + t.counted++ + t.value += v + } +} + +// figure turns a tally into a [GraphFigure]. KNOWN FOLLOWS SOURCE COVERAGE, NOT +// MERELY WHETHER AN OBSERVATION EXISTS: a figure over an empty population is a +// known zero only when the scope was read whole, and a population whose members +// all lack the fact (of > 0, counted == 0) is unknown even when the scope was +// read whole, because no source established the number. +func (t tally) figure(complete bool) GraphFigure { + known := t.counted > 0 || (t.of == 0 && complete) + return GraphFigure{Value: t.value, Known: known, Partial: t.counted < t.of || !complete, Counted: t.counted, Of: t.of} +} + +// exact is a figure that is a plain count of what the scope holds: known even +// at zero when the scope was read whole, partial when it was not. +func exact(n int, complete bool) GraphFigure { + return GraphFigure{Value: float64(n), Known: n > 0 || complete, Partial: !complete, Counted: n, Of: n} +} + +// measure computes the scope's figures in one pass over the kept tasks. +func (b *builder) measure() error { + s := b.snap + complete := len(b.in.Unread) == 0 && !b.in.Truncated + var landedTasks, landedFiles, checks, spend tally + var subtasks, tasks int + var filesCut bool + files := map[string]bool{} + var from, to time.Time + for i := range s.nodes { + if err := b.tick(i); err != nil { + return err + } + n := &s.nodes[i] + // A quick lookup is not one of the tasks the app lists; it is a node only so + // that a link to it resolves, so it is not counted as work. + if n.Kind != KindQuick { + tasks++ + if n.Parent != "" { + subtasks++ + } + } + if n.Kind != KindQuick { + measureLanding(n, &landedTasks, &landedFiles, files, &filesCut) + measureChecks(n, &checks) + } + measureSpend(n, &spend, &s.metrics.Unpriced) + from, to = span(from, to, n.Clock) + } + s.metrics.Tasks = exact(tasks, complete) + s.metrics.Subtasks = exact(subtasks, complete) + s.metrics.LandedTasks = landedTasks.figure(complete) + landedFiles.value = float64(len(files)) + s.metrics.LandedFiles = landedFiles.figure(complete) + // A path list the producer cut at its own bound still gives the distinct + // paths it has, and the figure says it is not the whole of them. + s.metrics.LandedFiles.Partial = s.metrics.LandedFiles.Partial || filesCut + s.metrics.ChecksPassed = checks.figure(complete) + s.metrics.Spend = spend.figure(complete) + // A BILL IS NEVER A KNOWN ZERO FOR WANT OF BILLS: with no task carrying a + // priced bill there is nothing that establishes the amount, even when the + // scope was read whole, so the figure stays unknown rather than $0.00. + s.metrics.Spend.Known = spend.counted > 0 + s.metrics.From, s.metrics.To = stamp(from), stamp(to) + if len(b.in.Members) > 0 { + m := exact(distinctMembers(b.in.Members), complete) + s.metrics.Members = &m + } + return nil +} + +// measureLanding: of the done tasks, the ones that carry a landing outcome are +// counted, and of those the ones that landed add to the value; the landed +// tasks' paths are collected for the distinct count. +func measureLanding(n *GraphNode, tasks, filesTally *tally, files map[string]bool, cut *bool) { + if n.State == StateDone { + hasOutcome := n.Landing != nil + v := 0.0 + if landed(n.Landing) { + v = 1 + } + tasks.add(true, hasOutcome, v) + } + if !landed(n.Landing) || n.State != StateDone { + return + } + filesTally.add(true, n.Files.Total > 0, 0) + if n.Files.Total > len(n.Files.Paths) { + *cut = true + } + for _, p := range n.Files.Paths { + files[p] = true + } +} + +func measureChecks(n *GraphNode, checks *tally) { + if !terminal(n.State) { + return + } + passed := 0.0 + if n.Checks != nil { + passed = float64(n.Checks.Passed) + } + checks.add(true, n.Checks != nil, passed) +} + +func measureSpend(n *GraphNode, spend *tally, unpriced *int) { + priced := n.Spend != nil && n.Spend.Basis == "priced" + usd := 0.0 + if priced { + usd = n.Spend.USD + } + spend.add(true, priced, usd) + if n.Spend != nil && n.Spend.Basis == "unpriced" { + *unpriced++ + } +} + +// span widens the recorded time bounds by one task's clock. +func span(from, to time.Time, c Clock) (time.Time, time.Time) { + for _, t := range []*time.Time{c.AdmittedAt, c.StartedAt} { + if t != nil && (from.IsZero() || t.Before(from)) { + from = *t + } + } + if c.EndedAt != nil && c.EndedAt.After(to) { + to = *c.EndedAt + } + return from, to +} + +func distinctMembers(in []MemberInput) int { + seen := map[string]bool{} + for _, m := range in { + seen[m.Agent] = true + } + return len(seen) +} diff --git a/internal/workgraph/types.go b/internal/workgraph/types.go index 3d937fc0b9..c7619039a2 100644 --- a/internal/workgraph/types.go +++ b/internal/workgraph/types.go @@ -35,8 +35,10 @@ package workgraph import ( + "strconv" "strings" "time" + "unicode/utf8" ) // Contract is the version of this vocabulary. It moves only when a field @@ -100,22 +102,82 @@ type GraphScope struct { // so the pair — never the number alone — names a task. type TaskRef struct { // Owner is the owning conversation's id (its folder's name, stable across - // launches). Task is that conversation's own number for the task, decimal. + // launches). Task is that conversation's own id for the task: a canonical + // decimal number for a task or run row the conversation minted, or the plan + // store's own "t-" token for a plan row (see [ValidPlanToken]). Owner string `json:"owner"` Task string `json:"task"` } // ID is the task's id on every wire that names a task: ":". It is -// collision-safe across owners because Task is decimal and has no colon. +// collision-safe across owners because neither part may hold a colon. func (r TaskRef) ID() string { return r.Owner + ":" + r.Task } -// Valid reports whether the reference names something: an owner and a decimal -// number without leading zeros or a sign. -func (r TaskRef) Valid() bool { - if r.Owner == "" || r.Task == "" || r.Task == "0" || r.Task[0] == '0' { +// The bounds a reference's parts live within. They are the engine's own limits +// on an identity, enforced before any path is built from it. +const ( + // MaxOwnerBytes bounds an owner id. A conversation's folder name is a uuid or + // a short slug today; 128 bytes leaves room without letting an id become a + // payload. + MaxOwnerBytes = 128 + // MaxPlanTokenBytes bounds a plan-store task token ("t-" and the store's id). + MaxPlanTokenBytes = 64 +) + +// ValidOwner reports whether s is a safe owner id: ONE path component, bounded, +// printable, with no separator, colon, NUL or control character, and neither "." +// nor ".." nor a dotfile. It is the same law the app backend applies before it +// joins a conversation id to a folder (a bare name, never a path), so a +// valid-looking reference can never walk out of the places root. +func ValidOwner(s string) bool { + if s == "" || len(s) > MaxOwnerBytes || s[0] == '.' || !utf8.ValidString(s) { + return false + } + for _, r := range s { + if r < 0x20 || r == 0x7f || r == '/' || r == '\\' || r == ':' || r == utf8.RuneError { + return false + } + } + return true +} + +// ValidNumber reports whether s is a canonical positive uint64: ASCII digits, +// no sign, no leading zero, at most 20 digits and no larger than 2^64-1. +func ValidNumber(s string) bool { + if s == "" || len(s) > 20 || s[0] == '0' { return false } - return strings.Trim(r.Task, "0123456789") == "" + for i := 0; i < len(s); i++ { + if s[i] < '0' || s[i] > '9' { + return false + } + } + _, err := strconv.ParseUint(s, 10, 64) + return err == nil +} + +// ValidPlanToken reports whether s is a plan-store task id as the engine spells +// it ("t-" followed by the store's own id of letters, digits, dot, dash and +// underscore). Plan rows are real tasks the app lists (AppTasks) whose identity +// is the store's, not a number the conversation minted; they keep it. +func ValidPlanToken(s string) bool { + if len(s) < 3 || len(s) > MaxPlanTokenBytes || s[0] != 't' || s[1] != '-' { + return false + } + for i := 2; i < len(s); i++ { + c := s[i] + ok := c >= 'a' && c <= 'z' || c >= 'A' && c <= 'Z' || c >= '0' && c <= '9' || c == '-' || c == '_' || c == '.' + if !ok { + return false + } + } + return true +} + +// Valid reports whether the reference names something safe to look up: a valid +// owner and a task that is either a canonical number or a plan token. +func (r TaskRef) Valid() bool { + return ValidOwner(r.Owner) && (ValidNumber(r.Task) || ValidPlanToken(r.Task)) } // ParseID is the reference an id names, and false for a string that is not @@ -205,8 +267,13 @@ type GraphResult struct { // it back as IfRevision; the events that say something moved are the ones // the wire already has (task.changed, member.changed, crew events), so no // caller polls. - Revision string `json:"revision"` - Unchanged bool `json:"unchanged,omitempty"` + Revision string `json:"revision"` + // AsOf is when the engine last read the sources this answer is built from — + // the build time of the cached snapshot, NOT the time of this request, and not + // part of the revision. A banner may say "as of 14:32"; it must not claim the + // content is newer than that. + AsOf *time.Time `json:"as_of,omitempty"` + Unchanged bool `json:"unchanged,omitempty"` Scope GraphScope `json:"scope"` About ScopeFacts `json:"about"` @@ -310,6 +377,8 @@ type ClockCoverage struct { } // The engine's task state words, as persisted. +const KindQuick = "quick" + const ( StateQueued = "queued" StateRunning = "running" @@ -349,9 +418,13 @@ type GraphNode struct { ID string `json:"id"` Ref TaskRef `json:"ref"` Seq uint64 `json:"seq"` - // Kind is the engine's kind word: "task" for ordinary work, "quick", - // "run" for a handed-over task's row, and the producer's own words for the - // rest (job, harness, subharness). + // Kind is the engine's kind word: "task" for ordinary work, "quick" (a + // one-shot lookup the app does not list as a task: kept only so a link to it + // resolves, and not counted in the metrics), "run" for a handed-over task's + // row, "plan" for a row of a run's own plan store (a real sub-task the app + // lists, identified by the store's "t-"), "index" for a task the project's + // index remembers that the conversation's checkpoint no longer holds, and the + // producer's own words for the rest (harness, subharness). Kind string `json:"kind"` Title string `json:"title"` // State is one of the engine state words (queued, running, done, failed, From c82a3d843876caaab0aec2020c8a4f7eb3916ab8 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 22:59:13 -0400 Subject: [PATCH 123/459] session: wait on another conversation's task, persisted and enforced outside every lock A queued task can now be told to wait on a task of another conversation (Agent.WaitOnTask). The link is stored in the task's record (waits_external), survives a reopen, and the frontier enforces it from an in-memory observation refreshed by one bounded read on its own goroutine, never by calling the reader under the graph's lock. Records also carry the admission instant and the instant of the present state, stamped where the transition happens. Co-Authored-By: Claude Sonnet 5.5 --- internal/session/assignment.go | 2 +- internal/session/session.go | 6 + internal/session/task_continue.go | 4 +- internal/session/task_external.go | 484 +++++++++++++++++++++++++ internal/session/task_external_test.go | 296 +++++++++++++++ internal/session/task_run.go | 60 ++- internal/session/task_store.go | 35 +- 7 files changed, 863 insertions(+), 24 deletions(-) create mode 100644 internal/session/task_external.go create mode 100644 internal/session/task_external_test.go diff --git a/internal/session/assignment.go b/internal/session/assignment.go index 151183e3e0..bc4e88757e 100644 --- a/internal/session/assignment.go +++ b/internal/session/assignment.go @@ -1164,7 +1164,7 @@ func (g *TaskGraph) runAgainForDirections(node *TaskNode) bool { node.finding = composeDirectedFinding(node.deliveredLocked(), waiting) node.publishing = false node.continuing = true - node.state = TaskQueued + node.setStateLocked(TaskQueued) node.claimed = false node.stopped = false node.ending = "" diff --git a/internal/session/session.go b/internal/session/session.go index f4b76ea5b2..88b444e056 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -2121,6 +2121,12 @@ type Config struct { // conversation, and for every scripted graph in the tests. TaskLanes *TaskLanes + // ExternalTasks reads one task of ANOTHER conversation, for the waits + // [Agent.WaitOnTask] records (task_external.go). It may do IO; the engine + // never calls it under a lock. Left nil the capability is absent: no + // cross-conversation wait can be recorded, and none is ever enforced. + ExternalTasks ExternalTasks + // InTask marks this agent as ONE TASK NODE'S RUNNER (task_run.go) rather // than the conversation. It changes exactly two things, and both are // consequences of the same fact — there is nobody to talk to: diff --git a/internal/session/task_continue.go b/internal/session/task_continue.go index 61c42d1915..41a872ae0f 100644 --- a/internal/session/task_continue.go +++ b/internal/session/task_continue.go @@ -142,7 +142,7 @@ func (g *TaskGraph) reopenAttempt(node *TaskNode, words string, failedOnly bool) node.offer = nil node.revise = nil } - node.state = TaskQueued + node.setStateLocked(TaskQueued) node.claimed = false node.stopped = false node.ending = "" @@ -444,7 +444,7 @@ func (g *TaskGraph) runAgainFromItsBranch(node *TaskNode) bool { node.offer = nil node.revise = nil } - node.state = TaskQueued + node.setStateLocked(TaskQueued) node.claimed = false node.stopped = false node.ending = "" diff --git a/internal/session/task_external.go b/internal/session/task_external.go new file mode 100644 index 0000000000..fdeb6b2f03 --- /dev/null +++ b/internal/session/task_external.go @@ -0,0 +1,484 @@ +package session + +// WAITING ON A TASK OF ANOTHER CONVERSATION. +// +// The task graph's frontier has always honoured `depends_on`, but only between +// the tasks of ONE conversation: a node waits, queued, until every id it names +// is done, and fails with the cascade when one fails. A graph that spans +// conversations (a crew whose members hand work to each other) had no such +// command, so nothing could be said to wait on another member's task except in +// prose. This file adds the command, at the seam the frontier already is: +// +// - [Agent.WaitOnTask] records, on a QUEUED node, that it waits on one task of +// another conversation. The link is persisted in the node's own record +// (taskRecord.WaitsExternal), so it survives a reopen. +// - [TaskGraph.readinessLocked] enforces it exactly as it enforces depends_on: +// the prerequisite done lets the node start, failed fails the dependent with +// a plain reason, anything else (running, queued, unverified, unreadable) +// keeps it waiting. A node never starts on a guess. +// +// ── NO IO UNDER A LOCK, EVER ── +// +// Another conversation's tasks are read through [Config.ExternalTasks], which +// opens files. [TaskGraph.readinessLocked] runs with the graph's lock held, and +// the graph's lock is what stop, steer and every other control takes, so a +// reader that blocked there would freeze the controls of a conversation over a +// disk it does not own. So the frontier never calls the reader. It consults an +// OBSERVATION — what the reader last said about each target, held in memory — +// and a target nobody has observed yet is simply "not done". After the lock is +// released the pass hands the targets that are still pending to ONE refresh, +// which asks the reader on its own goroutine under its own deadline, stores the +// answers under the lock for an instant, and turns the frontier again. +// +// THE REFRESH IS BOUNDED. At most one is in flight; it asks for at most +// [externalRefreshMax] targets, each under [externalAskTimeout]; and it +// re-arms itself only while some node is still pending on an unfinished target, +// so a graph with no external waits does no reading at all and a graph whose +// waits have resolved stops polling. + +import ( + "context" + "errors" + "fmt" + "sync" + "time" + "unicode/utf8" +) + +// TaskRef names one task of one conversation. Session is the conversation's id +// (its folder's name) and ID the conversation's own number for the task. +type TaskRef struct { + Session string + ID uint64 +} + +// String is the ref's wire spelling, ":", the same as the app's +// task ids. +func (r TaskRef) String() string { return fmt.Sprintf("%s:%d", r.Session, r.ID) } + +// maxSessionIDBytes bounds a conversation id; today's are 16 hex digits. +const maxSessionIDBytes = 128 + +// validSessionID is the law the app applies before it joins an id to a folder: +// ONE path component, bounded, printable, with no separator, colon or NUL. +func validSessionID(s string) bool { + if s == "" || len(s) > maxSessionIDBytes || s[0] == '.' || !utf8.ValidString(s) { + return false + } + for _, r := range s { + if r < 0x20 || r == 0x7f || r == '/' || r == '\\' || r == ':' || r == utf8.RuneError { + return false + } + } + return true +} + +// Valid reports whether the ref names something safe to look up. +func (r TaskRef) Valid() bool { return validSessionID(r.Session) && r.ID != 0 } + +// ExternalTask is what the reader says about one task of another conversation. +// Exists false with a nil error means the conversation was read and has no such +// task; an error means it could not be read, which says nothing about the task. +type ExternalTask struct { + Exists bool + State TaskState + // Waits are the tasks this one waits on, in any conversation, so that a new + // link can be refused when it would close a loop. Same-conversation + // prerequisites are included, spelled with this task's own session. + Waits []TaskRef +} + +// ExternalTasks reads one task of another conversation. It may do IO and may +// block; the engine only ever calls it from a goroutine holding no lock, under +// a deadline, and treats an error as "not known yet". +type ExternalTasks func(ctx context.Context, ref TaskRef) (ExternalTask, error) + +// The typed refusals of [Agent.WaitOnTask]. +var ( + ErrWaitNotQueued = errors.New("that task has already started, so it can no longer be told to wait") + ErrWaitSameSession = errors.New("that task is in the same conversation: use depends_on") + ErrWaitSelf = errors.New("a task cannot wait on itself") + ErrWaitMissing = errors.New("the task to wait on does not exist") + ErrWaitDuplicate = errors.New("that wait is already recorded") + ErrWaitCycle = errors.New("that wait would close a loop of tasks waiting on each other") + ErrWaitAbsent = errors.New("this process cannot read other conversations' tasks, so cross-conversation waits are not available") + ErrWaitUnreadable = errors.New("the task to wait on could not be read right now") + ErrWaitInvalid = errors.New("that is not a valid task reference") + ErrWaitNoTask = errors.New("this conversation has no such task") +) + +// taskWaitRecord is one external wait on disk. +type taskWaitRecord struct { + Session string `json:"session"` + Task uint64 `json:"task"` +} + +func waitRecords(refs []TaskRef) []taskWaitRecord { + if len(refs) == 0 { + return nil + } + out := make([]taskWaitRecord, len(refs)) + for i, r := range refs { + out[i] = taskWaitRecord{Session: r.Session, Task: r.ID} + } + return out +} + +// waitRefs reads the records back, dropping any that could not name a task +// safely: a hand-edited file never makes the frontier open an unsafe path. +func waitRefs(records []taskWaitRecord) []TaskRef { + var out []TaskRef + for _, r := range records { + ref := TaskRef{Session: r.Session, ID: r.Task} + if ref.Valid() { + out = append(out, ref) + } + } + return out +} + +// setStateLocked moves the node to a state and stamps the instant it entered +// it. The graph's lock is held. +func (n *TaskNode) setStateLocked(state TaskState) { + n.state = state + n.stateAt = time.Now() +} + +// The refresh's bounds. +const ( + // externalRefreshMax is the most targets one refresh asks about. + externalRefreshMax = 64 + // externalAskTimeout is how long the reader gets for one target. + externalAskTimeout = 3 * time.Second + // externalPoll is how often a still-pending wait is looked at again. + externalPoll = 2 * time.Second + // externalWalkMax bounds the loop check's walk, in tasks read. + externalWalkMax = 256 +) + +// externalLinks serialises the WRITING of cross-conversation links within this +// process, so that two conversations cannot each pass the loop check and then +// both commit the two halves of a loop. It guards no state the frontier reads. +var externalLinks sync.Mutex + +// externalState is a graph's view of the tasks its nodes wait on elsewhere. All +// of it is guarded by the graph's lock. +type externalState struct { + // seen is the reader's last answer per target. + seen map[TaskRef]ExternalTask + // refreshing says one refresh is in flight; armed that a timer for the next + // is set. Either keeps a second from starting. + refreshing bool + armed bool + // every is the poll period (0 is [externalPoll]); a field for the tests. + every time.Duration +} + +// externalVerdict is what the frontier makes of one node's external waits: +// whether any is unfinished and, if one failed, the reason. It reads the +// observation only. +func (g *TaskGraph) externalVerdict(node *TaskNode) (ready bool, blocked string) { + ready = true + for _, ref := range node.waitsExt { + seen, ok := g.ext.seen[ref] + switch { + case !ok: + ready = false + case !seen.Exists: + return false, fmt.Sprintf("it waits on task %s, which is not in that conversation's work", ref) + case seen.State == TaskDone: + case seen.State == TaskFailed: + return false, fmt.Sprintf("it waits on task %s, which did not finish", ref) + default: + ready = false + } + } + return ready, "" +} + +// pendingExternalLocked lists the targets some queued node still waits on that +// are not known to be done, nearest-first and bounded. +func (g *TaskGraph) pendingExternalLocked() []TaskRef { + var out []TaskRef + seen := map[TaskRef]bool{} + for _, id := range g.order { + node := g.nodes[id] + if node == nil || node.state != TaskQueued { + continue + } + for _, ref := range node.waitsExt { + if seen[ref] || (g.ext.seen[ref].Exists && g.ext.seen[ref].State == TaskDone) { + continue + } + seen[ref] = true + out = append(out, ref) + if len(out) == externalRefreshMax { + return out + } + } + } + return out +} + +// externalReader is the reader this graph asks, or nil. +func (g *TaskGraph) externalReader() ExternalTasks { + if g.home == nil { + return nil + } + return g.home.config.ExternalTasks +} + +// armExternal starts the one refresh the pass asked for, if there is something +// to refresh and none is running. It takes the lock only to decide and never +// calls the reader there. +func (g *TaskGraph) armExternal() { + reader := g.externalReader() + g.mu.Lock() + if g.quitting || g.ext.refreshing || g.ext.armed { + g.mu.Unlock() + return + } + pending := g.pendingExternalLocked() + if len(pending) == 0 || reader == nil { + g.mu.Unlock() + return + } + g.ext.refreshing = true + g.mu.Unlock() + go g.refreshExternal(reader, pending) +} + +// refreshExternal asks the reader about each pending target with NO LOCK HELD, +// publishes the answers, turns the frontier, and re-arms only while something +// is still waiting. +func (g *TaskGraph) refreshExternal(reader ExternalTasks, pending []TaskRef) { + answers := make(map[TaskRef]ExternalTask, len(pending)) + for _, ref := range pending { + ctx, cancel := context.WithTimeout(g.externalContext(), externalAskTimeout) + got, err := reader(ctx, ref) + cancel() + if err != nil { + // Unreadable says nothing about the task: keep the old answer. + continue + } + answers[ref] = got + } + g.mu.Lock() + if g.ext.seen == nil { + g.ext.seen = map[TaskRef]ExternalTask{} + } + for ref, got := range answers { + g.ext.seen[ref] = got + } + g.ext.refreshing = false + again := !g.quitting && len(g.pendingExternalLocked()) > 0 + every := g.ext.every + if again { + g.ext.armed = true + } + g.mu.Unlock() + g.runFrontier() + if !again { + return + } + if every <= 0 { + every = externalPoll + } + time.AfterFunc(every, func() { + g.mu.Lock() + g.ext.armed = false + g.mu.Unlock() + g.armExternal() + }) +} + +// externalContext is the context a refresh's reads are cancelled by when the +// session quits. +func (g *TaskGraph) externalContext() context.Context { + g.mu.Lock() + defer g.mu.Unlock() + if g.extCtx == nil { + g.extCtx, g.extCancel = context.WithCancel(context.Background()) + if g.quitting { + g.extCancel() + } + } + return g.extCtx +} + +// ownWaitsLocked is this conversation's own waits, same-conversation edges +// included, for the loop check's walk. +func (g *TaskGraph) ownWaitsLocked(session string) map[uint64][]TaskRef { + out := make(map[uint64][]TaskRef, len(g.nodes)) + for id, node := range g.nodes { + var waits []TaskRef + for _, dep := range node.dependsOn { + waits = append(waits, TaskRef{Session: session, ID: dep}) + } + waits = append(waits, node.waitsExt...) + if len(waits) > 0 { + out[id] = waits + } + } + return out +} + +// WaitOnTask records that this conversation's queued task id waits on `on`, a +// task of ANOTHER conversation, and persists the link so it survives a reopen. +// The frontier then enforces it: the task does not start until `on` is done, +// and fails if `on` fails. +// +// It refuses, with a typed error, when the task is not queued (a started task +// cannot be re-scoped), when `on` is the task itself or in this conversation +// (use depends_on), when `on` does not exist, when the link already exists, +// when it would close a loop across conversations, and when this process +// cannot read other conversations' tasks — the capability is absent, not +// broken. Every read happens with no lock held. +func (a *Agent) WaitOnTask(ctx context.Context, id uint64, on TaskRef) error { + self := a.config.Place.ID() + reader, err := a.refuseWaitTarget(self, on, id) + if err != nil { + return err + } + g := a.tasker() + if g == nil { + return ErrWaitNoTask + } + if err := g.checkWaitable(id, on); err != nil { + return err + } + // ALL IO FIRST, with no lock of the conversation's held: does the target + // exist, and would the link close a loop. + externalLinks.Lock() + defer externalLinks.Unlock() + target, err := reader(ctx, on) + if err != nil { + return fmt.Errorf("%w: %v", ErrWaitUnreadable, err) + } + if !target.Exists { + return ErrWaitMissing + } + g.mu.Lock() + own := g.ownWaitsLocked(self) + g.mu.Unlock() + if err := walkForLoop(ctx, reader, TaskRef{Session: self, ID: id}, on, own, self); err != nil { + return err + } + return g.commitWait(id, on, target) +} + +// refuseWaitTarget is the refusals that need no reading: a reference that is +// not safe, one that names this conversation or the task itself, and a process +// with no reader. It answers the reader the rest of the call uses. +func (a *Agent) refuseWaitTarget(self string, on TaskRef, id uint64) (ExternalTasks, error) { + switch { + case !on.Valid(): + return nil, ErrWaitInvalid + case self != "" && on.Session == self && on.ID == id: + return nil, ErrWaitSelf + case self != "" && on.Session == self: + return nil, ErrWaitSameSession + case a.config.ExternalTasks == nil: + return nil, ErrWaitAbsent + } + return a.config.ExternalTasks, nil +} + +// commitWait REVALIDATES AND COMMITS under the lock, and writes the file before +// answering: a link acknowledged is a link on the disk. A write that fails +// takes the link back off the record, so the record and the disk agree. +func (g *TaskGraph) commitWait(id uint64, on TaskRef, target ExternalTask) error { + g.mu.Lock() + if err := g.checkWaitableLocked(id, on); err != nil { + g.mu.Unlock() + return err + } + node := g.nodes[id] + node.waitsExt = append(append([]TaskRef(nil), node.waitsExt...), on) + node.app.Version++ + if g.ext.seen == nil { + g.ext.seen = map[TaskRef]ExternalTask{} + } + g.ext.seen[on] = target + store := g.store + g.mu.Unlock() + if err := store.write(g); err != nil && !errors.Is(err, errStoreClosed) { + g.mu.Lock() + if n := g.nodes[id]; n != nil { + n.waitsExt = withoutRef(n.waitsExt, on) + } + g.mu.Unlock() + return err + } + g.runFrontier() + return nil +} + +func withoutRef(refs []TaskRef, drop TaskRef) []TaskRef { + out := make([]TaskRef, 0, len(refs)) + for _, r := range refs { + if r != drop { + out = append(out, r) + } + } + return out +} + +func (g *TaskGraph) checkWaitable(id uint64, on TaskRef) error { + g.mu.Lock() + defer g.mu.Unlock() + return g.checkWaitableLocked(id, on) +} + +func (g *TaskGraph) checkWaitableLocked(id uint64, on TaskRef) error { + node := g.nodes[id] + if node == nil || g.deleted[id] { + return ErrWaitNoTask + } + if node.state != TaskQueued || !node.started.IsZero() { + return ErrWaitNotQueued + } + for _, ref := range node.waitsExt { + if ref == on { + return ErrWaitDuplicate + } + } + return nil +} + +// walkForLoop reports ErrWaitCycle when `from` is reachable from `start` +// through stored waits: adding from→start would close a loop. The walk reads +// other conversations through the reader and this conversation's own tasks from +// the snapshot, visits each task once and stops after [externalWalkMax] reads. +func walkForLoop(ctx context.Context, reader ExternalTasks, from, start TaskRef, own map[uint64][]TaskRef, self string) error { + seen := map[TaskRef]bool{start: true} + queue := []TaskRef{start} + reads := 0 + for len(queue) > 0 { + cur := queue[0] + queue = queue[1:] + if cur == from { + return ErrWaitCycle + } + var next []TaskRef + if cur.Session == self { + next = own[cur.ID] + } else { + if reads++; reads > externalWalkMax { + return fmt.Errorf("%w: the chain of waits is longer than %d tasks", ErrWaitUnreadable, externalWalkMax) + } + got, err := reader(ctx, cur) + if err != nil { + return fmt.Errorf("%w: %v", ErrWaitUnreadable, err) + } + next = got.Waits + } + for _, n := range next { + if !seen[n] && n.Valid() { + seen[n] = true + queue = append(queue, n) + } + } + } + return nil +} diff --git a/internal/session/task_external_test.go b/internal/session/task_external_test.go new file mode 100644 index 0000000000..2af311fedb --- /dev/null +++ b/internal/session/task_external_test.go @@ -0,0 +1,296 @@ +package session + +// WAITING ON ANOTHER CONVERSATION'S TASK: the link is stored, survives a reopen, +// is enforced by the frontier (not merely drawn), and the frontier never reads +// another conversation under a lock. + +import ( + "context" + "errors" + "path/filepath" + "sync" + "testing" + "time" +) + +// fakeElsewhere is a reader over a table of other conversations' tasks. Every +// ask is counted, and `gate`, when set, blocks each ask until it is closed. +type fakeElsewhere struct { + mu sync.Mutex + tasks map[TaskRef]ExternalTask + asks int + gate chan struct{} +} + +func (f *fakeElsewhere) read(ctx context.Context, ref TaskRef) (ExternalTask, error) { + f.mu.Lock() + f.asks++ + gate := f.gate + got, ok := f.tasks[ref] + f.mu.Unlock() + if gate != nil { + select { + case <-gate: + case <-ctx.Done(): + return ExternalTask{}, ctx.Err() + } + } + if !ok { + return ExternalTask{}, nil + } + return got, nil +} + +func (f *fakeElsewhere) set(ref TaskRef, task ExternalTask) { + f.mu.Lock() + f.tasks[ref] = task + f.mu.Unlock() +} + +func (f *fakeElsewhere) count() int { + f.mu.Lock() + defer f.mu.Unlock() + return f.asks +} + +// waiterGraph is a conversation whose task 1 is held running (so task 2, which +// depends_on it, is queued) and whose reader is the fake. It returns the agent, +// the graph, the journal's checkpoint path and a release for task 1. +func waiterGraph(t *testing.T, elsewhere *fakeElsewhere) (*Agent, *TaskGraph, string, func()) { + t.Helper() + dir := filepath.Join(t.TempDir(), "aaaaaaaaaaaaaaaa") + journal := filepath.Join(dir, placeTranscript) + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { + config.SessionFile = journal + config.Place = Place{Dir: dir} + config.ExternalTasks = elsewhere.read + }) + graph := agent.graph() + release := make(chan struct{}) + var once sync.Once + graph.mu.Lock() + graph.ext.every = 10 * time.Millisecond + graph.run = func(node *TaskNode) { + if node.id == 1 { + <-release + } + node.finish("done", nil, "", mergeMerged) + node.graph.complete(node, TaskDone) + } + graph.mu.Unlock() + one := graph.reserve() + graph.admit(one, taskSpec{title: "first", summary: "s", brief: "b", acceptance: "a"}) + two := graph.reserve() + graph.admit(two, taskSpec{title: "second", summary: "s", brief: "b", acceptance: "a", dependsOn: []uint64{one}}) + return agent, graph, taskCheckpointPath(journal), func() { once.Do(func() { close(release) }) } +} + +func stateOf(g *TaskGraph, id uint64) TaskState { + g.mu.Lock() + defer g.mu.Unlock() + return g.nodes[id].state +} + +func awaitState(t *testing.T, g *TaskGraph, id uint64, want TaskState) { + t.Helper() + deadline := time.Now().Add(5 * time.Second) + for stateOf(g, id) != want { + if time.Now().After(deadline) { + t.Fatalf("task %d is %q, want %q", id, stateOf(g, id), want) + } + time.Sleep(5 * time.Millisecond) + } +} + +var elsewhereTask = TaskRef{Session: "bbbbbbbbbbbbbbbb", ID: 7} + +// THE LINK IS ENFORCED, NOT DRAWN: the dependent stays queued after its local +// prerequisite is done, until the other conversation's task is done. +func TestAWaitOnAnotherConversationsTaskHoldsTheTaskUntilItIsDone(t *testing.T) { + elsewhere := &fakeElsewhere{tasks: map[TaskRef]ExternalTask{elsewhereTask: {Exists: true, State: TaskRunning}}} + agent, graph, checkpoint, release := waiterGraph(t, elsewhere) + if err := agent.WaitOnTask(context.Background(), 2, elsewhereTask); err != nil { + t.Fatal(err) + } + // Persisted at once, before anything could have run. + record := recordOf(t, readCheckpoint(t, checkpoint), 2) + if len(record.WaitsExternal) != 1 || record.WaitsExternal[0].Session != elsewhereTask.Session || record.WaitsExternal[0].Task != 7 { + t.Fatalf("the link did not reach the checkpoint: %+v", record.WaitsExternal) + } + if record.AdmittedAt.IsZero() || record.StateAt.IsZero() { + t.Fatalf("a new record carries its own clock: %+v", record) + } + release() + awaitState(t, graph, 1, TaskDone) + // Give the poll several beats: the prerequisite elsewhere is still running. + time.Sleep(80 * time.Millisecond) + if got := stateOf(graph, 2); got != TaskQueued { + t.Fatalf("task 2 is %q while its prerequisite elsewhere runs", got) + } + elsewhere.set(elsewhereTask, ExternalTask{Exists: true, State: TaskDone}) + awaitState(t, graph, 2, TaskDone) +} + +func TestAWaitOnATaskThatFailedElsewhereFailsTheDependent(t *testing.T) { + elsewhere := &fakeElsewhere{tasks: map[TaskRef]ExternalTask{elsewhereTask: {Exists: true, State: TaskRunning}}} + agent, graph, _, release := waiterGraph(t, elsewhere) + if err := agent.WaitOnTask(context.Background(), 2, elsewhereTask); err != nil { + t.Fatal(err) + } + release() + elsewhere.set(elsewhereTask, ExternalTask{Exists: true, State: TaskFailed}) + awaitState(t, graph, 2, TaskFailed) + graph.mu.Lock() + report := graph.nodes[2].report + graph.mu.Unlock() + if report == "" { + t.Fatal("a failed dependent says why") + } +} + +// A reopen reads the link back, validated, and the restored node still waits. +func TestAWaitSurvivesAReopen(t *testing.T) { + elsewhere := &fakeElsewhere{tasks: map[TaskRef]ExternalTask{elsewhereTask: {Exists: true, State: TaskRunning}}} + agent, _, checkpoint, release := waiterGraph(t, elsewhere) + defer release() + if err := agent.WaitOnTask(context.Background(), 2, elsewhereTask); err != nil { + t.Fatal(err) + } + restored := restoreNode(newTaskGraph(), recordOf(t, readCheckpoint(t, checkpoint), 2)) + if len(restored.waitsExt) != 1 || restored.waitsExt[0] != elsewhereTask { + t.Fatalf("restored waits = %+v", restored.waitsExt) + } + // A hand-edited file never makes the frontier open an unsafe name. + bad := taskRecord{ID: 3, WaitsExternal: []taskWaitRecord{{Session: "../etc", Task: 1}, {Session: "ok", Task: 0}, {Session: "a:b", Task: 1}}} + if got := waitRefs(bad.WaitsExternal); len(got) != 0 { + t.Fatalf("unsafe refs were kept: %+v", got) + } +} + +func TestWaitOnTaskRefusals(t *testing.T) { + elsewhere := &fakeElsewhere{tasks: map[TaskRef]ExternalTask{ + elsewhereTask: {Exists: true, State: TaskRunning}, + // The other conversation's task 9 waits on our task 2: waiting on it from + // task 2 would close a loop. + {Session: "bbbbbbbbbbbbbbbb", ID: 9}: {Exists: true, State: TaskQueued, Waits: []TaskRef{{Session: "aaaaaaaaaaaaaaaa", ID: 2}}}, + }} + agent, _, _, release := waiterGraph(t, elsewhere) + defer release() + ctx := context.Background() + for name, test := range map[string]struct { + id uint64 + on TaskRef + want error + }{ + "invalid session": {2, TaskRef{Session: "../x", ID: 1}, ErrWaitInvalid}, + "zero id": {2, TaskRef{Session: "bbbbbbbbbbbbbbbb"}, ErrWaitInvalid}, + "same conversation": {2, TaskRef{Session: "aaaaaaaaaaaaaaaa", ID: 1}, ErrWaitSameSession}, + "itself": {2, TaskRef{Session: "aaaaaaaaaaaaaaaa", ID: 2}, ErrWaitSelf}, + "started task": {1, elsewhereTask, ErrWaitNotQueued}, + "no such task": {99, elsewhereTask, ErrWaitNoTask}, + "missing target": {2, TaskRef{Session: "bbbbbbbbbbbbbbbb", ID: 123}, ErrWaitMissing}, + "loop": {2, TaskRef{Session: "bbbbbbbbbbbbbbbb", ID: 9}, ErrWaitCycle}, + } { + if err := agent.WaitOnTask(ctx, test.id, test.on); !errors.Is(err, test.want) { + t.Errorf("%s: err = %v, want %v", name, err, test.want) + } + } + if err := agent.WaitOnTask(ctx, 2, elsewhereTask); err != nil { + t.Fatal(err) + } + if err := agent.WaitOnTask(ctx, 2, elsewhereTask); !errors.Is(err, ErrWaitDuplicate) { + t.Errorf("duplicate: err = %v", err) + } +} + +// Without a reader the capability is absent, not broken. +func TestWaitOnTaskIsAbsentWithoutAReader(t *testing.T) { + agent, _ := newTestAgent(t, &scriptedCompleter{}, nil) + if err := agent.WaitOnTask(context.Background(), 1, elsewhereTask); !errors.Is(err, ErrWaitAbsent) { + t.Fatalf("err = %v", err) + } +} + +// THE FRONTIER NEVER READS UNDER A LOCK: a reader that blocks forever leaves +// every control of the graph answering, and the node stays queued (it never runs +// on a guess). +func TestABlockedExternalReaderFreezesNothing(t *testing.T) { + gate := make(chan struct{}) + elsewhere := &fakeElsewhere{tasks: map[TaskRef]ExternalTask{elsewhereTask: {Exists: true, State: TaskRunning}}} + agent, graph, _, release := waiterGraph(t, elsewhere) + defer release() + if err := agent.WaitOnTask(context.Background(), 2, elsewhereTask); err != nil { + t.Fatal(err) + } + // The link is in. Now the disk goes away: every later read blocks. + elsewhere.mu.Lock() + elsewhere.gate = gate + elsewhere.tasks[elsewhereTask] = ExternalTask{Exists: true, State: TaskDone} + graph.mu.Lock() + delete(graph.ext.seen, elsewhereTask) + graph.mu.Unlock() + elsewhere.mu.Unlock() + release() + awaitState(t, graph, 1, TaskDone) + + finished := make(chan struct{}) + go func() { + defer close(finished) + graph.runFrontier() // asks for a refresh; must not wait for it + _ = graph.document() + _ = graph.nodes[2].stateNow() + graph.stopAll(10 * time.Millisecond) + }() + select { + case <-finished: + case <-time.After(3 * time.Second): + t.Fatal("the graph's controls waited on another conversation's disk") + } + if got := stateOf(graph, 2); got != TaskQueued { + t.Fatalf("task 2 ran on a guess: %q", got) + } + if elsewhere.count() < 2 { + t.Fatalf("the reader was never asked (asks = %d)", elsewhere.count()) + } + close(gate) +} + +// A graph with no external waits does no reading at all, however often the +// frontier turns. +func TestNoExternalWaitsMeansNoReads(t *testing.T) { + elsewhere := &fakeElsewhere{tasks: map[TaskRef]ExternalTask{}} + _, graph, _, release := waiterGraph(t, elsewhere) + release() + awaitState(t, graph, 2, TaskDone) + for i := 0; i < 20; i++ { + graph.runFrontier() + } + if n := elsewhere.count(); n != 0 { + t.Fatalf("a graph with no external waits asked the reader %d times", n) + } +} + +// Genuine event clocks: a transition stamps its own instant, which is not later +// than the checkpoint read. +func TestAStateChangeStampsItsOwnInstant(t *testing.T) { + elsewhere := &fakeElsewhere{tasks: map[TaskRef]ExternalTask{}} + _, graph, _, release := waiterGraph(t, elsewhere) + graph.mu.Lock() + queued := graph.nodes[2].stateAt + admitted := graph.nodes[2].admittedAt + graph.mu.Unlock() + if queued.IsZero() || admitted.IsZero() { + t.Fatal("admission stamps both clocks") + } + time.Sleep(5 * time.Millisecond) + release() + awaitState(t, graph, 2, TaskDone) + graph.mu.Lock() + defer graph.mu.Unlock() + if !graph.nodes[2].stateAt.After(queued) { + t.Fatalf("stateAt did not move with the state: %v then %v", queued, graph.nodes[2].stateAt) + } + if !graph.nodes[2].admittedAt.Equal(admitted) { + t.Fatal("admittedAt moved") + } +} diff --git a/internal/session/task_run.go b/internal/session/task_run.go index 33c867be2b..b9835ba58c 100644 --- a/internal/session/task_run.go +++ b/internal/session/task_run.go @@ -629,7 +629,14 @@ type TaskNode struct { // seed a card codeaf is holding, and an engine that died mid-turn quietly // dropping the hand-back it owed. decider TaskAskOwner - started time.Time + // waitsExt are the tasks of other conversations this node waits on + // ([Agent.WaitOnTask]), admittedAt the instant it entered the graph and + // stateAt the instant it entered its present state; the checkpoint keeps all + // three (taskRecord.WaitsExternal, AdmittedAt, StateAt). + waitsExt []TaskRef + admittedAt time.Time + stateAt time.Time + started time.Time // ended is the instant this node last landed. It is stamped at the live // transition and kept on the checkpoint, so a rebuilt row reads the record's // fact instead of dating old work with the clock that happened to read it. @@ -1082,6 +1089,13 @@ type TaskGraph struct { // a slot frees, which is what a queue in a graph is for. limit int + // ext is what this graph knows of the tasks its nodes wait on in OTHER + // conversations, and extCtx cancels the reads that learn it at quit + // (task_external.go). All of it is guarded by mu. + ext externalState + extCtx context.Context + extCancel context.CancelFunc + // governor is the machine's own answer to "may one more node start" // (task_pressure.go), and nil is no governor: every scripted graph in the // tests, and a session whose person zeroed both rows. @@ -1378,16 +1392,18 @@ func (g *TaskGraph) admit(id uint64, spec taskSpec) TaskState { // (turnhandoff.go). admitter := g.admittingAgent(spec) node := &TaskNode{ - graph: g, - id: id, - dependsOn: spec.dependsOn, - parent: spec.parent, - depth: spec.depth, - owner: spec.owner, - done: make(chan struct{}), - spec: spec, - kind: spec.kind(), - state: TaskQueued, + graph: g, + id: id, + dependsOn: spec.dependsOn, + admittedAt: time.Now(), + stateAt: time.Now(), + parent: spec.parent, + depth: spec.depth, + owner: spec.owner, + done: make(chan struct{}), + spec: spec, + kind: spec.kind(), + state: TaskQueued, // WHOSE REQUEST HANDED THIS OUT, stamped at the one door every task in // this package comes through so no road has to remember to do it // (turnhandoff.go). @@ -1596,7 +1612,7 @@ func (g *TaskGraph) runFrontier() { } ready, blocked := g.readinessLocked(node) if blocked != "" { - node.state = TaskFailed + node.setStateLocked(TaskFailed) node.ended = time.Now() node.landLocked(blocked) node.held = "" @@ -1622,7 +1638,7 @@ func (g *TaskGraph) runFrontier() { // JIT: the brief is assembled here, with the prerequisites' reports in // hand, and never at proposal time when they did not exist yet. node.brief = g.briefLocked(node, orders) - node.state = TaskRunning + node.setStateLocked(TaskRunning) node.app.Version++ node.started = time.Now() // The hold is lifted by the start itself, so the running update this @@ -1657,6 +1673,9 @@ func (g *TaskGraph) runFrontier() { if machineHeld { g.armPoll() } + // AND A WAIT ON ANOTHER CONVERSATION'S TASK IS LOOKED AT once, outside every + // lock, only while one is pending (task_external.go). + g.armExternal() // The pass's transitions reach the disk BEFORE the runs they authorize start: // a node that is running in this process must never be a node the checkpoint @@ -1841,6 +1860,9 @@ func (g *TaskGraph) stopAll(grace time.Duration) { } g.mu.Lock() g.quitting = true + if g.extCancel != nil { + g.extCancel() + } var cuts []context.CancelFunc for _, id := range g.order { node := g.nodes[id] @@ -1926,7 +1948,13 @@ func (g *TaskGraph) readinessLocked(node *TaskNode) (bool, string) { ready = false } } - return ready, "" + if len(node.waitsExt) == 0 { + return ready, "" + } + // A WAIT ON ANOTHER CONVERSATION IS READ FROM THE OBSERVATION and never from + // the reader: this lock is the one every control takes (task_external.go). + outside, blocked := g.externalVerdict(node) + return ready && outside, blocked } // briefLocked assembles what one node is handed: its own brief, then what the @@ -2118,7 +2146,7 @@ func shareReports(reports []string, pot int) []string { // state transition and the slot being handed back. func (g *TaskGraph) complete(node *TaskNode, state TaskState) { g.mu.Lock() - node.state = state + node.setStateLocked(state) node.app.Version++ node.ended = time.Now() if !node.started.IsZero() { @@ -2180,7 +2208,7 @@ func (g *TaskGraph) handBackSlotLocked(node *TaskNode) { // dependents move on the new state. func (g *TaskGraph) resettle(node *TaskNode, state TaskState) { g.mu.Lock() - node.state = state + node.setStateLocked(state) node.app.Version++ node.ended = time.Now() // AND THE SETTLE'S OWN CLAIM IS HANDED BACK IN THE SAME WRITE — the claim diff --git a/internal/session/task_store.go b/internal/session/task_store.go index 462bf07b89..9e3fc9b796 100644 --- a/internal/session/task_store.go +++ b/internal/session/task_store.go @@ -194,6 +194,19 @@ type taskRecord struct { // as it always did: no plan task, no plan lines, nothing dispatched. PlanID string `json:"plan_id,omitempty"` DependsOn []uint64 `json:"depends_on,omitempty"` + // WaitsExternal are the tasks of OTHER conversations this node waits on + // ([Agent.WaitOnTask]): stored, and enforced by the same frontier that + // enforces DependsOn (task_external.go). Absent on every node that waits on + // nothing outside its conversation and on every checkpoint written before + // the link existed, which reads as "no such link recorded" and nothing more. + WaitsExternal []taskWaitRecord `json:"waits_external,omitempty"` + // AdmittedAt is the instant the node entered the graph, and StateAt the + // instant it entered the state it is in. BOTH ARE THE EVENT'S OWN CLOCK, + // stamped where the transition happens and never where a reader looks: a + // record written before they existed carries neither, and a reader must say + // the instant is not recorded rather than date old work with today. + AdmittedAt time.Time `json:"admittedAt,omitzero"` + StateAt time.Time `json:"stateAt,omitzero"` // Ground is the repository or folder the work IS ABOUT and Mode is how the // node stands on it ([TaskMode]). Where says which directory the worker typed @@ -1309,6 +1322,9 @@ func (n *TaskNode) recordLocked() taskRecord { Acceptance: n.spec.acceptance, PlanID: n.spec.planID, DependsOn: dependsOn, + WaitsExternal: waitRecords(n.waitsExt), + AdmittedAt: n.admittedAt, + StateAt: n.stateAt, Parent: n.parent, Depth: n.depth, State: n.state, @@ -2158,11 +2174,14 @@ func restoreNode(graph *TaskGraph, record taskRecord) *TaskNode { app: cloneAppTaskRecord(record.App), nextModel: record.NextModel, nextEffort: record.NextEffort, graph: graph, - id: record.ID, - dependsOn: record.DependsOn, - parent: record.Parent, - depth: record.Depth, - done: make(chan struct{}), + id: record.ID, + dependsOn: record.DependsOn, + waitsExt: waitRefs(record.WaitsExternal), + admittedAt: record.AdmittedAt, + stateAt: record.StateAt, + parent: record.Parent, + depth: record.Depth, + done: make(chan struct{}), spec: taskSpec{ parent: record.Parent, depth: record.Depth, @@ -2367,6 +2386,7 @@ func interrupt(record taskRecord, workspace string) (taskRecord, string) { record.State = TaskFailed record.Report = harnessInterruptedReport record.EndedAt = interruptedAt(record) + record.StateAt = record.EndedAt return record, "" } @@ -2376,6 +2396,7 @@ func interrupt(record taskRecord, workspace string) (taskRecord, string) { record.State = TaskFailed record.Report = subharnessInterruptedReport record.EndedAt = interruptedAt(record) + record.StateAt = record.EndedAt return record, "" } @@ -2387,12 +2408,16 @@ func interrupt(record taskRecord, workspace string) (taskRecord, string) { record.State = TaskFailed record.Changed = mergePaths(record.Changed, record.Wrote) record.EndedAt = interruptedAt(record) + record.StateAt = record.EndedAt return record, "" } // A process exit pauses ordinary work; it does not make a finding about it. // Put the node back on the frontier so the next session resumes it once. record.State = TaskQueued + // WHEN IT WENT BACK TO QUEUED IS NOT KNOWN — the process that could have said + // so is gone — so the instant is left unrecorded rather than guessed. + record.StateAt = time.Time{} // AND THE CUT ATTEMPT'S REASON DOES NOT RIDE INTO THE NEXT ONE. A node // machinery cut where it stood carries [TaskEndingInterrupted] on its record // (task_run.go's paused road), and [TaskNode.end] writes only the FIRST cause — From 2f89ade8ae33641540bcab492e8f611da3c60db0 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 23:08:20 -0400 Subject: [PATCH 124/459] session: read a conversation's persisted tasks as generic work-graph facts WorkSource projects tasks.json (nodes and adaptive-run rows) and the plan stores' rows - every row the app lists except unapproved proposals - into workgraph facts, cached by file stamp so an unchanged conversation is never reparsed. plandb.ReadTasks reads a store through a read-only connection. Co-Authored-By: Claude Sonnet 5.5 --- internal/plandb/readonly.go | 45 +++ internal/plandb/readonly_test.go | 37 ++ internal/session/workgraph_project.go | 290 ++++++++++++++ internal/session/workgraph_source.go | 399 ++++++++++++++++++++ internal/session/workgraph_source_test.go | 303 +++++++++++++++ internal/session/workgraph_stamp_unix.go | 24 ++ internal/session/workgraph_stamp_windows.go | 18 + 7 files changed, 1116 insertions(+) create mode 100644 internal/plandb/readonly.go create mode 100644 internal/plandb/readonly_test.go create mode 100644 internal/session/workgraph_project.go create mode 100644 internal/session/workgraph_source.go create mode 100644 internal/session/workgraph_source_test.go create mode 100644 internal/session/workgraph_stamp_unix.go create mode 100644 internal/session/workgraph_stamp_windows.go diff --git a/internal/plandb/readonly.go b/internal/plandb/readonly.go new file mode 100644 index 0000000000..59202e41a9 --- /dev/null +++ b/internal/plandb/readonly.go @@ -0,0 +1,45 @@ +package plandb + +import ( + "database/sql" + "net/url" +) + +// ReadOnly is the committed tasks of an existing store, in the store's order, +// and the id of its root. +type ReadOnly struct { + RootID string + Tasks []*Task +} + +// ReadTasks reads the committed tasks of an existing store through a read-only +// connection. Like [ReadRootPreview] it never creates a database, migrates its +// schema, adopts a plan or settles a task a dead process left running, so a +// reader of another conversation's work can look at live stores without being +// able to change them. A store it cannot read is an error, and says nothing +// about the tasks in it. +func ReadTasks(path string) (ReadOnly, error) { + uri := url.URL{Scheme: "file", Path: path, RawQuery: "mode=ro&_pragma=busy_timeout(2000)"} + db, err := sql.Open("sqlite", uri.String()) + if err != nil { + return ReadOnly{}, err + } + defer db.Close() + db.SetMaxOpenConns(1) + tx, err := db.Begin() + if err != nil { + return ReadOnly{}, err + } + defer tx.Rollback() + value, err := loadState(tx) + if err != nil { + return ReadOnly{}, err + } + out := ReadOnly{RootID: value.RootID, Tasks: make([]*Task, 0, len(value.Order))} + for _, id := range value.Order { + if task := value.Tasks[id]; task != nil { + out.Tasks = append(out.Tasks, task) + } + } + return out, nil +} diff --git a/internal/plandb/readonly_test.go b/internal/plandb/readonly_test.go new file mode 100644 index 0000000000..285b750294 --- /dev/null +++ b/internal/plandb/readonly_test.go @@ -0,0 +1,37 @@ +package plandb + +import ( + "os" + "path/filepath" + "testing" +) + +// A read of another conversation's store never creates one, sees the committed +// tasks in the store's order beside a writer that holds the write lock, and +// leaves the file as it found it. +func TestReadTasksReadsBesideAWriterAndNeverCreatesAStore(t *testing.T) { + path := filepath.Join(t.TempDir(), "plandb.db") + if _, err := ReadTasks(path); err == nil { + t.Fatal("a missing store was readable") + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatalf("a read created a store: %v", err) + } + store, err := Open(path, "program", "1", "fix parser", "fix the parser crash") + if err != nil { + t.Fatal(err) + } + defer store.Close() + tx, err := store.beginWrite() + if err != nil { + t.Fatal(err) + } + defer tx.Rollback() + got, err := ReadTasks(path) + if err != nil { + t.Fatal(err) + } + if got.RootID != "1" || len(got.Tasks) != 1 || got.Tasks[0].Title != "fix parser" { + t.Fatalf("read = %+v", got) + } +} diff --git a/internal/session/workgraph_project.go b/internal/session/workgraph_project.go new file mode 100644 index 0000000000..fe57027879 --- /dev/null +++ b/internal/session/workgraph_project.go @@ -0,0 +1,290 @@ +package session + +// THE PROJECTION: ONE CONVERSATION'S PERSISTED TASKS AS GENERIC FACTS. +// +// Everything in this file is a pure function of what the conversation put on +// disk — its tasks.json (the nodes it admitted, the rows of its adaptive runs) +// and the plan stores beside it (the sub-tasks of a run, which are real tasks +// the app lists). Nothing is read from a title, a body or the order tasks were +// made in; a link is a stored command or it is not a link. A fact the record +// does not carry is left out: no instant is dated with the clock that read it, +// no price is looked up, no link is guessed. +// +// WHAT THE APP LISTS IS WHAT IS HERE. [Agent.AppTasks] answers three persisted +// roads — the graph's nodes (a quick node is the one exception: it is listed +// elsewhere and kept here only so a link to it resolves), the rows of the +// conversation's adaptive runs, and the rows of its plan stores — and one +// transient road, proposals that have not been approved yet. Those have no +// record and no stable id, so they are the one thing a persisted graph cannot +// hold; [WorkExcluded] says so. + +import ( + "path" + "strconv" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/plandb" + "github.com/Agent-Field/codeaf/internal/redact" + "github.com/Agent-Field/codeaf/internal/workgraph" +) + +// WorkExcluded names the kinds of work the persisted graph does not hold, so +// their absence is a statement and not a silence. +var WorkExcluded = []string{"proposals"} + +// The words a record's merge outcome is said in on the graph. +func wgLanding(merge string, ended time.Time) *workgraph.Landing { + switch merge { + case mergeMerged, mergeInPlace, mergeKept, mergeConflicted, mergeAborted: + return &workgraph.Landing{Outcome: merge, At: workgraph.Stamp(ended)} + } + return nil +} + +// textOf cuts and scrubs words that left the machine's own files: the bound +// first (so a huge field costs a bounded amount), then the redactor over what +// is kept. +func wgText(source, s string, limit int) *workgraph.GraphText { + s = strings.TrimSpace(s) + if s == "" { + return nil + } + cut, was := workgraph.Cut(s, limit) + return &workgraph.GraphText{Source: source, Text: redact.Secrets(cut), Cut: was} +} + +func wgTitle(s string) string { + cut, _ := workgraph.Cut(strings.TrimSpace(s), workgraph.MaxTitleRunes) + return redact.Secrets(cut) +} + +// filesOf is the paths a task changed (or, for one still running, wrote), +// workspace-relative and slash-spelled. An absolute path or one that leaves +// the workspace is withheld and counted, never shown: a graph is read by more +// than the person whose machine it is. +func wgFiles(changed, wrote []string) workgraph.FileSet { + source, list := "changed", changed + if len(list) == 0 { + source, list = "wrote", wrote + } + var out workgraph.FileSet + seen := make(map[string]bool, len(list)) + for _, raw := range list { + clean, ok := wgRelPath(raw) + switch { + case !ok: + out.Omitted++ + case seen[clean]: + default: + seen[clean] = true + out.Total++ + if len(out.Paths) < workgraph.MaxFilesPerNode { + out.Paths = append(out.Paths, redact.Secrets(clean)) + } + } + } + if out.Total+out.Omitted > 0 { + out.Source = source + } + return out +} + +// relativePath is a recorded path as a clean workspace-relative one. +func wgRelPath(raw string) (string, bool) { + raw = strings.TrimSpace(strings.ReplaceAll(raw, "\\", "/")) + if raw == "" || strings.HasPrefix(raw, "/") || strings.ContainsRune(raw, 0) || (len(raw) > 1 && raw[1] == ':') { + return "", false + } + clean := path.Clean(raw) + if clean == "." || clean == ".." || strings.HasPrefix(clean, "../") { + return "", false + } + return clean, true +} + +// spendOf is a recorded bill. Dollars recorded make it priced; tokens without +// dollars make it unpriced (the dollars are UNKNOWN, not zero); nothing at all +// makes no bill. +func wgSpend(usd float64, tokens int) *workgraph.TaskSpend { + switch { + case usd > 0: + return &workgraph.TaskSpend{USD: usd, Tokens: tokens, Basis: "priced"} + case tokens > 0: + return &workgraph.TaskSpend{Tokens: tokens, Basis: "unpriced"} + } + return nil +} + +// checksOf is the recorded runs of a task's declared checks, from a verdict +// basis that rested on running them. A basis that read the work, or carries no +// run, records no check. +func wgChecks(basis *plandb.VerdictBasis) *workgraph.TaskChecks { + if basis == nil || basis.Kind != "run" || len(basis.Runs) == 0 { + return nil + } + out := &workgraph.TaskChecks{} + for _, run := range basis.Runs { + if run.ExitCode == 0 { + out.Passed++ + } else { + out.Failed++ + } + } + return out +} + +func wgTokens(r taskRecord) int { return r.Input + r.Output + r.CacheRead + r.CacheWrite } + +func wgRef(owner string, id uint64) workgraph.TaskRef { + return workgraph.TaskRef{Owner: owner, Task: strconv.FormatUint(id, 10)} +} + +func wgID(owner string, id uint64) string { return wgRef(owner, id).ID() } + +// instructionsOf is the words a task was given, with where they were read: the +// person's edit, then what it was admitted with, then the person's own message. +func wgInstructions(r taskRecord) *workgraph.GraphText { + if t := wgText("task_brief", r.App.Brief, workgraph.MaxTextRunes); t != nil { + return t + } + if t := wgText("admitted_brief", r.Brief, workgraph.MaxTextRunes); t != nil { + return t + } + return wgText("request", r.Request, workgraph.MaxTextRunes) +} + +// kindOf is the engine's kind word for a record. +func wgKind(r taskRecord) string { + if r.Kind == TaskKindQuick || r.Quick != nil { + return workgraph.KindQuick + } + if r.Kind == "" { + return "task" + } + return string(r.Kind) +} + +// nodeFact is one admitted node as a generic fact. Its links are RECORDED: the +// node carries its depends_on and its external waits, so none is a true "waits +// on nothing". +func nodeFact(owner string, r taskRecord) workgraph.Fact { + n := workgraph.GraphNode{ + ID: wgID(owner, r.ID), Ref: wgRef(owner, r.ID), Seq: r.ID, + Kind: wgKind(r), Title: wgTitle(r.Title), State: string(r.State), + Paused: r.App.Paused && r.State != TaskDone && r.State != TaskFailed, Declined: r.App.Declined, + Ending: string(r.Ending), Depth: r.Depth, + Clock: workgraph.Clock{ + AdmittedAt: workgraph.Stamp(r.AdmittedAt), StartedAt: workgraph.Stamp(r.StartedAt), + StateAt: workgraph.Stamp(r.StateAt), EndedAt: workgraph.Stamp(r.EndedAt), + }, + Instructions: wgInstructions(r), Model: wgTitle(r.Model), + Landing: wgLanding(r.Merge, r.EndedAt), Files: wgFiles(r.Changed, r.Wrote), + Spend: wgSpend(r.CostUSD, wgTokens(r)), Links: "recorded", + } + if r.Parent != 0 { + n.Parent = wgID(owner, r.Parent) + } + return workgraph.Fact{Node: n, Waits: nodeWaits(owner, r)} +} + +func nodeWaits(owner string, r taskRecord) []workgraph.Wait { + waits := make([]workgraph.Wait, 0, len(r.DependsOn)+len(r.WaitsExternal)) + for _, id := range r.DependsOn { + waits = append(waits, workgraph.Wait{On: wgRef(owner, id), Via: workgraph.ViaRecord}) + } + for _, w := range waitRefs(r.WaitsExternal) { + waits = append(waits, workgraph.Wait{On: workgraph.TaskRef{Owner: w.Session, Task: strconv.FormatUint(w.ID, 10)}, Via: workgraph.ViaExternal}) + } + return waits +} + +// runFamily is the id and the title of each adaptive run's own row, keyed by +// the row that opens it, so a worker row names the run it hangs under. +func wgRunTitles(runs []runRecord) map[uint64]string { + titles := make(map[uint64]string) + for _, r := range runs { + if r.Parent == 0 || r.Run == "" { + titles[r.ID] = r.Title + } + } + return titles +} + +// runFact is one row an adaptive run published. Its record carries no +// depends_on, so its links are UNRECORDED: zero edges here is unknown, not none. +func runFact(owner string, r runRecord, titles map[uint64]string) workgraph.Fact { + kind := "run" + if r.Kind != "" { + kind = string(r.Kind) + } + n := workgraph.GraphNode{ + ID: wgID(owner, r.ID), Ref: wgRef(owner, r.ID), Seq: r.ID, + Kind: kind, Title: wgTitle(r.Title), State: string(r.State), Stopped: r.Stopped, + Ending: string(r.Ending), Model: wgTitle(r.Model), + Clock: workgraph.Clock{StartedAt: workgraph.Stamp(r.StartedAt), EndedAt: workgraph.Stamp(r.EndedAt)}, + Landing: wgLanding(r.Merge, r.EndedAt), Files: wgFiles(r.Changed, nil), + Spend: wgSpend(r.CostUSD, 0), Links: "unrecorded", + Instructions: nil, + } + if r.State == TaskInterrupted { + n.State = string(TaskRunning) + } + if r.Parent != 0 { + n.Parent = wgID(owner, r.Parent) + } + if r.Run != "" { + root := r.Parent + if root == 0 { + root = r.ID + } + n.Group = &workgraph.GroupRef{ID: wgID(owner, root), Title: wgTitle(titles[root])} + } + return workgraph.Fact{Node: n} +} + +// planToken is a plan-store task's wire spelling. +func wgPlanToken(id string) string { return planStoreID(id) } + +// planStateWord is a plan task's status in the engine's state words. A row ready +// to run has not started, so it is queued; a cancelled one is a stopped or +// failed ending. +func planStateWord(t *plandb.Task) string { + switch t.Status { + case plandb.StatusDone: + return string(TaskDone) + case plandb.StatusFailed, plandb.StatusCancelled: + return string(TaskFailed) + case plandb.StatusClaimed, plandb.StatusRunning: + return string(TaskRunning) + } + return string(TaskQueued) +} + +// planFact is one task of a plan store as a generic fact: a real sub-task the +// app lists, identified by the store's own "t-". Its links are the store's +// hard dependencies — the ones it gates the task on; a `suggests` is advice +// the store does not enforce and is not a link. +func planFact(owner string, t *plandb.Task, group *workgraph.GroupRef, spend map[string]float64, usage map[string]planTaskUsage) workgraph.Fact { + tok := wgPlanToken(t.ID) + n := workgraph.GraphNode{ + ID: owner + ":" + tok, Ref: workgraph.TaskRef{Owner: owner, Task: tok}, Kind: "plan", + Title: wgTitle(t.Title), State: planStateWord(t), Paused: t.Paused, + Stopped: t.Status == plandb.StatusCancelled && planStopReason(t.Error), + Clock: workgraph.Clock{AdmittedAt: workgraph.Stamp(t.CreatedAt), EndedAt: workgraph.Stamp(t.CompletedAt)}, + Instructions: wgText("admitted_brief", t.Description, workgraph.MaxTextRunes), + Checks: wgChecks(&t.VerdictBasis), Links: "recorded", Group: group, + Spend: wgSpend(spend[t.ID], usage[t.ID].tokens), Model: wgTitle(usage[t.ID].model), + } + if t.ParentID != "" { + n.Parent = owner + ":" + wgPlanToken(t.ParentID) + } + var waits []workgraph.Wait + for _, dep := range t.Dependencies { + if dep.Kind == plandb.DepSuggests { + continue + } + waits = append(waits, workgraph.Wait{On: workgraph.TaskRef{Owner: owner, Task: wgPlanToken(dep.TaskID)}, Via: workgraph.ViaRecord}) + } + return workgraph.Fact{Node: n, Waits: waits} +} diff --git a/internal/session/workgraph_source.go b/internal/session/workgraph_source.go new file mode 100644 index 0000000000..78c352331d --- /dev/null +++ b/internal/session/workgraph_source.go @@ -0,0 +1,399 @@ +package session + +// THE SOURCE: PERSISTED TASK FACTS, READ ONCE PER CHANGE. +// +// [WorkSource] answers "what are the tasks of this conversation" from the +// files the conversation keeps, WITHOUT TAKING ANY LOCK THE WORK HOLDS and +// without asking a live agent anything: it stats and reads files. A reader of +// ten thousand tasks therefore costs the running work nothing, and a closed +// conversation answers exactly as an open one does. +// +// ── CACHED BY WHAT THE FILES ARE, NOT BY WHEN THEY WERE ASKED ABOUT ── +// +// Each conversation's projection is cached under the stamp of its sources — +// size, modification time and inode of tasks.json, the plan stores and the +// deletion list. [WorkSource.Stamp] is stat calls and nothing else, so a caller +// can ask "has anything moved" for a thousand conversations without reading a +// byte of any of them; an unchanged stamp is never reparsed (the checkpoint is +// written atomically by rename, which changes the inode, so a rewrite with the +// same size and second is still seen). The project's task index, which only the +// checks of landed work come from, is cached the same way under its own stamp +// and is NOT part of any conversation's, so a landing elsewhere in the project +// never reparses a conversation that did not change. + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "strconv" + "strings" + "sync" + "sync/atomic" + + "github.com/Agent-Field/codeaf/internal/plandb" + "github.com/Agent-Field/codeaf/internal/workgraph" +) + +// Why a conversation could not be read, in the words the graph reports. +const ( + WorkIssueMissing = "missing" + WorkIssueUnreadable = "unreadable" + WorkIssueTooLarge = "too_large" +) + +// workFileMax is the largest checkpoint the source will parse. Past it the +// conversation is reported too_large rather than read whole. +const workFileMax = 256 << 20 + +// ErrWorkOwnerMissing is the answer for a conversation folder that is not there. +var ErrWorkOwnerMissing = errors.New("the conversation's folder is not there") + +// OwnerFacts is one conversation's tasks as generic facts, immutable once +// built. Issue is non-empty when part of the conversation could not be read; the +// facts that could be are still here. +type OwnerFacts struct { + Owner string + Stamp string + Facts []workgraph.Fact + Issue string + + byID map[string]int32 +} + +// Find is the fact for a task token ("12" or "t-ab"), if the conversation has it. +func (o *OwnerFacts) Find(task string) (*workgraph.Fact, bool) { + i, ok := o.byID[task] + if !ok { + return nil, false + } + return &o.Facts[i], true +} + +type ownerEntry struct { + stamp string + facts *OwnerFacts +} + +// CheckIndex is the recorded check runs of a project's landed work, keyed by +// the conversation and task that earned them. +type CheckIndex struct { + Stamp string + by map[string]*workgraph.TaskChecks +} + +// Of is the recorded checks of one task, or nil when the index has none. +func (c *CheckIndex) Of(owner, task string) *workgraph.TaskChecks { + if c == nil { + return nil + } + return c.by[owner+":"+task] +} + +type indexEntry struct { + stamp string + index *CheckIndex +} + +// WorkSource reads and caches conversations' task facts. The zero value is not +// usable; use [NewWorkSource]. +type WorkSource struct { + mu sync.Mutex + owners map[string]*ownerEntry + index map[string]*indexEntry + parses atomic.Int64 +} + +// NewWorkSource is an empty source. +func NewWorkSource() *WorkSource { + return &WorkSource{owners: map[string]*ownerEntry{}, index: map[string]*indexEntry{}} +} + +// Parses is how many conversation projections have been built from disk, for +// tests and diagnostics: an unchanged conversation never adds to it. +func (s *WorkSource) Parses() int64 { return s.parses.Load() } + +// workSources are the files a conversation's tasks come from, in a fixed order. +func workSources(dir string) []string { + db := filepath.Join(dir, planStoreFilename) + files := []string{ + Place{Dir: dir}.Tasks(), filepath.Join(dir, taskDeletedFile), db, db + "-wal", + } + return append(files, planArchivePaths(db)...) +} + +// Stamp names the sources of the conversation in dir as they are right now: stat +// calls only. The empty string is a folder that is not there. +func (s *WorkSource) Stamp(dir string) string { + if info, err := os.Stat(dir); err != nil || !info.IsDir() { + return "" + } + var b strings.Builder + for _, file := range workSources(dir) { + b.WriteString(wgStamp(file)) + b.WriteByte(';') + } + return b.String() +} + +// Owner is the facts of the conversation owner, whose folder is dir. The same +// stamp answers from memory; a changed one is read once, by whoever asks first, +// and the others get that answer. A cancelled ctx stops the read between its +// files and caches nothing. +func (s *WorkSource) Owner(ctx context.Context, owner, dir string) (*OwnerFacts, error) { + if !workgraph.ValidOwner(owner) { + return nil, ErrWorkOwnerMissing + } + stamp := s.Stamp(dir) + if stamp == "" { + return nil, ErrWorkOwnerMissing + } + s.mu.Lock() + if e := s.owners[dir]; e != nil && e.stamp == stamp && e.facts.Owner == owner { + facts := e.facts + s.mu.Unlock() + return facts, nil + } + s.mu.Unlock() + // A READ MUST NOT BE CACHED UNDER A STAMP THE READ ITSELF MOVED. Opening a + // WAL database to look at it can create its side files, so the stamp taken + // before the read is not the one the files settle at; the read is made again + // under the new one, and a result is kept only when the files held still + // across it. A conversation that is being written to as it is read is served + // from what was read and not remembered. + var facts *OwnerFacts + for attempt := 0; attempt < 2; attempt++ { + var err error + if facts, err = readOwner(ctx, owner, dir, stamp); err != nil { + return nil, err + } + s.parses.Add(1) + if after := s.Stamp(dir); after == stamp { + s.mu.Lock() + s.owners[dir] = &ownerEntry{stamp: stamp, facts: facts} + s.mu.Unlock() + break + } else { + stamp = after + } + } + return facts, nil +} + +// Forget drops what is held for the conversation in dir. +func (s *WorkSource) Forget(dir string) { + s.mu.Lock() + delete(s.owners, dir) + s.mu.Unlock() +} + +// readOwner builds one conversation's projection from its files. +func readOwner(ctx context.Context, owner, dir, stamp string) (*OwnerFacts, error) { + out := &OwnerFacts{Owner: owner, Stamp: stamp} + doc, issue := readTaskDocument(dir) + out.Issue = issue + titles := wgRunTitles(doc.Runs) + for _, r := range doc.Nodes { + out.Facts = append(out.Facts, nodeFact(owner, r)) + } + for _, r := range doc.Runs { + out.Facts = append(out.Facts, runFact(owner, r, titles)) + } + if err := ctx.Err(); err != nil { + return nil, err + } + if plan := readPlanFacts(ctx, owner, dir); plan.issue != "" { + out.Issue = firstIssue(out.Issue, plan.issue) + } else { + out.Facts = append(out.Facts, plan.facts...) + } + if err := ctx.Err(); err != nil { + return nil, err + } + out.byID = make(map[string]int32, len(out.Facts)) + for i := range out.Facts { + out.byID[out.Facts[i].Node.Ref.Task] = int32(i) + } + return out, nil +} + +func firstIssue(a, b string) string { + if a != "" { + return a + } + return b +} + +// readTaskDocument is the conversation's tasks.json exactly as the engine +// itself would load it (validated, with the person's deletions applied). A +// folder with no checkpoint has no tasks and no issue; a checkpoint that will not +// parse is an issue and no tasks, the way the engine drops it whole. +func readTaskDocument(dir string) (taskDocument, string) { + file := Place{Dir: dir}.Tasks() + info, err := os.Stat(file) + switch { + case os.IsNotExist(err): + return taskDocument{}, "" + case err != nil: + return taskDocument{}, WorkIssueUnreadable + case info.Size() > workFileMax: + return taskDocument{}, WorkIssueTooLarge + } + content, err := os.ReadFile(file) + if err != nil { + return taskDocument{}, WorkIssueUnreadable + } + doc, err := decodeTasks(content) + if err != nil { + return taskDocument{}, WorkIssueUnreadable + } + return filterDeletedDocument(doc, taskDeletions(filepath.Join(dir, placeTranscript))), "" +} + +// planRead is what the conversation's plan stores gave. +type planRead struct { + facts []workgraph.Fact + issue string +} + +// readPlanFacts reads every plan store of the conversation, ended runs first, +// each through a read-only connection and only the rows this conversation's +// own tag names. A store that will not open is an issue, and the conversation +// then says so rather than showing a plan with a hole in it. +func readPlanFacts(ctx context.Context, owner, dir string) planRead { + db := filepath.Join(dir, planStoreFilename) + var out planRead + paths := append(planArchivePaths(db), db) + for _, path := range paths { + if ctx.Err() != nil { + return out + } + if _, err := os.Stat(path); err != nil { + continue + } + facts, err := planStoreFacts(owner, path) + if err != nil { + return planRead{issue: WorkIssueUnreadable} + } + out.facts = append(out.facts, facts...) + } + return out +} + +// planStoreFacts is one store's rows for the owner. +func planStoreFacts(owner, path string) ([]workgraph.Fact, error) { + read, err := plandb.ReadTasks(path) + if err != nil { + return nil, err + } + spend, usage := planSpendByTask(path), planUsageByTask(path) + var group *workgraph.GroupRef + for _, t := range read.Tasks { + if t.ID == read.RootID { + group = &workgraph.GroupRef{ID: owner + ":" + wgPlanToken(t.ID), Title: wgTitle(t.Title)} + } + } + var out []workgraph.Fact + for _, t := range read.Tasks { + if t.Chat == owner { + out = append(out, planFact(owner, t, group, spend, usage)) + } + } + return out, nil +} + +// Checks is the project's check index at path, parsed once per change. A missing +// index is an empty one. +func (s *WorkSource) Checks(ctx context.Context, path string) (*CheckIndex, error) { + stamp := wgStamp(path) + s.mu.Lock() + if e := s.index[path]; e != nil && e.stamp == stamp { + index := e.index + s.mu.Unlock() + return index, nil + } + s.mu.Unlock() + index, err := readCheckIndex(ctx, path, stamp) + if err != nil { + return nil, err + } + s.mu.Lock() + s.index[path] = &indexEntry{stamp: stamp, index: index} + s.mu.Unlock() + return index, nil +} + +// readCheckIndex streams the index and keeps only the rows whose verdict rested +// on running the declared checks, so its memory is the checks and not the file. +func readCheckIndex(ctx context.Context, path, stamp string) (*CheckIndex, error) { + out := &CheckIndex{Stamp: stamp, by: map[string]*workgraph.TaskChecks{}} + file, err := os.Open(path) + if err != nil { + if os.IsNotExist(err) { + return out, nil + } + return nil, err + } + defer file.Close() + scanner := bufio.NewScanner(file) + scanner.Buffer(make([]byte, 0, 4<<10), 256<<10) + for n := 0; scanner.Scan(); n++ { + if n%1024 == 0 && ctx.Err() != nil { + return nil, ctx.Err() + } + var row struct { + ID string `json:"id"` + SessionID string `json:"sessionId"` + VerdictBasis *plandb.VerdictBasis `json:"verdictBasis"` + } + if json.Unmarshal(scanner.Bytes(), &row) != nil || row.SessionID == "" { + continue + } + if checks := wgChecks(row.VerdictBasis); checks != nil { + out.by[row.SessionID+":"+row.ID] = checks + } + } + return out, nil +} + +// ExternalReader is the [ExternalTasks] reader a process supplies to its +// conversations, over this source: locate names the folder of a conversation id +// (and false for one it cannot find). It answers from the same cached +// projections the graph uses, so asking is a stat and, at most, one parse of a +// conversation that moved. +func (s *WorkSource) ExternalReader(locate func(owner string) (string, bool)) ExternalTasks { + return func(ctx context.Context, r TaskRef) (ExternalTask, error) { + dir, ok := locate(r.Session) + if !ok { + return ExternalTask{}, fmt.Errorf("no conversation %s", r.Session) + } + facts, err := s.Owner(ctx, r.Session, dir) + if err != nil { + return ExternalTask{}, err + } + fact, ok := facts.Find(strconv.FormatUint(r.ID, 10)) + if !ok { + if facts.Issue != "" { + return ExternalTask{}, fmt.Errorf("conversation %s is %s", r.Session, facts.Issue) + } + return ExternalTask{}, nil + } + return ExternalTask{Exists: true, State: TaskState(fact.Node.State), Waits: waitsOf(r.Session, fact)}, nil + } +} + +// waitsOf is a fact's stored waits as task references, for the loop check. +func waitsOf(session string, fact *workgraph.Fact) []TaskRef { + var out []TaskRef + for _, w := range fact.Waits { + id, err := strconv.ParseUint(w.On.Task, 10, 64) + if err != nil { + continue + } + out = append(out, TaskRef{Session: w.On.Owner, ID: id}) + } + return out +} diff --git a/internal/session/workgraph_source_test.go b/internal/session/workgraph_source_test.go new file mode 100644 index 0000000000..8fd46ecede --- /dev/null +++ b/internal/session/workgraph_source_test.go @@ -0,0 +1,303 @@ +package session + +// THE SOURCE READS WHAT THE APP LISTS. Every fixture here is a conversation +// that really ran its graph, wrote its checkpoint and its plan store, and was +// then read back off the disk by a source that never touched the live agent. + +import ( + "context" + "os" + "path/filepath" + "sort" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/plandb" + "github.com/Agent-Field/codeaf/internal/workgraph" +) + +const sourceOwner = "aaaaaaaaaaaaaaaa" + +// sourceFixture is a conversation with a three-level family of tasks, two +// handed-over run rows, a plan store three levels deep with a hard dependency, +// and a link to a task of another conversation. +type sourceFixture struct { + agent *Agent + graph *TaskGraph + folder string +} + +func newSourceFixture(t *testing.T) sourceFixture { + t.Helper() + folder := filepath.Join(t.TempDir(), sourceOwner) + place := Place{Dir: folder} + elsewhere := &fakeElsewhere{tasks: map[TaskRef]ExternalTask{elsewhereTask: {Exists: true, State: TaskRunning}}} + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { + config.SessionFile = place.Transcript() + config.Place = place + config.ExternalTasks = elsewhere.read + }) + seedPlanStore(t, filepath.Join(folder, planStoreFilename), sourceOwner, + plandb.TaskSpec{ID: "a", Title: "Plan a"}, + plandb.TaskSpec{ID: "a1", Title: "Plan a one", ParentID: "a"}, + plandb.TaskSpec{ID: "a1x", Title: "Plan a one x", ParentID: "a1"}, + plandb.TaskSpec{ID: "b", Title: "Plan b", Dependencies: []plandb.Dependency{{TaskID: "a", Kind: plandb.DepBlocks}, {TaskID: "a1", Kind: plandb.DepSuggests}}}, + ) + armPlanStore(t, agent, filepath.Join(folder, planStoreFilename), sourceOwner) + graph := agent.graph() + hold := make(chan struct{}) + t.Cleanup(func() { close(hold) }) + graph.mu.Lock() + graph.run = func(node *TaskNode) { + if node.id == 1 { + <-hold + } + node.finish("done", []string{"src/a.go", "/etc/passwd", "../outside"}, "task/x", mergeMerged) + node.graph.complete(node, TaskDone) + } + graph.mu.Unlock() + spec := func(parent uint64, depth int, deps ...uint64) taskSpec { + return taskSpec{title: "task", summary: "s", brief: "do it", acceptance: "a", parent: parent, depth: depth, dependsOn: deps} + } + graph.admit(graph.reserve(), spec(0, 1)) + graph.admit(graph.reserve(), spec(1, 2)) + graph.admit(graph.reserve(), spec(2, 3, 1)) + two := graph.reserve() + graph.keepRunRows(two, []TaskNotice{ + {ID: two, Run: "r1", Title: "Adaptive run", State: TaskDone}, + {ID: graph.reserve(), Run: "r1", Parent: two, Title: "Worker row", State: TaskDone}, + }) + return sourceFixture{agent: agent, graph: graph, folder: folder} +} + +func factIDs(facts []workgraph.Fact, skipQuick bool) []string { + var ids []string + for _, f := range facts { + if skipQuick && f.Node.Kind == workgraph.KindQuick { + continue + } + ids = append(ids, f.Node.Ref.Task) + } + sort.Strings(ids) + return ids +} + +// EVERY ROW tasks.list SHOWS IS IN THE GRAPH: the graph's nodes, the rows of the +// conversation's runs and the sub-tasks of its plan store, at every level, +// compared with the live agent's own AppTasks and then again after a reopen. +func TestTheSourceHoldsEveryRowTheAppLists(t *testing.T) { + f := newSourceFixture(t) + awaitRecord(t, taskCheckpointPath(Place{Dir: f.folder}.Transcript()), 3, func(r taskRecord) bool { return r.State == TaskQueued }, "queued behind task 1") + rows, err := f.agent.AppTasks() + if err != nil { + t.Fatal(err) + } + var live []string + for _, row := range rows { + live = append(live, row.ID) + } + sort.Strings(live) + if len(live) < 9 { + t.Fatalf("the fixture is not multi-level: %v", live) + } + source := NewWorkSource() + owner, err := source.Owner(context.Background(), sourceOwner, f.folder) + if err != nil { + t.Fatal(err) + } + if got := factIDs(owner.Facts, true); strings.Join(got, ",") != strings.Join(live, ",") { + t.Fatalf("the graph holds %v, the app lists %v", got, live) + } + if owner.Issue != "" { + t.Fatalf("issue = %q", owner.Issue) + } + // Family, links, groups and the plan's own structure are the records'. + byTask := func(id string) workgraph.GraphNode { + fact, ok := owner.Find(id) + if !ok { + t.Fatalf("no fact %s", id) + } + return fact.Node + } + if n := byTask("3"); n.Parent != sourceOwner+":2" || n.Depth != 3 || n.Links != "recorded" { + t.Fatalf("task 3 = %+v", n) + } + if fact, _ := owner.Find("3"); len(fact.Waits) != 1 || fact.Waits[0].On.ID() != sourceOwner+":1" || fact.Waits[0].Via != workgraph.ViaRecord { + t.Fatalf("task 3 waits = %+v", fact.Waits) + } + if n := byTask("t-a1x"); n.Parent != sourceOwner+":t-a1" || n.Kind != "plan" || n.Group == nil || n.Group.ID != sourceOwner+":t-"+planRootID { + t.Fatalf("plan row = %+v", n) + } + fact, _ := owner.Find("t-b") + if len(fact.Waits) != 1 || fact.Waits[0].On.Task != "t-a" { + t.Fatalf("plan b waits (suggests must not be a link) = %+v", fact.Waits) + } + if run := byTask("5"); run.Links != "unrecorded" || run.Group == nil || run.Group.Title != "Adaptive run" { + t.Fatalf("run worker row = %+v", run) + } + + // REOPEN: a new agent on the same folder lists the same rows, and the + // source's answer does not depend on which of them asked. + f.agent.Close() + reopened, _ := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { + config.SessionFile = Place{Dir: f.folder}.Transcript() + config.Place = Place{Dir: f.folder} + }) + armPlanStore(t, reopened, filepath.Join(f.folder, planStoreFilename), sourceOwner) + again, _ := reopened.AppTasks() + var after []string + for _, row := range again { + after = append(after, row.ID) + } + sort.Strings(after) + reread, err := NewWorkSource().Owner(context.Background(), sourceOwner, f.folder) + if err != nil { + t.Fatal(err) + } + if got := factIDs(reread.Facts, true); strings.Join(got, ",") != strings.Join(after, ",") { + t.Fatalf("after a reopen the graph holds %v, the app lists %v", got, after) + } +} + +// Landed work says where it landed; paths outside the workspace are withheld +// and counted; a clock is only what the record carries. +func TestTheSourceStatesLandingFilesAndTheRecordedClock(t *testing.T) { + f := newSourceFixture(t) + awaitRecord(t, taskCheckpointPath(Place{Dir: f.folder}.Transcript()), 2, func(r taskRecord) bool { return r.State == TaskDone }, "done") + owner, err := NewWorkSource().Owner(context.Background(), sourceOwner, f.folder) + if err != nil { + t.Fatal(err) + } + fact, _ := owner.Find("2") + n := fact.Node + if n.Landing == nil || n.Landing.Outcome != "merged" { + t.Fatalf("landing = %+v", n.Landing) + } + if n.Files.Total != 1 || n.Files.Paths[0] != "src/a.go" || n.Files.Omitted != 2 || n.Files.Source != "changed" { + t.Fatalf("files = %+v", n.Files) + } + if n.Clock.AdmittedAt == nil || n.Clock.StateAt == nil || n.Clock.EndedAt == nil { + t.Fatalf("clock = %+v", n.Clock) + } + if n.Clock.AdmittedAt.After(time.Now()) || n.Clock.StateAt.Before(*n.Clock.AdmittedAt) { + t.Fatalf("clock is not the events': %+v", n.Clock) + } + // A plan row never carries a start it was not given. + plan, _ := owner.Find("t-a") + if plan.Node.Clock.StartedAt != nil || plan.Node.Clock.AdmittedAt == nil { + t.Fatalf("plan clock = %+v", plan.Node.Clock) + } +} + +// An unchanged conversation is never reparsed; a changed one is read once. +func TestTheSourceNeverReparsesAnUnchangedConversation(t *testing.T) { + f := newSourceFixture(t) + source := NewWorkSource() + ctx := context.Background() + // The fixture's own tasks are still landing; wait until the files hold still. + for last, still := "", 0; still < 5; time.Sleep(20 * time.Millisecond) { + if now := source.Stamp(f.folder); now == last { + still++ + } else { + last, still = now, 0 + } + } + // The first read may read twice (opening a WAL store creates its side files); + // after it, an unchanged folder costs nothing more. + if _, err := source.Owner(ctx, sourceOwner, f.folder); err != nil { + t.Fatal(err) + } + base := source.Parses() + for i := 0; i < 5; i++ { + if _, err := source.Owner(ctx, sourceOwner, f.folder); err != nil { + t.Fatal(err) + } + } + if source.Parses() != base { + t.Fatalf("parses went %d -> %d across five reads of an unchanged folder", base, source.Parses()) + } + before := source.Stamp(f.folder) + f.graph.admit(f.graph.reserve(), taskSpec{title: "later", summary: "s", brief: "b", acceptance: "a", depth: 1}) + if source.Stamp(f.folder) == before { + t.Fatal("an admission left the stamp as it was") + } + if _, err := source.Owner(ctx, sourceOwner, f.folder); err != nil { + t.Fatal(err) + } + if source.Parses() <= base { + t.Fatalf("a change was not read: parses = %d", source.Parses()) + } +} + +// A folder that is not there is missing, a name that could walk out of the +// places root is refused, and a corrupt checkpoint is an issue and not a +// guess. +func TestTheSourceReportsWhatItCannotRead(t *testing.T) { + source := NewWorkSource() + ctx := context.Background() + if _, err := source.Owner(ctx, sourceOwner, filepath.Join(t.TempDir(), "absent")); err != ErrWorkOwnerMissing { + t.Fatalf("absent folder: %v", err) + } + if _, err := source.Owner(ctx, "../etc", t.TempDir()); err != ErrWorkOwnerMissing { + t.Fatalf("unsafe owner: %v", err) + } + folder := filepath.Join(t.TempDir(), sourceOwner) + if err := os.MkdirAll(folder, 0o755); err != nil { + t.Fatal(err) + } + empty, err := source.Owner(ctx, sourceOwner, folder) + if err != nil || len(empty.Facts) != 0 || empty.Issue != "" { + t.Fatalf("a conversation with no checkpoint has no tasks and no issue: %+v %v", empty, err) + } + writeFile(t, Place{Dir: folder}.Tasks(), "{not json") + bad, err := source.Owner(ctx, sourceOwner, folder) + if err != nil || bad.Issue != WorkIssueUnreadable || len(bad.Facts) != 0 { + t.Fatalf("corrupt checkpoint: %+v %v", bad, err) + } + cancelled, cancel := context.WithCancel(ctx) + cancel() + folder2 := filepath.Join(t.TempDir(), sourceOwner) + _ = os.MkdirAll(folder2, 0o755) + if _, err := source.Owner(cancelled, sourceOwner, folder2); err == nil { + t.Fatal("a cancelled read answered") + } +} + +// The external reader answers from the same projections: a task's state and its +// stored waits, "does not exist" for a conversation that was read and lacks the +// task, and an error for a conversation that cannot be read. +func TestTheExternalReaderAnswersFromTheSource(t *testing.T) { + f := newSourceFixture(t) + source := NewWorkSource() + read := source.ExternalReader(func(owner string) (string, bool) { + if owner == sourceOwner { + return f.folder, true + } + return "", false + }) + ctx := context.Background() + got, err := read(ctx, TaskRef{Session: sourceOwner, ID: 3}) + if err != nil || !got.Exists || got.State != TaskQueued || len(got.Waits) != 1 || got.Waits[0] != (TaskRef{Session: sourceOwner, ID: 1}) { + t.Fatalf("task 3 = %+v %v", got, err) + } + if got, err = read(ctx, TaskRef{Session: sourceOwner, ID: 999}); err != nil || got.Exists { + t.Fatalf("task 999 = %+v %v", got, err) + } + if _, err = read(ctx, TaskRef{Session: "bbbbbbbbbbbbbbbb", ID: 1}); err == nil { + t.Fatal("an unknown conversation was answered") + } +} + +func TestRelativePathsOnly(t *testing.T) { + for raw, want := range map[string]string{"a/b.go": "a/b.go", "./a//b.go": "a/b.go", "a\\b.go": "a/b.go"} { + if got, ok := wgRelPath(raw); !ok || got != want { + t.Errorf("%q = %q %v", raw, got, ok) + } + } + for _, raw := range []string{"", "/etc/x", "../x", "a/../../x", "C:/x", "x\x00y", ".", ".."} { + if _, ok := wgRelPath(raw); ok { + t.Errorf("%q was accepted", raw) + } + } +} diff --git a/internal/session/workgraph_stamp_unix.go b/internal/session/workgraph_stamp_unix.go new file mode 100644 index 0000000000..2f6762d9d9 --- /dev/null +++ b/internal/session/workgraph_stamp_unix.go @@ -0,0 +1,24 @@ +//go:build !windows + +package session + +import ( + "fmt" + "os" + "syscall" +) + +// wgStamp names a file's present state by size, modification instant and inode, +// and "-" for a file that is not there. The checkpoint is replaced by rename, so +// a rewrite changes the inode even when size and second are the same. +func wgStamp(path string) string { + info, err := os.Stat(path) + if err != nil { + return "-" + } + var ino uint64 + if st, ok := info.Sys().(*syscall.Stat_t); ok { + ino = uint64(st.Ino) + } + return fmt.Sprintf("%d/%d/%d", info.Size(), info.ModTime().UnixNano(), ino) +} diff --git a/internal/session/workgraph_stamp_windows.go b/internal/session/workgraph_stamp_windows.go new file mode 100644 index 0000000000..72ed940166 --- /dev/null +++ b/internal/session/workgraph_stamp_windows.go @@ -0,0 +1,18 @@ +//go:build windows + +package session + +import ( + "fmt" + "os" +) + +// wgStamp names a file's present state by size and modification instant, and +// "-" for a file that is not there. +func wgStamp(path string) string { + info, err := os.Stat(path) + if err != nil { + return "-" + } + return fmt.Sprintf("%d/%d", info.Size(), info.ModTime().UnixNano()) +} From 9c498fc5fb1058784b45af4787849ae3cfaceac0 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 23:10:35 -0400 Subject: [PATCH 125/459] appwire: work.graph, the optional read of work that waits on other work Co-Authored-By: Claude Sonnet 5.5 --- internal/appwire/server.go | 1 + internal/appwire/workgraph.go | 79 +++++++++++++++++ internal/appwire/workgraph_test.go | 136 +++++++++++++++++++++++++++++ 3 files changed, 216 insertions(+) create mode 100644 internal/appwire/workgraph.go create mode 100644 internal/appwire/workgraph_test.go diff --git a/internal/appwire/server.go b/internal/appwire/server.go index 23b637e088..b775c00b27 100644 --- a/internal/appwire/server.go +++ b/internal/appwire/server.go @@ -145,6 +145,7 @@ func (s *Server) Serve(ctx context.Context, in io.Reader, hello Hello, engine En hello.Contract = Contract hello = withCollectionsFeature(hello, engine) hello = withMessageReadsFeature(hello, engine) + hello = withWorkGraphFeature(hello, engine) s.Emit(EventHello, hello) if warmer, ok := engine.(Warmer); ok { go warmer.Warm() diff --git a/internal/appwire/workgraph.go b/internal/appwire/workgraph.go new file mode 100644 index 0000000000..f8e0a7f5ae --- /dev/null +++ b/internal/appwire/workgraph.go @@ -0,0 +1,79 @@ +package appwire + +import ( + "context" + "errors" + + "github.com/Agent-Field/codeaf/internal/workgraph" +) + +// WORK.GRAPH: the facts of work that waits on other work — tasks, who owns +// them, which waits on which, what state each is in and since when, what each +// changed and cost — as pages bounded in nodes, edges and words, with true +// totals and an honest account of how much was read. It is GENERAL: the types +// are internal/workgraph's, which knows no column, coordinate, colour or card; +// a surface computes every one of those from the facts. The call is read-only. + +// FeatureWorkGraph is the hello feature an engine serving work.graph announces. +const FeatureWorkGraph = "work.graph" + +// WorkGrapher is the optional engine half of work.graph. One that is not a +// WorkGrapher refuses work.graph as unknown. +type WorkGrapher interface { + // WorkGraph answers one request. It honours ctx: a cancelled or expired + // context ends the read and the answer is the context's error. + WorkGraph(ctx context.Context, req workgraph.GraphRequest) (*workgraph.GraphResult, error) +} + +// withWorkGraphFeature is hello carrying [FeatureWorkGraph] when the engine +// serves it. +func withWorkGraphFeature(hello Hello, e Engine) Hello { + if _, ok := e.(WorkGrapher); !ok { + return hello + } + return withFeature(hello, FeatureWorkGraph) +} + +// workGraphError is a graph error in the wire's words. +func workGraphError(err error) error { + var wire *Error + switch { + case errors.As(err, &wire): + return err + case errors.Is(err, workgraph.ErrBadRequest): + return Errorf(CodeBadParams, "%s", trimPrefix(err.Error(), "workgraph: bad request: ")) + case errors.Is(err, workgraph.ErrNotFound): + return Errorf(CodeNotFound, "%s", trimPrefix(err.Error(), "workgraph: not found: ")) + case errors.Is(err, context.DeadlineExceeded): + return Errorf(CodeEngine, "work.graph took longer than %s", workgraph.BuildTimeout) + case errors.Is(err, context.Canceled): + return Errorf(CodeEngine, "work.graph was cancelled") + } + return err +} + +func trimPrefix(s, prefix string) string { + if len(s) >= len(prefix) && s[:len(prefix)] == prefix { + return s[len(prefix):] + } + return s +} + +func init() { + methods["work.graph"] = typed(func(e Engine, p workgraph.GraphRequest) (*workgraph.GraphResult, error) { + g, ok := e.(WorkGrapher) + if !ok { + return nil, Errorf(CodeUnknownMethod, "this engine does not serve work.graph") + } + if err := workgraph.ValidateRequest(p); err != nil { + return nil, workGraphError(err) + } + ctx, cancel := context.WithTimeout(context.Background(), workgraph.BuildTimeout) + defer cancel() + res, err := g.WorkGraph(ctx, p) + if err != nil { + return nil, workGraphError(err) + } + return res, nil + }) +} diff --git a/internal/appwire/workgraph_test.go b/internal/appwire/workgraph_test.go new file mode 100644 index 0000000000..4ee7cd5524 --- /dev/null +++ b/internal/appwire/workgraph_test.go @@ -0,0 +1,136 @@ +package appwire + +import ( + "bufio" + "context" + "encoding/json" + "io" + "testing" + + "github.com/Agent-Field/codeaf/internal/workgraph" +) + +// graphEngine serves work.graph from a real snapshot built over three tasks, so +// the roundtrip below is the engine's own paging, cursor and bounds and not a +// canned answer. +type graphEngine struct { + *fakeEngine + snap *workgraph.Snapshot + err error + ctx context.Context +} + +func (g *graphEngine) WorkGraph(ctx context.Context, req workgraph.GraphRequest) (*workgraph.GraphResult, error) { + g.ctx = ctx + if g.err != nil { + return nil, g.err + } + return g.snap.Answer(ctx, req) +} + +func buildGraph(t *testing.T) *workgraph.Snapshot { + t.Helper() + node := func(seq uint64) workgraph.Fact { + ref := workgraph.TaskRef{Owner: "agent", Task: string(rune('0' + seq))} + return workgraph.Fact{Node: workgraph.GraphNode{ID: ref.ID(), Ref: ref, Seq: seq, Kind: "task", Title: "t", State: workgraph.StateQueued, Links: "recorded"}} + } + second := node(2) + second.Waits = []workgraph.Wait{{On: workgraph.TaskRef{Owner: "agent", Task: "1"}, Via: workgraph.ViaRecord}} + snap, err := workgraph.Build(context.Background(), workgraph.Input{ + Scope: workgraph.GraphScope{Kind: workgraph.ScopeAgent, ID: "agent"}, Revision: "r1", + Facts: []workgraph.Fact{node(1), second, node(3)}, Owners: 1, + }) + if err != nil { + t.Fatal(err) + } + return snap +} + +func startGraphWire(t *testing.T, engine Engine) *wire { + t.Helper() + inR, inW := io.Pipe() + outR, outW := io.Pipe() + served := make(chan error, 1) + server := NewServer(outW) + go func() { + served <- server.Serve(context.Background(), inR, Hello{Version: "test"}, engine) + outW.Close() + }() + scanner := bufio.NewScanner(outR) + scanner.Buffer(make([]byte, 4<<20), 4<<20) + t.Cleanup(func() { inW.Close() }) + return &wire{t: t, in: inW, lines: scanner, served: served} +} + +// WORK.GRAPH IS ANNOUNCED BY AN ENGINE THAT SERVES IT, answers a real page with +// its edge and a cursor that continues, refuses a tampered cursor as bad_params, +// carries a deadline into the engine, and is unknown to an engine that cannot. +func TestWorkGraphRoundtrip(t *testing.T) { + engine := &graphEngine{fakeEngine: &fakeEngine{}, snap: buildGraph(t)} + w := startGraphWire(t, engine) + found := false + for _, f := range features(w.next()) { + found = found || f == FeatureWorkGraph + } + if !found { + t.Fatal("hello does not announce work.graph") + } + w.send(`{"id":1,"method":"work.graph","params":{"scope":{"kind":"agent","id":"agent"},"limit":2}}`) + first := decodeGraph(t, w.next()) + if first.Contract != workgraph.Contract || len(first.Nodes) != 2 || first.Next == "" || first.Totals.Nodes != 3 || len(first.Edges) != 1 { + t.Fatalf("first page = %+v", first) + } + if first.Edges[0].From != "agent:2" || first.Edges[0].To != "agent:1" { + t.Fatalf("direction must be dependent -> prerequisite: %+v", first.Edges[0]) + } + if dl, ok := engine.ctx.Deadline(); !ok || dl.IsZero() { + t.Fatal("the engine was handed no deadline") + } + cursor, _ := json.Marshal(first.Next) + w.send(`{"id":2,"method":"work.graph","params":{"scope":{"kind":"agent","id":"agent"},"limit":2,"cursor":` + string(cursor) + `}}`) + if second := decodeGraph(t, w.next()); len(second.Nodes) != 1 || second.Next != "" { + t.Fatalf("second page = %+v", second) + } + w.send(`{"id":3,"method":"work.graph","params":{"scope":{"kind":"agent","id":"agent"},"cursor":"tampered"}}`) + if got := w.next(); got["error"].(map[string]any)["code"] != CodeBadParams { + t.Fatalf("tampered cursor = %v", got) + } + w.send(`{"id":4,"method":"work.graph","params":{"scope":{"kind":"nowhere","id":"x"}}}`) + if got := w.next(); got["error"].(map[string]any)["code"] != CodeBadParams { + t.Fatalf("bad scope = %v", got) + } + w.send(`{"id":5,"method":"work.graph","params":{"scope":{"kind":"agent","id":"agent"},"view":"metrics","if_revision":"` + first.Revision + `"}}`) + if got := decodeGraph(t, w.next()); !got.Unchanged || len(got.Nodes) != 0 { + t.Fatalf("unchanged answer = %+v", got) + } +} + +func TestWorkGraphErrorsKeepTheirWireCode(t *testing.T) { + for want, err := range map[string]error{ + CodeNotFound: workgraph.ErrNotFound, + CodeEngine: context.DeadlineExceeded, + CodeBusy: Errorf(CodeBusy, "busy"), + } { + engine := &graphEngine{fakeEngine: &fakeEngine{}, err: err} + _, got := methods["work.graph"].call(engine, json.RawMessage(`{"scope":{"kind":"agent","id":"a"}}`)) + if w, ok := got.(*Error); !ok || w.Code != want { + t.Errorf("%v became %v, want %s", err, got, want) + } + } + if _, err := methods["work.graph"].call(&fakeEngine{}, json.RawMessage(`{"scope":{"kind":"agent","id":"a"}}`)); err == nil || err.(*Error).Code != CodeUnknownMethod { + t.Fatalf("an engine that does not serve it answered %v", err) + } +} + +func decodeGraph(t *testing.T, line map[string]any) workgraph.GraphResult { + t.Helper() + if line["error"] != nil { + t.Fatalf("error: %v", line["error"]) + } + raw, _ := json.Marshal(line["result"]) + var out workgraph.GraphResult + if err := json.Unmarshal(raw, &out); err != nil { + t.Fatal(err) + } + return out +} From 84b65bceb65b9d6c3cae10426cb700d8aa3ec1f3 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 23:14:29 -0400 Subject: [PATCH 126/459] app: serve work.graph from the real backend, and give conversations the cross-conversation reader The backend resolves a scope (agent, task or crew) into conversation folders, revisions it from file stamps alone, and builds one snapshot per revision. Every conversation the process opens reads other conversations' tasks through the same cached source the graph uses. Co-Authored-By: Claude Sonnet 5.5 --- cmd/codeaf/appworkgraph.go | 519 +++++++++++++++++++++++++++ cmd/codeaf/appworkgraph_test.go | 226 ++++++++++++ cmd/codeaf/chatv3_orchestrate.go | 5 + internal/session/workgraph_source.go | 19 + 4 files changed, 769 insertions(+) create mode 100644 cmd/codeaf/appworkgraph.go create mode 100644 cmd/codeaf/appworkgraph_test.go diff --git a/cmd/codeaf/appworkgraph.go b/cmd/codeaf/appworkgraph.go new file mode 100644 index 0000000000..5762876dbb --- /dev/null +++ b/cmd/codeaf/appworkgraph.go @@ -0,0 +1,519 @@ +package main + +// WORK.GRAPH: the app backend's door onto internal/workgraph. +// +// This file does the three things only the app backend can: it says which +// conversations a scope is made of (an agent is itself, a task is its owner, a +// crew is every member of it and of the crews under it), it says where each +// one's folder is, and it hands the generic builder what the session package +// read off those folders. It reads FILES ONLY — no live agent is asked, no +// session or backend lock is held across any read — so the graph of a crew of +// two hundred conversations costs the running work nothing and answers the same +// for a conversation that is closed. +// +// THE REVISION IS STAT CALLS. It names the stamps of every source the answer +// is built from (each conversation's checkpoint and plan stores, the project +// check indexes, the crews' file, and the conversations the scope's tasks wait +// on), so "nothing moved" is known without reading or building anything and a +// caller holding the revision is answered `unchanged` for the price of the +// stats. A moved conversation is parsed once; the snapshot is rebuilt once per +// revision no matter how many callers ask. + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "sort" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/session" + teamstore "github.com/Agent-Field/codeaf/internal/teams" + "github.com/Agent-Field/codeaf/internal/workgraph" +) + +// The bounds a scope is read within. +const ( + // graphMaxOwners is the most conversations one scope reads. + graphMaxOwners = 2000 + // graphMaxExternal is the most other conversations a scope reads only to + // say the state of a task its own tasks wait on. + graphMaxExternal = 64 + // graphCacheEntries and graphCacheNodes bound the snapshots held. + graphCacheEntries = 8 + graphCacheNodes = 600_000 + // graphMaxUnread is how many unreadable conversations an answer names. + graphMaxUnread = 16 +) + +// workGraphs is this process's graph state, ONE for the process: the engine's +// own frontier reads other conversations' tasks through it (v3OpenSession) and +// the app's work.graph reads through it, so the two see the same cached +// projections and a conversation's files are parsed once however many ask. +var workGraphs appWorkGraph + +// appWorkGraph is the process's graph state; its zero value is usable. +type appWorkGraph struct { + once sync.Once + source *session.WorkSource + cache *workgraph.Cache + + mu sync.Mutex + dirs map[string]string + crews crewMemo +} + +// crewMemo is the crews' file as last read, by its stamp. +type crewMemo struct { + stamp string + file *teamstore.File +} + +func (g *appWorkGraph) init() { + g.once.Do(func() { + g.source = session.NewWorkSource() + g.cache = workgraph.NewCache(graphCacheEntries, graphCacheNodes) + g.dirs = map[string]string{} + }) +} + +// graphOwner is one conversation a scope reads. +type graphOwner struct{ id, dir string } + +// graphPlan is what a request resolves to before anything is read. +type graphPlan struct { + scope workgraph.GraphScope + about workgraph.ScopeFacts + root string + owners []graphOwner + missing []workgraph.OwnerIssue + crews []workgraph.CrewInput + members []workgraph.MemberInput + crewSig string +} + +var _ appwire.WorkGrapher = (*appBackend)(nil) + +// WorkGraph answers work.graph. +func (b *appBackend) WorkGraph(ctx context.Context, req workgraph.GraphRequest) (*workgraph.GraphResult, error) { + g := &workGraphs + g.init() + plan, err := b.graphPlan(req.Scope) + if err != nil { + return nil, err + } + loaded, err := g.load(ctx, plan) + if err != nil { + return nil, err + } + revision := loaded.revision(plan) + if req.IfRevision != "" && req.IfRevision == revision { + return unchangedGraph(req, revision), nil + } + snap, err := g.cache.Get(ctx, req.Scope, revision, func(ctx context.Context) (workgraph.Input, error) { + return loaded.input(ctx, g, plan, revision) + }) + if err != nil { + return nil, err + } + return snap.Answer(ctx, req) +} + +func unchangedGraph(req workgraph.GraphRequest, revision string) *workgraph.GraphResult { + view := req.View + if view == "" { + view = workgraph.ViewNodes + } + return &workgraph.GraphResult{Contract: workgraph.Contract, View: view, Revision: revision, Unchanged: true, Scope: req.Scope} +} + +// graphPlan resolves a scope into the conversations it reads. +func (b *appBackend) graphPlan(scope workgraph.GraphScope) (graphPlan, error) { + plan := graphPlan{scope: scope} + switch scope.Kind { + case workgraph.ScopeCrew: + return plan, b.crewPlan(&plan) + case workgraph.ScopeTask: + owner, _ := workgraph.ParseID(scope.ID) + plan.root = scope.ID + return plan, b.agentPlan(&plan, owner.Owner) + } + return plan, b.agentPlan(&plan, scope.ID) +} + +// agentPlan is the plan of a scope that reads one conversation. +func (b *appBackend) agentPlan(plan *graphPlan, id string) error { + a, err := b.find(id) + if err != nil { + return err + } + var view appwire.Agent + var transcript string + b.locked(func() { view, transcript = a.view, a.transcript }) + plan.owners = []graphOwner{{id: id, dir: filepath.Dir(transcript)}} + if plan.scope.Kind == workgraph.ScopeAgent { + plan.about = workgraph.ScopeFacts{Title: view.Title} + if view.ClosedAt != nil || view.State == "closed" || view.State == "archived" { + plan.about.State, plan.about.ClosedAt, plan.about.ReadOnly = "closed", view.ClosedAt, true + } else { + plan.about.State = "open" + } + } + return nil +} + +// crewPlan is the plan of a crew scope: the crew, every crew under it, and +// every conversation that is a member of any of them. +func (b *appBackend) crewPlan(plan *graphPlan) error { + file, sig, err := workGraphs.crewFile(b.crewProfile()) + if err != nil { + return err + } + root, ok := file.Team(plan.scope.ID) + if !ok { + return appwire.Errorf(appwire.CodeNotFound, "there is no crew %s", plan.scope.ID) + } + plan.crewSig = sig + teams := append([]teamstore.Team{root}, file.Descendants(root.ID)...) + plan.about = workgraph.ScopeFacts{Title: root.Name, State: crewStateWord(root), ReadOnly: root.Closed()} + if root.Closed() { + plan.about.ClosedAt = stampPtr(root.ClosedAt) + } + plan.crews, plan.members = crewFacts(file, teams) + seen := map[string]bool{} + for _, m := range plan.members { + if seen[m.Agent] { + continue + } + seen[m.Agent] = true + if len(plan.owners) >= graphMaxOwners { + break + } + if dir, ok := workGraphs.locate(m.Agent); ok { + plan.owners = append(plan.owners, graphOwner{id: m.Agent, dir: dir}) + } else { + plan.missing = append(plan.missing, workgraph.OwnerIssue{Owner: m.Agent, Reason: session.WorkIssueMissing}) + } + } + sort.Slice(plan.owners, func(i, j int) bool { return plan.owners[i].id < plan.owners[j].id }) + return nil +} + +func crewStateWord(t teamstore.Team) string { + if t.Closed() { + return "closed" + } + return "open" +} + +func stampPtr(t time.Time) *time.Time { + if t.IsZero() { + return nil + } + u := t.UTC() + return &u +} + +// crewFile is the crews' file, parsed once per change of the file. +func (g *appWorkGraph) crewFile(profile string) (*teamstore.File, string, error) { + g.init() + sig := session.FileStamp(teamstore.Path(profile)) + g.mu.Lock() + if g.crews.file != nil && g.crews.stamp == sig { + file := g.crews.file + g.mu.Unlock() + return file, sig, nil + } + g.mu.Unlock() + file, err := teamstore.Load(profile) + if err != nil { + return nil, "", err + } + g.mu.Lock() + g.crews = crewMemo{stamp: sig, file: file} + g.mu.Unlock() + return file, sig, nil +} + +// crewFacts turns teams into the generic crews and memberships. A member that +// runs a crew under another is that crew's lead; the manager a member reports +// to is the one its home membership names. +func crewFacts(file *teamstore.File, teams []teamstore.Team) ([]workgraph.CrewInput, []workgraph.MemberInput) { + leads := map[string]string{} + for _, t := range teams { + if t.Parent != "" && t.Manager != "" && !t.Closed() { + leads[t.Parent+"\x00"+t.Manager] = t.ID + } + } + var crews []workgraph.CrewInput + var members []workgraph.MemberInput + for _, t := range teams { + crews = append(crews, workgraph.CrewInput{CrewFact: crewFact(t)}) + for _, m := range t.Members { + members = append(members, memberFact(file, t, m, leads[t.ID+"\x00"+m.Key])) + } + } + return crews, members +} + +func crewFact(t teamstore.Team) workgraph.CrewFact { + f := workgraph.CrewFact{ID: t.ID, Name: t.Name, Parent: t.Parent, State: crewStateWord(t)} + hue := t.Hue + f.Hue = &hue + if t.Closed() { + f.ClosedAt = stampPtr(t.ClosedAt) + } + for _, m := range t.Members { + if m.Key == t.Manager { + f.Manager = appMemberAgent(m) + } + } + return f +} + +func memberFact(file *teamstore.File, t teamstore.Team, m teamstore.Member, leads string) workgraph.MemberInput { + f := workgraph.MemberFact{ + Agent: appMemberAgent(m), Handle: m.Handle, Title: m.Word, Crew: t.ID, + Manager: m.Key == t.Manager, Leads: leads, JoinedAt: stampPtr(m.JoinedAt), + } + if home, ok := file.Home(m.Key); ok { + if team, ok := file.Team(home.Team); ok { + if boss, ok := team.Member(home.Manager); ok { + f.ReportsTo = appMemberAgent(boss) + } + } + } + return workgraph.MemberInput{MemberFact: f, Home: m.Home} +} + +// locate is the folder of conversation id: one directory listing of the places +// root and one stat per project, remembered, and checked again by the stat of +// every read made from it. +func (g *appWorkGraph) locate(id string) (string, bool) { + if !workgraph.ValidOwner(id) { + return "", false + } + g.init() + g.mu.Lock() + dir, known := g.dirs[id] + g.mu.Unlock() + if known { + if info, err := os.Stat(dir); err == nil && info.IsDir() { + return dir, true + } + } + root := session.PlacesRoot() + buckets, err := os.ReadDir(root) + if err != nil { + return "", false + } + for _, bucket := range buckets { + if !bucket.IsDir() { + continue + } + candidate := filepath.Join(root, bucket.Name(), id) + if info, err := os.Stat(candidate); err == nil && info.IsDir() { + g.mu.Lock() + g.dirs[id] = candidate + g.mu.Unlock() + return candidate, true + } + } + return "", false +} + +// externalTasks is the reader the app's conversations wait on other +// conversations' tasks through. It answers from the same cached projections the +// graph reads, so the engine's frontier and the graph can never disagree. +func (g *appWorkGraph) externalTasks() session.ExternalTasks { + g.init() + return g.source.ExternalReader(g.locate) +} + +// graphLoaded is every conversation of a plan, read. +type graphLoaded struct { + owners []*session.OwnerFacts + unread []workgraph.OwnerIssue + external []externalOwner + indexes []string +} + +// externalOwner is a conversation read only to say the state of a target. +type externalOwner struct { + id string + facts *session.OwnerFacts + stamp string +} + +// load reads (or takes from memory) each conversation of the plan, then the +// other conversations its tasks wait on. A cancelled ctx ends it. +func (g *appWorkGraph) load(ctx context.Context, plan graphPlan) (*graphLoaded, error) { + out := &graphLoaded{unread: append([]workgraph.OwnerIssue(nil), plan.missing...)} + inScope := map[string]bool{} + indexes := map[string]bool{} + for _, o := range plan.owners { + inScope[o.id] = true + facts, err := g.source.Owner(ctx, o.id, o.dir) + switch { + case err == session.ErrWorkOwnerMissing: + out.unread = append(out.unread, workgraph.OwnerIssue{Owner: o.id, Reason: session.WorkIssueMissing}) + continue + case err != nil: + return nil, err + case facts.Issue != "": + out.unread = append(out.unread, workgraph.OwnerIssue{Owner: o.id, Reason: facts.Issue}) + } + out.owners = append(out.owners, facts) + indexes[session.TaskIndexPath(session.Place{Dir: o.dir}.Transcript())] = true + } + for path := range indexes { + out.indexes = append(out.indexes, path) + } + sort.Strings(out.indexes) + return out, g.loadExternal(ctx, out, inScope) +} + +// loadExternal reads the conversations the scope's tasks wait on that the scope +// does not hold, at most [graphMaxExternal] of them. +func (g *appWorkGraph) loadExternal(ctx context.Context, out *graphLoaded, inScope map[string]bool) error { + wanted := map[string]bool{} + for _, o := range out.owners { + for _, id := range o.Externals { + if !inScope[id] { + wanted[id] = true + } + } + } + ids := make([]string, 0, len(wanted)) + for id := range wanted { + ids = append(ids, id) + } + sort.Strings(ids) + if len(ids) > graphMaxExternal { + ids = ids[:graphMaxExternal] + } + for _, id := range ids { + dir, ok := g.locate(id) + if !ok { + continue + } + facts, err := g.source.Owner(ctx, id, dir) + if err == session.ErrWorkOwnerMissing { + continue + } + if err != nil { + return err + } + out.external = append(out.external, externalOwner{id: id, facts: facts, stamp: facts.Stamp}) + } + return nil +} + +// revision names every source the answer is built from. +func (l *graphLoaded) revision(plan graphPlan) string { + h := sha256.New() + put := func(parts ...string) { + for _, p := range parts { + h.Write([]byte(p)) + h.Write([]byte{0}) + } + } + put("work.graph/1", string(plan.scope.Kind), plan.scope.ID, plan.root, plan.crewSig) + for _, o := range l.owners { + put("o", o.Owner, o.Stamp) + } + for _, o := range l.external { + put("x", o.id, o.stamp) + } + for _, path := range l.indexes { + put("i", path, session.FileStamp(path)) + } + for _, u := range l.unread { + put("u", u.Owner, u.Reason) + } + return hex.EncodeToString(h.Sum(nil))[:32] +} + +// input is the generic builder's input for a loaded plan. It runs inside the +// cache's build, with no lock held, and honours ctx. +func (l *graphLoaded) input(ctx context.Context, g *appWorkGraph, plan graphPlan, revision string) (workgraph.Input, error) { + in := workgraph.Input{ + Scope: plan.scope, About: plan.about, Root: plan.root, Crews: plan.crews, Members: plan.members, + Revision: revision, AsOf: time.Now(), Owners: len(l.owners) + len(l.unread), Excluded: session.WorkExcluded, + } + if len(l.unread) > graphMaxUnread { + in.Unread = l.unread[:graphMaxUnread] + } else { + in.Unread = l.unread + } + checks := map[string]*session.CheckIndex{} + for _, path := range l.indexes { + index, err := g.source.Checks(ctx, path) + if err != nil { + return in, err + } + checks[path] = index + } + total := 0 + for _, o := range l.owners { + total += len(o.Facts) + } + in.Facts = make([]workgraph.Fact, 0, min(total, workgraph.MaxScopeNodes)) + for _, o := range l.owners { + for _, f := range o.Facts { + if len(in.Facts) == workgraph.MaxScopeNodes { + in.Truncated = true + break + } + in.Facts = append(in.Facts, f) + } + } + l.overlayChecks(&in, plan, checks) + in.Resolve = l.resolver() + return in, nil +} + +// overlayChecks gives a graph node the recorded check runs its project's index +// holds for it. A plan row carries its own and is left alone. +func (l *graphLoaded) overlayChecks(in *workgraph.Input, plan graphPlan, checks map[string]*session.CheckIndex) { + dirs := map[string]string{} + for _, o := range plan.owners { + dirs[o.id] = session.TaskIndexPath(session.Place{Dir: o.dir}.Transcript()) + } + for i := range in.Facts { + n := &in.Facts[i].Node + if n.Checks != nil || n.Kind == "plan" { + continue + } + if c := checks[dirs[n.Ref.Owner]].Of(n.Ref.Owner, n.Ref.Task); c != nil { + n.Checks = c + } + } +} + +// resolver answers about a target outside the scope from the conversations +// already read: found with its state, absent when the conversation was read and +// lacks the task, unknown when it was not read or could not be. +func (l *graphLoaded) resolver() func(workgraph.TaskRef) workgraph.Resolution { + by := make(map[string]*session.OwnerFacts, len(l.external)) + for _, o := range l.external { + by[o.id] = o.facts + } + return func(r workgraph.TaskRef) workgraph.Resolution { + facts, ok := by[r.Owner] + if !ok { + return workgraph.Resolution{Standing: workgraph.StandingUnknown} + } + fact, ok := facts.Find(r.Task) + switch { + case ok: + return workgraph.Resolution{Standing: workgraph.StandingFound, State: fact.Node.State} + case facts.Issue != "": + return workgraph.Resolution{Standing: workgraph.StandingUnknown} + } + return workgraph.Resolution{Standing: workgraph.StandingAbsent} + } +} diff --git a/cmd/codeaf/appworkgraph_test.go b/cmd/codeaf/appworkgraph_test.go new file mode 100644 index 0000000000..4d2f6ebbbb --- /dev/null +++ b/cmd/codeaf/appworkgraph_test.go @@ -0,0 +1,226 @@ +package main + +// WORK.GRAPH ON THE REAL BACKEND, THROUGH THE REAL WIRE. Two conversations are +// real folders under a real places root, each with the checkpoint the engine +// writes; a crew holds them; and a request line goes through appwire's server +// into appBackend and comes back as the bytes the app would read. + +import ( + "bufio" + "context" + "encoding/json" + "io" + "os" + "path/filepath" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/session" + teamstore "github.com/Agent-Field/codeaf/internal/teams" + "github.com/Agent-Field/codeaf/internal/workgraph" +) + +const ( + graphManager = "00000000000000c1" + graphMember = "00000000000000c2" +) + +// checkpoint1 is the manager's tasks.json: task 1 landed, task 2 is a queued +// sub-task of it that waits (depends_on) on it. +const checkpoint1 = `{"type":"tasks","version":1,"seq":2,"nodes":[ + {"id":1,"title":"Design the schema","brief":"b","acceptance":"a","state":"done","merge":"merged", + "changed":["db/schema.sql","/etc/hosts"],"admittedAt":"2026-10-01T09:00:00Z","startedAt":"2026-10-01T09:00:05Z", + "endedAt":"2026-10-01T09:30:00Z","stateAt":"2026-10-01T09:30:00Z","costUsd":0.12,"input":1000,"output":200}, + {"id":2,"title":"Write migrations","brief":"b","acceptance":"a","state":"queued","depends_on":[1],"parent":1,"depth":2, + "admittedAt":"2026-10-01T09:31:00Z","stateAt":"2026-10-01T09:31:00Z"}]}` + +// checkpoint2 is the member's: its only task waits on the manager's task 1, a +// link recorded across conversations. +const checkpoint2 = `{"type":"tasks","version":1,"seq":1,"nodes":[ + {"id":1,"title":"Build the API","brief":"b","acceptance":"a","state":"queued", + "waits_external":[{"session":"` + graphManager + `","task":1}],"admittedAt":"2026-10-01T09:05:00Z","stateAt":"2026-10-01T09:05:00Z"}]}` + +type graphWire struct { + t *testing.T + in io.Writer + lines *bufio.Scanner + id int +} + +func (w *graphWire) call(params string) (result json.RawMessage, wireErr map[string]any) { + w.t.Helper() + w.id++ + line := `{"id":` + string(rune('0'+w.id)) + `,"method":"work.graph","params":` + params + "}\n" + if _, err := io.WriteString(w.in, line); err != nil { + w.t.Fatal(err) + } + if !w.lines.Scan() { + w.t.Fatal("the wire ended") + } + var out struct { + Result json.RawMessage `json:"result"` + Error map[string]any `json:"error"` + } + if err := json.Unmarshal(w.lines.Bytes(), &out); err != nil { + w.t.Fatalf("not JSON: %s", w.lines.Text()) + } + return out.Result, out.Error +} + +func (w *graphWire) graph(params string) workgraph.GraphResult { + w.t.Helper() + raw, bad := w.call(params) + if bad != nil { + w.t.Fatalf("work.graph %s: %v", params, bad) + } + var res workgraph.GraphResult + if err := json.Unmarshal(raw, &res); err != nil { + w.t.Fatal(err) + } + return res +} + +func graphFixture(t *testing.T) (*appBackend, *graphWire, string) { + t.Helper() + t.Setenv("CODEAF_HOME", filepath.Join(t.TempDir(), "state")) + bucket, err := v3ProjectDir(t.TempDir()) + if err != nil { + t.Fatal(err) + } + dirs := map[string]string{ + graphManager: writeV3Session(t, bucket, graphManager, "lead", time.Now()), + graphMember: writeV3Session(t, bucket, graphMember, "build", time.Now()), + } + for id, doc := range map[string]string{graphManager: checkpoint1, graphMember: checkpoint2} { + if err := os.WriteFile(session.Place{Dir: dirs[id]}.Tasks(), []byte(doc), 0o600); err != nil { + t.Fatal(err) + } + } + backend := newFakeBackend(newAppRecorder(), func(ask appOpenAsk) (*appConn, error) { + return &appConn{Conv: &fakeConversation{}, Workspace: ask.Workspace, Transcript: ask.Transcript, Release: func() {}}, nil + }) + t.Cleanup(func() { backend.Close() }) + backend.state = &appState{path: filepath.Join(t.TempDir(), appStateName)} + backend.profileDir = t.TempDir() + crew := teamstore.NewID() + sub := teamstore.NewID() + err = teamstore.Update(backend.profileDir, func(f *teamstore.File) error { + f.Teams = append(f.Teams, teamstore.Team{ID: crew, Name: "harbor", Made: time.Now()}, teamstore.Team{ID: sub, Name: "api", Parent: crew, Made: time.Now()}) + for _, id := range []string{graphManager, graphMember} { + transcript := session.Place{Dir: dirs[id]}.Transcript() + member := teamstore.Member{Key: transcript, File: transcript, Where: "/w", Word: id, Handle: id} + if err := f.AddMember(crew, member); err != nil { + return err + } + } + member := teamstore.Member{Key: session.Place{Dir: dirs[graphMember]}.Transcript(), File: session.Place{Dir: dirs[graphMember]}.Transcript(), Where: "/w", Word: "api", Handle: "api"} + return f.AddMember(sub, member) + }) + if err != nil { + t.Fatal(err) + } + inR, inW := io.Pipe() + outR, outW := io.Pipe() + server := appwire.NewServer(outW) + go func() { + _ = server.Serve(context.Background(), inR, appwire.Hello{Version: "test"}, backend) + outW.Close() + }() + scanner := bufio.NewScanner(outR) + scanner.Buffer(make([]byte, 4<<20), 4<<20) + t.Cleanup(func() { inW.Close() }) + if !scanner.Scan() { + t.Fatal("no hello") + } + return backend, &graphWire{t: t, in: inW, lines: scanner}, crew +} + +func TestWorkGraphOnTheRealBackendThroughTheWire(t *testing.T) { + _, w, crew := graphFixture(t) + + // A CREW SCOPE reads every member's conversation: three tasks, the real + // edges between them, and the true ownership of each. + res := w.graph(`{"scope":{"kind":"crew","id":"` + crew + `"}}`) + if res.Totals.Nodes != 3 || !res.Coverage.Complete || res.Coverage.Owners != 2 { + t.Fatalf("crew totals = %+v coverage = %+v", res.Totals, res.Coverage) + } + edges := map[string]workgraph.GraphEdge{} + for _, e := range res.Edges { + edges[e.From+">"+e.To] = e + } + local := edges[graphManager+":2>"+graphManager+":1"] + across := edges[graphMember+":1>"+graphManager+":1"] + if local.Via != "record" || local.Target != "node" || across.Via != "external" || across.Target != "node" || across.ToState != "done" { + t.Fatalf("edges = %+v", edges) + } + byID := map[string]workgraph.GraphNode{} + for _, n := range res.Nodes { + byID[n.ID] = n + } + landed := byID[graphManager+":1"] + if landed.Landing == nil || landed.Landing.Outcome != "merged" || landed.Files.Total != 1 || landed.Files.Omitted != 1 || landed.Spend == nil || landed.Spend.Basis != "priced" { + t.Fatalf("landed task = %+v", landed) + } + if n := byID[graphManager+":2"]; n.Parent != graphManager+":1" || n.ParentState != "node" || n.WaitsOnTotal != 1 { + t.Fatalf("sub-task = %+v", n) + } + if n := byID[graphMember+":1"]; n.Crew == nil || len(n.Crew.Path) != 2 || n.Crew.Crew == crew { + t.Fatalf("the member's task sits in the sub-crew: %+v", n.Crew) + } + + // THE PEOPLE, THE CLUSTERS AND THE FIGURES come from the same snapshot. + people := w.graph(`{"scope":{"kind":"crew","id":"` + crew + `"},"view":"people"}`) + if len(people.Crews) != 2 || len(people.Members) != 3 { + t.Fatalf("people = %d crews %d members", len(people.Crews), len(people.Members)) + } + metrics := w.graph(`{"scope":{"kind":"crew","id":"` + crew + `"},"view":"metrics"}`).Metrics + if metrics == nil || metrics.Tasks.Value != 3 || metrics.LandedTasks.Value != 1 || !metrics.Spend.Known || metrics.Spend.Value != 0.12 || metrics.Subtasks.Value != 1 { + t.Fatalf("metrics = %+v", metrics) + } + + // AN AGENT SCOPE resolves the same link as an external target with the + // state the other conversation's file says. + agent := w.graph(`{"scope":{"kind":"agent","id":"` + graphMember + `"}}`) + if len(agent.Edges) != 1 || agent.Edges[0].Target != "external" || agent.Edges[0].ToState != "done" || agent.Nodes[0].ID != graphMember+":1" { + t.Fatalf("agent graph = %+v", agent) + } + + // A TASK SCOPE is that task's own sub-tasks. + task := w.graph(`{"scope":{"kind":"task","id":"` + graphManager + `:1"}}`) + if task.About.Root == nil || task.About.Root.ID != graphManager+":1" || len(task.Nodes) != 1 || task.Nodes[0].ID != graphManager+":2" { + t.Fatalf("task graph = %+v", task) + } +} + +func TestWorkGraphUnchangedPagingAndRefusals(t *testing.T) { + _, w, crew := graphFixture(t) + scope := `{"kind":"crew","id":"` + crew + `"}` + first := w.graph(`{"scope":` + scope + `,"limit":2}`) + if len(first.Nodes) != 2 || first.Next == "" || first.Totals.Nodes != 3 { + t.Fatalf("first page = %+v", first) + } + cursor, _ := json.Marshal(first.Next) + if next := w.graph(`{"scope":` + scope + `,"limit":2,"cursor":` + string(cursor) + `}`); len(next.Nodes) != 1 || next.Next != "" { + t.Fatalf("second page = %+v", next) + } + builds, _ := workGraphs.cache.Stats() + if got := w.graph(`{"scope":` + scope + `,"if_revision":"` + first.Revision + `"}`); !got.Unchanged || len(got.Nodes) != 0 { + t.Fatalf("an unmoved revision answered %+v", got) + } + if after, _ := workGraphs.cache.Stats(); after != builds { + t.Fatal("an unchanged answer built a snapshot") + } + if _, bad := w.call(`{"scope":` + scope + `,"cursor":"tampered"}`); bad == nil || bad["code"] != appwire.CodeBadParams { + t.Fatalf("tampered cursor = %v", bad) + } + if _, bad := w.call(`{"scope":{"kind":"crew","id":"nope"}}`); bad == nil || bad["code"] != appwire.CodeNotFound { + t.Fatalf("unknown crew = %v", bad) + } + if _, bad := w.call(`{"scope":{"kind":"agent","id":"00000000000000ff"}}`); bad == nil || bad["code"] != appwire.CodeNotFound { + t.Fatalf("unknown agent = %v", bad) + } + if _, bad := w.call(`{"scope":{"kind":"task","id":"../x:1"}}`); bad == nil || bad["code"] != appwire.CodeBadParams { + t.Fatalf("unsafe task id = %v", bad) + } +} diff --git a/cmd/codeaf/chatv3_orchestrate.go b/cmd/codeaf/chatv3_orchestrate.go index a9c1ea4a94..473f001edb 100644 --- a/cmd/codeaf/chatv3_orchestrate.go +++ b/cmd/codeaf/chatv3_orchestrate.go @@ -61,6 +61,11 @@ 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 + // AND THEY READ EACH OTHER'S TASKS through one reader, for the waits a task + // can hold on another conversation's task (session's task_external.go). + if cfg.ExternalTasks == nil { + cfg.ExternalTasks = workGraphs.externalTasks() + } cfg, runs := v3Adaptive(cfg) agent, err := session.New(cfg) if err != nil { diff --git a/internal/session/workgraph_source.go b/internal/session/workgraph_source.go index 78c352331d..fb2ace9e98 100644 --- a/internal/session/workgraph_source.go +++ b/internal/session/workgraph_source.go @@ -29,6 +29,7 @@ import ( "fmt" "os" "path/filepath" + "sort" "strconv" "strings" "sync" @@ -60,6 +61,10 @@ type OwnerFacts struct { Stamp string Facts []workgraph.Fact Issue string + // Externals are the other conversations this one's tasks wait on, sorted: the + // sources a target's state is read from, so a caller can name them in its + // revision. + Externals []string byID map[string]int32 } @@ -214,12 +219,26 @@ func readOwner(ctx context.Context, owner, dir, stamp string) (*OwnerFacts, erro return nil, err } out.byID = make(map[string]int32, len(out.Facts)) + others := map[string]bool{} for i := range out.Facts { out.byID[out.Facts[i].Node.Ref.Task] = int32(i) + for _, w := range out.Facts[i].Waits { + if w.On.Owner != owner { + others[w.On.Owner] = true + } + } } + for other := range others { + out.Externals = append(out.Externals, other) + } + sort.Strings(out.Externals) return out, nil } +// FileStamp names a file's present state (size, modification instant, inode), "-" +// when it is not there: the identity a caller caches a read of the file under. +func FileStamp(path string) string { return wgStamp(path) } + func firstIssue(a, b string) string { if a != "" { return a From a150750f1218743573898c9dd2a3e1fbf6804cc9 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 23:15:25 -0400 Subject: [PATCH 127/459] workgraph, session: measure the 200 000-task source bound Co-Authored-By: Claude Sonnet 5.5 --- internal/session/workgraph_scale_test.go | 84 +++++++++++++++++++ internal/workgraph/scale_test.go | 100 +++++++++++++++++++++++ 2 files changed, 184 insertions(+) create mode 100644 internal/session/workgraph_scale_test.go create mode 100644 internal/workgraph/scale_test.go diff --git a/internal/session/workgraph_scale_test.go b/internal/session/workgraph_scale_test.go new file mode 100644 index 0000000000..950fa97d4c --- /dev/null +++ b/internal/session/workgraph_scale_test.go @@ -0,0 +1,84 @@ +package session + +import ( + "context" + "fmt" + "os" + "path/filepath" + "runtime" + "testing" + "time" +) + +// scaleFolders writes `owners` conversation folders, each with a checkpoint of +// `each` nodes (every one waiting on the one before it), the way the engine +// writes them. +func scaleFolders(tb testing.TB, owners, each int) []string { + tb.Helper() + root := tb.TempDir() + var dirs []string + at := time.Date(2026, 10, 1, 9, 0, 0, 0, time.UTC) + for o := 0; o < owners; o++ { + dir := filepath.Join(root, fmt.Sprintf("agent%04d", o)) + doc := taskDocument{Type: taskDocumentType, Version: taskFileVersion, Seq: uint64(each)} + for n := 1; n <= each; n++ { + record := taskRecord{ + ID: uint64(n), Title: fmt.Sprintf("task %d of %d", n, o), Brief: "do the work", Acceptance: "it works", + State: TaskDone, Merge: mergeMerged, Changed: []string{"a.go", "b.go"}, CostUSD: 0.01, Input: 100, Output: 20, + AdmittedAt: at, StateAt: at, StartedAt: at, EndedAt: at, + } + if n > 1 { + record.DependsOn = []uint64{uint64(n - 1)} + } + doc.Nodes = append(doc.Nodes, record) + } + if err := os.MkdirAll(dir, 0o755); err != nil { + tb.Fatal(err) + } + writeCheckpoint(tb.(*testing.T), Place{Dir: dir}.Tasks(), doc) + dirs = append(dirs, dir) + } + return dirs +} + +// 200 000 TASKS ARE READ ONCE: cold, they are parsed within a bounded time and +// memory; warm, asking again is stat calls and no parse. +func TestTheSourceReadsTwoHundredThousandTasksOnceAndThenOnlyStats(t *testing.T) { + if testing.Short() { + t.Skip("writes and reads 200 000 tasks") + } + dirs := scaleFolders(t, 200, 1000) + source := NewWorkSource() + ctx := context.Background() + runtime.GC() + var before, after runtime.MemStats + runtime.ReadMemStats(&before) + start := time.Now() + total := 0 + for _, dir := range dirs { + facts, err := source.Owner(ctx, filepath.Base(dir), dir) + if err != nil { + t.Fatal(err) + } + total += len(facts.Facts) + } + cold := time.Since(start) + runtime.GC() + runtime.ReadMemStats(&after) + t.Logf("cold: %d tasks in %v, %d MiB held", total, cold, (after.HeapAlloc-before.HeapAlloc)>>20) + parses := source.Parses() + start = time.Now() + for _, dir := range dirs { + if _, err := source.Owner(ctx, filepath.Base(dir), dir); err != nil { + t.Fatal(err) + } + } + warm := time.Since(start) + t.Logf("warm: 200 conversations again in %v", warm) + if total != 200_000 || source.Parses() != parses { + t.Fatalf("total = %d, parses went %d -> %d", total, parses, source.Parses()) + } + if warm > cold/5 { + t.Fatalf("an unchanged read cost %v against %v cold", warm, cold) + } +} diff --git a/internal/workgraph/scale_test.go b/internal/workgraph/scale_test.go new file mode 100644 index 0000000000..110786e266 --- /dev/null +++ b/internal/workgraph/scale_test.go @@ -0,0 +1,100 @@ +package workgraph + +import ( + "context" + "encoding/json" + "fmt" + "runtime" + "testing" +) + +// scaleInput is a crew of `owners` conversations holding `each` tasks apiece. +// Every task waits on its predecessor in the same conversation, and every tenth +// waits across conversations, so the graph is deep and wide at once. +func scaleInput(owners, each int) Input { + facts := make([]Fact, 0, owners*each) + for o := 0; o < owners; o++ { + owner := fmt.Sprintf("agent%04d", o) + for n := 1; n <= each; n++ { + opts := []opt{state(StateDone), spend(TaskSpend{USD: 0.01, Tokens: 10, Basis: "priced"}), landing("merged"), files("a.go", "b.go")} + if n > 1 { + opts = append(opts, waits(owner, fmt.Sprint(n-1))) + } + if n%10 == 0 && o > 0 { + opts = append(opts, waitsExt(fmt.Sprintf("agent%04d", o-1), fmt.Sprint(n))) + } + facts = append(facts, task(owner, n, opts...)) + } + } + return Input{Scope: GraphScope{Kind: ScopeCrew, ID: "crew"}, Facts: facts, Revision: "r1", Owners: owners} +} + +func heapNow() uint64 { + runtime.GC() + var m runtime.MemStats + runtime.ReadMemStats(&m) + return m.HeapAlloc +} + +// THE SOURCE BOUND: 200 000 tasks build once, in bounded time and memory, and a +// page of them is a bounded response however large the scope is. +func TestAScopeAtTheSourceBoundBuildsAndPagesWithinBounds(t *testing.T) { + if testing.Short() { + t.Skip("builds 200 000 tasks") + } + in := scaleInput(200, 1000) + before := heapNow() + snap, err := Build(context.Background(), in) + if err != nil { + t.Fatal(err) + } + held := heapNow() - before + // The producer's own facts are released once built; what the snapshot keeps is + // what is measured. + in.Facts = nil + runtime.KeepAlive(snap) + t.Logf("200000 tasks: snapshot holds ~%d MiB", (heapNow()-before)/(1<<20)) + _ = held + res, err := snap.Answer(context.Background(), GraphRequest{Scope: in.Scope, Limit: MaxNodes}) + if err != nil { + t.Fatal(err) + } + if res.Totals.Nodes != 200_000 || len(res.Nodes) > MaxNodes || len(res.Edges) > MaxEdges || res.Next == "" { + t.Fatalf("page: totals %+v nodes %d edges %d next %q", res.Totals, len(res.Nodes), len(res.Edges), res.Next) + } + raw, _ := json.Marshal(res) + t.Logf("one full page is %d KiB of JSON", len(raw)/1024) + if len(raw) > 2<<20 { + t.Fatalf("a page of %d nodes is %d bytes", len(res.Nodes), len(raw)) + } + if !res.Coverage.Complete || res.Totals.Edges < 200_000 { + t.Fatalf("coverage %+v edges %d", res.Coverage, res.Totals.Edges) + } +} + +func BenchmarkBuild200K(b *testing.B) { + in := scaleInput(200, 1000) + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := Build(context.Background(), in); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkPageOf500From200K(b *testing.B) { + in := scaleInput(200, 1000) + snap, err := Build(context.Background(), in) + if err != nil { + b.Fatal(err) + } + req := GraphRequest{Scope: in.Scope, Limit: MaxNodes} + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := snap.Answer(context.Background(), req); err != nil { + b.Fatal(err) + } + } +} From e7ad2234db8047e0974a67fa52f351c1dfce498e Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 23:20:01 -0400 Subject: [PATCH 128/459] workgraph: protocol section, change entry, and the lock and frontier laws Co-Authored-By: Claude Sonnet 5.5 --- cmd/codeaf/appworkgraph.go | 45 ++++++++++++++++--------- docs/changes/unreleased/0-work-graph.md | 16 +++++++++ internal/session/task_run.go | 5 +-- internal/session/workgraph_source.go | 2 +- 4 files changed, 50 insertions(+), 18 deletions(-) create mode 100644 docs/changes/unreleased/0-work-graph.md diff --git a/cmd/codeaf/appworkgraph.go b/cmd/codeaf/appworkgraph.go index 5762876dbb..e265cbefc1 100644 --- a/cmd/codeaf/appworkgraph.go +++ b/cmd/codeaf/appworkgraph.go @@ -217,24 +217,32 @@ func stampPtr(t time.Time) *time.Time { return &u } +// locked runs fn holding g.mu; the unlock is deferred so an absorbed panic never +// leaves it held (internal/guard's lock law). +func (g *appWorkGraph) locked(fn func()) { + g.mu.Lock() + defer g.mu.Unlock() + fn() +} + // crewFile is the crews' file, parsed once per change of the file. func (g *appWorkGraph) crewFile(profile string) (*teamstore.File, string, error) { g.init() sig := session.FileStamp(teamstore.Path(profile)) - g.mu.Lock() - if g.crews.file != nil && g.crews.stamp == sig { - file := g.crews.file - g.mu.Unlock() - return file, sig, nil + var held *teamstore.File + g.locked(func() { + if g.crews.file != nil && g.crews.stamp == sig { + held = g.crews.file + } + }) + if held != nil { + return held, sig, nil } - g.mu.Unlock() file, err := teamstore.Load(profile) if err != nil { return nil, "", err } - g.mu.Lock() - g.crews = crewMemo{stamp: sig, file: file} - g.mu.Unlock() + g.locked(func() { g.crews = crewMemo{stamp: sig, file: file} }) return file, sig, nil } @@ -297,14 +305,24 @@ func (g *appWorkGraph) locate(id string) (string, bool) { return "", false } g.init() - g.mu.Lock() - dir, known := g.dirs[id] - g.mu.Unlock() + var dir string + var known bool + g.locked(func() { dir, known = g.dirs[id] }) if known { if info, err := os.Stat(dir); err == nil && info.IsDir() { return dir, true } } + found, ok := findConversation(id) + if ok { + g.locked(func() { g.dirs[id] = found }) + } + return found, ok +} + +// findConversation lists the places root once and stats the id under each +// project. +func findConversation(id string) (string, bool) { root := session.PlacesRoot() buckets, err := os.ReadDir(root) if err != nil { @@ -316,9 +334,6 @@ func (g *appWorkGraph) locate(id string) (string, bool) { } candidate := filepath.Join(root, bucket.Name(), id) if info, err := os.Stat(candidate); err == nil && info.IsDir() { - g.mu.Lock() - g.dirs[id] = candidate - g.mu.Unlock() return candidate, true } } diff --git a/docs/changes/unreleased/0-work-graph.md b/docs/changes/unreleased/0-work-graph.md new file mode 100644 index 0000000000..71da6f7ed3 --- /dev/null +++ b/docs/changes/unreleased/0-work-graph.md @@ -0,0 +1,16 @@ +--- +kind: added +title: work.graph reads the tasks of an agent, a task or a crew as general, bounded, paged facts +pr: 0 +surface: [engine] +invalidates: + - "A task could only wait on another task of its own conversation (depends_on). A queued task can now also wait on a task of another conversation through Agent.WaitOnTask: stored in the record as waits_external and enforced by the same frontier, which reads the other conversation outside every lock." + - "task checkpoints carried no admission time and no time of the present state. They now carry admittedAt and stateAt, stamped where the transition happens; a record written before them carries neither, and the graph says so rather than dating old work with the clock that read it." + - "The app had no read of what waits on what across a crew. work.graph {scope, view, cluster, focus, cursor, limit, if_revision} answers it from the conversations' own files, without asking any live agent, and the graph holds every task row tasks.list shows except unapproved proposals." +--- + +New optional wire method `work.graph` (feature `work.graph`; `unknown_method` from an engine +without it). Pages are bounded in nodes, edges and words, every total is true, and an edge exists +only where a wait was stored and enforced. Conversations are read from `tasks.json` and the plan +stores through read-only connections and cached by file stamp, so an unchanged conversation is +never reparsed. `docs/design/app-v3/PROTOCOL.md` ("Work graph") has the contract. diff --git a/internal/session/task_run.go b/internal/session/task_run.go index b9835ba58c..0935b44395 100644 --- a/internal/session/task_run.go +++ b/internal/session/task_run.go @@ -1638,9 +1638,10 @@ func (g *TaskGraph) runFrontier() { // JIT: the brief is assembled here, with the prerequisites' reports in // hand, and never at proposal time when they did not exist yet. node.brief = g.briefLocked(node, orders) - node.setStateLocked(TaskRunning) - node.app.Version++ + node.state = TaskRunning node.started = time.Now() + node.stateAt = node.started + node.app.Version++ // The hold is lifted by the start itself, so the running update this // node is about to send carries no reason to be waiting. node.held = "" diff --git a/internal/session/workgraph_source.go b/internal/session/workgraph_source.go index fb2ace9e98..9a7d9c5504 100644 --- a/internal/session/workgraph_source.go +++ b/internal/session/workgraph_source.go @@ -170,7 +170,7 @@ func (s *WorkSource) Owner(ctx context.Context, owner, dir string) (*OwnerFacts, // across it. A conversation that is being written to as it is read is served // from what was read and not remembered. var facts *OwnerFacts - for attempt := 0; attempt < 2; attempt++ { + for pass := 0; pass < 2; pass++ { var err error if facts, err = readOwner(ctx, owner, dir, stamp); err != nil { return nil, err From 68ddcbc2494919dbfa14d8d065273af60bf53100 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 23:27:59 -0400 Subject: [PATCH 129/459] session: bound the work source's memory, reads and paths Conversations and check indexes are remembered under a byte budget and a count, least recently used first; a projection over half the budget is served and not retained. Concurrent reads of one conversation share one cancellable read. Raw checkpoint, plan-store and index bytes, facts per conversation and waits per task are capped, and a cap hit is reported too_large or partial, never silent. Links are never followed out of a conversation's folder, and a scope stops reading conversations at the 200 000-task bound. Co-Authored-By: Claude Sonnet 5.5 --- cmd/codeaf/appworkgraph.go | 28 +- internal/session/workgraph_bounds_test.go | 299 ++++++++++++++ internal/session/workgraph_lru.go | 161 ++++++++ internal/session/workgraph_project.go | 16 +- internal/session/workgraph_scale_test.go | 14 +- internal/session/workgraph_source.go | 454 ++++++++++++++++------ internal/workgraph/types.go | 2 +- 7 files changed, 850 insertions(+), 124 deletions(-) create mode 100644 internal/session/workgraph_bounds_test.go create mode 100644 internal/session/workgraph_lru.go diff --git a/cmd/codeaf/appworkgraph.go b/cmd/codeaf/appworkgraph.go index e265cbefc1..f7b249884d 100644 --- a/cmd/codeaf/appworkgraph.go +++ b/cmd/codeaf/appworkgraph.go @@ -151,9 +151,14 @@ func (b *appBackend) agentPlan(plan *graphPlan, id string) error { return err } var view appwire.Agent - var transcript string - b.locked(func() { view, transcript = a.view, a.transcript }) - plan.owners = []graphOwner{{id: id, dir: filepath.Dir(transcript)}} + b.locked(func() { view = a.view }) + // THE FOLDER IS FOUND BY ID UNDER THE PLACES ROOT and never taken from a path + // the agent's record carries: a record can name anywhere. + if dir, ok := workGraphs.locate(id); ok { + plan.owners = []graphOwner{{id: id, dir: dir}} + } else { + plan.missing = append(plan.missing, workgraph.OwnerIssue{Owner: id, Reason: session.WorkIssueMissing}) + } if plan.scope.Kind == workgraph.ScopeAgent { plan.about = workgraph.ScopeFacts{Title: view.Title} if view.ClosedAt != nil || view.State == "closed" || view.State == "archived" { @@ -309,7 +314,7 @@ func (g *appWorkGraph) locate(id string) (string, bool) { var known bool g.locked(func() { dir, known = g.dirs[id] }) if known { - if info, err := os.Stat(dir); err == nil && info.IsDir() { + if info, err := os.Lstat(dir); err == nil && info.IsDir() { return dir, true } } @@ -333,7 +338,7 @@ func findConversation(id string) (string, bool) { continue } candidate := filepath.Join(root, bucket.Name(), id) - if info, err := os.Stat(candidate); err == nil && info.IsDir() { + if info, err := os.Lstat(candidate); err == nil && info.IsDir() { return candidate, true } } @@ -354,6 +359,8 @@ type graphLoaded struct { unread []workgraph.OwnerIssue external []externalOwner indexes []string + // truncated says conversations were left unread at the scope bound. + truncated bool } // externalOwner is a conversation read only to say the state of a target. @@ -369,8 +376,17 @@ func (g *appWorkGraph) load(ctx context.Context, plan graphPlan) (*graphLoaded, out := &graphLoaded{unread: append([]workgraph.OwnerIssue(nil), plan.missing...)} inScope := map[string]bool{} indexes := map[string]bool{} + total := 0 for _, o := range plan.owners { inScope[o.id] = true + // A SCOPE IS BUILT FROM AT MOST workgraph.MaxScopeNodes TASKS, and the + // conversations past that are not even read: they are named unread + // (too_large) and the answer is truncated, never silently short. + if total >= workgraph.MaxScopeNodes { + out.truncated = true + out.unread = append(out.unread, workgraph.OwnerIssue{Owner: o.id, Reason: session.WorkIssueTooLarge}) + continue + } facts, err := g.source.Owner(ctx, o.id, o.dir) switch { case err == session.ErrWorkOwnerMissing: @@ -382,6 +398,7 @@ func (g *appWorkGraph) load(ctx context.Context, plan graphPlan) (*graphLoaded, out.unread = append(out.unread, workgraph.OwnerIssue{Owner: o.id, Reason: facts.Issue}) } out.owners = append(out.owners, facts) + total += len(facts.Facts) indexes[session.TaskIndexPath(session.Place{Dir: o.dir}.Transcript())] = true } for path := range indexes { @@ -458,6 +475,7 @@ func (l *graphLoaded) input(ctx context.Context, g *appWorkGraph, plan graphPlan in := workgraph.Input{ Scope: plan.scope, About: plan.about, Root: plan.root, Crews: plan.crews, Members: plan.members, Revision: revision, AsOf: time.Now(), Owners: len(l.owners) + len(l.unread), Excluded: session.WorkExcluded, + Truncated: l.truncated, } if len(l.unread) > graphMaxUnread { in.Unread = l.unread[:graphMaxUnread] diff --git a/internal/session/workgraph_bounds_test.go b/internal/session/workgraph_bounds_test.go new file mode 100644 index 0000000000..60f5b3d6c5 --- /dev/null +++ b/internal/session/workgraph_bounds_test.go @@ -0,0 +1,299 @@ +package session + +// THE SOURCE'S MEMORY IS BOUNDED. Every bound here is an ESTIMATE the cache +// budgets with and a count it enforces; no claim is made about the process's +// resident size. + +import ( + "context" + "fmt" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" +) + +func wgOwners(t *testing.T, n, each int) []string { t.Helper(); return scaleFolders(t, n, each) } + +func wgOwnerOf(dir string) string { return filepath.Base(dir) } + +func TestManyOwnersStayWithinTheByteBudgetAndEvictLeastRecentlyUsed(t *testing.T) { + dirs := wgOwners(t, 40, 30) + probe := NewWorkSource() + first, err := probe.Owner(context.Background(), wgOwnerOf(dirs[0]), dirs[0]) + if err != nil { + t.Fatal(err) + } + budget := first.Bytes * 5 // five conversations fit + source := NewWorkSourceLimits(WorkLimits{CacheBytes: budget, CacheOwners: 1000}) + ctx := context.Background() + for _, dir := range dirs { + if _, err := source.Owner(ctx, wgOwnerOf(dir), dir); err != nil { + t.Fatal(err) + } + if _, held, _, _ := source.Held(); held > budget { + t.Fatalf("holds %d bytes of a %d budget", held, budget) + } + } + n, held, _, _ := source.Held() + evicted, _ := source.Evictions() + if n > 5 || held > budget || evicted < 30 { + t.Fatalf("held %d conversations, %d bytes (budget %d), evicted %d", n, held, budget, evicted) + } + // The newest are still in memory: asking again costs no parse. + before := source.Parses() + if _, err := source.Owner(ctx, wgOwnerOf(dirs[39]), dirs[39]); err != nil || source.Parses() != before { + t.Fatalf("the most recent conversation was parsed again (%v)", err) + } + // The oldest was evicted and is read again, correctly. + again, err := source.Owner(ctx, wgOwnerOf(dirs[0]), dirs[0]) + if err != nil || source.Parses() != before+1 || len(again.Facts) != 30 { + t.Fatalf("evicted conversation: %d facts, parses %d -> %d, %v", len(again.Facts), before, source.Parses(), err) + } +} + +func TestTheOwnerCountIsBoundedToo(t *testing.T) { + dirs := wgOwners(t, 20, 3) + source := NewWorkSourceLimits(WorkLimits{CacheOwners: 4}) + for _, dir := range dirs { + if _, err := source.Owner(context.Background(), wgOwnerOf(dir), dir); err != nil { + t.Fatal(err) + } + } + if n, _, _, _ := source.Held(); n != 4 { + t.Fatalf("holds %d conversations, bound is 4", n) + } +} + +// A projection over half the budget is served and NOT retained: one giant +// conversation neither evicts everyone else nor stays in memory. +func TestAnOversizedProjectionIsServedButNotRetained(t *testing.T) { + dirs := wgOwners(t, 2, 400) + source := NewWorkSourceLimits(WorkLimits{CacheBytes: 64 << 10}) + facts, err := source.Owner(context.Background(), wgOwnerOf(dirs[0]), dirs[0]) + if err != nil || len(facts.Facts) != 400 || facts.Bytes <= 32<<10 { + t.Fatalf("served %d facts of %d bytes, %v", len(facts.Facts), facts.Bytes, err) + } + if n, held, _, _ := source.Held(); n != 0 || held != 0 { + t.Fatalf("an oversized projection was retained: %d, %d", n, held) + } + if _, un := source.Evictions(); un != 1 { + t.Fatalf("unretained = %d", un) + } +} + +// A checkpoint over the raw bound is not opened at all: its stat alone says +// too_large, and the conversation is empty rather than silently short. +func TestAGiantCheckpointIsNeverRead(t *testing.T) { + dir := filepath.Join(t.TempDir(), sourceOwner) + if err := os.MkdirAll(dir, 0o755); err != nil { + t.Fatal(err) + } + // Not valid JSON: if it were read the issue would be unreadable. + writeFile(t, Place{Dir: dir}.Tasks(), strings.Repeat("x", 4096)) + source := NewWorkSourceLimits(WorkLimits{FileMax: 1024}) + got, err := source.Owner(context.Background(), sourceOwner, dir) + if err != nil || got.Issue != WorkIssueTooLarge || len(got.Facts) != 0 { + t.Fatalf("giant checkpoint: %+v %v", got, err) + } +} + +// Past the per-conversation bound the rest is left out and the conversation says +// partial; no count is silently short. +func TestFactAndWaitBoundsAreReportedPartialNotSilent(t *testing.T) { + dirs := wgOwners(t, 1, 25) + source := NewWorkSourceLimits(WorkLimits{Facts: 10}) + got, err := source.Owner(context.Background(), wgOwnerOf(dirs[0]), dirs[0]) + if err != nil || len(got.Facts) != 10 || got.Issue != WorkIssuePartial { + t.Fatalf("%d facts, issue %q, %v", len(got.Facts), got.Issue, err) + } + wide := filepath.Join(t.TempDir(), sourceOwner) + doc := taskDocument{Type: taskDocumentType, Version: taskFileVersion, Seq: 50} + for n := 1; n <= 50; n++ { + r := taskRecord{ID: uint64(n), Title: "t", Brief: "b", Acceptance: "a", State: TaskQueued} + for d := 1; d < n; d++ { + r.DependsOn = append(r.DependsOn, uint64(d)) + } + doc.Nodes = append(doc.Nodes, r) + } + writeCheckpoint(t, Place{Dir: wide}.Tasks(), doc) + limited := NewWorkSourceLimits(WorkLimits{Waits: 5}) + w, err := limited.Owner(context.Background(), sourceOwner, wide) + if err != nil || w.Issue != WorkIssuePartial { + t.Fatalf("wide: %q %v", w.Issue, err) + } + if f, _ := w.Find("50"); len(f.Waits) != 5 { + t.Fatalf("task 50 keeps %d waits", len(f.Waits)) + } +} + +// Many readers asking for one changed conversation share one read, and what +// they get is one immutable value they may all read at once. +func TestConcurrentReadsOfOneConversationAreCoalescedAndSafe(t *testing.T) { + dirs := wgOwners(t, 1, 20000) + source := NewWorkSource() + var wg sync.WaitGroup + start := make(chan struct{}) + results := make([]*OwnerFacts, 16) + for i := range results { + wg.Add(1) + go func() { + defer wg.Done() + <-start + got, err := source.Owner(context.Background(), wgOwnerOf(dirs[0]), dirs[0]) + if err != nil { + t.Error(err) + return + } + results[i] = got + total := 0 + for j := range got.Facts { + total += len(got.Facts[j].Node.Title) + } + if f, ok := got.Find("20000"); !ok || total == 0 || f.Node.Ref.Task != "20000" { + t.Error("a shared value read wrongly") + } + }() + } + close(start) + wg.Wait() + if p := source.Parses(); p > 3 { + t.Fatalf("16 concurrent readers caused %d parses", p) + } + for _, r := range results[1:] { + if r != nil && results[0] != nil && r != results[0] { + // Distinct only if a read finished before the next began; they must still be equal. + if len(r.Facts) != len(results[0].Facts) { + t.Fatal("readers got different answers") + } + } + } +} + +// A caller that gives up stops waiting at once, and when nobody waits the read +// is cancelled; the next caller reads normally. +func TestAWaiterThatLeavesCancelsTheReadAndTheNextCallerSucceeds(t *testing.T) { + dirs := wgOwners(t, 1, 30000) + source := NewWorkSource() + ctx, cancel := context.WithCancel(context.Background()) + done := make(chan error, 1) + go func() { _, err := source.Owner(ctx, wgOwnerOf(dirs[0]), dirs[0]); done <- err }() + cancel() + select { + case err := <-done: + if err == nil { + t.Log("the read finished before the cancel landed") + } + case <-time.After(5 * time.Second): + t.Fatal("a cancelled caller kept waiting") + } + got, err := source.Owner(context.Background(), wgOwnerOf(dirs[0]), dirs[0]) + if err != nil || len(got.Facts) != 30000 { + t.Fatalf("next caller: %d facts, %v", len(got.Facts), err) + } +} + +// Nothing is followed out of a conversation's folder: a linked folder is +// missing, a linked checkpoint or plan store is unreadable. +func TestTheSourceNeverFollowsALink(t *testing.T) { + outside := t.TempDir() + writeFile(t, filepath.Join(outside, "tasks.json"), `{"type":"tasks","version":1,"seq":1,"nodes":[{"id":1,"title":"t","brief":"b","acceptance":"a","state":"done"}]}`) + root := t.TempDir() + linkedDir := filepath.Join(root, "00000000000000d1") + if err := os.Symlink(outside, linkedDir); err != nil { + t.Skip("no symlinks here") + } + source := NewWorkSource() + if _, err := source.Owner(context.Background(), "00000000000000d1", linkedDir); err != ErrWorkOwnerMissing { + t.Fatalf("a linked folder: %v", err) + } + real := filepath.Join(root, "00000000000000d2") + if err := os.MkdirAll(real, 0o755); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(outside, "tasks.json"), Place{Dir: real}.Tasks()); err != nil { + t.Fatal(err) + } + got, err := source.Owner(context.Background(), "00000000000000d2", real) + if err != nil || got.Issue != WorkIssueUnreadable || len(got.Facts) != 0 { + t.Fatalf("a linked checkpoint: %+v %v", got, err) + } + if err := os.Remove(Place{Dir: real}.Tasks()); err != nil { + t.Fatal(err) + } + if err := os.Symlink(filepath.Join(outside, "plandb.db"), filepath.Join(real, planStoreFilename)); err != nil { + t.Fatal(err) + } + got, err = source.Owner(context.Background(), "00000000000000d2", real) + if err != nil || got.Issue != WorkIssueUnreadable { + t.Fatalf("a linked plan store: %+v %v", got, err) + } + // An owner id that could walk is refused before any path is built. + if _, err := source.Owner(context.Background(), "../x", real); err != ErrWorkOwnerMissing { + t.Fatalf("unsafe owner: %v", err) + } +} + +// A check index larger than its bound is read from its newest end and says so; +// the remembered indexes are bounded too. +func TestACheckIndexOverItsBoundIsReadFromItsNewestEndAndSaysPartial(t *testing.T) { + path := filepath.Join(t.TempDir(), "tasks.jsonl") + var b strings.Builder + row := func(session string, n int) string { + return fmt.Sprintf(`{"id":"%d","sessionId":"%s","title":"t","verdictBasis":{"kind":"run","runs":[{"command":"go test","exitCode":0}]}}`+"\n", n, session) + } + for n := 1; n <= 2000; n++ { + b.WriteString(row("00000000000000e1", n)) + } + writeFile(t, path, b.String()) + source := NewWorkSourceLimits(WorkLimits{IndexMax: 20 << 10}) + index, err := source.Checks(context.Background(), path) + if err != nil || !index.Partial { + t.Fatalf("index: %+v %v", index, err) + } + if index.Of("00000000000000e1", "2000") == nil || index.Of("00000000000000e1", "1") != nil { + t.Fatal("the newest rows are kept and the oldest are not") + } + whole := NewWorkSource() + full, _ := whole.Checks(context.Background(), path) + if full.Partial || full.Of("00000000000000e1", "1") == nil { + t.Fatal("an index within its bound is read whole") + } + if _, _, n, bytes := whole.Held(); n != 1 || bytes <= 0 { + t.Fatalf("held indexes %d, bytes %d", n, bytes) + } +} + +func BenchmarkOwnerCacheHit(b *testing.B) { + dirs := scaleFolders(b, 1, 1000) + source := NewWorkSource() + ctx := context.Background() + if _, err := source.Owner(ctx, wgOwnerOf(dirs[0]), dirs[0]); err != nil { + b.Fatal(err) + } + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := source.Owner(ctx, wgOwnerOf(dirs[0]), dirs[0]); err != nil { + b.Fatal(err) + } + } +} + +// BenchmarkOwnerEvictingReads cycles more conversations than the budget holds, +// so every read is a parse: the cost of a bound that is too small. +func BenchmarkOwnerEvictingReads(b *testing.B) { + dirs := scaleFolders(b, 8, 1000) + source := NewWorkSourceLimits(WorkLimits{CacheOwners: 2}) + ctx := context.Background() + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + d := dirs[i%len(dirs)] + if _, err := source.Owner(ctx, wgOwnerOf(d), d); err != nil { + b.Fatal(err) + } + } +} diff --git a/internal/session/workgraph_lru.go b/internal/session/workgraph_lru.go new file mode 100644 index 0000000000..c7675cfd92 --- /dev/null +++ b/internal/session/workgraph_lru.go @@ -0,0 +1,161 @@ +package session + +// A BYTE-BUDGETED LRU WITH COALESCED READS, for what [WorkSource] remembers. +// +// What the source keeps is a pure function of files, so forgetting it only costs +// a re-read; what it must never do is keep without limit. Two bounds hold the +// memory: a count of entries and a budget of BYTES, each entry carrying the size +// its builder estimated for it. The least recently used are dropped until both +// hold. A single value larger than half the budget is handed to the caller that +// asked and NOT retained, so one giant conversation can neither evict everything +// else nor sit in memory afterwards. +// +// Values are immutable once built, so a caller may keep reading one after it is +// evicted; eviction only drops this cache's reference. +// +// Reads are coalesced: callers asking for the same key and stamp at once share +// one read, run on a goroutine of its own under its own deadline and with no +// lock held. A caller whose context ends stops waiting, and when the last waiter +// leaves, the read is cancelled. + +import ( + "container/list" + "context" + "sync" + "sync/atomic" + "time" +) + +// workReadTimeout bounds one coalesced read, whoever is waiting for it. +const workReadTimeout = 30 * time.Second + +type lruEntry struct { + key, stamp string + value any + bytes int64 +} + +type lruFlight struct { + done chan struct{} + cancel context.CancelFunc + waiters int + value any + err error +} + +type byteLRU struct { + mu sync.Mutex + maxBytes int64 + maxEntries int + bytes int64 + lru *list.List + by map[string]*list.Element + flights map[string]*lruFlight + + evicted atomic.Int64 + unretained atomic.Int64 + coalesced atomic.Int64 +} + +func newByteLRU(maxBytes int64, maxEntries int) *byteLRU { + return &byteLRU{ + maxBytes: max(maxBytes, 1), maxEntries: max(maxEntries, 1), + lru: list.New(), by: map[string]*list.Element{}, flights: map[string]*lruFlight{}, + } +} + +// get is the value held for key if it was built under stamp. +func (c *byteLRU) get(key, stamp string) (any, bool) { + c.mu.Lock() + defer c.mu.Unlock() + el, ok := c.by[key] + if !ok || el.Value.(*lruEntry).stamp != stamp { + return nil, false + } + c.lru.MoveToFront(el) + return el.Value.(*lruEntry).value, true +} + +// put keeps a value, replacing whatever key held, and reports whether it was +// retained: a value over half the budget is not. +func (c *byteLRU) put(key, stamp string, value any, bytes int64) bool { + c.mu.Lock() + defer c.mu.Unlock() + if el, ok := c.by[key]; ok { + c.dropLocked(el) + } + if bytes > c.maxBytes/2 { + c.unretained.Add(1) + return false + } + c.by[key] = c.lru.PushFront(&lruEntry{key: key, stamp: stamp, value: value, bytes: bytes}) + c.bytes += bytes + for c.lru.Len() > c.maxEntries || c.bytes > c.maxBytes { + c.dropLocked(c.lru.Back()) + c.evicted.Add(1) + } + return true +} + +func (c *byteLRU) dropLocked(el *list.Element) { + e := c.lru.Remove(el).(*lruEntry) + delete(c.by, e.key) + c.bytes -= e.bytes +} + +func (c *byteLRU) forget(key string) { + c.mu.Lock() + defer c.mu.Unlock() + if el, ok := c.by[key]; ok { + c.dropLocked(el) + } +} + +// held is how many entries and bytes are retained right now. +func (c *byteLRU) held() (entries int, bytes int64) { + c.mu.Lock() + defer c.mu.Unlock() + return c.lru.Len(), c.bytes +} + +// do runs read once for everyone asking for flight at the same time. +func (c *byteLRU) do(ctx context.Context, flight string, read func(context.Context) (any, error)) (any, error) { + c.mu.Lock() + f, ok := c.flights[flight] + if ok { + c.coalesced.Add(1) + } else { + rctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), workReadTimeout) + f = &lruFlight{done: make(chan struct{}), cancel: cancel} + c.flights[flight] = f + go func() { + defer cancel() + value, err := read(rctx) + c.mu.Lock() + f.value, f.err = value, err + if c.flights[flight] == f { + delete(c.flights, flight) + } + c.mu.Unlock() + close(f.done) + }() + } + f.waiters++ + c.mu.Unlock() + select { + case <-f.done: + return f.value, f.err + case <-ctx.Done(): + c.mu.Lock() + f.waiters-- + if f.waiters == 0 && c.flights[flight] == f { + // NOBODY WANTS THIS READ: it is cancelled and forgotten at once, so a + // caller arriving next starts a read of its own rather than joining + // one that is about to answer with a cancellation. + f.cancel() + delete(c.flights, flight) + } + c.mu.Unlock() + return nil, ctx.Err() + } +} diff --git a/internal/session/workgraph_project.go b/internal/session/workgraph_project.go index fe57027879..29fef6f082 100644 --- a/internal/session/workgraph_project.go +++ b/internal/session/workgraph_project.go @@ -196,6 +196,9 @@ func nodeWaits(owner string, r taskRecord) []workgraph.Wait { for _, w := range waitRefs(r.WaitsExternal) { waits = append(waits, workgraph.Wait{On: workgraph.TaskRef{Owner: w.Session, Task: strconv.FormatUint(w.ID, 10)}, Via: workgraph.ViaExternal}) } + // A WAIT NAMES A SAFE REFERENCE OR IT IS NOT KEPT: an id read off a file is + // never carried further than the law that checks it. + waits = validWaits(waits) return waits } @@ -286,5 +289,16 @@ func planFact(owner string, t *plandb.Task, group *workgraph.GroupRef, spend map } waits = append(waits, workgraph.Wait{On: workgraph.TaskRef{Owner: owner, Task: wgPlanToken(dep.TaskID)}, Via: workgraph.ViaRecord}) } - return workgraph.Fact{Node: n, Waits: waits} + return workgraph.Fact{Node: n, Waits: validWaits(waits)} +} + +// validWaits keeps the waits whose target is a safe reference. +func validWaits(in []workgraph.Wait) []workgraph.Wait { + out := in[:0] + for _, w := range in { + if w.On.Valid() { + out = append(out, w) + } + } + return out } diff --git a/internal/session/workgraph_scale_test.go b/internal/session/workgraph_scale_test.go index 950fa97d4c..17b6f61ad8 100644 --- a/internal/session/workgraph_scale_test.go +++ b/internal/session/workgraph_scale_test.go @@ -2,6 +2,7 @@ package session import ( "context" + "encoding/json" "fmt" "os" "path/filepath" @@ -35,7 +36,13 @@ func scaleFolders(tb testing.TB, owners, each int) []string { if err := os.MkdirAll(dir, 0o755); err != nil { tb.Fatal(err) } - writeCheckpoint(tb.(*testing.T), Place{Dir: dir}.Tasks(), doc) + encoded, err := json.Marshal(doc) + if err != nil { + tb.Fatal(err) + } + if err := os.WriteFile(Place{Dir: dir}.Tasks(), encoded, 0o600); err != nil { + tb.Fatal(err) + } dirs = append(dirs, dir) } return dirs @@ -48,7 +55,10 @@ func TestTheSourceReadsTwoHundredThousandTasksOnceAndThenOnlyStats(t *testing.T) t.Skip("writes and reads 200 000 tasks") } dirs := scaleFolders(t, 200, 1000) - source := NewWorkSource() + // The budget is raised past the default so the whole scope is remembered; + // with the default, the least recently used conversations are dropped and + // read again (workgraph_bounds_test.go). + source := NewWorkSourceLimits(WorkLimits{CacheBytes: 1 << 30, CacheOwners: 1000}) ctx := context.Background() runtime.GC() var before, after runtime.MemStats diff --git a/internal/session/workgraph_source.go b/internal/session/workgraph_source.go index 9a7d9c5504..7b939c3717 100644 --- a/internal/session/workgraph_source.go +++ b/internal/session/workgraph_source.go @@ -27,12 +27,12 @@ import ( "encoding/json" "errors" "fmt" + "io" "os" "path/filepath" "sort" "strconv" "strings" - "sync" "sync/atomic" "github.com/Agent-Field/codeaf/internal/plandb" @@ -44,23 +44,87 @@ const ( WorkIssueMissing = "missing" WorkIssueUnreadable = "unreadable" WorkIssueTooLarge = "too_large" + // WorkIssuePartial: part of the conversation is here and part was left out at + // a bound; the facts that are here are true and the count is not the whole. + WorkIssuePartial = "partial" ) -// workFileMax is the largest checkpoint the source will parse. Past it the -// conversation is reported too_large rather than read whole. -const workFileMax = 256 << 20 +// WorkLimits are the bounds the source reads and remembers within. The zero +// value of a field takes its default; they are fields for the tests and for a +// process that wants other bounds, not settings a person sees. +type WorkLimits struct { + // CacheBytes and CacheOwners bound the conversations remembered: an estimate + // of their bytes and their count. A projection over half of CacheBytes is + // served and not retained. + CacheBytes int64 + CacheOwners int + // IndexBytes and IndexFiles bound the remembered check indexes the same way. + IndexBytes int64 + IndexFiles int + // FileMax is the largest checkpoint, and PlanMax the most bytes of plan store + // (database and its journal) read for one conversation. Past either the + // source does not read the file at all and says too_large. + FileMax int64 + PlanMax int64 + // IndexMax is the most bytes of a check index read: its NEWEST bytes. + IndexMax int64 + // Facts is the most facts one conversation yields; Waits the most stored + // waits one task yields. Past either the rest is left out and the + // conversation says partial. + Facts int + Waits int +} + +// The defaults. A task's projection is ~1 KiB, so these hold ~190 000 tasks. +const ( + defaultCacheBytes = 192 << 20 + defaultCacheOwners = 4096 + defaultIndexBytes = 32 << 20 + defaultIndexFiles = 64 + defaultFileMax = 48 << 20 + defaultPlanMax = 64 << 20 + defaultIndexMax = 32 << 20 + defaultFacts = 100_000 + defaultWaits = 1024 +) + +func (l WorkLimits) withDefaults() WorkLimits { + set := func(v *int64, d int64) { + if *v <= 0 { + *v = d + } + } + setN := func(v *int, d int) { + if *v <= 0 { + *v = d + } + } + set(&l.CacheBytes, defaultCacheBytes) + setN(&l.CacheOwners, defaultCacheOwners) + set(&l.IndexBytes, defaultIndexBytes) + setN(&l.IndexFiles, defaultIndexFiles) + set(&l.FileMax, defaultFileMax) + set(&l.PlanMax, defaultPlanMax) + set(&l.IndexMax, defaultIndexMax) + setN(&l.Facts, defaultFacts) + setN(&l.Waits, defaultWaits) + return l +} // ErrWorkOwnerMissing is the answer for a conversation folder that is not there. var ErrWorkOwnerMissing = errors.New("the conversation's folder is not there") // OwnerFacts is one conversation's tasks as generic facts, immutable once -// built. Issue is non-empty when part of the conversation could not be read; the -// facts that could be are still here. +// built, so any number of readers may hold it at once. Issue is non-empty when +// part of the conversation could not be read; the facts that could be are still +// here. type OwnerFacts struct { Owner string Stamp string Facts []workgraph.Fact Issue string + // Bytes is the estimate the cache budgets this projection at. + Bytes int64 // Externals are the other conversations this one's tasks wait on, sorted: the // sources a target's state is read from, so a caller can name them in its // revision. @@ -78,16 +142,15 @@ func (o *OwnerFacts) Find(task string) (*workgraph.Fact, bool) { return &o.Facts[i], true } -type ownerEntry struct { - stamp string - facts *OwnerFacts -} - // CheckIndex is the recorded check runs of a project's landed work, keyed by -// the conversation and task that earned them. +// the conversation and task that earned them. Partial says the index file was +// larger than the bound and only its newest bytes were read: a task with no +// entry then has NO RECORDED CHECKS KNOWN, not none. type CheckIndex struct { - Stamp string - by map[string]*workgraph.TaskChecks + Stamp string + Partial bool + Bytes int64 + by map[string]*workgraph.TaskChecks } // Of is the recorded checks of one task, or nil when the index has none. @@ -98,29 +161,59 @@ func (c *CheckIndex) Of(owner, task string) *workgraph.TaskChecks { return c.by[owner+":"+task] } -type indexEntry struct { - stamp string - index *CheckIndex -} - -// WorkSource reads and caches conversations' task facts. The zero value is not -// usable; use [NewWorkSource]. +// WorkSource reads and caches conversations' task facts within [WorkLimits]. +// The zero value is not usable; use [NewWorkSource]. type WorkSource struct { - mu sync.Mutex - owners map[string]*ownerEntry - index map[string]*indexEntry + limits WorkLimits + owners *byteLRU + index *byteLRU parses atomic.Int64 } -// NewWorkSource is an empty source. -func NewWorkSource() *WorkSource { - return &WorkSource{owners: map[string]*ownerEntry{}, index: map[string]*indexEntry{}} +// NewWorkSource is an empty source with the default bounds. +func NewWorkSource() *WorkSource { return NewWorkSourceLimits(WorkLimits{}) } + +// NewWorkSourceLimits is an empty source with the given bounds. +func NewWorkSourceLimits(l WorkLimits) *WorkSource { + l = l.withDefaults() + return &WorkSource{limits: l, owners: newByteLRU(l.CacheBytes, l.CacheOwners), index: newByteLRU(l.IndexBytes, l.IndexFiles)} } // Parses is how many conversation projections have been built from disk, for -// tests and diagnostics: an unchanged conversation never adds to it. +// tests and diagnostics: an unchanged, still-remembered conversation never adds +// to it. func (s *WorkSource) Parses() int64 { return s.parses.Load() } +// Held is what the source remembers right now: conversations and their +// estimated bytes, and check indexes and theirs. +func (s *WorkSource) Held() (owners int, ownerBytes int64, indexes int, indexBytes int64) { + owners, ownerBytes = s.owners.held() + indexes, indexBytes = s.index.held() + return owners, ownerBytes, indexes, indexBytes +} + +// Evictions is how many conversations were dropped for the budget, and how many +// were served without being retained for being over it. +func (s *WorkSource) Evictions() (evicted, unretained int64) { + return s.owners.evicted.Load(), s.owners.unretained.Load() +} + +// Coalesced is how many reads joined one already running. +func (s *WorkSource) Coalesced() int64 { return s.owners.coalesced.Load() + s.index.coalesced.Load() } + +// plainFile reports whether path is a regular file that is not a link: the +// source never follows a link out of a conversation's folder. +func plainFile(path string) bool { + info, err := os.Lstat(path) + return err == nil && info.Mode().IsRegular() +} + +// plainDir is the same for the folder itself. +func plainDir(path string) bool { + info, err := os.Lstat(path) + return err == nil && info.IsDir() +} + // workSources are the files a conversation's tasks come from, in a fixed order. func workSources(dir string) []string { db := filepath.Join(dir, planStoreFilename) @@ -131,9 +224,9 @@ func workSources(dir string) []string { } // Stamp names the sources of the conversation in dir as they are right now: stat -// calls only. The empty string is a folder that is not there. +// calls only. The empty string is a folder that is not there, or is a link. func (s *WorkSource) Stamp(dir string) string { - if info, err := os.Stat(dir); err != nil || !info.IsDir() { + if !plainDir(dir) { return "" } var b strings.Builder @@ -145,9 +238,9 @@ func (s *WorkSource) Stamp(dir string) string { } // Owner is the facts of the conversation owner, whose folder is dir. The same -// stamp answers from memory; a changed one is read once, by whoever asks first, -// and the others get that answer. A cancelled ctx stops the read between its -// files and caches nothing. +// stamp answers from memory; a changed one is read once, by whoever asks first +// (the others wait for that read), outside every lock. A cancelled ctx stops +// the wait, and the read itself when nobody is waiting. func (s *WorkSource) Owner(ctx context.Context, owner, dir string) (*OwnerFacts, error) { if !workgraph.ValidOwner(owner) { return nil, ErrWorkOwnerMissing @@ -156,74 +249,119 @@ func (s *WorkSource) Owner(ctx context.Context, owner, dir string) (*OwnerFacts, if stamp == "" { return nil, ErrWorkOwnerMissing } - s.mu.Lock() - if e := s.owners[dir]; e != nil && e.stamp == stamp && e.facts.Owner == owner { - facts := e.facts - s.mu.Unlock() - return facts, nil - } - s.mu.Unlock() - // A READ MUST NOT BE CACHED UNDER A STAMP THE READ ITSELF MOVED. Opening a - // WAL database to look at it can create its side files, so the stamp taken - // before the read is not the one the files settle at; the read is made again - // under the new one, and a result is kept only when the files held still - // across it. A conversation that is being written to as it is read is served - // from what was read and not remembered. + if v, ok := s.owners.get(dir, stamp); ok && v.(*OwnerFacts).Owner == owner { + return v.(*OwnerFacts), nil + } + v, err := s.owners.do(ctx, dir+"\x00"+owner+"\x00"+stamp, func(rctx context.Context) (any, error) { + return s.readStable(rctx, owner, dir, stamp) + }) + if err != nil { + return nil, err + } + return v.(*OwnerFacts), nil +} + +// readStable reads a conversation and keeps it only if its files held still. +// +// A READ MUST NOT BE CACHED UNDER A STAMP THE READ ITSELF MOVED. Opening a WAL +// database to look at it can create its side files, so the stamp taken before +// the read is not the one the files settle at; the read is made again under the +// new one, and a result is kept only when the files held still across it. A +// conversation that is being written to as it is read is served from what was +// read and not remembered. +func (s *WorkSource) readStable(ctx context.Context, owner, dir, stamp string) (*OwnerFacts, error) { var facts *OwnerFacts for pass := 0; pass < 2; pass++ { var err error - if facts, err = readOwner(ctx, owner, dir, stamp); err != nil { + if facts, err = s.readOwner(ctx, owner, dir, stamp); err != nil { return nil, err } s.parses.Add(1) - if after := s.Stamp(dir); after == stamp { - s.mu.Lock() - s.owners[dir] = &ownerEntry{stamp: stamp, facts: facts} - s.mu.Unlock() - break - } else { - stamp = after + after := s.Stamp(dir) + if after == stamp { + s.owners.put(dir, stamp, facts, facts.Bytes) + return facts, nil } + stamp = after } return facts, nil } // Forget drops what is held for the conversation in dir. -func (s *WorkSource) Forget(dir string) { - s.mu.Lock() - delete(s.owners, dir) - s.mu.Unlock() -} +func (s *WorkSource) Forget(dir string) { s.owners.forget(dir) } + +// loopCheck is how many items a loop handles between looks at its context. +const loopCheck = 1024 // readOwner builds one conversation's projection from its files. -func readOwner(ctx context.Context, owner, dir, stamp string) (*OwnerFacts, error) { +func (s *WorkSource) readOwner(ctx context.Context, owner, dir, stamp string) (*OwnerFacts, error) { out := &OwnerFacts{Owner: owner, Stamp: stamp} - doc, issue := readTaskDocument(dir) + doc, issue := s.readTaskDocument(dir) out.Issue = issue - titles := wgRunTitles(doc.Runs) - for _, r := range doc.Nodes { - out.Facts = append(out.Facts, nodeFact(owner, r)) - } - for _, r := range doc.Runs { - out.Facts = append(out.Facts, runFact(owner, r, titles)) + if err := s.projectDocument(ctx, out, doc); err != nil { + return nil, err } + plan := s.readPlanFacts(ctx, owner, dir) if err := ctx.Err(); err != nil { return nil, err } - if plan := readPlanFacts(ctx, owner, dir); plan.issue != "" { + if plan.issue != "" { out.Issue = firstIssue(out.Issue, plan.issue) } else { - out.Facts = append(out.Facts, plan.facts...) + out.add(s.limits, plan.facts) } - if err := ctx.Err(); err != nil { - return nil, err + return out, s.seal(ctx, out) +} + +// projectDocument projects the checkpoint's nodes and run rows, up to the +// per-conversation bound. +func (s *WorkSource) projectDocument(ctx context.Context, out *OwnerFacts, doc taskDocument) error { + titles := wgRunTitles(doc.Runs) + for i, r := range doc.Nodes { + if i%loopCheck == 0 && ctx.Err() != nil { + return ctx.Err() + } + out.add(s.limits, []workgraph.Fact{nodeFact(out.Owner, r)}) + } + for i, r := range doc.Runs { + if i%loopCheck == 0 && ctx.Err() != nil { + return ctx.Err() + } + out.add(s.limits, []workgraph.Fact{runFact(out.Owner, r, titles)}) + } + return nil +} + +// add appends facts within the bounds, bounding each fact's waits too. What does +// not fit is left out and the conversation says partial: nothing is cut silently. +func (o *OwnerFacts) add(l WorkLimits, facts []workgraph.Fact) { + for _, f := range facts { + if len(o.Facts) >= l.Facts { + o.Issue = firstIssue(o.Issue, WorkIssuePartial) + return + } + if len(f.Waits) > l.Waits { + f.Waits = f.Waits[:l.Waits] + o.Issue = firstIssue(o.Issue, WorkIssuePartial) + } + o.Facts = append(o.Facts, f) } +} + +// seal indexes the facts, names the conversations they wait on and prices the +// projection for the cache. +func (s *WorkSource) seal(ctx context.Context, out *OwnerFacts) error { out.byID = make(map[string]int32, len(out.Facts)) others := map[string]bool{} for i := range out.Facts { - out.byID[out.Facts[i].Node.Ref.Task] = int32(i) - for _, w := range out.Facts[i].Waits { - if w.On.Owner != owner { + if i%loopCheck == 0 && ctx.Err() != nil { + return ctx.Err() + } + f := &out.Facts[i] + out.byID[f.Node.Ref.Task] = int32(i) + out.Bytes += factBytes(f) + for _, w := range f.Waits { + if w.On.Owner != out.Owner { others[w.On.Owner] = true } } @@ -232,7 +370,28 @@ func readOwner(ctx context.Context, owner, dir, stamp string) (*OwnerFacts, erro out.Externals = append(out.Externals, other) } sort.Strings(out.Externals) - return out, nil + out.Bytes += int64(len(out.byID)) * 48 + return nil +} + +// factBytes is an estimate of what one fact holds: its fixed part, its strings +// and what it points at. It is an estimate for a budget, not a measurement. +func factBytes(f *workgraph.Fact) int64 { + n := &f.Node + b := int64(768) + int64(len(n.ID)+len(n.Ref.Owner)+len(n.Ref.Task)+len(n.Kind)+len(n.Title)+len(n.Parent)+len(n.Model)+len(n.Ending)) + if n.Instructions != nil { + b += 48 + int64(len(n.Instructions.Text)+len(n.Instructions.Source)) + } + if n.Group != nil { + b += 48 + int64(len(n.Group.ID)+len(n.Group.Title)) + } + for _, p := range n.Files.Paths { + b += 16 + int64(len(p)) + } + for _, w := range f.Waits { + b += 64 + int64(len(w.On.Owner)+len(w.On.Task)) + } + return b } // FileStamp names a file's present state (size, modification instant, inode), "-" @@ -249,19 +408,21 @@ func firstIssue(a, b string) string { // readTaskDocument is the conversation's tasks.json exactly as the engine // itself would load it (validated, with the person's deletions applied). A // folder with no checkpoint has no tasks and no issue; a checkpoint that will not -// parse is an issue and no tasks, the way the engine drops it whole. -func readTaskDocument(dir string) (taskDocument, string) { +// parse is an issue and no tasks, the way the engine drops it whole. A link, or +// a file over the bound, is never opened: it is unreadable, or too_large, from +// its stat alone. +func (s *WorkSource) readTaskDocument(dir string) (taskDocument, string) { file := Place{Dir: dir}.Tasks() - info, err := os.Stat(file) + info, err := os.Lstat(file) switch { case os.IsNotExist(err): return taskDocument{}, "" - case err != nil: + case err != nil, !info.Mode().IsRegular(): return taskDocument{}, WorkIssueUnreadable - case info.Size() > workFileMax: + case info.Size() > s.limits.FileMax: return taskDocument{}, WorkIssueTooLarge } - content, err := os.ReadFile(file) + content, err := readBounded(file, s.limits.FileMax) if err != nil { return taskDocument{}, WorkIssueUnreadable } @@ -269,9 +430,31 @@ func readTaskDocument(dir string) (taskDocument, string) { if err != nil { return taskDocument{}, WorkIssueUnreadable } + deleted := filepath.Join(dir, taskDeletedFile) + if _, err := os.Lstat(deleted); err == nil && !plainFile(deleted) { + return taskDocument{}, WorkIssueUnreadable + } return filterDeletedDocument(doc, taskDeletions(filepath.Join(dir, placeTranscript))), "" } +// readBounded reads a file and never more than limit bytes of it: a file that +// grew between the stat and the read is refused, not read whole. +func readBounded(path string, limit int64) ([]byte, error) { + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + content, err := io.ReadAll(io.LimitReader(file, limit+1)) + if err != nil { + return nil, err + } + if int64(len(content)) > limit { + return nil, errors.New("the file grew past its bound") + } + return content, nil +} + // planRead is what the conversation's plan stores gave. type planRead struct { facts []workgraph.Fact @@ -280,19 +463,31 @@ type planRead struct { // readPlanFacts reads every plan store of the conversation, ended runs first, // each through a read-only connection and only the rows this conversation's -// own tag names. A store that will not open is an issue, and the conversation -// then says so rather than showing a plan with a hole in it. -func readPlanFacts(ctx context.Context, owner, dir string) planRead { +// own tag names. A store that is a link, is over the byte bound together with +// its journal, or will not open is an issue, and the conversation then says so +// rather than showing a plan with a hole in it. +func (s *WorkSource) readPlanFacts(ctx context.Context, owner, dir string) planRead { db := filepath.Join(dir, planStoreFilename) var out planRead - paths := append(planArchivePaths(db), db) - for _, path := range paths { + var bytes int64 + for _, path := range append(planArchivePaths(db), db) { if ctx.Err() != nil { return out } - if _, err := os.Stat(path); err != nil { + info, err := os.Lstat(path) + if err != nil { continue } + bytes += info.Size() + if wal, err := os.Lstat(path + "-wal"); err == nil { + bytes += wal.Size() + } + switch { + case !info.Mode().IsRegular(): + return planRead{issue: WorkIssueUnreadable} + case bytes > s.limits.PlanMax: + return planRead{issue: WorkIssueTooLarge} + } facts, err := planStoreFacts(owner, path) if err != nil { return planRead{issue: WorkIssueUnreadable} @@ -324,60 +519,89 @@ func planStoreFacts(owner, path string) ([]workgraph.Fact, error) { return out, nil } -// Checks is the project's check index at path, parsed once per change. A missing -// index is an empty one. +// Checks is the project's check index at path, parsed once per change and +// remembered within the budget. A missing index is an empty one; a link is not +// followed. func (s *WorkSource) Checks(ctx context.Context, path string) (*CheckIndex, error) { stamp := wgStamp(path) - s.mu.Lock() - if e := s.index[path]; e != nil && e.stamp == stamp { - index := e.index - s.mu.Unlock() - return index, nil + if v, ok := s.index.get(path, stamp); ok { + return v.(*CheckIndex), nil } - s.mu.Unlock() - index, err := readCheckIndex(ctx, path, stamp) + v, err := s.index.do(ctx, path+"\x00"+stamp, func(rctx context.Context) (any, error) { + index, err := readCheckIndex(rctx, path, stamp, s.limits.IndexMax) + if err != nil { + return nil, err + } + s.index.put(path, stamp, index, index.Bytes) + return index, nil + }) if err != nil { return nil, err } - s.mu.Lock() - s.index[path] = &indexEntry{stamp: stamp, index: index} - s.mu.Unlock() - return index, nil + return v.(*CheckIndex), nil } -// readCheckIndex streams the index and keeps only the rows whose verdict rested -// on running the declared checks, so its memory is the checks and not the file. -func readCheckIndex(ctx context.Context, path, stamp string) (*CheckIndex, error) { +// readCheckIndex streams the NEWEST `limit` bytes of the index and keeps only +// the rows whose verdict rested on running the declared checks, so its memory +// is the checks and not the file. Older bytes are not read and the index says +// partial. +func readCheckIndex(ctx context.Context, path, stamp string, limit int64) (*CheckIndex, error) { out := &CheckIndex{Stamp: stamp, by: map[string]*workgraph.TaskChecks{}} - file, err := os.Open(path) + info, err := os.Lstat(path) if err != nil { if os.IsNotExist(err) { return out, nil } return nil, err } + if !info.Mode().IsRegular() { + return nil, errors.New("the index is not a plain file") + } + file, err := os.Open(path) + if err != nil { + return nil, err + } defer file.Close() - scanner := bufio.NewScanner(file) + skipFirst := false + if info.Size() > limit { + out.Partial, skipFirst = true, true + if _, err := file.Seek(info.Size()-limit, io.SeekStart); err != nil { + return nil, err + } + } + scanner := bufio.NewScanner(io.LimitReader(file, limit)) scanner.Buffer(make([]byte, 0, 4<<10), 256<<10) for n := 0; scanner.Scan(); n++ { - if n%1024 == 0 && ctx.Err() != nil { + if n%loopCheck == 0 && ctx.Err() != nil { return nil, ctx.Err() } - var row struct { - ID string `json:"id"` - SessionID string `json:"sessionId"` - VerdictBasis *plandb.VerdictBasis `json:"verdictBasis"` - } - if json.Unmarshal(scanner.Bytes(), &row) != nil || row.SessionID == "" { + if skipFirst { + // the first line of a tail read is a fragment + skipFirst = false continue } - if checks := wgChecks(row.VerdictBasis); checks != nil { - out.by[row.SessionID+":"+row.ID] = checks - } + wgIndexRow(out, scanner.Bytes()) } return out, nil } +// wgIndexRow records one index line's check runs, if its verdict rested on any. +func wgIndexRow(out *CheckIndex, line []byte) { + var row struct { + ID string `json:"id"` + SessionID string `json:"sessionId"` + VerdictBasis *plandb.VerdictBasis `json:"verdictBasis"` + } + if json.Unmarshal(line, &row) != nil || row.SessionID == "" || !workgraph.ValidOwner(row.SessionID) { + return + } + if checks := wgChecks(row.VerdictBasis); checks != nil { + key := row.SessionID + ":" + row.ID + out.by[key] = checks + out.Bytes += int64(len(key)) + 96 + } +} + // ExternalReader is the [ExternalTasks] reader a process supplies to its // conversations, over this source: locate names the folder of a conversation id // (and false for one it cannot find). It answers from the same cached diff --git a/internal/workgraph/types.go b/internal/workgraph/types.go index c7619039a2..0b2091a559 100644 --- a/internal/workgraph/types.go +++ b/internal/workgraph/types.go @@ -356,7 +356,7 @@ type Coverage struct { // OwnerIssue is one conversation the scope could not read. type OwnerIssue struct { Owner string `json:"owner"` - Reason string `json:"reason" enum:"missing,unreadable,too_large"` + Reason string `json:"reason" enum:"missing,unreadable,too_large,partial"` } // LinkCoverage counts nodes by whether their waits-on links were recorded. From fb20ec2648538a2964eac9a30adf36394a67e0a7 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 23:29:58 -0400 Subject: [PATCH 130/459] workgraph: the anchor to the person's words and the accepted route, where the record keeps them Co-Authored-By: Claude Sonnet 5.5 --- internal/session/workgraph_project.go | 40 +++++++++++++++++++++-- internal/session/workgraph_source_test.go | 29 ++++++++++++++++ internal/workgraph/build.go | 5 +++ internal/workgraph/types.go | 36 ++++++++++++++++---- 4 files changed, 100 insertions(+), 10 deletions(-) diff --git a/internal/session/workgraph_project.go b/internal/session/workgraph_project.go index 29fef6f082..67e2a28de6 100644 --- a/internal/session/workgraph_project.go +++ b/internal/session/workgraph_project.go @@ -20,6 +20,7 @@ package session import ( "path" + "path/filepath" "strconv" "strings" "time" @@ -149,9 +150,43 @@ func wgInstructions(r taskRecord) *workgraph.GraphText { return t } if t := wgText("admitted_brief", r.Brief, workgraph.MaxTextRunes); t != nil { + t.Anchor = wgAnchor(r.OriginJournal, r.OriginLine) return t } - return wgText("request", r.Request, workgraph.MaxTextRunes) + t := wgText("request", r.Request, workgraph.MaxTextRunes) + if t != nil { + t.Anchor = wgAnchor(r.OriginJournal, r.OriginLine) + } + return t +} + +// wgAnchor is the pointer a record keeps to the person's original words: the +// conversation whose journal it names and the line. A pointer with no line, or +// to a journal that is not some conversation's own, is not an anchor. +func wgAnchor(journal string, line int) *workgraph.TextAnchor { + if line <= 0 || filepath.Base(journal) != placeTranscript { + return nil + } + agent := filepath.Base(filepath.Dir(journal)) + if !workgraph.ValidOwner(agent) { + return nil + } + return &workgraph.TextAnchor{Agent: agent, Line: line} +} + +// wgRoute is the crew a run row was accepted onto, role by role. +func wgRoute(rec *TaskCrewRecord) []workgraph.RouteSeat { + if rec == nil || !rec.Routed { + return nil + } + var out []workgraph.RouteSeat + for _, pick := range rec.Decision.Crew { + if pick.Model == "" { + continue + } + out = append(out, workgraph.RouteSeat{Role: string(pick.Seat), Model: wgTitle(pick.Model), Pinned: pick.Pinned || rec.TaskPins[pick.Seat]}) + } + return out } // kindOf is the engine's kind word for a record. @@ -227,8 +262,7 @@ func runFact(owner string, r runRecord, titles map[uint64]string) workgraph.Fact Ending: string(r.Ending), Model: wgTitle(r.Model), Clock: workgraph.Clock{StartedAt: workgraph.Stamp(r.StartedAt), EndedAt: workgraph.Stamp(r.EndedAt)}, Landing: wgLanding(r.Merge, r.EndedAt), Files: wgFiles(r.Changed, nil), - Spend: wgSpend(r.CostUSD, 0), Links: "unrecorded", - Instructions: nil, + Spend: wgSpend(r.CostUSD, 0), Links: "unrecorded", Route: wgRoute(r.CrewState), } if r.State == TaskInterrupted { n.State = string(TaskRunning) diff --git a/internal/session/workgraph_source_test.go b/internal/session/workgraph_source_test.go index 8fd46ecede..caccc9cbf0 100644 --- a/internal/session/workgraph_source_test.go +++ b/internal/session/workgraph_source_test.go @@ -13,6 +13,7 @@ import ( "testing" "time" + "github.com/Agent-Field/codeaf/internal/crewroute" "github.com/Agent-Field/codeaf/internal/plandb" "github.com/Agent-Field/codeaf/internal/workgraph" ) @@ -301,3 +302,31 @@ func TestRelativePathsOnly(t *testing.T) { } } } + +// The anchor to the person's words and the accepted route are what the record +// carries and nothing more: no pointer, no anchor; no crew, no route. +func TestTheProjectionCarriesTheRecordedAnchorAndRouteOnly(t *testing.T) { + journal := filepath.Join("/x", "places", sourceOwner, placeTranscript) + with := nodeFact(sourceOwner, taskRecord{ID: 1, Title: "t", Brief: "do it", State: TaskQueued, OriginJournal: journal, OriginLine: 7}) + if a := with.Node.Instructions.Anchor; a == nil || a.Agent != sourceOwner || a.Line != 7 { + t.Fatalf("anchor = %+v", with.Node.Instructions) + } + for name, rec := range map[string]taskRecord{ + "no line": {ID: 2, Title: "t", Brief: "b", OriginJournal: journal}, + "not a transcript": {ID: 3, Title: "t", Brief: "b", OriginJournal: "/x/notes.txt", OriginLine: 4}, + "unsafe agent": {ID: 4, Title: "t", Brief: "b", OriginJournal: "/x/../" + placeTranscript, OriginLine: 4}, + } { + if a := nodeFact(sourceOwner, rec).Node.Instructions.Anchor; a != nil { + t.Errorf("%s: anchor = %+v", name, a) + } + } + crew := &TaskCrewRecord{Routed: true, TaskPins: map[crewroute.Seat]bool{crewroute.Checker: true}} + crew.Decision.Crew = []crewroute.Pick{{Seat: crewroute.Worker, Model: "a/worker"}, {Seat: crewroute.Checker, Model: "a/checker"}} + run := runFact(sourceOwner, runRecord{ID: 5, Title: "r", State: TaskDone, CrewState: crew}, nil) + if len(run.Node.Route) != 2 || run.Node.Route[0].Model != "a/worker" || run.Node.Route[0].Pinned || !run.Node.Route[1].Pinned { + t.Fatalf("route = %+v", run.Node.Route) + } + if got := runFact(sourceOwner, runRecord{ID: 6, Title: "r", State: TaskDone}, nil); got.Node.Route != nil { + t.Fatalf("a row with no crew has a route: %+v", got.Node.Route) + } +} diff --git a/internal/workgraph/build.go b/internal/workgraph/build.go index 0ba13354ee..0695d4c248 100644 --- a/internal/workgraph/build.go +++ b/internal/workgraph/build.go @@ -459,8 +459,13 @@ func cloneNode(n *GraphNode) *GraphNode { c.Clock = Clock{AdmittedAt: copyTime(n.Clock.AdmittedAt), StartedAt: copyTime(n.Clock.StartedAt), StateAt: copyTime(n.Clock.StateAt), EndedAt: copyTime(n.Clock.EndedAt)} if n.Instructions != nil { t := *n.Instructions + if n.Instructions.Anchor != nil { + a := *n.Instructions.Anchor + t.Anchor = &a + } c.Instructions = &t } + c.Route = append([]RouteSeat(nil), n.Route...) if n.Landing != nil { l := *n.Landing l.At = copyTime(n.Landing.At) diff --git a/internal/workgraph/types.go b/internal/workgraph/types.go index 0b2091a559..e48baabe44 100644 --- a/internal/workgraph/types.go +++ b/internal/workgraph/types.go @@ -320,6 +320,25 @@ type GraphText struct { Source string `json:"source"` Text string `json:"text"` Cut bool `json:"cut,omitempty"` + // Anchor is where the person's original words are, when the record points at + // them: the conversation and the line of its journal. Absent when the record + // carries no pointer — no anchor is ever made up. + Anchor *TextAnchor `json:"anchor,omitempty"` +} + +// TextAnchor points at a line of a conversation's journal. +type TextAnchor struct { + Agent string `json:"agent"` + Line int `json:"line"` +} + +// RouteSeat is one role of the crew a task was accepted to run on, as the record +// stored it at admission: the role, the model seated there, and whether a +// person pinned it. +type RouteSeat struct { + Role string `json:"role"` + Model string `json:"model"` + Pinned bool `json:"pinned,omitempty"` } // GraphTotals are the scope's true sizes, whatever the page holds. @@ -450,13 +469,16 @@ type GraphNode struct { // outside a crew scope and for an owner in no crew of it. Crew *CrewPlace `json:"crew,omitempty"` - Clock Clock `json:"clock"` - Instructions *GraphText `json:"instructions,omitempty"` - Model string `json:"model,omitempty"` - Landing *Landing `json:"landing,omitempty"` - Files FileSet `json:"files"` - Spend *TaskSpend `json:"spend,omitempty"` - Checks *TaskChecks `json:"checks,omitempty"` + Clock Clock `json:"clock"` + Instructions *GraphText `json:"instructions,omitempty"` + Model string `json:"model,omitempty"` + // Route is the crew the record accepted for the task, role by role. Absent + // where the record keeps none (only a handed-over run's row persists one). + Route []RouteSeat `json:"route,omitempty"` + Landing *Landing `json:"landing,omitempty"` + Files FileSet `json:"files"` + Spend *TaskSpend `json:"spend,omitempty"` + Checks *TaskChecks `json:"checks,omitempty"` // Links says whether this task's waits-on links were recorded ("recorded") // or the record never carried them ("unrecorded"). WaitsOnTotal is the true From 1c67b7c64492b03bf65ba0e7630ec31c0f6451ef Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 23:45:55 -0400 Subject: [PATCH 131/459] workgraph: hard byte budget for snapshots and reads, bounded admission, state_detail and route price Snapshot.Bytes() prices a snapshot field by field (measured 1.37x the live heap, never below it). The cache holds to bytes and entries with no oversize exemption: an over-budget snapshot is served whole and not kept. Distinct pending builds are bounded (ErrBusy -> busy), builds and raw decodes pass weighted cancellable gates, and the source refuses distinct reads past its flight limit. Aggregate budget 128 MiB. Plan rows carry the store's own status word as state_detail; an accepted route carries its published price. Co-Authored-By: Claude Sonnet 5.5 --- cmd/codeaf/appworkgraph.go | 57 ++++- cmd/codeaf/appworkgraph_test.go | 43 ++++ docs/changes/unreleased/0-work-graph.md | 5 +- internal/appwire/workgraph.go | 2 + internal/session/workgraph_bounds_test.go | 107 +++++++- internal/session/workgraph_lru.go | 24 +- internal/session/workgraph_project.go | 14 +- internal/session/workgraph_source.go | 93 ++++++- internal/session/workgraph_source_test.go | 17 +- internal/workgraph/build.go | 11 +- internal/workgraph/bytes.go | 150 +++++++++++ internal/workgraph/cache.go | 157 ++++++++---- internal/workgraph/cache_test.go | 299 ++++++++++++++++++++++ internal/workgraph/gate.go | 93 +++++++ internal/workgraph/types.go | 27 +- 15 files changed, 1027 insertions(+), 72 deletions(-) create mode 100644 internal/workgraph/bytes.go create mode 100644 internal/workgraph/cache_test.go create mode 100644 internal/workgraph/gate.go diff --git a/cmd/codeaf/appworkgraph.go b/cmd/codeaf/appworkgraph.go index f7b249884d..67a4090237 100644 --- a/cmd/codeaf/appworkgraph.go +++ b/cmd/codeaf/appworkgraph.go @@ -23,6 +23,7 @@ import ( "context" "crypto/sha256" "encoding/hex" + "errors" "os" "path/filepath" "sort" @@ -42,9 +43,21 @@ const ( // graphMaxExternal is the most other conversations a scope reads only to // say the state of a task its own tasks wait on. graphMaxExternal = 64 - // graphCacheEntries and graphCacheNodes bound the snapshots held. + // The snapshots held: at most graphCacheEntries of them and + // session.WorkSnapshotBytes between them, counted by Snapshot.Bytes. One + // bigger than that is served and not kept. graphPending is how many + // DISTINCT builds may be queued or running (a request for a build already + // pending joins it for free), and graphBuildBytes the weight of builds + // running at once, a build weighing graphBuildWeight bytes a fact. graphCacheEntries = 8 - graphCacheNodes = 600_000 + graphPending = 8 + graphBuildBytes = 64 << 20 + graphBuildWeight = 4608 + // graphRequestBytes is the working set of requests loading at once, weighed + // by their files' sizes, and graphRequestWaiters how many may wait for room; + // the next is told busy. + graphRequestBytes = 64 << 20 + graphRequestWaiters = 16 // graphMaxUnread is how many unreadable conversations an answer names. graphMaxUnread = 16 ) @@ -60,6 +73,8 @@ type appWorkGraph struct { once sync.Once source *session.WorkSource cache *workgraph.Cache + // requests admits the working set of a request before it reads anything. + requests *workgraph.Gate mu sync.Mutex dirs map[string]string @@ -75,7 +90,10 @@ type crewMemo struct { func (g *appWorkGraph) init() { g.once.Do(func() { g.source = session.NewWorkSource() - g.cache = workgraph.NewCache(graphCacheEntries, graphCacheNodes) + g.cache = workgraph.NewCache(workgraph.CacheLimits{ + Bytes: session.WorkSnapshotBytes, Entries: graphCacheEntries, Pending: graphPending, BuildBytes: graphBuildBytes, + }) + g.requests = workgraph.NewGate(graphRequestBytes).Limit(graphRequestWaiters) g.dirs = map[string]string{} }) } @@ -105,6 +123,28 @@ func (b *appBackend) WorkGraph(ctx context.Context, req workgraph.GraphRequest) if err != nil { return nil, err } + held, err := g.requests.Acquire(ctx, g.requestWeight(plan)) + if err != nil { + return nil, err + } + defer g.requests.Release(held) + res, err := g.answer(ctx, req, plan) + if errors.Is(err, session.ErrWorkBusy) { + return nil, workgraph.ErrBusy + } + return res, err +} + +// requestWeight is what a plan's files hold at peak once read. +func (g *appWorkGraph) requestWeight(plan graphPlan) int64 { + var w int64 + for _, o := range plan.owners { + w += g.source.RawWeight(o.dir) + } + return w +} + +func (g *appWorkGraph) answer(ctx context.Context, req workgraph.GraphRequest, plan graphPlan) (*workgraph.GraphResult, error) { loaded, err := g.load(ctx, plan) if err != nil { return nil, err @@ -113,7 +153,7 @@ func (b *appBackend) WorkGraph(ctx context.Context, req workgraph.GraphRequest) if req.IfRevision != "" && req.IfRevision == revision { return unchangedGraph(req, revision), nil } - snap, err := g.cache.Get(ctx, req.Scope, revision, func(ctx context.Context) (workgraph.Input, error) { + snap, err := g.cache.GetWeighted(ctx, req.Scope, revision, int64(loaded.factCount())*graphBuildWeight, func(ctx context.Context) (workgraph.Input, error) { return loaded.input(ctx, g, plan, revision) }) if err != nil { @@ -122,6 +162,15 @@ func (b *appBackend) WorkGraph(ctx context.Context, req workgraph.GraphRequest) return snap.Answer(ctx, req) } +// factCount is how many facts the build will be given. +func (l *graphLoaded) factCount() int { + n := 0 + for _, o := range l.owners { + n += len(o.Facts) + } + return min(n, workgraph.MaxScopeNodes) +} + func unchangedGraph(req workgraph.GraphRequest, revision string) *workgraph.GraphResult { view := req.View if view == "" { diff --git a/cmd/codeaf/appworkgraph_test.go b/cmd/codeaf/appworkgraph_test.go index 4d2f6ebbbb..d1177fbb23 100644 --- a/cmd/codeaf/appworkgraph_test.go +++ b/cmd/codeaf/appworkgraph_test.go @@ -224,3 +224,46 @@ func TestWorkGraphUnchangedPagingAndRefusals(t *testing.T) { t.Fatalf("unsafe task id = %v", bad) } } + +// A SCOPE TOO BIG FOR THE SNAPSHOT SHARE is answered in full and paged normally, +// and never remembered: each ask pays the build, the cache holds nothing of it, +// and the totals are the true ones — the cache was made to forget, the graph was +// not cut. +func TestAScopeOverTheSnapshotBudgetIsServedColdAndNeverKept(t *testing.T) { + _, w, crew := graphFixture(t) + scope := `{"kind":"crew","id":"` + crew + `"}` + prior := workGraphs.cache + t.Cleanup(func() { workGraphs.cache = prior }) + workGraphs.cache = workgraph.NewCache(workgraph.CacheLimits{Bytes: 512, Entries: 8, Pending: 4, BuildBytes: 1 << 20}) + builds, _ := workGraphs.cache.Stats() + for i := 0; i < 2; i++ { + got := w.graph(`{"scope":` + scope + `}`) + if got.Totals.Nodes != 3 || len(got.Nodes) != 3 { + t.Fatalf("ask %d answered %d of %d nodes", i, len(got.Nodes), got.Totals.Nodes) + } + } + if entries, held := workGraphs.cache.Held(); entries != 0 || held != 0 { + t.Fatalf("an over-budget snapshot is held: %d entries, %d bytes", entries, held) + } + if after, _ := workGraphs.cache.Stats(); after-builds != 2 { + t.Fatalf("builds went %d -> %d; an uncached scope pays each time", builds, after) + } +} + +// Past the pending-build limit the wire says busy, and says it to the caller who +// would have started one build too many. +func TestWorkGraphSaysBusyWhenTooManyDistinctBuildsAreQueued(t *testing.T) { + _, w, crew := graphFixture(t) + prior := workGraphs.cache + t.Cleanup(func() { workGraphs.cache = prior }) + workGraphs.cache = workgraph.NewCache(workgraph.CacheLimits{Bytes: 1 << 20, Entries: 8, Pending: 1, BuildBytes: 1 << 20}) + hold := make(chan struct{}) + started := make(chan struct{}) + go workGraphs.cache.Get(context.Background(), workgraph.GraphScope{Kind: workgraph.ScopeCrew, ID: "other"}, "r", + func(ctx context.Context) (workgraph.Input, error) { close(started); <-hold; return workgraph.Input{}, ctx.Err() }) + <-started + defer close(hold) + if _, bad := w.call(`{"scope":{"kind":"crew","id":"` + crew + `"}}`); bad == nil || bad["code"] != appwire.CodeBusy { + t.Fatalf("a build beyond the pending limit answered %v", bad) + } +} diff --git a/docs/changes/unreleased/0-work-graph.md b/docs/changes/unreleased/0-work-graph.md index 71da6f7ed3..944314e8f9 100644 --- a/docs/changes/unreleased/0-work-graph.md +++ b/docs/changes/unreleased/0-work-graph.md @@ -13,4 +13,7 @@ New optional wire method `work.graph` (feature `work.graph`; `unknown_method` fr without it). Pages are bounded in nodes, edges and words, every total is true, and an edge exists only where a wait was stored and enforced. Conversations are read from `tasks.json` and the plan stores through read-only connections and cached by file stamp, so an unchanged conversation is -never reparsed. `docs/design/app-v3/PROTOCOL.md` ("Work graph") has the contract. +never reparsed. Everything it holds or has in flight is bounded to 128 MiB in bytes (snapshots by a +conservative estimate), a snapshot too big to keep is served whole and not kept, and a build beyond the +pending limit is `busy`. Plan rows carry the store's own `state_detail`, and an accepted route carries its +published `price` (absent = unknown). `docs/design/app-v3/PROTOCOL.md` ("Work graph") has the contract. diff --git a/internal/appwire/workgraph.go b/internal/appwire/workgraph.go index f8e0a7f5ae..295452c191 100644 --- a/internal/appwire/workgraph.go +++ b/internal/appwire/workgraph.go @@ -44,6 +44,8 @@ func workGraphError(err error) error { return Errorf(CodeBadParams, "%s", trimPrefix(err.Error(), "workgraph: bad request: ")) case errors.Is(err, workgraph.ErrNotFound): return Errorf(CodeNotFound, "%s", trimPrefix(err.Error(), "workgraph: not found: ")) + case errors.Is(err, workgraph.ErrBusy): + return Errorf(CodeBusy, "work.graph is busy with other scopes; try again") case errors.Is(err, context.DeadlineExceeded): return Errorf(CodeEngine, "work.graph took longer than %s", workgraph.BuildTimeout) case errors.Is(err, context.Canceled): diff --git a/internal/session/workgraph_bounds_test.go b/internal/session/workgraph_bounds_test.go index 60f5b3d6c5..2a194a06d8 100644 --- a/internal/session/workgraph_bounds_test.go +++ b/internal/session/workgraph_bounds_test.go @@ -6,11 +6,14 @@ package session import ( "context" + "errors" "fmt" "os" "path/filepath" + "runtime" "strings" "sync" + "sync/atomic" "testing" "time" ) @@ -190,7 +193,7 @@ func TestAWaiterThatLeavesCancelsTheReadAndTheNextCallerSucceeds(t *testing.T) { t.Fatal("a cancelled caller kept waiting") } got, err := source.Owner(context.Background(), wgOwnerOf(dirs[0]), dirs[0]) - if err != nil || len(got.Facts) != 30000 { + if err != nil || len(got.Facts) != defaultFacts || got.Issue != WorkIssuePartial { t.Fatalf("next caller: %d facts, %v", len(got.Facts), err) } } @@ -297,3 +300,105 @@ func BenchmarkOwnerEvictingReads(b *testing.B) { } } } + +// MANY DISTINCT OWNERS AT ONCE: two hundred different conversations are asked for +// in the same instant. At most Flights of them are read; the rest are refused as +// busy at the door, with no goroutine queued for them, and the ones admitted all +// come back whole. +func TestManyDistinctOwnersAtOnceAreAdmittedUpToTheFlightLimit(t *testing.T) { + dirs := wgOwners(t, 200, 40) + const flights = 4 + source := NewWorkSourceLimits(WorkLimits{Flights: flights}) + baseline := runtime.NumGoroutine() + var wg sync.WaitGroup + var served, busy atomic.Int64 + start := make(chan struct{}) + for _, dir := range dirs { + wg.Add(1) + go func() { + defer wg.Done() + <-start + got, err := source.Owner(context.Background(), wgOwnerOf(dir), dir) + switch { + case err == nil && len(got.Facts) == 40: + served.Add(1) + case errors.Is(err, ErrWorkBusy): + busy.Add(1) + default: + t.Errorf("%v (%v)", err, got) + } + }() + } + close(start) + wg.Wait() + if served.Load() < flights || busy.Load() == 0 || served.Load()+busy.Load() != 200 { + t.Fatalf("served %d, busy %d", served.Load(), busy.Load()) + } + if refused := source.owners.refused.Load(); refused != busy.Load() { + t.Fatalf("refused %d, callers told busy %d", refused, busy.Load()) + } + time.Sleep(50 * time.Millisecond) + if extra := runtime.NumGoroutine() - baseline; extra > 4 { + t.Fatalf("%d goroutines left behind", extra) + } +} + +// THE RAW GATE: the weight of reads decoding at once never passes the gate, a +// read heavier than the whole runs alone, and a waiter that leaves frees its room. +func TestRawDecodesAreWeighedAndTheGateNeverOverfills(t *testing.T) { + dirs := wgOwners(t, 12, 200) + one := NewWorkSource().rawWeight(dirs[0]) + if one <= 0 { + t.Fatalf("weight %d", one) + } + // The gate fits two decodes; twelve conversations are read, flights allowed. + source := NewWorkSourceLimits(WorkLimits{Flights: 12, RawBytes: one * 2}) + var wg sync.WaitGroup + for _, dir := range dirs { + wg.Add(1) + go func() { + defer wg.Done() + if _, err := source.Owner(context.Background(), wgOwnerOf(dir), dir); err != nil { + t.Error(err) + } + }() + } + wg.Wait() + if source.Parses() < 12 { + t.Fatalf("parses %d", source.Parses()) + } + // A gate smaller than any one decode still reads, alone. + tiny := NewWorkSourceLimits(WorkLimits{RawBytes: 1}) + if got, err := tiny.Owner(context.Background(), wgOwnerOf(dirs[0]), dirs[0]); err != nil || len(got.Facts) != 200 { + t.Fatalf("a heavy read alone: %v", err) + } +} + +// A caller waiting for room at the gate stops at its own deadline. +func TestAReadWaitingForRawRoomStopsWithItsCaller(t *testing.T) { + dirs := wgOwners(t, 2, 50) + source := NewWorkSourceLimits(WorkLimits{RawBytes: 1}) + held, err := source.raw.Acquire(context.Background(), 1) + if err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(context.Background(), 50*time.Millisecond) + defer cancel() + if _, err := source.Owner(ctx, wgOwnerOf(dirs[0]), dirs[0]); !errors.Is(err, context.DeadlineExceeded) { + t.Fatalf("err = %v", err) + } + source.raw.Release(held) + if _, err := source.Owner(context.Background(), wgOwnerOf(dirs[0]), dirs[0]); err != nil { + t.Fatal(err) + } +} + +// The aggregate budget is a number somebody can add up: the source's three +// shares plus the snapshot cache's must not pass 128 MiB. +func TestTheAggregateBudgetIsAtMostOneHundredTwentyEightMiB(t *testing.T) { + l := WorkLimits{}.withDefaults() + sum := l.CacheBytes + l.IndexBytes + l.RawBytes + WorkSnapshotBytes + if sum > 128<<20 { + t.Fatalf("source %d + index %d + raw %d + snapshots %d = %d MiB", l.CacheBytes>>20, l.IndexBytes>>20, l.RawBytes>>20, WorkSnapshotBytes>>20, sum>>20) + } +} diff --git a/internal/session/workgraph_lru.go b/internal/session/workgraph_lru.go index c7675cfd92..470ba53255 100644 --- a/internal/session/workgraph_lru.go +++ b/internal/session/workgraph_lru.go @@ -16,11 +16,14 @@ package session // Reads are coalesced: callers asking for the same key and stamp at once share // one read, run on a goroutine of its own under its own deadline and with no // lock held. A caller whose context ends stops waiting, and when the last waiter -// leaves, the read is cancelled. +// leaves, the read is cancelled. The number of DISTINCT reads in flight is +// bounded too: past it a new read is refused with [ErrWorkBusy] instead of +// queueing a goroutine for it. import ( "container/list" "context" + "errors" "sync" "sync/atomic" "time" @@ -43,10 +46,16 @@ type lruFlight struct { err error } +// ErrWorkBusy is the answer when the source already has as many distinct reads +// in flight as it admits. It is an answer and not a failure of the files: the +// same ask a moment later is served. +var ErrWorkBusy = errors.New("the work source is busy reading other conversations") + type byteLRU struct { mu sync.Mutex maxBytes int64 maxEntries int + maxFlights int bytes int64 lru *list.List by map[string]*list.Element @@ -55,11 +64,12 @@ type byteLRU struct { evicted atomic.Int64 unretained atomic.Int64 coalesced atomic.Int64 + refused atomic.Int64 } -func newByteLRU(maxBytes int64, maxEntries int) *byteLRU { +func newByteLRU(maxBytes int64, maxEntries, maxFlights int) *byteLRU { return &byteLRU{ - maxBytes: max(maxBytes, 1), maxEntries: max(maxEntries, 1), + maxBytes: max(maxBytes, 1), maxEntries: max(maxEntries, 1), maxFlights: max(maxFlights, 1), lru: list.New(), by: map[string]*list.Element{}, flights: map[string]*lruFlight{}, } } @@ -125,6 +135,14 @@ func (c *byteLRU) do(ctx context.Context, flight string, read func(context.Conte if ok { c.coalesced.Add(1) } else { + if len(c.flights) >= c.maxFlights { + // ADMISSION: another distinct read would be one more goroutine and one + // more decode in memory. Callers for a read already in flight join it + // above and cost nothing; only a NEW read is refused. + c.mu.Unlock() + c.refused.Add(1) + return nil, ErrWorkBusy + } rctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), workReadTimeout) f = &lruFlight{done: make(chan struct{}), cancel: cancel} c.flights[flight] = f diff --git a/internal/session/workgraph_project.go b/internal/session/workgraph_project.go index 67e2a28de6..5bd1dfc249 100644 --- a/internal/session/workgraph_project.go +++ b/internal/session/workgraph_project.go @@ -25,6 +25,7 @@ import ( "strings" "time" + "github.com/Agent-Field/codeaf/internal/crewroute" "github.com/Agent-Field/codeaf/internal/plandb" "github.com/Agent-Field/codeaf/internal/redact" "github.com/Agent-Field/codeaf/internal/workgraph" @@ -175,6 +176,15 @@ func wgAnchor(journal string, line int) *workgraph.TextAnchor { } // wgRoute is the crew a run row was accepted onto, role by role. +// wgPrice is the accepted route's published tariff as the record stored it; nil +// stays nil, which the graph says as unknown. +func wgPrice(p *crewroute.RoutePrice) *workgraph.RoutePrice { + if p == nil { + return nil + } + return &workgraph.RoutePrice{Prompt: p.Prompt, Completion: p.Completion, CacheRead: p.CacheRead} +} + func wgRoute(rec *TaskCrewRecord) []workgraph.RouteSeat { if rec == nil || !rec.Routed { return nil @@ -184,7 +194,7 @@ func wgRoute(rec *TaskCrewRecord) []workgraph.RouteSeat { if pick.Model == "" { continue } - out = append(out, workgraph.RouteSeat{Role: string(pick.Seat), Model: wgTitle(pick.Model), Pinned: pick.Pinned || rec.TaskPins[pick.Seat]}) + out = append(out, workgraph.RouteSeat{Role: string(pick.Seat), Model: wgTitle(pick.Model), Pinned: pick.Pinned || rec.TaskPins[pick.Seat], Price: wgPrice(pick.Price)}) } return out } @@ -306,7 +316,7 @@ func planFact(owner string, t *plandb.Task, group *workgraph.GroupRef, spend map tok := wgPlanToken(t.ID) n := workgraph.GraphNode{ ID: owner + ":" + tok, Ref: workgraph.TaskRef{Owner: owner, Task: tok}, Kind: "plan", - Title: wgTitle(t.Title), State: planStateWord(t), Paused: t.Paused, + Title: wgTitle(t.Title), State: planStateWord(t), StateDetail: string(t.Status), Paused: t.Paused, Stopped: t.Status == plandb.StatusCancelled && planStopReason(t.Error), Clock: workgraph.Clock{AdmittedAt: workgraph.Stamp(t.CreatedAt), EndedAt: workgraph.Stamp(t.CompletedAt)}, Instructions: wgText("admitted_brief", t.Description, workgraph.MaxTextRunes), diff --git a/internal/session/workgraph_source.go b/internal/session/workgraph_source.go index 7b939c3717..325667a939 100644 --- a/internal/session/workgraph_source.go +++ b/internal/session/workgraph_source.go @@ -68,6 +68,14 @@ type WorkLimits struct { PlanMax int64 // IndexMax is the most bytes of a check index read: its NEWEST bytes. IndexMax int64 + // Flights bounds the distinct conversation reads in flight at once, and + // IndexFlights the check-index reads; past either a new read is refused with + // [ErrWorkBusy]. RawBytes bounds the WEIGHT of the reads decoding at once + // (their files' bytes, taken at the decode's own inflation): a read waits + // for room, cancellably, and one heavier than the whole runs alone. + Flights int + IndexFlights int + RawBytes int64 // Facts is the most facts one conversation yields; Waits the most stored // waits one task yields. Past either the rest is left out and the // conversation says partial. @@ -75,17 +83,37 @@ type WorkLimits struct { Waits int } -// The defaults. A task's projection is ~1 KiB, so these hold ~190 000 tasks. +// THE AGGREGATE BUDGET. Everything the work graph holds on to or has in flight +// sums to at most 128 MiB, so that it is a number a laptop can be promised: +// +// conversations remembered 32 MiB (this file) +// check indexes remembered 8 MiB (this file) +// raw reads decoding at once 32 MiB (this file, weighed at decode inflation) +// snapshots remembered 56 MiB (cmd/codeaf, workgraph.Cache) +// +// A scope too big for the snapshot share (at ~1.4 KiB a task, about 40 000 +// tasks) is served in full and not remembered: each ask pays the cold cost +// honestly. A task's projection is ~1 KiB, so a conversation is bounded at +// 20 000 facts and its checkpoint at 16 MiB. const ( - defaultCacheBytes = 192 << 20 - defaultCacheOwners = 4096 - defaultIndexBytes = 32 << 20 - defaultIndexFiles = 64 - defaultFileMax = 48 << 20 - defaultPlanMax = 64 << 20 - defaultIndexMax = 32 << 20 - defaultFacts = 100_000 - defaultWaits = 1024 + // WorkSnapshotBytes is the share the app's snapshot cache is built with, + // written here so the whole budget is added up in one place. + WorkSnapshotBytes = 56 << 20 + defaultCacheBytes = 32 << 20 + defaultCacheOwners = 4096 + defaultIndexBytes = 8 << 20 + defaultIndexFiles = 64 + defaultFileMax = 16 << 20 + defaultPlanMax = 16 << 20 + defaultIndexMax = 8 << 20 + defaultFacts = 20_000 + defaultWaits = 1024 + defaultFlights = 8 + defaultIndexFlights = 4 + defaultRawBytes = 32 << 20 + // rawInflation is how many times its file's bytes a decode holds at peak: + // the raw bytes, the decoded structs and the projected facts. + rawInflation = 3 ) func (l WorkLimits) withDefaults() WorkLimits { @@ -106,6 +134,9 @@ func (l WorkLimits) withDefaults() WorkLimits { set(&l.FileMax, defaultFileMax) set(&l.PlanMax, defaultPlanMax) set(&l.IndexMax, defaultIndexMax) + set(&l.RawBytes, defaultRawBytes) + setN(&l.Flights, defaultFlights) + setN(&l.IndexFlights, defaultIndexFlights) setN(&l.Facts, defaultFacts) setN(&l.Waits, defaultWaits) return l @@ -167,6 +198,7 @@ type WorkSource struct { limits WorkLimits owners *byteLRU index *byteLRU + raw *workgraph.Gate parses atomic.Int64 } @@ -176,7 +208,10 @@ func NewWorkSource() *WorkSource { return NewWorkSourceLimits(WorkLimits{}) } // NewWorkSourceLimits is an empty source with the given bounds. func NewWorkSourceLimits(l WorkLimits) *WorkSource { l = l.withDefaults() - return &WorkSource{limits: l, owners: newByteLRU(l.CacheBytes, l.CacheOwners), index: newByteLRU(l.IndexBytes, l.IndexFiles)} + return &WorkSource{ + limits: l, raw: workgraph.NewGate(l.RawBytes), + owners: newByteLRU(l.CacheBytes, l.CacheOwners, l.Flights), index: newByteLRU(l.IndexBytes, l.IndexFiles, l.IndexFlights), + } } // Parses is how many conversation projections have been built from disk, for @@ -253,6 +288,11 @@ func (s *WorkSource) Owner(ctx context.Context, owner, dir string) (*OwnerFacts, return v.(*OwnerFacts), nil } v, err := s.owners.do(ctx, dir+"\x00"+owner+"\x00"+stamp, func(rctx context.Context) (any, error) { + held, err := s.raw.Acquire(rctx, s.rawWeight(dir)) + if err != nil { + return nil, err + } + defer s.raw.Release(held) return s.readStable(rctx, owner, dir, stamp) }) if err != nil { @@ -261,6 +301,24 @@ func (s *WorkSource) Owner(ctx context.Context, owner, dir string) (*OwnerFacts, return v.(*OwnerFacts), nil } +// rawWeight is what decoding the conversation's files holds at peak, from their +// sizes alone: the checkpoint and the plan stores with their journals, each at +// the inflation of its decode and never more than the bound the read itself +// refuses at. +func (s *WorkSource) rawWeight(dir string) int64 { + tasks := min(wgSize(Place{Dir: dir}.Tasks()), s.limits.FileMax) + db := filepath.Join(dir, planStoreFilename) + var plan int64 + for _, path := range append(planArchivePaths(db), db) { + plan += wgSize(path) + wgSize(path+"-wal") + } + return (tasks + min(plan, s.limits.PlanMax)) * rawInflation +} + +// RawWeight is what reading the conversation in dir would hold at peak, so a +// caller can ask for room before it asks for the conversation. +func (s *WorkSource) RawWeight(dir string) int64 { return s.rawWeight(dir) } + // readStable reads a conversation and keeps it only if its files held still. // // A READ MUST NOT BE CACHED UNDER A STAMP THE READ ITSELF MOVED. Opening a WAL @@ -528,6 +586,11 @@ func (s *WorkSource) Checks(ctx context.Context, path string) (*CheckIndex, erro return v.(*CheckIndex), nil } v, err := s.index.do(ctx, path+"\x00"+stamp, func(rctx context.Context) (any, error) { + held, err := s.raw.Acquire(rctx, min(s.limits.IndexMax, max(1, wgSize(path)))) + if err != nil { + return nil, err + } + defer s.raw.Release(held) index, err := readCheckIndex(rctx, path, stamp, s.limits.IndexMax) if err != nil { return nil, err @@ -541,6 +604,14 @@ func (s *WorkSource) Checks(ctx context.Context, path string) (*CheckIndex, erro return v.(*CheckIndex), nil } +// wgSize is a plain file's size, and zero for anything else. +func wgSize(path string) int64 { + if info, err := os.Lstat(path); err == nil && info.Mode().IsRegular() { + return info.Size() + } + return 0 +} + // readCheckIndex streams the NEWEST `limit` bytes of the index and keeps only // the rows whose verdict rested on running the declared checks, so its memory // is the checks and not the file. Older bytes are not read and the index says diff --git a/internal/session/workgraph_source_test.go b/internal/session/workgraph_source_test.go index caccc9cbf0..f05f2a150f 100644 --- a/internal/session/workgraph_source_test.go +++ b/internal/session/workgraph_source_test.go @@ -184,6 +184,11 @@ func TestTheSourceStatesLandingFilesAndTheRecordedClock(t *testing.T) { if n.Clock.AdmittedAt.After(time.Now()) || n.Clock.StateAt.Before(*n.Clock.AdmittedAt) { t.Fatalf("clock is not the events': %+v", n.Clock) } + // A plan row keeps the store's own status word beside the mapped state, so + // queued-and-waiting ("pending") is told from queued-and-free ("ready"). + if plan, _ := owner.Find("t-b"); plan.Node.StateDetail != string(plandb.StatusPending) && plan.Node.StateDetail != string(plandb.StatusReady) { + t.Fatalf("plan b state_detail = %q", plan.Node.StateDetail) + } // A plan row never carries a start it was not given. plan, _ := owner.Find("t-a") if plan.Node.Clock.StartedAt != nil || plan.Node.Clock.AdmittedAt == nil { @@ -321,11 +326,21 @@ func TestTheProjectionCarriesTheRecordedAnchorAndRouteOnly(t *testing.T) { } } crew := &TaskCrewRecord{Routed: true, TaskPins: map[crewroute.Seat]bool{crewroute.Checker: true}} - crew.Decision.Crew = []crewroute.Pick{{Seat: crewroute.Worker, Model: "a/worker"}, {Seat: crewroute.Checker, Model: "a/checker"}} + crew.Decision.Crew = []crewroute.Pick{ + {Seat: crewroute.Worker, Model: "a/worker", Price: &crewroute.RoutePrice{Prompt: 1e-6, Completion: 4e-6, CacheRead: 1e-7}}, + {Seat: crewroute.Checker, Model: "a/checker"}, + } run := runFact(sourceOwner, runRecord{ID: 5, Title: "r", State: TaskDone, CrewState: crew}, nil) if len(run.Node.Route) != 2 || run.Node.Route[0].Model != "a/worker" || run.Node.Route[0].Pinned || !run.Node.Route[1].Pinned { t.Fatalf("route = %+v", run.Node.Route) } + // The price is the accepted route's own tariff; a pick that stored none is unknown, not free. + if p := run.Node.Route[0].Price; p == nil || p.Prompt != 1e-6 || p.Completion != 4e-6 || p.CacheRead != 1e-7 { + t.Fatalf("worker price = %+v", p) + } + if run.Node.Route[1].Price != nil { + t.Fatalf("an unpriced pick carries a price: %+v", run.Node.Route[1].Price) + } if got := runFact(sourceOwner, runRecord{ID: 6, Title: "r", State: TaskDone}, nil); got.Node.Route != nil { t.Fatalf("a row with no crew has a route: %+v", got.Node.Route) } diff --git a/internal/workgraph/build.go b/internal/workgraph/build.go index 0695d4c248..6e85aa08e2 100644 --- a/internal/workgraph/build.go +++ b/internal/workgraph/build.go @@ -49,7 +49,9 @@ type Snapshot struct { crews []CrewFact people []MemberFact - metrics GraphMetrics + metrics GraphMetrics + // bytes is the conservative size the cache budgets this snapshot at. + bytes int64 totals GraphTotals coverage Coverage diag Diagnostics @@ -400,6 +402,7 @@ func (b *builder) finish() error { s.totals.ByState[s.nodes[i].State]++ } s.coverage = b.coverage() + s.bytes = s.estimateBytes() return nil } @@ -466,6 +469,12 @@ func cloneNode(n *GraphNode) *GraphNode { c.Instructions = &t } c.Route = append([]RouteSeat(nil), n.Route...) + for i := range c.Route { + if p := n.Route[i].Price; p != nil { + price := *p + c.Route[i].Price = &price + } + } if n.Landing != nil { l := *n.Landing l.At = copyTime(n.Landing.At) diff --git a/internal/workgraph/bytes.go b/internal/workgraph/bytes.go new file mode 100644 index 0000000000..93dbed5bcc --- /dev/null +++ b/internal/workgraph/bytes.go @@ -0,0 +1,150 @@ +package workgraph + +import ( + "time" + "unsafe" +) + +// WHAT A SNAPSHOT HOLDS, IN BYTES, counted conservatively. +// +// A cache bounded by node count alone is a cache with no bound: a node may carry +// a hundred paths and a thousand-rune brief. So a snapshot prices itself when it +// is built, field by field — the fixed size of each struct (unsafe.Sizeof, which +// includes its pointers and headers), every string at its length, every slice at +// its capacity, every pointed-to value, and every map entry at its key and value +// plus a fixed overhead for the table — and the cache is held to a budget of +// those bytes. +// +// IT COUNTS THE STRINGS EVEN WHEN THEY ARE SHARED. A snapshot's strings are +// usually the very bytes the producer's facts hold (Go strings are immutable and +// copying a struct copies the header), so they stay alive for as long as the +// snapshot does even after the producer's own cache has dropped them. Counting +// them here means that retention is paid for by the snapshot that causes it and +// the budget cannot understate what is kept; counting them twice, once here and +// once in the producer, only makes the combined budget safer. +// +// A snapshot keeps no reference to its Input: the nodes are copies, the edges +// are built, and nothing in the struct points back at the producer's facts or its +// resolver. The tests measure the heap to prove it. + +const ( + // mapEntryOverhead is what a Go map spends per entry beyond key and value: + // bucket tophash, overflow pointers and load-factor slack, rounded up. + mapEntryOverhead = 48 + // fudge is the safety factor on the whole estimate, in percent. + fudge = 125 +) + +func strBytes(s string) int64 { return int64(len(s)) } + +func timeBytes(t *time.Time) int64 { + if t == nil { + return 0 + } + return int64(unsafe.Sizeof(*t)) +} + +func textBytes(t *GraphText) int64 { + if t == nil { + return 0 + } + n := int64(unsafe.Sizeof(*t)) + strBytes(t.Source) + strBytes(t.Text) + if t.Anchor != nil { + n += int64(unsafe.Sizeof(*t.Anchor)) + strBytes(t.Anchor.Agent) + } + return n +} + +func nodeBytes(n *GraphNode) int64 { + b := int64(unsafe.Sizeof(*n)) + b += strBytes(n.ID) + strBytes(n.Ref.Owner) + strBytes(n.Ref.Task) + strBytes(n.Kind) + strBytes(n.Title) + b += strBytes(n.State) + strBytes(n.StateDetail) + strBytes(n.Ending) + strBytes(n.Parent) + strBytes(n.ParentState) + strBytes(n.Model) + strBytes(n.Links) + if n.Group != nil { + b += int64(unsafe.Sizeof(*n.Group)) + strBytes(n.Group.ID) + strBytes(n.Group.Title) + } + if n.Crew != nil { + b += int64(unsafe.Sizeof(*n.Crew)) + strBytes(n.Crew.Crew) + for _, p := range n.Crew.Path { + b += 16 + strBytes(p) + } + for _, p := range n.Crew.AlsoIn { + b += 16 + strBytes(p) + } + } + for _, t := range []*time.Time{n.Clock.AdmittedAt, n.Clock.StartedAt, n.Clock.StateAt, n.Clock.EndedAt} { + b += timeBytes(t) + } + b += textBytes(n.Instructions) + for _, r := range n.Route { + b += int64(unsafe.Sizeof(r)) + strBytes(r.Role) + strBytes(r.Model) + if r.Price != nil { + b += int64(unsafe.Sizeof(*r.Price)) + } + } + if n.Landing != nil { + b += int64(unsafe.Sizeof(*n.Landing)) + strBytes(n.Landing.Outcome) + timeBytes(n.Landing.At) + } + for _, p := range n.Files.Paths { + b += 16 + strBytes(p) + } + b += strBytes(n.Files.Source) + if n.Spend != nil { + b += int64(unsafe.Sizeof(*n.Spend)) + strBytes(n.Spend.Basis) + } + if n.Checks != nil { + b += int64(unsafe.Sizeof(*n.Checks)) + } + return b +} + +func edgeBytes(e *GraphEdge) int64 { + return int64(unsafe.Sizeof(*e)) + strBytes(e.From) + strBytes(e.To) + strBytes(e.ToRef.Owner) + strBytes(e.ToRef.Task) + + strBytes(e.Via) + strBytes(e.Target) + strBytes(e.ToState) +} + +// estimateBytes prices the whole snapshot. +func (s *Snapshot) estimateBytes() int64 { + b := int64(unsafe.Sizeof(*s)) + for i := range s.nodes { + b += nodeBytes(&s.nodes[i]) + } + b += int64(len(s.keys)) * int64(unsafe.Sizeof(orderKey{})) + for i := range s.keys { + b += strBytes(s.keys[i].owner) + strBytes(s.keys[i].task) + } + for id := range s.idx { + b += mapEntryOverhead + 16 + strBytes(id) + 4 + } + for i := range s.edges { + b += edgeBytes(&s.edges[i]) + } + b += int64(len(s.edgeTo)+len(s.edgeStart)+len(s.revStart)+len(s.revList)) * 4 + for _, c := range s.clusters { + b += int64(unsafe.Sizeof(c)) + strBytes(c.ID) + strBytes(c.Title) + strBytes(c.Parent) + strBytes(c.Kind) + b += int64(len(c.ByState)) * (mapEntryOverhead + 24) + } + for k, list := range s.members { + b += mapEntryOverhead + 16 + strBytes(k) + 24 + int64(cap(list))*4 + } + for _, c := range s.crews { + b += int64(unsafe.Sizeof(c)) + strBytes(c.ID) + strBytes(c.Name) + strBytes(c.Parent) + strBytes(c.Manager) + strBytes(c.State) + } + for _, m := range s.people { + b += int64(unsafe.Sizeof(m)) + strBytes(m.Agent) + strBytes(m.Handle) + strBytes(m.Title) + strBytes(m.Crew) + strBytes(m.ReportsTo) + strBytes(m.Leads) + b += int64(len(m.Tasks.ByState)) * (mapEntryOverhead + 24) + } + for _, c := range s.diag.Cycles { + b += int64(unsafe.Sizeof(c)) + for _, t := range c.Tasks { + b += 16 + strBytes(t) + } + } + b += int64(len(s.totals.ByState)) * (mapEntryOverhead + 24) + for _, u := range s.coverage.Unread { + b += int64(unsafe.Sizeof(u)) + strBytes(u.Owner) + strBytes(u.Reason) + } + return b * fudge / 100 +} + +// Bytes is the conservative size of the snapshot the cache budgets it at. +func (s *Snapshot) Bytes() int64 { return s.bytes } diff --git a/internal/workgraph/cache.go b/internal/workgraph/cache.go index 9227033e2d..d60364cee1 100644 --- a/internal/workgraph/cache.go +++ b/internal/workgraph/cache.go @@ -3,6 +3,7 @@ package workgraph import ( "container/list" "context" + "errors" "sync" "sync/atomic" "time" @@ -11,32 +12,62 @@ import ( // BuildTimeout bounds one snapshot build, whoever is waiting for it. const BuildTimeout = 10 * time.Second +// ErrBusy is returned when the cache already has as many distinct builds +// pending as it admits. It is an answer, not a failure of the graph: the same +// ask a moment later may be served. +var ErrBusy = errors.New("workgraph: busy") + +// CacheLimits are the hard bounds on a [Cache]. Every field is a ceiling on +// something the cache can be made to hold or to run, and a zero is raised to the +// smallest working value, never to "unbounded". +type CacheLimits struct { + // Bytes bounds the snapshots HELD, by their [Snapshot.Bytes] estimate. A + // snapshot larger than this is served to the caller that built it and to the + // callers waiting on it, and then forgotten: nothing is exempt, and nothing + // is ever truncated to fit. + Bytes int64 + // Entries bounds how many snapshots are held, whatever their size. + Entries int + // Pending bounds the distinct builds queued or running at once. A caller + // that would start one more is refused with [ErrBusy]. Callers asking for a + // build already pending join it and cost nothing, so this bounds goroutines + // by builds, never by requests. + Pending int + // BuildBytes bounds the weight of the builds RUNNING at once. A build + // brings its own weight (an estimate of the memory its input costs); one + // heavier than this runs alone rather than never. + BuildBytes int64 +} + // Cache keeps built snapshots keyed by (scope, revision). THE REVISION IS THE // PRODUCER'S CLAIM ABOUT ITS SOURCES — stat stamps of the files a snapshot reads, // computed before any of them is read — so an unchanged source is one map // lookup, and a streaming token that touches no task source never rebuilds a -// graph. Three bounds hold it: the entry count, the total nodes held, and one -// build in flight per key. +// graph. Four bounds hold it: bytes held, entries held, distinct builds pending, +// and the weight of builds running; plus one build in flight per key. // // IO happens only inside the build function, which runs with no lock of this // cache held, so a slow disk never blocks a lookup of another scope. type Cache struct { - mu sync.Mutex - maxEntries int - maxNodes int - nodes int - lru *list.List - byKey map[string]*list.Element - flights map[string]*flight - - builds atomic.Int64 - hits atomic.Int64 + mu sync.Mutex + limits CacheLimits + bytes int64 + lru *list.List + byKey map[string]*list.Element + flights map[string]*flight + gate *Gate + + builds atomic.Int64 + hits atomic.Int64 + busy atomic.Int64 + unstored atomic.Int64 } type entry struct { key string scope string snap *Snapshot + bytes int64 } type flight struct { @@ -47,28 +78,40 @@ type flight struct { err error } -// NewCache is a cache of at most maxEntries snapshots holding at most maxNodes -// nodes between them. A single snapshot larger than maxNodes is still served, -// and is the only one kept. -func NewCache(maxEntries, maxNodes int) *Cache { +// NewCache is a cache held to limits. +func NewCache(l CacheLimits) *Cache { + l.Bytes, l.Entries = max(l.Bytes, 1), max(l.Entries, 1) + l.Pending, l.BuildBytes = max(l.Pending, 1), max(l.BuildBytes, 1) return &Cache{ - maxEntries: max(maxEntries, 1), maxNodes: max(maxNodes, 1), - lru: list.New(), byKey: map[string]*list.Element{}, flights: map[string]*flight{}, + limits: l, lru: list.New(), byKey: map[string]*list.Element{}, flights: map[string]*flight{}, + gate: NewGate(l.BuildBytes), } } -// Stats is how many snapshots were built and how many lookups were served from -// memory, for tests and for the app's own diagnostics. +// Stats is how many snapshots were built, how many lookups were served from +// memory, how many callers were refused as busy and how many built snapshots were +// too large to keep, for tests and for the app's own diagnostics. func (c *Cache) Stats() (builds, hits int64) { return c.builds.Load(), c.hits.Load() } +// Refusals is how many asks were refused with [ErrBusy] and how many snapshots +// were served without being kept. +func (c *Cache) Refusals() (busy, unstored int64) { return c.busy.Load(), c.unstored.Load() } + // Builder makes the [Input] for one scope. It runs without any cache lock and // must honour ctx. type Builder func(ctx context.Context) (Input, error) -// Get is the snapshot for scope at revision: the cached one, the one another -// caller is building right now, or a fresh one built by build. A caller whose -// own ctx ends stops waiting; the build is abandoned only when nobody waits. +// Get is [Cache.GetWeighted] for a build of unknown weight, taken as light. func (c *Cache) Get(ctx context.Context, scope GraphScope, revision string, build Builder) (*Snapshot, error) { + return c.GetWeighted(ctx, scope, revision, 1, build) +} + +// GetWeighted is the snapshot for scope at revision: the cached one, the one +// another caller is building right now, or a fresh one built by build, which is +// admitted to run when weight bytes of build capacity are free. A caller whose +// own ctx ends stops waiting; the build is abandoned only when nobody waits. A +// caller that would start a build beyond the pending limit gets [ErrBusy]. +func (c *Cache) GetWeighted(ctx context.Context, scope GraphScope, revision string, weight int64, build Builder) (*Snapshot, error) { key := scopeKey(scope) + "\x00" + revision c.mu.Lock() if el, ok := c.byKey[key]; ok { @@ -80,28 +123,28 @@ func (c *Cache) Get(ctx context.Context, scope GraphScope, revision string, buil } f, ok := c.flights[key] if !ok { - f = c.start(ctx, key, scope, revision, build) + if len(c.flights) >= c.limits.Pending { + c.mu.Unlock() + c.busy.Add(1) + return nil, ErrBusy + } + f = c.start(ctx, key, scope, revision, weight, build) } f.waiters++ c.mu.Unlock() return c.wait(ctx, key, f) } -// start begins a build in its own goroutine. The build's context is NOT the -// first caller's: a caller that goes away must not fail the callers that joined -// it. Called with c.mu held. -func (c *Cache) start(ctx context.Context, key string, scope GraphScope, revision string, build Builder) *flight { +// start begins a build in its own goroutine, which first waits its turn at the +// gate. The build's context is NOT the first caller's: a caller that goes away +// must not fail the callers that joined it. Called with c.mu held. +func (c *Cache) start(ctx context.Context, key string, scope GraphScope, revision string, weight int64, build Builder) *flight { bctx, cancel := context.WithTimeout(context.WithoutCancel(ctx), BuildTimeout) f := &flight{done: make(chan struct{}), cancel: cancel} c.flights[key] = f go func() { defer cancel() - in, err := build(bctx) - var snap *Snapshot - if err == nil { - in.Scope, in.Revision = scope, revision - snap, err = Build(bctx, in) - } + snap, err := c.run(bctx, scope, revision, weight, build) c.builds.Add(1) c.mu.Lock() f.snap, f.err = snap, err @@ -115,6 +158,22 @@ func (c *Cache) start(ctx context.Context, key string, scope GraphScope, revisio return f } +// run is one build, admitted by weight. The gate is released as soon as the +// snapshot exists, because the input it weighed is garbage by then. +func (c *Cache) run(ctx context.Context, scope GraphScope, revision string, weight int64, build Builder) (*Snapshot, error) { + held, err := c.gate.Acquire(ctx, weight) + if err != nil { + return nil, err + } + defer c.gate.Release(held) + in, err := build(ctx) + if err != nil { + return nil, err + } + in.Scope, in.Revision = scope, revision + return Build(ctx, in) +} + // wait blocks for a flight or for the caller's own ctx, and when the last // waiter leaves a build nobody wants any more, cancels it. func (c *Cache) wait(ctx context.Context, key string, f *flight) (*Snapshot, error) { @@ -133,9 +192,10 @@ func (c *Cache) wait(ctx context.Context, key string, f *flight) (*Snapshot, err } // store keeps a snapshot, first dropping what it replaces (an older revision of -// the same scope) and then the least recently used until the bounds hold. -// Called with c.mu held. +// the same scope) and then the least recently used until the bounds hold. A +// snapshot that cannot fit even alone is not kept. Called with c.mu held. func (c *Cache) store(key, scope string, snap *Snapshot) { + size := snap.Bytes() for el := c.lru.Back(); el != nil; { prev := el.Prev() if e := el.Value.(*entry); e.scope == scope { @@ -143,9 +203,13 @@ func (c *Cache) store(key, scope string, snap *Snapshot) { } el = prev } - c.byKey[key] = c.lru.PushFront(&entry{key: key, scope: scope, snap: snap}) - c.nodes += snap.Len() - for c.lru.Len() > 1 && (c.lru.Len() > c.maxEntries || c.nodes > c.maxNodes) { + if size > c.limits.Bytes { + c.unstored.Add(1) + return + } + c.byKey[key] = c.lru.PushFront(&entry{key: key, scope: scope, snap: snap, bytes: size}) + c.bytes += size + for c.lru.Len() > c.limits.Entries || c.bytes > c.limits.Bytes { c.drop(c.lru.Back()) } } @@ -153,12 +217,19 @@ func (c *Cache) store(key, scope string, snap *Snapshot) { func (c *Cache) drop(el *list.Element) { e := c.lru.Remove(el).(*entry) delete(c.byKey, e.key) - c.nodes -= e.snap.Len() + c.bytes -= e.bytes +} + +// Held is how many snapshots and estimated bytes the cache holds right now. +func (c *Cache) Held() (entries int, bytes int64) { + c.mu.Lock() + defer c.mu.Unlock() + return c.lru.Len(), c.bytes } -// Held is how many snapshots and nodes the cache holds right now. -func (c *Cache) Held() (entries, nodes int) { +// Pending is how many distinct builds are queued or running right now. +func (c *Cache) Pending() int { c.mu.Lock() defer c.mu.Unlock() - return c.lru.Len(), c.nodes + return len(c.flights) } diff --git a/internal/workgraph/cache_test.go b/internal/workgraph/cache_test.go new file mode 100644 index 0000000000..12b4323fea --- /dev/null +++ b/internal/workgraph/cache_test.go @@ -0,0 +1,299 @@ +package workgraph + +import ( + "context" + "errors" + "fmt" + "runtime" + "sync" + "sync/atomic" + "testing" + "time" +) + +func smallInput(n int) Builder { + return func(context.Context) (Input, error) { return scaleInput(1, n), nil } +} + +func scopeN(i int) GraphScope { return GraphScope{Kind: ScopeCrew, ID: fmt.Sprintf("crew%d", i)} } + +// THE ESTIMATE IS A CEILING, NOT A GUESS: what the snapshot really keeps alive +// (measured after its input is released and the heap collected) is below +// Bytes(), and Bytes() is not absurdly above it — a budget that counts ten times +// what is held would waste the cache it protects. +func TestSnapshotBytesIsAConservativeCeilingOnTheRealHeap(t *testing.T) { + for _, n := range []int{2_000, 20_000} { + before := heapNow() + in := scaleInput(10, n/10) + snap, err := Build(context.Background(), in) + if err != nil { + t.Fatal(err) + } + in = Input{} + real := int64(heapNow()) - int64(before) + est := snap.Bytes() + runtime.KeepAlive(snap) + t.Logf("%d tasks: held %d KiB, estimate %d KiB (x%.2f)", n, real/1024, est/1024, float64(est)/float64(real)) + if est < real { + t.Fatalf("%d tasks: estimate %d below the %d bytes really held", n, est, real) + } + if est > real*4 { + t.Fatalf("%d tasks: estimate %d is over four times the %d held", n, est, real) + } + } +} + +// A snapshot keeps nothing of its input alive: not the facts slice, not what the +// resolver closure captured. The input carries 16 MiB that only those hold. +func TestASnapshotRetainsNothingOfItsInput(t *testing.T) { + base := heapNow() + ballast := make([]byte, 16<<20) + in := scaleInput(2, 50) + in.Resolve = func(TaskRef) Resolution { return Resolution{State: fmt.Sprint(len(ballast))} } + snap, err := Build(context.Background(), in) + if err != nil { + t.Fatal(err) + } + in = Input{} + ballast = nil + held := int64(heapNow()) - int64(base) + runtime.KeepAlive(snap) + if held > 4<<20 { + t.Fatalf("a 100-task snapshot keeps %d bytes alive after its input was released", held) + } +} + +func TestACacheHoldsToItsByteBudgetAndEvictsOldestFirst(t *testing.T) { + probe, err := Build(context.Background(), scaleInput(1, 300)) + if err != nil { + t.Fatal(err) + } + one := probe.Bytes() + c := NewCache(CacheLimits{Bytes: one*5/2 + 1, Entries: 100, Pending: 4, BuildBytes: 1 << 20}) + for i := 0; i < 6; i++ { + if _, err := c.Get(context.Background(), scopeN(i), "r", smallInput(300)); err != nil { + t.Fatal(err) + } + if entries, held := c.Held(); held > one*5/2+1 || entries > 2 { + t.Fatalf("after %d: %d entries, %d bytes over a %d budget", i, entries, held, one*5/2+1) + } + } + // The newest are the ones kept. + builds, _ := c.Stats() + if _, err := c.Get(context.Background(), scopeN(5), "r", smallInput(300)); err != nil { + t.Fatal(err) + } + if after, hits := c.Stats(); after != builds || hits != 1 { + t.Fatalf("the newest snapshot was evicted: builds %d→%d hits %d", builds, after, hits) + } +} + +// NO EXEMPTION: a snapshot over the whole budget is served in full to everyone +// who asked, and never kept — not even alone, not even as the newest. +func TestASnapshotOverTheBudgetIsServedWholeAndNeverKept(t *testing.T) { + c := NewCache(CacheLimits{Bytes: 1 << 10, Entries: 10, Pending: 4, BuildBytes: 1 << 20}) + for i := 0; i < 3; i++ { + snap, err := c.Get(context.Background(), scopeN(1), "r", smallInput(500)) + if err != nil { + t.Fatal(err) + } + if snap.Len() != 500 || snap.Bytes() <= 1<<10 { + t.Fatalf("served %d nodes at %d bytes: truncated or under-counted", snap.Len(), snap.Bytes()) + } + } + if entries, held := c.Held(); entries != 0 || held != 0 { + t.Fatalf("held %d entries, %d bytes of an over-budget snapshot", entries, held) + } + if builds, _ := c.Stats(); builds != 3 { + t.Fatalf("builds %d: an uncached snapshot is rebuilt each time", builds) + } + if _, unstored := c.Refusals(); unstored != 3 { + t.Fatalf("unstored %d", unstored) + } +} + +func TestTheEntryCountCapsSmallSnapshotsToo(t *testing.T) { + c := NewCache(CacheLimits{Bytes: 1 << 30, Entries: 3, Pending: 4, BuildBytes: 1 << 20}) + for i := 0; i < 10; i++ { + if _, err := c.Get(context.Background(), scopeN(i), "r", smallInput(5)); err != nil { + t.Fatal(err) + } + } + if entries, _ := c.Held(); entries != 3 { + t.Fatalf("entries %d", entries) + } +} + +// MANY DISTINCT CONCURRENT KEYS: five hundred callers each ask for a different +// scope while every build is stuck. Only Pending of them are admitted, the rest +// are refused at once as busy, the goroutines spent are bounded by Pending and not +// by callers, and the weight running never passes the gate. +func TestManyDistinctKeysAreRefusedAtTheDoorAndSpendBoundedGoroutines(t *testing.T) { + const callers, pending = 500, 4 + c := NewCache(CacheLimits{Bytes: 1 << 30, Entries: 100, Pending: pending, BuildBytes: 100}) + release := make(chan struct{}) + var running, peak atomic.Int64 + build := stuckBuild(release, &running, &peak) + baseline := runtime.NumGoroutine() + var wg sync.WaitGroup + var ok, busy atomic.Int64 + for i := 0; i < callers; i++ { + wg.Add(1) + go func(i int) { + defer wg.Done() + _, err := c.GetWeighted(context.Background(), scopeN(i), "r", 60, build) + countAnswer(t, err, &ok, &busy) + }(i) + } + waitFor(5*time.Second, func() bool { return busy.Load() >= callers-pending }) + if got := c.Pending(); got != pending { + t.Fatalf("pending %d, want exactly %d", got, pending) + } + // 4 pending builds of weight 60 against a gate of 100: one runs, three queue. + if r := running.Load(); r != 1 { + t.Fatalf("%d builds running against a gate that fits one", r) + } + if extra := runtime.NumGoroutine() - baseline; extra > callers-int(busy.Load())+pending+4 { + t.Fatalf("%d extra goroutines for %d pending builds", extra, pending) + } + close(release) + wg.Wait() + if ok.Load() != pending || busy.Load() != callers-pending || peak.Load() != 1 { + t.Fatalf("served %d refused %d peak %d", ok.Load(), busy.Load(), peak.Load()) + } +} + +// stuckBuild blocks until release closes, counting how many run at once. +func stuckBuild(release chan struct{}, running, peak *atomic.Int64) Builder { + return func(ctx context.Context) (Input, error) { + n := running.Add(1) + defer running.Add(-1) + for p := peak.Load(); n > p && !peak.CompareAndSwap(p, n); p = peak.Load() { + } + select { + case <-release: + case <-ctx.Done(): + return Input{}, ctx.Err() + } + return scaleInput(1, 3), nil + } +} + +func countAnswer(t *testing.T, err error, ok, busy *atomic.Int64) { + switch { + case err == nil: + ok.Add(1) + case errors.Is(err, ErrBusy): + busy.Add(1) + default: + t.Error(err) + } +} + +func waitFor(d time.Duration, done func() bool) { + for end := time.Now().Add(d); !done() && time.Now().Before(end); { + time.Sleep(time.Millisecond) + } +} + +// Callers for the SAME key are not distinct builds: a thousand of them are one +// build and none is refused. +func TestOneKeyIsOneBuildWhoeverAsks(t *testing.T) { + c := NewCache(CacheLimits{Bytes: 1 << 30, Entries: 8, Pending: 1, BuildBytes: 1 << 20}) + release := make(chan struct{}) + var builds atomic.Int64 + build := func(context.Context) (Input, error) { builds.Add(1); <-release; return scaleInput(1, 3), nil } + var wg sync.WaitGroup + for i := 0; i < 1000; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if _, err := c.Get(context.Background(), scopeN(1), "r", build); err != nil { + t.Error(err) + } + }() + } + for c.Pending() == 0 { + time.Sleep(time.Millisecond) + } + time.Sleep(20 * time.Millisecond) + close(release) + wg.Wait() + if builds.Load() != 1 { + t.Fatalf("%d builds for one key", builds.Load()) + } +} + +// A build queued at the gate that nobody waits for any more leaves the queue and +// frees its pending slot; it neither runs nor holds weight. +func TestAQueuedBuildNobodyWaitsForLeavesTheGate(t *testing.T) { + c := NewCache(CacheLimits{Bytes: 1 << 30, Entries: 8, Pending: 4, BuildBytes: 10}) + hold := make(chan struct{}) + go c.GetWeighted(context.Background(), scopeN(1), "r", 10, func(ctx context.Context) (Input, error) { + <-hold + return scaleInput(1, 2), nil + }) + for c.Pending() == 0 { + time.Sleep(time.Millisecond) + } + var ran atomic.Bool + ctx, cancel := context.WithCancel(context.Background()) + errc := make(chan error, 1) + go func() { + _, err := c.GetWeighted(ctx, scopeN(2), "r", 10, func(context.Context) (Input, error) { ran.Store(true); return scaleInput(1, 2), nil }) + errc <- err + }() + for c.Pending() < 2 { + time.Sleep(time.Millisecond) + } + cancel() + if err := <-errc; !errors.Is(err, context.Canceled) { + t.Fatalf("err %v", err) + } + deadline := time.Now().Add(2 * time.Second) + for c.Pending() != 1 && time.Now().Before(deadline) { + time.Sleep(time.Millisecond) + } + if c.Pending() != 1 { + t.Fatalf("pending %d: the abandoned build still holds a slot", c.Pending()) + } + close(hold) + time.Sleep(20 * time.Millisecond) + if ran.Load() { + t.Fatal("an abandoned queued build ran") + } +} + +// A build heavier than the whole gate runs alone instead of never. +func TestABuildHeavierThanTheGateRunsAlone(t *testing.T) { + c := NewCache(CacheLimits{Bytes: 1 << 30, Entries: 8, Pending: 4, BuildBytes: 10}) + if _, err := c.GetWeighted(context.Background(), scopeN(1), "r", 1<<40, smallInput(3)); err != nil { + t.Fatal(err) + } +} + +func BenchmarkCacheHit(b *testing.B) { + c := NewCache(CacheLimits{Bytes: 1 << 30, Entries: 8, Pending: 4, BuildBytes: 1 << 20}) + if _, err := c.Get(context.Background(), scopeN(1), "r", smallInput(1000)); err != nil { + b.Fatal(err) + } + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := c.Get(context.Background(), scopeN(1), "r", nil); err != nil { + b.Fatal(err) + } + } +} + +func BenchmarkManyDistinctKeysAgainstTheDoor(b *testing.B) { + c := NewCache(CacheLimits{Bytes: 1 << 20, Entries: 8, Pending: 2, BuildBytes: 1 << 20}) + b.ReportAllocs() + b.RunParallel(func(pb *testing.PB) { + i := 0 + for pb.Next() { + i++ + _, _ = c.Get(context.Background(), scopeN(i%64), "r", smallInput(20)) + } + }) +} diff --git a/internal/workgraph/gate.go b/internal/workgraph/gate.go new file mode 100644 index 0000000000..14ff7d60e7 --- /dev/null +++ b/internal/workgraph/gate.go @@ -0,0 +1,93 @@ +package workgraph + +import ( + "container/list" + "context" + "sync" +) + +// Gate is a weighted, first-in-first-out admission: capacity units of running +// weight (bytes, in both its users), waiters parked on a channel each and no +// goroutine of the gate's own. A weight above capacity is clamped to it, so a +// heavy job runs alone rather than never. Acquire honours its context, and a +// waiter that leaves hands back anything it was granted as it left. +type Gate struct { + mu sync.Mutex + capacity int64 + maxWaiters int + used int64 + queue list.List +} + +type gateWaiter struct { + need int64 + ready chan struct{} +} + +// NewGate is a gate of the given capacity. +func NewGate(capacity int64) *Gate { return &Gate{capacity: max(capacity, 1)} } + +// Limit bounds how many may wait at once; a caller that would be one more is +// refused with [ErrBusy] and costs the gate nothing. Zero is unbounded. +func (g *Gate) Limit(waiters int) *Gate { + g.mu.Lock() + g.maxWaiters = waiters + g.mu.Unlock() + return g +} + +// Acquire waits for weight units of capacity and returns what it took, which +// the caller hands back to [Gate.Release]. +func (g *Gate) Acquire(ctx context.Context, weight int64) (int64, error) { + weight = min(max(weight, 1), g.capacity) + g.mu.Lock() + if g.queue.Len() == 0 && g.used+weight <= g.capacity { + g.used += weight + g.mu.Unlock() + return weight, nil + } + if g.maxWaiters > 0 && g.queue.Len() >= g.maxWaiters { + g.mu.Unlock() + return 0, ErrBusy + } + w := &gateWaiter{need: weight, ready: make(chan struct{})} + el := g.queue.PushBack(w) + g.mu.Unlock() + select { + case <-w.ready: + return weight, nil + case <-ctx.Done(): + g.mu.Lock() + select { + case <-w.ready: // granted as we left: hand it back. + g.used -= weight + g.admit() + default: + g.queue.Remove(el) + g.admit() + } + g.mu.Unlock() + return 0, ctx.Err() + } +} + +// Release returns what Acquire took. +func (g *Gate) Release(weight int64) { + g.mu.Lock() + g.used -= weight + g.admit() + g.mu.Unlock() +} + +// admit grants the head of the queue while it fits. Called with g.mu held. +func (g *Gate) admit() { + for el := g.queue.Front(); el != nil; el = g.queue.Front() { + w := el.Value.(*gateWaiter) + if g.used+w.need > g.capacity { + return + } + g.used += w.need + g.queue.Remove(el) + close(w.ready) + } +} diff --git a/internal/workgraph/types.go b/internal/workgraph/types.go index e48baabe44..72322f3591 100644 --- a/internal/workgraph/types.go +++ b/internal/workgraph/types.go @@ -339,6 +339,17 @@ type RouteSeat struct { Role string `json:"role"` Model string `json:"model"` Pinned bool `json:"pinned,omitempty"` + // Price is the accepted route's own published tariff at admission. Absent + // is unknown — a record written before tariffs were stored, or a route + // nobody prices — never a price of nothing. + Price *RoutePrice `json:"price,omitempty"` +} + +// RoutePrice is one route's published price in dollars per token. +type RoutePrice struct { + Prompt float64 `json:"prompt"` + Completion float64 `json:"completion"` + CacheRead float64 `json:"cache_read"` } // GraphTotals are the scope's true sizes, whatever the page holds. @@ -450,11 +461,17 @@ type GraphNode struct { // unverified, interrupted). Paused, Stopped and Declined are the person's // holds and endings, which the state word alone does not carry. Ending is // why a failed task stopped, in the engine's word. - State string `json:"state" enum:"queued,running,done,failed,unverified,interrupted"` - Paused bool `json:"paused,omitempty"` - Stopped bool `json:"stopped,omitempty"` - Declined bool `json:"declined,omitempty"` - Ending string `json:"ending,omitempty"` + State string `json:"state" enum:"queued,running,done,failed,unverified,interrupted"` + // StateDetail is the producing store's own status word when it has finer + // ones than State does — a plan row's "pending", "ready", "claimed", + // "running", "done", "failed" or "cancelled" — so that queued can be told + // apart as waiting on prerequisites ("pending") or free to start ("ready"). + // Absent where State is already the store's own word. + StateDetail string `json:"state_detail,omitempty"` + Paused bool `json:"paused,omitempty"` + Stopped bool `json:"stopped,omitempty"` + Declined bool `json:"declined,omitempty"` + Ending string `json:"ending,omitempty"` // Parent is the task that handed this one out (its wire id), empty at the // top of a family. ParentState says whether that parent is among the nodes: // "root" (none), "node", or "missing" (the record names a parent its owner From 4dfbecce3ce600d3da9c77e81904827ce7ad263b Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 16 Oct 2026 00:14:34 -0400 Subject: [PATCH 132/459] workgraph: refuse what does not fit before spending the memory; no gate exemption A gate no longer clamps or runs the heaviest job alone: a weight over capacity is a typed TooLarge (wire code too_large, detail needs_narrow_scope) raised before any flight or allocation. Admission runs on file sizes, then on facts charged per conversation as they are read (byte quota, build weight), then on the build gate. Budget 128 MiB: retained 16+4+16, in flight 28 raw + 64 build. About 10K ordinary tasks fit (cold ask peaks ~29 MiB; later pages build and read nothing); 40K is refused after reading 12 of 40 conversations. 100K/200K scopes are not supported. Adds elapsed_ms and attempt from the record, and coverage.unavailable [steps, asks], which the persisted record cannot give. Co-Authored-By: Claude Sonnet 5.5 --- cmd/codeaf/appworkgraph.go | 96 ++++++---- cmd/codeaf/appworkgraph_scale_test.go | 221 ++++++++++++++++++++++ cmd/codeaf/appworkgraph_test.go | 17 +- docs/changes/unreleased/0-work-graph.md | 8 +- internal/appwire/wire.go | 7 + internal/appwire/workgraph.go | 4 + internal/session/workgraph_bounds_test.go | 57 ++++-- internal/session/workgraph_project.go | 18 ++ internal/session/workgraph_source.go | 90 +++++---- internal/session/workgraph_source_test.go | 21 ++ internal/workgraph/answer.go | 1 + internal/workgraph/build.go | 15 +- internal/workgraph/bytes.go | 55 ++++++ internal/workgraph/cache.go | 18 +- internal/workgraph/cache_test.go | 8 - internal/workgraph/gate.go | 12 +- internal/workgraph/input.go | 2 + internal/workgraph/toolarge.go | 45 +++++ internal/workgraph/types.go | 11 ++ internal/workgraph/weight_test.go | 89 +++++++++ 20 files changed, 675 insertions(+), 120 deletions(-) create mode 100644 cmd/codeaf/appworkgraph_scale_test.go create mode 100644 internal/workgraph/toolarge.go create mode 100644 internal/workgraph/weight_test.go diff --git a/cmd/codeaf/appworkgraph.go b/cmd/codeaf/appworkgraph.go index 67a4090237..7bb3869f2c 100644 --- a/cmd/codeaf/appworkgraph.go +++ b/cmd/codeaf/appworkgraph.go @@ -48,16 +48,20 @@ const ( // bigger than that is served and not kept. graphPending is how many // DISTINCT builds may be queued or running (a request for a build already // pending joins it for free), and graphBuildBytes the weight of builds - // running at once, a build weighing graphBuildWeight bytes a fact. + // running at once (each build weighed by workgraph.BuildWeight). graphCacheEntries = 8 graphPending = 8 - graphBuildBytes = 64 << 20 - graphBuildWeight = 4608 + graphBuildBytes = session.WorkBuildBytes // graphRequestBytes is the working set of requests loading at once, weighed - // by their files' sizes, and graphRequestWaiters how many may wait for room; - // the next is told busy. - graphRequestBytes = 64 << 20 + // by their files' sizes at decode inflation, and graphRequestWaiters how many + // may wait for room; the next is told busy. A request whose files alone are + // heavier than this is refused as too large before any is read. + graphRequestBytes = 28 << 20 graphRequestWaiters = 16 + // graphScopeFactBytes is the most estimated bytes of facts one request + // accumulates across its conversations. It is counted AS THEY ARE READ, so a + // scope that will not fit stops reading at the conversation that crossed it. + graphScopeFactBytes = 12 << 20 // graphMaxUnread is how many unreadable conversations an answer names. graphMaxUnread = 16 ) @@ -123,7 +127,11 @@ func (b *appBackend) WorkGraph(ctx context.Context, req workgraph.GraphRequest) if err != nil { return nil, err } - held, err := g.requests.Acquire(ctx, g.requestWeight(plan)) + need := g.requestWeight(plan) + if need > graphRequestBytes { + return nil, narrower(plan, workgraph.NewTooLarge("files", need, graphRequestBytes)) + } + held, err := g.requests.Acquire(ctx, need) if err != nil { return nil, err } @@ -132,9 +140,24 @@ func (b *appBackend) WorkGraph(ctx context.Context, req workgraph.GraphRequest) if errors.Is(err, session.ErrWorkBusy) { return nil, workgraph.ErrBusy } + var tooLarge *workgraph.TooLarge + if errors.As(err, &tooLarge) { + return nil, narrower(plan, tooLarge) + } return res, err } +// narrower says which smaller scope is worth asking for instead. +func narrower(plan graphPlan, e *workgraph.TooLarge) error { + switch plan.scope.Kind { + case workgraph.ScopeCrew: + e.Scope = string(workgraph.ScopeAgent) + case workgraph.ScopeAgent: + e.Scope = string(workgraph.ScopeTask) + } + return e +} + // requestWeight is what a plan's files hold at peak once read. func (g *appWorkGraph) requestWeight(plan graphPlan) int64 { var w int64 @@ -153,7 +176,7 @@ func (g *appWorkGraph) answer(ctx context.Context, req workgraph.GraphRequest, p if req.IfRevision != "" && req.IfRevision == revision { return unchangedGraph(req, revision), nil } - snap, err := g.cache.GetWeighted(ctx, req.Scope, revision, int64(loaded.factCount())*graphBuildWeight, func(ctx context.Context) (workgraph.Input, error) { + snap, err := g.cache.GetWeighted(ctx, req.Scope, revision, loaded.weight, func(ctx context.Context) (workgraph.Input, error) { return loaded.input(ctx, g, plan, revision) }) if err != nil { @@ -162,15 +185,6 @@ func (g *appWorkGraph) answer(ctx context.Context, req workgraph.GraphRequest, p return snap.Answer(ctx, req) } -// factCount is how many facts the build will be given. -func (l *graphLoaded) factCount() int { - n := 0 - for _, o := range l.owners { - n += len(o.Facts) - } - return min(n, workgraph.MaxScopeNodes) -} - func unchangedGraph(req workgraph.GraphRequest, revision string) *workgraph.GraphResult { view := req.View if view == "" { @@ -408,8 +422,23 @@ type graphLoaded struct { unread []workgraph.OwnerIssue external []externalOwner indexes []string - // truncated says conversations were left unread at the scope bound. - truncated bool + // bytes and weight are what the owners read so far hold and what building + // them would allocate, charged by [graphLoaded.charge]. + bytes, weight int64 +} + +// charge adds one conversation to the request's working set, or refuses with +// [workgraph.TooLarge] when the set would pass its quota or the build gate. +func (l *graphLoaded) charge(facts *session.OwnerFacts) error { + l.bytes += facts.Bytes + l.weight += facts.Weight + switch { + case l.bytes > graphScopeFactBytes: + return workgraph.NewTooLarge("facts", l.bytes, graphScopeFactBytes) + case l.weight > graphBuildBytes: + return workgraph.NewTooLarge("build", l.weight, graphBuildBytes) + } + return nil } // externalOwner is a conversation read only to say the state of a target. @@ -425,17 +454,8 @@ func (g *appWorkGraph) load(ctx context.Context, plan graphPlan) (*graphLoaded, out := &graphLoaded{unread: append([]workgraph.OwnerIssue(nil), plan.missing...)} inScope := map[string]bool{} indexes := map[string]bool{} - total := 0 for _, o := range plan.owners { inScope[o.id] = true - // A SCOPE IS BUILT FROM AT MOST workgraph.MaxScopeNodes TASKS, and the - // conversations past that are not even read: they are named unread - // (too_large) and the answer is truncated, never silently short. - if total >= workgraph.MaxScopeNodes { - out.truncated = true - out.unread = append(out.unread, workgraph.OwnerIssue{Owner: o.id, Reason: session.WorkIssueTooLarge}) - continue - } facts, err := g.source.Owner(ctx, o.id, o.dir) switch { case err == session.ErrWorkOwnerMissing: @@ -446,8 +466,13 @@ func (g *appWorkGraph) load(ctx context.Context, plan graphPlan) (*graphLoaded, case facts.Issue != "": out.unread = append(out.unread, workgraph.OwnerIssue{Owner: o.id, Reason: facts.Issue}) } + // THE QUOTA IS CHARGED AS EACH CONVERSATION ARRIVES. A scope that cannot + // fit stops reading at the conversation that crossed the line, so the + // refusal costs what was read so far and not the whole scope. + if err := out.charge(facts); err != nil { + return nil, err + } out.owners = append(out.owners, facts) - total += len(facts.Facts) indexes[session.TaskIndexPath(session.Place{Dir: o.dir}.Transcript())] = true } for path := range indexes { @@ -523,8 +548,7 @@ func (l *graphLoaded) revision(plan graphPlan) string { func (l *graphLoaded) input(ctx context.Context, g *appWorkGraph, plan graphPlan, revision string) (workgraph.Input, error) { in := workgraph.Input{ Scope: plan.scope, About: plan.about, Root: plan.root, Crews: plan.crews, Members: plan.members, - Revision: revision, AsOf: time.Now(), Owners: len(l.owners) + len(l.unread), Excluded: session.WorkExcluded, - Truncated: l.truncated, + Revision: revision, AsOf: time.Now(), Owners: len(l.owners) + len(l.unread), Excluded: session.WorkExcluded, Unavailable: session.WorkUnavailable, } if len(l.unread) > graphMaxUnread { in.Unread = l.unread[:graphMaxUnread] @@ -543,15 +567,9 @@ func (l *graphLoaded) input(ctx context.Context, g *appWorkGraph, plan graphPlan for _, o := range l.owners { total += len(o.Facts) } - in.Facts = make([]workgraph.Fact, 0, min(total, workgraph.MaxScopeNodes)) + in.Facts = make([]workgraph.Fact, 0, total) for _, o := range l.owners { - for _, f := range o.Facts { - if len(in.Facts) == workgraph.MaxScopeNodes { - in.Truncated = true - break - } - in.Facts = append(in.Facts, f) - } + in.Facts = append(in.Facts, o.Facts...) } l.overlayChecks(&in, plan, checks) in.Resolve = l.resolver() diff --git a/cmd/codeaf/appworkgraph_scale_test.go b/cmd/codeaf/appworkgraph_scale_test.go new file mode 100644 index 0000000000..6e2c230a44 --- /dev/null +++ b/cmd/codeaf/appworkgraph_scale_test.go @@ -0,0 +1,221 @@ +package main + +// THE MEMORY LAW OF work.graph, on the real backend and the real wire: a +// board-sized scope is answered inside the budget and its second page costs +// nothing to build or read; a scope past the budget is refused, typed, before +// the memory is spent. + +import ( + "bufio" + "context" + "encoding/json" + "fmt" + "io" + "os" + "path/filepath" + "runtime" + "runtime/debug" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/session" + teamstore "github.com/Agent-Field/codeaf/internal/teams" + "github.com/Agent-Field/codeaf/internal/workgraph" +) + +// scaleCheckpoint is a conversation of n queued tasks, each waiting on the one +// before it, with a brief a person might have written. +func scaleCheckpoint(n int) []byte { + var b strings.Builder + fmt.Fprintf(&b, `{"type":"tasks","version":1,"seq":%d,"nodes":[`, n) + for i := 1; i <= n; i++ { + if i > 1 { + b.WriteByte(',') + } + dep := "" + if i > 1 { + dep = fmt.Sprintf(`,"depends_on":[%d]`, i-1) + } + fmt.Fprintf(&b, `{"id":%d,"title":"Task %d of the migration","brief":"Move the tables for part %d and keep the old reads working until the cutover.","acceptance":"The checks for part %d pass.","state":"queued","admittedAt":"2026-10-01T09:00:00Z","stateAt":"2026-10-01T09:00:00Z"%s}`, i, i, i, i, dep) + } + b.WriteString(`]}`) + return []byte(b.String()) +} + +// scaleFixture is a crew of `owners` conversations holding `each` tasks apiece, +// behind a real wire. +func scaleFixture(t *testing.T, owners, each int) (*graphWire, string) { + t.Helper() + t.Setenv("CODEAF_HOME", filepath.Join(t.TempDir(), "state")) + bucket, err := v3ProjectDir(t.TempDir()) + if err != nil { + t.Fatal(err) + } + body := scaleCheckpoint(each) + var transcripts []string + for i := 0; i < owners; i++ { + id := fmt.Sprintf("%016x", 0x1000+i) + dir := writeV3Session(t, bucket, id, "w", time.Now()) + if err := os.WriteFile(session.Place{Dir: dir}.Tasks(), body, 0o600); err != nil { + t.Fatal(err) + } + transcripts = append(transcripts, session.Place{Dir: dir}.Transcript()) + } + backend := newFakeBackend(newAppRecorder(), func(ask appOpenAsk) (*appConn, error) { + return &appConn{Conv: &fakeConversation{}, Workspace: ask.Workspace, Transcript: ask.Transcript, Release: func() {}}, nil + }) + t.Cleanup(func() { backend.Close() }) + backend.state = &appState{path: filepath.Join(t.TempDir(), appStateName)} + backend.profileDir = t.TempDir() + crew := teamstore.NewID() + err = teamstore.Update(backend.profileDir, func(f *teamstore.File) error { + f.Teams = append(f.Teams, teamstore.Team{ID: crew, Name: "harbor", Made: time.Now()}) + for i, tr := range transcripts { + if err := f.AddMember(crew, teamstore.Member{Key: tr, File: tr, Where: "/w", Word: fmt.Sprint(i), Handle: fmt.Sprint(i)}); err != nil { + return err + } + } + return nil + }) + if err != nil { + t.Fatal(err) + } + inR, inW := io.Pipe() + outR, outW := io.Pipe() + server := appwire.NewServer(outW) + go func() { + _ = server.Serve(context.Background(), inR, appwire.Hello{Version: "test"}, backend) + outW.Close() + }() + scanner := bufio.NewScanner(outR) + scanner.Buffer(make([]byte, 8<<20), 8<<20) + t.Cleanup(func() { inW.Close() }) + if !scanner.Scan() { + t.Fatal("no hello") + } + return &graphWire{t: t, in: inW, lines: scanner}, crew +} + +func allocated() uint64 { + var m runtime.MemStats + runtime.ReadMemStats(&m) + return m.TotalAlloc +} + +// peakHeap runs f and reports the most live heap it added over what was there +// before, sampled every millisecond with the collector run eagerly, so garbage +// that was already dead does not read as memory held. It is a measurement of +// this process's Go heap and no claim about its resident size. +func peakHeap(f func()) uint64 { + old := debug.SetGCPercent(10) + defer debug.SetGCPercent(old) + runtime.GC() + var base runtime.MemStats + runtime.ReadMemStats(&base) + var peak atomic.Uint64 + stop, done := make(chan struct{}), make(chan struct{}) + go func() { + defer close(done) + var m runtime.MemStats + for { + runtime.ReadMemStats(&m) + if m.HeapAlloc > peak.Load() { + peak.Store(m.HeapAlloc) + } + select { + case <-stop: + return + case <-time.After(time.Millisecond): + } + } + }() + f() + close(stop) + <-done + if p := peak.Load(); p > base.HeapAlloc { + return p - base.HeapAlloc + } + return 0 +} + +// TEN THOUSAND ORDINARY TASKS, the size a board is tested at: the cold ask +// allocates less than the whole 128 MiB budget, builds once, and every later +// page — and an unchanged revision — builds nothing and reads no file. +func TestATenThousandTaskScopeIsAnsweredInsideTheBudgetAndPagesCostNothing(t *testing.T) { + w, crew := scaleFixture(t, 10, 1000) + workGraphs.init() + scope := `{"kind":"crew","id":"` + crew + `"}` + var first workgraph.GraphResult + cold := peakHeap(func() { first = w.graph(`{"scope":` + scope + `,"limit":500}`) }) + if first.Totals.Nodes != 10_000 || len(first.Nodes) != 500 || first.Next == "" { + t.Fatalf("first page: totals %+v, %d nodes, next %q", first.Totals, len(first.Nodes), first.Next) + } + t.Logf("cold ask of 10 000 tasks peaked %d MiB of live heap", cold>>20) + if cold > 128<<20 { + t.Fatalf("the cold ask peaked at %d MiB against a 128 MiB budget", cold>>20) + } + builds, _ := workGraphs.cache.Stats() + parses := workGraphs.source.Parses() + cursor, _ := json.Marshal(first.Next) + start := time.Now() + pages := peakHeap(func() { + for i := 0; i < 5; i++ { + page := w.graph(`{"scope":` + scope + `,"limit":500,"cursor":` + string(cursor) + `}`) + if len(page.Nodes) != 500 { + t.Errorf("page %d has %d nodes", i, len(page.Nodes)) + } + } + }) + if b, _ := workGraphs.cache.Stats(); b != builds { + t.Fatalf("paging rebuilt the snapshot: %d -> %d", builds, b) + } + if workGraphs.source.Parses() != parses { + t.Fatal("paging read the conversations' files again") + } + t.Logf("5 more pages: %v, peak %d MiB", time.Since(start), pages>>20) + if pages > 24<<20 { + t.Fatalf("five cached pages peaked at %d MiB", pages>>20) + } + if got := w.graph(`{"scope":` + scope + `,"if_revision":"` + first.Revision + `"}`); !got.Unchanged { + t.Fatal("an unmoved revision was not answered unchanged") + } + if b, _ := workGraphs.cache.Stats(); b != builds { + t.Fatal("an unchanged answer built a snapshot") + } +} + +// A SCOPE PAST THE BUDGET is refused with a typed error, and the refusal costs +// little: reading stopped at the conversation that crossed the quota, nothing +// was built, and the wire says which smaller scope to ask for. +func TestAScopePastTheBudgetIsRefusedTypedBeforeTheMemoryIsSpent(t *testing.T) { + w, crew := scaleFixture(t, 40, 1000) + workGraphs.init() + parses := workGraphs.source.Parses() + builds, _ := workGraphs.cache.Stats() + var bad map[string]any + spent := peakHeap(func() { _, bad = w.call(`{"scope":{"kind":"crew","id":"` + crew + `"},"limit":500}`) }) + if bad == nil || bad["code"] != appwire.CodeTooLarge { + t.Fatalf("40 000 tasks answered %v", bad) + } + detail, _ := bad["detail"].(map[string]any) + if detail["reason"] != workgraph.NarrowScope || detail["scope"] != "agent" || detail["need"].(float64) <= detail["limit"].(float64) { + t.Fatalf("detail = %v", detail) + } + if read := workGraphs.source.Parses() - parses; read >= 40 { + t.Fatalf("the refusal read all %d conversations", read) + } + if b, _ := workGraphs.cache.Stats(); b != builds { + t.Fatal("a refused scope was built") + } + t.Logf("refused after reading %d of 40 conversations, peak %d MiB", workGraphs.source.Parses()-parses, spent>>20) + if spent > 64<<20 { + t.Fatalf("the refusal peaked at %d MiB", spent>>20) + } + // An agent of the same crew is within the budget and is answered. + if got := w.graph(`{"scope":{"kind":"agent","id":"` + fmt.Sprintf("%016x", 0x1000) + `"}}`); got.Totals.Nodes != 1000 { + t.Fatalf("one agent of the crew: %+v", got.Totals) + } +} diff --git a/cmd/codeaf/appworkgraph_test.go b/cmd/codeaf/appworkgraph_test.go index d1177fbb23..60d0da2bc8 100644 --- a/cmd/codeaf/appworkgraph_test.go +++ b/cmd/codeaf/appworkgraph_test.go @@ -12,6 +12,7 @@ import ( "io" "os" "path/filepath" + "strconv" "testing" "time" @@ -51,7 +52,7 @@ type graphWire struct { func (w *graphWire) call(params string) (result json.RawMessage, wireErr map[string]any) { w.t.Helper() w.id++ - line := `{"id":` + string(rune('0'+w.id)) + `,"method":"work.graph","params":` + params + "}\n" + line := `{"id":` + strconv.Itoa(w.id) + `,"method":"work.graph","params":` + params + "}\n" if _, err := io.WriteString(w.in, line); err != nil { w.t.Fatal(err) } @@ -193,6 +194,14 @@ func TestWorkGraphOnTheRealBackendThroughTheWire(t *testing.T) { } } +func TestWorkGraphNamesTheFactsItsSourcesCannotGive(t *testing.T) { + _, w, crew := graphFixture(t) + got := w.graph(`{"scope":{"kind":"crew","id":"` + crew + `"}}`) + if len(got.Coverage.Unavailable) != 2 || got.Coverage.Unavailable[0] != "steps" || got.Coverage.Unavailable[1] != "asks" { + t.Fatalf("coverage = %+v", got.Coverage) + } +} + func TestWorkGraphUnchangedPagingAndRefusals(t *testing.T) { _, w, crew := graphFixture(t) scope := `{"kind":"crew","id":"` + crew + `"}` @@ -260,7 +269,11 @@ func TestWorkGraphSaysBusyWhenTooManyDistinctBuildsAreQueued(t *testing.T) { hold := make(chan struct{}) started := make(chan struct{}) go workGraphs.cache.Get(context.Background(), workgraph.GraphScope{Kind: workgraph.ScopeCrew, ID: "other"}, "r", - func(ctx context.Context) (workgraph.Input, error) { close(started); <-hold; return workgraph.Input{}, ctx.Err() }) + func(ctx context.Context) (workgraph.Input, error) { + close(started) + <-hold + return workgraph.Input{}, ctx.Err() + }) <-started defer close(hold) if _, bad := w.call(`{"scope":{"kind":"crew","id":"` + crew + `"}}`); bad == nil || bad["code"] != appwire.CodeBusy { diff --git a/docs/changes/unreleased/0-work-graph.md b/docs/changes/unreleased/0-work-graph.md index 944314e8f9..f961f48c4b 100644 --- a/docs/changes/unreleased/0-work-graph.md +++ b/docs/changes/unreleased/0-work-graph.md @@ -13,7 +13,9 @@ New optional wire method `work.graph` (feature `work.graph`; `unknown_method` fr without it). Pages are bounded in nodes, edges and words, every total is true, and an edge exists only where a wait was stored and enforced. Conversations are read from `tasks.json` and the plan stores through read-only connections and cached by file stamp, so an unchanged conversation is -never reparsed. Everything it holds or has in flight is bounded to 128 MiB in bytes (snapshots by a -conservative estimate), a snapshot too big to keep is served whole and not kept, and a build beyond the -pending limit is `busy`. Plan rows carry the store's own `state_detail`, and an accepted route carries its +never reparsed. Everything it holds or has in flight is bounded to 128 MiB with no exemption in any gate: about ten thousand +ordinary tasks are answered (pages after the first build and read nothing); a larger scope is refused with the +additive `too_large` error and a `needs_narrow_scope` detail BEFORE the memory is spent, never served as a 900 MiB +success. A snapshot too big to keep is built within its gate, served whole and not kept; a build beyond the +pending limit is `busy`. `coverage.unavailable` says steps and asks are not in the persisted record. Plan rows carry the store's own `state_detail`, and an accepted route carries its published `price` (absent = unknown). `docs/design/app-v3/PROTOCOL.md` ("Work graph") has the contract. diff --git a/internal/appwire/wire.go b/internal/appwire/wire.go index 00e6e08b5c..af73f51bd4 100644 --- a/internal/appwire/wire.go +++ b/internal/appwire/wire.go @@ -36,6 +36,10 @@ const ( // CodeCycle refuses a collections.add that would make a collection hold // itself, at any depth. CodeCycle = "cycle" + // CodeTooLarge refuses a read whose working set would not fit the engine's + // memory budget. It is additive: an app that does not know the word treats + // it as any failure, and one that does reads Error.Detail. + CodeTooLarge = "too_large" ) // The states an agent can be in, as the app draws them. @@ -85,6 +89,9 @@ type Error struct { Message string `json:"message"` // Changed names the files a conflict is about ([CodeConflict]). Changed []string `json:"changed,omitempty"` + // Detail is the typed account of a refusal that has one: a [CodeTooLarge] + // carries what did not fit, by how much and which smaller scope to ask for. + Detail any `json:"detail,omitempty"` } func (e *Error) Error() string { return e.Code + ": " + e.Message } diff --git a/internal/appwire/workgraph.go b/internal/appwire/workgraph.go index 295452c191..1fa17b2190 100644 --- a/internal/appwire/workgraph.go +++ b/internal/appwire/workgraph.go @@ -44,6 +44,10 @@ func workGraphError(err error) error { return Errorf(CodeBadParams, "%s", trimPrefix(err.Error(), "workgraph: bad request: ")) case errors.Is(err, workgraph.ErrNotFound): return Errorf(CodeNotFound, "%s", trimPrefix(err.Error(), "workgraph: not found: ")) + case errors.Is(err, workgraph.ErrTooLarge): + var tl *workgraph.TooLarge + errors.As(err, &tl) + return &Error{Code: CodeTooLarge, Message: tl.Error(), Detail: tl} case errors.Is(err, workgraph.ErrBusy): return Errorf(CodeBusy, "work.graph is busy with other scopes; try again") case errors.Is(err, context.DeadlineExceeded): diff --git a/internal/session/workgraph_bounds_test.go b/internal/session/workgraph_bounds_test.go index 2a194a06d8..c743ca94b7 100644 --- a/internal/session/workgraph_bounds_test.go +++ b/internal/session/workgraph_bounds_test.go @@ -16,6 +16,8 @@ import ( "sync/atomic" "testing" "time" + + "github.com/Agent-Field/codeaf/internal/workgraph" ) func wgOwners(t *testing.T, n, each int) []string { t.Helper(); return scaleFolders(t, n, each) } @@ -135,7 +137,7 @@ func TestFactAndWaitBoundsAreReportedPartialNotSilent(t *testing.T) { // Many readers asking for one changed conversation share one read, and what // they get is one immutable value they may all read at once. func TestConcurrentReadsOfOneConversationAreCoalescedAndSafe(t *testing.T) { - dirs := wgOwners(t, 1, 20000) + dirs := wgOwners(t, 1, 6000) source := NewWorkSource() var wg sync.WaitGroup start := make(chan struct{}) @@ -155,7 +157,7 @@ func TestConcurrentReadsOfOneConversationAreCoalescedAndSafe(t *testing.T) { for j := range got.Facts { total += len(got.Facts[j].Node.Title) } - if f, ok := got.Find("20000"); !ok || total == 0 || f.Node.Ref.Task != "20000" { + if f, ok := got.Find("6000"); !ok || total == 0 || f.Node.Ref.Task != "6000" { t.Error("a shared value read wrongly") } }() @@ -178,7 +180,7 @@ func TestConcurrentReadsOfOneConversationAreCoalescedAndSafe(t *testing.T) { // A caller that gives up stops waiting at once, and when nobody waits the read // is cancelled; the next caller reads normally. func TestAWaiterThatLeavesCancelsTheReadAndTheNextCallerSucceeds(t *testing.T) { - dirs := wgOwners(t, 1, 30000) + dirs := wgOwners(t, 1, 6000) source := NewWorkSource() ctx, cancel := context.WithCancel(context.Background()) done := make(chan error, 1) @@ -193,7 +195,7 @@ func TestAWaiterThatLeavesCancelsTheReadAndTheNextCallerSucceeds(t *testing.T) { t.Fatal("a cancelled caller kept waiting") } got, err := source.Owner(context.Background(), wgOwnerOf(dirs[0]), dirs[0]) - if err != nil || len(got.Facts) != defaultFacts || got.Issue != WorkIssuePartial { + if err != nil || len(got.Facts) != 6000 || got.Issue != "" { t.Fatalf("next caller: %d facts, %v", len(got.Facts), err) } } @@ -367,18 +369,20 @@ func TestRawDecodesAreWeighedAndTheGateNeverOverfills(t *testing.T) { if source.Parses() < 12 { t.Fatalf("parses %d", source.Parses()) } - // A gate smaller than any one decode still reads, alone. - tiny := NewWorkSourceLimits(WorkLimits{RawBytes: 1}) - if got, err := tiny.Owner(context.Background(), wgOwnerOf(dirs[0]), dirs[0]); err != nil || len(got.Facts) != 200 { - t.Fatalf("a heavy read alone: %v", err) + // NO EXEMPTION: a gate smaller than one decode refuses it, typed, and reads nothing. + tiny := NewWorkSourceLimits(WorkLimits{RawBytes: one - 1}) + before := tiny.Parses() + if _, err := tiny.Owner(context.Background(), wgOwnerOf(dirs[0]), dirs[0]); !errors.Is(err, workgraph.ErrTooLarge) || tiny.Parses() != before { + t.Fatalf("a decode heavier than the gate: err %v, parses %d -> %d", err, before, tiny.Parses()) } } // A caller waiting for room at the gate stops at its own deadline. func TestAReadWaitingForRawRoomStopsWithItsCaller(t *testing.T) { dirs := wgOwners(t, 2, 50) - source := NewWorkSourceLimits(WorkLimits{RawBytes: 1}) - held, err := source.raw.Acquire(context.Background(), 1) + room := NewWorkSource().rawWeight(dirs[0]) + source := NewWorkSourceLimits(WorkLimits{RawBytes: room}) + held, err := source.raw.Acquire(context.Background(), room) if err != nil { t.Fatal(err) } @@ -393,12 +397,35 @@ func TestAReadWaitingForRawRoomStopsWithItsCaller(t *testing.T) { } } -// The aggregate budget is a number somebody can add up: the source's three -// shares plus the snapshot cache's must not pass 128 MiB. +// The aggregate budget is a number somebody can add up: what is retained plus +// what may be in flight, with no exemption in any of them, is at most 128 MiB. func TestTheAggregateBudgetIsAtMostOneHundredTwentyEightMiB(t *testing.T) { l := WorkLimits{}.withDefaults() - sum := l.CacheBytes + l.IndexBytes + l.RawBytes + WorkSnapshotBytes - if sum > 128<<20 { - t.Fatalf("source %d + index %d + raw %d + snapshots %d = %d MiB", l.CacheBytes>>20, l.IndexBytes>>20, l.RawBytes>>20, WorkSnapshotBytes>>20, sum>>20) + retained := l.CacheBytes + l.IndexBytes + WorkSnapshotBytes + inflight := l.RawBytes + WorkBuildBytes + if sum := retained + inflight; sum > 128<<20 { + t.Fatalf("retained %d MiB + in flight %d MiB = %d MiB", retained>>20, inflight>>20, sum>>20) + } + // A conversation that is read and kept fits its own cache share twice over, + // and the largest file read weighs no more than the gate that admits it. + if l.OwnerBytes*2 > l.CacheBytes || (l.FileMax+l.PlanMax)*rawInflation > l.RawBytes { + t.Fatalf("owner quota %d, file %d + plan %d against cache %d and gate %d", l.OwnerBytes, l.FileMax, l.PlanMax, l.CacheBytes, l.RawBytes) + } +} + +// A conversation past its byte quota is cut at the quota, says partial, and +// keeps everything before the cut whole. +func TestAConversationPastItsByteQuotaIsPartialAndNotCut(t *testing.T) { + dirs := wgOwners(t, 1, 400) + source := NewWorkSourceLimits(WorkLimits{OwnerBytes: 100 << 10}) + got, err := source.Owner(context.Background(), wgOwnerOf(dirs[0]), dirs[0]) + if err != nil { + t.Fatal(err) + } + if got.Issue != WorkIssuePartial || len(got.Facts) == 0 || len(got.Facts) >= 400 || got.Bytes > 100<<10+int64(len(got.Facts))*48 { + t.Fatalf("%d facts, %d bytes, issue %q", len(got.Facts), got.Bytes, got.Issue) + } + if got.Weight <= got.Bytes { + t.Fatalf("weight %d is under bytes %d", got.Weight, got.Bytes) } } diff --git a/internal/session/workgraph_project.go b/internal/session/workgraph_project.go index 5bd1dfc249..2ca9f4828c 100644 --- a/internal/session/workgraph_project.go +++ b/internal/session/workgraph_project.go @@ -31,6 +31,22 @@ import ( "github.com/Agent-Field/codeaf/internal/workgraph" ) +// WorkUnavailable names the facts the persisted record cannot give. A task's +// STEPS live in the running node's event ring (memory, gone with the process) +// and in its journal (a per-event trace this source never parses: reading it +// per request is the cost the graph exists to avoid), and the person's open +// ASKS live in memory too, where [Blocking.Tasks] names the work by title and +// not by reference. None is in tasks.json, so none is a fact of this graph. +var WorkUnavailable = []string{"steps", "asks"} + +// wgElapsed is the frozen age a record kept for work that has ended. +func wgElapsed(ms int64, state TaskState) *int64 { + if ms <= 0 || (state != TaskDone && state != TaskFailed) { + return nil + } + return &ms +} + // WorkExcluded names the kinds of work the persisted graph does not hold, so // their absence is a statement and not a silence. var WorkExcluded = []string{"proposals"} @@ -226,6 +242,7 @@ func nodeFact(owner string, r taskRecord) workgraph.Fact { Instructions: wgInstructions(r), Model: wgTitle(r.Model), Landing: wgLanding(r.Merge, r.EndedAt), Files: wgFiles(r.Changed, r.Wrote), Spend: wgSpend(r.CostUSD, wgTokens(r)), Links: "recorded", + ElapsedMS: wgElapsed(r.ElapsedMS, r.State), Attempt: r.Attempt, } if r.Parent != 0 { n.Parent = wgID(owner, r.Parent) @@ -273,6 +290,7 @@ func runFact(owner string, r runRecord, titles map[uint64]string) workgraph.Fact Clock: workgraph.Clock{StartedAt: workgraph.Stamp(r.StartedAt), EndedAt: workgraph.Stamp(r.EndedAt)}, Landing: wgLanding(r.Merge, r.EndedAt), Files: wgFiles(r.Changed, nil), Spend: wgSpend(r.CostUSD, 0), Links: "unrecorded", Route: wgRoute(r.CrewState), + ElapsedMS: wgElapsed(r.ElapsedMS, r.State), } if r.State == TaskInterrupted { n.State = string(TaskRunning) diff --git a/internal/session/workgraph_source.go b/internal/session/workgraph_source.go index 325667a939..5261c42f53 100644 --- a/internal/session/workgraph_source.go +++ b/internal/session/workgraph_source.go @@ -76,6 +76,8 @@ type WorkLimits struct { Flights int IndexFlights int RawBytes int64 + // OwnerBytes is the most estimated bytes of facts one conversation yields. + OwnerBytes int64 // Facts is the most facts one conversation yields; Waits the most stored // waits one task yields. Past either the rest is left out and the // conversation says partial. @@ -84,36 +86,47 @@ type WorkLimits struct { } // THE AGGREGATE BUDGET. Everything the work graph holds on to or has in flight -// sums to at most 128 MiB, so that it is a number a laptop can be promised: +// is bounded, in bytes, and the bounds add up to 128 MiB so that it is a number +// a laptop can be promised: // -// conversations remembered 32 MiB (this file) -// check indexes remembered 8 MiB (this file) -// raw reads decoding at once 32 MiB (this file, weighed at decode inflation) -// snapshots remembered 56 MiB (cmd/codeaf, workgraph.Cache) +// RETAINED +// conversations remembered 16 MiB (this file) +// check indexes remembered 4 MiB (this file) +// snapshots remembered 16 MiB (cmd/codeaf, workgraph.Cache) +// IN FLIGHT, each admitted by a gate that REFUSES what does not fit it +// files being read and decoded 28 MiB (this file, at decode inflation) +// snapshots being built 64 MiB (cmd/codeaf, at workgraph.BuildWeight) // -// A scope too big for the snapshot share (at ~1.4 KiB a task, about 40 000 -// tasks) is served in full and not remembered: each ask pays the cold cost -// honestly. A task's projection is ~1 KiB, so a conversation is bounded at -// 20 000 facts and its checkpoint at 16 MiB. +// There is no "runs alone" exemption anywhere: a request whose files, facts or +// build would not fit a gate is refused with workgraph.TooLarge BEFORE the +// memory is spent. About ten thousand ordinary tasks fit (a 10 000-task build +// allocates ~42 MiB and is weighed at ~58 MiB); a scope much past that, or ten +// thousand tasks that each carry the longest text a node may, is asked to be +// narrowed. A snapshot too big to KEEP (over its 16 MiB share) is still built +// within the gate and served whole, and not remembered. const ( // WorkSnapshotBytes is the share the app's snapshot cache is built with, - // written here so the whole budget is added up in one place. - WorkSnapshotBytes = 56 << 20 - defaultCacheBytes = 32 << 20 + // written here so the whole budget is added up in one place. WorkBuildBytes + // is the build gate. + WorkSnapshotBytes = 16 << 20 + WorkBuildBytes = 64 << 20 + defaultCacheBytes = 16 << 20 defaultCacheOwners = 4096 - defaultIndexBytes = 8 << 20 + defaultIndexBytes = 4 << 20 defaultIndexFiles = 64 - defaultFileMax = 16 << 20 - defaultPlanMax = 16 << 20 - defaultIndexMax = 8 << 20 + defaultFileMax = 7 << 20 + defaultPlanMax = 7 << 20 + defaultIndexMax = 4 << 20 defaultFacts = 20_000 + defaultOwnerBytes = 8 << 20 defaultWaits = 1024 defaultFlights = 8 defaultIndexFlights = 4 - defaultRawBytes = 32 << 20 + defaultRawBytes = 28 << 20 // rawInflation is how many times its file's bytes a decode holds at peak: - // the raw bytes, the decoded structs and the projected facts. - rawInflation = 3 + // the raw bytes and the decoded structs; the projected facts are bounded by + // their own quota. + rawInflation = 2 ) func (l WorkLimits) withDefaults() WorkLimits { @@ -135,6 +148,7 @@ func (l WorkLimits) withDefaults() WorkLimits { set(&l.PlanMax, defaultPlanMax) set(&l.IndexMax, defaultIndexMax) set(&l.RawBytes, defaultRawBytes) + set(&l.OwnerBytes, defaultOwnerBytes) setN(&l.Flights, defaultFlights) setN(&l.IndexFlights, defaultIndexFlights) setN(&l.Facts, defaultFacts) @@ -154,8 +168,11 @@ type OwnerFacts struct { Stamp string Facts []workgraph.Fact Issue string - // Bytes is the estimate the cache budgets this projection at. - Bytes int64 + // Bytes is the estimate the cache budgets this projection at, and Weight what + // building its facts into a snapshot allocates at peak ([workgraph.BuildWeight]). + // Both are accumulated as the facts are added, against a quota. + Bytes int64 + Weight int64 // Externals are the other conversations this one's tasks wait on, sorted: the // sources a target's state is read from, so a caller can name them in its // revision. @@ -402,6 +419,16 @@ func (o *OwnerFacts) add(l WorkLimits, facts []workgraph.Fact) { f.Waits = f.Waits[:l.Waits] o.Issue = firstIssue(o.Issue, WorkIssuePartial) } + // A BYTE QUOTA, NOT ONLY A COUNT: a thousand facts of the longest text are + // more than ten thousand short ones. The fact that would pass the quota is + // left out and the conversation says partial; what is kept is never cut. + size := workgraph.FactBytes(&f) + if o.Bytes+size > l.OwnerBytes { + o.Issue = firstIssue(o.Issue, WorkIssuePartial) + return + } + o.Bytes += size + o.Weight += workgraph.BuildWeight(&f) o.Facts = append(o.Facts, f) } } @@ -417,7 +444,6 @@ func (s *WorkSource) seal(ctx context.Context, out *OwnerFacts) error { } f := &out.Facts[i] out.byID[f.Node.Ref.Task] = int32(i) - out.Bytes += factBytes(f) for _, w := range f.Waits { if w.On.Owner != out.Owner { others[w.On.Owner] = true @@ -432,26 +458,6 @@ func (s *WorkSource) seal(ctx context.Context, out *OwnerFacts) error { return nil } -// factBytes is an estimate of what one fact holds: its fixed part, its strings -// and what it points at. It is an estimate for a budget, not a measurement. -func factBytes(f *workgraph.Fact) int64 { - n := &f.Node - b := int64(768) + int64(len(n.ID)+len(n.Ref.Owner)+len(n.Ref.Task)+len(n.Kind)+len(n.Title)+len(n.Parent)+len(n.Model)+len(n.Ending)) - if n.Instructions != nil { - b += 48 + int64(len(n.Instructions.Text)+len(n.Instructions.Source)) - } - if n.Group != nil { - b += 48 + int64(len(n.Group.ID)+len(n.Group.Title)) - } - for _, p := range n.Files.Paths { - b += 16 + int64(len(p)) - } - for _, w := range f.Waits { - b += 64 + int64(len(w.On.Owner)+len(w.On.Task)) - } - return b -} - // FileStamp names a file's present state (size, modification instant, inode), "-" // when it is not there: the identity a caller caches a read of the file under. func FileStamp(path string) string { return wgStamp(path) } diff --git a/internal/session/workgraph_source_test.go b/internal/session/workgraph_source_test.go index f05f2a150f..9f091b9710 100644 --- a/internal/session/workgraph_source_test.go +++ b/internal/session/workgraph_source_test.go @@ -345,3 +345,24 @@ func TestTheProjectionCarriesTheRecordedAnchorAndRouteOnly(t *testing.T) { t.Fatalf("a row with no crew has a route: %+v", got.Node.Route) } } + +// Elapsed and attempt are what the record froze and counted, and only for work +// that ended; steps and asks are said to be unavailable, never drawn as zero. +func TestTheProjectionCarriesElapsedAndAttemptOnlyWhereTheRecordKeptThem(t *testing.T) { + done := nodeFact(sourceOwner, taskRecord{ID: 1, Title: "t", Brief: "b", State: TaskDone, ElapsedMS: 4200, Attempt: 2}).Node + if done.ElapsedMS == nil || *done.ElapsedMS != 4200 || done.Attempt != 2 { + t.Fatalf("done node = elapsed %v attempt %d", done.ElapsedMS, done.Attempt) + } + for name, r := range map[string]taskRecord{ + "running": {ID: 2, Title: "t", Brief: "b", State: TaskRunning, ElapsedMS: 900}, + "no elapsed": {ID: 3, Title: "t", Brief: "b", State: TaskDone}, + "queued attempt 0": {ID: 4, Title: "t", Brief: "b", State: TaskQueued}, + } { + if n := nodeFact(sourceOwner, r).Node; n.ElapsedMS != nil || n.Attempt != 0 { + t.Errorf("%s: elapsed %v attempt %d", name, n.ElapsedMS, n.Attempt) + } + } + if len(WorkUnavailable) != 2 || WorkUnavailable[0] != "steps" || WorkUnavailable[1] != "asks" { + t.Fatalf("unavailable = %v", WorkUnavailable) + } +} diff --git a/internal/workgraph/answer.go b/internal/workgraph/answer.go index 4814dbcd62..396bcd7698 100644 --- a/internal/workgraph/answer.go +++ b/internal/workgraph/answer.go @@ -131,6 +131,7 @@ func (s *Snapshot) base(view string) *GraphResult { cov := s.coverage cov.Unread = append([]OwnerIssue(nil), s.coverage.Unread...) cov.Excluded = append([]string(nil), s.coverage.Excluded...) + cov.Unavailable = append([]string(nil), s.coverage.Unavailable...) return &GraphResult{ Contract: Contract, View: view, Revision: s.revision, AsOf: copyTime(s.asOf), Scope: s.scope, About: about, Totals: totals, Coverage: cov, Diagnostics: cloneDiagnostics(s.diag), diff --git a/internal/workgraph/build.go b/internal/workgraph/build.go index 6e85aa08e2..dacc093316 100644 --- a/internal/workgraph/build.go +++ b/internal/workgraph/build.go @@ -409,11 +409,12 @@ func (b *builder) finish() error { func (b *builder) coverage() Coverage { s := b.snap c := Coverage{ - Complete: len(b.in.Unread) == 0 && !b.in.Truncated, - Truncated: b.in.Truncated, - Owners: b.in.Owners, - Unread: append([]OwnerIssue(nil), b.in.Unread...), - Excluded: append([]string(nil), b.in.Excluded...), + Complete: len(b.in.Unread) == 0 && !b.in.Truncated, + Truncated: b.in.Truncated, + Owners: b.in.Owners, + Unread: append([]OwnerIssue(nil), b.in.Unread...), + Excluded: append([]string(nil), b.in.Excluded...), + Unavailable: append([]string(nil), b.in.Unavailable...), } if len(c.Unread) > 16 { c.Unread = c.Unread[:16] @@ -468,6 +469,10 @@ func cloneNode(n *GraphNode) *GraphNode { } c.Instructions = &t } + if n.ElapsedMS != nil { + ms := *n.ElapsedMS + c.ElapsedMS = &ms + } c.Route = append([]RouteSeat(nil), n.Route...) for i := range c.Route { if p := n.Route[i].Price; p != nil { diff --git a/internal/workgraph/bytes.go b/internal/workgraph/bytes.go index 93dbed5bcc..2653eaf697 100644 --- a/internal/workgraph/bytes.go +++ b/internal/workgraph/bytes.go @@ -91,6 +91,9 @@ func nodeBytes(n *GraphNode) int64 { if n.Spend != nil { b += int64(unsafe.Sizeof(*n.Spend)) + strBytes(n.Spend.Basis) } + if n.ElapsedMS != nil { + b += 8 + } if n.Checks != nil { b += int64(unsafe.Sizeof(*n.Checks)) } @@ -148,3 +151,55 @@ func (s *Snapshot) estimateBytes() int64 { // Bytes is the conservative size of the snapshot the cache budgets it at. func (s *Snapshot) Bytes() int64 { return s.bytes } + +// FactBytes is the conservative size of one fact as its producer holds it: the +// node as [nodeBytes] counts it plus its waits. A producer budgets what it +// accumulates with this, so the figure it bounds is the one the builder is later +// charged for. +func FactBytes(f *Fact) int64 { + b := nodeBytes(&f.Node) + for _, w := range f.Waits { + b += int64(unsafe.Sizeof(w)) + strBytes(w.On.Owner) + strBytes(w.On.Task) + strBytes(w.Via) + } + return b * fudge / 100 +} + +// WHAT BUILDING COSTS. A build allocates several times what its input holds: +// it copies every node, builds every edge (a dense graph's edges dominate), and +// indexes both ways. The multipliers below are not guesses made in the abstract: +// TestBuildWeightIsNeverBelowWhatBuildAllocates measures a normal, a dense and a +// long-text ten-thousand-task input against them, and fails if the weight falls +// under the bytes really allocated. +const ( + // buildFixedFactor multiplies the structural bytes of a fact (its struct + // and pointers); strings are shared and cost one. + buildFixedFactor = 6 + // buildEdgeBytes is what one stored wait costs to build into the graph. + buildEdgeBytes = 1400 +) + +// BuildWeight is the conservative peak allocation of building this fact into a +// snapshot, in bytes. +func BuildWeight(f *Fact) int64 { + str := factStrings(f) + fixed := FactBytes(f) - str + return fixed*buildFixedFactor + str + int64(len(f.Waits))*buildEdgeBytes +} + +// factStrings is the bytes of the strings a fact holds, counted as FactBytes +// counts them, so the structural remainder can be told from them. +func factStrings(f *Fact) int64 { + n := &f.Node + b := strBytes(n.ID) + strBytes(n.Ref.Owner) + strBytes(n.Ref.Task) + strBytes(n.Kind) + strBytes(n.Title) + + strBytes(n.State) + strBytes(n.StateDetail) + strBytes(n.Ending) + strBytes(n.Parent) + strBytes(n.ParentState) + strBytes(n.Model) + strBytes(n.Links) + if n.Instructions != nil { + b += strBytes(n.Instructions.Source) + strBytes(n.Instructions.Text) + } + for _, p := range n.Files.Paths { + b += strBytes(p) + } + for _, w := range f.Waits { + b += strBytes(w.On.Owner) + strBytes(w.On.Task) + strBytes(w.Via) + } + return b +} diff --git a/internal/workgraph/cache.go b/internal/workgraph/cache.go index d60364cee1..da529232d0 100644 --- a/internal/workgraph/cache.go +++ b/internal/workgraph/cache.go @@ -34,8 +34,9 @@ type CacheLimits struct { // by builds, never by requests. Pending int // BuildBytes bounds the weight of the builds RUNNING at once. A build - // brings its own weight (an estimate of the memory its input costs); one - // heavier than this runs alone rather than never. + // brings its own weight (an estimate of the memory it allocates, see + // [BuildWeight]); one heavier than this is refused with [TooLarge] before it + // starts, never run alone. BuildBytes int64 } @@ -61,6 +62,7 @@ type Cache struct { hits atomic.Int64 busy atomic.Int64 unstored atomic.Int64 + tooLarge atomic.Int64 } type entry struct { @@ -97,6 +99,9 @@ func (c *Cache) Stats() (builds, hits int64) { return c.builds.Load(), c.hits.Lo // were served without being kept. func (c *Cache) Refusals() (busy, unstored int64) { return c.busy.Load(), c.unstored.Load() } +// TooLarge is how many asks were refused because their build could never fit. +func (c *Cache) TooLarge() int64 { return c.tooLarge.Load() } + // Builder makes the [Input] for one scope. It runs without any cache lock and // must honour ctx. type Builder func(ctx context.Context) (Input, error) @@ -108,7 +113,8 @@ func (c *Cache) Get(ctx context.Context, scope GraphScope, revision string, buil // GetWeighted is the snapshot for scope at revision: the cached one, the one // another caller is building right now, or a fresh one built by build, which is -// admitted to run when weight bytes of build capacity are free. A caller whose +// admitted to run when weight bytes of build capacity are free, or refused with +// [TooLarge] when the weight can never fit. A caller whose // own ctx ends stops waiting; the build is abandoned only when nobody waits. A // caller that would start a build beyond the pending limit gets [ErrBusy]. func (c *Cache) GetWeighted(ctx context.Context, scope GraphScope, revision string, weight int64, build Builder) (*Snapshot, error) { @@ -123,6 +129,12 @@ func (c *Cache) GetWeighted(ctx context.Context, scope GraphScope, revision stri } f, ok := c.flights[key] if !ok { + if weight > c.limits.BuildBytes { + // Refused before a flight, a goroutine or a byte of the build exists. + c.mu.Unlock() + c.tooLarge.Add(1) + return nil, NewTooLarge("build", weight, c.limits.BuildBytes) + } if len(c.flights) >= c.limits.Pending { c.mu.Unlock() c.busy.Add(1) diff --git a/internal/workgraph/cache_test.go b/internal/workgraph/cache_test.go index 12b4323fea..0b4322cf02 100644 --- a/internal/workgraph/cache_test.go +++ b/internal/workgraph/cache_test.go @@ -264,14 +264,6 @@ func TestAQueuedBuildNobodyWaitsForLeavesTheGate(t *testing.T) { } } -// A build heavier than the whole gate runs alone instead of never. -func TestABuildHeavierThanTheGateRunsAlone(t *testing.T) { - c := NewCache(CacheLimits{Bytes: 1 << 30, Entries: 8, Pending: 4, BuildBytes: 10}) - if _, err := c.GetWeighted(context.Background(), scopeN(1), "r", 1<<40, smallInput(3)); err != nil { - t.Fatal(err) - } -} - func BenchmarkCacheHit(b *testing.B) { c := NewCache(CacheLimits{Bytes: 1 << 30, Entries: 8, Pending: 4, BuildBytes: 1 << 20}) if _, err := c.Get(context.Background(), scopeN(1), "r", smallInput(1000)); err != nil { diff --git a/internal/workgraph/gate.go b/internal/workgraph/gate.go index 14ff7d60e7..78cde0e382 100644 --- a/internal/workgraph/gate.go +++ b/internal/workgraph/gate.go @@ -9,8 +9,11 @@ import ( // Gate is a weighted, first-in-first-out admission: capacity units of running // weight (bytes, in both its users), waiters parked on a channel each and no // goroutine of the gate's own. A weight above capacity is clamped to it, so a -// heavy job runs alone rather than never. Acquire honours its context, and a -// waiter that leaves hands back anything it was granted as it left. +// Acquire honours its context, and a waiter that leaves hands back anything it +// was granted as it left. A weight ABOVE capacity is refused with [TooLarge]: +// there is no exemption for the heaviest job, because the capacity is the +// promise about memory and a job that does not fit it is the one the promise is +// about. type Gate struct { mu sync.Mutex capacity int64 @@ -39,7 +42,10 @@ func (g *Gate) Limit(waiters int) *Gate { // Acquire waits for weight units of capacity and returns what it took, which // the caller hands back to [Gate.Release]. func (g *Gate) Acquire(ctx context.Context, weight int64) (int64, error) { - weight = min(max(weight, 1), g.capacity) + weight = max(weight, 1) + if weight > g.capacity { + return 0, NewTooLarge("working set", weight, g.capacity) + } g.mu.Lock() if g.queue.Len() == 0 && g.used+weight <= g.capacity { g.used += weight diff --git a/internal/workgraph/input.go b/internal/workgraph/input.go index 838c84c1ef..9457e23fc3 100644 --- a/internal/workgraph/input.go +++ b/internal/workgraph/input.go @@ -79,6 +79,8 @@ type Input struct { Unread []OwnerIssue Truncated bool Excluded []string + // Unavailable is [Coverage.Unavailable]. + Unavailable []string } // orderKey is where a task sits in creation order. diff --git a/internal/workgraph/toolarge.go b/internal/workgraph/toolarge.go new file mode 100644 index 0000000000..ee4133f178 --- /dev/null +++ b/internal/workgraph/toolarge.go @@ -0,0 +1,45 @@ +package workgraph + +import ( + "errors" + "fmt" +) + +// NarrowScope is the advice a [TooLarge] carries: ask for less. +const NarrowScope = "needs_narrow_scope" + +// TooLarge is the typed refusal for a scope whose working set would not fit the +// hard memory budget. IT IS RAISED BEFORE THE ALLOCATION IT PREVENTS — from file +// sizes before anything is read, from the facts counted so far while they are +// read, from the weighed input before it is built — and never after the memory +// has been spent. Nothing is cut to fit and nothing is served partly: the caller +// is told what was needed against what is allowed and to ask for a smaller scope +// (a task, an agent, a smaller crew). +type TooLarge struct { + // What is the quantity that did not fit: "files", "facts" or "build". + What string `json:"what"` + // Need and Limit are bytes: the estimate of what the scope needs at peak, + // and the most this engine admits for it. + Need int64 `json:"need"` + Limit int64 `json:"limit"` + // Reason is always [NarrowScope], so a surface branches on a word. + Reason string `json:"reason"` + // Scope is the largest scope kind worth asking for instead: "agent" or + // "task". Empty when the asker is already at the smallest. + Scope string `json:"scope,omitempty"` +} + +func (e *TooLarge) Error() string { + return fmt.Sprintf("workgraph: too large: %s need ~%d MiB, the engine admits %d MiB; ask for a smaller scope", e.What, e.Need>>20, e.Limit>>20) +} + +// Is makes every [TooLarge] match [ErrTooLarge]. +func (e *TooLarge) Is(target error) bool { return target == ErrTooLarge } + +// ErrTooLarge matches any [TooLarge] under errors.Is. +var ErrTooLarge = errors.New("workgraph: too large") + +// NewTooLarge is a refusal for what needing need bytes against a limit. +func NewTooLarge(what string, need, limit int64) *TooLarge { + return &TooLarge{What: what, Need: need, Limit: limit, Reason: NarrowScope} +} diff --git a/internal/workgraph/types.go b/internal/workgraph/types.go index 72322f3591..1380e13846 100644 --- a/internal/workgraph/types.go +++ b/internal/workgraph/types.go @@ -381,6 +381,11 @@ type Coverage struct { // Excluded names the kinds of work the scope's producer does not put in this // graph, so their absence is a statement and not a silence. Excluded []string `json:"excluded,omitempty"` + // Unavailable names the facts a node could carry that this producer's + // sources cannot give: not zero and not none, but not kept anywhere this + // producer reads. A surface says nothing about them (no step count, no open + // ask) instead of drawing an absence as a figure. + Unavailable []string `json:"unavailable,omitempty"` } // OwnerIssue is one conversation the scope could not read. @@ -472,6 +477,12 @@ type GraphNode struct { Stopped bool `json:"stopped,omitempty"` Declined bool `json:"declined,omitempty"` Ending string `json:"ending,omitempty"` + // ElapsedMS is how long the work ran, as the record froze it when the task + // ended; absent while it runs and wherever the record kept none. Attempt is + // which life of the work the row describes (1 and up once it was re-armed); + // absent for work that has only run once. + ElapsedMS *int64 `json:"elapsed_ms,omitempty"` + Attempt int `json:"attempt,omitempty"` // Parent is the task that handed this one out (its wire id), empty at the // top of a family. ParentState says whether that parent is among the nodes: // "root" (none), "node", or "missing" (the record names a parent its owner diff --git a/internal/workgraph/weight_test.go b/internal/workgraph/weight_test.go new file mode 100644 index 0000000000..8baf102d38 --- /dev/null +++ b/internal/workgraph/weight_test.go @@ -0,0 +1,89 @@ +package workgraph + +import ( + "context" + "errors" + "fmt" + "runtime" + "strings" + "testing" +) + +// buildShapes are ten-thousand-task inputs: what a board is tested at, the same +// with every task waiting on forty others, and the same with the longest titles, +// briefs and path lists a node may carry. +func buildShapes() map[string]Input { + dense := scaleInput(10, 1000) + for i := range dense.Facts { + owner := dense.Facts[i].Node.Ref.Owner + for k := 1; k <= 40 && k < i%1000; k++ { + dense.Facts[i].Waits = append(dense.Facts[i].Waits, Wait{On: TaskRef{Owner: owner, Task: fmt.Sprint(k)}, Via: ViaRecord}) + } + } + long := scaleInput(10, 1000) + for i := range long.Facts { + n := &long.Facts[i].Node + n.Instructions = &GraphText{Source: "admitted_brief", Text: strings.Repeat("x", 1000)} + n.Title = strings.Repeat("t", 160) + for k := 0; k < 50; k++ { + n.Files.Paths = append(n.Files.Paths, fmt.Sprintf("internal/some/long/path/file%d.go", k)) + } + } + return map[string]Input{"normal": scaleInput(10, 1000), "dense": dense, "long": long} +} + +func inputWeight(in Input) (weight, facts int64) { + for i := range in.Facts { + weight += BuildWeight(&in.Facts[i]) + facts += FactBytes(&in.Facts[i]) + } + return weight, facts +} + +// THE WEIGHT IS A CEILING ON THE ALLOCATION IT ADMITS, measured on three shapes. +func TestBuildWeightIsNeverBelowWhatBuildAllocates(t *testing.T) { + for name, in := range buildShapes() { + weight, facts := inputWeight(in) + var before, after runtime.MemStats + runtime.GC() + runtime.ReadMemStats(&before) + snap, err := Build(context.Background(), in) + if err != nil { + t.Fatal(err) + } + runtime.ReadMemStats(&after) + alloc := int64(after.TotalAlloc - before.TotalAlloc) + runtime.KeepAlive(snap) + t.Logf("%s 10K: facts %d KiB, build allocated %d KiB, weight %d KiB (x%.2f)", name, facts>>10, alloc>>10, weight>>10, float64(weight)/float64(alloc)) + if weight < alloc { + t.Fatalf("%s: weight %d is under the %d bytes the build allocated", name, weight, alloc) + } + } +} + +// A BOARD-SIZED SCOPE FITS: ten thousand ordinary tasks are admitted under the +// build gate the app runs with, and weigh no more than the gate. +func TestTenThousandOrdinaryTasksFitTheBuildGate(t *testing.T) { + weight, _ := inputWeight(buildShapes()["normal"]) + if gate := int64(64 << 20); weight > gate { + t.Fatalf("ten thousand ordinary tasks weigh %d MiB against a %d MiB gate", weight>>20, gate>>20) + } +} + +// A gate never runs what exceeds it: the answer is a typed refusal, before +// anything is built. +func TestAWeightOverTheGateIsRefusedTypedAndNeverRun(t *testing.T) { + c := NewCache(CacheLimits{Bytes: 1 << 30, Entries: 8, Pending: 4, BuildBytes: 1000}) + ran := false + _, err := c.GetWeighted(context.Background(), scopeN(1), "r", 1001, func(context.Context) (Input, error) { ran = true; return Input{}, nil }) + var tl *TooLarge + if !errors.Is(err, ErrTooLarge) || !errors.As(err, &tl) || tl.Need != 1001 || tl.Limit != 1000 || tl.Reason != NarrowScope || ran { + t.Fatalf("err = %v (%+v) ran %v", err, tl, ran) + } + if c.Pending() != 0 || c.TooLarge() != 1 { + t.Fatalf("pending %d tooLarge %d", c.Pending(), c.TooLarge()) + } + if _, err := NewGate(10).Acquire(context.Background(), 11); !errors.Is(err, ErrTooLarge) { + t.Fatalf("gate err = %v", err) + } +} From 8092d12043765693768ed0580a3323edfa1cdc22 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 16 Oct 2026 00:25:49 -0400 Subject: [PATCH 133/459] Expose durable media source facts and make render cancellation truthful --- cmd/codeaf/appconvo.go | 16 + cmd/codeaf/appmedia.go | 153 +++ cmd/codeaf/appmedia_test.go | 207 ++++ .../changes/unreleased/0-app-media-sources.md | 19 + .../0-media-render-stop-truthful.md | 8 + internal/appwire/media.go | 253 +++++ internal/appwire/media_test.go | 122 +++ .../chat/making-pictures-audio-and-video.md | 17 +- internal/session/agent_test.go | 3 + internal/session/artifacts.go | 21 +- internal/session/jobs.go | 177 +++- internal/session/mediacache.go | 209 ++++ internal/session/mediacache_test.go | 269 +++++ internal/session/mediaevidence.go | 47 + internal/session/mediaevidence_test.go | 85 ++ internal/session/mediafact.go | 991 ++++++++++++++++++ internal/session/mediafact_test.go | 578 ++++++++++ internal/session/mediafact_tools_test.go | 108 ++ internal/session/mediaident_darwin.go | 17 + internal/session/mediaident_linux.go | 20 + internal/session/mediaident_other.go | 10 + internal/session/render_cancel_test.go | 435 ++++++++ internal/session/render_land.go | 83 ++ internal/session/tools_editvideo.go | 21 +- internal/session/tools_image.go | 28 +- internal/session/tools_music.go | 35 +- internal/session/tools_sense.go | 5 + internal/session/tools_speak.go | 5 +- internal/session/tools_video.go | 49 +- internal/session/tools_view.go | 1 + 30 files changed, 3919 insertions(+), 73 deletions(-) create mode 100644 cmd/codeaf/appmedia.go create mode 100644 cmd/codeaf/appmedia_test.go create mode 100644 docs/changes/unreleased/0-app-media-sources.md create mode 100644 docs/changes/unreleased/0-media-render-stop-truthful.md create mode 100644 internal/appwire/media.go create mode 100644 internal/appwire/media_test.go create mode 100644 internal/session/mediacache.go create mode 100644 internal/session/mediacache_test.go create mode 100644 internal/session/mediaevidence.go create mode 100644 internal/session/mediaevidence_test.go create mode 100644 internal/session/mediafact.go create mode 100644 internal/session/mediafact_test.go create mode 100644 internal/session/mediafact_tools_test.go create mode 100644 internal/session/mediaident_darwin.go create mode 100644 internal/session/mediaident_linux.go create mode 100644 internal/session/mediaident_other.go create mode 100644 internal/session/render_cancel_test.go create mode 100644 internal/session/render_land.go diff --git a/cmd/codeaf/appconvo.go b/cmd/codeaf/appconvo.go index feb8f14d5f..29c6fbaae9 100644 --- a/cmd/codeaf/appconvo.go +++ b/cmd/codeaf/appconvo.go @@ -1197,6 +1197,7 @@ func (b *appBackend) sendMessage(agent string, message appwire.AgentMessage) (ui delete(a.attach, id) } }) + b.recordAttachedMedia(a, kept) return turn, shown, nil } @@ -1587,3 +1588,18 @@ func appTrimmedStart(path string) int { } return lines + 1 } + +// recordAttachedMedia notes the files a person attached to a sent message in +// the media index, so they can be listed as sources (origin attachment). Only a +// file that reads as an image, a sound or a video is noted, and only its head is +// read. It happens after the message is sent and its failure is silence. +func (b *appBackend) recordAttachedMedia(a *appAgent, kept []appAttach) { + var workspace string + b.locked(func() { workspace = a.view.Workspace }) + id := filepath.Base(filepath.Dir(a.transcript)) + for _, attach := range kept { + if attach.view.Kind == appwire.AttachFile { + session.RecordAttachment(appMediaIndex(), id, appAttachFull(workspace, attach.view.Path)) + } + } +} diff --git a/cmd/codeaf/appmedia.go b/cmd/codeaf/appmedia.go new file mode 100644 index 0000000000..4ade8f7e78 --- /dev/null +++ b/cmd/codeaf/appmedia.go @@ -0,0 +1,153 @@ +package main + +import ( + "errors" + "path/filepath" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/session" +) + +// appMediaIndex is where the media sources are read from: the global +// deliverables index. It is a variable so a test can point it at a fixture. +var appMediaIndex = artifactsIndexPath + +// Time a media call may spend reading the index before it answers busy. A +// listing also stops at its byte budget (session.ListMedia) and hands back a +// cursor; only a lookup, which must read the whole history to say not-found, +// is bounded by time alone. +const ( + appMediaListTime = 2 * time.Second + appMediaGetTime = 8 * time.Second +) + +// mediaScope is what the engine knows of one conversation that media needs: its +// id (the folder's name), and the roots a generated file may live under. +func (b *appBackend) mediaScope(agent string) (sessionID string, roots []string, err error) { + a, err := b.find(agent) + if err != nil { + return "", nil, err + } + var workspace string + b.locked(func() { workspace = a.view.Workspace }) + dir := filepath.Dir(a.transcript) + place := session.Place{Dir: dir, Owned: true} + roots = []string{workspace, place.Work(), place.Artifacts()} + return filepath.Base(dir), roots, nil +} + +// ListMedia answers media.list; see appwire.MediaReader. +func (b *appBackend) ListMedia(agent, cursor string, limit int) (appwire.MediaPage, error) { + id, roots, err := b.mediaScope(agent) + if err != nil { + return appwire.MediaPage{}, err + } + // Each source is built, and its size as JSON measured, BEFORE it is admitted: + // the page ends before the row that would pass the reply bound, and the + // cursor resumes at that row. + built := map[string]appwire.MediaSource{} + scan, err := session.ListMediaFit(appMediaIndex(), id, cursor, limit, time.Now().Add(appMediaListTime), + appwire.MediaReplyBudget-8192, func(row session.MediaRow) (int, error) { + source := appMediaSource(row, session.ResolveMedia(row, roots), false) + size, err := appwire.MediaSourceSize(source) + if err != nil { + return 0, err + } + built[row.Fact.ID] = source + return size + 1, nil + }) + if err != nil { + return appwire.MediaPage{}, appMediaError(err) + } + page := appwire.MediaPage{Sources: make([]appwire.MediaSource, 0, len(scan.Rows)), Next: scan.Next, Incomplete: scan.Incomplete} + for _, row := range scan.Rows { + page.Sources = append(page.Sources, built[row.Fact.ID]) + } + return page, nil +} + +// GetMedia answers media.get; see appwire.MediaReader. +func (b *appBackend) GetMedia(agent, source string) (appwire.MediaSource, error) { + id, roots, err := b.mediaScope(agent) + if err != nil { + return appwire.MediaSource{}, err + } + row, err := session.FindMedia(appMediaIndex(), id, source, time.Now().Add(appMediaGetTime)) + if err != nil { + return appwire.MediaSource{}, appMediaError(err) + } + got := appMediaSource(row, session.ResolveMedia(row, roots), true) + if _, err := appwire.MediaSourceSize(got); err != nil { + return appwire.MediaSource{}, appMediaError(session.ErrMediaTooLarge) + } + return got, nil +} + +func appMediaError(err error) error { + switch { + case errors.Is(err, session.ErrMediaCursor): + return appwire.Errorf(appwire.CodeBadParams, "that cursor is no longer valid; list again from the start") + case errors.Is(err, session.ErrMediaNotFound): + return appwire.Errorf(appwire.CodeNotFound, "there is no such media source in this conversation") + case errors.Is(err, session.ErrMediaTooLarge): + return appwire.Errorf(appwire.CodeEngine, "a media record is too large to send within the reply bound") + case errors.Is(err, session.ErrMediaBusy): + return appwire.Errorf(appwire.CodeBusy, "the media history is still being searched; try again") + } + return err +} + +// appMediaSource is a row and its live check as the wire carries them. The +// record is passed through as recorded; only a legacy image whose record has no +// size is probed, and only on a get, and the answer says it was probed. +func appMediaSource(row session.MediaRow, live session.MediaLive, probe bool) appwire.MediaSource { + fact := row.Fact + out := appwire.MediaSource{ + ID: fact.ID, Kind: row.Kind, Origin: fact.Origin, Path: fact.Canonical, + State: live.State, Recorded: row.Recorded, Version: row.Version(), LiveVersion: live.Version, + MIME: fact.MIME, Created: row.Artifact.Created.UTC().Format(time.RFC3339Nano), + Title: row.Artifact.Title, Model: fact.Model, RequestedModel: fact.Model, Run: fact.Run, Message: fact.Message, Tool: fact.Tool, + References: fact.References, Said: fact.Said, + Verb: fact.Verb, Saw: fact.Saw, SawOmitted: fact.SawOmitted, SeenBy: fact.SeenBy, WorkingModel: fact.Working, Transcribed: fact.Transcribed, Charge: fact.Charge, + } + if out.Charge == "" { + out.Charge = session.MediaChargeUnknown + } + if out.Charge == session.MediaCharged { + out.CostUSD = fact.CostUSD + } + if row.Recorded && fact.Bytes > 0 { + bytes := fact.Bytes + out.Bytes = &bytes + } + if live.HasBytes { + bytes := live.Bytes + out.LiveBytes = &bytes + } + if fact.Width > 0 && fact.Height > 0 { + w, h := fact.Width, fact.Height + out.Width, out.Height = &w, &h + } + if fact.Requested > 0 { + n := fact.Requested + out.RequestedCount = &n + } + if fact.DurationMS > 0 { + ms := fact.DurationMS + out.DurationMS = &ms + } + if probe && live.Path != "" { + if out.MIME == "" { + out.MIME = session.ProbeMIME(live.Path) + } + if out.Width == nil && row.Kind == "image" { + if w, h, ok := session.ProbeImage(live.Path); ok { + out.Width, out.Height, out.Probed = &w, &h, true + } + } + } + return out +} + +var _ appwire.MediaReader = (*appBackend)(nil) diff --git a/cmd/codeaf/appmedia_test.go b/cmd/codeaf/appmedia_test.go new file mode 100644 index 0000000000..bf338235ae --- /dev/null +++ b/cmd/codeaf/appmedia_test.go @@ -0,0 +1,207 @@ +package main + +import ( + "encoding/json" + "errors" + "image" + "image/png" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/session" +) + +func writePNG(t *testing.T, path string, w, h int) []byte { + t.Helper() + var buf = new(bytesBuffer) + if err := png.Encode(buf, image.NewRGBA(image.Rect(0, 0, w, h))); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(path, buf.b, 0o644); err != nil { + t.Fatal(err) + } + return buf.b +} + +type bytesBuffer struct{ b []byte } + +func (w *bytesBuffer) Write(p []byte) (int, error) { w.b = append(w.b, p...); return len(p), nil } + +// MEDIA.LIST AND MEDIA.GET answer from the engine's own record: a generated +// file in the workspace and an attachment outside it are both sources, a source +// of another conversation is not found, an unknown id is not found, and a file +// that has gone keeps what was recorded. +func TestAppMediaListGetOwnershipAndMissing(t *testing.T) { + backend, _, agent := workBackend(t, &fakeConversation{script: []session.Event{{Kind: session.EventTurnDone}}}) + index := filepath.Join(t.TempDir(), "artifacts.jsonl") + old := appMediaIndex + appMediaIndex = func() string { return index } + t.Cleanup(func() { appMediaIndex = old }) + sessionID, _, err := backend.mediaScope(agent.ID) + if err != nil { + t.Fatal(err) + } + + generated := filepath.Join(agent.Workspace, "made.png") + data := writePNG(t, generated, 40, 30) + genID := session.RecordMedia(index, session.Artifact{Path: generated, Session: sessionID, Title: "made", Kind: "image", Created: time.Now()}, + session.GeneratedMedia(generated, data, "paint/model")) + elsewhere := filepath.Join(t.TempDir(), "picked.png") + writePNG(t, elsewhere, 8, 6) + session.RecordAttachment(index, sessionID, elsewhere) + session.RecordMedia(index, session.Artifact{Path: generated, Session: "someone-else", Title: "theirs", Kind: "image", Created: time.Now()}, + session.GeneratedMedia(generated, data, "m")) + + page, err := backend.ListMedia(agent.ID, "", 50) + if err != nil || len(page.Sources) != 2 || page.Next != "" { + t.Fatalf("list = %+v %v", page, err) + } + attached, made := page.Sources[0], page.Sources[1] + if attached.Origin != "attachment" || attached.State != appwire.MediaAvailable || attached.Width == nil || *attached.Width != 8 { + t.Fatalf("attachment = %+v", attached) + } + if made.ID != genID || made.Origin != "generated" || made.State != appwire.MediaAvailable || made.Model != "paint/model" || + made.RequestedModel != "paint/model" || made.Charge != "unknown" || made.CostUSD != nil || made.Version == "" || made.Bytes == nil || *made.Bytes != int64(len(data)) { + t.Fatalf("generated = %+v", made) + } + + got, err := backend.GetMedia(agent.ID, genID) + if err != nil || got.ID != genID || got.Path != made.Path { + t.Fatalf("get = %+v %v", got, err) + } + var wire *appwire.Error + if _, err := backend.GetMedia(agent.ID, session.NewMediaID()); !errors.As(err, &wire) || wire.Code != appwire.CodeNotFound { + t.Fatalf("unknown id answered %v", err) + } + if _, err := backend.GetMedia(agent.ID, "/etc/passwd"); !errors.As(err, &wire) || wire.Code != appwire.CodeNotFound { + t.Fatalf("a path as an id answered %v", err) + } + if _, err := backend.ListMedia(agent.ID, "garbage", 5); !errors.As(err, &wire) || wire.Code != appwire.CodeBadParams { + t.Fatalf("garbage cursor answered %v", err) + } + if _, err := backend.ListMedia("no-such-agent", "", 5); err == nil { + t.Fatal("an unknown agent listed media") + } + + // Gone: the record stays, nothing is guessed from the file. + if err := os.Remove(generated); err != nil { + t.Fatal(err) + } + gone, err := backend.GetMedia(agent.ID, genID) + if err != nil || gone.State != appwire.MediaMissing || gone.Bytes == nil || *gone.Bytes != int64(len(data)) || + gone.LiveBytes != nil || gone.LiveVersion != "" || gone.Width == nil || *gone.Width != 40 || gone.Version != made.Version { + t.Fatalf("missing = %+v %v", gone, err) + } +} + +// A legacy image row (no recorded size) is probed on get only, and says so. +func TestAppMediaLegacyImageIsProbedOnGetOnly(t *testing.T) { + backend, _, agent := workBackend(t, &fakeConversation{script: []session.Event{{Kind: session.EventTurnDone}}}) + index := filepath.Join(t.TempDir(), "artifacts.jsonl") + old := appMediaIndex + appMediaIndex = func() string { return index } + t.Cleanup(func() { appMediaIndex = old }) + sessionID, _, _ := backend.mediaScope(agent.ID) + path := filepath.Join(agent.Workspace, "old.png") + writePNG(t, path, 12, 7) + session.RecordArtifact(index, session.Artifact{Path: path, Session: sessionID, Title: "old", Kind: "image", Created: time.Now()}) + page, err := backend.ListMedia(agent.ID, "", 5) + if err != nil || len(page.Sources) != 1 { + t.Fatalf("list = %+v %v", page, err) + } + listed := page.Sources[0] + if listed.Recorded || listed.Width != nil || listed.Bytes != nil || listed.Version != "" || listed.LiveBytes == nil { + t.Fatalf("legacy listed = %+v", listed) + } + got, _ := backend.GetMedia(agent.ID, listed.ID) + if got.Width == nil || *got.Width != 12 || !got.Probed || got.Bytes != nil { + t.Fatalf("legacy get = %+v", got) + } +} + +// A file attached to a SENT message becomes a source (origin attachment); a +// non-media file and a message never sent do not. +func TestAppSendNotesAttachedMediaAsSources(t *testing.T) { + backend, _, agent := workBackend(t, &fakeConversation{script: []session.Event{{Kind: session.EventTurnDone}}}) + index := filepath.Join(t.TempDir(), "artifacts.jsonl") + old := appMediaIndex + appMediaIndex = func() string { return index } + t.Cleanup(func() { appMediaIndex = old }) + picture := filepath.Join(agent.Workspace, "shot.png") + writePNG(t, picture, 5, 4) + notes := filepath.Join(agent.Workspace, "notes.txt") + if err := os.WriteFile(notes, []byte("hi"), 0o644); err != nil { + t.Fatal(err) + } + staged := func(path string) string { + a, err := backend.AddAttachment(agent.ID, appwire.Attachment{Kind: appwire.AttachFile, Path: path}, "") + if err != nil { + t.Fatal(err) + } + return a.ID + } + pic, txt := staged("shot.png"), staged("notes.txt") + if page, _ := backend.ListMedia(agent.ID, "", 10); len(page.Sources) != 0 { + t.Fatalf("a staged, unsent attachment is already a source: %+v", page.Sources) + } + if _, err := backend.SendWith(agent.ID, "look", "m-1", []string{pic, txt}); err != nil { + t.Fatal(err) + } + page, err := backend.ListMedia(agent.ID, "", 10) + if err != nil || len(page.Sources) != 1 || page.Sources[0].Origin != "attachment" || page.Sources[0].Kind != "image" || + page.Sources[0].Width == nil || *page.Sources[0].Width != 5 { + t.Fatalf("sources = %+v %v", page.Sources, err) + } +} + +// A hundred sources each carrying 32 KiB of text that JSON escapes to six times +// its size never make one reply past the bound: pages end early, the cursor +// resumes exactly where the page stopped, and every source arrives once. +func TestAppMediaPagesStayUnderTheReplyBound(t *testing.T) { + backend, _, agent := workBackend(t, &fakeConversation{script: []session.Event{{Kind: session.EventTurnDone}}}) + index := filepath.Join(t.TempDir(), "artifacts.jsonl") + old := appMediaIndex + appMediaIndex = func() string { return index } + t.Cleanup(func() { appMediaIndex = old }) + sessionID, _, _ := backend.mediaScope(agent.ID) + path := filepath.Join(agent.Workspace, "p.png") + data := writePNG(t, path, 2, 2) + said := strings.Repeat("<", 32<<10) + var want []string + for i := 0; i < 100; i++ { + want = append(want, session.RecordMedia(index, session.Artifact{Path: path, Session: sessionID, Title: "t", Kind: "audio", Created: time.Now()}, + session.GeneratedMedia(path, data, "m").WithSaid(said))) + } + var got []string + cursor, pages := "", 0 + for { + page, err := backend.ListMedia(agent.ID, cursor, appwire.MediaPageMax) + if err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(page) + if err != nil || len(raw) > appwire.MediaReplyBudget { + t.Fatalf("a page of %d bytes (%v) passes the %d reply bound", len(raw), err, appwire.MediaReplyBudget) + } + for _, s := range page.Sources { + got = append(got, s.ID) + } + pages++ + if page.Next == "" { + break + } + cursor = page.Next + } + if pages < 4 || len(got) != 100 { + t.Fatalf("%d pages, %d sources", pages, len(got)) + } + for i, id := range got { + if id != want[99-i] { + t.Fatalf("source %d repeated or out of order", i) + } + } +} diff --git a/docs/changes/unreleased/0-app-media-sources.md b/docs/changes/unreleased/0-app-media-sources.md new file mode 100644 index 0000000000..266bc77531 --- /dev/null +++ b/docs/changes/unreleased/0-app-media-sources.md @@ -0,0 +1,19 @@ +kind: added +title: codeaf app lists and gets a conversation's media as engine-issued sources +pr: 0 +surface: [engine] +invalidates: + - "Generated and attached media existed only as a path inside a tool's prose and an untyped artifact row, so an app had to parse prose to know a picture's size, what a voice was told to say, or whether the provider charged. The artifact index row now carries an optional structured fact, and media.list / media.get answer it." + - "A source id derived from a path would make a regenerated file the same source as the one it replaced. New ids are minted once per record; only rows older than this change get a stable derived id." +--- + +New optional methods `media.list {agent, cursor?, limit?}` and +`media.get {agent, source}`. An engine that does not implement them answers +`unknown_method`, which means "this host cannot say", not "no media". The app +can only name a source id the engine issued — there is no path parameter — and +only `state: available` may be opened. `generate_image`, `speak`, +`generate_music`, `generate_video` and `edit_video` record the fact (actual +image size from the file header, hash of the bytes the provider sent, the +exact reference paths and spoken text, charge from the provider's reported +usage) beside their unchanged prose; a message sent with attached images, +sound or video notes those files as sources. diff --git a/docs/changes/unreleased/0-media-render-stop-truthful.md b/docs/changes/unreleased/0-media-render-stop-truthful.md new file mode 100644 index 0000000000..0ce696499a --- /dev/null +++ b/docs/changes/unreleased/0-media-render-stop-truthful.md @@ -0,0 +1,8 @@ +--- +kind: fixed +title: stopping a video or music render now either saves nothing or says the file was saved +pr: 0 +surface: [engine] +invalidates: + - "Was true: a render the person stopped could still write its file if the provider answered a moment later, `jobs kill` always said `no video was saved` even then, any number of renders could run at once (each holding up to 512 MB), a render that failed listed as `finished`, and a stop never said the provider was not told to cancel. Is true now: a stop accepted before the bytes are committed writes nothing; a stop that arrives after the render committed is answered `had already finished rendering and is being saved`, and its note still arrives; at most 3 renders run at once in the whole process (not per conversation), a place is held until the provider call actually returns even after a stop, and the next is refused with a reason before anything is spent; a failed render lists as `failed`; and a stop says the provider was not told to cancel, so whether it is charged is unknown. The file is written to a temporary name and renamed, so a crash leaves no partial video. Nothing retries a render." +--- diff --git a/internal/appwire/media.go b/internal/appwire/media.go new file mode 100644 index 0000000000..086bc12a9b --- /dev/null +++ b/internal/appwire/media.go @@ -0,0 +1,253 @@ +package appwire + +import ( + "encoding/json" + "errors" + "reflect" + "strings" +) + +// MEDIA.LIST and MEDIA.GET: the media files a conversation made or was handed, +// as structured facts the app can draw a card from without reading prose. +// +// A source is GRANTED BY THE ENGINE. The app names a source by the opaque id the +// engine gave it, and the engine answers with the path it recorded; there is no +// parameter through which a caller can name a path, so there is no way to ask +// the engine to vouch for one. Whether a file may be opened is the engine's +// answer in State, and only [MediaAvailable] may be. +// +// Unknown stays unknown: a size, a duration or a charge nothing reported is +// absent, never zero. The capability is optional — an engine that is not a +// [MediaReader] refuses both methods as unknown_method, which an app must read +// as "this host cannot say", not as "no media". + +// MediaPageDefault and MediaPageMax bound one media.list page. +const ( + MediaPageDefault = 50 + MediaPageMax = 100 +) + +// The bounds of a media reply. The transport refuses a frame far above these; +// they are held well below it, and they are checked BEFORE anything is marshaled +// so a bad record can neither blow memory nor be cut in the middle. +const ( + // MediaReplyBudget is the most one media.list or media.get answer may + // carry as JSON, escaping included. + MediaReplyBudget = 4 << 20 + // mediaStringMax and mediaArrayMax bound one field of a record. + mediaStringMax = 256 << 10 + mediaArrayMax = 256 + // mediaSourceEstimateMax bounds one record's worst-case size (every byte + // escaped to six) before it is marshaled at all. + mediaSourceEstimateMax = 1 << 20 +) + +// ErrMediaTooLarge is a record that cannot be sent within the bounds. +var ErrMediaTooLarge = errors.New("a media record is too large to send") + +// MediaSourceSize validates one record against the field bounds and answers the +// exact number of bytes it will take as JSON, escaping included. A record with +// a string or an array past its bound, or whose worst case is past the record +// bound, is [ErrMediaTooLarge] and is not marshaled. +func MediaSourceSize(source MediaSource) (int, error) { + estimate := 512 + value := reflect.ValueOf(source) + for i := 0; i < value.NumField(); i++ { + field := value.Field(i) + switch field.Kind() { + case reflect.String: + if field.Len() > mediaStringMax { + return 0, ErrMediaTooLarge + } + estimate += field.Len() * 6 + case reflect.Slice: + if field.Len() > mediaArrayMax { + return 0, ErrMediaTooLarge + } + for j := 0; j < field.Len(); j++ { + if item := field.Index(j); item.Kind() == reflect.String { + if item.Len() > mediaStringMax { + return 0, ErrMediaTooLarge + } + estimate += item.Len()*6 + 4 + } + } + } + if estimate > mediaSourceEstimateMax { + return 0, ErrMediaTooLarge + } + } + raw, err := json.Marshal(source) + if err != nil { + return 0, err + } + return len(raw), nil +} + +func mediaReplyError() error { + return Errorf(CodeEngine, "the media answer is larger than the reply bound of %d bytes", MediaReplyBudget) +} + +// What a source is now. +const ( + MediaAvailable = "available" // a regular file, in scope, as recorded: may be opened + MediaChanged = "changed" // there, but its size or time differ from the record: never opened + MediaMissing = "missing" // gone, or a dangling link + MediaOutside = "outside" // resolves somewhere the engine will not vouch for + MediaUnreadable = "unreadable" // not a regular file, or not readable +) + +// MediaSource is one media file and what the engine recorded about it. +// +// Everything but State, LiveVersion and LiveBytes is the RECORD and is returned +// unchanged whatever the file is now: a missing file keeps the size it had. +type MediaSource struct { + ID string `json:"id"` + Kind string `json:"kind" enum:"image,audio,video"` + Origin string `json:"origin" enum:"attachment,generated,evidence"` + // Path is the recorded canonical path, for display and for the app's + // native side to open ONLY when State is available. + Path string `json:"path"` + State string `json:"state" enum:"available,changed,missing,outside,unreadable"` + // Recorded says the engine recorded structured facts with this source; + // false is an older record that knows only its path, kind and time. + Recorded bool `json:"recorded"` + // Version is the recorded version: sha256: where the engine held the + // bytes, else file::::: as recorded. + // Absent for an older record. LiveVersion is the file's identity now, in + // the file: form; a caller that opens the file compares it again. + Version string `json:"version,omitempty"` + LiveVersion string `json:"live_version,omitempty"` + MIME string `json:"mime,omitempty"` + // Bytes is the recorded size; LiveBytes the size now, when it is a file. + Bytes *int64 `json:"bytes,omitempty"` + LiveBytes *int64 `json:"live_bytes,omitempty"` + Width *int `json:"width,omitempty"` + Height *int `json:"height,omitempty"` + DurationMS *int64 `json:"duration_ms,omitempty"` + // Probed says Width and Height were read from the file's header just now + // because the record had none. + Probed bool `json:"probed,omitempty"` + Created string `json:"created"` + Title string `json:"title,omitempty"` + // Model is the model the call ASKED for, kept for compatibility; + // RequestedModel says the same thing under the name that cannot be misread. + // No provider reports which model served a call, so there is no served + // model on this record, and none is guessed. + Model string `json:"model,omitempty"` + RequestedModel string `json:"requested_model,omitempty"` + // RequestedCount is how many files the request asked for in one call; files + // with the same Tool id were asked for together. + RequestedCount *int `json:"requested_count,omitempty"` + // Run is the turn's immutable execution id and Tool the provider's id for + // the tool call that made the file, both read off the call itself. + Run string `json:"run,omitempty"` + Message string `json:"message,omitempty"` + Tool string `json:"tool,omitempty"` + References []string `json:"references,omitempty"` + Said string `json:"said,omitempty"` + // Evidence only (origin evidence — a file the agent looked at): Verb is + // looked_at · listened_to · watched; Saw is the looking model's words + // verbatim (SawOmitted when too long to keep whole); SeenBy is the model + // that looked and WorkingModel the one the session was running, which an + // app says only when they differ; Transcribed says the words are a speech + // transcript. An app shows the person's own attachments once, by comparing + // an evidence source's path with the attachment sources' paths. + Verb string `json:"verb,omitempty"` + Saw string `json:"saw,omitempty"` + SawOmitted bool `json:"saw_omitted,omitempty"` + SeenBy string `json:"seen_by,omitempty"` + WorkingModel string `json:"working_model,omitempty"` + Transcribed bool `json:"transcribed,omitempty"` + // Charge is charged, not_charged or unknown, from the provider's reported + // usage; CostUSD is present only when charged. + Charge string `json:"charge" enum:"charged,not_charged,unknown"` + CostUSD *float64 `json:"cost_usd,omitempty"` +} + +// MediaPage is media.list's answer. Next resumes the listing below this page; +// absent means the whole history has been read. Incomplete says a page is short +// only because the engine spent its read budget, not because history ended — +// keep following Next. +type MediaPage struct { + Sources []MediaSource `json:"sources"` + Next string `json:"next,omitempty"` + Incomplete bool `json:"incomplete,omitempty"` +} + +// MediaResult is media.get's answer. +type MediaResult struct { + Source MediaSource `json:"source"` +} + +// MediaReader is the optional engine half of media.list and media.get. +type MediaReader interface { + // ListMedia answers one page of the agent's sources, newest first. + // limit is already clamped to 1..MediaPageMax. A cursor the engine cannot + // resume is a bad_params error; the caller lists again from the start. + ListMedia(agent, cursor string, limit int) (MediaPage, error) + // GetMedia answers one source of the agent by id, found anywhere in its + // history. An unknown id is not_found only after the whole history was + // read; a search that ran out of time is busy. + GetMedia(agent, source string) (MediaSource, error) +} + +func init() { + methods["media.list"] = typed(func(e Engine, p struct { + Agent string `json:"agent"` + Cursor string `json:"cursor"` + Limit int `json:"limit"` + }) (*MediaPage, error) { + r, ok := e.(MediaReader) + if !ok { + return nil, Errorf(CodeUnknownMethod, "this engine does not serve media.list") + } + if strings.TrimSpace(p.Agent) == "" || p.Limit < 0 { + return nil, Errorf(CodeBadParams, "media.list needs an agent and a limit of zero or more") + } + limit := p.Limit + if limit == 0 { + limit = MediaPageDefault + } + page, err := r.ListMedia(p.Agent, p.Cursor, min(limit, MediaPageMax)) + if err != nil { + return nil, err + } + if page.Sources == nil { + page.Sources = []MediaSource{} + } + // The engine ends a page before the reply bound; one that did not is + // refused whole rather than cut, because a cut page has no honest cursor. + total := 256 + len(page.Next) + for _, source := range page.Sources { + size, err := MediaSourceSize(source) + if err != nil { + return nil, mediaReplyError() + } + if total += size + 1; total > MediaReplyBudget { + return nil, mediaReplyError() + } + } + return &page, nil + }) + methods["media.get"] = typed(func(e Engine, p struct { + Agent string `json:"agent"` + Source string `json:"source"` + }) (*MediaResult, error) { + r, ok := e.(MediaReader) + if !ok { + return nil, Errorf(CodeUnknownMethod, "this engine does not serve media.get") + } + if strings.TrimSpace(p.Agent) == "" || strings.TrimSpace(p.Source) == "" { + return nil, Errorf(CodeBadParams, "media.get needs an agent and a source") + } + source, err := r.GetMedia(p.Agent, p.Source) + if err != nil { + return nil, err + } + if size, err := MediaSourceSize(source); err != nil || size+64 > MediaReplyBudget { + return nil, mediaReplyError() + } + return &MediaResult{source}, nil + }) +} diff --git a/internal/appwire/media_test.go b/internal/appwire/media_test.go new file mode 100644 index 0000000000..cea730a358 --- /dev/null +++ b/internal/appwire/media_test.go @@ -0,0 +1,122 @@ +package appwire + +import ( + "encoding/json" + "errors" + "strings" + "testing" +) + +type mediaEngine struct { + fakeEngine + agent, cursor, source string + limit int +} + +func (m *mediaEngine) ListMedia(agent, cursor string, limit int) (MediaPage, error) { + m.agent, m.cursor, m.limit = agent, cursor, limit + return MediaPage{}, nil +} + +func (m *mediaEngine) GetMedia(agent, source string) (MediaSource, error) { + m.agent, m.source = agent, source + return MediaSource{ID: source, Kind: "image", Origin: "generated", State: MediaAvailable, Charge: "unknown"}, nil +} + +// MEDIA.LIST and MEDIA.GET are an optional capability: an engine that cannot +// serve them is unknown_method, the limit is clamped, an empty page is an +// empty list and not null, and the params carry no path. +func TestMediaRoutes(t *testing.T) { + list, get := methods["media.list"], methods["media.get"] + if list.call == nil || get.call == nil { + t.Fatal("media methods are not registered") + } + eng := &mediaEngine{} + out, err := list.call(eng, json.RawMessage(`{"agent":"a1","cursor":"c","limit":99999}`)) + if err != nil || eng.agent != "a1" || eng.cursor != "c" || eng.limit != MediaPageMax { + t.Fatalf("list: %v %+v", err, eng) + } + if raw, _ := json.Marshal(out); string(raw) != `{"sources":[]}` { + t.Fatalf("an empty page is %s", raw) + } + if _, err := list.call(eng, json.RawMessage(`{"agent":"a1"}`)); err != nil || eng.limit != MediaPageDefault { + t.Fatalf("default limit %d (%v)", eng.limit, err) + } + if _, err := get.call(eng, json.RawMessage(`{"agent":"a1","source":"src_x"}`)); err != nil || eng.source != "src_x" { + t.Fatalf("get: %v", err) + } + var wire *Error + for _, bad := range []string{`{}`, `{"agent":"a1","source":" "}`, `{"source":"s"}`} { + if _, err := get.call(eng, json.RawMessage(bad)); !errors.As(err, &wire) || wire.Code != CodeBadParams { + t.Fatalf("get %s answered %v", bad, err) + } + } + if _, err := list.call(eng, json.RawMessage(`{"agent":"a1","limit":-1}`)); !errors.As(err, &wire) || wire.Code != CodeBadParams { + t.Fatalf("negative limit answered %v", err) + } + for _, m := range []method{list, get} { + if _, err := m.call(&fakeEngine{}, json.RawMessage(`{"agent":"a1","source":"s"}`)); !errors.As(err, &wire) || wire.Code != CodeUnknownMethod { + t.Fatalf("an engine without media answered %v", err) + } + } +} + +// A record past the field bounds is refused before it is marshaled, escaping +// counts at its worst, and a page past the reply budget is refused whole. +func TestMediaReplyBoundsAreChecked(t *testing.T) { + ok := MediaSource{ID: "src_x", Kind: "image", Origin: "generated", State: MediaAvailable, Charge: "unknown", Said: "<>&"} + if size, err := MediaSourceSize(ok); err != nil || size < 20 { + t.Fatalf("a small record: %d %v", size, err) + } + // Escaping is counted exactly: '<' becomes six bytes. + plain, _ := MediaSourceSize(MediaSource{Said: strings.Repeat("a", 1000)}) + escaped, _ := MediaSourceSize(MediaSource{Said: strings.Repeat("<", 1000)}) + if escaped-plain < 5000 { + t.Fatalf("escaping is not counted: %d vs %d", escaped, plain) + } + for name, bad := range map[string]MediaSource{ + "a huge string": {Said: strings.Repeat("x", mediaStringMax+1)}, + "too many references": {References: make([]string, mediaArrayMax+1)}, + "a huge reference": {References: []string{strings.Repeat("x", mediaStringMax+1)}}, + "worst-case escaping": {Said: strings.Repeat("<", 200<<10), Title: strings.Repeat("<", 200<<10)}, + "many long references": {References: repeated(200, strings.Repeat("r", 20<<10))}, + "a long path and title": {Path: strings.Repeat("p", 200<<10), Title: strings.Repeat("t", 200<<10)}, + } { + if _, err := MediaSourceSize(bad); !errors.Is(err, ErrMediaTooLarge) { + t.Errorf("%s answered %v", name, err) + } + } + + m := methods["media.list"] + big := make([]MediaSource, 40) + for i := range big { + big[i] = MediaSource{ID: "src_x", Said: strings.Repeat("<", 30<<10)} // ~180 KiB each as JSON + } + var wire *Error + if _, err := m.call(&bigEngine{page: MediaPage{Sources: big}}, json.RawMessage(`{"agent":"a"}`)); !errors.As(err, &wire) || wire.Code != CodeEngine { + t.Fatalf("a page of 7 MiB answered %v", err) + } + if _, err := m.call(&bigEngine{page: MediaPage{Sources: big[:10]}}, json.RawMessage(`{"agent":"a"}`)); err != nil { + t.Fatalf("a page of 1.8 MiB answered %v", err) + } + if _, err := methods["media.get"].call(&bigEngine{one: MediaSource{Said: strings.Repeat("x", 300<<10)}}, json.RawMessage(`{"agent":"a","source":"s"}`)); !errors.As(err, &wire) || wire.Code != CodeEngine { + t.Fatalf("an oversize get answered %v", err) + } +} + +func repeated(n int, s string) []string { + out := make([]string, n) + for i := range out { + out[i] = s + } + return out +} + +type bigEngine struct { + fakeEngine + page MediaPage + one MediaSource +} + +func (b *bigEngine) ListMedia(string, string, int) (MediaPage, error) { return b.page, nil } +func (b *bigEngine) GetMedia(string, string) (MediaSource, error) { return b.one, nil } diff --git a/internal/manual/chat/making-pictures-audio-and-video.md b/internal/manual/chat/making-pictures-audio-and-video.md index 5c2ace515e..2a4f6117a1 100644 --- a/internal/manual/chat/making-pictures-audio-and-video.md +++ b/internal/manual/chat/making-pictures-audio-and-video.md @@ -301,7 +301,14 @@ next step: A compose that fails says so the same way: `job 4 failed: music generation failed (): …`. It shows in `jobs list` as `job 4 · music · running · 12.3s · a calm solo piano loop`, and `jobs kill 4` stops it — `music (job 4) stopped; -no music was saved`. Like every job, it dies when the conversation ends. +no music was saved. Only waiting stopped here — the provider was not told to +cancel, so whether it is charged is unknown`. A stop that arrives after the piece +has finished is answered `had already finished rendering and is being saved`, and +the file and its note still arrive. A compose that fails lists as `failed`, and +nothing retries it. At most 3 renders (video and music together) run at once across the +whole program, and a stopped one still counts until the provider actually answers; +the next is refused until a place frees. A reply over the size limit is +refused, never saved cut short. Like every job, it dies when the conversation ends. **There is no length argument**, because the endpoint has none: the model writes a piece of its own choosing — half a minute to a minute in practice — and you @@ -387,8 +394,12 @@ Arguments: `prompt` (required), `duration` in seconds, `aspect_ratio`, The render **is** a job: it shows in `jobs list` as `job 3 · video · running · 42.1s · a ferry at dawn`, and `jobs kill 3` stops it — -`video (job 3) stopped; no video was saved`. Like every job, it dies when the -conversation ends. The provider gives up after **10 minutes** and no file is +`video (job 3) stopped; no video was saved. Only waiting stopped here — the +provider was not told to cancel, so whether it is charged is unknown`. A stop that +arrives after the render has finished is answered `had already finished rendering +and is being saved`; the file and its note still arrive. A render that fails lists +as `failed` and is never retried, and a video reply over 512MB is refused rather +than saved cut short. Like every job, it dies when the conversation ends. The provider gives up after **10 minutes** and no file is saved. ## Can you make a longer video — several clips, a whole story, 2 minutes of film? diff --git a/internal/session/agent_test.go b/internal/session/agent_test.go index 598b237d1f..a85bc4928c 100644 --- a/internal/session/agent_test.go +++ b/internal/session/agent_test.go @@ -272,6 +272,9 @@ func newTestAgent(t *testing.T, completer Completer, mutate func(*Config)) (*Age if err != nil { t.Fatalf("newAgent: %v", err) } + // A private render gate, so a test that leaves a provider call blocked + // cannot starve the next test of the process-wide places. + agent.jobs.gate = newRenderGate(jobRenderCap) // A SESSION'S DEFERRED WRITES ARE SETTLED BEFORE ITS DIRECTORIES GO AWAY, the // same call an exit door owes ([Agent.SettleWrites], placemeta.go). Cleanups // run last-registered-first, so this lands before the tempdirs above it. diff --git a/internal/session/artifacts.go b/internal/session/artifacts.go index 0dc50a6ff4..e241bf16b1 100644 --- a/internal/session/artifacts.go +++ b/internal/session/artifacts.go @@ -47,6 +47,9 @@ type Artifact struct { Kind string `json:"kind,omitempty"` // Created is when it landed; every ordering in this file is on it. Created time.Time `json:"created"` + // Media is the structured fact of a media file (mediafact.go). Optional + // and additive: a row without it is a legacy row and keeps listing. + Media *MediaFact `json:"media,omitempty"` } // artifactsMu serializes this process's appends; two processes are serialized @@ -57,32 +60,34 @@ var artifactsMu sync.Mutex // appendTaskIndex's reason: the caller has just produced real work, and there // is nothing it could usefully do with the news that a lookup file could not // be written. A row with no path or no title is not a citation and is not -// written. -func RecordArtifact(path string, artifact Artifact) { +// written. It answers whether the row was written, for the caller that must not +// hand out an id for a row that is not there. +func RecordArtifact(path string, artifact Artifact) bool { if strings.TrimSpace(path) == "" { - return + return false } if strings.TrimSpace(artifact.Path) == "" || strings.TrimSpace(artifact.Title) == "" { - return + return false } line, err := json.Marshal(artifact) if err != nil { - return + return false } artifactsMu.Lock() defer artifactsMu.Unlock() if directory := filepath.Dir(path); directory != "" && directory != "." { if err := os.MkdirAll(directory, 0o700); err != nil { - return + return false } } file, err := os.OpenFile(path, os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o600) if err != nil { - return + return false } defer file.Close() // ONE write, so O_APPEND's atomic offset covers the whole row. - _, _ = file.Write(append(line, '\n')) + _, err = file.Write(append(line, '\n')) + return err == nil } // ReadArtifacts reads the rows at path, NEWEST FIRST, and tolerates diff --git a/internal/session/jobs.go b/internal/session/jobs.go index e55cb96347..4ccaec1f85 100644 --- a/internal/session/jobs.go +++ b/internal/session/jobs.go @@ -221,6 +221,16 @@ type job struct { // Such a job does not report its own death: the caller already knows, and // a note saying so would be the agent telling itself what it just did. killRequested bool + // committed is a RENDER's point of no return: the provider's bytes have + // arrived and the job has decided to save them. It and killRequested are + // both written under mu, and exactly one of them can win — see + // [job.commitRender] and [job.requestKillRender]. + committed bool + // permit is the process-wide render place this job holds, if it is a render + // ([renderGate]). It is released by [job.releaseRender] when the PROVIDER + // GOROUTINE exits — never at a stop, and never because the row changed state. + permit *renderGate + permitOnce sync.Once // personStopped says the requested death this job is about to have is A // PERSON'S STOP ([Agent.cancelJob]) rather than the model's `jobs kill` or a // shutdown. @@ -360,6 +370,39 @@ func (j *job) requestKill() bool { return true } +// commitRender is a render's gate between "the provider answered" and "a file +// is written", and it is ATOMIC WITH THE STOP: it refuses when a stop was +// already accepted and otherwise makes any later stop lose. So a stop either +// lands before the file exists (nothing is ever written) or finds the render +// already committed (and is told so), and there is no third outcome in which a +// file appears after a stop was acknowledged. +func (j *job) commitRender() bool { + j.mu.Lock() + defer j.mu.Unlock() + if j.killRequested { + return false + } + j.committed = true + return true +} + +// requestKillRender is [job.requestKill] for a render: it claims the stop unless +// the render has already committed to saving its file, and says which. A render +// that has committed is NOT marked killed, because the file will exist and its +// note must still reach the model. +func (j *job) requestKillRender() (claimed, committed bool) { + j.mu.Lock() + defer j.mu.Unlock() + if j.state != jobRunning { + return false, false + } + if j.committed { + return false, true + } + j.killRequested = true + return true, false +} + // payOwed settles this job's debt, reporting whether there was one to settle. // // It is idempotent on purpose. The places that pay a debt — the two roads out of @@ -473,6 +516,13 @@ func (j *job) signal(sig syscall.Signal) { // for a turn: a job started in one turn is still running, and still listable, // three turns later, and Close is what ends it. type jobRegistry struct { + // renderMu makes "count the running renders, then add one" one step, so two + // renders starting together cannot both pass the cap. + renderMu sync.Mutex + // gate is the process-wide render admission this registry draws on. It is + // [processRenders] outside tests; a test hands two registries one private + // gate to prove the total is shared without disturbing other tests. + gate *renderGate workspace string // place is the session folder, and it is what decides where the logs land // (landing.go). The zero Place keeps them under the workspace, which is the @@ -842,6 +892,68 @@ func (r *jobRegistry) runningProcesses() int { return running } +// jobRenderCap is how many provider generations (video, music) one agent may +// have running at once. It is a separate count from the process cap because a +// render is a goroutine holding a response buffer, not a process. +const jobRenderCap = 3 + +// renderGate is a counting admission that never blocks: a place is taken or the +// caller is told no. It bounds how many provider render calls (each able to hold +// up to the media client's response ceiling, 512 MiB for video, plus its +// decoding and transient copies) exist in the PROCESS at once. IT BOUNDS THE +// NUMBER OF RENDERS ONLY and is not a memory guarantee: 3 x 512 MiB plus +// decoding is the worst case it permits, and a byte budget is later work. +type renderGate struct{ places chan struct{} } + +func newRenderGate(places int) *renderGate { + return &renderGate{places: make(chan struct{}, places)} +} + +func (g *renderGate) tryAcquire() bool { + select { + case g.places <- struct{}{}: + return true + default: + return false + } +} + +func (g *renderGate) release() { <-g.places } + +// processRenders is the one gate every agent in this process draws on. +var processRenders = newRenderGate(jobRenderCap) + +var errProcessRenderCap = fmt.Errorf("%d renders are already running in this process, counting stopped ones whose provider has not answered yet; try again when one returns.", jobRenderCap) + +// releaseRender gives the job's process-wide place back, once. The render +// goroutines defer it so that every road out of them — success, failure, a +// stop, a panic — returns the place. +func (j *job) releaseRender() { + j.permitOnce.Do(func() { + if j.permit != nil { + j.permit.release() + } + }) +} + +var errRenderCap = fmt.Errorf("%d renders are already running for this agent; wait for one to finish or stop one with jobs kill.", jobRenderCap) + +// runningRenders counts the renders still running. +func (r *jobRegistry) runningRenders() int { + running := 0 + for _, one := range r.all() { + if one.kind != jobKindRender { + continue + } + select { + case <-one.done: + default: + running++ + } + } + return running +} + func (r *jobRegistry) start(command string) (*job, error) { if r.runningProcesses() >= jobProcessCap { return nil, errJobProcessCap @@ -942,6 +1054,38 @@ func (r *jobRegistry) startTask(id uint64, title string, cancel context.CancelFu // a watch's loop is tools_watch.go's: this registry owns the id, the log, the // row and the kill. func (r *jobRegistry) startRender(label, prompt string) (*job, context.Context, error) { + // THE PROCESS-WIDE PLACE COMES FIRST AND IS NON-BLOCKING. It is held until + // the provider goroutine actually returns, so a stopped render whose provider + // ignores the cancel keeps its place and cannot be replaced by an endless + // stream of new ones. Every refusal and rollback below gives it back. + gate := r.gate + if gate == nil { + gate = processRenders + } + if !gate.tryAcquire() { + return nil, nil, errProcessRenderCap + } + started, ctx, err := r.startRenderHolding(label, prompt) + if err != nil { + gate.release() + return nil, nil, err + } + started.permit = gate + return started, ctx, nil +} + +// startRenderHolding is the per-registry part of [jobRegistry.startRender]; the +// caller owns the process-wide place and gives it back if this fails. +func (r *jobRegistry) startRenderHolding(label, prompt string) (*job, context.Context, error) { + // A RENDER HOLDS ITS WHOLE ANSWER IN MEMORY (up to the provider client's + // ceiling, 512 MB for video) and a goroutine until it settles, so the number + // running at once is bounded. The next is refused up front, before any money + // is spent, and a settled render frees its place. + r.renderMu.Lock() + defer r.renderMu.Unlock() + if r.runningRenders() >= jobRenderCap { + return nil, nil, errRenderCap + } started, err := r.newJob(prompt, jobKindRender) if err != nil { return nil, nil, err @@ -1223,7 +1367,19 @@ func (r *jobRegistry) kill(ctx context.Context, id int) (string, bool) { if target == nil { return fmt.Sprintf("No job %d.", id), true } - if !target.requestKill() { + // A render that already has its bytes and has decided to save them is not + // stopped: the file will exist, and its note is still coming. Saying + // "nothing was saved" there would be false. + if target.kind == jobKindRender { + claimed, committed := target.requestKillRender() + if committed { + return fmt.Sprintf("%s (job %d) had already finished rendering and is being saved; it was not stopped, and its note will name the file", target.label, id), false + } + if !claimed { + info := target.info() + return fmt.Sprintf("Job %d already %s.", id, statusText(info)), true + } + } else if !target.requestKill() { info := target.info() return fmt.Sprintf("Job %d already %s.", id, statusText(info)), true } @@ -1249,12 +1405,14 @@ func (r *jobRegistry) kill(ctx context.Context, id int) (string, bool) { if target.kind == jobKindTask { return fmt.Sprintf("%s (job %d) stopped; its branch is kept", target.label, id), false } - // A render is STOPPED and nothing was saved, which is the whole of what the - // model needs to know: no file landed, and no note about this job is - // coming. The label is the noun — "video", "music" — so the sentence names - // what was lost without this switch growing a case per modality. + // A render is STOPPED HERE and nothing was saved: no file landed (the + // commit gate guarantees it) and no note about this job is coming. The + // label is the noun — "video", "music". What this does NOT say is that the + // provider's job ended: nothing here tells the provider to cancel, so + // whether it is still running or charged is unknown, and the sentence says + // so rather than imply a refund. if target.kind == jobKindRender { - return fmt.Sprintf("%s (job %d) stopped; no %s was saved", target.label, id, target.label), false + return fmt.Sprintf("%s (job %d) stopped; no %s was saved. Only waiting stopped here — the provider was not told to cancel, so whether it is charged is unknown", target.label, id, target.label), false } return fmt.Sprintf("job %d killed", id), false } @@ -1327,8 +1485,13 @@ func statusText(info jobInfo) string { return "finished" } // Nor has a render: it either landed a file or failed, and both of - // those already reached the model as a note. + // those already reached the model as a note. The two are still told + // apart here, because a list that says "finished" for a render that + // produced nothing is a list that lies. if info.kind == jobKindRender { + if info.code != 0 { + return "failed" + } return "finished" } return fmt.Sprintf("exited(%d)", info.code) diff --git a/internal/session/mediacache.go b/internal/session/mediacache.go new file mode 100644 index 0000000000..fd6a6d95c6 --- /dev/null +++ b/internal/session/mediacache.go @@ -0,0 +1,209 @@ +package session + +// mediacache.go keeps the media records a listing or a lookup has just read, so +// the lookup of a source the person is already looking at does not read the +// whole index again. +// +// WHY. The index is global, append-only and unbounded in age; the only way to +// find a record by id without a side index is to scan it, and a screen of two +// hundred cards would ask for two hundred scans. A listing has the records in +// hand a moment before the cards ask for them, so it leaves them here. +// +// THE BOUNDS ARE HARD, with no exception for the newest or the most active: +// at most mediaCacheRecords records AND at most mediaCacheBytes of their index +// lines, least recently used first out. A cached record is a COPY — the +// references slice and the cost are cloned in and out, so no caller can change +// what the next one reads, and the cache holds no chain back to the history it +// came from. +// +// A HIT IS VALIDATED, never trusted. The entry remembers where its line sat in +// the index and a hash of it; a hit opens the index, checks it is the same file +// (device and inode) and long enough, and reads those bytes back to compare. +// A replaced, shortened or rewritten index therefore turns a hit into a miss. +// That costs one small read, not a scan. +// +// THE MISS IS NOT HIDDEN. Finding an uncached record still reads the index, at +// cost proportional to its size; what is bounded is how many of those run at +// once (mediaScanSlots), and a request that finds the slots full is told busy +// at once rather than queued behind a hundred others. Callers asking for the +// same record share one scan. + +import ( + "container/list" + "crypto/sha256" + "sync" + "sync/atomic" + "time" +) + +const ( + mediaCacheRecords = 512 + mediaCacheBytes = 4 << 20 + // mediaScanSlots is how many full-index lookups may run at once. + mediaScanSlots = 2 +) + +type mediaCacheKey struct { + index, session, id string + dev, ino uint64 +} + +type mediaCacheEntry struct { + key mediaCacheKey + row MediaRow + sum [sha256.Size]byte + cost int +} + +var mediaCache = struct { + mu sync.Mutex + items map[mediaCacheKey]*list.Element + order *list.List // front = most recently used + bytes int +}{items: map[mediaCacheKey]*list.Element{}, order: list.New()} + +// mediaIndexScans counts the full-index lookups that actually ran. A test, or +// a person reading the benchmark, can see that a screen of cards cost none. +var mediaIndexScans atomic.Uint64 + +// mediaScansActive and mediaScansPeak are how many lookups run now and the most +// that ever ran together, so the slot bound is something a test can read. +var mediaScansActive, mediaScansPeak atomic.Int64 + +// MediaIndexScans is how many full-index lookups this process has run. +func MediaIndexScans() uint64 { return mediaIndexScans.Load() } + +func cloneMediaRow(row MediaRow) MediaRow { + row.Fact.References = append([]string(nil), row.Fact.References...) + if row.Fact.CostUSD != nil { + cost := *row.Fact.CostUSD + row.Fact.CostUSD = &cost + } + // The fact is carried in Fact; the artifact's pointer to the same data is + // not kept, so a clone shares nothing with the row it was made from. + row.Artifact.Media = nil + return row +} + +// cacheMediaRow remembers a record read from the index at row.Start. +func cacheMediaRow(index string, id mediaCacheKey, row MediaRow, line []byte) { + cost := len(line) + 256 + if cost > mediaCacheBytes || row.Len != len(line) { + return + } + id.index, id.session = index, row.Artifact.Session + id.id = row.Fact.ID + entry := &mediaCacheEntry{key: id, row: cloneMediaRow(row), sum: sha256.Sum256(line), cost: cost} + mediaCache.mu.Lock() + defer mediaCache.mu.Unlock() + if old, ok := mediaCache.items[id]; ok { + mediaCache.bytes -= old.Value.(*mediaCacheEntry).cost + mediaCache.order.Remove(old) + } + mediaCache.items[id] = mediaCache.order.PushFront(entry) + mediaCache.bytes += cost + for mediaCache.order.Len() > mediaCacheRecords || mediaCache.bytes > mediaCacheBytes { + oldest := mediaCache.order.Back() + dropped := oldest.Value.(*mediaCacheEntry) + mediaCache.order.Remove(oldest) + delete(mediaCache.items, dropped.key) + mediaCache.bytes -= dropped.cost + } +} + +// dropMediaCacheEntry forgets an entry that failed validation. +func dropMediaCacheEntry(key mediaCacheKey) { + mediaCache.mu.Lock() + defer mediaCache.mu.Unlock() + if element, ok := mediaCache.items[key]; ok { + mediaCache.bytes -= element.Value.(*mediaCacheEntry).cost + mediaCache.order.Remove(element) + delete(mediaCache.items, key) + } +} + +// cachedMediaRow answers a validated copy of the record, or false. +func cachedMediaRow(index, sessionID, id string) (MediaRow, bool) { + file, now, err := openIndex(index) + if err != nil { + return MediaRow{}, false + } + defer file.Close() + key := mediaCacheKey{index: index, session: sessionID, id: id, dev: now.Dev, ino: now.Ino} + mediaCache.mu.Lock() + element, ok := mediaCache.items[key] + if !ok { + mediaCache.mu.Unlock() + return MediaRow{}, false + } + mediaCache.order.MoveToFront(element) + entry := *element.Value.(*mediaCacheEntry) + mediaCache.mu.Unlock() + + row := entry.row + if row.Start < 0 || row.Start+int64(row.Len) > now.End { + dropMediaCacheEntry(key) + return MediaRow{}, false + } + line := make([]byte, row.Len) + if _, err := file.ReadAt(line, row.Start); err != nil || sha256.Sum256(line) != entry.sum { + dropMediaCacheEntry(key) + return MediaRow{}, false + } + return cloneMediaRow(row), true +} + +// A flight is one running lookup that other askers of the same record join. +type mediaFlight struct { + done chan struct{} + row MediaRow + err error +} + +var mediaFlights = struct { + mu sync.Mutex + running map[mediaCacheKey]*mediaFlight +}{running: map[mediaCacheKey]*mediaFlight{}} + +// joinMediaFlight returns the flight for key, and whether the caller must run +// it. A caller that must run it and finds every scan slot taken is refused as +// busy straight away: the slots bound the work, they do not make a queue. +func joinMediaFlight(key mediaCacheKey) (flight *mediaFlight, run bool, err error) { + mediaFlights.mu.Lock() + defer mediaFlights.mu.Unlock() + if flight, ok := mediaFlights.running[key]; ok { + return flight, false, nil + } + if len(mediaFlights.running) >= mediaScanSlots { + return nil, false, ErrMediaBusy + } + flight = &mediaFlight{done: make(chan struct{})} + mediaFlights.running[key] = flight + return flight, true, nil +} + +func finishMediaFlight(key mediaCacheKey, flight *mediaFlight, row MediaRow, err error) { + flight.row, flight.err = row, err + mediaFlights.mu.Lock() + delete(mediaFlights.running, key) + mediaFlights.mu.Unlock() + close(flight.done) +} + +func waitMediaFlight(flight *mediaFlight, deadline time.Time) (MediaRow, error) { + if deadline.IsZero() { + <-flight.done + } else { + timer := time.NewTimer(time.Until(deadline)) + defer timer.Stop() + select { + case <-flight.done: + case <-timer.C: + return MediaRow{}, ErrMediaBusy + } + } + if flight.err != nil { + return MediaRow{}, flight.err + } + return cloneMediaRow(flight.row), nil +} diff --git a/internal/session/mediacache_test.go b/internal/session/mediacache_test.go new file mode 100644 index 0000000000..877d1aed7e --- /dev/null +++ b/internal/session/mediacache_test.go @@ -0,0 +1,269 @@ +package session + +import ( + "container/list" + "encoding/json" + "fmt" + "os" + "path/filepath" + "sync" + "testing" + "time" +) + +func resetMediaCache() { + mediaCache.mu.Lock() + mediaCache.items = map[mediaCacheKey]*list.Element{} + mediaCache.order.Init() + mediaCache.bytes = 0 + mediaCache.mu.Unlock() +} + +// listAll pages a session's whole listing and answers its ids newest first. +func listAll(t *testing.T, index, sessionID string) []string { + t.Helper() + var ids []string + cursor := "" + for { + scan, err := ListMedia(index, sessionID, cursor, MediaPageMax, time.Time{}) + if err != nil { + t.Fatal(err) + } + for _, row := range scan.Rows { + ids = append(ids, row.Fact.ID) + } + if scan.Next == "" { + return ids + } + cursor = scan.Next + } +} + +// After a listing of two hundred sources, getting each of the two hundred reads +// the index zero times: the listing left the records, and each hit is checked +// against the file with one small read. +func TestGetAfterListDoesNotRescanTheIndex(t *testing.T) { + resetMediaCache() + dir := t.TempDir() + index := filepath.Join(dir, "artifacts.jsonl") + sparseIndex(t, index, dir, 0, 0, 200) + ids := listAll(t, index, "s1") + if len(ids) != 200 { + t.Fatalf("listed %d", len(ids)) + } + before := MediaIndexScans() + for _, id := range ids { + row, err := FindMedia(index, "s1", id, time.Time{}) + if err != nil || row.Fact.ID != id { + t.Fatalf("get %s = %v", id, err) + } + } + if scans := MediaIndexScans() - before; scans != 0 { + t.Fatalf("200 gets after a list ran %d index scans, want 0", scans) + } + // Another conversation's id is not served from the cache. + if _, err := FindMedia(index, "s2", ids[0], time.Now().Add(5*time.Second)); err != ErrMediaNotFound { + t.Fatalf("foreign get answered %v", err) + } +} + +func TestColdGetScansOnceThenIsWarm(t *testing.T) { + resetMediaCache() + dir := t.TempDir() + index := filepath.Join(dir, "artifacts.jsonl") + ids := sparseIndex(t, index, dir, 1, 50, 5) + before := MediaIndexScans() + if _, err := FindMedia(index, "s1", ids[0], time.Time{}); err != nil { + t.Fatal(err) + } + if MediaIndexScans()-before != 1 { + t.Fatal("a cold get is an index scan, and says so") + } + // The scan left every newer record of the session behind it. + for _, id := range ids { + if _, err := FindMedia(index, "s1", id, time.Time{}); err != nil { + t.Fatal(err) + } + } + if MediaIndexScans()-before != 1 { + t.Fatalf("warm gets rescanned: %d scans", MediaIndexScans()-before) + } +} + +func TestMediaCacheIsBoundedAndCopiesRecords(t *testing.T) { + resetMediaCache() + dir := t.TempDir() + index := filepath.Join(dir, "artifacts.jsonl") + path := mediaFile(t, dir, "p.png") + for i := 0; i < mediaCacheRecords+300; i++ { + RecordMedia(index, Artifact{Path: path, Session: "s1", Title: "t", Kind: "image", Created: time.Now()}, + GeneratedMedia(path, []byte{byte(i)}, "m").WithReferences([]string{"a", "b"})) + } + listAll(t, index, "s1") + mediaCache.mu.Lock() + records, size := mediaCache.order.Len(), mediaCache.bytes + mediaCache.mu.Unlock() + if records > mediaCacheRecords || size > mediaCacheBytes { + t.Fatalf("cache holds %d records, %d bytes", records, size) + } + // What a caller does to its copy is not seen by the next one. + id := listAll(t, index, "s1")[0] + first, _ := FindMedia(index, "s1", id, time.Time{}) + first.Fact.References[0] = "tampered" + first.Fact.Model = "tampered" + again, _ := FindMedia(index, "s1", id, time.Time{}) + if again.Fact.References[0] != "a" || again.Fact.Model == "tampered" && again.Fact.References[0] != "a" { + t.Fatalf("a cached record was changed through a copy: %+v", again.Fact.References) + } +} + +func TestMediaCacheHitIsValidatedAgainstTheIndex(t *testing.T) { + resetMediaCache() + dir := t.TempDir() + index := filepath.Join(dir, "artifacts.jsonl") + ids := sparseIndex(t, index, dir, 0, 0, 3) + listAll(t, index, "s1") + // Shortened in place (same inode, fewer bytes): the entry must not survive. + if err := os.Truncate(index, 10); err != nil { + t.Fatal(err) + } + if _, err := FindMedia(index, "s1", ids[2], time.Now().Add(5*time.Second)); err != ErrMediaNotFound { + t.Fatalf("a record cut out of the index was still served: %v", err) + } + // Replaced by another file holding other rows. + resetMediaCache() + index2 := filepath.Join(dir, "second.jsonl") + other := sparseIndex(t, index2, dir, 0, 0, 2) + listAll(t, index2, "s1") + if err := os.Remove(index2); err != nil { + t.Fatal(err) + } + sparseIndex(t, index2, dir, 0, 0, 2) + if _, err := FindMedia(index2, "s1", other[0], time.Now().Add(5*time.Second)); err != ErrMediaNotFound { + t.Fatalf("a record of a replaced index was still served: %v", err) + } +} + +// Two hundred cold gets at once never run more than mediaScanSlots scans; the +// rest are told busy; whoever is answered is answered right. +func TestColdGetsAreBoundedAndShareAScan(t *testing.T) { + resetMediaCache() + dir := t.TempDir() + index := filepath.Join(dir, "artifacts.jsonl") + ids := sparseIndex(t, index, dir, 200, 3000, 0) + mediaScansPeak.Store(0) + before := MediaIndexScans() + var wg sync.WaitGroup + var mu sync.Mutex + answered, busy := 0, 0 + for i := 0; i < 200; i++ { + wg.Add(1) + go func(id string) { + defer wg.Done() + row, err := FindMedia(index, "s1", id, time.Now().Add(20*time.Second)) + mu.Lock() + defer mu.Unlock() + switch { + case err == ErrMediaBusy: + busy++ + case err != nil || row.Fact.ID != id: + t.Errorf("get %s = %v", id, err) + default: + answered++ + } + }(ids[i]) + } + wg.Wait() + if peak := mediaScansPeak.Load(); peak > mediaScanSlots { + t.Fatalf("%d scans ran together, bound is %d", peak, mediaScanSlots) + } + if answered == 0 { + t.Fatal("nobody was answered") + } + t.Logf("answered %d, busy %d, scans %d", answered, busy, MediaIndexScans()-before) + // Same record asked by many at once: one scan serves them. + resetMediaCache() + before = MediaIndexScans() + for i := 0; i < 50; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if _, err := FindMedia(index, "s1", ids[0], time.Now().Add(20*time.Second)); err != nil && err != ErrMediaBusy { + t.Error(err) + } + }() + } + wg.Wait() + if scans := MediaIndexScans() - before; scans > 3 { + t.Fatalf("50 askers of one record ran %d scans", scans) + } +} + +// bigIndex writes n rows, one in `every` of them the session's. +func bigIndex(tb testing.TB, dir string, n, every int) (string, []string) { + tb.Helper() + index := filepath.Join(dir, "artifacts.jsonl") + path := filepath.Join(dir, "p.png") + _ = os.WriteFile(path, []byte("x"), 0o644) + var ids []string + f, err := os.Create(index) + if err != nil { + tb.Fatal(err) + } + defer f.Close() + for i := 0; i < n; i++ { + session := fmt.Sprintf("other%04d", i%5000) + if i%every == 0 { + session = "s1" + } + fact := MediaFact{ID: NewMediaID(), Origin: MediaGenerated, Canonical: path, MIME: "image/png", Bytes: 1, ModNS: 1, Model: "m"} + row, _ := mediaRowJSON(Artifact{Path: path, Session: session, Title: "t", Kind: "image", Created: time.Unix(int64(i), 0), Media: &fact}) + _, _ = f.Write(append(row, '\n')) + if session == "s1" { + ids = append(ids, fact.ID) + } + } + return index, ids +} + +// BenchmarkFindMedia100kIndex: a cold lookup is an honest O(index) scan of a +// 100,000-row global index; after a listing the same lookups cost one small +// validating read each. +func BenchmarkFindMedia100kIndex(b *testing.B) { + dir := b.TempDir() + index, ids := bigIndex(b, dir, 100000, 500) // 200 rows for the session + oldest := ids[0] + b.Run("cold-oldest", func(b *testing.B) { + for i := 0; i < b.N; i++ { + resetMediaCache() + if _, err := FindMedia(index, "s1", oldest, time.Time{}); err != nil { + b.Fatal(err) + } + } + }) + b.Run("warm-after-list", func(b *testing.B) { + resetMediaCache() + for cursor := ""; ; { + scan, err := ListMedia(index, "s1", cursor, MediaPageMax, time.Time{}) + if err != nil { + b.Fatal(err) + } + if scan.Next == "" { + break + } + cursor = scan.Next + } + before := MediaIndexScans() + b.ResetTimer() + for i := 0; i < b.N; i++ { + if _, err := FindMedia(index, "s1", ids[i%len(ids)], time.Time{}); err != nil { + b.Fatal(err) + } + } + if MediaIndexScans() != before { + b.Fatal("a warm get scanned the index") + } + }) +} + +func mediaRowJSON(row Artifact) ([]byte, error) { return json.Marshal(row) } diff --git a/internal/session/mediaevidence.go b/internal/session/mediaevidence.go new file mode 100644 index 0000000000..c3f6c0039e --- /dev/null +++ b/internal/session/mediaevidence.go @@ -0,0 +1,47 @@ +package session + +// mediaevidence.go records what the agent LOOKED AT as sources of origin +// evidence: the file, and the words the looking model answered with, verbatim. +// +// It is a fact captured where the look happens (view_image, and read of an +// image, a sound or a video), from the values the look itself held — never +// recovered from the tool's prose afterwards. A look answered from the session's +// own memo is not recorded again: nothing was seen anew. +// +// A person's own attachments are not repeated as evidence by THIS file's rule; +// the surface applies it by comparing the canonical path of an evidence source +// with the attachment sources of the same conversation, both structured. + +import ( + "context" + "crypto/sha256" + "encoding/hex" + "path/filepath" + "time" +) + +// recordEvidence leaves one evidence row for a file the agent has just +// perceived. data is the exact bytes that were sent to the looking model, so +// the hash and the size are those bytes' and not a second reading of the disk. +func (a *Agent) recordEvidence(ctx context.Context, absolute, mime, kind string, data []byte, verb, saw, seenBy string, transcribed bool) { + fact := MediaFact{Origin: MediaEvidence, MIME: mime, Verb: verb, SeenBy: seenBy, Working: a.Model(), Transcribed: transcribed} + if len(saw) <= mediaSaidMax { + fact.Saw = saw + } else { + fact.SawOmitted = true + } + head := data + if len(head) > mediaProbeBytes { + head = head[:mediaProbeBytes] + } + if kind == "image" { + if w, h, ok := imageSize(head); ok { + fact.Width, fact.Height = w, h + } + } + fact.stampFile(absolute) + sum := sha256.Sum256(data) + fact.SHA256, fact.Bytes = hex.EncodeToString(sum[:]), int64(len(data)) + fact = fact.WithMade(madeBy(ctx, nil)) + a.recordMedia(Artifact{Path: absolute, Title: filepath.Base(absolute), Kind: kind, Created: time.Now()}, fact) +} diff --git a/internal/session/mediaevidence_test.go b/internal/session/mediaevidence_test.go new file mode 100644 index 0000000000..548fe561b9 --- /dev/null +++ b/internal/session/mediaevidence_test.go @@ -0,0 +1,85 @@ +package session + +import ( + "context" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" +) + +// A look is recorded as evidence from the values the look held: the file, the +// bytes' hash, the looking model's words verbatim, who looked and who was +// working — and a repeat answered from memory is not a second look. +func TestViewImageAndReadLeaveEvidenceSources(t *testing.T) { + index := filepath.Join(t.TempDir(), "artifacts.jsonl") + completer := &scriptedCompleter{steps: []step{ + func(context.Context, []ai.Message) (*ai.Response, error) { + return textResponse("a bar chart with the legend cut off"), nil + }, + }} + agent, workspace := newTestAgent(t, completer, func(config *Config) { + config.ArtifactsIndex = index + config.MediaModel = senseResolver(map[string]string{"vision": "vendor/slot-eyes"}) + }) + writeImage(t, workspace, "chart.png", "PHOTOBYTES") + ctx := withCallID(withRunIdentity(context.Background()), "call_view") + out, bad, err := viewTool(t, agent).Execute(ctx, []byte(`{"path":"chart.png"}`)) + if err != nil || bad { + t.Fatalf("view_image: %v %s", err, out) + } + rows := ReadArtifacts(index) + if len(rows) != 1 || rows[0].Media == nil { + t.Fatalf("rows = %+v", rows) + } + fact := rows[0].Media + if fact.Origin != MediaEvidence || fact.Verb != EvidenceLookedAt || fact.Saw != "a bar chart with the legend cut off" || + fact.SeenBy != "vendor/slot-eyes" || fact.Working != agent.Model() || fact.Tool != "call_view" || fact.Run == "" || + fact.Bytes != int64(len("PHOTOBYTES")) || len(fact.SHA256) != 64 || rows[0].Kind != "image" { + t.Fatalf("evidence = %+v", fact) + } + if fact.Charge != "" || fact.Model != "" { + t.Fatalf("a look claims a charge or a made-by model: %+v", fact) + } + // Listed, it is an evidence source, and the file is the workspace's own. + row, ok := mediaRowOf(rows[0]) + if !ok || row.Fact.Origin != MediaEvidence || !strings.HasPrefix(row.Fact.ID, "src_") { + t.Fatalf("row = %+v %v", row, ok) + } + if live := ResolveMedia(row, []string{workspace}); live.State != MediaAvailable { + t.Fatalf("live = %+v", live) + } + // An unlookable path records nothing. + if _, bad, _ := viewTool(t, agent).Execute(ctx, []byte(`{"path":"missing.png"}`)); !bad { + t.Fatal("a missing file was looked at") + } + if len(ReadArtifacts(index)) != 1 { + t.Fatal("a failed look left evidence") + } +} + +func TestReadTranscriptIsEvidenceOnceAndMemoIsNotALook(t *testing.T) { + index := filepath.Join(t.TempDir(), "artifacts.jsonl") + media := &scriptedEar{text: "The stand-up is moved to ten past nine tomorrow morning, in the small room."} + agent, workspace := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { + config.ArtifactsIndex = index + config.Media = media + config.MediaModel = senseResolver(map[string]string{"transcribe": "ears/model", "listen": "hear/model"}) + }) + name := dropFile(t, workspace, "memo.mp3", mp3Bytes(64)) + for i := 0; i < 2; i++ { + if text, bad := readTool(t, agent, map[string]any{"path": name}); bad { + t.Fatal(text) + } + } + rows := ReadArtifacts(index) + if len(rows) != 1 { + t.Fatalf("%d evidence rows for one look and a memo hit", len(rows)) + } + fact := rows[0].Media + if rows[0].Kind != "audio" || fact.Verb != EvidenceListenedTo || !fact.Transcribed || fact.SeenBy != "ears/model" || + !strings.Contains(fact.Saw, "ten past nine") || fact.DurationMS != 0 { + t.Fatalf("evidence = %+v", fact) + } +} diff --git a/internal/session/mediafact.go b/internal/session/mediafact.go new file mode 100644 index 0000000000..73ec55c4bf --- /dev/null +++ b/internal/session/mediafact.go @@ -0,0 +1,991 @@ +package session + +// mediafact.go is the structured record of a media file the harness made or was +// handed: what the file IS, said once, in typed fields, so no surface has to +// read a tool's prose to learn how big a picture is or what a voice was told +// to say. +// +// It rides on the deliverables index (artifacts.go) as ONE optional field on the +// existing row. There is no second store: a row written before this file existed +// still parses, still lists, and gets an honest, thinner answer (a legacy row is +// a path, a kind and a time — nothing more is claimed for it). +// +// ── A SOURCE ID IS GRANTED BY THE ENGINE, NEVER NAMED BY A CALLER ── +// +// A surface that wants to open, play or copy a file asks the engine for the +// SOURCE it already knows and is handed back a path it recorded itself. The id +// is derived from immutable facts of the record (origin, session, canonical +// path) and never from a path somebody supplies in a request, so a caller that +// can name any string still cannot make the engine vouch for a file it did not +// record. The scope law (see [ResolveMedia]) is the engine's, not any one +// surface's. +// +// ── UNKNOWN STAYS UNKNOWN ── +// +// A width, height, duration or charge that nothing actually reported is zero +// in the record and ABSENT on the wire (design-law §EMPTINESS). Dimensions come +// from the file's own header (image.DecodeConfig reads a header, not the +// picture), a duration from the provider or a probe, a charge from the +// provider's reported usage: positive is charged, an explicit zero is not +// charged, and an absent figure is unknown — never a fabricated $0. + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + stdimage "image" + "io" + "io/fs" + "math" + "net/http" + "os" + "path/filepath" + "strconv" + "strings" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" +) + +// Where a media file came from. +const ( + // MediaAttachment is a file the person explicitly attached to a message. + MediaAttachment = "attachment" + // MediaGenerated is a file a tool of the harness made. + MediaGenerated = "generated" + // MediaEvidence is a file the agent looked at. Reserved: nothing records + // it yet, and nothing is allowed to pretend it does. + MediaEvidence = "evidence" +) + +// What the agent did with a file it looked at. +const ( + EvidenceLookedAt = "looked_at" + EvidenceListenedTo = "listened_to" + EvidenceWatched = "watched" +) + +// Whether the provider billed for a generation. +const ( + MediaCharged = "charged" + MediaNotCharged = "not_charged" + MediaChargeUnknown = "unknown" +) + +// What a source is now, against the record. +const ( + MediaAvailable = "available" + MediaChanged = "changed" + MediaMissing = "missing" + MediaOutside = "outside" + MediaUnreadable = "unreadable" +) + +const ( + mediaIDPrefix = "src_" + mediaLegacyPrefix = "srl_" +) + +// mediaChunk is one backward read of the index; mediaScanBudget is the most +// bytes one listing call reads before it hands back a resumable cursor. They +// are variables only so a test can make a small index look large. +var ( + mediaChunk int64 = 256 << 10 + mediaScanBudget int64 = 16 << 20 +) + +// The bounds of reading sources. Every one is a cap on work, not a promise +// about content. +const ( + // MediaPageDefault and MediaPageMax bound one page of a listing. + MediaPageDefault = 50 + MediaPageMax = 100 + // mediaProbeBytes is how much of a file a header probe reads. + mediaProbeBytes = 64 << 10 + // mediaSaidMax is the longest spoken text kept verbatim; longer is kept + // as nothing rather than cut, because a cut quotation is a wrong one. + mediaSaidMax = 32 << 10 + // mediaRowMax is the longest index line read; anything longer is skipped. + mediaRowMax = 256 << 10 + // mediaReferencesMax bounds the reference paths one row keeps. + mediaReferencesMax = 32 +) + +// MediaFact is what the index remembers about one media file, beyond the +// columns every artifact row has. Every field is optional on disk. +type MediaFact struct { + // ID is the source id, persisted for new records; legacy rows have the + // same id computed from the same function ([MediaSourceID]). + ID string `json:"id,omitempty"` + Origin string `json:"origin,omitempty"` + // Canonical is the symlink-resolved absolute path at the moment of + // recording. The id is derived from it. + Canonical string `json:"canonical,omitempty"` + MIME string `json:"mime,omitempty"` + // Bytes, ModNS, CtimeNS, Dev and Ino are the file as it was recorded. + Bytes int64 `json:"bytes,omitempty"` + ModNS int64 `json:"mod_ns,omitempty"` + CtimeNS int64 `json:"ctime_ns,omitempty"` + Dev uint64 `json:"dev,omitempty"` + Ino uint64 `json:"ino,omitempty"` + // SHA256 is the content hash, set only where the engine held the bytes + // when it wrote them. + SHA256 string `json:"sha256,omitempty"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + DurationMS int64 `json:"duration_ms,omitempty"` + // Model is the model the call ASKED for (the resolved request), not a + // claim about which machine served it: no provider response reports that + // today, so there is no served model to record. + Model string `json:"model,omitempty"` + // Requested is how many pictures the request asked for in one call (the + // request's own N), where the request has one. Files with the same Tool id + // were asked for together; nothing is inferred beyond that. + Requested int `json:"requested_count,omitempty"` + // Run, Message and Tool are immutable ids of the turn that made it, where + // the tool site knew them. + Run string `json:"run,omitempty"` + Message string `json:"message,omitempty"` + Tool string `json:"tool,omitempty"` + // References are the exact reference_paths the generator was given. + References []string `json:"references,omitempty"` + // Said is the exact text given to speak. + Said string `json:"said,omitempty"` + // The rest is set only on evidence — a file the agent LOOKED AT (origin + // evidence). Verb is what it did, Saw the looking model's words verbatim + // (omitted, with SawOmitted, when too long to keep whole), SeenBy the model + // that looked and Working the model the session was running when it did. + // Transcribed says the words are a transcript from a speech endpoint. + Verb string `json:"verb,omitempty"` + Saw string `json:"saw,omitempty"` + SawOmitted bool `json:"saw_omitted,omitempty"` + SeenBy string `json:"seen_by,omitempty"` + Working string `json:"working_model,omitempty"` + Transcribed bool `json:"transcribed,omitempty"` + // Charge and CostUSD are the provider's reported usage, see + // [ChargeFromUsage]. + Charge string `json:"charge,omitempty"` + CostUSD *float64 `json:"cost_usd,omitempty"` +} + +// NewMediaID mints the id of a new source: random, unique per record, written +// once with the row and never recomputed. TWO FILES WRITTEN AT THE SAME PATH ARE +// TWO SOURCES: an id derived from the path would make a regenerated draft the +// same source as the one it replaced, and break every version chain built on it. +func NewMediaID() string { + var raw [16]byte + if _, err := rand.Read(raw[:]); err != nil { + // crypto/rand does not fail on a working machine; a clock-derived id + // is still unique enough for an index row that would otherwise be lost. + sum := sha256.Sum256([]byte(time.Now().String())) + copy(raw[:], sum[:]) + } + return mediaIDPrefix + hex.EncodeToString(raw[:]) +} + +// LegacyMediaID is the id of a row recorded before ids were persisted: a +// function of the row's own immutable columns — session, origin, recorded +// canonical path and creation time — so it is the same on every read and never +// derived from a path a caller supplies. The prefix differs from [NewMediaID]'s +// so the two can never collide. +func LegacyMediaID(sessionID, origin, canonical string, created time.Time) string { + sum := sha256.Sum256([]byte("media-source/legacy/v1\x00" + sessionID + "\x00" + origin + "\x00" + canonical + + "\x00" + strconv.FormatInt(created.UnixNano(), 10))) + return mediaLegacyPrefix + hex.EncodeToString(sum[:12]) +} + +// validMediaID says whether id has the shape this engine issues. +func validMediaID(id string) bool { + switch { + case strings.HasPrefix(id, mediaIDPrefix): + return len(id) == len(mediaIDPrefix)+32 && isHex(id[len(mediaIDPrefix):]) + case strings.HasPrefix(id, mediaLegacyPrefix): + return len(id) == len(mediaLegacyPrefix)+24 && isHex(id[len(mediaLegacyPrefix):]) + } + return false +} + +func isHex(s string) bool { + for _, r := range s { + if !strings.ContainsRune("0123456789abcdef", r) { + return false + } + } + return true +} + +// canonicalFile is path made absolute with every link followed, or cleaned +// when it cannot be followed (a file already gone still has a spelling). +func canonicalFile(path string) string { + if abs, err := filepath.Abs(path); err == nil { + path = abs + } + if resolved, err := filepath.EvalSymlinks(path); err == nil { + return resolved + } + return filepath.Clean(path) +} + +// mediaKindOf is the kind a sniffed content type belongs to, or "" for a file +// that is not media. +func mediaKindOf(mime string) string { + switch { + case strings.HasPrefix(mime, "image/"): + return "image" + case strings.HasPrefix(mime, "audio/"): + return "audio" + case strings.HasPrefix(mime, "video/"): + return "video" + } + return "" +} + +// sniffMedia is the media content type of the head of a file, or "". +func sniffMedia(head []byte) string { + if len(head) > 512 { + head = head[:512] + } + mime := http.DetectContentType(head) + if i := strings.IndexByte(mime, ';'); i >= 0 { + mime = mime[:i] + } + if mediaKindOf(mime) == "" { + return "" + } + return mime +} + +// imageSize is the header size of an image, and false when the standard +// library does not know the format (webp reports no size rather than a guess). +func imageSize(head []byte) (width, height int, ok bool) { + config, _, err := stdimage.DecodeConfig(bytes.NewReader(head)) + if err != nil || config.Width <= 0 || config.Height <= 0 { + return 0, 0, false + } + return config.Width, config.Height, true +} + +// ChargeFromUsage reads what the provider said it charged: a positive cost is +// charged, an explicit zero is not charged, and no figure at all is unknown. +func ChargeFromUsage(usage *ai.Usage) (string, *float64) { + if usage == nil || usage.Cost == nil { + return MediaChargeUnknown, nil + } + cost := *usage.Cost + if math.IsNaN(cost) || math.IsInf(cost, 0) { + // Not representable as JSON and not a figure: unknown, never charged. + return MediaChargeUnknown, nil + } + switch { + case cost > 0: + return MediaCharged, &cost + case cost == 0: + return MediaNotCharged, nil + } + return MediaChargeUnknown, nil +} + +// GeneratedMedia is the fact for bytes a tool has just written at path. The +// bytes are in hand, so the hash, the size and the picture's header are read +// from them and nothing is read back from disk but the file's identity. +func GeneratedMedia(path string, data []byte, model string) MediaFact { + fact := MediaFact{Origin: MediaGenerated, Model: strings.TrimSpace(model), Bytes: int64(len(data))} + sum := sha256.Sum256(data) + fact.SHA256 = hex.EncodeToString(sum[:]) + head := data + if len(head) > mediaProbeBytes { + head = head[:mediaProbeBytes] + } + fact.MIME = sniffMedia(head) + if mediaKindOf(fact.MIME) == "image" { + if w, h, ok := imageSize(head); ok { + fact.Width, fact.Height = w, h + } + } + fact.stampFile(path) + return fact +} + +// DerivedMedia is the fact for a file a local tool made (ffmpeg): the bytes are +// not in hand, so only the head of the file is read and no hash is taken. The +// origin is generated, and nothing is claimed about a charge. +func DerivedMedia(path string) MediaFact { + fact := MediaFact{Origin: MediaGenerated} + canonical := canonicalFile(path) + if file, err := os.Open(canonical); err == nil { + head, _ := io.ReadAll(io.LimitReader(file, mediaProbeBytes)) + _ = file.Close() + fact.MIME = sniffMedia(head) + if mediaKindOf(fact.MIME) == "image" { + if w, h, ok := imageSize(head); ok { + fact.Width, fact.Height = w, h + } + } + } + fact.stampFile(canonical) + return fact +} + +// stampFile records the file's identity as it is now. +func (f *MediaFact) stampFile(path string) { + f.Canonical = canonicalFile(path) + info, err := os.Stat(f.Canonical) + if err != nil || !info.Mode().IsRegular() { + return + } + f.Bytes = info.Size() + f.ModNS = info.ModTime().UnixNano() + f.CtimeNS, f.Dev, f.Ino = fileIdentity(info) +} + +// WithUsage sets the charge from the provider's reported usage. +func (f MediaFact) WithUsage(usage *ai.Usage) MediaFact { + f.Charge, f.CostUSD = ChargeFromUsage(usage) + return f +} + +// mediaMade is what the call that is making a file knew when it started: the +// inputs it was handed and the immutable ids of the turn and the tool call. It +// is captured where the tool runs, on the call's own context, because a job +// that lands the file minutes later runs on a background context that has +// neither. +type mediaMade struct { + Inputs []string + Run, Tool string +} + +// madeBy reads the call's identity off its context: the run is the turn's +// immutable execution id, the tool is the provider's id for this very call. +// Either may be absent (a test, a door with no turn) and is then left unset. +func madeBy(ctx context.Context, inputs []string) mediaMade { + return mediaMade{Inputs: inputs, Run: runIdentity(ctx), Tool: callIDFrom(ctx)} +} + +// WithMade stamps the call's inputs and identity onto a fact. +func (f MediaFact) WithMade(made mediaMade) MediaFact { + f.Run, f.Tool = made.Run, made.Tool + return f.WithReferences(made.Inputs) +} + +// WithRequested keeps how many files the request asked for. +func (f MediaFact) WithRequested(n int) MediaFact { + if n > 0 { + f.Requested = n + } + return f +} + +// WithReferences keeps the exact reference paths, bounded. +func (f MediaFact) WithReferences(paths []string) MediaFact { + for _, path := range paths { + if strings.TrimSpace(path) == "" || len(f.References) >= mediaReferencesMax { + continue + } + f.References = append(f.References, path) + } + return f +} + +// WithSaid keeps the exact text a voice was given, or nothing when it is too +// long to keep whole. +func (f MediaFact) WithSaid(text string) MediaFact { + if text != "" && len(text) <= mediaSaidMax { + f.Said = text + } + return f +} + +// WithDuration keeps a duration the provider or a probe genuinely knew. +func (f MediaFact) WithDuration(length time.Duration) MediaFact { + if length > 0 { + f.DurationMS = length.Milliseconds() + } + return f +} + +// WithSize keeps a frame size a probe genuinely read. +func (f MediaFact) WithSize(width, height int) MediaFact { + if width > 0 && height > 0 { + f.Width, f.Height = width, height + } + return f +} + +// RecordMedia writes a media row: the existing artifact columns plus the fact, +// under a freshly minted source id, which it returns ("" when nothing was +// written). The id is minted here so no tool site can forget it. +func RecordMedia(index string, artifact Artifact, fact MediaFact) string { + if fact.Canonical == "" { + fact.Canonical = canonicalFile(artifact.Path) + } + if fact.Origin == "" { + fact.Origin = MediaGenerated + } + fact.ID = NewMediaID() + artifact.Media = &fact + if !RecordArtifact(index, artifact) { + return "" + } + return fact.ID +} + +// RecordAttachment records a file the person attached to a message in the +// session, when it is a regular file that looks like an image, a sound or a +// video. A file that is not media, or cannot be read, records nothing (an +// attachment is not a deliverable, and a row nobody could open is noise). Only +// the head of the file is read. +func RecordAttachment(index, sessionID, path string) { + if sessionID == "" { + return + } + canonical := canonicalFile(path) + info, err := os.Stat(canonical) + if err != nil || !info.Mode().IsRegular() { + return + } + file, err := os.Open(canonical) + if err != nil { + return + } + head, _ := io.ReadAll(io.LimitReader(file, mediaProbeBytes)) + _ = file.Close() + mime := sniffMedia(head) + if mime == "" { + return + } + fact := MediaFact{Origin: MediaAttachment, MIME: mime} + if mediaKindOf(mime) == "image" { + if w, h, ok := imageSize(head); ok { + fact.Width, fact.Height = w, h + } + } + fact.stampFile(canonical) + RecordMedia(index, Artifact{ + Path: canonical, + Session: sessionID, + Title: filepath.Base(canonical), + Kind: mediaKindOf(mime), + Created: time.Now(), + }, fact) +} + +// recordMedia is the agent's door: the session id and the index are the +// agent's own. +func (a *Agent) recordMedia(artifact Artifact, fact MediaFact) { + artifact.Session = a.journalID() + RecordMedia(a.config.ArtifactsIndex, artifact, fact) +} + +// fileVersion is the identity form of a version: size, modification time, +// change time, device and inode, as stat reports them. +func fileVersion(size, modNS, ctimeNS int64, dev, ino uint64) string { + return "file:" + strconv.FormatInt(size, 10) + ":" + strconv.FormatInt(modNS, 10) + ":" + + strconv.FormatInt(ctimeNS, 10) + ":" + strconv.FormatUint(dev, 10) + ":" + strconv.FormatUint(ino, 10) +} + +// MediaRow is one source as the index remembers it, the legacy shape +// included, with its id settled. +type MediaRow struct { + Artifact Artifact + Fact MediaFact + Kind string + // Recorded says a structured fact was recorded with the row; false is a + // legacy row whose only facts are the artifact's own columns. + Recorded bool + // Start and Len are where the row's line sits in the index: the offset of + // its first byte and its length without the newline. They are what a page + // cursor resumes from. + Start int64 + Len int +} + +// Version is the recorded version: the content hash where the engine held the +// bytes, else the file's recorded identity. A legacy row has none. +func (r MediaRow) Version() string { + switch { + case r.Fact.SHA256 != "": + return "sha256:" + r.Fact.SHA256 + case r.Recorded && r.Fact.ModNS != 0: + return fileVersion(r.Fact.Bytes, r.Fact.ModNS, r.Fact.CtimeNS, r.Fact.Dev, r.Fact.Ino) + } + return "" +} + +// mediaRowOf settles one index row into a source, or reports that it is not +// one (an export, a document, a row with no media kind). +func mediaRowOf(row Artifact) (MediaRow, bool) { + out := MediaRow{Artifact: row} + if row.Media != nil { + out.Fact = *row.Media + out.Recorded = true + } + switch row.Kind { + case "image", "audio", "video": + out.Kind = row.Kind + default: + out.Kind = mediaKindOf(out.Fact.MIME) + } + if out.Kind == "" { + return MediaRow{}, false + } + if out.Fact.Origin == "" { + // Only generators wrote these kinds before facts existed. + out.Fact.Origin = MediaGenerated + } + if out.Fact.Canonical == "" { + out.Fact.Canonical = canonicalFile(row.Path) + } + if !validMediaID(out.Fact.ID) { + out.Fact.ID = LegacyMediaID(row.Session, out.Fact.Origin, out.Fact.Canonical, row.Created) + } + return out, true +} + +// Why a listing or a lookup did not answer in full. +var ( + // ErrMediaCursor is a cursor this engine did not issue, or one whose index + // has since been replaced or shortened: list again from the start. + ErrMediaCursor = errors.New("media cursor is not valid for the index as it is now") + // ErrMediaNotFound is a lookup that read the WHOLE index and found nothing. + ErrMediaNotFound = errors.New("no such media source in this conversation") + // ErrMediaBusy is a lookup that ran out of time before it had read the + // whole index. It says nothing about whether the source exists. + ErrMediaBusy = errors.New("the media index could not be searched in time") + // ErrMediaTooLarge is a single record that cannot be sent within the reply + // bound. It is reported, not skipped: a list that silently lost a row would + // be wrong in a way nobody could see. + ErrMediaTooLarge = errors.New("a media record is too large to send") +) + +// mediaCursorBody is the position a listing resumes from: the index file's +// identity and the byte offset below which older rows remain. The file is +// append-only, so an offset stays valid as rows are appended; a replaced or +// shortened file does not, and the cursor then says so rather than guessing. +type mediaCursorBody struct { + Dev uint64 `json:"d"` + Ino uint64 `json:"i"` + End int64 `json:"e"` +} + +func encodeMediaCursor(body mediaCursorBody) string { + raw, _ := json.Marshal(body) + return "m2." + base64.RawURLEncoding.EncodeToString(raw) +} + +func decodeMediaCursor(cursor string) (mediaCursorBody, error) { + var body mediaCursorBody + text, ok := strings.CutPrefix(cursor, "m2.") + if !ok { + return body, ErrMediaCursor + } + raw, err := base64.RawURLEncoding.DecodeString(text) + if err != nil || json.Unmarshal(raw, &body) != nil || body.End < 0 { + return mediaCursorBody{}, ErrMediaCursor + } + return body, nil +} + +// scanBackward visits the lines of file below end, newest (last) first, reading +// at most budget bytes and stopping at deadline. It keeps one chunk and one +// partial line in memory, whatever the file's size. visit answers false to stop +// after the line it was given. +// +// The boundary it returns is the start offset of the earliest line it finished +// with: everything at or after it has been visited, and a caller resumes by +// scanning below it. Zero means the start of the file was reached. A line +// longer than mediaRowMax is not visited at all. +func scanBackward(file *os.File, end, budget int64, deadline time.Time, visit func(start int64, line []byte) bool) (boundary int64, err error) { + pos, boundary := end, end + var carry []byte + overlong := false + for pos > 0 { + if budget <= 0 || (!deadline.IsZero() && time.Now().After(deadline)) { + if overlong { + // What was read of the oversize line is garbage; resume below it. + boundary = pos + } + return boundary, nil + } + size := mediaChunk + if size > pos { + size = pos + } + buf := make([]byte, size+int64(len(carry))) + if _, err := file.ReadAt(buf[:size], pos-size); err != nil && !errors.Is(err, io.EOF) { + return boundary, err + } + copy(buf[size:], carry) + budget -= size + pos -= size + // Walk the segments from the last, so lines arrive newest first. + hi := len(buf) + for { + nl := bytes.LastIndexByte(buf[:hi], '\n') + if nl < 0 { + break + } + if !overlong && hi-(nl+1) > 0 { + start := pos + int64(nl) + 1 + if !visit(start, buf[nl+1:hi]) { + return start, nil + } + } + overlong = false + boundary = pos + int64(nl) + 1 + hi = nl + } + // buf[:hi] holds no newline: the tail of a line that starts earlier. + switch { + case pos == 0: + if !overlong && hi > 0 && !visit(0, buf[:hi]) { + return 0, nil + } + return 0, nil + case overlong || hi > mediaRowMax: + overlong, carry = true, nil + default: + carry = append(carry[:0], buf[:hi]...) + } + } + return 0, nil +} + +// MediaScan is one page of a listing. +type MediaScan struct { + Rows []MediaRow + // Next resumes the listing below this page; empty means the whole index + // has been read. + Next string + // Incomplete says the page is short NOT because the history ended but + // because the call spent its read budget: Next is set and the caller keeps + // asking. An empty incomplete page is normal for a sparse session in a big + // index. + Incomplete bool +} + +// mediaNeedle is the byte pattern every row of a session contains, so a line of +// another conversation is dropped without being parsed. +func mediaNeedle(sessionID string) []byte { return []byte(`"session":"` + sessionID + `"`) } + +// openIndex opens the index and reports its identity and size. +func openIndex(index string) (*os.File, mediaCursorBody, error) { + file, err := os.Open(index) + if err != nil { + return nil, mediaCursorBody{}, err + } + info, err := file.Stat() + if err != nil { + _ = file.Close() + return nil, mediaCursorBody{}, err + } + _, dev, ino := fileIdentity(info) + return file, mediaCursorBody{Dev: dev, Ino: ino, End: info.Size()}, nil +} + +// ListMedia is one page of the session's media sources, newest first, read +// backward through the WHOLE index from the cursor. Each call reads at most +// mediaScanBudget bytes and keeps only the page; a session with few rows in a +// large index takes several calls, each handing back where to resume. The first +// call fixes the end of the index it reads to, so a page boundary is not moved +// by rows appended while the person scrolls. +func ListMedia(index, sessionID, cursor string, limit int, deadline time.Time) (MediaScan, error) { + return ListMediaFit(index, sessionID, cursor, limit, deadline, 0, nil) +} + +// ListMediaFit is ListMedia with a reply budget: fit answers what a row will +// cost to send, and the page ends before the row that would take it past +// budget, with a cursor that resumes at that row. A single row that alone does +// not fit is [ErrMediaTooLarge], never a silent drop. +func ListMediaFit(index, sessionID, cursor string, limit int, deadline time.Time, budget int, fit func(MediaRow) (int, error)) (MediaScan, error) { + spent, tooLarge := 0, false + if strings.TrimSpace(index) == "" || sessionID == "" { + return MediaScan{}, nil + } + if limit <= 0 { + limit = MediaPageDefault + } + limit = min(limit, MediaPageMax) + file, now, err := openIndex(index) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return MediaScan{}, nil + } + return MediaScan{}, err + } + defer file.Close() + end := now.End + if cursor != "" { + held, err := decodeMediaCursor(cursor) + if err != nil || held.Dev != now.Dev || held.Ino != now.Ino || held.End > now.End { + return MediaScan{}, ErrMediaCursor + } + end = held.End + } + needle := mediaNeedle(sessionID) + var scan MediaScan + rejected := false + boundary, err := scanBackward(file, end, mediaScanBudget, deadline, func(start int64, line []byte) bool { + if !bytes.Contains(line, needle) { + return true + } + var row Artifact + if json.Unmarshal(line, &row) != nil || row.Session != sessionID || + strings.TrimSpace(row.Path) == "" || strings.TrimSpace(row.Title) == "" { + return true + } + media, ok := mediaRowOf(row) + if !ok { + return true + } + media.Start, media.Len = start, len(line) + if fit != nil { + cost, err := fit(media) + if err != nil || cost > budget-spent { + if len(scan.Rows) == 0 { + tooLarge = true + } + rejected = true + return false + } + spent += cost + } + scan.Rows = append(scan.Rows, media) + cacheMediaRow(index, mediaCacheKey{dev: now.Dev, ino: now.Ino}, media, line) + return len(scan.Rows) < limit + }) + if err != nil { + return MediaScan{}, err + } + if tooLarge { + return MediaScan{}, ErrMediaTooLarge + } + if rejected { + // The page ends BEFORE the row that did not fit; the next page starts + // with it. Nothing is dropped and the cursor says exactly where. + boundary = scan.Rows[len(scan.Rows)-1].Start + } + if boundary > 0 { + scan.Next = encodeMediaCursor(mediaCursorBody{Dev: now.Dev, Ino: now.Ino, End: boundary}) + scan.Incomplete = len(scan.Rows) < limit && !rejected + } + return scan, nil +} + +// FindMedia is the session's source with id. A record a listing or an earlier +// lookup already read is answered from the bounded cache after one validating +// read of its line (mediacache.go); otherwise the whole index is read backward +// in bounded memory, at most mediaScanSlots such reads at a time, and askers of +// the same record share one. It never answers not-found for a source that was +// only out of reach: a deadline, or every slot taken, answers [ErrMediaBusy]. +func FindMedia(index, sessionID, id string, deadline time.Time) (MediaRow, error) { + if !validMediaID(id) { + return MediaRow{}, ErrMediaNotFound + } + // A warm cache still validates a record on disk. Do not start that read + // after the caller's budget expired. + if !deadline.IsZero() && !time.Now().Before(deadline) { + return MediaRow{}, ErrMediaBusy + } + if row, ok := cachedMediaRow(index, sessionID, id); ok { + return row, nil + } + file, now, err := openIndex(index) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return MediaRow{}, ErrMediaNotFound + } + return MediaRow{}, err + } + key := mediaCacheKey{index: index, session: sessionID, id: id, dev: now.Dev, ino: now.Ino} + flight, run, err := joinMediaFlight(key) + if err != nil { + _ = file.Close() + return MediaRow{}, err + } + if !run { + _ = file.Close() + return waitMediaFlight(flight, deadline) + } + row, err := scanForMedia(file, now, index, sessionID, id, deadline) + _ = file.Close() + finishMediaFlight(key, flight, row, err) + if err != nil { + return MediaRow{}, err + } + return cloneMediaRow(row), nil +} + +// scanForMedia is the full-index lookup. Every record of the session it passes +// on the way is left in the cache: the records newer than the one asked for are +// the likeliest next questions. +func scanForMedia(file *os.File, now mediaCursorBody, index, sessionID, id string, deadline time.Time) (MediaRow, error) { + mediaIndexScans.Add(1) + active := mediaScansActive.Add(1) + defer mediaScansActive.Add(-1) + for peak := mediaScansPeak.Load(); active > peak && !mediaScansPeak.CompareAndSwap(peak, active); peak = mediaScansPeak.Load() { + } + needle := mediaNeedle(sessionID) + var found MediaRow + hit := false + boundary, err := scanBackward(file, now.End, math.MaxInt64, deadline, func(start int64, line []byte) bool { + if !bytes.Contains(line, needle) { + return true + } + var row Artifact + if json.Unmarshal(line, &row) != nil || row.Session != sessionID || + strings.TrimSpace(row.Path) == "" || strings.TrimSpace(row.Title) == "" { + return true + } + media, ok := mediaRowOf(row) + if !ok { + return true + } + media.Start, media.Len = start, len(line) + cacheMediaRow(index, mediaCacheKey{dev: now.Dev, ino: now.Ino}, media, line) + if media.Fact.ID == id { + found, hit = media, true + return false + } + return true + }) + switch { + case err != nil: + return MediaRow{}, err + case hit: + return found, nil + case boundary > 0: + return MediaRow{}, ErrMediaBusy + } + return MediaRow{}, ErrMediaNotFound +} + +// MediaLive is a source checked against the disk now. +type MediaLive struct { + State string + // Version is the file's identity right now ("file:" form), "" when it + // cannot be read. + Version string + // Bytes is the file's size now, valid when HasBytes. + Bytes int64 + HasBytes bool + // Path is the resolved path to open, set only when the state allows + // opening (available or changed). + Path string +} + +// ResolveMedia checks one source against the disk and the engine's scope law. +// +// THE LAW. The live canonical path must equal the recorded one: a link that +// has been retargeted since is not the file that was recorded. A generated or +// evidence file must also lie inside one of roots (the session's workspace and +// artifacts folder, resolved by the caller); the exact file a person attached +// is allowed wherever they put it. Nothing here follows a path the caller +// named — there is none — and nothing larger than a stat is read. +func ResolveMedia(row MediaRow, roots []string) MediaLive { + recorded := row.Fact.Canonical + resolved, err := filepath.EvalSymlinks(recorded) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return MediaLive{State: MediaMissing} + } + return MediaLive{State: MediaUnreadable} + } + if resolved != recorded { + return MediaLive{State: MediaOutside} + } + if row.Fact.Origin != MediaAttachment && !withinAny(roots, resolved) { + return MediaLive{State: MediaOutside} + } + info, err := os.Stat(resolved) + if err != nil { + if errors.Is(err, fs.ErrNotExist) { + return MediaLive{State: MediaMissing} + } + return MediaLive{State: MediaUnreadable} + } + if !info.Mode().IsRegular() { + return MediaLive{State: MediaUnreadable} + } + ctime, dev, ino := fileIdentity(info) + live := MediaLive{ + State: MediaAvailable, + Version: fileVersion(info.Size(), info.ModTime().UnixNano(), ctime, dev, ino), + Bytes: info.Size(), + HasBytes: true, + Path: resolved, + } + if recordedIdentityDiffers(row.Fact, info.Size(), info.ModTime().UnixNano(), ctime, dev, ino) { + live.State = MediaChanged + } + return live +} + +// recordedIdentityDiffers says the file is no longer the one that was recorded. +// Size and modification time alone are not an identity: a same-size rewrite can +// put the old mtime back (os.Chtimes, an archive extract, a tool that preserves +// times). The change time cannot be set by a caller, so it is compared whenever +// both the record and the platform have one, and the inode and device catch an +// atomic replace that also reset the times. A record that has no identity (an +// older row, or a platform that reports none) is never called changed on a +// guess. +func recordedIdentityDiffers(fact MediaFact, size, modNS, ctimeNS int64, dev, ino uint64) bool { + if fact.ModNS == 0 { + return false + } + switch { + case size != fact.Bytes, modNS != fact.ModNS: + return true + case fact.CtimeNS != 0 && ctimeNS != 0 && ctimeNS != fact.CtimeNS: + return true + case fact.Ino != 0 && ino != 0 && (ino != fact.Ino || dev != fact.Dev): + return true + } + return false +} + +// withinAny says whether path is inside one of the roots (canonicalized here). +func withinAny(roots []string, path string) bool { + for _, root := range roots { + if strings.TrimSpace(root) == "" { + continue + } + root = canonicalFile(root) + rel, err := filepath.Rel(root, path) + if err == nil && rel != ".." && !strings.HasPrefix(rel, ".."+string(filepath.Separator)) && !filepath.IsAbs(rel) { + return true + } + } + return false +} + +// ProbeImage reads the header of the image at an already-resolved path, at +// most mediaProbeBytes of it. It exists for a legacy row recorded before sizes +// were; an unknown format answers false. +func ProbeImage(path string) (width, height int, ok bool) { + file, err := os.Open(path) + if err != nil { + return 0, 0, false + } + defer file.Close() + head, _ := io.ReadAll(io.LimitReader(file, mediaProbeBytes)) + if mediaKindOf(sniffMedia(head)) != "image" { + return 0, 0, false + } + return imageSize(head) +} + +// ProbeMIME is the media content type of the head of the file at path, or "". +func ProbeMIME(path string) string { + file, err := os.Open(path) + if err != nil { + return "" + } + defer file.Close() + head, _ := io.ReadAll(io.LimitReader(file, 512)) + return sniffMedia(head) +} diff --git a/internal/session/mediafact_test.go b/internal/session/mediafact_test.go new file mode 100644 index 0000000000..484bbeefa6 --- /dev/null +++ b/internal/session/mediafact_test.go @@ -0,0 +1,578 @@ +package session + +import ( + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "math" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" +) + +func usageCost(v float64) *ai.Usage { return &ai.Usage{Cost: &v} } + +func TestChargeFromUsageNeverFabricatesAFigure(t *testing.T) { + for _, tc := range []struct { + name string + usage *ai.Usage + charge string + cost bool + }{ + {"absent usage", nil, MediaChargeUnknown, false}, + {"usage without a cost", &ai.Usage{PromptTokens: 5}, MediaChargeUnknown, false}, + {"explicit zero", usageCost(0), MediaNotCharged, false}, + {"positive", usageCost(0.16), MediaCharged, true}, + {"positive infinity", usageCost(math.Inf(1)), MediaChargeUnknown, false}, + {"negative infinity", usageCost(math.Inf(-1)), MediaChargeUnknown, false}, + {"not a number", usageCost(math.NaN()), MediaChargeUnknown, false}, + {"negative", usageCost(-1), MediaChargeUnknown, false}, + } { + charge, cost := ChargeFromUsage(tc.usage) + if charge != tc.charge || (cost != nil) != tc.cost { + t.Errorf("%s: got %s %v, want %s (cost %v)", tc.name, charge, cost, tc.charge, tc.cost) + } + if cost != nil { + if _, err := json.Marshal(cost); err != nil { + t.Errorf("%s: a reported cost does not marshal: %v", tc.name, err) + } + } + } +} + +func TestGeneratedMediaReadsOnlyActualFacts(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "a.png") + data := pngOfSize(t, 40, 30) + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatal(err) + } + fact := GeneratedMedia(path, data, " paint/model ").WithUsage(usageCost(0.5)).WithReferences([]string{"r1.png", " ", "r2.png"}).WithSaid("hello") + if fact.Width != 40 || fact.Height != 30 || fact.MIME != "image/png" || fact.Bytes != int64(len(data)) { + t.Fatalf("fact = %+v", fact) + } + if len(fact.SHA256) != 64 || fact.Model != "paint/model" || fact.Charge != MediaCharged || fact.Said != "hello" { + t.Fatalf("fact = %+v", fact) + } + if strings.Join(fact.References, ",") != "r1.png,r2.png" { + t.Fatalf("references = %v", fact.References) + } + if fact.ModNS == 0 || fact.Canonical == "" { + t.Fatalf("identity not stamped: %+v", fact) + } + // Audio: no dimensions, no duration — nothing here knows them. + audio := GeneratedMedia(path, []byte("ID3\x04 not really"), "m") + if audio.Width != 0 || audio.DurationMS != 0 { + t.Fatalf("audio fact invents numbers: %+v", audio) + } + if long := (MediaFact{}).WithSaid(strings.Repeat("x", mediaSaidMax+1)); long.Said != "" { + t.Fatal("an over-long text was cut instead of dropped") + } +} + +// appendLegacyRow appends one raw row to an index. +func appendLegacyRow(t *testing.T, index string, row Artifact) { + t.Helper() + if !RecordArtifact(index, row) { + t.Fatalf("row %+v was not written", row) + } +} + +func mediaFile(t *testing.T, dir, name string) string { + t.Helper() + path := filepath.Join(dir, name) + if err := os.WriteFile(path, pngOfSize(t, 4, 3), 0o644); err != nil { + t.Fatal(err) + } + return path +} + +func TestSameCanonicalPathRecordedTwiceIsTwoSources(t *testing.T) { + dir := t.TempDir() + index := filepath.Join(dir, "artifacts.jsonl") + path := filepath.Join(dir, "draft.png") + var ids []string + for i := 1; i <= 2; i++ { + data := pngOfSize(t, 10*i, 10) + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatal(err) + } + id := RecordMedia(index, Artifact{Path: path, Session: "s1", Title: "draft", Kind: "image", Created: time.Now()}, + GeneratedMedia(path, data, "m")) + ids = append(ids, id) + } + if ids[0] == "" || ids[0] == ids[1] { + t.Fatalf("ids = %v: an overwrite shares its predecessor's id", ids) + } + scan, err := ListMedia(index, "s1", "", 10, time.Time{}) + if err != nil || len(scan.Rows) != 2 { + t.Fatalf("list = %+v %v", scan, err) + } + if scan.Rows[0].Fact.ID != ids[1] || scan.Rows[1].Fact.ID != ids[0] { + t.Fatalf("order/ids = %s %s", scan.Rows[0].Fact.ID, scan.Rows[1].Fact.ID) + } + if scan.Rows[0].Version() == scan.Rows[1].Version() || !strings.HasPrefix(scan.Rows[0].Version(), "sha256:") { + t.Fatalf("versions %q %q", scan.Rows[0].Version(), scan.Rows[1].Version()) + } + // Reopen: a second read of the same file answers the same ids. + again, _ := ListMedia(index, "s1", "", 10, time.Time{}) + if again.Rows[0].Fact.ID != ids[1] { + t.Fatal("ids did not persist") + } +} + +func TestLegacyRowsListWithStableDistinctIDs(t *testing.T) { + dir := t.TempDir() + index := filepath.Join(dir, "artifacts.jsonl") + path := mediaFile(t, dir, "old.png") + at := time.Unix(1700000000, 5) + appendLegacyRow(t, index, Artifact{Path: path, Session: "s1", Title: "old", Kind: "image", Created: at}) + appendLegacyRow(t, index, Artifact{Path: path, Session: "s1", Title: "old", Kind: "image", Created: at.Add(time.Second)}) + appendLegacyRow(t, index, Artifact{Path: path, Session: "s1", Title: "export", Kind: "export", Created: at}) + first, _ := ListMedia(index, "s1", "", 10, time.Time{}) + if len(first.Rows) != 2 { + t.Fatalf("rows = %d, want the export left out", len(first.Rows)) + } + a, b := first.Rows[0], first.Rows[1] + if !strings.HasPrefix(a.Fact.ID, "srl_") || a.Fact.ID == b.Fact.ID || a.Recorded || a.Version() != "" { + t.Fatalf("legacy rows = %+v %+v", a, b) + } + // Stable through appends. + appendLegacyRow(t, index, Artifact{Path: path, Session: "s1", Title: "new", Kind: "image", Created: at.Add(time.Hour)}) + second, _ := ListMedia(index, "s1", "", 10, time.Time{}) + if second.Rows[1].Fact.ID != a.Fact.ID || second.Rows[2].Fact.ID != b.Fact.ID { + t.Fatal("a legacy id moved when a row was appended") + } + got, err := FindMedia(index, "s1", b.Fact.ID, time.Time{}) + if err != nil || got.Fact.ID != b.Fact.ID { + t.Fatalf("find legacy = %v %v", got.Fact.ID, err) + } +} + +// sparseIndex writes `old` rows for s1, `filler` rows of other sessions, then +// `recent` rows for s1, returning the ids oldest-first. +func sparseIndex(t *testing.T, index, dir string, old, filler, recent int) []string { + t.Helper() + path := mediaFile(t, dir, "p.png") + var ids []string + add := func(session string, n int) { + id := RecordMedia(index, Artifact{Path: path, Session: session, Title: fmt.Sprintf("t%d", n), Kind: "image", Created: time.Now()}, + GeneratedMedia(path, []byte(fmt.Sprint(n)), "m")) + if session == "s1" { + ids = append(ids, id) + } + } + for i := 0; i < old; i++ { + add("s1", i) + } + for i := 0; i < filler; i++ { + add("other", i) + } + for i := 0; i < recent; i++ { + add("s1", 1000+i) + } + return ids +} + +func withSmallScan(t *testing.T, chunk, budget int64) { + t.Helper() + c, b := mediaChunk, mediaScanBudget + mediaChunk, mediaScanBudget = chunk, budget + t.Cleanup(func() { mediaChunk, mediaScanBudget = c, b }) +} + +func TestListWalksBackThroughTheWholeIndexInResumablePages(t *testing.T) { + dir := t.TempDir() + index := filepath.Join(dir, "artifacts.jsonl") + ids := sparseIndex(t, index, dir, 3, 400, 2) + withSmallScan(t, 512, 4096) // the filler is many times a call's budget + var got []string + cursor, calls, incomplete := "", 0, 0 + for { + scan, err := ListMedia(index, "s1", cursor, 100, time.Time{}) + if err != nil { + t.Fatal(err) + } + calls++ + if scan.Incomplete { + incomplete++ + if scan.Next == "" { + t.Fatal("incomplete without a cursor to resume") + } + } + for _, row := range scan.Rows { + got = append(got, row.Fact.ID) + } + if scan.Next == "" { + break + } + cursor = scan.Next + if calls > 10000 { + t.Fatal("listing does not terminate") + } + } + if incomplete == 0 { + t.Fatal("the budget never forced a resumable, incomplete page") + } + // Newest first, every row of the session, none from "other", none lost. + if len(got) != len(ids) { + t.Fatalf("got %d rows, want %d (oldest media must not be cut)", len(got), len(ids)) + } + for i, id := range got { + if id != ids[len(ids)-1-i] { + t.Fatalf("row %d = %s, want %s", i, id, ids[len(ids)-1-i]) + } + } +} + +func TestListPagesByLimitAndSurvivesAppends(t *testing.T) { + dir := t.TempDir() + index := filepath.Join(dir, "artifacts.jsonl") + ids := sparseIndex(t, index, dir, 0, 0, 7) + first, err := ListMedia(index, "s1", "", 3, time.Time{}) + if err != nil || len(first.Rows) != 3 || first.Next == "" || first.Incomplete { + t.Fatalf("first = %+v %v", first, err) + } + // A row appended between pages is not seen and shifts nothing. + sparseIndex(t, index, dir, 0, 0, 1) + second, err := ListMedia(index, "s1", first.Next, 3, time.Time{}) + if err != nil || len(second.Rows) != 3 || second.Rows[0].Fact.ID != ids[3] { + t.Fatalf("second = %+v %v", second, err) + } + third, _ := ListMedia(index, "s1", second.Next, 3, time.Time{}) + if len(third.Rows) != 1 || third.Rows[0].Fact.ID != ids[0] || third.Next != "" { + t.Fatalf("third = %+v", third) + } +} + +func TestListCursorRefusesAReplacedOrForeignIndex(t *testing.T) { + dir := t.TempDir() + index := filepath.Join(dir, "artifacts.jsonl") + sparseIndex(t, index, dir, 0, 0, 5) + page, _ := ListMedia(index, "s1", "", 2, time.Time{}) + for _, bad := range []string{"nonsense", "m2.!!", "m2." + base64.RawURLEncoding.EncodeToString([]byte(`{"e":-4}`))} { + if _, err := ListMedia(index, "s1", bad, 2, time.Time{}); !errors.Is(err, ErrMediaCursor) { + t.Fatalf("cursor %q answered %v", bad, err) + } + } + // Replace the file (new inode) with a shorter one. + if err := os.Remove(index); err != nil { + t.Fatal(err) + } + sparseIndex(t, index, dir, 0, 0, 1) + if _, err := ListMedia(index, "s1", page.Next, 2, time.Time{}); !errors.Is(err, ErrMediaCursor) { + t.Fatalf("a cursor into a replaced index answered %v", err) + } +} + +func TestScanSurvivesChunkBoundariesAndOverlongLines(t *testing.T) { + dir := t.TempDir() + index := filepath.Join(dir, "artifacts.jsonl") + ids := sparseIndex(t, index, dir, 2, 0, 0) + // An oversize junk line between rows, then more rows. + f, _ := os.OpenFile(index, os.O_APPEND|os.O_WRONLY, 0o600) + _, _ = f.WriteString(`{"session":"s1","junk":"` + strings.Repeat("z", mediaRowMax+10) + "\"}\n") + _ = f.Close() + ids = append(ids, sparseIndex(t, index, dir, 2, 0, 0)...) + for _, chunk := range []int64{7, 64, 1000, 1 << 20} { + withSmallScan(t, chunk, 1<<40) + scan, err := ListMedia(index, "s1", "", 100, time.Time{}) + if err != nil || len(scan.Rows) != len(ids) || scan.Next != "" { + t.Fatalf("chunk %d: rows=%d next=%q err=%v, want %d", chunk, len(scan.Rows), scan.Next, err, len(ids)) + } + for i, row := range scan.Rows { + if row.Fact.ID != ids[len(ids)-1-i] { + t.Fatalf("chunk %d: row %d wrong", chunk, i) + } + } + } +} + +func TestFindReachesTheOldestSourceAndNeverMistakesOutOfReachForAbsent(t *testing.T) { + dir := t.TempDir() + index := filepath.Join(dir, "artifacts.jsonl") + ids := sparseIndex(t, index, dir, 1, 300, 1) + withSmallScan(t, 256, 1024) // find is not subject to the listing's byte budget + row, err := FindMedia(index, "s1", ids[0], time.Time{}) + if err != nil || row.Fact.ID != ids[0] { + t.Fatalf("oldest = %v %v", row.Fact.ID, err) + } + // Another conversation's source is not this one's. + if _, err := FindMedia(index, "other", ids[0], time.Time{}); !errors.Is(err, ErrMediaNotFound) { + t.Fatalf("foreign source answered %v", err) + } + if _, err := FindMedia(index, "s1", NewMediaID(), time.Time{}); !errors.Is(err, ErrMediaNotFound) { + t.Fatalf("unknown source answered %v", err) + } + if _, err := FindMedia(index, "s1", "../../etc/passwd", time.Time{}); !errors.Is(err, ErrMediaNotFound) { + t.Fatalf("a path as an id answered %v", err) + } + // The first lookup warmed this source. An expired caller still refuses + // before a cache validation or a new scan starts. + before := MediaIndexScans() + if _, err := FindMedia(index, "s1", ids[0], time.Now().Add(-time.Second)); !errors.Is(err, ErrMediaBusy) { + t.Fatalf("expired deadline answered %v", err) + } + if after := MediaIndexScans(); after != before { + t.Fatalf("expired lookup started a scan: before %d, after %d", before, after) + } + if _, err := FindMedia(filepath.Join(dir, "none.jsonl"), "s1", ids[0], time.Time{}); !errors.Is(err, ErrMediaNotFound) { + t.Fatalf("no index answered %v", err) + } +} + +func TestResolveMediaStatesAndScope(t *testing.T) { + workspace := t.TempDir() + outside := t.TempDir() + index := filepath.Join(t.TempDir(), "artifacts.jsonl") + record := func(origin, path string) MediaRow { + fact := DerivedMedia(path) + fact.Origin = origin + RecordMedia(index, Artifact{Path: path, Session: "s", Title: filepath.Base(path), Kind: "image", Created: time.Now()}, fact) + scan, _ := ListMedia(index, "s", "", 1, time.Time{}) + return scan.Rows[0] + } + roots := []string{workspace} + + inside := mediaFile(t, workspace, "in.png") + row := record(MediaGenerated, inside) + if live := ResolveMedia(row, roots); live.State != MediaAvailable || live.Path == "" || !live.HasBytes { + t.Fatalf("inside = %+v", live) + } + // Changed: same path, new bytes. It may not be opened. + if err := os.WriteFile(inside, append(pngOfSize(t, 4, 3), 0), 0o644); err != nil { + t.Fatal(err) + } + if live := ResolveMedia(row, roots); live.State != MediaChanged { + t.Fatalf("rewritten = %+v", live) + } + // Missing keeps the record: the row still says what was recorded. + if err := os.Remove(inside); err != nil { + t.Fatal(err) + } + live := ResolveMedia(row, roots) + if live.State != MediaMissing || live.HasBytes || live.Path != "" || row.Fact.Bytes == 0 { + t.Fatalf("missing = %+v, recorded bytes %d", live, row.Fact.Bytes) + } + + // A generated file that is really a link out of the workspace is outside. + target := mediaFile(t, outside, "secret.png") + link := filepath.Join(workspace, "link.png") + if err := os.Symlink(target, link); err != nil { + t.Skip("symlinks unavailable") + } + escaped := record(MediaGenerated, link) + if got := ResolveMedia(escaped, roots); got.State != MediaOutside || got.Path != "" { + t.Fatalf("symlink escape = %+v", got) + } + // The exact file a person attached is allowed wherever it is. + if got := ResolveMedia(record(MediaAttachment, target), roots); got.State != MediaAvailable { + t.Fatalf("attachment = %+v", got) + } + // ...until a directory on its path becomes a link elsewhere. + sub := filepath.Join(outside, "d") + if err := os.Mkdir(sub, 0o755); err != nil { + t.Fatal(err) + } + attached := record(MediaAttachment, mediaFile(t, sub, "x.png")) + elsewhere := t.TempDir() + mediaFile(t, elsewhere, "x.png") + if err := os.RemoveAll(sub); err != nil { + t.Fatal(err) + } + if err := os.Symlink(elsewhere, sub); err != nil { + t.Fatal(err) + } + if got := ResolveMedia(attached, roots); got.State != MediaOutside { + t.Fatalf("retargeted = %+v", got) + } + // Not a regular file. + if got := ResolveMedia(record(MediaAttachment, outside), roots); got.State != MediaUnreadable { + t.Fatalf("directory = %+v", got) + } +} + +func TestRecordAttachmentOnlyNotesMediaAndReadsOnlyAHead(t *testing.T) { + dir := t.TempDir() + index := filepath.Join(dir, "artifacts.jsonl") + text := filepath.Join(dir, "notes.txt") + _ = os.WriteFile(text, []byte("hello"), 0o644) + RecordAttachment(index, "s1", text) + RecordAttachment(index, "s1", filepath.Join(dir, "gone.png")) + RecordAttachment(index, "s1", dir) + RecordAttachment(index, "", mediaFile(t, dir, "x.png")) + if scan, _ := ListMedia(index, "s1", "", 10, time.Time{}); len(scan.Rows) != 0 { + t.Fatalf("non-media recorded: %+v", scan.Rows) + } + RecordAttachment(index, "s1", mediaFile(t, dir, "pic.png")) + scan, _ := ListMedia(index, "s1", "", 10, time.Time{}) + if len(scan.Rows) != 1 { + t.Fatalf("rows = %d", len(scan.Rows)) + } + r := scan.Rows[0] + if r.Fact.Origin != MediaAttachment || r.Kind != "image" || r.Fact.Width != 4 || r.Fact.Height != 3 || r.Fact.SHA256 != "" { + t.Fatalf("attachment = %+v", r.Fact) + } + if !strings.HasPrefix(r.Version(), "file:") { + t.Fatalf("version %q: an attachment is never hashed", r.Version()) + } +} + +func TestRecordAndListRaceCleanly(t *testing.T) { + dir := t.TempDir() + index := filepath.Join(dir, "artifacts.jsonl") + path := mediaFile(t, dir, "p.png") + var wg sync.WaitGroup + for w := 0; w < 4; w++ { + wg.Add(2) + go func() { + defer wg.Done() + for i := 0; i < 25; i++ { + RecordMedia(index, Artifact{Path: path, Session: "s1", Title: "t", Kind: "image", Created: time.Now()}, DerivedMedia(path)) + } + }() + go func() { + defer wg.Done() + for i := 0; i < 25; i++ { + if _, err := ListMedia(index, "s1", "", 10, time.Time{}); err != nil { + t.Error(err) + } + } + }() + } + wg.Wait() + total, cursor := 0, "" + for { + scan, err := ListMedia(index, "s1", cursor, 100, time.Time{}) + if err != nil { + t.Fatal(err) + } + total += len(scan.Rows) + if scan.Next == "" { + break + } + cursor = scan.Next + } + if total != 100 { + t.Fatalf("listed %d rows, want 100", total) + } +} + +// A file whose size and modification time match the record is still not the +// recorded file when its change time or inode differ: times can be set back by +// a caller, the change time and the inode cannot. +func TestResolveMediaDetectsSameSizeReplacementWithRestoredTimes(t *testing.T) { + workspace := t.TempDir() + index := filepath.Join(t.TempDir(), "artifacts.jsonl") + record := func(name string) (MediaRow, string, os.FileInfo) { + path := filepath.Join(workspace, name) + if err := os.WriteFile(path, []byte("AAAAAAAAAAAAAAAA"), 0o644); err != nil { + t.Fatal(err) + } + fact := DerivedMedia(path) + fact.MIME = "image/png" + RecordMedia(index, Artifact{Path: path, Session: "s", Title: name, Kind: "image", Created: time.Now()}, fact) + scan, _ := ListMedia(index, "s", "", 1, time.Time{}) + info, _ := os.Stat(path) + return scan.Rows[0], path, info + } + roots := []string{workspace} + if _, _, ino := fileIdentity(mustStat(t, workspace)); ino == 0 { + t.Skip("this platform reports no inode or change time") + } + + // In place, same size, mtime put back: only the change time gives it away. + row, path, info := record("inplace.png") + if got := ResolveMedia(row, roots); got.State != MediaAvailable { + t.Fatalf("untouched = %+v", got) + } + time.Sleep(30 * time.Millisecond) // the kernel's timestamps are coarse + if err := os.WriteFile(path, []byte("BBBBBBBBBBBBBBBB"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(path, info.ModTime(), info.ModTime()); err != nil { + t.Fatal(err) + } + if got := ResolveMedia(row, roots); got.State != MediaChanged { + t.Fatalf("same-size rewrite with the mtime restored = %+v", got) + } + + // Atomic replace, same size, same mtime: a new inode. + row, path, info = record("atomic.png") + time.Sleep(30 * time.Millisecond) + temp := path + ".tmp" + if err := os.WriteFile(temp, []byte("CCCCCCCCCCCCCCCC"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(temp, info.ModTime(), info.ModTime()); err != nil { + t.Fatal(err) + } + if err := os.Rename(temp, path); err != nil { + t.Fatal(err) + } + if got := ResolveMedia(row, roots); got.State != MediaChanged { + t.Fatalf("atomic replace with the mtime restored = %+v", got) + } + + // A record with no identity (an older row) is never called changed on a guess. + legacy := MediaRow{Artifact: Artifact{Path: path}, Fact: MediaFact{Origin: MediaGenerated, Canonical: canonicalFile(path)}, Kind: "image"} + if got := ResolveMedia(legacy, roots); got.State != MediaAvailable { + t.Fatalf("legacy = %+v", got) + } +} + +func mustStat(t *testing.T, path string) os.FileInfo { + t.Helper() + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + return info +} + +// A page ends BEFORE the row that would pass its reply budget, with a cursor +// that resumes at that row; a row that alone does not fit is an error. +func TestListMediaFitEndsBeforeTheRowThatDoesNotFit(t *testing.T) { + dir := t.TempDir() + index := filepath.Join(dir, "artifacts.jsonl") + ids := sparseIndex(t, index, dir, 0, 0, 10) + cost := func(MediaRow) (int, error) { return 100, nil } + var got []string + cursor := "" + for pages := 0; ; pages++ { + scan, err := ListMediaFit(index, "s1", cursor, 100, time.Time{}, 250, cost) + if err != nil { + t.Fatal(err) + } + if len(scan.Rows) > 2 { + t.Fatalf("page of %d rows passes the budget", len(scan.Rows)) + } + for _, row := range scan.Rows { + got = append(got, row.Fact.ID) + } + if scan.Next == "" { + break + } + if pages > 20 { + t.Fatal("does not terminate") + } + cursor = scan.Next + } + if len(got) != len(ids) { + t.Fatalf("got %d of %d rows: a row was dropped or repeated", len(got), len(ids)) + } + for i, id := range got { + if id != ids[len(ids)-1-i] { + t.Fatalf("row %d out of order", i) + } + } + if _, err := ListMediaFit(index, "s1", "", 100, time.Time{}, 50, cost); !errors.Is(err, ErrMediaTooLarge) { + t.Fatalf("a row alone over budget answered %v", err) + } +} diff --git a/internal/session/mediafact_tools_test.go b/internal/session/mediafact_tools_test.go new file mode 100644 index 0000000000..4bd615d797 --- /dev/null +++ b/internal/session/mediafact_tools_test.go @@ -0,0 +1,108 @@ +package session + +import ( + "context" + "encoding/base64" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" +) + +// The tools write typed facts beside their unchanged prose, at the sites that +// already recorded a row. +func TestGenerateToolsRecordStructuredFacts(t *testing.T) { + zero, paid := 0.0, 0.25 + picture := pngOfSize(t, 40, 30) + media := &scriptedMedia{ + base64: base64.StdEncoding.EncodeToString(picture), mediaType: "image/png", usage: &ai.Usage{Cost: &paid}, + audio: []byte("ID3\x04voice"), speechCost: &ai.Usage{Cost: &zero}, + music: []byte("ID3\x04tune"), musicFormat: "mp3", + video: []byte("not an mp4 at all"), + } + index := filepath.Join(t.TempDir(), "artifacts.jsonl") + agent, workspace := newMediaAgent(t, media, func(config *Config) { config.ArtifactsIndex = index }) + ref := writeReference(t, workspace, "ref.png", pngOfSize(t, 2, 2)) + + // Each call runs on a context that carries what the loop gives it: the + // turn's run id and the call's own id. + run := func(name, args, call string) { + t.Helper() + ctx := withCallID(withRunIdentity(context.Background()), call) + out, bad, err := beltTool(t, agent, name).Execute(ctx, json.RawMessage(args)) + if err != nil || bad { + t.Fatalf("%s: %v %s", name, err, out) + } + } + run("generate_image", `{"prompt":"a harbour","reference_paths":["`+ref+`"]}`, "call_img") + run("speak", `{"text":"Good morning"}`, "call_say") + run("generate_music", `{"prompt":"a tune"}`, "call_mus") + run("generate_video", `{"prompt":"a ferry","reference_paths":["`+ref+`"]}`, "call_vid") + waitFor(t, "both jobs to land", func() bool { return len(ReadArtifacts(index)) == 4 }) + + byKind := map[string][]Artifact{} + for _, row := range ReadArtifacts(index) { + byKind[row.Kind] = append(byKind[row.Kind], row) + if row.Media == nil || !validMediaID(row.Media.ID) || row.Media.Origin != MediaGenerated { + t.Fatalf("row %+v has no minted fact", row) + } + if _, err := os.Stat(row.Media.Canonical); err != nil { + t.Fatalf("recorded canonical path is not the file: %v", err) + } + } + image := byKind["image"][0].Media + if image.Width != 40 || image.Height != 30 || image.MIME != "image/png" || image.Model != "paint/model" || + image.Charge != MediaCharged || image.CostUSD == nil || *image.CostUSD != paid || + len(image.References) != 1 || image.References[0] != ref || image.Bytes != int64(len(picture)) || image.SHA256 == "" { + t.Fatalf("image fact = %+v", image) + } + var voice, tune *MediaFact + for _, row := range byKind["audio"] { + if row.Media.Said != "" { + voice = row.Media + } else { + tune = row.Media + } + } + if voice == nil || voice.Said != "Good morning" || voice.Charge != MediaNotCharged || voice.CostUSD != nil || voice.Width != 0 { + t.Fatalf("speech fact = %+v", voice) + } + if tune == nil || tune.Charge != MediaChargeUnknown || tune.Model != "compose/model" { + t.Fatalf("music fact = %+v", tune) + } + film := byKind["video"][0].Media + if film.DurationMS != 0 || film.Width != 0 || film.Charge != MediaChargeUnknown || len(film.References) != 1 { + t.Fatalf("video fact invents what it did not read: %+v", film) + } + // Provenance is read off the call itself, never from prose: each file has + // the id of the tool call that made it and a run id, and no message id (the + // submission lane has not introduced one). + for kind, want := range map[string]string{"image": "call_img", "video": "call_vid"} { + fact := byKind[kind][0].Media + if fact.Tool != want || fact.Run == "" || fact.Message != "" { + t.Fatalf("%s provenance = run %q tool %q message %q", kind, fact.Run, fact.Tool, fact.Message) + } + } + if voice.Tool != "call_say" || tune.Tool != "call_mus" || voice.Run == "" { + t.Fatalf("audio provenance: %+v %+v", voice, tune) + } + if image.Requested != 1 || voice.Requested != 0 { + t.Fatalf("requested counts = %d %d: only the request's own N is recorded", image.Requested, voice.Requested) + } + // A call with no identity (a door with no turn) leaves it unset. + if _, bad := runTool(t, agent, "speak", `{"text":"anon"}`); bad { + t.Fatal("speak failed") + } + for _, row := range ReadArtifacts(index) { + if row.Media.Said == "anon" && (row.Media.Tool != "" || row.Media.Run != "") { + t.Fatalf("identity invented for a call that had none: %+v", row.Media) + } + } + // The prose the model reads is unchanged: no typed field leaked into it. + if out, _ := runTool(t, agent, "speak", `{"text":"again"}`); strings.Contains(out, "src_") || strings.Contains(out, "sha256") { + t.Fatalf("typed facts leaked into the prose: %q", out) + } +} diff --git a/internal/session/mediaident_darwin.go b/internal/session/mediaident_darwin.go new file mode 100644 index 0000000000..6d7c24473c --- /dev/null +++ b/internal/session/mediaident_darwin.go @@ -0,0 +1,17 @@ +//go:build darwin + +package session + +import ( + "os" + "syscall" +) + +// fileIdentity: see mediaident_linux.go. +func fileIdentity(info os.FileInfo) (ctimeNS int64, dev, ino uint64) { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return 0, 0, 0 + } + return stat.Ctimespec.Nano(), uint64(stat.Dev), uint64(stat.Ino) +} diff --git a/internal/session/mediaident_linux.go b/internal/session/mediaident_linux.go new file mode 100644 index 0000000000..b195aaf315 --- /dev/null +++ b/internal/session/mediaident_linux.go @@ -0,0 +1,20 @@ +//go:build linux + +package session + +import ( + "os" + "syscall" +) + +// fileIdentity is the parts of a file's identity that stat knows beyond its +// size and modification time: the change time, the device and the inode. A +// file replaced by another of the same size and mtime still has a new inode, +// so a version built from these notices what a size alone would not. +func fileIdentity(info os.FileInfo) (ctimeNS int64, dev, ino uint64) { + stat, ok := info.Sys().(*syscall.Stat_t) + if !ok { + return 0, 0, 0 + } + return stat.Ctim.Nano(), uint64(stat.Dev), uint64(stat.Ino) //nolint:unconvert // the widths differ by platform +} diff --git a/internal/session/mediaident_other.go b/internal/session/mediaident_other.go new file mode 100644 index 0000000000..9ed571ac8c --- /dev/null +++ b/internal/session/mediaident_other.go @@ -0,0 +1,10 @@ +//go:build !linux && !darwin + +package session + +import "os" + +// fileIdentity: see mediaident_linux.go. A platform whose stat is not read +// here knows only the size and the modification time, and says zero for the +// rest — which makes two versions differ only by those two, never by a guess. +func fileIdentity(os.FileInfo) (ctimeNS int64, dev, ino uint64) { return 0, 0, 0 } diff --git a/internal/session/render_cancel_test.go b/internal/session/render_cancel_test.go new file mode 100644 index 0000000000..1a7d2eb8e8 --- /dev/null +++ b/internal/session/render_cancel_test.go @@ -0,0 +1,435 @@ +package session + +import ( + "bytes" + "context" + "os" + "path/filepath" + "runtime" + "strings" + "sync" + "sync/atomic" + "syscall" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/provider" +) + +// gatedMedia is a video provider whose every call is held on a barrier the test +// owns. Nothing here sleeps: a call announces it has arrived, then waits to be +// released, and honours its context only when obeyCtx says a real client would. +type gatedMedia struct { + *scriptedMedia + arrived chan struct{} + release chan struct{} + obeyCtx bool + video []byte + cost *ai.Usage + mu sync.Mutex + calls int +} + +func newGatedMedia(video []byte, obeyCtx bool) *gatedMedia { + return &gatedMedia{scriptedMedia: &scriptedMedia{}, arrived: make(chan struct{}, 16), release: make(chan struct{}), obeyCtx: obeyCtx, video: video} +} + +func (g *gatedMedia) GenerateVideo(ctx context.Context, _ provider.VideoRequest) (*provider.VideoResponse, error) { + g.mu.Lock() + g.calls++ + g.mu.Unlock() + g.arrived <- struct{}{} + if g.obeyCtx { + select { + case <-g.release: + case <-ctx.Done(): + return nil, ctx.Err() + } + } else { + <-g.release + } + return &provider.VideoResponse{Video: g.video, Usage: g.cost}, nil +} + +func newGatedAgent(t *testing.T, media *gatedMedia) (*Agent, string) { + t.Helper() + return newTestAgent(t, &scriptedCompleter{}, func(config *Config) { + config.Media = media + config.MediaModel = mediaModels(map[string]string{modalityVideo: "film/model"}) + }) +} + +func renderJob(t *testing.T, agent *Agent, id int) *job { + t.Helper() + found := agent.jobs.find(id) + if found == nil { + t.Fatalf("no job %d", id) + } + return found +} + +func videoFiles(workspace string) []string { + entries, _ := os.ReadDir(filepath.Join(workspace, ".codeaf", "video")) + var names []string + for _, entry := range entries { + names = append(names, entry.Name()) + } + return names +} + +// Stop accepted while the provider is still working: nothing is written, the +// reply says what is and is not known, and the render reads as stopped. +func TestStopBeforeTheProviderAnswersSavesNothingAndDoesNotClaimAProviderCancel(t *testing.T) { + media := newGatedMedia([]byte("MP4"), true) + agent, workspace := newGatedAgent(t, media) + runTool(t, agent, "generate_video", `{"prompt":"a ferry"}`) + <-media.arrived + + reply, _ := runTool(t, agent, "jobs", `{"action":"kill","id":1}`) + <-renderJob(t, agent, 1).done + if !strings.Contains(reply, "no video was saved") || !strings.Contains(reply, "not told to cancel") { + t.Fatalf("kill said %q", reply) + } + if strings.Contains(reply, "cancelled at the provider") { + t.Fatalf("kill claimed an upstream cancel: %q", reply) + } + if files := videoFiles(workspace); len(files) != 0 { + t.Fatalf("a stopped render left %v", files) + } + if got := renderJob(t, agent, 1).info().state; got != jobKilled { + t.Fatalf("state = %v, want killed", got) + } + if notesContain(agent, "job 1") { + t.Fatalf("a stopped render reported itself: %v", sessionNotes(agent)) + } + if media.calls != 1 { + t.Fatalf("provider called %d times, want exactly 1", media.calls) + } +} + +// The provider ignores the cancel and answers anyway AFTER the stop was +// accepted: the bytes are dropped unwritten, not published. +func TestStopAcceptedBeforeBytesArriveNeverWritesAFileEvenIfTheProviderFinishes(t *testing.T) { + media := newGatedMedia([]byte("MP4"), false) + cost := 0.25 + media.cost = &ai.Usage{Cost: &cost} + agent, workspace := newGatedAgent(t, media) + runTool(t, agent, "generate_video", `{"prompt":"a ferry"}`) + <-media.arrived + + runTool(t, agent, "jobs", `{"action":"kill","id":1}`) + close(media.release) + <-renderJob(t, agent, 1).done + + if files := videoFiles(workspace); len(files) != 0 { + t.Fatalf("a file was published after an accepted stop: %v", files) + } + if notesContain(agent, "job 1") { + t.Fatalf("note after stop: %v", sessionNotes(agent)) + } +} + +// Finished first, stop second: the file is there, the stop is told it was too +// late, and the model still gets its note. +func TestStopAfterTheRenderLandedIsToldSoAndTheFileStays(t *testing.T) { + media := newGatedMedia([]byte("MP4 bytes"), true) + agent, workspace := newGatedAgent(t, media) + runTool(t, agent, "generate_video", `{"prompt":"a ferry"}`) + <-media.arrived + close(media.release) + <-renderJob(t, agent, 1).done + + reply, _ := runTool(t, agent, "jobs", `{"action":"kill","id":1}`) + if !strings.Contains(reply, "already finished") { + t.Fatalf("late kill said %q", reply) + } + files := videoFiles(workspace) + if len(files) != 1 { + t.Fatalf("files = %v, want the one render", files) + } + if !notesContain(agent, "job 1 finished") { + t.Fatalf("no completion note: %v", sessionNotes(agent)) + } +} + +// A stop arriving DURING the write loses: the file is complete, the reply does +// not say nothing was saved, and the job is not marked killed. +func TestStopDuringTheWriteDoesNotClaimNothingWasSaved(t *testing.T) { + agent, workspace := newTestAgent(t, &scriptedCompleter{}, nil) + rendering, _, err := agent.jobs.startRender(videoLabel, "a ferry") + if err != nil { + t.Fatalf("startRender: %v", err) + } + writing := make(chan struct{}) + finishWrite := make(chan struct{}) + type landed struct { + path string + landing renderLanding + err error + } + result := make(chan landed, 1) + go func() { + path, landing, err := agent.landRender(rendering, "", "a ferry", ".mp4", VideoDir(agent.config.Place, workspace), []byte("whole"), + func(path string, data []byte) error { + close(writing) + <-finishWrite + return writeFileAtomic(path, data) + }) + result <- landed{path, landing, err} + }() + <-writing + + reply, isError := agent.jobs.kill(context.Background(), rendering.id) + if isError || !strings.Contains(reply, "had already finished rendering") || strings.Contains(reply, "no video was saved") { + t.Fatalf("mid-write kill said %q", reply) + } + close(finishWrite) + got := <-result + if got.landing != renderLanded || got.err != nil { + t.Fatalf("landing = %v, %v", got.landing, got.err) + } + data, err := os.ReadFile(got.path) + if err != nil || !bytes.Equal(data, []byte("whole")) { + t.Fatalf("saved file = %q, %v", data, err) + } + rendering.mu.Lock() + killed := rendering.killRequested + rendering.mu.Unlock() + if killed { + t.Fatal("a committed render was marked killed, which would swallow its note") + } +} + +// A failed save leaves no empty placeholder behind and no temp file. +func TestFailedSaveLeavesNoPlaceholder(t *testing.T) { + agent, workspace := newTestAgent(t, &scriptedCompleter{}, nil) + rendering, _, err := agent.jobs.startRender(videoLabel, "a ferry") + if err != nil { + t.Fatal(err) + } + _, landing, err := agent.landRender(rendering, "", "a ferry", ".mp4", VideoDir(agent.config.Place, workspace), []byte("x"), + func(string, []byte) error { return os.ErrPermission }) + if landing != renderNotSaved || err == nil { + t.Fatalf("landing = %v, %v", landing, err) + } + if files := videoFiles(workspace); len(files) != 0 { + t.Fatalf("failed save left %v", files) + } +} + +// Renders are admitted up to the cap; the next is refused with the reason, and +// stopping one gives its place back. +func TestRenderAdmissionIsBoundedAndReleasedOnSettle(t *testing.T) { + media := newGatedMedia([]byte("MP4"), true) + agent, _ := newGatedAgent(t, media) + for i := 1; i <= jobRenderCap; i++ { + if reply, isError := runTool(t, agent, "generate_video", `{"prompt":"a ferry"}`); isError { + t.Fatalf("render %d refused: %s", i, reply) + } + <-media.arrived + } + reply, isError := runTool(t, agent, "generate_video", `{"prompt":"one too many"}`) + if !isError || !strings.Contains(reply, "renders are already running") { + t.Fatalf("over the cap: %q (error=%v)", reply, isError) + } + if media.calls != jobRenderCap { + t.Fatalf("provider saw %d calls, want %d", media.calls, jobRenderCap) + } + + runTool(t, agent, "jobs", `{"action":"kill","id":1}`) + <-renderJob(t, agent, 1).done + if reply, isError := runTool(t, agent, "generate_video", `{"prompt":"now there is room"}`); isError { + t.Fatalf("a freed place was not reusable: %s", reply) + } + <-media.arrived + for id := 2; id <= jobRenderCap+2; id++ { + runTool(t, agent, "jobs", `{"action":"kill","id":`+itoa(id)+`}`) + } +} + +// A failed render lists as failed, is called once, and is not retried. +func TestFailedRenderListsAsFailedAndIsNeverRetried(t *testing.T) { + media := &scriptedMedia{videoErr: os.ErrDeadlineExceeded} + agent, _ := newMediaAgent(t, media, nil) + runTool(t, agent, "generate_video", `{"prompt":"a ferry"}`) + <-renderJob(t, agent, 1).done + listed, _ := runTool(t, agent, "jobs", `{"action":"list"}`) + if !strings.Contains(listed, "job 1 · video · failed") { + t.Fatalf("list = %q, want a failed row", listed) + } + if media.films() != 1 { + t.Fatalf("provider called %d times, want 1", media.films()) + } +} + +// ── the process-wide admission ────────────────────────────────────────────── + +// A stopped render whose provider ignores the cancel still holds its place. +// The row says stopped at once; the place comes back only when the provider +// call actually returns. +func TestStoppedRenderKeepsItsProcessPlaceUntilTheProviderReturns(t *testing.T) { + media := newGatedMedia([]byte("MP4"), false) // ignores the cancel + agent, _ := newGatedAgent(t, media) + agent.jobs.gate = newRenderGate(jobRenderCap) + + for i := 1; i <= jobRenderCap; i++ { + if reply, isError := runTool(t, agent, "generate_video", `{"prompt":"a ferry"}`); isError { + t.Fatalf("render %d refused: %s", i, reply) + } + <-media.arrived + } + // The stop is accepted and delivered exactly as `jobs kill` does, without + // its two-second wait for a row that an ignoring provider never settles. + for id := 1; id <= jobRenderCap; id++ { + stopped := renderJob(t, agent, id) + if claimed, _ := stopped.requestKillRender(); !claimed { + t.Fatalf("stop on job %d not accepted", id) + } + stopped.signal(syscall.SIGTERM) + } + reply, isError := runTool(t, agent, "generate_video", `{"prompt":"replacement"}`) + if !isError || !strings.Contains(reply, "in this process") { + t.Fatalf("a replacement was admitted while stopped providers still ran: %q", reply) + } + if media.calls != jobRenderCap { + t.Fatalf("provider calls = %d, want %d", media.calls, jobRenderCap) + } + + // One provider actually returns: exactly one place frees. + media.release <- struct{}{} + waitGateFree(t, agent.jobs.gate, 1) + if reply, isError := runTool(t, agent, "generate_video", `{"prompt":"replacement"}`); isError { + t.Fatalf("a returned provider did not free its place: %s", reply) + } + <-media.arrived + if _, isError := runTool(t, agent, "generate_video", `{"prompt":"another"}`); !isError { + t.Fatal("only one place had freed") + } + close(media.release) +} + +// waitGateFree blocks until the gate has n free places, by polling the gate's +// own state with a yield — the release happens on a goroutine the test cannot +// otherwise observe. +func waitGateFree(t *testing.T, gate *renderGate, n int) { + t.Helper() + for len(gate.places) > cap(gate.places)-n { + runtime.Gosched() + } +} + +// Two agents share three places, not six. +func TestProcessRenderCapIsSharedAcrossAgents(t *testing.T) { + gate := newRenderGate(jobRenderCap) + media := newGatedMedia([]byte("MP4"), true) + first, _ := newGatedAgent(t, media) + second, _ := newGatedAgent(t, media) + first.jobs.gate, second.jobs.gate = gate, gate + + admitted := 0 + for _, agent := range []*Agent{first, second, first, second} { + if _, isError := runTool(t, agent, "generate_video", `{"prompt":"a ferry"}`); !isError { + admitted++ + <-media.arrived + } + } + if admitted != jobRenderCap { + t.Fatalf("admitted %d across two agents, want %d", admitted, jobRenderCap) + } + for _, agent := range []*Agent{first, second} { + for _, one := range agent.jobs.all() { + runTool(t, agent, "jobs", `{"action":"kill","id":`+itoa(one.id)+`}`) + } + } + waitGateFree(t, gate, jobRenderCap) +} + +// The place comes back on a provider failure, a committed save, and a stop that +// the provider honours. +func TestProcessPlaceIsReleasedOnEveryRoadOut(t *testing.T) { + gate := newRenderGate(1) + for name, scenario := range map[string]func(t *testing.T) (*Agent, func()){ + "failure": func(t *testing.T) (*Agent, func()) { + agent, _ := newMediaAgent(t, &scriptedMedia{videoErr: os.ErrDeadlineExceeded}, nil) + return agent, func() {} + }, + "commit": func(t *testing.T) (*Agent, func()) { + agent, _ := newMediaAgent(t, &scriptedMedia{video: []byte("MP4")}, nil) + return agent, func() {} + }, + "honoured stop": func(t *testing.T) (*Agent, func()) { + media := newGatedMedia([]byte("MP4"), true) + agent, _ := newGatedAgent(t, media) + return agent, func() { + <-media.arrived + runTool(t, agent, "jobs", `{"action":"kill","id":1}`) + } + }, + } { + t.Run(name, func(t *testing.T) { + agent, after := scenario(t) + agent.jobs.gate = gate + if reply, isError := runTool(t, agent, "generate_video", `{"prompt":"a ferry"}`); isError { + t.Fatalf("refused: %s", reply) + } + after() + <-renderJob(t, agent, 1).done + waitGateFree(t, gate, 1) + }) + } +} + +// A refusal from the per-registry cap or a failed start gives the place back. +func TestRefusedStartDoesNotLeakAProcessPlace(t *testing.T) { + agent, _ := newTestAgent(t, &scriptedCompleter{}, nil) + gate := newRenderGate(jobRenderCap + 2) + agent.jobs.gate = gate + for i := 0; i < jobRenderCap; i++ { + if _, _, err := agent.jobs.startRender(videoLabel, "p"); err != nil { + t.Fatal(err) + } + } + if _, _, err := agent.jobs.startRender(videoLabel, "p"); err != errRenderCap { + t.Fatalf("err = %v, want the per-agent cap", err) + } + if len(gate.places) != jobRenderCap { + t.Fatalf("gate holds %d places, want %d (the refusal leaked one)", len(gate.places), jobRenderCap) + } + agent.jobs.shutdown(0) + if _, _, err := agent.jobs.startRender(videoLabel, "p"); err == nil { + t.Fatal("a closed registry admitted a render") + } + if len(gate.places) != jobRenderCap { + t.Fatalf("a closed-registry refusal leaked a place: %d", len(gate.places)) + } +} + +// Concurrent admissions never exceed the gate, and every place is returned. +func TestRenderGateAccountingUnderContention(t *testing.T) { + gate := newRenderGate(jobRenderCap) + var held, peak, wins int32 + var wg sync.WaitGroup + for i := 0; i < 64; i++ { + wg.Add(1) + go func() { + defer wg.Done() + if !gate.tryAcquire() { + return + } + now := atomic.AddInt32(&held, 1) + for { + old := atomic.LoadInt32(&peak) + if now <= old || atomic.CompareAndSwapInt32(&peak, old, now) { + break + } + } + atomic.AddInt32(&wins, 1) + atomic.AddInt32(&held, -1) + gate.release() + }() + } + wg.Wait() + if peak > int32(jobRenderCap) || len(gate.places) != 0 { + t.Fatalf("peak %d (cap %d), %d places still held", peak, jobRenderCap, len(gate.places)) + } +} diff --git a/internal/session/render_land.go b/internal/session/render_land.go new file mode 100644 index 0000000000..d7fbf2e086 --- /dev/null +++ b/internal/session/render_land.go @@ -0,0 +1,83 @@ +package session + +import ( + "os" + "path/filepath" +) + +// renderLanding is how [Agent.landRender] ended. +type renderLanding int + +const ( + // renderLanded: the file exists, complete, at the returned path. + renderLanded renderLanding = iota + // renderStopped: a stop was accepted before the bytes were committed, and + // NOTHING was written — not a file, not an empty placeholder. + renderStopped + // renderNotSaved: the render was committed and saving it failed. + renderNotSaved +) + +// landRender is where a finished render becomes a file, and it is the only +// place that happens for a video or a clip. +// +// THE ORDER IS THE LAW. The commit gate ([job.commitRender]) comes FIRST, before +// the destination is even chosen — choosing one claims an empty file on disk — +// so a stop accepted before this point leaves no trace. After the gate a stop +// can no longer win ([job.requestKillRender] answers it with "already +// finished"), the bytes are written whole through [writeFileAtomic], and the +// caller records and announces a file that exists. There is no state in which a +// stop was acknowledged and a file then appears, and none in which "no file was +// saved" is said about one that was. +// +// write is a parameter only so a test can hold the write open and prove a stop +// arriving mid-write changes nothing. +func (a *Agent) landRender(rendering *job, asked, prompt, extension, directory string, data []byte, write func(string, []byte) error) (string, renderLanding, error) { + if !rendering.commitRender() { + return "", renderStopped, nil + } + path, err := a.mediaDestination(asked, prompt, extension, directory) + if err == nil { + err = os.MkdirAll(filepath.Dir(path), 0o755) + } + if err == nil { + err = write(path, data) + } + if err != nil { + // The placeholder mediaDestination claimed is ours to clear, and only + // while it is still empty and the name was chosen by us rather than the + // caller: a path the caller asked for may name a file they care about. + if path != "" && asked == "" { + if info, statErr := os.Stat(path); statErr == nil && info.Size() == 0 { + _ = os.Remove(path) + } + } + return "", renderNotSaved, err + } + return path, renderLanded, nil +} + +// writeFileAtomic writes data to a temporary file beside path and renames it +// into place, so a crash or a full disk leaves either the previous file or the +// whole new one at path and never a partial. +func writeFileAtomic(path string, data []byte) error { + temp, err := os.CreateTemp(filepath.Dir(path), ".render-*") + if err != nil { + return err + } + name := temp.Name() + _, err = temp.Write(data) + if closeErr := temp.Close(); err == nil { + err = closeErr + } + if err == nil { + err = os.Chmod(name, 0o644) + } + if err == nil { + err = os.Rename(name, path) + } + if err != nil { + _ = os.Remove(name) + } + return err +} diff --git a/internal/session/tools_editvideo.go b/internal/session/tools_editvideo.go index 08f7f5c291..de6defec73 100644 --- a/internal/session/tools_editvideo.go +++ b/internal/session/tools_editvideo.go @@ -228,7 +228,7 @@ func (a *Agent) frameVideo(ctx context.Context, parsed editVideoArguments) (stri if err := video.SaveFrame(ctx, full, at, destination); err != nil { return "Could not save the frame: " + err.Error(), true, nil } - a.recordVideoArtifact(destination, said, "image") + a.recordVideoArtifact(ctx, destination, said, "image", nil) // The picture's own geometry is read back off the file, for the same reason // every other answer here is: a size this claimed rather than measured would @@ -292,7 +292,7 @@ func (a *Agent) joinVideo(ctx context.Context, parsed editVideoArguments) (strin if err != nil { return "Could not join the clips: " + err.Error(), true, nil } - a.recordVideoArtifact(destination, said, "video") + a.recordVideoArtifact(ctx, destination, said, "video", &facts) return a.describeVideoFile(destination, facts) + fmt.Sprintf(", joined from %d clips", len(clips)), false, nil } @@ -343,7 +343,7 @@ func (a *Agent) scoreVideo(ctx context.Context, parsed editVideoArguments) (stri if err != nil { return "Could not score the video: " + err.Error(), true, nil } - a.recordVideoArtifact(destination, said, "video") + a.recordVideoArtifact(ctx, destination, said, "video", &facts) // Whether the piece was looped or trimmed is MEASURED, not assumed, and it // is worth saying: it is the difference between a score that repeats — where @@ -447,14 +447,21 @@ func formatList(allowed map[string]bool) string { // deliverables index, so `/files` finds a cut or a frame again by name and date // exactly as it finds a generated one. A file the person may want back is a // deliverable whether a provider made it or ffmpeg did. -func (a *Agent) recordVideoArtifact(path, said, kind string) { - RecordArtifact(a.config.ArtifactsIndex, Artifact{ +// +// Its media fact is the file as it now is (DerivedMedia), with a length and a +// frame size only when the caller's probe measured them: ffmpeg made it, so +// there is no provider usage and the charge stays unknown. +func (a *Agent) recordVideoArtifact(ctx context.Context, path, said, kind string, measured *video.Facts) { + fact := DerivedMedia(path).WithMade(madeBy(ctx, nil)) + if measured != nil { + fact = fact.WithDuration(measured.Length).WithSize(measured.Width, measured.Height) + } + a.recordMedia(Artifact{ Path: path, - Session: a.journalID(), Title: mediaTitle(said, path), Kind: kind, Created: time.Now(), - }) + }, fact) } // describeVideoFile is the one sentence every action that touches a video diff --git a/internal/session/tools_image.go b/internal/session/tools_image.go index 2bca84fa40..978230ccf4 100644 --- a/internal/session/tools_image.go +++ b/internal/session/tools_image.go @@ -121,6 +121,9 @@ type ImageGen struct { Directory string Account func(model string, usage *ai.Usage) Record func(path, prompt string) + // RecordMedia is Record with the structured fact of the file (mediafact.go); + // it wins over Record when both are set. + RecordMedia func(path, prompt string, fact MediaFact) } // generateImageTool wraps the shared road ([generateImage]) in the belt's @@ -142,7 +145,9 @@ func (a *Agent) generateImageTool(client MediaGenerator, defaultModel string) ba Workspace: a.config.Workspace, Directory: ImagesDir(a.config.Place, a.config.Workspace), Account: a.accountImageRung, - Record: a.recordImageArtifact, + RecordMedia: func(path, prompt string, fact MediaFact) { + a.recordImageMedia(path, prompt, fact.WithMade(madeBy(ctx, nil))) + }, }, parsed) return text, failed, nil }, @@ -159,19 +164,18 @@ func (a *Agent) accountImageRung(model string, usage *ai.Usage) { a.addAuxiliaryUsage(&ai.Response{Usage: usage}, model, 1) } -// recordImageArtifact is the road's artifacts hook: a picture the harness made +// recordImageMedia is the road's artifacts hook: a picture the harness made // is a DELIVERABLE, so it earns a row in the index a person finds their work // again by (artifacts.go). The recording is silent in both directions: it // happens after the bytes are safely down, and a failure to write the lookup // file is not news the model can act on. -func (a *Agent) recordImageArtifact(path, prompt string) { - RecordArtifact(a.config.ArtifactsIndex, Artifact{ +func (a *Agent) recordImageMedia(path, prompt string, fact MediaFact) { + a.recordMedia(Artifact{ Path: path, - Session: a.journalID(), Title: mediaTitle(prompt, path), Kind: "image", Created: time.Now(), - }) + }, fact) } // GenerateImage is the whole road behind generate_image, as a plain function @@ -216,8 +220,11 @@ func GenerateImage(ctx context.Context, gen ImageGen, parsed GenerateImageArgs) return "Invalid arguments: reference " + refusal, true } + // One picture per call is what the tool asks for; the count recorded with + // the file is this very number, not a guess about variants. + const requested = 1 response, err := gen.Client.GenerateImage(ctx, provider.ImageRequest{ - Model: model, Prompt: prompt, N: 1, OutputFormat: "png", + Model: model, Prompt: prompt, N: requested, OutputFormat: "png", AspectRatio: strings.TrimSpace(parsed.AspectRatio), Size: strings.TrimSpace(parsed.Size), InputReferences: references, @@ -249,7 +256,12 @@ func GenerateImage(ctx context.Context, gen ImageGen, parsed GenerateImageArgs) if err := os.WriteFile(path, data, 0o644); err != nil { return "Could not save the generated image: " + err.Error(), true } - if gen.Record != nil { + if gen.RecordMedia != nil { + // The facts are the ones the road itself holds: the bytes it wrote, the + // usage the provider reported, and the reference paths exactly as given. + gen.RecordMedia(path, parsed.Prompt, GeneratedMedia(path, data, model). + WithUsage(response.Usage).WithReferences(parsed.ReferencePaths).WithRequested(requested)) + } else if gen.Record != nil { gen.Record(path, parsed.Prompt) } return describeGeneratedImage(path, data, model), false diff --git a/internal/session/tools_music.go b/internal/session/tools_music.go index d681910114..a33c0d4f1c 100644 --- a/internal/session/tools_music.go +++ b/internal/session/tools_music.go @@ -45,8 +45,8 @@ import ( "encoding/json" "errors" "fmt" - "os" "path/filepath" + "strconv" "strings" "time" @@ -134,7 +134,7 @@ func (a *Agent) generateMusicTool(client MediaGenerator, defaultModel string) ba Name: "generate_music", Description: generateMusicDescription, Schema: a.mediaSchema(generateMusicSchemaJSON, "music"), - Execute: func(_ context.Context, args json.RawMessage) (string, bool, error) { + Execute: func(callCtx context.Context, args json.RawMessage) (string, bool, error) { var parsed generateMusicArguments if err := decodeToolArguments(args, &parsed); err != nil { return "Invalid arguments: " + err.Error(), true, nil @@ -159,7 +159,7 @@ func (a *Agent) generateMusicTool(client MediaGenerator, defaultModel string) ba go a.composeMusic(ctx, client, started, provider.MusicRequest{ Model: model, Prompt: prompt, - }, parsed.Path, model) + }, parsed.Path, model, madeBy(callCtx, nil)) // The id, the model and the log, and nothing else — the RESULT // arrives as a note, by tools_video.go's law exactly. @@ -175,7 +175,9 @@ func (a *Agent) generateMusicTool(client MediaGenerator, defaultModel string) ba // turn that started it and dies to `jobs kill` and to Close like any other // job; the only audience for a failure is the model, which hears it in the // note. -func (a *Agent) composeMusic(ctx context.Context, client MediaGenerator, composing *job, request provider.MusicRequest, asked, model string) { +func (a *Agent) composeMusic(ctx context.Context, client MediaGenerator, composing *job, request provider.MusicRequest, asked, model string, made mediaMade) { + // Keep admission until the actual provider goroutine exits, including late cancellation. + defer composing.releaseRender() response, err := client.GenerateMusic(ctx, request) if err != nil { a.failMusic(composing, musicFailure(model, err)) @@ -190,25 +192,24 @@ func (a *Agent) composeMusic(ctx context.Context, client MediaGenerator, composi // the law). a.addAuxiliaryUsage(&ai.Response{Usage: response.Usage}, model, 1) - path, err := a.mediaDestination(asked, request.Prompt, musicFileExtension(response.Format), - MusicDir(a.config.Place, a.config.Workspace)) - if err == nil { - err = os.MkdirAll(filepath.Dir(path), 0o755) - } - if err == nil { - err = os.WriteFile(path, response.Audio, 0o644) - } - if err != nil { + path, landing, err := a.landRender(composing, asked, request.Prompt, musicFileExtension(response.Format), + MusicDir(a.config.Place, a.config.Workspace), response.Audio, writeFileAtomic) + switch landing { + case renderStopped: + reason := "stopped before the music was saved; the provider had already finished it, so it may have been charged" + fmt.Fprintln(composing.sink, reason) + a.jobs.finish(composing, 1, "job "+strconv.Itoa(composing.id)+" "+reason) + return + case renderNotSaved: a.failMusic(composing, "could not save the generated music: "+err.Error()) return } - RecordArtifact(a.config.ArtifactsIndex, Artifact{ + a.recordMedia(Artifact{ Path: path, - Session: a.journalID(), Title: mediaTitle(request.Prompt, path), Kind: "audio", Created: time.Now(), - }) + }, GeneratedMedia(path, response.Audio, model).WithUsage(response.Usage).WithMade(made)) landed := describeGeneratedMusic(a.config.Workspace, path, len(response.Audio), model) fmt.Fprintln(composing.sink, landed) @@ -232,7 +233,7 @@ func (a *Agent) failMusic(composing *job, reason string) { func musicFailure(model string, err error) string { switch { case errors.Is(err, context.Canceled): - return "music generation was stopped (" + model + "); no music was saved" + return "music generation was stopped (" + model + "); no music was saved. The provider was not told to cancel, so whether it is charged is unknown" case errors.Is(err, context.DeadlineExceeded): return "music generation timed out (" + model + "); no music was saved" default: diff --git a/internal/session/tools_sense.go b/internal/session/tools_sense.go index 098e035c7f..7eee875bbe 100644 --- a/internal/session/tools_sense.go +++ b/internal/session/tools_sense.go @@ -467,6 +467,7 @@ func (a *Agent) imageSense(ctx context.Context, memo *senseMemo, shown, absolute } reading := senseReading{note: senseNote("vision", seer), text: answer} memo.put(key, reading) + a.recordEvidence(ctx, absolute, entry.mediaType, "image", data, EvidenceLookedAt, answer, seer, false) return reading.note + piReadLaw(a.resultCaps(), reading.text, offset, limit), false, nil } @@ -555,6 +556,7 @@ func (a *Agent) audioSense(ctx context.Context, memo *senseMemo, shown, absolute default: reading := senseReading{note: senseNote("transcript", scribe), text: text} memo.put(key, reading) + a.recordEvidence(ctx, absolute, entry.mediaType, "audio", data, EvidenceListenedTo, text, scribe, true) return reading.note + piReadLaw(a.resultCaps(), reading.text, offset, limit), false, nil } } @@ -568,6 +570,7 @@ func (a *Agent) audioSense(ctx context.Context, memo *senseMemo, shown, absolute if err == nil { reading := senseReading{note: senseNote("audio", listener), text: answer} memo.put(key, reading) + a.recordEvidence(ctx, absolute, entry.mediaType, "audio", data, EvidenceListenedTo, answer, listener, false) return reading.note + piReadLaw(a.resultCaps(), reading.text, offset, limit), false, nil } failures = append(failures, listener+": "+oneLineReason(err.Error())) @@ -575,6 +578,7 @@ func (a *Agent) audioSense(ctx context.Context, memo *senseMemo, shown, absolute if thin.text != "" { memo.put(key, thin) + a.recordEvidence(ctx, absolute, entry.mediaType, "audio", data, EvidenceListenedTo, thin.text, scribe, true) return thin.note + piReadLaw(a.resultCaps(), thin.text, offset, limit), false, nil } // Every rung named, in the order they were tried, because "which one broke" @@ -667,6 +671,7 @@ func (a *Agent) videoSense(ctx context.Context, memo *senseMemo, shown, absolute } reading := senseReading{note: senseNote("video", watcher), text: answer} memo.put(key, reading) + a.recordEvidence(ctx, absolute, entry.mediaType, "video", data, EvidenceWatched, answer, watcher, false) return reading.note + piReadLaw(a.resultCaps(), reading.text, offset, limit), false, nil } diff --git a/internal/session/tools_speak.go b/internal/session/tools_speak.go index 6b06823f06..d3e11ac496 100644 --- a/internal/session/tools_speak.go +++ b/internal/session/tools_speak.go @@ -121,13 +121,12 @@ func (a *Agent) speakTool(client MediaGenerator, defaultModel string) bare.Tool if err := os.WriteFile(path, response.Audio, 0o644); err != nil { return "Could not save the generated audio: " + err.Error(), true, nil } - RecordArtifact(a.config.ArtifactsIndex, Artifact{ + a.recordMedia(Artifact{ Path: path, - Session: a.journalID(), Title: mediaTitle(text, path), Kind: "audio", Created: time.Now(), - }) + }, GeneratedMedia(path, response.Audio, model).WithUsage(response.Usage).WithSaid(text).WithMade(madeBy(ctx, nil))) return describeGeneratedAudio(a.config.Workspace, path, len(response.Audio), model), false, nil }, } diff --git a/internal/session/tools_video.go b/internal/session/tools_video.go index 6a53dc27d9..51472766f2 100644 --- a/internal/session/tools_video.go +++ b/internal/session/tools_video.go @@ -27,8 +27,7 @@ import ( "encoding/json" "errors" "fmt" - "os" - "path/filepath" + "strconv" "strings" "time" @@ -108,7 +107,7 @@ func (a *Agent) generateVideoTool(client MediaGenerator, defaultModel string) ba Name: "generate_video", Description: generateVideoDescription, Schema: a.mediaSchema(generateVideoSchemaJSON, "video"), - Execute: func(_ context.Context, args json.RawMessage) (string, bool, error) { + Execute: func(callCtx context.Context, args json.RawMessage) (string, bool, error) { var parsed generateVideoArguments if err := decodeToolArguments(args, &parsed); err != nil { return "Invalid arguments: " + err.Error(), true, nil @@ -160,7 +159,7 @@ func (a *Agent) generateVideoTool(client MediaGenerator, defaultModel string) ba } fmt.Fprintf(started.sink, "filming on %s: %s\n", model, firstLine(prompt)) - go a.renderVideo(ctx, client, started, request, parsed.Path, model) + go a.renderVideo(ctx, client, started, request, parsed.Path, model, madeBy(callCtx, append(append([]string(nil), parsed.FramePaths...), parsed.ReferencePaths...))) // The id, the model and the log, and nothing else. There is no // output yet by construction, and the three things the model needs @@ -195,7 +194,9 @@ func (a *Agent) videoFrames(paths []string) ([]provider.ImageReference, string) // started it and dies to `jobs kill` and to Close like any other job. Nothing // here returns an error to anybody: the caller is a goroutine, and the only // audience for a failure is the model, which hears about it in the note. -func (a *Agent) renderVideo(ctx context.Context, client MediaGenerator, rendering *job, request provider.VideoRequest, asked, model string) { +func (a *Agent) renderVideo(ctx context.Context, client MediaGenerator, rendering *job, request provider.VideoRequest, asked, model string, made mediaMade) { + // Keep admission until the actual provider goroutine exits, including late cancellation. + defer rendering.releaseRender() response, err := client.GenerateVideo(ctx, request) if err != nil { a.failVideo(rendering, videoFailure(model, err)) @@ -211,25 +212,32 @@ func (a *Agent) renderVideo(ctx context.Context, client MediaGenerator, renderin // states the law). a.addAuxiliaryUsage(&ai.Response{Usage: response.Usage}, model, 1) - path, err := a.mediaDestination(asked, request.Prompt, videoExtension, - VideoDir(a.config.Place, a.config.Workspace)) - if err == nil { - err = os.MkdirAll(filepath.Dir(path), 0o755) - } - if err == nil { - err = os.WriteFile(path, response.Video, 0o644) - } - if err != nil { + path, landing, err := a.landRender(rendering, asked, request.Prompt, videoExtension, + VideoDir(a.config.Place, a.config.Workspace), response.Video, writeFileAtomic) + switch landing { + case renderStopped: + // The provider finished after the stop was accepted. The bytes are + // dropped unwritten, and the log says what is and is not known. + reason := "stopped before the video was saved; the provider had already finished it, so it may have been charged" + fmt.Fprintln(rendering.sink, reason) + a.jobs.finish(rendering, 1, "job "+strconv.Itoa(rendering.id)+" "+reason) + return + case renderNotSaved: a.failVideo(rendering, "could not save the generated video: "+err.Error()) return } - RecordArtifact(a.config.ArtifactsIndex, Artifact{ + // The fact keeps what the render was given (frames first, then style + // references, exactly as named) and the length the file's own boxes state. + fact := GeneratedMedia(path, response.Video, model).WithUsage(response.Usage).WithMade(made) + if length, _, ok := mp4Facts(response.Video); ok { + fact = fact.WithDuration(length) + } + a.recordMedia(Artifact{ Path: path, - Session: a.journalID(), Title: mediaTitle(request.Prompt, path), Kind: "video", Created: time.Now(), - }) + }, fact) landed := describeGeneratedVideo(a.config.Workspace, path, response.Video, model) fmt.Fprintln(rendering.sink, landed) @@ -237,8 +245,9 @@ func (a *Agent) renderVideo(ctx context.Context, client MediaGenerator, renderin } // failVideo ends a render that produced nothing. The exit code is 1 for the -// same reason a failed command's is: `jobs list` renders a video's status as -// "finished" either way, and the code is what a reader of the log has left. +// same reason a failed command's is, and it is what `jobs list` reads to say +// "failed" rather than "finished". A failure is never retried: the provider +// call is made once, and any further attempt is the model's decision. func (a *Agent) failVideo(rendering *job, reason string) { fmt.Fprintln(rendering.sink, reason) a.jobs.finish(rendering, 1, fmt.Sprintf("job %d failed: %s", rendering.id, reason)) @@ -253,7 +262,7 @@ func videoFailure(model string, err error) string { case errors.Is(err, provider.ErrVideoTimeout): return "video generation timed out (" + model + "); no video was saved" case errors.Is(err, context.Canceled): - return "video generation was stopped (" + model + "); no video was saved" + return "video generation was stopped (" + model + "); no video was saved. The provider was not told to cancel, so whether it is charged is unknown" default: return "video generation failed (" + model + "): " + clip(firstLine(err.Error()), jobExitNoteLimit) } diff --git a/internal/session/tools_view.go b/internal/session/tools_view.go index 57ab409e5c..69299226da 100644 --- a/internal/session/tools_view.go +++ b/internal/session/tools_view.go @@ -169,6 +169,7 @@ func (a *Agent) viewImage(ctx context.Context, path, question, known string) (st // wrong, and an API error can carry a whole HTML page. return seer + " could not look at " + shown + ": " + oneLineReason(err.Error()), true, nil } + a.recordEvidence(ctx, absolute, mediaType, "image", data, EvidenceLookedAt, answer, seer, false) // "seen by " is the same sentence internal/exec's own view_image // returns, and it is said out loud for image.go's reason: a second model // answering in the first one's voice, silently, would be the surface lying From 2c24ea4905636de6e4ec335e6a2c9ade2992e9c4 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 16 Oct 2026 00:33:58 -0400 Subject: [PATCH 134/459] Carry durable multimodal messages with bounded request image materialization --- cmd/codeaf/app.go | 28 +- cmd/codeaf/appconvo.go | 23 +- cmd/codeaf/appsubmission.go | 233 ++++++ cmd/codeaf/appsubmission_test.go | 288 ++++++++ .../unreleased/0-app-media-submission.md | 12 + internal/appwire/convo.go | 18 +- internal/appwire/crews.go | 5 + internal/appwire/media_submission.go | 243 +++++++ internal/appwire/media_submission_test.go | 124 ++++ internal/appwire/server.go | 33 +- internal/appwire/wire.go | 9 + internal/remote/client.go | 39 + internal/remote/composed_test.go | 92 +++ internal/remote/server.go | 33 + internal/remote/wire.go | 25 +- internal/session/agent.go | 11 +- internal/session/clientdoor.go | 5 +- internal/session/image.go | 61 +- internal/session/image_test.go | 4 +- internal/session/imageref.go | 252 +++++++ internal/session/imageref_bench_test.go | 34 + internal/session/imageref_test.go | 459 ++++++++++++ internal/session/presentation.go | 6 +- internal/session/recordcache.go | 4 +- internal/session/sessionfile.go | 29 +- internal/session/submission.go | 10 + internal/session/submission_media.go | 681 ++++++++++++++++++ internal/session/submission_media_test.go | 554 ++++++++++++++ 28 files changed, 3253 insertions(+), 62 deletions(-) create mode 100644 cmd/codeaf/appsubmission.go create mode 100644 cmd/codeaf/appsubmission_test.go create mode 100644 docs/changes/unreleased/0-app-media-submission.md create mode 100644 internal/appwire/media_submission.go create mode 100644 internal/appwire/media_submission_test.go create mode 100644 internal/remote/composed_test.go create mode 100644 internal/session/imageref.go create mode 100644 internal/session/imageref_bench_test.go create mode 100644 internal/session/imageref_test.go create mode 100644 internal/session/submission_media.go create mode 100644 internal/session/submission_media_test.go diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index 54a95d2dac..8df06b7eaf 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -1473,6 +1473,15 @@ func (b *appBackend) startLocked(a *appAgent, text, clientID string, shown *appS if shown != nil && !appKeepsQueuedMarks(live.conn.Conv) { return 0, appwire.Errorf(appwire.CodeUnknownMethod, "this conversation cannot keep a message's said, reads or attachments; update its engine before sending") } + // PICTURES ARE NEVER SENT AS WORDS ALONE: a conversation whose engine has not + // said it takes them refuses the message before the turn, and the chips stay. + var composer appComposer + if shown != nil && shown.composed != nil { + var ok bool + if composer, ok = live.conn.Conv.(appComposer); !ok || !composer.ComposedKnown() { + return 0, appwire.Errorf(appwire.CodeUnknownMethod, "this conversation cannot take pictures with a message; update its engine before sending") + } + } // A conversation joined on a window's host can have heard from the person // in that window too, so its count is read again before it is used. if !live.conn.Assembled && live.conn.Transcript != "" { @@ -1523,7 +1532,15 @@ func (b *appBackend) startLocked(a *appAgent, text, clientID string, shown *appS // riding on it is the plain Submit it always was. var events <-chan session.Event var err error - if shown != nil { + var composedMark *session.ComposedMark + if composer != nil { + var receipt session.SubmissionReceipt + receipt, events, err = composer.SubmitComposed(context.Background(), appSubmission(text, shown)) + if err == nil { + shown.composed.receipt = &receipt + composedMark = &session.ComposedMark{MessageID: receipt.MessageID, Media: receipt.Media, Annotation: shown.composed.annotation, Delivery: &session.MessageDelivery{Mode: receipt.Delivery, Model: receipt.SeenBy}} + } + } else if shown != nil { events, err = live.conn.Conv.SubmitMessage(context.Background(), appSubmission(text, shown)) } else { events, err = live.conn.Conv.Submit(context.Background(), text) @@ -1545,9 +1562,11 @@ func (b *appBackend) startLocked(a *appAgent, text, clientID string, shown *appS }) live.box.Event(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) live.box.Event(appwire.EventTurnStarted, appwire.TurnStarted{Agent: id, Turn: turn}) - live.box.Event(appwire.EventEntryAdded, appwire.EntryAdded{Entry: appwire.Entry{ + userEntry := appwire.Entry{ ID: appUserEntryID(n), Agent: id, Turn: turn, Kind: appwire.EntryUser, At: b.now(), Text: entryText, ClientID: clientID, Attachments: attachments, Reads: reads, - }}) + } + appComposedFields(&userEntry, composedMark) + live.box.Event(appwire.EventEntryAdded, appwire.EntryAdded{Entry: userEntry}) long := b.watchLong(id, live, turn, b.now()) b.newTurnCalls(a) tails := b.tickTools(a, id, live) @@ -2476,6 +2495,9 @@ func appEntryShapes(agent string, items []session.DisplayEntry, at time.Time, ma // line without that mark is its words, and has no attachments. entry.ID, entry.Kind, entry.Text, entry.Attachments = appUserEntryID(n), appwire.EntryUser, item.Text, appAttachmentsOf(item.Person) entry.Reads = appPersonReads(item.Person) + if item.Person != nil { + appComposedFields(&entry, item.Person.Composed) + } case "assistant", "aside": if strings.TrimSpace(item.Text) == "" { continue diff --git a/cmd/codeaf/appconvo.go b/cmd/codeaf/appconvo.go index 29c6fbaae9..442c61dd8c 100644 --- a/cmd/codeaf/appconvo.go +++ b/cmd/codeaf/appconvo.go @@ -997,7 +997,11 @@ func appAttachmentsOf(person *session.PersonMark) []appwire.Attachment { // them. func appSubmission(text string, shown *appSending) session.Submission { said := shown.text - return session.Submission{Text: text, Said: &said, Attachments: shown.described, Reads: shown.reads} + submission := session.Submission{Text: text, Said: &said, Attachments: shown.described, Reads: shown.reads} + if shown.composed != nil { + submission.Media, submission.Annotation = shown.composed.media, shown.composed.annotation + } + return submission } // appKeptReads is a message's reads as the record will keep them, or the @@ -1188,6 +1192,20 @@ func (b *appBackend) sendMessage(agent string, message appwire.AgentMessage) (ui if shown != nil { shown.reads = reads } + // THE PICTURES ARE RESOLVED AND CHECKED WHOLE BEFORE THE CONVERSATION IS + // OPENED: a message the engine would refuse leaves every chip staged. + var workspace string + b.locked(func() { workspace = a.view.Workspace }) + composed, err := appComposedOf(workspace, kept, message.Submission) + if err != nil { + return 0, nil, err + } + if composed != nil { + if shown == nil { + shown = &appSending{text: said, reads: reads} + } + shown.composed = composed + } turn, err := b.sendLocked(a, appAttachWords(message.Text, kept), message.ClientID, shown) if err != nil { return 0, nil, err @@ -1212,6 +1230,9 @@ type appSending struct { // reads are the reads the words were written from, as the record keeps // them ([appKeptReads]). reads []session.MessageRead + // composed is the message's pictures and annotation, and what the engine + // answered once it kept it (appsubmission.go); nil on a message of words. + composed *appComposed } // ── tasks as conversations ────────────────────────────────────────────────── diff --git a/cmd/codeaf/appsubmission.go b/cmd/codeaf/appsubmission.go new file mode 100644 index 0000000000..68da04803d --- /dev/null +++ b/cmd/codeaf/appsubmission.go @@ -0,0 +1,233 @@ +package main + +import ( + "context" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/remote" + "github.com/Agent-Field/codeaf/internal/session" +) + +// ── a message with pictures, from the app to the engine and back ──────────── +// +// The app stages files as attachments and, on send, names which of them are +// pictures and what was marked on them (appwire's MessageSubmission). This file +// turns that into the engine's one composed submission and the engine's answer +// back into the facts an entry shows. It holds no policy of its own: a path is +// resolved only from an attachment the person staged, and every bound belongs to +// the engine. + +// appComposer is a conversation that takes a message carrying pictures. Only a +// conversation whose engine said so is one; the rest are refused before the +// turn starts, never sent the words alone. +type appComposer interface { + ComposedKnown() bool + SubmitComposed(ctx context.Context, message session.Submission) (session.SubmissionReceipt, <-chan session.Event, error) +} + +var ( + _ appwire.ComposedSender = (*appBackend)(nil) + _ appComposer = (*remote.Agent)(nil) +) + +// appComposed is the pictures and annotation of one message as the engine is +// asked to take them, and — once the turn has started — what it answered. +type appComposed struct { + media []session.SubmissionMedia + annotation *session.Annotation + receipt *session.SubmissionReceipt +} + +// SendComposed is agents.send for a message that carries pictures. It is +// [appBackend.sendMessage] with the submission attached, and it answers what the +// engine said about the message it kept. +func (b *appBackend) SendComposed(agent string, message appwire.AgentMessage) (appwire.TurnReceipt, error) { + if message.Submission == nil { + turn, _, err := b.sendMessage(agent, message) + return appwire.TurnReceipt{Turn: turn}, err + } + turn, shown, err := b.sendMessage(agent, message) + if err != nil { + return appwire.TurnReceipt{}, err + } + receipt := appwire.TurnReceipt{Turn: turn} + if shown != nil && shown.composed != nil && shown.composed.receipt != nil { + got := shown.composed.receipt + receipt.MessageID, receipt.Delivery, receipt.SeenBy, receipt.Queued = got.MessageID, got.Delivery, got.SeenBy, got.Queued + } + return receipt, nil +} + +// appComposedOf resolves a send's submission against the attachments the person +// staged. A picture is named by its attachment id and found in kept, so the +// engine is never handed a path the person did not stage. The whole submission +// is checked here, before the conversation is opened or a turn is asked for, so +// a refusal leaves every chip staged. +func appComposedOf(workspace string, kept []appAttach, given *appwire.MessageSubmission) (*appComposed, error) { + if given == nil { + return nil, nil + } + staged := make(map[string]appAttach, len(kept)) + for _, attach := range kept { + staged[attach.view.ID] = attach + } + composed := &appComposed{media: make([]session.SubmissionMedia, 0, len(given.Media))} + for _, media := range given.Media { + attach, ok := staged[media.ID] + if !ok { + return nil, appwire.Errorf(appwire.CodeBadParams, "picture %q is not one of the message's attachments", media.ID) + } + if attach.view.Kind != appwire.AttachFile { + return nil, appwire.Errorf(appwire.CodeBadParams, "%s is not a file, and only a file can be sent as a picture", media.ID) + } + composed.media = append(composed.media, session.SubmissionMedia{ + ID: media.ID, Path: appAttachFull(workspace, attach.view.Path), Source: appSessionReference(media.Source), + }) + } + composed.annotation = appSessionAnnotation(given.Annotation) + if err := (session.Submission{Media: composed.media, Annotation: composed.annotation}).Check(); err != nil { + return nil, appEngineError(err) + } + return composed, nil +} + +func appSessionReference(ref *appwire.MediaReference) *session.MediaReference { + if ref == nil { + return nil + } + out := session.MediaReference{SourceID: ref.SourceID, Path: ref.Path} + if ref.AtMS != nil { + at := *ref.AtMS + out.AtMS = &at + } + return &out +} + +func appWireReference(ref *session.MediaReference) *appwire.MediaReference { + if ref == nil { + return nil + } + out := appwire.MediaReference{SourceID: ref.SourceID, Path: ref.Path} + if ref.AtMS != nil { + at := *ref.AtMS + out.AtMS = &at + } + return &out +} + +func appSessionPoint(p *appwire.Point) *session.Point { + if p == nil { + return nil + } + return &session.Point{X: p.X, Y: p.Y} +} + +func appWirePoint(p *session.Point) *appwire.Point { + if p == nil { + return nil + } + return &appwire.Point{X: p.X, Y: p.Y} +} + +// appSessionAnnotation is an annotation as the engine takes it. +func appSessionAnnotation(given *appwire.Annotation) *session.Annotation { + if given == nil { + return nil + } + out := &session.Annotation{ + Original: given.Original, Marked: given.Marked, MarksPath: given.MarksPath, ChangeOnlyMarked: given.ChangeOnlyMarked, + Marks: make([]session.AnnotationMark, 0, len(given.Marks)), + } + for _, mark := range given.Marks { + kept := session.AnnotationMark{Kind: mark.Kind, Note: mark.Note, At: appSessionPoint(mark.At), From: appSessionPoint(mark.From), To: appSessionPoint(mark.To)} + if mark.Shade != nil { + shade := session.Shade{BrushWidth: mark.Shade.BrushWidth, Points: make([]session.Point, 0, len(mark.Shade.Points))} + for _, p := range mark.Shade.Points { + shade.Points = append(shade.Points, session.Point{X: p.X, Y: p.Y}) + } + if mark.Shade.AreaPercent != nil { + area := *mark.Shade.AreaPercent + shade.AreaPercent = &area + } + kept.Shade = &shade + } + out.Marks = append(out.Marks, kept) + } + return out +} + +// appWireAnnotation is a recorded annotation as an entry shows it. +func appWireAnnotation(given *session.Annotation) *appwire.Annotation { + if given == nil { + return nil + } + out := &appwire.Annotation{ + Original: given.Original, Marked: given.Marked, MarksPath: given.MarksPath, ChangeOnlyMarked: given.ChangeOnlyMarked, + Marks: make([]appwire.AnnotationMark, 0, len(given.Marks)), + } + for _, mark := range given.Marks { + kept := appwire.AnnotationMark{Kind: mark.Kind, Note: mark.Note, At: appWirePoint(mark.At), From: appWirePoint(mark.From), To: appWirePoint(mark.To)} + if mark.Shade != nil { + shade := appwire.Shade{BrushWidth: mark.Shade.BrushWidth, Points: make([]appwire.Point, 0, len(mark.Shade.Points))} + for _, p := range mark.Shade.Points { + shade.Points = append(shade.Points, appwire.Point{X: p.X, Y: p.Y}) + } + if mark.Shade.AreaPercent != nil { + area := *mark.Shade.AreaPercent + shade.AreaPercent = &area + } + kept.Shade = &shade + } + out.Marks = append(out.Marks, kept) + } + return out +} + +// appAttachmentMedia is what the engine measured of one picture, as an +// attachment shows it. Unknown stays absent. +func appAttachmentMedia(media session.MessageMedia) *appwire.AttachmentMedia { + out := &appwire.AttachmentMedia{Mime: media.MIME, SHA256: media.SHA256, Role: media.Role, Source: appWireReference(media.Source)} + bytes := media.Bytes + out.Bytes = &bytes + if media.Width > 0 && media.Height > 0 { + width, height := media.Width, media.Height + out.Width, out.Height = &width, &height + } + return out +} + +// appWithMedia is attachments with the engine's measurements put on the ones +// that went as pictures. The views are copied: the slice belongs to a message +// that may be shown again. +func appWithMedia(views []appwire.Attachment, media []session.MessageMedia) []appwire.Attachment { + if len(media) == 0 || len(views) == 0 { + return views + } + out := append([]appwire.Attachment(nil), views...) + for index := range out { + for _, m := range media { + if m.ID == out[index].ID { + out[index].Media = appAttachmentMedia(m) + } + } + } + return out +} + +// appComposedFields puts a message's engine-kept facts on its entry: the +// durable id, how its pictures reached a model, the vision model that looked +// when it was not the talking one, and what was marked. +func appComposedFields(entry *appwire.Entry, mark *session.ComposedMark) { + if mark == nil { + return + } + entry.MessageID = mark.MessageID + if mark.Delivery != nil { + entry.Delivery = mark.Delivery.Mode + if mark.Delivery.Mode == session.DeliveryDescription { + entry.SeenBy = mark.Delivery.Model + } + } + entry.Annotation = appWireAnnotation(mark.Annotation) + entry.Attachments = appWithMedia(entry.Attachments, mark.Media) +} diff --git a/cmd/codeaf/appsubmission_test.go b/cmd/codeaf/appsubmission_test.go new file mode 100644 index 0000000000..2308b36cfe --- /dev/null +++ b/cmd/codeaf/appsubmission_test.go @@ -0,0 +1,288 @@ +package main + +import ( + "context" + "errors" + "os" + "path/filepath" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/session" +) + +// composingConversation is the fake conversation with the door a message that +// carries pictures goes through, as an engine that says it takes them has. +type composingConversation struct { + *fakeConversation + known bool + refuse error + composed []session.Submission +} + +func (c *composingConversation) ComposedKnown() bool { return c.known } + +func (c *composingConversation) SubmitComposed(ctx context.Context, message session.Submission) (session.SubmissionReceipt, <-chan session.Event, error) { + c.mu.Lock() + refuse := c.refuse + if refuse == nil { + c.composed = append(c.composed, message) + } + c.mu.Unlock() + if refuse != nil { + return session.SubmissionReceipt{}, nil, refuse + } + events, err := c.fakeConversation.Submit(ctx, message.Text) + if len(message.Media) == 0 { + return session.SubmissionReceipt{MessageID: "msg_0123456789abcdef01234567", Delivery: session.DeliveryText}, events, err + } + width, height := 40, 30 + return session.SubmissionReceipt{ + MessageID: "msg_0123456789abcdef01234567", Delivery: session.DeliveryDescription, SeenBy: "vendor/seer", + Media: []session.MessageMedia{ + {ID: "at1", Path: message.Media[0].Path, MIME: "image/png", Bytes: 99, SHA256: "ab", Width: width, Height: height, Role: session.MediaRoleOriginal}, + {ID: "at2", Path: message.Media[1].Path, MIME: "image/png", Bytes: 77, SHA256: "cd", Width: width, Height: height, Role: session.MediaRoleMarked}, + }, + }, events, err +} + +func (c *composingConversation) sent() []session.Submission { + c.mu.Lock() + defer c.mu.Unlock() + return append([]session.Submission(nil), c.composed...) +} + +func composingBackend(t *testing.T, conv appConversation) (*appBackend, *appRecorder, appwire.Agent, string, []string) { + t.Helper() + backend, rec, agent := markBackend(t, conv) + for _, name := range []string{"a.png", "b.png"} { + if err := os.WriteFile(filepath.Join(agent.Workspace, name), []byte("x"), 0o644); err != nil { + t.Fatal(err) + } + } + var ids []string + for _, name := range []string{"a.png", "b.png"} { + chip, err := backend.AddAttachment(agent.ID, appwire.Attachment{Kind: appwire.AttachFile, Path: name}, "") + if err != nil { + t.Fatal(err) + } + ids = append(ids, chip.ID) + } + return backend, rec, agent, agent.Workspace, ids +} + +// submissionFor is the wire's submission for two staged chips: the original and +// its marked copy, and three marks. +func submissionFor(ids []string) *appwire.MessageSubmission { + area := 6.0 + return &appwire.MessageSubmission{ + Media: []appwire.SubmissionMedia{{ID: ids[0], Source: &appwire.MediaReference{SourceID: "src_1"}}, {ID: ids[1]}}, + Annotation: &appwire.Annotation{ + Original: ids[0], Marked: ids[1], MarksPath: "/w/a.marks.json", ChangeOnlyMarked: true, + Marks: []appwire.AnnotationMark{ + {Kind: "pin", Note: "warmer", At: &appwire.Point{X: 24, Y: 18}}, + {Kind: "box", From: &appwire.Point{X: 70, Y: 56}, To: &appwire.Point{X: 82, Y: 76}}, + {Kind: "shade", Shade: &appwire.Shade{Points: []appwire.Point{{X: 1, Y: 2}}, BrushWidth: 4, AreaPercent: &area}}, + }, + }, + } +} + +// ONE SEND IS ONE MESSAGE: the engine is handed the pictures — resolved from +// what the person staged, never from a path the wire carried — and the marks +// together with the words, the entry shows the engine's id, delivery and +// measurements, and the chips go only once the message was kept. +func TestAppSendComposedHandsTheEngineOneMessageAndShowsWhatItKept(t *testing.T) { + conv := &composingConversation{fakeConversation: &fakeConversation{script: []session.Event{{Kind: session.EventTurnDone}}}, known: true} + backend, rec, agent, workspace, ids := composingBackend(t, conv) + + said := "make the sky warmer" + receipt, err := backend.SendComposed(agent.ID, appwire.AgentMessage{ + Text: "redo this", Said: &said, ClientID: "m-1", Attachments: ids, Submission: submissionFor(ids), + }) + if err != nil { + t.Fatalf("SendComposed: %v", err) + } + if receipt.MessageID != "msg_0123456789abcdef01234567" || receipt.Delivery != appwire.DeliveryDescription || receipt.SeenBy != "vendor/seer" || receipt.Turn == 0 { + t.Fatalf("receipt = %+v", receipt) + } + sent := conv.sent() + if len(sent) != 1 || len(conv.submissions) != 0 { + t.Fatalf("the engine took %d composed and %d plain messages, want exactly one composed", len(sent), len(conv.submissions)) + } + got := sent[0] + if got.Media[0].Path != filepath.Join(workspace, "a.png") || got.Media[1].Path != filepath.Join(workspace, "b.png") || got.Media[0].Source == nil || got.Media[0].Source.SourceID != "src_1" { + t.Fatalf("media = %+v", got.Media) + } + if got.Said == nil || *got.Said != said || len(got.Attachments) != 2 || got.Annotation == nil || len(got.Annotation.Marks) != 3 || + got.Annotation.Marks[2].Shade.BrushWidth != 4 || got.Annotation.Marks[1].To.X != 82 { + t.Fatalf("submission = %+v", got) + } + + user := entryWith(rec, t, func(e appwire.Entry) bool { return e.Kind == appwire.EntryUser }) + if user.MessageID != receipt.MessageID || user.Delivery != appwire.DeliveryDescription || user.SeenBy != "vendor/seer" || + user.Annotation == nil || len(user.Annotation.Marks) != 3 || user.Text != said || len(user.Attachments) != 2 { + t.Fatalf("entry = %+v", user) + } + media := user.Attachments[0].Media + if media == nil || media.Mime != "image/png" || *media.Width != 40 || *media.Height != 30 || media.Role != "original" || user.Attachments[1].Media.Role != "marked" { + t.Fatalf("attachment media = %+v", user.Attachments) + } + for _, id := range ids { + if staged(backend, agent.ID, id) { + t.Fatalf("chip %s was kept staged after the message was accepted", id) + } + } +} + +// A refusal — a description that does not fit, a file the engine will not carry, +// an engine that cannot take pictures — leaves every chip staged and the engine +// holding nothing, and the same ids then send once. +func TestAppRefusedPicturesLeaveEveryChipStaged(t *testing.T) { + conv := &composingConversation{fakeConversation: &fakeConversation{script: []session.Event{{Kind: session.EventTurnDone}}}, known: true} + backend, _, agent, _, ids := composingBackend(t, conv) + sendWith := func(mutate func(*appwire.MessageSubmission)) error { + sub := submissionFor(ids) + mutate(sub) + _, err := backend.SendComposed(agent.ID, appwire.AgentMessage{Text: "x", Attachments: ids, Submission: sub}) + return err + } + for name, mutate := range map[string]func(*appwire.MessageSubmission){ + "a coordinate over 100": func(s *appwire.MessageSubmission) { s.Annotation.Marks[0].At.X = 101 }, + "an inverted box": func(s *appwire.MessageSubmission) { s.Annotation.Marks[1].To.Y = 1 }, + "an unknown kind": func(s *appwire.MessageSubmission) { s.Annotation.Marks[0].Kind = "arrow" }, + "an unstaged picture": func(s *appwire.MessageSubmission) { s.Media[1].ID = "at99" }, + } { + if err := sendWith(mutate); wireCode(err) != appwire.CodeBadParams { + t.Errorf("%s answered %v, want bad_params", name, err) + } + } + if len(conv.sent()) != 0 || conv.submits != 0 { + t.Fatal("a refused message reached the engine") + } + + // The engine itself refuses a file it cannot carry. + conv.mu.Lock() + conv.refuse = errors.New("session: the message's description does not fit: b.png is not an image this surface can send") + conv.mu.Unlock() + if err := sendWith(func(*appwire.MessageSubmission) {}); err == nil { + t.Fatal("an engine refusal was swallowed") + } + for _, id := range ids { + if !staged(backend, agent.ID, id) { + t.Fatalf("chip %s was consumed by a refused message", id) + } + } + + // Once the engine accepts, the same chips send once and are gone. + conv.mu.Lock() + conv.refuse = nil + conv.mu.Unlock() + if err := sendWith(func(*appwire.MessageSubmission) {}); err != nil { + t.Fatalf("the retry: %v", err) + } + if len(conv.sent()) != 1 { + t.Fatalf("the engine took %d messages, want one", len(conv.sent())) + } + for _, id := range ids { + if staged(backend, agent.ID, id) { + t.Fatalf("chip %s stayed staged after it was sent", id) + } + } +} + +func TestAppPicturesAreNeverSentAsWordsAlone(t *testing.T) { + for name, conv := range map[string]appConversation{ + "a conversation with no door": &fakeConversation{}, + "an engine that has not said so": &composingConversation{fakeConversation: &fakeConversation{}, known: false}, + "an engine that has not said so 2": &composingConversation{fakeConversation: &fakeConversation{}}, + } { + t.Run(name, func(t *testing.T) { + backend, _, agent, _, ids := composingBackend(t, conv) + _, err := backend.SendComposed(agent.ID, appwire.AgentMessage{Text: "x", Attachments: ids, Submission: submissionFor(ids)}) + if wireCode(err) != appwire.CodeUnknownMethod { + t.Fatalf("answered %v, want unknown_method", err) + } + for _, id := range ids { + if !staged(backend, agent.ID, id) { + t.Fatalf("chip %s was consumed", id) + } + } + var texts []string + switch c := conv.(type) { + case *fakeConversation: + texts = sentTexts(c) + case *composingConversation: + texts = sentTexts(c.fakeConversation) + } + if len(texts) != 0 { + t.Fatalf("the engine was sent %q", texts) + } + }) + } +} + +func TestAppOnlyAFileCanBeSentAsAPicture(t *testing.T) { + conv := &composingConversation{fakeConversation: &fakeConversation{}, known: true} + backend, _, agent, _, ids := composingBackend(t, conv) + link, err := backend.AddAttachment(agent.ID, appwire.Attachment{Kind: appwire.AttachLink, URL: "https://example.com/a.png"}, "") + if err != nil { + t.Fatal(err) + } + all := append(append([]string(nil), ids...), link.ID) + sub := &appwire.MessageSubmission{Media: []appwire.SubmissionMedia{{ID: link.ID}}} + if _, err := backend.SendComposed(agent.ID, appwire.AgentMessage{Text: "x", Attachments: all, Submission: sub}); wireCode(err) != appwire.CodeBadParams { + t.Fatalf("a link sent as a picture answered %v", err) + } + if len(conv.sent()) != 0 { + t.Fatal("the engine heard of a link as a picture") + } +} + +// REOPENED, THE ENTRY SAYS THE SAME THINGS: the engine's id, delivery, the +// vision model that looked, the marks, and the measurements on each chip. +func TestAppReopenedMessageShowsWhatTheEngineKept(t *testing.T) { + width, height, bytes := 40, 30, int64(99) + _ = width + item := session.DisplayEntry{ + Role: "user", Text: "make the sky warmer", + Person: &session.PersonMark{ + Authored: true, + Attachments: []session.MessageAttachment{{ID: "at1", Kind: "file", Name: "a.png", Path: "/w/a.png"}, {ID: "at2", Kind: "file", Name: "b.png", Path: "/w/b.png"}}, + Composed: &session.ComposedMark{ + MessageID: "msg_x", Delivery: &session.MessageDelivery{Mode: session.DeliveryDescription, Model: "vendor/seer"}, + Media: []session.MessageMedia{{ID: "at1", MIME: "image/png", Bytes: bytes, Width: width, Height: height, Role: session.MediaRoleOriginal, SHA256: "ab"}}, + Annotation: &session.Annotation{Original: "at1", Marked: "at2", ChangeOnlyMarked: false, Marks: []session.AnnotationMark{{Kind: "pin", At: &session.Point{X: 1, Y: 2}}}}, + }, + }, + } + entries := appEntriesOf("a1", []session.DisplayEntry{item}, time.Now(), nil) + if len(entries) != 1 { + t.Fatalf("entries = %+v", entries) + } + user := entries[0] + if user.MessageID != "msg_x" || user.Delivery != appwire.DeliveryDescription || user.SeenBy != "vendor/seer" || + user.Annotation == nil || user.Annotation.ChangeOnlyMarked || user.Annotation.Marks[0].At.Y != 2 { + t.Fatalf("reopened entry = %+v", user) + } + if m := user.Attachments[0].Media; m == nil || *m.Width != 40 || m.Role != "original" || m.SHA256 != "ab" || user.Attachments[1].Media != nil { + t.Fatalf("reopened attachments = %+v", user.Attachments) + } +} + +// An explicit empty submission is a composed text message, not a nil presentation. +func TestAppComposedTextWithoutAttachmentsHasAReceipt(t *testing.T) { + conv := &composingConversation{fakeConversation: &fakeConversation{script: []session.Event{{Kind: session.EventTurnDone}}}, known: true} + backend, _, agent, _, _ := composingBackend(t, conv) + receipt, err := backend.SendComposed(agent.ID, appwire.AgentMessage{Text: "draw a tree", Submission: &appwire.MessageSubmission{}}) + if err != nil { + t.Fatal(err) + } + if receipt.MessageID == "" || receipt.Delivery != session.DeliveryText { + t.Fatalf("missing text receipt: %#v", receipt) + } + if sent := conv.sent(); len(sent) != 1 || sent[0].Text != "draw a tree" || len(sent[0].Media) != 0 { + t.Fatalf("not one plain composed message: %#v", sent) + } +} diff --git a/docs/changes/unreleased/0-app-media-submission.md b/docs/changes/unreleased/0-app-media-submission.md new file mode 100644 index 0000000000..dd4075556e --- /dev/null +++ b/docs/changes/unreleased/0-app-media-submission.md @@ -0,0 +1,12 @@ +--- +kind: added +title: codeaf app sends pictures and what was marked on them as one message +pr: 0 +surface: [engine] +invalidates: + - "Was true: a picture attached in the app reached the model only as the words `Attached file: `, and `SubmitImage` (real image parts) and `SubmitMessage` (the person's words and attachments kept on the line) were separate doors, so no message could be both a picture and a mark. Is true now: `agents.send` takes an optional `submission` naming which staged attachments go as pictures and a structured annotation (pins, boxes, shaded areas in 0–100 picture coordinates, ordered notes, change-only-marked); the engine reads the staged file once, sniffs its type, and sends it as an `image_url` part in the same single user message that carries the words, the attachment chips and the annotation." + - "Was true: a message had no identity beyond its place in the transcript. Is true now: a message sent with `submission` is given a `msg_` id when the engine accepts it, recorded on its journal line and returned in the `agents.send` answer, on the live user entry and on the entry after the conversation is reopened." + - "Was true: a blind talk model given a picture answered from the vision model with nothing recorded about who looked. Is true now: the message records `delivery` as `description` and the vision model that looked (`seen_by`), or `image` when the talking model received the pictures." + - "Was true: every picture sent or replayed sat in the live transcript as its whole base64 string for the rest of the session and was re-sent on every request, so opening a conversation with 200 pictures read and encoded every file. Is true now: the transcript holds a small immutable reference (path, digest, type, size) per picture and a replay opens no file; the bytes are read, checked against the recorded digest and encoded only by the request that carries them, for the newest pictures within 40MB decoded and 64 pictures per request. A picture is charged against that budget before its file is touched and keeps the charge when the file turns out gone or changed, so a history of missing pictures costs at most 64 attempts. Older pictures appear in that request alone as the placeholder sentence naming their path, and the conversation still holds every reference. A picture whose file moved or changed since it was accepted is that placeholder, not a different picture." + - "Unchanged and still true: a conversation hosted on an engine that has not said it takes pictures (`composedMessages` in its welcome, `messages.media` in hello) refuses the whole message with unknown_method rather than sending its words alone; a refusal of any kind leaves every staged attachment staged." +--- diff --git a/internal/appwire/convo.go b/internal/appwire/convo.go index 7f72e1076c..121f3ac848 100644 --- a/internal/appwire/convo.go +++ b/internal/appwire/convo.go @@ -84,6 +84,9 @@ type Attachment struct { Add *int `json:"add,omitempty"` Del *int `json:"del,omitempty"` State string `json:"state,omitempty"` + // Media is what the engine measured of a picture it sent with the message + // (media_submission.go); absent on every attachment sent as words. + Media *AttachmentMedia `json:"media,omitempty"` } // MessageRead is one read of a reference the app performed to write a @@ -130,6 +133,9 @@ type AgentMessage struct { ClientID string Attachments []string Reads []MessageRead + // Submission names which staged attachments are pictures and what was + // marked on them (media_submission.go); only agents.send carries one. + Submission *MessageSubmission } // MessageSender is the optional engine half of agents.send and crews.message @@ -184,6 +190,9 @@ func withMessageReadsFeature(hello Hello, e Engine) Hello { if _, ok := e.(MessageQueuer); ok { hello = withFeature(hello, FeatureQueueReads) } + if _, ok := e.(ComposedSender); ok { + hello = withFeature(hello, FeatureMessageMedia) + } return hello } @@ -416,12 +425,17 @@ type sayTo struct { ClientID string `json:"client_id"` Attachments []string `json:"attachments"` Reads []MessageRead `json:"reads"` + // Submission is refused: a correction or a queued message carries no pictures. + Submission *MessageSubmission `json:"submission,omitempty"` } func checkSay(p sayTo, name string) (sayTo, error) { if strings.TrimSpace(p.ID) == "" || strings.TrimSpace(p.Text) == "" { return p, Errorf(CodeBadParams, "%s needs an id and some text", name) } + if err := refuseSubmission(p.Submission, name); err != nil { + return p, err + } return p, refuseAttachments(p.Attachments, name) } @@ -448,6 +462,8 @@ type queueAt struct { Attachments []string `json:"attachments"` Reads []MessageRead `json:"reads"` To *int `json:"to"` + // Submission is refused: a queued message carries no pictures. + Submission *MessageSubmission `json:"submission,omitempty"` } // queuer is the engine's door for a queued message's said and reads, or a @@ -464,7 +480,7 @@ func checkQueueAt(p queueAt, name string) (queueAt, error) { if strings.TrimSpace(p.Agent) == "" || strings.TrimSpace(p.ID) == "" { return p, Errorf(CodeBadParams, "%s needs an agent and an id", name) } - return p, nil + return p, refuseSubmission(p.Submission, name) } // talkTask is one task verb that takes only an id, served by the Worker. diff --git a/internal/appwire/crews.go b/internal/appwire/crews.go index 3134ca19e5..328349bf05 100644 --- a/internal/appwire/crews.go +++ b/internal/appwire/crews.go @@ -384,6 +384,8 @@ type CrewMessage struct { ClientID string `json:"client_id"` Attachments []string `json:"attachments,omitempty"` Reads []MessageRead `json:"reads,omitempty"` + // Submission is refused: a crew message carries no pictures. + Submission *MessageSubmission `json:"submission,omitempty"` } // Crewer is the engine's half of crews. @@ -566,6 +568,9 @@ var crewMethods = map[string]method{ if err := CheckAttachIDs(p.Attachments); err != nil { return nil, err } + if err := refuseSubmission(p.Submission, "crews.message"); err != nil { + return nil, err + } entry, err := c.CrewMessage(p) if err != nil { return nil, err diff --git a/internal/appwire/media_submission.go b/internal/appwire/media_submission.go new file mode 100644 index 0000000000..97c7da2884 --- /dev/null +++ b/internal/appwire/media_submission.go @@ -0,0 +1,243 @@ +package appwire + +import ( + "math" + "strings" + "unicode/utf8" +) + +// ── a message that carries pictures, and what the person marked on them ────── +// +// agents.send gains one optional field, `submission`. It names which of the +// message's staged attachments are PICTURES the model must be sent as pictures +// (every other attachment stays the words it always was), and carries what the +// person marked on them as structured facts. It never carries a path: a picture +// is named by its staged attachment id, so the engine reads only a file the +// person staged, and nothing in a message can point it at another. +// +// THE SHAPE IS CHECKED HERE, THE MEANING IS THE ENGINE'S. Counts, lengths and +// numbers are refused as bad_params before the engine hears of the message, so a +// refusal leaves every staged chip where it was. Whether a file is a picture the +// engine can carry is the engine's to say, for the same reason. + +// FeatureMessageMedia is hello's word for an engine that is a [ComposedSender]: +// agents.send keeps a submission's pictures and annotation on the person's +// message. +const FeatureMessageMedia = "messages.media" + +// The bounds of a submission's shape. The engine's own bounds are the same +// numbers, spelled again there because the engine is also a library. +const ( + MaxSubmissionMarks = 64 + MaxSubmissionPoint = 512 + MaxSubmissionNote = 2000 + maxSubmissionPath = 4096 + // MaxPictureCoordinate is the far edge of picture coordinates: 0 to this on + // both axes whatever the picture's size in pixels. + MaxPictureCoordinate = 100.0 +) + +// MediaReference says where a picture came from, as the app knew it. Every field +// is optional. +type MediaReference struct { + SourceID string `json:"source_id,omitempty"` + Path string `json:"path,omitempty"` + AtMS *int64 `json:"at_ms,omitempty"` +} + +// SubmissionMedia names one staged attachment to be sent as a picture. +type SubmissionMedia struct { + ID string `json:"id"` + Source *MediaReference `json:"source,omitempty"` +} + +// Point is a position on a picture, 0 to 100 on both axes. +type Point struct { + X float64 `json:"x"` + Y float64 `json:"y"` +} + +// Shade is a painted shape: the path of the brush and its width. AreaPercent is +// a derived fact, never a replacement for the points. +type Shade struct { + Points []Point `json:"points"` + BrushWidth float64 `json:"brush_width"` + AreaPercent *float64 `json:"area_percent,omitempty"` +} + +// AnnotationMark is one mark; its place in the list is its number. Kind is pin +// (At), box (From and To) or shade (Shade). An empty Note means "change this". +type AnnotationMark struct { + Kind string `json:"kind" enum:"pin,box,shade"` + Note string `json:"note,omitempty"` + At *Point `json:"at,omitempty"` + From *Point `json:"from,omitempty"` + To *Point `json:"to,omitempty"` + Shade *Shade `json:"shade,omitempty"` +} + +// Annotation is what a person marked on a picture. Original and Marked name +// attachments of the same message. MarksPath is recorded text the engine never +// opens. +type Annotation struct { + Original string `json:"original"` + Marked string `json:"marked,omitempty"` + MarksPath string `json:"marks_path,omitempty"` + ChangeOnlyMarked bool `json:"change_only_marked"` + Marks []AnnotationMark `json:"marks"` +} + +// MessageSubmission is the pictures of a message and what was marked on them. +type MessageSubmission struct { + Media []SubmissionMedia `json:"media"` + Annotation *Annotation `json:"annotation,omitempty"` +} + +// AttachmentMedia is what the engine measured of a picture it was sent, on the +// attachment of the entry that carried it. Every field is omitted when unknown. +type AttachmentMedia struct { + Mime string `json:"mime,omitempty"` + Bytes *int64 `json:"bytes,omitempty"` + Width *int `json:"width,omitempty"` + Height *int `json:"height,omitempty"` + SHA256 string `json:"sha256,omitempty"` + Role string `json:"role,omitempty" enum:"original,marked"` + Source *MediaReference `json:"source,omitempty"` +} + +// The delivery words, as the engine records them. +const ( + DeliveryText = "text" + DeliveryImage = "image" + DeliveryDescription = "description" +) + +// TurnReceipt is what a composed send answers: the turn, the durable message id +// minted when the engine accepted the message, how its pictures reached a model, +// and — for a description — the vision model that actually looked. +type TurnReceipt struct { + Turn uint64 + MessageID string + Delivery string + SeenBy string + Queued bool +} + +// ComposedSender is the optional engine half of agents.send carrying a +// submission. An engine that is not one answers such a send unknown_method +// before anything is read, and every staged attachment stays staged. +type ComposedSender interface { + SendComposed(agent string, message AgentMessage) (TurnReceipt, error) +} + +// Check is the shape of a submission against the attachments the send names. The +// zero check is a nil submission. +func (s *MessageSubmission) Check(attachments []string) error { + if s == nil { + return nil + } + if len(s.Media) == 0 { + return Errorf(CodeBadParams, "a submission names at least one picture") + } + if len(s.Media) > MaxAttachments { + return Errorf(CodeBadParams, "a message carries at most %d pictures, not %d", MaxAttachments, len(s.Media)) + } + staged := make(map[string]bool, len(attachments)) + for _, id := range attachments { + staged[id] = true + } + named := make(map[string]bool, len(s.Media)) + for _, media := range s.Media { + if media.ID == "" || !staged[media.ID] { + return Errorf(CodeBadParams, "picture %q is not one of the message's attachments", media.ID) + } + if named[media.ID] { + return Errorf(CodeBadParams, "picture %s is named twice", media.ID) + } + named[media.ID] = true + if err := checkMediaReference(media.Source); err != nil { + return err + } + } + if s.Annotation == nil { + return nil + } + return checkAnnotationShape(s.Annotation, named) +} + +func checkMediaReference(ref *MediaReference) error { + if ref == nil { + return nil + } + if len(ref.SourceID) > maxSubmissionPath || len(ref.Path) > maxSubmissionPath || !utf8.ValidString(ref.SourceID+ref.Path) { + return Errorf(CodeBadParams, "a picture's source is not text of at most %d bytes", maxSubmissionPath) + } + if ref.AtMS != nil && *ref.AtMS < 0 { + return Errorf(CodeBadParams, "a picture's source names a negative moment") + } + return nil +} + +func okCoordinate(v, limit float64) bool { + return !math.IsNaN(v) && !math.IsInf(v, 0) && v >= 0 && v <= limit +} + +func okPoint(p *Point) bool { + return p != nil && okCoordinate(p.X, MaxPictureCoordinate) && okCoordinate(p.Y, MaxPictureCoordinate) +} + +func checkAnnotationShape(a *Annotation, named map[string]bool) error { + if !named[a.Original] { + return Errorf(CodeBadParams, "the annotation's original is not one of the message's pictures") + } + if a.Marked != "" && (!named[a.Marked] || a.Marked == a.Original) { + return Errorf(CodeBadParams, "the annotation's marked copy is not a second picture of the message") + } + if len(a.MarksPath) > maxSubmissionPath || !utf8.ValidString(a.MarksPath) || strings.ContainsRune(a.MarksPath, 0) { + return Errorf(CodeBadParams, "the annotation's marks file is not a path of at most %d bytes", maxSubmissionPath) + } + if len(a.Marks) == 0 || len(a.Marks) > MaxSubmissionMarks { + return Errorf(CodeBadParams, "an annotation names 1 to %d marks, not %d", MaxSubmissionMarks, len(a.Marks)) + } + for index, mark := range a.Marks { + n := index + 1 + if len(mark.Note) > MaxSubmissionNote || !utf8.ValidString(mark.Note) || strings.ContainsRune(mark.Note, 0) { + return Errorf(CodeBadParams, "mark %d's note is not text of at most %d bytes", n, MaxSubmissionNote) + } + switch mark.Kind { + case "pin": + if !okPoint(mark.At) || mark.From != nil || mark.To != nil || mark.Shade != nil { + return Errorf(CodeBadParams, "mark %d is not a pin at a point from 0 to %.0f", n, MaxPictureCoordinate) + } + case "box": + if !okPoint(mark.From) || !okPoint(mark.To) || mark.At != nil || mark.Shade != nil || !(mark.From.X < mark.To.X && mark.From.Y < mark.To.Y) { + return Errorf(CodeBadParams, "mark %d is not a box inside 0 to %.0f with its top-left corner above and left of its bottom-right", n, MaxPictureCoordinate) + } + case "shade": + shade := mark.Shade + if shade == nil || mark.At != nil || mark.From != nil || mark.To != nil || + len(shade.Points) == 0 || len(shade.Points) > MaxSubmissionPoint || + !okCoordinate(shade.BrushWidth, MaxPictureCoordinate) || shade.BrushWidth == 0 || + (shade.AreaPercent != nil && !okCoordinate(*shade.AreaPercent, 100)) { + return Errorf(CodeBadParams, "mark %d is not a shaded area of 1 to %d points and a brush from 0 to %.0f", n, MaxSubmissionPoint, MaxPictureCoordinate) + } + for i := range shade.Points { + if !okPoint(&shade.Points[i]) { + return Errorf(CodeBadParams, "mark %d has a point outside 0 to %.0f", n, MaxPictureCoordinate) + } + } + default: + return Errorf(CodeBadParams, "mark %d is a %q, and a mark is a pin, a box or a shade", n, mark.Kind) + } + } + return nil +} + +// refuseSubmission is a correction, a queued message or a crew message carrying +// a submission, refused rather than run without the pictures it names. +func refuseSubmission(s *MessageSubmission, name string) error { + if s != nil { + return Errorf(CodeBadParams, "%s does not carry pictures; send them with agents.send", name) + } + return nil +} diff --git a/internal/appwire/media_submission_test.go b/internal/appwire/media_submission_test.go new file mode 100644 index 0000000000..34446282b0 --- /dev/null +++ b/internal/appwire/media_submission_test.go @@ -0,0 +1,124 @@ +package appwire + +import ( + "encoding/json" + "math" + "strings" + "testing" +) + +// composedEngine is an Engine that is a ComposedSender. +type composedEngine struct { + fakeEngine + agent string + heard AgentMessage + back TurnReceipt +} + +func (c *composedEngine) SendComposed(agent string, message AgentMessage) (TurnReceipt, error) { + c.agent, c.heard = agent, message + return c.back, nil +} + +const goodSubmission = `{"id":"a1","text":"redo","attachments":["at1","at2"],"submission":{"media":[{"id":"at1","source":{"source_id":"src_1","at_ms":3000}},{"id":"at2"}], +"annotation":{"original":"at1","marked":"at2","marks_path":"/w/m.json","change_only_marked":true, +"marks":[{"kind":"pin","note":"warmer","at":{"x":24,"y":18}},{"kind":"box","from":{"x":70,"y":56},"to":{"x":82,"y":76}},{"kind":"shade","shade":{"points":[{"x":1,"y":2}],"brush_width":4,"area_percent":6}}]}}}` + +func TestAgentsSendCarriesASubmissionAndAnswersTheReceipt(t *testing.T) { + eng := &composedEngine{back: TurnReceipt{Turn: 4, MessageID: "msg_x", Delivery: DeliveryDescription, SeenBy: "v/seer", Queued: true}} + out, err := methods["agents.send"].call(eng, json.RawMessage(goodSubmission)) + if err != nil { + t.Fatalf("agents.send: %v", err) + } + raw, _ := json.Marshal(out) + for _, want := range []string{`"turn":4`, `"message_id":"msg_x"`, `"delivery":"description"`, `"seen_by":"v/seer"`, `"queued":true`} { + if !strings.Contains(string(raw), want) { + t.Fatalf("result %s lacks %s", raw, want) + } + } + got := eng.heard.Submission + if got == nil || len(got.Media) != 2 || got.Annotation == nil || len(got.Annotation.Marks) != 3 || + got.Media[0].Source == nil || *got.Media[0].Source.AtMS != 3000 || !got.Annotation.ChangeOnlyMarked { + t.Fatalf("engine heard %+v", eng.heard) + } +} + +func TestASubmissionToAnEngineWithoutADoorIsRefusedUnknownMethod(t *testing.T) { + _, err := methods["agents.send"].call(&sendEngine{}, json.RawMessage(goodSubmission)) + if codeOf(err) != CodeUnknownMethod { + t.Fatalf("answered %v, want unknown_method", err) + } +} + +func TestAnInvalidSubmissionIsBadParamsBeforeTheEngineHearsOfIt(t *testing.T) { + cases := map[string]func(*MessageSubmission){ + "no pictures": func(s *MessageSubmission) { s.Media = nil }, + "unstaged picture": func(s *MessageSubmission) { s.Media[0].ID = "at9" }, + "picture twice": func(s *MessageSubmission) { s.Media[1].ID = "at1" }, + "original not a media": func(s *MessageSubmission) { s.Annotation.Original = "at9" }, + "marked is original": func(s *MessageSubmission) { s.Annotation.Marked = "at1" }, + "no marks": func(s *MessageSubmission) { s.Annotation.Marks = nil }, + "nan": func(s *MessageSubmission) { s.Annotation.Marks[0].At.X = math.NaN() }, + "over 100": func(s *MessageSubmission) { s.Annotation.Marks[0].At.Y = 101 }, + "inverted box": func(s *MessageSubmission) { s.Annotation.Marks[1].To.X = 1 }, + "unknown kind": func(s *MessageSubmission) { s.Annotation.Marks[0].Kind = "arrow" }, + "shade without brush": func(s *MessageSubmission) { s.Annotation.Marks[2].Shade.BrushWidth = 0 }, + "note too long": func(s *MessageSubmission) { s.Annotation.Marks[0].Note = strings.Repeat("x", MaxSubmissionNote+1) }, + "too many marks": func(s *MessageSubmission) { + s.Annotation.Marks = make([]AnnotationMark, MaxSubmissionMarks+1) + for i := range s.Annotation.Marks { + s.Annotation.Marks[i] = AnnotationMark{Kind: "pin", At: &Point{}} + } + }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + var p struct { + Submission MessageSubmission `json:"submission"` + } + if err := json.Unmarshal([]byte(goodSubmission), &p); err != nil { + t.Fatal(err) + } + mutate(&p.Submission) + if err := p.Submission.Check([]string{"at1", "at2"}); codeOf(err) != CodeBadParams { + t.Fatalf("Check answered %v, want bad_params", err) + } + }) + } + // Through the method, the engine is never reached. + eng := &composedEngine{} + bad := strings.Replace(goodSubmission, `"x":24`, `"x":240`, 1) + if _, err := methods["agents.send"].call(eng, json.RawMessage(bad)); codeOf(err) != CodeBadParams || eng.agent != "" { + t.Fatalf("answered %v, engine heard %q", err, eng.agent) + } +} + +func TestThePathsOtherDoorsRefuseASubmission(t *testing.T) { + sub := `"submission":{"media":[{"id":"at1"}]}` + for name, call := range map[string]struct{ method, params string }{ + "steer": {"agents.steer", `{"id":"a1","text":"x",` + sub + `}`}, + "queue": {"agents.queue", `{"id":"a1","text":"x",` + sub + `}`}, + "queue.edit": {"queue.edit", `{"agent":"a1","id":"q1","text":"x",` + sub + `}`}, + "crew": {"crews.message", `{"crew":"c1","text":"x",` + sub + `}`}, + } { + t.Run(name, func(t *testing.T) { + if _, err := methods[call.method].call(&sendEngine{}, json.RawMessage(call.params)); codeOf(err) != CodeBadParams { + t.Fatalf("%s answered %v, want bad_params", call.method, err) + } + }) + } +} + +func TestHelloAdvertisesPicturesOnlyForAnEngineThatTakesThem(t *testing.T) { + has := func(h Hello) bool { + for _, f := range h.Features { + if f == FeatureMessageMedia { + return true + } + } + return false + } + if !has(withMessageReadsFeature(Hello{}, &composedEngine{})) || has(withMessageReadsFeature(Hello{}, &sendEngine{})) { + t.Fatal("the messages.media feature does not follow the engine's door") + } +} diff --git a/internal/appwire/server.go b/internal/appwire/server.go index b775c00b27..f389711a5d 100644 --- a/internal/appwire/server.go +++ b/internal/appwire/server.go @@ -354,7 +354,7 @@ var methods = map[string]method{ if err != nil { return nil, err } - return &TurnResult{turn}, nil + return &TurnResult{Turn: turn}, nil }), "models.list": typed(func(e Engine, _ struct{}) (*ModelsResult, error) { models, err := e.ListModels() @@ -395,6 +395,9 @@ var methods = map[string]method{ ClientID string `json:"client_id"` Attachments []string `json:"attachments"` Reads []MessageRead `json:"reads"` + // Submission names which attachments go as pictures and what was marked + // on them (media_submission.go). + Submission *MessageSubmission `json:"submission,omitempty"` }) (*TurnResult, error) { if strings.TrimSpace(p.ID) == "" { return nil, Errorf(CodeBadParams, "agents.send needs an id") @@ -405,6 +408,26 @@ var methods = map[string]method{ } var turn uint64 var err error + if p.Submission != nil { + // THE PICTURES ARE SENT WITH THE MESSAGE OR THE MESSAGE IS NOT SENT: an + // engine with no door for them refuses before it reads anything, and + // the staged attachments stay staged. + c, ok := e.(ComposedSender) + if !ok { + return nil, Errorf(CodeUnknownMethod, "this engine does not serve agents.send with pictures") + } + if err := CheckAttachIDs(p.Attachments); err != nil { + return nil, err + } + if err := p.Submission.Check(p.Attachments); err != nil { + return nil, err + } + receipt, err := c.SendComposed(p.ID, AgentMessage{Text: p.Text, Said: p.Said, ClientID: p.ClientID, Attachments: p.Attachments, Reads: p.Reads, Submission: p.Submission}) + if err != nil { + return nil, err + } + return &TurnResult{Turn: receipt.Turn, MessageID: receipt.MessageID, Delivery: receipt.Delivery, SeenBy: receipt.SeenBy, Queued: receipt.Queued}, nil + } if p.Said != nil || len(p.Reads) > 0 { // WHAT THE MESSAGE CARRIED IS SENT WITH IT OR NOT AT ALL: an engine // with no door for it refuses, rather than running the words and @@ -432,7 +455,7 @@ var methods = map[string]method{ if err != nil { return nil, err } - return &TurnResult{turn}, nil + return &TurnResult{Turn: turn}, nil }), "agents.stop": typed(func(e Engine, p IDParams) (*Empty, error) { id, err := required("id", p.ID) @@ -538,6 +561,12 @@ type ( } TurnResult struct { Turn uint64 `json:"turn"` + // MessageID, Delivery, SeenBy and Queued answer an agents.send that + // carried a submission (media_submission.go) and are otherwise absent. + MessageID string `json:"message_id,omitempty"` + Delivery string `json:"delivery,omitempty" enum:"text,image,description"` + SeenBy string `json:"seen_by,omitempty"` + Queued bool `json:"queued,omitempty"` } ModelsResult struct { Models []Model `json:"models"` diff --git a/internal/appwire/wire.go b/internal/appwire/wire.go index af73f51bd4..914e633759 100644 --- a/internal/appwire/wire.go +++ b/internal/appwire/wire.go @@ -345,6 +345,15 @@ type Entry struct { // were written from, as the app found them when it sent it (convo.go's // [MessageRead]); absent on every message sent without them. Reads []MessageRead `json:"reads,omitempty"` + // MessageID is the durable identity the engine minted when it accepted a + // person's message that carried pictures; Delivery says how the pictures + // reached a model and SeenBy names the vision model that looked when it was + // not the one talking; Annotation is what the person marked on them + // (media_submission.go). All absent on every other entry. + MessageID string `json:"message_id,omitempty"` + Delivery string `json:"delivery,omitempty" enum:"text,image,description"` + SeenBy string `json:"seen_by,omitempty"` + Annotation *Annotation `json:"annotation,omitempty"` // Op is a durable fact the conversation's record keeps beside its // messages, said as the fact and never as words: the app words it. Op *EntryOp `json:"op,omitempty"` diff --git a/internal/remote/client.go b/internal/remote/client.go index 707b17ae47..2a1c40a408 100644 --- a/internal/remote/client.go +++ b/internal/remote/client.go @@ -1740,18 +1740,54 @@ func (a *Agent) Submit(ctx context.Context, text string) (<-chan session.Event, // words ([session.Agent.SubmitMessage]): it travels on the same call, so the // engine records the words and the mark on one line or neither. func (a *Agent) SubmitMessage(ctx context.Context, message session.Submission) (<-chan session.Event, error) { + if argsOf(message).composed() { + _, events, err := a.SubmitComposed(ctx, message) + return events, err + } if argsOf(message).marked() && !a.MessageMarksKnown() { return nil, errors.New("engine: this engine cannot keep a message's description; update it before sending") } return a.open(ctx, MethodSubmit, argsOf(message)) } +// SubmitComposed is Submit for a message that carries pictures and what the +// person marked on them ([session.Agent.SubmitComposed]). The engine reads the +// files, on ITS machine, so the paths must mean something there; an engine that +// has not said it takes them is refused BEFORE ANYTHING IS SENT, because run as +// its words alone the pictures would be silently dropped. +func (a *Agent) SubmitComposed(ctx context.Context, message session.Submission) (session.SubmissionReceipt, <-chan session.Event, error) { + if !a.ComposedKnown() { + return session.SubmissionReceipt{}, nil, errors.New("engine: this engine cannot take pictures with a message; update it before sending") + } + payload, err := a.c.call(ctx, MethodSubmit, argsOf(message)) + if err != nil { + return session.SubmissionReceipt{}, nil, err + } + var ref StreamRef + if err := json.Unmarshal(payload, &ref); err != nil { + return session.SubmissionReceipt{}, nil, err + } + if ref.Receipt == nil { + return session.SubmissionReceipt{}, nil, errors.New("engine: the engine took the message without saying how") + } + return *ref.Receipt, a.c.stream(ref.Stream).events(), nil +} + +// ComposedKnown is what the engine said in its welcome about taking a message +// that carries pictures. +func (a *Agent) ComposedKnown() bool { + return a.welcomeSays(func(w Welcome) bool { return w.ComposedMessages }) +} + // SteerMessage is Steer with what the door knows about the correction beyond // its words ([session.Agent.SteerMessage]). An engine that has not said it keeps // a correction's mark is refused BEFORE ANYTHING IS SENT: run as its words // alone it would lose what the person read, and the correction would already be // in the turn. A submission with no mark is Steer. func (a *Agent) SteerMessage(message session.Submission) (<-chan session.Event, error) { + if argsOf(message).composed() { + return nil, errors.New("engine: a correction carries no pictures; send them as a message") + } if !argsOf(message).marked() { return a.Steer(message.Text) } @@ -1766,6 +1802,9 @@ func (a *Agent) SteerMessage(message session.Submission) (<-chan session.Event, // to an engine that has not said it keeps a follow-up's mark. Its stream is // remembered for the take-back exactly as a plain follow-up's is. func (a *Agent) FollowUpMessage(message session.Submission) (<-chan session.Event, error) { + if argsOf(message).composed() { + return nil, errors.New("engine: a queued message carries no pictures; send them as a message") + } if !argsOf(message).marked() { return a.FollowUp(message.Text) } diff --git a/internal/remote/composed_test.go b/internal/remote/composed_test.go new file mode 100644 index 0000000000..7dc4723065 --- /dev/null +++ b/internal/remote/composed_test.go @@ -0,0 +1,92 @@ +package remote + +import ( + "context" + "encoding/json" + "io" + "reflect" + "testing" + + "github.com/Agent-Field/codeaf/internal/session" +) + +// composedAgent is the scripted agent with the door pictures are sent through. +type composedAgent struct { + *fakeAgent + submissions []session.Submission +} + +func (c *composedAgent) SubmitComposed(ctx context.Context, message session.Submission) (session.SubmissionReceipt, <-chan session.Event, error) { + c.mu.Lock() + c.submissions = append(c.submissions, message) + c.mu.Unlock() + events, err := c.fakeAgent.Submit(ctx, message.Text) + return session.SubmissionReceipt{MessageID: "msg_1", Delivery: session.DeliveryImage, Media: []session.MessageMedia{{ID: "at1", Width: 4}}}, events, err +} + +// PICTURES AND THEIR MARKS CROSS THE WIRE ON THE SAME CALL AS THE WORDS, and the +// engine's receipt comes back on the answer that names the stream. +func TestSubmitCarriesPicturesAndAnnotationAndAnswersTheReceipt(t *testing.T) { + agent := &composedAgent{fakeAgent: &fakeAgent{model: "m"}} + s := &server{out: io.Discard, session: NewSession(&Engine{Agent: agent}, false)} + want := session.Submission{ + Text: "redo this", + Media: []session.SubmissionMedia{{ID: "at1", Path: "/w/a.png"}}, + Annotation: &session.Annotation{ + Original: "at1", ChangeOnlyMarked: true, + Marks: []session.AnnotationMark{{Kind: "pin", At: &session.Point{X: 1, Y: 2}}}, + }, + } + out, err := s.invoke(Frame{Kind: "call", ID: 1, Method: MethodSubmit, Payload: mustJSON(argsOf(want))}) + if err != nil { + t.Fatal(err) + } + var ref StreamRef + if err := json.Unmarshal(out, &ref); err != nil || ref.Stream == 0 || ref.Receipt == nil || ref.Receipt.MessageID != "msg_1" || len(ref.Receipt.Media) != 1 { + t.Fatalf("answer = %s (%v)", out, err) + } + if len(agent.submissions) != 1 || !reflect.DeepEqual(agent.submissions[0].Media, want.Media) || !reflect.DeepEqual(agent.submissions[0].Annotation, want.Annotation) { + t.Fatalf("the door was handed %+v", agent.submissions) + } + if !composedKnown(agent) || composedKnown(&fakeAgent{}) { + t.Fatal("the welcome does not distinguish engines that take pictures") + } +} + +func TestAnEngineWithoutTheDoorRefusesPicturesAndRunsNothing(t *testing.T) { + plain := &markedAgent{fakeAgent: &fakeAgent{model: "m"}} + s := &server{out: io.Discard, session: NewSession(&Engine{Agent: plain}, false)} + args := SubmitArgs{Text: "x", Media: []session.SubmissionMedia{{ID: "at1", Path: "/w/a.png"}}} + if _, err := s.invoke(Frame{Kind: "call", ID: 1, Method: MethodSubmit, Payload: mustJSON(args)}); err == nil { + t.Fatal("an engine without the door took a message with pictures") + } + if len(plain.sent) != 0 || len(plain.submissions) != 0 { + t.Fatal("pictures were run as words alone") + } + // A correction and a queued message carry no pictures, whatever the engine. + for _, method := range []string{MethodSteer, MethodFollowUp} { + if _, err := s.invoke(Frame{Kind: "call", ID: 2, Method: method, Payload: mustJSON(args)}); err == nil { + t.Fatalf("%s accepted pictures", method) + } + } +} + +func TestAnOlderHostIsRefusedBeforeAComposedMessageCrossesTheWire(t *testing.T) { + client := &Client{welcome: Welcome{Version: Version, MessageMarks: true}} + agent := &Agent{c: client} + message := session.Submission{Text: "x", Media: []session.SubmissionMedia{{ID: "at1", Path: "/w/a.png"}}} + if _, _, err := agent.SubmitComposed(t.Context(), message); err == nil { + t.Fatal("an older host was sent pictures") + } + if _, err := agent.SubmitMessage(t.Context(), message); err == nil { + t.Fatal("SubmitMessage dropped pictures for an older host") + } + if _, err := agent.SteerMessage(message); err == nil { + t.Fatal("a correction carried pictures") + } + if client.made.Load() != 0 { + t.Fatal("a refused message crossed the wire") + } +} + +var _ composedDoor = (*session.Agent)(nil) diff --git a/internal/remote/server.go b/internal/remote/server.go index bf5b9694d8..53dbc23c16 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -1202,6 +1202,16 @@ func memoryCommandsKnown(agent any) bool { return ok && door.Remembers() } +// composedDoor is the agent's door for a Submit that carries pictures. +type composedDoor interface { + SubmitComposed(context.Context, session.Submission) (session.SubmissionReceipt, <-chan session.Event, error) +} + +func composedKnown(agent any) bool { + _, ok := agent.(composedDoor) + return ok +} + func messageMarksKnown(agent any) bool { _, ok := agent.(interface { SubmitMessage(context.Context, session.Submission) (<-chan session.Event, error) @@ -1255,6 +1265,7 @@ func (sess *Session) welcomeLocked(s *server) Welcome { } return Welcome{ MessageMarks: messageMarksKnown(sess.agent), + ComposedMessages: composedKnown(sess.agent), SteerMarks: steerMarksKnown(sess.agent), FollowUpMarks: followUpMarksKnown(sess.agent), Version: Version, @@ -2600,6 +2611,22 @@ func (s *server) invoke(call Frame) (out json.RawMessage, err error) { SubmitMessage(context.Context, session.Submission) (<-chan session.Event, error) }) hasMark := args.marked() + if args.composed() { + // PICTURES ARE SENT WITH THE MESSAGE OR THE MESSAGE IS NOT SENT, and the + // receipt rides the answer that names the stream. + composer, ok := agent.(composedDoor) + if !ok { + return nil, errors.New("engine: this session cannot take pictures with a message; update the engine before sending it") + } + receipt, events, err := composer.SubmitComposed(context.Background(), args.submission()) + if err != nil { + return nil, err + } + if _, err := s.stream(MethodSubmit, args.Text, events, nil); err != nil { + return nil, err + } + return json.Marshal(StreamRef{Stream: s.pending.id, Receipt: &receipt}) + } if hasMark && !marked { return nil, errors.New("engine: this session cannot keep a message's description; update the engine before sending it") } @@ -2632,6 +2659,9 @@ func (s *server) invoke(call Frame) (out json.RawMessage, err error) { // is queued; its stream is registered for the take-back below exactly as // a plain one's is. var events <-chan session.Event + if args.composed() { + return nil, errors.New("engine: a queued message carries no pictures; send them as a message") + } if args.marked() { door, ok := followUpMarksDoor(agent) if !ok { @@ -2679,6 +2709,9 @@ func (s *server) invoke(call Frame) (out json.RawMessage, err error) { // A MARKED CORRECTION IS SPLICED WITH ITS MARK OR REFUSED before it // reaches the turn. var events <-chan session.Event + if args.composed() { + return nil, errors.New("engine: a correction carries no pictures; send them as a message") + } if args.marked() { door, ok := steerMarksDoor(agent) if !ok { diff --git a/internal/remote/wire.go b/internal/remote/wire.go index e1d3e67219..d1cb394077 100644 --- a/internal/remote/wire.go +++ b/internal/remote/wire.go @@ -1050,6 +1050,10 @@ type Welcome struct { // not keep a Steer's — and absence refuses a marked call before it is sent. SteerMarks bool `json:"steerMarks,omitempty"` FollowUpMarks bool `json:"followUpMarks,omitempty"` + // ComposedMessages says this engine takes a Submit that carries pictures and + // an annotation and answers its receipt. Absence is an older engine: such a + // Submit is refused before anything is sent. + ComposedMessages bool `json:"composedMessages,omitempty"` // SessionInstance identifies this live conversation owner, not its durable // transcript. A replaced engine can reopen the same file with stream IDs // starting over, so a returning surface must forget the previous owner IDs. @@ -1467,23 +1471,35 @@ type SubmitArgs struct { Said *string `json:"said,omitempty"` Attachments []session.MessageAttachment `json:"attachments,omitempty"` Reads []session.MessageRead `json:"reads,omitempty"` + // Media and Annotation are the pictures a message carries as real image + // parts and what the person marked on them ([session.Agent.SubmitComposed]), + // on Submit alone. Their paths mean something on the ENGINE's machine, which + // is where the files are read. An engine built before them would run the + // words and drop the pictures, so a surface asks the welcome's + // ComposedMessages first. + Media []session.SubmissionMedia `json:"media,omitempty"` + Annotation *session.Annotation `json:"annotation,omitempty"` } // marked is whether the call carries a mark beside its words. Submit, Steer // and FollowUp each take one; a marked call is kept with its mark or refused // whole, and never run as its words alone. func (a SubmitArgs) marked() bool { - return a.Said != nil || len(a.Attachments) > 0 || len(a.Reads) > 0 + return a.Said != nil || len(a.Attachments) > 0 || len(a.Reads) > 0 || a.composed() } +// composed is whether the call carries pictures or an annotation, which only a +// Submit can. +func (a SubmitArgs) composed() bool { return len(a.Media) > 0 || a.Annotation != nil } + // submission is the call as the agent's own door for a mark takes it. func (a SubmitArgs) submission() session.Submission { - return session.Submission{Text: a.Text, Said: a.Said, Attachments: a.Attachments, Reads: a.Reads} + return session.Submission{Text: a.Text, Said: a.Said, Attachments: a.Attachments, Reads: a.Reads, Media: a.Media, Annotation: a.Annotation} } // argsOf is a submission as the wire carries it. func argsOf(message session.Submission) SubmitArgs { - return SubmitArgs{Text: message.Text, Said: message.Said, Attachments: message.Attachments, Reads: message.Reads} + return SubmitArgs{Text: message.Text, Said: message.Said, Attachments: message.Attachments, Reads: message.Reads, Media: message.Media, Annotation: message.Annotation} } // UnqueueArgs names the queued follow-up a surface wants taken back out: the @@ -1901,6 +1917,9 @@ type LaneWire struct { type StreamRef struct { Stream uint64 `json:"stream"` + // Receipt answers a composed Submit: the message's durable id and how its + // pictures reached a model. Absent on every other stream. + Receipt *session.SubmissionReceipt `json:"receipt,omitempty"` } type ReasoningArgs struct { diff --git a/internal/session/agent.go b/internal/session/agent.go index dc86df80f0..3c43fa8574 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -2322,7 +2322,10 @@ func (f sessionCompleter) CompleteWithMessages(ctx context.Context, messages []a if f.unguarded { ctx = provider.WithoutBabbleGuard(ctx) } - return f.inner.CompleteWithMessages(ctx, messages, options...) + // A reference is never forwarded: a request that reached here without passing + // completeWithNamedModel is materialized now (imageref.go), and one that did + // pass has none left to find. + return f.inner.CompleteWithMessages(ctx, materializeImageRefs(messages), options...) } // ownLineageKey marks a call that brings its own prompt-cache lineage @@ -4910,6 +4913,10 @@ type PersonMark struct { // from, as the door observed them when it sent the message — never looked // up again — each a copy of the record's own. Reads []MessageRead + // Composed is what a composed submission recorded: the message's durable + // id, its pictures as the engine measured them, its annotation and how the + // pictures reached a model. Nil on every message no such door sent. + Composed *ComposedMark } // SteerMark is what the record keeps about one steer that LANDED: when the @@ -5054,7 +5061,7 @@ func shapeEntries(messages []ai.Message, journal *sessionFile, indexes ...*prese // on it and the reads its words were written from. A line with no // such mark is shown as it always was. if mark := presentation.of(msg).person(); mark != nil { - person = &PersonMark{Attachments: cloneAttachments(mark.Attachments), Reads: cloneReads(mark.Reads)} + person = &PersonMark{Attachments: cloneAttachments(mark.Attachments), Reads: cloneReads(mark.Reads), Composed: cloneComposed(mark.Composed)} if mark.Text != nil { displayText, person.Authored = *mark.Text, true } diff --git a/internal/session/clientdoor.go b/internal/session/clientdoor.go index 1317c6ac42..dc7055467a 100644 --- a/internal/session/clientdoor.go +++ b/internal/session/clientdoor.go @@ -804,7 +804,10 @@ func (a *Agent) completeWithNamedModel(ctx context.Context, purpose callPurpose, }) // A CALL UNDER A TOLD WINDOW IS TOLD IT HERE, at the last moment the context // is this package's to change (callwindow.go says why it cannot be earlier). - response, err := client.CompleteWithMessages(toldItsWindow(ctx), messages, append(options, ai.WithModel(wire))...) + // THE ONE PLACE A PICTURE BECOMES BYTES: references in the transcript are read + // back, verified and encoded for this request alone, under its budget + // (imageref.go). A copy is made only when a reference is present. + response, err := client.CompleteWithMessages(toldItsWindow(ctx), materializeImageRefs(messages), append(options, ai.WithModel(wire))...) if helper != nil { helper.after(ctx, model, response, held) crewTaskOf(ctx).addHelperSpend(response) diff --git a/internal/session/image.go b/internal/session/image.go index 29af3446b0..e51c7154b1 100644 --- a/internal/session/image.go +++ b/internal/session/image.go @@ -2,9 +2,6 @@ package session import ( "context" - "crypto/sha256" - "encoding/base64" - "encoding/hex" "errors" "fmt" "os" @@ -129,11 +126,19 @@ func (a *Agent) SubmitImage(ctx context.Context, text string, images []Image) (< if digest := a.planDigest(); digest != "" { user = planDigestedParts(digest, user) } + events, _, err := a.admitImageMessage(ctx, user) + return events, err +} +// admitImageMessage is the closed check, the steering splice, the spend rail +// and the turn for an assembled picture message — the half of [Agent.SubmitImage] +// that is a law about a turn starting, shared with [Agent.SubmitComposed] so the +// two cannot drift. queued says the message joined a running turn. +func (a *Agent) admitImageMessage(ctx context.Context, user userMessage) (events <-chan Event, queued bool, err error) { a.mu.Lock() if a.closed { a.mu.Unlock() - return nil, errors.New("session: agent is closed") + return nil, false, errors.New("session: agent is closed") } if a.running { // Steering, by the same law text steering follows (agent.go): the queue @@ -144,15 +149,15 @@ func (a *Agent) SubmitImage(ctx context.Context, text string, images []Image) (< a.steering = append(a.steering, user) events := a.hub.subscribe() a.mu.Unlock() - return events, nil + return events, true, nil } if err := a.railBlockLocked(); err != nil { a.mu.Unlock() - return refusedStream(err), nil + return refusedStream(err), false, nil } - events := a.startTurnLocked(ctx, user, nil) + started := a.startTurnLocked(ctx, user, nil) a.mu.Unlock() - return events, nil + return started, false, nil } // imageUserMessage assembles the message and the journal references for it in @@ -187,16 +192,12 @@ func imageUserMessage(text string, images []Image) (userMessage, error) { if total > maxMessageImageBytes { return userMessage{}, fmt.Errorf("session: these images total more than the %s a single message may carry — send them across a few messages", byteLimit(maxMessageImageBytes)) } - sum := sha256.Sum256(data) - parts = append(parts, ai.ContentPart{Type: "image_url", ImageURL: &ai.ImageURLData{ - URL: "data:" + mediaType + ";base64," + base64.StdEncoding.EncodeToString(data), - }}) - refs = append(refs, journalPart{ - Type: journalPartImage, - Path: path, - SHA256: hex.EncodeToString(sum[:]), - MIME: mediaType, - }) + // The picture enters the transcript as a REFERENCE (imageref.go): its + // bytes are read again, verified against this digest, at the request that + // carries it, and never held here. + ref, journaled := imageRefFromFile(path, mediaType, data) + parts = append(parts, ref.part()) + refs = append(refs, journaled) } return userMessage{message: ai.Message{Role: "user", Content: parts}, refs: refs}, nil } @@ -325,23 +326,29 @@ func (a *Agent) visionSeer() string { // cannot see. It returns SubmitImage's refusal, unchanged, when nothing else can // see either. func (a *Agent) visionTurn(ctx context.Context, text string, images []Image, model string) (<-chan Event, error) { - // A seer that names the blind model is refused rather than called: a slot or - // a tier set to the model we just established cannot see is a configuration, - // not a capability, and sending the picture to it is the 400 this whole path - // exists to avoid. seer := a.visionSeer() if seer == "" || strings.EqualFold(seer, model) { return nil, blindRefusal(model) } - - // Assembled first because the images have to be read and bounded before - // anything is recorded — the same order the sighted path uses, and the same - // errors. live, err := imageUserMessage(text, images) if err != nil { return nil, err } + return a.startVision(ctx, visionUserMessage(text, live.refs), live.message, model, seer) +} + +// visionTurnMarked is [Agent.visionTurn] for an already assembled composed +// message: the person's mark rides the words the transcript keeps, and the seer +// is the one the caller resolved and recorded as having looked. +func (a *Agent) visionTurnMarked(ctx context.Context, text, said string, live userMessage, model, seer string, mark *messagePresentation) (<-chan Event, error) { + kept := visionUserMessage(said, live.refs) + kept.person = mark + return a.startVision(ctx, kept, live.message, model, seer) +} +// startVision is what both fallbacks share once the seer is known and the +// message is assembled. +func (a *Agent) startVision(ctx context.Context, kept userMessage, live ai.Message, model, seer string) (<-chan Event, error) { a.mu.Lock() if a.closed { a.mu.Unlock() @@ -360,7 +367,7 @@ func (a *Agent) visionTurn(ctx context.Context, text string, images []Image, mod a.mu.Unlock() return refusedStream(err), nil } - events := a.startVisionTurnLocked(ctx, visionUserMessage(text, live.refs), live.message, seer) + events := a.startVisionTurnLocked(ctx, kept, live, seer) a.mu.Unlock() return events, nil } diff --git a/internal/session/image_test.go b/internal/session/image_test.go index ad15cf7b98..ddf5ac4d7a 100644 --- a/internal/session/image_test.go +++ b/internal/session/image_test.go @@ -358,7 +358,9 @@ func TestImageReplayPlaceholderWhenTheFileChangedOrWentAway(t *testing.T) { if err != nil { t.Fatalf("replay: %v", err) } - restored := replayed.messages[0] + // The replay holds a reference and opens nothing; the file is checked + // against its digest by the request that would carry it. + restored := materializeImageRefs(replayed.messages)[0] if len(restored.Content) != 2 { t.Fatalf("restored %+v, want text + placeholder", restored.Content) } diff --git a/internal/session/imageref.go b/internal/session/imageref.go new file mode 100644 index 0000000000..2b25f4d7cc --- /dev/null +++ b/internal/session/imageref.go @@ -0,0 +1,252 @@ +package session + +import ( + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "strings" + + "github.com/Agent-Field/agentfield/sdk/go/ai" +) + +// ── pictures are carried by reference and become bytes at the request ─────── +// +// A picture in the live transcript is NOT its base64. It is a small immutable +// reference — where the file is, what it hashed to when the message was +// accepted, its type and its size — spelled as an image_url whose URL is a +// private scheme. The conversation, the journal, a replay of two hundred +// pictures and every copy the loop makes of the transcript carry that reference +// and nothing heavier. The BYTES are read, hashed and encoded in exactly one +// place: [materializeImageRefs], at the moment a request is about to leave for a +// model, for the pictures that request will carry. +// +// THREE LAWS FOLLOW FROM THAT. +// +// - A REQUEST HAS A BUDGET, NOT A HISTORY. The newest pictures are +// materialized first, until the aggregate decoded bytes or the picture count +// of ONE request would pass its ceiling. Older pictures are replaced, in the +// request only, by the same placeholder sentence a lost file gets, naming the +// path so the person or the model can get it back. The transcript still +// holds every reference, so the person's history is never edited and a later +// request with room carries them again. +// +// - A REFERENCE NEVER LEAVES THE PROCESS. Every part whose URL is in the +// private scheme is replaced — by the picture or by text — and one that +// cannot be read back is text, never forwarded. A moved, edited or deleted +// file is a placeholder, because the digest recorded at accept time no longer +// matches what is on disk (the same rule the journal's replay always had). +// +// - THE NEWEST PICTURES ARE NEVER THE ONES DROPPED. One message's pictures are +// refused at accept above [maxMessageImageBytes], and a request's ceiling is +// twice that, so the latest message — an original and its marked copy — always +// fits whatever the transcript before it holds. + +const imageRefScheme = "codeaf-image:" + +// They are variables only so a test can lower them; nothing else assigns them. +var ( + // maxRequestImageBytes is the most decoded picture bytes ONE request carries. + // The encoded size is a third more, which is bounded by the same figure. + maxRequestImageBytes int64 = 2 * maxMessageImageBytes + // maxRequestImages is the most pictures ONE request carries, so a transcript + // of thousands of tiny pictures costs a bounded number of file reads. + maxRequestImages = 64 +) + +// imageRef is one picture as the transcript carries it. +type imageRef struct { + Path string `json:"p"` + MIME string `json:"m"` + Bytes int64 `json:"b,omitempty"` + SHA256 string `json:"-"` +} + +// part is the reference as a content part. The digest leads the URL so that +// [partKey], which fingerprints a part by its two ends, tells two pictures apart. +func (r imageRef) part() ai.ContentPart { + body, _ := json.Marshal(r) + return ai.ContentPart{Type: "image_url", ImageURL: &ai.ImageURLData{ + URL: imageRefScheme + r.SHA256 + ":" + base64.RawURLEncoding.EncodeToString(body), + }} +} + +// isImageRefPart is whether a part carries the private scheme, whether or not it +// parses. +func isImageRefPart(part ai.ContentPart) bool { + return part.Type == "image_url" && part.ImageURL != nil && strings.HasPrefix(part.ImageURL.URL, imageRefScheme) +} + +// imageRefOf parses a reference part. +func imageRefOf(part ai.ContentPart) (imageRef, bool) { + if !isImageRefPart(part) { + return imageRef{}, false + } + rest := strings.TrimPrefix(part.ImageURL.URL, imageRefScheme) + sum, body, found := strings.Cut(rest, ":") + if !found || len(sum) != 64 { + return imageRef{}, false + } + if _, err := hex.DecodeString(sum); err != nil { + return imageRef{}, false + } + raw, err := base64.RawURLEncoding.DecodeString(body) + if err != nil { + return imageRef{}, false + } + var ref imageRef + if json.Unmarshal(raw, &ref) != nil || strings.TrimSpace(ref.Path) == "" || strings.TrimSpace(ref.MIME) == "" || ref.Bytes < 0 { + return imageRef{}, false + } + ref.SHA256 = sum + return ref, true +} + +// imageFiles is the only door to the disk for a request's pictures, so a test +// can count what a request reads. +type imageFiles struct { + stat func(path string) (int64, error) + read func(path string, size int64) ([]byte, error) +} + +var requestImageFiles = imageFiles{ + stat: func(path string) (int64, error) { + info, err := os.Stat(path) + if err != nil || !info.Mode().IsRegular() { + return 0, os.ErrNotExist + } + return info.Size(), nil + }, + read: readSubmissionImage, +} + +func hasImageRef(messages []ai.Message) bool { + for _, message := range messages { + for _, part := range message.Content { + if isImageRefPart(part) { + return true + } + } + } + return false +} + +// materializeImageRefs is the one place a picture becomes bytes. It returns +// messages untouched — not one allocation — when none carries a reference, and +// otherwise a copy in which only the messages that carried references are new; +// the transcript it was handed is never written to. +func materializeImageRefs(messages []ai.Message) []ai.Message { + if !hasImageRef(messages) { + return messages + } + type slot struct { + message, part int + } + var slots []slot + for m, message := range messages { + for p, part := range message.Content { + if isImageRefPart(part) { + slots = append(slots, slot{m, p}) + } + } + } + replaced := make(map[slot]ai.ContentPart, len(slots)) + // THE BUDGET IS FOR ATTEMPTS, NOT FOR SUCCESSES. A picture is charged — one + // slot of the count and its recorded size of the bytes — BEFORE the disk is + // touched, and the charge stays when the file turns out gone, edited or + // unreadable. A budget that only counted pictures that loaded would let a + // transcript of ten thousand changed files cost ten thousand reads and hashes + // per request, with every failure invisible to the ceiling. What the walk + // does for a reference it has no room for is read its metadata and write a + // placeholder: no stat, no open. + var reserved int64 + attempts, exhausted := 0, false + // Newest first, so the budget is spent on the pictures the model is being + // asked about now. + for i := len(slots) - 1; i >= 0; i-- { + at := slots[i] + part := messages[at.message].Content[at.part] + ref, ok := imageRefOf(part) + if !ok { + replaced[at] = ai.ContentPart{Type: "text", Text: "[image — its reference could not be read]"} + continue + } + lost := journalPart{Type: journalPartImage, Path: ref.Path, SHA256: ref.SHA256, MIME: ref.MIME} + if ref.Bytes > maxImageBytes { + // Larger than a send could ever have accepted: not a picture this + // transcript's own door wrote, and not one to open. + replaced[at] = lost.placeholderBecause("it is over the size one picture may be") + continue + } + if exhausted || attempts >= maxRequestImages { + // Once one picture does not fit, every older one is left out too: a + // request that skipped a big one to keep a small older one would be a + // different request every time the transcript grew. + exhausted = true + replaced[at] = lost.placeholderBecause("it is older than the pictures one request carries") + continue + } + // This reference takes a slot now; the disk is touched only after. + attempts++ + size := ref.Bytes + if size <= 0 { + // A line written before sizes were recorded: one stat finds it, and + // it has already been counted. + var err error + if size, err = requestImageFiles.stat(ref.Path); err != nil { + replaced[at] = lost.placeholder() + continue + } + if size > maxImageBytes { + replaced[at] = lost.placeholderBecause("it is over the size one picture may be") + continue + } + } + // Compared as what is left, never as a sum, so no size can overflow. + if size > maxRequestImageBytes-reserved { + exhausted = true + replaced[at] = lost.placeholderBecause("it is older than the pictures one request carries") + continue + } + reserved += size + data, err := requestImageFiles.read(ref.Path, size) + if err != nil { + replaced[at] = lost.placeholder() + continue + } + sum := sha256.Sum256(data) + if hex.EncodeToString(sum[:]) != ref.SHA256 { + replaced[at] = lost.placeholder() + continue + } + replaced[at] = ai.ContentPart{Type: "image_url", ImageURL: &ai.ImageURLData{URL: imageDataURL(ref.MIME, data)}} + } + out := make([]ai.Message, len(messages)) + copy(out, messages) + for _, at := range slots { + if out[at.message].Content == nil || sameBacking(out[at.message].Content, messages[at.message].Content) { + // A NEW slice, never a write into the old one: a request already in + // flight holds a shallow copy of this message (see blindswap.go). + out[at.message].Content = append([]ai.ContentPart(nil), messages[at.message].Content...) + } + out[at.message].Content[at.part] = replaced[at] + } + return out +} + +// sameBacking is whether two content slices are the same array. +func sameBacking(a, b []ai.ContentPart) bool { + return len(a) > 0 && len(b) > 0 && &a[0] == &b[0] +} + +// imageRefFromFile is a picture accepted into the conversation: its digest and +// size are the ones its bytes had, now. +func imageRefFromFile(path, mediaType string, data []byte) (imageRef, journalPart) { + sum := sha256.Sum256(data) + digest := hex.EncodeToString(sum[:]) + path = filepath.Clean(path) + return imageRef{Path: path, MIME: mediaType, Bytes: int64(len(data)), SHA256: digest}, + journalPart{Type: journalPartImage, Path: path, SHA256: digest, MIME: mediaType, Bytes: int64(len(data))} +} diff --git a/internal/session/imageref_bench_test.go b/internal/session/imageref_bench_test.go new file mode 100644 index 0000000000..6bb63583d4 --- /dev/null +++ b/internal/session/imageref_bench_test.go @@ -0,0 +1,34 @@ +package session + +import ( + "crypto/sha256" + "encoding/hex" + "os" + "path/filepath" + "strconv" + "testing" +) + +// BenchmarkReplayTwoHundredPictures is the cost of reopening a conversation that +// holds two hundred 1MiB pictures: reading the journal's parts back into the +// live transcript. Run with -benchmem. +func BenchmarkReplayTwoHundredPictures(b *testing.B) { + directory := b.TempDir() + var parts []journalPart + for i := 0; i < 200; i++ { + data := make([]byte, 1<<20) + copy(data, strconv.Itoa(i)) + path := filepath.Join(directory, "p"+strconv.Itoa(i)+".png") + if err := os.WriteFile(path, data, 0o644); err != nil { + b.Fatal(err) + } + sum := sha256.Sum256(data) + parts = append(parts, journalPart{Type: journalPartImage, Path: path, SHA256: hex.EncodeToString(sum[:]), MIME: "image/png"}) + } + entry := sessionEntry{Role: "user", Content: "two hundred", Parts: parts} + b.ReportAllocs() + b.ResetTimer() + for i := 0; i < b.N; i++ { + _ = replayedMessage(entry, true) + } +} diff --git a/internal/session/imageref_test.go b/internal/session/imageref_test.go new file mode 100644 index 0000000000..b612a5efbf --- /dev/null +++ b/internal/session/imageref_test.go @@ -0,0 +1,459 @@ +package session + +// Pictures ride the transcript as references and become bytes only at the request +// that carries them, under that request's budget. These tests count the reads. + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "runtime" + "strconv" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" +) + +// countFiles swaps the request's disk door for one that counts, for one test. +func countFiles(t *testing.T) (stats, reads *int) { + t.Helper() + before := requestImageFiles + var s, r int + requestImageFiles = imageFiles{ + stat: func(path string) (int64, error) { s++; return before.stat(path) }, + read: func(path string, size int64) ([]byte, error) { r++; return before.read(path, size) }, + } + t.Cleanup(func() { requestImageFiles = before }) + return &s, &r +} + +// refMessages is n user messages, each carrying one picture of the given size as +// a reference, and the files behind them. Content is distinct per file. +func refMessages(t *testing.T, directory string, n int, size int64) []ai.Message { + t.Helper() + messages := make([]ai.Message, 0, n) + for i := 0; i < n; i++ { + path := filepath.Join(directory, "p"+strconv.Itoa(i)+".png") + data := make([]byte, size) + copy(data, strconv.Itoa(i)) + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatal(err) + } + ref, _ := imageRefFromFile(path, "image/png", data) + messages = append(messages, ai.Message{Role: "user", Content: []ai.ContentPart{ + {Type: "text", Text: "picture " + strconv.Itoa(i)}, ref.part(), + }}) + } + return messages +} + +func countKinds(messages []ai.Message) (data, refs, placeholders int) { + for _, message := range messages { + for _, part := range message.Content { + switch { + case isImageRefPart(part): + refs++ + case part.Type == "image_url" && part.ImageURL != nil: + data++ + case part.Type == "text" && strings.HasPrefix(part.Text, "[image "): + placeholders++ + } + } + } + return +} + +func TestReplayOfTwoHundredPicturesOpensNoFileAndHoldsOnlyReferences(t *testing.T) { + directory := t.TempDir() + var parts []journalPart + for i := 0; i < 200; i++ { + // The files do not exist: a replay that tried to open one would show it. + parts = append(parts, journalPart{Type: journalPartImage, Path: filepath.Join(directory, "gone"+strconv.Itoa(i)+".png"), SHA256: strings.Repeat("a", 63) + strconv.Itoa(i%10), MIME: "image/png", Bytes: 8 << 20}) + } + stats, reads := countFiles(t) + var before, after runtime.MemStats + runtime.ReadMemStats(&before) + message := replayedMessage(sessionEntry{Role: "user", Content: "two hundred", Parts: parts}, true) + runtime.ReadMemStats(&after) + if *stats != 0 || *reads != 0 { + t.Fatalf("a replay touched the disk: %d stats, %d reads", *stats, *reads) + } + if _, refs, _ := countKinds([]ai.Message{message}); refs != 200 { + t.Fatalf("replayed %d references, want 200", refs) + } + if grown := after.TotalAlloc - before.TotalAlloc; grown > 1<<20 { + t.Fatalf("replaying 200 pictures allocated %d bytes", grown) + } + // And a display read opens nothing either. + if got := len(imagePartURLs(replayedMessage(sessionEntry{Role: "user", Content: "x", Parts: parts}, false))); got != 0 { + t.Fatalf("a display read rebuilt %d pictures", got) + } +} + +func TestARequestCarriesTheNewestPicturesUnderItsByteBudgetAndKeepsEveryReference(t *testing.T) { + directory := t.TempDir() + transcript := refMessages(t, directory, 200, 1<<20) + snapshot := make([]ai.Message, len(transcript)) + copy(snapshot, transcript) + _, reads := countFiles(t) + + var before, after runtime.MemStats + runtime.ReadMemStats(&before) + request := materializeImageRefs(transcript) + runtime.ReadMemStats(&after) + + data, refs, placeholders := countKinds(request) + if data != 40 || refs != 0 || placeholders != 160 || *reads != 40 { + t.Fatalf("request carries %d pictures, %d references, %d placeholders after %d reads; want 40/0/160/40", data, refs, placeholders, *reads) + } + // The newest forty, in place, and the older ones named rather than missing. + if len(imagePartURLs(request[199])) != 1 || len(imagePartURLs(request[160])) != 1 || len(imagePartURLs(request[159])) != 0 { + t.Fatal("the budget was not spent on the newest pictures") + } + older := request[0].Content[1].Text + if !strings.Contains(older, "p0.png") || !strings.Contains(older, "older than the pictures one request carries") { + t.Fatalf("an older picture is %q, want it named and explained", older) + } + // The transcript is untouched: still two hundred references, none encoded. + if _, refs, _ := countKinds(transcript); refs != 200 { + t.Fatalf("the transcript holds %d references after a request, want 200", refs) + } + for i := range transcript { + if &transcript[i].Content[0] != &snapshot[i].Content[0] { + t.Fatal("the request wrote into the transcript") + } + } + // 40 pictures of 1MiB are 40MiB decoded and about 54MiB encoded: the budget + // is the bound, not the transcript. + if grown := after.TotalAlloc - before.TotalAlloc; grown > 160<<20 { + t.Fatalf("one request allocated %d bytes for a 40MiB budget", grown) + } +} + +func TestARequestCarriesAtMostSixtyFourPictures(t *testing.T) { + transcript := refMessages(t, t.TempDir(), 200, 100) + _, reads := countFiles(t) + data, _, placeholders := countKinds(materializeImageRefs(transcript)) + if data != maxRequestImages || *reads != maxRequestImages || placeholders != 200-maxRequestImages { + t.Fatalf("%d pictures, %d reads, %d placeholders", data, *reads, placeholders) + } +} + +// THE LATEST PAIR IS NEVER THE ONE DROPPED, however much older history there is. +func TestTheLatestOriginalAndMarkedCopyAlwaysTravel(t *testing.T) { + directory := t.TempDir() + transcript := refMessages(t, directory, 100, 1<<20) + pair := ai.Message{Role: "user", Content: []ai.ContentPart{{Type: "text", Text: "mark-up"}}} + for _, name := range []string{"original.png", "original.marked.png"} { + path := filepath.Join(directory, name) + data := make([]byte, 9<<20) + copy(data, name) + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatal(err) + } + ref, _ := imageRefFromFile(path, "image/png", data) + pair.Content = append(pair.Content, ref.part()) + } + request := materializeImageRefs(append(transcript, pair)) + last := request[len(request)-1] + if len(imagePartURLs(last)) != 2 || last.Content[0].Text != "mark-up" { + t.Fatalf("the latest pair did not travel whole: %+v", last.Content) + } + // 18MiB of pair leaves room for twenty-two older 1MiB pictures. + if data, _, _ := countKinds(request); data != 2+22 { + t.Fatalf("request carries %d pictures, want the pair and the 22 newest older ones", data) + } +} + +func TestAMovedEditedOrUnreadableReferenceIsAPlaceholderAndNeverLeaks(t *testing.T) { + directory := t.TempDir() + transcript := refMessages(t, directory, 4, 64) + if err := os.Remove(filepath.Join(directory, "p0.png")); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(directory, "p1.png"), []byte("something else entirely"), 0o644); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(filepath.Join(directory, "p2.png.d"), 0o755); err != nil { + t.Fatal(err) + } + bad := ai.Message{Role: "user", Content: []ai.ContentPart{{Type: "image_url", ImageURL: &ai.ImageURLData{URL: imageRefScheme + "not-a-reference"}}}} + request := materializeImageRefs(append(transcript, bad)) + data, refs, placeholders := countKinds(request) + if data != 2 || refs != 0 || placeholders != 3 { + t.Fatalf("%d pictures, %d references, %d placeholders; want 2/0/3", data, refs, placeholders) + } + for _, message := range request { + for _, part := range message.Content { + if part.ImageURL != nil && strings.HasPrefix(part.ImageURL.URL, imageRefScheme) { + t.Fatal("a reference reached the request") + } + } + } + if text := request[0].Content[1].Text; !strings.Contains(text, "p0.png") || !strings.Contains(text, "file changed or gone") { + t.Fatalf("gone file = %q", text) + } + raw, _ := json.Marshal(request) + if strings.Contains(string(raw), imageRefScheme) { + t.Fatal("the private scheme is in the serialized request") + } +} + +func TestATranscriptWithNoPicturesIsReturnedUntouched(t *testing.T) { + messages := []ai.Message{{Role: "user", Content: []ai.ContentPart{{Type: "text", Text: "hi"}}}} + var before, after runtime.MemStats + runtime.ReadMemStats(&before) + out := materializeImageRefs(messages) + runtime.ReadMemStats(&after) + if &out[0] != &messages[0] || after.Mallocs-before.Mallocs > 2 { + t.Fatal("a picture-free request was copied") + } +} + +// THE REAL SDK AND A FAKE PROVIDER: two marked messages in a row, a request +// budget of two pictures. The second request carries the second pair as image +// parts with the person's words, and the first pair is named, not missing. +func TestTheProviderRequestCarriesTheLatestPairAndNamesTheOlderOnes(t *testing.T) { + oldImages := maxRequestImages + maxRequestImages = 2 + t.Cleanup(func() { maxRequestImages = oldImages }) + + var mu sync.Mutex + var bodies [][]byte + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if !answersChatOnly(w, r) { + return + } + raw, _ := io.ReadAll(r.Body) + mu.Lock() + bodies = append(bodies, raw) + mu.Unlock() + w.Header().Set("Content-Type", "text/event-stream") + io.WriteString(w, `data: {"id":"x","choices":[{"index":0,"delta":{"role":"assistant","content":"ok"}}]}`+"\n\n") + io.WriteString(w, "data: [DONE]\n\n") + })) + defer server.Close() + + workspace := t.TempDir() + agent, err := New(Config{ + Workspace: workspace, Model: "vendor/vision-model", APIKey: "test", BaseURL: server.URL, System: "SYSTEM", + SupportsImages: func(string) bool { return true }, + SessionFile: filepath.Join(t.TempDir(), "session.jsonl"), + }) + if err != nil { + t.Fatal(err) + } + defer agent.Close() + + send := func(words string, shade uint8) { + original := writePNG(t, workspace, words+".png", 8, 8, shade) + marked := writePNG(t, workspace, words+".marked.png", 8, 8, shade+1) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + _, events, err := agent.SubmitComposed(ctx, Submission{ + Text: words, + Media: []SubmissionMedia{{ID: "o", Path: original}, {ID: "m", Path: marked}}, + Annotation: &Annotation{Original: "o", Marked: "m", ChangeOnlyMarked: true, + Marks: []AnnotationMark{{Kind: "pin", At: &Point{X: 1, Y: 1}}}}, + }) + if err != nil { + t.Fatal(err) + } + for event := range events { + if event.Kind == EventError { + t.Fatalf("turn errored: %v", event.Err) + } + } + } + send("first", 10) + send("second", 100) + + mu.Lock() + defer mu.Unlock() + // The requests are the turns' and the session's own errands (a title); the + // second turn is the one that carries both messages and the system prompt. + var turn []byte + for _, body := range bodies { + if strings.Contains(string(body), `"SYSTEM"`) && strings.Contains(string(body), "second") { + turn = body + } + } + if turn == nil { + t.Fatalf("the provider saw %d requests and none was the second turn", len(bodies)) + } + var wire struct { + Messages []struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + } `json:"messages"` + } + if err := json.Unmarshal(turn, &wire); err != nil { + t.Fatal(err) + } + var users [][]struct { + Type string `json:"type"` + Text string `json:"text"` + ImageURL *struct { + URL string `json:"url"` + } `json:"image_url"` + } + for _, message := range wire.Messages { + if message.Role != "user" { + continue + } + var content []struct { + Type string `json:"type"` + Text string `json:"text"` + ImageURL *struct { + URL string `json:"url"` + } `json:"image_url"` + } + if err := json.Unmarshal(message.Content, &content); err != nil { + t.Fatalf("a picture message was flattened: %s", message.Content) + } + users = append(users, content) + } + if len(users) != 2 { + t.Fatalf("the second request holds %d user messages, want both", len(users)) + } + first, second := users[0], users[1] + if first[0].Text != "first" || second[0].Text != "second" { + t.Fatalf("the person's words are %q and %q", first[0].Text, second[0].Text) + } + if len(second) != 3 || second[1].ImageURL == nil || second[2].ImageURL == nil || + !strings.HasPrefix(second[1].ImageURL.URL, "data:image/png;base64,") || second[1].ImageURL.URL == second[2].ImageURL.URL { + t.Fatalf("the latest pair did not travel as two distinct image parts: %+v", second) + } + if len(first) != 3 || first[1].ImageURL != nil || first[2].ImageURL != nil || + !strings.Contains(first[1].Text, "first.png") || !strings.Contains(first[2].Text, "first.marked.png") { + t.Fatalf("the older pair is not named in the request: %+v", first) + } + if strings.Contains(string(turn), imageRefScheme) { + t.Fatal("a reference reached the provider") + } +} + +// attemptLog records every disk door a request opens and the bytes it asked for. +func attemptLog(t *testing.T) (stats, reads *int, asked *int64) { + t.Helper() + before := requestImageFiles + var s, r int + var bytes int64 + requestImageFiles = imageFiles{ + stat: func(path string) (int64, error) { s++; return before.stat(path) }, + read: func(path string, size int64) ([]byte, error) { r++; bytes += size; return before.read(path, size) }, + } + t.Cleanup(func() { requestImageFiles = before }) + return &s, &r, &bytes +} + +// refsFor builds n reference messages whose recorded digest never matches (or +// whose files do not exist), with the size the journal recorded. +func badRefMessages(t *testing.T, directory string, n int, recorded int64, exist bool) []ai.Message { + t.Helper() + messages := make([]ai.Message, 0, n) + for i := 0; i < n; i++ { + path := filepath.Join(directory, "bad"+strconv.Itoa(i)+".png") + if exist { + if err := os.WriteFile(path, make([]byte, recorded), 0o644); err != nil { + t.Fatal(err) + } + } + ref := imageRef{Path: path, MIME: "image/png", Bytes: recorded, SHA256: strings.Repeat("ab", 32)} + messages = append(messages, ai.Message{Role: "user", Content: []ai.ContentPart{ref.part()}}) + } + return messages +} + +// FAILURES SPEND THE BUDGET TOO: a transcript of pictures that are gone or no +// longer match their digest cannot cost the filesystem more than a request that +// carries good ones. +func TestFailedPicturesSpendTheAttemptBudget(t *testing.T) { + for name, build := range map[string]func(*testing.T, string) []ai.Message{ + "missing files": func(t *testing.T, d string) []ai.Message { return badRefMessages(t, d, 200, 1<<20, false) }, + "changed 1MiB files": func(t *testing.T, d string) []ai.Message { return badRefMessages(t, d, 200, 1<<20, true) }, + "changed 8MiB files": func(t *testing.T, d string) []ai.Message { return badRefMessages(t, d, 200, 8<<20, false) }, + "sizes not recorded": func(t *testing.T, d string) []ai.Message { return badRefMessages(t, d, 10000, 0, false) }, + } { + t.Run(name, func(t *testing.T) { + transcript := build(t, t.TempDir()) + stats, reads, asked := attemptLog(t) + request := materializeImageRefs(transcript) + if *reads > maxRequestImages || *stats > maxRequestImages || *stats+*reads > maxRequestImages { + t.Fatalf("%d stats and %d reads for %d bad pictures; a request may attempt %d", *stats, *reads, len(transcript), maxRequestImages) + } + if *asked > maxRequestImageBytes { + t.Fatalf("a request asked the disk for %d bytes, over its %d", *asked, maxRequestImageBytes) + } + data, refs, placeholders := countKinds(request) + if data != 0 || refs != 0 || placeholders != len(transcript) { + t.Fatalf("%d pictures, %d references, %d placeholders for %d bad ones", data, refs, placeholders, len(transcript)) + } + }) + } +} + +// A sizes-recorded transcript that cannot load costs no stat at all: the walk is +// metadata only. +func TestARecordedSizeTranscriptTouchesNoDiskBeyondItsAttempts(t *testing.T) { + transcript := badRefMessages(t, t.TempDir(), 10000, 1<<20, false) + stats, reads, asked := attemptLog(t) + materializeImageRefs(transcript) + if *stats != 0 || *reads > 40 || *asked > maxRequestImageBytes { + t.Fatalf("%d stats, %d reads, %d bytes for 10000 pictures of 1MiB", *stats, *reads, *asked) + } +} + +// THE LATEST PAIR SURVIVES a history of ruin behind it, because it is walked first. +func TestTheLatestPairTravelsPastAHistoryOfBadPictures(t *testing.T) { + directory := t.TempDir() + transcript := badRefMessages(t, directory, 200, 1<<20, false) + pair := ai.Message{Role: "user", Content: []ai.ContentPart{{Type: "text", Text: "mark-up"}}} + for _, name := range []string{"o.png", "m.png"} { + path := filepath.Join(directory, name) + data := make([]byte, 4<<20) + copy(data, name) + if err := os.WriteFile(path, data, 0o644); err != nil { + t.Fatal(err) + } + ref, _ := imageRefFromFile(path, "image/png", data) + pair.Content = append(pair.Content, ref.part()) + } + _, reads, asked := attemptLog(t) + request := materializeImageRefs(append(transcript, pair)) + if last := request[len(request)-1]; len(imagePartURLs(last)) != 2 { + t.Fatalf("the latest pair did not travel: %+v", last.Content) + } + if *reads > maxRequestImages || *asked > maxRequestImageBytes { + t.Fatalf("%d reads asking %d bytes", *reads, *asked) + } +} + +// A recorded size is the journal's word, and the journal can be edited: sizes at +// the edge of the integers must neither wrap the budget nor reach the disk. +func TestRecordedSizesCannotOverflowTheBudget(t *testing.T) { + for _, size := range []int64{1<<63 - 1, 1<<63 - 2, maxImageBytes + 1, maxRequestImageBytes} { + ref := imageRef{Path: "/x/a.png", MIME: "image/png", Bytes: size, SHA256: strings.Repeat("cd", 32)} + other := imageRef{Path: "/x/b.png", MIME: "image/png", Bytes: 1 << 20, SHA256: strings.Repeat("ef", 32)} + transcript := []ai.Message{ + {Role: "user", Content: []ai.ContentPart{ref.part()}}, + {Role: "user", Content: []ai.ContentPart{other.part()}}, + } + stats, reads, asked := attemptLog(t) + request := materializeImageRefs(transcript) + if *asked > maxRequestImageBytes || *stats != 0 { + t.Fatalf("size %d: %d stats, %d bytes asked", size, *stats, *asked) + } + if _, refs, _ := countKinds(request); refs != 0 { + t.Fatalf("size %d left a reference in the request", size) + } + _ = reads + } +} diff --git a/internal/session/presentation.go b/internal/session/presentation.go index 075b1a2faf..91850224cf 100644 --- a/internal/session/presentation.go +++ b/internal/session/presentation.go @@ -31,7 +31,11 @@ type messagePresentation struct { Interrupted bool `json:"interrupted,omitempty"` Attachments []MessageAttachment `json:"attachments,omitempty"` Reads []MessageRead `json:"reads,omitempty"` - SkillsBlock string `json:"-"` + // Composed is what a composed submission added to a person's line: its + // durable id, its pictures as measured, its annotation and how the pictures + // reached a model ([Agent.SubmitComposed]). + Composed *ComposedMark `json:"composed,omitempty"` + SkillsBlock string `json:"-"` } // person is the part of a mark a person's message carries into the record: the diff --git a/internal/session/recordcache.go b/internal/session/recordcache.go index 803c6ac5b4..e60ae09543 100644 --- a/internal/session/recordcache.go +++ b/internal/session/recordcache.go @@ -460,6 +460,7 @@ func recordVectors(record Record) int64 { int64(len(entry.ReplyTags))*int64(unsafe.Sizeof(TaskReplyTag{})) + int64(len(entry.Team))*int64(unsafe.Sizeof(TeamLine{})) if entry.Person != nil { + cost += composedSizes(entry.Person.Composed) cost += int64(unsafe.Sizeof(PersonMark{})) + int64(len(entry.Person.Attachments))*int64(unsafe.Sizeof(MessageAttachment{})) for _, attachment := range entry.Person.Attachments { @@ -521,6 +522,7 @@ func entriesCost(entries []DisplayEntry) int64 { cost += int64(len(line.Team) + len(line.From) + len(line.To) + len(line.Kind) + len(line.Text) + len(line.Thread)) } if entry.Person != nil { + cost += composedSizes(entry.Person.Composed) cost += int64(unsafe.Sizeof(PersonMark{})) + int64(cap(entry.Person.Attachments))*int64(unsafe.Sizeof(MessageAttachment{})) for _, attachment := range entry.Person.Attachments { cost += int64(len(attachment.ID)+len(attachment.Kind)+len(attachment.Name)+len(attachment.Path)+len(attachment.URL)) + @@ -581,7 +583,7 @@ func cloneEntries(entries []DisplayEntry) []DisplayEntry { entry.ReplyTags = slices.Clone(entry.ReplyTags) entry.Team = slices.Clone(entry.Team) if entry.Person != nil { - person := PersonMark{Authored: entry.Person.Authored, Attachments: cloneAttachments(entry.Person.Attachments), Reads: cloneReads(entry.Person.Reads)} + person := PersonMark{Authored: entry.Person.Authored, Attachments: cloneAttachments(entry.Person.Attachments), Reads: cloneReads(entry.Person.Reads), Composed: cloneComposed(entry.Person.Composed)} entry.Person = &person } if entry.Steer != nil { diff --git a/internal/session/sessionfile.go b/internal/session/sessionfile.go index 27a647c28d..606cc0b1c8 100644 --- a/internal/session/sessionfile.go +++ b/internal/session/sessionfile.go @@ -4,8 +4,6 @@ import ( "bufio" "bytes" "crypto/rand" - "crypto/sha256" - "encoding/base64" "encoding/hex" "encoding/json" "errors" @@ -1066,6 +1064,10 @@ type journalPart struct { Path string `json:"path"` SHA256 string `json:"sha256"` MIME string `json:"mime,omitempty"` + // Bytes is the picture's size when it was accepted, absent on a line written + // before it was recorded. It lets a request know what a picture costs + // without opening the file. + Bytes int64 `json:"bytes,omitempty"` } // contentPart turns one reference back into content for the live transcript. @@ -1077,31 +1079,20 @@ type journalPart struct { // the same bytes on the wire. Anything else — moved, deleted, edited, unreadable, // grown past the limit — is a text part that says which picture is missing. func (p journalPart) contentPart() ai.ContentPart { - if p.Type != journalPartImage { - return p.placeholder() - } - info, err := os.Stat(p.Path) - if err != nil || info.IsDir() || info.Size() > maxImageBytes { - return p.placeholder() - } - data, err := os.ReadFile(p.Path) - if err != nil || len(data) > maxImageBytes { - return p.placeholder() - } - sum := sha256.Sum256(data) - if hex.EncodeToString(sum[:]) != p.SHA256 { + if p.Type != journalPartImage || len(p.SHA256) != 64 { return p.placeholder() } mediaType := strings.TrimSpace(p.MIME) if mediaType == "" { mediaType = imageMediaTypes[strings.ToLower(filepath.Ext(p.Path))] } - if mediaType == "" { + if mediaType == "" || strings.TrimSpace(p.Path) == "" { return p.placeholder() } - return ai.ContentPart{Type: "image_url", ImageURL: &ai.ImageURLData{ - URL: "data:" + mediaType + ";base64," + base64.StdEncoding.EncodeToString(data), - }} + // NO FILE IS OPENED HERE. A replay of two hundred pictures is two hundred + // references; the bytes are read, and checked against this digest, by the + // request that carries them (imageref.go). + return imageRef{Path: p.Path, MIME: mediaType, Bytes: p.Bytes, SHA256: p.SHA256}.part() } // reference is one journaled part as a REFERENCE AND NOTHING MORE: where the diff --git a/internal/session/submission.go b/internal/session/submission.go index 469b6ef465..937b8a5532 100644 --- a/internal/session/submission.go +++ b/internal/session/submission.go @@ -70,6 +70,11 @@ type Submission struct { // Reads are the reads of references the door performed to write Text, in // the order it performed them, as it found them at that moment. Reads []MessageRead + // Media are the pictures that ride on this message as real image parts, in + // order, after Text; Annotation is what the person marked on them. Either + // makes the message a composed one ([Agent.SubmitComposed]). + Media []SubmissionMedia + Annotation *Annotation } // ── what the door read to write the message ───────────────────────────────── @@ -177,6 +182,11 @@ var ErrMessageMark = errors.New("session: the message's description does not fit // recorded, so a refusal leaves nothing behind. A submission with no mark — // Said nil, no attachments and no reads — is exactly [Agent.Submit]. func (a *Agent) SubmitMessage(ctx context.Context, message Submission) (<-chan Event, error) { + if len(message.Media) > 0 || message.Annotation != nil { + // Pictures are never dropped to fit a door that cannot carry them. + _, events, err := a.SubmitComposed(ctx, message) + return events, err + } mark, err := message.mark() if err != nil { return nil, err diff --git a/internal/session/submission_media.go b/internal/session/submission_media.go new file mode 100644 index 0000000000..e764af6224 --- /dev/null +++ b/internal/session/submission_media.go @@ -0,0 +1,681 @@ +package session + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "image" + _ "image/gif" // registered for the header-only dimension probe + _ "image/jpeg" // registered for the header-only dimension probe + _ "image/png" // registered for the header-only dimension probe + "io" + "math" + "net/http" + "os" + "path/filepath" + "strings" + "unicode/utf8" + + _ "golang.org/x/image/webp" // registered for the header-only dimension probe +) + +// ── one message: words, pictures and what the person marked on them ───────── +// +// A person who marks up a picture and sends it back sends ONE message. The +// words, the original, the marked copy and the marks are not four things the +// surface sequences; they are the message, and the record has to be able to say +// so. Before this file the two halves had separate doors — SubmitMessage kept a +// description beside text, SubmitImage sent parts with none — so "a picture +// with a note on it" had no way to be both, and an app that showed a chip +// handed the engine a path in a sentence. +// +// THE ENGINE KEEPS WHAT THE DOOR SAID, TYPED. Marks are coordinates and notes +// the editor produced, validated here and recorded on the message's own line; +// NOTHING IS EVER PARSED OUT OF THE WORDS, and nothing in the words changes what +// is recorded. The model reads Text and the pictures; the mark is for the record +// and for whoever draws it. + +// MaxSubmissionMedia is how many pictures one message carries. It is the same +// ten an app message may name attachments, so a full tray is never refused here. +const MaxSubmissionMedia = 10 + +// The bounds of an annotation. Like the rest of a message's mark it is written +// on every line that carries one and read on every page that shows it, so it is +// small and fixed, and a bound is checked on the bytes as given. +const ( + // MaxAnnotationMarks is how many marks one annotation names. + MaxAnnotationMarks = 64 + // MaxShadePoints is how many points one painted shape may be made of. + MaxShadePoints = 512 + // MaxMarkNote is the longest one note may be, in bytes. + MaxMarkNote = 2000 + // maxAnnotationBytes is every string of one annotation together. + maxAnnotationBytes = 64 << 10 + // MaxPictureCoordinate is the far edge of picture coordinates: they run + // from 0 to this on both axes whatever the picture's pixel size. + MaxPictureCoordinate = 100.0 +) + +// Delivery words: how the pictures reached the model, as the record says it. +const ( + // DeliveryText is a message that carried no picture. + DeliveryText = "text" + // DeliveryImage is a message whose pictures the model talking to the person + // received as image parts. + DeliveryImage = "image" + // DeliveryDescription is a message whose pictures a VISION MODEL looked at + // because the talking model cannot: the talking model read that model's + // words, not the pictures. + DeliveryDescription = "description" +) + +// Roles a picture can have in an annotated message. +const ( + MediaRoleOriginal = "original" + MediaRoleMarked = "marked" +) + +// MediaReference says where a picture came from, as the door knew it: the id the +// engine's own index gave the source, its path, and the moment in a clip a frame +// was taken at. Every field is optional; an empty one is "the door did not say". +type MediaReference struct { + SourceID string `json:"sourceId,omitempty"` + Path string `json:"path,omitempty"` + AtMS *int64 `json:"atMs,omitempty"` +} + +// SubmissionMedia is one picture riding on a message. ID is the door's own name +// for it, unique within the message — an app's staged attachment id — and is +// what an [Annotation] names. Path is read by the engine, once, at the moment +// the message is accepted. +type SubmissionMedia struct { + ID string `json:"id"` + Path string `json:"path"` + Source *MediaReference `json:"source,omitempty"` +} + +// Point is a position on a picture in picture coordinates, 0 to 100 on both axes. +type Point struct { + X float64 `json:"x"` + Y float64 `json:"y"` +} + +// Shade is an arbitrary painted shape: the path of the brush and how wide it +// was. AreaPercent, where the door computed it, is a derived fact — the share of +// the picture the shape covers — and never a replacement for the points. +type Shade struct { + Points []Point `json:"points"` + BrushWidth float64 `json:"brushWidth"` + AreaPercent *float64 `json:"areaPercent,omitempty"` +} + +// AnnotationMark is one mark. Its place in [Annotation.Marks] is its number. +// Kind is "pin" (At), "box" (From and To, the top-left and bottom-right +// corners) or "shade" (Shade). An empty Note means "change this". +type AnnotationMark struct { + Kind string `json:"kind"` + Note string `json:"note,omitempty"` + At *Point `json:"at,omitempty"` + From *Point `json:"from,omitempty"` + To *Point `json:"to,omitempty"` + Shade *Shade `json:"shade,omitempty"` +} + +// Annotation is what a person marked on a picture, as structured facts. +// Original and Marked name [SubmissionMedia.ID]s: the picture as it was, and the +// copy with the marks drawn on it. MarksPath is where the marks were also +// written as a file; it is recorded text and the engine never opens it. +type Annotation struct { + Original string `json:"original"` + Marked string `json:"marked,omitempty"` + MarksPath string `json:"marksPath,omitempty"` + ChangeOnlyMarked bool `json:"changeOnlyMarked"` + Marks []AnnotationMark `json:"marks"` +} + +// MessageMedia is one picture of a message as the record keeps it: facts the +// engine measured when it read the file, never the bytes. +type MessageMedia struct { + ID string `json:"id"` + Path string `json:"path"` + MIME string `json:"mime"` + Bytes int64 `json:"bytes"` + SHA256 string `json:"sha256"` + Width int `json:"width,omitempty"` + Height int `json:"height,omitempty"` + Role string `json:"role,omitempty"` + Source *MediaReference `json:"source,omitempty"` +} + +// MessageDelivery is how the pictures reached a model, as it happened. For +// DeliveryDescription Model is the vision model that actually looked. +type MessageDelivery struct { + Mode string `json:"mode"` + Model string `json:"model,omitempty"` +} + +// ComposedMark is what a composed submission adds to the person's mark. It is +// one pointer on the mark because nearly every message has none. +type ComposedMark struct { + // MessageID is the message's durable identity, minted when the engine + // accepted it. + MessageID string `json:"messageId"` + Media []MessageMedia `json:"media,omitempty"` + Annotation *Annotation `json:"annotation,omitempty"` + Delivery *MessageDelivery `json:"delivery,omitempty"` +} + +// SubmissionReceipt is the engine's answer to an accepted composed submission. Queued says +// a turn was running and the message joined it at the next step boundary. +type SubmissionReceipt struct { + MessageID string `json:"messageId"` + Delivery string `json:"delivery"` + SeenBy string `json:"seenBy,omitempty"` + Queued bool `json:"queued,omitempty"` + // Media are the pictures as the engine measured them and recorded them. + Media []MessageMedia `json:"media,omitempty"` +} + +// ── checking ───────────────────────────────────────────────────────────────── + +// Check is the pure, filesystem-free half of validating a submission: every +// bound and every shape, answered as an error wrapping [ErrMessageMark] so a door +// can tell a description it got wrong from a session that would not take a turn. +// [Agent.SubmitComposed] runs it first, so a refusal here leaves nothing behind. +func (m Submission) Check() error { + if _, err := m.mark(); err != nil { + return err + } + return m.checkComposed() +} + +func (m Submission) checkComposed() error { + if len(m.Media) > MaxSubmissionMedia { + return markError("%d pictures, and one message carries at most %d", len(m.Media), MaxSubmissionMedia) + } + ids := make(map[string]bool, len(m.Media)) + for index, media := range m.Media { + n := index + 1 + if media.ID == "" || len(media.ID) > maxAttachmentField || !utf8.ValidString(media.ID) { + return markError("picture %d has no id of at most %d bytes", n, maxAttachmentField) + } + if ids[media.ID] { + return markError("two pictures are both called %s", media.ID) + } + ids[media.ID] = true + if err := checkPath(media.Path); err != nil || strings.TrimSpace(media.Path) == "" { + return markError("picture %d has no path of at most %d bytes", n, maxAttachmentField) + } + if err := checkReference(media.Source); err != nil { + return markError("picture %d's source %v", n, err) + } + } + if m.Annotation == nil { + return nil + } + return checkAnnotation(m.Annotation, ids) +} + +func markError(format string, args ...any) error { + return fmt.Errorf("%w: "+format, append([]any{ErrMessageMark}, args...)...) +} + +func checkPath(path string) error { + if len(path) > maxAttachmentField || !utf8.ValidString(path) || strings.ContainsRune(path, 0) { + return fmt.Errorf("is not a path of at most %d bytes", maxAttachmentField) + } + return nil +} + +func checkReference(ref *MediaReference) error { + if ref == nil { + return nil + } + if len(ref.SourceID) > maxAttachmentField || !utf8.ValidString(ref.SourceID) { + return fmt.Errorf("has an id that is not text of at most %d bytes", maxAttachmentField) + } + if err := checkPath(ref.Path); err != nil { + return err + } + if ref.AtMS != nil && *ref.AtMS < 0 { + return errors.New("has a negative moment") + } + return nil +} + +func checkPoint(p *Point) bool { + return p != nil && finiteWithin(p.X, MaxPictureCoordinate) && finiteWithin(p.Y, MaxPictureCoordinate) +} + +// finiteWithin is whether v is a number from 0 to limit: NaN and the infinities +// fail every comparison a bound could make, so they are named here. +func finiteWithin(v, limit float64) bool { + return !math.IsNaN(v) && !math.IsInf(v, 0) && v >= 0 && v <= limit +} + +func checkAnnotation(note *Annotation, media map[string]bool) error { + if note.Original == "" || !media[note.Original] { + return markError("the annotation's original is not one of the message's pictures") + } + if note.Marked != "" { + if !media[note.Marked] { + return markError("the annotation's marked copy is not one of the message's pictures") + } + if note.Marked == note.Original { + return markError("the marked copy and the original are the same picture") + } + } + if err := checkPath(note.MarksPath); err != nil { + return markError("the annotation's marks file %v", err) + } + if len(note.Marks) == 0 || len(note.Marks) > MaxAnnotationMarks { + return markError("%d marks, and an annotation names 1 to %d", len(note.Marks), MaxAnnotationMarks) + } + total := len(note.MarksPath) + for index, mark := range note.Marks { + n := index + 1 + if len(mark.Note) > MaxMarkNote || !utf8.ValidString(mark.Note) || strings.ContainsRune(mark.Note, 0) { + return markError("mark %d's note is not text of at most %d bytes", n, MaxMarkNote) + } + total += len(mark.Note) + switch mark.Kind { + case "pin": + if !checkPoint(mark.At) || mark.From != nil || mark.To != nil || mark.Shade != nil { + return markError("mark %d is not a pin at a point from 0 to %.0f", n, MaxPictureCoordinate) + } + case "box": + if !checkPoint(mark.From) || !checkPoint(mark.To) || mark.At != nil || mark.Shade != nil || + !(mark.From.X < mark.To.X && mark.From.Y < mark.To.Y) { + return markError("mark %d is not a box with its top-left corner above and left of its bottom-right, inside 0 to %.0f", n, MaxPictureCoordinate) + } + case "shade": + shade := mark.Shade + if shade == nil || mark.At != nil || mark.From != nil || mark.To != nil { + return markError("mark %d is not a shaded area", n) + } + if len(shade.Points) == 0 || len(shade.Points) > MaxShadePoints { + return markError("mark %d's shaded area has %d points, and one has 1 to %d", n, len(shade.Points), MaxShadePoints) + } + for _, point := range shade.Points { + if !checkPoint(&point) { + return markError("mark %d's shaded area has a point outside 0 to %.0f", n, MaxPictureCoordinate) + } + } + if !finiteWithin(shade.BrushWidth, MaxPictureCoordinate) || shade.BrushWidth == 0 { + return markError("mark %d's brush is not wider than 0 and at most %.0f", n, MaxPictureCoordinate) + } + if shade.AreaPercent != nil && !finiteWithin(*shade.AreaPercent, 100) { + return markError("mark %d's area is not a share from 0 to 100", n) + } + default: + return markError("mark %d is a %q, and a mark is a pin, a box or a shade", n, mark.Kind) + } + } + if total > maxAnnotationBytes { + return markError("the annotation's text comes to more than %d bytes", maxAnnotationBytes) + } + return nil +} + +// keptAnnotation is the record's own deep copy of a checked annotation. +func keptAnnotation(given *Annotation) *Annotation { + if given == nil { + return nil + } + kept := *given + kept.Original, kept.Marked, kept.MarksPath = strings.Clone(given.Original), strings.Clone(given.Marked), strings.Clone(given.MarksPath) + kept.Marks = make([]AnnotationMark, len(given.Marks)) + for index, mark := range given.Marks { + copied := AnnotationMark{Kind: mark.Kind, Note: strings.Clone(mark.Note)} + if mark.At != nil { + at := *mark.At + copied.At = &at + } + if mark.From != nil { + from := *mark.From + copied.From = &from + } + if mark.To != nil { + to := *mark.To + copied.To = &to + } + if mark.Shade != nil { + shade := *mark.Shade + shade.Points = append([]Point(nil), mark.Shade.Points...) + if shade.AreaPercent != nil { + area := *shade.AreaPercent + shade.AreaPercent = &area + } + copied.Shade = &shade + } + kept.Marks[index] = copied + } + return &kept +} + +func cloneMedia(media []MessageMedia) []MessageMedia { + if len(media) == 0 { + return nil + } + out := append([]MessageMedia(nil), media...) + for index := range out { + if source := out[index].Source; source != nil { + copied := *source + if source.AtMS != nil { + at := *source.AtMS + copied.AtMS = &at + } + out[index].Source = &copied + } + } + return out +} + +// cloneComposed is a reading's own copy of a composed mark, sharing nothing +// mutable with the index behind it. +func cloneComposed(mark *ComposedMark) *ComposedMark { + if mark == nil { + return nil + } + out := *mark + out.Media = cloneMedia(mark.Media) + out.Annotation = keptAnnotation(mark.Annotation) + if mark.Delivery != nil { + delivery := *mark.Delivery + out.Delivery = &delivery + } + return &out +} + +// composedSizes is what a composed mark costs a record that holds it. +func composedSizes(mark *ComposedMark) int64 { + if mark == nil { + return 0 + } + cost := int64(len(mark.MessageID)) + for _, media := range mark.Media { + cost += int64(len(media.ID)+len(media.Path)+len(media.MIME)+len(media.SHA256)+len(media.Role)) + 64 + if media.Source != nil { + cost += int64(len(media.Source.SourceID)+len(media.Source.Path)) + 16 + } + } + if note := mark.Annotation; note != nil { + cost += int64(len(note.Original)+len(note.Marked)+len(note.MarksPath)) + 16 + for _, m := range note.Marks { + cost += int64(len(m.Note)) + 96 + if m.Shade != nil { + cost += int64(len(m.Shade.Points)) * 16 + } + } + } + if mark.Delivery != nil { + cost += int64(len(mark.Delivery.Mode) + len(mark.Delivery.Model)) + } + return cost +} + +// ── reading the pictures ───────────────────────────────────────────────────── + +// newMessageID mints a message's durable identity. +func newMessageID() (string, error) { + var raw [12]byte + if _, err := rand.Read(raw[:]); err != nil { + return "", fmt.Errorf("session: could not mint a message id: %w", err) + } + return "msg_" + hex.EncodeToString(raw[:]), nil +} + +// sniffedImageTypes is what the bytes can say they are. The extension and any +// word a caller gave are not consulted: a path is a name, and a name is what a +// person or a link can make say anything. +var sniffedImageTypes = map[string]bool{"image/png": true, "image/jpeg": true, "image/webp": true, "image/gif": true} + +// readComposedImages reads the message's pictures, refusing before it allocates +// for anything it will refuse. Every file is stat'ed first — a regular file, not +// empty, not over the per-image limit — and the sizes are summed against the +// per-message limit before one byte of any is read, so a refused message costs +// the box a handful of syscalls and not the file. +func readComposedImages(ctx context.Context, media []SubmissionMedia, roles map[string]string) ([]Image, []MessageMedia, error) { + sizes := make([]int64, len(media)) + var total int64 + for index, item := range media { + path := strings.TrimSpace(item.Path) + info, err := os.Stat(path) + if err != nil || !info.Mode().IsRegular() { + return nil, nil, markError("%s is not a file this message can carry", filepath.Base(path)) + } + if info.Size() == 0 { + return nil, nil, markError("%s is empty", filepath.Base(path)) + } + if info.Size() > maxImageBytes { + return nil, nil, markError("%s is over the %s image limit", filepath.ToSlash(path), byteLimit(maxImageBytes)) + } + sizes[index] = info.Size() + total += info.Size() + if total > maxMessageImageBytes { + return nil, nil, markError("these pictures total more than the %s a single message may carry — send them across a few messages", byteLimit(maxMessageImageBytes)) + } + } + images := make([]Image, 0, len(media)) + kept := make([]MessageMedia, 0, len(media)) + for index, item := range media { + if err := ctx.Err(); err != nil { + return nil, nil, err + } + path := strings.TrimSpace(item.Path) + data, err := readSubmissionImage(path, sizes[index]) + if err != nil { + return nil, nil, markError("could not read %s", filepath.ToSlash(path)) + } + mediaType := http.DetectContentType(data[:min(len(data), 512)]) + if !sniffedImageTypes[mediaType] { + return nil, nil, markError("%s is not an image this surface can send — png, jpeg, webp and gif are", filepath.Base(path)) + } + sum := sha256.Sum256(data) + fact := MessageMedia{ + ID: strings.Clone(item.ID), Path: strings.Clone(path), MIME: mediaType, Bytes: int64(len(data)), + SHA256: hex.EncodeToString(sum[:]), Role: roles[item.ID], + } + if config, _, err := image.DecodeConfig(bytes.NewReader(data)); err == nil && config.Width > 0 && config.Height > 0 { + fact.Width, fact.Height = config.Width, config.Height + } + if item.Source != nil { + source := *item.Source + if item.Source.AtMS != nil { + at := *item.Source.AtMS + source.AtMS = &at + } + fact.Source = &source + } + images = append(images, Image{Path: path, MIME: mediaType, Bytes: data}) + kept = append(kept, fact) + } + return images, kept, nil +} + +// readSubmissionImage reads a file the caller already stat'ed into ONE buffer of exactly +// the size it was told, reading one byte past it to learn the file grew. A reader +// that appended its way up would allocate several times the picture to hold it. +func readSubmissionImage(path string, size int64) ([]byte, error) { + if size < 0 || size > maxImageBytes { + return nil, errors.New("outside the image limit") + } + file, err := os.Open(path) + if err != nil { + return nil, err + } + defer file.Close() + buf := make([]byte, size+1) + n, err := io.ReadFull(file, buf) + if err != nil && !errors.Is(err, io.ErrUnexpectedEOF) && !errors.Is(err, io.EOF) { + return nil, err + } + if int64(n) != size { + return nil, errors.New("changed size while being read") + } + return buf[:n], nil +} + +// imageDataURL is a picture as the content part carries it. The encoder writes into +// the builder it grew once, so the string is the only copy beside the file's own +// bytes: concatenating a prefix onto a separately encoded string would hold the +// picture three times at its peak. +func imageDataURL(mediaType string, data []byte) string { + prefix := "data:" + mediaType + ";base64," + var out strings.Builder + out.Grow(len(prefix) + base64.StdEncoding.EncodedLen(len(data))) + out.WriteString(prefix) + encoder := base64.NewEncoder(base64.StdEncoding, &out) + _, _ = encoder.Write(data) + _ = encoder.Close() + return out.String() +} + +// ── the door ───────────────────────────────────────────────────────────────── + +// SubmitComposed is [Agent.SubmitMessage] for a message that carries pictures and +// what the person marked on them: ONE user message, in the record once, with its +// words, its descriptors, its real image parts and its annotation together, and a +// durable id minted when it was accepted. +// +// IT REFUSES BEFORE IT RECORDS OR READS, in this order: a cancelled context, a +// description that does not fit, a spend rail that would refuse the turn, a +// picture that cannot be carried. A refusal leaves nothing behind, so a door that +// keeps its attachments staged until a message was accepted keeps them on every +// one of these. +// +// A MODEL THAT CANNOT SEE DOES NOT SILENTLY GET WORDS ALONE. The pictures go to +// the vision model the surface resolves ([Agent.visionSeer]), exactly as +// [Agent.SubmitImage] sends them, and the record says so: Delivery is +// [DeliveryDescription] and names the model that looked. A surface with no +// vision model gets SubmitImage's refusal and nothing is recorded. +// +// A message with neither pictures nor annotation is a plain message that still +// gets an id. +func (a *Agent) SubmitComposed(ctx context.Context, message Submission) (SubmissionReceipt, <-chan Event, error) { + if err := ctx.Err(); err != nil { + return SubmissionReceipt{}, nil, err + } + if err := message.Check(); err != nil { + return SubmissionReceipt{}, nil, err + } + id, err := newMessageID() + if err != nil { + return SubmissionReceipt{}, nil, err + } + if err := a.admissible(); err != nil { + return SubmissionReceipt{}, nil, err + } + + if len(message.Media) == 0 { + mark, err := message.composedMark(&ComposedMark{MessageID: id, Delivery: &MessageDelivery{Mode: DeliveryText}}) + if err != nil { + return SubmissionReceipt{}, nil, err + } + events, err := a.submitText(ctx, message.Text, mark) + if err != nil { + return SubmissionReceipt{}, nil, err + } + return SubmissionReceipt{MessageID: id, Delivery: DeliveryText}, events, nil + } + + roles := map[string]string{} + if note := message.Annotation; note != nil { + roles[note.Original] = MediaRoleOriginal + if note.Marked != "" { + roles[note.Marked] = MediaRoleMarked + } + } + // The model is read before the files: nothing is opened for a surface that + // has no one to show the pictures to. + model := a.Model() + sees := a.config.SupportsImages != nil && a.config.SupportsImages(model) + seer := "" + if !sees { + seer = a.visionSeer() + if seer == "" || strings.EqualFold(seer, model) { + return SubmissionReceipt{}, nil, blindRefusal(model) + } + } + images, facts, err := readComposedImages(ctx, message.Media, roles) + if err != nil { + return SubmissionReceipt{}, nil, err + } + + words := strings.TrimSpace(message.Text) + said := words + if note := a.mentionNote(words); note != "" && words != "" { + words += "\n\n" + note + } + user, err := imageUserMessage(words, images) + if err != nil { + return SubmissionReceipt{}, nil, markError("%s", strings.TrimPrefix(err.Error(), "session: ")) + } + if words != said { + user.said = said + } + + if sees { + mark, err := message.composedMark(&ComposedMark{MessageID: id, Media: facts, Delivery: &MessageDelivery{Mode: DeliveryImage, Model: model}}) + if err != nil { + return SubmissionReceipt{}, nil, err + } + user.person = mark + if digest := a.planDigest(); digest != "" { + user = planDigestedParts(digest, user) + } + events, queued, err := a.admitImageMessage(ctx, user) + if err != nil { + return SubmissionReceipt{}, nil, err + } + return SubmissionReceipt{MessageID: id, Delivery: DeliveryImage, Queued: queued, Media: cloneMedia(facts)}, events, nil + } + + mark, err := message.composedMark(&ComposedMark{MessageID: id, Media: facts, Delivery: &MessageDelivery{Mode: DeliveryDescription, Model: seer}}) + if err != nil { + return SubmissionReceipt{}, nil, err + } + events, err := a.visionTurnMarked(ctx, words, said, user, model, seer, mark) + if err != nil { + return SubmissionReceipt{}, nil, err + } + return SubmissionReceipt{MessageID: id, Delivery: DeliveryDescription, SeenBy: seer, Media: cloneMedia(facts)}, events, nil +} + +// composedMark is the submission's presentation mark with the composed half +// set: the same mark [Submission.mark] makes, or a bare person's mark when the +// submission said nothing beyond its pictures. +func (m Submission) composedMark(composed *ComposedMark) (*messagePresentation, error) { + mark, err := m.mark() + if err != nil { + return nil, err + } + if mark == nil { + mark = &messagePresentation{Audience: audiencePerson} + } + composed.Annotation = keptAnnotation(m.Annotation) + mark.Composed = composed + return mark, nil +} + +// admissible refuses, before anything is read or recorded, a message the agent +// could not take: a closed one, or — when no turn is running to steer into — one +// the spend rail would refuse. The rail's own check happens again where the turn +// starts; this one exists so a refused message is an error a door can leave its +// staged attachments alone on, rather than a stream that reports it. +func (a *Agent) admissible() error { + a.mu.Lock() + defer a.mu.Unlock() + if a.closed { + return errors.New("session: agent is closed") + } + if a.running { + return nil + } + return a.railBlockLocked() +} diff --git a/internal/session/submission_media_test.go b/internal/session/submission_media_test.go new file mode 100644 index 0000000000..e29c5aaf86 --- /dev/null +++ b/internal/session/submission_media_test.go @@ -0,0 +1,554 @@ +package session + +// One message: words, pictures and what the person marked on them. +// +// These tests drive the real Agent against a scripted completer — the request the +// fake receives is the request the SDK would marshal — and read the real journal +// back. Nothing here calls a provider. + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "errors" + "image" + "image/color" + "image/png" + "math" + "os" + "path/filepath" + "runtime" + "strings" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" +) + +// writePNG writes a real, decodable picture of the given size and returns its path. +func writePNG(t *testing.T, directory, name string, width, height int, shade uint8) string { + t.Helper() + canvas := image.NewRGBA(image.Rect(0, 0, width, height)) + for y := 0; y < height; y++ { + for x := 0; x < width; x++ { + canvas.Set(x, y, color.RGBA{R: shade, G: uint8(x), B: uint8(y), A: 255}) + } + } + var out bytes.Buffer + if err := png.Encode(&out, canvas); err != nil { + t.Fatalf("encode png: %v", err) + } + path := filepath.Join(directory, name) + if err := os.WriteFile(path, out.Bytes(), 0o644); err != nil { + t.Fatalf("write %s: %v", name, err) + } + return path +} + +// sparse makes a file of the given size that costs the disk nothing, so a size +// bound can be tested without a test that is itself heavy. +func sparse(t *testing.T, directory, name string, size int64) string { + t.Helper() + path := filepath.Join(directory, name) + file, err := os.Create(path) + if err != nil { + t.Fatal(err) + } + if err := file.Truncate(size); err != nil { + t.Fatal(err) + } + if err := file.Close(); err != nil { + t.Fatal(err) + } + return path +} + +// markedSubmission is the message a mark-up sends: the words, the original, the +// marked copy and two marks. +func markedSubmission(original, marked string) Submission { + said := "make the sky warmer" + area := 6.0 + return Submission{ + Text: "[image 1] original.png\n[image 2] original.marked.png\nMarks, in picture coordinates (0–100):\n1 pin at (24, 18): \"warmer\"\nChange only what I marked.", + Said: &said, + Attachments: []MessageAttachment{ + {ID: "at1", Kind: "file", Name: "original.png", Path: original}, + {ID: "at2", Kind: "file", Name: "original.marked.png", Path: marked}, + }, + Media: []SubmissionMedia{ + {ID: "at1", Path: original, Source: &MediaReference{SourceID: "src_abc", Path: original}}, + {ID: "at2", Path: marked}, + }, + Annotation: &Annotation{ + Original: "at1", Marked: "at2", MarksPath: filepath.Join(filepath.Dir(original), "original.marks.json"), + ChangeOnlyMarked: true, + Marks: []AnnotationMark{ + {Kind: "pin", Note: "warmer", At: &Point{X: 24, Y: 18}}, + {Kind: "box", From: &Point{X: 70, Y: 56}, To: &Point{X: 82, Y: 76}}, + {Kind: "shade", Note: "dim this", Shade: &Shade{Points: []Point{{X: 14, Y: 82}, {X: 16, Y: 85}}, BrushWidth: 4, AreaPercent: &area}}, + }, + }, + } +} + +// journalUserLines returns every user message line of a journal. +func journalUserLines(t *testing.T, path string) []sessionEntry { + t.Helper() + raw, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read journal: %v", err) + } + var lines []sessionEntry + for _, line := range strings.Split(strings.TrimSpace(string(raw)), "\n") { + var entry sessionEntry + if json.Unmarshal([]byte(line), &entry) == nil && entry.Type == "message" && entry.Role == "user" { + lines = append(lines, entry) + } + } + return lines +} + +func TestAComposedMessageIsOneJournaledMessageWithRealPartsAndMarks(t *testing.T) { + completer := &scriptedCompleter{} + journal := filepath.Join(t.TempDir(), "session.jsonl") + agent, workspace := newTestAgent(t, completer, func(config *Config) { + withVision(config) + config.SessionFile = journal + }) + original := writePNG(t, workspace, "original.png", 40, 30, 10) + marked := writePNG(t, workspace, "original.marked.png", 40, 30, 200) + + ctx, cancel := deadline(10 * time.Second) + defer cancel() + receipt, events, err := agent.SubmitComposed(ctx, markedSubmission(original, marked)) + if err != nil { + t.Fatalf("SubmitComposed: %v", err) + } + collect(t, events) + + if !strings.HasPrefix(receipt.MessageID, "msg_") || len(receipt.MessageID) != 28 { + t.Fatalf("message id = %q, want msg_ and 24 hex", receipt.MessageID) + } + if receipt.Delivery != DeliveryImage || receipt.SeenBy != "" || receipt.Queued { + t.Fatalf("receipt = %+v, want image delivery seen by nobody else", receipt) + } + + // The model's request: the words, then the two pictures, as image_url parts. + request := completer.request(0) + sent := request[len(request)-1] + if len(sent.Content) != 3 || sent.Content[0].Type != "text" || !strings.Contains(sent.Content[0].Text, "Marks, in picture coordinates") { + t.Fatalf("sent content = %+v, want the words then two pictures", sent.Content) + } + urls := imagePartURLs(sent) + if len(urls) != 2 || !strings.HasPrefix(urls[0], "data:image/png;base64,") || urls[0] == urls[1] { + t.Fatalf("sent pictures = %d, want two distinct png data urls", len(urls)) + } + + // The live reading: one user entry, with the id, the measured pictures and + // the marks, and the person's own words. + var users []DisplayEntry + for _, entry := range agent.Transcript() { + if entry.Role == "user" { + users = append(users, entry) + } + } + if len(users) != 1 { + t.Fatalf("the transcript holds %d user entries, want exactly one", len(users)) + } + person := users[0].Person + if person == nil || person.Composed == nil || person.Composed.MessageID != receipt.MessageID { + t.Fatalf("person mark = %+v, want the receipt's id", person) + } + composed := person.Composed + if users[0].Text != "make the sky warmer" || !person.Authored { + t.Fatalf("entry text = %q authored=%v, want the person's own words", users[0].Text, person.Authored) + } + if len(composed.Media) != 2 || composed.Media[0].Width != 40 || composed.Media[0].Height != 30 || + composed.Media[0].MIME != "image/png" || composed.Media[0].Role != MediaRoleOriginal || + composed.Media[1].Role != MediaRoleMarked || composed.Media[0].SHA256 == "" || + composed.Media[0].Source == nil || composed.Media[0].Source.SourceID != "src_abc" { + t.Fatalf("media facts = %+v", composed.Media) + } + if composed.Delivery == nil || composed.Delivery.Mode != DeliveryImage || composed.Delivery.Model != "test/model" { + t.Fatalf("delivery = %+v", composed.Delivery) + } + if composed.Annotation == nil || len(composed.Annotation.Marks) != 3 || !composed.Annotation.ChangeOnlyMarked || + composed.Annotation.Marks[2].Shade.BrushWidth != 4 { + t.Fatalf("annotation = %+v", composed.Annotation) + } + + if err := agent.Close(); err != nil { + t.Fatalf("Close: %v", err) + } + + // The journal: exactly one user line, parts as references, the mark on the + // same line, and no bytes anywhere. + lines := journalUserLines(t, journal) + if len(lines) != 1 { + t.Fatalf("the journal holds %d user lines, want one", len(lines)) + } + if len(lines[0].Parts) != 2 || lines[0].Parts[0].Path != original || lines[0].Parts[0].SHA256 == "" { + t.Fatalf("journal parts = %+v", lines[0].Parts) + } + if lines[0].Presentation == nil || lines[0].Presentation.Composed == nil || lines[0].Presentation.Composed.MessageID != receipt.MessageID { + t.Fatalf("journal presentation = %+v", lines[0].Presentation) + } + raw, _ := os.ReadFile(journal) + if strings.Contains(string(raw), "data:image") || strings.Contains(string(raw), "base64") { + t.Fatal("the journal holds picture bytes") + } + + // Reopened for display: the same id, once, with the references and no file + // opened (the files are removed first to prove it). + if err := os.Remove(original); err != nil { + t.Fatal(err) + } + record := ReadTranscript(journal) + reopened := 0 + for _, entry := range record.Entries { + if entry.Role != "user" { + continue + } + reopened++ + if entry.Person == nil || entry.Person.Composed == nil || entry.Person.Composed.MessageID != receipt.MessageID || + len(entry.Person.Composed.Annotation.Marks) != 3 || len(entry.ImageRefs) != 2 { + t.Fatalf("reopened entry = %+v person=%+v", entry, entry.Person) + } + } + if reopened != 1 { + t.Fatalf("reopened %d user entries, want one", reopened) + } +} + +func TestAResumedComposedMessageSendsTheSameBytes(t *testing.T) { + completer := &scriptedCompleter{} + journal := filepath.Join(t.TempDir(), "session.jsonl") + agent, workspace := newTestAgent(t, completer, func(config *Config) { + withVision(config) + config.SessionFile = journal + }) + original := writePNG(t, workspace, "a.png", 8, 8, 1) + marked := writePNG(t, workspace, "b.png", 8, 8, 2) + ctx, cancel := deadline(10 * time.Second) + defer cancel() + _, events, err := agent.SubmitComposed(ctx, markedSubmission(original, marked)) + if err != nil { + t.Fatal(err) + } + collect(t, events) + live := imagePartURLs(agent.messages[1]) + if err := agent.Close(); err != nil { + t.Fatal(err) + } + replayed, err := replaySessionFile(journal) + if err != nil { + t.Fatal(err) + } + restored := imagePartURLs(replayed.messages[0]) + if len(restored) != 2 || restored[0] != live[0] || restored[1] != live[1] { + t.Fatalf("restored pictures differ from the ones first sent (%d vs %d)", len(restored), len(live)) + } +} + +func TestABlindTalkModelReadsTheSeersWordsThroughTheConfiguredVisionModel(t *testing.T) { + completer := &scriptedCompleter{} + journal := filepath.Join(t.TempDir(), "session.jsonl") + agent, workspace := newTestAgent(t, completer, func(config *Config) { + config.SupportsImages = func(model string) bool { return model == "vendor/seer" } + config.MediaModel = func(modality string) string { + if modality == "vision" { + return "vendor/seer" + } + return "" + } + config.SessionFile = journal + }) + original := writePNG(t, workspace, "a.png", 8, 8, 1) + marked := writePNG(t, workspace, "b.png", 8, 8, 2) + + ctx, cancel := deadline(10 * time.Second) + defer cancel() + submission := markedSubmission(original, marked) + receipt, events, err := agent.SubmitComposed(ctx, submission) + if err != nil { + t.Fatalf("SubmitComposed: %v", err) + } + collect(t, events) + + if receipt.Delivery != DeliveryDescription || receipt.SeenBy != "vendor/seer" { + t.Fatalf("receipt = %+v, want a description by the configured vision model", receipt) + } + if completer.model(0) != "vendor/seer" { + t.Fatalf("the look rode %q, want the configured vision model", completer.model(0)) + } + seen := completer.request(0) + if len(seen) != 1 || len(imagePartURLs(seen[0])) != 2 || !strings.Contains(seen[0].Content[0].Text, "Marks, in picture coordinates") { + t.Fatalf("the seer was sent %+v, want the person's words and both pictures", seen) + } + + // The talking model's transcript keeps the words it was sent (the marks text) and names the + // files; it never holds the pictures. + agent.mu.Lock() + kept := agent.messages[1] + agent.mu.Unlock() + if len(imagePartURLs(kept)) != 0 || !strings.Contains(messageContentText(kept), "Marks, in picture coordinates") || + !strings.Contains(messageContentText(kept), "[attached images: a.png, b.png]") { + t.Fatalf("kept message = %+v", kept) + } + if err := agent.Close(); err != nil { + t.Fatal(err) + } + lines := journalUserLines(t, journal) + if len(lines) != 1 || lines[0].Presentation == nil || lines[0].Presentation.Composed == nil { + t.Fatalf("journal lines = %+v", lines) + } + composed := lines[0].Presentation.Composed + if composed.MessageID != receipt.MessageID || composed.Delivery == nil || + composed.Delivery.Mode != DeliveryDescription || composed.Delivery.Model != "vendor/seer" || + len(composed.Annotation.Marks) != 3 || len(lines[0].Parts) != 2 { + t.Fatalf("recorded composed mark = %+v parts=%d", composed, len(lines[0].Parts)) + } +} + +func TestABlindModelWithNoSeerRefusesAndRecordsNothing(t *testing.T) { + completer := &scriptedCompleter{} + journal := filepath.Join(t.TempDir(), "session.jsonl") + agent, workspace := newTestAgent(t, completer, func(config *Config) { + config.SupportsImages = func(string) bool { return false } + config.MediaModel = func(string) string { return "" } + config.SessionFile = journal + }) + original := writePNG(t, workspace, "a.png", 8, 8, 1) + marked := writePNG(t, workspace, "b.png", 8, 8, 2) + ctx, cancel := deadline(10 * time.Second) + defer cancel() + _, events, err := agent.SubmitComposed(ctx, markedSubmission(original, marked)) + if err == nil || events != nil || !strings.Contains(err.Error(), "test/model") { + t.Fatalf("err=%v events=%v, want the blind refusal naming the model", err, events) + } + if completer.requests() != 0 || len(journalUserLines(t, journal)) != 0 { + t.Fatal("a refused composed message called a model or was recorded") + } +} + +func TestAnInvalidSubmissionIsRefusedBeforeAnythingIsReadOrRecorded(t *testing.T) { + nan := math.NaN() + for name, mutate := range map[string]func(*Submission){ + "nan coordinate": func(s *Submission) { s.Annotation.Marks[0].At.X = nan }, + "infinite coordinate": func(s *Submission) { s.Annotation.Marks[0].At.Y = math.Inf(1) }, + "coordinate over 100": func(s *Submission) { s.Annotation.Marks[0].At.X = 100.5 }, + "negative coordinate": func(s *Submission) { s.Annotation.Marks[1].From.X = -1 }, + "inverted box": func(s *Submission) { s.Annotation.Marks[1].To.X = 10 }, + "unknown kind": func(s *Submission) { s.Annotation.Marks[0].Kind = "arrow" }, + "pin with a box": func(s *Submission) { s.Annotation.Marks[0].From = &Point{X: 1, Y: 1} }, + "shade without points": func(s *Submission) { s.Annotation.Marks[2].Shade.Points = nil }, + "shade too many points": func(s *Submission) { s.Annotation.Marks[2].Shade.Points = make([]Point, MaxShadePoints+1) }, + "shade zero brush": func(s *Submission) { s.Annotation.Marks[2].Shade.BrushWidth = 0 }, + "note too long": func(s *Submission) { s.Annotation.Marks[0].Note = strings.Repeat("x", MaxMarkNote+1) }, + "note not utf8": func(s *Submission) { s.Annotation.Marks[0].Note = "\xff\xfe" }, + "too many marks": func(s *Submission) { + s.Annotation.Marks = make([]AnnotationMark, MaxAnnotationMarks+1) + for i := range s.Annotation.Marks { + s.Annotation.Marks[i] = AnnotationMark{Kind: "pin", At: &Point{}} + } + }, + "no marks": func(s *Submission) { s.Annotation.Marks = nil }, + "original not a picture": func(s *Submission) { s.Annotation.Original = "at9" }, + "marked is the original": func(s *Submission) { s.Annotation.Marked = "at1" }, + "duplicate picture id": func(s *Submission) { s.Media[1].ID = "at1" }, + "picture without a path": func(s *Submission) { s.Media[0].Path = " " }, + "negative moment": func(s *Submission) { at := int64(-1); s.Media[0].Source.AtMS = &at }, + "too many pictures": func(s *Submission) { s.Media = append(s.Media, make([]SubmissionMedia, MaxSubmissionMedia)...) }, + "annotation without media": func(s *Submission) { s.Media = nil }, + } { + t.Run(name, func(t *testing.T) { + completer := &scriptedCompleter{} + journal := filepath.Join(t.TempDir(), "session.jsonl") + agent, workspace := newTestAgent(t, completer, func(config *Config) { + withVision(config) + config.SessionFile = journal + }) + submission := markedSubmission(writePNG(t, workspace, "a.png", 8, 8, 1), writePNG(t, workspace, "b.png", 8, 8, 2)) + mutate(&submission) + ctx, cancel := deadline(10 * time.Second) + defer cancel() + _, events, err := agent.SubmitComposed(ctx, submission) + if !errors.Is(err, ErrMessageMark) || events != nil { + t.Fatalf("err=%v events=%v, want a refusal wrapping ErrMessageMark", err, events) + } + if completer.requests() != 0 || len(journalUserLines(t, journal)) != 0 { + t.Fatal("a refused submission called a model or was recorded") + } + }) + } +} + +func TestAPictureThatCannotBeCarriedIsRefusedWithoutBeingRead(t *testing.T) { + completer := &scriptedCompleter{} + journal := filepath.Join(t.TempDir(), "session.jsonl") + agent, workspace := newTestAgent(t, completer, func(config *Config) { + withVision(config) + config.SessionFile = journal + }) + good := writePNG(t, workspace, "good.png", 8, 8, 1) + notAnImage := writeImage(t, workspace, "fake.png", "this is text wearing a png name") + directory := filepath.Join(workspace, "folder.png") + if err := os.Mkdir(directory, 0o755); err != nil { + t.Fatal(err) + } + huge := sparse(t, workspace, "huge.png", 64<<20) + + ctx, cancel := deadline(10 * time.Second) + defer cancel() + for name, path := range map[string]string{"text with a png name": notAnImage, "a folder": directory, "missing": filepath.Join(workspace, "gone.png")} { + _, events, err := agent.SubmitComposed(ctx, Submission{Text: "look", Media: []SubmissionMedia{{ID: "a", Path: good}, {ID: "b", Path: path}}}) + if !errors.Is(err, ErrMessageMark) || events != nil { + t.Fatalf("%s: err=%v, want a refusal", name, err) + } + } + + // A 64MB file is refused on its stat. The refusal must not allocate for it: + // the whole call stays within a megabyte of allocation. + var before, after runtime.MemStats + runtime.ReadMemStats(&before) + _, _, err := agent.SubmitComposed(ctx, Submission{Text: "look", Media: []SubmissionMedia{{ID: "a", Path: huge}}}) + runtime.ReadMemStats(&after) + if !errors.Is(err, ErrMessageMark) || !strings.Contains(err.Error(), "image limit") { + t.Fatalf("err = %v, want the image limit", err) + } + if grown := after.TotalAlloc - before.TotalAlloc; grown > 1<<20 { + t.Fatalf("refusing a 64MB file allocated %d bytes", grown) + } + + // Three pictures each under the per-image limit but over the message's are + // refused on their stats too, before the first is read. + runtime.ReadMemStats(&before) + media := []SubmissionMedia{} + for i, name := range []string{"s1.png", "s2.png", "s3.png"} { + media = append(media, SubmissionMedia{ID: string(rune('a' + i)), Path: sparse(t, workspace, name, 9<<20)}) + } + _, _, err = agent.SubmitComposed(ctx, Submission{Text: "look", Media: media}) + runtime.ReadMemStats(&after) + if !errors.Is(err, ErrMessageMark) || !strings.Contains(err.Error(), "a single message may carry") { + t.Fatalf("err = %v, want the message limit", err) + } + if grown := after.TotalAlloc - before.TotalAlloc; grown > 1<<20 { + t.Fatalf("refusing 27MB of pictures allocated %d bytes", grown) + } + if completer.requests() != 0 || len(journalUserLines(t, journal)) != 0 { + t.Fatal("a refused picture called a model or was recorded") + } +} + +func TestACancelledSubmissionIsRefusedAndRecordsNothing(t *testing.T) { + completer := &scriptedCompleter{} + journal := filepath.Join(t.TempDir(), "session.jsonl") + agent, workspace := newTestAgent(t, completer, func(config *Config) { + withVision(config) + config.SessionFile = journal + }) + original := writePNG(t, workspace, "a.png", 8, 8, 1) + marked := writePNG(t, workspace, "b.png", 8, 8, 2) + ctx, cancel := context.WithCancel(context.Background()) + cancel() + _, events, err := agent.SubmitComposed(ctx, markedSubmission(original, marked)) + if !errors.Is(err, context.Canceled) || events != nil { + t.Fatalf("err=%v, want the cancellation", err) + } + if completer.requests() != 0 || len(journalUserLines(t, journal)) != 0 { + t.Fatal("a cancelled submission called a model or was recorded") + } +} + +func TestAComposedMessageSteersARunningTurnAndIsRecordedOnce(t *testing.T) { + started, release := make(chan struct{}), make(chan struct{}) + completer := &scriptedCompleter{steps: []step{ + func(ctx context.Context, messages []ai.Message) (*ai.Response, error) { + close(started) + <-release + return toolResponse("call-1", "todo", `{"action":"list"}`), nil + }, + func(ctx context.Context, messages []ai.Message) (*ai.Response, error) { + return textResponse("done"), nil + }, + }} + journal := filepath.Join(t.TempDir(), "session.jsonl") + agent, workspace := newTestAgent(t, completer, func(config *Config) { + withVision(config) + config.SessionFile = journal + }) + original := writePNG(t, workspace, "a.png", 8, 8, 1) + marked := writePNG(t, workspace, "b.png", 8, 8, 2) + + ctx, cancel := deadline(10 * time.Second) + defer cancel() + events, err := agent.Submit(ctx, "start working") + if err != nil { + t.Fatal(err) + } + <-started + receipt, steered, err := agent.SubmitComposed(ctx, markedSubmission(original, marked)) + if err != nil || steered == nil || !receipt.Queued { + t.Fatalf("receipt=%+v err=%v, want a queued message", receipt, err) + } + close(release) + collect(t, events) + + lines := journalUserLines(t, journal) + var composed []sessionEntry + for _, line := range lines { + if line.Presentation != nil && line.Presentation.Composed != nil { + composed = append(composed, line) + } + } + if len(composed) != 1 || composed[0].Presentation.Composed.MessageID != receipt.MessageID || len(composed[0].Parts) != 2 { + t.Fatalf("composed lines = %+v", composed) + } +} + +func TestSubmitMessageNeverDropsPicturesToFitAPlainDoor(t *testing.T) { + completer := &scriptedCompleter{} + agent, workspace := newTestAgent(t, completer, withVision) + path := writePNG(t, workspace, "a.png", 8, 8, 1) + ctx, cancel := deadline(10 * time.Second) + defer cancel() + events, err := agent.SubmitMessage(ctx, Submission{Text: "look", Media: []SubmissionMedia{{ID: "x", Path: path}}}) + if err != nil { + t.Fatal(err) + } + collect(t, events) + request := completer.request(0) + if len(imagePartURLs(request[len(request)-1])) != 1 { + t.Fatal("SubmitMessage dropped the picture") + } +} + +func TestAPlainComposedMessageStillGetsADurableID(t *testing.T) { + completer := &scriptedCompleter{} + journal := filepath.Join(t.TempDir(), "session.jsonl") + agent, _ := newTestAgent(t, completer, func(config *Config) { config.SessionFile = journal }) + ctx, cancel := deadline(10 * time.Second) + defer cancel() + receipt, events, err := agent.SubmitComposed(ctx, Submission{Text: "just words"}) + if err != nil { + t.Fatal(err) + } + collect(t, events) + if receipt.Delivery != DeliveryText || receipt.MessageID == "" { + t.Fatalf("receipt = %+v", receipt) + } + if err := agent.Close(); err != nil { + t.Fatal(err) + } + lines := journalUserLines(t, journal) + if len(lines) != 1 || lines[0].Presentation.Composed.MessageID != receipt.MessageID || len(lines[0].Parts) != 0 { + t.Fatalf("lines = %+v", lines) + } +} + +func sha256Hex(data []byte) string { + sum := sha256.Sum256(data) + return hex.EncodeToString(sum[:]) +} From a07fc5e09b31171dd121719421810fa0e01bc659 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 23:52:17 -0400 Subject: [PATCH 135/459] catalog: read OpenRouter's dedicated image and video listings beside /models GET /models has no video outputs and 16 of 59 image generators. Read /images/models and /videos/models, bounded (4 MiB, 500 rows, shared 15s deadline, two joined requests), only for the OpenRouter source on its own base, and merge them over the general rows: dedicated modalities and parameters win, a chat row keeps its text side and token prices, a pure generator never gains text. Per-second video rates are typed MediaPrice units from the cents_per_* skus only; every other sku stays unknown. A listing that fails keeps its previous rows, is reported stale, and is never proof of absence. Single-model ImageEndpointPrices is lazy and explicit. Co-Authored-By: Claude Sonnet 5.5 --- internal/catalog/catalog.go | 136 ++- internal/catalog/lazy_test.go | 7 +- internal/catalog/media.go | 809 ++++++++++++++++++ internal/catalog/media_test.go | 767 +++++++++++++++++ internal/catalog/openrouter_test.go | 8 +- .../openrouter-image-klein-endpoints.json | 1 + .../openrouter-image-seedream-endpoints.json | 1 + .../catalog/testdata/openrouter-images.json | 309 +++++++ .../catalog/testdata/openrouter-videos.json | 336 ++++++++ 9 files changed, 2359 insertions(+), 15 deletions(-) create mode 100644 internal/catalog/media.go create mode 100644 internal/catalog/media_test.go create mode 100644 internal/catalog/testdata/openrouter-image-klein-endpoints.json create mode 100644 internal/catalog/testdata/openrouter-image-seedream-endpoints.json create mode 100644 internal/catalog/testdata/openrouter-images.json create mode 100644 internal/catalog/testdata/openrouter-videos.json diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go index ec1ac7e4d9..7b669d14ad 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -155,6 +155,17 @@ type Model struct { // kept and on a row the provider published nothing for — see // [Catalog.ReasoningProfile]. Reasoning ReasoningProfile `json:"reasoning,omitempty"` + // Dedicated says which dedicated media listings ("image", "video") this row + // was also found in. Those listings are authoritative for the media side of + // the row — see media.go — and say nothing about its chat side. + Dedicated []string `json:"dedicated,omitempty"` + // Media is what a dedicated listing published about the model's knobs, nil + // when none did. + Media *MediaParams `json:"media_params,omitempty"` + // MediaPrices are the per-second and per-image prices a dedicated listing + // published, in their own units. They are NOT per-token prices and share no + // field with PromptPrice; empty means nobody published one, never free. + MediaPrices []MediaPrice `json:"media_prices,omitempty"` } // ReasoningProfile is the provider's own account of a model's thinking pass. @@ -237,6 +248,9 @@ type cache struct { // A cache seeded from model ids alone ([Remember]), and one written before // this mark existed, knows NO price: its zeros are absences, never free. Priced bool `json:"priced,omitempty"` + // Media is the dedicated image and video listings' own compartment, nil in a + // cache written before they were read. See media.go. + Media *mediaCache `json:"media,omitempty"` } // FetchTimeout bounds one listing request on the default HTTP client. A caller @@ -268,11 +282,19 @@ type Options struct { // facts than it had. Refresh bool + // NoDedicatedMedia keeps this catalog to the general /models listing even on + // the OpenRouter source, for the caller that wants exactly the old behaviour. + // Every other source never reads the dedicated listings whatever this says. + NoDedicatedMedia bool + // serveStale answers a cache past [TTL] as it stands instead of fetching // in front of it. Only [LoadLazy] sets it, because only a lazy catalog has // somewhere to put the refresh: its own warming goroutine, behind the // answer. See [LoadLazy] for why a day-old catalog must not hold a frame. serveStale bool + // mediaOnly asks [load] to top up the dedicated listings of the cache it has + // and leave the general rows alone. Only the lazy warm sets it. + mediaOnly bool } // Catalog is immutable once resolved and therefore safe to share among the @@ -318,6 +340,9 @@ type rows struct { // network at all — the built-in fallbacks — and a reader must say so rather // than print the epoch. fetchedAt time.Time + // media is the standing of the dedicated image and video listings; the zero + // value says neither was ever read. + media MediaState } // Load fetches at most once. A fresh cache avoids I/O; a failed fetch degrades @@ -359,8 +384,18 @@ func Remember(options Options, models []Model) error { } source := strings.TrimSpace(options.Source) base := normalizeBase(options.BaseURL) - return writeCache(cachePath(options.Dir, source, base), cache{ - FetchedAt: now().UTC(), Models: models, Source: source, Base: base, + path := cachePath(options.Dir, source, base) + // A second read must never erase what the dedicated listings already proved, + // so their pure rows are carried over and laid back on the rows given. + var media *mediaCache + if mediaEligible(options, source, base) { + if previous, ok := readCache(path, source, base); ok { + media = previous.Media + models = mergeMedia(models, media) + } + } + return writeCache(path, cache{ + FetchedAt: now().UTC(), Models: models, Source: source, Base: base, Media: media, }) } @@ -375,7 +410,17 @@ func Recall(options Options) *Catalog { if !ok { return &Catalog{ready: newRows(nil)} } - return &Catalog{ready: newRowsAt(cached.Models, cached.FetchedAt)} + return &Catalog{ready: cachedRows(cached, options, time.Now())} +} + +// cachedRows is the rows a cache file holds, with the standing of the dedicated +// listings it recorded. +func cachedRows(cached cache, options Options, now time.Time) *rows { + resolved := newRowsAt(cached.Models, cached.FetchedAt) + if mediaEligible(options, strings.TrimSpace(options.Source), normalizeBase(options.BaseURL)) { + resolved.media = mediaStateOf(cached.Media, now) + } + return resolved } // errUnreadable is what a fault inside discovery is reported as. The fault @@ -432,7 +477,7 @@ func LoadLazy(ctx context.Context, options Options) *Catalog { base, source := normalizeBase(options.BaseURL), strings.TrimSpace(options.Source) var preview *rows if cached, ok := readCache(cachePath(options.Dir, source, base), source, base); ok { - preview = newRowsAt(cached.Models, cached.FetchedAt) + preview = cachedRows(cached, options, nowOf(options)()) } else if source == "" && base == DefaultBaseURL { preview = newRows(hardcodedFallbacks()) } @@ -457,7 +502,16 @@ func LoadLazy(ctx context.Context, options Options) *Catalog { resolved.resolve = resolve guard.Go("catalog/warm", func() { defer close(resolved.warmDone) - if !stale(resolve(), options) { + answered := resolve() + if !stale(answered, options) { + // Rows fresh in every way the clock can tell may still lack a + // dedicated listing nobody has read yet (a cache older than this + // build). That is topped up alone, never by refetching the chat rows. + if answered != nil && answered.media.due() { + topUp := options + topUp.mediaOnly = true + _, _ = loadOrFallback(warmCtx, topUp) + } return } // The refresh the answer did not wait for. Its rows go to the cache @@ -507,20 +561,39 @@ func (c *Catalog) Close() { // network and did not land. A fresh cache that needed no fetch is not a failure // and answers nil. func load(ctx context.Context, options Options) (*rows, error) { - now := time.Now - if options.Now != nil { - now = options.Now - } + now := nowOf(options) base := normalizeBase(options.BaseURL) source := strings.TrimSpace(options.Source) path := cachePath(options.Dir, source, base) cached, cachedOK := readCache(path, source, base) + eligible := mediaEligible(options, source, base) + if cachedOK && eligible && options.mediaOnly { + return topUpMedia(ctx, options, cached, path, source, base) + } if cachedOK && !options.Refresh && (options.serveStale || now().Before(cached.FetchedAt.Add(TTL))) { - return newRowsAt(cached.Models, cached.FetchedAt), nil + // A fresh cache is served without any request, as it always was, even + // when it has never read a dedicated listing (an older build wrote it): + // the lazy warm tops those up behind the answer, and an eager caller + // gets them on the next refresh. + return cachedRows(cached, options, now()), nil } // fetch refuses an empty listing itself, so a nil error here is always rows. models, err := fetch(ctx, options) + var ( + media *mediaCache + state MediaState + ) + if err == nil && eligible { + // The dedicated listings ride behind a general listing that landed. A + // failure of either keeps that listing's previous rows and is reported + // stale; it can never fail the general fetch or empty the catalog. + var prior *mediaCache + if cachedOK { + prior = cached.Media + } + models, media, state = refreshMedia(ctx, options, models, prior, now().UTC()) + } if err == nil { // A transport may report success after cancellation. Closing the owner // must still prevent that late response from changing persistent state. @@ -529,16 +602,18 @@ func load(ctx context.Context, options Options) (*rows, error) { if err == nil { fetchedAt := now().UTC() if path != "" { - _ = writeCache(path, cache{FetchedAt: fetchedAt, Models: models, Source: source, Base: base, Priced: true}) + _ = writeCache(path, cache{FetchedAt: fetchedAt, Models: models, Source: source, Base: base, Priced: true, Media: media}) } - return newRowsAt(models, fetchedAt), nil + resolved := newRowsAt(models, fetchedAt) + resolved.media = state + return resolved, nil } if cachedOK { // The network is gone and the cache is old. It is still the truest // answer anyone has, so it is served WITH ITS DATE rather than // withheld: a stale catalog a surface can date is worth more than an // empty one it cannot explain. - return newRowsAt(cached.Models, cached.FetchedAt), err + return cachedRows(cached, options, now()), err } if source == "" && base == DefaultBaseURL { return newRows(hardcodedFallbacks()), err @@ -549,6 +624,31 @@ func load(ctx context.Context, options Options) (*rows, error) { return newRows(nil), err } +// topUpMedia reads the dedicated listings over a cache whose general rows it +// leaves exactly as they were, and writes the result back. Whatever it cannot +// read stays as the cache had it, dated, and is reported stale. +func topUpMedia(ctx context.Context, options Options, cached cache, path, source, base string) (*rows, error) { + now := nowOf(options) + models, media, state := refreshMedia(ctx, options, cached.Models, cached.Media, now().UTC()) + if err := ctx.Err(); err != nil { + return cachedRows(cached, options, now()), err + } + if path != "" { + cached.Models, cached.Media = models, media + _ = writeCache(path, cached) + } + resolved := newRowsAt(models, cached.FetchedAt) + resolved.media = state + return resolved, nil +} + +func nowOf(options Options) func() time.Time { + if options.Now != nil { + return options.Now + } + return time.Now +} + // FetchedAt is when this catalog's rows left the provider, or the zero time // when they never did — an unloaded catalog, or the built-in fallbacks. A // surface that shows a model list may date it from here; nothing inside this @@ -1454,6 +1554,16 @@ func cloneModel(model Model) Model { model.InputModalities = append([]string(nil), model.InputModalities...) model.OutputModalities = append([]string(nil), model.OutputModalities...) model.Parameters = append([]string(nil), model.Parameters...) + model.Dedicated = append([]string(nil), model.Dedicated...) + model.MediaPrices = append([]MediaPrice(nil), model.MediaPrices...) + if model.Media != nil { + params := *model.Media + params.Resolutions = append([]string(nil), params.Resolutions...) + params.AspectRatios = append([]string(nil), params.AspectRatios...) + params.Durations = append([]int(nil), params.Durations...) + params.FrameImages = append([]string(nil), params.FrameImages...) + model.Media = ¶ms + } return model } diff --git a/internal/catalog/lazy_test.go b/internal/catalog/lazy_test.go index db7275f307..905d965395 100644 --- a/internal/catalog/lazy_test.go +++ b/internal/catalog/lazy_test.go @@ -77,7 +77,12 @@ func TestWarmedWaitsForACachedCatalogWithinTheBound(t *testing.T) { }); err != nil { t.Fatal(err) } - lazy := LoadLazy(context.Background(), Options{Dir: dir}) + // The cache is the default service's and carries no dedicated listings, so + // an unguarded warm would go to the real network to top them up (and write + // the cache after the test's directory is gone). This test is about the + // general warm only. + lazy := LoadLazy(context.Background(), Options{Dir: dir, NoDedicatedMedia: true}) + defer lazy.Close() ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) defer cancel() if !lazy.Warmed(ctx) { diff --git a/internal/catalog/media.go b/internal/catalog/media.go new file mode 100644 index 0000000000..b4a38314af --- /dev/null +++ b/internal/catalog/media.go @@ -0,0 +1,809 @@ +package catalog + +import ( + "context" + "encoding/json" + "errors" + "io" + "math" + "net/http" + "net/url" + "strconv" + "strings" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/guard" + "github.com/Agent-Field/codeaf/internal/modelsource" +) + +// THE DEDICATED MEDIA CATALOGS — what /models?output_modalities=all does not say. +// +// OpenRouter's general listing carries no video output at all, and only a +// sixteenth of the image generators: the rest are published by two sibling +// listings, GET /images/models (59 rows on 2026-10-15) and GET /videos/models +// (30). Their rows are shaped differently from a chat row — parameters are an +// object of enums and ranges, a video row has no architecture block, and +// prices are per unit of output (a second at 480p, a megapixel) rather than per +// token — so they are read here, merged into the one catalog every capability +// question already asks, and kept in their own cache compartment so a failure +// of one listing never costs the facts of another. +// +// WHAT THIS DOES NOT DO: it fetches no per-endpoint detail at startup (that is +// one request per model; see [ImageEndpointPrices] for the lazy, single-model +// door), it never posts anything, and it never speaks to a base URL that is not +// the OpenRouter source's own. + +const ( + // MaxMediaCatalogBytes bounds one dedicated listing before it is decoded. + // The live listings are 28 KB and 45 KB; this leaves two orders of + // magnitude and still refuses a response that was never a catalog. + MaxMediaCatalogBytes = 4 << 20 + // MaxMediaRows bounds how many rows one dedicated listing may carry. A + // listing past it is refused whole rather than truncated: a partial video + // list is not evidence of which videos exist. + MaxMediaRows = 500 + // MaxEndpointBytes and MaxEndpointRows bound the single-model endpoint + // detail the same way. + MaxEndpointBytes = 256 << 10 + MaxEndpointRows = 32 + + // mediaRetry is how soon a dedicated listing that failed is tried again by + // a load nobody explicitly asked to refresh, so an offline machine pays a + // failed request at most once per interval rather than once per launch. + mediaRetry = 15 * time.Minute + + kindImage = "image" + kindVideo = "video" +) + +// MediaPrice is one published price of a media model, in dollars per Unit. +// It is typed on purpose: a per-second video rate and a per-megapixel image +// rate are not per-token prices and must never be shown in a per-token column. +// An empty list on a model means NOBODY PUBLISHED ONE that this package can +// read, never that the model is free. +type MediaPrice struct { + // Billable is what is charged for: "video_output", "image_output" or + // "image_input". + Billable string `json:"billable"` + // Unit is what one USD figure buys: "second", "image" or "megapixel". + Unit string `json:"unit"` + // Variant narrows the rate, today only the output resolution ("480p"). + Variant string `json:"variant,omitempty"` + // Provider is the endpoint that published it; empty for a model-level rate. + Provider string `json:"provider,omitempty"` + USD float64 `json:"usd"` +} + +// MediaParams is what the dedicated listing publishes about the knobs of a +// media model. Every field is empty when unpublished, which means unknown. +type MediaParams struct { + Resolutions []string `json:"resolutions,omitempty"` + AspectRatios []string `json:"aspect_ratios,omitempty"` + // Durations are the output lengths in seconds a video model accepts. + Durations []int `json:"durations,omitempty"` + // FrameImages names the still frames a video model takes ("first_frame"). + FrameImages []string `json:"frame_images,omitempty"` + // MaxInputReferences is how many reference images an image model takes. + MaxInputReferences int `json:"max_input_references,omitempty"` + // MaxOutputs is how many images one request may return. + MaxOutputs int `json:"max_outputs,omitempty"` +} + +// MediaKind is what a catalog knows about one dedicated listing. +// +// Known false means the listing has NEVER been read on this install, so no +// absence from the catalog proves anything. Stale means the facts are older +// than [TTL] or the last attempt to refresh them failed; a stale kind still +// serves its rows, dated. +type MediaKind struct { + Known bool + Stale bool + FetchedAt time.Time + // due says a load that nobody insisted on may try the listing again now. + due bool +} + +// MediaState is the standing of both dedicated listings. +type MediaState struct { + Images, Videos MediaKind +} + +// Authoritative says the named kind ("image" or "video") was read and is +// current, so that a model's ABSENCE from the catalog may be reported as "no +// connected service offers it". Anything else — never read, failed, or old — +// must be reported as "could not confirm", because a transient failure is not +// proof of none. +func (s MediaState) Authoritative(kind string) bool { + switch strings.ToLower(strings.TrimSpace(kind)) { + case kindImage, "images": + return s.Images.Known && !s.Images.Stale + case kindVideo, "videos": + return s.Videos.Known && !s.Videos.Stale + } + return false +} + +// due says some listing is worth another request now. +func (s MediaState) due() bool { return s.Images.due || s.Videos.due } + +// mediaCache is the persisted half: the PURE dedicated rows, unmerged, beside +// when each listing was last read and last tried. Keeping them unmerged lets a +// later general refresh re-merge them over fresh chat rows without carrying +// forward a text capability the chat row has since lost. Absent in a cache +// written before this field existed, which reads as "never read". +type mediaCache struct { + Images mediaKindCache `json:"images"` + Videos mediaKindCache `json:"videos"` +} + +type mediaKindCache struct { + // FetchedAt is the last SUCCESS; zero is "never". + FetchedAt time.Time `json:"fetched_at"` + // TriedAt is the last attempt, success or not. + TriedAt time.Time `json:"tried_at"` + Rows []Model `json:"rows,omitempty"` +} + +// mediaEligible is the one gate on every dedicated request. These two paths +// exist on OpenRouter and are not an OpenAI-compatible convention, so a custom +// endpoint, another source, and a Codex compartment never see them. +func mediaEligible(options Options, source, base string) bool { + if options.NoDedicatedMedia || base != DefaultBaseURL { + return false + } + return source == "" || strings.EqualFold(source, modelsource.DefaultID) +} + +func mediaStateOf(cached *mediaCache, now time.Time) MediaState { + if cached == nil { + // Never read: due at once, once. + cached = &mediaCache{} + } + return MediaState{ + Images: kindStateOf(cached.Images, now), + Videos: kindStateOf(cached.Videos, now), + } +} + +func kindStateOf(kind mediaKindCache, now time.Time) MediaKind { + state := MediaKind{FetchedAt: kind.FetchedAt} + if kind.FetchedAt.IsZero() { + state.due = kind.TriedAt.IsZero() || !now.Before(kind.TriedAt.Add(mediaRetry)) + return state + } + state.Known = true + failedSince := kind.TriedAt.After(kind.FetchedAt) + state.Stale = failedSince || !now.Before(kind.FetchedAt.Add(TTL)) + state.due = state.Stale && (kind.TriedAt.IsZero() || !now.Before(kind.TriedAt.Add(mediaRetry))) + return state +} + +// refreshMedia reads both dedicated listings (at most two requests, in +// parallel, joined before it returns) and merges them over models. A listing +// that fails keeps the previous rows and date of its compartment and is +// reported stale; it never empties the catalog. +func refreshMedia(ctx context.Context, options Options, models []Model, prior *mediaCache, now time.Time) ([]Model, *mediaCache, MediaState) { + next := &mediaCache{} + if prior != nil { + *next = *prior + } + // The two requests share one deadline, so the whole top-up is bounded by a + // single fetch timeout rather than two. + ctx, cancel := context.WithTimeout(ctx, FetchTimeout) + defer cancel() + var ( + wg sync.WaitGroup + imageRows, videoRows []Model + imageErr, videoErr error + ) + run := func(kind string, rows *[]Model, failure *error) { + defer wg.Done() + defer func() { + if recovered := recover(); recovered != nil { + _ = guard.Note("catalog/media", recovered) + *failure = errUnreadable + } + }() + *rows, *failure = fetchMedia(ctx, options, kind) + } + wg.Add(2) + go run(kindImage, &imageRows, &imageErr) + go run(kindVideo, &videoRows, &videoErr) + wg.Wait() + + settle := func(kind *mediaKindCache, rows []Model, err error) { + kind.TriedAt = now + if err == nil { + kind.FetchedAt, kind.Rows = now, rows + } + } + settle(&next.Images, imageRows, imageErr) + settle(&next.Videos, videoRows, videoErr) + return mergeMedia(models, next), next, mediaStateOf(next, now) +} + +// mergeMedia lays the pure dedicated rows over the general rows. +// +// The dedicated listing is authoritative for what a media model takes and +// returns, so its modalities, parameters and prices win. The general row stays +// authoritative for being a CHAT model: a row that answers in text there keeps +// answering in text, and the chat parameters, token prices and context length +// it published are untouched. A pure generator — present only in a dedicated +// listing — is given exactly the outputs that listing implies, and never text. +func mergeMedia(models []Model, media *mediaCache) []Model { + if media == nil { + return models + } + merged := make([]Model, len(models), len(models)+len(media.Images.Rows)+len(media.Videos.Rows)) + copy(merged, models) + index := make(map[string]int, len(merged)) + for i, model := range merged { + if _, seen := index[normalizeID(model.ID)]; !seen { + index[normalizeID(model.ID)] = i + } + } + for _, dedicated := range append(append([]Model(nil), media.Images.Rows...), media.Videos.Rows...) { + dedicated.ID = strings.TrimSpace(dedicated.ID) + if dedicated.ID == "" || strings.HasSuffix(dedicated.ID, ":batch") { + continue + } + at, found := index[normalizeID(dedicated.ID)] + if !found { + index[normalizeID(dedicated.ID)] = len(merged) + merged = append(merged, cloneModel(dedicated)) + continue + } + merged[at] = overlayMedia(cloneModel(merged[at]), dedicated) + } + return merged +} + +func overlayMedia(general, dedicated Model) Model { + chat := hasModality(general.OutputModalities, "text") + outputs := append([]string(nil), dedicated.OutputModalities...) + if chat && !hasModality(outputs, "text") { + outputs = append([]string{"text"}, outputs...) + } + inputs := append([]string(nil), dedicated.InputModalities...) + // A video row publishes no architecture, so its inputs are only inferred + // (see [videoRow]) and must not displace inputs the general row published: + // a video model that takes sound or footage says so there, not here. + inferred := false + for _, kind := range dedicated.Dedicated { + inferred = inferred || kind == kindVideo + } + if chat || inferred { + inputs = append(append([]string(nil), general.InputModalities...), inputs...) + } + general.InputModalities = cleanLowerList(inputs) + general.OutputModalities = cleanLowerList(outputs) + general.Dedicated = unionStrings(general.Dedicated, dedicated.Dedicated) + if dedicated.Media != nil { + general.Media = mergeParams(general.Media, dedicated.Media) + } + general.MediaPrices = unionPrices(general.MediaPrices, dedicated.MediaPrices) + if general.Name == "" { + general.Name = dedicated.Name + } + if general.Created == 0 { + general.Created = dedicated.Created + } + return general +} + +func mergeParams(have, add *MediaParams) *MediaParams { + if have == nil { + copied := *add + return &copied + } + merged := *have + if len(add.Resolutions) > 0 { + merged.Resolutions = add.Resolutions + } + if len(add.AspectRatios) > 0 { + merged.AspectRatios = add.AspectRatios + } + if len(add.Durations) > 0 { + merged.Durations = add.Durations + } + if len(add.FrameImages) > 0 { + merged.FrameImages = add.FrameImages + } + if add.MaxInputReferences > 0 { + merged.MaxInputReferences = add.MaxInputReferences + } + if add.MaxOutputs > 0 { + merged.MaxOutputs = add.MaxOutputs + } + return &merged +} + +func unionStrings(a, b []string) []string { + out := append([]string(nil), a...) + for _, value := range b { + found := false + for _, have := range out { + if have == value { + found = true + break + } + } + if !found { + out = append(out, value) + } + } + return out +} + +func unionPrices(a, b []MediaPrice) []MediaPrice { + out := append([]MediaPrice(nil), a...) + for _, price := range b { + found := false + for _, have := range out { + if have == price { + found = true + break + } + } + if !found { + out = append(out, price) + } + } + return out +} + +// fetchMedia reads one dedicated listing. Every failure is an error and none +// is an empty result: a listing that parsed to no usable row is a listing that +// could not be read, not proof that the service offers none. +func fetchMedia(ctx context.Context, options Options, kind string) ([]Model, error) { + path := "/images/models" + if kind == kindVideo { + path = "/videos/models" + } + body, err := getBounded(ctx, options, strings.TrimSuffix(strings.TrimSpace(options.BaseURL), "/")+path, MaxMediaCatalogBytes) + if err != nil { + return nil, err + } + var payload struct { + Data []json.RawMessage `json:"data"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return nil, err + } + if len(payload.Data) > MaxMediaRows { + return nil, &statusError{status: "dedicated listing too long"} + } + rows := make([]Model, 0, len(payload.Data)) + for _, raw := range payload.Data { + var row Model + var ok bool + if kind == kindVideo { + row, ok = videoRow(raw) + } else { + row, ok = imageRow(raw) + } + if ok { + rows = append(rows, row) + } + } + rows = cleanModels(rows) + if len(rows) == 0 { + return nil, &statusError{status: "empty dedicated listing"} + } + return rows, nil +} + +// getBounded is one GET with the catalog's headers, its client and a hard +// ceiling on the bytes read. It reads one byte past the ceiling so a response +// that is too large is refused rather than silently cut into invalid JSON. +func getBounded(ctx context.Context, options Options, endpoint string, limit int64) ([]byte, error) { + request, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) + if err != nil { + return nil, err + } + request.Header.Set("Accept", "application/json") + if key := strings.TrimSpace(options.APIKey); key != "" { + request.Header.Set("Authorization", "Bearer "+key) + } + client := options.HTTPClient + if client == nil { + client = &http.Client{Timeout: FetchTimeout} + } + response, err := client.Do(request) + if err != nil { + return nil, err + } + defer response.Body.Close() + if response.StatusCode < http.StatusOK || response.StatusCode >= http.StatusMultipleChoices { + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 4<<10)) + return nil, &statusError{status: response.Status} + } + body, err := io.ReadAll(io.LimitReader(response.Body, limit+1)) + if err != nil { + return nil, err + } + if int64(len(body)) > limit { + return nil, &statusError{status: "response too large"} + } + return body, nil +} + +// videoRow reads one row of GET /videos/models. The row has no architecture +// block, so its modalities are the ones its fields imply: it returns video and +// nothing else, takes a prompt, and takes a picture only if it lists frame +// images. Each field is decoded on its own so one drifted field costs itself. +func videoRow(raw json.RawMessage) (Model, bool) { + var fields map[string]json.RawMessage + if json.Unmarshal(raw, &fields) != nil { + return Model{}, false + } + id := rawString(fields["id"]) + if id == "" { + return Model{}, false + } + // A row that publishes none of the video fields is not a video row — it is + // a chat row that reached the wrong URL. + known := false + for _, key := range []string{"supported_resolutions", "supported_aspect_ratios", "supported_durations", "supported_frame_images", "pricing_skus"} { + if len(fields[key]) > 0 && string(fields[key]) != "null" { + known = true + } + } + if !known { + return Model{}, false + } + params := MediaParams{ + Resolutions: cleanList(rawStrings(fields["supported_resolutions"])), + AspectRatios: cleanList(rawStrings(fields["supported_aspect_ratios"])), + Durations: rawInts(fields["supported_durations"]), + FrameImages: cleanList(rawStrings(fields["supported_frame_images"])), + } + inputs := []string{"text"} + if len(params.FrameImages) > 0 { + inputs = append(inputs, "image") + } + return Model{ + ID: id, Name: rawString(fields["name"]), Created: rawInt(fields["created"]), + // The token price is unknown, not zero: this model is billed per second. + PriceUnknown: true, + InputModalities: inputs, OutputModalities: []string{"video"}, + Media: ¶ms, MediaPrices: videoPrices(fields["pricing_skus"]), + Dedicated: []string{kindVideo}, + }, true +} + +// videoPrices reads the pricing_skus this package can state the unit of, which +// is the cents-per-unit family: +// +// cents_per_video_output_second_480p → video output, per second, at 480p +// cents_per_second_output[_720p] → the same, spelled the other way +// cents_per_image_input → a still given to the model, per image +// +// Every other sku — duration_seconds_*, video_tokens_*, the megapixel and +// continuation rates, a minimum charge — is NOT interpreted: guessing a unit +// for a number that will be shown beside a "$" is how a surface tells someone +// a video costs a hundredth of what it does. They are left unknown. +func videoPrices(raw json.RawMessage) []MediaPrice { + var skus map[string]json.RawMessage + if json.Unmarshal(raw, &skus) != nil { + return nil + } + var prices []MediaPrice + for key, value := range skus { + rest, ok := strings.CutPrefix(key, "cents_per_") + if !ok { + continue + } + cents, ok := rawNumber(value) + if !ok || cents < 0 { + continue + } + price := MediaPrice{USD: cents / 100} + switch { + case rest == "image_input": + price.Billable, price.Unit = "image_input", "image" + case strings.HasPrefix(rest, "video_output_second"): + price.Billable, price.Unit = "video_output", "second" + price.Variant, ok = resolutionSuffix(strings.TrimPrefix(rest, "video_output_second")) + case strings.HasPrefix(rest, "second_output"): + price.Billable, price.Unit = "video_output", "second" + price.Variant, ok = resolutionSuffix(strings.TrimPrefix(rest, "second_output")) + default: + ok = false + } + if ok { + prices = append(prices, price) + } + } + sortPrices(prices) + return prices +} + +// resolutionSuffix accepts "" or "_480p" / "_2k" and nothing else, so an +// unfamiliar qualifier ("_continuation_2k") makes the whole sku unknown. +func resolutionSuffix(suffix string) (string, bool) { + if suffix == "" { + return "", true + } + variant, ok := strings.CutPrefix(suffix, "_") + if !ok || len(variant) < 2 || len(variant) > 5 { + return "", false + } + last := variant[len(variant)-1] | 0x20 + if last != 'p' && last != 'k' { + return "", false + } + if _, err := strconv.Atoi(variant[:len(variant)-1]); err != nil { + return "", false + } + return strings.ToLower(variant), true +} + +func sortPrices(prices []MediaPrice) { + for i := 1; i < len(prices); i++ { + for j := i; j > 0 && priceKey(prices[j]) < priceKey(prices[j-1]); j-- { + prices[j], prices[j-1] = prices[j-1], prices[j] + } + } +} + +func priceKey(p MediaPrice) string { + return p.Billable + "\x00" + p.Unit + "\x00" + p.Variant + "\x00" + p.Provider +} + +// imageRow reads one row of GET /images/models. That listing publishes +// modalities and parameters and NO price: image prices live per endpoint. +func imageRow(raw json.RawMessage) (Model, bool) { + var fields map[string]json.RawMessage + if json.Unmarshal(raw, &fields) != nil { + return Model{}, false + } + id := rawString(fields["id"]) + if id == "" { + return Model{}, false + } + var architecture struct { + Input []string `json:"input_modalities"` + Output []string `json:"output_modalities"` + } + _ = json.Unmarshal(fields["architecture"], &architecture) + outputs := cleanLowerList(architecture.Output) + if len(outputs) == 0 { + // Served by the image listing, so it draws; nothing else is implied. + outputs = []string{"image"} + } + if !hasModality(outputs, "image") { + return Model{}, false + } + inputs := cleanLowerList(architecture.Input) + if len(inputs) == 0 { + inputs = []string{"text"} + } + params := imageParams(fields["supported_parameters"]) + return Model{ + ID: id, Name: rawString(fields["name"]), Created: rawInt(fields["created"]), + PriceUnknown: true, + InputModalities: inputs, OutputModalities: outputs, + Media: params, Dedicated: []string{kindImage}, + }, true +} + +// imageParams reads the supported_parameters object: each knob is an enum with +// values, a range with bounds, or a boolean. +func imageParams(raw json.RawMessage) *MediaParams { + var knobs map[string]struct { + Values []string `json:"values"` + Max *float64 `json:"max"` + } + if json.Unmarshal(raw, &knobs) != nil || len(knobs) == 0 { + return nil + } + params := MediaParams{ + Resolutions: cleanList(knobs["resolution"].Values), + AspectRatios: cleanList(knobs["aspect_ratio"].Values), + } + if max := knobs["input_references"].Max; max != nil && *max > 0 && *max < 1000 { + params.MaxInputReferences = int(*max) + } + if max := knobs["n"].Max; max != nil && *max > 0 && *max < 1000 { + params.MaxOutputs = int(*max) + } + return ¶ms +} + +// ImageEndpointPrices is the lazy, single-model price door for an image +// generator: ONE GET of /images/models/{id}/endpoints, made only when a caller +// needs the price of one model it is about to show or use. +// +// The prices there are per endpoint and per unit — dollars per image or per +// megapixel — and different endpoints of one model may differ, so every +// published rate comes back with the provider that published it and the caller +// decides how to present a range. It is bounded like every other read here, it +// is never part of startup, and it answers an error rather than an empty list +// when nothing was published. +func ImageEndpointPrices(ctx context.Context, options Options, modelID string) ([]MediaPrice, error) { + source := strings.TrimSpace(options.Source) + if !mediaEligible(options, source, normalizeBase(options.BaseURL)) { + return nil, &statusError{status: "no dedicated image catalog for this service"} + } + segments := strings.Split(strings.TrimSpace(modelID), "/") + for _, segment := range segments { + if segment == "" || segment == "." || segment == ".." || strings.ContainsAny(segment, "?#\\") { + return nil, errors.New("model catalog: not a model id") + } + if len(segment) > 128 { + return nil, errors.New("model catalog: not a model id") + } + } + for i, segment := range segments { + segments[i] = url.PathEscape(segment) + } + ctx, cancel := context.WithTimeout(ctx, FetchTimeout) + defer cancel() + endpoint := strings.TrimSuffix(strings.TrimSpace(options.BaseURL), "/") + "/images/models/" + strings.Join(segments, "/") + "/endpoints" + body, err := getBounded(ctx, options, endpoint, MaxEndpointBytes) + if err != nil { + return nil, err + } + return endpointPrices(body) +} + +func endpointPrices(body []byte) ([]MediaPrice, error) { + var payload struct { + Endpoints []json.RawMessage `json:"endpoints"` + } + if err := json.Unmarshal(body, &payload); err != nil { + return nil, err + } + if len(payload.Endpoints) > MaxEndpointRows { + return nil, &statusError{status: "endpoint listing too long"} + } + var prices []MediaPrice + for _, raw := range payload.Endpoints { + var endpoint struct { + Slug string `json:"provider_slug"` + Name string `json:"provider_name"` + Pricing []struct { + Billable string `json:"billable"` + Unit string `json:"unit"` + Cost *float64 `json:"cost_usd"` + } `json:"pricing"` + } + if json.Unmarshal(raw, &endpoint) != nil { + continue + } + provider := strings.TrimSpace(endpoint.Slug) + if provider == "" { + provider = strings.TrimSpace(endpoint.Name) + } + for _, entry := range endpoint.Pricing { + unit := strings.ToLower(strings.TrimSpace(entry.Unit)) + billable := strings.ToLower(strings.TrimSpace(entry.Billable)) + if entry.Cost == nil || *entry.Cost < 0 || math.IsNaN(*entry.Cost) || math.IsInf(*entry.Cost, 0) { + continue + } + if unit != "image" && unit != "megapixel" { + continue + } + if billable != "output_image" && billable != "input_image" { + continue + } + prices = append(prices, MediaPrice{ + Billable: map[string]string{"output_image": "image_output", "input_image": "image_input"}[billable], + Unit: unit, Provider: provider, USD: *entry.Cost, + }) + } + } + if len(prices) == 0 { + return nil, &statusError{status: "no published price"} + } + sortPrices(prices) + return prices, nil +} + +func rawString(raw json.RawMessage) string { + var value string + if json.Unmarshal(raw, &value) != nil { + return "" + } + return strings.TrimSpace(value) +} + +func rawStrings(raw json.RawMessage) []string { + var values []string + if json.Unmarshal(raw, &values) != nil { + return nil + } + return values +} + +func rawInt(raw json.RawMessage) int64 { + number, ok := rawNumber(raw) + if !ok || number < 0 || number > 1<<53 { + return 0 + } + return int64(number) +} + +// rawNumber reads a JSON number or a numeric string, because pricing_skus +// publishes its figures as strings and a drift to numbers must not cost them. +func rawNumber(raw json.RawMessage) (float64, bool) { + var number float64 + if json.Unmarshal(raw, &number) == nil { + return number, !math.IsNaN(number) && !math.IsInf(number, 0) + } + var text string + if json.Unmarshal(raw, &text) != nil { + return 0, false + } + number, err := strconv.ParseFloat(strings.TrimSpace(text), 64) + return number, err == nil && !math.IsNaN(number) && !math.IsInf(number, 0) +} + +func rawInts(raw json.RawMessage) []int { + var values []float64 + if json.Unmarshal(raw, &values) != nil { + return nil + } + out := make([]int, 0, len(values)) + for _, value := range values { + if value >= 1 && value <= 3600 && value == math.Trunc(value) { + out = append(out, int(value)) + } + } + return out +} + +func cleanList(values []string) []string { + seen := make(map[string]bool, len(values)) + var cleaned []string + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" || seen[value] { + continue + } + seen[value] = true + cleaned = append(cleaned, value) + } + return cleaned +} + +// MediaState is the standing of the dedicated image and video listings. Like +// every capability question it waits for the catalog to resolve. +func (c *Catalog) MediaState() MediaState { + resolved := c.rows() + if resolved == nil { + return MediaState{} + } + return resolved.media +} + +// MediaStateNow is [Catalog.MediaState] without waiting: a catalog still +// warming answers "never read", which is the honest word for it. +func (c *Catalog) MediaStateNow() MediaState { + resolved := c.rowsNow() + if resolved == nil { + return MediaState{} + } + return resolved.media +} + +// MediaPrices answers the published per-unit prices of one model, and nil when +// none were published. Nil is "unknown", never "free". +func (c *Catalog) MediaPrices(modelID string) []MediaPrice { + model, ok := c.Model(modelID) + if !ok { + return nil + } + return model.MediaPrices +} + +// MediaParams answers the dedicated listing's knobs for one model. +func (c *Catalog) MediaParams(modelID string) (MediaParams, bool) { + model, ok := c.Model(modelID) + if !ok || model.Media == nil { + return MediaParams{}, false + } + return *model.Media, true +} diff --git a/internal/catalog/media_test.go b/internal/catalog/media_test.go new file mode 100644 index 0000000000..4e53b1c5f6 --- /dev/null +++ b/internal/catalog/media_test.go @@ -0,0 +1,767 @@ +package catalog + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" +) + +// The dedicated media listings, tested against REAL responses. The testdata +// files openrouter-images.json and openrouter-videos.json are rows copied +// byte for byte (descriptions shortened) out of GET /images/models and +// GET /videos/models as served on 2026-10-15, and the two *-endpoints files are +// whole GET /images/models/{id}/endpoints responses. Nothing in them is +// hand-written; the failures below are fakes of the TRANSPORT only. + +func fixtureBytes(t *testing.T, name string) string { + t.Helper() + raw, err := os.ReadFile(filepath.Join("testdata", name)) + if err != nil { + t.Fatal(err) + } + return string(raw) +} + +type mediaReply struct { + status int + body string + // wait, when set, holds the reply until the request is cancelled. + wait bool +} + +// mediaRouter answers each path of the default service with its own reply and +// records every request, so a test can assert what was asked and by which verb. +type mediaRouter struct { + t *testing.T + mu sync.Mutex + replies map[string]mediaReply + asked map[string]int + methods map[string]bool + headers []string + inflight atomic.Int32 + peak atomic.Int32 +} + +func newMediaRouter(t *testing.T, replies map[string]mediaReply) *mediaRouter { + return &mediaRouter{t: t, replies: replies, asked: map[string]int{}, methods: map[string]bool{}} +} + +func (r *mediaRouter) client() *http.Client { + return &http.Client{Transport: roundTripFunc(func(request *http.Request) (*http.Response, error) { + now := r.inflight.Add(1) + defer r.inflight.Add(-1) + for { + peak := r.peak.Load() + if now <= peak || r.peak.CompareAndSwap(peak, now) { + break + } + } + r.mu.Lock() + r.asked[request.URL.Path]++ + r.methods[request.Method] = true + r.headers = append(r.headers, request.Header.Get("Authorization")) + reply, ok := r.replies[request.URL.Path] + r.mu.Unlock() + if !ok { + reply = mediaReply{status: http.StatusNotFound, body: "{}"} + } + if reply.wait { + <-request.Context().Done() + return nil, request.Context().Err() + } + return &http.Response{ + StatusCode: reply.status, Status: http.StatusText(reply.status), Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(reply.body)), Request: request, + }, nil + })} +} + +func (r *mediaRouter) count(path string) int { + r.mu.Lock() + defer r.mu.Unlock() + return r.asked[path] +} + +func (r *mediaRouter) total() int { + r.mu.Lock() + defer r.mu.Unlock() + n := 0 + for _, c := range r.asked { + n += c + } + return n +} + +const ( + generalPath = "/api/v1/models" + imagesPath = "/api/v1/images/models" + videosPath = "/api/v1/videos/models" +) + +func okReply(body string) mediaReply { return mediaReply{status: http.StatusOK, body: body} } + +func realReplies(t *testing.T) map[string]mediaReply { + return map[string]mediaReply{ + generalPath: okReply(fixtureBytes(t, "openrouter-models.json")), + imagesPath: okReply(fixtureBytes(t, "openrouter-images.json")), + videosPath: okReply(fixtureBytes(t, "openrouter-videos.json")), + } +} + +func loadAt(t *testing.T, router *mediaRouter, dir string, now time.Time, mutate func(*Options)) *Catalog { + t.Helper() + options := Options{BaseURL: DefaultBaseURL, APIKey: "k", Dir: dir, HTTPClient: router.client(), Now: func() time.Time { return now }} + if mutate != nil { + mutate(&options) + } + return Load(context.Background(), options) +} + +var day = time.Date(2026, 10, 15, 9, 0, 0, 0, time.UTC) + +func TestDedicatedListingsAddTheVideoModelsTheGeneralListingNeverHad(t *testing.T) { + router := newMediaRouter(t, realReplies(t)) + c := loadAt(t, router, t.TempDir(), day, nil) + + if !c.MediaState().Authoritative("video") || !c.MediaState().Authoritative("image") { + t.Fatalf("both listings were read and the state says %+v", c.MediaState()) + } + // grok-imagine is video-only in the dedicated listing and absent from the + // general one: it must exist, draw video, and NOT be given a text answer. + grok, ok := c.Model("x-ai/grok-imagine-video-1.5") + if !ok { + t.Fatal("a model published only by /videos/models is missing") + } + if len(grok.OutputModalities) != 1 || grok.OutputModalities[0] != "video" { + t.Errorf("a pure generator answers %v, want [video] and no invented text", grok.OutputModalities) + } + if !c.Supports(grok.ID, "input", "image") || !c.Supports(grok.ID, "input", "text") { + t.Errorf("a model with first_frame images and a prompt takes %v", grok.InputModalities) + } + if !grok.PriceUnknown || grok.PromptPrice != 0 || grok.CompletionPrice != 0 { + t.Errorf("a per-second model was given a token price: %+v", grok) + } + // The cents-per-second family is read, in dollars, with its unit. + want := []MediaPrice{ + {Billable: "image_input", Unit: "image", USD: 0.01}, + {Billable: "video_output", Unit: "second", Variant: "1080p", USD: 0.25}, + {Billable: "video_output", Unit: "second", Variant: "480p", USD: 0.08}, + {Billable: "video_output", Unit: "second", Variant: "720p", USD: 0.14}, + } + if got := c.MediaPrices(grok.ID); fmt.Sprint(got) != fmt.Sprint(want) { + t.Errorf("grok prices = %+v\nwant %+v", got, want) + } + if params, ok := c.MediaParams(grok.ID); !ok || len(params.Resolutions) != 3 || params.Durations[0] != 1 || params.FrameImages[0] != "first_frame" { + t.Errorf("grok params = %+v", params) + } + + // runway publishes the other spelling of the same rate. + if got := c.MediaPrices("runway/gen-4.5"); len(got) != 1 || got[0].USD != 0.12 || got[0].Unit != "second" || got[0].Variant != "" { + t.Errorf("runway prices = %+v", got) + } + // SKUs whose unit this package cannot state are LEFT UNKNOWN, not guessed: + // wan prices by duration_seconds_480p and seedance by video tokens. + for _, id := range []string{"alibaba/wan-3.0", "bytedance/seedance-2.5", "google/veo-3.1"} { + if got := c.MediaPrices(id); len(got) != 0 { + t.Errorf("%s: an uninterpreted sku became a price: %+v", id, got) + } + } + // A model that publishes no frame images takes a prompt and nothing else. + avatar, _ := c.Model("heygen/avatar-iv") + if c.Supports(avatar.ID, "input", "image") { + t.Errorf("a model with no frame images was given image input: %v", avatar.InputModalities) + } + if got := len(c.ModelsWithOutput("video")); got != 6 { + t.Errorf("video models = %d, want the 5 dedicated-only plus the 1 seedance both listings share", got) + } +} + +func TestADedicatedRowKeepsTheGeneralRowsOwnFactsAndTextCapability(t *testing.T) { + general := `{"data":[ + {"id":"google/gemini-3-pro-image","name":"Gemini Pro Image","context_length":65536, + "architecture":{"input_modalities":["text","image"],"output_modalities":["text","image"]}, + "pricing":{"prompt":"0.000002","completion":"0.000012"},"supported_parameters":["max_tokens","reasoning"]}, + {"id":"bytedance/seedance-2.5","name":"Seedance","architecture":{"input_modalities":["text","image","video","audio"],"output_modalities":["video"]},"pricing":{"prompt":"0","completion":"0"}}, + {"id":"plain/chat","architecture":{"input_modalities":["text"],"output_modalities":["text"]},"pricing":{"prompt":"0.000001","completion":"0.000002"}}]}` + router := newMediaRouter(t, map[string]mediaReply{ + generalPath: okReply(general), + imagesPath: okReply(fixtureBytes(t, "openrouter-images.json")), + videosPath: okReply(fixtureBytes(t, "openrouter-videos.json")), + }) + c := loadAt(t, router, t.TempDir(), day, nil) + + gemini, _ := c.Model("google/gemini-3-pro-image") + if !c.Supports(gemini.ID, "output", "text") || !c.Supports(gemini.ID, "output", "image") { + t.Errorf("a chat model that also draws lost a side: %v", gemini.OutputModalities) + } + if gemini.PromptPrice != 0.000002 || gemini.CompletionPrice != 0.000012 || gemini.PriceUnknown || gemini.ContextLength != 65536 { + t.Errorf("the general row's token facts changed: %+v", gemini) + } + if ok, known := c.SupportsParameter(gemini.ID, "reasoning"); !ok || !known { + t.Error("the general row's published chat parameters were lost") + } + if params, ok := c.MediaParams(gemini.ID); !ok || len(params.AspectRatios) == 0 { + t.Errorf("the dedicated parameters did not arrive: %+v", params) + } + if len(gemini.Dedicated) != 1 || gemini.Dedicated[0] != "image" { + t.Errorf("provenance = %v", gemini.Dedicated) + } + + // A video row inferred from dedicated fields must not displace the inputs + // the general row actually published. + seed, _ := c.Model("bytedance/seedance-2.5") + for _, input := range []string{"text", "image", "video", "audio"} { + if !c.Supports(seed.ID, "input", input) { + t.Errorf("seedance lost its published %s input: %v", input, seed.InputModalities) + } + } + if c.Supports(seed.ID, "output", "text") { + t.Error("seedance was given text output") + } + // A token-priced chat model that no dedicated listing mentions is untouched. + chat, _ := c.Model("plain/chat") + if len(chat.Dedicated) != 0 || chat.Media != nil || len(chat.MediaPrices) != 0 || chat.PromptPrice != 0.000001 { + t.Errorf("an unrelated chat row was altered: %+v", chat) + } +} + +func TestADedicatedGeneratorOverTextOnlyGeneralRowDoesNotDemoteIt(t *testing.T) { + // The general listing says a model answers in text only; the dedicated one + // says it draws. Both are true of the id: text must survive. + general := `{"data":[{"id":"recraft/recraft-v4.1-flash","architecture":{"input_modalities":["text"],"output_modalities":["text"]},"pricing":{"prompt":"0.000001","completion":"0.000001"}}]}` + router := newMediaRouter(t, map[string]mediaReply{ + generalPath: okReply(general), + imagesPath: okReply(fixtureBytes(t, "openrouter-images.json")), + videosPath: mediaReply{status: 500, body: "no"}, + }) + c := loadAt(t, router, t.TempDir(), day, nil) + row, _ := c.Model("recraft/recraft-v4.1-flash") + if !c.Supports(row.ID, "output", "text") || !c.Supports(row.ID, "output", "image") { + t.Errorf("merged outputs = %v, want text and image", row.OutputModalities) + } +} + +func TestImageGeneratorsOutsideTheChatCatalogAppearWithoutTextOrAGuessedPrice(t *testing.T) { + router := newMediaRouter(t, realReplies(t)) + c := loadAt(t, router, t.TempDir(), day, nil) + for _, id := range []string{"krea/k3-edit", "black-forest-labs/flux.2-klein-4b", "bytedance-seed/seedream-5-0-lite"} { + row, ok := c.Model(id) + if !ok { + t.Fatalf("%s is missing", id) + } + if len(row.OutputModalities) != 1 || row.OutputModalities[0] != "image" { + t.Errorf("%s outputs = %v", id, row.OutputModalities) + } + if !row.PriceUnknown || len(row.MediaPrices) != 0 || row.PromptPrice != 0 || row.RequestPrice != 0 { + t.Errorf("%s was given a price the listing never published: %+v", id, row) + } + } + klein, _ := c.MediaParams("black-forest-labs/flux.2-klein-4b") + if klein.MaxInputReferences != 4 || klein.MaxOutputs != 1 || len(klein.AspectRatios) == 0 { + t.Errorf("klein params = %+v", klein) + } + if recraft, _ := c.Model("recraft/recraft-v4.1-flash"); c.Supports(recraft.ID, "input", "image") { + t.Errorf("a text-only image generator was given image input: %v", recraft.InputModalities) + } + // An image generator is not a pair of eyes: its text output is absent. + for _, model := range c.ModelsWithOutput("text") { + if len(model.Dedicated) > 0 && model.ID != "google/gemini-3-pro-image" { + t.Errorf("%s was given a text output", model.ID) + } + } +} + +func TestDedicatedDiscoveryOnlyAsksGETsOfTheOpenRouterSourcesOwnPaths(t *testing.T) { + router := newMediaRouter(t, realReplies(t)) + loadAt(t, router, t.TempDir(), day, nil) + router.mu.Lock() + defer router.mu.Unlock() + if len(router.methods) != 1 || !router.methods[http.MethodGet] { + t.Errorf("methods used = %v, want GET only", router.methods) + } + if len(router.asked) != 3 || router.asked[generalPath] != 1 || router.asked[imagesPath] != 1 || router.asked[videosPath] != 1 { + t.Errorf("requests = %v, want one each of the three listings and no per-model detail", router.asked) + } + for _, header := range router.headers { + if header != "Bearer k" { + t.Errorf("a request carried the authorization %q, want the catalog's own key", header) + } + } + if router.peak.Load() > 2 { + t.Errorf("%d dedicated requests were in flight at once, want at most 2", router.peak.Load()) + } +} + +func TestOnlyTheKnownOpenRouterSourceReadsDedicatedListings(t *testing.T) { + cases := map[string]func(*Options){ + "a custom endpoint": func(o *Options) { o.BaseURL = "https://models.example.test/api/v1" }, + "another source": func(o *Options) { o.Source = "someone-else" }, + "a lookalike host": func(o *Options) { o.BaseURL = "https://openrouter.ai.evil.test/api/v1" }, + "the opt-out": func(o *Options) { o.NoDedicatedMedia = true }, + "a different path prefix": func(o *Options) { o.BaseURL = "https://openrouter.ai/api/v2" }, + "codex's compartment name": func(o *Options) { o.Source = "codex" }, + } + for name, mutate := range cases { + t.Run(name, func(t *testing.T) { + var paths []string + var mu sync.Mutex + client := catalogClient(t, 200, fixtureBytes(t, "openrouter-models.json"), func(r *http.Request) { + mu.Lock() + paths = append(paths, r.URL.Path) + mu.Unlock() + }) + options := Options{BaseURL: DefaultBaseURL, Dir: t.TempDir(), HTTPClient: client, Now: func() time.Time { return day }} + mutate(&options) + c := Load(context.Background(), options) + if (c.MediaState() != MediaState{}) { + t.Errorf("media state = %+v, want none", c.MediaState()) + } + for _, path := range paths { + if strings.Contains(path, "images") || strings.Contains(path, "videos") { + t.Errorf("%s asked %s", name, path) + } + } + }) + } + t.Run("the openrouter source id on the default base is eligible", func(t *testing.T) { + router := newMediaRouter(t, realReplies(t)) + c := loadAt(t, router, t.TempDir(), day, func(o *Options) { o.Source = "openrouter" }) + if !c.MediaState().Authoritative("video") { + t.Errorf("state = %+v", c.MediaState()) + } + }) +} + +func TestAFailedDedicatedListingNeverPublishesNoneAsProof(t *testing.T) { + for name, reply := range map[string]mediaReply{ + "a server error": {status: 503, body: "down"}, + "not json": okReply(""), + "an empty list": okReply(`{"data":[]}`), + "no list": okReply(`{}`), + "all rows bad": okReply(`{"data":[1,"x",{"id":""},{"id":"nofields"}]}`), + "a not-found": {status: 404, body: "{}"}, + } { + t.Run(name, func(t *testing.T) { + replies := realReplies(t) + replies[videosPath] = reply + router := newMediaRouter(t, replies) + c := loadAt(t, router, t.TempDir(), day, nil) + + state := c.MediaState() + if state.Videos.Known || state.Authoritative("video") { + t.Errorf("%s was read as a video catalog: %+v", name, state.Videos) + } + if !state.Authoritative("image") { + t.Errorf("a failed video listing took the image listing with it: %+v", state) + } + // The general rows, including its own video model, are all still here. + if _, ok := c.Model("bytedance/seedance-2.5"); !ok || len(c.ModelsWithOutput("text")) == 0 { + t.Error("a failed dedicated listing cost the general catalog its rows") + } + if _, ok := c.Model("krea/k3-edit"); !ok { + t.Error("the listing that landed was thrown away with the one that did not") + } + }) + } +} + +func TestAFailedRefreshKeepsThePreviousDedicatedRowsAndSaysTheyAreStale(t *testing.T) { + dir := t.TempDir() + first := newMediaRouter(t, realReplies(t)) + loadAt(t, first, dir, day, nil) + + replies := realReplies(t) + replies[videosPath] = mediaReply{status: 500, body: "x"} + failing := newMediaRouter(t, replies) + later := day.Add(2 * time.Hour) + c := loadAt(t, failing, dir, later, func(o *Options) { o.Refresh = true }) + + state := c.MediaState() + if !state.Videos.Known || !state.Videos.Stale || state.Authoritative("video") { + t.Errorf("video state = %+v, want known, stale, and not authoritative", state.Videos) + } + if !state.Videos.FetchedAt.Equal(day) { + t.Errorf("the previous rows were re-dated %v, want %v", state.Videos.FetchedAt, day) + } + if _, ok := c.Model("x-ai/grok-imagine-video-1.5"); !ok { + t.Error("a transient failure erased the video models already known") + } + if got := c.MediaPrices("x-ai/grok-imagine-video-1.5"); len(got) != 4 { + t.Errorf("the previous prices were lost: %+v", got) + } + if !state.Authoritative("image") { + t.Errorf("the image listing that did land should be current: %+v", state.Images) + } + + // And the next plain load serves the same stale standing from disk. + again := loadAt(t, newMediaRouter(t, nil), dir, later.Add(time.Minute), nil) + if s := again.MediaState(); !s.Videos.Known || !s.Videos.Stale { + t.Errorf("the persisted standing = %+v", s.Videos) + } +} + +func TestADedicatedListingOlderThanTheTTLIsStaleEvenWhenNothingFailed(t *testing.T) { + dir := t.TempDir() + loadAt(t, newMediaRouter(t, realReplies(t)), dir, day, nil) + c := loadAt(t, newMediaRouter(t, nil), dir, day.Add(TTL-time.Hour), nil) + if !c.MediaState().Authoritative("video") { + t.Errorf("an hour inside the TTL is stale: %+v", c.MediaState()) + } + // Past the TTL with the network gone the whole cache degrades together. + c = loadAt(t, newMediaRouter(t, nil), dir, day.Add(TTL+time.Hour), nil) + if s := c.MediaState(); s.Authoritative("video") || !s.Videos.Known || !s.Videos.Stale { + t.Errorf("a day-old dedicated listing = %+v, want known and stale", s.Videos) + } +} + +func TestADedicatedListingThatTimesOutIsAFailureNotAnAbsence(t *testing.T) { + replies := realReplies(t) + replies[videosPath] = mediaReply{wait: true} + router := newMediaRouter(t, replies) + ctx, cancel := context.WithTimeout(context.Background(), 150*time.Millisecond) + defer cancel() + started := time.Now() + c := Load(ctx, Options{BaseURL: DefaultBaseURL, Dir: t.TempDir(), HTTPClient: router.client(), Now: func() time.Time { return day }}) + if took := time.Since(started); took > 5*time.Second { + t.Fatalf("the load waited %v on a listing that never answered", took) + } + if c.MediaState().Videos.Known || c.MediaState().Authoritative("video") { + t.Errorf("a timeout counted as a video catalog: %+v", c.MediaState()) + } + if router.inflight.Load() != 0 { + t.Error("a media request was left running after the load returned") + } +} + +func TestOversizeAndOverlongDedicatedListingsAreRefusedWholeAndBeforeDecoding(t *testing.T) { + huge := `{"data":[{"id":"a/b","supported_resolutions":["480p"],"description":"` + strings.Repeat("x", MaxMediaCatalogBytes) + `"}]}` + var rows strings.Builder + rows.WriteString(`{"data":[`) + for i := 0; i <= MaxMediaRows; i++ { + if i > 0 { + rows.WriteString(",") + } + fmt.Fprintf(&rows, `{"id":"vendor/video-%d","supported_resolutions":["480p"]}`, i) + } + rows.WriteString(`]}`) + for name, body := range map[string]string{"oversize": huge, "overlong": rows.String()} { + t.Run(name, func(t *testing.T) { + replies := realReplies(t) + replies[videosPath] = okReply(body) + c := loadAt(t, newMediaRouter(t, replies), t.TempDir(), day, nil) + if c.MediaState().Videos.Known { + t.Error("a refused listing was accepted") + } + if _, ok := c.Model("vendor/video-0"); ok { + t.Error("a listing past its bound was truncated and kept instead of refused") + } + }) + } + t.Run("just inside the row bound is accepted", func(t *testing.T) { + var b strings.Builder + b.WriteString(`{"data":[`) + for i := 0; i < MaxMediaRows; i++ { + if i > 0 { + b.WriteString(",") + } + fmt.Fprintf(&b, `{"id":"vendor/video-%d","supported_resolutions":["480p"]}`, i) + } + b.WriteString(`]}`) + replies := realReplies(t) + replies[videosPath] = okReply(b.String()) + c := loadAt(t, newMediaRouter(t, replies), t.TempDir(), day, nil) + if !c.MediaState().Videos.Known { + t.Error("a listing at the bound was refused") + } + }) +} + +func TestDuplicateAndMalformedDedicatedRowsCostOnlyThemselves(t *testing.T) { + body := `{"data":[ + {"id":"a/first","name":"First","supported_resolutions":["480p"],"pricing_skus":{"cents_per_video_output_second_480p":"2"}}, + {"id":"a/first","name":"Second copy","supported_resolutions":["720p"],"pricing_skus":{"cents_per_video_output_second_480p":"9"}}, + {"id":"a/drift","supported_resolutions":"480p","supported_durations":{"x":1},"pricing_skus":"free","supported_frame_images":["first_frame"]}, + {"id":"a/numbers","supported_durations":[2.5,4,0,-1,5],"pricing_skus":{"cents_per_image_input":2,"cents_per_video_output_second_480p":"-3","cents_per_video_output_second_weird":"1","cents_per_video_output_second_continuation_2k":"1","cents_per_video_output_second_NaN":"NaN"}}, + "not an object", + {"name":"no id","supported_resolutions":["480p"]}, + {"id":"a/last","supported_aspect_ratios":["16:9"]}]}` + replies := realReplies(t) + replies[videosPath] = okReply(body) + c := loadAt(t, newMediaRouter(t, replies), t.TempDir(), day, nil) + + first, _ := c.Model("a/first") + if first.Name != "First" || len(c.MediaPrices("a/first")) != 1 || c.MediaPrices("a/first")[0].USD != 0.02 { + t.Errorf("a duplicate id replaced the first row: %+v", first) + } + if _, ok := c.Model("a/drift"); !ok { + t.Error("a row whose fields drifted in shape was dropped instead of kept without them") + } + if params, _ := c.MediaParams("a/drift"); len(params.FrameImages) != 1 || len(params.Resolutions) != 0 || len(params.Durations) != 0 { + t.Errorf("drifted fields were not isolated: %+v", params) + } + numbers := c.MediaPrices("a/numbers") + if len(numbers) != 1 || numbers[0].Billable != "image_input" || numbers[0].USD != 0.02 { + t.Errorf("only the well-formed numeric sku should be priced, got %+v", numbers) + } + if params, _ := c.MediaParams("a/numbers"); fmt.Sprint(params.Durations) != "[4 5]" { + t.Errorf("durations = %v, want only whole positive seconds", params.Durations) + } + if _, ok := c.Model("a/last"); !ok { + t.Error("a row after the malformed ones was lost") + } +} + +func TestAPureVideoListingIsDiscoveredOverATinyGeneralCatalog(t *testing.T) { + tiny := `{"data":[{"id":"only/chat","architecture":{"input_modalities":["text"],"output_modalities":["text"]},"pricing":{"prompt":"0.000001","completion":"0.000001"}}]}` + replies := realReplies(t) + replies[generalPath] = okReply(tiny) + c := loadAt(t, newMediaRouter(t, replies), t.TempDir(), day, nil) + if len(c.ModelsWithOutput("video")) != 6 { + t.Errorf("video models over a one-row general catalog = %d, want the 6 dedicated rows", len(c.ModelsWithOutput("video"))) + } + if _, ok := c.Model("only/chat"); !ok { + t.Error("the general row vanished") + } +} + +func TestTheDedicatedCacheRoundTripsAndServesWithoutAnyRequest(t *testing.T) { + dir := t.TempDir() + loadAt(t, newMediaRouter(t, realReplies(t)), dir, day, nil) + + quiet := newMediaRouter(t, nil) + c := loadAt(t, quiet, dir, day.Add(time.Hour), nil) + if quiet.total() != 0 { + t.Errorf("a fresh cache made %d requests", quiet.total()) + } + if !c.MediaState().Authoritative("video") || len(c.MediaPrices("x-ai/grok-imagine-video-1.5")) != 4 { + t.Errorf("the cache lost the dedicated facts: %+v", c.MediaState()) + } + if row, _ := c.Model("anthropic/claude-opus-5"); row.PromptPrice != 0.000005 { + t.Errorf("the general facts changed across the cache: %+v", row) + } + if recalled := Recall(Options{BaseURL: DefaultBaseURL, Dir: dir, Now: func() time.Time { return day }}); len(recalled.ModelsWithOutput("video")) == 0 { + t.Error("Recall does not see the dedicated rows") + } +} + +func TestACacheWrittenBeforeDedicatedListingsStillLoadsAndIsToppedUpAlone(t *testing.T) { + dir := t.TempDir() + // An older build's cache: general rows only, none of the new fields. + legacy := `{"fetched_at":"` + day.Format(time.RFC3339) + `","priced":true,"models":[{"id":"only/chat","input_modalities":["text"],"output_modalities":["text"],"prompt_price":0.000001}]}` + if err := os.WriteFile(filepath.Join(dir, cacheName), []byte(legacy), 0o600); err != nil { + t.Fatal(err) + } + options := func(router *mediaRouter, at time.Time) Options { + return Options{BaseURL: DefaultBaseURL, Dir: dir, HTTPClient: router.client(), Now: func() time.Time { return at }} + } + + // An eager load keeps the old promise: a fresh cache costs no request. + quiet := newMediaRouter(t, realReplies(t)) + c := Load(context.Background(), options(quiet, day.Add(time.Hour))) + if quiet.total() != 0 { + t.Errorf("a fresh legacy cache made %d requests", quiet.total()) + } + if c.MediaState().Videos.Known || c.MediaState().Authoritative("video") { + t.Errorf("a cache that never read the video listing claims it did: %+v", c.MediaState()) + } + if !c.MediaState().Videos.due { + t.Error("an unread listing is not marked as worth a top-up") + } + + // The lazy warm tops up the two dedicated listings and NOT the general one. + topUp := newMediaRouter(t, realReplies(t)) + lazy := LoadLazy(context.Background(), options(topUp, day.Add(time.Hour))) + deadline := time.After(5 * time.Second) + // Wait for the top-up to be PERSISTED, not merely asked: closing the catalog + // between the two cancels the write, which is the law, and not the test. + for { + if cached, ok := readCache(cachePath(dir, "", DefaultBaseURL), "", DefaultBaseURL); ok && cached.Media != nil && !cached.Media.Videos.FetchedAt.IsZero() { + break + } + select { + case <-deadline: + t.Fatalf("the lazy warm never topped up: %v", topUp.asked) + case <-time.After(10 * time.Millisecond): + } + } + lazy.Close() + if topUp.count(generalPath) != 0 { + t.Errorf("a media top-up refetched the general listing %d times", topUp.count(generalPath)) + } + after := Load(context.Background(), options(newMediaRouter(t, nil), day.Add(2*time.Hour))) + if !after.MediaState().Authoritative("video") { + t.Errorf("the top-up was not persisted: %+v", after.MediaState()) + } + if _, ok := after.Model("only/chat"); !ok { + t.Error("the top-up lost the general rows") + } + // The file is still readable by the old reader: only additive fields. + raw, _ := os.ReadFile(filepath.Join(dir, cacheName)) + var old struct { + FetchedAt time.Time `json:"fetched_at"` + Models []struct { + ID string `json:"id"` + } `json:"models"` + } + if err := json.Unmarshal(raw, &old); err != nil || len(old.Models) < 2 { + t.Errorf("the cache no longer reads as the old shape: %v %d", err, len(old.Models)) + } +} + +func TestAFailedTopUpIsNotRetriedUntilTheIntervalPasses(t *testing.T) { + dir := t.TempDir() + legacy := `{"fetched_at":"` + day.Format(time.RFC3339) + `","priced":true,"models":[{"id":"only/chat","input_modalities":["text"],"output_modalities":["text"]}]}` + if err := os.WriteFile(filepath.Join(dir, cacheName), []byte(legacy), 0o600); err != nil { + t.Fatal(err) + } + down := newMediaRouter(t, map[string]mediaReply{imagesPath: {status: 500}, videosPath: {status: 500}}) + run := func(router *mediaRouter, at time.Time) *rows { + resolved, _ := load(context.Background(), Options{ + BaseURL: DefaultBaseURL, Dir: dir, HTTPClient: router.client(), Now: func() time.Time { return at }, mediaOnly: true, + }) + return resolved + } + first := run(down, day.Add(time.Minute)) + if down.total() != 2 || first.media.Videos.Known { + t.Fatalf("first attempt: %d requests, state %+v", down.total(), first.media) + } + if first.media.due() { + t.Error("a listing that just failed is already due again") + } + // Persisted: a later reader inside the interval is not due, one after is. + soon := cachedRows(mustCache(t, dir), Options{BaseURL: DefaultBaseURL}, day.Add(5*time.Minute)) + if soon.media.due() { + t.Error("the retry throttle was not persisted") + } + late := cachedRows(mustCache(t, dir), Options{BaseURL: DefaultBaseURL}, day.Add(time.Minute+mediaRetry)) + if !late.media.due() { + t.Error("a failed listing is never retried") + } + if got := mustCache(t, dir).Models; len(got) != 1 { + t.Errorf("a failed top-up changed the general rows: %d", len(got)) + } +} + +func mustCache(t *testing.T, dir string) cache { + t.Helper() + cached, ok := readCache(cachePath(dir, "", DefaultBaseURL), "", DefaultBaseURL) + if !ok { + t.Fatal("no cache") + } + return cached +} + +func TestRememberKeepsWhatTheDedicatedListingsAlreadyProved(t *testing.T) { + dir := t.TempDir() + loadAt(t, newMediaRouter(t, realReplies(t)), dir, day, nil) + err := Remember(Options{BaseURL: DefaultBaseURL, Dir: dir, Now: func() time.Time { return day.Add(time.Hour) }}, []Model{ + {ID: "probe/one", InputModalities: []string{"text"}, OutputModalities: []string{"text"}}, + }) + if err != nil { + t.Fatal(err) + } + c := Recall(Options{BaseURL: DefaultBaseURL, Dir: dir, Now: func() time.Time { return day.Add(time.Hour) }}) + if _, ok := c.Model("x-ai/grok-imagine-video-1.5"); !ok { + t.Error("a probe write erased the dedicated rows") + } + if !c.MediaState().Authoritative("video") { + t.Errorf("a probe write erased the dedicated standing: %+v", c.MediaState()) + } +} + +func TestImageEndpointPricesAreTypedPerUnitAndPerProvider(t *testing.T) { + klein := "/api/v1/images/models/black-forest-labs/flux.2-klein-4b/endpoints" + seed := "/api/v1/images/models/bytedance-seed/seedream-5-0-lite/endpoints" + router := newMediaRouter(t, map[string]mediaReply{ + klein: okReply(fixtureBytes(t, "openrouter-image-klein-endpoints.json")), + seed: okReply(fixtureBytes(t, "openrouter-image-seedream-endpoints.json")), + }) + options := Options{BaseURL: DefaultBaseURL, APIKey: "k", HTTPClient: router.client()} + + prices, err := ImageEndpointPrices(context.Background(), options, "black-forest-labs/flux.2-klein-4b") + if err != nil { + t.Fatal(err) + } + if len(prices) != 1 || prices[0] != (MediaPrice{Billable: "image_output", Unit: "megapixel", Provider: "black-forest-labs", USD: 0.014}) { + t.Errorf("klein = %+v", prices) + } + prices, err = ImageEndpointPrices(context.Background(), options, "bytedance-seed/seedream-5-0-lite") + if err != nil { + t.Fatal(err) + } + if len(prices) != 2 { + t.Fatalf("seedream = %+v", prices) + } + // A published zero is a known zero, kept; and it is a different unit from + // the per-megapixel rate above, so the two are never comparable by number. + if prices[0].Billable != "image_input" || prices[0].USD != 0 || prices[1] != (MediaPrice{Billable: "image_output", Unit: "image", Provider: "seed", USD: 0.035}) { + t.Errorf("seedream = %+v", prices) + } + if router.total() != 2 || len(router.methods) != 1 || !router.methods[http.MethodGet] { + t.Errorf("each lookup should be one GET, saw %v", router.asked) + } +} + +func TestImageEndpointPricesRefuseWhatTheyCannotVouchFor(t *testing.T) { + router := newMediaRouter(t, map[string]mediaReply{ + "/api/v1/images/models/a/none/endpoints": okReply(`{"endpoints":[{"provider_slug":"p","pricing":[{"billable":"output_image","unit":"token","cost_usd":1},{"billable":"output_image","unit":"image","cost_usd":-1}]}]}`), + "/api/v1/images/models/a/empty/endpoints": okReply(`{"endpoints":[]}`), + "/api/v1/images/models/a/big/endpoints": okReply(`{"endpoints":[],"pad":"` + strings.Repeat("x", MaxEndpointBytes) + `"}`), + "/api/v1/images/models/a/down/endpoints": {status: 502, body: "x"}, + "/api/v1/images/models/a/many/endpoints": okReply(`{"endpoints":[` + strings.Repeat(`{},`, MaxEndpointRows) + `{}]}`), + }) + options := Options{BaseURL: DefaultBaseURL, HTTPClient: router.client()} + for _, id := range []string{"a/none", "a/empty", "a/big", "a/down", "a/many"} { + if prices, err := ImageEndpointPrices(context.Background(), options, id); err == nil || prices != nil { + t.Errorf("%s answered %+v, %v; an unreadable price must be an error", id, prices, err) + } + } + before := router.total() + for _, id := range []string{"", "../etc", "a/../b", "a//b", "a/b?x=1", "a/b#c", strings.Repeat("a", 200)} { + if _, err := ImageEndpointPrices(context.Background(), options, id); err == nil { + t.Errorf("%q was accepted as a model id", id) + } + } + custom := Options{BaseURL: "https://models.example.test/api/v1", HTTPClient: router.client()} + if _, err := ImageEndpointPrices(context.Background(), custom, "a/none"); err == nil { + t.Error("a custom endpoint was asked for image prices") + } + if router.total() != before { + t.Error("a refused lookup still made a request") + } +} + +func TestExistingGeneralBehaviorIsUnchangedByTheDedicatedListings(t *testing.T) { + withMedia := loadAt(t, newMediaRouter(t, realReplies(t)), t.TempDir(), day, nil) + without := loadAt(t, newMediaRouter(t, realReplies(t)), t.TempDir(), day, func(o *Options) { o.NoDedicatedMedia = true }) + for _, model := range without.ModelsNow() { + merged, ok := withMedia.Model(model.ID) + if !ok { + t.Fatalf("%s vanished", model.ID) + } + if merged.PromptPrice != model.PromptPrice || merged.CompletionPrice != model.CompletionPrice || merged.PriceUnknown != model.PriceUnknown || + merged.ContextLength != model.ContextLength || merged.RequestPrice != model.RequestPrice || merged.CacheReadPrice != model.CacheReadPrice { + t.Errorf("%s: token economics changed: %+v vs %+v", model.ID, merged, model) + } + if fmt.Sprint(merged.Parameters) != fmt.Sprint(model.Parameters) || merged.Reasoning.Known != model.Reasoning.Known { + t.Errorf("%s: chat parameters changed", model.ID) + } + if hasModality(model.OutputModalities, "text") && !hasModality(merged.OutputModalities, "text") { + t.Errorf("%s lost its text output", model.ID) + } + for _, input := range model.InputModalities { + if !hasModality(merged.InputModalities, input) { + t.Errorf("%s lost its %s input", model.ID, input) + } + } + } +} diff --git a/internal/catalog/openrouter_test.go b/internal/catalog/openrouter_test.go index d8c047b18d..6e92d9ea97 100644 --- a/internal/catalog/openrouter_test.go +++ b/internal/catalog/openrouter_test.go @@ -210,7 +210,13 @@ func TestTheCacheIsDailyDatedAndRefreshableByHand(t *testing.T) { t.Fatal(err) } counting := func() *http.Client { - return catalogClient(t, http.StatusOK, string(raw), func(*http.Request) { fetches.Add(1) }) + // Only the general listing is counted: the default base also reads the two + // dedicated media listings, which have their own tests. + return catalogClient(t, http.StatusOK, string(raw), func(r *http.Request) { + if r.URL.Path == "/api/v1/models" { + fetches.Add(1) + } + }) } day := time.Date(2026, 8, 11, 9, 0, 0, 0, time.UTC) now := day diff --git a/internal/catalog/testdata/openrouter-image-klein-endpoints.json b/internal/catalog/testdata/openrouter-image-klein-endpoints.json new file mode 100644 index 0000000000..a2932c34bc --- /dev/null +++ b/internal/catalog/testdata/openrouter-image-klein-endpoints.json @@ -0,0 +1 @@ +{"id":"black-forest-labs/flux.2-klein-4b","endpoints":[{"provider_name":"Black Forest Labs","provider_slug":"black-forest-labs","provider_tag":"black-forest-labs","supported_parameters":{"aspect_ratio":{"type":"enum","values":["1:1","4:3","3:4","3:2","2:3","16:9","9:16","21:9","auto"]},"output_format":{"type":"enum","values":["png","jpeg"]},"n":{"type":"range","min":1,"max":1},"input_references":{"type":"range","min":0,"max":4},"seed":{"type":"boolean"}},"allowed_passthrough_parameters":["steps","guidance","safety_tolerance"],"supports_streaming":false,"pricing":[{"billable":"output_image","unit":"megapixel","cost_usd":0.014}]}]} \ No newline at end of file diff --git a/internal/catalog/testdata/openrouter-image-seedream-endpoints.json b/internal/catalog/testdata/openrouter-image-seedream-endpoints.json new file mode 100644 index 0000000000..4a0463236c --- /dev/null +++ b/internal/catalog/testdata/openrouter-image-seedream-endpoints.json @@ -0,0 +1 @@ +{"id":"bytedance-seed/seedream-5-0-lite","endpoints":[{"provider_name":"Seed","provider_slug":"seed","provider_tag":"seed","supported_parameters":{"resolution":{"type":"enum","values":["2K","4K"]},"aspect_ratio":{"type":"enum","values":["1:1","1:2","2:1","2:3","3:2","3:4","4:3","4:5","5:4","9:16","16:9","9:19.5","19.5:9","9:20","20:9","9:21","21:9","auto"]},"n":{"type":"range","min":1,"max":4},"input_references":{"type":"range","min":0,"max":14},"seed":{"type":"boolean"}},"allowed_passthrough_parameters":[],"supports_streaming":false,"pricing":[{"billable":"output_image","unit":"image","cost_usd":0.035},{"billable":"input_image","unit":"image","cost_usd":0}]}]} \ No newline at end of file diff --git a/internal/catalog/testdata/openrouter-images.json b/internal/catalog/testdata/openrouter-images.json new file mode 100644 index 0000000000..a5ee885023 --- /dev/null +++ b/internal/catalog/testdata/openrouter-images.json @@ -0,0 +1,309 @@ +{ + "data": [ + { + "id": "krea/k3-edit", + "name": "Krea: K3 Edit", + "description": "Krea K3 Edit is an image-to-image editing model from Krea. I", + "created": 1792103728, + "architecture": { + "input_modalities": [ + "text", + "image" + ], + "output_modalities": [ + "image" + ] + }, + "supported_parameters": { + "resolution": { + "type": "enum", + "values": [ + "1K", + "2K" + ] + }, + "aspect_ratio": { + "type": "enum", + "values": [ + "1:1", + "4:3", + "3:2", + "16:9", + "4:5", + "2:3", + "9:16" + ] + }, + "input_references": { + "type": "range", + "min": 1, + "max": 4 + }, + "seed": { + "type": "boolean" + } + }, + "supports_streaming": false, + "endpoints": "/api/v1/images/models/krea/k3-edit/endpoints" + }, + { + "id": "recraft/recraft-v4.1-flash", + "name": "Recraft: Recraft V4.1 Flash", + "description": "Recraft V4.1 Flash is a text-to-image model from Recraft, th", + "created": 1790177946, + "architecture": { + "input_modalities": [ + "text" + ], + "output_modalities": [ + "image" + ] + }, + "supported_parameters": { + "aspect_ratio": { + "type": "enum", + "values": [ + "1:1", + "4:3", + "3:4", + "16:9", + "9:16", + "auto" + ] + }, + "n": { + "type": "range", + "min": 1, + "max": 6 + } + }, + "supports_streaming": false, + "endpoints": "/api/v1/images/models/recraft/recraft-v4.1-flash/endpoints" + }, + { + "id": "bytedance-seed/seedream-5-0-lite", + "name": "ByteDance Seed: Seedream 5.0 Lite", + "description": "Seedream 5.0 Lite is an image generation model from ByteDanc", + "created": 1786650094, + "architecture": { + "input_modalities": [ + "text", + "image" + ], + "output_modalities": [ + "image" + ] + }, + "supported_parameters": { + "resolution": { + "type": "enum", + "values": [ + "2K", + "4K" + ] + }, + "aspect_ratio": { + "type": "enum", + "values": [ + "1:1", + "1:2", + "2:1", + "2:3", + "3:2", + "3:4", + "4:3", + "4:5", + "5:4", + "9:16", + "16:9", + "9:19.5", + "19.5:9", + "9:20", + "20:9", + "9:21", + "21:9", + "auto" + ] + }, + "n": { + "type": "range", + "min": 1, + "max": 4 + }, + "input_references": { + "type": "range", + "min": 0, + "max": 14 + }, + "seed": { + "type": "boolean" + } + }, + "supports_streaming": false, + "endpoints": "/api/v1/images/models/bytedance-seed/seedream-5-0-lite/endpoints" + }, + { + "id": "black-forest-labs/flux.2-klein-4b", + "name": "Black Forest Labs: FLUX.2 Klein 4B", + "description": "FLUX.2 [klein] 4B is the fastest and most cost-effective mod", + "created": 1768429228, + "architecture": { + "input_modalities": [ + "text", + "image" + ], + "output_modalities": [ + "image" + ] + }, + "supported_parameters": { + "aspect_ratio": { + "type": "enum", + "values": [ + "1:1", + "4:3", + "3:4", + "3:2", + "2:3", + "16:9", + "9:16", + "21:9", + "auto" + ] + }, + "output_format": { + "type": "enum", + "values": [ + "png", + "jpeg" + ] + }, + "n": { + "type": "range", + "min": 1, + "max": 1 + }, + "input_references": { + "type": "range", + "min": 0, + "max": 4 + }, + "seed": { + "type": "boolean" + } + }, + "supports_streaming": false, + "endpoints": "/api/v1/images/models/black-forest-labs/flux.2-klein-4b/endpoints" + }, + { + "id": "google/gemini-3-pro-image", + "name": "Google: Nano Banana Pro (Gemini 3 Pro Image)", + "description": "Nano Banana Pro is Google\u2019s most advanced image-generation a", + "created": 1781754054, + "architecture": { + "input_modalities": [ + "image", + "text" + ], + "output_modalities": [ + "image", + "text" + ] + }, + "supported_parameters": { + "resolution": { + "type": "enum", + "values": [ + "1K", + "2K", + "4K" + ] + }, + "aspect_ratio": { + "type": "enum", + "values": [ + "1:1", + "2:3", + "3:2", + "3:4", + "4:3", + "4:5", + "5:4", + "9:16", + "16:9", + "21:9" + ] + }, + "n": { + "type": "range", + "min": 1, + "max": 1 + }, + "input_references": { + "type": "range", + "min": 0, + "max": 14 + } + }, + "supports_streaming": false, + "endpoints": "/api/v1/images/models/google/gemini-3-pro-image/endpoints" + }, + { + "id": "qwen/qwen-image-3", + "name": "Qwen: Qwen Image 3", + "description": "Qwen Image 3 is a unified image generation and editing model", + "created": 1785894548, + "architecture": { + "input_modalities": [ + "text", + "image" + ], + "output_modalities": [ + "image" + ] + }, + "supported_parameters": { + "resolution": { + "type": "enum", + "values": [ + "1K", + "2K" + ] + }, + "aspect_ratio": { + "type": "enum", + "values": [ + "1:1", + "1:2", + "1:4", + "2:1", + "2:3", + "3:2", + "3:4", + "4:1", + "4:3", + "4:5", + "5:4", + "9:16", + "16:9" + ] + }, + "n": { + "type": "range", + "min": 1, + "max": 6 + }, + "input_references": { + "type": "range", + "min": 0, + "max": 4 + }, + "seed": { + "type": "boolean" + } + }, + "supports_streaming": false, + "endpoints": "/api/v1/images/models/qwen/qwen-image-3/endpoints" + } + ] +} diff --git a/internal/catalog/testdata/openrouter-videos.json b/internal/catalog/testdata/openrouter-videos.json new file mode 100644 index 0000000000..b2808c163c --- /dev/null +++ b/internal/catalog/testdata/openrouter-videos.json @@ -0,0 +1,336 @@ +{ + "data": [ + { + "id": "x-ai/grok-imagine-video-1.5", + "canonical_slug": "x-ai/grok-imagine-video-1.5-20260719", + "hugging_face_id": null, + "name": "SpaceXAI: Grok Imagine Video 1.5", + "created": 1784548100, + "description": "Grok Imagine Video 1.5 is a video generation model from Spac", + "supported_resolutions": [ + "480p", + "720p", + "1080p" + ], + "supported_aspect_ratios": [ + "16:9", + "9:16", + "1:1", + "4:3", + "3:4", + "3:2", + "2:3" + ], + "supported_sizes": null, + "supported_durations": [ + 1, + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15 + ], + "supported_frame_images": [ + "first_frame" + ], + "upscale_factor": null, + "creativity": null, + "generate_audio": null, + "seed": null, + "pricing_skus": { + "cents_per_image_input": "1", + "cents_per_video_output_second_480p": "8", + "cents_per_video_output_second_720p": "14", + "cents_per_video_output_second_1080p": "25" + }, + "allowed_passthrough_parameters": [] + }, + { + "id": "runway/gen-4.5", + "canonical_slug": "runway/gen-4.5-20260729", + "hugging_face_id": null, + "name": "Runway: Gen-4.5", + "created": 1785339483, + "description": "Runway Gen-4.5 is a video generation model from Runway for t", + "supported_resolutions": [ + "720p" + ], + "supported_aspect_ratios": [ + "16:9", + "9:16" + ], + "supported_sizes": [ + "1280x720", + "720x1280" + ], + "supported_durations": [ + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10 + ], + "supported_frame_images": [ + "first_frame" + ], + "upscale_factor": null, + "creativity": null, + "generate_audio": false, + "seed": true, + "pricing_skus": { + "cents_per_second_output": "12" + }, + "allowed_passthrough_parameters": [ + "contentModeration" + ] + }, + { + "id": "alibaba/wan-3.0", + "canonical_slug": "alibaba/wan-3.0-20260824", + "hugging_face_id": null, + "name": "Alibaba: Wan 3.0", + "created": 1787603856, + "description": "Wan 3.0 is a video generation model from Alibaba for text-to", + "supported_resolutions": [ + "480p", + "720p", + "1080p" + ], + "supported_aspect_ratios": [ + "16:9", + "4:3", + "1:1", + "3:4", + "9:16" + ], + "supported_sizes": null, + "supported_durations": [ + 2, + 3, + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30 + ], + "supported_frame_images": [ + "first_frame" + ], + "upscale_factor": null, + "creativity": null, + "generate_audio": true, + "seed": true, + "pricing_skus": { + "duration_seconds_480p": "0.05", + "duration_seconds_720p": "0.1", + "duration_seconds_1080p": "0.2" + }, + "allowed_passthrough_parameters": [] + }, + { + "id": "bytedance/seedance-2.5", + "canonical_slug": "bytedance/seedance-2.5-20260807", + "hugging_face_id": null, + "name": "ByteDance: Seedance 2.5", + "created": 1786141253, + "description": "Seedance 2.5 is a video generation model from ByteDance. It ", + "supported_resolutions": [ + "480p", + "720p" + ], + "supported_aspect_ratios": [ + "16:9", + "4:3", + "1:1", + "3:4", + "9:16", + "21:9" + ], + "supported_sizes": [ + "854x480", + "752x560", + "640x640", + "560x752", + "480x854", + "992x432", + "1280x720", + "1112x834", + "960x960", + "834x1112", + "720x1280", + "1470x630" + ], + "supported_durations": [ + 4, + 5, + 6, + 7, + 8, + 9, + 10, + 11, + 12, + 13, + 14, + 15, + 16, + 17, + 18, + 19, + 20, + 21, + 22, + 23, + 24, + 25, + 26, + 27, + 28, + 29, + 30 + ], + "supported_frame_images": [ + "first_frame", + "last_frame" + ], + "upscale_factor": null, + "creativity": null, + "generate_audio": true, + "seed": true, + "pricing_skus": { + "video_tokens": "0.0000107", + "video_tokens_without_audio": "0.0000107", + "video_tokens_with_video_input": "0.0000064" + }, + "allowed_passthrough_parameters": [ + "watermark", + "req_key", + "output_format" + ] + }, + { + "id": "google/veo-3.1", + "canonical_slug": "google/veo-3.1-20260320", + "hugging_face_id": null, + "name": "Google: Veo 3.1", + "created": 1774277148, + "description": "Google's state-of-the-art video generation model, built for ", + "supported_resolutions": [ + "720p", + "1080p", + "4K" + ], + "supported_aspect_ratios": [ + "16:9", + "9:16" + ], + "supported_sizes": [ + "1280x720", + "1080x1920", + "1920x1080", + "720x1280", + "3840x2160", + "2160x3840" + ], + "supported_durations": [ + 4, + 6, + 8 + ], + "supported_frame_images": [ + "first_frame", + "last_frame" + ], + "upscale_factor": null, + "creativity": null, + "generate_audio": true, + "seed": true, + "pricing_skus": { + "duration_seconds_with_audio": "0.40", + "duration_seconds_with_audio_4k": "0.60", + "duration_seconds_without_audio": "0.20", + "duration_seconds_without_audio_4k": "0.40" + }, + "allowed_passthrough_parameters": [ + "personGeneration", + "aspectRatio", + "negativePrompt", + "conditioningScale", + "enhancePrompt" + ] + }, + { + "id": "heygen/avatar-iv", + "canonical_slug": "heygen/avatar-iv-20260625", + "hugging_face_id": null, + "name": "HeyGen: Avatar IV", + "created": 1787595728, + "description": "HeyGen: Avatar IV is an image-to-video model that animates a", + "supported_resolutions": [ + "720p", + "1080p" + ], + "supported_aspect_ratios": [ + "16:9", + "9:16", + "1:1" + ], + "supported_sizes": null, + "supported_durations": null, + "supported_frame_images": null, + "upscale_factor": null, + "creativity": null, + "generate_audio": false, + "seed": false, + "pricing_skus": { + "duration_seconds": "0.05" + }, + "allowed_passthrough_parameters": [ + "voice_id", + "voice_settings", + "motion_prompt", + "expressiveness", + "fit", + "remove_background", + "background", + "caption", + "title" + ] + } + ] +} From f9c3011834394408e64d2367a740563a49f2f21a Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 23:53:59 -0400 Subject: [PATCH 136/459] config: a vision proxy must answer in text, not draw ResolveVisionModel took the first catalog model with image input, which an image editor also has. Require image input AND a text-only answer for the live talk and work choices, the preferred default and the catalog fallback; an explicit CODEAF_VISION_MODEL is still used as written. Adds the change entry for the dedicated media listings. Co-Authored-By: Claude Sonnet 5.5 --- .../0-catalog-dedicated-media-listings.md | 10 ++++ internal/config/config.go | 48 +++++++++++++++++-- internal/config/config_test.go | 26 ++++++++++ 3 files changed, 80 insertions(+), 4 deletions(-) create mode 100644 docs/changes/unreleased/0-catalog-dedicated-media-listings.md diff --git a/docs/changes/unreleased/0-catalog-dedicated-media-listings.md b/docs/changes/unreleased/0-catalog-dedicated-media-listings.md new file mode 100644 index 0000000000..360c812b3d --- /dev/null +++ b/docs/changes/unreleased/0-catalog-dedicated-media-listings.md @@ -0,0 +1,10 @@ +--- +kind: added +title: the model catalog reads OpenRouter's dedicated image and video listings +pr: 0 +surface: [engine] +invalidates: + - "Was true: the catalog was GET /models?output_modalities=all and nothing else, so it held no video-output model at all and 16 of the 59 image generators OpenRouter serves. Is true now: on the OpenRouter source, on its own base only, a load also reads GET /images/models and GET /videos/models (two bounded requests, read after the general listing lands, 4 MiB and 500 rows each) and merges them in; their modalities and parameters win, a chat model keeps its text side and token prices, and a pure generator never gains text. A listing that fails keeps its previous rows and is reported stale by `Catalog.MediaState`; its failure is never proof that no service offers a model." + - "Was true: a catalog price was always dollars per token. Is true now: a video model's per-second rate (`cents_per_*` skus) rides `Model.MediaPrices` in its own unit and `PriceUnknown` still says there is no token price; every other sku, and every image price, stays unknown until `catalog.ImageEndpointPrices` is asked for one model." + - "Was true: `ResolveVisionModel` took the first model that accepted image input. Is true now: it must also answer in text alone, because an image editor takes pictures and returns one. An explicit vision model is still used as written." +--- diff --git a/internal/config/config.go b/internal/config/config.go index bb95439aca..3dae7f627e 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -776,6 +776,13 @@ func (c Config) ResolveVideoModel(models *catalog.Catalog) string { // each advertises image input, Qwen VL when advertised, then the first model // with image input. Unlike generation slots, an unadvertised default is never // invented: no result means view_image can preserve its calm refusal. +// +// EVERY CANDIDATE MUST BE A PAIR OF EYES, NOT A PAINTER. Image input is also +// what an image editor takes, and its answer is a picture: handed a photograph +// and asked what is in it, it draws one. A candidate therefore has to take +// images AND answer in text and nothing else ([seesAndAnswersInText]) — the +// same reading the perception slots apply. The explicit slot above is the +// person's own word and is not second-guessed. func (c Config) ResolveVisionModel(models *catalog.Catalog, talkModel, workModel string) string { if configured := strings.TrimSpace(c.VisionModel); configured != "" { return configured @@ -785,17 +792,50 @@ func (c Config) ResolveVisionModel(models *catalog.Catalog, talkModel, workModel } for _, candidate := range []string{talkModel, workModel, preferredVisionModel} { candidate = strings.TrimSpace(candidate) - if candidate != "" && models.Supports(candidate, "input", "image") { + if candidate == "" { + continue + } + if row, ok := models.Model(candidate); ok && seesAndAnswersInText(row) { return candidate } } - candidates := models.ModelsWithInput("image") - if len(candidates) > 0 { - return candidates[0].ID + for _, candidate := range models.ModelsWithInput("image") { + if seesAndAnswersInText(candidate) { + return candidate.ID + } } return "" } +// seesAndAnswersInText says a row takes images and replies in words. A row +// that publishes no output list at all is a cache written before modalities +// were kept, and is read as it always was: by its input alone. +func seesAndAnswersInText(model catalog.Model) bool { + if !hasInput(model, "image") { + return false + } + return len(model.OutputModalities) == 0 || + (hasOutput(model, "text") && !generatesMedia(model)) +} + +func hasInput(model catalog.Model, modality string) bool { + for _, input := range model.InputModalities { + if strings.EqualFold(strings.TrimSpace(input), modality) { + return true + } + } + return false +} + +func hasOutput(model catalog.Model, modality string) bool { + for _, output := range model.OutputModalities { + if strings.EqualFold(strings.TrimSpace(output), modality) { + return true + } + } + return false +} + // chatCandidatesWithInput is the three PERCEPTION slots' shared question: a // model that takes this kind of material AND can still hold a conversation // about it. The second half is the whole point — a model that takes sound and diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 3aa3261556..2c80b8f9fc 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -120,6 +120,32 @@ func TestVisionModelResolutionOrder(t *testing.T) { } } +// An image editor takes pictures in and gives a picture back. Merging the +// dedicated image listing put dozens of them in the catalog with exactly the +// input a vision proxy looks for, and one of them was first. +func TestVisionModelResolutionNeverPicksAPainterAsEyes(t *testing.T) { + t.Setenv("CODEAF_VISION_MODEL", "") + configured := settings(t) + models := runtimeCatalog(t, ` + {"id":"paint/edit","architecture":{"input_modalities":["text","image"],"output_modalities":["image"]}}, + {"id":"paint/captioning-edit","architecture":{"input_modalities":["text","image"],"output_modalities":["image","text"]}}, + {"id":"eyes/real","architecture":{"input_modalities":["text","image"],"output_modalities":["text"]}}`) + // A painter chosen as the live talk or work model is passed over too. + if got := configured.ResolveVisionModel(models, "paint/edit", "paint/captioning-edit"); got != "eyes/real" { + t.Fatalf("vision = %q, want the first model that answers in text only", got) + } + onlyPainters := runtimeCatalog(t, ` + {"id":"paint/edit","architecture":{"input_modalities":["text","image"],"output_modalities":["image"]}}`) + if got := configured.ResolveVisionModel(onlyPainters, "paint/edit", ""); got != "" { + t.Fatalf("vision = %q, want no proxy rather than an image generator", got) + } + // The person's own slot is never second-guessed. + t.Setenv("CODEAF_VISION_MODEL", "paint/edit") + if got := settings(t).ResolveVisionModel(models, "eyes/real", ""); got != "paint/edit" { + t.Fatalf("explicit vision = %q, want the operator's choice untouched", got) + } +} + type configRoundTripFunc func(*http.Request) (*http.Response, error) func (fn configRoundTripFunc) RoundTrip(request *http.Request) (*http.Response, error) { From 5b29dc7793ae2ec26b53134faf1b12b84d29a5a9 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 23:56:24 -0400 Subject: [PATCH 137/459] catalog: run the dedicated media reads under guard.Go The goroutine sweep refuses a bare go statement in internal/catalog. The failure is pre-set so a recovered fault reads as a failed listing, never as an empty success. Co-Authored-By: Claude Sonnet 5.5 --- internal/catalog/media.go | 20 ++++++++++---------- 1 file changed, 10 insertions(+), 10 deletions(-) diff --git a/internal/catalog/media.go b/internal/catalog/media.go index b4a38314af..193ef54e30 100644 --- a/internal/catalog/media.go +++ b/internal/catalog/media.go @@ -197,19 +197,19 @@ func refreshMedia(ctx context.Context, options Options, models []Model, prior *m imageRows, videoRows []Model imageErr, videoErr error ) + // guard.Go carries the recover, so a fault in a listing's decoding reads as + // that listing failing. The failure is pre-set for exactly that case: a + // recovered goroutine returns nothing, and nothing must not read as success. run := func(kind string, rows *[]Model, failure *error) { - defer wg.Done() - defer func() { - if recovered := recover(); recovered != nil { - _ = guard.Note("catalog/media", recovered) - *failure = errUnreadable - } - }() - *rows, *failure = fetchMedia(ctx, options, kind) + *failure = errUnreadable + guard.Go("catalog/media", func() { + defer wg.Done() + *rows, *failure = fetchMedia(ctx, options, kind) + }) } wg.Add(2) - go run(kindImage, &imageRows, &imageErr) - go run(kindVideo, &videoRows, &videoErr) + run(kindImage, &imageRows, &imageErr) + run(kindVideo, &videoRows, &videoErr) wg.Wait() settle := func(kind *mediaKindCache, rows []Model, err error) { From 09756f456a1e83ae91e0cadf88b2ef7ff417dd07 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 16 Oct 2026 00:10:43 -0400 Subject: [PATCH 138/459] catalog: re-merge dedicated listings from a kept general snapshot A top-up merged fresh dedicated rows over rows that already carried the previous merge, so a retired generator stayed offered and a repriced one held both rates. The cache now keeps the general rows as fetched beside the merged ones; every merge starts from that snapshot and the current dedicated compartments, so a successful listing replaces and a failed one keeps its old rows, dated and stale. A cache that merged without the snapshot cannot recover the chat rows' own capabilities and refetches the general listing once, throttled, instead of guessing. Co-Authored-By: Claude Sonnet 5.5 --- internal/catalog/catalog.go | 18 ++- internal/catalog/media.go | 41 +++++- internal/catalog/media_test.go | 219 +++++++++++++++++++++++++++++++++ 3 files changed, 268 insertions(+), 10 deletions(-) diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go index 7b669d14ad..10f0ab2a61 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -389,8 +389,10 @@ func Remember(options Options, models []Model) error { // so their pure rows are carried over and laid back on the rows given. var media *mediaCache if mediaEligible(options, source, base) { - if previous, ok := readCache(path, source, base); ok { + if previous, ok := readCache(path, source, base); ok && previous.Media != nil { + // The rows given are the new GENERAL rows; the snapshot follows them. media = previous.Media + media.General = models models = mergeMedia(models, media) } } @@ -568,7 +570,14 @@ func load(ctx context.Context, options Options) (*rows, error) { cached, cachedOK := readCache(path, source, base) eligible := mediaEligible(options, source, base) if cachedOK && eligible && options.mediaOnly { - return topUpMedia(ctx, options, cached, path, source, base) + if _, ok := generalOf(cached); ok { + return topUpMedia(ctx, options, cached, path) + } + // The cache merged dedicated rows without keeping the general ones, so + // the chat rows' own capabilities cannot be recovered from it. Say so by + // refetching the general listing, once, here; the retry throttle on the + // dedicated compartment bounds how often this can be asked for. + options.Refresh = true } if cachedOK && !options.Refresh && (options.serveStale || now().Before(cached.FetchedAt.Add(TTL))) { // A fresh cache is served without any request, as it always was, even @@ -627,9 +636,10 @@ func load(ctx context.Context, options Options) (*rows, error) { // topUpMedia reads the dedicated listings over a cache whose general rows it // leaves exactly as they were, and writes the result back. Whatever it cannot // read stays as the cache had it, dated, and is reported stale. -func topUpMedia(ctx context.Context, options Options, cached cache, path, source, base string) (*rows, error) { +func topUpMedia(ctx context.Context, options Options, cached cache, path string) (*rows, error) { now := nowOf(options) - models, media, state := refreshMedia(ctx, options, cached.Models, cached.Media, now().UTC()) + general, _ := generalOf(cached) + models, media, state := refreshMedia(ctx, options, general, cached.Media, now().UTC()) if err := ctx.Err(); err != nil { return cachedRows(cached, options, now()), err } diff --git a/internal/catalog/media.go b/internal/catalog/media.go index 193ef54e30..a75953d869 100644 --- a/internal/catalog/media.go +++ b/internal/catalog/media.go @@ -135,6 +135,15 @@ func (s MediaState) due() bool { return s.Images.due || s.Videos.due } type mediaCache struct { Images mediaKindCache `json:"images"` Videos mediaKindCache `json:"videos"` + // General is the general /models rows exactly as they were fetched, BEFORE + // any dedicated row was laid over them. The cache's own Models are the + // merged result (an older reader wants one list), and a merged row cannot be + // un-merged: a hybrid keeps its chat capabilities and a generator does not, + // and nothing in the merged row says which part came from where. Every + // re-merge therefore starts from this snapshot and the current dedicated + // rows, so a model a listing retired is gone and a repriced one has ONE + // price. Empty in a cache a build without this field wrote: see [generalOf]. + General []Model `json:"general,omitempty"` } type mediaKindCache struct { @@ -179,11 +188,28 @@ func kindStateOf(kind mediaKindCache, now time.Time) MediaKind { return state } +// generalOf is the pristine general rows a cache can vouch for. A cache that +// never merged a dedicated listing holds only general rows in Models. One that +// did holds them in the snapshot. One that merged WITHOUT keeping the snapshot +// (a build from before the field) cannot say which capabilities were the chat +// row's own, and the answer is "unknown" — the caller must refetch the general +// listing rather than pretend to recover them. +func generalOf(cached cache) ([]Model, bool) { + if cached.Media == nil { + return cached.Models, true + } + if len(cached.Media.General) > 0 { + return cached.Media.General, true + } + return nil, false +} + // refreshMedia reads both dedicated listings (at most two requests, in -// parallel, joined before it returns) and merges them over models. A listing -// that fails keeps the previous rows and date of its compartment and is -// reported stale; it never empties the catalog. -func refreshMedia(ctx context.Context, options Options, models []Model, prior *mediaCache, now time.Time) ([]Model, *mediaCache, MediaState) { +// parallel, joined before it returns) and merges them over the GENERAL rows +// given — never over rows already merged. A listing that fails keeps the +// previous rows and date of its compartment and is reported stale; it never +// empties the catalog. A listing that succeeds REPLACES its compartment. +func refreshMedia(ctx context.Context, options Options, general []Model, prior *mediaCache, now time.Time) ([]Model, *mediaCache, MediaState) { next := &mediaCache{} if prior != nil { *next = *prior @@ -220,10 +246,13 @@ func refreshMedia(ctx context.Context, options Options, models []Model, prior *m } settle(&next.Images, imageRows, imageErr) settle(&next.Videos, videoRows, videoErr) - return mergeMedia(models, next), next, mediaStateOf(next, now) + next.General = general + return mergeMedia(general, next), next, mediaStateOf(next, now) } -// mergeMedia lays the pure dedicated rows over the general rows. +// mergeMedia lays the pure dedicated rows over the GENERAL rows. Its first +// argument must never be an earlier result of itself: the overlay adds and +// unions, so merging twice keeps what the first merge added. // // The dedicated listing is authoritative for what a media model takes and // returns, so its modalities, parameters and prices win. The general row stays diff --git a/internal/catalog/media_test.go b/internal/catalog/media_test.go index 4e53b1c5f6..66756ac32b 100644 --- a/internal/catalog/media_test.go +++ b/internal/catalog/media_test.go @@ -765,3 +765,222 @@ func TestExistingGeneralBehaviorIsUnchangedByTheDedicatedListings(t *testing.T) } } } + +// ---- Refresh must REPLACE a dedicated compartment, never accumulate into it. ---- + +func videoListing(rows ...string) mediaReply { + return okReply(`{"data":[` + strings.Join(rows, ",") + `]}`) +} + +func videoRowJSON(id string, centsPerSecond string) string { + return fmt.Sprintf(`{"id":%q,"supported_resolutions":["480p"],"pricing_skus":{"cents_per_video_output_second_480p":%q}}`, id, centsPerSecond) +} + +const hybridGeneral = `{"data":[ + {"id":"hybrid/draws-and-talks","context_length":1000,"architecture":{"input_modalities":["text"],"output_modalities":["text"]},"pricing":{"prompt":"0.000001","completion":"0.000002"},"supported_parameters":["max_tokens"]}, + {"id":"other/chat","architecture":{"input_modalities":["text"],"output_modalities":["text"]},"pricing":{"prompt":"0.000001","completion":"0.000001"}}]}` + +func mediaOptions(router *mediaRouter, dir string, at time.Time) Options { + return Options{BaseURL: DefaultBaseURL, Dir: dir, HTTPClient: router.client(), Now: func() time.Time { return at }} +} + +func TestAFreshDedicatedRefreshRetiresRemovedModelsAndReplacesPrices(t *testing.T) { + dir := t.TempDir() + first := newMediaRouter(t, map[string]mediaReply{ + generalPath: okReply(hybridGeneral), + imagesPath: okReply(fixtureBytes(t, "openrouter-images.json")), + videosPath: videoListing(videoRowJSON("vendor/retired", "2"), videoRowJSON("vendor/repriced", "2")), + }) + loadAt(t, first, dir, day, nil) + + second := newMediaRouter(t, map[string]mediaReply{ + generalPath: okReply(hybridGeneral), + imagesPath: okReply(fixtureBytes(t, "openrouter-images.json")), + videosPath: videoListing(videoRowJSON("vendor/repriced", "3"), videoRowJSON("vendor/new", "5")), + }) + c := loadAt(t, second, dir, day.Add(time.Hour), func(o *Options) { o.Refresh = true }) + assertRetiredAndRepriced(t, c) + + // And the same truth survives a trip through the disk. + assertRetiredAndRepriced(t, loadAt(t, newMediaRouter(t, nil), dir, day.Add(2*time.Hour), nil)) +} + +func assertRetiredAndRepriced(t *testing.T, c *Catalog) { + t.Helper() + if _, ok := c.Model("vendor/retired"); ok { + t.Error("a model the fresh listing no longer publishes is still offered") + } + prices := c.MediaPrices("vendor/repriced") + if len(prices) != 1 || prices[0].USD != 0.03 { + t.Errorf("a repriced model has %+v, want exactly the new 3 cents", prices) + } + if _, ok := c.Model("vendor/new"); !ok { + t.Error("a newly published model is missing") + } +} + +func TestALazyTopUpOfAnOlderCacheRetiresAndRepricesThroughTheDisk(t *testing.T) { + dir := t.TempDir() + legacy := `{"fetched_at":"` + day.Format(time.RFC3339) + `","priced":true,"models":[` + + `{"id":"hybrid/draws-and-talks","input_modalities":["text"],"output_modalities":["text"],"prompt_price":0.000001,"completion_price":0.000002}]}` + if err := os.WriteFile(filepath.Join(dir, cacheName), []byte(legacy), 0o600); err != nil { + t.Fatal(err) + } + waitPersisted := func(want time.Time) { + t.Helper() + deadline := time.After(5 * time.Second) + for { + if cached, ok := readCache(cachePath(dir, "", DefaultBaseURL), "", DefaultBaseURL); ok && cached.Media != nil && cached.Media.Videos.FetchedAt.Equal(want) { + return + } + select { + case <-deadline: + t.Fatalf("the top-up for %v was never persisted", want) + case <-time.After(10 * time.Millisecond): + } + } + } + lazyAt := func(router *mediaRouter, at time.Time, persisted time.Time) { + lazy := LoadLazy(context.Background(), mediaOptions(router, dir, at)) + waitPersisted(persisted) + lazy.Close() + if router.count(generalPath) != 0 { + t.Errorf("a media top-up refetched the general listing") + } + } + t1, t2, t3 := day.Add(time.Hour), day.Add(2*time.Hour), day.Add(3*time.Hour) + + lazyAt(newMediaRouter(t, map[string]mediaReply{ + imagesPath: okReply(fixtureBytes(t, "openrouter-images.json")), + videosPath: videoListing(videoRowJSON("vendor/retired", "2"), videoRowJSON("vendor/repriced", "2")), + }), t1, t1) + + // A failed attempt in between: the old rows stay, and say they are stale. + down := newMediaRouter(t, map[string]mediaReply{imagesPath: {status: 500}, videosPath: {status: 500}}) + if _, err := load(context.Background(), func() Options { o := mediaOptions(down, dir, t2); o.mediaOnly = true; return o }()); err != nil { + t.Fatal(err) + } + between := Load(context.Background(), mediaOptions(newMediaRouter(t, nil), dir, t2.Add(time.Minute))) + if s := between.MediaState(); !s.Videos.Known || !s.Videos.Stale || !s.Videos.FetchedAt.Equal(t1) { + t.Fatalf("after a failed top-up the standing is %+v", s.Videos) + } + if _, ok := between.Model("vendor/retired"); !ok { + t.Error("a transient failure dropped a model it had no evidence against") + } + + // The retry interval passes and the listing answers differently. + lazyAt(newMediaRouter(t, map[string]mediaReply{ + imagesPath: okReply(fixtureBytes(t, "openrouter-images.json")), + videosPath: videoListing(videoRowJSON("vendor/repriced", "3"), videoRowJSON("vendor/new", "5")), + }), t3, t3) + assertRetiredAndRepriced(t, Load(context.Background(), mediaOptions(newMediaRouter(t, nil), dir, t3.Add(time.Minute)))) +} + +func TestARetiredGeneratorHandsAHybridBackExactlyItsChatCapabilities(t *testing.T) { + dir := t.TempDir() + hybridImage := `{"id":"hybrid/draws-and-talks","architecture":{"input_modalities":["text","image"],"output_modalities":["image"]},"supported_parameters":{"aspect_ratio":{"type":"enum","values":["1:1"]}}}` + first := newMediaRouter(t, map[string]mediaReply{ + generalPath: okReply(hybridGeneral), + imagesPath: okReply(`{"data":[` + hybridImage + `]}`), + videosPath: videoListing(videoRowJSON("vendor/v", "2")), + }) + c := loadAt(t, first, dir, day, nil) + if !c.Supports("hybrid/draws-and-talks", "output", "image") || !c.Supports("hybrid/draws-and-talks", "output", "text") { + t.Fatalf("the hybrid was not merged: %v", mustModel(t, c, "hybrid/draws-and-talks").OutputModalities) + } + + // The image listing now publishes a different model entirely. + second := newMediaRouter(t, map[string]mediaReply{ + generalPath: okReply(hybridGeneral), + imagesPath: okReply(`{"data":[{"id":"vendor/other-painter","architecture":{"input_modalities":["text"],"output_modalities":["image"]}}]}`), + videosPath: videoListing(videoRowJSON("vendor/v", "2")), + }) + for name, c := range map[string]*Catalog{ + "in the refresh": loadAt(t, second, dir, day.Add(time.Hour), func(o *Options) { o.Refresh = true }), + "after reopening": loadAt(t, newMediaRouter(t, nil), dir, day.Add(2*time.Hour), nil), + } { + h := mustModel(t, c, "hybrid/draws-and-talks") + if fmt.Sprint(h.OutputModalities) != "[text]" || fmt.Sprint(h.InputModalities) != "[text]" { + t.Errorf("%s: modalities in/out = %v/%v, want the chat row's own", name, h.InputModalities, h.OutputModalities) + } + if h.Media != nil || len(h.Dedicated) != 0 || len(h.MediaPrices) != 0 { + t.Errorf("%s: a retired generator left media facts behind: %+v", name, h) + } + if h.PromptPrice != 0.000001 || h.CompletionPrice != 0.000002 || h.ContextLength != 1000 || len(h.Parameters) != 1 { + t.Errorf("%s: the chat facts changed: %+v", name, h) + } + if _, ok := c.Model("vendor/other-painter"); !ok { + t.Errorf("%s: the new generator is missing", name) + } + } +} + +func mustModel(t *testing.T, c *Catalog, id string) Model { + t.Helper() + model, ok := c.Model(id) + if !ok { + t.Fatalf("%s is missing", id) + } + return model +} + +func TestACacheThatMergedWithoutKeepingTheGeneralRowsRefetchesThemBoundedly(t *testing.T) { + dir := t.TempDir() + // What the first version of this feature wrote: merged Models, dedicated + // rows, no general snapshot. The painter below is a merged-in generator + // that must not survive, and the chat row's text cannot be proven from it. + merged := `{"fetched_at":"` + day.Format(time.RFC3339) + `","priced":true,"models":[` + + `{"id":"hybrid/draws-and-talks","input_modalities":["text","image"],"output_modalities":["text","image"],"dedicated":["image"]},` + + `{"id":"vendor/retired","input_modalities":["text"],"output_modalities":["video"],"dedicated":["video"]}],` + + `"media":{"images":{"fetched_at":"` + day.Format(time.RFC3339) + `","tried_at":"` + day.Format(time.RFC3339) + `"},"videos":{"fetched_at":"` + day.Format(time.RFC3339) + `","tried_at":"` + day.Format(time.RFC3339) + `"}}}` + if err := os.WriteFile(filepath.Join(dir, cacheName), []byte(merged), 0o600); err != nil { + t.Fatal(err) + } + if _, ok := generalOf(mustCache(t, dir)); ok { + t.Fatal("a snapshot-less merged cache claims to know its general rows") + } + router := newMediaRouter(t, map[string]mediaReply{ + generalPath: okReply(hybridGeneral), + imagesPath: okReply(fixtureBytes(t, "openrouter-images.json")), + videosPath: videoListing(videoRowJSON("vendor/v", "2")), + }) + options := mediaOptions(router, dir, day.Add(30*time.Minute)) + options.mediaOnly = true + if _, err := load(context.Background(), options); err != nil { + t.Fatal(err) + } + if router.count(generalPath) != 1 || router.count(imagesPath) != 1 || router.count(videosPath) != 1 { + t.Errorf("recovery asked %v, want one of each listing", router.asked) + } + c := Recall(Options{BaseURL: DefaultBaseURL, Dir: dir, Now: func() time.Time { return day.Add(time.Hour) }}) + if _, ok := c.Model("vendor/retired"); ok { + t.Error("a retired generator from the unrecoverable cache survived the refetch") + } + if h := mustModel(t, c, "hybrid/draws-and-talks"); len(h.Dedicated) != 0 || fmt.Sprint(h.OutputModalities) != "[text]" { + t.Errorf("the hybrid was not rebuilt from the fresh general row: %+v", h) + } + if _, ok := generalOf(mustCache(t, dir)); !ok { + t.Error("the recovered cache still does not keep its general rows") + } +} + +func TestAnUnreadableOrUnsupportedSkuIsUnknownNotAFreeModel(t *testing.T) { + body := videoListing( + `{"id":"vendor/blank","supported_resolutions":["480p"],"pricing_skus":{"cents_per_video_output_second_480p":"","cents_per_image_input":"abc"}}`, + `{"id":"vendor/tokens","supported_resolutions":["480p"],"pricing_skus":{"video_tokens":"0.00001","duration_seconds":"0.05"}}`, + `{"id":"vendor/null","supported_resolutions":["480p"],"pricing_skus":null}`, + `{"id":"vendor/free","supported_resolutions":["480p"],"pricing_skus":{"cents_per_image_input":"0"}}`) + replies := realReplies(t) + replies[videosPath] = body + c := loadAt(t, newMediaRouter(t, replies), t.TempDir(), day, nil) + for _, id := range []string{"vendor/blank", "vendor/tokens", "vendor/null"} { + row := mustModel(t, c, id) + if len(row.MediaPrices) != 0 || !row.PriceUnknown { + t.Errorf("%s: an unreadable price became %+v (unknown=%v)", id, row.MediaPrices, row.PriceUnknown) + } + } + // Only an explicit published zero is a zero. + if free := c.MediaPrices("vendor/free"); len(free) != 1 || free[0].USD != 0 || free[0].Billable != "image_input" { + t.Errorf("a published zero = %+v", free) + } +} From a7c921fd9529196333010cbce6a2dbab3754d749 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 16 Oct 2026 00:41:58 -0400 Subject: [PATCH 139/459] Require a configured or confirmed priced curated media route --- internal/catalog/catalog.go | 6 +- internal/catalog/media.go | 49 +++++- internal/catalog/media_test.go | 37 ++++ internal/config/config.go | 24 ++- internal/config/config_test.go | 42 +++-- internal/config/mediaroute.go | 208 +++++++++++++++++++++++ internal/config/mediaroute_test.go | 261 +++++++++++++++++++++++++++++ internal/config/models.go | 14 +- 8 files changed, 609 insertions(+), 32 deletions(-) create mode 100644 internal/config/mediaroute.go create mode 100644 internal/config/mediaroute_test.go diff --git a/internal/catalog/catalog.go b/internal/catalog/catalog.go index 10f0ab2a61..7b04bdcc9a 100644 --- a/internal/catalog/catalog.go +++ b/internal/catalog/catalog.go @@ -444,6 +444,7 @@ func loadOrFallback(ctx context.Context, options Options) (resolved *rows, err e // rather than to eleven names it never published. if strings.TrimSpace(options.Source) == "" && normalizeBase(options.BaseURL) == DefaultBaseURL { resolved, err = newRows(hardcodedFallbacks()), errUnreadable + resolved.media = unreadMedia(mediaEligible(options, strings.TrimSpace(options.Source), normalizeBase(options.BaseURL))) } else { resolved, err = newRows(nil), errUnreadable } @@ -482,6 +483,7 @@ func LoadLazy(ctx context.Context, options Options) *Catalog { preview = cachedRows(cached, options, nowOf(options)()) } else if source == "" && base == DefaultBaseURL { preview = newRows(hardcodedFallbacks()) + preview.media = unreadMedia(mediaEligible(options, source, base)) } warmCtx, cancel := context.WithCancel(ctx) resolved := &Catalog{ @@ -625,7 +627,9 @@ func load(ctx context.Context, options Options) (*rows, error) { return cachedRows(cached, options, now()), err } if source == "" && base == DefaultBaseURL { - return newRows(hardcodedFallbacks()), err + fallback := newRows(hardcodedFallbacks()) + fallback.media = unreadMedia(eligible) + return fallback, err } // A CAPABILITY THAT CANNOT WORK IS ABSENT, NOT BROKEN — handing eleven // OpenRouter ids to a service that never published them puts four verbs on diff --git a/internal/catalog/media.go b/internal/catalog/media.go index a75953d869..03cfa27471 100644 --- a/internal/catalog/media.go +++ b/internal/catalog/media.go @@ -106,9 +106,20 @@ type MediaKind struct { // MediaState is the standing of both dedicated listings. type MediaState struct { + // Applicable says this catalog is one that reads the dedicated listings at + // all (the OpenRouter source on its own base). False means the question + // "was the video listing read?" has no answer here, and the general rows are + // all there is; true with a kind that is not [MediaKind.Known] means the + // listing has never been read on this install. + Applicable bool Images, Videos MediaKind } +// unreadMedia is the standing of a catalog that is eligible for the dedicated +// listings and has read neither: the compiled-in fallback rows, and an install +// whose first fetch failed. +func unreadMedia(eligible bool) MediaState { return MediaState{Applicable: eligible} } + // Authoritative says the named kind ("image" or "video") was read and is // current, so that a model's ABSENCE from the catalog may be reported as "no // connected service offers it". Anything else — never read, failed, or old — @@ -170,8 +181,9 @@ func mediaStateOf(cached *mediaCache, now time.Time) MediaState { cached = &mediaCache{} } return MediaState{ - Images: kindStateOf(cached.Images, now), - Videos: kindStateOf(cached.Videos, now), + Applicable: true, + Images: kindStateOf(cached.Images, now), + Videos: kindStateOf(cached.Videos, now), } } @@ -818,6 +830,39 @@ func (c *Catalog) MediaStateNow() MediaState { return resolved.media } +// PublishedPrice says the dedicated listing, or the general one, genuinely +// published a finite price for what this model makes ("image" or "video"), in +// a unit a person can be told. It is the test a DEFAULT must pass before it is +// chosen for someone who chose nothing. +// +// Prompt/completion token zeros do not establish a generation tariff. Some +// image models really bill output tokens, but this catalog does not yet carry +// that output tariff separately; it remains unknown here. Unreadable units +// remain unknown too. A positive published per-request price counts for an +// image, while an explicit dedicated per-image zero remains a known zero. +func (m Model) PublishedPrice(output string) bool { + switch strings.ToLower(strings.TrimSpace(output)) { + case kindVideo: + for _, price := range m.MediaPrices { + if price.Billable == "video_output" && price.Unit == "second" && finitePrice(price.USD) { + return true + } + } + case kindImage: + for _, price := range m.MediaPrices { + if price.Billable == "image_output" && (price.Unit == "image" || price.Unit == "megapixel") && finitePrice(price.USD) { + return true + } + } + return m.RequestPrice > 0 && finitePrice(m.RequestPrice) + } + return false +} + +func finitePrice(value float64) bool { + return value >= 0 && !math.IsNaN(value) && !math.IsInf(value, 0) +} + // MediaPrices answers the published per-unit prices of one model, and nil when // none were published. Nil is "unknown", never "free". func (c *Catalog) MediaPrices(modelID string) []MediaPrice { diff --git a/internal/catalog/media_test.go b/internal/catalog/media_test.go index 66756ac32b..764bacdaf4 100644 --- a/internal/catalog/media_test.go +++ b/internal/catalog/media_test.go @@ -984,3 +984,40 @@ func TestAnUnreadableOrUnsupportedSkuIsUnknownNotAFreeModel(t *testing.T) { t.Errorf("a published zero = %+v", free) } } + +func TestPublishedPriceNeedsAFiniteUnitPriceAndNeverATokenZero(t *testing.T) { + cases := []struct { + name string + model Model + output string + want bool + }{ + {"video per second", Model{MediaPrices: []MediaPrice{{Billable: "video_output", Unit: "second", USD: 0.02}}}, "video", true}, + {"video published zero is a published price", Model{MediaPrices: []MediaPrice{{Billable: "video_output", Unit: "second", USD: 0}}}, "video", true}, + {"video token zero", Model{PromptPrice: 0, CompletionPrice: 0}, "video", false}, + {"video request price is not a per-second price", Model{RequestPrice: 0.5}, "video", false}, + {"video input still price only", Model{MediaPrices: []MediaPrice{{Billable: "image_input", Unit: "image", USD: 0.01}}}, "video", false}, + {"image per megapixel", Model{MediaPrices: []MediaPrice{{Billable: "image_output", Unit: "megapixel", USD: 0.014}}}, "image", true}, + {"image request price", Model{RequestPrice: 0.04}, "image", true}, + {"image zero request", Model{RequestPrice: 0}, "image", false}, + {"image negative", Model{MediaPrices: []MediaPrice{{Billable: "image_output", Unit: "image", USD: -1}}}, "image", false}, + {"unknown modality", Model{RequestPrice: 1}, "music", false}, + } + for _, c := range cases { + if got := c.model.PublishedPrice(c.output); got != c.want { + t.Errorf("%s: PublishedPrice = %v, want %v", c.name, got, c.want) + } + } +} + +func TestMediaStateSaysWhetherTheDedicatedListingsApplyAtAll(t *testing.T) { + custom := Load(context.Background(), Options{BaseURL: "https://x.example/v1", Dir: t.TempDir(), + HTTPClient: catalogClient(t, 200, `{"data":[{"id":"a/b"}]}`, nil)}) + if custom.MediaState().Applicable { + t.Error("a custom endpoint claims dedicated listings") + } + offline := Load(context.Background(), Options{BaseURL: DefaultBaseURL, Dir: t.TempDir(), HTTPClient: catalogClient(t, 503, "x", nil)}) + if s := offline.MediaState(); !s.Applicable || s.Authoritative("video") || s.Videos.Known { + t.Errorf("an unread OpenRouter catalog = %+v, want applicable and unconfirmed", s) + } +} diff --git a/internal/config/config.go b/internal/config/config.go index 3dae7f627e..1ef0e56975 100644 --- a/internal/config/config.go +++ b/internal/config/config.go @@ -641,15 +641,13 @@ func load(requireKey bool) (Config, error) { } // ResolveImageModel applies the runtime preference order: an explicit slot, -// Seedream 5.0 Pro then Krea 2 Medium Turbo as each is advertised, then the -// catalog's first image output model. Catalog's offline fallbacks make the -// last resort usable while keeping the ordinary path free of unverified -// defaults. +// then Seedream 5.0 Pro or Krea 2 Medium Turbo — and only when the catalog +// advertises it AND has published a price for it. There is no "first image +// model" rung: see [DefaultMediaRoute], which also says why there was no +// answer. Empty means no default, and the caller keeps image generation off +// the belt or shows [MediaRoute.Err]. func (c Config) ResolveImageModel(models *catalog.Catalog) string { - if configured := strings.TrimSpace(c.ImageModel); configured != "" { - return configured - } - return resolveOutputModel(models, "image", preferredImageModel, fallbackImageModel) + return c.ResolveImageRoute(models).Model } // ResolveSpeechModel prefers Fish Audio S2.1 Pro, then Fish Audio S1, then @@ -762,13 +760,11 @@ func ModelCandidates(models *catalog.Catalog, slot string) []catalog.Model { } } -// ResolveVideoModel prefers Seedance 2.5, then Seedance 2.0 Mini as each is -// advertised, then the catalog's first exact video-output model. +// ResolveVideoModel prefers Seedance 2.5, then Seedance 2.0 Mini — each only +// when advertised and carrying a published per-second price. There is no +// "first video model" rung; see [DefaultMediaRoute]. func (c Config) ResolveVideoModel(models *catalog.Catalog) string { - if configured := strings.TrimSpace(c.VideoModel); configured != "" { - return configured - } - return resolveOutputModel(models, "video", preferredVideoModel, fallbackVideoModel) + return c.ResolveVideoRoute(models).Model } // ResolveVisionModel applies the inspection-proxy order at the moment a leaf diff --git a/internal/config/config_test.go b/internal/config/config_test.go index 2c80b8f9fc..e7bf1cd5a0 100644 --- a/internal/config/config_test.go +++ b/internal/config/config_test.go @@ -66,8 +66,14 @@ func TestMediaSlotsUseEnvironmentAndRuntimeCatalogOrder(t *testing.T) { return nil, http.ErrServerClosed })}, }) - if got := resolved.ResolveImageModel(models); got != preferredImageModel { - t.Fatalf("resolved image = %q", got) + // The offline rows name these models but publish no price for them, and + // a default is only ever a priced one: drawing and filming have NO default + // on a machine that has read nothing, and say the list is unconfirmed. + if got := resolved.ResolveImageModel(models); got != "" { + t.Fatalf("resolved image = %q, want no unpriced default", got) + } + if reason := resolved.ResolveImageRoute(models).Reason; reason != RouteUnconfirmed { + t.Fatalf("offline image route reason = %q", reason) } if got := resolved.ResolveSpeechModel(models); got != preferredSpeechModel { t.Fatalf("resolved speech = %q", got) @@ -75,8 +81,8 @@ func TestMediaSlotsUseEnvironmentAndRuntimeCatalogOrder(t *testing.T) { if got := resolved.ResolveMusicModel(models); got != preferredMusicModel { t.Fatalf("resolved music = %q", got) } - if got := resolved.ResolveVideoModel(models); got != preferredVideoModel { - t.Fatalf("resolved video = %q", got) + if got := resolved.ResolveVideoModel(models); got != "" { + t.Fatalf("resolved video = %q, want no unpriced default", got) } } @@ -166,8 +172,8 @@ func runtimeCatalog(t *testing.T, rows string) *catalog.Catalog { func TestImageAndSpeechPreferenceOrders(t *testing.T) { models := runtimeCatalog(t, ` {"id":"first/image","architecture":{"output_modalities":["image"]}}, - {"id":"bytedance-seed/seedream-5-0-pro","architecture":{"output_modalities":["image"]}}, - {"id":"krea/krea-2-medium-turbo","architecture":{"output_modalities":["image"]}}, + {"id":"bytedance-seed/seedream-5-0-pro","architecture":{"output_modalities":["image"]},"pricing":{"request":"0.04"}}, + {"id":"krea/krea-2-medium-turbo","architecture":{"output_modalities":["image"]},"pricing":{"request":"0.02"}}, {"id":"first/speech","architecture":{"output_modalities":["speech"]}}, {"id":"openai/gpt-4o-mini-tts","architecture":{"output_modalities":["audio"]}}, {"id":"hexgrad/kokoro-82m","architecture":{"output_modalities":["speech"]}}, @@ -186,8 +192,13 @@ func TestImageAndSpeechPreferenceOrders(t *testing.T) { {"id":"first/speech","architecture":{"output_modalities":["speech"]}}, {"id":"openai/gpt-4o-mini-tts","architecture":{"output_modalities":["audio"]}}, {"id":"hexgrad/kokoro-82m","architecture":{"output_modalities":["speech"]}}`) - if got := configured.ResolveImageModel(withoutPrimary); got != "first/image" { - t.Fatalf("image catalog fallback = %q", got) + // No remembered image model is advertised: the catalog's first row is NOT + // taken in its place. The refusal names what is available. + if got := configured.ResolveImageModel(withoutPrimary); got != "" { + t.Fatalf("image catalog fallback = %q, want a model-required refusal", got) + } + if route := configured.ResolveImageRoute(withoutPrimary); route.Reason != RouteModelRequired || len(route.Options) != 1 || route.Options[0] != "first/image" { + t.Fatalf("image route = %+v", route) } if got := configured.ResolveSpeechModel(withoutPrimary); got != kokoroSpeechModel { t.Fatalf("speech secondary preference = %q", got) @@ -221,8 +232,11 @@ func TestMusicAndVideoPreferenceOrders(t *testing.T) { if got := configured.ResolveMusicModel(models); got != preferredMusicModel { t.Fatalf("music preference = %q", got) } - if got := configured.ResolveVideoModel(models); got != preferredVideoModel { - t.Fatalf("video preference = %q", got) + // A general listing cannot price a film (its per-token zero proves nothing), + // so even the remembered Seedance row is not a default here: see + // mediaroute_test.go for the priced path through the dedicated listing. + if got := configured.ResolveVideoModel(models); got != "" { + t.Fatalf("video default = %q, want none without a published price", got) } // The older pair is still reached for when the preferred row is missing, @@ -235,8 +249,8 @@ func TestMusicAndVideoPreferenceOrders(t *testing.T) { if got := configured.ResolveMusicModel(olderPair); got != fallbackMusicModel { t.Fatalf("music secondary preference = %q", got) } - if got := configured.ResolveVideoModel(olderPair); got != fallbackVideoModel { - t.Fatalf("video secondary preference = %q", got) + if got := configured.ResolveVideoModel(olderPair); got != "" { + t.Fatalf("video secondary default = %q, want none without a published price", got) } withoutPreferred := runtimeCatalog(t, ` @@ -246,8 +260,8 @@ func TestMusicAndVideoPreferenceOrders(t *testing.T) { if got := configured.ResolveMusicModel(withoutPreferred); got != "first/music" { t.Fatalf("music catalog fallback = %q", got) } - if got := configured.ResolveVideoModel(withoutPreferred); got != "first/video" { - t.Fatalf("video catalog fallback = %q", got) + if got := configured.ResolveVideoModel(withoutPreferred); got != "" { + t.Fatalf("video catalog fallback = %q, want a model-required refusal", got) } if got := configured.ResolveMusicModel(runtimeCatalog(t, ``)); got != "" { t.Fatalf("empty music catalog resolved %q", got) diff --git a/internal/config/mediaroute.go b/internal/config/mediaroute.go new file mode 100644 index 0000000000..9f979e9aa0 --- /dev/null +++ b/internal/config/mediaroute.go @@ -0,0 +1,208 @@ +package config + +import ( + "errors" + "fmt" + "sort" + "strings" + + "github.com/Agent-Field/codeaf/internal/catalog" +) + +// THE DEFAULT ROUTE FOR DRAWING AND FILMING — chosen, or refused out loud. +// +// A person who has named a model gets that model, whatever the catalog says +// (Config.ImageModel and VideoModel are their decision and nothing here +// substitutes for it). A person who has named none gets a DEFAULT, and a default +// is spending someone else's money on their behalf: it is therefore only ever a +// model this build already remembers (the curated constants below, which this +// file never reorders or replaces), and only when the catalog both advertises it +// for the job and has published a price for it in a unit a person can be told. +// It is never the first row, the newest row, the cheapest-looking row, or a row +// that merely appeared in a listing: the dedicated image and video listings put +// dozens of new rows in the catalog, and "whichever is first" is a different +// bill every time the provider reorders. +// +// WHAT COMES BACK WHEN THERE IS NO DEFAULT is a [MediaRoute] that says which of +// three different things is true, because they need three different sentences: +// +// - RouteModelRequired: models ARE advertised, but none this build may pick +// for you. The person is told to choose one and is shown what is available. +// - RouteNoneAdvertised: the listing was read and is current, and nothing in +// it makes this. Only this reason may say that no connected service offers it. +// - RouteUnconfirmed: the listing was never read, failed, or is out of date. +// A transient failure is not evidence of absence, so nothing is claimed. +// +// Music and speech are deliberately NOT routed through here: the dedicated +// listings add no rows to either, so their defaults are unchanged. + +// RouteReason says why a media route has no model, or how it got one. +type RouteReason string + +const ( + // RouteCurated: the model is this build's remembered default, advertised + // for the job and carrying a published price. + RouteCurated RouteReason = "curated" + // RouteConfigured: the person's own row. No catalog fact can overrule it. + RouteConfigured RouteReason = "configured" + // RouteModelRequired: choose a model; see MediaRoute.Options. + RouteModelRequired RouteReason = "model_required" + // RouteNoneAdvertised: the current listing offers nothing for this job. + RouteNoneAdvertised RouteReason = "none_advertised" + // RouteUnconfirmed: the listing could not be confirmed, so nothing is claimed. + RouteUnconfirmed RouteReason = "unconfirmed" +) + +// Sentinels for errors.Is. A *MediaRouteError matches the one for its reason. +var ( + ErrModelRequired = errors.New("a model must be chosen") + ErrNoneAdvertised = errors.New("no service advertises this") + ErrUnconfirmed = errors.New("the model list could not be confirmed") +) + +// maxRouteOptions bounds how many available models an error names. +const maxRouteOptions = 8 + +// MediaRoute is the answer to "which model should draw or film for someone who +// chose none". +type MediaRoute struct { + Modality string + Model string + Reason RouteReason + // Options are the advertised models for the job, sorted by id and bounded + // by maxRouteOptions; Total counts them all. Sorted by name on purpose: + // an order that carried a judgement (cheapest, newest) would be a default. + Options []string + Total int +} + +// Err is nil when the route has a model and a *MediaRouteError otherwise. +func (r MediaRoute) Err() error { + if r.Model != "" { + return nil + } + return &MediaRouteError{Route: r} +} + +// MediaRouteError is the one error a tool or a setup screen shows when there is +// no default. Its text is a sentence for a person; nothing retries on it. +type MediaRouteError struct{ Route MediaRoute } + +func (e *MediaRouteError) Error() string { + noun := e.Route.Modality + article := "a" + if noun == "image" { + article = "an" + } + switch e.Route.Reason { + case RouteNoneAdvertised: + return fmt.Sprintf("no connected service advertises %s generation", noun) + case RouteUnconfirmed: + return fmt.Sprintf("the %s model list could not be confirmed (it has not been read, or it is out of date), so no default was chosen. Choose %s %s model, or try again later%s", + noun, article, noun, e.available()) + default: + return fmt.Sprintf("there is no default %s model with a published price. Choose %s %s model in settings or name one in the request%s", + noun, article, noun, e.available()) + } +} + +func (e *MediaRouteError) available() string { + if len(e.Route.Options) == 0 { + return "" + } + text := ". Advertised: " + strings.Join(e.Route.Options, ", ") + if more := e.Route.Total - len(e.Route.Options); more > 0 { + text += fmt.Sprintf(" (and %d more)", more) + } + return text +} + +// Is lets errors.Is tell the three reasons apart. +func (e *MediaRouteError) Is(target error) bool { + switch e.Route.Reason { + case RouteModelRequired: + return target == ErrModelRequired + case RouteNoneAdvertised: + return target == ErrNoneAdvertised + case RouteUnconfirmed: + return target == ErrUnconfirmed + } + return false +} + +// curatedRoutes are the remembered defaults, in the order they are tried. +var curatedRoutes = map[string][]string{ + "image": {preferredImageModel, fallbackImageModel}, + "video": {preferredVideoModel, fallbackVideoModel}, +} + +// DefaultMediaRoute is the default for "image" or "video" with no configured +// choice in play. Any other modality has no route here and answers an empty +// RouteModelRequired. +func DefaultMediaRoute(models *catalog.Catalog, modality string) MediaRoute { + modality = strings.ToLower(strings.TrimSpace(modality)) + route := MediaRoute{Modality: modality, Reason: RouteModelRequired} + curated, routed := curatedRoutes[modality] + if models == nil || !routed { + route.Reason = RouteUnconfirmed + return route + } + candidates := ModelCandidates(models, modality) + route.Options, route.Total = routeOptions(candidates) + + // A catalog that reads the dedicated listings must have read THIS one, and + // recently, before it may choose for anyone or claim there is nothing. One + // that does not (another service) has only its general listing, which is + // current only if it was ever read. + state := models.MediaState() + switch { + case state.Applicable && !state.Authoritative(modality): + route.Reason = RouteUnconfirmed + return route + case !state.Applicable && models.FetchedAt().IsZero(): + route.Reason = RouteUnconfirmed + return route + } + if len(candidates) == 0 { + route.Reason = RouteNoneAdvertised + return route + } + for _, preferred := range curated { + for _, candidate := range candidates { + if candidate.ID == preferred && candidate.PublishedPrice(modality) { + route.Model, route.Reason = candidate.ID, RouteCurated + return route + } + } + } + return route +} + +func routeOptions(candidates []catalog.Model) ([]string, int) { + ids := make([]string, 0, len(candidates)) + for _, candidate := range candidates { + ids = append(ids, candidate.ID) + } + sort.Strings(ids) + total := len(ids) + if len(ids) > maxRouteOptions { + ids = ids[:maxRouteOptions] + } + return ids, total +} + +// ResolveImageRoute is [Config.ResolveImageModel] with the reason attached. +func (c Config) ResolveImageRoute(models *catalog.Catalog) MediaRoute { + if configured := strings.TrimSpace(c.ImageModel); configured != "" { + return MediaRoute{Modality: "image", Model: configured, Reason: RouteConfigured} + } + return DefaultMediaRoute(models, "image") +} + +// ResolveVideoRoute is [Config.ResolveVideoModel] with the reason attached. +func (c Config) ResolveVideoRoute(models *catalog.Catalog) MediaRoute { + if configured := strings.TrimSpace(c.VideoModel); configured != "" { + return MediaRoute{Modality: "video", Model: configured, Reason: RouteConfigured} + } + return DefaultMediaRoute(models, "video") +} diff --git a/internal/config/mediaroute_test.go b/internal/config/mediaroute_test.go new file mode 100644 index 0000000000..066160b7e7 --- /dev/null +++ b/internal/config/mediaroute_test.go @@ -0,0 +1,261 @@ +package config + +import ( + "context" + "errors" + "io" + "net/http" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/catalog" +) + +// A fake OpenRouter: the general, image and video listings of the default base +// answered from strings, with every request counted. Nothing here is the +// network, and nothing is a per-model request. +type routeServer struct { + mu sync.Mutex + general, images, vids string + failVideos bool + requests []string +} + +func (s *routeServer) client() *http.Client { + return &http.Client{Transport: configRoundTripFunc(func(request *http.Request) (*http.Response, error) { + s.mu.Lock() + s.requests = append(s.requests, request.Method+" "+request.URL.Path) + s.mu.Unlock() + body, status := "{}", http.StatusNotFound + switch request.URL.Path { + case "/api/v1/models": + body, status = s.general, http.StatusOK + case "/api/v1/images/models": + body, status = s.images, http.StatusOK + case "/api/v1/videos/models": + body, status = s.vids, http.StatusOK + if s.failVideos { + body, status = "down", http.StatusServiceUnavailable + } + } + return &http.Response{StatusCode: status, Status: http.StatusText(status), Header: make(http.Header), + Body: io.NopCloser(strings.NewReader(body)), Request: request}, nil + })} +} + +func (s *routeServer) load(t *testing.T, dir string, at time.Time, refresh bool) *catalog.Catalog { + t.Helper() + return catalog.Load(context.Background(), catalog.Options{ + BaseURL: catalog.DefaultBaseURL, Dir: dir, HTTPClient: s.client(), Refresh: refresh, Now: func() time.Time { return at }, + }) +} + +const routeGeneral = `{"data":[{"id":"chat/model","architecture":{"input_modalities":["text"],"output_modalities":["text"]},"pricing":{"prompt":"0.000001","completion":"0.000001"}}]}` + +func imageListing(ids ...string) string { + rows := make([]string, 0, len(ids)) + for _, id := range ids { + rows = append(rows, `{"id":"`+id+`","architecture":{"input_modalities":["text"],"output_modalities":["image"]},"supported_parameters":{}}`) + } + return `{"data":[` + strings.Join(rows, ",") + `]}` +} + +func videoListing(rows ...string) string { return `{"data":[` + strings.Join(rows, ",") + `]}` } + +func pricedVideo(id string) string { + return `{"id":"` + id + `","supported_resolutions":["480p"],"pricing_skus":{"cents_per_video_output_second_480p":"2"}}` +} + +func tokenOnlyVideo(id string) string { + return `{"id":"` + id + `","supported_resolutions":["480p"],"pricing_skus":{"video_tokens":"0.00001"}}` +} + +var routeDay = time.Now().UTC() + +func TestAPricedAdvertisedCuratedVideoIsTheOnlyDefault(t *testing.T) { + server := &routeServer{general: routeGeneral, images: imageListing("a/img"), + vids: videoListing(tokenOnlyVideo("aaa/first-listed"), pricedVideo(preferredVideoModel), pricedVideo("zzz/newest"))} + models := server.load(t, t.TempDir(), routeDay, false) + route := (Config{}).ResolveVideoRoute(models) + if route.Model != preferredVideoModel || route.Reason != RouteCurated || route.Err() != nil { + t.Fatalf("route = %+v", route) + } + if (Config{}).ResolveVideoModel(models) != preferredVideoModel || CandidateMediaModel(models, "video") != preferredVideoModel { + t.Error("the string resolvers disagree with the route") + } + if FallbackMediaModel("video") != "" || FallbackMediaModel("image") != "" { + t.Error("the bare curated name for a spending modality bypasses the price gate") + } +} + +func TestReorderingOrAddingUnknownNewRowsNeverChangesTheDefault(t *testing.T) { + rows := []string{tokenOnlyVideo("m/one"), pricedVideo("m/two"), pricedVideo(fallbackVideoModel), pricedVideo("m/three")} + baseline := (&routeServer{general: routeGeneral, images: imageListing("a/img"), vids: videoListing(rows...)}).load(t, t.TempDir(), routeDay, false) + want := (Config{}).ResolveVideoRoute(baseline) + if want.Model != fallbackVideoModel { + t.Fatalf("baseline = %+v, want the remembered older Seedance row", want) + } + reordered := []string{rows[3], rows[2], rows[1], rows[0]} + withNew := append([]string{pricedVideo("brand/new-and-cheap"), tokenOnlyVideo("brand/unpriced")}, rows...) + for name, listing := range map[string][]string{"reordered": reordered, "with new rows first": withNew} { + got := (Config{}).ResolveVideoRoute((&routeServer{general: routeGeneral, images: imageListing("a/img"), vids: videoListing(listing...)}).load(t, t.TempDir(), routeDay, false)) + if got.Model != want.Model { + t.Errorf("%s: default moved to %q", name, got.Model) + } + } + // With no remembered row at all the answer is the same refusal however the + // listing is ordered, with options sorted by name and not by anything else. + noCurated := []string{pricedVideo("m/zz"), tokenOnlyVideo("m/aa"), pricedVideo("m/mm")} + a := (Config{}).ResolveVideoRoute((&routeServer{general: routeGeneral, images: imageListing("a/img"), vids: videoListing(noCurated...)}).load(t, t.TempDir(), routeDay, false)) + b := (Config{}).ResolveVideoRoute((&routeServer{general: routeGeneral, images: imageListing("a/img"), vids: videoListing(noCurated[2], noCurated[1], noCurated[0])}).load(t, t.TempDir(), routeDay, false)) + if a.Model != "" || a.Reason != RouteModelRequired || strings.Join(a.Options, ",") != "m/aa,m/mm,m/zz" || strings.Join(a.Options, ",") != strings.Join(b.Options, ",") { + t.Errorf("refusals = %+v / %+v", a, b) + } +} + +func TestAnAbsentOrUnpricedCuratedModelIsRefusedNotReplaced(t *testing.T) { + absent := (&routeServer{general: routeGeneral, images: imageListing("a/img"), vids: videoListing(pricedVideo("other/priced"))}).load(t, t.TempDir(), routeDay, false) + unpriced := (&routeServer{general: routeGeneral, images: imageListing("a/img"), vids: videoListing(tokenOnlyVideo(preferredVideoModel), tokenOnlyVideo(fallbackVideoModel), pricedVideo("other/priced"))}).load(t, t.TempDir(), routeDay, false) + for name, models := range map[string]*catalog.Catalog{"absent": absent, "unpriced": unpriced} { + route := (Config{}).ResolveVideoRoute(models) + if route.Model != "" || route.Reason != RouteModelRequired || !errors.Is(route.Err(), ErrModelRequired) { + t.Errorf("%s: route = %+v", name, route) + } + if errors.Is(route.Err(), ErrNoneAdvertised) { + t.Errorf("%s: models exist and the error says none do", name) + } + if message := route.Err().Error(); !strings.Contains(message, "other/priced") || !strings.Contains(message, "Choose a video model") { + t.Errorf("%s: message = %q", name, message) + } + } +} + +func TestAnImageDefaultNeedsAGenuinelyReportedPrice(t *testing.T) { + general := `{"data":[{"id":"` + preferredImageModel + `","architecture":{"input_modalities":["text"],"output_modalities":["image"]},"pricing":{"prompt":"0","completion":"0"}}]}` + zeroTokens := (&routeServer{general: general, images: imageListing(preferredImageModel, "x/other"), vids: videoListing(pricedVideo("v/v"))}).load(t, t.TempDir(), routeDay, false) + route := (Config{}).ResolveImageRoute(zeroTokens) + if route.Model != "" || route.Reason != RouteModelRequired { + t.Errorf("a per-token zero was taken as a price: %+v", route) + } + if !strings.Contains(route.Err().Error(), "x/other") { + t.Errorf("the refusal does not name the options: %v", route.Err()) + } + + priced := `{"data":[{"id":"` + preferredImageModel + `","architecture":{"input_modalities":["text"],"output_modalities":["image"]},"pricing":{"request":"0.04"}}]}` + ok := (&routeServer{general: priced, images: imageListing(preferredImageModel), vids: videoListing(pricedVideo("v/v"))}).load(t, t.TempDir(), routeDay, false) + if got := (Config{}).ResolveImageRoute(ok); got.Model != preferredImageModel || got.Reason != RouteCurated { + t.Errorf("a genuinely priced curated image row = %+v", got) + } + // A request price of zero is not a price either. + free := `{"data":[{"id":"` + preferredImageModel + `","architecture":{"input_modalities":["text"],"output_modalities":["image"]},"pricing":{"request":"0"}}]}` + zero := (&routeServer{general: free, images: imageListing(preferredImageModel), vids: videoListing(pricedVideo("v/v"))}).load(t, t.TempDir(), routeDay, false) + if got := (Config{}).ResolveImageRoute(zero); got.Model != "" { + t.Errorf("a zero request price chose a default: %+v", got) + } +} + +func TestAConfiguredRouteIsAlwaysTheHumansChoice(t *testing.T) { + models := (&routeServer{general: routeGeneral, images: imageListing("a/img"), vids: videoListing(tokenOnlyVideo("m/one"))}).load(t, t.TempDir(), routeDay, false) + configured := Config{ImageModel: " my/drawer ", VideoModel: "my/filmer"} + if r := configured.ResolveImageRoute(models); r.Model != "my/drawer" || r.Reason != RouteConfigured { + t.Errorf("image = %+v", r) + } + if r := configured.ResolveVideoRoute(models); r.Model != "my/filmer" || r.Reason != RouteConfigured { + t.Errorf("video = %+v", r) + } + if configured.ResolveVideoModel(nil) != "my/filmer" { + t.Error("a configured route needs no catalog") + } +} + +func TestAStaleOrUnreadListingNeverClaimsNoServiceOffersVideo(t *testing.T) { + dir := t.TempDir() + good := &routeServer{general: routeGeneral, images: imageListing("a/img"), vids: videoListing(pricedVideo(preferredVideoModel))} + good.load(t, dir, routeDay, false) + + // The video listing fails on the next refresh: the old rows are kept, so the + // remembered model is still ADVERTISED — but it is no longer a CURRENT + // listing, so no default is chosen and nothing says "none". + down := &routeServer{general: routeGeneral, images: imageListing("a/img"), vids: "", failVideos: true} + stale := down.load(t, dir, routeDay.Add(time.Hour), true) + route := (Config{}).ResolveVideoRoute(stale) + if route.Model != "" || route.Reason != RouteUnconfirmed || !errors.Is(route.Err(), ErrUnconfirmed) { + t.Fatalf("stale route = %+v", route) + } + if errors.Is(route.Err(), ErrNoneAdvertised) { + t.Error("a stale listing claimed no service offers video") + } + if len(route.Options) != 1 { + t.Errorf("the known models were hidden from the person: %v", route.Options) + } + + // A listing that has NEVER been read (cold start, video endpoint down) is + // unconfirmed too, with nothing to list. + cold := (&routeServer{general: routeGeneral, images: imageListing("a/img"), vids: "", failVideos: true}).load(t, t.TempDir(), routeDay, false) + if r := (Config{}).ResolveVideoRoute(cold); r.Reason != RouteUnconfirmed || r.Err() == nil { + t.Errorf("cold route = %+v", r) + } + // And a current, authoritative, genuinely empty answer is the only none. + // (An empty listing is itself refused as unreadable, so none is reached + // through a service that does not read the dedicated listings at all.) + other := runtimeCatalog(t, `{"id":"chat/model","architecture":{"input_modalities":["text"],"output_modalities":["text"]}}`) + if r := (Config{}).ResolveVideoRoute(other); r.Reason != RouteNoneAdvertised || !errors.Is(r.Err(), ErrNoneAdvertised) { + t.Errorf("other service route = %+v", r) + } +} + +func TestResolvingDefaultsFloodsNothingAndNeverAsksPerModelPrices(t *testing.T) { + server := &routeServer{general: routeGeneral, images: imageListing("a/img", "b/img", "c/img"), vids: videoListing(pricedVideo("m/one"), pricedVideo("m/two"))} + models := server.load(t, t.TempDir(), routeDay, false) + before := len(server.requests) + for i := 0; i < 50; i++ { + (Config{}).ResolveImageModel(models) + (Config{}).ResolveVideoModel(models) + CandidateMediaModel(models, "video") + } + if len(server.requests) != before { + t.Errorf("resolving made %d requests", len(server.requests)-before) + } + for _, request := range server.requests { + if !strings.HasPrefix(request, "GET ") || strings.Contains(request, "/endpoints") { + t.Errorf("unexpected request %q", request) + } + } + if before != 3 { + t.Errorf("a load made %d requests, want the three listings", before) + } +} + +func TestSpeechAndMusicDefaultsAreUnchanged(t *testing.T) { + models := runtimeCatalog(t, ` + {"id":"first/speech","architecture":{"output_modalities":["speech"]}}, + {"id":"first/music","architecture":{"output_modalities":["music"]}}`) + if got := (Config{}).ResolveSpeechModel(models); got != "first/speech" { + t.Errorf("speech default = %q", got) + } + if got := (Config{}).ResolveMusicModel(models); got != "first/music" { + t.Errorf("music default = %q", got) + } + if got := FallbackMediaModel("music"); got != preferredMusicModel { + t.Errorf("music curated name = %q", got) + } +} + +func TestMediaRouteErrorsReadAsOneSentenceEach(t *testing.T) { + cases := map[RouteReason]string{ + RouteModelRequired: "there is no default image model with a published price. Choose an image model", + RouteUnconfirmed: "the image model list could not be confirmed", + RouteNoneAdvertised: "no connected service advertises image generation", + } + for reason, want := range cases { + err := MediaRoute{Modality: "image", Reason: reason, Options: []string{"a", "b"}, Total: 10}.Err() + if err == nil || !strings.HasPrefix(err.Error(), want) { + t.Errorf("%s: %v", reason, err) + } + } + if err := (MediaRoute{Modality: "image", Reason: RouteModelRequired, Options: []string{"a"}, Total: 10}).Err(); !strings.Contains(err.Error(), "(and 9 more)") { + t.Errorf("the overflow is not counted: %v", err) + } +} diff --git a/internal/config/models.go b/internal/config/models.go index d7e0135e60..3cdb1ee3b3 100644 --- a/internal/config/models.go +++ b/internal/config/models.go @@ -84,6 +84,11 @@ var curatedMediaModels = map[string]string{ // the most expensive thing on the shelf. func CandidateMediaModel(models *catalog.Catalog, modality string) string { modality = strings.ToLower(strings.TrimSpace(modality)) + // Drawing and filming spend money, so their catalog rung is the same gated + // default as the settings resolvers: never a row that merely came first. + if _, routed := curatedRoutes[modality]; routed { + return DefaultMediaRoute(models, modality).Model + } candidates := ModelCandidates(models, modality) if len(candidates) == 0 { return "" @@ -137,7 +142,14 @@ func provisionalModelID(id string) bool { // FallbackMediaModel is the curated rung, and empty for a modality this build // has no remembered name for. func FallbackMediaModel(modality string) string { - return curatedMediaModels[strings.ToLower(strings.TrimSpace(modality))] + modality = strings.ToLower(strings.TrimSpace(modality)) + // The curated NAME of a spending modality is only a default once the catalog + // has priced it, and that check is [DefaultMediaRoute]'s. Handing the bare + // name to a caller that only checks capability would bypass it. + if _, routed := curatedRoutes[modality]; routed { + return "" + } + return curatedMediaModels[modality] } type scoredModel struct { From 8f276cd3ff74c5145b7c507d897d3af79e71c392 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 16 Oct 2026 00:47:23 -0400 Subject: [PATCH 140/459] Expose typed render lifecycle and refuse unsaved or stale history facts --- cmd/codeaf/app.go | 10 +- cmd/codeaf/apprender.go | 162 ++++ cmd/codeaf/apprender_test.go | 157 ++++ .../unreleased/0-media-oversize-refused.md | 2 +- .../unreleased/0-media-render-ledger-exact.md | 8 + .../unreleased/0-media-render-ledger.md | 8 + internal/appwire/render.go | 149 ++++ internal/appwire/render_test.go | 58 ++ internal/remote/callclass.go | 4 +- internal/remote/driver.go | 1 + internal/remote/server.go | 4 +- internal/remote/wire_render.go | 163 ++++ internal/remote/wire_render_test.go | 115 +++ internal/session/jobs.go | 100 ++- internal/session/render_app.go | 85 ++ internal/session/render_event.go | 21 + internal/session/render_ledger_test.go | 751 ++++++++++++++++ internal/session/renderlog.go | 831 ++++++++++++++++++ internal/session/session.go | 11 + internal/session/tools_music.go | 16 + internal/session/tools_video.go | 16 + 21 files changed, 2665 insertions(+), 7 deletions(-) create mode 100644 cmd/codeaf/apprender.go create mode 100644 cmd/codeaf/apprender_test.go create mode 100644 docs/changes/unreleased/0-media-render-ledger-exact.md create mode 100644 docs/changes/unreleased/0-media-render-ledger.md create mode 100644 internal/appwire/render.go create mode 100644 internal/appwire/render_test.go create mode 100644 internal/remote/wire_render.go create mode 100644 internal/remote/wire_render_test.go create mode 100644 internal/session/render_app.go create mode 100644 internal/session/render_event.go create mode 100644 internal/session/render_ledger_test.go create mode 100644 internal/session/renderlog.go diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index 8df06b7eaf..5a50d71a43 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -481,6 +481,10 @@ type appLive struct { asksDone chan struct{} stopAsks func() tasks map[string]appwire.Task + // renders is the last record sent for each render, by id: the same news + // arrives on the turn's stream and on the task lane, and each change is + // said once (apprender.go). + renders map[string]appwire.Render // asks are the open asks in the order they were raised, and questions the // engine's own question behind each, which is what an answer is built from. asks []appwire.Ask @@ -1768,7 +1772,7 @@ func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, n if title := strings.TrimSpace(event.Text); title != "" { changed(func(v *appwire.Agent) { v.Title = title }) } - case session.EventTaskUpdate, session.EventTaskProposal, session.EventTaskPhase, session.EventJobUpdate: + case session.EventTaskUpdate, session.EventTaskProposal, session.EventTaskPhase, session.EventJobUpdate, session.EventRenderUpdate: // A task a running task call made is that call's task. if task := event.Task; task != nil && (event.Kind == session.EventTaskProposal || task.State == "" || task.State == session.TaskQueued) { b.callTask(a, appTaskID(id, task.ID)) @@ -1986,6 +1990,10 @@ func (b *appBackend) workEvent(id string, live *appLive, event session.Event) { if event.Job != nil { b.processMoved(id, live, event.Job) } + case session.EventRenderUpdate: + if event.Render != nil { + b.renderChanged(id, live, event.Render) + } } } diff --git a/cmd/codeaf/apprender.go b/cmd/codeaf/apprender.go new file mode 100644 index 0000000000..190a8e800e --- /dev/null +++ b/cmd/codeaf/apprender.go @@ -0,0 +1,162 @@ +package main + +import ( + "errors" + "path/filepath" + "reflect" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/session" +) + +// THE RENDER DOOR of the desktop app: render.list, render.get and render.cancel +// (appwire/render.go), answered from the engine's render ledger. The engine is +// asked through the conversation when this process holds it, and the ledger file +// is read directly when it does not. A conversation that cannot answer is +// reported as unsupported — never as an empty list. + +// appRenderEngine is the conversation half: a connection that is not the +// engine's own session (an older host) does not have it. +type appRenderEngine interface { + Renders(limit int, cursor string) (session.RenderPage, error) + Render(id string) (session.RenderRecord, error) + CancelRender(id string) (session.RenderCancel, error) +} + +func appRenderOf(r session.RenderRecord) appwire.Render { + return appwire.Render{ + ID: r.ID, Job: r.Job, Session: r.Session, Kind: r.Kind, State: string(r.State), Model: r.Model, + Started: r.Started, Ended: r.Ended, DurationMS: r.DurationMS, Run: r.Run, Call: r.Call, + Committed: r.Committed, Path: r.Path, Charge: string(r.Charge), CostUSD: r.CostUSD, + Upstream: r.Upstream, Error: r.Error, Persistence: r.Persistence, + } +} + +func appRenderError(err error) error { + switch { + case errors.Is(err, session.ErrRenderNotFound): + return appwire.Errorf(appwire.CodeNotFound, "there is no such render") + case errors.Is(err, session.ErrRenderBusy): + return appwire.Errorf(appwire.CodeBusy, "the render history is too large to search right now; try again") + case errors.Is(err, session.ErrRenderCursor): + return appwire.Errorf(appwire.CodeBadParams, "the cursor is not valid; list again from the start") + } + return appEngineError(err) +} + +// renderEngine is the live conversation's engine half, or nil when this process +// does not hold the conversation open. unsupported is true when it is held but +// cannot answer for renders. +func (b *appBackend) renderEngine(a *appAgent) (engine appRenderEngine, unsupported bool) { + live := b.liveOf(a) + if live == nil { + return nil, false + } + engine, ok := live.conn.Conv.(appRenderEngine) + return engine, !ok +} + +func (b *appBackend) renderDir(a *appAgent) string { + var transcript string + b.locked(func() { transcript = a.transcript }) + if transcript == "" { + return "" + } + return filepath.Dir(transcript) +} + +// ListRenders pages the conversation's whole render history, newest first. +func (b *appBackend) ListRenders(agent, cursor string, limit int) (appwire.RenderPage, error) { + a, err := b.find(agent) + if err != nil { + return appwire.RenderPage{}, err + } + var page session.RenderPage + engine, unsupported := b.renderEngine(a) + switch { + case unsupported: + return appwire.RenderPage{}, appwire.Errorf(appwire.CodeUnknownMethod, "this host does not serve render.list") + case engine != nil: + page, err = engine.Renders(limit, cursor) + default: + page, err = session.ReadRenders(b.renderDir(a), limit, cursor) + } + if err != nil { + return appwire.RenderPage{}, appRenderError(err) + } + out := appwire.RenderPage{Renders: make([]appwire.Render, 0, len(page.Renders)), Next: page.Next, Incomplete: page.Incomplete} + for _, r := range page.Renders { + out.Renders = append(out.Renders, appRenderOf(r)) + } + return out, nil +} + +// GetRender answers one render by its stable id from the whole history. +func (b *appBackend) GetRender(agent, id string) (appwire.Render, error) { + a, err := b.find(agent) + if err != nil { + return appwire.Render{}, err + } + var record session.RenderRecord + engine, unsupported := b.renderEngine(a) + switch { + case unsupported: + return appwire.Render{}, appwire.Errorf(appwire.CodeUnknownMethod, "this host does not serve render.get") + case engine != nil: + record, err = engine.Render(id) + default: + record, err = session.ReadRender(b.renderDir(a), id) + } + if err != nil { + return appwire.Render{}, appRenderError(err) + } + return appRenderOf(record), nil +} + +// CancelRender stops a running render. It needs the conversation held: a render +// can only be running in a process that holds it. +func (b *appBackend) CancelRender(agent, id string) (appwire.RenderCancel, error) { + a, err := b.find(agent) + if err != nil { + return appwire.RenderCancel{}, err + } + engine, unsupported := b.renderEngine(a) + switch { + case unsupported: + return appwire.RenderCancel{}, appwire.Errorf(appwire.CodeUnknownMethod, "this host does not serve render.cancel") + case engine == nil: + // Not held here: nothing in this process is running it. Say what the + // history says rather than pretend a stop happened. + record, readErr := session.ReadRender(b.renderDir(a), id) + if readErr != nil { + return appwire.RenderCancel{}, appRenderError(readErr) + } + return appwire.RenderCancel{Outcome: string(session.RenderOutcomeEnded), Render: appRenderOf(record)}, nil + } + result, err := engine.CancelRender(id) + if err != nil { + return appwire.RenderCancel{}, appRenderError(err) + } + return appwire.RenderCancel{Outcome: string(result.Outcome), Render: appRenderOf(result.Render)}, nil +} + +// renderChanged forwards one render's record to the app as render.changed. The +// record replaces the one with that id; a late charge receipt is the same +// event with the same state, never a second completion. +func (b *appBackend) renderChanged(agent string, live *appLive, record *session.RenderRecord) { + render := appRenderOf(*record) + same := false + b.locked(func() { + if live.renders == nil { + live.renders = map[string]appwire.Render{} + } + before, known := live.renders[render.ID] + same = known && reflect.DeepEqual(before, render) + live.renders[render.ID] = render + }) + if !same { + live.box.Event(appwire.EventRenderChanged, appwire.RenderChanged{Agent: agent, Render: render}) + } +} + +var _ appwire.RenderController = (*appBackend)(nil) diff --git a/cmd/codeaf/apprender_test.go b/cmd/codeaf/apprender_test.go new file mode 100644 index 0000000000..4b2026c4d7 --- /dev/null +++ b/cmd/codeaf/apprender_test.go @@ -0,0 +1,157 @@ +package main + +import ( + "errors" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/session" +) + +// renderConversation is a conversation that keeps renders. +type renderConversation struct { + *fakeConversation + page session.RenderPage + listErr error + records map[string]session.RenderRecord + cancelled []string +} + +func (c *renderConversation) Renders(limit int, cursor string) (session.RenderPage, error) { + return c.page, c.listErr +} + +func (c *renderConversation) Render(id string) (session.RenderRecord, error) { + if r, ok := c.records[id]; ok { + return r, nil + } + return session.RenderRecord{}, session.ErrRenderNotFound +} + +func (c *renderConversation) CancelRender(id string) (session.RenderCancel, error) { + c.cancelled = append(c.cancelled, id) + r, ok := c.records[id] + if !ok { + return session.RenderCancel{}, session.ErrRenderNotFound + } + r.State = session.RenderCancelled + return session.RenderCancel{Outcome: session.RenderOutcomeCancelled, Render: r}, nil +} + +// plainConversation hides everything but the conversation interface, which is +// what an older host's connection looks like. +type plainConversation struct{ appConversation } + +func renderBackend(t *testing.T, conv appConversation) (*appBackend, *appRecorder, appwire.Agent) { + t.Helper() + rec := newAppRecorder() + backend := newFakeBackend(rec, func(ask appOpenAsk) (*appConn, error) { + return &appConn{Conv: conv, Workspace: ask.Workspace, Transcript: appFakeTranscript, Release: func() {}}, nil + }) + t.Cleanup(func() { backend.Close() }) + agent, err := mustCreate(t, backend, appwire.CreateAsk{Workspace: t.TempDir()}) + if err != nil { + t.Fatal(err) + } + return backend, rec, agent +} + +func TestRenderRPCsReachTheEngineAndKeepItsAnswers(t *testing.T) { + cost := 0.4 + record := session.RenderRecord{ID: "rnd_1", Job: 3, Kind: "video", State: session.RenderRunning, Model: "m", Started: time.Unix(1_700_000_000, 0), + Charge: session.RenderChargeUnknown, Run: "r", Call: "c"} + done := record + done.ID, done.State, done.Charge, done.CostUSD = "rnd_2", session.RenderFinished, session.RenderCharged, &cost + conv := &renderConversation{ + fakeConversation: &fakeConversation{}, + page: session.RenderPage{Renders: []session.RenderRecord{record, done}, Next: "r2.12", Incomplete: true}, + records: map[string]session.RenderRecord{"rnd_1": record, "rnd_2": done}, + } + backend, _, agent := renderBackend(t, conv) + if _, _, err := backend.ensure(mustFind(t, backend, agent.ID)); err != nil { + t.Fatal(err) + } + + page, err := backend.ListRenders(agent.ID, "", 50) + if err != nil || len(page.Renders) != 2 || page.Next != "r2.12" || !page.Incomplete || page.Renders[1].CostUSD == nil || *page.Renders[1].CostUSD != cost || page.Renders[0].Run != "r" || page.Renders[0].Call != "c" { + t.Fatalf("list = %+v, %v", page, err) + } + if _, err := backend.GetRender(agent.ID, "rnd_missing"); !isWire(err, appwire.CodeNotFound) { + t.Fatalf("an absent id = %v, want not_found", err) + } + cancelled, err := backend.CancelRender(agent.ID, "rnd_1") + if err != nil || cancelled.Outcome != "cancelled" || cancelled.Render.State != "cancelled" || len(conv.cancelled) != 1 { + t.Fatalf("cancel = %+v, %v", cancelled, err) + } + conv.listErr = session.ErrRenderBusy + if _, err := backend.ListRenders(agent.ID, "", 50); !isWire(err, appwire.CodeBusy) { + t.Fatalf("a busy history = %v, want busy", err) + } +} + +// A host whose connection does not keep renders is UNSUPPORTED, never empty. +func TestRenderRPCsOnAnOldHostAreUnknownMethodNotEmpty(t *testing.T) { + backend, _, agent := renderBackend(t, plainConversation{&fakeConversation{}}) + if _, _, err := backend.ensure(mustFind(t, backend, agent.ID)); err != nil { + t.Fatal(err) + } + if _, err := backend.ListRenders(agent.ID, "", 50); !isWire(err, appwire.CodeUnknownMethod) { + t.Fatalf("list on an old host = %v", err) + } + if _, err := backend.GetRender(agent.ID, "rnd_1"); !isWire(err, appwire.CodeUnknownMethod) { + t.Fatalf("get on an old host = %v", err) + } + if _, err := backend.CancelRender(agent.ID, "rnd_1"); !isWire(err, appwire.CodeUnknownMethod) { + t.Fatalf("cancel on an old host = %v", err) + } +} + +// The same news arrives on the turn's stream and the task lane; the app hears +// each change once, and a late receipt is the SAME id with the same state. +func TestRenderChangedIsSaidOncePerChangeAndAReceiptKeepsTheState(t *testing.T) { + conv := &renderConversation{fakeConversation: &fakeConversation{}, records: map[string]session.RenderRecord{}} + backend, rec, agent := renderBackend(t, conv) + if _, _, err := backend.ensure(mustFind(t, backend, agent.ID)); err != nil { + t.Fatal(err) + } + cancelled := session.RenderRecord{ID: "rnd_9", Kind: "video", State: session.RenderCancelled, Model: "m", Started: time.Unix(1_700_000_000, 0), Charge: session.RenderChargeUnknown, Upstream: "unknown"} + cost := 0.9 + receipt := cancelled + receipt.Charge, receipt.CostUSD = session.RenderCharged, &cost + + for _, record := range []session.RenderRecord{cancelled, cancelled, receipt, receipt} { + record := record + conv.fakeConversation.lane(session.Event{Kind: session.EventRenderUpdate, Render: &record}) + } + rec.waitFor(t, 2*time.Second, func(event string, data any) bool { + changed, ok := data.(appwire.RenderChanged) + return ok && changed.Render.Charge == "charged" + }) + rec.mu.Lock() + defer rec.mu.Unlock() + var heard []appwire.RenderChanged + for _, data := range rec.data { + if changed, ok := data.(appwire.RenderChanged); ok { + heard = append(heard, changed) + } + } + if len(heard) != 2 || heard[0].Render.State != "cancelled" || heard[0].Render.Charge != "unknown" || + heard[1].Render.State != "cancelled" || heard[1].Render.Charge != "charged" || heard[1].Render.ID != "rnd_9" || heard[1].Agent != agent.ID { + t.Fatalf("render.changed heard = %+v, want one per distinct change", heard) + } +} + +func isWire(err error, code string) bool { + var wire *appwire.Error + return errors.As(err, &wire) && wire.Code == code +} + +func mustFind(t *testing.T, backend *appBackend, id string) *appAgent { + t.Helper() + a, err := backend.find(id) + if err != nil { + t.Fatal(err) + } + return a +} diff --git a/docs/changes/unreleased/0-media-oversize-refused.md b/docs/changes/unreleased/0-media-oversize-refused.md index 72c446329f..58ed1b27aa 100644 --- a/docs/changes/unreleased/0-media-oversize-refused.md +++ b/docs/changes/unreleased/0-media-oversize-refused.md @@ -1,6 +1,6 @@ --- kind: fixed -title: a generated image, speech, music or video reply over its size limit is refused instead of saved cut short +title: an oversize media reply is refused instead of being saved cut short pr: 0 surface: [engine] invalidates: diff --git a/docs/changes/unreleased/0-media-render-ledger-exact.md b/docs/changes/unreleased/0-media-render-ledger-exact.md new file mode 100644 index 0000000000..d7cadd6221 --- /dev/null +++ b/docs/changes/unreleased/0-media-render-ledger-exact.md @@ -0,0 +1,8 @@ +--- +kind: fixed +title: a render list page is exact or refused, never stale +pr: 0 +surface: [engine] +invalidates: + - "Was true: render.list picked a page of renders and then read their later rows with the errors and the deadline thrown away, so a read that ran out of time served the first rows (running, uncharged) as though they were current; the forward read could also run from an old page to the end of the whole history; an oversize line was skipped; and a restore at open that could not finish still marked a partial set interrupted. Is true now: a page whose latest rows cannot all be read within the time and the 16 MiB resolve bound is answered busy (or with the read error) and no rows, an oversize line is an error, and a restore that cannot finish marks nothing, leaves those renders reading unknown, and retries on the next open." +--- diff --git a/docs/changes/unreleased/0-media-render-ledger.md b/docs/changes/unreleased/0-media-render-ledger.md new file mode 100644 index 0000000000..4f9e9666ab --- /dev/null +++ b/docs/changes/unreleased/0-media-render-ledger.md @@ -0,0 +1,8 @@ +--- +kind: added +title: video and music renders have a durable, complete history the app can list, look up and stop +pr: 0 +surface: [engine, remote] +invalidates: + - "Was true: a render existed only as a job row and prose notes, so an app could not list past renders, could not tell a failed one from a finished one without reading text, could not stop one except through the job number, and knew nothing of its charge. Is true now: every render has a stable id and a typed record (state running, finished, failed, cancelled or interrupted; model, start, duration, the run and call that asked for it, whether its bytes were committed, the saved path, and the provider's charge as charged, not_charged or unknown) kept in an append-only history that is never cut off, so an old render resolves by id after any number of later ones and after the conversation is reopened. render.list, render.get and render.cancel serve it, and render.changed says each start, ending and late charge once. A render stopped before it committed stays cancelled with no output even if the provider ignores the stop and later reports a charge; the charge is added to the same record rather than as a second ending. An older host answers unknown_method, never an empty list. Stopping tells the provider nothing, so a cancelled render says the provider's state is unknown." +--- diff --git a/internal/appwire/render.go b/internal/appwire/render.go new file mode 100644 index 0000000000..94ea7b42b4 --- /dev/null +++ b/internal/appwire/render.go @@ -0,0 +1,149 @@ +package appwire + +import ( + "reflect" + "strings" + "time" +) + +// RENDER.LIST, RENDER.GET AND RENDER.CANCEL are the app's door to the video and +// music renders a conversation has started. They are an OPTIONAL capability: an +// engine that does not implement [RenderController] — an older host, a remote +// one — answers unknown_method, which means "unsupported", and is never +// answered with an empty list. The contract, field by field, is +// the render contract (RENDER-CONTRACT.md in the media design notes). + +// The bounds of render.list. +const ( + RenderPageDefault = 50 + RenderPageMax = 100 +) + +// Render is one video or music render. Unknown stays unknown: an absent charge +// is "unknown", never a zero. +type Render struct { + Persistence string `json:"persistence,omitempty"` + ID string `json:"id"` + Job int `json:"job"` + Session string `json:"session,omitempty"` + Kind string `json:"kind"` + State string `json:"state"` + Model string `json:"model"` + Started time.Time `json:"started"` + Ended *time.Time `json:"ended,omitempty"` + DurationMS *int64 `json:"duration_ms,omitempty"` + Run string `json:"run,omitempty"` + Call string `json:"call,omitempty"` + Committed bool `json:"committed"` + Path string `json:"path,omitempty"` + Charge string `json:"charge"` + CostUSD *float64 `json:"cost_usd,omitempty"` + Upstream string `json:"upstream,omitempty"` + Error string `json:"error,omitempty"` +} + +// RenderPage is one window of renders, newest first. +type RenderPage struct { + Renders []Render `json:"renders"` + // Next is absent only when the whole history has been read. Incomplete says + // the page is short because the read budget ran out: keep following Next. + Next string `json:"next,omitempty"` + Incomplete bool `json:"incomplete,omitempty"` +} + +// EventRenderChanged carries the whole record of a render that started, +// finished, failed, was cancelled, or gained a charge receipt after it ended. +// It replaces the record with the same id; it is never a second completion. +const EventRenderChanged = "render.changed" + +// RenderChanged is render.changed's data. +type RenderChanged struct { + Agent string `json:"agent"` + Render Render `json:"render"` +} + +// RenderCancel is render.cancel's answer: cancelled, too_late or already_ended. +type RenderCancel struct { + Outcome string `json:"outcome"` + Render Render `json:"render"` +} + +// RenderResult is render.get's answer. +type RenderResult struct { + Render Render `json:"render"` +} + +// RenderController is the optional engine half of the render methods. +type RenderController interface { + ListRenders(agent, cursor string, limit int) (RenderPage, error) + GetRender(agent, id string) (Render, error) + // CancelRender never waits on the provider and never claims the provider + // was told to cancel. + CancelRender(agent, id string) (RenderCancel, error) +} + +func init() { + eventShapes[EventRenderChanged] = reflect.TypeFor[RenderChanged]() + methods["render.list"] = typed(func(e Engine, p struct { + Agent string `json:"agent"` + Cursor string `json:"cursor,omitempty"` + Limit int `json:"limit,omitempty"` + }) (*RenderPage, error) { + c, ok := e.(RenderController) + if !ok { + return nil, Errorf(CodeUnknownMethod, "this engine does not serve render.list") + } + if strings.TrimSpace(p.Agent) == "" || p.Limit < 0 { + return nil, Errorf(CodeBadParams, "render.list needs an agent and a limit of zero or more") + } + limit := p.Limit + if limit == 0 { + limit = RenderPageDefault + } + if limit > RenderPageMax { + limit = RenderPageMax + } + page, err := c.ListRenders(p.Agent, p.Cursor, limit) + if err != nil { + return nil, err + } + if page.Renders == nil { + page.Renders = []Render{} + } + return &page, nil + }) + methods["render.get"] = typed(func(e Engine, p struct { + Agent string `json:"agent"` + ID string `json:"id"` + }) (*RenderResult, error) { + c, ok := e.(RenderController) + if !ok { + return nil, Errorf(CodeUnknownMethod, "this engine does not serve render.get") + } + if strings.TrimSpace(p.Agent) == "" || strings.TrimSpace(p.ID) == "" { + return nil, Errorf(CodeBadParams, "render.get needs an agent and an id") + } + render, err := c.GetRender(p.Agent, p.ID) + if err != nil { + return nil, err + } + return &RenderResult{Render: render}, nil + }) + methods["render.cancel"] = typed(func(e Engine, p struct { + Agent string `json:"agent"` + ID string `json:"id"` + }) (*RenderCancel, error) { + c, ok := e.(RenderController) + if !ok { + return nil, Errorf(CodeUnknownMethod, "this engine does not serve render.cancel") + } + if strings.TrimSpace(p.Agent) == "" || strings.TrimSpace(p.ID) == "" { + return nil, Errorf(CodeBadParams, "render.cancel needs an agent and an id") + } + result, err := c.CancelRender(p.Agent, p.ID) + if err != nil { + return nil, err + } + return &result, nil + }) +} diff --git a/internal/appwire/render_test.go b/internal/appwire/render_test.go new file mode 100644 index 0000000000..44ee4c99e2 --- /dev/null +++ b/internal/appwire/render_test.go @@ -0,0 +1,58 @@ +package appwire + +import ( + "encoding/json" + "testing" +) + +type testRenderEngine struct { + fakeEngine + limit int + failed bool +} + +func (e *testRenderEngine) ListRenders(agent, cursor string, limit int) (RenderPage, error) { + e.limit = limit + if e.failed { + return RenderPage{}, Errorf(CodeBusy, "not ready") + } + return RenderPage{}, nil +} +func (e *testRenderEngine) GetRender(agent, id string) (Render, error) { + return Render{ID: id, State: "cancelled", Charge: "unknown", Persistence: "unavailable"}, nil +} +func (e *testRenderEngine) CancelRender(agent, id string) (RenderCancel, error) { + return RenderCancel{Outcome: "too_late", Render: Render{ID: id, State: "running", Charge: "unknown"}}, nil +} +func TestRenderRoutesKeepRefusalsAndPersistenceFacts(t *testing.T) { + e := &testRenderEngine{} + out, err := methods["render.list"].call(e, json.RawMessage(`{"agent":"a","limit":100000}`)) + if err != nil || e.limit != RenderPageMax { + t.Fatalf("list: %v %d", err, e.limit) + } + raw, _ := json.Marshal(out) + if string(raw) != `{"renders":[]}` { + t.Fatalf("empty page: %s", raw) + } + e.failed = true + if _, err := methods["render.list"].call(e, json.RawMessage(`{"agent":"a"}`)); codeOf(err) != CodeBusy { + t.Fatalf("busy became success: %v", err) + } + got, err := methods["render.get"].call(e, json.RawMessage(`{"agent":"a","id":"rnd_x"}`)) + if err != nil { + t.Fatal(err) + } + record := got.(*RenderResult).Render + if record.Persistence != "unavailable" || record.Charge != "unknown" || record.CostUSD != nil { + t.Fatalf("unknown facts changed: %#v", record) + } + cancelled, err := methods["render.cancel"].call(e, json.RawMessage(`{"agent":"a","id":"rnd_x"}`)) + if err != nil || cancelled.(*RenderCancel).Outcome != "too_late" { + t.Fatalf("cancel: %#v %v", cancelled, err) + } + for name, body := range map[string]string{"render.list": `{"agent":"a"}`, "render.get": `{"agent":"a","id":"r"}`, "render.cancel": `{"agent":"a","id":"r"}`} { + if _, err := methods[name].call(&fakeEngine{}, json.RawMessage(body)); codeOf(err) != CodeUnknownMethod { + t.Fatalf("%s unsupported: %v", name, err) + } + } +} diff --git a/internal/remote/callclass.go b/internal/remote/callclass.go index 9eef67497e..80b9bee380 100644 --- a/internal/remote/callclass.go +++ b/internal/remote/callclass.go @@ -163,14 +163,14 @@ func classify(method string) callClass { MethodTeamsName, MethodTeamsPropose, // The desktop app's task and process readings are drawn while a reply // streams, so they are never queued behind it. - MethodProcessList, + MethodProcessList, MethodRenderList, MethodRenderGet, // The automations readings are the watcher's beat (wire_automations.go): // a store read every two seconds that a turn in flight must never hold // up, or a run's line would arrive when the reply did. MethodAutomationsList, MethodAutomationsRuns, MethodAutomationsChanges, MethodAutomationsCursor, MethodAutomationsActive, MethodAutomationsWindows: return classGetter - case MethodAppTaskRoutingAct, MethodAppTaskAction, MethodAppQuickTaskAction, MethodAppMoveToTask, MethodAppResumeAfterQuit, MethodAppRename, MethodProcessRerun, MethodQuestionResolve, MethodQuestionHold, + case MethodAppTaskRoutingAct, MethodAppTaskAction, MethodAppQuickTaskAction, MethodAppMoveToTask, MethodAppResumeAfterQuit, MethodAppRename, MethodProcessRerun, MethodRenderCancel, MethodQuestionResolve, MethodQuestionHold, MethodConsent, MethodConsentRemember, MethodHarness, MethodConnect, MethodConnectKey, MethodNoteConnected, diff --git a/internal/remote/driver.go b/internal/remote/driver.go index 87c5d4bb10..2e6dc9fb1f 100644 --- a/internal/remote/driver.go +++ b/internal/remote/driver.go @@ -294,6 +294,7 @@ var watcherReads = map[string]bool{ MethodQuestionWatch: true, MethodPlanTaskPage: true, MethodAppTasks: true, MethodAppTask: true, MethodAppQuickTasks: true, + MethodRenderList: true, MethodRenderGet: true, MethodDetach: true, MethodPing: true, } diff --git a/internal/remote/server.go b/internal/remote/server.go index 53dbc23c16..dc2176d32c 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -2152,7 +2152,7 @@ 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, MethodSubmit, MethodFollowUp, MethodUnqueueFollowUp, MethodSteer, MethodQuestionReplace, MethodSubmitImage, MethodSubmitFiles, - MethodTaskSteer, MethodTaskStop, MethodTaskRetry, MethodProcessRerun, MethodAppTaskAction, MethodAppQuickTaskAction, MethodAppMoveToTask, MethodAppResumeAfterQuit, MethodAppRename, + MethodTaskSteer, MethodTaskStop, MethodTaskRetry, MethodProcessRerun, MethodRenderCancel, MethodAppTaskAction, MethodAppQuickTaskAction, MethodAppMoveToTask, MethodAppResumeAfterQuit, MethodAppRename, MethodAppTaskRoutingAct: if err := s.mayDrive(); err != nil { return nil, err @@ -2162,6 +2162,8 @@ func (s *server) invoke(call Frame) (out json.RawMessage, err error) { switch call.Method { case MethodProcessList, MethodProcessRerun: return serveAppProcesses(agent, call) + case MethodRenderList, MethodRenderGet, MethodRenderCancel: + return dispatchRender(agent, call) case MethodAppTasks, MethodAppTask, MethodAppTaskAction, MethodAppQuickTasks, MethodAppQuickTaskAction, MethodAppMoveToTask, MethodAppResumeAfterQuit, MethodAppRename, MethodAppTaskRouting, MethodAppTaskRoutingAct, MethodAppTaskRoutingOptions: return dispatchAppTask(agent, call) diff --git a/internal/remote/wire_render.go b/internal/remote/wire_render.go new file mode 100644 index 0000000000..a4b27fcfef --- /dev/null +++ b/internal/remote/wire_render.go @@ -0,0 +1,163 @@ +package remote + +import ( + "encoding/json" + "errors" + + "github.com/Agent-Field/codeaf/internal/session" +) + +// The render calls carry the engine's own render ledger across the connection +// (session's renderlog.go). A refusal travels as a refusal: a conversation that +// cannot answer is never turned into an empty list. +const ( + MethodRenderList = "Render.List" + MethodRenderGet = "Render.Get" + MethodRenderCancel = "Render.Cancel" +) + +// RenderListArgs is one page of the render list. +type RenderListArgs struct { + Limit int `json:"limit,omitempty"` + Cursor string `json:"cursor,omitempty"` +} + +// RenderIDArgs names one render by its stable id. +type RenderIDArgs struct { + ID string `json:"id"` +} + +// renderAnswer keeps the three refusals a caller must tell apart — no such +// render, a lookup that ran out of time, a cursor that is no longer valid — +// from every other failure, which travels as an ordinary error. Each is a +// typed answer because "not found" and "could not tell" are different facts. +type renderAnswer struct { + Page *session.RenderPage `json:"page,omitempty"` + Render *session.RenderRecord `json:"render,omitempty"` + Cancel *session.RenderCancel `json:"cancel,omitempty"` + Refusal string `json:"refusal,omitempty"` +} + +const ( + renderRefusalNotFound = "not_found" + renderRefusalBusy = "busy" + renderRefusalCursor = "cursor" +) + +func renderRefusal(err error) (string, bool) { + switch { + case errors.Is(err, session.ErrRenderNotFound): + return renderRefusalNotFound, true + case errors.Is(err, session.ErrRenderBusy): + return renderRefusalBusy, true + case errors.Is(err, session.ErrRenderCursor): + return renderRefusalCursor, true + } + return "", false +} + +func renderRefusalError(word string) error { + switch word { + case renderRefusalNotFound: + return session.ErrRenderNotFound + case renderRefusalBusy: + return session.ErrRenderBusy + case renderRefusalCursor: + return session.ErrRenderCursor + } + return errors.New("engine: " + word) +} + +// renderDoor is the conversation whose renders can be read and stopped. +type renderDoor interface { + Renders(limit int, cursor string) (session.RenderPage, error) + Render(id string) (session.RenderRecord, error) + CancelRender(id string) (session.RenderCancel, error) +} + +func dispatchRender(agent any, call Frame) (json.RawMessage, error) { + door, ok := agent.(renderDoor) + if !ok { + return nil, errors.New("engine: this conversation does not keep renders") + } + var answer renderAnswer + var err error + switch call.Method { + case MethodRenderList: + var args RenderListArgs + if args, err = arg[RenderListArgs](call); err != nil { + return nil, err + } + var page session.RenderPage + page, err = door.Renders(args.Limit, args.Cursor) + answer.Page = &page + case MethodRenderGet: + var args RenderIDArgs + if args, err = arg[RenderIDArgs](call); err != nil { + return nil, err + } + var render session.RenderRecord + render, err = door.Render(args.ID) + answer.Render = &render + case MethodRenderCancel: + var args RenderIDArgs + if args, err = arg[RenderIDArgs](call); err != nil { + return nil, err + } + var result session.RenderCancel + result, err = door.CancelRender(args.ID) + answer.Cancel = &result + default: + return nil, errors.New("unknown render method") + } + if err != nil { + if word, typed := renderRefusal(err); typed { + return json.Marshal(renderAnswer{Refusal: word}) + } + return nil, err + } + return json.Marshal(answer) +} + +func (a *Agent) renderCall(method string, args any) (renderAnswer, error) { + raw, err := a.c.call(nil, method, args) + if err != nil { + return renderAnswer{}, err + } + var answer renderAnswer + if err := json.Unmarshal(raw, &answer); err != nil { + return renderAnswer{}, err + } + if answer.Refusal != "" { + return renderAnswer{}, renderRefusalError(answer.Refusal) + } + return answer, nil +} + +// Renders lists the conversation's renders, newest first. +func (a *Agent) Renders(limit int, cursor string) (session.RenderPage, error) { + answer, err := a.renderCall(MethodRenderList, RenderListArgs{Limit: limit, Cursor: cursor}) + if err != nil || answer.Page == nil { + return session.RenderPage{}, err + } + return *answer.Page, nil +} + +// Render answers one render: [session.ErrRenderNotFound] only after the engine +// searched the whole history, [session.ErrRenderBusy] when it ran out of time. +func (a *Agent) Render(id string) (session.RenderRecord, error) { + answer, err := a.renderCall(MethodRenderGet, RenderIDArgs{ID: id}) + if err != nil || answer.Render == nil { + return session.RenderRecord{}, err + } + return *answer.Render, nil +} + +// CancelRender stops a running render and says how the stop ended. +func (a *Agent) CancelRender(id string) (session.RenderCancel, error) { + answer, err := a.renderCall(MethodRenderCancel, RenderIDArgs{ID: id}) + if err != nil || answer.Cancel == nil { + return session.RenderCancel{}, err + } + return *answer.Cancel, nil +} diff --git a/internal/remote/wire_render_test.go b/internal/remote/wire_render_test.go new file mode 100644 index 0000000000..619b3a65a3 --- /dev/null +++ b/internal/remote/wire_render_test.go @@ -0,0 +1,115 @@ +package remote + +import ( + "encoding/json" + "errors" + "reflect" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/session" +) + +type renderFake struct { + page session.RenderPage + records map[string]session.RenderRecord + err error +} + +func (f *renderFake) Renders(int, string) (session.RenderPage, error) { return f.page, f.err } +func (f *renderFake) Render(id string) (session.RenderRecord, error) { + if f.err != nil { + return session.RenderRecord{}, f.err + } + if r, ok := f.records[id]; ok { + return r, nil + } + return session.RenderRecord{}, session.ErrRenderNotFound +} +func (f *renderFake) CancelRender(id string) (session.RenderCancel, error) { + r, err := f.Render(id) + return session.RenderCancel{Outcome: session.RenderOutcomeCancelled, Render: r}, err +} + +// The three refusals a caller must tell apart survive the wire as themselves, +// and everything else stays an ordinary error. +func TestRenderWireKeepsTypedRefusalsAndRecords(t *testing.T) { + cost := 0.5 + record := session.RenderRecord{ID: "rnd_1", Kind: "video", State: session.RenderCancelled, Started: time.Unix(1_700_000_000, 0).UTC(), Charge: session.RenderCharged, CostUSD: &cost, Upstream: "unknown"} + fake := &renderFake{page: session.RenderPage{Renders: []session.RenderRecord{record}, Next: "r2.9", Incomplete: true}, records: map[string]session.RenderRecord{"rnd_1": record}} + + client, engine := newEngine(t) + call := func(method string, payload any) renderAnswer { + frame := Frame{Method: method} + frame.Payload, _ = json.Marshal(payload) + raw, err := dispatchRender(fake, frame) + if err != nil { + t.Fatalf("%s: %v", method, err) + } + var answer renderAnswer + if err := json.Unmarshal(raw, &answer); err != nil { + t.Fatal(err) + } + return answer + } + + engine.answers[MethodRenderList] = call(MethodRenderList, RenderListArgs{}) + engine.answers[MethodRenderGet] = call(MethodRenderGet, RenderIDArgs{ID: "rnd_1"}) + engine.answers[MethodRenderCancel] = call(MethodRenderCancel, RenderIDArgs{ID: "rnd_1"}) + page, err := client.Agent().Renders(10, "") + if err != nil || !reflect.DeepEqual(page, fake.page) { + t.Fatalf("page = %+v, %v", page, err) + } + if got, err := client.Agent().Render("rnd_1"); err != nil || !reflect.DeepEqual(got, record) { + t.Fatalf("get = %+v, %v", got, err) + } + if got, err := client.Agent().CancelRender("rnd_1"); err != nil || got.Outcome != session.RenderOutcomeCancelled || got.Render.ID != "rnd_1" { + t.Fatalf("cancel = %+v, %v", got, err) + } + + for refusal, want := range map[error]error{ + session.ErrRenderNotFound: session.ErrRenderNotFound, + session.ErrRenderBusy: session.ErrRenderBusy, + session.ErrRenderCursor: session.ErrRenderCursor, + } { + fake.err = refusal + engine.answers[MethodRenderGet] = call(MethodRenderGet, RenderIDArgs{ID: "x"}) + if _, err := client.Agent().Render("x"); !errors.Is(err, want) { + t.Fatalf("refusal %v came back as %v", refusal, err) + } + } + fake.err = errors.New("disk on fire") + if _, err := dispatchRender(fake, Frame{Method: MethodRenderList}); err == nil { + t.Fatal("an ordinary failure was swallowed into an answer") + } + if _, err := dispatchRender(struct{}{}, Frame{Method: MethodRenderList}); err == nil { + t.Fatal("a conversation without renders answered") + } +} + +// The render event carries its whole record across a JSON hop. +func TestRenderEventSurvivesTheWire(t *testing.T) { + cost := 0.1 + event := session.Event{Kind: session.EventRenderUpdate, Render: &session.RenderRecord{ID: "rnd_2", State: session.RenderFinished, Charge: session.RenderCharged, CostUSD: &cost, Committed: true, Path: "/a.mp4"}} + raw, err := json.Marshal(WireEvent(event)) + if err != nil { + t.Fatal(err) + } + var wired EventWire + if err := json.Unmarshal(raw, &wired); err != nil { + t.Fatal(err) + } + back := wired.Unwire() + if back.Kind != session.EventRenderUpdate || back.Render == nil || !reflect.DeepEqual(*back.Render, *event.Render) { + t.Fatalf("event = %+v", back) + } +} + +func TestRenderMethodsAreClassifiedAndWatchersMayOnlyRead(t *testing.T) { + if !watcherMay(MethodRenderList) || !watcherMay(MethodRenderGet) || watcherMay(MethodRenderCancel) { + t.Fatal("a watcher may read renders and may not cancel one") + } + if classify(MethodRenderList) != classGetter || classify(MethodRenderGet) != classGetter || classify(MethodRenderCancel) == classGetter { + t.Fatal("reads are getters; a cancel is an act") + } +} diff --git a/internal/session/jobs.go b/internal/session/jobs.go index 4ccaec1f85..04730fb2d4 100644 --- a/internal/session/jobs.go +++ b/internal/session/jobs.go @@ -63,6 +63,7 @@ import ( "syscall" "time" + "github.com/Agent-Field/agentfield/sdk/go/ai" "github.com/Agent-Field/codeaf/internal/config" "github.com/Agent-Field/codeaf/internal/exec/bare" "github.com/Agent-Field/codeaf/internal/processgroup" @@ -231,6 +232,10 @@ type job struct { // GOROUTINE exits — never at a stop, and never because the row changed state. permit *renderGate permitOnce sync.Once + // ledger and renderID tie a render job to its durable record + // ([renderLedger]); both are set once by [jobRegistry.beginRender]. + ledger *renderLedger + renderID string // personStopped says the requested death this job is about to have is A // PERSON'S STOP ([Agent.cancelJob]) rather than the model's `jobs kill` or a // shutdown. @@ -362,14 +367,40 @@ func (j *job) running() bool { // decision made once rather than a race between two goroutines. func (j *job) requestKill() bool { j.mu.Lock() - defer j.mu.Unlock() if j.state != jobRunning { + j.mu.Unlock() return false } j.killRequested = true + // A render that has not committed is cancelled by this claim, and the + // record says so now — not when a provider that ignores the cancel returns. + seal := j.kind == jobKindRender && !j.committed + j.mu.Unlock() + if seal { + j.sealRender(renderPatch{state: RenderCancelled}) + } return true } +// sealRender writes the render's one terminal record, if it has a record and no +// ending yet. It reports whether this call was the ending. +func (j *job) sealRender(patch renderPatch) bool { + j.mu.Lock() + ledger, id := j.ledger, j.renderID + j.mu.Unlock() + if ledger == nil || id == "" { + return false + } + return ledger.settle(id, patch, time.Now()) +} + +// isCommitted reports a render's commit flag. +func (j *job) isCommitted() bool { + j.mu.Lock() + defer j.mu.Unlock() + return j.committed +} + // commitRender is a render's gate between "the provider answered" and "a file // is written", and it is ATOMIC WITH THE STOP: it refuses when a stop was // already accepted and otherwise makes any later stop lose. So a stop either @@ -392,14 +423,17 @@ func (j *job) commitRender() bool { // note must still reach the model. func (j *job) requestKillRender() (claimed, committed bool) { j.mu.Lock() - defer j.mu.Unlock() if j.state != jobRunning { + j.mu.Unlock() return false, false } if j.committed { + j.mu.Unlock() return false, true } j.killRequested = true + j.mu.Unlock() + j.sealRender(renderPatch{state: RenderCancelled}) return true, false } @@ -519,6 +553,10 @@ type jobRegistry struct { // renderMu makes "count the running renders, then add one" one step, so two // renders starting together cannot both pass the cap. renderMu sync.Mutex + // renders is the durable render ledger, opened on first use + // ([jobRegistry.renderLedger]). + renders *renderLedger + rendersOnce sync.Once // gate is the process-wide render admission this registry draws on. It is // [processRenders] outside tests; a test hands two registries one private // gate to prove the total is shared without disturbing other tests. @@ -892,6 +930,45 @@ func (r *jobRegistry) runningProcesses() int { return running } +// renderLedger opens this session's render ledger once. A session with no +// folder (the legacy layout) keeps it in memory. +func (r *jobRegistry) renderLedger() *renderLedger { + r.rendersOnce.Do(func() { + r.mu.Lock() + place := r.place + r.mu.Unlock() + r.renders = openRenderLedger(place.join(placeRenders), place.ID(), time.Now()) + }) + return r.renders +} + +// closeRenders releases the ledger's file at shutdown. It opens nothing. +func (r *jobRegistry) closeRenders() { + if r.renders != nil { + r.renders.close() + } +} + +// beginRender records a started render and ties the job to its record. run and +// call are the tool site's own identities (runIdentity, callIDFrom), never +// parsed from anything. +func (r *jobRegistry) beginRender(one *job, kind, model, run, call string, notify func(RenderRecord)) error { + ledger := r.renderLedger() + ledger.mu.Lock() + if ledger.notify == nil { + ledger.notify = notify + } + ledger.mu.Unlock() + id := ledger.begin(one.id, kind, model, run, call, time.Now()) + if id == "" { + return errors.New("the render history could not be saved; free space or fix permissions before starting a render") + } + one.mu.Lock() + one.ledger, one.renderID = ledger, id + one.mu.Unlock() + return nil +} + // jobRenderCap is how many provider generations (video, music) one agent may // have running at once. It is a separate count from the process cap because a // render is a goroutine holding a response buffer, not a process. @@ -930,12 +1007,30 @@ var errProcessRenderCap = fmt.Errorf("%d renders are already running in this pro // stop, a panic — returns the place. func (j *job) releaseRender() { j.permitOnce.Do(func() { + j.mu.Lock() + ledger, id := j.ledger, j.renderID + j.mu.Unlock() + if ledger != nil && id != "" { + ledger.release(id) + } if j.permit != nil { j.permit.release() } }) } +// noteCharge hands the ledger what the provider reported charging, the moment +// the provider call returns — whatever became of the render meanwhile. A +// render already cancelled keeps its state and gains the receipt. +func (j *job) noteCharge(usage *ai.Usage) { + j.mu.Lock() + ledger, id := j.ledger, j.renderID + j.mu.Unlock() + if ledger != nil && id != "" { + ledger.charge(id, usage, time.Now()) + } +} + var errRenderCap = fmt.Errorf("%d renders are already running for this agent; wait for one to finish or stop one with jobs kill.", jobRenderCap) // runningRenders counts the renders still running. @@ -1437,6 +1532,7 @@ func (r *jobRegistry) shutdown(grace time.Duration) { // its grace are tidied too. A pass reads one folder at a time and is // bounded, so the wait carries no timer of its own. defer r.joinRetention() + defer r.closeRenders() var claimed []*job for _, candidate := range r.all() { diff --git a/internal/session/render_app.go b/internal/session/render_app.go new file mode 100644 index 0000000000..c33513d442 --- /dev/null +++ b/internal/session/render_app.go @@ -0,0 +1,85 @@ +package session + +import ( + "fmt" + "syscall" +) + +// RenderOutcome is how a cancel request ended. +type RenderOutcome string + +const ( + // RenderOutcomeCancelled: the Stop was accepted before the bytes were + // committed; nothing will be written. + RenderOutcomeCancelled RenderOutcome = "cancelled" + // RenderOutcomeTooLate: the render had already committed to saving; it was + // NOT stopped and will finish. + RenderOutcomeTooLate RenderOutcome = "too_late" + // RenderOutcomeEnded: the render had already ended; the record is unchanged. + RenderOutcomeEnded RenderOutcome = "already_ended" +) + +// RenderCancel is the answer to [Agent.CancelRender]. +type RenderCancel struct { + Outcome RenderOutcome `json:"outcome"` + Render RenderRecord `json:"render"` +} + +func (a *Agent) renderJobs() *jobRegistry { + a.mu.Lock() + defer a.mu.Unlock() + return a.jobs +} + +// Renders lists this session's video and music renders, newest first. +func (a *Agent) Renders(limit int, cursor string) (RenderPage, error) { + jobs := a.renderJobs() + if jobs == nil { + return RenderPage{Renders: []RenderRecord{}}, nil + } + return jobs.renderLedger().list(limit, cursor) +} + +// Render answers one render by its stable id: [ErrRenderNotFound] only when the +// whole history was searched, [ErrRenderBusy] when the search ran out of time. +func (a *Agent) Render(id string) (RenderRecord, error) { + jobs := a.renderJobs() + if jobs == nil { + return RenderRecord{}, ErrRenderNotFound + } + return jobs.renderLedger().get(id) +} + +// CancelRender asks a running render to stop, and NEVER waits for the provider: +// the answer is the ledger's own, written at the instant the Stop is accepted. +// It does not tell the provider to cancel — there is no such call — so a +// cancelled record carries upstream "unknown". +func (a *Agent) CancelRender(id string) (RenderCancel, error) { + jobs := a.renderJobs() + if jobs == nil { + return RenderCancel{}, ErrRenderNotFound + } + ledger := jobs.renderLedger() + record, err := ledger.get(id) + if err != nil { + return RenderCancel{}, err + } + if record.State != RenderRunning { + return RenderCancel{Outcome: RenderOutcomeEnded, Render: record}, nil + } + target := jobs.find(record.Job) + if target == nil { + return RenderCancel{}, fmt.Errorf("render %s is recorded as running but its job is not in this session", id) + } + claimed, committed := target.requestKillRender() + outcome := RenderOutcomeEnded + switch { + case claimed: + target.signal(syscall.SIGTERM) + outcome = RenderOutcomeCancelled + case committed: + outcome = RenderOutcomeTooLate + } + record, _ = ledger.get(id) + return RenderCancel{Outcome: outcome, Render: record}, nil +} diff --git a/internal/session/render_event.go b/internal/session/render_event.go new file mode 100644 index 0000000000..85473522d4 --- /dev/null +++ b/internal/session/render_event.go @@ -0,0 +1,21 @@ +package session + +// emitRenderUpdate fans one render's record out to whoever is listening, on the +// same two lanes as [Agent.emitJobUpdate]: the turn's hub when a turn is in +// flight and every standing watcher. It is called by the render ledger after +// each change; the ledger — not a surface — is the durable account, so an event +// nobody heard costs nothing: render.list shows the record. +func (a *Agent) emitRenderUpdate(record RenderRecord) { + event := Event{Kind: EventRenderUpdate, Render: &record} + a.mu.Lock() + hub := a.hub + watchers := make([]*eventStream, len(a.taskWatchers)) + copy(watchers, a.taskWatchers) + a.mu.Unlock() + if hub != nil { + hub.send(event) + } + for _, watcher := range watchers { + watcher.send(event) + } +} diff --git a/internal/session/render_ledger_test.go b/internal/session/render_ledger_test.go new file mode 100644 index 0000000000..2108707a9e --- /dev/null +++ b/internal/session/render_ledger_test.go @@ -0,0 +1,751 @@ +package session + +import ( + "context" + "encoding/json" + "errors" + "math" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" +) + +func ledgerRows(t *testing.T, path string) []renderRow { + t.Helper() + data, err := os.ReadFile(path) + if err != nil { + t.Fatalf("read ledger: %v", err) + } + var rows []renderRow + for _, line := range strings.Split(strings.TrimSpace(string(data)), "\n") { + if line == "" { + continue + } + rows = append(rows, renderRow{}) + if err := jsonUnmarshal(line, &rows[len(rows)-1]); err != nil { + t.Fatalf("bad row %q: %v", line, err) + } + } + return rows +} + +func TestRenderChargeIsKnownOnlyWhenTheProviderSaidSo(t *testing.T) { + cost, zero, negative, nan := 0.25, 0.0, -1.0, math.NaN() + for name, test := range map[string]struct { + usage *ai.Usage + want RenderCharge + }{ + "no usage": {nil, RenderChargeUnknown}, + "no cost": {&ai.Usage{}, RenderChargeUnknown}, + "positive": {&ai.Usage{Cost: &cost}, RenderCharged}, + "zero": {&ai.Usage{Cost: &zero}, RenderNotCharged}, + "negative": {&ai.Usage{Cost: &negative}, RenderChargeUnknown}, + "not a num": {&ai.Usage{Cost: &nan}, RenderChargeUnknown}, + } { + got, amount := renderCharge(test.usage) + if got != test.want || (got == RenderCharged) != (amount != nil) { + t.Errorf("%s: charge = %q, %v; want %q", name, got, amount, test.want) + } + } +} + +// openForTest opens a ledger on a fresh file with a fixed clock. +func openForTest(t *testing.T) (*renderLedger, string) { + t.Helper() + path := filepath.Join(t.TempDir(), "renders.jsonl") + return openRenderLedger(path, "sess", time.Unix(1_700_000_000, 0)), path +} + +var epoch = time.Unix(1_700_000_000, 0) + +// One ending per render, whoever gets there first; the record survives a reopen +// exactly as it ended, and reopening writes nothing. +func TestRenderLedgerSealsOnceAndSurvivesReopen(t *testing.T) { + ledger, path := openForTest(t) + id := ledger.begin(7, "video", "film/model", "run1", "call1", epoch) + cost := 0.5 + if !ledger.settle(id, renderPatch{state: RenderCancelled}, epoch.Add(time.Second)) { + t.Fatal("the first ending was refused") + } + if ledger.settle(id, renderPatch{state: RenderFinished, committed: true, path: "/x.mp4", usage: &ai.Usage{Cost: &cost}}, epoch.Add(2*time.Second)) { + t.Fatal("a late finish replaced the cancel") + } + ledger.release(id) + ledger.close() + if rows := ledgerRows(t, path); len(rows) != 2 { + t.Fatalf("%d rows, want start + one end", len(rows)) + } + reopened := openRenderLedger(path, "sess", epoch.Add(time.Hour)) + got, err := reopened.get(id) + if err != nil || got.State != RenderCancelled || got.Upstream != "unknown" || got.Committed || got.Path != "" || + got.Charge != RenderChargeUnknown || got.DurationMS == nil || *got.DurationMS != 1000 || + got.Run != "run1" || got.Call != "call1" || got.Job != 7 || got.Model != "film/model" { + t.Fatalf("reopened record = %+v, %v", got, err) + } + reopened.close() + if rows := ledgerRows(t, path); len(rows) != 2 { + t.Fatalf("reopening wrote %d rows, want still 2", len(rows)) + } +} + +// A provider that ignores the cancel and later reports a charge: the record +// stays cancelled with no output and gains the receipt — same id, same state — +// once, and a second report changes nothing. +func TestRenderChargeAfterCancelIsAReceiptOnTheSameRecord(t *testing.T) { + ledger, path := openForTest(t) + var heard []RenderRecord + ledger.notify = func(r RenderRecord) { heard = append(heard, r) } + id := ledger.begin(1, "video", "m", "", "", epoch) + ledger.settle(id, renderPatch{state: RenderCancelled}, epoch.Add(time.Second)) + cost, other := 0.75, 9.0 + ledger.charge(id, &ai.Usage{Cost: &cost}, epoch.Add(time.Minute)) + ledger.charge(id, &ai.Usage{Cost: &other}, epoch.Add(time.Hour)) + ledger.release(id) + got, _ := ledger.get(id) + if got.State != RenderCancelled || got.Committed || got.Path != "" || got.Charge != RenderCharged || got.CostUSD == nil || *got.CostUSD != cost { + t.Fatalf("record = %+v", got) + } + if len(heard) != 3 || heard[2].ID != id || heard[2].State != RenderCancelled || heard[2].Charge != RenderCharged { + t.Fatalf("events = %+v, want start, cancel, one receipt on the same id", heard) + } + ledger.close() + if rows := ledgerRows(t, path); len(rows) != 3 || rows[2].Ev != renderEvCharge { + t.Fatalf("rows = %d, want start, end, one charge", len(rows)) + } +} + +// A charge reported BEFORE the ending rides into it; a provider that reports +// nothing leaves the charge unknown, never zero. +func TestRenderChargeBeforeEndingRidesIntoIt(t *testing.T) { + ledger, _ := openForTest(t) + failed := ledger.begin(1, "video", "m", "", "", epoch) + cost := 0.2 + ledger.charge(failed, &ai.Usage{Cost: &cost}, epoch) + ledger.settle(failed, renderPatch{state: RenderFailed, committed: true, err: "could not save"}, epoch.Add(time.Second)) + got, _ := ledger.get(failed) + if got.Charge != RenderCharged || !got.Committed || got.State != RenderFailed { + t.Fatalf("failed save after a charge = %+v", got) + } + silent := ledger.begin(2, "video", "m", "", "", epoch) + ledger.settle(silent, renderPatch{state: RenderFailed, err: "provider said no"}, epoch) + got, _ = ledger.get(silent) + if got.Charge != RenderChargeUnknown || got.CostUSD != nil { + t.Fatalf("a failure with no usage = %+v, want unknown", got) + } +} + +// A render the last process left running becomes interrupted ONCE. +func TestRenderLedgerMarksALeftRunningRenderInterruptedOnce(t *testing.T) { + first, path := openForTest(t) + id := first.begin(1, "music", "m", "", "", epoch) + first.close() + second := openRenderLedger(path, "sess", epoch.Add(time.Minute)) + got, _ := second.get(id) + if got.State != RenderInterrupted || got.Charge != RenderChargeUnknown || got.Committed { + t.Fatalf("record = %+v, want interrupted with unknown charge", got) + } + second.close() + openRenderLedger(path, "sess", epoch.Add(2*time.Minute)).close() + if rows := ledgerRows(t, path); len(rows) != 2 { + t.Fatalf("%d rows after two reopens, want 2", len(rows)) + } +} + +// Read without owning the session, an unfinished render is unknown, never running. +func TestReadRendersShowsAnUnfinishedRenderAsUnknown(t *testing.T) { + dir := t.TempDir() + ledger := openRenderLedger(filepath.Join(dir, placeRenders), "s", epoch) + id := ledger.begin(1, "video", "m", "", "", epoch) + ledger.close() + page, err := ReadRenders(dir, 0, "") + if err != nil || len(page.Renders) != 1 || page.Renders[0].State != RenderUnknown { + t.Fatalf("page = %+v, %v", page, err) + } + if rec, err := ReadRender(dir, id); err != nil || rec.State != RenderUnknown { + t.Fatalf("get = %+v, %v", rec, err) + } + if _, err := ReadRender(dir, "rnd_missing"); err != ErrRenderNotFound { + t.Fatalf("missing id err = %v", err) + } +} + +// Cancel racing finish from many goroutines: exactly one ending, one end row. +func TestRenderLedgerCancelAndFinishRaceHasOneWinner(t *testing.T) { + ledger, path := openForTest(t) + for round := 0; round < 50; round++ { + id := ledger.begin(round, "video", "m", "", "", epoch) + var mu sync.Mutex + wins := 0 + var wg sync.WaitGroup + start := make(chan struct{}) + for _, state := range []RenderState{RenderCancelled, RenderFinished, RenderFailed, RenderCancelled} { + wg.Add(1) + go func() { + defer wg.Done() + <-start + if ledger.settle(id, renderPatch{state: state}, epoch) { + mu.Lock() + wins++ + mu.Unlock() + } + }() + } + close(start) + wg.Wait() + if wins != 1 { + t.Fatalf("round %d: %d endings won", round, wins) + } + } + ledger.close() + if rows := ledgerRows(t, path); len(rows) != 100 { + t.Fatalf("%d rows for 50 renders, want 100", len(rows)) + } +} + +// THE HISTORY IS NOT TRUNCATED. Far more renders than any hot cache, written +// with endings, then read back through a reopened ledger: every one resolves by +// id, and paging by cursor visits each exactly once, newest first. +func TestRenderHistoryIsCompleteAcrossReopenAndPaging(t *testing.T) { + ledger, path := openForTest(t) + const total = 450 + ids := make([]string, total) + for i := range ids { + ids[i] = ledger.begin(i, "video", "m", "", "", epoch.Add(time.Duration(i)*time.Second)) + state := RenderFailed + if i%2 == 0 { + state = RenderCancelled + } + ledger.settle(ids[i], renderPatch{state: state, err: "boom"}, epoch.Add(time.Duration(i)*time.Second+time.Millisecond)) + ledger.release(ids[i]) + } + ledger.close() + + reopened := openRenderLedger(path, "sess", epoch.Add(24*time.Hour)) + defer reopened.close() + for _, probe := range []int{0, 1, 199, 200, 449} { // the oldest are far outside any 200-record window + got, err := reopened.get(ids[probe]) + want := RenderFailed + if probe%2 == 0 { + want = RenderCancelled + } + if err != nil || got.State != want || got.Job != probe { + t.Fatalf("record %d after reopen = %+v, %v", probe, got, err) + } + } + seen := map[string]bool{} + cursor, pages := "", 0 + for { + page, err := reopened.list(100, cursor) + if err != nil { + t.Fatal(err) + } + pages++ + for _, rec := range page.Renders { + if seen[rec.ID] || rec.State == RenderRunning || rec.State == RenderUnknown { + t.Fatalf("duplicate or unresolved record %+v", rec) + } + seen[rec.ID] = true + } + if page.Next == "" { + break + } + cursor = page.Next + } + if len(seen) != total || pages != 5 { + t.Fatalf("visited %d records in %d pages, want %d in 5", len(seen), pages, total) + } + if _, err := reopened.get("rnd_never_existed"); err != ErrRenderNotFound { + t.Fatalf("an id that never existed = %v, want not found after a whole-history search", err) + } +} + +// A scan that runs out of budget says so and continues from where it stopped; +// one that runs out of time is busy, never not-found. +func TestRenderScanBudgetAndDeadlineAreHonest(t *testing.T) { + ledger, path := openForTest(t) + var first string + for i := 0; i < 30; i++ { + id := ledger.begin(i, "video", "m", "", "", epoch.Add(time.Duration(i)*time.Second)) + if i == 0 { + first = id + } + ledger.settle(id, renderPatch{state: RenderFailed}, epoch.Add(time.Hour)) + ledger.release(id) + } + ledger.close() + + store := newRenderStore(path) + store.budget = 1 // one chunk per call + seen, cursor, calls := map[string]bool{}, "", 0 + for { + page, err := store.list(5, cursor) + if err != nil { + t.Fatal(err) + } + for _, rec := range page.Renders { + seen[rec.ID] = true + } + calls++ + if page.Next == "" { + break + } + cursor = page.Next + if calls > 100 { + t.Fatal("paging never finished") + } + } + if len(seen) != 30 { + t.Fatalf("budget-limited paging saw %d of 30", len(seen)) + } + store.deadline = -time.Second + if _, err := store.get(first); err != ErrRenderBusy { + t.Fatalf("get past the deadline = %v, want busy", err) + } + if _, err := store.list(5, "garbage"); err != ErrRenderCursor { + t.Fatalf("garbage cursor = %v", err) + } + if _, err := store.list(5, "r2.999999999"); err != ErrRenderCursor { + t.Fatalf("cursor past the end = %v", err) + } +} + +// ── through the real tools ────────────────────────────────────────────────── + +func renderIDOf(t *testing.T, agent *Agent) string { + t.Helper() + page, err := agent.Renders(10, "") + if err != nil || len(page.Renders) == 0 { + t.Fatalf("Renders = %+v, %v", page, err) + } + return page.Renders[0].ID +} + +func TestGeneratedVideoLeavesAStartedThenFinishedRecord(t *testing.T) { + media := newGatedMedia([]byte("MP4 bytes"), true) + cost := 0.4 + media.cost = &ai.Usage{Cost: &cost} + agent, workspace := newGatedAgent(t, media) + runTool(t, agent, "generate_video", `{"prompt":"a ferry"}`) + <-media.arrived + + id := renderIDOf(t, agent) + running, _ := agent.Render(id) + if running.State != RenderRunning || running.Kind != "video" || running.Model != "film/model" || running.Job != 1 || running.Ended != nil { + t.Fatalf("running record = %+v", running) + } + close(media.release) + <-renderJob(t, agent, 1).done + + done, _ := agent.Render(id) + files := videoFiles(workspace) + if done.State != RenderFinished || !done.Committed || done.Charge != RenderCharged || done.CostUSD == nil || *done.CostUSD != cost || + len(files) != 1 || filepath.Base(done.Path) != files[0] || done.DurationMS == nil { + t.Fatalf("finished record = %+v, files %v", done, files) + } +} + +func TestRenderCancelNeverWaitsAndALateProviderReturnIsSilent(t *testing.T) { + media := newGatedMedia([]byte("MP4"), false) // ignores the cancel + agent, workspace := newGatedAgent(t, media) + runTool(t, agent, "generate_video", `{"prompt":"a ferry"}`) + <-media.arrived + id := renderIDOf(t, agent) + + result, err := agent.CancelRender(id) // returns although the provider is still blocked + if err != nil || result.Outcome != RenderOutcomeCancelled || result.Render.State != RenderCancelled || + result.Render.Upstream != "unknown" || result.Render.Committed || result.Render.Charge != RenderChargeUnknown { + t.Fatalf("cancel = %+v, %v", result, err) + } + again, _ := agent.CancelRender(id) + if again.Outcome != RenderOutcomeEnded { + t.Fatalf("second cancel = %+v", again) + } + + close(media.release) // the provider finally answers + <-renderJob(t, agent, 1).done + got, _ := agent.Render(id) + if got.State != RenderCancelled || len(videoFiles(workspace)) != 0 || got.Path != "" { + t.Fatalf("after a late return: %+v, files %v", got, videoFiles(workspace)) + } +} + +func TestRenderCancelAfterCommitIsTooLateAndTheRenderFinishes(t *testing.T) { + agent, workspace := newTestAgent(t, &scriptedCompleter{}, nil) + rendering, _, err := agent.jobs.startRender(videoLabel, "a ferry") + if err != nil { + t.Fatal(err) + } + agent.jobs.beginRender(rendering, "video", "film/model", "", "", nil) + id := rendering.renderID + writing, release := make(chan struct{}), make(chan struct{}) + done := make(chan string, 1) + go func() { + path, _, _ := agent.landRender(rendering, "", "a ferry", ".mp4", VideoDir(agent.config.Place, workspace), []byte("whole"), + func(path string, data []byte) error { + close(writing) + <-release + return writeFileAtomic(path, data) + }) + done <- path + }() + <-writing + + result, err := agent.CancelRender(id) + if err != nil || result.Outcome != RenderOutcomeTooLate || result.Render.State != RenderRunning { + t.Fatalf("cancel after commit = %+v, %v", result, err) + } + close(release) + path := <-done + if !rendering.sealRender(renderPatch{state: RenderFinished, committed: true, path: path}) { + t.Fatal("the finish was refused after a too-late cancel") + } + agent.jobs.finish(rendering, 0, "") + got, _ := agent.Render(id) + if got.State != RenderFinished || !got.Committed { + t.Fatalf("record = %+v", got) + } +} + +func TestFailedRenderRecordsTheProvidersMessageAndNoCharge(t *testing.T) { + media := &scriptedMedia{videoErr: context.DeadlineExceeded} + agent, _ := newMediaAgent(t, media, nil) + runTool(t, agent, "generate_video", `{"prompt":"a ferry"}`) + <-renderJob(t, agent, 1).done + got, _ := agent.Render(renderIDOf(t, agent)) + if got.State != RenderFailed || got.Committed || got.Charge != RenderChargeUnknown || got.Error == "" || got.Path != "" { + t.Fatalf("record = %+v", got) + } +} + +func TestCancelRenderOfAnUnknownIDIsNotFound(t *testing.T) { + agent, _ := newTestAgent(t, &scriptedCompleter{}, nil) + if _, err := agent.CancelRender("rnd_nope"); err != ErrRenderNotFound { + t.Fatalf("err = %v", err) + } +} + +func jsonUnmarshal(line string, into any) error { return json.Unmarshal([]byte(line), into) } + +// renderEventsUntil blocks on the lane until want render events have arrived; +// the 10 second guard only turns a lost event into a failure instead of a hang. +func renderEventsUntil(t *testing.T, events <-chan Event, want int) []RenderRecord { + t.Helper() + var heard []RenderRecord + guard := time.After(10 * time.Second) + for len(heard) < want { + select { + case event := <-events: + if event.Kind == EventRenderUpdate && event.Render != nil { + heard = append(heard, *event.Render) + } + case <-guard: + t.Fatalf("heard %d render events, want %d: %+v", len(heard), want, heard) + } + } + return heard +} + +// The provider ignores the Stop and answers later WITH a charge: the record +// stays cancelled with no output and no second ending, gains the receipt, and +// the events say start, cancel, receipt — all on one id. +func TestLateChargeAfterCancelUpdatesTheSameRecordAndFiresOneReceiptEvent(t *testing.T) { + media := newGatedMedia([]byte("MP4"), false) + cost := 0.6 + media.cost = &ai.Usage{Cost: &cost} + agent, workspace := newGatedAgent(t, media) + events, stop := agent.WatchTaskUpdates() + defer stop() + + runTool(t, agent, "generate_video", `{"prompt":"a ferry"}`) + <-media.arrived + id := renderIDOf(t, agent) + if _, err := agent.CancelRender(id); err != nil { + t.Fatal(err) + } + close(media.release) + <-renderJob(t, agent, 1).done + + got, _ := agent.Render(id) + if got.State != RenderCancelled || got.Committed || got.Path != "" || got.Charge != RenderCharged || got.CostUSD == nil || *got.CostUSD != cost || len(videoFiles(workspace)) != 0 { + t.Fatalf("record = %+v", got) + } + heard := renderEventsUntil(t, events, 3) + if len(heard) != 3 || heard[0].State != RenderRunning || heard[1].State != RenderCancelled || heard[1].Charge != RenderChargeUnknown || + heard[2].State != RenderCancelled || heard[2].Charge != RenderCharged { + t.Fatalf("events = %+v", heard) + } + for _, event := range heard { + if event.ID != id { + t.Fatalf("event for another id: %+v", event) + } + } + if notesContain(agent, "job 1") { + t.Fatalf("a cancelled render announced itself: %v", sessionNotes(agent)) + } +} + +// Started and finished each fire exactly once, with the run and call identity +// the tool site had. +func TestRenderEventsCarryStartedAndFinishedOnce(t *testing.T) { + media := newGatedMedia([]byte("MP4"), true) + agent, _ := newGatedAgent(t, media) + events, stop := agent.WatchTaskUpdates() + defer stop() + runTool(t, agent, "generate_video", `{"prompt":"a ferry"}`) + <-media.arrived + close(media.release) + <-renderJob(t, agent, 1).done + heard := renderEventsUntil(t, events, 2) + if len(heard) != 2 || heard[0].State != RenderRunning || heard[1].State != RenderFinished || heard[1].Path == "" { + t.Fatalf("events = %+v", heard) + } +} + +// The run and call identity are the ones the tool site was handed, captured +// before the render's own background context cut it loose. +func TestRenderCapturesTheCallersRunAndCallIdentity(t *testing.T) { + media := newGatedMedia([]byte("MP4"), true) + agent, _ := newGatedAgent(t, media) + ctx := withCallID(withRunIdentity(context.Background()), "call-77") + run := runIdentity(ctx) + tool := beltTool(t, agent, "generate_video") + if _, isError, err := tool.Execute(ctx, json.RawMessage(`{"prompt":"a ferry"}`)); err != nil || isError { + t.Fatalf("generate_video: %v %v", err, isError) + } + <-media.arrived + got, _ := agent.Render(renderIDOf(t, agent)) + if got.Call != "call-77" || got.Run != run || run == "" { + t.Fatalf("record = %+v, want run %q call call-77", got, run) + } + close(media.release) + <-renderJob(t, agent, 1).done +} + +// ── review corrections: exact truth or an honest refusal ──────────────────── + +// writeHistory writes n renders, each started, ended failed and (every third) +// charge-receipted, in order, and returns the file path and the ids. +func writeHistory(t *testing.T, n int) (string, []string) { + t.Helper() + ledger, path := openForTest(t) + ids := make([]string, n) + for i := range ids { + ids[i] = ledger.begin(i, "video", "m", "", "", epoch.Add(time.Duration(i)*time.Second)) + ledger.settle(ids[i], renderPatch{state: RenderCancelled}, epoch.Add(time.Duration(i)*time.Second+time.Millisecond)) + if i%3 == 0 { + cost := 0.1 + ledger.charge(ids[i], &ai.Usage{Cost: &cost}, epoch.Add(time.Hour)) + } + ledger.release(ids[i]) + } + ledger.close() + return path, ids +} + +// A deadline that expires AFTER the page's entries were chosen but BEFORE their +// latest rows were read must refuse the page. It must never answer with the +// start rows (running, uncharged) as though they were the latest truth. +func TestRenderPageThatCannotBeResolvedIsBusyNeverStale(t *testing.T) { + path, _ := writeHistory(t, 30) + store := newRenderStore(path) + base := time.Unix(2_000_000_000, 0) + clock := base + store.now = func() time.Time { return clock } + store.beforeResolve = func() { clock = base.Add(time.Hour) } // time runs out between the phases + + page, err := store.list(10, "") + if err != ErrRenderBusy || len(page.Renders) != 0 { + t.Fatalf("an unresolvable page = %+v, %v; want busy and no rows", page, err) + } + // The same call with time to spare resolves every entry to its ending. + store.beforeResolve = nil + clock = base + page, err = store.list(10, "") + if err != nil || len(page.Renders) != 10 { + t.Fatalf("resolved page = %+v, %v", page, err) + } + for _, rec := range page.Renders { + if rec.State != RenderCancelled { + t.Fatalf("stale row served as truth: %+v", rec) + } + if rec.Job%3 == 0 && rec.Charge != RenderCharged { + t.Fatalf("a receipted render lost its charge: %+v", rec) + } + } +} + +// A page whose entries are further from the end of the history than the resolve +// budget allows is refused outright; a page near the end, and the cursor chain +// from it, are not — and retrying the refused cursor with room succeeds with +// nothing skipped. +func TestRenderResolveBudgetRefusesFarPagesAndTheCursorStaysComplete(t *testing.T) { + path, ids := writeHistory(t, 450) + info, _ := os.Stat(path) + store := newRenderStore(path) + + whole := map[string]bool{} + cursor := "" + var cursors []string + for { + page, err := store.list(100, cursor) + if err != nil { + t.Fatalf("default bounds, cursor %q: %v", cursor, err) + } + for _, rec := range page.Renders { + if whole[rec.ID] || rec.State != RenderCancelled { + t.Fatalf("duplicate or stale %+v", rec) + } + whole[rec.ID] = true + } + if page.Next == "" { + break + } + cursors = append(cursors, page.Next) + cursor = page.Next + } + if len(whole) != len(ids) { + t.Fatalf("paged %d of %d", len(whole), len(ids)) + } + + tight := store + tight.resolveBudget = info.Size() / 4 + if _, err := tight.list(100, cursors[len(cursors)-1]); err != ErrRenderBusy { + t.Fatalf("the oldest page under a tight resolve budget = %v, want busy", err) + } + if page, err := tight.list(100, ""); err != nil || len(page.Renders) != 100 { + t.Fatalf("the newest page under the same budget = %d rows, %v", len(page.Renders), err) + } + // The refused cursor is still good: with room it resolves and the chain ends. + if page, err := store.list(100, cursors[len(cursors)-1]); err != nil || len(page.Renders) == 0 { + t.Fatalf("retrying the refused cursor = %+v, %v", page, err) + } +} + +// A read error or an oversize line is reported; neither is skipped. +func TestRenderScansReportOversizeLinesInsteadOfSkippingThem(t *testing.T) { + path, _ := writeHistory(t, 5) + file, err := os.OpenFile(path, os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + t.Fatal(err) + } + if _, err := file.WriteString(strings.Repeat("x", renderMaxLine+10) + "\n"); err != nil { + t.Fatal(err) + } + file.Close() + + store := newRenderStore(path) + if _, err := store.list(10, ""); err != errRenderLine { + t.Fatalf("list over an oversize line = %v, want the line error", err) + } + if _, err := store.get("rnd_anything"); err != errRenderLine { + t.Fatalf("get over an oversize line = %v", err) + } + if _, err := store.unresolved(); err != errRenderLine { + t.Fatalf("restore over an oversize line = %v", err) + } +} + +// A restore that cannot finish marks nothing and says so; the left-running +// render reads as unknown, never running, and the next open completes the +// restore exactly once. +func TestRenderRestoreThatCannotFinishMarksNothingAndRetriesOnTheNextOpen(t *testing.T) { + first, path := openForTest(t) + id := first.begin(1, "video", "m", "", "", epoch) + first.close() + + store := newRenderStore(path) + base := time.Unix(2_000_000_000, 0) + clock := base + store.now = func() time.Time { c := clock; clock = clock.Add(time.Hour); return c } // every look at the clock is past the deadline + degraded := openRenderLedgerOn(store, "sess", epoch.Add(time.Minute)) + if degraded.restoreErr == nil { + t.Fatal("a restore that ran out of time reported success") + } + degraded.close() + got, err := ReadRender(filepath.Dir(path), id) + if err != nil || got.State != RenderUnknown { + t.Fatalf("a render the restore could not settle = %+v, %v; want unknown", got, err) + } + if rows := ledgerRows(t, path); len(rows) != 1 { + t.Fatalf("a failed restore wrote %d rows, want none added", len(rows)-1) + } + + healthy := openRenderLedger(path, "sess", epoch.Add(2*time.Minute)) + defer healthy.close() + if healthy.restoreErr != nil { + t.Fatal(healthy.restoreErr) + } + if got, _ := healthy.get(id); got.State != RenderInterrupted { + t.Fatalf("after a clean open = %+v, want interrupted", got) + } + openRenderLedger(path, "sess", epoch.Add(3*time.Minute)).close() + if rows := ledgerRows(t, path); len(rows) != 2 { + t.Fatalf("%d rows, want start + one interrupted", len(rows)) + } +} + +func TestRenderCannotStartWithoutItsJournal(t *testing.T) { + path := filepath.Join(t.TempDir(), "missing", "renders.jsonl") + ledger := openRenderLedger(path, "s", time.Now()) + defer ledger.close() + if id := ledger.begin(1, "video", "chosen", "run", "call", time.Now()); id != "" { + t.Fatalf("unrecorded start admitted: %s", id) + } + if len(ledger.live) != 0 { + t.Fatal("refused start retained a live render") + } +} + +func TestFailedRenderReceiptStaysOwnedUntilItCanBeSaved(t *testing.T) { + parent := t.TempDir() + dir := filepath.Join(parent, "session") + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatal(err) + } + ledger := openRenderLedger(filepath.Join(dir, "renders.jsonl"), "s", time.Now()) + defer ledger.close() + id := ledger.begin(1, "video", "chosen", "run", "call", time.Now()) + if id == "" { + t.Fatal("initial start refused") + } + _ = ledger.file.Close() + ledger.file = nil + if err := os.Rename(dir, dir+"-offline"); err != nil { + t.Fatal(err) + } + defer os.Rename(dir+"-offline", dir) + cost := 0.17 + ledger.settle(id, renderPatch{state: RenderFailed, usage: &ai.Usage{Cost: &cost}, err: "provider failed after charge"}, time.Now()) + ledger.release(id) + rec, err := ledger.get(id) + if err != nil || rec.State != RenderFailed || rec.Persistence != "unavailable" || rec.CostUSD == nil || *rec.CostUSD != cost { + t.Fatalf("receipt lost: %#v, %v", rec, err) + } + if _, err := ledger.list(50, ""); !errors.Is(err, ErrRenderBusy) { + t.Fatalf("unflushed history pretended complete: %v", err) + } + if next := ledger.begin(2, "video", "chosen", "run2", "call2", time.Now()); next != "" { + t.Fatal("new paid start admitted with unflushed receipt") + } + if len(ledger.live) != 1 { + t.Fatalf("failed starts grew retained records: %d", len(ledger.live)) + } + if err := os.Rename(dir+"-offline", dir); err != nil { + t.Fatal(err) + } + if next := ledger.begin(3, "video", "chosen", "run3", "call3", time.Now()); next == "" { + t.Fatal("storage recovery did not admit a new start") + } + rec, err = ledger.get(id) + if err != nil || rec.Persistence != "" || rec.CostUSD == nil || *rec.CostUSD != cost || rec.State != RenderFailed { + t.Fatalf("saved receipt wrong: %#v, %v", rec, err) + } + if _, held := ledger.live[id]; held { + t.Fatal("saved released receipt stayed pinned") + } +} diff --git a/internal/session/renderlog.go b/internal/session/renderlog.go new file mode 100644 index 0000000000..b4305e38c4 --- /dev/null +++ b/internal/session/renderlog.go @@ -0,0 +1,831 @@ +package session + +import ( + "bufio" + "bytes" + "crypto/rand" + "encoding/hex" + "encoding/json" + "errors" + "fmt" + "io" + "math" + "os" + "strconv" + "strings" + "sync" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" +) + +// THE RENDER LEDGER is the durable, typed account of every video and music +// render: when it started, how it ended, whether a file was ever committed, and +// what the provider reported it cost. It exists so no surface has to read prose +// notes to know any of that. The contract is +// the render contract (RENDER-CONTRACT.md in the media design notes). +// +// APPEND-ONLY, FULL HISTORY, BOUNDED MEMORY. Every change appends one full +// snapshot row (`start`, then ONE `end`, then at most one `charge` receipt), and +// the last row for an id is the record. The file is NEVER read whole into +// memory: a page is read backward in bounded chunks from a byte-offset cursor, +// and one id is found by a bounded reverse scan under a deadline. There is no +// fixed number of records beyond which history stops resolving. +// +// EXACTLY ONE TERMINAL. The first end wins under the ledger's lock, so a Stop +// that is accepted seals `cancelled` at once and a provider that ignores the +// cancel and answers later cannot write a second ending. What it CAN still do +// is report what it charged: that arrives as a `charge` receipt on the SAME +// record — same id, same state — never as a new ending. + +type RenderState string + +const ( + RenderRunning RenderState = "running" + RenderFinished RenderState = "finished" + RenderFailed RenderState = "failed" + RenderCancelled RenderState = "cancelled" + RenderInterrupted RenderState = "interrupted" + // RenderUnknown is a render the last row of which says running, read by + // something that does not own the session: it may have ended since. + RenderUnknown RenderState = "unknown" +) + +type RenderCharge string + +const ( + RenderCharged RenderCharge = "charged" + RenderNotCharged RenderCharge = "not_charged" + RenderChargeUnknown RenderCharge = "unknown" +) + +// RenderRecord is one render as every reader sees it. +type RenderRecord struct { + ID string `json:"id"` + Job int `json:"job"` + Session string `json:"session,omitempty"` + Kind string `json:"kind"` + State RenderState `json:"state"` + Model string `json:"model"` + Started time.Time `json:"started"` + Ended *time.Time `json:"ended,omitempty"` + DurationMS *int64 `json:"duration_ms,omitempty"` + Run string `json:"run,omitempty"` + Call string `json:"call,omitempty"` + Committed bool `json:"committed"` + Path string `json:"path,omitempty"` + Charge RenderCharge `json:"charge"` + CostUSD *float64 `json:"cost_usd,omitempty"` + // Upstream is "unknown" on a cancelled render: only local waiting stopped, + // and nothing here tells the provider to cancel. + Upstream string `json:"upstream,omitempty"` + Error string `json:"error,omitempty"` + // Persistence is unavailable when the latest fact is retained in memory because its journal write failed. + Persistence string `json:"persistence,omitempty"` +} + +// renderPatch is what a terminal row adds to the started one. +type renderPatch struct { + state RenderState + committed bool + path string + usage *ai.Usage + err string +} + +const ( + // Bounds of one read. A page reads at most renderScanBudget bytes backward + // and a lookup runs at most renderScanDeadline; running out of either is + // reported honestly (a continuation cursor, or busy), never as "not found". + renderScanBudget = 16 << 20 + renderResolveBudget = 16 << 20 + renderScanDeadline = 8 * time.Second + renderChunk = 64 << 10 + renderMaxLine = 256 << 10 + renderErrorRunes = 300 + renderListDefault = 50 + renderListMax = 100 +) + +const placeRenders = "renders.jsonl" + +const ( + renderEvStart = "start" + renderEvEnd = "end" + renderEvCharge = "charge" +) + +// renderRow is one line of the file: a full snapshot and what kind of change +// produced it. +type renderRow struct { + Ev string `json:"ev"` + RenderRecord +} + +var ( + ErrRenderCursor = errors.New("the render cursor is not valid; list again from the start") + ErrRenderNotFound = errors.New("there is no such render") + // ErrRenderBusy says the lookup ran out of time before it could tell; the + // render may exist. + ErrRenderBusy = errors.New("the render history is too large to search right now; try again") + errRenderLine = errors.New("a render history line is over the size limit") +) + +// renderStore reads a ledger file. It owns no state but the path and the bounds. +type renderStore struct { + path string + // budget bounds the bytes one page reads backward; resolveBudget bounds the + // bytes read forward to find the latest row of every entry on the page. + budget int64 + resolveBudget int64 + deadline time.Duration + // now is the clock every deadline is judged by; a test replaces it. + now func() time.Time + // beforeResolve, when set, runs between a page's two phases (tests only). + beforeResolve func() +} + +func newRenderStore(path string) renderStore { + return renderStore{path: path, budget: renderScanBudget, resolveBudget: renderResolveBudget, deadline: renderScanDeadline, now: time.Now} +} + +// expiry answers the moment a read that starts now must be over by. +func (s renderStore) expiry() func() bool { + end := s.now().Add(s.deadline) + return func() bool { return s.now().After(end) } +} + +// reverseScan visits the file's complete lines whose end is at or before upper, +// newest first, reading at most budget bytes and stopping at the deadline. It +// returns the start of the lowest line visited (upper when none), whether the +// whole range down to offset 0 was covered, and whether it stopped for time. +func reverseScan(file *os.File, upper, budget int64, expired func() bool, visit func(start int64, line []byte) bool) (low int64, covered, timedOut bool, err error) { + low = upper + pos := upper + var carry []byte + read := int64(0) + for pos > 0 { + if expired() { + return low, false, true, nil + } + if read >= budget { + return low, false, false, nil + } + n := int64(renderChunk) + if n > pos { + n = pos + } + chunk := make([]byte, n+int64(len(carry))) + if _, err := file.ReadAt(chunk[:n], pos-n); err != nil && !errors.Is(err, io.EOF) { + return low, false, false, err + } + copy(chunk[n:], carry) + read += n + pos -= n + data := chunk + for { + i := bytes.LastIndexByte(data, '\n') + if i < 0 { + break + } + if seg := data[i+1:]; len(seg) > 0 { + if len(seg) > renderMaxLine { + return low, false, false, errRenderLine + } + start := pos + int64(i) + 1 + low = start + if !visit(start, seg) { + return low, false, false, nil + } + } + data = data[:i] + } + if len(data) > renderMaxLine { + return low, false, false, errRenderLine + } + carry = data + } + if len(carry) > 0 { + low = 0 + if !visit(0, carry) { + return low, false, false, nil + } + } + return low, true, false, nil +} + +// forwardScan visits complete lines from offset from up to offset until, oldest +// first, and reads nothing beyond until. A line over the size limit is an error, +// never skipped: a skipped row could be the latest one. +func forwardScan(file *os.File, from, until int64, expired func() bool, visit func(start int64, line []byte)) (timedOut bool, err error) { + reader := bufio.NewReaderSize(io.NewSectionReader(file, from, until-from), renderChunk) + offset := from + for { + if expired() { + return true, nil + } + line, readErr := reader.ReadSlice('\n') + if errors.Is(readErr, bufio.ErrBufferFull) { + return false, errRenderLine + } + if len(bytes.TrimSpace(line)) > 0 && len(line) <= renderMaxLine { + visit(offset, line) + } + offset += int64(len(line)) + if readErr != nil { + if errors.Is(readErr, io.EOF) { + return false, nil + } + return false, readErr + } + } +} + +func parseRenderRow(line []byte) (renderRow, bool) { + var row renderRow + if json.Unmarshal(line, &row) != nil || row.ID == "" || row.Ev == "" { + return renderRow{}, false + } + return row, true +} + +// list reads one page, newest started first. cursor is "" or an offset token +// from a previous page. +func (s renderStore) list(limit int, cursor string) (RenderPage, error) { + if limit <= 0 { + limit = renderListDefault + } + if limit > renderListMax { + limit = renderListMax + } + page := RenderPage{Renders: []RenderRecord{}} + file, err := os.Open(s.path) + if err != nil { + if os.IsNotExist(err) { + return page, nil + } + return page, err + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return page, err + } + size := info.Size() + upper := size + if cursor != "" { + offset, ok := parseRenderCursor(cursor) + if !ok || offset > size { + return page, ErrRenderCursor + } + if offset > 0 { + var prior [1]byte + if _, err := file.ReadAt(prior[:], offset-1); err != nil || prior[0] != '\n' { + return page, ErrRenderCursor + } + } + upper = offset + } + expired := s.expiry() + type anchor struct { + off int64 + rec RenderRecord + } + var anchors []anchor + low, covered, timedOut, err := reverseScan(file, upper, s.budget, expired, func(off int64, line []byte) bool { + if row, ok := parseRenderRow(line); ok && row.Ev == renderEvStart { + anchors = append(anchors, anchor{off, row.RenderRecord}) + return len(anchors) < limit + } + return true + }) + if err != nil { + return page, err + } + if timedOut && len(anchors) == 0 { + return page, ErrRenderBusy + } + if !covered && low > 0 { + page.Next = fmt.Sprintf("r2.%d", low) + page.Incomplete = len(anchors) < limit + } + if len(anchors) == 0 { + return page, nil + } + // RESOLVE EACH ENTRY TO ITS LATEST ROW, EXACTLY OR NOT AT ALL. The later rows + // are after the entry, so one forward pass from the oldest entry to the size + // read at the start covers them, holding one record per page entry. If that + // span is over the resolve budget, or time or a read runs out before the pass + // is done, the page is refused: a start row is only ever the truth when the + // pass proved nothing newer exists, and a stale "running" or "uncharged" + // answer is worse than a busy one. + oldest := anchors[len(anchors)-1].off + if size-oldest > s.resolveBudget { + return RenderPage{Renders: []RenderRecord{}}, ErrRenderBusy + } + if s.beforeResolve != nil { + s.beforeResolve() + } + byID := make(map[string]int, len(anchors)) + resolved := make([]RenderRecord, len(anchors)) + for index, a := range anchors { + resolved[index] = a.rec + byID[a.rec.ID] = index + } + timedOut, err = forwardScan(file, oldest, size, expired, func(_ int64, line []byte) { + if row, ok := parseRenderRow(line); ok { + if index, held := byID[row.ID]; held { + resolved[index] = row.RenderRecord + } + } + }) + switch { + case err != nil: + return RenderPage{Renders: []RenderRecord{}}, err + case timedOut: + return RenderPage{Renders: []RenderRecord{}}, ErrRenderBusy + } + page.Renders = resolved + return page, nil +} + +// get finds one id by a bounded reverse scan of the whole file. +func (s renderStore) get(id string) (RenderRecord, error) { + file, err := os.Open(s.path) + if err != nil { + if os.IsNotExist(err) { + return RenderRecord{}, ErrRenderNotFound + } + return RenderRecord{}, err + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return RenderRecord{}, err + } + var latest *RenderRecord + _, covered, timedOut, err := reverseScan(file, info.Size(), 1<<62, s.expiry(), func(_ int64, line []byte) bool { + row, ok := parseRenderRow(line) + if !ok || row.ID != id { + return true + } + if latest == nil { + rec := row.RenderRecord + latest = &rec + } + return row.Ev != renderEvStart + }) + switch { + case err != nil: + return RenderRecord{}, err + case latest != nil: + return *latest, nil + case timedOut && !covered: + return RenderRecord{}, ErrRenderBusy + } + return RenderRecord{}, ErrRenderNotFound +} + +// unresolved is every render whose last row is its start: the renders a dead +// process left running. It streams the whole file and holds only the unresolved +// ones. A scan that could not finish returns an error and NO partial answer: an +// incomplete set must never be acted on as though it were the whole one. +func (s renderStore) unresolved() ([]RenderRecord, error) { + file, err := os.Open(s.path) + if err != nil { + if os.IsNotExist(err) { + return nil, nil + } + return nil, err + } + defer file.Close() + info, err := file.Stat() + if err != nil { + return nil, err + } + open := map[string]RenderRecord{} + timedOut, err := forwardScan(file, 0, info.Size(), s.expiry(), func(_ int64, line []byte) { + if row, ok := parseRenderRow(line); ok { + if row.Ev == renderEvStart { + open[row.ID] = row.RenderRecord + } else { + delete(open, row.ID) + } + } + }) + if err != nil { + return nil, err + } + if timedOut { + return nil, ErrRenderBusy + } + out := make([]RenderRecord, 0, len(open)) + for _, rec := range open { + out = append(out, rec) + } + return out, nil +} + +func parseRenderCursor(cursor string) (int64, bool) { + rest, ok := strings.CutPrefix(cursor, "r2.") + if !ok { + return 0, false + } + offset, err := strconv.ParseInt(rest, 10, 64) + return offset, err == nil && offset >= 0 +} + +// RenderPage is one window of renders, newest first. Next is absent only when +// the whole history has been read; Incomplete says the page is short because +// the read budget ran out, so keep following Next. +type RenderPage struct { + Renders []RenderRecord `json:"renders"` + Next string `json:"next,omitempty"` + Incomplete bool `json:"incomplete,omitempty"` +} + +// ── the owner's ledger ────────────────────────────────────────────────────── + +// renderLive is a render this process still holds a goroutine for. It is kept +// until that goroutine returns, because a provider that ignored a cancel may +// still report what it charged. +type renderLive struct { + rec RenderRecord + usage *ai.Usage // a charge reported before the ending, carried into it + dirty bool + released bool +} + +type renderLedger struct { + store renderStore + session string + mu sync.Mutex + file *os.File + live map[string]*renderLive + notify func(RenderRecord) + scratch bool // the file is a temporary one, removed at close + // restoreErr is why the restore at open could not finish; nil when it did. + restoreErr error + closed bool // a late append after close is dropped, never reopens the file +} + +func newRenderID() string { + buffer := make([]byte, 16) + if _, err := rand.Read(buffer); err != nil { + return "" + } + return "rnd_" + hex.EncodeToString(buffer) +} + +// openRenderLedger opens the ledger for a session the caller OWNS. A render the +// last process left running is recorded as interrupted — once, here, durably. +func openRenderLedger(path, session string, now time.Time) *renderLedger { + return openRenderLedgerOn(newRenderStore(path), session, now) +} + +// openRenderLedgerOn is [openRenderLedger] over a store with its own bounds. +func openRenderLedgerOn(store renderStore, session string, now time.Time) *renderLedger { + path := store.path + ledger := &renderLedger{store: store, session: session, live: map[string]*renderLive{}} + if path == "" { + // A session with no folder (the legacy layout) has no durable home for + // its history, so it gets a private temporary file that lives as long as + // the session does and is removed when the ledger closes. The reads are + // the same reads. + if temp, err := os.CreateTemp("", "codeaf-renders-*.jsonl"); err == nil { + ledger.store.path = temp.Name() + ledger.scratch = true + _ = temp.Close() + } + return ledger + } + // The left-running renders are marked interrupted only when the whole file + // was read. If it could not be, nothing is marked and restoreErr says why: + // those renders keep reading as unknown, never as running, and the next open + // tries again. + left, err := ledger.store.unresolved() + if err != nil { + ledger.restoreErr = err + return ledger + } + for _, rec := range left { + rec := rec + ledger.applyEnd(&rec, renderPatch{state: RenderInterrupted}, now) + if err := ledger.append(renderEvEnd, rec); err != nil { + ledger.restoreErr = err + break + } + } + return ledger +} + +func (l *renderLedger) append(ev string, rec RenderRecord) error { + if l.store.path == "" || l.closed { + return errors.New("the render journal is unavailable") + } + if l.file == nil { + file, err := os.OpenFile(l.store.path, os.O_CREATE|os.O_APPEND|os.O_WRONLY, 0o644) + if err != nil { + return err + } + l.file = file + } + line, err := json.Marshal(renderRow{Ev: ev, RenderRecord: rec}) + if err != nil { + return err + } + info, err := l.file.Stat() + if err != nil { + _ = l.file.Close() + l.file = nil + return err + } + line = append(line, '\n') + n, err := l.file.Write(line) + if err == nil && n != len(line) { + err = io.ErrShortWrite + } + if err != nil { + // A failed append may have left a partial row. Roll back only this + // writer's attempted row before another retry can append behind it. + if trimErr := l.file.Truncate(info.Size()); trimErr != nil { + l.restoreErr = trimErr + } + _ = l.file.Close() + l.file = nil + } + return err +} + +// flushPendingLocked retains every unflushed terminal fact. No new paid render +// starts while a previous outcome cannot be journaled. The active-render gate +// bounds the number that can become pending concurrently. +func (l *renderLedger) flushPendingLocked() ([]RenderRecord, error) { + var saved []RenderRecord + if l.restoreErr != nil { + return nil, l.restoreErr + } + for id, entry := range l.live { + if !entry.dirty { + continue + } + rec := entry.rec + rec.Persistence = "" + if err := l.append(renderEvCharge, rec); err != nil { + return saved, err + } + entry.rec, entry.dirty = rec, false + saved = append(saved, rec) + if entry.released { + delete(l.live, id) + } + } + return saved, nil +} + +func (l *renderLedger) emit(rec RenderRecord) { + if l.notify != nil { + l.notify(rec) + } +} + +// begin records a render's start and returns its stable id. +func (l *renderLedger) begin(job int, kind, model, run, call string, now time.Time) string { + l.mu.Lock() + var notices []RenderRecord + defer func() { + l.mu.Unlock() + for _, rec := range notices { + l.emit(rec) + } + }() + if l.closed { + return "" + } + saved, err := l.flushPendingLocked() + notices = append(notices, saved...) + if err != nil { + return "" + } + rec := RenderRecord{ + ID: newRenderID(), Job: job, Session: l.session, Kind: kind, State: RenderRunning, + Model: model, Started: now, Run: run, Call: call, Charge: RenderChargeUnknown, + } + if rec.ID == "" { + return "" + } + if err := l.append(renderEvStart, rec); err != nil { + return "" + } + l.live[rec.ID] = &renderLive{rec: rec} + notices = append(notices, rec) + return rec.ID +} + +func (l *renderLedger) applyEnd(rec *RenderRecord, patch renderPatch, now time.Time) { + rec.State = patch.state + rec.Committed = patch.committed + rec.Path = patch.path + ended := now + rec.Ended = &ended + duration := now.Sub(rec.Started).Milliseconds() + if duration < 0 { + duration = 0 + } + rec.DurationMS = &duration + if patch.usage != nil { + rec.Charge, rec.CostUSD = renderCharge(patch.usage) + } + if patch.state == RenderCancelled { + rec.Upstream = "unknown" + } + rec.Error = strings.Join(strings.Fields(patch.err), " ") + if runes := []rune(rec.Error); len(runes) > renderErrorRunes { + rec.Error = string(runes[:renderErrorRunes]) + } +} + +// settle writes the render's one ending and reports whether THIS call was it. +// A render already ended answers false and writes nothing. +func (l *renderLedger) settle(id string, patch renderPatch, now time.Time) bool { + l.mu.Lock() + entry := l.live[id] + if entry == nil || entry.rec.State != RenderRunning { + l.mu.Unlock() + return false + } + if patch.usage == nil { + patch.usage = entry.usage + } + l.applyEnd(&entry.rec, patch, now) + entry.rec.Persistence = "" + if err := l.append(renderEvEnd, entry.rec); err != nil { + entry.rec.Persistence, entry.dirty = "unavailable", true + } + rec := entry.rec + l.mu.Unlock() + l.emit(rec) + return true +} + +// charge reports what the provider said it charged, whenever it says it. Before +// the ending it rides into the ending; after it, it is a receipt on the SAME +// record: same id, same state, one row, once. A charge the provider did not +// report is not recorded as anything. +func (l *renderLedger) charge(id string, usage *ai.Usage, now time.Time) { + if known, _ := renderCharge(usage); known == RenderChargeUnknown { + return + } + l.mu.Lock() + entry := l.live[id] + if entry == nil { + l.mu.Unlock() + return + } + if entry.rec.State == RenderRunning { + entry.usage = usage + l.mu.Unlock() + return + } + if entry.rec.Charge != RenderChargeUnknown { + l.mu.Unlock() + return + } + entry.rec.Charge, entry.rec.CostUSD = renderCharge(usage) + entry.rec.Persistence = "" + if err := l.append(renderEvCharge, entry.rec); err != nil { + entry.rec.Persistence, entry.dirty = "unavailable", true + } + rec := entry.rec + l.mu.Unlock() + l.emit(rec) +} + +// release says the render's goroutine has returned: nothing more can arrive. +func (l *renderLedger) release(id string) { + l.mu.Lock() + if entry := l.live[id]; entry != nil && entry.dirty { + entry.released = true + } else { + delete(l.live, id) + } + l.mu.Unlock() +} + +func (l *renderLedger) get(id string) (RenderRecord, error) { + l.mu.Lock() + if entry := l.live[id]; entry != nil { + rec := entry.rec + l.mu.Unlock() + return rec, nil + } + l.mu.Unlock() + rec, err := l.store.get(id) + return l.settleUnresolved(rec), err +} + +func (l *renderLedger) list(limit int, cursor string) (RenderPage, error) { + l.mu.Lock() + for _, entry := range l.live { + if entry.dirty { + l.mu.Unlock() + return RenderPage{}, ErrRenderBusy + } + } + l.mu.Unlock() + page, err := l.store.list(limit, cursor) + if err != nil { + return page, err + } + l.mu.Lock() + for index, rec := range page.Renders { + if entry := l.live[rec.ID]; entry != nil { + page.Renders[index] = entry.rec + } + } + l.mu.Unlock() + for index := range page.Renders { + l.settleUnresolvedInPlace(&page.Renders[index]) + } + return page, nil +} + +// settleUnresolved shows a render whose last row is its start, and which this +// process is not running, as unknown rather than running. +func (l *renderLedger) settleUnresolved(rec RenderRecord) RenderRecord { + l.settleUnresolvedInPlace(&rec) + return rec +} + +func (l *renderLedger) settleUnresolvedInPlace(rec *RenderRecord) { + if rec.State != RenderRunning { + return + } + l.mu.Lock() + _, mine := l.live[rec.ID] + l.mu.Unlock() + if !mine { + rec.State = RenderUnknown + } +} + +func (l *renderLedger) close() { + l.mu.Lock() + defer l.mu.Unlock() + l.closed = true + if l.file != nil { + _ = l.file.Close() + l.file = nil + } + if l.scratch { + _ = os.Remove(l.store.path) + } +} + +// ReadRenders lists a session's renders from disk WITHOUT owning the session +// (the conversation is not open here). A render whose last row is its start is +// shown as unknown: it may have ended since, and claiming otherwise is a guess. +func ReadRenders(sessionDir string, limit int, cursor string) (RenderPage, error) { + path := Place{Dir: sessionDir}.join(placeRenders) + if path == "" { + return RenderPage{Renders: []RenderRecord{}}, nil + } + page, err := newRenderStore(path).list(limit, cursor) + for index := range page.Renders { + if page.Renders[index].State == RenderRunning { + page.Renders[index].State = RenderUnknown + } + } + return page, err +} + +// ReadRender is [ReadRenders] for one id; an id the history does not hold is +// [ErrRenderNotFound], and a lookup that ran out of time is [ErrRenderBusy]. +func ReadRender(sessionDir, id string) (RenderRecord, error) { + path := Place{Dir: sessionDir}.join(placeRenders) + if path == "" { + return RenderRecord{}, ErrRenderNotFound + } + rec, err := newRenderStore(path).get(id) + if rec.State == RenderRunning { + rec.State = RenderUnknown + } + return rec, err +} + +// renderCharge reads the provider's own usage: a positive finite cost is +// charged, an explicit zero is not charged, anything else is unknown. A missing +// figure is never turned into a zero. +func renderCharge(usage *ai.Usage) (RenderCharge, *float64) { + if usage == nil || usage.Cost == nil { + return RenderChargeUnknown, nil + } + cost := *usage.Cost + switch { + case math.IsNaN(cost) || math.IsInf(cost, 0) || cost < 0: + return RenderChargeUnknown, nil + case cost == 0: + return RenderNotCharged, nil + } + return RenderCharged, &cost +} diff --git a/internal/session/session.go b/internal/session/session.go index 88b444e056..eef5d1c8d5 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -661,6 +661,13 @@ const ( // IT IS PINNED AT EventAutomationUpdate + 3, after the two summary // ordinals above; persisted event numbers must not move. EventKeepRunning = EventAutomationUpdate + 3 + // EventRenderUpdate reports one video or music RENDER changing: started, + // finished, failed, cancelled, or a charge receipt on one already ended + // (renderlog.go). [Event.Render] is the whole record, so a surface replaces + // the record with that id rather than appending. Receipts for an ended + // render carry the SAME id and state. Appended after every existing ordinal + // because hosts serialize EventKind as an integer. + EventRenderUpdate = EventKeepRunning + 1 ) // TaskReplyTag is the task identity a surface places beside the answer its @@ -985,6 +992,10 @@ type Event struct { // minted somewhere else to keep it from colliding with a task's. Job *JobNotice + // Render carries one EventRenderUpdate's payload (renderlog.go) and is nil + // on every other kind. + Render *RenderRecord + // TaskPhase carries one EventTaskPhase's payload (task_contract.go): which // running node moved into which of its three lives. It is nil on every // other kind, and it is its OWN payload rather than more fields on diff --git a/internal/session/tools_music.go b/internal/session/tools_music.go index a33c0d4f1c..cc66d3a04e 100644 --- a/internal/session/tools_music.go +++ b/internal/session/tools_music.go @@ -155,6 +155,14 @@ func (a *Agent) generateMusicTool(client MediaGenerator, defaultModel string) ba if err != nil { return "Could not start the compose: " + err.Error(), true, nil } + if err := a.jobs.beginRender(started, "music", model, runIdentity(callCtx), callIDFrom(callCtx), a.emitRenderUpdate); err != nil { + if started.stop != nil { + started.stop() + } + a.jobs.finish(started, 1, "") + started.releaseRender() + return "Could not start the music render: " + err.Error(), true, nil + } fmt.Fprintf(started.sink, "composing on %s: %s\n", model, firstLine(prompt)) go a.composeMusic(ctx, client, started, provider.MusicRequest{ @@ -183,6 +191,11 @@ func (a *Agent) composeMusic(ctx context.Context, client MediaGenerator, composi a.failMusic(composing, musicFailure(model, err)) return } + // WHAT THE PROVIDER SAYS IT CHARGED goes to the ledger the moment the call + // returns, whatever became of the render meanwhile. + if response != nil { + composing.noteCharge(response.Usage) + } if response == nil || len(response.Audio) == 0 { a.failMusic(composing, "music generation returned no audio ("+model+")") return @@ -198,6 +211,7 @@ func (a *Agent) composeMusic(ctx context.Context, client MediaGenerator, composi case renderStopped: reason := "stopped before the music was saved; the provider had already finished it, so it may have been charged" fmt.Fprintln(composing.sink, reason) + composing.sealRender(renderPatch{state: RenderCancelled}) a.jobs.finish(composing, 1, "job "+strconv.Itoa(composing.id)+" "+reason) return case renderNotSaved: @@ -211,6 +225,7 @@ func (a *Agent) composeMusic(ctx context.Context, client MediaGenerator, composi Created: time.Now(), }, GeneratedMedia(path, response.Audio, model).WithUsage(response.Usage).WithMade(made)) + composing.sealRender(renderPatch{state: RenderFinished, committed: true, path: path, usage: response.Usage}) landed := describeGeneratedMusic(a.config.Workspace, path, len(response.Audio), model) fmt.Fprintln(composing.sink, landed) a.jobs.finish(composing, 0, fmt.Sprintf("job %d finished: %s", composing.id, landed)) @@ -219,6 +234,7 @@ func (a *Agent) composeMusic(ctx context.Context, client MediaGenerator, composi // failMusic ends a compose that produced nothing, [Agent.failVideo]'s twin: // exit code 1, the reason in the log, the same sentence as the note. func (a *Agent) failMusic(composing *job, reason string) { + composing.sealRender(renderPatch{state: RenderFailed, committed: composing.isCommitted(), err: reason}) fmt.Fprintln(composing.sink, reason) a.jobs.finish(composing, 1, fmt.Sprintf("job %d failed: %s", composing.id, reason)) } diff --git a/internal/session/tools_video.go b/internal/session/tools_video.go index 51472766f2..1fe98610ce 100644 --- a/internal/session/tools_video.go +++ b/internal/session/tools_video.go @@ -157,6 +157,14 @@ func (a *Agent) generateVideoTool(client MediaGenerator, defaultModel string) ba if err != nil { return "Could not start the video render: " + err.Error(), true, nil } + if err := a.jobs.beginRender(started, "video", model, runIdentity(callCtx), callIDFrom(callCtx), a.emitRenderUpdate); err != nil { + if started.stop != nil { + started.stop() + } + a.jobs.finish(started, 1, "") + started.releaseRender() + return "Could not start the video render: " + err.Error(), true, nil + } fmt.Fprintf(started.sink, "filming on %s: %s\n", model, firstLine(prompt)) go a.renderVideo(ctx, client, started, request, parsed.Path, model, madeBy(callCtx, append(append([]string(nil), parsed.FramePaths...), parsed.ReferencePaths...))) @@ -202,6 +210,11 @@ func (a *Agent) renderVideo(ctx context.Context, client MediaGenerator, renderin a.failVideo(rendering, videoFailure(model, err)) return } + // WHAT THE PROVIDER SAYS IT CHARGED goes to the ledger the moment the call + // returns, whatever became of the render meanwhile. + if response != nil { + rendering.noteCharge(response.Usage) + } if response == nil || len(response.Video) == 0 { a.failVideo(rendering, "video generation returned no video ("+model+")") return @@ -220,6 +233,7 @@ func (a *Agent) renderVideo(ctx context.Context, client MediaGenerator, renderin // dropped unwritten, and the log says what is and is not known. reason := "stopped before the video was saved; the provider had already finished it, so it may have been charged" fmt.Fprintln(rendering.sink, reason) + rendering.sealRender(renderPatch{state: RenderCancelled}) a.jobs.finish(rendering, 1, "job "+strconv.Itoa(rendering.id)+" "+reason) return case renderNotSaved: @@ -239,6 +253,7 @@ func (a *Agent) renderVideo(ctx context.Context, client MediaGenerator, renderin Created: time.Now(), }, fact) + rendering.sealRender(renderPatch{state: RenderFinished, committed: true, path: path, usage: response.Usage}) landed := describeGeneratedVideo(a.config.Workspace, path, response.Video, model) fmt.Fprintln(rendering.sink, landed) a.jobs.finish(rendering, 0, fmt.Sprintf("job %d finished: %s", rendering.id, landed)) @@ -249,6 +264,7 @@ func (a *Agent) renderVideo(ctx context.Context, client MediaGenerator, renderin // "failed" rather than "finished". A failure is never retried: the provider // call is made once, and any further attempt is the model's decision. func (a *Agent) failVideo(rendering *job, reason string) { + rendering.sealRender(renderPatch{state: RenderFailed, committed: rendering.isCommitted(), err: reason}) fmt.Fprintln(rendering.sink, reason) a.jobs.finish(rendering, 1, fmt.Sprintf("job %d failed: %s", rendering.id, reason)) } From 875136313aef63238a18f2ea24838d9f3b191dc7 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 16 Oct 2026 00:58:28 -0400 Subject: [PATCH 141/459] Retain unsaved render facts across closed session owners within a process bound --- internal/session/jobs.go | 6 +- internal/session/render_ledger_test.go | 192 +++++++++++++++++++++++-- internal/session/render_retention.go | 140 ++++++++++++++++++ internal/session/renderlog.go | 103 ++++++++----- 4 files changed, 389 insertions(+), 52 deletions(-) create mode 100644 internal/session/render_retention.go diff --git a/internal/session/jobs.go b/internal/session/jobs.go index 04730fb2d4..13a7d633dc 100644 --- a/internal/session/jobs.go +++ b/internal/session/jobs.go @@ -959,9 +959,9 @@ func (r *jobRegistry) beginRender(one *job, kind, model, run, call string, notif ledger.notify = notify } ledger.mu.Unlock() - id := ledger.begin(one.id, kind, model, run, call, time.Now()) - if id == "" { - return errors.New("the render history could not be saved; free space or fix permissions before starting a render") + id, err := ledger.beginChecked(one.id, kind, model, run, call, time.Now()) + if err != nil { + return err } one.mu.Lock() one.ledger, one.renderID = ledger, id diff --git a/internal/session/render_ledger_test.go b/internal/session/render_ledger_test.go index 2108707a9e..128dfd1a65 100644 --- a/internal/session/render_ledger_test.go +++ b/internal/session/render_ledger_test.go @@ -55,10 +55,15 @@ func TestRenderChargeIsKnownOnlyWhenTheProviderSaidSo(t *testing.T) { } // openForTest opens a ledger on a fresh file with a fixed clock. +func openTestRenderLedger(t *testing.T, path, session string, now time.Time) *renderLedger { + t.Helper() + return openRenderLedgerUsing(newRenderStore(path), session, now, newRenderRetention(renderHistoryOwners)) +} + func openForTest(t *testing.T) (*renderLedger, string) { t.Helper() path := filepath.Join(t.TempDir(), "renders.jsonl") - return openRenderLedger(path, "sess", time.Unix(1_700_000_000, 0)), path + return openTestRenderLedger(t, path, "sess", time.Unix(1_700_000_000, 0)), path } var epoch = time.Unix(1_700_000_000, 0) @@ -80,7 +85,7 @@ func TestRenderLedgerSealsOnceAndSurvivesReopen(t *testing.T) { if rows := ledgerRows(t, path); len(rows) != 2 { t.Fatalf("%d rows, want start + one end", len(rows)) } - reopened := openRenderLedger(path, "sess", epoch.Add(time.Hour)) + reopened := openTestRenderLedger(t, path, "sess", epoch.Add(time.Hour)) got, err := reopened.get(id) if err != nil || got.State != RenderCancelled || got.Upstream != "unknown" || got.Committed || got.Path != "" || got.Charge != RenderChargeUnknown || got.DurationMS == nil || *got.DurationMS != 1000 || @@ -144,13 +149,13 @@ func TestRenderLedgerMarksALeftRunningRenderInterruptedOnce(t *testing.T) { first, path := openForTest(t) id := first.begin(1, "music", "m", "", "", epoch) first.close() - second := openRenderLedger(path, "sess", epoch.Add(time.Minute)) + second := openTestRenderLedger(t, path, "sess", epoch.Add(time.Minute)) got, _ := second.get(id) if got.State != RenderInterrupted || got.Charge != RenderChargeUnknown || got.Committed { t.Fatalf("record = %+v, want interrupted with unknown charge", got) } second.close() - openRenderLedger(path, "sess", epoch.Add(2*time.Minute)).close() + openTestRenderLedger(t, path, "sess", epoch.Add(2*time.Minute)).close() if rows := ledgerRows(t, path); len(rows) != 2 { t.Fatalf("%d rows after two reopens, want 2", len(rows)) } @@ -159,7 +164,7 @@ func TestRenderLedgerMarksALeftRunningRenderInterruptedOnce(t *testing.T) { // Read without owning the session, an unfinished render is unknown, never running. func TestReadRendersShowsAnUnfinishedRenderAsUnknown(t *testing.T) { dir := t.TempDir() - ledger := openRenderLedger(filepath.Join(dir, placeRenders), "s", epoch) + ledger := openTestRenderLedger(t, filepath.Join(dir, placeRenders), "s", epoch) id := ledger.begin(1, "video", "m", "", "", epoch) ledger.close() page, err := ReadRenders(dir, 0, "") @@ -225,7 +230,7 @@ func TestRenderHistoryIsCompleteAcrossReopenAndPaging(t *testing.T) { } ledger.close() - reopened := openRenderLedger(path, "sess", epoch.Add(24*time.Hour)) + reopened := openTestRenderLedger(t, path, "sess", epoch.Add(24*time.Hour)) defer reopened.close() for _, probe := range []int{0, 1, 199, 200, 449} { // the oldest are far outside any 200-record window got, err := reopened.get(ids[probe]) @@ -675,7 +680,7 @@ func TestRenderRestoreThatCannotFinishMarksNothingAndRetriesOnTheNextOpen(t *tes t.Fatalf("a failed restore wrote %d rows, want none added", len(rows)-1) } - healthy := openRenderLedger(path, "sess", epoch.Add(2*time.Minute)) + healthy := openTestRenderLedger(t, path, "sess", epoch.Add(2*time.Minute)) defer healthy.close() if healthy.restoreErr != nil { t.Fatal(healthy.restoreErr) @@ -683,7 +688,7 @@ func TestRenderRestoreThatCannotFinishMarksNothingAndRetriesOnTheNextOpen(t *tes if got, _ := healthy.get(id); got.State != RenderInterrupted { t.Fatalf("after a clean open = %+v, want interrupted", got) } - openRenderLedger(path, "sess", epoch.Add(3*time.Minute)).close() + openTestRenderLedger(t, path, "sess", epoch.Add(3*time.Minute)).close() if rows := ledgerRows(t, path); len(rows) != 2 { t.Fatalf("%d rows, want start + one interrupted", len(rows)) } @@ -691,7 +696,7 @@ func TestRenderRestoreThatCannotFinishMarksNothingAndRetriesOnTheNextOpen(t *tes func TestRenderCannotStartWithoutItsJournal(t *testing.T) { path := filepath.Join(t.TempDir(), "missing", "renders.jsonl") - ledger := openRenderLedger(path, "s", time.Now()) + ledger := openTestRenderLedger(t, path, "s", time.Now()) defer ledger.close() if id := ledger.begin(1, "video", "chosen", "run", "call", time.Now()); id != "" { t.Fatalf("unrecorded start admitted: %s", id) @@ -707,7 +712,7 @@ func TestFailedRenderReceiptStaysOwnedUntilItCanBeSaved(t *testing.T) { if err := os.Mkdir(dir, 0o700); err != nil { t.Fatal(err) } - ledger := openRenderLedger(filepath.Join(dir, "renders.jsonl"), "s", time.Now()) + ledger := openTestRenderLedger(t, filepath.Join(dir, "renders.jsonl"), "s", time.Now()) defer ledger.close() id := ledger.begin(1, "video", "chosen", "run", "call", time.Now()) if id == "" { @@ -732,8 +737,8 @@ func TestFailedRenderReceiptStaysOwnedUntilItCanBeSaved(t *testing.T) { if next := ledger.begin(2, "video", "chosen", "run2", "call2", time.Now()); next != "" { t.Fatal("new paid start admitted with unflushed receipt") } - if len(ledger.live) != 1 { - t.Fatalf("failed starts grew retained records: %d", len(ledger.live)) + if len(ledger.live) != 0 || len(ledger.owner.pending(ledger.store.path)) != 1 { + t.Fatal("the released receipt was not transferred to its bounded process owner") } if err := os.Rename(dir+"-offline", dir); err != nil { t.Fatal(err) @@ -749,3 +754,166 @@ func TestFailedRenderReceiptStaysOwnedUntilItCanBeSaved(t *testing.T) { t.Fatal("saved released receipt stayed pinned") } } + +func TestRenderReceiptTransfersAcrossClosedOwnersAndRecoversOnce(t *testing.T) { + parent := t.TempDir() + dir := filepath.Join(parent, "session") + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, "renders.jsonl") + owner := newRenderRetention(2) + first := openRenderLedgerUsing(newRenderStore(path), "same-session", epoch, owner) + id := first.begin(1, "video", "chosen", "run", "call", epoch) + if id == "" { + t.Fatal("start refused") + } + _ = first.file.Close() + first.file = nil + if err := os.Rename(dir, dir+"-offline"); err != nil { + t.Fatal(err) + } + cost := 0.29 + first.settle(id, renderPatch{state: RenderFailed, usage: &ai.Usage{Cost: &cost}}, epoch.Add(time.Second)) + first.release(id) + first.close() + first = nil // The old session object no longer owns the receipt. + second := openRenderLedgerUsing(newRenderStore(path), "same-session", epoch.Add(time.Hour), owner) + got, err := second.get(id) + if err != nil || got.State != RenderFailed || got.CostUSD == nil || *got.CostUSD != cost || got.Persistence != "unavailable" { + t.Fatalf("closed owner lost actual receipt: %#v %v", got, err) + } + if next := second.begin(2, "video", "chosen", "run2", "call2", epoch); next != "" { + t.Fatal("unwritable reopen admitted a paid call") + } + second.close() + if err := os.Rename(dir+"-offline", dir); err != nil { + t.Fatal(err) + } + third := openRenderLedgerUsing(newRenderStore(path), "same-session", epoch.Add(2*time.Hour), owner) + defer third.close() + got, err = third.get(id) + if err != nil || got.State != RenderFailed || got.CostUSD == nil || *got.CostUSD != cost || got.Persistence != "" { + t.Fatalf("recovery changed actual receipt: %#v %v", got, err) + } + rows := ledgerRows(t, path) + ends := 0 + for _, row := range rows { + if row.Ev == renderEvEnd { + ends++ + } + } + if ends != 1 || len(owner.pending(path)) != 0 { + t.Fatalf("recovery duplicated/forgot ending: %d %#v", ends, rows) + } +} + +func TestRenderProcessOwnerRefusesAcrossAgentsWithoutEvictingReceipts(t *testing.T) { + owner := newRenderRetention(2) + parent := t.TempDir() + newLedger := func(name string) *renderLedger { + dir := filepath.Join(parent, name) + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatal(err) + } + return openRenderLedgerUsing(newRenderStore(filepath.Join(dir, "renders.jsonl")), name, epoch, owner) + } + a, b, c := newLedger("a"), newLedger("b"), newLedger("c") + defer a.close() + defer b.close() + defer c.close() + idA := a.begin(1, "music", "chosen", "run", "call", epoch) + idB := b.begin(1, "video", "chosen", "run", "call", epoch) + if idA == "" || idB == "" { + t.Fatal("initial slots refused") + } + if id := c.begin(1, "video", "chosen", "run", "call", epoch); id != "" { + t.Fatal("process owner exceeded cross-agent bound") + } + _ = a.file.Close() + a.file = nil + dir := filepath.Dir(a.store.path) + if err := os.Rename(dir, dir+"-offline"); err != nil { + t.Fatal(err) + } + a.settle(idA, renderPatch{state: RenderFailed}, epoch) + a.release(idA) + a.close() + if id := c.begin(2, "video", "chosen", "run", "call", epoch); id != "" { + t.Fatal("unflushed receipt was evicted for another agent") + } + if err := os.Rename(dir+"-offline", dir); err != nil { + t.Fatal(err) + } + reopened := openRenderLedgerUsing(newRenderStore(a.store.path), "a", epoch, owner) + defer reopened.close() + idC := c.begin(3, "video", "chosen", "run", "call", epoch) + if idC == "" { + t.Fatal("saved receipt did not release process slot") + } + b.release(idB) + c.release(idC) +} + +func TestSavingAnOlderRenderSnapshotCannotLoseItsNewCharge(t *testing.T) { + owner := newRenderRetention(1) + if !owner.reserve("p", "r") { + t.Fatal("reserve") + } + owner.retain("p", RenderRecord{ID: "r", State: RenderCancelled, Charge: RenderChargeUnknown}, renderEvEnd) + old := owner.pending("p")[0] + cost := 0.31 + owner.retain("p", RenderRecord{ID: "r", State: RenderCancelled, Charge: RenderCharged, CostUSD: &cost}, renderEvCharge) + if owner.saved(old.key, old.revision, old.event) { + t.Fatal("stale persistence retired newer charge") + } + next := owner.pending("p") + if len(next) != 1 || next[0].record.CostUSD == nil || *next[0].record.CostUSD != cost || next[0].event != renderEvCharge { + t.Fatalf("new charge lost or second ending requested: %#v", next) + } + owner.release("p", "r") + if owner.reserve("other", "new") { + t.Fatal("unflushed charge slot evicted") + } + if !owner.saved(next[0].key, next[0].revision, next[0].event) || !owner.reserve("other", "new") { + t.Fatal("persisted charge did not free reservation") + } +} + +func TestLateRenderChargeAfterOwnerCloseReachesItsReplacement(t *testing.T) { + path := filepath.Join(t.TempDir(), "renders.jsonl") + owner := newRenderRetention(2) + first := openRenderLedgerUsing(newRenderStore(path), "session", epoch, owner) + id := first.begin(1, "video", "chosen", "run", "call", epoch) + if id == "" { + t.Fatal("start refused") + } + first.settle(id, renderPatch{state: RenderCancelled}, epoch.Add(time.Second)) + first.close() + second := openRenderLedgerUsing(newRenderStore(path), "session", epoch.Add(time.Hour), owner) + defer second.close() + cost := 0.43 + first.charge(id, &ai.Usage{Cost: &cost}, epoch.Add(2*time.Hour)) + first.release(id) + got, err := second.get(id) + if err != nil || got.State != RenderCancelled || got.CostUSD == nil || *got.CostUSD != cost { + t.Fatalf("replacement lost late charge: %#v %v", got, err) + } + *got.CostUSD = 999 // A reader's copy must not edit the process owner's money. + page, err := second.list(50, "") + if err != nil || len(page.Renders) != 1 || page.Renders[0].CostUSD == nil || *page.Renders[0].CostUSD != cost { + t.Fatalf("late receipt recovery: %#v %v", page, err) + } + ends, receipts := 0, 0 + for _, row := range ledgerRows(t, path) { + if row.Ev == renderEvEnd { + ends++ + } + if row.Ev == renderEvCharge { + receipts++ + } + } + if ends != 1 || receipts != 1 { + t.Fatalf("late receipt duplicated ending: %d endings %d receipts", ends, receipts) + } +} diff --git a/internal/session/render_retention.go b/internal/session/render_retention.go new file mode 100644 index 0000000000..a92fd46d51 --- /dev/null +++ b/internal/session/render_retention.go @@ -0,0 +1,140 @@ +package session + +import ( + "strings" + "sync" +) + +// Slots are reserved before a paid render starts and owned until the provider +// returns AND its latest fact is saved. Closed session objects are not owners +// of unsaved outcomes. Refusal at this fixed process bound never evicts one. +const renderHistoryOwners = 64 + +var processRenderHistory = newRenderRetention(renderHistoryOwners) + +type retainedRender struct { + path string + active bool + revision uint64 + record *RenderRecord + event string +} +type renderRetention struct { + mu sync.Mutex + limit int + rows map[string]*retainedRender +} +type pendingRender struct { + key string + revision uint64 + record RenderRecord + event string +} + +func newRenderRetention(limit int) *renderRetention { + return &renderRetention{limit: limit, rows: make(map[string]*retainedRender)} +} +func renderRetentionKey(path, id string) string { return path + "\x00" + id } +func (r *renderRetention) reserve(path, id string) bool { + r.mu.Lock() + defer r.mu.Unlock() + if len(r.rows) >= r.limit { + return false + } + key := renderRetentionKey(path, id) + if _, exists := r.rows[key]; exists { + return false + } + r.rows[key] = &retainedRender{path: strings.Clone(path), active: true} + return true +} +func (r *renderRetention) retain(path string, record RenderRecord, event string) { + r.mu.Lock() + defer r.mu.Unlock() + entry := r.rows[renderRetentionKey(path, record.ID)] + if entry == nil { + return + } // Every provider has reserved its slot before starting. + copy := cloneRenderRecord(record) + entry.record = © + if entry.event != renderEvEnd { + entry.event = event + } + entry.revision++ +} +func (r *renderRetention) release(path, id string) { + r.mu.Lock() + defer r.mu.Unlock() + key := renderRetentionKey(path, id) + if entry := r.rows[key]; entry != nil { + entry.active = false + if entry.record == nil { + delete(r.rows, key) + } + } +} +func (r *renderRetention) pending(path string) []pendingRender { + r.mu.Lock() + defer r.mu.Unlock() + var out []pendingRender + for key, entry := range r.rows { + if entry.path == path && entry.record != nil { + out = append(out, pendingRender{key: key, revision: entry.revision, record: cloneRenderRecord(*entry.record), event: entry.event}) + } + } + return out +} +func (r *renderRetention) saved(key string, revision uint64, event string) bool { + r.mu.Lock() + defer r.mu.Unlock() + entry := r.rows[key] + if entry == nil { + return false + } + if event == renderEvEnd { + entry.event = renderEvCharge + } + if entry.revision != revision { + return false + } + entry.record = nil + entry.event = "" + if !entry.active { + delete(r.rows, key) + } + return true +} +func (r *renderRetention) get(path, id string) (RenderRecord, bool) { + r.mu.Lock() + defer r.mu.Unlock() + if entry := r.rows[renderRetentionKey(path, id)]; entry != nil && entry.record != nil { + return cloneRenderRecord(*entry.record), true + } + return RenderRecord{}, false +} +func (r *renderRetention) hasPending(path string) bool { + r.mu.Lock() + defer r.mu.Unlock() + for _, entry := range r.rows { + if entry.path == path && entry.record != nil { + return true + } + } + return false +} + +func cloneRenderRecord(record RenderRecord) RenderRecord { + if record.Ended != nil { + value := *record.Ended + record.Ended = &value + } + if record.DurationMS != nil { + value := *record.DurationMS + record.DurationMS = &value + } + if record.CostUSD != nil { + value := *record.CostUSD + record.CostUSD = &value + } + return record +} diff --git a/internal/session/renderlog.go b/internal/session/renderlog.go index b4305e38c4..03e1b83104 100644 --- a/internal/session/renderlog.go +++ b/internal/session/renderlog.go @@ -450,14 +450,13 @@ type RenderPage struct { // until that goroutine returns, because a provider that ignored a cancel may // still report what it charged. type renderLive struct { - rec RenderRecord - usage *ai.Usage // a charge reported before the ending, carried into it - dirty bool - released bool + rec RenderRecord + usage *ai.Usage // a charge reported before the ending, carried into it } type renderLedger struct { store renderStore + owner *renderRetention session string mu sync.Mutex file *os.File @@ -485,8 +484,12 @@ func openRenderLedger(path, session string, now time.Time) *renderLedger { // openRenderLedgerOn is [openRenderLedger] over a store with its own bounds. func openRenderLedgerOn(store renderStore, session string, now time.Time) *renderLedger { + return openRenderLedgerUsing(store, session, now, processRenderHistory) +} + +func openRenderLedgerUsing(store renderStore, session string, now time.Time, owner *renderRetention) *renderLedger { path := store.path - ledger := &renderLedger{store: store, session: session, live: map[string]*renderLive{}} + ledger := &renderLedger{store: store, owner: owner, session: strings.Clone(session), live: map[string]*renderLive{}} if path == "" { // A session with no folder (the legacy layout) has no durable home for // its history, so it gets a private temporary file that lives as long as @@ -499,6 +502,11 @@ func openRenderLedgerOn(store renderStore, session string, now time.Time) *rende } return ledger } + // Recover actual receipts from a former owner before interpreting its start + // records. A failed retry leaves the process-owned facts intact. + if _, err := ledger.flushPendingLocked(); err != nil { + return ledger + } // The left-running renders are marked interrupted only when the whole file // was read. If it could not be, nothing is marked and restoreErr says why: // those renders keep reading as unknown, never as running, and the next open @@ -565,25 +573,28 @@ func (l *renderLedger) flushPendingLocked() ([]RenderRecord, error) { if l.restoreErr != nil { return nil, l.restoreErr } - for id, entry := range l.live { - if !entry.dirty { - continue - } - rec := entry.rec + for _, pending := range l.owner.pending(l.store.path) { + rec := pending.record rec.Persistence = "" - if err := l.append(renderEvCharge, rec); err != nil { + if err := l.append(pending.event, rec); err != nil { return saved, err } - entry.rec, entry.dirty = rec, false - saved = append(saved, rec) - if entry.released { - delete(l.live, id) + if !l.owner.saved(pending.key, pending.revision, pending.event) { + continue } + if entry := l.live[rec.ID]; entry != nil { + entry.rec = rec + } + saved = append(saved, rec) + } + if l.owner.hasPending(l.store.path) { + return saved, ErrRenderBusy } return saved, nil } func (l *renderLedger) emit(rec RenderRecord) { + rec = cloneRenderRecord(rec) if l.notify != nil { l.notify(rec) } @@ -591,6 +602,11 @@ func (l *renderLedger) emit(rec RenderRecord) { // begin records a render's start and returns its stable id. func (l *renderLedger) begin(job int, kind, model, run, call string, now time.Time) string { + id, _ := l.beginChecked(job, kind, model, run, call, now) + return id +} + +func (l *renderLedger) beginChecked(job int, kind, model, run, call string, now time.Time) (string, error) { l.mu.Lock() var notices []RenderRecord defer func() { @@ -600,26 +616,30 @@ func (l *renderLedger) begin(job int, kind, model, run, call string, now time.Ti } }() if l.closed { - return "" + return "", errors.New("the render session has closed") } saved, err := l.flushPendingLocked() notices = append(notices, saved...) if err != nil { - return "" + return "", fmt.Errorf("pending render facts must be saved before another render starts: %w", err) } rec := RenderRecord{ ID: newRenderID(), Job: job, Session: l.session, Kind: kind, State: RenderRunning, - Model: model, Started: now, Run: run, Call: call, Charge: RenderChargeUnknown, + Model: strings.Clone(model), Started: now, Run: strings.Clone(run), Call: strings.Clone(call), Charge: RenderChargeUnknown, } - if rec.ID == "" { - return "" + if rec.ID == "" || len(model) > 1024 || len(run) > 1024 || len(call) > 1024 || len(l.session) > 1024 { + return "", errors.New("the render identity is unavailable or exceeds the metadata limit") + } + if !l.owner.reserve(l.store.path, rec.ID) { + return "", errors.New("the process has no room to retain another render outcome; restore storage for unsaved renders and retry") } if err := l.append(renderEvStart, rec); err != nil { - return "" + l.owner.release(l.store.path, rec.ID) + return "", fmt.Errorf("the render start could not be saved: %w", err) } l.live[rec.ID] = &renderLive{rec: rec} notices = append(notices, rec) - return rec.ID + return rec.ID, nil } func (l *renderLedger) applyEnd(rec *RenderRecord, patch renderPatch, now time.Time) { @@ -660,7 +680,8 @@ func (l *renderLedger) settle(id string, patch renderPatch, now time.Time) bool l.applyEnd(&entry.rec, patch, now) entry.rec.Persistence = "" if err := l.append(renderEvEnd, entry.rec); err != nil { - entry.rec.Persistence, entry.dirty = "unavailable", true + entry.rec.Persistence = "unavailable" + l.owner.retain(l.store.path, entry.rec, renderEvEnd) } rec := entry.rec l.mu.Unlock() @@ -694,7 +715,8 @@ func (l *renderLedger) charge(id string, usage *ai.Usage, now time.Time) { entry.rec.Charge, entry.rec.CostUSD = renderCharge(usage) entry.rec.Persistence = "" if err := l.append(renderEvCharge, entry.rec); err != nil { - entry.rec.Persistence, entry.dirty = "unavailable", true + entry.rec.Persistence = "unavailable" + l.owner.retain(l.store.path, entry.rec, renderEvCharge) } rec := entry.rec l.mu.Unlock() @@ -704,35 +726,36 @@ func (l *renderLedger) charge(id string, usage *ai.Usage, now time.Time) { // release says the render's goroutine has returned: nothing more can arrive. func (l *renderLedger) release(id string) { l.mu.Lock() - if entry := l.live[id]; entry != nil && entry.dirty { - entry.released = true - } else { - delete(l.live, id) - } + delete(l.live, id) + l.owner.release(l.store.path, id) l.mu.Unlock() } func (l *renderLedger) get(id string) (RenderRecord, error) { l.mu.Lock() if entry := l.live[id]; entry != nil { - rec := entry.rec + rec := cloneRenderRecord(entry.rec) l.mu.Unlock() return rec, nil } l.mu.Unlock() + if rec, ok := l.owner.get(l.store.path, id); ok { + return rec, nil + } rec, err := l.store.get(id) return l.settleUnresolved(rec), err } func (l *renderLedger) list(limit int, cursor string) (RenderPage, error) { l.mu.Lock() - for _, entry := range l.live { - if entry.dirty { - l.mu.Unlock() - return RenderPage{}, ErrRenderBusy - } - } + saved, flushErr := l.flushPendingLocked() l.mu.Unlock() + for _, rec := range saved { + l.emit(rec) + } + if flushErr != nil { + return RenderPage{}, ErrRenderBusy + } page, err := l.store.list(limit, cursor) if err != nil { return page, err @@ -740,7 +763,7 @@ func (l *renderLedger) list(limit int, cursor string) (RenderPage, error) { l.mu.Lock() for index, rec := range page.Renders { if entry := l.live[rec.ID]; entry != nil { - page.Renders[index] = entry.rec + page.Renders[index] = cloneRenderRecord(entry.rec) } } l.mu.Unlock() @@ -790,6 +813,9 @@ func ReadRenders(sessionDir string, limit int, cursor string) (RenderPage, error if path == "" { return RenderPage{Renders: []RenderRecord{}}, nil } + if processRenderHistory.hasPending(path) { + return RenderPage{}, ErrRenderBusy + } page, err := newRenderStore(path).list(limit, cursor) for index := range page.Renders { if page.Renders[index].State == RenderRunning { @@ -806,6 +832,9 @@ func ReadRender(sessionDir, id string) (RenderRecord, error) { if path == "" { return RenderRecord{}, ErrRenderNotFound } + if rec, ok := processRenderHistory.get(path, id); ok { + return rec, nil + } rec, err := newRenderStore(path).get(id) if rec.State == RenderRunning { rec.State = RenderUnknown From e288398d6f6fc61952ca6e1740f1b3879f7405f7 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 16 Oct 2026 00:18:49 -0400 Subject: [PATCH 142/459] Say each crew history task with the agent that held it Crew.history_tasks [{task, owner}] is the kept history ids with their original owner (the manager at creation; a layer from before falls back to the team's founder, else nothing is said). Crew.history strings are unchanged. Co-Authored-By: Claude Sonnet 5.5 --- cmd/codeaf/appcrewlayer.go | 10 ++++++---- cmd/codeaf/appcrews.go | 22 ++++++++++++++++++++++ cmd/codeaf/appcrews_test.go | 26 ++++++++++++++++++++++++++ internal/appwire/crews.go | 12 +++++++++++- 4 files changed, 65 insertions(+), 5 deletions(-) diff --git a/cmd/codeaf/appcrewlayer.go b/cmd/codeaf/appcrewlayer.go index 2fac9fd9e2..3aa49ad2b7 100644 --- a/cmd/codeaf/appcrewlayer.go +++ b/cmd/codeaf/appcrewlayer.go @@ -32,10 +32,12 @@ const appCrewLayerName = "crews.json" // appCrewExtra is what the layer keeps for one crew. type appCrewExtra struct { - Goal string `json:"goal,omitempty"` - EndsAt *time.Time `json:"ends_at,omitempty"` - History []string `json:"history,omitempty"` - Items []appwire.CrewItem `json:"items,omitempty"` + Goal string `json:"goal,omitempty"` + EndsAt *time.Time `json:"ends_at,omitempty"` + History []string `json:"history,omitempty"` + // HistoryOwner is the agent that held History's tasks when the crew was made. + HistoryOwner string `json:"history_owner,omitempty"` + Items []appwire.CrewItem `json:"items,omitempty"` } // appMemberExtra is what the layer keeps for one member of one crew. diff --git a/cmd/codeaf/appcrews.go b/cmd/codeaf/appcrews.go index c4f70a4aa2..8b8bfd8318 100644 --- a/cmd/codeaf/appcrews.go +++ b/cmd/codeaf/appcrews.go @@ -372,6 +372,7 @@ func (b *appBackend) crewView(read appCrewRead, t teamstore.Team) appwire.Crew { } if extra := read.layer.Crews[t.ID]; extra != nil { view.Goal, view.EndsAt, view.History = extra.Goal, extra.EndsAt, extra.History + view.HistoryTasks = crewHistoryTasks(extra, t.Founder) view.Items = append(view.Items, extra.Items...) } view.State = b.crewState(read, t, view) @@ -1292,10 +1293,31 @@ func (b *appBackend) crewExtraOnCreate(id, manager, goal string, ends *time.Time _ = b.crewLayer().write(func(f *appCrewLayerFile) error { extra := f.crew(id) extra.Goal, extra.EndsAt, extra.History = strings.TrimSpace(goal), ends, history + if len(history) > 0 { + extra.HistoryOwner = manager + } return nil }) } +// crewHistoryTasks pairs the kept task ids with the agent that held them. A +// layer from before the owner was kept falls back to the team's founder, the +// manager it was made under; with neither, nothing is said rather than guessed. +func crewHistoryTasks(extra *appCrewExtra, founder string) []appwire.CrewHistoryTask { + owner := extra.HistoryOwner + if owner == "" { + owner = founder + } + if owner == "" || len(extra.History) == 0 { + return nil + } + out := make([]appwire.CrewHistoryTask, 0, len(extra.History)) + for _, id := range extra.History { + out = append(out, appwire.CrewHistoryTask{Task: id, Owner: owner}) + } + return out +} + // crewReceipt says in the founding manager's conversation that the crew was // made. It says the fact the teams file keeps ([teamstore.Team.Founder], Made // and Name) and no words, so a restart reads the same entry back from diff --git a/cmd/codeaf/appcrews_test.go b/cmd/codeaf/appcrews_test.go index 9e63979d6c..4f66552fe0 100644 --- a/cmd/codeaf/appcrews_test.go +++ b/cmd/codeaf/appcrews_test.go @@ -887,3 +887,29 @@ func TestAppCrewSaysSinceWhenNoMemberHasRun(t *testing.T) { backend.locked(func() { backend.agents["b0b0b0b0"].view.UpdatedAt = later }) check(appwire.CrewNeedsYou, later) } + +func TestCrewHistoryTasksCarryTheirOwner(t *testing.T) { + kept := &appCrewExtra{History: []string{"7", "9"}, HistoryOwner: "agent-a"} + got := crewHistoryTasks(kept, "agent-b") + want := []appwire.CrewHistoryTask{{Task: "7", Owner: "agent-a"}, {Task: "9", Owner: "agent-a"}} + if !reflect.DeepEqual(got, want) { + t.Fatalf("owner kept at creation must win over the founder: %v", got) + } + // A layer from before the owner was kept: the founder, the manager it was made under. + legacy := &appCrewExtra{History: []string{"7"}} + if got := crewHistoryTasks(legacy, "agent-b"); len(got) != 1 || got[0].Owner != "agent-b" || got[0].Task != "7" { + t.Fatalf("legacy falls back to the founder: %v", got) + } + // Neither: nothing is guessed. No ids: nothing to say. + if got := crewHistoryTasks(legacy, ""); got != nil { + t.Fatalf("no owner, no records: %v", got) + } + if got := crewHistoryTasks(&appCrewExtra{HistoryOwner: "agent-a"}, "agent-b"); got != nil { + t.Fatalf("no ids, no records: %v", got) + } + // The legacy string list is still what the layer file keeps. + raw, _ := json.Marshal(kept) + if !strings.Contains(string(raw), `"history":["7","9"]`) { + t.Fatalf("legacy history lost: %s", raw) + } +} diff --git a/internal/appwire/crews.go b/internal/appwire/crews.go index 328349bf05..79d578f71f 100644 --- a/internal/appwire/crews.go +++ b/internal/appwire/crews.go @@ -139,7 +139,17 @@ type Crew struct { Goal string `json:"goal,omitempty"` EndsAt *time.Time `json:"ends_at,omitempty"` History []string `json:"history,omitempty"` - Items []CrewItem `json:"items,omitempty"` + // HistoryTasks is History with its owner: the tasks the founding manager + // had when the crew was made, each with the agent that owned it then. + HistoryTasks []CrewHistoryTask `json:"history_tasks,omitempty"` + Items []CrewItem `json:"items,omitempty"` +} + +// CrewHistoryTask is one task from before a crew was made. Task is its id in +// Owner's own task list; Owner is the agent that held it then and keeps it. +type CrewHistoryTask struct { + Task string `json:"task"` + Owner string `json:"owner"` } // CrewReportsTo is the manager a member answers to. From 6f0d832f8b5d11a408c4f483beebaeb0150f6071 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 16 Oct 2026 01:13:26 -0400 Subject: [PATCH 143/459] Recover original owners from persisted canonical crew history task IDs --- cmd/codeaf/appcrews.go | 28 +++++++++++++++++----------- cmd/codeaf/appcrews_test.go | 18 ++++++++++++++++-- 2 files changed, 33 insertions(+), 13 deletions(-) diff --git a/cmd/codeaf/appcrews.go b/cmd/codeaf/appcrews.go index 8b8bfd8318..d5005f3678 100644 --- a/cmd/codeaf/appcrews.go +++ b/cmd/codeaf/appcrews.go @@ -1300,20 +1300,26 @@ func (b *appBackend) crewExtraOnCreate(id, manager, goal string, ends *time.Time }) } -// crewHistoryTasks pairs the kept task ids with the agent that held them. A -// layer from before the owner was kept falls back to the team's founder, the -// manager it was made under; with neither, nothing is said rather than guessed. +// crewHistoryTasks uses the owner kept at creation, or the immutable owner +// embedded in a canonical legacy task ID. A changed/current manager is never a +// fallback. Very old local-only numbers require the team's original founder. func crewHistoryTasks(extra *appCrewExtra, founder string) []appwire.CrewHistoryTask { - owner := extra.HistoryOwner - if owner == "" { - owner = founder + fallback := extra.HistoryOwner + if fallback == "" { + fallback = founder } - if owner == "" || len(extra.History) == 0 { - return nil - } - out := make([]appwire.CrewHistoryTask, 0, len(extra.History)) + var out []appwire.CrewHistoryTask for _, id := range extra.History { - out = append(out, appwire.CrewHistoryTask{Task: id, Owner: owner}) + owner := fallback + if original, local, ok := appSplitID(id); ok { + if n, err := strconv.ParseUint(local, 10, 64); err == nil && n > 0 { + owner = original + } + } + if owner == "" { + continue + } + out = append(out, appwire.CrewHistoryTask{Task: appIndexTaskID(owner, id), Owner: owner}) } return out } diff --git a/cmd/codeaf/appcrews_test.go b/cmd/codeaf/appcrews_test.go index 4f66552fe0..22fcc96570 100644 --- a/cmd/codeaf/appcrews_test.go +++ b/cmd/codeaf/appcrews_test.go @@ -891,13 +891,13 @@ func TestAppCrewSaysSinceWhenNoMemberHasRun(t *testing.T) { func TestCrewHistoryTasksCarryTheirOwner(t *testing.T) { kept := &appCrewExtra{History: []string{"7", "9"}, HistoryOwner: "agent-a"} got := crewHistoryTasks(kept, "agent-b") - want := []appwire.CrewHistoryTask{{Task: "7", Owner: "agent-a"}, {Task: "9", Owner: "agent-a"}} + want := []appwire.CrewHistoryTask{{Task: "agent-a:7", Owner: "agent-a"}, {Task: "agent-a:9", Owner: "agent-a"}} if !reflect.DeepEqual(got, want) { t.Fatalf("owner kept at creation must win over the founder: %v", got) } // A layer from before the owner was kept: the founder, the manager it was made under. legacy := &appCrewExtra{History: []string{"7"}} - if got := crewHistoryTasks(legacy, "agent-b"); len(got) != 1 || got[0].Owner != "agent-b" || got[0].Task != "7" { + if got := crewHistoryTasks(legacy, "agent-b"); len(got) != 1 || got[0].Owner != "agent-b" || got[0].Task != "agent-b:7" { t.Fatalf("legacy falls back to the founder: %v", got) } // Neither: nothing is guessed. No ids: nothing to say. @@ -913,3 +913,17 @@ func TestCrewHistoryTasksCarryTheirOwner(t *testing.T) { t.Fatalf("legacy history lost: %s", raw) } } + +func TestCrewLegacyCanonicalHistoryNeedsNoFounder(t *testing.T) { + extra := &appCrewExtra{History: []string{"original:1", "original:3", "original:2"}} + want := []appwire.CrewHistoryTask{{Task: "original:1", Owner: "original"}, {Task: "original:3", Owner: "original"}, {Task: "original:2", Owner: "original"}} + if got := crewHistoryTasks(extra, ""); !reflect.DeepEqual(got, want) { + t.Fatalf("lost canonical original owner: %#v", got) + } + if got := crewHistoryTasks(extra, "replacement"); !reflect.DeepEqual(got, want) { + t.Fatalf("replacement founder cannot rewrite embedded ownership: %#v", got) + } + if got := crewHistoryTasks(&appCrewExtra{History: []string{"3 tasks from before"}}, ""); len(got) != 0 { + t.Fatalf("descriptive legacy text is not a task reference: %#v", got) + } +} From f5d2605e31e097a8f613bcb9970eb9a16bcdd673 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 22:07:26 -0400 Subject: [PATCH 144/459] app: an ask says whether anything waits on it, a held ask's clock waits until shown, agents.since counts what is new Ask.blocking is the engine's own derivation: true for a permission and for the model's question that parks its turn or names tasks, false only for one whose turn carried on and names no task, absent otherwise. asks.defer / asks.shown move the clock of a blocking:false ask through the remote Question.Clock door (session askclock.go): a deferred clock takes nothing, starts whole when shown, starts by itself after five minutes, and never undoes a person's hold. agents.since counts turns, entries and distinct changed files after one entry from the record's cached shapes, bounded at 400 calls. --- cmd/codeaf/appattention.go | 195 ++++++++++++++++++ cmd/codeaf/appattention_test.go | 161 +++++++++++++++ cmd/codeaf/appconvo.go | 19 ++ .../unreleased/app-calm-attention-clock.md | 10 + internal/appwire/attention.go | 98 +++++++++ internal/appwire/wire.go | 13 +- internal/remote/callclass.go | 2 +- internal/remote/questionclock_test.go | 90 ++++++++ internal/remote/questionlane.go | 17 ++ internal/remote/server.go | 29 +++ internal/remote/wire.go | 29 +++ internal/session/askclock.go | 105 ++++++++++ internal/session/askclock_test.go | 143 +++++++++++++ internal/session/asklane.go | 8 +- internal/session/askwait.go | 4 + 15 files changed, 918 insertions(+), 5 deletions(-) create mode 100644 cmd/codeaf/appattention.go create mode 100644 cmd/codeaf/appattention_test.go create mode 100644 docs/changes/unreleased/app-calm-attention-clock.md create mode 100644 internal/appwire/attention.go create mode 100644 internal/remote/questionclock_test.go create mode 100644 internal/session/askclock.go create mode 100644 internal/session/askclock_test.go diff --git a/cmd/codeaf/appattention.go b/cmd/codeaf/appattention.go new file mode 100644 index 0000000000..7b45301455 --- /dev/null +++ b/cmd/codeaf/appattention.go @@ -0,0 +1,195 @@ +package main + +// appattention.go — the facts a calm surface needs to hold a question that +// holds nothing up, and to say what is new since the person last read. +// +// TWO VERBS ON A QUESTION'S CLOCK. A surface holding a question until the +// person pauses defers its clock (asks.defer) and starts it when the question +// is finally shown (asks.shown); internal/session's askclock.go keeps the clock +// and its five-minute bound. Only a question the engine KNOWS holds nothing up +// is deferred ([appAskBlocking] false): anything else is refused, so a surface +// that guessed is told so rather than obeyed. +// +// ONE BOUNDED READING OF WHAT CAME AFTER A PLACE (agents.since): the turns, +// the entries and the distinct files changed after one entry of the record, +// counted from the same cached shapes a page is cut from. No page is dressed +// and nothing is sent but the counts. + +import ( + "context" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/remote" + "github.com/Agent-Field/codeaf/internal/session" +) + +// appClockWait is how long a clock verb waits for the conversation's host. +const appClockWait = 5 * time.Second + +// appSinceFacts is how many tool entries after the place have their facts +// read for the files count. Past it the count is not known and is not said. +const appSinceFacts = 400 + +// appQuestionClocker is the conversation's clock door; a [remote.Agent] is one. +type appQuestionClocker interface { + ClockQuestion(ctx context.Context, kind session.QuestionKind, token string, start bool) (remote.QuestionClockReply, error) +} + +// DeferAsk keeps one open ask's clock from starting until [appBackend.ShowAsk]. +func (b *appBackend) DeferAsk(agent, id string) (*appwire.Ask, error) { + return b.clockAsk(agent, id, false) +} + +// ShowAsk starts a deferred ask's clock now and answers the ask as it stands. +// An ask whose clock was never deferred is answered unchanged. +func (b *appBackend) ShowAsk(agent, id string) (*appwire.Ask, error) { + return b.clockAsk(agent, id, true) +} + +func (b *appBackend) clockAsk(agent, id string, start bool) (*appwire.Ask, error) { + a, err := b.find(agent) + if err != nil { + return nil, err + } + live := b.liveOf(a) + if live == nil { + return nil, appwire.Errorf(appwire.CodeNotFound, "the ask %s is not open", id) + } + var q session.Question + open := false + b.locked(func() { q, open = live.questions[id] }) + if !open { + return nil, appwire.Errorf(appwire.CodeNotFound, "the ask %s is not open", id) + } + if blocks := appAskBlocking(q); !start && (blocks == nil || *blocks) { + return nil, appwire.Errorf(appwire.CodeBadParams, "the ask %s is not known to hold nothing up, so its clock is not deferred", id) + } + clocker, ok := live.conn.Conv.(appQuestionClocker) + if !ok { + return nil, appwire.Errorf(appwire.CodeEngine, "this conversation cannot defer a question's clock") + } + ctx, cancel := context.WithTimeout(context.Background(), appClockWait) + defer cancel() + reply, err := clocker.ClockQuestion(ctx, q.Kind, q.Token(), start) + if err != nil { + return nil, appEngineError(err) + } + var ask *appwire.Ask + b.locked(func() { + q, open = live.questions[id] + if !open { + return + } + q.Deadline = reply.Deadline + live.questions[id] = q + shaped := appAskOf(agent, q) + appAskFacts(agent, &shaped, q) + shaped.HoldingUp = askHoldingUpLocked(live, shaped) + for i := range live.asks { + if live.asks[i].ID == id { + live.asks[i] = shaped + } + } + ask = &shaped + }) + return ask, nil +} + +// Since counts what the agent's record holds after one entry. +func (b *appBackend) Since(agent, after string) (appwire.Since, error) { + a, err := b.find(agent) + if err != nil { + return appwire.Since{}, err + } + after = strings.TrimSpace(after) + var transcript string + var at time.Time + marks := map[int]appTurnMark{} + b.locked(func() { + transcript, at = a.transcript, a.view.UpdatedAt + for n, mark := range a.marks { + marks[n] = mark + } + }) + if transcript == "" { + return appwire.Since{}, nil + } + var since appwire.Since + err = appRecords.WithRecord(transcript, func(record session.Record) error { + items := appRecordItems(record) + all, from := appEntryShapes(agent, items, at, marks) + since = appSinceOf(all, after, func(i int) []appwire.FileChange { + if i >= len(from) || from[i] < 0 { + return nil + } + return b.appEntryFacts(a, items[from[i]]).Files + }) + return nil + }) + if err != nil { + return appwire.Since{}, appEntriesBusy(err) + } + return since, nil +} + +// appSinceOf is [appBackend.Since] over shaped entries; files reads one tool +// entry's changed files and is called at most [appSinceFacts] times. +// +// A TURN is what the person sees as one: each of their messages opens one, and +// what the conversation did on its own before the next of their messages — a +// wake, a crew's delivery, the rest of a reply — is one more. +func appSinceOf(all []appwire.Entry, after string, files func(int) []appwire.FileChange) appwire.Since { + place := -1 + for i, entry := range all { + if entry.ID == after { + place = i + break + } + } + if place < 0 { + return appwire.Since{} + } + since := appwire.Since{Found: true} + read := all[place].At + since.At = &read + paths := map[string]bool{} + tools, turns, inTurn, over := 0, 0, false, false + for i := place + 1; i < len(all); i++ { + entry := all[i] + since.Entries++ + if since.First == "" { + since.First = entry.ID + } + last := entry.At + since.LastAt = &last + switch entry.Kind { + case appwire.EntryUser: + turns, inTurn = turns+1, true + case appwire.EntryAnswer, appwire.EntryUpdate, appwire.EntryTool: + if !inTurn { + turns, inTurn = turns+1, true + } + } + if entry.Kind != appwire.EntryTool || over { + continue + } + if tools == appSinceFacts { + over = true + continue + } + tools++ + for _, change := range files(i) { + if path := strings.TrimSpace(change.Path); path != "" { + paths[path] = true + } + } + } + since.Turns = turns + if !over { + n := len(paths) + since.Files = &n + } + return since +} diff --git a/cmd/codeaf/appattention_test.go b/cmd/codeaf/appattention_test.go new file mode 100644 index 0000000000..f2e47cc4cc --- /dev/null +++ b/cmd/codeaf/appattention_test.go @@ -0,0 +1,161 @@ +package main + +import ( + "context" + "errors" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/remote" + "github.com/Agent-Field/codeaf/internal/session" +) + +// WHAT BLOCKS IS ONLY WHAT THE ENGINE KNOWS. A permission blocks its call; the +// model's own question blocks when its turn parks or it names work; every +// other lane is unknown — never a guessed false from an empty holding_up. +func TestAppAskBlockingIsWhatTheEngineKnows(t *testing.T) { + is := func(b *bool, want string) bool { + switch want { + case "unknown": + return b == nil + case "true": + return b != nil && *b + } + return b != nil && !*b + } + for _, c := range []struct { + name string + q session.Question + want string + }{ + {"a permission", session.Question{Kind: session.QuestionConsent}, "true"}, + {"a parked question", session.Question{Kind: session.QuestionAsk, Blocking: session.Blocking{Turn: true}}, "true"}, + {"a question naming work", session.Question{Kind: session.QuestionAsk, Blocking: session.Blocking{Tasks: []string{"4"}}}, "true"}, + {"a question the turn carried on past", session.Question{Kind: session.QuestionAsk}, "false"}, + {"a proposed task", session.Question{Kind: session.QuestionTask}, "unknown"}, + {"a landing", session.Question{Kind: session.QuestionLanding}, "unknown"}, + {"a cap", session.Question{Kind: session.QuestionFuel}, "unknown"}, + } { + if got := appAskBlocking(c.q); !is(got, c.want) { + t.Errorf("%s: blocking = %v, want %s", c.name, got, c.want) + } + } +} + +// clockingConversation is the fake conversation with the host's clock door. +type clockingConversation struct { + *fakeConversation + until time.Time + err error + calls []bool +} + +func (c *clockingConversation) ClockQuestion(_ context.Context, _ session.QuestionKind, _ string, start bool) (remote.QuestionClockReply, error) { + c.calls = append(c.calls, start) + if c.err != nil { + return remote.QuestionClockReply{}, c.err + } + if start { + return remote.QuestionClockReply{Changed: true, Deadline: c.until}, nil + } + return remote.QuestionClockReply{Changed: true}, nil +} + +// A HELD ASK'S CLOCK WAITS IN THE ENGINE AND STARTS WHEN SHOWN, and the ask the +// surface draws carries the engine's own deadline; an ask the engine does not +// know holds nothing up is refused, and a host without the door says so. +func TestAppDeferAndShowAnAsksClock(t *testing.T) { + conv := &fakeConversation{} + backend, rec, agent := workBackend(t, conv) + until := time.Now().Add(time.Hour).Round(time.Second) + clocked := &clockingConversation{fakeConversation: conv, until: until} + backend.locked(func() { backend.agents[agent.ID].live.conn.Conv = clocked }) + q := session.Question{ID: 3, Kind: session.QuestionAsk, Ask: session.AskChoice, Head: "Which shape?", + Options: []session.AnswerOption{{Key: "a", Label: "A"}, {Key: "b", Label: "B"}}, + Pick: &session.Pick{Key: "a"}, Deadline: time.Now().Add(12 * time.Second)} + conv.raise(session.Event{Kind: session.EventQuestion, Question: &q}) + i := rec.waitFor(t, 5*time.Second, appAskEvent(appwire.EventAskOpened)) + rec.mu.Lock() + ask := rec.data[i].(appwire.AskOpened).Ask + rec.mu.Unlock() + if ask.Blocking == nil || *ask.Blocking { + t.Fatalf("a question that holds nothing up says blocking %v", ask.Blocking) + } + held, err := backend.DeferAsk(agent.ID, ask.ID) + if err != nil || held == nil || held.ClockUntil != nil { + t.Fatalf("defer = %+v %v", held, err) + } + shown, err := backend.ShowAsk(agent.ID, ask.ID) + if err != nil || shown == nil || shown.ClockUntil == nil || !shown.ClockUntil.Equal(until) { + t.Fatalf("shown = %+v %v", shown, err) + } + if len(clocked.calls) != 2 || clocked.calls[0] || !clocked.calls[1] { + t.Fatalf("the host heard %v", clocked.calls) + } + + consent := session.Question{ID: 4, Kind: session.QuestionConsent, Head: "Run go test?"} + conv.raise(session.Event{Kind: session.EventQuestion, Question: &consent}) + rec.waitFor(t, 5*time.Second, func(event string, data any) bool { + o, ok := data.(appwire.AskOpened) + return ok && o.Ask.Kind == appwire.AskApproval + }) + var permission string + backend.locked(func() { + for id, open := range backend.agents[agent.ID].live.questions { + if open.Kind == session.QuestionConsent { + permission = id + } + } + }) + var wire *appwire.Error + if _, err := backend.DeferAsk(agent.ID, permission); !errors.As(err, &wire) || wire.Code != appwire.CodeBadParams { + t.Fatalf("a permission's clock was deferred: %v", err) + } + clocked.err = errors.New("engine: no such method") + if _, err := backend.DeferAsk(agent.ID, ask.ID); err == nil { + t.Fatal("a host that cannot defer was read as having deferred") + } +} + +// WHAT CAME AFTER A PLACE IS COUNTED FROM THE SHAPES: the person's messages +// are turns, what the conversation did on its own first is one more, files are +// distinct paths, and past the bound the files count is not said at all. +func TestAppSinceCountsTurnsAndFilesAfterAPlace(t *testing.T) { + at := time.Date(2026, 10, 15, 9, 0, 0, 0, time.UTC) + all := []appwire.Entry{ + {ID: "u0", Kind: appwire.EntryUser, At: at}, + {ID: "a0.0", Kind: appwire.EntryAnswer, At: at.Add(time.Minute)}, + {ID: "tc1", Kind: appwire.EntryTool, At: at.Add(2 * time.Minute)}, + {ID: "a0.1", Kind: appwire.EntryAnswer, At: at.Add(3 * time.Minute)}, + {ID: "u1", Kind: appwire.EntryUser, At: at.Add(4 * time.Minute)}, + {ID: "tc2", Kind: appwire.EntryTool, At: at.Add(5 * time.Minute)}, + {ID: "a1.0", Kind: appwire.EntryAnswer, At: at.Add(6 * time.Minute)}, + } + files := map[int][]appwire.FileChange{2: {{Path: "a.go"}, {Path: "b.go"}}, 5: {{Path: "a.go"}}} + read := func(i int) []appwire.FileChange { return files[i] } + + since := appSinceOf(all, "a0.0", read) + if !since.Found || since.Turns != 2 || since.Entries != 5 || since.First != "tc1" || since.Files == nil || *since.Files != 2 { + t.Fatalf("since a0.0 = %+v", since) + } + if since.At == nil || !since.At.Equal(at.Add(time.Minute)) || since.LastAt == nil || !since.LastAt.Equal(at.Add(6*time.Minute)) { + t.Fatalf("the moments = %+v", since) + } + if nothing := appSinceOf(all, "a1.0", read); !nothing.Found || nothing.Turns != 0 || nothing.Entries != 0 || *nothing.Files != 0 { + t.Fatalf("nothing new = %+v", nothing) + } + if gone := appSinceOf(all, "a9.9", read); gone.Found || gone.Files != nil { + t.Fatalf("a place the record does not hold = %+v", gone) + } + + long := []appwire.Entry{{ID: "u0", Kind: appwire.EntryUser}} + reads := 0 + for n := 0; n <= appSinceFacts; n++ { + long = append(long, appwire.Entry{ID: "t" + time.Duration(n).String(), Kind: appwire.EntryTool}) + } + over := appSinceOf(long, "u0", func(int) []appwire.FileChange { reads++; return nil }) + if over.Files != nil || reads != appSinceFacts { + t.Fatalf("past the bound: files %v after %d reads", over.Files, reads) + } +} diff --git a/cmd/codeaf/appconvo.go b/cmd/codeaf/appconvo.go index 442c61dd8c..d747e6e15c 100644 --- a/cmd/codeaf/appconvo.go +++ b/cmd/codeaf/appconvo.go @@ -477,6 +477,25 @@ func appAskFacts(agent string, ask *appwire.Ask, q session.Question) { ask.ClockUntil = &until } ask.SayWhy = q.Kind == session.QuestionConsent + ask.Blocking = appAskBlocking(q) +} + +// appAskBlocking is what the engine knows waits on a question, for the two +// lanes that derive it rather than default it: a permission blocks its call, +// and the model's own question says whether its turn parks and which work +// waits ([session.Question.Blocking], set by the ask tool from whether the +// call really parks). Every other lane leaves Blocking at its zero value +// without having said nothing waits, so it is nil: unknown. +func appAskBlocking(q session.Question) *bool { + switch q.Kind { + case session.QuestionConsent: + blocks := true + return &blocks + case session.QuestionAsk: + blocks := q.Blocking.Blocks() + return &blocks + } + return nil } // batchLocked is the calls asked about together with q, open and answered, diff --git a/docs/changes/unreleased/app-calm-attention-clock.md b/docs/changes/unreleased/app-calm-attention-clock.md new file mode 100644 index 0000000000..2753d9a6ba --- /dev/null +++ b/docs/changes/unreleased/app-calm-attention-clock.md @@ -0,0 +1,10 @@ +--- +kind: added +title: an ask says whether anything waits on it, a held ask's clock waits until it is shown, and the app can count what is new since a place +pr: 0 +surface: [app, engine] +invalidates: + - "An app ask said nothing about whether work waited on it, and an empty holding_up was the only hint. Ask.blocking is now true for a permission and for the model's own question that parks its turn or names tasks, false only for the model's own question whose turn carried on and names no task, and absent (unknown, read as waiting) for every other kind." + - "A choice's clock started when the question was raised, so a surface that held a question until the person paused let the suggestion be taken unseen. asks.defer stops the clock of a blocking:false ask until asks.shown starts it for its whole length (remote Question.Clock); the engine starts a deferred clock by itself after five minutes, and a person's hold is never undone." + - "Counting what came after the place a person last read meant paging the whole conversation. agents.since {id, after} counts the turns, entries and distinct changed files after one entry from the record's cached shapes; files is absent past 400 calls." +--- diff --git a/internal/appwire/attention.go b/internal/appwire/attention.go new file mode 100644 index 0000000000..f0cdba76b6 --- /dev/null +++ b/internal/appwire/attention.go @@ -0,0 +1,98 @@ +package appwire + +// attention.go — what a surface needs to interrupt the person less: a clock +// that waits while it holds a question nothing waits on, and a count of what +// came after the place the person last read (cmd/codeaf's appattention.go). + +import ( + "strings" + "time" +) + +// Since is what an agent's record holds after one of its entries. Found false +// is an entry the record does not hold, and says nothing else. Files is the +// distinct paths the tool calls after it changed, nil when there were more +// calls than the engine reads for one answer: a count it did not make. +type Since struct { + Found bool `json:"found"` + // At is the moment of the entry the count starts after. + At *time.Time `json:"at,omitempty"` + Entries int `json:"entries"` + // Turns counts the person's messages after the entry, and one more for + // what the conversation did on its own before the first of them. + Turns int `json:"turns"` + Files *int `json:"files,omitempty"` + // First is the id of the first entry after it, and LastAt the moment of + // the newest. + First string `json:"first,omitempty"` + LastAt *time.Time `json:"last_at,omitempty"` +} + +// AskResult is one ask as it now stands, absent when it is no longer open. +type AskResult struct { + Ask *Ask `json:"ask,omitempty"` +} + +// Attender is the optional half of attention. An [Engine] that is not one +// refuses these methods as unknown. +type Attender interface { + // DeferAsk keeps an open ask's clock from starting; it refuses an ask the + // engine does not know holds nothing up (Ask.Blocking false). + DeferAsk(agent, id string) (*Ask, error) + // ShowAsk starts a deferred clock from now, for the policy's whole length. + ShowAsk(agent, id string) (*Ask, error) + Since(agent, after string) (Since, error) +} + +func attender(e Engine, name string) (Attender, error) { + a, ok := e.(Attender) + if !ok { + return nil, Errorf(CodeUnknownMethod, "this engine does not serve %s", name) + } + return a, nil +} + +// askAt is asks.defer and asks.shown's params. +type askAt struct { + Agent string `json:"agent"` + ID string `json:"id"` +} + +func askClock(name string, call func(Attender, string, string) (*Ask, error)) method { + return typed(func(e Engine, p askAt) (*AskResult, error) { + a, err := attender(e, name) + if err != nil { + return nil, err + } + if strings.TrimSpace(p.Agent) == "" || strings.TrimSpace(p.ID) == "" { + return nil, Errorf(CodeBadParams, "%s needs an agent and an id", name) + } + ask, err := call(a, p.Agent, p.ID) + return &AskResult{ask}, err + }) +} + +var attentionMethods = map[string]method{ + "asks.defer": askClock("asks.defer", Attender.DeferAsk), + "asks.shown": askClock("asks.shown", Attender.ShowAsk), + "agents.since": typed(func(e Engine, p struct { + ID string `json:"id"` + After string `json:"after"` + }) (*Since, error) { + a, err := attender(e, "agents.since") + if err != nil { + return nil, err + } + if strings.TrimSpace(p.ID) == "" || strings.TrimSpace(p.After) == "" { + return nil, Errorf(CodeBadParams, "agents.since needs an id and the entry to count after") + } + since, err := a.Since(p.ID, p.After) + return &since, err + }), +} + +func init() { + for name, m := range attentionMethods { + methods[name] = m + } +} diff --git a/internal/appwire/wire.go b/internal/appwire/wire.go index 914e633759..35ccf11ec1 100644 --- a/internal/appwire/wire.go +++ b/internal/appwire/wire.go @@ -236,8 +236,17 @@ type Ask struct { ClockUntil *time.Time `json:"clock_until,omitempty"` Batch []BatchItem `json:"batch,omitempty"` HoldingUp []string `json:"holding_up,omitempty"` - SayWhy bool `json:"say_why,omitempty"` - Later bool `json:"later,omitempty"` + // Blocking is whether the engine KNOWS something waits on this ask: true + // for a permission (its call is blocked) and for the model's own question + // that parks its turn or names work waiting on it; false only for the + // model's own question whose turn carries on and that names no work, the + // one kind whose answer arrives later as a message. Nil is unknown, which + // is every other kind and every older engine, and a surface reads unknown + // as waiting. An empty HoldingUp never says false: a parked reply waits + // on no task. + Blocking *bool `json:"blocking,omitempty"` + SayWhy bool `json:"say_why,omitempty"` + Later bool `json:"later,omitempty"` // Path is the crews a crew's decision climbed through to the person, // "Backend → Release → you". Path []string `json:"path,omitempty"` diff --git a/internal/remote/callclass.go b/internal/remote/callclass.go index 80b9bee380..89014d959d 100644 --- a/internal/remote/callclass.go +++ b/internal/remote/callclass.go @@ -170,7 +170,7 @@ func classify(method string) callClass { MethodAutomationsList, MethodAutomationsRuns, MethodAutomationsChanges, MethodAutomationsCursor, MethodAutomationsActive, MethodAutomationsWindows: return classGetter - case MethodAppTaskRoutingAct, MethodAppTaskAction, MethodAppQuickTaskAction, MethodAppMoveToTask, MethodAppResumeAfterQuit, MethodAppRename, MethodProcessRerun, MethodRenderCancel, MethodQuestionResolve, MethodQuestionHold, + case MethodAppTaskRoutingAct, MethodAppTaskAction, MethodAppQuickTaskAction, MethodAppMoveToTask, MethodAppResumeAfterQuit, MethodAppRename, MethodProcessRerun, MethodRenderCancel, MethodQuestionResolve, MethodQuestionHold, MethodQuestionClock, MethodConsent, MethodConsentRemember, MethodHarness, MethodConnect, MethodConnectKey, MethodNoteConnected, diff --git a/internal/remote/questionclock_test.go b/internal/remote/questionclock_test.go new file mode 100644 index 0000000000..28c01318ca --- /dev/null +++ b/internal/remote/questionclock_test.go @@ -0,0 +1,90 @@ +package remote + +// The clock a surface defers while it holds a question nothing waits on +// ([MethodQuestionClock], internal/session's askclock.go). + +import ( + "context" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/session" +) + +// clockingAgent is an [askingAgent] with the session's clock door. +type clockingAgent struct { + *askingAgent + calls []string +} + +func (a *clockingAgent) DeferQuestionClock(kind session.QuestionKind, token string) bool { + a.mu.Lock() + defer a.mu.Unlock() + a.calls = append(a.calls, "defer "+string(kind)+":"+token) + for i := range a.open { + if a.open[i].Kind == kind && a.open[i].Token() == token && !a.open[i].Deadline.IsZero() { + a.open[i].Deadline = time.Time{} + return true + } + } + return false +} + +func (a *clockingAgent) StartQuestionClock(kind session.QuestionKind, token string) bool { + a.mu.Lock() + defer a.mu.Unlock() + a.calls = append(a.calls, "start "+string(kind)+":"+token) + for i := range a.open { + if a.open[i].Kind == kind && a.open[i].Token() == token && a.open[i].Deadline.IsZero() { + a.open[i].Deadline = askedAt.Add(time.Hour) + return true + } + } + return false +} + +// THE CLOCK CROSSES AND ANSWERS WITH THE ENGINE'S OWN DEADLINE, so the surface +// draws the engine's countdown and not its own guess. +func TestAQuestionsClockIsDeferredAndStartedAcrossTheWire(t *testing.T) { + asked := wholeQuestion() + asked.Deadline = askedAt.Add(time.Minute) + far := &clockingAgent{askingAgent: newAskingAgent(asked)} + loop := laneLoop(t, far) + ctx := context.Background() + + reply, err := loop.Client.Agent().ClockQuestion(ctx, session.QuestionAsk, asked.Token(), false) + if err != nil { + t.Fatal(err) + } + if !reply.Changed || !reply.Deadline.IsZero() { + t.Fatalf("deferring answered %+v", reply) + } + reply, err = loop.Client.Agent().ClockQuestion(ctx, session.QuestionAsk, asked.Token(), true) + if err != nil { + t.Fatal(err) + } + if !reply.Changed || !reply.Deadline.Equal(askedAt.Add(time.Hour)) { + t.Fatalf("starting answered %+v", reply) + } + reply, err = loop.Client.Agent().ClockQuestion(ctx, session.QuestionAsk, asked.Token(), true) + if err != nil { + t.Fatal(err) + } + if reply.Changed || !reply.Deadline.Equal(askedAt.Add(time.Hour)) { + t.Fatalf("starting a running clock answered %+v", reply) + } + if got := strings.Join(far.calls, ", "); got != "defer ask:7, start ask:7, start ask:7" { + t.Fatalf("the engine heard %s", got) + } +} + +// AN ENGINE WITHOUT THE DOOR REFUSES, and never answers a quiet yes: a surface +// told a clock was deferred would hold a question whose clock still runs. +func TestAnEngineThatCannotDeferAClockRefuses(t *testing.T) { + asked := wholeQuestion() + loop := laneLoop(t, newAskingAgent(asked)) + if _, err := loop.Client.Agent().ClockQuestion(context.Background(), session.QuestionAsk, asked.Token(), false); err == nil { + t.Fatal("an engine with no clock door said the clock was deferred") + } +} diff --git a/internal/remote/questionlane.go b/internal/remote/questionlane.go index daa31b5865..ec14a7aeb8 100644 --- a/internal/remote/questionlane.go +++ b/internal/remote/questionlane.go @@ -39,6 +39,7 @@ package remote // ([Client.buryLanes]). import ( + "context" "encoding/json" "sync" @@ -167,3 +168,19 @@ func (a *Agent) HoldQuestion(kind session.QuestionKind, token string) { _, _ = c.call(nil, MethodQuestionHold, QuestionHoldArgs{Kind: kind, Token: token}) }() } + +// ClockQuestion defers one question's clock on the far machine, or starts a +// deferred one (start), and answers what the engine's clock now says +// ([MethodQuestionClock]). It waits for the answer because the caller is about +// to draw it; an engine without the method is an error, never a quiet yes. +func (a *Agent) ClockQuestion(ctx context.Context, kind session.QuestionKind, token string, start bool) (QuestionClockReply, error) { + raw, err := a.c.call(ctx, MethodQuestionClock, QuestionClockArgs{Kind: kind, Token: token, Start: start}) + if err != nil { + return QuestionClockReply{}, err + } + var reply QuestionClockReply + if err := json.Unmarshal(raw, &reply); err != nil { + return QuestionClockReply{}, err + } + return reply, nil +} diff --git a/internal/remote/server.go b/internal/remote/server.go index dc2176d32c..a028cd2b13 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -2386,6 +2386,35 @@ func (s *server) invoke(call Frame) (out json.RawMessage, err error) { } door.HoldQuestion(args.Kind, args.Token) return nil, nil + case MethodQuestionClock: + args, err := arg[QuestionClockArgs](call) + if err != nil { + return nil, err + } + // A MISSING DOOR REFUSES here, where Hold's costs nothing: a surface + // told this clock was deferred would hold a question whose suggestion + // is still being counted down unseen. + door, ok := agent.(interface { + DeferQuestionClock(session.QuestionKind, string) bool + StartQuestionClock(session.QuestionKind, string) bool + OpenQuestions() []session.Question + }) + if !ok { + return nil, errors.New("engine: this session cannot defer a question's clock") + } + changed := false + if args.Start { + changed = door.StartQuestionClock(args.Kind, args.Token) + } else { + changed = door.DeferQuestionClock(args.Kind, args.Token) + } + reply := QuestionClockReply{Changed: changed} + for _, q := range door.OpenQuestions() { + if q.Kind == args.Kind && q.Token() == args.Token { + reply.Deadline = q.Deadline + } + } + return json.Marshal(reply) case MethodAutonomy: // THE READ SIDE OF THE ROW BELOW. It is asserted rather than called on diff --git a/internal/remote/wire.go b/internal/remote/wire.go index d1cb394077..4ccf2528fc 100644 --- a/internal/remote/wire.go +++ b/internal/remote/wire.go @@ -550,6 +550,18 @@ const ( // says the question again with its deadline gone, so every window stops // counting from the same frame rather than from its own guess. MethodQuestionHold = "Question.Hold" // QuestionHoldArgs → nothing + // MethodQuestionClock is a surface saying it has NOT shown a question yet, + // or that it now has: the first stops the question's clock until the + // second starts it for the policy's whole length from then + // (internal/session's askclock.go). It is not a hold — it is undone, and + // the engine undoes it itself after its own bound. + // + // IT ANSWERS, unlike [MethodQuestionHold], because the surface asking it + // is about to draw the countdown and must draw the engine's deadline and + // not its own guess. An engine without it says no such method, and a + // surface that is told so shows the question at once rather than holding + // it on a clock it cannot stop. + MethodQuestionClock = "Question.Clock" // QuestionClockArgs → QuestionClockReply // MethodQuestionWatch is the surface saying it draws questions, and it buys // exactly what [MethodTaskWatch] and [MethodDesignWatch] buy: "question" // frames from here on, including everything already open replayed the @@ -1955,6 +1967,23 @@ type QuestionHoldArgs struct { Token string `json:"token"` } +// QuestionClockArgs names one question the way [QuestionHoldArgs] does, and +// says which way its clock goes: Start false defers it, Start true starts it. +type QuestionClockArgs struct { + Kind session.QuestionKind `json:"kind"` + Token string `json:"token"` + Start bool `json:"start,omitempty"` +} + +// QuestionClockReply is what the clock did: Changed is false for a question +// the call could not move (answered, held by a person, never clocked, or +// already that way), and Deadline is the clock's end after the call, zero +// while it is deferred or has none. +type QuestionClockReply struct { + Changed bool `json:"changed"` + Deadline time.Time `json:"deadline,omitempty"` +} + type HarnessArgs struct { ID uint64 `json:"id"` Run bool `json:"run"` diff --git a/internal/session/askclock.go b/internal/session/askclock.go new file mode 100644 index 0000000000..39ac93fe19 --- /dev/null +++ b/internal/session/askclock.go @@ -0,0 +1,105 @@ +package session + +// askclock.go — A CLOCK THAT WAITS FOR ITS QUESTION TO BE SEEN. +// +// A choice with a suggested answer runs on a clock, and the clock starts when +// the question is raised ([Agent.startAskClock]). That is right for a surface +// that draws every question the moment it is raised. A surface that holds a +// question nothing waits on until the person pauses — so a reply carrying on +// in the background does not interrupt them mid-word — would let the clock take +// the suggestion for somebody who never saw it. +// +// So a surface may DEFER the clock of a question it is holding, and START it +// when the question is finally shown, for the policy's own length counted from +// then. It is not [Agent.HoldQuestion]: that is a person's key on a question +// they are reading and is never undone, while this is a surface saying it has +// not shown the question yet, and is undone the moment it has. +// +// IT IS BOUNDED. A deferred clock starts by itself after [askClockDeferLimit], +// so a surface that deferred and then went away cannot leave a suggestion +// unclocked for the life of the conversation. + +import ( + "strconv" + "strings" + "time" +) + +// askClockDeferLimit is the longest a surface may keep a question's clock from +// starting. It is the same five minutes a surface holds a question for at most +// (docs/design/app-v3 V84), so the two bounds end together. +// +// It is a var only so the tests can run it out in a millisecond. NOTHING IN A +// BUILD WRITES IT. +var askClockDeferLimit = 5 * time.Minute + +// DeferQuestionClock stops the clock of one open question a surface has not +// shown yet, and reports whether it did. It does nothing to a question with no +// running clock, one a person held, one already answered or deferred, and to +// every lane but the model's own question, which is the only lane whose clock +// lives here. +func (a *Agent) DeferQuestionClock(kind QuestionKind, token string) bool { + if child, answer, ok := a.discussionAnswer(Answer{Kind: kind, Ref: token}); ok { + return child.DeferQuestionClock(kind, answerToken(answer)) + } + id, ok := askClockID(kind, token) + if !ok { + return false + } + a.mu.Lock() + open := a.asked.atLocked(id) + if open == nil || open.settled != nil || open.held || open.deferred || open.clock == nil || open.q.Deadline.IsZero() || a.closed { + a.mu.Unlock() + return false + } + open.clock.Stop() + open.deferred = true + open.q.Deadline = time.Time{} + // THE BOUND IS THE SAME TIMER, so a deferred question carries one timer + // exactly as a clocked one does and Close stops it the same way. + open.clock = time.AfterFunc(askClockDeferLimit, func() { a.StartQuestionClock(QuestionAsk, strconv.FormatUint(id, 10)) }) + q := open.q + a.mu.Unlock() + a.restateQuestion(q) + return true +} + +// StartQuestionClock starts a deferred clock now, for the policy's own length, +// and reports whether it did. A question that was not deferred is untouched: +// its clock is already running, or a person held it, or it never had one. +func (a *Agent) StartQuestionClock(kind QuestionKind, token string) bool { + if child, answer, ok := a.discussionAnswer(Answer{Kind: kind, Ref: token}); ok { + return child.StartQuestionClock(kind, answerToken(answer)) + } + id, ok := askClockID(kind, token) + if !ok { + return false + } + a.mu.Lock() + open := a.asked.atLocked(id) + if open == nil || open.settled != nil || open.held || !open.deferred || a.closed { + a.mu.Unlock() + return false + } + if open.clock != nil { + open.clock.Stop() + } + open.deferred = false + after := open.q.Policy.After + open.q.Deadline = time.Now().Add(after) + open.clock = time.AfterFunc(after, func() { a.askClockRanOut(id) }) + q := open.q + a.mu.Unlock() + a.restateQuestion(q) + return true +} + +// askClockID reads the model's own question's token; every other lane's clock +// is not this file's. +func askClockID(kind QuestionKind, token string) (uint64, bool) { + if kind != QuestionAsk { + return 0, false + } + id, err := strconv.ParseUint(strings.TrimSpace(token), 10, 64) + return id, err == nil +} diff --git a/internal/session/askclock_test.go b/internal/session/askclock_test.go new file mode 100644 index 0000000000..12a5a882b6 --- /dev/null +++ b/internal/session/askclock_test.go @@ -0,0 +1,143 @@ +package session + +// The laws of a clock that waits for its question to be seen (askclock.go). + +import ( + "context" + "encoding/json" + "testing" + "time" +) + +// clockedAsk is one open question nobody is parked on, on an hour's clock, so +// nothing here races the wall: every clock is run by hand. +func clockedAsk(t *testing.T) (*Agent, Question) { + t.Helper() + a := askTestAgent(t, true) + if err := a.SetAutonomy(AskChoice, Policy{Kind: PolicyRecommendThenAuto, After: time.Hour}); err != nil { + t.Fatal(err) + } + if _, _, err := a.executeAsk(context.Background(), json.RawMessage(askNotWaiting)); err != nil { + t.Fatal(err) + } + q := waitForOneQuestion(t, a) + if q.Deadline.IsZero() { + t.Fatal("the question carries no clock") + } + return a, q +} + +// A DEFERRED CLOCK TAKES NOTHING, AND STARTS FROM WHEN IT IS SHOWN. The +// suggestion is never taken for somebody who has not seen the question, and +// the person who finally sees it gets the policy's whole length. +func TestADeferredClockTakesNothingAndStartsWholeWhenShown(t *testing.T) { + a, q := clockedAsk(t) + if !a.DeferQuestionClock(QuestionAsk, q.Token()) { + t.Fatal("the clock was not deferred") + } + if got := waitForOneQuestion(t, a); !got.Deadline.IsZero() { + t.Fatalf("a deferred question still says when its clock ends: %v", got.Deadline) + } + // A timer that fired as the clock was deferred lost that race. + a.askClockRanOut(q.ID) + if notes := askDelivered(a); len(notes) > 0 { + t.Fatalf("a deferred clock answered a question nobody had seen: %v", notes) + } + if a.DeferQuestionClock(QuestionAsk, q.Token()) { + t.Fatal("a clock already deferred was deferred again") + } + shown := time.Now() + if !a.StartQuestionClock(QuestionAsk, q.Token()) { + t.Fatal("the deferred clock did not start") + } + got := waitForOneQuestion(t, a) + if got.Deadline.Before(shown.Add(time.Hour)) { + t.Fatalf("the clock did not get its whole length from being shown: %v, shown at %v", got.Deadline, shown) + } + if a.StartQuestionClock(QuestionAsk, q.Token()) { + t.Fatal("a running clock was started a second time") + } + a.askClockRanOut(q.ID) + if notes := askDelivered(a); len(notes) == 0 { + t.Fatal("the started clock never took the pick") + } +} + +// THE DEFERRAL IS BOUNDED: a surface that deferred and went away cannot keep a +// suggestion unclocked. The bound is the same one timer, so it is run here by +// shortening it rather than by sleeping five minutes. +func TestADeferredClockStartsByItselfAtItsBound(t *testing.T) { + saved := askClockDeferLimit + askClockDeferLimit = time.Millisecond + t.Cleanup(func() { askClockDeferLimit = saved }) + a, q := clockedAsk(t) + if !a.DeferQuestionClock(QuestionAsk, q.Token()) { + t.Fatal("the clock was not deferred") + } + deadline := time.After(2 * time.Second) + for { + if open := a.OpenQuestions(); len(open) == 1 && !open[0].Deadline.IsZero() { + return + } + select { + case <-deadline: + t.Fatal("the deferred clock never started at its bound") + default: + time.Sleep(time.Millisecond) + } + } +} + +// A PERSON'S HOLD WINS AND IS NEVER UNDONE: showing the question later does +// not start a clock somebody stopped. +func TestShowingAHeldQuestionDoesNotStartItsClock(t *testing.T) { + a, q := clockedAsk(t) + if !a.DeferQuestionClock(QuestionAsk, q.Token()) { + t.Fatal("the clock was not deferred") + } + a.HoldQuestion(QuestionAsk, q.Token()) + if a.StartQuestionClock(QuestionAsk, q.Token()) { + t.Fatal("showing a held question started its clock") + } + if a.DeferQuestionClock(QuestionAsk, q.Token()) { + t.Fatal("a held question's clock was deferred") + } + if got := waitForOneQuestion(t, a); !got.Deadline.IsZero() { + t.Fatalf("a held question carries a deadline: %v", got.Deadline) + } +} + +// ONLY THE LANE'S OWN CLOCK IS ITS TO DEFER: a parked question has no clock +// here to stop, and another lane's token is not read as this one's. +func TestOnlyAClockedAskOfThisLaneIsDeferred(t *testing.T) { + a, q := clockedAsk(t) + if a.DeferQuestionClock(QuestionConsent, q.Token()) { + t.Fatal("another lane's token deferred the ask's clock") + } + if a.DeferQuestionClock(QuestionAsk, "not a number") { + t.Fatal("an unreadable token deferred a clock") + } + plain := askTestAgent(t, true) + if _, _, err := plain.executeAsk(context.Background(), json.RawMessage(askNotWaiting)); err != nil { + t.Fatal(err) + } + unclocked := waitForOneQuestion(t, plain) + if plain.DeferQuestionClock(QuestionAsk, unclocked.Token()) { + t.Fatal("a question with no clock was deferred") + } +} + +// WHAT IS BLOCKING IS THE ASK TOOL'S OWN DERIVATION: a turn that carries on +// and names no work is the one shape nothing waits on. +func TestOnlyACarriedOnAskNamingNoWorkBlocksNothing(t *testing.T) { + a, q := clockedAsk(t) + _ = a + if q.Blocking.Blocks() { + t.Fatal("an ask whose turn carried on and names no work says it blocks") + } + parked := askTestAgent(t, true) + go func() { _, _, _ = parked.executeAsk(context.Background(), json.RawMessage(askWaiting)) }() + if got := waitForOneQuestion(t, parked); !got.Blocking.Blocks() { + t.Fatal("an ask the turn parked on says it blocks nothing") + } +} diff --git a/internal/session/asklane.go b/internal/session/asklane.go index 0940fb532d..f3bab8349c 100644 --- a/internal/session/asklane.go +++ b/internal/session/asklane.go @@ -237,7 +237,10 @@ func (a *Agent) stopAskClocksLocked() { a.asked.stopClocksLocked() } func (a *Agent) askClockRanOut(id uint64) { a.mu.Lock() open := a.asked.atLocked(id) - if open == nil || open.settled != nil || open.held || a.closed { + if open == nil || open.settled != nil || open.held || open.deferred || a.closed { + // A DEFERRED CLOCK IS NOT RUNNING: a timer that fired as the surface + // deferred it lost that race (askclock.go). + // // NOTHING THIS CLOCK COULD SAY WOULD REACH ANYBODY once the session has // closed, and a decision nobody read is worse than no decision: the next // run of this conversation reads the record as something the person @@ -288,11 +291,12 @@ func (a *Agent) HoldQuestion(kind QuestionKind, token string) { func (a *Agent) holdAsk(id uint64) { a.mu.Lock() open := a.asked.atLocked(id) - if open == nil || open.settled != nil || open.held || open.q.Deadline.IsZero() { + if open == nil || open.settled != nil || open.held || (open.q.Deadline.IsZero() && !open.deferred) { a.mu.Unlock() return } open.held = true + open.deferred = false if open.clock != nil { open.clock.Stop() } diff --git a/internal/session/askwait.go b/internal/session/askwait.go index 4d8789e2a9..17b6676379 100644 --- a/internal/session/askwait.go +++ b/internal/session/askwait.go @@ -104,6 +104,10 @@ type askOpen struct { // answer it. There is no way back: a clock that resumed would fire exactly // when somebody looked away mid-decision. held bool + // deferred says a surface has not shown the question yet and its clock waits + // for it to be (askclock.go). Unlike held it is undone: by the surface + // showing it, or by the bound running out. + deferred bool // settled is the answer that ended it, and nil while it is still open. It is // kept because a settled reversible decision can still be CHANGED, and the // correction says what it was changed from. From af02a9c1e49a0ad494825ca686677c055b77513c Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 22:13:54 -0400 Subject: [PATCH 145/459] session: a stale ask-clock timer decides nothing, and a blocking question is never deferred time.Timer.Stop cannot recall a callback already waiting on a.mu. Every arming of a question's timer (the policy clock and the deferral bound) now carries a generation, and a callback whose generation is not the live one does nothing: an old clock cannot take the pick against the deadline a deferral and show set, and an earlier deferral's bound cannot start a later one. DeferQuestionClock also refuses any question whose own Blocking says a turn or work waits on it. --- internal/session/askclock.go | 23 ++++++---- internal/session/askclock_test.go | 76 +++++++++++++++++++++++++++++++ internal/session/asklane.go | 35 +++++++++++--- internal/session/askwait.go | 3 ++ 4 files changed, 123 insertions(+), 14 deletions(-) diff --git a/internal/session/askclock.go b/internal/session/askclock.go index 39ac93fe19..179364cfeb 100644 --- a/internal/session/askclock.go +++ b/internal/session/askclock.go @@ -38,6 +38,10 @@ var askClockDeferLimit = 5 * time.Minute // running clock, one a person held, one already answered or deferred, and to // every lane but the model's own question, which is the only lane whose clock // lives here. +// +// AND NOTHING TO A QUESTION THAT BLOCKS. A turn parked on it or work named as +// waiting on it ([Blocking.Blocks]) is exactly what must reach the person at +// once, so the engine refuses to defer it whatever the surface believed. func (a *Agent) DeferQuestionClock(kind QuestionKind, token string) bool { if child, answer, ok := a.discussionAnswer(Answer{Kind: kind, Ref: token}); ok { return child.DeferQuestionClock(kind, answerToken(answer)) @@ -48,16 +52,15 @@ func (a *Agent) DeferQuestionClock(kind QuestionKind, token string) bool { } a.mu.Lock() open := a.asked.atLocked(id) - if open == nil || open.settled != nil || open.held || open.deferred || open.clock == nil || open.q.Deadline.IsZero() || a.closed { + if open == nil || open.settled != nil || open.held || open.deferred || open.clock == nil || open.q.Deadline.IsZero() || open.q.Blocking.Blocks() || a.closed { a.mu.Unlock() return false } - open.clock.Stop() open.deferred = true open.q.Deadline = time.Time{} // THE BOUND IS THE SAME TIMER, so a deferred question carries one timer // exactly as a clocked one does and Close stops it the same way. - open.clock = time.AfterFunc(askClockDeferLimit, func() { a.StartQuestionClock(QuestionAsk, strconv.FormatUint(id, 10)) }) + armAskClockLocked(open, askClockDeferLimit, func(gen uint64) { a.startDeferredClock(id, gen, true) }) q := open.q a.mu.Unlock() a.restateQuestion(q) @@ -75,19 +78,23 @@ func (a *Agent) StartQuestionClock(kind QuestionKind, token string) bool { if !ok { return false } + return a.startDeferredClock(id, 0, false) +} + +// startDeferredClock is [Agent.StartQuestionClock] by id; armed is the bound's +// own timer, which starts nothing unless it is still the live arming — a bound +// from an earlier deferral must not start a later one early. +func (a *Agent) startDeferredClock(id, gen uint64, armed bool) bool { a.mu.Lock() open := a.asked.atLocked(id) - if open == nil || open.settled != nil || open.held || !open.deferred || a.closed { + if open == nil || open.settled != nil || open.held || !open.deferred || a.closed || (armed && open.clockGen != gen) { a.mu.Unlock() return false } - if open.clock != nil { - open.clock.Stop() - } open.deferred = false after := open.q.Policy.After open.q.Deadline = time.Now().Add(after) - open.clock = time.AfterFunc(after, func() { a.askClockRanOut(id) }) + armAskClockLocked(open, after, func(gen uint64) { a.askClockEnded(id, gen, true) }) q := open.q a.mu.Unlock() a.restateQuestion(q) diff --git a/internal/session/askclock_test.go b/internal/session/askclock_test.go index 12a5a882b6..fe58de1c15 100644 --- a/internal/session/askclock_test.go +++ b/internal/session/askclock_test.go @@ -127,6 +127,82 @@ func TestOnlyAClockedAskOfThisLaneIsDeferred(t *testing.T) { } } +// clockGen is the live arming of one question's timer. +func clockGen(a *Agent, id uint64) uint64 { + a.mu.Lock() + defer a.mu.Unlock() + return a.asked.atLocked(id).clockGen +} + +// A TIMER STOPPED TOO LATE DECIDES NOTHING. Its callback had begun and was +// waiting on the lock while the clock was deferred and shown again; that +// callback is exactly a call carrying the arming it was made with, so it is +// made here by hand — no sleep, no race — and it must not take the pick +// against the new deadline. The live arming still does. +func TestAStaleClockCallbackCannotAnswerAfterDeferAndShow(t *testing.T) { + a, q := clockedAsk(t) + stale := clockGen(a, q.ID) + if !a.DeferQuestionClock(QuestionAsk, q.Token()) || !a.StartQuestionClock(QuestionAsk, q.Token()) { + t.Fatal("the clock was not deferred and shown") + } + shown := waitForOneQuestion(t, a).Deadline + a.askClockEnded(q.ID, stale, true) + if notes := askDelivered(a); len(notes) > 0 { + t.Fatalf("the old clock's callback took the pick before the new deadline: %v", notes) + } + if got := waitForOneQuestion(t, a); !got.Deadline.Equal(shown) { + t.Fatalf("the old callback moved the new deadline: %v, want %v", got.Deadline, shown) + } + a.askClockEnded(q.ID, clockGen(a, q.ID), true) + if notes := askDelivered(a); len(notes) == 0 { + t.Fatal("the live clock never took the pick") + } +} + +// A BOUND FROM AN EARLIER DEFERRAL DOES NOT START A LATER ONE: deferred, +// shown, deferred again, the first bound's callback finds itself stale. +func TestAStaleBoundCannotStartALaterDeferral(t *testing.T) { + a, q := clockedAsk(t) + if !a.DeferQuestionClock(QuestionAsk, q.Token()) { + t.Fatal("the clock was not deferred") + } + firstBound := clockGen(a, q.ID) + if !a.StartQuestionClock(QuestionAsk, q.Token()) || !a.DeferQuestionClock(QuestionAsk, q.Token()) { + t.Fatal("the clock was not shown and deferred again") + } + if a.startDeferredClock(q.ID, firstBound, true) { + t.Fatal("an earlier deferral's bound started the clock") + } + if got := waitForOneQuestion(t, a); !got.Deadline.IsZero() { + t.Fatalf("the question is clocked while deferred: %v", got.Deadline) + } + if !a.startDeferredClock(q.ID, clockGen(a, q.ID), true) { + t.Fatal("the live bound did not start the clock") + } +} + +// A QUESTION THAT BLOCKS IS NEVER DEFERRED, whatever a surface believed: the +// engine reads its own Blocking and not a hint. +func TestABlockingQuestionsClockIsNeverDeferred(t *testing.T) { + for name, raw := range map[string]string{ + "the turn parks on it": askWaiting, + "it names waiting work": `{"head":"Which storage shape?","kind":"choice","reason":"two fit","stakes":"reversible","blocking":{"turn":false,"tasks":["port the parser"]},"options":[{"key":"1","label":"sqlite"},{"key":"2","label":"jsonl"}],"pick":{"key":"1","reason":"sqlite readers"}}`, + } { + a := askTestAgent(t, true) + if err := a.SetAutonomy(AskChoice, Policy{Kind: PolicyRecommendThenAuto, After: time.Hour}); err != nil { + t.Fatal(err) + } + go func() { _, _, _ = a.executeAsk(context.Background(), json.RawMessage(raw)) }() + q := waitForOneQuestion(t, a) + if !q.Blocking.Blocks() || q.Deadline.IsZero() { + t.Fatalf("%s: the question blocks %v with deadline %v; this test needs a clocked blocking question", name, q.Blocking.Blocks(), q.Deadline) + } + if a.DeferQuestionClock(QuestionAsk, q.Token()) { + t.Fatalf("%s: a blocking question's clock was deferred", name) + } + } +} + // WHAT IS BLOCKING IS THE ASK TOOL'S OWN DERIVATION: a turn that carries on // and names no work is the one shape nothing waits on. func TestOnlyACarriedOnAskNamingNoWorkBlocksNothing(t *testing.T) { diff --git a/internal/session/asklane.go b/internal/session/asklane.go index f3bab8349c..421b0fb2ad 100644 --- a/internal/session/asklane.go +++ b/internal/session/asklane.go @@ -213,10 +213,28 @@ func (a *Agent) startAskClock(open *askOpen) { a.mu.Unlock() return } - open.clock = time.AfterFunc(open.q.Policy.After, func() { a.askClockRanOut(id) }) + armAskClockLocked(open, open.q.Policy.After, func(gen uint64) { a.askClockEnded(id, gen, true) }) a.mu.Unlock() } +// armAskClockLocked is the only way a question's timer is set: it stops the +// one before, and the timer it arms carries its own arming so a callback of an +// older one can tell it is stale. +// +// A STOPPED TIMER CAN STILL CALL. [time.Timer.Stop] reports false for a timer +// whose callback has already begun, and that callback may be waiting on a.mu +// while the clock is deferred and started again; read after, its question +// looks clocked and it would take the pick against the NEW deadline. So +// every callback names its arming and does nothing unless it is still the one. +func armAskClockLocked(open *askOpen, after time.Duration, fire func(gen uint64)) { + if open.clock != nil { + open.clock.Stop() + } + open.clockGen++ + gen := open.clockGen + open.clock = time.AfterFunc(after, func() { fire(gen) }) +} + // stopAskClocksLocked is the session's own end of the book's close, called from // [Agent.Close] beside [Agent.stopSteerGraceLocked] — the two laws are one law, // and steer_grace.go's file comment states it: NOTHING ARMED OUTLIVES THE @@ -233,13 +251,18 @@ func (a *Agent) stopAskClocksLocked() { a.asked.stopClocksLocked() } // askClockRanOut takes the asker's own pick for the person, through the one door // every other answer goes through — so it is recorded, announced and delivered -// exactly as a key press would be, and marked as what it is. -func (a *Agent) askClockRanOut(id uint64) { +// exactly as a key press would be, and marked as what it is. It is the clock +// running now that ran out; a timer's own callback is [Agent.askClockEnded]. +func (a *Agent) askClockRanOut(id uint64) { a.askClockEnded(id, 0, false) } + +// askClockEnded is one timer's end: gen is the arming it carries, read only +// when armed, so a stale timer decides nothing ([armAskClockLocked]). +func (a *Agent) askClockEnded(id, gen uint64, armed bool) { a.mu.Lock() open := a.asked.atLocked(id) - if open == nil || open.settled != nil || open.held || open.deferred || a.closed { - // A DEFERRED CLOCK IS NOT RUNNING: a timer that fired as the surface - // deferred it lost that race (askclock.go). + if open == nil || open.settled != nil || open.held || open.deferred || a.closed || (armed && open.clockGen != gen) { + // A DEFERRED CLOCK IS NOT RUNNING, and a timer armed before the + // clock was deferred is not the clock (askclock.go). // // NOTHING THIS CLOCK COULD SAY WOULD REACH ANYBODY once the session has // closed, and a decision nobody read is worse than no decision: the next diff --git a/internal/session/askwait.go b/internal/session/askwait.go index 17b6676379..dc081bcfd9 100644 --- a/internal/session/askwait.go +++ b/internal/session/askwait.go @@ -100,6 +100,9 @@ type askOpen struct { // — which is every ratify and every ask whose turn did not wait // (asklane.go's [Agent.startAskClock]). clock *time.Timer + // clockGen is which arming of clock is the live one; asklane.go's + // [armAskClockLocked] is the only writer. + clockGen uint64 // held says a person pressed a key on this question and the clock will never // answer it. There is no way back: a clock that resumed would fire exactly // when somebody looked away mid-decision. From cbc447365760ef1732073113a26c5c073c88eb24 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 23:09:04 -0400 Subject: [PATCH 146/459] app: a message the queue sent says so on its own line, live, in history and reopened session.Submission.Queued is written as the person's line's mark; the app's sendQueued hands it through the conversation's door, and the user Entry carries queued:true live and from the record. A remote engine says queuedMarks at its door and is never sent a mark it would drop. An immediate send and a retry carry nothing; older lines read as unknown. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/app.go | 10 +++++++ cmd/codeaf/appconvo.go | 21 +++++++++++-- cmd/codeaf/appconvo_test.go | 46 +++++++++++++++++++++++++++++ internal/appwire/wire.go | 5 ++++ internal/remote/client.go | 12 +++++++- internal/remote/server.go | 11 +++++++ internal/remote/wire.go | 13 ++++++-- internal/session/agent.go | 3 +- internal/session/presentation.go | 1 + internal/session/recordcache.go | 2 +- internal/session/submission.go | 5 ++-- internal/session/submission_test.go | 41 +++++++++++++++++++++++++ 12 files changed, 160 insertions(+), 10 deletions(-) diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index 5a50d71a43..f4aa6d8697 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -1449,6 +1449,14 @@ func (b *appBackend) Retry(id string) (uint64, error) { if text == "" { return 0, appwire.Errorf(appwire.CodeNotFound, "this agent has no failed turn to run again") } + // A retry is the person's own send, never the queue's. + if shown != nil && shown.queued { + again := *shown + again.queued = false + if shown = &again; len(again.described) == 0 { + shown = nil + } + } return b.send(id, text, "", shown) } @@ -1569,6 +1577,7 @@ func (b *appBackend) startLocked(a *appAgent, text, clientID string, shown *appS userEntry := appwire.Entry{ ID: appUserEntryID(n), Agent: id, Turn: turn, Kind: appwire.EntryUser, At: b.now(), Text: entryText, ClientID: clientID, Attachments: attachments, Reads: reads, } + userEntry.Queued = shown != nil && shown.queued && appKeepsQueued(live.conn.Conv) appComposedFields(&userEntry, composedMark) live.box.Event(appwire.EventEntryAdded, appwire.EntryAdded{Entry: userEntry}) long := b.watchLong(id, live, turn, b.now()) @@ -2502,6 +2511,7 @@ func appEntryShapes(agent string, items []session.DisplayEntry, at time.Time, ma // rode on them, as the record keeps them on the message's line; a // line without that mark is its words, and has no attachments. entry.ID, entry.Kind, entry.Text, entry.Attachments = appUserEntryID(n), appwire.EntryUser, item.Text, appAttachmentsOf(item.Person) + entry.Queued = item.Person != nil && item.Person.Queued entry.Reads = appPersonReads(item.Person) if item.Person != nil { appComposedFields(&entry, item.Person.Composed) diff --git a/cmd/codeaf/appconvo.go b/cmd/codeaf/appconvo.go index d747e6e15c..affaaae9e0 100644 --- a/cmd/codeaf/appconvo.go +++ b/cmd/codeaf/appconvo.go @@ -425,7 +425,15 @@ func (b *appBackend) sendQueued(a *appAgent, live *appLive) { return } b.emit.Emit(appwire.EventQueueChanged, appwire.QueueChanged{Agent: agent, Queued: queued}) - if _, err := b.send(agent, next.text, next.clientID, next.shown); err != nil { + shown := next.shown + if shown == nil { + shown = &appSending{text: next.text} + } else { + copy := *shown + shown = © + } + shown.queued = true + if _, err := b.send(agent, next.text, next.clientID, shown); err != nil { live.box.Event(appwire.EventEntryAdded, appwire.EntryAdded{Entry: appwire.Entry{ ID: "qx" + next.id, Agent: agent, Kind: appwire.EntryError, At: b.now(), Text: "the queued message was not sent: " + err.Error(), }}) @@ -1016,13 +1024,21 @@ func appAttachmentsOf(person *session.PersonMark) []appwire.Attachment { // them. func appSubmission(text string, shown *appSending) session.Submission { said := shown.text - submission := session.Submission{Text: text, Said: &said, Attachments: shown.described, Reads: shown.reads} + submission := session.Submission{Text: text, Said: &said, Attachments: shown.described, Reads: shown.reads, Queued: shown.queued} if shown.composed != nil { submission.Media, submission.Annotation = shown.composed.media, shown.composed.annotation } + if shown.queued && shown.text == text && len(shown.described) == 0 && len(shown.reads) == 0 && shown.composed == nil { + submission.Said = nil + } return submission } +func appKeepsQueued(conv any) bool { + door, ok := conv.(interface{ QueuedMarksKnown() bool }) + return !ok || door.QueuedMarksKnown() +} + // appKeptReads is a message's reads as the record will keep them, or the // refusal the engine would answer, as bad_params before anything is done. func appKeptReads(reads []appwire.MessageRead) ([]session.MessageRead, error) { @@ -1252,6 +1268,7 @@ type appSending struct { // composed is the message's pictures and annotation, and what the engine // answered once it kept it (appsubmission.go); nil on a message of words. composed *appComposed + queued bool } // ── tasks as conversations ────────────────────────────────────────────────── diff --git a/cmd/codeaf/appconvo_test.go b/cmd/codeaf/appconvo_test.go index e6a42944f4..d4410e3a94 100644 --- a/cmd/codeaf/appconvo_test.go +++ b/cmd/codeaf/appconvo_test.go @@ -403,3 +403,49 @@ func TestAppAskHoldingUpIsTheTaskGraph(t *testing.T) { t.Errorf("an ask from no task holds up %v", got) } } + +// A MESSAGE THE QUEUE SENT SAYS SO, AND NOTHING ELSE DOES. The person's own +// send carries no mark; the queued one is handed to the conversation's door +// with the mark and its words alone, its entry says queued live, and the +// record's mark is what a reopened history reads. +func TestAppAQueuedMessageIsMarkedQueuedAndASentOneIsNot(t *testing.T) { + hold := make(chan struct{}) + conv := &fakeConversation{hold: hold} + backend, rec, agent := workBackend(t, conv) + if _, err := backend.Send(agent.ID, "run the tests", "c-1"); err != nil { + t.Fatal(err) + } + if _, err := backend.Queue(agent.ID, "then the lint", "c-2"); err != nil { + t.Fatal(err) + } + conv.mu.Lock() + conv.hold = nil + conv.mu.Unlock() + close(hold) + sent := entryWith(rec, t, func(e appwire.Entry) bool { return e.Kind == appwire.EntryUser && e.Text == "run the tests" }) + queued := entryWith(rec, t, func(e appwire.Entry) bool { return e.Kind == appwire.EntryUser && e.Text == "then the lint" }) + if sent.Queued || !queued.Queued || queued.ClientID != "c-2" || len(queued.Attachments) != 0 { + t.Fatalf("the sent entry = %+v, the queued one = %+v", sent, queued) + } + conv.mu.Lock() + handed := append([]session.Submission(nil), conv.submissions...) + conv.mu.Unlock() + if len(handed) != 1 || !handed[0].Queued || handed[0].Said != nil || handed[0].Text != "then the lint" { + t.Fatalf("the door was handed %+v", handed) + } + + entries := appEntriesOf("a", []session.DisplayEntry{ + {Role: "user", Text: "run the tests"}, + {Role: "user", Text: "then the lint", Person: &session.PersonMark{Queued: true}}, + {Role: "user", Text: "and tag it", Person: &session.PersonMark{Authored: true}}, + }, time.Now(), nil) + var marks []bool + for _, e := range entries { + if e.Kind == appwire.EntryUser { + marks = append(marks, e.Queued) + } + } + if !reflect.DeepEqual(marks, []bool{false, true, false}) { + t.Fatalf("history reads queued marks %v", marks) + } +} diff --git a/internal/appwire/wire.go b/internal/appwire/wire.go index 35ccf11ec1..c1fb4d4143 100644 --- a/internal/appwire/wire.go +++ b/internal/appwire/wire.go @@ -321,6 +321,11 @@ type Entry struct { // ClientID echoes the id the app sent with the person's message, on that // message's user entry. ClientID string `json:"client_id,omitempty"` + // Queued is true on a user entry the engine sent from the person's queue + // (agents.queue) when the turn it waited behind ended, as the record keeps + // it on the message's own line. Absent is "not known to be queued": a + // message sent at once, or a line written before the mark existed. + Queued bool `json:"queued,omitempty"` // Msg is an answer's place among the assistant messages of its turn, // counted from zero; the turn.delta lines of that message carry the same. Msg *int `json:"msg,omitempty"` diff --git a/internal/remote/client.go b/internal/remote/client.go index 2a1c40a408..678c3ef0ca 100644 --- a/internal/remote/client.go +++ b/internal/remote/client.go @@ -1739,12 +1739,19 @@ func (a *Agent) Submit(ctx context.Context, text string) (<-chan session.Event, // SubmitMessage is Submit with what the door knows about the message beyond its // words ([session.Agent.SubmitMessage]): it travels on the same call, so the // engine records the words and the mark on one line or neither. +// +// A queued mark goes only to an engine that said it keeps one +// ([Welcome.QueuedMarks]); to any other the words go without it, so the record +// reads "not known to be queued" rather than carrying a fact it dropped. func (a *Agent) SubmitMessage(ctx context.Context, message session.Submission) (<-chan session.Event, error) { + message.Queued = message.Queued && a.QueuedMarksKnown() if argsOf(message).composed() { _, events, err := a.SubmitComposed(ctx, message) return events, err } - if argsOf(message).marked() && !a.MessageMarksKnown() { + mark := argsOf(message) + mark.Queued = false + if mark.marked() && !a.MessageMarksKnown() { return nil, errors.New("engine: this engine cannot keep a message's description; update it before sending") } return a.open(ctx, MethodSubmit, argsOf(message)) @@ -1835,6 +1842,9 @@ func (a *Agent) welcomeSays(fact func(Welcome) bool) bool { return fact(a.c.welcome) } +// QueuedMarksKnown is whether the engine keeps a queued mark ([Welcome.QueuedMarks]). +func (a *Agent) QueuedMarksKnown() bool { return a.c.Welcome().QueuedMarks } + // SubmitBash explicitly runs the person's command; ordinary Submit never // interprets message content as executable shell syntax. func (a *Agent) SubmitBash(ctx context.Context, text string) (<-chan session.Event, error) { diff --git a/internal/remote/server.go b/internal/remote/server.go index a028cd2b13..49be08b5fe 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -1197,6 +1197,16 @@ func steerRepeatKnown(agent any) bool { return ok && door.SteerRepeatKnown() } +// queuedMarksKnown is whether the agent this engine has open keeps a message's +// mark at all: this build's Submit hands a queued mark to that door and to +// nothing else. +func queuedMarksKnown(agent any) bool { + _, ok := agent.(interface { + SubmitMessage(context.Context, session.Submission) (<-chan session.Event, error) + }) + return ok +} + func memoryCommandsKnown(agent any) bool { door, ok := agent.(MemoryCommands) return ok && door.Remembers() @@ -1288,6 +1298,7 @@ func (sess *Session) welcomeLocked(s *server) Welcome { Launch: sess.engine.Launch, Facts: sess.factsLocked(), SteerRepeat: steerRepeatKnown(sess.agent), + QueuedMarks: queuedMarksKnown(sess.agent), // Whether this engine can hold a folder at all, asked of the agent it has // open — for [Welcome.Folders]'s stated reason: the surface's own type // assertion cannot see across the wire. diff --git a/internal/remote/wire.go b/internal/remote/wire.go index 4ccf2528fc..60df1845f2 100644 --- a/internal/remote/wire.go +++ b/internal/remote/wire.go @@ -1208,6 +1208,12 @@ type Welcome struct { // asking twice, which is the degradation that costs a keystroke rather than // the one that corrects a worker twice. SteerRepeat bool `json:"steerRepeat,omitempty"` + // QueuedMarks says this engine keeps a Submit's queued mark + // ([SubmitArgs.Queued]) on the message's line. ABSENCE IS AN ENGINE THAT + // WOULD DROP IT: the surface then sends the words without the mark and + // says nothing about where the message came from, rather than showing a + // fact the record will not hold. + QueuedMarks bool `json:"queuedMarks,omitempty"` // SteerOwner says this engine CHECKS THE CONVERSATION A SEND WAS WRITTEN FOR // ([TaskSteerArgs.Session]) against the one it actually has open, and refuses @@ -1491,13 +1497,14 @@ type SubmitArgs struct { // ComposedMessages first. Media []session.SubmissionMedia `json:"media,omitempty"` Annotation *session.Annotation `json:"annotation,omitempty"` + Queued bool `json:"queued,omitempty"` } // marked is whether the call carries a mark beside its words. Submit, Steer // and FollowUp each take one; a marked call is kept with its mark or refused // whole, and never run as its words alone. func (a SubmitArgs) marked() bool { - return a.Said != nil || len(a.Attachments) > 0 || len(a.Reads) > 0 || a.composed() + return a.Queued || a.Said != nil || len(a.Attachments) > 0 || len(a.Reads) > 0 || a.composed() } // composed is whether the call carries pictures or an annotation, which only a @@ -1506,12 +1513,12 @@ func (a SubmitArgs) composed() bool { return len(a.Media) > 0 || a.Annotation != // submission is the call as the agent's own door for a mark takes it. func (a SubmitArgs) submission() session.Submission { - return session.Submission{Text: a.Text, Said: a.Said, Attachments: a.Attachments, Reads: a.Reads, Media: a.Media, Annotation: a.Annotation} + return session.Submission{Text: a.Text, Said: a.Said, Attachments: a.Attachments, Reads: a.Reads, Media: a.Media, Annotation: a.Annotation, Queued: a.Queued} } // argsOf is a submission as the wire carries it. func argsOf(message session.Submission) SubmitArgs { - return SubmitArgs{Text: message.Text, Said: message.Said, Attachments: message.Attachments, Reads: message.Reads, Media: message.Media, Annotation: message.Annotation} + return SubmitArgs{Text: message.Text, Said: message.Said, Attachments: message.Attachments, Reads: message.Reads, Media: message.Media, Annotation: message.Annotation, Queued: message.Queued} } // UnqueueArgs names the queued follow-up a surface wants taken back out: the diff --git a/internal/session/agent.go b/internal/session/agent.go index 3c43fa8574..5de36c4f8f 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -4917,6 +4917,7 @@ type PersonMark struct { // id, its pictures as the engine measured them, its annotation and how the // pictures reached a model. Nil on every message no such door sent. Composed *ComposedMark + Queued bool } // SteerMark is what the record keeps about one steer that LANDED: when the @@ -5061,7 +5062,7 @@ func shapeEntries(messages []ai.Message, journal *sessionFile, indexes ...*prese // on it and the reads its words were written from. A line with no // such mark is shown as it always was. if mark := presentation.of(msg).person(); mark != nil { - person = &PersonMark{Attachments: cloneAttachments(mark.Attachments), Reads: cloneReads(mark.Reads), Composed: cloneComposed(mark.Composed)} + person = &PersonMark{Attachments: cloneAttachments(mark.Attachments), Reads: cloneReads(mark.Reads), Composed: cloneComposed(mark.Composed), Queued: mark.Queued} if mark.Text != nil { displayText, person.Authored = *mark.Text, true } diff --git a/internal/session/presentation.go b/internal/session/presentation.go index 91850224cf..24a0be6cb6 100644 --- a/internal/session/presentation.go +++ b/internal/session/presentation.go @@ -35,6 +35,7 @@ type messagePresentation struct { // durable id, its pictures as measured, its annotation and how the pictures // reached a model ([Agent.SubmitComposed]). Composed *ComposedMark `json:"composed,omitempty"` + Queued bool `json:"queued,omitempty"` SkillsBlock string `json:"-"` } diff --git a/internal/session/recordcache.go b/internal/session/recordcache.go index e60ae09543..8c10b664e3 100644 --- a/internal/session/recordcache.go +++ b/internal/session/recordcache.go @@ -583,7 +583,7 @@ func cloneEntries(entries []DisplayEntry) []DisplayEntry { entry.ReplyTags = slices.Clone(entry.ReplyTags) entry.Team = slices.Clone(entry.Team) if entry.Person != nil { - person := PersonMark{Authored: entry.Person.Authored, Attachments: cloneAttachments(entry.Person.Attachments), Reads: cloneReads(entry.Person.Reads), Composed: cloneComposed(entry.Person.Composed)} + person := PersonMark{Authored: entry.Person.Authored, Attachments: cloneAttachments(entry.Person.Attachments), Reads: cloneReads(entry.Person.Reads), Composed: cloneComposed(entry.Person.Composed), Queued: entry.Person.Queued} entry.Person = &person } if entry.Steer != nil { diff --git a/internal/session/submission.go b/internal/session/submission.go index 937b8a5532..a6dafb4dee 100644 --- a/internal/session/submission.go +++ b/internal/session/submission.go @@ -67,6 +67,7 @@ type Submission struct { Said *string // Attachments are what rode on the message, in the order it carried them. Attachments []MessageAttachment + Queued bool // Reads are the reads of references the door performed to write Text, in // the order it performed them, as it found them at that moment. Reads []MessageRead @@ -198,7 +199,7 @@ func (a *Agent) SubmitMessage(ctx context.Context, message Submission) (<-chan E // it says nothing beyond the words. The copy is the record's own: nothing the // caller does to its slices afterwards reaches a line or a reading. func (m Submission) mark() (*messagePresentation, error) { - if m.Said == nil && len(m.Attachments) == 0 && len(m.Reads) == 0 { + if m.Said == nil && len(m.Attachments) == 0 && len(m.Reads) == 0 && !m.Queued { return nil, nil } reads, err := keptReads(m.Reads) @@ -212,7 +213,7 @@ func (m Submission) mark() (*messagePresentation, error) { if len(m.Attachments) > MaxMessageAttachments { return nil, fmt.Errorf("%w: %d attachments, and one message names at most %d", ErrMessageMark, len(m.Attachments), MaxMessageAttachments) } - mark := &messagePresentation{Audience: audiencePerson} + mark := &messagePresentation{Audience: audiencePerson, Queued: m.Queued} // Text stays nil when the door did not say the person's words apart: the // line's own content is theirs, and a copy of it here would be the same // words written twice on one line. diff --git a/internal/session/submission_test.go b/internal/session/submission_test.go index ff274c9d5a..56f5bab404 100644 --- a/internal/session/submission_test.go +++ b/internal/session/submission_test.go @@ -465,3 +465,44 @@ func TestAPersonsMarkCostsAnEntryNothing(t *testing.T) { t.Fatalf("DisplayEntry is %d bytes; it was 288 before the person's mark", size) } } + +// A MESSAGE THE DOOR DELIVERED FROM THE PERSON'S QUEUE SAYS SO ON ITS OWN LINE, +// and nothing else does: the same words sent at once carry no mark at all. The +// live reading, the record at rest and the reopened conversation agree, and +// the model reads the words alone. +func TestAQueuedMessageSaysSoOnItsLineAndASentOneDoesNot(t *testing.T) { + path := filepath.Join(t.TempDir(), "session.jsonl") + completer := answering() + agent, _ := newTestAgent(t, completer, func(c *Config) { c.SessionFile = path }) + for _, message := range []Submission{{Text: "run the tests"}, {Text: "then the lint", Queued: true}} { + events, err := agent.SubmitMessage(context.Background(), message) + if err != nil { + t.Fatal(err) + } + collect(t, events) + } + completer.mu.Lock() + last := completer.seen[len(completer.seen)-1] + completer.mu.Unlock() + if got := messageContentText(last[len(last)-1]); got != "then the lint" { + t.Fatalf("the model read %q", got) + } + check := func(where string, entries []DisplayEntry) { + t.Helper() + users := personEntries(entries) + if len(users) != 2 || users[0].Person != nil || users[1].Person == nil || !users[1].Person.Queued || + users[1].Person.Authored || users[1].Text != "then the lint" { + t.Fatalf("%s: the person's messages = %#v", where, users) + } + } + check("live", agent.Transcript()) + if err := agent.Close(); err != nil { + t.Fatal(err) + } + check("at rest", ReadTranscript(path).Entries) + if marks := journalMarks(t, path); len(marks) != 2 || marks[0] != nil || marks[1]["queued"] != true || marks[1]["text"] != nil { + t.Fatalf("the person's lines carry marks %v", marks) + } + reopened, _ := newTestAgent(t, answering(), func(c *Config) { c.SessionFile = path }) + check("reopened", reopened.Transcript()) +} From ac94a4d5b60254a27eda1d86cbf227e4e00e5e64 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 23:11:01 -0400 Subject: [PATCH 147/459] app: tasks.since counts what a task's own record holds after one of its entries The same one-read room journal tasks.entries reads, shaped once and counted by appSinceOf: turns by the record's own marks (absent when they cannot tell), entries, distinct changed files (absent past 400 calls), first and last moment. Ids are the task's own; a note, a sub-task row or a place older than the tail is not found. Served through an optional TaskSincer so an engine without it answers unknown_method. Co-Authored-By: Claude Opus 5.5 --- cmd/codeaf/appattention.go | 57 ++++++++++++++--- cmd/codeaf/appattention_test.go | 61 +++++++++++++++++++ .../unreleased/app-calm-attention-clock.md | 4 +- internal/appwire/attention.go | 24 ++++++++ 4 files changed, 138 insertions(+), 8 deletions(-) diff --git a/cmd/codeaf/appattention.go b/cmd/codeaf/appattention.go index 7b45301455..5b0d7cea44 100644 --- a/cmd/codeaf/appattention.go +++ b/cmd/codeaf/appattention.go @@ -17,6 +17,7 @@ package main import ( "context" + "strconv" "strings" "time" @@ -134,13 +135,55 @@ func (b *appBackend) Since(agent, after string) (appwire.Since, error) { return since, nil } -// appSinceOf is [appBackend.Since] over shaped entries; files reads one tool -// entry's changed files and is called at most [appSinceFacts] times. -// -// A TURN is what the person sees as one: each of their messages opens one, and -// what the conversation did on its own before the next of their messages — a -// wake, a crew's delivery, the rest of a reply — is one more. -func appSinceOf(all []appwire.Entry, after string, files func(int) []appwire.FileChange) appwire.Since { +// TaskSince counts what one task's own record holds after one of its entries: +// the same room journal tail tasks.entries reads, read once, shaped once. +// Entry ids are the task's ("/"); an anchor that is not on the +// record (a note, a sub-task row, a step line, or a place older than the tail +// the room keeps) is not found and nothing is counted. +func (b *appBackend) TaskSince(id, after string) (appwire.Since, error) { + agent, local, ok := appSplitID(id) + if !ok { + return appwire.Since{}, appwire.Errorf(appwire.CodeBadParams, "%s is not a task id", id) + } + n, err := strconv.ParseUint(local, 10, 64) + if err != nil { + return appwire.Since{}, appwire.Errorf(appwire.CodeBadParams, "%s is not a task id", id) + } + prefix := id + "/" + after = strings.TrimSpace(after) + if !strings.HasPrefix(after, prefix) { + return appwire.Since{}, nil + } + a, live, err := b.heldLive(agent) + if err != nil { + return appwire.Since{}, err + } + journal, ok := live.conn.Conv.(appTaskJournal) + if !ok { + return appwire.Since{}, nil + } + record, err := journal.TaskRoom(n, session.TaskJournalTail) + if err != nil { + return appwire.Since{}, appEngineError(err) + } + items := appRecordItems(session.ReadTranscriptBytes(record.Journal)) + all, from := appEntryShapes(agent, items, b.now(), nil) + since := appSinceOf(all, from, items, strings.TrimPrefix(after, prefix), func(i int) []appwire.FileChange { + if i >= len(from) || from[i] < 0 { + return nil + } + return b.appEntryFacts(a, items[from[i]]).Files + }) + if since.First != "" { + since.First = prefix + since.First + } + return since, nil +} + +// appSinceOf is [appBackend.Since] over shaped entries: from is each entry's +// item, items the record's items, and files reads one tool entry's changed +// files, at most [appSinceFacts] times. +func appSinceOf(all []appwire.Entry, from []int, items []session.DisplayEntry, after string, files func(int) []appwire.FileChange) appwire.Since { place := -1 for i, entry := range all { if entry.ID == after { diff --git a/cmd/codeaf/appattention_test.go b/cmd/codeaf/appattention_test.go index f2e47cc4cc..11ab926ed4 100644 --- a/cmd/codeaf/appattention_test.go +++ b/cmd/codeaf/appattention_test.go @@ -3,6 +3,8 @@ package main import ( "context" "errors" + "strconv" + "strings" "testing" "time" @@ -159,3 +161,62 @@ func TestAppSinceCountsTurnsAndFilesAfterAPlace(t *testing.T) { t.Fatalf("past the bound: files %v after %d reads", over.Files, reads) } } + +// roomConversation is the fake conversation with one task's room journal. +type roomConversation struct { + *fakeConversation + journal []byte + reads int +} + +func (c *roomConversation) TaskRoom(id uint64, tail int) (session.TaskRecord, error) { + c.reads++ + if id != 4 { + return session.TaskRecord{}, errors.New("no such task") + } + return session.TaskRecord{Kept: true, Journal: c.journal}, nil +} + +// A TASK'S PLACE IS COUNTED FROM ITS OWN RECORD, read once a call: the entries +// after it with the task's own ids, and nothing for a place the record does not +// hold or an engine with no room. +func TestAppTaskSinceCountsTheTasksOwnRecord(t *testing.T) { + conv := &fakeConversation{} + backend, _, agent := workBackend(t, conv) + var journal strings.Builder + journal.WriteString(`{"type":"session","version":1,"id":"n4","cwd":"/tmp/lab"}` + "\n") + for n := range 3 { + journal.WriteString(`{"type":"message","role":"user","content":"step ` + strconv.Itoa(n) + `"}` + "\n") + journal.WriteString(`{"type":"message","role":"assistant","content":"did ` + strconv.Itoa(n) + `"}` + "\n") + } + room := &roomConversation{fakeConversation: conv, journal: []byte(journal.String())} + task := agent.ID + ":4" + + if since, err := backend.TaskSince(task, task+"/a1.0"); err != nil || since.Found { + t.Fatalf("an engine with no room = %+v, %v", since, err) + } + backend.locked(func() { backend.agents[agent.ID].live.conn.Conv = room }) + page, _, err := backend.TaskEntries(task, "", 200) + if err != nil || len(page) != 6 { + t.Fatalf("the task's entries = %+v, %v", page, err) + } + reads := room.reads + since, err := backend.TaskSince(task, page[1].ID) + if err != nil || !since.Found || since.Entries != 4 || since.First != page[2].ID || since.Files == nil || *since.Files != 0 { + t.Fatalf("since %s = %+v, %v", page[1].ID, since, err) + } + if since.Turns != nil && *since.Turns != 2 { + t.Fatalf("two of the person's turns counted as %d", *since.Turns) + } + if room.reads != reads+1 { + t.Fatalf("one count read the room %d times", room.reads-reads) + } + for _, place := range []string{task + "/note0", "a1.0", task + "/u99.0"} { + if gone, err := backend.TaskSince(task, place); err != nil || gone.Found || gone.Turns != nil || gone.Entries != 0 { + t.Fatalf("a place the record does not hold (%s) = %+v, %v", place, gone, err) + } + } + if _, err := backend.TaskSince("nonsense", "x"); err == nil { + t.Fatal("a task id that is not one was counted") + } +} diff --git a/docs/changes/unreleased/app-calm-attention-clock.md b/docs/changes/unreleased/app-calm-attention-clock.md index 2753d9a6ba..256dbbfc8f 100644 --- a/docs/changes/unreleased/app-calm-attention-clock.md +++ b/docs/changes/unreleased/app-calm-attention-clock.md @@ -6,5 +6,7 @@ surface: [app, engine] invalidates: - "An app ask said nothing about whether work waited on it, and an empty holding_up was the only hint. Ask.blocking is now true for a permission and for the model's own question that parks its turn or names tasks, false only for the model's own question whose turn carried on and names no task, and absent (unknown, read as waiting) for every other kind." - "A choice's clock started when the question was raised, so a surface that held a question until the person paused let the suggestion be taken unseen. asks.defer stops the clock of a blocking:false ask until asks.shown starts it for its whole length (remote Question.Clock); the engine starts a deferred clock by itself after five minutes, and a person's hold is never undone." - - "Counting what came after the place a person last read meant paging the whole conversation. agents.since {id, after} counts the turns, entries and distinct changed files after one entry from the record's cached shapes; files is absent past 400 calls." + - "Counting what came after the place a person last read meant paging the whole conversation. agents.since {id, after} counts the turns, entries and distinct changed files after one entry from the record's cached shapes. Turns are told by the record's own marks (a person's message, a wake, a crew delivery, a completed or stopped reply) and are absent where those marks cannot tell turns apart; files is absent past 400 calls." + - "A message the queue sent when a turn ended looked the same as one the person sent at once. A user Entry the engine sent from the queue now carries queued:true, kept on the message's own line in the record (session Submission.Queued), so history and a reopened conversation say so too; older lines and immediate sends carry nothing." + - "A task page could count what was new since a place only from the pages it had loaded. tasks.since {id, after} counts the task's own record the same way agents.since counts an agent's, from one read of the room journal tasks.entries reads." --- diff --git a/internal/appwire/attention.go b/internal/appwire/attention.go index f0cdba76b6..60fc1c33c8 100644 --- a/internal/appwire/attention.go +++ b/internal/appwire/attention.go @@ -44,6 +44,16 @@ type Attender interface { Since(agent, after string) (Since, error) } +// TaskSincer is [Attender.Since] over one task's own record (tasks.since). An +// [Engine] that is not one refuses tasks.since as unknown. +type TaskSincer interface { + // TaskSince counts what the task's record holds after one of its entries + // (an id tasks.entries served). The count is the record's own entries: + // the notes and sub-task rows tasks.entries lists after them are not in + // it, and an entry that is not on the record is Found false. + TaskSince(task, after string) (Since, error) +} + func attender(e Engine, name string) (Attender, error) { a, ok := e.(Attender) if !ok { @@ -89,6 +99,20 @@ var attentionMethods = map[string]method{ since, err := a.Since(p.ID, p.After) return &since, err }), + "tasks.since": typed(func(e Engine, p struct { + ID string `json:"id"` + After string `json:"after"` + }) (*Since, error) { + t, ok := e.(TaskSincer) + if !ok { + return nil, Errorf(CodeUnknownMethod, "this engine does not serve tasks.since") + } + if strings.TrimSpace(p.ID) == "" || strings.TrimSpace(p.After) == "" { + return nil, Errorf(CodeBadParams, "tasks.since needs a task id and the entry to count after") + } + since, err := t.TaskSince(p.ID, p.After) + return &since, err + }), } func init() { From 8b7bc43d9cef11bf303f865880ae0fe7ef2bc190 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Thu, 15 Oct 2026 22:23:11 -0400 Subject: [PATCH 148/459] app: agents.since tells turns by the record's own marks, and omits a count it cannot make A turn opens at the person's message, a line the session wrote to its model, or a crew's delivery, and ends at a reply marked complete or cut short by a stop; work after an end is a new turn. Two turns the conversation started on its own are no longer counted as one. Where the marks cannot tell one turn from two, turns is absent rather than guessed. --- cmd/codeaf/appattention.go | 73 +++++++++++++++++++---- cmd/codeaf/appattention_test.go | 102 ++++++++++++++++++++++++-------- internal/appwire/attention.go | 8 ++- 3 files changed, 144 insertions(+), 39 deletions(-) diff --git a/cmd/codeaf/appattention.go b/cmd/codeaf/appattention.go index 5b0d7cea44..98edb6547c 100644 --- a/cmd/codeaf/appattention.go +++ b/cmd/codeaf/appattention.go @@ -121,7 +121,7 @@ func (b *appBackend) Since(agent, after string) (appwire.Since, error) { err = appRecords.WithRecord(transcript, func(record session.Record) error { items := appRecordItems(record) all, from := appEntryShapes(agent, items, at, marks) - since = appSinceOf(all, after, func(i int) []appwire.FileChange { + since = appSinceOf(all, from, items, after, func(i int) []appwire.FileChange { if i >= len(from) || from[i] < 0 { return nil } @@ -197,8 +197,13 @@ func appSinceOf(all []appwire.Entry, from []int, items []session.DisplayEntry, a since := appwire.Since{Found: true} read := all[place].At since.At = &read + if place < len(from) && from[place] >= 0 && from[place] < len(items) { + if turns, known := appSinceTurns(items[from[place]:]); known { + since.Turns = &turns + } + } paths := map[string]bool{} - tools, turns, inTurn, over := 0, 0, false, false + tools, over := 0, false for i := place + 1; i < len(all); i++ { entry := all[i] since.Entries++ @@ -207,14 +212,6 @@ func appSinceOf(all []appwire.Entry, from []int, items []session.DisplayEntry, a } last := entry.At since.LastAt = &last - switch entry.Kind { - case appwire.EntryUser: - turns, inTurn = turns+1, true - case appwire.EntryAnswer, appwire.EntryUpdate, appwire.EntryTool: - if !inTurn { - turns, inTurn = turns+1, true - } - } if entry.Kind != appwire.EntryTool || over { continue } @@ -229,10 +226,64 @@ func appSinceOf(all []appwire.Entry, from []int, items []session.DisplayEntry, a } } } - since.Turns = turns if !over { n := len(paths) since.Files = &n } return since } + +// appSinceTurns counts the turns after the first item, the place read, and +// says whether the record could tell them apart. +// +// A TURN IS TOLD BY THE RECORD'S OWN MARKS, never by words: the person's +// message opens one; a line the session wrote to its model (a wake, a landing +// note) or a crew's delivery opens one the conversation started itself; a +// reply marked complete ([session.DisplayEntry.Answer], not an update) or one +// a stop cut short ends one. Work after an end with no opener of its own is a +// new turn too. +// +// WHERE THE MARKS CANNOT SAY, THE COUNT IS NOT GIVEN. An opener inside a turn +// that never said it ended is a note steered in or a turn that ended unsaid; +// a reply with no completion mark followed by more work is a record written +// before the mark, or a turn that failed. Either could be one turn or two. +func appSinceTurns(items []session.DisplayEntry) (int, bool) { + if len(items) == 0 { + return 0, true + } + turns, known := 0, true + inTurn := !appTurnEnds(items[0]) + for i := 1; i < len(items); i++ { + item := items[i] + switch { + case item.Role == "user": + turns, inTurn = turns+1, true + case item.Role == "aside" && item.Interrupted: + if !inTurn { + turns++ + } + inTurn = false + case item.Role == "aside" && (item.ForModel || len(item.Team) > 0): + if inTurn { + known = false + } + turns, inTurn = turns+1, true + case item.Role == "assistant" || (item.Role == "tool" && (item.CallID != "" || item.Tool != "")): + if !inTurn { + turns, inTurn = turns+1, true + } + if appTurnEnds(item) { + inTurn = false + } else if item.Role == "assistant" && i+1 < len(items) && items[i+1].Role != "tool" && items[i+1].Role != "user" { + known = false + } + } + } + return turns, known +} + +// appTurnEnds reports whether one item is a turn's recorded end: a reply +// marked complete and not an update, or an answer a stop cut short. +func appTurnEnds(item session.DisplayEntry) bool { + return (item.Role == "assistant" && item.Answer && !item.Addressed) || (item.Role == "aside" && item.Interrupted) +} diff --git a/cmd/codeaf/appattention_test.go b/cmd/codeaf/appattention_test.go index 11ab926ed4..c22d5eb670 100644 --- a/cmd/codeaf/appattention_test.go +++ b/cmd/codeaf/appattention_test.go @@ -120,43 +120,95 @@ func TestAppDeferAndShowAnAsksClock(t *testing.T) { } } -// WHAT CAME AFTER A PLACE IS COUNTED FROM THE SHAPES: the person's messages -// are turns, what the conversation did on its own first is one more, files are -// distinct paths, and past the bound the files count is not said at all. -func TestAppSinceCountsTurnsAndFilesAfterAPlace(t *testing.T) { +// sinceRecord shapes items the way a page is cut, so the count is read from +// the same entries and ids the app holds. +func sinceRecord(items []session.DisplayEntry) ([]appwire.Entry, []int) { at := time.Date(2026, 10, 15, 9, 0, 0, 0, time.UTC) - all := []appwire.Entry{ - {ID: "u0", Kind: appwire.EntryUser, At: at}, - {ID: "a0.0", Kind: appwire.EntryAnswer, At: at.Add(time.Minute)}, - {ID: "tc1", Kind: appwire.EntryTool, At: at.Add(2 * time.Minute)}, - {ID: "a0.1", Kind: appwire.EntryAnswer, At: at.Add(3 * time.Minute)}, - {ID: "u1", Kind: appwire.EntryUser, At: at.Add(4 * time.Minute)}, - {ID: "tc2", Kind: appwire.EntryTool, At: at.Add(5 * time.Minute)}, - {ID: "a1.0", Kind: appwire.EntryAnswer, At: at.Add(6 * time.Minute)}, - } - files := map[int][]appwire.FileChange{2: {{Path: "a.go"}, {Path: "b.go"}}, 5: {{Path: "a.go"}}} - read := func(i int) []appwire.FileChange { return files[i] } - - since := appSinceOf(all, "a0.0", read) - if !since.Found || since.Turns != 2 || since.Entries != 5 || since.First != "tc1" || since.Files == nil || *since.Files != 2 { + for i := range items { + items[i].At = at.Add(time.Duration(i) * time.Minute) + } + return appEntryShapes("agent", items, at, nil) +} + +var ( + sincePerson = func(text string) session.DisplayEntry { return session.DisplayEntry{Role: "user", Text: text} } + sinceDone = func(text string) session.DisplayEntry { + return session.DisplayEntry{Role: "assistant", Text: text, Answer: true} + } + sinceSaid = func(text string) session.DisplayEntry { return session.DisplayEntry{Role: "assistant", Text: text} } + sinceCall = func(id string) session.DisplayEntry { + return session.DisplayEntry{Role: "tool", Tool: "bash", CallID: id, Answered: true} + } + sinceWake = session.DisplayEntry{Role: "aside", Text: "the task landed", ForModel: true} + sinceTeam = session.DisplayEntry{Role: "aside", Text: "from @api", Team: []session.TeamLine{{From: "api"}}} + sinceNoted = session.DisplayEntry{Role: "aside", Text: "a job exited"} +) + +// TURNS ARE TOLD BY THE RECORD'S OWN MARKS. A person's message opens one; a +// wake or a crew's delivery opens one the conversation started; a completed +// reply ends one; work after an end is a new one. Where the marks cannot tell +// one turn from two, the count is not given at all. +func TestAppSinceCountsTurnsByTheRecordsOwnMarks(t *testing.T) { + for _, c := range []struct { + name string + items []session.DisplayEntry + after string + turns int // -1: not known + }{ + {"two of the person's turns", []session.DisplayEntry{sincePerson("a"), sinceDone("ok"), sincePerson("b"), sinceCall("c1"), sinceDone("done"), sincePerson("c"), sinceDone("done")}, "a0.0", 2}, + {"two turns the conversation started itself", []session.DisplayEntry{sincePerson("a"), sinceDone("ok"), sinceWake, sinceCall("c1"), sinceDone("landed"), sinceTeam, sinceDone("thanks")}, "a0.0", 2}, + {"work after a completed reply with no opener", []session.DisplayEntry{sincePerson("a"), sinceDone("ok"), sinceCall("c1"), sinceDone("one"), sinceCall("c2"), sinceDone("two")}, "a0.0", 2}, + {"one turn's call and answer", []session.DisplayEntry{sincePerson("a"), sinceDone("ok"), sinceWake, sinceCall("c1"), sinceSaid("update"), sinceCall("c2"), sinceDone("done")}, "a0.0", 1}, + {"a turn still running", []session.DisplayEntry{sincePerson("a"), sinceDone("ok"), sincePerson("b"), sinceCall("c1")}, "a0.0", 1}, + {"a note the session wrote is no turn", []session.DisplayEntry{sincePerson("a"), sinceDone("ok"), sinceNoted}, "a0.0", 0}, + {"replies with no completion mark", []session.DisplayEntry{sincePerson("a"), sinceDone("ok"), sinceCall("c1"), sinceSaid("one"), sinceSaid("two")}, "a0.0", -1}, + {"a wake inside a turn that never ended", []session.DisplayEntry{sincePerson("a"), sinceDone("ok"), sincePerson("b"), sinceCall("c1"), sinceWake, sinceDone("done")}, "a0.0", -1}, + } { + all, from := sinceRecord(c.items) + since := appSinceOf(all, from, c.items, c.after, func(int) []appwire.FileChange { return nil }) + if !since.Found { + t.Errorf("%s: the place was not found in %+v", c.name, all) + continue + } + switch { + case c.turns < 0 && since.Turns != nil: + t.Errorf("%s: a count the marks cannot make was given: %d", c.name, *since.Turns) + case c.turns >= 0 && (since.Turns == nil || *since.Turns != c.turns): + t.Errorf("%s: turns = %v, want %d", c.name, since.Turns, c.turns) + } + } +} + +// WHAT CAME AFTER A PLACE: entries, its first id, the moments, and distinct +// files; past the bound the files count is not said at all. +func TestAppSinceCountsEntriesAndFilesAfterAPlace(t *testing.T) { + items := []session.DisplayEntry{sincePerson("a"), sinceDone("ok"), sinceCall("c1"), sinceDone("one"), sincePerson("b"), sinceCall("c2"), sinceDone("two")} + all, from := sinceRecord(items) + files := map[string][]appwire.FileChange{"tc1": {{Path: "a.go"}, {Path: "b.go"}}, "tc2": {{Path: "a.go"}}} + read := func(i int) []appwire.FileChange { return files[all[i].ID] } + + since := appSinceOf(all, from, items, "a0.0", read) + if !since.Found || since.Entries != 5 || since.First != "tc1" || since.Files == nil || *since.Files != 2 { t.Fatalf("since a0.0 = %+v", since) } - if since.At == nil || !since.At.Equal(at.Add(time.Minute)) || since.LastAt == nil || !since.LastAt.Equal(at.Add(6*time.Minute)) { + if since.At == nil || !since.At.Equal(all[1].At) || since.LastAt == nil || !since.LastAt.Equal(all[len(all)-1].At) { t.Fatalf("the moments = %+v", since) } - if nothing := appSinceOf(all, "a1.0", read); !nothing.Found || nothing.Turns != 0 || nothing.Entries != 0 || *nothing.Files != 0 { + last := all[len(all)-1].ID + if nothing := appSinceOf(all, from, items, last, read); !nothing.Found || nothing.Turns == nil || *nothing.Turns != 0 || nothing.Entries != 0 || *nothing.Files != 0 { t.Fatalf("nothing new = %+v", nothing) } - if gone := appSinceOf(all, "a9.9", read); gone.Found || gone.Files != nil { + if gone := appSinceOf(all, from, items, "a9.9", read); gone.Found || gone.Files != nil || gone.Turns != nil { t.Fatalf("a place the record does not hold = %+v", gone) } - long := []appwire.Entry{{ID: "u0", Kind: appwire.EntryUser}} - reads := 0 + long := []session.DisplayEntry{sincePerson("a"), sinceDone("ok")} for n := 0; n <= appSinceFacts; n++ { - long = append(long, appwire.Entry{ID: "t" + time.Duration(n).String(), Kind: appwire.EntryTool}) + long = append(long, sinceCall("c"+strconv.Itoa(n))) } - over := appSinceOf(long, "u0", func(int) []appwire.FileChange { reads++; return nil }) + longAll, longFrom := sinceRecord(long) + reads := 0 + over := appSinceOf(longAll, longFrom, long, "a0.0", func(int) []appwire.FileChange { reads++; return nil }) if over.Files != nil || reads != appSinceFacts { t.Fatalf("past the bound: files %v after %d reads", over.Files, reads) } diff --git a/internal/appwire/attention.go b/internal/appwire/attention.go index 60fc1c33c8..6c5f6254b5 100644 --- a/internal/appwire/attention.go +++ b/internal/appwire/attention.go @@ -18,9 +18,11 @@ type Since struct { // At is the moment of the entry the count starts after. At *time.Time `json:"at,omitempty"` Entries int `json:"entries"` - // Turns counts the person's messages after the entry, and one more for - // what the conversation did on its own before the first of them. - Turns int `json:"turns"` + // Turns counts the turns after the entry by the record's own marks: a + // person's message, a line the session wrote to start one or a crew's + // delivery opens one, and a completed or stopped reply ends one. It is nil + // where the marks cannot tell turns apart, never a guess. + Turns *int `json:"turns,omitempty"` Files *int `json:"files,omitempty"` // First is the id of the first entry after it, and LastAt the moment of // the newest. From 8f5da3148fd9697775bc2c1666e42d0c676fed9a Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 16 Oct 2026 01:19:21 -0400 Subject: [PATCH 149/459] Verify queued provenance alongside preserved reference receipts --- cmd/codeaf/appsteerqueue_reads_test.go | 10 ++++++---- 1 file changed, 6 insertions(+), 4 deletions(-) diff --git a/cmd/codeaf/appsteerqueue_reads_test.go b/cmd/codeaf/appsteerqueue_reads_test.go index fa826b36eb..efb2cf0f63 100644 --- a/cmd/codeaf/appsteerqueue_reads_test.go +++ b/cmd/codeaf/appsteerqueue_reads_test.go @@ -137,14 +137,14 @@ func TestAppQueuedMessageKeepsItsMarkAndShowsThePersonsWords(t *testing.T) { conv.mu.Unlock() close(hold) marked := appLiveUser(t, rec, "q-2") - if marked.Text != "" || !reflect.DeepEqual(marked.Reads, []appwire.MessageRead{appCutRead()}) { + if !marked.Queued || marked.Text != "" || !reflect.DeepEqual(marked.Reads, []appwire.MessageRead{appCutRead()}) { t.Fatalf("the queued message went live as %+v", marked) } submissions, texts := appSubmissionsOf(conv) if want := []string{"first", "then the README", links}; !reflect.DeepEqual(texts, want) { t.Fatalf("the engine was sent %q", texts) } - if want := appMarkedSubmission(t, links, "", appCutRead()); len(submissions) != 1 || !reflect.DeepEqual(submissions[0], want) { + if want := appQueuedSubmission(appMarkedSubmission(t, links, "", appCutRead())); len(submissions) != 2 || submissions[0].Text != "then the README" || !submissions[0].Queued || !reflect.DeepEqual(submissions[1], want) { t.Fatalf("the marked door was handed %+v", submissions) } if _, err := backend.EditQueuedMessage(agent.ID, second, appwire.AgentMessage{Text: "too late", Said: &none, Reads: []appwire.MessageRead{appCutRead()}}); wireCode(err) != appwire.CodeSent { @@ -189,12 +189,12 @@ func TestAppQueuedEditRacingTheSendIsEitherSentOrRefused(t *testing.T) { switch { case editErr == nil: landed++ - if want := appMarkedSubmission(t, "new body", "new", appCutRead()); len(submissions) != 1 || !reflect.DeepEqual(submissions[0], want) || live.Text != "new" { + if want := appQueuedSubmission(appMarkedSubmission(t, "new body", "new", appCutRead())); len(submissions) != 1 || !reflect.DeepEqual(submissions[0], want) || live.Text != "new" { t.Fatalf("run %d: the edit landed and %+v was sent, %+v went live", i, submissions, live) } case wireCode(editErr) == appwire.CodeSent: refused++ - if want := appMarkedSubmission(t, "old body", "old", appSpecsRead()); len(submissions) != 1 || !reflect.DeepEqual(submissions[0], want) || live.Text != "old" { + if want := appQueuedSubmission(appMarkedSubmission(t, "old body", "old", appSpecsRead())); len(submissions) != 1 || !reflect.DeepEqual(submissions[0], want) || live.Text != "old" { t.Fatalf("run %d: the edit was refused and %+v was sent, %+v went live", i, submissions, live) } default: @@ -356,3 +356,5 @@ func TestAppOlderHostIsRefusedEveryMarkedMessageBeforeAnything(t *testing.T) { t.Fatalf("the older host was handed %+v and sent %q", submissions, texts) } } + +func appQueuedSubmission(s session.Submission) session.Submission { s.Queued = true; return s } From 27d4567ae4798a657dba899e5eac8bb094cd7e58 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 16 Oct 2026 01:14:09 -0400 Subject: [PATCH 150/459] Read bounded agent labels without scanning conversation journals --- cmd/codeaf/applabels.go | 128 ++++++++++++++++++++++++++++++++ cmd/codeaf/applabels_test.go | 68 +++++++++++++++++ internal/appwire/labels.go | 50 +++++++++++++ internal/appwire/labels_test.go | 35 +++++++++ 4 files changed, 281 insertions(+) create mode 100644 cmd/codeaf/applabels.go create mode 100644 cmd/codeaf/applabels_test.go create mode 100644 internal/appwire/labels.go create mode 100644 internal/appwire/labels_test.go diff --git a/cmd/codeaf/applabels.go b/cmd/codeaf/applabels.go new file mode 100644 index 0000000000..7dfe4e8bb7 --- /dev/null +++ b/cmd/codeaf/applabels.go @@ -0,0 +1,128 @@ +package main + +import ( + "encoding/json" + "io" + "os" + "path/filepath" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/session" +) + +// Labels read only the requested identities. A chart needing four labels must +// not enumerate/parse all conversation journals or retain their Agent views. +const labelBucketLimit = 512 +const labelReadBudget = 200 * time.Millisecond +const labelMetaBytes = 128 << 10 + +func (b *appBackend) AgentLabels(ids []string) (appwire.AgentLabels, error) { + if err := appwire.CheckAgentLabelIDs(ids); err != nil { + return appwire.AgentLabels{}, err + } + known := make(map[string]string, len(ids)) + var missing []string + b.locked(func() { + for _, id := range ids { + if a := b.agents[id]; a != nil && strings.TrimSpace(a.view.Title) != "" && len(a.view.Title) <= 4096 { + known[id] = strings.Clone(a.view.Title) + } + } + }) + for _, id := range ids { + if _, ok := known[id]; !ok { + missing = append(missing, id) + } + } + found, partial := readAgentLabelBatch(session.PlacesRoot(), missing, time.Now().Add(labelReadBudget), labelBucketLimit, loadAgentLabelMeta) + for id, title := range found { + known[id] = title + } + out := appwire.AgentLabels{Labels: []appwire.AgentLabel{}, Incomplete: partial} + seen := make(map[string]bool, len(ids)) + for _, id := range ids { + if title, ok := known[id]; ok && !seen[id] { + out.Labels = append(out.Labels, appwire.AgentLabel{ID: id, Title: title}) + seen[id] = true + } + } + return out, nil +} + +// The sole directory stream contains project buckets, never session folders. +// Each bucket probes only wanted ids. Bounds stop with incomplete, not false absence. +func readAgentLabelBatch(root string, ids []string, deadline time.Time, bucketLimit int, load func(string, string) (string, bool)) (map[string]string, bool) { + found := make(map[string]string) + wanted := make(map[string]bool, len(ids)) + for _, id := range ids { + wanted[id] = true + } + if len(wanted) == 0 { + return found, false + } + if !deadline.IsZero() && !time.Now().Before(deadline) { + return found, true + } + dir, err := os.Open(root) + if err != nil { + return found, !os.IsNotExist(err) + } + defer dir.Close() + visited := 0 + for len(wanted) > 0 { + if visited >= bucketLimit || (!deadline.IsZero() && !time.Now().Before(deadline)) { + return found, true + } + entries, readErr := dir.ReadDir(min(64, bucketLimit-visited)) + for _, entry := range entries { + visited++ + if !entry.IsDir() { + continue + } + for id := range wanted { + if !deadline.IsZero() && !time.Now().Before(deadline) { + return found, true + } + if title, ok := load(filepath.Join(root, entry.Name(), id, "meta.json"), id); ok { + delete(wanted, id) + if title != "" { + found[id] = title + } + } + } + } + if readErr == io.EOF { + return found, false + } + if readErr != nil { + return found, true + } + } + return found, false +} + +func loadAgentLabelMeta(path, id string) (string, bool) { + file, err := os.Open(path) + if err != nil { + return "", false + } + defer file.Close() + info, err := file.Stat() + if err != nil || !info.Mode().IsRegular() || info.Size() > labelMetaBytes { + return "", false + } + raw, err := io.ReadAll(io.LimitReader(file, labelMetaBytes+1)) + if err != nil || len(raw) > labelMetaBytes { + return "", false + } + var meta struct { + ID string `json:"id"` + Title string `json:"title"` + } + if json.Unmarshal(raw, &meta) != nil || meta.ID != id || len(meta.Title) > 4096 { + return "", false + } + return strings.TrimSpace(meta.Title), true +} diff --git a/cmd/codeaf/applabels_test.go b/cmd/codeaf/applabels_test.go new file mode 100644 index 0000000000..28becf9575 --- /dev/null +++ b/cmd/codeaf/applabels_test.go @@ -0,0 +1,68 @@ +package main + +import ( + "encoding/json" + "os" + "path/filepath" + "testing" + "time" +) + +func TestAgentLabelsReadOnlyRequestedMetadataNotSessionJournals(t *testing.T) { + root := t.TempDir() + bucket := filepath.Join(root, "project") + if err := os.Mkdir(bucket, 0o700); err != nil { + t.Fatal(err) + } + for _, id := range []string{"a", "b", "unrequested"} { + dir := filepath.Join(bucket, id) + if err := os.Mkdir(dir, 0o700); err != nil { + t.Fatal(err) + } + raw, _ := json.Marshal(map[string]string{"id": id, "title": "Name " + id}) + if err := os.WriteFile(filepath.Join(dir, "meta.json"), raw, 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, "transcript.jsonl"), []byte("not a transcript; never parse this"), 0o600); err != nil { + t.Fatal(err) + } + } + reads := 0 + found, partial := readAgentLabelBatch(root, []string{"a", "b"}, time.Now().Add(time.Second), 512, func(path, id string) (string, bool) { + reads++ + if filepath.Base(path) != "meta.json" || id == "unrequested" { + t.Fatal("read unrelated source") + } + return loadAgentLabelMeta(path, id) + }) + if partial || reads != 2 || found["a"] != "Name a" || found["b"] != "Name b" { + t.Fatalf("lookup %#v partial%v reads%d", found, partial, reads) + } +} +func TestAgentLabelsBudgetDoesNotInventMissingNames(t *testing.T) { + root := t.TempDir() + if err := os.Mkdir(filepath.Join(root, "project"), 0o700); err != nil { + t.Fatal(err) + } + calls := 0 + load := func(string, string) (string, bool) { calls++; return "", false } + got, partial := readAgentLabelBatch(root, []string{"a"}, time.Now().Add(-time.Second), 512, load) + if !partial || len(got) != 0 || calls != 0 { + t.Fatalf("expired lookup did work: %#v %v %d", got, partial, calls) + } + got, partial = readAgentLabelBatch(root, []string{"a"}, time.Now().Add(time.Second), 1, load) + if !partial || len(got) != 0 || calls != 1 { + t.Fatalf("bounded lookup pretended complete: %#v %v %d", got, partial, calls) + } +} +func TestAgentLabelMetaRequiresTheActualIdentityAndBound(t *testing.T) { + path := filepath.Join(t.TempDir(), "meta.json") + for _, raw := range []string{`{"id":"another","title":"Wrong"}`, `{"id":"a","title":`, string(make([]byte, labelMetaBytes+1))} { + if err := os.WriteFile(path, []byte(raw), 0o600); err != nil { + t.Fatal(err) + } + if _, ok := loadAgentLabelMeta(path, "a"); ok { + t.Fatal("invalid metadata became a label") + } + } +} diff --git a/internal/appwire/labels.go b/internal/appwire/labels.go new file mode 100644 index 0000000000..4244900a29 --- /dev/null +++ b/internal/appwire/labels.go @@ -0,0 +1,50 @@ +package appwire + +import "strings" + +const AgentLabelsMax = 32 + +type AgentLabel struct { + ID string `json:"id"` + Title string `json:"title"` +} +type AgentLabels struct { + Labels []AgentLabel `json:"labels"` + Incomplete bool `json:"incomplete,omitempty"` +} +type AgentLabelReader interface { + AgentLabels(ids []string) (AgentLabels, error) +} + +func CheckAgentLabelIDs(ids []string) error { + if len(ids) > AgentLabelsMax { + return Errorf(CodeBadParams, "at most %d agent labels may be requested", AgentLabelsMax) + } + for _, id := range ids { + if id == "" || len(id) > 256 || strings.TrimSpace(id) != id || strings.ContainsAny(id, "/\\\x00") || strings.HasPrefix(id, ".") { + return Errorf(CodeBadParams, "an agent label needs a valid agent id") + } + } + return nil +} +func init() { + methods["agents.labels"] = typed(func(e Engine, p struct { + IDs []string `json:"ids"` + }) (*AgentLabels, error) { + if err := CheckAgentLabelIDs(p.IDs); err != nil { + return nil, err + } + reader, ok := e.(AgentLabelReader) + if !ok { + return nil, Errorf(CodeUnknownMethod, "this engine does not serve agents.labels") + } + out, err := reader.AgentLabels(p.IDs) + if err != nil { + return nil, err + } + if out.Labels == nil { + out.Labels = []AgentLabel{} + } + return &out, nil + }) +} diff --git a/internal/appwire/labels_test.go b/internal/appwire/labels_test.go new file mode 100644 index 0000000000..0048493898 --- /dev/null +++ b/internal/appwire/labels_test.go @@ -0,0 +1,35 @@ +package appwire + +import ( + "encoding/json" + "testing" +) + +type labelsEngine struct { + fakeEngine + seen []string +} + +func (e *labelsEngine) AgentLabels(ids []string) (AgentLabels, error) { + e.seen = ids + return AgentLabels{Labels: []AgentLabel{{ID: "a", Title: "Release"}}, Incomplete: true}, nil +} +func TestAgentLabelsBoundedOptionalRoute(t *testing.T) { + e := &labelsEngine{} + out, err := methods["agents.labels"].call(e, json.RawMessage(`{"ids":["a"]}`)) + if err != nil { + t.Fatal(err) + } + p := out.(*AgentLabels) + if len(e.seen) != 1 || len(p.Labels) != 1 || p.Labels[0].Title != "Release" || !p.Incomplete { + t.Fatalf("lost metadata: %#v", p) + } + if _, err := methods["agents.labels"].call(&fakeEngine{}, json.RawMessage(`{"ids":["a"]}`)); codeOf(err) != CodeUnknownMethod { + t.Fatalf("unsupported: %v", err) + } + for _, ids := range [][]string{make([]string, 33), {"../x"}, {"a/b"}, {" "}, {"x\\y"}} { + if err := CheckAgentLabelIDs(ids); codeOf(err) != CodeBadParams { + t.Fatalf("invalid ids: %q %v", ids, err) + } + } +} From 8d17c11ecd552afecfc493506b9df6f7ab2b795c Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 16 Oct 2026 01:15:15 -0400 Subject: [PATCH 151/459] Record measured media and document evidence with read-time provenance --- cmd/codeaf/appmedia.go | 8 + cmd/codeaf/appmedia_test.go | 46 +++ internal/appwire/media.go | 12 +- internal/appwire/media_test.go | 27 ++ internal/pdfx/pdfx.go | 20 +- internal/session/media_observed.go | 48 +++ internal/session/mediacache.go | 4 + internal/session/mediaevidence.go | 44 ++- internal/session/mediafact.go | 47 ++- internal/session/mediaheader.go | 112 ++++++ internal/session/mediametadata_test.go | 462 +++++++++++++++++++++++++ internal/session/tools_doc.go | 63 +++- internal/session/tools_sense.go | 40 +-- internal/session/tools_video.go | 5 +- internal/session/tools_view.go | 11 +- 15 files changed, 904 insertions(+), 45 deletions(-) create mode 100644 internal/session/media_observed.go create mode 100644 internal/session/mediaheader.go create mode 100644 internal/session/mediametadata_test.go diff --git a/cmd/codeaf/appmedia.go b/cmd/codeaf/appmedia.go index 4ade8f7e78..df283d169d 100644 --- a/cmd/codeaf/appmedia.go +++ b/cmd/codeaf/appmedia.go @@ -133,6 +133,14 @@ func appMediaSource(row session.MediaRow, live session.MediaLive, probe bool) ap n := fact.Requested out.RequestedCount = &n } + if fact.PageCount > 0 { + n := fact.PageCount + out.PageCount = &n + } + if fact.TextLayer != nil { + layer := *fact.TextLayer + out.TextLayer = &layer + } if fact.DurationMS > 0 { ms := fact.DurationMS out.DurationMS = &ms diff --git a/cmd/codeaf/appmedia_test.go b/cmd/codeaf/appmedia_test.go index bf338235ae..3bc540ba5d 100644 --- a/cmd/codeaf/appmedia_test.go +++ b/cmd/codeaf/appmedia_test.go @@ -205,3 +205,49 @@ func TestAppMediaPagesStayUnderTheReplyBound(t *testing.T) { } } } + +// A document the agent read is a source of kind document: media.get returns the +// full words verbatim, the parser's page count and a known-false text layer, +// and a document whose parser reported nothing carries neither field. +func TestAppMediaGetReturnsDocumentEvidenceFacts(t *testing.T) { + backend, _, agent := workBackend(t, &fakeConversation{script: []session.Event{{Kind: session.EventTurnDone}}}) + index := filepath.Join(t.TempDir(), "artifacts.jsonl") + old := appMediaIndex + appMediaIndex = func() string { return index } + t.Cleanup(func() { appMediaIndex = old }) + sessionID, _, err := backend.mediaScope(agent.ID) + if err != nil { + t.Fatal(err) + } + pdf := filepath.Join(agent.Workspace, "contract.pdf") + if err := os.WriteFile(pdf, []byte("%PDF-1.4 stand-in"), 0o644); err != nil { + t.Fatal(err) + } + words := strings.Repeat("The parties agree to the terms set out below. ", 40) + layer := false + scan := session.RecordMedia(index, session.Artifact{Path: pdf, Session: sessionID, Title: "contract.pdf", Kind: "document", Created: time.Now()}, + session.MediaFact{Origin: session.MediaEvidence, MIME: "application/pdf", Verb: session.EvidenceRead, Saw: words, + SeenBy: "vendor/reader", Working: "chat/model", PageCount: 14, TextLayer: &layer}) + billed := session.RecordMedia(index, session.Artifact{Path: pdf, Session: sessionID, Title: "contract.pdf", Kind: "document", Created: time.Now()}, + session.MediaFact{Origin: session.MediaEvidence, MIME: "application/pdf", Verb: session.EvidenceRead, Saw: "billed words"}) + // Another deliverable that merely has the kind "document" is not a source. + session.RecordArtifact(index, session.Artifact{Path: pdf, Session: sessionID, Title: "export", Kind: "document", Created: time.Now()}) + + page, err := backend.ListMedia(agent.ID, "", 50) + if err != nil || len(page.Sources) != 2 { + t.Fatalf("list = %+v %v", page, err) + } + got, err := backend.GetMedia(agent.ID, scan) + if err != nil || got.Kind != "document" || got.Origin != "evidence" || got.Verb != "read" || got.Saw != words || + got.SeenBy != "vendor/reader" || got.WorkingModel != "chat/model" || got.PageCount == nil || *got.PageCount != 14 || + got.TextLayer == nil || *got.TextLayer || got.State != appwire.MediaAvailable || got.Charge != "unknown" { + t.Fatalf("get = %+v %v", got, err) + } + thin, err := backend.GetMedia(agent.ID, billed) + if err != nil || thin.PageCount != nil || thin.TextLayer != nil || thin.Saw != "billed words" { + t.Fatalf("unreported facts reached the wire: %+v %v", thin, err) + } + if raw, _ := json.Marshal(thin); strings.Contains(string(raw), "page_count") || strings.Contains(string(raw), "text_layer") { + t.Fatalf("serialized %s", raw) + } +} diff --git a/internal/appwire/media.go b/internal/appwire/media.go index 086bc12a9b..fdbadc117e 100644 --- a/internal/appwire/media.go +++ b/internal/appwire/media.go @@ -103,7 +103,7 @@ const ( // unchanged whatever the file is now: a missing file keeps the size it had. type MediaSource struct { ID string `json:"id"` - Kind string `json:"kind" enum:"image,audio,video"` + Kind string `json:"kind" enum:"image,audio,video,document"` Origin string `json:"origin" enum:"attachment,generated,evidence"` // Path is the recorded canonical path, for display and for the app's // native side to open ONLY when State is available. @@ -147,7 +147,7 @@ type MediaSource struct { References []string `json:"references,omitempty"` Said string `json:"said,omitempty"` // Evidence only (origin evidence — a file the agent looked at): Verb is - // looked_at · listened_to · watched; Saw is the looking model's words + // looked_at · listened_to · watched · read; Saw is the looking model's words // verbatim (SawOmitted when too long to keep whole); SeenBy is the model // that looked and WorkingModel the one the session was running, which an // app says only when they differ; Transcribed says the words are a speech @@ -159,6 +159,14 @@ type MediaSource struct { SeenBy string `json:"seen_by,omitempty"` WorkingModel string `json:"working_model,omitempty"` Transcribed bool `json:"transcribed,omitempty"` + // PageCount and TextLayer are on a "document" source (a PDF the agent read) + // and only where the parser reported them: a billed parse that returns no + // page boundaries leaves PageCount absent, and TextLayer false means the + // extractor found a scanned file, not that nothing is known. A document is + // evidence only (Origin evidence, Verb read) and is never an attachment or + // generated. + PageCount *int `json:"page_count,omitempty"` + TextLayer *bool `json:"text_layer,omitempty"` // Charge is charged, not_charged or unknown, from the provider's reported // usage; CostUSD is present only when charged. Charge string `json:"charge" enum:"charged,not_charged,unknown"` diff --git a/internal/appwire/media_test.go b/internal/appwire/media_test.go index cea730a358..27b652c1ba 100644 --- a/internal/appwire/media_test.go +++ b/internal/appwire/media_test.go @@ -120,3 +120,30 @@ type bigEngine struct { func (b *bigEngine) ListMedia(string, string, int) (MediaPage, error) { return b.page, nil } func (b *bigEngine) GetMedia(string, string) (MediaSource, error) { return b.one, nil } + +// A document source carries its parser facts as optional fields: an older +// client's source (no page count, no text layer) still decodes, an unknown fact +// is absent, and a KNOWN false text layer is present and is not mistaken for +// absence. +func TestMediaSourceDocumentFactsAreOptionalAndFalseSurvives(t *testing.T) { + old := `{"id":"src_x","kind":"image","origin":"generated","path":"/w/a.png","state":"available","recorded":true,"created":"2026-10-16T00:00:00Z","charge":"unknown"}` + var decoded MediaSource + if err := json.Unmarshal([]byte(old), &decoded); err != nil || decoded.PageCount != nil || decoded.TextLayer != nil { + t.Fatalf("an old source decoded as %+v (%v)", decoded, err) + } + if raw, _ := json.Marshal(decoded); strings.Contains(string(raw), "page_count") || strings.Contains(string(raw), "text_layer") { + t.Fatalf("unknown document facts were serialized: %s", raw) + } + pages, layer := 14, false + doc := MediaSource{ID: "src_d", Kind: "document", Origin: "evidence", Verb: "read", PageCount: &pages, TextLayer: &layer, Charge: "unknown"} + raw, _ := json.Marshal(doc) + for _, want := range []string{`"kind":"document"`, `"page_count":14`, `"text_layer":false`, `"verb":"read"`} { + if !strings.Contains(string(raw), want) { + t.Fatalf("%s missing from %s", want, raw) + } + } + var back MediaSource + if err := json.Unmarshal(raw, &back); err != nil || back.PageCount == nil || *back.PageCount != 14 || back.TextLayer == nil || *back.TextLayer { + t.Fatalf("round trip = %+v (%v)", back, err) + } +} diff --git a/internal/pdfx/pdfx.go b/internal/pdfx/pdfx.go index e50b2ce53f..dd5ae3db11 100644 --- a/internal/pdfx/pdfx.go +++ b/internal/pdfx/pdfx.go @@ -83,20 +83,28 @@ func (e *NoTextLayerError) Unwrap() error { return ErrNoTextLayer } // wrapping ErrNoTextLayer for a scanned file, "" and a plain error for a file // that would not parse. func Extract(path string) (string, error) { + text, _, err := ExtractCounted(path) + return text, err +} + +// ExtractCounted is Extract plus the page count the parser itself reported: the +// number of pages the document parsed to, which is zero only when the file did +// not parse. A scanned file still answers its count, in the NoTextLayerError. +func ExtractCounted(path string) (string, int, error) { info, err := os.Stat(path) if err != nil { - return "", err + return "", 0, err } if info.IsDir() { - return "", fmt.Errorf("%s is a directory", path) + return "", 0, fmt.Errorf("%s is a directory", path) } document, err := load(path, info.Size()) if err != nil { - return "", err + return "", 0, err } if len(document.Pages) == 0 { - return "", errors.New("the file parsed but contains no pages") + return "", 0, errors.New("the file parsed but contains no pages") } var text strings.Builder @@ -113,9 +121,9 @@ func Extract(path string) (string, error) { } } if !anyText { - return "", &NoTextLayerError{Pages: len(document.Pages)} + return "", len(document.Pages), &NoTextLayerError{Pages: len(document.Pages)} } - return text.String(), nil + return text.String(), len(document.Pages), nil } // ExtractPages returns the text of the pages named, first through last diff --git a/internal/session/media_observed.go b/internal/session/media_observed.go new file mode 100644 index 0000000000..72a92a357c --- /dev/null +++ b/internal/session/media_observed.go @@ -0,0 +1,48 @@ +package session + +import ( + "errors" + "io" + "os" +) + +var errObservedMediaLarge = errors.New("the media file is over its read limit") + +// The identity comes from the SAME open file as the bytes, before a model can +// wait on them. A replacement during that wait must be changed, never shown as +// the file whose description was recorded. A growing file is bounded on read. +func readObservedMedia(path string, ceiling int64) ([]byte, MediaFact, error) { + info, err := os.Stat(path) + if err != nil { + return nil, MediaFact{}, err + } + if !info.Mode().IsRegular() { + return nil, MediaFact{}, errors.New("the media path is not a regular file") + } + if info.Size() > ceiling { + return nil, MediaFact{}, errObservedMediaLarge + } + file, err := os.Open(path) + if err != nil { + return nil, MediaFact{}, err + } + defer file.Close() + info, err = file.Stat() + if err != nil || !info.Mode().IsRegular() { + return nil, MediaFact{}, errors.New("the media file could not be inspected") + } + if info.Size() > ceiling { + return nil, MediaFact{}, errObservedMediaLarge + } + fact := MediaFact{Canonical: canonicalFile(path), Bytes: info.Size(), ModNS: info.ModTime().UnixNano()} + fact.CtimeNS, fact.Dev, fact.Ino = fileIdentity(info) + data, err := io.ReadAll(io.LimitReader(file, ceiling+1)) + if err != nil { + return nil, MediaFact{}, err + } + if int64(len(data)) > ceiling { + return nil, MediaFact{}, errObservedMediaLarge + } + fact.Bytes = int64(len(data)) + return data, fact, nil +} diff --git a/internal/session/mediacache.go b/internal/session/mediacache.go index fd6a6d95c6..e04ec90b39 100644 --- a/internal/session/mediacache.go +++ b/internal/session/mediacache.go @@ -75,6 +75,10 @@ func MediaIndexScans() uint64 { return mediaIndexScans.Load() } func cloneMediaRow(row MediaRow) MediaRow { row.Fact.References = append([]string(nil), row.Fact.References...) + if row.Fact.TextLayer != nil { + layer := *row.Fact.TextLayer + row.Fact.TextLayer = &layer + } if row.Fact.CostUSD != nil { cost := *row.Fact.CostUSD row.Fact.CostUSD = &cost diff --git a/internal/session/mediaevidence.go b/internal/session/mediaevidence.go index c3f6c0039e..a9812a928a 100644 --- a/internal/session/mediaevidence.go +++ b/internal/session/mediaevidence.go @@ -20,11 +20,24 @@ import ( "time" ) +// captureWorking: every perception entry reads `working := a.Model()` BEFORE +// its first network call and hands it to recordEvidence. The call can last a +// minute and the person can switch models inside it; reading the model at +// record time would relabel a look the old model supervised with the new one, +// and "seen by" is shown only where the two differ. + // recordEvidence leaves one evidence row for a file the agent has just // perceived. data is the exact bytes that were sent to the looking model, so // the hash and the size are those bytes' and not a second reading of the disk. -func (a *Agent) recordEvidence(ctx context.Context, absolute, mime, kind string, data []byte, verb, saw, seenBy string, transcribed bool) { - fact := MediaFact{Origin: MediaEvidence, MIME: mime, Verb: verb, SeenBy: seenBy, Working: a.Model(), Transcribed: transcribed} +// working is the session's model as the perception began (see captureWorking). +func (a *Agent) recordEvidence(ctx context.Context, working, absolute, mime, kind string, data []byte, verb, saw, seenBy string, transcribed bool, observed ...MediaFact) { + fact := MediaFact{} + if len(observed) > 0 { + fact = observed[0] + } else { + fact.stampFile(absolute) + } + fact.Origin, fact.MIME, fact.Verb, fact.SeenBy, fact.Working, fact.Transcribed = MediaEvidence, mime, verb, seenBy, working, transcribed if len(saw) <= mediaSaidMax { fact.Saw = saw } else { @@ -39,9 +52,34 @@ func (a *Agent) recordEvidence(ctx context.Context, absolute, mime, kind string, fact.Width, fact.Height = w, h } } - fact.stampFile(absolute) + // The length of a sound and the length and frame of a clip are read off the + // very bytes the looking model was given, where the container states them. + fact.measureContainer(data) sum := sha256.Sum256(data) fact.SHA256, fact.Bytes = hex.EncodeToString(sum[:]), int64(len(data)) fact = fact.WithMade(madeBy(ctx, nil)) a.recordMedia(Artifact{Path: absolute, Title: filepath.Base(absolute), Kind: kind, Created: time.Now()}, fact) } + +// recordDocumentEvidence leaves the evidence row of a document the agent has +// just read through read_document: the extraction verbatim (omitted, and said +// so, when too long to keep whole), the model that read it only where a model +// did, and the page count and text layer only where the parser reported them. +// Nothing is guessed: a billed parse that returns no page boundaries leaves the +// page count out. +func (a *Agent) recordDocumentEvidence(ctx context.Context, working, absolute string, answer DocumentAnswer) { + fact := answer.observed + fact.Origin, fact.MIME, fact.Verb, fact.SeenBy, fact.Working = MediaEvidence, answer.MediaType, EvidenceRead, answer.Model, working + fact.PageCount, fact.TextLayer = answer.Pages, answer.TextLayer + if len(answer.Text) <= mediaSaidMax { + fact.Saw = answer.Text + } else { + fact.SawOmitted = true + } + if answer.Digest != "" { + // The bytes a billed parse was given were in hand, so the hash is theirs. + fact.SHA256 = answer.Digest + } + fact = fact.WithMade(madeBy(ctx, nil)) + a.recordMedia(Artifact{Path: absolute, Title: filepath.Base(absolute), Kind: mediaDocument, Created: time.Now()}, fact) +} diff --git a/internal/session/mediafact.go b/internal/session/mediafact.go index 73ec55c4bf..59dfa5d841 100644 --- a/internal/session/mediafact.go +++ b/internal/session/mediafact.go @@ -58,8 +58,7 @@ const ( MediaAttachment = "attachment" // MediaGenerated is a file a tool of the harness made. MediaGenerated = "generated" - // MediaEvidence is a file the agent looked at. Reserved: nothing records - // it yet, and nothing is allowed to pretend it does. + // MediaEvidence is a file the agent looked at, listened to, watched or read. MediaEvidence = "evidence" ) @@ -68,8 +67,16 @@ const ( EvidenceLookedAt = "looked_at" EvidenceListenedTo = "listened_to" EvidenceWatched = "watched" + // EvidenceRead is a document the agent read (read_document). It is the only + // verb on a "document" kind source. + EvidenceRead = "read" ) +// mediaDocument is the kind of a document the agent read. It exists ONLY as +// evidence: a row of this kind with no structured fact, or one that is not +// evidence, is some other deliverable and is not a media source. +const mediaDocument = "document" + // Whether the provider billed for a generation. const ( MediaCharged = "charged" @@ -167,6 +174,13 @@ type MediaFact struct { SeenBy string `json:"seen_by,omitempty"` Working string `json:"working_model,omitempty"` Transcribed bool `json:"transcribed,omitempty"` + // PageCount and TextLayer are what the PARSER reported about a document it + // read, and are absent when it reported nothing: a parse that bills per page + // and returns no boundaries has no page count, and "no text layer" is only + // known when the local extractor found a scanned file. TextLayer is a pointer + // so a known false stays distinct from an unknown. + PageCount int `json:"page_count,omitempty"` + TextLayer *bool `json:"text_layer,omitempty"` // Charge and CostUSD are the provider's reported usage, see // [ChargeFromUsage]. Charge string `json:"charge,omitempty"` @@ -307,10 +321,33 @@ func GeneratedMedia(path string, data []byte, model string) MediaFact { fact.Width, fact.Height = w, h } } + fact.measureContainer(data) fact.stampFile(path) return fact } +// measureContainer reads the length of a WAV and the length and frame size of +// an mp4 from bytes in hand, and leaves anything it cannot state as unknown. +// It goes by the container's own signature and not by the sniffed content type: +// http.DetectContentType does not know the "isom" brand real renders carry. +// Nothing here looks at a request: a duration the caller ASKED for is not one +// the file has. +func (f *MediaFact) measureContainer(data []byte) { + switch { + case len(data) >= 12 && string(data[0:4]) == "RIFF" && string(data[8:12]) == "WAVE": + if length, ok := wavLength(data); ok { + f.DurationMS = length.Milliseconds() + } + case len(data) >= 12 && string(data[4:8]) == "ftyp": + if length, _, ok := mp4Facts(data); ok { + f.DurationMS = length.Milliseconds() + } + if w, h, ok := mp4FrameSize(data); ok { + f.Width, f.Height = w, h + } + } +} + // DerivedMedia is the fact for a file a local tool made (ffmpeg): the bytes are // not in hand, so only the head of the file is read and no hash is taken. The // origin is generated, and nothing is claimed about a charge. @@ -527,6 +564,12 @@ func mediaRowOf(row Artifact) (MediaRow, bool) { switch row.Kind { case "image", "audio", "video": out.Kind = row.Kind + case mediaDocument: + // Other deliverables are called documents too; only the engine's own + // evidence row is a media source. + if out.Recorded && out.Fact.Origin == MediaEvidence { + out.Kind = mediaDocument + } default: out.Kind = mediaKindOf(out.Fact.MIME) } diff --git a/internal/session/mediaheader.go b/internal/session/mediaheader.go new file mode 100644 index 0000000000..636b70d617 --- /dev/null +++ b/internal/session/mediaheader.go @@ -0,0 +1,112 @@ +package session + +// mediaheader.go reads two facts out of bytes the engine already holds, with no +// process and no file read: how long a WAV runs, and how large an mp4's picture +// is. Both are pure functions of a byte slice, bounded by its length, and both +// answer "not known" rather than a number the header did not honestly state +// (the emptiness law): a streaming WAV whose size field says "unknown", a +// header that claims more audio than the file holds, a rotated mp4 whose +// stored size is not the size anybody sees. +// +// The length of an mp4 is mp4.go's; this file adds only its frame size. + +import ( + "encoding/binary" + "math" + "time" +) + +// wavLength is the playing time of a PCM or float WAV, from its fmt chunk's +// byte rate and the data chunk's declared size. ok is false for anything else: +// not a RIFF/WAVE, a compressed encoding (its byte rate is an average, not a +// length), a data size of zero or the 32-bit "unknown" sentinel a streaming +// writer leaves, or a data chunk longer than the bytes in hand. +func wavLength(data []byte) (time.Duration, bool) { + if len(data) < 12 || string(data[0:4]) != "RIFF" || string(data[8:12]) != "WAVE" { + return 0, false + } + byteRate := uint32(0) + pos := 12 + for pos+8 <= len(data) { + name := string(data[pos : pos+4]) + size := uint64(binary.LittleEndian.Uint32(data[pos+4 : pos+8])) + body := pos + 8 + switch name { + case "fmt ": + if size < 16 || uint64(len(data)-body) < 16 { + return 0, false + } + // 1 is integer PCM and 3 is IEEE float: the encodings whose byte rate + // is exact. WAVE_FORMAT_EXTENSIBLE (0xFFFE) wraps one of them in a + // sub-format this does not read, so it is not known. + if format := binary.LittleEndian.Uint16(data[body : body+2]); format != 1 && format != 3 { + return 0, false + } + byteRate = binary.LittleEndian.Uint32(data[body+8 : body+12]) + case "data": + if byteRate == 0 || size == 0 || size == math.MaxUint32 || size > uint64(len(data)-body) { + return 0, false + } + seconds := float64(size) / float64(byteRate) + if seconds < mp4LengthFloor || seconds > 24*3600 { + return 0, false + } + return time.Duration(seconds * float64(time.Second)), true + } + // Chunks are word-aligned. + next := uint64(body) + size + size&1 + if next > uint64(len(data)) { + return 0, false + } + pos = int(next) + } + return 0, false +} + +// mp4FrameSize is the picture size of an mp4's first video track, in pixels, +// from the track header. It answers false when the track is rotated or +// sheared (the stored size is then not the displayed one), when no video +// track is declared, or when the box is too short to state a size. +func mp4FrameSize(data []byte) (width, height int, ok bool) { + moov, found := mp4FirstBox(data, "moov") + if !found { + return 0, 0, false + } + mp4EachBox(moov, "trak", func(trak []byte) bool { + mdia, found := mp4FirstBox(trak, "mdia") + if !found { + return true + } + hdlr, found := mp4FirstBox(mdia, "hdlr") + if !found || len(hdlr) < 12 || string(hdlr[8:12]) != "vide" { + return true + } + tkhd, found := mp4FirstBox(trak, "tkhd") + if !found || len(tkhd) < 1 { + return false + } + // The matrix starts 36 bytes before the two size words; version 1 widens + // the timestamps and the duration, so its fixed part is 12 bytes longer. + fixed := 84 + if tkhd[0] == 1 { + fixed = 96 + } + if tkhd[0] > 1 || len(tkhd) < fixed { + return false + } + matrix := tkhd[fixed-44 : fixed-8] + unit := uint32(0x10000) + if binary.BigEndian.Uint32(matrix[0:4]) != unit || binary.BigEndian.Uint32(matrix[16:20]) != unit || + binary.BigEndian.Uint32(matrix[4:8]) != 0 || binary.BigEndian.Uint32(matrix[12:16]) != 0 || + binary.BigEndian.Uint32(matrix[8:12]) != 0 || binary.BigEndian.Uint32(matrix[20:24]) != 0 || binary.BigEndian.Uint32(matrix[32:36]) != 1<<30 { + return false + } + w := int(binary.BigEndian.Uint32(tkhd[fixed-8:fixed-4]) >> 16) + h := int(binary.BigEndian.Uint32(tkhd[fixed-4:fixed]) >> 16) + if w > 0 && h > 0 { + width, height, ok = w, h, true + } + return false + }) + return width, height, ok +} diff --git a/internal/session/mediametadata_test.go b/internal/session/mediametadata_test.go new file mode 100644 index 0000000000..0a464c8985 --- /dev/null +++ b/internal/session/mediametadata_test.go @@ -0,0 +1,462 @@ +package session + +// mediametadata_test.go pins the next stage of media source facts: the length +// of a sound and the frame of a clip read from headers, the document a model +// read, and WHO was working when a look began. + +import ( + "context" + "crypto/sha256" + "encoding/binary" + "encoding/hex" + "encoding/json" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/provider" +) + +// ── fixtures ──────────────────────────────────────────────────────────────── + +// wavFile builds a WAV by chunk: a fmt chunk of the given format and byte +// rate, then a data chunk that DECLARES declared bytes and carries have. +func wavFile(format uint16, byteRate uint32, declared uint32, have int) []byte { + chunk := func(name string, payload []byte, size uint32) []byte { + out := make([]byte, 8, 8+len(payload)) + copy(out, name) + binary.LittleEndian.PutUint32(out[4:8], size) + return append(out, payload...) + } + spec := make([]byte, 16) + binary.LittleEndian.PutUint16(spec[0:2], format) + binary.LittleEndian.PutUint16(spec[2:4], 1) + binary.LittleEndian.PutUint32(spec[4:8], byteRate/2) + binary.LittleEndian.PutUint32(spec[8:12], byteRate) + binary.LittleEndian.PutUint16(spec[12:14], 2) + binary.LittleEndian.PutUint16(spec[14:16], 16) + body := append([]byte("WAVE"), chunk("fmt ", spec, 16)...) + body = append(body, chunk("data", make([]byte, have), declared)...) + head := make([]byte, 8) + copy(head, "RIFF") + binary.LittleEndian.PutUint32(head[4:8], uint32(len(body))) + return append(head, body...) +} + +// mp4Track is a trak with a track header (tkhd) stating a stored size and a +// matrix, and a handler. +func mp4Track(handler string, width, height uint32, rotated bool) []byte { + tkhd := make([]byte, 84) + matrix := tkhd[40:76] + binary.BigEndian.PutUint32(matrix[0:4], 0x10000) + binary.BigEndian.PutUint32(matrix[16:20], 0x10000) + binary.BigEndian.PutUint32(matrix[32:36], 0x40000000) + if rotated { + binary.BigEndian.PutUint32(matrix[0:4], 0) + binary.BigEndian.PutUint32(matrix[4:8], 0x10000) + binary.BigEndian.PutUint32(matrix[12:16], 0xFFFF0000) + binary.BigEndian.PutUint32(matrix[16:20], 0) + } + binary.BigEndian.PutUint32(tkhd[76:80], width<<16) + binary.BigEndian.PutUint32(tkhd[80:84], height<<16) + hdlr := make([]byte, 12) + copy(hdlr[8:12], handler) + return mp4TestBox("trak", append(mp4TestBox("tkhd", tkhd), mp4TestBox("mdia", mp4TestBox("hdlr", hdlr))...)) +} + +// ── the header parsers ────────────────────────────────────────────────────── + +func TestWavLengthAndMp4FrameStateOnlyWhatTheHeaderStates(t *testing.T) { + if length, ok := wavLength(wavFile(1, 32000, 64000, 64000)); !ok || length != 2*time.Second { + t.Fatalf("a two second PCM WAV read as %v %v", length, ok) + } + for name, data := range map[string][]byte{ + "streaming sentinel": wavFile(1, 32000, 0xFFFFFFFF, 100), + "zero data": wavFile(1, 32000, 0, 0), + "claims more than held": wavFile(1, 32000, 64000, 100), + "compressed encoding": wavFile(0x55, 32000, 64000, 64000), + "zero byte rate": wavFile(1, 0, 64000, 64000), + "not riff": []byte("ID3\x04voice voice voice"), + "empty": nil, + } { + if length, ok := wavLength(data); ok || length != 0 { + t.Fatalf("%s read as %v: a header that does not state a length must stay unknown", name, length) + } + } + + film := mp4TestFile(mp4TestBox("mvhd", mp4TestMovieHeader(600, 3000)), mp4Track("vide", 1280, 720, false), mp4TestTrack("soun")) + if w, h, ok := mp4FrameSize(film); !ok || w != 1280 || h != 720 { + t.Fatalf("frame = %dx%d %v", w, h, ok) + } + if length, sound, ok := mp4Facts(film); !ok || length != 5*time.Second || !sound { + t.Fatalf("length = %v %v %v", length, sound, ok) + } + for name, data := range map[string][]byte{ + "rotated": mp4TestFile(mp4TestBox("mvhd", mp4TestMovieHeader(600, 3000)), mp4Track("vide", 1280, 720, true)), + "audio only": mp4TestFile(mp4TestBox("mvhd", mp4TestMovieHeader(600, 3000)), mp4Track("soun", 1280, 720, false)), + "zero size": mp4TestFile(mp4TestBox("mvhd", mp4TestMovieHeader(600, 3000)), mp4Track("vide", 0, 0, false)), + "no tkhd": mp4TestFile(mp4TestBox("mvhd", mp4TestMovieHeader(600, 3000)), mp4TestTrack("vide")), + "not an mp4": []byte("not an mp4 at all"), + "short header": mp4TestFile(mp4TestBox("trak", mp4TestBox("tkhd", []byte{0, 1, 2}))), + } { + if w, h, ok := mp4FrameSize(data); ok || w != 0 || h != 0 { + t.Fatalf("%s read as %dx%d", name, w, h) + } + } +} + +// ── generated media: measured, never requested ────────────────────────────── + +// A rendered clip's length and frame come from ITS OWN boxes. The request asked +// for eight seconds at 1080p; the file says five at 1280x720; the record says +// what the file says. A file that states nothing records nothing. +func TestGeneratedWavAndMp4RecordMeasuredFactsNotRequested(t *testing.T) { + film := mp4TestFile(mp4TestBox("mvhd", mp4TestMovieHeader(600, 3000)), mp4Track("vide", 1280, 720, false)) + tune := wavFile(1, 32000, 96000, 96000) + media := &scriptedMedia{music: tune, musicFormat: "wav", video: film} + index := filepath.Join(t.TempDir(), "artifacts.jsonl") + agent, _ := newMediaAgent(t, media, func(config *Config) { config.ArtifactsIndex = index }) + if _, bad := runTool(t, agent, "generate_music", `{"prompt":"a tune"}`); bad { + t.Fatal("generate_music failed") + } + if _, bad := runTool(t, agent, "generate_video", `{"prompt":"a ferry","duration":8,"resolution":"1080p"}`); bad { + t.Fatal("generate_video failed") + } + waitFor(t, "both renders to land", func() bool { return len(ReadArtifacts(index)) == 2 }) + for _, row := range ReadArtifacts(index) { + switch row.Kind { + case "audio": + if row.Media.DurationMS != 3000 || row.Media.Width != 0 { + t.Fatalf("music = %+v", row.Media) + } + case "video": + if row.Media.DurationMS != 5000 || row.Media.Width != 1280 || row.Media.Height != 720 { + t.Fatalf("video = %+v: the request's 8s and 1080p are not the file's", row.Media) + } + } + } + + // An mp3 has no parser here, and a file that cannot be measured says nothing. + bare := &scriptedMedia{audio: []byte("ID3\x04voice"), video: []byte("not an mp4")} + other := filepath.Join(t.TempDir(), "artifacts.jsonl") + spoken, _ := newMediaAgent(t, bare, func(config *Config) { config.ArtifactsIndex = other }) + if _, bad := runTool(t, spoken, "speak", `{"text":"hello"}`); bad { + t.Fatal("speak failed") + } + if rows := ReadArtifacts(other); len(rows) != 1 || rows[0].Media.DurationMS != 0 { + t.Fatalf("a spoken mp3 invented a length: %+v", rows) + } +} + +// A sound and a clip the agent perceives carry the length the container states. +func TestEvidenceCarriesMeasuredLengthOnlyWhereTheContainerStatesIt(t *testing.T) { + index := filepath.Join(t.TempDir(), "artifacts.jsonl") + ear := &scriptedEar{text: "Meet me by the north gate at nine, and bring the folder with the signed copies."} + agent, workspace := newTestAgent(t, &scriptedCompleter{}, func(config *Config) { + config.ArtifactsIndex = index + config.Media = ear + config.MediaModel = senseResolver(map[string]string{"transcribe": "ears/model"}) + }) + wav := dropFile(t, workspace, "memo.wav", wavFile(1, 32000, 64000, 64000)) + mp3 := dropFile(t, workspace, "memo.mp3", mp3Bytes(64)) + for _, name := range []string{wav, mp3} { + if text, bad := readTool(t, agent, map[string]any{"path": name}); bad { + t.Fatal(text) + } + } + got := map[string]int64{} + for _, row := range ReadArtifacts(index) { + got[row.Title] = row.Media.DurationMS + } + if len(got) != 2 || got["memo.wav"] != 2000 || got["memo.mp3"] != 0 { + t.Fatalf("durations = %v", got) + } +} + +// ── who was working ───────────────────────────────────────────────────────── + +// A model switched while a look is in flight does not relabel it: the record +// says who was working when the look BEGAN. +func TestWorkingModelIsCapturedBeforeTheNetworkCall(t *testing.T) { + index := filepath.Join(t.TempDir(), "artifacts.jsonl") + var agent *Agent + switchTo := "switched/after-the-call-began" + completer := &scriptedCompleter{steps: []step{ + func(context.Context, []ai.Message) (*ai.Response, error) { + agent.SetModel(switchTo) + return textResponse("a harbour at dusk"), nil + }, + }} + agent, workspace := newTestAgent(t, completer, func(config *Config) { + config.ArtifactsIndex = index + config.MediaModel = senseResolver(map[string]string{"vision": "vendor/slot-eyes"}) + }) + before := agent.Model() + if before == switchTo { + t.Fatal("the fixture starts on the model it switches to") + } + writeImage(t, workspace, "harbour.png", "PHOTOBYTES") + if out, bad, err := viewTool(t, agent).Execute(context.Background(), []byte(`{"path":"harbour.png"}`)); err != nil || bad { + t.Fatalf("view_image: %v %s", err, out) + } + if agent.Model() != switchTo { + t.Fatalf("the switch did not happen (%s): the test proves nothing", agent.Model()) + } + fact := ReadArtifacts(index)[0].Media + if fact.Working != before || fact.SeenBy != "vendor/slot-eyes" { + t.Fatalf("working = %q seen by %q, want %q", fact.Working, fact.SeenBy, before) + } + + // The same holds for a sound and a clip, and for a document. + hear := &switchingEar{scriptedEar: scriptedEar{text: "The delivery is rescheduled to Thursday afternoon, after lunch."}, agent: &agent, to: switchTo + "/2"} + agent.SetModel(before) + agent.config.Media = hear + agent.config.MediaModel = senseResolver(map[string]string{"transcribe": "ears/model"}) + name := dropFile(t, workspace, "memo.mp3", mp3Bytes(64)) + if text, bad := readTool(t, agent, map[string]any{"path": name}); bad { + t.Fatal(text) + } + if agent.Model() != switchTo+"/2" { + t.Fatal("the transcription did not switch the model") + } + var memo *MediaFact + for _, row := range ReadArtifacts(index) { + if row.Title == "memo.mp3" { + memo = row.Media + } + } + if memo == nil || memo.Working != before || memo.SeenBy != "ears/model" { + t.Fatalf("audio evidence = %+v", memo) + } +} + +type switchingEar struct { + scriptedEar + agent **Agent + to string +} + +func (e *switchingEar) Transcribe(ctx context.Context, request provider.TranscriptionRequest) (*provider.TranscriptionResponse, error) { + (*e.agent).SetModel(e.to) + return e.scriptedEar.Transcribe(ctx, request) +} + +// ── a document the model read ─────────────────────────────────────────────── + +// switchingParser changes the session's model while the parse is in flight. +type switchingParser struct { + scriptedParser + agent **Agent + to string +} + +func (p *switchingParser) ParseDocument(ctx context.Context, request provider.DocumentRequest) (*provider.DocumentResponse, error) { + (*p.agent).SetModel(p.to) + return p.scriptedParser.ParseDocument(ctx, request) +} + +func documentEvidence(t *testing.T, index string) []Artifact { + t.Helper() + var out []Artifact + for _, row := range ReadArtifacts(index) { + if row.Kind == mediaDocument { + out = append(out, row) + } + } + return out +} + +// The local extractor states a count and a text layer; the evidence keeps them +// and the words exactly as the model was given them, and no model is credited. +func TestReadDocumentLocalPDFIsDocumentEvidenceWithParserFacts(t *testing.T) { + index := filepath.Join(t.TempDir(), "artifacts.jsonl") + parser := &scriptedParser{} + agent, workspace := newDocumentAgent(t, parser, nil, func(config *Config) { config.ArtifactsIndex = index }) + name := dropFile(t, workspace, "memo.pdf", onePageTextPDF("Section 4 begins here and runs for several honest lines of text")) + if text, bad := documentTool(t, agent, map[string]any{"path": name}); bad { + t.Fatal(text) + } + rows := documentEvidence(t, index) + if len(rows) != 1 { + t.Fatalf("%d document rows", len(rows)) + } + fact := rows[0].Media + if fact.Origin != MediaEvidence || fact.Verb != EvidenceRead || fact.MIME != "application/pdf" || + !strings.Contains(fact.Saw, "Section 4 begins here") || fact.PageCount != 1 || + fact.TextLayer == nil || !*fact.TextLayer || fact.SeenBy != "" || fact.Working != agent.Model() || len(parser.requests()) != 0 { + t.Fatalf("evidence = %+v", fact) + } + // Listed, it is a document source, and the full words are retrievable. + row, ok := mediaRowOf(rows[0]) + if !ok || row.Kind != mediaDocument { + t.Fatalf("row = %+v %v", row, ok) + } + // The agent's own door has no journal id in a test; the record is moved to a + // session and read back through the same lookup the wire uses. + moved := filepath.Join(t.TempDir(), "artifacts.jsonl") + id := RecordMedia(moved, Artifact{Path: rows[0].Path, Session: "s1", Title: rows[0].Title, Kind: rows[0].Kind, Created: time.Now()}, *fact) + found, err := FindMedia(moved, "s1", id, time.Now().Add(time.Second)) + if err != nil || found.Kind != mediaDocument || found.Fact.Saw != fact.Saw || found.Fact.PageCount != 1 || + found.Fact.TextLayer == nil || !*found.Fact.TextLayer { + t.Fatalf("found = %+v %v", found, err) + } + if live := ResolveMedia(row, []string{workspace}); live.State != MediaAvailable { + t.Fatalf("live = %+v", live) + } +} + +// A scan has a known page count and a known absence of a text layer; the billed +// native rung is a MODEL that read it. An engine pinned to a billed parser has +// neither fact, and says so by saying nothing — never a zero or a false. +func TestReadDocumentScanAndBilledParseKeepOnlyWhatWasReported(t *testing.T) { + index := filepath.Join(t.TempDir(), "artifacts.jsonl") + var agent *Agent + parser := &switchingParser{ + scriptedParser: scriptedParser{answers: map[provider.DocumentParseEngine]string{ + provider.DocumentParseNative: "Invoice 114, total due thirty days after delivery of the goods.", + provider.DocumentParseMistralOCR: "Receipt 9, paid in full on the first of the month, thank you.", + }}, + agent: &agent, to: "switched/mid-parse", + } + original := newDocClient + newDocClient = func(Config) (DocumentParser, error) { return parser, nil } + t.Cleanup(func() { newDocClient = original }) + var workspace string + agent, workspace = newTestAgent(t, &scriptedCompleter{}, func(config *Config) { config.ArtifactsIndex = index }) + before := agent.Model() + scan := dropFile(t, workspace, "scan.pdf", scannedPDF()) + if text, bad := documentTool(t, agent, map[string]any{"path": scan}); bad { + t.Fatal(text) + } + rows := documentEvidence(t, index) + if len(rows) != 1 { + t.Fatalf("%d document rows", len(rows)) + } + fact := rows[0].Media + if fact.PageCount != 1 || fact.TextLayer == nil || *fact.TextLayer || fact.SeenBy != before || fact.Working != before || + !strings.Contains(fact.Saw, "Invoice 114") || len(fact.SHA256) != 64 { + t.Fatalf("scan evidence = %+v", fact) + } + if agent.Model() != "switched/mid-parse" { + t.Fatal("the parse did not switch the model") + } + + // Pinned to the OCR parser: the local extractor never ran, so no count and + // no verdict on a text layer exist, and neither is serialized. + agent.SetModel(before) + agent.config.DocumentEngine = "ocr" + other := dropFile(t, workspace, "second.pdf", scannedPDF()[:len(scannedPDF())-1]) + if text, bad := documentTool(t, agent, map[string]any{"path": other}); bad { + t.Fatal(text) + } + var billed *MediaFact + for _, row := range documentEvidence(t, index) { + if row.Title == "second.pdf" { + billed = row.Media + } + } + if billed == nil || billed.PageCount != 0 || billed.TextLayer != nil || billed.SeenBy != "" || !strings.Contains(billed.Saw, "Receipt 9") { + t.Fatalf("billed evidence = %+v", billed) + } + raw, _ := json.Marshal(billed) + if strings.Contains(string(raw), "page_count") || strings.Contains(string(raw), "text_layer") { + t.Fatalf("unknown facts reached the record: %s", raw) + } +} + +// A repeat answered from the memo is not a second look, a failed read leaves +// nothing, and a document the road does not read as a PDF is not document +// evidence; another deliverable that is called a document is not a source. +func TestDocumentEvidenceIsFreshPDFReadsOnly(t *testing.T) { + index := filepath.Join(t.TempDir(), "artifacts.jsonl") + parser := &scriptedParser{answers: map[provider.DocumentParseEngine]string{ + provider.DocumentParseMistralOCR: "Receipt 9, paid in full on the first of the month, thank you.", + }} + agent, workspace := newDocumentAgent(t, parser, nil, func(config *Config) { + config.ArtifactsIndex = index + config.DocumentEngine = "ocr" + }) + scan := dropFile(t, workspace, "scan.pdf", scannedPDF()) + for i := 0; i < 2; i++ { + if text, bad := documentTool(t, agent, map[string]any{"path": scan}); bad { + t.Fatal(text) + } + } + if rows := documentEvidence(t, index); len(rows) != 1 { + t.Fatalf("%d evidence rows for one read and a memo hit", len(rows)) + } + office := dropFile(t, workspace, "plan.docx", []byte("PK not really a docx")) + _, _ = documentTool(t, agent, map[string]any{"path": office}) + failing := &scriptedParser{err: context.DeadlineExceeded} + agent.docs.parser, agent.docs.err = failing, nil + if text, bad := documentTool(t, agent, map[string]any{"path": dropFile(t, workspace, "other.pdf", append(scannedPDF(), ' '))}); !bad { + t.Fatalf("a failing parse was read as success: %s", text) + } + if rows := documentEvidence(t, index); len(rows) != 1 { + t.Fatalf("a docx or a failed read left evidence: %d rows", len(rows)) + } + + // A "document" deliverable of the old kind is not a media source, however it + // is dressed; only the engine's own evidence row is. + export := Artifact{Path: scan, Session: "s", Title: "export", Kind: mediaDocument, Created: time.Now()} + if _, ok := mediaRowOf(export); ok { + t.Fatal("a plain document deliverable became a media source") + } + export.Media = &MediaFact{Origin: MediaGenerated, MIME: "application/pdf"} + if _, ok := mediaRowOf(export); ok { + t.Fatal("a generated 'document' became a media source") + } +} + +func TestPerceptionKeepsTheVersionReadBeforeAFileChangesDuringTheCall(t *testing.T) { + for _, tool := range []string{"view_image", "read"} { + t.Run(tool, func(t *testing.T) { + index := filepath.Join(t.TempDir(), "artifacts.jsonl") + var workspace string + original := []byte("PHOTOBYTES") + completer := &scriptedCompleter{steps: []step{func(context.Context, []ai.Message) (*ai.Response, error) { + path := filepath.Join(workspace, "scene.png") + if err := os.WriteFile(path+".replacement", []byte("OTHERBYTES"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Rename(path+".replacement", path); err != nil { + t.Fatal(err) + } + return textResponse("the original scene"), nil + }}} + agent, dir := newTestAgent(t, completer, func(c *Config) { + c.ArtifactsIndex = index + c.MediaModel = senseResolver(map[string]string{"vision": "eyes/model"}) + }) + workspace = dir + writeImage(t, workspace, "scene.png", string(original)) + if tool == "view_image" { + if text, bad, err := viewTool(t, agent).Execute(context.Background(), []byte(`{"path":"scene.png"}`)); err != nil || bad { + t.Fatalf("look: %s %v", text, err) + } + } else { + if text, bad := readTool(t, agent, map[string]any{"path": "scene.png"}); bad { + t.Fatal(text) + } + } + artifacts := ReadArtifacts(index) + if len(artifacts) != 1 { + t.Fatalf("evidence count %d", len(artifacts)) + } + row, ok := mediaRowOf(artifacts[0]) + if !ok { + t.Fatal("missing source") + } + sum := sha256.Sum256(original) + if row.Fact.SHA256 != hex.EncodeToString(sum[:]) || row.Fact.Saw != "the original scene" { + t.Fatalf("look bytes or words changed: %#v", row.Fact) + } + if live := ResolveMedia(row, []string{workspace}); live.State != MediaChanged { + t.Fatalf("replacement presented as original: %#v", live) + } + }) + } +} diff --git a/internal/session/tools_doc.go b/internal/session/tools_doc.go index 77de2ccb6e..ad98af3034 100644 --- a/internal/session/tools_doc.go +++ b/internal/session/tools_doc.go @@ -292,6 +292,7 @@ func (a *Agent) documentTool() bare.Tool { // command line's doc door: the tool adds the belt's truncation law and its // memo, and nothing else. One road, two doors, no drift. func (a *Agent) readDocument(ctx context.Context, path, question string, offset, limit *int) (string, bool, error) { + working := a.Model() // WHO WAS WORKING when the read began; see captureWorking. answer, err := ReadDocument(ctx, DocumentRead{ Path: path, Question: question, @@ -315,6 +316,11 @@ func (a *Agent) readDocument(ctx context.Context, path, question string, offset, } return err.Error(), true, nil } + // Only a PDF read afresh is evidence: an office file or a photographed page + // has no page count or text layer to state, and a memo hit looked at nothing. + if answer.Kind == "pdf" && !answer.Recalled { + a.recordDocumentEvidence(ctx, working, resolveInWorkspace(path, a.config.Workspace), answer) + } return documentNote(answer.Rung, question, answer.Rung != docEngineNative && answer.Rung != "local") + piReadLaw(a.resultCaps(), answer.Text, offset, limit), false, nil } @@ -378,6 +384,24 @@ var ( type DocumentAnswer struct { Rung string Text string + + // The rest is what the road itself knew, for a caller that records it; every + // field is zero when nothing reported it. Kind is the road's word for the + // file ("pdf", "office", "image") and MediaType its content type. Model is + // the model that read it, set only when a MODEL did (the native rung): a + // parser rung is an engine, not a model. Pages and TextLayer are the local + // extractor's own report — a billed parse returns no page boundaries, so a + // document that needed one has no page count unless the local rung had + // already counted it. Digest is the hash of the exact bytes a billed rung + // was given. Recalled says the text came from the paging memo, so nothing + // was read anew. + Kind, MediaType string + Model string + Pages int + TextLayer *bool + Digest string + Recalled bool + observed MediaFact } // DocumentRead is everything one billed document read needs, named so a caller @@ -453,16 +477,37 @@ func ReadDocument(ctx context.Context, read DocumentRead) (DocumentAnswer, error // comes here — a PDF that turns out to HAVE a text layer must not be sent to // a parser that bills per page for text the binary can extract itself. Same // law as the plain-text guard above, one file type along. + kind := docKindName(entry.kind) + known := DocumentAnswer{Kind: kind, MediaType: entry.mediaType} + known.observed.stampFile(absolute) if entry.kind == documentPDF && setting == docEngineAuto { var text string var localErr error + var pages int if read.PagesFrom > 0 { text, localErr = pdfx.ExtractPages(absolute, read.PagesFrom, read.PagesTo) } else { - text, localErr = pdfx.Extract(absolute) + text, pages, localErr = pdfx.ExtractCounted(absolute) } if localErr == nil && !documentThin(text) { - return DocumentAnswer{Rung: "local", Text: strings.TrimSpace(text)}, nil + // A page range names pages without counting the document, so only the + // whole-document extraction states a count. + local := known + local.Rung, local.Text, local.Pages = "local", strings.TrimSpace(text), pages + layer := true + local.TextLayer = &layer + return local, nil + } + // What the local extractor learned stays with the billed answer: a + // scanned file is a known "no text layer" with a known count, and a file + // that parsed to thin text has a count but no verdict on its layer. + var scanned *pdfx.NoTextLayerError + switch { + case errors.As(localErr, &scanned): + layer := false + known.Pages, known.TextLayer = scanned.Pages, &layer + case localErr == nil: + known.Pages = pages } // A page range the local rung cannot answer is a refusal, not a fall // through: a billed parse returns no page boundaries, so the honest @@ -473,7 +518,7 @@ func ReadDocument(ctx context.Context, read DocumentRead) (DocumentAnswer, error } } - data, err := os.ReadFile(absolute) + data, observed, err := readObservedMedia(absolute, documentMaxBytes) if err != nil { return DocumentAnswer{}, fmt.Errorf("could not read %s", shown) } @@ -482,11 +527,14 @@ func ReadDocument(ctx context.Context, read DocumentRead) (DocumentAnswer, error return DocumentAnswer{}, fmt.Errorf("%s is over the %dMB document limit", shown, documentMaxBytes>>20) } + known.observed = observed digest := sha256.Sum256(data) key := hex.EncodeToString(digest[:]) + "|" + setting + "|" + read.Question if read.Recall != nil { if rung, text, ok := read.Recall(key); ok { - return DocumentAnswer{Rung: rung, Text: text}, nil + recalled := known + recalled.Rung, recalled.Text, recalled.Recalled = rung, text, true + return recalled, nil } } @@ -548,7 +596,12 @@ func ReadDocument(ctx context.Context, read DocumentRead) (DocumentAnswer, error if read.Keep != nil { read.Keep(key, string(rung), text) } - return DocumentAnswer{Rung: string(rung), Text: text}, nil + answer := known + answer.Rung, answer.Text, answer.Digest = string(rung), text, hex.EncodeToString(digest[:]) + if rung == provider.DocumentParseNative { + answer.Model = model + } + return answer, nil } // Every rung named, in the order they were tried, because "which one broke" diff --git a/internal/session/tools_sense.go b/internal/session/tools_sense.go index 7eee875bbe..1a050c2e80 100644 --- a/internal/session/tools_sense.go +++ b/internal/session/tools_sense.go @@ -48,6 +48,7 @@ import ( "encoding/base64" "encoding/hex" "encoding/json" + "errors" "fmt" "os" "path/filepath" @@ -287,23 +288,15 @@ const ( // multi-gigabyte file into memory to discover it was too big. // // A non-empty second return is the whole answer, worded like tools_doc.go's. -func readSenseFile(absolute, shown, kindWord string, ceiling int) ([]byte, string) { - info, err := os.Stat(absolute) - if err != nil { - return nil, fmt.Sprintf("could not read %s", shown) - } - if info.Size() > int64(ceiling) { - return nil, fmt.Sprintf("%s is over the %dMB %s limit", shown, ceiling>>20, kindWord) +func readSenseFile(absolute, shown, kindWord string, ceiling int) ([]byte, MediaFact, string) { + data, observed, err := readObservedMedia(absolute, int64(ceiling)) + if errors.Is(err, errObservedMediaLarge) { + return nil, MediaFact{}, fmt.Sprintf("%s is over the %dMB %s limit", shown, ceiling>>20, kindWord) } - data, err := os.ReadFile(absolute) if err != nil { - return nil, fmt.Sprintf("could not read %s", shown) - } - // Checked again: the file could have grown between the stat and the read. - if len(data) > ceiling { - return nil, fmt.Sprintf("%s is over the %dMB %s limit", shown, ceiling>>20, kindWord) + return nil, MediaFact{}, fmt.Sprintf("could not read %s", shown) } - return data, "" + return data, observed, "" } // ── the memo ──────────────────────────────────────────────────────────────── @@ -447,11 +440,12 @@ func senseNote(label, model string) string { return "[" + label + ": " + model + // imageSense is the undirected look: one shot on the looking model, one fixed // prompt, the answer paged by pi's law. func (a *Agent) imageSense(ctx context.Context, memo *senseMemo, shown, absolute string, entry senseType, offset, limit *int) (string, bool, error) { + working := a.Model() // WHO WAS WORKING when the look began; see captureWorking. seer := a.senseModel(roleSenseVision) if seer == "" { return fmt.Sprintf("%s is an image, and this session has no model that can look at one — set the looking model in settings, or attach the picture to a message.", shown), true, nil } - data, refusal := readSenseFile(absolute, shown, "image", maxImageBytes) + data, observed, refusal := readSenseFile(absolute, shown, "image", maxImageBytes) if refusal != "" { return refusal, true, nil } @@ -467,7 +461,7 @@ func (a *Agent) imageSense(ctx context.Context, memo *senseMemo, shown, absolute } reading := senseReading{note: senseNote("vision", seer), text: answer} memo.put(key, reading) - a.recordEvidence(ctx, absolute, entry.mediaType, "image", data, EvidenceLookedAt, answer, seer, false) + a.recordEvidence(ctx, working, absolute, entry.mediaType, "image", data, EvidenceLookedAt, answer, seer, false, observed) return reading.note + piReadLaw(a.resultCaps(), reading.text, offset, limit), false, nil } @@ -496,6 +490,7 @@ func (a *Agent) imageSense(ctx context.Context, memo *senseMemo, shown, absolute // a refusal invented by a threshold is worse than a short truth // ([documentMinimumRunes] says this at length). func (a *Agent) audioSense(ctx context.Context, memo *senseMemo, shown, absolute string, entry senseType, offset, limit *int) (string, bool, error) { + working := a.Model() // WHO WAS WORKING when the look began; see captureWorking. scribe := a.senseModel(roleSenseTranscribe) listener := a.senseModel(roleSenseListen) if a.config.Media == nil && scribe != "" { @@ -512,7 +507,7 @@ func (a *Agent) audioSense(ctx context.Context, memo *senseMemo, shown, absolute if scribe == "" && listener == "" { return fmt.Sprintf("%s is audio, and this session has no model that can listen to one — set the listening model in settings.", shown), true, nil } - data, refusal := readSenseFile(absolute, shown, "audio", senseAudioMaxBytes) + data, observed, refusal := readSenseFile(absolute, shown, "audio", senseAudioMaxBytes) if refusal != "" { return refusal, true, nil } @@ -556,7 +551,7 @@ func (a *Agent) audioSense(ctx context.Context, memo *senseMemo, shown, absolute default: reading := senseReading{note: senseNote("transcript", scribe), text: text} memo.put(key, reading) - a.recordEvidence(ctx, absolute, entry.mediaType, "audio", data, EvidenceListenedTo, text, scribe, true) + a.recordEvidence(ctx, working, absolute, entry.mediaType, "audio", data, EvidenceListenedTo, text, scribe, true, observed) return reading.note + piReadLaw(a.resultCaps(), reading.text, offset, limit), false, nil } } @@ -570,7 +565,7 @@ func (a *Agent) audioSense(ctx context.Context, memo *senseMemo, shown, absolute if err == nil { reading := senseReading{note: senseNote("audio", listener), text: answer} memo.put(key, reading) - a.recordEvidence(ctx, absolute, entry.mediaType, "audio", data, EvidenceListenedTo, answer, listener, false) + a.recordEvidence(ctx, working, absolute, entry.mediaType, "audio", data, EvidenceListenedTo, answer, listener, false, observed) return reading.note + piReadLaw(a.resultCaps(), reading.text, offset, limit), false, nil } failures = append(failures, listener+": "+oneLineReason(err.Error())) @@ -578,7 +573,7 @@ func (a *Agent) audioSense(ctx context.Context, memo *senseMemo, shown, absolute if thin.text != "" { memo.put(key, thin) - a.recordEvidence(ctx, absolute, entry.mediaType, "audio", data, EvidenceListenedTo, thin.text, scribe, true) + a.recordEvidence(ctx, working, absolute, entry.mediaType, "audio", data, EvidenceListenedTo, thin.text, scribe, true, observed) return thin.note + piReadLaw(a.resultCaps(), thin.text, offset, limit), false, nil } // Every rung named, in the order they were tried, because "which one broke" @@ -651,11 +646,12 @@ func audioNoise(text string) bool { // cheaper than a model that can watch exists to fall back to, exactly as // read_document has one rung for an image ([documentRungs]). func (a *Agent) videoSense(ctx context.Context, memo *senseMemo, shown, absolute string, entry senseType, offset, limit *int) (string, bool, error) { + working := a.Model() // WHO WAS WORKING when the look began; see captureWorking. watcher := a.senseModel(roleSenseWatch) if watcher == "" { return fmt.Sprintf("%s is video, and this session has no model that can watch one — set the watching model in settings.", shown), true, nil } - data, refusal := readSenseFile(absolute, shown, "video", senseVideoMaxBytes) + data, observed, refusal := readSenseFile(absolute, shown, "video", senseVideoMaxBytes) if refusal != "" { return refusal, true, nil } @@ -671,7 +667,7 @@ func (a *Agent) videoSense(ctx context.Context, memo *senseMemo, shown, absolute } reading := senseReading{note: senseNote("video", watcher), text: answer} memo.put(key, reading) - a.recordEvidence(ctx, absolute, entry.mediaType, "video", data, EvidenceWatched, answer, watcher, false) + a.recordEvidence(ctx, working, absolute, entry.mediaType, "video", data, EvidenceWatched, answer, watcher, false, observed) return reading.note + piReadLaw(a.resultCaps(), reading.text, offset, limit), false, nil } diff --git a/internal/session/tools_video.go b/internal/session/tools_video.go index 1fe98610ce..11c94b94f7 100644 --- a/internal/session/tools_video.go +++ b/internal/session/tools_video.go @@ -242,10 +242,9 @@ func (a *Agent) renderVideo(ctx context.Context, client MediaGenerator, renderin } // The fact keeps what the render was given (frames first, then style // references, exactly as named) and the length the file's own boxes state. + // GeneratedMedia reads the length and the frame size off the bytes in hand; + // nothing is taken from the request's own duration or resolution. fact := GeneratedMedia(path, response.Video, model).WithUsage(response.Usage).WithMade(made) - if length, _, ok := mp4Facts(response.Video); ok { - fact = fact.WithDuration(length) - } a.recordMedia(Artifact{ Path: path, Title: mediaTitle(request.Prompt, path), diff --git a/internal/session/tools_view.go b/internal/session/tools_view.go index 69299226da..98740cc758 100644 --- a/internal/session/tools_view.go +++ b/internal/session/tools_view.go @@ -35,6 +35,7 @@ import ( "encoding/base64" "encoding/json" "errors" + "fmt" "path/filepath" "strings" @@ -113,6 +114,7 @@ func (a *Agent) viewImageTool(known string) bare.Tool { // on — look somewhere else, ask the person, say so out loud — and none of them // is a reason to end the turn. func (a *Agent) viewImage(ctx context.Context, path, question, known string) (string, bool, error) { + working := a.Model() // WHO WAS WORKING when the look began; see captureWorking. seer := a.visionSeer() if seer == "" { seer = known @@ -133,8 +135,13 @@ func (a *Agent) viewImage(ctx context.Context, path, question, known string) (st if err != nil { return toolReason(err), true, nil } - data, err := imageBytes(image) + data, observed, err := readObservedMedia(absolute, maxImageBytes) if err != nil { + if errors.Is(err, errObservedMediaLarge) { + err = oversizeImage(absolute) + } else { + err = fmt.Errorf("session: could not read %s", filepath.ToSlash(absolute)) + } return toolReason(err), true, nil } @@ -169,7 +176,7 @@ func (a *Agent) viewImage(ctx context.Context, path, question, known string) (st // wrong, and an API error can carry a whole HTML page. return seer + " could not look at " + shown + ": " + oneLineReason(err.Error()), true, nil } - a.recordEvidence(ctx, absolute, mediaType, "image", data, EvidenceLookedAt, answer, seer, false) + a.recordEvidence(ctx, working, absolute, mediaType, "image", data, EvidenceLookedAt, answer, seer, false, observed) // "seen by " is the same sentence internal/exec's own view_image // returns, and it is said out loud for image.go's reason: a second model // answering in the first one's voice, silently, would be the surface lying From ae047a2582bdc3f713e0af41687b5621b806db2a Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 16 Oct 2026 01:22:49 -0400 Subject: [PATCH 152/459] Preserve reference marks on retry and plain queued sends on older hosts --- cmd/codeaf/app.go | 2 +- cmd/codeaf/appconvo.go | 8 +++++--- 2 files changed, 6 insertions(+), 4 deletions(-) diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index f4aa6d8697..527761ef4d 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -1453,7 +1453,7 @@ func (b *appBackend) Retry(id string) (uint64, error) { if shown != nil && shown.queued { again := *shown again.queued = false - if shown = &again; len(again.described) == 0 { + if shown = &again; len(again.described) == 0 && len(again.reads) == 0 && again.composed == nil && again.text == text { shown = nil } } diff --git a/cmd/codeaf/appconvo.go b/cmd/codeaf/appconvo.go index affaaae9e0..8ebb7a4b23 100644 --- a/cmd/codeaf/appconvo.go +++ b/cmd/codeaf/appconvo.go @@ -426,13 +426,15 @@ func (b *appBackend) sendQueued(a *appAgent, live *appLive) { } b.emit.Emit(appwire.EventQueueChanged, appwire.QueueChanged{Agent: agent, Queued: queued}) shown := next.shown - if shown == nil { + if shown == nil && appKeepsQueuedMarks(live.conn.Conv) { shown = &appSending{text: next.text} - } else { + } else if shown != nil { copy := *shown shown = © } - shown.queued = true + if shown != nil { + shown.queued = true + } if _, err := b.send(agent, next.text, next.clientID, shown); err != nil { live.box.Event(appwire.EventEntryAdded, appwire.EntryAdded{Entry: appwire.Entry{ ID: "qx" + next.id, Agent: agent, Kind: appwire.EntryError, At: b.now(), Text: "the queued message was not sent: " + err.Error(), From bd34d31e3354519c67c95270b1e9d949ec1c8500 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 16 Oct 2026 01:19:28 -0400 Subject: [PATCH 153/459] Keep image variants as one provider request and one recorded charge --- cmd/codeaf/appmedia.go | 2 + cmd/codeaf/appmedia_test.go | 9 + internal/appwire/media.go | 6 + internal/appwire/media_test.go | 19 + .../chat/making-pictures-audio-and-video.md | 22 +- internal/session/mediacache.go | 4 + internal/session/mediafact.go | 37 ++ internal/session/tools_image.go | 138 +++++-- internal/session/tools_image_batch.go | 181 +++++++++ internal/session/tools_image_batch_test.go | 373 ++++++++++++++++++ 10 files changed, 758 insertions(+), 33 deletions(-) create mode 100644 internal/session/tools_image_batch.go create mode 100644 internal/session/tools_image_batch_test.go diff --git a/cmd/codeaf/appmedia.go b/cmd/codeaf/appmedia.go index df283d169d..e5ced41983 100644 --- a/cmd/codeaf/appmedia.go +++ b/cmd/codeaf/appmedia.go @@ -109,6 +109,7 @@ func appMediaSource(row session.MediaRow, live session.MediaLive, probe bool) ap MIME: fact.MIME, Created: row.Artifact.Created.UTC().Format(time.RFC3339Nano), Title: row.Artifact.Title, Model: fact.Model, RequestedModel: fact.Model, Run: fact.Run, Message: fact.Message, Tool: fact.Tool, References: fact.References, Said: fact.Said, + Batch: fact.Batch, VariantIndex: fact.VariantIndex, ReturnedCount: fact.Returned, CostScope: fact.CostScope, Verb: fact.Verb, Saw: fact.Saw, SawOmitted: fact.SawOmitted, SeenBy: fact.SeenBy, WorkingModel: fact.Working, Transcribed: fact.Transcribed, Charge: fact.Charge, } if out.Charge == "" { @@ -116,6 +117,7 @@ func appMediaSource(row session.MediaRow, live session.MediaLive, probe bool) ap } if out.Charge == session.MediaCharged { out.CostUSD = fact.CostUSD + out.CallCostUSD = fact.CallCostUSD } if row.Recorded && fact.Bytes > 0 { bytes := fact.Bytes diff --git a/cmd/codeaf/appmedia_test.go b/cmd/codeaf/appmedia_test.go index 3bc540ba5d..3f796a01db 100644 --- a/cmd/codeaf/appmedia_test.go +++ b/cmd/codeaf/appmedia_test.go @@ -251,3 +251,12 @@ func TestAppMediaGetReturnsDocumentEvidenceFacts(t *testing.T) { t.Fatalf("serialized %s", raw) } } + +func TestAppMediaProjectsOneBatchChargeWithoutDividingIt(t *testing.T) { + cost := 0.17 + row := session.MediaRow{Kind: "image", Recorded: true, Fact: session.MediaFact{ID: "s", Batch: "bat_actual", VariantIndex: 2, Returned: 3, Requested: 4, Charge: session.MediaCharged, CostScope: session.MediaCostScopeCall, CallCostUSD: &cost}} + got := appMediaSource(row, session.MediaLive{}, false) + if got.Batch != "bat_actual" || got.VariantIndex != 2 || got.ReturnedCount != 3 || got.RequestedCount == nil || *got.RequestedCount != 4 || got.CallCostUSD == nil || *got.CallCostUSD != cost || got.CostUSD != nil { + t.Fatalf("wrong batch projection: %+v", got) + } +} diff --git a/internal/appwire/media.go b/internal/appwire/media.go index fdbadc117e..eb5fa2f405 100644 --- a/internal/appwire/media.go +++ b/internal/appwire/media.go @@ -171,6 +171,12 @@ type MediaSource struct { // usage; CostUSD is present only when charged. Charge string `json:"charge" enum:"charged,not_charged,unknown"` CostUSD *float64 `json:"cost_usd,omitempty"` + // Variant fields describe one actual provider request; call cost is never per file. + Batch string `json:"batch,omitempty"` + VariantIndex int `json:"variant_index,omitempty"` + ReturnedCount int `json:"returned_count,omitempty"` + CostScope string `json:"cost_scope,omitempty"` + CallCostUSD *float64 `json:"call_cost_usd,omitempty"` } // MediaPage is media.list's answer. Next resumes the listing below this page; diff --git a/internal/appwire/media_test.go b/internal/appwire/media_test.go index 27b652c1ba..cacf2a9728 100644 --- a/internal/appwire/media_test.go +++ b/internal/appwire/media_test.go @@ -147,3 +147,22 @@ func TestMediaSourceDocumentFactsAreOptionalAndFalseSurvives(t *testing.T) { t.Fatalf("round trip = %+v (%v)", back, err) } } + +func TestMediaVariantCallCostIsNotAFileCharge(t *testing.T) { + cost := 0.17 + source := MediaSource{ID: "s", Batch: "bat_actual", VariantIndex: 2, ReturnedCount: 3, Charge: "charged", CostScope: "call", CallCostUSD: &cost} + raw, err := json.Marshal(source) + if err != nil { + t.Fatal(err) + } + var got map[string]any + if err = json.Unmarshal(raw, &got); err != nil { + t.Fatal(err) + } + if got["batch"] != "bat_actual" || got["variant_index"] != float64(2) || got["returned_count"] != float64(3) || got["cost_scope"] != "call" || got["call_cost_usd"] != cost { + t.Fatalf("lost actual call facts: %s", raw) + } + if _, invented := got["cost_usd"]; invented { + t.Fatalf("invented per-file money: %s", raw) + } +} diff --git a/internal/manual/chat/making-pictures-audio-and-video.md b/internal/manual/chat/making-pictures-audio-and-video.md index 2a4f6117a1..820eecbebf 100644 --- a/internal/manual/chat/making-pictures-audio-and-video.md +++ b/internal/manual/chat/making-pictures-audio-and-video.md @@ -21,7 +21,7 @@ in this conversation — see "Can a task or a harness make media?" below. Yes, with `generate_image`, when a drawing model is available. Arguments: `prompt` (required), `reference_paths`, `aspect_ratio`, `size`, `path`, -`model`. +`model`, `n`. The picture is written to a file, and the result codeaf reads is one line naming it — **the whole path, absolute, from the root** — like: @@ -50,6 +50,26 @@ and folder; an existing file there is overwritten, exactly as `write` would. `aspect_ratio` (for example `16:9`) and `size` (for example `1024x1024`) are passed to the image model untouched. Leave them out for its own default. +## Can you make several versions, variants or a contact sheet of one picture? + +Yes: give `generate_image` an `n` from 1 to 10 (default 1). It asks the drawing +model for that many pictures in **one** request and saves each as its own file — +with `path: "sheet.png"` they are `sheet-1.png`, `sheet-2.png` and so on, and an +existing file with one of those names is never overwritten (the next free name is +used). The result says how many were asked for, how many the model sent and how +many were saved, lists one path per line in the order the model sent them, and +does not rank them — you choose. + +Limits: more than 10 is refused before anything is spent. If the drawing model +cannot return several pictures in one request it says so and the call fails; +codeaf does not quietly make several single requests instead, and it never +retries a paid request. If the model sends fewer than you asked for, only those +are saved and the count is stated. If one picture will not decode or write, the +others stay saved and the failure is named. The cost is for the whole request; +codeaf does not split it per picture. A reply whose pictures together are over +64MB (or any one over 32MB) is refused after being paid for, with a suggestion to +ask for fewer or a smaller size. + ## Do I only get a file path, or can I see the image you made? **You see it, in colour, in the terminal, without doing anything at all.** diff --git a/internal/session/mediacache.go b/internal/session/mediacache.go index e04ec90b39..1daf3deb46 100644 --- a/internal/session/mediacache.go +++ b/internal/session/mediacache.go @@ -79,6 +79,10 @@ func cloneMediaRow(row MediaRow) MediaRow { layer := *row.Fact.TextLayer row.Fact.TextLayer = &layer } + if row.Fact.CallCostUSD != nil { + cost := *row.Fact.CallCostUSD + row.Fact.CallCostUSD = &cost + } if row.Fact.CostUSD != nil { cost := *row.Fact.CostUSD row.Fact.CostUSD = &cost diff --git a/internal/session/mediafact.go b/internal/session/mediafact.go index 59dfa5d841..d900cc8629 100644 --- a/internal/session/mediafact.go +++ b/internal/session/mediafact.go @@ -154,6 +154,15 @@ type MediaFact struct { // request's own N), where the request has one. Files with the same Tool id // were asked for together; nothing is inferred beyond that. Requested int `json:"requested_count,omitempty"` + // Batch, VariantIndex and Returned describe ONE provider request that asked + // for several pictures (generate_image's n > 1); see [MediaFact.WithBatch]. + // Files with the same Batch came from the same request. VariantIndex is the + // 1-based position in the provider's reply (a gap means that output was not + // saved) and Returned is how many pictures the provider's reply held. None of + // the three says anything about quality: variants are never ranked. + Batch string `json:"batch,omitempty"` + VariantIndex int `json:"variant_index,omitempty"` + Returned int `json:"returned_count,omitempty"` // Run, Message and Tool are immutable ids of the turn that made it, where // the tool site knew them. Run string `json:"run,omitempty"` @@ -185,6 +194,12 @@ type MediaFact struct { // [ChargeFromUsage]. Charge string `json:"charge,omitempty"` CostUSD *float64 `json:"cost_usd,omitempty"` + // CostScope is "call" when the charge covers the whole provider request and + // not this file alone; CallCostUSD is then that request's TOTAL, repeated on + // every file of the Batch, and CostUSD is left unset because no provider + // reports a per-picture price. Sum CallCostUSD once per Batch, never per file. + CostScope string `json:"cost_scope,omitempty"` + CallCostUSD *float64 `json:"call_cost_usd,omitempty"` } // NewMediaID mints the id of a new source: random, unique per record, written @@ -386,6 +401,28 @@ func (f MediaFact) WithUsage(usage *ai.Usage) MediaFact { return f } +// MediaCostScopeCall is the [MediaFact.CostScope] of a charge that covers a +// whole provider request. +const MediaCostScopeCall = "call" + +// WithBatch marks a file as one output of a single multi-picture provider +// request: the batch id minted once for that request, the file's 1-based +// position in the reply, and how many pictures the reply held. +func (f MediaFact) WithBatch(batch string, variant, returned int) MediaFact { + f.Batch, f.VariantIndex, f.Returned = batch, variant, returned + return f +} + +// WithCallUsage records the provider's usage as the cost of the WHOLE request: +// the total is kept as CallCostUSD and never as this file's CostUSD, because +// dividing it, or repeating it as each file's price, would invent a figure. +func (f MediaFact) WithCallUsage(usage *ai.Usage) MediaFact { + var total *float64 + f.Charge, total = ChargeFromUsage(usage) + f.CostScope, f.CallCostUSD, f.CostUSD = MediaCostScopeCall, total, nil + return f +} + // mediaMade is what the call that is making a file knew when it started: the // inputs it was handed and the immutable ids of the turn and the tool call. It // is captured where the tool runs, on the call's own context, because a job diff --git a/internal/session/tools_image.go b/internal/session/tools_image.go index 978230ccf4..9a154a8672 100644 --- a/internal/session/tools_image.go +++ b/internal/session/tools_image.go @@ -3,8 +3,8 @@ package session import ( "bytes" "context" - "encoding/base64" "encoding/json" + "errors" "fmt" stdimage "image" // The decoders are imported for their side effect alone: they register @@ -15,7 +15,6 @@ import ( _ "image/gif" _ "image/jpeg" _ "image/png" - "os" "path/filepath" "strings" "time" @@ -70,14 +69,15 @@ const imageDirectory = ".codeaf/images" // itself a valid reference can edit, restyle, combine, and iterate on its own // last render until the thing is right. That is the whole difference between one // picture and a working loop, and it costs three clauses to say. -const generateImageDescription = "Generate an image from a text prompt and save it. Returns the path it was written to and the picture's dimensions — never the image itself, which stays on disk: this conversation carries the path, and the file is what you and the user both refer to afterwards. Give reference_paths to work from pictures instead of from words alone — one reference edits or restyles it, several combine them — and remember that the path this tool just returned is itself a valid reference, so you can pass your own last render back in and iterate on it until the diagram, the character or the layout is right. A first render is a draft: look at the file before you build on it or hand it over, judge it against what was asked, and iterate when it fell short. Give a path to choose the name and the folder; leave it out and the image is saved under a timestamped name derived from the prompt, and the result says where it went." +const generateImageDescription = "Generate an image from a text prompt and save it. Returns the path it was written to and the picture's dimensions — never the image itself, which stays on disk: this conversation carries the path, and the file is what you and the user both refer to afterwards. Give reference_paths to work from pictures instead of from words alone — one reference edits or restyles it, several combine them — and remember that the path this tool just returned is itself a valid reference, so you can pass your own last render back in and iterate on it until the diagram, the character or the layout is right. Set n (1 to 10) to get that many variants of one prompt from a single request; you choose among them yourself. A first render is a draft: look at the file before you build on it or hand it over, judge it against what was asked, and iterate when it fell short. Give a path to choose the name and the folder; leave it out and the image is saved under a timestamped name derived from the prompt, and the result says where it went." const generateImageSchemaJSON = `{"type":"object","properties":{` + `"prompt":{"type":"string","description":"What to draw, as a full description: subject, composition, style, lighting. The whole prompt reaches the image model, so detail is worth writing — every dimension the prompt leaves open, the model fills with its statistical average, and that average is what generic AI imagery looks like. Decide the medium, the light, the palette and the mood yourself and write them down; specificity is the difference between the picture you meant and a generic one. A prompt assembled from the genre's usual clichés lands on that same average by choice — escape a genre by naming a real medium (a print process, a photographic setup, a drafting tradition), not by recoloring it. And specify positively: image models barely read negation, so describe the surface, material and light that ARE there until the default has no room. With reference_paths it is the instruction — what to change about the pictures you gave."},` + `"reference_paths":{"type":"array","items":{"type":"string"},"description":"Pictures to work from, as paths in the workspace (png, jpeg, webp or gif, each up to 10MB). One is an edit or a restyle of it; several are combined. A path this tool returned earlier is a valid reference, which is how you iterate on your own output."},` + `"aspect_ratio":{"type":"string","description":"Shape of the frame, as the image model spells it (for example 16:9 or 1:1). Passed through untouched; leave it out for the model's own default."},` + `"size":{"type":"string","description":"Exact pixel size as WIDTHxHEIGHT (for example 1024x1024). Passed through untouched; leave it out for the model's own default."},` + - `"path":{"type":"string","description":"Where to save it, relative to the workspace. Leave it out for a timestamped name derived from the prompt, saved where this session keeps its pictures. An existing file at this path is overwritten, as with the write tool."}` + + `"n":{"type":"integer","minimum":1,"maximum":10,"default":1,"description":"How many pictures ONE request should return, for a contact sheet of variants of the same prompt. Default 1. Each is saved as its own file and the cost is that of the whole request. If the model cannot return several, the call fails and nothing is retried."},` + + `"path":{"type":"string","description":"Where to save it, relative to the workspace. Leave it out for a timestamped name derived from the prompt, saved where this session keeps its pictures. An existing file at this path is overwritten, as with the write tool; with n above 1 the files are numbered from it (name-1.png, name-2.png) and no existing file is overwritten."}` + `},"required":["prompt"],"additionalProperties":false}` // imageTools is the picture-making half of the belt, and it is CONDITIONAL by @@ -106,6 +106,9 @@ type GenerateImageArgs struct { Size string `json:"size"` Path string `json:"path"` Model string `json:"model"` + // N is how many pictures ONE request asks for, 1 to [ImageVariantsMax]; zero + // means one. + N int `json:"n"` } // ImageGen is everything one generation needs from the surface it runs in: @@ -203,6 +206,12 @@ func GenerateImage(ctx context.Context, gen ImageGen, parsed GenerateImageArgs) if prompt == "" { return "Invalid arguments: prompt is required", true } + // THE COUNT IS CHECKED BEFORE ANYTHING IS PAID FOR, like the prompt: a request + // for eleven pictures is a typo the caller can fix, not a charge. + requested, refusal := imageCount(parsed.N) + if refusal != "" { + return "Invalid arguments: " + refusal, true + } // The call's own choice, resolved before anything is paid for, so a word // that matches nothing costs nothing. From here down `model` is the model // that actually draws, wherever it is named — the request, the failure @@ -220,51 +229,116 @@ func GenerateImage(ctx context.Context, gen ImageGen, parsed GenerateImageArgs) return "Invalid arguments: reference " + refusal, true } - // One picture per call is what the tool asks for; the count recorded with - // the file is this very number, not a guess about variants. - const requested = 1 response, err := gen.Client.GenerateImage(ctx, provider.ImageRequest{ Model: model, Prompt: prompt, N: requested, OutputFormat: "png", AspectRatio: strings.TrimSpace(parsed.AspectRatio), Size: strings.TrimSpace(parsed.Size), InputReferences: references, }) + // A provider may return a usage receipt alongside an error. Account the + // actual receipt once, before any failure or unusable-output branch. + if response != nil && gen.Account != nil { + gen.Account(model, response.Usage) + } if err != nil { - return "Image generation failed (" + model + "): " + err.Error(), true + text := "Image generation failed (" + model + "): " + err.Error() + if requested > 1 { + // A provider that cannot give n pictures says so; the answer is the + // caller's to act on, and asking again as several calls would spend + // money nobody approved. + text += fmt.Sprintf(" — asked for %d images in one request; nothing was retried", requested) + } + return text, true } - if response == nil || len(response.Data) == 0 { + if response == nil { return "Image generation returned no image (" + model + ")", true } - data, decodeErr := base64.StdEncoding.DecodeString(response.Data[0].Base64) - if decodeErr != nil || len(data) == 0 { - return "Image generation returned an unreadable image (" + model + ")", true + // THE BILL IS PAID ONCE PER PROVIDER RESPONSE, BEFORE ANYTHING CAN REFUSE IT. + // A reply that charged and then carried no picture, a picture that will not + // decode or one too large to keep is still a charge, and the figure the + // provider reported is the only one there is to record (see + // [ImageGen.Account]). It is never once per picture. + if len(response.Data) == 0 { + return "Image generation returned no image (" + model + ")", true } - // The picture is paid for whether or not it is any good, so the accounting - // lands before the write can fail (see [imageGen.Account]). - if gen.Account != nil { - gen.Account(model, response.Usage) + // Only the pictures that were asked for are kept: a provider that returns + // more than n is not obeyed, and the one-picture call keeps its old reading + // of the first. + items := response.Data + if len(items) > requested { + items = items[:requested] + } + if why := imageReplyRefusal(items); why != "" { + return fmt.Sprintf("Image generation returned more than can be kept (%s): %s, so none were saved. "+ + "No request was retried; any provider-reported charge was recorded. Ask for fewer images or a smaller size.", model, why), true } - path, err := imageDestinationFrom(gen.Workspace, parsed.Path, parsed.Prompt, data, - response.Data[0].MediaType, gen.Directory) - if err != nil { - return "Could not save the generated image: " + err.Error(), true + numbered := requested > 1 + batch := newBatchID() + var saved []string + var failures []string + for i, item := range items { + path, data, saveErr := saveImageVariant(gen, parsed, numbered, i+1, item) + // The encoded copy is dropped as soon as it is decoded, so a large reply + // does not stay in memory beside the pictures already written. + response.Data[i].Base64 = "" + if saveErr != nil { + if !numbered { + if errors.Is(saveErr, errUnreadableImage) { + return "Image generation returned an unreadable image (" + model + ")", true + } + return "Could not save the generated image: " + saveErr.Error(), true + } + if errors.Is(saveErr, errUnreadableImage) { + failures = append(failures, fmt.Sprintf("Image %d was not saved: the provider's image was unreadable.", i+1)) + } else { + failures = append(failures, fmt.Sprintf("Image %d was not saved: %s", i+1, saveErr.Error())) + } + continue + } + // The facts are the ones the road itself holds: the bytes it wrote, the + // usage the provider reported, and the reference paths exactly as given. + fact := GeneratedMedia(path, data, model).WithReferences(parsed.ReferencePaths).WithRequested(requested) + if numbered { + // One request, one total: the charge is the call's, never a picture's. + fact = fact.WithBatch(batch, i+1, len(response.Data)).WithCallUsage(response.Usage) + } else { + fact = fact.WithUsage(response.Usage) + } + if gen.RecordMedia != nil { + gen.RecordMedia(path, parsed.Prompt, fact) + } else if gen.Record != nil { + gen.Record(path, parsed.Prompt) + } + saved = append(saved, describeGeneratedImage(path, data, model)) } - if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { - return "Could not save the generated image: " + err.Error(), true + if !numbered { + return saved[0], false } - if err := os.WriteFile(path, data, 0o644); err != nil { - return "Could not save the generated image: " + err.Error(), true + return describeImageBatch(model, requested, len(response.Data), saved, failures) +} + +// describeImageBatch is the result of a request for several pictures: how many +// were asked for, how many the provider sent, how many are on disk, then one +// line per file and one per failure. It claims no more than happened, and it +// does not rank — the order is the provider's. +func describeImageBatch(model string, requested, returned int, saved, failures []string) (string, bool) { + var out strings.Builder + fmt.Fprintf(&out, "Saved %d of %d requested images on %s from one request", len(saved), requested, model) + if returned != requested { + fmt.Fprintf(&out, " (the provider returned %d)", returned) } - if gen.RecordMedia != nil { - // The facts are the ones the road itself holds: the bytes it wrote, the - // usage the provider reported, and the reference paths exactly as given. - gen.RecordMedia(path, parsed.Prompt, GeneratedMedia(path, data, model). - WithUsage(response.Usage).WithReferences(parsed.ReferencePaths).WithRequested(requested)) - } else if gen.Record != nil { - gen.Record(path, parsed.Prompt) + out.WriteString(", in the provider's order and not ranked:") + for _, line := range saved { + out.WriteString("\n" + line) + } + for _, line := range failures { + out.WriteString("\n" + line) + } + if returned > requested { + fmt.Fprintf(&out, "\nThe provider returned %d images; only the %d asked for were kept.", returned, requested) } - return describeGeneratedImage(path, data, model), false + return out.String(), len(saved) == 0 } func imageDestinationFrom(workspace, asked, prompt string, data []byte, declared, directory string) (string, error) { diff --git a/internal/session/tools_image_batch.go b/internal/session/tools_image_batch.go new file mode 100644 index 0000000000..f06373acdf --- /dev/null +++ b/internal/session/tools_image_batch.go @@ -0,0 +1,181 @@ +package session + +import ( + "crypto/rand" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "os" + "path/filepath" + "strings" + "time" + + "github.com/Agent-Field/codeaf/internal/provider" +) + +// The bounds of asking for several pictures in one request. Every one is a cap +// on work or memory, checked BEFORE the paid call or BEFORE any allocation, and +// each has one name so the tool's schema, its refusal and its tests cannot +// disagree about the figure. +const ( + // ImageVariantsMax is the most pictures one generate_image call may ask for. + ImageVariantsMax = 10 + // imageDecodedMax and imageDecodedTotalMax bound what a reply holding SEVERAL + // pictures may decode to, one picture and all of them. A reply holding one + // picture is bounded by the provider client's own response cap and gets no + // new limit, so every single-picture call that worked still works. + imageDecodedMax = 32 << 20 + imageDecodedTotalMax = 64 << 20 + // imageNameSuffixes bounds how many numbered names are looked at for a free one. + imageNameSuffixes = 1000 +) + +// newBatchID mints the identity of ONE provider request, once, before its +// outputs are written; it is persisted with every file the request produced, so +// it is the same after a restart. +func newBatchID() string { + var raw [16]byte + if _, err := rand.Read(raw[:]); err != nil { + sum := sha256.Sum256([]byte(time.Now().String())) + copy(raw[:], sum[:]) + } + return "bat_" + hex.EncodeToString(raw[:]) +} + +// imageCount is the n a call means: absent is one, and anything outside +// 1..[ImageVariantsMax] is a refusal the caller can fix without paying. +func imageCount(n int) (int, string) { + switch { + case n == 0: + return 1, "" + case n < 0 || n > ImageVariantsMax: + return 0, fmt.Sprintf("n must be between 1 and %d", ImageVariantsMax) + } + return n, "" +} + +// imageReplyRefusal says whether a reply of several pictures is too large to +// decode, from the encoded lengths alone — nothing is allocated to find out. +// A single picture is never refused here. +func imageReplyRefusal(items []provider.GeneratedImage) string { + if len(items) < 2 { + return "" + } + var total int64 + for _, item := range items { + size := int64(base64.StdEncoding.DecodedLen(len(item.Base64))) + if size > imageDecodedMax { + return fmt.Sprintf("one of the %d images is over %s", len(items), mediaByteSize(imageDecodedMax)) + } + total += size + } + if total > imageDecodedTotalMax { + return fmt.Sprintf("the %d images together are over %s", len(items), mediaByteSize(imageDecodedTotalMax)) + } + return "" +} + +var errUnreadableImage = errors.New("unreadable image") + +// saveImageVariant decodes ONE picture and writes it, so no more than one +// decoded picture is alive at a time. A name that was reserved for it and then +// not used is given back, and nothing is left behind for a picture that did not +// decode. +func saveImageVariant(gen ImageGen, parsed GenerateImageArgs, numbered bool, variant int, item provider.GeneratedImage) (string, []byte, error) { + data, err := base64.StdEncoding.DecodeString(item.Base64) + if err != nil || len(data) == 0 { + return "", nil, errUnreadableImage + } + var path string + // A name is OWNED once this call reserved it: the placeholder is ours to + // replace, and to remove if the write fails. An explicit single path is the + // caller's own file and keeps the write tool's overwrite behaviour. + owned := strings.TrimSpace(parsed.Path) == "" || numbered + if numbered && strings.TrimSpace(parsed.Path) != "" { + path, err = claimNumberedImage(gen.Workspace, parsed.Path, provider.ImageExtension(data, item.MediaType), variant) + } else { + path, err = imageDestinationFrom(gen.Workspace, parsed.Path, parsed.Prompt, data, item.MediaType, gen.Directory) + } + if err != nil { + return "", nil, err + } + if err := writeImageAtomic(path, data); err != nil { + if owned { + _ = os.Remove(path) + } + return "", nil, err + } + return path, data, nil +} + +// claimNumberedImage reserves "-" with O_EXCL, trying +// "--2" and onward when that name is taken. A file that is not +// ours is never opened for writing, so no unrelated file is overwritten. +func claimNumberedImage(workspace, asked, extension string, variant int) (string, error) { + path := strings.TrimSpace(asked) + if !filepath.IsAbs(path) { + path = filepath.Join(workspace, path) + } + path = filepath.Clean(path) + stem := strings.TrimSuffix(path, filepath.Ext(path)) + if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil { + return "", err + } + for suffix := 0; suffix < imageNameSuffixes; suffix++ { + name := fmt.Sprintf("%s-%d", stem, variant) + if suffix > 0 { + name += fmt.Sprintf("-%d", suffix+1) + } + name += extension + file, err := os.OpenFile(name, os.O_CREATE|os.O_EXCL|os.O_WRONLY, 0o644) + if err == nil { + if closeErr := file.Close(); closeErr != nil { + return "", closeErr + } + return name, nil + } + if !os.IsExist(err) { + return "", err + } + } + return "", fmt.Errorf("too many files share the name %s-%d", stem, variant) +} + +// writeImageAtomic puts data at path through a temporary file in the same +// folder and a rename, so a reader sees the old file or the whole new one and +// a failed write leaves no partial picture. A link at path is followed, as the +// write tool's plain write would. +func writeImageAtomic(path string, data []byte) error { + target := path + if info, err := os.Lstat(path); err == nil && info.Mode()&os.ModeSymlink != 0 { + if resolved, err := filepath.EvalSymlinks(path); err == nil { + target = resolved + } + } + dir := filepath.Dir(target) + if err := os.MkdirAll(dir, 0o755); err != nil { + return err + } + temp, err := os.CreateTemp(dir, ".codeaf-image-*") + if err != nil { + return err + } + name := temp.Name() + _, writeErr := temp.Write(data) + closeErr := temp.Close() + if err := errors.Join(writeErr, closeErr); err != nil { + _ = os.Remove(name) + return err + } + if err := os.Chmod(name, 0o644); err != nil { + _ = os.Remove(name) + return err + } + if err := os.Rename(name, target); err != nil { + _ = os.Remove(name) + return err + } + return nil +} diff --git a/internal/session/tools_image_batch_test.go b/internal/session/tools_image_batch_test.go new file mode 100644 index 0000000000..9c3a244b67 --- /dev/null +++ b/internal/session/tools_image_batch_test.go @@ -0,0 +1,373 @@ +package session + +import ( + "context" + "encoding/base64" + "encoding/json" + "math" + "os" + "path/filepath" + "strings" + "sync" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/provider" +) + +// batchMedia answers GenerateImage with exactly the encoded pictures it is +// given, whatever n was asked, and counts the calls. Nothing here is a model. +type batchMedia struct { + *scriptedMedia + mu sync.Mutex + calls int + items []string + usage *ai.Usage + err error + receiptOnError bool +} + +func (b *batchMedia) GenerateImage(_ context.Context, request provider.ImageRequest) (*provider.ImageResponse, error) { + b.mu.Lock() + b.calls++ + b.scriptedMedia.seen = append(b.scriptedMedia.seen, request) + b.mu.Unlock() + if b.err != nil { + if b.receiptOnError { + return &provider.ImageResponse{Usage: b.usage}, b.err + } + return nil, b.err + } + response := &provider.ImageResponse{Usage: b.usage} + for _, item := range b.items { + response.Data = append(response.Data, provider.GeneratedImage{Base64: item, MediaType: "image/png"}) + } + return response, nil +} + +func encodedPictures(t *testing.T, count int) []string { + t.Helper() + var out []string + for i := 0; i < count; i++ { + out = append(out, base64.StdEncoding.EncodeToString(pngOfSize(t, 4+i, 3))) + } + return out +} + +type batchRig struct { + agent *Agent + workspace string + index string + media *batchMedia +} + +func newBatchRig(t *testing.T, items []string, usage *ai.Usage) *batchRig { + t.Helper() + media := &batchMedia{scriptedMedia: &scriptedMedia{}, items: items, usage: usage} + index := filepath.Join(t.TempDir(), "artifacts.jsonl") + agent, workspace := newMediaAgent(t, nil, func(config *Config) { + config.Media = media + config.ArtifactsIndex = index + }) + return &batchRig{agent: agent, workspace: workspace, index: index, media: media} +} + +func (r *batchRig) run(t *testing.T, args string) (string, bool) { + t.Helper() + ctx := withCallID(withRunIdentity(context.Background()), "call_sheet") + out, bad, err := beltTool(t, r.agent, "generate_image").Execute(ctx, json.RawMessage(args)) + if err != nil { + t.Fatalf("harness error: %v", err) + } + return out, bad +} + +func (r *batchRig) facts(t *testing.T) []*MediaFact { + t.Helper() + var out []*MediaFact + for _, row := range ReadArtifacts(r.index) { + if row.Media != nil { + out = append(out, row.Media) + } + } + return out +} + +func leftoverTemps(t *testing.T, root string) []string { + t.Helper() + var found []string + _ = filepath.Walk(root, func(path string, info os.FileInfo, err error) error { + if err == nil && strings.HasPrefix(info.Name(), ".codeaf-image-") { + found = append(found, path) + } + return nil + }) + return found +} + +func TestSingleImageStaysTheCompatibleShape(t *testing.T) { + cost := 0.04 + rig := newBatchRig(t, encodedPictures(t, 1), &ai.Usage{Cost: &cost}) + out, bad := rig.run(t, `{"prompt":"a harbour"}`) + if bad || rig.media.calls != 1 || rig.media.request(0).N != 1 { + t.Fatalf("bad=%v calls=%d n=%d out=%s", bad, rig.media.calls, rig.media.request(0).N, out) + } + facts := rig.facts(t) + if len(facts) != 1 { + t.Fatalf("facts = %d", len(facts)) + } + f := facts[0] + if f.Batch != "" || f.VariantIndex != 0 || f.Returned != 0 || f.CostScope != "" || f.CallCostUSD != nil || + f.Requested != 1 || f.Charge != MediaCharged || f.CostUSD == nil || *f.CostUSD != cost { + t.Fatalf("single fact changed shape: %+v", f) + } +} + +func TestFourVariantsAreFourFilesOneRequestOneBatch(t *testing.T) { + cost := 0.2 + items := encodedPictures(t, 4) + rig := newBatchRig(t, items, &ai.Usage{Cost: &cost}) + out, bad := rig.run(t, `{"prompt":"a harbour at dusk","n":4}`) + if bad { + t.Fatal(out) + } + if rig.media.calls != 1 || rig.media.request(0).N != 4 { + t.Fatalf("calls=%d n=%d; want one request for 4", rig.media.calls, rig.media.request(0).N) + } + for _, item := range items { + if strings.Contains(out, item[:40]) { + t.Fatal("the result carries base64") + } + } + facts := rig.facts(t) + if len(facts) != 4 { + t.Fatalf("facts = %d, want 4", len(facts)) + } + paths, batch := map[string]bool{}, facts[0].Batch + seen := map[int]bool{} + for _, f := range facts { + paths[f.Canonical] = true + seen[f.VariantIndex] = true + if f.Batch == "" || f.Batch != batch || f.Requested != 4 || f.Returned != 4 || f.Tool != "call_sheet" || + f.CostScope != MediaCostScopeCall || f.CostUSD != nil || f.CallCostUSD == nil || *f.CallCostUSD != cost || + f.Charge != MediaCharged || f.Model != "paint/model" || f.Width == 0 || f.SHA256 == "" { + t.Fatalf("variant fact = %+v", f) + } + } + if len(paths) != 4 || len(seen) != 4 || !seen[1] || !seen[4] { + t.Fatalf("paths=%v variants=%v", paths, seen) + } + if usage := rig.agent.Usage(); math.Abs(usage.CostUSD-cost) > 1e-9 { + t.Fatalf("charged %v, want the one reply's %v", usage.CostUSD, cost) + } + if temps := leftoverTemps(t, rig.workspace); len(temps) != 0 { + t.Fatalf("temp files left: %v", temps) + } +} + +func TestTenVariantsAndTheUpperBound(t *testing.T) { + rig := newBatchRig(t, encodedPictures(t, 10), nil) + if out, bad := rig.run(t, `{"prompt":"sheet","n":10}`); bad || len(rig.facts(t)) != 10 { + t.Fatalf("n=10: bad=%v facts=%d %s", bad, len(rig.facts(t)), out) + } + for _, args := range []string{`{"prompt":"x","n":11}`, `{"prompt":"x","n":-1}`, `{"prompt":"x","n":2.5}`} { + before := rig.media.calls + out, bad := rig.run(t, args) + if !bad || !strings.HasPrefix(out, "Invalid arguments") || rig.media.calls != before { + t.Fatalf("%s: bad=%v calls %d->%d out=%s", args, bad, before, rig.media.calls, out) + } + } +} + +func TestFewerReturnedThanAskedIsRecordedExactly(t *testing.T) { + rig := newBatchRig(t, encodedPictures(t, 2), nil) + out, bad := rig.run(t, `{"prompt":"sheet","n":4}`) + if bad || !strings.Contains(out, "Saved 2 of 4") || !strings.Contains(out, "provider returned 2") { + t.Fatalf("bad=%v %s", bad, out) + } + if rig.media.calls != 1 { + t.Fatalf("calls = %d; fewer outputs must not trigger more calls", rig.media.calls) + } + for _, f := range rig.facts(t) { + if f.Requested != 4 || f.Returned != 2 { + t.Fatalf("fact = %+v", f) + } + if f.Charge != MediaChargeUnknown || f.CallCostUSD != nil { + t.Fatalf("invented a charge: %+v", f) + } + } +} + +func TestAProviderRefusalOfNIsReportedAndNotRetried(t *testing.T) { + rig := newBatchRig(t, nil, nil) + rig.media.err = os.ErrInvalid + out, bad := rig.run(t, `{"prompt":"sheet","n":4}`) + if !bad || rig.media.calls != 1 || !strings.Contains(out, "nothing was retried") { + t.Fatalf("bad=%v calls=%d %s", bad, rig.media.calls, out) + } +} + +func TestAChargeOnAnUnusableReplyIsAccountedOnce(t *testing.T) { + cost := 0.07 + for name, items := range map[string][]string{ + "malformed": {"!!not base64!!"}, + "empty": nil, + } { + t.Run(name, func(t *testing.T) { + rig := newBatchRig(t, items, &ai.Usage{Cost: &cost}) + if _, bad := rig.run(t, `{"prompt":"a harbour"}`); !bad { + t.Fatal("an unusable reply must fail") + } + if usage := rig.agent.Usage(); math.Abs(usage.CostUSD-cost) > 1e-9 { + t.Fatalf("session cost = %v, want %v once", usage.CostUSD, cost) + } + }) + } +} + +func TestOneBadVariantKeepsTheOthersAndTheChargeIsOnce(t *testing.T) { + cost := 0.3 + items := encodedPictures(t, 3) + items[1] = "!!not base64!!" + rig := newBatchRig(t, items, &ai.Usage{Cost: &cost}) + out, bad := rig.run(t, `{"prompt":"sheet","n":3}`) + if bad || !strings.Contains(out, "Saved 2 of 3") || !strings.Contains(out, "Image 2 was not saved") { + t.Fatalf("bad=%v %s", bad, out) + } + facts := rig.facts(t) + if len(facts) != 2 || facts[0].VariantIndex+facts[1].VariantIndex != 4 || facts[0].Returned != 3 { + t.Fatalf("facts = %+v %+v", facts[0], facts[1]) + } + if usage := rig.agent.Usage(); math.Abs(usage.CostUSD-cost) > 1e-9 { + t.Fatalf("session cost = %v, want %v once, not per picture", usage.CostUSD, cost) + } +} + +func TestAReplyTooLargeToKeepIsRefusedAfterTheChargeAndBeforeDecoding(t *testing.T) { + cost := 0.5 + // 25 MiB decoded each: under the per-picture cap, over the total. + big := strings.Repeat("A", (25<<20)/3*4) + rig := newBatchRig(t, []string{big, big, big}, &ai.Usage{Cost: &cost}) + out, bad := rig.run(t, `{"prompt":"sheet","n":3}`) + if !bad || !strings.Contains(out, "none were saved") || !strings.Contains(out, "provider-reported charge was recorded") { + t.Fatalf("bad=%v %s", bad, out) + } + if len(rig.facts(t)) != 0 || rig.media.calls != 1 { + t.Fatal("a refused reply must record nothing and retry nothing") + } + if usage := rig.agent.Usage(); math.Abs(usage.CostUSD-cost) > 1e-9 { + t.Fatalf("session cost = %v, want %v", usage.CostUSD, cost) + } + if why := imageReplyRefusal([]provider.GeneratedImage{{Base64: strings.Repeat("A", (40<<20)/3*4)}, {Base64: "AAAA"}}); why == "" { + t.Fatal("one oversize picture in a reply of two must be refused") + } + if why := imageReplyRefusal([]provider.GeneratedImage{{Base64: strings.Repeat("A", (40<<20)/3*4)}}); why != "" { + t.Fatal("a single picture keeps its old, larger allowance") + } +} + +func TestNumberedPathsNeverOverwriteUnrelatedFiles(t *testing.T) { + rig := newBatchRig(t, encodedPictures(t, 2), nil) + unrelated := filepath.Join(rig.workspace, "sheet-1.png") + if err := os.WriteFile(unrelated, []byte("precious"), 0o644); err != nil { + t.Fatal(err) + } + out, bad := rig.run(t, `{"prompt":"sheet","n":2,"path":"sheet.png"}`) + if bad { + t.Fatal(out) + } + if got, _ := os.ReadFile(unrelated); string(got) != "precious" { + t.Fatalf("an unrelated file was overwritten: %q", got) + } + for _, f := range rig.facts(t) { + if f.Canonical == canonicalFile(unrelated) { + t.Fatal("recorded the unrelated file") + } + if !strings.HasPrefix(filepath.Base(f.Canonical), "sheet-") { + t.Fatalf("path = %s", f.Canonical) + } + } + if temps := leftoverTemps(t, rig.workspace); len(temps) != 0 { + t.Fatalf("temps: %v", temps) + } +} + +func TestASingleExplicitPathStillOverwrites(t *testing.T) { + rig := newBatchRig(t, encodedPictures(t, 1), nil) + target := filepath.Join(rig.workspace, "draft.png") + if err := os.WriteFile(target, []byte("old"), 0o644); err != nil { + t.Fatal(err) + } + if out, bad := rig.run(t, `{"prompt":"p","path":"draft.png"}`); bad { + t.Fatal(out) + } + if got, _ := os.ReadFile(target); string(got) == "old" || len(got) == 0 { + t.Fatal("the explicit path was not overwritten") + } +} + +func TestEveryVariantFailingToWriteLeavesNothingAndKeepsTheCharge(t *testing.T) { + if os.Geteuid() == 0 { + t.Skip("root writes anywhere") + } + cost := 0.1 + rig := newBatchRig(t, encodedPictures(t, 2), &ai.Usage{Cost: &cost}) + locked := filepath.Join(rig.workspace, "locked") + if err := os.Mkdir(locked, 0o500); err != nil { + t.Fatal(err) + } + defer os.Chmod(locked, 0o700) + out, bad := rig.run(t, `{"prompt":"p","n":2,"path":"locked/x.png"}`) + if !bad || strings.Count(out, "was not saved") != 2 { + t.Fatalf("bad=%v %s", bad, out) + } + if entries, _ := os.ReadDir(locked); len(entries) != 0 { + t.Fatalf("left %d files behind", len(entries)) + } + if usage := rig.agent.Usage(); math.Abs(usage.CostUSD-cost) > 1e-9 { + t.Fatalf("session cost = %v", usage.CostUSD) + } +} + +func TestDefaultNamedVariantsGetDistinctClaimedNames(t *testing.T) { + rig := newBatchRig(t, encodedPictures(t, 3), nil) + if out, bad := rig.run(t, `{"prompt":"sheet","n":3}`); bad { + t.Fatal(out) + } + names := map[string]bool{} + for _, f := range rig.facts(t) { + names[f.Canonical] = true + } + if len(names) != 3 { + t.Fatalf("names = %v", names) + } +} + +func TestTheSchemaTeachesN(t *testing.T) { + var schema struct { + Properties map[string]struct { + Type string `json:"type"` + Minimum int `json:"minimum"` + Maximum int `json:"maximum"` + Default int `json:"default"` + } `json:"properties"` + } + if err := json.Unmarshal([]byte(generateImageSchemaJSON), &schema); err != nil { + t.Fatal(err) + } + n := schema.Properties["n"] + if n.Type != "integer" || n.Minimum != 1 || n.Maximum != ImageVariantsMax || n.Default != 1 { + t.Fatalf("n = %+v", n) + } +} + +func TestFailedImageResponseRetainsItsActualChargeOnce(t *testing.T) { + cost := 0.13 + rig := newBatchRig(t, nil, &ai.Usage{Cost: &cost}) + rig.media.err = os.ErrInvalid + rig.media.receiptOnError = true + out, bad := rig.run(t, `{"prompt":"sheet","n":4}`) + if !bad || rig.media.calls != 1 || rig.agent.Usage().CostUSD != cost || len(rig.facts(t)) != 0 { + t.Fatalf("failure lost or duplicated receipt: bad%v usage%+v out%s", bad, rig.agent.Usage(), out) + } +} From ac191a17b47160e18ba7c8b41390e7d675780bd2 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 16 Oct 2026 01:21:27 -0400 Subject: [PATCH 154/459] Prove media cache readers cannot alter shared batch receipts --- internal/session/mediacache_test.go | 26 ++++++++++++++++++++++++++ 1 file changed, 26 insertions(+) diff --git a/internal/session/mediacache_test.go b/internal/session/mediacache_test.go index 877d1aed7e..3c2d128456 100644 --- a/internal/session/mediacache_test.go +++ b/internal/session/mediacache_test.go @@ -267,3 +267,29 @@ func BenchmarkFindMedia100kIndex(b *testing.B) { } func mediaRowJSON(row Artifact) ([]byte, error) { return json.Marshal(row) } + +func TestMediaCacheDoesNotLendOutTheBatchReceipt(t *testing.T) { + resetMediaCache() + dir := t.TempDir() + index := filepath.Join(dir, "artifacts.jsonl") + path := mediaFile(t, dir, "p.png") + cost := 0.17 + fact := GeneratedMedia(path, []byte("picture"), "painter").WithRequested(4).WithBatch("bat_actual", 2, 3) + fact.Charge = MediaCharged + fact.CostScope = MediaCostScopeCall + fact.CallCostUSD = &cost + RecordMedia(index, Artifact{Path: path, Session: "s1", Title: "actual variant", Kind: "image", Created: time.Now()}, fact) + ids := listAll(t, index, "s1") + if len(ids) != 1 { + t.Fatalf("ids%v", ids) + } + first, err := FindMedia(index, "s1", ids[0], time.Time{}) + if err != nil || first.Fact.CallCostUSD == nil { + t.Fatalf("receipt unavailable: %+v %v", first, err) + } + *first.Fact.CallCostUSD = 999 + again, err := FindMedia(index, "s1", ids[0], time.Time{}) + if err != nil || again.Fact.CallCostUSD == nil || *again.Fact.CallCostUSD != cost || again.Fact.CostUSD != nil { + t.Fatalf("caller changed another view's receipt: %+v %v", again, err) + } +} From 4a9536463ec7570a73cd8f5cccfd7b41dc26701b Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 16 Oct 2026 01:23:54 -0400 Subject: [PATCH 155/459] Prove retries keep queued collection references without the queued flag --- cmd/codeaf/appsteerqueue_reads_test.go | 22 ++++++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/cmd/codeaf/appsteerqueue_reads_test.go b/cmd/codeaf/appsteerqueue_reads_test.go index efb2cf0f63..a536f1809b 100644 --- a/cmd/codeaf/appsteerqueue_reads_test.go +++ b/cmd/codeaf/appsteerqueue_reads_test.go @@ -358,3 +358,25 @@ func TestAppOlderHostIsRefusedEveryMarkedMessageBeforeAnything(t *testing.T) { } func appQueuedSubmission(s session.Submission) session.Submission { s.Queued = true; return s } + +func TestAppRetryQueuedMessagePreservesReferenceMarks(t *testing.T) { + conv := &fakeConversation{} + backend, _, agent := markBackend(t, conv) + reads, err := appKeptReads([]appwire.MessageRead{appSpecsRead()}) + if err != nil { + t.Fatal(err) + } + a := backend.agent(agent.ID) + backend.locked(func() { + a.failed = "expanded body" + a.failedShown = &appSending{text: "mine", reads: reads, queued: true} + }) + if _, err := backend.Retry(agent.ID); err != nil { + t.Fatal(err) + } + submissions, _ := appSubmissionsOf(conv) + want := appMarkedSubmission(t, "expanded body", "mine", appSpecsRead()) + if len(submissions) != 1 || !reflect.DeepEqual(submissions[0], want) { + t.Fatalf("retry lost its reference mark or retained its queued flag: %+v", submissions) + } +} From 47799284a38d72744f7c52f1baef3463ecf5b7d6 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 16 Oct 2026 01:27:56 -0400 Subject: [PATCH 156/459] Explain connected media route refusals without making a paid request --- cmd/codeaf/apprender.go | 2 +- cmd/codeaf/apprender_test.go | 12 +++ cmd/codeaf/chatv3.go | 3 +- cmd/codeaf/chatv3_media_admission.go | 62 +++++++++++++ cmd/codeaf/chatv3_media_admission_test.go | 49 ++++++++++ internal/appwire/render.go | 2 + .../chat/making-pictures-audio-and-video.md | 13 ++- internal/session/harness_belt.go | 10 +- internal/session/media_admission.go | 43 +++++++++ internal/session/media_admission_test.go | 91 +++++++++++++++++++ internal/session/render_retention.go | 1 + internal/session/renderlog.go | 18 +++- internal/session/session.go | 6 +- internal/session/task_run.go | 7 +- internal/session/tools_image.go | 11 ++- internal/session/tools_media.go | 18 +++- internal/session/tools_video.go | 10 +- 17 files changed, 333 insertions(+), 25 deletions(-) create mode 100644 cmd/codeaf/chatv3_media_admission.go create mode 100644 cmd/codeaf/chatv3_media_admission_test.go create mode 100644 internal/session/media_admission.go create mode 100644 internal/session/media_admission_test.go diff --git a/cmd/codeaf/apprender.go b/cmd/codeaf/apprender.go index 190a8e800e..1157da302f 100644 --- a/cmd/codeaf/apprender.go +++ b/cmd/codeaf/apprender.go @@ -28,7 +28,7 @@ func appRenderOf(r session.RenderRecord) appwire.Render { ID: r.ID, Job: r.Job, Session: r.Session, Kind: r.Kind, State: string(r.State), Model: r.Model, Started: r.Started, Ended: r.Ended, DurationMS: r.DurationMS, Run: r.Run, Call: r.Call, Committed: r.Committed, Path: r.Path, Charge: string(r.Charge), CostUSD: r.CostUSD, - Upstream: r.Upstream, Error: r.Error, Persistence: r.Persistence, + Upstream: r.Upstream, Error: r.Error, Persistence: r.Persistence, Refusal: r.Refusal, Options: append([]string(nil), r.Options...), } } diff --git a/cmd/codeaf/apprender_test.go b/cmd/codeaf/apprender_test.go index 4b2026c4d7..8a09af4776 100644 --- a/cmd/codeaf/apprender_test.go +++ b/cmd/codeaf/apprender_test.go @@ -155,3 +155,15 @@ func mustFind(t *testing.T, backend *appBackend, id string) *appAgent { } return a } + +func TestAppRenderKeepsStructuredAdmissionReasonAndCopiesOptions(t *testing.T) { + record := session.RenderRecord{ID: "rnd_refused", Kind: "image", State: session.RenderFailed, Refusal: "model_required", Options: []string{"actual/painter"}, Charge: session.RenderNotCharged} + got := appRenderOf(record) + if got.Refusal != "model_required" || got.Charge != "not_charged" || got.Model != "" || len(got.Options) != 1 { + t.Fatalf("lost refusal facts: %+v", got) + } + got.Options[0] = "changed" + if record.Options[0] != "actual/painter" { + t.Fatal("wire reader changed owned admission options") + } +} diff --git a/cmd/codeaf/chatv3.go b/cmd/codeaf/chatv3.go index ad03b9fd14..4659c0dd60 100644 --- a/cmd/codeaf/chatv3.go +++ b/cmd/codeaf/chatv3.go @@ -1235,6 +1235,7 @@ func v3ConfigFor(proc *v3Process, opts v3Options, workspace string, place sessio cfg.Media = v3MediaClient(mediaSettings) cfg.MediaModel = v3MediaModel(activeModels, settings.ProfileDir, cfg.RolesSource) cfg.MediaPick = v3MediaPick(activeModels) + cfg.MediaAdmission = v3MediaAdmission(activeModels, settings.ProfileDir, cfg.RolesSource) } // AND THE RUN DOOR IS BUILT FROM THE SAME PAIR. A saved harness may name a @@ -1248,7 +1249,7 @@ func v3ConfigFor(proc *v3Process, opts v3Options, workspace string, place sessio // the same row in `/files`. Left out, the run's belt fell to the legacy dot // directory and recorded nothing, which made a harness's work invisible. cfg.RunHarness = v3RunHarness(harnesses, settings, chosen, workspace, session.HarnessBeltSeams{ - Media: cfg.Media, MediaModel: cfg.MediaModel, MediaPick: cfg.MediaPick, + Media: cfg.Media, MediaModel: cfg.MediaModel, MediaPick: cfg.MediaPick, MediaAdmission: cfg.MediaAdmission, Place: cfg.Place, ArtifactsIndex: cfg.ArtifactsIndex, }) // AND THE SAME DOOR PUTS THAT STORE ON THE LIST. A program saved as a page is diff --git a/cmd/codeaf/chatv3_media_admission.go b/cmd/codeaf/chatv3_media_admission.go new file mode 100644 index 0000000000..8b0859228a --- /dev/null +++ b/cmd/codeaf/chatv3_media_admission.go @@ -0,0 +1,62 @@ +package main + +import ( + "strings" + + "github.com/Agent-Field/codeaf/internal/catalog" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/roles" + "github.com/Agent-Field/codeaf/internal/session" +) + +// The richer route is opt-in for connected generation. It preserves explicit +// choices and turns an unsafe absent default into an actual typed refusal. +func v3MediaAdmission(models *catalog.Catalog, profileDir string, source roles.Source) func(string, string) session.MediaAdmission { + return func(modality, word string) session.MediaAdmission { + if models == nil { + return session.MediaAdmission{Reason: "unconfirmed", Message: "The media catalog is not confirmed. Choose a compatible model before generating."} + } + available := models.SnapshotNow() + if strings.TrimSpace(word) != "" { + model, err := config.ResolveMediaModel(available, modality, word) + if err != nil { + return session.MediaAdmission{Reason: "model_invalid", Message: err.Error()} + } + if model != "" { + return session.MediaAdmission{Model: model} + } + } + configured := "" + if slot, ok := v3MediaSlot[modality]; ok { + configured = strings.TrimSpace(config.MediaSlotModelAt(profileDir, slot)) + } + if configured == "" { + if role, ok := v3MediaPin[modality]; ok { + if pin, set := roles.Pinned(source, role); set { + configured = strings.TrimSpace(pin) + } + } + } + if configured != "" { + // A known incompatible choice is an actionable refusal. A user explicitly + // chosen route not in a listing remains theirs: the provider decides it. + if _, known := available.Model(configured); known && !v3MediaCapable(available, modality, configured) { + return session.MediaAdmission{Reason: "configured_model_invalid", Message: "The configured model " + configured + " does not advertise " + modality + " output. Choose a compatible model; no other model was substituted."} + } + return session.MediaAdmission{Model: configured} + } + var route config.MediaRoute + switch modality { + case "image": + route = (config.Config{}).ResolveImageRoute(available) + case "video": + route = (config.Config{}).ResolveVideoRoute(available) + default: + return session.MediaAdmission{Reason: "model_required", Message: "Choose a compatible " + modality + " model before generating."} + } + if route.Model != "" { + return session.MediaAdmission{Model: route.Model} + } + return session.MediaAdmission{Reason: string(route.Reason), Message: route.Err().Error(), Options: append([]string(nil), route.Options...)} + } +} diff --git a/cmd/codeaf/chatv3_media_admission_test.go b/cmd/codeaf/chatv3_media_admission_test.go new file mode 100644 index 0000000000..c3577e8d48 --- /dev/null +++ b/cmd/codeaf/chatv3_media_admission_test.go @@ -0,0 +1,49 @@ +package main + +import ( + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/roles" +) + +func TestV3MediaAdmissionNeverSubstitutesForAnInvalidExplicitChoice(t *testing.T) { + mediaEnvOff(t) + profile := t.TempDir() + models := mediaCatalogFor(t, mediaFixture) + source := func(string) (string, bool) { return "", false } + resolve := v3MediaAdmission(models, profile, source) + writeMediaSlot(t, profile, "image", "vendor/first-voice") + got := resolve("image", "") + if got.Model != "" || got.Reason != "configured_model_invalid" || !strings.Contains(got.Message, "vendor/first-voice") { + t.Fatalf("silently substituted a painter: %+v", got) + } + writeMediaSlot(t, profile, "image", "operator/explicit-not-listed") + if got = resolve("image", ""); got.Model != "operator/explicit-not-listed" { + t.Fatalf("listing overruled explicit route: %+v", got) + } +} + +func TestV3MediaAdmissionNoDefaultExplainsChoicesWithoutPickingTheFirst(t *testing.T) { + mediaEnvOff(t) + models := mediaCatalogFor(t, `{"id":"new/painter","architecture":{"input_modalities":["text"],"output_modalities":["image"]}}`) + got := v3MediaAdmission(models, t.TempDir(), func(string) (string, bool) { return "", false })("image", "") + if got.Model != "" || got.Reason == "" || got.Message == "" { + t.Fatalf("picked an arbitrary route or lost refusal: %+v", got) + } +} + +func TestV3MediaAdmissionKeepsExplicitRoleChoiceAndActualCallChoice(t *testing.T) { + mediaEnvOff(t) + models := mediaCatalogFor(t, mediaFixture) + source := func(key string) (string, bool) { + return "vendor/first-painter", key == roles.PinKey(roles.RoleImageGen) + } + resolve := v3MediaAdmission(models, t.TempDir(), source) + if got := resolve("image", ""); got.Model != "vendor/first-painter" { + t.Fatalf("lost explicit pin: %+v", got) + } + if got := resolve("image", "vendor/second-painter"); got.Model != "vendor/second-painter" { + t.Fatalf("lost actual invocation choice: %+v", got) + } +} diff --git a/internal/appwire/render.go b/internal/appwire/render.go index 94ea7b42b4..9b924c6915 100644 --- a/internal/appwire/render.go +++ b/internal/appwire/render.go @@ -40,6 +40,8 @@ type Render struct { CostUSD *float64 `json:"cost_usd,omitempty"` Upstream string `json:"upstream,omitempty"` Error string `json:"error,omitempty"` + Refusal string `json:"refusal,omitempty"` + Options []string `json:"options,omitempty"` } // RenderPage is one window of renders, newest first. diff --git a/internal/manual/chat/making-pictures-audio-and-video.md b/internal/manual/chat/making-pictures-audio-and-video.md index 820eecbebf..d216d89814 100644 --- a/internal/manual/chat/making-pictures-audio-and-video.md +++ b/internal/manual/chat/making-pictures-audio-and-video.md @@ -5,13 +5,12 @@ spoken audio file from text, a piece of music from a brief, a short video from a prompt. Each one is a tool on the list, each one costs money, and each one saves a file and tells you where it went. -**These tools are only there when a model behind them is.** codeaf resolves one -model per kind of media — drawing, speaking, composing, filming — from your -settings, and a kind with no model available simply has no tool, rather than a -tool that refuses. So `generate_image`, `speak`, `generate_music` and -`generate_video` may each be present or absent independently, and asking for one -that is absent gets you an honest "I do not have that here" rather than a failed -attempt. +**A missing service has no tool.** codeaf resolves a model per media kind +from your settings. A connected drawing or filming service may keep its tool +while a safe default still needs your choice: an actual attempt then explains +which model setting is needed and makes no provider request. It does not pick +an arbitrary model or retry. Speaking and composing retain their existing +model requirement; each media kind can be present or absent independently. **All four work in tasks, in adaptive runs and inside saved harnesses**, not only in this conversation — see "Can a task or a harness make media?" below. diff --git a/internal/session/harness_belt.go b/internal/session/harness_belt.go index 68c661e095..3d255e9947 100644 --- a/internal/session/harness_belt.go +++ b/internal/session/harness_belt.go @@ -66,9 +66,11 @@ type HarnessBeltSeams struct { // Media and MediaModel are [Config]'s own pair, with [Config]'s own meaning: // the client that carries a generation request, and the resolver that says // which model serves a modality. Either one absent takes every generation - // verb off, exactly as it does on the conversation's belt. - Media MediaGenerator - MediaModel func(modality string) string + // verb off, exactly as it does on the conversation's belt. MediaAdmission + // carries the same optional connected-capability refusal seam. + Media MediaGenerator + MediaModel func(modality string) string + MediaAdmission func(modality, word string) MediaAdmission // MediaPick is [Config.MediaPick]: the just-in-time model choice on the // four making verbs. Nil keeps the `model` argument off their schemas, @@ -144,6 +146,7 @@ func HarnessBelt(workspace string, seams HarnessBeltSeams) []bare.Tool { Workspace: workspace, Media: seams.Media, MediaModel: seams.MediaModel, + MediaAdmission: seams.MediaAdmission, MediaPick: seams.MediaPick, Place: seams.Place, ArtifactsIndex: seams.ArtifactsIndex, @@ -184,6 +187,7 @@ func (a *Agent) harnessSeams() HarnessBeltSeams { return HarnessBeltSeams{ Media: a.config.Media, MediaModel: a.config.MediaModel, + MediaAdmission: a.config.MediaAdmission, MediaPick: a.config.MediaPick, Seer: a.routedCompleter(), Place: a.config.Place, diff --git a/internal/session/media_admission.go b/internal/session/media_admission.go new file mode 100644 index 0000000000..3655c03269 --- /dev/null +++ b/internal/session/media_admission.go @@ -0,0 +1,43 @@ +package session + +import ( + "context" + "strings" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" +) + +// MediaAdmission is a generation route resolved at the actual tool invocation. +// A connected surface can explain a refused capability without guessing intent +// from conversation prose or choosing an arbitrary newly advertised model. +type MediaAdmission struct { + Model string + Reason string + Message string + Options []string +} + +func (a *Agent) refuseMediaAdmission(ctx context.Context, kind string, route MediaAdmission) (string, bool, error) { + message := strings.TrimSpace(route.Message) + if message == "" { + message = "Choose a compatible " + kind + " model before generating." + } + jobs := a.renderJobs() + if jobs == nil { + return message + " The refused attempt could not be saved because the session is closed.", true, nil + } + ledger := jobs.renderLedger() + made := madeBy(ctx, nil) + now := time.Now() + id, err := ledger.beginChecked(0, kind, "", made.Run, made.Tool, now) + if err != nil { + return message + " The refused attempt could not be saved: " + err.Error(), true, nil + } + // The engine made no provider request: zero is a known local admission fact, + // never an inference from an absent provider usage receipt. + zero := float64(0) + ledger.settle(id, renderPatch{state: RenderFailed, usage: &ai.Usage{Cost: &zero}, err: message, refusal: route.Reason, options: route.Options}, time.Now()) + ledger.release(id) + return message, true, nil +} diff --git a/internal/session/media_admission_test.go b/internal/session/media_admission_test.go new file mode 100644 index 0000000000..5dfacc4156 --- /dev/null +++ b/internal/session/media_admission_test.go @@ -0,0 +1,91 @@ +package session + +import ( + "context" + "encoding/base64" + "encoding/json" + "strings" + "testing" + "time" +) + +func TestConnectedMediaRefusalIsAnActualUnpaidAttemptNotAProviderCall(t *testing.T) { + for _, kind := range []string{"image", "video"} { + t.Run(kind, func(t *testing.T) { + media := &scriptedMedia{} + agent, _ := newMediaAgent(t, media, func(c *Config) { + c.MediaModel = nil + c.MediaAdmission = func(modality, word string) MediaAdmission { + return MediaAdmission{Reason: "model_required", Message: "Choose a model before generating.", Options: []string{"a/model", "b/model"}} + } + }) + ctx := withCallID(withRunIdentity(context.Background()), "actual_call") + out, bad, err := beltTool(t, agent, "generate_"+kind).Execute(ctx, json.RawMessage(`{"prompt":"a genuine request"}`)) + if err != nil || !bad || !strings.Contains(out, "Choose a model") || len(media.seen) != 0 { + t.Fatalf("refusal started a provider: bad%v err%v seen%v out%s", bad, err, media.seen, out) + } + page, err := agent.Renders(10, "") + if err != nil || len(page.Renders) != 1 { + t.Fatalf("missing actual refusal: %+v %v", page, err) + } + got := page.Renders[0] + if got.Kind != kind || got.State != RenderFailed || got.Refusal != "model_required" || got.Charge != RenderNotCharged || got.Committed || got.Model != "" || got.Call != "actual_call" || got.Run != runIdentity(ctx) || len(got.Options) != 2 { + t.Fatalf("invented or lost attempt facts: %+v", got) + } + got.Options[0] = "tampered" + again, err := agent.Render(got.ID) + if err != nil || again.Options[0] != "a/model" { + t.Fatalf("caller changed history: %+v %v", again, err) + } + }) + } +} + +func TestMediaAdmissionDoesNotOfferAClientItDoesNotHave(t *testing.T) { + agent, _ := newMediaAgent(t, nil, func(c *Config) { + c.Media = nil + c.MediaModel = nil + c.MediaAdmission = func(string, string) MediaAdmission { return MediaAdmission{Reason: "model_required"} } + }) + for _, kind := range []string{"image", "video"} { + if _, _, ready := agent.mediaHand(kind); ready { + t.Fatalf("%s promised a missing client", kind) + } + } +} + +func TestAdmissionRefusalReopensWithBoundedChoicesAndNoGuessedModel(t *testing.T) { + path := t.TempDir() + "/renders.jsonl" + ledger := openRenderLedger(path, "actual_session", time.Now()) + id, err := ledger.beginChecked(0, "image", "", "run", "call", time.Now()) + if err != nil { + t.Fatal(err) + } + options := []string{"a/model", "b/model", strings.Repeat("x", 1025), "d", "e", "f", "g", "h", "discarded"} + ledger.settle(id, renderPatch{state: RenderFailed, refusal: "model_required", options: options, err: "Choose a model."}, time.Now()) + ledger.release(id) + ledger.close() + reopened := openRenderLedger(path, "actual_session", time.Now()) + defer reopened.close() + rec, err := reopened.get(id) + if err != nil || rec.Refusal != "model_required" || rec.Model != "" || len(rec.Options) != 7 || rec.Options[6] != "h" { + t.Fatalf("bad persisted refusal: %+v %v", rec, err) + } +} + +func TestMediaAdmissionUsesTheInvocationRouteNotTheOldBeltDefault(t *testing.T) { + media := &scriptedMedia{base64: base64.StdEncoding.EncodeToString(pngOfSize(t, 2, 2))} + chosen := "actual/first" + agent, _ := newMediaAgent(t, media, func(c *Config) { + c.MediaModel = mediaModels(map[string]string{"image": "stale/default"}) + c.MediaAdmission = func(string, string) MediaAdmission { return MediaAdmission{Model: chosen} } + }) + tool := beltTool(t, agent, "generate_image") + for _, model := range []string{"actual/first", "actual/second"} { + chosen = model + out, bad, err := tool.Execute(context.Background(), json.RawMessage(`{"prompt":"real route"}`)) + if err != nil || bad || media.seen[len(media.seen)-1].Model != model { + t.Fatalf("ignored current route: bad%v err%v seen%+v out%s", bad, err, media.seen, out) + } + } +} diff --git a/internal/session/render_retention.go b/internal/session/render_retention.go index a92fd46d51..27cb547574 100644 --- a/internal/session/render_retention.go +++ b/internal/session/render_retention.go @@ -124,6 +124,7 @@ func (r *renderRetention) hasPending(path string) bool { } func cloneRenderRecord(record RenderRecord) RenderRecord { + record.Options = append([]string(nil), record.Options...) if record.Ended != nil { value := *record.Ended record.Ended = &value diff --git a/internal/session/renderlog.go b/internal/session/renderlog.go index 03e1b83104..a489bb61e9 100644 --- a/internal/session/renderlog.go +++ b/internal/session/renderlog.go @@ -78,8 +78,10 @@ type RenderRecord struct { CostUSD *float64 `json:"cost_usd,omitempty"` // Upstream is "unknown" on a cancelled render: only local waiting stopped, // and nothing here tells the provider to cancel. - Upstream string `json:"upstream,omitempty"` - Error string `json:"error,omitempty"` + Upstream string `json:"upstream,omitempty"` + Error string `json:"error,omitempty"` + Refusal string `json:"refusal,omitempty"` + Options []string `json:"options,omitempty"` // Persistence is unavailable when the latest fact is retained in memory because its journal write failed. Persistence string `json:"persistence,omitempty"` } @@ -91,6 +93,8 @@ type renderPatch struct { path string usage *ai.Usage err string + refusal string + options []string } const ( @@ -659,6 +663,16 @@ func (l *renderLedger) applyEnd(rec *RenderRecord, patch renderPatch, now time.T if patch.state == RenderCancelled { rec.Upstream = "unknown" } + rec.Refusal = "" + if len(patch.refusal) <= 128 { + rec.Refusal = strings.Clone(patch.refusal) + } + rec.Options = nil + for _, option := range patch.options[:min(len(patch.options), 8)] { + if option != "" && len(option) <= 1024 { + rec.Options = append(rec.Options, strings.Clone(option)) + } + } rec.Error = strings.Join(strings.Fields(patch.err), " ") if runes := []rune(rec.Error); len(runes) > renderErrorRunes { rec.Error = string(runes[:renderErrorRunes]) diff --git a/internal/session/session.go b/internal/session/session.go index eef5d1c8d5..3dbc60bda6 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -1873,7 +1873,8 @@ type Config struct { // which is why this is a closure and not a table. // // The absence law is per-verb: a nil Media keeps every generation tool off - // the belt; a nil MediaModel (or one answering "") keeps that MODALITY's + // the belt; without the optional MediaAdmission seam, a nil MediaModel + // (or one answering "") keeps that MODALITY's // tools off ([Agent.mediaHand]). They REPLACED a pre-revision pair of this // config's own — an image client and an image slug, with a pin ladder the // tool walked itself — and nothing of that pair survives: one client and one @@ -1881,6 +1882,9 @@ type Config struct { // speak for reasons nobody can find. Media MediaGenerator MediaModel func(modality string) string + // MediaAdmission optionally keeps connected generation capabilities explainable + // when no safe default exists. It is evaluated for the actual invocation. + MediaAdmission func(modality, word string) MediaAdmission // MediaPick is the just-in-time half of the pair above: where MediaModel // answers "the default for this modality", MediaPick answers "the model diff --git a/internal/session/task_run.go b/internal/session/task_run.go index 0935b44395..5cc7ab9247 100644 --- a/internal/session/task_run.go +++ b/internal/session/task_run.go @@ -7846,9 +7846,10 @@ func (a *Agent) newTaskAgentOn(ctx context.Context, dir string, node *TaskNode, // The media belt travels for the search pair's reason: a node briefed to // draw a diagram needs the hand that draws it, and the resolver is what // says which model does (media_contract.go). - Media: parent.Media, - MediaModel: parent.MediaModel, - MediaPick: parent.MediaPick, + Media: parent.Media, + MediaModel: parent.MediaModel, + MediaAdmission: parent.MediaAdmission, + MediaPick: parent.MediaPick, // A node reads documents on the rung the person chose, like the // conversation does (tools_doc.go): the same worker, working somewhere // quieter, must not silently drop to a different engine — or to a paid diff --git a/internal/session/tools_image.go b/internal/session/tools_image.go index 9a154a8672..c82f828bf2 100644 --- a/internal/session/tools_image.go +++ b/internal/session/tools_image.go @@ -141,9 +141,18 @@ func (a *Agent) generateImageTool(client MediaGenerator, defaultModel string) ba if err := decodeToolArguments(args, &parsed); err != nil { return "Invalid arguments: " + err.Error(), true, nil } + resolvedDefault := defaultModel + if strings.TrimSpace(parsed.Prompt) != "" && a.config.MediaAdmission != nil { + admission := a.config.MediaAdmission(modalityImage, parsed.Model) + if admission.Model == "" { + return a.refuseMediaAdmission(ctx, modalityImage, admission) + } + resolvedDefault = admission.Model + parsed.Model = "" // The invocation's exact choice was already resolved. + } text, failed := GenerateImage(ctx, ImageGen{ Client: client, - DefaultModel: defaultModel, + DefaultModel: resolvedDefault, Pick: a.config.MediaPick, Workspace: a.config.Workspace, Directory: ImagesDir(a.config.Place, a.config.Workspace), diff --git a/internal/session/tools_media.go b/internal/session/tools_media.go index 0cc29871ec..585bb2f0fa 100644 --- a/internal/session/tools_media.go +++ b/internal/session/tools_media.go @@ -10,7 +10,10 @@ package session // one call. A nil Media is no hand; a resolver that answers "" for the // modality is a hand with nothing to ask for. Either way the tool is left // OFF rather than added and made to refuse (tools_search.go states the law -// at length). +// at length). The explicit MediaAdmission seam is the exception for a +// connected image/video capability: its actual invocation explains a +// missing safe route in a durable, unpaid refusal. A missing client is +// always absent. // // - WHAT DOES A REFERENCE PATH MEAN? A picture the model points at is read off // the disk and encoded as a data URL, exactly as internal/exec/media.go's @@ -51,7 +54,9 @@ const ( // The bool is the belt's answer. It is false when the surface wired no media // client, when it wired no resolver, and when the resolver says this machine has // no model for this modality — three different absences that mean exactly one -// thing to the model, which is that it does not have the verb. +// thing to the model, which is that it does not have the verb. A surface that +// explicitly wires MediaAdmission may instead keep a connected image/video +// capability explainable; that seam must admit an actual route before payment. // // STUB(media/knob): both fields arrive FILLED from the door — the media client // built from the person's key, and the resolver with its four capability-checked @@ -60,11 +65,14 @@ const ( // and nothing here may re-derive one. func (a *Agent) mediaHand(modality string) (MediaGenerator, string, bool) { client := a.config.Media - if client == nil || a.config.MediaModel == nil { + if client == nil { return nil, "", false } - model := strings.TrimSpace(a.config.MediaModel(modality)) - if model == "" { + model := "" + if a.config.MediaModel != nil { + model = strings.TrimSpace(a.config.MediaModel(modality)) + } + if model == "" && (a.config.MediaAdmission == nil || (modality != modalityImage && modality != modalityVideo)) { return nil, "", false } return client, model, true diff --git a/internal/session/tools_video.go b/internal/session/tools_video.go index 11c94b94f7..83ad6e00a4 100644 --- a/internal/session/tools_video.go +++ b/internal/session/tools_video.go @@ -116,10 +116,18 @@ func (a *Agent) generateVideoTool(client MediaGenerator, defaultModel string) ba if prompt == "" { return "Invalid arguments: prompt is required", true, nil } + resolvedDefault := defaultModel + if a.config.MediaAdmission != nil { + admission := a.config.MediaAdmission(modalityVideo, parsed.Model) + if admission.Model == "" { + return a.refuseMediaAdmission(callCtx, modalityVideo, admission) + } + resolvedDefault, parsed.Model = admission.Model, "" + } // The call's own choice, resolved before the job starts, so a word // that matches nothing costs neither money nor a job row // (tools_image.go states the shape). - model, refusal := a.mediaPick(modalityVideo, parsed.Model, defaultModel) + model, refusal := a.mediaPick(modalityVideo, parsed.Model, resolvedDefault) if refusal != "" { return "Invalid arguments: " + refusal, true, nil } From d394f4466206db54397fdcedce6d1a7dc8762142 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 16 Oct 2026 01:42:12 -0400 Subject: [PATCH 157/459] Recognize raw MP3 content from consecutive valid frame headers --- internal/session/media_mp3.go | 43 ++++++++++++++++++++++++++++++ internal/session/media_mp3_test.go | 41 ++++++++++++++++++++++++++++ internal/session/mediafact.go | 3 +++ 3 files changed, 87 insertions(+) create mode 100644 internal/session/media_mp3.go create mode 100644 internal/session/media_mp3_test.go diff --git a/internal/session/media_mp3.go b/internal/session/media_mp3.go new file mode 100644 index 0000000000..ce02deb091 --- /dev/null +++ b/internal/session/media_mp3.go @@ -0,0 +1,43 @@ +package session + +// mp3Frames recognizes consecutive complete MPEG Layer III frame headers. +// Raw speech streams often have no ID3 tag; neither their extension nor a +// requested response format proves their content. Two frames at their computed +// boundaries avoid treating a lone sync-like byte pair as recorded audio. +func mp3Frames(data []byte) bool { + first, version, rate, ok := mp3Frame(data) + if !ok || first+4 > len(data) { + return false + } + second, nextVersion, nextRate, ok := mp3Frame(data[first:]) + return ok && nextVersion == version && nextRate == rate && first+second <= len(data) +} +func mp3Frame(data []byte) (length, version, rate int, ok bool) { + if len(data) < 4 || data[0] != 0xff || data[1]&0xe0 != 0xe0 { + return + } + version = int(data[1] >> 3 & 3) + if version == 1 || data[1]>>1&3 != 1 || data[3]&3 == 2 { + return + } // reserved version/layer/emphasis + bitrate := int(data[2] >> 4) + sample := int(data[2] >> 2 & 3) + if bitrate == 0 || bitrate == 15 || sample == 3 { + return + } + rates := [3]int{44100, 48000, 32000} + rate = rates[sample] + v1 := [15]int{0, 32, 40, 48, 56, 64, 80, 96, 112, 128, 160, 192, 224, 256, 320} + v2 := [15]int{0, 8, 16, 24, 32, 40, 48, 56, 64, 80, 96, 112, 128, 144, 160} + scale, kbps := 144000, v1[bitrate] + if version != 3 { + scale, kbps = 72000, v2[bitrate] + rate /= 2 + if version == 0 { + rate /= 2 + } + } + length = scale*kbps/rate + int(data[2]>>1&1) + ok = length >= 4 && length <= len(data) + return +} diff --git a/internal/session/media_mp3_test.go b/internal/session/media_mp3_test.go new file mode 100644 index 0000000000..bd008d529b --- /dev/null +++ b/internal/session/media_mp3_test.go @@ -0,0 +1,41 @@ +package session + +import "testing" + +func TestRawMP3SpeechIsRecognizedFromConsecutiveFrameHeadersNotItsName(t *testing.T) { + // fff3c4c4 is the actual Kokoro MP3 header observed in the private live probe: + // MPEG2 LayerIII,128kbit/s,24kHz. Only the public header is used here; these + // zero-filled frame payloads test structural recognition, not audio decoding. + header := []byte{0xff, 0xf3, 0xc4, 0xc4} + data := make([]byte, 768) + copy(data, header) + copy(data[384:], header) + if got := sniffMedia(data); got != "audio/mpeg" { + t.Fatalf("raw frames unknown: %q", got) + } + for _, test := range []struct { + name string + data []byte + }{ + {"one complete frame", data[:384]}, {"truncated second frame", data[:767]}, + {"sync-like binary", []byte{0xff, 0xf3, 0, 0}}, {"file extension is not content", []byte("hello.mp3")}, + } { + t.Run(test.name, func(t *testing.T) { + if mp3Frames(test.data) { + t.Fatal("invented MP3 content") + } + }) + } + for _, bad := range []byte{0x04, 0xf4, 0xcc} { + changed := append([]byte(nil), data...) + changed[2] = bad + if mp3Frames(changed) { + t.Fatalf("accepted invalid/free/unsupported frame bits %x", bad) + } + } + changed := append([]byte(nil), data...) + changed[385] = 0xfb // second frame claims a different MPEG version + if mp3Frames(changed) { + t.Fatal("unrelated headers were treated as consecutive audio") + } +} diff --git a/internal/session/mediafact.go b/internal/session/mediafact.go index d900cc8629..f7a26a38f5 100644 --- a/internal/session/mediafact.go +++ b/internal/session/mediafact.go @@ -276,6 +276,9 @@ func mediaKindOf(mime string) string { // sniffMedia is the media content type of the head of a file, or "". func sniffMedia(head []byte) string { + if mp3Frames(head) { + return "audio/mpeg" + } if len(head) > 512 { head = head[:512] } From eaf1e70bb4d13d58c5fa6b51ef0b9a90c99977ec Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 16 Oct 2026 01:43:14 -0400 Subject: [PATCH 158/459] Keep stable identities on ordinary new user messages and real send receipts --- cmd/codeaf/app.go | 15 ++++++- cmd/codeaf/appconvo.go | 3 ++ cmd/codeaf/appsubmission_test.go | 15 +++++++ internal/session/agent.go | 1 + internal/session/message_identity.go | 31 ++++++++++++++ internal/session/message_identity_test.go | 51 +++++++++++++++++++++++ 6 files changed, 114 insertions(+), 2 deletions(-) create mode 100644 internal/session/message_identity.go create mode 100644 internal/session/message_identity_test.go diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index 527761ef4d..56ffa401fb 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -1494,6 +1494,14 @@ func (b *appBackend) startLocked(a *appAgent, text, clientID string, shown *appS return 0, appwire.Errorf(appwire.CodeUnknownMethod, "this conversation cannot take pictures with a message; update its engine before sending") } } + // The receipt road also accepts ordinary text. A capable current host gives + // that actual user message an identity without upgrading an older host's + // plain-text promise or guessing a message id from its words. + if composer == nil { + if capable, ok := live.conn.Conv.(appComposer); ok && capable.ComposedKnown() { + composer = capable + } + } // A conversation joined on a window's host can have heard from the person // in that window too, so its count is read again before it is used. if !live.conn.Assembled && live.conn.Transcript != "" { @@ -1549,8 +1557,11 @@ func (b *appBackend) startLocked(a *appAgent, text, clientID string, shown *appS var receipt session.SubmissionReceipt receipt, events, err = composer.SubmitComposed(context.Background(), appSubmission(text, shown)) if err == nil { - shown.composed.receipt = &receipt - composedMark = &session.ComposedMark{MessageID: receipt.MessageID, Media: receipt.Media, Annotation: shown.composed.annotation, Delivery: &session.MessageDelivery{Mode: receipt.Delivery, Model: receipt.SeenBy}} + composedMark = &session.ComposedMark{MessageID: receipt.MessageID, Media: receipt.Media, Delivery: &session.MessageDelivery{Mode: receipt.Delivery, Model: receipt.SeenBy}} + if shown != nil && shown.composed != nil { + shown.composed.receipt = &receipt + composedMark.Annotation = shown.composed.annotation + } } } else if shown != nil { events, err = live.conn.Conv.SubmitMessage(context.Background(), appSubmission(text, shown)) diff --git a/cmd/codeaf/appconvo.go b/cmd/codeaf/appconvo.go index 8ebb7a4b23..e19df76a05 100644 --- a/cmd/codeaf/appconvo.go +++ b/cmd/codeaf/appconvo.go @@ -1025,6 +1025,9 @@ func appAttachmentsOf(person *session.PersonMark) []appwire.Attachment { // the model is sent, and the person's own words, attachments and reads beside // them. func appSubmission(text string, shown *appSending) session.Submission { + if shown == nil { + return session.Submission{Text: text} + } said := shown.text submission := session.Submission{Text: text, Said: &said, Attachments: shown.described, Reads: shown.reads, Queued: shown.queued} if shown.composed != nil { diff --git a/cmd/codeaf/appsubmission_test.go b/cmd/codeaf/appsubmission_test.go index 2308b36cfe..d715dbe249 100644 --- a/cmd/codeaf/appsubmission_test.go +++ b/cmd/codeaf/appsubmission_test.go @@ -286,3 +286,18 @@ func TestAppComposedTextWithoutAttachmentsHasAReceipt(t *testing.T) { t.Fatalf("not one plain composed message: %#v", sent) } } + +func TestOrdinaryAppSendUsesTheRealReceiptOnACapableHost(t *testing.T) { + conv := &composingConversation{fakeConversation: &fakeConversation{script: []session.Event{{Kind: session.EventTurnDone}}}, known: true} + backend, rec, agent, _, _ := composingBackend(t, conv) + if _, err := backend.Send(agent.ID, "ordinary words", "plain-1"); err != nil { + t.Fatal(err) + } + user := entryWith(rec, t, func(e appwire.Entry) bool { return e.Kind == appwire.EntryUser }) + if user.MessageID != "msg_0123456789abcdef01234567" || user.Text != "ordinary words" { + t.Fatalf("ordinary send invented or lost receipt: %+v", user) + } + if got := conv.sent(); len(got) != 1 || got[0].Text != "ordinary words" || len(got[0].Media) != 0 { + t.Fatalf("ordinary text was changed: %+v", got) + } +} diff --git a/internal/session/agent.go b/internal/session/agent.go index 5de36c4f8f..e8ccdf5f18 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -2719,6 +2719,7 @@ func (a *Agent) recordLocked(message ai.Message) { // must not write. Everything the model and the tools produce is text and goes // through recordLocked exactly as before. func (a *Agent) recordUserLocked(user userMessage) { + user = ensureUserIdentity(user) // AND WHOEVER SENT IT IS OWED AN ANSWER ONLY IF THE RECORD REALLY HOLDS IT. // `durable` is set by each road below: the journal write answers whether the // line reached the file, and a write that failed may not settle a delivery — diff --git a/internal/session/message_identity.go b/internal/session/message_identity.go new file mode 100644 index 0000000000..6d2ea3c727 --- /dev/null +++ b/internal/session/message_identity.go @@ -0,0 +1,31 @@ +package session + +// ensureUserIdentity runs only where a NEW user record lands. Replayed legacy +// messages remain unknown; authored notes are not relabelled as user requests. +// It copies the small presentation mark, never the immutable content allocation. +func ensureUserIdentity(user userMessage) userMessage { + if user.authored { + return user + } + if user.person != nil && user.person.Composed != nil && user.person.Composed.MessageID != "" { + return user + } + id, err := newMessageID() + if err != nil { + return user + } + mark := messagePresentation{Audience: audiencePerson} + if user.person != nil { + mark = *user.person + } + composed := ComposedMark{MessageID: id} + if mark.Composed != nil { + composed = *mark.Composed + composed.MessageID = id + } + // No delivery is invented for old submission doors: only the explicit + // composed road knows whether actual pictures or descriptions were sent. + mark.Composed = &composed + user.person = &mark + return user +} diff --git a/internal/session/message_identity_test.go b/internal/session/message_identity_test.go new file mode 100644 index 0000000000..4941e4b3c7 --- /dev/null +++ b/internal/session/message_identity_test.go @@ -0,0 +1,51 @@ +package session + +import ( + "path/filepath" + "testing" +) + +func TestOrdinaryNewUserMessagesHaveDistinctIdsWithoutCopyingContent(t *testing.T) { + original := userMessage{message: textMessage("user", "repeat the same words")} + a, b := ensureUserIdentity(original), ensureUserIdentity(original) + if a.person == nil || a.person.Composed == nil || a.person.Composed.MessageID == "" || a.person.Composed.MessageID == b.person.Composed.MessageID { + t.Fatalf("new equal-word messages lost distinct identity: %+v %+v", a.person, b.person) + } + if &a.message.Content[0] != &original.message.Content[0] || a.person.Composed.Delivery != nil || original.person != nil { + t.Fatal("identity copied content, invented delivery or changed its caller") + } + if got := ensureUserIdentity(a); got.person.Composed.MessageID != a.person.Composed.MessageID { + t.Fatal("existing accepted identity changed") + } + if got := ensureUserIdentity(userMessage{message: original.message, authored: true}); got.person != nil { + t.Fatal("an authored note became a user request") + } +} + +func TestOrdinaryMessageIdentitySurvivesRealJournalReopen(t *testing.T) { + path := filepath.Join(t.TempDir(), "session.jsonl") + first, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { c.SessionFile = path }) + // This is the real new-message record path, with no model/network request. + first.mu.Lock() + first.recordUserLocked(userMessage{message: textMessage("user", "ordinary words")}) + first.mu.Unlock() + entries := first.Transcript() + last := entries[len(entries)-1] + if last.Person == nil || last.Person.Composed == nil || last.Person.Composed.MessageID == "" { + t.Fatalf("live identity missing: %+v", last) + } + id := last.Person.Composed.MessageID + if err := first.Close(); err != nil { + t.Fatal(err) + } + reopened, err := newAgent(Config{Workspace: first.config.Workspace, Model: "test/model", System: "SYSTEM", SessionFile: path}, &scriptedCompleter{}) + if err != nil { + t.Fatal(err) + } + defer reopened.Close() + entries = reopened.Transcript() + last = entries[len(entries)-1] + if last.Person == nil || last.Person.Composed == nil || last.Person.Composed.MessageID != id { + t.Fatalf("reopen changed identity: %+v", last) + } +} From 324801c81743db1121ec8c92d12f91f649a7d3cb Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 16 Oct 2026 01:48:29 -0400 Subject: [PATCH 159/459] Notify source subscribers after real media index commits --- cmd/codeaf/app.go | 10 ++- cmd/codeaf/appconvo.go | 9 ++- cmd/codeaf/appmedia_events.go | 44 +++++++++++++ cmd/codeaf/appmedia_events_test.go | 35 +++++++++++ internal/appwire/media.go | 12 ++++ internal/remote/media_event_test.go | 23 +++++++ internal/session/media_event.go | 31 ++++++++++ internal/session/media_event_test.go | 92 ++++++++++++++++++++++++++++ internal/session/mediafact.go | 16 ++--- internal/session/session.go | 3 + 10 files changed, 265 insertions(+), 10 deletions(-) create mode 100644 cmd/codeaf/appmedia_events.go create mode 100644 cmd/codeaf/appmedia_events_test.go create mode 100644 internal/remote/media_event_test.go create mode 100644 internal/session/media_event.go create mode 100644 internal/session/media_event_test.go diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index 56ffa401fb..fb5591b1b0 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -484,7 +484,9 @@ type appLive struct { // renders is the last record sent for each render, by id: the same news // arrives on the turn's stream and on the task lane, and each change is // said once (apprender.go). - renders map[string]appwire.Render + renders map[string]appwire.Render + mediaRecent []string + mediaSeen map[string]bool // asks are the open asks in the order they were raised, and questions the // engine's own question behind each, which is what an answer is built from. asks []appwire.Ask @@ -1792,7 +1794,7 @@ func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, n if title := strings.TrimSpace(event.Text); title != "" { changed(func(v *appwire.Agent) { v.Title = title }) } - case session.EventTaskUpdate, session.EventTaskProposal, session.EventTaskPhase, session.EventJobUpdate, session.EventRenderUpdate: + case session.EventTaskUpdate, session.EventTaskProposal, session.EventTaskPhase, session.EventJobUpdate, session.EventRenderUpdate, session.EventMediaUpdate: // A task a running task call made is that call's task. if task := event.Task; task != nil && (event.Kind == session.EventTaskProposal || task.State == "" || task.State == session.TaskQueued) { b.callTask(a, appTaskID(id, task.ID)) @@ -2014,6 +2016,10 @@ func (b *appBackend) workEvent(id string, live *appLive, event session.Event) { if event.Render != nil { b.renderChanged(id, live, event.Render) } + case session.EventMediaUpdate: + if event.Media != nil { + b.mediaChanged(id, live, *event.Media) + } } } diff --git a/cmd/codeaf/appconvo.go b/cmd/codeaf/appconvo.go index e19df76a05..3ed9482205 100644 --- a/cmd/codeaf/appconvo.go +++ b/cmd/codeaf/appconvo.go @@ -1661,7 +1661,14 @@ func (b *appBackend) recordAttachedMedia(a *appAgent, kept []appAttach) { id := filepath.Base(filepath.Dir(a.transcript)) for _, attach := range kept { if attach.view.Kind == appwire.AttachFile { - session.RecordAttachment(appMediaIndex(), id, appAttachFull(workspace, attach.view.Path)) + if source := session.RecordAttachment(appMediaIndex(), id, appAttachFull(workspace, attach.view.Path)); source != "" { + var live *appLive + var agent string + b.locked(func() { live = a.live; agent = a.view.ID }) + if live != nil { + b.mediaChanged(agent, live, session.MediaChange{ID: source}) + } + } } } } diff --git a/cmd/codeaf/appmedia_events.go b/cmd/codeaf/appmedia_events.go new file mode 100644 index 0000000000..361da0e020 --- /dev/null +++ b/cmd/codeaf/appmedia_events.go @@ -0,0 +1,44 @@ +package main + +import ( + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/session" + "strings" +) + +const appMediaRecentMax = 128 + +// Turn streams and standing watchers can carry the same append. This is only a +// bounded recent-event filter, never an all-history source cache or authority. +func (b *appBackend) mediaChanged(agent string, live *appLive, change session.MediaChange) { + if live == nil || change.ID == "" || len(change.ID) > 128 { + return + } + event := appwire.MediaSourceChanged{Agent: agent, Source: strings.Clone(change.ID)} + if len(change.Run) <= 1024 { + event.Run = strings.Clone(change.Run) + } + if len(change.Call) <= 1024 { + event.Call = strings.Clone(change.Call) + } + fresh := false + b.locked(func() { + if live.mediaSeen == nil { + live.mediaSeen = map[string]bool{} + } + if live.mediaSeen[change.ID] { + return + } + live.mediaSeen[event.Source] = true + live.mediaRecent = append(live.mediaRecent, event.Source) + fresh = true + if len(live.mediaRecent) > appMediaRecentMax { + delete(live.mediaSeen, live.mediaRecent[0]) + copy(live.mediaRecent, live.mediaRecent[1:]) + live.mediaRecent = live.mediaRecent[:appMediaRecentMax] + } + }) + if fresh { + live.box.Event(appwire.EventMediaChanged, event) + } +} diff --git a/cmd/codeaf/appmedia_events_test.go b/cmd/codeaf/appmedia_events_test.go new file mode 100644 index 0000000000..f3448d06da --- /dev/null +++ b/cmd/codeaf/appmedia_events_test.go @@ -0,0 +1,35 @@ +package main + +import ( + "fmt" + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/session" + "testing" +) + +func TestMediaInvalidationDeduplicatesRecentDeliveryWithoutRetainingAllSources(t *testing.T) { + recorder := newAppRecorder() + backend := &appBackend{} + live := &appLive{box: appwire.NewOutbox(recorder)} + first := session.MediaChange{ID: "src_first", Run: "run_actual", Call: "call_actual"} + for range 20 { + backend.mediaChanged("agent", live, first) + } + for i := range 1000 { + backend.mediaChanged("agent", live, session.MediaChange{ID: fmt.Sprintf("src_%032x", i)}) + } + if len(live.mediaSeen) != appMediaRecentMax || len(live.mediaRecent) != appMediaRecentMax { + t.Fatalf("retained unbounded source history: %d %d", len(live.mediaSeen), len(live.mediaRecent)) + } + backend.mediaChanged("agent", live, session.MediaChange{}) + live.box.Close() + recorder.mu.Lock() + defer recorder.mu.Unlock() + if len(recorder.events) != 1001 { + t.Fatalf("notification count %d, expected one per unique append", len(recorder.events)) + } + firstData, ok := recorder.data[0].(appwire.MediaSourceChanged) + if !ok || firstData.Source != first.ID || firstData.Run != first.Run || firstData.Call != first.Call || firstData.Agent != "agent" { + t.Fatalf("lost actual source facts: %+v", recorder.data[0]) + } +} diff --git a/internal/appwire/media.go b/internal/appwire/media.go index eb5fa2f405..2df108e2e7 100644 --- a/internal/appwire/media.go +++ b/internal/appwire/media.go @@ -22,6 +22,17 @@ import ( // as "this host cannot say", not as "no media". // MediaPageDefault and MediaPageMax bound one media.list page. +const EventMediaChanged = "media.changed" + +// MediaChanged invalidates sources after a successful index append. The source +// descriptor must still be read through the authorized media APIs. +type MediaSourceChanged struct { + Agent string `json:"agent"` + Source string `json:"source"` + Run string `json:"run,omitempty"` + Call string `json:"call,omitempty"` +} + const ( MediaPageDefault = 50 MediaPageMax = 100 @@ -207,6 +218,7 @@ type MediaReader interface { } func init() { + eventShapes[EventMediaChanged] = reflect.TypeFor[MediaSourceChanged]() methods["media.list"] = typed(func(e Engine, p struct { Agent string `json:"agent"` Cursor string `json:"cursor"` diff --git a/internal/remote/media_event_test.go b/internal/remote/media_event_test.go new file mode 100644 index 0000000000..cc7796dd10 --- /dev/null +++ b/internal/remote/media_event_test.go @@ -0,0 +1,23 @@ +package remote + +import ( + "encoding/json" + "github.com/Agent-Field/codeaf/internal/session" + "testing" +) + +func TestMediaSourceChangeSurvivesTheHostEventWire(t *testing.T) { + original := session.Event{Kind: session.EventMediaUpdate, Media: &session.MediaChange{ID: "src_actual", Run: "run_actual", Call: "call_actual"}} + raw, err := json.Marshal(WireEvent(original)) + if err != nil { + t.Fatal(err) + } + var wire EventWire + if err = json.Unmarshal(raw, &wire); err != nil { + t.Fatal(err) + } + got := wire.Unwire() + if got.Kind != original.Kind || got.Media == nil || *got.Media != *original.Media { + t.Fatalf("lost source producer event: %+v", got) + } +} diff --git a/internal/session/media_event.go b/internal/session/media_event.go new file mode 100644 index 0000000000..436413a310 --- /dev/null +++ b/internal/session/media_event.go @@ -0,0 +1,31 @@ +package session + +// MediaChange invalidates a source listing after one actual index append. +// Immutable source facts still come from ListMedia/FindMedia; no path grant or +// guessed user-message join travels on this notification. +type MediaChange struct { + ID string `json:"id"` + Run string `json:"run,omitempty"` + Call string `json:"call,omitempty"` +} + +func (a *Agent) emitMediaChange(id string, fact MediaFact) { + change := MediaChange{ID: id} + if len(fact.Run) <= 1024 { + change.Run = fact.Run + } + if len(fact.Tool) <= 1024 { + change.Call = fact.Tool + } + event := Event{Kind: EventMediaUpdate, Media: &change} + a.mu.Lock() + hub := a.hub + watchers := append([]*eventStream(nil), a.taskWatchers...) + a.mu.Unlock() + if hub != nil { + hub.send(event) + } + for _, watcher := range watchers { + watcher.send(event) + } +} diff --git a/internal/session/media_event_test.go b/internal/session/media_event_test.go new file mode 100644 index 0000000000..6b62362adb --- /dev/null +++ b/internal/session/media_event_test.go @@ -0,0 +1,92 @@ +package session + +import ( + "os" + "path/filepath" + "testing" + "time" +) + +func TestMediaChangeIsEmittedOnlyAfterTheActualSourceWasRecorded(t *testing.T) { + dir := t.TempDir() + index := filepath.Join(dir, "artifacts.jsonl") + path := mediaFile(t, dir, "actual.png") + agent, _ := newMediaAgent(t, nil, func(c *Config) { c.ArtifactsIndex = index; c.SessionFile = filepath.Join(dir, "session.jsonl") }) + seen := 0 + hub := newEventHub() + hub.observe = func(event Event) { + if event.Kind != EventMediaUpdate { + return + } + seen++ + if event.Media == nil || event.Media.ID == "" || event.Media.Run != "run_actual" || event.Media.Call != "call_actual" { + t.Fatalf("invalid source notification: %+v", event.Media) + } + if _, err := FindMedia(index, agent.journalID(), event.Media.ID, time.Time{}); err != nil { + t.Fatalf("event preceded actual commit: %v", err) + } + } + agent.mu.Lock() + agent.hub = hub + agent.mu.Unlock() + fact := GeneratedMedia(path, pngOfSize(t, 4, 3), "painter") + fact.Run = "run_actual" + fact.Tool = "call_actual" + artifact := Artifact{Path: path, Title: "actual file", Kind: "image", Created: time.Now()} + agent.recordMedia(artifact, fact) + if seen != 1 { + t.Fatalf("source produced %d notifications", seen) + } + if err := os.Remove(index); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(index, 0700); err != nil { + t.Fatal(err) + } // A directory cannot be appended as a source index. + agent.recordMedia(artifact, fact) + if seen != 1 { + t.Fatal("a failed write emitted an invented source") + } +} +func TestMediaChangeReachesTheStandingLaneWithoutAnActiveTurn(t *testing.T) { + dir := t.TempDir() + index := filepath.Join(dir, "artifacts.jsonl") + path := mediaFile(t, dir, "actual.png") + agent, _ := newMediaAgent(t, nil, func(c *Config) { c.ArtifactsIndex = index; c.SessionFile = filepath.Join(dir, "session.jsonl") }) + lane, stop := agent.WatchTaskUpdates() + defer stop() + agent.recordMedia(Artifact{Path: path, Title: "actual file", Kind: "image", Created: time.Now()}, GeneratedMedia(path, pngOfSize(t, 4, 3), "painter")) + deadline := time.After(time.Second) + for { + select { + case event := <-lane: + if event.Kind == EventMediaUpdate { + if _, err := FindMedia(index, agent.journalID(), event.Media.ID, time.Time{}); err != nil { + t.Fatal(err) + } + return + } + case <-deadline: + t.Fatal("idle subscription missed source append") + } + } +} +func TestRecordAttachmentReturnsOnlyAnActualPersistedSourceIdentity(t *testing.T) { + dir := t.TempDir() + path := mediaFile(t, dir, "picked.png") + index := filepath.Join(dir, "artifacts.jsonl") + if id := RecordAttachment(dir, "s", path); id != "" { + t.Fatal("failed append returned a source identity") + } + id := RecordAttachment(index, "s", path) + if id == "" { + t.Fatal("actual picked media lacks identity") + } + if _, err := FindMedia(index, "s", id, time.Time{}); err != nil { + t.Fatal(err) + } + os.WriteFile(filepath.Join(dir, "notes.txt"), []byte("words"), 0600) + if id := RecordAttachment(index, "s", filepath.Join(dir, "notes.txt")); id != "" { + t.Fatal("nonmedia attachment produced an invalidation") + } +} diff --git a/internal/session/mediafact.go b/internal/session/mediafact.go index f7a26a38f5..6069fa4613 100644 --- a/internal/session/mediafact.go +++ b/internal/session/mediafact.go @@ -516,24 +516,24 @@ func RecordMedia(index string, artifact Artifact, fact MediaFact) string { // video. A file that is not media, or cannot be read, records nothing (an // attachment is not a deliverable, and a row nobody could open is noise). Only // the head of the file is read. -func RecordAttachment(index, sessionID, path string) { +func RecordAttachment(index, sessionID, path string) string { if sessionID == "" { - return + return "" } canonical := canonicalFile(path) info, err := os.Stat(canonical) if err != nil || !info.Mode().IsRegular() { - return + return "" } file, err := os.Open(canonical) if err != nil { - return + return "" } head, _ := io.ReadAll(io.LimitReader(file, mediaProbeBytes)) _ = file.Close() mime := sniffMedia(head) if mime == "" { - return + return "" } fact := MediaFact{Origin: MediaAttachment, MIME: mime} if mediaKindOf(mime) == "image" { @@ -542,7 +542,7 @@ func RecordAttachment(index, sessionID, path string) { } } fact.stampFile(canonical) - RecordMedia(index, Artifact{ + return RecordMedia(index, Artifact{ Path: canonical, Session: sessionID, Title: filepath.Base(canonical), @@ -555,7 +555,9 @@ func RecordAttachment(index, sessionID, path string) { // agent's own. func (a *Agent) recordMedia(artifact Artifact, fact MediaFact) { artifact.Session = a.journalID() - RecordMedia(a.config.ArtifactsIndex, artifact, fact) + if id := RecordMedia(a.config.ArtifactsIndex, artifact, fact); id != "" { + a.emitMediaChange(id, fact) + } } // fileVersion is the identity form of a version: size, modification time, diff --git a/internal/session/session.go b/internal/session/session.go index 3dbc60bda6..4255f821b6 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -668,6 +668,8 @@ const ( // render carry the SAME id and state. Appended after every existing ordinal // because hosts serialize EventKind as an integer. EventRenderUpdate = EventKeepRunning + 1 + // EventMediaUpdate follows a successful immutable source-index append. + EventMediaUpdate = EventRenderUpdate + 1 ) // TaskReplyTag is the task identity a surface places beside the answer its @@ -995,6 +997,7 @@ type Event struct { // Render carries one EventRenderUpdate's payload (renderlog.go) and is nil // on every other kind. Render *RenderRecord + Media *MediaChange // TaskPhase carries one EventTaskPhase's payload (task_contract.go): which // running node moved into which of its three lives. It is nil on every From 5131faa21f4df3d2482ea5d5a24ba4d4d431983e Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 16 Oct 2026 01:53:57 -0400 Subject: [PATCH 160/459] Verify identity-only marks for ordinary accepted submissions --- internal/session/submission_test.go | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/internal/session/submission_test.go b/internal/session/submission_test.go index 56f5bab404..3ed2d59b46 100644 --- a/internal/session/submission_test.go +++ b/internal/session/submission_test.go @@ -203,15 +203,20 @@ func TestASubmissionThatDoesNotFitRecordsNothing(t *testing.T) { } collect(t, events) users := personEntries(agent.Transcript()) - if len(users) != 1 || users[0].Text != "plain" || authored(users[0]) || users[0].Person != nil { + if len(users) != 1 || users[0].Text != "plain" || authored(users[0]) || users[0].Person == nil || users[0].Person.Composed == nil || users[0].Person.Composed.MessageID == "" || users[0].Person.Composed.Delivery != nil { t.Fatalf("a submission with no mark read back as %#v", users) } if err := agent.Close(); err != nil { t.Fatal(err) } - if marks := journalMarks(t, path); len(marks) != 1 || marks[0] != nil { + marks := journalMarks(t, path) + if len(marks) != 1 || marks[0] == nil { t.Fatalf("the journal holds %v", marks) } + composed, ok := marks[0]["composed"].(map[string]any) + if !ok || composed["messageId"] != users[0].Person.Composed.MessageID || composed["delivery"] != nil { + t.Fatalf("plain message identity/delivery changed: %v", marks) + } } // A LINE WRITTEN BEFORE THE MARK, OR WITHOUT ONE, HAS NO ATTACHMENTS, however From 628e249c5abd9c967a49ddf368c38be8e0fd7954 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 16 Oct 2026 01:59:28 -0400 Subject: [PATCH 161/459] fix(remote): negotiate ordinary message receipts before sending --- cmd/codeaf/app.go | 6 +++-- cmd/codeaf/appsubmission_test.go | 28 ++++++++++++++++++++++- internal/remote/client.go | 13 ++++++++++- internal/remote/composed_test.go | 39 ++++++++++++++++++++++++++++++++ internal/remote/server.go | 3 ++- internal/remote/wire.go | 6 ++++- 6 files changed, 89 insertions(+), 6 deletions(-) diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index fb5591b1b0..2f260f4ceb 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -1500,8 +1500,10 @@ func (b *appBackend) startLocked(a *appAgent, text, clientID string, shown *appS // that actual user message an identity without upgrading an older host's // plain-text promise or guessing a message id from its words. if composer == nil { - if capable, ok := live.conn.Conv.(appComposer); ok && capable.ComposedKnown() { - composer = capable + if receipts, advertised := live.conn.Conv.(interface{ MessageReceiptsKnown() bool }); advertised && receipts.MessageReceiptsKnown() { + if capable, ok := live.conn.Conv.(appComposer); ok && capable.ComposedKnown() { + composer = capable + } } } // A conversation joined on a window's host can have heard from the person diff --git a/cmd/codeaf/appsubmission_test.go b/cmd/codeaf/appsubmission_test.go index d715dbe249..56ab0d8f25 100644 --- a/cmd/codeaf/appsubmission_test.go +++ b/cmd/codeaf/appsubmission_test.go @@ -21,7 +21,8 @@ type composingConversation struct { composed []session.Submission } -func (c *composingConversation) ComposedKnown() bool { return c.known } +func (c *composingConversation) ComposedKnown() bool { return c.known } +func (c *composingConversation) MessageReceiptsKnown() bool { return c.known } func (c *composingConversation) SubmitComposed(ctx context.Context, message session.Submission) (session.SubmissionReceipt, <-chan session.Event, error) { c.mu.Lock() @@ -301,3 +302,28 @@ func TestOrdinaryAppSendUsesTheRealReceiptOnACapableHost(t *testing.T) { t.Fatalf("ordinary text was changed: %+v", got) } } + +// An old composed host accepts picture messages but did not promise receipts +// for ordinary text. A plain send must retain its old exactly-once road. +type imageOnlyReceiptHost struct{ *fakeConversation } + +func (c *imageOnlyReceiptHost) ComposedKnown() bool { return true } +func (c *imageOnlyReceiptHost) SubmitComposed(context.Context, session.Submission) (session.SubmissionReceipt, <-chan session.Event, error) { + panic("ordinary text crossed an unadvertised receipt road") +} +func TestOrdinaryAppSendKeepsTheOldHostPlainRoad(t *testing.T) { + conv := &imageOnlyReceiptHost{fakeConversation: &fakeConversation{script: []session.Event{{Kind: session.EventTurnDone}}}} + backend, rec, agent, _, _ := composingBackend(t, conv) + if _, err := backend.Send(agent.ID, "ordinary words", "old-1"); err != nil { + t.Fatal(err) + } + entry := entryWith(rec, t, func(e appwire.Entry) bool { return e.Kind == appwire.EntryUser }) + if entry.MessageID != "" || entry.Text != "ordinary words" { + t.Fatalf("old host identity was invented: %+v", entry) + } + conv.mu.Lock() + defer conv.mu.Unlock() + if len(conv.texts) != 1 || conv.texts[0] != "ordinary words" { + t.Fatalf("plain message lost or repeated: %+v", conv.texts) + } +} diff --git a/internal/remote/client.go b/internal/remote/client.go index 678c3ef0ca..5be7094c6e 100644 --- a/internal/remote/client.go +++ b/internal/remote/client.go @@ -1766,7 +1766,12 @@ func (a *Agent) SubmitComposed(ctx context.Context, message session.Submission) if !a.ComposedKnown() { return session.SubmissionReceipt{}, nil, errors.New("engine: this engine cannot take pictures with a message; update it before sending") } - payload, err := a.c.call(ctx, MethodSubmit, argsOf(message)) + args := argsOf(message) + if !args.composed() && !a.MessageReceiptsKnown() { + return session.SubmissionReceipt{}, nil, errors.New("engine: this host cannot acknowledge an ordinary message identity; update it before requesting a receipt") + } + args.WantReceipt = true + payload, err := a.c.call(ctx, MethodSubmit, args) if err != nil { return session.SubmissionReceipt{}, nil, err } @@ -2758,3 +2763,9 @@ func (c *Client) DeleteTask(file, id string) error { _, err := c.call(nil, MethodPlacesTaskDelete, TaskDeleteArgs{File: file, ID: id}) return err } + +// MessageReceiptsKnown distinguishes hosts that can acknowledge plain text +// from earlier composed-image-only hosts. A client checks before sending. +func (a *Agent) MessageReceiptsKnown() bool { + return a.welcomeSays(func(w Welcome) bool { return w.MessageReceipts }) +} diff --git a/internal/remote/composed_test.go b/internal/remote/composed_test.go index 7dc4723065..7e589863b8 100644 --- a/internal/remote/composed_test.go +++ b/internal/remote/composed_test.go @@ -90,3 +90,42 @@ func TestAnOlderHostIsRefusedBeforeAComposedMessageCrossesTheWire(t *testing.T) } var _ composedDoor = (*session.Agent)(nil) + +func TestPlainReceiptRequiresExplicitRequestAndIsReturnedOnTheHostWire(t *testing.T) { + agent := &composedAgent{fakeAgent: &fakeAgent{model: "m"}} + server := &server{out: io.Discard, session: NewSession(&Engine{Agent: agent}, false)} + raw, err := server.invoke(Frame{Kind: "call", ID: 1, Method: MethodSubmit, Payload: mustJSON(SubmitArgs{Text: "ordinary", WantReceipt: true})}) + if err != nil { + t.Fatal(err) + } + var got StreamRef + if err = json.Unmarshal(raw, &got); err != nil || got.Receipt == nil || got.Receipt.MessageID == "" || len(agent.submissions) != 1 { + t.Fatalf("receipt was lost after submission: %s %v", raw, err) + } + if agent.submissions[0].Text != "ordinary" || len(agent.submissions[0].Media) != 0 { + t.Fatalf("plain text changed: %+v", agent.submissions) + } +} +func TestPlainSubmitWithoutReceiptKeepsTheExistingHostRoad(t *testing.T) { + agent := &composedAgent{fakeAgent: &fakeAgent{model: "m"}} + server := &server{out: io.Discard, session: NewSession(&Engine{Agent: agent}, false)} + raw, err := server.invoke(Frame{Kind: "call", ID: 1, Method: MethodSubmit, Payload: mustJSON(SubmitArgs{Text: "ordinary"})}) + if err != nil { + t.Fatal(err) + } + var got StreamRef + if err = json.Unmarshal(raw, &got); err != nil || got.Receipt != nil || len(agent.submissions) != 0 { + t.Fatalf("plain road changed: %s %v", raw, err) + } +} + +func TestOldImageOnlyHostRefusesPlainReceiptBeforeSendingAnything(t *testing.T) { + client := &Client{welcome: Welcome{ComposedMessages: true}} + agent := &Agent{c: client} + if _, _, err := agent.SubmitComposed(t.Context(), session.Submission{Text: "ordinary"}); err == nil { + t.Fatal("ordinary receipt crossed an unadvertised capability") + } + if client.made.Load() != 0 { + t.Fatal("refused plain receipt crossed the wire") + } +} diff --git a/internal/remote/server.go b/internal/remote/server.go index 49be08b5fe..31fb27c34c 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -1276,6 +1276,7 @@ func (sess *Session) welcomeLocked(s *server) Welcome { return Welcome{ MessageMarks: messageMarksKnown(sess.agent), ComposedMessages: composedKnown(sess.agent), + MessageReceipts: composedKnown(sess.agent), SteerMarks: steerMarksKnown(sess.agent), FollowUpMarks: followUpMarksKnown(sess.agent), Version: Version, @@ -2653,7 +2654,7 @@ func (s *server) invoke(call Frame) (out json.RawMessage, err error) { SubmitMessage(context.Context, session.Submission) (<-chan session.Event, error) }) hasMark := args.marked() - if args.composed() { + if args.composed() || args.WantReceipt { // PICTURES ARE SENT WITH THE MESSAGE OR THE MESSAGE IS NOT SENT, and the // receipt rides the answer that names the stream. composer, ok := agent.(composedDoor) diff --git a/internal/remote/wire.go b/internal/remote/wire.go index 60df1845f2..5e888e37a3 100644 --- a/internal/remote/wire.go +++ b/internal/remote/wire.go @@ -1066,6 +1066,8 @@ type Welcome struct { // an annotation and answers its receipt. Absence is an older engine: such a // Submit is refused before anything is sent. ComposedMessages bool `json:"composedMessages,omitempty"` + // MessageReceipts also acknowledges ordinary text with an engine-minted ID. + MessageReceipts bool `json:"messageReceipts,omitempty"` // SessionInstance identifies this live conversation owner, not its durable // transcript. A replaced engine can reopen the same file with stream IDs // starting over, so a returning surface must forget the previous owner IDs. @@ -1480,7 +1482,9 @@ type InterruptArgs struct { } type SubmitArgs struct { - Text string `json:"text"` + // WantReceipt is used only when the welcome advertised MessageReceipts. + WantReceipt bool `json:"wantReceipt,omitempty"` + Text string `json:"text"` // Said, Attachments and Reads are what a door that knows more than the // words said about a person's message (internal/session's // [session.Submission]), on Submit alone. All are absent from every other From 1cb8aa172db295a5ad993bd61b53506a12d2573b Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 16 Oct 2026 01:59:28 -0400 Subject: [PATCH 162/459] test(session): assert ordinary identities without invented message facts --- internal/session/submission_media_test.go | 2 +- internal/session/submission_test.go | 39 ++++++++++++++++------- 2 files changed, 28 insertions(+), 13 deletions(-) diff --git a/internal/session/submission_media_test.go b/internal/session/submission_media_test.go index e29c5aaf86..b064004a36 100644 --- a/internal/session/submission_media_test.go +++ b/internal/session/submission_media_test.go @@ -499,7 +499,7 @@ func TestAComposedMessageSteersARunningTurnAndIsRecordedOnce(t *testing.T) { lines := journalUserLines(t, journal) var composed []sessionEntry for _, line := range lines { - if line.Presentation != nil && line.Presentation.Composed != nil { + if line.Presentation != nil && line.Presentation.Composed != nil && len(line.Presentation.Composed.Media) > 0 { composed = append(composed, line) } } diff --git a/internal/session/submission_test.go b/internal/session/submission_test.go index 3ed2d59b46..961cd29fbb 100644 --- a/internal/session/submission_test.go +++ b/internal/session/submission_test.go @@ -24,6 +24,26 @@ func answering() *scriptedCompleter { // authored says an entry's Text is the person's own words as their door said them. func authored(entry DisplayEntry) bool { return entry.Person != nil && entry.Person.Authored } +// An ordinary new message carries only its actual identity. It must not gain +// invented authorship, attachments, reads, media or delivery facts. +func ordinaryIdentity(entry DisplayEntry, queued bool) bool { + p := entry.Person + if p == nil || p.Authored || p.Queued != queued || len(p.Attachments) != 0 || len(p.Reads) != 0 || p.Composed == nil || p.Composed.MessageID == "" { + return false + } + copy := *p.Composed + copy.MessageID = "" + return reflect.DeepEqual(copy, ComposedMark{}) +} +func journalIdentity(mark map[string]any) string { + composed, ok := mark["composed"].(map[string]any) + if !ok || len(composed) != 1 { + return "" + } + id, _ := composed["messageId"].(string) + return id +} + // attachmentsOf is what an entry says rode on the person's message. func attachmentsOf(entry DisplayEntry) []MessageAttachment { if entry.Person == nil { @@ -90,7 +110,7 @@ func assertSubmitted(t *testing.T, where string, entries []DisplayEntry) { if users[1].Text != "" || !authored(users[1]) || !reflect.DeepEqual(attachmentsOf(users[1]), submittedAlone) { t.Fatalf("%s: the attachments alone = %#v", where, users[1]) } - if users[2].Text != "see Attached file: other.go" || authored(users[2]) || users[2].Person != nil { + if users[2].Text != "see Attached file: other.go" || authored(users[2]) || !ordinaryIdentity(users[2], false) { t.Fatalf("%s: the plain words = %#v", where, users[2]) } } @@ -149,12 +169,12 @@ func TestASubmittedMessageKeepsThePersonsWordsAndAttachmentsOnItsLine(t *testing } assertSubmitted(t, "bytes", ReadTranscriptBytes(data).Entries) marks := journalMarks(t, path) - if len(marks) != 3 || marks[0] == nil || marks[1] == nil || marks[2] != nil { + if len(marks) != 3 || marks[0] == nil || marks[1] == nil || journalIdentity(marks[2]) == "" { t.Fatalf("the person's lines carry marks %v", marks) } for _, mark := range marks[:2] { for key := range mark { - if key != "audience" && key != "text" && key != "attachments" { + if key != "audience" && key != "text" && key != "attachments" && key != "composed" { t.Fatalf("a mark carries %q: %v", key, mark) } } @@ -203,20 +223,15 @@ func TestASubmissionThatDoesNotFitRecordsNothing(t *testing.T) { } collect(t, events) users := personEntries(agent.Transcript()) - if len(users) != 1 || users[0].Text != "plain" || authored(users[0]) || users[0].Person == nil || users[0].Person.Composed == nil || users[0].Person.Composed.MessageID == "" || users[0].Person.Composed.Delivery != nil { + if len(users) != 1 || users[0].Text != "plain" || authored(users[0]) || !ordinaryIdentity(users[0], false) { t.Fatalf("a submission with no mark read back as %#v", users) } if err := agent.Close(); err != nil { t.Fatal(err) } - marks := journalMarks(t, path) - if len(marks) != 1 || marks[0] == nil { + if marks := journalMarks(t, path); len(marks) != 1 || journalIdentity(marks[0]) == "" || len(marks[0]) != 2 { t.Fatalf("the journal holds %v", marks) } - composed, ok := marks[0]["composed"].(map[string]any) - if !ok || composed["messageId"] != users[0].Person.Composed.MessageID || composed["delivery"] != nil { - t.Fatalf("plain message identity/delivery changed: %v", marks) - } } // A LINE WRITTEN BEFORE THE MARK, OR WITHOUT ONE, HAS NO ATTACHMENTS, however @@ -495,7 +510,7 @@ func TestAQueuedMessageSaysSoOnItsLineAndASentOneDoesNot(t *testing.T) { check := func(where string, entries []DisplayEntry) { t.Helper() users := personEntries(entries) - if len(users) != 2 || users[0].Person != nil || users[1].Person == nil || !users[1].Person.Queued || + if len(users) != 2 || !ordinaryIdentity(users[0], false) || !ordinaryIdentity(users[1], true) || users[1].Person == nil || !users[1].Person.Queued || users[1].Person.Authored || users[1].Text != "then the lint" { t.Fatalf("%s: the person's messages = %#v", where, users) } @@ -505,7 +520,7 @@ func TestAQueuedMessageSaysSoOnItsLineAndASentOneDoesNot(t *testing.T) { t.Fatal(err) } check("at rest", ReadTranscript(path).Entries) - if marks := journalMarks(t, path); len(marks) != 2 || marks[0] != nil || marks[1]["queued"] != true || marks[1]["text"] != nil { + if marks := journalMarks(t, path); len(marks) != 2 || journalIdentity(marks[0]) == "" || len(marks[0]) != 2 || journalIdentity(marks[1]) == "" || journalIdentity(marks[0]) == journalIdentity(marks[1]) || marks[1]["queued"] != true || marks[1]["text"] != nil { t.Fatalf("the person's lines carry marks %v", marks) } reopened, _ := newTestAgent(t, answering(), func(c *Config) { c.SessionFile = path }) From 9d55a87f0c37d2ed7c4b112259c0d2ea391662bc Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 16 Oct 2026 02:03:52 -0400 Subject: [PATCH 163/459] Initialize graph cache before overriding test admission limits --- cmd/codeaf/appworkgraph_test.go | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/cmd/codeaf/appworkgraph_test.go b/cmd/codeaf/appworkgraph_test.go index 60d0da2bc8..18b9100cd3 100644 --- a/cmd/codeaf/appworkgraph_test.go +++ b/cmd/codeaf/appworkgraph_test.go @@ -241,6 +241,8 @@ func TestWorkGraphUnchangedPagingAndRefusals(t *testing.T) { func TestAScopeOverTheSnapshotBudgetIsServedColdAndNeverKept(t *testing.T) { _, w, crew := graphFixture(t) scope := `{"kind":"crew","id":"` + crew + `"}` + // Initialize the lazy singleton before replacing its cache for this test. + workGraphs.init() prior := workGraphs.cache t.Cleanup(func() { workGraphs.cache = prior }) workGraphs.cache = workgraph.NewCache(workgraph.CacheLimits{Bytes: 512, Entries: 8, Pending: 4, BuildBytes: 1 << 20}) @@ -263,6 +265,8 @@ func TestAScopeOverTheSnapshotBudgetIsServedColdAndNeverKept(t *testing.T) { // would have started one build too many. func TestWorkGraphSaysBusyWhenTooManyDistinctBuildsAreQueued(t *testing.T) { _, w, crew := graphFixture(t) + // Initialize the lazy singleton before replacing its cache for this test. + workGraphs.init() prior := workGraphs.cache t.Cleanup(func() { workGraphs.cache = prior }) workGraphs.cache = workgraph.NewCache(workgraph.CacheLimits{Bytes: 1 << 20, Entries: 8, Pending: 1, BuildBytes: 1 << 20}) From cad84c365097dbb4fc0a095f32ab467e925b1958 Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 16 Oct 2026 02:04:34 -0400 Subject: [PATCH 164/459] feat(session): preserve immutable producing runs on replayed work --- cmd/codeaf/app.go | 12 ++- cmd/codeaf/apprun_test.go | 57 +++++++++++ cmd/codeaf/apptools.go | 7 +- internal/appwire/wire.go | 2 + internal/remote/run_event_test.go | 26 +++++ internal/session/agent.go | 14 ++- internal/session/event_run.go | 20 ++++ internal/session/loop.go | 8 +- internal/session/presentation.go | 12 ++- internal/session/reasoning.go | 4 +- internal/session/run_projection_test.go | 122 ++++++++++++++++++++++++ internal/session/session.go | 1 + 12 files changed, 273 insertions(+), 12 deletions(-) create mode 100644 cmd/codeaf/apprun_test.go create mode 100644 internal/remote/run_event_test.go create mode 100644 internal/session/event_run.go create mode 100644 internal/session/run_projection_test.go diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index 2f260f4ceb..72d7fbb648 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -1646,6 +1646,7 @@ func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, n b.locked(func() { live.lastN, live.lastMsgs, live.lastNotices = n, msg, notices }) }() var reply strings.Builder + var replyRun string // A MESSAGE BEFORE A TOOL CALL IS AN UPDATE, the agent saying what it // found mid-turn; the last one is the answer. // withheld says the reply's words so far could still be the withdrawal token @@ -1654,7 +1655,7 @@ func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, n withheld := true sayReply := func(kind string) { words := strings.TrimSpace(reply.String()) - defer func() { withheld = true }() + defer func() { withheld = true; replyRun = "" }() if session.IsNoChangeReply(words) { reply.Reset() return @@ -1665,7 +1666,7 @@ func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, n if words != "" { b.locked(func() { live.lastAnswer = words }) at := msg - entry(appwire.Entry{ID: appAnswerEntryID(n, at), Kind: kind, Text: words, Msg: &at}) + entry(appwire.Entry{ID: appAnswerEntryID(n, at), Kind: kind, Text: words, Msg: &at, RunID: replyRun}) msg++ } reply.Reset() @@ -1683,6 +1684,11 @@ func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, n for event := range events { switch event.Kind { case session.EventTextDelta: + if reply.Len() == 0 { + replyRun = event.RunID + } else if replyRun != event.RunID { + replyRun = "" + } endThought() reply.WriteString(event.Text) switch { @@ -2512,7 +2518,7 @@ func appEntryShapes(agent string, items []session.DisplayEntry, at time.Time, ma // Each entry carries the instant its own line was written; the // conversation's latest moment stands in only where the record never // said. - entry := appwire.Entry{Agent: agent, At: at} + entry := appwire.Entry{Agent: agent, At: at, RunID: item.RunID} if !item.At.IsZero() { entry.At = item.At } diff --git a/cmd/codeaf/apprun_test.go b/cmd/codeaf/apprun_test.go new file mode 100644 index 0000000000..51f401590a --- /dev/null +++ b/cmd/codeaf/apprun_test.go @@ -0,0 +1,57 @@ +package main + +import ( + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/session" +) + +func TestAppRunProjectionCarriesOnlyRecordedEntryIdentity(t *testing.T) { + entries := appEntriesOf("agent", []session.DisplayEntry{ + {Role: "assistant", Text: "same", RunID: "run-one", Answer: true}, + {Role: "tool", Tool: "read", CallID: "call", RunID: "run-one"}, + {Role: "assistant", Text: "same", RunID: "run-two", Answer: true}, + {Role: "assistant", Text: "legacy", Answer: true}, + }, time.Now(), nil) + if len(entries) != 4 || entries[0].RunID != "run-one" || entries[1].RunID != "run-one" || entries[2].RunID != "run-two" || entries[3].RunID != "" { + t.Fatalf("entry producer identities changed: %+v", entries) + } +} + +func TestAppRunProjectionKeepsActualLiveAnswerAndToolProducer(t *testing.T) { + conv := &fakeConversation{script: []session.Event{ + {Kind: session.EventTextDelta, Text: "before", RunID: "actual-run"}, + {Kind: session.EventToolBegin, CallID: "call", Tool: "read", RunID: "actual-run"}, + {Kind: session.EventToolEnd, CallID: "call", Tool: "read", RunID: "actual-run"}, + {Kind: session.EventTextDelta, Text: "after", RunID: "actual-run"}, + }} + backend, rec, agent := workBackend(t, conv) + if _, err := backend.Send(agent.ID, "hello", ""); err != nil { + t.Fatal(err) + } + rec.waitFor(t, 3*time.Second, func(event string, _ any) bool { return event == appwire.EventTurnDone }) + rec.mu.Lock() + defer rec.mu.Unlock() + answers, tools := 0, 0 + for _, value := range rec.data { + switch v := value.(type) { + case appwire.EntryAdded: + if v.Entry.Kind == appwire.EntryAnswer || v.Entry.Kind == appwire.EntryUpdate { + answers++ + if v.Entry.RunID != "actual-run" { + t.Fatalf("answer run=%+v", v.Entry) + } + } + case appwire.TurnTool: + tools++ + if v.RunID != "actual-run" { + t.Fatalf("tool run=%+v", v) + } + } + } + if answers != 2 || tools < 2 { + t.Fatalf("saw %d answers and %d tool events", answers, tools) + } +} diff --git a/cmd/codeaf/apptools.go b/cmd/codeaf/apptools.go index 719986df30..8871788d2a 100644 --- a/cmd/codeaf/apptools.go +++ b/cmd/codeaf/apptools.go @@ -61,6 +61,7 @@ const ( // appCall is one tool call as the work log knows it. type appCall struct { id, tool, args, hint, title string + run string turn uint64 facts appwire.ToolFacts ended bool @@ -214,6 +215,7 @@ func (b *appBackend) callBegan(a *appAgent, agent string, turn uint64, event ses c.facts.StartedAt = &at log.turn = append(log.turn, c) } + c.run = event.RunID c.tool, c.args, c.hint, c.title, c.turn = event.Tool, event.Args, event.Hint, title, turn kind := appToolKind(event.Tool, event.Service) if c.facts.Ask != "" { @@ -278,7 +280,7 @@ func (b *appBackend) rowLocked(agent string, c *appCall) appwire.TurnTool { if c.ended { phase = appwire.PhaseEnd } - row := appwire.TurnTool{Agent: agent, Turn: c.turn, Call: c.id, Name: c.tool, Title: c.title, Phase: phase, OK: c.ok, Summary: c.summary, Detail: c.detail} + row := appwire.TurnTool{RunID: c.run, Agent: agent, Turn: c.turn, Call: c.id, Name: c.tool, Title: c.title, Phase: phase, OK: c.ok, Summary: c.summary, Detail: c.detail} row.ToolFacts = c.factsLocked() return row } @@ -389,6 +391,9 @@ func (b *appBackend) callEnded(a *appAgent, agent string, turn uint64, event ses log.order = append(log.order, event.CallID) } } + if c.run == "" { + c.run = event.RunID + } if c.title == "" { c.title = title } diff --git a/internal/appwire/wire.go b/internal/appwire/wire.go index c1fb4d4143..d0feb1e3d9 100644 --- a/internal/appwire/wire.go +++ b/internal/appwire/wire.go @@ -310,6 +310,7 @@ const ( // Entry is one item of a conversation's transcript, in order. Which of the // optional fields are set depends on Kind. type Entry struct { + RunID string `json:"run_id,omitempty"` ID string `json:"id"` Agent string `json:"agent"` Turn uint64 `json:"turn,omitempty"` @@ -624,6 +625,7 @@ type ( Text string `json:"text"` } TurnTool struct { + RunID string `json:"run_id,omitempty"` Agent string `json:"agent"` Turn uint64 `json:"turn"` Call string `json:"call"` diff --git a/internal/remote/run_event_test.go b/internal/remote/run_event_test.go new file mode 100644 index 0000000000..7781e6a78a --- /dev/null +++ b/internal/remote/run_event_test.go @@ -0,0 +1,26 @@ +package remote + +import ( + "encoding/json" + "testing" + + "github.com/Agent-Field/codeaf/internal/session" +) + +func TestActualRunIdentitySurvivesTheHostEventWire(t *testing.T) { + for _, run := range []string{"", "actual-run"} { + original := session.Event{Kind: session.EventToolBegin, CallID: "same-call", RunID: run} + raw, err := json.Marshal(WireEvent(original)) + if err != nil { + t.Fatal(err) + } + var wire EventWire + if err = json.Unmarshal(raw, &wire); err != nil { + t.Fatal(err) + } + got := wire.Unwire() + if got.RunID != run || got.CallID != original.CallID { + t.Fatalf("run replaced by host: %+v", got) + } + } +} diff --git a/internal/session/agent.go b/internal/session/agent.go index e8ccdf5f18..af9167b1b3 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -1796,6 +1796,7 @@ func (a *Agent) startTurnLocked(ctx context.Context, user userMessage, watcher * a.moveAsked.Store(false) turnCtx, cancel := context.WithCancelCause(ctx) turnCtx = withRunIdentity(turnCtx) + hub.runID = runIdentity(turnCtx) a.cancel = cancel // AND THE TURN CARRIES ITS OWN SECOND STAGE (abandon.go). The signal rides on // the context because the waits that need it are several frames down inside @@ -4257,6 +4258,8 @@ func textMessage(role, text string) ai.Message { // never saw the events, and starting empty would leave it looking at a session // that is visibly working and saying nothing. type eventHub struct { + // runID is immutable before this hub is published to a turn reader. + runID string // observe, when set, receives every event in place of subscribers: a // pass a surface asked for outside any turn ([Agent.CompactObserved]). observe func(Event) @@ -4472,6 +4475,9 @@ func (h *eventHub) send(event Event) (landed bool) { if h == nil { return false } + if event.RunID == "" && foregroundRunEvent(event.Kind) { + event.RunID = h.runID + } if h.observe != nil { h.observe(event) return true @@ -4529,7 +4535,7 @@ func (h *eventHub) foldedLocked() { // this comparison, or be excluded from folding, to survive a late attach. func foldsInto(prev, next Event) bool { - if prev.Kind != next.Kind || prev.Addressed != next.Addressed { + if prev.Kind != next.Kind || prev.Addressed != next.Addressed || prev.RunID != next.RunID { return false } return prev.Kind == EventTextDelta || prev.Kind == EventReasoning || @@ -4724,6 +4730,8 @@ func (s *eventStream) pump() { // from this display shape even though the journal keeps it for model continuity; // nothing about the wire itself is shown to the person. type DisplayEntry struct { + // RunID is the immutable producing turn, absent on legacy records. + RunID string // Role is "user" | "assistant" | "tool" | "note" | "aside". // // "note" is a system-injected marker a surface draws as a rule of its own — a @@ -5070,7 +5078,9 @@ func shapeEntries(messages []ai.Message, journal *sessionFile, indexes ...*prese } } interrupted, explicitlyHuman := false, false + run := "" if mark := presentation.of(msg); role == "assistant" && mark != nil { + run = mark.RunID interrupted = mark.Interrupted explicitlyHuman = mark.Audience == "human" if mark.Audience == "operational" { @@ -5095,6 +5105,7 @@ func shapeEntries(messages []ai.Message, journal *sessionFile, indexes ...*prese } entries = append(entries, DisplayEntry{ Role: role, + RunID: run, Answer: role == "assistant" && !interrupted && (len(msg.ToolCalls) == 0 || update), Addressed: role == "assistant" && (update || interrupted && explicitlyHuman), Interrupted: interrupted, @@ -5120,6 +5131,7 @@ func shapeEntries(messages []ai.Message, journal *sessionFile, indexes ...*prese told, family := journal.caption(call.ID) entries = append(entries, DisplayEntry{ Role: "tool", + RunID: run, Tool: call.Function.Name, CallID: call.ID, Hint: gloss(*call), diff --git a/internal/session/event_run.go b/internal/session/event_run.go new file mode 100644 index 0000000000..cf664c6926 --- /dev/null +++ b/internal/session/event_run.go @@ -0,0 +1,20 @@ +package session + +func hubRunID(h *eventHub) string { + if h == nil { + return "" + } + return h.runID +} + +// Background notifications carry their own provenance; receiving them during +// this turn never makes them work of this turn. +func foregroundRunEvent(kind EventKind) bool { + switch kind { + case EventTextDelta, EventThinking, EventReasoning, EventToolBegin, + EventToolEnd, EventToolFailed, EventToolOutput, EventToolAnnounced, + EventToolForming, EventToolFinished, EventTurnDone: + return true + } + return false +} diff --git a/internal/session/loop.go b/internal/session/loop.go index 5513a6048b..8249b70831 100644 --- a/internal/session/loop.go +++ b/internal/session/loop.go @@ -1173,7 +1173,7 @@ func (a *Agent) runTurn(ctx context.Context, hub *eventHub, user userMessage) bo } } } else { - a.recordAssistant(ai.Message{Role: "assistant", Content: assistantContent(response)}, reasoning.snapshot()) + a.recordAssistant(ai.Message{Role: "assistant", Content: assistantContent(response)}, reasoning.snapshot(), runIdentity(ctx)) assistantDone = true } // The step's text is in the transcript now. Resetting here rather @@ -1319,7 +1319,7 @@ func (a *Agent) runTurn(ctx context.Context, hub *eventHub, user userMessage) bo severed := a.considerSeverance(calls, store.ClassifyEnd(provider.FinishReason(response), true), &salvages) assistant := ai.Message{Role: "assistant", Content: assistantContent(response), ToolCalls: calls} - a.recordAssistant(assistant, reasoning.snapshot()) + a.recordAssistant(assistant, reasoning.snapshot(), runIdentity(ctx)) visibleText := strings.TrimSpace(messageContentText(assistant)) != "" partial.reset() usedTools = true @@ -1639,7 +1639,7 @@ func (a *Agent) keepPartial(partial *partialBuffer, hub *eventHub) { if a.stoppedSoup(text, hub) { return } - a.recordPresentedAssistant(textMessage("assistant", text), provider.MessageReasoning{}, interruptedPresentation(text)) + a.recordPresentedAssistant(textMessage("assistant", text), provider.MessageReasoning{}, interruptedPresentation(text), hubRunID(hub)) } // stoppedSoup is the keep path's one question, and its one word to the person @@ -1727,7 +1727,7 @@ func (a *Agent) keepSteeredPartial(partial *partialBuffer, reasoning *reasoningB return } mark := interruptedPresentation(visible) - a.recordPresentedAssistant(textMessage("assistant", text), reasoning.snapshot(), mark) + a.recordPresentedAssistant(textMessage("assistant", text), reasoning.snapshot(), mark, hubRunID(hub)) } // sealTurn stamps the turn's wall duration, folds it into the session total, diff --git a/internal/session/presentation.go b/internal/session/presentation.go index 24a0be6cb6..44f8d86dcb 100644 --- a/internal/session/presentation.go +++ b/internal/session/presentation.go @@ -26,6 +26,7 @@ import ( // words were written from ([Agent.SubmitMessage]). None of them changes the // content; all are absent from every line no such door wrote. type messagePresentation struct { + RunID string `json:"run_id,omitempty"` Audience string `json:"audience"` Text *string `json:"text,omitempty"` Interrupted bool `json:"interrupted,omitempty"` @@ -164,7 +165,16 @@ func humanPresentation(text string) *messagePresentation { return &messagePresentation{Audience: "human", Text: &text} } -func (a *Agent) recordPresentedAssistant(message ai.Message, reasoning provider.MessageReasoning, mark *messagePresentation) { +func (a *Agent) recordPresentedAssistant(message ai.Message, reasoning provider.MessageReasoning, mark *messagePresentation, runs ...string) { + if len(runs) > 0 && runs[0] != "" { + if mark == nil { + mark = &messagePresentation{Audience: "human"} + } else { + copy := *mark + mark = © + } + mark.RunID = runs[0] + } a.mu.Lock() defer a.mu.Unlock() if a.presentation == nil { diff --git a/internal/session/reasoning.go b/internal/session/reasoning.go index 7310579fae..d91c54469c 100644 --- a/internal/session/reasoning.go +++ b/internal/session/reasoning.go @@ -173,8 +173,8 @@ func assembledReasoningDetails(raw json.RawMessage) json.RawMessage { return append(out, ']') } -func (a *Agent) recordAssistant(message ai.Message, reasoning provider.MessageReasoning) { - a.recordPresentedAssistant(message, reasoning, nil) +func (a *Agent) recordAssistant(message ai.Message, reasoning provider.MessageReasoning, runs ...string) { + a.recordPresentedAssistant(message, reasoning, nil, runs...) } // snapshotWithReasoning takes both aligned slices under one lock. THE SIDECAR diff --git a/internal/session/run_projection_test.go b/internal/session/run_projection_test.go new file mode 100644 index 0000000000..afe224a9e2 --- /dev/null +++ b/internal/session/run_projection_test.go @@ -0,0 +1,122 @@ +package session + +import ( + "context" + "path/filepath" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/provider" +) + +func TestRunProjectionMatchesActualTurnAndSurvivesReopen(t *testing.T) { + path := filepath.Join(t.TempDir(), "conversation.jsonl") + next := func(ctx context.Context, _ []ai.Message) (*ai.Response, error) { + return textResponse("identical answer"), nil + } + agent, _ := newTestAgent(t, &scriptedCompleter{steps: []step{next, next}}, func(c *Config) { c.SessionFile = path }) + var runs []string + for range 2 { + events, err := agent.Submit(context.Background(), "identical question") + if err != nil { + t.Fatal(err) + } + var run string + for _, e := range collect(t, events) { + if e.Kind == EventTurnDone { + run = e.RunID + } + } + if run == "" { + t.Fatal("actual turn did not carry its immutable run") + } + runs = append(runs, run) + } + if runs[0] == runs[1] { + t.Fatal("different turns shared one run") + } + check := func(entries []DisplayEntry) { + t.Helper() + n := 0 + for _, entry := range entries { + if entry.Role == "assistant" { + if n >= len(runs) || entry.RunID != runs[n] { + t.Fatalf("answer lost producing run: %+v", entry) + } + n++ + } + } + if n != 2 { + t.Fatalf("got %d answers", n) + } + } + check(agent.Transcript()) + if err := agent.Close(); err != nil { + t.Fatal(err) + } + check(ReadTranscript(path).Entries) +} + +func TestRunProjectionPreservesToolOnlyOccurrencesThroughCompaction(t *testing.T) { + path := filepath.Join(t.TempDir(), "conversation.jsonl") + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { c.SessionFile = path }) + response := toolResponse("same-provider-call", "read", `{"path":"x"}`) + message := ai.Message{Role: "assistant", Content: assistantContent(response), ToolCalls: response.Choices[0].Message.ToolCalls} + agent.recordAssistant(message, provider.MessageReasoning{}, "run-one") + agent.recordAssistant(message, provider.MessageReasoning{}, "run-two") + agent.recordAssistant(textMessage("assistant", "legacy unknown"), provider.MessageReasoning{}) + check := func(entries []DisplayEntry) { + t.Helper() + var got []string + for _, entry := range entries { + if entry.Role == "tool" { + got = append(got, entry.RunID) + } + if entry.Text == "legacy unknown" && entry.RunID != "" { + t.Fatal("legacy acquired a run") + } + } + if len(got) != 2 || got[0] != "run-one" || got[1] != "run-two" { + t.Fatalf("tool occurrence runs = %v", got) + } + } + check(agent.Transcript()) + agent.file.appendCompaction(compactionPass{}, 0, agent.snapshot()[1:]) + if err := agent.Close(); err != nil { + t.Fatal(err) + } + check(ReadTranscript(path).Entries) +} + +func TestRunHubDoesNotRelabelBackgroundFactsOrFoldDifferentRuns(t *testing.T) { + hub := newEventHub() + hub.runID = "foreground" + events := hub.subscribe() + hub.send(Event{Kind: EventTextDelta, Text: "one"}) + hub.send(Event{Kind: EventMediaUpdate, Media: &MediaChange{ID: "source", Run: "background"}}) + hub.send(Event{Kind: EventTextDelta, Text: "other", RunID: "explicit"}) + hub.close() + got := collect(t, events) + if len(got) != 3 || got[0].RunID != "foreground" || got[1].RunID != "" || got[1].Media.Run != "background" || got[2].RunID != "explicit" { + t.Fatalf("wrong producer join: %+v", got) + } + if foldsInto(got[0], got[2]) { + t.Fatal("text from distinct runs folded together") + } +} + +func TestInterruptedRunProjectionUsesItsOwnHubAfterAnotherTurnExists(t *testing.T) { + agent, _ := newTestAgent(t, &scriptedCompleter{}, nil) + old := newEventHub() + old.runID = "old-run" + newer := newEventHub() + newer.runID = "new-run" + agent.hub = newer + partial := &partialBuffer{} + partial.write("[update] A genuine partial finding.") + agent.keepPartial(partial, old) + entries := agent.Transcript() + if len(entries) != 1 || entries[0].RunID != "old-run" || !entries[0].Interrupted { + t.Fatalf("partial borrowed current turn: %+v", entries) + } +} diff --git a/internal/session/session.go b/internal/session/session.go index 4255f821b6..f20a3f6f12 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -706,6 +706,7 @@ type TaskReplyTag struct { // today — sees all of it in order and needs no second rule; a caller that stops // at the terminal event stops at the terminal event of the FIRST turn. type Event struct { + RunID string `json:"RunID,omitempty"` ReplayCursor ReplayCursor `json:",omitempty"` ReplayObserved bool `json:"-"` From 75b5c8af499b5b5b0f559bebc482e85bc9b699dc Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 16 Oct 2026 02:10:11 -0400 Subject: [PATCH 165/459] feat(media): retain known inputs from the actual successful request --- cmd/codeaf/appmedia.go | 1 + cmd/codeaf/apprun_test.go | 7 ++ internal/appwire/media.go | 12 +-- internal/session/loop.go | 14 +++- internal/session/media_input.go | 82 +++++++++++++++++++ internal/session/media_input_test.go | 116 +++++++++++++++++++++++++++ internal/session/mediacache.go | 1 + internal/session/mediacache_test.go | 5 +- internal/session/mediafact.go | 9 ++- 9 files changed, 234 insertions(+), 13 deletions(-) create mode 100644 internal/session/media_input.go create mode 100644 internal/session/media_input_test.go diff --git a/cmd/codeaf/appmedia.go b/cmd/codeaf/appmedia.go index e5ced41983..850240e9d5 100644 --- a/cmd/codeaf/appmedia.go +++ b/cmd/codeaf/appmedia.go @@ -109,6 +109,7 @@ func appMediaSource(row session.MediaRow, live session.MediaLive, probe bool) ap MIME: fact.MIME, Created: row.Artifact.Created.UTC().Format(time.RFC3339Nano), Title: row.Artifact.Title, Model: fact.Model, RequestedModel: fact.Model, Run: fact.Run, Message: fact.Message, Tool: fact.Tool, References: fact.References, Said: fact.Said, + InputMessages: fact.InputMessages, InputMessagesOmitted: fact.InputMessagesOmitted, Batch: fact.Batch, VariantIndex: fact.VariantIndex, ReturnedCount: fact.Returned, CostScope: fact.CostScope, Verb: fact.Verb, Saw: fact.Saw, SawOmitted: fact.SawOmitted, SeenBy: fact.SeenBy, WorkingModel: fact.Working, Transcribed: fact.Transcribed, Charge: fact.Charge, } diff --git a/cmd/codeaf/apprun_test.go b/cmd/codeaf/apprun_test.go index 51f401590a..6574ad2ec8 100644 --- a/cmd/codeaf/apprun_test.go +++ b/cmd/codeaf/apprun_test.go @@ -55,3 +55,10 @@ func TestAppRunProjectionKeepsActualLiveAnswerAndToolProducer(t *testing.T) { t.Fatalf("saw %d answers and %d tool events", answers, tools) } } + +func TestAppMediaInputMessagesRemainKnownSubsetNotSingleCausality(t *testing.T) { + source := appMediaSource(session.MediaRow{Fact: session.MediaFact{InputMessages: []string{"known-one", "known-two"}, InputMessagesOmitted: true}}, session.MediaLive{}, false) + if len(source.InputMessages) != 2 || source.InputMessages[1] != "known-two" || !source.InputMessagesOmitted || source.Message != "" { + t.Fatalf("source lineage invented: %+v", source) + } +} diff --git a/internal/appwire/media.go b/internal/appwire/media.go index 2df108e2e7..53fd40cf28 100644 --- a/internal/appwire/media.go +++ b/internal/appwire/media.go @@ -152,11 +152,13 @@ type MediaSource struct { RequestedCount *int `json:"requested_count,omitempty"` // Run is the turn's immutable execution id and Tool the provider's id for // the tool call that made the file, both read off the call itself. - Run string `json:"run,omitempty"` - Message string `json:"message,omitempty"` - Tool string `json:"tool,omitempty"` - References []string `json:"references,omitempty"` - Said string `json:"said,omitempty"` + Run string `json:"run,omitempty"` + Message string `json:"message,omitempty"` + Tool string `json:"tool,omitempty"` + References []string `json:"references,omitempty"` + InputMessages []string `json:"input_messages,omitempty"` + InputMessagesOmitted bool `json:"input_messages_omitted,omitempty"` + Said string `json:"said,omitempty"` // Evidence only (origin evidence — a file the agent looked at): Verb is // looked_at · listened_to · watched · read; Saw is the looking model's words // verbatim (SawOmitted when too long to keep whole); SeenBy is the model diff --git a/internal/session/loop.go b/internal/session/loop.go index 8249b70831..e14d62fdd0 100644 --- a/internal/session/loop.go +++ b/internal/session/loop.go @@ -1014,7 +1014,8 @@ func (a *Agent) runTurn(ctx context.Context, hub *eventHub, user userMessage) bo // the person's wait to be sent anywhere ends and where a tool-result gap // closes. pace.sending(sentAt) - response, answered, err := a.completeWithRetryReasoning(ctx, hub, model, rung, partial, reached, reasoning, warm, forming, frozenToolHistory) + inputCapture := &mediaInputCapture{} + response, answered, err := a.completeWithRetryReasoning(withMediaInputCapture(ctx, inputCapture), hub, model, rung, partial, reached, reasoning, warm, forming, frozenToolHistory) a.config.beat.ended() // THE MODEL THIS TURN IS ON CAN CHANGE UNDER IT. A step whose budget of // cut streams ran out moves to the next model in the chain and says so, @@ -1379,15 +1380,18 @@ func (a *Agent) runTurn(ctx context.Context, hub *eventHub, user userMessage) bo // so the loop — the one place the reading's cost is a fact rather than an // instruction — spends the hand-off itself. Conversations only: a task // worker's own reads are its work, not a sweep. + // Post-response tools receive the successful request snapshot. Early warm + // reads already started on their own context and deliberately stay unknown. + batchCtx := withMediaInputs(toolCtx, inputCapture.messages) var results []toolResult if !a.config.InTask && chatRunEngine != nil && sweep.due(calls) { - if handed := a.handoffReadSweep(toolCtx, episode, hub, user, calls, sweep, warm); handed != nil { + if handed := a.handoffReadSweep(batchCtx, episode, hub, user, calls, sweep, warm); handed != nil { results = handed } else { - results = a.runToolsWarm(toolCtx, episode, calls, hub, warm) + results = a.runToolsWarm(batchCtx, episode, calls, hub, warm) } } else { - results = a.runToolsWarm(toolCtx, episode, calls, hub, warm) + results = a.runToolsWarm(batchCtx, episode, calls, hub, warm) sweep.count(calls) } // THE GAP THE LAW IS ABOUT STARTS HERE. Everything between this line and @@ -2038,6 +2042,7 @@ func (a *Agent) completeWithRetryReasoning(ctx context.Context, hub *eventHub, m Window: a.window(), Reserve: ctxbudget.CompletionReserve(), PromptFloor: promptFloor, }) attemptCtx, generation := a.beginGeneration(attemptCtx, reached) + inputMessages := a.preparedMediaInputs(messages) response, err := a.completeWithModel(attemptCtx, purpose, messages, model, ai.WithTools(a.beltDefinitions())) cause := a.endGeneration(generation) @@ -2051,6 +2056,7 @@ func (a *Agent) completeWithRetryReasoning(ctx context.Context, hub *eventHub, m // it is deliberate: a person typing into a turn has said they want the // boundary whatever else happened. if err == nil { + publishMediaInputs(ctx, inputMessages) return response, model, nil } // AND THE MARK'S READING TRAVELS OUT, because what it wants is the diff --git a/internal/session/media_input.go b/internal/session/media_input.go new file mode 100644 index 0000000000..9a35b34144 --- /dev/null +++ b/internal/session/media_input.go @@ -0,0 +1,82 @@ +package session + +import ( + "context" + + "github.com/Agent-Field/agentfield/sdk/go/ai" +) + +const mediaInputMessageLimit = 64 + +type mediaInputMessages struct { + IDs []string + Omitted bool +} +type mediaInputContextKey struct{} +type mediaInputCaptureKey struct{} + +// A capture belongs to one synchronous completion invocation. Only the +// completion caller reads it, after the call returns; it never rides a tool or +// a streamed callback's context. Failed attempts cannot publish a snapshot. +type mediaInputCapture struct{ messages mediaInputMessages } + +func withMediaInputCapture(ctx context.Context, capture *mediaInputCapture) context.Context { + return context.WithValue(ctx, mediaInputCaptureKey{}, capture) +} +func publishMediaInputs(ctx context.Context, messages mediaInputMessages) { + if capture, _ := ctx.Value(mediaInputCaptureKey{}).(*mediaInputCapture); capture != nil { + capture.messages = messages + } +} +func withMediaInputs(ctx context.Context, messages mediaInputMessages) context.Context { + messages.IDs = append([]string(nil), messages.IDs...) + return context.WithValue(ctx, mediaInputContextKey{}, messages) +} +func mediaInputsFrom(ctx context.Context) mediaInputMessages { + messages, _ := ctx.Value(mediaInputContextKey{}).(mediaInputMessages) + messages.IDs = append([]string(nil), messages.IDs...) + return messages +} + +// Only identities on the actual prepared request count. Equal words, latest +// user state and chronological proximity prove no input relationship. Work is +// once per provider request, never per token, with at most 64 retained IDs. +func (a *Agent) preparedMediaInputs(messages []ai.Message) mediaInputMessages { + a.mu.Lock() + p := a.presentation + a.mu.Unlock() + return p.inputMessages(messages) +} +func (p *presentationIndex) inputMessages(messages []ai.Message) mediaInputMessages { + if p == nil { + return mediaInputMessages{} + } + p.mu.Lock() + defer p.mu.Unlock() + var result mediaInputMessages + seen := make(map[string]struct{}) + for i := len(messages) - 1; i >= 0; i-- { + message := messages[i] + if message.Role != "user" || len(message.Content) == 0 { + continue + } + mark, ok := p.entries[&message.Content[0]] + if !ok || mark.Audience != audiencePerson || mark.Composed == nil || mark.Composed.MessageID == "" { + continue + } + id := mark.Composed.MessageID + if _, ok := seen[id]; ok { + continue + } + if len(result.IDs) == mediaInputMessageLimit { + result.Omitted = true + break + } + seen[id] = struct{}{} + result.IDs = append(result.IDs, id) + } + for i, j := 0, len(result.IDs)-1; i < j; i, j = i+1, j-1 { + result.IDs[i], result.IDs[j] = result.IDs[j], result.IDs[i] + } + return result +} diff --git a/internal/session/media_input_test.go b/internal/session/media_input_test.go new file mode 100644 index 0000000000..597b2ee439 --- /dev/null +++ b/internal/session/media_input_test.go @@ -0,0 +1,116 @@ +package session + +import ( + "context" + "encoding/base64" + "fmt" + "path/filepath" + "reflect" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/exec/bare" +) + +func TestMediaInputsFollowActualPreparedOccurrencesAndBoundKnownIDs(t *testing.T) { + p := &presentationIndex{} + var messages []ai.Message + for i := range 80 { + m := textMessage("user", "identical words") + p.remember(m, &messagePresentation{Audience: audiencePerson, Composed: &ComposedMark{MessageID: fmt.Sprint("message-", i)}}) + messages = append(messages, m) + } + selected := append([]ai.Message(nil), messages[3:6]...) + selected = append(selected, messages[4], textMessage("user", "identical words")) // repeated occurrence + unknown legacy + got := p.inputMessages(selected) + if !reflect.DeepEqual(got.IDs, []string{"message-3", "message-5", "message-4"}) || got.Omitted { + t.Fatalf("prepared inputs guessed: %+v", got) + } + got = p.inputMessages(messages) + if len(got.IDs) != 64 || got.IDs[0] != "message-16" || got.IDs[63] != "message-79" || !got.Omitted { + t.Fatalf("unbounded/incorrect input snapshot: %+v", got) + } +} + +func TestMediaInputsOnGeneratedSourceMatchTheActualProviderRequest(t *testing.T) { + index := filepath.Join(t.TempDir(), "artifacts.jsonl") + media := &scriptedMedia{base64: base64.StdEncoding.EncodeToString(pngOfSize(t, 2, 2)), mediaType: "image/png"} + var agent *Agent + var expected []string + completer := &scriptedCompleter{steps: []step{ + func(context.Context, []ai.Message) (*ai.Response, error) { return textResponse("first answer"), nil }, + func(_ context.Context, messages []ai.Message) (*ai.Response, error) { + expected = agent.preparedMediaInputs(messages).IDs + return toolResponse("generation-call", "generate_image", `{"prompt":"a circle"}`), nil + }, + func(context.Context, []ai.Message) (*ai.Response, error) { return textResponse("done"), nil }, + }} + agent, _ = newTestAgent(t, completer, func(c *Config) { + c.ArtifactsIndex = index + c.Media = media + c.MediaModel = mediaModels(map[string]string{modalityImage: "paint/model"}) + }) + if _, err := agent.armFamily([]bare.Tool{beltTool(t, agent, "generate_image")}); err != nil { + t.Fatal(err) + } + firstEvents, err := agent.Submit(context.Background(), "keep these instructions") + if err != nil { + t.Fatal(err) + } + collect(t, firstEvents) + events, err := agent.Submit(context.Background(), "make a circle") + if err != nil { + t.Fatal(err) + } + collect(t, events) + rows := ReadArtifacts(index) + if len(expected) != 2 || len(rows) != 1 || rows[0].Media == nil || !reflect.DeepEqual(rows[0].Media.InputMessages, expected) || rows[0].Media.Tool != "generation-call" || rows[0].Media.Run == "" || rows[0].Media.Message != "" { + t.Fatalf("source did not preserve actual request: expected=%v rows=%+v", expected, rows) + } + rows[0].Media.InputMessages[0] = "changed returned copy" + // A separately read journal fact is immutable and not backed by that slice. + if again := ReadArtifacts(index); !reflect.DeepEqual(again[0].Media.InputMessages, expected) { + t.Fatal("returned provenance mutated durable source") + } +} + +func TestMediaInputSnapshotsDoNotLeakAcrossCallsOrIntoEarlyContexts(t *testing.T) { + early := context.Background() + first := mediaInputMessages{IDs: []string{"first"}} + batch := withMediaInputs(early, first) + first.IDs[0] = "mutated caller" + if got := mediaInputsFrom(batch); len(got.IDs) != 1 || got.IDs[0] != "first" { + t.Fatalf("batch alias: %+v", got) + } + if got := mediaInputsFrom(early); len(got.IDs) != 0 { + t.Fatal("early context was relabeled") + } + made := madeBy(batch, nil) + next := withMediaInputs(early, mediaInputMessages{IDs: []string{"second"}}) + _ = madeBy(next, nil) + if made.Messages.IDs[0] != "first" { + t.Fatal("late first job inherited second request") + } +} + +func BenchmarkPreparedMediaInputs(b *testing.B) { + for _, n := range []int{10000, 100000} { + b.Run(fmt.Sprint(n), func(b *testing.B) { + p := &presentationIndex{} + messages := make([]ai.Message, n) + for i := range messages { + messages[i] = textMessage("tool", "result") + } + messages[0] = textMessage("user", "request") + p.remember(messages[0], &messagePresentation{Audience: audiencePerson, Composed: &ComposedMark{MessageID: "actual-user"}}) + b.ReportAllocs() + b.ResetTimer() + for range b.N { + got := p.inputMessages(messages) + if len(got.IDs) != 1 { + b.Fatal("lost actual user") + } + } + }) + } +} diff --git a/internal/session/mediacache.go b/internal/session/mediacache.go index 1daf3deb46..4962f32a73 100644 --- a/internal/session/mediacache.go +++ b/internal/session/mediacache.go @@ -75,6 +75,7 @@ func MediaIndexScans() uint64 { return mediaIndexScans.Load() } func cloneMediaRow(row MediaRow) MediaRow { row.Fact.References = append([]string(nil), row.Fact.References...) + row.Fact.InputMessages = append([]string(nil), row.Fact.InputMessages...) if row.Fact.TextLayer != nil { layer := *row.Fact.TextLayer row.Fact.TextLayer = &layer diff --git a/internal/session/mediacache_test.go b/internal/session/mediacache_test.go index 3c2d128456..3161aaa1d6 100644 --- a/internal/session/mediacache_test.go +++ b/internal/session/mediacache_test.go @@ -97,7 +97,7 @@ func TestMediaCacheIsBoundedAndCopiesRecords(t *testing.T) { path := mediaFile(t, dir, "p.png") for i := 0; i < mediaCacheRecords+300; i++ { RecordMedia(index, Artifact{Path: path, Session: "s1", Title: "t", Kind: "image", Created: time.Now()}, - GeneratedMedia(path, []byte{byte(i)}, "m").WithReferences([]string{"a", "b"})) + GeneratedMedia(path, []byte{byte(i)}, "m").WithReferences([]string{"a", "b"}).WithMade(mediaMade{Messages: mediaInputMessages{IDs: []string{"actual-input"}}})) } listAll(t, index, "s1") mediaCache.mu.Lock() @@ -110,9 +110,10 @@ func TestMediaCacheIsBoundedAndCopiesRecords(t *testing.T) { id := listAll(t, index, "s1")[0] first, _ := FindMedia(index, "s1", id, time.Time{}) first.Fact.References[0] = "tampered" + first.Fact.InputMessages[0] = "tampered" first.Fact.Model = "tampered" again, _ := FindMedia(index, "s1", id, time.Time{}) - if again.Fact.References[0] != "a" || again.Fact.Model == "tampered" && again.Fact.References[0] != "a" { + if again.Fact.InputMessages[0] != "actual-input" || again.Fact.References[0] != "a" || again.Fact.Model == "tampered" && again.Fact.References[0] != "a" { t.Fatalf("a cached record was changed through a copy: %+v", again.Fact.References) } } diff --git a/internal/session/mediafact.go b/internal/session/mediafact.go index 6069fa4613..f74642e387 100644 --- a/internal/session/mediafact.go +++ b/internal/session/mediafact.go @@ -169,7 +169,9 @@ type MediaFact struct { Message string `json:"message,omitempty"` Tool string `json:"tool,omitempty"` // References are the exact reference_paths the generator was given. - References []string `json:"references,omitempty"` + References []string `json:"references,omitempty"` + InputMessages []string `json:"input_messages,omitempty"` + InputMessagesOmitted bool `json:"input_messages_omitted,omitempty"` // Said is the exact text given to speak. Said string `json:"said,omitempty"` // The rest is set only on evidence — a file the agent LOOKED AT (origin @@ -433,6 +435,7 @@ func (f MediaFact) WithCallUsage(usage *ai.Usage) MediaFact { // neither. type mediaMade struct { Inputs []string + Messages mediaInputMessages Run, Tool string } @@ -440,12 +443,14 @@ type mediaMade struct { // immutable execution id, the tool is the provider's id for this very call. // Either may be absent (a test, a door with no turn) and is then left unset. func madeBy(ctx context.Context, inputs []string) mediaMade { - return mediaMade{Inputs: inputs, Run: runIdentity(ctx), Tool: callIDFrom(ctx)} + return mediaMade{Inputs: inputs, Messages: mediaInputsFrom(ctx), Run: runIdentity(ctx), Tool: callIDFrom(ctx)} } // WithMade stamps the call's inputs and identity onto a fact. func (f MediaFact) WithMade(made mediaMade) MediaFact { f.Run, f.Tool = made.Run, made.Tool + f.InputMessages = append([]string(nil), made.Messages.IDs...) + f.InputMessagesOmitted = made.Messages.Omitted return f.WithReferences(made.Inputs) } From 678b4d28a82024c3749b4b0c39de888d25489f1b Mon Sep 17 00:00:00 2001 From: santoshkumarradha Date: Fri, 16 Oct 2026 03:01:14 -0400 Subject: [PATCH 166/459] feat(engine): opt-in bounded CPU and heap diagnostic profile Off unless CODEAF_DIAGNOSTIC_PROFILE_DIR is set; one hook at the top of main covers every command. Exclusive 0600 per-process files, bounded 1..60s window, invalid settings refuse with a fixed stderr sentence. Co-Authored-By: Claude Sonnet 5.5 --- cmd/codeaf/diagnostics.go | 31 ++ cmd/codeaf/main.go | 7 +- docs/README.md | 1 + .../unreleased/engine-diagnostic-profile.md | 11 + internal/diagprofile/diagprofile.go | 284 +++++++++++++++ internal/diagprofile/diagprofile_test.go | 331 ++++++++++++++++++ internal/diagprofile/open_other.go | 23 ++ internal/diagprofile/open_unix.go | 35 ++ 8 files changed, 722 insertions(+), 1 deletion(-) create mode 100644 cmd/codeaf/diagnostics.go create mode 100644 docs/changes/unreleased/engine-diagnostic-profile.md create mode 100644 internal/diagprofile/diagprofile.go create mode 100644 internal/diagprofile/diagprofile_test.go create mode 100644 internal/diagprofile/open_other.go create mode 100644 internal/diagprofile/open_unix.go diff --git a/cmd/codeaf/diagnostics.go b/cmd/codeaf/diagnostics.go new file mode 100644 index 0000000000..55d005e81a --- /dev/null +++ b/cmd/codeaf/diagnostics.go @@ -0,0 +1,31 @@ +package main + +import ( + "context" + "fmt" + "os" + + "github.com/Agent-Field/codeaf/internal/diagprofile" + "github.com/Agent-Field/codeaf/internal/env" +) + +// startDiagnostics is main's one hook for the developer-only profile +// (internal/diagprofile). It runs before anything else so the window covers the +// process's real startup, and it is a no-op unless CODEAF_DIAGNOSTIC_PROFILE_DIR +// is set. A set-but-invalid variable ends the process with status 2 and one +// fixed sentence on STDERR: stdout belongs to the JSON protocols (`engine`, +// `app`, `do --json`) and this never writes there. +// +// The returned stop waits for the files to be complete. A process that dies by +// signal, os.Exit elsewhere or a runtime crash does not run it, and leaves the +// CPU file incomplete; that is stated in docs/DIAGNOSTIC-PROFILE.md. +func startDiagnostics() (stop func()) { + session, err := diagprofile.Start(context.Background(), env.Lookup, func(line string) { + fmt.Fprintln(os.Stderr, line) + }) + if err != nil { + fmt.Fprintln(os.Stderr, "codeaf:", err) + os.Exit(2) + } + return session.Stop +} diff --git a/cmd/codeaf/main.go b/cmd/codeaf/main.go index 798ef1509a..d0a182a172 100644 --- a/cmd/codeaf/main.go +++ b/cmd/codeaf/main.go @@ -44,13 +44,18 @@ import ( ) func main() { + // THE DEVELOPER-ONLY PROFILE STARTS FIRST AND STOPS LAST, and is inert + // unless CODEAF_DIAGNOSTIC_PROFILE_DIR is set (diagnostics.go). + stopDiagnostics := startDiagnostics() home.Adopt(log.Printf) // CODEAF SCHEDULES NOTHING ON THE MACHINE. The timers the feature that // automations replaced installed are taken off this login on every start, // whoever installed them (internal/automation's legacy.go). automation.RemoveOldTimers() registerRunningCLI() - os.Exit(execute()) + code := execute() + stopDiagnostics() + os.Exit(code) } // registerRunningCLI tells the worker harness which binary a worker's `codeaf` diff --git a/docs/README.md b/docs/README.md index 594d194bdc..9dfdce47c4 100644 --- a/docs/README.md +++ b/docs/README.md @@ -12,6 +12,7 @@ One page that says where everything is. Start here. | **Know what it promises a person** | [JOURNEY.md](JOURNEY.md) — the product law: one mouth, and the journeys we hold ourselves to | | **Measure it** | [../BENCHMARKS.md](../BENCHMARKS.md) — the record of what came back, with its caveats | | **Know what it may spend** | [LIMITS.md](LIMITS.md) — every money rail, its default, what fires when it is reached, and where `0` means no limit | +| **Profile a slow run (developers)** | [DIAGNOSTIC-PROFILE.md](DIAGNOSTIC-PROFILE.md) — opt-in bounded CPU and heap capture of any command | | **Keep it fast** | [../PERF.md](../PERF.md) — the size ratchet, the allocation laws, the launch-path pins, and why none of them is a stopwatch | ## The subsystems diff --git a/docs/changes/unreleased/engine-diagnostic-profile.md b/docs/changes/unreleased/engine-diagnostic-profile.md new file mode 100644 index 0000000000..14b115743f --- /dev/null +++ b/docs/changes/unreleased/engine-diagnostic-profile.md @@ -0,0 +1,11 @@ +--- +kind: added +title: an opt-in, bounded CPU and heap profile of any codeaf process, for developers +pr: 0 +surface: [engine, docs] +invalidates: + - "There was no way to profile a codeaf command without editing code. CODEAF_DIAGNOSTIC_PROFILE_DIR (absolute, private) now writes a CPU profile of up to CODEAF_DIAGNOSTIC_PROFILE_SECONDS (1..60, default 30) from main, and CODEAF_DIAGNOSTIC_PROFILE_HEAP=1 adds a heap snapshot; with the directory unset nothing starts." + - "A set-but-invalid diagnostic variable is not ignored: the process exits 2 with one fixed sentence on stderr, never on stdout." +--- + +See docs/DIAGNOSTIC-PROFILE.md for the guarantees and the limits. diff --git a/internal/diagprofile/diagprofile.go b/internal/diagprofile/diagprofile.go new file mode 100644 index 0000000000..51d88a51a3 --- /dev/null +++ b/internal/diagprofile/diagprofile.go @@ -0,0 +1,284 @@ +// Package diagprofile is the developer-only, opt-in, bounded CPU (and +// optionally heap) profile of one codeaf process, for real performance +// investigation. +// +// IT IS OFF, EXACTLY. With CODEAF_DIAGNOSTIC_PROFILE_DIR unset or empty, +// [Start] reads the environment, returns an inert [Session], and does nothing +// else: no goroutine, no file, no directory, no profiler, no stderr. A Session +// that never started is safe to Stop any number of times. +// +// It is generic on purpose. The hook is the process's own main, so every +// command surface (chat, do, exec, run, engine, app, ...) is covered by the same +// two lines and none of them knows this package exists. Nothing here is an app +// API, a port, or a protocol field. +// +// The knobs are three environment variables, all read once, at start: +// +// CODEAF_DIAGNOSTIC_PROFILE_DIR absolute path of a private directory +// CODEAF_DIAGNOSTIC_PROFILE_SECONDS optional, whole seconds 1..60, default 30 +// CODEAF_DIAGNOSTIC_PROFILE_HEAP optional, exactly "1" to also write a heap profile +// +// A variable that is set and wrong is REFUSED ([Start] returns an *Error whose +// text is a fixed sentence): a developer who asked for a profile and silently +// got none would investigate a number that was never measured. The sentence +// never quotes the value, so a mistyped path or a pasted secret is not echoed +// into a log. +// +// The inheritance rule is documented rather than hidden: the variables are NOT +// cleared, so a codeaf process started by a codeaf process (the engine daemon a +// chat launch spawns, a worker's own `codeaf` call) profiles itself too, into +// the same directory. That is safe because every file name is unique per +// process and per file, and every file is created exclusively. Unset the +// variable for a child you do not want measured. +package diagprofile + +import ( + "context" + "crypto/rand" + "encoding/hex" + "errors" + "fmt" + "io" + "os" + "path/filepath" + "runtime/pprof" + "strconv" + "sync" + "time" +) + +const ( + // EnvDir, EnvSeconds and EnvHeap are the whole public surface. + EnvDir = "CODEAF_DIAGNOSTIC_PROFILE_DIR" + EnvSeconds = "CODEAF_DIAGNOSTIC_PROFILE_SECONDS" + EnvHeap = "CODEAF_DIAGNOSTIC_PROFILE_HEAP" + + // DefaultSeconds is the interval when EnvSeconds is unset; MinSeconds and + // MaxSeconds bound it. A profile is a measurement of a window, and an + // unbounded window is a profiler left on by accident. + DefaultSeconds = 30 + MinSeconds = 1 + MaxSeconds = 60 + + // FilePrefix and FileSuffix make the files findable: codeaf-cpu-*.pprof and + // codeaf-heap-*.pprof. + FilePrefix = "codeaf-" + FileSuffix = ".pprof" + + // createAttempts bounds the retry when a freshly drawn name already exists. + // With a 64-bit random component one retry is already astronomically rare. + createAttempts = 4 +) + +// Error is a refusal. Its text is fixed per cause and never contains a value +// the environment supplied. +type Error struct{ Reason string } + +func (e *Error) Error() string { return "diagnostic profile refused: " + e.Reason } + +func refuse(reason string) error { return &Error{Reason: reason} } + +// Lookup is how the environment is read. The product passes the codeaf +// environment door; tests pass a map. +type Lookup func(name string) (value string, set bool) + +// Config is the parsed environment. The zero value is "off". +type Config struct { + Dir string + Duration time.Duration + Heap bool +} + +// Enabled reports whether any profiling was asked for. +func (c Config) Enabled() bool { return c.Dir != "" } + +// ParseConfig reads and validates the three variables without touching the +// filesystem. Unset or empty EnvDir is off whatever the others say. +func ParseConfig(lookup Lookup) (Config, error) { + dir, _ := lookup(EnvDir) + if dir == "" { + return Config{}, nil + } + if !filepath.IsAbs(dir) || filepath.Clean(dir) != dir { + return Config{}, refuse("the directory must be an absolute, clean path") + } + cfg := Config{Dir: dir, Duration: DefaultSeconds * time.Second} + if raw, _ := lookup(EnvSeconds); raw != "" { + n, ok := parseSeconds(raw) + if !ok { + return Config{}, refuse(fmt.Sprintf("the duration must be a whole number of seconds from %d to %d", MinSeconds, MaxSeconds)) + } + cfg.Duration = time.Duration(n) * time.Second + } + switch raw, _ := lookup(EnvHeap); raw { + case "", "0": + case "1": + cfg.Heap = true + default: + return Config{}, refuse(`the heap switch must be "1" or unset`) + } + return cfg, nil +} + +// parseSeconds accepts plain ASCII digits only: no sign, no space, no unit. +func parseSeconds(raw string) (int, bool) { + if len(raw) > 2 { + return 0, false + } + for _, r := range raw { + if r < '0' || r > '9' { + return 0, false + } + } + n, err := strconv.Atoi(raw) + if err != nil || n < MinSeconds || n > MaxSeconds { + return 0, false + } + return n, true +} + +// Session is one process's profile. Stop is idempotent and safe on a nil or +// inert Session. +type Session struct { + cfg Config + warn func(string) + cancel context.CancelFunc + cpu *os.File + done chan struct{} // closed when the finishing goroutine has returned + stopOne sync.Once +} + +// Start validates the environment, prepares the directory, creates the CPU +// profile file exclusively, starts the CPU profiler and ONE goroutine that +// owns the interval and the finishing work. warn receives fixed sentences about +// failures after start (it is never given a value from the environment, and it +// must not write to stdout); it may be nil. +// +// The returned Session is non-nil on success even when profiling is off. On +// error nothing was started and nothing remains on disk but, at most, a +// directory Start created itself. +// +// Cancelling ctx finishes the profile early exactly as Stop does. +func Start(ctx context.Context, lookup Lookup, warn func(string)) (*Session, error) { + cfg, err := ParseConfig(lookup) + if err != nil { + return nil, err + } + return start(ctx, cfg, warn) +} + +// start is Start after parsing, so a test can pick an interval shorter than +// the one second the environment allows. +func start(ctx context.Context, cfg Config, warn func(string)) (*Session, error) { + if !cfg.Enabled() { + return &Session{}, nil + } + if warn == nil { + warn = func(string) {} + } + if err := preparePrivateDir(cfg.Dir); err != nil { + return nil, err + } + nonce, err := newNonce() + if err != nil { + return nil, refuse("a unique file name could not be drawn") + } + cpu, _, err := createExclusive(cfg.Dir, "cpu", nonce) + if err != nil { + return nil, err + } + if err := pprof.StartCPUProfile(cpu); err != nil { + cpu.Close() + removeOwn(cpu) + return nil, refuse("a CPU profile is already running in this process") + } + ctx, cancel := context.WithCancel(ctx) + s := &Session{cfg: cfg, warn: warn, cancel: cancel, cpu: cpu, done: make(chan struct{})} + go s.run(ctx, nonce) + return s, nil +} + +// run is the single goroutine. It waits for the interval or a cancellation, +// then finishes once. The expensive CPU stop therefore happens here, never on +// the caller's goroutine unless the caller chose to wait in Stop. +func (s *Session) run(ctx context.Context, nonce string) { + defer close(s.done) + timer := time.NewTimer(s.cfg.Duration) + defer timer.Stop() + select { + case <-timer.C: + case <-ctx.Done(): + } + s.finish(nonce) +} + +func (s *Session) finish(nonce string) { + pprof.StopCPUProfile() + if err := s.cpu.Close(); err != nil { + s.warn("diagnostic profile: the CPU profile could not be closed") + } + if !s.cfg.Heap { + return + } + // A heap SNAPSHOT: the runtime's already-sampled allocation record as of + // the last completed collection. runtime.GC is deliberately not called; + // forcing a collection would distort the very behaviour being measured. + heap, _, err := createExclusive(s.cfg.Dir, "heap", nonce) + if err != nil { + s.warn("diagnostic profile: the heap profile file could not be created") + return + } + if err := pprof.Lookup("heap").WriteTo(heap, 0); err != nil { + s.warn("diagnostic profile: the heap profile could not be written") + } + if err := heap.Close(); err != nil { + s.warn("diagnostic profile: the heap profile could not be closed") + } +} + +// Stop ends profiling early (or collects it if the interval already ran out) +// and waits for the files to be complete and closed. It is idempotent and +// concurrency-safe. Call it on the way out of the process, before os.Exit. +func (s *Session) Stop() { + if s == nil || s.done == nil { + return + } + s.stopOne.Do(s.cancel) + <-s.done +} + +// Active reports whether this Session started a profiler. +func (s *Session) Active() bool { return s != nil && s.done != nil } + +func newNonce() (string, error) { + var raw [8]byte + if _, err := io.ReadFull(rand.Reader, raw[:]); err != nil { + return "", err + } + return hex.EncodeToString(raw[:]), nil +} + +// createExclusive creates codeaf--