diff --git a/cmd/codeaf/app.go b/cmd/codeaf/app.go index 69eaacf17f..54e4d9cfa0 100644 --- a/cmd/codeaf/app.go +++ b/cmd/codeaf/app.go @@ -37,6 +37,7 @@ import ( "net/http" "os" "path/filepath" + "reflect" "slices" "sort" "strconv" @@ -58,6 +59,7 @@ import ( "github.com/Agent-Field/codeaf/internal/home" "github.com/Agent-Field/codeaf/internal/leave" "github.com/Agent-Field/codeaf/internal/modelsource" + "github.com/Agent-Field/codeaf/internal/orchestrate" "github.com/Agent-Field/codeaf/internal/provider" "github.com/Agent-Field/codeaf/internal/redact" "github.com/Agent-Field/codeaf/internal/remote" @@ -663,6 +665,9 @@ type appBackend struct { // providerWarm closes when the latest fetch of the providers' model lists // is over ([appBackend.warmProviderModels]); models.list waits a moment for it. providerWarm chan struct{} + // stopModelNotices removes the model completion subscription and joins + // the initial catalog waiter and cancellable sender when this app closes. + stopModelNotices func() // state is the app's own file: setup, its settings, budgets and raises. state *appState // dayAuth is the daily limit's one-time hand-over from this file's old cap. @@ -2003,18 +2008,20 @@ func (b *appBackend) questionEvent(a *appAgent, id string, live *appLive, event } ask := appAskOf(id, q) appAskFacts(id, &ask, q) - defer b.restateBatch(live, q) - restated, moved := false, false + restated, changed, moved := false, false, false var view appwire.Agent b.locked(func() { ask.HoldingUp = askHoldingUpLocked(live, ask) if _, open := live.questions[askID]; open { - // The lane replays what is open when it is subscribed again, - // and a question restated in place is the same ask. + // Identical subscription replays say nothing new. A changed + // question, such as a held clock, restates the same ask id. restated = true live.questions[askID] = q + ask.Batch, _ = b.batchLocked(live, q) for i := range live.asks { if live.asks[i].ID == askID { + ask.Later = live.asks[i].Later + changed = !reflect.DeepEqual(live.asks[i], ask) live.asks[i] = ask } } @@ -2024,13 +2031,14 @@ func (b *appBackend) questionEvent(a *appAgent, id string, live *appLive, event live.asks = append(live.asks, ask) view, moved = b.restateLocked(a, live) }) - if restated { + if restated && !changed { return } live.box.Event(appwire.EventAskOpened, appwire.AskOpened{Ask: ask}) if moved { live.box.Event(appwire.EventAgentChanged, appwire.AgentChanged{Agent: view}) } + b.restateBatch(live, q) case session.EventQuestionAnswered, session.EventQuestionWithdrawn: open, moved := false, false by, option := appwire.ClosedByElsewhere, "" @@ -3209,18 +3217,9 @@ func (b *appBackend) answerAskScope(id, askID, option, text, scope string) error refused = appwire.Errorf(appwire.CodeNotFound, "the ask %s is not open; it may have been answered elsewhere", askID) return } - if option != "" { - picked, offered := appOptionNamed(q, option) - if !offered { - refused = appwire.Errorf(appwire.CodeBadParams, "the ask %s offers no option %q", askID, option) - return - } - key = picked.key - } - if q.Kind == session.QuestionConsent && option == appwire.OptionAlways { - if refused = appValidateScope(option, scope, q.ConsentScopes); refused != nil { - return - } + key, refused = appValidateQuestionAnswer(askID, q, option, text, scope) + if refused != nil { + return } live.answered[askID] = appAnsweredMark + option live.lastUse = b.now() @@ -3253,6 +3252,57 @@ func (b *appBackend) answerAskScope(id, askID, option, text, scope string) error return nil } +// appValidateQuestionAnswer is shared by immediate answers and Inbox preflight. +// Words alone remain valid for model questions and task redirects. +func appValidateQuestionAnswer(id string, q session.Question, option, text, scope string) (string, error) { + key := "" + if option != "" { + picked, offered := appOptionNamed(q, option) + if !offered { + return "", appwire.Errorf(appwire.CodeBadParams, "the ask %s offers no option %q", id, option) + } + key = picked.key + } else { + required := false + switch q.Kind { + case session.QuestionConsent, session.QuestionKeep, session.QuestionLanding: + required = true + case session.QuestionTask, session.QuestionFactory, session.QuestionRecipe, session.QuestionItem, session.QuestionAutomation: + required = strings.TrimSpace(text) == "" + } + if required { + return "", appwire.Errorf(appwire.CodeBadParams, "the ask %s needs an option", id) + } + } + switch q.Kind { + case session.QuestionConsent: + if err := appValidateScope(option, scope, q.ConsentScopes); err != nil { + return "", err + } + case session.QuestionLanding, session.QuestionConflict: + if key == session.LandingTellKey && strings.TrimSpace(text) == "" { + return "", appwire.Errorf(appwire.CodeBadParams, "the ask %s needs words for Tell", id) + } + case session.QuestionFuel: + answer := strings.ToLower(strings.TrimSpace(text)) + switch key { + case "1": + answer = orchestrate.GateTopup + ":" + answer + case "2": + answer = orchestrate.GateFinish + case "3": + answer = orchestrate.GateStop + } + if answer != orchestrate.GateFinish && answer != orchestrate.GateStop { + amount, topup := strings.CutPrefix(answer, orchestrate.GateTopup+":") + if _, valid := appParseUSD(amount); !topup || !valid { + return "", appwire.Errorf(appwire.CodeBadParams, "the ask %s needs a positive top-up amount, finish or stop", id) + } + } + } + return key, nil +} + // appValidateScope keeps both answer doors on the permission's offered rules. func appValidateScope(option, scope string, scopes []string) error { if option == appwire.OptionAlways && (scope == "" || !slices.Contains(scopes, scope)) { @@ -3858,6 +3908,9 @@ func (b *appBackend) process() (*v3Process, error) { // opened — the keys a launch hands over, a setup finished on its first screen // — would otherwise offer nothing in models.list until the next fetch. func (b *appBackend) markAssembled() { + if proc := openedEngineProcess(); proc != nil { + b.watchModelCompletions(proc) + } if !b.assembled.Swap(true) { b.warmProviderModels() } @@ -4199,6 +4252,7 @@ func (b *appBackend) Close() error { var agents []*appAgent var warmed chan struct{} var balances *appBalances + var stopModelNotices func() b.locked(func() { if already = b.closing; already { return @@ -4215,6 +4269,7 @@ func (b *appBackend) Close() error { b.accountAuth.flow.Cancel() } crews, warmed, balances = b.crewRun, b.warmed, b.balances + stopModelNotices = b.stopModelNotices if b.signIn != nil { b.signIn.Cancel() b.signIn = nil @@ -4235,6 +4290,9 @@ func (b *appBackend) Close() error { b.stopSyncedSettings() } b.closeChats() + if stopModelNotices != nil { + stopModelNotices() + } if b.devices != nil { b.devices.close() } diff --git a/cmd/codeaf/appconvo.go b/cmd/codeaf/appconvo.go index d1f6cbaa16..0e88840fce 100644 --- a/cmd/codeaf/appconvo.go +++ b/cmd/codeaf/appconvo.go @@ -788,8 +788,8 @@ func (b *appBackend) AnswerAskWith(agent, id string, answer appwire.AskAnswer) e // appDecideMark marks an ask the person handed back to the agent's own pick. const appDecideMark = "decide:" -// HoldAsk stops an ask's clock. Only a proposed task has one: its hold is the -// task's own. +// HoldAsk stops an ask's clock through its owning lane. Proposed tasks keep +// their task action; the model's questions use the engine's question hold. func (b *appBackend) HoldAsk(agent, id string) error { a, err := b.findCrewOwner(agent) if err != nil { @@ -805,6 +805,16 @@ func (b *appBackend) HoldAsk(agent, id string) error { if !open { return appwire.Errorf(appwire.CodeNotFound, "the ask %s is not open", id) } + if q.Kind == session.QuestionAsk { + holder, ok := live.conn.Conv.(interface { + HoldQuestion(session.QuestionKind, string) + }) + if !ok { + return appwire.Errorf(appwire.CodeEngine, "this conversation cannot hold a question's clock") + } + holder.HoldQuestion(q.Kind, q.Token()) + return nil + } if q.Kind != session.QuestionTask || q.Deadline.IsZero() { return nil } diff --git a/cmd/codeaf/appcrewmembers.go b/cmd/codeaf/appcrewmembers.go index a4f4478fff..e7f943ddfc 100644 --- a/cmd/codeaf/appcrewmembers.go +++ b/cmd/codeaf/appcrewmembers.go @@ -702,6 +702,9 @@ func appParseProposal(answer string) (appwire.Proposal, bool) { // answerProposal is the person's answer to a proposal: make the crew, or keep // the agent alone. Edit is the app's editor, and leaves the ask open. func (b *appBackend) answerProposal(agent, id, option string) error { + if err := appValidateProposalAnswer(option); err != nil { + return err + } switch option { case appwire.ProposalCreate: _, err := b.CrewCreate(appwire.CrewCreate{From: "proposal", Proposal: id}) @@ -715,6 +718,14 @@ func (b *appBackend) answerProposal(agent, id, option string) error { return appwire.Errorf(appwire.CodeBadParams, "a proposal is answered create, edit or keep") } +func appValidateProposalAnswer(option string) error { + switch option { + case appwire.ProposalCreate, appwire.ProposalEdit, appwire.ProposalKeep: + return nil + } + return appwire.Errorf(appwire.CodeBadParams, "a proposal is answered create, edit or keep") +} + // dropProposal forgets a proposal and closes its ask. func (b *appBackend) dropProposal(id, option string) { _ = b.crewLayer().write(func(f *appCrewLayerFile) error { @@ -740,6 +751,9 @@ func (b *appBackend) dropProposal(id, option string) { // "Made the crew" here drew the card's end twice. A kept agent's receipt // says so in its own words. if option == appwire.ProposalCreate { + // The formation fact names the crew, so its receipt id cannot resolve + // the proposal's Inbox item through the usual receipt-id mapping. + b.inboxResolved(appInboxID(ask.Agent, ask.ID), appwire.Entry{}) return } b.emit.Emit(appwire.EventEntryAdded, appwire.EntryAdded{Entry: appwire.Entry{ diff --git a/cmd/codeaf/appcrewpropose_test.go b/cmd/codeaf/appcrewpropose_test.go index bf52844150..f9240beb68 100644 --- a/cmd/codeaf/appcrewpropose_test.go +++ b/cmd/codeaf/appcrewpropose_test.go @@ -462,6 +462,7 @@ func TestAppCrewProposalAtTheDayCapAsksAboutTheCap(t *testing.T) { // agent alone leaves "Kept as one agent · you ·