Skip to content

Commit cf3074c

Browse files
AbirAbbasclaude
andcommitted
Merge app/v3 into workstreams/main
Brings the integration branch up to app/v3 (6 commits: the stored repository list served with a background refresh, step conditions read the change's files, step and My pipeline AI count as Factory AI, notes and the completion guard share one command outcome, opening a job doesn't count as a person's touch, a budget raise isn't refused by spend progress). No conflicts; the three files both sides touched (cmd/codeaf/app.go, internal/appwire/server.go, docs/design/app-v3/PROTOCOL.md) merged line-disjoint. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
2 parents e75aa1f + 0ef0e27 commit cf3074c

102 files changed

Lines changed: 4593 additions & 228 deletions

File tree

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

‎cmd/codeaf/app.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2662,6 +2662,9 @@ func (b *appBackend) startTurnLocked(a *appAgent, text, clientID string, shown *
26622662
b.pump(a, id, live, turn, n, text, shown, events)
26632663
b.sendQueued(a, live)
26642664
})
2665+
if err := b.touchJobConversation(a.transcript); err != nil {
2666+
return turn, &appUnsureSend{err: appEngineError(err)}
2667+
}
26652668
return turn, nil
26662669
}
26672670

‎cmd/codeaf/appagentid_cells_test.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -90,6 +90,7 @@ func TestAJobsChatsNameTheCellTalksFolder(t *testing.T) {
9090
if err != nil {
9191
t.Fatal(err)
9292
}
93+
makeJobManager(t, s, it.ID)
9394
var chats appwire.FactoryJobChats
9495
s.must("factory.job_chats", map[string]any{"factory": st.Factory, "job": it.ID}, &chats)
9596
kept, err := s.backend.local.host.floor.Get(it.ID)

‎cmd/codeaf/appfactory.go‎

Lines changed: 5 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1065,9 +1065,10 @@ func (b *appBackend) FactoryJob(ctx context.Context, id string, job int) (api.Jo
10651065

10661066
func (b *appBackend) FactoryRepos(ctx context.Context, id string) (appwire.FactoryRepos, error) {
10671067
var body struct {
1068-
Factory api.Factory `json:"factory"`
1069-
Watched []string `json:"watched"`
1070-
Available []struct {
1068+
AvailableReadAt time.Time `json:"available_read_at"`
1069+
Factory api.Factory `json:"factory"`
1070+
Watched []string `json:"watched"`
1071+
Available []struct {
10711072
Full string `json:"full"`
10721073
Name string `json:"name"`
10731074
Owner string `json:"owner"`
@@ -1081,7 +1082,7 @@ func (b *appBackend) FactoryRepos(ctx context.Context, id string) (appwire.Facto
10811082
if err := b.factoryHub().read(ctx, id, "/v1/repos", appFactoryBody, &body); err != nil {
10821083
return appwire.FactoryRepos{}, err
10831084
}
1084-
out := appwire.FactoryRepos{Factory: body.Factory, Watched: body.Watched, Available: make([]appwire.FactoryRepo, len(body.Available))}
1085+
out := appwire.FactoryRepos{AvailableReadAt: body.AvailableReadAt, Factory: body.Factory, Watched: body.Watched, Available: make([]appwire.FactoryRepo, len(body.Available))}
10851086
if out.Watched == nil {
10861087
out.Watched = []string{}
10871088
}

‎cmd/codeaf/appfactory_brief_spend_test.go‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,7 @@ func TestAppFactoryBriefChargesEachProviderCallOnce(t *testing.T) {
8484
var held api.WorkerPull
8585
var baseline factory.Item
8686
if outcome == "remote worker" {
87+
makeJobManager(t, s, it.ID)
8788
var chats appwire.FactoryJobChats
8889
s.must("factory.job_chats", map[string]any{"factory": st.Factory, "job": it.ID}, &chats)
8990
// local_start also runs this app's worker. Only the simulated

‎cmd/codeaf/appfactory_brief_writer.go‎

Lines changed: 8 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,14 @@ func (b *appBackend) writeBrief(ctx context.Context, factoryID string, job int,
9696
// briefErrand asks the job's agent for the brief. Every way of not
9797
// answering is a Reason, which the factory keeps as the write's failure.
9898
func (b *appBackend) briefErrand(ctx context.Context, factoryID string, job int, begin api.BriefBegin) session.ErrandAnswer {
99+
host, err := b.localHostOf(factoryID)
100+
if err != nil || host.seam.Talk == nil {
101+
return session.ErrandAnswer{Reason: appwire.ErrandNotOpen}
102+
}
103+
// Writing a brief needs a manager; reading its chats no longer creates one.
104+
if _, err := host.seam.Talk(ctx, job); err != nil {
105+
return session.ErrandAnswer{Reason: appwire.ErrandNotOpen}
106+
}
99107
chats, err := b.FactoryJobChats(ctx, appwire.FactoryJobChatsParams{Factory: factoryID, Job: job})
100108
if err != nil {
101109
return session.ErrandAnswer{Reason: appwire.ErrandNotOpen}
Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package main
2+
3+
import (
4+
"testing"
5+
6+
"github.com/Agent-Field/codeaf/internal/appwire"
7+
"github.com/Agent-Field/codeaf/internal/factory"
8+
"github.com/Agent-Field/codeaf/internal/factory/api"
9+
"github.com/Agent-Field/codeaf/internal/factory/lease"
10+
)
11+
12+
func TestAppFactoryBudgetProgress(t *testing.T) {
13+
for _, scenario := range []string{"spend-report", "other-editor", "budget-changed-back", "finished", "dropped"} {
14+
t.Run(scenario, func(t *testing.T) {
15+
s := newLocalSetup(t)
16+
status := s.start()
17+
floor := s.backend.local.host.floor
18+
job, err := floor.Create(factory.Item{Repo: "acme/api", Kind: factory.KindIssue, State: factory.StateQueued, Cap: 5,
19+
Stream: &factory.Stream{Phases: []factory.Phase{{Name: "build", State: factory.PhaseRunning}}}})
20+
if err != nil {
21+
t.Fatal(err)
22+
}
23+
desk, err := lease.Open(floor, lease.Options{})
24+
if err != nil {
25+
t.Fatal(err)
26+
}
27+
held, err := desk.Pull(lease.Worker{ID: "budget-worker", Handle: "owner", Solo: true})
28+
if err != nil || held.Job != job.ID {
29+
t.Fatalf("pull: %+v %v", held, err)
30+
}
31+
var before api.Job
32+
s.must("factory.job", map[string]any{"factory": status.Factory, "job": job.ID}, &before)
33+
if err := desk.Apply(held.ID, held.Worker, false, func(it *factory.Item) error {
34+
it.Stream.Spent = .25
35+
return nil
36+
}); err != nil {
37+
t.Fatal(err)
38+
}
39+
var moved api.Job
40+
s.must("factory.job", map[string]any{"factory": status.Factory, "job": job.ID}, &moved)
41+
if moved.Version <= before.Version {
42+
t.Fatal("worker report did not advance version")
43+
}
44+
params := map[string]any{"factory": status.Factory, "job": job.ID,
45+
"budget": 10, "expect": map[string]any{"version": before.Version}}
46+
switch scenario {
47+
case "other-editor", "budget-changed-back":
48+
var edited appwire.FactoryJobActed
49+
s.must("factory.job_set_budget", map[string]any{"factory": status.Factory, "job": job.ID,
50+
"budget": 20, "expect": map[string]any{"version": moved.Version}}, &edited)
51+
want := 20.0
52+
if scenario == "budget-changed-back" {
53+
s.must("factory.job_set_budget", map[string]any{"factory": status.Factory, "job": job.ID,
54+
"budget": 5, "expect": map[string]any{"version": edited.Current.Version}}, &edited)
55+
want = 5
56+
}
57+
s.mustFail("factory.job_set_budget", params, appwire.CodeConflict)
58+
var current api.Job
59+
s.must("factory.job", map[string]any{"factory": status.Factory, "job": job.ID}, &current)
60+
if current.Budget != want || current.Version != edited.Current.Version {
61+
t.Fatalf("stale editor changed job: %+v", current)
62+
}
63+
return
64+
case "finished", "dropped":
65+
if err := floor.Update(job.ID, func(it *factory.Item) error {
66+
it.State = factory.StateShipped
67+
if scenario == "dropped" {
68+
it.State = factory.StateDismissed
69+
}
70+
return nil
71+
}); err != nil {
72+
t.Fatal(err)
73+
}
74+
s.mustFail("factory.job_set_budget", params, appwire.CodeConflict)
75+
return
76+
}
77+
var raised appwire.FactoryJobActed
78+
s.must("factory.job_set_budget", params, &raised)
79+
if raised.Current == nil || raised.Current.Budget != 10 || raised.Current.Cost != .25 {
80+
t.Fatalf("raise lost budget or report: %+v", raised.Current)
81+
}
82+
})
83+
}
84+
}

‎cmd/codeaf/appfactory_chats.go‎

Lines changed: 32 additions & 35 deletions
Original file line numberDiff line numberDiff line change
@@ -24,50 +24,27 @@ import (
2424
var _ appwire.FactoryJobChatter = (*appBackend)(nil)
2525

2626
// FactoryJobChats answers one job's conversations on this machine's factory,
27-
// making the job's agent when it has none yet. It starts no job and asks no
28-
// model.
27+
// returning only conversations already recorded on the job. It writes no job.
2928
func (b *appBackend) FactoryJobChats(ctx context.Context, p appwire.FactoryJobChatsParams) (appwire.FactoryJobChats, error) {
3029
host, err := b.localHostOf(p.Factory)
3130
if err != nil {
3231
return appwire.FactoryJobChats{}, err
3332
}
34-
if host.seam.Talk == nil {
35-
return appwire.FactoryJobChats{}, appwire.Errorf(appwire.CodeEngine, "this factory cannot make a job's conversation here")
36-
}
37-
item, err := host.floor.Get(p.Job)
38-
if err != nil || !factory.IsIssueJob(item) {
33+
it, err := host.floor.Get(p.Job)
34+
if err != nil || !factory.IsIssueJob(it) {
3935
return appwire.FactoryJobChats{}, appwire.Errorf(appwire.CodeNotFound, "issue job not found")
4036
}
41-
talk, err := host.seam.Talk(ctx, p.Job)
42-
if err != nil {
43-
return appwire.FactoryJobChats{}, appwire.Errorf(appwire.CodeNotFound, "the job's conversation could not be opened: %s", err.Error())
37+
if err := factory.RefuseDropped(it); err != nil {
38+
return appwire.FactoryJobChats{}, appwire.Errorf(appwire.CodeNotFound, "%s", err)
4439
}
45-
it, err := host.floor.Get(p.Job)
46-
if err != nil {
47-
return appwire.FactoryJobChats{}, appwire.Errorf(appwire.CodeNotFound, "there is no job %d in this factory", p.Job)
48-
}
49-
out := appwire.FactoryJobChats{Factory: p.Factory, Job: p.Job, Agent: appAgentID(talk), Steps: []appwire.FactoryJobStepChat{}}
50-
// Whenever the job's agent is opened, by this read or later by a send or
51-
// Reopen, it takes the job's effort as the job then has it.
52-
if a, err := b.find(out.Agent); err == nil && a != nil {
53-
floor, job := host.floor, p.Job
54-
b.locked(func() {
55-
a.jobEffort = func() string {
56-
it, err := floor.Get(job)
57-
if err != nil {
58-
return ""
59-
}
60-
return it.Effort
61-
}
62-
})
63-
}
64-
held, err := b.holdJobChat(out.Agent)
65-
if err != nil {
66-
return appwire.FactoryJobChats{}, err
40+
out := appwire.FactoryJobChats{Factory: p.Factory, Job: p.Job, Steps: []appwire.FactoryJobStepChat{}}
41+
if talk := strings.TrimSpace(it.Talk); talk != "" {
42+
out.Agent = appAgentID(talk)
6743
}
68-
// A held agent takes it now; a closed one keeps its own until it is opened.
69-
if held && it.Effort != "" {
70-
_, _ = b.SetEffort(out.Agent, it.Effort)
44+
if out.Agent != "" {
45+
if err := b.holdJobManager(host, p.Job, out.Agent); err != nil {
46+
return appwire.FactoryJobChats{}, err
47+
}
7148
}
7249
if it.Stream != nil {
7350
for _, ph := range it.Stream.Phases {
@@ -87,6 +64,26 @@ func (b *appBackend) FactoryJobChats(ctx context.Context, p appwire.FactoryJobCh
8764
return out, nil
8865
}
8966

67+
// holdJobManager binds future opens to the job's current effort without saving it.
68+
func (b *appBackend) holdJobManager(host *appFactoryHost, job int, id string) error {
69+
a, err := b.find(id)
70+
if err != nil {
71+
return err
72+
}
73+
floor := host.floor
74+
b.locked(func() {
75+
a.jobEffort = func() string {
76+
it, err := floor.Get(job)
77+
if err != nil {
78+
return ""
79+
}
80+
return it.Effort
81+
}
82+
})
83+
_, err = b.holdJobChat(id)
84+
return err
85+
}
86+
9087
// roundChats is each finished round's chat that can be opened, held open as
9188
// the step's own is. The same rule leaves a round out as a step: one whose chat
9289
// cannot be opened is never answered.

‎cmd/codeaf/appfactory_chats_closed_test.go‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -35,6 +35,7 @@ func TestViewingAJobsPageLeavesItsClosedChatsClosed(t *testing.T) {
3535
if err != nil {
3636
t.Fatal(err)
3737
}
38+
makeJobManager(t, s, it.ID)
3839
read := func() appwire.FactoryJobChats {
3940
var chats appwire.FactoryJobChats
4041
s.must("factory.job_chats", map[string]any{"factory": st.Factory, "job": it.ID}, &chats)
@@ -114,6 +115,7 @@ func TestAJobsClosedChatTakesTheJobsEffortWhenItIsOpenedAgain(t *testing.T) {
114115
if err != nil {
115116
t.Fatal(err)
116117
}
118+
makeJobManager(t, s, it.ID)
117119
read := func() appwire.FactoryJobChats {
118120
var chats appwire.FactoryJobChats
119121
s.must("factory.job_chats", map[string]any{"factory": st.Factory, "job": it.ID}, &chats)

‎cmd/codeaf/appfactory_chats_test.go‎

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
package main
22

33
import (
4+
"context"
45
"strings"
56
"testing"
67
"time"
@@ -10,11 +11,10 @@ import (
1011
"github.com/Agent-Field/codeaf/internal/session"
1112
)
1213

13-
// A JOB'S CONVERSATIONS ARE ITS OWN PAGE'S. Reading them makes the job's agent
14-
// once (the same one on every read after), keeps it on the job, holds it open
14+
// A job's page returns its existing manager on every read and holds it open
1515
// for the app, lists no step that has not run, and starts nothing; another
1616
// factory's chats are not this engine's to answer.
17-
func TestAJobsChatsAreItsAgentMadeOnceAndItsStepsThatRan(t *testing.T) {
17+
func TestAJobsChatsAreItsExistingAgentAndItsStepsThatRan(t *testing.T) {
1818
s := newLocalSetup(t)
1919
// The app opens a conversation through its opener; this one holds a fake.
2020
s.backend.open = func(ask appOpenAsk) (*appConn, error) {
@@ -26,6 +26,7 @@ func TestAJobsChatsAreItsAgentMadeOnceAndItsStepsThatRan(t *testing.T) {
2626
if err != nil {
2727
t.Fatal(err)
2828
}
29+
makeJobManager(t, s, it.ID)
2930
var first appwire.FactoryJobChats
3031
s.must("factory.job_chats", map[string]any{"factory": st.Factory, "job": it.ID}, &first)
3132
if first.Agent == "" || len(first.Steps) != 0 {
@@ -176,6 +177,7 @@ func TestFactoryChatRefreshKeepsThePersonsModelPair(t *testing.T) {
176177
if err != nil {
177178
t.Fatal(err)
178179
}
180+
makeJobManager(t, s, it.ID)
179181
var chats appwire.FactoryJobChats
180182
read := func() { s.must("factory.job_chats", map[string]any{"factory": st.Factory, "job": it.ID}, &chats) }
181183
read()
@@ -197,3 +199,11 @@ func TestFactoryChatRefreshKeepsThePersonsModelPair(t *testing.T) {
197199
}
198200
}
199201
}
202+
203+
// makeJobManager explicitly creates the existing manager a read fixture needs.
204+
func makeJobManager(t *testing.T, s *localSetup, job int) {
205+
t.Helper()
206+
if _, err := s.backend.local.host.seam.Talk(context.Background(), job); err != nil {
207+
t.Fatal(err)
208+
}
209+
}

‎cmd/codeaf/appfactory_job_pause_test.go‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -94,6 +94,7 @@ func TestFactoryRunningJobPause(t *testing.T) {
9494
s.backend.open = func(ask appOpenAsk) (*appConn, error) {
9595
return &appConn{Conv: &fakeConversation{}, Workspace: ask.Workspace, Transcript: ask.Transcript, Release: func() {}}, nil
9696
}
97+
makeJobManager(t, s, it.ID)
9798
var chats appwire.FactoryJobChats
9899
s.must("factory.job_chats", params, &chats)
99100
desk, err := lease.Open(floor, lease.Options{})
@@ -639,6 +640,7 @@ func TestFactoryResumeChat(t *testing.T) {
639640
}
640641
}
641642
params := map[string]any{"factory": status.Factory, "job": it.ID}
643+
makeJobManager(t, s, it.ID)
642644
var chats appwire.FactoryJobChats
643645
s.must("factory.job_chats", params, &chats)
644646
act := func(method string) {
@@ -689,6 +691,7 @@ func TestFactoryResumeChatRetries(t *testing.T) {
689691
if err != nil {
690692
t.Fatal(err)
691693
}
694+
makeJobManager(t, s, it.ID)
692695
var chats appwire.FactoryJobChats
693696
s.must("factory.job_chats", map[string]any{"factory": status.Factory, "job": it.ID}, &chats)
694697
desk, err := lease.Open(floor, lease.Options{})

0 commit comments

Comments
 (0)