diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 407ec52157..54188ead14 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -36,12 +36,14 @@ on: - dev - app/v3 - santos/dev2 + - workstreams/main # Also on the push, because a merge of two individually-green branches can # still be red, and the merge is exactly the moment nobody is watching. push: branches: - dev - santos/dev2 + - workstreams/main workflow_dispatch: permissions: diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index b13ed7d377..e070380edf 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -203,6 +203,7 @@ func runApp(args []string) error { // ONE SUPERVISOR FOR EVERY CONVERSATION THE APP BUILDS (appservices.go). services := openAppServices() appServiceDoor.Store(services) + appClientToolsBackend.Store(backend) 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 @@ -561,6 +562,7 @@ func appConnOf(client *remote.Client, release func(), assembled bool) *appConn { // appBackend is the [appwire.Engine] over this process's conversations. type appBackend struct { + clientTools appClientTools libraryWatch sync.Once libraryInboxMu sync.Mutex pluginPauses map[string]appPluginPause @@ -763,6 +765,7 @@ type appBackend struct { collections *workspace.Store collectionsClosed bool collectionsPath string + collectionFactory collectionFactory // services is the app's one supervisor of kept-running processes // (appservices.go); nil is the capability absent. services *appServices @@ -1276,10 +1279,12 @@ func appRowOnDisk(id string) (session.SessionRow, bool) { // 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) == "" { + if err != nil || meta.ID != id { continue } - return session.SessionRow{ID: id, AgentDID: meta.AgentDID(), Dir: dir, Transcript: transcript, Title: meta.Title, Workspace: meta.Workspace, Created: meta.Created}, true + return session.SessionRow{ID: id, AgentDID: meta.AgentDID(), Dir: dir, Transcript: transcript, + Title: meta.Title, Workspace: meta.Workspace, Owned: meta.Owned, Model: meta.Model, + Archived: meta.Archived, Spend: meta.SpentUSD, At: meta.Created, Created: meta.Created}, true } return session.SessionRow{}, false } @@ -1934,6 +1939,9 @@ func (b *appBackend) hold(a *appAgent, conn *appConn, workspace string) appwire. if door, ok := conn.Conv.(interface{ ModelIdentity() session.ModelIdentity }); ok { state := door.ModelIdentity() modelIdentity = &state + if state.Selected != "" { + model = state.Selected + } } effort := conn.Conv.ReasoningFor(model) if dial, ok := conn.Conv.(interface{ ConversationEffort() string }); ok { @@ -1987,6 +1995,7 @@ func (b *appBackend) hold(a *appAgent, conn *appConn, workspace string) appwire. a.crewHeld = a.crewHeld || crewHeld view = a.view }) + b.holdClientTools(a, live) startTitles() b.flushSync(a) b.locked(func() { view = a.view }) @@ -2918,7 +2927,7 @@ func (b *appBackend) pump(a *appAgent, id string, live *appLive, turn uint64, n b.stepMoved(a, live, title) row := b.callBegan(a, id, turn, event, title) row.Summary = event.Hint - live.box.Event(appwire.EventTurnTool, row) + live.sayTool(row) case session.EventToolOutput: b.callOutput(a, id, live, event) case session.EventToolFinished: @@ -2937,7 +2946,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, b.callEnded(a, id, turn, event, title, summary, appDetail(summary), succeeded)) + live.sayTool(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. @@ -4572,6 +4581,8 @@ func (b *appBackend) Close() error { if b.leaving != nil { b.leaving() } + b.clientTools.disconnect() + appClientToolsBackend.CompareAndSwap(b, nil) already := false var crews *appCrewRun var agents []*appAgent diff --git a/cmd/codeaf/app_model_for_now_test.go b/cmd/codeaf/app_model_for_now_test.go new file mode 100644 index 0000000000..f097eaad0a --- /dev/null +++ b/cmd/codeaf/app_model_for_now_test.go @@ -0,0 +1,136 @@ +package main + +import ( + "bytes" + "context" + "os" + "path/filepath" + "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/modelsource/sourcestub" + "github.com/Agent-Field/codeaf/internal/session" +) + +// C1, C2, C3: the app reopens the saved choice and title route, leaving settings +// and setup state byte-identical across an automatic run-only selection. +func TestAppReopensConfiguredModelAfterRunOnlyReplacement(t *testing.T) { + t.Setenv(home.EnvVar, t.TempDir()) + profile := t.TempDir() + t.Setenv(config.ProfileDirEnv, profile) + dir, workspace := t.TempDir(), t.TempDir() + path := filepath.Join(dir, "transcript.jsonl") + provider := sourcestub.New("vendor/configured", "available/model", "vendor/backup") + defer provider.Close() + settings := []byte(`{ + "model.talk": "vendor/configured", + "models.tiers.low": "", + "models.tiers.reflex": "vendor/reflex", + "models.tiers.worker": "vendor/worker", + "models.tiers.high": "vendor/checker", + "models.tiers.mastermind": "vendor/planner", + "models.roles": "planner:vendor/planner", + "task.model": "vendor/task", + "models.tasks.mode": "auto", + "models.tasks.one": "vendor/one" +} +`) + if err := os.WriteFile(config.BudgetConfigPath(profile), settings, 0600); err != nil { + t.Fatal(err) + } + rolesSource, err := v3RolesSource(workspace, profile) + if err != nil { + t.Fatal(err) + } + cfg := session.Config{Workspace: workspace, Place: session.Place{Dir: dir, Workspace: workspace}, SessionFile: path, + Model: "vendor/configured", APIKey: "fixture", BaseURL: provider.URL(), ProfileDir: profile, System: "SYSTEM", + RolesSource: rolesSource, + ConversationModels: &session.ConversationModels{Main: "vendor/configured", Fallback: "vendor/backup"}, + } + a, err := session.New(cfg) + if err != nil { + t.Fatal(err) + } + defer a.Close() + var reopened *session.Agent + b := newFakeBackend(newAppRecorder(), func(ask appOpenAsk) (*appConn, error) { + meta, err := session.LoadMeta(dir) + if err != nil { + return nil, err + } + fresh := cfg + fresh.Model = meta.Model + reopened, err = session.New(fresh) + if err != nil { + return nil, err + } + return &appConn{Conv: reopened, Workspace: workspace, Transcript: path, Release: func() { _ = reopened.Close() }}, nil + }) + defer b.Close() + b.state = &appState{path: filepath.Join(t.TempDir(), appStateName)} + if err := b.state.write(func(f *appStateFile) error { + f.Setup = &appwire.Setup{DefaultModel: cfg.Model} + return nil + }); err != nil { + t.Fatal(err) + } + stateBefore, err := os.ReadFile(b.state.where()) + if err != nil { + t.Fatal(err) + } + if err := a.UseModelForNow("available/model"); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + events, err := a.Submit(ctx, "respond on the available model") + if err != nil { + t.Fatal(err) + } + for event := range events { + if event.Kind == session.EventError { + t.Fatalf("scripted turn failed: %v", event.Err) + } + } + if err := a.Close(); err != nil { + t.Fatal(err) + } + a.SettleWrites() + profileAfter, err := os.ReadFile(config.BudgetConfigPath(profile)) + if err != nil || !bytes.Equal(settings, profileAfter) { + t.Fatalf("fallback wrote profile: %s, %v", profileAfter, err) + } + stateAfter, err := os.ReadFile(b.state.where()) + if err != nil || !bytes.Equal(stateBefore, stateAfter) { + t.Fatalf("fallback wrote app setup: %s, %v", stateAfter, err) + } + row, ok := session.ReadRows([]string{path})[filepath.Clean(path)] + if !ok { + t.Fatal("saved conversation is missing from app inventory") + } + listed := appRowView(row) + if listed.Model != cfg.Model || listed.ModelIdentity == nil || listed.ModelIdentity.Selected != cfg.Model { + t.Fatalf("agents.list row=%+v", listed) + } + b.remember(row) + opened, err := b.OpenAgent(listed.ID) + if err != nil || opened.Model != cfg.Model { + t.Fatalf("agents.open=%+v, %v", opened, err) + } + routes, _, err := b.errandRoutes(listed.ID) + if err != nil { + t.Fatal(err) + } + for _, route := range routes { + if route.Role == "title" { + if route.Model != cfg.Model { + t.Fatalf("agents.errand.models title=%+v", route) + } + return + } + } + t.Fatal("agents.errand.models omitted title") +} diff --git a/cmd/codeaf/appblank.go b/cmd/codeaf/appblank.go index 7dd6e31fb9..90ab9cbff9 100644 --- a/cmd/codeaf/appblank.go +++ b/cmd/codeaf/appblank.go @@ -139,7 +139,7 @@ func (b *appBackend) reapBlank(a *appAgent) bool { } gone := false _ = teamstore.InspectConversation(b.crewProfile(), transcript, func(referenced bool) error { - if referenced || session.InUse(transcript) || !v3EmptyUnassignedSession(dir) { + if referenced || session.InUse(transcript) || !v3EmptyUnassignedSession(dir) || collectionKeepsConversation(dir) { return nil } gone = os.RemoveAll(dir) == nil diff --git a/cmd/codeaf/appclienttools.go b/cmd/codeaf/appclienttools.go new file mode 100644 index 0000000000..9695a186c0 --- /dev/null +++ b/cmd/codeaf/appclienttools.go @@ -0,0 +1,502 @@ +package main + +import ( + "bytes" + "context" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "sort" + "sync" + "sync/atomic" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/guard" + "github.com/Agent-Field/codeaf/internal/session" +) + +const clientToolSettledTTL = 10 * time.Minute +const clientToolSettledMax = 1024 + +var appClientToolsBackend atomic.Pointer[appBackend] +var clientToolFreshID atomic.Uint64 + +type clientToolAgentSets struct { + version uint64 + sets []session.ClientToolSet + builtins map[string]bool +} + +type clientToolPending struct { + ctx context.Context + done chan struct{} + call session.ClientToolCall + answerBy time.Time + answer session.ClientToolAnswer + event *appwire.AgentsToolCall +} + +type clientToolSettled struct { + answer session.ClientToolAnswer + at time.Time +} + +// The registry belongs to one stdio connection, including its replay memory. +type appClientTools struct { + mu sync.Mutex + agents map[string]clientToolAgentSets + pending map[string]*clientToolPending + settled map[string]clientToolSettled + order []string + closed bool + senderWake chan struct{} + senderDone chan struct{} + publishing map[string]chan struct{} +} + +type appClientToolDoor struct { + backend *appBackend + agent string +} + +var _ appwire.ClientTooler = (*appBackend)(nil) +var _ session.ClientToolDoor = (*appClientToolDoor)(nil) + +func (b *appBackend) clientToolEventCategory(a *appAgent, event session.Event) session.ActionCategory { + if event.Category != "" { + return event.Category + } + var id string + var conv appConversation + b.locked(func() { + id = a.view.ID + if a.live != nil { + conv = a.live.conn.Conv + } + }) + if local, ok := conv.(interface { + ActionCategoryForTool(string) session.ActionCategory + }); ok { + return local.ActionCategoryForTool(event.Tool) + } + r := &b.clientTools + r.mu.Lock() + defer r.mu.Unlock() + if r.agents[id].builtins[event.Tool] { + return session.ActionCategoryForTool(event.Tool) + } + for _, set := range r.agents[id].sets { + for _, tool := range set.Tools { + if tool.Name == event.Tool { + return tool.Category + } + } + } + return session.ActionCategoryForTool(event.Tool) +} + +func appClientToolsDoor(sessionFile string) session.ClientToolDoor { + b := appClientToolsBackend.Load() + if b == nil { + return nil + } + return &appClientToolDoor{backend: b, agent: appAgentID(sessionFile)} +} + +// Root builders share launch settings, but each opening needs its own conversation door. +func appClientToolsOpener(open func(session.Config) (*session.Agent, error)) func(session.Config) (*session.Agent, error) { + return func(cfg session.Config) (*session.Agent, error) { + cfg.ClientTools = appClientToolsDoor(cfg.SessionFile) + return open(cfg) + } +} + +func (b *appBackend) SetClientTools(p appwire.ClientToolsSetParams) (appwire.ClientToolsSetResult, error) { + result := appwire.ClientToolsSetResult{Agent: p.Agent, Set: p.Set, Tools: []string{}} + if err := p.Validate(); err != nil { + return result, err + } + a, err := b.find(p.Agent) + if err != nil { + return result, err + } + a.gate.Lock() + defer a.gate.Unlock() + if err := b.clientToolScope(a); err != nil { + return result, err + } + if agent := b.clientToolRemoteAgent(a); agent != nil { + return b.setRemoteClientTools(a, agent, p) + } + held := b.clientToolBuiltinNames(a) + r := &b.clientTools + r.mu.Lock() + defer r.mu.Unlock() + if r.closed { + return result, appwire.Errorf(appwire.CodeClosed, "the app connection is closed") + } + state := r.agents[p.Agent] + for name := range state.builtins { + held[name] = true + } + if err := clientToolReplacementValid(p, state.sets, held); err != nil { + return result, err + } + sets := make([]session.ClientToolSet, 0, len(state.sets)+1) + for _, set := range state.sets { + if set.ID != p.Set { + sets = append(sets, set) + } + } + if len(p.Tools) > 0 { + set := clientToolSnapshot(p) + sets = append(sets, set) + for _, tool := range set.Tools { + result.Tools = append(result.Tools, tool.Name) + } + } + sort.Slice(sets, func(i, j int) bool { return sets[i].ID < sets[j].ID }) + if r.agents == nil { + r.agents = make(map[string]clientToolAgentSets) + } + r.agents[p.Agent] = clientToolAgentSets{version: state.version + 1, sets: sets, builtins: state.builtins} + return result, nil +} + +func (b *appBackend) clientToolScope(a *appAgent) error { + if agent := b.clientToolRemoteAgent(a); agent != nil && !agent.SupportsClientTools() { + return appwire.Errorf(appwire.CodeBadScope, "client tools need a conversation this engine runs") + } + return nil +} + +// Local app conversations also use a transport proxy. Their session reports its +// built-in inventory here, so validation still sees the actual engine belt. +func (d *appClientToolDoor) ClientToolBuiltins(names []string) { + r := &d.backend.clientTools + r.mu.Lock() + defer r.mu.Unlock() + if r.closed { + return + } + state := r.agents[d.agent] + state.builtins = make(map[string]bool, len(names)) + for _, name := range names { + state.builtins[name] = true + } + if r.agents == nil { + r.agents = make(map[string]clientToolAgentSets) + } + r.agents[d.agent] = state +} + +func (b *appBackend) clientToolBuiltinNames(a *appAgent) map[string]bool { + var conv appConversation + b.locked(func() { + if a.live != nil { + conv = a.live.conn.Conv + } + }) + held := make(map[string]bool) + if live, ok := conv.(interface{ ClientToolBuiltinNames() []string }); ok { + for _, name := range live.ClientToolBuiltinNames() { + held[name] = true + } + } + return held +} + +func clientToolReplacementValid(p appwire.ClientToolsSetParams, sets []session.ClientToolSet, builtins map[string]bool) error { + names := make(map[string]bool) + count, setCount := len(p.Tools), 0 + for _, set := range sets { + if set.ID == p.Set { + continue + } + setCount++ + count += len(set.Tools) + for _, tool := range set.Tools { + names[tool.Name] = true + } + } + if len(p.Tools) > 0 { + setCount++ + } + if setCount > session.ClientToolMaxSets || count > session.ClientToolMaxPerAgent { + return appwire.Errorf(appwire.CodeBadParams, "an agent allows at most %d sets and %d client tools", session.ClientToolMaxSets, session.ClientToolMaxPerAgent) + } + for _, tool := range p.Tools { + if builtins[tool.Name] || session.ActionCategoryForTool(tool.Name) != session.ActionWork { + return appwire.Errorf(appwire.CodeBadParams, "%s is a built-in engine tool", tool.Name) + } + if names[tool.Name] { + return appwire.Errorf(appwire.CodeBadParams, "%s is already in another set", tool.Name) + } + } + return nil +} + +func clientToolSnapshot(p appwire.ClientToolsSetParams) session.ClientToolSet { + set := session.ClientToolSet{ID: p.Set, Instructions: p.Instructions, DeadlineMS: p.DeadlineMS, + Tools: append([]session.ClientTool(nil), p.Tools...)} + if set.DeadlineMS == 0 { + set.DeadlineMS = session.ClientToolDefaultDeadlineMS + } + for i := range set.Tools { + set.Tools[i].InputSchema = bytes.Clone(set.Tools[i].InputSchema) + } + return set +} + +func (d *appClientToolDoor) ClientToolSets() (uint64, []session.ClientToolSet) { + r := &d.backend.clientTools + r.mu.Lock() + defer r.mu.Unlock() + state := r.agents[d.agent] + return state.version, state.sets +} + +func clientToolCallID(agent, provider string) string { + if provider == "" { + return fmt.Sprintf("ct_fresh_%x", clientToolFreshID.Add(1)) + } + sum := sha256.Sum256([]byte(agent + "\x00" + provider)) + return "ct_" + hex.EncodeToString(sum[:]) +} + +func (d *appClientToolDoor) CallClientTool(ctx context.Context, call session.ClientToolCall) (session.ClientToolAnswer, error) { + if err := ctx.Err(); err != nil { + return clientToolContextEnded(ctx, call.Name), nil + } + var object map[string]json.RawMessage + if json.Unmarshal(call.Args, &object) != nil || object == nil { + return session.ClientToolAnswer{Text: "Invalid arguments: expected a JSON object", IsError: true}, nil + } + id := clientToolCallID(d.agent, call.TurnCall) + r := &d.backend.clientTools + pending, answer, emit := r.begin(ctx, d.agent, id, call) + if pending == nil { + return answer, nil + } + if emit { + var turn uint64 + d.backend.locked(func() { + if a := d.backend.agents[d.agent]; a != nil && a.live != nil { + turn = a.live.turn + } + }) + r.queueEmission(d.backend.emit, pending, appwire.AgentsToolCall{Agent: d.agent, Turn: turn, + Call: id, TurnCall: call.TurnCall, SourceMessageID: call.SourceMessage, Set: call.Set, Name: call.Name, Args: bytes.Clone(call.Args), DeadlineMS: pending.call.DeadlineMS}) + } + return r.wait(ctx, id, pending), nil +} + +// One sender owns all writer waits. Queued events live only in pending calls, +// so calls that end behind a blocked writer leave no stale queue to grow. +func (r *appClientTools) queueEmission(emit appwire.Emitter, pending *clientToolPending, event appwire.AgentsToolCall) { + r.mu.Lock() + defer r.mu.Unlock() + if r.closed || r.pending[event.Call] != pending { + return + } + pending.event = &event + if r.senderWake == nil { + r.senderWake = make(chan struct{}, 1) + r.senderDone = make(chan struct{}) + guard.Go("app/client-tools", func() { r.sendEvents(emit) }) + } + select { + case r.senderWake <- struct{}{}: + default: + } +} + +func (r *appClientTools) sendEvents(emit appwire.Emitter) { + defer close(r.senderDone) + for range r.senderWake { + for { + event := r.nextEmission() + if event == nil { + break + } + emit.Emit(appwire.EventAgentsToolCall, *event) + } + } +} + +func (r *appClientTools) nextEmission() *appwire.AgentsToolCall { + r.mu.Lock() + defer r.mu.Unlock() + if r.closed { + return nil + } + for _, pending := range r.pending { + event := pending.event + pending.event = nil + if event != nil && pending.ctx.Err() == nil && time.Now().Before(pending.answerBy) { + return event + } + } + return nil +} + +// begin takes the registry lock for [appClientTools.beginLocked] alone, so a +// panic inside it cannot leave the lock held. +func (r *appClientTools) begin(ctx context.Context, agent, id string, call session.ClientToolCall) (*clientToolPending, session.ClientToolAnswer, bool) { + r.mu.Lock() + defer r.mu.Unlock() + return r.beginLocked(ctx, agent, id, call) +} + +// Register before emitting, so even a synchronous client answer finds its waiter. +func (r *appClientTools) beginLocked(ctx context.Context, agent, id string, call session.ClientToolCall) (*clientToolPending, session.ClientToolAnswer, bool) { + r.pruneLocked(time.Now()) + if settled, ok := r.settled[id]; ok { + return nil, settled.answer, false + } + if pending := r.pending[id]; pending != nil { + return pending, session.ClientToolAnswer{}, false + } + waitMS, attached := r.attachedLocked(agent, call.Set, call.Name) + if r.closed || !attached { + return nil, session.ClientToolUnavailable(call.Name), false + } + if call.DeadlineMS >= session.ClientToolMinDeadlineMS && call.DeadlineMS <= session.ClientToolMaxDeadlineMS { + waitMS = call.DeadlineMS + } + call.DeadlineMS = waitMS + pending := &clientToolPending{ctx: ctx, done: make(chan struct{}), call: call, answerBy: time.Now().Add(time.Duration(waitMS) * time.Millisecond)} + if r.pending == nil { + r.pending = make(map[string]*clientToolPending) + } + r.pending[id] = pending + return pending, session.ClientToolAnswer{}, true +} + +func (r *appClientTools) attachedLocked(agent, setID, name string) (int, bool) { + for _, set := range r.agents[agent].sets { + if set.ID != setID { + continue + } + for _, tool := range set.Tools { + if tool.Name == name { + return set.DeadlineMS, true + } + } + } + return 0, false +} + +func (r *appClientTools) wait(ctx context.Context, id string, pending *clientToolPending) session.ClientToolAnswer { + timer := time.NewTimer(time.Until(pending.answerBy)) + defer timer.Stop() + var failed session.ClientToolAnswer + select { + case <-pending.done: + return pending.answer + case <-timer.C: + failed = clientToolTimeout(pending.call) + case <-ctx.Done(): + failed = clientToolContextEnded(ctx, pending.call.Name) + } + r.mu.Lock() + defer r.mu.Unlock() + r.settleLocked(id, pending, failed) + return pending.answer +} + +func clientToolTimeout(call session.ClientToolCall) session.ClientToolAnswer { + return session.ClientToolAnswer{Text: fmt.Sprintf("client_timeout: the app that provides %s did not answer within %gs; the outcome is unknown, so check before trying again.", call.Name, float64(call.DeadlineMS)/1000), IsError: true} +} + +func clientToolTurnEnded(name string) session.ClientToolAnswer { + return session.ClientToolAnswer{Text: fmt.Sprintf("the turn ended before the app answered %s; the outcome is unknown, so check before trying again.", name), IsError: true} +} + +func (r *appClientTools) settleLocked(id string, pending *clientToolPending, answer session.ClientToolAnswer) bool { + if r.pending[id] != pending { + return false + } + delete(r.pending, id) + pending.answer = answer + if r.settled == nil { + r.settled = make(map[string]clientToolSettled) + } + r.pruneLocked(time.Now()) + if len(r.order) == clientToolSettledMax { + delete(r.settled, r.order[0]) + r.order = r.order[1:] + } + r.settled[id] = clientToolSettled{answer: answer, at: time.Now()} + r.order = append(r.order, id) + close(pending.done) + return true +} + +func (r *appClientTools) pruneLocked(now time.Time) { + for len(r.order) > 0 { + id := r.order[0] + if now.Sub(r.settled[id].at) < clientToolSettledTTL { + break + } + delete(r.settled, id) + r.order = r.order[1:] + } +} + +func (b *appBackend) ClientToolResult(p appwire.ClientToolResultParams) (appwire.ClientToolResultAck, error) { + if err := p.Validate(); err != nil { + return appwire.ClientToolResultAck{}, err + } + var result string + if p.Error == nil { + var compact bytes.Buffer + _ = json.Compact(&compact, p.Result) + result = session.BoundClientToolOutput(compact.String()) + } + r := &b.clientTools + r.mu.Lock() + defer r.mu.Unlock() + pending := r.pending[p.Call] + if pending == nil { + return appwire.ClientToolResultAck{}, nil + } + if pending.ctx.Err() != nil { + r.settleLocked(p.Call, pending, clientToolContextEnded(pending.ctx, pending.call.Name)) + return appwire.ClientToolResultAck{}, nil + } + if !time.Now().Before(pending.answerBy) { + r.settleLocked(p.Call, pending, clientToolTimeout(pending.call)) + return appwire.ClientToolResultAck{}, nil + } + answer := session.ClientToolAnswer{} + if p.Error != nil { + answer.Text = fmt.Sprintf("%s could not do %s: %s", pending.call.Set, pending.call.Name, *p.Error) + answer.IsError = true + } else { + answer.Text = result + } + return appwire.ClientToolResultAck{Accepted: r.settleLocked(p.Call, pending, answer)}, nil +} + +// Disconnect precedes conversation shutdown, so pending calls receive the owner-loss answer. +func (r *appClientTools) disconnect() { + r.mu.Lock() + defer r.mu.Unlock() + if r.closed { + return + } + r.closed = true + if r.senderWake != nil { + close(r.senderWake) + } + for agent, state := range r.agents { + r.agents[agent] = clientToolAgentSets{version: state.version + 1} + } + for id, pending := range r.pending { + answer := session.ClientToolAnswer{Text: fmt.Sprintf("client_unavailable: the app that provides %s disconnected before answering; the outcome is unknown, so check before trying again.", pending.call.Name), IsError: true} + r.settleLocked(id, pending, answer) + } +} diff --git a/cmd/codeaf/appclienttools_remote.go b/cmd/codeaf/appclienttools_remote.go new file mode 100644 index 0000000000..da45257ccd --- /dev/null +++ b/cmd/codeaf/appclienttools_remote.go @@ -0,0 +1,184 @@ +package main + +import ( + "context" + "errors" + "fmt" + "log" + "sort" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/remote" + "github.com/Agent-Field/codeaf/internal/session" +) + +const clientToolRemoteWait = 10 * time.Second + +// ClientToolsRemote marks the app backend's forwarding implementation for hello. +func (b *appBackend) ClientToolsRemote() bool { return true } + +func (b *appBackend) clientToolRemoteAgent(a *appAgent) *remote.Agent { + var agent *remote.Agent + b.locked(func() { + if a.view.Location != nil && !a.view.Location.Here { + return + } + if a.live != nil && !a.live.conn.Assembled && !a.live.conn.Lent { + agent, _ = a.live.conn.Conv.(*remote.Agent) + } + }) + return agent +} + +// The agent gate serializes snapshots with holds; the registry lock never spans a call. +func (b *appBackend) setRemoteClientTools(a *appAgent, agent *remote.Agent, p appwire.ClientToolsSetParams) (appwire.ClientToolsSetResult, error) { + result := appwire.ClientToolsSetResult{Agent: p.Agent, Set: p.Set, Tools: []string{}} + r := &b.clientTools + state, err := r.prepareRemoteReplacement(p, b.clientToolBuiltinNames(a)) + if err != nil { + return result, err + } + defer r.finishPublication(p.Agent) + for _, tool := range p.Tools { + result.Tools = append(result.Tools, tool.Name) + } + ctx, cancel := context.WithTimeout(context.Background(), clientToolRemoteWait) + defer cancel() + if _, err := agent.SetClientTools(ctx, state.sets); err != nil { + var refusal *remote.ClientToolsRefusal + if errors.As(err, &refusal) { + return result, appwire.Errorf(appwire.CodeBadParams, "%s", refusal.Reason) + } + if errors.Is(err, remote.ErrClientToolsUnsupported) { + return result, appwire.Errorf(appwire.CodeBadScope, "client tools need a conversation this engine runs") + } + return result, appwire.Errorf(appwire.CodeUnreachable, "%s", err) + } + return result, r.commitRemoteReplacement(p.Agent, state) +} + +func (r *appClientTools) prepareRemoteReplacement(p appwire.ClientToolsSetParams, held map[string]bool) (clientToolAgentSets, error) { + r.mu.Lock() + defer r.mu.Unlock() + if r.closed { + return clientToolAgentSets{}, appwire.Errorf(appwire.CodeClosed, "the app connection is closed") + } + state := r.agents[p.Agent] + for name := range state.builtins { + held[name] = true + } + if err := clientToolReplacementValid(p, state.sets, held); err != nil { + return clientToolAgentSets{}, err + } + sets := make([]session.ClientToolSet, 0, len(state.sets)+1) + for _, set := range state.sets { + if set.ID != p.Set { + sets = append(sets, set) + } + } + if len(p.Tools) > 0 { + sets = append(sets, clientToolSnapshot(p)) + } + sort.Slice(sets, func(i, j int) bool { return sets[i].ID < sets[j].ID }) + if r.publishing == nil { + r.publishing = make(map[string]chan struct{}) + } + r.publishing[p.Agent] = make(chan struct{}) + return clientToolAgentSets{version: state.version + 1, sets: sets}, nil +} + +func (r *appClientTools) commitRemoteReplacement(agent string, state clientToolAgentSets) error { + r.mu.Lock() + defer r.mu.Unlock() + if r.closed { + return appwire.Errorf(appwire.CodeClosed, "the app connection is closed") + } + if r.agents == nil { + r.agents = make(map[string]clientToolAgentSets) + } + state.builtins = r.agents[agent].builtins + r.agents[agent] = state + return nil +} + +func (r *appClientTools) finishPublication(agent string) { + r.mu.Lock() + defer r.mu.Unlock() + close(r.publishing[agent]) + delete(r.publishing, agent) +} + +func (r *appClientTools) publication(agent string) <-chan struct{} { + r.mu.Lock() + defer r.mu.Unlock() + return r.publishing[agent] +} + +func (b *appBackend) callRemoteClientTool(ctx context.Context, door *appClientToolDoor, call session.ClientToolCall) (session.ClientToolAnswer, error) { + ctx, cancel := context.WithTimeout(ctx, time.Duration(call.DeadlineMS)*time.Millisecond) + defer cancel() + // Acceptance can arrive before the setter commits the matching app registry. + // Waiting here must still end when the call loses its link or wait. + for { + published := b.clientTools.publication(door.agent) + if published == nil { + break + } + select { + case <-published: + case <-ctx.Done(): + if errors.Is(ctx.Err(), context.DeadlineExceeded) { + return clientToolTimeout(call), nil + } + return clientToolContextEnded(ctx, call.Name), nil + } + } + return door.CallClientTool(ctx, call) +} + +func (b *appBackend) holdClientTools(a *appAgent, live *appLive) { + if live.conn.Assembled || live.conn.Lent { + return + } + agent, ok := live.conn.Conv.(*remote.Agent) + if !ok || !agent.SupportsClientTools() { + return + } + door := &appClientToolDoor{backend: b, agent: a.view.ID} + agent.SetClientToolHandler(func(ctx context.Context, call session.ClientToolCall) (session.ClientToolAnswer, error) { + return b.callRemoteClientTool(ctx, door, call) + }) + release := live.conn.Release + live.conn.Release = func() { + agent.SetClientToolHandler(nil) + if release != nil { + release() + } + } + _, sets := door.ClientToolSets() + ctx, cancel := context.WithTimeout(context.Background(), clientToolRemoteWait) + defer cancel() + if _, err := agent.SetClientTools(ctx, sets); err != nil { + log.Printf("app/client-tools: could not attach tools for %s: %v", door.agent, err) + } +} + +func clientToolContextEnded(ctx context.Context, name string) session.ClientToolAnswer { + if errors.Is(context.Cause(ctx), remote.ErrClientToolDisconnected) { + return session.ClientToolAnswer{Text: fmt.Sprintf("client_unavailable: the app that provides %s disconnected before answering; the outcome is unknown, so check before trying again.", name), IsError: true} + } + return clientToolTurnEnded(name) +} + +// A host process shares one hub across initial, fresh and resumed openings. +func appClientToolsEngineOpener(open func(session.Config) (*session.Agent, error)) (func(session.Config) (*session.Agent, error), *remote.ClientToolHub) { + if appClientToolsBackend.Load() != nil { + return appClientToolsOpener(open), nil + } + hub := &remote.ClientToolHub{} + return func(cfg session.Config) (*session.Agent, error) { + cfg.ClientTools = hub + return open(cfg) + }, hub +} diff --git a/cmd/codeaf/appclienttools_remote_test.go b/cmd/codeaf/appclienttools_remote_test.go new file mode 100644 index 0000000000..e579d9e58e --- /dev/null +++ b/cmd/codeaf/appclienttools_remote_test.go @@ -0,0 +1,577 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/remote" + "github.com/Agent-Field/codeaf/internal/session" +) + +type clientToolRemoteRig struct { + backend *appBackend + door *appClientToolDoor + emitter *clientToolEmitter + hub *remote.ClientToolHub + held *remote.Session + agent *appAgent + loops []*remote.Loop + file string + root string +} + +func clientToolRemoteBackend(t *testing.T, endpoint string) *clientToolRemoteRig { + t.Helper() + return clientToolRemoteBackendSupport(t, endpoint, true) +} + +func clientToolRemoteBackendSupport(t *testing.T, endpoint string, forwards bool) *clientToolRemoteRig { + t.Helper() + b, door, emitter := clientToolBackend(t) + hub := &remote.ClientToolHub{} + root := t.TempDir() + file := filepath.Join(root, "transcript.jsonl") + if endpoint == "" { + endpoint = "https://example.invalid" + } + engineAgent, err := session.New(session.Config{Workspace: root, SessionFile: file, + Model: "test/model", APIKey: "fixture", BaseURL: endpoint, OneModel: true, + DeterministicNames: true, PromptProfile: "full", ClientTools: hub}) + if err != nil { + t.Fatal(err) + } + engine := &remote.Engine{Agent: engineAgent, SessionFile: file, Workspace: root} + if forwards { + engine.ClientTools = hub + } + held := remote.NewSession(engine, true) + a, err := b.find(door.agent) + if err != nil { + t.Fatal(err) + } + b.locked(func() { a.live = nil }) + rig := &clientToolRemoteRig{backend: b, door: door, emitter: emitter, hub: hub, held: held, agent: a, root: root, file: file} + t.Cleanup(func() { + func() { + a.gate.Lock() + defer a.gate.Unlock() + b.release(a) + }() + for _, loop := range rig.loops { + _ = loop.Close() + } + _ = held.Close() + }) + rig.hold(t) + return rig +} + +func (r *clientToolRemoteRig) hold(t *testing.T) *remote.Loop { + t.Helper() + loop, err := remote.LoopbackAttach(remote.Hello{Session: r.file, Join: true}, remote.AttachOptions{ + Open: func(hello remote.Hello) (*remote.Session, error) { + if !hello.Join || hello.Session != r.file { + t.Errorf("expected a join of %s, got %+v", r.file, hello) + } + return r.held, nil + }, + }) + if err != nil { + t.Fatal(err) + } + r.loops = append(r.loops, loop) + conn := &appConn{Conv: loop.Client.Agent(), Transcript: r.file, Workspace: r.root, Release: func() { _ = loop.Client.Close() }} + r.agent.gate.Lock() + defer r.agent.gate.Unlock() + r.backend.hold(r.agent, conn, r.root) + if r.agent.live.conn.Assembled || r.agent.live.conn.Lent { + t.Fatal("fixture must hold a joined conversation") + } + return loop +} + +// C1, C2: a joined app sends declarations and returns results through the holder's model loop. +func TestClientToolsRemoteJoinedModelRoundTrip(t *testing.T) { + for _, isError := range []bool{false, true} { + t.Run(fmt.Sprintf("error=%t", isError), func(t *testing.T) { + var mu sync.Mutex + var requests []json.RawMessage + provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + body, err := io.ReadAll(r.Body) + if err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + var request struct { + Messages []struct { + Role string `json:"role"` + } `json:"messages"` + Tools []json.RawMessage `json:"tools"` + } + if err := json.Unmarshal(body, &request); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + first := func() bool { + mu.Lock() + defer mu.Unlock() + first := len(request.Tools) > 0 && len(requests) == 0 + if len(request.Tools) > 0 { + requests = append(requests, body) + } + return first + }() + w.Header().Set("Content-Type", "text/event-stream") + if first { + fmt.Fprint(w, "data: "+`{"choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"provider-remote-1","type":"function","function":{"name":"app_note","arguments":"{}"}}]},"finish_reason":null}]}`+"\n\n") + fmt.Fprint(w, "data: "+`{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`+"\n\n") + } else { + fmt.Fprint(w, "data: "+`{"choices":[{"index":0,"delta":{"role":"assistant","content":"done"},"finish_reason":"stop"}]}`+"\n\n") + } + fmt.Fprint(w, "data: [DONE]\n\n") + })) + t.Cleanup(provider.Close) + rig := clientToolRemoteBackend(t, provider.URL) + if _, err := rig.backend.SetClientTools(clientToolParams(rig.door.agent, "notes", "app_note")); err != nil { + t.Fatal(err) + } + rig.emitter.answer = func(event appwire.AgentsToolCall) { + params := appwire.ClientToolResultParams{Call: event.Call, Result: json.RawMessage(`{ "saved": true }`)} + if isError { + text := "no access" + params.Result, params.Error = nil, &text + } + ack, err := rig.backend.ClientToolResult(params) + if err != nil || !ack.Accepted { + t.Errorf("answer: %+v %v", ack, err) + } + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + events, err := rig.loops[0].Client.Agent().Submit(ctx, "use the app note") + if err != nil { + t.Fatal(err) + } + for { + select { + case event, open := <-events: + if !open { + goto finished + } + if event.Kind == session.EventError { + t.Errorf("turn: %v", event.Err) + } + case <-ctx.Done(): + t.Fatal("joined model loop did not finish") + } + } + finished: + call := clientToolAwait(t, rig.emitter.seen) + if call.Call != clientToolCallID(rig.door.agent, "provider-remote-1") || call.TurnCall != "provider-remote-1" || call.SourceMessageID == "" { + t.Fatalf("call identity: %+v", call) + } + matchedMessage := false + for _, entry := range appEntriesOf(rig.door.agent, rig.loops[0].Client.Agent().Transcript(), time.Time{}, nil) { + matchedMessage = matchedMessage || entry.Kind == "user" && entry.Text == "use the app note" && entry.MessageID == call.SourceMessageID + } + if !matchedMessage { + t.Fatalf("call did not name the holder's visible message: %+v", call) + } + mu.Lock() + defer mu.Unlock() + if len(requests) != 2 { + t.Fatalf("model requests: %d", len(requests)) + } + if !strings.Contains(string(requests[0]), "Tools from the app: notes") || !strings.Contains(string(requests[0]), "Use this set.") || !strings.Contains(string(requests[0]), "app_note") { + t.Fatalf("holder belt or instructions omitted: %s", requests[0]) + } + var resultRequest struct { + Messages []struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + } `json:"messages"` + } + if err := json.Unmarshal(requests[1], &resultRequest); err != nil { + t.Fatal(err) + } + want := `{"saved":true}` + if isError { + want = "notes could not do app_note: no access" + } + found := false + for _, message := range resultRequest.Messages { + found = found || message.Role == "tool" && strings.Contains(clientToolWireText(message.Content), want) + } + if !found { + t.Fatalf("model did not receive %q: %s", want, requests[1]) + } + count := func() int { + rig.emitter.mu.Lock() + defer rig.emitter.mu.Unlock() + return len(rig.emitter.calls) + }() + if count != 1 { + t.Fatalf("one model call emitted %d events", count) + } + }) + } +} + +// C3: losing the remote link settles the app registry and rejects later answers. +func TestClientToolsRemoteAppDropSettlesPending(t *testing.T) { + rig := clientToolRemoteBackend(t, "") + call := clientToolAttach(t, rig.backend, rig.door) + answer := make(chan session.ClientToolAnswer, 1) + go func() { + got, _ := rig.hub.CallClientTool(context.Background(), call) + answer <- got + }() + event := clientToolAwait(t, rig.emitter.seen) + _ = rig.loops[0].Client.Close() + got := clientToolAwait(t, answer) + if !got.IsError || !strings.HasPrefix(got.Text, "client_unavailable:") { + t.Fatalf("holder did not settle: %+v", got) + } + clientToolAwaitNoPending(t, rig, event.Call) + ack, err := rig.backend.ClientToolResult(appwire.ClientToolResultParams{Call: event.Call, Result: json.RawMessage(`true`)}) + if err != nil || ack.Accepted { + t.Fatalf("late app answer: %+v %v", ack, err) + } +} + +// C2: transport preserves the app's bounded JSON and its original dropped-byte count. +func TestClientToolsRemoteBoundedResult(t *testing.T) { + rig := clientToolRemoteBackend(t, "") + call := clientToolAttach(t, rig.backend, rig.door) + data := json.RawMessage(`"` + strings.Repeat("é", session.ClientToolOutputMax) + `"`) + rig.emitter.answer = func(event appwire.AgentsToolCall) { + ack, err := rig.backend.ClientToolResult(appwire.ClientToolResultParams{Call: event.Call, Result: data}) + if err != nil || !ack.Accepted { + t.Errorf("bounded result: %+v %v", ack, err) + } + } + answer := make(chan session.ClientToolAnswer, 1) + go func() { + got, _ := rig.hub.CallClientTool(context.Background(), call) + answer <- got + }() + got := clientToolAwait(t, answer) + want := fmt.Sprintf("[cut: %d bytes dropped]", len(data)-(session.ClientToolOutputMax-1)) + if got.IsError || !strings.HasSuffix(got.Text, want) || strings.Count(got.Text, "[cut:") != 1 { + t.Fatalf("forwarding changed the truncation receipt: %.80s", got.Text[max(0, len(got.Text)-80):]) + } +} + +// C4: every hold sends the current registry, without another tools_set. +func TestClientToolsRemoteReholdPushesSets(t *testing.T) { + rig := clientToolRemoteBackend(t, "") + clientToolAttach(t, rig.backend, rig.door) + func() { + rig.agent.gate.Lock() + defer rig.agent.gate.Unlock() + rig.backend.release(rig.agent) + }() + _ = rig.loops[0].Close() + if _, sets := rig.hub.ClientToolSets(); len(sets) != 0 { + t.Fatal("old link left its sets attached") + } + rig.hold(t) + if _, sets := rig.hub.ClientToolSets(); len(sets) != 1 || sets[0].Tools[0].Name != "app_note" { + t.Fatalf("re-hold snapshot: %+v", sets) + } +} + +// C6, C7: old holders retain their refusal, while relay-held attachments stay cold. +func TestClientToolsRemoteScopeAndElsewhereStorage(t *testing.T) { + b, door, _ := clientToolBackend(t) + a, _ := b.find(door.agent) + b.locked(func() { a.live.conn.Conv = &remote.Agent{} }) + _, err := b.SetClientTools(clientToolParams(door.agent, "notes", "app_note")) + var refusal *appwire.Error + if !errors.As(err, &refusal) || refusal.Code != appwire.CodeBadScope || refusal.Message != "client tools need a conversation this engine runs" { + t.Fatalf("old holder refusal: %v", err) + } + if _, sets := door.ClientToolSets(); len(sets) != 0 { + t.Fatal("scope refusal changed registry") + } + b.locked(func() { a.view.Location = &appwire.Location{Here: false} }) + if _, err := b.SetClientTools(clientToolParams(door.agent, "notes", "app_note")); err != nil { + t.Fatalf("relay-held attachment: %v", err) + } + b.locked(func() { + a.view.Location.Here = true + a.live = nil + }) + if _, sets := door.ClientToolSets(); len(sets) != 1 || sets[0].Tools[0].Name != "app_note" { + t.Fatalf("sets after returning here: %+v", sets) + } + older := clientToolRemoteBackendSupport(t, "", false) + _, err = older.backend.SetClientTools(clientToolParams(older.door.agent, "notes", "app_note")) + if !errors.As(err, &refusal) || refusal.Code != appwire.CodeBadScope || refusal.Message != "client tools need a conversation this engine runs" { + t.Fatalf("older live holder refusal: %v", err) + } +} + +// C3: a holder's turn-ended frame cancels the app wait before its set's long limit. +func TestClientToolsRemoteAppTurnEnded(t *testing.T) { + rig := clientToolRemoteBackend(t, "") + p := clientToolParams(rig.door.agent, "notes", "app_note") + p.DeadlineMS = 120000 + if _, err := rig.backend.SetClientTools(p); err != nil { + t.Fatal(err) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + answer := make(chan session.ClientToolAnswer, 1) + go func() { + got, _ := rig.hub.CallClientTool(ctx, session.ClientToolCall{Set: "notes", Name: "app_note", TurnCall: "ended-call", Args: json.RawMessage(`{}`)}) + answer <- got + }() + event := clientToolAwait(t, rig.emitter.seen) + cancel() + got := clientToolAwait(t, answer) + if !got.IsError || !strings.Contains(got.Text, "the turn ended") { + t.Fatalf("holder answer: %+v", got) + } + clientToolAwaitNoPending(t, rig, event.Call) + ack, err := rig.backend.ClientToolResult(appwire.ClientToolResultParams{Call: event.Call, Result: json.RawMessage(`true`)}) + if err != nil || ack.Accepted { + t.Fatalf("late result after holder cancellation: %+v %v", ack, err) + } +} + +func clientToolAwaitNoPending(t *testing.T, rig *clientToolRemoteRig, id string) { + t.Helper() + timer := time.NewTimer(3 * time.Second) + defer timer.Stop() + tick := time.NewTicker(5 * time.Millisecond) + defer tick.Stop() + for { + pending := func() bool { + rig.backend.clientTools.mu.Lock() + defer rig.backend.clientTools.mu.Unlock() + return rig.backend.clientTools.pending[id] != nil + }() + if !pending { + break + } + select { + case <-tick.C: + case <-timer.C: + t.Fatal("ended link or call did not clear the app wait before a late result") + } + } +} + +// C8: the app's real hello advertises forwarding alongside its existing features. +func TestClientToolsRemoteHelloFeature(t *testing.T) { + features, _ := slashCatalogOverTheWire(t) + for _, want := range []string{appwire.FeatureClientTools, appwire.FeatureClientToolsSource, appwire.FeatureClientToolsRemote} { + found := false + for _, feature := range features { + found = found || feature == want + } + if !found { + t.Fatalf("hello omitted %s: %v", want, features) + } + } +} + +// C1: the holder's additional collision checks refuse without changing the app registry. +func TestClientToolsRemoteHolderRefusalIsAtomic(t *testing.T) { + rig := clientToolRemoteBackend(t, "") + rig.hub.ClientToolBuiltins([]string{"app_note"}) + _, err := rig.backend.SetClientTools(clientToolParams(rig.door.agent, "notes", "app_note")) + var refusal *appwire.Error + if !errors.As(err, &refusal) || refusal.Code != appwire.CodeBadParams || !strings.Contains(refusal.Message, "app_note") { + t.Fatalf("holder refusal: %v", err) + } + if _, sets := rig.door.ClientToolSets(); len(sets) != 0 { + t.Fatal("holder refusal changed the app registry") + } + if _, sets := rig.hub.ClientToolSets(); len(sets) != 0 { + t.Fatal("holder refusal changed its own snapshot") + } +} + +// C1: a transport failure is retryable and no registry lock is held on the wire. +func TestClientToolsRemoteTransportFailureLeavesRegistry(t *testing.T) { + b, door, _ := clientToolBackend(t) + surface, holder := remote.Pipe() + t.Cleanup(func() { + _ = surface.Close() + _ = holder.Close() + }) + readRegistry := make(chan struct{}) + go func() { + defer holder.Close() + reader := json.NewDecoder(holder) + var hello remote.Frame + if reader.Decode(&hello) != nil { + return + } + payload, _ := json.Marshal(remote.Welcome{Version: remote.Version, ClientTools: true}) + if json.NewEncoder(holder).Encode(remote.Frame{Kind: "welcome", Payload: payload}) != nil { + return + } + var call remote.Frame + if reader.Decode(&call) != nil || call.Method != remote.MethodClientToolsSet { + return + } + _, _ = door.ClientToolSets() + close(readRegistry) + }() + client, err := remote.Dial(surface, "engine", remote.Hello{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close() }) + b.locked(func() { b.agents[door.agent].live.conn.Conv = client.Agent() }) + failed := make(chan error, 1) + go func() { + _, err := b.SetClientTools(clientToolParams(door.agent, "notes", "app_note")) + failed <- err + }() + err = clientToolAwait(t, failed) + var refusal *appwire.Error + if !errors.As(err, &refusal) || refusal.Code != appwire.CodeUnreachable { + t.Fatalf("transport failure: %v", err) + } + clientToolAwait(t, readRegistry) + if _, sets := door.ClientToolSets(); len(sets) != 0 { + t.Fatal("a failed transport committed the app snapshot") + } +} + +// C1, C3: publication orders calls while cancellation and the call wait still apply. +func TestClientToolsRemoteCallDuringPublication(t *testing.T) { + for _, outcome := range []string{"published", "disconnect", "timeout"} { + t.Run(outcome, func(t *testing.T) { + b, door, emitter := clientToolBackend(t) + surface, holder := remote.Pipe() + inFlight := make(chan struct{}) + publish, stop, served := make(chan struct{}), make(chan struct{}), make(chan struct{}) + var finish sync.Once + go func() { + defer close(served) + defer holder.Close() + reader := json.NewDecoder(holder) + var hello, call remote.Frame + if reader.Decode(&hello) != nil { + return + } + payload, _ := json.Marshal(remote.Welcome{Version: remote.Version, ClientTools: true}) + if json.NewEncoder(holder).Encode(remote.Frame{Kind: "welcome", Payload: payload}) != nil || reader.Decode(&call) != nil { + return + } + close(inFlight) + <-publish + _ = json.NewEncoder(holder).Encode(remote.Frame{Kind: "result", ID: call.ID, Payload: json.RawMessage(`["app_note"]`)}) + <-stop + }() + client, err := remote.Dial(surface, "engine", remote.Hello{}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + finish.Do(func() { close(publish) }) + close(stop) + _ = client.Close() + clientToolAwait(t, served) + }) + b.locked(func() { b.agents[door.agent].live.conn.Conv = client.Agent() }) + emitter.answer = func(event appwire.AgentsToolCall) { + _, _ = b.ClientToolResult(appwire.ClientToolResultParams{Call: event.Call, Result: json.RawMessage(`true`)}) + } + setter := make(chan error, 1) + go func() { + _, err := b.SetClientTools(clientToolParams(door.agent, "notes", "app_note")) + setter <- err + }() + clientToolAwait(t, inFlight) + ctx, cancel := context.WithCancelCause(context.Background()) + defer cancel(context.Canceled) + answer := make(chan session.ClientToolAnswer, 1) + go func() { + got, _ := b.callRemoteClientTool(ctx, door, session.ClientToolCall{Set: "notes", Name: "app_note", + TurnCall: "during-publication", Args: json.RawMessage(`{}`), DeadlineMS: 1000}) + answer <- got + }() + if outcome == "published" { + finish.Do(func() { close(publish) }) + if err := clientToolAwait(t, setter); err != nil { + t.Fatal(err) + } + got := clientToolAwait(t, answer) + if got.IsError || got.Text != "true" { + t.Fatalf("call did not wait for registry publication: %+v", got) + } + return + } + if outcome == "disconnect" { + cancel(remote.ErrClientToolDisconnected) + } + got := clientToolAwait(t, answer) + want := "client_timeout:" + if outcome == "disconnect" { + want = "client_unavailable:" + } + if !got.IsError || !strings.HasPrefix(got.Text, want) { + t.Fatalf("call outlived cancellation while publication was in flight: %+v", got) + } + select { + case <-emitter.seen: + t.Fatal("unpublished call reached the app") + default: + } + finish.Do(func() { close(publish) }) + if err := clientToolAwait(t, setter); err != nil { + t.Fatal(err) + } + }) + } +} + +// C4: each opening uses the same host hub, while the app retains its own door. +func TestClientToolsRemoteEngineOpenings(t *testing.T) { + previous := appClientToolsBackend.Swap(nil) + t.Cleanup(func() { appClientToolsBackend.Store(previous) }) + var doors []session.ClientToolDoor + open := func(cfg session.Config) (*session.Agent, error) { + doors = append(doors, cfg.ClientTools) + return nil, nil + } + hostOpen, hub := appClientToolsEngineOpener(open) + if hub == nil { + t.Fatal("a host process has no forwarding hub") + } + for _, file := range []string{"initial", "fresh", "resumed"} { + _, _ = hostOpen(session.Config{SessionFile: file}) + } + for _, door := range doors { + if door != hub { + t.Fatal("an opening lost the conversation's hub") + } + } + b, _, _ := clientToolBackend(t) + appClientToolsBackend.Store(b) + appOpen, appHub := appClientToolsEngineOpener(open) + if appHub != nil { + t.Fatal("the app replaced its own registry with a host hub") + } + _, _ = appOpen(session.Config{SessionFile: "app-conversation"}) + if _, ok := doors[len(doors)-1].(*appClientToolDoor); !ok { + t.Fatal("the app lost its existing conversation door") + } +} diff --git a/cmd/codeaf/appclienttools_test.go b/cmd/codeaf/appclienttools_test.go new file mode 100644 index 0000000000..41d19415b6 --- /dev/null +++ b/cmd/codeaf/appclienttools_test.go @@ -0,0 +1,890 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "reflect" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/remote" + "github.com/Agent-Field/codeaf/internal/session" +) + +type clientToolEmitter struct { + t *testing.T + mu sync.Mutex + calls []appwire.AgentsToolCall + seen chan appwire.AgentsToolCall + answer func(appwire.AgentsToolCall) +} + +// Source contract (a), (b): real provider calls name the entry returned by the +// app read projection, across a second send, a reopened journal and takeover resume. +func TestClientToolsSourceMessageWireAndRestart(t *testing.T) { + for _, stopped := range []bool{false, true} { + t.Run(fmt.Sprintf("stopped=%t", stopped), func(t *testing.T) { + b, door, emitter := clientToolBackend(t) + clientToolAttach(t, b, door) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + entered := make(chan struct{}) + var mu sync.Mutex + calls := 0 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request struct { + Messages []struct { + Role string `json:"role"` + } `json:"messages"` + Tools []json.RawMessage `json:"tools"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + tool := len(request.Tools) > 0 && len(request.Messages) > 0 && request.Messages[len(request.Messages)-1].Role == "user" + mu.Lock() + if tool { + calls++ + } + call := calls + mu.Unlock() + if tool && stopped && call == 1 { + close(entered) + select { + case <-r.Context().Done(): + case <-ctx.Done(): + } + return + } + w.Header().Set("Content-Type", "text/event-stream") + if tool { + fmt.Fprintf(w, "data: "+`{"choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"provider-source-%d","type":"function","function":{"name":"app_note","arguments":"{}"}}]},"finish_reason":null}]}`+"\n\n", call) + fmt.Fprint(w, "data: "+`{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`+"\n\n") + } else { + fmt.Fprint(w, "data: "+`{"choices":[{"index":0,"delta":{"role":"assistant","content":"done"},"finish_reason":"stop"}]}`+"\n\n") + } + fmt.Fprint(w, "data: [DONE]\n\n") + })) + t.Cleanup(server.Close) + answer := func(backend *appBackend, emit *clientToolEmitter) { + emit.answer = func(event appwire.AgentsToolCall) { + ack, err := backend.ClientToolResult(appwire.ClientToolResultParams{Call: event.Call, Result: json.RawMessage(`true`)}) + if err != nil || !ack.Accepted { + t.Errorf("tool result: %+v %v", ack, err) + } + } + } + answer(b, emitter) + path := filepath.Join(t.TempDir(), "session.jsonl") + cfg := session.Config{Workspace: t.TempDir(), SessionFile: path, Model: "test/model", APIKey: "fixture", BaseURL: server.URL, + OneModel: true, DeterministicNames: true, PromptProfile: "full", ClientTools: door} + agent, err := session.New(cfg) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = agent.Close() }) + b.locked(func() { b.agents[door.agent].live.conn.Conv = agent }) + drain := func(events <-chan session.Event) { + t.Helper() + for { + select { + case _, open := <-events: + if !open { + return + } + case <-ctx.Done(): + t.Fatal("turn did not finish") + } + } + } + events, err := agent.Submit(ctx, "first question") + if err != nil { + t.Fatal(err) + } + if stopped { + clientToolAwait(t, entered) + agent.InterruptFor(session.StopByTakeover) + } + drain(events) + messageID := func(conv *session.Agent, text string) string { + t.Helper() + for _, entry := range appEntriesOf(door.agent, conv.Transcript(), time.Time{}, nil) { + if entry.Kind == "user" && entry.Text == text && entry.MessageID != "" { + return entry.MessageID + } + } + t.Fatalf("read omitted message id for %q", text) + return "" + } + firstID := messageID(agent, "first question") + if !stopped { + first := clientToolAwait(t, emitter.seen) + if first.SourceMessageID != firstID { + t.Fatalf("first call=%+v read message=%q", first, firstID) + } + next, err := agent.SubmitMessage(ctx, session.Submission{Text: "second question", ClientID: "second-window"}) + if err != nil { + t.Fatal(err) + } + drain(next) + second := clientToolAwait(t, emitter.seen) + secondID := messageID(agent, "second question") + if second.SourceMessageID != secondID || secondID == firstID { + t.Fatalf("second call=%+v read ids=%q/%q", second, firstID, secondID) + } + } + if err := agent.Close(); err != nil { + t.Fatal(err) + } + fresh, freshDoor, freshEmitter := clientToolBackend(t) + clientToolAttach(t, fresh, freshDoor) + answer(fresh, freshEmitter) + cfg.ClientTools = freshDoor + reopened, err := session.New(cfg) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = reopened.Close() }) + fresh.locked(func() { fresh.agents[freshDoor.agent].live.conn.Conv = reopened }) + if got := messageID(reopened, "first question"); got != firstID { + t.Fatalf("reopened read changed message id: %q want %q", got, firstID) + } + if stopped { + resumed, ok := reopened.ResumeStoppedTurn(ctx) + if !ok { + t.Fatal("reopened stopped turn did not resume") + } + drain(resumed) + call := clientToolAwait(t, freshEmitter.seen) + if call.SourceMessageID != firstID { + t.Fatalf("resumed call=%+v read message=%q", call, firstID) + } + } + }) + } +} + +// Contract (a), (b), (e): a real session provider request crosses the app door, +// then a turn started after disconnect loses the tools and their instructions. +func TestClientToolsModelWireRoundTripAndDisconnectBoundary(t *testing.T) { + b, door, emitter := clientToolBackend(t) + clientToolAttach(t, b, door) + var mu sync.Mutex + var requests []struct { + Messages []struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + Call string `json:"tool_call_id"` + } `json:"messages"` + Tools []struct { + Function struct { + Name string `json:"name"` + } `json:"function"` + } `json:"tools"` + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request struct { + Messages []struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + Call string `json:"tool_call_id"` + } `json:"messages"` + Tools []struct { + Function struct { + Name string `json:"name"` + } `json:"function"` + } `json:"tools"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + mu.Lock() + foreground := len(request.Tools) > 0 + first := foreground && len(requests) == 0 + if foreground { + requests = append(requests, request) + } + mu.Unlock() + w.Header().Set("Content-Type", "text/event-stream") + if first { + fmt.Fprint(w, "data: "+`{"choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"provider-client-1","type":"function","function":{"name":"app_note","arguments":"{\"id\":3}"}}]},"finish_reason":null}]}`+"\n\n") + fmt.Fprint(w, "data: "+`{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`+"\n\n") + } else { + fmt.Fprint(w, "data: "+`{"choices":[{"index":0,"delta":{"role":"assistant","content":"done"},"finish_reason":"stop"}]}`+"\n\n") + } + fmt.Fprint(w, "data: [DONE]\n\n") + })) + t.Cleanup(server.Close) + disconnected := make(chan struct{}) + emitter.answer = func(event appwire.AgentsToolCall) { + ack, err := b.ClientToolResult(appwire.ClientToolResultParams{Call: event.Call, Result: json.RawMessage(`{ "note": "found" }`)}) + if err != nil || !ack.Accepted { + t.Errorf("wire answer: %+v %v", ack, err) + } + b.clientTools.disconnect() + close(disconnected) + } + agent, err := session.New(session.Config{Workspace: t.TempDir(), Model: "test/model", APIKey: "fixture", BaseURL: server.URL, + OneModel: true, DeterministicNames: true, PromptProfile: "full", ClientTools: door}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = agent.Close() }) + b.locked(func() { b.agents[door.agent].live.conn.Conv = agent }) + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + events, err := agent.Submit(ctx, "read the app note") + if err != nil { + t.Fatal(err) + } + turnCall := "" + for { + select { + case event, open := <-events: + if !open { + goto finished + } + if event.Kind == session.EventToolBegin && event.Tool == "app_note" { + turnCall = event.CallID + } + case <-ctx.Done(): + t.Fatal("model round trip did not finish") + } + } +finished: + clientToolAwait(t, disconnected) + // Start this turn after disconnect has completed, independently of sender scheduling. + next, err := agent.Submit(ctx, "what tools remain?") + if err != nil { + t.Fatal(err) + } + for { + select { + case _, open := <-next: + if !open { + goto nextFinished + } + case <-ctx.Done(): + t.Fatal("post-disconnect turn did not finish") + } + } +nextFinished: + event := clientToolAwait(t, emitter.seen) + if event.TurnCall != turnCall || turnCall != "provider-client-1" { + t.Fatalf("turn.tool correlation: %+v / %q", event, turnCall) + } + mu.Lock() + defer mu.Unlock() + if len(requests) != 3 { + t.Fatalf("provider requests=%d", len(requests)) + } + first, second, afterDisconnect := requests[0], requests[1], requests[2] + if first.Tools[len(first.Tools)-1].Function.Name != "app_note" || !strings.Contains(string(first.Messages[0].Content), "Tools from the app") { + t.Fatal("initial request lost app definitions or instructions") + } + for _, tool := range afterDisconnect.Tools { + if tool.Function.Name == "app_note" { + t.Fatal("disconnected tool remained on the next request") + } + } + if strings.Contains(string(afterDisconnect.Messages[0].Content), "Tools from the app") { + t.Fatal("disconnected instructions remained on the next request") + } + found := false + for _, message := range second.Messages { + if message.Role != "tool" || message.Call != turnCall { + continue + } + if text := clientToolWireText(message.Content); text != `{"note":"found"}` { + t.Fatalf("model result: %s", message.Content) + } + found = true + } + if !found { + t.Fatal("model did not receive the wire result") + } +} + +func clientToolWireText(raw json.RawMessage) string { + var text string + if json.Unmarshal(raw, &text) == nil { + return text + } + var parts []struct { + Text string `json:"text"` + } + _ = json.Unmarshal(raw, &parts) + for _, part := range parts { + text += part.Text + } + return text +} + +func (e *clientToolEmitter) Emit(event string, data any) { + if event != appwire.EventAgentsToolCall { + return + } + call := data.(appwire.AgentsToolCall) + e.mu.Lock() + e.calls = append(e.calls, call) + e.mu.Unlock() + select { + case e.seen <- call: + case <-time.After(2 * time.Second): + e.t.Error("client tool event could not be delivered within two seconds") + return + } + if e.answer != nil { + e.answer(call) + } +} + +func clientToolBackend(t *testing.T) (*appBackend, *appClientToolDoor, *clientToolEmitter) { + t.Helper() + t.Setenv(home.EnvVar, t.TempDir()) + e := &clientToolEmitter{t: t, seen: make(chan appwire.AgentsToolCall, 20)} + b := newFakeBackend(e, nil) + id := "client-agent" + transcript := filepath.Join(t.TempDir(), id, "transcript.jsonl") + b.locked(func() { + b.agents[id] = &appAgent{view: appwire.Agent{ID: id}, transcript: transcript, + live: &appLive{turn: 7, conn: &appConn{Conv: &fakeConversation{}}}} + }) + t.Cleanup(func() { + b.clientTools.disconnect() + b.locked(func() { b.agents[id].live = nil }) + _ = b.Close() + }) + return b, &appClientToolDoor{backend: b, agent: id}, e +} + +func clientToolParams(agent, set, name string) appwire.ClientToolsSetParams { + return appwire.ClientToolsSetParams{Agent: agent, Set: set, Instructions: "Use this set.", Tools: []session.ClientTool{ + {Name: name, Description: "An app action", InputSchema: json.RawMessage(`{"type":"object"}`), Category: session.ActionEdit}, + }} +} + +func clientToolAttach(t *testing.T, b *appBackend, d *appClientToolDoor) session.ClientToolCall { + t.Helper() + if _, err := b.SetClientTools(clientToolParams(d.agent, "notes", "app_note")); err != nil { + t.Fatal(err) + } + return session.ClientToolCall{Set: "notes", Name: "app_note", TurnCall: "provider-1", Args: json.RawMessage(`{"id":3}`)} +} + +// Contract (a), (h): the Fresh/Open builder binds the final conversation, even when its config carried another door. +func TestClientToolsFreshAndOpenBindTheirConversation(t *testing.T) { + b, door, _ := clientToolBackend(t) + clientToolAttach(t, b, door) + previous := appClientToolsBackend.Swap(b) + t.Cleanup(func() { appClientToolsBackend.Store(previous) }) + root := t.TempDir() + dir := writeV3Session(t, root, "client-b", "", time.Time{}) + file := (session.Place{Dir: dir}).Transcript() + b.locked(func() { b.agents["client-b"] = &appAgent{view: appwire.Agent{ID: "client-b"}, transcript: file} }) + p := clientToolParams("client-b", "other", "app_other") + p.Instructions = "Only conversation B's instructions." + p.Tools[0].Category = session.ActionRead + if _, err := b.SetClientTools(p); err != nil { + t.Fatal(err) + } + cfg := session.Config{Workspace: root, ClientTools: door} + b.locked(func() { cfg.SessionFile = b.agents[door.agent].transcript }) + cfg.Place = session.Place{Dir: session.DirOf(cfg.SessionFile), Workspace: root} + for _, path := range []string{"fresh", "open"} { + t.Run(path, func(t *testing.T) { + open := appClientToolsOpener(func(opened session.Config) (*session.Agent, error) { + bound, ok := opened.ClientTools.(*appClientToolDoor) + if !ok || bound.agent != "client-b" { + t.Fatalf("replacement door: %+v", opened.ClientTools) + } + _, sets := bound.ClientToolSets() + if len(sets) != 1 || sets[0].ID != "other" || sets[0].Instructions != p.Instructions || + len(sets[0].Tools) != 1 || sets[0].Tools[0].Name != "app_other" || sets[0].Tools[0].Category != session.ActionRead { + t.Fatalf("replacement inherited the wrong sets: %+v", sets) + } + return nil, nil + }) + var next session.Config + var err error + if path == "fresh" { + next, err = v3PointAt(cfg, session.Place{Dir: dir, Workspace: root}) + } else { + next, err = v3Reopen(cfg, file, root) + } + if err != nil { + t.Fatal(err) + } + if _, err := open(next); err != nil { + t.Fatal(err) + } + }) + } + if cfg.ClientTools != door { + t.Fatal("opening a replacement changed the original config") + } + appClientToolsBackend.Store(nil) + open := appClientToolsOpener(func(opened session.Config) (*session.Agent, error) { + if opened.ClientTools != nil { + t.Fatal("opening without an app retained a stale door") + } + return nil, nil + }) + if _, err := open(cfg); err != nil { + t.Fatal(err) + } +} + +func clientToolStart(ctx context.Context, d *appClientToolDoor, call session.ClientToolCall) <-chan session.ClientToolAnswer { + done := make(chan session.ClientToolAnswer, 1) + go func() { + answer, err := d.CallClientTool(ctx, call) + if err != nil { + answer = session.ClientToolAnswer{Text: err.Error(), IsError: true} + } + done <- answer + }() + return done +} + +func clientToolAwait[T any](t *testing.T, done <-chan T) T { + t.Helper() + select { + case answer := <-done: + return answer + case <-time.After(2 * time.Second): + t.Fatal("client tool did not settle within two seconds") + } + var zero T + return zero +} + +// Contract (b), (d), source contract (b): redispatch preserves the source and emits once. +func TestClientToolsEventResultAndReplay(t *testing.T) { + b, door, emitter := clientToolBackend(t) + call := clientToolAttach(t, b, door) + call.SourceMessage = "msg-recorded" + first := clientToolStart(context.Background(), door, call) + event := clientToolAwait(t, emitter.seen) + if event.Agent != door.agent || event.Turn != 7 || event.TurnCall != call.TurnCall || event.Set != call.Set || event.Name != call.Name || + string(event.Args) != string(call.Args) || event.DeadlineMS != session.ClientToolDefaultDeadlineMS || event.Call != clientToolCallID(door.agent, call.TurnCall) || event.SourceMessageID != call.SourceMessage { + t.Fatalf("event shape: %+v", event) + } + second := clientToolStart(context.Background(), door, call) + ack, err := b.ClientToolResult(appwire.ClientToolResultParams{Call: event.Call, Result: json.RawMessage(`{ "ok": true }`)}) + if err != nil || !ack.Accepted { + t.Fatalf("answer: %+v %v", ack, err) + } + for _, done := range []<-chan session.ClientToolAnswer{first, second, clientToolStart(context.Background(), door, call)} { + if answer := clientToolAwait(t, done); answer.IsError || answer.Text != `{"ok":true}` { + t.Fatalf("result: %+v", answer) + } + } + for _, id := range []string{event.Call, "unknown"} { + ack, err := b.ClientToolResult(appwire.ClientToolResultParams{Call: id, Result: json.RawMessage(`null`)}) + if err != nil || ack.Accepted { + t.Fatalf("duplicate/unknown: %+v %v", ack, err) + } + } + emitter.mu.Lock() + defer emitter.mu.Unlock() + if len(emitter.calls) != 1 { + t.Fatalf("%d events for one call", len(emitter.calls)) + } + // A new registry represents a new process, with a different local counter. + fresh, freshDoor, freshEmitter := clientToolBackend(t) + clientToolAttach(t, fresh, freshDoor) + fresh.locked(func() { fresh.agents[freshDoor.agent].live.turn = 1 }) + replayed := clientToolStart(context.Background(), freshDoor, call) + again := clientToolAwait(t, freshEmitter.seen) + if again.Call != event.Call || again.SourceMessageID != event.SourceMessageID || again.Turn != 1 { + t.Fatalf("fresh process changed correlation: %+v / %+v", event, again) + } + if _, err := fresh.ClientToolResult(appwire.ClientToolResultParams{Call: again.Call, Result: json.RawMessage(`true`)}); err != nil { + t.Fatal(err) + } + clientToolAwait(t, replayed) +} + +// Contract (b): the pending entry exists before an emitter can answer synchronously. +func TestClientToolsFastErrorAnswer(t *testing.T) { + b, door, emitter := clientToolBackend(t) + call := clientToolAttach(t, b, door) + emitter.answer = func(event appwire.AgentsToolCall) { + text := "permission denied" + ack, err := b.ClientToolResult(appwire.ClientToolResultParams{Call: event.Call, Error: &text}) + if err != nil || !ack.Accepted { + t.Errorf("fast answer: %+v %v", ack, err) + } + } + answer := clientToolAwait(t, clientToolStart(context.Background(), door, call)) + if !answer.IsError || answer.Text != "notes could not do app_note: permission denied" { + t.Fatalf("error result: %+v", answer) + } +} + +// Contract (c), (d), (e): each ending is bounded and rejects every late answer. +func TestClientToolsDisconnectDeadlineAndTurnEnd(t *testing.T) { + for _, ending := range []string{"disconnect", "deadline", "turn end"} { + t.Run(ending, func(t *testing.T) { + b, door, emitter := clientToolBackend(t) + call := clientToolAttach(t, b, door) + if ending == "deadline" { + // Only this in-process fixture bypasses the wire's one-second minimum. + b.clientTools.mu.Lock() + state := b.clientTools.agents[door.agent] + state.sets[0].DeadlineMS = 15 + b.clientTools.agents[door.agent] = state + b.clientTools.mu.Unlock() + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := clientToolStart(ctx, door, call) + event := clientToolAwait(t, emitter.seen) + want := "client_timeout: the app that provides app_note did not answer within 0.015s; the outcome is unknown, so check before trying again." + switch ending { + case "disconnect": + b.clientTools.disconnect() + b.clientTools.disconnect() + want = "client_unavailable: the app that provides app_note disconnected before answering; the outcome is unknown, so check before trying again." + _, sets := door.ClientToolSets() + if len(sets) != 0 { + t.Fatal("disconnect kept sets") + } + case "turn end": + cancel() + want = clientToolTurnEnded(call.Name).Text + } + if answer := clientToolAwait(t, done); !answer.IsError || answer.Text != want { + t.Fatalf("ending: %+v", answer) + } + ack, err := b.ClientToolResult(appwire.ClientToolResultParams{Call: event.Call, Result: json.RawMessage(`1`)}) + if err != nil || ack.Accepted { + t.Fatalf("late answer: %+v %v", ack, err) + } + }) + } +} + +// Contract (c), (d): a full writer cannot hold the deadline, turn end or disconnect hostage. +func TestClientToolsBlockedEmitterDoesNotBlockEndings(t *testing.T) { + for _, ending := range []string{"deadline", "turn end", "disconnect"} { + t.Run(ending, func(t *testing.T) { + b, door, emitter := clientToolBackend(t) + call := clientToolAttach(t, b, door) + if ending == "deadline" { + // Only this fixture bypasses the wire's one-second minimum. + b.clientTools.mu.Lock() + b.clientTools.agents[door.agent].sets[0].DeadlineMS = 100 + b.clientTools.mu.Unlock() + } + entered, release, returned := make(chan struct{}), make(chan struct{}), make(chan struct{}) + var once sync.Once + unblock := func() { once.Do(func() { close(release) }) } + t.Cleanup(unblock) + emitter.answer = func(appwire.AgentsToolCall) { + close(entered) + <-release + close(returned) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := clientToolStart(ctx, door, call) + event := clientToolAwait(t, emitter.seen) + clientToolAwait(t, entered) + want := "client_timeout: the app that provides app_note did not answer within 0.1s; the outcome is unknown, so check before trying again." + switch ending { + case "turn end": + cancel() + want = clientToolTurnEnded(call.Name).Text + case "disconnect": + b.clientTools.disconnect() + want = "client_unavailable: the app that provides app_note disconnected before answering; the outcome is unknown, so check before trying again." + } + if answer := clientToolAwait(t, done); !answer.IsError || answer.Text != want { + t.Fatalf("blocked %s: %+v", ending, answer) + } + select { + case <-returned: + t.Fatal("emitter returned before the call ended") + default: + } + ack, err := b.ClientToolResult(appwire.ClientToolResultParams{Call: event.Call, Result: json.RawMessage(`null`)}) + if err != nil || ack.Accepted { + t.Fatalf("late answer: %+v %v", ack, err) + } + unblock() + clientToolAwait(t, returned) + b.clientTools.disconnect() + clientToolAwait(t, b.clientTools.senderDone) + }) + } +} + +// Contract (c), (d): calls ending behind a stalled writer are dropped before delivery. +func TestClientToolsEndedQueuedCallsAreNotEmitted(t *testing.T) { + for _, ending := range []string{"deadline", "turn end", "disconnect", "result"} { + t.Run(ending, func(t *testing.T) { + b, door, emitter := clientToolBackend(t) + call := clientToolAttach(t, b, door) + release := make(chan struct{}) + var once sync.Once + unblock := func() { once.Do(func() { close(release) }) } + t.Cleanup(unblock) + emitter.answer = func(event appwire.AgentsToolCall) { + if event.TurnCall == "provider-1" { + <-release + } + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + first := clientToolStart(ctx, door, call) + clientToolAwait(t, emitter.seen) + cancel() + clientToolAwait(t, first) + + queuedCtx, cancelQueued := context.WithCancel(context.Background()) + defer cancelQueued() + call.TurnCall = "queued-call" + id := clientToolCallID(door.agent, call.TurnCall) + r := &b.clientTools + r.mu.Lock() + pending, _, emit := r.beginLocked(queuedCtx, door.agent, id, call) + r.mu.Unlock() + if pending == nil || !emit { + t.Fatal("queued call was not registered") + } + r.queueEmission(b.emit, pending, appwire.AgentsToolCall{Agent: door.agent, Call: id, TurnCall: call.TurnCall}) + switch ending { + case "deadline": + r.mu.Lock() + pending.answerBy = time.Now().Add(-time.Second) + r.mu.Unlock() + case "turn end": + cancelQueued() + case "disconnect": + r.disconnect() + case "result": + ack, err := b.ClientToolResult(appwire.ClientToolResultParams{Call: id, Result: json.RawMessage(`true`)}) + if err != nil || !ack.Accepted { + t.Fatalf("queued answer: %+v %v", ack, err) + } + } + done := make(chan session.ClientToolAnswer, 1) + go func() { done <- r.wait(queuedCtx, id, pending) }() + answer := clientToolAwait(t, done) + if answer.IsError != (ending != "result") { + t.Fatalf("queued ending: %+v", answer) + } + unblock() + if ending != "disconnect" { + call.TurnCall = "after-queue" + after := clientToolStart(context.Background(), door, call) + event := clientToolAwait(t, emitter.seen) + if event.TurnCall != "after-queue" { + t.Fatalf("ended call left the queue: %+v", event) + } + r.disconnect() + clientToolAwait(t, after) + } + clientToolAwait(t, r.senderDone) + select { + case event := <-emitter.seen: + t.Fatalf("extra event after queue drain: %+v", event) + default: + } + }) + } +} + +// Contract (e), (h): replacement is atomic and removal affects only its own set. +func TestClientToolsSetReplacementRemovalAndValidation(t *testing.T) { + b, door, _ := clientToolBackend(t) + clientToolAttach(t, b, door) + if _, err := b.SetClientTools(clientToolParams(door.agent, "second", "app_other")); err != nil { + t.Fatal(err) + } + beforeVersion, before := door.ClientToolSets() + for _, p := range []appwire.ClientToolsSetParams{ + clientToolParams(door.agent, "notes", "factory_add"), + clientToolParams(door.agent, "notes", "app_other"), + clientToolParams(door.agent, "notes", "change_setting"), + } { + if _, err := b.SetClientTools(p); err == nil || !strings.Contains(err.Error(), p.Tools[0].Name) { + t.Fatalf("collision accepted: %v", err) + } + version, sets := door.ClientToolSets() + if version != beforeVersion || !reflect.DeepEqual(sets, before) { + t.Fatal("refusal changed the registry") + } + } + replacement := clientToolParams(door.agent, "notes", "app_replaced") + if _, err := b.SetClientTools(replacement); err != nil { + t.Fatal(err) + } + replacement.Tools[0].InputSchema[0] = '[' + _, snapshots := door.ClientToolSets() + if !json.Valid(snapshots[0].Tools[0].InputSchema) { + t.Fatal("registry kept the caller's mutable schema") + } + removed, err := b.SetClientTools(appwire.ClientToolsSetParams{Agent: door.agent, Set: "notes"}) + if err != nil || len(removed.Tools) != 0 { + t.Fatalf("removal: %+v %v", removed, err) + } + _, sets := door.ClientToolSets() + if len(sets) != 1 || sets[0].ID != "second" { + t.Fatal("removal changed another set") + } + answer := clientToolAwait(t, clientToolStart(context.Background(), door, session.ClientToolCall{Set: "notes", Name: "app_replaced", Args: json.RawMessage(`{}`)})) + if !answer.IsError || answer.Text != "client_unavailable: the app that provides app_replaced is not connected; nothing was done." { + t.Fatalf("retired closure: %+v", answer) + } +} + +// Contract (h): aggregate limits and remote scope do not open a conversation. +func TestClientToolsAgentLimitsAndScope(t *testing.T) { + b, door, _ := clientToolBackend(t) + if _, err := b.SetClientTools(clientToolParams("missing", "notes", "app_note")); err == nil { + t.Fatal("unknown agent accepted") + } + for i := 0; i < session.ClientToolMaxSets; i++ { + if _, err := b.SetClientTools(clientToolParams(door.agent, fmt.Sprintf("set%d", i), fmt.Sprintf("app_%d", i))); err != nil { + t.Fatal(err) + } + } + if _, err := b.SetClientTools(clientToolParams(door.agent, "fifth", "app_fifth")); err == nil { + t.Fatal("fifth set accepted") + } + b.clientTools.mu.Lock() + b.clientTools.agents[door.agent] = clientToolAgentSets{} + b.clientTools.mu.Unlock() + for i := 0; i < 2; i++ { + p := clientToolParams(door.agent, fmt.Sprintf("set%d", i), "unused") + p.Tools = nil + for j := 0; j < session.ClientToolMaxPerSet; j++ { + p.Tools = append(p.Tools, clientToolParams(door.agent, p.Set, fmt.Sprintf("app_%d_%d", i, j)).Tools[0]) + } + if _, err := b.SetClientTools(p); err != nil { + t.Fatal(err) + } + } + if _, err := b.SetClientTools(clientToolParams(door.agent, "overflow", "app_extra")); err == nil { + t.Fatal("65th tool accepted") + } + b.locked(func() { b.agents[door.agent].live.conn.Conv = &remote.Agent{} }) + if _, err := b.SetClientTools(clientToolParams(door.agent, "remote", "app_remote")); err == nil || !strings.Contains(err.Error(), "client tools need a conversation this engine runs") { + t.Fatal(err) + } + b.locked(func() { b.agents[door.agent].live.conn.Assembled = true }) + if _, err := b.SetClientTools(appwire.ClientToolsSetParams{Agent: door.agent, Set: "set0"}); err != nil { + t.Fatalf("local transport proxy refused: %v", err) + } + b.locked(func() { b.agents[door.agent].live = nil }) + if _, err := b.SetClientTools(appwire.ClientToolsSetParams{Agent: door.agent, Set: "set0"}); err != nil { + t.Fatalf("cold attachment opened a conversation: %v", err) + } +} + +// Contract (d), source contract (b): pending joins retain the source without emitting. +func TestClientToolsPendingJoinAndCancelledAnswer(t *testing.T) { + b, door, emitter := clientToolBackend(t) + call := clientToolAttach(t, b, door) + call.SourceMessage = "msg-pending" + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := clientToolStart(ctx, door, call) + event := clientToolAwait(t, emitter.seen) + if event.SourceMessageID != call.SourceMessage { + t.Fatalf("pending event lost source: %+v", event) + } + r := &b.clientTools + r.mu.Lock() + first := r.pending[event.Call] + joined, _, emit := r.beginLocked(ctx, door.agent, event.Call, call) + r.mu.Unlock() + if first != joined || emit { + t.Fatal("duplicate dispatch did not join") + } + cancel() + ack, err := b.ClientToolResult(appwire.ClientToolResultParams{Call: event.Call, Result: json.RawMessage(`true`)}) + if err != nil || ack.Accepted { + t.Fatalf("cancelled turn accepted an answer: %+v %v", ack, err) + } + if answer := clientToolAwait(t, done); !answer.IsError || !strings.Contains(answer.Text, "turn ended") { + t.Fatal(answer) + } +} + +// Contract (h): local proxy inventories include dynamically armed engine names. +func TestClientToolsBuiltinInventoryFromLocalEngine(t *testing.T) { + b, door, _ := clientToolBackend(t) + clientToolAttach(t, b, door) + door.ClientToolBuiltins([]string{"app_reserved"}) + if _, err := b.SetClientTools(clientToolParams(door.agent, "other", "app_reserved")); err == nil || !strings.Contains(err.Error(), "app_reserved") { + t.Fatal(err) + } +} + +// Contract (d): replay memory expires and never exceeds its entry budget. +func TestClientToolsSettledMemoryBoundAndExpiry(t *testing.T) { + r := &appClientTools{pending: make(map[string]*clientToolPending)} + for i := 0; i < clientToolSettledMax+5; i++ { + id := fmt.Sprint(i) + pending := &clientToolPending{done: make(chan struct{})} + r.pending[id] = pending + r.settleLocked(id, pending, session.ClientToolAnswer{Text: id}) + } + if len(r.settled) != clientToolSettledMax || len(r.order) != clientToolSettledMax { + t.Fatal("unbounded replay memory") + } + r.pruneLocked(time.Now().Add(clientToolSettledTTL)) + if len(r.settled) != 0 { + t.Fatal("expired answers remain") + } + if clientToolCallID("a", "p") == clientToolCallID("b", "p") || clientToolCallID("a", "") == clientToolCallID("a", "") { + t.Fatal("call identity collision") + } +} + +// Contract (b): compact JSON is bounded with the exact number of dropped bytes. +func TestClientToolsBoundedOutputAndKinds(t *testing.T) { + data := []byte(`"` + strings.Repeat("é", session.ClientToolOutputMax) + `"`) + text := session.BoundClientToolOutput(string(data)) + if !strings.HasSuffix(text, fmt.Sprintf("[cut: %d bytes dropped]", len(data)-(session.ClientToolOutputMax-1))) { + t.Fatalf("cut marker: %.80s", text[len(text)-80:]) + } + b, door, _ := clientToolBackend(t) + clientToolAttach(t, b, door) + if category := b.clientToolEventCategory(b.agents[door.agent], session.Event{Tool: "app_note"}); category != session.ActionEdit { + t.Fatal(category) + } + if kind := appToolKind("app_note", "", session.ActionEdit); kind != appwire.ToolEdit { + t.Fatal(kind) + } +} + +// Contract (i): the feature remains usable by any app, with no domain vocabulary. +func TestClientToolsSourcesStayGeneric(t *testing.T) { + for _, path := range []string{"../../internal/appwire/client_tools.go", "../../internal/appwire/client_tools_test.go", + "../../internal/session/client_tools.go", "../../internal/session/client_tools_test.go", "appclienttools.go", "appclienttools_test.go", + "../../internal/manual/chat/client-tools.md"} { + raw, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if strings.Contains(strings.ToLower(string(raw)), "work"+"stream") { + t.Errorf("%s contains domain vocabulary", path) + } + } +} diff --git a/cmd/codeaf/appcollectionkeep.go b/cmd/codeaf/appcollectionkeep.go new file mode 100644 index 0000000000..b64f4bfb65 --- /dev/null +++ b/cmd/codeaf/appcollectionkeep.go @@ -0,0 +1,39 @@ +package main + +import ( + "context" + "database/sql" + "errors" + "os" + "path/filepath" + "time" + + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/sqlitefile" + "github.com/Agent-Field/codeaf/internal/workspace" +) + +// A collection's reference makes a bare conversation intentional. Read-only mode +// also protects against the store disappearing between Stat and the SQLite open. +func collectionKeepsConversation(dir string) bool { + path := home.Join("v3", "collections.db") + if _, err := os.Stat(path); errors.Is(err, os.ErrNotExist) { + return false + } else if err != nil { + return true + } + db, err := sql.Open("sqlite", sqlitefile.URI(path, "mode=ro&_pragma=busy_timeout(100)")) + if err != nil { + return true + } + defer db.Close() + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + var kept bool + // These membership columns exist in every collections schema. EXISTS avoids + // loading collection metadata or interpreting any newer collection features. + err = db.QueryRowContext(ctx, `SELECT EXISTS(SELECT 1 FROM memberships m + JOIN collections c ON c.id=m.collection_id WHERE m.kind=? AND m.ref_id=? AND m.session_id='')`, + workspace.ConversationKind, filepath.Base(filepath.Clean(dir))).Scan(&kept) + return kept || err != nil +} diff --git a/cmd/codeaf/appcollectionkeep_test.go b/cmd/codeaf/appcollectionkeep_test.go new file mode 100644 index 0000000000..41e282c152 --- /dev/null +++ b/cmd/codeaf/appcollectionkeep_test.go @@ -0,0 +1,745 @@ +package main + +import ( + "bytes" + "context" + "os" + "path/filepath" + "reflect" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/agentdid" + "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/teams" + "github.com/Agent-Field/codeaf/internal/workspace" +) + +func collectionKeepEnvironment(t *testing.T) (string, string) { + t.Helper() + root := t.TempDir() + t.Setenv(home.EnvVar, filepath.Join(root, "state")) + t.Setenv(config.ProfileDirEnv, filepath.Join(root, "profile")) + bucket, err := v3ProjectDir(root) + if err != nil { + t.Fatal(err) + } + return root, bucket +} + +// STAB2 E-B1 +func TestACollectionHeldBlankIsNeverTheConversationALaunchResumes(t *testing.T) { + for _, test := range []struct { + name string + spoken bool + held bool + unreadable bool + team bool + }{ + {name: "a held blank beside an older spoken conversation", spoken: true, held: true}, + {name: "only a held blank", held: true}, + {name: "an unreadable store beside an older spoken conversation", spoken: true, unreadable: true}, + {name: "only a blank with an unreadable store", unreadable: true}, + {name: "a blank held by both a team and a collection", held: true, team: true}, + } { + t.Run(test.name, func(t *testing.T) { + root, bucket := collectionKeepEnvironment(t) + blank := writeV3Session(t, bucket, "blank-agent", "", time.Time{}) + older := time.Date(2026, 10, 28, 9, 0, 0, 0, time.UTC) + newer := older.Add(24 * time.Hour) + if err := os.Chtimes(blank, newer, newer); err != nil { + t.Fatal(err) + } + var conversation string + if test.spoken { + conversation = writeV3Session(t, bucket, "spoken-agent", "keep these notes", older) + } + path := home.Join("v3", "collections.db") + if test.held { + store, err := workspace.Open(path) + if err != nil { + t.Fatal(err) + } + collection, err := store.Create(context.Background(), "Kept conversations") + if err != nil { + t.Fatal(err) + } + if err := store.Add(context.Background(), collection.ID, workspace.Ref{Kind: workspace.ConversationKind, ID: filepath.Base(blank)}); err != nil { + t.Fatal(err) + } + if err := store.Close(); err != nil { + t.Fatal(err) + } + } + if test.unreadable { + if err := os.WriteFile(path, []byte("unreadable database"), 0o600); err != nil { + t.Fatal(err) + } + } + if test.team { + transcript := session.Place{Dir: blank}.Transcript() + if err := teams.Save(config.ProfileDir(), []teams.Team{{ID: "aaaaaaaaaaaa", Name: "Notes", Manager: transcript, + Members: []teams.Member{{Key: transcript, File: transcript}}, + }}); err != nil { + t.Fatal(err) + } + } + + spoken, empty := v3ScanBucket(bucket) + if test.spoken { + if len(spoken) != 1 || spoken[0].dir != conversation { + t.Fatalf("resume candidates: %+v, want only %s", spoken, conversation) + } + } else if len(spoken) != 0 { + t.Fatalf("a blank became a resume candidate: %+v", spoken) + } + if len(empty) != 0 { + t.Fatalf("a kept blank was offered for reuse: %+v", empty) + } + folder, resumed, found := v3PickFolder(spoken, empty, true) + if test.spoken { + if !found || !resumed || folder.dir != conversation { + t.Fatalf("launch picked %+v, resumed=%t found=%t", folder, resumed, found) + } + } else if found || resumed { + t.Fatalf("launch did not ask for a fresh identity: %+v, resumed=%t found=%t", folder, resumed, found) + } + want := "" + if test.spoken { + want = session.Place{Dir: conversation}.Transcript() + } + if got := v3LatestTranscript(root); got != want { + t.Fatalf("host discovery found %q, want %q", got, want) + } + // Recheck a stale cleanup candidate as well as the scan's own pile. + v3ReapEmpty([]v3Folder{{dir: blank}}, "") + if _, err := os.Stat(blank); err != nil { + t.Fatalf("cleanup removed a kept blank: %v", err) + } + }) + } +} + +func TestABlankConversationCanBeReapedAfterItsLastCollectionReferenceIsRemoved(t *testing.T) { + for _, withSpoken := range []bool{false, true} { + name := "only a detached blank" + if withSpoken { + name = "a detached blank beside an older spoken conversation" + } + t.Run(name, func(t *testing.T) { + root, bucket := collectionKeepEnvironment(t) + dir := writeV3Session(t, bucket, "reserved-agent", "", time.Time{}) + older := time.Date(2026, 10, 28, 9, 0, 0, 0, time.UTC) + newer := older.Add(24 * time.Hour) + if err := os.Chtimes(dir, newer, newer); err != nil { + t.Fatal(err) + } + var conversation string + wantSpoken := 0 + if withSpoken { + conversation = writeV3Session(t, bucket, "spoken-agent", "keep these notes", older) + wantSpoken = 1 + } + store, err := workspace.Open(home.Join("v3", "collections.db")) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = store.Close() }) + collection, err := store.Create(context.Background(), "Reserved") + if err != nil { + t.Fatal(err) + } + ref := workspace.Ref{Kind: workspace.ConversationKind, ID: filepath.Base(dir)} + if err := store.Add(context.Background(), collection.ID, ref); err != nil { + t.Fatal(err) + } + spoken, empty := v3ScanBucket(bucket) + if len(spoken) != wantSpoken || withSpoken && spoken[0].dir != conversation || len(empty) != 0 || v3EmptySession(dir) { + t.Fatalf("a held blank was offered for resume or cleanup: spoken=%+v empty=%+v", spoken, empty) + } + if err := store.Remove(context.Background(), collection.ID, ref); err != nil { + t.Fatal(err) + } + spoken, empty = v3ScanBucket(bucket) + if len(spoken) != wantSpoken || withSpoken && spoken[0].dir != conversation || len(empty) != 1 || empty[0].dir != dir || !v3EmptySession(dir) { + t.Fatalf("a detached blank was still kept: spoken=%+v empty=%+v", spoken, empty) + } + want := dir + if withSpoken { + want = conversation + } + folder, resumed, found := v3PickFolder(spoken, empty, false) + if !found || resumed != withSpoken || folder.dir != want { + t.Fatalf("host discovery picked %+v, resumed=%t found=%t", folder, resumed, found) + } + if got := v3LatestTranscript(root); got != (session.Place{Dir: want}).Transcript() { + t.Fatalf("host discovery found %q, want the transcript in %s", got, want) + } + folder, resumed, found = v3PickFolder(spoken, empty, true) + if withSpoken { + if !found || !resumed || folder.dir != conversation { + t.Fatalf("launch picked %+v, resumed=%t found=%t", folder, resumed, found) + } + } else if found || resumed { + t.Fatalf("launch did not ask for a fresh identity: %+v, resumed=%t found=%t", folder, resumed, found) + } + v3ReapEmpty(empty, "") + if _, err := os.Stat(dir); !os.IsNotExist(err) { + t.Fatalf("cleanup kept a detached blank: %v", err) + } + }) + } +} + +// Contract (g): collection-held blanks survive launch and the app's cleanup. +func TestCollectionHeldConversationSurvivesEveryBlankCleanup(t *testing.T) { + _, bucket := collectionKeepEnvironment(t) + held := writeV3Session(t, bucket, "collection-held", "", time.Time{}) + unheld := writeV3Session(t, bucket, "unheld", "", time.Time{}) + path := home.Join("v3", "collections.db") + store, err := workspace.Open(path) + if err != nil { + t.Fatal(err) + } + collection, err := store.Create(context.Background(), "Kept conversations") + if err != nil { + t.Fatal(err) + } + if err := store.Add(context.Background(), collection.ID, workspace.Ref{Kind: workspace.ConversationKind, ID: filepath.Base(held)}); err != nil { + t.Fatal(err) + } + if err := store.Close(); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + beforeInfo, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if !v3EmptyUnassignedSession(held) { + t.Fatal("a collection reference changed whether the conversation is blank") + } + if v3EmptySession(held) { + t.Fatal("collection-held conversation is litter") + } + if !v3EmptyUnassignedSession(unheld) { + t.Fatal("unheld empty conversation was retained") + } + v3ReapEmpty([]v3Folder{{dir: held}, {dir: unheld}}, "") + if _, err := os.Stat(held); err != nil { + t.Fatal("launch reap removed collection-held conversation") + } + if _, err := os.Stat(unheld); !os.IsNotExist(err) { + t.Fatalf("launch reap kept unheld conversation: %v", err) + } + b := newFakeBackend(newAppRecorder(), nil) + b.profileDir = config.ProfileDir() + row, ok := appRowOnDisk(filepath.Base(held)) + if !ok { + t.Fatal("bare row missing") + } + b.remember(row) + a := b.agent(row.ID) + b.locked(func() { a.minted = true }) + if b.reapBlank(a) { + t.Fatal("app cleanup removed collection-held conversation") + } + if err := b.Close(); err != nil { + t.Fatal(err) + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + afterInfo, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(before, after) || !beforeInfo.ModTime().Equal(afterInfo.ModTime()) { + t.Fatal("blank cleanup wrote collections.db") + } +} + +// Contract (g): the real launch-time sweep keeps collection-held litter and unreadable stores. +func TestCollectionHeldConversationSurvivesBackgroundSweep(t *testing.T) { + for _, unreadable := range []bool{false, true} { + name := "held and unheld" + if unreadable { + name = "unreadable store" + } + t.Run(name, func(t *testing.T) { + root, bucket := collectionKeepEnvironment(t) + held := writeV3Session(t, bucket, "collection-held", "", time.Time{}) + unheld := writeV3Session(t, bucket, "unheld", "", time.Time{}) + old := time.Now().Add(-8 * 24 * time.Hour) + for _, dir := range []string{held, unheld} { + meta := session.Meta{ID: filepath.Base(dir), Workspace: root, LaunchDir: root, Created: old} + if err := session.SaveMeta(dir, meta); err != nil { + t.Fatal(err) + } + if err := os.Chtimes(dir, old, old); err != nil { + t.Fatal(err) + } + } + path := home.Join("v3", "collections.db") + store, err := workspace.Open(path) + if err != nil { + t.Fatal(err) + } + collection, err := store.Create(context.Background(), "Kept conversations") + if err != nil { + t.Fatal(err) + } + if err := store.Add(context.Background(), collection.ID, workspace.Ref{Kind: workspace.ConversationKind, ID: filepath.Base(held)}); err != nil { + t.Fatal(err) + } + if err := store.Close(); err != nil { + t.Fatal(err) + } + if unreadable { + if err := os.WriteFile(path, []byte("unreadable database"), 0o600); err != nil { + t.Fatal(err) + } + } + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + info, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + proc := &v3Process{} + proc.startPlaceSweep() + t.Cleanup(proc.stopPlaceSweep) + select { + case <-proc.sweepDone: + case <-time.After(3 * time.Second): + t.Fatal("background sweep did not finish") + } + if _, err := os.Stat(held); err != nil { + t.Fatalf("background sweep removed held conversation: %v", err) + } + _, err = os.Stat(unheld) + if unreadable && err != nil { + t.Fatalf("background sweep discarded an uncertain conversation: %v", err) + } + if !unreadable && !os.IsNotExist(err) { + t.Fatalf("background sweep kept unheld control: %v", err) + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + afterInfo, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if !bytes.Equal(before, after) || !info.ModTime().Equal(afterInfo.ModTime()) { + t.Fatal("background sweep wrote collections.db") + } + }) + } +} + +// Contract (g): missing means unheld; every unreadable database means keep. +func TestCollectionKeepMissingUnreadableAndNoWrites(t *testing.T) { + _, bucket := collectionKeepEnvironment(t) + dir := writeV3Session(t, bucket, "empty-chat", "", time.Time{}) + path := home.Join("v3", "collections.db") + if collectionKeepsConversation(dir) { + t.Fatal("missing store retained conversation") + } + if _, err := os.Stat(path); !os.IsNotExist(err) { + t.Fatal("read created a store") + } + if err := os.MkdirAll(filepath.Dir(path), 0o700); err != nil { + t.Fatal(err) + } + bad := []byte("not a SQLite database") + if err := os.WriteFile(path, bad, 0o600); err != nil { + t.Fatal(err) + } + if !collectionKeepsConversation(dir) || v3EmptySession(dir) { + t.Fatal("uncertainty discarded a conversation") + } + got, err := os.ReadFile(path) + if err != nil || !bytes.Equal(got, bad) { + t.Fatal("read repaired or migrated a corrupt store") + } + if err := os.Remove(path); err != nil { + t.Fatal(err) + } + if err := os.Mkdir(path, 0o700); err != nil { + t.Fatal(err) + } + if !collectionKeepsConversation(dir) { + t.Fatal("unopenable store discarded a conversation") + } +} + +// STAB2 E-M1 +func TestACollectionHeldBlankAgentIsLiveAfterRestart(t *testing.T) { + for _, title := range []string{"Reserved notes", ""} { + name := "a saved title" + if title == "" { + name = "no saved title" + } + t.Run(name, func(t *testing.T) { + root, bucket := collectionKeepEnvironment(t) + const id = "reserved-agent" + dir := writeV3Session(t, bucket, id, "", time.Time{}) + if err := session.SaveMeta(dir, session.Meta{ID: id, Workspace: root, Title: title, + Created: time.Date(2026, 10, 29, 9, 0, 0, 0, time.UTC), + }); err != nil { + t.Fatal(err) + } + store, err := workspace.Open(home.Join("v3", "collections.db")) + if err != nil { + t.Fatal(err) + } + collection, err := store.Create(context.Background(), "Reserved") + if err != nil { + t.Fatal(err) + } + ref := workspace.Ref{Kind: workspace.ConversationKind, ID: id} + if err := store.Add(context.Background(), collection.ID, ref); err != nil { + t.Fatal(err) + } + if err := store.Close(); err != nil { + t.Fatal(err) + } + + opened := 0 + b := newFakeBackend(newAppRecorder(), func(ask appOpenAsk) (*appConn, error) { + opened++ + return refuseAppOpen(ask) + }) + t.Cleanup(func() { _ = b.Close() }) + b.profileDir = config.ProfileDir() + checkUnlisted := func() { + t.Helper() + list, err := b.ListAgents("") + if err != nil { + t.Fatal(err) + } + if _, found := listsAgent(list, id); found { + t.Fatal("agents.list included a blank conversation") + } + } + checkUnlisted() + open, err := b.OpenCollection(collection.ID, 1, 500, "") + if err != nil || len(open.Members) != 1 { + t.Fatalf("collections.open: %+v, %v", open, err) + } + member := open.Members[0] + if member.ID != id || member.Target.State != appwire.TargetLive || member.Target.Title != title { + t.Fatalf("collections.open member: %+v", member) + } + expanded, err := b.ExpandCollection(collection.ID, 3, 200) + if err != nil || expanded.Missing != 0 || expanded.Unavailable != 0 || len(expanded.Refs) != 1 { + t.Fatalf("collections.expand: %+v, %v", expanded, err) + } + live := expanded.Refs[0] + if live.ID != id || live.Target.State != appwire.TargetLive || live.Target.Title != title { + t.Fatalf("collections.expand member: %+v", live) + } + checkUnlisted() + if opened != 0 { + t.Fatalf("resolving collection members opened %d conversations", opened) + } + + missing := appwire.Ref{Kind: appwire.RefConversation, ID: "missing-agent"} + if _, err := b.AddToCollection(collection.ID, []appwire.Member{{Ref: missing}}); err != nil { + t.Fatal(err) + } + open, err = b.OpenCollection(collection.ID, 1, 500, "") + if err != nil || len(open.Members) != 2 { + t.Fatalf("collections.open with a missing agent: %+v, %v", open, err) + } + foundMissing := false + for _, member := range open.Members { + if member.ID == missing.ID { + foundMissing = true + if member.Target.State != appwire.TargetDeleted { + t.Fatalf("missing agent: %+v", member) + } + } + } + if !foundMissing { + t.Fatal("collections.open omitted the deleted member") + } + expanded, err = b.ExpandCollection(collection.ID, 3, 200) + if err != nil || expanded.Missing != 1 || expanded.Unavailable != 0 || len(expanded.Refs) != 1 || expanded.Refs[0].ID != id { + t.Fatalf("collections.expand with a missing agent: %+v, %v", expanded, err) + } + if opened != 0 { + t.Fatalf("resolving a missing member opened %d conversations", opened) + } + }) + } +} + +// STAB2-fix R4 +func TestACollectionReadNeverOpensAHeldBlankToResolveItsChildren(t *testing.T) { + for _, method := range []string{"collections.open", "collections.expand"} { + t.Run(method, func(t *testing.T) { + root, bucket := collectionKeepEnvironment(t) + const id = "reserved-agent" + dir := writeV3Session(t, bucket, id, "", time.Time{}) + if err := session.SaveMeta(dir, session.Meta{ID: id, Workspace: root, Title: "Reserved notes", + Created: time.Date(2026, 10, 29, 9, 0, 0, 0, time.UTC), + }); err != nil { + t.Fatal(err) + } + transcript := session.Place{Dir: dir}.Transcript() + if err := teams.Update(config.ProfileDir(), func(f *teams.File) error { + crew := teams.NewID() + f.Teams = append(f.Teams, teams.Team{ID: crew, Name: "Notes", Made: time.Now()}) + if err := f.AddMember(crew, teams.Member{Key: transcript, File: transcript, Where: root, Word: "notes", Handle: "notes"}); err != nil { + return err + } + return f.SetManager(crew, transcript) + }); err != nil { + t.Fatal(err) + } + store, err := workspace.Open(home.Join("v3", "collections.db")) + if err != nil { + t.Fatal(err) + } + collection, err := store.Create(context.Background(), "Reserved") + if err != nil { + t.Fatal(err) + } + refs := []workspace.Ref{ + {Kind: workspace.ConversationKind, ID: id}, + {Kind: workspace.TaskKind, ID: "1", SessionID: id}, + {Kind: workspace.ProcessKind, ID: id + ":p1", SessionID: id}, + } + // Reads visit newest members first, so resolve the conversation's + // fallback before either child asks for its owner. + if _, _, err := store.AddRefs(context.Background(), collection.ID, []workspace.Addition{ + {Ref: refs[0], AddedAt: 3}, + {Ref: refs[1], AddedAt: 2}, + {Ref: refs[2], AddedAt: 1}, + }); err != nil { + t.Fatal(err) + } + if err := store.Close(); err != nil { + t.Fatal(err) + } + + var opened atomic.Int32 + b := newFakeBackend(newAppRecorder(), func(ask appOpenAsk) (*appConn, error) { + opened.Add(1) + return refuseAppOpen(ask) + }) + t.Cleanup(func() { + _ = b.Close() + if got := opened.Load(); got != 0 { + t.Errorf("resolving collection members opened %d conversations", got) + } + }) + b.profileDir = config.ProfileDir() + list, err := b.ListAgents("") + if err != nil { + t.Fatal(err) + } + if _, found := listsAgent(list, id); found { + t.Fatal("agents.list included a blank conversation") + } + if method == "collections.open" { + open, err := b.OpenCollection(collection.ID, 1, 500, "") + if err != nil || len(open.Members) != len(refs) { + t.Fatalf("collections.open: %+v, %v", open, err) + } + for i, member := range open.Members { + if member.Kind != string(refs[i].Kind) || member.ID != refs[i].ID || member.SessionID != refs[i].SessionID { + t.Fatalf("collections.open member %d: %+v", i, member) + } + state := appwire.TargetDeleted + if i == 0 { + state = appwire.TargetLive + if member.Target.Title != "Reserved notes" || !reflect.DeepEqual(member.Target.In, []string{"Notes"}) { + t.Fatalf("held blank conversation: %+v", member) + } + } + if member.Target.State != state { + t.Fatalf("collections.open member: %+v, want %s", member, state) + } + } + } else { + expanded, err := b.ExpandCollection(collection.ID, 3, 200) + if err != nil || expanded.Missing != 2 || expanded.Unavailable != 0 || len(expanded.Refs) != 1 { + t.Fatalf("collections.expand: %+v, %v", expanded, err) + } + live := expanded.Refs[0] + if live.Kind != appwire.RefConversation || live.ID != id || live.Target.State != appwire.TargetLive || live.Target.Title != "Reserved notes" || !reflect.DeepEqual(live.Target.In, []string{"Notes"}) { + t.Fatalf("collections.expand member: %+v", live) + } + } + }) + } +} + +// STAB2-fix R1 +func TestAPermanentlyDeletedConversationStaysDeletedInItsCollection(t *testing.T) { + for _, pending := range []bool{false, true} { + name := "completed deletion" + if pending { + name = "pending deletion" + } + t.Run(name, func(t *testing.T) { + root, bucket := collectionKeepEnvironment(t) + const id = "deleted-agent" + dir := writeV3Session(t, bucket, id, "saved notes", time.Date(2026, 10, 29, 9, 0, 0, 0, time.UTC)) + meta, err := session.LoadMeta(dir) + if err != nil { + t.Fatal(err) + } + meta.Workspace, meta.Title = root, "Deleted notes" + if err := session.SaveMeta(dir, meta); err != nil { + t.Fatal(err) + } + store, err := workspace.Open(home.Join("v3", "collections.db")) + if err != nil { + t.Fatal(err) + } + collection, err := store.Create(context.Background(), "Saved conversations") + if err != nil { + t.Fatal(err) + } + ref := workspace.Ref{Kind: workspace.ConversationKind, ID: id} + if err := store.Add(context.Background(), collection.ID, ref); err != nil { + t.Fatal(err) + } + if err := store.Close(); err != nil { + t.Fatal(err) + } + + transcript := session.Place{Dir: dir}.Transcript() + if pending { + // A staged deletion keeps its cleanup receipt and pending journal. + if err := os.WriteFile(filepath.Join(dir, ".delete-task-cleanup.json"), []byte("{}"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, teams.ConversationDeletedFile), []byte("deleted\n"), 0o600); err != nil { + t.Fatal(err) + } + if err := os.Rename(transcript, transcript+".delete-pending"); err != nil { + t.Fatal(err) + } + if row, ok := session.ReadRows([]string{transcript})[filepath.Clean(transcript)]; !ok || !row.DeletionPending { + t.Fatalf("pending deletion fixture: %+v, found=%t", row, ok) + } + } else { + if err := session.DeleteConversationUnder(session.PlacesRoot(), config.ProfileDir(), transcript, nil, nil); err != nil { + t.Fatal(err) + } + } + if _, err := os.Stat(transcript); !os.IsNotExist(err) { + t.Fatalf("permanent deletion kept the transcript: %v", err) + } + if _, err := os.Stat(filepath.Join(dir, teams.ConversationDeletedFile)); err != nil { + t.Fatalf("permanent deletion lost its lifetime marker: %v", err) + } + if saved, err := session.LoadMeta(dir); err != nil || saved.ID != id || saved.Title != meta.Title { + t.Fatalf("permanent deletion lost its saved metadata: %+v, %v", saved, err) + } + + b := newFakeBackend(newAppRecorder(), nil) + t.Cleanup(func() { _ = b.Close() }) + b.profileDir = config.ProfileDir() + open, err := b.OpenCollection(collection.ID, 1, 500, "") + if err != nil || len(open.Members) != 1 { + t.Fatalf("collections.open: %+v, %v", open, err) + } + if member := open.Members[0]; member.ID != id || member.Target.State != appwire.TargetDeleted { + t.Fatalf("collections.open revived a permanently deleted conversation: %+v", member) + } + expanded, err := b.ExpandCollection(collection.ID, 3, 200) + if err != nil || expanded.Missing != 1 || expanded.Unavailable != 0 || len(expanded.Refs) != 0 { + t.Fatalf("collections.expand: %+v, %v", expanded, err) + } + + other, err := b.CreateCollection("Other conversations") + if err != nil { + t.Fatal(err) + } + from := appwire.Ref{Kind: appwire.RefConversation, ID: "another-agent"} + if _, err := b.AddToCollection(other.ID, []appwire.Member{{Ref: from}}); err != nil { + t.Fatal(err) + } + to := appwire.Ref{Kind: appwire.RefConversation, ID: id} + if _, err := b.RelinkCollection(other.ID, from, to); wireCode(err) != appwire.CodeNotFound { + t.Fatalf("relink to a permanently deleted conversation: %v", err) + } + open, err = b.OpenCollection(other.ID, 1, 500, "") + if err != nil || len(open.Members) != 1 || open.Members[0].ID != from.ID { + t.Fatalf("refused relink changed the collection: %+v, %v", open, err) + } + }) + } +} + +// Contract (g): a restart recovers every identity field from a never-spoken meta. +func TestCollectionHeldConversationIsFoundAndOpenedAfterRestart(t *testing.T) { + root, bucket := collectionKeepEnvironment(t) + const id = "reserved-agent" + dir := writeV3Session(t, bucket, id, "", time.Time{}) + did, err := agentdid.New() + if err != nil { + t.Fatal(err) + } + created := time.Date(2026, 10, 29, 9, 0, 0, 0, time.UTC) + meta := session.Meta{ID: id, Workspace: root, Owned: true, Title: "Reserved notes", Model: "vendor/model", Archived: true, SpentUSD: 1.25, Created: created, + AgentIdentity: &session.AgentIdentity{DID: did, Session: id}} + if err := session.SaveMeta(dir, meta); err != nil { + t.Fatal(err) + } + store, err := workspace.Open(home.Join("v3", "collections.db")) + if err != nil { + t.Fatal(err) + } + collection, err := store.Create(context.Background(), "Reserved") + if err != nil { + t.Fatal(err) + } + if err := store.Add(context.Background(), collection.ID, workspace.Ref{Kind: workspace.ConversationKind, ID: id}); err != nil { + t.Fatal(err) + } + _ = store.Close() + want := session.SessionRow{ID: id, AgentDID: did, Dir: dir, Transcript: (session.Place{Dir: dir}).Transcript(), Workspace: root, Owned: true, + Title: meta.Title, Model: meta.Model, Archived: true, Spend: meta.SpentUSD, At: created, Created: created} + row, ok := appRowOnDisk(id) + if !ok || !reflect.DeepEqual(row, want) { + t.Fatalf("identity after restart: %+v want %+v", row, want) + } + opened := 0 + b := newFakeBackend(newAppRecorder(), func(ask appOpenAsk) (*appConn, error) { + opened++ + if ask.Transcript != want.Transcript || ask.Workspace != root || ask.Model != meta.Model { + t.Errorf("open identity: %+v", ask) + } + return &appConn{Conv: &fakeConversation{model: ask.Model}, Workspace: ask.Workspace, Transcript: ask.Transcript, Release: func() {}}, nil + }) + t.Cleanup(func() { _ = b.Close() }) + found, err := b.find(id) + if err != nil || found.view.DID != did || found.view.Title != meta.Title || opened != 0 { + t.Fatalf("find: %+v %v", found, err) + } + view, err := b.OpenAgent(id) + if err != nil || view.ID != id || view.DID != did || view.Title != meta.Title || view.Model != meta.Model || opened != 1 { + t.Fatalf("agents.open: %+v %v", view, err) + } + if view.State == appwire.StateWorking { + t.Fatal("opening started a turn") + } +} diff --git a/cmd/codeaf/appcollections.go b/cmd/codeaf/appcollections.go index 4471d60792..1d25860c2c 100644 --- a/cmd/codeaf/appcollections.go +++ b/cmd/codeaf/appcollections.go @@ -10,12 +10,12 @@ package main // // 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, with a reason that -// names the owner and why it could not be asked. +// folder lookup per unlisted conversation id, one crews listing, one task list +// and one process list per distinct listed 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. A member +// whose owner cannot be asked is "unknown", which says nothing about the member +// itself, with a reason naming the owner and why it could not be asked. import ( "bufio" @@ -34,6 +34,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/teams" "github.com/Agent-Field/codeaf/internal/workspace" ) @@ -123,11 +124,11 @@ func (b *appBackend) collectionChanged(id string, deleted bool) { } func appStoreRef(r appwire.Ref) workspace.Ref { - return workspace.Ref{Kind: workspace.Kind(r.Kind), ID: r.ID, SessionID: r.SessionID} + return workspace.Ref{Kind: workspace.Kind(r.Kind), ID: r.ID, SessionID: r.SessionID, Factory: r.Factory} } func appWireRef(r workspace.Ref) appwire.Ref { - return appwire.Ref{Kind: string(r.Kind), ID: r.ID, SessionID: r.SessionID} + return appwire.Ref{Kind: string(r.Kind), ID: r.ID, SessionID: r.SessionID, Factory: r.Factory} } // appMoment is a stored moment on the wire: absent when unknown. @@ -308,6 +309,7 @@ func (b *appBackend) OpenCollection(id string, depth, limit int, after string) ( return appwire.CollectionOpenResult{}, appCollectionError(err) } r := newCollectionResolver(b) + r.prepareFactoryTree(tree.Level) var nodes func(level workspace.Level) []appwire.Node nodes = func(level workspace.Level) []appwire.Node { out := make([]appwire.Node, 0, len(level.Members)) @@ -359,6 +361,7 @@ func (b *appBackend) ExpandCollection(id string, depth, limit int) (appwire.Coll Missing: e.Deleted, Unread: e.Unread, Collections: e.Collections, More: e.More, } r := newCollectionResolver(b) + r.prepareFactoryRefs(e.Refs) for _, m := range e.Refs { target := r.target(m.Ref) switch target.State { @@ -490,18 +493,20 @@ func appUnaskedReason(owner string, err error) string { type collectionResolver struct { b *appBackend - agentsRead bool - agents map[string]appwire.Agent - agentsErr error + agentsRead bool + agents map[string]appwire.Agent + agentsErr error + savedAgents map[string]appwire.Agent 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 + tasks map[string]appOwned[appwire.Task] + procs map[string]appOwned[appwire.Process] + git map[string]*appGitTree + factoryTargets map[workspace.Ref]appwire.Target } // appOwned is one agent's tasks or processes by wire id, or why they could @@ -517,8 +522,13 @@ func newCollectionResolver(b *appBackend) *collectionResolver { func (r *collectionResolver) target(ref workspace.Ref) appwire.Target { switch ref.Kind { + case workspace.FactoryJobKind: + if _, ok := r.factoryTargets[ref]; !ok { + r.prepareFactoryRefs([]workspace.Member{{Ref: ref}}) + } + return r.factoryTargets[ref] case workspace.ConversationKind: - a, not := r.agent(ref.ID) + a, not := r.conversation(ref.ID) if not != nil { return *not } @@ -573,8 +583,8 @@ func (r *collectionResolver) target(ref workspace.Ref) appwire.Target { return appwire.Target{State: appwire.TargetDeleted} } -// 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). +// agent is one agent off the 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 @@ -589,13 +599,42 @@ func (r *collectionResolver) agent(id string) (appwire.Agent, *appwire.Target) { t := appUnknown("the conversation list", r.agentsErr) return appwire.Agent{}, &t } - a, ok := r.agents[id] - if !ok { + a := r.agents[id] + if a.ID == "" { return appwire.Agent{}, &appwire.Target{State: appwire.TargetDeleted} } return a, nil } +// conversation also checks saved folders for explicitly referenced agents. +// Its cache stays separate so child references still require a listed owner. +func (r *collectionResolver) conversation(id string) (appwire.Agent, *appwire.Target) { + a, not := r.agent(id) + if not == nil || not.State != appwire.TargetDeleted { + return a, not + } + if r.savedAgents == nil { + r.savedAgents = map[string]appwire.Agent{} + } + a, ok := r.savedAgents[id] + if !ok { + // Named blanks stay out of agents.list but can still be held by a + // collection. Cache misses too, since several members can name one agent. + if row, found := appRowOnDisk(id); found && !row.DeletionPending { + // Permanent deletion retains metadata, but its lifetime marker + // prevents the saved folder from becoming a live target again. + if _, err := os.Stat(filepath.Join(row.Dir, teams.ConversationDeletedFile)); errors.Is(err, fs.ErrNotExist) { + a = appRowView(row) + } + } + r.savedAgents[id] = a + } + if a.ID == "" { + return appwire.Agent{}, not + } + return a, nil +} + // appFindOwned is one task or process of an agent, read with list once per // agent per call, or the target it is when it is not there; owner names the // list in an unknown target's reason. diff --git a/cmd/codeaf/appcollections_factory.go b/cmd/codeaf/appcollections_factory.go new file mode 100644 index 0000000000..d5927afd69 --- /dev/null +++ b/cmd/codeaf/appcollections_factory.go @@ -0,0 +1,246 @@ +package main + +import ( + "context" + "errors" + "strconv" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/factory/api" + "github.com/Agent-Field/codeaf/internal/guard" + "github.com/Agent-Field/codeaf/internal/workspace" +) + +const ( + factoryReadTimeout = 3 * time.Second + factoryQueueFresh = 10 * time.Second + factoryQueueCacheMax = 32 + factoryMissingJobsMax = 20 + factoryReadParallel = 8 +) + +// collectionFactory belongs to one backend so cached queue reads cannot cross profiles. +type collectionFactory struct { + queue func(context.Context, string) (api.Queue, error) + job func(context.Context, string, int) (api.Job, error) + cacheMu sync.Mutex + cache map[string]factoryQueueRead + flights map[string]chan struct{} +} + +type factoryJob struct { + Factory string + Number int +} + +type factoryQueueRead struct { + at time.Time + jobs map[int]api.Job + err error +} + +// factoryQueue shares successful and failed reads briefly, including an +// in-flight read, so concurrent list/get calls cannot multiply Factory work. +func (b *appBackend) factoryQueue(ctx context.Context, factory string) (entry factoryQueueRead) { + state := &b.collectionFactory + entry, flight, readQueue := state.factoryQueueStart(factory, b.now()) + if flight == nil { + return entry + } + if !readQueue { + select { + case <-flight: + return b.factoryQueue(ctx, factory) + case <-ctx.Done(): + return factoryQueueRead{err: ctx.Err()} + } + } + // Complete ownership even when the guarded reader panics. Only a completed + // read replaces the unavailable entry, so a fault cannot publish success. + entry = factoryQueueRead{err: errors.New("the Factory queue read did not complete")} + defer func() { + entry.at = b.now() + state.factoryQueueFinish(factory, flight, entry) + }() + read := state.queue + if read == nil { + read = b.FactoryQueue + } + queue, err := read(ctx, factory) + jobs := map[int]api.Job{} + for _, j := range queue.Jobs { + jobs[j.ID] = j + } + entry = factoryQueueRead{err: err, jobs: jobs} + return entry +} + +func (state *collectionFactory) factoryQueueStart(factory string, now time.Time) (factoryQueueRead, chan struct{}, bool) { + state.cacheMu.Lock() + defer state.cacheMu.Unlock() + if entry, ok := state.cache[factory]; ok && now.Sub(entry.at) < factoryQueueFresh { + return entry, nil, false + } + if flight := state.flights[factory]; flight != nil { + return factoryQueueRead{}, flight, false + } + if state.flights == nil { + state.flights = map[string]chan struct{}{} + } + flight := make(chan struct{}) + state.flights[factory] = flight + return factoryQueueRead{}, flight, true +} + +func (state *collectionFactory) factoryQueueFinish(factory string, flight chan struct{}, entry factoryQueueRead) { + state.cacheMu.Lock() + defer state.cacheMu.Unlock() + if state.cache == nil { + state.cache = map[string]factoryQueueRead{} + } + if len(state.cache) >= factoryQueueCacheMax { + oldest := "" + var at time.Time + for id, v := range state.cache { + if oldest == "" || v.at.Before(at) { + oldest, at = id, v.at + } + } + delete(state.cache, oldest) + } + state.cache[factory] = entry + delete(state.flights, factory) + close(flight) +} + +type factoryJobReading struct { + job api.Job + err error + notFound bool +} + +// factoryJobReads gives each factory one concurrent queue read. Missing +// jobs share a global fallback allowance and the same overall deadline. +func (b *appBackend) factoryJobReads(jobs []factoryJob) map[factoryJob]factoryJobReading { + out := map[factoryJob]factoryJobReading{} + grouped := map[string][]factoryJob{} + for _, j := range jobs { + if _, seen := out[j]; !seen { + out[j] = factoryJobReading{err: context.DeadlineExceeded} + grouped[j.Factory] = append(grouped[j.Factory], j) + } + } + if len(grouped) == 0 { + return out + } + ctx, cancel := context.WithTimeout(context.Background(), factoryReadTimeout) + defer cancel() + var wg sync.WaitGroup + var mu sync.Mutex + missing := 0 + factoryMissingAllowed := func() bool { + mu.Lock() + defer mu.Unlock() + if missing >= factoryMissingJobsMax { + return false + } + missing++ + return true + } + factoryRememberRead := func(j factoryJob, read factoryJobReading) { + mu.Lock() + defer mu.Unlock() + out[j] = read + } + slots := make(chan struct{}, factoryReadParallel) + for factory, members := range grouped { + wg.Add(1) + guard.Go("app/collection-factory", func() { + defer wg.Done() + select { + case slots <- struct{}{}: + defer func() { <-slots }() + case <-ctx.Done(): + return + } + queue := b.factoryQueue(ctx, factory) + for _, j := range members { + job, ok := queue.jobs[j.Number] + err := queue.err + if err == nil && !ok { + allowed := factoryMissingAllowed() + switch { + case !allowed: + err = errors.New("the missing-job read allowance was reached") + case ctx.Err() != nil: + err = ctx.Err() + default: + read := b.collectionFactory.job + if read == nil { + read = b.FactoryJob + } + job, err = read(ctx, j.Factory, j.Number) + if err == nil && job.ID != j.Number { + err = errors.New("the Factory returned another job") + } + } + } + var wire *appwire.Error + notFound := queue.err == nil && errors.As(err, &wire) && wire.Code == appwire.CodeNotFound + factoryRememberRead(j, factoryJobReading{job: job, err: err, notFound: notFound}) + } + }) + } + wg.Wait() + return out +} + +// Collection resolution uses the same bounded batch reader, with each job +// asked once even when it appears through several nested collections. +func (r *collectionResolver) prepareFactoryRefs(members []workspace.Member) { + jobs := []factoryJob{} + refs := map[factoryJob]workspace.Ref{} + for _, member := range members { + if member.Kind != workspace.FactoryJobKind { + continue + } + number, _ := strconv.Atoi(member.ID) + job := factoryJob{Factory: member.Factory, Number: number} + jobs = append(jobs, job) + refs[job] = member.Ref + } + r.factoryTargets = map[workspace.Ref]appwire.Target{} + for job, read := range r.b.factoryJobReads(jobs) { + var target appwire.Target + if read.err != nil { + if read.notFound { + target = appwire.Target{State: appwire.TargetDeleted} + } else { + target = appUnknown("the Factory", read.err) + target.Status = "unknown" + } + } else { + target = appwire.Target{State: appwire.TargetLive, Title: read.job.Title, Status: read.job.State, Repo: read.job.Repo} + } + r.factoryTargets[refs[job]] = target + } +} + +func (r *collectionResolver) prepareFactoryTree(level workspace.Level) { + members := []workspace.Member{} + var collect func(workspace.Level) + collect = func(level workspace.Level) { + for _, node := range level.Members { + if node.Kind == workspace.FactoryJobKind { + members = append(members, node.Member) + } + if node.Children != nil { + collect(*node.Children) + } + } + } + collect(level) + r.prepareFactoryRefs(members) +} diff --git a/cmd/codeaf/appcollections_factory_test.go b/cmd/codeaf/appcollections_factory_test.go new file mode 100644 index 0000000000..0530e2c7e7 --- /dev/null +++ b/cmd/codeaf/appcollections_factory_test.go @@ -0,0 +1,319 @@ +package main + +import ( + "context" + "errors" + "fmt" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/factory/api" + "github.com/Agent-Field/codeaf/internal/guard" +) + +func collectionFactoryRef(factory string, job int) appwire.Member { + return appwire.Member{Ref: appwire.Ref{Kind: appwire.RefFactoryJob, ID: fmt.Sprint(job), Factory: factory}} +} + +func collectionFactoryCreate(t *testing.T, b *appBackend, name string, refs ...appwire.Member) string { + t.Helper() + c, err := b.CreateCollection(name) + if err != nil { + t.Fatal(err) + } + if len(refs) > 0 { + if _, err := b.AddToCollection(c.ID, refs); err != nil { + t.Fatal(err) + } + } + return c.ID +} + +// Contract (b), G1: the moved batch reader resolves nested refs once per job, +// reads distinct factories concurrently, and shares cache entries with expand. +func TestCollectionFactoryBatchCacheAndNestedTargets(t *testing.T) { + b, _, _, _ := collectionsBackend(t) + nested := collectionFactoryCreate(t, b, "Nested", collectionFactoryRef("f", 7), collectionFactoryRef("g", 9)) + root := collectionFactoryCreate(t, b, "Root", collectionFactoryRef("f", 7), appwire.Member{Ref: appwire.Ref{Kind: appwire.RefCollection, ID: nested}}) + entered := make(chan string, 2) + release := make(chan struct{}) + var releaseOnce sync.Once + defer releaseOnce.Do(func() { close(release) }) + var mu sync.Mutex + calls := map[string]int{} + b.collectionFactory.queue = func(ctx context.Context, factory string) (api.Queue, error) { + func() { mu.Lock(); defer mu.Unlock(); calls[factory]++ }() + entered <- factory + select { + case <-release: + case <-ctx.Done(): + return api.Queue{}, ctx.Err() + } + id := 7 + if factory == "g" { + id = 9 + } + return api.Queue{Jobs: []api.Job{{ID: id, Num: 1107, Title: "Recorded title", State: "backlog", Repo: "acme/api"}}}, nil + } + done := make(chan error, 1) + guard.Go("app/collection-factory-open-test", func() { + open, err := b.OpenCollection(root, 3, 200, "") + if err == nil { + var visit func([]appwire.Node) + found := 0 + visit = func(nodes []appwire.Node) { + for _, node := range nodes { + if node.Kind == appwire.RefFactoryJob { + found++ + if node.Target.State != appwire.TargetLive || node.Target.Title != "Recorded title" || node.Target.Status != "backlog" || node.Target.Repo != "acme/api" { + t.Errorf("target: %+v", node.Target) + } + } + if node.Children != nil { + visit(node.Children.Members) + } + } + } + visit(open.Members) + if found != 3 { + t.Errorf("resolved %d job refs, want 3", found) + } + } + done <- err + }) + for range 2 { + select { + case <-entered: + case <-time.After(time.Second): + t.Fatal("factories were not read concurrently") + } + } + releaseOnce.Do(func() { close(release) }) + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(time.Second): + t.Fatal("open did not finish") + } + if _, err := b.ExpandCollection(root, 3, 200); err != nil { + t.Fatal(err) + } + mu.Lock() + defer mu.Unlock() + if calls["f"] != 1 || calls["g"] != 1 { + t.Fatalf("queue calls: %v", calls) + } +} + +// Contract (b), G1: missing-job reads have a shared deadline and global bound; +// duplicate refs are asked once and unavailable queues never trigger fallbacks. +func TestCollectionFactoryMissingJobReadsAreBounded(t *testing.T) { + b, _, _, _ := collectionsBackend(t) + refs := []appwire.Member{} + for i := 1; i <= 25; i++ { + refs = append(refs, collectionFactoryRef("f", i)) + } + id := collectionFactoryCreate(t, b, "Missing", refs...) + queueReads, jobReads := 0, 0 + b.collectionFactory.queue = func(ctx context.Context, _ string) (api.Queue, error) { + queueReads++ + if deadline, ok := ctx.Deadline(); !ok || time.Until(deadline) > factoryReadTimeout { + t.Error("queue read has no bounded deadline") + } + return api.Queue{}, nil + } + b.collectionFactory.job = func(ctx context.Context, _ string, job int) (api.Job, error) { + jobReads++ + if _, ok := ctx.Deadline(); !ok { + t.Error("job read has no deadline") + } + return api.Job{ID: job, Num: 999, State: "backlog"}, nil + } + open, err := b.OpenCollection(id, 1, 200, "") + live, unknown := 0, 0 + for _, node := range open.Members { + switch node.Target.State { + case appwire.TargetLive: + live++ + case appwire.TargetUnknown: + unknown++ + } + } + if err != nil || queueReads != 1 || jobReads != 20 || live != 20 || unknown != 5 { + t.Fatalf("bounded fallback: %+v, %v; queue %d jobs %d", open, err, queueReads, jobReads) + } + b.collectionFactory.cache = nil + jobReads = 0 + b.collectionFactory.queue = func(context.Context, string) (api.Queue, error) { return api.Queue{}, errors.New("unreachable") } + open, err = b.OpenCollection(id, 1, 200, "") + if err != nil || jobReads != 0 { + t.Fatalf("unavailable queue triggered fallbacks: %v, %d", err, jobReads) + } + for _, node := range open.Members { + if node.Target.State != appwire.TargetUnknown || node.Target.Status != "unknown" { + t.Fatalf("unavailable target: %+v", node.Target) + } + } + b.collectionFactory.cache = nil + b.collectionFactory.queue = func(context.Context, string) (api.Queue, error) { return api.Queue{}, nil } + jobReads = 0 + reads := b.factoryJobReads([]factoryJob{{Factory: "f", Number: 7}, {Factory: "f", Number: 7}}) + if len(reads) != 1 || jobReads != 1 { + t.Fatalf("duplicate job read %d times: %v", jobReads, reads) + } +} + +// Contract (b), G1: a missing job is deleted, a queue error or a wrong returned +// job is unknown, and a short cache shares both successes and failures. +func TestCollectionFactoryDeletedUnknownAndCacheExpiry(t *testing.T) { + b, _, _, _ := collectionsBackend(t) + id := collectionFactoryCreate(t, b, "Targets", collectionFactoryRef("f", 7)) + clock := time.Now() + b.now = func() time.Time { return clock } + calls := 0 + b.collectionFactory.queue = func(context.Context, string) (api.Queue, error) { calls++; return api.Queue{}, nil } + b.collectionFactory.job = func(context.Context, string, int) (api.Job, error) { + return api.Job{}, appwire.Errorf(appwire.CodeNotFound, "gone") + } + assertTarget := func(state string) { + t.Helper() + open, err := b.OpenCollection(id, 1, 200, "") + if err != nil || len(open.Members) != 1 || open.Members[0].Target.State != state { + t.Fatalf("target: %+v, %v", open, err) + } + } + assertTarget(appwire.TargetDeleted) + b.collectionFactory.job = func(context.Context, string, int) (api.Job, error) { return api.Job{ID: 8}, nil } + assertTarget(appwire.TargetUnknown) + if calls != 1 { + t.Fatalf("queue was not shared: %d", calls) + } + clock = clock.Add(factoryQueueFresh) + b.collectionFactory.queue = func(context.Context, string) (api.Queue, error) { + calls++ + return api.Queue{}, errors.New("unreachable") + } + assertTarget(appwire.TargetUnknown) + assertTarget(appwire.TargetUnknown) + if calls != 2 { + t.Fatalf("failed queue cache: %d", calls) + } + clock = clock.Add(factoryQueueFresh) + b.collectionFactory.queue = func(context.Context, string) (api.Queue, error) { + calls++ + return api.Queue{Jobs: []api.Job{{ID: 7, Title: "Back", State: "running"}}}, nil + } + assertTarget(appwire.TargetLive) + if calls != 3 { + t.Fatalf("queue did not retry after expiry: %d", calls) + } +} + +// factoryQueueWaitContext signals when a coalesced reader selects on Done, +// so the panic cannot race ahead of the waiter entering the flight branch. +type factoryQueueWaitContext struct { + context.Context + waiting chan struct{} + once sync.Once +} + +func (c *factoryQueueWaitContext) Done() <-chan struct{} { + c.once.Do(func() { close(c.waiting) }) + return c.Context.Done() +} + +// Contract (b), G1: the moved guarded reader releases its flight on panic, +// caches the unavailable result and permits a successful retry after expiry. +func TestCollectionFactoryPanicFinishesFlight(t *testing.T) { + b, _, _, _ := collectionsBackend(t) + id := collectionFactoryCreate(t, b, "Panic", collectionFactoryRef("f", 7)) + var clock atomic.Int64 + clock.Store(time.Now().UnixMilli()) + b.now = func() time.Time { return time.UnixMilli(clock.Load()) } + entered, release := make(chan struct{}), make(chan struct{}) + var releaseOnce sync.Once + defer releaseOnce.Do(func() { close(release) }) + var calls atomic.Int32 + b.collectionFactory.queue = func(ctx context.Context, _ string) (api.Queue, error) { + if calls.Add(1) == 1 { + close(entered) + select { + case <-release: + panic("queue reader fault") + case <-ctx.Done(): + return api.Queue{}, ctx.Err() + } + } + return api.Queue{Jobs: []api.Job{{ID: 7, State: "backlog"}}}, nil + } + b.collectionFactory.job = func(context.Context, string, int) (api.Job, error) { + t.Error("failed queue triggered fallback") + return api.Job{}, errors.New("unexpected fallback") + } + owner := make(chan appwire.CollectionOpenResult, 1) + guard.Go("app/collection-factory-panic-owner-test", func() { + open, err := b.OpenCollection(id, 1, 200, "") + if err != nil { + t.Error(err) + } + owner <- open + }) + select { + case <-entered: + case <-time.After(time.Second): + t.Fatal("reader did not enter") + } + _, flight, owns := b.collectionFactory.factoryQueueStart("f", b.now()) + if flight == nil || owns { + t.Fatal("reader did not own a shared flight") + } + ctx, cancel := context.WithTimeout(context.Background(), factoryReadTimeout) + defer cancel() + waitCtx := &factoryQueueWaitContext{Context: ctx, waiting: make(chan struct{})} + waiter := make(chan factoryQueueRead, 1) + guard.Go("app/collection-factory-panic-waiter-test", func() { waiter <- b.factoryQueue(waitCtx, "f") }) + select { + case <-waitCtx.waiting: + case <-time.After(time.Second): + t.Fatal("waiter did not enter flight") + } + releaseOnce.Do(func() { close(release) }) + select { + case open := <-owner: + if len(open.Members) != 1 || open.Members[0].Target.State != appwire.TargetUnknown { + t.Fatalf("panic target: %+v", open) + } + case <-time.After(time.Second): + t.Fatal("owner did not finish") + } + select { + case <-flight: + default: + t.Fatal("panic left flight open") + } + select { + case read := <-waiter: + if read.err == nil || errors.Is(read.err, context.DeadlineExceeded) || !read.at.Equal(b.now()) { + t.Fatalf("cached failure: %+v", read) + } + case <-time.After(time.Second): + t.Fatal("panic stranded waiter") + } + entry, flight, owns := b.collectionFactory.factoryQueueStart("f", b.now()) + if entry.err == nil || flight != nil || owns || calls.Load() != 1 { + t.Fatal("panic did not cache failure and release flight") + } + clock.Add((factoryQueueFresh - time.Millisecond).Milliseconds()) + if got := b.factoryQueue(ctx, "f"); got.err == nil || calls.Load() != 1 { + t.Fatalf("failure not cached: %+v", got) + } + clock.Add(1) + if got := b.factoryQueue(ctx, "f"); got.err != nil || got.jobs[7].ID != 7 || calls.Load() != 2 { + t.Fatalf("retry after expiry: %+v", got) + } +} diff --git a/cmd/codeaf/appfactory.go b/cmd/codeaf/appfactory.go index dd4c94f02d..418d1dea7e 100644 --- a/cmd/codeaf/appfactory.go +++ b/cmd/codeaf/appfactory.go @@ -165,8 +165,9 @@ type appFactoryHub struct { keptChanged func() // linkSt is the invitations the engine holds and the lists it owes // (appfactory_links.go), made by the first use. - linkSt *linkState - closed bool + linkSt *linkState + jobEvents *appFactoryJobEvents + closed bool } // newAppFactoryHub is a registry whose HTTP client takes no ambient proxy, @@ -211,6 +212,7 @@ func (b *appBackend) closeFactories() { } func (h *appFactoryHub) close() { + h.stopJobEvents() h.forgetAll() h.client.CloseIdleConnections() } @@ -825,10 +827,14 @@ func (b *appBackend) PipelineAdaptKindEdits() {} func (b *appBackend) FactoryCapabilities(context.Context) (appwire.FactoryCapabilities, error) { return appwire.FactoryCapabilities{ - Contract: api.Contract, Remote: true, Registry: true, AccessLinks: true, SignIn: true, + GroupPolicy: true, + WaitsOn: true, + CreateIssue: true, CreateGroup: true, CreateBudget: true, JobContext: true, CreateKey: true, + CreateSplitFrom: true, + Contract: api.Contract, Remote: true, Registry: true, AccessLinks: true, SignIn: true, Local: true, Runner: true, Writes: false, Admin: true, Links: true, CopyLink: b.copier().Available(), ReposManage: true, RunnerManage: true, Rules: true, PipelinePR: true, PipelinePRs: true, PipelinePRUpdate: true, PipelineSourceRef: true, SpendByRepo: true, SpendByModel: true, PipelineEdit: true, PipelineEditRuntime: true, - PipelineEditStructure: true, PipelineEditRules: true, RepoRead: true, RepoFacts: true, MaxConnections: appwire.MaxFactoryConnections, MaxFlows: appwire.MaxFactoryFlows, + PipelineEditStructure: true, PipelineEditRules: true, RepoRead: true, RepoFacts: true, JobEvents: true, RepoIssues: true, MaxConnections: appwire.MaxFactoryConnections, MaxFlows: appwire.MaxFactoryFlows, }, nil } diff --git a/cmd/codeaf/appfactory_actions.go b/cmd/codeaf/appfactory_actions.go index 92fb8da1d4..93bd2a74dd 100644 --- a/cmd/codeaf/appfactory_actions.go +++ b/cmd/codeaf/appfactory_actions.go @@ -125,6 +125,7 @@ func (h *appFactoryHub) sendWrite(ctx context.Context, client *http.Client, meth req.Header.Set("Accept", "application/json") req.Header.Set("Content-Type", "application/json") req.Header.Set("User-Agent", "codeaf-app") + setFactoryCreateKey(req) if token != "" { req.Header.Set("Authorization", "Bearer "+token) } @@ -143,7 +144,7 @@ func (h *appFactoryHub) sendWrite(ctx context.Context, client *http.Client, meth return nil, appwire.Errorf(appwire.CodeUncertain, "the factory's answer is too large to read; read the job before trying again") } if reply.status < 200 || reply.status >= 300 { - return nil, appFactoryActionFailure(reply) + return nil, factoryJobContextFailure(req, reply) } h.touch(id) return data, nil @@ -450,14 +451,10 @@ func (b *appBackend) FactoryJobStepRerun(ctx context.Context, p appwire.FactoryJ func stepSegment(name string) string { return url.PathEscape(name) } // FactoryJobCreate makes a job. It is a backlog job unless the person asked for -// "now", opens no GitHub issue, and asks no model for anything. +// "now", opens an issue only when asked, and asks no model for anything. func (b *appBackend) FactoryJobCreate(ctx context.Context, p appwire.FactoryJobCreateParams) (appwire.FactoryJobActed, error) { h := b.factoryHub() - body := api.NewJob{Repo: p.Repo, Title: p.Title, Text: p.Text, Source: factory.AskApp, Assignee: p.Assignee, Pipeline: p.Pipeline} - if p.Start == "now" { - body.Start = "now" - } - a, err := h.write(ctx, p.Factory, "/v1/jobs", body) + a, err := h.createJob(ctx, p) if err != nil { return appwire.FactoryJobActed{}, err } diff --git a/cmd/codeaf/appfactory_job_context.go b/cmd/codeaf/appfactory_job_context.go new file mode 100644 index 0000000000..79b7491f6b --- /dev/null +++ b/cmd/codeaf/appfactory_job_context.go @@ -0,0 +1,44 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strings" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/factory" + "github.com/Agent-Field/codeaf/internal/factory/api" +) + +var _ appwire.FactoryJobContextSetter = (*appBackend)(nil) + +// FactoryJobContextSet carries one replacement through the existing hub write. +func (b *appBackend) FactoryJobContextSet(ctx context.Context, p appwire.FactoryJobContextSetParams) (appwire.FactoryJobActed, error) { + attachments := make([]factory.JobContext, len(p.Context)) + for i, c := range p.Context { + attachments[i] = factory.JobContext{Title: c.Title, Markdown: c.Markdown, Source: c.Source} + } + return b.jobWrite(ctx, p.Factory, appwire.JobActContextSet, p.Job, "context", api.JobContextRequest{Context: attachments, Expect: p.Expect}) +} + +// factoryJobContextFailure distinguishes the mux's missing route from a real +// Factory JSON refusal. Every other job write keeps its existing mapping. +func factoryJobContextFailure(req *http.Request, reply appFactoryReply) error { + if req.Method == http.MethodPost && strings.HasPrefix(req.URL.Path, "/v1/jobs/") && strings.HasSuffix(req.URL.Path, "/context") && + (reply.status == http.StatusNotFound || reply.status == http.StatusMethodNotAllowed) { + var refusal struct { + Error string `json:"error"` + } + if json.Unmarshal(reply.body, &refusal) != nil || refusal.Error == api.EndpointNotFound { + err := factoryTooOld() + var e *appwire.Error + if errors.As(err, &e) { + e.Detail = &appwire.FactoryRefusal{Reason: appwire.FactoryReasonTooOld} + } + return err + } + } + return factoryCreateFailure(req, reply) +} diff --git a/cmd/codeaf/appfactory_job_context_test.go b/cmd/codeaf/appfactory_job_context_test.go new file mode 100644 index 0000000000..c1e0040b9f --- /dev/null +++ b/cmd/codeaf/appfactory_job_context_test.go @@ -0,0 +1,148 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "reflect" + "strings" + "sync/atomic" + "testing" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/factory/api" +) + +// E1/E7': the actual wire announces context and posts exactly one versioned replacement. +func TestAppFactoryJobContextSetTransport(t *testing.T) { + for _, attachments := range [][]appwire.FactoryJobContext{ + {{Title: " reference ", Markdown: strings.Repeat("é", 60000), Source: "guide"}}, {}, + } { + var posts atomic.Int64 + stub := startStub(t, func(w http.ResponseWriter, r *http.Request) { + if r.Header.Get("Idempotency-Key") != "" { + t.Error("context write carried a create key") + } + if r.Method == http.MethodGet { + io.WriteString(w, `{"id":"j","document_id":9,"version":4,"context":[{"title":"reference","source":"guide"}]}`) + return + } + posts.Add(1) + var body struct { + Expect api.Expect `json:"expect"` + Context []appwire.FactoryJobContext `json:"context"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + if r.Method != http.MethodPost || r.URL.Path != "/v1/jobs/9/context" || body.Expect.Version == nil || *body.Expect.Version != 3 || len(body.Context) != len(attachments) || body.Context == nil { + t.Errorf("context request: %s %s %+v", r.Method, r.URL.Path, body) + } + for i, c := range body.Context { + if c.Title != strings.TrimSpace(attachments[i].Title) || c.Markdown != attachments[i].Markdown || c.Source != attachments[i].Source { + t.Error("attachment changed") + } + } + io.WriteString(w, `{"factory":{"name":"Stub"}}`) + }) + b, conn := attachStub(t, testFactoryHub(t), stub, actionTestCredential) + wire := wireOver(t, b) + if !strings.Contains(wire.hello, `factory.job_context`) { + t.Fatal("hello lacks context feature") + } + var got appwire.FactoryJobActed + wire.must("factory.job_context_set", map[string]any{"factory": conn.ID, "job": 9, "expect": map[string]int{"version": 3}, "context": attachments}, &got) + if posts.Load() != 1 || got.Action != "context_set" || got.Job != 9 || got.Current == nil || got.Current.ID != 9 { + t.Fatalf("context receipt: %+v; writes %d", got, posts.Load()) + } + } +} + +// E8'/E15: invalid context, missing versions and the removed create field send nothing. +func TestAppFactoryJobContextBadParamsSendNothing(t *testing.T) { + var requests atomic.Int64 + stub := startStub(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + requests.Add(1) + } + }) + b, conn := attachStub(t, testFactoryHub(t), stub, actionTestCredential) + w := wireOver(t, b) + for _, attachments := range []any{ + []appwire.FactoryJobContext{{Title: "t", Markdown: strings.Repeat("x", 100001)}}, + make([]appwire.FactoryJobContext, 21), + []appwire.FactoryJobContext{{Title: "", Markdown: "body"}}, + []appwire.FactoryJobContext{{Title: strings.Repeat("t", 201), Markdown: "body"}}, + []appwire.FactoryJobContext{{Title: "t", Markdown: "body", Source: strings.Repeat("s", 501)}}, + []appwire.FactoryJobContext{{Title: "t"}}, + "reference", []any{map[string]any{"title": "t", "markdown": 7}}, + } { + w.mustFail("factory.job_context_set", map[string]any{"factory": conn.ID, "job": 9, "expect": map[string]int{"version": 3}, "context": attachments}, appwire.CodeBadParams) + } + w.mustFail("factory.job_context_set", map[string]any{"factory": conn.ID, "job": 9, "context": []any{}}, appwire.CodeBadParams) + w.mustFail("factory.job_create", map[string]any{"factory": conn.ID, "repo": "o/r", "title": "Fix", "context": []any{}}, appwire.CodeBadParams) + if requests.Load() != 0 { + t.Fatal("invalid params reached Factory") + } +} + +// E13/E14: mux refusals alone mean too old; real Factory refusals and uncertain outcomes retain their meaning. +func TestAppFactoryJobContextRefusals(t *testing.T) { + cases := []struct { + name string + status int + body, code string + old bool + }{ + {"mux 404", 404, "Not Found\n", appwire.CodeConflict, true}, + {"mux 405", 405, "Method Not Allowed\n", appwire.CodeConflict, true}, + {"Factory mux 404", 404, `{"error":"factory endpoint not found","reason":"not_found"}`, appwire.CodeConflict, true}, + {"job missing", 404, `{"error":"job not found","reason":"not_found"}`, appwire.CodeNotFound, false}, + {"Factory method", 405, `{"error":"this endpoint accepts GET"}`, appwire.CodeEngine, false}, + {"started", 409, `{"error":"The job has started; its steps already read their context.","reason":"conflict"}`, appwire.CodeConflict, false}, + {"stale", 409, `{"error":"job moved on while you looked","reason":"stale"}`, appwire.CodeStale, false}, + {"unavailable", 501, `{"error":"this factory does not provide that action"}`, appwire.CodeEngine, false}, + {"uncertain", 503, `{"error":"failed"}`, appwire.CodeUncertain, false}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var requests atomic.Int64 + stub := startStub(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + requests.Add(1) + } + w.WriteHeader(tc.status) + io.WriteString(w, tc.body) + }) + b, conn := attachStub(t, testFactoryHub(t), stub, actionTestCredential) + version := 3 + _, err := b.FactoryJobContextSet(context.Background(), appwire.FactoryJobContextSetParams{Factory: conn.ID, Job: 9, Expect: &api.Expect{Version: &version}, Context: []appwire.FactoryJobContext{}}) + if wireCodeOf(t, err) != tc.code || requests.Load() != 1 { + t.Fatalf("refusal %v; requests %d", err, requests.Load()) + } + if tc.old { + var e *appwire.Error + if !errors.As(err, &e) || !reflect.DeepEqual(e.Detail, &appwire.FactoryRefusal{Reason: appwire.FactoryReasonTooOld}) { + t.Fatalf("too old detail: %v", err) + } + } + }) + } +} + +// E14: standard ServeMux responses from genuinely absent routes are compatibility refusals. +func TestAppFactoryJobContextAbsentMuxRoute(t *testing.T) { + for _, pattern := range []string{"GET /v1/jobs/{id}/context", "/other"} { + mux := http.NewServeMux() + mux.HandleFunc(pattern, func(w http.ResponseWriter, r *http.Request) {}) + stub := startStub(t, mux.ServeHTTP) + b, conn := attachStub(t, testFactoryHub(t), stub, actionTestCredential) + version := 3 + _, err := b.FactoryJobContextSet(context.Background(), appwire.FactoryJobContextSetParams{Factory: conn.ID, Job: 9, Expect: &api.Expect{Version: &version}, Context: []appwire.FactoryJobContext{}}) + if wireCodeOf(t, err) != appwire.CodeConflict { + t.Fatal(err) + } + } +} diff --git a/cmd/codeaf/appfactory_job_create_extras.go b/cmd/codeaf/appfactory_job_create_extras.go new file mode 100644 index 0000000000..ec76c12e1b --- /dev/null +++ b/cmd/codeaf/appfactory_job_create_extras.go @@ -0,0 +1,60 @@ +package main + +import ( + "context" + "errors" + "net/http" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/factory" + "github.com/Agent-Field/codeaf/internal/factory/api" +) + +type factoryCreateKey struct{} + +// setFactoryCreateKey is used only by the create's write, never its later read. +func setFactoryCreateKey(req *http.Request) { + if key, _ := req.Context().Value(factoryCreateKey{}).(string); key != "" { + req.Header.Set("Idempotency-Key", key) + } +} + +func factoryCreateBody(p appwire.FactoryJobCreateParams) api.NewJob { + body := api.NewJob{ + Repo: p.Repo, Title: p.Title, Text: p.Text, Source: factory.AskApp, + Assignee: p.Assignee, Pipeline: p.Pipeline, Issue: p.OpenIssue, + Group: p.Group, Milestone: p.Milestone, Budget: p.Budget, + SplitFrom: p.SplitFrom, + } + if p.Start == "now" { + body.Start = "now" + } + return body +} + +func (h *appFactoryHub) createJob(ctx context.Context, p appwire.FactoryJobCreateParams) (appFactoryAnswer, error) { + path := "/v1/jobs" + if p.OpenIssue { + path = "/v1/issues" + } + ctx = context.WithValue(ctx, factoryCreateKey{}, p.Key) + return h.write(ctx, p.Factory, path, factoryCreateBody(p)) +} + +func factoryCreateFailure(req *http.Request, reply appFactoryReply) error { + err := appFactoryActionFailure(reply) + if _, create := req.Context().Value(factoryCreateKey{}).(string); !create { + return err + } + missing := req.URL.Path == "/v1/issues" && (reply.status == http.StatusNotFound || reply.status == http.StatusMethodNotAllowed) + unknown := reply.status == http.StatusUnprocessableEntity && unknownFactoryField(err, "group", "milestone", "budget", "issue", "split_from") + if !missing && !unknown { + return err + } + err = factoryTooOld() + var refusal *appwire.Error + if errors.As(err, &refusal) { + refusal.Detail = &appwire.FactoryRefusal{Reason: appwire.FactoryReasonTooOld} + } + return err +} diff --git a/cmd/codeaf/appfactory_job_create_extras_test.go b/cmd/codeaf/appfactory_job_create_extras_test.go new file mode 100644 index 0000000000..d35f63cecc --- /dev/null +++ b/cmd/codeaf/appfactory_job_create_extras_test.go @@ -0,0 +1,169 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "reflect" + "strings" + "sync/atomic" + "testing" + + "github.com/Agent-Field/codeaf/internal/appwire" +) + +// E1: factory.capabilities announces create extras and the separate context write. +func TestAppFactoryCreateCapabilities(t *testing.T) { + w := newFactoryWire(t, testFactoryHub(t)) + var got map[string]any + w.must("factory.capabilities", map[string]any{}, &got) + for _, flag := range []string{"create_issue", "create_group", "create_budget", "job_context", "create_key"} { + if got[flag] != true { + t.Errorf("%s = %v", flag, got[flag]) + } + } + if _, exists := got["create_context"]; exists { + t.Fatal("obsolete create_context capability") + } +} + +// E2/E3/E10: create chooses one route, preserves the plain body and scopes the key to that POST. +func TestAppFactoryCreateRoutesBodyAndKey(t *testing.T) { + for _, issue := range []bool{false, true} { + t.Run(fmt.Sprintf("issue=%v", issue), func(t *testing.T) { + var posts atomic.Int64 + stub := startStub(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + if r.Header.Get("Idempotency-Key") != "" { + t.Error("key leaked to the follow-up read") + } + io.WriteString(w, `{"job_id":"j","version":1}`) + return + } + posts.Add(1) + path, key := "/v1/jobs", "" + want := map[string]any{"repo": "o/r", "title": "Fix", "text": "body", "source": "app"} + if issue { + path, key = "/v1/issues", "k1" + want["issue"], want["group"], want["milestone"], want["budget"] = true, "batch", "release", float64(10) + } + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + if r.Method != http.MethodPost || r.URL.Path != path || r.Header.Get("Idempotency-Key") != key || !reflect.DeepEqual(body, want) { + t.Errorf("request: %s %s key=%q body=%v; want %s key=%q body=%v", r.Method, r.URL.Path, r.Header.Get("Idempotency-Key"), body, path, key, want) + } + io.WriteString(w, `{"factory":{"name":"Stub"},"id":9}`) + }) + b, conn := attachStub(t, testFactoryHub(t), stub, actionTestCredential) + wire := wireOver(t, b) + p := map[string]any{"factory": conn.ID, "repo": "o/r", "title": "Fix", "text": "body"} + if issue { + p["open_issue"], p["group"], p["milestone"], p["budget"], p["key"] = true, "batch", "release", 10, "k1" + } + var made appwire.FactoryJobActed + wire.must("factory.job_create", p, &made) + if made.Action != appwire.JobActCreate || made.Job != 9 || posts.Load() != 1 { + t.Fatalf("receipt %+v; posts %d", made, posts.Load()) + } + }) + } +} + +// E4/E11: old routes and strict fields become a typed refusal; other errors keep their meaning. +func TestAppFactoryCreateOlderFactoryRefusals(t *testing.T) { + cases := []struct { + name, words, code string + status int + issue, old bool + }{ + {"issue 404", "not found", appwire.CodeConflict, 404, true, true}, + {"issue 405", "method not allowed", appwire.CodeConflict, 405, true, true}, + {"plain 404", "not found", appwire.CodeNotFound, 404, false, false}, + {"unavailable", "unavailable", appwire.CodeEngine, 501, true, false}, + {"uncertain", "failed", appwire.CodeUncertain, 503, true, false}, + {"GitHub sign-in", "Reconnect GitHub", appwire.CodeSignedOut, 401, true, false}, + {"other validation", "invalid title", appwire.CodeBadParams, 422, false, false}, + } + for _, field := range []string{"group", "milestone", "budget", "issue"} { + cases = append(cases, struct { + name, words, code string + status int + issue, old bool + }{field, `invalid JSON body: json: unknown field "` + field + `"`, appwire.CodeConflict, 422, true, true}) + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + var posts atomic.Int64 + stub := startStub(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + posts.Add(1) + } + w.WriteHeader(tc.status) + json.NewEncoder(w).Encode(map[string]string{"error": tc.words}) + }) + b, conn := attachStub(t, testFactoryHub(t), stub, actionTestCredential) + _, err := b.FactoryJobCreate(context.Background(), appwire.FactoryJobCreateParams{Factory: conn.ID, Repo: "o/r", Title: "Fix", OpenIssue: tc.issue, Group: "batch"}) + if wireCodeOf(t, err) != tc.code || posts.Load() != 1 { + t.Fatalf("refusal %v; requests %d", err, posts.Load()) + } + if tc.old { + var wire *appwire.Error + if !errors.As(err, &wire) || wire.Error() != factoryTooOld().Error() { + t.Fatalf("old factory words: %v", err) + } + d, ok := wire.Detail.(*appwire.FactoryRefusal) + if !ok || d.Reason != appwire.FactoryReasonTooOld { + t.Fatalf("old factory detail: %#v", wire.Detail) + } + } + }) + } +} + +// E5/E6/E10/E15: bad params and removed context send nothing to the fake Factory. +func TestAppFactoryCreateInvalidExtrasSendNothing(t *testing.T) { + var posts atomic.Int64 + stub := startStub(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + posts.Add(1) + } + }) + b, conn := attachStub(t, testFactoryHub(t), stub, actionTestCredential) + w := wireOver(t, b) + for field, value := range map[string]any{ + "group": strings.Repeat("g", 201), "milestone": "bad\x00label", "budget": 7, + "context": []appwire.FactoryJobContext{{Title: "reference"}}, "key": "a b", + } { + w.mustFail("factory.job_create", map[string]any{"factory": conn.ID, "repo": "o/r", "title": "Fix", field: value}, appwire.CodeBadParams) + } + if posts.Load() != 0 { + t.Fatal("bad params reached the Factory") + } +} + +// E10: a key forwarded through the real wire and store-backed Factory makes one job. +func TestAppFactoryCreateKeyReplaysJobNumber(t *testing.T) { + f := startActionFactory(t) + p := signedIn(t, f, 1, "alice") + params := map[string]any{"factory": p.conn.ID, "repo": "acme/api", "title": "Fix", "key": "k1"} + first := p.act2(t, "factory.job_create", params) + second := p.act2(t, "factory.job_create", params) + if first.Job < 1 || first.Job != second.Job { + t.Fatalf("replayed job numbers: %d / %d", first.Job, second.Job) + } + items, err := f.st.List() + if err != nil || len(items) != 1 { + t.Fatalf("same key: jobs=%d err=%v", len(items), err) + } + params["key"] = "k2" + third := p.act2(t, "factory.job_create", params) + items, err = f.st.List() + if err != nil || len(items) != 2 || third.Job == first.Job { + t.Fatalf("different key: jobs=%d number=%d err=%v", len(items), third.Job, err) + } +} diff --git a/cmd/codeaf/appfactory_job_events.go b/cmd/codeaf/appfactory_job_events.go new file mode 100644 index 0000000000..ab160f46d5 --- /dev/null +++ b/cmd/codeaf/appfactory_job_events.go @@ -0,0 +1,214 @@ +package main + +import ( + "context" + "errors" + "net/url" + "strconv" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/factory/api" + "github.com/Agent-Field/codeaf/internal/guard" +) + +var _ appwire.FactoryJobEventsReader = (*appBackend)(nil) + +// FactoryEventsSince reads once; following is owned by the hub, beyond this call. +func (b *appBackend) FactoryEventsSince(ctx context.Context, p appwire.FactoryEventsSinceParams) (appwire.FactoryEvents, error) { + h := b.factoryHub() + out, err := h.readJobEvents(ctx, p, 0) + if err == nil && out.Supported && p.Follow { + h.jobEventsState().follow(b, h, p.Factory, out.Cursor) + } + return out, err +} + +func (h *appFactoryHub) readJobEvents(ctx context.Context, p appwire.FactoryEventsSinceParams, wait int) (appwire.FactoryEvents, error) { + out := appwire.FactoryEvents{Factory: p.Factory, Events: []appwire.FactoryEvent{}} + limit := p.Limit + if limit == 0 { + limit = appwire.DefaultFactoryEvents + } + q := url.Values{"limit": {strconv.Itoa(limit)}} + if p.After != nil { + q.Set("after", strconv.FormatInt(*p.After, 10)) + } + if wait > 0 { + q.Set("wait", strconv.Itoa(wait)) + } + var got api.JobEvents + if err := h.read(ctx, p.Factory, "/v1/job-events?"+q.Encode(), appFactorySmall, &got); err != nil { + if routeMissing(err) { + return out, nil + } + return appwire.FactoryEvents{}, err + } + out.Supported, out.Cursor, out.Reset, out.More = true, got.Cursor, got.Reset, got.More + for _, e := range got.Events { + out.Events = append(out.Events, appwire.FactoryEvent{ + Factory: p.Factory, Seq: e.Seq, Kind: e.Kind, Job: e.Job, At: e.At, + Title: e.Title, Group: e.Group, How: e.How, Reason: e.Reason, + Need: e.Need, Code: e.Code, Step: e.Step, PRNumber: e.PRNumber, PRURL: e.PRURL, + }) + } + return out, nil +} + +type appFactoryJobEvents struct { + mu sync.Mutex + ctx context.Context + cancel context.CancelFunc + followers map[string]*appFactoryJobFollower + wg sync.WaitGroup + // after lets retry tests advance the backoff without waiting on a clock. + after func(time.Duration) <-chan time.Time + // beforeRead lets tests hold a follower before its credential lookup. + beforeRead func(context.Context) +} + +type appFactoryJobFollower struct { + cursor int64 +} + +func (h *appFactoryHub) jobEventsState() *appFactoryJobEvents { + h.mu.Lock() + defer h.mu.Unlock() + if h.jobEvents == nil { + ctx, cancel := context.WithCancel(context.Background()) + h.jobEvents = &appFactoryJobEvents{ + ctx: ctx, cancel: cancel, followers: map[string]*appFactoryJobFollower{}, after: time.After, + } + if h.closed { + cancel() + } + } + return h.jobEvents +} + +func (h *appFactoryHub) stopJobEvents() { + s := h.jobEventsState() + s.stop() + s.wg.Wait() +} + +func (s *appFactoryJobEvents) stop() { + // Cancel before taking the lock so a full wire queue releases its sender. + s.cancel() + s.mu.Lock() + defer s.mu.Unlock() + // Every admitted follower has joined the wait group before this returns. +} + +func (s *appFactoryJobEvents) follow(b *appBackend, h *appFactoryHub, id string, cursor int64) { + s.mu.Lock() + defer s.mu.Unlock() + if s.ctx.Err() != nil { + return + } + if f := s.followers[id]; f != nil { + f.cursor = max(f.cursor, cursor) + return + } + if len(s.followers) >= appwire.MaxFactoryConnections { + return + } + f := &appFactoryJobFollower{cursor: cursor} + s.followers[id] = f + s.wg.Add(1) + guard.Go("factory job outcomes", func() { + defer s.wg.Done() + defer s.remove(id, f) + s.run(b, h, id, f) + }) +} + +func (s *appFactoryJobEvents) remove(id string, f *appFactoryJobFollower) { + s.mu.Lock() + defer s.mu.Unlock() + if s.followers[id] == f { + delete(s.followers, id) + } +} + +func (s *appFactoryJobEvents) cursor(f *appFactoryJobFollower) int64 { + s.mu.Lock() + defer s.mu.Unlock() + return f.cursor +} + +func (s *appFactoryJobEvents) run(b *appBackend, h *appFactoryHub, id string, f *appFactoryJobFollower) { + delay := 2 * time.Second + for s.ctx.Err() == nil { + cursor := s.cursor(f) + ctx, cancel := context.WithTimeout(s.ctx, appFactoryTimeout) + if s.beforeRead != nil { + s.beforeRead(ctx) + } + out, err := h.readJobEvents(ctx, appwire.FactoryEventsSinceParams{Factory: id, After: &cursor, Limit: appwire.DefaultFactoryEvents}, 15) + cancel() + if jobEventsEnded(s.ctx, out, err) || jobEventsDetached(h, id, err) { + return + } + if err != nil { + if !s.retry(delay) { + return + } + delay = min(2*delay, time.Minute) + continue + } + s.deliver(b, f, cursor, out) + delay = 2 * time.Second + } +} + +func jobEventsEnded(ctx context.Context, out appwire.FactoryEvents, err error) bool { + if ctx.Err() != nil || (err == nil && !out.Supported) || errors.Is(err, context.Canceled) { + return true + } + var wire *appwire.Error + return errors.As(err, &wire) && (wire.Code == appwire.CodeNotFound || wire.Code == appwire.CodeClosed) +} + +func jobEventsDetached(h *appFactoryHub, id string, err error) bool { + var wire *appwire.Error + if !errors.As(err, &wire) || wire.Code != appwire.CodeSignedOut { + return false + } + // A detached poll can return signed_out after its credential was forgotten. + _, _, err = h.lookup(id) + return errors.As(err, &wire) && wire.Code == appwire.CodeNotFound +} + +func (s *appFactoryJobEvents) retry(delay time.Duration) bool { + select { + case <-s.ctx.Done(): + return false + case <-s.after(delay): + return true + } +} + +func (s *appFactoryJobEvents) deliver(b *appBackend, f *appFactoryJobFollower, asked int64, out appwire.FactoryEvents) { + s.mu.Lock() + defer s.mu.Unlock() + if s.ctx.Err() != nil { + return + } + if out.Reset { + // A concurrent read may already have advanced beyond this request. + if f.cursor == asked { + f.cursor = out.Cursor + } + return + } + delivery := appModelNoticeDelivery{ctx: s.ctx, emit: b.inbox.next} + for _, e := range out.Events { + if e.Seq > f.cursor { + delivery.Emit(appwire.EventFactoryEvent, e) + f.cursor = e.Seq + } + } + f.cursor = max(f.cursor, out.Cursor) +} diff --git a/cmd/codeaf/appfactory_job_events_test.go b/cmd/codeaf/appfactory_job_events_test.go new file mode 100644 index 0000000000..dc48950bc1 --- /dev/null +++ b/cmd/codeaf/appfactory_job_events_test.go @@ -0,0 +1,521 @@ +package main + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "net/http/httputil" + "net/url" + "strings" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/factory" + "github.com/Agent-Field/codeaf/internal/factory/api" + "github.com/Agent-Field/codeaf/internal/guard" +) + +// E3, E10, E14: the real read announces support and keeps a Factory cursor across engines. +func TestAppFactoryJobEventsReadAndEngineRestart(t *testing.T) { + f := startActionFactory(t) + alice := signedIn(t, f, 1, "alice") + if !strings.Contains(alice.w.hello, appwire.FeatureFactoryJobEvents) { + t.Fatalf("hello: %s", alice.w.hello) + } + var caps appwire.FactoryCapabilities + alice.w.must("factory.capabilities", map[string]any{}, &caps) + if !caps.JobEvents { + t.Fatalf("capabilities: %+v", caps) + } + var initial appwire.FactoryEvents + alice.w.must("factory.events_since", map[string]any{"factory": alice.conn.ID}, &initial) + if !initial.Supported || !initial.Reset || initial.Events == nil { + t.Fatalf("initial: %+v", initial) + } + for _, bad := range []map[string]any{{}, {"factory": alice.conn.ID, "after": -1}, {"factory": alice.conn.ID, "limit": 501}} { + alice.refused(t, "factory.events_since", bad, appwire.CodeBadParams) + } + if err := alice.w.backend.Close(); err != nil { + t.Fatal(err) + } + job := f.create(t, factory.Item{State: factory.StateRunning, Title: "Retained outcome"}) + at := f.clock.now() + if err := f.st.Update(job.ID, func(it *factory.Item) error { + it.State, it.DoneAt = factory.StateShipped, at + return nil + }); err != nil { + t.Fatal(err) + } + bob := signedIn(t, f, 2, "bob") + var got appwire.FactoryEvents + bob.w.must("factory.events_since", map[string]any{"factory": bob.conn.ID, "after": initial.Cursor}, &got) + if !got.Supported || got.Reset || len(got.Events) != 1 || got.Cursor != 1 { + t.Fatalf("restarted read: %+v", got) + } + e := got.Events[0] + if e.Factory != bob.conn.ID || e.Seq != 1 || e.Kind != "finished" || e.Job != job.ID || !e.At.Equal(at) || e.How != "answered" || e.Title != job.Title { + t.Fatalf("event: %+v", e) + } +} + +func nextFactoryOutcome(t *testing.T, w *factoryWire) appwire.FactoryEvent { + t.Helper() + timer := time.NewTimer(2 * time.Second) + defer timer.Stop() + for { + select { + case raw, ok := <-w.answers: + if !ok { + t.Fatal("wire closed before outcome") + } + var frame struct { + Event string `json:"event"` + Data appwire.FactoryEvent `json:"data"` + } + if json.Unmarshal(raw, &frame) == nil && frame.Event == appwire.EventFactoryEvent { + return frame.Data + } + case <-timer.C: + t.Fatal("no factory.event push") + } + } +} + +func factoryOutcomePushes(w *factoryWire) int { + w.mu.Lock() + defer w.mu.Unlock() + n := 0 + for _, line := range w.lines { + var frame struct { + Event string `json:"event"` + } + if json.Unmarshal([]byte(line), &frame) == nil && frame.Event == appwire.EventFactoryEvent { + n++ + } + } + return n +} + +func jobFollowerCount(s *appFactoryJobEvents) int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.followers) +} + +func waitForNoJobFollowers(t *testing.T, s *appFactoryJobEvents) { + t.Helper() + timer := time.NewTimer(2 * time.Second) + defer timer.Stop() + tick := time.NewTicker(time.Millisecond) + defer tick.Stop() + for jobFollowerCount(s) != 0 { + select { + case <-tick.C: + case <-timer.C: + t.Fatal("follower did not end") + } + } +} + +// E8: two later outcomes arrive once in order; close cancels and joins the follower. +func TestAppFactoryJobEventsPushOnceAndClose(t *testing.T) { + f := startActionFactory(t) + alice := signedIn(t, f, 1, "alice") + // The harness normally records backend events separately; send these to its wire. + alice.w.backend.inbox.next = alice.w.server + params := map[string]any{"factory": alice.conn.ID, "after": 0, "follow": true} + var got appwire.FactoryEvents + alice.w.must("factory.events_since", params, &got) + alice.w.must("factory.events_since", params, &got) + s := alice.w.backend.factoryHub().jobEventsState() + if jobFollowerCount(s) != 1 { + t.Fatal("repeated follow did not keep one follower") + } + jobs := []factory.Item{f.create(t, factory.Item{State: factory.StateRunning}), f.create(t, factory.Item{State: factory.StateRunning})} + at := f.clock.now() + for _, job := range jobs { + if err := f.st.Update(job.ID, func(it *factory.Item) error { + it.State, it.DoneAt = factory.StateShipped, at + return nil + }); err != nil { + t.Fatal(err) + } + } + for i, job := range jobs { + e := nextFactoryOutcome(t, alice.w) + if e.Factory != alice.conn.ID || e.Seq != int64(i+1) || e.Kind != "finished" || e.Job != job.ID || !e.At.Equal(at) { + t.Fatalf("push %d: %+v", i, e) + } + } + if err := alice.w.backend.Close(); err != nil { + t.Fatal(err) + } + if jobFollowerCount(s) != 0 { + t.Fatal("close left a follower") + } + if err := f.st.Update(jobs[0].ID, func(it *factory.Item) error { + it.PullRequest = &factory.PullRequest{State: factory.PRMerged, MergedAt: at, Number: 7} + return nil + }); err != nil { + t.Fatal(err) + } + if factoryOutcomePushes(alice.w) != 2 { + t.Fatalf("pushes: %d, want 2", factoryOutcomePushes(alice.w)) + } +} + +// E8: detaching is observed by the credentialed read and removes the follower. +func TestAppFactoryJobEventsDetachEndsFollower(t *testing.T) { + f := startActionFactory(t) + alice := signedIn(t, f, 1, "alice") + var got appwire.FactoryEvents + alice.w.must("factory.events_since", map[string]any{"factory": alice.conn.ID, "after": 0, "follow": true}, &got) + s := alice.w.backend.factoryHub().jobEventsState() + alice.w.must("factory.detach", map[string]any{"factory": alice.conn.ID}, nil) + job := f.create(t, factory.Item{State: factory.StateRunning}) + if err := f.st.Update(job.ID, func(it *factory.Item) error { it.State = factory.StateShipped; return nil }); err != nil { + t.Fatal(err) + } + waitForNoJobFollowers(t, s) +} + +// E9: the older route's exact refusal becomes supported false, with no follower. +func TestAppFactoryJobEventsOlderFactory(t *testing.T) { + f := startActionFactory(t) + target, _ := url.Parse(f.URL) + proxy := httputil.NewSingleHostReverseProxy(target) + var reads atomic.Int64 + old := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/v1/job-events" { + reads.Add(1) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusNotFound) + _, _ = w.Write([]byte(`{"error":"factory endpoint not found","reason":"not_found"}`)) + return + } + proxy.ServeHTTP(w, r) + })) + defer old.Close() + alice := signedInAt(t, f, old.URL, 1, "alice") + var got appwire.FactoryEvents + alice.w.must("factory.events_since", map[string]any{"factory": alice.conn.ID, "follow": true}, &got) + if got.Supported || got.Factory != alice.conn.ID || got.Events == nil || len(got.Events) != 0 || reads.Load() != 1 { + t.Fatalf("older Factory: %+v, reads %d", got, reads.Load()) + } + h := alice.w.backend.factoryHub() + h.mu.Lock() + defer h.mu.Unlock() + if h.jobEvents != nil { + t.Fatal("unsupported read started a follower registry") + } +} + +// E8: resets are silent, repeated reads raise the cursor, retries cap and recover. +func TestAppFactoryJobEventsRetryAndReset(t *testing.T) { + var requests atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/job-events" { + t.Errorf("unexpected route: %s", r.URL.Path) + } + if r.Header.Get("Authorization") != "Bearer outcome-test-token" { + t.Error("outcome read lost credentials") + } + n := requests.Add(1) + if n <= 7 { + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(`{"error":"busy"}`)) + return + } + if n == 8 { + _ = json.NewEncoder(w).Encode(api.JobEvents{Events: []api.JobEvent{}, Cursor: 9, Reset: true}) + return + } + if n == 9 { + w.WriteHeader(http.StatusServiceUnavailable) + _, _ = w.Write([]byte(`{"error":"busy"}`)) + return + } + <-r.Context().Done() + })) + t.Cleanup(server.Close) + h := testFactoryHub(t) + h.loaded = true + h.rows = []appFactoryRow{{ID: "f", URL: server.URL, Mode: api.FactoryTeam}} + h.sessions["f"] = appFactorySession{token: "outcome-test-token", expires: time.Now().Add(time.Hour)} + b := newFakeBackend(newAppRecorder(), nil) + b.factoryOnce.Do(func() { b.factories = h }) + t.Cleanup(func() { _ = b.Close() }) + s := h.jobEventsState() + delays := make(chan time.Duration, 12) + s.after = func(delay time.Duration) <-chan time.Time { + delays <- delay + ready := make(chan time.Time, 1) + ready <- time.Now() + return ready + } + s.follow(b, h, "f", 0) + want := []time.Duration{2, 4, 8, 16, 32, 60, 60, 2} + for _, seconds := range want { + select { + case got := <-delays: + if got != seconds*time.Second { + t.Fatalf("backoff %v, want %v seconds", got, seconds) + } + case <-time.After(2 * time.Second): + t.Fatal("retry did not continue") + } + } + f := func() *appFactoryJobFollower { + s.mu.Lock() + defer s.mu.Unlock() + return s.followers["f"] + }() + s.follow(b, h, "f", 12) + s.follow(b, h, "f", 10) + if f == nil || s.cursor(f) != 12 || jobFollowerCount(s) != 1 { + t.Fatal("follow did not raise the single cursor") + } + rec := b.inbox.next.(*appRecorder) + rec.mu.Lock() + for _, event := range rec.events { + if event == appwire.EventFactoryEvent { + t.Error("reset emitted an outcome") + } + } + rec.mu.Unlock() + h.stopJobEvents() + if jobFollowerCount(s) != 0 { + t.Fatal("retry follower remained after cancellation") + } +} + +// E8: a cancelled backend context and a detached connection stop, other errors retry. +func TestAppFactoryJobEventsEndingErrors(t *testing.T) { + for _, code := range []string{appwire.CodeNotFound, appwire.CodeClosed} { + if !jobEventsEnded(context.Background(), appwire.FactoryEvents{}, appwire.Errorf(code, "ended")) { + t.Fatalf("%s did not end following", code) + } + } + for _, code := range []string{appwire.CodeUnreachable, appwire.CodeSignedOut, appwire.CodeGitHubExpired, appwire.CodeBusy, appwire.CodeEngine} { + if jobEventsEnded(context.Background(), appwire.FactoryEvents{}, appwire.Errorf(code, "retry")) { + t.Fatalf("%s ended following", code) + } + } + ctx, cancel := context.WithCancel(context.Background()) + cancel() + if !jobEventsEnded(ctx, appwire.FactoryEvents{Supported: true}, nil) || !jobEventsEnded(context.Background(), appwire.FactoryEvents{Supported: false}, nil) { + t.Fatal("cancelled or unsupported follower did not end") + } +} + +type blockedJobOutcomeEmitter struct { + started chan struct{} +} + +func (*blockedJobOutcomeEmitter) Emit(string, any) { + panic("job outcome used the uncancellable emitter") +} + +func (e *blockedJobOutcomeEmitter) EmitContext(ctx context.Context, _ string, _ any) { + close(e.started) + <-ctx.Done() +} + +// E8: close releases a follower blocked on the wire before joining it. +func TestAppFactoryJobEventsCloseCancelsBlockedDelivery(t *testing.T) { + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(api.JobEvents{Events: []api.JobEvent{{Seq: 1, Kind: "finished", Job: 1}}, Cursor: 1}) + })) + t.Cleanup(server.Close) + h := testFactoryHub(t) + h.loaded = true + h.rows = []appFactoryRow{{ID: "f", URL: server.URL, Mode: api.FactoryTeam}} + h.sessions["f"] = appFactorySession{token: "outcome-test-token", expires: time.Now().Add(time.Hour)} + b := newFakeBackend(newAppRecorder(), nil) + b.factoryOnce.Do(func() { b.factories = h }) + t.Cleanup(func() { _ = b.Close() }) + emitter := &blockedJobOutcomeEmitter{started: make(chan struct{})} + b.inbox.next = emitter + s := h.jobEventsState() + s.follow(b, h, "f", 0) + select { + case <-emitter.started: + case <-time.After(2 * time.Second): + t.Fatal("delivery did not reach the wire") + } + done := make(chan struct{}) + guard.Go("job outcome close test", func() { + defer close(done) + h.close() + }) + select { + case <-done: + case <-time.After(2 * time.Second): + t.Fatal("close did not cancel blocked delivery") + } + if jobFollowerCount(s) != 0 { + t.Fatal("blocked follower was not joined") + } +} + +// E8: the registry admits no more followers than attached Factory connections. +func TestAppFactoryJobEventsFollowerBound(t *testing.T) { + h := testFactoryHub(t) + s := h.jobEventsState() + for i := 0; i < appwire.MaxFactoryConnections; i++ { + s.followers[fmt.Sprint(i)] = &appFactoryJobFollower{} + } + s.follow(nil, h, "overflow", 0) + if jobFollowerCount(s) != appwire.MaxFactoryConnections { + t.Fatal("follower bound was exceeded") + } + s.cancel() +} + +// E14: each call is one credentialed GET; all outcome fields keep their values. +func TestAppFactoryJobEventsQueryAndMapping(t *testing.T) { + at := time.Date(2026, 11, 1, 12, 0, 0, 0, time.UTC) + event := api.JobEvent{ + Seq: 7, Kind: "merged", Job: 42, At: at, Title: "A job", Group: "batch", + How: "answered", Reason: "question", Need: "question", Code: "check", Step: "verify", + PRNumber: 9, PRURL: "https://github.com/acme/api/pull/9", + } + var calls atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + n := calls.Add(1) + q := r.URL.Query() + if r.URL.Path != "/v1/job-events" || r.Header.Get("Authorization") != "Bearer outcome-test-token" || q.Has("wait") { + t.Errorf("read: %s, auth %q", r.URL.String(), r.Header.Get("Authorization")) + } + if n == 1 && (q.Has("after") || q.Get("limit") != "100") { + t.Errorf("initial query: %v", q) + } + if n == 2 && (!q.Has("after") || q.Get("after") != "0" || q.Get("limit") != "2") { + t.Errorf("zero query: %v", q) + } + _ = json.NewEncoder(w).Encode(api.JobEvents{Events: []api.JobEvent{event}, Cursor: 9, More: true}) + })) + t.Cleanup(server.Close) + h := testFactoryHub(t) + h.loaded = true + h.rows = []appFactoryRow{{ID: "f", URL: server.URL, Mode: api.FactoryTeam}} + h.sessions["f"] = appFactorySession{token: "outcome-test-token", expires: time.Now().Add(time.Hour)} + w := newFactoryWire(t, h) + for _, params := range []map[string]any{{"factory": "f"}, {"factory": "f", "after": 0, "limit": 2}} { + var got appwire.FactoryEvents + w.must("factory.events_since", params, &got) + if !got.Supported || got.Factory != "f" || got.Cursor != 9 || got.Reset || !got.More || len(got.Events) != 1 || got.Events[0].Factory != "f" { + t.Fatalf("answer: %+v", got) + } + actual, _ := json.Marshal(got.Events[0]) + var mapped map[string]any + _ = json.Unmarshal(actual, &mapped) + delete(mapped, "factory") + actual, _ = json.Marshal(mapped) + want, _ := json.Marshal(event) + var normalized map[string]any + _ = json.Unmarshal(want, &normalized) + want, _ = json.Marshal(normalized) + if string(actual) != string(want) { + t.Fatalf("mapped %s, want %s", actual, want) + } + } + if calls.Load() != 2 { + t.Fatalf("reads: %d", calls.Load()) + } +} + +// E8: detach before lookup and during a credentialed poll end without backoff. +func TestAppFactoryJobEventsDetachRequestOrders(t *testing.T) { + tests := []struct { + name string + beforeLookup bool + signedOut bool + polls int64 + retries int64 + }{ + {name: "before lookup", beforeLookup: true}, + {name: "outstanding long poll", polls: 1}, + {name: "signed out while attached then detach", signedOut: true, polls: 2, retries: 1}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + started := make(chan struct{}, 1) + release := make(chan struct{}) + var polls, retries atomic.Int64 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/v1/job-events" || r.Header.Get("Authorization") != "Bearer outcome-test-token" { + t.Errorf("unexpected credentialed read: %s", r.URL.Path) + } + if r.URL.Query().Get("wait") == "15" { + n := polls.Add(1) + if tt.signedOut && n == 1 { + w.WriteHeader(http.StatusUnauthorized) + _, _ = w.Write([]byte(`{"error":"sign in","reason":"signed_out"}`)) + return + } + select { + case started <- struct{}{}: + case <-r.Context().Done(): + return + } + select { + case <-release: + case <-r.Context().Done(): + return + } + } + _ = json.NewEncoder(w).Encode(api.JobEvents{Events: []api.JobEvent{}, Cursor: 0}) + })) + t.Cleanup(server.Close) + h := testFactoryHub(t) + h.loaded = true + h.rows = []appFactoryRow{{ID: "f", URL: server.URL, Mode: api.FactoryTeam}} + h.sessions["f"] = appFactorySession{token: "outcome-test-token", expires: time.Now().Add(time.Hour)} + s := h.jobEventsState() + s.after = func(delay time.Duration) <-chan time.Time { + retries.Add(1) + if delay != 2*time.Second { + t.Errorf("retry delay %v, want 2 seconds", delay) + } + ready := make(chan time.Time, 1) + ready <- time.Now() + return ready + } + if tt.beforeLookup { + s.beforeRead = func(ctx context.Context) { + select { + case started <- struct{}{}: + case <-ctx.Done(): + return + } + select { + case <-release: + case <-ctx.Done(): + } + } + } + w := newFactoryWire(t, h) + var got appwire.FactoryEvents + w.must("factory.events_since", map[string]any{"factory": "f", "after": 0, "follow": true}, &got) + if !got.Supported { + t.Fatalf("initial read: %+v", got) + } + select { + case <-started: + case <-time.After(2 * time.Second): + t.Fatal("follower did not reach the controlled request") + } + w.must("factory.detach", map[string]any{"factory": "f"}, nil) + close(release) + waitForNoJobFollowers(t, s) + if polls.Load() != tt.polls || retries.Load() != tt.retries { + t.Fatalf("polls %d, retries %d; want %d, %d", polls.Load(), retries.Load(), tt.polls, tt.retries) + } + }) + } +} diff --git a/cmd/codeaf/appfactory_policy.go b/cmd/codeaf/appfactory_policy.go new file mode 100644 index 0000000000..7e0c4a3b6b --- /dev/null +++ b/cmd/codeaf/appfactory_policy.go @@ -0,0 +1,89 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/url" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/factory" +) + +var _ appwire.FactoryPolicyKeeper = (*appBackend)(nil) + +func policyFactoryTooOld() error { + err := factoryTooOld() + var refusal *appwire.Error + if errors.As(err, &refusal) { + refusal.Detail = &appwire.FactoryRefusal{Reason: "factory_too_old"} + } + return err +} + +func policyPath(group string) string { return "/v1/groups/" + url.PathEscape(group) + "/policy" } + +func decodeFactoryPolicy(raw []byte) (appwire.FactoryPolicy, error) { + var out appwire.FactoryPolicy + if err := json.Unmarshal(raw, &out); err != nil { + return out, appwire.Errorf(appwire.CodeEngine, "the factory sent a policy this app cannot read") + } + return out, nil +} + +func (b *appBackend) FactoryPolicyGet(ctx context.Context, p appwire.FactoryPolicyGetParams) (appwire.FactoryPolicy, error) { + p, err := appwire.CheckPolicyGet(p) + if err != nil { + return appwire.FactoryPolicy{}, err + } + h := b.factoryHub() + row, token, err := h.credentialed(ctx, p.Factory) + if err != nil { + return appwire.FactoryPolicy{}, err + } + reply, err := h.send(ctx, row.URL, token, http.MethodGet, policyPath(p.Group), nil, appFactoryBody, nil) + if err != nil { + return appwire.FactoryPolicy{}, err + } + if err := h.staleCredential(p.Factory, token); err != nil { + return appwire.FactoryPolicy{}, err + } + if reply.status == 404 || reply.status == 405 { + return appwire.FactoryPolicy{}, policyFactoryTooOld() + } + if reply.status < 200 || reply.status >= 300 { + return appwire.FactoryPolicy{}, appFactoryFailure(reply) + } + out, err := decodeFactoryPolicy(reply.body) + if err == nil { + h.touch(p.Factory) + } + return out, err +} + +func (b *appBackend) FactoryPolicySet(ctx context.Context, p appwire.FactoryPolicySetParams) (appwire.FactoryPolicy, error) { + p, err := appwire.CheckPolicySet(p) + if err != nil { + return appwire.FactoryPolicy{}, err + } + h := b.factoryHub() + client := *h.client + transport := &waitsTransport{RoundTripper: client.Transport} + if transport.RoundTripper == nil { + transport.RoundTripper = http.DefaultTransport + } + client.Transport = transport + raw, err := h.sendWrite(ctx, &client, http.MethodPut, p.Factory, policyPath(p.Group), struct { + Stopped bool `json:"stopped"` + Allowances []factory.Allowance `json:"allowances"` + Version *int `json:"version,omitempty"` + }{p.Stopped, p.Allowances, p.Version}) + if transport.status == 404 || transport.status == 405 { + return appwire.FactoryPolicy{}, policyFactoryTooOld() + } + if err != nil { + return appwire.FactoryPolicy{}, err + } + return decodeFactoryPolicy(raw) +} diff --git a/cmd/codeaf/appfactory_policy_test.go b/cmd/codeaf/appfactory_policy_test.go new file mode 100644 index 0000000000..e7ff37472a --- /dev/null +++ b/cmd/codeaf/appfactory_policy_test.go @@ -0,0 +1,118 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "reflect" + "strings" + "sync/atomic" + "testing" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/factory" +) + +// P12: both typed routes name an older factory and retain existing mappings. +func TestFactoryPolicyRefusals(t *testing.T) { + for _, tc := range []struct { + status int + answer, code, reason string + }{ + {404, `{"error":"factory endpoint not found"}`, appwire.CodeConflict, "factory_too_old"}, + {405, `{"error":"this endpoint accepts GET"}`, appwire.CodeConflict, "factory_too_old"}, + {403, `{"error":"only the policy's owner or the operator can change it"}`, appwire.CodeForbidden, ""}, + {409, `{"error":"the policy changed","reason":"stale"}`, appwire.CodeStale, "stale"}, + {422, `{"error":"allowance jobs must be distinct positive job ids"}`, appwire.CodeBadParams, ""}, + } { + for _, put := range []bool{false, true} { + // Reads are never owner-guarded: only a replacement can meet 403. + if !put && tc.status != 404 && tc.status != 405 { + continue + } + var sends atomic.Int64 + b := waitsBackend(t, func(w http.ResponseWriter, r *http.Request) { + sends.Add(1) + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(tc.answer)) + }) + var err error + if put { + _, err = b.FactoryPolicySet(context.Background(), appwire.FactoryPolicySetParams{Factory: "f", Group: "g"}) + } else { + _, err = b.FactoryPolicyGet(context.Background(), appwire.FactoryPolicyGetParams{Factory: "f", Group: "g"}) + } + var wire *appwire.Error + if !errors.As(err, &wire) || wire.Code != tc.code || sends.Load() != 1 { + t.Fatalf("status %d, put %t: %v, sends %d", tc.status, put, err, sends.Load()) + } + if tc.reason != "" { + detail, _ := wire.Detail.(*appwire.FactoryRefusal) + if detail == nil || detail.Reason != tc.reason { + t.Fatalf("detail = %+v", wire.Detail) + } + } + } + } +} + +// P9, P11, P12: one read or replacement carries group, authority and version. +func TestFactoryPolicyRoundTrip(t *testing.T) { + var gets, puts atomic.Int64 + group := "g / next" + allowances := []factory.Allowance{{ID: "m2", Jobs: []int{1, 2}, CapUSD: 15, EndsAt: "2026-11-12T12:00:00Z"}} + b := waitsBackend(t, func(w http.ResponseWriter, r *http.Request) { + if r.URL.EscapedPath() != "/v1/groups/g%20%2F%20next/policy" || r.Header.Get("Authorization") != "Bearer waits-fixture-bearer-credential" { + t.Errorf("request = %s %s", r.Method, r.URL.EscapedPath()) + } + if r.Method == "GET" { + gets.Add(1) + } else if r.Method == "PUT" { + puts.Add(1) + var body struct { + Stopped bool `json:"stopped"` + Allowances []factory.Allowance `json:"allowances"` + Version *int `json:"version"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || !body.Stopped || body.Version == nil || *body.Version != 7 || !reflect.DeepEqual(body.Allowances, allowances) { + t.Errorf("body = %+v, %v", body, err) + } + } else { + t.Errorf("method = %s", r.Method) + } + _, _ = w.Write([]byte(`{"group":"g / next","version":8,"stopped":true,"spent_usd":12,"allowances":[{"id":"m2","jobs":[1,2],"cap_usd":15,"ends_at":"2026-11-12T12:00:00Z","spent_usd":12,"state":"active"}]}`)) + }) + version := 7 + got, err := b.FactoryPolicySet(context.Background(), appwire.FactoryPolicySetParams{Factory: "f", Group: group, Stopped: true, Allowances: allowances, Version: &version}) + if err != nil || got.Version != 8 || got.SpentUSD != 12 || puts.Load() != 1 || gets.Load() != 0 { + t.Fatalf("set = %+v, %v", got, err) + } + got, err = b.FactoryPolicyGet(context.Background(), appwire.FactoryPolicyGetParams{Factory: "f", Group: group}) + if err != nil || got.Group != group || got.Allowances[0].State != "active" || gets.Load() != 1 || puts.Load() != 1 { + t.Fatalf("get = %+v, %v", got, err) + } + caps, err := b.FactoryCapabilities(context.Background()) + if err != nil || !caps.GroupPolicy || !caps.CreateGroup { + t.Fatalf("capabilities = %+v, %v", caps, err) + } +} + +// P11: direct backend calls validate before acquiring or sending credentials. +func TestFactoryPolicyBackendBounds(t *testing.T) { + var sends atomic.Int64 + b := waitsBackend(t, func(w http.ResponseWriter, r *http.Request) { sends.Add(1); w.WriteHeader(500) }) + for _, p := range []appwire.FactoryPolicySetParams{ + {Factory: "f", Group: ""}, {Factory: "f", Group: strings.Repeat("x", 201)}, + {Factory: "f", Group: "g", Allowances: []factory.Allowance{{ID: "bad id"}}}, + } { + _, err := b.FactoryPolicySet(context.Background(), p) + var wire *appwire.Error + if !errors.As(err, &wire) || wire.Code != appwire.CodeBadParams { + t.Fatal(err) + } + } + if sends.Load() != 0 { + t.Fatalf("invalid requests = %d", sends.Load()) + } +} diff --git a/cmd/codeaf/appfactory_repo_issues.go b/cmd/codeaf/appfactory_repo_issues.go new file mode 100644 index 0000000000..b6db998afa --- /dev/null +++ b/cmd/codeaf/appfactory_repo_issues.go @@ -0,0 +1,87 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/url" + "strconv" + "strings" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/factory/api" +) + +var _ appwire.FactoryRepoIssuesReader = (*appBackend)(nil) + +// FactoryRepoIssues reads one fixed route through the caller's connection. +func (b *appBackend) FactoryRepoIssues(ctx context.Context, p appwire.FactoryRepoIssuesParams) (appwire.FactoryRepoIssues, error) { + p, err := appwire.CheckRepoIssues(p) + if err != nil { + return appwire.FactoryRepoIssues{}, err + } + owner, name, _ := strings.Cut(p.Repo, "/") + query := url.Values{"page": {strconv.Itoa(*p.Page)}} + if p.Since != "" { + query.Set("since", p.Since) + } + path := appFactoryRepoRoute + url.PathEscape(owner) + "/" + url.PathEscape(name) + "/issues?" + query.Encode() + got, err := b.factoryHub().readRepoIssues(ctx, p.Factory, path) + if err != nil { + return appwire.FactoryRepoIssues{}, err + } + if got.Issues == nil { + got.Issues = []api.RepoIssue{} + } + return appwire.FactoryRepoIssues{Factory: p.Factory, RepoIssues: got}, nil +} + +// This read keeps the response body until refusals have been decoded, because +// the ordinary read mapping has no rate-limit retry detail. +func (h *appFactoryHub) readRepoIssues(ctx context.Context, id, path string) (api.RepoIssues, error) { + var out api.RepoIssues + row, token, err := h.credentialed(ctx, id) + if err != nil { + return out, err + } + r, err := h.send(ctx, row.URL, token, http.MethodGet, path, nil, appFactoryBody, nil) + if err != nil { + return out, err + } + if err := h.staleCredential(id, token); err != nil { + return out, err + } + if r.status != http.StatusOK { + err := repoIssuesFailure(r) + var wire *appwire.Error + if errors.As(err, &wire) && wire.Code == appwire.CodeSignedOut { + h.refused(id, token) + } + return out, err + } + if json.Unmarshal(r.body, &out) != nil { + return out, appwire.Errorf(appwire.CodeEngine, "the factory sent an answer this app cannot read") + } + h.touch(id) + return out, nil +} + +func repoIssuesFailure(r appFactoryReply) error { + err := appFactoryFailure(r) + if routeMissing(err) { + var wire *appwire.Error + if errors.As(factoryTooOld(), &wire) { + wire.Detail = &appwire.FactoryRefusal{Reason: appwire.FactoryReasonTooOld} + return wire + } + } + if r.status == http.StatusTooManyRequests { + var retry api.RepoIssuesRetry + var wire *appwire.Error + if json.Unmarshal(r.body, &retry) == nil && !retry.RetryAt.IsZero() && errors.As(err, &wire) { + wire.Detail = &retry + } + } + return err +} diff --git a/cmd/codeaf/appfactory_repo_issues_test.go b/cmd/codeaf/appfactory_repo_issues_test.go new file mode 100644 index 0000000000..8ce927ffd7 --- /dev/null +++ b/cmd/codeaf/appfactory_repo_issues_test.go @@ -0,0 +1,165 @@ +package main + +import ( + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/factory/api" +) + +type repoIssuesFactory struct { + mu sync.Mutex + asks []string +} + +func attachRepoIssuesFactory(t *testing.T, status int, body string) (*factoryWire, string, *repoIssuesFactory) { + t.Helper() + f := &repoIssuesFactory{} + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + w.Header().Set("Content-Type", "application/json") + switch r.URL.Path { + case "/v1/about", "/v1/me": + _ = json.NewEncoder(w).Encode(api.Me{Contract: api.Contract, Factory: api.Factory{Name: "acme"}, Mode: api.FactoryTeam}) + case "/v1/repos/acme/api/issues": + if r.Method != "GET" || r.Header.Get("Authorization") != "Bearer "+testHostToken { + t.Errorf("issue request = %s, authorization %q", r.Method, r.Header.Get("Authorization")) + } + f.mu.Lock() + f.asks = append(f.asks, r.URL.RawQuery) + f.mu.Unlock() + w.WriteHeader(status) + _, _ = io.WriteString(w, body) + default: + t.Errorf("unexpected %s %s", r.Method, r.URL.Path) + http.NotFound(w, r) + } + })) + t.Cleanup(srv.Close) + wire := newFactoryWire(t, testFactoryHub(t)) + var conn appwire.FactoryConnection + wire.must("factory.attach", map[string]any{"url": srv.URL, "token": testHostToken}, &conn) + return wire, conn.ID, f +} + +// C1, C2, C3, C4: The real wire advertises the read and carries its fields, +// caller credential, default, cursor, row count and PR-inclusive newest. +func TestAppFactoryRepoIssuesAdvertisesAndReadsThroughTheConnection(t *testing.T) { + body := `{"repo":"acme/api","issues":[{"id":9007199254740993,"number":7,"title":"Retry","body":"details","body_cut":true,"url":"https://github.com/acme/api/issues/7","state":"closed","created_at":"2026-10-01T00:00:00Z","updated_at":"2026-11-01T01:00:00Z"}],"rows":2,"newest":"2026-11-01T02:00:00Z"}` + wire, id, f := attachRepoIssuesFactory(t, 200, body) + if !strings.Contains(wire.hello, appwire.FeatureFactoryRepoIssues) { + t.Fatalf("hello = %s", wire.hello) + } + var caps appwire.FactoryCapabilities + wire.must("factory.capabilities", map[string]any{}, &caps) + if !caps.RepoIssues { + t.Fatalf("capabilities = %+v", caps) + } + const since = "2026-11-01T01:02:03.123+05:30" + var got appwire.FactoryRepoIssues + wire.must("factory.repo_issues", map[string]any{"factory": id, "repo": " acme/api ", "since": since, "page": 10}, &got) + if got.Factory != id || got.Repo != "acme/api" || got.Rows != 2 || got.Newest == nil || len(got.Issues) != 1 { + t.Fatalf("result = %+v", got) + } + if i := got.Issues[0]; i.ID != 9007199254740993 || i.Number != 7 || i.Title != "Retry" || i.Body != "details" || !i.BodyCut || + i.State != "closed" || i.URL != "https://github.com/acme/api/issues/7" || i.CreatedAt.Format(time.RFC3339) != "2026-10-01T00:00:00Z" || + i.UpdatedAt.Format(time.RFC3339) != "2026-11-01T01:00:00Z" || got.Newest.Format(time.RFC3339) != "2026-11-01T02:00:00Z" { + t.Fatalf("issue = %+v, newest = %v", i, got.Newest) + } + wire.must("factory.repo_issues", map[string]any{"factory": id, "repo": "acme/api"}, &got) + f.mu.Lock() + defer f.mu.Unlock() + want := url.Values{"since": {since}, "page": {"10"}}.Encode() + " page=1" + if strings.Join(f.asks, " ") != want { + t.Fatalf("queries = %v, want %s", f.asks, want) + } +} + +// C4: Invalid params never reach the Factory's HTTP route. +func TestAppFactoryRepoIssuesBadParamsSendNothing(t *testing.T) { + wire, id, f := attachRepoIssuesFactory(t, 200, `{"repo":"acme/api","issues":[],"rows":0}`) + for _, bad := range []map[string]any{ + {"factory": id, "repo": "acme/api", "page": 11}, + {"factory": id, "repo": "acme/api", "page": 0}, + {"factory": id, "repo": "acme/api", "since": "yesterday"}, + {"factory": id, "repo": "acme"}, + {"factory": id, "repo": "acme/api", "state": "open"}, + } { + wire.mustFail("factory.repo_issues", bad, appwire.CodeBadParams) + } + f.mu.Lock() + defer f.mu.Unlock() + if len(f.asks) != 0 { + t.Fatalf("refused reads sent %v", f.asks) + } +} + +func repoIssuesErrorDetail(t *testing.T, wire *factoryWire, id, want string) json.RawMessage { + t.Helper() + wire.mustFail("factory.repo_issues", map[string]any{"factory": id, "repo": "acme/api"}, want) + wire.mu.Lock() + defer wire.mu.Unlock() + for i := len(wire.lines) - 1; i >= 0; i-- { + var frame struct { + ID int `json:"id"` + Error *struct { + Detail json.RawMessage `json:"detail"` + } `json:"error"` + } + if json.Unmarshal([]byte(wire.lines[i]), &frame) == nil && frame.ID == wire.next && frame.Error != nil { + return frame.Error.Detail + } + } + t.Fatal("no error frame") + return nil +} + +// C6: The wire's busy refusal preserves the Factory's RFC 3339 retry time. +func TestAppFactoryRepoIssuesPreservesRateLimitRetryAt(t *testing.T) { + wire, id, _ := attachRepoIssuesFactory(t, 429, `{"error":"GitHub rate limit","retry_at":"2026-11-01T03:00:00Z"}`) + raw := repoIssuesErrorDetail(t, wire, id, appwire.CodeBusy) + var detail api.RepoIssuesRetry + if json.Unmarshal(raw, &detail) != nil || detail.RetryAt.Format(time.RFC3339) != "2026-11-01T03:00:00Z" { + t.Fatalf("detail = %s", raw) + } +} + +// C5, C7: A missing route is too old; a repository's 404 remains not_found. +func TestAppFactoryRepoIssuesDistinguishesAnOlderFactoryFromAMissingRepository(t *testing.T) { + for _, tc := range []struct{ body, code, reason string }{ + {`{"error":"factory endpoint not found"}`, appwire.CodeConflict, appwire.FactoryReasonTooOld}, + {`{"error":"repository not found"}`, appwire.CodeNotFound, ""}, + } { + t.Run(tc.code, func(t *testing.T) { + wire, id, _ := attachRepoIssuesFactory(t, 404, tc.body) + raw := repoIssuesErrorDetail(t, wire, id, tc.code) + var detail appwire.FactoryRefusal + if len(raw) > 0 && json.Unmarshal(raw, &detail) != nil { + t.Fatalf("detail = %s", raw) + } + if detail.Reason != tc.reason { + t.Fatalf("reason = %q, want %q", detail.Reason, tc.reason) + } + }) + } +} + +// The existing Factory authentication reasons remain the wire's sign-in codes. +func TestAppFactoryRepoIssuesKeepsSignInRefusals(t *testing.T) { + for _, tc := range []struct{ body, code string }{ + {`{"error":"sign in"}`, appwire.CodeSignedOut}, + {`{"error":"reconnect","reason":"github_expired"}`, appwire.CodeGitHubExpired}, + } { + t.Run(tc.code, func(t *testing.T) { + wire, id, _ := attachRepoIssuesFactory(t, 401, tc.body) + wire.mustFail("factory.repo_issues", map[string]any{"factory": id, "repo": "acme/api"}, tc.code) + }) + } +} diff --git a/cmd/codeaf/appfactory_split_from_test.go b/cmd/codeaf/appfactory_split_from_test.go new file mode 100644 index 0000000000..70a97ba0e5 --- /dev/null +++ b/cmd/codeaf/appfactory_split_from_test.go @@ -0,0 +1,179 @@ +package main + +import ( + "encoding/json" + "io" + "net/http" + "reflect" + "sync/atomic" + "testing" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/factory" +) + +// S1: the engine advertises split lineage through factory.capabilities. +func TestAppFactoryCreateSplitFromCapability(t *testing.T) { + w := newFactoryWire(t, testFactoryHub(t)) + var got map[string]any + w.must("factory.capabilities", map[string]any{}, &got) + if got["create_split_from"] != true { + t.Fatalf("create_split_from = %v, want true", got["create_split_from"]) + } +} + +// S2: a split create carries the parent number on either route and its current read. +func TestAppFactoryCreateSplitFromRoutesAndRead(t *testing.T) { + for _, issue := range []bool{false, true} { + t.Run(map[bool]string{false: "job", true: "issue"}[issue], func(t *testing.T) { + var posts atomic.Int64 + stub := startStub(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method == http.MethodGet { + io.WriteString(w, `{"id":"j","document_id":81,"version":1,"split_from":80}`) + return + } + posts.Add(1) + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Error(err) + } + path := "/v1/jobs" + if issue { + path = "/v1/issues" + } + if r.Method != http.MethodPost || r.URL.Path != path || body["split_from"] != float64(80) { + t.Errorf("create request: %s %s %+v", r.Method, r.URL.Path, body) + } + io.WriteString(w, `{"factory":{"name":"Stub"},"id":81}`) + }) + b, conn := attachStub(t, testFactoryHub(t), stub, actionTestCredential) + var made appwire.FactoryJobActed + wireOver(t, b).must("factory.job_create", map[string]any{ + "factory": conn.ID, "repo": "o/r", "title": "Smaller fix", "open_issue": issue, "split_from": 80, + }, &made) + if posts.Load() != 1 || made.Job != 81 || made.Current == nil || made.Current.SplitFrom != 80 { + t.Fatalf("split receipt: %+v; posts=%d", made, posts.Load()) + } + }) + } +} + +// S2/S4: the real wire and Factory retain lineage without changing the parent. +func TestAppFactoryCreateSplitFromPersistsAndLeavesParent(t *testing.T) { + f := startActionFactory(t) + p := signedIn(t, f, 1, "alice") + parent := f.create(t, factory.Item{Kind: factory.KindIssue, State: factory.StateNew, Assignee: "alice"}) + before := p.job(t, parent.ID) + made := p.act2(t, "factory.job_create", map[string]any{ + "factory": p.conn.ID, "repo": "acme/api", "title": "Smaller fix", "split_from": parent.ID, + }) + if got := p.job(t, made.Job); got.SplitFrom != parent.ID || got.FromJob != 0 { + t.Fatalf("split job read: %+v", got) + } + if after := p.job(t, parent.ID); !reflect.DeepEqual(before, after) { + t.Fatal("split create changed the parent's read") + } + plain := p.act2(t, "factory.job_create", map[string]any{"factory": p.conn.ID, "repo": "acme/api", "title": "Independent fix"}) + var got map[string]json.RawMessage + p.w.must("factory.job", map[string]any{"factory": p.conn.ID, "job": plain.Job}, &got) + if _, exists := got["split_from"]; exists { + t.Fatal("plain job read includes split_from") + } +} + +// S3: invalid numbers send nothing, and a missing parent keeps the existing 422 mapping. +func TestAppFactoryCreateSplitFromRefusals(t *testing.T) { + var posts atomic.Int64 + stub := startStub(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + posts.Add(1) + } + w.WriteHeader(http.StatusUnprocessableEntity) + io.WriteString(w, `{"error":"split_from must name a visible job in this factory"}`) + }) + b, conn := attachStub(t, testFactoryHub(t), stub, actionTestCredential) + w := wireOver(t, b) + for _, id := range []int{0, -1} { + w.mustFail("factory.job_create", map[string]any{ + "factory": conn.ID, "repo": "o/r", "title": "Fix", "split_from": id, + }, appwire.CodeBadParams) + } + if posts.Load() != 0 { + t.Fatal("invalid split_from was sent to the Factory") + } + w.mustFail("factory.job_create", map[string]any{ + "factory": conn.ID, "repo": "o/r", "title": "Fix", "split_from": 80, + }, appwire.CodeBadParams) + if posts.Load() != 1 { + t.Fatalf("missing parent sent %d requests, want 1", posts.Load()) + } +} + +// S5: an older strict decoder refuses before creation, with no fallback request. +func TestAppFactoryCreateSplitFromOlderFactory(t *testing.T) { + var posts, created atomic.Int64 + stub := startStub(t, func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodGet { + posts.Add(1) + } + var body struct{ Repo, Title, Text, Source string } + decoder := json.NewDecoder(r.Body) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&body); err != nil { + w.WriteHeader(http.StatusUnprocessableEntity) + json.NewEncoder(w).Encode(map[string]string{"error": "invalid JSON body: " + err.Error()}) + return + } + created.Add(1) + io.WriteString(w, `{"id":81}`) + }) + b, conn := attachStub(t, testFactoryHub(t), stub, actionTestCredential) + p := &person{w: wireOver(t, b), conn: conn} + words, detail := p.refused(t, "factory.job_create", map[string]any{ + "factory": conn.ID, "repo": "o/r", "title": "Fix", "split_from": 80, + }, appwire.CodeConflict) + if detail == nil || detail.Reason != appwire.FactoryReasonTooOld || words != factoryTooOld().(*appwire.Error).Message || posts.Load() != 1 || created.Load() != 0 { + t.Fatalf("old Factory refusal: %+v; posts=%d created=%d", detail, posts.Load(), created.Load()) + } +} + +// S3: a hidden parent and a missing parent have the same refusal and no effects. +func TestAppFactoryCreateSplitFromHiddenParent(t *testing.T) { + f := startActionFactory(t) + var issues atomic.Int64 + f.github.mu.Lock() + f.github.rest = func(r *http.Request) (int, string, bool) { + if r.URL.Path == "/repos/acme/hidden" { + return http.StatusNotFound, `{}`, true + } + if r.Method == http.MethodPost && r.URL.Path == "/repos/acme/api/issues" { + issues.Add(1) + return http.StatusCreated, `{"number":42}`, true + } + return 0, "", false + } + f.github.mu.Unlock() + p := signedIn(t, f, 1, "alice") + parent := f.create(t, factory.Item{Repo: "acme/hidden", Kind: factory.KindIssue, State: factory.StateNew}) + p.w.mustFail("factory.job", map[string]any{"factory": p.conn.ID, "job": parent.ID}, appwire.CodeNotFound) + before, err := f.st.Get(parent.ID) + if err != nil { + t.Fatal(err) + } + var refusal string + for _, issue := range []bool{false, true} { + for _, id := range []int{parent.ID, 80} { + words := p.w.mustFail("factory.job_create", map[string]any{ + "factory": p.conn.ID, "repo": "acme/api", "title": "Fix", "split_from": id, "open_issue": issue, + }, appwire.CodeBadParams) + if refusal != "" && words != refusal { + t.Fatalf("hidden/missing parent refusals differ: %q / %q", refusal, words) + } + refusal = words + } + } + items, err := f.st.List() + if err != nil || len(items) != 1 || !reflect.DeepEqual(items[0], before) || issues.Load() != 0 { + t.Fatalf("refused split: jobs=%d GitHub=%d err=%v", len(items), issues.Load(), err) + } +} diff --git a/cmd/codeaf/appfactory_waits.go b/cmd/codeaf/appfactory_waits.go new file mode 100644 index 0000000000..7d9c7ce1c3 --- /dev/null +++ b/cmd/codeaf/appfactory_waits.go @@ -0,0 +1,94 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "strconv" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/factory/api" +) + +var _ appwire.FactoryWaitsSetter = (*appBackend)(nil) +var _ appwire.FactoryFeaturesReader = (*appBackend)(nil) + +// waitsTransport keeps the status of this one write before the shared error +// mapping, so an absent typed route can be named as an older factory. +type waitsTransport struct { + http.RoundTripper + status int +} + +func (t *waitsTransport) RoundTrip(r *http.Request) (*http.Response, error) { + answer, err := t.RoundTripper.RoundTrip(r) + if answer != nil { + t.status = answer.StatusCode + } + return answer, err +} + +// FactoryWaitsSet replaces the parents once, then reads the current job once. +func (b *appBackend) FactoryWaitsSet(ctx context.Context, p appwire.FactoryWaitsSetParams) (appwire.FactoryWaitsSet, error) { + p, err := appwire.CheckWaitsSet(p) + if err != nil { + return appwire.FactoryWaitsSet{}, err + } + h := b.factoryHub() + client := *h.client + transport := &waitsTransport{RoundTripper: client.Transport} + if transport.RoundTripper == nil { + transport.RoundTripper = http.DefaultTransport + } + client.Transport = transport + _, err = h.sendWrite(ctx, &client, http.MethodPut, p.Factory, jobPath(p.Job, "waits_on"), struct { + WaitsOn []int `json:"waits_on"` + Expect *api.Expect `json:"expect,omitempty"` + }{p.WaitsOn, p.Expect}) + if transport.status == http.StatusNotFound || transport.status == http.StatusMethodNotAllowed || unknownFactoryField(err, "waits_on") { + old := factoryTooOld() + var refusal *appwire.Error + if errors.As(old, &refusal) { + refusal.Detail = &appwire.FactoryRefusal{Reason: "factory_too_old"} + } + return appwire.FactoryWaitsSet{}, old + } + if err != nil { + return appwire.FactoryWaitsSet{}, err + } + out := appwire.FactoryWaitsSet{Factory: p.Factory, Job: p.Job, WaitsOn: append([]int{}, p.WaitsOn...)} + var current api.Job + if err := h.read(ctx, p.Factory, "/v1/jobs/"+strconv.Itoa(p.Job), appFactoryBody, ¤t); err == nil { + out.Current = ¤t + } + return out, nil +} + +// FactoryFeatures reads the factory's public about with any held authority. +func (b *appBackend) FactoryFeatures(ctx context.Context, p appwire.FactoryFeaturesParams) (appwire.FactoryFeatures, error) { + h := b.factoryHub() + row, held, err := h.target(p.Factory) + if err != nil { + return appwire.FactoryFeatures{}, err + } + r, err := h.send(ctx, row.URL, held, http.MethodGet, "/v1/about", nil, appFactorySmall, nil) + if err != nil { + return appwire.FactoryFeatures{}, err + } + if err := h.staleCredential(p.Factory, held); err != nil { + return appwire.FactoryFeatures{}, err + } + out := appwire.FactoryFeatures{Features: []string{}} + if r.status < 200 || r.status >= 300 { + return appwire.FactoryFeatures{}, appFactoryFailure(r) + } + if err := json.Unmarshal(r.body, &out); err != nil { + return appwire.FactoryFeatures{}, appwire.Errorf(appwire.CodeEngine, "the factory sent an answer this app cannot read") + } + if out.Features == nil { + out.Features = []string{} + } + h.touch(p.Factory) + return out, nil +} diff --git a/cmd/codeaf/appfactory_waits_test.go b/cmd/codeaf/appfactory_waits_test.go new file mode 100644 index 0000000000..db8e0bcad0 --- /dev/null +++ b/cmd/codeaf/appfactory_waits_test.go @@ -0,0 +1,156 @@ +package main + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "reflect" + "strings" + "sync/atomic" + "testing" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/factory/api" +) + +func waitsBackend(t *testing.T, handler http.HandlerFunc) *appBackend { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + h := testFactoryHub(t) + h.loaded, h.rows = true, []appFactoryRow{{ID: "f", URL: srv.URL, Mode: api.FactoryTeam}} + h.hold("f", "waits-fixture-bearer-credential", 0) + b := &appBackend{} + b.factoryOnce.Do(func() { b.factories = h }) + t.Cleanup(h.close) + return b +} + +// W9: discovery reads the connected factory and carries its held authority. +func TestFactoryWaitsFeatures(t *testing.T) { + for _, about := range []string{`{"features":["waits_on"]}`, `{}`, `{"features":null}`} { + t.Run(about, func(t *testing.T) { + var reads atomic.Int64 + b := waitsBackend(t, func(w http.ResponseWriter, r *http.Request) { + reads.Add(1) + if r.Method != http.MethodGet || r.URL.Path != "/v1/about" || r.Header.Get("Authorization") != "Bearer waits-fixture-bearer-credential" { + t.Errorf("unexpected features read: %s %s", r.Method, r.URL.Path) + } + _, _ = w.Write([]byte(about)) + }) + got, err := b.FactoryFeatures(context.Background(), appwire.FactoryFeaturesParams{Factory: "f"}) + want := []string{} + if strings.Contains(about, "waits_on") { + want = []string{"waits_on"} + } + if err != nil || !reflect.DeepEqual(got.Features, want) || reads.Load() != 1 { + t.Fatalf("features = %+v, %v; reads = %d", got, err, reads.Load()) + } + caps, err := b.FactoryCapabilities(context.Background()) + if err != nil || !caps.WaitsOn { + t.Fatalf("capabilities = %+v, %v", caps, err) + } + }) + } +} + +// W9: older routes and strict decoders refuse clearly, while 422 and stale keep their mappings. +func TestFactoryWaitsRefusals(t *testing.T) { + for _, tc := range []struct { + name string + status int + answer, code, reason string + }{ + {"missing", 404, `{"error":"factory endpoint not found"}`, appwire.CodeConflict, "factory_too_old"}, + {"method", 405, `{"error":"this endpoint accepts GET"}`, appwire.CodeConflict, "factory_too_old"}, + {"old field", 400, `{"error":"json: unknown field \"waits_on\""}`, appwire.CodeConflict, "factory_too_old"}, + {"invalid", 422, `{"error":"a job cannot wait on itself"}`, appwire.CodeBadParams, ""}, + {"stale", 409, `{"error":"job moved on while you looked","reason":"stale"}`, appwire.CodeStale, "stale"}, + } { + t.Run(tc.name, func(t *testing.T) { + var writes atomic.Int64 + b := waitsBackend(t, func(w http.ResponseWriter, r *http.Request) { + writes.Add(1) + if r.Method != http.MethodPut || r.URL.Path != "/v1/jobs/1/waits_on" { + t.Errorf("unexpected write: %s %s", r.Method, r.URL.Path) + } + w.WriteHeader(tc.status) + _, _ = w.Write([]byte(tc.answer)) + }) + _, err := b.FactoryWaitsSet(context.Background(), appwire.FactoryWaitsSetParams{Factory: "f", Job: 1, WaitsOn: []int{2}}) + var wire *appwire.Error + if !errors.As(err, &wire) || wire.Code != tc.code || writes.Load() != 1 { + t.Fatalf("refusal = %v; writes = %d", err, writes.Load()) + } + detail, _ := wire.Detail.(*appwire.FactoryRefusal) + if tc.reason != "" && (detail == nil || detail.Reason != tc.reason) { + t.Fatalf("detail = %+v", wire.Detail) + } + if tc.reason == "factory_too_old" && wire.Message != "This factory doesn't support that yet; nothing was changed. Update the factory to use it." { + t.Fatalf("old factory words = %q", wire.Message) + } + if tc.status == 422 && wire.Message != "a job cannot wait on itself" { + t.Fatalf("422 words = %q", wire.Message) + } + }) + } +} + +// W9, W11: one PUT is followed by exactly one optional current-job read. +func TestFactoryWaitsSetReadsCurrentOnce(t *testing.T) { + for _, failRead := range []bool{false, true} { + var writes, reads atomic.Int64 + b := waitsBackend(t, func(w http.ResponseWriter, r *http.Request) { + switch r.Method { + case http.MethodPut: + writes.Add(1) + var body struct { + WaitsOn []int `json:"waits_on"` + Expect *api.Expect `json:"expect"` + } + if err := json.NewDecoder(r.Body).Decode(&body); err != nil || !reflect.DeepEqual(body.WaitsOn, []int{2, 3}) || body.Expect == nil || body.Expect.Version == nil || *body.Expect.Version != 7 { + t.Errorf("body = %+v, %v", body, err) + } + _, _ = w.Write([]byte(`{"id":1}`)) + case http.MethodGet: + reads.Add(1) + if r.URL.Path != "/v1/jobs/1" { + t.Errorf("unexpected current read: %s", r.URL.Path) + } + if failRead { + w.WriteHeader(503) + return + } + _, _ = w.Write([]byte(`{"id":"job 1","state":"backlog","waits_on":[2,3],"waiting_on":[3]}`)) + default: + t.Errorf("unexpected method %s", r.Method) + } + }) + rev := 7 + got, err := b.FactoryWaitsSet(context.Background(), appwire.FactoryWaitsSetParams{Factory: "f", Job: 1, WaitsOn: []int{2, 3}, Expect: &api.Expect{Version: &rev}}) + if err != nil || got.Factory != "f" || got.Job != 1 || !reflect.DeepEqual(got.WaitsOn, []int{2, 3}) || writes.Load() != 1 || reads.Load() != 1 { + t.Fatalf("answer = %+v, %v; writes/reads = %d/%d", got, err, writes.Load(), reads.Load()) + } + if failRead && got.Current != nil || !failRead && (got.Current == nil || !reflect.DeepEqual(got.Current.WaitingOn, []int{3})) { + t.Fatalf("current = %+v", got.Current) + } + } +} + +// W10: direct backend callers also cannot send an invalid wait list. +func TestFactoryWaitsBackendBounds(t *testing.T) { + var sends atomic.Int64 + b := waitsBackend(t, func(w http.ResponseWriter, r *http.Request) { sends.Add(1); w.WriteHeader(500) }) + for _, ids := range [][]int{{0}, {-1}, {2, 2}, make([]int, 21)} { + _, err := b.FactoryWaitsSet(context.Background(), appwire.FactoryWaitsSetParams{Factory: "f", Job: 1, WaitsOn: ids}) + var wire *appwire.Error + if !errors.As(err, &wire) || wire.Code != appwire.CodeBadParams { + t.Fatalf("invalid = %v", err) + } + } + if sends.Load() != 0 { + t.Fatalf("invalid sends = %d", sends.Load()) + } +} diff --git a/cmd/codeaf/appscope.go b/cmd/codeaf/appscope.go new file mode 100644 index 0000000000..8818e1f71c --- /dev/null +++ b/cmd/codeaf/appscope.go @@ -0,0 +1,44 @@ +package main + +import ( + "strings" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/session" +) + +// SetAgentScope writes the conversation's stored scope without opening it. +// A held conversation reads the word beside its next task-index reading. +func (b *appBackend) SetAgentScope(id, scope string) (appwire.AgentScope, error) { + if scope != session.WorkScopeProject && scope != session.WorkScopeConversation { + return appwire.AgentScope{}, appwire.Errorf(appwire.CodeBadParams, "scope is project or conversation") + } + var a *appAgent + b.locked(func() { a = b.agents[id] }) + if a == nil { + var err error + a, err = b.seenTarget(id) + if err != nil { + return appwire.AgentScope{}, err + } + } + a.gate.Lock() + defer a.gate.Unlock() + if err := b.lentFence(a, "scope"); err != nil { + return appwire.AgentScope{}, err + } + var transcript string + b.locked(func() { transcript = a.transcript }) + if strings.TrimSpace(transcript) == "" { + return appwire.AgentScope{}, appwire.Errorf(appwire.CodeConflict, "this conversation has no local folder on this computer") + } + dir := session.DirOf(transcript) + meta, err := session.LoadMeta(dir) + if err != nil || meta.ID == "" { + return appwire.AgentScope{}, appwire.Errorf(appwire.CodeConflict, "this conversation has no local folder on this computer") + } + if _, err := session.SetConversationScope(dir, scope); err != nil { + return appwire.AgentScope{}, appEngineError(err) + } + return appwire.AgentScope{ID: id, Scope: session.ConversationScopeAt(dir)}, nil +} diff --git a/cmd/codeaf/appscope_test.go b/cmd/codeaf/appscope_test.go new file mode 100644 index 0000000000..2dd906ffc8 --- /dev/null +++ b/cmd/codeaf/appscope_test.go @@ -0,0 +1,154 @@ +package main + +import ( + "bytes" + "encoding/json" + "os" + "path/filepath" + "reflect" + "slices" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/session" +) + +// L-FIX9-cross A7 +func TestAppScopeWritesAClosedConversationWithoutOpeningIt(t *testing.T) { + d := newUnreadDisk(t) + if err := d.backend.state.write(func(f *appStateFile) error { + f.Closed = map[string]time.Time{d.id: time.Now()} + return nil + }); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(filepath.Join(d.dir, "transcript.jsonl")) + if err != nil { + t.Fatal(err) + } + w := newActionWire(t, d.backend) + if !slices.Contains(w.hello, appwire.FeatureAgentScope) { + t.Fatal("the backend does not announce agents.scope") + } + for _, word := range []string{session.WorkScopeConversation, session.WorkScopeProject} { + raw, failure := w.call("agents.set_scope", map[string]string{"id": d.id, "scope": word}) + if failure != nil { + t.Fatal(failure) + } + var result appwire.AgentScope + if err := json.Unmarshal(raw, &result); err != nil { + t.Fatal(err) + } + if result != (appwire.AgentScope{ID: d.id, Scope: word}) || session.ConversationScopeAt(d.dir) != word { + t.Fatalf("set %q = %+v, stored %q", word, result, session.ConversationScopeAt(d.dir)) + } + } + d.mu.Lock() + opens := d.opens + d.mu.Unlock() + if opens != 0 || d.backend.held(d.id) || !d.backend.tidied(d.id) { + t.Fatalf("scope opened or reopened the conversation: opens %d, held %v, closed %v", opens, d.backend.held(d.id), d.backend.tidied(d.id)) + } + after, err := os.ReadFile(filepath.Join(d.dir, "transcript.jsonl")) + if err != nil || !bytes.Equal(before, after) { + t.Fatalf("scope changed the transcript: %q, %v", after, err) + } + d.rec.mu.Lock() + defer d.rec.mu.Unlock() + if slices.Contains(d.rec.events, appwire.EventAgentChanged) { + t.Fatalf("scope emitted agent.changed: %v", d.rec.events) + } +} + +// L-FIX9-cross A7 +func TestAppScopeRefusesAnUnknownAgent(t *testing.T) { + d := newUnreadDisk(t) + w := newActionWire(t, d.backend) + if _, err := w.call("agents.set_scope", map[string]string{"id": "no-such-agent", "scope": session.WorkScopeConversation}); err == nil || err.Code != appwire.CodeNotFound { + t.Fatalf("unknown agent = %v", err) + } +} + +// L-FIX9-cross A7 +func TestAppScopeRefusesALentConversationBeforeWriting(t *testing.T) { + backend, _, agent, dir := liveUnread(t, &fakeConversation{}) + a, err := backend.seenTarget(agent.ID) + if err != nil { + t.Fatal(err) + } + backend.locked(func() { a.live.conn.Lent = true }) + path := filepath.Join(dir, "meta.json") + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + _, err = backend.SetAgentScope(agent.ID, session.WorkScopeConversation) + failure, ok := err.(*appwire.Error) + want := errLentStage("scope").(*appwire.Error) + if !ok || !reflect.DeepEqual(failure, want) { + t.Fatalf("lent scope change = %v, want %v", err, want) + } + after, err := os.ReadFile(path) + if err != nil || !bytes.Equal(before, after) { + t.Fatalf("refused scope changed metadata: %q, %v", after, err) + } +} + +// L-FIX9-cross A7 +func TestAppScopeLeavesAHeldConversationAndItsUseTimeAlone(t *testing.T) { + conv := &fakeConversation{} + backend, _, agent, dir := liveUnread(t, conv) + a, err := backend.seenTarget(agent.ID) + if err != nil { + t.Fatal(err) + } + var before *appLive + var used time.Time + var view appwire.Agent + backend.locked(func() { before, used, view = a.live, a.live.lastUse, a.view }) + result, err := backend.SetAgentScope(agent.ID, session.WorkScopeConversation) + if err != nil || result.Scope != session.WorkScopeConversation || session.ConversationScopeAt(dir) != session.WorkScopeConversation { + t.Fatalf("held scope = %+v, %v", result, err) + } + backend.locked(func() { + if a.live != before || !a.live.lastUse.Equal(used) || !reflect.DeepEqual(a.view, view) { + t.Errorf("scope changed the held conversation or warmed it") + } + }) + conv.mu.Lock() + defer conv.mu.Unlock() + if conv.submits != 0 || conv.interrupted != 0 { + t.Fatalf("scope drove the conversation: submits %d, interruptions %d", conv.submits, conv.interrupted) + } +} + +// L-FIX9-cross A7 +func TestAppScopeRequiresALocalConversationFolder(t *testing.T) { + for _, where := range []string{"no transcript", "missing folder", "missing metadata"} { + t.Run(where, func(t *testing.T) { + d := newUnreadDisk(t) + a, err := d.backend.seenTarget(d.id) + if err != nil { + t.Fatal(err) + } + dir := t.TempDir() + transcript := "" + if where == "missing folder" { + dir = filepath.Join(dir, "absent") + transcript = filepath.Join(dir, "transcript.jsonl") + } else if where == "missing metadata" { + transcript = filepath.Join(dir, "transcript.jsonl") + } + d.backend.locked(func() { a.transcript = transcript }) + _, err = d.backend.SetAgentScope(d.id, session.WorkScopeConversation) + failure, ok := err.(*appwire.Error) + if !ok || failure.Code != appwire.CodeConflict || failure.Message != "this conversation has no local folder on this computer" { + t.Fatalf("%s = %v", where, err) + } + if _, err := os.Stat(filepath.Join(dir, "meta.json")); !os.IsNotExist(err) { + t.Fatalf("scope created a conversation: %v", err) + } + }) + } +} diff --git a/cmd/codeaf/appspendlive_test.go b/cmd/codeaf/appspendlive_test.go new file mode 100644 index 0000000000..78f98aee27 --- /dev/null +++ b/cmd/codeaf/appspendlive_test.go @@ -0,0 +1,216 @@ +package main + +import ( + "encoding/json" + "fmt" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/provider" + "github.com/Agent-Field/codeaf/internal/session" +) + +// Contract (a), (c), (d): a priced provider call reaches both tool phases +// during a live turn without deadlocking, while open and list keep saved spend. +func TestTurnToolCarriesLiveSpendDuringARunningTurn(t *testing.T) { + testTurnToolSpend(t, .125, .25) +} + +// Contract (b), (d): a real turn with no spend omits the JSON key at both +// tool phases and completes under the same deadline as a priced turn. +func TestTurnToolOmitsSpendWhenNothingWasSpent(t *testing.T) { + testTurnToolSpend(t, 0, 0) +} + +// Contract (a), (d): failed calls, approval updates and output refreshes carry +// the live reading at each emission, without holding the call-log lock. +func TestTurnToolRestatementsCarryTheCurrentSpend(t *testing.T) { + hold := make(chan struct{}) + defer close(hold) + conv := &fakeConversation{cost: .375, hold: hold, script: []session.Event{ + {Kind: session.EventToolBegin, CallID: "failed", Tool: "bash"}, + {Kind: session.EventToolFailed, CallID: "failed", Tool: "bash", Text: "failed"}, + {Kind: session.EventToolBegin, CallID: "long", Tool: "bash"}, + }} + backend, rec, agent := workBackend(t, conv) + backend.toolQuiet = 0 + if _, err := backend.Send(agent.ID, "read the output", ""); err != nil { + t.Fatal(err) + } + failed := toolRow(rec, t, func(row appwire.TurnTool) bool { return row.Call == "failed" && row.Phase == appwire.PhaseEnd }) + if failed.SpentUSD == nil || *failed.SpentUSD != .375 || failed.OK == nil || *failed.OK { + t.Fatalf("failed tool row = %+v", failed) + } + toolRow(rec, t, func(row appwire.TurnTool) bool { return row.Call == "long" && row.Phase == appwire.PhaseBegin }) + conv.mu.Lock() + conv.cost = .5 + conv.mu.Unlock() + conv.raise(session.Event{Kind: session.EventQuestion, Question: &session.Question{ + ID: 7, Kind: session.QuestionConsent, Head: "Allow the command?", + Subject: session.SubjectRef{Kind: session.SubjectCall, CallID: "long", Name: "bash"}, + Options: session.AnswerOptions(session.QuestionConsent), + }}) + asked := toolRow(rec, t, func(row appwire.TurnTool) bool { return row.Call == "long" && row.Ask != "" }) + if asked.SpentUSD == nil || *asked.SpentUSD != .5 { + t.Fatalf("approval tool row = %+v", asked) + } + conv.mu.Lock() + conv.cost = .75 + conv.mu.Unlock() + a, err := backend.find(agent.ID) + if err != nil { + t.Fatal(err) + } + backend.callOutput(a, agent.ID, backend.liveOf(a), session.Event{Kind: session.EventToolOutput, CallID: "long", Text: "still running\n"}) + fresh := toolRow(rec, t, func(row appwire.TurnTool) bool { return row.Call == "long" && len(row.Tail) == 1 }) + if fresh.SpentUSD == nil || *fresh.SpentUSD != .75 { + t.Fatalf("refreshed tool row = %+v", fresh) + } +} + +func testTurnToolSpend(t *testing.T, saved, cost float64) { + t.Helper() + d := newUnreadDisk(t) + meta, err := session.LoadMeta(d.dir) + if err != nil { + t.Fatal(err) + } + meta.SpentUSD = saved + if err := session.SaveMeta(d.dir, meta); err != nil { + t.Fatal(err) + } + transcript := filepath.Join(d.dir, "transcript.jsonl") + journal := fmt.Sprintf("{\"type\":\"session\",\"version\":1,\"id\":%q}\n{\"type\":\"usage\",\"usage\":{\"costUsd\":%g}}\n", d.id, saved) + if err := os.WriteFile(transcript, []byte(journal), 0o600); err != nil { + t.Fatal(err) + } + path := filepath.Join(meta.Workspace, "receipt.txt") + if err := os.WriteFile(path, []byte("the tool ran\n"), 0o600); err != nil { + t.Fatal(err) + } + args, err := json.Marshal(map[string]string{"path": path}) + if err != nil { + t.Fatal(err) + } + finish := make(chan struct{}) + var once sync.Once + release := func() { once.Do(func() { close(finish) }) } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var request struct { + Messages []struct { + Role string `json:"role"` + } `json:"messages"` + } + if err := json.NewDecoder(r.Body).Decode(&request); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + answered := false + for _, message := range request.Messages { + answered = answered || message.Role == "tool" + } + if answered { + select { + case <-finish: + case <-r.Context().Done(): + return + } + } + w.Header().Set("Content-Type", "text/event-stream") + if answered { + fmt.Fprint(w, "data: "+`{"choices":[{"index":0,"delta":{"role":"assistant","content":"done"},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"cost":0}}`+"\n\n") + } else { + fmt.Fprintf(w, "data: "+`{"choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"live-spend","type":"function","function":{"name":"read","arguments":%q}}]},"finish_reason":"tool_calls"}],"usage":{"prompt_tokens":10,"completion_tokens":5,"cost":%g}}`+"\n\n", string(args), cost) + } + fmt.Fprint(w, "data: [DONE]\n\n") + })) + t.Cleanup(server.Close) + profile := t.TempDir() + var engine *session.Agent + d.backend.open = func(ask appOpenAsk) (*appConn, error) { + var err error + engine, err = session.New(session.Config{ + Workspace: ask.Workspace, Place: session.Place{Dir: d.dir}, SessionFile: ask.Transcript, + ProfileDir: profile, Model: "test/model", APIKey: "fixture", BaseURL: server.URL, + OneModel: true, Routing: provider.RoutingOff, DeterministicNames: true, + }) + if err != nil { + return nil, err + } + return &appConn{Conv: engine, Workspace: ask.Workspace, Transcript: ask.Transcript, Release: func() { _ = engine.Close() }}, nil + } + // Release the held response before either the server or backend is closed. + t.Cleanup(release) + d.listed() + if _, err := d.backend.OpenAgent(d.id); err != nil { + t.Fatal(err) + } + if _, err := d.backend.Send(d.id, "read receipt.txt, then finish", ""); err != nil { + t.Fatal(err) + } + for _, phase := range []string{appwire.PhaseBegin, appwire.PhaseEnd} { + row := toolRow(d.rec, t, func(row appwire.TurnTool) bool { return row.Call == "live-spend" && row.Phase == phase }) + live := engine.Usage().CostUSD + if live != saved+cost { + t.Fatalf("live meter = %v, want %v", live, saved+cost) + } + if live > 0 && (row.SpentUSD == nil || *row.SpentUSD != live) { + t.Fatalf("%s tool row = %+v, want live spend %v", phase, row, live) + } + raw, err := json.Marshal(row) + if err != nil { + t.Fatal(err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + t.Fatal(err) + } + if _, present := fields["spent_usd"]; present != (live > 0) { + t.Fatalf("%s tool row has wrong spend presence: %s", phase, raw) + } + if phase == appwire.PhaseEnd && (row.OK == nil || !*row.OK || !strings.Contains(strings.Join(row.Tail, "\n"), "the tool ran")) { + t.Fatalf("the tool did not run successfully: %+v", row) + } + opened, err := d.backend.OpenAgent(d.id) + if err != nil || opened.SpentUSD != saved || opened.State != appwire.StateWorking { + t.Fatalf("running agents.open = %+v, %v; want saved spend %v", opened, err, saved) + } + if listed := d.listed(); listed.SpentUSD != saved { + t.Fatalf("running agents.list spend = %v, want saved spend %v", listed.SpentUSD, saved) + } + } + release() + d.rec.waitFor(t, 5*time.Second, appTurnDone) + entries, _, err := d.backend.Entries(d.id, "", 100) + if err != nil { + t.Fatal(err) + } + found := false + for _, entry := range entries { + if entry.Kind != appwire.EntryTool { + continue + } + found = true + raw, err := json.Marshal(entry) + if err != nil { + t.Fatal(err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + t.Fatal(err) + } + if _, present := fields["spent_usd"]; present { + t.Fatalf("history tool row carries live spend: %s", raw) + } + } + if !found { + t.Fatal("history did not replay the tool call") + } +} diff --git a/cmd/codeaf/apptools.go b/cmd/codeaf/apptools.go index 430092b0f4..5ed480d312 100644 --- a/cmd/codeaf/apptools.go +++ b/cmd/codeaf/apptools.go @@ -133,11 +133,15 @@ func appFileName(id string) string { // 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 { +func appToolKind(tool, service string, declared ...session.ActionCategory) string { if service != "" { return appwire.ToolConnector } - switch session.ActionCategoryForTool(tool) { + category := session.ActionCategoryForTool(tool) + if len(declared) > 0 && declared[0] != "" { + category = declared[0] + } + switch category { case session.ActionSearch: if strings.HasPrefix(tool, "web_") { return appwire.ToolWeb @@ -198,6 +202,7 @@ func (b *appBackend) newTurnCalls(a *appAgent) { // 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 { + category := b.clientToolEventCategory(a, event) log := b.callLog(a) var workspace string b.locked(func() { workspace = a.view.Workspace }) @@ -227,7 +232,7 @@ func (b *appBackend) callBegan(a *appAgent, agent string, turn uint64, event ses } 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) + kind := appToolKind(event.Tool, event.Service, category) if c.facts.Ask != "" { kind = appwire.ToolAsksFirst } @@ -295,6 +300,13 @@ func (b *appBackend) rowLocked(agent string, c *appCall) appwire.TurnTool { return row } +// sayTool reads the live meter only after the caller has released its locks, +// because a local conversation's Usage takes the session agent's lock. +func (live *appLive) sayTool(row appwire.TurnTool) { + row.SpentUSD = appFigure(live.conn.Conv.Usage().CostUSD) + live.box.Event(appwire.EventTurnTool, 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 { @@ -389,6 +401,7 @@ func (b *appBackend) callFinished(a *appAgent, event session.Event) { // 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 { + category := b.clientToolEventCategory(a, event) log := b.callLog(a) process := "" if ok && !event.HarnessMade && !event.Refused && !event.RejectedBeforeRun { @@ -399,7 +412,7 @@ func (b *appBackend) callEnded(a *appAgent, agent string, turn uint64, event ses 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, "") + c.facts.Kind = appToolKind(event.Tool, "", category) if event.CallID != "" { log.calls[event.CallID] = c log.order = append(log.order, event.CallID) @@ -468,7 +481,7 @@ func (b *appBackend) callAsked(a *appAgent, agent string, live *appLive, call, a row = b.rowLocked(agent, c) }) if known { - live.box.Event(appwire.EventTurnTool, row) + live.sayTool(row) } } @@ -517,7 +530,7 @@ func (b *appBackend) tickTools(a *appAgent, agent string, live *appLive) func() } }) for _, row := range rows { - live.box.Event(appwire.EventTurnTool, row) + live.sayTool(row) } } }) @@ -627,7 +640,7 @@ func (b *appBackend) appEntryFacts(a *appAgent, item session.DisplayEntry) appwi facts.MediaAttempt = item.ToolResult.MediaAttempt() var workspace string b.locked(func() { workspace = a.view.Workspace }) - facts.Kind = appToolKind(item.Tool, "") + facts.Kind = appToolKind(item.Tool, "", b.clientToolEventCategory(a, session.Event{Tool: 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}} diff --git a/cmd/codeaf/appwork.go b/cmd/codeaf/appwork.go index 13896f64b9..03c4c3d69c 100644 --- a/cmd/codeaf/appwork.go +++ b/cmd/codeaf/appwork.go @@ -330,9 +330,12 @@ func appTaskOfView(agent string, view session.AppTaskView) appwire.Task { if n != 0 { id = appTaskID(agent, n) } - task := appwire.Task{Retryable: view.Retryable, 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) + task := appwire.Task{Retryable: view.Retryable, ID: id, Agent: agent, Group: view.Group, Parent: view.Parent, 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 { + task.Parent = "" + if p != 0 { + task.Parent = appTaskID(agent, p) + } } switch view.State { case "waiting", "paused": diff --git a/cmd/codeaf/appwork_tree_test.go b/cmd/codeaf/appwork_tree_test.go new file mode 100644 index 0000000000..8f0f19b2f9 --- /dev/null +++ b/cmd/codeaf/appwork_tree_test.go @@ -0,0 +1,47 @@ +package main + +import ( + "testing" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/session" +) + +// L-LOOP2 C7a, C7b, C8a: the view-to-wire mapper tasks.list uses keeps every +// parent id, the run's group and the ended state. The session listing that +// produces these views is covered in internal/session. +func TestAppTasksListPreservesRunSubtaskParents(t *testing.T) { + const agent = "agent-1" + views := []session.AppTaskView{ + {ID: "7", Title: "Run", State: "done", Group: "group-7"}, + {ID: "t-child", Parent: "7", Title: "Child", State: "stopped", Group: "group-7"}, + {ID: "t-grandchild", Parent: "t-child", Title: "Grandchild", State: "stopped", Group: "group-7"}, + {ID: "9", Parent: "8", Title: "Ordinary task", State: "done"}, + {ID: "10", Parent: "0", Title: "Top-level task", State: "done"}, + } + byID := map[string]appwire.Task{} + for _, view := range views { + task := appTaskOfView(agent, view) + byID[task.ID] = task + if task.State == appwire.TaskRunning { + t.Fatalf("ended task reads running: %+v", task) + } + } + if got := byID["t-child"].Parent; got != "agent-1:7" { + t.Fatalf("a child of the run root has parent %q, want the run row agent-1:7", got) + } + if got := byID["t-grandchild"].Parent; got != "t-child" { + t.Fatalf("a grandchild has parent %q, want its parent row t-child", got) + } + if got := byID["agent-1:9"].Parent; got != "agent-1:8" { + t.Fatalf("an ordinary numeric parent changed: %q", got) + } + if got := byID["agent-1:10"].Parent; got != "" { + t.Fatalf("a zero parent became %q, want none", got) + } + for _, id := range []string{"agent-1:7", "t-child", "t-grandchild"} { + if byID[id].Group != "group-7" { + t.Fatalf("wire lost group: %+v", byID[id]) + } + } +} diff --git a/cmd/codeaf/chatv3_layout.go b/cmd/codeaf/chatv3_layout.go index 4f5d9a43c4..b753ecb2f8 100644 --- a/cmd/codeaf/chatv3_layout.go +++ b/cmd/codeaf/chatv3_layout.go @@ -677,8 +677,9 @@ func v3ScanBucket(bucket string) (spoken, empty []v3Folder) { } } if v3EmptyUnassignedSession(dir) { - // A team's reference keeps it, and so does a store that cannot say. - if referenced, err := teams.ConversationReferenced(config.ProfileDir(), place.Transcript()); err == nil && !referenced { + // A team or collection reference keeps a blank out of both piles. + // So does a store that cannot say whether it is referenced. + if referenced, err := teams.ConversationReferenced(config.ProfileDir(), place.Transcript()); err == nil && !referenced && !collectionKeepsConversation(dir) { empty = append(empty, folder) } continue @@ -710,14 +711,17 @@ func v3ScanBucket(bucket string) (spoken, empty []v3Folder) { // A team reference makes an empty conversation intentional, not reusable // litter. Only the teams that name this conversation are read (the store's // index), and a store that cannot say keeps the folder. +// A collection reference keeps it too, including when its store cannot be read. func v3EmptySession(dir string) bool { referenced, err := teams.ConversationReferenced(config.ProfileDir(), session.Place{Dir: dir}.Transcript()) if err != nil || referenced { return false } - return v3EmptyUnassignedSession(dir) + return v3EmptyUnassignedSession(dir) && !collectionKeepsConversation(dir) } +// v3EmptyUnassignedSession reads only content. References decide whether a blank +// is kept, never whether it is a conversation somebody has spoken in. func v3EmptyUnassignedSession(dir string) bool { place := session.Place{Dir: dir, Owned: true} spoken, sure := session.SpokeIn(place.Transcript()) @@ -751,7 +755,7 @@ func v3ReapEmpty(empty []v3Folder, keep string) { } transcript := session.Place{Dir: folder.dir}.Transcript() _ = teams.InspectConversation(config.ProfileDir(), transcript, func(referenced bool) error { - if !referenced && !session.InUse(transcript) && v3EmptyUnassignedSession(folder.dir) { + if !referenced && !session.InUse(transcript) && v3EmptyUnassignedSession(folder.dir) && !collectionKeepsConversation(folder.dir) { _ = os.RemoveAll(folder.dir) } return nil diff --git a/cmd/codeaf/chatv3_local.go b/cmd/codeaf/chatv3_local.go index b5fc049365..887157f3b0 100644 --- a/cmd/codeaf/chatv3_local.go +++ b/cmd/codeaf/chatv3_local.go @@ -452,15 +452,19 @@ func localDoors(options *tui3.Options, welcome remote.Welcome, settings config.C } if options.Agent != nil { // THE ENGINE RESOLVES THE PROFILE. A connected service is already on - // disk when this callback runs; setting the current model through the - // existing wire door makes the engine re-read that disk without sending - // a key or address through a second protocol. + // disk when this callback runs. Reapplying the run-only model makes the + // engine re-read that disk without saving an automatic replacement or + // sending a key or address through the wire. stock := options.ApplyModelSources options.ApplyModelSources = func(sources modelsource.Set) { if stock != nil { stock(sources) } - options.Agent.SetModel(options.Agent.Model()) + if door, ok := options.Agent.(interface{ UseModelForNow(string) error }); ok { + _ = door.UseModelForNow(options.Agent.Model()) + } else { + options.Agent.SetModel(options.Agent.Model()) + } } } } diff --git a/cmd/codeaf/chatv3_model_for_now_test.go b/cmd/codeaf/chatv3_model_for_now_test.go new file mode 100644 index 0000000000..2a0fa70262 --- /dev/null +++ b/cmd/codeaf/chatv3_model_for_now_test.go @@ -0,0 +1,43 @@ +package main + +import ( + "testing" + + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/modelsource" + "github.com/Agent-Field/codeaf/internal/remote" + "github.com/Agent-Field/codeaf/internal/session" + "github.com/Agent-Field/codeaf/internal/tui3" +) + +// C1, C2, C4: refreshing linked-local provider sources must not confirm the +// temporary model as a saved choice before catalog reconciliation can restore it. +func TestLinkedLocalSourceRefreshKeepsRunOnlyModelTemporary(t *testing.T) { + dir, profile := t.TempDir(), t.TempDir() + agent, err := session.New(session.Config{Workspace: t.TempDir(), Place: session.Place{Dir: dir}, Model: "vendor/configured", APIKey: "fixture", BaseURL: "http://127.0.0.1:1/v1"}) + if err != nil { + t.Fatal(err) + } + defer agent.Close() + loop, err := remote.Loopback(remote.Hello{Version: remote.Version}, remote.Options{Boot: func(remote.Hello) (*remote.Engine, error) { + return &remote.Engine{Agent: agent, RefreshModelSources: func() { agent.SetSources(modelsource.Set{}) }}, nil + }}) + if err != nil { + t.Fatal(err) + } + defer loop.Close() + if err := loop.Client.Agent().UseModelForNow("available/model"); err != nil { + t.Fatal(err) + } + options := tui3.Options{Agent: loop.Client.Agent(), ProfileDir: profile} + localDoors(&options, remote.Welcome{ProfileDir: profile}, config.Config{ProfileDir: profile}) + options.ApplyModelSources(modelsource.Set{}) + agent.SettleWrites() + meta, err := session.LoadMeta(dir) + if err != nil || meta.Model != "vendor/configured" || agent.Model() != "available/model" || loop.Client.Agent().ConfiguredModel() != "vendor/configured" { + t.Fatalf("source refresh persisted the run-only model: %+v, %v", meta, err) + } + if config.ChatModelAt(profile) != "" { + t.Fatal("source refresh wrote a talk preference") + } +} diff --git a/cmd/codeaf/chatv3_sweep.go b/cmd/codeaf/chatv3_sweep.go index 0c38d8c1d6..f204396515 100644 --- a/cmd/codeaf/chatv3_sweep.go +++ b/cmd/codeaf/chatv3_sweep.go @@ -42,7 +42,7 @@ const sweepLogName = "sweep.log" // directory to walk so that the rule can be pointed at a temp directory and // proved. [v3ErrandsRoot] is the one answer to where that is, and // [errandHeldByAutomation] keeps the errands an automation was saved from. -var sweepHome func(context.Context, string, func(string) bool, func(string)) = session.SweepHomeContext +var sweepHome func(context.Context, string, func(string) bool, func(string) bool, func(string)) = session.SweepHomeContext func (p *v3Process) startPlaceSweep() { ctx, cancel := context.WithCancel(context.Background()) @@ -51,7 +51,7 @@ func (p *v3Process) startPlaceSweep() { p.sweepDone = done guard.Go("chatv3/sweep-home", func() { defer close(done) - sweepHome(ctx, v3ErrandsRoot(), errandHeldByAutomation(), func(line string) { + sweepHome(ctx, v3ErrandsRoot(), errandHeldByAutomation(), collectionKeepsConversation, func(line string) { if ctx.Err() == nil { noteSweep(line) } diff --git a/cmd/codeaf/chatv3_sweep_test.go b/cmd/codeaf/chatv3_sweep_test.go index d4b6f6ee8f..e84a0e580b 100644 --- a/cmd/codeaf/chatv3_sweep_test.go +++ b/cmd/codeaf/chatv3_sweep_test.go @@ -28,7 +28,7 @@ func TestCloseJoinsPlaceSweepAndNextProcessStillSweeps(t *testing.T) { t.Cleanup(func() { sweepHome = previousSweep }) var starts atomic.Int32 var destructive atomic.Int32 - sweepHome = func(ctx context.Context, _ string, _ func(string) bool, _ func(string)) { + sweepHome = func(ctx context.Context, _ string, _, _ func(string) bool, _ func(string)) { i := int(starts.Add(1)) - 1 if i >= len(passes) { t.Errorf("unexpected sweep start %d", i+1) diff --git a/cmd/codeaf/collections.go b/cmd/codeaf/collections.go index 70ed21b4e0..f0afef48ba 100644 --- a/cmd/codeaf/collections.go +++ b/cmd/codeaf/collections.go @@ -22,8 +22,8 @@ const collectionsUsage = collectionsSummary + ` codeaf collections show codeaf collections add|remove codeaf collections find - kinds: collection, conversation, task, process, crew, artifact (a file path) - tasks and processes need --session ; all accept --db and --json + kinds: collection, conversation, task, process, crew, factory_job, artifact (a file path) + factory_job needs --factory ; 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) } @@ -32,6 +32,7 @@ func runCollectionsTo(args []string, output io.Writer) error { flags := commandFlags("collections") database := flags.String("db", home.Join("v3", "collections.db"), "collection database path; separate from learned memory") sessionID := flags.String("session", "", "owning conversation id for a task reference") + factoryID := flags.String("factory", "", "owning factory id for a factory_job reference") asJSON := flags.Bool("json", false, "print structured records") if err := parseCommandFlags(flags, reorder(flags, args)); err != nil { return err @@ -63,7 +64,7 @@ func runCollectionsTo(args []string, output io.Writer) error { } var ref workspace.Ref if verb == "add" || verb == "remove" || verb == "find" { - ref = workspace.Ref{Kind: workspace.Kind(rest[len(rest)-2]), ID: rest[len(rest)-1], SessionID: *sessionID} + ref = workspace.Ref{Kind: workspace.Kind(rest[len(rest)-2]), ID: rest[len(rest)-1], SessionID: *sessionID, Factory: *factoryID} if ref.Kind == workspace.ArtifactKind { if ref.ID == "" { return fmt.Errorf("%w: an artifact reference needs a file path", workspace.ErrInvalid) @@ -86,6 +87,8 @@ func runCollectionsTo(args []string, output io.Writer) error { if verb == "add" && ref.Kind == workspace.StandingKind { return fmt.Errorf("%w: standing items are gone, so a collection cannot gain one", workspace.ErrInvalid) } + } else if *factoryID != "" { + return fmt.Errorf("%w: --factory belongs to a factory_job reference in add, remove or find", workspace.ErrInvalid) } else if *sessionID != "" { return fmt.Errorf("%w: --session belongs to a task or process reference in add, remove or find", workspace.ErrInvalid) } @@ -205,6 +208,8 @@ func writeCollectionRef(output io.Writer, ref workspace.Ref) error { var err error 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 if ref.Kind == workspace.FactoryJobKind { + _, err = fmt.Fprintf(output, "%s %s factory %s\n", ref.Kind, ref.ID, ref.Factory) } else { _, err = fmt.Fprintf(output, "%s %s\n", ref.Kind, ref.ID) } diff --git a/cmd/codeaf/collections_factory_job_test.go b/cmd/codeaf/collections_factory_job_test.go new file mode 100644 index 0000000000..233234d8a0 --- /dev/null +++ b/cmd/codeaf/collections_factory_job_test.go @@ -0,0 +1,53 @@ +package main + +import ( + "bytes" + "encoding/json" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/appwire" + "github.com/Agent-Field/codeaf/internal/workspace" +) + +// Part A (A2): add/remove/find accept --factory, show names it readably, and +// omitting or misusing the flag is refused before opening storage. +func TestPartACollectionsCLIFactoryJob(t *testing.T) { + db := filepath.Join(t.TempDir(), "collections.db") + c := collectionCreate(t, db, "Jobs") + var out bytes.Buffer + if err := runCollectionsTo([]string{"add", c.ID, "factory_job", "7", "--db", db}, &out); err == nil || !strings.Contains(err.Error(), "factory") { + t.Fatalf("missing factory: %v", err) + } + collectionCommand(t, db, "add", c.ID, "factory_job", "7", "--factory", "f") + if err := runCollectionsTo([]string{"show", c.ID, "--db", db}, &out); err != nil { + t.Fatal(err) + } + if !strings.Contains(out.String(), "factory_job 7 factory f") { + t.Fatalf("show: %q", out.String()) + } + var refs []workspace.Ref + if err := json.Unmarshal([]byte(collectionCommand(t, db, "show", c.ID)), &refs); err != nil || len(refs) != 1 || refs[0].Factory != "f" { + t.Fatalf("refs: %+v %v", refs, err) + } + var parents []workspace.Collection + if err := json.Unmarshal([]byte(collectionCommand(t, db, "find", "factory_job", "7", "--factory", "f")), &parents); err != nil || len(parents) != 1 || parents[0].ID != c.ID { + t.Fatalf("find: %+v %v", parents, err) + } + collectionCommand(t, db, "remove", c.ID, "factory_job", "7", "--factory", "f") + if got := collectionCommand(t, db, "show", c.ID); got != "[]\n" { + t.Fatalf("remove: %s", got) + } + out.Reset() + if err := runCollectionsTo([]string{"add", c.ID, "conversation", "chat", "--factory", "f", "--db", db}, &out); err == nil { + t.Fatal("factory on conversation accepted") + } +} + +func TestPartACollectionRefConversionsRetainFactory(t *testing.T) { + r := appwire.Ref{Kind: appwire.RefFactoryJob, ID: "7", Factory: "f"} + if got := appWireRef(appStoreRef(r)); got != r { + t.Fatalf("round trip: %+v", got) + } +} diff --git a/cmd/codeaf/engine.go b/cmd/codeaf/engine.go index 911d7dd483..e9136a7904 100644 --- a/cmd/codeaf/engine.go +++ b/cmd/codeaf/engine.go @@ -1169,6 +1169,7 @@ func assembleEngine(hello remote.Hello, workspace, sessionFlag, launchDir string // Harnesses and RunHarness, properties of the machine that the shared // assembly fills for every door. cfg, open := v3Shape(cfg, v3LanesOverWire()) + open, clientTools := appClientToolsEngineOpener(open) // AND AUTOMATIONS ARE ON, which is the one capability on this list that a // connection does not take away. The store arrives already filled, from the @@ -1243,6 +1244,7 @@ func assembleEngine(hello remote.Hello, workspace, sessionFlag, launchDir string proc.warmModels("engine/models", agent, launch.Model) return &remote.Engine{ + ClientTools: clientTools, TeamsPaired: teamPaired, SyncService: chatSyncService(), ReleaseOnLeave: func(current remote.WrappedAgent) bool { diff --git a/docs/REMOTE.md b/docs/REMOTE.md index d181805772..c283d68c0a 100644 --- a/docs/REMOTE.md +++ b/docs/REMOTE.md @@ -76,6 +76,24 @@ Frame gzip is that shape: an old engine ignores the offer and answers plain JSON a new engine sends plain JSON unless the surface offered gzip. This does not negotiate protocol semantics down — a version mismatch is still refused. +Client tools use the same version-22 rule: `Welcome.clientTools` advertises +`ClientTools.Set` (`{owner, sets}`, one connection's whole snapshot) and +`ClientTool.Answer`. Each client mints one random owner token with at least +128 bits of entropy and carries it on every snapshot (26–256 bytes). A snapshot +from a new connection with the same token moves its sets and ends the old +owner's calls; a different token retains the ordinary collision checks. The old +connection's later departure cannot remove the new owner's sets. +Only a connection that attaches sets receives `clientToolCall` and +`clientToolEnded` frames; neither frame requires a reply. Calls and answers stay +with that connection, and leaving removes its sets and settles its waits. +Watching connections cannot attach or answer tools. A roaming client resends +its last acknowledged snapshot after redial only onto the same conversation, +using the welcome's transcript identity (or live instance when unnamed). A +changed conversation forgets that snapshot. One sender per owner skips queued +calls whose wait ended, so a stuck write blocks only that owner's sender. +Client requests share the ordinary call/write path and its failure behavior. +A holder without the flag receives none of these new calls. + **Why the handshake happens before the TUI starts.** Everything that might ask the person a question happens on a plain terminal: ssh's passphrase prompt, its unknown-host-key question, and this refusal. A TUI that came up first would diff --git a/docs/changes/unreleased/2296-codeaf-leg-finishes-in-ci.md b/docs/changes/unreleased/2296-codeaf-leg-finishes-in-ci.md new file mode 100644 index 0000000000..f99f06de54 --- /dev/null +++ b/docs/changes/unreleased/2296-codeaf-leg-finishes-in-ci.md @@ -0,0 +1,11 @@ +--- +kind: internal +title: The cmd/codeaf touched leg gets a 25-minute timeout +pr: 2296 +surface: [build] +invalidates: [] +--- + +The package's serial suite measured 17.8 minutes on the CI runner, over the +15-minute timeout, so tests after the cut went unchecked. Only the codeaf leg +changes; the other legs keep 15 minutes. diff --git a/docs/changes/unreleased/2336-app-generic-collections-and-client-tools.md b/docs/changes/unreleased/2336-app-generic-collections-and-client-tools.md new file mode 100644 index 0000000000..ea69b436b4 --- /dev/null +++ b/docs/changes/unreleased/2336-app-generic-collections-and-client-tools.md @@ -0,0 +1,29 @@ +--- +kind: added +title: Collections hold Factory jobs and keep blank conversations, and apps attach client tools +pr: 2336 +surface: [app, chat] +invalidates: + - The collections database becomes schema 7 on its first successful write; an engine built before this change refuses it, leaving collections unavailable there and data intact. + - A never-spoken conversation was removed at launch unless a team referenced it; a conversation any collection references is now kept as well. +--- + +**Factory jobs in collections.** A membership can name a Factory job: kind +`factory_job` with its `factory`. `codeaf collections add|remove|find` take +`--factory`. In the app, `collections.open` shows each job's title, status and +repository from one bounded, briefly shared read per Factory; a job that is gone +or unreadable keeps its row and says so. One job may sit in several collections. + +**Client tools.** An app attaches sets of tools to one conversation +(`agents.tools_set`, hello feature `agents.client_tools`). They follow the +built-in tools while that connection lasts. The engine sends every call as an +`agents.tool_call` event and waits for `agents.tool_result`; a lost connection, +the turn ending, or the deadline (default 30 s, ceiling 120 s) gives the model +an error saying the outcome is unknown, and a late or second answer is dropped. +Limits: 4 sets, 64 tools, no built-in names, never inherited by child tasks. +Remote-held conversations are refused for now. + +**Kept conversations.** Every cleanup path (launch, blank-chat sweep, background +sweep) now spares a never-spoken conversation that some collection holds, and an +unreadable collections file counts as holding it. After a restart, opening it +by id brings back its saved details. diff --git a/docs/changes/unreleased/2337-held-blank-conversations-never-resumed.md b/docs/changes/unreleased/2337-held-blank-conversations-never-resumed.md new file mode 100644 index 0000000000..3d745df9fc --- /dev/null +++ b/docs/changes/unreleased/2337-held-blank-conversations-never-resumed.md @@ -0,0 +1,12 @@ +--- +kind: fixed +title: A blank conversation a collection holds is never resumed and reads as live in its collection +pr: 2337 +surface: [app, chat] +invalidates: [] +--- + +A never-spoken conversation that a collection references is kept, but a launch +without an explicit session no longer resumes it ahead of the last spoken +conversation, and `collections.open` no longer reports it as deleted after a +restart. diff --git a/docs/changes/unreleased/2346-client-tool-source-message.md b/docs/changes/unreleased/2346-client-tool-source-message.md new file mode 100644 index 0000000000..2676eba4c7 --- /dev/null +++ b/docs/changes/unreleased/2346-client-tool-source-message.md @@ -0,0 +1,16 @@ +--- +kind: added +title: A client tool call names the message its turn answers +pr: 2346 +surface: [app, chat] +invalidates: [] +--- + +`agents.tool_call` carries an optional `source_message_id`: the durable id of +the message the running turn answers, the same string as `Entry.message_id` +on that message's chat entry. It is captured when the turn starts, so it is the +same after an engine restart and on a replayed call. A stopped turn asked again +carries its recorded message. Turns the engine starts itself, including a +carry-on after a quit or close, carry none. The hello feature +`agents.client_tools.source` is advertised with `agents.client_tools`. `turn` +remains the process-local counter. diff --git a/docs/changes/unreleased/2360-client-tool-answer-wait-name.md b/docs/changes/unreleased/2360-client-tool-answer-wait-name.md new file mode 100644 index 0000000000..abb3050079 --- /dev/null +++ b/docs/changes/unreleased/2360-client-tool-answer-wait-name.md @@ -0,0 +1,11 @@ +--- +kind: internal +title: Client tools name their answer cutoff so the leaf-room law reads it as a wait +pr: 2360 +surface: [app] +invalidates: [] +--- + +The pending client-tool answer timestamp is named `answerBy`, and its selected +millisecond limit is named `waitMS`. The client's effective time limit, emitted +`DeadlineMS`, wait timer and expiry checks are unchanged. diff --git a/docs/changes/unreleased/2413-turn-tool-live-spend.md b/docs/changes/unreleased/2413-turn-tool-live-spend.md new file mode 100644 index 0000000000..84acadc799 --- /dev/null +++ b/docs/changes/unreleased/2413-turn-tool-live-spend.md @@ -0,0 +1,12 @@ +--- +kind: added +title: Tool step events carry the conversation's live spend so far +pr: 2413 +surface: [app, docs] +invalidates: + - A running turn said nothing about its conversation's spend until it ended; turn.tool now carries the live figure as spent_usd. +--- + +`spent_usd` on `turn.tool` is the same live meter `agents.budget.get` answers as +`engine.spent_usd`, so it can be ahead of `Agent.spent_usd`, which is saved at +turn end. Zero or unknown readings and history rows omit it. diff --git a/docs/changes/unreleased/2414-replay-late-receipts.md b/docs/changes/unreleased/2414-replay-late-receipts.md new file mode 100644 index 0000000000..4cef8d6e70 --- /dev/null +++ b/docs/changes/unreleased/2414-replay-late-receipts.md @@ -0,0 +1,12 @@ +--- +kind: fixed +title: A resumed conversation keeps its late receipts and never saves a lower spend +pr: 2414 +surface: [chat, engine, app] +invalidates: + - Replay restored a conversation's spend from usage seals only, so a restart forgot late receipts and the next turn could save a lower total. + - Journal call lines were never read back as spend; a call line marked as a late receipt now restores one call and its charge. +--- + +Ordinary call lines remain evidence only. A meta row rebuilt from the +transcript counts the same late receipts. diff --git a/docs/changes/unreleased/2415-factory-job-create-extras.md b/docs/changes/unreleased/2415-factory-job-create-extras.md new file mode 100644 index 0000000000..5d506e9699 --- /dev/null +++ b/docs/changes/unreleased/2415-factory-job-create-extras.md @@ -0,0 +1,24 @@ +--- +kind: added +title: Factory jobs can be made with an issue, labels, a budget and a retry key, and take context +pr: 2415 +surface: [engine, factory] +invalidates: + - "Was true: factory.job_create always made a job without a GitHub issue, took no budget or labels, a retried create could make a second job, and a job's plan read only its description. Is true now: factory.job_create takes optional open_issue, group, milestone, budget and key, and factory.job_context_set attaches context the plan step reads." +--- + +`factory.job_create` takes optional `open_issue` (posts to `POST /v1/issues` +and opens one GitHub issue with the title and text), `group` and `milestone` +(opaque labels stored on the job and returned on its reads), `budget` (one of +the standard steps, the job's budget from creation) and `key` (sent as +`Idempotency-Key`, so a retried create returns the same job). +`factory.job_context_set {factory, job, expect, context}` replaces a job's +context attachments while it has no run: up to 20 `{title, markdown, source?}` +items, at most 100,000 characters in all, kept outside the 20,000-character +description. The plan step that runs reads them whole (the first conversation +step that runs when there is no plan step); job reads list them by title and +source only, and they never go to GitHub. `factory.capabilities` announces +`create_issue`, `create_group`, `create_budget`, `create_key` and +`job_context`; hello names `factory.job_context`. A Factory older than a +field or route answers `conflict` with `detail.reason: "factory_too_old"` and +nothing changes. diff --git a/docs/changes/unreleased/2416-factory-job-events.md b/docs/changes/unreleased/2416-factory-job-events.md new file mode 100644 index 0000000000..09961ac149 --- /dev/null +++ b/docs/changes/unreleased/2416-factory-job-events.md @@ -0,0 +1,25 @@ +--- +kind: added +title: App clients can read Factory job outcomes from a cursor and have them pushed as factory.event +pr: 2416 +surface: [engine, factory] +invalidates: + - "Was true: an app learned that a Factory job merged, failed or needed a person only by reading the whole queue again, and nothing kept those changes for an app that was not connected. Is true now: the Factory keeps the newest 2,000 job outcomes and factory.events_since answers them from the app's cursor, with factory.event pushing later ones." +--- + +The Factory records a job outcome at the store write: `merged`, `finished` +(with how), `failed` (code, step) or `needs_you` (need, reason). Each carries +the job's own time for that fact, the same time `factory.job` answers. They +are kept in `job-outcomes.json` beside the live record, newest 2,000, and +served by `GET /v1/job-events?after=&limit=&wait=` for the jobs the caller may +see. The live record and `/v1/events` are unchanged. + +`factory.events_since {factory, after?, limit?, follow?}` (hello feature +`factory.job_events`, capability `job_events`) answers +`{factory, supported, events, cursor, reset, more}`. `reset` means the cursor +was absent, older than what is kept, or crossed a gap written by an older +binary: read the Factory's current state once, then continue from `cursor`. +With `follow`, the engine pushes each later outcome as `factory.event`. A +Factory older than the route answers `supported: false`. A job created already +finished or waiting records nothing, and `group` stays empty until jobs carry +one. diff --git a/docs/changes/unreleased/2417-client-tools-remote.md b/docs/changes/unreleased/2417-client-tools-remote.md new file mode 100644 index 0000000000..9d3a54c23a --- /dev/null +++ b/docs/changes/unreleased/2417-client-tools-remote.md @@ -0,0 +1,24 @@ +--- +kind: added +title: Client tools reach conversations held by another codeaf or on another computer +pr: 2417 +surface: [app, remote, chat] +invalidates: + - The app answered bad_scope to agents.tools_set for a conversation another codeaf host held or one held on another computer; it now forwards the tools when the holder supports it and stores them for a chat held elsewhere. +--- + +**Client tools across the remote door.** When the app joins a conversation that +another codeaf host already holds, `agents.tools_set` sends the agent's sets to +that holder. Its calls come back as `agents.tool_call` on the app connection +that set them, and `agents.tool_result` answers go back the same way. The app +pushes the sets again every time it joins the conversation, and a roaming +connection re-sends them after a redial. + +A connection that leaves loses its sets, and its pending calls end at once with +the existing disconnected answer. A call nobody answers still ends at its wait. +Two connections on one conversation keep separate sets and never receive each +other's calls. + +A chat held on another computer keeps its sets until it runs here again. The app +hello adds `agents.client_tools.remote`. A holder from an older build is still +answered with `bad_scope`, and the remote protocol version is unchanged. diff --git a/docs/changes/unreleased/2418-factory-job-waits.md b/docs/changes/unreleased/2418-factory-job-waits.md new file mode 100644 index 0000000000..b3a94bef63 --- /dev/null +++ b/docs/changes/unreleased/2418-factory-job-waits.md @@ -0,0 +1,11 @@ +--- +kind: added +title: A Factory job can wait on other jobs' merges before it starts +pr: 2418 +surface: [factory, app] +invalidates: [] +--- +`factory.waits_set` sets the jobs one Backlog job waits on. It becomes startable +once every parent's pull request has merged; a parent that fails or closes +unmerged blocks it with the reason until it is fixed. A person's Start still +starts it at any time. `factory.features` says whether a factory serves this. diff --git a/docs/changes/unreleased/2419-factory-repo-issues.md b/docs/changes/unreleased/2419-factory-repo-issues.md new file mode 100644 index 0000000000..87377168d4 --- /dev/null +++ b/docs/changes/unreleased/2419-factory-repo-issues.md @@ -0,0 +1,27 @@ +--- +kind: added +title: App clients can read a repository's issues through the Factory's GitHub connection +pr: 2419 +surface: [engine, factory] +invalidates: + - "Was true: an app could read a repository's issues only through GitHub's public API or the Factory's open-issue search. Is true now: factory.repo_issues lists issues in every state, updated since a time, through the Factory's GitHub connection, so private repositories work." +--- + +`factory.repo_issues {factory, repo, since?, page?}` lists one repository's +GitHub issues in every state, oldest update first, through the caller's Factory +connection. The hello feature is `factory.repo_issues`; the capability is +`repo_issues`. The read accepts an optional RFC 3339 lower bound and `page` +from 1–10, default 1, with at most 50 GitHub rows each. Bodies are cut at +8,192 bytes on a rune boundary, marked by `body_cut: true`. This replaces +the earlier 4,000-character limit, preserving matching words later in that prefix. + +Pull requests are excluded from `issues` while still contributing to `rows` +and `newest`, so a client can advance its cursor through them. GitHub's rate +limit on the issues request answers `busy` with `detail.retry_at`; a rate +limit during repository visibility checking currently answers `not_found`, +as on every repository route. A missing Factory route answers +`conflict` with reason `factory_too_old`, distinct from a repository's +`not_found`. The route itself stores nothing and appends no activity; the Factory's +ordinary per-request housekeeping runs as it does for every authenticated request. +This app wire read adds no chat or resident surface, so neither shipped manual +corpus changes. diff --git a/docs/changes/unreleased/2423-factory-group-policy.md b/docs/changes/unreleased/2423-factory-group-policy.md new file mode 100644 index 0000000000..584a6ac982 --- /dev/null +++ b/docs/changes/unreleased/2423-factory-group-policy.md @@ -0,0 +1,13 @@ +--- +kind: added +title: A Factory group policy starts waiting jobs inside an allowance and stops past it +pr: 2423 +surface: [factory, app] +invalidates: [] +--- +`factory.policy_set` gives a group of jobs allowances (jobs, a dollar cap, an +end date) and a stopped flag. A released waiting job starts on its own only +inside an allowance with room for its budget; a run the policy started holds +when its allowance ends or is spent, and every run of a stopped group holds. +A person's Start or Continue still runs a job as theirs, unless its group is +stopped. `factory.policy_get` reports what each allowance has spent. diff --git a/docs/changes/unreleased/2429-factory-job-split-from.md b/docs/changes/unreleased/2429-factory-job-split-from.md new file mode 100644 index 0000000000..05a489f3a0 --- /dev/null +++ b/docs/changes/unreleased/2429-factory-job-split-from.md @@ -0,0 +1,21 @@ +--- +kind: added +title: Factory jobs remember which job they were split from +pr: 2429 +surface: [engine, factory] +invalidates: + - "Was true: a job created from part of another job carried no split lineage. Is true now: factory.job_create accepts split_from, and the new job's reads retain the parent job number." +--- + +`factory.job_create` accepts optional `split_from`, a positive job number held +by the same Factory and visible to the caller. `factory.capabilities` announces +`create_split_from: true`. The job read, queue and create reply's `current` +return the number when present; other jobs omit it. The new job's own activity +records `split from #N`, and the parent stays unchanged. + +Zero and negative numbers are `bad_params` before sending. A missing or hidden +parent is refused before any job or GitHub issue is made, using the existing +422-to-`bad_params` mapping. An older Factory's strict rejection of the field +answers `conflict` with `detail.reason: "factory_too_old"`, without a fallback. +Split lineage is separate from run-as-new lineage, preserving pipeline +stamping, step briefs and provisional-copy cleanup. diff --git a/docs/changes/unreleased/2436-factory-bookkeeping-saved-reads.md b/docs/changes/unreleased/2436-factory-bookkeeping-saved-reads.md new file mode 100644 index 0000000000..5ea5976fb2 --- /dev/null +++ b/docs/changes/unreleased/2436-factory-bookkeeping-saved-reads.md @@ -0,0 +1,7 @@ +--- +kind: internal +title: Factory policy, waits, split-parent and outcome-cursor reads skip author lookup +pr: 2436 +surface: [factory] +invalidates: [] +--- diff --git a/docs/changes/unreleased/2439-factory-transient-failures-retry.md b/docs/changes/unreleased/2439-factory-transient-failures-retry.md new file mode 100644 index 0000000000..b28c873ff6 --- /dev/null +++ b/docs/changes/unreleased/2439-factory-transient-failures-retry.md @@ -0,0 +1,18 @@ +--- +kind: fixed +title: A Factory rate limit or server error no longer reads as a missing repository or replays for a day +pr: 2439 +surface: [engine, factory] +invalidates: + - "Was true: a rate limit during the Factory's repository visibility check answered not_found on every repository route, and the denial was cached for ten minutes. Is true now: it answers busy with detail.retry_at on factory.repo_issues (busy on other calls), and the next check after the reset asks GitHub again." + - "Was true: a keyed Factory request replayed its first answer for 24 hours even when that answer was a server error. Is true now: 5xx, 408 and 429 answers are not replayed, so a retry with the same key runs again." +--- + +A spent GitHub rate limit on the repository visibility check (403 or 429 +with `X-RateLimit-Remaining: 0` or a `Retry-After`) now answers HTTP 429 with +`retry_at` on the repository routes, in the issues read's existing shape, and is +never cached as a denial. Job lookups behind visibility still answer a hidden job +the same as a missing one. A source poll that fails on a spent limit says +"rate limited". Under an Idempotency-Key, 5xx, 408 and 429 answers are no longer +kept or replayed, including rows stored by an older build; successes and other +refusals still replay for 24 hours. diff --git a/docs/changes/unreleased/2440-abandoned-turn-step-seal.md b/docs/changes/unreleased/2440-abandoned-turn-step-seal.md new file mode 100644 index 0000000000..4d9f4e92bb --- /dev/null +++ b/docs/changes/unreleased/2440-abandoned-turn-step-seal.md @@ -0,0 +1,13 @@ +--- +kind: fixed +title: A turn stopped at the second stage keeps its paid steps across a restart +pr: 2440 +surface: [chat, engine] +invalidates: + - "Was true: a turn let go of at the stop bound recorded its spend only on its abandoned line, which replay never bills, so a restart could save a lower total. Is true now: the turn's paid conversation steps are written as a usage seal without duration, and replay restores them." +--- + +The abandoned line stays evidence and is never billed; errands on the turn's clock +keep their own usage lines. A settle turn stopping at its bound and a final stage +report now write the same step seal, without duration. Transcripts written +before this change cannot recover an abandoned turn's missing steps. diff --git a/docs/changes/unreleased/2446-task-tree-bounds.md b/docs/changes/unreleased/2446-task-tree-bounds.md new file mode 100644 index 0000000000..93ad886221 --- /dev/null +++ b/docs/changes/unreleased/2446-task-tree-bounds.md @@ -0,0 +1,22 @@ +--- +kind: fixed +title: task workers keep their own assignment and bounded task trees +pr: 2446 +surface: [engine, chat, docs] +invalidates: + - "Was true: conversation proposals opened their workers on the whole conversation request, including instructions to hand out tasks. Is true now: proposed run workers read their own work first and carry the conversation request under a rule that scopes it to that assignment." + - "Was true: run-worker task creation had no duplicate-title, depth or work-count gate, and a crewless run could have no dollar ceiling. Is true now: worker growth refuses duplicate titles, more than two descendant levels or more than 20 counted tasks, and ordinary conversation runs share the configured per-task ceiling across their whole tree." +--- + +Growth refusals are atomic across concurrent workers and leave a refused batch unwritten. +Countdown-approved proposals joining a live run obey the same gate; explicit approval and +typed tasks keep their existing join authority. Task-cap call refusals stop the entire run +as a dollar-limit ending, while a checker's own ceiling remains local to its check. The +report names the configured task limit and `/crew` when that ceiling binds. Delegated +programs retain their own ceilings. + +Continuing an interrupted run subtracts its persisted spend from the task allowance +and retains the configured-limit explanation. Only checks seated by the review round +are exempt from the task count; a worker's `--role check` tasks still count. Existing +cost and elapsed limits retain completed-work precedence; only a typed task-wide +spend refusal suppresses completion. diff --git a/docs/changes/unreleased/2452-worker-task-scope.md b/docs/changes/unreleased/2452-worker-task-scope.md new file mode 100644 index 0000000000..9ab9d0b571 --- /dev/null +++ b/docs/changes/unreleased/2452-worker-task-scope.md @@ -0,0 +1,24 @@ +--- +kind: fixed +title: proposed run workers receive only their assignment and add only their own children +pr: 2452 +surface: [engine, chat, app, docs] +invalidates: + - "Was true: proposed runs carried the conversation request, admission evidence and original-message pointer under a scope rule. Is true now: their documents contain only the proposed title, work, deliverable and acceptance." + - "Was true: a run worker could add siblings or root children and evade depth limits by renaming tasks. Is true now: each worker adds only beneath its own task and has five counted additions, alongside the existing tree bounds." + - "Was true: app reads dropped nonnumeric plan parents and a stop could leave open children below an ended root. Is true now: listed parents match listed task ids and stopping cancels lingering work even when the root already ended." +--- + +Worker additions retain their adding task in the live store and archive. Engine-created +handoffs, reviews and fixes do not consume the worker allowance; cancelled additions do +not count. Run-bound callers without a task binding retain their previous bounds and +default parent. Person-typed briefs, delegated program words and the older session-tree +road retain their existing documents. + +App lists inherit recorded run groups and reflect stopped store states while worker +returns settle. A stop preserves earlier completed results and clears open holds. +Joined assignments and their descendants inherit their own ask. Woken workers distinguish +their own dispatched children from other work they must not redo. Setting a store aside +ends lingering open descendants even below a completed root; stopping an older retained +run can repair its children without touching the newer run. Released ready work reads +queued once its supervisor is gone, while a live run keeps its existing display. diff --git a/docs/changes/unreleased/2460-bare-429-rate-limit.md b/docs/changes/unreleased/2460-bare-429-rate-limit.md new file mode 100644 index 0000000000..660e6bcbff --- /dev/null +++ b/docs/changes/unreleased/2460-bare-429-rate-limit.md @@ -0,0 +1,12 @@ +--- +kind: fixed +title: a GitHub 429 without rate-limit headers is a rate limit, not a missing repository +pr: 2460 +surface: [factory] +invalidates: + - "Was true: a GitHub 429 on the Factory's visibility check without X-RateLimit-Remaining or Retry-After read as repository not found and was cached as a denial. Is true now: every 429 answers busy with retry_at and the next read checks GitHub again." +--- + +The repository issues read answers a bare 429 the same way instead of a 502, and a poll's +trouble words say rate limited. Without a reset or Retry-After hint, retry_at is one minute +after the Factory's clock. A 403 is a rate limit only with the spent-limit headers. diff --git a/docs/changes/unreleased/2461-check-ceiling-per-check.md b/docs/changes/unreleased/2461-check-ceiling-per-check.md new file mode 100644 index 0000000000..0780d48a96 --- /dev/null +++ b/docs/changes/unreleased/2461-check-ceiling-per-check.md @@ -0,0 +1,12 @@ +--- +kind: fixed +title: each check in a task run gets its own checker spend ceiling +pr: 2461 +surface: [engine, chat, docs] +invalidates: + - "Was true: a run's checker ceiling was tallied across every check in the run, so once one check spent it, every later check was refused on its first call. Is true now: each check task has its own checker ceiling." +--- + +Every check's spend still counts toward the run's per-task limit and the day's limits. +A crew that serves one task keeps its per-seat ceiling, and saved crew records keep their +per-seat totals. diff --git a/docs/changes/unreleased/2462-agent-scope.md b/docs/changes/unreleased/2462-agent-scope.md new file mode 100644 index 0000000000..c05b340d01 --- /dev/null +++ b/docs/changes/unreleased/2462-agent-scope.md @@ -0,0 +1,18 @@ +--- +kind: added +title: A conversation can be told only about its own work (agents.set_scope) +pr: 2462 +surface: [app, engine] +invalidates: + - "Was true: every conversation in a folder was told about the other conversations' landed and running work there, and its tasks tool listed them. Is true now: a client can set a conversation's saved scope to conversation so its turns and tasks tool show only its own work; project remains the default and restores the earlier behavior." +--- + +`agents.set_scope {id, scope}` stores `project` or `conversation` and answers the +stored word. Engines serving it announce `agents.scope`. The choice survives a +restart and takes effect on the next turn or tasks call without opening, +warming or interrupting the conversation. Its folder, transcript, title, model +and tasks stay as they were. A lent conversation or one without a local folder +is refused. + +The person's task list, task nodes and the write-safety checks keep their +existing behavior. diff --git a/docs/changes/unreleased/2464-node-road-tree-bounds.md b/docs/changes/unreleased/2464-node-road-tree-bounds.md new file mode 100644 index 0000000000..512537a212 --- /dev/null +++ b/docs/changes/unreleased/2464-node-road-tree-bounds.md @@ -0,0 +1,13 @@ +--- +kind: fixed +title: the older task road refuses duplicate work in a tree and holds the tree to one spend total +pr: 2464 +surface: [engine, chat, docs] +invalidates: + - "Was true: with CODEAF_TASK_BELT=node two tasks in one tree could hand out the same work, and only each node's own bill was limited. Is true now: duplicate titles are refused tree-wide and the whole tree shares one spend total." +--- + +The duplicate check uses the plan store's title normalization, so both task roads compare +work the same way. The tree total is the lower of the per-task limit (crew.task_cap) and +the conversation's remaining limit; reaching it stops the whole tree and names the limit. +A typed task starts its own tree. The default road is unchanged. diff --git a/docs/changes/unreleased/2465-fallback-model-run-only.md b/docs/changes/unreleased/2465-fallback-model-run-only.md new file mode 100644 index 0000000000..6c05b920bd --- /dev/null +++ b/docs/changes/unreleased/2465-fallback-model-run-only.md @@ -0,0 +1,13 @@ +--- +kind: fixed +title: a fallback model is used for that run only and never becomes the conversation's saved model +pr: 2465 +surface: [chat, engine, remote, app, docs] +invalidates: + - "Was true: when a local launch replaced an unlisted configured model with the first listed model, the replacement became the conversation's saved model and the agent reopened on it. Is true now: the replacement serves requests only, the saved model stays the configured one, and the conversation returns to it as soon as it is listed." +--- + +The same holds after disconnecting the service of the running model. An untouched chat +still saves the build's default when it follows a credit reading. Choosing a model yourself, including the replacement that is already +running, and connecting a service still save the choice. The desktop app reports the +configured model. A chat continued from another computer keeps its existing replacement rule. diff --git a/docs/changes/unreleased/2468-collections-factory-job-feature.md b/docs/changes/unreleased/2468-collections-factory-job-feature.md new file mode 100644 index 0000000000..2be520b63e --- /dev/null +++ b/docs/changes/unreleased/2468-collections-factory-job-feature.md @@ -0,0 +1,11 @@ +--- +kind: added +title: Engines whose collections hold Factory jobs announce collections.factory_job +pr: 2468 +surface: [app] +invalidates: [] +--- + +A client requires `collections.factory_job` in `hello.features` before it relies +on adding a `factory_job` ref to a collection. An engine that announces only +`collections` may refuse that kind with `bad_params`. diff --git a/docs/design/app-v3/PROTOCOL.md b/docs/design/app-v3/PROTOCOL.md index b7dc04d448..7e9c5637b4 100644 --- a/docs/design/app-v3/PROTOCOL.md +++ b/docs/design/app-v3/PROTOCOL.md @@ -75,11 +75,146 @@ decides which combinations are accepted. | `agents.set_effort` | `{effort, id}` | `agentResult` | | `agents.set_model` | `{automatic, id, model}` | `agentResult` | | `agents.set_models` | `{fallback, id, main}` | `agentResult` | +| `agents.set_scope` | `{id, scope}` | `AgentScope` | | `agents.since` | `{after, id}` | `Since` | | `agents.steer` | `in.sayTo` | `Empty` | | `agents.stop` | `in.IDParams` | `Empty` | | `agents.suggest_reply` | `in.ReplySuggestionParams` | `ReplySuggestion` | +## Conversation work scope + +`agents.set_scope {id, scope}` answers `{id, scope}` with the stored word. +`project` is the default: the conversation is told about other conversations' +landed and running work in its folder, as today. `conversation` means it is told +only about its own work: its turns receive no other-conversation `` +block, and its `tasks` tool lists and addresses only its own tasks. Neither the +other-window running section nor the wider `scope: "everywhere"` section is +added. Task nodes keep their existing scope. + +The choice is stored with the conversation and survives restarts. It takes +effect from the conversation's next turn; a live tasks call also reads the +stored choice. Setting it never opens, warms or interrupts the conversation, +never moves its folder, and never changes its transcript, title, model or tasks. +The write-safety checks still warn before its own work writes over files other +conversations are writing. The person's task list is unchanged. + +Engines that serve this method announce `agents.scope` in `hello.features`. +A missing id or a scope other than `project` or `conversation` is `bad_params`; +an engine without the door answers `unknown_method`. An unknown conversation is +`not_found`; a lent conversation or one without a local folder is `conflict`. +No `agent.changed` event is emitted, because the Agent view carries no scope. + +## Client tools + +The optional features `agents.client_tools` and `agents.client_tools.source` +appear together in `hello.features` exactly when the engine implements the +client-tools interface. The latter guarantees message identity on calls whenever +the running turn answers a message. `agents.client_tools.remote` additionally +advertises forwarding through a joined conversation's remote connection when +its holder supports it. Sets belong to this +stdio connection and one conversation. They are memory-only: register again +after reconnecting. Attachment does not open the conversation or start a turn. +Children, workers and clarification conversations inherit neither tools nor +instructions. Within the same app connection, every join or re-join pushes the +conversation's current sets to its holder; a roaming link resends them after +redial only when the welcome identifies the same conversation. A changed +conversation forgets the old snapshot and ends its calls. A returning owner's +random token moves its sets to the replacement connection even while the old +connection remains attached; other owners still face collision checks. A +conversation returning to this engine uses the registry directly. +If another computer holds the chat (`Location.Here == false`), attachment still +validates and stores the sets without opening the conversation. They apply +when this engine runs it again after "Continue here" or take, without a second +`agents.tools_set`. + +| method | params | result | +|---|---|---| +| `agents.tools_set` | `{agent, set, instructions?, deadline_ms?, tools?: [{name, description, input_schema, category}]}` | `{agent, set, tools: [name, ...]}` | +| `agents.tool_result` | `{call, result: }` or `{call, error: }` | `{accepted: true}` or `{accepted: false}` | + +`agents.tools_set` replaces that `agent` + `set` whole; other sets remain. +Omitted `tools` or `tools: []` removes only that set and returns `tools: []`. +An agent holds at most 4 sets, 32 tools per set and 64 tools total. All validation +is atomic: a refusal leaves every set unchanged. + +- `set` matches `^[a-z][a-z0-9_.-]{0,63}$`. +- `name` matches `^[a-z][a-z0-9_]{0,63}$`, unique within and across the agent's sets. +- `description` is 1–2048 bytes. `input_schema` is a JSON object whose `type` + is `"object"`, at most 16 KiB. Schema validation of call arguments is the app's job. +- `category` is `read`, `edit` or `create` and supplies this conversation's tool + category. Reading-only contexts refuse edits and creations. Ordinary tool + approval rules apply; attachment is not approval. +- `instructions` is at most 4096 bytes per set and may be empty. It appears in a + section headed `Tools from the app: `. A caller-supplied system page is + preserved and receives no section. Ordinary conversations gain no text. +- `deadline_ms` is 0 (the default 30000) or 1000–120000. + +Invalid fields, aggregate limits, duplicates and built-in tool names answer +`bad_params`; a collision message names the tool. Collision checks include the +live belt and all classified engine tools. A built-in arriving later wins and +the colliding client tool leaves the belt. No model request has duplicate names. +Unknown agents answer `not_found`. A joined holder without forwarding support +answers `bad_scope`: `client tools need a conversation this engine runs`. +Holder refusals answer `bad_params` with the holder's message; a failed or timed +out link answers `unreachable`, leaving the app registry unchanged for retry. +Different remote connections keep separate sets. The aggregate limits count +all of them, tool-name collisions are refused atomically, and only the +connection that owns a set can receive or answer its calls. +Tools and instructions update at the next step boundary, with client tools at +the tail after all built-ins. + +| event | data | +|---|---| +| `agents.tool_call` | `{agent, turn, call, turn_call, source_message_id?, set, name, args: , deadline_ms}` | + +`source_message_id` names the message the running turn answers: the same string +as `Entry.message_id` on that message's chat entry in `agents.read` (and in +`entry.added` when present). It is captured when the turn starts, never changed +by a mid-turn steer or note, and survives journal restarts and call replays. +It is absent when no message can be named exactly. A stopped answer reuses its +recorded message id. Turns the engine starts itself, including ordinary session +wakes, mixed batches and a carry-on after a quit or close, carry none. +With `agents.client_tools.source` advertised, +an absent field means no such message, rather than an older engine. + +One model call emits exactly one `agents.tool_call`. `turn` is the engine's +process-local conversation counter, which resets after restart; +`turn_call` matches `turn.tool.call`. `call` is unique +among pending calls and deterministic for the agent + provider call id. Without +a provider id the engine mints a fresh id. A concurrent redispatch joins the +pending wait and emits no second event. A settled redispatch returns the saved +answer without an event for 10 minutes, subject to a maximum of 1024 saved +answers per connection. Non-object model arguments become an invalid-arguments +error result without an event. + +`agents.tool_result` requires exactly one of `result` and `error`. `result` +accepts any JSON, including `null`, and reaches the model as compact JSON text, +cut at 64 KiB with a plain dropped-byte marker. `error` must be a nonempty string +of at most 4096 bytes and becomes an error tool result: +` could not do : `. Invalid answers are `bad_params`. +A pending answer returns `accepted: true`; unknown, late or duplicate ids return +`accepted: false` without an error or any other effect. + +Connection end (EOF, shutdown or context cancellation) disconnects the registry +before conversations close: all sets are dropped and all pending calls settle +immediately. These failures reach the model as error tool results, with no +engine retry. + +For forwarded calls, a disconnect observed by the holder immediately removes +that connection's sets and settles its waits. A link loss the holder cannot +observe ends at the set's `deadline_ms`. The app drops the pending entry when +its link ends or the holder reports that the call ended; later results return +`accepted: false`. Failure text is: + +- A stale call after removal or disconnection: + `client_unavailable: the app that provides is not connected; nothing was done.` +- Disconnection during a call: + `client_unavailable: the app that provides disconnected before answering; the outcome is unknown, so check before trying again.` +- Deadline: + `client_timeout: the app that provides did not answer within s; the outcome is unknown, so check before trying again.` +- Turn cancellation: the turn ended before the app answered. The pending entry + is dropped immediately and later answers return `accepted: false`. + ## asks methods | method | params | result | @@ -158,6 +293,21 @@ keeps the value in a private file the agent's commands read (the model's own | `collections.remove` | `{id, refs}` | `CollectionRemoveResult` | | `collections.rename` | `in.collectionNameParams` | `CollectionResult` | +Collection refs include `factory_job`, with a canonical positive decimal job `id` +and a required `factory` field. The pair addresses the same job as `factory.job`. +Other ref kinds have no `factory`. Several collections can reference the same job. +Engines whose collections hold `factory_job` refs announce +`collections.factory_job` in `hello.features`. A client requires it before it +relies on adding a job: an engine that announces only `collections` may refuse +the kind with `bad_params`. +Factory job targets carry the recorded title, state word and repository; an +unavailable Factory produces an `unknown` target and status. A missing job +produces a `deleted` target. Resolution uses one bounded batch across nested +collections: each distinct job is asked once, with a three-second deadline, +up to eight concurrent Factory queue reads and 20 missing-job fallbacks. Queue +reads, including unavailable results, share a ten-second cache of at most +32 factories. A failed queue read never triggers per-job fallbacks. + ## competence methods | method | params | result | @@ -255,6 +405,7 @@ keeps the value in a private file the agent's commands read (the model's own | `factory.job_send` | `in.FactoryJobSendParams` | `FactoryJobSent` | | `factory.job_close` | `in.FactoryJobActionParams` | `FactoryJobActed` | | `factory.job_control` | `in.FactoryJobControlParams` | `FactoryJobActed` | +| `factory.job_context_set` | `in.FactoryJobContextSetParams` | `FactoryJobActed` | | `factory.job_create` | `in.FactoryJobCreateParams` | `FactoryJobActed` | | `factory.job_dismiss` | `in.FactoryJobActionParams` | `FactoryJobActed` | | `factory.job_drop` | `in.FactoryJobDropParams` | `FactoryJobActed` | @@ -310,6 +461,11 @@ keeps the value in a private file the agent's commands read (the model's own | `factory.read_on_arrival` | `in.FactoryReadOnArrivalParams` | `FactoryReadOnArrival` | | `factory.rename` | `in.FactoryRenameParams` | `Empty` | | `factory.repo_facts` | `in.FactoryRepoFactsParams` | `FactoryRepoFacts` | +| `factory.waits_set` | `in.FactoryWaitsSetParams` | `FactoryWaitsSet` | +| `factory.features` | `in.FactoryFeaturesParams` | `FactoryFeatures` | +| `factory.repo_issues` | `in.FactoryRepoIssuesParams` | `FactoryRepoIssues` | +| `factory.policy_get` | `in.FactoryPolicyGetParams` | `FactoryPolicy` | +| `factory.policy_set` | `in.FactoryPolicySetParams` | `FactoryPolicy` | | `factory.repo_read` | `in.FactoryRepoReadParams` | `FactoryRepoRead` | | `factory.repo_set` | `in.FactoryRepoSetParams` | `FactoryRepoSetDone` | | `factory.repos` | `in.FactoryRef` | `FactoryRepos` | @@ -339,6 +495,7 @@ keeps the value in a private file the agent's commands read (the model's own | `factory.sign_in_start` | `in.FactorySignInStartParams` | `FactorySignIn` | | `factory.sign_out` | `in.FactoryAdminParams` | `FactoryAdminDone` | | `factory.spend_by_model` | `in.FactorySpendByModelParams` | `FactorySpendByModel` | +| `factory.events_since` | `in.FactoryEventsSinceParams` | `FactoryEvents` | | `factory.usage_by_model` | `in.FactoryUsageParams` | `FactoryUsage` | | `factory.spend_by_repo` | `in.FactorySpendByRepoParams` | `FactorySpendByRepo` | | `factory.spending` | `in.FactoryRef` | `FactorySpending` | @@ -372,6 +529,49 @@ The job title alone supplies the draft PR title. Existing jobs are not rewritten The method, reply, `factory.job_actions` hello feature and error codes are unchanged. Factories without the separate-title capability retain their prior behavior. +`factory.job_create` also accepts these optional fields. Engine capabilities are +`create_issue`, `create_group` (group and milestone), `create_budget`, +`create_key`, `create_split_from` and `job_context` (the separate attachment write +below). Each is true when the engine carries that feature end to end; an older +engine omits it or answers false. These flags describe the engine; the attached +Factory may still be older than a feature. + +| Parameter | Rule | +| --- | --- | +| `open_issue` | Boolean, default false. True sends `POST /v1/issues`: open one GitHub issue using only `title` and `text`, then create the job tied to its number. False sends `POST /v1/jobs` and opens no issue. | +| `group`, `milestone` | Opaque labels, trimmed, at most 200 characters each, with no control characters. Empty means none. | +| `budget` | Initial job budget in USD: one of the standard steps, 5, 10, 20 or 30. Recorded as a budget action before starting; a start now or later keeps it instead of taking the pipeline default. | +| `key` | A create key of 1–128 characters from `[A-Za-z0-9._:-]`, sent only as the create request's `Idempotency-Key` header. `key: ""` is the same as no key (`omitempty`): no header is sent. | +| `split_from` | Integer at least 1, naming a job held by the same Factory that the caller can see. Stores the job this new job was split from. An explicit zero or negative number is `bad_params` before sending. | + +The Factory stores group and milestone outside the description. +`factory.job`, queue rows and `current` carry the labels when present. +Those reads also carry `split_from` when supplied, omitted otherwise. The parent +job stays unchanged. The new job's own activity adds one line, `split from #N`. +Split lineage leaves normal pipeline stamping, step briefs and run-as-new +cleanup behavior in effect; it is stored separately from `from_job`. + +A missing or hidden `split_from` parent is the same Factory 422 refusal, mapped +to `bad_params`, before any job or GitHub issue is made, including with +`open_issue: true`. + +Bad extras answer `bad_params`, naming the field and limit, before anything is +sent. The Factory also validates extras before opening an issue or making a job. +A Factory's 422 strict-decoder refusal of `group`, `milestone`, `budget`, +`issue` or `split_from`, or a 404/405 on the issue create route, answers `conflict` +with `detail.reason: "factory_too_old"` and the existing update instruction: +"This factory doesn't support that yet; nothing was changed. Update the factory +to use it." No fallback request is sent. A 501 stays `engine` with "this factory +does not provide that action"; GitHub and sign-in refusals retain their existing +mapping. A timeout or 5xx stays `uncertain`. + +The reply is unchanged: `FactoryJobActed` with `action: "create"`, `job` and +optional `current`. A Factory that keeps keys replays the first response for +the same caller, route and key for 24 hours, so resending a create with the same +key after an uncertain response is safe. A different key creates a separate job. +With none of the new fields, the route, body and absent idempotency header are +unchanged. + An engine whose hello carries `factory.stop_any_version` accepts `factory.job_stop {factory, job, expect?}` and @@ -461,6 +661,45 @@ manual takeover adds `X-Codeaf-Lease-Withdrawn: 1`, so the worker cancels its ol execution without reporting a person's Stop. The latest withdrawn lease is retained per worker until that worker pulls again. +An engine whose hello carries `factory.job_events` and whose capabilities +answer `job_events: true` serves `factory.events_since +{factory, after?, limit?, follow?}`. `factory` is required; `after`, when +present, is an integer at least zero. An absent cursor requests an initial +reset; zero requests the beginning. `limit` is 1–500, or 0/omitted for 100. +Invalid parameters answer `bad_params`. An engine without the Factory outcome +reader answers `unknown_method` and does not announce the feature. + +The result is `{factory, supported, events, cursor, reset, more}`. `events` +is always an array, in increasing `seq` order, and includes only jobs the +caller may see; the cursor also advances past hidden outcomes. The cursor +belongs to the Factory and survives an engine restart. `more: true` asks for +another read after `cursor`. With no further outcomes, `cursor` is the latest +position. Other Factory errors retain their usual codes. A Factory older +than `GET /v1/job-events` answers `supported: false` with an empty event array +and no error; following does not start for that answer. + +Each event is `{factory, seq, kind, job, at, title?, group?, how?, reason?, +need?, code?, step?, pr_number?, pr_url?}`. `kind` is `finished`, `merged`, +`failed` or `needs_you`. `how` describes a non-merge finish; merged outcomes +carry the PR number and URL; failures carry code and step; needs-you outcomes +carry need and reason. Titles are limited to 200 bytes at a rune boundary. +The Factory records outcomes at job updates even with no app connected and +keeps the newest 2,000 independently of its live notifications. + +`reset: true` means read the Factory's current state once, then continue from +the returned cursor. A reset has no events. It occurs on an initial read, +an expired or replaced cursor, or a gap caused by an older writer. Cursor +zero on an empty record is caught up and does not reset repeatedly. + +`follow: true` starts one follower per attached Factory. A later outcome is +pushed as `factory.event` with the same `FactoryEvent` shape and sequence. +The push is a prompt copy: the app can always read again from its own cursor. +Repeated follow calls raise the follower's cursor without duplicating it. +A follower takes reset cursors silently; the app's own read observes resets. +Closing the engine or detaching the Factory ends following. The Factory HTTP +read takes `after`, `limit` and optional `wait` seconds (0–15, default 0), +returning on an outcome or reset when a watch is available. + An engine whose hello carries `factory.spend_by_model` (and capabilities `spend_by_model: true`) serves `factory.spend_by_model {factory, from, to}`: the same days and the same job spend as `factory.spend_by_repo`, from @@ -569,6 +808,40 @@ last `months` calendar months (1 to 12, default 3) by the rule of areas and 20 stuck steps are answered, busiest first (at most 97 facts); `truncated` counts the rest. A repository the caller cannot read is not found. +An engine whose hello carries `factory.repo_issues` and whose capabilities +answer `repo_issues: true` serves `factory.repo_issues {factory, repo, since?, +page?}`. `repo` is `owner/name`, `since` is RFC 3339 (absent means no lower +bound), and `page` is 1–10, default 1. It reads only `GET +/v1/repos/{owner}/{repo}/issues?since=&page=` through the caller's Factory +connection. The route accepts only those two query keys, once each, and uses +the caller's GitHub sign-in, or a solo Factory's own profile. + +The result is `{factory, repo, issues, rows, newest?}`. `issues` contains at +most 50 entries, oldest update first, in every state: `{id, number, title, +body, url, state, created_at, updated_at, body_cut?}`. `id` is GitHub's int64 +issue identity, `url` is its HTML URL, and `state` is `open` or `closed`. +`body` is cut at 8,192 bytes on a rune boundary; `body_cut: true` marks a +cut. Pull requests are excluded from `issues`, but `rows` counts all rows +GitHub returned (0–50) and `newest` is their latest `updated_at`, including +pull requests. An empty result has `issues: []`, `rows: 0` and no `newest`. + +Invalid params are `bad_params` before any request. A repository the caller +cannot see, or GitHub's repository 404, is `not_found`. A spent GitHub rate +limit on the visibility check or issues request is `busy` with +`detail.retry_at` in RFC 3339, using GitHub's reset time when supplied, +otherwise its `retry-after` hint. +Every GitHub HTTP 429 is a rate limit, even without headers; a 403 is a +rate limit only with `X-RateLimit-Remaining: 0` or `Retry-After`. +A rate-limited visibility check is not cached as a repository denial; +the next read checks GitHub again. Without a usable reset or retry hint, +`detail.retry_at` is one minute after the Factory's clock. +Sign-in failures retain `signed_out` or `github_expired`. A Factory missing +the route is `conflict` with `detail.reason: "factory_too_old"`; its own +"repository not found" remains `not_found`. An engine without the reader +answers `unknown_method`. +The route itself stores nothing and appends no activity; the Factory's +ordinary per-request housekeeping runs as it does for every authenticated request. + An engine whose hello carries the feature `factory.pipeline_pr_update` (and capabilities `pipeline_pr_update: true`) lets `factory.pipeline_pr` take `pr`, the number of an open pull request from a branch of the same repository into @@ -836,6 +1109,36 @@ with wire code `conflict`, typed outcome `refused`, and the message The existing `factory.remote` and `factory.issue_comment` hello features continue to apply; no method, parameter or reply field is added. +## factory.job_context_set + +Hello feature `factory.job_context` and capability `job_context` announce this +write. `factory.job_create` refuses `context` as an unknown field. + +| Parameter | Rule | +| --- | --- | +| `factory`, `job` | One attached Factory and a positive job number. | +| `expect` | Required `expect.version` from the job read; stale versions answer `stale` without changing attachments. | +| `context` | Required list of at most 20 `{title, markdown, source?}` attachments. Title is 1–200 characters after trimming; markdown is non-empty; source is at most 500 characters. Total markdown is at most 100,000 characters, separate from the 20,000-character `text` limit. `[]` clears attachments. | + +The engine sends one `POST /v1/jobs/{id}/context` with `{context, expect}`. +The job must be in Backlog or queued with no run (no stream): before its first +run, or after Bring back cleared its last one, so its next run reads it. Caller rules +match model edits: solo callers, the job's person, or anyone when no person is +assigned. A begun job answers `conflict`: "The job has started; its steps +already read their context." Malformed attachments answer `bad_params` before +transport. The Factory validates again and records one activity per replacement. + +Job, queue and `current` reads list `context: [{title, source?}]` only. +Worker admission carries full markdown. A non-skipped conversation `plan`, +otherwise the first non-skipped conversation, receives every attachment whole +after notes as reference material. Without a stream only eligible stages count. +Other steps receive none; attachments never enter a GitHub issue, PR or comment. + +Reply: `FactoryJobActed` with `action: "context_set"`, `job`, optional `current`. +The mux's 404/405 (including "factory endpoint not found") means `conflict` with +`detail.reason: "factory_too_old"`; JSON "job not found" stays `not_found`. +A lost answer or 5xx is `uncertain`. The engine never re-sends the write. + ## files methods | method | params | result | @@ -1290,6 +1593,7 @@ this optional field; older engines provide no Library breakdown. | `devices.changed` | `DevicesResult` | | `entry.added` | `EntryAdded` | | `factory.sessions_changed` | `Empty` | +| `factory.event` | `FactoryEvent` | | `hello` | `Hello` | | `inbox.added` | `InboxAddedEvent` | | `inbox.counts` | `InboxCounts` | @@ -1325,6 +1629,11 @@ this optional field; older engines provide no Library breakdown. | `turn.thinking_done` | `TurnThinkingDone` | | `turn.tool` | `TurnTool` | +`turn.tool` optionally carries `spent_usd`, the conversation's live spend when +the step was said (the same meter as `agents.budget.get`'s `engine.spent_usd`, +which can be ahead of the saved `Agent.spent_usd`); zero or unknown readings and +history rows omit it, and absence means the engine did not say. + ## Slash catalogue commands and forms `slash.catalog` lists the shared terminal vocabulary; the table is 54 commands, 93 rows, 31 aliases. diff --git a/docs/rules/ci.md b/docs/rules/ci.md index 0cf710a2a3..ddb1811f94 100644 --- a/docs/rules/ci.md +++ b/docs/rules/ci.md @@ -2,9 +2,10 @@ ## Pull request checks -`PR gate` (`ci.yml`) runs on pull requests into `dev`, `app/v3` and -`santos/dev2`, and pushes to `dev` and `santos/dev2`. The required `check` -status combines the light gate and successful touched-package checks. +`PR gate` (`ci.yml`) runs on pull requests into `dev`, `app/v3`, +`santos/dev2` and `workstreams/main`, and pushes to `dev`, `santos/dev2` and +`workstreams/main`. The required `check` status combines the light gate and +successful touched-package checks. Pull requests compare against their exact base SHA, so an `app/v3` PR selects packages and attributes failures against `app/v3`, rather than `dev`. diff --git a/internal/appwire/agent_scope.go b/internal/appwire/agent_scope.go new file mode 100644 index 0000000000..03c95abad6 --- /dev/null +++ b/internal/appwire/agent_scope.go @@ -0,0 +1,43 @@ +package appwire + +// FeatureAgentScope names an engine that keeps a conversation's work scope. +const FeatureAgentScope = "agents.scope" + +type AgentScope struct { + ID string `json:"id"` + Scope string `json:"scope" enum:"project,conversation"` +} + +type AgentScoper interface { + SetAgentScope(id, scope string) (AgentScope, error) +} + +func withAgentScopeFeature(h Hello, e Engine) Hello { + if _, ok := e.(AgentScoper); ok { + return withFeature(h, FeatureAgentScope) + } + return h +} + +func init() { + methods["agents.set_scope"] = typed(func(e Engine, p struct { + ID string `json:"id"` + Scope string `json:"scope" enum:"project,conversation"` + }) (*AgentScope, error) { + if p.ID == "" { + return nil, Errorf(CodeBadParams, "agents.set_scope needs an id") + } + if p.Scope != "project" && p.Scope != "conversation" { + return nil, Errorf(CodeBadParams, "scope is project or conversation") + } + scoper, ok := e.(AgentScoper) + if !ok { + return nil, Errorf(CodeUnknownMethod, "this engine does not serve agents.set_scope") + } + out, err := scoper.SetAgentScope(p.ID, p.Scope) + if err != nil { + return nil, err + } + return &out, nil + }) +} diff --git a/internal/appwire/agent_scope_test.go b/internal/appwire/agent_scope_test.go new file mode 100644 index 0000000000..d5e71b2a8d --- /dev/null +++ b/internal/appwire/agent_scope_test.go @@ -0,0 +1,92 @@ +package appwire + +import ( + "encoding/json" + "reflect" + "slices" + "testing" +) + +type scopeEngine struct { + fakeEngine +} + +func (e *scopeEngine) SetAgentScope(id, scope string) (AgentScope, error) { + if id == "missing" { + return AgentScope{}, Errorf(CodeNotFound, "there is no conversation with id missing") + } + return AgentScope{ID: id, Scope: scope}, nil +} + +// L-FIX9-cross A7 +func TestAgentScopeIsAnnouncedAndServedOnlyWithItsDoor(t *testing.T) { + engine := &scopeEngine{} + w := startWireOver(t, engine, &engine.fakeEngine) + if !slices.Contains(features(w.next()), FeatureAgentScope) { + t.Fatal("hello lacks agents.scope") + } + for i, word := range []string{"conversation", "project"} { + request, err := json.Marshal(map[string]any{ + "id": i + 1, "method": "agents.set_scope", "params": map[string]string{"id": "a1", "scope": word}, + }) + if err != nil { + t.Fatal(err) + } + w.send(string(request)) + got := w.next() + want := map[string]any{"id": "a1", "scope": word} + if got["error"] != nil || !reflect.DeepEqual(got["result"], want) { + t.Fatalf("scope %q = %v", word, got) + } + } + plain := startWire(t, &fakeEngine{}) + if slices.Contains(features(plain.next()), FeatureAgentScope) { + t.Fatal("an engine without the door announced agents.scope") + } + plain.send(`{"id":3,"method":"agents.set_scope","params":{"id":"a1","scope":"conversation"}}`) + got := plain.next() + if failure, ok := got["error"].(map[string]any); !ok || failure["code"] != CodeUnknownMethod || failure["message"] != "this engine does not serve agents.set_scope" { + t.Fatalf("an unsupported engine = %v", got) + } +} + +// L-FIX9-cross A7 +func TestAgentScopeValidatesParametersAndCarriesEngineErrors(t *testing.T) { + for _, tc := range []struct { + params, code, message string + }{ + {`{"scope":"conversation"}`, CodeBadParams, "agents.set_scope needs an id"}, + {`{"id":"","scope":"conversation"}`, CodeBadParams, "agents.set_scope needs an id"}, + {`{"id":"a1"}`, CodeBadParams, "scope is project or conversation"}, + {`{"id":"a1","scope":"everywhere"}`, CodeBadParams, "scope is project or conversation"}, + {`{"id":"a1","scope":"Conversation"}`, CodeBadParams, "scope is project or conversation"}, + {`{"id":"missing","scope":"conversation"}`, CodeNotFound, "there is no conversation with id missing"}, + } { + _, err := methods["agents.set_scope"].call(&scopeEngine{}, json.RawMessage(tc.params)) + failure, ok := err.(*Error) + if !ok || failure.Code != tc.code || failure.Message != tc.message { + t.Fatalf("params %s = %v, want %s: %s", tc.params, err, tc.code, tc.message) + } + } +} + +// L-FIX9-cross A7 +func TestAgentScopeSchemaNamesBothWordsAndTheResult(t *testing.T) { + doc := Schema() + method := doc["methods"].(map[string]any)["agents.set_scope"].(map[string]any) + params := method["params"].(map[string]any)["properties"].(map[string]any) + result := doc["$defs"].(map[string]any)["AgentScope"].(map[string]any) + for name, properties := range map[string]map[string]any{ + "params": params, "result": result["properties"].(map[string]any), + } { + if properties["id"].(map[string]any)["type"] != "string" { + t.Fatalf("%s lacks a string id: %v", name, properties) + } + if got := properties["scope"].(map[string]any)["enum"]; !reflect.DeepEqual(got, []string{"project", "conversation"}) { + t.Fatalf("%s scope enum = %v", name, got) + } + } + if got := method["result"].(map[string]any)["$ref"]; got != "#/$defs/AgentScope" { + t.Fatalf("result schema = %v", method["result"]) + } +} diff --git a/internal/appwire/client_tools.go b/internal/appwire/client_tools.go new file mode 100644 index 0000000000..e3637496df --- /dev/null +++ b/internal/appwire/client_tools.go @@ -0,0 +1,166 @@ +package appwire + +import ( + "encoding/json" + "regexp" + + "github.com/Agent-Field/codeaf/internal/session" +) + +const FeatureClientTools = "agents.client_tools" +const FeatureClientToolsSource = "agents.client_tools.source" +const FeatureClientToolsRemote = "agents.client_tools.remote" +const EventAgentsToolCall = "agents.tool_call" + +// ClientTooler is optional because only a connected app can answer these tools. +type ClientTooler interface { + SetClientTools(ClientToolsSetParams) (ClientToolsSetResult, error) + ClientToolResult(ClientToolResultParams) (ClientToolResultAck, error) +} + +type ClientToolsSetParams struct { + Agent string `json:"agent"` + Set string `json:"set"` + Instructions string `json:"instructions"` + DeadlineMS int `json:"deadline_ms"` + Tools []session.ClientTool `json:"tools"` +} + +type ClientToolsSetResult struct { + Agent string `json:"agent"` + Set string `json:"set"` + Tools []string `json:"tools"` +} + +type AgentsToolCall struct { + Agent string `json:"agent"` + Turn uint64 `json:"turn"` + Call string `json:"call"` + TurnCall string `json:"turn_call"` + SourceMessageID string `json:"source_message_id,omitempty"` + Set string `json:"set"` + Name string `json:"name"` + Args json.RawMessage `json:"args"` + DeadlineMS int `json:"deadline_ms"` +} + +type ClientToolResultParams struct { + Call string `json:"call"` + // RawMessage distinguishes an explicit JSON null result from an omitted one. + Result json.RawMessage `json:"result,omitempty"` + Error *string `json:"error,omitempty"` +} + +type ClientToolResultAck struct { + Accepted bool `json:"accepted"` +} + +func (p *ClientToolResultParams) UnmarshalJSON(data []byte) error { + type plain ClientToolResultParams + var decoded plain + if err := json.Unmarshal(data, &decoded); err != nil { + return err + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(data, &fields); err != nil { + return err + } + if _, present := fields["error"]; present && decoded.Error == nil { + return Errorf(CodeBadParams, "error must be a nonempty string") + } + *p = ClientToolResultParams(decoded) + return nil +} + +var clientToolSetName = regexp.MustCompile(`^[a-z][a-z0-9_.-]{0,63}$`) +var clientToolName = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`) + +// Validate rejects a whole replacement before the backend can change any set. +func (p ClientToolsSetParams) Validate() error { + if p.Agent == "" || !clientToolSetName.MatchString(p.Set) { + return Errorf(CodeBadParams, "agent and a valid set id are required") + } + if len(p.Tools) > session.ClientToolMaxPerSet || len(p.Instructions) > session.ClientToolInstructionsMax { + return Errorf(CodeBadParams, "a set allows at most %d tools and %d instruction bytes", session.ClientToolMaxPerSet, session.ClientToolInstructionsMax) + } + if p.DeadlineMS != 0 && (p.DeadlineMS < session.ClientToolMinDeadlineMS || p.DeadlineMS > session.ClientToolMaxDeadlineMS) { + return Errorf(CodeBadParams, "deadline_ms must be 0 or %d–%d", session.ClientToolMinDeadlineMS, session.ClientToolMaxDeadlineMS) + } + held := make(map[string]bool, len(p.Tools)) + for _, tool := range p.Tools { + if !clientToolName.MatchString(tool.Name) || held[tool.Name] { + return Errorf(CodeBadParams, "invalid or duplicate tool name %q", tool.Name) + } + held[tool.Name] = true + if err := validateClientTool(tool); err != nil { + return err + } + } + return nil +} + +func validateClientTool(tool session.ClientTool) error { + if len(tool.Description) == 0 || len(tool.Description) > session.ClientToolDescriptionMax { + return Errorf(CodeBadParams, "%s description must be 1–%d bytes", tool.Name, session.ClientToolDescriptionMax) + } + var object map[string]json.RawMessage + if len(tool.InputSchema) > session.ClientToolSchemaMax || json.Unmarshal(tool.InputSchema, &object) != nil || object == nil { + return Errorf(CodeBadParams, "%s input_schema must be an object of at most %d bytes", tool.Name, session.ClientToolSchemaMax) + } + var kind string + if json.Unmarshal(object["type"], &kind) != nil || kind != "object" { + return Errorf(CodeBadParams, "%s input_schema type must be object", tool.Name) + } + switch tool.Category { + case session.ActionRead, session.ActionEdit, session.ActionCreate: + return nil + } + return Errorf(CodeBadParams, "%s category must be read, edit or create", tool.Name) +} + +func (p ClientToolResultParams) Validate() error { + if (p.Result != nil) == (p.Error != nil) { + return Errorf(CodeBadParams, "exactly one of result or error is required") + } + if p.Error != nil && (len(*p.Error) == 0 || len(*p.Error) > session.ClientToolErrorMax) { + return Errorf(CodeBadParams, "error must be 1–%d bytes", session.ClientToolErrorMax) + } + if p.Result != nil && !json.Valid(p.Result) { + return Errorf(CodeBadParams, "result must be JSON") + } + return nil +} + +func withClientToolsFeature(hello Hello, engine Engine) Hello { + if _, ok := engine.(ClientTooler); ok { + hello = withFeature(withFeature(hello, FeatureClientTools), FeatureClientToolsSource) + if remote, ok := engine.(interface{ ClientToolsRemote() bool }); ok && remote.ClientToolsRemote() { + hello = withFeature(hello, FeatureClientToolsRemote) + } + } + return hello +} + +func setClientTools(e Engine, p ClientToolsSetParams) (*ClientToolsSetResult, error) { + door, ok := e.(ClientTooler) + if !ok { + return nil, Errorf(CodeUnknownMethod, "client tools are unavailable") + } + if err := p.Validate(); err != nil { + return nil, err + } + result, err := door.SetClientTools(p) + return &result, err +} + +func answerClientTool(e Engine, p ClientToolResultParams) (*ClientToolResultAck, error) { + door, ok := e.(ClientTooler) + if !ok { + return nil, Errorf(CodeUnknownMethod, "client tools are unavailable") + } + if err := p.Validate(); err != nil { + return nil, err + } + result, err := door.ClientToolResult(p) + return &result, err +} diff --git a/internal/appwire/client_tools_test.go b/internal/appwire/client_tools_test.go new file mode 100644 index 0000000000..70f2b779b8 --- /dev/null +++ b/internal/appwire/client_tools_test.go @@ -0,0 +1,209 @@ +package appwire + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "os" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/session" +) + +type clientToolsEngine struct { + sendEngine + sets []ClientToolsSetParams + answers []ClientToolResultParams +} + +func (e *clientToolsEngine) SetClientTools(p ClientToolsSetParams) (ClientToolsSetResult, error) { + e.sets = append(e.sets, p) + names := []string{} + for _, tool := range p.Tools { + names = append(names, tool.Name) + } + return ClientToolsSetResult{Agent: p.Agent, Set: p.Set, Tools: names}, nil +} +func (e *clientToolsEngine) ClientToolResult(p ClientToolResultParams) (ClientToolResultAck, error) { + e.answers = append(e.answers, p) + return ClientToolResultAck{Accepted: true}, nil +} + +func validClientToolsParams() ClientToolsSetParams { + return ClientToolsSetParams{Agent: "agent-1", Set: "app.notes", Tools: []session.ClientTool{{Name: "app_note", Description: "Read a note", + InputSchema: json.RawMessage(`{"type":"object"}`), Category: session.ActionRead}}} +} + +// Contract (f) and source contract (d): both features accompany the implemented door. +func TestClientToolsHelloFeature(t *testing.T) { + for _, test := range []struct { + engine Engine + want bool + }{{&sendEngine{}, false}, {&clientToolsEngine{}, true}} { + var out bytes.Buffer + server := NewServer(&out) + if err := server.Serve(context.Background(), strings.NewReader(""), Hello{}, test.engine); err != nil { + t.Fatal(err) + } + var frame struct { + Data Hello `json:"data"` + } + if err := json.Unmarshal(bytes.Split(out.Bytes(), []byte("\n"))[0], &frame); err != nil { + t.Fatal(err) + } + found, source := false, false + for _, feature := range frame.Data.Features { + found = found || feature == FeatureClientTools + source = source || feature == FeatureClientToolsSource + } + if found != test.want || source != test.want { + t.Fatalf("features=%v want client tools=%v", frame.Data.Features, test.want) + } + } +} + +// Source contract (d): an unknown message is omitted, and a known one is exact. +func TestClientToolCallSourceMessageJSON(t *testing.T) { + for _, source := range []string{"", "msg-recorded"} { + raw, err := json.Marshal(AgentsToolCall{SourceMessageID: source}) + if err != nil { + t.Fatal(err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + t.Fatal(err) + } + value, present := fields["source_message_id"] + if present != (source != "") || present && string(value) != `"msg-recorded"` { + t.Fatalf("source %q encoded as %s", source, raw) + } + } +} + +// Source contract (d): the written feature guarantee and event shape match the wire. +func TestClientToolsSourceProtocol(t *testing.T) { + raw, err := os.ReadFile(protocolDoc) + if err != nil { + t.Fatal(err) + } + for _, phrase := range []string{FeatureClientToolsSource, "source_message_id?", "Entry.message_id", "process-local"} { + if !strings.Contains(string(raw), phrase) { + t.Errorf("protocol omits %q", phrase) + } + } +} + +// Contract (b), (h): both typed methods preserve their declared shapes. +func TestClientToolsTypedMethods(t *testing.T) { + e := &clientToolsEngine{} + raw, _ := json.Marshal(validClientToolsParams()) + result, err := methods["agents.tools_set"].call(e, raw) + if err != nil { + t.Fatal(err) + } + encoded, _ := json.Marshal(result) + if string(encoded) != `{"agent":"agent-1","set":"app.notes","tools":["app_note"]}` { + t.Fatal(string(encoded)) + } + for _, raw := range []string{`{"call":"ct_1","result":null}`, `{"call":"ct_1","result":[1,true]}`, `{"call":"ct_1","error":"no access"}`} { + result, err := methods["agents.tool_result"].call(e, json.RawMessage(raw)) + if err != nil { + t.Fatal(err) + } + encoded, _ := json.Marshal(result) + if string(encoded) != `{"accepted":true}` { + t.Fatal(string(encoded)) + } + } + if len(e.sets) != 1 || len(e.answers) != 3 || string(e.answers[0].Result) != "null" { + t.Fatal("typed arguments were lost") + } + if _, err := methods["agents.tools_set"].call(&sendEngine{}, raw); codeOf(err) != CodeUnknownMethod { + t.Fatal(err) + } +} + +// Contract (h): every scalar and per-set limit refuses before the optional engine. +func TestClientToolsValidationRefusals(t *testing.T) { + changes := map[string]func(*ClientToolsSetParams){ + "agent": func(p *ClientToolsSetParams) { p.Agent = "" }, + "set": func(p *ClientToolsSetParams) { p.Set = "Bad set" }, + "set length": func(p *ClientToolsSetParams) { p.Set = strings.Repeat("a", 65) }, + "name": func(p *ClientToolsSetParams) { p.Tools[0].Name = "Bad-name" }, + "name length": func(p *ClientToolsSetParams) { p.Tools[0].Name = strings.Repeat("a", 65) }, + "duplicate": func(p *ClientToolsSetParams) { p.Tools = append(p.Tools, p.Tools[0]) }, + "description empty": func(p *ClientToolsSetParams) { p.Tools[0].Description = "" }, + "description bytes": func(p *ClientToolsSetParams) { + p.Tools[0].Description = strings.Repeat("é", session.ClientToolDescriptionMax) + }, + "schema array": func(p *ClientToolsSetParams) { p.Tools[0].InputSchema = json.RawMessage(`[]`) }, + "schema null": func(p *ClientToolsSetParams) { p.Tools[0].InputSchema = json.RawMessage(`null`) }, + "schema type": func(p *ClientToolsSetParams) { p.Tools[0].InputSchema = json.RawMessage(`{"type":"array"}`) }, + "schema bytes": func(p *ClientToolsSetParams) { + p.Tools[0].InputSchema = json.RawMessage(`{"type":"object","padding":"` + strings.Repeat("x", session.ClientToolSchemaMax) + `"}`) + }, + "category": func(p *ClientToolsSetParams) { p.Tools[0].Category = session.ActionRun }, + "tools": func(p *ClientToolsSetParams) { p.Tools = make([]session.ClientTool, session.ClientToolMaxPerSet+1) }, + "instructions": func(p *ClientToolsSetParams) { + p.Instructions = strings.Repeat("x", session.ClientToolInstructionsMax+1) + }, + "negative deadline": func(p *ClientToolsSetParams) { p.DeadlineMS = -1 }, + "short deadline": func(p *ClientToolsSetParams) { p.DeadlineMS = session.ClientToolMinDeadlineMS - 1 }, + "long deadline": func(p *ClientToolsSetParams) { p.DeadlineMS = session.ClientToolMaxDeadlineMS + 1 }, + } + for name, change := range changes { + t.Run(name, func(t *testing.T) { + p := validClientToolsParams() + change(&p) + raw, err := json.Marshal(p) + if err != nil { + t.Fatal(err) + } + e := &clientToolsEngine{} + if _, err := methods["agents.tools_set"].call(e, raw); codeOf(err) != CodeBadParams { + t.Fatalf("accepted %s: %v", name, err) + } + if len(e.sets) != 0 { + t.Fatal("invalid set reached the engine") + } + }) + } +} + +// Contract (h): null is a result, while an error must be a nonempty string. +func TestClientToolResultValidation(t *testing.T) { + for _, raw := range []string{`{}`, `{"call":"c","result":1,"error":"bad"}`, `{"call":"c","error":""}`, `{"call":"c","error":null}`, + `{"call":"c","result":1,"error":null}`, `{"call":"c","error":7}`, `{"call":"c","error":"` + strings.Repeat("x", session.ClientToolErrorMax+1) + `"}`} { + e := &clientToolsEngine{} + if _, err := methods["agents.tool_result"].call(e, json.RawMessage(raw)); codeOf(err) != CodeBadParams { + t.Fatalf("accepted %.90s: %v", raw, err) + } + if len(e.answers) > 0 { + t.Fatal("invalid answer reached the engine") + } + } +} + +// Contract (h): the shipped section stays bounded and names the actual limits. +func TestClientToolsManualLimits(t *testing.T) { + raw, err := os.ReadFile("../manual/chat/client-tools.md") + if err != nil { + t.Fatal(err) + } + page := string(raw) + if strings.Count(page, "\n## ") != 1 || len(raw) >= 2000 { + t.Fatal("client tools manual section is not self-contained and bounded") + } + for _, phrase := range []string{ + fmt.Sprintf("%d sets", session.ClientToolMaxSets), fmt.Sprintf("%d tools per set", session.ClientToolMaxPerSet), + fmt.Sprintf("%d tools total", session.ClientToolMaxPerAgent), fmt.Sprintf("%d seconds", session.ClientToolDefaultDeadlineMS/1000), + fmt.Sprintf("%d seconds is the maximum", session.ClientToolMaxDeadlineMS/1000), fmt.Sprintf("%d KiB", session.ClientToolOutputMax/1024), + "outcome unknown", "Child tasks", "connected", + } { + if !strings.Contains(page, phrase) { + t.Errorf("manual omits %q", phrase) + } + } +} diff --git a/internal/appwire/collections.go b/internal/appwire/collections.go index 579d05e7a6..afb5e92008 100644 --- a/internal/appwire/collections.go +++ b/internal/appwire/collections.go @@ -6,6 +6,7 @@ import ( "strings" "time" "unicode" + "unicode/utf8" ) // WAVE 9: COLLECTIONS (PROTOCOL.md, V75). A collection is a hand-picked list @@ -31,6 +32,7 @@ const ( RefArtifact = "artifact" RefCrew = "crew" RefProcess = "process" + RefFactoryJob = "factory_job" RefStanding = "standing" ) @@ -60,9 +62,10 @@ const ( // 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"` + Kind string `json:"kind" enum:"collection,conversation,task,artifact,crew,process,standing,factory_job"` ID string `json:"id"` SessionID string `json:"session_id,omitempty"` + Factory string `json:"factory,omitempty"` } // Member is a reference as a collection holds it, with when it was added; @@ -252,6 +255,9 @@ func plainText(s string, limit int) bool { // 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 r.Kind != RefFactoryJob && r.Factory != "" { + return Errorf(CodeBadParams, "only a factory_job reference has a factory") + } if !plainText(r.ID, collectionIDMax) { return Errorf(CodeBadParams, "a reference needs an id without surrounding space or control characters") } @@ -263,6 +269,11 @@ func CheckRef(r Ref, adding bool) error { return Errorf(CodeBadParams, "only a task or a process reference has a session_id") } switch r.Kind { + case RefFactoryJob: + n, err := strconv.ParseUint(r.ID, 10, 63) + if err != nil || n == 0 || strconv.FormatUint(n, 10) != r.ID || (!plainText(r.Factory, collectionIDMax) || !utf8.ValidString(r.Factory)) { + return Errorf(CodeBadParams, "a factory_job needs a factory and a positive canonical job number") + } case RefCollection, RefConversation, RefCrew, RefProcess: case RefTask: n, err := strconv.ParseUint(r.ID, 10, 64) diff --git a/internal/appwire/collections_factory_job.go b/internal/appwire/collections_factory_job.go new file mode 100644 index 0000000000..c71b0e062f --- /dev/null +++ b/internal/appwire/collections_factory_job.go @@ -0,0 +1,17 @@ +package appwire + +// FeatureCollectionsFactoryJob names an engine whose collections hold +// factory_job references: a Factory job by its factory and job number. A +// client requires it before it relies on adding a job to a collection: an +// engine that announces only "collections" may refuse the kind with bad_params. +const FeatureCollectionsFactoryJob = "collections.factory_job" + +// withCollectionsFactoryJobFeature is hello carrying collections.factory_job +// when the engine serves collections: this wire checks the factory_job kind +// and passes it to every collector. +func withCollectionsFactoryJobFeature(hello Hello, e Engine) Hello { + if _, ok := e.(Collector); !ok { + return hello + } + return withFeature(hello, FeatureCollectionsFactoryJob) +} diff --git a/internal/appwire/collections_factory_job_feature_test.go b/internal/appwire/collections_factory_job_feature_test.go new file mode 100644 index 0000000000..24a6ee2bf2 --- /dev/null +++ b/internal/appwire/collections_factory_job_feature_test.go @@ -0,0 +1,38 @@ +package appwire + +import ( + "slices" + "testing" +) + +// An engine announces collections.factory_job only when it serves +// collections, so a client can tell before it adds a job. +func TestCollectionsFactoryJobFeatureFollowsCollections(t *testing.T) { + if h := withCollectionsFactoryJobFeature(Hello{}, &fakeEngine{}); len(h.Features) != 0 { + t.Fatalf("an engine without collections announces %v", h.Features) + } + h := withCollectionsFactoryJobFeature(Hello{}, &fakeCollector{fakeEngine: &fakeEngine{}}) + if len(h.Features) != 1 || h.Features[0] != FeatureCollectionsFactoryJob { + t.Fatalf("a collector announces %v", h.Features) + } +} + +// The announced feature is served: a factory_job ref with its factory reaches +// the collector, and one without a factory is refused before it. +func TestCollectionsFactoryJobFeatureIsServed(t *testing.T) { + w, engine := startCollectorWire(t) + if !slices.Contains(features(w.next()), FeatureCollectionsFactoryJob) { + t.Fatal("hello lacks collections.factory_job") + } + w.send(`{"id":1,"method":"collections.add","params":{"id":"c1","refs":[{"kind":"factory_job","id":"7","factory":"f"}]}}`) + if got := w.next(); got["error"] != nil { + t.Fatalf("factory_job add = %v", got) + } + if len(engine.added) != 1 || engine.added[0].Kind != RefFactoryJob || engine.added[0].ID != "7" || engine.added[0].Factory != "f" { + t.Fatalf("collector got %+v", engine.added) + } + w.send(`{"id":2,"method":"collections.add","params":{"id":"c1","refs":[{"kind":"factory_job","id":"7"}]}}`) + if got := w.next(); got["error"] == nil || got["error"].(map[string]any)["code"] != CodeBadParams { + t.Fatalf("factory_job without a factory = %v", got) + } +} diff --git a/internal/appwire/collections_factory_job_test.go b/internal/appwire/collections_factory_job_test.go new file mode 100644 index 0000000000..be9fec713d --- /dev/null +++ b/internal/appwire/collections_factory_job_test.go @@ -0,0 +1,35 @@ +package appwire + +import ( + "encoding/json" + "strings" + "testing" +) + +// Part A (A2): the wire validator and optional fields match store ref identity. +func TestPartACollectionFactoryJobRefs(t *testing.T) { + for _, tc := range []struct { + r Ref + ok bool + }{ + {Ref{Kind: RefFactoryJob, ID: "7", Factory: "f"}, true}, + {Ref{Kind: RefFactoryJob, ID: "7"}, false}, + {Ref{Kind: RefFactoryJob, ID: "0", Factory: "f"}, false}, + {Ref{Kind: RefFactoryJob, ID: "07", Factory: "f"}, false}, + {Ref{Kind: RefFactoryJob, ID: "+7", Factory: "f"}, false}, + {Ref{Kind: RefFactoryJob, ID: "7", Factory: " f"}, false}, + {Ref{Kind: RefFactoryJob, ID: "7", Factory: strings.Repeat("x", 4097)}, false}, + {Ref{Kind: RefFactoryJob, ID: "7", Factory: "f", SessionID: "chat"}, false}, + {Ref{Kind: RefConversation, ID: "chat", Factory: "f"}, false}, + } { + for _, adding := range []bool{false, true} { + if err := CheckRef(tc.r, adding); (err == nil) != tc.ok { + t.Errorf("%+v adding %v: %v", tc.r, adding, err) + } + } + } + raw, err := json.Marshal(Member{Ref: Ref{Kind: RefFactoryJob, ID: "7", Factory: "f"}}) + if err != nil || !strings.Contains(string(raw), `"factory":"f"`) { + t.Fatalf("factory: %s %v", raw, err) + } +} diff --git a/internal/appwire/collections_test.go b/internal/appwire/collections_test.go index 782a02cdae..7b06bfac33 100644 --- a/internal/appwire/collections_test.go +++ b/internal/appwire/collections_test.go @@ -106,12 +106,12 @@ func features(hello map[string]any) []any { // engine without collections answers its methods unknown_method. func TestCollectionsFeatureFollowsTheEngine(t *testing.T) { w, _ := startCollectorWire(t) - if got := features(w.next()); len(got) != 3 || got[0] != "x" || got[1] != FeatureAgentEffortRungs || got[2] != FeatureCollections { + if got := features(w.next()); len(got) != 4 || got[0] != "x" || got[1] != FeatureAgentEffortRungs || got[2] != FeatureCollections || got[3] != FeatureCollectionsFactoryJob { t.Fatalf("features = %v", got) } plain := startWire(t, &fakeEngine{}) for _, f := range features(plain.next()) { - if f == FeatureCollections { + if f == FeatureCollections || f == FeatureCollectionsFactoryJob { t.Fatal("an engine without collections claims them") } } diff --git a/internal/appwire/contract_test.go b/internal/appwire/contract_test.go index 1c6b32e270..19fad5e978 100644 --- a/internal/appwire/contract_test.go +++ b/internal/appwire/contract_test.go @@ -140,6 +140,7 @@ func TestTheSchemaIsGeneratedForTheWholeWire(t *testing.T) { Events map[string]json.RawMessage `json:"events"` 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 { @@ -148,6 +149,15 @@ func TestTheSchemaIsGeneratedForTheWholeWire(t *testing.T) { 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())) } + // Source contract (d): the event registry exposes the optional durable id. + if got := doc.Defs["AgentsToolCall"].Properties["source_message_id"]["type"]; got != "string" { + t.Fatalf("AgentsToolCall.source_message_id schema type=%v", got) + } + for _, field := range doc.Defs["AgentsToolCall"].Required { + if field == "source_message_id" { + t.Fatal("source_message_id became required") + } + } for _, field := range []string{"invoked", "rejected_before_run", "harness_made", "refused", "media_attempt"} { for _, name := range []string{"TurnTool", "Entry"} { if got := doc.Defs[name].Properties[field]["type"]; got != "boolean" { diff --git a/internal/appwire/events.go b/internal/appwire/events.go index a0f2cced26..10aeb2dda8 100644 --- a/internal/appwire/events.go +++ b/internal/appwire/events.go @@ -6,6 +6,7 @@ import "reflect" // 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{ + EventAgentsToolCall: reflect.TypeFor[AgentsToolCall](), EventPairChanged: reflect.TypeFor[PairResult](), EventChatChanged: reflect.TypeFor[ChatChanged](), EventChatRemoved: reflect.TypeFor[ChatRemoved](), diff --git a/internal/appwire/factory.go b/internal/appwire/factory.go index 24f617bda7..92c3338ae1 100644 --- a/internal/appwire/factory.go +++ b/internal/appwire/factory.go @@ -72,6 +72,28 @@ const factoryCallBudget = 25 * time.Second // FactoryCapabilities is what this engine can do about a factory, stated // plainly so an unsupported verb is absent from the screen and not broken. type FactoryCapabilities struct { + // GroupPolicy: factory.policy_get and factory.policy_set are served; whether + // a connected factory keeps policies is its own features answer. + GroupPolicy bool `json:"group_policy"` + // JobEvents: factory.events_since is served and factory.event is pushed + // (feature factory.job_events); a factory older than the route answers supported false. + JobEvents bool `json:"job_events"` + // WaitsOn: factory.waits_set and factory.features are served; whether a + // connected factory takes waits is its own features answer. + WaitsOn bool `json:"waits_on"` + + // CreateIssue carries the create request's explicit GitHub issue choice. + CreateIssue bool `json:"create_issue"` + // CreateGroup carries group and milestone labels on a new job. + CreateGroup bool `json:"create_group"` + // CreateBudget carries a new job's explicit initial budget. + CreateBudget bool `json:"create_budget"` + // JobContext replaces context attachments before a job's first run. + JobContext bool `json:"job_context"` + // CreateKey carries the caller's create key as Idempotency-Key. + CreateKey bool `json:"create_key"` + // CreateSplitFrom carries the job a new job was split from. + CreateSplitFrom bool `json:"create_split_from"` // Contract is the factory API contract this engine speaks. Contract int `json:"contract"` // Remote: attach to, read and sign in to a factory served elsewhere. @@ -162,6 +184,8 @@ type FactoryCapabilities struct { // True only for an engine that implements [FactoryRepoFactsReader]; a // factory older than the route answers not found. RepoFacts bool `json:"repo_facts"` + // RepoIssues reads one repository's GitHub issues through its Factory. + RepoIssues bool `json:"repo_issues"` // RepoRead: factory.repo_read is served (feature factory.repo_read). True // only for an engine that implements [FactoryRepoReader]; whether the // factory has a checkout of a repository is answered by the call. diff --git a/internal/appwire/factory_actions.go b/internal/appwire/factory_actions.go index 33f8be49cc..a950db338a 100644 --- a/internal/appwire/factory_actions.go +++ b/internal/appwire/factory_actions.go @@ -259,8 +259,8 @@ func withFactoryJobStepRerunModelFeature(hello Hello, e Engine) Hello { } // FactoryJobCreateParams makes a job. WITHOUT Start THE JOB IS ONLY ADDED TO -// THE BACKLOG: nothing starts, no model is asked, and no GitHub issue is -// opened. Start "now" is the one explicit way to also start it, and it requires +// THE BACKLOG: nothing starts and no model is asked. OpenIssue explicitly opens a +// GitHub issue. Start "now" is the one way to also start it, and it requires // Assignee to be present ("" for no one; a team factory refuses that). Assignee // left out leaves the factory's rule in charge; "" leaves the job unassigned. type FactoryJobCreateParams struct { @@ -273,6 +273,18 @@ type FactoryJobCreateParams struct { // Pipeline (feature factory.start_pipeline) is the kind whose pipeline the // new job runs, as [FactoryJobStartParams] says; "" for the issue steps. Pipeline string `json:"pipeline,omitempty"` + // OpenIssue opens a GitHub issue with the title and text before making the job. + OpenIssue bool `json:"open_issue,omitempty"` + // Group is an opaque label, trimmed to at most 200 characters without controls. + Group string `json:"group,omitempty"` + // Milestone is a second label with the same bounds as Group. + Milestone string `json:"milestone,omitempty"` + // Budget sets the job's initial cap to one of FactoryBudgetSteps. + Budget *float64 `json:"budget,omitempty"` + // Key is carried as Idempotency-Key on this create request alone. + Key string `json:"key,omitempty"` + // SplitFrom is a visible job number on the same Factory, at least 1. + SplitFrom *int `json:"split_from,omitempty"` } func (p *FactoryJobCreateParams) pickedPipeline() string { return p.Pipeline } @@ -316,7 +328,7 @@ type FactoryJobActed struct { // commit ids and times, never a credential. type FactoryRefusal struct { // Reason is the factory's word: stale, not_job_person, not_allowed, - // conflict, already_answered or changed_since_opened. + // conflict, already_answered, changed_since_opened or factory_too_old. Reason string `json:"reason,omitempty"` // By is who the refusal names (the assignee, the approver), with @. By string `json:"by,omitempty"` @@ -654,6 +666,9 @@ func prepStep(p *FactoryJobStepParams) error { } func prepCreate(p *FactoryJobCreateParams) error { + if err := checkCreateExtras(p); err != nil { + return err + } id, err := factoryID(strings.TrimSpace(p.Factory)) if err != nil { return err diff --git a/internal/appwire/factory_job_context.go b/internal/appwire/factory_job_context.go new file mode 100644 index 0000000000..934c4c9102 --- /dev/null +++ b/internal/appwire/factory_job_context.go @@ -0,0 +1,106 @@ +package appwire + +import ( + "context" + "encoding/json" + "strings" + "unicode/utf8" + + "github.com/Agent-Field/codeaf/internal/factory/api" +) + +// FeatureFactoryJobContext announces the separate versioned attachment write. +const FeatureFactoryJobContext = "factory.job_context" + +// JobActContextSet names the action in a successful attachment receipt. +const JobActContextSet = "context_set" + +// FactoryJobContext is reference material the plan reads, outside the job text. +type FactoryJobContext struct { + // Title names the attachment, trimmed to 1–200 characters. + Title string `json:"title"` + // Markdown is the complete reference text, separate from the description. + Markdown string `json:"markdown"` + // Source is an optional label, path or URL of at most 500 characters. + Source string `json:"source,omitempty"` +} + +// FactoryJobContextSetParams replaces one job's attachments; [] clears them. +type FactoryJobContextSetParams struct { + Factory string `json:"factory"` + Job int `json:"job"` + Expect *api.Expect `json:"expect"` + Context []FactoryJobContext `json:"context"` +} + +// FactoryJobContextSetter carries one write, never re-sent on an uncertain outcome. +// The Factory decides whether the caller may edit and whether the job has begun. +type FactoryJobContextSetter interface { + FactoryJobContextSet(context.Context, FactoryJobContextSetParams) (FactoryJobActed, error) +} + +func withFactoryJobContextFeature(hello Hello, e Engine) Hello { + if _, ok := e.(Factory); !ok { + return hello + } + if _, ok := e.(FactoryJobContextSetter); ok { + hello = withFeature(hello, FeatureFactoryJobContext) + } + return hello +} + +func init() { + methods["factory.job_context_set"] = shaped[FactoryJobContextSetParams, FactoryJobActed](setJobContext) +} + +func setJobContext(e Engine, raw json.RawMessage) (*FactoryJobActed, error) { + s, ok := e.(FactoryJobContextSetter) + if _, isFactory := e.(Factory); !ok || !isFactory { + return nil, Errorf(CodeUnknownMethod, "this engine does not serve factory.job_context_set") + } + var p FactoryJobContextSetParams + if err := decodeStrict("factory.job_context_set", raw, &p); err != nil { + return nil, err + } + if err := checkJob(&p.Factory, p.Job, p.Expect); err != nil { + return nil, err + } + if err := checkJobContext(p.Context); err != nil { + return nil, err + } + ctx, cancel := factoryCall() + defer cancel() + out, err := s.FactoryJobContextSet(ctx, p) + if err != nil { + return nil, err + } + return &out, nil +} + +func checkJobContext(attachments []FactoryJobContext) error { + if attachments == nil { + return Errorf(CodeBadParams, "context is required: send [] to clear attachments") + } + if len(attachments) > MaxFactoryJobContextItems { + return Errorf(CodeBadParams, "context takes at most %d items", MaxFactoryJobContextItems) + } + total := 0 + for i := range attachments { + c := &attachments[i] + c.Title = strings.TrimSpace(c.Title) + if n := utf8.RuneCountInString(c.Title); n < 1 || n > MaxFactoryJobContextTitle { + return Errorf(CodeBadParams, "context title must be 1–%d characters after trimming", MaxFactoryJobContextTitle) + } + if utf8.RuneCountInString(c.Source) > MaxFactoryJobContextSource { + return Errorf(CodeBadParams, "context source must be at most %d characters", MaxFactoryJobContextSource) + } + if strings.TrimSpace(c.Markdown) == "" { + return Errorf(CodeBadParams, "context markdown must be non-empty") + } + total += utf8.RuneCountInString(c.Markdown) + } + if total > MaxFactoryJobContext { + return Errorf(CodeBadParams, "context markdown must total at most %d characters", MaxFactoryJobContext) + } + return nil +} diff --git a/internal/appwire/factory_job_context_test.go b/internal/appwire/factory_job_context_test.go new file mode 100644 index 0000000000..274d91c136 --- /dev/null +++ b/internal/appwire/factory_job_context_test.go @@ -0,0 +1,97 @@ +package appwire + +import ( + "context" + "encoding/json" + "slices" + "strings" + "testing" +) + +type contextEngine struct{ actorEngine } + +func (e *contextEngine) FactoryJobContextSet(_ context.Context, p FactoryJobContextSetParams) (FactoryJobActed, error) { + return e.did(JobActContextSet, p) +} + +// E1: only engines carrying the separate context write announce its feature. +func TestJobContextFeature(t *testing.T) { + for _, e := range []Engine{&fakeEngine{}, &factoryEngine{}, &actorEngine{}} { + if slices.Contains(withFactoryJobContextFeature(Hello{}, e).Features, FeatureFactoryJobContext) { + t.Fatal("engine without context setter announced context") + } + if _, err := methods["factory.job_context_set"].call(e, json.RawMessage(`{}`)); wireCode(t, err) != CodeUnknownMethod { + t.Fatal(err) + } + } + if !slices.Contains(withFactoryJobContextFeature(Hello{}, &contextEngine{}).Features, "factory.job_context") { + t.Fatal("context setter did not announce context") + } +} + +// E7'/E8': complete attachments and inclusive Unicode bounds survive the wire; [] clears. +func TestJobContextSetCarriesBoundsAndClear(t *testing.T) { + items := []FactoryJobContext{{Title: " reference ", Markdown: strings.Repeat("é", MaxFactoryJobContext), Source: strings.Repeat("é", MaxFactoryJobContextSource)}} + bounded := make([]FactoryJobContext, MaxFactoryJobContextItems) + for i := range bounded { + bounded[i] = FactoryJobContext{Title: strings.Repeat("é", MaxFactoryJobContextTitle), Markdown: "body"} + } + for _, attachments := range [][]FactoryJobContext{items, bounded, {}} { + eng := &contextEngine{} + raw, err := json.Marshal(map[string]any{"factory": " f ", "job": 1, "expect": map[string]int{"version": 3}, "context": attachments}) + if err != nil { + t.Fatal(err) + } + if _, err := methods["factory.job_context_set"].call(eng, raw); err != nil { + t.Fatal(err) + } + got := eng.got[0].(FactoryJobContextSetParams) + if got.Factory != "f" || got.Job != 1 || len(got.Context) != len(attachments) || eng.acts[0] != "context_set" { + t.Fatalf("context arguments: %+v", got) + } + for i, c := range got.Context { + if c.Title != strings.TrimSpace(attachments[i].Title) || c.Markdown != attachments[i].Markdown || c.Source != attachments[i].Source { + t.Fatal("attachment changed") + } + } + } +} + +// E8': all context limits and malformed shapes fail before the engine. +func TestJobContextSetRefusesBeforeEngine(t *testing.T) { + cases := []struct{ context, words string }{ + {`[{"title":"a","markdown":"` + strings.Repeat("x", 100001) + `"}]`, "100000"}, + {`[{"title":"a","markdown":"` + strings.Repeat("x", 50000) + `"},{"title":"b","markdown":"` + strings.Repeat("x", 50001) + `"}]`, "100000"}, + {`[` + strings.Repeat(`{"title":"a","markdown":"x"},`, 20) + `{"title":"a","markdown":"x"}]`, "20"}, + {`[{"title":" ","markdown":"x"}]`, "title"}, + {`[{"title":"` + strings.Repeat("t", 201) + `","markdown":"x"}]`, "200"}, + {`[{"title":"t","markdown":"x","source":"` + strings.Repeat("s", 501) + `"}]`, "500"}, + {`[{"title":"t","markdown":" "}]`, "markdown"}, + {`[{"title":"t","markdown":7}]`, "markdown"}, + {`[{"title":7,"markdown":"x"}]`, "title"}, + {`[{"title":"t","markdown":"x","source":7}]`, "source"}, + {`[{"title":"t","markdown":"x","unknown":true}]`, "unknown"}, + {`"reference"`, "context"}, + {`null`, "context"}, + } + for _, tc := range cases { + eng := &contextEngine{} + _, err := methods["factory.job_context_set"].call(eng, json.RawMessage(`{"factory":"f","job":1,"expect":{"version":3},"context":`+tc.context+`}`)) + if wireCode(t, err) != CodeBadParams || !strings.Contains(err.Error(), tc.words) || len(eng.acts) != 0 { + t.Fatalf("context refusal: %v", err) + } + } + for _, raw := range []string{ + `{"factory":"f","job":1,"context":[]}`, + `{"factory":"f","job":1,"expect":{},"context":[]}`, + `{"factory":"f","job":1,"expect":{"version":0},"context":[]}`, + `{"factory":"f","job":1,"expect":{"version":3}}`, + `{"factory":"f","job":0,"expect":{"version":3},"context":[]}`, + } { + eng := &contextEngine{} + _, err := methods["factory.job_context_set"].call(eng, json.RawMessage(raw)) + if wireCode(t, err) != CodeBadParams || len(eng.acts) != 0 { + t.Fatalf("missing context or guard: %v", err) + } + } +} diff --git a/internal/appwire/factory_job_create_extras.go b/internal/appwire/factory_job_create_extras.go new file mode 100644 index 0000000000..582e9eefa6 --- /dev/null +++ b/internal/appwire/factory_job_create_extras.go @@ -0,0 +1,45 @@ +package appwire + +import ( + "regexp" + "slices" + "strings" + "unicode" + "unicode/utf8" + + "github.com/Agent-Field/codeaf/internal/factory" +) + +// FactoryReasonTooOld identifies a Factory that cannot carry a field or route. +const FactoryReasonTooOld = "factory_too_old" + +// The shared bounds keep attachments separate from the description limit. +const ( + MaxFactoryJobContextItems = factory.MaxJobContextItems + MaxFactoryJobContext = factory.MaxJobContext + MaxFactoryJobContextTitle = factory.MaxJobContextTitle + MaxFactoryJobContextSource = factory.MaxJobContextSource + MaxFactoryJobLabel = factory.MaxJobLabel + MaxFactoryJobKey = 128 +) + +var factoryJobKey = regexp.MustCompile(`^[A-Za-z0-9._:-]+$`) + +func checkCreateExtras(p *FactoryJobCreateParams) error { + if p.SplitFrom != nil && *p.SplitFrom < 1 { + return Errorf(CodeBadParams, "split_from must be a job number of at least 1") + } + for field, value := range map[string]*string{"group": &p.Group, "milestone": &p.Milestone} { + if strings.ContainsFunc(*value, unicode.IsControl) || utf8.RuneCountInString(strings.TrimSpace(*value)) > MaxFactoryJobLabel { + return Errorf(CodeBadParams, "%s must be at most %d characters with no control characters", field, MaxFactoryJobLabel) + } + *value = strings.TrimSpace(*value) + } + if p.Budget != nil && !slices.Contains(FactoryBudgetSteps, *p.Budget) { + return Errorf(CodeBadParams, "budget must be one of %v US dollars", FactoryBudgetSteps) + } + if p.Key != "" && (len(p.Key) > MaxFactoryJobKey || !factoryJobKey.MatchString(p.Key)) { + return Errorf(CodeBadParams, "key must be 1–%d characters from [A-Za-z0-9._:-]", MaxFactoryJobKey) + } + return nil +} diff --git a/internal/appwire/factory_job_create_extras_test.go b/internal/appwire/factory_job_create_extras_test.go new file mode 100644 index 0000000000..1c6d7b33e6 --- /dev/null +++ b/internal/appwire/factory_job_create_extras_test.go @@ -0,0 +1,130 @@ +package appwire + +import ( + "encoding/json" + "strings" + "testing" +) + +// E1: the generated schema exposes every create capability and parameter. +func TestCreateExtrasSchema(t *testing.T) { + raw, err := SchemaJSON() + if err != nil { + t.Fatal(err) + } + var schema struct { + Defs map[string]struct { + Properties map[string]json.RawMessage `json:"properties"` + } `json:"$defs"` + } + if err := json.Unmarshal(raw, &schema); err != nil { + t.Fatal(err) + } + for def, fields := range map[string][]string{ + "FactoryCapabilities": {"create_issue", "create_group", "create_budget", "job_context", "create_key"}, + "in.FactoryJobCreateParams": {"open_issue", "group", "milestone", "budget", "key"}, + "in.FactoryJobContextSetParams": {"factory", "job", "expect", "context"}, + "in.FactoryJobContext": {"title", "markdown", "source"}, + } { + for _, field := range fields { + if _, ok := schema.Defs[def].Properties[field]; !ok { + t.Errorf("%s lacks %s", def, field) + } + } + } + if _, ok := schema.Defs["in.FactoryJobCreateParams"].Properties["context"]; ok { + t.Fatal("create schema still carries context") + } + if _, ok := schema.Defs["FactoryCapabilities"].Properties["create_context"]; ok { + t.Fatal("capabilities still advertise create_context") + } +} + +// E5/E6/E10/B1: valid extras reach the actor, including the open_issue wire flag. +func TestCreateExtrasCarryTrimmedArguments(t *testing.T) { + for _, budget := range FactoryBudgetSteps { + eng := &actorEngine{} + params := FactoryJobCreateParams{ + Factory: "f", Repo: "o/r", Title: "Fix", Text: strings.Repeat("é", MaxFactoryJobText), + OpenIssue: true, Group: " batch ", Milestone: " release ", Budget: &budget, + Key: strings.Repeat("k", MaxFactoryJobKey), + } + raw, err := json.Marshal(params) + if err != nil { + t.Fatal(err) + } + if _, err := methods["factory.job_create"].call(eng, raw); err != nil { + t.Fatal(err) + } + got := eng.got[0].(FactoryJobCreateParams) + if got.Group != "batch" || got.Milestone != "release" || !got.OpenIssue || *got.Budget != budget { + t.Fatalf("create arguments: %+v", got) + } + if got.Text != params.Text || got.Key != params.Key { + t.Fatal("literal text or key changed") + } + } +} + +// E5/E6/E10: bad extras name their field and limit before reaching the actor. +func TestCreateExtrasRefuseBeforeActor(t *testing.T) { + cases := []struct { + name, field, limit string + value any + }{ + {"group length", "group", "200", strings.Repeat("g", 201)}, + {"group control", "group", "control", "batch\x00"}, + {"milestone length", "milestone", "200", strings.Repeat("m", 201)}, + {"milestone control", "milestone", "control", "release\nname"}, + {"budget", "budget", "5 10 20 30", 7}, + {"zero budget", "budget", "5 10 20 30", 0}, + {"key alphabet", "key", "128", "a b"}, + {"key length", "key", "128", strings.Repeat("k", 129)}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + eng := &actorEngine{} + raw, err := json.Marshal(map[string]any{"factory": "f", "repo": "o/r", "title": "Fix", tc.field: tc.value}) + if err != nil { + t.Fatal(err) + } + _, err = methods["factory.job_create"].call(eng, raw) + if wireCode(t, err) != CodeBadParams || !strings.Contains(err.Error(), tc.field) || !strings.Contains(err.Error(), tc.limit) { + t.Fatalf("refusal: %v", err) + } + if len(eng.acts) != 0 { + t.Fatal("invalid extras reached the actor") + } + }) + } +} + +// E5/E10: inclusive label and key-alphabet boundaries are accepted. +func TestCreateExtrasInclusiveBounds(t *testing.T) { + eng := &actorEngine{} + params := FactoryJobCreateParams{ + Factory: "f", Repo: "o/r", Title: "Fix", Group: strings.Repeat("é", MaxFactoryJobLabel), + Milestone: strings.Repeat("é", MaxFactoryJobLabel), Key: "AZaz09._:-", + } + raw, err := json.Marshal(params) + if err != nil { + t.Fatal(err) + } + if _, err := methods["factory.job_create"].call(eng, raw); err != nil { + t.Fatal(err) + } + if len(eng.acts) != 1 { + t.Fatal("valid bounds did not reach the actor") + } +} + +// E15: create refuses context and the old issue wire name as unknown fields. +func TestCreateExtrasRefuseRemovedFields(t *testing.T) { + for _, field := range []string{`"context":[]`, `"issue":true`} { + eng := &actorEngine{} + _, err := methods["factory.job_create"].call(eng, json.RawMessage(`{"factory":"f","repo":"o/r","title":"Fix",`+field+`}`)) + if wireCode(t, err) != CodeBadParams || len(eng.acts) != 0 { + t.Fatalf("removed create field: %v", err) + } + } +} diff --git a/internal/appwire/factory_job_events.go b/internal/appwire/factory_job_events.go new file mode 100644 index 0000000000..6d62619408 --- /dev/null +++ b/internal/appwire/factory_job_events.go @@ -0,0 +1,110 @@ +package appwire + +// Job outcomes are finished, merged, failed and needs-you transitions kept by +// the Factory. The cursor is the Factory's. A factory.event push is a prompt +// copy of an event the app can always read again from its own cursor. A reset +// means read the Factory's current state once, then continue from this cursor. + +import ( + "context" + "reflect" + "time" +) + +// FeatureFactoryJobEvents announces the durable outcome read and push. +const FeatureFactoryJobEvents = "factory.job_events" + +// EventFactoryEvent is one outcome from a followed Factory connection. +const EventFactoryEvent = "factory.event" + +// DefaultFactoryEvents bounds a read when no limit is supplied. +const DefaultFactoryEvents = 100 + +// MaxFactoryEvents is the most outcomes one read may request. +const MaxFactoryEvents = 500 + +// FactoryEventsSinceParams identifies the Factory and its optional outcome cursor. +type FactoryEventsSinceParams struct { + Factory string `json:"factory"` + After *int64 `json:"after,omitempty"` + Limit int `json:"limit,omitempty"` + Follow bool `json:"follow,omitempty"` +} + +// FactoryEvent is a durable outcome with the app's Factory connection id. +type FactoryEvent struct { + Factory string `json:"factory"` + Seq int64 `json:"seq"` + Kind string `json:"kind" enum:"finished,merged,failed,needs_you"` + Job int `json:"job"` + At time.Time `json:"at"` + Title string `json:"title,omitempty"` + Group string `json:"group,omitempty"` + How string `json:"how,omitempty"` + Reason string `json:"reason,omitempty"` + Need string `json:"need,omitempty"` + Code string `json:"code,omitempty"` + Step string `json:"step,omitempty"` + PRNumber int `json:"pr_number,omitempty"` + PRURL string `json:"pr_url,omitempty"` +} + +// FactoryEvents carries visible outcomes and the Factory's next read cursor. +type FactoryEvents struct { + Factory string `json:"factory"` + Supported bool `json:"supported"` + Events []FactoryEvent `json:"events"` + Cursor int64 `json:"cursor"` + Reset bool `json:"reset"` + More bool `json:"more"` +} + +// FactoryJobEventsReader is the engine's half of factory.events_since. +type FactoryJobEventsReader interface { + FactoryEventsSince(ctx context.Context, p FactoryEventsSinceParams) (FactoryEvents, error) +} + +func withFactoryJobEventsFeature(hello Hello, e Engine) Hello { + if _, ok := e.(Factory); !ok { + return hello + } + if _, ok := e.(FactoryJobEventsReader); ok { + hello = withFeature(hello, FeatureFactoryJobEvents) + } + return hello +} + +func init() { + methods["factory.events_since"] = typed(factoryEventsSince) + eventShapes[EventFactoryEvent] = reflect.TypeFor[FactoryEvent]() +} + +func factoryEventsSince(e Engine, p FactoryEventsSinceParams) (*FactoryEvents, error) { + s, ok := e.(FactoryJobEventsReader) + if _, isFactory := e.(Factory); !ok || !isFactory { + return nil, Errorf(CodeUnknownMethod, "this engine does not serve factory.events_since") + } + var err error + if p.Factory, err = factoryID(p.Factory); err != nil { + return nil, err + } + if p.After != nil && *p.After < 0 { + return nil, Errorf(CodeBadParams, "after must be an integer from 0") + } + if p.Limit < 0 || p.Limit > MaxFactoryEvents { + return nil, Errorf(CodeBadParams, "limit must be from 1 to %d, or 0 for the default", MaxFactoryEvents) + } + if p.Limit == 0 { + p.Limit = DefaultFactoryEvents + } + ctx, cancel := factoryCall() + defer cancel() + out, err := s.FactoryEventsSince(ctx, p) + if err != nil { + return nil, err + } + if out.Events == nil { + out.Events = []FactoryEvent{} + } + return &out, nil +} diff --git a/internal/appwire/factory_job_events_test.go b/internal/appwire/factory_job_events_test.go new file mode 100644 index 0000000000..3646dd1e4a --- /dev/null +++ b/internal/appwire/factory_job_events_test.go @@ -0,0 +1,74 @@ +package appwire + +import ( + "context" + "encoding/json" + "reflect" + "testing" +) + +type jobEventsEngine struct { + factoryEngine + got []FactoryEventsSinceParams +} + +func (e *jobEventsEngine) FactoryEventsSince(_ context.Context, p FactoryEventsSinceParams) (FactoryEvents, error) { + e.got = append(e.got, p) + return FactoryEvents{Factory: p.Factory, Supported: true, Reset: p.After == nil}, nil +} + +type onlyJobEvents struct{ fakeEngine } + +func (*onlyJobEvents) FactoryEventsSince(context.Context, FactoryEventsSinceParams) (FactoryEvents, error) { + return FactoryEvents{}, nil +} + +// E10: only a Factory implementing the reader announces and serves the feature. +func TestFactoryJobEventsCapability(t *testing.T) { + for _, e := range []Engine{&fakeEngine{}, &factoryEngine{}, &onlyJobEvents{}} { + if _, err := methods["factory.events_since"].call(e, json.RawMessage(`{"factory":"f"}`)); wireCode(t, err) != CodeUnknownMethod { + t.Fatalf("%T: %v", e, err) + } + if h := withFactoryJobEventsFeature(Hello{}, e); len(h.Features) != 0 { + t.Fatalf("%T announced %v", e, h.Features) + } + } + if h := withFactoryJobEventsFeature(Hello{}, &jobEventsEngine{}); len(h.Features) != 1 || h.Features[0] != FeatureFactoryJobEvents { + t.Fatalf("hello: %+v", h) + } +} + +// E14, E5: validation keeps absent and zero cursors distinct and defaults the limit. +func TestFactoryJobEventsParamsAndShape(t *testing.T) { + e := &jobEventsEngine{} + for _, raw := range []string{`{}`, `{"factory":" "}`, `{"factory":"f","after":-1}`, `{"factory":"f","limit":501}`, `{"factory":"f","limit":-1}`, `{"factory":"f","after":"0"}`} { + if _, err := methods["factory.events_since"].call(e, json.RawMessage(raw)); wireCode(t, err) != CodeBadParams { + t.Fatalf("%s: %v", raw, err) + } + } + for _, raw := range []string{`{"factory":"f"}`, `{"factory":"f","after":0,"follow":true}`} { + out, err := methods["factory.events_since"].call(e, json.RawMessage(raw)) + if err != nil { + t.Fatal(err) + } + got := out.(*FactoryEvents) + if got.Events == nil || !got.Supported { + t.Fatalf("answer: %+v", got) + } + } + if len(e.got) != 2 || e.got[0].After != nil || e.got[1].After == nil || *e.got[1].After != 0 || e.got[0].Limit != DefaultFactoryEvents || !e.got[1].Follow { + t.Fatalf("params: %+v", e.got) + } + if EventShape(EventFactoryEvent) != reflect.TypeFor[FactoryEvent]() { + t.Fatal("event shape was not registered") + } + schema := Schema() + if schema["events"].(map[string]any)[EventFactoryEvent] == nil || schema["methods"].(map[string]any)["factory.events_since"] == nil { + t.Fatal("schema omits the method or event") + } + defs := schema["$defs"].(map[string]any) + props := defs["FactoryEvent"].(map[string]any)["properties"].(map[string]any) + if !reflect.DeepEqual(props["kind"].(map[string]any)["enum"], []string{"finished", "merged", "failed", "needs_you"}) { + t.Fatalf("kind schema: %+v", props["kind"]) + } +} diff --git a/internal/appwire/factory_policy.go b/internal/appwire/factory_policy.go new file mode 100644 index 0000000000..d08601bf9d --- /dev/null +++ b/internal/appwire/factory_policy.go @@ -0,0 +1,123 @@ +package appwire + +import ( + "context" + "encoding/json" + "strings" + + "github.com/Agent-Field/codeaf/internal/factory" + "github.com/Agent-Field/codeaf/internal/factory/api" +) + +const FeatureFactoryGroupPolicy = "factory.group_policy" + +type FactoryPolicy api.Policy + +type FactoryPolicyGetParams struct { + Factory string `json:"factory"` + Group string `json:"group"` +} + +type FactoryPolicySetParams struct { + Factory string `json:"factory"` + Group string `json:"group"` + Stopped bool `json:"stopped"` + Allowances []factory.Allowance `json:"allowances"` + Version *int `json:"version,omitempty"` +} + +type FactoryPolicyKeeper interface { + FactoryPolicyGet(context.Context, FactoryPolicyGetParams) (FactoryPolicy, error) + FactoryPolicySet(context.Context, FactoryPolicySetParams) (FactoryPolicy, error) +} + +func withFactoryGroupPolicyFeature(hello Hello, e Engine) Hello { + if _, ok := e.(Factory); !ok { + return hello + } + if _, ok := e.(FactoryPolicyKeeper); ok { + return withFeature(hello, FeatureFactoryGroupPolicy) + } + return hello +} + +// CheckPolicyGet is shared with direct backend callers. +func CheckPolicyGet(p FactoryPolicyGetParams) (FactoryPolicyGetParams, error) { + var err error + p.Factory, err = factoryID(strings.TrimSpace(p.Factory)) + if err != nil { + return p, err + } + p.Group, err = factory.CheckGroup(p.Group) + if err != nil { + return p, Errorf(CodeBadParams, "%s", err) + } + if p.Group == "" { + return p, Errorf(CodeBadParams, "a policy requires a non-empty group") + } + return p, nil +} + +// CheckPolicySet refuses invalid replacements before any request is sent. +func CheckPolicySet(p FactoryPolicySetParams) (FactoryPolicySetParams, error) { + target, err := CheckPolicyGet(FactoryPolicyGetParams{Factory: p.Factory, Group: p.Group}) + if err != nil { + return p, err + } + p.Factory, p.Group = target.Factory, target.Group + if p.Version != nil && *p.Version < 0 { + return p, Errorf(CodeBadParams, "policy version must not be negative") + } + if err := (factory.GroupPolicy{Group: p.Group, Allowances: p.Allowances}).Check(); err != nil { + return p, Errorf(CodeBadParams, "%s", err) + } + if p.Allowances == nil { + p.Allowances = []factory.Allowance{} + } + return p, nil +} + +func init() { + methods["factory.policy_get"] = shaped[FactoryPolicyGetParams, FactoryPolicy](func(e Engine, raw json.RawMessage) (*FactoryPolicy, error) { + keeper, ok := e.(FactoryPolicyKeeper) + if _, serves := e.(Factory); !ok || !serves { + return nil, Errorf(CodeUnknownMethod, "this engine does not serve factory.policy_get") + } + var p FactoryPolicyGetParams + if err := decodeStrict("factory.policy_get", raw, &p); err != nil { + return nil, err + } + p, err := CheckPolicyGet(p) + if err != nil { + return nil, err + } + ctx, cancel := factoryCall() + defer cancel() + out, err := keeper.FactoryPolicyGet(ctx, p) + if err != nil { + return nil, err + } + return &out, nil + }) + methods["factory.policy_set"] = shaped[FactoryPolicySetParams, FactoryPolicy](func(e Engine, raw json.RawMessage) (*FactoryPolicy, error) { + keeper, ok := e.(FactoryPolicyKeeper) + if _, serves := e.(Factory); !ok || !serves { + return nil, Errorf(CodeUnknownMethod, "this engine does not serve factory.policy_set") + } + var p FactoryPolicySetParams + if err := decodeStrict("factory.policy_set", raw, &p); err != nil { + return nil, err + } + p, err := CheckPolicySet(p) + if err != nil { + return nil, err + } + ctx, cancel := factoryCall() + defer cancel() + out, err := keeper.FactoryPolicySet(ctx, p) + if err != nil { + return nil, err + } + return &out, nil + }) +} diff --git a/internal/appwire/factory_policy_test.go b/internal/appwire/factory_policy_test.go new file mode 100644 index 0000000000..c1812a8b44 --- /dev/null +++ b/internal/appwire/factory_policy_test.go @@ -0,0 +1,100 @@ +package appwire + +import ( + "context" + "encoding/json" + "slices" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/factory" +) + +type policyEngine struct { + factoryEngine + sent int +} + +func (e *policyEngine) FactoryPolicyGet(_ context.Context, p FactoryPolicyGetParams) (FactoryPolicy, error) { + e.sent++ + return FactoryPolicy{Group: p.Group}, nil +} +func (e *policyEngine) FactoryPolicySet(_ context.Context, p FactoryPolicySetParams) (FactoryPolicy, error) { + e.sent++ + return FactoryPolicy{Group: p.Group, Stopped: p.Stopped}, nil +} + +// P11, P12: policy methods register outside job actions and announce their feature. +func TestPolicyWire(t *testing.T) { + e := &policyEngine{} + for _, name := range []string{"factory.policy_get", "factory.policy_set"} { + if _, ok := factoryActionMethods[name]; ok { + t.Fatalf("job action %s", name) + } + if _, ok := methods[name]; !ok { + t.Fatalf("missing %s", name) + } + if _, err := methods[name].call(&factoryEngine{}, json.RawMessage(`{}`)); wireCode(t, err) != CodeUnknownMethod { + t.Fatal(err) + } + } + if h := withFactoryGroupPolicyFeature(Hello{}, e); !slices.Contains(h.Features, "factory.group_policy") { + t.Fatalf("hello = %+v", h) + } + if h := withFactoryGroupPolicyFeature(Hello{}, &factoryEngine{}); len(h.Features) != 0 { + t.Fatal(h) + } + if !slices.Contains(helloFeatures(t, e), FeatureFactoryGroupPolicy) { + t.Fatal("the server omitted group policy from hello") + } + defs := Schema()["$defs"].(map[string]any) + props := defs["FactoryCapabilities"].(map[string]any)["properties"].(map[string]any) + for _, flag := range []string{"group_policy", "create_group"} { + if props[flag] == nil { + t.Fatalf("schema omitted %s", flag) + } + } + if _, err := methods["factory.policy_get"].call(e, json.RawMessage(`{"factory":" f ","group":" g "}`)); err != nil || e.sent != 1 { + t.Fatalf("get = %v, %d", err, e.sent) + } + if _, err := methods["factory.policy_set"].call(e, json.RawMessage(`{"factory":"f","group":"g","stopped":false,"allowances":[],"version":0}`)); err != nil || e.sent != 2 { + t.Fatalf("set = %v, %d", err, e.sent) + } +} + +// P11: every bound is checked before reaching the backend. +func TestPolicyWireBounds(t *testing.T) { + valid := func() FactoryPolicySetParams { + return FactoryPolicySetParams{Factory: "f", Group: "g", Allowances: []factory.Allowance{{ID: "m2", Jobs: []int{1}, CapUSD: 15, EndsAt: "2026-11-12T12:00:00Z"}}} + } + for _, change := range []func(*FactoryPolicySetParams){ + func(p *FactoryPolicySetParams) { p.Group = "" }, + func(p *FactoryPolicySetParams) { p.Group = strings.Repeat("x", 201) }, + func(p *FactoryPolicySetParams) { p.Group = "g\x00" }, + func(p *FactoryPolicySetParams) { p.Allowances = make([]factory.Allowance, 51) }, + func(p *FactoryPolicySetParams) { p.Allowances[0].ID = "" }, + func(p *FactoryPolicySetParams) { p.Allowances[0].ID = strings.Repeat("x", 65) }, + func(p *FactoryPolicySetParams) { p.Allowances[0].ID = "bad id" }, + func(p *FactoryPolicySetParams) { p.Allowances = append(p.Allowances, p.Allowances[0]) }, + func(p *FactoryPolicySetParams) { p.Allowances[0].Jobs = nil }, + func(p *FactoryPolicySetParams) { p.Allowances[0].Jobs = make([]int, 201) }, + func(p *FactoryPolicySetParams) { p.Allowances[0].Jobs = []int{0} }, + func(p *FactoryPolicySetParams) { p.Allowances[0].Jobs = []int{-1} }, + func(p *FactoryPolicySetParams) { p.Allowances[0].Jobs = []int{1, 1} }, + func(p *FactoryPolicySetParams) { p.Allowances[0].CapUSD = 0 }, + func(p *FactoryPolicySetParams) { p.Allowances[0].CapUSD = 10001 }, + func(p *FactoryPolicySetParams) { p.Allowances[0].EndsAt = "tomorrow" }, + func(p *FactoryPolicySetParams) { n := -1; p.Version = &n }, + } { + p := valid() + change(&p) + raw, err := json.Marshal(p) + if err != nil { + t.Fatal(err) + } + e := &policyEngine{} + if _, err := methods["factory.policy_set"].call(e, raw); wireCode(t, err) != CodeBadParams || e.sent != 0 { + t.Fatalf("accepted %s: %v, sends %d", raw, err, e.sent) + } + } +} diff --git a/internal/appwire/factory_repo_issues.go b/internal/appwire/factory_repo_issues.go new file mode 100644 index 0000000000..e62208dbfd --- /dev/null +++ b/internal/appwire/factory_repo_issues.go @@ -0,0 +1,83 @@ +package appwire + +import ( + "context" + "encoding/json" + + "github.com/Agent-Field/codeaf/internal/factory/api" +) + +// FeatureFactoryRepoIssues announces a read of one repository's GitHub issues. +const FeatureFactoryRepoIssues = "factory.repo_issues" + +// FactoryRepoIssuesParams selects one bounded read, with no lower bound when +// Since is absent. A nil Page defaults to 1; an explicit zero is invalid. +type FactoryRepoIssuesParams struct { + Factory string `json:"factory"` + Repo string `json:"repo"` + Since string `json:"since,omitempty"` + Page *int `json:"page,omitempty"` +} + +// FactoryRepoIssues names the connection that supplied the repository issues. +type FactoryRepoIssues struct { + Factory string `json:"factory"` + api.RepoIssues +} + +// FactoryRepoIssuesReader is the engine's read-only repository issue interface. +type FactoryRepoIssuesReader interface { + FactoryRepoIssues(context.Context, FactoryRepoIssuesParams) (FactoryRepoIssues, error) +} + +func withFactoryRepoIssuesFeature(hello Hello, e Engine) Hello { + if _, ok := e.(Factory); !ok { + return hello + } + if _, ok := e.(FactoryRepoIssuesReader); ok { + hello = withFeature(hello, FeatureFactoryRepoIssues) + } + return hello +} + +// CheckRepoIssues checks the repository and cursor before any request is sent. +func CheckRepoIssues(p FactoryRepoIssuesParams) (FactoryRepoIssuesParams, error) { + var err error + if p.Factory, p.Repo, err = checkPipelineRepo(p.Factory, p.Repo); err != nil { + return p, err + } + if p.Page == nil { + n := 1 + p.Page = &n + } + if err := api.CheckRepoIssues(p.Since, *p.Page); err != nil { + return p, Errorf(CodeBadParams, "%s", err) + } + return p, nil +} + +func init() { + methods[FeatureFactoryRepoIssues] = shaped[FactoryRepoIssuesParams, FactoryRepoIssues](readRepoIssues) +} + +func readRepoIssues(e Engine, raw json.RawMessage) (*FactoryRepoIssues, error) { + r, ok := e.(FactoryRepoIssuesReader) + if _, isFactory := e.(Factory); !ok || !isFactory { + return nil, Errorf(CodeUnknownMethod, "this engine does not serve %s", FeatureFactoryRepoIssues) + } + var p FactoryRepoIssuesParams + if err := decodeStrict(FeatureFactoryRepoIssues, raw, &p); err != nil { + return nil, err + } + p, err := CheckRepoIssues(p) + if err != nil { + return nil, err + } + ctx, cancel := factoryCall() + defer cancel() + out, err := r.FactoryRepoIssues(ctx, p) + if err != nil { + return nil, err + } + return &out, nil +} diff --git a/internal/appwire/factory_repo_issues_test.go b/internal/appwire/factory_repo_issues_test.go new file mode 100644 index 0000000000..cfee32c145 --- /dev/null +++ b/internal/appwire/factory_repo_issues_test.go @@ -0,0 +1,138 @@ +package appwire + +import ( + "context" + "encoding/json" + "reflect" + "testing" +) + +type repoIssuesEngine struct { + factoryEngine + got []FactoryRepoIssuesParams +} + +func (e *repoIssuesEngine) FactoryRepoIssues(_ context.Context, p FactoryRepoIssuesParams) (FactoryRepoIssues, error) { + e.got = append(e.got, p) + return FactoryRepoIssues{Factory: p.Factory}, nil +} + +type onlyRepoIssuesReader struct{ fakeEngine } + +func (*onlyRepoIssuesReader) FactoryRepoIssues(context.Context, FactoryRepoIssuesParams) (FactoryRepoIssues, error) { + return FactoryRepoIssues{}, nil +} + +// C1: Only a Factory with this reader announces and serves the method. +func TestRepoIssuesNeedsItsFactoryReader(t *testing.T) { + for _, e := range []Engine{&fakeEngine{}, &factoryEngine{}, &onlyRepoIssuesReader{}} { + _, err := methods[FeatureFactoryRepoIssues].call(e, json.RawMessage(`{"factory":"f","repo":"acme/api"}`)) + if wireCode(t, err) != CodeUnknownMethod { + t.Errorf("%T: %v", e, err) + } + if h := withFactoryRepoIssuesFeature(Hello{}, e); len(h.Features) != 0 { + t.Errorf("%T announced %v", e, h.Features) + } + } + h := withFactoryRepoIssuesFeature(Hello{}, &repoIssuesEngine{}) + if len(h.Features) != 1 || h.Features[0] != FeatureFactoryRepoIssues { + t.Fatalf("hello = %+v", h) + } +} + +// C4: Defaults and valid cursors reach the reader without changing since. +func TestRepoIssuesChecksAndDefaultsItsParams(t *testing.T) { + e := &repoIssuesEngine{} + for _, raw := range []string{ + `{"factory":" f ","repo":" acme/api "}`, + `{"factory":"f","repo":"acme/api","since":"2026-11-01T02:03:04.123+05:30","page":10}`, + } { + if _, err := methods[FeatureFactoryRepoIssues].call(e, json.RawMessage(raw)); err != nil { + t.Fatal(err) + } + } + if len(e.got) != 2 || e.got[0].Factory != "f" || e.got[0].Repo != "acme/api" || *e.got[0].Page != 1 || + *e.got[1].Page != 10 || e.got[1].Since != "2026-11-01T02:03:04.123+05:30" { + t.Fatalf("reader got %+v", e.got) + } +} + +// C4: Invalid input is refused before the reader can send a request. +func TestRepoIssuesRefusesBadParamsBeforeReading(t *testing.T) { + e := &repoIssuesEngine{} + for _, raw := range []string{ + `{}`, `{"factory":"f"}`, `{"repo":"acme/api"}`, + `{"factory":"f","repo":"acme"}`, `{"factory":"f","repo":"acme/../api"}`, + `{"factory":"f","repo":"acme/api?x=1"}`, + `{"factory":"f","repo":"acme/api","since":"yesterday"}`, + `{"factory":"f","repo":"acme/api","since":"2026-11-01"}`, + `{"factory":"f","repo":"acme/api","page":0}`, + `{"factory":"f","repo":"acme/api","page":-1}`, + `{"factory":"f","repo":"acme/api","page":11}`, + `{"factory":"f","repo":"acme/api","page":1.5}`, + `{"factory":"f","repo":"acme/api","page":"2"}`, + `{"factory":"f","repo":"acme/api","state":"open"}`, + } { + if _, err := methods[FeatureFactoryRepoIssues].call(e, json.RawMessage(raw)); wireCode(t, err) != CodeBadParams { + t.Errorf("%s: %v", raw, err) + } + } + if len(e.got) != 0 { + t.Fatalf("refused input reached the reader: %+v", e.got) + } +} + +// C1, C2, C3, C4: The independently registered method publishes its params, +// result, issue states, timestamps, optional fields and literal capability key. +func TestFactoryRepoIssuesSchemaNamesTheMethodAndTheCapability(t *testing.T) { + doc := Schema() + wantMethod := map[string]any{ + "params": map[string]any{"$ref": "#/$defs/in.FactoryRepoIssuesParams"}, + "result": map[string]any{"$ref": "#/$defs/FactoryRepoIssues"}, + } + if got := doc["methods"].(map[string]any)["factory.repo_issues"]; !reflect.DeepEqual(got, wantMethod) { + t.Fatalf("method schema = %#v, want %#v", got, wantMethod) + } + defs := doc["$defs"].(map[string]any) + text := map[string]any{"type": "string"} + integer := map[string]any{"type": "integer"} + stamp := map[string]any{"type": "string", "format": "date-time"} + null := map[string]any{"type": "null"} + for _, tc := range []struct { + name string + fields map[string]any + required []string + }{ + {"in.FactoryRepoIssuesParams", map[string]any{ + "factory": text, "repo": text, "since": text, + "page": map[string]any{"anyOf": []any{integer, null}}, + }, nil}, + {"FactoryRepoIssues", map[string]any{ + "factory": text, "repo": text, "rows": integer, + "issues": map[string]any{"anyOf": []any{ + map[string]any{"type": "array", "items": map[string]any{"$ref": "#/$defs/RepoIssue"}}, null, + }}, + "newest": map[string]any{"anyOf": []any{stamp, null}}, + }, []string{"factory", "repo", "issues", "rows"}}, + {"RepoIssue", map[string]any{ + "id": integer, "number": integer, "title": text, "body": text, "url": text, + "state": map[string]any{"type": "string", "enum": []string{"open", "closed"}}, + "created_at": stamp, "updated_at": stamp, + "body_cut": map[string]any{"type": "boolean"}, + }, []string{"id", "number", "title", "body", "url", "state", "created_at", "updated_at"}}, + } { + t.Run(tc.name, func(t *testing.T) { + want := map[string]any{"type": "object", "properties": tc.fields, "additionalProperties": false} + if tc.required != nil { + want["required"] = tc.required + } + if got := defs[tc.name]; !reflect.DeepEqual(got, want) { + t.Fatalf("schema = %#v, want %#v", got, want) + } + }) + } + caps := defs["FactoryCapabilities"].(map[string]any)["properties"].(map[string]any) + if got := caps["repo_issues"]; !reflect.DeepEqual(got, map[string]any{"type": "boolean"}) { + t.Fatalf("repo_issues capability schema = %#v", got) + } +} diff --git a/internal/appwire/factory_split_from_test.go b/internal/appwire/factory_split_from_test.go new file mode 100644 index 0000000000..9a39a8be89 --- /dev/null +++ b/internal/appwire/factory_split_from_test.go @@ -0,0 +1,61 @@ +package appwire + +import ( + "encoding/json" + "strings" + "testing" +) + +// S1/S2: split lineage is described by the schema and carried to the actor. +func TestFactoryCreateSplitFromSchemaAndArguments(t *testing.T) { + raw, err := SchemaJSON() + if err != nil { + t.Fatal(err) + } + var schema struct { + Defs map[string]struct { + Properties map[string]json.RawMessage `json:"properties"` + } `json:"$defs"` + } + if err := json.Unmarshal(raw, &schema); err != nil { + t.Fatal(err) + } + for def, field := range map[string]string{ + "FactoryCapabilities": "create_split_from", "in.FactoryJobCreateParams": "split_from", + } { + if _, ok := schema.Defs[def].Properties[field]; !ok { + t.Errorf("%s lacks %s", def, field) + } + } + for _, value := range []int{1, 80} { + eng := &actorEngine{} + params, err := json.Marshal(map[string]any{"factory": "f", "repo": "o/r", "title": "Fix", "split_from": value}) + if err != nil { + t.Fatal(err) + } + _, err = methods["factory.job_create"].call(eng, params) + if err != nil { + t.Fatal(err) + } + got := eng.got[0].(FactoryJobCreateParams) + if got.SplitFrom == nil || *got.SplitFrom != value { + t.Fatalf("split_from not carried: %+v", got) + } + } +} + +// S3: explicit zero, negative and non-integer values never reach the actor. +func TestFactoryCreateSplitFromRefusesInvalidNumbers(t *testing.T) { + for _, value := range []string{"0", "-1", "1.5", `"80"`} { + t.Run(value, func(t *testing.T) { + eng := &actorEngine{} + _, err := methods["factory.job_create"].call(eng, json.RawMessage(`{"factory":"f","repo":"o/r","title":"Fix","split_from":`+value+`}`)) + if wireCode(t, err) != CodeBadParams || !strings.Contains(err.Error(), "split_from") { + t.Fatalf("invalid split_from refusal: %v", err) + } + if len(eng.acts) != 0 { + t.Fatal("invalid split_from reached the actor") + } + }) + } +} diff --git a/internal/appwire/factory_waits.go b/internal/appwire/factory_waits.go new file mode 100644 index 0000000000..6c59f6430e --- /dev/null +++ b/internal/appwire/factory_waits.go @@ -0,0 +1,143 @@ +package appwire + +import ( + "context" + "encoding/json" + "strings" + + "github.com/Agent-Field/codeaf/internal/factory" + "github.com/Agent-Field/codeaf/internal/factory/api" +) + +// FeatureFactoryWaitsSet announces the engine's typed waits door. +const FeatureFactoryWaitsSet = "factory.waits_set" + +// FeatureFactoryFeatures announces discovery of a connected factory's features. +const FeatureFactoryFeatures = "factory.features" + +// FactoryWaitsSetParams replaces one unstarted job's parents; an empty list clears them. +type FactoryWaitsSetParams struct { + Factory string `json:"factory"` + Job int `json:"job"` + WaitsOn []int `json:"waits_on"` + Expect *api.Expect `json:"expect,omitempty"` +} + +// FactoryWaitsSet includes one read after the accepted change, when available. +type FactoryWaitsSet struct { + Factory string `json:"factory"` + Job int `json:"job"` + WaitsOn []int `json:"waits_on"` + Current *api.Job `json:"current,omitempty"` +} + +// FactoryWaitsSetter is optional on top of Factory and sends each change once. +type FactoryWaitsSetter interface { + FactoryWaitsSet(context.Context, FactoryWaitsSetParams) (FactoryWaitsSet, error) +} + +// FactoryFeaturesParams names the factory whose own features are read. +type FactoryFeaturesParams struct { + Factory string `json:"factory"` +} + +// FactoryFeatures is what the connected factory serves, independent of this engine. +type FactoryFeatures struct { + Features []string `json:"features"` +} + +// FactoryFeaturesReader is the engine's half of per-factory discovery. +type FactoryFeaturesReader interface { + FactoryFeatures(context.Context, FactoryFeaturesParams) (FactoryFeatures, error) +} + +func withFactoryWaitsSetFeature(hello Hello, e Engine) Hello { + if _, ok := e.(Factory); !ok { + return hello + } + if _, ok := e.(FactoryWaitsSetter); ok { + return withFeature(hello, FeatureFactoryWaitsSet) + } + return hello +} + +func withFactoryFeaturesFeature(hello Hello, e Engine) Hello { + if _, ok := e.(Factory); !ok { + return hello + } + if _, ok := e.(FactoryFeaturesReader); ok { + return withFeature(hello, FeatureFactoryFeatures) + } + return hello +} + +// CheckWaitsSet is shared with the backend so invalid requests cannot be sent. +func CheckWaitsSet(p FactoryWaitsSetParams) (FactoryWaitsSetParams, error) { + if err := checkJobTarget(&p.Factory, p.Job); err != nil { + return p, err + } + if p.WaitsOn == nil { + return p, Errorf(CodeBadParams, "waits_on must be a list of job ids; use an empty list to clear it") + } + if err := factory.CheckWaitsIDs(p.WaitsOn); err != nil { + return p, Errorf(CodeBadParams, "%s", err) + } + if exp := p.Expect; exp != nil { + if exp.Version != nil && *exp.Version < 1 { + return p, Errorf(CodeBadParams, "expect.version must be a positive whole number") + } + if len(exp.State) > 64 || len(exp.Step) > MaxFactoryStepName { + return p, Errorf(CodeBadParams, "expect.state and expect.step are too long") + } + } + return p, nil +} + +func init() { + methods[FeatureFactoryWaitsSet] = shaped[FactoryWaitsSetParams, FactoryWaitsSet](func(e Engine, raw json.RawMessage) (*FactoryWaitsSet, error) { + r, ok := e.(FactoryWaitsSetter) + if _, isFactory := e.(Factory); !ok || !isFactory { + return nil, Errorf(CodeUnknownMethod, "this engine does not serve %s", FeatureFactoryWaitsSet) + } + var p FactoryWaitsSetParams + if err := decodeStrict(FeatureFactoryWaitsSet, raw, &p); err != nil { + return nil, err + } + p, err := CheckWaitsSet(p) + if err != nil { + return nil, err + } + ctx, cancel := factoryCall() + defer cancel() + out, err := r.FactoryWaitsSet(ctx, p) + if err != nil { + return nil, err + } + return &out, nil + }) + methods[FeatureFactoryFeatures] = shaped[FactoryFeaturesParams, FactoryFeatures](func(e Engine, raw json.RawMessage) (*FactoryFeatures, error) { + r, ok := e.(FactoryFeaturesReader) + if _, isFactory := e.(Factory); !ok || !isFactory { + return nil, Errorf(CodeUnknownMethod, "this engine does not serve %s", FeatureFactoryFeatures) + } + var p FactoryFeaturesParams + if err := decodeStrict(FeatureFactoryFeatures, raw, &p); err != nil { + return nil, err + } + var err error + p.Factory, err = factoryID(strings.TrimSpace(p.Factory)) + if err != nil { + return nil, err + } + ctx, cancel := factoryCall() + defer cancel() + out, err := r.FactoryFeatures(ctx, p) + if err != nil { + return nil, err + } + if out.Features == nil { + out.Features = []string{} + } + return &out, nil + }) +} diff --git a/internal/appwire/factory_waits_test.go b/internal/appwire/factory_waits_test.go new file mode 100644 index 0000000000..177214ca08 --- /dev/null +++ b/internal/appwire/factory_waits_test.go @@ -0,0 +1,91 @@ +package appwire + +import ( + "context" + "encoding/json" + "fmt" + "reflect" + "slices" + "testing" +) + +type waitsEngine struct { + factoryEngine + sent int + got FactoryWaitsSetParams +} + +func (e *waitsEngine) FactoryWaitsSet(_ context.Context, p FactoryWaitsSetParams) (FactoryWaitsSet, error) { + e.sent++ + e.got = p + return FactoryWaitsSet{Factory: p.Factory, Job: p.Job, WaitsOn: p.WaitsOn}, nil +} + +func (e *waitsEngine) FactoryFeatures(context.Context, FactoryFeaturesParams) (FactoryFeatures, error) { + return FactoryFeatures{Features: []string{"waits_on"}}, nil +} + +// W9: both methods register independently of job actions and advertise only when served. +func TestWaitsMethodsAndFeatures(t *testing.T) { + e := &waitsEngine{} + if _, ok := factoryActionMethods[FeatureFactoryWaitsSet]; ok { + t.Fatal("waits_set was registered as a job action") + } + for _, name := range []string{FeatureFactoryWaitsSet, FeatureFactoryFeatures} { + if _, ok := methods[name]; !ok { + t.Fatalf("missing %s", name) + } + } + h := withFactoryFeaturesFeature(withFactoryWaitsSetFeature(Hello{}, e), e) + if !slices.Contains(h.Features, FeatureFactoryWaitsSet) || !slices.Contains(h.Features, FeatureFactoryFeatures) { + t.Fatalf("hello = %+v", h) + } + for _, old := range []Engine{&fakeEngine{}, &factoryEngine{}} { + h := withFactoryFeaturesFeature(withFactoryWaitsSetFeature(Hello{}, old), old) + if len(h.Features) != 0 { + t.Fatalf("old engine hello = %+v", h) + } + for _, name := range []string{FeatureFactoryWaitsSet, FeatureFactoryFeatures} { + if _, err := methods[name].call(old, json.RawMessage(`{}`)); wireCode(t, err) != CodeUnknownMethod { + t.Fatalf("%s = %v", name, err) + } + } + } + got, err := methods[FeatureFactoryFeatures].call(e, json.RawMessage(`{"factory":"f"}`)) + if err != nil || !reflect.DeepEqual(got, &FactoryFeatures{Features: []string{"waits_on"}}) { + t.Fatalf("features = %+v, %v", got, err) + } +} + +// W10: invalid input never reaches the engine. +func TestWaitsWireBounds(t *testing.T) { + ids := make([]int, 21) + for i := range ids { + ids[i] = i + 1 + } + tooMany, _ := json.Marshal(ids) + for _, raw := range []string{ + `{"factory":"f","job":1,"waits_on":` + string(tooMany) + `}`, + `{"factory":"f","job":1,"waits_on":[0]}`, + `{"factory":"f","job":1,"waits_on":[-1]}`, + `{"factory":"f","job":1,"waits_on":[2,2]}`, + `{"factory":"f","job":0,"waits_on":[]}`, + `{"factory":"f","job":1}`, `{"factory":"f","job":1,"waits_on":null}`, + `{"factory":"f","job":1,"waits_on":[],"extra":true}`, + } { + t.Run(raw, func(t *testing.T) { + e := &waitsEngine{} + _, err := methods[FeatureFactoryWaitsSet].call(e, json.RawMessage(raw)) + if wireCode(t, err) != CodeBadParams || e.sent != 0 { + t.Fatalf("invalid waits = %v; sends = %d", err, e.sent) + } + }) + } + for _, ids := range []string{`[]`, `[2,3]`} { + e := &waitsEngine{} + _, err := methods[FeatureFactoryWaitsSet].call(e, json.RawMessage(fmt.Sprintf(`{"factory":" f ","job":1,"waits_on":%s}`, ids))) + if err != nil || e.sent != 1 || e.got.Factory != "f" { + t.Fatalf("valid waits = %v; sends = %d; params = %+v", err, e.sent, e.got) + } + } +} diff --git a/internal/appwire/server.go b/internal/appwire/server.go index c5b0a8d730..5f4cfe85ed 100644 --- a/internal/appwire/server.go +++ b/internal/appwire/server.go @@ -214,7 +214,9 @@ func (s *Server) Serve(ctx context.Context, in io.Reader, hello Hello, engine En hello = withCrewsOrganizeFeature(hello, engine) hello = withReplySuggestionFeature(hello, engine) hello = withUnreadFeature(hello, engine) + hello = withAgentScopeFeature(hello, engine) hello = withErrandFeature(hello, engine) + hello = withClientToolsFeature(hello, engine) hello = withAsksDrawnFeature(hello, engine) hello = withTaskModelsAuxiliaryFeature(hello, engine) hello = withMemorySettingsFeature(hello, engine) @@ -228,6 +230,7 @@ func (s *Server) Serve(ctx context.Context, in io.Reader, hello Hello, engine En hello = withFactoryFeature(hello, engine) hello = withSearchJobsFeature(hello, engine) hello = withFactoryActorFeature(hello, engine) + hello = withFactoryJobContextFeature(hello, engine) hello = withFactoryJobStepRerunModelFeature(hello, engine) hello = withFactoryJobStepEditFeature(hello, engine) hello = withFactoryJobStepEditMaxFeature(hello, engine) @@ -280,7 +283,13 @@ func (s *Server) Serve(ctx context.Context, in io.Reader, hello Hello, engine En hello = withFactoryPipelineCopyFeature(hello, engine) hello = withFactorySpendByRepoFeature(hello, engine) hello = withFactorySpendByModelFeature(hello, engine) + hello = withFactoryJobEventsFeature(hello, engine) hello = withFactoryRepoFactsFeature(hello, engine) + hello = withFactoryWaitsSetFeature(hello, engine) + hello = withFactoryFeaturesFeature(hello, engine) + hello = withFactoryRepoIssuesFeature(hello, engine) + hello = withFactoryGroupPolicyFeature(hello, engine) + hello = withCollectionsFactoryJobFeature(hello, engine) s.Emit(EventHello, hello) if warmer, ok := engine.(Warmer); ok { go warmer.Warm() @@ -398,7 +407,9 @@ func (s *Server) handle(ctx context.Context, line []byte, engine Engine, infligh // 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": typed(func(Engine, struct{}) (*Empty, error) { return &Empty{}, nil }), + "agents.tools_set": typed(setClientTools), + "agents.tool_result": typed(answerClientTool), + "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 { diff --git a/internal/appwire/turntool_spend_test.go b/internal/appwire/turntool_spend_test.go new file mode 100644 index 0000000000..3d9988635d --- /dev/null +++ b/internal/appwire/turntool_spend_test.go @@ -0,0 +1,64 @@ +package appwire + +import ( + "encoding/json" + "testing" +) + +// Contract (a), (b): the registered event schema exposes an optional numeric +// spend, and the JSON event carries a supplied figure while omitting absence. +func TestTurnToolSpendWireContract(t *testing.T) { + out, err := SchemaJSON() + if err != nil { + t.Fatal(err) + } + var doc struct { + Events map[string]struct { + Ref string `json:"$ref"` + } `json:"events"` + Defs map[string]struct { + Properties map[string]struct { + AnyOf []struct { + Type string `json:"type"` + } `json:"anyOf"` + } `json:"properties"` + Required []string `json:"required"` + } `json:"$defs"` + } + if err := json.Unmarshal(out, &doc); err != nil { + t.Fatal(err) + } + if doc.Events[EventTurnTool].Ref != "#/$defs/TurnTool" { + t.Fatalf("turn.tool schema = %+v", doc.Events[EventTurnTool]) + } + shape := doc.Defs["TurnTool"] + alts := shape.Properties["spent_usd"].AnyOf + if len(alts) != 2 || alts[0].Type != "number" || alts[1].Type != "null" { + t.Fatalf("TurnTool.spent_usd schema = %+v", alts) + } + for _, field := range shape.Required { + if field == "spent_usd" { + t.Fatal("TurnTool.spent_usd became required") + } + } + if _, present := doc.Defs["Entry"].Properties["spent_usd"]; present { + t.Fatal("history entries acquired live spend") + } + usd := .375 + for _, figure := range []*float64{nil, &usd} { + raw, err := json.Marshal(TurnTool{Agent: "a", Turn: 1, Call: "c", Name: "read", Phase: PhaseBegin, SpentUSD: figure}) + if err != nil { + t.Fatal(err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + t.Fatal(err) + } + if _, present := fields["spent_usd"]; present != (figure != nil) { + t.Fatalf("wrong spend presence: %s", raw) + } + if figure != nil && string(fields["spent_usd"]) != "0.375" { + t.Fatalf("wrong spend figure: %s", raw) + } + } +} diff --git a/internal/appwire/wire.go b/internal/appwire/wire.go index a342ce47c8..baa5453d85 100644 --- a/internal/appwire/wire.go +++ b/internal/appwire/wire.go @@ -981,6 +981,11 @@ type ( Phase string `json:"phase"` OK *bool `json:"ok,omitempty"` Summary string `json:"summary,omitempty"` + // SpentUSD is the conversation's live spend when this step was said, + // the same meter agents.budget.get calls engine.spent_usd. It can be + // ahead of Agent.SpentUSD, saved at turn end, and is omitted when zero + // or unknown. + SpentUSD *float64 `json:"spent_usd,omitempty"` ToolFacts } TurnDone struct { diff --git a/internal/factory/api/codec.go b/internal/factory/api/codec.go index f398bb5197..836c6b2ead 100644 --- a/internal/factory/api/codec.go +++ b/internal/factory/api/codec.go @@ -110,6 +110,8 @@ func ToSnapshot(q Queue, jobs []Job) factory.Snapshot { // Lost names the Go fields the wire does not carry, and why. The round-trip // test reads it, so a field is either carried or named here. var Lost = map[string]string{ + "JobContext.Markdown": "attachment text is private to worker admission; ordinary job and queue reads carry only title and source", + "Item.GitHubIssueID": "service-owned permanent GitHub identity; workers send RunReport deltas through run.ApplyReport, never replacement Items", "Item.SourceClosed": "service-owned automatic close provenance; RunReport has no such field; starting work clears it through TouchIssue", "Item.PersonTouched": "service-owned reconciliation exclusion; RunReport cannot replace it; store.RecordIssueTouch preserves ownership", @@ -150,7 +152,8 @@ func FromItem(it factory.Item) Job { factory.MarkPersonStopped(&it) } j := Job{ - FromJob: it.FromJob, SourceIssue: it.SourceIssue, + Group: it.Group, Milestone: it.Milestone, Context: mapList(it.Context, fromJobContext), + FromJob: it.FromJob, SplitFrom: it.SplitFrom, SourceIssue: it.SourceIssue, Recovery: it.Recovery, Autonomy: it.Autonomy, AutonomyWhy: factory.AutonomyBlockReason(it), FollowUp: it.FollowUp, Manual: it.Manual, Effort: it.Effort, SetupHeld: it.SetupHeld, Held: heldInQueue(it), OverLimit: it.OverLimit && it.State == factory.StateQueued, Owners: append([]string(nil), it.Owners...), @@ -218,6 +221,8 @@ func FromItem(it factory.Item) Job { } j.FollowPR = factory.IsIssueJob(it) && it.DoneHow != "dropped" && it.DoneHow != "closed_locally" + waitsFromItem(&j, it) + policyFromItem(&j, it) return j } @@ -481,7 +486,8 @@ func ToItem(j Job) factory.Item { d = &JobDesk{} } it := factory.Item{ - FromJob: j.FromJob, SourceIssue: j.SourceIssue, + Group: j.Group, Milestone: j.Milestone, Context: mapList(j.Context, toJobContext), + FromJob: j.FromJob, SplitFrom: j.SplitFrom, SourceIssue: j.SourceIssue, Recovery: j.Recovery, Autonomy: j.Autonomy, FollowUp: j.FollowUp, Manual: j.Manual, Effort: j.Effort, SetupHeld: j.SetupHeld, StopAfterStep: j.StopAfterStep, Owners: append([]string(nil), j.Owners...), diff --git a/internal/factory/api/job_context.go b/internal/factory/api/job_context.go new file mode 100644 index 0000000000..cbb3e049a5 --- /dev/null +++ b/internal/factory/api/job_context.go @@ -0,0 +1,25 @@ +package api + +import "github.com/Agent-Field/codeaf/internal/factory" + +// JobContextRequest replaces attachments before the job's first run; [] clears them. +type JobContextRequest struct { + Context []factory.JobContext `json:"context"` + Expect *Expect `json:"expect"` +} + +// JobContext names an attachment on a job read; only worker admission carries Markdown. +type JobContext struct { + Title string `json:"title"` + Source string `json:"source,omitempty"` + // Markdown crosses only to the worker that must brief the plan. + Markdown string `json:"markdown,omitempty"` +} + +func fromJobContext(c factory.JobContext) JobContext { + return JobContext{Title: c.Title, Source: c.Source} +} + +func toJobContext(c JobContext) factory.JobContext { + return factory.JobContext{Title: c.Title, Source: c.Source, Markdown: c.Markdown} +} diff --git a/internal/factory/api/job_events.go b/internal/factory/api/job_events.go new file mode 100644 index 0000000000..58111c16e7 --- /dev/null +++ b/internal/factory/api/job_events.go @@ -0,0 +1,18 @@ +package api + +import "github.com/Agent-Field/codeaf/internal/factory/live" + +// JobEvent is one durable finished, merged, failed or needs-you job outcome. +type JobEvent = live.Outcome + +// JobEvents answers a bounded read from the factory's outcome cursor. +type JobEvents struct { + // Events contains visible outcomes in sequence order, and is never null. + Events []JobEvent `json:"events"` + // Cursor is the position to send as after on the next read. + Cursor int64 `json:"cursor"` + // Reset asks the caller to read the current factory state once, then continue. + Reset bool `json:"reset"` + // More says another bounded read can return further retained outcomes. + More bool `json:"more"` +} diff --git a/internal/factory/api/jobs.go b/internal/factory/api/jobs.go index a6b31aa5b6..4be0828673 100644 --- a/internal/factory/api/jobs.go +++ b/internal/factory/api/jobs.go @@ -24,6 +24,11 @@ type NewJob struct { Pipeline string `json:"pipeline,omitempty"` Issue bool `json:"issue,omitempty"` Autostart bool `json:"autostart,omitempty"` + + Group string `json:"group,omitempty"` + Milestone string `json:"milestone,omitempty"` + Budget *float64 `json:"budget,omitempty"` + SplitFrom *int `json:"split_from,omitempty"` } // JobAction carries the read a person acted on so mutations can reject stale pages. diff --git a/internal/factory/api/policy.go b/internal/factory/api/policy.go new file mode 100644 index 0000000000..c5204485fc --- /dev/null +++ b/internal/factory/api/policy.go @@ -0,0 +1,53 @@ +package api + +import ( + "time" + + "github.com/Agent-Field/codeaf/internal/factory" +) + +// Policy is the saved group choice with current lifetime spending. +type Policy struct { + Group string `json:"group"` + Version int `json:"version"` + Stopped bool `json:"stopped"` + Owner string `json:"owner,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + SpentUSD float64 `json:"spent_usd"` + Allowances []PolicyAllowance `json:"allowances"` +} + +type PolicyAllowance struct { + factory.Allowance + SpentUSD float64 `json:"spent_usd"` + State string `json:"state" enum:"active,ended,spent"` +} + +// PolicyFrom gives an ended allowance precedence over a spent one. +func PolicyFrom(p factory.GroupPolicy, jobs []factory.Item, now time.Time) Policy { + spent, per := factory.PolicySpend(p, jobs) + out := Policy{Group: p.Group, Version: p.Version, Stopped: p.Stopped, Owner: p.Owner, UpdatedAt: p.UpdatedAt, SpentUSD: spent, Allowances: make([]PolicyAllowance, 0, len(p.Allowances))} + for _, a := range p.Allowances { + state := "active" + end, _ := time.Parse(time.RFC3339, a.EndsAt) + if !now.Before(end) { + state = "ended" + } else if factory.PolicyMoneyCompare(per[a.ID], a.CapUSD) >= 0 { + state = "spent" + } + a.Jobs = append([]int(nil), a.Jobs...) + out.Allowances = append(out.Allowances, PolicyAllowance{Allowance: a, SpentUSD: per[a.ID], State: state}) + } + return out +} + +func policyFromItem(j *Job, it factory.Item) { + j.Group = it.Group + if b := it.Blocked; b != nil && b.By == factory.BlockPolicy { + j.Blocked = &JobBlock{By: b.By, Reason: b.Reason, Since: b.Since, Job: b.Job} + } +} + +func init() { + Lost["Item.PolicyRun"] = "service-owned allowance admission; a worker or client snapshot cannot start a policy run" +} diff --git a/internal/factory/api/policy_test.go b/internal/factory/api/policy_test.go new file mode 100644 index 0000000000..e4250077b2 --- /dev/null +++ b/internal/factory/api/policy_test.go @@ -0,0 +1,53 @@ +package api + +import ( + "encoding/json" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/factory" +) + +// P2, P5: a policy block crosses even after waits retire; held stays a boolean. +func TestPolicyBlockRead(t *testing.T) { + now := time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC) + it := factory.Item{ID: 1, Kind: factory.KindIssue, State: factory.StateNew, Group: "g", WaitsOn: []int{2}, WaitsReleased: true, Blocked: &factory.JobBlock{By: factory.BlockPolicy, Reason: "no allowance covers it", Since: now}} + j := FromItem(it) + if j.Group != "g" || j.Blocked == nil || j.Blocked.Reason != "no allowance covers it" || j.State != "backlog" || j.NeedsYou != nil || j.Held { + t.Fatalf("read = %+v", j) + } + raw, err := json.Marshal(j) + if err != nil { + t.Fatal(err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + t.Fatal(err) + } + if raw, ok := fields["held"]; ok { + var held bool + if err := json.Unmarshal(raw, &held); err != nil || held { + t.Fatalf("held = %s, %v", raw, err) + } + } + if restored := ToItem(j); restored.Group != "g" || restored.PolicyRun != "" || restored.Blocked != nil { + t.Fatalf("client authored policy state: %+v", restored) + } +} + +// P4, P9: the end instant wins over a reached cap and an unset read has []. +func TestPolicyReadSpendingAndState(t *testing.T) { + now := time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC) + p := factory.GroupPolicy{Group: "g", Allowances: []factory.Allowance{{ID: "m2", Jobs: []int{1, 99}, CapUSD: 5, EndsAt: now.Format(time.RFC3339)}}} + read := PolicyFrom(p, []factory.Item{{ID: 1, Group: "g", SpentBefore: 2, Stream: &factory.Stream{Spent: 3}}}, now) + if read.SpentUSD != 5 || read.Allowances[0].SpentUSD != 5 || read.Allowances[0].State != "ended" { + t.Fatal(read) + } + read.Allowances[0].Jobs[0] = 100 + if p.Allowances[0].Jobs[0] != 1 { + t.Fatal("read mutated saved scope") + } + if p := PolicyFrom(factory.GroupPolicy{Group: "empty"}, nil, now); p.Allowances == nil { + t.Fatal("empty allowances are null") + } +} diff --git a/internal/factory/api/repo_issues.go b/internal/factory/api/repo_issues.go new file mode 100644 index 0000000000..4f1f5da5d9 --- /dev/null +++ b/internal/factory/api/repo_issues.go @@ -0,0 +1,54 @@ +package api + +import ( + "fmt" + "time" +) + +// Repository issue reads have the same bounds on both sides of the wire. +const ( + RepoIssuesPerPage = 50 + RepoIssuesPageMax = 10 + RepoIssueBodyMax = 8192 // The limit counts bytes, preserving whole runes. +) + +// RepoIssue is a GitHub issue, excluding pull requests. ID is GitHub's +// permanent numeric identity; Number is its number within the repository. +type RepoIssue struct { + ID int64 `json:"id"` + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + URL string `json:"url"` + State string `json:"state" enum:"open,closed"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + BodyCut bool `json:"body_cut,omitempty"` +} + +// RepoIssues counts all GitHub rows, including omitted pull requests, so a +// caller can advance its cursor even when there are no issues in the answer. +type RepoIssues struct { + Repo string `json:"repo"` + Issues []RepoIssue `json:"issues"` + Rows int `json:"rows"` + Newest *time.Time `json:"newest,omitempty"` +} + +// RepoIssuesRetry is the retry detail of a rate-limited repository read. +type RepoIssuesRetry struct { + RetryAt time.Time `json:"retry_at"` +} + +// CheckRepoIssues validates the cursor without rewriting its timestamp. +func CheckRepoIssues(since string, n int) error { + if n < 1 || n > RepoIssuesPageMax { + return fmt.Errorf("page is from 1 to %d", RepoIssuesPageMax) + } + if since != "" { + if _, err := time.Parse(time.RFC3339, since); err != nil { + return fmt.Errorf("since must be an RFC 3339 timestamp") + } + } + return nil +} diff --git a/internal/factory/api/repo_issues_test.go b/internal/factory/api/repo_issues_test.go new file mode 100644 index 0000000000..d3ae6d537f --- /dev/null +++ b/internal/factory/api/repo_issues_test.go @@ -0,0 +1,27 @@ +package api + +import ( + "encoding/json" + "strings" + "testing" +) + +// C2, C3: The issue identity remains int64 and an empty result has no cursor. +func TestRepoIssuesJSONKeepsIdentityAndEmptyShape(t *testing.T) { + var got RepoIssues + raw := `{"repo":"acme/api","issues":[{"id":9007199254740993,"number":7,"title":"Retry","body":"details","url":"https://github.com/acme/api/issues/7","state":"closed","created_at":"2026-10-01T00:00:00Z","updated_at":"2026-11-01T00:00:00Z"}],"rows":1,"newest":"2026-11-01T00:00:00Z"}` + if err := json.Unmarshal([]byte(raw), &got); err != nil { + t.Fatal(err) + } + if len(got.Issues) != 1 || got.Issues[0].ID != 9007199254740993 || got.Newest == nil || !got.Newest.Equal(got.Issues[0].UpdatedAt) { + t.Fatalf("decoded %+v", got) + } + encoded, err := json.Marshal(RepoIssues{Repo: "acme/api", Issues: []RepoIssue{}, Rows: 0}) + if err != nil || string(encoded) != `{"repo":"acme/api","issues":[],"rows":0}` { + t.Fatalf("empty result = %s, %v", encoded, err) + } + encoded, err = json.Marshal(got) + if err != nil || !strings.Contains(string(encoded), `"id":9007199254740993`) || strings.Contains(string(encoded), "body_cut") { + t.Fatalf("issue result = %s, %v", encoded, err) + } +} diff --git a/internal/factory/api/split_from_test.go b/internal/factory/api/split_from_test.go new file mode 100644 index 0000000000..38b725461d --- /dev/null +++ b/internal/factory/api/split_from_test.go @@ -0,0 +1,33 @@ +package api + +import ( + "encoding/json" + "testing" + + "github.com/Agent-Field/codeaf/internal/factory" +) + +// S2/S4: lineage survives the job codec and is absent from ordinary job JSON. +func TestSplitFromJobCodecAndOmission(t *testing.T) { + for _, parent := range []int{0, 80} { + job := FromItem(factory.Item{ID: 81, Kind: factory.KindIssue, State: factory.StateNew, SplitFrom: parent}) + raw, err := json.Marshal(job) + if err != nil { + t.Fatal(err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + t.Fatal(err) + } + if value, ok := fields["split_from"]; ok != (parent > 0) || ok && string(value) != "80" { + t.Fatalf("split_from JSON = %s (present=%v), parent=%d", value, ok, parent) + } + var decoded Job + if err := json.Unmarshal(raw, &decoded); err != nil { + t.Fatal(err) + } + if got := ToItem(decoded); got.SplitFrom != parent || got.FromJob != 0 { + t.Fatalf("split lineage after round trip: split_from=%d from_job=%d", got.SplitFrom, got.FromJob) + } + } +} diff --git a/internal/factory/api/waits.go b/internal/factory/api/waits.go new file mode 100644 index 0000000000..521d4aaef5 --- /dev/null +++ b/internal/factory/api/waits.go @@ -0,0 +1,33 @@ +package api + +import ( + "time" + + "github.com/Agent-Field/codeaf/internal/factory" +) + +// JobBlock is a reason to fix a parent; it leaves the job's state and doors alone. +type JobBlock struct { + By string `json:"by"` + Reason string `json:"reason"` + Since time.Time `json:"since"` + Job int `json:"job,omitempty"` +} + +func waitsFromItem(j *Job, it factory.Item) { + j.WaitsOn = append([]int(nil), it.WaitsOn...) + if !factory.WaitsPending(it) { + return + } + j.WaitingOn = append([]int(nil), it.WaitingOn...) + if b := it.Blocked; b != nil { + j.Blocked = &JobBlock{By: b.By, Reason: b.Reason, Since: b.Since, Job: b.Job} + } +} + +func init() { + Lost["Item.WaitsOn"] = "read-only parent ids; only the factory's waits_on door changes them, never a worker report or a client snapshot" + Lost["Item.WaitingOn"] = "read-only unsatisfied parent ids; only the factory's waits code updates them, never a worker report or a client snapshot" + Lost["Item.WaitsReleased"] = "service-owned once-only release mark; workers and client snapshots cannot restore waits" + Lost["Item.Blocked"] = "read-only scheduler decision; workers and client snapshots cannot set or clear it" +} diff --git a/internal/factory/api/waits_test.go b/internal/factory/api/waits_test.go new file mode 100644 index 0000000000..495435686c --- /dev/null +++ b/internal/factory/api/waits_test.go @@ -0,0 +1,93 @@ +package api + +import ( + "encoding/json" + "reflect" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/factory" +) + +// W11: the new fields preserve held, Backlog, Start and the needs-you contract. +func TestWaitsReadShape(t *testing.T) { + at := time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC) + it := factory.Item{ID: 3, Kind: factory.KindIssue, State: factory.StateNew, WaitsOn: []int{1, 2}, WaitingOn: []int{1, 2}} + before := CanFor(it, "", at) + for _, blocked := range []bool{false, true} { + if blocked { + it.Blocked = &factory.JobBlock{By: factory.BlockWaitsOn, Reason: "waits on job 1, which failed at Test", Since: at, Job: 1} + } + j := FromItem(it) + if j.State != StateBacklog || j.NeedsYou != nil || j.Held || !reflect.DeepEqual(CanFor(it, "", at), before) { + t.Fatalf("job doors or state changed: %+v", j) + } + raw, err := json.Marshal(j) + if err != nil { + t.Fatal(err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + t.Fatal(err) + } + if string(fields["waits_on"]) != "[1,2]" || string(fields["waiting_on"]) != "[1,2]" { + t.Fatalf("wait fields: %s", raw) + } + if _, ok := fields["blocked"]; ok != blocked { + t.Fatalf("blocked present = %v", ok) + } + if blocked { + var block map[string]json.RawMessage + if err := json.Unmarshal(fields["blocked"], &block); err != nil { + t.Fatal(err) + } + if string(block["by"]) != `"waits_on"` || string(block["job"]) != "1" || string(block["since"]) != `"2026-11-05T12:00:00Z"` || len(block["reason"]) == 0 { + t.Fatalf("block = %s", fields["blocked"]) + } + } + } + it.State, it.Stream = factory.StateQueued, &factory.Stream{Paused: true} + j := FromItem(it) + if !j.Held || len(j.WaitingOn) != 0 || j.Blocked != nil { + t.Fatalf("person's Hold = %+v", j) + } +} + +// W1, W11: reads copy the persisted waiting list and hide released or retired waits. +func TestWaitsReadPersistedFields(t *testing.T) { + it := factory.Item{ID: 4, Kind: factory.KindIssue, State: factory.StateNew, WaitsOn: []int{3, 2, 1}, WaitingOn: []int{2, 1}} + j := FromItem(it) + if !reflect.DeepEqual(j.WaitsOn, it.WaitsOn) || !reflect.DeepEqual(j.WaitingOn, it.WaitingOn) { + t.Fatalf("job = %+v", j) + } + j.WaitsOn[0], j.WaitingOn[0] = 99, 99 + if !reflect.DeepEqual(it.WaitsOn, []int{3, 2, 1}) || !reflect.DeepEqual(it.WaitingOn, []int{2, 1}) { + t.Fatal("read changed the item's waits") + } + q := FromSnapshot(factory.Snapshot{Items: []factory.Item{it}}, "") + if len(q.Jobs) != 1 || !reflect.DeepEqual(q.Jobs[0].WaitingOn, it.WaitingOn) { + t.Fatalf("snapshot = %+v", q.Jobs) + } + it.Blocked = &factory.JobBlock{By: factory.BlockWaitsOn, Reason: "waits on job 2, which failed", Job: 2} + for _, change := range []func(*factory.Item){ + func(cur *factory.Item) { cur.WaitsReleased = true }, + func(cur *factory.Item) { cur.State = factory.StateQueued }, + func(cur *factory.Item) { cur.Stream = &factory.Stream{} }, + func(cur *factory.Item) { cur.Failure = &factory.Failure{Code: factory.CodeStopped} }, + } { + cur := it + change(&cur) + j = FromItem(cur) + raw, err := json.Marshal(j) + if err != nil { + t.Fatal(err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + t.Fatal(err) + } + if j.WaitingOn != nil || j.Blocked != nil || !reflect.DeepEqual(j.WaitsOn, it.WaitsOn) || fields["waiting_on"] != nil || fields["blocked"] != nil { + t.Fatalf("inert waits = %s", raw) + } + } +} diff --git a/internal/factory/api/wire.go b/internal/factory/api/wire.go index 587a3d1ed8..ce24fff50d 100644 --- a/internal/factory/api/wire.go +++ b/internal/factory/api/wire.go @@ -394,9 +394,19 @@ type JobReadSize struct { // Job is one unit of work on the floor (code: [factory.Item]). type Job struct { + WaitsOn []int `json:"waits_on,omitempty"` + WaitingOn []int `json:"waiting_on,omitempty"` + Blocked *JobBlock `json:"blocked,omitempty"` + + // Group and Milestone are opaque labels supplied at creation. + Group string `json:"group,omitempty"` + Milestone string `json:"milestone,omitempty"` + // Context lists attachment metadata; only worker admission includes its text. + Context []JobContext `json:"context,omitempty"` // RoundCostTables advertises table support only on worker admission. RoundCostTables bool `json:"round_cost_tables,omitempty"` FromJob int `json:"from_job,omitempty"` + SplitFrom int `json:"split_from,omitempty"` SourceIssue string `json:"source_issue,omitempty"` // Following is whether the CALLER follows this job ([ForViewer]); absent diff --git a/internal/factory/api/workers.go b/internal/factory/api/workers.go index 8deb3714d8..4d01b62778 100644 --- a/internal/factory/api/workers.go +++ b/internal/factory/api/workers.go @@ -184,6 +184,9 @@ func stageResultItem(r StepResult) factory.StageResult { // only the lease holder may read (serve's withholdChats). func WorkerJob(it factory.Item) Job { j := FromItem(it) + for i := range j.Context { + j.Context[i].Markdown = it.Context[i].Markdown + } j.RoundCostTables = true // The runner runs the file the job carries, so it is sent whole here. j.PipelineCopy = it.PipelineCopy.Clone() diff --git a/internal/factory/github/poll.go b/internal/factory/github/poll.go index f2a2c98da0..1f8e50465f 100644 --- a/internal/factory/github/poll.go +++ b/internal/factory/github/poll.go @@ -199,6 +199,12 @@ func TroubleWords(err error) string { return "token refused" case http.StatusNotFound: return "repository not found" + case http.StatusTooManyRequests: + return "rate limited" + case http.StatusForbidden: + if apiErr.RetryAfter > 0 { + return "rate limited" + } } } return "not reachable" diff --git a/internal/factory/github/poll_limit_test.go b/internal/factory/github/poll_limit_test.go new file mode 100644 index 0000000000..445c7fe092 --- /dev/null +++ b/internal/factory/github/poll_limit_test.go @@ -0,0 +1,36 @@ +package github + +import ( + "fmt" + "testing" + "time" + + forge "github.com/Agent-Field/codeaf/internal/praf/github" +) + +// B5: Spent limits have their own facts words, including wrapped errors; +// ordinary refusals and the existing 401/404 words remain unchanged. +func TestTroubleWordsNameASpentRateLimit(t *testing.T) { + for _, tc := range []struct { + status int + retry time.Duration + want string + }{ + {403, time.Minute, "rate limited"}, + {429, time.Minute, "rate limited"}, + {403, 0, "not reachable"}, + {429, 0, "rate limited"}, + {500, time.Minute, "not reachable"}, + {401, 0, "token refused"}, + {401, time.Minute, "token refused"}, + {404, 0, "repository not found"}, + {404, time.Minute, "repository not found"}, + } { + t.Run(fmt.Sprintf("%d/%s", tc.status, tc.retry), func(t *testing.T) { + err := fmt.Errorf("repository read: %w", &forge.APIError{StatusCode: tc.status, RetryAfter: tc.retry}) + if got := TroubleWords(err); got != tc.want { + t.Fatalf("trouble words = %q, want %q", got, tc.want) + } + }) + } +} diff --git a/internal/factory/intake/intake.go b/internal/factory/intake/intake.go index 12f7d59b8c..d871287925 100644 --- a/internal/factory/intake/intake.go +++ b/internal/factory/intake/intake.go @@ -98,7 +98,7 @@ func (in *Intake) Once(ctx context.Context) error { if err := ctx.Err(); err != nil { return err } - if factory.StoppedByPerson(it) || factory.JobState(it) == "done" || !factory.IsIssueJob(it) || it.SetupHeld || it.State != factory.StateNew || len(it.Labels) == 0 || it.AskedFirstReason != "" || sw.OffJob(it) { + if factory.StoppedByPerson(it) || factory.JobState(it) == "done" || !factory.IsIssueJob(it) || it.SetupHeld || it.State != factory.StateNew || len(it.Labels) == 0 || it.AskedFirstReason != "" || sw.OffJob(it) || factory.WaitsPending(it) || it.Group != "" { continue } if err := in.decide(ctx, it, book); err != nil { @@ -231,6 +231,9 @@ func (in *Intake) lookFirst(ctx context.Context, it factory.Item) (factory.Item, // after Once listed the backlog still wins. Only a person's Run door may // restart a job they stopped; labels never restore that permission. func (in *Intake) canStart(ctx context.Context, it factory.Item) error { + if factory.WaitsPending(it) || it.Group != "" { + return errMoved + } if factory.StoppedByPerson(it) || it.SetupHeld || factory.JobState(it) == "done" { return errMoved } diff --git a/internal/factory/intake/policy_fix1_test.go b/internal/factory/intake/policy_fix1_test.go new file mode 100644 index 0000000000..49f09e2987 --- /dev/null +++ b/internal/factory/intake/policy_fix1_test.go @@ -0,0 +1,101 @@ +package intake_test + +import ( + "context" + "fmt" + "reflect" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/factory" + "github.com/Agent-Field/codeaf/internal/factory/intake" + "github.com/Agent-Field/codeaf/internal/factory/pipeline" + "github.com/Agent-Field/codeaf/internal/factory/record" + "github.com/Agent-Field/codeaf/internal/factory/store" +) + +// P2: intake neither starts nor asks about grouped releases without an allowance. +// The listing guard also avoids loading rules or scouting those jobs. +func TestIntakePolicyGroupGuards(t *testing.T) { + for _, duringLoad := range []bool{false, true} { + for _, ask := range []bool{false, true} { + t.Run(fmt.Sprintf("during_load=%t/ask=%t", duringLoad, ask), func(t *testing.T) { + st, err := store.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + group := "g" + if duringLoad { + group = "" + } + job, err := st.Create(factory.Item{Repo: "owner/api", Kind: factory.KindIssue, State: factory.StateNew, Tier: factory.TierOwner, Group: group, WaitsOn: []int{99}, WaitsReleased: true, Labels: []string{"agent-ready", "size:S"}}) + if err != nil { + t.Fatal(err) + } + history, err := record.Open(st, record.Options{}) + if err != nil { + t.Fatal(err) + } + rules := "default_budget: 1\nstart:\n labels: [agent-ready]\n" + if ask { + rules += " ask_first: [changes secrets]\n" + } + file, err := pipeline.Parse(rules) + if err != nil { + t.Fatal(err) + } + loaded, resume := make(chan struct{}), make(chan struct{}) + loads, scouts, launches := 0, 0, 0 + in := intake.New(st, intake.Options{Record: history, Load: func(string) (pipeline.File, error) { + loads++ + if duringLoad { + close(loaded) + <-resume + } + return file, nil + }, Scout: func(context.Context, int) error { scouts++; return nil }, Launch: func(int) error { launches++; return nil }}) + before, err := st.Get(job.ID) + if err != nil { + t.Fatal(err) + } + done := make(chan error, 1) + go func() { done <- in.Once(context.Background()) }() + if duringLoad { + select { + case <-loaded: + case err := <-done: + t.Fatalf("intake returned before Load: %v", err) + case <-time.After(5 * time.Second): + t.Fatal("intake did not reach Load") + } + err := st.Update(job.ID, func(it *factory.Item) error { it.Group = "g"; return nil }) + if err == nil { + before, err = st.Get(job.ID) + } + close(resume) + if err != nil { + <-done + t.Fatal(err) + } + } + if err := <-done; err != nil { + t.Fatal(err) + } + after, err := st.Get(job.ID) + if err != nil { + t.Fatal(err) + } + events, err := history.Since(context.Background(), time.Time{}) + if err != nil { + t.Fatal(err) + } + if launches != 0 || after.State != factory.StateNew || after.PolicyRun != "" || after.QKind != "" || after.AskedFirstReason != "" || !reflect.DeepEqual(before, after) || len(events) != 0 { + t.Fatalf("intake admitted grouped job: launches=%d job=%+v events=%+v", launches, after, events) + } + if !duringLoad && (loads != 0 || scouts != 0) { + t.Fatalf("grouped job read rules %d times and scouted %d times", loads, scouts) + } + }) + } + } +} diff --git a/internal/factory/intake/waits_fix2_test.go b/internal/factory/intake/waits_fix2_test.go new file mode 100644 index 0000000000..e9567c9ce0 --- /dev/null +++ b/internal/factory/intake/waits_fix2_test.go @@ -0,0 +1,97 @@ +package intake_test + +import ( + "context" + "fmt" + "net/http/httptest" + "reflect" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/factory" + "github.com/Agent-Field/codeaf/internal/factory/api" + "github.com/Agent-Field/codeaf/internal/factory/intake" + "github.com/Agent-Field/codeaf/internal/factory/pipeline" + "github.com/Agent-Field/codeaf/internal/factory/record" + "github.com/Agent-Field/codeaf/internal/factory/serve" + "github.com/Agent-Field/codeaf/internal/factory/store" +) + +// W8: waits set after the list still refuse automatic start and ask-first admission. +func TestIntakeWaitsSetDuringDecision(t *testing.T) { + for _, ask := range []bool{false, true} { + t.Run(fmt.Sprintf("ask=%v", ask), func(t *testing.T) { + st, err := store.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if err := st.SetRepos([]string{"owner/api"}); err != nil { + t.Fatal(err) + } + parent, err := st.Create(factory.Item{Repo: "owner/api", Kind: factory.KindIssue, State: factory.StateNew}) + if err != nil { + t.Fatal(err) + } + child, err := st.Create(factory.Item{Repo: "owner/api", Kind: factory.KindIssue, State: factory.StateNew, Tier: factory.TierOwner, Labels: []string{"agent-ready", "size:S"}}) + if err != nil { + t.Fatal(err) + } + history, err := record.Open(st, record.Options{}) + if err != nil { + t.Fatal(err) + } + policy := "default_budget: 1\nstart:\n labels: [agent-ready]\n" + if ask { + policy += " ask_first: [changes secrets]\n" + } + file, err := pipeline.Parse(policy) + if err != nil { + t.Fatal(err) + } + h := serve.New(factory.Seam{Load: func() (factory.Snapshot, error) { + items, err := st.List() + return factory.Snapshot{Items: items}, err + }}, serve.Config{Mode: api.FactorySolo, AccessToken: "test-secret", Store: st, Record: history}) + t.Cleanup(h.Close) + loaded, resume := make(chan struct{}), make(chan struct{}) + launched := 0 + in := intake.New(st, intake.Options{Record: history, Load: func(string) (pipeline.File, error) { + close(loaded) + <-resume + return file, nil + }, Launch: func(int) error { launched++; return nil }}) + done := make(chan error, 1) + finished := make(chan struct{}) + defer func() { close(resume); <-finished }() + go func() { defer close(finished); done <- in.Once(context.Background()) }() + <-loaded + r := httptest.NewRequest("PUT", fmt.Sprintf("/v1/jobs/%d/waits_on", child.ID), strings.NewReader(fmt.Sprintf(`{"waits_on":[%d]}`, parent.ID))) + r.Header.Set("Authorization", "Bearer test-secret") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + if w.Code != 200 { + t.Fatalf("set waits = %d %s", w.Code, w.Body) + } + before, err := st.Get(child.ID) + if err != nil { + t.Fatal(err) + } + resume <- struct{}{} + if err := <-done; err != nil { + t.Fatal(err) + } + after, err := st.Get(child.ID) + if err != nil { + t.Fatal(err) + } + events, err := history.Since(context.Background(), time.Time{}) + if err != nil { + t.Fatal(err) + } + if launched != 0 || after.State != factory.StateNew || after.QKind != "" || after.AskedFirstReason != "" || !reflect.DeepEqual(after, before) || len(events) != 0 { + t.Fatalf("pending waits admitted: launches=%d job=%+v events=%+v", launched, after, events) + } + }) + } +} diff --git a/internal/factory/intake/waits_test.go b/internal/factory/intake/waits_test.go new file mode 100644 index 0000000000..b20c334706 --- /dev/null +++ b/internal/factory/intake/waits_test.go @@ -0,0 +1,67 @@ +package intake_test + +import ( + "context" + "reflect" + "testing" + + "github.com/Agent-Field/codeaf/internal/factory" + "github.com/Agent-Field/codeaf/internal/factory/intake" + "github.com/Agent-Field/codeaf/internal/factory/pipeline" + "github.com/Agent-Field/codeaf/internal/factory/record" + "github.com/Agent-Field/codeaf/internal/factory/store" +) + +// W8: a labelled issue with pending waits never reaches intake's Launch. +func TestIntakeSkipsPendingWaits(t *testing.T) { + st, err := store.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + if err := st.SetRepos([]string{"owner/api"}); err != nil { + t.Fatal(err) + } + parent, err := st.Create(factory.Item{Repo: "owner/api", Num: 1, Kind: factory.KindIssue, State: factory.StateNew}) + if err != nil { + t.Fatal(err) + } + child, err := st.Create(factory.Item{Repo: "owner/api", Num: 2, Kind: factory.KindIssue, State: factory.StateNew, Tier: factory.TierOwner, Title: "Wait for the parent", Labels: []string{"agent-ready", "size:S"}, WaitsOn: []int{parent.ID}}) + if err != nil { + t.Fatal(err) + } + child, err = st.Get(child.ID) + if err != nil { + t.Fatal(err) + } + history, err := record.Open(st, record.Options{}) + if err != nil { + t.Fatal(err) + } + f, err := pipeline.Parse("default_budget: 1\nstart:\n labels: [agent-ready]\n") + if err != nil { + t.Fatal(err) + } + launched := 0 + in := intake.New(st, intake.Options{Record: history, Load: func(string) (pipeline.File, error) { return f, nil }, Launch: func(id int) error { launched++; return nil }}) + for range 2 { + if err := in.Once(context.Background()); err != nil { + t.Fatal(err) + } + } + after, err := st.Get(child.ID) + if err != nil { + t.Fatal(err) + } + if launched != 0 || !reflect.DeepEqual(after, child) { + t.Fatalf("pending job changed: launches = %d, job = %+v", launched, after) + } + if err := st.Update(child.ID, func(it *factory.Item) error { it.WaitsReleased = true; return nil }); err != nil { + t.Fatal(err) + } + if err := in.Once(context.Background()); err != nil { + t.Fatal(err) + } + if launched != 1 { + t.Fatalf("released job launches = %d", launched) + } +} diff --git a/internal/factory/job_context.go b/internal/factory/job_context.go new file mode 100644 index 0000000000..b5811f85a9 --- /dev/null +++ b/internal/factory/job_context.go @@ -0,0 +1,17 @@ +package factory + +// Job metadata bounds are shared by the wire and the Factory HTTP handlers. +const ( + MaxJobLabel = 200 + MaxJobContextItems = 20 + MaxJobContext = 100000 + MaxJobContextTitle = 200 + MaxJobContextSource = 500 +) + +// JobContext is reference material attached before a job's first run. +type JobContext struct { + Title string `json:"title"` + Markdown string `json:"markdown"` + Source string `json:"source,omitempty"` +} diff --git a/internal/factory/lease/lease.go b/internal/factory/lease/lease.go index 1e212e533c..2d015c56a0 100644 --- a/internal/factory/lease/lease.go +++ b/internal/factory/lease/lease.go @@ -111,6 +111,8 @@ type Desk struct { record *record.Record // persist is the atomic lease-file writer; nil uses writeAtomic. persist func(string, []byte) error + + policySpend policySpendCache } // Open holds no handles. Reading is deferred to each operation so a desk @@ -268,6 +270,10 @@ func (d *Desk) reserve(doc *document, worker Worker, taken *Lease) error { if !eligible(*it, worker) { return ErrNoJob } + if d.holdAtPolicy(it, d.now()) { + blocked = true + return nil + } if holdAtJobBudget(it, d.now()) { blocked = true return nil diff --git a/internal/factory/lease/policy_fix1_test.go b/internal/factory/lease/policy_fix1_test.go new file mode 100644 index 0000000000..5691a264cf --- /dev/null +++ b/internal/factory/lease/policy_fix1_test.go @@ -0,0 +1,192 @@ +package lease + +import ( + "errors" + "os" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/factory" + "github.com/Agent-Field/codeaf/internal/factory/store" +) + +func policyReportFixture(t *testing.T, repo string) (*store.Store, *Desk, Lease, *time.Time) { + t.Helper() + st, err := store.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + now := time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC) + job, err := st.Create(factory.Item{Repo: repo, Kind: factory.KindIssue, State: factory.StateQueued, Assignee: "owner", Group: "g", PolicyRun: "m2", Cap: 20, Stream: &factory.Stream{Phases: []factory.Phase{{Name: "test", State: factory.PhaseRunning, Round: 1}}}}) + if err != nil { + t.Fatal(err) + } + if _, err := st.ChangePolicy("g", func(p *factory.GroupPolicy) error { + p.Allowances = []factory.Allowance{{ID: "m2", Jobs: []int{job.ID, 2}, CapUSD: 10, EndsAt: now.Add(time.Hour).Format(time.RFC3339)}} + return nil + }); err != nil { + t.Fatal(err) + } + desk, err := Open(st, Options{Now: func() time.Time { return now }}) + if err != nil { + t.Fatal(err) + } + held, err := desk.Pull(Worker{ID: "w", Handle: "owner"}) + if err != nil { + t.Fatal(err) + } + return st, desk, held, &now +} + +// P5: resolving an alias after leasing must not make a report lock itself. +func TestPolicyReportDoesNotMigrateRepository(t *testing.T) { + st, desk, held, now := policyReportFixture(t, "api") + if err := st.SetRepos([]string{"owner/api"}); err != nil { + t.Fatal(err) + } + *now = now.Add(2 * time.Second) + done := make(chan error, 1) + go func() { + done <- desk.Apply(held.ID, "w", false, false, func(it *factory.Item) error { it.Stream.Spent = 1; return nil }) + }() + select { + case err := <-done: + if err != nil { + t.Fatal(err) + } + case <-time.After(5 * time.Second): + t.Fatal("report deadlocked after repository alias became resolvable") + } + it, err := st.ReadOnly(held.Job) + if err != nil || it.Repo != "owner/api" || it.Stream.Spent != 1 { + t.Fatalf("report did not persist normally: %+v, %v", it, err) + } +} + +// P5: only the allowance's other documents are read, once per two seconds. +// An unrelated floor of 1,000 jobs must leave that count unchanged. +func TestPolicyReportDocumentWork(t *testing.T) { + st, desk, held, now := policyReportFixture(t, "owner/api") + reads := 0 + desk.policySpend.read = func(id int) (factory.Item, error) { + reads++ + return st.ReadOnly(id) + } + report := func(want int) { + t.Helper() + reads = 0 + if err := desk.Apply(held.ID, "w", false, false, func(*factory.Item) error { return nil }); err != nil { + t.Fatal(err) + } + if reads != want { + t.Fatalf("read %d documents, want %d", reads, want) + } + } + *now = now.Add(2 * time.Second) + report(1) + for id := 3; id < 1003; id++ { + if err := st.Save(factory.Item{ID: id, Repo: "owner/api", Kind: factory.KindIssue, State: factory.StateNew}); err != nil { + t.Fatal(err) + } + } + *now = now.Add(2 * time.Second) + report(1) + *now = now.Add(2*time.Second - time.Nanosecond) + report(0) + *now = now.Add(time.Nanosecond) + report(1) + if _, err := st.ChangePolicy("g", func(p *factory.GroupPolicy) error { + p.Allowances[0].Jobs = append(p.Allowances[0].Jobs, 3) + return nil + }); err != nil { + t.Fatal(err) + } + report(2) + if _, err := st.ChangePolicy("g", func(p *factory.GroupPolicy) error { p.Stopped = true; return nil }); err != nil { + t.Fatal(err) + } + *now = now.Add(2 * time.Second) + report(0) + it, err := st.ReadOnly(held.Job) + if err != nil || !it.StopAfterStep { + t.Fatalf("stopped report did not arm boundary: %+v, %v", it, err) + } +} + +// P5, P9: unreadable listed jobs count as zero; the locked spend is current +// even when the allowance snapshot was populated before the latest report. +func TestPolicyReportCurrentSpendAndUnreadableJob(t *testing.T) { + st, desk, held, now := policyReportFixture(t, "owner/api") + if err := os.WriteFile(st.ItemPath(2), []byte("{"), 0600); err != nil { + t.Fatal(err) + } + *now = now.Add(2 * time.Second) + if err := desk.Apply(held.ID, "w", false, false, func(it *factory.Item) error { it.Stream.Spent = 9; return nil }); err != nil { + t.Fatal(err) + } + if err := desk.Apply(held.ID, "w", false, false, func(it *factory.Item) error { it.Stream.Spent = 10; return nil }); !errors.Is(err, ErrWithdrawn) { + t.Fatalf("cached own spend escaped cap: %v", err) + } +} + +// P5, P9: the lease gate and policy read agree at a fractional-dollar cap. +func TestPolicyReportDecimalCap(t *testing.T) { + st, desk, held, _ := policyReportFixture(t, "owner/api") + if err := st.Save(factory.Item{ID: 2, Repo: "owner/api", ChatSpent: 0.10}); err != nil { + t.Fatal(err) + } + if _, err := st.ChangePolicy("g", func(p *factory.GroupPolicy) error { + p.Allowances[0].CapUSD = 0.80 + p.Allowances[0].ID = "decimal" + return nil + }); err != nil { + t.Fatal(err) + } + err := desk.Apply(held.ID, "w", false, false, func(it *factory.Item) error { + it.PolicyRun, it.Stream.Spent = "decimal", 0.70 + return nil + }) + if !errors.Is(err, ErrWithdrawn) { + t.Fatalf("exact decimal cap = %v", err) + } + it, err := st.ReadOnly(held.Job) + if err != nil || it.Question != "allowance decimal reached its $0.80 cap" || it.Stream.Spent != 0.70 { + t.Fatalf("cap hold = %+v, %v", it, err) + } +} + +// P7: a running report for the same saved step is progress, whereas a new +// phase or round is admission even before the previous phase reports done. +func TestPolicyStopAdmitsOnlySavedSteps(t *testing.T) { + for _, kind := range []string{"progress", "phase", "round"} { + t.Run(kind, func(t *testing.T) { + st, desk, held, now := policyReportFixture(t, "owner/api") + if _, err := st.ChangePolicy("g", func(p *factory.GroupPolicy) error { p.Stopped = true; return nil }); err != nil { + t.Fatal(err) + } + err := desk.Apply(held.ID, "w", false, false, func(it *factory.Item) error { + it.Stream.Spent = 0.25 + it.Stream.Phases[0].Started = now.Add(-time.Hour) + switch kind { + case "phase": + it.Stream.Phases = append(it.Stream.Phases, factory.Phase{Name: "next", State: factory.PhaseRunning, Round: 1}) + it.Stream.Cur = 1 + case "round": + it.Stream.Phases[0].Round++ + } + return nil + }) + it, readErr := st.ReadOnly(held.Job) + if readErr != nil || factory.JobLifetimeSpent(it) != 0.25 { + t.Fatalf("report lost spend: %+v, %v", it, readErr) + } + if kind == "progress" { + if err != nil || !it.StopAfterStep || it.State != factory.StateRunning { + t.Fatalf("admitted step was cut: %+v, %v", it, err) + } + } else if !errors.Is(err, ErrWithdrawn) || it.Blocked == nil || it.Blocked.Reason != "its group is stopped" { + t.Fatalf("new admission escaped stop: %+v, %v", it, err) + } + }) + } +} diff --git a/internal/factory/lease/policy_gate.go b/internal/factory/lease/policy_gate.go new file mode 100644 index 0000000000..9df2f81ecb --- /dev/null +++ b/internal/factory/lease/policy_gate.go @@ -0,0 +1,57 @@ +package lease + +import ( + "context" + "time" + + "github.com/Agent-Field/codeaf/internal/factory" + "github.com/Agent-Field/codeaf/internal/factory/record" +) + +// holdAtPolicy reads settings and a bounded, non-migrating spend snapshot +// while this job's lock is held. A lease has no admitted report steps. +func (d *Desk) holdAtPolicy(it *factory.Item, now time.Time, admitted ...map[policyStep]bool) bool { + if it.Group == "" { + return false + } + p, err := d.store.Policy(it.Group) + reason := "" + switch { + case err != nil: + reason = "its group's policy cannot be read" + case p.Version == 0: + return false + case p.Stopped: + if len(admitted) != 0 && !policyNewStep(*it, admitted[0]) { + it.StopAfterStep = true + return false + } + reason = "its group is stopped" + case it.Cap > 0 && factory.JobLifetimeSpent(*it) >= it.Cap && !approvedWriteOnly(*it): + // The existing job-budget gate retains its question when both caps apply. + return false + case it.PolicyRun == "": + return false + default: + var ok bool + reason, ok = factory.ContinueDecision(p, *it, d.policySpentOf(p, *it, now), now) + if ok { + return false + } + } + if it.Blocked == nil || it.Blocked.By != factory.BlockPolicy || it.Blocked.Reason != reason { + it.Blocked = &factory.JobBlock{By: factory.BlockPolicy, Reason: reason, Since: now.UTC()} + it.Note(now, "held: "+reason) + if d.record != nil { + _, _ = d.record.Append(context.Background(), record.Event{Kind: record.Held, Job: it.ID, Actor: record.Actor{Kind: "factory"}, Rule: factory.BlockPolicy, Detail: reason, Cost: factory.JobLifetimeSpent(*it)}) + } + } + ask := factory.Asked{With: factory.AskedYou, At: now, Question: reason} + if it.QKind == "cap" && it.Asking != nil { + ask = *it.Asking + ask.Question = reason + } + it.State, it.QKind, it.Question, it.Asking = factory.StateNeedsYou, "cap", reason, &ask + it.PolicyRun, it.OverLimit, it.StopAfterStep = "", false, false + return true +} diff --git a/internal/factory/lease/policy_gate_test.go b/internal/factory/lease/policy_gate_test.go new file mode 100644 index 0000000000..88b676e1fa --- /dev/null +++ b/internal/factory/lease/policy_gate_test.go @@ -0,0 +1,141 @@ +package lease_test + +import ( + "errors" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/factory" + "github.com/Agent-Field/codeaf/internal/factory/api" + "github.com/Agent-Field/codeaf/internal/factory/lease" +) + +func leasePolicy(t *testing.T, f *fixture, job int, cap float64, end time.Time, stopped bool) { + t.Helper() + _, err := f.store.ChangePolicy("g", func(p *factory.GroupPolicy) error { + p.Stopped = stopped + p.Allowances = []factory.Allowance{{ID: "m2", Jobs: []int{job}, CapUSD: cap, EndsAt: end.Format(time.RFC3339)}} + return nil + }) + if err != nil { + t.Fatal(err) + } +} + +// P5, P6: reports and step admission withdraw execution without losing work. +func TestPolicyGateWithdraws(t *testing.T) { + for _, ended := range []bool{false, true} { + t.Run(map[bool]string{false: "cap", true: "end"}[ended], func(t *testing.T) { + f := setup(t) + job := f.add(t, factory.Item{Group: "g", PolicyRun: "m2", Assignee: "owner", Cap: 20, Stream: &factory.Stream{Cur: 0, Phases: []factory.Phase{{Name: "test", State: factory.PhaseRunning}}}}) + leasePolicy(t, f, job.ID, 5, f.now.Add(10*time.Second), false) + held, err := f.desk.Pull(worker("w", "owner")) + if err != nil { + t.Fatal(err) + } + if ended { + f.now = f.now.Add(10 * time.Second) + } + err = f.desk.Apply(held.ID, "w", false, false, func(it *factory.Item) error { it.Stream.Spent = 5; return nil }) + if !errors.Is(err, lease.ErrWithdrawn) { + t.Fatalf("report = %v", err) + } + it, err := f.store.Get(job.ID) + if err != nil || it.State != factory.StateNeedsYou || it.QKind != "cap" || it.Asking == nil || it.Blocked == nil || it.Blocked.By != "policy" || it.PolicyRun != "" || factory.JobLifetimeSpent(it) != 5 { + t.Fatalf("hold = %+v, %v", it, err) + } + if ended && it.Question != "allowance m2 ended Oct 11" || !ended && it.Question != "allowance m2 reached its $5.00 cap" { + t.Fatalf("question = %q", it.Question) + } + j := api.FromItem(it) + if j.State != "needs_you" || j.NeedsYou == nil || j.Blocked == nil || j.Held { + t.Fatalf("read = %+v", j) + } + before := len(it.Activity) + if err := f.desk.Apply(held.ID, "w", false, false, func(*factory.Item) error { t.Fatal("withdrawn report applied"); return nil }); !errors.Is(err, lease.ErrWithdrawn) { + t.Fatal(err) + } + it, _ = f.store.Get(job.ID) + if len(it.Activity) != before { + t.Fatal("hold recorded twice") + } + if live, err := f.desk.Live(); err != nil || len(live) != 0 { + t.Fatalf("leases = %+v, %v", live, err) + } + }) + } +} + +// P7, P8, P13, P14: policy and lifetime budget gates compose at lease admission. +func TestPolicyGateLease(t *testing.T) { + for _, tc := range []struct { + name string + policyRun string + stopped, unreadable, ownCap, noPolicy, ended bool + hold bool + }{ + {name: "person spent allowance"}, + {name: "person ended allowance", ended: true}, + {name: "policy spent allowance", policyRun: "m2", hold: true}, + {name: "person stopped group", stopped: true, hold: true}, + {name: "policy unreadable", policyRun: "m2", unreadable: true, hold: true}, + {name: "person unreadable", unreadable: true, hold: true}, + {name: "own budget", policyRun: "m2", ownCap: true, hold: true}, + {name: "no policy", policyRun: "m2", noPolicy: true}, + } { + t.Run(tc.name, func(t *testing.T) { + f := setup(t) + job := f.add(t, factory.Item{Group: "g", PolicyRun: tc.policyRun, Assignee: "owner", Cap: 20, ChatSpent: 5}) + if !tc.noPolicy { + cap := 5.0 + if tc.ownCap { + cap = 100 + } + end := f.now.Add(time.Hour) + if tc.ended { + end = f.now + } + leasePolicy(t, f, job.ID, cap, end, tc.stopped) + } + if tc.ownCap { + if err := f.store.Update(job.ID, func(it *factory.Item) error { it.Cap = 5; return nil }); err != nil { + t.Fatal(err) + } + } + if tc.unreadable { + if err := os.WriteFile(filepath.Join(f.store.Root(), "factory-policies.json"), []byte("{"), 0600); err != nil { + t.Fatal(err) + } + } + _, err := f.desk.Pull(worker("w", "owner")) + it, readErr := f.store.Get(job.ID) + if readErr != nil { + t.Fatal(readErr) + } + if !tc.hold { + if err != nil || it.State != factory.StateRunning { + t.Fatalf("person run = %+v, %v", it, err) + } + return + } + if !errors.Is(err, lease.ErrNoJob) || it.State != factory.StateNeedsYou || it.QKind != "cap" { + t.Fatalf("hold = %+v, %v", it, err) + } + if tc.ownCap { + if it.Blocked != nil || !strings.Contains(it.Question, "job total") { + t.Fatalf("budget changed = %+v", it) + } + return + } + if it.Blocked == nil || it.Blocked.By != "policy" { + t.Fatalf("missing policy block = %+v", it) + } + if tc.stopped && it.Question != "its group is stopped" || tc.unreadable && it.Question != "its group's policy cannot be read" { + t.Fatal(it.Question) + } + }) + } +} diff --git a/internal/factory/lease/policy_spend.go b/internal/factory/lease/policy_spend.go new file mode 100644 index 0000000000..ad503b3c4d --- /dev/null +++ b/internal/factory/lease/policy_spend.go @@ -0,0 +1,84 @@ +package lease + +import ( + "slices" + "strconv" + "strings" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/factory" + "github.com/Agent-Field/codeaf/internal/factory/store" +) + +const ( + policySpendTTL = 2 * time.Second + policySpendMost = 64 +) + +type policySpendKey struct { + group, allowance, jobs string +} + +type policySpendSnapshot struct { + at time.Time + spent map[int]float64 +} + +type policySpendCache struct { + mu sync.Mutex + entries map[policySpendKey]policySpendSnapshot + // read defaults to the store's non-migrating document reader. + read func(int) (factory.Item, error) +} + +// policySpentOf reads only the run's own allowance, never the whole floor. +// The locked job's current spend replaces its cached value on every report. +func (d *Desk) policySpentOf(p factory.GroupPolicy, it factory.Item, now time.Time) func(int) float64 { + var spent map[int]float64 + for _, a := range p.Allowances { + if a.ID == it.PolicyRun && slices.Contains(a.Jobs, it.ID) { + spent = d.policySpend.snapshot(d.store, p.Group, a, it, now) + break + } + } + return func(id int) float64 { + if id == it.ID { + return factory.JobLifetimeSpent(it) + } + return spent[id] + } +} + +func (c *policySpendCache) snapshot(st *store.Store, group string, a factory.Allowance, current factory.Item, now time.Time) map[int]float64 { + var ids strings.Builder + for _, id := range a.Jobs { + ids.WriteString(strconv.Itoa(id)) + ids.WriteByte(',') + } + key := policySpendKey{group: group, allowance: a.ID, jobs: ids.String()} + c.mu.Lock() + defer c.mu.Unlock() + if saved, ok := c.entries[key]; ok && !now.Before(saved.at) && now.Before(saved.at.Add(policySpendTTL)) { + return saved.spent + } + read := c.read + if read == nil { + read = st.ReadOnly + } + spent := make(map[int]float64, len(a.Jobs)) + for _, id := range a.Jobs { + if id == current.ID { + spent[id] = factory.JobLifetimeSpent(current) + } else if job, err := read(id); err == nil { + spent[id] = factory.JobLifetimeSpent(job) + } + // Missing or unreadable listed jobs contribute zero. + } + // Bound memory and eviction work when many groups report on one desk. + if c.entries == nil || len(c.entries) >= policySpendMost { + c.entries = make(map[policySpendKey]policySpendSnapshot) + } + c.entries[key] = policySpendSnapshot{at: now, spent: spent} + return spent +} diff --git a/internal/factory/lease/policy_step.go b/internal/factory/lease/policy_step.go new file mode 100644 index 0000000000..e0ff863f80 --- /dev/null +++ b/internal/factory/lease/policy_step.go @@ -0,0 +1,39 @@ +package lease + +import "github.com/Agent-Field/codeaf/internal/factory" + +type policyStep struct { + index int + name string + round int +} + +// policyAdmittedSteps copies the service's saved phase identities before a +// report can change them. Worker timestamps never grant admission. +func policyAdmittedSteps(it factory.Item) map[policyStep]bool { + var steps map[policyStep]bool + if it.Group != "" && it.Stream != nil { + for i, ph := range it.Stream.Phases { + if ph.State == factory.PhaseRunning { + if steps == nil { + steps = make(map[policyStep]bool) + } + steps[policyStep{index: i, name: ph.Name, round: ph.Round}] = true + } + } + } + return steps +} + +// policyNewStep recognizes a phase or round the service has not admitted yet. +// Cost, progress and completion reports of an admitted step may finish it. +func policyNewStep(it factory.Item, admitted map[policyStep]bool) bool { + if it.Stream != nil { + for i, ph := range it.Stream.Phases { + if ph.State == factory.PhaseRunning && !admitted[policyStep{index: i, name: ph.Name, round: ph.Round}] { + return true + } + } + } + return false +} diff --git a/internal/factory/lease/report.go b/internal/factory/lease/report.go index 05fa58be55..8a6cebc399 100644 --- a/internal/factory/lease/report.go +++ b/internal/factory/lease/report.go @@ -82,12 +82,16 @@ func (d *Desk) Apply(id, worker string, complete, capWait bool, change func(*fac return false, err } if err := d.store.Update(held.Job, func(it *factory.Item) error { + admitted := policyAdmittedSteps(*it) if err := change(it); err != nil { return err } if !complete && holdAtJobBudget(it, now) { blocked = !capWait } + if !complete && d.holdAtPolicy(it, now, admitted) { + blocked = true + } if complete && !held.ThreadOnly { it.Publishing = false if it.State == factory.StateShipped && it.PullRequest == nil && it.CommentURL != "" { diff --git a/internal/factory/live/live.go b/internal/factory/live/live.go index 90748a6a34..fec696dfbc 100644 --- a/internal/factory/live/live.go +++ b/internal/factory/live/live.go @@ -32,8 +32,9 @@ type Event struct { } type document struct { - Next uint64 `json:"next"` - Events []Event `json:"events"` + Next uint64 `json:"next"` + Events []Event `json:"events"` + OutcomesThrough uint64 `json:"outcomes_through,omitempty"` } // Append syncs and atomically replaces the bounded record under a shared flock. @@ -52,7 +53,11 @@ func Commit(root string, events []Event, write func() error) error { return locked(root, true, func(d *document) error { before := *d before.Events = append([]Event(nil), d.Events...) - for _, e := range events { + outcomes, notifications, err := prepareOutcomes(root, d, events) + if err != nil { + return err + } + for _, e := range notifications { d.Next++ e.ID = "live_" + strconv.FormatUint(d.Next, 10) d.Events = append(d.Events, e) @@ -60,11 +65,15 @@ func Commit(root string, events []Event, write func() error) error { if len(d.Events) > Limit { d.Events = d.Events[len(d.Events)-Limit:] } - if err := save(root, d); err != nil { + d.OutcomesThrough = d.Next + if err := outcomes.save(root); err != nil { return err } + if err := save(root, d); err != nil { + return errors.Join(err, outcomes.restore(root)) + } if err := write(); err != nil { - return errors.Join(err, save(root, &before)) + return errors.Join(err, save(root, &before), outcomes.restore(root)) } return nil }) @@ -185,6 +194,9 @@ func ForgetJobs(root string, ids map[int]bool) error { } } d.Events = kept + if err := forgetOutcomes(root, ids); err != nil { + return err + } return save(root, d) }) } diff --git a/internal/factory/live/outcomes.go b/internal/factory/live/outcomes.go new file mode 100644 index 0000000000..ca91e2cdd1 --- /dev/null +++ b/internal/factory/live/outcomes.go @@ -0,0 +1,337 @@ +package live + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "time" + "unicode/utf8" + + "github.com/Agent-Field/codeaf/internal/factory" +) + +// OutcomeKind routes durable job outcomes separately from live notifications. +const OutcomeKind = "job.outcome" + +// OutcomeFile is the outcome record under the factory root. +const OutcomeFile = "job-outcomes.json" + +// OutcomeLimit bounds the number of retained outcomes. +const OutcomeLimit = 2000 + +// Outcome is one finished, merged, failed or needs-you transition of a job. +type Outcome struct { + Seq int64 `json:"seq"` + Kind string `json:"kind"` + Job int `json:"job"` + At time.Time `json:"at"` + Title string `json:"title,omitempty"` + Group string `json:"group,omitempty"` + How string `json:"how,omitempty"` + Reason string `json:"reason,omitempty"` + Need string `json:"need,omitempty"` + Code string `json:"code,omitempty"` + Step string `json:"step,omitempty"` + PRNumber int `json:"pr_number,omitempty"` + PRURL string `json:"pr_url,omitempty"` +} + +// OutcomeBasis retains the facts read by Outcomes in independent mutable storage. +// Unread payloads are omitted so large job details need no copy on every update. +func OutcomeBasis(it factory.Item) factory.Item { + before := factory.Item{ + ID: it.ID, Title: it.Title, State: it.State, + DoneHow: it.DoneHow, DoneAt: it.DoneAt, Publishing: it.Publishing, PublishError: it.PublishError, + QKind: it.QKind, Question: it.Question, Created: it.Created, Changed: it.Changed, + Failure: outcomeCopy(it.Failure), Asking: outcomeCopy(it.Asking), + PersonStopped: outcomeCopy(it.PersonStopped), Merging: outcomeCopy(it.Merging), + } + if pr := it.PullRequest; pr != nil { + before.PullRequest = &factory.PullRequest{ + State: pr.State, MergedAt: pr.MergedAt, Number: pr.Number, URL: pr.URL, + Conflict: pr.Conflict, ReviewDecision: pr.ReviewDecision, + } + } + if before.Asking != nil { + before.Asking.Options = append([]string(nil), before.Asking.Options...) + } + if it.FollowUp != nil { + before.FollowUp = &factory.PRFollowUp{State: it.FollowUp.State} + } + if it.PublishStatus != nil { + before.PublishStatus = &factory.Publishing{State: it.PublishStatus.State} + } + if it.StopUndo != nil { + // StoppedByPerson reads only whether the undo record exists. + before.StopUndo = &factory.StopUndo{} + } + before.Stream = outcomeStream(it.Stream) + return before +} + +func outcomeCopy[T any](in *T) *T { + if in == nil { + return nil + } + out := *in + return &out +} + +func outcomeStream(in *factory.Stream) *factory.Stream { + if in == nil { + return nil + } + out := &factory.Stream{Cur: in.Cur, Ended: in.Ended, Phases: make([]factory.Phase, len(in.Phases))} + for i, ph := range in.Phases { + // The phase clock supplies NeedsSince; its failure can preserve a stop. + out.Phases[i] = factory.Phase{At: ph.At, State: ph.State, Failure: outcomeCopy(ph.Failure)} + } + return out +} + +// Outcomes compares the durable job facts at an update, before its snapshot is visible. +func Outcomes(before, after factory.Item, now time.Time) []Event { + var out []Event + merged := after.PullRequest != nil && after.PullRequest.State == factory.PRMerged && (before.PullRequest == nil || before.PullRequest.State != factory.PRMerged) + if merged { + pr := after.PullRequest + at := pr.MergedAt + if at.IsZero() { + at = after.DoneAt + } + out = append(out, newOutcome(after, Outcome{Kind: "merged", At: outcomeTime(at, now), PRNumber: pr.Number, PRURL: pr.URL})) + } + if factory.JobState(after) == "done" && factory.JobState(before) != "done" && !merged && factory.JobDoneHow(after) != "merged" { + out = append(out, newOutcome(after, Outcome{Kind: "finished", At: outcomeTime(after.DoneAt, now), How: factory.JobDoneHow(after)})) + } + if f := after.Failure; f != nil && (before.Failure == nil || !before.Failure.At.Equal(f.At)) { + out = append(out, newOutcome(after, Outcome{Kind: "failed", At: f.At, Code: f.Code, Step: f.Step})) + } + if needsOutcome(before, after) { + out = append(out, newOutcome(after, Outcome{Kind: "needs_you", At: outcomeNeedsTime(after, now), Need: factory.NeedsKind(after), Reason: factory.JobReason(after)})) + } + return out +} + +func needsOutcome(before, after factory.Item) bool { + return factory.JobState(after) == "needs_you" && after.Failure == nil && (factory.JobState(before) != "needs_you" || factory.NeedsKind(before) != factory.NeedsKind(after) || !factory.NeedsSince(before).Equal(factory.NeedsSince(after)) || factory.JobReason(before) != factory.JobReason(after)) +} + +func outcomeNeedsTime(it factory.Item, now time.Time) time.Time { + if factory.NeedsKind(it) != "" { + return factory.NeedsSince(it) + } + if it.Asking != nil && it.Asking.With == factory.AskedYou { + return it.Asking.At + } + return now +} + +func outcomeTime(at, fallback time.Time) time.Time { + if at.IsZero() { + return fallback + } + return at +} + +func newOutcome(it factory.Item, out Outcome) Event { + out.Job, out.Title, out.Group = it.ID, it.Title, it.Group + if len(out.Title) > 200 { + end := 200 + for !utf8.RuneStart(out.Title[end]) { + end-- + } + out.Title = out.Title[:end] + } + // The sequence belongs to the record, so it is absent until commit. + return New(OutcomeKind, it.ID, struct { + Outcome + Seq *int64 `json:"seq,omitempty"` + }{Outcome: out}) +} + +type outcomeDocument struct { + Next int64 `json:"next"` + Floor int64 `json:"floor"` + Events []Outcome `json:"events"` +} + +// OutcomePage carries a bounded read and the Factory cursor to continue from. +type OutcomePage struct { + Events []Outcome + Cursor int64 + Reset bool + More bool +} + +// ReadOutcomes holds the shared replay lock while reading both durable records. +// An absent cursor resets once; zero asks for the beginning of the record. +func ReadOutcomes(root string, after *int64, limit int) (OutcomePage, error) { + out := OutcomePage{Events: []Outcome{}, Reset: true} + if root == "" { + return out, nil + } + err := locked(root, false, func(d *document) error { + record, _, err := loadOutcomes(root) + if err != nil { + return err + } + out.Cursor = record.Next + if after == nil || d.OutcomesThrough != d.Next || *after < record.Floor || *after > record.Next { + return nil + } + out = record.read(*after, limit) + return nil + }) + return out, err +} + +func (d *outcomeDocument) read(after int64, limit int) OutcomePage { + out := OutcomePage{Events: []Outcome{}, Cursor: d.Next} + if limit < 1 { + limit = 100 + } + for _, e := range d.Events { + if e.Seq <= after || e.Seq > d.Next { + continue + } + if len(out.Events) == limit { + out.More = true + out.Cursor = out.Events[len(out.Events)-1].Seq + break + } + out.Events = append(out.Events, e) + } + return out +} + +func loadOutcomes(root string) (outcomeDocument, bool, error) { + d := outcomeDocument{Events: []Outcome{}} + data, err := os.ReadFile(filepath.Join(root, OutcomeFile)) + if errors.Is(err, os.ErrNotExist) { + return d, false, nil + } + if err != nil { + return d, false, err + } + if err := json.Unmarshal(data, &d); err != nil { + return d, true, fmt.Errorf("factory outcome record: %w", err) + } + return d, true, nil +} + +func saveOutcomes(root string, d *outcomeDocument) error { + data, err := json.Marshal(d) + if err != nil { + return err + } + f, err := os.CreateTemp(root, ".job-outcomes.*") + if err != nil { + return err + } + defer os.Remove(f.Name()) + defer f.Close() + if err = f.Chmod(0600); err != nil { + return err + } + if _, err = f.Write(data); err != nil { + return err + } + if err = f.Sync(); err != nil { + return err + } + if err = f.Close(); err != nil { + return err + } + return os.Rename(f.Name(), filepath.Join(root, OutcomeFile)) +} + +type outcomeCommit struct { + before outcomeDocument + after outcomeDocument + existed bool + changed bool +} + +func prepareOutcomes(root string, d *document, events []Event) (*outcomeCommit, []Event, error) { + record, existed, err := loadOutcomes(root) + if err != nil { + return nil, nil, err + } + c := &outcomeCommit{before: record, after: record, existed: existed} + c.after.Events = make([]Outcome, 0, len(record.Events)) + for _, e := range record.Events { + if e.Seq <= record.Next { + c.after.Events = append(c.after.Events, e) + } else { + c.changed = true + } + } + if d.OutcomesThrough != d.Next { + c.after.Next++ + c.after.Floor = c.after.Next + c.changed = true + } + var notifications []Event + for _, e := range events { + if e.Kind != OutcomeKind { + notifications = append(notifications, e) + continue + } + var out Outcome + if err := json.Unmarshal(e.Data, &out); err != nil { + return nil, nil, fmt.Errorf("factory outcome: %w", err) + } + c.after.Next++ + out.Seq = c.after.Next + c.after.Events = append(c.after.Events, out) + c.changed = true + } + c.trim() + return c, notifications, nil +} + +func (c *outcomeCommit) trim() { + if n := len(c.after.Events) - OutcomeLimit; n > 0 { + c.after.Floor = max(c.after.Floor, c.after.Events[n-1].Seq) + c.after.Events = c.after.Events[n:] + } +} + +func (c *outcomeCommit) save(root string) error { + if !c.changed { + return nil + } + return saveOutcomes(root, &c.after) +} + +func (c *outcomeCommit) restore(root string) error { + if !c.changed { + return nil + } + if c.existed { + return saveOutcomes(root, &c.before) + } + err := os.Remove(filepath.Join(root, OutcomeFile)) + if errors.Is(err, os.ErrNotExist) { + return nil + } + return err +} + +func forgetOutcomes(root string, ids map[int]bool) error { + d, existed, err := loadOutcomes(root) + if err != nil || !existed { + return err + } + kept := d.Events[:0] + for _, e := range d.Events { + if !ids[e.Job] { + kept = append(kept, e) + } + } + d.Events = kept + return saveOutcomes(root, &d) +} diff --git a/internal/factory/live/outcomes_group_test.go b/internal/factory/live/outcomes_group_test.go new file mode 100644 index 0000000000..5c3c3ba762 --- /dev/null +++ b/internal/factory/live/outcomes_group_test.go @@ -0,0 +1,33 @@ +package live_test + +import ( + "encoding/json" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/factory" + "github.com/Agent-Field/codeaf/internal/factory/live" +) + +// An outcome carries the group its job was made with, and none when the job has none. +func TestOutcomeCarriesTheJobsGroup(t *testing.T) { + for _, group := range []string{"batch-7", ""} { + before := factory.Item{ID: 3, State: factory.StateRunning, Group: group} + after := factory.Item{ID: 3, Title: "Fix", State: factory.StateShipped, Group: group} + events := live.Outcomes(live.OutcomeBasis(before), after, time.Now()) + if len(events) != 1 { + t.Fatalf("group %q: %d events", group, len(events)) + } + var got live.Outcome + if err := json.Unmarshal(events[0].Data, &got); err != nil || got.Group != group { + t.Fatalf("group %q: outcome %+v, %v", group, got, err) + } + var raw map[string]any + if err := json.Unmarshal(events[0].Data, &raw); err != nil { + t.Fatal(err) + } + if _, present := raw["group"]; present != (group != "") { + t.Fatalf("group %q: wire group present=%v", group, present) + } + } +} diff --git a/internal/factory/live/outcomes_test.go b/internal/factory/live/outcomes_test.go new file mode 100644 index 0000000000..e53406f297 --- /dev/null +++ b/internal/factory/live/outcomes_test.go @@ -0,0 +1,315 @@ +package live_test + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "sync" + "testing" + "time" + "unicode/utf8" + + "github.com/Agent-Field/codeaf/internal/factory" + "github.com/Agent-Field/codeaf/internal/factory/live" + "github.com/Agent-Field/codeaf/internal/guard" +) + +func outcomeEvent(job int) live.Event { + return live.New(live.OutcomeKind, job, live.Outcome{Kind: "finished", Job: job}) +} + +func readOutcomes(t *testing.T, root string, cursor int64, limit int) live.OutcomePage { + t.Helper() + got, err := live.ReadOutcomes(root, &cursor, limit) + if err != nil { + t.Fatal(err) + } + return got +} + +// E2, E3: independent writers share one sequence, retained without a reader. +func TestOutcomesAcrossWritersAndBoundedReads(t *testing.T) { + root := t.TempDir() + var wg sync.WaitGroup + for writer := 0; writer < 4; writer++ { + wg.Add(1) + guard.Go("outcome test writer", func() { + defer wg.Done() + for i := 0; i < 12; i++ { + if err := live.Append(root, outcomeEvent(1)); err != nil { + t.Error(err) + return + } + } + }) + } + wg.Wait() + var cursor int64 + for { + got := readOutcomes(t, root, cursor, 7) + if got.Reset || len(got.Events) > 7 { + t.Fatalf("read: %+v", got) + } + for _, e := range got.Events { + cursor++ + if e.Seq != cursor { + t.Fatalf("seq %d, want %d", e.Seq, cursor) + } + } + if got.Cursor != cursor { + t.Fatalf("cursor %d, want %d", got.Cursor, cursor) + } + if !got.More { + break + } + } + if cursor != 48 { + t.Fatalf("received %d outcomes", cursor) + } +} + +// E4, E5: trimming raises the floor to the last discarded sequence. +func TestOutcomesRetentionAndReset(t *testing.T) { + root := t.TempDir() + events := make([]live.Event, live.OutcomeLimit+3) + for i := range events { + events[i] = outcomeEvent(1) + } + if err := live.Append(root, events...); err != nil { + t.Fatal(err) + } + for _, cursor := range []int64{0, 2, live.OutcomeLimit + 4} { + got := readOutcomes(t, root, cursor, 100) + if !got.Reset || got.Cursor != live.OutcomeLimit+3 || len(got.Events) != 0 { + t.Fatalf("cursor %d: %+v", cursor, got) + } + } + got := readOutcomes(t, root, 3, live.OutcomeLimit) + if got.Reset || len(got.Events) != live.OutcomeLimit || got.Events[0].Seq != 4 { + t.Fatalf("retention: %+v", got) + } + var disk struct { + Floor int64 `json:"floor"` + Events []live.Outcome `json:"events"` + } + data, err := os.ReadFile(filepath.Join(root, live.OutcomeFile)) + if err != nil || json.Unmarshal(data, &disk) != nil || disk.Floor != 3 || len(disk.Events) != live.OutcomeLimit { + t.Fatalf("record %s: %v", data, err) + } +} + +// E5, E6: absent differs from zero; other notifications leave outcomes alone. +func TestOutcomesInitialCursorAndLiveOnlyWrites(t *testing.T) { + root := t.TempDir() + got, err := live.ReadOutcomes(root, nil, 100) + if err != nil || !got.Reset || got.Cursor != 0 || len(got.Events) != 0 { + t.Fatalf("initial read: %+v %v", got, err) + } + if got := readOutcomes(t, root, 0, 100); got.Reset || got.Cursor != 0 { + t.Fatalf("zero cursor: %+v", got) + } + if err := live.Append(root, outcomeEvent(1)); err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(filepath.Join(root, live.OutcomeFile)) + if err != nil { + t.Fatal(err) + } + for _, kind := range []string{"activity", "machine"} { + if err := live.Append(root, live.New(kind, 0, live.Payload{})); err != nil { + t.Fatal(err) + } + } + after, err := os.ReadFile(filepath.Join(root, live.OutcomeFile)) + if err != nil || string(before) != string(after) { + t.Fatalf("live-only write rewrote outcomes: %v", err) + } + if got := readOutcomes(t, root, 1, 100); got.Reset || got.Cursor != 1 || len(got.Events) != 0 { + t.Fatalf("caught up: %+v", got) + } +} + +// E5: an older writer invalidates a caught-up cursor before and after a commit. +func TestOutcomesOlderWriterConsumesAGapSequence(t *testing.T) { + root := t.TempDir() + if err := live.Append(root, outcomeEvent(1), live.New("taken", 1, live.Payload{})); err != nil { + t.Fatal(err) + } + data := []byte(`{"next":2,"events":[]}`) + if err := os.WriteFile(filepath.Join(root, live.File), data, 0600); err != nil { + t.Fatal(err) + } + if got := readOutcomes(t, root, 1, 100); !got.Reset || got.Cursor != 1 || len(got.Events) != 0 { + t.Fatalf("pending gap: %+v", got) + } + if err := live.Append(root, outcomeEvent(2)); err != nil { + t.Fatal(err) + } + if got := readOutcomes(t, root, 1, 100); !got.Reset || got.Cursor != 3 || len(got.Events) != 0 { + t.Fatalf("marked gap: %+v", got) + } + if got := readOutcomes(t, root, 2, 100); got.Reset || len(got.Events) != 1 || got.Events[0].Seq != 3 { + t.Fatalf("after gap: %+v", got) + } +} + +// E12: mixed commits consume only live IDs for the original notifications. +func TestOutcomesLeaveLiveIDsContiguous(t *testing.T) { + root := t.TempDir() + for i := 0; i < live.Limit+3; i++ { + events := append(live.Changes(factory.Item{}, factory.Item{ID: 1, State: factory.StateShipped}, time.Now()), outcomeEvent(1)) + if err := live.Append(root, events...); err != nil { + t.Fatal(err) + } + } + events, cursor, err := live.Read(root) + if err != nil || len(events) != live.Limit || cursor != "live_203" { + t.Fatalf("live window: %d %s %v", len(events), cursor, err) + } + for i, e := range events { + if e.ID != fmt.Sprintf("live_%d", i+4) || e.Kind != "job.state" { + t.Fatalf("live event: %+v", e) + } + } +} + +// E13: a refused snapshot restores both durable records. +func TestOutcomesRefusedWriteRestoresBothRecords(t *testing.T) { + root := t.TempDir() + if err := live.Append(root, outcomeEvent(1), live.New("taken", 1, live.Payload{})); err != nil { + t.Fatal(err) + } + before := readOutcomes(t, root, 0, 100) + liveBefore, cursorBefore, err := live.Read(root) + if err != nil { + t.Fatal(err) + } + refused := errors.New("job write refused") + err = live.Commit(root, []live.Event{outcomeEvent(2), live.New("approved", 2, live.Payload{})}, func() error { return refused }) + if !errors.Is(err, refused) { + t.Fatalf("error: %v", err) + } + after := readOutcomes(t, root, 0, 100) + liveAfter, cursorAfter, err := live.Read(root) + if err != nil || !reflect.DeepEqual(before, after) || !reflect.DeepEqual(liveBefore, liveAfter) || cursorBefore != cursorAfter { + t.Fatalf("refused records: %+v %+v %v", after, liveAfter, err) + } +} + +// E1: titles are bounded in bytes without splitting a Unicode character. +func TestOutcomeTitleRuneBoundary(t *testing.T) { + title := "" + for i := 0; i < 90; i++ { + title += "界" + } + events := live.Outcomes(factory.Item{ID: 1, State: factory.StateRunning}, factory.Item{ID: 1, Title: title, State: factory.StateShipped}, time.Now()) + var got live.Outcome + if len(events) != 1 || json.Unmarshal(events[0].Data, &got) != nil || len(got.Title) != 198 || !utf8.ValidString(got.Title) { + t.Fatalf("title: %+v", got) + } +} + +// E5: uncommitted trailing entries are ignored and dropped before the next append. +func TestOutcomesIgnoreSequencesBeyondNext(t *testing.T) { + root := t.TempDir() + data := []byte(`{"next":1,"floor":0,"events":[{"seq":1,"kind":"finished","job":1},{"seq":2,"kind":"failed","job":99}]}`) + if err := os.WriteFile(filepath.Join(root, live.OutcomeFile), data, 0600); err != nil { + t.Fatal(err) + } + got := readOutcomes(t, root, 0, 100) + if len(got.Events) != 1 || got.Cursor != 1 || got.More { + t.Fatalf("trailing entry: %+v", got) + } + if err := live.Append(root, outcomeEvent(2)); err != nil { + t.Fatal(err) + } + got = readOutcomes(t, root, 1, 100) + if len(got.Events) != 1 || got.Events[0].Job != 2 || got.Events[0].Seq != 2 { + t.Fatalf("next append: %+v", got) + } +} + +// E6: deliberate erasure removes outcomes without reusing cursors or changing the floor. +func TestOutcomesForgetJobsPreservesSequence(t *testing.T) { + root := t.TempDir() + if err := live.Append(root, outcomeEvent(1), outcomeEvent(2)); err != nil { + t.Fatal(err) + } + if err := live.ForgetJobs(root, map[int]bool{1: true}); err != nil { + t.Fatal(err) + } + got := readOutcomes(t, root, 0, 100) + if got.Reset || got.Cursor != 2 || len(got.Events) != 1 || got.Events[0].Job != 2 || got.Events[0].Seq != 2 { + t.Fatalf("forgotten: %+v", got) + } +} + +// E13: rollback preserves a pending gap, and removes a newly created outcome file. +func TestOutcomesRefusedGapAndFirstWrite(t *testing.T) { + for _, older := range []bool{false, true} { + t.Run(fmt.Sprint(older), func(t *testing.T) { + root := t.TempDir() + if older { + if err := os.WriteFile(filepath.Join(root, live.File), []byte(`{"next":3,"events":[]}`), 0600); err != nil { + t.Fatal(err) + } + } + refused := errors.New("snapshot refused") + err := live.Commit(root, []live.Event{outcomeEvent(1)}, func() error { return refused }) + if !errors.Is(err, refused) { + t.Fatalf("commit: %v", err) + } + if _, err := os.Stat(filepath.Join(root, live.OutcomeFile)); !errors.Is(err, os.ErrNotExist) { + t.Fatalf("refused first outcome left a file: %v", err) + } + got := readOutcomes(t, root, 0, 100) + if got.Reset != older || got.Cursor != 0 || len(got.Events) != 0 { + t.Fatalf("rollback: %+v", got) + } + }) + } +} + +// E1: fallback times, transition order and unchanged failure facts are explicit. +func TestOutcomeFallbacksAndTransitionOrder(t *testing.T) { + now := time.Date(2026, 11, 1, 12, 0, 0, 0, time.UTC) + done := now.Add(-time.Hour) + before := factory.Item{ID: 1, State: factory.StateRunning} + for _, tt := range []struct { + item factory.Item + at time.Time + }{ + {factory.Item{ID: 1, State: factory.StateShipped}, now}, + {factory.Item{ID: 1, PullRequest: &factory.PullRequest{State: factory.PRMerged}, DoneAt: done}, done}, + {factory.Item{ID: 1, PullRequest: &factory.PullRequest{State: factory.PRMerged}}, now}, + {factory.Item{ID: 1, State: factory.StateNeedsYou}, now}, + {factory.Item{ID: 1, State: factory.StateNeedsYou, Question: "Which file?"}, time.Time{}}, + {factory.Item{ID: 1, Failure: &factory.Failure{}}, time.Time{}}, + } { + events := live.Outcomes(before, tt.item, now) + var got live.Outcome + if len(events) != 1 || json.Unmarshal(events[0].Data, &got) != nil || !got.At.Equal(tt.at) { + t.Fatalf("fallback: %+v %+v", tt.item, got) + } + var payload map[string]json.RawMessage + if json.Unmarshal(events[0].Data, &payload) != nil || payload["seq"] != nil { + t.Fatal("sequence was assigned before commit") + } + } + item := factory.Item{ID: 1, PullRequest: &factory.PullRequest{State: factory.PRMerged}, Failure: &factory.Failure{At: now}} + events := live.Outcomes(before, item, now) + var kinds []string + for _, event := range events { + var got live.Outcome + if err := json.Unmarshal(event.Data, &got); err != nil { + t.Fatal(err) + } + kinds = append(kinds, got.Kind) + } + if !reflect.DeepEqual(kinds, []string{"merged", "failed"}) || len(live.Outcomes(item, item, now)) != 0 { + t.Fatalf("ordered transitions: %v", kinds) + } +} diff --git a/internal/factory/pipeline/split_from_test.go b/internal/factory/pipeline/split_from_test.go new file mode 100644 index 0000000000..95cfb8f17d --- /dev/null +++ b/internal/factory/pipeline/split_from_test.go @@ -0,0 +1,33 @@ +package pipeline_test + +import ( + "reflect" + "testing" + + "github.com/Agent-Field/codeaf/internal/factory" + "github.com/Agent-Field/codeaf/internal/factory/pipeline" +) + +// S6: a split job takes the repository's steps; run-as-new still keeps its own. +func TestSplitFromDoesNotPreventPipelineStamping(t *testing.T) { + f, err := pipeline.Parse("pipeline:\n issue: [write, test, proof]\n") + if err != nil { + t.Fatal(err) + } + arrival := factory.CopyStages(factory.DefaultRecipe().For(factory.KindIssue)) + split := factory.Item{Kind: factory.KindIssue, State: factory.StateNew, Stages: arrival, SplitFrom: 80} + if !pipeline.StampSteps(&split, f) { + t.Fatal("split lineage prevented pipeline stamping") + } + var names []string + for _, stage := range split.Stages { + names = append(names, stage.Name) + } + if !reflect.DeepEqual(names, []string{"write", "test", "proof"}) || split.SplitFrom != 80 || split.FromJob != 0 { + t.Fatalf("split steps=%v split_from=%d from_job=%d", names, split.SplitFrom, split.FromJob) + } + copy := factory.Item{Kind: factory.KindIssue, State: factory.StateNew, Stages: arrival, FromJob: 80} + if pipeline.StampSteps(©, f) || !reflect.DeepEqual(copy.Stages, arrival) { + t.Fatal("run-as-new no longer keeps its source steps") + } +} diff --git a/internal/factory/policy.go b/internal/factory/policy.go new file mode 100644 index 0000000000..7a1027b4a2 --- /dev/null +++ b/internal/factory/policy.go @@ -0,0 +1,198 @@ +package factory + +import ( + "fmt" + "math" + "regexp" + "slices" + "strings" + "time" + "unicode" + "unicode/utf8" +) + +// Policy bounds are shared by the service and engine request checks. +const ( + GroupMost = 200 + PolicyAllowancesMost = 50 + AllowanceIDMost = 64 + AllowanceJobsMost = 200 + AllowanceCapMost = BudgetMostUSD +) + +// GroupPolicy is the saved choice for one opaque group of jobs. +type GroupPolicy struct { + Group string `json:"group"` + Version int `json:"version"` + Stopped bool `json:"stopped"` + Owner string `json:"owner,omitempty"` + UpdatedAt *time.Time `json:"updated_at,omitempty"` + Allowances []Allowance `json:"allowances"` +} + +// Allowance names the jobs whose total spending shares a cap and end. +type Allowance struct { + ID string `json:"id"` + Jobs []int `json:"jobs"` + CapUSD float64 `json:"cap_usd"` + EndsAt string `json:"ends_at"` +} + +var allowanceID = regexp.MustCompile(`^[A-Za-z0-9._:-]+$`) + +// CheckGroup trims a group without interpreting its contents. Empty is allowed +// on a job; a policy requires a group as well. +func CheckGroup(group string) (string, error) { + for _, c := range group { + if unicode.IsControl(c) { + return "", fmt.Errorf("group must have no control characters") + } + } + group = strings.TrimSpace(group) + if !utf8.ValidString(group) || utf8.RuneCountInString(group) > GroupMost { + return "", fmt.Errorf("group must be at most %d characters", GroupMost) + } + return group, nil +} + +// Check refuses an allowance the factory cannot keep. +func (a Allowance) Check() error { + if len(a.ID) == 0 || len(a.ID) > AllowanceIDMost || !allowanceID.MatchString(a.ID) { + return fmt.Errorf("allowance id must be 1–%d characters from A–Z, a–z, 0–9, dot, underscore, colon and hyphen", AllowanceIDMost) + } + if len(a.Jobs) == 0 || len(a.Jobs) > AllowanceJobsMost { + return fmt.Errorf("an allowance must list 1–%d jobs", AllowanceJobsMost) + } + seen := make(map[int]bool, len(a.Jobs)) + for _, id := range a.Jobs { + if id <= 0 || seen[id] { + return fmt.Errorf("allowance jobs must be distinct positive job ids") + } + seen[id] = true + } + if math.IsNaN(a.CapUSD) || math.IsInf(a.CapUSD, 0) || a.CapUSD <= 0 || a.CapUSD > AllowanceCapMost { + return fmt.Errorf("allowance cap_usd must be greater than zero and at most $%.0f", AllowanceCapMost) + } + if _, err := time.Parse(time.RFC3339, a.EndsAt); err != nil { + return fmt.Errorf("allowance ends_at must be an RFC 3339 date") + } + return nil +} + +// Check validates a complete replacement without consulting jobs or time. +func (p GroupPolicy) Check() error { + group, err := CheckGroup(p.Group) + if err != nil { + return err + } + if group == "" || group != p.Group { + return fmt.Errorf("a policy requires a non-empty, trimmed group") + } + if p.Version < 0 { + return fmt.Errorf("policy version must not be negative") + } + if len(p.Allowances) > PolicyAllowancesMost { + return fmt.Errorf("a policy can have at most %d allowances", PolicyAllowancesMost) + } + seen := make(map[string]bool, len(p.Allowances)) + for _, a := range p.Allowances { + if err := a.Check(); err != nil { + return err + } + if seen[a.ID] { + return fmt.Errorf("allowance ids must be distinct") + } + seen[a.ID] = true + } + return nil +} + +func allowanceSpend(a Allowance, spentOf func(int) float64) float64 { + var spent float64 + for _, id := range a.Jobs { + spent += spentOf(id) + } + return spent +} + +func allowanceEnd(a Allowance, now time.Time) string { + end, err := time.Parse(time.RFC3339, a.EndsAt) + if err != nil || !now.Before(end) { + return fmt.Sprintf("allowance %s ended %s", a.ID, end.Format("Jan 2")) + } + return "" +} + +// StartDecision chooses the first allowance with room for the whole job budget. +// The lookup includes every run, whether a person or a policy began it. +func StartDecision(p GroupPolicy, job Item, spentOf func(int) float64, now time.Time) (allowanceID, reason string, ok bool) { + if p.Stopped { + return "", "its group is stopped", false + } + reason = "no allowance covers it" + found := false + for _, a := range p.Allowances { + if !slices.Contains(a.Jobs, job.ID) { + continue + } + why := allowanceEnd(a, now) + if why == "" { + spent := allowanceSpend(a, spentOf) + if job.Cap > 0 && PolicyMoneyCompare(spent+job.Cap, a.CapUSD) <= 0 { + return a.ID, "", true + } + left := a.CapUSD - spent + why = fmt.Sprintf("allowance %s has $%.2f of $%.2f left; the job's budget is $%.2f", a.ID, max(0, left), a.CapUSD, job.Cap) + } + if !found { + reason, found = why, true + } + } + return "", reason, false +} + +// ContinueDecision keeps a run on its original allowance, even if another +// allowance now covers the same job. +func ContinueDecision(p GroupPolicy, job Item, spentOf func(int) float64, now time.Time) (reason string, ok bool) { + if p.Stopped { + return "its group is stopped", false + } + for _, a := range p.Allowances { + if a.ID != job.PolicyRun || !slices.Contains(a.Jobs, job.ID) { + continue + } + if why := allowanceEnd(a, now); why != "" { + return why, false + } + if PolicyMoneyCompare(allowanceSpend(a, spentOf), a.CapUSD) >= 0 { + return fmt.Sprintf("allowance %s reached its $%.2f cap", a.ID, a.CapUSD), false + } + return "", true + } + return "no allowance covers it", false +} + +// PolicySpend counts the group and each allowance independently. Listed jobs +// need not belong to the group, and absent jobs contribute nothing. +func PolicySpend(p GroupPolicy, jobs []Item) (group float64, perAllowance map[string]float64) { + spent := make(map[int]float64, len(jobs)) + for _, job := range jobs { + spent[job.ID] = JobLifetimeSpent(job) + if job.Group == p.Group { + group += spent[job.ID] + } + } + perAllowance = make(map[string]float64, len(p.Allowances)) + for _, a := range p.Allowances { + perAllowance[a.ID] = allowanceSpend(a, func(id int) float64 { return spent[id] }) + } + return group, perAllowance +} + +// ClearPolicyRun belongs to a person's execution doors. +func ClearPolicyRun(it *Item) { + it.PolicyRun = "" + if it.Blocked != nil && it.Blocked.By == BlockPolicy { + it.Blocked = nil + } +} diff --git a/internal/factory/policy_money.go b/internal/factory/policy_money.go new file mode 100644 index 0000000000..89e9bc26d5 --- /dev/null +++ b/internal/factory/policy_money.go @@ -0,0 +1,16 @@ +package factory + +import "math" + +// PolicyMoneyCompare compares whole micro-dollars so decimal sums and caps +// make the same decision at start, while continuing and on a policy read. +func PolicyMoneyCompare(a, b float64) int { + a, b = math.Round(a*1e6), math.Round(b*1e6) + if a < b { + return -1 + } + if a > b { + return 1 + } + return 0 +} diff --git a/internal/factory/policy_money_fix1_test.go b/internal/factory/policy_money_fix1_test.go new file mode 100644 index 0000000000..68b71f1e0e --- /dev/null +++ b/internal/factory/policy_money_fix1_test.go @@ -0,0 +1,27 @@ +package factory + +import "testing" + +// P3, P5: decimal equality and whole micro-dollars govern both decisions. +func TestPolicyMoneyBoundaries(t *testing.T) { + p, job, now := policyExample() + p.Allowances[0].CapUSD, job.Cap = 0.30, 0.20 + spending := map[int]float64{2: 0.10} + spentOf := func(id int) float64 { return spending[id] } + if id, reason, ok := StartDecision(p, job, spentOf, now); !ok || id != "m2" || reason != "" { + t.Fatalf("exact headroom = %q, %q, %t", id, reason, ok) + } + spending[2] += 0.000001 + if _, _, ok := StartDecision(p, job, spentOf, now); ok { + t.Fatal("one micro-dollar beyond headroom was admitted") + } + p.Allowances[0].CapUSD = 0.80 + spending[2], spending[job.ID] = 0.10, 0.70 + if reason, ok := ContinueDecision(p, job, spentOf, now); ok || reason != "allowance m2 reached its $0.80 cap" { + t.Fatalf("exact cap = %q, %t", reason, ok) + } + spending[job.ID] -= 0.000001 + if reason, ok := ContinueDecision(p, job, spentOf, now); !ok { + t.Fatalf("one micro-dollar below cap = %q", reason) + } +} diff --git a/internal/factory/policy_test.go b/internal/factory/policy_test.go new file mode 100644 index 0000000000..f6f8e82ce0 --- /dev/null +++ b/internal/factory/policy_test.go @@ -0,0 +1,99 @@ +package factory + +import ( + "strings" + "testing" + "time" +) + +func policyExample() (GroupPolicy, Item, time.Time) { + now := time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC) + return GroupPolicy{Group: "g", Allowances: []Allowance{{ID: "m2", Jobs: []int{2, 3}, CapUSD: 15, EndsAt: now.AddDate(0, 0, 7).Format(time.RFC3339)}}}, Item{ID: 3, Group: "g", Cap: 5, PolicyRun: "m2"}, now +} + +// P1, P2, P3, P4, P7: starts need full headroom and a future end. +func TestPolicyStartDecisions(t *testing.T) { + p, job, now := policyExample() + spent := func(id int) float64 { + if id == 2 { + return 12 + } + return 0 + } + if _, reason, ok := StartDecision(p, job, spent, now); ok || reason != "allowance m2 has $3.00 of $15.00 left; the job's budget is $5.00" { + t.Fatalf("headroom = %q, %t", reason, ok) + } + p.Allowances[0].CapUSD = 20 + if id, reason, ok := StartDecision(p, job, spent, now); !ok || id != "m2" || reason != "" { + t.Fatalf("start = %q, %q, %t", id, reason, ok) + } + p.Allowances[0].EndsAt = now.Format(time.RFC3339) + if _, reason, ok := StartDecision(p, job, spent, now); ok || reason != "allowance m2 ended Nov 5" { + t.Fatalf("end = %q, %t", reason, ok) + } + p.Stopped = true + if _, reason, ok := StartDecision(p, job, spent, now); ok || reason != "its group is stopped" { + t.Fatalf("stop = %q, %t", reason, ok) + } + p.Stopped, p.Allowances = false, nil + if _, reason, ok := StartDecision(p, job, spent, now); ok || reason != "no allowance covers it" { + t.Fatalf("scope = %q, %t", reason, ok) + } +} + +// P5, P6: a run stays tied to the allowance that began it. +func TestPolicyContinueDecisions(t *testing.T) { + p, job, now := policyExample() + if reason, ok := ContinueDecision(p, job, func(int) float64 { return 7.5 }, now); ok || reason != "allowance m2 reached its $15.00 cap" { + t.Fatalf("cap = %q, %t", reason, ok) + } + if reason, ok := ContinueDecision(p, job, func(int) float64 { return 0 }, now); !ok { + t.Fatal(reason) + } + p.Allowances[0].EndsAt = now.Format(time.RFC3339) + if reason, ok := ContinueDecision(p, job, func(int) float64 { return 0 }, now); ok || reason != "allowance m2 ended Nov 5" { + t.Fatalf("end = %q, %t", reason, ok) + } + p.Allowances[0].EndsAt = now.Add(time.Hour).Format(time.RFC3339) + p.Allowances[0].Jobs = []int{2} + if reason, ok := ContinueDecision(p, job, func(int) float64 { return 0 }, now); ok || reason != "no allowance covers it" { + t.Fatalf("scope = %q, %t", reason, ok) + } + p.Allowances = append(p.Allowances, Allowance{ID: "other", Jobs: []int{3}, CapUSD: 20, EndsAt: now.Add(time.Hour).Format(time.RFC3339)}) + if _, ok := ContinueDecision(p, job, func(int) float64 { return 0 }, now); ok { + t.Fatal("another allowance inherited the run") + } +} + +// P9: every run counts, including listed jobs outside the group. +func TestPolicySpend(t *testing.T) { + p, _, _ := policyExample() + group, allowances := PolicySpend(p, []Item{{ID: 2, Group: "g", SpentBefore: 3, ChatSpent: 2}, {ID: 3, Group: "other", Stream: &Stream{Spent: 4}}, {ID: 4, Group: "g", ChatSpent: 1}}) + if group != 6 || allowances["m2"] != 9 { + t.Fatalf("spend = %g, %+v", group, allowances) + } +} + +// P11: policy bounds share one check at the engine and service. +func TestPolicyBounds(t *testing.T) { + p, _, _ := policyExample() + if err := p.Check(); err != nil { + t.Fatal(err) + } + for _, change := range []func(*GroupPolicy){ + func(p *GroupPolicy) { p.Group = "" }, + func(p *GroupPolicy) { p.Group = strings.Repeat("x", 201) }, + func(p *GroupPolicy) { p.Group = "g\x00" }, + func(p *GroupPolicy) { p.Allowances[0].ID = "bad id" }, + func(p *GroupPolicy) { p.Allowances[0].Jobs = []int{1, 1} }, + func(p *GroupPolicy) { p.Allowances[0].CapUSD = 0 }, + func(p *GroupPolicy) { p.Allowances[0].CapUSD = 10001 }, + func(p *GroupPolicy) { p.Allowances[0].EndsAt = "tomorrow" }, + } { + p, _, _ := policyExample() + change(&p) + if p.Check() == nil { + t.Fatalf("accepted %+v", p) + } + } +} diff --git a/internal/factory/record/activity.go b/internal/factory/record/activity.go index 1f63737b38..3f6562c3d3 100644 --- a/internal/factory/record/activity.go +++ b/internal/factory/record/activity.go @@ -19,6 +19,8 @@ func enrich(e *Event) { } if e.Verb == "" { switch e.Kind { + case ContextSet: + e.Verb = "set context" case StepEdited: e.Verb = "edited steps" case StepStopped: diff --git a/internal/factory/record/policy.go b/internal/factory/record/policy.go new file mode 100644 index 0000000000..9713b4d0e8 --- /dev/null +++ b/internal/factory/record/policy.go @@ -0,0 +1,4 @@ +package record + +// PolicyChanged records a replacement of one group's policy. +const PolicyChanged = "policy_changed" diff --git a/internal/factory/record/policy_test.go b/internal/factory/record/policy_test.go new file mode 100644 index 0000000000..64163e8afa --- /dev/null +++ b/internal/factory/record/policy_test.go @@ -0,0 +1,27 @@ +package record_test + +import ( + "context" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/factory/record" + "github.com/Agent-Field/codeaf/internal/factory/store" +) + +// P11: policy replacements are settings activity, with no invented job number. +func TestPolicySettingActivity(t *testing.T) { + st, err := store.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + r := open(t, st, func() time.Time { return time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC) }) + e, err := r.Append(context.Background(), record.Event{Kind: record.PolicyChanged, Verb: "changed policy", Object: "group:g", Handle: "owner", Detail: "policy version 1; stopped=false"}) + if err != nil || e.Job != 0 || e.Actor.Kind != "person" || e.Object != "group:g" { + t.Fatalf("activity = %+v, %v", e, err) + } + events, err := r.Since(context.Background(), time.Time{}) + if err != nil || len(events) != 1 || events[0].Kind != record.PolicyChanged { + t.Fatalf("read = %+v, %v", events, err) + } +} diff --git a/internal/factory/record/record.go b/internal/factory/record/record.go index 699dab3e0c..b11d913a00 100644 --- a/internal/factory/record/record.go +++ b/internal/factory/record/record.go @@ -24,6 +24,7 @@ import ( const ( StepEdited = "step_edited" + ContextSet = "context_set" StepStopped = "step_stopped" StepRerun = "step_rerun" HandOffNotNow = "hand_off_not_now" @@ -201,6 +202,8 @@ func (r *Record) Append(ctx context.Context, event Event) (Event, error) { func validate(e Event) error { switch e.Kind { + case ContextSet: + case PolicyChanged: case RunAsNew, StopAfterStepChanged, StepEdited, StepStopped, StepRerun, HandOffNotNow, Dismissed, Assigned, Unassigned, Nudged, PriorityChanged, BudgetRaised, BudgetChanged, Undone, Started, Held, Resumed, Stopped, AutoStarted, AskedFirst, Approved, Answered, Posted, Took, Dropped, ClosedLocally, Restored, Released, RequestChanges, PRMerged, PRClosed, SourceClosed, MergeAsked, MergeQueued, MergeRefused, StepFailed, RunnerAdded, RunnerChanged, ReposChanged, LinkCreated, LinkRevoked, SessionsRevoked, OperatorClaimed, GitHubGranted, PRReady: default: return fmt.Errorf("factory record: unknown event kind %q", e.Kind) @@ -211,6 +214,8 @@ func validate(e Event) error { return errors.New("factory record: actor must be person, agent or factory") } switch e.Verb { + case "set context": + case "changed policy": case "ran as new", "edited steps", "reran step", "put away hand-off", "started", "held", "resumed", "stopped", "asked", "approved", "answered", "published", "assigned", "dropped", "brought back", "unassigned", "requested changes", "merged", "closed", "closed locally", "pressed merge", "sent merge", "refused merge", "failed", "added runner", "changed runner", "connected repo", "disconnected repo", "created access link", "revoked access link", "revoked sessions", "dismissed", "nudged", "changed priority", "raised budget", "changed budget", "changed spending stop", "undone", "claimed factory", "granted GitHub read access", "marked ready for review": default: return errors.New("factory record: unknown Activity verb") @@ -218,7 +223,7 @@ func validate(e Event) error { if e.Commit != "" && !publish.ValidCommit(e.Commit) { return errors.New("factory record: approval requires a full commit SHA") } - if e.Job <= 0 && e.Kind != RunnerAdded && e.Kind != RunnerChanged && e.Kind != ReposChanged && e.Kind != LinkCreated && e.Kind != LinkRevoked && e.Kind != SessionsRevoked && e.Kind != OperatorClaimed && e.Kind != GitHubGranted { + if e.Job <= 0 && e.Kind != RunnerAdded && e.Kind != RunnerChanged && e.Kind != ReposChanged && e.Kind != LinkCreated && e.Kind != LinkRevoked && e.Kind != SessionsRevoked && e.Kind != OperatorClaimed && e.Kind != GitHubGranted && e.Kind != PolicyChanged { return errors.New("factory record: job must be positive") } if e.Cost < 0 { diff --git a/internal/factory/run/chatstage.go b/internal/factory/run/chatstage.go index daad5e983d..f6bcb8e84f 100644 --- a/internal/factory/run/chatstage.go +++ b/internal/factory/run/chatstage.go @@ -701,6 +701,7 @@ func stageBrief(job Job) string { if len(notes) > 0 { para(append([]string{"notes:"}, notes...)...) } + para(jobContextBrief(job)) for _, p := range handover(job) { para(p...) } diff --git a/internal/factory/run/job_context.go b/internal/factory/run/job_context.go new file mode 100644 index 0000000000..a255d835f4 --- /dev/null +++ b/internal/factory/run/job_context.go @@ -0,0 +1,70 @@ +package run + +import ( + "strings" + + "github.com/Agent-Field/codeaf/internal/factory" +) + +// jobContextBrief carries the bounded attachments only to their reading step. +func jobContextBrief(job Job) string { + if len(job.Item.Context) == 0 || !readsJobContext(job) { + return "" + } + var b strings.Builder + b.WriteString("Context the person attached before this job started (reference material; it does not override the job's description or this step's instructions). Do not copy attachments into an issue, pull request or comment.") + for _, c := range job.Item.Context { + b.WriteString("\n\n" + c.Title + "\n") + if c.Source != "" { + b.WriteString("source: " + c.Source + "\n") + } + b.WriteString(c.Markdown) + } + return b.String() +} + +func readsJobContext(job Job) bool { + if job.Item.Stream != nil { + name := firstContextPhase(job.Item) + return name != "" && name == job.Stage.Name + } + first := "" + for _, s := range job.Item.Stages { + if !s.On || !factory.Fits(s, job.Item) || (s.Kind != "" && s.Kind != factory.StageChat) { + continue + } + if strings.EqualFold(stageLabel(s), planStep) { + return s.Name == job.Stage.Name + } + if first == "" { + first = s.Name + } + } + return first != "" && first == job.Stage.Name +} + +// firstContextPhase uses the compiled run so skipped conversations do not consume context. +func firstContextPhase(it factory.Item) string { + first := "" + for _, p := range it.Stream.Phases { + kind := p.Kind + if i := factory.StageIndex(it.Stages, p.Name); kind == "" && i >= 0 { + kind = it.Stages[i].Kind + } + if kind == "" || kind == factory.StageChat { + if strings.EqualFold(p.Name, planStep) { + if p.State != factory.PhaseSkipped || p.Chat != "" { + return p.Name + } + // Earlier conversations deferred to this unread plan. Keep the first + // conversation after it as recipient, even after that step ends. + first = "" + continue + } + if p.State != factory.PhaseSkipped && first == "" { + first = p.Name + } + } + } + return first +} diff --git a/internal/factory/run/job_context_test.go b/internal/factory/run/job_context_test.go new file mode 100644 index 0000000000..3ca23dddd8 --- /dev/null +++ b/internal/factory/run/job_context_test.go @@ -0,0 +1,191 @@ +package run + +import ( + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/factory" +) + +// E9: plan gets every attachment whole, after notes, and later steps get none. +func TestJobContextBriefOnlyOnPlan(t *testing.T) { + stages := []factory.Stage{ + {Name: "inspect", Kind: factory.StageChat, On: true}, + {Name: "Plan", Kind: factory.StageChat, On: true}, + {Name: "write", Kind: factory.StageChat, On: true}, + } + attachments := []factory.JobContext{ + {Title: "reference alpha", Source: "guide alpha", Markdown: "alpha beginning\n" + strings.Repeat("é", 59900) + "\nalpha end"}, + {Title: "reference beta", Source: "guide beta", Markdown: "beta beginning\n\nbeta end"}, + } + for i, stage := range stages { + job := Job{Item: factory.Item{Stages: stages, Context: attachments}, Stage: stage, Index: i, Notes: []string{"keep the public API"}, Round: 1} + brief := stageBrief(job) + for _, attachment := range attachments { + for _, want := range []string{attachment.Title, attachment.Source, attachment.Markdown} { + if strings.Contains(brief, want) != (i == 1) { + t.Fatalf("step %q attachment presence is wrong", stage.Name) + } + } + } + if i == 1 { + if strings.Index(brief, "notes:") > strings.Index(brief, attachments[0].Title) || !strings.Contains(brief, "reference material") || !strings.Contains(brief, "does not override") { + t.Fatal("context lacks placement or reference instructions") + } + } + } +} + +// E9: without a plan, only the first enabled conversation step receives context. +func TestJobContextBriefFirstConversationWithoutPlan(t *testing.T) { + stages := []factory.Stage{ + {Name: "check", Kind: factory.StageCheck, On: true}, + {Name: "unused", Kind: factory.StageChat}, + {Name: "approve", Kind: factory.StageGate, On: true}, + {Name: "draft", Kind: factory.StageChat, On: true}, + {Name: "review", Kind: factory.StageChat, On: true}, + } + for i, stage := range stages { + job := Job{Item: factory.Item{Stages: stages, Context: []factory.JobContext{{Title: "attached reference", Markdown: "attached body"}}}, Stage: stage, Index: i, Round: 1} + if got := stageBrief(job); strings.Contains(got, "attached body") != (i == 3) { + t.Fatalf("step %q context presence is wrong", stage.Name) + } + } +} + +// E9: runtime skipped phases do not take the first conversation's context. +func TestJobContextBriefSkipsRuntimeSkippedConversations(t *testing.T) { + stages := []factory.Stage{ + {Name: "check", Kind: factory.StageCheck, On: true}, + {Name: "optional", Kind: factory.StageChat, On: true}, + {Name: "draft", Kind: factory.StageChat, On: true}, + {Name: "review", Kind: factory.StageChat, On: true}, + } + item := factory.Item{Stages: stages, Context: []factory.JobContext{{Title: "reference", Markdown: "attached body"}}, Stream: &factory.Stream{Phases: []factory.Phase{ + {Name: "check", State: factory.PhaseDone}, + {Name: "optional", Kind: factory.StageChat, State: factory.PhaseSkipped}, + {Name: "draft", Kind: factory.StageChat, State: factory.PhaseRunning}, + {Name: "review", Kind: factory.StageChat, State: factory.PhasePending}, + }}} + for i := 2; i < len(stages); i++ { + if got := stageBrief(Job{Item: item, Stage: stages[i], Index: i}); strings.Contains(got, "attached body") != (i == 2) { + t.Fatalf("step %q context presence is wrong", stages[i].Name) + } + } +} + +// E9/M1: disabled, excluded and skipped plans cannot consume another conversation's context. +func TestJobContextBriefInactivePlan(t *testing.T) { + for _, mode := range []string{"disabled", "excluded", "skipped", "check"} { + for _, compiled := range []bool{false, true} { + if mode == "skipped" && !compiled { + continue + } + t.Run(mode+map[bool]string{false: "/stages", true: "/compiled"}[compiled], func(t *testing.T) { + plan := factory.Stage{Name: "plan", Kind: factory.StageChat, On: true} + switch mode { + case "disabled": + plan.On = false + case "excluded": + plan.When = "large" + case "check": + plan.Kind = factory.StageCheck + } + stages := []factory.Stage{plan, {Name: "write", Kind: factory.StageChat, On: true}, {Name: "review", Kind: factory.StageChat, On: true}} + it := factory.Item{Stages: stages, Context: []factory.JobContext{{Title: "reference", Markdown: "attached body"}}} + if compiled { + phases := []factory.Phase{{Name: "write", Kind: factory.StageChat, State: factory.PhaseRunning}, {Name: "review", Kind: factory.StageChat, State: factory.PhasePending}} + if mode == "skipped" { + phases = append([]factory.Phase{{Name: "plan", Kind: factory.StageChat, State: factory.PhaseSkipped}}, phases...) + } + if mode == "check" { + phases = append([]factory.Phase{{Name: "plan", Kind: factory.StageCheck, State: factory.PhaseDone}}, phases...) + } + it.Stream = &factory.Stream{Phases: phases} + } + for i, s := range stages { + brief := stageBrief(Job{Item: it, Stage: s, Index: i}) + if strings.Contains(brief, "attached body") != (s.Name == "write") { + t.Fatalf("%s consumed context incorrectly", s.Name) + } + } + }) + } + } +} + +// E9/M1: a compiled conversation plan wins even when it is not the first phase. +func TestJobContextBriefCompiledPlan(t *testing.T) { + stages := []factory.Stage{{Name: "inspect", Kind: factory.StageChat, On: true}, {Name: "Plan", Kind: factory.StageChat, On: true}, {Name: "write", Kind: factory.StageChat, On: true}} + it := factory.Item{Stages: stages, Context: []factory.JobContext{{Title: "reference", Markdown: "attached body"}}, Stream: &factory.Stream{Phases: []factory.Phase{ + {Name: "inspect", Kind: factory.StageChat, State: factory.PhaseDone}, + {Name: "Plan", State: factory.PhaseRunning}, + {Name: "write", Kind: factory.StageChat, State: factory.PhasePending}, + }}} + for i, s := range stages { + if brief := stageBrief(Job{Item: it, Stage: s, Index: i}); strings.Contains(brief, "attached body") != (s.Name == "Plan") { + t.Fatalf("step %s context recipient is wrong", s.Name) + } + } +} + +// E9/M1: a plan skipped after inspect defers context to write, which keeps it +// after finishing; a plan that opened a chat keeps priority even if later skipped. +func TestJobContextBriefAfterConditionalPlan(t *testing.T) { + for _, mode := range []string{"skipped", "runs", "runs then skipped"} { + runPlan := mode != "skipped" + path, recipient := "docs/guide.md", 2 + if runPlan { + path, recipient = "auth/login.go", 1 + } + t.Run(mode, func(t *testing.T) { + stages := []factory.Stage{ + {Name: "inspect", Kind: factory.StageChat, On: true}, + {Name: "plan", Kind: factory.StageChat, On: true, When: "touches auth"}, + {Name: "write", Kind: factory.StageChat, On: true}, + {Name: "review", Kind: factory.StageChat, On: true}, + {Name: "summarize", Kind: factory.StageChat, On: true}, + } + attachments := []factory.JobContext{ + {Title: "reference alpha", Source: "guide alpha", Markdown: "alpha beginning\n" + strings.Repeat("é", 59900) + "\nalpha end"}, + {Title: "reference beta", Source: "guide beta", Markdown: "beta beginning\n\nbeta end"}, + } + it := factory.Item{Stages: stages, Context: attachments, Files: []factory.FileChange{{Path: path}}} + if factory.Fits(stages[1], it) != runPlan { + t.Fatal("conditional plan does not match the scenario") + } + it.Stream = &factory.Stream{Phases: loopPhases(it)} + if len(it.Stream.Phases) != len(stages) { + t.Fatal("file-conditional plan must stay pending until its turn") + } + for i, stage := range stages { + it.Stream.Cur = i + if i == 1 && !runPlan { + it.Stream.Phases[i].State = factory.PhaseSkipped + continue + } + it.Stream.Phases[i].State = factory.PhaseRunning + brief := stageBrief(Job{Item: it, Stage: stage, Index: i, Round: 1}) + want := 0 + if i == recipient { + want = 1 + } + for _, attachment := range attachments { + for _, part := range []string{attachment.Title, attachment.Source, attachment.Markdown} { + if got := strings.Count(brief, part); got != want { + t.Fatalf("step %q has %d copies of attachment %q content; want %d", stage.Name, got, attachment.Title, want) + } + } + } + it.Stream.Phases[i].State = factory.PhaseDone + if i == 1 && runPlan { + it.Stream.Phases[i].Chat = "plan-conversation" + if mode == "runs then skipped" { + it.Files[0].Path = "docs/guide.md" + it.Stream.Phases[i].State = factory.PhaseSkipped + } + } + } + }) + } +} diff --git a/internal/factory/run/split_from_test.go b/internal/factory/run/split_from_test.go new file mode 100644 index 0000000000..8d3c1e5686 --- /dev/null +++ b/internal/factory/run/split_from_test.go @@ -0,0 +1,24 @@ +package run + +import ( + "testing" + + "github.com/Agent-Field/codeaf/internal/factory" +) + +// S6: split lineage leaves each step's brief unchanged. +func TestSplitFromDoesNotChangeStepBriefs(t *testing.T) { + for _, name := range []string{"plan", "write", "review", "proof"} { + t.Run(name, func(t *testing.T) { + job := handoverJob(factory.Stage{Name: name, Kind: factory.StageChat, On: true, Ask: "Do this step"}) + job.Item.Body = "Only the first part" + job.Notes = []string{"Keep the public API"} + job.Item.Context = []factory.JobContext{{Title: "Reference", Markdown: "The first part uses the existing parser"}} + before := stageBrief(job) + job.Item.SplitFrom = 80 + if got := stageBrief(job); got != before { + t.Fatal("split lineage changed the step brief") + } + }) + } +} diff --git a/internal/factory/serve/idempotency.go b/internal/factory/serve/idempotency.go index 872f553e21..2324d8c230 100644 --- a/internal/factory/serve/idempotency.go +++ b/internal/factory/serve/idempotency.go @@ -113,6 +113,11 @@ func (h *Handler) idempotent(w http.ResponseWriter, r *http.Request) { // This bridge still has a handler-commit/response-cache crash gap; only an // acknowledgment committed with the job itself can close it. func cacheWorkerResponse(r *http.Request, row replay) bool { + // Replaying a try-again answer (5xx, 408, 429) for a day defeats the retry + // it asks for, on every keyed route and not only a worker's. + if api.WorkerResendable(row.Status) { + return false + } if api.ReportCommittedNothing(row.Status) && (strings.HasPrefix(r.URL.Path, "/v1/workers/leases/") || strings.HasPrefix(r.URL.Path, "/v1/runners/leases/")) && strings.HasSuffix(r.URL.Path, "/report") { diff --git a/internal/factory/serve/idempotency_transient_test.go b/internal/factory/serve/idempotency_transient_test.go new file mode 100644 index 0000000000..0bdf35c9be --- /dev/null +++ b/internal/factory/serve/idempotency_transient_test.go @@ -0,0 +1,145 @@ +package serve + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "net/http" + "net/http/httptest" + "os" + "path/filepath" + "strconv" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/factory/store" +) + +func transientReplayHandler(t *testing.T, durable bool) (*Handler, *time.Time) { + t.Helper() + clock := time.Date(2026, 11, 5, 12, 0, 0, 0, time.UTC) + h := &Handler{mux: http.NewServeMux(), config: Config{Now: func() time.Time { return clock }}} + if durable { + var err error + h.config.Store, err = store.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + } + return h, &clock +} + +func transientKeyedRequest() *http.Request { + r := httptest.NewRequest("POST", "/v1/jobs?mode=test", strings.NewReader(`{}`)) + r.Header.Set("Authorization", "Bearer reader") + r.Header.Set("Cookie", "session=reader") + r.Header.Set("Idempotency-Key", "same-attempt") + return r +} + +func seedTransientReplay(t *testing.T, h *Handler, status int) { + t.Helper() + r := transientKeyedRequest() + sum := sha256.Sum256([]byte(r.Header.Get("Authorization") + "\x00" + r.Header.Get("Cookie") + "\x00" + r.URL.RequestURI() + "\x00" + r.Header.Get("Idempotency-Key"))) + id := hex.EncodeToString(sum[:]) + row := replay{At: h.clock(), Status: status, Body: []byte("old transient answer")} + if h.config.Store == nil { + h.replays.rows = map[string]replay{id: row} + return + } + dir := filepath.Join(h.config.Store.Root(), "idempotency") + if err := os.MkdirAll(dir, 0700); err != nil { + t.Fatal(err) + } + raw, err := json.Marshal(row) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(filepath.Join(dir, id+".json"), raw, 0600); err != nil { + t.Fatal(err) + } +} + +// C1, C2, C4: A transient answer reaches its caller once, then the same key +// runs again and replays its success. Older memory and disk rows are bypassed. +func TestAKeyedServerErrorRunsAgainOnRetry(t *testing.T) { + for _, durable := range []bool{false, true} { + for _, seeded := range []bool{false, true} { + for _, status := range []int{500, 503, 429} { + name := strconv.Itoa(status) + map[bool]string{false: "/memory", true: "/disk"}[durable] + map[bool]string{false: "/fresh", true: "/old-row"}[seeded] + t.Run(name, func(t *testing.T) { + h, _ := transientReplayHandler(t, durable) + if seeded { + seedTransientReplay(t, h, status) + } + calls := 0 + h.mux.HandleFunc("/v1/jobs", func(w http.ResponseWriter, _ *http.Request) { + calls++ + code, body := 200, "accepted" + if !seeded && calls == 1 { + code, body = status, "retry this answer" + } + w.Header().Set("X-Answer", body) + w.WriteHeader(code) + _, _ = w.Write([]byte(body)) + }) + for attempt := range 3 { + w := httptest.NewRecorder() + h.idempotent(w, transientKeyedRequest()) + wantStatus, wantBody, wantCalls := 200, "accepted", 2 + if seeded { + wantCalls = 1 + if durable { + // A fresh handler must read the replacement from disk. + h = &Handler{mux: h.mux, config: h.config} + } + } else if attempt == 0 { + wantStatus, wantBody, wantCalls = status, "retry this answer", 1 + } + if w.Code != wantStatus || w.Body.String() != wantBody || w.Header().Get("X-Answer") != wantBody || calls != wantCalls { + t.Fatalf("attempt %d = %d %q, header=%q calls=%d; want %d %q calls=%d", attempt, w.Code, w.Body.String(), w.Header().Get("X-Answer"), calls, wantStatus, wantBody, wantCalls) + } + } + }) + } + } + } +} + +// C3: Successful and definitive refusal answers still replay for 24 hours +// in memory and across a store-backed restart, then expire as before. +func TestKeyedSuccessAndRefusalsStillReplay(t *testing.T) { + for _, durable := range []bool{false, true} { + for _, status := range []int{200, 422} { + t.Run(strconv.Itoa(status)+map[bool]string{false: "/memory", true: "/disk"}[durable], func(t *testing.T) { + h, clock := transientReplayHandler(t, durable) + calls := 0 + h.mux.HandleFunc("/v1/jobs", func(w http.ResponseWriter, _ *http.Request) { + calls++ + w.WriteHeader(status) + _, _ = w.Write([]byte(strconv.Itoa(calls))) + }) + for attempt := range 3 { + w := httptest.NewRecorder() + h.idempotent(w, transientKeyedRequest()) + wantCalls := 1 + if attempt == 2 { + wantCalls = 2 + } + if w.Code != status || w.Body.String() != strconv.Itoa(wantCalls) || calls != wantCalls { + t.Fatalf("attempt %d = %d %q calls=%d, want status=%d calls=%d", attempt, w.Code, w.Body.String(), calls, status, wantCalls) + } + if attempt == 0 { + *clock = clock.Add(24*time.Hour - time.Nanosecond) + if durable { + h = &Handler{mux: h.mux, config: h.config} + } + } else { + *clock = clock.Add(time.Nanosecond) + } + } + }) + } + } +} diff --git a/internal/factory/serve/job_context.go b/internal/factory/serve/job_context.go new file mode 100644 index 0000000000..2588aaf316 --- /dev/null +++ b/internal/factory/serve/job_context.go @@ -0,0 +1,84 @@ +package serve + +import ( + "fmt" + "net/http" + "slices" + "strings" + "unicode/utf8" + + "github.com/Agent-Field/codeaf/internal/factory" + "github.com/Agent-Field/codeaf/internal/factory/api" + "github.com/Agent-Field/codeaf/internal/factory/record" +) + +// setJobContext uses the model edit's caller rule and the version read by the +// person. The store checks it again under its lock before replacing attachments. +func (h *Handler) setJobContext(w http.ResponseWriter, r *http.Request, it factory.Item) { + var body api.JobContextRequest + if !h.decode(w, r, &body, false) { + return + } + if body.Expect == nil || body.Expect.Version == nil || *body.Expect.Version < 1 { + h.fail(w, http.StatusUnprocessableEntity, "expect.version is required: send the version of the job the person read") + return + } + if err := validateJobContext(body.Context); err != nil { + h.fail(w, http.StatusUnprocessableEntity, err.Error()) + return + } + if !h.fresh(w, r, it, "context", body.Expect) { + return + } + if h.failed(w, contextEditable(it)) { + return + } + err := h.recordAction(r, it, record.ContextSet, fmt.Sprintf("%d attachments", len(body.Context)), func() error { + return h.config.Store.UpdateExpected(it.ID, *body.Expect.Version, func(cur *factory.Item) error { + if err := contextEditable(*cur); err != nil { + return err + } + cur.Context = slices.Clone(body.Context) + return nil + }) + }) + h.finish(w, err) +} + +func contextEditable(it factory.Item) error { + if it.Stream != nil { + return factory.EditRefusal{Cause: fmt.Errorf("The job has started; its steps already read their context.")} + } + if it.State != factory.StateNew && it.State != factory.StateQueued { + return factory.EditRefusal{Cause: fmt.Errorf("Context can only be set in Backlog or queued before the job's first run.")} + } + return nil +} + +func validateJobContext(attachments []factory.JobContext) error { + if attachments == nil { + return fmt.Errorf("context is required: send [] to clear attachments") + } + if len(attachments) > factory.MaxJobContextItems { + return fmt.Errorf("context takes at most %d items", factory.MaxJobContextItems) + } + total := 0 + for i := range attachments { + c := &attachments[i] + c.Title = strings.TrimSpace(c.Title) + if n := utf8.RuneCountInString(c.Title); n < 1 || n > factory.MaxJobContextTitle { + return fmt.Errorf("context title must be 1–%d characters after trimming", factory.MaxJobContextTitle) + } + if utf8.RuneCountInString(c.Source) > factory.MaxJobContextSource { + return fmt.Errorf("context source must be at most %d characters", factory.MaxJobContextSource) + } + if strings.TrimSpace(c.Markdown) == "" { + return fmt.Errorf("context markdown must be non-empty") + } + total += utf8.RuneCountInString(c.Markdown) + } + if total > factory.MaxJobContext { + return fmt.Errorf("context markdown must total at most %d characters", factory.MaxJobContext) + } + return nil +} diff --git a/internal/factory/serve/job_context_test.go b/internal/factory/serve/job_context_test.go new file mode 100644 index 0000000000..247f23e36e --- /dev/null +++ b/internal/factory/serve/job_context_test.go @@ -0,0 +1,194 @@ +package serve_test + +import ( + "context" + "encoding/json" + "fmt" + "net/http" + "reflect" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/factory" + "github.com/Agent-Field/codeaf/internal/factory/api" + "github.com/Agent-Field/codeaf/internal/factory/record" +) + +func contextBody(t *testing.T, version int, attachments []factory.JobContext) string { + t.Helper() + raw, err := json.Marshal(api.JobContextRequest{Context: attachments, Expect: &api.Expect{Version: &version}}) + if err != nil { + t.Fatal(err) + } + return string(raw) +} + +// E7'/E9/E13: Backlog and queued jobs replace and clear context outside text; +// ordinary reads carry metadata, while worker admission retains complete markdown. +func TestJobContextReplaceClearAndRead(t *testing.T) { + for _, state := range []factory.State{factory.StateNew, factory.StateQueued} { + t.Run(string(state), func(t *testing.T) { + f := newCreateExtrasJobs(t) + text, markdown := strings.Repeat("d", 20000), strings.Repeat("é", 60000) + it := createExtrasJob(t, f, "/v1/jobs", api.NewJob{Repo: "acme/api", Title: "Fix", Text: text, Source: factory.AskApp}) + if err := f.st.Update(it.ID, func(cur *factory.Item) error { cur.State = state; return nil }); err != nil { + t.Fatal(err) + } + attachments := []factory.JobContext{{Title: " reference ", Source: "guide", Markdown: markdown}} + before := f.read(t, it) + f.post(t, it, "context", contextBody(t, before.Version, attachments), http.StatusOK) + stored, err := f.st.Get(it.ID) + if err != nil { + t.Fatal(err) + } + if stored.Body != text || stored.Ask.Text != text || len(stored.Context) != 1 || stored.Context[0].Title != "reference" || stored.Context[0].Markdown != markdown { + t.Fatal("context changed description or lost text") + } + for _, path := range []string{fmt.Sprintf("/v1/jobs/%d", it.ID), "/v1/queue"} { + w := verbRequest(f.h, http.MethodGet, path, "") + checkJSON(t, w, http.StatusOK) + if strings.Contains(w.Body.String(), "markdown") || !strings.Contains(w.Body.String(), `"context":[{"title":"reference","source":"guide"}]`) { + t.Fatalf("metadata-only read: %s", path) + } + } + raw, err := json.Marshal(api.WorkerJob(stored)) + if err != nil { + t.Fatal(err) + } + var worker api.Job + if err := json.Unmarshal(raw, &worker); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(api.ToItem(worker).Context, stored.Context) { + t.Fatal("worker lost attachment text") + } + f.post(t, it, "context", contextBody(t, before.Version, []factory.JobContext{}), http.StatusConflict) + current := f.read(t, it) + f.post(t, it, "context", contextBody(t, current.Version, []factory.JobContext{{Title: "replacement", Markdown: "new body"}}), http.StatusOK) + current = f.read(t, it) + if len(current.Context) != 1 || current.Context[0].Title != "replacement" { + t.Fatal("replacement appended context") + } + f.post(t, it, "context", contextBody(t, current.Version, []factory.JobContext{}), http.StatusOK) + if current = f.read(t, it); len(current.Context) != 0 { + t.Fatal("empty list did not clear context") + } + events, err := f.rec.Since(context.Background(), time.Time{}) + if err != nil { + t.Fatal(err) + } + count := 0 + for _, e := range events { + if e.Job == it.ID && e.Kind == record.ContextSet { + count++ + if e.Handle != "sam" || e.Verb != "set context" || strings.Contains(e.Detail, markdown) { + t.Fatalf("context activity: %+v", e) + } + } + } + if count != 3 { + t.Fatalf("context activity count %d; want 3", count) + } + }) + } +} + +// E8'/E13: refusals leave attachments, revision and activity unchanged. +func TestJobContextRefusalsHaveNoEffects(t *testing.T) { + cases := []struct { + name string + state factory.State + stream bool + person string + body string + status int + words string + }{ + {"started", factory.StateRunning, true, "sam", "", 409, "has started"}, + {"returned to backlog", factory.StateNew, true, "sam", "", 409, "has started"}, + {"queued after run", factory.StateQueued, true, "sam", "", 409, "has started"}, + {"other person", factory.StateNew, false, "alex", "", 403, ""}, + {"finished", factory.StateShipped, false, "sam", "", 409, "Backlog"}, + {"stale", factory.StateNew, false, "sam", `{"expect":{"version":999},"context":[]}`, 409, "stale"}, + {"missing expect", factory.StateNew, false, "sam", `{"context":[]}`, 422, "expect.version"}, + {"missing context", factory.StateNew, false, "sam", `{"expect":{"version":1}}`, 422, "context"}, + {"null context", factory.StateNew, false, "sam", `{"expect":{"version":1},"context":null}`, 422, "context"}, + {"title", factory.StateNew, false, "sam", `{"expect":{"version":1},"context":[{"title":" ","markdown":"body"}]}`, 422, "title"}, + {"markdown", factory.StateNew, false, "sam", `{"expect":{"version":1},"context":[{"title":"t","markdown":""}]}`, 422, "markdown"}, + {"oversized", factory.StateNew, false, "sam", `{"expect":{"version":1},"context":[{"title":"t","markdown":"` + strings.Repeat("x", 100001) + `"}]}`, 422, "100000"}, + } + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + f := newV2Jobs(t) + it := factory.Item{State: tc.state, Assignee: tc.person, Context: []factory.JobContext{{Title: "original", Markdown: "keep me"}}} + if tc.stream { + it.Stream = recordedRun() + } + it = f.create(t, it) + before := f.read(t, it) + body := tc.body + if body == "" { + body = contextBody(t, before.Version, []factory.JobContext{}) + } + w := f.post(t, it, "context", body, tc.status) + if !strings.Contains(w.Body.String(), tc.words) { + t.Fatalf("refusal: %s", w.Body) + } + if got := f.read(t, it); got.Version != before.Version || !reflect.DeepEqual(got.Context, before.Context) { + t.Fatal("refused edit changed job") + } + events, err := f.rec.Since(context.Background(), time.Time{}) + if err != nil || len(events) != 0 { + t.Fatalf("refused edit activity: %d %v", len(events), err) + } + }) + } +} + +// E14: a route that exists answers its ordinary JSON job-not-found refusal. +func TestJobContextMissingJob(t *testing.T) { + f := newV2Jobs(t) + w := verbRequest(f.h, http.MethodPost, "/v1/jobs/999/context", `{"expect":{"version":1},"context":[]}`) + checkJSON(t, w, http.StatusNotFound) + if !strings.Contains(w.Body.String(), `"error":"job not found"`) { + t.Fatalf("missing job: %s", w.Body) + } +} + +// E7'/E13: editing steps before the first run does not make context immutable. +func TestJobContextAfterBacklogStepEdit(t *testing.T) { + f := newV2Jobs(t) + f.seam.Edit = factory.LocalSeam(f.st, now).Edit + reopenJobs(t, f) + it := f.create(t, factory.Item{State: factory.StateNew, Assignee: "sam", Stages: []factory.Stage{ + {Name: "write", Kind: factory.StageChat, Ask: "old ask", On: true}, + }}) + f.post(t, it, "step-edits", `{"ask":{"write":"new ask"},"why":"clarify scope"}`, http.StatusOK) + before := f.read(t, it) + f.post(t, it, "context", contextBody(t, before.Version, []factory.JobContext{{Title: "reference", Markdown: "attached body"}}), http.StatusOK) + if got := f.read(t, it); len(got.Context) != 1 { + t.Fatal("pre-run edit prevented context attachment") + } +} + +// E13: a job brought back has no run, so its next run reads whatever context +// it is given now, whether or not it ran before the drop. +func TestJobContextAfterRestore(t *testing.T) { + for _, ran := range []bool{false, true} { + t.Run(fmt.Sprintf("ran=%v", ran), func(t *testing.T) { + f := newV2Jobs(t) + it := factory.Item{State: factory.StateDismissed, Assignee: "sam", Context: []factory.JobContext{{Title: "reference", Markdown: "original body"}}} + if ran { + it.Stream = &factory.Stream{Started: now, Ended: now, Phases: []factory.Phase{{Name: "plan", Kind: factory.StageChat, State: factory.PhaseDone}}} + } + it = f.create(t, it) + f.post(t, it, "restore", "{}", http.StatusOK) + before := f.read(t, it) + f.post(t, it, "context", contextBody(t, before.Version, []factory.JobContext{}), http.StatusOK) + if after := f.read(t, it); len(after.Context) != 0 { + t.Fatalf("brought-back job kept its old context: %+v", after.Context) + } + }) + } +} diff --git a/internal/factory/serve/job_events.go b/internal/factory/serve/job_events.go new file mode 100644 index 0000000000..e3a375dbd2 --- /dev/null +++ b/internal/factory/serve/job_events.go @@ -0,0 +1,107 @@ +package serve + +import ( + "fmt" + "net/http" + "net/url" + "strconv" + "time" + + "github.com/Agent-Field/codeaf/internal/factory/api" + "github.com/Agent-Field/codeaf/internal/factory/live" +) + +type jobEventsQuery struct { + after *int64 + limit int + wait time.Duration +} + +func parseJobEvents(q url.Values) (jobEventsQuery, error) { + out := jobEventsQuery{limit: 100} + if q.Has("after") { + n, err := strconv.ParseInt(q.Get("after"), 10, 64) + if err != nil || n < 0 || len(q["after"]) != 1 { + return out, fmt.Errorf("after must be an integer from 0") + } + out.after = &n + } + if q.Has("limit") { + n, err := strconv.Atoi(q.Get("limit")) + if err != nil || n < 1 || n > 500 || len(q["limit"]) != 1 { + return out, fmt.Errorf("limit must be an integer from 1 to 500") + } + out.limit = n + } + if q.Has("wait") { + n, err := strconv.Atoi(q.Get("wait")) + if err != nil || n < 0 || n > 15 || len(q["wait"]) != 1 { + return out, fmt.Errorf("wait must be an integer from 0 to 15") + } + out.wait = time.Duration(n) * time.Second + } + return out, nil +} + +// jobEvents joins before reading so an outcome between the read and wait is kept. +func (h *Handler) jobEvents(w http.ResponseWriter, r *http.Request) { + q, err := parseJobEvents(r.URL.Query()) + if err != nil { + h.fail(w, http.StatusBadRequest, err.Error()) + return + } + var ticks chan string + if q.wait > 0 && h.watch != nil { + ticks = h.watch.hub.join() + defer h.watch.hub.leave(ticks) + } + timer := time.NewTimer(q.wait) + defer timer.Stop() + for { + out, err := h.visibleJobEvents(r, q.after, q.limit) + if h.failed(w, err) || h.authFailed(w, r) { + return + } + if out.Reset || len(out.Events) != 0 || ticks == nil { + h.write(w, http.StatusOK, out) + return + } + // Move past hidden outcomes even when the long poll continues waiting. + cursor := out.Cursor + q.after = &cursor + if out.More { + continue + } + select { + case <-r.Context().Done(): + return + case <-timer.C: + h.write(w, http.StatusOK, out) + return + case <-ticks: + } + } +} + +func (h *Handler) visibleJobEvents(r *http.Request, after *int64, limit int) (api.JobEvents, error) { + got, err := live.ReadOutcomes(h.eventRoot(), after, limit) + out := api.JobEvents{Events: []api.JobEvent{}, Cursor: got.Cursor, Reset: got.Reset, More: got.More} + for _, e := range got.Events { + if h.jobEventVisible(r, e.Job) { + out.Events = append(out.Events, e) + } + } + return out, err +} + +// Outcome cursors check repository visibility without hydrating author access. +func (h *Handler) jobEventVisible(r *http.Request, id int) bool { + if h.config.Store == nil { + return h.viewerWantsLive(r, live.Event{Kind: live.OutcomeKind, Job: id}) + } + if _, filter := h.repoAccess(r, nil); !filter { + return true + } + it, err := h.config.Store.GetSaved(id) + return err == nil && h.repoVisible(r, itemRepo(it)) +} diff --git a/internal/factory/serve/job_events_test.go b/internal/factory/serve/job_events_test.go new file mode 100644 index 0000000000..4b72aa523d --- /dev/null +++ b/internal/factory/serve/job_events_test.go @@ -0,0 +1,200 @@ +package serve + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/factory" + "github.com/Agent-Field/codeaf/internal/factory/api" + "github.com/Agent-Field/codeaf/internal/factory/live" + "github.com/Agent-Field/codeaf/internal/guard" +) + +func jobEventsHandler(t *testing.T, root string) *Handler { + t.Helper() + h := New(factory.Seam{}, Config{Root: root, AccessToken: "outcome-test-token", Mode: api.FactorySolo}) + t.Cleanup(h.Close) + return h +} + +func jobEventsRequest(h *Handler, query string) *httptest.ResponseRecorder { + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + r := httptest.NewRequest(http.MethodGet, "/v1/job-events"+query, nil).WithContext(ctx) + r.Header.Set("Authorization", "Bearer outcome-test-token") + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + return w +} + +func decodeJobEvents(t *testing.T, w *httptest.ResponseRecorder) api.JobEvents { + t.Helper() + var got api.JobEvents + if w.Code != http.StatusOK || json.Unmarshal(w.Body.Bytes(), &got) != nil || got.Events == nil { + t.Fatalf("job events: %d %s", w.Code, w.Body.String()) + } + return got +} + +// E3, E5, E6: the HTTP cursor survives a handler restart; live ticks do not count. +func TestJobEventsReadCursorAndHandlerRestart(t *testing.T) { + root := t.TempDir() + h := jobEventsHandler(t, root) + initial := decodeJobEvents(t, jobEventsRequest(h, "")) + if !initial.Reset || initial.Cursor != 0 || len(initial.Events) != 0 { + t.Fatalf("initial: %+v", initial) + } + zero := decodeJobEvents(t, jobEventsRequest(h, "?after=0")) + if zero.Reset || zero.Cursor != 0 { + t.Fatalf("zero: %+v", zero) + } + if err := live.Append(root, live.New(live.OutcomeKind, 1, live.Outcome{Job: 1, Kind: "finished"}), live.New("machine", 0, live.Payload{})); err != nil { + t.Fatal(err) + } + h.Close() + reopened := jobEventsHandler(t, root) + got := decodeJobEvents(t, jobEventsRequest(reopened, "?after=0&limit=1")) + if got.Reset || got.Cursor != 1 || got.More || len(got.Events) != 1 || got.Events[0].Seq != 1 { + t.Fatalf("reopened: %+v", got) + } + got = decodeJobEvents(t, jobEventsRequest(reopened, "?after=1")) + if got.Reset || got.Cursor != 1 || len(got.Events) != 0 { + t.Fatalf("caught up: %+v", got) + } +} + +// E5, E14: malformed values are 400; a handler without a root resets with []. +func TestJobEventsQueryValidationAndEmptyRoot(t *testing.T) { + h := jobEventsHandler(t, "") + for _, query := range []string{"?after=-1", "?after=x", "?after=", "?after=0&after=1", "?limit=0", "?limit=501", "?limit=x", "?wait=-1", "?wait=16", "?wait=0.5", "?wait="} { + w := jobEventsRequest(h, query) + if w.Code != http.StatusBadRequest || !strings.Contains(w.Body.String(), `"error"`) { + t.Fatalf("%s: %d %s", query, w.Code, w.Body.String()) + } + } + got := decodeJobEvents(t, jobEventsRequest(h, "?after=0&wait=15")) + if !got.Reset || got.Cursor != 0 || got.More || len(got.Events) != 0 { + t.Fatalf("empty root: %+v", got) + } +} + +// E7: repository visibility is identical to live events; hidden seqs are consumed. +func TestJobEventsVisibilityMovesPastHiddenJobs(t *testing.T) { + h, clock, g := visibilityFloor(t, map[string]map[string]bool{"priya": {"acme/api": true}}) + token := signViewer(t, h, g, clock, "priya") + queueAs(t, h, token, false) + for _, job := range []int{1, 2, 3} { + if err := live.Append(h.eventRoot(), live.New(live.OutcomeKind, job, live.Outcome{Job: job, Kind: "finished"})); err != nil { + t.Fatal(err) + } + } + r := httptest.NewRequest(http.MethodGet, "/v1/job-events?after=0&limit=2", nil) + r.RemoteAddr = "192.0.2.1:9" + r.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + h.ServeHTTP(w, r) + got := decodeJobEvents(t, w) + if got.Cursor != 2 || !got.More || len(got.Events) != 1 || got.Events[0].Job != 1 { + t.Fatalf("visible outcomes: %+v", got) + } + r.URL.RawQuery = "after=2" + w = httptest.NewRecorder() + h.ServeHTTP(w, r) + got = decodeJobEvents(t, w) + if got.Cursor != 3 || got.More || len(got.Events) != 1 || got.Events[0].Job != 3 { + t.Fatalf("continued: %+v", got) + } + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + r = r.WithContext(ctx) + r.URL.RawQuery = "after=1&limit=1&wait=15" + w = httptest.NewRecorder() + h.ServeHTTP(w, r) + got = decodeJobEvents(t, w) + if got.Cursor != 3 || got.More || len(got.Events) != 1 || got.Events[0].Job != 3 { + t.Fatalf("long poll past hidden outcome: %+v", got) + } +} + +func waitForJobEventsJoin(t *testing.T, h *Handler) { + t.Helper() + timer := time.NewTimer(time.Second) + defer timer.Stop() + tick := time.NewTicker(time.Millisecond) + defer tick.Stop() + joined := func() bool { + h.watch.hub.mu.Lock() + defer h.watch.hub.mu.Unlock() + return len(h.watch.hub.subs) != 0 + } + for !joined() { + select { + case <-tick.C: + case <-timer.C: + t.Fatal("long poll did not join") + } + } +} + +// E11: an empty read joins first, then returns promptly on the next outcome. +func TestJobEventsLongPollReturnsOnOutcome(t *testing.T) { + root := t.TempDir() + h := jobEventsHandler(t, root) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + r := httptest.NewRequest(http.MethodGet, "/v1/job-events?after=0&wait=15", nil).WithContext(ctx) + r.Header.Set("Authorization", "Bearer outcome-test-token") + w := httptest.NewRecorder() + done := make(chan struct{}) + guard.Go("job outcome long poll test", func() { + defer close(done) + h.ServeHTTP(w, r) + }) + waitForJobEventsJoin(t, h) + if err := live.Append(root, live.New(live.OutcomeKind, 1, live.Outcome{Job: 1, Kind: "finished"})); err != nil { + t.Fatal(err) + } + h.watch.hub.send("1") + select { + case <-done: + case <-time.After(time.Second): + t.Fatal("outcome did not release the long poll") + } + got := decodeJobEvents(t, w) + if got.Reset || len(got.Events) != 1 || got.Cursor != 1 { + t.Fatalf("long poll: %+v", got) + } +} + +// E11: existing events and resets are immediate, idle waits end at their bound. +func TestJobEventsLongPollImmediateAndTimeout(t *testing.T) { + root := t.TempDir() + h := jobEventsHandler(t, root) + if err := live.Append(root, live.New(live.OutcomeKind, 1, live.Outcome{Job: 1, Kind: "finished"})); err != nil { + t.Fatal(err) + } + for _, query := range []string{"?wait=15", "?after=0&wait=15", "?after=99&wait=15"} { + start := time.Now() + got := decodeJobEvents(t, jobEventsRequest(h, query)) + if time.Since(start) > time.Second || (!got.Reset && len(got.Events) != 1) { + t.Fatalf("immediate %s: %+v", query, got) + } + } + start := time.Now() + got := decodeJobEvents(t, jobEventsRequest(h, "?after=1&wait=1")) + if elapsed := time.Since(start); elapsed < 900*time.Millisecond || elapsed > 2*time.Second || got.Reset || got.Cursor != 1 || len(got.Events) != 0 { + t.Fatalf("idle: %+v after %v", got, elapsed) + } + withoutWatch := &Handler{config: Config{Root: root}} + w := httptest.NewRecorder() + start = time.Now() + withoutWatch.jobEvents(w, httptest.NewRequest(http.MethodGet, "/v1/job-events?after=1&wait=15", nil)) + if got := decodeJobEvents(t, w); time.Since(start) > time.Second || got.Cursor != 1 { + t.Fatalf("no watch: %+v", got) + } +} diff --git a/internal/factory/serve/job_screens.go b/internal/factory/serve/job_screens.go index ffc2c093fa..94e498213a 100644 --- a/internal/factory/serve/job_screens.go +++ b/internal/factory/serve/job_screens.go @@ -316,7 +316,7 @@ func (h *Handler) searchIssues(w http.ResponseWriter, r *http.Request) { return } if !h.repoVisible(r, repo) { - if !h.authFailed(w, r) { + if !h.authFailed(w, r) && !h.githubVisibilityLimited(w, r) { h.fail(w, 404, "repository not found") } return diff --git a/internal/factory/serve/jobs.go b/internal/factory/serve/jobs.go index ffda4d14b3..c01c5f0dfd 100644 --- a/internal/factory/serve/jobs.go +++ b/internal/factory/serve/jobs.go @@ -25,6 +25,7 @@ import ( // jobVerbs keeps canvas actions additive to the existing /v1 doors. func (h *Handler) jobVerbs() { stored := h.config.Store != nil + h.postJob("context", stored, h.onlyOwner(h.setJobContext)) h.postJob("publish_anyway", stored && h.seam.Answer != nil, h.publishAnyway) h.postJob("start_now", h.seam.Launch != nil, h.guardedStart()) h.postJob("run-as-new", stored && h.seam.NewJob != nil && h.seam.Launch != nil, h.onlyOwner(h.runAsNew)) @@ -254,6 +255,7 @@ func (h *Handler) launchJob(w http.ResponseWriter, r *http.Request, it factory.I if cur.State != factory.StateQueued { return fmt.Errorf("%s is no longer queued", cur.Ref()) } + factory.ClearPolicyRun(cur) cur.OverLimit = true if cur.Stream != nil { cur.Stream.Paused = false diff --git a/internal/factory/serve/new_job_extras.go b/internal/factory/serve/new_job_extras.go new file mode 100644 index 0000000000..227ed88d4e --- /dev/null +++ b/internal/factory/serve/new_job_extras.go @@ -0,0 +1,86 @@ +package serve + +import ( + "errors" + "fmt" + "net/http" + "slices" + "strings" + "unicode" + "unicode/utf8" + + "github.com/Agent-Field/codeaf/internal/factory" + "github.com/Agent-Field/codeaf/internal/factory/api" + "github.com/Agent-Field/codeaf/internal/factory/record" + "github.com/Agent-Field/codeaf/internal/factory/store" +) + +// checkNewJobExtras runs before any issue, job or startup write. +func (h *Handler) checkNewJobExtras(w http.ResponseWriter, r *http.Request, body *api.NewJob) bool { + if err := validateNewJobExtras(body); err != nil { + h.fail(w, http.StatusUnprocessableEntity, err.Error()) + return false + } + if body.Group != "" || body.Milestone != "" || body.Budget != nil || body.SplitFrom != nil { + if !h.requireVerb(w, h.config.Store != nil) { + return false + } + } + if body.SplitFrom == nil { + return true + } + parent, err := h.config.Store.GetSaved(*body.SplitFrom) + if err != nil && !errors.Is(err, store.ErrNotFound) { + h.failed(w, err) + return false + } + visible := err == nil && factory.IsIssueJob(parent) && h.repoVisible(r, itemRepo(parent)) + if h.authFailed(w, r) { + return false + } + if !visible { + h.fail(w, http.StatusUnprocessableEntity, "split_from must name a visible job in this factory") + return false + } + return true +} + +func validateNewJobExtras(body *api.NewJob) error { + if body.SplitFrom != nil && *body.SplitFrom < 1 { + return fmt.Errorf("split_from must be a job number of at least 1") + } + for field, value := range map[string]*string{"group": &body.Group, "milestone": &body.Milestone} { + if strings.ContainsFunc(*value, unicode.IsControl) || utf8.RuneCountInString(strings.TrimSpace(*value)) > factory.MaxJobLabel { + return fmt.Errorf("%s must be at most %d characters with no control characters", field, factory.MaxJobLabel) + } + *value = strings.TrimSpace(*value) + } + if body.Budget != nil && !slices.Contains(factory.JobBudgetSteps(), *body.Budget) { + return fmt.Errorf("budget must be one of %v US dollars", factory.JobBudgetSteps()) + } + return nil +} + +// stampNewJobExtras leaves the default create untouched and budgets before start. +func (h *Handler) stampNewJobExtras(r *http.Request, id int, body api.NewJob) error { + if body.Group == "" && body.Milestone == "" && body.Budget == nil && body.SplitFrom == nil { + return nil + } + stamp := func() error { + return h.config.Store.Update(id, func(it *factory.Item) error { + it.Group, it.Milestone = body.Group, body.Milestone + if body.SplitFrom != nil { + it.SplitFrom = *body.SplitFrom + it.Note(h.clock(), fmt.Sprintf("split from #%d", it.SplitFrom)) + } + if body.Budget != nil { + it.Cap = *body.Budget + } + return nil + }) + } + if body.Budget != nil { + return h.recordAction(r, factory.Item{ID: id}, record.BudgetChanged, fmt.Sprintf("$%.2f", *body.Budget), stamp) + } + return stamp() +} diff --git a/internal/factory/serve/new_job_extras_test.go b/internal/factory/serve/new_job_extras_test.go new file mode 100644 index 0000000000..dc23b3c700 --- /dev/null +++ b/internal/factory/serve/new_job_extras_test.go @@ -0,0 +1,206 @@ +package serve_test + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/factory" + "github.com/Agent-Field/codeaf/internal/factory/api" + "github.com/Agent-Field/codeaf/internal/factory/record" +) + +func newCreateExtrasJobs(t *testing.T) *v2Jobs { + t.Helper() + f := newV2Jobs(t) + f.seam.NewJob = factory.LocalSeam(f.st, now).NewJob + reopenJobs(t, f) + return f +} + +func createExtrasJob(t *testing.T, f *v2Jobs, path string, body api.NewJob) factory.Item { + t.Helper() + raw, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + w := verbRequest(f.h, http.MethodPost, path, string(raw)) + checkJSON(t, w, http.StatusCreated) + var made api.JobCreated + if err := json.Unmarshal(w.Body.Bytes(), &made); err != nil { + t.Fatal(err) + } + it, err := f.st.Get(made.ID) + if err != nil { + t.Fatal(err) + } + return it +} + +// E5/E6: labels persist and budget survives starts. +func TestNewJobExtrasPersistAndBudgetPrecedesStart(t *testing.T) { + for _, start := range []string{"", "now"} { + t.Run("start="+start, func(t *testing.T) { + f := newCreateExtrasJobs(t) + budget, assignee := 10.0, "sam" + text := strings.Repeat("d", 20000) + it := createExtrasJob(t, f, "/v1/jobs", api.NewJob{ + Repo: "acme/api", Title: "Fix", Text: text, Source: factory.AskApp, Start: start, Assignee: &assignee, + Group: " batch ", Milestone: " release ", Budget: &budget, + }) + if it.Group != "batch" || it.Milestone != "release" || it.Cap != 10 || it.Body != text || it.Ask.Text != text { + t.Fatal("created job lost extras or mixed them into the description") + } + events, err := f.rec.Since(context.Background(), time.Time{}) + if err != nil { + t.Fatal(err) + } + budgetAt, startedAt := -1, -1 + for i, e := range events { + if e.Job == it.ID && e.Kind == record.BudgetChanged { + budgetAt = i + if e.Detail != "$10.00" || e.Handle != "sam" { + t.Fatalf("budget activity: %+v", e) + } + } + if e.Job == it.ID && e.Kind == record.AutoStarted { + startedAt = i + } + } + if budgetAt < 0 || start == "now" && (startedAt < 0 || budgetAt >= startedAt) { + t.Fatalf("activity order: budget=%d start=%d", budgetAt, startedAt) + } + if start == "" { + if it.State != factory.StateNew { + t.Fatalf("backlog state %s", it.State) + } + f.post(t, it, "run", "", http.StatusOK) + } + if got := f.read(t, it); got.Budget != 10 || got.Group != "batch" || got.Milestone != "release" || got.State != api.StateQueued { + t.Fatalf("read after start: budget=%v group=%q milestone=%q state=%q", got.Budget, got.Group, got.Milestone, got.State) + } + for _, path := range []string{fmt.Sprintf("/v1/jobs/%d", it.ID), "/v1/queue"} { + w := verbRequest(f.h, http.MethodGet, path, "") + checkJSON(t, w, http.StatusOK) + if strings.Contains(w.Body.String(), `"context"`) { + t.Fatalf("create unexpectedly stored context: %s", path) + } + } + }) + } +} + +// E2/E5: omitted extras stay absent on ordinary job reads. +func TestNewJobWithoutExtrasKeepsReadShape(t *testing.T) { + f := newCreateExtrasJobs(t) + it := createExtrasJob(t, f, "/v1/jobs", api.NewJob{Repo: "acme/api", Title: "Fix", Source: factory.AskApp}) + w := verbRequest(f.h, http.MethodGet, fmt.Sprintf("/v1/jobs/%d", it.ID), "") + checkJSON(t, w, http.StatusOK) + var got map[string]json.RawMessage + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + for _, field := range []string{"group", "milestone", "context"} { + if _, ok := got[field]; ok { + t.Errorf("absent %s appeared in read", field) + } + } +} + +// E3/E10: one issue is opened with text alone, and replay preserves its job number. +func TestNewIssueJobExtrasAndReplay(t *testing.T) { + f := newCreateExtrasJobs(t) + f.cfg.Mode = api.FactorySolo + f.cfg.GitHubToken = func(context.Context) string { return "issue-fixture-credential" } + calls := 0 + f.cfg.GitHubHTTPClient = &http.Client{Transport: jobsTransport(func(r *http.Request) (*http.Response, error) { + calls++ + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if r.Method != http.MethodPost || r.URL.Path != "/repos/acme/api/issues" || body["title"] != "Fix" || body["body"] != "description only" || len(body) != 3 { + t.Fatalf("GitHub request: %s %s %+v", r.Method, r.URL.Path, body) + } + return &http.Response{StatusCode: http.StatusCreated, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(`{"number":42,"title":"Fix","html_url":"https://github.com/acme/api/issues/42"}`))}, nil + })} + reopenJobs(t, f) + body := `{"repo":"acme/api","title":"Fix","text":"description only","source":"app","group":"batch","milestone":"release","budget":10}` + first := keyedRequest(f.h, "/v1/issues", body, "k1") + second := keyedRequest(f.h, "/v1/issues", body, "k1") + checkJSON(t, first, http.StatusCreated) + checkJSON(t, second, http.StatusCreated) + if first.Body.String() != second.Body.String() || calls != 1 { + t.Fatalf("issue replay changed the receipt or opened %d issues", calls) + } + items, err := f.st.List() + if err != nil || len(items) != 1 { + t.Fatalf("jobs=%d err=%v", len(items), err) + } + it := items[0] + if it.Num != 42 || it.Origin != factory.OriginForge || it.Group != "batch" || it.Cap != 10 { + t.Fatalf("issue job: %+v", it) + } + checkJSON(t, verbRequest(f.h, http.MethodPost, "/v1/jobs", body), http.StatusCreated) + if calls != 1 { + t.Fatal("plain job opened an issue") + } +} + +// E10: store-backed creates replay for the same caller and key, including after reopening. +func TestNewJobCreateKeyReplay(t *testing.T) { + f := newCreateExtrasJobs(t) + body := `{"repo":"acme/api","title":"Fix","source":"app"}` + first := keyedRequest(f.h, "/v1/jobs", body, "k1") + checkJSON(t, first, http.StatusCreated) + reopenJobs(t, f) + second := keyedRequest(f.h, "/v1/jobs", body, "k1") + checkJSON(t, second, http.StatusCreated) + if first.Body.String() != second.Body.String() { + t.Fatal("replay named a different job") + } + third := keyedRequest(f.h, "/v1/jobs", body, "k2") + checkJSON(t, third, http.StatusCreated) + items, err := f.st.List() + if err != nil || len(items) != 2 || first.Body.String() == third.Body.String() { + t.Fatalf("different key did not make a second job: count=%d err=%v", len(items), err) + } +} + +// E12/E5/E6/E15: invalid extras and the removed context field have no effects on either create route. +func TestNewJobInvalidExtrasHaveNoEffects(t *testing.T) { + f := newCreateExtrasJobs(t) + f.cfg.Mode = api.FactorySolo + f.cfg.GitHubToken = func(context.Context) string { return "issue-fixture-credential" } + calls := 0 + f.cfg.GitHubHTTPClient = &http.Client{Transport: jobsTransport(func(r *http.Request) (*http.Response, error) { + calls++ + return nil, fmt.Errorf("invalid extras reached GitHub") + })} + reopenJobs(t, f) + for _, path := range []string{"/v1/jobs", "/v1/issues"} { + for field, value := range map[string]any{ + "budget": 7, "group": strings.Repeat("g", 201), "milestone": "bad\x00label", + "context": []factory.JobContext{{Title: "reference", Markdown: strings.Repeat("x", 100001)}}, + } { + raw, err := json.Marshal(map[string]any{"repo": "acme/api", "title": "Fix", "issue": true, field: value}) + if err != nil { + t.Fatal(err) + } + w := verbRequest(f.h, http.MethodPost, path, string(raw)) + checkJSON(t, w, http.StatusUnprocessableEntity) + if !strings.Contains(w.Body.String(), field) { + t.Fatalf("refusal did not name %s: %s", field, w.Body) + } + } + } + items, err := f.st.List() + if err != nil || len(items) != 0 || calls != 0 { + t.Fatalf("invalid extras: jobs=%d GitHub=%d err=%v", len(items), calls, err) + } +} diff --git a/internal/factory/serve/new_job_split_from_test.go b/internal/factory/serve/new_job_split_from_test.go new file mode 100644 index 0000000000..1b64dcb1d9 --- /dev/null +++ b/internal/factory/serve/new_job_split_from_test.go @@ -0,0 +1,166 @@ +package serve_test + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "os" + "reflect" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/factory" + "github.com/Agent-Field/codeaf/internal/factory/api" + "github.com/Agent-Field/codeaf/internal/factory/store" +) + +// A stored fixture gives S2 its exact job number without minting 79 unrelated jobs. +func splitFromParent(t *testing.T, f *v2Jobs) factory.Item { + t.Helper() + it := factory.Item{ + ID: 80, Repo: "acme/api", Kind: factory.KindIssue, Title: "Large fix", State: factory.StateNew, Assignee: "sam", + Failure: &factory.Failure{Next: factory.FailureStopped, Detail: "Split into smaller jobs"}, + } + raw, err := json.Marshal(map[string]any{"schema": store.Schema, "revision": 1, "budget_revision": 1, "item": it}) + if err != nil { + t.Fatal(err) + } + if err := os.WriteFile(f.st.ItemPath(it.ID), raw, 0o600); err != nil { + t.Fatal(err) + } + return it +} + +// S2: lineage persists before a start, appears on reads and adds one activity line. +func TestNewJobSplitFromPersistsAndLeavesParent(t *testing.T) { + for _, start := range []string{"", "now"} { + t.Run("start="+start, func(t *testing.T) { + f := newCreateExtrasJobs(t) + parent := splitFromParent(t, f) + before := f.read(t, parent) + budget, assignee := 10.0, "sam" + it := createExtrasJob(t, f, "/v1/jobs", api.NewJob{ + Repo: "acme/api", Title: "Smaller fix", Text: "Only the first part", Source: factory.AskApp, + SplitFrom: &parent.ID, Start: start, Assignee: &assignee, Budget: &budget, Group: "batch", Milestone: "release", + }) + reopenJobs(t, f) + got := f.read(t, it) + if it.SplitFrom != 80 || got.SplitFrom != 80 || got.FromJob != 0 || got.Budget != 10 || got.Group != "batch" || got.Milestone != "release" { + t.Fatalf("split job read: %+v", got) + } + if got.Ask == nil || got.Ask.Text != "Only the first part" { + t.Fatal("split lineage changed the ask") + } + if got.Desk == nil { + t.Fatal("job read lost its activity") + } + lines := 0 + for _, event := range got.Desk.Activity { + if event.What == "split from #80" { + lines++ + } + } + if lines != 1 { + t.Fatalf("split activity lines=%d, want 1", lines) + } + if after := f.read(t, parent); !reflect.DeepEqual(before, after) { + t.Fatal("split create changed the parent job read") + } + w := verbRequest(f.h, http.MethodGet, "/v1/queue", "") + checkJSON(t, w, http.StatusOK) + var queue api.Queue + if err := json.Unmarshal(w.Body.Bytes(), &queue); err != nil { + t.Fatal(err) + } + found := false + for _, job := range queue.Jobs { + if job.ID == it.ID { + found = job.SplitFrom == 80 + } + } + if !found { + t.Fatal("queue lost split lineage") + } + }) + } +} + +// S2: an issue-backed job retains split lineage without changing the issue body. +func TestNewIssueSplitFromPersists(t *testing.T) { + f := newCreateExtrasJobs(t) + parent := splitFromParent(t, f) + f.cfg.Mode = api.FactorySolo + f.cfg.GitHubToken = func(context.Context) string { return "split-issue-fixture-credential" } + calls := 0 + f.cfg.GitHubHTTPClient = &http.Client{Transport: jobsTransport(func(r *http.Request) (*http.Response, error) { + calls++ + var body map[string]any + if err := json.NewDecoder(r.Body).Decode(&body); err != nil { + t.Fatal(err) + } + if r.Method != http.MethodPost || r.URL.Path != "/repos/acme/api/issues" || body["body"] != "Only the first part" || len(body) != 3 { + t.Fatalf("issue request: %s %s %+v", r.Method, r.URL.Path, body) + } + return &http.Response{StatusCode: http.StatusCreated, Header: http.Header{}, Body: io.NopCloser(strings.NewReader(`{"number":42,"title":"Smaller fix","html_url":"https://github.com/acme/api/issues/42"}`))}, nil + })} + reopenJobs(t, f) + before := f.read(t, parent) + it := createExtrasJob(t, f, "/v1/issues", api.NewJob{ + Repo: "acme/api", Title: "Smaller fix", Text: "Only the first part", Source: factory.AskApp, SplitFrom: &parent.ID, + }) + if got := f.read(t, it); got.SplitFrom != 80 || got.Num != 42 || calls != 1 { + t.Fatalf("issue job: %+v; GitHub calls=%d", got, calls) + } + if after := f.read(t, parent); !reflect.DeepEqual(before, after) { + t.Fatal("issue-backed split changed the parent job read") + } +} + +// S3: missing or invalid parent numbers are refused before a job or issue exists. +func TestNewJobSplitFromRefusesBeforeSideEffects(t *testing.T) { + f := newCreateExtrasJobs(t) + f.cfg.Mode = api.FactorySolo + f.cfg.GitHubToken = func(context.Context) string { return "split-issue-fixture-credential" } + calls := 0 + f.cfg.GitHubHTTPClient = &http.Client{Transport: jobsTransport(func(*http.Request) (*http.Response, error) { + calls++ + return nil, fmt.Errorf("invalid split_from reached GitHub") + })} + reopenJobs(t, f) + for _, path := range []string{"/v1/jobs", "/v1/issues"} { + for _, parent := range []int{80, 0, -1} { + body := fmt.Sprintf(`{"repo":"acme/api","title":"Fix","issue":true,"split_from":%d}`, parent) + w := verbRequest(f.h, http.MethodPost, path, body) + checkJSON(t, w, http.StatusUnprocessableEntity) + if !strings.Contains(w.Body.String(), "split_from") { + t.Fatalf("refusal did not name split_from: %s", w.Body) + } + } + } + items, err := f.st.List() + if err != nil || len(items) != 0 || calls != 0 { + t.Fatalf("invalid split_from: jobs=%d GitHub=%d err=%v", len(items), calls, err) + } +} + +// S4: omitting lineage keeps the read and stored activity free of split facts. +func TestNewJobWithoutSplitFromOmitsLineage(t *testing.T) { + f := newCreateExtrasJobs(t) + it := createExtrasJob(t, f, "/v1/jobs", api.NewJob{Repo: "acme/api", Title: "Fix", Source: factory.AskApp}) + w := verbRequest(f.h, http.MethodGet, fmt.Sprintf("/v1/jobs/%d", it.ID), "") + checkJSON(t, w, http.StatusOK) + var got map[string]json.RawMessage + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if _, exists := got["split_from"]; exists { + t.Fatal("plain job read includes split_from") + } + for _, event := range it.Activity { + if strings.Contains(event.What, "split from") { + t.Fatal("plain job has split activity") + } + } +} diff --git a/internal/factory/serve/new_job_tier_merge_test.go b/internal/factory/serve/new_job_tier_merge_test.go new file mode 100644 index 0000000000..71e66085b0 --- /dev/null +++ b/internal/factory/serve/new_job_tier_merge_test.go @@ -0,0 +1,53 @@ +package serve_test + +import ( + "encoding/json" + "net/http" + "testing" + + "github.com/Agent-Field/codeaf/internal/factory" + "github.com/Agent-Field/codeaf/internal/factory/api" + "github.com/Agent-Field/codeaf/internal/factory/live" + "github.com/Agent-Field/codeaf/internal/factory/run" +) + +func TestNewJobExtrasKeepTierRefusalAndOutcome(t *testing.T) { + f := newCreateExtrasJobs(t) + parent := f.create(t, factory.Item{Kind: factory.KindIssue, State: factory.StateNew}) + f.seam.NewJob = func(repo, title, text string) (int, error) { + it, err := f.st.Create(factory.Item{Repo: repo, Title: title, Body: text, Kind: factory.KindIssue, State: factory.StateNew, + Origin: factory.OriginForge, Author: "author", Tier: factory.TierStranger, + Stages: []factory.Stage{{Name: "plan", Kind: factory.StageChat, On: true}}}) + return it.ID, err + } + reopenJobs(t, f) + budget := 10.0 + body, err := json.Marshal(api.NewJob{Repo: "acme/api", Title: "Smaller fix", Source: factory.AskApp, + Start: "now", Group: "g", Milestone: "release", Budget: &budget, SplitFrom: &parent.ID}) + if err != nil { + t.Fatal(err) + } + w := verbRequest(f.h, http.MethodPost, "/v1/jobs", string(body)) + checkJSON(t, w, http.StatusConflict) + var refusal struct{ Error string } + if err := json.Unmarshal(w.Body.Bytes(), &refusal); err != nil || refusal.Error != (run.WriteAccessRefusal{}).Error() { + t.Fatalf("tier refusal: %s err=%v", w.Body, err) + } + jobs, err := f.st.ListSaved() + if err != nil || len(jobs) != 2 { + t.Fatalf("created jobs=%d err=%v", len(jobs), err) + } + child := jobs[0] + if child.ID == parent.ID || child.SplitFrom != parent.ID || child.Group != "g" || child.Milestone != "release" || child.Cap != budget || child.Stream != nil || child.Failure == nil { + t.Fatalf("refused child lost its extras or started: %+v", child) + } + zero := int64(0) + out, err := live.ReadOutcomes(f.st.Root(), &zero, 100) + if err != nil || out.Reset || len(out.Events) != 1 { + t.Fatalf("tier outcomes: %+v err=%v", out, err) + } + event := out.Events[0] + if event.Job != child.ID || event.Kind != "failed" || event.Group != "g" || event.Code != factory.CodeToolError || event.Step != "plan" || !event.At.Equal(child.Failure.At) { + t.Fatalf("tier outcome lost saved facts: %+v", event) + } +} diff --git a/internal/factory/serve/policy.go b/internal/factory/serve/policy.go new file mode 100644 index 0000000000..6ebfe6fbbe --- /dev/null +++ b/internal/factory/serve/policy.go @@ -0,0 +1,178 @@ +package serve + +import ( + "errors" + "fmt" + "io/fs" + "net/http" + + "github.com/Agent-Field/codeaf/internal/factory" + "github.com/Agent-Field/codeaf/internal/factory/api" + "github.com/Agent-Field/codeaf/internal/factory/record" + "github.com/Agent-Field/codeaf/internal/factory/run" +) + +var errPolicyMoved = errors.New("the group's policy changed since it was read; read it again, then make the change") + +func (h *Handler) policyRoutes() { + h.route("/v1/groups/{group}/policy", map[string]http.HandlerFunc{http.MethodGet: h.groupPolicy, http.MethodPut: h.groupPolicy}) +} + +func (h *Handler) groupPolicy(w http.ResponseWriter, r *http.Request) { + if !h.requireVerb(w, h.config.Store != nil) { + return + } + group, err := factory.CheckGroup(r.PathValue("group")) + if err != nil { + h.fail(w, 422, err.Error()) + return + } + if group == "" { + h.fail(w, 422, "a policy requires a non-empty group") + return + } + if r.Method == http.MethodGet { + p, err := h.config.Store.Policy(group) + if h.failed(w, err) { + return + } + h.writePolicy(w, p) + return + } + var body struct { + Stopped bool `json:"stopped"` + Allowances []factory.Allowance `json:"allowances"` + Version *int `json:"version,omitempty"` + } + if !h.decode(w, r, &body, false) { + return + } + next := factory.GroupPolicy{Group: group, Stopped: body.Stopped, Allowances: body.Allowances} + if err := next.Check(); err != nil { + h.fail(w, 422, err.Error()) + return + } + if body.Version != nil && *body.Version < 0 { + h.fail(w, 422, "policy version must not be negative") + return + } + caller, err := h.callerHandle(r) + if h.failed(w, err) { + return + } + caller = factory.NormalizeHandle(caller) + operator := h.isOperator(r) + if caller == "" && operator { + caller = h.operatorLogin(r) + } + stopped := false + clearPhase(w) + saved, err := h.config.Store.ChangePolicy(group, func(cur *factory.GroupPolicy) error { + if h.mode() != api.FactorySolo && (caller == "" || cur.Owner != "" && caller != cur.Owner && !operator) { + return fs.ErrPermission + } + if body.Version != nil && *body.Version != cur.Version { + return errPolicyMoved + } + stopped = next.Stopped + next.Owner = cur.Owner + if next.Owner == "" { + next.Owner = caller + } + now := h.clock().UTC() + next.UpdatedAt = &now + *cur = next + return nil + }) + if errors.Is(err, fs.ErrPermission) || errors.Is(err, errPolicyMoved) { + w.Header().Set(api.PhaseHeader, api.PhasePreMutation) + if errors.Is(err, fs.ErrPermission) { + h.fail(w, 403, "only the policy's owner or the operator can change it") + } else { + h.refuse(w, 409, err.Error(), "stale", "", nil, "") + } + return + } + if h.failed(w, err) { + return + } + if h.config.Record != nil { + _, err = h.config.Record.Append(r.Context(), record.Event{Kind: record.PolicyChanged, Verb: "changed policy", Handle: caller, Object: "group:" + group, Detail: fmt.Sprintf("policy version %d; stopped=%t", saved.Version, saved.Stopped)}) + if h.failed(w, err) { + return + } + } + if stopped && h.failed(w, h.stopPolicyJobs(r, group)) { + return + } + h.writePolicy(w, saved) +} + +func (h *Handler) writePolicy(w http.ResponseWriter, p factory.GroupPolicy) { + jobs, err := h.config.Store.ListSaved() + if h.failed(w, err) { + return + } + h.write(w, 200, api.PolicyFrom(p, jobs, h.clock())) +} + +func (h *Handler) stopPolicyJobs(r *http.Request, group string) error { + jobs, err := h.config.Store.ListSaved() + if err != nil { + return err + } + for _, job := range jobs { + if job.Group != group || !factory.MayStopAfterStep(job) || job.StopAfterStep { + continue + } + err := h.recordAction(r, job, record.StopAfterStepChanged, "stop_after_step=true; its group is stopped", func() error { + return h.config.Store.Update(job.ID, func(cur *factory.Item) error { + if cur.Group != group || !factory.MayStopAfterStep(*cur) || cur.StopAfterStep { + return errWaitsUnchanged + } + cur.StopAfterStep = true + cur.Note(h.clock(), "stop after this step: its group is stopped") + return nil + }) + }) + if err != nil && !errors.Is(err, errWaitsUnchanged) { + return err + } + } + return nil +} + +// continuePolicy releases the same cap question as a budget hold. Its worker +// lease was withdrawn, so the saved step returns to the queue for admission. +func (h *Handler) continuePolicy(r *http.Request, it factory.Item, words string) (bool, error) { + if h.config.Store != nil && it.PolicyRun != "" && it.QKind == "cap" { + if err := h.config.Store.Update(it.ID, func(cur *factory.Item) error { cur.PolicyRun = ""; return nil }); err != nil { + return true, err + } + } + if it.Blocked == nil || it.Blocked.By != factory.BlockPolicy || it.QKind != "cap" || h.config.Store == nil { + return false, nil + } + err := h.config.Store.Update(it.ID, func(cur *factory.Item) error { + if cur.Blocked == nil || cur.Blocked.By != factory.BlockPolicy || cur.QKind != "cap" { + return fmt.Errorf("this job's policy hold changed; read it again") + } + factory.ClearPolicyRun(cur) + run.ReleaseCap(cur, h.clock()) + return nil + }) + if err == nil { + err = h.recordContinued(r, it, words) + } + return true, err +} + +// resumePolicyStart keeps the saved work when Start answers a policy hold. +// No worker holds this run, so queuing it needs no second Launch. +func (h *Handler) resumePolicyStart(it *factory.Item) bool { + if it.State != factory.StateNeedsYou || it.QKind != "cap" || it.Blocked == nil || it.Blocked.By != factory.BlockPolicy { + return false + } + run.ReleaseCap(it, h.clock()) + return true +} diff --git a/internal/factory/serve/policy_fix1_test.go b/internal/factory/serve/policy_fix1_test.go new file mode 100644 index 0000000000..3440eaba2f --- /dev/null +++ b/internal/factory/serve/policy_fix1_test.go @@ -0,0 +1,131 @@ +package serve + +import ( + "errors" + "fmt" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/factory" + "github.com/Agent-Field/codeaf/internal/factory/api" + "github.com/Agent-Field/codeaf/internal/factory/lease" + "github.com/Agent-Field/codeaf/internal/factory/record" + "github.com/Agent-Field/codeaf/internal/factory/run" +) + +// P7: a real PUT's activity write interleaves a report after policy persistence +// and before its per-job pass. Worker clocks in either direction change nothing. +func TestPolicyStopReportDuringPUT(t *testing.T) { + for _, skew := range []time.Duration{-30 * time.Second, 30 * time.Second} { + for _, policyRun := range []string{"", "m2"} { + t.Run(fmt.Sprintf("skew=%s/policy_run=%s", skew, policyRun), func(t *testing.T) { + f := waitsSetup(t) + job := f.addJob(t, 0) + f.edit(t, job.ID, func(it *factory.Item) { + it.Group, it.PolicyRun, it.State = "g", policyRun, factory.StateQueued + it.Stream = &factory.Stream{Cur: 0, Phases: []factory.Phase{{Name: "test", State: factory.PhaseRunning, Round: 1, Started: f.now.Add(skew)}}} + }) + held, err := f.h.workers.desk.Pull(lease.Worker{ID: "w", Handle: "owner", Solo: true}) + if err != nil { + t.Fatal(err) + } + interleaved := false + history, err := record.Open(f.st, record.Options{Now: func() time.Time { + if !interleaved { + interleaved = true + p, err := f.st.Policy("g") + if err != nil || !p.Stopped || f.item(t, job.ID).StopAfterStep { + t.Fatalf("report outside publication window: %+v, %v", p, err) + } + err = f.h.workers.desk.Apply(held.ID, "w", false, false, func(it *factory.Item) error { + return run.ApplyReport(it, factory.RunReport{Cost: 0.25}, f.now) + }) + if err != nil { + t.Fatalf("stop cut admitted step during PUT: %v", err) + } + it := f.item(t, job.ID) + if !it.StopAfterStep || it.State != factory.StateRunning || factory.JobLifetimeSpent(it) != 0.25 { + t.Fatalf("report did not arm step boundary: %+v", it) + } + } + return f.now + }}) + if err != nil { + t.Fatal(err) + } + f.h.config.Record = history + putPolicy(t, f, true, policyAllowance(f, job.ID)) + if !interleaved { + t.Fatal("PUT did not reach activity write") + } + // Completion belongs to the admitted step and keeps its work. + if err := f.h.workers.desk.Apply(held.ID, "w", false, false, func(it *factory.Item) error { + return run.ApplyReport(it, factory.RunReport{Stages: []factory.StageReport{{Name: "test", State: factory.PhaseDone, Round: 1}}}, f.now) + }); err != nil { + t.Fatalf("completion was withdrawn: %v", err) + } + // A delayed new round carries an old worker timestamp, including + // after an identical stopped replacement advances UpdatedAt. + f.now = f.now.Add(time.Second) + putPolicy(t, f, true, policyAllowance(f, job.ID)) + err = f.h.workers.desk.Apply(held.ID, "w", false, false, func(it *factory.Item) error { + return run.ApplyReport(it, factory.RunReport{Stages: []factory.StageReport{{Name: "test", State: factory.PhaseRunning, Round: 2, Started: f.now.Add(-time.Hour)}}}, f.now) + }) + if !errors.Is(err, lease.ErrWithdrawn) { + t.Fatalf("delayed admission escaped stop: %v", err) + } + it := f.item(t, job.ID) + if it.Blocked == nil || it.Blocked.Reason != "its group is stopped" || it.State != factory.StateNeedsYou || factory.JobLifetimeSpent(it) != 0.25 { + t.Fatalf("new step hold = %+v", it) + } + }) + } + } +} + +// P7: stopped PUTs repair boundary flags even when already stopped. +func TestPolicyRepeatedStopRearmsJobs(t *testing.T) { + f := waitsSetup(t) + job := f.addJob(t, 0) + f.edit(t, job.ID, func(it *factory.Item) { it.Group, it.State = "g", factory.StateRunning }) + putPolicy(t, f, true, policyAllowance(f, job.ID)) + if !f.item(t, job.ID).StopAfterStep { + t.Fatal("initial stop did not arm boundary") + } + f.edit(t, job.ID, func(it *factory.Item) { it.StopAfterStep = false }) + putPolicy(t, f, true, policyAllowance(f, job.ID)) + if !f.item(t, job.ID).StopAfterStep { + t.Fatal("repeated stopped PUT did not repair missing boundary") + } +} + +// P3, P5, P9: automatic start, lease admission and GET use decimal boundaries. +func TestPolicyHTTPDecimalMoney(t *testing.T) { + f := waitsSetup(t) + parent, child := policyPair(t, f) + paid := f.addJob(t, 0) + f.edit(t, paid.ID, func(it *factory.Item) { it.Group, it.ChatSpent = "g", 0.10 }) + f.edit(t, child.ID, func(it *factory.Item) { it.Cap = 0.20 }) + a := policyAllowance(f, child.ID, paid.ID) + a.CapUSD = 0.30 + putPolicy(t, f, false, a) + f.merge(t, parent.ID) + f.scan(t) + if it := f.item(t, child.ID); it.PolicyRun != "m2" || it.State != factory.StateQueued || f.launches != 1 { + t.Fatalf("exact headroom refused: %+v; launches %d", it, f.launches) + } + f.edit(t, child.ID, func(it *factory.Item) { it.Cap, it.Stream.Spent = 2, 0.70 }) + a.CapUSD = 0.80 + putPolicy(t, f, false, a) + var p api.Policy + f.request(t, "GET", "/v1/groups/g/policy", "operator-secret", nil, 200, &p) + if len(p.Allowances) != 1 || p.Allowances[0].State != "spent" { + t.Fatalf("decimal read state = %+v", p) + } + if _, err := f.h.workers.desk.Pull(lease.Worker{ID: "w", Handle: "owner", Solo: true}); !errors.Is(err, lease.ErrNoJob) { + t.Fatalf("decimal cap lease = %v", err) + } + if it := f.item(t, child.ID); it.Blocked == nil || it.Question != "allowance m2 reached its $0.80 cap" { + t.Fatalf("decimal cap hold = %+v", it) + } +} diff --git a/internal/factory/serve/policy_start.go b/internal/factory/serve/policy_start.go new file mode 100644 index 0000000000..f50ef620ec --- /dev/null +++ b/internal/factory/serve/policy_start.go @@ -0,0 +1,113 @@ +package serve + +import ( + "context" + "errors" + "fmt" + "net/http" + + "github.com/Agent-Field/codeaf/internal/factory" + "github.com/Agent-Field/codeaf/internal/factory/api" + "github.com/Agent-Field/codeaf/internal/factory/lease" + "github.com/Agent-Field/codeaf/internal/factory/record" +) + +func policyStartable(it factory.Item) bool { + return it.Group != "" && len(it.WaitsOn) != 0 && it.WaitsReleased && it.PolicyRun == "" && it.Stream == nil && len(it.Adapted) == 0 && it.SpentBefore == 0 && !factory.WaitsOverridden(it) +} + +// scanPolicyStarts also visits releases from earlier scans. A missing grant or +// stopped group can therefore become startable without changing the waits. +func (h *Handler) scanPolicyStarts(jobs []factory.Item) error { + var spent map[int]float64 + var errs []error + for _, job := range jobs { + if job.Group == "" || len(job.WaitsOn) == 0 { + continue + } + if job.Stream != nil || factory.WaitsOverridden(job) || job.PolicyRun != "" { + continue + } + if spent == nil { + spent = make(map[int]float64, len(jobs)) + for _, job := range jobs { + spent[job.ID] = factory.JobLifetimeSpent(job) + } + } + r, _ := http.NewRequestWithContext(context.Background(), http.MethodPost, fmt.Sprintf("/v1/jobs/%d/run", job.ID), nil) + budget, budgetErr := h.startBudget(r, job) + err := h.startPolicyJob(r, job.ID, budget, budgetErr, spent) + if err != nil && !errors.Is(err, errWaitsUnchanged) { + errs = append(errs, err) + } + } + return errors.Join(errs...) +} + +// startPolicyJob claims only the locked, still released job. A durable +// PolicyRun prevents a second handler from entering Launch for the same run. +func (h *Handler) startPolicyJob(r *http.Request, id int, budget float64, budgetErr error, spent map[int]float64) error { + var started factory.Item + var allowance, owner string + claimed := false + err := h.config.Store.Update(id, func(it *factory.Item) error { + if !policyStartable(*it) { + return errWaitsUnchanged + } + p, err := h.config.Store.Policy(it.Group) + reason := "" + if err != nil { + reason = "its group's policy cannot be read" + } else if p.Stopped { + reason = "its group is stopped" + } else if budgetErr != nil { + reason = budgetErr.Error() + } + sw, switchErr := h.config.Store.RepoSwitch() + if reason == "" && switchErr != nil { + reason = "the factory's repository settings cannot be read" + } + if reason == "" && sw.OffJob(*it) { + reason = "its repository is off" + } + if reason == "" && h.waits.launch == nil { + reason = "this factory cannot start jobs" + } + candidate := *it + if candidate.Cap <= 0 { + candidate.Cap = budget + } + if reason == "" { + allowance, reason, claimed = factory.StartDecision(p, candidate, func(id int) float64 { + if id == it.ID { + return factory.JobLifetimeSpent(*it) + } + return spent[id] + }, h.clock()) + } + if claimed && h.mode() != api.FactorySolo && (p.Owner == "" || !h.teamAllows(p.Owner) || it.Person() != "" && it.Person() != p.Owner) { + reason, claimed = "the policy's owner cannot start this job", false + } + if !claimed { + block := &factory.JobBlock{By: factory.BlockPolicy, Reason: reason, Since: h.clock().UTC()} + if sameWaitsBlock(it.Blocked, block) { + return errWaitsUnchanged + } + it.Blocked = block + return nil + } + it.Cap = candidate.Cap + owner = p.Owner + if h.mode() != api.FactorySolo && it.Person() == "" { + it.Starter = owner + } + retireLaunchWaits(it, allowance) + started = *it + return nil + }) + if err != nil || !claimed { + return err + } + r = r.WithContext(context.WithValue(r.Context(), workerKey{}, lease.Worker{Handle: owner})) + return h.recordAction(r, started, record.AutoStarted, "allowance "+allowance, func() error { return h.waits.launch(id) }) +} diff --git a/internal/factory/serve/policy_test.go b/internal/factory/serve/policy_test.go new file mode 100644 index 0000000000..be19ccb802 --- /dev/null +++ b/internal/factory/serve/policy_test.go @@ -0,0 +1,459 @@ +package serve + +import ( + "context" + "errors" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/factory" + "github.com/Agent-Field/codeaf/internal/factory/api" + "github.com/Agent-Field/codeaf/internal/factory/lease" + "github.com/Agent-Field/codeaf/internal/factory/record" +) + +func policyPair(t *testing.T, f *waitsFixture) (factory.Item, factory.Item) { + t.Helper() + parent, child := f.addJob(t, 1107), f.addJob(t, 0) + f.edit(t, child.ID, func(it *factory.Item) { it.Group, it.Cap = "g", 5 }) + f.set(t, child.ID, []int{parent.ID}, 200) + return parent, child +} + +func policyAllowance(f *waitsFixture, jobs ...int) factory.Allowance { + return factory.Allowance{ID: "m2", Jobs: jobs, CapUSD: 15, EndsAt: f.now.AddDate(0, 0, 7).Format(time.RFC3339)} +} + +func putPolicy(t *testing.T, f *waitsFixture, stopped bool, allowances ...factory.Allowance) api.Policy { + t.Helper() + var out api.Policy + f.request(t, "PUT", "/v1/groups/g/policy", "operator-secret", map[string]any{"stopped": stopped, "allowances": allowances}, 200, &out) + return out +} + +// P1, P10: merging releases once, records the allowance and survives a new handler. +func TestPolicyOrderingAndRestart(t *testing.T) { + for _, restart := range []bool{false, true} { + f := waitsSetup(t) + parent, child := policyPair(t, f) + putPolicy(t, f, false, policyAllowance(f, child.ID)) + // The intake limit does not constrain a policy's explicit allowance. + if _, err := f.st.ChangeSpending(func(s *factory.Spending) error { value := 1.0; s.AutoStartMaxUSD = &value; return nil }); err != nil { + t.Fatal(err) + } + f.scan(t) + if f.launches != 0 { + t.Fatal("started before merge") + } + if restart { + f.h.Close() + f.h = New(f.seam, f.cfg) + f.h.stopWaits() + t.Cleanup(f.h.Close) + } + f.merge(t, parent.ID) + f.scan(t) + f.scan(t) + it := f.item(t, child.ID) + if f.launches != 1 || it.PolicyRun != "m2" || !it.WaitsReleased || it.Blocked != nil || len(it.WaitingOn) != 0 || it.State != factory.StateQueued { + t.Fatalf("start = %+v; launches %d", it, f.launches) + } + events, err := f.cfg.Record.Since(context.Background(), time.Time{}) + if err != nil { + t.Fatal(err) + } + count := 0 + for _, e := range events { + if e.Kind == record.AutoStarted && e.Job == child.ID { + count++ + if !strings.Contains(e.Detail, "m2") { + t.Fatal(e) + } + } + } + if count != 1 { + t.Fatalf("automatic receipts = %d", count) + } + } +} + +// P2, P3, P4, P7: refusal leaves Backlog and later replacements are rechecked. +func TestPolicyRefusedReleaseAndLaterGrant(t *testing.T) { + for _, kind := range []string{"missing", "headroom", "ended", "stopped"} { + t.Run(kind, func(t *testing.T) { + f := waitsSetup(t) + parent, child := policyPair(t, f) + a := policyAllowance(f, child.ID) + want := "no allowance covers it" + switch kind { + case "headroom": + paid := f.addJob(t, 0) + f.edit(t, paid.ID, func(it *factory.Item) { it.ChatSpent = 12 }) + a.Jobs = append(a.Jobs, paid.ID) + want = "allowance m2 has $3.00 of $15.00 left; the job's budget is $5.00" + putPolicy(t, f, false, a) + case "ended": + a.EndsAt = f.now.Format(time.RFC3339) + want = "allowance m2 ended Oct 11" + p := putPolicy(t, f, false, a) + if p.Allowances[0].State != "ended" { + t.Fatal(p) + } + case "stopped": + want = "its group is stopped" + putPolicy(t, f, true, a) + } + f.merge(t, parent.ID) + f.scan(t) + j := f.readJob(t, child.ID) + if j.State != "backlog" || j.Held || j.NeedsYou != nil || j.Blocked == nil || j.Blocked.By != "policy" || j.Blocked.Reason != want || !j.Can.Run || f.launches != 0 { + t.Fatalf("refusal = %+v; launches %d", j, f.launches) + } + rev := waitsRevision(t, f, child.ID) + f.scan(t) + if waitsRevision(t, f, child.ID) != rev { + t.Fatal("unchanged refusal rewrote job") + } + a.CapUSD, a.EndsAt = 20, f.now.Add(time.Hour).Format(time.RFC3339) + putPolicy(t, f, false, a) + f.scan(t) + if f.launches != 1 || f.item(t, child.ID).PolicyRun != "m2" { + t.Fatalf("grant did not start: %+v", f.item(t, child.ID)) + } + }) + } +} + +// P1: two handlers sharing a folder admit the same released job only once. +func TestPolicyStartAcrossHandlers(t *testing.T) { + f := waitsSetup(t) + parent, child := policyPair(t, f) + putPolicy(t, f, false, policyAllowance(f, child.ID)) + f.merge(t, parent.ID) + other := New(f.seam, f.cfg) + other.stopWaits() + t.Cleanup(other.Close) + // New scanned synchronously; subsequent concurrent scans cannot start again. + var wg sync.WaitGroup + for _, h := range []*Handler{f.h, other} { + wg.Add(1) + go func() { + defer wg.Done() + if err := h.scanWaits(); err != nil { + t.Error(err) + } + }() + } + wg.Wait() + if f.launches != 1 { + t.Fatalf("launches = %d", f.launches) + } +} + +// P5, P8: real Continue and Start resume held work as a person's run. +func TestPolicyHoldPersonDoors(t *testing.T) { + for _, door := range []string{"answer", "run", "start_now"} { + t.Run(door, func(t *testing.T) { + f := waitsSetup(t) + // The answer door exists only when the seam has Answer at New, so build the handler with it. + f.h.Close() + f.seam.Answer = func(int, bool, string) error { t.Fatal("policy hold used a withdrawn worker's answer"); return nil } + f.h = New(f.seam, f.cfg) + t.Cleanup(f.h.Close) + f.h.stopWaits() + parent, child := policyPair(t, f) + a := policyAllowance(f, child.ID) + a.CapUSD = 5 + putPolicy(t, f, false, a) + f.merge(t, parent.ID) + f.scan(t) + f.request(t, "POST", fmt.Sprintf("/v1/jobs/%d/raise_budget", child.ID), "operator-secret", map[string]any{"budget": 10}, 200, nil) + desk := f.h.workers.desk + worker := lease.Worker{ID: "w", Handle: "owner", Solo: true} + held, err := desk.Pull(worker) + if err != nil { + t.Fatal(err) + } + err = desk.Apply(held.ID, worker.ID, false, false, func(it *factory.Item) error { it.Stream.Spent = 5; return nil }) + if !errors.Is(err, lease.ErrWithdrawn) { + t.Fatal(err) + } + var body any + if door == "answer" { + body = map[string]any{"choice": "continue"} + } + f.request(t, "POST", fmt.Sprintf("/v1/jobs/%d/%s", child.ID, door), "operator-secret", body, 200, nil) + it := f.item(t, child.ID) + if it.PolicyRun != "" || it.Blocked != nil || it.State != factory.StateQueued || factory.JobLifetimeSpent(it) != 5 { + t.Fatalf("person resumed = %+v", it) + } + held, err = desk.Pull(worker) + if err != nil { + t.Fatal(err) + } + if err := desk.Apply(held.ID, worker.ID, false, false, func(it *factory.Item) error { it.Stream.Spent++; return nil }); err != nil { + t.Fatal(err) + } + events, err := f.cfg.Record.Since(context.Background(), time.Time{}) + if err != nil { + t.Fatal(err) + } + holds := 0 + for _, e := range events { + if e.Job == child.ID && e.Kind == record.Held && e.Rule == factory.BlockPolicy { + holds++ + } + } + if holds != 1 { + t.Fatalf("policy holds recorded = %d", holds) + } + }) + } +} + +// P7: stopping requests the existing step boundary and gates queued person runs. +func TestPolicyStop(t *testing.T) { + f := waitsSetup(t) + parent, child := policyPair(t, f) + putPolicy(t, f, false, policyAllowance(f, child.ID)) + f.merge(t, parent.ID) + f.scan(t) + worker := lease.Worker{ID: "w", Handle: "owner", Solo: true} + held, err := f.h.workers.desk.Pull(worker) + if err != nil { + t.Fatal(err) + } + putPolicy(t, f, true, policyAllowance(f, child.ID)) + if !f.item(t, child.ID).StopAfterStep { + t.Fatal("running job has no step stop") + } + if err := f.h.workers.desk.Apply(held.ID, worker.ID, false, false, func(it *factory.Item) error { + it.Stream.Phases[0].State = factory.PhaseRunning + return nil + }); !errors.Is(err, lease.ErrWithdrawn) { + t.Fatal(err) + } + putPolicy(t, f, false, policyAllowance(f, child.ID)) + f.scan(t) + if it := f.item(t, child.ID); it.State != factory.StateNeedsYou { + t.Fatalf("unstop resumed hold = %+v", it) + } + f.request(t, "POST", fmt.Sprintf("/v1/jobs/%d/run", child.ID), "operator-secret", nil, 200, nil) + putPolicy(t, f, true, policyAllowance(f, child.ID)) + if _, err := f.h.workers.desk.Pull(worker); !errors.Is(err, lease.ErrNoJob) { + t.Fatalf("person's start escaped stop: %v", err) + } + if j := f.readJob(t, child.ID); j.State != "needs_you" || j.Blocked == nil || j.Blocked.Reason != "its group is stopped" { + t.Fatal(j) + } +} + +// P7: a stopped group's paid step finishes before the next admission is held. +func TestPolicyStopFinishesTheStep(t *testing.T) { + f := waitsSetup(t) + parent, child := policyPair(t, f) + putPolicy(t, f, false, policyAllowance(f, child.ID)) + f.merge(t, parent.ID) + f.scan(t) + w := lease.Worker{ID: "w", Handle: "owner", Solo: true} + held, err := f.h.workers.desk.Pull(w) + if err != nil { + t.Fatal(err) + } + f.edit(t, child.ID, func(it *factory.Item) { + it.Stream.Phases[0].State, it.Stream.Phases[0].Started = factory.PhaseRunning, f.now + }) + f.now = f.now.Add(time.Second) + putPolicy(t, f, true, policyAllowance(f, child.ID)) + if err := f.h.workers.desk.Apply(held.ID, w.ID, false, false, func(it *factory.Item) error { it.Stream.Spent = 1; return nil }); err != nil { + t.Fatalf("paid step was cut: %v", err) + } + if it := f.item(t, child.ID); it.State != factory.StateRunning || !it.StopAfterStep || factory.JobLifetimeSpent(it) != 1 { + t.Fatal(it) + } + if err := f.h.workers.desk.Apply(held.ID, w.ID, false, false, func(it *factory.Item) error { + it.Stream.Spent = 2 + it.Stream.Phases[0].State = factory.PhaseDone + return nil + }); err != nil { + t.Fatalf("completion = %v", err) + } + if err := f.h.workers.desk.Apply(held.ID, w.ID, false, false, func(it *factory.Item) error { + it.Stream.Phases[0].State = factory.PhaseRunning + it.Stream.Phases[0].Round++ + return nil + }); !errors.Is(err, lease.ErrWithdrawn) { + t.Fatalf("next admission = %v", err) + } + if it := f.item(t, child.ID); it.State != factory.StateNeedsYou || it.Blocked == nil || it.Blocked.Reason != "its group is stopped" || factory.JobLifetimeSpent(it) != 2 { + t.Fatal(it) + } +} + +// P1, P8: a queued Start now makes the run the person's without a second launch. +func TestPolicyQueuedStartNow(t *testing.T) { + f := waitsSetup(t) + parent, child := policyPair(t, f) + putPolicy(t, f, false, policyAllowance(f, child.ID)) + f.merge(t, parent.ID) + f.scan(t) + f.request(t, "POST", fmt.Sprintf("/v1/jobs/%d/start_now", child.ID), "operator-secret", nil, 200, nil) + if it := f.item(t, child.ID); it.PolicyRun != "" || f.launches != 1 || !it.OverLimit { + t.Fatalf("person's queued start = %+v, launches %d", it, f.launches) + } +} + +// P1: the automatic start ensures the same default job budget as create-and-start. +func TestPolicyStartEnsuresBudget(t *testing.T) { + f := waitsSetup(t) + parent, child := policyPair(t, f) + f.edit(t, child.ID, func(it *factory.Item) { it.Cap = 0 }) + putPolicy(t, f, false, policyAllowance(f, child.ID)) + f.merge(t, parent.ID) + f.scan(t) + if it := f.item(t, child.ID); it.Cap != factory.DefaultJobBudget || it.PolicyRun != "m2" || f.launches != 1 { + t.Fatalf("automatic budget = %+v", it) + } +} + +// P2, P4, P8: a person can start a refused release under an ended allowance. +func TestPolicyBacklogPersonStart(t *testing.T) { + f := waitsSetup(t) + parent, child := policyPair(t, f) + a := policyAllowance(f, child.ID) + a.EndsAt = f.now.Format(time.RFC3339) + putPolicy(t, f, false, a) + f.merge(t, parent.ID) + f.scan(t) + f.request(t, "POST", fmt.Sprintf("/v1/jobs/%d/run", child.ID), "operator-secret", nil, 200, nil) + if it := f.item(t, child.ID); it.PolicyRun != "" || it.Blocked != nil || f.launches != 1 { + t.Fatalf("person start = %+v", it) + } + if _, err := f.h.workers.desk.Pull(lease.Worker{ID: "w", Handle: "owner", Solo: true}); err != nil { + t.Fatal(err) + } +} + +// P9, P11, P12: HTTP reads lifetime totals, state, versions and capabilities. +func TestPolicyHTTPReadsAndStale(t *testing.T) { + f := waitsSetup(t) + _, child := policyPair(t, f) + f.edit(t, child.ID, func(it *factory.Item) { it.ChatSpent, it.SpentBefore = 2, 3 }) + other := f.addJob(t, 0) + f.edit(t, other.ID, func(it *factory.Item) { it.Group, it.ChatSpent = "g", 4 }) + var empty api.Policy + f.request(t, "GET", "/v1/groups/g/policy", "operator-secret", nil, 200, &empty) + if empty.SpentUSD != 9 || empty.Stopped || len(empty.Allowances) != 0 || empty.Allowances == nil { + t.Fatal(empty) + } + a := policyAllowance(f, child.ID) + a.CapUSD = 5 + p := putPolicy(t, f, false, a) + if p.SpentUSD != 9 || p.Allowances[0].SpentUSD != 5 || p.Allowances[0].State != "spent" || p.Version != 1 || p.UpdatedAt == nil { + t.Fatal(p) + } + f.request(t, "PUT", "/v1/groups/g/policy", "operator-secret", map[string]any{"stopped": true, "allowances": []factory.Allowance{}, "version": 0}, 409, nil) + var after api.Policy + f.request(t, "GET", "/v1/groups/g/policy", "operator-secret", nil, 200, &after) + if !reflect.DeepEqual(p, after) { + t.Fatalf("stale wrote: %+v", after) + } + var about struct { + Features []string `json:"features"` + } + f.request(t, "GET", "/v1/about", "", nil, 200, &about) + if !strings.Contains(strings.Join(about.Features, ","), "group_policy") { + t.Fatal(about) + } +} + +// P11: invalid replacements leave the policy and every job unchanged. +func TestPolicyHTTPBounds(t *testing.T) { + f := waitsSetup(t) + _, child := policyPair(t, f) + putPolicy(t, f, false, policyAllowance(f, child.ID)) + before, err := os.ReadFile(filepath.Join(f.st.Root(), "factory-policies.json")) + if err != nil { + t.Fatal(err) + } + for _, change := range []func(*factory.Allowance){ + func(a *factory.Allowance) { a.ID = "" }, func(a *factory.Allowance) { a.ID = strings.Repeat("x", 65) }, func(a *factory.Allowance) { a.ID = "bad id" }, + func(a *factory.Allowance) { a.Jobs = nil }, func(a *factory.Allowance) { a.Jobs = make([]int, 201) }, func(a *factory.Allowance) { a.Jobs = []int{0} }, func(a *factory.Allowance) { a.Jobs = []int{-1} }, func(a *factory.Allowance) { a.Jobs = []int{1, 1} }, + func(a *factory.Allowance) { a.CapUSD = 0 }, func(a *factory.Allowance) { a.CapUSD = 10001 }, func(a *factory.Allowance) { a.EndsAt = "tomorrow" }, + } { + a := policyAllowance(f, child.ID) + change(&a) + f.request(t, "PUT", "/v1/groups/g/policy", "operator-secret", map[string]any{"stopped": true, "allowances": []factory.Allowance{a}}, 422, nil) + } + a := policyAllowance(f, child.ID) + for _, allowances := range [][]factory.Allowance{{a, a}, make([]factory.Allowance, 51)} { + f.request(t, "PUT", "/v1/groups/g/policy", "operator-secret", map[string]any{"allowances": allowances}, 422, nil) + } + for _, group := range []string{strings.Repeat("x", 201), "%00"} { + f.request(t, "PUT", "/v1/groups/"+group+"/policy", "operator-secret", map[string]any{}, 422, nil) + } + after, err := os.ReadFile(filepath.Join(f.st.Root(), "factory-policies.json")) + if err != nil || string(after) != string(before) { + t.Fatalf("invalid wrote policy: %s, %v", after, err) + } +} + +// P11: the first setter owns a team policy and only that person's jobs start. +func TestPolicyTeamOwnership(t *testing.T) { + f, writer, reader := waitsTeamSetup(t) + f.h.waits.launch = func(id int) error { f.launches++; return lease.NewDoors(f.h.workers.desk).Launch(id) } + var p api.Policy + f.request(t, "PUT", "/v1/groups/g/policy", writer, map[string]any{"allowances": []factory.Allowance{}}, 200, &p) + if p.Owner != "writer" { + t.Fatal(p) + } + f.request(t, "PUT", "/v1/groups/g/policy", reader, map[string]any{"stopped": true}, 403, nil) + f.request(t, "PUT", "/v1/groups/g/policy", "floor-secret", map[string]any{"stopped": false}, 200, &p) + if p.Owner != "writer" { + t.Fatal("operator changed owner") + } + parent := waitsTeamJob(t, f, "acme/api", "writer", 1107) + var children []factory.Item + for _, person := range []string{"writer", "reader", ""} { + child := waitsTeamJob(t, f, "acme/api", person, 0) + f.edit(t, child.ID, func(it *factory.Item) { + it.Group, it.Cap = "g", 5 + it.WaitsOn, it.WaitingOn = []int{parent.ID}, []int{parent.ID} + }) + children = append(children, child) + } + a := policyAllowance(f, children[0].ID, children[1].ID, children[2].ID) + f.request(t, "PUT", "/v1/groups/g/policy", writer, map[string]any{"allowances": []factory.Allowance{a}}, 200, nil) + f.merge(t, parent.ID) + f.scan(t) + if f.launches != 2 { + t.Fatalf("owner starts = %d", f.launches) + } + if it := f.item(t, children[1].ID); it.State != factory.StateNew || it.Blocked == nil || it.Blocked.By != "policy" { + t.Fatal(it) + } + if it := f.item(t, children[2].ID); it.Person() != "writer" { + t.Fatalf("unassigned start = %+v", it) + } +} + +// P13: a damaged policy file never admits a release. +func TestPolicyUnreadableRelease(t *testing.T) { + f := waitsSetup(t) + parent, child := policyPair(t, f) + putPolicy(t, f, false, policyAllowance(f, child.ID)) + f.merge(t, parent.ID) + if err := os.WriteFile(filepath.Join(f.st.Root(), "factory-policies.json"), []byte("{"), 0600); err != nil { + t.Fatal(err) + } + f.scan(t) + if it := f.item(t, child.ID); f.launches != 0 || it.State != factory.StateNew || it.Blocked == nil || it.Blocked.Reason != "its group's policy cannot be read" { + t.Fatalf("unreadable started: %+v", it) + } +} diff --git a/internal/factory/serve/policy_worker_test.go b/internal/factory/serve/policy_worker_test.go new file mode 100644 index 0000000000..cc1264ae8e --- /dev/null +++ b/internal/factory/serve/policy_worker_test.go @@ -0,0 +1,54 @@ +package serve_test + +import ( + "fmt" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/factory" + "github.com/Agent-Field/codeaf/internal/factory/api" +) + +// P5, P6: the actual worker report and step-begin HTTP routes withdraw the +// lease, retain spending and expose the policy reason on the job read. +func TestWorkerPolicyGate(t *testing.T) { + for _, ended := range []bool{false, true} { + t.Run(fmt.Sprint("ended=", ended), func(t *testing.T) { + f := workerSetupAs(t, "owner") + t.Cleanup(f.handler.Close) + worker := f.attach(t, "owner") + job := f.add(t, "owner") + if err := f.st.Update(job.ID, func(it *factory.Item) error { it.Group, it.PolicyRun, it.Cap = "g", "m2", 20; return nil }); err != nil { + t.Fatal(err) + } + instant := time.Unix(0, f.clock.Load()).UTC() + if _, err := f.st.ChangePolicy("g", func(p *factory.GroupPolicy) error { + p.Allowances = []factory.Allowance{{ID: "m2", Jobs: []int{job.ID}, CapUSD: 5, EndsAt: instant.Add(10 * time.Second).Format(time.RFC3339)}} + return nil + }); err != nil { + t.Fatal(err) + } + pulled := f.pull(t, worker) + if ended { + f.clock.Add(int64(10 * time.Second)) + } + cost := 5.0 + if ended { + cost = 0 + } + endpoint := fmt.Sprintf("/v1/workers/leases/%s", pulled.Lease.ID) + f.request(t, "POST", endpoint+"/report", worker.Credential, api.WorkerReport{Cost: cost, Steps: []api.StepReport{{Name: "proof", State: api.StepRunning, Round: 1}}}, 409, nil) + f.request(t, "POST", endpoint+"/heartbeat", worker.Credential, struct{}{}, 409, nil) + f.request(t, "POST", endpoint+"/done", worker.Credential, api.WorkerDone{State: api.StateBacklog}, 409, nil) + var j api.Job + f.request(t, "GET", fmt.Sprintf("/v1/jobs/%d", job.ID), "", nil, 200, &j) + if j.State != "needs_you" || j.Blocked == nil || j.Blocked.By != "policy" || j.LifetimeCost != cost || j.Held { + t.Fatalf("read = %+v", j) + } + it, err := f.st.Get(job.ID) + if err != nil || it.PolicyRun != "" || it.Asking == nil || it.QKind != "cap" { + t.Fatalf("hold = %+v, %v", it, err) + } + }) + } +} diff --git a/internal/factory/serve/repo_issues.go b/internal/factory/serve/repo_issues.go new file mode 100644 index 0000000000..4b44c478b4 --- /dev/null +++ b/internal/factory/serve/repo_issues.go @@ -0,0 +1,187 @@ +package serve + +import ( + "encoding/json" + "fmt" + "io" + "net/http" + "net/url" + "strconv" + "time" + "unicode/utf8" + + "github.com/Agent-Field/codeaf/internal/factory/api" +) + +// GitHub can return fifty full issue bodies before the public body cut. +const repoIssuesGitHubMax = 32 << 20 + +type githubRepoIssue struct { + ID int64 `json:"id"` + Number int `json:"number"` + Title string `json:"title"` + Body string `json:"body"` + URL string `json:"html_url"` + State string `json:"state"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` + PullRequest json.RawMessage `json:"pull_request"` +} + +// repoIssues reads GitHub as this caller, after the usual repository check. +func (h *Handler) repoIssues(w http.ResponseWriter, r *http.Request) { + query, err := repoIssuesQuery(r.URL.RawQuery) + if err != nil { + h.fail(w, http.StatusUnprocessableEntity, err.Error()) + return + } + owner, name, ok := h.splitRepo(w, r) + if !ok { + return + } + token := h.repoIssuesToken(w, r) + if token == "" { + return + } + path := "/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(name) + "/issues?" + query.Encode() + rows, ok := h.githubRepoIssues(w, r, path, token) + if ok { + h.write(w, http.StatusOK, repoIssuesOf(owner+"/"+name, rows)) + } +} + +func repoIssuesQuery(raw string) (url.Values, error) { + query, err := url.ParseQuery(raw) + if err != nil { + return nil, fmt.Errorf("issues takes since and page, once each") + } + for key, values := range query { + if (key != "since" && key != "page") || len(values) != 1 { + return nil, fmt.Errorf("issues takes since and page, once each") + } + } + n := 1 + if query.Has("page") { + if n, err = strconv.Atoi(query.Get("page")); err != nil { + return nil, fmt.Errorf("page is a whole number from 1 to %d", api.RepoIssuesPageMax) + } + } + if err := api.CheckRepoIssues(query.Get("since"), n); err != nil { + return nil, err + } + out := url.Values{"state": {"all"}, "sort": {"updated"}, "direction": {"asc"}, + "per_page": {strconv.Itoa(api.RepoIssuesPerPage)}, "page": {strconv.Itoa(n)}} + if since := query.Get("since"); since != "" { + out.Set("since", since) + } + return out, nil +} + +// The credential rule matches searchIssues: a person's sign-in, or only a +// solo Factory's own profile. A team's host credential cannot stand in for it. +func (h *Handler) repoIssuesToken(w http.ResponseWriter, r *http.Request) string { + session, _ := h.credential(r) + token := session.githubToken + if token == "" && h.mode() == api.FactorySolo && h.config.GitHubToken != nil { + token = h.config.GitHubToken(r.Context()) + } + if h.authFailed(w, r) { + return "" + } + if token == "" { + h.fail(w, http.StatusUnauthorized, "Reconnect GitHub to read repository issues") + } + return token +} + +func (h *Handler) githubRepoIssues(w http.ResponseWriter, r *http.Request, path, token string) ([]githubRepoIssue, bool) { + req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, "https://api.github.com"+path, nil) + if h.failed(w, err) { + return nil, false + } + req.Header.Set("Authorization", "Bearer "+token) + req.Header.Set("Accept", "application/vnd.github+json") + req.Header.Set("Content-Type", "application/json") + req.Header.Set("User-Agent", "codeaf-factory") + client := h.githubClient() + response, err := client.Do(req) + if err != nil { + h.fail(w, http.StatusBadGateway, "GitHub isn't answering; retry this issue read") + return nil, false + } + defer response.Body.Close() + if !h.repoIssuesStatus(w, r, response, token) { + return nil, false + } + var rows []githubRepoIssue + raw, err := io.ReadAll(io.LimitReader(response.Body, repoIssuesGitHubMax+1)) + if err != nil || len(raw) > repoIssuesGitHubMax || json.Unmarshal(raw, &rows) != nil || len(rows) > api.RepoIssuesPerPage { + h.fail(w, http.StatusBadGateway, "GitHub returned an invalid repository issues response") + return nil, false + } + return rows, true +} + +func (h *Handler) repoIssuesStatus(w http.ResponseWriter, r *http.Request, response *http.Response, token string) bool { + status, header := response.StatusCode, response.Header + switch { + case status == http.StatusOK: + return true + case githubLimitSpent(status, header): + h.write(w, http.StatusTooManyRequests, struct { + Factory api.Factory `json:"factory"` + Error string `json:"error"` + api.RepoIssuesRetry + }{h.factory, "GitHub's rate limit is spent; retry after the reset", api.RepoIssuesRetry{RetryAt: h.repoIssuesRetryAt(header)}}) + case status == http.StatusNotFound: + h.fail(w, http.StatusNotFound, "repository not found") + case status == http.StatusUnauthorized: + h.endGitHub(r, token) + h.authFailed(w, r) + default: + h.fail(w, http.StatusBadGateway, fmt.Sprintf("GitHub refused the repository issues read (HTTP %d)", status)) + } + return false +} + +func (h *Handler) repoIssuesRetryAt(header http.Header) time.Time { + if reset, err := strconv.ParseInt(header.Get("X-RateLimit-Reset"), 10, 64); err == nil && reset > 0 { + return time.Unix(reset, 0).UTC() + } + if seconds, err := strconv.ParseInt(header.Get("Retry-After"), 10, 32); err == nil && seconds >= 0 { + return h.clock().Add(time.Duration(seconds) * time.Second).UTC() + } + if retry, err := http.ParseTime(header.Get("Retry-After")); err == nil { + return retry.UTC() + } + // A spent limit without either clock hint still needs a usable retry time. + return h.clock().Add(time.Minute).UTC() +} + +func repoIssuesOf(repo string, rows []githubRepoIssue) api.RepoIssues { + out := api.RepoIssues{Repo: repo, Issues: []api.RepoIssue{}, Rows: len(rows)} + for _, row := range rows { + if out.Newest == nil || row.UpdatedAt.After(*out.Newest) { + stamp := row.UpdatedAt + out.Newest = &stamp + } + if len(row.PullRequest) != 0 && string(row.PullRequest) != "null" { + continue + } + body, cut := repoIssueBody(row.Body) + out.Issues = append(out.Issues, api.RepoIssue{ID: row.ID, Number: row.Number, Title: row.Title, Body: body, URL: row.URL, + State: row.State, CreatedAt: row.CreatedAt, UpdatedAt: row.UpdatedAt, BodyCut: cut}) + } + return out +} + +func repoIssueBody(body string) (string, bool) { + if len(body) <= api.RepoIssueBodyMax { + return body, false + } + end := api.RepoIssueBodyMax + for !utf8.RuneStart(body[end]) { + end-- + } + return body[:end], true +} diff --git a/internal/factory/serve/repo_issues_test.go b/internal/factory/serve/repo_issues_test.go new file mode 100644 index 0000000000..0f3a223e1b --- /dev/null +++ b/internal/factory/serve/repo_issues_test.go @@ -0,0 +1,317 @@ +package serve + +import ( + "context" + "encoding/json" + "io" + "net/http" + "net/http/httptest" + "net/url" + "reflect" + "strings" + "testing" + "time" + "unicode/utf8" + + "github.com/Agent-Field/codeaf/internal/factory/api" +) + +func repoIssuesFloor(t *testing.T) (*Handler, string) { + t.Helper() + h, clock, g := visibilityFloor(t, map[string]map[string]bool{"reader": {"acme/api": true}}) + token := signViewer(t, h, g, clock, "reader") + // Cache both visibility answers before installing the issues-only transport. + for repo, status := range map[string]int{"api": 200, "web": 404} { + w := identityRequest(h, "GET", "/v1/repos/acme/"+repo+"/facts", "", token, nil) + if w.Code != status { + t.Fatalf("prime %s = %d %s", repo, w.Code, w.Body) + } + } + return h, token +} + +func repoIssuesGitHub(h *Handler, serve func(*http.Request) (int, http.Header, string)) { + h.config.GitHubHTTPClient = &http.Client{Transport: githubTransport(func(r *http.Request) (*http.Response, error) { + status, header, body := serve(r) + return &http.Response{StatusCode: status, Header: header, Body: io.NopCloser(strings.NewReader(body))}, nil + })} +} + +func readRepoIssuesAnswer(t *testing.T, h *Handler, token, query string) (api.RepoIssues, *httptest.ResponseRecorder) { + t.Helper() + w := identityRequest(h, "GET", "/v1/repos/acme/api/issues"+query, "", token, nil) + var got api.RepoIssues + if w.Code != 200 || json.Unmarshal(w.Body.Bytes(), &got) != nil { + t.Fatalf("read = %d %s", w.Code, w.Body) + } + return got, w +} + +// C2, C3, C4: Every issue state and field survives; PRs only count toward +// rows and newest. The caller's cursor and the fixed ordering reach GitHub. +func TestRepoIssuesListsEveryStateAndCountsPullRequests(t *testing.T) { + h, token := repoIssuesFloor(t) + const since = "2026-11-01T02:03:04.123+05:30" + long := strings.Repeat("界", api.RepoIssueBodyMax/3) + "🙂" + "x" + rows := []map[string]any{ + {"id": int64(9007199254740993), "number": 7, "title": "Open retry", "body": long, "html_url": "https://github.com/acme/api/issues/7", "state": "open", "created_at": "2026-10-01T00:00:00Z", "updated_at": "2026-11-01T01:00:00Z"}, + {"id": 82, "number": 8, "title": "Closed retry", "body": "fixed", "html_url": "https://github.com/acme/api/issues/8", "state": "closed", "created_at": "2026-10-02T00:00:00Z", "updated_at": "2026-11-01T02:00:00Z"}, + {"id": 83, "number": 9, "updated_at": "2026-11-01T03:00:00Z", "pull_request": map[string]any{"url": "https://api.github.com/repos/acme/api/pulls/9"}}, + } + raw, err := json.Marshal(rows) + if err != nil { + t.Fatal(err) + } + calls := 0 + repoIssuesGitHub(h, func(r *http.Request) (int, http.Header, string) { + calls++ + want := url.Values{"state": {"all"}, "sort": {"updated"}, "direction": {"asc"}, "per_page": {"50"}, "since": {since}, "page": {"3"}} + if r.Method != "GET" || r.URL.Host != "api.github.com" || r.URL.Path != "/repos/acme/api/issues" || !reflect.DeepEqual(r.URL.Query(), want) { + t.Errorf("GitHub request = %s %s", r.Method, r.URL) + } + if r.Header.Get("Authorization") != "Bearer gh-reader" || r.Header.Get("Accept") != "application/vnd.github+json" || + r.Header.Get("Content-Type") != "application/json" || r.Header.Get("User-Agent") != "codeaf-factory" { + t.Errorf("GitHub headers = %v", r.Header) + } + return 200, http.Header{}, string(raw) + }) + got, _ := readRepoIssuesAnswer(t, h, token, "?"+url.Values{"since": {since}, "page": {"3"}}.Encode()) + if calls != 1 || got.Repo != "acme/api" || got.Rows != 3 || len(got.Issues) != 2 || got.Newest == nil || got.Newest.Format(time.RFC3339) != "2026-11-01T03:00:00Z" { + t.Fatalf("result = %+v, calls = %d", got, calls) + } + checkRepoIssueFields(t, got.Issues) +} + +func checkRepoIssueFields(t *testing.T, issues []api.RepoIssue) { + t.Helper() + first, second := issues[0], issues[1] + if first.ID != 9007199254740993 || first.Number != 7 || first.Title != "Open retry" || first.State != "open" || + first.URL != "https://github.com/acme/api/issues/7" || first.CreatedAt.Format(time.RFC3339) != "2026-10-01T00:00:00Z" || + first.UpdatedAt.Format(time.RFC3339) != "2026-11-01T01:00:00Z" || !first.BodyCut || !utf8.ValidString(first.Body) || + first.Body != strings.Repeat("界", api.RepoIssueBodyMax/3) { + t.Fatalf("first issue = %+v", first) + } + if second.ID != 82 || second.Number != 8 || second.Title != "Closed retry" || second.State != "closed" || second.Body != "fixed" || second.BodyCut || + second.URL != "https://github.com/acme/api/issues/8" || second.CreatedAt.Format(time.RFC3339) != "2026-10-02T00:00:00Z" || !first.UpdatedAt.Before(second.UpdatedAt) { + t.Fatalf("second issue = %+v", second) + } +} + +// C3, C4: Empty and PR-only results retain the right cursor and row count; +// a full result counts 50 rows and defaults to the first request with no since. +func TestRepoIssuesEmptyPullRequestOnlyAndFullResults(t *testing.T) { + h, token := repoIssuesFloor(t) + pr := `{"updated_at":"2026-11-01T03:00:00Z","pull_request":{}}` + for _, tc := range []struct { + name, body string + rows int + }{ + {"empty", `[]`, 0}, + {"pull requests", `[` + pr + `]`, 1}, + {"full", `[` + strings.Repeat(pr+",", api.RepoIssuesPerPage-1) + pr + `]`, api.RepoIssuesPerPage}, + } { + t.Run(tc.name, func(t *testing.T) { + repoIssuesGitHub(h, func(r *http.Request) (int, http.Header, string) { + if r.URL.Query().Get("page") != "1" || r.URL.Query().Has("since") { + t.Errorf("default query = %s", r.URL.RawQuery) + } + return 200, http.Header{}, tc.body + }) + got, w := readRepoIssuesAnswer(t, h, token, "") + if got.Rows != tc.rows || got.Issues == nil || len(got.Issues) != 0 || (got.Newest == nil) != (tc.rows == 0) || + !strings.Contains(w.Body.String(), `"issues":[]`) || (tc.rows == 0 && strings.Contains(w.Body.String(), `"newest"`)) { + t.Fatalf("result = %s", w.Body) + } + }) + } +} + +// C4, C5, C8: Invalid queries, hidden repositories and writes never call GitHub. +func TestRepoIssuesRefusesQueriesHiddenRepositoriesAndWrites(t *testing.T) { + h, token := repoIssuesFloor(t) + repoIssuesGitHub(h, func(r *http.Request) (int, http.Header, string) { + t.Errorf("unexpected GitHub request: %s %s", r.Method, r.URL) + return 500, http.Header{}, "" + }) + for _, query := range []string{"?other=x", "?state=all", "?since=a&since=b", "?page=1&page=2", "?page=0", "?page=11", "?page=x", "?page=", "?since=yesterday", "?since=2026-11-01", "?bad=%zz"} { + w := identityRequest(h, "GET", "/v1/repos/acme/api/issues"+query, "", token, nil) + if w.Code != 422 { + t.Errorf("%s = %d %s", query, w.Code, w.Body) + } + } + w := identityRequest(h, "GET", "/v1/repos/acme/web/issues", "", token, nil) + if w.Code != 404 || !strings.Contains(w.Body.String(), `"error":"repository not found"`) { + t.Fatalf("hidden repository = %d %s", w.Code, w.Body) + } + for _, method := range []string{"POST", "PUT", "PATCH", "DELETE", "HEAD"} { + w := identityRequest(h, method, "/v1/repos/acme/api/issues", "", token, nil) + if w.Code != 405 || w.Header().Get("Allow") != "GET" { + t.Errorf("%s = %d, Allow %q", method, w.Code, w.Header().Get("Allow")) + } + } +} + +// C6: Both GitHub limit statuses carry the reset, with retry-after as fallback. +func TestRepoIssuesRateLimitsCarryRetryAt(t *testing.T) { + h, token := repoIssuesFloor(t) + now := time.Date(2026, 11, 1, 0, 0, 0, 0, time.UTC) + h.config.Now = func() time.Time { return now } + for _, tc := range []struct { + status int + header http.Header + want time.Time + }{ + {403, http.Header{"X-Ratelimit-Remaining": {"0"}, "X-Ratelimit-Reset": {"1793494800"}}, now.Add(time.Hour)}, + {429, http.Header{"X-Ratelimit-Remaining": {"0"}, "X-Ratelimit-Reset": {"1793494800"}, "Retry-After": {"30"}}, now.Add(time.Hour)}, + {403, http.Header{"Retry-After": {"30"}}, now.Add(30 * time.Second)}, + {429, http.Header{"Retry-After": {now.Add(time.Minute).Format(http.TimeFormat)}}, now.Add(time.Minute)}, + } { + repoIssuesGitHub(h, func(*http.Request) (int, http.Header, string) { return tc.status, tc.header, `{}` }) + w := identityRequest(h, "GET", "/v1/repos/acme/api/issues", "", token, nil) + var retry api.RepoIssuesRetry + if w.Code != 429 || json.Unmarshal(w.Body.Bytes(), &retry) != nil || !retry.RetryAt.Equal(tc.want) { + t.Errorf("GitHub %d = %d %s, want %s", tc.status, w.Code, w.Body, tc.want) + } + } +} + +// C8: The route itself stores nothing and appends no activity; the Factory's +// ordinary per-request housekeeping runs as it does for every authenticated request. +// This fixture has no job store and supplies a fixed snapshot, so it covers +// only the route's own effects. +func TestRepoIssuesRouteStoresNothingAndAppendsNoActivity(t *testing.T) { + h, token := repoIssuesFloor(t) + before, err := h.config.Record.Since(context.Background(), time.Time{}) + if err != nil { + t.Fatal(err) + } + snapshot, err := h.seam.Load() + if err != nil { + t.Fatal(err) + } + repoIssuesGitHub(h, func(*http.Request) (int, http.Header, string) { return 200, http.Header{}, `[]` }) + readRepoIssuesAnswer(t, h, token, "") + after, err := h.config.Record.Since(context.Background(), time.Time{}) + if err != nil { + t.Fatal(err) + } + next, err := h.seam.Load() + if err != nil || !reflect.DeepEqual(before, after) || !reflect.DeepEqual(snapshot, next) { + t.Fatalf("route changed the fixture's snapshot or activity: %v", err) + } +} + +// C2: Bodies retain the first 8,192 bytes on a rune boundary, including matching +// words after character 4,000. Only bodies beyond the byte limit are marked cut. +func TestRepoIssuesBodyCutBoundary(t *testing.T) { + h, token := repoIssuesFloor(t) + ascii := strings.Repeat("a", 8192) + emoji := strings.Repeat("🙂", 2048) + han := strings.Repeat("界", 2730) + matching := strings.Repeat("a", 4001) + " relatedness " + strings.Repeat("z", 5000) + for _, tc := range []struct { + name, body, want string + cut bool + }{ + {"empty", "", "", false}, + {"ASCII below limit", ascii[:8191], ascii[:8191], false}, + {"ASCII at limit", ascii, ascii, false}, + {"ASCII beyond limit", ascii + "a", ascii, true}, + {"matching word after character 4000", matching, matching[:8192], true}, + {"multibyte below limit", han, han, false}, + {"multibyte at limit", emoji, emoji, false}, + {"multibyte crossing limit", han + "🙂", han, true}, + {"multibyte at last byte", ascii[:8191] + "🙂", ascii[:8191], true}, + {"multibyte beyond limit", emoji + "🙂", emoji, true}, + } { + t.Run(tc.name, func(t *testing.T) { + raw, err := json.Marshal([]map[string]any{{"body": tc.body, "updated_at": "2026-11-01T00:00:00Z"}}) + if err != nil { + t.Fatal(err) + } + repoIssuesGitHub(h, func(*http.Request) (int, http.Header, string) { return 200, http.Header{}, string(raw) }) + got, _ := readRepoIssuesAnswer(t, h, token, "") + if len(got.Issues) != 1 { + t.Fatalf("issues = %+v", got.Issues) + } + issue := got.Issues[0] + if issue.Body != tc.want || issue.BodyCut != tc.cut || len(issue.Body) > 8192 || !utf8.ValidString(issue.Body) { + t.Fatalf("body = %q (%d bytes), cut = %t; want %q, cut = %t", issue.Body, len(issue.Body), issue.BodyCut, tc.want, tc.cut) + } + }) + } +} + +// C5 and the sign-in contract: GitHub's own 404 stays not found, and a +// rejected GitHub sign-in is github_expired rather than a missing repository. +func TestRepoIssuesMapsGitHubRepositoryAndSignInFailures(t *testing.T) { + for _, tc := range []struct{ github, status int }{{404, 404}, {403, 502}, {401, 401}} { + h, token := repoIssuesFloor(t) + repoIssuesGitHub(h, func(*http.Request) (int, http.Header, string) { return tc.github, http.Header{}, `{}` }) + w := identityRequest(h, "GET", "/v1/repos/acme/api/issues", "", token, nil) + if w.Code != tc.status { + t.Fatalf("GitHub %d = %d %s", tc.github, w.Code, w.Body) + } + if tc.github == 401 && !strings.Contains(w.Body.String(), `"reason":"github_expired"`) { + t.Fatalf("GitHub sign-in = %s", w.Body) + } + } +} + +// The credential contract permits the local profile only on a solo Factory. +func TestRepoIssuesOnlySoloMayUseTheFactoryProfile(t *testing.T) { + for _, mode := range []string{api.FactoryTeam, api.FactorySolo} { + t.Run(mode, func(t *testing.T) { + h, _ := repoIssuesFloor(t) + h.config.Mode = mode + h.config.GitHubToken = func(context.Context) string { return "gh-profile" } + calls := 0 + repoIssuesGitHub(h, func(r *http.Request) (int, http.Header, string) { + calls++ + if mode != api.FactorySolo || r.Header.Get("Authorization") != "Bearer gh-profile" { + t.Errorf("%s used profile: %v", mode, r.Header) + } + return 200, http.Header{}, `[]` + }) + w := identityRequest(h, "GET", "/v1/repos/acme/api/issues", "", "floor-secret", nil) + wantStatus, wantCalls := 401, 0 + if mode == api.FactorySolo { + wantStatus, wantCalls = 200, 1 + } + if w.Code != wantStatus || calls != wantCalls { + t.Fatalf("%s = %d %s, GitHub calls %d", mode, w.Code, w.Body, calls) + } + }) + } +} + +// C2, C3: Oversized or invalid upstream results are refused within the +// bounded body read rather than reported as a valid partial result. +func TestRepoIssuesRefusesInvalidGitHubResults(t *testing.T) { + h, token := repoIssuesFloor(t) + for _, body := range []string{`{}`, `[] {}`, `[` + strings.Repeat(`{},`, api.RepoIssuesPerPage) + `{}` + `]`} { + repoIssuesGitHub(h, func(*http.Request) (int, http.Header, string) { return 200, http.Header{}, body }) + w := identityRequest(h, "GET", "/v1/repos/acme/api/issues", "", token, nil) + if w.Code != 502 { + t.Fatalf("invalid GitHub result = %d %s", w.Code, w.Body) + } + } + spaces := &repoIssuesSpaces{} + h.config.GitHubHTTPClient = &http.Client{Transport: githubTransport(func(*http.Request) (*http.Response, error) { + return &http.Response{StatusCode: 200, Header: http.Header{}, Body: io.NopCloser(io.LimitReader(spaces, repoIssuesGitHubMax+2))}, nil + })} + w := identityRequest(h, "GET", "/v1/repos/acme/api/issues", "", token, nil) + if w.Code != 502 || spaces.read != repoIssuesGitHubMax+1 { + t.Fatalf("oversized GitHub result = %d %s, read %d bytes", w.Code, w.Body, spaces.read) + } +} + +type repoIssuesSpaces struct{ read int } + +func (s *repoIssuesSpaces) Read(p []byte) (int, error) { + for i := range p { + p[i] = ' ' + } + s.read += len(p) + return len(p), nil +} diff --git a/internal/factory/serve/repos_v2.go b/internal/factory/serve/repos_v2.go index c570dd6f8d..28c2e49a0b 100644 --- a/internal/factory/serve/repos_v2.go +++ b/internal/factory/serve/repos_v2.go @@ -29,6 +29,9 @@ func (h *Handler) repoPower(w http.ResponseWriter, r *http.Request) { return } if !h.repoVisible(r, repo) { + if h.githubVisibilityLimited(w, r) { + return + } h.fail(w, http.StatusNotFound, "repository not found") return } diff --git a/internal/factory/serve/saved_bookkeeping_test.go b/internal/factory/serve/saved_bookkeeping_test.go new file mode 100644 index 0000000000..67d40cd175 --- /dev/null +++ b/internal/factory/serve/saved_bookkeeping_test.go @@ -0,0 +1,174 @@ +package serve + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "os" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/factory" + "github.com/Agent-Field/codeaf/internal/factory/api" + "github.com/Agent-Field/codeaf/internal/factory/live" + "github.com/Agent-Field/codeaf/internal/factory/store" +) + +func TestPolicyBookkeepingLeavesAuthorAccessUnchanged(t *testing.T) { + for _, method := range []string{http.MethodGet, http.MethodPut} { + t.Run(method, func(t *testing.T) { + f := waitsSetup(t) + job := f.addJob(t, 1) + f.edit(t, job.ID, func(it *factory.Item) { + it.Author, it.Group, it.State = "author", "g", factory.StateRunning + }) + var lookups atomic.Int32 + f.st.SetTierResolver(func(context.Context, factory.Item) (factory.Tier, time.Time, error) { + lookups.Add(1) + return factory.TierCollab, time.Time{}, nil + }) + var out api.Policy + var body any + if method == http.MethodPut { + body = map[string]any{"stopped": true, "allowances": []factory.Allowance{policyAllowance(f, job.ID)}} + } + raw, err := json.Marshal(body) + if err != nil { + t.Fatal(err) + } + r := httptest.NewRequest(method, "/v1/groups/g/policy", bytes.NewReader(raw)) + r.SetPathValue("group", "g") + r.Header.Set("Authorization", "Bearer operator-secret") + w := httptest.NewRecorder() + // Request settlement hydrates jobs before routing; this handler owns policy bookkeeping. + f.h.groupPolicy(w, authRequest(r)) + if w.Code != http.StatusOK || json.Unmarshal(w.Body.Bytes(), &out) != nil { + t.Fatalf("policy response: %d %s", w.Code, w.Body) + } + if out.Group != "g" || out.Stopped != (method == http.MethodPut) { + t.Fatalf("policy response: %+v", out) + } + got, err := f.st.GetSaved(job.ID) + if err != nil || lookups.Load() != 0 || got.Tier != "" || got.Failure != nil { + t.Fatalf("policy bookkeeping: lookups=%d tier=%q failure=%+v err=%v", lookups.Load(), got.Tier, got.Failure, err) + } + if method == http.MethodPut && !got.StopAfterStep { + t.Fatal("stopping the group did not arm its job's step boundary") + } + }) + } +} + +func TestWaitsIgnoreTransientAuthorLookupFailure(t *testing.T) { + for _, scan := range []bool{false, true} { + t.Run(map[bool]string{false: "set", true: "release"}[scan], func(t *testing.T) { + f := waitsSetup(t) + parent, child := f.addJob(t, 1), f.addJob(t, 0) + f.edit(t, parent.ID, func(it *factory.Item) { + it.Author, it.State = "author", factory.StateShipped + it.CommentURL = "https://github.com/owner/api/issues/1#issuecomment-1" + }) + f.st.SetTierResolver(func(context.Context, factory.Item) (factory.Tier, time.Time, error) { + return "", time.Time{}, errors.New("permission lookup unavailable") + }) + if read, err := f.st.Get(parent.ID); err != nil || read.Failure == nil { + t.Fatalf("lookup failure fixture: failure=%+v err=%v", read.Failure, err) + } + if scan { + f.edit(t, child.ID, func(it *factory.Item) { it.WaitsOn = []int{parent.ID} }) + f.scan(t) + } else { + f.set(t, child.ID, []int{parent.ID}, http.StatusOK) + } + got, err := f.st.GetSaved(child.ID) + if err != nil || len(got.WaitingOn) != 0 || got.Blocked != nil || got.WaitsReleased != scan { + t.Fatalf("transient lookup changed waits: %+v err=%v", got, err) + } + }) + } +} + +func TestJobEventsVisibilityUsesSavedAuthorAccess(t *testing.T) { + h, clock, github := visibilityFloor(t, map[string]map[string]bool{"priya": {"acme/api": true}}) + token := signViewer(t, h, github, clock, "priya") + st, err := store.Open(h.eventRoot()) + if err != nil { + t.Fatal(err) + } + snap, err := h.seam.Load() + if err != nil { + t.Fatal(err) + } + for _, it := range snap.Items { + it.ID, it.Num, it.Author = 0, 1, "author" + if _, err := st.Create(it); err != nil { + t.Fatal(err) + } + } + h.config.Store = st + h.seam.Load = func() (factory.Snapshot, error) { + items, err := st.List() + return factory.Snapshot{Items: items}, err + } + var lookups atomic.Int32 + st.SetTierResolver(func(context.Context, factory.Item) (factory.Tier, time.Time, error) { + lookups.Add(1) + return factory.TierCollab, time.Time{}, nil + }) + for _, id := range []int{1, 2, 3} { + if err := live.Append(st.Root(), live.New(live.OutcomeKind, id, live.Outcome{Job: id, Kind: "finished"})); err != nil { + t.Fatal(err) + } + } + r := httptest.NewRequest(http.MethodGet, "/v1/job-events?after=0", nil) + r.RemoteAddr = "192.0.2.1:9" + r.Header.Set("Authorization", "Bearer "+token) + w := httptest.NewRecorder() + // Request settlement hydrates the queue before the event handler checks saved visibility. + h.jobEvents(w, authRequest(r)) + got := decodeJobEvents(t, w) + if got.Reset || got.Cursor != 3 || len(got.Events) != 2 || got.Events[0].Job != 1 || got.Events[1].Job != 3 || lookups.Load() != 0 { + t.Fatalf("saved visibility: %+v lookups=%d", got, lookups.Load()) + } +} + +func TestSplitFromValidationDoesNotHydrateParent(t *testing.T) { + f := waitsSetup(t) + parent, child := f.addJob(t, 7), f.addJob(t, 0) + f.edit(t, parent.ID, func(it *factory.Item) { it.Author = "author" }) + before, err := os.ReadFile(f.st.ItemPath(parent.ID)) + if err != nil { + t.Fatal(err) + } + var lookups atomic.Int32 + f.st.SetTierResolver(func(context.Context, factory.Item) (factory.Tier, time.Time, error) { + lookups.Add(1) + return factory.TierCollab, time.Time{}, nil + }) + body := api.NewJob{Repo: "owner/api", Title: "Smaller fix", SplitFrom: &parent.ID} + r := authRequest(httptest.NewRequest(http.MethodPost, "/v1/jobs", nil)) + r.Header.Set("Authorization", "Bearer operator-secret") + w := httptest.NewRecorder() + // Validate and stamp directly so request settlement and create admission cannot hydrate the parent first. + if !f.h.checkNewJobExtras(w, r, &body) { + t.Fatalf("split validation: %d %s", w.Code, w.Body) + } + if err := f.h.stampNewJobExtras(r, child.ID, body); err != nil { + t.Fatal(err) + } + child, err = f.st.GetSaved(child.ID) + if err != nil { + t.Fatal(err) + } + after, err := os.ReadFile(f.st.ItemPath(parent.ID)) + if err != nil || !bytes.Equal(before, after) || lookups.Load() != 0 || child.SplitFrom != parent.ID { + t.Fatalf("split changed its parent: lookups=%d split_from=%d err=%v", lookups.Load(), child.SplitFrom, err) + } + if read, err := f.st.Get(parent.ID); err != nil || read.Tier != factory.TierCollab || lookups.Load() != 1 { + t.Fatalf("user read did not hydrate: tier=%q lookups=%d err=%v", read.Tier, lookups.Load(), err) + } +} diff --git a/internal/factory/serve/serve.go b/internal/factory/serve/serve.go index 76352d983a..7290432cfb 100644 --- a/internal/factory/serve/serve.go +++ b/internal/factory/serve/serve.go @@ -162,6 +162,8 @@ type Config struct { // Handler keeps the seam and host identity together for every response. type Handler struct { + waits waitsScheduler + setupMu sync.Mutex setupBusy bool setupClosed bool @@ -220,6 +222,8 @@ func New(seam factory.Seam, config Config) *Handler { h := &Handler{config: config, factory: api.Factory{Address: config.Address, Version: config.Version}, mux: http.NewServeMux()} h.seam = h.areas.remembering(seam) + h.waits.launch = h.seam.Launch + h.seam = h.waitsLaunches(h.seam) h.factory.Name = factory.DisplayName(config.Name, h.nameRepos()) h.sessions = &accessSessions{ids: make(map[string]accessSession), now: time.Now} if config.Now != nil { @@ -236,13 +240,16 @@ func New(seam factory.Seam, config Config) *Handler { h.route("/access", map[string]http.HandlerFunc{http.MethodGet: h.access, http.MethodPost: h.access}) h.route("/v1/access/revoke-sessions", map[string]http.HandlerFunc{http.MethodPost: h.revokeSessions}) h.read("/v1/queue", h.queue) + h.policyRoutes() h.read("/v1/jobs/{id}", h.job) h.read("/v1/jobs/{id}/thread", h.thread) h.read("/v1/events", h.events) + h.read("/v1/job-events", h.jobEvents) h.route("/v1/repos", map[string]http.HandlerFunc{http.MethodGet: h.repos, http.MethodPut: h.setRepos}) h.read("/v1/repos/{owner}/{repo}/pipeline", h.pipeline) h.read("/v1/repos/{owner}/{repo}/pipeline/edit-source", h.pipelineEditSource) h.read("/v1/repos/{owner}/{repo}/read", h.repoRead) + h.read("/v1/repos/{owner}/{repo}/issues", h.repoIssues) h.read("/v1/repos/{owner}/{repo}/pipeline/prs", h.pipelinePRs) h.read("/v1/repos/{owner}/{repo}/dryrun", h.dryrun) h.read("/v1/repos/{owner}/{repo}/estimate", h.estimateNew) @@ -261,6 +268,7 @@ func New(seam factory.Seam, config Config) *Handler { h.read("/v1/audit", h.audit) h.read("/v1/publish/codes", h.publishCodes) h.verbs() + h.waitsRoutes() h.scoutRoutes() h.readRoutes() h.workerRoutes() @@ -289,6 +297,7 @@ func New(seam factory.Seam, config Config) *Handler { if root := strings.TrimSpace(h.eventRoot()); root != "" { h.watch = watchRoot(root) } + h.startWaits() h.machineEvents() return h } @@ -303,6 +312,7 @@ func (h *Handler) root(w http.ResponseWriter, r *http.Request) { // a change after Close is not delivered. func (h *Handler) Close() { if h != nil { + h.stopWaits() h.reposRead.mu.Lock() h.reposRead.closed = true if h.reposRead.cancel != nil { @@ -717,6 +727,9 @@ func (h *Handler) splitRepo(w http.ResponseWriter, r *http.Request) (owner, repo return "", "", false } if !visible { + if h.githubVisibilityLimited(w, r) { + return "", "", false + } h.fail(w, http.StatusNotFound, "repository not found") return "", "", false } @@ -843,12 +856,15 @@ func (h *Handler) about(w http.ResponseWriter, r *http.Request) { // AccessCheckPost is optional and public: POST /v1/factory-access takes // verb "check", so a client need not put a link's secret in a URL. AccessCheckPost bool `json:"access_check_post"` + + Features []string `json:"features"` + // GitHubLimited is the sentence an app shows while GitHub's rate // limit is spent, and GitHubResumesAt is when it resets. GitHubLimited string `json:"github_limited,omitempty"` GitHubResumesAt *time.Time `json:"github_resumes_at,omitempty"` }{h.factory, scheme(r), h.aboutName(), h.factory.Version, api.Contract, h.needsWord(), - h.accessMode(), h.pollingEvery(), h.servedBy(), true, limited, resumes}) + h.accessMode(), h.pollingEvery(), h.servedBy(), true, []string{factory.BlockWaitsOn, "group_policy"}, limited, resumes}) } // githubLimited is the reason and reset time about carries while GitHub's diff --git a/internal/factory/serve/session_refresh.go b/internal/factory/serve/session_refresh.go index dc9595a85d..de2fd8cf58 100644 --- a/internal/factory/serve/session_refresh.go +++ b/internal/factory/serve/session_refresh.go @@ -18,8 +18,9 @@ var errSessionEnded = errors.New("factory session has ended; sign in again") type authErrorKey struct{} type authErrorState struct { - err error - hadSession bool + err error + hadSession bool + githubRetryAt time.Time } func (h *Handler) setAuthError(r *http.Request, err error) { diff --git a/internal/factory/serve/verbs.go b/internal/factory/serve/verbs.go index 49bcc90120..0fb278e405 100644 --- a/internal/factory/serve/verbs.go +++ b/internal/factory/serve/verbs.go @@ -299,6 +299,9 @@ func (h *Handler) answer(w http.ResponseWriter, r *http.Request, it factory.Item // through the answer door: the runner holding it carries on, and the answer // is recorded. A cap no runner waits on any more goes back to the queue. func (h *Handler) continueCap(r *http.Request, it factory.Item, words string) error { + if handled, err := h.continuePolicy(r, it, words); handled { + return err + } if h.seam.Answer != nil { err := h.recordAnswer(r, it, true, words) if err == nil || !strings.Contains(err.Error(), "is not waiting on you") { @@ -755,6 +758,9 @@ func (h *Handler) newJob(w http.ResponseWriter, r *http.Request) { if !h.decode(w, r, &body, false) { return } + if !h.checkNewJobExtras(w, r, &body) { + return + } if strings.TrimSpace(body.Repo) == "" || strings.TrimSpace(body.Title) == "" { h.fail(w, http.StatusUnprocessableEntity, "new job requires repo and title") return @@ -801,6 +807,9 @@ func (h *Handler) newJob(w http.ResponseWriter, r *http.Request) { return } if !visible { + if h.githubVisibilityLimited(w, r) { + return + } h.fail(w, http.StatusNotFound, "repository not found") return } @@ -889,6 +898,9 @@ func (h *Handler) newJob(w http.ResponseWriter, r *http.Request) { if h.failed(w, h.stampAsk(r, id, body)) { return } + if h.failed(w, h.stampNewJobExtras(r, id, body)) { + return + } if selected != nil && h.failed(w, h.config.Store.Update(id, func(it *factory.Item) error { it.Stages = factory.CopyStages(selected) if decided { @@ -917,7 +929,7 @@ func (h *Handler) newJob(w http.ResponseWriter, r *http.Request) { } // stampAsk records the app's ask on a job this factory's store holds. -// The body carries the person's words, never a cap. +// The initial budget and attachments are stamped separately after these words. func (h *Handler) stampAsk(r *http.Request, id int, body api.NewJob) error { if h.config.Store == nil { return nil diff --git a/internal/factory/serve/visibility.go b/internal/factory/serve/visibility.go index b9fbbd975a..1122c3e09f 100644 --- a/internal/factory/serve/visibility.go +++ b/internal/factory/serve/visibility.go @@ -207,16 +207,16 @@ func (h *Handler) repoAllowed(r *http.Request, token, key string) bool { return allow } -// askRepo is GitHub's own answer for this token. A 5xx or a transport failure -// is not cached: the next read tries again. 200 is readable; anything else -// definitive is not. +// askRepo is GitHub's own answer for this token. A spent limit, 5xx or +// transport failure is not cached: the next read tries again. 200 is +// readable; anything else definitive is not. func (h *Handler) askRepo(r *http.Request, token, key string) (allow, cache bool) { owner, name, ok := strings.Cut(key, "/") if !ok || owner == "" || name == "" { return false, true } endpoint := "https://api.github.com/repos/" + url.PathEscape(owner) + "/" + url.PathEscape(name) - status, err := h.githubStatus(r, http.MethodGet, endpoint, token) + status, header, err := h.githubVisibilityStatus(r, endpoint, token) if status == http.StatusUnauthorized { h.endGitHub(r, token) return false, false @@ -224,6 +224,12 @@ func (h *Handler) askRepo(r *http.Request, token, key string) (allow, cache bool if err != nil || status >= 500 { return false, false } + if githubLimitSpent(status, header) { + if state, ok := r.Context().Value(authErrorKey{}).(*authErrorState); ok { + state.githubRetryAt = h.repoIssuesRetryAt(header) + } + return false, false + } return status == http.StatusOK, true } diff --git a/internal/factory/serve/visibility_bare429_test.go b/internal/factory/serve/visibility_bare429_test.go new file mode 100644 index 0000000000..84550dae13 --- /dev/null +++ b/internal/factory/serve/visibility_bare429_test.go @@ -0,0 +1,124 @@ +package serve + +import ( + "net/http" + "strconv" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/factory/api" + "github.com/Agent-Field/codeaf/internal/factory/store" +) + +// D1: A bare 429 on visibility answers the three-field retry body at every +// repository entry point, before issue reads, job creation or repo changes. +func TestBare429VisibilityAnswersRetryNotMissing(t *testing.T) { + for _, route := range []struct{ name, method, path, body string }{ + {"issues", "GET", "/v1/repos/acme/api/issues", ""}, + {"power off", "PUT", "/v1/repos/acme/api", `{"on":false}`}, + {"power on", "PUT", "/v1/repos/acme/api", `{"on":true}`}, + {"create", "POST", "/v1/jobs", `{"repo":"acme/api","title":"retry"}`}, + {"search", "GET", "/v1/issues?repo=acme/api", ""}, + } { + t.Run(route.name, func(t *testing.T) { + h, token, clock := visibilityLimitFloor(t) + if route.method == "PUT" { + var err error + h.config.Store, err = store.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + h.config.Mode, h.config.OperatorLogin = api.FactorySolo, "reader" + h.seam.SetRepos = func([]string) error { t.Error("changed repositories"); return nil } + } + h.seam.New = func(string, string) (int, error) { t.Error("created a job"); return 9, nil } + probes := 0 + repoIssuesGitHub(h, func(r *http.Request) (int, http.Header, string) { + probes++ + if r.Method != "GET" || r.URL.String() != "https://api.github.com/repos/acme/api" { + t.Errorf("unexpected GitHub request: %s %s", r.Method, r.URL) + } + return 429, http.Header{}, `{}` + }) + w := identityRequest(h, route.method, route.path, route.body, token, nil) + checkVisibilityRetry(t, h, w, clock.Add(time.Minute)) + if probes != 1 { + t.Fatalf("GitHub calls = %d, want only the visibility probe", probes) + } + }) + } +} + +// D2: The next request retries a bare 429 within the denial TTL, without +// waiting for retry_at; the recovered grant is cached normally. +func TestBare429VisibilityIsNotCached(t *testing.T) { + h, token, clock := visibilityLimitFloor(t) + probes, issues := 0, 0 + repoIssuesGitHub(h, func(r *http.Request) (int, http.Header, string) { + switch r.URL.Path { + case "/repos/acme/api": + probes++ + if probes == 1 { + return 429, http.Header{}, `{}` + } + return 200, http.Header{}, `{}` + case "/repos/acme/api/issues": + issues++ + return 200, http.Header{}, `[]` + default: + t.Errorf("unexpected GitHub request: %s", r.URL) + return 500, http.Header{}, `{}` + } + }) + checkVisibilityRetry(t, h, identityRequest(h, "GET", "/v1/repos/acme/api/issues", "", token, nil), clock.Add(time.Minute)) + if probes != 1 || issues != 0 { + t.Fatalf("limited read: probes=%d issues=%d", probes, issues) + } + for range 2 { + readRepoIssuesAnswer(t, h, token, "") + } + if probes != 2 || issues != 2 { + t.Fatalf("recovered reads: probes=%d issues=%d", probes, issues) + } +} + +// D3, D4: The issues read treats a bare 429 as busy with the Factory clock's +// default, while known resets take precedence and retry hints keep their times. +func TestRepoIssuesRateLimitsIncludeBare429(t *testing.T) { + h, token := repoIssuesFloor(t) + now := time.Date(2026, 11, 1, 0, 0, 0, 0, time.UTC) + h.config.Now = func() time.Time { return now } + reset := strconv.FormatInt(now.Add(time.Hour).Unix(), 10) + for _, tc := range []struct { + name string + status int + header http.Header + want time.Time + }{ + {"bare 429", 429, http.Header{}, now.Add(time.Minute)}, + {"429 reset only", 429, http.Header{"X-Ratelimit-Reset": {reset}}, now.Add(time.Hour)}, + {"429 reset precedence", 429, http.Header{"X-Ratelimit-Reset": {reset}, "Retry-After": {"30"}}, now.Add(time.Hour)}, + {"403 reset precedence", 403, http.Header{"X-Ratelimit-Remaining": {"0"}, "X-Ratelimit-Reset": {reset}, "Retry-After": {"30"}}, now.Add(time.Hour)}, + {"429 retry seconds", 429, http.Header{"Retry-After": {"30"}}, now.Add(30 * time.Second)}, + {"403 retry seconds", 403, http.Header{"Retry-After": {"30"}}, now.Add(30 * time.Second)}, + {"429 retry date", 429, http.Header{"Retry-After": {now.Add(2 * time.Minute).Format(http.TimeFormat)}}, now.Add(2 * time.Minute)}, + {"403 retry date", 403, http.Header{"Retry-After": {now.Add(2 * time.Minute).Format(http.TimeFormat)}}, now.Add(2 * time.Minute)}, + {"403 no reset", 403, http.Header{"X-Ratelimit-Remaining": {"0"}}, now.Add(time.Minute)}, + } { + t.Run(tc.name, func(t *testing.T) { + calls := 0 + repoIssuesGitHub(h, func(r *http.Request) (int, http.Header, string) { + calls++ + if r.URL.Path != "/repos/acme/api/issues" { + t.Errorf("unexpected GitHub request: %s", r.URL) + } + return tc.status, tc.header, `{}` + }) + w := identityRequest(h, "GET", "/v1/repos/acme/api/issues", "", token, nil) + checkVisibilityRetry(t, h, w, tc.want) + if calls != 1 { + t.Fatalf("GitHub issue reads = %d, want 1", calls) + } + }) + } +} diff --git a/internal/factory/serve/visibility_limit.go b/internal/factory/serve/visibility_limit.go new file mode 100644 index 0000000000..a3a53aca36 --- /dev/null +++ b/internal/factory/serve/visibility_limit.go @@ -0,0 +1,50 @@ +package serve + +import ( + "io" + "net/http" + + "github.com/Agent-Field/codeaf/internal/factory/api" +) + +// githubVisibilityStatus keeps the headers that distinguish a spent limit +// from a repository denial, without retaining GitHub's response body. +func (h *Handler) githubVisibilityStatus(r *http.Request, endpoint, token string) (int, http.Header, error) { + req, err := http.NewRequestWithContext(r.Context(), http.MethodGet, endpoint, nil) + if err != nil { + return 0, nil, err + } + req.Header.Set("Accept", "application/json") + req.Header.Set("User-Agent", "codeaf-factory") + if token != "" { + req.Header.Set("Authorization", "Bearer "+token) + } + client := h.githubClient() + response, err := client.Do(req) + if err != nil { + return 0, nil, err + } + defer response.Body.Close() + _, _ = io.Copy(io.Discard, io.LimitReader(response.Body, 1<<20)) + return response.StatusCode, response.Header, nil +} + +func githubLimitSpent(status int, header http.Header) bool { + return status == http.StatusTooManyRequests || status == http.StatusForbidden && + (header.Get("X-RateLimit-Remaining") == "0" || header.Get("Retry-After") != "") +} + +// githubVisibilityLimited is used only when the caller named the repository. Job +// lookups must keep the same answer for hidden and missing jobs. +func (h *Handler) githubVisibilityLimited(w http.ResponseWriter, r *http.Request) bool { + state, ok := r.Context().Value(authErrorKey{}).(*authErrorState) + if !ok || state.githubRetryAt.IsZero() { + return false + } + h.write(w, http.StatusTooManyRequests, struct { + Factory api.Factory `json:"factory"` + Error string `json:"error"` + api.RepoIssuesRetry + }{h.factory, "GitHub's rate limit is spent; retry after the reset", api.RepoIssuesRetry{RetryAt: state.githubRetryAt}}) + return true +} diff --git a/internal/factory/serve/visibility_limit_test.go b/internal/factory/serve/visibility_limit_test.go new file mode 100644 index 0000000000..6d0fb304c6 --- /dev/null +++ b/internal/factory/serve/visibility_limit_test.go @@ -0,0 +1,247 @@ +package serve + +import ( + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strconv" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/factory/api" + "github.com/Agent-Field/codeaf/internal/factory/store" +) + +func visibilityLimitFloor(t *testing.T) (*Handler, string, *time.Time) { + t.Helper() + h, clock, g := visibilityFloor(t, map[string]map[string]bool{"reader": {"acme/api": true}}) + token := signViewer(t, h, g, clock, "reader") + h.config.Now = func() time.Time { return *clock } + return h, token, clock +} + +func checkVisibilityRetry(t *testing.T, h *Handler, w *httptest.ResponseRecorder, retry time.Time) { + t.Helper() + var got struct { + Factory api.Factory `json:"factory"` + Error string `json:"error"` + api.RepoIssuesRetry + } + if w.Code != http.StatusTooManyRequests || json.Unmarshal(w.Body.Bytes(), &got) != nil || + got.Factory != h.factory || got.Error != "GitHub's rate limit is spent; retry after the reset" || !got.RetryAt.Equal(retry) { + t.Fatalf("visibility refusal = %d %s, want retry at %s", w.Code, w.Body, retry) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(w.Body.Bytes(), &fields); err != nil || len(fields) != 3 { + t.Fatalf("retry body shape = %s, err = %v", w.Body, err) + } +} + +// B1, B6: Only caller-named repository checks answer a spent limit with +// retry detail, before any issues read, job creation or repository change. +func TestARateLimitedVisibilityCheckAnswersRetryNotMissing(t *testing.T) { + for _, limit := range []string{"403 remaining", "429 retry seconds", "403 retry date", "403 no reset"} { + for _, route := range []struct{ name, method, path, body string }{ + {"issues", "GET", "/v1/repos/acme/api/issues", ""}, + {"power", "PUT", "/v1/repos/acme/api", `{"on":false}`}, + {"create", "POST", "/v1/jobs", `{"repo":"acme/api","title":"retry"}`}, + {"search", "GET", "/v1/issues?repo=acme/api", ""}, + } { + t.Run(limit+"/"+route.name, func(t *testing.T) { + h, token, clock := visibilityLimitFloor(t) + if route.name == "power" { + var err error + h.config.Store, err = store.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + h.config.Mode, h.config.OperatorLogin = api.FactorySolo, "reader" + h.seam.SetRepos = func([]string) error { t.Error("changed repositories"); return nil } + } + h.seam.New = func(string, string) (int, error) { t.Error("created a job"); return 9, nil } + status, header, retry := 403, http.Header{}, clock.Add(time.Minute) + switch limit { + case "403 remaining": + header.Set("X-RateLimit-Remaining", "0") + header.Set("X-RateLimit-Reset", strconv.FormatInt(retry.Unix(), 10)) + header.Set("Retry-After", "5") + case "429 retry seconds": + status = 429 + header.Set("Retry-After", "60") + case "403 retry date": + header.Set("Retry-After", retry.Format(http.TimeFormat)) + case "403 no reset": + header.Set("X-RateLimit-Remaining", "0") + } + probes := 0 + repoIssuesGitHub(h, func(r *http.Request) (int, http.Header, string) { + if r.Method != "GET" || r.URL.String() != "https://api.github.com/repos/acme/api" || + r.Header.Get("Authorization") != "Bearer gh-reader" || r.Header.Get("Accept") != "application/json" || + r.Header.Get("User-Agent") != "codeaf-factory" { + t.Errorf("unexpected GitHub request: %s %s, headers %v", r.Method, r.URL, r.Header) + } + probes++ + return status, header, `{}` + }) + w := identityRequest(h, route.method, route.path, route.body, token, nil) + checkVisibilityRetry(t, h, w, retry) + if probes != 1 { + t.Fatalf("GitHub calls = %d, want only the visibility probe", probes) + } + }) + } + } +} + +// B2: A spent limit never becomes a cached denial; a read after reset probes +// again within the denial TTL, and its readable answer is cached normally. +func TestARateLimitedVisibilityCheckIsNotCached(t *testing.T) { + for _, status := range []int{403, 429} { + t.Run(strconv.Itoa(status), func(t *testing.T) { + h, token, clock := visibilityLimitFloor(t) + reset := clock.Add(time.Minute) + probes, issues := 0, 0 + repoIssuesGitHub(h, func(r *http.Request) (int, http.Header, string) { + switch r.URL.Path { + case "/repos/acme/api": + probes++ + if clock.Before(reset) { + header := http.Header{} + header.Set("X-RateLimit-Remaining", "0") + header.Set("X-RateLimit-Reset", strconv.FormatInt(reset.Unix(), 10)) + return status, header, `{}` + } + return 200, http.Header{}, `{}` + case "/repos/acme/api/issues": + issues++ + return 200, http.Header{}, `[]` + default: + t.Errorf("unexpected GitHub request: %s", r.URL) + return 500, http.Header{}, `{}` + } + }) + checkVisibilityRetry(t, h, identityRequest(h, "GET", "/v1/repos/acme/api/issues", "", token, nil), reset) + if probes != 1 || issues != 0 { + t.Fatalf("limited read: probes=%d issues=%d", probes, issues) + } + *clock = reset.Add(time.Second) + for range 2 { + readRepoIssuesAnswer(t, h, token, "") + } + if probes != 2 || issues != 2 { + t.Fatalf("after reset: probes=%d issues=%d", probes, issues) + } + }) + } +} + +// B3: Definitive denials remain repository-not-found answers cached for the +// visibility TTL. +func TestVisibilityDenialsStillReadAsMissing(t *testing.T) { + for _, status := range []int{404, 403} { + t.Run(strconv.Itoa(status), func(t *testing.T) { + h, token, clock := visibilityLimitFloor(t) + probes := 0 + repoIssuesGitHub(h, func(r *http.Request) (int, http.Header, string) { + probes++ + if r.URL.Path != "/repos/acme/api" { + t.Errorf("unexpected GitHub request: %s", r.URL) + } + return status, http.Header{}, `{}` + }) + for range 2 { + w := identityRequest(h, "GET", "/v1/repos/acme/api/issues", "", token, nil) + var got struct{ Error string } + if w.Code != 404 || json.Unmarshal(w.Body.Bytes(), &got) != nil || got.Error != "repository not found" { + t.Fatalf("denied read = %d %s", w.Code, w.Body) + } + } + if probes != 1 { + t.Fatalf("cached denial asked GitHub %d times", probes) + } + *clock = clock.Add(repoVisibilityTTL) + identityRequest(h, "GET", "/v1/repos/acme/api/issues", "", token, nil) + if probes != 2 { + t.Fatalf("expired denial did not ask GitHub again: %d", probes) + } + }) + } +} + +// B3: Server and transport failures still avoid the visibility cache, while +// a 401 still ends the GitHub sign-in rather than setting a retry hint. +func TestVisibilityTransientAndSignInFailuresKeepTheirAnswers(t *testing.T) { + for _, failure := range []string{"server", "transport", "sign-in"} { + t.Run(failure, func(t *testing.T) { + h, token, _ := visibilityLimitFloor(t) + probes := 0 + h.config.GitHubHTTPClient = &http.Client{Transport: githubTransport(func(*http.Request) (*http.Response, error) { + probes++ + if failure == "transport" { + return nil, errors.New("GitHub unavailable") + } + status := 503 + if failure == "sign-in" { + status = 401 + } + return &http.Response{StatusCode: status, Header: http.Header{}, Body: http.NoBody}, nil + })} + for range 2 { + w := identityRequest(h, "GET", "/v1/repos/acme/api/issues", "", token, nil) + var got struct{ Error, Reason string } + if err := json.Unmarshal(w.Body.Bytes(), &got); err != nil { + t.Fatal(err) + } + if failure == "sign-in" { + if w.Code != 401 || got.Reason != api.ReasonGitHubExpired { + t.Fatalf("ended sign-in = %d %s", w.Code, w.Body) + } + } else if w.Code != 404 || got.Error != "repository not found" { + t.Fatalf("transient visibility failure = %d %s", w.Code, w.Body) + } + } + want := 2 + if failure == "sign-in" { + want = 1 + } + if probes != want { + t.Fatalf("probes = %d, want %d", probes, want) + } + }) + } +} + +// B3, B6: A hidden job keeps the missing-job answer; a narrowed queue hides +// limited repositories for that read, with no retry detail or cached denial. +func TestRateLimitedVisibilityStillHidesJobsAndNarrowsLists(t *testing.T) { + h, token, _ := visibilityLimitFloor(t) + limited, probes := true, 0 + repoIssuesGitHub(h, func(*http.Request) (int, http.Header, string) { + probes++ + if limited { + return 429, http.Header{"Retry-After": {"60"}}, `{}` + } + return 200, http.Header{}, `{}` + }) + for _, path := range []string{"/v1/jobs/1", "/v1/jobs/999"} { + w := identityRequest(h, "GET", path, "", token, nil) + var got struct { + Error string `json:"error"` + RetryAt json.RawMessage `json:"retry_at"` + } + if w.Code != 404 || json.Unmarshal(w.Body.Bytes(), &got) != nil || got.Error != "job not found" || got.RetryAt != nil { + t.Fatalf("hidden or missing job = %d %s", w.Code, w.Body) + } + } + if queue := queueAs(t, h, token, false); len(queue.Jobs) != 0 || len(queue.Repos) != 0 { + t.Fatalf("limited queue exposed jobs or repositories: %+v", queue) + } + if probes != 3 { + t.Fatalf("hidden-job and queue probes = %d, want 3", probes) + } + limited = false + if queue := queueAs(t, h, token, false); len(queue.Jobs) != 3 || len(queue.Repos) != 2 || probes != 5 { + t.Fatalf("next queue retained a denial: jobs=%d repos=%d probes=%d", len(queue.Jobs), len(queue.Repos), probes) + } +} diff --git a/internal/factory/serve/waits.go b/internal/factory/serve/waits.go new file mode 100644 index 0000000000..84424c2156 --- /dev/null +++ b/internal/factory/serve/waits.go @@ -0,0 +1,184 @@ +package serve + +import ( + "context" + "errors" + "log" + "slices" + "strings" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/factory" +) + +const waitsScanEvery = 2 * time.Second +const waitsSafetyEvery = time.Minute + +type waitsScheduler struct { + launch func(int) error + + mu sync.Mutex + cancel context.CancelFunc + done chan struct{} +} + +var errWaitsUnchanged = errors.New("waits did not change") + +// scanWaits lists once, then locks only the child being changed. Parent reads +// belong to that snapshot, so no two job locks are ever held together. +func (h *Handler) scanWaits() error { + h.waits.mu.Lock() + defer h.waits.mu.Unlock() + st := h.config.Store + if st == nil { + return nil + } + items, err := st.ListSaved() + if err != nil { + return err + } + var parents map[int]factory.Item + for _, it := range items { + if len(it.WaitsOn) != 0 && !it.WaitsReleased { + parents = make(map[int]factory.Item, len(items)) + break + } + } + if parents == nil { + return h.scanPolicyStarts(items) + } + for _, it := range items { + parents[it.ID] = it + } + var errs []error + for _, it := range items { + if it.WaitsReleased || len(it.WaitsOn) == 0 { + continue + } + if !waitsChange(it, factory.EvaluateWaits(it, parents)) { + continue + } + err := st.Update(it.ID, func(cur *factory.Item) error { + if cur.WaitsReleased || len(cur.WaitsOn) == 0 { + return errWaitsUnchanged + } + rule := factory.EvaluateWaits(*cur, parents) + if !waitsChange(*cur, rule) { + return errWaitsUnchanged + } + if rule.Released { + h.releaseWaits(cur, parents) + return nil + } + cur.WaitingOn = rule.WaitingOn + if !sameWaitsBlock(cur.Blocked, rule.Blocked) { + cur.Blocked = rule.Blocked + if cur.Blocked != nil { + cur.Blocked.Since = h.clock().UTC() + } + } + return nil + }) + if err != nil && !errors.Is(err, errWaitsUnchanged) { + errs = append(errs, err) + } + } + errs = append(errs, h.scanPolicyStarts(items)) + return errors.Join(errs...) +} + +func waitsChange(it factory.Item, rule factory.WaitsRule) bool { + return rule.Released || !slices.Equal(it.WaitingOn, rule.WaitingOn) || !sameWaitsBlock(it.Blocked, rule.Blocked) +} + +func sameWaitsBlock(a, b *factory.JobBlock) bool { + if a == nil || b == nil { + return a == nil && b == nil + } + return a.By == b.By && a.Job == b.Job && a.Reason == b.Reason +} + +// releaseWaits is the one release decision. It makes the job startable; +// a person's earlier start only retires the waits, without a release event. +func (h *Handler) releaseWaits(it *factory.Item, parents map[int]factory.Item) { + it.WaitsReleased, it.Blocked = true, nil + it.WaitingOn = nil + if factory.WaitsOverridden(*it) { + return + } + words := make([]string, 0, len(it.WaitsOn)) + for _, id := range it.WaitsOn { + parent := parents[id] + parent = factory.WaitsParent(*it, parent) + why := "merged" + if parent.PullRequest == nil { + why = "shipped with nothing to merge" + } + words = append(words, factory.JobRef(parent)+" "+why) + } + it.Note(h.clock().UTC(), "waits over: "+strings.Join(words, ", ")) +} + +func (h *Handler) startWaits() { + if h.config.Store == nil { + return + } + ctx, cancel := context.WithCancel(context.Background()) + h.waits.cancel, h.waits.done = cancel, make(chan struct{}) + var changes chan string + if h.watch != nil { + changes = h.watch.floor.join() + } + scan := func() { + if err := h.scanWaits(); err != nil { + log.Printf("factory: could not check job waits: %v", err) + } + } + scan() + go func() { + defer close(h.waits.done) + if changes != nil { + defer h.watch.floor.leave(changes) + } + tick := time.NewTicker(waitsSafetyEvery) + defer tick.Stop() + last := time.Now() + var timer *time.Timer + var due <-chan time.Time + defer func() { + if timer != nil { + timer.Stop() + } + }() + for { + select { + case <-ctx.Done(): + return + case <-changes: + case <-tick.C: + case <-due: + timer, due = nil, nil + } + if ctx.Err() != nil { + return + } + if left := waitsScanEvery - time.Since(last); left > 0 { + if timer == nil { + timer = time.NewTimer(left) + due = timer.C + } + continue + } + scan() + last = time.Now() + } + }() +} + +func (h *Handler) stopWaits() { + if h.waits.cancel != nil { + h.waits.cancel() + <-h.waits.done + } +} diff --git a/internal/factory/serve/waits_clock_test.go b/internal/factory/serve/waits_clock_test.go new file mode 100644 index 0000000000..9fc965bae9 --- /dev/null +++ b/internal/factory/serve/waits_clock_test.go @@ -0,0 +1,169 @@ +package serve + +import ( + "context" + "sync/atomic" + "testing" + "testing/synctest" + "time" + + "github.com/Agent-Field/codeaf/internal/factory" + "github.com/Agent-Field/codeaf/internal/factory/store" +) + +// synctest injects the scheduler's time.Now, NewTimer and NewTicker together. +// Waiting for a timer's channel advances that clock without a wall-clock sleep. +func waitsAdvance(d time.Duration) { + timer := time.NewTimer(d) + <-timer.C + synctest.Wait() +} + +type waitsLoopFixture struct { + h *Handler + reads atomic.Int64 + block atomic.Bool + entered chan struct{} + resume chan struct{} +} + +func waitsLoopSetup(t *testing.T) *waitsLoopFixture { + t.Helper() + st, err := store.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + // This one streamed document makes each list observable through the store's + // existing retention callback. No other reader or filesystem watcher runs. + if _, err := st.Create(factory.Item{State: factory.StateNew, Stream: &factory.Stream{}}); err != nil { + t.Fatal(err) + } + f := &waitsLoopFixture{entered: make(chan struct{}, 1), resume: make(chan struct{})} + st.SetOutputPolicy(func() (int, int) { + f.reads.Add(1) + if f.block.Load() { + f.entered <- struct{}{} + <-f.resume + } + return 0, 0 + }) + ctx, cancel := context.WithCancel(context.Background()) + f.h = &Handler{config: Config{Store: st}, watch: &rootWatch{ctx: ctx, cancel: cancel, floor: newHub()}} + f.h.startWaits() + synctest.Wait() + if f.reads.Load() != 1 { + t.Fatalf("startup scans = %d", f.reads.Load()) + } + return f +} + +// W1, W7: the existing floor hub delivers a change to the running scheduler. +func TestWaitsFloorChangeSchedulesScan(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + f := waitsLoopSetup(t) + defer f.h.Close() + waitsAdvance(2 * time.Second) + f.h.watch.floor.send("1") + synctest.Wait() + if f.reads.Load() != 2 { + t.Fatalf("floor change scans = %d", f.reads.Load()) + } + }) +} + +// W1, W7: changes inside two seconds share one scan at the original deadline. +func TestWaitsScanCoalescesForTwoSeconds(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + f := waitsLoopSetup(t) + defer f.h.Close() + f.h.watch.floor.send("1") + synctest.Wait() + waitsAdvance(time.Second) + f.h.watch.floor.send("2") + synctest.Wait() + waitsAdvance(time.Second - time.Nanosecond) + if f.reads.Load() != 1 { + t.Fatalf("scanned before two seconds: %d", f.reads.Load()) + } + waitsAdvance(time.Nanosecond) + if f.reads.Load() != 2 { + t.Fatalf("coalesced scans = %d", f.reads.Load()) + } + waitsAdvance(2 * time.Second) + if f.reads.Load() != 2 { + t.Fatalf("a duplicate scan remained pending: %d", f.reads.Load()) + } + }) +} + +// W1, W7: the safety tick scans even when the floor reports no changes. +func TestWaitsSafetyTick(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + f := waitsLoopSetup(t) + defer f.h.Close() + waitsAdvance(time.Minute - time.Nanosecond) + if f.reads.Load() != 1 { + t.Fatalf("early safety scan: %d", f.reads.Load()) + } + waitsAdvance(time.Nanosecond) + if f.reads.Load() != 2 { + t.Fatalf("safety scans = %d", f.reads.Load()) + } + waitsAdvance(time.Minute) + if f.reads.Load() != 3 { + t.Fatalf("second safety scan = %d", f.reads.Load()) + } + }) +} + +// W7: the real Close door cancels pending work and joins an active store scan. +func TestWaitsCloseCancelsAndJoins(t *testing.T) { + t.Run("pending", func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + f := waitsLoopSetup(t) + defer f.h.Close() + f.h.watch.floor.send("1") + synctest.Wait() + if f.reads.Load() != 1 { + t.Fatal("scan did not wait for its deadline") + } + f.h.Close() + f.h.watch.floor.send("2") + waitsAdvance(time.Minute) + if f.reads.Load() != 1 { + t.Fatalf("Close left a scan pending: %d", f.reads.Load()) + } + f.h.watch.floor.mu.Lock() + subscribers := len(f.h.watch.floor.subs) + f.h.watch.floor.mu.Unlock() + if subscribers != 0 { + t.Fatalf("Close left %d floor subscriptions", subscribers) + } + }) + }) + t.Run("active", func(t *testing.T) { + synctest.Test(t, func(t *testing.T) { + f := waitsLoopSetup(t) + defer f.h.Close() + defer close(f.resume) + waitsAdvance(2 * time.Second) + f.block.Store(true) + f.h.watch.floor.send("1") + <-f.entered + closed := make(chan struct{}) + go func() { f.h.Close(); close(closed) }() + synctest.Wait() + select { + case <-closed: + t.Fatal("Close returned while the scan was still active") + default: + } + f.resume <- struct{}{} + <-closed + waitsAdvance(time.Minute) + if f.reads.Load() != 2 { + t.Fatalf("scan after Close: %d", f.reads.Load()) + } + }) + }) +} diff --git a/internal/factory/serve/waits_fix2_test.go b/internal/factory/serve/waits_fix2_test.go new file mode 100644 index 0000000000..889f48b180 --- /dev/null +++ b/internal/factory/serve/waits_fix2_test.go @@ -0,0 +1,242 @@ +package serve + +import ( + "encoding/json" + "fmt" + "reflect" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/factory" + "github.com/Agent-Field/codeaf/internal/factory/api" + "github.com/Agent-Field/codeaf/internal/factory/lease" +) + +// W1, W4: closing an unworked issue blocks existing children at the scan. +func TestWaitsClosedIssueBlocks(t *testing.T) { + f := waitsSetup(t) + parent, child := f.addJob(t, 1107), f.addJob(t, 0) + f.set(t, child.ID, []int{parent.ID}, 200) + f.edit(t, parent.ID, func(it *factory.Item) { it.State, it.DoneHow = factory.StateShipped, "closed" }) + f.scan(t) + got := f.readJob(t, child.ID) + if got.State != "backlog" || got.Blocked == nil || got.Blocked.Reason != "waits on api#1107, which closed without merging" || !reflect.DeepEqual(got.WaitingOn, []int{parent.ID}) || f.item(t, child.ID).WaitsReleased || f.launches != 0 { + t.Fatalf("closed issue released child: %+v", got) + } +} + +// W2: a closed issue is refused before any document or revision changes. +func TestWaitsSetRefusesClosedIssue(t *testing.T) { + f := waitsSetup(t) + parent, child := f.addJob(t, 1107), f.addJob(t, 0) + f.edit(t, parent.ID, func(it *factory.Item) { it.State, it.DoneHow = factory.StateShipped, "closed" }) + before := f.item(t, child.ID) + revision := waitsRevision(t, f, child.ID) + answer := f.set(t, child.ID, []int{parent.ID}, 422) + if !strings.Contains(answer, "closed without merging") || !reflect.DeepEqual(f.item(t, child.ID), before) || waitsRevision(t, f, child.ID) != revision { + t.Fatalf("closed issue refusal = %s", answer) + } +} + +// W1, W2: a published open pull request is accepted and waits for its merge. +func TestWaitsPublishedParentWaitsForMerge(t *testing.T) { + f := waitsSetup(t) + parent, child := f.addJob(t, 1107), f.addJob(t, 0) + f.edit(t, parent.ID, func(it *factory.Item) { + it.State = factory.StateShipped + it.PullRequest = &factory.PullRequest{State: factory.PROpen} + }) + f.set(t, child.ID, []int{parent.ID}, 200) + f.scan(t) + if it := f.item(t, child.ID); it.WaitsReleased || it.Blocked != nil || !reflect.DeepEqual(it.WaitingOn, []int{parent.ID}) { + t.Fatalf("open pull request did not stay waiting: %+v", it) + } + f.merge(t, parent.ID) + f.scan(t) + if it := f.item(t, child.ID); !it.WaitsReleased || it.Blocked != nil || len(it.WaitingOn) != 0 { + t.Fatalf("merged parent did not release: %+v", it) + } +} + +// W5, W6: real person doors retire waits before Drop and Bring back erase a run. +func TestWaitsStartDropRestoreBeforeScan(t *testing.T) { + for _, door := range []string{"run", "start_now"} { + for _, blocked := range []bool{false, true} { + t.Run(fmt.Sprintf("%s/blocked=%v", door, blocked), func(t *testing.T) { + f := waitsSetup(t) + parent, child := f.addJob(t, 1107), f.addJob(t, 0) + f.set(t, child.ID, []int{parent.ID}, 200) + if blocked { + f.edit(t, parent.ID, func(it *factory.Item) { it.Failure = &factory.Failure{Step: "test"} }) + f.scan(t) + } + f.request(t, "POST", fmt.Sprintf("/v1/jobs/%d/%s", child.ID, door), "operator-secret", nil, 200, nil) + if it := f.item(t, child.ID); !it.WaitsReleased || it.Blocked != nil || len(it.WaitingOn) != 0 || f.launches != 1 { + t.Fatalf("start did not retire waits immediately: %+v", it) + } + f.request(t, "POST", fmt.Sprintf("/v1/jobs/%d/drop", child.ID), "operator-secret", nil, 200, nil) + f.request(t, "POST", fmt.Sprintf("/v1/jobs/%d/restore", child.ID), "operator-secret", nil, 200, nil) + before := f.item(t, child.ID) + if factory.JobState(before) != "backlog" || !before.WaitsReleased { + t.Fatalf("restored job = %+v", before) + } + f.edit(t, parent.ID, func(it *factory.Item) { it.Failure = &factory.Failure{Step: "test"} }) + f.scan(t) + f.merge(t, parent.ID) + f.scan(t) + if got := f.item(t, child.ID); !reflect.DeepEqual(got, before) || f.launches != 1 { + t.Fatalf("restored waits became active: %+v", got) + } + for _, event := range before.Activity { + if strings.HasPrefix(event.What, "waits over:") { + t.Fatalf("person start recorded a release: %+v", event) + } + } + }) + } + } +} + +// W5: per-job launch doors also persist retirement before entering the runner. +func TestWaitsForJobLaunchRetiresBeforeLaunch(t *testing.T) { + f := waitsSetup(t) + parent, child := f.addJob(t, 1107), f.addJob(t, 0) + f.set(t, child.ID, []int{parent.ID}, 200) + f.h.Close() + f.seam.ForJob = func(int) factory.Seam { + return factory.Seam{Launch: func(id int) error { + it := f.item(t, id) + if !it.WaitsReleased || len(it.WaitingOn) != 0 || it.Blocked != nil { + t.Fatalf("runner entered before waits retired: %+v", it) + } + return lease.NewDoors(f.h.workers.desk).Launch(id) + }} + } + f.h = New(f.seam, f.cfg) + t.Cleanup(f.h.Close) + f.h.stopWaits() + if err := f.h.seam.Job(child.ID).Launch(child.ID); err != nil { + t.Fatal(err) + } +} + +func waitsTeamSetup(t *testing.T) (*waitsFixture, string, string) { + t.Helper() + f := &waitsFixture{peopleV2Fixture: peopleV2Setup(t, api.FactoryTeam)} + f.h.Close() + if err := f.st.SetRepos([]string{"acme/api", "acme/web"}); err != nil { + t.Fatal(err) + } + g := &scriptGitHub{t: t, allow: map[string]map[string]bool{ + "writer": {"acme/api": true, "acme/web": true}, + "reader": {"acme/api": true}, + }} + f.cfg.GitHubClientID, f.cfg.GitHubHTTPClient = "client", g.client() + f.cfg.AccessToken = "floor-secret" + f.h = New(f.seam, f.cfg) + t.Cleanup(f.h.Close) + f.h.stopWaits() + writer := signViewer(t, f.h, g, &f.now, "writer") + reader := signViewer(t, f.h, g, &f.now, "reader") + return f, writer, reader +} + +func waitsTeamJob(t *testing.T, f *waitsFixture, repo, owner string, num int) factory.Item { + t.Helper() + it, err := f.st.Create(factory.Item{Kind: factory.KindIssue, State: factory.StateNew, Repo: repo, Assignee: owner, Num: num, Title: "test waits"}) + if err != nil { + t.Fatal(err) + } + return it +} + +func waitsRevision(t *testing.T, f *waitsFixture, id int) int { + t.Helper() + revision, err := f.st.Revision(id) + if err != nil { + t.Fatal(err) + } + return revision +} + +// W2, W6: a hidden parent has the unknown-parent answer and makes no write. +func TestWaitsSetHidesUnreadableParent(t *testing.T) { + f, _, reader := waitsTeamSetup(t) + parent := waitsTeamJob(t, f, "acme/web", "writer", 1107) + child := waitsTeamJob(t, f, "acme/api", "reader", 1108) + before := f.item(t, child.ID) + revision := waitsRevision(t, f, child.ID) + path := fmt.Sprintf("/v1/jobs/%d/waits_on", child.ID) + body := fmt.Sprintf(`{"waits_on":[%d]}`, parent.ID) + for _, ended := range []bool{false, true} { + if ended { + f.edit(t, parent.ID, func(it *factory.Item) { it.State, it.DoneHow = factory.StateDismissed, "dropped" }) + } + hidden := identityRequest(f.h, "PUT", path, body, reader, nil) + if hidden.Code != 422 || !strings.Contains(hidden.Body.String(), fmt.Sprintf("job %d does not exist in this factory", parent.ID)) || strings.Contains(hidden.Body.String(), "web") || !reflect.DeepEqual(f.item(t, child.ID), before) { + t.Fatalf("hidden parent = %d %s", hidden.Code, hidden.Body) + } + missing := identityRequest(f.h, "PUT", path, `{"waits_on":[99999]}`, reader, nil) + var hiddenError, missingError struct { + Error string `json:"error"` + } + if err := json.Unmarshal(hidden.Body.Bytes(), &hiddenError); err != nil { + t.Fatal(err) + } + if err := json.Unmarshal(missing.Body.Bytes(), &missingError); err != nil { + t.Fatal(err) + } + if missing.Code != 422 || strings.ReplaceAll(hiddenError.Error, fmt.Sprint(parent.ID), "99999") != missingError.Error || !reflect.DeepEqual(f.item(t, child.ID), before) || waitsRevision(t, f, child.ID) != revision { + t.Fatalf("hidden and missing differ: %s / %s", hidden.Body, missing.Body) + } + } +} + +// W1, W3, W4, W11: job, queue and activity reads carry no other repo's details. +func TestWaitsReadsHideUnreadableParent(t *testing.T) { + f, writer, reader := waitsTeamSetup(t) + parent := waitsTeamJob(t, f, "acme/web", "writer", 1107) + child := waitsTeamJob(t, f, "acme/api", "writer", 1108) + f.request(t, "PUT", fmt.Sprintf("/v1/jobs/%d/waits_on", child.ID), writer, map[string]any{"waits_on": []int{parent.ID}}, 200, nil) + check := func(reason string) api.Job { + t.Helper() + var job api.Job + for _, path := range []string{fmt.Sprintf("/v1/jobs/%d", child.ID), "/v1/queue"} { + w := identityRequest(f.h, "GET", path, "", reader, nil) + if w.Code != 200 || strings.Contains(w.Body.String(), "web") || strings.Contains(w.Body.String(), "Private check") { + t.Fatalf("parent details in %s: %d %s", path, w.Code, w.Body) + } + if path == "/v1/queue" { + var queue api.Queue + if err := json.Unmarshal(w.Body.Bytes(), &queue); err != nil { + t.Fatal(err) + } + if len(queue.Jobs) != 1 || queue.Jobs[0].ID != child.ID || !reflect.DeepEqual(queue.Jobs[0].Blocked, job.Blocked) { + t.Fatalf("queue differs from child read: %+v", queue.Jobs) + } + } else if err := json.Unmarshal(w.Body.Bytes(), &job); err != nil { + t.Fatal(err) + } + } + if reason != "" && (job.Blocked == nil || job.Blocked.Reason != reason) { + t.Fatalf("blocked = %+v", job.Blocked) + } + return job + } + f.edit(t, parent.ID, func(it *factory.Item) { + it.Failure = &factory.Failure{Step: "check"} + it.Stages = []factory.Stage{{Name: "check", Label: "Private check"}} + }) + f.scan(t) + check(fmt.Sprintf("waits on job %d, which failed", parent.ID)) + f.edit(t, parent.ID, func(it *factory.Item) { it.State, it.DoneHow = factory.StateShipped, "closed" }) + f.scan(t) + check(fmt.Sprintf("waits on job %d, which closed without merging", parent.ID)) + f.merge(t, parent.ID) + f.scan(t) + job := check("") + it := f.item(t, child.ID) + if job.Blocked != nil || !it.WaitsReleased || len(it.Activity) == 0 || it.Activity[len(it.Activity)-1].What != fmt.Sprintf("waits over: job %d merged", parent.ID) { + t.Fatalf("release = %+v", it) + } +} diff --git a/internal/factory/serve/waits_launch.go b/internal/factory/serve/waits_launch.go new file mode 100644 index 0000000000..08e70bd2a0 --- /dev/null +++ b/internal/factory/serve/waits_launch.go @@ -0,0 +1,48 @@ +package serve + +import ( + "errors" + + "github.com/Agent-Field/codeaf/internal/factory" +) + +// waitsLaunches makes retirement durable before any service launch enters its +// runner. Resetting a run later cannot restore the old waits between scans. +func (h *Handler) waitsLaunches(seam factory.Seam) factory.Seam { + if h.config.Store == nil { + return seam + } + if launch := seam.Launch; launch != nil { + seam.Launch = func(id int) error { + resumed := false + err := h.config.Store.Update(id, func(it *factory.Item) error { + if (it.WaitsReleased || len(it.WaitsOn) == 0) && it.PolicyRun == "" && (it.Blocked == nil || it.Blocked.By != factory.BlockPolicy) { + return errWaitsUnchanged + } + resumed = h.resumePolicyStart(it) + retireLaunchWaits(it, "") + return nil + }) + if err != nil && !errors.Is(err, errWaitsUnchanged) { + return err + } + if resumed { + return nil + } + return launch(id) + } + } + if forJob := seam.ForJob; forJob != nil { + seam.ForJob = func(id int) factory.Seam { return h.waitsLaunches(forJob(id)) } + } + return seam +} + +// retireLaunchWaits binds automatic admission to the same write as retirement. +// Every other launch clears the allowance that belonged to the earlier run. +func retireLaunchWaits(it *factory.Item, allowance string) { + if len(it.WaitsOn) != 0 { + it.WaitsReleased = true + } + it.PolicyRun, it.WaitingOn, it.Blocked = allowance, nil, nil +} diff --git a/internal/factory/serve/waits_set.go b/internal/factory/serve/waits_set.go new file mode 100644 index 0000000000..ed04efe7a8 --- /dev/null +++ b/internal/factory/serve/waits_set.go @@ -0,0 +1,151 @@ +package serve + +import ( + "errors" + "fmt" + "net/http" + + "github.com/Agent-Field/codeaf/internal/factory" + "github.com/Agent-Field/codeaf/internal/factory/api" + "github.com/Agent-Field/codeaf/internal/factory/store" +) + +// waitsRoutes is separate from the job verbs: waits replace a list of parents. +func (h *Handler) waitsRoutes() { + h.route("/v1/jobs/{id}/waits_on", map[string]http.HandlerFunc{http.MethodPut: func(w http.ResponseWriter, r *http.Request) { + if !h.requireVerb(w, h.config.Store != nil) { + return + } + it, ok := h.findJob(w, r) + if ok { + h.onlyOwner(h.setWaits)(w, r, it) + } + }}) +} + +type waitsRefusal string + +func (e waitsRefusal) Error() string { return string(e) } + +func (h *Handler) setWaits(w http.ResponseWriter, r *http.Request, it factory.Item) { + var body struct { + WaitsOn *[]int `json:"waits_on"` + Expect *api.Expect `json:"expect,omitempty"` + } + if !h.decode(w, r, &body, false) { + return + } + if body.WaitsOn == nil { + h.fail(w, http.StatusUnprocessableEntity, "waits_on must be a list of job ids; use an empty list to clear it") + return + } + if err := factory.CheckWaitsIDs(*body.WaitsOn); err != nil { + h.fail(w, http.StatusUnprocessableEntity, err.Error()) + return + } + if !h.fresh(w, r, it, "waits_on", body.Expect) { + return + } + caller, err := h.callerHandle(r) + if h.failed(w, err) { + return + } + // The shared consent lock serializes graph edits across handlers too. + // It is taken before listing parents and before taking the child's lock. + unlock, err := h.lockConsent() + if h.failed(w, err) { + return + } + defer unlock() + items, err := h.config.Store.ListSaved() + if h.failed(w, err) { + return + } + parents := make(map[int]factory.Item, len(items)) + for _, parent := range items { + parents[parent.ID] = parent + } + if !h.waitsParentsVisible(w, r, *body.WaitsOn, parents) { + return + } + if err := checkWaitsParents(it.ID, *body.WaitsOn, parents); err != nil { + h.fail(w, http.StatusUnprocessableEntity, err.Error()) + return + } + change := func(cur *factory.Item) error { + if h.mode() != api.FactorySolo && cur.Person() != "" && cur.Person() != factory.NormalizeHandle(caller) { + return notJobPersonError{who: cur.Person()} + } + if exp := body.Expect; exp != nil { + job := api.FromItem(*cur) + if exp.State != "" && exp.State != job.State || exp.Step != "" && (job.Step == nil || job.Step.Name != exp.Step) { + return errPlanStepMoved + } + } + if cur.WaitsReleased || factory.WaitsOverridden(*cur) { + return waitsRefusal("waits can only be changed on a Backlog job that has never started or been released") + } + cur.WaitsOn = append([]int(nil), (*body.WaitsOn)...) + cur.WaitingOn = factory.EvaluateWaits(*cur, parents).WaitingOn + cur.Blocked = nil + return nil + } + if exp := body.Expect; exp != nil && exp.Version != nil { + err = h.config.Store.UpdateExpected(it.ID, *exp.Version, change) + } else { + err = h.config.Store.Update(it.ID, change) + } + var stale *store.StaleError + var person notJobPersonError + var refused waitsRefusal + switch { + case errors.As(err, &stale) || errors.Is(err, errPlanStepMoved): + if cur, ok := h.findJob(w, r); ok { + h.moved(w, r, cur, h.withVersion(api.FromItem(cur)), "waits_on") + } + case errors.As(err, &person): + h.notJobPerson(w, it, person.who) + case errors.As(err, &refused): + h.fail(w, http.StatusUnprocessableEntity, refused.Error()) + case err != nil: + h.failed(w, err) + default: + h.job(w, r) + } +} + +func checkWaitsParents(job int, ids []int, parents map[int]factory.Item) error { + seen := make(map[int]bool) + var reachesJob func(int) bool + reachesJob = func(id int) bool { + if id == job { + return true + } + if seen[id] { + return false + } + seen[id] = true + for _, parent := range parents[id].WaitsOn { + if reachesJob(parent) { + return true + } + } + return false + } + for _, id := range ids { + if id == job { + return fmt.Errorf("a job cannot wait on itself") + } + parent, ok := parents[id] + if !ok || !factory.IsIssueJob(parent) { + return fmt.Errorf("job %d does not exist in this factory", id) + } + if why := factory.WaitsEndedReason(parent); why != "" { + return fmt.Errorf("%s %s; a job cannot wait on it", factory.JobRef(parent), why) + } + if reachesJob(id) { + return fmt.Errorf("these waits would make a cycle between jobs") + } + } + return nil +} diff --git a/internal/factory/serve/waits_test.go b/internal/factory/serve/waits_test.go new file mode 100644 index 0000000000..56cc3de0a2 --- /dev/null +++ b/internal/factory/serve/waits_test.go @@ -0,0 +1,437 @@ +package serve + +import ( + "encoding/json" + "fmt" + "reflect" + "slices" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/factory" + "github.com/Agent-Field/codeaf/internal/factory/api" + "github.com/Agent-Field/codeaf/internal/factory/lease" +) + +type waitsFixture struct { + *peopleV2Fixture + launches int +} + +func waitsSetup(t *testing.T) *waitsFixture { + t.Helper() + f := &waitsFixture{peopleV2Fixture: peopleV2Setup(t, api.FactorySolo)} + // Scans are called directly with the fixture's clock, never after a sleep. + f.h.stopWaits() + if err := f.st.SetRepos([]string{"owner/api"}); err != nil { + t.Fatal(err) + } + f.h.Close() + f.seam.Launch = func(id int) error { f.launches++; return lease.NewDoors(f.h.workers.desk).Launch(id) } + f.seam.Stop = func(id int) error { return lease.NewDoors(f.h.workers.desk).Stop(id) } + f.h = New(f.seam, f.cfg) + t.Cleanup(f.h.Close) + f.h.stopWaits() + return f +} + +func (f *waitsFixture) addJob(t *testing.T, num int) factory.Item { + t.Helper() + it, err := f.st.Create(factory.Item{Kind: factory.KindIssue, State: factory.StateNew, Repo: "owner/api", Num: num, Title: "test waits", Cap: 1, Stages: []factory.Stage{{Name: "test", Label: "Test", Kind: factory.StageCheck, On: true}}}) + if err != nil { + t.Fatal(err) + } + return it +} + +func (f *waitsFixture) edit(t *testing.T, id int, change func(*factory.Item)) { + t.Helper() + if err := f.st.Update(id, func(it *factory.Item) error { change(it); return nil }); err != nil { + t.Fatal(err) + } +} + +func (f *waitsFixture) item(t *testing.T, id int) factory.Item { + t.Helper() + it, err := f.st.Get(id) + if err != nil { + t.Fatal(err) + } + return it +} + +func (f *waitsFixture) readJob(t *testing.T, id int) api.Job { + t.Helper() + var j api.Job + f.request(t, "GET", fmt.Sprintf("/v1/jobs/%d", id), "operator-secret", nil, 200, &j) + return j +} + +func (f *waitsFixture) set(t *testing.T, id int, ids []int, status int) string { + t.Helper() + return f.request(t, "PUT", fmt.Sprintf("/v1/jobs/%d/waits_on", id), "operator-secret", struct { + WaitsOn []int `json:"waits_on"` + }{ids}, status, nil) +} + +func (f *waitsFixture) scan(t *testing.T) { + t.Helper() + if err := f.h.scanWaits(); err != nil { + t.Fatal(err) + } +} + +func (f *waitsFixture) merge(t *testing.T, id int) { + t.Helper() + f.edit(t, id, func(it *factory.Item) { + it.State = factory.StateShipped + it.PullRequest = &factory.PullRequest{State: factory.PRMerged} + }) +} + +// W1, W11: only unmerged parents remain in waiting_on, and release never launches. +func TestWaitsOrdering(t *testing.T) { + f := waitsSetup(t) + a, b, child := f.addJob(t, 1107), f.addJob(t, 1108), f.addJob(t, 0) + f.set(t, child.ID, []int{a.ID, b.ID}, 200) + if it := f.item(t, child.ID); !reflect.DeepEqual(it.WaitingOn, []int{a.ID, b.ID}) { + t.Fatalf("set waiting = %+v", it) + } + f.scan(t) + j := f.readJob(t, child.ID) + if j.State != api.StateBacklog || !reflect.DeepEqual(j.WaitingOn, []int{a.ID, b.ID}) || j.Blocked != nil || j.Held { + t.Fatalf("waiting = %+v", j) + } + rev, err := f.st.Revision(child.ID) + if err != nil { + t.Fatal(err) + } + f.scan(t) + if after, _ := f.st.Revision(child.ID); after != rev { + t.Fatalf("unchanged scan wrote revision %d", after) + } + f.merge(t, a.ID) + if j := f.readJob(t, child.ID); !reflect.DeepEqual(j.WaitingOn, []int{a.ID, b.ID}) { + t.Fatalf("read changed waits before scan = %+v", j) + } + f.scan(t) + j = f.readJob(t, child.ID) + it := f.item(t, child.ID) + if !reflect.DeepEqual(j.WaitingOn, []int{b.ID}) || !reflect.DeepEqual(it.WaitingOn, []int{b.ID}) || it.WaitsReleased { + t.Fatalf("one merged = %+v", j) + } + for _, endpoint := range []string{"/v1/queue"} { + var queue api.Queue + f.request(t, "GET", endpoint, "operator-secret", nil, 200, &queue) + found := false + for _, job := range queue.Jobs { + if job.ID == child.ID { + found = true + if !reflect.DeepEqual(job.WaitingOn, it.WaitingOn) { + t.Fatalf("%s waiting = %+v", endpoint, job) + } + } + } + if !found { + t.Fatalf("%s omitted the waiting child", endpoint) + } + } + f.merge(t, b.ID) + f.scan(t) + j, it = f.readJob(t, child.ID), f.item(t, child.ID) + if j.State != api.StateBacklog || len(j.WaitingOn) != 0 || len(it.WaitingOn) != 0 || j.Blocked != nil || !it.WaitsReleased || f.launches != 0 { + t.Fatalf("released = %+v, launches = %d", j, f.launches) + } + if len(it.Activity) == 0 || !strings.Contains(it.Activity[len(it.Activity)-1].What, "waits over: api#1107 merged") || !strings.Contains(it.Activity[len(it.Activity)-1].What, "api#1108 merged") { + t.Fatalf("activity = %+v", it.Activity) + } + f.scan(t) + if after := f.item(t, child.ID); !reflect.DeepEqual(after, it) { + t.Fatalf("release repeated: %+v", after) + } +} + +// W1, W2, W6: PUT persists unsatisfied parents in order and clears both lists. +func TestWaitsSetPersistsUnsatisfiedParents(t *testing.T) { + f := waitsSetup(t) + a, b, c, child := f.addJob(t, 1107), f.addJob(t, 1108), f.addJob(t, 1109), f.addJob(t, 0) + f.merge(t, b.ID) + waits, waiting := []int{c.ID, b.ID, a.ID}, []int{c.ID, a.ID} + var answer api.Job + f.request(t, "PUT", fmt.Sprintf("/v1/jobs/%d/waits_on", child.ID), "operator-secret", map[string]any{"waits_on": waits}, 200, &answer) + it := f.item(t, child.ID) + if !reflect.DeepEqual(it.WaitsOn, waits) || !reflect.DeepEqual(it.WaitingOn, waiting) || !reflect.DeepEqual(answer.WaitingOn, waiting) || it.WaitsReleased { + t.Fatalf("set waits = %+v; answer = %+v", it, answer) + } + f.edit(t, a.ID, func(it *factory.Item) { it.Failure = &factory.Failure{Step: "test"} }) + f.scan(t) + if it := f.item(t, child.ID); it.Blocked == nil { + t.Fatal("failed parent did not block") + } + f.set(t, child.ID, []int{}, 200) + if it := f.item(t, child.ID); len(it.WaitsOn) != 0 || len(it.WaitingOn) != 0 || it.Blocked != nil { + t.Fatalf("cleared waits = %+v", it) + } +} + +// W1, W3: a partial merge updates waiting_on without changing an existing block's since. +func TestWaitsPartialMergeKeepsBlockSince(t *testing.T) { + f := waitsSetup(t) + a, b, child := f.addJob(t, 1107), f.addJob(t, 1108), f.addJob(t, 0) + f.set(t, child.ID, []int{a.ID, b.ID}, 200) + f.edit(t, b.ID, func(it *factory.Item) { it.Failure = &factory.Failure{Step: "test", Code: "failed"} }) + f.scan(t) + before := f.item(t, child.ID) + if before.Blocked == nil { + t.Fatal("failed parent did not block") + } + f.now = f.now.Add(time.Minute) + f.merge(t, a.ID) + f.scan(t) + after := f.item(t, child.ID) + if !reflect.DeepEqual(after.WaitingOn, []int{b.ID}) || !reflect.DeepEqual(after.Blocked, before.Blocked) || after.WaitsReleased { + t.Fatalf("partial merge = %+v", after) + } + rev, err := f.st.Revision(child.ID) + if err != nil { + t.Fatal(err) + } + f.scan(t) + if next, _ := f.st.Revision(child.ID); next != rev { + t.Fatalf("unchanged block and waits wrote revision %d", next) + } +} + +// W2: every invalid parent list leaves both the document and revision unchanged. +func TestWaitsSetRefused(t *testing.T) { + f := waitsSetup(t) + parent, child := f.addJob(t, 0), f.addJob(t, 0) + f.set(t, child.ID, []int{parent.ID}, 200) + closed, dropped, local := f.addJob(t, 0), f.addJob(t, 0), f.addJob(t, 0) + f.edit(t, closed.ID, func(it *factory.Item) { it.PullRequest = &factory.PullRequest{State: factory.PRClosed} }) + f.edit(t, dropped.ID, func(it *factory.Item) { it.DoneHow = "dropped" }) + f.edit(t, local.ID, func(it *factory.Item) { it.DoneHow = "closed_locally" }) + for _, ids := range [][]int{{9999}, make([]int, 21), {parent.ID, parent.ID}, {child.ID}, {closed.ID}, {dropped.ID}, {local.ID}, {0}, {-1}} { + before := f.item(t, child.ID) + rev, err := f.st.Revision(child.ID) + if err != nil { + t.Fatal(err) + } + raw := f.set(t, child.ID, ids, 422) + var refusal struct { + Error string `json:"error"` + } + if err := json.Unmarshal([]byte(raw), &refusal); err != nil || refusal.Error == "" { + t.Fatalf("refusal = %s, %v", raw, err) + } + if !reflect.DeepEqual(f.item(t, child.ID), before) { + t.Fatalf("refused %v changed job", ids) + } + if after, _ := f.st.Revision(child.ID); after != rev { + t.Fatalf("refused %v changed revision", ids) + } + } + f.merge(t, parent.ID) + f.set(t, child.ID, []int{parent.ID}, 200) + if it := f.item(t, child.ID); len(it.WaitingOn) != 0 || it.WaitsReleased { + t.Fatalf("merged parent was not satisfied at PUT = %+v", it) + } + f.scan(t) + if !f.item(t, child.ID).WaitsReleased || f.launches != 0 { + t.Fatal("merged parent did not release on the first scan") + } +} + +// W3, W11: a failed parent blocks without changing Backlog or the person's Start. +func TestWaitsBlockedParentAndRepair(t *testing.T) { + f := waitsSetup(t) + parent, child := f.addJob(t, 1107), f.addJob(t, 0) + f.set(t, child.ID, []int{parent.ID}, 200) + before := f.readJob(t, child.ID) + f.edit(t, parent.ID, func(it *factory.Item) { it.Failure = &factory.Failure{Step: "test", Code: "failed"} }) + f.scan(t) + j := f.readJob(t, child.ID) + if j.State != api.StateBacklog || j.NeedsYou != nil || j.Can.Run != before.Can.Run || j.Can.StartNow != before.Can.StartNow || j.Held != before.Held || j.Blocked == nil || j.Blocked.By != "waits_on" || j.Blocked.Job != parent.ID || !strings.Contains(j.Blocked.Reason, "api#1107, which failed at Test") || !j.Blocked.Since.Equal(f.now) { + t.Fatalf("blocked = %+v", j) + } + blocked := f.item(t, child.ID) + f.now = f.now.Add(time.Minute) + f.scan(t) + if !reflect.DeepEqual(f.item(t, child.ID), blocked) { + t.Fatal("unchanged block was written again") + } + f.edit(t, parent.ID, func(it *factory.Item) { it.Failure = nil }) + f.scan(t) + j = f.readJob(t, child.ID) + if j.Blocked != nil || !reflect.DeepEqual(j.WaitingOn, []int{parent.ID}) { + t.Fatalf("repaired = %+v", j) + } + f.merge(t, parent.ID) + f.scan(t) + if !f.item(t, child.ID).WaitsReleased { + t.Fatal("repaired parent did not release") + } +} + +// W4: ended-unmerged reasons are derived, and bringing a dropped parent back clears its block. +func TestWaitsClosedParents(t *testing.T) { + for _, tc := range []struct { + name, why string + change func(*factory.Item) + }{ + {"closed PR", "closed without merging", func(it *factory.Item) { it.PullRequest = &factory.PullRequest{State: factory.PRClosed} }}, + {"dropped", "was dropped", func(it *factory.Item) { it.DoneHow, it.State = "dropped", factory.StateDismissed }}, + {"closed locally", "was closed locally", func(it *factory.Item) { it.DoneHow = "closed_locally" }}, + } { + t.Run(tc.name, func(t *testing.T) { + f := waitsSetup(t) + parent, child := f.addJob(t, 0), f.addJob(t, 0) + f.set(t, child.ID, []int{parent.ID}, 200) + f.edit(t, parent.ID, tc.change) + f.scan(t) + j := f.readJob(t, child.ID) + if j.State != api.StateBacklog || j.Blocked == nil || !strings.Contains(j.Blocked.Reason, tc.why) { + t.Fatalf("blocked = %+v", j) + } + f.edit(t, parent.ID, func(it *factory.Item) { it.DoneHow, it.State, it.PullRequest = "", factory.StateNew, nil }) + f.scan(t) + if j := f.readJob(t, child.ID); j.Blocked != nil || len(j.WaitingOn) != 1 { + t.Fatalf("restored = %+v", j) + } + }) + } +} + +// W5: the real run and Stop doors permanently retire waiting and blocked waits. +func TestWaitsPersonStartOverrides(t *testing.T) { + for _, blocked := range []bool{false, true} { + for _, stopBeforeScan := range []bool{false, true} { + f := waitsSetup(t) + parent, child := f.addJob(t, 0), f.addJob(t, 0) + f.set(t, child.ID, []int{parent.ID}, 200) + if blocked { + f.edit(t, parent.ID, func(it *factory.Item) { it.Failure = &factory.Failure{Step: "test"} }) + f.scan(t) + } + f.request(t, "POST", fmt.Sprintf("/v1/jobs/%d/run", child.ID), "operator-secret", nil, 200, nil) + if f.launches != 1 { + t.Fatalf("launches = %d", f.launches) + } + if !stopBeforeScan { + f.scan(t) + } + f.request(t, "POST", fmt.Sprintf("/v1/jobs/%d/stop", child.ID), "operator-secret", nil, 200, nil) + f.scan(t) + it := f.item(t, child.ID) + if !it.WaitsReleased || it.Blocked != nil || len(it.WaitingOn) != 0 { + t.Fatalf("started waits = %+v", it) + } + for _, event := range it.Activity { + if strings.HasPrefix(event.What, "waits over:") { + t.Fatalf("person's start recorded a release: %+v", event) + } + } + f.merge(t, parent.ID) + f.scan(t) + if after := f.item(t, child.ID); !reflect.DeepEqual(after, it) || f.launches != 1 { + t.Fatalf("inert job changed: %+v; launches = %d", after, f.launches) + } + } + } +} + +// W6: replacement, clearing and cycle checks operate before any write. +func TestWaitsChangesAndCycles(t *testing.T) { + f := waitsSetup(t) + a, b, c := f.addJob(t, 0), f.addJob(t, 0), f.addJob(t, 0) + f.set(t, a.ID, []int{b.ID}, 200) + f.set(t, a.ID, []int{c.ID}, 200) + if it := f.item(t, a.ID); !reflect.DeepEqual(it.WaitsOn, []int{c.ID}) || !reflect.DeepEqual(it.WaitingOn, []int{c.ID}) { + t.Fatal("waits were not replaced") + } + f.set(t, a.ID, []int{}, 200) + if it := f.item(t, a.ID); len(it.WaitsOn) != 0 || len(it.WaitingOn) != 0 || it.Blocked != nil { + t.Fatal("waits were not cleared") + } + f.set(t, a.ID, []int{b.ID}, 200) + f.set(t, b.ID, []int{a.ID}, 422) + f.set(t, b.ID, []int{c.ID}, 200) + before := f.item(t, c.ID) + f.set(t, c.ID, []int{a.ID}, 422) + if !reflect.DeepEqual(f.item(t, c.ID), before) { + t.Fatal("cycle refusal changed job") + } + for _, change := range []func(*factory.Item){ + func(it *factory.Item) { it.Stream = &factory.Stream{} }, + func(it *factory.Item) { it.Stream = nil; it.WaitsReleased = true }, + } { + f.edit(t, c.ID, change) + before := f.item(t, c.ID) + f.set(t, c.ID, []int{b.ID}, 422) + if !reflect.DeepEqual(f.item(t, c.ID), before) { + t.Fatal("inert-job refusal changed job") + } + } +} + +// W6: expect is checked and ownership is enforced, as at the run door. +func TestWaitsExpectAndOwner(t *testing.T) { + f := waitsSetup(t) + parent, child := f.addJob(t, 0), f.addJob(t, 0) + j := f.readJob(t, child.ID) + f.edit(t, child.ID, func(it *factory.Item) { it.Title = "changed" }) + before := f.item(t, child.ID) + f.request(t, "PUT", fmt.Sprintf("/v1/jobs/%d/waits_on", child.ID), "operator-secret", map[string]any{"waits_on": []int{parent.ID}, "expect": map[string]any{"version": j.Version}}, 409, nil) + if !reflect.DeepEqual(f.item(t, child.ID), before) { + t.Fatal("stale change wrote the job") + } + f.h.config.Mode = api.FactoryTeam + f.edit(t, child.ID, func(it *factory.Item) { it.Assignee = "other" }) + before = f.item(t, child.ID) + f.set(t, child.ID, []int{parent.ID}, 403) + if !reflect.DeepEqual(f.item(t, child.ID), before) { + t.Fatal("owner refusal wrote the job") + } +} + +// W7: restarting the handler loses no waits, and startup itself checks merged parents. +func TestWaitsRestart(t *testing.T) { + f := waitsSetup(t) + parent, child := f.addJob(t, 1107), f.addJob(t, 0) + f.set(t, child.ID, []int{parent.ID}, 200) + f.scan(t) + f.h.Close() + f.h = New(f.seam, f.cfg) + t.Cleanup(f.h.Close) + f.h.stopWaits() + if it := f.item(t, child.ID); it.WaitsReleased || !reflect.DeepEqual(it.WaitsOn, []int{parent.ID}) || !reflect.DeepEqual(it.WaitingOn, []int{parent.ID}) { + t.Fatalf("restart = %+v", it) + } + f.merge(t, parent.ID) + f.scan(t) + if !f.item(t, child.ID).WaitsReleased || f.launches != 0 { + t.Fatal("new handler did not release") + } + other := f.addJob(t, 0) + f.set(t, other.ID, []int{parent.ID}, 200) + f.h.Close() + f.h = New(f.seam, f.cfg) + t.Cleanup(f.h.Close) + f.h.stopWaits() + if !f.item(t, other.ID).WaitsReleased || f.launches != 0 { + t.Fatal("startup did not release merged parents") + } +} + +// W9: the factory advertises waits independently of its API contract number. +func TestWaitsAboutFeature(t *testing.T) { + f := waitsSetup(t) + var about struct { + Features []string `json:"features"` + } + f.request(t, "GET", "/v1/about", "", nil, 200, &about) + if !slices.Contains(about.Features, "waits_on") { + t.Fatalf("features = %v", about.Features) + } +} diff --git a/internal/factory/serve/waits_visibility.go b/internal/factory/serve/waits_visibility.go new file mode 100644 index 0000000000..53e03967dd --- /dev/null +++ b/internal/factory/serve/waits_visibility.go @@ -0,0 +1,23 @@ +package serve + +import ( + "fmt" + "net/http" + + "github.com/Agent-Field/codeaf/internal/factory" +) + +func (h *Handler) waitsParentsVisible(w http.ResponseWriter, r *http.Request, ids []int, parents map[int]factory.Item) bool { + for _, id := range ids { + parent, ok := parents[id] + visible := ok && factory.IsIssueJob(parent) && h.repoVisible(r, itemRepo(parent)) + if h.authFailed(w, r) { + return false + } + if !visible { + h.fail(w, http.StatusUnprocessableEntity, fmt.Sprintf("job %d does not exist in this factory", id)) + return false + } + } + return true +} diff --git a/internal/factory/store/outcomes_test.go b/internal/factory/store/outcomes_test.go new file mode 100644 index 0000000000..8fcf7f9b0d --- /dev/null +++ b/internal/factory/store/outcomes_test.go @@ -0,0 +1,252 @@ +package store_test + +import ( + "encoding/json" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/factory" + "github.com/Agent-Field/codeaf/internal/factory/api" + "github.com/Agent-Field/codeaf/internal/factory/live" + "github.com/Agent-Field/codeaf/internal/factory/store" +) + +// E1: each stored transition uses the exact timestamp of the factory.job fact. +func TestStoredOutcomesMatchJobFacts(t *testing.T) { + at := time.Date(2026, 11, 1, 12, 3, 4, 567, time.FixedZone("offset", 3600)) + tests := []struct { + name string + before factory.Item + after factory.Item + kind string + fact func(api.Job) time.Time + }{ + {"merged", factory.Item{State: factory.StateShipped, PullRequest: &factory.PullRequest{State: factory.PROpen}}, factory.Item{State: factory.StateShipped, PullRequest: &factory.PullRequest{State: factory.PRMerged, Number: 42, URL: "https://github.com/acme/api/pull/42", MergedAt: at}}, "merged", func(j api.Job) time.Time { return *j.PR.MergedAt }}, + {"answered", factory.Item{State: factory.StateRunning}, factory.Item{State: factory.StateShipped, DoneAt: at}, "finished", func(j api.Job) time.Time { return j.DoneAt }}, + {"closed", factory.Item{State: factory.StateShipped, PullRequest: &factory.PullRequest{State: factory.PROpen}}, factory.Item{State: factory.StateShipped, PullRequest: &factory.PullRequest{State: factory.PRClosed}, DoneAt: at}, "finished", func(j api.Job) time.Time { return j.DoneAt }}, + {"dropped", factory.Item{State: factory.StateNew}, factory.Item{State: factory.StateDismissed, DoneAt: at}, "finished", func(j api.Job) time.Time { return j.DoneAt }}, + {"failed", factory.Item{State: factory.StateRunning}, factory.Item{State: factory.StateNew, Failure: &factory.Failure{At: at, Code: factory.CodeToolError, Step: "check"}}, "failed", func(j api.Job) time.Time { return j.Failure.At }}, + {"question", factory.Item{State: factory.StateRunning}, factory.Item{State: factory.StateNeedsYou, Asking: &factory.Asked{At: at, With: factory.AskedYou, Question: "Which file?"}}, "needs_you", func(j api.Job) time.Time { return j.NeedsYou.Since }}, + {"need changed", factory.Item{State: factory.StateNeedsYou, QKind: factory.QKindApprove, Asking: &factory.Asked{At: at, With: factory.AskedYou}}, factory.Item{State: factory.StateNeedsYou, QKind: factory.QKindAskFirst, Asking: &factory.Asked{At: at, With: factory.AskedYou}}, "needs_you", func(j api.Job) time.Time { return j.NeedsYou.Since }}, + {"since changed", factory.Item{State: factory.StateNeedsYou, Asking: &factory.Asked{At: at.Add(-time.Second), With: factory.AskedYou}}, factory.Item{State: factory.StateNeedsYou, Asking: &factory.Asked{At: at, With: factory.AskedYou}}, "needs_you", func(j api.Job) time.Time { return j.NeedsYou.Since }}, + {"reason changed", factory.Item{State: factory.StateNeedsYou, Asking: &factory.Asked{At: at, With: factory.AskedYou}}, factory.Item{State: factory.StateNeedsYou, Asking: &factory.Asked{At: at, With: factory.AskedYou}, Merging: &factory.MergeRun{State: factory.MergeFailed}}, "needs_you", func(j api.Job) time.Time { return j.NeedsYou.Since }}, + {"failure repeated", factory.Item{State: factory.StateNew, Failure: &factory.Failure{At: at.Add(-time.Second)}}, factory.Item{State: factory.StateNew, Failure: &factory.Failure{At: at, Code: factory.CodeToolError, Step: "check"}}, "failed", func(j api.Job) time.Time { return j.Failure.At }}, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + st, err := store.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + it, err := st.Create(tt.before) + if err != nil { + t.Fatal(err) + } + if err := st.Update(it.ID, func(cur *factory.Item) error { + id := cur.ID + *cur = tt.after + cur.ID, cur.Title = id, "A job" + return nil + }); err != nil { + t.Fatal(err) + } + stored, err := st.Get(it.ID) + if err != nil { + t.Fatal(err) + } + zero := int64(0) + got, err := live.ReadOutcomes(st.Root(), &zero, 100) + if err != nil || got.Reset || len(got.Events) != 1 { + t.Fatalf("outcomes: %+v %v", got, err) + } + e, j := got.Events[0], api.FromItem(stored) + actual, _ := json.Marshal(e.At) + want, _ := json.Marshal(tt.fact(j)) + if string(actual) != string(want) || e.Kind != tt.kind || e.Job != it.ID || e.Title != "A job" { + t.Fatalf("outcome %+v: at %s, want %s", e, actual, want) + } + switch e.Kind { + case "merged": + if e.PRNumber != 42 || e.PRURL != tt.after.PullRequest.URL { + t.Fatalf("merged receipt: %+v", e) + } + case "finished": + if e.How != j.DoneHow { + t.Fatalf("finished how: %+v", e) + } + case "failed": + if e.Code != j.Failure.Code || e.Step != j.Failure.Step { + t.Fatalf("failure: %+v", e) + } + case "needs_you": + if e.Need != j.NeedsYou.Kind || e.Reason != j.Reason { + t.Fatalf("need: %+v", e) + } + waiting, _ := json.Marshal(j.WaitingSince) + if string(actual) != string(waiting) { + t.Fatalf("needs-you time %s differs from waiting_since %s", actual, waiting) + } + } + if err := st.Update(it.ID, func(cur *factory.Item) error { cur.Title = "Renamed"; return nil }); err != nil { + t.Fatal(err) + } + cursor := got.Cursor + quiet, err := live.ReadOutcomes(st.Root(), &cursor, 100) + if err != nil || len(quiet.Events) != 0 { + t.Fatalf("unchanged facts: %+v %v", quiet, err) + } + }) + } +} + +// E1, E3: creates are silent; Save updates persist across a reopened store. +func TestOutcomeCreateIsSilentAndSaveSurvivesReopen(t *testing.T) { + root := t.TempDir() + st, err := store.Open(root) + if err != nil { + t.Fatal(err) + } + for _, it := range []factory.Item{{State: factory.StateShipped}, {State: factory.StateNeedsYou, Question: "Which file?"}} { + if _, err := st.Create(it); err != nil { + t.Fatal(err) + } + } + it, err := st.Create(factory.Item{State: factory.StateRunning}) + if err != nil { + t.Fatal(err) + } + zero := int64(0) + initial, err := live.ReadOutcomes(st.Root(), &zero, 100) + if err != nil || initial.Reset || initial.Cursor != 0 || len(initial.Events) != 0 { + t.Fatalf("create outcomes: %+v %v", initial, err) + } + it.State, it.DoneAt = factory.StateShipped, time.Now().UTC() + if err := st.Save(it); err != nil { + t.Fatal(err) + } + reopened, err := store.Open(root) + if err != nil { + t.Fatal(err) + } + got, err := live.ReadOutcomes(reopened.Root(), &zero, 100) + if err != nil || got.Reset || len(got.Events) != 1 || got.Events[0].How != "answered" { + t.Fatalf("reopened: %+v %v", got, err) + } +} + +// E1: in-place nested edits retain the prior need, reason and wait timestamp. +func TestStoredOutcomesFromInPlaceNestedUpdates(t *testing.T) { + at := time.Date(2026, 11, 1, 12, 3, 4, 567, time.FixedZone("offset", 3600)) + later := at.Add(time.Minute) + tests := []struct { + name string + setup func(*factory.Item) + change func(*factory.Item) + reason string + since time.Time + }{ + { + name: "current phase time", + setup: func(*factory.Item) {}, + change: func(it *factory.Item) { it.Stream.Phases[it.Stream.Cur].At = later }, + reason: "approval_plan", since: later, + }, + { + name: "merge reason", + setup: func(it *factory.Item) { it.Merging = &factory.MergeRun{State: factory.MergeQueued} }, + change: func(it *factory.Item) { it.Merging.State = factory.MergeFailed }, + reason: "merge_failed", since: at, + }, + { + name: "follow-up enters needs-you", + setup: func(it *factory.Item) { + it.State = factory.StateShipped + it.PullRequest = &factory.PullRequest{State: factory.PROpen} + it.FollowUp = &factory.PRFollowUp{State: "watching"} + }, + change: func(it *factory.Item) { it.FollowUp.State = "needs_you" }, + reason: "approval_plan", since: at, + }, + { + name: "explicit stop enters needs-you", + setup: func(it *factory.Item) { + it.State = factory.StateNew + stopped := false + it.PersonStopped = &stopped + }, + change: func(it *factory.Item) { *it.PersonStopped = true }, + reason: "approval_plan", since: at, + }, + { + name: "phase stop enters needs-you", + setup: func(it *factory.Item) { + it.State, it.Stream.Ended = factory.StateNew, at + it.Stream.Phases[0].State = factory.PhaseFailed + it.Stream.Phases[0].Failure = &factory.Failure{Code: factory.CodeToolError} + }, + change: func(it *factory.Item) { it.Stream.Phases[it.Stream.Cur].Failure.Code = factory.CodeStopped }, + reason: "approval_plan", since: at, + }, + { + name: "publish enters needs-you", + setup: func(it *factory.Item) { + it.State = factory.StateRunning + it.PublishStatus = &factory.Publishing{State: "pushing"} + }, + change: func(it *factory.Item) { it.PublishStatus.State = "failed" }, + reason: "publish_failed", since: at, + }, + { + name: "phase note is quiet", + setup: func(*factory.Item) {}, + change: func(it *factory.Item) { it.Stream.Phases[it.Stream.Cur].Note = "Plan clarified" }, + }, + } + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + st, err := store.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + initial := factory.Item{ + State: factory.StateNeedsYou, QKind: factory.QKindApprove, Title: "A nested edit", + Stream: &factory.Stream{Cur: 0, Phases: []factory.Phase{{Name: "plan", At: at}}}, + } + tt.setup(&initial) + it, err := st.Create(initial) + if err != nil { + t.Fatal(err) + } + if err := st.Update(it.ID, func(cur *factory.Item) error { tt.change(cur); return nil }); err != nil { + t.Fatal(err) + } + stored, err := st.Get(it.ID) + if err != nil { + t.Fatal(err) + } + zero := int64(0) + got, err := live.ReadOutcomes(st.Root(), &zero, 100) + if err != nil || got.Reset || got.More { + t.Fatalf("outcomes: %+v %v", got, err) + } + if tt.reason == "" { + if len(got.Events) != 0 || got.Cursor != 0 || stored.Stream.Phases[0].Note != "Plan clarified" { + t.Fatalf("quiet nested edit: %+v, phase %+v", got, stored.Stream.Phases[0]) + } + return + } + if len(got.Events) != 1 || got.Cursor != 1 { + t.Fatalf("outcomes: %+v", got) + } + e, j := got.Events[0], api.FromItem(stored) + if j.State != "needs_you" || j.NeedsYou == nil || !j.NeedsYou.Since.Equal(tt.since) || j.Reason != tt.reason { + t.Fatalf("stored job facts: %+v", j) + } + actual, _ := json.Marshal(e.At) + want, _ := json.Marshal(j.NeedsYou.Since) + if e.Kind != "needs_you" || e.Job != it.ID || e.Title != stored.Title || e.Need != j.NeedsYou.Kind || e.Reason != j.Reason || string(actual) != string(want) { + t.Fatalf("outcome %+v: at %s, want since %s", e, actual, want) + } + }) + } +} diff --git a/internal/factory/store/policies.go b/internal/factory/store/policies.go new file mode 100644 index 0000000000..f7a1f6baa4 --- /dev/null +++ b/internal/factory/store/policies.go @@ -0,0 +1,95 @@ +package store + +import ( + "encoding/json" + "errors" + "fmt" + "os" + "path/filepath" + + "github.com/Agent-Field/codeaf/internal/factory" +) + +// policiesFile is not a job number, so List never reads settings as a job. +const policiesFile = "factory-policies.json" + +func (st *Store) policies() (map[string]factory.GroupPolicy, error) { + raw, err := os.ReadFile(filepath.Join(st.root, policiesFile)) + if errors.Is(err, os.ErrNotExist) { + return map[string]factory.GroupPolicy{}, nil + } + if err != nil { + return nil, err + } + var out map[string]factory.GroupPolicy + if json.Unmarshal(raw, &out) != nil || out == nil { + return nil, errors.New("the factory's policy file cannot be read") + } + for group, p := range out { + if group != p.Group || p.Check() != nil || p.Version < 1 { + return nil, errors.New("the factory's policy file cannot be read") + } + } + return out, nil +} + +// Policy reads fresh settings. Missing settings answer an empty policy; an +// unreadable settings file always refuses, including for an unset group. +func (st *Store) Policy(group string) (factory.GroupPolicy, error) { + all, err := st.policies() + if err != nil { + return factory.GroupPolicy{}, err + } + p, ok := all[group] + if !ok { + p = factory.GroupPolicy{Group: group, Allowances: []factory.Allowance{}} + } + if p.Allowances == nil { + p.Allowances = []factory.Allowance{} + } + return p, nil +} + +// ChangePolicy replaces one group's choice under a cross-process lock. The +// version advances only after a valid replacement is ready for atomic writing. +func (st *Store) ChangePolicy(group string, change func(*factory.GroupPolicy) error) (factory.GroupPolicy, error) { + var out factory.GroupPolicy + if checked, err := factory.CheckGroup(group); err != nil || checked == "" || checked != group { + return out, fmt.Errorf("a policy requires a non-empty, trimmed group") + } + err := st.underLock(filepath.Join(st.root, "factory-policies.lock"), func() error { + all, err := st.policies() + if err != nil { + return err + } + cur, ok := all[group] + if !ok { + cur = factory.GroupPolicy{Group: group, Allowances: []factory.Allowance{}} + } + version := cur.Version + if err := change(&cur); err != nil { + return err + } + if cur.Group != group { + return fmt.Errorf("a policy cannot change its group") + } + cur.Version = version + 1 + if cur.Allowances == nil { + cur.Allowances = []factory.Allowance{} + } + if err := cur.Check(); err != nil { + return err + } + all[group] = cur + raw, err := json.Marshal(all) + if err != nil { + return err + } + if err := writeAtomic(filepath.Join(st.root, policiesFile), append(raw, '\n')); err != nil { + return err + } + out = cur + return nil + }) + return out, err +} diff --git a/internal/factory/store/policies_test.go b/internal/factory/store/policies_test.go new file mode 100644 index 0000000000..082c10bd2b --- /dev/null +++ b/internal/factory/store/policies_test.go @@ -0,0 +1,98 @@ +package store + +import ( + "errors" + "os" + "path/filepath" + "sync" + "testing" + + "github.com/Agent-Field/codeaf/internal/factory" +) + +// P9, P10, P11: unset reads are empty, writes survive reopening, and rejected +// replacements leave both the saved policy and its version alone. +func TestPoliciesPersist(t *testing.T) { + st, err := Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + empty, err := st.Policy("g") + if err != nil || empty.Group != "g" || empty.Stopped || empty.Version != 0 || empty.Allowances == nil { + t.Fatalf("empty = %+v, %v", empty, err) + } + p, err := st.ChangePolicy("g", func(p *factory.GroupPolicy) error { p.Stopped = true; return nil }) + if err != nil || p.Version != 1 { + t.Fatalf("write = %+v, %v", p, err) + } + other, err := Open(st.Root()) + if err != nil { + t.Fatal(err) + } + p, err = other.Policy("g") + if err != nil || !p.Stopped || p.Version != 1 { + t.Fatalf("restart = %+v, %v", p, err) + } + refused := errors.New("refused") + if _, err := other.ChangePolicy("g", func(p *factory.GroupPolicy) error { p.Stopped = false; return refused }); !errors.Is(err, refused) { + t.Fatal(err) + } + p, err = st.Policy("g") + if err != nil || !p.Stopped || p.Version != 1 { + t.Fatalf("refusal wrote = %+v, %v", p, err) + } + if jobs, err := st.List(); err != nil || len(jobs) != 0 { + t.Fatalf("policy became a job: %+v, %v", jobs, err) + } +} + +// P11: independent stores serialize replacements of different groups. +func TestPoliciesShareLock(t *testing.T) { + st, err := Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + var wg sync.WaitGroup + for _, group := range []string{"g1", "g2", "g3"} { + wg.Add(1) + go func() { + defer wg.Done() + other, err := Open(st.Root()) + if err == nil { + _, err = other.ChangePolicy(group, func(p *factory.GroupPolicy) error { p.Stopped = true; return nil }) + } + if err != nil { + t.Error(err) + } + }() + } + wg.Wait() + for _, group := range []string{"g1", "g2", "g3"} { + if p, err := st.Policy(group); err != nil || !p.Stopped || p.Version != 1 { + t.Fatalf("lost %s: %+v, %v", group, p, err) + } + } +} + +// P13: malformed settings refuse reads and writes instead of disappearing. +func TestPoliciesUnreadable(t *testing.T) { + st, err := Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + for _, raw := range []string{"{", "null", `{"g":{"group":"g","allowances":[{"id":"m2","jobs":[1],"cap_usd":0,"ends_at":"2026-11-12T00:00:00Z"}]}}`} { + if err := os.WriteFile(filepath.Join(st.Root(), policiesFile), []byte(raw), 0600); err != nil { + t.Fatal(err) + } + if _, err := st.Policy("g"); err == nil { + t.Fatalf("read accepted %s", raw) + } + called := false + if _, err := st.ChangePolicy("g", func(*factory.GroupPolicy) error { called = true; return nil }); err == nil || called { + t.Fatalf("change accepted %s: %v", raw, err) + } + if after, err := os.ReadFile(filepath.Join(st.Root(), policiesFile)); err != nil || string(after) != raw { + t.Fatalf("refusal wrote %s, %v", after, err) + } + } +} diff --git a/internal/factory/store/read_only.go b/internal/factory/store/read_only.go new file mode 100644 index 0000000000..0983125eb5 --- /dev/null +++ b/internal/factory/store/read_only.go @@ -0,0 +1,17 @@ +package store + +import "github.com/Agent-Field/codeaf/internal/factory" + +// ReadOnly decodes one item document without locking, writing, retaining +// output or repairing repository identities. Atomic replacement lets a caller +// holding another item's lock safely read a spending snapshot by id. +func (st *Store) ReadOnly(id int) (factory.Item, error) { + if err := checkID(id); err != nil { + return factory.Item{}, ErrNotFound + } + d, err := st.read(st.ItemPath(id)) + if err != nil { + return factory.Item{}, err + } + return d.Item, nil +} diff --git a/internal/factory/store/read_only_test.go b/internal/factory/store/read_only_test.go new file mode 100644 index 0000000000..c4a96cc43f --- /dev/null +++ b/internal/factory/store/read_only_test.go @@ -0,0 +1,67 @@ +package store_test + +import ( + "bytes" + "errors" + "os" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/factory" + "github.com/Agent-Field/codeaf/internal/factory/store" +) + +// P5, P9: a spending read may run under an item lock without migrating aliases, +// rewriting the document, or changing the data used for lifetime spending. +func TestReadOnlyDoesNotLockOrRepair(t *testing.T) { + st, err := store.Open(t.TempDir()) + if err != nil { + t.Fatal(err) + } + job, err := st.Create(factory.Item{Repo: "api", SpentBefore: 2, ChatSpent: 1, Stream: &factory.Stream{Spent: 3}}) + if err != nil { + t.Fatal(err) + } + before, err := os.ReadFile(st.ItemPath(job.ID)) + if err != nil { + t.Fatal(err) + } + if err := st.SetRepos([]string{"owner/api"}); err != nil { + t.Fatal(err) + } + unchanged := errors.New("read only") + done := make(chan error, 1) + go func() { + done <- st.Update(job.ID, func(*factory.Item) error { + got, err := st.ReadOnly(job.ID) + if err != nil { + return err + } + if got.Repo != "api" || factory.JobLifetimeSpent(got) != 5 { + return errors.New("read repaired the alias or changed spending") + } + return unchanged + }) + }() + select { + case err := <-done: + if !errors.Is(err, unchanged) { + t.Fatal(err) + } + case <-time.After(5 * time.Second): + t.Fatal("read-only read acquired an item lock") + } + after, err := os.ReadFile(st.ItemPath(job.ID)) + if err != nil || !bytes.Equal(before, after) { + t.Fatalf("document changed: %s, %v", after, err) + } + if _, err := st.ReadOnly(job.ID + 1); !errors.Is(err, store.ErrNotFound) { + t.Fatalf("missing document = %v", err) + } + if err := os.WriteFile(st.ItemPath(job.ID+1), []byte("{"), 0600); err != nil { + t.Fatal(err) + } + if _, err := st.ReadOnly(job.ID + 1); err == nil { + t.Fatal("unreadable document was accepted") + } +} diff --git a/internal/factory/store/split_from_test.go b/internal/factory/store/split_from_test.go new file mode 100644 index 0000000000..4dc37e7489 --- /dev/null +++ b/internal/factory/store/split_from_test.go @@ -0,0 +1,34 @@ +package store + +import ( + "errors" + "testing" + + "github.com/Agent-Field/codeaf/internal/factory" +) + +// S6: the unstarted run-as-new cleanup cannot forget a split job with no run. +func TestSplitFromWithoutRunCannotBeForgottenAsCopy(t *testing.T) { + st := open(t) + split, err := st.Create(factory.Item{Repo: "acme/api", Title: "Smaller fix", State: factory.StateNew, SplitFrom: 80}) + if err != nil { + t.Fatal(err) + } + if err := st.ForgetUnstartedCopy(split.ID); err == nil { + t.Fatal("unstarted split job was forgotten as a run-as-new copy") + } + got, err := st.Get(split.ID) + if err != nil || got.SplitFrom != 80 || got.FromJob != 0 || got.Stream != nil { + t.Fatalf("retained split: split_from=%d from_job=%d stream=%v err=%v", got.SplitFrom, got.FromJob, got.Stream, err) + } + copy, err := st.Create(factory.Item{Repo: "acme/api", Title: "Run again", State: factory.StateNew, FromJob: 80}) + if err != nil { + t.Fatal(err) + } + if err := st.ForgetUnstartedCopy(copy.ID); err != nil { + t.Fatalf("run-as-new cleanup changed: %v", err) + } + if _, err := st.Get(copy.ID); !errors.Is(err, ErrNotFound) { + t.Fatalf("provisional copy remains: %v", err) + } +} diff --git a/internal/factory/store/store.go b/internal/factory/store/store.go index 353d5092cd..faf2772942 100644 --- a/internal/factory/store/store.go +++ b/internal/factory/store/store.go @@ -344,7 +344,7 @@ func (st *Store) Save(it factory.Item) error { if err != nil { return err } - err = live.Commit(st.root, live.Changes(current.Item, it, st.now()), func() error { + err = live.Commit(st.root, append(live.Changes(current.Item, it, st.now()), live.Outcomes(current.Item, it, st.now())...), func() error { return writeAtomic(st.ItemPath(it.ID), data) }) was, wrote = current.Item, err == nil @@ -500,6 +500,7 @@ func (st *Store) update(id int, mode writeMode, expect *int, change func(*factor } it := current.Item before := current.Item + outcomeBefore := live.OutcomeBasis(current.Item) before.Stages = factory.CopyStages(it.Stages) // In-place PR and failure edits must not rewrite the prior event facts. if before.PullRequest != nil { @@ -553,7 +554,7 @@ func (st *Store) update(id int, mode writeMode, expect *int, change func(*factor if err != nil { return err } - err = live.Commit(st.root, live.Changes(before, it, now), func() error { + err = live.Commit(st.root, append(live.Changes(before, it, now), live.Outcomes(outcomeBefore, it, now)...), func() error { return writeAtomic(st.ItemPath(id), data) }) was, after, wrote = before, it, err == nil diff --git a/internal/factory/types.go b/internal/factory/types.go index a1798d3b0a..f92220d98d 100644 --- a/internal/factory/types.go +++ b/internal/factory/types.go @@ -509,6 +509,19 @@ func (it *Item) SetPublishCheck(found string) { } type Item struct { + PolicyRun string `json:",omitempty"` + + WaitsOn []int `json:",omitempty"` + WaitingOn []int `json:",omitempty"` + WaitsReleased bool `json:",omitempty"` + Blocked *JobBlock `json:",omitempty"` + + // Group is the opaque label supplied when the job was made. + Group string `json:",omitempty"` + // Milestone is the milestone label supplied when the job was made. + Milestone string `json:",omitempty"` + // Context holds the person's reference attachments outside the description. + Context []JobContext `json:",omitempty"` // PublishAnyway preserves the person decision without changing measured proof. PublishAnyway *Approval `json:",omitempty"` // GitHubIssueID pins the permanent issue identity, learned only by a direct read. @@ -520,6 +533,8 @@ type Item struct { // FromJob is the ended job whose request this person started afresh. FromJob int `json:",omitempty"` + // SplitFrom is the job this job was split from, independent of run-as-new. + SplitFrom int `json:",omitempty"` // SourceIssue is a reference only; it never gives this job a forge identity. SourceIssue string `json:",omitempty"` diff --git a/internal/factory/waits.go b/internal/factory/waits.go new file mode 100644 index 0000000000..9c79c97340 --- /dev/null +++ b/internal/factory/waits.go @@ -0,0 +1,162 @@ +package factory + +import ( + "fmt" + "strings" + "time" +) + +// WaitsOnMost is the most parents one job can wait on. +const WaitsOnMost = 20 + +// BlockWaitsOn names a block caused by a parent job. +const BlockWaitsOn = "waits_on" + +// BlockPolicy names an allowance refusal or a group stop. +const BlockPolicy = "policy" + +// JobBlock says why a job waits for a parent to be fixed. +type JobBlock struct { + By string `json:"by"` + Reason string `json:"reason"` + Since time.Time `json:"since"` + Job int `json:"job,omitempty"` +} + +// WaitsRule is a decision over one snapshot of the parents, in waits_on order. +// A block also retains the unsatisfied parents; it never changes a job's state. +type WaitsRule struct { + WaitingOn []int + Blocked *JobBlock + Released bool +} + +// WaitsOverridden is permanent evidence that a person has started the job. +func WaitsOverridden(it Item) bool { + return it.Stream != nil || StoppedByPerson(it) || JobState(it) != "backlog" +} + +// WaitsPending is the guard for automatic intake. Only a scan releases waits. +func WaitsPending(it Item) bool { + return len(it.WaitsOn) != 0 && !it.WaitsReleased && !WaitsOverridden(it) +} + +// CheckWaitsIDs refuses invalid parent lists before either wire sends a write. +func CheckWaitsIDs(ids []int) error { + if len(ids) > WaitsOnMost { + return fmt.Errorf("a job can wait on at most %d jobs", WaitsOnMost) + } + seen := make(map[int]bool, len(ids)) + for _, id := range ids { + if id <= 0 { + return fmt.Errorf("waits_on must contain positive job ids") + } + if seen[id] { + return fmt.Errorf("waits_on must contain distinct job ids") + } + seen[id] = true + } + return nil +} + +// EvaluateWaits reads only the child and the given parents. It writes nothing. +func EvaluateWaits(it Item, parents map[int]Item) WaitsRule { + if it.WaitsReleased || WaitsOverridden(it) { + return WaitsRule{Released: true} + } + var out WaitsRule + for _, id := range it.WaitsOn { + parent, ok := parents[id] + if ok && WaitsSatisfied(parent) { + continue + } + out.WaitingOn = append(out.WaitingOn, id) + why := "" + if !ok { + parent.ID = id + why = "is no longer available" + } else { + why = WaitsEndedReason(parent) + if why == "" && !StoppedByPerson(parent) { + why = waitsFailureReason(parent) + } + } + if why != "" && out.Blocked == nil { + parent, why = waitsParentWords(it, parent, why) + out.Blocked = &JobBlock{By: BlockWaitsOn, Job: id, Reason: "waits on " + JobRef(parent) + ", which " + why} + } + } + out.Released = len(out.WaitingOn) == 0 + return out +} + +// WaitsSatisfied is a merge, or a shipped job with nothing to merge. +func WaitsSatisfied(it Item) bool { + if it.PullRequest != nil { + return it.PullRequest.State == PRMerged + } + if strings.TrimSpace(it.CommentURL) == "" || it.DoneHow == "closed" { + return false + } + return it.State == StateShipped && WaitsEndedReason(it) == "" && waitsFailureReason(it) == "" +} + +// WaitsEndedReason is why a parent ended without satisfying its children. +// A failed job can be retried, so failure alone never refuses a new wait. +func WaitsEndedReason(it Item) string { + if it.PullRequest != nil && it.PullRequest.State == PRMerged { + return "" + } + switch it.DoneHow { + case "dropped": + return "was dropped" + case "closed_locally": + return "was closed locally" + } + if it.PullRequest != nil && it.PullRequest.State == PRClosed { + return "closed without merging" + } + if it.State == StateDismissed { + return "was dismissed" + } + if it.DoneHow == "closed" || it.State == StateShipped && it.PullRequest == nil && strings.TrimSpace(it.CommentURL) == "" { + return "closed without merging" + } + if it.DoneHow != "" && it.State != StateShipped { + return "closed without merging" + } + return "" +} + +func waitsFailureReason(it Item) string { + if f := it.Failure; f != nil && f.Code != CodeStopped { + return waitsFailedStep(it, f.Step) + } + if it.Stream != nil { + for _, ph := range it.Stream.Phases { + if ph.State == PhaseFailed && (ph.Failure == nil || ph.Failure.Code != CodeStopped) { + return waitsFailedStep(it, ph.Name) + } + } + } + if it.PublishError != "" || it.PublishStatus != nil && it.PublishStatus.State == "failed" { + return "failed to publish" + } + if it.Merging != nil && it.Merging.State == MergeFailed { + return "failed to merge" + } + return "" +} + +func waitsFailedStep(it Item, name string) string { + for _, step := range it.Stages { + if step.Name == name && strings.TrimSpace(step.Label) != "" { + name = step.Label + break + } + } + if name != "" { + return "failed at " + name + } + return "failed" +} diff --git a/internal/factory/waits_fix2_test.go b/internal/factory/waits_fix2_test.go new file mode 100644 index 0000000000..9d28548723 --- /dev/null +++ b/internal/factory/waits_fix2_test.go @@ -0,0 +1,64 @@ +package factory + +import ( + "strings" + "testing" +) + +// W1, W2, W4: only a merge or a published comment receipt satisfies children. +func TestWaitsClosedIssueBlocks(t *testing.T) { + child := Item{ID: 2, Repo: "owner/api", WaitsOn: []int{1}} + for _, parent := range []Item{ + {State: StateShipped, DoneHow: "closed"}, + {State: StateShipped}, + {State: StateShipped, Publish: "comment"}, + {State: StateShipped, DoneHow: "answered"}, + {State: StateShipped, CommentURL: " "}, + } { + parent.ID, parent.Repo, parent.Num = 1, "owner/api", 1107 + got := EvaluateWaits(child, map[int]Item{1: parent}) + if WaitsSatisfied(parent) || WaitsEndedReason(parent) != "closed without merging" || got.Released || got.Blocked == nil || got.Blocked.Reason != "waits on api#1107, which closed without merging" { + t.Fatalf("closed parent %+v: rule %+v", parent, got) + } + } + for _, parent := range []Item{ + {State: StateShipped, DoneHow: "answered", Publish: "comment", CommentURL: "https://github.com/owner/api/issues/1107#issuecomment-7"}, + {State: StateShipped, CommentURL: "https://github.com/owner/api/issues/1107#issuecomment-7"}, + {State: StateShipped, DoneHow: "closed", PullRequest: &PullRequest{State: PRMerged}}, + } { + if !WaitsSatisfied(parent) || WaitsEndedReason(parent) != "" { + t.Fatalf("published parent did not satisfy: %+v", parent) + } + } + open := Item{ID: 1, State: StateShipped, PullRequest: &PullRequest{State: PROpen}} + if got := EvaluateWaits(child, map[int]Item{1: open}); WaitsSatisfied(open) || WaitsEndedReason(open) != "" || got.Blocked != nil || got.Released || len(got.WaitingOn) != 1 { + t.Fatalf("open pull request must keep waiting: %+v", got) + } +} + +// W3, W4, W11: another repository contributes no reference or step label. +func TestWaitsParentWordsAcrossRepositories(t *testing.T) { + child := Item{Repo: "owner/api", WaitsOn: []int{1}} + parent := Item{ID: 1, Product: "owner", Repo: "other", Num: 1107, Failure: &Failure{Step: "check"}, Stages: []Stage{{Name: "check", Label: "Private check"}}} + got := EvaluateWaits(child, map[int]Item{1: parent}) + if got.Blocked == nil || got.Blocked.Reason != "waits on job 1, which failed" { + t.Fatalf("cross-repository failure = %+v", got.Blocked) + } + for _, ending := range []string{"dropped", "closed_locally", "closed"} { + parent.DoneHow = ending + got = EvaluateWaits(child, map[int]Item{1: parent}) + if got.Blocked == nil || got.Blocked.Reason != "waits on job 1, which closed without merging" { + t.Fatalf("cross-repository ending %q = %+v", ending, got.Blocked) + } + } + parent.DoneHow, parent.Product = "", "" + got = EvaluateWaits(Item{Repo: "api", WaitsOn: []int{1}}, map[int]Item{1: parent}) + if got.Blocked == nil || got.Blocked.Reason != "waits on job 1, which failed" { + t.Fatalf("different short repositories = %+v", got.Blocked) + } + parent.DoneHow, parent.Repo, parent.Product = "", "API", "OWNER" + got = EvaluateWaits(child, map[int]Item{1: parent}) + if got.Blocked == nil || !strings.Contains(got.Blocked.Reason, "API#1107") || !strings.Contains(got.Blocked.Reason, "failed at Private check") { + t.Fatalf("same-repository failure = %+v", got.Blocked) + } +} diff --git a/internal/factory/waits_parent.go b/internal/factory/waits_parent.go new file mode 100644 index 0000000000..c27863a07e --- /dev/null +++ b/internal/factory/waits_parent.go @@ -0,0 +1,34 @@ +package factory + +import "strings" + +// WaitsParent keeps another repository's identity out of the child's text. +// Readers of the child may not be able to read the parent's repository. +func WaitsParent(child, parent Item) Item { + if !waitsSameRepo(child, parent) { + parent.Repo, parent.Product, parent.Num = "", "", 0 + } + return parent +} + +func waitsParentWords(child, parent Item, why string) (Item, string) { + if !waitsSameRepo(child, parent) { + if strings.HasPrefix(why, "failed") { + why = "failed" + } else { + why = "closed without merging" + } + } + return WaitsParent(child, parent), why +} + +func waitsSameRepo(child, parent Item) bool { + a, b := RepoOf(child), RepoOf(parent) + if a == "" { + a = strings.TrimSpace(child.Repo) + } + if b == "" { + b = strings.TrimSpace(parent.Repo) + } + return strings.EqualFold(a, b) +} diff --git a/internal/factory/waits_test.go b/internal/factory/waits_test.go new file mode 100644 index 0000000000..42c1501f11 --- /dev/null +++ b/internal/factory/waits_test.go @@ -0,0 +1,95 @@ +package factory + +import ( + "reflect" + "strings" + "testing" +) + +// W1, W3, W4: parents satisfy in order, and a repaired parent waits again. +func TestWaitsRules(t *testing.T) { + child := Item{ID: 3, Repo: "owner/api", State: StateNew, WaitsOn: []int{1, 2}} + parents := map[int]Item{1: {ID: 1, Repo: "owner/api", Num: 1107}, 2: {ID: 2, Repo: "owner/api"}} + if got := EvaluateWaits(child, parents); !reflect.DeepEqual(got.WaitingOn, []int{1, 2}) || got.Released || got.Blocked != nil { + t.Fatalf("waiting = %+v", got) + } + parents[1] = Item{ID: 1, PullRequest: &PullRequest{State: PRMerged}} + if got := EvaluateWaits(child, parents); !reflect.DeepEqual(got.WaitingOn, []int{2}) { + t.Fatalf("one merged = %+v", got) + } + parents[2] = Item{ID: 2, Repo: "owner/api", State: StateShipped, CommentURL: "https://github.com/owner/api/issues/2#issuecomment-1"} + if got := EvaluateWaits(child, parents); !got.Released || len(got.WaitingOn) != 0 || got.Blocked != nil { + t.Fatalf("released = %+v", got) + } + for _, tc := range []struct { + name string + parent Item + why string + }{ + {"step", Item{Failure: &Failure{Step: "test"}, Stages: []Stage{{Name: "test", Label: "Test"}}}, "failed at Test"}, + {"phase", Item{Stream: &Stream{Phases: []Phase{{Name: "test", State: PhaseFailed, Failure: &Failure{}}}}, Stages: []Stage{{Name: "test", Label: "Test"}}}, "failed at Test"}, + {"publish", Item{PublishError: "refused"}, "failed to publish"}, + {"publishing", Item{PublishStatus: &Publishing{State: "failed"}}, "failed to publish"}, + {"merge", Item{Merging: &MergeRun{State: MergeFailed}}, "failed to merge"}, + {"closed PR", Item{PullRequest: &PullRequest{State: PRClosed}}, "closed without merging"}, + {"dropped", Item{DoneHow: "dropped"}, "was dropped"}, + {"local close", Item{DoneHow: "closed_locally"}, "was closed locally"}, + {"dismissed", Item{State: StateDismissed}, "was dismissed"}, + } { + t.Run(tc.name, func(t *testing.T) { + parent := tc.parent + parent.ID, parent.Repo, parent.Num = 1, "owner/api", 1107 + parents[1] = parent + got := EvaluateWaits(child, parents) + if got.Blocked == nil || got.Blocked.By != BlockWaitsOn || got.Blocked.Job != 1 || !strings.Contains(got.Blocked.Reason, "api#1107") || !strings.Contains(got.Blocked.Reason, tc.why) { + t.Fatalf("blocked = %+v", got.Blocked) + } + parents[1] = Item{ID: 1, Repo: "owner/api"} + if fixed := EvaluateWaits(child, parents); fixed.Blocked != nil || fixed.Released || !reflect.DeepEqual(fixed.WaitingOn, []int{1}) { + t.Fatalf("fixed = %+v", fixed) + } + }) + } +} + +// W3, W4: a person's Stop is waiting, and a merged PR satisfies even an old failure. +func TestWaitsStopAndMerge(t *testing.T) { + parent := Item{ID: 1, Failure: &Failure{Code: CodeStopped}} + child := Item{WaitsOn: []int{1}} + if got := EvaluateWaits(child, map[int]Item{1: parent}); got.Blocked != nil || got.Released { + t.Fatalf("stopped parent = %+v", got) + } + parent.PullRequest = &PullRequest{State: PRMerged} + if got := EvaluateWaits(child, map[int]Item{1: parent}); !got.Released { + t.Fatalf("merged parent = %+v", got) + } + if got := EvaluateWaits(Item{WaitsOn: []int{12}}, map[int]Item{12: {ID: 12, DoneHow: "closed_locally"}}); got.Blocked == nil || !strings.Contains(got.Blocked.Reason, "job 12") { + t.Fatalf("local reference = %+v", got) + } +} + +// W5, W6: waits are inert after any start, Stop or release. +func TestWaitsInert(t *testing.T) { + for _, it := range []Item{ + {State: StateQueued}, {State: StateRunning}, {Stream: &Stream{}}, {WaitsReleased: true}, {Failure: &Failure{Code: CodeStopped}}, + } { + it.WaitsOn = []int{1} + if WaitsPending(it) || !EvaluateWaits(it, nil).Released { + t.Fatalf("waits remain active: %+v", it) + } + } +} + +// W2, W6, W10: shape bounds are shared by the service and engine. +func TestWaitsIDs(t *testing.T) { + for _, ids := range [][]int{{0}, {-1}, {1, 1}, make([]int, WaitsOnMost+1)} { + if err := CheckWaitsIDs(ids); err == nil { + t.Fatalf("accepted %v", ids) + } + } + for _, ids := range [][]int{nil, {}, {1, 2}} { + if err := CheckWaitsIDs(ids); err != nil { + t.Fatal(err) + } + } +} diff --git a/internal/manual/chat/client-tools.md b/internal/manual/chat/client-tools.md new file mode 100644 index 0000000000..163fadb4fa --- /dev/null +++ b/internal/manual/chat/client-tools.md @@ -0,0 +1,36 @@ +# Client tools: tools an app adds to a conversation + +## Client tools: connection, remote holders, limits and waiting + +An app connected through `codeaf app` can attach tools and instructions to one +conversation. The model sees their names, descriptions and schemas; the app +checks arguments and answers calls. Child tasks, workers and clarification +conversations do not receive them. + +Attaching tools does not open the conversation or start a turn. Changes apply +at the next model step. Each conversation allows 4 sets, 32 tools per set and +64 tools total. Replacing a set replaces its tools and instructions; an empty set +removes it. Tool names must be unique and cannot reuse engine tool names. + +A collection keeps a conversation before its first message; reopen it by id +after a restart. + +Set ids start with a lowercase letter, followed by lowercase letters, digits, +underscores, dots or hyphens, up to 64 characters. Tool names exclude dots and +hyphens. Descriptions take 1–2048 bytes; schemas are JSON objects with +`type: "object"`, up to 16 KiB. Instructions allow 4096 bytes per set. Tools +declare `read`, `edit` or `create`; reading-only contexts refuse changes. +The usual approval rules apply. + +The wait defaults to 30 seconds; apps can choose 1–120 seconds per set. +120 seconds is the maximum. Stopping the turn ends the wait. Timeout or +disconnect leaves the outcome unknown: check before trying again. +codeaf does not retry calls automatically. +Results are compact JSON, cut at 64 KiB with the dropped-byte count. Error +answers take 1–4096 bytes. + +Joined holders forward tools when supported; older holders refuse attachment. +Reconnect restores sets only to the same conversation. A chat held on another +computer stores sets until "Continue here" or take. Each connection owns its +tools and calls. Link loss ends calls when observed or at their wait limit; +later answers are refused. diff --git a/internal/manual/chat/collections.md b/internal/manual/chat/collections.md index 0b878f3181..4816a949d2 100644 --- a/internal/manual/chat/collections.md +++ b/internal/manual/chat/collections.md @@ -3,7 +3,7 @@ ## How do I group chats in logical folders `codeaf collections` is a local command for organizing references to conversations, -tasks and files. A collection is a logical folder with a stable ID +tasks, Factory jobs and files. A collection is a logical folder with a stable ID and name. Several collections can reference the same record. Collections can contain other collections, including one child shared by several parents; circular membership is refused. Renaming a collection preserves its ID. @@ -71,7 +71,8 @@ belong to collections. ## Adding, removing and finding a chat or work reference Use `codeaf collections add `. -The kinds are `collection`, `conversation`, `task`, `process`, `crew` and `artifact`. There is no kind +The kinds are `collection`, `conversation`, `task`, `process`, `crew`, +`factory_job` 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`. @@ -100,6 +101,19 @@ something that was never there is not an error. Automatic organization, inherited instructions and communication between conversations are not implemented by these commands. +## Adding, finding and removing a Factory job + +Use `codeaf collections add factory_job --factory +`. The job number must be a positive decimal without a sign or leading +zeroes. The factory ID is required: the same number in two factories names two +different jobs. Use the same `--factory` with `find` and `remove`. Other kinds +refuse a factory ID. `show` prints `factory_job factory `; +`--json` includes the `factory` field. + +A collection can reference a job alongside any other records, and several +collections can hold the same job. These commands retain references without +asking the Factory whether the job is available and never start a job. + ## Deleting or duplicating a collection `codeaf collections delete ` deletes the collection, its @@ -108,3 +122,13 @@ memberships, and every membership naming it in another collection, answering 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`. + + +## Collections database versions and older engines + +Opening and reading a collections database never migrate it. The first successful +write upgrades supported versions 1–6 to schema 7; a failed write leaves the file +unchanged. Existing collections and references survive the upgrade, including +Factory job references. Older engines that do not support schema 7 refuse the +database with `unsupported collections database`. Collections are unavailable +in those engines; the data remains intact. Use a compatible engine to read it. diff --git a/internal/manual/chat/conversations-and-teams.md b/internal/manual/chat/conversations-and-teams.md index 57370deb1f..129c11cb7f 100644 --- a/internal/manual/chat/conversations-and-teams.md +++ b/internal/manual/chat/conversations-and-teams.md @@ -436,8 +436,9 @@ Use the Memories tab to forget a saved memory separately. Deleting a team also p Ask “start another chat on kimi-k3 to read the README.” Load the `chats` capability group if needed. The `start_chat` tool takes `brief`, the complete first message, and `model`, a catalog ID or unique model name. -Omitting `model` uses the requesting chat's current model. An unknown or ambiguous -name is refused before any chat opens. The new chat opens behind the current one, +Omitting `model` saves the requesting chat's configured model in the new chat. +The new chat applies its own temporary availability replacement before sending. +An unknown or ambiguous name is refused before any chat opens. The new chat opens behind the current one, with its model and approval posture set before its first message. Your focus stays in the original chat. Its receipt confirms the request; the terminal reports whether it actually opened. This needs an attached interactive terminal that can open chats; app-only sessions do not offer this tool. diff --git a/internal/manual/chat/factory-group-policy.md b/internal/manual/chat/factory-group-policy.md new file mode 100644 index 0000000000..e389bd792c --- /dev/null +++ b/internal/manual/chat/factory-group-policy.md @@ -0,0 +1,70 @@ +# Factory group policy and allowances + +## group policy, allowances and automatic starts after waits + +A job can carry an opaque `group`, set by `factory.job_create`. It is trimmed, +at most 200 characters, with no control characters. `create_group` says the +engine carries it. `factory.policy_get` reads a group's policy; +`factory.policy_set` replaces its stopped flag and allowances. Both take +`factory` and a non-empty `group`. HTTP uses GET and PUT on +`/v1/groups/{group}/policy`, with the group escaped as one path segment. + +A released waiting job in Backlog starts automatically only when its policy +allows it. An allowance must list the job, end in the future and have room for +the job's whole budget after recorded spending. The automatic start names the +allowance in activity. The factory's intake cost limit does not apply. Without +an allowance it stays in Backlog with `blocked.by: policy`, for example +`no allowance covers it`. A later grant is checked on the next waits scan. +Your Start overrides an allowance's scope, cap and end, and the waits. The job's +own lifetime budget still applies. + +The engine advertises `factory.group_policy` and capability `group_policy`. +Use it when the connected factory's `factory.features` also lists +`group_policy`. An older factory returns a conflict with reason +`factory_too_old`. Group policies survive a factory restart. + +## policy limits, allowance spending, end dates and versions + +A policy has at most 50 allowances. Each allowance has a distinct `id` of +1–64 characters from letters, digits, dot, underscore, colon and hyphen; +1–200 distinct positive job ids; `cap_usd` greater than zero and at most +10,000; and an RFC 3339 `ends_at`. Past end dates are accepted. An empty +allowance list revokes the grants. The job ids belong to this factory. + +Reads include group `spent_usd` across every job in the group. Each allowance's +`spent_usd` counts every run of its listed jobs that exist here, including +earlier runs and runs a person started. Its `state` is `ended` at the end date, +else `spent` at the cap, else `active`. An unset group returns an empty policy +and its group spending. Reads include `version`; send that optional version +with a replacement to reject stale changes with a conflict. + +Money decisions compare whole micro-dollars, so $0.10 spent plus a $0.20 +job budget fits a $0.30 allowance. During a run, other listed jobs' spending +may be cached for up to two seconds; this job's own spending is current. +Missing or unreadable listed jobs contribute $0. + +On a team factory the first setter owns the policy. Only that owner or the +operator may replace it, and it automatically starts only jobs the owner may +start. A solo factory skips ownership checks. Every replacement is recorded +in factory activity. + +## policy hold, stopped groups and Continue or Start + +A policy's run is checked again at lease admission, step begin and worker +reports. When its allowance ends, reaches its cap or stops covering the job, +the job becomes needs-you with `blocked.by: policy`. Its cap question states +the policy reason. Its work and spending stay saved, and execution is +withdrawn. Continue or Start resumes the work as your run; allowance caps, +ends and scope then stop checking that run. + +Setting `stopped: true` refuses every run in the group, including one you +started, with `its group is stopped`. Running jobs get Stop after this step; +queued jobs meet the lease gate. Clearing stopped allows new automatic starts +of covered releases. Jobs already held wait for a person to Continue or Start. +An unreadable policy file holds with `its group's policy cannot be read` and +prevents automatic starts. + +A stopped group's progress, cost and completion reports keep the admitted +step's work. Admission of a new step or round is held even if the worker's +clock differs. Repeating a stopped replacement retries Stop after this step +for running jobs. diff --git a/internal/manual/chat/factory.md b/internal/manual/chat/factory.md index 8ab49fada3..278c5b44d4 100644 --- a/internal/manual/chat/factory.md +++ b/internal/manual/chat/factory.md @@ -1,5 +1,50 @@ # The factory +## a job that waits on other jobs — waits on, blocked, ready + +A Backlog job can wait on up to 20 other jobs of the same factory. Set or +replace its parents with `factory.waits_set` (`waits_on`); an empty list clears +them. You can change waits only before the job has started or been released. +Unknown or repeated ids, the job itself, cycles and parents that already ended +without merging are refused without changing the job. + +The job read keeps `waits_on` as set. `waiting_on` lists the parents still +unsatisfied at the factory's last check, in that order. A parent satisfies +its children when its pull request merges, or it ships with nothing to merge. +A failed parent or one that ends without merging adds `blocked`, naming the +parent and why. Repairing the parent clears that block on the next check. +A person's Stop on a parent is not a failure. The person's queue Hold (`held`) +is separate. + +The factory checks on startup and after folder changes, at most once every +two seconds, with a safety check every minute. After every parent satisfies +it, the job is ready in Backlog and its activity records `waits over:` with +the parents. This release never starts the job. Your Start works while it +waits or is blocked; from then on those waits stay inert, including after Stop. + +The engine advertises `factory.waits_set`, `factory.features` and capability +`waits_on`. `factory.features` reads the connected factory's own features; +use waits only when that list also includes `waits_on`. Older factories that +omit the list answer an empty list. An older factory refusing the waits route +answers a conflict with reason `factory_too_old` and asks you to update it. + +## closed issues, comment receipts and parent visibility for waits + +A closed GitHub issue does not satisfy its children. A job that shipped with +nothing to merge must have a saved comment receipt; choosing comment publication +alone is not enough. Closing without a merge or that receipt blocks its children +and refuses new waits on it. + +You can set waits only on parents whose repositories you can read. An unreadable +parent gets the same refusal as an unknown job. A parent in another repository +appears in the child's block and release activity as `job` and its number. Its +block says only `failed` or `closed without merging`; its repository and failed +step stay out of the child's text. Parents in the same repository keep their +usual references and failed-step labels. + +Your Start retires waits immediately. They stay inert after Drop and Bring back, +even when no factory check happened between those actions. + ## factory repository list refresh, saved repositories and available_read_at The factory saves the last complete GitHub repository list across a restart. @@ -825,6 +870,87 @@ Terminal factory sentences still read those words as chips. Existing jobs are not rewritten; this applies when a new job is created. Factories without the separate-title creation capability retain their existing creation behavior. +## Creating a job with an issue, group, milestone, budget and retry key + +`factory.job_create` accepts these optional fields alongside the job's title +and details: + +| Field | Bounds and default | +| --- | --- | +| `open_issue` | Boolean, default false: create a job without opening an issue. True opens one GitHub issue using only the title and details, then ties the job to its number. | +| `group`, `milestone` | Opaque labels, trimmed, at most 200 characters each, with no control characters. Omitted or empty means none; labels stay outside the description and appear on job and queue reads. | +| `budget` | Initial USD limit: 5, 10, 20 or 30. Recorded before starting and kept on an immediate or later start. Omission keeps the usual pipeline/Factory default ($5 when neither sets one). | +| `key` | Retry key: 1–128 characters from `[A-Za-z0-9._:-]`, sent as the create's `Idempotency-Key` header. Omitted or empty (`key: ""`) means no key and no header. | + +`factory.capabilities` flags are `create_issue`, `create_group` (both labels), +`create_budget` and `create_key`. True means the engine supports the choice; +an older engine omits the flag or reports false. The attached Factory can +still be older. A 422 refusing unknown `group`, `milestone`, `budget` or HTTP +`issue`, or a 404/405 on `/v1/issues`, answers `conflict` with +`detail.reason: "factory_too_old"`: +"This factory doesn't support that yet; nothing was changed. Update the factory +to use it." No fallback create is sent. Invalid fields answer `bad_params` +before a write. + +The Factory replays the first response for the same caller, route and key for +24 hours. After an `uncertain` create, resend the same key within that window; +a different key creates a separate job. Without a key there is no replay +protection. Context is a separate `factory.job_context_set` write after +creation; it is not a create field. + +## Split a failed job into smaller jobs — split_from lineage + +When creating smaller jobs from a failed job, `factory.job_create` accepts +optional `split_from`: an integer job number of at least 1 on the same Factory. +Engines that carry it announce `create_split_from: true` in +`factory.capabilities`. + +Each new job remembers that number. Its job read, queue row and create reply's +`current` carry `split_from`; a job created without it omits the field. The new +job's own activity adds one line such as `split from #80`. The parent job stays +unchanged. The smaller job follows the normal creation, pipeline and start +rules; its steps read its own brief and details. + +An explicit zero or negative number answers `bad_params` before the engine +sends anything. The Factory also requires the parent to exist and be visible +to the caller. A missing or hidden parent answers `bad_params` before any job +or GitHub issue is made, including when `open_issue: true` was requested. + +An older Factory that refuses the `split_from` field answers `conflict` with +`detail.reason: "factory_too_old"`; update that Factory. Nothing is made, and +the engine sends no fallback create. + +## Job context attachments before the first run + +`factory.job_context_set` replaces the reference material attached to one job. +Send `factory`, `job`, the `expect.version` from the job you read, and `context`. +An empty list clears the attachments. Engines announce `factory.job_context` +in hello and `job_context` in `factory.capabilities`. Job creation does not +accept `context`; create the job in Backlog, attach context, then start it. + +Context can be changed in Backlog or while queued while the job has no run: +before its first run, or after Bring back cleared its last one. Once a run has +begun, the Factory refuses with "The job has started; its steps already read +their context." A stale version changes nothing: read the job again. +The same caller rule as model edits applies: in a team Factory the +job's person may edit, or anyone when no person is assigned; solo allows it. + +Attach at most 20 items, each with a title, markdown and optional source label, +path or URL. Titles are 1–200 characters after trimming, sources at most 500, +and markdown must be non-empty. All markdown together is at most 100,000 +characters, separate from the job description's 20,000-character limit. +Job and queue reads show only titles and sources. + +The first eligible conversation named `plan` reads the whole attachments as +reference material; if no such plan runs, the first eligible conversation reads +them. Disabled, excluded and skipped plans do not take context away from that +conversation. Other steps receive none. Attachments do not override the job or +step instructions and never enter a GitHub issue, pull request or comment. + +An older Factory without the context route answers `conflict` with reason +`factory_too_old`; update it. A lost answer is `uncertain`: the engine sends the +write once, so read the job before deciding what to do next. + ## Job Brief inputs — later requests, answers and saved notes The Brief reads the issue plus your later requests from gate answers and saved diff --git a/internal/manual/chat/how-tasks-run.md b/internal/manual/chat/how-tasks-run.md index 876b894b94..c1fc6e221b 100644 --- a/internal/manual/chat/how-tasks-run.md +++ b/internal/manual/chat/how-tasks-run.md @@ -3059,15 +3059,63 @@ A missing argument comes back as an ordinary result, never an error: `deliverable` and `acceptance`, in that order. Unparseable JSON answers `Invalid arguments: ` and the parse error. -There is a sixth part the model is **not** asked for and cannot leave out: your own -message. See the next section. +For a proposed run, the model must put all needed context in those arguments. The +worker receives that assignment without your conversation message or its journal pointer. + +## Task tree limits, duplicate tasks and proposals that auto-start + +A run worker can hand out work at most two levels below its run's root task. A run can +hold at most 20 counted tasks: the root, checks seated by the review round and cancelled +tasks do not consume those slots. A task a worker creates with `--role check` still +counts. These are runaway stops, not a target for how much work to hand out. + +Each worker can add at most **five** tasks in total, excluding cancelled additions. +Finished and archived additions still count. Joined handoffs, review checks and fix tasks +created by the engine do not spend that worker's five slots. + +Every worker-created task must be under that worker's own task. `plandb add` without +`--parent` uses its own task; an explicit different parent is refused. A worker can split +or pivot only its own task, and insert only beside one of its own children. A refusal +adds nothing and tells it to do the remaining work itself or report it undone. Your own +unbound CLI still defaults to the run root. + +## Duplicate task titles and countdown proposals joining a run + +A worker's `plandb add`, `split`, `task insert` or `task pivot` also refuses a title already +used anywhere in that run, including its own task and the root. Titles are compared in +lower case with runs of punctuation and whitespace folded to one space. Cancelled titles +can be reused. A duplicate within one batch, or a batch that would cross either tree +limit, refuses the whole batch: nothing is added and nothing can start from it. The +refusal tells the worker to do its own work or report what remains undone. + +A proposal that joins a live run because its countdown expired obeys the same growth +rules. A refusal settles its card as not started. Choosing to start it yourself, or typing +`/task`, retains your ability to join work beyond the count limit. Every ordinary run +still has its whole-tree per-task dollar ceiling. Your own `codeaf plandb` commands with +no run bound retain their existing behavior; the growth refusal applies to run workers. + +## What a proposed task reads — its own title, work, output and done-condition + +A task the conversation proposed on the run road receives its title, **THE WORK**, +**WHAT TO PRODUCE** and **DONE WHEN**. Your conversation message, surrounding quotes, +earlier call evidence and pointer to the original journal line are not included. This +applies whether the countdown starts the proposal or you approve it yourself. The +proposing model must make the assignment self-contained. + +Every worker opens on its own task id and title. A child reads its assignment's ask +later as context. A proposal joined into an existing run keeps its own assignment +for its workers and checks; they do not inherit the other task's root description. +A task you typed yourself, such as `/task`, keeps its existing brief. The older +session-tree road keeps its existing conversation context and journal pointers. ## What the task actually reads — does it see what I said? -Yes. Your own message travels with the work, word for word. +For a task you typed yourself or a task on the older session-tree road, your own message +travels with the work, word for word. Proposed runs use the assignment-only document above. -A task's first and only message is assembled by codeaf from these parts, under headings, in -this order: +A task you typed yourself keeps your message first. Its opening document is assembled by +codeaf from these parts, under headings, in this order; the older session-tree road uses +this order too: ``` WHAT THE PERSON ASKED FOR, IN THEIR OWN WORDS @@ -3156,7 +3204,7 @@ Long messages are cut at 6000 bytes and the cut is marked with `…`, so a task handed a shortened version of what you said can see that it was. The brief then names the journal path and line of your original turn, so the worker can read the uncut words itself. -A task the model hands out from **inside** another task inherits the same words: there is +On the older session-tree road, a task handed out from **inside** another task inherits the same words: there is nobody inside a task's own folder to type a new message, so the sentence that started the family is what every task under it reads. @@ -3188,7 +3236,8 @@ question to answer. Most steering is that, and it moves no target. ## Can the task see the original request — does the task know what I originally said, can a task read my full message when the brief is cut -Yes. The brief is still the contract — the task is not handed this conversation — but it is +For a person-typed task or the older session-tree road, yes. Proposed runs receive no +original-message pointer. The brief is still the contract — the task is not handed this conversation — but it is handed an address: the filesystem path of the session journal and the line where your turn began. The worker already has `read` and `grep`, so that pointer is enough. @@ -3209,7 +3258,7 @@ it must ship is still what the brief says. **An empty pointer draws nothing.** A task restored from a checkpoint written before this existed has no origin section. Unknown is absent, not a guessed path. -A task handed out from inside another task, and every part of a division, inherit the same +On the older session-tree road, a task handed out from inside another task and every part of a division inherit the same pointer — they still point at your turn, never at the parent task's own journal. ## The optional arguments on propose_task diff --git a/internal/manual/chat/models-and-cost.md b/internal/manual/chat/models-and-cost.md index e4365b83db..fdac87d515 100644 --- a/internal/manual/chat/models-and-cost.md +++ b/internal/manual/chat/models-and-cost.md @@ -121,13 +121,22 @@ On a local launch, the opening model comes from the same available chat list as is kept only if that model is listed. Otherwise codeaf selects the first available chat model in provider order. With only Ollama connected, that is an installed Ollama model. With several providers, the default still belongs to their combined list. -Automatic replacements do not overwrite your saved preference. +Availability replacements apply only to this run. They do not overwrite the +conversation's saved model, main/fallback pair or profile model settings. When +the configured model is listed again, codeaf returns to it automatically. +A hidden chat reconciles availability when it next comes to the front. +Choosing a model yourself, including the replacement already running, saves that +choice as usual. While every catalog is cold or empty, no default model is displayed and a message stays in the draft. The picker opens with: `no available model · connect a provider or refresh /model`. When a provider's list arrives, codeaf selects an available model. Press Enter again -to send the draft. Refreshing a list or removing a connection also replaces a model +to send the draft. The replacement is displayed immediately, and each send waits +for that chat's model switch to finish. A failed selection reports +`could not change model · `; the message still sends once on the engine's +current model, and the next availability check retries the selection. +Refreshing a list or removing a connection also replaces a model that is no longer listed; existing requests finish on the model they started with. Remote sessions keep the engine's opening model; this laptop's catalog does not @@ -817,6 +826,9 @@ unchecked rather than on a bill ten times its estimate. Only the checker's own calls count toward that ceiling, whatever model the worker or planner runs, including a crew whose three seats use one model. The ceiling follows the checker when it moves to another model. +Each check in a run starts with its own checker ceiling. An earlier check's actual +cost does not raise the estimate held against a later check's ceiling. Every check's +spend still counts toward the whole run's per-task limit and the day's limits. This cap is the crew's own. The day's limit under `/settings` → Spending counts everything codeaf spends, and still applies. diff --git a/internal/manual/chat/running-from-the-terminal.md b/internal/manual/chat/running-from-the-terminal.md index d06cd57a6e..f37a514cc8 100644 --- a/internal/manual/chat/running-from-the-terminal.md +++ b/internal/manual/chat/running-from-the-terminal.md @@ -432,6 +432,9 @@ 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. +Live step events also carry the conversation's spend so far when it is known +and positive; the saved conversation total can catch up after the reply ends. + ## 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. diff --git a/internal/manual/chat/services.md b/internal/manual/chat/services.md index c4780263d3..c5242ac1fe 100644 --- a/internal/manual/chat/services.md +++ b/internal/manual/chat/services.md @@ -220,6 +220,9 @@ A provider answering the current turn cannot be cut: the disconnected sentence first and then says `this conversation was on deepseek-direct/deepseek-v4-pro · it is now on ~deepseek/deepseek-v4-flash-latest`, or, when nothing can replace it, `this conversation was on deepseek-direct/deepseek-v4-pro and nothing else here can take it · connect a provider or pick a model`. +The replacement is for this run only; it leaves the saved model and profile model +settings unchanged. The configured model returns when it is available again. +Choosing the replacement yourself saves it, even if it is already running. OpenRouter uses the same two-Enter confirmation. A saved profile key is cleared from the profile and the running session; its models disappear, while Ollama and other connected @@ -306,7 +309,9 @@ empty key box keeps the saved key. A changed name is a rename: every model id al name is re-spelled with the new one, the conversation's own pick first (a turn still answering is waited out), and with it the stored ones: reasoning levels, the worker, checker and planner pins, role pins, the fallback chain and the capability slots. A rename changes a label and nothing -else; it does not move the conversation onto a different model. `ctrl+r` on the row +else; it carries saved and temporary selections separately, keeping a temporary +replacement unsaved. A deferred rename stays with its conversation; a later +explicit model or main/fallback choice supersedes it. `ctrl+r` on the row reconnects with the saved details. The `+ add a provider` row checks the address and asks for a name, requesting a key only when the server requires authentication, so the tab never sends you to `/connect` to add one. The `active provider` row reads diff --git a/internal/manual/chat/sessions-and-rewind.md b/internal/manual/chat/sessions-and-rewind.md index 80a447287e..6895583645 100644 --- a/internal/manual/chat/sessions-and-rewind.md +++ b/internal/manual/chat/sessions-and-rewind.md @@ -310,20 +310,24 @@ exactly `[image /path/to/file — file changed or gone]`. Yes. Every piece of work that cost money appends a `usage` line to the transcript, carrying the model it ran on, tokens in and out, cache read and cache write, the money, how many -requests it took and how long it lasted. +requests it took and how long it lasted. A late receipt writes a marked `call` line instead +(below). There is one line per **completed turn**, and one more for each call made beside a turn — naming the conversation, the memory reflex, the check on a reply, a task's fold-up, a -planner, a harness run. Those are marked `aux` so a reader can tell the turn itself from -the work done around it. A turn that spent nothing writes no line at all, because a zero is -absence, not a fact worth a line. +planner, a harness run. Those are marked `aux`. A second-stage stop that lets go of a +turn while tools still run also saves its paid conversation steps in a `usage` seal, +without duration. Its `abandoned` line records the stop and last-known spend as evidence; +it is never added to the bill. A turn that spent nothing writes no usage line. +Older transcripts without that step seal cannot restore the missing steps. One exception to "one line per turn": a turn that was answered by **more than one model** — the model hopped mid-turn — writes **one line per model that answered**, each with that model's own tokens and money. A single sum could only name one of them, and naming the one standing at the end would hand the whole turn's bill to the wrong model. -**On a resume these lines are added up, and that sum is the session's totals.** So `/cost`, +**On a resume these lines, and the marked late-receipt `call` lines below, are added up, +and that sum is the session's totals.** So `/cost`, `/status` and the status line show what the whole conversation has spent across every restart, not only what has happened since you reopened it. The spend ceiling reads the same sum — see the spending pages for what that means the day after you reach one. @@ -354,11 +358,11 @@ provider's own **receipt** is fetched afterwards, the request writes its line th with `arm`: `hedge` for a rescue arm, `reconciled` for any other late receipt. A receipt names no endpoint, so the line leaves that field out rather than guess. -These lines are **evidence, never spending**. Nothing adds them up, `/cost` does not read +Ordinary call lines are **evidence, never spending**. Nothing adds them up, `/cost` does not read them, and a resume ignores them entirely, because every dollar on them is already in the `usage` line that sealed the turn. They are there so a question about *shape* — what does a mostly-cached request actually cost here — can be read out of the file instead of guessed -at. +at. A line with an `arm` is the exception: its money is in no seal, so a resume adds it once. ## Picking up where you left off diff --git a/internal/manual/chat/tasks.md b/internal/manual/chat/tasks.md index 2715304d23..59d9458b58 100644 --- a/internal/manual/chat/tasks.md +++ b/internal/manual/chat/tasks.md @@ -12,10 +12,11 @@ engine reports it. Check the running work before starting the same brief again. A task is one self-contained piece of work handed off to run on its own while the conversation carries on. It works in a folder of its own and reports back when it lands. Projects may be copied; a task that would copy your home folder reads it in place and -starts with an empty folder. It never inherits the conversation: what it reads is one written brief — your own message, word -for word, then the work, what to produce and what done means, and a pointer at the +starts with an empty folder. It never inherits the conversation: what it reads is one written brief — +the work, what to produce, what done means, your own message word for word, and a pointer at the journal path and line of your original turn so it can read those words in full when the -restatement was cut. How that is assembled is on the *how tasks run* page, under *What the +restatement was cut. A proposal on the run road puts its own work first; a task you typed +yourself keeps your message first. How that is assembled is on the *how tasks run* page, under *What the task actually reads* and *Can the task see the original request*. The one exception is a **quick task started with `inherit`**, which opens holding the conversation itself — see *Can it keep what it read* below. @@ -2321,8 +2322,19 @@ you set it — and under the day's limit above it. An adaptive run they start op $100.00 default. **An ordinary `/task`, or any task codeaf's own worker does, also has a money limit of its own: $5 unless you set another**, shown on the -Spending tab's `per task` row and set with `/crew cap task <$>`. A call that would spend -past it is not made, whichever way the ordinary task was started, and the task stops there. +Spending tab's `per task` row and set with `/crew cap task <$>`. On both task roads this is +one ceiling for the whole tree, including planning and checks. The smaller of that limit +and the conversation's remaining allowance wins, even when no crew was routed. +Continuing interrupted work keeps what the same tree already spent: only the unspent +part of its per-task limit remains available. +A routed crew refuses a call that would cross its per-task limit. Reaching the run's +ceiling ends workers in flight and starts no more. +On the node belt, the saved ceiling survives restarts; reaching it refuses each worker's next request, queued workers spend nothing, and in-flight calls finish without cancellation. +When the per-task limit binds, the +report says `this task reached its $5 limit · raise it in /crew` (with your configured +amount). Raise it with `/crew cap task <$>`, then rerun the remaining work with `/task`. +When the conversation's smaller limit binds, the report names that amount and `/settings`. +Continuing a run is for interrupted work; a settled dollar-limit ending needs a rerun. **senior-dev, sec and review have their own dollar and time ceilings for each run**, separate from that `per task` row; see each program's page for its defaults and how to change them. @@ -2854,8 +2866,9 @@ it had started. ## Does it know what my other windows are doing — will it notice work from another terminal -Yes, and it is told rather than having to go and ask. When another codeaf window on this -same directory lands a task, or has one running, codeaf puts a small block into the chat's +With the default `project` conversation scope, yes: it is told rather than having to +go and ask. When another codeaf window on this same directory lands a task, or has +one running, codeaf puts a small block into the chat's own context — never on your screen — that reads: ``` @@ -2892,10 +2905,30 @@ running in another window now: told. - A **task** is never given this block. A task's brief is its whole world. +## Conversation scope — tell this chat only about its own work + +A client can set a saved conversation's scope with `agents.set_scope {id, scope}`. +`project` is the default: the chat is told about other conversations' landed and +running work in the same folder. `conversation` means it is told only about its +own work. No `` block is added to its turns, and the model's `tasks` +tool lists only its own tasks. Another conversation's tasks cannot be read by +name or id, steered or resolved through that tool. The running-in-other-windows +section and the `scope: "everywhere"` section are absent too. + +The choice survives a restart and takes effect from the next turn; a tasks call +also reads the saved choice. Setting it does not open or interrupt the chat, +move its folder, or change its transcript, title, model or tasks. Set `project` +to restore the ordinary reading. This setting has no terminal command or UI +control. The person's `@` task list and task nodes keep their existing behavior. + +The checks that warn before this chat's own work writes over files other +conversations are writing still apply. + ## Asking the chat what else is running on this project right now -Ask it in words. It reads the other windows itself rather than guessing from the project's -record — which cannot answer, because an ordinary task writes no row there until it lands. +With `project` conversation scope, ask it in words. It reads the other windows itself +rather than guessing from the project's record — which cannot answer, because an +ordinary task writes no row there until it lands. What it gets back looks like this: ``` @@ -4277,6 +4310,22 @@ splits. `task.parallel` still applies to the whole session: pieces queue behind it exactly as top-level tasks do. +## Duplicate task titles on the node belt + +With `CODEAF_TASK_BELT=node`, `legacy` or `off`, a worker cannot add the same work +twice anywhere in its task tree. Letter case is ignored; runs of punctuation or +whitespace fold to one space: +`Read: the LAW!` and `read-the law` name the same work. Ancestors, siblings, cousins and +finished tasks all count; a cancelled task's title may be reused. A division naming +the same work twice is refused whole, before any part starts. Quick tasks use the same +check. Separate conversation tasks have separate trees; a person's typed task stays +exempt. + +The refusal says `no: this run already has a task named "Read: the LAW!" (7), so nothing +was added.` It tells the worker to do that work in its own hands, or name different +work for what is different. The shared tree money limit is described above under +*A task started from the composer carries a cap*. + ## How the bash belt run splits work instead of nesting node tasks With the switch unset, or `CODEAF_TASK_BELT=bash`, a task uses the run engine instead of the node tree. @@ -4687,6 +4736,18 @@ and what the task made is committed onto that branch before it lands — so the produced are listed under `changed:` and `git merge task/…` brings them over. The merge is never done for you, because only work that was checked reaches your branch. +## Stop a running task and its open run children + +Use `x` on the task's row or in its room over an empty box, then choose `stop it`. +Stopping a run ends every still-open task in its store, including workers mid-step and +open children whose root already finished. Earlier completed results keep their endings. +Task lists reflect the store's stop while the last workers return, so those parts no +longer read as running. + +Starting a new run also ends open children retained below an earlier completed root, +preserving that root's result. A later stop can clear lingering children in an older +retained store without changing the newer run. + ## Continue task N — keep going on a failed or finished task, No task 1 in this project When you say `continue task 7` or `keep going on task 7`, the model calls `tasks` with that diff --git a/internal/manual/chat/worker-harness.md b/internal/manual/chat/worker-harness.md index 103b082f12..557c80fa20 100644 --- a/internal/manual/chat/worker-harness.md +++ b/internal/manual/chat/worker-harness.md @@ -88,6 +88,14 @@ in its own words — and new work never runs inside it. A task handed off in the seconds while a run is finishing (its work landing, its summary being written) waits until that run is over and then starts its own: it never joins a run on its way out. +## Run subtasks in app task lists + +App task lists keep each run's internal tasks under their actual parent. A direct child +names the run's listed task id; deeper children name their parent's listed id. The +`parent` field lets a client build that tree instead of counting every internal part as +another top-level conversation task. When a recorded group is known, the parts carry the +same group as their run. + ## When does a task run's work come home, including commits its workers made When a `/task` run on the worker harness ends, any uncommitted work in its copy is committed. @@ -167,6 +175,9 @@ Each row wears one state word, mapped off the store's own status: - `your call` — the store says `paused`: the task is held at a gate, which is your call and nothing else's. +A ready task retained after its run's supervisor leaves reads `queued`; it stays +available for continuation. Ready rows of a live run keep their usual word. + Beside the word a row may carry the steps its worker recorded and the dollars its spend rows hold — each left out when it is nothing. @@ -505,11 +516,19 @@ has never heard of, one with no words on it, or one that has already landed draw ## Does a subtask see my original request -Yes — every worker that is not the run's root reads your sentence again, word for -word, in a section of its page headed *The ask this run serves*. The planner's work -order is only that worker's one part of it, and where the two disagree about that -part your words win — a worker that had to go against them says so in its report -rather than quietly choosing. +Workers below a person-typed root read your sentence again, word for word, under +*The ask this run serves*. Proposed assignments carry their own title, work, output +and done-condition instead of the conversation message. Where that assignment and +a descendant's work order disagree about its part, the assignment wins and the +worker says so in its report. + +A handoff joined into a live run is its own assignment. Its worker already has +that work order; its descendants and checks inherit the joined assignment's ask, +even when the run began with another proposal or a task you typed yourself. + +On waking, a worker reads only its own additions as children it dispatched. Other +work, including joined handoffs, reviews and fixes, is listed separately with an +instruction not to redo it. ## Steering a task: notes, pause, cancel, amend, priority diff --git a/internal/plandb/cli.go b/internal/plandb/cli.go index 12f4de2384..fa44fd7170 100644 --- a/internal/plandb/cli.go +++ b/internal/plandb/cli.go @@ -234,6 +234,11 @@ func cliRefusal(p *cliParsed) (string, bool) { // says where a run's store was, and only the root says which run it is. const RunEnv = "PLANDB_RUN" +// TaskEnv names the store task whose run worker is creating work. +const TaskEnv = "PLANDB_TASK" + +func cliRunBound() bool { return os.Getenv(RunEnv) != "" } + // cliStore opens the run's store without being told where it is: --db, then // PLANDB_DB, then the first ancestor holding plandb.db or // .codeaf/plandb.db. One store per file; --project is accepted and checked @@ -417,9 +422,8 @@ func cliDispatch(st *Store, p *cliParsed) error { } } -// cliAdd creates one task. Absent --parent the task lands under the run's -// root — the caller's running task is the runtime's business, and the -// doctrine teaches --parent explicitly for decomposition. +// cliAdd defaults to the worker's own task when bound to one; other callers +// keep the run root as their default parent. func cliAdd(st *Store, p *cliParsed) error { if len(p.pos) < 2 { return errors.New(`add needs a title — plandb add "title" --description "the work order"`) @@ -446,6 +450,8 @@ func cliAdd(st *Store, p *cliParsed) error { return err } spec.ParentID = up.ID + } else if cliRunBound() { + spec.ParentID = cliWorkerTask() } for _, dep := range p.lists["dep"] { word, kind, err := cliDepArg(dep) @@ -471,7 +477,7 @@ func cliAdd(st *Store, p *cliParsed) error { if err := cliPlaceholderChecks(st, spec.Checks, spec.Title, spec.Description, "Nothing was added."); err != nil { return err } - created, err := st.AddMany([]TaskSpec{spec}) + created, err := cliAddMany(st, []TaskSpec{spec}) if err != nil { return err } @@ -521,7 +527,7 @@ func cliSplit(st *Store, p *cliParsed) error { if err != nil { return err } - created, err := st.AddMany(specs) + created, err := cliAddMany(st, specs) if err != nil { return err } @@ -952,6 +958,9 @@ func cliInsert(st *Store, p *cliParsed) error { if err != nil { return err } + if err := cliWorkerParent(up.ParentID); err != nil { + return err + } var before *Task if word := p.vals["before"]; word != "" { down, err := cliResolve(st, word) @@ -960,7 +969,7 @@ func cliInsert(st *Store, p *cliParsed) error { } before = down } - created, err := st.AddMany([]TaskSpec{{ + created, err := cliAddMany(st, []TaskSpec{{ ID: st.NextID(), Title: p.vals["title"], Description: p.vals["description"], ParentID: up.ParentID, }}) @@ -1013,6 +1022,9 @@ func cliPivot(st *Store, p *cliParsed) error { if err != nil { return err } + if err := cliWorkerParent(parent.ID); err != nil { + return err + } if parent.ID == st.RootID() { return errors.New("the root task is the run itself — pivot one of its children instead") } @@ -1032,7 +1044,7 @@ func cliPivot(st *Store, p *cliParsed) error { // The whole subtree, read before the new children go in, so what // --keep-done spared can be told apart from the replacements and reported. standing := cliSubtreeAll(st, parent.ID) - created, err := st.AddMany(specs) + created, err := cliAddMany(st, specs) if err != nil { return err } diff --git a/internal/plandb/model.go b/internal/plandb/model.go index 5d9709876c..96654348a8 100644 --- a/internal/plandb/model.go +++ b/internal/plandb/model.go @@ -128,6 +128,15 @@ type Task struct { TaskSpec Status Status `json:"status"` Composite bool `json:"composite"` + // ReviewCheck is set only by AddReviewCheck, so choosing the check role + // through a worker's CLI cannot exempt its task from the growth count. + ReviewCheck bool `json:"review_check,omitempty"` + // AddedByTask names the adding worker's task. Only bounded worker writes + // set it; engine handoffs, reviews and fixes do not spend worker slots. + AddedByTask string `json:"added_by_task,omitempty"` + // JoinedAssignment is set only by the engine's join door. Its subtree + // serves this assignment rather than the run root's independent work. + JoinedAssignment bool `json:"joined_assignment,omitempty"` // Paused is the status-independent hold on a task and everything under it. // A paused task keeps the status it had — ready stays ready — and leaves // the ready frontier whole while the flag is set; Resume clears it. It is diff --git a/internal/plandb/persist.go b/internal/plandb/persist.go index a160af1d6f..9f3de0aa6a 100644 --- a/internal/plandb/persist.go +++ b/internal/plandb/persist.go @@ -32,6 +32,8 @@ type state struct { Contexts []ContextEntry NextID uint64 Deleted map[string]bool + // Stopped records a person's stop even if the root had already finished. + Stopped bool } // sqliteBusyCode is SQLITE_BUSY, the refusal SQLite gives a writer while @@ -77,7 +79,8 @@ var schemaStatements = []string{ project TEXT NOT NULL, root_id TEXT NOT NULL, next_id INTEGER NOT NULL, - version INTEGER NOT NULL + version INTEGER NOT NULL, + stopped INTEGER NOT NULL DEFAULT 0 )`, `CREATE TABLE IF NOT EXISTS tasks ( id TEXT PRIMARY KEY, @@ -117,7 +120,10 @@ var schemaStatements = []string{ seen_at TEXT NOT NULL DEFAULT '', waiting INTEGER NOT NULL DEFAULT 0, waited_at TEXT NOT NULL DEFAULT '', - verdict_basis TEXT NOT NULL DEFAULT 'null' + verdict_basis TEXT NOT NULL DEFAULT 'null', + review_check INTEGER NOT NULL DEFAULT 0, + added_by_task TEXT NOT NULL DEFAULT '', + joined_assignment INTEGER NOT NULL DEFAULT 0 )`, `CREATE TABLE IF NOT EXISTS deps ( downstream TEXT NOT NULL, @@ -169,6 +175,9 @@ var schemaStatements = []string{ waiting INTEGER NOT NULL DEFAULT 0, waited_at TEXT NOT NULL DEFAULT '', verdict_basis TEXT NOT NULL DEFAULT 'null', + review_check INTEGER NOT NULL DEFAULT 0, + added_by_task TEXT NOT NULL DEFAULT '', + joined_assignment INTEGER NOT NULL DEFAULT 0, archived_at TEXT NOT NULL )`, `CREATE TABLE IF NOT EXISTS notes ( @@ -349,6 +358,9 @@ func schemaTransaction(db *sql.DB) error { // schema transaction, so an old store's columns are added atomically with the // tables and a reader never sees one column present and its sibling absent. func migrateColumns(tx *sql.Tx) error { + if err := ensureColumn(tx, "meta", "stopped", "INTEGER", "0"); err != nil { + return err + } for _, table := range []string{"tasks", "notes", "contexts"} { for _, column := range []string{"project", "chat"} { if err := ensureColumn(tx, table, column, "TEXT", "''"); err != nil { @@ -398,6 +410,15 @@ func migrateColumns(tx *sql.Tx) error { if err := ensureColumn(tx, table, "verdict_basis", "TEXT", "'null'"); err != nil { return err } + if err := ensureColumn(tx, table, "review_check", "INTEGER", "0"); err != nil { + return err + } + if err := ensureColumn(tx, table, "added_by_task", "TEXT", "''"); err != nil { + return err + } + if err := ensureColumn(tx, table, "joined_assignment", "INTEGER", "0"); err != nil { + 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 @@ -505,8 +526,8 @@ func enterWAL(db *sql.DB) error { // — so a database that has been tampered with is refused rather than trusted. func loadState(tx *sql.Tx) (state, error) { var value state - row := tx.QueryRow(`SELECT project, root_id, next_id, version FROM meta WHERE id = 1`) - if err := row.Scan(&value.Project, &value.RootID, &value.NextID, &value.Version); err != nil { + row := tx.QueryRow(`SELECT project, root_id, next_id, version, stopped FROM meta WHERE id = 1`) + if err := row.Scan(&value.Project, &value.RootID, &value.NextID, &value.Version, &value.Stopped); err != nil { if errors.Is(err, sql.ErrNoRows) { return state{}, errNoStore } @@ -563,7 +584,7 @@ const taskColumns = `id, title, description, question, kind, parent_id, priority parallel, isolation, role, agent, acceptance, checks, capabilities, resources, context_inputs, deliverables, evidence_requirements, status, composite, claimed_by, result, err, artifacts, evidence, created_at, updated_at, completed_at, project, chat, paused, owner, seen_at, - waiting, waited_at, verdict_basis, model` + waiting, waited_at, verdict_basis, model, review_check, added_by_task, joined_assignment` // rowQuerier is the read half both the database handle and a transaction // carry, so the archive reader can share the task scan with the loader @@ -584,7 +605,7 @@ func scanTask(row *sql.Rows, extra ...any) (*Task, error) { createdAt, updatedAt, completedAt string effect, parallel, isolation string seenAt, waitedAt string - waiting int + waiting, reviewCheck, joinedAssignment int basis string ) dest := []any{ @@ -593,7 +614,7 @@ func scanTask(row *sql.Rows, extra ...any) (*Task, error) { &checks, &capabilities, &resources, &contextInputs, &deliverables, &evidenceRequirements, &task.Status, &composite, &task.ClaimedBy, &task.Result, &task.Error, &artifacts, &evidence, &createdAt, &updatedAt, &completedAt, &task.Project, &task.Chat, &paused, - &task.Owner, &seenAt, &waiting, &waitedAt, &basis, &task.Model, + &task.Owner, &seenAt, &waiting, &waitedAt, &basis, &task.Model, &reviewCheck, &task.AddedByTask, &joinedAssignment, } dest = append(dest, extra...) if err := row.Scan(dest...); err != nil { @@ -604,6 +625,8 @@ func scanTask(row *sql.Rows, extra ...any) (*Task, error) { task.Composite = composite != 0 task.Paused = paused != 0 task.Waiting = waiting != 0 + task.ReviewCheck = reviewCheck != 0 + task.JoinedAssignment = joinedAssignment != 0 if err := decodeJSON(checks, &task.Checks); err != nil { return nil, err } @@ -700,7 +723,7 @@ func insertArchived(tx *sql.Tx, tasks []*Task, now time.Time) error { id, ord, title, description, question, kind, parent_id, priority, effect, parallel, isolation, role, agent, acceptance, checks, capabilities, resources, context_inputs, deliverables, evidence_requirements, status, composite, claimed_by, result, err, artifacts, evidence, - created_at, updated_at, completed_at, project, chat, paused, owner, seen_at, waiting, waited_at, archived_at, model) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + created_at, updated_at, completed_at, project, chat, paused, owner, seen_at, waiting, waited_at, archived_at, model, review_check, added_by_task, joined_assignment) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) if err != nil { return err } @@ -744,7 +767,7 @@ func insertArchived(tx *sql.Tx, tasks []*Task, now time.Time) error { evidenceRequirements, string(task.Status), boolInt(task.Composite), task.ClaimedBy, task.Result, task.Error, artifacts, evidence, formatTime(task.CreatedAt), formatTime(task.UpdatedAt), formatTime(task.CompletedAt), task.Project, task.Chat, - boolInt(task.Paused), task.Owner, formatTime(task.SeenAt), boolInt(task.Waiting), formatTime(task.WaitedAt), formatTime(now), task.Model); err != nil { + boolInt(task.Paused), task.Owner, formatTime(task.SeenAt), boolInt(task.Waiting), formatTime(task.WaitedAt), formatTime(now), task.Model, boolInt(task.ReviewCheck), task.AddedByTask, boolInt(task.JoinedAssignment)); err != nil { return err } } @@ -847,8 +870,8 @@ func saveState(tx *sql.Tx, value state) error { return err } } - if _, err := tx.Exec(`INSERT INTO meta (id, project, root_id, next_id, version) VALUES (1, ?, ?, ?, ?)`, - value.Project, value.RootID, value.NextID, value.Version); err != nil { + if _, err := tx.Exec(`INSERT INTO meta (id, project, root_id, next_id, version, stopped) VALUES (1, ?, ?, ?, ?, ?)`, + value.Project, value.RootID, value.NextID, value.Version, boolInt(value.Stopped)); err != nil { return err } if err := saveTasks(tx, value); err != nil { @@ -869,7 +892,7 @@ func saveTasks(tx *sql.Tx, value state) error { role, agent, acceptance, checks, capabilities, resources, context_inputs, deliverables, evidence_requirements, status, composite, claimed_by, result, err, artifacts, evidence, created_at, updated_at, completed_at, project, chat, paused, owner, seen_at, waiting, waited_at, - verdict_basis, model) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) + verdict_basis, model, review_check, added_by_task, joined_assignment) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`) if err != nil { return err } @@ -918,7 +941,7 @@ func saveTasks(tx *sql.Tx, value state) error { evidenceRequirements, string(task.Status), boolInt(task.Composite), task.ClaimedBy, task.Result, task.Error, artifacts, evidence, formatTime(task.CreatedAt), formatTime(task.UpdatedAt), formatTime(task.CompletedAt), task.Project, task.Chat, boolInt(task.Paused), - task.Owner, formatTime(task.SeenAt), boolInt(task.Waiting), formatTime(task.WaitedAt), basis, task.Model); err != nil { + task.Owner, formatTime(task.SeenAt), boolInt(task.Waiting), formatTime(task.WaitedAt), basis, task.Model, boolInt(task.ReviewCheck), task.AddedByTask, boolInt(task.JoinedAssignment)); err != nil { return err } } diff --git a/internal/plandb/store.go b/internal/plandb/store.go index 69f47821c2..c648ca7ba3 100644 --- a/internal/plandb/store.go +++ b/internal/plandb/store.go @@ -236,6 +236,15 @@ func (s *Store) RootID() string { return s.data.RootID } +// Stopped reports the run's recorded stop, including when its root had already +// finished. Ordinary completion does not carry this fact. +func (s *Store) Stopped() bool { + s.mu.Lock() + defer s.mu.Unlock() + s.refresh() + return s.data.Stopped +} + // AddMany admits a batch of specs and answers the tasks as they now stand. // The batch is validated whole before any of it is written, so a split with a // bad third part creates nothing — which is the property the CLI's split @@ -245,6 +254,16 @@ func (s *Store) RootID() string { // CLI mints short random ones — `t-` + six base-36 characters — and honours // `--as` names. Both roads end here. func (s *Store) AddMany(specs []TaskSpec) ([]*Task, error) { + return s.addMany(specs, false, "", false) +} + +// AddJoinedMany admits independent assignments through the engine's join door. +// Silent joins keep the ordinary tree bounds; person-approved joins may exceed them. +func (s *Store) AddJoinedMany(specs []TaskSpec, bounded bool) ([]*Task, error) { + return s.addMany(specs, bounded, "", true) +} + +func (s *Store) addMany(specs []TaskSpec, bounded bool, workerTask string, joined bool) ([]*Task, error) { if len(specs) == 0 { return nil, errors.New("tasks must not be empty") } @@ -277,6 +296,11 @@ func (s *Store) AddMany(specs []TaskSpec) ([]*Task, error) { } batch[specs[i].ID] = true } + if bounded { + if err := boundedGrowthRefusal(*next, s.rdb, specs, workerTask); err != nil { + return err + } + } for _, spec := range specs { if next.Tasks[spec.ParentID] == nil && !batch[spec.ParentID] { return fmt.Errorf("task %q has unknown parent %q", spec.ID, spec.ParentID) @@ -294,7 +318,8 @@ func (s *Store) AddMany(specs []TaskSpec) ([]*Task, error) { } } for _, spec := range specs { - task := &Task{TaskSpec: spec, Status: StatusPending, CreatedAt: now, UpdatedAt: now} + task := &Task{TaskSpec: spec, Status: StatusPending, CreatedAt: now, UpdatedAt: now, AddedByTask: workerTask} + task.JoinedAssignment = joined next.Tasks[spec.ID] = task next.Order = append(next.Order, spec.ID) } @@ -568,6 +593,9 @@ func (s *Store) AddReviewCheck(spec TaskSpec) (*Task, error) { defer s.mu.Unlock() var id string err := s.transact(func(next *state, now time.Time) error { + if next.Stopped { + return errors.New("this run was stopped, so no review was added") + } spec = normalizeSpec(spec, next.RootID) id = spec.ID if err := validateSpec(spec); err != nil { @@ -586,7 +614,7 @@ func (s *Store) AddReviewCheck(spec TaskSpec) (*Task, error) { if parent == nil { return fmt.Errorf("parent %q does not exist", spec.ParentID) } - check := &Task{TaskSpec: spec, Status: StatusPending, CreatedAt: now, UpdatedAt: now} + check := &Task{TaskSpec: spec, Status: StatusPending, ReviewCheck: true, CreatedAt: now, UpdatedAt: now} next.Tasks[spec.ID] = check next.Order = append(next.Order, spec.ID) for ancestor := parent; ancestor != nil; ancestor = next.Tasks[ancestor.ParentID] { @@ -1712,16 +1740,21 @@ func (s *Store) EndRoot(reason string) error { // closeRoot is the one write [Store.StopRoot] and [Store.EndRoot] share: the // run's own task takes the ending named, every open task is cancelled under the -// same reason, and nothing that had already ended is touched. +// same reason, even beneath an already-ended root. Earlier task endings stand; +// a person's stop is also kept in metadata so late reviews cannot reopen them. func (s *Store) closeRoot(rootStatus Status, reason string) error { s.mu.Lock() defer s.mu.Unlock() return s.transact(func(next *state, now time.Time) error { root := next.Tasks[next.RootID] - if root == nil || terminal(root.Status) { + if root == nil { return errNoChange } reason = strings.TrimSpace(reason) + changed := false + if rootStatus == StatusCancelled && !next.Stopped { + next.Stopped, changed = true, true + } for _, task := range next.Tasks { if terminal(task.Status) { continue @@ -1729,8 +1762,15 @@ func (s *Store) closeRoot(rootStatus Status, reason string) error { task.Status, task.Error, task.ClaimedBy = StatusCancelled, reason, "" task.Owner, task.SeenAt = "", time.Time{} task.UpdatedAt, task.CompletedAt = now, now + changed = true + task.Paused, task.Waiting, task.WaitedAt = false, false, time.Time{} + if task.ID == root.ID { + task.Status = rootStatus + } + } + if !changed { + return errNoChange } - root.Status = rootStatus promote(next, now) return nil }) diff --git a/internal/plandb/testmain_test.go b/internal/plandb/testmain_test.go index 829e14579c..1053e6fd14 100644 --- a/internal/plandb/testmain_test.go +++ b/internal/plandb/testmain_test.go @@ -11,5 +11,6 @@ import ( func TestMain(m *testing.M) { _ = os.Unsetenv("PLANDB_DB") _ = os.Unsetenv("PLANDB_RUN") + _ = os.Unsetenv("PLANDB_TASK") os.Exit(m.Run()) } diff --git a/internal/plandb/treebound.go b/internal/plandb/treebound.go new file mode 100644 index 0000000000..39f83c36c0 --- /dev/null +++ b/internal/plandb/treebound.go @@ -0,0 +1,196 @@ +package plandb + +import ( + "errors" + "fmt" + "maps" + "os" + "strings" + "unicode" +) + +// TreeDepthLimit and TreeTaskLimit mirror session's taskDepthLimit (three +// levels counting the root) and taskFanLimit. These stop runaway growth. +const ( + TreeDepthLimit = 2 + TreeTaskLimit = 20 + // TreeWorkerLimit restores the original per-node runaway bound for run workers. + TreeWorkerLimit = 5 +) + +func normalizedTitle(title string) string { + return strings.Join(strings.FieldsFunc(strings.ToLower(title), func(r rune) bool { + return !unicode.IsLetter(r) && !unicode.IsDigit(r) + }), " ") +} + +// NormalizedTitle is the work identity shared by both task admission roads. +func NormalizedTitle(title string) string { return normalizedTitle(title) } + +// GrowthRefusal answers why worker-created tasks cannot join this run, or +// nothing when they fit. Internal review and person-approved writes bypass it. +func (s *Store) GrowthRefusal(parentID string, titles []string) string { + s.mu.Lock() + defer s.mu.Unlock() + if s.closed { + return ErrClosed.Error() + } + tx, err := s.rdb.Begin() + if err != nil { + return err.Error() + } + defer tx.Rollback() + plan, err := loadState(tx) + if err != nil { + return err.Error() + } + plan, err = growthPlan(plan, tx) + if err != nil { + return err.Error() + } + return growthRefusal(plan, parentID, titles) +} + +// Archiving finished work does not give a worker another slot or title. +func growthPlan(plan state, q rowQuerier) (state, error) { + archived, err := loadArchived(q) + if err != nil { + return plan, err + } + if len(archived) == 0 { + return plan, nil + } + plan.Tasks = maps.Clone(plan.Tasks) + plan.Order = append([]string(nil), plan.Order...) + for _, task := range archived { + plan.Tasks[task.ID] = task + plan.Order = append(plan.Order, task.ID) + } + return plan, nil +} + +func growthRefusal(plan state, parentID string, titles []string) string { + if parentID == "" { + parentID = plan.RootID + } + named := make(map[string]*Task, len(plan.Tasks)) + count := 0 + for _, id := range plan.Order { + task := plan.Tasks[id] + if task == nil || task.Status == StatusCancelled { + continue + } + named[normalizedTitle(task.Title)] = task + if id != plan.RootID && (task.Role != RoleCheck || !task.ReviewCheck) { + count++ + } + } + batch := make(map[string]bool, len(titles)) + for _, title := range titles { + key := normalizedTitle(title) + if task := named[key]; task != nil { + return fmt.Sprintf("no: this run already has a task named %q (%s), so nothing was added. If that work is yours, do it in your own hands; if it is different work, name it for what is different.", task.Title, cliID(task.ID)) + } + if batch[key] { + return fmt.Sprintf("no: this batch names the same work %q more than once, so nothing was added. Do that work in your own hands, or name each different part for what is different.", title) + } + batch[key] = true + } + depth := 0 + for id := parentID; id != plan.RootID; depth++ { + parent := plan.Tasks[id] + if parent == nil || depth > len(plan.Tasks) { + return fmt.Sprintf("no: %s is not a task under this run's root, so nothing was added.", cliID(parentID)) + } + id = parent.ParentID + } + if depth >= TreeDepthLimit && len(titles) > 0 { + return fmt.Sprintf("no: %s is already %d levels below this run's root task, which is as deep as tasks go here, so nothing was added. Do this work in your own hands.", cliID(parentID), TreeDepthLimit) + } + if count+len(titles) > TreeTaskLimit { + return fmt.Sprintf("no: this run already holds %d tasks and this would exceed %d tasks, which is as many as one run may hand out, so nothing was added. Do the rest in your own hands, or finish and report what is left undone.", count, TreeTaskLimit) + } + return "" +} + +// AddManyBounded applies GrowthRefusal inside the same write transaction as +// AddMany. Separate workers cannot race a read of the last available slot. +func (s *Store) AddManyBounded(specs []TaskSpec) ([]*Task, error) { + return s.addMany(specs, true, "", false) +} + +// AddManyForWorker enforces ownership and the worker's allowance in the same +// transaction as the tree bounds and the new rows, including their author. +func (s *Store) AddManyForWorker(workerTask string, specs []TaskSpec) ([]*Task, error) { + return s.addMany(specs, true, workerTask, false) +} + +func boundedGrowthRefusal(plan state, q rowQuerier, specs []TaskSpec, workerTask string) error { + plan, err := growthPlan(plan, q) + if err != nil { + return err + } + if workerTask != "" { + if err := workerGrowthRefusal(plan, specs, workerTask); err != nil { + return err + } + } + titles := make([]string, len(specs)) + for i, spec := range specs { + titles[i] = spec.Title + } + // CLI batches share one parent. Check each parent as well so this atomic + // door cannot admit an over-deep task through a mixed-parent batch. + for _, spec := range specs { + if refusal := growthRefusal(plan, spec.ParentID, titles); refusal != "" { + return errors.New(refusal) + } + } + return nil +} + +func cliAddMany(st *Store, specs []TaskSpec) ([]*Task, error) { + if cliRunBound() { + if own := cliWorkerTask(); own != "" { + return st.AddManyForWorker(own, specs) + } + return st.AddManyBounded(specs) + } + return st.AddMany(specs) +} + +func cliWorkerTask() string { + if !cliRunBound() { + return "" + } + return strings.TrimPrefix(os.Getenv(TaskEnv), "t-") +} + +func workerParentRefusal(own string) error { + return fmt.Errorf("no: a worker adds tasks only under its own task (%s), so nothing was added. Add it with --parent %s, or do it in your own hands.", cliID(own), cliID(own)) +} + +func cliWorkerParent(parent string) error { + if own := cliWorkerTask(); own != "" && own != parent { + return workerParentRefusal(own) + } + return nil +} + +func workerGrowthRefusal(plan state, specs []TaskSpec, own string) error { + for _, spec := range specs { + if spec.ParentID != own { + return workerParentRefusal(own) + } + } + count := 0 + for _, task := range plan.Tasks { + if task.AddedByTask == own && task.Status != StatusCancelled { + count++ + } + } + if count+len(specs) > TreeWorkerLimit { + return fmt.Errorf("no: this task has already added %d tasks and this would exceed %d tasks, which is as many as one task may hand out, so nothing was added. Do the rest in your own hands, or finish and report what is left undone.", count, TreeWorkerLimit) + } + return nil +} diff --git a/internal/plandb/treebound_test.go b/internal/plandb/treebound_test.go new file mode 100644 index 0000000000..58390407bc --- /dev/null +++ b/internal/plandb/treebound_test.go @@ -0,0 +1,205 @@ +package plandb + +import ( + "fmt" + "reflect" + "strings" + "sync" + "testing" + "time" +) + +func treeBoundStore(t *testing.T) (*cliHarness, *Store) { + t.Helper() + t.Setenv(RunEnv, "") + t.Setenv(TaskEnv, "") + h := cliNewHarness(t) + h.cliInitFresh() + s, err := Open(h.db, "", "", "", "") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = s.Close() }) + return h, s +} + +func treeBoundAdd(t *testing.T, s *Store, specs ...TaskSpec) { + t.Helper() + if _, err := s.AddMany(specs); err != nil { + t.Fatal(err) + } +} + +// L-LOOP C2a, C2b, C2c, C2d: every growth verb refuses the whole write. +func TestRunWorkerRefusesDuplicateTitles(t *testing.T) { + for _, tc := range []struct { + name string + args []string + }{ + {"own", []string{"add", "Create green.txt", "--parent", "green"}}, + {"ancestor", []string{"add", "DEMO!", "--parent", "green"}}, + {"sibling", []string{"add", "CREATE---GREEN TXT", "--parent", "red"}}, + {"batch", []string{"split", "red", "--into", "New work, NEW-work"}}, + {"insert", []string{"task", "insert", "--after", "red", "--title", "Create green.txt"}}, + {"pivot", []string{"task", "pivot", "red", "--subtasks", "Fresh, Create green.txt"}}, + } { + t.Run(tc.name, func(t *testing.T) { + h, s := treeBoundStore(t) + treeBoundAdd(t, s, TaskSpec{ID: "green", Title: "Create green.txt"}, TaskSpec{ID: "red", Title: "Create red.txt"}) + t.Setenv(RunEnv, s.RootID()) + before := s.Tasks() + if code := h.run(append([]string{"--db", h.db}, tc.args...)...); code == 0 { + t.Fatal("duplicate was accepted") + } + if !strings.Contains(h.errb.String(), "nothing was added") || !strings.Contains(h.errb.String(), "own hands") { + t.Fatalf("refusal gives no next action: %s", h.errb) + } + if !reflect.DeepEqual(before, s.Tasks()) { + t.Fatal("refused write changed the plan") + } + }) + } +} + +// L-LOOP C2e: a person's unbound CLI keeps its existing duplicate-title use. +func TestUnboundPlanCLIStillAllowsDuplicateTitles(t *testing.T) { + h, s := treeBoundStore(t) + h.cliAdd("Same work", "one") + h.cliAdd("SAME-work", "two") + if len(s.Tasks()) != 3 { + t.Fatal("unbound CLI lost a task") + } +} + +// L-LOOP C3a, C3b, C3d: a batch cannot partly cross either bound. +func TestRunWorkerTreeBoundsRefuseWholeBatches(t *testing.T) { + for _, tc := range []struct { + name string + count int + args []string + want string + }{ + {"count", TreeTaskLimit, []string{"add", "One too many"}, "20 tasks"}, + {"batch", TreeTaskLimit - 1, []string{"split", "root", "--into", "Extra one, Extra two"}, "20 tasks"}, + {"depth", 0, []string{"add", "Too deep", "--parent", "deep"}, "2 levels below"}, + } { + t.Run(tc.name, func(t *testing.T) { + h, s := treeBoundStore(t) + for i := 0; i < tc.count; i++ { + treeBoundAdd(t, s, TaskSpec{ID: fmt.Sprintf("part%d", i), Title: fmt.Sprintf("Part %d", i)}) + } + if tc.name == "depth" { + treeBoundAdd(t, s, TaskSpec{ID: "middle", Title: "Middle"}, TaskSpec{ID: "deep", ParentID: "middle", Title: "Deep"}) + } + t.Setenv(RunEnv, s.RootID()) + before := s.Tasks() + if code := h.run(append([]string{"--db", h.db}, tc.args...)...); code == 0 || !strings.Contains(h.errb.String(), tc.want) { + t.Fatalf("bound was not explained: exit %d, %s", code, h.errb) + } + if !reflect.DeepEqual(before, s.Tasks()) { + t.Fatal("refused batch changed the plan") + } + }) + } +} + +// L-LOOP C3c and C2: root/review/cancelled rows do not consume work slots; +// cancelled titles can be reused, but the root's title still cannot. +func TestTreeGrowthExclusions(t *testing.T) { + h, s := treeBoundStore(t) + treeBoundAdd(t, s, TaskSpec{ID: "cancelled", Title: "Reusable"}) + if _, err := s.AddReviewCheck(TaskSpec{ID: "check", Title: "Review", Role: RoleCheck}); err != nil { + t.Fatal(err) + } + if _, err := s.Cancel("cancelled", "no longer needed"); err != nil { + t.Fatal(err) + } + for i := 0; i < TreeTaskLimit-1; i++ { + treeBoundAdd(t, s, TaskSpec{ID: fmt.Sprintf("part%d", i), Title: fmt.Sprintf("Part %d", i)}) + } + t.Setenv(RunEnv, s.RootID()) + h.cliAdd("Reusable", "replacement") + if refusal := s.GrowthRefusal(s.RootID(), []string{"demo"}); !strings.Contains(refusal, "already has a task named") { + t.Fatalf("root title was excluded: %s", refusal) + } +} + +// L-LOOP C3a, C3c: choosing the check seat cannot evade a worker's count. +func TestRunWorkerCreatedChecksConsumeTreeSlots(t *testing.T) { + h, s := treeBoundStore(t) + t.Setenv(RunEnv, s.RootID()) + for i := 0; i < TreeTaskLimit; i++ { + h.cliAdd(fmt.Sprintf("Inspect part %d", i), fmt.Sprintf("check%d", i), "--role", "check", "--parent", s.RootID()) + } + before := s.Tasks() + if code := h.run("--db", h.db, "add", "Inspect one more part", "--role", "check", "--parent", s.RootID()); code == 0 || !strings.Contains(h.errb.String(), "20 tasks") { + t.Fatalf("worker-created checks escaped the count: exit %d, %s", code, h.errb) + } + if !reflect.DeepEqual(before, s.Tasks()) { + t.Fatal("refused check changed the plan") + } +} + +// L-LOOP C2, C3: concurrent handles enforce growth against the write snapshot. +func TestBoundedWritesCannotRacePastTheTreeLimit(t *testing.T) { + _, s := treeBoundStore(t) + other, err := Open(s.path, "", "", "", "") + if err != nil { + t.Fatal(err) + } + defer other.Close() + for i := 0; i < TreeTaskLimit-1; i++ { + treeBoundAdd(t, s, TaskSpec{ID: fmt.Sprintf("part%d", i), Title: fmt.Sprintf("Part %d", i)}) + } + var wg sync.WaitGroup + errs := make(chan error, 2) + for i, handle := range []*Store{s, other} { + wg.Add(1) + go func(i int, handle *Store) { + defer wg.Done() + _, err := handle.AddManyBounded([]TaskSpec{{ID: fmt.Sprintf("extra%d", i), Title: fmt.Sprintf("Extra %d", i)}}) + errs <- err + }(i, handle) + } + wg.Wait() + close(errs) + accepted := 0 + for err := range errs { + if err == nil { + accepted++ + } + } + if accepted != 1 || len(s.Tasks()) != TreeTaskLimit+1 { + t.Fatalf("accepted %d writes; plan holds %d rows", accepted, len(s.Tasks())) + } +} + +// L-LOOP C2, C3: archived work is still part of the same run's allowance. +func TestArchivingWorkDoesNotResetTreeGrowth(t *testing.T) { + h, s := treeBoundStore(t) + now := time.Now() + s.now = func() time.Time { return now.Add(-2 * time.Hour) } + for i := 0; i < TreeTaskLimit; i++ { + treeBoundAdd(t, s, TaskSpec{ID: fmt.Sprintf("part%d", i), Title: fmt.Sprintf("Part %d", i)}) + } + if _, err := s.Claim("part0", "part0", "test-owner"); err != nil { + t.Fatal(err) + } + if _, err := s.Done("part0", "part0", "finished", nil, nil); err != nil { + t.Fatal(err) + } + s.now = func() time.Time { return now } + if archived, err := s.Archive(time.Hour); err != nil || len(archived) != 1 { + t.Fatalf("archive: %v, %+v", err, archived) + } + t.Setenv(RunEnv, s.RootID()) + before := s.Tasks() + for _, title := range []string{"PART---0", "Different work"} { + if code := h.run("--db", h.db, "add", title); code == 0 || !strings.Contains(h.errb.String(), "nothing was added") { + t.Fatalf("archived work reset the allowance: exit %d, %s", code, h.errb) + } + } + if !reflect.DeepEqual(before, s.Tasks()) { + t.Fatal("refused write changed the plan") + } +} diff --git a/internal/plandb/worker_stop_test.go b/internal/plandb/worker_stop_test.go new file mode 100644 index 0000000000..900f282209 --- /dev/null +++ b/internal/plandb/worker_stop_test.go @@ -0,0 +1,61 @@ +package plandb + +import ( + "path/filepath" + "testing" + "time" +) + +// L-LOOP2 C8a: an already-ended root cannot shelter claimed or running work +// from a person's stop, and a late worker cannot overwrite that cancellation. +func TestStopRootCancelsOpenTasksEvenWhenRootAlreadyEnded(t *testing.T) { + store := planOpen(t, filepath.Join(t.TempDir(), "plan.db")) + planAdd(t, store, planSpec("claimed", "Claimed"), planSpec("running", "Running")) + for _, id := range []string{"claimed", "running"} { + if _, err := store.Claim(id, id, "test-owner"); err != nil { + t.Fatal(err) + } + } + // Retained stores can contain a terminal root with still-open children. + store.mu.Lock() + err := store.transact(func(next *state, now time.Time) error { + next.Tasks[next.RootID].Status = StatusDone + next.Tasks[next.RootID].Result = "root's earlier result" + next.Tasks[next.RootID].CompletedAt = now + next.Tasks["running"].Status = StatusRunning + next.Tasks["claimed"].Paused = true + return nil + }) + store.mu.Unlock() + if err != nil { + t.Fatal(err) + } + if err := store.StopRoot("stopped"); err != nil { + t.Fatal(err) + } + for _, id := range []string{"claimed", "running"} { + task := store.Task(id) + if task.Status != StatusCancelled || task.ClaimedBy != "" || task.Owner != "" || task.Paused || task.Waiting || task.CompletedAt.IsZero() { + t.Fatalf("stop left open work: %+v", task) + } + _, _ = store.Done(id, id, "late worker result", nil, nil) + if store.Task(id).Status != StatusCancelled { + t.Fatalf("worker return overwrote stop: %+v", store.Task(id)) + } + } + if root := store.Task(store.RootID()); root.Status != StatusDone || root.Result != "root's earlier result" { + t.Fatalf("stop rewrote completed root: %+v", root) + } + // Late reviews run through a fresh handle, just like a returning engine. + fresh, err := Open(store.Path(), "", "", "", "") + if err != nil { + t.Fatal(err) + } + defer fresh.Close() + if _, err := fresh.AddReviewCheck(TaskSpec{ID: "late-review", Title: "Late review", Role: RoleCheck}); err == nil { + t.Fatal("a late worker return reopened the stopped tree for a review") + } + if len(store.Tasks()) != 3 || store.Task(store.RootID()).Status != StatusDone { + t.Fatal("a refused late review changed the stopped store") + } +} diff --git a/internal/plandb/workerbound_test.go b/internal/plandb/workerbound_test.go new file mode 100644 index 0000000000..f5d0e77fd3 --- /dev/null +++ b/internal/plandb/workerbound_test.go @@ -0,0 +1,224 @@ +package plandb + +import ( + "fmt" + "reflect" + "strings" + "sync" + "testing" + "time" +) + +// L-LOOP2 C3a, C3b, C3c, C3f: each creation verb obeys the caller's own task. +func TestWorkerCreationStaysUnderItsOwnTask(t *testing.T) { + for _, tc := range []struct { + name string + args []string + ok bool + }{ + {"default", []string{"add", "New work", "--as", "new"}, true}, + {"explicit", []string{"add", "New work", "--as", "new", "--parent", "t-own"}, true}, + {"root", []string{"add", "New work", "--parent", "root"}, false}, + {"sibling", []string{"add", "New work", "--parent", "other"}, false}, + {"child", []string{"add", "New work", "--parent", "child"}, false}, + {"split", []string{"split", "other", "--into", "Part one, Part two"}, false}, + {"pivot", []string{"task", "pivot", "other", "--subtasks", "Part one, Part two"}, false}, + {"pivot root", []string{"task", "pivot", "root", "--subtasks", "Part one, Part two"}, false}, + {"insert sibling", []string{"task", "insert", "--after", "other", "--title", "New work"}, false}, + {"insert child", []string{"task", "insert", "--after", "child", "--title", "New work"}, true}, + } { + t.Run(tc.name, func(t *testing.T) { + h, s := treeBoundStore(t) + treeBoundAdd(t, s, TaskSpec{ID: "own", Title: "Own work"}, TaskSpec{ID: "other", Title: "Other work"}, TaskSpec{ID: "child", ParentID: "own", Title: "Existing child"}) + t.Setenv(RunEnv, s.RootID()) + t.Setenv(TaskEnv, "own") + before := s.Tasks() + code := h.run(append([]string{"--db", h.db}, tc.args...)...) + if tc.ok { + if code != 0 || len(s.Tasks()) != len(before)+1 { + t.Fatalf("own work refused: %s", h.errb) + } + for _, task := range s.Tasks() { + if task.Title == "New work" && (task.ParentID != "own" || task.AddedByTask != "own") { + t.Fatalf("wrong parent or author: %+v", task) + } + } + } else if code == 0 || !strings.Contains(h.errb.String(), "a worker adds tasks only under its own task (t-own)") || !reflect.DeepEqual(before, s.Tasks()) { + t.Fatalf("out-of-scope creation changed the store or lacked its explanation: %d %s", code, h.errb) + } + }) + } + t.Run("unbound", func(t *testing.T) { + h, s := treeBoundStore(t) + t.Setenv(TaskEnv, "ignored-without-run") + h.cliAdd("Person's work", "person") + if task := s.Task("person"); task.ParentID != s.RootID() || task.AddedByTask != "" { + t.Fatalf("unbound creation changed: %+v", task) + } + }) +} + +// L-LOOP2 C3d: only stored worker additions consume that worker's five slots. +func TestWorkerAllowanceCountsOnlyItsOwnAdditions(t *testing.T) { + h, s := treeBoundStore(t) + treeBoundAdd(t, s, TaskSpec{ID: "own", Title: "Own work"}) + treeBoundAdd(t, s, TaskSpec{ID: "joined", ParentID: "own", Title: "Joined work"}, TaskSpec{ID: "fix", ParentID: "own", Title: "Fix work"}) + if _, err := s.AddReviewCheck(TaskSpec{ID: "review", ParentID: "own", Title: "Review work", Role: RoleCheck}); err != nil { + t.Fatal(err) + } + t.Setenv(RunEnv, s.RootID()) + t.Setenv(TaskEnv, "own") + for i := 0; i < 5; i++ { + h.cliAdd(fmt.Sprintf("Part %d", i), fmt.Sprintf("part%d", i), "--role", "check") + } + before := s.Tasks() + if code := h.run("--db", h.db, "add", "Sixth part"); code == 0 || !strings.Contains(h.errb.String(), "already added 5 tasks") || !reflect.DeepEqual(before, s.Tasks()) { + t.Fatalf("sixth addition escaped the allowance: %d %s", code, h.errb) + } + if _, err := s.Cancel("part0", "no longer needed"); err != nil { + t.Fatal(err) + } + h.cliAdd("Replacement part", "replacement") + if task := s.Task("replacement"); task.AddedByTask != "own" { + t.Fatalf("worker marker did not survive a fresh CLI handle: %+v", task) + } +} + +// L-LOOP2 C3d: a batch cannot partly cross a worker's remaining allowance. +func TestWorkerAllowanceRefusesWholeBatch(t *testing.T) { + h, s := treeBoundStore(t) + treeBoundAdd(t, s, TaskSpec{ID: "own", Title: "Own work"}) + t.Setenv(RunEnv, s.RootID()) + t.Setenv(TaskEnv, "own") + for i := 0; i < 4; i++ { + h.cliAdd(fmt.Sprintf("Part %d", i), fmt.Sprintf("part%d", i)) + } + before := s.Tasks() + if code := h.run("--db", h.db, "split", "own", "--into", "Fifth, Sixth"); code == 0 || !strings.Contains(h.errb.String(), "5 tasks") || !reflect.DeepEqual(before, s.Tasks()) { + t.Fatalf("partial batch admitted: %d %s", code, h.errb) + } +} + +// L-LOOP2 C3d: archiving cannot replenish a worker's allowance or lose authorship. +func TestArchivedAdditionsKeepTheirWorkerMarker(t *testing.T) { + h, s := treeBoundStore(t) + t.Setenv(RunEnv, s.RootID()) + t.Setenv(TaskEnv, s.RootID()) + now := time.Now() + s.now = func() time.Time { return now.Add(-2 * time.Hour) } + for i := 0; i < 5; i++ { + id := fmt.Sprintf("part%d", i) + if _, err := s.AddManyForWorker(s.RootID(), []TaskSpec{{ID: id, ParentID: s.RootID(), Title: fmt.Sprintf("Part %d", i)}}); err != nil { + t.Fatal(err) + } + if _, err := s.Claim(id, id); err != nil { + t.Fatal(err) + } + if _, err := s.Done(id, id, "finished", nil, nil); err != nil { + t.Fatal(err) + } + } + s.now = func() time.Time { return now } + archived, err := s.Archive(time.Hour) + if err != nil || len(archived) != 5 { + t.Fatalf("archive: %+v %v", archived, err) + } + stored, err := s.Archived() + if err != nil { + t.Fatal(err) + } + for _, task := range stored { + if task.AddedByTask != s.RootID() { + t.Fatalf("archive lost author: %+v", task) + } + } + if code := h.run("--db", h.db, "add", "Sixth part"); code == 0 || !strings.Contains(h.errb.String(), "5 tasks") { + t.Fatalf("archive replenished worker slots: %d %s", code, h.errb) + } +} + +// L-LOOP2 C3d: concurrent store handles cannot spend the same worker slot twice. +func TestWorkerAllowanceIsAtomicAcrossHandles(t *testing.T) { + _, s := treeBoundStore(t) + for i := 0; i < 4; i++ { + if _, err := s.AddManyForWorker(s.RootID(), []TaskSpec{{ID: fmt.Sprintf("part%d", i), ParentID: s.RootID(), Title: fmt.Sprintf("Part %d", i)}}); err != nil { + t.Fatal(err) + } + } + other, err := Open(s.Path(), "", "", "", "") + if err != nil { + t.Fatal(err) + } + defer other.Close() + var wg sync.WaitGroup + errs := make(chan error, 2) + for i, handle := range []*Store{s, other} { + wg.Add(1) + go func(i int, handle *Store) { + defer wg.Done() + _, err := handle.AddManyForWorker(s.RootID(), []TaskSpec{{ID: fmt.Sprintf("extra%d", i), ParentID: s.RootID(), Title: fmt.Sprintf("Extra %d", i)}}) + errs <- err + }(i, handle) + } + wg.Wait() + close(errs) + accepted := 0 + for err := range errs { + if err == nil { + accepted++ + } else if !strings.Contains(err.Error(), "5 tasks") { + t.Fatalf("unexpected refusal: %v", err) + } + } + if accepted != 1 || len(s.Tasks()) != 6 { + t.Fatalf("worker allowance raced: accepted=%d tasks=%d", accepted, len(s.Tasks())) + } +} + +// L-LOOP2 C3e: default parenting makes the depth refusal reachable. +func TestGrandchildWorkerCannotHandOutMoreWork(t *testing.T) { + h, s := treeBoundStore(t) + treeBoundAdd(t, s, TaskSpec{ID: "child", Title: "Child"}, TaskSpec{ID: "deep", ParentID: "child", Title: "Grandchild"}) + t.Setenv(RunEnv, s.RootID()) + t.Setenv(TaskEnv, "deep") + before := s.Tasks() + if code := h.run("--db", h.db, "add", "Renamed deeper work"); code == 0 || !strings.Contains(h.errb.String(), "2 levels below") || !reflect.DeepEqual(before, s.Tasks()) { + t.Fatalf("grandchild escaped the depth bound: %d %s", code, h.errb) + } +} + +// L-LOOP2 C3d, C6: old stores migrate without guessing who added their rows +// or inventing a person's stop. +func TestOldStoresDefaultToUnattributedAdditionsAndNoStop(t *testing.T) { + h, s := treeBoundStore(t) + treeBoundAdd(t, s, TaskSpec{ID: "old", Title: "Earlier work"}) + if err := s.Close(); err != nil { + t.Fatal(err) + } + db, err := openDatabase(h.db) + if err != nil { + t.Fatal(err) + } + for _, table := range []string{"tasks", "archived_tasks"} { + if _, err := db.Exec("ALTER TABLE " + table + " DROP COLUMN added_by_task"); err != nil { + t.Fatal(err) + } + } + if _, err := db.Exec("ALTER TABLE meta DROP COLUMN stopped"); err != nil { + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + migrated, err := Open(h.db, "", "", "", "") + if err != nil { + t.Fatal(err) + } + defer migrated.Close() + if migrated.Task("old").AddedByTask != "" || migrated.Stopped() { + t.Fatal("migration invented a worker author or a person's stop") + } + if _, err := migrated.AddManyForWorker(migrated.RootID(), []TaskSpec{{ID: "new", ParentID: migrated.RootID(), Title: "New work"}}); err != nil { + t.Fatal(err) + } +} diff --git a/internal/remote/callclass.go b/internal/remote/callclass.go index 9dcd203e48..e9dc1637ba 100644 --- a/internal/remote/callclass.go +++ b/internal/remote/callclass.go @@ -146,6 +146,8 @@ const lateCallTail = " did not answer in time" func classify(method string) callClass { switch method { + case MethodClientToolsSet, MethodClientToolAnswer: + return classAct case methodFactoryStatus, MethodAppTasks, MethodAppTask, MethodAppProgramActions, MethodAppTaskRouting, MethodAppTaskRoutingOptions, MethodAppQuickTasks, MethodPing, MethodCheckWork, MethodModel, MethodTitle, MethodUsage, MethodContextTokens, MethodSpendRail, MethodTranscript, MethodEarlier, MethodRewindPoints, MethodPlanSpend, diff --git a/internal/remote/client.go b/internal/remote/client.go index 42bdf60790..121463d2e3 100644 --- a/internal/remote/client.go +++ b/internal/remote/client.go @@ -89,6 +89,7 @@ const callDeadline = 10 * time.Second // it has to be: the surface asks synchronous getters from its update loop while // a turn's events are arriving on the reader. type Client struct { + clientTools clientToolLink // host keeps the typed destination as the fallback until the window chooses // its displayed name. Keeping the two apart preserves the connection route. host string @@ -298,6 +299,8 @@ func newClient(host string, hello Hello) *Client { } hello.Encodings = []string{frameEncodingGzip} return &Client{ + clientTools: clientToolLink{owner: rand.Text()}, + host: strings.TrimSpace(host), hello: hello, calls: map[uint64]chan result{}, @@ -736,6 +739,7 @@ func (c *Client) Close() error { c.closing = true conn := c.conn c.mu.Unlock() + c.endClientToolCalls() // A CLOSE ENDS THE ROAMING TOO, and it has to end it now rather than at the // end of whatever backoff the redial loop is sitting in: the person quit, // and a client that went on dialling a machine nobody is watching would be @@ -870,6 +874,8 @@ func (c *Client) read() { return } switch frame.Kind { + case clientToolCallKind, clientToolEndedKind: + c.clientToolFrame(frame) case "observed", "observerClosed": c.observerFrame(frame) case "sideAskDelta": @@ -1041,6 +1047,7 @@ func (c *Client) streamLocked(id uint64) *stream { // a channel left open would leave the surface spinning on a turn nobody is // running. func (c *Client) bury(cause error) { + c.endClientToolCalls() c.closeObservers() c.mu.Lock() if c.dead != nil { diff --git a/internal/remote/clienttools.go b/internal/remote/clienttools.go new file mode 100644 index 0000000000..e57f5ce032 --- /dev/null +++ b/internal/remote/clienttools.go @@ -0,0 +1,654 @@ +package remote + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "sort" + "sync" + "time" + + "github.com/Agent-Field/codeaf/internal/guard" + "github.com/Agent-Field/codeaf/internal/session" +) + +// Client tool methods ride the existing connection without opening a stream. +const ( + MethodClientToolsSet = "ClientTools.Set" + MethodClientToolAnswer = "ClientTool.Answer" + clientToolCallKind = "clientToolCall" + clientToolEndedKind = "clientToolEnded" + clientToolOwnerMin = 26 + clientToolOwnerMax = 256 +) + +// ErrClientToolsUnsupported means no forwarding capability was advertised. +var ErrClientToolsUnsupported = errors.New("the holder does not forward client tools") + +// ErrClientToolDisconnected distinguishes a lost connection from a stopped turn. +var ErrClientToolDisconnected = errors.New("the client tool connection ended") + +// ClientToolsRefusal is a holder's atomic refusal of a declaration snapshot. +type ClientToolsRefusal struct{ Reason string } + +func (e *ClientToolsRefusal) Error() string { return e.Reason } + +type clientToolCallWire struct { + Call string `json:"call"` + Set string `json:"set"` + Name string `json:"name"` + TurnCall string `json:"turn_call"` + SourceMessage string `json:"source_message,omitempty"` + Args json.RawMessage `json:"args"` + DeadlineMS int `json:"deadline_ms"` +} + +type clientToolAnswerWire struct { + Call string `json:"call"` + Text string `json:"text"` + IsError bool `json:"is_error"` +} + +type clientToolSetsWire struct { + Owner string `json:"owner"` + Sets []session.ClientToolSet `json:"sets"` +} + +type clientToolEndedWire struct { + Call string `json:"call"` +} + +type clientToolAck struct { + Accepted bool `json:"accepted"` +} + +type clientToolWait struct { + ctx context.Context + owner *server + call session.ClientToolCall + answerBy time.Time + done chan struct{} + answer session.ClientToolAnswer + sent bool +} + +type clientToolOwner struct { + token string + sets []session.ClientToolSet + wake chan struct{} + ended map[string]bool +} + +// ClientToolHub owns one conversation's declarations and routes by connection. +// Its zero value is ready to use; snapshots remain immutable after publication. +type ClientToolHub struct { + mu sync.Mutex + owners map[*server]*clientToolOwner + builtins map[string]bool + version uint64 + sets []session.ClientToolSet + pending map[string]*clientToolWait + sequence uint64 +} + +var _ session.ClientToolDoor = (*ClientToolHub)(nil) + +// ClientToolSets returns the merged snapshot sorted by set id and first tool name. +func (h *ClientToolHub) ClientToolSets() (uint64, []session.ClientToolSet) { + h.mu.Lock() + defer h.mu.Unlock() + return h.version, h.sets +} + +// ClientToolBuiltins includes deferred engine tools in collision checks. +func (h *ClientToolHub) ClientToolBuiltins(names []string) { + h.mu.Lock() + defer h.mu.Unlock() + h.builtins = make(map[string]bool, len(names)) + for _, name := range names { + h.builtins[name] = true + } +} + +func cloneClientToolSets(sets []session.ClientToolSet) []session.ClientToolSet { + copySets := make([]session.ClientToolSet, len(sets)) + copy(copySets, sets) + for i := range copySets { + copySets[i].Tools = append([]session.ClientTool(nil), sets[i].Tools...) + for j := range copySets[i].Tools { + copySets[i].Tools[j].InputSchema = bytes.Clone(sets[i].Tools[j].InputSchema) + } + if copySets[i].DeadlineMS == 0 { + copySets[i].DeadlineMS = session.ClientToolDefaultDeadlineMS + } + } + return copySets +} + +func (h *ClientToolHub) replace(owner *server, token string, sets []session.ClientToolSet) ([]string, error) { + h.mu.Lock() + defer h.mu.Unlock() + if owner.clientToolsGone.Load() { + return nil, ErrClientToolDisconnected + } + if len(token) < clientToolOwnerMin || len(token) > clientToolOwnerMax { + return nil, fmt.Errorf("client tools need an owner token of %d–%d bytes", clientToolOwnerMin, clientToolOwnerMax) + } + if state := h.owners[owner]; state != nil && state.token != token { + return nil, errors.New("client tool owner token cannot change on a connection") + } + names := make(map[string]bool) + ids := make(map[string]bool) + setCount, toolCount := len(sets), 0 + for other, state := range h.owners { + if other == owner || state.token == token { + continue + } + setCount += len(state.sets) + for _, set := range state.sets { + toolCount += len(set.Tools) + for _, tool := range set.Tools { + names[tool.Name] = true + } + } + } + attached := []string{} + for _, set := range sets { + if err := session.ValidateClientToolSet(set); err != nil { + return nil, err + } + if ids[set.ID] { + return nil, fmt.Errorf("duplicate set id %q", set.ID) + } + ids[set.ID] = true + toolCount += len(set.Tools) + for _, tool := range set.Tools { + if h.builtins[tool.Name] || session.ActionCategoryForTool(tool.Name) != session.ActionWork { + return nil, fmt.Errorf("%s is a built-in engine tool", tool.Name) + } + if names[tool.Name] { + return nil, fmt.Errorf("%s is already in another set", tool.Name) + } + names[tool.Name] = true + attached = append(attached, tool.Name) + } + } + if setCount > session.ClientToolMaxSets || toolCount > session.ClientToolMaxPerAgent { + return nil, fmt.Errorf("an agent allows at most %d sets and %d client tools", session.ClientToolMaxSets, session.ClientToolMaxPerAgent) + } + if h.owners == nil { + h.owners = make(map[*server]*clientToolOwner) + } + for other, state := range h.owners { + if other != owner && state.token == token { + other.clientToolsGone.Store(true) + h.dropLocked(other) + } + } + state := h.owners[owner] + if state == nil { + state = &clientToolOwner{token: token, wake: make(chan struct{}, 1), ended: make(map[string]bool)} + h.owners[owner] = state + guard.Go("remote/client-tools-sender", func() { h.sendCalls(owner, state) }) + } + state.sets = cloneClientToolSets(sets) + h.snapshotLocked() + return attached, nil +} + +func (h *ClientToolHub) snapshotLocked() { + var sets []session.ClientToolSet + for _, state := range h.owners { + sets = append(sets, state.sets...) + } + sort.Slice(sets, func(i, j int) bool { + if sets[i].ID != sets[j].ID { + return sets[i].ID < sets[j].ID + } + return clientToolFirstName(sets[i]) < clientToolFirstName(sets[j]) + }) + h.sets = sets + h.version++ +} + +func clientToolFirstName(set session.ClientToolSet) string { + if len(set.Tools) == 0 { + return "" + } + return set.Tools[0].Name +} + +func clientToolDisconnected(name string) session.ClientToolAnswer { + return session.ClientToolAnswer{Text: fmt.Sprintf("client_unavailable: the app that provides %s disconnected before answering; the outcome is unknown, so check before trying again.", name), IsError: true} +} + +func clientToolWaitEnded(call session.ClientToolCall, timedOut bool) session.ClientToolAnswer { + if timedOut { + return session.ClientToolAnswer{Text: fmt.Sprintf("client_timeout: the app that provides %s did not answer within %gs; the outcome is unknown, so check before trying again.", call.Name, float64(call.DeadlineMS)/1000), IsError: true} + } + return session.ClientToolAnswer{Text: fmt.Sprintf("the turn ended before the app answered %s; the outcome is unknown, so check before trying again.", call.Name), IsError: true} +} + +func (h *ClientToolHub) settleLocked(id string, pending *clientToolWait, answer session.ClientToolAnswer, answered bool) bool { + if h.pending[id] != pending { + return false + } + delete(h.pending, id) + pending.answer = answer + if state := h.owners[pending.owner]; state != nil && pending.sent && !answered && !pending.owner.clientToolsGone.Load() { + state.ended[id] = true + h.wakeLocked(state) + } + close(pending.done) + return true +} + +func (h *ClientToolHub) drop(owner *server) { + h.mu.Lock() + defer h.mu.Unlock() + h.dropLocked(owner) +} + +func (h *ClientToolHub) dropLocked(owner *server) { + if state := h.owners[owner]; state != nil { + delete(h.owners, owner) + close(state.wake) + h.snapshotLocked() + } + for id, pending := range h.pending { + if pending.owner == owner { + h.settleLocked(id, pending, clientToolDisconnected(pending.call.Name), false) + } + } +} + +// CallClientTool waits for only the connection that attached this declaration. +func (h *ClientToolHub) CallClientTool(ctx context.Context, call session.ClientToolCall) (session.ClientToolAnswer, error) { + if ctx.Err() != nil { + return clientToolWaitEnded(call, false), nil + } + id, pending := h.begin(ctx, call) + if pending == nil { + return session.ClientToolUnavailable(call.Name), nil + } + timer := time.NewTimer(time.Until(pending.answerBy)) + defer timer.Stop() + timedOut := false + select { + case <-pending.done: + return pending.answer, nil + case <-timer.C: + timedOut = true + case <-ctx.Done(): + } + return h.endWait(id, pending, timedOut), nil +} + +func (h *ClientToolHub) begin(ctx context.Context, call session.ClientToolCall) (string, *clientToolWait) { + h.mu.Lock() + defer h.mu.Unlock() + var owner *server + for candidate, state := range h.owners { + for _, set := range state.sets { + if set.ID != call.Set { + continue + } + for _, tool := range set.Tools { + if tool.Name == call.Name { + owner, call.DeadlineMS = candidate, set.DeadlineMS + } + } + } + } + if owner == nil || owner.clientToolsGone.Load() { + return "", nil + } + h.sequence++ + id := fmt.Sprintf("ct_remote_%x", h.sequence) + pending := &clientToolWait{ctx: ctx, owner: owner, call: call, done: make(chan struct{}), + answerBy: time.Now().Add(time.Duration(call.DeadlineMS) * time.Millisecond)} + if h.pending == nil { + h.pending = make(map[string]*clientToolWait) + } + h.pending[id] = pending + h.wakeLocked(h.owners[owner]) + return id, pending +} + +func (h *ClientToolHub) endWait(id string, pending *clientToolWait, timedOut bool) session.ClientToolAnswer { + h.mu.Lock() + defer h.mu.Unlock() + h.settleLocked(id, pending, clientToolWaitEnded(pending.call, timedOut), false) + return pending.answer +} + +func (h *ClientToolHub) wakeLocked(state *clientToolOwner) { + select { + case state.wake <- struct{}{}: + default: + } +} + +// Each connection has one sender. Calls waiting behind a blocked write live +// only in pending, so their waits remove both their arguments and queued work. +func (h *ClientToolHub) sendCalls(owner *server, state *clientToolOwner) { + for range state.wake { + for { + sent, err := h.sendNext(owner, state) + if err != nil { + owner.dropClientTools() + return + } + if !sent { + break + } + } + } +} + +func (h *ClientToolHub) sendNext(owner *server, state *clientToolOwner) (bool, error) { + owner.write.Lock() + defer owner.write.Unlock() + // Check after taking the writer: another frame may have held it past the wait. + frame, ok := h.nextEmission(owner, state) + if !ok { + return false, nil + } + return true, owner.sendLocked(frame) +} + +func (h *ClientToolHub) nextEmission(owner *server, state *clientToolOwner) (Frame, bool) { + h.mu.Lock() + defer h.mu.Unlock() + if h.owners[owner] != state || owner.clientToolsGone.Load() { + return Frame{}, false + } + for id := range state.ended { + delete(state.ended, id) + return Frame{Kind: clientToolEndedKind, Payload: mustJSON(clientToolEndedWire{Call: id})}, true + } + for id, pending := range h.pending { + if pending.owner != owner || pending.sent { + continue + } + if pending.ctx.Err() != nil || !time.Now().Before(pending.answerBy) { + h.settleLocked(id, pending, clientToolWaitEnded(pending.call, pending.ctx.Err() == nil), false) + continue + } + pending.sent = true + call := pending.call + wire := clientToolCallWire{Call: id, Set: call.Set, Name: call.Name, TurnCall: call.TurnCall, + SourceMessage: call.SourceMessage, Args: call.Args, DeadlineMS: call.DeadlineMS} + return Frame{Kind: clientToolCallKind, Payload: mustJSON(wire)}, true + } + return Frame{}, false +} + +func (h *ClientToolHub) answer(owner *server, wire clientToolAnswerWire) bool { + h.mu.Lock() + defer h.mu.Unlock() + pending := h.pending[wire.Call] + if pending == nil || pending.owner != owner { + return false + } + if owner.clientToolsGone.Load() { + h.settleLocked(wire.Call, pending, clientToolDisconnected(pending.call.Name), false) + return false + } + if pending.ctx.Err() != nil { + h.settleLocked(wire.Call, pending, clientToolWaitEnded(pending.call, false), false) + return false + } + if !time.Now().Before(pending.answerBy) { + h.settleLocked(wire.Call, pending, clientToolWaitEnded(pending.call, true), false) + return false + } + // The app already bounded this text; cutting again would replace its byte count. + return h.settleLocked(wire.Call, pending, session.ClientToolAnswer{Text: wire.Text, IsError: wire.IsError}, true) +} + +func (s *server) dropClientTools() { + s.clientToolsGone.Store(true) + if s.session != nil && s.session.engine != nil && s.session.engine.ClientTools != nil { + s.session.engine.ClientTools.drop(s) + } +} + +func (s *server) invokeClientTools(call Frame) (json.RawMessage, error) { + hub := s.session.engine.ClientTools + if hub == nil { + return nil, ErrClientToolsUnsupported + } + switch call.Method { + case MethodClientToolsSet: + snapshot, err := arg[clientToolSetsWire](call) + if err != nil { + return nil, err + } + names, err := hub.replace(s, snapshot.Owner, snapshot.Sets) + return mustJSON(names), err + case MethodClientToolAnswer: + answer, err := arg[clientToolAnswerWire](call) + if err != nil { + return nil, err + } + return mustJSON(clientToolAck{Accepted: hub.answer(s, answer)}), nil + } + return nil, errors.New("unknown client tool method") +} + +type clientToolIncoming struct { + cancel context.CancelCauseFunc +} + +type clientToolLink struct { + setMu sync.Mutex + owner string + sets []session.ClientToolSet + attached bool + welcome Welcome + + handler func(context.Context, session.ClientToolCall) (session.ClientToolAnswer, error) + incoming map[string]*clientToolIncoming +} + +// SupportsClientTools reports the welcome capability without a wire call. +func (a *Agent) SupportsClientTools() bool { + if a == nil || a.c == nil { + return false + } + return a.c.Welcome().ClientTools +} + +// SetClientTools atomically replaces this connection's entire snapshot. +func (a *Agent) SetClientTools(ctx context.Context, sets []session.ClientToolSet) ([]string, error) { + if !a.SupportsClientTools() { + return nil, ErrClientToolsUnsupported + } + c := a.c + c.clientTools.setMu.Lock() + defer c.clientTools.setMu.Unlock() + sets = cloneClientToolSets(sets) + welcome := c.Welcome() + payload, answered, err := c.callAnswered(ctx, MethodClientToolsSet, + clientToolSetsWire{Owner: c.clientTools.owner, Sets: sets}, callDeadline) + if err != nil { + if answered && c.clientToolHolderAnswered() { + return nil, &ClientToolsRefusal{Reason: err.Error()} + } + return nil, err + } + var names []string + if err := json.Unmarshal(payload, &names); err != nil { + return nil, err + } + if !c.rememberClientTools(sets, welcome) { + return nil, ErrClientToolDisconnected + } + return names, nil +} + +func (c *Client) clientToolHolderAnswered() bool { + c.mu.Lock() + defer c.mu.Unlock() + return c.dead == nil && !c.reconnecting && !c.closing +} + +// Durable transcript identity survives a holder restart. Without a transcript, +// the live instance is the only conversation identity the welcome supplies. +func clientToolConversationChanged(left, now Welcome) bool { + if left.SessionFile != "" { + return conversationSwitched(left.SessionFile, now.SessionFile) + } + return left.SessionInstance != "" && left.SessionInstance != now.SessionInstance +} + +func (c *Client) rememberClientTools(sets []session.ClientToolSet, welcome Welcome) bool { + c.mu.Lock() + defer c.mu.Unlock() + if clientToolConversationChanged(welcome, c.welcome) { + c.forgetClientToolsLocked() + return false + } + c.clientTools.sets, c.clientTools.attached = sets, true + c.clientTools.welcome = Welcome{SessionFile: welcome.SessionFile, SessionInstance: welcome.SessionInstance} + return true +} + +func (c *Client) forgetClientToolsLocked() { + c.clientTools.sets = nil + c.clientTools.attached = false + c.clientTools.welcome = Welcome{} + c.endClientToolCallsLocked() +} + +// SetClientToolHandler replaces the one handler and cancels its previous calls. +// Passing nil releases the handler when a surface stops holding the connection. +func (a *Agent) SetClientToolHandler(handler func(context.Context, session.ClientToolCall) (session.ClientToolAnswer, error)) { + if a == nil || a.c == nil { + return + } + c := a.c + c.mu.Lock() + defer c.mu.Unlock() + c.endClientToolCallsLocked() + c.clientTools.handler = handler +} + +func (c *Client) endClientToolCalls() { + c.mu.Lock() + defer c.mu.Unlock() + c.endClientToolCallsLocked() +} + +func (c *Client) endClientToolCallsLocked() { + for id, incoming := range c.clientTools.incoming { + incoming.cancel(ErrClientToolDisconnected) + delete(c.clientTools.incoming, id) + } +} + +func (c *Client) endClientToolCall(id string) { + c.mu.Lock() + defer c.mu.Unlock() + if incoming := c.clientTools.incoming[id]; incoming != nil { + incoming.cancel(context.Canceled) + delete(c.clientTools.incoming, id) + } +} + +func (c *Client) beginClientToolCall(wire clientToolCallWire) (context.Context, *clientToolIncoming, func(context.Context, session.ClientToolCall) (session.ClientToolAnswer, error)) { + c.mu.Lock() + defer c.mu.Unlock() + if c.closing || c.dead != nil || c.reconnecting || c.clientTools.incoming[wire.Call] != nil { + return nil, nil, nil + } + ctx, cancel := context.WithCancelCause(context.Background()) + incoming := &clientToolIncoming{cancel: cancel} + if c.clientTools.incoming == nil { + c.clientTools.incoming = make(map[string]*clientToolIncoming) + } + c.clientTools.incoming[wire.Call] = incoming + return ctx, incoming, c.clientTools.handler +} + +func (c *Client) clientToolCallActive(id string, incoming *clientToolIncoming) bool { + c.mu.Lock() + defer c.mu.Unlock() + return c.clientTools.incoming[id] == incoming +} + +func (c *Client) finishClientToolCall(id string, incoming *clientToolIncoming) { + c.mu.Lock() + defer c.mu.Unlock() + if c.clientTools.incoming[id] == incoming { + delete(c.clientTools.incoming, id) + } +} + +func (c *Client) clientToolFrame(frame Frame) { + if frame.Kind == clientToolEndedKind { + var ended clientToolEndedWire + if json.Unmarshal(frame.Payload, &ended) == nil { + c.endClientToolCall(ended.Call) + } + return + } + var wire clientToolCallWire + if json.Unmarshal(frame.Payload, &wire) != nil || wire.Call == "" { + return + } + ctx, incoming, handler := c.beginClientToolCall(wire) + if incoming == nil { + return + } + guard.Go("remote/client-tool-handler", func() { + defer incoming.cancel(context.Canceled) + defer c.finishClientToolCall(wire.Call, incoming) + call := session.ClientToolCall{Set: wire.Set, Name: wire.Name, TurnCall: wire.TurnCall, + SourceMessage: wire.SourceMessage, Args: wire.Args, DeadlineMS: wire.DeadlineMS} + answer := session.ClientToolUnavailable(call.Name) + if handler != nil { + var err error + answer, err = handler(ctx, call) + if err != nil { + answer = session.ClientToolAnswer{Text: err.Error(), IsError: true} + } + } + if c.clientToolCallActive(wire.Call, incoming) && ctx.Err() == nil { + _, _ = c.call(ctx, MethodClientToolAnswer, + clientToolAnswerWire{Call: wire.Call, Text: answer.Text, IsError: answer.IsError}) + } + }) +} + +func (c *Client) replayClientToolSets() ([]session.ClientToolSet, bool) { + c.mu.Lock() + defer c.mu.Unlock() + if !c.clientTools.attached { + return nil, false + } + if clientToolConversationChanged(c.clientTools.welcome, c.welcome) { + c.forgetClientToolsLocked() + return nil, false + } + return c.clientTools.sets, c.welcome.ClientTools +} + +func (c *Client) resendClientTools() { + // The reader must resume before a synchronous snapshot can receive its reply. + guard.Go("remote/client-tools-reattach", func() { + c.clientTools.setMu.Lock() + defer c.clientTools.setMu.Unlock() + sets, attached := c.replayClientToolSets() + if !attached { + return + } + if _, err := c.call(context.Background(), MethodClientToolsSet, + clientToolSetsWire{Owner: c.clientTools.owner, Sets: sets}); err != nil { + c.note("client tools could not be reattached: " + err.Error()) + } + }) +} diff --git a/internal/remote/clienttools_test.go b/internal/remote/clienttools_test.go new file mode 100644 index 0000000000..7473647d4e --- /dev/null +++ b/internal/remote/clienttools_test.go @@ -0,0 +1,936 @@ +package remote + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/home" + "github.com/Agent-Field/codeaf/internal/session" +) + +func clientToolTestSet(id, name string) session.ClientToolSet { + return session.ClientToolSet{ID: id, Instructions: "Use the attached tool.", DeadlineMS: 1000, + Tools: []session.ClientTool{{Name: name, Description: "An app action", + InputSchema: json.RawMessage(`{"type":"object"}`), Category: session.ActionEdit}}} +} + +func clientToolTestPair(t *testing.T, held *Session) *Loop { + t.Helper() + loop, err := LoopbackAttach(Hello{}, AttachOptions{Open: func(Hello) (*Session, error) { return held, nil }}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = loop.Close() }) + return loop +} + +// C1, C6, C9: capability discovery precedes attachment, and refusals are atomic. +func TestClientToolsRemoteCapabilityAndSnapshot(t *testing.T) { + hub := &ClientToolHub{} + engine := engineOn(&fakeAgent{model: "test/model"}) + engine.ClientTools = hub + held := NewSession(engine, true) + t.Cleanup(func() { _ = held.Close() }) + loop := clientToolTestPair(t, held) + if !loop.Client.Agent().SupportsClientTools() { + t.Fatal("holder did not advertise forwarding") + } + if _, sets := hub.ClientToolSets(); len(sets) != 0 { + t.Fatal("an unattached conversation has client tools") + } + set := clientToolTestSet("notes", "app_note") + if names, err := loop.Client.Agent().SetClientTools(context.Background(), []session.ClientToolSet{set}); err != nil || len(names) != 1 || names[0] != "app_note" { + t.Fatalf("attach: %v %v", names, err) + } + version, sets := hub.ClientToolSets() + if len(sets) != 1 || sets[0].Instructions != set.Instructions || sets[0].Tools[0].Name != "app_note" { + t.Fatalf("snapshot: %+v", sets) + } + set.Tools[0].InputSchema = json.RawMessage(`{"type":"string"}`) + if _, err := loop.Client.Agent().SetClientTools(context.Background(), []session.ClientToolSet{set}); err == nil { + t.Fatal("holder accepted an invalid schema") + } + if next, _ := hub.ClientToolSets(); next != version { + t.Fatal("a refusal changed the snapshot") + } + oldHeld := NewSession(engineOn(&fakeAgent{}), true) + t.Cleanup(func() { _ = oldHeld.Close() }) + older := clientToolTestPair(t, oldHeld) + before := older.CallsMade() + if _, err := older.Client.Agent().SetClientTools(context.Background(), nil); !errors.Is(err, ErrClientToolsUnsupported) { + t.Fatalf("old holder: %v", err) + } + if older.CallsMade() != before { + t.Fatal("sent a new method to an old holder") + } +} + +// C3: all three departures remove sets and settle calls before the ordinary wait. +func TestClientToolsRemoteDisconnectDetachAndClose(t *testing.T) { + for _, road := range []string{"pipe", "detach", "close"} { + t.Run(road, func(t *testing.T) { + hub := &ClientToolHub{} + engine := engineOn(&fakeAgent{}) + engine.ClientTools = hub + held := NewSession(engine, true) + t.Cleanup(func() { _ = held.Close() }) + loop := clientToolTestPair(t, held) + set := clientToolTestSet("notes", "app_note") + set.DeadlineMS = 120000 + if _, err := loop.Client.Agent().SetClientTools(context.Background(), []session.ClientToolSet{set}); err != nil { + t.Fatal(err) + } + entered, ended := make(chan struct{}), make(chan struct{}) + loop.Client.Agent().SetClientToolHandler(func(ctx context.Context, _ session.ClientToolCall) (session.ClientToolAnswer, error) { + close(entered) + <-ctx.Done() + close(ended) + return session.ClientToolAnswer{}, nil + }) + answer := clientToolTestCall(hub, context.Background(), "app_note") + clientToolTestSignal(t, entered) + switch road { + case "pipe": + _ = loop.Client.Close() + case "detach": + if err := loop.Client.Agent().Detach(); err != nil { + t.Fatal(err) + } + case "close": + if err := loop.Client.Agent().Close(); err != nil { + t.Fatal(err) + } + } + got := clientToolTestAnswer(t, answer) + if !got.IsError || got.Text != "client_unavailable: the app that provides app_note disconnected before answering; the outcome is unknown, so check before trying again." { + t.Fatalf("departure answer: %+v", got) + } + if _, sets := hub.ClientToolSets(); len(sets) != 0 { + t.Fatal("departed connection still has sets") + } + _ = loop.Client.Close() + clientToolTestSignal(t, ended) + }) + } +} + +func clientToolTestSignal(t *testing.T, signal <-chan struct{}) { + t.Helper() + select { + case <-signal: + case <-time.After(3 * time.Second): + t.Fatal("client tool signal did not arrive") + } +} + +type clientToolRawSurface struct { + conn net.Conn + owner string + frames chan Frame + errors chan error + next uint64 + callFrames atomic.Int32 +} + +func clientToolTestRaw(t *testing.T, held *Session, watch bool) *clientToolRawSurface { + t.Helper() + return clientToolTestRawOutput(t, held, watch, func(out io.Writer) io.Writer { return out }) +} + +func clientToolTestRawOutput(t *testing.T, held *Session, watch bool, wrap func(io.Writer) io.Writer) *clientToolRawSurface { + t.Helper() + surface, engine := net.Pipe() + done := make(chan struct{}) + go func() { + defer close(done) + defer engine.Close() + _ = ServeAttach(engine, wrap(engine), AttachOptions{Open: func(Hello) (*Session, error) { return held, nil }}) + }() + t.Cleanup(func() { + _ = surface.Close() + <-done + }) + raw := &clientToolRawSurface{conn: surface, owner: newClientID(), frames: make(chan Frame, 64), errors: make(chan error, 1)} + go func() { + reader := json.NewDecoder(surface) + for { + var frame Frame + if err := reader.Decode(&frame); err != nil { + raw.errors <- err + return + } + if frame.Kind == clientToolCallKind { + raw.callFrames.Add(1) + } + raw.frames <- frame + } + }() + if err := json.NewEncoder(surface).Encode(Frame{Kind: "hello", Payload: mustJSON(Hello{Version: Version, Watch: watch})}); err != nil { + t.Fatal(err) + } + raw.frame(t, "welcome") + return raw +} + +func (r *clientToolRawSurface) frame(t *testing.T, kind string) Frame { + t.Helper() + timer := time.NewTimer(3 * time.Second) + defer timer.Stop() + for { + select { + case frame := <-r.frames: + if frame.Kind == kind { + return frame + } + case err := <-r.errors: + t.Fatal(err) + case <-timer.C: + t.Fatalf("no %s frame", kind) + } + } +} + +func (r *clientToolRawSurface) call(t *testing.T, method string, args any) Frame { + t.Helper() + if method == MethodClientToolsSet { + args = clientToolSetsWire{Owner: r.owner, Sets: args.([]session.ClientToolSet)} + } + r.next++ + if err := json.NewEncoder(r.conn).Encode(Frame{Kind: "call", ID: r.next, Method: method, Payload: mustJSON(args)}); err != nil { + t.Fatal(err) + } + return r.frame(t, "result") +} + +// C3, C5: cancellation reaches the owner, and foreign or late answers change nothing. +func TestClientToolsRemoteWaitEndAndAnswerOwnership(t *testing.T) { + for _, timedOut := range []bool{false, true} { + t.Run(fmt.Sprintf("timeout=%t", timedOut), func(t *testing.T) { + hub := &ClientToolHub{} + engine := engineOn(&fakeAgent{}) + engine.ClientTools = hub + held := NewSession(engine, true) + t.Cleanup(func() { _ = held.Close() }) + owner := clientToolTestRaw(t, held, false) + foreign := clientToolTestPair(t, held) + if reply := owner.call(t, MethodClientToolsSet, []session.ClientToolSet{clientToolTestSet("notes", "app_note")}); reply.Error != "" { + t.Fatal(reply.Error) + } + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + answer := clientToolTestCall(hub, ctx, "app_note") + var call clientToolCallWire + if err := json.Unmarshal(owner.frame(t, clientToolCallKind).Payload, &call); err != nil { + t.Fatal(err) + } + payload, err := foreign.Client.call(ctx, MethodClientToolAnswer, clientToolAnswerWire{Call: call.Call, Text: "wrong connection"}) + if err != nil || string(payload) != `{"accepted":false}` { + t.Fatalf("foreign answer: %s %v", payload, err) + } + select { + case got := <-answer: + t.Fatalf("foreign answer settled the call: %+v", got) + default: + } + if !timedOut { + cancel() + } + got := clientToolTestAnswer(t, answer) + want := "the turn ended before the app answered app_note; the outcome is unknown, so check before trying again." + if timedOut { + want = "client_timeout: the app that provides app_note did not answer within 1s; the outcome is unknown, so check before trying again." + } + if !got.IsError || got.Text != want { + t.Fatalf("wait answer: %+v", got) + } + var ended clientToolEndedWire + if err := json.Unmarshal(owner.frame(t, clientToolEndedKind).Payload, &ended); err != nil || ended.Call != call.Call { + t.Fatalf("call-ended frame: %+v %v", ended, err) + } + late := owner.call(t, MethodClientToolAnswer, clientToolAnswerWire{Call: call.Call, Text: "too late"}) + if late.Error != "" || string(late.Payload) != `{"accepted":false}` { + t.Fatalf("late answer: %+v", late) + } + }) + } +} + +// C1, C5: holder validation applies to raw surfaces and counts all connections. +func TestClientToolsRemoteHolderValidationAndAggregateLimits(t *testing.T) { + hub := &ClientToolHub{} + hub.ClientToolBuiltins([]string{"fixture_builtin"}) + engine := engineOn(&fakeAgent{}) + engine.ClientTools = hub + held := NewSession(engine, true) + t.Cleanup(func() { _ = held.Close() }) + a, b := clientToolTestPair(t, held), clientToolTestPair(t, held) + ctx := context.Background() + for _, change := range []func(*session.ClientToolSet){ + func(s *session.ClientToolSet) { s.ID = "Bad id" }, + func(s *session.ClientToolSet) { + s.Instructions = strings.Repeat("x", session.ClientToolInstructionsMax+1) + }, + func(s *session.ClientToolSet) { s.DeadlineMS = 999 }, + func(s *session.ClientToolSet) { s.DeadlineMS = 120001 }, + func(s *session.ClientToolSet) { s.Tools[0].Name = "Bad-name" }, + func(s *session.ClientToolSet) { s.Tools[0].Description = "" }, + func(s *session.ClientToolSet) { s.Tools[0].InputSchema = json.RawMessage(`null`) }, + func(s *session.ClientToolSet) { s.Tools[0].InputSchema = json.RawMessage(`{"type":"array"}`) }, + func(s *session.ClientToolSet) { s.Tools[0].Category = session.ActionWork }, + func(s *session.ClientToolSet) { s.Tools[0].Name = "fixture_builtin" }, + func(s *session.ClientToolSet) { s.Tools[0].Name = "bash" }, + } { + set := clientToolTestSet("notes", "app_note") + change(&set) + if _, err := a.Client.Agent().SetClientTools(ctx, []session.ClientToolSet{set}); err == nil { + t.Fatalf("holder accepted invalid declaration: %+v", set) + } + if _, sets := hub.ClientToolSets(); len(sets) != 0 { + t.Fatal("refusal partially attached tools") + } + } + sets := []session.ClientToolSet{} + for i := range 4 { + sets = append(sets, clientToolTestSet(fmt.Sprintf("set%d", i), fmt.Sprintf("app_%d", i))) + } + if _, err := a.Client.Agent().SetClientTools(ctx, sets); err != nil { + t.Fatal(err) + } + if _, err := b.Client.Agent().SetClientTools(ctx, []session.ClientToolSet{clientToolTestSet("extra", "app_extra")}); err == nil { + t.Fatal("aggregate fifth set accepted") + } + for i := range 2 { + sets[i].Tools = nil + for j := range 32 { + tool := clientToolTestSet("unused", fmt.Sprintf("app_%d_%d", i, j)).Tools[0] + sets[i].Tools = append(sets[i].Tools, tool) + } + } + if _, err := a.Client.Agent().SetClientTools(ctx, sets[:2]); err != nil { + t.Fatal(err) + } + if _, err := b.Client.Agent().SetClientTools(ctx, []session.ClientToolSet{clientToolTestSet("extra", "app_extra")}); err == nil { + t.Fatal("aggregate 65th tool accepted") + } + watcher := clientToolTestRaw(t, held, true) + if reply := watcher.call(t, MethodClientToolsSet, []session.ClientToolSet{}); reply.Error == "" { + t.Fatal("a watcher changed the belt") + } +} + +// C4: roaming republishes its last acknowledged snapshot on the replacement link. +func TestClientToolsRemoteRedialRestoresSnapshot(t *testing.T) { + hub := &ClientToolHub{} + engine := engineOn(&fakeAgent{}) + engine.ClientTools = hub + held := NewSession(engine, true) + t.Cleanup(func() { _ = held.Close() }) + var mu sync.Mutex + var first io.ReadWriteCloser + dialled := 0 + client, err := Roam("engine", Hello{}, Roaming{Window: 3 * time.Second, Dial: func() (io.ReadWriteCloser, error) { + surface, pipe := Pipe() + func() { + mu.Lock() + defer mu.Unlock() + dialled++ + if first == nil { + first = surface + } + }() + go func() { + defer pipe.Close() + _ = ServeAttach(pipe, pipe, AttachOptions{Open: func(Hello) (*Session, error) { return held, nil }}) + }() + return surface, nil + }}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = client.Close() }) + if _, err := client.Agent().SetClientTools(context.Background(), []session.ClientToolSet{clientToolTestSet("notes", "app_note")}); err != nil { + t.Fatal(err) + } + version, _ := hub.ClientToolSets() + initial := func() io.ReadWriteCloser { + mu.Lock() + defer mu.Unlock() + return first + }() + _ = initial.Close() + waitFor(t, "client tool snapshot after redial", func() bool { + mu.Lock() + defer mu.Unlock() + redialled := dialled > 1 + next, sets := hub.ClientToolSets() + return redialled && next > version && len(sets) == 1 && sets[0].Tools[0].Name == "app_note" + }) +} + +// C3, C4, C5: a replay moves ownership before the half-open old link detaches. +func TestClientToolsRemoteRedialMovesAttachedOwner(t *testing.T) { + hub := &ClientToolHub{} + held := NewSession(&Engine{Agent: &fakeAgent{}, ClientTools: hub, SessionFile: "/conversation.jsonl"}, true) + t.Cleanup(func() { _ = held.Close() }) + var first net.Conn + var dialled atomic.Int32 + served := make(chan struct{}, 8) + oldDetached := make(chan struct{}) + client, err := Roam("engine", Hello{}, Roaming{Window: 3 * time.Second, Dial: func() (io.ReadWriteCloser, error) { + n := dialled.Add(1) + surface, pipe := net.Pipe() + if n == 1 { + first = surface + } + go func() { + defer func() { served <- struct{}{} }() + defer pipe.Close() + if n == 1 { + defer close(oldDetached) + } + _ = ServeAttach(pipe, pipe, AttachOptions{Open: func(Hello) (*Session, error) { return held, nil }}) + }() + return surface, nil + }}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = client.Close() + _ = first.Close() + for range int(dialled.Load()) { + clientToolTestSignal(t, served) + } + }) + set := clientToolTestSet("notes", "app_note") + set.DeadlineMS = 120000 + if _, err := client.Agent().SetClientTools(context.Background(), []session.ClientToolSet{set}); err != nil { + t.Fatal(err) + } + version, _ := hub.ClientToolSets() + entered := make(chan struct{}, 1) + var calls atomic.Int32 + client.Agent().SetClientToolHandler(func(ctx context.Context, _ session.ClientToolCall) (session.ClientToolAnswer, error) { + if calls.Add(1) == 1 { + entered <- struct{}{} + <-ctx.Done() + return session.ClientToolAnswer{}, ctx.Err() + } + return session.ClientToolAnswer{Text: "replacement connection"}, nil + }) + pending := clientToolTestCall(hub, context.Background(), "app_note") + clientToolTestSignal(t, entered) + if err := first.SetReadDeadline(time.Now()); err != nil { + t.Fatal(err) + } + waitFor(t, "reader leaving the old connection", func() bool { + client.mu.Lock() + defer client.mu.Unlock() + return client.reconnecting + }) + if err := first.SetReadDeadline(time.Time{}); err != nil { + t.Fatal(err) + } + // Keep the old server attached and drain its room notices independently of + // the roaming reader, so its ordinary driver broadcast can finish as usual. + drained := make(chan struct{}) + go func() { + defer close(drained) + _, _ = io.Copy(io.Discard, first) + }() + waitFor(t, "replacement replay while old owner is attached", func() bool { + held.mu.Lock() + defer held.mu.Unlock() + next, sets := hub.ClientToolSets() + return dialled.Load() == 2 && len(held.surfaces) == 2 && next > version && len(sets) == 1 + }) + got := clientToolTestAnswer(t, pending) + if !got.IsError || got.Text != "client_unavailable: the app that provides app_note disconnected before answering; the outcome is unknown, so check before trying again." { + t.Fatalf("old owner's pending call: %+v", got) + } + _ = first.Close() + clientToolTestSignal(t, drained) + clientToolTestSignal(t, oldDetached) + if _, sets := hub.ClientToolSets(); len(sets) != 1 { + t.Fatal("old detach removed the replacement's tools") + } + got = clientToolTestAnswer(t, clientToolTestCall(hub, context.Background(), "app_note")) + if got.IsError || got.Text != "replacement connection" { + t.Fatalf("call after old detach: %+v", got) + } +} + +// C4: a changed welcome forgets the snapshot instead of attaching it elsewhere. +func TestClientToolsRemoteRedialConversationIdentity(t *testing.T) { + for _, files := range [][2]string{{"/a.jsonl", "/b.jsonl"}, {"", ""}, {"/a.jsonl", "/a.jsonl"}} { + t.Run(fmt.Sprintf("%s-to-%s", files[0], files[1]), func(t *testing.T) { + aHub, bHub := &ClientToolHub{}, &ClientToolHub{} + a := NewSession(&Engine{Agent: &fakeAgent{}, ClientTools: aHub, SessionFile: files[0]}, true) + b := NewSession(&Engine{Agent: &fakeAgent{}, ClientTools: bHub, SessionFile: files[1]}, true) + t.Cleanup(func() { + _ = a.Close() + _ = b.Close() + }) + var first io.ReadWriteCloser + var dialled atomic.Int32 + served := make(chan struct{}, 8) + client, err := Roam("engine", Hello{}, Roaming{Window: 3 * time.Second, Dial: func() (io.ReadWriteCloser, error) { + n := dialled.Add(1) + surface, pipe := Pipe() + held := b + if n == 1 { + first, held = surface, a + } + go func() { + defer func() { served <- struct{}{} }() + defer pipe.Close() + _ = ServeAttach(pipe, pipe, AttachOptions{Open: func(Hello) (*Session, error) { return held, nil }}) + }() + return surface, nil + }}) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { + _ = client.Close() + _ = first.Close() + for range int(dialled.Load()) { + clientToolTestSignal(t, served) + } + }) + set := clientToolTestSet("notes", "app_note") + set.DeadlineMS = 120000 + if _, err := client.Agent().SetClientTools(context.Background(), []session.ClientToolSet{set}); err != nil { + t.Fatal(err) + } + callsBefore := client.CallsMade() + entered, canceled := make(chan struct{}), make(chan struct{}) + client.Agent().SetClientToolHandler(func(ctx context.Context, _ session.ClientToolCall) (session.ClientToolAnswer, error) { + close(entered) + <-ctx.Done() + close(canceled) + return session.ClientToolAnswer{}, ctx.Err() + }) + pending := clientToolTestCall(aHub, context.Background(), "app_note") + clientToolTestSignal(t, entered) + _ = first.Close() + clientToolTestSignal(t, canceled) + clientToolTestAnswer(t, pending) + wantReplay := files[0] != "" && files[0] == files[1] + waitFor(t, "client tool replay decision after changed welcome", func() bool { + client.mu.Lock() + defer client.mu.Unlock() + if client.reconnecting || client.welcome.SessionInstance != b.instance { + return false + } + _, sets := bHub.ClientToolSets() + if wantReplay { + return len(sets) == 1 + } + return !client.clientTools.attached && len(client.clientTools.sets) == 0 && len(client.clientTools.incoming) == 0 + }) + if !wantReplay { + if client.CallsMade() != callsBefore { + t.Fatal("sent a tool snapshot to another conversation") + } + if _, sets := bHub.ClientToolSets(); len(sets) != 0 { + t.Fatal("old tools reached another conversation") + } + got := clientToolTestAnswer(t, clientToolTestCall(bHub, context.Background(), "app_note")) + if !got.IsError || !strings.HasPrefix(got.Text, "client_unavailable:") { + t.Fatalf("another conversation used the old tools: %+v", got) + } + } + }) + } +} + +// C1, C2, C9: the real holder refreshes its belt and instructions at model steps. +func TestClientToolsRemoteModelBoundary(t *testing.T) { + t.Setenv(home.EnvVar, t.TempDir()) + type request struct { + Messages []struct { + Role string `json:"role"` + Content json.RawMessage `json:"content"` + } `json:"messages"` + Tools []struct { + Function struct { + Name string `json:"name"` + } `json:"function"` + } `json:"tools"` + } + var mu sync.Mutex + var requests []request + provider := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + var got request + if err := json.NewDecoder(r.Body).Decode(&got); err != nil { + http.Error(w, err.Error(), http.StatusBadRequest) + return + } + attached := false + for _, tool := range got.Tools { + attached = attached || tool.Function.Name == "app_note" + } + call := attached && len(got.Messages) > 0 && got.Messages[len(got.Messages)-1].Role == "user" + if len(got.Tools) > 0 { + func() { + mu.Lock() + defer mu.Unlock() + requests = append(requests, got) + }() + } + w.Header().Set("Content-Type", "text/event-stream") + if call { + fmt.Fprint(w, "data: "+`{"choices":[{"index":0,"delta":{"role":"assistant","tool_calls":[{"index":0,"id":"provider-boundary-1","type":"function","function":{"name":"app_note","arguments":"{}"}}]},"finish_reason":null}]}`+"\n\n") + fmt.Fprint(w, "data: "+`{"choices":[{"index":0,"delta":{},"finish_reason":"tool_calls"}]}`+"\n\n") + } else { + fmt.Fprint(w, "data: "+`{"choices":[{"index":0,"delta":{"role":"assistant","content":"done"},"finish_reason":"stop"}]}`+"\n\n") + } + fmt.Fprint(w, "data: [DONE]\n\n") + })) + t.Cleanup(provider.Close) + hub := &ClientToolHub{} + agent, err := session.New(session.Config{Workspace: t.TempDir(), SessionFile: filepath.Join(t.TempDir(), "transcript.jsonl"), + Model: "test/model", APIKey: "fixture", BaseURL: provider.URL, OneModel: true, + DeterministicNames: true, PromptProfile: "full", ClientTools: hub}) + if err != nil { + t.Fatal(err) + } + held := NewSession(&Engine{Agent: agent, ClientTools: hub}, true) + t.Cleanup(func() { _ = held.Close() }) + loop := clientToolTestPair(t, held) + loop.Client.Agent().SetClientToolHandler(func(_ context.Context, call session.ClientToolCall) (session.ClientToolAnswer, error) { + if call.SourceMessage == "" || call.TurnCall != "provider-boundary-1" { + t.Errorf("model call identity: %+v", call) + } + return session.ClientToolAnswer{Text: `{"saved":true}`}, nil + }) + ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second) + defer cancel() + turn := func() { + t.Helper() + events, err := loop.Client.Agent().Submit(ctx, "inspect the available tools") + if err != nil { + t.Fatal(err) + } + for { + select { + case event, open := <-events: + if !open { + return + } + if event.Kind == session.EventError { + t.Errorf("model turn: %v", event.Err) + } + case <-ctx.Done(): + t.Fatal("holder model turn did not finish") + } + } + } + turn() + if _, err := loop.Client.Agent().SetClientTools(ctx, []session.ClientToolSet{clientToolTestSet("notes", "app_note")}); err != nil { + t.Fatal(err) + } + turn() + if _, err := loop.Client.Agent().SetClientTools(ctx, nil); err != nil { + t.Fatal(err) + } + turn() + mu.Lock() + defer mu.Unlock() + if len(requests) != 4 { + t.Fatalf("provider requests: %d", len(requests)) + } + before, attached, after := requests[0], requests[1], requests[3] + if len(before.Tools) != len(after.Tools) || len(attached.Tools) != len(before.Tools)+1 { + t.Fatalf("belt counts: %d, %d, %d", len(before.Tools), len(attached.Tools), len(after.Tools)) + } + for i := range before.Tools { + if before.Tools[i].Function.Name != after.Tools[i].Function.Name { + t.Fatal("a conversation without sets changed its belt") + } + } + if strings.Contains(string(before.Messages[0].Content), "Tools from the app:") || strings.Contains(string(after.Messages[0].Content), "Tools from the app:") { + t.Fatal("an empty hub added instructions") + } + if !strings.Contains(string(attached.Messages[0].Content), "Tools from the app: notes") || !strings.Contains(string(attached.Messages[0].Content), "Use the attached tool.") { + t.Fatal("holder did not refresh its instructions") + } +} + +func clientToolTestAnswer(t *testing.T, answer <-chan session.ClientToolAnswer) session.ClientToolAnswer { + t.Helper() + select { + case got := <-answer: + return got + case <-time.After(3 * time.Second): + t.Fatal("client tool wait did not settle") + return session.ClientToolAnswer{} + } +} + +func clientToolTestCall(hub *ClientToolHub, ctx context.Context, name string) <-chan session.ClientToolAnswer { + answer := make(chan session.ClientToolAnswer, 1) + go func() { + got, _ := hub.CallClientTool(ctx, session.ClientToolCall{Set: "notes", Name: name, + TurnCall: "provider-1", SourceMessage: "message-1", Args: json.RawMessage(`{"id":3}`)}) + answer <- got + }() + return answer +} + +// C2, C5: the owning connection alone receives and answers its calls. +func TestClientToolsRemoteOwnersAndAnswers(t *testing.T) { + hub := &ClientToolHub{} + engine := engineOn(&fakeAgent{}) + engine.ClientTools = hub + held := NewSession(engine, true) + t.Cleanup(func() { _ = held.Close() }) + a, b := clientToolTestPair(t, held), clientToolTestPair(t, held) + ctx := context.Background() + if _, err := a.Client.Agent().SetClientTools(ctx, []session.ClientToolSet{clientToolTestSet("notes", "app_note")}); err != nil { + t.Fatal(err) + } + if _, err := b.Client.Agent().SetClientTools(ctx, []session.ClientToolSet{clientToolTestSet("other", "app_note")}); err == nil || !strings.Contains(err.Error(), "app_note") { + t.Fatalf("collision: %v", err) + } + called := make(chan session.ClientToolCall, 1) + foreign := make(chan bool, 1) + b.Client.Agent().SetClientToolHandler(func(context.Context, session.ClientToolCall) (session.ClientToolAnswer, error) { + foreign <- true + return session.ClientToolAnswer{}, nil + }) + a.Client.Agent().SetClientToolHandler(func(ctx context.Context, call session.ClientToolCall) (session.ClientToolAnswer, error) { + called <- call + return session.ClientToolAnswer{Text: `{"saved":true}`}, nil + }) + answer := clientToolTestAnswer(t, clientToolTestCall(hub, ctx, "app_note")) + if answer.IsError || answer.Text != `{"saved":true}` { + t.Fatalf("model answer: %+v", answer) + } + call := <-called + if call.SourceMessage != "message-1" || call.TurnCall != "provider-1" || string(call.Args) != `{"id":3}` { + t.Fatalf("call identity: %+v", call) + } + select { + case <-foreign: + t.Fatal("a call reached a different connection") + default: + } + if _, err := b.Client.Agent().SetClientTools(ctx, []session.ClientToolSet{clientToolTestSet("notes", "app_other")}); err != nil { + t.Fatal(err) + } + a.Client.Agent().SetClientToolHandler(func(context.Context, session.ClientToolCall) (session.ClientToolAnswer, error) { + foreign <- true + return session.ClientToolAnswer{}, nil + }) + b.Client.Agent().SetClientToolHandler(func(_ context.Context, call session.ClientToolCall) (session.ClientToolAnswer, error) { + return session.ClientToolAnswer{Text: "other connection"}, nil + }) + got := clientToolTestAnswer(t, clientToolTestCall(hub, ctx, "app_other")) + if got.Text != "other connection" || got.IsError { + t.Fatalf("second owner answer: %+v", got) + } + select { + case <-foreign: + t.Fatal("the second owner's call reached the first") + default: + } +} + +type clientToolBlockedAgent struct { + *fakeAgent + entered chan struct{} + release chan struct{} +} + +func (a *clientToolBlockedAgent) SetModel(model string) { + close(a.entered) + <-a.release + a.fakeAgent.SetModel(model) +} + +// C2: tool answers bypass an ordered call that is still holding its lane. +func TestClientToolsRemoteAnswerBypassesOrderedLane(t *testing.T) { + hub := &ClientToolHub{} + agent := &clientToolBlockedAgent{fakeAgent: &fakeAgent{}, entered: make(chan struct{}), release: make(chan struct{})} + defer close(agent.release) + held := NewSession(&Engine{Agent: agent, ClientTools: hub}, true) + t.Cleanup(func() { _ = held.Close() }) + loop := clientToolTestPair(t, held) + if _, err := loop.Client.Agent().SetClientTools(context.Background(), []session.ClientToolSet{clientToolTestSet("notes", "app_note")}); err != nil { + t.Fatal(err) + } + loop.Client.Agent().SetClientToolHandler(func(context.Context, session.ClientToolCall) (session.ClientToolAnswer, error) { + return session.ClientToolAnswer{Text: "answered while the lane was held"}, nil + }) + go loop.Client.Agent().SetModel("another/model") + clientToolTestSignal(t, agent.entered) + got := clientToolTestAnswer(t, clientToolTestCall(hub, context.Background(), "app_note")) + if got.IsError || got.Text != "answered while the lane was held" { + t.Fatalf("answer queued behind ordered work: %+v", got) + } +} + +type clientToolStalledOutput struct { + io.Writer + entered chan struct{} + release chan struct{} + once sync.Once +} + +func (w *clientToolStalledOutput) Write(data []byte) (int, error) { + var frame Frame + if json.Unmarshal(data, &frame) == nil && frame.Kind == clientToolCallKind { + w.once.Do(func() { + close(w.entered) + <-w.release + }) + } + return w.Writer.Write(data) +} + +// C3, C5: a stuck owner blocks one sender while queued calls expire and peers work. +func TestClientToolsRemoteBlockedSenderDropsExpiredCalls(t *testing.T) { + hub := &ClientToolHub{} + held := NewSession(&Engine{Agent: &fakeAgent{}, ClientTools: hub}, true) + t.Cleanup(func() { _ = held.Close() }) + stalled := &clientToolStalledOutput{entered: make(chan struct{}), release: make(chan struct{})} + var release sync.Once + owner := clientToolTestRawOutput(t, held, false, func(out io.Writer) io.Writer { + stalled.Writer = out + return stalled + }) + t.Cleanup(func() { release.Do(func() { close(stalled.release) }) }) + set := clientToolTestSet("notes", "app_note") + if reply := owner.call(t, MethodClientToolsSet, []session.ClientToolSet{set}); reply.Error != "" { + t.Fatal(reply.Error) + } + peer := clientToolTestPair(t, held) + if _, err := peer.Client.Agent().SetClientTools(context.Background(), []session.ClientToolSet{clientToolTestSet("notes", "app_other")}); err != nil { + t.Fatal(err) + } + peer.Client.Agent().SetClientToolHandler(func(context.Context, session.ClientToolCall) (session.ClientToolAnswer, error) { + return session.ClientToolAnswer{Text: "other owner still works"}, nil + }) + first := clientToolTestCall(hub, context.Background(), "app_note") + clientToolTestSignal(t, stalled.entered) + var queued []<-chan session.ClientToolAnswer + for range 16 { + queued = append(queued, clientToolTestCall(hub, context.Background(), "app_note")) + } + if got := clientToolTestAnswer(t, clientToolTestCall(hub, context.Background(), "app_other")); got.IsError || got.Text != "other owner still works" { + t.Fatalf("blocked one owner stalled another: %+v", got) + } + for _, answer := range append(queued, first) { + got := clientToolTestAnswer(t, answer) + if !got.IsError || !strings.HasPrefix(got.Text, "client_timeout:") { + t.Fatalf("blocked send did not respect its wait: %+v", got) + } + } + func() { + hub.mu.Lock() + defer hub.mu.Unlock() + if len(hub.pending) != 0 { + t.Fatal("expired queued calls retained their arguments") + } + }() + release.Do(func() { close(stalled.release) }) + owner.frame(t, clientToolCallKind) + owner.frame(t, clientToolEndedKind) + if reply := owner.call(t, MethodClientToolsSet, []session.ClientToolSet{set}); reply.Error != "" { + t.Fatal(reply.Error) + } + if got := owner.callFrames.Load(); got != 1 { + t.Fatalf("sent %d calls after a blocked writer resumed; queued expired calls must be skipped", got) + } +} + +type clientToolStalledLink struct { + io.ReadWriteCloser + writes int + entered chan struct{} + release chan struct{} +} + +func (p *clientToolStalledLink) Write(data []byte) (int, error) { + p.writes++ + if p.writes == 2 { + close(p.entered) + <-p.release + return 0, io.ErrClosedPipe + } + return p.ReadWriteCloser.Write(data) +} + +// C1: tool publication shares ordinary calls' writer ownership and write failures. +func TestClientToolsRemoteAttachmentUsesCallPath(t *testing.T) { + for _, method := range []string{MethodModel, MethodClientToolsSet} { + t.Run(method, func(t *testing.T) { + hub := &ClientToolHub{} + held := NewSession(&Engine{Agent: &fakeAgent{}, ClientTools: hub}, true) + t.Cleanup(func() { _ = held.Close() }) + surface, engine := Pipe() + served := make(chan struct{}) + go func() { + defer close(served) + defer engine.Close() + _ = ServeAttach(engine, engine, AttachOptions{Open: func(Hello) (*Session, error) { return held, nil }}) + }() + link := &clientToolStalledLink{ReadWriteCloser: surface, entered: make(chan struct{}), release: make(chan struct{})} + client, err := Dial(link, "engine", Hello{}) + if err != nil { + t.Fatal(err) + } + var release sync.Once + t.Cleanup(func() { + release.Do(func() { close(link.release) }) + _ = client.Close() + clientToolTestSignal(t, served) + }) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + failed := make(chan error, 1) + go func() { + if method == MethodClientToolsSet { + _, err := client.Agent().SetClientTools(ctx, []session.ClientToolSet{clientToolTestSet("notes", "app_note")}) + failed <- err + return + } + _, err := client.call(ctx, method, nil) + failed <- err + }() + clientToolTestSignal(t, link.entered) + cancel() + select { + case err := <-failed: + t.Fatalf("call abandoned its synchronous writer: %v", err) + default: + } + release.Do(func() { close(link.release) }) + select { + case err := <-failed: + if err == nil || err.Error() != "the connection to engine is gone — run the same command to pick the conversation back up" { + t.Fatalf("ordinary write failure: %v", err) + } + case <-time.After(3 * time.Second): + t.Fatal("call retained a writer after its transport write ended") + } + if _, sets := hub.ClientToolSets(); len(sets) != 0 { + t.Fatal("a failed write changed the holder") + } + }) + } +} diff --git a/internal/remote/model_for_now.go b/internal/remote/model_for_now.go new file mode 100644 index 0000000000..d371af8675 --- /dev/null +++ b/internal/remote/model_for_now.go @@ -0,0 +1,51 @@ +package remote + +import ( + "errors" + "fmt" +) + +const MethodUseModelForNow = "UseModelForNow" + +const modelForNowUnsupported = "run-only model selection is unsupported by this engine" + +// ConfiguredModel reads the saved selection, rather than this run's replacement. +func (a *Agent) ConfiguredModel() string { + if selected := a.ModelIdentity().Selected; selected != "" { + return selected + } + return a.Model() +} + +// UseModelForNow is acknowledged before the surface sends its next request. +// Older engines retain their existing SetModel behavior. +func (a *Agent) UseModelForNow(model string) error { + _, err := a.c.call(nil, MethodUseModelForNow, model) + if err != nil && (err.Error() == fmt.Sprintf("engine: no such method %q", MethodUseModelForNow) || err.Error() == modelForNowUnsupported) { + a.SetModel(model) + return nil + } + return err +} + +func (s *server) modelForNowCall(agent WrappedAgent, call Frame) ([]byte, bool, error) { + if call.Method != MethodUseModelForNow { + return nil, false, nil + } + model, err := arg[string](call) + if err != nil { + return nil, true, err + } + door, ok := agent.(interface{ UseModelForNow(string) error }) + if !ok { + return nil, true, errors.New(modelForNowUnsupported) + } + if refresh := s.session.engine.RefreshModelSources; refresh != nil { + refresh() + } + if err := door.UseModelForNow(model); err != nil { + return nil, true, err + } + s.session.announce() + return nil, true, nil +} diff --git a/internal/remote/model_for_now_test.go b/internal/remote/model_for_now_test.go new file mode 100644 index 0000000000..eef76957e3 --- /dev/null +++ b/internal/remote/model_for_now_test.go @@ -0,0 +1,77 @@ +package remote + +import ( + "fmt" + "testing" + + "github.com/Agent-Field/codeaf/internal/session" +) + +// C1, C4, C5: the linked-local wire distinguishes temporary and explicit picks. +func TestRunOnlyModelCrossesTheWireWithoutReplacingTheSavedPair(t *testing.T) { + dir := t.TempDir() + a, err := session.New(session.Config{Workspace: t.TempDir(), Place: session.Place{Dir: dir}, Model: "vendor/configured", APIKey: "fixture", BaseURL: "http://127.0.0.1:1/v1", + ConversationModels: &session.ConversationModels{Main: "vendor/configured", Fallback: "vendor/backup"}, + }) + if err != nil { + t.Fatal(err) + } + defer a.Close() + loop, err := Loopback(Hello{Version: Version}, Options{Boot: func(Hello) (*Engine, error) { return &Engine{Agent: a}, nil }}) + if err != nil { + t.Fatal(err) + } + defer loop.Close() + agent := loop.Client.Agent() + for _, model := range []string{"available/model", "vendor/configured", "available/model"} { + if err := agent.UseModelForNow(model); err != nil { + t.Fatal(err) + } + if agent.Model() != model || agent.ConfiguredModel() != "vendor/configured" { + t.Fatalf("live=%q configured=%q", agent.Model(), agent.ConfiguredModel()) + } + a.SettleWrites() + meta, err := session.LoadMeta(dir) + if err != nil || meta.Model != "vendor/configured" || meta.ModelIdentity == nil || meta.ModelIdentity.Models == nil || meta.ModelIdentity.Models.Main != "vendor/configured" || meta.ModelIdentity.Models.Fallback != "vendor/backup" { + t.Fatalf("saved pair=%+v identity=%+v error=%v", meta, meta.ModelIdentity, err) + } + } + agent.SetModel("available/model") + a.SettleWrites() + meta, err := session.LoadMeta(dir) + if err != nil || meta.Model != "available/model" || agent.ConfiguredModel() != "available/model" { + t.Fatalf("explicit pick=%+v error=%v", meta, err) + } +} + +// C6: an engine without the additive door retains the old setter behavior. +func TestRunOnlyModelFallsBackToTheOlderEngineSetter(t *testing.T) { + for _, refusal := range []string{fmt.Sprintf("engine: no such method %q", MethodUseModelForNow), modelForNowUnsupported} { + t.Run(refusal, func(t *testing.T) { + client, engine := newEngine(t) + engine.fails[MethodUseModelForNow] = refusal + engine.answers[MethodSetModel] = nil + if err := client.Agent().UseModelForNow("available/model"); err != nil { + t.Fatal(err) + } + if client.Agent().Model() != "available/model" { + t.Fatal("older engine setter was not used") + } + if len(engine.calls(MethodUseModelForNow)) != 1 || len(engine.calls(MethodSetModel)) != 1 || client.CallsMade() != 2 { + t.Fatal("older engine did not receive the temporary request followed by SetModel") + } + }) + } +} + +// C2, C6: other failures must never turn a temporary pick into a durable one. +func TestRunOnlyModelFailureDoesNotCallThePersistentSetter(t *testing.T) { + client, engine := newEngine(t) + engine.fails[MethodUseModelForNow] = "connection unavailable" + if err := client.Agent().UseModelForNow("available/model"); err == nil { + t.Fatal("failed selection reported success") + } + if len(engine.calls(MethodUseModelForNow)) != 1 || len(engine.calls(MethodSetModel)) != 0 { + t.Fatal("a failed temporary request reached SetModel") + } +} diff --git a/internal/remote/redial.go b/internal/remote/redial.go index 06721c11f9..07578f7d60 100644 --- a/internal/remote/redial.go +++ b/internal/remote/redial.go @@ -166,6 +166,7 @@ func plural(n int, word string) string { // stream state has been reconciled with what the engine says; false means this // client is over and [Client.bury] has already said why. func (c *Client) lost(cause error) bool { + c.endClientToolCalls() c.closeObservers() c.mu.Lock() roam, closing, dead := c.roam, c.closing, c.dead @@ -210,6 +211,7 @@ func (c *Client) lost(cause error) bool { } c.reconcile(left, welcome) c.retakeLanes(left, welcome) + c.resendClientTools() return true } diff --git a/internal/remote/server.go b/internal/remote/server.go index ec223d499d..07302f61eb 100644 --- a/internal/remote/server.go +++ b/internal/remote/server.go @@ -133,6 +133,8 @@ type WrappedAgent interface { // and the locked-file fallback; this package owns nothing about how an agent is // made and everything about how one is spoken to. type Engine struct { + // ClientTools is the conversation's connection-owned forwarding door. + ClientTools *ClientToolHub // SyncService is the engine's address for chat outage notices. SyncService string // DeltaWindow lets each of this conversation's streams fold streamed text @@ -1199,6 +1201,7 @@ func (sess *Session) attach(s *server, hello Hello) error { // gets around to reporting the end, which may be after the window that replaced // it has already been welcomed (held.go states the whole rule). func (sess *Session) detach(s *server, deliberate bool) { + s.dropClientTools() // THE RAIL'S SUBSCRIPTION GOES WITH THE WINDOW. It is left before anything // else because leaving it is what ends the goroutine pumping frames at a // pipe that is closing (tasklane.go). @@ -1352,6 +1355,7 @@ func (sess *Session) welcomeLocked(s *server) Welcome { } _, workAdmission := sess.agent.(interface{ CheckWork() error }) return Welcome{ + ClientTools: sess.engine.ClientTools != nil, WorkAdmission: workAdmission, UserMessages: users, MessageMarks: messageMarksKnown(sess.agent), @@ -1819,6 +1823,7 @@ func (sess *Session) swap(asked *server, build func() (WrappedAgent, string, boo // ── one connection ────────────────────────────────────────────────────────── type server struct { + clientToolsGone atomic.Bool // write guards the writer. ONE FRAME IS ONE LINE IS ONE WRITE: a torn frame // is not a slow surface, it is a surface that can never parse this stream // again, so every writer on this process goes through here. @@ -1959,6 +1964,7 @@ func (s *server) serve(in io.Reader) (err error) { s.ordered = newOrderedLane() go s.ordered.run(s.dispatch) defer func() { + s.dropClientTools() s.ordered.close() s.ordered.wait() }() @@ -2338,6 +2344,8 @@ func (s *server) invoke(call Frame) (out json.RawMessage, err error) { return out, err } switch call.Method { + case MethodClientToolsSet, MethodClientToolAnswer: + return s.invokeClientTools(call) case MethodProcessList, MethodProcessRerun, MethodProcessPage, MethodProcessRecord, MethodProcessActions, MethodProcessRerunAt, MethodProcessStopAt, MethodProcessStopAll: return serveAppProcesses(agent, call) @@ -3081,6 +3089,7 @@ func (s *server) invoke(call Frame) (out json.RawMessage, err error) { return nil, err case MethodClose: + s.dropClientTools() // The surface said goodbye politely, and it is saying it about the // CONVERSATION rather than about this window — so [server.leave] closes // the session behind it whoever else is attached. The journal is flushed @@ -3607,6 +3616,9 @@ func (s *server) invoke(call Frame) (out json.RawMessage, err error) { // bargain: a surface that meets it says the sentence its place has always // said rather than waiting on a call nobody is going to answer // (wire_places.go states the law). + if payload, handled, err := s.modelForNowCall(agent, call); handled { + return payload, err + } if payload, handled, err := s.placesCall(call); handled { return payload, err } @@ -3846,9 +3858,14 @@ func (s *server) sendLocked(frame Frame) error { } if _, err := s.out.Write(line); err != nil { s.dead = true + s.dropClientTools() return err } - return flushFrame(s.out) + err = flushFrame(s.out) + if err != nil { + s.dropClientTools() + } + return err } func (s *server) hungUp() bool { diff --git a/internal/remote/wire.go b/internal/remote/wire.go index 8828fd46ae..d111307329 100644 --- a/internal/remote/wire.go +++ b/internal/remote/wire.go @@ -393,6 +393,9 @@ import ( // are new. The number moves because a version-21 engine would answer the // place's move as "no such method" — a key that did nothing, and a person told // so in words that were not true. +// +// ClientTools.Set and ClientTool.Answer ride version 22 behind Welcome.ClientTools. +// Like the other welcome capabilities, absence preserves the existing wire. const Version = 22 // AND THE NEWS FRAMES RIDE THAT SAME NUMBER, for the reason the places methods @@ -417,7 +420,8 @@ const Version = 22 type Frame struct { // Kind says what this frame is: "hello", "welcome", "call", "result", // "event", "closed", "facts", "task", "design", "question", "phase", "lane", - // "turn", "driver", "moved", "fatal", "sideAskDelta". + // "turn", "driver", "moved", "fatal", "sideAskDelta", "clientToolCall", + // "clientToolEnded". The two client-tool kinds answer nothing. // // "facts", "task", "design", "question", "phase" and "lane" are the KINDS // THAT ANSWER NOTHING. The third and fourth are version 11's harness lane @@ -1097,6 +1101,8 @@ 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 { + // ClientTools says this holder forwards connection-owned client tools. + ClientTools bool `json:"clientTools,omitempty"` // WorkAdmission says this engine exposes the session's ownership gate. WorkAdmission bool `json:"workAdmission,omitempty"` // UserMessages is counted from the engine's replay, so an attaching app diff --git a/internal/run/crew.go b/internal/run/crew.go index 0b72ea8b72..4fdec7b065 100644 --- a/internal/run/crew.go +++ b/internal/run/crew.go @@ -188,12 +188,11 @@ func crewFactory(store *plandb.Store, workspace, profileDir string, seats Seats, if model == "" { return seatlessWorker{tier: tier} } - // EVERY CALL IS MARKED WITH THE SEAT THE TASK SITS, because this is the - // one place that knows it: the spend guard holds the checker to its own - // ceiling by seat, and a crew whose seats share one model would give it - // nothing else to tell a check's call from a worker's. + // Every call carries its seat and task id so a shared guard gives each + // check its own ceiling, including when seats share a model or a task + // is seated again after a wake. seat, _ := config.CrewTierSeat(tier) - return workerFor(model, session.SeatCompleter(seat, completerFor(model))) + return workerFor(model, session.SeatCompleterFor(seat, task.ID, completerFor(model))) } } diff --git a/internal/run/crew_scope_day_test.go b/internal/run/crew_scope_day_test.go new file mode 100644 index 0000000000..e99791e731 --- /dev/null +++ b/internal/run/crew_scope_day_test.go @@ -0,0 +1,55 @@ +package run + +import ( + "errors" + "testing" + + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/crewroute" + "github.com/Agent-Field/codeaf/internal/plandb" + "github.com/Agent-Field/codeaf/internal/session" +) + +// C1: factory-marked checks on one day-backed model each get their own +// ceiling, even when the preceding check's actual cost overran its ceiling. +func TestCrewFactorySeparatesCheckEstimatesWithASharedDay(t *testing.T) { + store := boundedRunStore(t, "Root work") + if _, err := store.AddMany([]plandb.TaskSpec{ + {ID: "check-a", Title: "First check", Role: plandb.RoleCheck}, + {ID: "check-b", Title: "Second check", Role: plandb.RoleCheck}, + }); err != nil { + t.Fatal(err) + } + guard := &session.SpendGuard{ + Price: func(string) (float64, float64, float64, bool) { return 0, 1e-6, 0, true }, + Day: session.NewSpendDay(0), + Task: &session.SpendTask{}, + SeatCeilings: map[crewroute.Seat]float64{crewroute.Checker: .125}, + CeilingAction: config.CrewCheckCeilingAction, + } + provider := &scopedSpendCompleter{cost: .1875} + factory := CrewFactory(store, t.TempDir(), t.TempDir(), Seats{Check: "vendor/shared"}, "", false, func(model string) session.Completer { + return guard.Wrap(model, provider) + }) + for _, id := range []string{"check-a", "check-b"} { + calls := 1 + if id == "check-b" { + provider.cost, calls = .0625, 2 + } + for i := 0; i < calls; i++ { + worker := factory(*store.Task(id)).(*BashWorker) + if _, err := worker.completer.CompleteWithMessages(t.Context(), nil); err != nil { + t.Fatalf("%s call %d: %v", id, i, err) + } + } + worker := factory(*store.Task(id)).(*BashWorker) + _, err := worker.completer.CompleteWithMessages(t.Context(), nil) + var stopped session.ErrSpendStopped + if !errors.As(err, &stopped) || stopped.TaskLimit || stopped.Action != "the check stopped at its spend ceiling of $0.12, three times its estimate, before it finished" { + t.Fatalf("%s lost its ceiling: %v", id, err) + } + } + if provider.calls != 3 || guard.Task.Total() != .3125 || guard.Day.Total() != .3125 { + t.Fatalf("shared accounting: calls=%d task=%v day=%v", provider.calls, guard.Task.Total(), guard.Day.Total()) + } +} diff --git a/internal/run/crew_scope_test.go b/internal/run/crew_scope_test.go new file mode 100644 index 0000000000..cd4ffc1b6f --- /dev/null +++ b/internal/run/crew_scope_test.go @@ -0,0 +1,117 @@ +package run + +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/plandb" + "github.com/Agent-Field/codeaf/internal/session" +) + +type scopedSpendCompleter struct { + cost float64 + calls int +} + +func (c *scopedSpendCompleter) CompleteWithMessages(context.Context, []ai.Message, ...ai.Option) (*ai.Response, error) { + c.calls++ + cost := c.cost + return &ai.Response{Usage: &ai.Usage{Cost: &cost}}, nil +} + +// C1, C2: the real factory separates two checks on a shared model and keeps +// a check's ceiling when that same task is seated again. +func TestCrewFactoryGivesEachCheckItsOwnCeiling(t *testing.T) { + store := boundedRunStore(t, "Root work") + if _, err := store.AddMany([]plandb.TaskSpec{ + {ID: "check-a", Title: "First check", Role: plandb.RoleCheck}, + {ID: "check-b", Title: "Second check", Role: plandb.RoleCheck}, + }); err != nil { + t.Fatal(err) + } + guard := &session.SpendGuard{ + Price: func(string) (float64, float64, float64, bool) { return 0, 1e-6, 0, true }, + SeatCeilings: map[crewroute.Seat]float64{crewroute.Checker: .125}, + CeilingAction: config.CrewCheckCeilingAction, + } + provider := &scopedSpendCompleter{cost: .0625} + factory := CrewFactory(store, t.TempDir(), t.TempDir(), Seats{Check: "vendor/shared"}, "", false, func(model string) session.Completer { + return guard.Wrap(model, provider) + }) + for _, id := range []string{"check-a", "check-b"} { + for i := 0; i < 2; i++ { + worker := factory(*store.Task(id)).(*BashWorker) + if _, err := worker.completer.CompleteWithMessages(t.Context(), nil); err != nil { + t.Fatalf("%s call %d: %v", id, i, err) + } + } + worker := factory(*store.Task(id)).(*BashWorker) + _, err := worker.completer.CompleteWithMessages(t.Context(), nil) + var stopped session.ErrSpendStopped + if !errors.As(err, &stopped) || stopped.TaskLimit || stopped.Action != "the check stopped at its spend ceiling of $0.12, three times its estimate, before it finished" { + t.Fatalf("%s lost its ceiling: %v", id, err) + } + } + if provider.calls != 4 { + t.Fatalf("provider calls=%d, want 4", provider.calls) + } +} + +// C3: factory-marked work and separate checks reach one task cap. A real +// guard refusal reaches the supervisor as TaskLimit and ends on LimitCost. +func TestScopedCheckSpendEndsTheWholeRunAtTheTaskCap(t *testing.T) { + store := boundedRunStore(t, "Root work") + if _, err := store.AddMany([]plandb.TaskSpec{ + {ID: "work", Title: "Work", Priority: 3}, + {ID: "check-a", Title: "First check", Role: plandb.RoleCheck, Priority: 2}, + {ID: "check-b", Title: "Second check", Role: plandb.RoleCheck, Priority: 1}, + }); err != nil { + t.Fatal(err) + } + guard := &session.SpendGuard{ + Price: func(string) (float64, float64, float64, bool) { return 0, 1e-6, 0, true }, + Day: session.NewSpendDay(0), Cap: .75, CapAction: "day limit", + TaskCap: .5, TaskAction: "task limit", + SeatCeilings: map[crewroute.Seat]float64{crewroute.Checker: .25}, + CeilingAction: config.CrewCheckCeilingAction, + } + provider := &scopedSpendCompleter{} + crew := CrewFactory(store, t.TempDir(), t.TempDir(), Seats{Work: "vendor/work", Plan: "vendor/plan", Check: "vendor/check"}, "", false, func(model string) session.Completer { + return guard.Wrap(model, provider) + }) + taskLimit := false + factory := func(task plandb.Task) Worker { + worker := crew(task).(*BashWorker) + return workerFunc(func(ctx context.Context, task plandb.Task) (Report, error) { + if task.ID == store.RootID() { + return Report{Result: "coordinated"}, nil + } + provider.cost = .125 + if task.ID == "work" { + provider.cost = .25 + } + _, err := worker.completer.CompleteWithMessages(ctx, nil) + if err != nil { + return Report{}, err + } + report := Report{Result: "finished", Steps: 1, USD: provider.cost} + if task.ID == "check-b" { + _, err = worker.completer.CompleteWithMessages(ctx, nil) + var stopped session.ErrSpendStopped + taskLimit = errors.As(err, &stopped) && stopped.TaskLimit + } + return report, err + }) + } + ctx, cancel := context.WithTimeout(t.Context(), 10*time.Second) + defer cancel() + outcome, summary := Start(ctx, Spec{Store: store, Workspace: t.TempDir(), Slots: 1, Factory: factory, Limits: Limits{CostUSD: .5}}) + if outcome != OutcomeLimit || summary.Limit != LimitCost || summary.USD != .5 || !taskLimit || provider.calls != 3 || guard.Day.Total() != .5 || ctx.Err() != nil { + t.Fatalf("shared task cap: outcome=%s summary=%+v taskLimit=%v calls=%d day=%v context=%v", outcome, summary, taskLimit, provider.calls, guard.Day.Total(), ctx.Err()) + } +} diff --git a/internal/run/placeholder_check_test.go b/internal/run/placeholder_check_test.go index 61144fa7e1..3f83462f07 100644 --- a/internal/run/placeholder_check_test.go +++ b/internal/run/placeholder_check_test.go @@ -124,8 +124,9 @@ func TestAFanOutPlannerIsSentBackFromAPlaceholderCheckAndNoFixIsSpawned(t *testi t.Errorf("part %s has checks %q, want its own %s", task.Title, task.Checks, file) } } - if parts != 20 { - t.Errorf("part tasks = %d, want 20", parts) + // L-LOOP2 C3d: valid checks still consume the planner's worker allowance. + if parts != 5 { + t.Errorf("part tasks = %d, want 5", parts) } if outcome != run.OutcomeDone { t.Errorf("outcome = %q, want done; summary=%+v", outcome, summary) diff --git a/internal/run/plandb_path_test.go b/internal/run/plandb_path_test.go index 7f55db2330..37c349ff68 100644 --- a/internal/run/plandb_path_test.go +++ b/internal/run/plandb_path_test.go @@ -138,6 +138,7 @@ func TestBashWorkerResolvesTheRunsPlandbOverAHostOne(t *testing.T) { // The command cd's to a fresh directory that holds no store and no ancestor // store, so only the bound PLANDB_DB can put the row in the run's plan — and the // assertion reads the run's store, not the CLI's stdout. +// L-LOOP2 C3a: a changed cwd does not change the worker's default parent. func TestBashWorkerPlandbWritesTheRunsStoreFromAnyCwd(t *testing.T) { t.Setenv("CODEAF_TASK_BELT", "bash") t.Setenv("CODEAF_PLANDB_BIN", realPlandbDoor(t)) @@ -145,8 +146,8 @@ func TestBashWorkerPlandbWritesTheRunsStoreFromAnyCwd(t *testing.T) { store := runOpenStore(t) storeDir := filepath.Dir(store.Path()) // THE WORKER RUNS ON A LEAF, because this test's own first command adds a - // task under the root: a root with an open child cannot finish, and the - // leaf's own `plandb done` is what ends the loop. + // task under itself. It parks after the handoff; the supervisor would wake + // that composite only after its new child lands. if _, err := store.AddMany([]plandb.TaskSpec{leafDone("outside")}); err != nil { t.Fatalf("add the leaf: %v", err) } @@ -160,7 +161,7 @@ func TestBashWorkerPlandbWritesTheRunsStoreFromAnyCwd(t *testing.T) { return toolReply(bashArguments(t, command)), nil }, func(context.Context, []ai.Message) (*ai.Response, error) { - return toolReply(finishCommand("outside", "added the task")), nil + return toolReply(bashArguments(t, "plandb wait --agent outside")), nil }, }} worker := run.NewBashWorker(store, t.TempDir(), "test/model", seat) @@ -179,6 +180,9 @@ func TestBashWorkerPlandbWritesTheRunsStoreFromAnyCwd(t *testing.T) { found := false for _, task := range fresh.Tasks() { if task.Title == title { + if task.ParentID != "outside" || task.AddedByTask != "outside" { + t.Fatalf("outside-cwd addition escaped its worker: %+v", task) + } found = true break } diff --git a/internal/run/run.go b/internal/run/run.go index 179f4d1e9f..d0899474ac 100644 --- a/internal/run/run.go +++ b/internal/run/run.go @@ -12,6 +12,7 @@ import ( "time" "github.com/Agent-Field/codeaf/internal/plandb" + "github.com/Agent-Field/codeaf/internal/session" ) // The outcome words are the exit ladder's own (cmd/codeaf/envelope.go, the @@ -185,9 +186,12 @@ type Supervisor struct { // elapsed signal in Run, the spend counters in countLiveSpend and // settleSpend), so the ending can name the limit that caused it rather // than the one sentence both share. - limitHit Limit - dispatchedRoot bool - cancels map[string]context.CancelFunc + limitHit Limit + // taskSpendStopped preserves a task-wide pre-call refusal over tree + // completion without changing existing cost or elapsed-limit precedence. + taskSpendStopped bool + dispatchedRoot bool + cancels map[string]context.CancelFunc // cut is every task whose worker came home with the run's own ending as its // error (a context the run ended: its elapsed wall, its spend ceiling, or a // person's stop), by store id. It is the typed fact that says which rows the @@ -294,6 +298,7 @@ func (s *Supervisor) Run(ctx context.Context) Outcome { s.rootFailed = false s.rootCut = false s.limitHit = "" + s.taskSpendStopped = false s.cut = make(map[string]bool) s.dispatchedRoot = false s.inFlight = 0 @@ -416,7 +421,7 @@ func (s *Supervisor) pass(ctx context.Context, rootID string) Outcome { // process's own touch and is left alone. s.touchClaims() s.releaseStale() - if terminalStatus(root.Status) { + if terminalStatus(root.Status) && !s.taskSpendStopped { // A ROOT WORKER CAN WRITE DONE BEFORE ITS GOROUTINE RETURNS. Its return // is what seats the review, so do not accept the stored ending first. // Other in-flight workers may be remnants of an already completed tree; @@ -469,7 +474,7 @@ func (s *Supervisor) pass(ctx context.Context, rootID string) Outcome { } return OutcomeIncomplete } - if !s.full() && !s.dispatchedRoot && s.mayStart(rootID) { + if s.limitHit == "" && !s.full() && !s.dispatchedRoot && s.mayStart(rootID) { // The root is the first worker of the run. It is claimed by the runtime // in the store, so it needs no claim here — only a seat. s.dispatchedRoot = true @@ -614,6 +619,11 @@ func (s *Supervisor) drain() { ret := <-s.finished s.inFlight-- s.returned() + // Work kept for a later run has no worker now. Release only this + // supervisor's remaining claim so it reads queued, not running. + if task := s.store.Task(ret.task.ID); task != nil && task.ClaimedBy == ret.task.ID && task.Owner == s.Owner { + _, _ = s.store.Release(task.ID, ret.task.ID) + } // THE ENDING IS DROPPED, BUT THE FACT OF WHO IT CUT IS NOT: a worker // that came home with the run's own ending as its error was taken down // by that ending, and the run records it where it knows ([Summary.Cut]). @@ -830,6 +840,16 @@ func (s *Supervisor) absorbQueued() { // that stays open would stall the whole run: nothing else can make it // terminal, and the run would wait on it forever. func (s *Supervisor) absorb(ret workerReturn) { + var stopped session.ErrSpendStopped + if errors.As(ret.err, &stopped) && stopped.TaskLimit { + s.taskSpendStopped = true + if s.limitHit == "" { + s.limitHit = LimitCost + } + for _, cancel := range s.cancels { + cancel() + } + } if s.onWorker != nil { defer s.onWorker(ret.task.ID, false) } @@ -1247,7 +1267,7 @@ func descriptionWithChecks(base string, checks []string) string { // reported. A root whose own worker failed stays open — the run cannot // complete itself, and a later pass may pick it up. func (s *Supervisor) completeTree() { - if s.rootFailed { + if s.rootFailed || s.taskSpendStopped { return } // THE ROOT IS NOT COMPLETED WHILE A WAKE IS STILL OWED IT: its children @@ -1749,17 +1769,28 @@ func (s *Supervisor) cutIDs() []string { // wakeClause is the resume clause a woken parent's worker opens with: what every // child it dispatched reported, verbatim, and the instruction to integrate and -// verify the combined result. It is the same sentence for the root and for every -// other parent — the law does not turn on who owns the run. +// verify the combined result. Engine-added work is reported separately so the +// worker never treats another assignment as one of its own dispatches. func wakeClause(tasks []*plandb.Task, task plandb.Task) string { var b strings.Builder b.WriteString("every child you dispatched has landed. Integrate their results, verify the combined result in the workspace, add more children if something is missing, and report.") for _, child := range tasks { - if child.ParentID != task.ID { + if child.ParentID != task.ID || child.AddedByTask != task.ID { continue } fmt.Fprintf(&b, "\n- child %s %q is %s: %s", child.ID, child.Title, child.Status, resultOrNoResult(child.Result)) } + other := false + for _, child := range tasks { + if child.ParentID != task.ID || child.AddedByTask == task.ID { + continue + } + if !other { + b.WriteString("\nOther work you did not dispatch; do not redo it:") + other = true + } + fmt.Fprintf(&b, "\n- task %s %q is %s: %s", child.ID, child.Title, child.Status, resultOrNoResult(child.Result)) + } return b.String() } diff --git a/internal/run/task_bounds_test.go b/internal/run/task_bounds_test.go new file mode 100644 index 0000000000..7eabdc5d6e --- /dev/null +++ b/internal/run/task_bounds_test.go @@ -0,0 +1,232 @@ +package run + +import ( + "context" + "fmt" + "path/filepath" + "strings" + "sync/atomic" + "testing" + "time" + "unicode" + + "github.com/Agent-Field/codeaf/internal/plandb" + "github.com/Agent-Field/codeaf/internal/session" +) + +func boundedRunStore(t *testing.T, title string) *plandb.Store { + t.Helper() + s, err := plandb.Open(filepath.Join(t.TempDir(), "plan.db"), "bounded-run", "root", title, "the assigned work") + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = s.Close() }) + return s +} + +// L-LOOP C3e: replay the recursive red/green/blue adds through the real CLI. +func TestRepeatedTaskHandoffsCannotFanOutTheRun(t *testing.T) { + store := boundedRunStore(t, "Create green.txt") + t.Setenv(plandb.RunEnv, store.RootID()) + t.Setenv("PLANDB_DB", store.Path()) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + var accepted, refused, launches atomic.Int32 + factory := func(task plandb.Task) Worker { + launches.Add(1) + return workerFunc(func(ctx context.Context, task plandb.Task) (Report, error) { + if task.Role == plandb.RoleCheck { + return Report{Result: "holds: the work was read", Steps: 1}, nil + } + for _, title := range []string{"Create red.txt", "Create green.txt", "Create blue.txt"} { + if code := plandb.Main([]string{"add", title, "--parent", "t-" + task.ID}); code == 0 { + accepted.Add(1) + } else { + refused.Add(1) + } + } + return Report{Result: "did my own work", Steps: 1}, nil + }) + } + outcome, summary := Start(ctx, Spec{Store: store, Workspace: t.TempDir(), Slots: 1, Factory: factory, Limits: Limits{ReviewRound: true}}) + if outcome != OutcomeDone || summary.Limit != "" || ctx.Err() != nil { + t.Fatalf("bounded replay did not end normally: %s, %+v, %v", outcome, summary, ctx.Err()) + } + seen := map[string]bool{} + counted := 0 + for _, task := range store.Tasks() { + if task.Status == plandb.StatusCancelled { + continue + } + key := strings.Join(strings.FieldsFunc(strings.ToLower(task.Title), func(r rune) bool { + return !unicode.IsLetter(r) && !unicode.IsDigit(r) + }), " ") + if seen[key] { + t.Fatalf("duplicate title in the finished store: %s", task.Title) + } + seen[key] = true + if task.ID != store.RootID() && task.Role != plandb.RoleCheck { + counted++ + } + depth := 0 + for parent := task; parent.ID != store.RootID(); parent = store.Task(parent.ParentID) { + depth++ + if depth > plandb.TreeDepthLimit { + t.Fatalf("task %s exceeds the depth limit", task.ID) + } + } + } + // Two accepted children, their checks, and the root's initial/waking turns. + if accepted.Load() != 2 || refused.Load() < 6 || counted != 2 || counted > plandb.TreeTaskLimit || launches.Load() != 6 { + t.Fatalf("replay admitted refused work: accepted=%d refused=%d counted=%d launches=%d", accepted.Load(), refused.Load(), counted, launches.Load()) + } +} + +// L-LOOP C4b, C4c: a typed pre-call stop ends peers and never launches the +// next ready task, even though the run's metered total is below its ceiling. +func TestTaskSpendRefusalStopsTheWholeRun(t *testing.T) { + store := boundedRunStore(t, "Root work") + if _, err := store.AddMany([]plandb.TaskSpec{{ID: "peer", Title: "Peer work"}}); err != nil { + t.Fatal(err) + } + peerStarted := make(chan struct{}) + var launches, peersEnded atomic.Int32 + factory := func(task plandb.Task) Worker { + launches.Add(1) + return workerFunc(func(ctx context.Context, task plandb.Task) (Report, error) { + switch task.ID { + case "root": + select { + case <-peerStarted: + case <-ctx.Done(): + return Report{}, ctx.Err() + } + if _, err := store.AddMany([]plandb.TaskSpec{{ID: "later", Title: "Later work"}}); err != nil { + return Report{}, err + } + return Report{}, fmt.Errorf("seat ended: %w", session.ErrSpendStopped{Action: "task cap", TaskLimit: true}) + case "peer": + close(peerStarted) + <-ctx.Done() + peersEnded.Add(1) + return Report{}, ctx.Err() + default: + return Report{Result: "must never start"}, nil + } + }) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + outcome, summary := Start(ctx, Spec{Store: store, Workspace: t.TempDir(), Slots: 2, Factory: factory, Limits: Limits{CostUSD: 5}}) + if outcome != OutcomeLimit || summary.Limit != LimitCost || summary.USD != 0 || launches.Load() != 2 || peersEnded.Load() != 1 || ctx.Err() != nil { + t.Fatalf("task-cap refusal did not stop the tree: %s %+v launches=%d peers ended=%d context=%v", outcome, summary, launches.Load(), peersEnded.Load(), ctx.Err()) + } +} + +// L-LOOP C4c: a checker's own ceiling fails that task and leaves work going. +func TestSeatSpendRefusalLeavesOtherTasksRunning(t *testing.T) { + store := boundedRunStore(t, "Root work") + if _, err := store.AddMany([]plandb.TaskSpec{{ID: "check", Title: "Check", Role: plandb.RoleCheck}, {ID: "work", Title: "Work"}}); err != nil { + t.Fatal(err) + } + var workStarted atomic.Bool + factory := func(task plandb.Task) Worker { + return workerFunc(func(context.Context, plandb.Task) (Report, error) { + if task.ID == "check" { + return Report{}, fmt.Errorf("seat ended: %w", session.ErrSpendStopped{Action: "checker ceiling"}) + } + if task.ID == "work" { + workStarted.Store(true) + } + return Report{Result: "did my work", Steps: 1}, nil + }) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + outcome, summary := Start(ctx, Spec{Store: store, Workspace: t.TempDir(), Slots: 1, Factory: factory, Limits: Limits{CostUSD: 5}}) + if outcome == OutcomeLimit || summary.Limit != "" || !workStarted.Load() || store.Task("check").Status != plandb.StatusFailed || ctx.Err() != nil { + t.Fatalf("seat ceiling stopped other work: %s %+v work started=%v check=%+v context=%v", outcome, summary, workStarted.Load(), store.Task("check"), ctx.Err()) + } +} + +// L-LOOP C4c: the tree's cap can refuse a review call after the work landed. +func TestTaskSpendRefusalInTheReviewRoundStillEndsOnCost(t *testing.T) { + store := boundedRunStore(t, "Single task") + factory := func(task plandb.Task) Worker { + return workerFunc(func(context.Context, plandb.Task) (Report, error) { + if task.Role == plandb.RoleCheck { + return Report{}, session.ErrSpendStopped{Action: "task cap", TaskLimit: true} + } + return Report{Result: "finished the work", Steps: 1}, nil + }) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + outcome, summary := Start(ctx, Spec{Store: store, Workspace: t.TempDir(), Slots: 1, Factory: factory, Limits: Limits{CostUSD: 5, ReviewRound: true}}) + if outcome != OutcomeLimit || summary.Limit != LimitCost || ctx.Err() != nil { + t.Fatalf("review's task-cap refusal was lost: %s %+v context=%v", outcome, summary, ctx.Err()) + } +} + +// L-LOOP C6: an ordinary single task still lands after its review, under cap. +func TestSingleTaskUnderTreeCeilingStillRunsAndChecks(t *testing.T) { + store := boundedRunStore(t, "Single task") + var checks atomic.Int32 + factory := func(task plandb.Task) Worker { + return workerFunc(func(context.Context, plandb.Task) (Report, error) { + if task.Role == plandb.RoleCheck { + checks.Add(1) + return Report{Result: "holds: read the result", Steps: 1, USD: .01}, nil + } + return Report{Result: "finished the work", Steps: 1, USD: .02}, nil + }) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + outcome, summary := Start(ctx, Spec{Store: store, Workspace: t.TempDir(), Slots: 1, Factory: factory, Limits: Limits{CostUSD: 5, ReviewRound: true}}) + if outcome != OutcomeDone || summary.Limit != "" || summary.Result != "finished the work" || checks.Load() != 1 || ctx.Err() != nil { + t.Fatalf("ordinary task changed: %s %+v checks=%d context=%v", outcome, summary, checks.Load(), ctx.Err()) + } +} + +// L-LOOP C6: a final successful receipt retains the existing completion +// precedence even when that receipt reaches the ordinary cost limit. +func TestFinishedChildlessRunAtCostLimitStillEndsDone(t *testing.T) { + store := boundedRunStore(t, "Single task") + factory := func(plandb.Task) Worker { + return workerFunc(func(context.Context, plandb.Task) (Report, error) { + return Report{Result: "finished", USD: 1}, nil + }) + } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + outcome, summary := Start(ctx, Spec{Store: store, Workspace: t.TempDir(), Slots: 1, Factory: factory, Limits: Limits{CostUSD: 1}}) + if outcome != OutcomeDone || summary.Result != "finished" || summary.USD != 1 || summary.Limit != LimitCost || store.Task(store.RootID()).Status != plandb.StatusDone || ctx.Err() != nil { + t.Fatalf("finished work lost to its final receipt: %s %+v root=%+v context=%v", outcome, summary, store.Task(store.RootID()), ctx.Err()) + } +} + +// L-LOOP C6: the original pass accepts a completed root after the elapsed +// signal drains a successful worker return. Drive that ordering explicitly. +func TestCompletedRootStillWinsOverTheElapsedSignal(t *testing.T) { + store := boundedRunStore(t, "Single task") + elapsed := make(chan time.Time) + factory := func(plandb.Task) Worker { + return workerFunc(func(ctx context.Context, _ plandb.Task) (Report, error) { + if err := store.CompleteRoot("finished"); err != nil { + return Report{}, err + } + close(elapsed) + <-ctx.Done() + return Report{Result: "finished"}, nil + }) + } + supervisor := NewSupervisor(store, t.TempDir(), 1, Limits{Elapsed: time.Hour}, factory) + supervisor.after = func(time.Duration) <-chan time.Time { return elapsed } + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + outcome := supervisor.Run(ctx) + if outcome != OutcomeDone || supervisor.limitHit != LimitTime || supervisor.rootResult != "finished" || store.Task(store.RootID()).Status != plandb.StatusDone || ctx.Err() != nil { + t.Fatalf("completed root lost to elapsed signal: %s limit=%s result=%q root=%+v context=%v", outcome, supervisor.limitHit, supervisor.rootResult, store.Task(store.RootID()), ctx.Err()) + } +} diff --git a/internal/run/testmain_test.go b/internal/run/testmain_test.go index 6e211c2ad3..e3fdc923b3 100644 --- a/internal/run/testmain_test.go +++ b/internal/run/testmain_test.go @@ -25,5 +25,6 @@ func TestMain(m *testing.M) { } _ = os.Unsetenv("PLANDB_DB") _ = os.Unsetenv("PLANDB_RUN") + _ = os.Unsetenv("PLANDB_TASK") os.Exit(m.Run()) } diff --git a/internal/run/wake_test.go b/internal/run/wake_test.go index 75f0c3df52..68568c3fc4 100644 --- a/internal/run/wake_test.go +++ b/internal/run/wake_test.go @@ -27,6 +27,37 @@ func usdClose(got, want float64) bool { return diff < 1e-9 && diff > -1e-9 } +// L-LOOP2 C1, C6: only the woken worker's own additions are dispatched work. +func TestRootWakeSeparatesJoinedWorkFromDispatchedChildren(t *testing.T) { + store := startOpenStore(t, "Run") + seat := newFakeSeat() + calls := 0 + clause := "" + seat.actions["root"] = func(ctx context.Context, task plandb.Task) (run.Report, error) { + calls++ + if calls == 1 { + if _, err := store.AddManyForWorker(task.ID, []plandb.TaskSpec{{ID: "own", ParentID: task.ID, Title: "Dispatched work"}}); err != nil { + return run.Report{}, err + } + _, err := store.AddJoinedMany([]plandb.TaskSpec{{ID: "joined", ParentID: task.ID, Title: "Independent assignment"}}, false) + return run.Report{Result: "handed out my part", Steps: 1}, err + } + clause = run.WakeClause(ctx) + return run.Report{Result: "integrated my part", Steps: 1}, nil + } + outcome, _ := run.Start(runContext(t), run.Spec{Store: store, Workspace: t.TempDir(), Slots: 1, Factory: seat.workerFor}) + if outcome != run.OutcomeDone || calls != 2 { + t.Fatalf("root did not wake once: outcome=%s calls=%d", outcome, calls) + } + dispatched, other, _ := strings.Cut(clause, "Other work you did not dispatch; do not redo it:") + if !strings.Contains(dispatched, "every child you dispatched") || !strings.Contains(dispatched, "Dispatched work") || strings.Contains(dispatched, "Independent assignment") { + t.Fatalf("wake misattributes dispatched work: %s", clause) + } + if strings.Contains(clause, "Independent assignment") && !strings.Contains(other, "Independent assignment") { + t.Fatalf("joined work was not identified as independent: %s", clause) + } +} + // TestSupervisorWakesARootWithItsChildrensResults is the law's first half: the // root splits into two leaves, both land, and the root is run a second time // with an opening that carries both leaves' titles, statuses and results. The @@ -45,7 +76,7 @@ func TestSupervisorWakesARootWithItsChildrensResults(t *testing.T) { defer mu.Unlock() calls++ if calls == 1 { - _, err := store.AddMany([]plandb.TaskSpec{ + _, err := store.AddManyForWorker(task.ID, []plandb.TaskSpec{ {ID: "l1", Title: "the first leaf", ParentID: task.ID}, {ID: "l2", Title: "the second leaf", ParentID: task.ID}, }) @@ -123,14 +154,14 @@ func TestSupervisorWakesARootAgainWhenItAddsAChild(t *testing.T) { calls++ switch calls { case 1: - _, err := store.AddMany([]plandb.TaskSpec{ + _, err := store.AddManyForWorker(task.ID, []plandb.TaskSpec{ {ID: "l1", Title: "leaf one", ParentID: task.ID}, {ID: "l2", Title: "leaf two", ParentID: task.ID}, }) return run.Report{Result: "split into two", Steps: 1}, err case 2: clauses = append(clauses, run.WakeClause(ctx)) - _, err := store.AddMany([]plandb.TaskSpec{{ID: "l3", Title: "the gap leaf", ParentID: task.ID}}) + _, err := store.AddManyForWorker(task.ID, []plandb.TaskSpec{{ID: "l3", Title: "the gap leaf", ParentID: task.ID}}) return run.Report{Result: "integrated two, added a third", Steps: 1}, err default: clauses = append(clauses, run.WakeClause(ctx)) @@ -183,7 +214,7 @@ func TestSupervisorStopsWakingAParentAtTheCap(t *testing.T) { calls++ // One more leaf every turn: without a cap the root would never stop. id := fmt.Sprintf("l%d", calls) - _, err := store.AddMany([]plandb.TaskSpec{{ID: id, Title: "leaf " + id, ParentID: task.ID}}) + _, err := store.AddManyForWorker(task.ID, []plandb.TaskSpec{{ID: id, Title: "leaf " + id, ParentID: task.ID}}) return run.Report{Result: fmt.Sprintf("call %d", calls), Steps: 1}, err } @@ -258,7 +289,7 @@ func TestSupervisorWakesACoordinatorBelowTheRootAgainWhenItAddsAChild(t *testing defer mu.Unlock() rootCalls++ if rootCalls == 1 { - _, err := store.AddMany([]plandb.TaskSpec{{ID: "m", Title: "the coordinator", ParentID: task.ID}}) + _, err := store.AddManyForWorker(task.ID, []plandb.TaskSpec{{ID: "m", Title: "the coordinator", ParentID: task.ID}}) return run.Report{Result: "split into a coordinator", Steps: 1}, err } return run.Report{Result: "root integrated: " + store.Task("m").Result, Steps: 1}, nil @@ -269,7 +300,7 @@ func TestSupervisorWakesACoordinatorBelowTheRootAgainWhenItAddsAChild(t *testing midCalls++ switch midCalls { case 1: - _, err := store.AddMany([]plandb.TaskSpec{ + _, err := store.AddManyForWorker(task.ID, []plandb.TaskSpec{ {ID: "c1", Title: "child one", ParentID: task.ID}, {ID: "c2", Title: "child two", ParentID: task.ID}, }) @@ -279,7 +310,7 @@ func TestSupervisorWakesACoordinatorBelowTheRootAgainWhenItAddsAChild(t *testing // reports showed up as missing — which the store only lets it do // because it left the parent open. clauses = append(clauses, run.WakeClause(ctx)) - _, err := store.AddMany([]plandb.TaskSpec{{ID: "c3", Title: "the gap child", ParentID: task.ID}}) + _, err := store.AddManyForWorker(task.ID, []plandb.TaskSpec{{ID: "c3", Title: "the gap child", ParentID: task.ID}}) if err != nil { t.Errorf("a woken coordinator could not add a child: %v", err) } @@ -345,7 +376,7 @@ func TestSupervisorWakesAParentWhoseChildrenLandedFirst(t *testing.T) { defer mu.Unlock() rootCalls++ if rootCalls == 1 { - _, err := store.AddMany([]plandb.TaskSpec{{ID: "m", Title: "the coordinator", ParentID: task.ID}}) + _, err := store.AddManyForWorker(task.ID, []plandb.TaskSpec{{ID: "m", Title: "the coordinator", ParentID: task.ID}}) return run.Report{Result: "split into a coordinator", Steps: 1}, err } return run.Report{Result: "root integrated: " + store.Task("m").Result, Steps: 1}, nil @@ -355,7 +386,7 @@ func TestSupervisorWakesAParentWhoseChildrenLandedFirst(t *testing.T) { defer mu.Unlock() midCalls++ if midCalls == 1 { - _, err := store.AddMany([]plandb.TaskSpec{ + _, err := store.AddManyForWorker(task.ID, []plandb.TaskSpec{ {ID: "c1", Title: "child one", ParentID: task.ID}, {ID: "c2", Title: "child two", ParentID: task.ID}, }) @@ -416,7 +447,7 @@ func TestSupervisorKeepsACappedParentsOwnReport(t *testing.T) { mu.Lock() defer mu.Unlock() if store.Task("m") == nil { - _, err := store.AddMany([]plandb.TaskSpec{{ID: "m", Title: "the coordinator", ParentID: task.ID}}) + _, err := store.AddManyForWorker(task.ID, []plandb.TaskSpec{{ID: "m", Title: "the coordinator", ParentID: task.ID}}) return run.Report{Result: "split into a coordinator", Steps: 1}, err } return run.Report{Result: "root integrated: " + store.Task("m").Result, Steps: 1}, nil @@ -428,7 +459,7 @@ func TestSupervisorKeepsACappedParentsOwnReport(t *testing.T) { // One more child every turn: without the cap this coordinator would // never stop being woken. id := fmt.Sprintf("c%d", midCalls) - _, err := store.AddMany([]plandb.TaskSpec{{ID: id, Title: "child " + id, ParentID: task.ID}}) + _, err := store.AddManyForWorker(task.ID, []plandb.TaskSpec{{ID: id, Title: "child " + id, ParentID: task.ID}}) return run.Report{Result: "the coordinator's report " + fmt.Sprint(midCalls), Steps: 1}, err } diff --git a/internal/run/worker_stop_test.go b/internal/run/worker_stop_test.go new file mode 100644 index 0000000000..89d49ad666 --- /dev/null +++ b/internal/run/worker_stop_test.go @@ -0,0 +1,73 @@ +package run + +import ( + "context" + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/plandb" + "github.com/Agent-Field/codeaf/internal/session" +) + +// L-LOOP2 C8a: a root worker returning after the stop cannot reopen its done +// root for review. The stopped tree still accounts for its paid receipt. +func TestStoppedRunRejectsALateRootWorkersReview(t *testing.T) { + store := boundedRunStore(t, "Single task") + if err := store.CompleteRoot("already finished"); err != nil { + t.Fatal(err) + } + if err := store.StopRoot("stopped"); err != nil { + t.Fatal(err) + } + supervisor := NewSupervisor(store, t.TempDir(), 1, Limits{ReviewRound: true}, nil) + supervisor.absorb(workerReturn{task: *store.Task(store.RootID()), report: Report{Result: "late result", USD: .01}, err: context.Canceled}) + if len(store.Tasks()) != 1 || store.Task(store.RootID()).Status != plandb.StatusDone || store.Task(store.RootID()).Result != "already finished" || supervisor.spent != .01 { + t.Fatalf("late return reopened or rewrote stopped work: %+v spent=%v", store.Tasks(), supervisor.spent) + } +} + +// L-LOOP2 C8, C6: unabsorbed work retained for continuation is queued once +// its worker is home; its report is still dropped and its receipt is counted. +func TestDrainedWorkerReleasesItsClaimWithoutDiscardingRetainedWork(t *testing.T) { + store := boundedRunStore(t, "Run") + if _, err := store.AddMany([]plandb.TaskSpec{{ID: "part", Title: "Retained work"}}); err != nil { + t.Fatal(err) + } + supervisor := NewSupervisor(store, t.TempDir(), 1, Limits{}, nil) + if _, err := store.Claim("part", "part", supervisor.Owner); err != nil { + t.Fatal(err) + } + supervisor.inFlight = 1 + supervisor.finished <- workerReturn{task: *store.Task("part"), report: Report{Result: "dropped result", USD: .01}, err: context.Canceled} + supervisor.drain() + if task := store.Task("part"); task.Status != plandb.StatusReady || task.ClaimedBy != "" || task.Owner != "" || task.Result != "" || supervisor.spent != .01 { + t.Fatalf("drain kept a running claim or discarded retained work: %+v spent=%v", task, supervisor.spent) + } + t.Setenv("CODEAF_TASK_BELT", "bash") + t.Setenv("CODEAF_PLANDB_BIN", filepath.Join(t.TempDir(), "stub-codeaf")) + agent, err := session.NewBeltWorker(session.Config{Workspace: t.TempDir(), Model: "test/model"}, &crewCallProbe{}, store.Task("part"), store.Path(), store.RootID()) + if err != nil { + t.Fatal(err) + } + defer agent.Close() + page, ok := agent.PlanTaskPage("t-part") + if !ok || page.Row.Status != "ready" || page.Row.StateWord() != "queued" { + t.Fatalf("drained task's page still reads running: %+v, found=%v", page.Row, ok) + } + views, err := agent.AppTasks() + if err != nil { + t.Fatal(err) + } + found := false + for _, view := range views { + if view.ID == "t-part" { + found = true + if view.State != "waiting" { + t.Fatalf("drained task's app row reads %s, want waiting", view.State) + } + } + } + if !found { + t.Fatal("drained task disappeared from the app listing") + } +} diff --git a/internal/run/workerbound_replay_test.go b/internal/run/workerbound_replay_test.go new file mode 100644 index 0000000000..78d31c4e14 --- /dev/null +++ b/internal/run/workerbound_replay_test.go @@ -0,0 +1,88 @@ +package run_test + +import ( + "context" + "fmt" + "strings" + "sync" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/plandb" + "github.com/Agent-Field/codeaf/internal/run" +) + +// L-LOOP2 C1c, C3a, C3d, C3e: replay one real-shaped chained bash call as the +// first step of each worker, with renamed work bypassing the title guard. +func TestWorkerFirstStepCannotRecreateSiblingTasksAtTheRunRoot(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + t.Setenv("CODEAF_PLANDB_BIN", realPlandbDoor(t)) + store := startOpenStore(t, "Create the red file") + workspace := t.TempDir() + var mu sync.Mutex + starts := map[string]int{} + depthRefusals := 0 + factory := func(task plandb.Task) run.Worker { + mu.Lock() + starts[task.ID]++ + first := starts[task.ID] == 1 + mu.Unlock() + command := `plandb add "Create red.txt" --description "Write 'red' into red.txt" && plandb add "Create green.txt" --description "Write green into green.txt" && plandb add "Create blue.txt" --description "Write blue into blue.txt"` + if task.ID != store.RootID() { + command = fmt.Sprintf(`plandb add "Create the green file for %s" --description "Write 'green' into green.txt" && plandb add "Make blue.txt for %s" --description "Write blue into blue.txt" && plandb add "Make the red file for %s" --description "Write red into red.txt"`, task.ID, task.ID, task.ID) + } + calls := 0 + completer := &seat{ever: func(_ context.Context, messages []ai.Message) (*ai.Response, error) { + calls++ + if first && calls == 1 { + return toolReply(bashArguments(t, command)), nil + } + for _, message := range messages { + if strings.Contains(messageContent(message), "2 levels below") { + mu.Lock() + depthRefusals++ + mu.Unlock() + break + } + } + for _, child := range store.Tasks() { + if child.ParentID == task.ID && child.Status != plandb.StatusDone && child.Status != plandb.StatusFailed && child.Status != plandb.StatusCancelled { + return toolReply(bashArguments(t, "plandb wait "+task.ID+" --agent "+task.ID)), nil + } + } + return toolReply(finishCommand(task.ID, "finished my own work")), nil + }} + return run.NewBashWorker(store, workspace, "test/model", completer) + } + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + outcome, summary := run.Start(ctx, run.Spec{Store: store, Workspace: workspace, Title: "Create the red file", Brief: "Write red into red.txt", Slots: 1, Factory: factory, Limits: run.Limits{StepsPerTask: 4}}) + if outcome != run.OutcomeDone || ctx.Err() != nil { + t.Fatalf("replay did not end: %s %+v %v", outcome, summary, ctx.Err()) + } + added := map[string]int{} + tasks := store.Tasks() + for _, task := range tasks { + if task.ID == store.RootID() { + continue + } + if task.AddedByTask == "" || task.ParentID != task.AddedByTask { + t.Fatalf("worker added outside its own task: %+v", task) + } + added[task.AddedByTask]++ + if added[task.AddedByTask] > 5 { + t.Fatalf("one worker handed out more than five: %+v", added) + } + depth := 0 + for parent := task; parent.ID != store.RootID(); parent = store.Task(parent.ParentID) { + depth++ + if depth > 2 { + t.Fatalf("worker escaped the depth bound: %+v", task) + } + } + } + if len(tasks) != 13 || len(tasks) > 20 || added[store.RootID()] != 3 || depthRefusals == 0 { + t.Fatalf("replay missed recursive renamed work or a real depth refusal: tasks=%d added=%v refusals=%d", len(tasks), added, depthRefusals) + } +} diff --git a/internal/session/abandon.go b/internal/session/abandon.go index b62f39d99c..0fbbeacb41 100644 --- a/internal/session/abandon.go +++ b/internal/session/abandon.go @@ -37,9 +37,10 @@ import ( // // THE LINE IS THE ACCEPTANCE AND NOT THE DECORATION. A turn that is let go of // while it may still be spending is a turn nobody could otherwise account for: -// the seal that carries a turn's cost is written when a turn ENDS, and the whole -// definition of an abandoned turn is that nobody waited for its end. The money -// itself already reaches the machine's ledger per call rather than per turn +// the abandoned line records the last-known spend while nobody waits for the end. +// The displaced loop still writes its conversation-step usage seal on exit; +// errands already have their own seals. This line remains evidence only. The +// money itself reaches the machine's ledger per call rather than per turn // (usage_ledger.go, issue #269), so the bill is not lost — what was lost was any // record that a turn had been let go of at all. // @@ -272,8 +273,8 @@ const toolAbandonedWord = "the person stopped this turn and it was not waited fo // abandoned reports that the turn this context belongs to has been let go of. // -// IT IS A NON-BLOCKING READ and it is the answer to "may I still write?". A turn -// that has been abandoned must not touch the session's transcript again: the +// IT IS A NON-BLOCKING READ and it answers "may I still write messages?". A turn +// that has been abandoned must not append to the session's messages again: the // session has moved on and a.messages belongs to whatever turn the person // started next, so an append from here is one conversation growing another's // rows. False is the ordinary answer and false is what every context with no diff --git a/internal/session/abandon_spend_test.go b/internal/session/abandon_spend_test.go new file mode 100644 index 0000000000..9fce93a0ff --- /dev/null +++ b/internal/session/abandon_spend_test.go @@ -0,0 +1,464 @@ +package session + +import ( + "context" + "encoding/json" + "math" + "path/filepath" + "reflect" + "sync" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/provider" +) + +// Contract A1, A2: abandoned steps survive a restart, and a later stamp grows +// from .26 to .27 rather than forgetting the abandoned .25. +func TestAnAbandonedTurnsStepSpendSurvivesResumeAndTheNextStamp(t *testing.T) { + first, entered, dir, path, ledger := abandonSpendAgent(t, &scriptedCompleter{steps: []step{ + func(context.Context, []ai.Message) (*ai.Response, error) { + return priced(toolResponse("paid", "wedge", `{}`), .25), nil + }, + func(context.Context, []ai.Message) (*ai.Response, error) { + return priced(textResponse("after the stop"), .01), nil + }, + }}) + events := mustSubmit(t, first, "park") + waitMetaPatch(t, entered) + first.Interrupt() + if _, ok := first.Abandon(AbandonStopTimeout); !ok { + t.Fatal("the parked turn was not abandoned") + } + collect(t, events) + waitAbandonSpendExit(t, path, 1) + seals := turnUsageLines(t, path) + if len(seals) != 1 || seals[0].CostUSD != .25 || seals[0].DurationMS != 0 { + t.Fatalf("abandoned seals = %+v, want one .25 seal with no duration", seals) + } + if lines := abandonedLines(t, path); len(lines) != 1 { + t.Fatalf("abandoned lines = %+v, want exactly one", lines) + } + collect(t, mustSubmit(t, first, "continue")) + before := stampedMeta(t, first, dir) + if math.Abs(before.SpentUSD-.26) > 1e-12 || before.Tokens != 42 { + t.Fatalf("before restart saved %+v, want .26 and 42 tokens", before) + } + live := first.Usage() + if math.Abs(live.CostUSD-.26) > 1e-12 || live.Input != 30 || live.Output != 12 || live.Calls != 2 || live.Turns != 2 { + t.Fatalf("live meter = %+v, want .26, 30/12 tokens, two calls and two conversation steps", live) + } + if err := first.Close(); err != nil { + t.Fatalf("close before restart: %v", err) + } + second, err := newAgent(Config{ + Workspace: first.config.Workspace, Model: "test/model", System: "SYSTEM", + Place: Place{Dir: dir, Workspace: first.config.Workspace}, SessionFile: path, usageLedger: ledger, + }, &scriptedCompleter{steps: []step{ + func(context.Context, []ai.Message) (*ai.Response, error) { + return priced(textResponse("done"), .01), nil + }, + }}) + if err != nil { + t.Fatalf("resume: %v", err) + } + t.Cleanup(second.SettleWrites) + t.Cleanup(func() { _ = second.Close() }) + assertAbandonSpendMeter(t, second.Usage(), live) + collect(t, mustSubmit(t, second, "finish")) + after := stampedMeta(t, second, dir) + if math.Abs(after.SpentUSD-.27) > 1e-12 || after.SpentUSD < before.SpentUSD || after.Tokens != 57 { + t.Fatalf("next stamp = %+v, want .27 and 57 tokens, above the previous %+v", after, before) + } + if used := second.Usage(); used.Calls != 3 || used.Turns != 3 { + t.Fatalf("next meter = %+v, want three calls and three conversation steps", used) + } +} + +// Contract A3, A5: on-clock errands retain only their own seals, late receipts +// retain their arm lines, and abandonment changes neither duration nor ledger. +func TestAnAbandonedTurnIsJournaledOnceWithItsErrands(t *testing.T) { + agent, entered, _, path, ledger := abandonSpendAgent(t, &scriptedCompleter{steps: []step{ + func(context.Context, []ai.Message) (*ai.Response, error) { + response := priced(toolResponse("paid", "wedge", `{}`), .25) + response.Usage.CacheReadInputTokens = 3 + response.Usage.CacheCreationInputTokens = 2 + return response, nil + }, + }}) + events := mustSubmit(t, agent, "park") + waitMetaPatch(t, entered) + errands := priced(textResponse("checked"), .125) + errands.Usage.CacheReadInputTokens = 4 + errands.Usage.CacheCreationInputTokens = 1 + agent.addAuxiliaryUsageAs(errands, "test/helper", 1, "guardian") + agent.reconciled(provider.Reconciled{ + Billed: provider.Billed{Model: "test/model", PromptTokens: 80, CompletionTokens: 20, CachedTokens: 30, Cost: .125}, + Ref: "late-abandoned", Reason: "torn", Found: true, + }) + live := agent.Usage() + assertAbandonSpendMeter(t, live, Usage{CostUSD: .5, Input: 110, Output: 32, CacheRead: 37, CacheWrite: 3, Calls: 3, Turns: 1}) + FlushUsage() + before, err := ReadUsage(ledger, time.Time{}) + if err != nil || len(before) != 3 { + t.Fatalf("ledger before abandon = %+v, %v; want three paid calls", before, err) + } + agent.Interrupt() + spend, ok := agent.Abandon(AbandonStopTimeout) + if !ok || spend.CostUSD != .375 || spend.Calls != 2 { + t.Fatalf("abandon = %+v, %v; want only the step and on-clock errand", spend, ok) + } + collect(t, events) + waitAbandonSpendExit(t, path, 1) + if _, again := agent.Abandon(AbandonStopTimeout); again { + t.Fatal("a second abandon let go of the same turn") + } + if lines := abandonedLines(t, path); len(lines) != 1 || lines[0] != (journalAbandoned{ + Reason: string(AbandonStopTimeout), Input: 30, Output: 12, CacheRead: 7, CacheWrite: 3, CostUSD: .375, Calls: 2, + }) { + t.Fatalf("abandoned evidence = %+v, want the original mixed tally without the late receipt", lines) + } + seals := turnUsageLines(t, path) + if len(seals) != 1 || seals[0].CostUSD != .25 || seals[0].Calls != 1 || seals[0].DurationMS != 0 { + t.Fatalf("step seals = %+v, want exactly one .25 step with no duration", seals) + } + if lines := journalUsageLines(t, path); len(lines) != 2 || !lines[0].Aux || lines[0].CostUSD != .125 || lines[0].Role != "guardian" { + t.Fatalf("usage rows = %+v, want one auxiliary errand and one step seal", lines) + } + var arms int + for _, line := range readLines(t, path) { + var entry sessionEntry + if json.Unmarshal([]byte(line), &entry) == nil && entry.Call != nil && entry.Call.Arm != "" { + arms++ + if entry.Call.Arm != "reconciled" || entry.Call.CostUSD != .125 { + t.Fatalf("late receipt changed: %+v", entry.Call) + } + } + } + if arms != 1 { + t.Fatalf("late arms = %d, want one", arms) + } + replayed, err := replaySessionFile(path) + if err != nil { + t.Fatalf("replay: %v", err) + } + assertAbandonSpendMeter(t, replayed.usage, live) + if got := agent.Usage(); got != live { + t.Fatalf("abandon changed the live meter or duration: before %+v, after %+v", live, got) + } + FlushUsage() + after, err := ReadUsage(ledger, time.Time{}) + if err != nil || !reflect.DeepEqual(after, before) { + t.Fatalf("abandon changed the machine ledger: before %+v, after %+v, %v", before, after, err) + } +} + +// Contract A4, A3: digest metadata and replay agree on abandoned step spend, +// including the ordinary per-model split when two models answered the turn. +func TestAnAbandonedTurnsDigestAgreesWithReplay(t *testing.T) { + for _, split := range []bool{false, true} { + name := "one model" + if split { + name = "two models" + } + t.Run(name, func(t *testing.T) { + steps := []step{} + want := Usage{CostUSD: .25, Input: 20, Output: 7, Calls: 1, Turns: 1} + seals := 1 + if split { + steps = append(steps, func(context.Context, []ai.Message) (*ai.Response, error) { + response := priced(toolResponse("earlier", "ls", `{"path":"."}`), .125) + response.Model = "test/earlier" + return response, nil + }) + want = Usage{CostUSD: .375, Input: 40, Output: 14, Calls: 2, Turns: 2} + seals = 2 + } + steps = append(steps, func(context.Context, []ai.Message) (*ai.Response, error) { + response := priced(toolResponse("paid", "wedge", `{}`), .25) + response.Model = "test/model" + return response, nil + }) + agent, entered, _, path, _ := abandonSpendAgent(t, &scriptedCompleter{steps: steps}) + events := mustSubmit(t, agent, "park") + waitMetaPatch(t, entered) + agent.Interrupt() + if _, ok := agent.Abandon(AbandonStopTimeout); !ok { + t.Fatal("the parked turn was not abandoned") + } + collect(t, events) + waitAbandonSpendExit(t, path, seals) + live := agent.Usage() + if err := agent.Close(); err != nil { + t.Fatalf("close before reopen: %v", err) + } + file, _, err := openSessionFile(path, agent.config.Workspace, "test/model", "") + if err != nil { + t.Fatalf("reopen: %v", err) + } + t.Cleanup(func() { _ = file.Close() }) + assertAbandonSpendMeter(t, file.RestoredUsage(), want) + assertAbandonSpendMeter(t, file.RestoredUsage(), live) + digest, err := ReadDigest(path) + if err != nil { + t.Fatalf("digest: %v", err) + } + if usd, tokens := digest.Spend(); usd != want.CostUSD || tokens != want.Input+want.Output { + t.Fatalf("digest spend = %v / %d tokens, want %+v", usd, tokens, want) + } + if meta := digest.Derive(Meta{}); meta.SpentUSD != want.CostUSD || meta.Tokens != want.Input+want.Output { + t.Fatalf("derived metadata = %+v, want %+v", meta, want) + } + lines := turnUsageLines(t, path) + if len(lines) != seals || len(digest.Usage) != seals { + t.Fatalf("turn seals = %+v, digest usage = %+v, want %d", lines, digest.Usage, seals) + } + for _, line := range lines { + cost := .25 + if line.Model == "test/earlier" && split { + cost = .125 + } else if line.Model != "test/model" { + t.Fatalf("unexpected seal model: %+v", line) + } + if line.CostUSD != cost || line.Input != 20 || line.Output != 7 || line.Calls != 1 || line.DurationMS != 0 { + t.Fatalf("per-model seal = %+v, want its own paid call with no duration", line) + } + } + }) + } +} + +// Contract A5: an abandoned request with no usage writes only its evidence, +// keeping the live duration and machine ledger unchanged. +func TestAnAbandonedTurnThatSpentNothingWritesNoSeal(t *testing.T) { + agent, entered, _, path, ledger := abandonSpendAgent(t, &scriptedCompleter{steps: []step{ + func(context.Context, []ai.Message) (*ai.Response, error) { + response := toolResponse("unpaid", "wedge", `{}`) + response.Usage = nil + return response, nil + }, + }}) + events := mustSubmit(t, agent, "park without usage") + waitMetaPatch(t, entered) + before := agent.Usage() + if before != (Usage{}) { + t.Fatalf("unpaid request banked %+v", before) + } + agent.Interrupt() + if spend, ok := agent.Abandon(AbandonStopTimeout); !ok || spend != (Usage{}) { + t.Fatalf("abandon = %+v, %v; want no spend", spend, ok) + } + collect(t, events) + waitAbandonSpendExit(t, path, 0) + if _, again := agent.Abandon(AbandonStopTimeout); again { + t.Fatal("the empty turn was abandoned twice") + } + if lines := abandonedLines(t, path); len(lines) != 1 || lines[0] != (journalAbandoned{Reason: string(AbandonStopTimeout)}) { + t.Fatalf("empty abandoned evidence = %+v, want exactly one reason-only line", lines) + } + if seals := journalUsageLines(t, path); len(seals) != 0 { + t.Fatalf("an empty abandoned turn wrote usage seals: %+v", seals) + } + if after := agent.Usage(); after != before { + t.Fatalf("empty abandon changed meter or duration: before %+v, after %+v", before, after) + } + if usd, rows := ledgerUSD(t, ledger); usd != 0 || rows != 0 { + t.Fatalf("empty abandon wrote machine ledger spend: %v in %d rows", usd, rows) + } +} + +// Contract A6: historical abandoned evidence never becomes a charge, even +// when its conversation steps have no exit seal to recover. +func TestAnOldAbandonedLineIsStillNotBilled(t *testing.T) { + path := filepath.Join(t.TempDir(), "transcript.jsonl") + write(t, path, + `{"type":"session","version":1,"id":"abc","cwd":"/w","model":"m","timestamp":"t"}`, + `{"type":"usage","usage":{"model":"m","input":10,"output":5,"cacheRead":3,"cacheWrite":2,"costUsd":0.25,"calls":1,"durationMs":250}}`, + `{"type":"call","call":{"model":"m","input":20,"output":7,"costUsd":0.5}}`, + `{"type":"abandoned","abandoned":{"reason":"stop timeout","input":20,"output":7,"costUsd":0.5,"calls":1}}`, + ) + file, _, err := openSessionFile(path, "/w", "m", "") + if err != nil { + t.Fatalf("open historical journal: %v", err) + } + t.Cleanup(func() { _ = file.Close() }) + want := Usage{Input: 10, Output: 5, CacheRead: 3, CacheWrite: 2, CostUSD: .25, Calls: 1, Turns: 1, Duration: 250 * time.Millisecond} + if got := file.RestoredUsage(); got != want { + t.Fatalf("historical replay = %+v, want only the existing seal %+v", got, want) + } + digest, err := ReadDigest(path) + if err != nil { + t.Fatalf("historical digest: %v", err) + } + if usd, tokens := digest.Spend(); usd != .25 || tokens != 15 { + t.Fatalf("historical digest spend = %v / %d tokens, want .25 / 15", usd, tokens) + } +} + +// Contract A3: a displaced turn returning through its ordinary cancellation +// ending writes that seal alone, without an additional abandoned exit seal. +func TestAnAbandonedTurnEndingOrdinarilySealsOnlyOnce(t *testing.T) { + held, release := make(chan struct{}), make(chan struct{}) + var once sync.Once + letReturn := func() { once.Do(func() { close(release) }) } + agent, _, _, path, _ := abandonSpendAgent(t, &scriptedCompleter{steps: []step{ + func(context.Context, []ai.Message) (*ai.Response, error) { + return priced(toolResponse("paid", "ls", `{"path":"."}`), .25), nil + }, + func(ctx context.Context, _ []ai.Message) (*ai.Response, error) { + close(held) + <-release + return nil, ctx.Err() + }, + }}) + t.Cleanup(letReturn) + events := mustSubmit(t, agent, "read and wait") + waitMetaPatch(t, held) + live := agent.Usage() + agent.Interrupt() + if _, ok := agent.Abandon(AbandonStopTimeout); !ok { + t.Fatal("the held request was not abandoned") + } + collect(t, events) + letReturn() + waitAbandonSpendExit(t, path, 1) + if seals := turnUsageLines(t, path); len(seals) != 1 || seals[0].CostUSD != .25 || seals[0].Calls != 1 { + t.Fatalf("ordinary ending seals = %+v, want just one .25 seal", seals) + } + if lines := abandonedLines(t, path); len(lines) != 1 { + t.Fatalf("ordinary ending abandoned evidence = %+v, want one", lines) + } + replayed, err := replaySessionFile(path) + if err != nil { + t.Fatalf("ordinary ending replay: %v", err) + } + assertAbandonSpendMeter(t, replayed.usage, live) +} + +// Contract A3, exit audit: the settle bound and final-report returns must seal +// their paid steps once, without duration or ordinary turn-ending acts. +func TestOtherTurnEndingsSealTheirStepSpend(t *testing.T) { + for _, ending := range []string{"settle bound", "final report"} { + t.Run(ending, func(t *testing.T) { + ctx := context.Background() + tool, args := "ls", `{"path":"."}` + var configure func(*Config) + if ending == "settle bound" { + ctx = withSettleWake(ctx, settleWake{ceiling: 1}) + } else { + tool, args = "stage_result", `{"done":true,"output":"finished"}` + configure = func(c *Config) { + c.Stage = &fakeStageDoor{} + c.Unattended = true + } + } + agent, _, _, path, _ := abandonSpendAgent(t, &scriptedCompleter{steps: []step{ + func(context.Context, []ai.Message) (*ai.Response, error) { + return priced(toolResponse("paid", tool, args), .25), nil + }, + }}, configure) + events, err := agent.Submit(ctx, "finish at the boundary") + if err != nil { + t.Fatalf("submit: %v", err) + } + collect(t, events) + seals := turnUsageLines(t, path) + if len(seals) != 1 || seals[0].CostUSD != .25 || seals[0].Calls != 1 || seals[0].DurationMS != 0 { + t.Fatalf("%s seals = %+v, want one .25 seal with no duration", ending, seals) + } + replayed, err := replaySessionFile(path) + if err != nil { + t.Fatalf("%s replay: %v", ending, err) + } + assertAbandonSpendMeter(t, replayed.usage, Usage{CostUSD: .25, Input: 20, Output: 7, Calls: 1, Turns: 1}) + live := agent.Usage() + assertAbandonSpendMeter(t, replayed.usage, live) + if live.Duration != 0 { + t.Fatalf("%s added live duration: %v", ending, live.Duration) + } + }) + } +} + +// abandonSpendAgent uses the existing releasable wedge and constructor with a +// fixed session folder and private ledger, so resume observes the same record. +func abandonSpendAgent(t *testing.T, completer Completer, configure ...func(*Config)) (*Agent, <-chan struct{}, string, string, string) { + t.Helper() + if scripted, ok := completer.(*scriptedCompleter); ok { + scripted.aside = func(messages []ai.Message) (*ai.Response, bool) { + if isCaptionCall(messages) || isTitleCall(messages) { + response := textResponse("") + response.Usage = nil + return response, true + } + return nil, false + } + } + dir := t.TempDir() + path := filepath.Join(dir, "transcript.jsonl") + ledger := filepath.Join(t.TempDir(), UsageLedgerName) + t.Cleanup(func() { StopUsageWriter(ledger) }) + agent, _ := newTestAgent(t, completer, func(c *Config) { + c.Place = Place{Dir: dir, Workspace: c.Workspace} + c.SessionFile = path + c.usageLedger = ledger + for _, mutate := range configure { + if mutate != nil { + mutate(c) + } + } + }) + entered, release := make(chan struct{}), make(chan struct{}) + t.Cleanup(func() { close(release) }) + agent.tools = append(agent.tools, releasableWedgeTool(entered, release)) + definitions, err := toolDefinitions(agent.tools) + if err != nil { + t.Fatalf("tool definitions: %v", err) + } + agent.definitions = definitions + return agent, entered, dir, path, ledger +} + +// waitAbandonSpendExit waits for the loop's deferred pace row, which follows +// its exit seal even when the abandoned turn's event stream closed earlier. +func waitAbandonSpendExit(t *testing.T, path string, seals int) { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for { + var ended bool + var written []journalUsage + for _, line := range readLines(t, path) { + var entry sessionEntry + if json.Unmarshal([]byte(line), &entry) != nil { + continue + } + if entry.Type == "pace" { + ended = true + } + if entry.Type == "usage" && entry.Usage != nil && !entry.Usage.Aux { + written = append(written, *entry.Usage) + } + } + if ended { + if len(written) != seals { + t.Fatalf("turn exit wrote %+v, want %d step seals", written, seals) + } + return + } + if time.Now().After(deadline) { + t.Fatal("the displaced turn did not journal its exit within 10 seconds") + } + time.Sleep(5 * time.Millisecond) + } +} + +// assertAbandonSpendMeter compares the accounting that must survive replay; +// duration is rounded to milliseconds by the journal's existing format. +func assertAbandonSpendMeter(t *testing.T, got, want Usage) { + t.Helper() + if math.Abs(got.CostUSD-want.CostUSD) > 1e-12 || got.Input != want.Input || got.Output != want.Output || + got.CacheRead != want.CacheRead || got.CacheWrite != want.CacheWrite || got.Calls != want.Calls || got.Turns != want.Turns { + t.Fatalf("replayed meter = %+v, want live accounting %+v", got, want) + } +} diff --git a/internal/session/agent.go b/internal/session/agent.go index b8a537b46e..e9ccb04f68 100644 --- a/internal/session/agent.go +++ b/internal/session/agent.go @@ -90,6 +90,8 @@ func New(config Config) (*Agent, error) { // parent's account-keyed pool and resolve their own model before their first // request, rather than inheriting whichever adapter the parent last used. func (a *Agent) newChildAgent(config Config) (*Agent, error) { + config.ClientTools = nil + config.System = clientToolsWithoutSections(config.System) if pair, ok := a.ConversationModelPair(); ok && config.ConversationModels == nil { pair.Main = config.Model if config.OneModel || pair.Fallback == pair.Main { @@ -189,6 +191,9 @@ func newAgent(config Config, client Completer) (*Agent, error) { } if config.Place.Dir != "" { if meta, err := LoadMeta(config.Place.Dir); err == nil { + if meta.Model == config.Model { + agent.modelForNowProvider = meta.ModelProvider + } if meta.ModelIdentity != nil { agent.modelIdentity = *meta.ModelIdentity agent.modelIdentity.Active, agent.modelIdentity.Pending = "", false @@ -415,9 +420,10 @@ func newAgent(config Config, client Completer) (*Agent, error) { // not reachable yet. agent.scrubBlindImagePartsLocked(agent.model) // AND IT KEEPS WHAT IT SPENT. The total is the sum of the file's usage - // lines (sessionfile.go), so a conversation reopened tomorrow reports the - // money it actually cost rather than the money this process has spent so - // far — which for a resumed session was always nothing. + // lines and late-receipt call lines (sessionfile.go), so a conversation + // reopened tomorrow reports the money it actually cost rather than the + // money this process has spent so far — which for a resumed session was + // always nothing. // // THIS ALSO RESTORES THE SPEND RAIL, and that is the intended change: the // rail is this conversation's own ceiling (rail.go), so a conversation @@ -425,7 +431,7 @@ func newAgent(config Config, client Completer) (*Agent, error) { // the process was a ceiling on a window, not on a conversation. agent.usage = file.RestoredUsage() // AND A JOURNAL WRITTEN BEFORE THE TOTAL WAS KEPT GETS ONE NOW. The sum - // above came out of the file's own usage lines, so this costs the read + // above came out of the file's own charge records, so this costs the read // of a meta.json and — only when it has no figure at all — one write // (placemeta.go's [Agent.stampRestoredSpend]). Without it a conversation // that has not spoken since the field arrived would read as free on @@ -794,6 +800,9 @@ func (a *Agent) SetModel(model string) { a.setModel(model) } func (a *Agent) setModel(model string) ModelLanding { if pair, ok := a.ConversationModelPair(); ok { pair.Main = model + if pair.Main == pair.Fallback { + pair.Fallback = "" + } _, _ = a.SetConversationModels(pair) return ModelLandsNextRequest } @@ -818,6 +827,11 @@ func (a *Agent) setModel(model string) ModelLanding { // that said `switching now` and a step that changed nothing (steer.go's // [Agent.ridingNowLocked]). changed := a.ridingNowLocked() != model + // An explicit confirmation also withdraws a queued temporary choice. + // This only changes the next request; the current call keeps answering. + a.spokenModel = "" + a.modelForNowConfigured = "" + a.modelForNowProvider = "" a.model = model if a.clientPool != nil { a.clientPool.setSeat(model) @@ -862,6 +876,10 @@ func (a *Agent) SetSources(sources modelsource.Set) { return } a.mu.Lock() + // A disconnected source must not erase the saved model's provider identity. + if a.modelForNowProvider == "" { + a.modelForNowProvider = a.config.serviceFor(a.configuredModelLocked()).Source.ID + } a.config.Sources = sources a.clientPool.setSources(sources) if !a.running { @@ -1430,6 +1448,9 @@ type userMessage struct { // leave it empty, and the turn is then stamped with no person message — an // unknown, never a guess at the latest one. answersMessage string + // sourceMessage is the exact recorded message a resumed answer continues. + // Only ResumeStoppedTurn sets it; ordinary notes never inherit the latest message. + sourceMessage string // said is THE PERSON'S OWN WORDS, when what the model reads is not only // them. Empty in every ordinary case, and set where the session writes @@ -2050,7 +2071,7 @@ func (a *Agent) startTurnLocked(ctx context.Context, user userMessage, watcher * // turn, or one a task or member report opened, has a run and no person // message, which is the truth. A steer that lands later is NOT the opener // and changes nothing here. - turnCtx = withTurnOrigin(turnCtx, turnOrigin{run: runIdentity(turnCtx), turn: openingMessage(user)}) + turnCtx = withTurnOrigin(turnCtx, turnOriginLocked(user, runIdentity(turnCtx))) // AND THE TURN CARRIES THE CREATION CONTEXT the accepting door stated on // its opening message (creation_team.go), only when it stated one: any other // turn leaves the team unknown. Never rewritten from who manages a team now. diff --git a/internal/session/bashbelt_worker.go b/internal/session/bashbelt_worker.go index e443c20bf2..dcb7cde560 100644 --- a/internal/session/bashbelt_worker.go +++ b/internal/session/bashbelt_worker.go @@ -133,7 +133,7 @@ func NewBeltWorker(config Config, completer Completer, task *plandb.Task, storeP // back — and a shim that never landed is a seat that cannot run, because // every `plandb` its worker runs would resolve to whatever shares the // machine's PATH and write a plan this run would never read. - plan := &planState{path: storePath, root: rootID} + plan := &planState{path: storePath, root: rootID, task: task.ID} if err := plan.armShim(); err != nil { _ = agent.Close() return nil, fmt.Errorf("arm the plandb shim: %w", err) @@ -193,11 +193,12 @@ func workerJournalName() string { // the run's own ask reached the store as the ROOT task's description. A leaf // handed only the paraphrase inherits its omissions and cannot notice: on two // runs of one objective the planner dropped the same bullet, and only the leaf -// that read the person's own sentence carried it. So one section of a non-root -// worker's document carries that sentence VERBATIM — the leaf reads the whole +// that read the person's own sentence carried it. So a descendant's document +// carries that sentence verbatim — the leaf reads the whole // ask even though it owns one part, and where the ask and its work order // disagree about a requirement it owns, the ask wins and the leaf says so in -// its report. +// its report. A joined handoff establishes its own ask; its descendants read +// that assignment, while its own work order already carries it. // // THE ROOT'S OWN DOCUMENT IS UNCHANGED: its work order IS the ask, so there is // nothing to put beside it. The section is absent there, and absent when the @@ -224,6 +225,10 @@ func BeltWorkerBrief(store *plandb.Store, task *plandb.Task, root, resume bool, strings.Join(task.Deliverables, "\n"), task.Acceptance, "", AdmissionContext{}, taskOrigin{}, taskCopy{}) + if task.Title != "" { + identity, rest, _ := strings.Cut(doc, "\n") + doc = identity + "\n" + task.Title + "\n" + rest + } // The runtime's directory is authority, while the work order may still // quote the source checkout. Preserve those words and explain their scope // before the worker sees them, as the checker already does for its probes. @@ -233,12 +238,10 @@ func BeltWorkerBrief(store *plandb.Store, task *plandb.Task, root, resume bool, identity, work, _ := strings.Cut(doc, "\n\n") doc = identity + "\n\n" + assignment + "\n\n" + work } - // THE ASK, FOR EVERY LEAF AND ONLY A LEAF. The section is absent on the - // root's own document (its work order is the ask) and absent when the store - // holds no root row to read it from, which is the emptiness law and not a - // special case. + // Descendants inherit their assignment's ask. A joined assignment's own + // work order already carries it, just as the run root's does. if !root { - if ask := runRootAsk(store); ask != "" { + if ask := runTaskAsk(store, task); ask != "" { doc = withReport(doc, askSection(ask)) } } @@ -275,8 +278,8 @@ func BeltWorkerBrief(store *plandb.Store, task *plandb.Task, root, resume bool, return doc } -// The heading, the rule and the bound over the run root's own words, carried -// on every leaf's document ([BeltWorkerBrief]). The heading is the belt pages' +// The heading, the rule and the bound over an assignment's own words, carried +// on its descendants' documents ([BeltWorkerBrief]). The heading is the belt pages' // own Markdown voice; the rule is the one sentence that says what the section // is for. const ( @@ -290,7 +293,7 @@ const ( askSectionLimit = 16 << 10 ) -// askSection lays out the run root's own words: the heading, the one sentence +// askSection lays out the assignment's own words: the heading, the one sentence // saying what they are for, and the description VERBATIM — unedited and // untrimmed, bounded only by [askSectionLimit], which marks its cut. func askSection(ask string) string { @@ -329,6 +332,20 @@ If no declared command can be run, answer "check: " instead. One line. A requirement you could not test is one you did not prove. ` +// runTaskAsk selects the nearest independent joined assignment, falling back +// to the root for work the root's own worker or reviewer added. +func runTaskAsk(store *plandb.Store, task *plandb.Task) string { + if store == nil || task == nil || task.JoinedAssignment { + return "" + } + for parent := store.Task(task.ParentID); parent != nil && parent.ID != store.RootID(); parent = store.Task(parent.ParentID) { + if parent.JoinedAssignment { + return parent.Description + } + } + return runRootAsk(store) +} + // runRootAsk answers the run root's description as the store keeps it — the // person's own ask, seeded on the root row — or "" when there is no root to // read (a store that never seeded one, a lost root, a nil handle). The empty @@ -359,6 +376,7 @@ func (a *Agent) TaskReport() string { return taskReport(a) } // The shared event hub stays asynchronous; only this producer waits, outside // every agent and hub lock. Cancellation also releases a failed event reader. func (a *Agent) sendBeltStep(ctx context.Context, hub *eventHub, event Event) { + event = a.clientToolEvent(event) if !a.config.WaitForBeltSteps { hub.send(event) return diff --git a/internal/session/client_tools.go b/internal/session/client_tools.go new file mode 100644 index 0000000000..134512985a --- /dev/null +++ b/internal/session/client_tools.go @@ -0,0 +1,374 @@ +package session + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "regexp" + "strings" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/exec/bare" +) + +// Client tool limits are shared by the wire validator and the conversation door. +const ( + ClientToolMaxSets = 4 + ClientToolMaxPerSet = 32 + ClientToolMaxPerAgent = 64 + ClientToolDescriptionMax = 2048 + ClientToolSchemaMax = 16 << 10 + ClientToolInstructionsMax = 4096 + ClientToolErrorMax = 4096 + ClientToolOutputMax = 64 << 10 + ClientToolDefaultDeadlineMS = 30000 + ClientToolMinDeadlineMS = 1000 + ClientToolMaxDeadlineMS = 120000 +) + +// ClientToolDoor belongs to one conversation and one connected app. +type ClientToolDoor interface { + // Snapshots are immutable, so an unchanged version costs no copying. + ClientToolSets() (version uint64, sets []ClientToolSet) + CallClientTool(context.Context, ClientToolCall) (ClientToolAnswer, error) +} + +type ClientTool struct { + Name string `json:"name"` + Description string `json:"description"` + InputSchema json.RawMessage `json:"input_schema"` + Category ActionCategory `json:"category"` +} + +type ClientToolSet struct { + ID string `json:"id"` + Instructions string `json:"instructions"` + DeadlineMS int `json:"deadline_ms"` + Tools []ClientTool `json:"tools"` +} + +var clientToolSetPattern = regexp.MustCompile(`^[a-z][a-z0-9_.-]{0,63}$`) +var clientToolNamePattern = regexp.MustCompile(`^[a-z][a-z0-9_]{0,63}$`) + +// ValidateClientToolSet checks declarations at a conversation's transport door. +func ValidateClientToolSet(set ClientToolSet) error { + if !clientToolSetPattern.MatchString(set.ID) { + return fmt.Errorf("a valid set id is required") + } + if len(set.Tools) > ClientToolMaxPerSet || len(set.Instructions) > ClientToolInstructionsMax { + return fmt.Errorf("a set allows at most %d tools and %d instruction bytes", ClientToolMaxPerSet, ClientToolInstructionsMax) + } + if set.DeadlineMS != 0 && (set.DeadlineMS < ClientToolMinDeadlineMS || set.DeadlineMS > ClientToolMaxDeadlineMS) { + return fmt.Errorf("deadline_ms must be 0 or %d–%d", ClientToolMinDeadlineMS, ClientToolMaxDeadlineMS) + } + names := make(map[string]bool, len(set.Tools)) + for _, tool := range set.Tools { + if !clientToolNamePattern.MatchString(tool.Name) || names[tool.Name] { + return fmt.Errorf("invalid or duplicate tool name %q", tool.Name) + } + names[tool.Name] = true + if len(tool.Description) == 0 || len(tool.Description) > ClientToolDescriptionMax { + return fmt.Errorf("%s description must be 1–%d bytes", tool.Name, ClientToolDescriptionMax) + } + var object map[string]json.RawMessage + if len(tool.InputSchema) > ClientToolSchemaMax || json.Unmarshal(tool.InputSchema, &object) != nil || object == nil { + return fmt.Errorf("%s input_schema must be an object of at most %d bytes", tool.Name, ClientToolSchemaMax) + } + var kind string + if json.Unmarshal(object["type"], &kind) != nil || kind != "object" { + return fmt.Errorf("%s input_schema type must be object", tool.Name) + } + switch tool.Category { + case ActionRead, ActionEdit, ActionCreate: + default: + return fmt.Errorf("%s category must be read, edit or create", tool.Name) + } + } + return nil +} + +type ClientToolCall struct { + Set, Name, TurnCall string + SourceMessage string + Args json.RawMessage + DeadlineMS int +} + +type ClientToolAnswer struct { + Text string + IsError bool +} + +// clientToolState is protected by armMu, along with the belt it describes. +type clientToolState struct { + version uint64 + seen bool + sets []ClientToolSet + names map[string]ActionCategory + retired map[string]bool + section string +} + +func (c Config) clientToolSets() (uint64, []ClientToolSet) { + if c.ClientTools == nil || c.InTask || c.Errand || c.promptProfile().chat() { + return 0, nil + } + return c.ClientTools.ClientToolSets() +} + +const clientToolHeading = "\n\n## Tools from the app: " +const clientToolSectionEnd = "\n" + +func clientToolsSection(config Config) string { + _, sets := config.clientToolSets() + return clientToolsSectionOf(sets) +} + +func clientToolsSectionOf(sets []ClientToolSet) string { + var out strings.Builder + for _, set := range sets { + out.WriteString(clientToolHeading) + out.WriteString(set.ID) + out.WriteByte('\n') + out.WriteString(set.Instructions) + out.WriteString(clientToolSectionEnd) + } + return out.String() +} + +// Child pages lose the app's instructions as well as the execution door. +func clientToolsWithoutSections(page string) string { + start := strings.Index(page, clientToolHeading) + if start < 0 { + return page + } + // All app sections form one tail. The last renderer-owned end also covers + // delimiter text that an app may have included inside its instructions. + end := strings.LastIndex(page, clientToolSectionEnd) + if end < start { + return page[:start] + } + return page[:start] + page[end+len(clientToolSectionEnd):] +} + +// appendClientTools runs during construction and complete belt rebuilds. +func (a *Agent) appendClientTools(builtins []bare.Tool) []bare.Tool { + a.armMu.Lock() + defer a.armMu.Unlock() + version, sets := a.config.clientToolSets() + a.clientTools.version, a.clientTools.seen = version, true + a.clientTools.sets = sets + a.clientTools.section = clientToolsSectionOf(sets) + return a.clientToolTailLocked(builtins, sets) +} + +func (a *Agent) clientToolTailLocked(builtins []bare.Tool, sets []ClientToolSet) []bare.Tool { + a.noteClientToolBuiltinsLocked(builtins) + for name := range a.clientTools.names { + if a.clientTools.retired == nil { + a.clientTools.retired = make(map[string]bool) + } + a.clientTools.retired[name] = true + } + a.clientTools.names = nil + if len(sets) == 0 { + return builtins + } + tools := make([]bare.Tool, len(builtins), len(builtins)+ClientToolMaxPerAgent) + copy(tools, builtins) + held := make(map[string]bool, len(builtins)) + for _, tool := range builtins { + held[tool.Name] = true + } + a.clientTools.names = make(map[string]ActionCategory) + for _, set := range sets { + for _, spec := range set.Tools { + if held[spec.Name] { + continue + } + held[spec.Name] = true + a.clientTools.names[spec.Name] = spec.Category + delete(a.clientTools.retired, spec.Name) + tools = append(tools, a.clientTool(set, spec)) + } + } + return tools +} + +func (a *Agent) noteClientToolBuiltinsLocked(builtins []bare.Tool) { + observer, ok := a.config.ClientTools.(interface{ ClientToolBuiltins([]string) }) + if !ok { + return + } + names := make([]string, 0, len(builtins)) + for _, tool := range builtins { + names = append(names, tool.Name) + } + for _, tools := range a.shelf { + for _, tool := range tools { + names = append(names, tool.Name) + } + } + observer.ClientToolBuiltins(names) +} + +func (a *Agent) clientToolRetiredNotice(name string) (string, bool) { + a.armMu.Lock() + defer a.armMu.Unlock() + if !a.clientTools.retired[name] { + return "", false + } + return ClientToolUnavailable(name).Text, true +} + +func ClientToolUnavailable(name string) ClientToolAnswer { + return ClientToolAnswer{Text: fmt.Sprintf("client_unavailable: the app that provides %s is not connected; nothing was done.", name), IsError: true} +} + +// BoundClientToolOutput reuses the engine's rune-safe cut and spells the loss plainly. +func BoundClientToolOutput(text string) string { + bounded := capBytes(text, ClientToolOutputMax) + if len(text) <= ClientToolOutputMax { + return bounded + } + cut := strings.LastIndex(bounded, "… (") + return bounded[:cut] + fmt.Sprintf("\n[cut: %d bytes dropped]", len(text)-cut) +} + +func (a *Agent) clientTool(set ClientToolSet, spec ClientTool) bare.Tool { + return bare.Tool{Name: spec.Name, Description: spec.Description, Schema: spec.InputSchema, + Execute: func(ctx context.Context, args json.RawMessage) (string, bool, error) { + var object map[string]json.RawMessage + data := bytes.TrimSpace(args) + if len(data) == 0 || data[0] != '{' || !json.Valid(data) || decodeToolArguments(data, &object) != nil || object == nil { + return rejectToolArguments(ctx, invalidArgumentsPrefix+"expected a JSON object"), true, nil + } + answer, err := a.config.ClientTools.CallClientTool(ctx, ClientToolCall{ + Set: set.ID, Name: spec.Name, Args: args, TurnCall: callIDFrom(ctx), DeadlineMS: set.DeadlineMS, + SourceMessage: turnOriginFrom(ctx).answers, + }) + if err != nil { + return err.Error(), true, nil + } + return answer.Text, answer.IsError, nil + }, + } +} + +// A boundary swaps fresh arrays; existing requests can keep walking their snapshot. +func (a *Agent) clientToolsBoundary() { + if a.config.ClientTools == nil { + return + } + version, sets := a.config.clientToolSets() + a.armMu.Lock() + if a.clientTools.seen && version == a.clientTools.version { + a.armMu.Unlock() + return + } + section := clientToolsSectionOf(sets) + changed := section != a.clientTools.section + builtins, definitions, err := a.clientToolBuiltinsLocked(0) + if err != nil { + a.armMu.Unlock() + return + } + a.tools = a.clientToolTailLocked(builtins, sets) + tail, _ := toolDefinitions(a.tools[len(builtins):]) + a.definitions = append(definitions, tail...) + a.clientTools.version, a.clientTools.seen = version, true + a.clientTools.sets, a.clientTools.section = sets, section + a.armMu.Unlock() + if changed { + a.mu.Lock() + a.rerenderSystemLocked(time.Now()) + a.mu.Unlock() + } +} + +func (a *Agent) clientToolBuiltinsLocked(room int) ([]bare.Tool, []ai.ToolDefinition, error) { + cache := a.definitions + if len(cache) < len(a.tools) { + // Lightweight callers may have no cache, or only a previously armed family's definitions. + var err error + cache, err = toolDefinitions(a.tools) + if err != nil { + return nil, nil, err + } + } + tools := make([]bare.Tool, 0, len(a.tools)+room) + definitions := make([]ai.ToolDefinition, 0, len(a.tools)+room) + for i, tool := range a.tools { + if _, client := a.clientTools.names[tool.Name]; client { + continue + } + tools = append(tools, tool) + definitions = append(definitions, cache[i]) + } + return tools, definitions, nil +} + +func (a *Agent) withoutClientToolsLocked(tools []bare.Tool) []bare.Tool { + if len(a.clientTools.names) == 0 { + return tools + } + builtins := make([]bare.Tool, 0, len(tools)) + for _, tool := range tools { + if _, client := a.clientTools.names[tool.Name]; !client { + builtins = append(builtins, tool) + } + } + return builtins +} + +// ActionCategoryForTool resolves this conversation's declaration before the static floor. +func (a *Agent) ActionCategoryForTool(name string) ActionCategory { + a.armMu.Lock() + category, ok := a.clientTools.names[name] + a.armMu.Unlock() + if ok { + return category + } + return ActionCategoryForTool(name) +} + +func (a *Agent) clientToolCategory(name string) (ActionCategory, bool) { + a.armMu.Lock() + defer a.armMu.Unlock() + category, ok := a.clientTools.names[name] + return category, ok +} + +func (a *Agent) clientToolEvent(event Event) Event { + if category, ok := a.clientToolCategory(event.Tool); ok { + event.Category = category + } + return event +} + +func toolEventCategory(event Event) ActionCategory { + if event.Category != "" { + return event.Category + } + return ActionCategoryForTool(event.Tool) +} + +// ClientToolBuiltinNames includes the live belt and deferred tools for collision checks. +func (a *Agent) ClientToolBuiltinNames() []string { + a.armMu.Lock() + defer a.armMu.Unlock() + var names []string + for _, tool := range a.tools { + if _, client := a.clientTools.names[tool.Name]; !client { + names = append(names, tool.Name) + } + } + for _, tools := range a.shelf { + for _, tool := range tools { + names = append(names, tool.Name) + } + } + return names +} diff --git a/internal/session/client_tools_test.go b/internal/session/client_tools_test.go new file mode 100644 index 0000000000..9f5f1e1d76 --- /dev/null +++ b/internal/session/client_tools_test.go @@ -0,0 +1,677 @@ +package session + +import ( + "context" + "encoding/json" + "fmt" + "os" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/approval" + "github.com/Agent-Field/codeaf/internal/exec/bare" +) + +type clientToolsFakeDoor struct { + version uint64 + sets []ClientToolSet + calls []ClientToolCall + answer ClientToolAnswer +} + +func clientToolSourceStep(id string) step { + return func(context.Context, []ai.Message) (*ai.Response, error) { + return toolResponse(id, "app_note", `{}`), nil + } +} + +// Source contract (a): every submission door and a queued follow-up name their own line. +func TestClientToolsSourceSubmissionDoors(t *testing.T) { + for _, kind := range []string{"plain", "message", "composed", "follow-up"} { + t.Run(kind, func(t *testing.T) { + door := clientToolsFixture() + entered, release := make(chan struct{}), make(chan struct{}) + first := clientToolSourceStep("first") + if kind == "follow-up" { + first = func(ctx context.Context, messages []ai.Message) (*ai.Response, error) { + close(entered) + select { + case <-release: + case <-ctx.Done(): + return nil, ctx.Err() + } + return clientToolSourceStep("first")(ctx, messages) + } + } + a, _ := newTestAgent(t, &scriptedCompleter{steps: []step{first, finishingStep(), clientToolSourceStep("second"), finishingStep()}}, + func(c *Config) { c.ClientTools = door; c.PromptProfile = "full" }) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + var events, next <-chan Event + var err error + switch kind { + case "plain", "follow-up": + events, err = a.Submit(ctx, "first question") + case "message": + events, err = a.SubmitMessage(ctx, Submission{Text: "first question", ClientID: "another-window"}) + case "composed": + _, events, err = a.SubmitComposed(ctx, Submission{Text: "first question"}) + } + if err != nil { + t.Fatal(err) + } + if kind == "follow-up" { + waitSignal(t, entered, "the first provider step before queuing the follow-up") + next, err = a.FollowUp("second question") + close(release) + if err != nil { + t.Fatal(err) + } + } + collect(t, events) + if next == nil { + next = mustSubmit(t, a, "second question") + } + collect(t, next) + var ids []string + for _, entry := range a.Transcript() { + if entry.Role == "user" && entry.Person != nil && entry.Person.Composed != nil { + ids = append(ids, entry.Person.Composed.MessageID) + } + } + if len(ids) != 2 || ids[0] == "" || ids[0] == ids[1] || len(door.calls) != 2 { + t.Fatalf("messages=%v calls=%+v", ids, door.calls) + } + for i, call := range door.calls { + if call.SourceMessage != ids[i] { + t.Errorf("call %d source=%q want %q", i, call.SourceMessage, ids[i]) + } + } + }) + } +} + +// Source contract (b): takeover replay reuses the recorded id without changing crew provenance. +func TestClientToolsSourceStoppedTurnReplay(t *testing.T) { + path := takenOverMidThought(t, func(a *Agent) { a.InterruptFor(StopByTakeover) }) + want := journalPersonIDs(t, path)[0] + door := clientToolsFixture() + var crewOpener string + a, _ := newTestAgent(t, &scriptedCompleter{steps: []step{ + func(ctx context.Context, messages []ai.Message) (*ai.Response, error) { + crewOpener = turnOriginFrom(ctx).turn + return clientToolSourceStep("resumed")(ctx, messages) + }, finishingStep(), + }}, func(c *Config) { c.SessionFile = path; c.ClientTools = door; c.PromptProfile = "full" }) + events, resumed := a.ResumeStoppedTurn(context.Background()) + if !resumed { + t.Fatal("stopped turn did not resume") + } + collect(t, events) + if len(door.calls) != 1 || door.calls[0].SourceMessage != want || crewOpener != "" { + t.Fatalf("calls=%+v crew opener=%q want message %q", door.calls, crewOpener, want) + } +} + +// Source contract (b), (d): resuming a legacy line never invents a message id. +func TestClientToolsSourceLegacyStoppedTurn(t *testing.T) { + path := filepath.Join(t.TempDir(), "session.jsonl") + journal := `{"type":"session","version":1,"id":"legacy"}` + "\n" + + `{"type":"message","role":"user","content":"old question"}` + "\n" + + `{"type":"error","error":{"door":"taken over","message":"turn ended: taken over"}}` + "\n" + if err := os.WriteFile(path, []byte(journal), 0600); err != nil { + t.Fatal(err) + } + door := clientToolsFixture() + a, _ := newTestAgent(t, &scriptedCompleter{steps: []step{clientToolSourceStep("legacy"), finishingStep()}}, + func(c *Config) { c.SessionFile = path; c.ClientTools = door; c.PromptProfile = "full" }) + events, resumed := a.ResumeStoppedTurn(context.Background()) + if !resumed { + t.Fatal("legacy stopped turn did not resume") + } + collect(t, events) + if len(door.calls) != 1 || door.calls[0].SourceMessage != "" { + t.Fatalf("legacy line gained identity: %+v", door.calls) + } +} + +// Source contract (b), (d): quit and close carry-ons never name a source message, +// even when a person opened the interrupted run in the reopened journal. +func TestClientToolsSourceCarryOnReplay(t *testing.T) { + for _, closeKey := range []bool{false, true} { + for _, wake := range []bool{false, true} { + t.Run(fmt.Sprintf("close=%t/wake=%t", closeKey, wake), func(t *testing.T) { + path := filepath.Join(t.TempDir(), "session.jsonl") + started := make(chan struct{}) + steps := []step{thinkingStep(started)} + if wake { + steps = append([]step{finishingStep()}, steps...) + } + a, _ := newTestAgent(t, &scriptedCompleter{steps: steps}, func(c *Config) { c.SessionFile = path }) + t.Cleanup(a.Interrupt) + var events <-chan Event + if wake { + collect(t, mustSubmit(t, a, "original question")) + wakes, stop := a.WatchWakes() + defer stop() + if !a.enqueueNote(wakeNote("a job finished")) { + t.Fatal("initial wake note refused") + } + events = awaitWallStream(t, wakes, 10*time.Second) + } else { + events = mustSubmit(t, a, "original question") + } + waitSignal(t, started, "the interrupted provider step") + a.InterruptFor(StopByClosing) + collect(t, events) + if err := a.Close(); err != nil { + t.Fatal(err) + } + ids := journalPersonIDs(t, path) + if len(ids) != 1 { + t.Fatalf("recorded person messages=%v want one", ids) + } + door := clientToolsFixture() + fresh, _ := newTestAgent(t, &scriptedCompleter{steps: []step{clientToolSourceStep("carry"), finishingStep()}}, + func(c *Config) { c.SessionFile = path; c.ClientTools = door; c.PromptProfile = "full" }) + wakes, stop := fresh.WatchWakes() + defer stop() + accepted := false + if closeKey { + accepted = fresh.ResumeAfterCloseKey("continuing") + } else { + accepted = fresh.ResumeAfterQuit() + } + if !accepted { + t.Fatal("carry-on refused") + } + finishJobWake(t, wakes) + if len(door.calls) != 1 || door.calls[0].SourceMessage != "" { + t.Fatalf("carry-on calls=%+v want no source; recorded message=%q", door.calls, ids[0]) + } + }) + } + } +} + +// Source contract (c): only a wake answering one drained person's line names it. +func TestClientToolsSourceWakes(t *testing.T) { + for _, kind := range []string{"note", "one person", "two people", "mixed", "coalesced"} { + t.Run(kind, func(t *testing.T) { + door := clientToolsFixture() + var a *Agent + ending := func(context.Context, []ai.Message) (*ai.Response, error) { + var queued []userMessage + switch kind { + case "note": + queued = []userMessage{wakeNote("a job finished")} + case "one person": + queued = []userMessage{userText("drained question")} + case "two people": + queued = []userMessage{userText("drained question"), userText("another question")} + case "mixed": + queued = []userMessage{userText("drained question"), wakeNote("a job finished")} + case "coalesced": + queued = []userMessage{wakeNote("a job finished"), wakeNote("another job finished")} + } + for i := range queued { + if queued[i].wake { + queued[i].authored = true + } + } + // Land after the final request, through the same queue the end drain reads. + a.mu.Lock() + a.steering = append(a.steering, queued...) + a.mu.Unlock() + return textResponse("first answer"), nil + } + a, _ = newTestAgent(t, &scriptedCompleter{steps: []step{clientToolSourceStep("person"), ending, clientToolSourceStep("wake"), finishingStep()}}, + func(c *Config) { c.ClientTools = door; c.PromptProfile = "full" }) + wakes, stop := a.WatchWakes() + defer stop() + collect(t, mustSubmit(t, a, "original question")) + finishJobWake(t, wakes) + want := "" + for _, entry := range a.Transcript() { + if kind == "one person" && entry.Text == "drained question" && entry.Person != nil && entry.Person.Composed != nil { + want = entry.Person.Composed.MessageID + } + } + if kind == "one person" && want == "" { + t.Fatal("drained message has no recorded id") + } + if len(door.calls) != 2 || door.calls[0].SourceMessage == "" || door.calls[1].SourceMessage != want { + t.Fatalf("calls=%+v want wake source %q", door.calls, want) + } + }) + } +} + +// Source contract (d): a steer and a runtime note cannot replace a running turn's source. +func TestClientToolsSourceSurvivesSteering(t *testing.T) { + door := clientToolsFixture() + entered, release := make(chan struct{}), make(chan struct{}) + first := func(ctx context.Context, messages []ai.Message) (*ai.Response, error) { + close(entered) + select { + case <-release: + case <-ctx.Done(): + return nil, ctx.Err() + } + return clientToolSourceStep("before")(ctx, messages) + } + a, _ := newTestAgent(t, &scriptedCompleter{steps: []step{first, clientToolSourceStep("after"), finishingStep()}}, + func(c *Config) { c.ClientTools = door; c.PromptProfile = "full" }) + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + events, err := a.Submit(ctx, "original question") + if err != nil { + t.Fatal(err) + } + waitSignal(t, entered, "the first provider step before steering") + steer := mustSubmit(t, a, "a correction") + if !a.enqueueNote(wakeNote("a job finished")) { + t.Fatal("runtime note refused") + } + close(release) + collect(t, events) + collect(t, steer) + var ids []string + for _, entry := range a.Transcript() { + if entry.Role == "user" && entry.Person != nil && entry.Person.Composed != nil { + ids = append(ids, entry.Person.Composed.MessageID) + } + } + if len(ids) != 2 || ids[0] == ids[1] || len(door.calls) != 2 { + t.Fatalf("ids=%v calls=%+v", ids, door.calls) + } + for _, call := range door.calls { + if call.SourceMessage != ids[0] { + t.Errorf("source=%q want opener %q, steer=%q", call.SourceMessage, ids[0], ids[1]) + } + } +} + +func (d *clientToolsFakeDoor) ClientToolSets() (uint64, []ClientToolSet) { return d.version, d.sets } +func (d *clientToolsFakeDoor) CallClientTool(_ context.Context, call ClientToolCall) (ClientToolAnswer, error) { + d.calls = append(d.calls, call) + return d.answer, nil +} + +func clientToolsFixture() *clientToolsFakeDoor { + return &clientToolsFakeDoor{version: 1, sets: []ClientToolSet{{ID: "notes", Instructions: "Use the app's notes.", DeadlineMS: ClientToolDefaultDeadlineMS, + Tools: []ClientTool{{Name: "app_note", Description: "Read a note", InputSchema: json.RawMessage(`{"type":"object","properties":{"id":{"type":"string"}}}`), Category: ActionRead}}}}} +} + +// Contract (a), (e): definitions and instructions follow only this conversation's sets. +func TestClientToolsTailInstructionsAndRemoval(t *testing.T) { + door := clientToolsFixture() + a := &Agent{config: Config{ClientTools: door, profile: profileFull}} + builtins := []bare.Tool{{Name: "read", Schema: json.RawMessage(`{"type":"object"}`)}} + a.tools = a.appendClientTools(builtins) + a.definitions, _ = toolDefinitions(a.tools) + defs := a.beltDefinitions() + if len(defs) != 2 || a.tools[1].Name != "app_note" { + t.Fatalf("client tool is not the tail: %+v", a.tools) + } + encoded, _ := json.Marshal(defs[1]) + for _, word := range []string{"app_note", "Read a note", "properties", "id"} { + if !strings.Contains(string(encoded), word) { + t.Errorf("definition lost %q: %s", word, encoded) + } + } + page := renderSystem(a.config) + if !strings.Contains(page, "## Tools from the app: notes\nUse the app's notes.") { + t.Fatal("instructions absent") + } + if strings.Contains(renderSystem(Config{}), "Tools from the app") { + t.Fatal("ordinary prompt gained client bytes") + } + if got := testing.AllocsPerRun(10, a.clientToolsBoundary); got != 0 { + t.Fatalf("unchanged boundary allocated %v", got) + } + door.version++ + door.sets = nil + a.clientToolsBoundary() + if len(a.beltTools()) != 1 || len(defs) != 2 { + t.Fatal("removal changed an old reader or kept the tool") + } + if strings.Contains(renderSystem(a.config), "Tools from the app") { + t.Fatal("removed instructions remain") + } +} + +// Contract (b), (h): object arguments only, errors and provider ids cross the door. +func TestClientToolsExecuteArgumentsAndError(t *testing.T) { + door := clientToolsFixture() + a := &Agent{config: Config{ClientTools: door}} + tool := a.appendClientTools(nil)[0] + for _, args := range []string{"null", "[]", "1", `"x"`, `"{\"id\":3}"`, "{"} { + text, failed, err := tool.Execute(context.Background(), json.RawMessage(args)) + if !failed || err != nil || !strings.Contains(text, "Invalid arguments") { + t.Fatalf("%s: %q %v %v", args, text, failed, err) + } + } + if len(door.calls) != 0 { + t.Fatal("non-object reached the client") + } + door.answer = ClientToolAnswer{Text: "notes could not do app_note: unavailable", IsError: true} + text, failed, err := tool.Execute(withCallID(context.Background(), "provider-7"), json.RawMessage(`{}`)) + if err != nil || !failed || text != door.answer.Text || door.calls[0].TurnCall != "provider-7" { + t.Fatalf("answer/id lost: %q %+v", text, door.calls) + } +} + +// Source contract (a), (d): Execute reads only the immutable call context. +func TestClientToolsExecuteSourceMessage(t *testing.T) { + door := clientToolsFixture() + a := &Agent{config: Config{ClientTools: door}} + tool := a.appendClientTools(nil)[0] + for _, test := range []struct { + ctx context.Context + want string + }{ + {withTurnOrigin(context.Background(), turnOrigin{turn: "crew-opener", answers: "msg-answer"}), "msg-answer"}, + {withTurnOrigin(context.Background(), turnOrigin{turn: "crew-opener"}), ""}, + {context.Background(), ""}, + } { + if _, failed, err := tool.Execute(test.ctx, json.RawMessage(`{}`)); failed || err != nil { + t.Fatalf("execute: failed=%v err=%v", failed, err) + } + if got := door.calls[len(door.calls)-1].SourceMessage; got != test.want { + t.Fatalf("source=%q want %q", got, test.want) + } + } +} + +// Contract (h): classification is per conversation and client tools never start early. +func TestClientToolsCategoriesAndBuiltinWins(t *testing.T) { + door := clientToolsFixture() + a := &Agent{config: Config{ClientTools: door}} + a.tools = a.appendClientTools(nil) + a.definitions, _ = toolDefinitions(a.tools) + if a.ActionCategoryForTool("app_note") != ActionRead || (&Agent{}).ActionCategoryForTool("app_note") != ActionWork { + t.Fatal("category escaped the conversation") + } + otherDoor := clientToolsFixture() + otherDoor.sets[0].Tools[0].Category = ActionCreate + other := &Agent{config: Config{ClientTools: otherDoor}} + other.tools = other.appendClientTools(nil) + if other.ActionCategoryForTool("app_note") != ActionCreate || a.ActionCategoryForTool("app_note") != ActionRead { + t.Fatal("two conversations shared a category declaration") + } + if earlyTools["app_note"] { + t.Fatal("client tool can start speculatively") + } + old := a.beltTools() + if _, err := a.armFamily([]bare.Tool{{Name: "app_note", Description: "Built-in", Schema: json.RawMessage(`{"type":"object"}`)}}); err != nil { + t.Fatal(err) + } + if len(a.tools) != 1 || a.tools[0].Description != "Built-in" || old[0].Description == "Built-in" { + t.Fatal("late built-in did not win with fresh arrays") + } +} + +// Contract (a), (h): newly armed built-ins keep their prefix ahead of the app tail. +func TestClientToolsLateBuiltinPrecedesTail(t *testing.T) { + door := clientToolsFixture() + a := &Agent{config: Config{ClientTools: door}} + a.tools = a.appendClientTools([]bare.Tool{{Name: "read", Schema: json.RawMessage(`{"type":"object"}`)}}) + a.definitions, _ = toolDefinitions(a.tools) + oldTools, oldDefinitions := a.beltTools(), a.beltDefinitions() + if _, err := a.armFamily([]bare.Tool{{Name: "new_builtin", Schema: json.RawMessage(`{"type":"object"}`)}}); err != nil { + t.Fatal(err) + } + tools := a.beltTools() + if len(tools) != 3 || tools[0].Name != "read" || tools[1].Name != "new_builtin" || tools[2].Name != "app_note" { + t.Fatalf("belt order: %+v", tools) + } + if len(oldTools) != 2 || oldTools[1].Name != "app_note" || len(oldDefinitions) != 2 { + t.Fatal("arming rewrote an old request") + } +} + +// Contract (a), (h): automation looks arm a family without a complete definition cache. +func TestClientToolsArmFamilyWithMissingDefinitions(t *testing.T) { + for _, withClient := range []bool{false, true} { + for _, cached := range []string{"nil", "short prefix", "short tail"} { + t.Run(fmt.Sprintf("client=%t/cache=%s", withClient, cached), func(t *testing.T) { + a := &Agent{} + a.tools = []bare.Tool{ + {Name: "read", Schema: json.RawMessage(`{"type":"object"}`)}, + {Name: "bash", Schema: json.RawMessage(`{"type":"object"}`)}, + } + if withClient { + a.config.ClientTools = clientToolsFixture() + a.tools = a.appendClientTools(a.tools) + } + if cached != "nil" { + from := a.tools[:1] + if cached == "short tail" { + from = a.tools[len(a.tools)-1:] + } + var err error + a.definitions, err = toolDefinitions(from) + if err != nil { + t.Fatal(err) + } + } + cachedCount := len(a.definitions) + oldTools, oldDefinitions := a.beltTools(), a.beltDefinitions() + names, err := a.armFamily([]bare.Tool{{Name: "account_read", Schema: json.RawMessage(`{"type":"object"}`)}}) + if err != nil || len(names) != 1 || names[0] != "account_read" { + t.Fatalf("arming: %v, %v", names, err) + } + want := []string{"read", "bash", "account_read"} + if withClient { + want = append(want, "app_note") + } + tools, definitions := a.beltTools(), a.beltDefinitions() + if len(tools) != len(want) || len(definitions) != len(want) { + t.Fatalf("belt/cache lengths: %d/%d, want %d", len(tools), len(definitions), len(want)) + } + for i, name := range want { + if tools[i].Name != name || definitions[i].Function.Name != name { + t.Fatalf("belt/cache at %d: %s/%s, want %s", i, tools[i].Name, definitions[i].Function.Name, name) + } + } + if len(oldDefinitions) != cachedCount || oldTools[1].Name != "bash" || (withClient && oldTools[2].Name != "app_note") { + t.Fatal("arming changed an earlier snapshot") + } + }) + } + } +} + +// Contract (a), (b): the ordinary provider loop offers definitions and consumes the answer. +func TestClientToolsModelRequestAndToolResult(t *testing.T) { + for _, failed := range []bool{false, true} { + t.Run(map[bool]string{false: "result", true: "error"}[failed], func(t *testing.T) { + door := clientToolsFixture() + door.answer = ClientToolAnswer{Text: `{"note":"found"}`, IsError: failed} + if failed { + door.answer.Text = "notes could not do app_note: unavailable" + } + recorder := &blockRecorder{steps: []step{ + func(context.Context, []ai.Message) (*ai.Response, error) { + return toolResponse("provider-7", "app_note", `{"id":"n"}`), nil + }, + func(context.Context, []ai.Message) (*ai.Response, error) { return textResponse("done"), nil }, + }} + a, _ := newTestAgent(t, recorder, func(c *Config) { c.System = ""; c.ClientTools = door; c.PromptProfile = "full" }) + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) + defer cancel() + events, err := a.Submit(ctx, "read the app note") + if err != nil { + t.Fatal(err) + } + collected := collect(t, events) + if len(door.calls) != 1 || door.calls[0].TurnCall != "provider-7" { + t.Fatalf("dispatches: %+v", door.calls) + } + if got := recorder.resultAt(t, 1, "provider-7"); got != door.answer.Text { + t.Fatalf("model result: %q", got) + } + recorder.mu.Lock() + block := recorder.blocks[0] + page := messageText(recorder.sent[0][0]) + recorder.mu.Unlock() + definition, _ := json.Marshal(block[len(block)-1]) + if !strings.Contains(string(definition), "app_note") || !strings.Contains(page, "## Tools from the app: notes") { + t.Fatalf("request lost its tail/page: %s", definition) + } + ended := false + for _, event := range collected { + if event.CallID != "provider-7" { + continue + } + if event.Kind == EventToolBegin && toolEventCategory(event) != ActionRead { + t.Fatal("step lost the declared category") + } + if event.Kind == EventToolEnd || event.Kind == EventToolFailed { + ended = true + if (event.Kind == EventToolFailed) != failed { + t.Fatalf("error flag lost: %+v", event) + } + } + } + if !ended { + t.Fatal("tool result lifecycle absent") + } + }) + } +} + +// Contract (e): instructions rerender only on this agent's own page at a boundary. +func TestClientToolsBoundaryRefreshesOwnedPageAndPreservesCallerPage(t *testing.T) { + for _, supplied := range []string{"", "The caller's page"} { + door := clientToolsFixture() + a, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { c.System = supplied; c.ClientTools = door }) + door.version++ + door.sets = []ClientToolSet{{ID: "notes", Instructions: "Read the updated notes.", Tools: door.sets[0].Tools}} + a.clientToolsBoundary() + if supplied != "" && a.system != supplied { + t.Fatal("the caller's page was rewritten") + } + if supplied == "" && !strings.Contains(a.system, "Read the updated notes.") { + t.Fatal("instruction change did not rerender") + } + door.version++ + door.sets = nil + a.clientToolsBoundary() + if strings.Contains(a.system, "Tools from the app") { + t.Fatal("disconnected page kept instructions") + } + if notice, ok := a.clientToolRetiredNotice("app_note"); !ok || notice != ClientToolUnavailable("app_note").Text { + t.Fatal("stale call lost the unavailable answer") + } + } +} + +// Contract (h): declared reads alone can cross a reading-only context. +func TestClientToolsReadingCategoriesAndApprovalPolicy(t *testing.T) { + door := clientToolsFixture() + a := &Agent{config: Config{ClientTools: door, taskControl: &TaskNode{spec: taskSpec{quick: &quickTaskSpec{reading: true}}}}} + a.tools = a.appendClientTools(nil) + if a.readingToolRefusal("app_note") != "" { + t.Fatal("declared read refused") + } + for _, category := range []ActionCategory{ActionEdit, ActionCreate} { + door.sets[0].Tools[0].Category = category + a.tools = a.appendClientTools(nil) + if a.readingToolRefusal("app_note") == "" { + t.Fatalf("reading allowed %s", category) + } + } + for _, action := range []approval.Action{approval.ActionAllow, approval.ActionPrompt, approval.ActionDeny} { + a.config.ApprovalPolicy = &approval.Policy{Default: action} + for _, name := range []string{"app_note", "factory_add", "change_setting"} { + decision, _ := a.decide(ai.ToolCall{Function: ai.ToolCallFunction{Name: name, Arguments: `{}`}}) + if decision.Action != action { + t.Fatalf("%s changed the ordinary policy: %+v", name, decision) + } + } + } +} + +// Contract (a), (h), source contract (c): errands and children have no client capability. +func TestClientToolsDoNotTravelToChildren(t *testing.T) { + door := clientToolsFixture() + door.sets[0].Instructions += clientToolSectionEnd + "\nThese are still the app's instructions." + page := "The caller's page" + clientToolsSection(Config{ClientTools: door}) + "\nFooter" + if got := clientToolsWithoutSections(page); got != "The caller's page\nFooter" { + t.Fatalf("child page: %q", got) + } + for _, config := range []Config{{ClientTools: door, InTask: true}, {ClientTools: door, Errand: true}} { + _, sets := config.clientToolSets() + if len(sets) != 0 || clientToolsSection(config) != "" { + t.Fatal("child inherited a set") + } + if tools := (&Agent{config: config}).appendClientTools(nil); len(tools) != 0 || len(door.calls) != 0 { + t.Fatal("an errand or child gained a client tool or emitted a call") + } + } + started := make(chan struct{}) + parent, _ := newTestAgent(t, &scriptedCompleter{steps: []step{ + func(context.Context, []ai.Message) (*ai.Response, error) { + return toolResponse("original", "write", `{"path":"note.txt","content":"note"}`), nil + }, + func(ctx context.Context, _ []ai.Message) (*ai.Response, error) { + close(started) + <-ctx.Done() + return nil, ctx.Err() + }, + }}, func(c *Config) { + c.System, c.PromptProfile = "", "full" + c.ClientTools = door + c.AskConsent, c.Interactive = true, true + c.ApprovalPolicy = promptAll() + }) + t.Cleanup(parent.Interrupt) + if parent.config.ClientTools == nil || !strings.Contains(parent.system, "Tools from the app: notes") { + t.Fatal("parent lacks the capability this test must exclude") + } + cfg := parent.config + cfg.System = parent.system + cfg.SessionFile, cfg.Place = "", Place{} + cfg.InTask = true + child, err := parent.newChildAgent(cfg) + if err != nil { + t.Fatal(err) + } + t.Cleanup(func() { _ = child.Close() }) + clientToolsAssertExcluded(t, child) + + watch, stop := parent.WatchQuestions() + defer stop() + mustSubmit(t, parent, "write note.txt") + q := discussionWait(t, watch, func(e Event) bool { return e.Kind == EventQuestion }).Question + if err := parent.ResolveQuestion(Answer{Kind: q.Kind, ID: q.ID, Clarify: true, AskedBack: []Exchange{{Asked: "why this note?"}}}); err != nil { + t.Fatal(err) + } + waitSignal(t, started, "the clarification provider step") + parent.mu.Lock() + clarification := parent.discussions["clarify-1"].child + parent.mu.Unlock() + clientToolsAssertExcluded(t, clarification) +} + +func clientToolsAssertExcluded(t *testing.T, child *Agent) { + t.Helper() + if child.config.ClientTools != nil { + t.Fatal("child inherited the client door") + } + for _, page := range []string{child.config.System, child.system} { + if strings.Contains(page, "Tools from the app") || strings.Contains(page, "These are still the app's instructions.") { + t.Fatal("child inherited app instructions") + } + } + for _, definition := range child.beltDefinitions() { + if definition.Function.Name == "app_note" { + t.Fatal("child offers a client tool") + } + } +} diff --git a/internal/session/clientdoor.go b/internal/session/clientdoor.go index de08894a3c..b6edc500d0 100644 --- a/internal/session/clientdoor.go +++ b/internal/session/clientdoor.go @@ -764,6 +764,7 @@ func (a *Agent) completeWithNamedModel(ctx context.Context, purpose callPurpose, // task's guard's (taskcrew.go), and the person's turn is theirs. var helper *SpendGuard var held float64 + var reservation spendReservation if purpose != purposeTurn && !isCrewSeatCall(ctx) { task := crewTaskOf(ctx) if err := task.beginCall(); err != nil { @@ -777,7 +778,7 @@ func (a *Agent) completeWithNamedModel(ctx context.Context, purpose callPurpose, } helper = a.helperGuard(task) var err error - if held, err = helper.before(ctx, model, messages, options); err != nil { + if held, err = helper.before(ctx, model, messages, options, &reservation); err != nil { return nil, model, err } if helper != nil { @@ -845,7 +846,7 @@ func (a *Agent) completeWithNamedModel(ctx context.Context, purpose callPurpose, actual = response.Model } if helper != nil { - helper.after(ctx, model, response, held) + helper.after(ctx, model, response, held, &reservation) crewTaskOf(ctx).addHelperSpend(response) } return response, called, err diff --git a/internal/session/connect.go b/internal/session/connect.go index 2d52f4fbc8..103d00f84c 100644 --- a/internal/session/connect.go +++ b/internal/session/connect.go @@ -565,7 +565,9 @@ func (a *Agent) armFamily(tools []bare.Tool) ([]string, error) { held := make(map[string]bool, len(a.tools)) for _, tool := range a.tools { - held[tool.Name] = true + if _, client := a.clientTools.names[tool.Name]; !client { + held[tool.Name] = true + } } var arriving []bare.Tool var names []string @@ -575,6 +577,7 @@ func (a *Agent) armFamily(tools []bare.Tool) ([]string, error) { } arriving = append(arriving, tool) names = append(names, tool.Name) + held[tool.Name] = true } if len(arriving) == 0 { return nil, nil @@ -586,12 +589,14 @@ func (a *Agent) armFamily(tools []bare.Tool) ([]string, error) { // Fresh arrays, exact length: the old ones are still being walked by // whatever took a snapshot a moment ago, and appending into spare capacity // would write into the array they are reading. - grownTools := make([]bare.Tool, 0, len(a.tools)+len(arriving)) - grownTools = append(append(grownTools, a.tools...), arriving...) - grownDefinitions := make([]ai.ToolDefinition, 0, len(a.definitions)+len(definitions)) - grownDefinitions = append(append(grownDefinitions, a.definitions...), definitions...) - a.tools = grownTools - a.definitions = grownDefinitions + builtins, builtinDefinitions, err := a.clientToolBuiltinsLocked(len(arriving)) + if err != nil { + return nil, err + } + builtins = append(builtins, arriving...) + a.tools = a.clientToolTailLocked(builtins, a.clientTools.sets) + tail, _ := toolDefinitions(a.tools[len(builtins):]) + a.definitions = append(append(builtinDefinitions, definitions...), tail...) for _, tool := range arriving { known := false for _, kept := range a.profileArmed { diff --git a/internal/session/conversation_models.go b/internal/session/conversation_models.go index 4dc9de4761..0c09edc900 100644 --- a/internal/session/conversation_models.go +++ b/internal/session/conversation_models.go @@ -93,12 +93,17 @@ func (a *Agent) setConversationModels(pair ConversationModels, mainNotice string return nil, errors.New("this conversation was launched with one-model; a fallback is unavailable") } hadPair := a.conversationModels != nil - previous := ConversationModels{Main: a.model} + previous := ConversationModels{Main: a.configuredModelLocked()} if a.conversationModels != nil { previous = *a.conversationModels } active := a.modelIdentity.Active != "" a.conversationModels = &pair + // The pair is the next request's selection, including when its main did + // not change. A queued temporary main must not outlive this explicit pick. + a.spokenModel = "" + a.modelForNowConfigured = "" + a.modelForNowProvider = "" a.model = pair.Main if a.clientPool != nil { a.clientPool.setSeat(pair.Main) diff --git a/internal/session/digest.go b/internal/session/digest.go index 13ae4c6053..a1c1d58f3f 100644 --- a/internal/session/digest.go +++ b/internal/session/digest.go @@ -31,6 +31,11 @@ type Digest struct { // Usage is one row per journaled usage line, in file order. Usage []UsageLine + // Late receipts were banked outside every usage seal. Only Spend adds them; + // Usage stays the usage-line list its ledger readers already know. + lateUSD float64 + lateTokens int + placeholder string } @@ -43,6 +48,7 @@ var digestFolds = map[string]foldFunc{ "session": foldHeader, "title": foldTitle, "usage": foldUsage, + "call": foldCall, "message": foldMessage, } @@ -115,6 +121,14 @@ func foldUsage(d *Digest, _ []byte, e sessionEntry) { }) } +func foldCall(d *Digest, _ []byte, e sessionEntry) { + if e.Call == nil || e.Call.Arm == "" { + return + } + d.lateUSD += e.Call.CostUSD + d.lateTokens += e.Call.Input + e.Call.Output +} + func foldMessage(d *Digest, _ []byte, e sessionEntry) { if e.Role != "user" || e.Note { return @@ -130,14 +144,14 @@ func parseStamp(s string) time.Time { return t } -// Spend is the transcript's own total: money and input plus output tokens, the -// two figures [Meta] stamps. +// Spend is the transcript's own total: usage seals plus late-receipt call +// lines, in money and input plus output tokens, the two figures [Meta] stamps. func (d Digest) Spend() (usd float64, tokens int) { for _, u := range d.Usage { usd += u.USD tokens += u.Input + u.Output } - return usd, tokens + return usd + d.lateUSD, tokens + d.lateTokens } // Derive lays what the transcript says over m and returns it. Fields the diff --git a/internal/session/inherit.go b/internal/session/inherit.go index 3113ed686e..ccbeb8115a 100644 --- a/internal/session/inherit.go +++ b/internal/session/inherit.go @@ -387,6 +387,7 @@ func adoptSeed(child *Agent, quick *quickTaskSpec) { // carries a System gets at construction (agent.go), said here because this // page arrives after the agent was built. child.system = promotedSystem(quick.system) + child.system = clientToolsWithoutSections(child.system) child.systemOwn = false messages[0] = textMessage("system", child.system) child.messages = messages diff --git a/internal/session/loop.go b/internal/session/loop.go index c24bd50e77..99db6b1d27 100644 --- a/internal/session/loop.go +++ b/internal/session/loop.go @@ -1003,6 +1003,10 @@ func (a *Agent) runTurn(ctx context.Context, hub *eventHub, user userMessage) (f // asked here only for the sentence it carries ([Agent.markSettleBound]). if count, tripped := a.settleBoundTripped(ctx, &turn, settleCalls); tripped { a.markSettleBound(count) + // Keep the step spend without duration or ordinary turn-ending acts; + // abandonment can displace this turn while the bound is marked. + turn.Duration = 0 + a.file.appendUsage(turn, model, false, "") return false } @@ -1467,20 +1471,23 @@ func (a *Agent) runTurn(ctx context.Context, hub *eventHub, user userMessage) (f // the fold, the readings that ride beside it — and the law says it is // milliseconds. pace.resultsIn(time.Now()) - // AND A TURN THIS SESSION HAS ALREADY LET GO OF STOPS HERE, WRITING - // NOTHING. [waitBatch] is the one wait in this loop that can return with - // its work still running, and everything below this line writes: the tool - // messages go into a.messages, which by now belongs to whatever turn the - // person started after they stopped this one. A left-behind turn appending + // AND A TURN THIS SESSION HAS ALREADY LET GO OF STOPS HERE, KEEPING ONLY + // ITS STEP SPEND. [waitBatch] can return with its work still running, + // and everything below this line writes: the tool messages go into + // a.messages, which belongs to the turn the person started next. + // A left-behind turn appending // its results there would be another conversation's transcript growing // tool rows nobody asked for. // - // It is read here and nowhere else because this is the only boundary an - // abandoned turn can reach — the batch above is what it was parked in, and - // its own cleanup already knows to do nothing (agent.go's turnSeq). The - // read is non-blocking and costs an ordinary turn one closed-channel test - // per tool round. + // The local tally holds conversation steps only; on-clock errands already + // wrote their own auxiliary usage lines. Bank moved the live meter and + // machine ledger, so this writes only the journal seal, with no duration + // or ordinary turn-ending acts. Each exit keeps its own seal; + // this return cannot reach another ending. Cleanup already knows to leave + // the displaced turn alone (agent.go's turnSeq). if abandoned(ctx) { + turn.Duration = 0 + a.file.appendUsage(turn, model, false, "") return false } if severed != nil { @@ -1499,6 +1506,10 @@ func (a *Agent) runTurn(ctx context.Context, hub *eventHub, user userMessage) (f } if a.stageBatchFinal(calls, results) { + // Keep the step spend without duration or ordinary turn-ending acts; + // abandonment can displace this turn after the batch check above. + turn.Duration = 0 + a.file.appendUsage(turn, model, false, "") return true } // The batch can settle after its result lines and each call's `took` @@ -1815,11 +1826,10 @@ func (a *Agent) keepSteeredPartial(partial *partialBuffer, reasoning *reasoningB // sealTurn stamps the turn's wall duration, folds it into the session total, // and writes the turn down. // -// THIS IS THE ONE PLACE A TURN'S COST REACHES THE JOURNAL, and it is here -// because every turn shape in the package ends through it: the loop above, the -// harness turn, the orchestrated turn and the image turn, on the answering path -// and on both failing ones. A write anywhere else would be a turn shape that -// silently kept no record. +// Ordinary endings write their cost here: the loop above, the harness turn, +// the orchestrated turn and the image turn. The abandoned tool-batch, settle-bound +// and final stage-report exits write only their step seals, without duration or +// ordinary turn-ending acts that could affect a replacement turn after abandonment. // // AND IT IS NOT WHERE THE MACHINE'S LEDGER IS WRITTEN, which is the whole of // issue #269. A seal is the shape of a TURN — its duration, its place in the @@ -1842,7 +1852,7 @@ func (a *Agent) sealTurn(turn Usage, started time.Time, model string) Usage { a.mu.Unlock() a.file.appendUsage(turn, model, false, "") // AND THE SESSION'S RUNNING TOTAL IS STAMPED BESIDE IT, for the reason this - // function is the one place the journal is written: what a conversation has + // function owns ordinary turn endings: what a conversation has // cost is a fact every reader of the machine wants and only the transcript // holds, and a surface listing every session on disk cannot open every // transcript to find it (placemeta.go's [Agent.stampSpend]). A turn that @@ -1916,7 +1926,7 @@ 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) { // The current provider call is never cancelled by a selection. Every later // outbound attempt observes the newly acknowledged pair. - if pair, ok := a.ConversationModelPair(); ok { + if pair, ok := a.requestModelPair(); ok { a.mu.Lock() a.fallbackNoticeOwed = true a.mu.Unlock() @@ -1931,6 +1941,10 @@ func (a *Agent) completeWithRetryReasoning(ctx context.Context, hub *eventHub, m if err := node.appBoundary(ctx); err != nil { return nil, model, err } + if action := node.treeSpendLimit(); action != "" { + node.end(TaskEndingCostLimit) + return nil, model, ErrSpendStopped{Action: action} + } if a.config.taskCapUSD > 0 && node.spend() >= a.config.taskCapUSD { return nil, model, ErrSpendStopped{Action: config.CrewTaskCapAction(a.config.taskCapUSD)} } @@ -2079,7 +2093,7 @@ func (a *Agent) completeWithRetryReasoning(ctx context.Context, hub *eventHub, m // a request boundary: no cancellation and no stale retry chain. { before, paired := requestConversationModels(ctx) - if latest, ok := a.ConversationModelPair(); ok && (!paired || latest != before) { + if latest, ok := a.requestModelPair(); ok && (!paired || latest != before) { ctx = context.WithValue(ctx, conversationModelsKey{}, latest) if latest.Main != before.Main || model == before.Fallback { takeTheModel(latest.Main, true) @@ -2319,7 +2333,7 @@ func (a *Agent) completeWithRetryReasoning(ctx context.Context, hub *eventHub, m // unasked in the same session, and that is the road this is. { before, paired := requestConversationModels(ctx) - if latest, ok := a.ConversationModelPair(); ok && (!paired || latest != before) { + if latest, ok := a.requestModelPair(); ok && (!paired || latest != before) { ctx = context.WithValue(ctx, conversationModelsKey{}, latest) if latest.Main != before.Main { takeTheModel(latest.Main, true) @@ -3577,7 +3591,7 @@ func (a *Agent) runToolsWarm(ctx context.Context, ep *episode, calls []ai.ToolCa if service != "" { serviceName = a.serviceName(service) } - hub.send(Event{ + hub.send(a.clientToolEvent(Event{ Kind: EventToolBegin, Tool: call.Function.Name, Hint: a.gloss(call), @@ -3586,7 +3600,7 @@ func (a *Agent) runToolsWarm(ctx context.Context, ep *episode, calls []ai.ToolCa Batch: batch, Service: service, ServiceName: serviceName, - }) + })) } // THE NARRATOR ARMS BESIDE THE WORK, not after a long silence. A half-second @@ -3979,6 +3993,9 @@ func (a *Agent) dispatchTool(ctx context.Context, ep *episode, hub *eventHub, ca if notice, retired := a.teamRetiredNotice(call.Function.Name); retired { return toolResult{text: notice, isError: true, harness: true} } + if notice, retired := a.clientToolRetiredNotice(call.Function.Name); retired { + return toolResult{text: notice, isError: true, harness: true} + } // A name nobody ever had keeps the old answer, and keeps it word for word: // that one IS a sentence about the model. return toolResult{text: "Unknown tool: " + call.Function.Name, isError: true} @@ -4526,11 +4543,11 @@ func (a *Agent) bank(call bankedCall) { // 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 - // here (abandon.go's [Agent.Abandon]). It is reset when a turn opens and is - // meaningless outside one, which is why it is not exported. + // The running turn's last-known spend includes on-clock errands as well as + // conversation steps. Abandon records it as evidence only; the loop's local + // step tally supplies the seal, and errands write their own auxiliary seals. + // It is reset when a turn opens and is meaningless outside one, which is why + // it is not exported. if !call.late { a.turnSpend.Input += call.used.Input a.turnSpend.Output += call.used.Output @@ -4611,8 +4628,9 @@ func (a *Agent) reconciled(receipt provider.Reconciled) { // way: EVERY request this session makes writes one, and a request whose // usage block never arrived wrote none — so the journal's call lines summed // to barely half the measured session's bill, and exactly the expensive - // half was missing. The row is evidence and never spend; the money moved - // through the door above and nowhere else. + // half was missing. The live money moved through the door above and nowhere + // else; this row is the only record a replay can restore it from, because + // late money belongs to no turn seal. a.file.appendCall(armCall(receipt)) // A cancelled turn has already sealed. Save the meter now rather than // leaving its late charge out of every reader of meta.json until another turn. diff --git a/internal/session/model_for_now.go b/internal/session/model_for_now.go new file mode 100644 index 0000000000..aff284f7ba --- /dev/null +++ b/internal/session/model_for_now.go @@ -0,0 +1,80 @@ +package session + +import ( + "errors" + "strings" +) + +// ConfiguredModel is the saved choice, even while this run needs another model. +func (a *Agent) ConfiguredModel() string { + a.mu.Lock() + defer a.mu.Unlock() + return a.configuredModelLocked() +} + +func (a *Agent) configuredModelLocked() string { + if a.modelForNowConfigured != "" { + return a.modelForNowConfigured + } + return a.model +} + +// UseModelForNow changes future requests without replacing the saved choice or +// pair. Choosing the configured model again removes the temporary selection. +func (a *Agent) UseModelForNow(model string) error { + model = strings.TrimSpace(model) + if model == "" { + return errors.New("choose a model") + } + a.modelChanges.Lock() + defer a.modelChanges.Unlock() + a.mu.Lock() + configured := a.configuredModelLocked() + if a.modelForNowProvider == "" { + a.modelForNowProvider = a.config.serviceFor(configured).Source.ID + } + a.modelForNowConfigured = configured + if model == configured { + a.modelForNowConfigured = "" + } + a.model = model + if a.running { + // Let the current call finish; the next request takes this model. + a.spokenModel = model + } + if a.clientPool != nil { + a.clientPool.setSeat(model) + } + if !a.running { + a.rebindClientLocked(model) + } + if a.config.ContextWindowFor != nil { + if window := a.config.ContextWindowFor(model); window > 0 { + a.contextWindow.Store(int64(window)) + } + } + a.noteModelWindow(model) + a.scrubBlindImagePartsLocked(model) + a.followModelOnThePageLocked() + a.mu.Unlock() + a.publishModelIdentity() + return nil +} + +// Request routing uses a copy of the saved pair so a temporary main never +// enters metadata or a later explicit edit of the conversation's fallback. +func (a *Agent) requestModelPair() (ConversationModels, bool) { + a.mu.Lock() + defer a.mu.Unlock() + if a.conversationModels == nil { + return ConversationModels{}, false + } + pair := *a.conversationModels + if a.modelForNowConfigured != "" { + pair.Main = a.model + if pair.Fallback == pair.Main { + pair.Fallback = "" + } + } + return pair, true +} diff --git a/internal/session/model_for_now_test.go b/internal/session/model_for_now_test.go new file mode 100644 index 0000000000..695da2a676 --- /dev/null +++ b/internal/session/model_for_now_test.go @@ -0,0 +1,246 @@ +package session + +import ( + "context" + "path/filepath" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/modelsource" +) + +// C5: an explicit single or pair pick supersedes a queued temporary model +// before the next tool step, without cutting the request already on that pick. +func TestExplicitChoiceSupersedesQueuedRunOnlyModel(t *testing.T) { + for _, paired := range []bool{false, true} { + t.Run(map[bool]string{false: "single", true: "pair"}[paired], func(t *testing.T) { + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + entered, release := make(chan struct{}), make(chan struct{}) + cut := make(chan struct{}, 1) + c := chained(nil, + func(callCtx context.Context, _ []ai.Message) (*ai.Response, error) { + close(entered) + select { + case <-release: + if callCtx.Err() != nil { + cut <- struct{}{} + } + return toolResponse("read", "read_file", `{"path":"README.md"}`), nil + case <-callCtx.Done(): + cut <- struct{}{} + return nil, context.Cause(callCtx) + } + }, + func(context.Context, []ai.Message) (*ai.Response, error) { return textResponse("done"), nil }, + ) + a, _ := newTestAgent(t, c, func(cfg *Config) { + if paired { + cfg.ConversationModels = &ConversationModels{Main: cfg.Model, Fallback: "chosen/backup"} + } + }) + stream, err := a.Submit(ctx, "read README") + if err != nil { + t.Fatal(err) + } + select { + case <-entered: + case <-ctx.Done(): + t.Fatal("first request did not start") + } + if err := a.UseModelForNow("available/model"); err != nil { + t.Fatal(err) + } + if paired { + _, err = a.SetConversationModels(ConversationModels{Main: "test/model", Fallback: "chosen/backup"}) + } else { + a.SetModel("test/model") + } + if err != nil { + t.Fatal(err) + } + close(release) + if events := collect(t, stream); len(textsOfKind(events, EventError)) != 0 { + t.Fatalf("turn failed: %v", textsOfKind(events, EventError)) + } + if len(cut) != 0 { + t.Fatal("confirming the active model cut its in-flight request") + } + if c.requests() != 2 || c.model(0) != "test/model" || c.model(1) != "test/model" || a.ConfiguredModel() != "test/model" { + t.Fatalf("requests=%d models=%q/%q configured=%q", c.requests(), c.model(0), c.model(1), a.ConfiguredModel()) + } + }) + } +} + +// C1, C3, C4: a run-only model serves requests without replacing saved choices. +func TestRunOnlyModelSurvivesSavingAsTheConfiguredChoice(t *testing.T) { + for _, paired := range []bool{false, true} { + t.Run(map[bool]string{false: "single", true: "pair"}[paired], func(t *testing.T) { + path := filepath.Join(t.TempDir(), "transcript.jsonl") + c := chained(nil) + a, _ := newTestAgent(t, c, func(cfg *Config) { + cfg.SessionFile, cfg.Place = path, Place{Dir: filepath.Dir(path)} + cloud := modelsource.DefaultSource("http://127.0.0.1:1") + local := modelsource.Source{ID: "local", Written: "available", Address: "http://127.0.0.1:2", KeyOptional: true} + cfg.Sources = modelsource.NewSet(modelsource.Connected{Source: cloud, Key: "fixture", Address: cloud.Address}, modelsource.Connected{Source: local, Address: local.Address}) + if paired { + cfg.ConversationModels = &ConversationModels{Main: cfg.Model, Fallback: "chosen/backup"} + } + }) + a.stampModel() + a.SettleWrites() + before, err := LoadMeta(filepath.Dir(path)) + if err != nil { + t.Fatal(err) + } + if err := a.UseModelForNow("available/model"); err != nil { + t.Fatal(err) + } + if a.config.serviceFor(a.Model()).Source.ID == before.ModelProvider { + t.Fatal("fixture must replace the configured provider as well as its model") + } + runModelForNowTurn(t, a) + if c.model(0) != "model" || a.Model() != "available/model" || a.ConfiguredModel() != "test/model" { + t.Fatalf("request=%q configured=%q", c.model(0), a.ConfiguredModel()) + } + if err := a.Close(); err != nil { + t.Fatal(err) + } + a.SettleWrites() + meta, err := LoadMeta(filepath.Dir(path)) + if err != nil { + t.Fatal(err) + } + if meta.Model != before.Model || meta.ModelProvider != before.ModelProvider || meta.ModelIdentity == nil || meta.ModelIdentity.Selected != before.Model { + t.Fatalf("saved choice changed: before=%+v after=%+v identity=%+v", before, meta, meta.ModelIdentity) + } + if paired && (meta.ModelIdentity.Models == nil || *meta.ModelIdentity.Models != *before.ModelIdentity.Models) { + t.Fatalf("saved pair changed: %+v", meta.ModelIdentity.Models) + } + fresh := chained(nil) + b, _ := newTestAgent(t, fresh, func(cfg *Config) { + cfg.Model, cfg.Place = meta.Model, Place{Dir: filepath.Dir(path)} + }) + if b.Model() != "test/model" || b.ModelIdentity().Selected != "test/model" { + t.Fatalf("reopened on %q: %+v", b.Model(), b.ModelIdentity()) + } + routes, _ := b.ErrandRoutes() + found := false + for _, route := range routes { + if route.Role == "title" { + found = true + if route.Model != "test/model" { + t.Fatalf("reopened title route: %+v", route) + } + } + } + if !found { + t.Fatal("title route missing") + } + runModelForNowTurn(t, b) + if fresh.model(0) != "test/model" { + t.Fatalf("fresh request=%q", fresh.model(0)) + } + }) + } +} + +// C5: an explicit pair edit clears the temporary main, and promoting its +// current fallback through SetModel saves that model as the new main. +func TestExplicitPairSelectionClearsTheRunOnlyMain(t *testing.T) { + a, _ := newTestAgent(t, chained(nil), func(cfg *Config) { + cfg.Place = Place{Dir: t.TempDir()} + cfg.ConversationModels = &ConversationModels{Main: cfg.Model, Fallback: "available/model"} + }) + if err := a.UseModelForNow("available/model"); err != nil { + t.Fatal(err) + } + pair := ConversationModels{Main: "test/model", Fallback: "available/model"} + if _, err := a.SetConversationModels(pair); err != nil { + t.Fatal(err) + } + if a.Model() != "test/model" || a.ConfiguredModel() != "test/model" { + t.Fatal("explicit pair edit left a temporary main") + } + if err := a.UseModelForNow("available/model"); err != nil { + t.Fatal(err) + } + a.SetModel("available/model") + a.SettleWrites() + meta, err := LoadMeta(a.config.Place.Dir) + if err != nil || meta.Model != "available/model" || meta.ModelIdentity.Models.Main != "available/model" || meta.ModelIdentity.Models.Fallback != "" { + t.Fatalf("explicit fallback promotion=%+v %v", meta, err) + } +} + +// C4, C5: restoration is transient; confirming the running fallback is durable. +func TestExplicitModelChoiceClearsRunOnlyModel(t *testing.T) { + for _, paired := range []bool{false, true} { + t.Run(map[bool]string{false: "single", true: "pair"}[paired], func(t *testing.T) { + c := chained(nil) + a, _ := newTestAgent(t, c, func(cfg *Config) { + cfg.Place = Place{Dir: t.TempDir()} + if paired { + cfg.ConversationModels = &ConversationModels{Main: cfg.Model, Fallback: "chosen/backup"} + } + }) + for _, model := range []string{"available/model", "test/model", "available/model"} { + if err := a.UseModelForNow(model); err != nil { + t.Fatal(err) + } + runModelForNowTurn(t, a) + } + if c.model(1) != "test/model" { + t.Fatalf("restored request=%q", c.model(1)) + } + a.SetModel("available/model") + a.SettleWrites() + meta, err := LoadMeta(a.config.Place.Dir) + if err != nil || meta.Model != "available/model" || a.ConfiguredModel() != "available/model" { + t.Fatalf("explicit choice not saved: %+v %v", meta, err) + } + if paired && (meta.ModelIdentity.Models.Main != "available/model" || meta.ModelIdentity.Models.Fallback != "chosen/backup") { + t.Fatalf("explicit pair=%+v", meta.ModelIdentity.Models) + } + }) + } +} + +func runModelForNowTurn(t *testing.T, a *Agent) { + t.Helper() + events, err := a.Submit(t.Context(), "respond") + if err != nil { + t.Fatal(err) + } + if got := collect(t, events); len(textsOfKind(got, EventError)) != 0 { + t.Fatalf("turn failed: %v", textsOfKind(got, EventError)) + } +} + +// C1: removing a provider must preserve its identity beside the saved model. +func TestRunOnlyModelKeepsConfiguredProviderWhenDisconnected(t *testing.T) { + cloud := modelsource.DefaultSource("http://127.0.0.1:1") + local := modelsource.Source{ID: "local", Written: "available", Address: "http://127.0.0.1:2", KeyOptional: true} + remaining := modelsource.NewSet(modelsource.Connected{Source: cloud, Key: "fixture", Address: cloud.Address}) + a, _ := newTestAgent(t, chained(nil), func(cfg *Config) { + cfg.Model, cfg.Place = "available/configured", Place{Dir: t.TempDir()} + cfg.Sources = modelsource.NewSet(modelsource.Connected{Source: cloud, Key: "fixture", Address: cloud.Address}, modelsource.Connected{Source: local, Address: local.Address}) + }) + a.stampModel() + a.SettleWrites() + a.SetSources(remaining) + // Linked-local source refresh reapplies the live model before reconciliation. + if err := a.UseModelForNow(a.Model()); err != nil { + t.Fatal(err) + } + if err := a.UseModelForNow("vendor/replacement"); err != nil { + t.Fatal(err) + } + a.SettleWrites() + meta, err := LoadMeta(a.config.Place.Dir) + if err != nil || meta.Model != "available/configured" || meta.ModelProvider != "local" { + t.Fatalf("disconnected model identity=%+v, %v", meta, err) + } +} diff --git a/internal/session/model_identity.go b/internal/session/model_identity.go index 7bf1abcde8..65fb609cab 100644 --- a/internal/session/model_identity.go +++ b/internal/session/model_identity.go @@ -23,7 +23,7 @@ type ModelIdentity struct { func (a *Agent) modelIdentityLocked() ModelIdentity { s := a.modelIdentity - s.Selected = a.model + s.Selected = a.configuredModelLocked() if a.conversationModels != nil { pair := *a.conversationModels s.Models = &pair diff --git a/internal/session/place.go b/internal/session/place.go index d0ff1ca9be..14bd1e3221 100644 --- a/internal/session/place.go +++ b/internal/session/place.go @@ -296,6 +296,9 @@ type Meta struct { // a gate a person opened from inside a conversation must be open when they // come back to it. Approval string `json:"approval,omitempty"` + // Scope is conversation when the model is told only about its own work. + // Project or absence keeps the project's reading of other conversations' work. + Scope string `json:"scope,omitempty"` // Created is when the session was minted. Created time.Time `json:"created"` // LastUserAt is when the PERSON last said something. Resume order is on @@ -513,6 +516,9 @@ func saveMetaLocked(dir string, meta Meta) error { } meta.ChatURI = current.ChatURI } + if meta.Scope == "" { + meta.Scope = current.Scope + } meta.AgentIdentity = keptAgentIdentity(current, meta) // K3: schema paths use slashes even when their source machine is Windows. for _, field := range []*string{&meta.Workspace, &meta.Origin, &meta.FromWorkspace} { diff --git a/internal/session/placemeta.go b/internal/session/placemeta.go index d92eff6aad..d0827080e3 100644 --- a/internal/session/placemeta.go +++ b/internal/session/placemeta.go @@ -336,9 +336,12 @@ func (a *Agent) fillMetaLocked(meta Meta) Meta { if meta.Created.IsZero() { meta.Created = time.Now() } - if model := strings.TrimSpace(a.model); model != "" { + if model := strings.TrimSpace(a.configuredModelLocked()); model != "" { meta.Model = model meta.ModelProvider = a.config.serviceFor(model).Source.ID + if a.modelForNowProvider != "" { + meta.ModelProvider = a.modelForNowProvider + } } // The rung is written whatever it is, absence included, because absence is // a value a person can choose their way back to: a session dialled to max @@ -515,12 +518,12 @@ func isAnswer(message ai.Message) bool { // journal written before this field existed. // // IT FOLDS NOTHING AND READS NOTHING EXTRA. The resume already replayed the -// file and already summed its usage lines ([sessionFile.RestoredUsage], which -// is what the agent's live counters are restored from — agent.go), so this -// takes that sum and writes it once. A session with nothing restored, or one -// whose meta already carries a figure, writes nothing at all: the stamp above -// keeps it true from here on, and a rewrite per open would be a file touched by -// every window that merely looked. +// file and already summed its usage lines and late-receipt call lines +// ([sessionFile.RestoredUsage], which is what the agent's live counters are +// restored from — agent.go), so this takes that sum and writes it once. A +// session with nothing restored, or one whose meta already carries a figure, +// writes nothing at all: the stamp above keeps it true from here on, and a +// rewrite per open would be a file touched by every window that merely looked. func (a *Agent) stampRestoredSpend(restored Usage) { a.spendStampMu.Lock() defer a.spendStampMu.Unlock() diff --git a/internal/session/plandb_plan.go b/internal/session/plandb_plan.go index b950c04ce3..9db73fa53b 100644 --- a/internal/session/plandb_plan.go +++ b/internal/session/plandb_plan.go @@ -49,7 +49,10 @@ type planState struct { // own plan ([NewBeltWorker]) from the run's open handle. It is what the // worker's `plandb` checks the store at path against ([plandb.RunEnv]), so // a later store at the same path cannot take the worker's writes. - root string + root string + // task binds creation to the run worker's own store task, independently of + // PLANDB_AGENT, which remains the identity used for claims and completion. + task string shimmed bool // archives holds read handles for ended stores. Ended stores are immutable, // so each is opened at most once for the life of this conversation. @@ -510,7 +513,7 @@ func (g *TaskGraph) planPulse() { if parent == nil || parent.state == TaskQueued || parent.depth+1 > taskDepthLimit { continue } - if refused := g.claimChild(parent.nodeID); refused != "" { + if refused := g.claimChild(parent.nodeID, task.Title); refused != "" { continue } spec := taskSpec{ @@ -811,6 +814,9 @@ func (g *TaskGraph) planBashPrefix() string { // whose root is another's ([plandb.RunEnv]). if plan.root != "" { prefix += " " + plandb.RunEnv + "=" + quoteShWord(plan.root) + if plan.task != "" { + prefix += " " + plandb.TaskEnv + "=" + quoteShWord(plan.task) + } } return prefix + "; " } diff --git a/internal/session/plandb_steer.go b/internal/session/plandb_steer.go index 5171b78c71..8775ce5c1f 100644 --- a/internal/session/plandb_steer.go +++ b/internal/session/plandb_steer.go @@ -51,9 +51,10 @@ func planNoTask(id string) error { // store refusal — a whole run is not held, a terminal task cannot be // cancelled, a revision is only for work that has not started — travels back // as the store wrote it, because the store is the one that knows its own laws. -func (a *Agent) planSteer(id string, write func(*plandb.Store, *plandb.Task) error) error { +func (a *Agent) planSteer(id string, write func(*plandb.Store, *plandb.Task) error, stopRoot ...bool) error { + rootStop := len(stopRoot) > 0 && stopRoot[0] for _, notice := range a.historicalPlanNotices() { - if planTaskID(notice.PlanTask) == planTaskID(id) { + if planTaskID(notice.PlanTask) == planTaskID(id) && !rootStop { return errPlanEndedRun } } @@ -76,7 +77,9 @@ func (a *Agent) planSteer(id string, write func(*plandb.Store, *plandb.Task) err // there to read them: the write landed and nothing ever read it. An ended // run answers the one sentence every earlier run answers. if root := live.Task(live.RootID()); root != nil && terminalStoreStatus(root.Status) { - return errPlanEndedRun + if !planStopOpenWork(live, task, stopRoot) { + return errPlanEndedRun + } } return write(live, task) } @@ -85,12 +88,30 @@ func (a *Agent) planSteer(id string, write func(*plandb.Store, *plandb.Task) err if task.Chat != plan.chat { return errPlanOtherChat } + if planStopOpenWork(store, task, stopRoot) { + return write(store, task) + } return errPlanEndedRun } } return planNoTask(id) } +// planStopOpenWork permits only a stop of lingering work below an ended root. +// Other steering of ended runs, and repeated stops of fully ended runs, keeps +// its existing refusal. +func planStopOpenWork(store *plandb.Store, task *plandb.Task, stopRoot []bool) bool { + if len(stopRoot) == 0 || !stopRoot[0] || task.ID != store.RootID() { + return false + } + for _, child := range store.Tasks() { + if !terminalStoreStatus(child.Status) { + return true + } + } + return false +} + // PlanNote leaves a note in the person's own voice on one task. It is the soft // steering beside the hard verbs: the task's worker is handed it between its own // steps, on the road internal/run's note channel carries, and @@ -186,7 +207,7 @@ func (a *Agent) PlanCancel(id string) error { } _, err := store.Cancel(task.ID, taskStoppedWord) return err - }) + }, true) } // PlanAmend prepends text to a task's description, the way the CLI's `task diff --git a/internal/session/plandb_tasks.go b/internal/session/plandb_tasks.go index 409d15a0af..4c72458c11 100644 --- a/internal/session/plandb_tasks.go +++ b/internal/session/plandb_tasks.go @@ -61,8 +61,11 @@ type PlanTaskRow struct { // driving its run when the next request arrived ([planTaskInterrupted]). It // crosses remote reads as row data, the way Stopped does. Interrupted bool - Seat string - Parent string + // Undriven marks ready work retained after its run's supervisor left this + // conversation. Its status stays ready for a later continuation. + Undriven bool `json:",omitempty"` + Seat string + Parent string // Depth is the row's level below the page task; direct children are zero. Depth int // Waits is the tasks this row is held behind that are not its parent: the ids @@ -151,6 +154,9 @@ func (row PlanTaskRow) StateWord() string { if row.Interrupted { return taskWordInterrupted } + if row.Undriven && row.Status == string(plandb.StatusReady) { + return taskWordQueued + } if row.Hold != "" && (row.Status == string(plandb.StatusReady) || row.Status == string(plandb.StatusRunning)) { return taskWordQueued } @@ -510,11 +516,13 @@ func (a *Agent) planTaskPageFor(id string, withProgram bool) (PlanTaskPage, plan }, source, true } -// markPlanMachineHold copies only this row's refused start. An archived store -// and a worker already running have no admission to wait for. +// markPlanMachineHold copies this row's refused start and whether its run +// still drives ready work. Archived work has no supervisor or admission here. func (a *Agent) markPlanMachineHold(row *PlanTaskRow, path string, root bool) { a.beltMu.Lock() run := a.beltRun + live := run != nil && !run.ending && run.store != nil && filepath.Clean(run.store.Path()) == filepath.Clean(path) + row.Undriven = row.Status == string(plandb.StatusReady) && !live pending := run != nil && run.pending held := run != nil && (pending || run.machineHeld[row.ID]) && run.store != nil && filepath.Clean(run.store.Path()) == filepath.Clean(path) a.beltMu.Unlock() diff --git a/internal/session/prompt.go b/internal/session/prompt.go index 582229d166..9d2ca93d82 100644 --- a/internal/session/prompt.go +++ b/internal/session/prompt.go @@ -400,6 +400,7 @@ func renderSystemAt(config Config, now time.Time) string { } out.WriteString(workerFooter(config, now)) + out.WriteString(clientToolsSection(config)) return out.String() } diff --git a/internal/session/promptprofile_live.go b/internal/session/promptprofile_live.go index 4a1d55e675..a2abd43f3b 100644 --- a/internal/session/promptprofile_live.go +++ b/internal/session/promptprofile_live.go @@ -52,6 +52,7 @@ func (a *Agent) preparePromptProfile(model string) error { state.current.Store(next) tools := a.belt() a.armMu.Lock() + tools = a.withoutClientToolsLocked(tools) // Explicitly loaded capabilities and connected services remain usable. // Only the default belt is repartitioned when the window changes. held := make(map[string]bool, len(tools)) @@ -80,6 +81,11 @@ func (a *Agent) preparePromptProfile(model string) error { held[tool.Name] = true } } + if !next.chat() { + tools = a.clientToolTailLocked(tools, a.clientTools.sets) + } else { + a.clientTools.names = nil + } definitions, err := toolDefinitions(tools) if err != nil { a.shelf, a.shelfOrder, a.prearm = oldShelf, oldOrder, oldPrearm diff --git a/internal/session/questionconversation.go b/internal/session/questionconversation.go index 613d5336df..3d4b1b90b3 100644 --- a/internal/session/questionconversation.go +++ b/internal/session/questionconversation.go @@ -96,6 +96,7 @@ func (a *Agent) clarifyQuestion(answer Answer) error { } a.mu.Lock() cfg := a.config + cfg.ClientTools = nil cfg.Model = a.model cfg.SessionFile, cfg.Place = "", Place{} cfg.rootSession = a.id @@ -108,6 +109,7 @@ func (a *Agent) clarifyQuestion(answer Answer) error { cfg.ApprovalPolicy = a.config.ApprovalPolicy } cfg.System = a.system + cfg.System = clientToolsWithoutSections(cfg.System) if a.guardianOverride != nil { cfg.Guardian = *a.guardianOverride } diff --git a/internal/session/resume.go b/internal/session/resume.go index 424416f76f..e7f33b7dca 100644 --- a/internal/session/resume.go +++ b/internal/session/resume.go @@ -155,6 +155,9 @@ func (a *Agent) ResumeStoppedTurn(ctx context.Context) (<-chan Event, bool) { resumed := userMessage{message: last, resumed: true} if mark := a.presentation.of(last); mark != nil { resumed.creation = acceptedCreation(mark.Creation) + if mark.Composed != nil { + resumed.sourceMessage = mark.Composed.MessageID + } } events := a.startTurnLocked(ctx, resumed, nil) a.mu.Unlock() diff --git a/internal/session/scope.go b/internal/session/scope.go new file mode 100644 index 0000000000..883d77834c --- /dev/null +++ b/internal/session/scope.go @@ -0,0 +1,61 @@ +package session + +import ( + "fmt" + "strings" +) + +const ( + WorkScopeProject = "project" + WorkScopeConversation = "conversation" +) + +// ConversationScopeAt reads the stored scope. An absent or unreadable identity +// keeps the project's ordinary reading of other conversations' work. +func ConversationScopeAt(dir string) string { + if strings.TrimSpace(dir) == "" { + return WorkScopeProject + } + meta, err := LoadMeta(dir) + if err == nil && meta.Scope == WorkScopeConversation { + return WorkScopeConversation + } + return WorkScopeProject +} + +// SetConversationScope changes only an existing conversation's scope. A word +// already kept, or project when no scope is stored, owes no write. +func SetConversationScope(dir, scope string) (changed bool, err error) { + if scope != WorkScopeProject && scope != WorkScopeConversation { + return false, fmt.Errorf("scope is project or conversation") + } + dir = strings.TrimSpace(dir) + if dir == "" { + return false, fmt.Errorf("set conversation scope: no conversation directory") + } + err = withMetaLock(dir, func() error { + meta, err := LoadMeta(dir) + if err != nil { + return err + } + if meta.ID == "" { + return fmt.Errorf("no conversation at %s", dir) + } + if meta.Scope == scope || (meta.Scope == "" && scope == WorkScopeProject) { + return nil + } + meta.Scope = scope + if err := saveMetaLocked(dir, meta); err != nil { + return err + } + changed = true + return nil + }) + return changed, err +} + +// ownWorkOnly is read beside the task index, so a stored choice reaches a live +// conversation without another connection or a read on the request path. +func (a *Agent) ownWorkOnly() bool { + return ConversationScopeAt(a.config.Place.Dir) == WorkScopeConversation +} diff --git a/internal/session/scope_test.go b/internal/session/scope_test.go new file mode 100644 index 0000000000..6d7750fe31 --- /dev/null +++ b/internal/session/scope_test.go @@ -0,0 +1,502 @@ +package session + +import ( + "bytes" + "context" + "encoding/json" + "os" + "path/filepath" + "reflect" + "strconv" + "strings" + "testing" + "time" +) + +// scopeRig uses the same index and presence records as the turn-context and +// tasks-tool fixtures, with an own row and another project to catch wider reads. +func scopeRig(t *testing.T) (*Agent, string) { + t.Helper() + root := t.TempDir() + bucket := filepath.Join(root, "here") + dir := filepath.Join(bucket, "0123456789abcdef") + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { + c.Place = Place{Dir: dir, Workspace: c.Workspace} + c.SessionFile = c.Place.Transcript() + }) + if err := SaveMeta(dir, Meta{ID: "0123456789abcdef", Workspace: agent.config.Workspace}); err != nil { + t.Fatal(err) + } + writeWindow(t, bucket, "theirs", "Other conversation", time.Second, + PresenceTask{ID: "4", Title: "Sweep the call sites", State: string(TaskRunning), + Files: []string{"internal/session/agent.go"}}) + appendTaskIndex(filepath.Join(bucket, taskIndexName), + deltaLandedRow("99", "theirs", "Fix the nil-map crash", time.Now().Add(-time.Minute))) + agent.mu.Lock() + id := agent.sessionID() + agent.mu.Unlock() + appendTaskIndex(filepath.Join(bucket, taskIndexName), + deltaLandedRow("7", id, "Repair this parser", time.Now().Add(-2*time.Minute))) + machineSession(t, root, "away", "another", "Separate project", t.TempDir(), 4242, time.Second, + PresenceTask{ID: "8", Title: "Separate project work", State: string(TaskRunning)}) + return agent, dir +} + +// L-FIX9-cross A1 +func TestConversationScopeLeavesOtherWorkOutOfTurnContext(t *testing.T) { + agent, dir := scopeRig(t) + if changed, err := SetConversationScope(dir, WorkScopeConversation); err != nil || !changed { + t.Fatalf("set scope = %v, %v", changed, err) + } + agent.refreshElsewhere(context.Background()) + if got := volatileNote(agent); strings.Contains(got, "") { + t.Fatalf("other work reached the turn: %s", got) + } + if agent.elsewhereText != "" || len(agent.elsewhereTold) != 0 { + t.Fatalf("other work was retained: %q, %+v", agent.elsewhereText, agent.elsewhereTold) + } + agent.SettleWrites() + if !LastTold(dir).IsZero() { + t.Fatal("a conversation told no other work advanced its told stamp") + } +} + +// L-FIX9-cross A2 +func TestConversationScopeTasksReachOnlyItsOwnRows(t *testing.T) { + agent, dir := scopeRig(t) + if _, err := SetConversationScope(dir, WorkScopeConversation); err != nil { + t.Fatal(err) + } + for _, args := range []string{`{}`, `{"scope":"everywhere"}`, `{"query":"nil-map"}`} { + text, isError := runTool(t, agent, "tasks", args) + if isError { + t.Fatalf("search %s = %s", args, text) + } + for _, absent := range []string{"Fix the nil-map crash", "Sweep the call sites", "Separate project work", "running in other codeaf windows", "running in other projects"} { + if strings.Contains(text, absent) { + t.Fatalf("search %s reached %q: %s", args, absent, text) + } + } + if args != `{"query":"nil-map"}` && !strings.Contains(text, "Repair this parser") { + t.Fatalf("own work is missing: %s", text) + } + } + for _, args := range []string{ + `{"id":99}`, `{"id":"fix-the-nil-map-crash"}`, + `{"id":99,"say":"change the guard"}`, `{"id":99,"resolve":"accept"}`, + } { + text, isError := runTool(t, agent, "tasks", args) + if !isError || !strings.Contains(text, "No task") || strings.Contains(text, "Fix the nil-map crash") { + t.Fatalf("other task %s = %s (error %v)", args, text, isError) + } + } + text, isError := runTool(t, agent, "tasks", `{"id":7}`) + if isError || !strings.Contains(text, "Repair this parser") { + t.Fatalf("own task read = %s (error %v)", text, isError) + } + if rows := agent.TaskIndex(); len(rows) != 2 { + t.Fatalf("the person's task index was narrowed: %+v", rows) + } +} + +// L-FIX9-cross A3 +func TestProjectScopeKeepsOtherWorkVisible(t *testing.T) { + for _, reset := range []bool{false, true} { + t.Run(map[bool]string{false: "absent", true: "restored"}[reset], func(t *testing.T) { + agent, dir := scopeRig(t) + if reset { + if _, err := SetConversationScope(dir, WorkScopeConversation); err != nil { + t.Fatal(err) + } + agent.refreshElsewhere(context.Background()) + if _, err := SetConversationScope(dir, WorkScopeProject); err != nil { + t.Fatal(err) + } + } + agent.refreshElsewhere(context.Background()) + for _, want := range []string{"", "Fix the nil-map crash", "Sweep the call sites"} { + if got := volatileNote(agent); !strings.Contains(got, want) { + t.Fatalf("project context lacks %q: %s", want, got) + } + } + text, isError := runTool(t, agent, "tasks", `{}`) + if isError || !strings.Contains(text, "Fix the nil-map crash") || !strings.Contains(text, "running in other codeaf windows") { + t.Fatalf("project tasks = %s (error %v)", text, isError) + } + text, isError = runTool(t, agent, "tasks", `{"scope":"everywhere"}`) + if isError || !strings.Contains(text, "Separate project work") { + t.Fatalf("project wide tasks = %s (error %v)", text, isError) + } + text, isError = runTool(t, agent, "tasks", `{"id":99}`) + if isError || !strings.Contains(text, "Fix the nil-map crash") { + t.Fatalf("project task read = %s (error %v)", text, isError) + } + }) + } +} + +// L-FIX9-cross A5 +func TestConversationScopeClearsAnEarlierBlockOnTheNextReading(t *testing.T) { + agent, dir := scopeRig(t) + agent.refreshElsewhere(context.Background()) + agent.SettleWrites() + if !strings.Contains(volatileNote(agent), "") || len(agent.elsewhereTold) == 0 { + t.Fatal("the fixture never delivered other work") + } + was := LastTold(dir) + if _, err := SetConversationScope(dir, WorkScopeConversation); err != nil { + t.Fatal(err) + } + agent.refreshElsewhere(context.Background()) + agent.SettleWrites() + agent.mu.Lock() + current := agent.volatileBlockLocked() + agent.mu.Unlock() + if agent.elsewhereText != "" || agent.elsewhereTold != nil || strings.Contains(current, "") { + t.Fatalf("earlier work survived the next reading: %s", current) + } + if !LastTold(dir).Equal(was) { + t.Fatal("clearing the block advanced the told stamp") + } +} + +// L-FIX9-cross A6 +// LoadMeta reports the same facts except scope and the build stamped on every +// metadata write; the write applies the usual metadata normalisation. +func TestConversationScopeChangesOnlyItsStoredWord(t *testing.T) { + dir := t.TempDir() + meta := Meta{ + ID: "0123456789abcdef", Workspace: t.TempDir(), Title: "Parser repairs", + Model: "test/model", Effort: "high", Approval: PostureAsk, + SpentUSD: .25, Tokens: 140, Created: time.Now().Add(-time.Hour).UTC(), + LastUserAt: time.Now().Add(-time.Minute).UTC(), Archived: true, + Places: []PlaceRef{{Path: t.TempDir()}}, + } + if err := SaveMeta(dir, meta); err != nil { + t.Fatal(err) + } + transcript := (Place{Dir: dir}).Transcript() + journal := []byte("{\"type\":\"session\",\"id\":\"0123456789abcdef\"}\n") + if err := os.WriteFile(transcript, journal, 0o600); err != nil { + t.Fatal(err) + } + before := scopeMetaFields(t, dir) + for _, word := range []string{WorkScopeConversation, WorkScopeProject} { + changed, err := SetConversationScope(dir, word) + if err != nil || !changed || ConversationScopeAt(dir) != word { + t.Fatalf("set %q = %v, %v; stored %q", word, changed, err, ConversationScopeAt(dir)) + } + after := scopeMetaFields(t, dir) + if string(after["scope"]) != strconv.Quote(word) { + t.Fatalf("scope = %s, want %q", after["scope"], word) + } + delete(after, "scope") + if !reflect.DeepEqual(before, after) { + t.Fatalf("scope changed other metadata: before %v, after %v", before, after) + } + got, err := os.ReadFile(transcript) + if err != nil || !bytes.Equal(got, journal) { + t.Fatalf("scope changed the transcript: %q, %v", got, err) + } + + path := filepath.Join(dir, placeMeta) + old := time.Unix(1234567890, 0) + if err := os.Chtimes(path, old, old); err != nil { + t.Fatal(err) + } + was, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + changed, err = SetConversationScope(dir, word) + if err != nil || changed { + t.Fatalf("repeat %q = %v, %v", word, changed, err) + } + now, err := os.Stat(path) + if err != nil || !os.SameFile(was, now) || !was.ModTime().Equal(now.ModTime()) { + t.Fatalf("repeat %q rewrote meta.json: %v", word, err) + } + } +} + +// L-FIX9-cross A6 +func TestProjectScopeLeavesAbsentScopeUnwritten(t *testing.T) { + dir := t.TempDir() + if err := SaveMeta(dir, Meta{ID: "mine"}); err != nil { + t.Fatal(err) + } + path := filepath.Join(dir, placeMeta) + old := time.Unix(1234567890, 0) + if err := os.Chtimes(path, old, old); err != nil { + t.Fatal(err) + } + before, err := os.Stat(path) + if err != nil { + t.Fatal(err) + } + if changed, err := SetConversationScope(dir, WorkScopeProject); err != nil || changed { + t.Fatalf("set absent project = %v, %v", changed, err) + } + after, err := os.Stat(path) + if err != nil || !os.SameFile(before, after) || !before.ModTime().Equal(after.ModTime()) { + t.Fatalf("default project rewrote meta.json: %v", err) + } + if _, present := scopeMetaFields(t, dir)["scope"]; present { + t.Fatal("default project wrote a scope") + } +} + +func scopeMetaFields(t *testing.T, dir string) map[string]json.RawMessage { + t.Helper() + raw, err := os.ReadFile(filepath.Join(dir, placeMeta)) + if err != nil { + t.Fatal(err) + } + var fields map[string]json.RawMessage + if err := json.Unmarshal(raw, &fields); err != nil { + t.Fatal(err) + } + return fields +} + +// L-FIX9-cross A8 +func TestConversationScopeRefusesUnknownWordsAndMissingConversations(t *testing.T) { + dir := t.TempDir() + if err := SaveMeta(dir, Meta{ID: "mine", Workspace: t.TempDir()}); err != nil { + t.Fatal(err) + } + before := scopeMetaFields(t, dir) + for _, word := range []string{"", "everywhere", "Conversation", " conversation "} { + if changed, err := SetConversationScope(dir, word); err == nil || changed { + t.Fatalf("unknown %q = %v, %v", word, changed, err) + } + } + if !reflect.DeepEqual(before, scopeMetaFields(t, dir)) { + t.Fatal("an unknown word changed metadata") + } + for _, dir := range []string{"", " ", t.TempDir(), filepath.Join(t.TempDir(), "absent")} { + if changed, err := SetConversationScope(dir, WorkScopeConversation); err == nil || changed { + t.Fatalf("no conversation at %q = %v, %v", dir, changed, err) + } + if dir != "" && dir != " " { + if _, err := os.Stat(filepath.Join(dir, placeMeta)); !os.IsNotExist(err) { + t.Fatalf("a missing conversation acquired meta.json: %v", err) + } + } + } +} + +// L-FIX9-cross A3 +func TestConversationScopeDefaultsQuietlyToProject(t *testing.T) { + for _, stored := range []string{"", "project", "Conversation", "elsewhere", WorkScopeConversation} { + dir := t.TempDir() + if err := SaveMeta(dir, Meta{ID: "mine", Scope: stored}); err != nil { + t.Fatal(err) + } + want := WorkScopeProject + if stored == WorkScopeConversation { + want = WorkScopeConversation + } + if got := ConversationScopeAt(dir); got != want { + t.Fatalf("stored %q reads %q, want %q", stored, got, want) + } + } + for _, raw := range []string{"", "{broken", `{"scope":"conversation"}`} { + dir := t.TempDir() + if raw != "" { + if err := os.WriteFile(filepath.Join(dir, placeMeta), []byte(raw), 0o600); err != nil { + t.Fatal(err) + } + } + if got := ConversationScopeAt(dir); got != WorkScopeProject { + t.Fatalf("unreadable identity %q reads %q", raw, got) + } + } + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, placeMeta), 0o700); err != nil { + t.Fatal(err) + } + if got := ConversationScopeAt(dir); got != WorkScopeProject { + t.Fatalf("failed metadata read = %q", got) + } + if got := ConversationScopeAt(""); got != WorkScopeProject { + t.Fatalf("no folder = %q", got) + } +} + +// L-FIX9-cross A4 +func TestConversationScopeSurvivesReloadAndLaterMetadataStamps(t *testing.T) { + agent, dir := scopeRig(t) + stale := agent.metaSnapshot() + if _, err := SetConversationScope(dir, WorkScopeConversation); err != nil { + t.Fatal(err) + } + place := t.TempDir() + for _, stamp := range []struct { + name string + write func() + }{ + {"effort", func() { + if !agent.SetConversationEffort("high") { + t.Fatal("effort was refused") + } + }}, + {"title", func() { agent.stampTitle("Parser repairs") }}, + {"spend", func() { agent.writeSpendSnapshot(dir, stale, .25, 140) }}, + {"places", func() { + agent.mu.Lock() + agent.places = []PlaceRef{{Path: place, Arrival: PlaceSaid}} + agent.mu.Unlock() + agent.stampPlaces() + }}, + {"trees", agent.stampTrees}, + {"model", agent.stampMeta}, + {"approval", func() { + agent.config.ApprovalGate = &fakeApprovalGate{standing: PostureAsk} + if err := agent.SetApprovalPosture(PostureAllow); err != nil { + t.Fatal(err) + } + }}, + {"user", func() { + agent.mu.Lock() + agent.stampUserLocked("Keep the parser repair") + agent.mu.Unlock() + }}, + } { + stamp.write() + agent.SettleWrites() + meta, err := LoadMeta(dir) + if err != nil || meta.Scope != WorkScopeConversation || ConversationScopeAt(dir) != WorkScopeConversation { + t.Fatalf("%s lost scope: %+v, %v", stamp.name, meta, err) + } + } + meta, err := LoadMeta(dir) + if err != nil || meta.Effort != "high" || meta.Title != "Parser repairs" || meta.SpentUSD != .25 || meta.Approval != PostureAllow || len(meta.Places) != 1 || meta.Places[0].Path != place { + t.Fatalf("the later stamps did not land: %+v, %v", meta, err) + } + if err := agent.Close(); err != nil { + t.Fatal(err) + } + reopened, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { + c.Workspace = agent.config.Workspace + c.Place = agent.config.Place + c.SessionFile = c.Place.Transcript() + }) + reopened.refreshElsewhere(context.Background()) + if strings.Contains(volatileNote(reopened), "") || ConversationScopeAt(dir) != WorkScopeConversation { + t.Fatal("a reopened conversation forgot its scope") + } +} + +// L-FIX9-cross A4 +func TestConversationScopeSurvivesStaleWholeMetadataSaves(t *testing.T) { + dir := t.TempDir() + if err := SaveMeta(dir, Meta{ID: "mine"}); err != nil { + t.Fatal(err) + } + stale, err := LoadMeta(dir) + if err != nil || stale.Scope != "" { + t.Fatalf("initial metadata = %+v, %v", stale, err) + } + stale.Title = "Parser repairs" + for _, word := range []string{WorkScopeConversation, WorkScopeProject} { + if changed, err := SetConversationScope(dir, word); err != nil || !changed { + t.Fatalf("set %q = %v, %v", word, changed, err) + } + if err := SaveMeta(dir, stale); err != nil { + t.Fatal(err) + } + meta, err := LoadMeta(dir) + if err != nil || meta.Scope != word || ConversationScopeAt(dir) != word || meta.Title != stale.Title { + t.Fatalf("stale save after %q = %+v, %v", word, meta, err) + } + } +} + +// L-FIX9-cross A2 +func TestConversationScopeKeepsItsLiveGraphWhileFilteringTheMergedIndex(t *testing.T) { + agent, _, id := runningStubbedNode(t, "Keep my live task") + dir := filepath.Join(t.TempDir(), "mine") + agent.mu.Lock() + agent.config.Place = Place{Dir: dir, Workspace: agent.config.Workspace} + agent.mu.Unlock() + if err := SaveMeta(dir, Meta{ID: "mine"}); err != nil { + t.Fatal(err) + } + number := strconv.FormatUint(id, 10) + appendTaskIndex(agent.config.taskIndexFile(), + deltaLandedRow(number, "theirs", "An unrelated newer task", time.Now())) + if _, err := SetConversationScope(dir, WorkScopeConversation); err != nil { + t.Fatal(err) + } + text, isError := runTool(t, agent, "tasks", `{}`) + if isError || !strings.Contains(text, "Keep my live task") || strings.Contains(text, "An unrelated newer task") { + t.Fatalf("merged listing = %s (error %v)", text, isError) + } + text, isError = runTool(t, agent, "tasks", `{"id":`+number+`}`) + if isError || !strings.Contains(text, "Keep my live task") || strings.Contains(text, "An unrelated newer task") { + t.Fatalf("merged read = %s (error %v)", text, isError) + } + if rows := agent.TaskIndex(); len(rows) != 2 { + t.Fatalf("the person's merged index was narrowed: %+v", rows) + } +} + +// L-FIX9-cross A2 +func TestConversationScopeLeavesATaskNodesChildrenVisible(t *testing.T) { + owner, dir := scopeRig(t) + graph := stubbedGraph(owner, func(*TaskNode) {}) + parent := graph.reserve() + graph.admit(parent, taskSpec{title: "Parent task", named: true, brief: "b", acceptance: "a"}) + child := graph.reserve() + graph.admit(child, taskSpec{title: "Its own piece", named: true, brief: "b", acceptance: "a", parent: parent}) + worker, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { + c.Place = owner.config.Place + c.InTask, c.taskID, c.tasker = true, parent, graph + }) + for _, scope := range []string{WorkScopeProject, WorkScopeConversation} { + if _, err := SetConversationScope(dir, scope); err != nil { + t.Fatal(err) + } + text, isError := runTool(t, worker, "tasks", `{"scope":"everywhere"}`) + if isError || !strings.Contains(text, "Its own piece") || strings.Contains(text, "Parent task") || strings.Contains(text, "Fix the nil-map crash") || strings.Contains(text, "Separate project work") { + t.Fatalf("node under %q = %s (error %v)", scope, text, isError) + } + worker.refreshElsewhere(context.Background()) + if worker.elsewhereText != "" { + t.Fatalf("node under %q received other work: %s", scope, worker.elsewhereText) + } + } +} + +// L-FIX9-cross A6 +func TestConversationScopeStaysInMetaBesideSealedConversationSettings(t *testing.T) { + dir := t.TempDir() + if err := os.Mkdir(filepath.Join(dir, cellStateDir), 0o700); err != nil { + t.Fatal(err) + } + if err := SaveMeta(dir, Meta{ID: "mine", Model: "test/model", Effort: "high", Approval: PostureAsk}); err != nil { + t.Fatal(err) + } + path := layoutOf(dir).metaTruth(dir) + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + for _, scope := range []string{WorkScopeConversation, WorkScopeProject} { + if _, err := SetConversationScope(dir, scope); err != nil { + t.Fatal(err) + } + if got := ConversationScopeAt(dir); got != scope { + t.Fatalf("stored scope = %q, want %q", got, scope) + } + after, err := os.ReadFile(path) + if err != nil || !bytes.Equal(before, after) { + t.Fatalf("scope changed sealed settings: %q, %v", after, err) + } + meta, err := LoadMeta(dir) + if err != nil || meta.Model != "test/model" || meta.Effort != "high" || meta.Approval != PostureAsk { + t.Fatalf("scope changed conversation settings: %+v, %v", meta, err) + } + } +} diff --git a/internal/session/seatcompleter.go b/internal/session/seatcompleter.go index 0b4effa5e2..8215a54632 100644 --- a/internal/session/seatcompleter.go +++ b/internal/session/seatcompleter.go @@ -36,7 +36,14 @@ func crewSeatOf(ctx context.Context) crewroute.Seat { // chain, because a completer that dropped it would silently end model // fallback for every task it wraps. func SeatCompleter(seat crewroute.Seat, next Completer) Completer { - marked := seatCompleter{seat: seat, next: next} + return SeatCompleterFor(seat, "", next) +} + +// SeatCompleterFor also marks the task scope within a shared crew guard. Each +// scope has its own seat ceiling; seat totals and task/day limits stay shared. +// An empty scope keeps the single-task crew's per-seat ceiling. +func SeatCompleterFor(seat crewroute.Seat, scope string, next Completer) Completer { + marked := seatCompleter{seat: seat, scope: scope, next: next} if chain, ok := next.(modelChain); ok { return seatChain{seatCompleter: marked, chain: chain} } @@ -45,11 +52,15 @@ func SeatCompleter(seat crewroute.Seat, next Completer) Completer { // seatCompleter is one seat's completer with its calls marked. type seatCompleter struct { - seat crewroute.Seat - next Completer + seat crewroute.Seat + scope string + next Completer } func (c seatCompleter) CompleteWithMessages(ctx context.Context, messages []ai.Message, options ...ai.Option) (*ai.Response, error) { + if c.scope != "" { + ctx = context.WithValue(ctx, crewScopeContextKey{}, c.scope) + } return c.next.CompleteWithMessages(context.WithValue(ctx, crewSeatContextKey{}, c.seat), messages, options...) } diff --git a/internal/session/session.go b/internal/session/session.go index 1ae21e8684..9ac864ffc3 100644 --- a/internal/session/session.go +++ b/internal/session/session.go @@ -852,6 +852,8 @@ type Event struct { // a surface reads as "unknown" and never as "nothing happened". Compaction *CompactionFact `json:"Compaction,omitempty"` + // Tool lifecycle events can also carry the conversation's declared client-tool + // category, so surfaces need no name-only guess for those dynamic tools. // 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. @@ -2531,6 +2533,9 @@ type Config struct { // foremanFloorDoor), on a launch that has a Factory door. Floor FloorDoor + // ClientTools is the door onto tools an app attached to this conversation. Nil is no door. + ClientTools ClientToolDoor + // factoryWindow overrides how long one factory card holds its tool call // open, for tests that watch the window end. Zero is [factoryCardWindow]. factoryWindow time.Duration @@ -2921,21 +2926,20 @@ type Agent struct { systemAt time.Time systemOwn bool // tools is the belt and definitions is its wire form, built once at - // construction — rebuilding them per step would re-marshal every schema on - // the hot path — and thereafter grown only at the tail under armMu - // (connect.go), with one exception: the team verbs leave when the role that - // gave them does (team.go's [Agent.retireTeamTools]). + // construction. Built-ins grow under armMu (connect.go), before the replaceable + // client-tool tail. Team verbs leave when their role ends (team.go). // // Both are COPY-ON-WRITE: arming allocates a new array and swaps the header, // so a reader that took a snapshot under armMu may walk it without the lock // and can never see a half-written slice. Nothing already in either is ever - // moved or rewritten, and nothing but a team verb is removed, because the - // definition block rides at the front of every request and a definition that - // shifts re-bills the whole prompt behind it (internal/exec's tools.go states - // the law). + // moved or rewritten in place. Built-ins keep their order while client sets + // can be replaced. The definition block rides at the front of every request, + // so a definition that shifts re-bills the whole prompt behind it + // (internal/exec's tools.go states the law). presentation *presentationIndex tools []bare.Tool definitions []ai.ToolDefinition + clientTools clientToolState // served is what the belt cannot say about the tools an ACCOUNT named // rather than this build (served.go): whose account each one is, what the // account calls it, and which capability governs it. Keyed by the name the @@ -3157,6 +3161,9 @@ type Agent struct { // the one call that must always be answerable. mu sync.Mutex model string + // modelForNowConfigured retains the saved choice while model serves this run. + modelForNowConfigured string + modelForNowProvider string // reasoning is how hard each model is asked to think, by model id, and it // is a MAP rather than a field for the reason agent.go's block states: the // level is a choice about a model, and a /model switch must not carry one diff --git a/internal/session/sessionfile.go b/internal/session/sessionfile.go index 8831ecfc48..0c851c1cf5 100644 --- a/internal/session/sessionfile.go +++ b/internal/session/sessionfile.go @@ -446,12 +446,11 @@ type journalRule struct { // journalAbandoned is ONE TURN NOBODY WAITED FOR THE END OF. // -// IT IS THE ONE LINE THAT COULD NOT BE RECONSTRUCTED. Every other record of what -// a turn did is written when the turn ENDS — the seal carries its cost, the -// principal line carries its decision — and the whole definition of an abandoned -// turn is that its ending never came. Before this line a turn let go of at a -// bound was indistinguishable in this file from a turn that simply stopped -// talking, which is exactly the reading that made issue #265's four minutes +// IT IS THE ONE LINE THAT COULD NOT BE RECONSTRUCTED. The seal carries a turn's +// cost, but cannot say that the session let go of it while tools still ran. +// Before this line a turn let go of at a bound was indistinguishable in this +// file from a turn that simply stopped talking, which is exactly the reading +// that made issue #265's four minutes // unaccountable: the surface was freed, the person moved on, and the file said a // turn had been stopped and nothing about the fact that something may still have // been running under it. @@ -459,9 +458,10 @@ type journalRule struct { // Reason is why the turn was let go of ([AbandonReason]); today the only one is // the stop bound. The token and money figures are THE TURN'S LAST KNOWN SPEND — // what [Agent.bank] had moved by the moment the door was opened — and they are -// EVIDENCE AND NEVER SPEND, for [journalCall]'s reason exactly: every one of -// those calls already wrote its own line and moved the machine's ledger, so a -// replay that summed this one too would bill the abandoned turn twice. +// EVIDENCE AND NEVER SPEND: the displaced loop writes its conversation steps +// in a usage seal, while on-clock errands already wrote auxiliary usage lines. +// The calls also moved the machine's ledger when banked. Replaying this tally +// as spend would count those charges twice. // // A turn abandoned before it had spent anything writes the line with no figures // on it, which is the emptiness law: the fact worth recording is that the turn @@ -502,10 +502,10 @@ type journalCreated struct { // journalCall is what ONE provider response reported, on its own line. // -// IT IS EVIDENCE AND NEVER SPEND. The seal above already carries every one of -// these numbers, summed; a replay that added these lines too would bill the -// session twice for the same calls. Nothing reads them back into the session's -// totals, and [replaySessionFile] says so where it drops them. +// AN ORDINARY ROW IS EVIDENCE AND NEVER SPEND. The seal above already carries +// every one of these numbers, summed; a replay that added these lines too would +// bill the session twice for the same calls. Only a late receipt, marked by +// Arm below, restores money from its own row rather than from a seal. // // It exists because a turn is sixty-odd requests with wildly different shapes — // a cold first call, then fifty that are almost all cache read — and the sum of @@ -541,9 +541,10 @@ type journalCreated struct { // fires when the first answer is slow, a receipt is fetched when a stream was // cut — and a journal that records every cheap call and none of the dear ones // answers a cost autopsy wrongly: the measured session's call lines summed to -// barely half its bill until the arms wrote theirs. The money is already banked -// by the reconciliation itself, so the row is evidence here and never spend, -// the same as every other line of this shape. +// barely half its bill until the arms wrote theirs. The money was banked LATE, +// outside every turn seal, so this row is the only record a replay has of that +// charge. Restoring it adds no turn: the receipt is a call, not a new step of +// the conversation. type journalCall struct { Model string `json:"model,omitempty"` Endpoint string `json:"endpoint,omitempty"` @@ -1234,7 +1235,8 @@ type sessionFile struct { 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. + // opened: the SUM of its usage lines and late-receipt call lines, replayed + // once and never updated after. // It is the file's answer to "what did this cost before today", and the agent // seeds its own running total from it at construction (agent.go). Nothing // stores a second copy of the total — the lines are the record, and this is @@ -1243,7 +1245,8 @@ type sessionFile struct { } // RestoredUsage is what the conversation in this file had spent before it was -// opened, and the zero Usage for a file that is new or holds no usage lines. +// opened, and the zero Usage for a file that is new or holds no usage lines or +// late-receipt call lines. func (s *sessionFile) RestoredUsage() Usage { if s == nil { return Usage{} @@ -2195,9 +2198,9 @@ func readJournal(reader io.Reader, path string, rebuild bool) (replayedSession, messages = messages[:len(messages)-entry.Dropped] reasoning = reasoning[:len(reasoning)-entry.Dropped] case "usage": - // EVERY line is added, and none is ever taken back. This is the one - // arm that accumulates rather than rebuilds: a compaction below - // replaces the message window, and money spent before it stays spent. + // EVERY line is added, and none is ever taken back. Charges accumulate + // rather than rebuild: a compaction below replaces the message window, + // and money spent before it stays spent. if entry.Usage == nil { continue } @@ -2226,18 +2229,30 @@ func readJournal(reader io.Reader, path string, rebuild bool) (replayedSession, spent.Turns += used.Calls } case "call": - // NEVER BILLED, and this arm exists to say so rather than to leave it - // to the switch falling off the end. A call line is the SHAPE of one - // request — who served it, how much of its prompt was warm — and every - // dollar on it is already counted in the seal that closed its turn. - // Folding it into `spent` would bill the session twice for the same - // money. + // Ordinary call lines are NEVER BILLED, and this arm exists to say so + // rather than to leave it to the switch falling off the end. A call line + // is the SHAPE of one request — who served it, how much of its prompt + // was warm — and every ordinary dollar on it is already counted in the + // seal that closed its turn. Folding it into `spent` would bill the + // session twice for the same money. + // + // AN ARM IS THE EXCEPTION: its money was banked late, outside every + // seal, so this line is the only record a replay has of that charge. + // It restores one call and no turn, exactly as [Agent.reconciled] banked. // // IT IS READ FOR ITS SHAPE, which is what it was written for: the // size of each request and what came back from it, for a page that // watches this work with no lane to it ([RequestBooks]). if entry.Call != nil { requests.observe(*entry.Call) + if call := entry.Call; call.Arm != "" { + spent.Input += call.Input + spent.Output += call.Output + spent.CacheRead += call.CacheRead + spent.CacheWrite += call.CacheWrite + spent.CostUSD += call.CostUSD + spent.Calls++ + } } case "error": // DROPPED ON PURPOSE, for the reason a call line is, and one of its @@ -2258,13 +2273,13 @@ func readJournal(reader io.Reader, path string, rebuild bool) (replayedSession, } } case "abandoned": - // DROPPED ON PURPOSE, for the reason a call line is and one of its - // own. Every dollar on it is already counted — the calls it sums each - // wrote their own line and each moved the machine's ledger when they - // were made (usage_ledger.go) — so folding it in would bill the - // abandoned turn twice. And it is not a message: what the turn had - // said before it was let go of is already in the transcript above it, - // and a resumed session must open on that rather than on a note about + // Evidence only: conversation steps belong to the displaced loop's + // usage seal, and errands to their own auxiliary usage lines. This + // tally mixes both, so billing it would count errands twice even in + // older journals whose abandoned turns have no step seal. It is not + // a message either: what the turn said before it was let go of is + // already in the transcript above it. A resumed session opens on + // that rather than on a note about // how the last one ended. The line is for whoever reads the file. case "mark", "ceiling", "division", "carry", "failure", "flight": // DROPPED ON PURPOSE, for the reason a call line is: these are the @@ -3249,8 +3264,8 @@ func (s *sessionFile) appendUsage(used Usage, model string, aux bool, role strin }) } -// appendCall writes ONE response's own accounting down, beside the seal that -// will sum it. +// appendCall writes ONE response's own accounting down. An ordinary response +// sits beside the seal that will sum it; a late receipt is its own record. // // A RESPONSE THAT REPORTED NO USAGE WRITES NOTHING. The emptiness law, and the // same test the seal keeps: a call with no tokens and no cost is a call the @@ -3443,7 +3458,8 @@ func (s *sessionFile) writeLine(entry any) bool { // records people share — the call log and the debug record — not here. payload = trace.ScrubRegistered(payload) entryLine, _ := entry.(sessionEntry) - if !s.appendLine(payload, entryLine.Type == "usage") { + paid := entryLine.Type == "usage" || entryLine.Type == "call" && entryLine.Call != nil && entryLine.Call.Arm != "" + if !s.appendLine(payload, paid) { return false } if s.onWrite != nil { @@ -3452,15 +3468,16 @@ func (s *sessionFile) writeLine(entry any) bool { return true } -func (s *sessionFile) appendLine(payload []byte, usage bool) bool { +func (s *sessionFile) appendLine(payload []byte, paid bool) bool { s.mu.Lock() defer s.mu.Unlock() if s.closed { - if !usage { + if !paid { return false } - // Paid work can outlive its stage. Keep its usage in the same journal - // under this file's mutex so reopening restores the full paid total. + // Paid work can outlive its stage. Keep its usage seals and late arm + // receipts in the same journal under this file's mutex so reopening + // restores the full paid total. file, err := os.OpenFile(s.file.Name(), os.O_WRONLY|os.O_APPEND, 0600) if err != nil { return false @@ -3481,7 +3498,7 @@ func (s *sessionFile) appendLine(payload []byte, usage bool) bool { // flock on its own — but it states the release at the place a reader looks for // it, and it puts the release before the close rather than as a side effect of // it. Nothing can slip into the gap: closed is already set, so no write reaches -// the file after this point, except paid usage appended by path. +// the file after this point, except paid accounting appended by path. func (s *sessionFile) Close() error { s.mu.Lock() defer s.mu.Unlock() diff --git a/internal/session/spend_replay_test.go b/internal/session/spend_replay_test.go new file mode 100644 index 0000000000..115c9c3bf0 --- /dev/null +++ b/internal/session/spend_replay_test.go @@ -0,0 +1,245 @@ +package session + +import ( + "context" + "fmt" + "math" + "path/filepath" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/provider" +) + +// Contract (a), (c): late reconciled and hedge calls restore their unsealed +// money and tokens as calls, never turns; ordinary call lines stay evidence. +func TestLateCallLinesRestoreSpendWithoutAddingTurns(t *testing.T) { + for _, test := range []struct { + name string + arm string + want Usage + }{ + { + name: "reconciled", arm: "reconciled", + want: Usage{Input: 90, Output: 25, CacheRead: 33, CacheWrite: 9, CostUSD: .375, Calls: 2, Turns: 1, Duration: 250 * time.Millisecond}, + }, + { + name: "hedge", arm: "hedge", + want: Usage{Input: 90, Output: 25, CacheRead: 33, CacheWrite: 9, CostUSD: .375, Calls: 2, Turns: 1, Duration: 250 * time.Millisecond}, + }, + { + name: "ordinary", + want: Usage{Input: 10, Output: 5, CacheRead: 3, CacheWrite: 2, CostUSD: .25, Calls: 1, Turns: 1, Duration: 250 * time.Millisecond}, + }, + } { + t.Run(test.name, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "transcript.jsonl") + write(t, path, + `{"type":"session","version":1,"id":"abc","cwd":"/w","model":"m","timestamp":"t"}`, + `{"type":"call","call":{"model":"m","input":10,"output":5,"cacheRead":3,"cacheWrite":2,"costUsd":0.25}}`, + `{"type":"usage","usage":{"model":"m","input":10,"output":5,"cacheRead":3,"cacheWrite":2,"costUsd":0.25,"calls":1,"durationMs":250}}`, + fmt.Sprintf(`{"type":"call","call":{"model":"m","arm":%q,"input":80,"output":20,"cacheRead":30,"cacheWrite":7,"costUsd":0.125}}`, test.arm), + ) + file, _, err := openSessionFile(path, "/w", "m", "") + if err != nil { + t.Fatalf("open: %v", err) + } + t.Cleanup(func() { _ = file.Close() }) + if got := file.RestoredUsage(); got != test.want { + t.Fatalf("restored usage = %+v, want %+v", got, test.want) + } + }) + } +} + +// Contract (b): a cancelled .25 turn saves .375 after its late .125 receipt; +// reopening the same folder and spending .01 saves .385 rather than .26. +func TestALateReceiptSurvivesResumeAndTheNextSpendStamp(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "transcript.jsonl") + ledger := filepath.Join(t.TempDir(), UsageLedgerName) + held := make(chan struct{}) + first, workspace := newTestAgent(t, &scriptedCompleter{steps: []step{ + func(context.Context, []ai.Message) (*ai.Response, error) { + return priced(toolResponse("first", "ls", `{"path":"."}`), .25), nil + }, + func(ctx context.Context, _ []ai.Message) (*ai.Response, error) { + close(held) + <-ctx.Done() + return nil, ctx.Err() + }, + }}, func(config *Config) { + config.Place = Place{Dir: dir, Workspace: config.Workspace} + config.SessionFile = path + config.usageLedger = ledger + }) + events := mustSubmit(t, first, "look around") + waitMetaPatch(t, held) + first.Interrupt() + collect(t, events) + if meta := stampedMeta(t, first, dir); meta.SpentUSD != .25 || meta.Tokens != 27 { + t.Fatalf("cancelled turn saved %+v, want .25 and 27 tokens", meta) + } + if seals := turnUsageLines(t, path); len(seals) != 1 || seals[0].CostUSD != .25 { + t.Fatalf("cancelled turn seals = %+v, want one .25 seal", seals) + } + first.reconciled(provider.Reconciled{ + Billed: provider.Billed{Model: "test/model", PromptTokens: 80, CompletionTokens: 20, CachedTokens: 30, Cost: .125}, + Ref: "late-before-resume", Reason: "torn", Found: true, + }) + if meta := stampedMeta(t, first, dir); meta.SpentUSD != .375 || meta.Tokens != 127 { + t.Fatalf("late receipt saved %+v, want .375 and 127 tokens", meta) + } + if err := first.Close(); err != nil { + t.Fatalf("close before resume: %v", err) + } + + second, err := newAgent(Config{ + Workspace: workspace, Model: "test/model", System: "SYSTEM", + Place: Place{Dir: dir, Workspace: workspace}, SessionFile: path, usageLedger: ledger, + }, &scriptedCompleter{steps: []step{ + func(context.Context, []ai.Message) (*ai.Response, error) { + return priced(textResponse("done"), .01), nil + }, + }}) + if err != nil { + t.Fatalf("resume: %v", err) + } + t.Cleanup(second.SettleWrites) + t.Cleanup(func() { _ = second.Close() }) + if used := second.Usage(); used.CostUSD != .375 || used.Input != 100 || used.Output != 27 || used.CacheRead != 30 || used.Calls != 2 || used.Turns != 1 { + t.Errorf("resumed meter = %+v, want the seal plus its late receipt", used) + } + collect(t, mustSubmit(t, second, "finish up")) + meta := stampedMeta(t, second, dir) + if math.Abs(meta.SpentUSD-.385) > 1e-12 || meta.Tokens != 142 { + t.Fatalf("next turn saved %+v, want .385 and 142 tokens after resume", meta) + } + if used := second.Usage(); math.Abs(used.CostUSD-.385) > 1e-12 || used.Calls != 3 || used.Turns != 2 { + t.Fatalf("next turn meter = %+v, want .385, three calls and two conversation steps", used) + } +} + +// Contract (b), review 1 major finding: a receipt pending across Close must +// keep its paid arm line, so a resume restores .375 and the next turn saves +// .385. Ordinary call lines must still be refused after close. +func TestALateReceiptAfterCloseSurvivesResumeAndTheNextSpendStamp(t *testing.T) { + dir := t.TempDir() + path := filepath.Join(dir, "transcript.jsonl") + ledger := filepath.Join(t.TempDir(), UsageLedgerName) + t.Cleanup(func() { StopUsageWriter(ledger) }) + held := make(chan struct{}) + first, workspace := newTestAgent(t, &scriptedCompleter{steps: []step{ + func(context.Context, []ai.Message) (*ai.Response, error) { + return priced(toolResponse("first", "ls", `{"path":"."}`), .25), nil + }, + func(ctx context.Context, _ []ai.Message) (*ai.Response, error) { + close(held) + <-ctx.Done() + return nil, ctx.Err() + }, + }}, func(config *Config) { + config.Place = Place{Dir: dir, Workspace: config.Workspace} + config.SessionFile = path + config.usageLedger = ledger + }) + events := mustSubmit(t, first, "look around") + waitMetaPatch(t, held) + settled := first.pendingReceipt() + first.Interrupt() + collect(t, events) + if meta := stampedMeta(t, first, dir); meta.SpentUSD != .25 || meta.Tokens != 27 { + t.Fatalf("cancelled turn saved %+v, want .25 and 27 tokens", meta) + } + if seals := turnUsageLines(t, path); len(seals) != 1 || seals[0].CostUSD != .25 { + t.Fatalf("cancelled turn seals = %+v, want one .25 seal", seals) + } + if err := first.Close(); err != nil { + t.Fatalf("close with a pending receipt: %v", err) + } + lines := len(readLines(t, path)) + if first.file.writeLine(sessionEntry{ + Type: "call", Call: &journalCall{Model: "test/model", Input: 1, Output: 1, CostUSD: 1}, Timestamp: stamp(), + }) { + t.Error("ordinary call line was accepted after close") + } + if got := len(readLines(t, path)); got != lines { + t.Fatalf("ordinary call changed the closed journal from %d to %d lines", lines, got) + } + first.reconciled(provider.Reconciled{ + Billed: provider.Billed{Model: "test/model", PromptTokens: 80, CompletionTokens: 20, CachedTokens: 30, Cost: .125}, + Ref: "late-after-close", Reason: "torn", Found: true, + }) + settled() + if meta := stampedMeta(t, first, dir); meta.SpentUSD != .375 || meta.Tokens != 127 { + t.Fatalf("post-close receipt saved %+v, want .375 and 127 tokens", meta) + } + if seals := turnUsageLines(t, path); len(seals) != 1 || seals[0].CostUSD != .25 { + t.Fatalf("post-close receipt changed turn seals: %+v", seals) + } + digest, err := ReadDigest(path) + if err != nil { + t.Fatalf("digest after close: %v", err) + } + if usd, tokens := digest.Spend(); usd != .375 || tokens != 127 { + t.Errorf("post-close digest spend = %v / %d tokens, want .375 / 127", usd, tokens) + } + + second, err := newAgent(Config{ + Workspace: workspace, Model: "test/model", System: "SYSTEM", + Place: Place{Dir: dir, Workspace: workspace}, SessionFile: path, usageLedger: ledger, + }, &scriptedCompleter{steps: []step{ + func(context.Context, []ai.Message) (*ai.Response, error) { + return priced(textResponse("done"), .01), nil + }, + }}) + if err != nil { + t.Fatalf("resume after post-close receipt: %v", err) + } + t.Cleanup(second.SettleWrites) + t.Cleanup(func() { _ = second.Close() }) + if used := second.Usage(); used.CostUSD != .375 || used.Input != 100 || used.Output != 27 || used.CacheRead != 30 || used.Calls != 2 || used.Turns != 1 { + t.Errorf("resumed meter = %+v, want the seal plus its post-close receipt", used) + } + collect(t, mustSubmit(t, second, "finish up")) + meta := stampedMeta(t, second, dir) + if math.Abs(meta.SpentUSD-.385) > 1e-12 || meta.Tokens != 142 { + t.Fatalf("next turn saved %+v, want .385 and 142 tokens after the post-close receipt", meta) + } + if used := second.Usage(); math.Abs(used.CostUSD-.385) > 1e-12 || used.Calls != 3 || used.Turns != 2 { + t.Fatalf("next turn meter = %+v, want .385, three calls and two conversation steps", used) + } +} + +// Contract (d), (c): the digest adds late reconciled and hedge receipts to +// saved spend while Usage still contains only the seal, never the call rows. +func TestDigestSpendIncludesLateReceiptsWithoutAddingUsageLines(t *testing.T) { + for _, arm := range []string{"reconciled", "hedge"} { + t.Run(arm, func(t *testing.T) { + path := filepath.Join(t.TempDir(), "transcript.jsonl") + write(t, path, + `{"type":"session","version":1,"id":"abc","cwd":"/w","model":"m","timestamp":"t"}`, + `{"type":"call","call":{"model":"m","input":10,"output":5,"cacheRead":3,"cacheWrite":2,"costUsd":0.25}}`, + `{"type":"usage","usage":{"model":"m","input":10,"output":5,"cacheRead":3,"cacheWrite":2,"costUsd":0.25,"calls":1}}`, + fmt.Sprintf(`{"type":"call","call":{"model":"m","arm":%q,"input":80,"output":20,"cacheRead":30,"cacheWrite":7,"costUsd":0.125}}`, arm), + ) + digest, err := ReadDigest(path) + if err != nil { + t.Fatalf("digest: %v", err) + } + if usd, tokens := digest.Spend(); usd != .375 || tokens != 115 { + t.Fatalf("digest spend = %v / %d tokens, want .375 / 115", usd, tokens) + } + if len(digest.Usage) != 1 { + t.Fatalf("digest Usage = %+v, want only the one usage seal", digest.Usage) + } + if used := digest.Usage[0]; used.USD != .25 || used.Input != 10 || used.Output != 5 || used.Calls != 1 { + t.Fatalf("digest Usage row = %+v, want the seal's figures alone", used) + } + if meta := digest.Derive(Meta{}); meta.SpentUSD != .375 || meta.Tokens != 115 { + t.Fatalf("derived metadata = %+v, want .375 and 115 tokens", meta) + } + }) + } +} diff --git a/internal/session/spendguard.go b/internal/session/spendguard.go index ed8dc389cc..a221e1c9ee 100644 --- a/internal/session/spendguard.go +++ b/internal/session/spendguard.go @@ -210,7 +210,8 @@ type SpendGuard struct { // refuses every call that might be paid — never a default, never none. // It must not take locks of its own that a guarded call can hold. DayLimit func() (usd float64, action string, err error) - // SeatCeilings are a seat's own spend ceiling on this task, and + // SeatCeilings are a seat's own spend ceiling on each marked task scope + // (the whole crew when no scope is marked), and // CeilingAction the sentence (with the ceiling's dollars) a call it stops // ends on. SeatCeilings map[crewroute.Seat]float64 @@ -228,6 +229,8 @@ type SpendGuard struct { mu sync.Mutex modelSpent map[string]float64 seatSpent map[crewroute.Seat]*SpendTask + // Scoped tallies enforce ceilings; records and receipts keep seatSpent. + scopedSpent map[seatScope]*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 @@ -239,6 +242,8 @@ type SpendTask struct { mu sync.Mutex spent float64 held float64 + // Only scoped ceiling tallies remember costs for their own estimates. + last map[string]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 @@ -259,8 +264,9 @@ func (t *SpendTask) Counts() (calls, unknown int) { // 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) { +// call without its certainty or the other way round. A scoped ceiling also +// names the model to retain its own most recent actual cost. +func (t *SpendTask) book(held, usd float64, known bool, model ...string) { t.mu.Lock() defer t.mu.Unlock() t.held -= held @@ -269,6 +275,12 @@ func (t *SpendTask) book(held, usd float64, known bool) { } if usd > 0 { t.spent += usd + if len(model) > 0 { + if t.last == nil { + t.last = map[string]float64{} + } + t.last[model[0]] = usd + } } t.calls++ if !known { @@ -297,15 +309,20 @@ func (t *SpendTask) Total() float64 { return t.spent } -// hold holds est against capUSD (none when zero) and answers whether it fit. -func (t *SpendTask) hold(est, capUSD float64) bool { +// hold holds est against capUSD (none when zero) and answers what fit. A scoped +// ceiling names its model to raise the estimate by its own last actual cost +// under the same lock, so a concurrent settlement cannot leave that cost out. +func (t *SpendTask) hold(est, capUSD float64, model ...string) (float64, bool) { t.mu.Lock() defer t.mu.Unlock() + if len(model) > 0 { + est = max(est, t.last[model[0]]) + } if capUSD > 0 && t.spent+t.held+est > capUSD { - return false + return 0, false } t.held += est - return true + return est, true } // settle releases a hold and books what the call cost. @@ -346,7 +363,11 @@ func (g *SpendGuard) seatTally(seat crewroute.Seat) *SpendTask { // ErrSpendStopped is a call the guard did not make. Its text is the one // sentence that says which line it met. -type ErrSpendStopped struct{ Action string } +type ErrSpendStopped struct { + Action string + // TaskLimit ends the whole task tree; a seat's own ceiling ends its check. + TaskLimit bool +} func (e ErrSpendStopped) Error() string { return e.Action } @@ -399,7 +420,12 @@ func (g *SpendGuard) dayCap() (float64, string, error) { // before is whether a call to model with these messages may be made, and // what it holds on the day until [SpendGuard.after] settles it. -func (g *SpendGuard) before(ctx context.Context, model string, messages []ai.Message, options []ai.Option) (float64, error) { +// Calls carrying a reservation retain the task and ceiling amounts separately +// and must pass it to after; calls without one retain the common-hold interface. +func (g *SpendGuard) before(ctx context.Context, model string, messages []ai.Message, options []ai.Option, reservation ...*spendReservation) (float64, error) { + if len(reservation) > 0 { + *reservation[0] = spendReservation{} + } if g == nil { return 0, nil } @@ -434,9 +460,9 @@ func (g *SpendGuard) before(ctx context.Context, model string, messages []ai.Mes } } if g.TaskCap > 0 && g.tally().Total() >= g.TaskCap { - return 0, ErrSpendStopped{Action: g.TaskAction} + return 0, ErrSpendStopped{Action: g.TaskAction, TaskLimit: true} } - if seat := crewSeatOf(ctx); g.SeatCeilings[seat] > 0 && g.seatTally(seat).Total() >= g.SeatCeilings[seat] { + if seat := crewSeatOf(ctx); g.SeatCeilings[seat] > 0 && g.ceilingTally(ctx, seat).Total() >= g.SeatCeilings[seat] { ceiling := g.SeatCeilings[seat] return 0, ErrSpendStopped{Action: fmt.Sprintf(g.CeilingAction, ceiling)} } @@ -446,27 +472,38 @@ func (g *SpendGuard) before(ctx context.Context, model string, messages []ai.Mes modelSpent := g.modelSpent[model] g.mu.Unlock() est := g.estimate(messages, options, prompt, completion, cacheRead, modelSpent > 0) + ceilingEst := est day := g.dayTally() if last := day.lastCost(model); last > est { est = last } seat := crewSeatOf(ctx) ceiling := g.SeatCeilings[seat] - if ceiling > 0 && !g.seatTally(seat).hold(est, ceiling) { - return 0, ErrSpendStopped{Action: fmt.Sprintf(g.CeilingAction, ceiling)} + var ceilingTally *SpendTask + if ceiling > 0 { + ceilingTally = g.ceilingTally(ctx, seat) + var fits bool + if scope, _ := ctx.Value(crewScopeContextKey{}).(string); scope != "" { + ceilingEst, fits = ceilingTally.hold(ceilingEst, ceiling, model) + } else { + ceilingEst, fits = ceilingTally.hold(est, ceiling) + } + if !fits { + return 0, ErrSpendStopped{Action: fmt.Sprintf(g.CeilingAction, ceiling)} + } } task := g.tally() - if !task.hold(est, g.TaskCap) { + if _, fits := task.hold(est, g.TaskCap); !fits { if ceiling > 0 { - g.seatTally(seat).settle(est, 0) + ceilingTally.settle(ceilingEst, 0) } - return 0, ErrSpendStopped{Action: g.TaskAction} + return 0, ErrSpendStopped{Action: g.TaskAction, TaskLimit: true} } held, fits, readErr := day.holdChecked(model, est, capUSD) if !fits { task.settle(est, 0) if ceiling > 0 { - g.seatTally(seat).settle(est, 0) + ceilingTally.settle(ceilingEst, 0) } if readErr != nil { return 0, ErrSpendStopped{Action: spendAccountingUnread} @@ -476,6 +513,9 @@ func (g *SpendGuard) before(ctx context.Context, model string, messages []ai.Mes if day == nil { held = est } + if len(reservation) > 0 { + *reservation[0] = spendReservation{task: est, ceiling: ceilingEst} + } return held, nil } @@ -506,19 +546,19 @@ func (g *SpendGuard) estimate(messages []ai.Message, options []ai.Option, prompt } // after releases what before held and records what a call to model cost. -func (g *SpendGuard) after(ctx context.Context, model string, response *ai.Response, held float64) { +func (g *SpendGuard) after(ctx context.Context, model string, response *ai.Response, held float64, reservation ...*spendReservation) { if g == nil { return } + taskHeld, ceilingHeld := held, held + if len(reservation) > 0 { + taskHeld, ceilingHeld = reservation[0].task, reservation[0].ceiling + } task := g.tally() // 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. @@ -544,15 +584,26 @@ func (g *SpendGuard) after(ctx context.Context, model string, response *ai.Respo g.dayTally().settle(model, 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 + var role, ceiling *SpendTask if seat != "" { role = g.seatTally(seat) + if g.SeatCeilings[seat] > 0 { + ceiling = g.ceilingTally(ctx, seat) + } } g.bookMu.Lock() - task.book(held, usd, known) + task.book(taskHeld, usd, known) if role != nil { + // A scoped call holds its own ceiling, never the seat aggregate. + seatHeld := 0.0 + if ceiling == role { + seatHeld = ceilingHeld + } role.book(seatHeld, usd, known) } + if ceiling != nil && ceiling != role { + ceiling.book(ceilingHeld, usd, known, model) + } g.bookMu.Unlock() if usd <= 0 { return @@ -581,7 +632,8 @@ func (c guardedCompleter) CompleteWithMessages(ctx context.Context, messages []a if request.Model != "" { model = request.Model } - held, err := c.guard.before(ctx, model, messages, options) + var reservation spendReservation + held, err := c.guard.before(ctx, model, messages, options, &reservation) if err != nil { return nil, err } @@ -589,7 +641,7 @@ func (c guardedCompleter) CompleteWithMessages(ctx context.Context, messages []a c.guard.after(ctx, model, response, 0) }) response, err := c.next.CompleteWithMessages(callCtx, messages, options...) - c.guard.after(ctx, model, response, held) + c.guard.after(ctx, model, response, held, &reservation) return response, err } diff --git a/internal/session/spendguard_scope.go b/internal/session/spendguard_scope.go new file mode 100644 index 0000000000..f68de5da6e --- /dev/null +++ b/internal/session/spendguard_scope.go @@ -0,0 +1,40 @@ +package session + +import ( + "context" + + "github.com/Agent-Field/codeaf/internal/crewroute" +) + +// crewScopeContextKey keeps one run task's ceiling across calls and model changes. +type crewScopeContextKey struct{} + +type seatScope struct { + seat crewroute.Seat + scope string +} + +// spendReservation keeps the task and ceiling holds separate from the day +// hold returned by before, since each tally can reserve a different estimate. +type spendReservation struct { + task, ceiling float64 +} + +// ceilingTally separates a run task's ceiling from the persisted seat aggregate. +// Calls without a scope retain the single-task crew's existing seat tally. +func (g *SpendGuard) ceilingTally(ctx context.Context, seat crewroute.Seat) *SpendTask { + scope, _ := ctx.Value(crewScopeContextKey{}).(string) + if scope == "" { + return g.seatTally(seat) + } + g.mu.Lock() + defer g.mu.Unlock() + if g.scopedSpent == nil { + g.scopedSpent = make(map[seatScope]*SpendTask) + } + key := seatScope{seat: seat, scope: scope} + if g.scopedSpent[key] == nil { + g.scopedSpent[key] = &SpendTask{} + } + return g.scopedSpent[key] +} diff --git a/internal/session/spendguard_scope_day_test.go b/internal/session/spendguard_scope_day_test.go new file mode 100644 index 0000000000..8e7a6f20f7 --- /dev/null +++ b/internal/session/spendguard_scope_day_test.go @@ -0,0 +1,126 @@ +package session + +import ( + "context" + "errors" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/crewroute" +) + +// C1: a shared day's last actual cost from an overrun cannot reserve a fresh +// check's ceiling, while the task and day keep their conservative estimates. +func TestEachCheckHasItsOwnCeilingWithASharedDay(t *testing.T) { + guard := &SpendGuard{ + Price: func(string) (float64, float64, float64, bool) { return 0, 1e-6, 0, true }, + Day: NewSpendDay(0), + SeatCeilings: map[crewroute.Seat]float64{crewroute.Checker: .125}, + CeilingAction: config.CrewCheckCeilingAction, + } + first := &spendingCompleter{usd: .1875} + checkA := SeatCompleterFor(crewroute.Checker, "check-a", guard.Wrap("vendor/shared", first)) + if _, err := checkA.CompleteWithMessages(t.Context(), nil); err != nil { + t.Fatal(err) + } + var stopped ErrSpendStopped + _, err := checkA.CompleteWithMessages(t.Context(), nil) + if !errors.As(err, &stopped) || stopped.TaskLimit || stopped.Action != "the check stopped at its spend ceiling of $0.12, three times its estimate, before it finished" || first.calls != 1 { + t.Fatalf("first check past its ceiling: %v, calls=%d", err, first.calls) + } + second := &spendingCompleter{usd: .0625} + probe := &scriptedCompleter{steps: []step{ + func(ctx context.Context, messages []ai.Message) (*ai.Response, error) { + if guard.tally().held != .1875 || guard.Day.held != .1875 || guard.ceilingTally(ctx, crewroute.Checker).held != .002 { + t.Fatalf("first call holds: task=%v day=%v check=%v", guard.tally().held, guard.Day.held, guard.ceilingTally(ctx, crewroute.Checker).held) + } + return second.CompleteWithMessages(ctx, messages) + }, + func(ctx context.Context, messages []ai.Message) (*ai.Response, error) { + if guard.ceilingTally(ctx, crewroute.Checker).held != .0625 { + t.Fatalf("check forgot its own last actual cost: held=%v", guard.ceilingTally(ctx, crewroute.Checker).held) + } + return second.CompleteWithMessages(ctx, messages) + }, + }} + checkB := SeatCompleterFor(crewroute.Checker, "check-b", guard.Wrap("vendor/shared", probe)) + for i := 0; i < 2; i++ { + if _, err := checkB.CompleteWithMessages(t.Context(), nil); err != nil { + t.Fatalf("fresh check call %d: %v", i, err) + } + } + _, err = checkB.CompleteWithMessages(t.Context(), nil) + if !errors.As(err, &stopped) || stopped.TaskLimit || stopped.Action != "the check stopped at its spend ceiling of $0.12, three times its estimate, before it finished" || second.calls != 2 { + t.Fatalf("second check at its own ceiling: %v, calls=%d", err, second.calls) + } + if guard.tally().Total() != .3125 || guard.Day.Total() != .3125 || guard.seatTally(crewroute.Checker).Total() != .3125 || guard.tally().held != 0 || guard.Day.held != 0 { + t.Fatalf("shared accounting: task=%+v day=%+v checker=%+v", guard.tally(), guard.Day, guard.seatTally(crewroute.Checker)) + } +} + +// C1, C2, C3: rollback, zero-cost answers, failed calls and discarded usage +// release only their own reservations even when the shared estimates are larger. +func TestScopedCheckReleasesOnlyWhatEachTallyHeld(t *testing.T) { + for _, ending := range []string{"answer", "error", "discarded", "task", "day"} { + t.Run(ending, func(t *testing.T) { + guard := &SpendGuard{ + Price: func(string) (float64, float64, float64, bool) { return 0, .03125 / 2000, 0, true }, + Day: NewSpendDay(0), + TaskAction: "task limit", + CapAction: "day limit", + SeatCeilings: map[crewroute.Seat]float64{crewroute.Checker: .125}, + CeilingAction: config.CrewCheckCeilingAction, + } + seed := SeatCompleterFor(crewroute.Checker, "check-a", guard.Wrap("vendor/shared", &spendingCompleter{usd: .1875})) + if _, err := seed.CompleteWithMessages(t.Context(), nil); err != nil { + t.Fatal(err) + } + failure := errors.New("scripted call failed") + inner := &scriptedCompleter{steps: []step{func(ctx context.Context, _ []ai.Message) (*ai.Response, error) { + if ending == "discarded" { + guard.after(ctx, "vendor/shared", usage(usd(.0625)), 0) + if guard.ceilingTally(ctx, crewroute.Checker).held != .0625 || guard.tally().held != .375 || guard.Day.held != .375 { + t.Fatal("discarded usage released an in-flight reservation") + } + } + if ending == "error" { + return nil, failure + } + return usage(usd(0)), nil + }}} + check := SeatCompleterFor(crewroute.Checker, "check-b", guard.Wrap("vendor/shared", inner)) + outer := &scriptedCompleter{steps: []step{func(ctx context.Context, _ []ai.Message) (*ai.Response, error) { + if ending == "task" { + guard.TaskCap = .5 + } else if ending == "day" { + guard.Cap = .5 + } + _, err := check.CompleteWithMessages(ctx, nil) + if ending == "task" || ending == "day" { + var stopped ErrSpendStopped + if !errors.As(err, &stopped) || stopped.TaskLimit != (ending == "task") || stopped.Action != ending+" limit" || inner.requests() != 0 { + t.Fatalf("shared limit refusal: %v, calls=%d", err, inner.requests()) + } + } else if ending == "error" { + if !errors.Is(err, failure) { + t.Fatalf("provider failure changed: %v", err) + } + } else if err != nil { + t.Fatal(err) + } + if guard.ceilingTally(ctx, crewroute.Checker).held != .03125 || guard.tally().held != .1875 || guard.Day.held != .1875 { + t.Fatalf("inner call released the outer hold: check=%v task=%v day=%v", guard.ceilingTally(ctx, crewroute.Checker).held, guard.tally().held, guard.Day.held) + } + return usage(usd(0)), nil + }}} + marked := SeatCompleterFor(crewroute.Checker, "check-b", guard.Wrap("vendor/shared", outer)) + if _, err := marked.CompleteWithMessages(t.Context(), nil); err != nil { + t.Fatal(err) + } + if guard.tally().held != 0 || guard.Day.held != 0 || guard.scopedSpent[seatScope{seat: crewroute.Checker, scope: "check-b"}].held != 0 { + t.Fatal("call left an outstanding reservation") + } + }) + } +} diff --git a/internal/session/spendguard_scope_record_test.go b/internal/session/spendguard_scope_record_test.go new file mode 100644 index 0000000000..ff1c6a683e --- /dev/null +++ b/internal/session/spendguard_scope_record_test.go @@ -0,0 +1,113 @@ +package session + +import ( + "context" + "encoding/json" + "errors" + "reflect" + "testing" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/crewroute" +) + +// C5: scoped calls persist the same aggregate totals, seat figures and call +// certainty, and the unchanged record format restores without scope data. +func TestScopedCrewRecordRestoresAggregateSpend(t *testing.T) { + guard := &SpendGuard{ + Day: NewSpendDay(0), TaskCap: 1, TaskAction: "task limit", + SeatCeilings: map[crewroute.Seat]float64{crewroute.Checker: .25}, + CeilingAction: config.CrewCheckCeilingAction, + } + for _, call := range []struct { + seat crewroute.Seat + scope string + cost float64 + }{ + {crewroute.Worker, "work", .25}, + {crewroute.Planner, "plan", .125}, + {crewroute.Checker, "check-a", .0625}, + {crewroute.Checker, "check-b", .0625}, + } { + marked := SeatCompleterFor(call.seat, call.scope, guard.Wrap("vendor/shared", &spendingCompleter{usd: call.cost})) + if _, err := marked.CompleteWithMessages(t.Context(), nil); err != nil { + t.Fatal(err) + } + } + unknown := &scriptedCompleter{steps: []step{func(context.Context, []ai.Message) (*ai.Response, error) { + return &ai.Response{}, nil + }}} + marked := SeatCompleterFor(crewroute.Checker, "check-b", guard.Wrap("vendor/unknown", unknown)) + if _, err := marked.CompleteWithMessages(t.Context(), nil); err != nil { + t.Fatal(err) + } + decision := liveDecision() + crew := &taskCrew{decision: decision, original: originals(decision), guard: guard, roleFacts: true} + record := crew.record() + saved := record.Guard + if saved.TaskSpent != .5 || saved.TaskCalls != 5 || saved.TaskUnknown != 1 || + saved.SeatSpent[crewroute.Worker] != .25 || saved.SeatSpent[crewroute.Planner] != .125 || saved.SeatSpent[crewroute.Checker] != .125 || + saved.SeatCalls[crewroute.Checker] != 3 || saved.SeatUnknown[crewroute.Checker] != 1 || saved.DaySpent != .5 { + t.Fatalf("record lost aggregate accounting: %+v", saved) + } + data, err := json.Marshal(record) + if err != nil { + t.Fatal(err) + } + var decoded TaskCrewRecord + if err := json.Unmarshal(data, &decoded); err != nil { + t.Fatal(err) + } + a := &Agent{config: Config{Workspace: t.TempDir(), ProfileDir: t.TempDir()}} + a.crewDayOnce.Do(func() { a.crewDayHeld = NewSpendDay(0) }) + restored, err := a.restoreTaskCrew(1, &decoded) + if err != nil { + t.Fatal(err) + } + back := restored.record().Guard + if back.TaskSpent != saved.TaskSpent || back.TaskCalls != saved.TaskCalls || back.TaskUnknown != saved.TaskUnknown || back.DaySpent != saved.DaySpent || + !reflect.DeepEqual(back.SeatSpent, saved.SeatSpent) || !reflect.DeepEqual(back.SeatCalls, saved.SeatCalls) || + !reflect.DeepEqual(back.SeatUnknown, saved.SeatUnknown) || !reflect.DeepEqual(back.ModelSpent, saved.ModelSpent) { + t.Fatalf("record changed on restore: before=%+v after=%+v", saved, back) + } +} + +// C5, C4: an older record without scopes or call counts still restores its +// task/seat totals and enforces the original unscoped checker ceiling. +func TestOlderCrewRecordWithoutScopesStillHoldsItsSeatCeiling(t *testing.T) { + decision := liveDecision() + record := &TaskCrewRecord{ + Version: 1, Routed: true, Decision: decision, Original: originals(decision), + Guard: &crewGuardRecord{ + TaskCap: 1, TaskSpent: .375, TaskAction: "task limit", + SeatCeilings: map[crewroute.Seat]float64{crewroute.Checker: .125}, + CeilingAction: config.CrewCheckCeilingAction, + SeatSpent: map[crewroute.Seat]float64{crewroute.Worker: .25, crewroute.Checker: .125}, + ModelSpent: map[string]float64{"vendor/shared": .375}, + }, + } + a := &Agent{config: Config{Workspace: t.TempDir(), ProfileDir: t.TempDir()}} + a.crewDayOnce.Do(func() { a.crewDayHeld = NewSpendDay(0) }) + restored, err := a.restoreTaskCrew(1, record) + if err != nil { + t.Fatal(err) + } + guard := restored.guard + guard.DayLimit = nil + guard.Price = func(string) (float64, float64, float64, bool) { return 0, 0, 0, false } + if guard.tally().Total() != .375 || guard.seatTally(crewroute.Worker).Total() != .25 || guard.seatTally(crewroute.Checker).Total() != .125 { + t.Fatalf("older totals changed: %+v", restored.record().Guard) + } + calls := &spendingCompleter{usd: .0625} + check := SeatCompleter(crewroute.Checker, guard.Wrap("vendor/shared", calls)) + _, err = check.CompleteWithMessages(t.Context(), nil) + var stopped ErrSpendStopped + if !errors.As(err, &stopped) || stopped.TaskLimit || stopped.Action != "the check stopped at its spend ceiling of $0.12, three times its estimate, before it finished" || calls.calls != 0 { + t.Fatalf("older checker ceiling reset: %v, calls=%d", err, calls.calls) + } + work := SeatCompleter(crewroute.Worker, guard.Wrap("vendor/shared", calls)) + if _, err := work.CompleteWithMessages(t.Context(), nil); err != nil || calls.calls != 1 || guard.tally().Total() != .4375 { + t.Fatalf("older worker accounting: %v, calls=%d total=%v", err, calls.calls, guard.tally().Total()) + } +} diff --git a/internal/session/spendguard_scope_test.go b/internal/session/spendguard_scope_test.go new file mode 100644 index 0000000000..d0a9aff0d9 --- /dev/null +++ b/internal/session/spendguard_scope_test.go @@ -0,0 +1,252 @@ +package session + +import ( + "context" + "errors" + "testing" + + "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" +) + +// C1: each check has its own ceiling on both priced and unpriced routes; +// an actual cost above one check's ceiling cannot refuse the next check. +func TestEachCheckHasItsOwnSeatCeiling(t *testing.T) { + for _, priced := range []bool{true, false} { + name := "unpriced" + if priced { + name = "priced" + } + t.Run(name, func(t *testing.T) { + guard := &SpendGuard{ + Price: func(string) (float64, float64, float64, bool) { return 0, 1e-6, 0, priced }, + SeatCeilings: map[crewroute.Seat]float64{crewroute.Checker: .125}, + CeilingAction: config.CrewCheckCeilingAction, + } + first := &spendingCompleter{usd: .1875} + checkA := SeatCompleterFor(crewroute.Checker, "check-a", guard.Wrap("vendor/shared", first)) + if _, err := checkA.CompleteWithMessages(t.Context(), nil); err != nil { + t.Fatal(err) + } + _, err := checkA.CompleteWithMessages(t.Context(), nil) + var stopped ErrSpendStopped + if !errors.As(err, &stopped) || stopped.TaskLimit || stopped.Action != "the check stopped at its spend ceiling of $0.12, three times its estimate, before it finished" || first.calls != 1 { + t.Fatalf("first check past its ceiling: %v, calls=%d", err, first.calls) + } + + second := &spendingCompleter{usd: .0625} + checkB := SeatCompleterFor(crewroute.Checker, "check-b", guard.Wrap("vendor/shared", second)) + for i := 0; i < 2; i++ { + if _, err := checkB.CompleteWithMessages(t.Context(), nil); err != nil { + t.Fatalf("second check call %d: %v", i, err) + } + } + _, err = checkB.CompleteWithMessages(t.Context(), nil) + if !errors.As(err, &stopped) || stopped.TaskLimit || stopped.Action != "the check stopped at its spend ceiling of $0.12, three times its estimate, before it finished" || second.calls != 2 { + t.Fatalf("second check at its own ceiling: %v, calls=%d", err, second.calls) + } + if guard.tally().Total() != .3125 || guard.seatTally(crewroute.Checker).Total() != .3125 { + t.Fatalf("aggregate spend changed: task=%v checker=%v", guard.tally().Total(), guard.seatTally(crewroute.Checker).Total()) + } + }) + } +} + +// C2: a real turn's model fallback retains the check's scope and all its +// earlier calls still count when that check asks again. +func TestCheckCeilingSurvivesTheTurnsModelFallback(t *testing.T) { + paidCut := func(ctx context.Context, messages []ai.Message) (*ai.Response, error) { + _, err := blindCutStep(provider.CutSilent, "")(ctx, messages) + return usage(usd(.0625)), err + } + next := chained([]string{"vendor/fallback"}, paidCut, paidCut, + func(context.Context, []ai.Message) (*ai.Response, error) { + response := textResponse("checked on the fallback") + response.Usage = &ai.Usage{Cost: usd(.0625)} + return response, nil + }, + ) + guard := &SpendGuard{ + Price: func(string) (float64, float64, float64, bool) { return 0, 1e-9, 0, true }, + SeatCeilings: map[crewroute.Seat]float64{crewroute.Checker: .1875}, + CeilingAction: config.CrewCheckCeilingAction, + } + marked := SeatCompleterFor(crewroute.Checker, "check-a", guard.Wrap("test/model", next)) + agent, _ := newTestAgent(t, marked, nil) + events, err := agent.Submit(t.Context(), "check the result") + if err != nil { + t.Fatal(err) + } + collected := collect(t, events) + if failure, failed := firstOfKind(collected, EventError); failed { + t.Fatalf("fallback failed: %v", failure.Err) + } + if next.requests() != 3 || next.model(2) != "vendor/fallback" { + t.Fatalf("fallback was not called: requests=%d", next.requests()) + } + events, err = agent.Submit(t.Context(), "check once more") + if err != nil { + t.Fatal(err) + } + failure, failed := firstOfKind(collect(t, events), EventError) + var stopped ErrSpendStopped + if !failed || !errors.As(failure.Err, &stopped) || stopped.TaskLimit || stopped.Action != "the check stopped at its spend ceiling of $0.19, three times its estimate, before it finished" || next.requests() != 3 { + t.Fatalf("check forgot its fallback spend: %v, requests=%d", failure.Err, next.requests()) + } +} + +// C3: work and separate checks share the task and day tallies; the task-wide +// refusal is typed even while every check remains below its own ceiling. +func TestScopedChecksStillShareTheTaskAndDayLimits(t *testing.T) { + for _, priced := range []bool{true, false} { + guard := &SpendGuard{ + Price: func(string) (float64, float64, float64, bool) { return 0, 1e-6, 0, priced }, + Day: NewSpendDay(0), Cap: .75, CapAction: "day limit", + TaskCap: .5, TaskAction: "task limit", + SeatCeilings: map[crewroute.Seat]float64{crewroute.Checker: .25}, + CeilingAction: config.CrewCheckCeilingAction, + } + worker := &spendingCompleter{usd: .25} + work := SeatCompleterFor(crewroute.Worker, "work", guard.Wrap("vendor/work", worker)) + if _, err := work.CompleteWithMessages(t.Context(), nil); err != nil { + t.Fatal(err) + } + checks := &spendingCompleter{usd: .125} + for _, scope := range []string{"check-a", "check-b"} { + check := SeatCompleterFor(crewroute.Checker, scope, guard.Wrap("vendor/check", checks)) + if _, err := check.CompleteWithMessages(t.Context(), nil); err != nil { + t.Fatalf("priced=%v scope=%s: %v", priced, scope, err) + } + } + check := SeatCompleterFor(crewroute.Checker, "check-c", guard.Wrap("vendor/check", checks)) + _, err := check.CompleteWithMessages(t.Context(), nil) + var stopped ErrSpendStopped + if !errors.As(err, &stopped) || !stopped.TaskLimit || stopped.Action != "task limit" || checks.calls != 2 { + t.Fatalf("priced=%v: task limit lost: %v, check calls=%d", priced, err, checks.calls) + } + if guard.tally().Total() != .5 || guard.Day.Total() != .5 || guard.seatTally(crewroute.Checker).Total() != .25 { + t.Fatalf("priced=%v: task=%v day=%v checker=%v", priced, guard.tally().Total(), guard.Day.Total(), guard.seatTally(crewroute.Checker).Total()) + } + } +} + +// C4: an unscoped crew retains its per-seat ceiling, while seats without +// ceilings can keep working on the same model, with or without task scopes. +func TestUnscopedCrewKeepsItsSeatCeiling(t *testing.T) { + guard := &SpendGuard{ + Price: func(string) (float64, float64, float64, bool) { return 0, 1e-6, 0, true }, + SeatCeilings: map[crewroute.Seat]float64{crewroute.Checker: .125}, + CeilingAction: config.CrewCheckCeilingAction, + } + checks := &spendingCompleter{usd: .0625} + check := SeatCompleter(crewroute.Checker, guard.Wrap("vendor/shared", checks)) + for i := 0; i < 2; i++ { + if _, err := check.CompleteWithMessages(t.Context(), nil); err != nil { + t.Fatal(err) + } + } + // A fresh wrapper without a scope must keep the crew's earlier checker spend. + check = SeatCompleter(crewroute.Checker, guard.Wrap("vendor/fallback", checks)) + _, err := check.CompleteWithMessages(t.Context(), nil) + var stopped ErrSpendStopped + if !errors.As(err, &stopped) || stopped.TaskLimit || checks.calls != 2 { + t.Fatalf("unscoped ceiling changed: %v, calls=%d", err, checks.calls) + } + for _, seat := range []crewroute.Seat{crewroute.Worker, crewroute.Planner} { + calls := &spendingCompleter{usd: .25} + for _, scope := range []string{"", "work"} { + marked := SeatCompleterFor(seat, scope, guard.Wrap("vendor/shared", calls)) + for i := 0; i < 3; i++ { + if _, err := marked.CompleteWithMessages(t.Context(), nil); err != nil { + t.Fatalf("%s scope=%q: %v", seat, scope, err) + } + } + } + if calls.calls != 6 { + t.Fatalf("%s calls=%d", seat, calls.calls) + } + } +} + +// C1, C2, C4: in-flight calls hold the ceiling for their own scope. Finishing +// another scope cannot release an unscoped crew's outstanding reservation. +func TestCheckCeilingReservationsStayWithTheirScope(t *testing.T) { + guard := &SpendGuard{ + Price: func(string) (float64, float64, float64, bool) { return 0, .125 / 2000, 0, true }, + SeatCeilings: map[crewroute.Seat]float64{crewroute.Checker: .125}, + CeilingAction: config.CrewCheckCeilingAction, + } + calls := &spendingCompleter{} + unscoped := SeatCompleter(crewroute.Checker, guard.Wrap("vendor/shared", calls)) + checkA := SeatCompleterFor(crewroute.Checker, "check-a", guard.Wrap("vendor/shared", calls)) + checkB := SeatCompleterFor(crewroute.Checker, "check-b", guard.Wrap("vendor/shared", calls)) + inside := &scriptedCompleter{steps: []step{func(context.Context, []ai.Message) (*ai.Response, error) { + _, err := checkA.CompleteWithMessages(t.Context(), nil) + var stopped ErrSpendStopped + if !errors.As(err, &stopped) || stopped.TaskLimit || calls.calls != 0 { + t.Fatalf("same check ignored its outstanding hold: %v, calls=%d", err, calls.calls) + } + if _, err := checkB.CompleteWithMessages(t.Context(), nil); err != nil { + t.Fatalf("second check shared the first hold: %v", err) + } + _, err = unscoped.CompleteWithMessages(t.Context(), nil) + if !errors.As(err, &stopped) || calls.calls != 1 { + t.Fatalf("second check released the unscoped hold: %v, calls=%d", err, calls.calls) + } + return usage(usd(.125)), nil + }}} + outer := &scriptedCompleter{steps: []step{func(context.Context, []ai.Message) (*ai.Response, error) { + marked := SeatCompleterFor(crewroute.Checker, "check-a", guard.Wrap("vendor/shared", inside)) + if _, err := marked.CompleteWithMessages(t.Context(), nil); err != nil { + t.Fatal(err) + } + return usage(usd(.125)), nil + }}} + marked := SeatCompleter(crewroute.Checker, guard.Wrap("vendor/shared", outer)) + if _, err := marked.CompleteWithMessages(t.Context(), nil); err != nil { + t.Fatal(err) + } + if guard.tally().Total() != .25 || guard.seatTally(crewroute.Checker).Total() != .25 || guard.tally().held != 0 || guard.seatTally(crewroute.Checker).held != 0 { + t.Fatalf("reservations or aggregates changed: task=%+v checker=%+v", guard.tally(), guard.seatTally(crewroute.Checker)) + } +} + +// C2, C3: a task/day refusal releases the check's estimate, so raising that +// shared limit admits the same check without losing its own ceiling. +func TestSharedLimitRefusalReleasesTheCheckReservation(t *testing.T) { + for _, limit := range []string{"task", "day"} { + t.Run(limit, func(t *testing.T) { + guard := &SpendGuard{ + Price: func(string) (float64, float64, float64, bool) { return 0, .125 / 2000, 0, true }, + Day: NewSpendDay(0), CapAction: "day limit", TaskAction: "task limit", + SeatCeilings: map[crewroute.Seat]float64{crewroute.Checker: .125}, + CeilingAction: config.CrewCheckCeilingAction, + } + if limit == "task" { + guard.TaskCap = .0625 + } else { + guard.Cap = .0625 + } + calls := &spendingCompleter{usd: .125} + check := SeatCompleterFor(crewroute.Checker, "check-a", guard.Wrap("vendor/shared", calls)) + _, err := check.CompleteWithMessages(t.Context(), nil) + var stopped ErrSpendStopped + if !errors.As(err, &stopped) || stopped.TaskLimit != (limit == "task") || stopped.Action != limit+" limit" || calls.calls != 0 { + t.Fatalf("shared limit did not refuse the call: %v, calls=%d", err, calls.calls) + } + guard.TaskCap, guard.Cap = 1, 1 + if _, err := check.CompleteWithMessages(t.Context(), nil); err != nil || calls.calls != 1 { + t.Fatalf("refusal leaked the check reservation: %v, calls=%d", err, calls.calls) + } + _, err = check.CompleteWithMessages(t.Context(), nil) + if !errors.As(err, &stopped) || stopped.TaskLimit || stopped.Action != "the check stopped at its spend ceiling of $0.12, three times its estimate, before it finished" || calls.calls != 1 { + t.Fatalf("raised shared limit bypassed the check ceiling: %v, calls=%d", err, calls.calls) + } + if guard.Day.held != 0 || guard.tally().held != 0 { + t.Fatal("shared reservations were not released") + } + }) + } +} diff --git a/internal/session/stale_forks_test.go b/internal/session/stale_forks_test.go index 57d5f2abfe..e0821e5781 100644 --- a/internal/session/stale_forks_test.go +++ b/internal/session/stale_forks_test.go @@ -144,7 +144,7 @@ func TestSweepForkFoldersCancellationStopsAfterFirstRemoval(t *testing.T) { } ctx, cancel := context.WithCancel(context.Background()) defer cancel() - SweepPlacesContext(ctx, root, now, func(string) { cancel() }) + SweepPlacesContext(ctx, root, now, nil, func(string) { cancel() }) if _, err := os.Stat(first); !os.IsNotExist(err) { t.Fatalf("first removal did not happen: %v", err) } diff --git a/internal/session/start_chat_tool_test.go b/internal/session/start_chat_tool_test.go index d57708015a..d370e35a07 100644 --- a/internal/session/start_chat_tool_test.go +++ b/internal/session/start_chat_tool_test.go @@ -44,6 +44,47 @@ func TestStartChatResolvesModelAndEmitsOnlyARequest(t *testing.T) { } } +// C1, C5: omitting a new chat's model inherits the configured selection of a +// temporary parent; an explicit model still names the person's chosen model. +func TestStartChatInheritsConfiguredModelFromTemporaryParent(t *testing.T) { + for _, explicit := range []bool{false, true} { + t.Run(map[bool]string{false: "inherited", true: "explicit"}[explicit], func(t *testing.T) { + args, want := `{"brief":"Read README"}`, "test/model" + if explicit { + args, want = `{"brief":"Read README","model":"chosen/model"}`, "chosen/model" + } + c := chained(nil, + func(context.Context, []ai.Message) (*ai.Response, error) { + return toolResponse("new-chat", "start_chat", args), nil + }, + func(context.Context, []ai.Message) (*ai.Response, error) { return textResponse("Requested."), nil }, + ) + a, _ := newTestAgent(t, c, func(cfg *Config) { + cfg.Interactive = true + cfg.Place = Place{Dir: t.TempDir()} + cfg.TaskModels = func() []string { return []string{"chosen/model"} } + }) + if err := a.UseModelForNow("available/model"); err != nil { + t.Fatal(err) + } + _, _ = a.loadCapability("chats") + stream, err := a.Submit(t.Context(), "start another chat") + if err != nil { + t.Fatal(err) + } + var request *ChatStart + for _, event := range collect(t, stream) { + if event.Kind == EventChatStart { + request = event.ChatStart + } + } + if request == nil || request.Model != want || a.Model() != "available/model" || a.ConfiguredModel() != "test/model" { + t.Fatalf("request=%+v live=%q configured=%q", request, a.Model(), a.ConfiguredModel()) + } + }) + } +} + func TestStartChatRefusesUnknownAndAmbiguousModels(t *testing.T) { agent, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { c.TaskModels = func() []string { return []string{"moonshotai/kimi-k3", "moonshotai/kimi-k3.1-pro"} } diff --git a/internal/session/stoprun.go b/internal/session/stoprun.go index 37eb35cbe9..da8f69b452 100644 --- a/internal/session/stoprun.go +++ b/internal/session/stoprun.go @@ -243,6 +243,13 @@ func (a *Agent) endedBeltRow(id uint64, why string) (string, bool, error) { if kept.State == TaskRunning { return a.stopUndrivenRow(id, kept.Parent, why) } + stopped, err := a.stopUndrivenRunWork(kept, why) + if err != nil { + return "", true, err + } + if stopped { + return a.stopUndrivenRow(id, kept.Parent, why) + } return taskStopName(id, kept.Title) + " has already finished; there is nothing to stop", true, nil } } @@ -256,10 +263,9 @@ func (a *Agent) endedBeltRow(id uint64, why string) (string, bool, error) { const runRowUndrivenWord = "nothing is driving this task any more, so no worker can read a message; stop it to clear the row" // stopUndrivenRow settles a run row nothing drives: a row the rail draws -// running whose run is gone, or whose task the run's plan does not hold. THERE -// IS NO WORK TO CUT, so the stop is the row's ending and nothing else, written -// where every run row's ending is written ([Agent.publishRunRow]) so the rail -// and the conversation read back tomorrow agree that it stopped. +// running whose run is gone, or whose task the run's plan does not hold. It +// clears any lingering work in that root's store, then publishes the row's +// ending where every run row's ending is kept ([Agent.publishRunRow]). func (a *Agent) stopUndrivenRow(id, parent uint64, why string) (string, bool, error) { g := a.graph() notice := TaskNotice{ID: id, Parent: parent} @@ -268,6 +274,9 @@ func (a *Agent) stopUndrivenRow(id, parent uint64, why string) (string, bool, er notice = kept } } + if _, err := a.stopUndrivenRunWork(notice, why); err != nil { + return "", true, err + } notice.State, notice.Stopped = TaskFailed, true notice.Report = stopBecause(taskStoppedWord, why) + " · nothing was driving it any more" notice.EndedAt = a.taskClockNow() @@ -279,6 +288,30 @@ func (a *Agent) stopUndrivenRow(id, parent uint64, why string) (string, bool, er return "stopped " + stopBecause(taskStopName(id, notice.Title), why) + " — nothing was driving it any more", true, nil } +// stopUndrivenRunWork clears a retained root's lingering workers as well as +// its stale notice. Fully ended runs and individual joined rows stay as they are. +func (a *Agent) stopUndrivenRunWork(row TaskNotice, why string) (bool, error) { + stopped := false + matched := false + err := a.planSteer(planStoreID(appRunRowKey(row)), func(store *plandb.Store, task *plandb.Task) error { + matched = true + if !planStopOpenWork(store, task, []bool{true}) { + return nil + } + if err := store.StopRoot(stopBecause(taskStoppedWord, why)); err != nil { + return err + } + stopped = true + return nil + }, true) + if !matched { + // A stale notice can outlive its store; only an attempted store write + // can fail this cleanup and prevent the stop receipt. + return false, nil + } + return stopped, err +} + // sayToRunRowFrom is a message to a row a run owns, and it reports whether the // number is a run's at all: false sends the caller on to the answer that there // is no such task. diff --git a/internal/session/sweep.go b/internal/session/sweep.go index eb24e4511a..eaad561804 100644 --- a/internal/session/sweep.go +++ b/internal/session/sweep.go @@ -106,18 +106,19 @@ const ( // an errand's folder is still somebody's — an automation was saved from it — // and may be nil. func SweepHome(errandsRoot string, held func(dir string) bool, note func(string)) { - SweepHomeContext(context.Background(), errandsRoot, held, note) + SweepHomeContext(context.Background(), errandsRoot, held, nil, note) } // SweepHomeContext is SweepHome with cancellation. Cancellation is checked // before resolving the home so a stopped launch cannot target a home selected -// after it started. -func SweepHomeContext(ctx context.Context, errandsRoot string, held func(dir string) bool, note func(string)) { +// after it started. A non-nil keep preserves project conversations retained by +// the caller; held independently preserves errands saved by automations. +func SweepHomeContext(ctx context.Context, errandsRoot string, held, keep func(dir string) bool, note func(string)) { if contextDone(ctx) { return } now := time.Now() - SweepPlacesContext(ctx, home.Join("v3", placesDirName), now, note) + SweepPlacesContext(ctx, home.Join("v3", placesDirName), now, keep, note) if contextDone(ctx) { return } @@ -129,12 +130,13 @@ func SweepHomeContext(ctx context.Context, errandsRoot string, held func(dir str // A root that is not there is not a failure — it is a machine that has not held // a conversation yet — and answers silently. func SweepPlaces(root string, now time.Time, note func(string)) { - SweepPlacesContext(context.Background(), root, now, note) + SweepPlacesContext(context.Background(), root, now, nil, note) } // SweepPlacesContext is SweepPlaces with cancellation between entries and -// immediately before each destructive operation. -func SweepPlacesContext(ctx context.Context, root string, now time.Time, note func(string)) { +// immediately before each destructive operation. A non-nil keep can retain a +// conversation that would otherwise be litter, without preventing log expiry. +func SweepPlacesContext(ctx context.Context, root string, now time.Time, keep func(dir string) bool, note func(string)) { if contextDone(ctx) { return } @@ -174,7 +176,7 @@ func SweepPlacesContext(ctx context.Context, root string, now time.Time, note fu sweepUnfinishedCopies(ctx, path, sessions, now, note) continue } - sweepSession(ctx, filepath.Join(path, entry.Name()), now, note) + sweepSession(ctx, filepath.Join(path, entry.Name()), now, keep, note) } } } @@ -182,7 +184,7 @@ func SweepPlacesContext(ctx context.Context, root string, now time.Time, note fu // sweepSession is the three rules over one session folder, in the order that // makes rule 3 unconditional: the live check first, and nothing at all happens // to a folder that fails it. -func sweepSession(ctx context.Context, dir string, now time.Time, note func(string)) { +func sweepSession(ctx context.Context, dir string, now time.Time, keep func(dir string) bool, note func(string)) { if contextDone(ctx) || sessionIsOpen(dir) { // Somebody is talking to it. Not its logs, not its folder, nothing. return @@ -205,6 +207,9 @@ func sweepSession(ctx context.Context, dir string, now time.Time, note func(stri retireCheckpointForks(ctx, dir, true, note) return } + if keep != nil && keep(dir) { + return + } reapSession(ctx, dir, meta, note) } diff --git a/internal/session/sweep_copy_test.go b/internal/session/sweep_copy_test.go index 212139b9d7..3ce7bd498e 100644 --- a/internal/session/sweep_copy_test.go +++ b/internal/session/sweep_copy_test.go @@ -174,7 +174,7 @@ func TestSweepHomeContextReapsOnlyRecognizedUnfinishedCopies(t *testing.T) { writeFile(t, unknown+".lock", "") writeFile(t, filepath.Join(filepath.Dir(copy), cell.CopyFolderReadme), "keep this note") stampTree(t, unknown, now.Add(-8*24*time.Hour)) - SweepHomeContext(context.Background(), "", nil, nil) + SweepHomeContext(context.Background(), "", nil, nil, nil) if _, err := os.Stat(copy); !os.IsNotExist(err) { t.Fatalf("launch left scratch copy: %v", err) } diff --git a/internal/session/sweep_test.go b/internal/session/sweep_test.go index a5287ee018..14b150e8cc 100644 --- a/internal/session/sweep_test.go +++ b/internal/session/sweep_test.go @@ -543,7 +543,7 @@ func TestSweepHomeContextCancelledBeforeStartTouchesNothing(t *testing.T) { ctx, cancel := context.WithCancel(context.Background()) cancel() - SweepHomeContext(ctx, errandsRoot, nil, func(line string) { t.Fatalf("cancelled sweep said %q", line) }) + SweepHomeContext(ctx, errandsRoot, nil, nil, func(line string) { t.Fatalf("cancelled sweep said %q", line) }) for _, kept := range []string{place, staleLog, exchange} { if _, err := os.Stat(kept); err != nil { @@ -564,7 +564,7 @@ func TestSweepPlacesContextCancellationStopsLaterDestructiveOperations(t *testin first, firstTree := newSweptUniverseSession(t, root, "1111111111111111", repo, now) second, secondTree := newSweptUniverseSession(t, root, "2222222222222222", repo, now) ctx, cancel := context.WithCancel(context.Background()) - SweepPlacesContext(ctx, root, now, func(line string) { + SweepPlacesContext(ctx, root, now, nil, func(line string) { if strings.Contains(line, firstTree.universe) { cancel() } diff --git a/internal/session/task.go b/internal/session/task.go index 7ec774d474..45f3fe930c 100644 --- a/internal/session/task.go +++ b/internal/session/task.go @@ -827,7 +827,7 @@ func (a *Agent) stageTask(ctx context.Context, args json.RawMessage) bare.Staged // THE SLOT IS TAKEN BEFORE THE QUESTION and handed back by everything that // is not an admission, so a batch of proposals cannot walk through the fan // cap together ([TaskGraph.claimChild]). - if refusal := graph.claimChild(spec.parent); refusal != "" { + if refusal := graph.claimChild(spec.parent, spec.title); refusal != "" { return bare.Settled(refusal, true) } id := graph.reserve() @@ -843,7 +843,7 @@ func (a *Agent) stageTask(ctx context.Context, args json.RawMessage) bare.Staged if err != nil { // The session closed under the question. Nothing was asked and nothing // admitted, so the proposal simply never became one. - graph.releaseChild(spec.parent) + graph.releaseChild(spec.parent, spec.title) return bare.Settled(taskNeverAnswered, true) } return &stagedProposal{agent: a, graph: graph, id: id, spec: spec, stand: stand, elsewhere: elsewhere, wait: wait} @@ -871,7 +871,7 @@ type stagedProposal struct { // other ending of [stagedProposal.Commit], and exactly one of the two runs. func (p *stagedProposal) Withdraw() { p.wait.withdraw() - p.graph.releaseChild(p.spec.parent) + p.graph.releaseChild(p.spec.parent, p.spec.title) } // Commit reads what the wait came to and admits on it. It runs once the message @@ -885,7 +885,7 @@ func (p *stagedProposal) Commit(ctx context.Context) (string, bool, error) { admitted := false defer func() { if !admitted { - graph.releaseChild(spec.parent) + graph.releaseChild(spec.parent, spec.title) } }() answer, err := p.wait.answer() @@ -957,6 +957,7 @@ func (p *stagedProposal) Commit(ctx context.Context) (string, bool, error) { // 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, a.proposalCrewWish(p.id, crewWish{effort: spec.crewEffort, worker: namedModel(spec)})) + ctx = withSilentTaskStart(ctx, p.wait.silent) // THE BLOCK THE ROW WILL BE BORN IN is the one captured when the card was raised, // whichever road publishes its first row (task_source.go). a.expectRunSource(p.id, spec.source) @@ -969,15 +970,26 @@ func (p *stagedProposal) Commit(ctx context.Context) (string, bool, error) { return taskReceipt(p.id, spec, state, p.stand, elsewhere), false, nil } -// proposalBrief is what a proposal committed to a run hands its work: the -// composed brief every task gets, or, for a program that reads its brief as -// words, the words alone, the way a typed `/` hands them -// ([delegate.Delegate.Words]). +// proposalBrief hands a proposed run only its assignment. The proposing +// conversation must put all needed context into the work order itself. func proposalBrief(spec taskSpec, via *delegate.Delegate) string { if via != nil && via.Words { return programWords(spec.brief, *via) } - return composeBrief(briefWhole, spec.request, spec.brief, spec.deliverable, spec.acceptance, "", spec.admission, spec.origin, taskCopy{}) + var parts []string + if strings.TrimSpace(spec.title) != "" { + parts = append(parts, spec.title) + } + if strings.TrimSpace(spec.brief) != "" { + parts = append(parts, briefWorkHeading+"\n\n"+strings.TrimSpace(spec.brief)) + } + if strings.TrimSpace(spec.deliverable) != "" { + parts = append(parts, briefMakeHeading+"\n\n"+spec.deliverable) + } + if strings.TrimSpace(spec.acceptance) != "" { + parts = append(parts, briefDoneHeading+"\n\n"+spec.acceptance) + } + return strings.Join(parts, "\n\n") } // commitProposalToRun is the run road of an approved proposal: an approved @@ -1565,6 +1577,8 @@ type taskWait struct { // settled carries what the wait came to, once; a withdrawn wait sends // nothing on it. settled chan taskSettled + // silent is written before the expiry answer is sent and read after it. + silent bool // withdrawn is closed by [taskWait.withdraw] and finished by // [taskWait.run] as it returns. withdrawn chan struct{} @@ -1612,6 +1626,7 @@ func (w *taskWait) run(ctx context.Context, expiry <-chan time.Time, stopTimer f // Silence is a yes, and it is a yes with no redirect: the person said // nothing, so nothing is appended to the brief. silent := TaskAnswer{Approved: true} + w.silent = true w.decided(silent) // AND THE QUESTION CLOSES SAYING THE WORK STARTED, before the turn is // handed the yes. The card above already says so, but on the task diff --git a/internal/session/task_app.go b/internal/session/task_app.go index 0406d6e384..36bacd05fa 100644 --- a/internal/session/task_app.go +++ b/internal/session/task_app.go @@ -22,6 +22,7 @@ type AppTaskView struct { Retryable bool ID, Parent, Title, Brief, State, Model, Origin, Result string + Group string Waits []string Deadline time.Time Held bool @@ -195,6 +196,7 @@ func (g *TaskGraph) writeAppControl(change func() (func(), error)) error { store.mu.Lock() defer store.mu.Unlock() } + bills := g.nodeTreeBills() g.mu.Lock() defer g.mu.Unlock() rollback, err := change() @@ -202,7 +204,7 @@ func (g *TaskGraph) writeAppControl(change func() (func(), error)) error { return err } if store != nil { - if err := store.writeLocked(g.documentLocked()); err != nil { + if err := store.writeLocked(g.documentLocked(bills)); err != nil { if rollback != nil { rollback() } @@ -264,6 +266,7 @@ func (a *Agent) AppTasks() ([]AppTaskView, error) { out = a.appGraphTasks(out, cap, graphIDs) // Both projections share the plan gate and each trajectory for this listing. rows := a.appRunRows() + aliases := appRunPlanAliases(rows) stores, plan, closeStores := a.openPlanReadHandles() defer closeStores() read := &planTaskRead{stores: stores, plan: plan} @@ -273,12 +276,18 @@ func (a *Agent) AppTasks() ([]AppTaskView, error) { continue } graphIDs[id] = true + graphIDs[planStoreID(appRunRowKey(row))] = true out = append(out, a.appRunViewIn(row, cap, read)) } - for _, row := range a.planTasksIn(read) { + plans := a.planTasksIn(read) + groups := appPlanGroups(rows, plans) + for _, row := range plans { if graphIDs[row.ID] { continue } + if parent := aliases[row.Parent]; parent != "" { + row.Parent = parent + } state := row.StateWord() if state == "queued" { state = "waiting" @@ -289,7 +298,7 @@ func (a *Agent) AppTasks() ([]AppTaskView, error) { if state == "incomplete" { state = "failed" } - out = append(out, AppTaskView{Source: cloneSource(row.Source), Dismissed: state == "failed" && a.tasker().isDismissed(row.ID), 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, AppTaskView{Source: cloneSource(row.Source), Dismissed: state == "failed" && a.tasker().isDismissed(row.ID), ID: row.ID, Parent: row.Parent, Group: groups[row.ID], 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 }) diff --git a/internal/session/task_app_runs.go b/internal/session/task_app_runs.go index 0a65d559f8..ef805e63cd 100644 --- a/internal/session/task_app_runs.go +++ b/internal/session/task_app_runs.go @@ -117,6 +117,7 @@ func (a *Agent) appRunViewIn(row TaskNotice, cap float64, read *planTaskRead) Ap 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", } + view.Group = row.Run view.Source, view.FinishedAt = cloneSource(row.Source), row.EndedAt view.RunProgram, view.Ending, view.Branch = appRunFacts(row) view.Dismissed = view.State == "failed" && row.Ending != TaskEndingStopped && a.tasker().isDismissed(view.ID) @@ -153,6 +154,14 @@ func (a *Agent) appRunDetail(view *AppTaskView, key string, read *planTaskRead) if task == nil { continue } + appRunStoreEnding(view, store, task) + if view.State == "running" && task.Status == plandb.StatusReady { + row := PlanTaskRow{Status: string(task.Status)} + a.markPlanMachineHold(&row, store.Path(), task.ID == store.RootID()) + if row.Undriven { + view.State = "waiting" + } + } dir := filepath.Dir(store.Path()) trajectory := read.trajectory(planTrajectoryPath(dir, key), true) steps, reads := trajectory.app, trajectory.reads @@ -183,6 +192,31 @@ func (a *Agent) appRunDetail(view *AppTaskView, key string, read *planTaskRead) } } +// appRunStoreEnding lets the stop's store write take effect before the last +// worker return publishes the run row. A stale notice cannot reopen ended work. +func appRunStoreEnding(view *AppTaskView, store *plandb.Store, task *plandb.Task) { + if view.State != "running" && view.State != "waiting" && view.State != "paused" { + return + } + switch task.Status { + case plandb.StatusDone: + if !store.Stopped() { + return + } + view.State = "done" + case plandb.StatusCancelled: + view.State = "failed" + if planTaskStopped(store, task) { + view.State, view.Ending = "stopped", string(TaskEndingStopped) + } + case plandb.StatusFailed: + view.State = "failed" + default: + return + } + view.FinishedAt = task.CompletedAt +} + // 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, diff --git a/internal/session/task_app_runs_scale_test.go b/internal/session/task_app_runs_scale_test.go index d2feb90428..84877da9e9 100644 --- a/internal/session/task_app_runs_scale_test.go +++ b/internal/session/task_app_runs_scale_test.go @@ -110,15 +110,17 @@ func TestAppTaskListingRereadsChangedTrajectories(t *testing.T) { seen := 0 for _, view := range views { switch view.ID { - case "7", "t-work": + case "7": if view.Step != count { t.Errorf("%s has %d steps, want %d", view.ID, view.Step, count) } seen++ + case "t-work": + t.Fatal("listing repeated the run task as a plan alias") } } - if seen != 2 { - t.Fatalf("saw %d work projections, want 2", seen) + if seen != 1 { + t.Fatalf("saw %d canonical work rows, want 1", seen) } writePlanTrajectory(t, dir, "work", `{"kind":"step","step":1,"command":"echo first"}`, `{"kind":"step","step":2,"command":"echo next"}`) } diff --git a/internal/session/task_app_steps.go b/internal/session/task_app_steps.go index d1fbf05064..80e1fc2aa9 100644 --- a/internal/session/task_app_steps.go +++ b/internal/session/task_app_steps.go @@ -248,7 +248,7 @@ func (l *taskLive) openStep(event Event, at time.Time) { ID: appLiveStepID(event, len(l.calls)), Call: event.CallID, Tool: strings.TrimSpace(event.Tool), - Category: ActionCategoryForTool(event.Tool), + Category: toolEventCategory(event), Target: appStepText(event.Hint), Args: event.Args, Running: true, diff --git a/internal/session/task_app_tree.go b/internal/session/task_app_tree.go new file mode 100644 index 0000000000..e47f07123f --- /dev/null +++ b/internal/session/task_app_tree.go @@ -0,0 +1,34 @@ +package session + +import "strconv" + +// appRunPlanAliases links store ids to the run rows already in the listing. +// Plan-only ids keep their bytes, including nonnumeric parent ids. +func appRunPlanAliases(rows []TaskNotice) map[string]string { + aliases := make(map[string]string, len(rows)) + for _, row := range rows { + aliases[planStoreID(appRunRowKey(row))] = strconv.FormatUint(row.ID, 10) + } + return aliases +} + +// appPlanGroups inherits only a group's recorded value, never a guessed run id. +func appPlanGroups(runs []TaskNotice, plans []PlanTaskRow) map[string]string { + groups := make(map[string]string, len(plans)) + parents := make(map[string]string, len(plans)) + for _, run := range runs { + groups[planStoreID(appRunRowKey(run))] = run.Run + } + for _, plan := range plans { + parents[plan.ID] = plan.Parent + } + for _, plan := range plans { + for id, n := plan.ID, 0; id != "" && n <= len(parents); id, n = parents[id], n+1 { + if group := groups[id]; group != "" { + groups[plan.ID] = group + break + } + } + } + return groups +} diff --git a/internal/session/task_app_tree_test.go b/internal/session/task_app_tree_test.go new file mode 100644 index 0000000000..2361cdded0 --- /dev/null +++ b/internal/session/task_app_tree_test.go @@ -0,0 +1,133 @@ +package session + +import ( + "database/sql" + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/effort" + "github.com/Agent-Field/codeaf/internal/plandb" +) + +// L-LOOP2 C7a: the app's tree links use exactly the ids the listing emits. +func TestAppPlanChildrenLinkToTheirListedRunAndParent(t *testing.T) { + path := filepath.Join(t.TempDir(), planStoreFilename) + seedPlanStore(t, path, "chat-a") + store, err := plandb.Open(path, "", "", "", "") + if err != nil { + t.Fatal(err) + } + defer store.Close() + if _, err := store.AddManyForWorker(store.RootID(), []plandb.TaskSpec{{ID: "child", ParentID: store.RootID(), Title: "Child"}}); err != nil { + t.Fatal(err) + } + if _, err := store.AddManyForWorker("child", []plandb.TaskSpec{{ID: "grandchild", ParentID: "child", Title: "Grandchild"}}); err != nil { + t.Fatal(err) + } + agent, _ := newTestAgent(t, &scriptedCompleter{}, nil) + armPlanStore(t, agent, path, "chat-a") + graph := agent.graph() + graph.mu.Lock() + graph.runs = map[uint64][]TaskNotice{7: {{ID: 7, PlanTask: store.RootID(), Title: "Run", State: TaskRunning, Run: "group-7"}}} + graph.runRuns = []uint64{7} + graph.mu.Unlock() + views, err := agent.AppTasks() + if err != nil { + t.Fatal(err) + } + byID := map[string]AppTaskView{} + for _, view := range views { + byID[view.ID] = view + } + if len(views) != 3 || byID["t-child"].Parent != "7" || byID["t-grandchild"].Parent != "t-child" { + t.Fatalf("listing loses or duplicates the tree: %+v", views) + } + for _, view := range views { + if view.Group != "group-7" { + t.Fatalf("run group lost: %+v", view) + } + } +} + +// L-LOOP2 C8a, C8b: the existing stop door cancels open children even when the +// root had already ended, and stale published run rows cannot keep spinning. +func TestPlanStopEndsOpenWorkBelowAnAlreadyEndedRoot(t *testing.T) { + for _, ended := range []bool{false, true} { + t.Run(map[bool]string{false: "running root", true: "done root"}[ended], func(t *testing.T) { + path := filepath.Join(t.TempDir(), planStoreFilename) + seedPlanStore(t, path, "chat-a", plandb.TaskSpec{ID: "8", Title: "Joined task"}, plandb.TaskSpec{ID: "child", Title: "Worker task"}) + store, err := plandb.Open(path, "", "", "", "") + if err != nil { + t.Fatal(err) + } + defer store.Close() + for _, id := range []string{"8", "child"} { + if _, err := store.Claim(id, id, "test-owner"); err != nil { + t.Fatal(err) + } + } + if ended { + // Reproduce a retained store whose root ended before its workers. + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + _, err = db.Exec("UPDATE tasks SET status = 'done', result = 'already finished' WHERE id = ?", store.RootID()) + _ = db.Close() + if err != nil { + t.Fatal(err) + } + } + agent, _ := newTestAgent(t, &scriptedCompleter{}, nil) + armPlanStore(t, agent, path, "chat-a") + graph := agent.graph() + rootState := TaskRunning + if ended { + rootState = TaskDone + } + graph.mu.Lock() + graph.runs = map[uint64][]TaskNotice{7: { + {ID: 7, PlanTask: store.RootID(), Title: "Run", State: rootState}, + {ID: 8, Parent: 7, Title: "Joined task", State: TaskRunning}, + }} + graph.runRuns = []uint64{7} + graph.mu.Unlock() + if _, err := agent.Cancel(CancelTask + ":7"); err != nil { + t.Fatal(err) + } + for _, task := range store.Tasks() { + if !terminalStoreStatus(task.Status) || task.ID != store.RootID() && (task.ClaimedBy != "" || task.Owner != "") { + t.Fatalf("stop left open work: %+v", task) + } + } + // A late worker result cannot overwrite the cancelled task. + _, _ = store.Done("child", "child", "late result", nil, nil) + views, err := agent.AppTasks() + if err != nil { + t.Fatal(err) + } + for _, view := range views { + if view.State == "running" { + t.Fatalf("stop left a running app row: %+v", view) + } + } + if ended && (store.Task(store.RootID()).Status != plandb.StatusDone || store.Task(store.RootID()).Result != "already finished") { + t.Fatal("stop rewrote the root's earlier ending") + } + }) + } +} + +// L-LOOP2 C3: the actual worker constructor binds its own task beside its run. +func TestRunWorkerShellBindsItsOwnTask(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + t.Setenv(planCLIBinEnv, filepath.Join(t.TempDir(), "stub-codeaf")) + agent := newRunBeltWorker(t, effort.None) + prefix := agent.graph().planBashPrefix() + for _, binding := range []string{plandb.RunEnv + "=", plandb.TaskEnv + "="} { + if !strings.Contains(prefix, binding+quoteShWord(planRootID)) { + t.Fatalf("worker lacks %s: %s", binding, prefix) + } + } +} diff --git a/internal/session/task_archive_stop_test.go b/internal/session/task_archive_stop_test.go new file mode 100644 index 0000000000..75b8b82d7a --- /dev/null +++ b/internal/session/task_archive_stop_test.go @@ -0,0 +1,118 @@ +package session + +import ( + "database/sql" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/plandb" +) + +// L-LOOP2 C8a: starting another run cleans a done root's open descendants. +// The stop also repairs retained stores written before that cleanup existed. +func TestStopRetainedRunLeavesNewerRunUntouched(t *testing.T) { + for _, legacy := range []bool{false, true} { + t.Run(map[bool]string{false: "set aside", true: "older archive"}[legacy], func(t *testing.T) { + path := filepath.Join(t.TempDir(), planStoreFilename) + agent, _ := newTestAgent(t, &scriptedCompleter{}, nil) + graph := agent.graph() + chat := graph.planChat() + seedPlanStore(t, path, chat, plandb.TaskSpec{ID: "8", Title: "Joined task"}, plandb.TaskSpec{ID: "child", Title: "Worker task"}) + store, err := plandb.Open(path, "", "", "", "") + if err != nil { + t.Fatal(err) + } + for _, id := range []string{"8", "child"} { + if _, err := store.Claim(id, id, "old-owner"); err != nil { + t.Fatal(err) + } + } + if err := store.Close(); err != nil { + t.Fatal(err) + } + writeSQL := func(path, query string) { + t.Helper() + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + _, err = db.Exec(query) + _ = db.Close() + if err != nil { + t.Fatal(err) + } + } + writeSQL(path, "UPDATE tasks SET status = 'done', result = 'already finished' WHERE id = 'root'") + armPlanStore(t, agent, path, chat) + graph.mu.Lock() + graph.runs = map[uint64][]TaskNotice{7: { + {ID: 7, PlanTask: "root", Title: "Old run", State: TaskDone}, + {ID: 8, Parent: 7, Title: "Joined task", State: TaskRunning}, + }} + graph.runRuns = []uint64{7} + graph.mu.Unlock() + _, newer, err := agent.openBeltRunStore(graph, path, "9", "New run", "Independent work", false) + if err != nil { + t.Fatal(err) + } + defer newer.Close() + before := newer.Tasks() + archived, err := plandb.Open(path+".1", "", "", "", "") + if err != nil { + t.Fatal(err) + } + defer archived.Close() + if legacy { + writeSQL(path+".1", "UPDATE tasks SET status = 'claimed', claimed_by = id, owner = 'old-owner' WHERE id IN ('8', 'child')") + } else { + for _, task := range archived.Tasks() { + if !terminalStoreStatus(task.Status) || task.ID != "root" && (task.ClaimedBy != "" || task.Owner != "") { + t.Fatalf("archiving left open work: %+v", task) + } + } + } + if legacy { + writeSQL(path+".1", "CREATE TRIGGER reject_stop BEFORE DELETE ON tasks BEGIN SELECT RAISE(ABORT, 'stop write refused'); END") + if _, err := agent.Cancel(CancelTask + ":7"); err == nil || !strings.Contains(err.Error(), "stop write refused") { + t.Fatalf("stop hid a retained-store write error: %v", err) + } + writeSQL(path+".1", "DROP TRIGGER reject_stop") + } + if _, err := agent.Cancel(CancelTask + ":7"); err != nil { + t.Fatal(err) + } + for _, task := range archived.Tasks() { + if !terminalStoreStatus(task.Status) || task.ID != "root" && (task.ClaimedBy != "" || task.Owner != "") { + t.Fatalf("stop left retained work running: %+v", task) + } + } + root := archived.Task("root") + if root.Status != plandb.StatusDone || root.Result != "already finished" || root.ClaimedBy != "runtime" { + t.Fatalf("old root's ending changed: %+v", root) + } + if !reflect.DeepEqual(before, newer.Tasks()) { + t.Fatal("stopping the retained run changed the newer run") + } + views, err := agent.AppTasks() + if err != nil { + t.Fatal(err) + } + newerShown := false + for _, view := range views { + if view.ID == "t-9" { + newerShown = true + if view.State != "running" { + t.Fatalf("stop changed the newer app row: %+v", view) + } + } else if view.State == "running" { + t.Fatalf("retained task still reads running: %+v", view) + } + } + if !newerShown { + t.Fatal("stop hid the newer run from the app listing") + } + }) + } +} diff --git a/internal/session/task_bounds.go b/internal/session/task_bounds.go new file mode 100644 index 0000000000..fe94421182 --- /dev/null +++ b/internal/session/task_bounds.go @@ -0,0 +1,64 @@ +package session + +import ( + "context" + "time" + + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/plandb" +) + +type silentTaskStartKey struct{} + +func withSilentTaskStart(ctx context.Context, silent bool) context.Context { + return context.WithValue(ctx, silentTaskStartKey{}, silent) +} + +func silentTaskStart(ctx context.Context) bool { + silent, _ := ctx.Value(silentTaskStartKey{}).(bool) + return silent +} + +func addJoinedTasks(ctx context.Context, store *plandb.Store, specs []plandb.TaskSpec) ([]*plandb.Task, error) { + return store.AddJoinedMany(specs, silentTaskStart(ctx)) +} + +// taskRunCost bounds the whole tree even when no crew is routed for it. +func (a *Agent) taskRunCost(run *beltRun, cost float64) float64 { + if run.delegate != nil { + return cost + } + taskCap, _ := config.CrewTaskSpendCap(a.config.ProfileDir) + spent := 0.0 + if run.store != nil { + spent = storeSpent(run.store) + } + // Every dispatch passes here, including continuation. The ending names + // the configured cap, while the engine receives only its unspent allowance. + run.costCeiling = taskCap + allowance, conversation := taskTreeCost(taskCap, spent, cost) + run.conversationCostLimit = conversation + return allowance +} + +// taskTreeCost chooses the same remaining allowance for either task road. +func taskTreeCost(taskCap, spent, cost float64) (float64, bool) { + left := runCostLeft(taskCap, spent) + conversation := cost > 0 && cost <= left + if cost <= 0 || left < cost { + return left, conversation + } + return cost, conversation +} + +func (run *beltRun) taskLimitWords(summary RunSummary) string { + if run.delegate == nil && summary.Limit == RunLimitCost && run.costCeiling > 0 && !run.conversationCostLimit { + return config.CrewTaskCapAction(run.costCeiling) + } + return "" +} + +func (run *beltRun) outcomeNote(summary RunSummary, landing RunLanding, span time.Duration) string { + line := beltRunOutcomeNote(run.store, run.root, summary, landing, span) + return withReport(line, run.taskLimitWords(summary)) +} diff --git a/internal/session/task_bounds_continue_test.go b/internal/session/task_bounds_continue_test.go new file mode 100644 index 0000000000..de5eb24802 --- /dev/null +++ b/internal/session/task_bounds_continue_test.go @@ -0,0 +1,132 @@ +package session + +import ( + "context" + "math" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/plandb" +) + +// The double models the engine's exhausted-ceiling door; the real supervisor's +// no-launch behavior is pinned by TestARunWhoseLimitIsAlreadySpentSeatsNoWorker. +type continuedTreeLimitEngine struct{ *beltRunDouble } + +func (engine continuedTreeLimitEngine) Start(ctx context.Context, spec RunSpec) RunSummary { + if spec.CostUSD > 0 && spec.CostUSD <= 1e-9 { + spec.CompleterFor = nil + } + return engine.beltRunDouble.Start(ctx, spec) +} + +// L-LOOP C4a, C4b, C4d: continuing adopts the recorded spend rather than +// replenishing the tree, keeps the configured-cap words, and charges only new work. +func TestContinuedRunKeepsItsSpentTreeAllowanceAndConfiguredLimitWords(t *testing.T) { + for _, tc := range []struct { + name string + spent, limit float64 + want, newSpend float64 + calls int + conversationBinds bool + }{ + {name: "nearly spent", spent: 4.875, want: .125, newSpend: .125, calls: 1}, + {name: "task remainder binds", spent: 4.875, limit: 6, want: .125, newSpend: .125, calls: 1}, + {name: "conversation remainder binds", spent: 4.875, limit: 5.1875, want: .0625, newSpend: .0625, calls: 1, conversationBinds: true}, + {name: "spent", spent: 5, want: math.SmallestNonzeroFloat64}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + double := newBeltRunDouble("unfinished work") + double.summary = RunSummary{Outcome: runLimitSentence, Limit: RunLimitCost, USD: tc.newSpend} + double.leaveOpen = true + registerBeltRunEngine(t, continuedTreeLimitEngine{double}) + completer := &scriptedCompleter{steps: []step{finalText("unfinished work")}} + agent, _ := newTestAgent(t, completer, func(c *Config) { + c.Workspace = newTestRepo(t) + c.Place = Place{Dir: t.TempDir()} + c.ProfileDir = t.TempDir() + c.AskConsent = false + c.Interactive = true + c.Budget = Budget{USD: tc.limit} + }) + if err := config.SetCrewTaskCap(agent.config.ProfileDir, "5"); err != nil { + t.Fatal(err) + } + g := agent.graph() + tree, err := prepareTaskTree(agent.config.Place, agent.config.Workspace, "3333dddd3333dddd", 7, "finish the work") + if err != nil { + t.Fatal(err) + } + keepInterruptedRun(t, agent, g, 7, "finish the work", runCopyOf(tree)) + store, err := plandb.Open(g.planPath(), "continued-run", "7", "finish the work", "the unfinished work", g.planChat()) + if err != nil { + t.Fatal(err) + } + if err := store.AddSpend("7", "test/model", plandb.RoleWork, tc.spent, 1, 1); err != nil { + _ = store.Close() + t.Fatal(err) + } + if err := store.Close(); err != nil { + t.Fatal(err) + } + priorUsage := tc.spent + .25 + agent.usage.CostUSD = priorUsage + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if _, err := agent.ContinueRun(ctx, 7); err != nil { + t.Fatal(err) + } + func() { + defer endBeltRun(t, agent, double) + select { + case <-double.entered: + case <-ctx.Done(): + t.Fatal("continuation never reached the engine") + } + double.mu.Lock() + got := double.spec.CostUSD + double.mu.Unlock() + if got != tc.want { + t.Errorf("continued allowance = %v, want %v", got, tc.want) + } + }() + // Only the run's own worker call counts here: the landing's one + // summary refresh afterwards is the conversation's, not the tree's. + completer.mu.Lock() + calls := 0 + for _, seen := range completer.seen { + for _, message := range seen { + if strings.Contains(messageText(message), "run the brief") { + calls++ + break + } + } + } + completer.mu.Unlock() + if calls != tc.calls { + t.Errorf("continued model calls = %d, want %d", calls, tc.calls) + } + if got := agent.Usage().CostUSD; got != priorUsage+tc.newSpend { + t.Errorf("conversation charged persisted spend again: %v, want %v", got, priorUsage+tc.newSpend) + } + row, found := runRowOf(g, 7) + wantWords := config.CrewTaskCapAction(5) + if !found || row.Ending != TaskEndingCostLimit || strings.Contains(row.Report, wantWords) == tc.conversationBinds { + t.Fatalf("continued ending lost its binding limit: %+v", row) + } + store = beltRunStoreAt(t, agent.config.Place.Dir) + defer store.Close() + notes := store.Notes("7", 0) + foundWords := false + for _, note := range notes { + foundWords = foundWords || strings.Contains(note.Body, wantWords) + } + if foundWords == tc.conversationBinds { + t.Fatalf("continued outcome note lost its binding limit: %+v", notes) + } + }) + } +} diff --git a/internal/session/task_bounds_test.go b/internal/session/task_bounds_test.go new file mode 100644 index 0000000000..dd8d9cb0ff --- /dev/null +++ b/internal/session/task_bounds_test.go @@ -0,0 +1,263 @@ +package session + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "path/filepath" + "reflect" + "strings" + "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/delegate" + "github.com/Agent-Field/codeaf/internal/plandb" +) + +// L-LOOP2 C1a: a countdown proposal carries only its own assignment. +func TestProposedRunWorkersReadTheirOwnWorkBeforeTheConversationAsk(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + double := newBeltRunDouble("red was created") + registerBeltRunEngine(t, double) + completer := &routedCompleter{parent: []step{ + proposeCall("Create red.txt", "Create red.txt with the word red"), + finalText("handed off"), + }} + agent, _ := newTestAgent(t, completer, func(c *Config) { + c.Workspace = newTestRepo(t) + c.Place = Place{Dir: t.TempDir()} + c.ProfileDir = t.TempDir() + c.SessionFile = filepath.Join(t.TempDir(), "conversation.jsonl") + c.AskConsent = false + c.TaskAutoApproveSeconds = 0 + }) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + const ask = "call propose_task three times: Create red.txt, Create green.txt, Create blue.txt — propose them, don't do them" + events, err := agent.Submit(ctx, ask) + if err != nil { + t.Fatal(err) + } + collect(t, events) + select { + case <-double.entered: + case <-ctx.Done(): + t.Fatal("proposal never reached the run engine") + } + defer endBeltRun(t, agent, double) + double.mu.Lock() + spec := double.spec + double.mu.Unlock() + root := spec.Store.Task(spec.Store.RootID()) + if root == nil || root.Title != "Create red.txt" { + t.Fatalf("the proposed work was not the run's root: %+v", root) + } + if spec.CostUSD != config.CrewTaskCapDefault { + t.Fatalf("proposal's RunSpec has no per-task ceiling: %v", spec.CostUSD) + } + assertWorkerDocument := func(doc, work string) { + t.Helper() + for _, forbidden := range []string{"propose_task", "Create green.txt", "Create blue.txt", "three times", agent.config.SessionFile} { + if forbidden != "" && strings.Contains(doc, forbidden) { + t.Errorf("worker document contains conversation context %q:\n%s", forbidden, doc) + } + } + for _, want := range []string{"Create red.txt", "Create red.txt with the word red", work} { + if !strings.Contains(doc, want) { + t.Errorf("worker document is missing its assignment %q:\n%s", want, doc) + } + } + } + for _, doc := range []string{spec.Brief, root.Description, BeltWorkerBrief(spec.Store, root, true, false, "", "")} { + assertWorkerDocument(doc, "Create red.txt with the word red") + } + children, err := spec.Store.AddMany([]plandb.TaskSpec{{ID: "part", Title: "Write red bytes", Description: "Write red into red.txt"}}) + if err != nil { + t.Fatal(err) + } + childDoc := BeltWorkerBrief(spec.Store, children[0], false, false, "", "") + assertWorkerDocument(childDoc, "Write red into red.txt") + if !strings.Contains(strings.SplitN(childDoc, "THE WORK", 2)[0], "Write red bytes") { + t.Fatalf("worker opening omits its task title:\n%s", childDoc) + } +} + +// L-LOOP2 C1b, C6: a person-authored whole-job brief keeps its exact earlier bytes. +func TestPersonAuthoredWholeBriefKeepsItsExactDocument(t *testing.T) { + const work = "Create green.txt" + const want = "WHAT THE PERSON ASKED FOR, IN THEIR OWN WORDS\nThis is the message this work came out of. Where anything below reads differently from it, their words are what was asked for.\n\nCreate green.txt" + if got := composeBrief(briefWhole, work, work, "", "", "", AdmissionContext{}, taskOrigin{}, taskCopy{}); got != want { + t.Fatalf("person-authored task brief changed:\n%q", got) + } +} + +// L-LOOP C4a: the shared per-task setting caps an ordinary RunSpec, while a +// delegated program keeps its existing independent ceiling. +func TestConversationRunSpecsHaveATreeSpendCeiling(t *testing.T) { + for _, tc := range []struct { + name string + limit float64 + spent float64 + taskCap string + program *delegate.Delegate + want float64 + }{ + {name: "default", want: config.CrewTaskCapDefault}, + {name: "configured", taskCap: "2.5", want: 2.5}, + {name: "conversation", limit: 3, spent: 1, want: 2}, + {name: "task", limit: 12, spent: 1, want: config.CrewTaskCapDefault}, + {name: "program", program: &delegate.Delegate{Unattended: delegate.Ceilings{CostUSD: 9}}, want: 9}, + {name: "program without ceiling", program: &delegate.Delegate{}, want: 0}, + } { + t.Run(tc.name, func(t *testing.T) { + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { + c.ProfileDir = t.TempDir() + c.Interactive = true + c.Budget = Budget{USD: tc.limit} + }) + if tc.taskCap != "" { + if err := config.SetCrewTaskCap(agent.config.ProfileDir, tc.taskCap); err != nil { + t.Fatal(err) + } + } + agent.usage.CostUSD = tc.spent + run := &beltRun{delegate: tc.program} + if got := agent.beltRunSpec(run, "the work").CostUSD; got != tc.want { + t.Fatalf("run cost ceiling = %v, want %v", got, tc.want) + } + }) + } +} + +// L-LOOP C4b: the existing report, outcome note and ending carry the stop. +func TestPerTaskTreeLimitNamesTheLimitAndHowToRaiseIt(t *testing.T) { + agent, _ := newTestAgent(t, &scriptedCompleter{}, func(c *Config) { + c.ProfileDir = t.TempDir() + c.Interactive = true + }) + run := &beltRun{} + agent.holdProgramCeilings(run, agent.beltRunSpec(run, "the work")) + summary := RunSummary{Outcome: "a limit you set stopped it", Limit: RunLimitCost} + want := config.CrewTaskCapAction(config.CrewTaskCapDefault) + if notice := agent.beltRunNotice(run, summary, RunLanding{}); notice.Ending != TaskEndingCostLimit || !strings.Contains(notice.Report, want) { + t.Fatalf("task-limit row lost its ending or action: %+v", notice) + } + if note := run.outcomeNote(summary, RunLanding{}, 0); !strings.Contains(note, want) { + t.Fatalf("task-limit outcome note lost the action: %s", note) + } + agent.config.Budget = Budget{USD: 1} + agent.holdProgramCeilings(run, agent.beltRunSpec(run, "the work")) + if got := run.taskLimitWords(summary); got != "" { + t.Fatalf("conversation's smaller limit was called the per-task limit: %s", got) + } +} + +// L-LOOP C4c: both guard branches carry the typed task stop through stopCrew; +// a checker ceiling remains local to that seat. +func TestTaskSpendStopKeepsItsScopeThroughTheCrewError(t *testing.T) { + for _, priced := range []bool{false, true} { + t.Run(fmt.Sprint(priced), func(t *testing.T) { + guard := &SpendGuard{TaskCap: 1, TaskAction: "task limit", Task: &SpendTask{}} + if priced { + guard.Price = func(string) (float64, float64, float64, bool) { return 0, 1, 0, true } + } else { + guard.Task.settle(0, 1) + } + _, err := guard.before(t.Context(), "test/model", []ai.Message{textMessage("user", "work")}, nil) + agent, _ := newTestAgent(t, &scriptedCompleter{}, nil) + run := &beltRun{crew: &taskCrew{}} + var stopped ErrSpendStopped + if !errors.As(agent.stopCrew(run, err), &stopped) || !stopped.TaskLimit { + t.Fatalf("task stop lost its scope: %v", err) + } + }) + } + guard := &SpendGuard{ + Price: func(string) (float64, float64, float64, bool) { return 0, 1, 0, true }, + TaskCap: 5, SeatCeilings: map[crewroute.Seat]float64{crewroute.Checker: .01}, CeilingAction: "checker $%.2f", + } + _, err := guard.before(context.WithValue(t.Context(), crewSeatContextKey{}, crewroute.Checker), "test/model", nil, nil) + var stopped ErrSpendStopped + if !errors.As(err, &stopped) || stopped.TaskLimit { + t.Fatalf("a seat ceiling became a tree stop: %v", err) + } +} + +// L-LOOP C5a, C5b, C5c: the expiry fact crosses the real wait and commit; +// an explicit approval and a typed task retain their person-approved join. +func TestSilenceCannotGrowALiveRunPastItsBounds(t *testing.T) { + for _, tc := range []struct { + name, title string + full, click bool + }{ + {name: "duplicate", title: "Open the run"}, + {name: "full", title: "Different work", full: true}, + {name: "approved full", title: "Different work", full: true, click: true}, + } { + t.Run(tc.name, func(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + double := newBeltRunDouble("done") + registerBeltRunEngine(t, double) + agent, _ := newTestAgent(t, beltRunCompleter{text: "done"}, func(c *Config) { + c.Workspace = newTestRepo(t) + c.Place = Place{Dir: t.TempDir()} + c.ProfileDir = t.TempDir() + c.AskConsent = false + }) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if _, _, _, err := agent.StartTask(ctx, "Open the run", false); err != nil { + t.Fatal(err) + } + <-double.entered + defer endBeltRun(t, agent, double) + double.mu.Lock() + store := double.spec.Store + double.mu.Unlock() + if tc.full { + for i := 0; i < plandb.TreeTaskLimit; i++ { + if _, err := store.AddMany([]plandb.TaskSpec{{ID: fmt.Sprintf("part%d", i), Title: fmt.Sprintf("Part %d", i)}}); err != nil { + t.Fatal(err) + } + } + } + before := store.Tasks() + expiry := make(chan time.Time, 1) + agent.taskTimer = func(time.Duration) (<-chan time.Time, func()) { return expiry, func() {} } + args, _ := json.Marshal(taskArguments{Title: tc.title, Summary: "the work", Brief: "Do the different work", Deliverable: "the output", Acceptance: "the output exists"}) + proposal, ok := agent.stageTask(ctx, args).(*stagedProposal) + if !ok { + t.Fatal("proposal was not staged") + } + if tc.click { + agent.ResolveTask(proposal.id, TaskAnswer{Approved: true}) + } else { + expiry <- time.Now() + } + answer, refused, err := proposal.Commit(ctx) + if err != nil || refused == tc.click || proposal.wait.silent == tc.click { + t.Fatalf("join: refused=%v silent=%v err=%v answer=%s", refused, proposal.wait.silent, err, answer) + } + if tc.click { + if len(store.Tasks()) != len(before)+1 { + t.Fatal("person-approved proposal did not join at the count limit") + } + if _, _, _, err := agent.StartTask(ctx, "One more typed task", false); err != nil { + t.Fatalf("typed task could not join: %v", err) + } + } else if !reflect.DeepEqual(before, store.Tasks()) || !strings.Contains(answer, "nothing was added") || agent.graph().node(proposal.id) != nil { + t.Fatalf("silent refusal changed the store or fell back to a session node: %s", answer) + } + if !tc.click { + row, found := runRowOf(agent.graph(), proposal.id) + if !found || row.State != TaskFailed || !strings.Contains(row.Report, "did not start") { + t.Fatalf("refused proposal was left as a starting card: %+v", row) + } + } + }) + } +} diff --git a/internal/session/task_brief.go b/internal/session/task_brief.go index 2f7f033b1e..310247e163 100644 --- a/internal/session/task_brief.go +++ b/internal/session/task_brief.go @@ -300,6 +300,8 @@ func composeBriefScoped(scope briefScope, role briefRole, request, work, deliver order := append([]briefPart{ask}, assignment...) if scope.planBorn() { ask.rule = briefBackgroundRule + } + if scope.planBorn() { order = append(append([]briefPart{}, assignment...), ask) } order = append(order, record...) diff --git a/internal/session/task_divide_compose_test.go b/internal/session/task_divide_compose_test.go index 46c54c31b6..9bdf67968b 100644 --- a/internal/session/task_divide_compose_test.go +++ b/internal/session/task_divide_compose_test.go @@ -189,7 +189,10 @@ func TestAPartsBriefHoldsItsBoundWhateverLengthTheFieldsArriveAt(t *testing.T) { }, false}, {"every field at once", []dividePart{ {Title: huge, Summary: huge, Brief: huge}, - {Title: huge, Summary: huge, Brief: huge}, + // A division refuses two parts naming the same work, so the second + // part's title differs from its first word while every field stays + // at full length. + {Title: "the beta adapter " + huge, Summary: huge, Brief: huge}, }, false}, } { t.Run(test.name, func(t *testing.T) { diff --git a/internal/session/task_divide_family_check_test.go b/internal/session/task_divide_family_check_test.go index 14e5b5da51..60c7780751 100644 --- a/internal/session/task_divide_family_check_test.go +++ b/internal/session/task_divide_family_check_test.go @@ -706,7 +706,9 @@ func TestAThirdAskAfterARepairNeitherRefusesNorDoublesTheFamilysChecks(t *testin nest.divide(t, divideArgsFor(wideEvidence, familyParts("rank", "browse")...)) nest.divide(t, divideArgsFor(wideEvidence, familyParts("rank", "browse")...)) - third := nest.divide(t, divideArgsFor(wideEvidence, familyParts("rank", "browse")...)) + // The repaired ask already admitted the original work. Different work + // can reuse its family check without dispatching those tasks twice. + third := nest.divide(t, divideArgsFor(wideEvidence, familyParts("ranking", "browsing")...)) if !strings.HasPrefix(third, "split into 2 parts:") { t.Fatalf("the third ask was told %q, want the division admitted", third) } diff --git a/internal/session/task_divide_test.go b/internal/session/task_divide_test.go index 9f05566603..e8ac42b14c 100644 --- a/internal/session/task_divide_test.go +++ b/internal/session/task_divide_test.go @@ -1120,14 +1120,20 @@ func TestAStoppedPartsSpendStillReachesTheSessionLedger(t *testing.T) { func TestADivisionWiderThanTheCapIsRefusedWholeRatherThanHalfTaken(t *testing.T) { nest := newDivideNest(t, wideBrief, 0) - // Four parts already handed out leaves room for one, and a division of - // three is not a division of one. - for i := 0; i < taskFanLimit-1; i++ { - if answer := nest.divide(t, divideArgs(wideEvidence, 2)); i == 0 && !strings.Contains(answer, "split into") { + // Distinct parts leave room for two, so a division of three cannot fit. + for i := 0; i < (taskFanLimit-1)/2; i++ { + parts := []dividePart{ + {Title: fmt.Sprintf("Existing north %d", i), Summary: "s", Brief: "b", Acceptance: "a"}, + {Title: fmt.Sprintf("Existing south %d", i), Summary: "s", Brief: "b", Acceptance: "a"}, + } + if answer := nest.divide(t, divideArgsFor(wideEvidence, parts...)); i == 0 && !strings.Contains(answer, "split into") { t.Fatalf("the first division was refused: %q", answer) } } before := nest.graph.children(nest.parent.id) + if len(before) != taskFanLimit-2 { + t.Fatalf("the fixture admitted %d distinct parts, want %d", len(before), taskFanLimit-2) + } answer := nest.divide(t, divideArgs(wideEvidence, 3)) if !strings.Contains(answer, "as many as one task may") { t.Fatalf("the worker was told %q, want the cap said in the words it is said in everywhere else", answer) diff --git a/internal/session/task_divide_wip.go b/internal/session/task_divide_wip.go index 767a7a30b3..83610997e5 100644 --- a/internal/session/task_divide_wip.go +++ b/internal/session/task_divide_wip.go @@ -114,7 +114,11 @@ func (a *Agent) startTheParts(node *TaskNode, parts []dividePart, line *journalD // with one release rather than by a counter each ending has to remember. hands := divisionHands{graph: graph, parent: a.config.taskID} defer hands.release() - if refusal := hands.take(len(parts)); refusal != "" { + partTitles := make([]string, len(parts)) + for i, part := range parts { + partTitles[i] = part.Title + } + if refusal := hands.take(len(parts), partTitles...); refusal != "" { line.Decision = divisionRefusedCap return nil, nil, refusal } @@ -191,6 +195,7 @@ func (a *Agent) startTheParts(node *TaskNode, parts []dividePart, line *journalD // has already gone back ([TaskGraph.admit] releases it under the graph's // own lock) and this is the book agreeing with it. hands.held-- + hands.titles = hands.titles[1:] ids = append(ids, id) titles = append(titles, part.Title) } @@ -209,11 +214,19 @@ type divisionHands struct { graph *TaskGraph parent uint64 held int + titles []string } // take claims one hand per part, or answers the cap's own refusal having given // back whatever it managed to claim first. -func (h *divisionHands) take(parts int) string { +func (h *divisionHands) take(parts int, titles ...string) string { + if len(titles) > 0 { + if refusal := h.graph.claimChild(h.parent, titles...); refusal != "" { + return refusal + } + h.held, h.titles = parts, titles + return "" + } for i := 0; i < parts; i++ { if refusal := h.graph.claimChild(h.parent); refusal != "" { return refusal @@ -225,7 +238,12 @@ func (h *divisionHands) take(parts int) string { func (h *divisionHands) release() { for ; h.held > 0; h.held-- { - h.graph.releaseChild(h.parent) + if len(h.titles) > 0 { + h.graph.releaseChild(h.parent, h.titles[0]) + h.titles = h.titles[1:] + } else { + h.graph.releaseChild(h.parent) + } } } diff --git a/internal/session/task_join_ask_test.go b/internal/session/task_join_ask_test.go new file mode 100644 index 0000000000..29555f0eec --- /dev/null +++ b/internal/session/task_join_ask_test.go @@ -0,0 +1,115 @@ +package session + +import ( + "context" + "encoding/json" + "strings" + "testing" + "time" + + "github.com/Agent-Field/codeaf/internal/plandb" +) + +// L-LOOP2 C1, C6: a real proposal join establishes its own assignment for +// workers and checks below it, under either kind of run root. +func TestJoinedProposalWorkersReadTheirOwnAssignmentsAsk(t *testing.T) { + for _, proposedRoot := range []bool{false, true} { + t.Run(map[bool]string{false: "typed root", true: "proposal root"}[proposedRoot], func(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "bash") + double := newBeltRunDouble("finished") + registerBeltRunEngine(t, double) + completer := &routedCompleter{parent: []step{ + proposeCall("Create red.txt", "ROOT_ONLY_ASSIGNMENT: write red.txt"), + finalText("handed off"), + }} + agent, _ := newTestAgent(t, completer, func(c *Config) { + c.Workspace = newTestRepo(t) + c.Place = Place{Dir: t.TempDir()} + c.ProfileDir = t.TempDir() + c.AskConsent = false + c.TaskAutoApproveSeconds = 0 + }) + ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second) + defer cancel() + if proposedRoot { + events, err := agent.Submit(ctx, "propose_task: Create red.txt") + if err != nil { + t.Fatal(err) + } + collect(t, events) + } else if _, _, _, err := agent.StartTask(ctx, "ROOT_ONLY_ASSIGNMENT: Create red.txt; PERSON_ONLY_FORBIDDEN", false); err != nil { + t.Fatal(err) + } + select { + case <-double.entered: + case <-ctx.Done(): + t.Fatal("root never reached the run engine") + } + defer endBeltRun(t, agent, double) + double.mu.Lock() + store := double.spec.Store + double.mu.Unlock() + agent.taskTimer = func(time.Duration) (<-chan time.Time, func()) { return make(chan time.Time), func() {} } + args, err := json.Marshal(taskArguments{Title: "Create green.txt", Summary: "Write green into green.txt", Brief: "Write green into green.txt", Deliverable: "green.txt", Acceptance: "green.txt contains green"}) + if err != nil { + t.Fatal(err) + } + proposal, ok := agent.stageTask(ctx, args).(*stagedProposal) + if !ok { + t.Fatal("joined proposal was not staged") + } + agent.ResolveTask(proposal.id, TaskAnswer{Approved: true}) + if answer, refused, err := proposal.Commit(ctx); err != nil || refused { + t.Fatalf("join refused: %s, %v", answer, err) + } + var joined *plandb.Task + for _, task := range store.Tasks() { + if task.Title == "Create green.txt" { + joined = task + } + } + if joined == nil || joined.ParentID != store.RootID() { + t.Fatalf("proposal did not join the live run: %+v", joined) + } + if _, err := store.AddManyForWorker(joined.ID, []plandb.TaskSpec{{ID: "part", ParentID: joined.ID, Title: "Write green bytes", Description: "Do this part"}}); err != nil { + t.Fatal(err) + } + if _, err := store.AddReviewCheck(plandb.TaskSpec{ID: "check", ParentID: "part", Role: plandb.RoleCheck, Title: "Check green bytes", Description: "Check this part"}); err != nil { + t.Fatal(err) + } + // Read through a second handle so the assignment fact must be stored. + read, err := plandb.Open(store.Path(), "", "", "", "") + if err != nil { + t.Fatal(err) + } + defer read.Close() + for _, id := range []string{joined.ID, "part", "check"} { + doc := BeltWorkerBrief(read, read.Task(id), false, false, "", "") + for _, forbidden := range []string{"ROOT_ONLY_ASSIGNMENT", "PERSON_ONLY_FORBIDDEN", "Create red.txt", "propose_task"} { + if strings.Contains(doc, forbidden) { + t.Errorf("%s reads another assignment %q:\n%s", id, forbidden, doc) + } + } + for _, want := range []string{"Create green.txt", "Write green into green.txt", "green.txt contains green"} { + if !strings.Contains(doc, want) { + t.Errorf("%s lost its joined assignment %q:\n%s", id, want, doc) + } + } + } + if _, err := store.AddManyForWorker(store.RootID(), []plandb.TaskSpec{{ID: "root-part", ParentID: store.RootID(), Title: "Root part"}}); err != nil { + t.Fatal(err) + } + if _, err := store.AddReviewCheck(plandb.TaskSpec{ID: "root-check", ParentID: store.RootID(), Role: plandb.RoleCheck, Title: "Check root work"}); err != nil { + t.Fatal(err) + } + if _, err := store.AddMany([]plandb.TaskSpec{{ID: "root-fix", ParentID: store.RootID(), Title: "Fix root work"}}); err != nil { + t.Fatal(err) + } + for _, id := range []string{"root-part", "root-check", "root-fix"} { + if doc := BeltWorkerBrief(read, read.Task(id), false, false, "", ""); !strings.Contains(doc, "ROOT_ONLY_ASSIGNMENT") { + t.Fatalf("%s lost the root ask:\n%s", id, doc) + } + } + }) + } +} diff --git a/internal/session/task_node_bounds_test.go b/internal/session/task_node_bounds_test.go new file mode 100644 index 0000000000..340a7f2f0f --- /dev/null +++ b/internal/session/task_node_bounds_test.go @@ -0,0 +1,549 @@ +package session + +import ( + "context" + "errors" + "fmt" + "path/filepath" + "strings" + "testing" + "time" + + "github.com/Agent-Field/agentfield/sdk/go/ai" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/plandb" +) + +// C1, C6: proposals compare every relative, including finished work, using the shared normalization. +func TestNodeTreeRefusesDuplicateWorkAnywhere(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "node") + for _, relative := range []string{"ancestor", "sibling", "cousin", "finished"} { + t.Run(relative, func(t *testing.T) { + n := newNest(t, nil, nil) + n.handOut(t, "Read: the LAW!") + first := n.graph.children(n.parent.id)[0] + worker := n.node + if relative == "ancestor" { + worker = n.workerFor(t, first) + first = n.parent + } + if relative == "cousin" { + n.handOut(t, "Inspect the parser") + worker = n.workerFor(t, n.graph.children(n.parent.id)[1]) + firstWorker := n.workerFor(t, first) + n.handOutFrom(t, firstWorker, "Read the rules") + first = n.graph.children(first.id)[0] + } + if relative == "finished" { + n.graph.complete(first, TaskDone) + } + before := len(n.graph.nodes) + title := "read---THE law" + if relative == "ancestor" { + title = "THE---whole JOB" + } + if relative == "cousin" { + title = "read---THE rules" + } + answer, refused, err := worker.proposeTask(t.Context(), pieceArgs(title)) + for _, want := range []string{"no: this run already has a task named", fmt.Sprintf("(%d)", first.id), "nothing was added", "do it in your own hands", "name it for what is different"} { + if err != nil || !refused || !strings.Contains(answer, want) { + t.Fatalf("duplicate refusal missing %q: %q, %v, %v", want, answer, refused, err) + } + } + if len(n.graph.nodes) != before { + t.Fatal("duplicate work was admitted") + } + if answer := n.handOutFrom(t, worker, "Different work"); !strings.Contains(answer, "started") { + t.Fatalf("different work refused: %s", answer) + } + }) + } +} + +// C1, C3: cancelled titles are reusable, and two person-authored roots have independent title scopes. +func TestNodeTreeAllowsCancelledTitlesAndSeparateRoots(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "node") + n := newNest(t, nil, nil) + n.handOut(t, "Read the law") + kid := n.graph.children(n.parent.id)[0] + if _, err := n.graph.stop(kid.id); err != nil { + t.Fatal(err) + } + if answer := n.handOut(t, "read-the LAW"); !strings.Contains(answer, "started") { + t.Fatalf("cancelled title refused: %s", answer) + } + id, _, _, err := n.session.StartTask(t.Context(), n.parent.title(), false) + if err != nil { + t.Fatalf("a person's typed task was refused: %v", err) + } + other := n.workerFor(t, n.graph.node(id)) + if answer := n.handOutFrom(t, other, "Read the law"); !strings.Contains(answer, "started") { + t.Fatalf("a different tree inherited the first tree's titles: %s", answer) + } +} + +// C1, C2: division batches are refused before any part exists; quick tasks use the same door. +func TestNodeTreeRefusesAnEntireDuplicateDivisionAndQuickTask(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "node") + n := newNest(t, nil, nil) + line := &journalDivision{} + ids, _, refusal := n.node.startTheParts(n.parent, []dividePart{{Title: "Read the law"}, {Title: "read---the LAW"}}, line) + if len(ids) != 0 || len(n.graph.children(n.parent.id)) != 0 || !strings.Contains(refusal, "this batch names the same work") || !strings.Contains(refusal, "nothing was added") { + t.Fatalf("division partially admitted or refused for the wrong reason: %v, %s", ids, refusal) + } + n.handOut(t, "Read the law") + ids, _, refusal = n.node.startTheParts(n.parent, []dividePart{{Title: "Different part"}, {Title: "read the LAW"}}, &journalDivision{}) + if len(ids) != 0 || len(n.graph.children(n.parent.id)) != 1 || !strings.Contains(refusal, "already has a task named") { + t.Fatalf("division containing existing work partly started: %v, %s", ids, refusal) + } + id, _, quick := n.node.admitQuick(quickAsk{line: "read---the LAW"}) + if id != 0 || !strings.Contains(quick.said, "already has a task named") || len(n.graph.children(n.parent.id)) != 1 { + t.Fatalf("quick task bypassed duplicate refusal: %d, %+v", id, quick) + } +} + +// C1, C2: staged sibling proposals reserve their titles and withdrawal releases exactly its reservation. +func TestNodeTreeReservesPendingProposalTitles(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "node") + n := newNest(t, nil, nil) + first, ok := n.node.stageTask(t.Context(), pieceArgs("Read the law")).(*stagedProposal) + if !ok { + t.Fatal("first proposal did not stage") + } + other, ok := n.node.stageTask(t.Context(), pieceArgs("Inspect the parser")).(*stagedProposal) + if !ok { + t.Fatal("different proposal did not stage") + } + first.Withdraw() + if answer := n.handOut(t, "READ the law"); !strings.Contains(answer, "started") { + t.Fatalf("withdrawn title stayed reserved: %s", answer) + } + answer, refused, err := n.node.proposeTask(t.Context(), pieceArgs("inspect---the PARSER")) + if err != nil || !refused || !strings.Contains(answer, "this batch names the same work") { + t.Fatalf("another pending title lost its reservation: %q, %v, %v", answer, refused, err) + } + other.Withdraw() +} + +// C3, C4: real runners refuse each tree member's next request at the binding limit; other trees still finish. +func TestNodeTreeSpendStopsEveryNodeAndNamesTheBindingLimit(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "node") + useTaskUsageHome(t) + for _, conversation := range []bool{false, true} { + t.Run(fmt.Sprint(conversation), func(t *testing.T) { + rootEntered, childEntered := make(chan struct{}), make(chan struct{}) + rootHold, releaseRoot := context.WithCancel(t.Context()) + defer releaseRoot() + childHold, releaseChild := context.WithCancel(t.Context()) + defer releaseChild() + completer := &scriptedCompleter{steps: []step{ + func(context.Context, []ai.Message) (*ai.Response, error) { + return nodeTreeReadResponse("root-read", .5), nil + }, + func(ctx context.Context, _ []ai.Message) (*ai.Response, error) { + close(rootEntered) + select { + case <-rootHold.Done(): + return nodeTreeReadResponse("root-read-again", 0), nil + case <-ctx.Done(): + return nil, ctx.Err() + } + }, + func(ctx context.Context, _ []ai.Message) (*ai.Response, error) { + close(childEntered) + select { + case <-childHold.Done(): + cost := 1.0 + if conversation { + cost = .5 + } + return nodeTreeReadResponse("part-read", cost), nil + case <-ctx.Done(): + return nil, ctx.Err() + } + }, + func(context.Context, []ai.Message) (*ai.Response, error) { + return pricedResponse("another tree finished", .125), nil + }, + }} + a, _ := newTestAgent(t, completer, func(c *Config) { + c.ProfileDir = t.TempDir() + writeFile(t, filepath.Join(c.Workspace, "rules.txt"), "Read the rules.\n") + c.TaskParallel = 2 + c.TaskAudit = false + c.Interactive = true + if conversation { + c.Budget = Budget{USD: 1.25} + } + }) + if err := config.SetCrewTaskCap(a.config.ProfileDir, "1.5"); err != nil { + t.Fatal(err) + } + if conversation { + a.usage.CostUSD = .25 + } + g := a.graph() + // Deliver child reports so the root can finish folding its parts. + // Root reports stay quiet to keep conversation wakes off the script. + g.report = func(node *TaskNode) { + if node.parent != 0 { + a.reportTaskNode(node) + } + } + g.store = newTaskStore(filepath.Join(t.TempDir(), "tasks.json")) + root := nodeTreeQuick(t, a, 0, "Whole assignment") + nodeTreeAwait(t, rootEntered) + part := nodeTreeQuick(t, a, root.id, "Read the rules") + nodeTreeAwait(t, childEntered) + queued := nodeTreeQuick(t, a, root.id, "Read the examples") + if queued.stateNow() != TaskQueued { + t.Fatal("third node did not queue behind the two live workers") + } + releaseChild() + waitDoneNode(t, part) + waitDoneNode(t, queued) + if queued.notice().StartedAt.IsZero() { + t.Fatal("queued node did not reach its first request boundary") + } + // Reaching the limit leaves the already admitted provider call alone. + select { + case <-root.done: + t.Fatal("the tree limit cancelled a request in flight") + default: + } + if queued.spend() != 0 || completer.requests() != 3 { + t.Fatalf("queued node bought a request: spend=%v, calls=%d", queued.spend(), completer.requests()) + } + otherID := g.reserve() + g.admit(otherID, taskSpec{title: "Whole assignment", named: true, brief: "Read the rules", acceptance: "The rules are read", depth: 1, model: "test/model"}) + other := g.node(otherID) + waitDoneNode(t, other) + if row := other.notice(); row.State != TaskDone || !strings.Contains(row.Report, "another tree finished") || row.CostUSD != .125 { + t.Fatalf("another tree inherited the exhausted allowance: %+v", row) + } + releaseRoot() + waitDoneNode(t, root) + want := config.CrewTaskCapAction(1.5) + if conversation { + want = "the conversation reached its $1.25 limit · raise the per conversation limit in /settings" + } + for _, node := range []*TaskNode{root, part, queued} { + nodeTreeCostEnding(t, node, want) + if conversation && strings.Contains(node.notice().Report, "/crew") { + t.Fatalf("conversation limit was described as the task cap: %+v", node.notice()) + } + } + if calls := completer.requests(); calls != 4 { + t.Fatalf("exhausted tree reached the provider again: calls=%d, want 4 including the other tree", calls) + } + g.checkpoint() + document := readCheckpoint(t, g.store.path) + recovered := newTaskGraph() + recovered.rehydrate(document, a.config.Workspace, TaskSettleAsk) + for _, node := range []*TaskNode{root, part, queued} { + record := recordOf(t, document, node.id) + if record.Ending != TaskEndingCostLimit || recovered.node(node.id).stoppedByPerson() { + t.Fatalf("cost ending became a person's stop after restore: %+v", record) + } + } + }) + } +} + +// C5: an ordinary node below the ceiling runs through the real runner and finishes normally. +func TestNodeTreeUnderTheCeilingFinishesNormally(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "node") + useTaskUsageHome(t) + a, _ := newTestAgent(t, &scriptedCompleter{steps: []step{func(context.Context, []ai.Message) (*ai.Response, error) { + return pricedResponse("finished", .25), nil + }}}, func(c *Config) { + c.ProfileDir = t.TempDir() + c.TaskAudit = false + }) + g := a.graph() + g.report = nil + id := g.reserve() + g.admit(id, taskSpec{title: "Read the law", named: true, brief: "Read the law", acceptance: "The law is read", depth: 1, model: "test/model"}) + node := g.node(id) + waitDoneNode(t, node) + if row := node.notice(); row.State != TaskDone || !strings.Contains(row.Report, "finished") || row.CostUSD != .25 || row.Ending == TaskEndingCostLimit { + t.Fatalf("under-ceiling node changed: %+v", row) + } +} + +// C4: recovery retains a conversation-bound ceiling and a paid live worker's bill before any owner fold. +func TestNodeTreeCheckpointKeepsTheUnspentAllowance(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "node") + useTaskUsageHome(t) + profile := t.TempDir() + if err := config.SetCrewTaskCap(profile, "5"); err != nil { + t.Fatal(err) + } + entered := make(chan struct{}) + hold, release := context.WithCancel(t.Context()) + defer release() + completer := &scriptedCompleter{steps: []step{ + func(context.Context, []ai.Message) (*ai.Response, error) { + return nodeTreeReadResponse("before-recovery", .75), nil + }, + func(ctx context.Context, _ []ai.Message) (*ai.Response, error) { + close(entered) + select { + case <-hold.Done(): + return pricedResponse("finished after the snapshot", 0), nil + case <-ctx.Done(): + return nil, ctx.Err() + } + }, + }} + options := func(c *Config) { + c.ProfileDir = profile + writeFile(t, filepath.Join(c.Workspace, "rules.txt"), "Read the rules.\n") + c.Interactive = true + c.Budget = Budget{USD: 1.25} + c.TaskAudit = false + } + a, _ := newTestAgent(t, completer, options) + a.usage.CostUSD = .25 + g := a.graph() + g.report = nil + checkpoint := filepath.Join(t.TempDir(), "tasks.json") + g.store = newTaskStore(checkpoint) + id := g.reserve() + g.admit(id, taskSpec{title: "Read the law", named: true, brief: "Read the law", acceptance: "The law is read", depth: 1, model: "test/model"}) + root := g.node(id) + nodeTreeAwait(t, entered) + g.checkpoint() + // Atomic control writes must retain the same unfolded bill as ordinary saves. + for _, action := range []string{"pause", "resume"} { + if err := a.AppTaskAction(AppTaskIntent{ID: fmt.Sprint(id), Action: action}); err != nil { + t.Fatal(err) + } + } + document := readCheckpoint(t, checkpoint) + record := recordOf(t, document, id) + if record.TreeLimit == nil || record.TreeLimit.CeilingUSD != 1 || !record.TreeLimit.Conversation || record.TreeLimit.ConversationCapUSD != 1.25 || record.CostUSD != .75 { + t.Fatalf("checkpoint lost the admitted ceiling or live bill: %+v", record) + } + g.mu.Lock() + folded := root.cost + g.mu.Unlock() + if folded != 0 || a.Usage().CostUSD != .25 { + t.Fatalf("snapshot was taken after folding: node=%v, conversation=%v", folded, a.Usage().CostUSD) + } + // Changed settings and the owner's still-unfolded books cannot grant a new ceiling. + if err := config.SetCrewTaskCap(profile, "10"); err != nil { + t.Fatal(err) + } + resumedCalls := &scriptedCompleter{steps: []step{ + func(context.Context, []ai.Message) (*ai.Response, error) { + return nodeTreeReadResponse("after-recovery", .25), nil + }, + }} + b, _ := newTestAgent(t, resumedCalls, options) + b.usage.CostUSD = .25 + restored := b.graph() + restored.report = nil + restored.rehydrate(document, b.config.Workspace, TaskSettleAsk) + restored.runFrontier() + resumed := restored.node(id) + waitDoneNode(t, resumed) + nodeTreeCostEnding(t, resumed, "the conversation reached its $1.25 limit · raise the per conversation limit in /settings") + if resumed.spend() != 1 || resumedCalls.requests() != 1 { + t.Fatalf("restored tree replenished its allowance: spend=%v, requests=%d", resumed.spend(), resumedCalls.requests()) + } + release() + waitDoneNode(t, root) +} + +// C4: a checkpoint without the additive ceiling fact retains the existing task-cap remainder. +func TestNodeTreeCheckpointWithoutLimitComputesItAtRestore(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "node") + useTaskUsageHome(t) + completer := &scriptedCompleter{steps: []step{ + func(context.Context, []ai.Message) (*ai.Response, error) { + return nodeTreeReadResponse("remaining-work", .25), nil + }, + }} + a, _ := newTestAgent(t, completer, func(c *Config) { + c.ProfileDir = t.TempDir() + writeFile(t, filepath.Join(c.Workspace, "rules.txt"), "Read the rules.\n") + c.TaskAudit = false + }) + if err := config.SetCrewTaskCap(a.config.ProfileDir, "1"); err != nil { + t.Fatal(err) + } + g := a.graph() + g.report = nil + g.rehydrate(taskDocument{Type: taskDocumentType, Version: taskFileVersion, Seq: 1, Nodes: []taskRecord{{ + ID: 1, Title: "Read the law", Brief: "Read the law", Acceptance: "The law is read", + State: TaskQueued, Depth: 1, Model: "test/model", CostUSD: .75, + }}}, a.config.Workspace, TaskSettleAsk) + g.runFrontier() + root := g.node(1) + waitDoneNode(t, root) + nodeTreeCostEnding(t, root, config.CrewTaskCapAction(1)) + if root.spend() != 1 || completer.requests() != 1 { + t.Fatalf("old checkpoint lost its remaining allowance: spend=%v, requests=%d", root.spend(), completer.requests()) + } +} + +// C6: both admission roads make the same decision for Unicode, digits and punctuation. +func TestNodeTreeUsesThePlanStoresWorkIdentity(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "node") + for _, title := range []string{"READ---café 42", "read_café...42", "Read / CAFÉ / 42"} { + t.Run(title, func(t *testing.T) { + store, err := plandb.Open(filepath.Join(t.TempDir(), "plan.db"), "work", "root", "Read: Café 42!", "the work") + if err != nil { + t.Fatal(err) + } + defer store.Close() + if _, err := store.AddManyBounded([]plandb.TaskSpec{{ID: "variant", Title: title}}); err == nil || !strings.Contains(err.Error(), "already has a task named") { + t.Fatalf("plan admission changed its work identity: %v", err) + } + n := newNest(t, nil, nil) + n.handOut(t, "Read: Café 42!") + if answer := n.handOut(t, title); !strings.Contains(answer, "already has a task named") { + t.Fatalf("node admission disagrees with plan admission: %s", answer) + } + }) + } +} + +// C4: a queued worker in an already exhausted conversation is refused at its first request without spending. +func TestNodeTreeWithNoConversationAllowanceSpendsNothing(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "node") + useTaskUsageHome(t) + completer := &scriptedCompleter{} + a, _ := newTestAgent(t, completer, func(c *Config) { + c.ProfileDir = t.TempDir() + c.Interactive = true + c.Budget = Budget{USD: 1} + }) + a.usage.CostUSD = 1 + g := a.graph() + g.report = nil + root := nodeTreeQuick(t, a, 0, "Read the law") + waitDoneNode(t, root) + nodeTreeCostEnding(t, root, "the conversation reached its $1 limit · raise the per conversation limit in /settings") + if root.spend() != 0 || completer.requests() != 0 { + t.Fatalf("an exhausted conversation reached the provider: spend=%v, calls=%d", root.spend(), completer.requests()) + } +} + +// C4: a reached ceiling refuses the next request even while checkpoint writes are blocked. +func TestNodeTreeLimitRefusesBeforeCheckpointWrites(t *testing.T) { + t.Setenv("CODEAF_TASK_BELT", "node") + useTaskUsageHome(t) + entered := make(chan struct{}) + hold, release := context.WithCancel(t.Context()) + defer release() + completer := &scriptedCompleter{steps: []step{ + func(ctx context.Context, _ []ai.Message) (*ai.Response, error) { + close(entered) + select { + case <-hold.Done(): + return nodeTreeReadResponse("last-read", .5), nil + case <-ctx.Done(): + return nil, ctx.Err() + } + }, + }} + a, _ := newTestAgent(t, completer, func(c *Config) { + c.ProfileDir = t.TempDir() + writeFile(t, filepath.Join(c.Workspace, "rules.txt"), "Read the rules.\n") + c.Interactive = true + c.Budget = Budget{USD: .5} + }) + if err := config.SetCrewTaskCap(a.config.ProfileDir, "1.5"); err != nil { + t.Fatal(err) + } + g := a.graph() + g.report = nil + g.store = newTaskStore(filepath.Join(t.TempDir(), "tasks.json")) + root := nodeTreeQuick(t, a, 0, "Read the law") + nodeTreeAwait(t, entered) + events, leave, err := a.WatchTaskRoom(root.id) + if err != nil { + t.Fatal(err) + } + defer leave() + g.store.mu.Lock() + locked := true + defer func() { + if locked { + g.store.mu.Unlock() + } + }() + release() + action := "the conversation reached its $0.50 limit · raise the per conversation limit in /settings" + deadline := time.After(5 * time.Second) + refused := false + for !refused { + select { + case event, open := <-events: + if !open { + t.Fatal("worker ended before publishing its refusal") + } + if event.Kind == EventError { + var stopped ErrSpendStopped + if !errors.As(event.Err, &stopped) || stopped.Action != action { + t.Fatalf("wrong request refusal: %v", event.Err) + } + refused = true + } + case <-deadline: + t.Fatal("the spend boundary waited for checkpoint I/O") + } + } + if root.endingSet() != TaskEndingCostLimit || completer.requests() != 1 { + t.Fatalf("blocked checkpoint postponed the limit: ending=%q, calls=%d", root.endingSet(), completer.requests()) + } + g.store.mu.Unlock() + locked = false + waitDoneNode(t, root) + nodeTreeCostEnding(t, root, action) +} + +func nodeTreeReadResponse(id string, cost float64) *ai.Response { + response := toolResponse(id, "read", `{"path":"rules.txt"}`) + response.Usage.Cost = &cost + return response +} + +func nodeTreeAwait(t *testing.T, entered <-chan struct{}) { + t.Helper() + select { + case <-entered: + case <-time.After(5 * time.Second): + t.Fatal("scripted worker did not reach its request") + } +} + +func nodeTreeQuick(t *testing.T, a *Agent, parent uint64, title string) *TaskNode { + t.Helper() + spec, refusal := a.newQuickSpec(quickAsk{line: title}) + if refusal != "" { + t.Fatal(refusal) + } + g := a.graph() + spec.parent = parent + if parent != 0 { + spec.depth = g.node(parent).depth + 1 + } + if refusal := g.claimChild(parent, title); refusal != "" { + t.Fatal(refusal) + } + id := g.reserve() + g.admit(id, spec) + return g.node(id) +} + +func nodeTreeCostEnding(t *testing.T, node *TaskNode, action string) { + t.Helper() + row := node.notice() + if row.State != TaskFailed || row.Ending != TaskEndingCostLimit || row.Stopped || !strings.Contains(row.Report, action) { + t.Fatalf("wrong cost-limit ending, want %q: %+v", action, row) + } +} diff --git a/internal/session/task_node_spend.go b/internal/session/task_node_spend.go new file mode 100644 index 0000000000..df7cd2ed32 --- /dev/null +++ b/internal/session/task_node_spend.go @@ -0,0 +1,129 @@ +package session + +import ( + "fmt" + "math" + + "github.com/Agent-Field/codeaf/internal/config" +) + +// nodeTreeLimit is fixed at root admission and survives changes to the +// conversation's books or settings while an interrupted tree is restored. +type nodeTreeLimit struct { + CeilingUSD float64 `json:"ceiling_usd"` + Conversation bool `json:"conversation,omitempty"` + ConversationCapUSD float64 `json:"conversation_cap_usd,omitempty"` +} + +// nodeTreeBills snapshots live bills before a checkpoint takes the graph lock. +// Atomic direction and app writes need the same unfolded costs as ordinary saves. +func (g *TaskGraph) nodeTreeBills() map[uint64]float64 { + if bashBeltAsked() { + return nil + } + g.mu.Lock() + nodes := make([]*TaskNode, 0, len(g.nodes)) + for _, node := range g.nodes { + if node != nil { + nodes = append(nodes, node) + } + } + g.mu.Unlock() + bills := make(map[uint64]float64, len(nodes)) + for _, node := range nodes { + bills[node.id] = node.spend() + } + return bills +} + +func (g *TaskGraph) prepareNodeTreeLimits() { + if bashBeltAsked() { + return + } + g.mu.Lock() + var roots []*TaskNode + for _, id := range g.order { + if node := g.nodes[id]; node != nil && node.parent == 0 && node.treeLimit == nil { + roots = append(roots, node) + } + } + g.mu.Unlock() + for _, node := range roots { + g.setNodeTreeLimit(node) + } +} + +func (g *TaskGraph) setNodeTreeLimit(node *TaskNode) { + if bashBeltAsked() || node == nil || node.parent != 0 { + return + } + g.mu.Lock() + home, saved, spent := g.home, node.treeLimit, 0.0 + if saved == nil { + for _, member := range g.nodes { + if member != nil && g.nodeRootLocked(member.id) == node.id { + spent += member.cost + } + } + } + g.mu.Unlock() + if saved != nil || home == nil { + return + } + // Old checkpoints have no ceiling fact. Their existing bills consume the + // task cap, and only the conversation's current remainder is still offered. + taskCap, _ := config.CrewTaskSpendCap(home.config.ProfileDir) + conversationCap := home.railCap(0) + allowance, conversation := taskTreeCost(taskCap, spent, runCostLeft(conversationCap, home.Usage().CostUSD)) + if allowance == math.SmallestNonzeroFloat64 { + allowance = 0 + } + limit := &nodeTreeLimit{CeilingUSD: taskCap, Conversation: conversation} + if conversation { + limit.CeilingUSD = spent + allowance + limit.ConversationCapUSD = conversationCap + } + g.mu.Lock() + if node.treeLimit == nil { + node.treeLimit = limit + } + g.mu.Unlock() +} + +// treeSpendLimit reads the same bills as the per-node boundary. Collect under +// the graph lock, then release it before spend takes that lock for each node. +func (n *TaskNode) treeSpendLimit() string { + if bashBeltAsked() { + return "" + } + g := n.graph + g.mu.Lock() + root := g.nodeRootLocked(n.id) + var limit *nodeTreeLimit + if node := g.nodes[root]; node != nil { + limit = node.treeLimit + } + var nodes []*TaskNode + if limit != nil { + for _, node := range g.nodes { + if node != nil && g.nodeRootLocked(node.id) == root { + nodes = append(nodes, node) + } + } + } + g.mu.Unlock() + if limit == nil { + return "" + } + spent := 0.0 + for _, node := range nodes { + spent += node.spend() + } + if spent < limit.CeilingUSD { + return "" + } + if limit.Conversation { + return fmt.Sprintf("the conversation reached its %s limit · raise the per conversation limit in /settings", config.CrewTaskMoney(limit.ConversationCapUSD)) + } + return config.CrewTaskCapAction(limit.CeilingUSD) +} diff --git a/internal/session/task_node_titles.go b/internal/session/task_node_titles.go new file mode 100644 index 0000000000..9a88752dd6 --- /dev/null +++ b/internal/session/task_node_titles.go @@ -0,0 +1,83 @@ +package session + +import ( + "fmt" + + "github.com/Agent-Field/codeaf/internal/plandb" +) + +// nodeRootLocked follows ownership, not dependency edges: separate assignments +// in one conversation may name the same work. +func (g *TaskGraph) nodeRootLocked(id uint64) uint64 { + for steps := 0; steps <= len(g.nodes); steps++ { + node := g.nodes[id] + if node == nil || node.parent == 0 { + return id + } + id = node.parent + } + return id +} + +func (g *TaskGraph) nodeGrowthRefusalLocked(parent uint64, titles []string) string { + if bashBeltAsked() { + return "" + } + root := g.nodeRootLocked(parent) + named := g.nodeTreeTitlesLocked(root) + batch := make(map[string]bool, len(titles)) + for _, title := range titles { + key := plandb.NormalizedTitle(title) + if node := named[key]; node != nil { + return fmt.Sprintf("no: this run already has a task named %q (%d), so nothing was added. If that work is yours, do it in your own hands; if it is different work, name it for what is different.", node.spec.title, node.id) + } + if batch[key] || g.pendingNodeTitleLocked(root, key) { + return fmt.Sprintf("no: this batch names the same work %q more than once, so nothing was added. Do that work in your own hands, or name each different part for what is different.", title) + } + batch[key] = true + } + return "" +} + +func (g *TaskGraph) nodeTreeTitlesLocked(root uint64) map[string]*TaskNode { + named := make(map[string]*TaskNode) + for _, id := range g.order { + node := g.nodes[id] + // The older road records a cancellation as a stopped node. A + // dollar-limit ending still reserves its unfinished assignment. + if node == nil || node.stopped && node.ending != TaskEndingCostLimit || g.nodeRootLocked(id) != root { + continue + } + named[plandb.NormalizedTitle(node.spec.title)] = node + } + return named +} + +func (g *TaskGraph) pendingNodeTitleLocked(root uint64, key string) bool { + for owner, pending := range g.childTitles { + if g.nodeRootLocked(owner) != root { + continue + } + for _, held := range pending { + if plandb.NormalizedTitle(held) == key { + return true + } + } + } + return false +} + +func (g *TaskGraph) releaseChildTitleLocked(parent uint64, titles []string) { + for _, title := range titles { + pending := g.childTitles[parent] + for i, held := range pending { + if plandb.NormalizedTitle(held) == plandb.NormalizedTitle(title) { + g.childTitles[parent] = append(pending[:i], pending[i+1:]...) + break + } + } + } + if len(g.childTitles[parent]) == 0 { + delete(g.childTitles, parent) + } +} diff --git a/internal/session/task_proposal_scope_test.go b/internal/session/task_proposal_scope_test.go new file mode 100644 index 0000000000..1eed70b36e --- /dev/null +++ b/internal/session/task_proposal_scope_test.go @@ -0,0 +1,49 @@ +package session + +import ( + "path/filepath" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/plandb" +) + +// L-LOOP2 C1a, C6: admission quotes, evidence and journal pointers cannot +// reintroduce the conversation through the root, a child or a check's opening. +func TestProposedRunDocumentsExcludeEveryConversationContextSection(t *testing.T) { + spec := taskSpec{ + title: "Create red.txt", brief: "Write red into red.txt", + deliverable: "red.txt", acceptance: "red.txt contains red", + request: "call propose_task three times: Create red.txt, Create green.txt, Create blue.txt", + origin: taskOrigin{journal: "conversation-original.jsonl", line: 12}, + admission: AdmissionContext{ + Version: AdmissionContextVersion, + Quotes: []AdmissionQuote{{Speaker: admissionPerson, Text: "Create green.txt", Source: "conversation-quotes.jsonl"}}, + Evidence: []AdmissionHandle{{Tool: "bash", Input: "Create blue.txt", Source: "conversation-evidence.jsonl", Result: "conversation-result.txt"}}, + }, + } + description := proposalBrief(spec, nil) + store, err := plandb.Open(filepath.Join(t.TempDir(), planStoreFilename), "the run", "root", spec.title, description) + if err != nil { + t.Fatal(err) + } + defer store.Close() + children, err := store.AddMany([]plandb.TaskSpec{{ID: "child", Title: "Write red bytes", Description: "Write red into red.txt"}, {ID: "check", Title: "Check red bytes", Description: "Read red.txt", Role: plandb.RoleCheck}}) + if err != nil { + t.Fatal(err) + } + documents := []string{description, store.Task("root").Description} + for _, task := range append(children, store.Task("root")) { + documents = append(documents, BeltWorkerBrief(store, task, task.ID == "root", true, "Children finished their assigned work.", "MEMORY\nKeep project edits small.")) + } + for _, doc := range documents { + for _, forbidden := range []string{"propose_task", "three times", "Create green.txt", "Create blue.txt", "conversation-original.jsonl", "conversation-quotes.jsonl", "conversation-evidence.jsonl", "conversation-result.txt"} { + if strings.Contains(doc, forbidden) { + t.Errorf("conversation context %q leaked:\n%s", forbidden, doc) + } + } + if !strings.Contains(doc, "Write red into red.txt") { + t.Errorf("assignment missing:\n%s", doc) + } + } +} diff --git a/internal/session/task_quick.go b/internal/session/task_quick.go index 77291c839b..d4a49562b2 100644 --- a/internal/session/task_quick.go +++ b/internal/session/task_quick.go @@ -501,7 +501,7 @@ func (a *Agent) admitQuick(ask quickAsk) (uint64, taskSpec, quickRefusal) { return 0, taskSpec{}, quickRefusal{said: refusal} } - if refused := graph.claimChild(spec.parent); refused != "" { + if refused := graph.claimChild(spec.parent, spec.title); refused != "" { return 0, taskSpec{}, quickRefusal{said: refused, fanFull: true} } id := graph.reserve() diff --git a/internal/session/task_quick_files.go b/internal/session/task_quick_files.go index b7174a0bb1..fc56495bf8 100644 --- a/internal/session/task_quick_files.go +++ b/internal/session/task_quick_files.go @@ -108,6 +108,9 @@ func (a *Agent) executeTaskTool(ctx context.Context, tool bare.Tool, call ai.Too // readingToolRefusal also guards calls named off the belt. The names come from // the same set that arms the hand-off, so its promise cannot drift from it. func (a *Agent) readingToolRefusal(name string) string { + if category, client := a.clientToolCategory(name); client && category == ActionRead { + return "" + } if !a.config.readingTask() || earlyTools[name] { return "" } diff --git a/internal/session/task_retained_ready_test.go b/internal/session/task_retained_ready_test.go new file mode 100644 index 0000000000..c1f554555a --- /dev/null +++ b/internal/session/task_retained_ready_test.go @@ -0,0 +1,72 @@ +package session + +import ( + "path/filepath" + "testing" + + "github.com/Agent-Field/codeaf/internal/plandb" +) + +// L-LOOP2 C8, C6: only ready work without a live supervisor changes its word, +// including a canonical run row that hides its duplicate plan alias. +func TestRetainedReadyWorkReadsQueuedOnlyAfterItsRunLeaves(t *testing.T) { + path := filepath.Join(t.TempDir(), planStoreFilename) + seedPlanStore(t, path, "chat-a", plandb.TaskSpec{ID: "8", Title: "Ready work"}) + store, err := plandb.Open(path, "", "", "", "") + if err != nil { + t.Fatal(err) + } + defer store.Close() + agent, _ := newTestAgent(t, &scriptedCompleter{}, nil) + t.Cleanup(func() { + agent.beltMu.Lock() + agent.beltRun = nil + agent.beltMu.Unlock() + }) + armPlanStore(t, agent, path, "chat-a") + graph := agent.graph() + graph.mu.Lock() + graph.runs = map[uint64][]TaskNotice{7: { + {ID: 7, PlanTask: store.RootID(), Title: "Run", State: TaskRunning}, + {ID: 8, Parent: 7, PlanTask: "8", Title: "Ready work", State: TaskRunning}, + }} + graph.runRuns = []uint64{7} + graph.mu.Unlock() + for _, state := range []string{"live", "ending", "gone"} { + agent.beltMu.Lock() + agent.beltRun = &beltRun{store: store, ending: state == "ending"} + if state == "gone" { + agent.beltRun = nil + } + agent.beltMu.Unlock() + want, appWant := "queued", "waiting" + if state == "live" { + want, appWant = "running", "running" + } + page, ok := agent.PlanTaskPage("t-8") + if !ok || page.Row.Status != "ready" || page.Row.StateWord() != want { + t.Errorf("%s page = %+v, found=%v, want %s", state, page.Row, ok, want) + } + for _, row := range agent.PlanTasks() { + if row.ID == "t-8" && row.StateWord() != want { + t.Errorf("%s list row = %+v, want %s", state, row, want) + } + } + views, err := agent.AppTasks() + if err != nil { + t.Fatal(err) + } + found := false + for _, view := range views { + if view.ID == "8" { + found = true + if view.State != appWant { + t.Errorf("%s app row = %+v, want %s", state, view, appWant) + } + } + } + if !found { + t.Errorf("%s canonical row disappeared", state) + } + } +} diff --git a/internal/session/task_run.go b/internal/session/task_run.go index 455dc272a9..6482c0a0bd 100644 --- a/internal/session/task_run.go +++ b/internal/session/task_run.go @@ -353,6 +353,8 @@ type TaskNode struct { admit sync.Mutex spec taskSpec + // Only the root holds the tree's fixed admission ceiling. + treeLimit *nodeTreeLimit // kind is what sort of node this is ([TaskKind]), settled at admission from // the spec and never touched again. It is a FIELD rather than a question put // to the spec because a node restored from a checkpoint has no spec worth @@ -1136,6 +1138,8 @@ type TaskGraph struct { // at rest — every claim is released by the admission it authorized or by the // proposal that came to nothing. claims map[uint64]int + // Pending titles stay reserved until their own proposal lands or withdraws. + childTitles map[uint64][]string // run executes one node to completion and calls [TaskGraph.complete] when // it lands. It is a field rather than a method so the graph can be exercised @@ -1459,7 +1463,7 @@ func (g *TaskGraph) admit(id uint64, spec taskSpec) TaskState { refused := g.quitting || g.deleted[spec.parent] if g.deleted[spec.parent] { g.deleted[id] = true - g.releaseChildLocked(spec.parent) + g.releaseChildLocked(spec.parent, spec.title) g.mu.Unlock() return TaskFailed } @@ -1472,8 +1476,9 @@ func (g *TaskGraph) admit(id uint64, spec taskSpec) TaskState { g.order = append(g.order, id) // The node counts itself from here, so the slot its proposal was holding // goes back (see [TaskGraph.claimChild]). - g.releaseChildLocked(spec.parent) + g.releaseChildLocked(spec.parent, spec.title) g.mu.Unlock() + g.setNodeTreeLimit(node) // ADMISSION IS A TRANSITION, and it is checkpointed before the frontier turns // rather than after: a process killed between these two lines still resumes @@ -1504,7 +1509,7 @@ func (g *TaskGraph) admit(id uint64, spec taskSpec) TaskState { // model fanning out emits its propose_task calls in one batch, which is exactly // the moment this cap is for. Every one of them would read the same count and // every one of them would pass. -func (g *TaskGraph) claimChild(parent uint64) string { +func (g *TaskGraph) claimChild(parent uint64, titles ...string) string { if parent == 0 { // The conversation's own work is not fanned out and is not capped: the // person is watching every proposal go by and can stop any of them. @@ -1512,19 +1517,29 @@ func (g *TaskGraph) claimChild(parent uint64) string { } g.mu.Lock() defer g.mu.Unlock() + if refusal := g.nodeGrowthRefusalLocked(parent, titles); refusal != "" { + return refusal + } + count := max(1, len(titles)) held := g.claims[parent] for _, node := range g.nodes { if node != nil && node.parent == parent { held++ } } - if held >= taskFanLimit { + if held+count > taskFanLimit { return fmt.Sprintf("no: you have already handed out %d pieces of this work, which is as many as one task may. Do the rest in your own hands, or finish these and report what is left undone.", taskFanLimit) } if g.claims == nil { g.claims = make(map[uint64]int, 1) } - g.claims[parent]++ + g.claims[parent] += count + if !bashBeltAsked() && len(titles) > 0 { + if g.childTitles == nil { + g.childTitles = make(map[uint64][]string) + } + g.childTitles[parent] = append(g.childTitles[parent], titles...) + } return "" } @@ -1542,17 +1557,18 @@ func (g *TaskGraph) claimChild(parent uint64) string { // never come and the run's ending cancels it first. The pass runs OUTSIDE the // lock: it takes the plan gate before the graph's mu, and nothing may hold // that mu while asking for the gate. -func (g *TaskGraph) releaseChild(parent uint64) { +func (g *TaskGraph) releaseChild(parent uint64, titles ...string) { g.mu.Lock() freed := g.claims[parent] > 0 - g.releaseChildLocked(parent) + g.releaseChildLocked(parent, titles...) g.mu.Unlock() if freed { g.planPulse() } } -func (g *TaskGraph) releaseChildLocked(parent uint64) { +func (g *TaskGraph) releaseChildLocked(parent uint64, titles ...string) { + g.releaseChildTitleLocked(parent, titles) if parent == 0 || g.claims == nil { return } @@ -1601,6 +1617,7 @@ func (g *TaskGraph) children(parent uint64) []*TaskNode { // call when nothing can move: the cost of a pass with nothing to do is one lock // and a walk of the order slice. func (g *TaskGraph) runFrontier() { + g.prepareNodeTreeLimits() // The machine is READ before the lock and at most once a pass: a handful of // small file reads behind a one-second cache, and the graph's lock is held // by everything that announces a node — no reading of /proc belongs under diff --git a/internal/session/task_run_belt.go b/internal/session/task_run_belt.go index ad0438d355..07a08858e4 100644 --- a/internal/session/task_run_belt.go +++ b/internal/session/task_run_belt.go @@ -101,7 +101,7 @@ func programUnattended(program *delegate.Delegate) delegate.Ceilings { // holdProgramCeilings writes onto the run which ceilings it starts under and // whether the conversation's own limit, rather than the program's unattended // one, is the one that will stop it — the words its ending is told in. A run -// of no program keeps none. +// of no program holds its per-task ceiling in taskRunCost instead. // // A PROGRAM WITH NO CEILINGS OF ITS OWN IS STOPPED BY THE CONVERSATION'S, and // its ending says so. Skipping it left the run holding no ceiling and no @@ -960,11 +960,11 @@ func (a *Agent) joinOrWait(ctx context.Context, stand taskStand, id uint64, titl } over := live.over if !live.ending && !live.closing && !live.stopped { - if refusal := taskRunJoinRefusal(ctx, stand, via, live); refusal != nil { + if refusal := taskRunJoinRefusal(ctx, stand, via, live, title); refusal != nil { a.beltMu.Unlock() return nil, refusal } - _, err := live.store.AddMany([]plandb.TaskSpec{{ + _, err := addJoinedTasks(ctx, live.store, []plandb.TaskSpec{{ ID: storeID, ParentID: live.root, Title: title, Description: brief, Dependencies: dependencies, Model: crewWishOf(ctx).worker, }}) if err == nil { @@ -1125,7 +1125,7 @@ func (a *Agent) beltRunSpec(run *beltRun, brief string) RunSpec { wallLeft = time.Nanosecond } } - cost := runCostLeft(a.railCap(0), a.Usage().CostUSD) + cost := a.taskRunCost(run, runCostLeft(a.railCap(0), a.Usage().CostUSD)) if !programUnattended(run.delegate).IsZero() { ceilings := a.programCeilings(run.delegate, a.Usage().CostUSD) cost, wallLeft = ceilings.CostUSD, ceilings.Elapsed() @@ -1658,7 +1658,7 @@ func setAsideRunStore(path string) error { return err } _, _ = endOrphanedProgramRun(existing) - if root := existing.Task(existing.RootID()); root != nil && !terminalStoreStatus(root.Status) { + if root := existing.Task(existing.RootID()); root != nil { if err := existing.EndRoot(taskWordInterrupted); err != nil { _ = existing.Close() return err @@ -2123,7 +2123,7 @@ func (a *Agent) completeBeltRun(ctx context.Context, engine RunEngine, run *belt } cancelRefresh() } - note := beltRunOutcomeNote(run.store, run.root, summary, landing, a.beltRunSpan(run)) + note := run.outcomeNote(summary, landing, a.beltRunSpan(run)) if run.delegate != nil { note = programPageNote(programName(run.delegate), landing) } @@ -2351,7 +2351,7 @@ func (a *Agent) bringBeltRunHome(run *beltRun, landing RunLanding) RunLanding { var beltLandingAdmissionWaits func() func (a *Agent) deliverBeltRunLanding(run *beltRun, summary RunSummary, landing RunLanding) { - line := beltRunOutcomeNote(run.store, run.root, summary, landing, a.beltRunSpan(run)) + line := run.outcomeNote(summary, landing, a.beltRunSpan(run)) if a.beltRunQuiet(run) { a.recordQuietBeltLanding(run, summary, line, landing) return @@ -2628,6 +2628,7 @@ func cancelledByRunEnding(store *plandb.Store, id uint64) bool { // two different endings. func (a *Agent) beltRunNotice(run *beltRun, summary RunSummary, landing RunLanding) TaskNotice { state, report := beltRunResultWords(summary, landing) + report = withReport(report, run.taskLimitWords(summary)) if run.remoteEngine != nil && summary.NeedsDecision { state = TaskUnverified } @@ -2941,7 +2942,7 @@ func beltRunResultWords(summary RunSummary, landing RunLanding) (TaskState, stri } // A remote instruction is frozen, and a program or a different ground cannot join it. -func taskRunJoinRefusal(ctx context.Context, stand taskStand, via *delegate.Delegate, live *beltRun) error { +func taskRunJoinRefusal(ctx context.Context, stand taskStand, via *delegate.Delegate, live *beltRun, title string) error { if live.remoteEngine != nil || taskPlacementOf(ctx).computer != nil { return errRemotePlacementBusy } @@ -2951,5 +2952,10 @@ func taskRunJoinRefusal(ctx context.Context, stand taskStand, via *delegate.Dele if !stand.sharesWorkspace(live) { return standsElsewhereError{underway: live.ground, asked: canonicalPath(stand.dir)} } + if silentTaskStart(ctx) { + if refusal := live.store.GrowthRefusal(live.root, []string{title}); refusal != "" { + return errors.New(refusal) + } + } return nil } diff --git a/internal/session/task_run_belt_test.go b/internal/session/task_run_belt_test.go index 27f5c194a8..5c64fea8c1 100644 --- a/internal/session/task_run_belt_test.go +++ b/internal/session/task_run_belt_test.go @@ -1409,6 +1409,9 @@ func TestPlanReadsWithoutArchiveStayIdentical(t *testing.T) { // The listing says where the run works, once per row, exactly as the // single-store read did before there was an archive to aggregate. row.Folder = agent.config.Workspace + // And the same per-row run facts the listing lays over every row: here + // no run drives the store, so ready work reads as retained, not running. + agent.markPlanMachineHold(&row, path, task.ID == store.RootID()) wantRows = append(wantRows, row) if task.ID == store.RootID() { applyPlanRootProgress(&wantRows[len(wantRows)-1], tasks, store.RootID()) diff --git a/internal/session/task_stability_test.go b/internal/session/task_stability_test.go index b8c22a95ae..1c8268e1c7 100644 --- a/internal/session/task_stability_test.go +++ b/internal/session/task_stability_test.go @@ -50,7 +50,11 @@ func TestALocalTaskKeepsItsRunLandingAndRecords(t *testing.T) { if strings.Contains(spec.Store.Task(spec.Store.RootID()).Description, "\n\nChecks\n") { t.Fatal("a local plan row gained the remote Checks section") } - if result, refused := approveRemoteProposal(t, a, args); refused || !strings.Contains(result, "joined") { + // A countdown-started proposal titled like work already in the run is + // refused (task tree bounds), so the join exercised here is different work. + joinArgs := args + joinArgs.Title, joinArgs.Summary = "Repair import", "Repair the import failure" + if result, refused := approveRemoteProposal(t, a, joinArgs); refused || !strings.Contains(result, "joined") { t.Fatalf("local join changed: %q, refused=%v", result, refused) } close(local.release) diff --git a/internal/session/task_store.go b/internal/session/task_store.go index 2ca74dce61..f78881bc80 100644 --- a/internal/session/task_store.go +++ b/internal/session/task_store.go @@ -431,11 +431,12 @@ type taskRecord struct { // carried a bill, which resumes as zero — the same thing an unpriced model // leaves behind, and the same thing every surface here already draws as // nothing rather than as "$0.00". - CostUSD float64 `json:"costUsd,omitempty"` - Input int `json:"input,omitempty"` - Output int `json:"output,omitempty"` - CacheRead int `json:"cacheRead,omitempty"` - CacheWrite int `json:"cacheWrite,omitempty"` + CostUSD float64 `json:"costUsd,omitempty"` + TreeLimit *nodeTreeLimit `json:"tree_limit,omitempty"` + Input int `json:"input,omitempty"` + Output int `json:"output,omitempty"` + CacheRead int `json:"cacheRead,omitempty"` + CacheWrite int `json:"cacheWrite,omitempty"` // Noted says this node's completion note has been handed to the steering // lane. It is what stops a resumed session re-announcing work the transcript @@ -1122,6 +1123,7 @@ func (g *TaskGraph) admitWritten(take func() directionHeard, drop func(direction } store.mu.Lock() defer store.mu.Unlock() + bills := g.nodeTreeBills() g.mu.Lock() defer g.mu.Unlock() heard := take() @@ -1130,7 +1132,7 @@ func (g *TaskGraph) admitWritten(take func() directionHeard, drop func(direction if !heard.fresh() { return heard, nil } - if err := store.writeLocked(g.documentLocked()); err != nil { + if err := store.writeLocked(g.documentLocked(bills)); err != nil { drop(heard) return heard, err } @@ -1156,31 +1158,38 @@ func (g *TaskGraph) dropWritten(drop func()) error { } store.mu.Lock() defer store.mu.Unlock() + bills := g.nodeTreeBills() g.mu.Lock() defer g.mu.Unlock() drop() - return store.writeLocked(g.documentLocked()) + return store.writeLocked(g.documentLocked(bills)) } // document is the graph as the file sees it, in admission order — the order that // makes the frontier deterministic, and the order a person reads the file in. func (g *TaskGraph) document() taskDocument { + bills := g.nodeTreeBills() g.mu.Lock() defer g.mu.Unlock() - return g.documentLocked() + return g.documentLocked(bills) } // documentLocked is the same reading with the graph's lock already held, for the // admission that publishes and writes under one hold of it // ([TaskGraph.admitWritten]). -func (g *TaskGraph) documentLocked() taskDocument { +func (g *TaskGraph) documentLocked(bills ...map[uint64]float64) taskDocument { document := taskDocument{Type: taskDocumentType, Version: taskFileVersion, Seq: g.seq} for _, id := range g.order { node := g.nodes[id] if node == nil || g.deleted[id] { continue } - document.Nodes = append(document.Nodes, node.recordLocked()) + record := node.recordLocked() + if len(bills) > 0 { + // A fold after the live snapshot may already have raised the frozen bill. + record.CostUSD = max(record.CostUSD, bills[0][id]) + } + document.Nodes = append(document.Nodes, record) } // AND THE ADAPTIVE RUNS' ROWS GO DOWN IN THE SAME DOCUMENT, written by the // same store on the same beats. There is no second writer of tasks.json: a @@ -1444,6 +1453,7 @@ func (n *TaskNode) recordLocked() taskRecord { StartedAt: n.started, EndedAt: n.ended, CostUSD: n.cost, + TreeLimit: n.treeLimit, Input: n.input, Output: n.output, CacheRead: n.cacheRead, @@ -2386,6 +2396,7 @@ func restoreNode(graph *TaskGraph, record taskRecord) *TaskNode { started: record.StartedAt, ended: record.EndedAt, cost: record.CostUSD, + treeLimit: record.TreeLimit, input: record.Input, output: record.Output, cacheRead: record.CacheRead, diff --git a/internal/session/taskcrew.go b/internal/session/taskcrew.go index 2eeae56d59..214d891151 100644 --- a/internal/session/taskcrew.go +++ b/internal/session/taskcrew.go @@ -470,7 +470,8 @@ func (c crewSeatCompleter) completeSeat(ctx context.Context, key string, message } continue } - held, err := crew.guard.before(ctx, current, messages, options) + var reservation spendReservation + held, err := crew.guard.before(ctx, current, messages, options, &reservation) if err != nil { // THE CALL THAT WOULD CROSS THE LINE IS NOT MADE, and the line // says which line it met — never "/redo stronger". @@ -491,7 +492,7 @@ func (c crewSeatCompleter) completeSeat(ctx context.Context, key string, message 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) + crew.guard.after(ctx, current, response, held, &reservation) if err == nil { c.agent.crewAnswered(c.run, current, seat) return response, nil diff --git a/internal/session/taskdelta.go b/internal/session/taskdelta.go index 3e9463177e..4944b166b7 100644 --- a/internal/session/taskdelta.go +++ b/internal/session/taskdelta.go @@ -591,6 +591,18 @@ func (a *Agent) refreshElsewhere(ctx context.Context) { mine := a.sessionID() a.mu.Unlock() + // A conversation scoped to its own work drops an earlier block too, so the + // next reading follows the stored choice without advancing the told stamp. + if a.ownWorkOnly() { + a.mu.Lock() + if !a.closed { + a.elsewhereTold = nil + a.elsewhereText = "" + } + a.mu.Unlock() + return + } + dir := strings.TrimSpace(a.config.Place.Dir) now := time.Now() since := LastTold(dir) diff --git a/internal/session/team.go b/internal/session/team.go index 49418fb9fd..4c9b2ee824 100644 --- a/internal/session/team.go +++ b/internal/session/team.go @@ -488,6 +488,7 @@ func memberOf(team teams.Team, keys []string) (teams.Member, bool) { // and a demotion is rare enough that paying once for it is cheap. A role // gained again appends its verbs at the tail as before. func (a *Agent) teamBoundary() string { + a.clientToolsBoundary() profile := a.config.teamProfile() if profile == "" { return "" diff --git a/internal/session/tools.go b/internal/session/tools.go index 331d052254..01688d8f44 100644 --- a/internal/session/tools.go +++ b/internal/session/tools.go @@ -340,7 +340,7 @@ func (a *Agent) belt() []bare.Tool { // (tools_capabilities.go). It removes no capability and adds none: a machine // with no media models still has no media group, and a shelf with nothing on // it puts no verb on this belt at all. - return a.shelveDeferred(tools) + return a.appendClientTools(a.shelveDeferred(tools)) } // toolDefinitions builds the wire form of the belt, carrying each tool's diff --git a/internal/session/tools_start_chat.go b/internal/session/tools_start_chat.go index 25d1a4053c..63730dfff7 100644 --- a/internal/session/tools_start_chat.go +++ b/internal/session/tools_start_chat.go @@ -26,7 +26,7 @@ func (a *Agent) startChatTools() []bare.Tool { if !a.config.Interactive || a.config.InTask || a.config.HandOverOnAsk || strings.TrimSpace(a.config.Place.Dir) == "" { return nil } - return []bare.Tool{{Name: "start_chat", Description: "Request another independent chat in the attached terminal, keeping this chat in front. Give its complete first message and, when the person names one, its model. The terminal reports whether it opened; this receipt only confirms the request. Use team_start for a teammate, quick_task for a result returned here, or propose_task for checked work in a folder of its own.", Schema: json.RawMessage(`{"type":"object","properties":{"brief":{"type":"string","description":"Complete first message for the new chat."},"model":{"type":"string","description":"Catalog ID or unique model name requested for this chat. Omit to use this chat's model."}},"required":["brief"],"additionalProperties":false}`), Execute: a.startChatTool}} + return []bare.Tool{{Name: "start_chat", Description: "Request another independent chat in the attached terminal, keeping this chat in front. Give its complete first message and, when the person names one, its model. The terminal reports whether it opened; this receipt only confirms the request. Use team_start for a teammate, quick_task for a result returned here, or propose_task for checked work in a folder of its own.", Schema: json.RawMessage(`{"type":"object","properties":{"brief":{"type":"string","description":"Complete first message for the new chat."},"model":{"type":"string","description":"Catalog ID or unique model name requested for this chat. Omit to use this chat's configured model."}},"required":["brief"],"additionalProperties":false}`), Execute: a.startChatTool}} } func (a *Agent) startChatTool(ctx context.Context, args json.RawMessage) (string, bool, error) { @@ -41,7 +41,7 @@ func (a *Agent) startChatTool(ctx context.Context, args json.RawMessage) (string if brief == "" { return rejectToolArguments(ctx, invalidArgumentsPrefix+"brief is empty"), true, nil } - model := a.Model() + model := a.ConfiguredModel() if word := strings.TrimSpace(parsed.Model); word != "" { choice := a.resolveTaskModel(word) if choice.problem != "" { diff --git a/internal/session/tools_tasks.go b/internal/session/tools_tasks.go index fb0820c213..34baf90ea5 100644 --- a/internal/session/tools_tasks.go +++ b/internal/session/tools_tasks.go @@ -375,7 +375,7 @@ func (a *Agent) taskSearchText(query string, limit int, scope string) string { // THIS CONVERSATION'S OWN RUNS ARE LEFT TO THEIR STORE'S LISTING, which the // tool puts above this one ([Agent.withoutOwnRunRows]). out := taskRowsTextLimit(a.withoutOwnRunRows(a.taskRows()), query, limit) - if !a.tellsElsewhere() { + if !a.tellsElsewhere() || a.ownWorkOnly() { return a.taskConversationHint(out) } now := time.Now() @@ -1079,8 +1079,8 @@ func taskWhereWord(entry TaskIndexEntry) string { return taskWhereNowhere } -// taskRows is what this tool answers from: the project's whole index in a -// conversation, and in a node ONLY THE CHILDREN IT HANDED OUT ITSELF. +// taskRows is what this tool answers from: the project's index or only this +// conversation's own rows, and in a node ONLY THE CHILDREN IT HANDED OUT ITSELF. // // The scope is applied here, at the one door, rather than in each of the three // ops: a search, a read, a steer and a resolve are four things to do with a @@ -1088,7 +1088,20 @@ func taskWhereWord(entry TaskIndexEntry) string { func (a *Agent) taskRows() []TaskIndexEntry { parent := a.config.taskID if parent == 0 { - return a.TaskIndex() + rows := a.TaskIndex() + if a.config.InTask || !a.ownWorkOnly() { + return rows + } + a.mu.Lock() + session := a.sessionID() + a.mu.Unlock() + own := make([]TaskIndexEntry, 0, len(rows)) + for _, row := range rows { + if row.SessionID == session { + own = append(own, row) + } + } + return own } a.mu.Lock() session := a.sessionID() @@ -1118,9 +1131,9 @@ func (a *Agent) taskRows() []TaskIndexEntry { // left to settle it in" and pointed at a stranger's worktree — over a node this // session was holding all along. // -// A NAME STILL MEANS THE NEWEST. A slug is derived from a title and carries no -// conversation in it, so "the nil-map task" said out loud means the last one -// whoever ran it, exactly as it always did. +// A NAME STILL MEANS THE NEWEST VISIBLE ROW. A slug is derived from a title +// and carries no conversation in it, so the scope of the rows decides whose +// "nil-map task" a name can reach. // // AND A NODE ASKS FOR NOTHING WIDER THAN ITS OWN FAMILY. Inside a node // [Agent.taskRows] is already only the pieces it handed out, and reaching into diff --git a/internal/session/turn_origin.go b/internal/session/turn_origin.go index 1804318433..98e513e508 100644 --- a/internal/session/turn_origin.go +++ b/internal/session/turn_origin.go @@ -6,26 +6,39 @@ import ( "github.com/Agent-Field/codeaf/internal/teams" ) -// turnOrigin is the two facts that say which turn caused a line of team -// traffic: the run that is writing it and the person's message that OPENED that -// run. It is built once, at the moment a turn starts, and never changed. +// turnOrigin holds the run, the person's message that opened it for team +// traffic, and the message it answers for client tools. It is built once, at +// the moment a turn starts, and never changed. // // IT IS CARRIED ON THE TURN'S CONTEXT AND NEVER READ FROM THE AGENT. A line is // stamped from the context of the call that produced it, or from a copy taken // when a write was queued, so a turn that was abandoned and replaced, a late // callback and a steer that landed mid-turn can never charge their line to the // turn that happens to be current when the write finally runs. The same law as -// [withRunIdentity], one value wider. +// [withRunIdentity], with the message identities beside it. type turnOrigin struct { // run is the turn's run identity, the id its journal records as `usage_run`. run string // turn is the durable id of the person's message that opened the turn, "" // when no person message opened it or none can be named exactly. turn string + // answers is the durable id of the message this turn answers. Resuming an + // answer can name it without changing who opened the run for crew traffic. + answers string } type turnOriginKey struct{} +// turnOriginLocked captures the message being answered before the turn runs. +// A resumed stopped answer reuses its recorded id independently of crew provenance. +func turnOriginLocked(user userMessage, run string) turnOrigin { + origin := turnOrigin{run: run, turn: openingMessage(user), answers: openingMessage(user)} + if user.resumed { + origin.answers = user.sourceMessage + } + return origin +} + // withTurnOrigin hangs origin on ctx for the life of the turn. func withTurnOrigin(ctx context.Context, origin turnOrigin) context.Context { return context.WithValue(ctx, turnOriginKey{}, origin) diff --git a/internal/tui3/app.go b/internal/tui3/app.go index ec75db114d..9bd16e29e0 100644 --- a/internal/tui3/app.go +++ b/internal/tui3/app.go @@ -1967,6 +1967,11 @@ type app struct { // MID-SENTENCE, and dropping the move would leave them on the service they // just replaced, so the one pending id lands at the turn boundary. deferredModelServiceModel string + deferredModelRename *modelRename + // A later explicit choice keeps its wire and preference-write order even + // across a chat switch. Sends wait by agent inside their commands instead. + pendingModelSelection <-chan struct{} + modelCommands []tea.Cmd // harn is the subharness registry (Options.Harnesses) and harnPanel the // list /harness opens over it (harnesspanel.go). A nil harn is a surface // that cannot show harnesses and says so; nothing about the OFFER depends on @@ -3636,7 +3641,7 @@ func (a *app) Init() tea.Cmd { // legend needs before it may name the switcher (hop.go), and it is asked off // the loop for the reason every other reading on this list is: the walk opens // every project's index, and the paint path may never pay for one. - standing := []tea.Cmd{a.resumeAtLaunch, a.probeGit(), a.watchTasks(), a.watchWakes(), a.watchDesigns(), a.watchTitles(), + standing := []tea.Cmd{a.takeModelCommands(), a.resumeAtLaunch, a.probeGit(), a.watchTasks(), a.watchWakes(), a.watchDesigns(), a.watchTitles(), a.watchRuns(), a.watchQuestions(), a.loadTasks(), a.stirLane(), a.askHeld(), a.watchDriving(), a.watchFollowing(), a.linkPingTick(), a.prefetchReplayedPictures(), a.countConversations(), tea.RequestBackgroundColor, // AND WHAT AUTOMATIONS HAVE BEEN DOING, read off their store on a beat of @@ -3831,7 +3836,7 @@ func (a *app) Update(msg tea.Msg) (tea.Model, tea.Cmd) { if summary := a.refreshRunSummary(); summary != nil { cmd = tea.Batch(cmd, summary) } - return model, cmd + return model, tea.Batch(cmd, a.takeModelCommands()) } // route is the message switch: every message this surface handles, handled once. @@ -7472,7 +7477,12 @@ func (a *app) submitShown(text, shown string, plain []segment) tea.Cmd { // counts as an accepted turn, and keeps the update-loop door budget from growing // merely because a conversation can now send while it is behind the screen. func submitStart(agent Agent, ctx context.Context, text string) func() (<-chan session.Event, error) { - return func() (<-chan session.Event, error) { return agent.Submit(ctx, text) } + return func() (<-chan session.Event, error) { + if err := waitModelSelection(ctx, agent); err != nil { + return nil, err + } + return agent.Submit(ctx, text) + } } // submitting is that body with the CALL left to the caller: everything a @@ -7544,7 +7554,11 @@ func (a *app) submittingShown(text, shown string, plain []segment, start func() a.startClock() a.follow() a.touch() + ctx, agent := a.ctx, a.agent return tea.Batch(func() tea.Msg { + if err := waitModelSelection(ctx, agent); err != nil { + return submittedMsg{err: err, echo: mark, call: call} + } ch, err := start() return submittedMsg{ch: ch, err: err, echo: mark, call: call} }, a.wake()) diff --git a/internal/tui3/attach.go b/internal/tui3/attach.go index d1b9f44c49..d7124ac0c3 100644 --- a/internal/tui3/attach.go +++ b/internal/tui3/attach.go @@ -998,6 +998,9 @@ func attachmentStart(agent Agent, ctx context.Context, hosted bool, text, shown } display = shown start = func() (<-chan session.Event, error) { + if err := waitModelSelection(ctx, agent); err != nil { + return nil, err + } images, err := readAttachments(pictures) if err != nil { return nil, err diff --git a/internal/tui3/detach.go b/internal/tui3/detach.go index 12d6aeaa7b..5674f52517 100644 --- a/internal/tui3/detach.go +++ b/internal/tui3/detach.go @@ -63,6 +63,8 @@ type aside struct { // surface yet. It follows the sidecar so returning mid-send cannot send the // same message again when the stream closes. parkSending bool + // A deferred relabel belongs to this conversation across chat switches. + deferredModelRename *modelRename // parkNotes are receipts from held sends and memory commands. They land in // this conversation when it comes forward rather than in whichever unrelated // conversation happened to be on screen at the time. @@ -276,17 +278,18 @@ func (a *app) detachConversation() *aside { // A conversation can be put down while a task's page is in front, and the // box then holds that page's steering line — which is not this // conversation's unsent message and must not come back as one. - draft: a.mainDraftText(), - parks: a.parks, - parkSending: a.parkSending, - chips: main.chips, - pastes: main.pastes, - sends: main.sends, - offset: a.offset, - stick: a.stick, - since: a.now(), - title: a.title, - openingPrompt: a.openingPrompt, + draft: a.mainDraftText(), + parks: a.parks, + parkSending: a.parkSending, + deferredModelRename: a.deferredModelRename, + chips: main.chips, + pastes: main.pastes, + sends: main.sends, + offset: a.offset, + stick: a.stick, + since: a.now(), + title: a.title, + openingPrompt: a.openingPrompt, } // A SHARED HANDLE CANNOT KEEP A WAITING TURN. Its engine interrupts and // closes the old conversation during the swap, so the only honest fallback @@ -463,6 +466,7 @@ func (a *app) clearConversation() { a.forgetComposers() a.parks = nil a.parkSending = false + a.deferredModelRename = nil a.touch() } @@ -532,6 +536,10 @@ func (a *app) attachConversation(conv Conversation, side *aside) tea.Cmd { } was := a.agent a.takeUp(conv, true) + a.deferredModelRename = nil + if side != nil { + a.deferredModelRename, side.deferredModelRename = side.deferredModelRename, nil + } // ANOTHER CONVERSATION'S QUESTIONS DO NOT COME ALONG ([app.forgetQuestions]); // the new lane below replays its own. if a.agent != was { @@ -550,7 +558,6 @@ func (a *app) attachConversation(conv Conversation, side *aside) tea.Cmd { a.resetMeters() if agent != nil { a.model = agent.Model() - a.ensureAvailableModel() a.title = strings.TrimSpace(agent.Title()) // A cached earned name survives an agent whose snapshot is still arriving. if a.title == "" && side != nil { @@ -671,6 +678,7 @@ func (a *app) attachConversation(conv Conversation, side *aside) tea.Cmd { if side != nil { cmds = append(cmds, a.restoreAside(side)) } + a.ensureAvailableModel() // THE IN-FLIGHT TURN'S STREAM IS PUMPED LAST, so the replay above has // already put the person's own message on the screen: the journal holds it // from the moment it was submitted, and the events this stream carries diff --git a/internal/tui3/followup.go b/internal/tui3/followup.go index 9c25a6d4fd..791debdc0f 100644 --- a/internal/tui3/followup.go +++ b/internal/tui3/followup.go @@ -141,7 +141,11 @@ func (a *app) sendFollowFrom(agent Agent, spoken, line string, pastes []pasteChi return nil } call := a.hostCallStarted() + ctx := a.ctx return func() tea.Msg { + if err := waitModelSelection(ctx, agent); err != nil { + return followMsg{text: line, pastes: pastes, demoted: demoted, err: err, call: call} + } ch, err := agent.FollowUp(spoken) return followMsg{text: line, pastes: pastes, demoted: demoted, ch: ch, err: err, call: call} } diff --git a/internal/tui3/homeexchange.go b/internal/tui3/homeexchange.go index 55ed2effa0..a0696fdde1 100644 --- a/internal/tui3/homeexchange.go +++ b/internal/tui3/homeexchange.go @@ -1287,6 +1287,9 @@ func errandSend(ex *homeExchange, text string) tea.Cmd { chips := ex.chips ex.chips = nil return func() tea.Msg { + if err := waitModelSelection(errandContext(), ex.agent); err != nil { + return errandStartedMsg{ex: ex, err: err} + } if len(chips) == 0 { ch, err := ex.agent.Submit(errandContext(), text) return errandStartedMsg{ex: ex, ch: ch, err: err} diff --git a/internal/tui3/hostreplay.go b/internal/tui3/hostreplay.go index 5db80bec5e..1b900958b5 100644 --- a/internal/tui3/hostreplay.go +++ b/internal/tui3/hostreplay.go @@ -79,6 +79,9 @@ func (a *app) finishHostedReplay() tea.Cmd { } } if !a.hostReplayLoading { + if a.deferredModelRename != nil { + a.ensureAvailableModel() + } deferred := a.hostDeferred a.hostDeferred = nil for _, start := range deferred { diff --git a/internal/tui3/model_change_test.go b/internal/tui3/model_change_test.go new file mode 100644 index 0000000000..49fdf2a602 --- /dev/null +++ b/internal/tui3/model_change_test.go @@ -0,0 +1,303 @@ +package tui3 + +import ( + "context" + "errors" + "sync" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/modelsource" + "github.com/Agent-Field/codeaf/internal/session" +) + +type heldModelAgent struct { + *modelForNowAgent + entered chan struct{} + release chan struct{} + once sync.Once + err error + atSubmit chan string +} + +func (a *heldModelAgent) UseModelForNow(id string) error { + a.once.Do(func() { close(a.entered) }) + select { + case <-a.release: + case <-time.After(2 * time.Second): + return errors.New("model door fixture timed out") + } + if a.err != nil { + return a.err + } + return a.modelForNowAgent.UseModelForNow(id) +} + +func (a *heldModelAgent) Submit(ctx context.Context, text string) (<-chan session.Event, error) { + a.atSubmit <- a.Model() + return a.modelForNowAgent.Submit(ctx, text) +} + +func (a *heldModelAgent) FollowUp(text string) (<-chan session.Event, error) { + return a.Submit(context.Background(), text) +} + +func modelOrderingApp(t *testing.T, refused bool) (*app, *heldModelAgent, func()) { + t.Helper() + agent := &heldModelAgent{ + modelForNowAgent: &modelForNowAgent{fakeAgent: &fakeAgent{model: "vendor/configured"}, configured: "vendor/configured"}, + entered: make(chan struct{}), release: make(chan struct{}), atSubmit: make(chan string, 8), + } + if refused { + agent.err = errors.New("connection unavailable") + } + unblock := sync.OnceFunc(func() { close(agent.release) }) + t.Cleanup(unblock) + t.Cleanup(agent.finish) + dir := t.TempDir() + a := newApp(t.Context(), Options{Agent: agent, ProfileDir: dir, + Sources: modelsource.NewSet(testDefaultService("key")), + Models: func() []Model { return []Model{{ID: "vendor/replacement"}, {ID: "vendor/chosen"}} }, + SaveModel: func(id string) error { return config.WriteChatModel(dir, id) }, + }) + a.requireListedModel = true + return a, agent, unblock +} + +func beginOrderedSend(t *testing.T, agent *heldModelAgent, cmd tea.Cmd) <-chan []tea.Msg { + t.Helper() + select { + case <-agent.entered: + case <-time.After(time.Second): + t.Fatal("model door never started") + } + done := make(chan []tea.Msg, 1) + go func() { done <- runCmd(cmd) }() + select { + case model := <-agent.atSubmit: + t.Fatalf("Submit passed the unfinished model door on %q", model) + case <-done: + t.Fatal("send command finished before the model door") + case <-time.After(30 * time.Millisecond): + } + return done +} + +func finishOrderedSend(t *testing.T, a *app, done <-chan []tea.Msg) { + t.Helper() + select { + case msgs := <-done: + drive(t, a, msgs...) + case <-time.After(time.Second): + t.Fatal("send did not resume after the model door") + } +} + +// C1, C4: display changes immediately; only the command waits for selection, +// without requiring its UI acknowledgment. +func TestRunOnlySelectionOrdersTheSendOffTheLoop(t *testing.T) { + a, agent, unblock := modelOrderingApp(t, false) + if !a.ensureAvailableModel() || a.model != "vendor/replacement" { + t.Fatal("selection did not display immediately") + } + a.input.insert("send this draft") + done := beginOrderedSend(t, agent, a.enterLine()) + if a.input.String() != "" || a.state != stateWorking { + t.Fatal("surface held the send") + } + unblock() + finishOrderedSend(t, a, done) + acknowledgeModelChanges(t, a) + if len(agent.sent) != 1 || agent.sent[0] != "send this draft" || agent.submitted[0] != "vendor/replacement" || agent.ConfiguredModel() != "vendor/configured" { + t.Fatalf("sent=%q models=%q", agent.sent, agent.submitted) + } +} + +// C4: a newer catalog can restore the configured model behind a pending +// replacement, and the next send waits for both asks without a UI fold. +func TestCatalogRecoverySupersedesAPendingReplacement(t *testing.T) { + a, agent, unblock := modelOrderingApp(t, false) + a.ensureAvailableModel() + a.models = func() []Model { return []Model{{ID: "vendor/configured"}} } + a.serviceModelsLanded(modelsource.DefaultID, config.DefaultBaseURL) + if a.model != "vendor/configured" { + t.Fatal("recovery did not display immediately") + } + done := beginOrderedSend(t, agent, a.submit("use the recovered model")) + unblock() + finishOrderedSend(t, a, done) + acknowledgeModelChanges(t, a) + if len(agent.sent) != 1 || agent.submitted[0] != "vendor/configured" || len(agent.temporary) != 2 { + t.Fatalf("sent=%q models=%q selections=%q", agent.sent, agent.submitted, agent.temporary) + } +} + +// C4, C6: alternate surface turn doors share the command wait even when +// their start closure does not go through submitStart. +func TestAlternateTurnStartFollowsTheOrderedSelection(t *testing.T) { + a, agent, unblock := modelOrderingApp(t, false) + a.ensureAvailableModel() + cmd := a.submitting("alternate turn", func() (<-chan session.Event, error) { + return agent.Submit(t.Context(), "alternate turn") + }) + done := beginOrderedSend(t, agent, cmd) + unblock() + finishOrderedSend(t, a, done) + acknowledgeModelChanges(t, a) + if len(agent.sent) != 1 || agent.submitted[0] != "vendor/replacement" { + t.Fatal("alternate turn passed the model selection") + } +} + +// C4, C6: a follow-up on an idle engine starts a turn, so its separate call +// must also follow the pending switch rather than overtaking plain sends. +func TestIdleFollowUpFollowsTheOrderedSelection(t *testing.T) { + a, agent, unblock := modelOrderingApp(t, false) + a.ensureAvailableModel() + a.input.insert("start this follow-up") + done := beginOrderedSend(t, agent, a.followUp()) + unblock() + finishOrderedSend(t, a, done) + acknowledgeModelChanges(t, a) + if len(agent.sent) != 1 || agent.sent[0] != "start this follow-up" || agent.submitted[0] != "vendor/replacement" { + t.Fatal("idle follow-up bypassed the ordered model selection") + } +} + +// C6: cancellation bounds a send already waiting for a model door, without +// submitting or canceling the independently ordered model change. +func TestCancelingSendEndsTheModelWait(t *testing.T) { + a, agent, unblock := modelOrderingApp(t, false) + ctx, cancel := context.WithCancel(t.Context()) + defer cancel() + a.ctx = ctx + a.ensureAvailableModel() + done := beginOrderedSend(t, agent, a.submit("canceled while waiting")) + cancel() + var msgs []tea.Msg + select { + case msgs = <-done: + case <-time.After(time.Second): + t.Fatal("send context did not end the model wait") + } + found := false + for _, msg := range msgs { + if submitted, ok := msg.(submittedMsg); ok && errors.Is(submitted.err, context.Canceled) { + found = true + } + } + if !found || len(agent.atSubmit) != 0 { + t.Fatal("canceled wait reached Submit or lost its cancellation") + } + unblock() + drive(t, a, msgs...) + acknowledgeModelChanges(t, a) + if len(agent.sent) != 0 { + t.Fatal("model acknowledgment resurrected a canceled send") + } +} + +// C1, C4: refusal releases one message on the current model; reconciliation retries. +func TestRefusedRunOnlySelectionStillSendsOnce(t *testing.T) { + a, agent, unblock := modelOrderingApp(t, true) + a.ensureAvailableModel() + done := beginOrderedSend(t, agent, a.submit("send despite refusal")) + unblock() + finishOrderedSend(t, a, done) + if len(agent.sent) != 1 || agent.sent[0] != "send despite refusal" || agent.submitted[0] != "vendor/configured" { + t.Fatalf("sent=%q models=%q", agent.sent, agent.submitted) + } + if got := lastNote(t, a); got != "could not change model · connection unavailable" { + t.Fatalf("refusal=%q", got) + } + agent.err = nil + a.settle() + acknowledgeModelChanges(t, a) + if agent.Model() != "vendor/replacement" || len(agent.sent) != 1 { + t.Fatal("retry failed or repeated the send") + } +} + +// C5: the explicit pick after a pending temporary selection owns the next send, +// profile write and immediate receipt. +func TestExplicitPickSupersedesAnUnacknowledgedRunOnlySelection(t *testing.T) { + a, agent, unblock := modelOrderingApp(t, false) + a.useModelForNow("vendor/replacement", 0) + a.switchModel("vendor/chosen", 0) + if a.model != "vendor/chosen" || noteSaying(t, a, "model ·") != "model · vendor/chosen" { + t.Fatal("explicit pick did not display immediately") + } + done := beginOrderedSend(t, agent, a.submit("use the explicit choice")) + unblock() + finishOrderedSend(t, a, done) + acknowledgeModelChanges(t, a) + if agent.ConfiguredModel() != "vendor/chosen" || config.ChatModelAt(a.profileDir) != "vendor/chosen" || len(agent.sent) != 1 || agent.submitted[0] != "vendor/chosen" { + t.Fatal("temporary selection displaced the explicit pick or its send") + } +} + +// C5: picks made after leaving a conversation keep preference writes ordered. +func TestPendingExplicitPickSavesAfterLeavingTheConversation(t *testing.T) { + for _, chooseAgain := range []bool{false, true} { + t.Run(map[bool]string{false: "leave", true: "choose in next chat"}[chooseAgain], func(t *testing.T) { + a, agent, unblock := modelOrderingApp(t, false) + a.useModelForNow("vendor/replacement", 0) + a.switchModel("vendor/chosen", 0) + a.frontGen++ + a.agent, a.model = &fakeAgent{model: "other/model"}, "other/model" + wantSaved, wantLive := "vendor/chosen", "other/model" + if chooseAgain { + a.switchModel("other/chosen", 0) + wantSaved, wantLive = "other/chosen", "other/chosen" + } + unblock() + acknowledgeModelChanges(t, a) + if agent.ConfiguredModel() != "vendor/chosen" || config.ChatModelAt(a.profileDir) != wantSaved || a.model != wantLive { + t.Fatal("leaving lost a choice or reordered preferences") + } + }) + } +} + +type heldLegacyModelAgent struct { + *fakeAgent + release chan struct{} +} + +func (a *heldLegacyModelAgent) SetModel(id string) { + select { + case <-a.release: + case <-time.After(2 * time.Second): + return + } + a.fakeAgent.SetModel(id) +} + +// C6: legacy selection displays immediately, precedes Submit without a UI fold, +// and a canceled send context ends the wait without submitting. +func TestLegacyRunOnlySelectionUsesTheOffLoopDoor(t *testing.T) { + agent := &heldLegacyModelAgent{fakeAgent: &fakeAgent{model: "vendor/configured"}, release: make(chan struct{})} + unblock := sync.OnceFunc(func() { close(agent.release) }) + t.Cleanup(unblock) + a := newApp(t.Context(), Options{Agent: agent, ProfileDir: t.TempDir()}) + a.useModelForNow("vendor/replacement", 0) + if a.model != "vendor/replacement" { + t.Fatal("legacy selection did not display immediately") + } + ctx, cancel := context.WithCancel(t.Context()) + cancel() + if _, err := submitStart(agent, ctx, "canceled send")(); !errors.Is(err, context.Canceled) || len(agent.sent) != 0 { + t.Fatalf("canceled wait submitted: sent=%q err=%v", agent.sent, err) + } + unblock() + if _, err := submitStart(agent, t.Context(), "legacy send")(); err != nil { + t.Fatal(err) + } + acknowledgeModelChanges(t, a) + if agent.Model() != "vendor/replacement" || len(agent.sent) != 1 { + t.Fatal("legacy selection did not precede its send") + } + agent.finish() +} diff --git a/internal/tui3/model_for_now.go b/internal/tui3/model_for_now.go new file mode 100644 index 0000000000..c196f9540f --- /dev/null +++ b/internal/tui3/model_for_now.go @@ -0,0 +1,99 @@ +package tui3 + +import ( + "strings" + + tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/session" +) + +const modelChangeRefusedWord = "could not change model · " + +// configuredModel keeps reconciliation anchored to the person's saved choice. +// Agents predating the run-only door keep the existing live-model behavior. +func (a *app) configuredModel() string { + if door, ok := a.agent.(interface{ ConfiguredModel() string }); ok { + return door.ConfiguredModel() + } + if a.model != "" { + return a.model + } + return a.agent.Model() +} + +func (a *app) useModelForNow(id string, window int) { + a.queueModelChange(id, window, true, true, nil) +} + +func (a *app) takeModelCommands() tea.Cmd { + cmds := a.modelCommands + a.modelCommands = nil + return tea.Batch(cmds...) +} + +// Temporary picks and explicit picks behind them share the ordered door line. +// The display changes immediately; send commands wait for the agent's token, +// which completes with the ask rather than its later UI acknowledgment. +func (a *app) queueModelChange(id string, window int, temporary, quiet bool, rename *modelRename) { + agent := a.agent + save := a.saveModel + remember := "" + if !quiet { + remember = strings.TrimSpace(id) + } + if rename != nil { + remember = rename.save + } + if window <= 0 { + window = a.windowFor(id) + } + selected := &modelSelection{done: make(chan struct{}), model: id, window: window, temporary: temporary && rename == nil} + modelSelections.Store(agent, selected) + a.pendingModelSelection = selected.done + cmd := a.offLoop(func() func(bool) tea.Cmd { + defer func() { + modelSelections.CompareAndDelete(agent, selected) + close(selected.done) + }() + var err error + if rename != nil { + if door, ok := agent.(interface { + SetConversationModels(session.ConversationModels) ([]session.ModelSwitch, error) + }); rename.pair != nil && ok { + _, err = door.SetConversationModels(*rename.pair) + } else if rename.save != "" { + agent.SetModel(rename.save) + } + } + if err == nil { + if door, ok := agent.(interface{ UseModelForNow(string) error }); temporary && ok { + err = door.UseModelForNow(id) + } else if rename == nil { + agent.SetModel(id) + } + } + // Durable choices keep their order even after leaving the conversation. + if err == nil && save != nil && remember != "" { + _ = save(remember) + } + return func(here bool) tea.Cmd { + if err != nil && here && a.agent == agent { + a.note(modelChangeRefusedWord + err.Error()) + } + return nil + } + }) + a.modelCommands = append(a.modelCommands, cmd) + wasSwitching := a.creditSwitching + a.creditSwitching = quiet + a.displayModelSwitched(id, window, false) + a.creditSwitching = wasSwitching + if a.connPanel.open { + a.connPanel.adopt(a.connectionRows()) + } + if a.at(pageSettings) { + a.sheet.build() + } + a.hudStale = true + a.touch() +} diff --git a/internal/tui3/model_for_now_test.go b/internal/tui3/model_for_now_test.go new file mode 100644 index 0000000000..d8fa63e757 --- /dev/null +++ b/internal/tui3/model_for_now_test.go @@ -0,0 +1,286 @@ +package tui3 + +import ( + "bytes" + "context" + "os" + "testing" + + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/credits" + "github.com/Agent-Field/codeaf/internal/modelsource" + "github.com/Agent-Field/codeaf/internal/modelsource/sourcestub" + "github.com/Agent-Field/codeaf/internal/session" +) + +type modelForNowAgent struct { + *fakeAgent + configured string + temporary []string + submitted []string +} + +func (a *modelForNowAgent) ConfiguredModel() string { return a.configured } +func (a *modelForNowAgent) UseModelForNow(model string) error { + a.temporary = append(a.temporary, model) + a.fakeAgent.SetModel(model) + return nil +} +func (a *modelForNowAgent) SetModel(model string) { + a.configured = model + a.fakeAgent.SetModel(model) +} + +func (a *modelForNowAgent) Submit(ctx context.Context, text string) (<-chan session.Event, error) { + a.submitted = append(a.submitted, a.Model()) + return a.fakeAgent.Submit(ctx, text) +} + +func acknowledgeModelChanges(t *testing.T, a *app) { + t.Helper() + spend(t, a, a.takeModelCommands()) +} + +// C2, C4, C5: availability reconciles to the saved model without profile writes. +func TestListedModelReplacementKeepsProfileAndReturnsToConfiguredModel(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + dir := t.TempDir() + profile := []byte(`{ + "model.talk": "vendor/configured", + "models.tiers.reflex": "vendor/reflex", + "models.tiers.low": "", + "models.tiers.worker": "vendor/worker", + "models.tiers.high": "vendor/high", + "models.tiers.mastermind": "vendor/planner", + "models.roles": "title:vendor/title", + "task.model": "vendor/task" +} +`) + if err := os.WriteFile(config.BudgetConfigPath(dir), profile, 0600); err != nil { + t.Fatal(err) + } + rows := []Model{{ID: "vendor/replacement", ContextLength: 32768}} + agent := &modelForNowAgent{fakeAgent: &fakeAgent{model: "vendor/configured"}, configured: "vendor/configured"} + a := newApp(t.Context(), Options{Agent: agent, ProfileDir: dir, Sources: modelsource.NewSet(testDefaultService("key")), RequireListedModel: true, + Models: func() []Model { return rows }, SaveModel: func(id string) error { return config.WriteChatModel(dir, id) }, + }) + acknowledgeModelChanges(t, a) + if a.model != "vendor/replacement" || agent.Model() != a.model || agent.ConfiguredModel() != "vendor/configured" || agent.window != 32768 { + t.Fatalf("replacement=%q/%q configured=%q window=%d", a.model, agent.Model(), agent.ConfiguredModel(), agent.window) + } + rows = append(rows, Model{ID: "vendor/configured", ContextLength: 65536}) + a.state = stateWorking + a.serviceModelsLanded(modelsource.DefaultID, config.DefaultBaseURL) + if a.model != "vendor/replacement" { + t.Fatal("catalog recovery changed an answering request") + } + a.settle() + acknowledgeModelChanges(t, a) + if a.model != "vendor/configured" || agent.Model() != a.model || agent.window != 65536 { + t.Fatalf("recovered=%q/%q window=%d", a.model, agent.Model(), agent.window) + } + got, err := os.ReadFile(config.BudgetConfigPath(dir)) + if err != nil || !bytes.Equal(got, profile) { + t.Fatalf("automatic selection changed profile: %s, %v", got, err) + } + rows = rows[:1] + a.serviceModelsLanded(modelsource.DefaultID, config.DefaultBaseURL) + a.slash("/model vendor/replacement") + acknowledgeModelChanges(t, a) + if agent.ConfiguredModel() != "vendor/replacement" || config.ChatModelAt(dir) != "vendor/replacement" || a.modelWord() != "vendor/replacement" { + t.Fatal("confirming the fallback did not save the explicit choice and HUD") + } + if note := noteSaying(t, a, "model ·"); note != "model · vendor/replacement" { + t.Fatalf("explicit model note=%q", note) + } +} + +// C1, C4: catalog recovery changes the next real, scripted provider request. +func TestCatalogRecoveryUsesConfiguredModelOnTheNextRequest(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + server := sourcestub.New("configured", "replacement") + defer server.Close() + service := testDirectService(server.URL()) + sources := modelsource.NewSet(testDefaultService(""), service) + agent, err := session.New(session.Config{Workspace: t.TempDir(), Model: "deepseek-direct/configured", Sources: sources}) + if err != nil { + t.Fatal(err) + } + defer agent.Close() + rows := []Model{{ID: "replacement"}} + a := newApp(t.Context(), Options{Agent: agent, ProfileDir: t.TempDir(), Sources: sources, RequireListedModel: true, + ModelsForService: func(modelsource.Connected) []Model { return rows }, + }) + acknowledgeModelChanges(t, a) + drainModelServiceTurn(t, agent, "use the available model") + rows = append(rows, Model{ID: "configured"}) + a.serviceModelsLanded(service.Source.ID, service.Address) + acknowledgeModelChanges(t, a) + drainModelServiceTurn(t, agent, "use the recovered model") + requests := completionRequests(server) + if len(requests) != 2 || completionModel(t, requests[0]) != "replacement" || completionModel(t, requests[1]) != "configured" { + t.Fatalf("provider requests=%+v", requests) + } +} + +// C2, C5: setup only saves an actual pick, including the current run-only model. +func TestSetupCanExplicitlyChooseTheRunOnlyModel(t *testing.T) { + dir := t.TempDir() + agent := &modelForNowAgent{fakeAgent: &fakeAgent{model: "vendor/configured"}, configured: "vendor/configured"} + a := newApp(t.Context(), Options{Agent: agent, ProfileDir: dir, SaveModel: func(id string) error { return config.WriteChatModel(dir, id) }}) + a.useModelForNow("vendor/replacement", 0) + acknowledgeModelChanges(t, a) + if config.ChatModelAt(dir) != "" { + t.Fatal("an automatic setup model was saved") + } + a.takeSetupModel("vendor/replacement") + if agent.ConfiguredModel() != "vendor/replacement" || config.ChatModelAt(dir) != "vendor/replacement" { + t.Fatal("setup ignored an explicit pick of the running fallback") + } +} + +// C6: an untouched implicit credit default follows the ordinary saved model +// road, and availability reconciliation preserves it at the submission gate. +func TestCreditDefaultSurvivesListedModelSubmission(t *testing.T) { + for _, low := range []bool{true, false} { + t.Run(map[bool]string{true: "low", false: "recovered"}[low], func(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + initial, want := config.DefaultModel, config.FreeChatModel + if !low { + initial, want = want, initial + } + agent := &modelForNowAgent{fakeAgent: &fakeAgent{model: initial}, configured: initial} + a := newApp(t.Context(), Options{Agent: agent, ProfileDir: t.TempDir(), ImplicitTalk: true, RequireListedModel: true, + Sources: modelsource.NewSet(testDefaultService("credit-test-key")), + Models: func() []Model { return []Model{{ID: config.DefaultModel}, {ID: config.FreeChatModel}} }, + }) + if err := config.SetCrewFreeRoutes(a.profileDir, true); err != nil { + t.Fatal(err) + } + seedLowCredits(t, a) + reading := credits.Reading{Known: true, Low: low} + if err := config.WriteCreditsReading(a.profileDir, "credit-test-key", reading); err != nil { + t.Fatal(err) + } + a.readCredits = func(context.Context) (credits.Reading, error) { return reading, nil } + a.refreshCreditWarnings() + a.input.insert("answer on the credit default") + spend(t, a, a.enterLine()) + if len(agent.submitted) != 1 || agent.submitted[0] != want || agent.ConfiguredModel() != want || config.ChatModelAt(a.profileDir) != "" || len(agent.temporary) != 0 { + t.Fatalf("submitted=%v configured=%q temporary=%v", agent.submitted, agent.ConfiguredModel(), agent.temporary) + } + }) + } +} + +// C6: remote doors leave the engine's model alone even when it is not listed. +func TestRemoteCatalogDoesNotSelectARunOnlyModel(t *testing.T) { + agent := &modelForNowAgent{fakeAgent: &fakeAgent{model: "engine/model"}, configured: "engine/model"} + a := newApp(t.Context(), Options{Agent: agent, ProfileDir: t.TempDir(), Models: func() []Model { return []Model{{ID: "other/model"}} }}) + a.ensureAvailableModel() + if agent.Model() != "engine/model" || len(agent.temporary) != 0 { + t.Fatal("a remote catalog changed the engine selection") + } +} + +// C5, C6; fix round 1 items 1 and 4: connecting or reconnecting under the same +// name is a persistent choice, idle or deferred, even on the running fallback. +func TestServiceConnectionPersistsIdleAndDeferredMoves(t *testing.T) { + for _, testCase := range []struct { + name string + working bool + running bool + }{ + {name: "idle"}, + {name: "deferred", working: true}, + {name: "idle on the fallback", running: true}, + {name: "deferred on the fallback", working: true, running: true}, + } { + t.Run(testCase.name, func(t *testing.T) { + for _, connection := range []string{"connect", "reconnect"} { + t.Run(connection, func(t *testing.T) { + a, dir := newZAIConnectTestApp(t, "") + configured := a.model + want := "z-ai-direct/glm-5.3" + if connection == "reconnect" { + connectZAIFromPanel(t, a, "plan-test-key") + a.switchModel(configured, 0) + } + agent := &modelForNowAgent{fakeAgent: &fakeAgent{model: configured}, configured: configured} + a.agent = agent + a.requireListedModel = true + if err := config.WriteChatModel(dir, configured); err != nil { + t.Fatal(err) + } + if testCase.running { + a.requireListedModel = false + a.useModelForNow(want, 0) + acknowledgeModelChanges(t, a) + a.requireListedModel = true + } + temporary := len(agent.temporary) + opening := a.model + if testCase.working { + a.state = stateWorking + } + if connection == "reconnect" { + a.adoptModelConnectResult(modelConnectResultMsg{ + service: "z-ai", written: "z-ai-direct", renamedFrom: "z-ai-direct", + outcome: modelsource.Outcome{Kind: modelsource.OutcomeConnected}, + models: a.sourceModels["z-ai"], + }) + } else { + connectZAIFromPanel(t, a, "plan-test-key") + } + if testCase.working { + if a.model != opening || agent.ConfiguredModel() != configured || config.ChatModelAt(dir) != configured || a.deferredModelServiceModel != want { + t.Fatal("connection changed the answering turn or lost its deferred choice") + } + a.settle() + } + if a.model != want || agent.Model() != want || agent.ConfiguredModel() != want || config.ChatModelAt(dir) != want || a.deferredModelServiceModel != "" || len(agent.temporary) != temporary { + t.Fatal("connection did not persist its preferred model") + } + }) + } + }) + } +} + +// C1, C2, C5; fix round 1 items 3 and 4: disconnecting keeps the configured +// choice until the person explicitly confirms the running fallback, idle or deferred. +func TestDisconnectMoveStaysTemporaryUntilExplicitlyChosen(t *testing.T) { + for _, working := range []bool{false, true} { + name := "idle" + if working { + name = "deferred" + } + t.Run(name, func(t *testing.T) { + a, dir := newZAIConnectTestApp(t, "sk-default-1234567890") + fallback := a.model + connectZAIFromPanel(t, a, "plan-test-key") + configured := a.model + agent := &modelForNowAgent{fakeAgent: &fakeAgent{model: configured}, configured: configured} + a.agent = agent + a.requireListedModel = true + a.disconnectModelService("z-ai") + acknowledgeModelChanges(t, a) + if a.model != fallback || agent.Model() != fallback || agent.ConfiguredModel() != configured || config.ChatModelAt(dir) != configured || len(agent.temporary) == 0 { + t.Fatal("disconnect did not keep its replacement temporary") + } + if working { + a.state = stateWorking + } + a.moveConversationOrDefer(fallback, "openrouter") + if working { + if agent.ConfiguredModel() != configured || config.ChatModelAt(dir) != configured || a.deferredModelServiceModel != fallback { + t.Fatal("explicit fallback choice did not wait for the turn boundary") + } + a.settle() + } + if a.model != fallback || agent.ConfiguredModel() != fallback || config.ChatModelAt(dir) != fallback || a.deferredModelServiceModel != "" { + t.Fatal("explicit confirmation of the running fallback did not persist") + } + }) + } +} diff --git a/internal/tui3/model_order.go b/internal/tui3/model_order.go new file mode 100644 index 0000000000..e69624e40f --- /dev/null +++ b/internal/tui3/model_order.go @@ -0,0 +1,44 @@ +package tui3 + +import ( + "context" + "sync" +) + +// modelSelections holds only unfinished asks, keyed by their owning agent. +// Commands can start in any order, including after replay or a chat switch. +var modelSelections sync.Map + +type modelSelection struct { + done chan struct{} + model string + window int + temporary bool +} + +func pendingModelSelection(agent Agent) *modelSelection { + if selected, ok := modelSelections.Load(agent); ok { + return selected.(*modelSelection) + } + return nil +} + +// waitModelSelection runs only inside a send command, never on the update loop. +// A refusal still completes the ask, letting the engine answer on its current +// model. A later selection queued during the wait must also precede this send. +func waitModelSelection(ctx context.Context, agent Agent) error { + for { + if err := ctx.Err(); err != nil { + return err + } + selected := pendingModelSelection(agent) + if selected == nil { + return nil + } + select { + case <-selected.done: + case <-ctx.Done(): + return ctx.Err() + } + } +} diff --git a/internal/tui3/model_park_test.go b/internal/tui3/model_park_test.go new file mode 100644 index 0000000000..ed125e89db --- /dev/null +++ b/internal/tui3/model_park_test.go @@ -0,0 +1,202 @@ +package tui3 + +import ( + "context" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/modelsource" + "github.com/Agent-Field/codeaf/internal/session" +) + +type parkedModelAgent struct{ *heldModelAgent } + +func (a *parkedModelAgent) SubmitImage(ctx context.Context, text string, images []session.Image) (<-chan session.Event, error) { + a.atSubmit <- a.Model() + a.submitted = append(a.submitted, a.Model()) + return a.fakeAgent.SubmitImage(ctx, text, images) +} + +// C1, C2, C4: parked text, paste bodies and attachments submit exactly once after +// the model door, without UI acknowledgment or loss of a newer draft tray. +func TestParkedMessageFollowsItsOrderedModelAtStreamClose(t *testing.T) { + for _, attachment := range []string{"text", "file", "picture"} { + t.Run(attachment, func(t *testing.T) { + a, base, unblock := modelOrderingApp(t, false) + agent := &parkedModelAgent{base} + a.agent = agent + a.state = stateWorking + a.pastes = []pasteChip{{n: 1, text: "the complete pasted document"}} + if attachment != "text" { + path := filepath.Join(t.TempDir(), "attachment") + if err := os.WriteFile(path, []byte("attachment bytes"), 0600); err != nil { + t.Fatal(err) + } + a.chips = []chip{{path: path, file: attachment == "file"}} + } + a.park("waiting "+pasteToken(1, 1), []segment{{from: 0, to: 7}}) + waiting := a.parks[0] + a.chips = []chip{{path: "draft attachment", file: true}} + draft := append([]chip(nil), a.chips...) + _, cmd := a.Update(streamClosedMsg{gen: a.gen}) + done := beginOrderedSend(t, base, cmd) + if len(a.parks) != 0 || !reflect.DeepEqual(a.chips, draft) { + t.Fatal("surface held the parked message or spent the newer tray") + } + unblock() + finishOrderedSend(t, a, done) + if len(agent.sent) != 1 || !strings.Contains(agent.sent[0], waiting.spoken()) || len(agent.submitted) != 1 || agent.submitted[0] != "vendor/replacement" { + t.Fatalf("sent=%q models=%q", agent.sent, agent.submitted) + } + if attachment == "picture" && (len(agent.images) != 1 || len(agent.images[0]) != 1 || string(agent.images[0][0].Bytes) != "attachment bytes") { + t.Fatalf("picture=%+v", agent.images) + } + if attachment == "file" && !strings.Contains(agent.sent[0], waiting.chips[0].path) { + t.Fatal("file path was lost") + } + acknowledgeModelChanges(t, a) + if len(agent.sent) != 1 || agent.ConfiguredModel() != "vendor/configured" { + t.Fatal("acknowledgment repeated the send or saved the replacement") + } + }) + } +} + +type replayModelAgent struct{ *heldModelAgent } + +func (a *replayModelAgent) AttachReplay() ([]session.DisplayEntry, <-chan session.Event, func()) { + return nil, nil, func() {} +} + +// C1, C4: a replay-deferred draft survives a catalog change before the replay +// fold and sends once after the switch, including a refused switch. +func TestReplayDeferredSendFollowsItsOrderedModel(t *testing.T) { + for _, refused := range []bool{false, true} { + t.Run(map[bool]string{false: "accepted", true: "refused"}[refused], func(t *testing.T) { + a, base, unblock := modelOrderingApp(t, refused) + a.agent = &replayModelAgent{base} + base.fakeAgent.model, a.model = "vendor/replacement", "vendor/replacement" + answer := a.refreshHostedReplay()() + a.input.insert("keep the replay-deferred words") + if cmd := a.enterLine(); cmd != nil || len(a.hostDeferred) != 1 || a.input.String() != "" { + t.Fatal("send was not deferred at the replay boundary") + } + a.models = func() []Model { return []Model{{ID: "vendor/configured"}} } + a.serviceModelsLanded(modelsource.DefaultID, config.DefaultBaseURL) + _, cmd := a.Update(answer) + done := beginOrderedSend(t, base, cmd) + if len(a.hostDeferred) != 0 { + t.Fatal("replay kept a second copy of its send") + } + unblock() + finishOrderedSend(t, a, done) + want := "vendor/configured" + if refused { + want = "vendor/replacement" + } + if len(base.sent) != 1 || base.sent[0] != "keep the replay-deferred words" || base.submitted[0] != want { + t.Fatalf("sent=%q models=%q", base.sent, base.submitted) + } + acknowledgeModelChanges(t, a) + if len(base.sent) != 1 { + t.Fatal("replay acknowledgment repeated the send") + } + }) + } +} + +type parkedSwitchModelAgent struct{ *heldModelAgent } + +func (a *parkedSwitchModelAgent) Attach() (<-chan session.Event, bool, func()) { + return nil, false, func() {} +} + +// C1, C4, C5: a held chat's pending switch precedes its parked send and never +// changes the unrelated front chat; the keeper consumes the message once. +func TestHeldSendFollowsItsOwnOrderedModel(t *testing.T) { + a, base, unblock := modelOrderingApp(t, false) + agent := &parkedSwitchModelAgent{base} + a.agent = agent + a.file = filepath.Join(t.TempDir(), "held.jsonl") + a.parks = []parked{{text: "the held conversation's message"}} + a.ensureAvailableModel() + conv, side := a.front(), a.detachConversation() + stowed := a.stow(conv, side) + key := a.convKey(conv.SessionFile) + held := a.behind[key] + t.Cleanup(held.watch.stop) + front := &modelForNowAgent{fakeAgent: &fakeAgent{model: "vendor/chosen"}, configured: "vendor/chosen"} + attached := a.attachConversation(Conversation{Agent: front, SessionFile: filepath.Join(t.TempDir(), "front.jsonl")}, nil) + done := beginOrderedSend(t, base, tea.Batch(a.sendBehindParked(key, held), stowed, attached)) + unblock() + finishOrderedSend(t, a, done) + acknowledgeModelChanges(t, a) + if len(base.sent) != 1 || base.sent[0] != "the held conversation's message" || base.submitted[0] != "vendor/replacement" || len(side.parks) != 0 { + t.Fatalf("held send=%q models=%q parks=%+v", base.sent, base.submitted, side.parks) + } + if len(front.sent) != 0 || front.Model() != "vendor/chosen" || a.model != "vendor/chosen" { + t.Fatal("held selection affected the front chat") + } +} + +// C1, C4, C5: returning before the model ask completes restores its immediate +// display and sends once through the same agent token, across front generations. +func TestReturningBeforeModelSelectionKeepsTheOrderedSend(t *testing.T) { + a, base, unblock := modelOrderingApp(t, false) + a.ensureAvailableModel() + conv, side := a.front(), a.detachConversation() + away := a.attachConversation(Conversation{Agent: &fakeAgent{model: "vendor/chosen"}}, nil) + a.detachConversation() + back := a.attachConversation(conv, side) + if a.model != "vendor/replacement" { + t.Fatal("returning lost the pending model display") + } + done := beginOrderedSend(t, base, tea.Batch(away, back, a.submit("send after returning"))) + unblock() + finishOrderedSend(t, a, done) + acknowledgeModelChanges(t, a) + if len(base.sent) != 1 || base.submitted[0] != "vendor/replacement" { + t.Fatal("returning repeated or bypassed the ordered send") + } +} + +// C4, C6: catalog recovery does not reconcile a hidden chat; its next front +// attach restores the configured model before that chat's subsequent request. +func TestHiddenChatReconcilesAvailabilityOnlyWhenItReturns(t *testing.T) { + a, base, unblock := modelOrderingApp(t, false) + agent := &parkedSwitchModelAgent{base} + a.agent = agent + a.file = filepath.Join(t.TempDir(), "held.jsonl") + a.ensureAvailableModel() + unblock() + acknowledgeModelChanges(t, a) + conv, side := a.front(), a.detachConversation() + stowed := a.stow(conv, side) + key := a.convKey(conv.SessionFile) + held := a.behind[key] + t.Cleanup(held.watch.stop) + front := &modelForNowAgent{fakeAgent: &fakeAgent{model: "vendor/chosen"}, configured: "vendor/chosen"} + spend(t, a, tea.Batch(stowed, a.attachConversation(Conversation{Agent: front}, nil))) + a.models = func() []Model { return []Model{{ID: "vendor/configured"}, {ID: "vendor/chosen"}} } + a.serviceModelsLanded(modelsource.DefaultID, config.DefaultBaseURL) + held.side.parks = []parked{{text: "send while hidden"}} + spend(t, a, a.checkBehindParked(key, agent)) + if len(base.sent) != 1 || base.submitted[0] != "vendor/replacement" || len(base.temporary) != 1 { + t.Fatal("hidden catalog arrival reconciled the held conversation") + } + base.finish() + held.watch.stop() + delete(a.behind, key) + a.detachConversation() + spend(t, a, a.attachConversation(conv, side)) + acknowledgeModelChanges(t, a) + spend(t, a, a.submit("send after returning")) + if len(base.sent) != 2 || base.submitted[1] != "vendor/configured" || base.ConfiguredModel() != "vendor/configured" { + t.Fatalf("returning send=%q models=%q", base.sent, base.submitted) + } +} diff --git a/internal/tui3/model_rename.go b/internal/tui3/model_rename.go new file mode 100644 index 0000000000..be44f4c484 --- /dev/null +++ b/internal/tui3/model_rename.go @@ -0,0 +1,99 @@ +package tui3 + +import ( + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/session" +) + +// A label change carries saved and temporary selections independently. A +// working turn keeps both until settlement unless a later choice supersedes it. +type modelRename struct { + owner Agent + configured string + running string + models *session.ConversationModels + save string + live string + pair *session.ConversationModels +} + +func (a *app) reprefixTemporaryModel(oldWritten, newWritten string) bool { + configured, live := a.configuredModel(), a.model + var saved string + var changedPair *session.ConversationModels + var pair *session.ConversationModels + if door, ok := a.agent.(interface{ ModelIdentity() session.ModelIdentity }); ok { + if saved := door.ModelIdentity().Models; saved != nil { + copy := *saved + pair = © + } + } + if deferred := a.deferredModelRename; deferred != nil && !a.modelRenameCurrent(deferred) { + a.deferredModelRename = nil + } + owner, original, running, models := a.agent, configured, a.agent.Model(), pair + if deferred := a.deferredModelRename; deferred != nil { + owner, original, running, models = deferred.owner, deferred.configured, deferred.running, deferred.models + saved, changedPair = deferred.save, deferred.pair + if deferred.save != "" { + configured = deferred.save + } + live = deferred.live + if deferred.pair != nil { + pair = deferred.pair + } + } + if configured == live && pair == nil && a.deferredModelRename == nil { + return false + } + next := config.ReprefixModelID(configured, oldWritten, newWritten) + rename := &modelRename{owner: owner, configured: original, running: running, models: models, save: saved, pair: changedPair, live: config.ReprefixModelID(live, oldWritten, newWritten)} + if next != configured { + rename.save = next + } + if pair != nil { + updated := session.ConversationModels{ + Main: config.ReprefixModelID(pair.Main, oldWritten, newWritten), + Fallback: config.ReprefixModelID(pair.Fallback, oldWritten, newWritten), + } + if updated != *pair { + rename.pair = &updated + } + } + if rename.save == "" && rename.pair == nil && rename.live == live { + return true + } + if a.state == stateWorking { + a.deferredModelRename = rename + } else { + a.applyModelRename(rename) + } + return true +} + +func (a *app) applyModelRename(rename *modelRename) { + if !a.modelRenameCurrent(rename) { + return + } + configured := a.configuredModel() + if rename.save != "" { + configured = rename.save + } + a.queueModelChange(rename.live, 0, rename.live != configured, true, rename) +} + +// An explicit choice made after the label change owns the saved selection, +// including a pair whose main model stayed the same but whose fallback changed. +func (a *app) modelRenameCurrent(rename *modelRename) bool { + if rename.owner != a.agent || rename.configured != a.configuredModel() || rename.running != a.agent.Model() { + return false + } + var pair *session.ConversationModels + if door, ok := a.agent.(interface{ ModelIdentity() session.ModelIdentity }); ok { + pair = door.ModelIdentity().Models + } + if pair == nil || rename.models == nil { + return pair == nil && rename.models == nil + } + return *pair == *rename.models +} diff --git a/internal/tui3/model_rename_owner_test.go b/internal/tui3/model_rename_owner_test.go new file mode 100644 index 0000000000..2958af96d3 --- /dev/null +++ b/internal/tui3/model_rename_owner_test.go @@ -0,0 +1,146 @@ +package tui3 + +import ( + "testing" + + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/modelsource" + "github.com/Agent-Field/codeaf/internal/session" +) + +type replayRenameAgent struct{ *pairedModelForNowAgent } + +func (a *replayRenameAgent) AttachReplay() ([]session.DisplayEntry, <-chan session.Event, func()) { + return nil, nil, func() {} +} + +func deferredRenameApp(t *testing.T, paired bool, hosted ...bool) (*app, *pairedModelForNowAgent) { + t.Helper() + dir := t.TempDir() + customs := connectionWriteSources(t, dir, "mybox") + agent := &pairedModelForNowAgent{ + modelForNowAgent: &modelForNowAgent{fakeAgent: &fakeAgent{model: "mybox/chosen"}, configured: "mybox/chosen"}, + } + var door Agent = agent.modelForNowAgent + if paired { + agent.pair = session.ConversationModels{Main: "mybox/chosen", Fallback: "mybox/backup"} + door = agent + } + if len(hosted) > 0 && hosted[0] { + door = &replayRenameAgent{agent} + } + a := modelServiceTestAppWithAgent(t, dir, agent.configured, + modelsource.NewSet(append([]modelsource.Connected{connectionDefaultService()}, customs...)...), + []Model{{ID: "vendor/replacement"}, {ID: "vendor/other"}, {ID: "vendor/explicit"}}, door) + a.modelsForService = func(modelsource.Connected) []Model { return []Model{{ID: "replacement"}} } + if err := config.WriteChatModel(dir, agent.configured); err != nil { + t.Fatal(err) + } + a.useModelForNow("vendor/replacement", 0) + acknowledgeModelChanges(t, a) + a.requireListedModel = true + a.state = stateWorking + rows := config.PersistedSources(dir) + rows[0].Written = "lab" + if err := config.WriteSources(dir, rows); err != nil { + t.Fatal(err) + } + a.adoptModelConnectResult(modelConnectResultMsg{service: customs[0].Source.ID, written: "lab", renamedFrom: "mybox", + outcome: modelsource.Outcome{Kind: modelsource.OutcomeConnected}, models: []Model{{ID: "replacement"}}, + }) + if a.deferredModelRename == nil || agent.ConfiguredModel() != "mybox/chosen" { + t.Fatal("rename did not wait for its answering conversation") + } + return a, agent +} + +// C1, C2, C4, C5: switching chats before settlement keeps the rename with its +// owner. The unrelated chat settles without adopting either captured model. +func TestDeferredRenameStaysWithItsConversationAcrossASwitch(t *testing.T) { + for _, paired := range []bool{false, true} { + t.Run(map[bool]string{false: "single", true: "pair"}[paired], func(t *testing.T) { + a, original := deferredRenameApp(t, paired) + a.park("waiting for the renamed conversation", nil) + conv, side := a.front(), a.detachConversation() + other := &modelForNowAgent{fakeAgent: &fakeAgent{model: "vendor/other"}, configured: "vendor/other"} + spend(t, a, a.attachConversation(Conversation{Agent: other}, nil)) + a.switchModel("vendor/other", 0) + a.state = stateWorking + drive(t, a, streamClosedMsg{gen: a.gen}) + if other.ConfiguredModel() != "vendor/other" || other.Model() != "vendor/other" || config.ChatModelAt(a.profileDir) != "vendor/other" || len(other.temporary) != 0 { + t.Fatal("another conversation's settlement adopted the deferred rename") + } + if original.ConfiguredModel() != "mybox/chosen" { + t.Fatal("the original conversation changed before returning to it") + } + a.detachConversation() + spend(t, a, a.attachConversation(conv, side)) + acknowledgeModelChanges(t, a) + spend(t, a, a.sendParked()) + if original.ConfiguredModel() != "lab/chosen" || original.Model() != "vendor/replacement" || other.ConfiguredModel() != "vendor/other" || config.ChatModelAt(a.profileDir) == "vendor/replacement" { + t.Fatal("the owning conversation lost its rename or saved its replacement") + } + if paired && original.pair != (session.ConversationModels{Main: "lab/chosen", Fallback: "lab/backup"}) { + t.Fatalf("the owner's renamed pair=%+v", original.pair) + } + if len(original.sent) != 1 || original.sent[0] != "waiting for the renamed conversation" || len(a.parks) != 0 { + t.Fatal("the owner's parked send did not resume after the rename acknowledgment") + } + original.finish() + }) + } +} + +// C1, C2, C5: the owning chat's hosted replay boundary applies its deferred +// rename even when no send is waiting; the other chat remains untouched. +func TestDeferredRenameAppliesAtItsOwningReplayBoundary(t *testing.T) { + a, original := deferredRenameApp(t, true, true) + conv, side := a.front(), a.detachConversation() + other := &modelForNowAgent{fakeAgent: &fakeAgent{model: "vendor/other"}, configured: "vendor/other"} + spend(t, a, a.attachConversation(Conversation{Agent: other}, nil)) + a.detachConversation() + spend(t, a, a.attachConversation(conv, side)) + acknowledgeModelChanges(t, a) + if original.ConfiguredModel() != "lab/chosen" || original.Model() != "vendor/replacement" || original.pair != (session.ConversationModels{Main: "lab/chosen", Fallback: "lab/backup"}) || a.deferredModelRename != nil { + t.Fatal("the owning replay did not apply its deferred rename") + } + if other.ConfiguredModel() != "vendor/other" || config.ChatModelAt(a.profileDir) == "vendor/replacement" || len(original.sent) != 0 || len(other.sent) != 0 { + t.Fatal("the replay rename changed another chat, saved the replacement or invented a send") + } +} + +// C2, C5: a later explicit single or pair choice retires the captured rename, +// including a pair whose main model is unchanged and only its fallback changes. +func TestLaterExplicitChoiceRetiresTheDeferredRename(t *testing.T) { + for _, pick := range []string{"single", "pair", "pair fallback", "same pair"} { + t.Run(pick, func(t *testing.T) { + a, agent := deferredRenameApp(t, pick != "single") + want := "vendor/explicit" + var pair session.ConversationModels + if pick == "single" { + a.slash("/model " + want) + } else { + pair = session.ConversationModels{Main: want, Fallback: "vendor/backup"} + if pick == "pair fallback" { + want, pair.Main = "mybox/chosen", "mybox/chosen" + } + if pick == "same pair" { + pair = agent.pair + want = pair.Main + } + if _, err := agent.SetConversationModels(pair); err != nil { + t.Fatal(err) + } + a.modelSwitched(want, 0, true) + } + acknowledgeModelChanges(t, a) + drive(t, a, streamClosedMsg{gen: a.gen}) + if agent.ConfiguredModel() != want || config.ChatModelAt(a.profileDir) != want || a.deferredModelRename != nil { + t.Fatalf("settlement replaced the explicit choice: configured=%q saved=%q", agent.ConfiguredModel(), config.ChatModelAt(a.profileDir)) + } + if pick != "single" && agent.pair != pair { + t.Fatalf("settlement replaced the explicit pair: %+v", agent.pair) + } + }) + } +} diff --git a/internal/tui3/model_rename_test.go b/internal/tui3/model_rename_test.go new file mode 100644 index 0000000000..cf12057de7 --- /dev/null +++ b/internal/tui3/model_rename_test.go @@ -0,0 +1,95 @@ +package tui3 + +import ( + "testing" + + "github.com/Agent-Field/codeaf/internal/config" + "github.com/Agent-Field/codeaf/internal/modelsource" + "github.com/Agent-Field/codeaf/internal/session" +) + +type pairedModelForNowAgent struct { + *modelForNowAgent + pair session.ConversationModels +} + +func (a *pairedModelForNowAgent) ModelIdentity() session.ModelIdentity { + pair := a.pair + return session.ModelIdentity{Selected: a.configured, Models: &pair} +} + +func (a *pairedModelForNowAgent) SetConversationModels(pair session.ConversationModels) ([]session.ModelSwitch, error) { + a.pair = pair + a.SetModel(pair.Main) + return nil, nil +} + +// C1, C2, C4: idle and deferred service renames carry the configured and +// temporary models separately. The configured model returns when listed. +func TestServiceRenamePreservesConfiguredAndRunOnlySelections(t *testing.T) { + for _, working := range []bool{false, true} { + for _, configuredService := range []bool{false, true} { + for _, paired := range []bool{false, true} { + name := map[bool]string{false: "idle", true: "deferred"}[working] + "/" + map[bool]string{false: "replacement service", true: "configured service"}[configuredService] + "/" + map[bool]string{false: "single", true: "pair"}[paired] + t.Run(name, func(t *testing.T) { + dir := t.TempDir() + customs := connectionWriteSources(t, dir, "mybox") + configured, live := "vendor/configured", "mybox/replacement" + wantConfigured, wantLive := configured, "lab/replacement" + var cloud []Model + if configuredService { + configured, live = "mybox/chosen", "vendor/replacement" + wantConfigured, wantLive = "lab/chosen", live + cloud = []Model{{ID: live}} + } + base := &modelForNowAgent{fakeAgent: &fakeAgent{model: configured}, configured: configured} + var agent Agent = base + var pairAgent *pairedModelForNowAgent + if paired { + pairAgent = &pairedModelForNowAgent{modelForNowAgent: base, pair: session.ConversationModels{Main: configured, Fallback: "mybox/backup"}} + agent = pairAgent + } + a := modelServiceTestAppWithAgent(t, dir, configured, modelsource.NewSet(append([]modelsource.Connected{connectionDefaultService()}, customs...)...), cloud, agent) + a.modelsForService = func(modelsource.Connected) []Model { return []Model{{ID: "replacement"}} } + if err := config.WriteChatModel(dir, configured); err != nil { + t.Fatal(err) + } + a.useModelForNow(live, 0) + acknowledgeModelChanges(t, a) + a.requireListedModel = true + if working { + a.state = stateWorking + } + rows := config.PersistedSources(dir) + rows[0].Written = "lab" + if err := config.WriteSources(dir, rows); err != nil { + t.Fatal(err) + } + a.adoptModelConnectResult(modelConnectResultMsg{service: customs[0].Source.ID, written: "lab", renamedFrom: "mybox", outcome: modelsource.Outcome{Kind: modelsource.OutcomeConnected}, models: []Model{{ID: "replacement"}}}) + if working { + if a.model != live || base.ConfiguredModel() != configured { + t.Fatal("rename changed the answering turn") + } + a.settle() + } + acknowledgeModelChanges(t, a) + if a.model != wantLive || base.Model() != wantLive || base.ConfiguredModel() != wantConfigured || config.ChatModelAt(dir) != wantConfigured { + t.Fatalf("live=%q/%q configured=%q saved=%q", a.model, base.Model(), base.ConfiguredModel(), config.ChatModelAt(dir)) + } + if paired && (pairAgent.pair.Main != wantConfigured || pairAgent.pair.Fallback != "lab/backup") { + t.Fatalf("renamed saved pair=%+v", pairAgent.pair) + } + if configuredService { + a.modelsForService = func(modelsource.Connected) []Model { return []Model{{ID: "replacement"}, {ID: "chosen"}} } + delete(a.sourceModels, customs[0].Source.ID) + a.serviceModelsLanded(customs[0].Source.ID, customs[0].Address) + acknowledgeModelChanges(t, a) + if a.model != wantConfigured || base.Model() != wantConfigured || config.ChatModelAt(dir) != wantConfigured { + t.Fatal("the renamed configured model did not return when listed") + } + } + }) + } + } + } +} diff --git a/internal/tui3/modeldefault.go b/internal/tui3/modeldefault.go index 7905a60ce0..37238ceaa2 100644 --- a/internal/tui3/modeldefault.go +++ b/internal/tui3/modeldefault.go @@ -37,11 +37,29 @@ func (a *app) isAvailableModel(id string) bool { // ensureAvailableModel reconciles a local conversation with its picker catalog. // A launch preference is not evidence of availability. An automatic choice uses -// the ordinary model-change road without saving a preference the person did not +// the off-loop run-only door without saving a preference the person did not // choose. With no rows, only the display is cleared: the engine has no unset-model -// door, and the send gates below keep that old launch preference from being used. +// door, and the empty-catalog checks keep that old preference from being used. func (a *app) ensureAvailableModel() bool { - if !a.requireListedModel || a.agent == nil { + if rename := a.deferredModelRename; rename != nil { + // Relabel only at the owning conversation's turn/replay boundary. + if a.state != stateWorking && !a.hostReplayLoading { + a.deferredModelRename = nil + a.applyModelRename(rename) + } + } + if a.agent == nil { + return true + } + pending := pendingModelSelection(a.agent) + if pending != nil { + // Returning before the ask completes still shows the chosen model. + a.model = pending.model + if pending.window > 0 { + a.ctxWindow = pending.window + } + } + if !a.requireListedModel { return true } // An answering request keeps its model until it settles. The turn-end path @@ -49,22 +67,21 @@ func (a *app) ensureAvailableModel() bool { if a.state == stateWorking { return a.model != "" } - preferred := a.model - if preferred == "" { - preferred = a.agent.Model() + // A later explicit pick or relabel owns its place on the line. Automatic + // selections may follow a newer catalog while an earlier ask is unfinished. + if pending != nil && !pending.temporary { + return a.model != "" } + preferred := a.configuredModel() model, ok := availableConversationModel(preferred, a.modelList()) if !ok { a.model, a.ctxWindow = "", 0 a.hudStale = true return false } - if model.ID != a.model { - wasSwitching := a.creditSwitching - a.creditSwitching = true - a.switchModel(model.ID, model.ContextLength) - a.creditSwitching = wasSwitching + if model.ID != a.model || (pending == nil && model.ID != a.agent.Model()) { + a.useModelForNow(model.ID, model.ContextLength) a.hudStale = true } - return true + return a.model == model.ID } diff --git a/internal/tui3/modeldefault_test.go b/internal/tui3/modeldefault_test.go index 22b9fbf800..06a315500d 100644 --- a/internal/tui3/modeldefault_test.go +++ b/internal/tui3/modeldefault_test.go @@ -43,6 +43,7 @@ func TestTheOpeningModelAlwaysBelongsToItsAvailablePicker(t *testing.T) { ModelsForService: func(modelsource.Connected) []Model { return test.installed }, SaveModel: func(id string) error { saved = id; return nil }, }) + acknowledgeModelChanges(t, a) if a.model != test.want { t.Fatalf("opening model = %q, want %q", a.model, test.want) } @@ -67,6 +68,7 @@ func TestAColdCatalogHoldsTheDraftThenSuppliesTheEngineDefault(t *testing.T) { } rows = []Model{{ID: "vendor/real", ContextLength: 32768}} a.serviceModelsLanded(modelsource.DefaultID, config.DefaultBaseURL) + acknowledgeModelChanges(t, a) if a.model != "vendor/real" || agent.Model() != a.model || agent.window != 32768 || a.input.String() != "keep this draft" { t.Fatalf("landed default = %q/%q window=%d draft=%q", a.model, agent.Model(), agent.window, a.input.String()) } @@ -88,6 +90,7 @@ func TestRemovingTheDefaultModelChoosesAnotherListedModel(t *testing.T) { a := newApp(t.Context(), Options{Agent: agent, ProfileDir: t.TempDir(), Sources: modelsource.NewSet(testDefaultService("key")), RequireListedModel: true, Models: func() []Model { return rows }}) rows = []Model{{ID: "vendor/replacement"}} a.serviceModelsLanded(modelsource.DefaultID, config.DefaultBaseURL) + acknowledgeModelChanges(t, a) if a.model != "vendor/replacement" || agent.Model() != a.model { t.Fatalf("replacement = %q/%q", a.model, agent.Model()) } @@ -144,6 +147,7 @@ func TestAnAvailableOpeningModelIsTheModelSentToItsRealProvider(t *testing.T) { if a.model != "deepseek-direct/actually-listed" { t.Fatalf("default = %q", a.model) } + acknowledgeModelChanges(t, a) drainModelServiceTurn(t, agent, "answer on the available default") requests := completionRequests(server) if len(requests) != 1 || completionModel(t, requests[0]) != "actually-listed" { @@ -163,6 +167,7 @@ func TestARefreshDefersDefaultReplacementUntilTheAnswerSettles(t *testing.T) { t.Fatal("the catalog change interrupted an answering request") } a.settle() + acknowledgeModelChanges(t, a) if a.model != "vendor/replacement" || agent.Model() != a.model { t.Fatalf("settled default = %q/%q", a.model, agent.Model()) } diff --git a/internal/tui3/modelservices.go b/internal/tui3/modelservices.go index 182d5274a4..e6c3318392 100644 --- a/internal/tui3/modelservices.go +++ b/internal/tui3/modelservices.go @@ -950,7 +950,7 @@ func (a *app) adoptModelConnectResult(msg modelConnectResultMsg) { a.deferredModelServiceModel = "" } preferred := connected.Source.PreferredModel(connected.Door, listedModelIDs(a.sourceModels[msg.service])) - if next := connected.Qualify(preferred); next != "" && next != strings.TrimSpace(a.model) { + if next := connected.Qualify(preferred); next != "" && (next != strings.TrimSpace(a.model) || next != a.configuredModel()) { if a.state == stateWorking { a.deferredModelServiceModel = next } else { @@ -1012,6 +1012,9 @@ func (a *app) reprefixRenamedModel(oldWritten, newWritten string) string { if modelUsesService(a.deferredModelServiceModel, oldWritten) { a.deferredModelServiceModel = config.ReprefixModelID(a.deferredModelServiceModel, oldWritten, newWritten) } + if a.reprefixTemporaryModel(oldWritten, newWritten) { + return "" + } if !modelUsesService(a.model, oldWritten) { return "" } @@ -1112,7 +1115,7 @@ func listedModelIDs(models []Model) []string { // automatic moves cannot drift into two accounts of what happened. func (a *app) moveConversationToConnectedModel(next string) { next = strings.TrimSpace(next) - if next == "" || next == strings.TrimSpace(a.model) { + if next == "" || next == strings.TrimSpace(a.model) && next == a.configuredModel() { return } was := a.model @@ -1127,6 +1130,10 @@ func (a *app) moveConversationToConnectedModel(next string) { // answering turn has settled. Clearing it first makes the second settle event // free and prevents a failed later path from replaying an old connection. func (a *app) applyDeferredModelServiceMove() { + if rename := a.deferredModelRename; rename != nil { + a.deferredModelRename = nil + a.applyModelRename(rename) + } next := a.deferredModelServiceModel a.deferredModelServiceModel = "" was := a.model @@ -1172,7 +1179,7 @@ func (a *app) moveConversationOrDefer(id, written string) { // Routing folds case, so an id differing from the running model only in // case is the same pick — the deferred guard above already compares with // EqualFold, and the idle road does too. - if !strings.EqualFold(id, strings.TrimSpace(a.model)) { + if !strings.EqualFold(id, strings.TrimSpace(a.model)) || !strings.EqualFold(id, a.configuredModel()) { a.moveConversationToConnectedModel(id) } } @@ -1303,7 +1310,7 @@ func (a *app) disconnectModelService(id string) { if !conversationUsed { // The disconnect acknowledgement is the whole answer. } else if next, ok := a.reachableModelAfterDisconnect(); ok { - a.switchModel(next, 0) + a.useModelForNow(next, 0) a.modelServiceFollowup(serviceMovedWord(was, next)) } else { a.modelServiceFollowup(serviceStrandedWord(was)) diff --git a/internal/tui3/modelservices_test.go b/internal/tui3/modelservices_test.go index cd63ecc0b2..2bcec3bc9f 100644 --- a/internal/tui3/modelservices_test.go +++ b/internal/tui3/modelservices_test.go @@ -1365,6 +1365,7 @@ func TestDisconnectingAServiceLeavesTheConversationOnSomethingItCanReach(t *test a := modelServiceTestAppWithAgent(t, dir, agent.Model(), sources, []Model{{ID: config.DefaultModel}}, agent) a.disconnectModelService("deepseek") + acknowledgeModelChanges(t, a) if got := noteSaying(t, a, "is disconnected"); got != "deepseek-direct is disconnected · its models are gone from the picker" { t.Fatalf("disconnect acknowledgement = %q", got) } @@ -1673,6 +1674,7 @@ func TestDisconnectingReplacesTheLiveClientBeforeTheNextRequest(t *testing.T) { drainModelServiceTurn(t, agent, "before disconnect") a := modelServiceTestAppWithAgent(t, dir, agent.Model(), sources, []Model{{ID: config.DefaultModel}}, agent) a.disconnectModelService("deepseek") + acknowledgeModelChanges(t, a) drainModelServiceTurn(t, agent, "after disconnect") if got := completionRequests(directServer); len(got) != 1 || got[0].Bearer != "Bearer sk-direct-1234567890" { t.Fatalf("removed service received another request or wrong bearer: %+v", got) diff --git a/internal/tui3/onboarding.go b/internal/tui3/onboarding.go index 2d6ce93c74..c7e440a3a0 100644 --- a/internal/tui3/onboarding.go +++ b/internal/tui3/onboarding.go @@ -591,7 +591,7 @@ func (a *app) commitSetupControls() bool { func (a *app) takeSetupModel(id string) { s := &a.setup id = strings.TrimSpace(id) - if id == "" || id == a.model { + if id == "" || id == a.model && id == a.configuredModel() { return } row, ok := a.registry().Row(config.ModelSettingKey(talkSlot)) diff --git a/internal/tui3/palette.go b/internal/tui3/palette.go index 8d80f9468a..e8967e69b3 100644 --- a/internal/tui3/palette.go +++ b/internal/tui3/palette.go @@ -3106,11 +3106,29 @@ func (a *app) windowFor(id string) int { // fires at a fraction of it, so a session that switched to a 1M model without // saying so would keep compacting as if it were on the 128k one it started on. func (a *app) switchModel(id string, window int) { + a.deferredModelRename = nil + if a.pendingModelSelection != nil { + select { + case <-a.pendingModelSelection: + default: + a.queueModelChange(id, window, false, a.creditSwitching, nil) + return + } + } a.agent.SetModel(id) - a.model = a.agent.Model() - if a.model == "" { - a.model = id + a.modelSwitched(id, window, true) +} + +func (a *app) modelSwitched(id string, window int, remember bool) { + if model := a.agent.Model(); model != "" { + id = model } + a.displayModelSwitched(id, window, remember) +} + +// Queued selections paint on the gesture, before the engine acknowledges them. +func (a *app) displayModelSwitched(id string, window int, remember bool) { + a.model = id // The new model's dial is learned HERE rather than left to the frame clock, // so the status row names it on the very frame the switch lands on // (reasoninglevel.go states the three moments that are seeded and why). @@ -3125,7 +3143,9 @@ func (a *app) switchModel(id string, window int) { // this number (see [app.ctxPercent]). a.ctxWindow = window } - a.rememberModel(a.model) + if remember { + a.rememberModel(a.model) + } a.refreshCreditWarnings() if a.chatCreditWarning != "" { a.askCredits(credits.PaidSwitch) @@ -3207,7 +3227,7 @@ func (a *app) pickerKey(msg tea.KeyPressMsg) tea.Cmd { // one of them asked for that model on that machine, and pinning a // lane under a model they are not talking to would be a setting // that took effect the next time they happened to switch. - if chosen.ID != a.model { + if chosen.ID != a.model || chosen.ID != a.configuredModel() { a.switchModel(chosen.ID, chosen.ContextLength) } a.applyLaneChoice(chosen.ID, row, a.pick.lanes) diff --git a/internal/tui3/provideraccess_test.go b/internal/tui3/provideraccess_test.go index 708e5af047..d6d5533f90 100644 --- a/internal/tui3/provideraccess_test.go +++ b/internal/tui3/provideraccess_test.go @@ -135,6 +135,7 @@ func TestAddingOpenRouterFromTheModelMenuPreservesOllamaAndHandsOverTheKey(t *te a.requireListedModel = true a.model = "vendor/cloud" a.connectAct(a.connPanel.cursor) + acknowledgeModelChanges(t, a) if a.model != "ollama/installed" || a.agent.Model() != "ollama/installed" { t.Fatal("removing OpenRouter did not move the next request to available Ollama") } diff --git a/internal/tui3/startchat.go b/internal/tui3/startchat.go index e1af62c518..672579c5b4 100644 --- a/internal/tui3/startchat.go +++ b/internal/tui3/startchat.go @@ -26,7 +26,12 @@ func (a *app) startRequestedChat(request *session.ChatStart, owner Agent) tea.Cm return nil } start := a.start - return a.besideLine(func() func(bool) tea.Cmd { + listed := a.requireListedModel + var models []Model + if listed { + models = a.modelList() + } + return a.offLoop(func() func(bool) tea.Cmd { claimer, ok := owner.(interface{ ClaimChatStart(string) (bool, error) }) if !ok { return func(bool) tea.Cmd { a.note("could not start chat · launch ownership unavailable"); return nil } @@ -44,6 +49,9 @@ func (a *app) startRequestedChat(request *session.ChatStart, owner Agent) tea.Cm err = fmt.Errorf("the new conversation has no agent") } if err == nil { + // The request carries the parent's saved choice. Availability is + // reconciled for this child before its first request, without saving + // a temporary replacement inherited from the parent. conv.Agent.SetModel(request.Model) if request.Approval != "" { if setter, ok := conv.Agent.(interface{ SetApprovalPosture(string) error }); ok { @@ -53,6 +61,24 @@ func (a *app) startRequestedChat(request *session.ChatStart, owner Agent) tea.Cm } } } + if err == nil && listed { + model, available := availableConversationModel(request.Model, models) + if !available { + err = fmt.Errorf("%s", noAvailableModelWord) + } else if model.ID != request.Model { + if door, ok := conv.Agent.(interface{ UseModelForNow(string) error }); ok { + err = door.UseModelForNow(model.ID) + } else { + conv.Agent.SetModel(model.ID) + } + } + if err == nil && model.ContextLength > 0 { + conv.Agent.SetContextWindow(model.ContextLength) + } + } + if err == nil { + err = waitModelSelection(context.Background(), conv.Agent) + } if err == nil { events, err = conv.Agent.Submit(context.Background(), request.Brief) } diff --git a/internal/tui3/startchat_test.go b/internal/tui3/startchat_test.go index 73e7dfd9f9..470609a1bd 100644 --- a/internal/tui3/startchat_test.go +++ b/internal/tui3/startchat_test.go @@ -1,14 +1,110 @@ package tui3 import ( + "context" "path/filepath" "sync" "testing" + "time" + "github.com/Agent-Field/codeaf/internal/modelsource" + "github.com/Agent-Field/codeaf/internal/modelsource/sourcestub" "github.com/Agent-Field/codeaf/internal/session" teamstore "github.com/Agent-Field/codeaf/internal/teams" ) +type observedRequestedChat struct { + *session.Agent + done chan struct{} +} + +func (a *observedRequestedChat) Submit(ctx context.Context, text string) (<-chan session.Event, error) { + ctx, cancel := context.WithTimeout(ctx, 5*time.Second) + events, err := a.Agent.Submit(ctx, text) + if err != nil { + cancel() + return nil, err + } + forward := make(chan session.Event, 128) + go func() { + defer cancel() + defer close(a.done) + defer close(forward) + for event := range events { + select { + case forward <- event: + case <-ctx.Done(): + return + } + } + }() + return forward, nil +} + +// C1, C4, C5: a temporary parent's configured model is saved in an inherited +// chat. That child reconciles availability before sending and reopens on its +// saved choice; an explicit-model request retains its persistent behavior. +func TestRequestedChatSavesConfiguredModelBeforeItsOwnAvailabilityReconcile(t *testing.T) { + for _, explicit := range []bool{false, true} { + t.Run(map[bool]string{false: "inherited", true: "explicit"}[explicit], func(t *testing.T) { + server := sourcestub.New("replacement", "explicit") + defer server.Close() + service := testDirectService(server.URL()) + sources := modelsource.NewSet(testDefaultService(""), service) + a, _, _ := tabApp(t) + parent := &modelForNowAgent{fakeAgent: &fakeAgent{model: "deepseek-direct/configured"}, configured: "deepseek-direct/configured"} + if err := parent.UseModelForNow("deepseek-direct/replacement"); err != nil { + t.Fatal(err) + } + a.agent = parent + owner := &claimingChatAgent{Agent: parent, claimed: &sync.Map{}} + a.sources, a.requireListedModel = sources, true + a.modelsForService = func(modelsource.Connected) []Model { return []Model{{ID: "replacement"}, {ID: "explicit"}} } + childDir := t.TempDir() + child, err := session.New(session.Config{Workspace: childDir, SessionFile: filepath.Join(childDir, "transcript.jsonl"), Place: session.Place{Dir: childDir}, Model: "deepseek-direct/configured", Sources: sources, DeterministicNames: true}) + if err != nil { + t.Fatal(err) + } + defer child.Close() + observed := &observedRequestedChat{Agent: child, done: make(chan struct{})} + a.start = func(where string) (Conversation, error) { + return Conversation{Agent: observed, SessionFile: filepath.Join(childDir, "transcript.jsonl"), Workspace: where}, nil + } + model, wantRequest := parent.ConfiguredModel(), "replacement" + if explicit { + model, wantRequest = "deepseek-direct/explicit", "explicit" + } + request := &session.ChatStart{ID: "new-chat", Workspace: childDir, Model: model, Brief: "answer briefly"} + spend(t, a, a.startRequestedChat(request, owner)) + select { + case <-observed.done: + case <-time.After(5 * time.Second): + t.Fatal("requested chat did not finish") + } + if err := child.Close(); err != nil { + t.Fatal(err) + } + child.SettleWrites() + meta, err := session.LoadMeta(childDir) + if err != nil || meta.Model != model || meta.ModelIdentity == nil || meta.ModelIdentity.Selected != model { + t.Fatalf("child metadata=%+v, %v", meta, err) + } + requests := completionRequests(server) + if len(requests) != 1 || completionModel(t, requests[0]) != wantRequest { + t.Fatalf("child requests=%+v", requests) + } + reopened, err := session.New(session.Config{Workspace: childDir, SessionFile: filepath.Join(childDir, "transcript.jsonl"), Place: session.Place{Dir: childDir}, Model: meta.Model, Sources: sources, DeterministicNames: true}) + if err != nil { + t.Fatal(err) + } + defer reopened.Close() + if reopened.ConfiguredModel() != model || reopened.Model() != model || parent.ConfiguredModel() != "deepseek-direct/configured" { + t.Fatal("child reopen or parent choice inherited the temporary model") + } + }) + } +} + func TestStartedTeammateUsesNamedModelBeforeWaking(t *testing.T) { a, harbor, _, _ := trafficApp(t) var fresh *postureTestAgent diff --git a/internal/tui3/takeover.go b/internal/tui3/takeover.go index 592781042b..8c64b09817 100644 --- a/internal/tui3/takeover.go +++ b/internal/tui3/takeover.go @@ -665,8 +665,11 @@ func (a *app) resumeStoppedTurn() tea.Cmd { if !ok || a.stream != nil { return nil } - gen, ctx := a.gen, a.ctx + gen, ctx, agent := a.gen, a.ctx, a.agent return func() tea.Msg { + if err := waitModelSelection(ctx, agent); err != nil { + return nil + } events, resumed := door.ResumeStoppedTurn(ctx) if !resumed { return nil diff --git a/internal/tui3/takeover_model_order_test.go b/internal/tui3/takeover_model_order_test.go new file mode 100644 index 0000000000..43b6c0dbf6 --- /dev/null +++ b/internal/tui3/takeover_model_order_test.go @@ -0,0 +1,220 @@ +package tui3 + +import ( + "context" + "encoding/json" + "errors" + "os" + "path/filepath" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + tea "charm.land/bubbletea/v2" + "github.com/Agent-Field/codeaf/internal/modelsource" + "github.com/Agent-Field/codeaf/internal/modelsource/sourcestub" + "github.com/Agent-Field/codeaf/internal/session" +) + +// Only the arrival and selection doors are added to the ordinary surface +// interface, so unrelated standing task lanes do not participate in this test. +type heldResumeModelAgent struct { + Agent + engine *session.Agent + ctx context.Context + entered chan struct{} + release chan struct{} + refused bool + resumeCalls atomic.Int32 +} + +func (a *heldResumeModelAgent) ConfiguredModel() string { return a.engine.ConfiguredModel() } + +func (a *heldResumeModelAgent) UseModelForNow(model string) error { + close(a.entered) + select { + case <-a.release: + case <-a.ctx.Done(): + return a.ctx.Err() + } + if a.refused { + return errors.New("model selection refused") + } + return a.engine.UseModelForNow(model) +} + +func (a *heldResumeModelAgent) AttachReplay() ([]session.DisplayEntry, <-chan session.Event, func()) { + return a.engine.AttachReplay() +} + +func (a *heldResumeModelAgent) ResumeStoppedTurn(ctx context.Context) (<-chan session.Event, bool) { + a.resumeCalls.Add(1) + return a.engine.ResumeStoppedTurn(ctx) +} + +// C1, C4, C6; fix round 5: an unanswered arrival waits for its owning model +// selection before resuming once. Refusal permits the current model; canceling +// the wait preserves the journal's resume opportunity for a later attempt. +func TestTakeoverReplayResumeFollowsItsOrderedModel(t *testing.T) { + for _, outcome := range []string{"accepted", "refused", "canceled"} { + t.Run(outcome, func(t *testing.T) { + t.Setenv("CODEAF_HOME", t.TempDir()) + ctx, cancel := context.WithTimeout(t.Context(), 5*time.Second) + defer cancel() + server := sourcestub.New("replacement") + defer server.Close() + sources := modelsource.NewSet(testDefaultService(""), testDirectService(server.URL())) + dir := t.TempDir() + path := filepath.Join(dir, "transcript.jsonl") + const question = "answer the question already in the journal" + journal := `{"type":"session","version":1,"id":"resume-model","model":"deepseek-direct/configured"}` + "\n" + + `{"type":"message","role":"user","content":"` + question + `"}` + "\n" + + `{"type":"error","error":{"door":"taken over","message":"turn ended: taken over"}}` + "\n" + + `{"type":"title","title":"An unanswered question"}` + "\n" + if err := os.WriteFile(path, []byte(journal), 0600); err != nil { + t.Fatal(err) + } + engine, err := session.New(session.Config{Workspace: dir, SessionFile: path, + Model: "deepseek-direct/configured", Sources: sources, System: "SYSTEM", DeterministicNames: true}) + if err != nil { + t.Fatal(err) + } + defer engine.Close() + agent := &heldResumeModelAgent{Agent: engine, engine: engine, ctx: ctx, + entered: make(chan struct{}), release: make(chan struct{}), refused: outcome == "refused"} + unblock := sync.OnceFunc(func() { close(agent.release) }) + defer unblock() + a := newApp(ctx, Options{Agent: &fakeAgent{model: "deepseek-direct/replacement"}, + ProfileDir: t.TempDir(), Workspace: dir, Sources: sources, RequireListedModel: true, + ModelsForService: func(modelsource.Connected) []Model { return []Model{{ID: "replacement"}} }, + }) + defer a.doorLine.close() + a.detachConversation() + resumeCtx, cancelResume := context.WithCancel(ctx) + defer cancelResume() + a.ctx = resumeCtx + attached := a.attachConversation(Conversation{Agent: agent, SessionFile: path, Workspace: dir, Resumed: true}, nil) + select { + case <-agent.entered: + case <-ctx.Done(): + t.Fatal("arrival never asked for the listed replacement") + } + var resume tea.Cmd + for _, msg := range runCmd(attached) { + if answer, ok := msg.(doorMsg); ok { + resume = a.doorSaid(answer) + } + } + if a.hostReplayLoading || resume == nil || pendingModelSelection(agent) == nil || + a.model != "deepseek-direct/replacement" || engine.Model() != "deepseek-direct/configured" { + t.Fatal("idle replay did not leave a resume behind the pending replacement") + } + if got := strings.TrimSpace(transcriptText(a)); got != question { + t.Fatalf("arrival replay = %q, want the one journal question", got) + } + // Fold the replay without consuming the separate selection receipt: + // completing the engine ask alone must release this command. + done := make(chan []tea.Msg, 1) + go func() { done <- runCmd(resume) }() + select { + case <-done: + t.Fatal("resume command finished before model selection") + case <-time.After(30 * time.Millisecond): + } + if agent.resumeCalls.Load() != 0 || len(completionRequests(server)) != 0 { + t.Fatal("resume entered the engine before model selection completed") + } + if outcome == "canceled" { + cancelResume() + select { + case msgs := <-done: + if len(msgs) != 0 || agent.resumeCalls.Load() != 0 || pendingModelSelection(agent) == nil { + t.Fatal("canceled wait consumed the resume opportunity or the independent selection") + } + case <-ctx.Done(): + t.Fatal("canceled resume did not leave the model wait") + } + } + unblock() + var msgs []tea.Msg + if outcome == "canceled" { + acknowledgeModelChanges(t, a) + if agent.resumeCalls.Load() != 0 || len(completionRequests(server)) != 0 { + t.Fatal("selection receipt resurrected a canceled resume") + } + a.ctx = ctx + msgs = runCmd(a.resumeStoppedTurn()) + } else { + select { + case msgs = <-done: + case <-ctx.Done(): + t.Fatal("resume did not follow the completed model ask") + } + } + if len(msgs) != 1 || agent.resumeCalls.Load() != 1 { + t.Fatalf("resume calls = %d, messages = %v", agent.resumeCalls.Load(), msgs) + } + resumed, ok := msgs[0].(resumedTurnMsg) + if !ok || resumed.ch == nil { + t.Fatalf("arrival did not start its unanswered turn: %v", msgs) + } + drainResumedModelTurn(t, ctx, resumed.ch) + acknowledgeModelChanges(t, a) + if again := runCmd(a.resumeStoppedTurn()); len(again) != 0 { + t.Fatalf("arrival resumed the same question twice: %v", again) + } + want := "replacement" + if outcome == "refused" { + want = "configured" + } + requests := completionRequests(server) + if len(requests) != 1 || completionModel(t, requests[0]) != want { + t.Fatalf("resumed provider requests = %+v, want one on %q", requests, want) + } + var said []string + saved, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + for _, line := range strings.Split(strings.TrimSpace(string(saved)), "\n") { + var entry struct { + Type, Role, Content string + Note bool + } + if err := json.Unmarshal([]byte(line), &entry); err != nil { + t.Fatal(err) + } + if entry.Type == "message" && entry.Role == "user" && !entry.Note { + said = append(said, entry.Content) + } + } + if len(said) != 1 || said[0] != question || engine.ConfiguredModel() != "deepseek-direct/configured" { + t.Fatalf("resume duplicated the journal question or saved the replacement: %q", said) + } + }) + } +} + +func drainResumedModelTurn(t *testing.T, ctx context.Context, events <-chan session.Event) { + t.Helper() + finished := false + for { + select { + case event, ok := <-events: + if !ok { + if !finished { + t.Fatal("resumed turn closed without finishing") + } + return + } + if event.Kind == session.EventError { + t.Fatalf("resumed turn failed: %+v", event) + } + finished = finished || event.Kind == session.EventTurnDone + case <-ctx.Done(): + t.Fatal("resumed turn exceeded its deadline") + } + } +} diff --git a/internal/tui3/taskplan.go b/internal/tui3/taskplan.go index 03e1c18129..6ad4b5b48d 100644 --- a/internal/tui3/taskplan.go +++ b/internal/tui3/taskplan.go @@ -176,6 +176,9 @@ func planStateWord(row session.PlanTaskRow) string { if row.Interrupted { return "interrupted" } + if row.Undriven && row.Status == "ready" { + return "queued" + } switch strings.TrimSpace(row.Status) { case "pending": return "queued" @@ -222,6 +225,9 @@ func planStatus(row session.PlanTaskRow) session.TaskStatus { Word: planStateWord(row), On: session.TaskWaitMachine, Reason: row.Hold, } } + if row.Undriven && store == "ready" { + store = "pending" + } switch strings.TrimSpace(store) { case "pending": // ADMITTED, NOT STARTED — the queued presence, and the moving tier diff --git a/internal/tui3/taskplan_test.go b/internal/tui3/taskplan_test.go index ec6712887a..5bac0d9d07 100644 --- a/internal/tui3/taskplan_test.go +++ b/internal/tui3/taskplan_test.go @@ -31,6 +31,18 @@ func TestPlanReadyPartHeldByMachineReadsAsQueuedWithReason(t *testing.T) { } } +// L-LOOP2 C8, C6: retained ready work has queued presence as well as its word. +func TestPlanRetainedReadyPartReadsQueuedWithoutAWorker(t *testing.T) { + row := session.PlanTaskRow{ID: "t-leaf", Status: "ready", Undriven: true} + if status := planStatus(row); status.Presence != session.TaskPresenceQueued || status.RowWord() != "queued" { + t.Fatalf("retained ready row still looks active: %+v", status) + } + row.Undriven = false + if status := planStatus(row); status.Presence != session.TaskPresenceWorking || status.RowWord() != "running" { + t.Fatalf("live ready row changed: %+v", status) + } +} + func TestPlanUnstartedRunningRootShowsMachineHold(t *testing.T) { row := session.PlanTaskRow{ID: "t-root", Status: "running", Hold: "machine busy"} status := planStatus(row) diff --git a/internal/workspace/schema3_test.go b/internal/workspace/schema3_test.go new file mode 100644 index 0000000000..fdb8483631 --- /dev/null +++ b/internal/workspace/schema3_test.go @@ -0,0 +1,206 @@ +package workspace + +import ( + "context" + "database/sql" + "os" + "path/filepath" + "reflect" + "strings" + "testing" +) + +// Part A (a): this literal is schema 2, independent of the schema being written. +const schemaTwoDDL = ` +CREATE TABLE collections (seq INTEGER PRIMARY KEY AUTOINCREMENT,id TEXT NOT NULL UNIQUE,name TEXT NOT NULL,created_at INTEGER NOT NULL DEFAULT 0); +CREATE TABLE memberships ( + seq INTEGER PRIMARY KEY AUTOINCREMENT,collection_id TEXT NOT NULL REFERENCES collections(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 collections(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)); +CREATE INDEX memberships_reference ON memberships(kind,ref_id,session_id); +CREATE INDEX memberships_target ON memberships(target_collection); +PRAGMA application_id=1095123788; PRAGMA user_version=2; +INSERT INTO collections(seq,id,name,created_at) VALUES (4,'aaaa','Product',1234),(9,'bbbb','Nested',2345); +INSERT INTO memberships(seq,collection_id,kind,ref_id,session_id,target_collection,added_at) VALUES + (2,'aaaa','conversation','chat','',NULL,4567),(6,'aaaa','task','3','chat',NULL,5678), + (11,'aaaa','collection','bbbb','','bbbb',6789); +` + +func writeSchemaTwo(t *testing.T) string { + t.Helper() + path := filepath.Join(t.TempDir(), "two.db") + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + if _, err := db.Exec(schemaTwoDDL); err != nil { + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + return path +} + +func TestPartASchemaTwoReadsWithoutWritingAndMigratesAllRows(t *testing.T) { + ctx := context.Background() + path := writeSchemaTwo(t) + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + s := openTestStore(t, path) + wantC, wantM := legacyRows(t, s.db) + list, err := s.List(ctx, &Ref{Kind: ConversationKind, ID: "chat"}) + if err != nil || len(list) != 2 || list[0].CreatedAt != 1234 || list[0].LastAddedAt != 6789 || !*list[0].Holds { + t.Fatalf("schema 2 read: %+v, %v", list, err) + } + tree, err := s.Tree(ctx, "aaaa", 0, 20, "") + if err != nil || len(tree.Members) != 3 || tree.Members[0].AddedAt != 6789 { + t.Fatalf("tree: %+v, %v", tree, err) + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(before) != string(after) { + t.Fatal("read changed schema 2") + } + if err := s.Rename(ctx, "aaaa", "Products"); err != nil { + t.Fatal(err) + } + if userVersion(t, s) != schemaVersion { + t.Fatal("first write did not migrate to the current schema") + } + gotC, gotM := legacyRows(t, s.db) + wantC[0] = strings.Replace(wantC[0], "Product|", "Products|", 1) + if !reflect.DeepEqual(gotC, wantC) || !reflect.DeepEqual(gotM, wantM) { + t.Fatalf("migration lost rows: %v %v", gotC, gotM) + } + rows, err := s.db.Query("SELECT seq,created_at FROM collections ORDER BY seq") + if err != nil { + t.Fatal(err) + } + var collectionTimes [][2]int64 + for rows.Next() { + var seq, at int64 + if err := rows.Scan(&seq, &at); err != nil { + t.Fatal(err) + } + collectionTimes = append(collectionTimes, [2]int64{seq, at}) + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + if err := rows.Close(); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(collectionTimes, [][2]int64{{4, 1234}, {9, 2345}}) { + t.Fatalf("collection times changed: %+v", collectionTimes) + } + rows, err = s.db.Query("SELECT seq,added_at FROM memberships ORDER BY seq") + if err != nil { + t.Fatal(err) + } + var memberTimes [][2]int64 + for rows.Next() { + var seq, at int64 + if err := rows.Scan(&seq, &at); err != nil { + t.Fatal(err) + } + memberTimes = append(memberTimes, [2]int64{seq, at}) + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + if err := rows.Close(); err != nil { + t.Fatal(err) + } + if !reflect.DeepEqual(memberTimes, [][2]int64{{2, 4567}, {6, 5678}, {11, 6789}}) { + t.Fatalf("membership times changed: %+v", memberTimes) + } + if err := s.Add(ctx, "aaaa", Ref{Kind: FactoryJobKind, ID: "17", Factory: "factory-a"}); err != nil { + t.Fatal(err) + } +} + +func TestPartASchemaTwoMigrationRollsBackWithFailedWrite(t *testing.T) { + path := writeSchemaTwo(t) + before, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + s := openTestStore(t, path) + if err := s.Rename(context.Background(), "aaaa", "Nested"); err != ErrNameTaken { + t.Fatalf("want name conflict: %v", err) + } + after, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + if string(before) != string(after) || userVersion(t, s) != 2 { + t.Fatal("failed first write changed file") + } +} + +func TestPartAFactoryJobRefValidation(t *testing.T) { + for _, tc := range []struct { + ref Ref + ok bool + }{ + {Ref{Kind: FactoryJobKind, ID: "17", Factory: "f"}, true}, + {Ref{Kind: FactoryJobKind, ID: "0", Factory: "f"}, false}, + {Ref{Kind: FactoryJobKind, ID: "017", Factory: "f"}, false}, + {Ref{Kind: FactoryJobKind, ID: "+17", Factory: "f"}, false}, + {Ref{Kind: FactoryJobKind, ID: "17"}, false}, + {Ref{Kind: FactoryJobKind, ID: "17", Factory: " f"}, false}, + {Ref{Kind: FactoryJobKind, ID: "17", Factory: "f", SessionID: "chat"}, false}, + {Ref{Kind: ConversationKind, ID: "chat", Factory: "f"}, false}, + } { + if err := tc.ref.Validate(); (err == nil) != tc.ok { + t.Errorf("%+v: %v", tc.ref, err) + } + } +} + +// Part A (a): deleted sequence numbers remain retired across both migrations, +// preserving the monotonic keys used by existing listing and member cursors. +func TestPartAMigrationPreservesSequenceHighWatermarks(t *testing.T) { + for _, tc := range []struct { + name string + fixture func(*testing.T) string + }{ + {"schema 1", writeLegacyStore}, {"schema 2", writeSchemaTwo}, + } { + t.Run(tc.name, func(t *testing.T) { + s := openTestStore(t, tc.fixture(t)) + ctx := context.Background() + if _, err := s.db.Exec(`INSERT INTO collections(seq,id,name) VALUES (100,'gone','Gone'); +DELETE FROM collections WHERE id='gone'; +INSERT INTO memberships(seq,collection_id,kind,ref_id,session_id) VALUES (200,'aaaa','conversation','gone',''); +DELETE FROM memberships WHERE ref_id='gone';`); err != nil { + t.Fatal(err) + } + c, err := s.Create(ctx, "After") + if err != nil { + t.Fatal(err) + } + if err := s.Add(ctx, c.ID, Ref{Kind: FactoryJobKind, ID: "1", Factory: "f"}); err != nil { + t.Fatal(err) + } + var collectionSeq, memberSeq int64 + if err := s.db.QueryRow("SELECT seq FROM collections WHERE id=?", c.ID).Scan(&collectionSeq); err != nil { + t.Fatal(err) + } + if err := s.db.QueryRow("SELECT seq FROM memberships WHERE collection_id=?", c.ID).Scan(&memberSeq); err != nil { + t.Fatal(err) + } + if collectionSeq <= 100 || memberSeq <= 200 { + t.Fatalf("reused retired sequences: collection %d member %d", collectionSeq, memberSeq) + } + }) + } +} diff --git a/internal/workspace/schema7.go b/internal/workspace/schema7.go new file mode 100644 index 0000000000..404c0d7ef9 --- /dev/null +++ b/internal/workspace/schema7.go @@ -0,0 +1,32 @@ +package workspace + +// schemaSevenCleanup removes tables left by development builds (schemas 3–6), +// including their indexes and triggers. +const schemaSevenCleanup = ` +DROP TRIGGER IF EXISTS workstream_collection_keep; +DROP TRIGGER IF EXISTS workstream_job_promote; +DROP TRIGGER IF EXISTS workstream_job_update; +DROP TRIGGER IF EXISTS workstream_job_insert; +DROP INDEX IF EXISTS workstream_suggestion_page; +DROP INDEX IF EXISTS workstream_sources_order; +DROP INDEX IF EXISTS workstream_wake_item_list; +DROP INDEX IF EXISTS workstream_wake_list; +DROP INDEX IF EXISTS workstream_goahead_list; +DROP INDEX IF EXISTS workstream_record_list; +DROP INDEX IF EXISTS workstream_overview; +DROP INDEX IF EXISTS workstream_page_tree; +DROP INDEX IF EXISTS workstream_job_owner; +DROP INDEX IF EXISTS workstreams_client; +DROP TABLE IF EXISTS workstream_suggestion_edits; +DROP TABLE IF EXISTS workstream_suggestions; +DROP TABLE IF EXISTS workstream_sources; +DROP TABLE IF EXISTS workstream_wake_items; +DROP TABLE IF EXISTS workstream_wakes; +DROP TABLE IF EXISTS workstream_plans; +DROP TABLE IF EXISTS workstream_goaheads; +DROP TABLE IF EXISTS workstream_records; +DROP TABLE IF EXISTS workstream_page_versions; +DROP TABLE IF EXISTS workstream_pages; +DROP TABLE IF EXISTS workstream_jobs; +DROP TABLE IF EXISTS workstreams; +` diff --git a/internal/workspace/schema7_test.go b/internal/workspace/schema7_test.go new file mode 100644 index 0000000000..af5dfed7fa --- /dev/null +++ b/internal/workspace/schema7_test.go @@ -0,0 +1,574 @@ +package workspace + +import ( + "context" + "database/sql" + "fmt" + "os" + "path/filepath" + "reflect" + "strings" + "testing" + + "github.com/Agent-Field/codeaf/internal/sqlitefile" +) + +// Contract (c): frozen SQL from the schema-6 writer and the schema-2 merge base. +// These fixtures deliberately do not call the current schema builders. +const schemaSevenTwoDDL = ` +CREATE TABLE collections ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE memberships ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + collection_id TEXT NOT NULL REFERENCES collections(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 collections(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) +); + +CREATE INDEX memberships_reference ON memberships(kind,ref_id,session_id); +CREATE INDEX memberships_target ON memberships(target_collection); +` + +const schemaSevenCoreDDL = ` +CREATE TABLE collections ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL UNIQUE, + name TEXT NOT NULL, + created_at INTEGER NOT NULL DEFAULT 0 +); +CREATE TABLE memberships ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + collection_id TEXT NOT NULL REFERENCES collections(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK(kind IN ('collection','conversation','task','standing','artifact','crew','process','factory_job')), + ref_id TEXT NOT NULL, + session_id TEXT NOT NULL, + target_collection TEXT REFERENCES collections(id) ON DELETE CASCADE, + added_at INTEGER NOT NULL DEFAULT 0, + factory TEXT NOT NULL DEFAULT '', + CHECK ((kind='factory_job' AND length(factory)>0) OR (kind!='factory_job' AND factory='')), + 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,factory) +); + +CREATE INDEX memberships_reference ON memberships(kind,ref_id,session_id,factory); +CREATE INDEX memberships_target ON memberships(target_collection); + +` + +const schemaSevenThreeDDL = `CREATE TABLE workstreams ( + id TEXT PRIMARY KEY REFERENCES collections(id) ON DELETE CASCADE, + why TEXT NOT NULL DEFAULT '', origin_chat_id TEXT NOT NULL DEFAULT '', agent_id TEXT NOT NULL, + autonomy TEXT NOT NULL DEFAULT 'propose' CHECK(autonomy IN ('propose','small','off')), + wake_morning INTEGER NOT NULL DEFAULT 0, wake_related_issues INTEGER NOT NULL DEFAULT 0, + budget_usd REAL CHECK(budget_usd IS NULL OR budget_usd>0), + created_at INTEGER NOT NULL, updated_at INTEGER NOT NULL, started_at INTEGER, done_at INTEGER, + how_it_went TEXT NOT NULL DEFAULT '', client_id TEXT NOT NULL DEFAULT '' +); +CREATE UNIQUE INDEX workstreams_client ON workstreams(client_id) WHERE client_id!=''; +CREATE TABLE workstream_jobs ( + member_seq INTEGER PRIMARY KEY REFERENCES memberships(seq) ON DELETE CASCADE, + workstream_id TEXT NOT NULL REFERENCES workstreams(id) ON DELETE CASCADE, + factory TEXT NOT NULL, job TEXT NOT NULL +); +CREATE UNIQUE INDEX workstream_job_owner ON workstream_jobs(factory,job); +CREATE TRIGGER workstream_job_insert AFTER INSERT ON memberships + WHEN NEW.kind='factory_job' AND EXISTS(SELECT 1 FROM workstreams WHERE id=NEW.collection_id) + BEGIN INSERT INTO workstream_jobs VALUES(NEW.seq,NEW.collection_id,NEW.factory,NEW.ref_id); END; +CREATE TRIGGER workstream_job_update AFTER UPDATE ON memberships + BEGIN + DELETE FROM workstream_jobs WHERE member_seq=OLD.seq; + INSERT INTO workstream_jobs SELECT NEW.seq,NEW.collection_id,NEW.factory,NEW.ref_id + WHERE NEW.kind='factory_job' AND EXISTS(SELECT 1 FROM workstreams WHERE id=NEW.collection_id); + END; +CREATE TRIGGER workstream_job_promote AFTER INSERT ON workstreams + BEGIN INSERT INTO workstream_jobs SELECT seq,collection_id,factory,ref_id FROM memberships + WHERE collection_id=NEW.id AND kind='factory_job'; END; +CREATE TABLE workstream_pages ( + id TEXT PRIMARY KEY, workstream_id TEXT NOT NULL REFERENCES workstreams(id) ON DELETE CASCADE, + parent_id TEXT REFERENCES workstream_pages(id) ON DELETE CASCADE, + title TEXT NOT NULL, owner TEXT NOT NULL CHECK(owner IN ('you','agent')), + read_by_jobs INTEGER NOT NULL DEFAULT 0, position INTEGER NOT NULL CHECK(position>=0), + version INTEGER NOT NULL CHECK(version>0), updated_at INTEGER NOT NULL, + updated_by TEXT NOT NULL CHECK(updated_by IN ('you','agent')), updated_from TEXT, + doc TEXT NOT NULL, overview INTEGER NOT NULL DEFAULT 0 CHECK(overview IN (0,1)), + deleted_at INTEGER, deleted_root TEXT +); +CREATE INDEX workstream_page_tree ON workstream_pages(workstream_id,parent_id,position); +CREATE UNIQUE INDEX workstream_overview ON workstream_pages(workstream_id) WHERE overview=1; +CREATE TABLE workstream_page_versions ( + page_id TEXT NOT NULL REFERENCES workstream_pages(id) ON DELETE CASCADE, + version INTEGER NOT NULL, saved_at INTEGER NOT NULL, saved_by TEXT NOT NULL CHECK(saved_by IN ('you','agent')), + source TEXT, title TEXT NOT NULL, doc TEXT NOT NULL, bytes INTEGER NOT NULL, + PRIMARY KEY(page_id,version) +); +CREATE TABLE workstream_records ( + id TEXT PRIMARY KEY, workstream_id TEXT NOT NULL REFERENCES workstreams(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK(kind IN ('decision','question','goahead')), text TEXT NOT NULL, + state TEXT NOT NULL CHECK(state IN ('proposed','kept','dropped','superseded')), + actor TEXT NOT NULL CHECK(actor IN ('you','agent')), source TEXT, created_at INTEGER NOT NULL, + decided_at INTEGER, supersedes TEXT REFERENCES workstream_records(id), superseded_by TEXT REFERENCES workstream_records(id), + goahead TEXT, goahead_id TEXT +); +CREATE INDEX workstream_record_list ON workstream_records(workstream_id,created_at); +CREATE TABLE workstream_goaheads ( + id TEXT PRIMARY KEY, workstream_id TEXT NOT NULL REFERENCES workstreams(id) ON DELETE CASCADE, + scope TEXT NOT NULL CHECK(scope IN ('job','wave','milestone')), + factory TEXT, job INTEGER, wave INTEGER, milestone_id TEXT, + cap_usd REAL NOT NULL CHECK(cap_usd>0 AND cap_usd<=10000), ends_at INTEGER NOT NULL, + state TEXT NOT NULL CHECK(state IN ('active','ended')), + end_reason TEXT CHECK(end_reason IN ('expired','revoked','workstream_done')), + granted_at INTEGER NOT NULL, ended_at INTEGER, + CHECK((scope='job' AND factory IS NOT NULL AND length(factory)>0 AND job IS NOT NULL AND job>0 AND wave IS NULL AND milestone_id IS NULL) + OR (scope='wave' AND wave IS NOT NULL AND wave>0 AND factory IS NULL AND job IS NULL AND milestone_id IS NULL) + OR (scope='milestone' AND milestone_id IS NOT NULL AND length(milestone_id)>0 AND factory IS NULL AND job IS NULL AND wave IS NULL)), + CHECK(ends_at>granted_at AND ends_at<=granted_at+7776000000) +); +CREATE INDEX workstream_goahead_list ON workstream_goaheads(workstream_id,state); +CREATE TABLE workstream_plans ( + workstream_id TEXT PRIMARY KEY REFERENCES workstreams(id) ON DELETE CASCADE, + version INTEGER NOT NULL CHECK(version>0), updated_at INTEGER NOT NULL, + updated_by TEXT NOT NULL CHECK(updated_by IN ('you','agent')), + milestones TEXT NOT NULL, waves TEXT NOT NULL +); + +CREATE TRIGGER IF NOT EXISTS workstream_collection_keep BEFORE DELETE ON collections + WHEN EXISTS(SELECT 1 FROM workstreams WHERE id=OLD.id) + BEGIN SELECT RAISE(ABORT,'mark a workstream done instead'); END; + +` + +const schemaSevenFourDDL = `CREATE TABLE workstream_wakes ( + id TEXT PRIMARY KEY, workstream_id TEXT NOT NULL REFERENCES workstreams(id) ON DELETE CASCADE, + event_key TEXT NOT NULL, event_json TEXT NOT NULL, + state TEXT NOT NULL CHECK(state IN ('running','settled','failed')), + summary TEXT NOT NULL DEFAULT '', error TEXT NOT NULL DEFAULT '', cost_usd REAL, + started_at INTEGER NOT NULL, settled_at INTEGER, dismissed_at INTEGER, + UNIQUE(workstream_id,event_key) +); +CREATE INDEX workstream_wake_list ON workstream_wakes(workstream_id,started_at); +CREATE TABLE workstream_wake_items ( + id TEXT PRIMARY KEY, wake_id TEXT NOT NULL REFERENCES workstream_wakes(id) ON DELETE CASCADE, + position INTEGER NOT NULL, kind TEXT NOT NULL CHECK(kind IN ('move','draft','page','decision','question')), + state TEXT NOT NULL CHECK(state IN ('applied','undone','proposed','dismissed')), + why TEXT NOT NULL DEFAULT '', item_json TEXT NOT NULL, undo_json TEXT, + note TEXT NOT NULL DEFAULT '', applied_at INTEGER, undone_at INTEGER +); +CREATE INDEX workstream_wake_item_list ON workstream_wake_items(wake_id,position); + +` + +const schemaSevenFiveDDL = `CREATE TABLE workstream_sources ( + seq INTEGER PRIMARY KEY AUTOINCREMENT, + id TEXT NOT NULL UNIQUE, + workstream_id TEXT NOT NULL REFERENCES workstreams(id) ON DELETE CASCADE, + kind TEXT NOT NULL CHECK(kind IN ('file','repo','issue','link','kept')), + target TEXT NOT NULL, + title TEXT NOT NULL, + path TEXT, repo TEXT, number INTEGER, url TEXT, ref TEXT, why TEXT, + read_by_jobs INTEGER NOT NULL DEFAULT 0, + proposed INTEGER NOT NULL DEFAULT 0, + added_by TEXT NOT NULL CHECK(added_by IN ('you','agent')), + added_at INTEGER NOT NULL, + UNIQUE(workstream_id,kind,target) +); +CREATE INDEX workstream_sources_order ON workstream_sources(workstream_id,seq); + +` + +const schemaSevenSixDDL = `CREATE TABLE workstream_suggestions ( + id TEXT PRIMARY KEY, workstream_id TEXT NOT NULL REFERENCES workstreams(id) ON DELETE CASCADE, + page_id TEXT NOT NULL REFERENCES workstream_pages(id) ON DELETE CASCADE, + base_version INTEGER NOT NULL, why TEXT NOT NULL, actor TEXT NOT NULL, source TEXT, + created_at INTEGER NOT NULL +); +CREATE INDEX workstream_suggestion_page ON workstream_suggestions(page_id,created_at); +CREATE TABLE workstream_suggestion_edits ( + suggestion_id TEXT NOT NULL REFERENCES workstream_suggestions(id) ON DELETE CASCADE, + id TEXT NOT NULL, position INTEGER NOT NULL, block TEXT NOT NULL, at_offset INTEGER NOT NULL, + del TEXT NOT NULL, ins TEXT NOT NULL, block_text TEXT NOT NULL, why TEXT NOT NULL, + state TEXT NOT NULL CHECK(state IN ('proposed','kept','dropped')), decided_at INTEGER, + PRIMARY KEY(suggestion_id,id) +); +` + +const schemaSevenRows = ` +INSERT INTO collections(seq,id,name,created_at) VALUES + (4,'root','Root',1234),(9,'nested','Nested',2345),(12,'former','Former owner',3456); +INSERT INTO memberships(seq,collection_id,kind,ref_id,session_id,target_collection,added_at) VALUES + (2,'root','conversation','chat','',NULL,4567), + (6,'root','task','3','chat',NULL,5678), + (11,'root','collection','nested','','nested',6789), + (15,'nested','collection','former','','former',7000), + (16,'nested','crew','crew','',NULL,7100), + (17,'nested','process','chat:p1','chat',NULL,7200), + (18,'former','conversation','agent','',NULL,7300), + (19,'nested','artifact','/tmp/report.md','',NULL,7400), + (20,'nested','standing','retired','',NULL,7500); +INSERT INTO collections(seq,id,name) VALUES (100,'gone','Deleted'); +DELETE FROM collections WHERE id='gone'; +INSERT INTO memberships(seq,collection_id,kind,ref_id,session_id) VALUES (200,'root','conversation','gone',''); +DELETE FROM memberships WHERE ref_id='gone'; +` + +const schemaSevenThreeRows = ` +INSERT INTO workstreams(id,agent_id,created_at,updated_at,client_id) VALUES ('former','agent',3456,4000,'client'); +INSERT INTO memberships(seq,collection_id,kind,ref_id,session_id,added_at,factory) VALUES + (21,'former','factory_job','7','',7600,'factory-a'),(22,'root','factory_job','7','',7700,'factory-a'); +INSERT INTO workstream_pages(id,workstream_id,title,owner,position,version,updated_at,updated_by,doc,overview) + VALUES ('page','former','Overview','you',0,1,4000,'you','{}',1); +INSERT INTO workstream_page_versions(page_id,version,saved_at,saved_by,title,doc,bytes) + VALUES ('page',1,4000,'you','Overview','{}',2); +INSERT INTO workstream_records(id,workstream_id,kind,text,state,actor,created_at) + VALUES ('record','former','decision','Keep','kept','you',4000); +INSERT INTO workstream_goaheads(id,workstream_id,scope,wave,cap_usd,ends_at,state,granted_at) + VALUES ('grant','former','wave',1,1,5000,'active',4000); +INSERT INTO workstream_plans(workstream_id,version,updated_at,updated_by,milestones,waves) + VALUES ('former',1,4000,'you','[]','[]'); +` +const schemaSevenFourRows = ` +INSERT INTO workstream_wakes(id,workstream_id,event_key,event_json,state,started_at) + VALUES ('wake','former','morning:2026-11-03','{}','settled',4000); +INSERT INTO workstream_wake_items(id,wake_id,position,kind,state,item_json) + VALUES ('item','wake',0,'page','applied','{}'); +` +const schemaSevenFiveRows = ` +INSERT INTO workstream_sources(id,workstream_id,kind,target,title,added_by,added_at) + VALUES ('source','former','link','https://example.com','Example','you',4000); +` +const schemaSevenSixRows = ` +INSERT INTO workstream_suggestions(id,workstream_id,page_id,base_version,why,actor,created_at) + VALUES ('suggestion','former','page',1,'Clarify','agent',4000); +INSERT INTO workstream_suggestion_edits(suggestion_id,id,position,block,at_offset,del,ins,block_text,why,state) + VALUES ('suggestion','edit',0,'[0]',0,'Old','New','Old','Clearer','proposed'); +` + +func schemaSevenFixture(t *testing.T, version int) string { + t.Helper() + ddl, rows := schemaSevenTwoDDL, schemaSevenRows + if version >= 3 { + ddl = schemaSevenCoreDDL + schemaSevenThreeDDL + rows += schemaSevenThreeRows + } + if version >= 4 { + ddl += schemaSevenFourDDL + rows += schemaSevenFourRows + } + if version >= 5 { + ddl += schemaSevenFiveDDL + rows += schemaSevenFiveRows + } + if version >= 6 { + ddl += schemaSevenSixDDL + rows += schemaSevenSixRows + } + path := filepath.Join(t.TempDir(), "collections.db") + db, err := sql.Open("sqlite", sqlitefile.URI(path, "_pragma=foreign_keys(1)")) + if err != nil { + t.Fatal(err) + } + defer db.Close() + if _, err := db.Exec(ddl + rows + fmt.Sprintf("PRAGMA application_id=%d; PRAGMA user_version=%d;", applicationID, version)); err != nil { + t.Fatal(err) + } + return path +} + +func schemaSevenFile(t *testing.T, path string) []byte { + t.Helper() + b, err := os.ReadFile(path) + if err != nil { + t.Fatal(err) + } + return b +} + +func schemaSevenQuery(t *testing.T, db *sql.DB, query string) [][]any { + t.Helper() + rows, err := db.Query(query) + if err != nil { + t.Fatal(err) + } + defer rows.Close() + columns, err := rows.Columns() + if err != nil { + t.Fatal(err) + } + var out [][]any + for rows.Next() { + values, pointers := make([]any, len(columns)), make([]any, len(columns)) + for i := range values { + pointers[i] = &values[i] + } + if err := rows.Scan(pointers...); err != nil { + t.Fatal(err) + } + out = append(out, values) + } + if err := rows.Err(); err != nil { + t.Fatal(err) + } + return out +} + +func schemaSevenSchema(t *testing.T, s *Store) [][]any { + t.Helper() + schema := schemaSevenQuery(t, s.db, "SELECT type,name,tbl_name,sql FROM sqlite_master ORDER BY type,name") + // ALTER TABLE quotes renamed table identifiers; the logical shape is identical. + for _, row := range schema { + if text, ok := row[3].(string); ok { + row[3] = strings.ReplaceAll(text, `"`, "") + } + } + return schema +} + +func schemaSevenSequences(t *testing.T, s *Store) [][]any { + t.Helper() + return schemaSevenQuery(t, s.db, "SELECT name,seq FROM sqlite_sequence WHERE name IN ('collections','memberships') ORDER BY name") +} + +// Contract (c), G2: every supported predecessor reads without writing, migrates +// on the requested successful write, preserves every row and cursor high-water +// mark, permits former-owner deletion, and performs no DDL on subsequent writes. +func TestSchemaSevenMigratesAndPreservesGenericData(t *testing.T) { + for _, version := range []int{2, 3, 4, 5, 6} { + t.Run(fmt.Sprint(version), func(t *testing.T) { + ctx := context.Background() + path := schemaSevenFixture(t, version) + before := schemaSevenFile(t, path) + s := openTestStore(t, path) + wantCollections := schemaSevenQuery(t, s.db, "SELECT seq,id,name,created_at FROM collections ORDER BY seq") + projection := "seq,collection_id,kind,ref_id,session_id,target_collection,added_at,''" + if version >= 3 { + projection = "seq,collection_id,kind,ref_id,session_id,target_collection,added_at,factory" + } + wantMembers := schemaSevenQuery(t, s.db, "SELECT "+projection+" FROM memberships ORDER BY seq") + wantSequences := schemaSevenSequences(t, s) + if !reflect.DeepEqual(wantSequences, [][]any{{"collections", int64(100)}, {"memberships", int64(200)}}) { + t.Fatalf("fixture lacks deleted-row gaps: %v", wantSequences) + } + if version == 6 { + // Every frozen legacy table contains data; cleanup is exercised with foreign keys on. + for _, row := range schemaSevenQuery(t, s.db, "SELECT name FROM sqlite_master WHERE type='table' AND name NOT IN ('collections','memberships','sqlite_sequence')") { + count := schemaSevenQuery(t, s.db, "SELECT count(*) FROM "+row[0].(string)) + if count[0][0].(int64) == 0 { + t.Fatalf("empty fixture table: %s", row[0]) + } + } + } + collections, err := s.Collections(ctx) + if err != nil || len(collections) != 3 { + t.Fatalf("collections: %v, %v", collections, err) + } + summaries, err := s.List(ctx, nil) + if err != nil || len(summaries) != 3 || summaries[0].CreatedAt != 1234 { + t.Fatalf("summaries: %+v, %v", summaries, err) + } + wantRefs := map[string][]Ref{} + wantTrees := map[string]Tree{} + for _, c := range collections { + refs, err := s.Members(ctx, c.ID) + if err != nil { + t.Fatal(err) + } + wantRefs[c.ID] = refs + tree, err := s.Tree(ctx, c.ID, 3, 200, "") + if err != nil { + t.Fatal(err) + } + wantTrees[c.ID] = tree + expected := []Ref{} + for _, row := range wantMembers { + if row[1] == c.ID { + expected = append(expected, Ref{Kind: Kind(row[2].(string)), ID: row[3].(string), SessionID: row[4].(string), Factory: row[7].(string)}) + } + } + if !reflect.DeepEqual(refs, expected) { + t.Fatalf("read refs: %v, want %v", refs, expected) + } + } + if version >= 3 { + parents, err := s.CollectionsFor(ctx, Ref{Kind: FactoryJobKind, ID: "7", Factory: "factory-a"}) + if err != nil || !reflect.DeepEqual(parents, []Collection{{ID: "root", Name: "Root"}, {ID: "former", Name: "Former owner"}}) { + t.Fatalf("shared job: %v, %v", parents, err) + } + } + if userVersion(t, s) != version || !reflect.DeepEqual(schemaSevenFile(t, path), before) { + t.Fatal("open/read modified predecessor") + } + // Renaming to the existing name is still a successful public write and + // lets us compare every original row without editing its expected values. + if err := s.Rename(ctx, "root", "Root"); err != nil { + t.Fatal(err) + } + if userVersion(t, s) != 7 { + t.Fatal("first write did not install schema 7") + } + if got := schemaSevenQuery(t, s.db, "SELECT seq,id,name,created_at FROM collections ORDER BY seq"); !reflect.DeepEqual(got, wantCollections) { + t.Fatalf("collections changed: %v", got) + } + if got := schemaSevenQuery(t, s.db, "SELECT seq,collection_id,kind,ref_id,session_id,target_collection,added_at,factory FROM memberships ORDER BY seq"); !reflect.DeepEqual(got, wantMembers) { + t.Fatalf("memberships changed: %v", got) + } + if got := schemaSevenSequences(t, s); !reflect.DeepEqual(got, wantSequences) { + t.Fatalf("high-water marks changed: %v", got) + } + if got, err := s.List(ctx, nil); err != nil || !reflect.DeepEqual(got, summaries) { + t.Fatalf("summaries changed: %v, %v", got, err) + } + for _, c := range collections { + if got, err := s.Members(ctx, c.ID); err != nil || !reflect.DeepEqual(got, wantRefs[c.ID]) { + t.Fatalf("refs changed: %v, %v", got, err) + } + if got, err := s.Tree(ctx, c.ID, 3, 200, ""); err != nil || !reflect.DeepEqual(got, wantTrees[c.ID]) { + t.Fatalf("tree changed: %v, %v", got, err) + } + } + fresh := openTestStore(t, filepath.Join(t.TempDir(), "fresh.db")) + if err := fresh.ensureSchema(ctx); err != nil { + t.Fatal(err) + } + if userVersion(t, fresh) != 7 { + t.Fatal("blank file did not initialize as schema 7") + } + if got, want := schemaSevenSchema(t, s), schemaSevenSchema(t, fresh); !reflect.DeepEqual(got, want) { + t.Fatalf("migration left legacy objects or wrong schema: %v; want %v", got, want) + } + schema := schemaSevenSchema(t, s) + var schemaCookie int + if err := s.db.QueryRow("PRAGMA schema_version").Scan(&schemaCookie); err != nil { + t.Fatal(err) + } + if err := s.Rename(ctx, "root", "Root again"); err != nil { + t.Fatal(err) + } + var afterCookie int + if err := s.db.QueryRow("PRAGMA schema_version").Scan(&afterCookie); err != nil { + t.Fatal(err) + } + if got := schemaSevenSchema(t, s); !reflect.DeepEqual(got, schema) || afterCookie != schemaCookie { + t.Fatal("second write ran DDL") + } + // IF EXISTS makes the predecessor cleanup safe when repeated or partially absent. + if _, err := s.db.Exec(schemaSevenCleanup); err != nil { + t.Fatal(err) + } + if got := schemaSevenSchema(t, s); !reflect.DeepEqual(got, schema) { + t.Fatal("repeated cleanup changed schema") + } + if _, err := s.Delete(ctx, "former"); err != nil { + t.Fatalf("former owner is not deletable: %v", err) + } + c, err := s.Create(ctx, "After") + if err != nil { + t.Fatal(err) + } + if err := s.Add(ctx, c.ID, Ref{Kind: FactoryJobKind, ID: "7", Factory: "factory-a"}); err != nil { + t.Fatal(err) + } + var collectionSeq, memberSeq int + if err := s.db.QueryRow("SELECT seq FROM collections WHERE id=?", c.ID).Scan(&collectionSeq); err != nil { + t.Fatal(err) + } + if err := s.db.QueryRow("SELECT seq FROM memberships WHERE collection_id=?", c.ID).Scan(&memberSeq); err != nil { + t.Fatal(err) + } + if collectionSeq <= 100 || memberSeq <= 200 { + t.Fatalf("reused retired sequences: %d, %d", collectionSeq, memberSeq) + } + }) + } +} + +// Contract (c): a failed requested write rolls back DDL, data and version bytes. +func TestSchemaSevenFailedFirstWriteLeavesPredecessorUntouched(t *testing.T) { + for _, version := range []int{2, 6} { + t.Run(fmt.Sprint(version), func(t *testing.T) { + path := schemaSevenFixture(t, version) + before := schemaSevenFile(t, path) + s := openTestStore(t, path) + schema := schemaSevenSchema(t, s) + if err := s.Rename(context.Background(), "root", "Nested"); err != ErrNameTaken { + t.Fatalf("expected name conflict: %v", err) + } + if userVersion(t, s) != version || !reflect.DeepEqual(schemaSevenFile(t, path), before) || !reflect.DeepEqual(schemaSevenSchema(t, s), schema) { + t.Fatal("failed write migrated predecessor") + } + }) + } +} + +// Contract (c): future versions and foreign application IDs are refused without +// modifying the file, including a file that changed after this handle opened. +func TestSchemaSevenRefusesUnknownDatabases(t *testing.T) { + for _, pragma := range []string{"PRAGMA user_version=8", "PRAGMA application_id=12345"} { + t.Run(pragma, func(t *testing.T) { + path := schemaSevenFixture(t, 6) + s := openTestStore(t, path) + if _, err := s.db.Exec(pragma); err != nil { + t.Fatal(err) + } + before := schemaSevenFile(t, path) + opened, err := Open(path) + if opened != nil { + opened.Close() + } + if err == nil || !strings.Contains(err.Error(), "unsupported collections database") { + t.Fatalf("open accepted unknown database: %v", err) + } + if _, err := s.Collections(context.Background()); err == nil || !strings.Contains(err.Error(), "unsupported collections database") { + t.Fatalf("read accepted unknown database: %v", err) + } + if err := s.Rename(context.Background(), "root", "Changed"); err == nil || !strings.Contains(err.Error(), "unsupported collections database") { + t.Fatalf("write accepted unknown database: %v", err) + } + if !reflect.DeepEqual(schemaSevenFile(t, path), before) { + t.Fatal("refusal changed file") + } + }) + } +} + +// Contract (c): schemas 3–6 are recognized by the generic tables alone. +func TestSchemaSevenReadsCleanPredecessorShapes(t *testing.T) { + for _, version := range []int{3, 4, 5, 6} { + t.Run(fmt.Sprint(version), func(t *testing.T) { + path := filepath.Join(t.TempDir(), "clean.db") + db, err := sql.Open("sqlite", path) + if err != nil { + t.Fatal(err) + } + if _, err := db.Exec(schemaSevenCoreDDL + schemaSevenRows + fmt.Sprintf("PRAGMA application_id=%d; PRAGMA user_version=%d;", applicationID, version)); err != nil { + t.Fatal(err) + } + if err := db.Close(); err != nil { + t.Fatal(err) + } + before := schemaSevenFile(t, path) + s := openTestStore(t, path) + if got, err := s.Members(context.Background(), "root"); err != nil || len(got) != 3 { + t.Fatalf("clean predecessor: %v, %v", got, err) + } + if !reflect.DeepEqual(schemaSevenFile(t, path), before) { + t.Fatal("read changed clean predecessor") + } + if err := s.Rename(context.Background(), "root", "Root"); err != nil { + t.Fatal(err) + } + if userVersion(t, s) != 7 { + t.Fatal("clean predecessor not upgraded") + } + }) + } +} diff --git a/internal/workspace/store.go b/internal/workspace/store.go index 6319953420..5c28fe4372 100644 --- a/internal/workspace/store.go +++ b/internal/workspace/store.go @@ -20,13 +20,10 @@ import ( sqlite3 "modernc.org/sqlite/lib" ) -// 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. +// schemaVersion is the schema this build writes. Earlier schemas remain +// readable without migration; the first successful write upgrades them. const ( - schemaVersion = 2 + schemaVersion = 7 legacySchemaVersion = 1 ) const applicationID = 0x4146434c // AFCL distinguishes this store from optional memory. @@ -40,19 +37,17 @@ const busyTimeout = 10 * time.Second type schemaState uint8 const ( - schemaBlank schemaState = iota - schemaReady - schemaLegacy - schemaRefused + schemaBlank schemaState = 0 + schemaLegacy schemaState = 1 + schemaTwo schemaState = 2 + schemaReady schemaState = schemaVersion + schemaRefused schemaState = schemaVersion + 1 ) -// version is the schema a state reads through: 0 for a blank file. +// version is the schema a state reads through: 0 for a blank or refused file. func (s schemaState) version() int { - switch s { - case schemaReady: - return schemaVersion - case schemaLegacy: - return legacySchemaVersion + if s < schemaRefused { + return int(s) } return 0 } @@ -66,8 +61,8 @@ func (s schemaState) version() int { // lock to do it — which is what put a plain read behind whichever peer happened // 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. +// from both answering it. The same law covers migration: older supported files +// are read as they stand and upgraded only by the first write. type Store struct { db *sql.DB schemaMu sync.Mutex @@ -161,7 +156,7 @@ func (s *Store) snapshotSchema(ctx context.Context) (schemaState, error) { } // inspectSchema decides whether the database on the other end of q is ours and -// built (at this schema or the legacy one), ours and still blank, or somebody +// built at a supported schema, 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 { @@ -174,17 +169,18 @@ 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 || version == legacySchemaVersion) { - state := schemaReady + if app == applicationID && version >= legacySchemaVersion && version <= schemaVersion { + state := schemaState(version) 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 >= 3 { + memberships = "SELECT seq,collection_id,kind,ref_id,session_id,target_collection,added_at,factory 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. + // Only the generic tables define a supported file's readable shape. if _, err := q.ExecContext(ctx, collections); err != nil { return state, err } @@ -205,7 +201,7 @@ 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 +// schemaTables builds the collection 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 @@ -222,25 +218,27 @@ CREATE TABLE %[1]s ( 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')), + kind TEXT NOT NULL CHECK(kind IN ('collection','conversation','task','standing','artifact','crew','process','factory_job')), 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, + factory TEXT NOT NULL DEFAULT '', + CHECK ((kind='factory_job' AND length(factory)>0) OR (kind!='factory_job' AND factory='')), 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) + UNIQUE(collection_id,kind,ref_id,session_id,factory) ); `, collections, memberships) } const schemaIndexes = ` -CREATE INDEX memberships_reference ON memberships(kind,ref_id,session_id); +CREATE INDEX memberships_reference ON memberships(kind,ref_id,session_id,factory); 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 +// migrateLegacy rebuilds a schema-1 or schema-2 file in the current generic +// shape inside the caller's immediate transaction: every row arrives 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. @@ -248,68 +246,116 @@ CREATE INDEX memberships_target ON memberships(target_collection); // 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; +// carries the foreign keys that name it along. Sequence high-water marks also +// survive deleted rows so migration cannot reuse a sequence named by an older +// page cursor. +func migrateLegacy(ctx context.Context, tx *sql.Tx, version int) error { + created, added := "0", "0" + if version >= 2 { + created, added = "created_at", "added_at" + } + _, err := tx.ExecContext(ctx, schemaTables("collections_v3", "memberships_v3")+fmt.Sprintf(` +INSERT INTO collections_v3(seq,id,name,created_at) SELECT seq,id,name,%s FROM collections ORDER BY seq; +INSERT INTO memberships_v3(seq,collection_id,kind,ref_id,session_id,target_collection,added_at) + SELECT seq,collection_id,kind,ref_id,session_id,target_collection,%s FROM memberships ORDER BY seq; +UPDATE sqlite_sequence SET seq=max(seq,coalesce((SELECT seq FROM sqlite_sequence WHERE name='collections'),0)) + WHERE name='collections_v3'; +INSERT INTO sqlite_sequence(name,seq) SELECT 'collections_v3',seq FROM sqlite_sequence + WHERE name='collections' AND NOT EXISTS(SELECT 1 FROM sqlite_sequence WHERE name='collections_v3'); +UPDATE sqlite_sequence SET seq=max(seq,coalesce((SELECT seq FROM sqlite_sequence WHERE name='memberships'),0)) + WHERE name='memberships_v3'; +INSERT INTO sqlite_sequence(name,seq) SELECT 'memberships_v3',seq FROM sqlite_sequence + WHERE name='memberships' AND NOT EXISTS(SELECT 1 FROM sqlite_sequence WHERE name='memberships_v3'); DROP TABLE memberships; DROP TABLE collections; -ALTER TABLE collections_v2 RENAME TO collections; -ALTER TABLE memberships_v2 RENAME TO memberships; -`+schemaIndexes) +ALTER TABLE collections_v3 RENAME TO collections; +ALTER TABLE memberships_v3 RENAME TO memberships; +`, created, added)+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, -// 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.version == schemaVersion { - return nil +// beginWrite ensures schema and data changes share one immediate transaction. +// A failed first write rolls back the migration as well as the requested change. +func (s *Store) beginWrite(ctx context.Context, create bool) (*sql.Tx, error) { + if !create { + version, err := s.readyForRead(ctx) + if err != nil { + return nil, err + } + if version == 0 { + return nil, ErrNotFound + } } tx, err := s.db.BeginTx(ctx, nil) if err != nil { - return storeError(err) + return nil, storeError(err) } - defer tx.Rollback() state, err := inspectSchema(ctx, tx) + if err == nil { + switch { + case state == schemaReady: + // A current file needs no DDL. + case state == schemaLegacy || state == schemaTwo: + err = migrateLegacy(ctx, tx, state.version()) + case state >= 3 && state < schemaReady: + _, err = tx.ExecContext(ctx, schemaSevenCleanup) + case state == schemaBlank: + if !create { + err = ErrNotFound + } else { + _, err = tx.ExecContext(ctx, schemaTables("collections", "memberships")+schemaIndexes) + } + } + } + if err == nil && state != schemaReady { + _, err = tx.ExecContext(ctx, fmt.Sprintf("PRAGMA application_id=%d; PRAGMA user_version=%d", applicationID, schemaVersion)) + } if err != nil { - return storeError(err) + _ = tx.Rollback() + return nil, storeError(err) } - switch state { - case schemaReady: - if err := tx.Commit(); err != nil { - return storeError(err) - } - s.version = schemaVersion - return nil - case schemaLegacy: - err = migrateLegacy(ctx, tx) - default: - _, err = tx.ExecContext(ctx, schemaTables("collections", "memberships")+schemaIndexes) + return tx, nil +} + +// ensureSchema is used by bulk fixtures that initialize without adding rows. +func (s *Store) ensureSchema(ctx context.Context) error { + tx, err := s.beginWrite(ctx, true) + if err != nil { + return err } + defer tx.Rollback() + return storeError(tx.Commit()) +} + +// Read runs fn against a consistent read-only snapshot. Version 0 means blank. +// Reads never initialize or migrate the database. +func (s *Store) Read(ctx context.Context, fn func(tx *sql.Tx, version int) error) error { + tx, version, err := s.beginRead(ctx) if err != nil { - return storeError(err) + return err } - if _, err := tx.ExecContext(ctx, fmt.Sprintf("PRAGMA application_id=%d; PRAGMA user_version=%d", applicationID, schemaVersion)); err != nil { - return storeError(err) + defer tx.Rollback() + return storeError(fn(tx, version)) +} + +// beginRead keeps the schema decision and projected rows in one snapshot. A +// peer may migrate between separate transactions, adding fields an old projection +// cannot supply (notably factory on a new job ref). +func (s *Store) beginRead(ctx context.Context) (*sql.Tx, int, error) { + tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) + if err != nil { + return nil, 0, storeError(err) } - if err := tx.Commit(); err != nil { - return storeError(err) + state, err := inspectSchema(ctx, tx) + if err != nil { + _ = tx.Rollback() + return nil, 0, storeError(err) } - s.version = schemaVersion - return nil + return tx, state.version(), nil } // 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 +// file. A handle that saw a blank or an older schema 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) { @@ -329,26 +375,10 @@ func (s *Store) readyForRead(ctx context.Context) (int, error) { 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 { + if version >= 2 { return "c.created_at", "m.added_at" } return "0", "0" @@ -409,10 +439,7 @@ func (s *Store) Create(ctx context.Context, name string) (Collection, error) { if err != nil { return Collection{}, storeError(err) } - if err := s.ensureSchema(ctx); err != nil { - return Collection{}, storeError(err) - } - tx, err := s.db.BeginTx(ctx, nil) + tx, err := s.beginWrite(ctx, true) if err != nil { return Collection{}, storeError(err) } @@ -434,14 +461,7 @@ func (s *Store) Rename(ctx context.Context, id, name string) error { if err := ValidateName(name); err != nil { return storeError(err) } - ready, err := s.readyForWrite(ctx) - if err != nil { - return storeError(err) - } - if !ready { - return storeError(ErrNotFound) - } - tx, err := s.db.BeginTx(ctx, nil) + tx, err := s.beginWrite(ctx, false) if err != nil { return storeError(err) } @@ -463,14 +483,7 @@ func (s *Store) Rename(ctx context.Context, id, name string) error { // 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) + tx, err := s.beginWrite(ctx, false) if err != nil { return nil, storeError(err) } @@ -511,18 +524,11 @@ func (s *Store) Duplicate(ctx context.Context, id, name string) (Collection, err 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) + tx, err := s.beginWrite(ctx, false) if err != nil { return Collection{}, storeError(err) } @@ -536,8 +542,8 @@ func (s *Store) Duplicate(ctx context.Context, id, name string) (Collection, 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 { + if _, err := tx.ExecContext(ctx, `INSERT INTO memberships(collection_id,kind,ref_id,session_id,target_collection,added_at,factory) + SELECT ?,kind,ref_id,session_id,target_collection,added_at,factory FROM memberships WHERE collection_id=? ORDER BY seq`, copyID, id); err != nil { return Collection{}, storeError(err) } if err := tx.Commit(); err != nil { @@ -547,20 +553,23 @@ func (s *Store) Duplicate(ctx context.Context, id, name string) (Collection, err } 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") + out := make([]Collection, 0) + err := s.Read(ctx, func(tx *sql.Tx, version int) error { + if version == 0 { + return nil + } + rows, err := tx.QueryContext(ctx, "SELECT c.id,c.name FROM collections c ORDER BY seq") + if err != nil { + return err + } + defer rows.Close() + out, err = readCollections(rows) + return err + }) if err != nil { - return nil, storeError(err) + return nil, err } - defer rows.Close() - result, err := readCollections(rows) - return result, storeError(err) + return out, nil } func readCollections(rows *sql.Rows) ([]Collection, error) { @@ -610,14 +619,7 @@ func (s *Store) AddRefs(ctx context.Context, id string, refs []Addition) ([]Memb return nil, nil, storeError(fmt.Errorf("%w: added_at cannot be negative", ErrInvalid)) } } - ready, err := s.readyForWrite(ctx) - if err != nil { - return nil, nil, storeError(err) - } - if !ready { - return nil, nil, storeError(ErrNotFound) - } - tx, err := s.db.BeginTx(ctx, nil) + tx, err := s.beginWrite(ctx, false) if err != nil { return nil, nil, storeError(err) } @@ -625,6 +627,7 @@ func (s *Store) AddRefs(ctx context.Context, id string, refs []Addition) ([]Memb if err := requireCollection(ctx, tx, id); err != nil { return nil, nil, storeError(err) } + now := s.millis() added, already := make([]Member, 0, len(refs)), make([]Ref, 0) for _, ref := range refs { @@ -639,8 +642,8 @@ func (s *Store) AddRefs(ctx context.Context, id string, refs []Addition) ([]Memb 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) + result, err := tx.ExecContext(ctx, `INSERT INTO memberships(collection_id,kind,ref_id,session_id,target_collection,added_at,factory) + VALUES (?,?,?,?,?,?,?) ON CONFLICT(collection_id,kind,ref_id,session_id,factory) DO NOTHING`, id, ref.Kind, ref.ID, ref.SessionID, target, at, ref.Factory) if err != nil { return nil, nil, storeError(err) } @@ -701,14 +704,7 @@ func (s *Store) Relink(ctx context.Context, id string, from, to Ref) (Member, bo 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) + tx, err := s.beginWrite(ctx, false) if err != nil { return Member{}, false, storeError(err) } @@ -717,8 +713,8 @@ func (s *Store) Relink(ctx context.Context, id string, from, to Ref) (Member, bo 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) + err = tx.QueryRowContext(ctx, "SELECT seq,added_at FROM memberships WHERE collection_id=? AND kind=? AND ref_id=? AND session_id=? AND factory=?", + id, ref.Kind, ref.ID, ref.SessionID, ref.Factory).Scan(&seq, &at) return seq, at, err } seq, at, err := membership(from) @@ -749,8 +745,8 @@ func (s *Store) Relink(ctx context.Context, id string, from, to Ref) (Member, bo } 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 { + if _, err := tx.ExecContext(ctx, "UPDATE memberships SET kind=?,ref_id=?,session_id=?,target_collection=?,factory=? WHERE seq=?", + to.Kind, to.ID, to.SessionID, target, to.Factory, seq); err != nil { return Member{}, false, storeError(err) } if err := tx.Commit(); err != nil { @@ -774,14 +770,7 @@ func (s *Store) RemoveRefs(ctx context.Context, id string, refs []Ref) ([]Member return nil, storeError(err) } } - 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) + tx, err := s.beginWrite(ctx, false) if err != nil { return nil, storeError(err) } @@ -792,8 +781,8 @@ func (s *Store) RemoveRefs(ctx context.Context, id string, refs []Ref) ([]Member 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) + err := tx.QueryRowContext(ctx, "DELETE FROM memberships WHERE collection_id=? AND kind=? AND ref_id=? AND session_id=? AND factory=? RETURNING added_at", + id, ref.Kind, ref.ID, ref.SessionID, ref.Factory).Scan(&at) if errors.Is(err, sql.ErrNoRows) { continue } @@ -809,55 +798,56 @@ func (s *Store) RemoveRefs(ctx context.Context, id string, refs []Ref) ([]Member } func (s *Store) Members(ctx context.Context, id string) ([]Ref, error) { - version, err := s.readyForRead(ctx) - if err != nil { - return nil, storeError(err) - } - if version == 0 { - return nil, storeError(ErrNotFound) - } - var exists bool - if err := s.db.QueryRowContext(ctx, "SELECT EXISTS(SELECT 1 FROM collections WHERE id=?)", id).Scan(&exists); err != nil { - return nil, storeError(err) - } - if !exists { - return nil, storeError(ErrNotFound) - } - rows, err := s.db.QueryContext(ctx, "SELECT kind,ref_id,session_id FROM memberships WHERE collection_id=? ORDER BY seq", id) - if err != nil { - return nil, storeError(err) - } - defer rows.Close() - result := make([]Ref, 0) - for rows.Next() { - var ref Ref - if err := rows.Scan(&ref.Kind, &ref.ID, &ref.SessionID); err != nil { - return nil, storeError(err) + out := make([]Ref, 0) + err := s.Read(ctx, func(tx *sql.Tx, version int) error { + if version == 0 { + return ErrNotFound } - result = append(result, ref) + if err := requireCollection(ctx, tx, id); err != nil { + return err + } + rows, err := tx.QueryContext(ctx, "SELECT kind,ref_id,session_id,"+factoryColumn(version)+" FROM memberships WHERE collection_id=? ORDER BY seq", id) + if err != nil { + return err + } + defer rows.Close() + for rows.Next() { + var ref Ref + if err := rows.Scan(&ref.Kind, &ref.ID, &ref.SessionID, &ref.Factory); err != nil { + return err + } + out = append(out, ref) + } + return rows.Err() + }) + if err != nil { + return nil, err } - return result, storeError(rows.Err()) + return out, nil } func (s *Store) CollectionsFor(ctx context.Context, ref Ref) ([]Collection, error) { if err := ref.Validate(); err != nil { return nil, storeError(err) } - 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 c.id,c.name FROM collections c JOIN memberships m ON m.collection_id=c.id - WHERE m.kind=? AND m.ref_id=? AND m.session_id=? ORDER BY c.seq`, ref.Kind, ref.ID, ref.SessionID) + out := make([]Collection, 0) + err := s.Read(ctx, func(tx *sql.Tx, version int) error { + if version == 0 { + return nil + } + rows, err := tx.QueryContext(ctx, `SELECT c.id,c.name FROM collections c JOIN memberships m ON m.collection_id=c.id + WHERE m.kind=? AND m.ref_id=? AND m.session_id=? AND `+factoryColumn(version)+`=? ORDER BY c.seq`, ref.Kind, ref.ID, ref.SessionID, ref.Factory) + if err != nil { + return err + } + defer rows.Close() + out, err = readCollections(rows) + return err + }) if err != nil { - return nil, storeError(err) + return nil, err } - defer rows.Close() - result, err := readCollections(rows) - return result, storeError(err) + return out, nil } // storeError replaces SQLite's lock account with the stable sentence exposed by @@ -942,3 +932,10 @@ func pathReason(err error) error { } return err } + +func factoryColumn(version int) string { + if version >= 3 { + return "factory" + } + return "''" +} diff --git a/internal/workspace/views.go b/internal/workspace/views.go index 1f235ad2c8..3ca1c469c8 100644 --- a/internal/workspace/views.go +++ b/internal/workspace/views.go @@ -136,18 +136,14 @@ func (s *Store) ListPage(ctx context.Context, holds *Ref, after string, limit in } from = c.S } - version, err := s.readyForRead(ctx) + tx, version, err := s.beginRead(ctx) if err != nil { return Page{}, storeError(err) } + defer tx.Rollback() if version == 0 { return Page{Collections: make([]Summary, 0)}, nil } - tx, err := s.db.BeginTx(ctx, &sql.TxOptions{ReadOnly: true}) - if err != nil { - return Page{}, storeError(err) - } - defer tx.Rollback() var page Page if err := tx.QueryRowContext(ctx, "SELECT count(*) FROM collections").Scan(&page.Total); err != nil { return Page{}, storeError(err) @@ -183,18 +179,14 @@ func (s *Store) List(ctx context.Context, holds *Ref) ([]Summary, error) { // Describe is one collection as List says it. func (s *Store) Describe(ctx context.Context, id string) (Summary, error) { - version, err := s.readyForRead(ctx) + tx, version, err := s.beginRead(ctx) if err != nil { return Summary{}, storeError(err) } + defer tx.Rollback() 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, 0, 1) if err != nil { return Summary{}, storeError(err) @@ -212,8 +204,8 @@ func summaries(ctx context.Context, tx *sql.Tx, version int, holds *Ref, only st 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) + held = "EXISTS(SELECT 1 FROM memberships h WHERE h.collection_id=c.id AND h.kind=? AND h.ref_id=? AND h.session_id=? AND " + factoryColumn(version) + "=?)" + args = append(args, holds.Kind, holds.ID, holds.SessionID, holds.Factory) } var where []string if only != "" { @@ -320,18 +312,14 @@ func (s *Store) Tree(ctx context.Context, id string, depth, limit int, after str } start = &levelAfter{added: c.A, seq: c.S} } - version, err := s.readyForRead(ctx) + tx, version, err := s.beginRead(ctx) if err != nil { return Tree{}, storeError(err) } + defer tx.Rollback() 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, 0, 1) if err != nil { return Tree{}, storeError(err) @@ -415,14 +403,14 @@ func levelOf(ctx context.Context, tx *sql.Tx, version int, id string, limit int, // 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 { + if version >= 2 { order = "m.added_at DESC, m.seq DESC" } 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, + rows, err := tx.QueryContext(ctx, fmt.Sprintf(`SELECT m.seq,m.kind,m.ref_id,m.session_id,%s,%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=?%s ORDER BY %s LIMIT ?`, added, from, order), args...) +WHERE m.collection_id=?%s ORDER BY %s LIMIT ?`, added, factoryColumn(version), from, order), args...) if err != nil { return nil, false, err } @@ -434,7 +422,7 @@ WHERE m.collection_id=?%s ORDER BY %s LIMIT ?`, added, from, order), args...) break } var n Node - if err := rows.Scan(&n.seq, &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.Factory, &n.Name, &n.Found, &n.Count); err != nil { return nil, false, err } nodes = append(nodes, n) @@ -467,18 +455,14 @@ type Expansion struct { // 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) + tx, version, err := s.beginRead(ctx) if err != nil { return Expansion{}, storeError(err) } + defer tx.Rollback() 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, 0, 1) if err != nil { return Expansion{}, storeError(err) diff --git a/internal/workspace/workspace.go b/internal/workspace/workspace.go index 647c004f46..0414537657 100644 --- a/internal/workspace/workspace.go +++ b/internal/workspace/workspace.go @@ -22,8 +22,9 @@ const ( // 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" + CrewKind Kind = "crew" + ProcessKind Kind = "process" + FactoryJobKind Kind = "factory_job" // 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 @@ -57,6 +58,7 @@ type Ref struct { Kind Kind `json:"kind"` ID string `json:"id"` SessionID string `json:"session_id,omitempty"` + Factory string `json:"factory,omitempty"` } type Collection struct { @@ -71,12 +73,17 @@ type Member struct { 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 } +// key includes the factory because job numbers are local to one factory. +func (r Ref) key() string { + return string(r.Kind) + "\x00" + r.ID + "\x00" + r.SessionID + "\x00" + r.Factory +} // 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 r.Kind != FactoryJobKind && r.Factory != "" { + return fmt.Errorf("%w: only a factory_job reference has a factory", ErrInvalid) + } if !validText(r.ID, 4096) { return fmt.Errorf("%w: record id is empty, too long or contains control characters", ErrInvalid) } @@ -84,6 +91,11 @@ func (r Ref) Validate() error { return fmt.Errorf("%w: only a task or a process reference has a session id", ErrInvalid) } switch r.Kind { + case FactoryJobKind: + n, err := strconv.ParseUint(r.ID, 10, 63) + if err != nil || n == 0 || strconv.FormatUint(n, 10) != r.ID || !validText(r.Factory, 4096) { + return fmt.Errorf("%w: a factory_job needs a factory and a positive canonical job number", ErrInvalid) + } case CollectionKind, ConversationKind, StandingKind, CrewKind: case ProcessKind: if !validText(r.SessionID, 1024) {