Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
92 changes: 75 additions & 17 deletions cmd/codeaf/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@ import (
"net/http"
"os"
"path/filepath"
"reflect"
"slices"
"sort"
"strconv"
Expand All @@ -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"
Expand Down Expand Up @@ -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.
Expand Down Expand Up @@ -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
}
}
Expand All @@ -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, ""
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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)) {
Expand Down Expand Up @@ -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()
}
Expand Down Expand Up @@ -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
Expand All @@ -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
Expand All @@ -4235,6 +4290,9 @@ func (b *appBackend) Close() error {
b.stopSyncedSettings()
}
b.closeChats()
if stopModelNotices != nil {
stopModelNotices()
}
if b.devices != nil {
b.devices.close()
}
Expand Down
14 changes: 12 additions & 2 deletions cmd/codeaf/appconvo.go
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -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
}
Expand Down
14 changes: 14 additions & 0 deletions cmd/codeaf/appcrewmembers.go
Original file line number Diff line number Diff line change
Expand Up @@ -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})
Expand All @@ -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 {
Expand All @@ -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{
Expand Down
24 changes: 23 additions & 1 deletion cmd/codeaf/appcrewpropose_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -462,6 +462,7 @@ func TestAppCrewProposalAtTheDayCapAsksAboutTheCap(t *testing.T) {
// agent alone leaves "Kept as one agent · you · <time>", and making the crew
// leaves the crew's formation fact alone — not a second "Made the crew" beside
// it.
// Contract 1: proposal answers settle the Inbox and chat exactly once.
func TestAppCrewProposalEndsInOneReceipt(t *testing.T) {
conv := &proposingConversation{fakeConversation: &fakeConversation{}, answer: helpersProposal}
backend, rec, agent := proposingBackend(t, conv)
Expand Down Expand Up @@ -495,6 +496,12 @@ func TestAppCrewProposalEndsInOneReceipt(t *testing.T) {
}

kept := opened()
if err := backend.AnswerAsk(agent.ID, kept.ID, appwire.ProposalEdit, ""); err != nil {
t.Fatal(err)
}
if waiting, counts, err := backend.ListInbox(appwire.InboxWaiting, ""); err != nil || len(waiting) != 1 || counts.Waiting != 1 {
t.Fatalf("Edit changed Waiting: %+v %+v %v", waiting, counts, err)
}
rec.mu.Lock()
mark := len(rec.events)
rec.mu.Unlock()
Expand All @@ -512,9 +519,24 @@ func TestAppCrewProposalEndsInOneReceipt(t *testing.T) {
// Made with its helpers edited away, so no start runs on behind the test:
// the receipt is what is asserted, and a start is the crew watch's own.
edits := &appwire.Proposal{Helpers: []appwire.ProposalHelper{}}
if _, err := backend.CrewCreate(appwire.CrewCreate{From: "proposal", Proposal: strings.TrimPrefix(made.ID, appProposalAskPrefix), Edits: edits}); err != nil {
create := appwire.CrewCreate{From: "proposal", Proposal: strings.TrimPrefix(made.ID, appProposalAskPrefix), Edits: edits}
crew, err := backend.CrewCreate(create)
if err != nil {
t.Fatal(err)
}
if asks, err := backend.ListAsks(agent.ID); err != nil || len(asks) != 0 {
t.Fatalf("asks after create: %+v %v", asks, err)
}
if waiting, counts, err := backend.ListInbox(appwire.InboxWaiting, ""); err != nil || len(waiting) != 0 || counts.Waiting != 0 {
t.Fatalf("Waiting after create: %+v %+v %v", waiting, counts, err)
}
if again, err := backend.CrewCreate(create); err != nil || again.ID != crew.ID {
t.Fatalf("retried create: %+v %v", again, err)
}
done, counts, err := backend.ListInbox(appwire.InboxDone, "")
if err != nil || len(done) != 2 || counts.DoneToday != 2 || done[0].ID != appInboxID(agent.ID, made.ID) || done[0].Answered == nil || done[0].Answered.Option != appwire.ProposalCreate {
t.Fatalf("Done after Keep and retried create: %+v %+v %v", done, counts, err)
}
got := receipts(mark)
if len(got) != 1 || got[0].Op == nil || got[0].Op.Kind != appwire.OpCrewFormed || got[0].Op.Name != "flaky" {
t.Fatalf("making the crew left %+v", got)
Expand Down
20 changes: 16 additions & 4 deletions cmd/codeaf/appcrews.go
Original file line number Diff line number Diff line change
Expand Up @@ -2011,10 +2011,11 @@ func (b *appBackend) CrewDecisions(id string, all bool) ([]appwire.Decision, err
// closes the crew it reports on.
func (b *appBackend) CrewDecide(packet, option, text, reason string) (appwire.Decision, error) {
profile := b.crewProfile()
decision := strings.TrimSpace(option)
if decision == "" {
decision = strings.TrimSpace(text)
} else if strings.TrimSpace(text) != "" && strings.TrimSpace(reason) == "" {
decision, err := appValidateCrewDecisionAnswer(option, text)
if err != nil {
return appwire.Decision{}, err
}
if strings.TrimSpace(option) != "" && strings.TrimSpace(text) != "" && strings.TrimSpace(reason) == "" {
reason = text
}
p, err := teamstore.Decide(profile, packet, teamstore.Person, decision, strings.TrimSpace(reason))
Expand Down Expand Up @@ -2044,6 +2045,17 @@ func (b *appBackend) CrewDecide(packet, option, text, reason string) (appwire.De
return appDecisionOf(f, p), nil
}

func appValidateCrewDecisionAnswer(option, text string) (string, error) {
decision := strings.TrimSpace(option)
if decision == "" {
decision = strings.TrimSpace(text)
}
if decision == "" {
return "", appwire.Errorf(appwire.CodeBadParams, "a decision needs an answer")
}
return decision, nil
}

// CrewManager makes a member the crew's manager, or clears it.
func (b *appBackend) CrewManager(id, agent string) (appwire.Crew, error) {
t, _, err := b.crewTeam(id)
Expand Down
Loading
Loading