Repository navigation
Expand file tree
/
Copy pathcarried.go
More file actions
1232 lines (1161 loc) · 46.9 KB
/
Copy pathcarried.go
File metadata and controls
1232 lines (1161 loc) · 46.9 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
651
652
653
654
655
656
657
658
659
660
661
662
663
664
665
666
667
668
669
670
671
672
673
674
675
676
677
678
679
680
681
682
683
684
685
686
687
688
689
690
691
692
693
694
695
696
697
698
699
700
701
702
703
704
705
706
707
708
709
710
711
712
713
714
715
716
717
718
719
720
721
722
723
724
725
726
727
728
729
730
731
732
733
734
735
736
737
738
739
740
741
742
743
744
745
746
747
748
749
750
751
752
753
754
755
756
757
758
759
760
761
762
763
764
765
766
767
768
769
770
771
772
773
774
775
776
777
778
779
780
781
782
783
784
785
786
787
788
789
790
791
792
793
794
795
796
797
798
799
800
801
802
803
804
805
806
807
808
809
810
811
812
813
814
815
816
817
818
819
820
821
822
823
824
825
826
827
828
829
830
831
832
833
834
835
836
837
838
839
840
841
842
843
844
845
846
847
848
849
850
851
852
853
854
855
856
857
858
859
860
861
862
863
864
865
866
867
868
869
870
871
872
873
874
875
876
877
878
879
880
881
882
883
884
885
886
887
888
889
890
891
892
893
894
895
896
897
898
899
900
901
902
903
904
905
906
907
908
909
910
911
912
913
914
915
916
917
918
919
920
921
922
923
924
925
926
927
928
929
930
931
932
933
934
935
936
937
938
939
940
941
942
943
944
945
946
947
948
949
950
951
952
953
954
955
956
957
958
959
960
961
962
963
964
965
966
967
968
969
970
971
972
973
974
975
976
977
978
979
980
981
982
983
984
985
986
987
988
989
990
991
992
993
994
995
996
997
998
999
1000
package main
// `codeaf <name> …` for a program this build carries (internal/delegate): the
// verb every one of them answers, from a person's shell and from the chat's
// own run alike.
//
// TWO CALLERS, ONE LINE. The chat's run starts `codeaf senior-dev run --json
// --dir … -- <brief>` as its child, with the model API's address and token in
// the child's environment; a person types the same verb at a shell with
// neither. The environment is how the two are told apart: a child of a host
// runs the program's body here and writes its records on stdout; a shell run
// becomes the host itself — it serves the model API and starts the same child.
//
// ── A SHELL RUN IS THE SAME TWO PROCESSES A CHAT'S RUN IS ───────────────────
//
// The host reaches models the way every headless verb does — the person's own
// profile, its services and its keys (config.Load) — and serves them to the
// program through a model API of its own (internal/provider/modelapi), exactly
// as the chat's run does: the program is started as a child of this very
// executable with the API's address and token and no key, every call it makes
// is metered, held to the ceiling the person set, written to this machine's
// spending ledger, and kept as one turn of a conversation log in the run's own
// record folder. What the chat draws on a task page, the host prints as lines.
import (
"context"
"errors"
"fmt"
"io"
"os"
"os/signal"
"path/filepath"
"slices"
"strconv"
"strings"
"sync"
"sync/atomic"
"syscall"
"time"
"github.com/Agent-Field/agentfield/sdk/go/ai"
"github.com/Agent-Field/codeaf/internal/catalog"
"github.com/Agent-Field/codeaf/internal/config"
"github.com/Agent-Field/codeaf/internal/delegate"
"github.com/Agent-Field/codeaf/internal/delegate/builtin"
"github.com/Agent-Field/codeaf/internal/gitidentity"
"github.com/Agent-Field/codeaf/internal/home"
lanes "github.com/Agent-Field/codeaf/internal/lane"
"github.com/Agent-Field/codeaf/internal/modelsource"
"github.com/Agent-Field/codeaf/internal/provider"
"github.com/Agent-Field/codeaf/internal/provider/modelapi"
"github.com/Agent-Field/codeaf/internal/roles"
"github.com/Agent-Field/codeaf/internal/session"
"github.com/Agent-Field/codeaf/internal/tui2/reltime"
)
// carriedStdout is where a carried verb writes what a person reads: its help,
// and a shell run's lines or records. A variable so a test can read it back;
// a child of a host writes its records to the real stdout whatever this says,
// because that pipe is its host's.
var carriedStdout io.Writer = os.Stdout
// carriedStderr is where a shell run says what it is doing about its own
// ending — the stop, and the wait for a last call's price — beside the lines
// or records on stdout. A variable so a test can read it back.
var carriedStderr io.Writer = os.Stderr
// carriedGrace overrides the launch's SIGTERM grace for a shell run, for a
// test that must not wait fifteen seconds; zero is delegate.DefaultGrace.
var carriedGrace time.Duration
// runCarried runs one line of a carried program's verb and leaves on the exit
// ladder (envelope.go).
func runCarried(program delegate.Delegate, args []string) error {
inv, err := delegate.Parse(program, args, carriedStdout)
if errors.Is(err, delegate.ErrHelp) {
return exitDone
}
if err != nil {
fmt.Fprintln(os.Stderr, "error:", err)
return exitCannotRun
}
// SIGTERM IS THE HOST'S STOP (internal/delegate's launch): the body's
// context ends, and the program writes its terminal on the way out.
ctx, stop := carriedSignals()
defer stop()
if _, child := delegate.ModelAPIFromEnv(); child {
return carriedExit(delegate.RunChild(ctx, inv, os.Stdout))
}
return runCarriedHost(ctx, inv)
}
// carriedSignals is a shell run's context: it ends on the first ctrl-c,
// SIGTERM or hangup, and that first signal hands ctrl-c and SIGTERM back to the
// terminal.
//
// A SECOND CTRL-C LEAVES AT ONCE. After the first one the run still waits for
// the program's grace, its last calls to finish and the price of a call the
// stop cut short — up to about a minute and a half, said on stderr as it
// happens. Holding the signals for all of that swallowed a second ctrl-c, and
// a person who means "now" is owed a way out that does not wait for money to
// be counted. What leaving costs is said in the manual: the folder is
// finished before that wait ([runCarriedHost]), so it is only a price still
// being waited for that is then not in the run's line.
//
// A HANGUP IS A STOP, AND ONLY THE FIRST ONE IS HEARD. A closed terminal or a
// dropped ssh connection sent SIGHUP, which nothing caught: the host died on
// the spot, its program worked on unstopped, and the folder was left on the
// program's branch with nothing said. It now stops the program and finishes
// the folder the way ctrl-c does, and a second hangup — a shell passing one on
// to its jobs as it exits — is ignored rather than allowed to kill that
// finishing halfway.
func carriedSignals() (context.Context, context.CancelFunc) {
ctx, stop := signal.NotifyContext(hostSeated(context.Background()), os.Interrupt, syscall.SIGTERM, syscall.SIGHUP)
context.AfterFunc(ctx, func() {
signal.Ignore(syscall.SIGHUP)
stop()
})
return ctx, stop
}
// carriedRoad is how one shell run reaches models: the funnel a call on a
// model goes out through, whether this person's services can take a call on a
// model, and the work seat a call nothing here can serve is answered on.
type carriedRoad struct {
completerFor func(model string) modelapi.Completer
authKeySource func(model string) string
serves func(model string) bool
resolveModel func(word string) (string, error)
modelPrice func(model string) (input, output float64, known bool)
seat string
defaultSeat func() (string, error)
signNamed bool
// warmCatalog waits, within ctx, for the named models' service catalogs
// to be resolved and written down. Nil for a road with no catalog.
warmCatalog func(ctx context.Context, models []string) bool
}
// carriedModels resolves a shell run's road. It is the person's own profile,
// read the way `codeaf exec` reads it; a variable so a test can hand a
// scripted road instead of a profile and a key.
var carriedModels = profileRoad
// carriedCatalogWarm bounds how long a senior-dev shell run waits for the
// profile's model catalog to be written before it starts: the catalog's own
// fetch timeout, so the wait is never longer than one fetch.
const carriedCatalogWarm = catalog.FetchTimeout
// profileRoad is the road through the person's profile: config.Load's
// services and keys — so a machine with no key at all is answered with the
// one sentence every command gives it — the crew's work seat if this run
// needs a default, and one adapter per model, built the way every client outside internal/config is built
// ([config.Config.ClientConfig]).
func profileRoad() (carriedRoad, error) {
settings, err := config.Load()
if err != nil {
return carriedRoad{}, err
}
useAutoSeats(settings)
settings.Models = sharedCatalog(settings)
adapters := &carriedAdapters{settings: settings, built: map[string]modelapi.Completer{}}
sources := settings.Sources.OrDefault(settings.APIKey, settings.BaseURL)
return carriedRoad{
completerFor: adapters.forModel,
authKeySource: func(model string) string { return config.APIKeySourceForModel(settings.ProfileDir, sources, model) },
serves: func(model string) bool { return session.ServesModel(sources, model) },
resolveModel: func(word string) (string, error) {
// A shell has no model picker already warming in the background.
// Wait for the same catalog the chat's task matcher reads before
// deciding what a person's short model word means.
settings.Models.Warmed(context.Background())
return session.ResolveProgramShellModels(word, v3TaskModels(settings.Models)(), sources)
},
modelPrice: func(model string) (float64, float64, bool) { return carriedModelPrice(settings, model) },
defaultSeat: func() (string, error) {
// A shell run needs the profile's work seat only when nobody pinned
// a model for this invocation. A fresh profile can still use --high.
seats, err := config.ResolveSeats(settings.ProfileDir, config.SeatFlags{}, config.CrewAsk{})
if err != nil && !errors.Is(err, config.ErrCrewAtCap) {
return "", err
}
return seats.Work.Model, nil
},
signNamed: config.AttributionModelAt(settings.ProfileDir),
warmCatalog: func(ctx context.Context, models []string) bool {
// The child reads each service's own cache without provider keys.
// Waiting only for the default cache leaves a fresh custom service's
// known window invisible to senior-dev.
catalogs := map[string]*catalog.Catalog{}
for _, model := range models {
model = strings.TrimPrefix(strings.TrimSpace(model), modelsource.DefaultID+"/")
if model == "" {
continue
}
service, _ := sources.For(model)
if service.Source.ID == modelsource.DefaultID {
catalogs[modelsource.DefaultID] = settings.Models
continue
}
options := config.CatalogOptionsFor(service, settings.ProfileDir)
key := catalog.CacheKey(options.Source, options.BaseURL)
if catalogs[key] == nil {
catalogs[key] = catalog.LoadLazy(ctx, options)
}
}
ready := true
for _, models := range catalogs {
ready = models.Warmed(ctx) && ready
}
return ready
},
}, nil
}
// carriedModelPrice prices the same service and bare id the adapter calls.
// A connection qualifier is not a vendor prefix; only the selected default
// service owns the shared catalog. Other services keep the unpriced bound
// rather than borrowing an OpenRouter tariff for a coincidentally equal id.
func carriedModelPrice(settings config.Config, model string) (float64, float64, bool) {
model, _ = roles.SplitEffort(model)
service, bare := settings.Sources.OrDefault(settings.APIKey, settings.BaseURL).For(model)
if !strings.EqualFold(strings.TrimSpace(service.Source.ID), modelsource.DefaultID) {
return 0, 0, false
}
return settings.Models.PriceNow(bare)
}
// carriedAdapters is one adapter per model a shell run's program asks for,
// built once and kept for the run.
type carriedAdapters struct {
settings config.Config
mu sync.Mutex
built map[string]modelapi.Completer
}
// forModel is the adapter for one model. THE WIRE SLUG IS THE LAST OPTION: a
// program names a model in the person's own spelling — `openrouter/…`, a
// connection's own prefix — and that spelling decides the account; what goes
// on the wire is the service's own id for it, which config resolved beside
// the account, exactly as the conversation's own door appends it
// (internal/session's completeWithNamedModel).
func (a *carriedAdapters) forModel(model string) modelapi.Completer {
a.mu.Lock()
defer a.mu.Unlock()
if built, ok := a.built[model]; ok {
return built
}
configured := a.settings.ClientConfig(model)
client, err := provider.NewClient(configured)
if err != nil {
return refusingCompleter{err: err}
}
built := wireCompleter{client: client, wire: configured.Model}
a.built[model] = built
return built
}
// wireCompleter is one adapter with the model's wire slug appended to every
// call.
type wireCompleter struct {
client *provider.Client
wire string
}
func (c wireCompleter) CompleteWithMessages(ctx context.Context, messages []ai.Message, options ...ai.Option) (*ai.Response, error) {
return c.client.CompleteWithMessages(ctx, messages, append(options, ai.WithModel(c.wire))...)
}
// refusingCompleter is a model whose adapter could not be built: every call is
// answered with why.
type refusingCompleter struct{ err error }
func (c refusingCompleter) CompleteWithMessages(context.Context, []ai.Message, ...ai.Option) (*ai.Response, error) {
return nil, c.err
}
// runCarriedHost is a person's shell run: this process serves the model API
// with the person's own services and starts the program as its own child.
func runCarriedHost(ctx context.Context, inv *delegate.Invocation) error {
// A RUN WITH NOTHING TO DO IS NOT STARTED. The default command is a whole
// task, and a task with no brief is a program sent off to guess; the line
// that says what it wanted is the answer, and nothing is spent on the way.
if inv.Command.Name == inv.Program.Default && inv.Brief() == "" {
fmt.Fprintf(os.Stderr, "no brief given\n\n%s\n\nrun `codeaf %s --help` for its commands and flags.\n",
strings.Join(foldSynopsis("codeaf "+inv.Program.Name+" "+carriedSynopsis), "\n"), inv.Program.Name)
return exitCannotRun
}
// THE PROGRAM SAYS WHICH OF ITS FLAGS NAMES ITS MODELS
// ([delegate.Delegate.ModelFlag]); codeaf resolves what was typed there.
modelFlag := strings.TrimSpace(inv.Program.ModelFlag)
asked := ""
if modelFlag != "" {
asked = strings.TrimSpace(inv.ExplicitFlags[modelFlag])
}
road, err := carriedModels()
if err != nil {
if word := asked; word != "" && errors.Is(err, config.ErrNoAPIKey) {
return session.ProgramShellModelRefusal(word)
}
return err
}
var modelsToWarm []string
if asked != "" {
modelsToWarm = strings.Split(asked, ",")
}
if word := asked; word != "" && road.resolveModel != nil {
resolved, err := road.resolveModel(word)
if err != nil {
return err
}
modelsToWarm = strings.Split(resolved, ",")
if inv.Program.Name == "senior-dev" {
// Its pool is addressed through the loopback API's OpenRouter
// dialect, just as the chat road's crewFlags spells every seat.
models := strings.Split(resolved, ",")
for i, model := range models {
if !strings.HasPrefix(model, modelsource.DefaultID+"/") {
models[i] = modelsource.DefaultID + "/" + model
}
}
resolved = strings.Join(models, ",")
}
inv.Line = carriedResolvedModel(inv.Line, modelFlag, resolved)
}
// AN AUXILIARY MODEL IS RESOLVED BY THE SAME DOOR. Leaving a light
// model as typed sent an unmarked floating alias where the main model
// had already been replaced by its catalog spelling.
for _, flag := range inv.Program.AuxiliaryModelFlags {
flag = strings.TrimSpace(flag)
word := strings.TrimSpace(inv.ExplicitFlags[flag])
if word == "" {
continue
}
resolved := word
if road.resolveModel != nil {
resolved, err = road.resolveModel(word)
if err != nil {
return err
}
}
inv.Line = carriedResolvedModel(inv.Line, flag, resolved)
modelsToWarm = append(modelsToWarm, strings.Split(resolved, ",")...)
}
if asked == "" && road.defaultSeat != nil {
road.seat, err = road.defaultSeat()
if err != nil {
return err
}
modelsToWarm = append(modelsToWarm, road.seat)
// AND THE SEAT IS WHAT THE PROGRAM WORKS ON, as a conversation's crew is
// ([carriedSeatedLine]): a shell run and a chat run of one program on
// one profile route on the same model.
inv.Line = carriedSeatedLine(inv, road.seat)
}
// A PERSON TYPED THIS AND IS WATCHING ITS LINES, which is the fact the
// lane layer reads for the calls that ride no context of the door's own
// (exec.go's typedDoorContext says the whole of why).
provider.SetPersonAtTheDoor(true)
record := carriedRecordDir(inv.Program.Name)
view := newCarriedView(carriedStdout, inv, record)
// THE FOLDER IS READIED BEFORE ANYTHING STARTS, the one way a conversation's
// run readies it (internal/session's programfolder.go): a copy of its own on
// a branch of its own in a repository, the folder itself otherwise, and a
// refusal — another program's run in a plain folder — before a cent is
// spent. A plain folder is no longer the program's first-line failure:
// codeaf says so on the program's line.
folder, err := carriedFolder(inv, record)
if err != nil {
fmt.Fprintln(carriedStderr, "error:", err)
return exitCannotRun
}
view.inFolder(folder)
// AND THE FOLDER IS FINISHED ON EVERY ROAD OUT: the run's ending below, or
// a door that failed before the program ever ran, which leaves nothing.
finished := false
finish := func(result string) {
if folder != nil && !finished {
finished = true
if err := session.SetProgramAnswerAttribution(folder, road.signNamed); err != nil {
fmt.Fprintln(carriedStderr, "could not read the models that answered:", err)
}
view.left(folder.Finish(result).Sentence())
}
}
defer finish("")
runCtx, cut := context.WithCancel(ctx)
defer cut()
// A LIMIT THE PERSON SET ENDS THE PROGRAM FROM OUTSIDE, whatever it does
// with the same figure on its own command line: the hours by a clock here,
// the dollars the moment a metered call reaches them. The API refuses the
// next call as well, so a program that ignores its SIGTERM cannot spend on.
var limited atomic.Bool
if wall := inv.Ceilings.Elapsed(); wall > 0 {
clock := time.AfterFunc(wall, func() {
limited.Store(true)
cut()
})
defer clock.Stop()
}
// SENIOR-DEV SIZES ITS MODELS FROM THE CATALOG THIS PROFILE KEEPS ON DISK
// whenever models.dev cannot answer (internal/config's
// CachedContextWindow), and it cannot fetch that catalog itself: it runs
// with no provider key. On a profile that has never listed its models the
// file is written by the catalog's first resolution, so the run waits for
// it, at most one catalog fetch's length. A profile that already holds the
// list answers at once, and a fetch that cannot land leaves the run to say
// it guessed.
if inv.Program.Name == "senior-dev" && road.warmCatalog != nil {
warmCtx, warmed := context.WithTimeout(runCtx, carriedCatalogWarm)
for _, flag := range []string{"low", "frontier"} {
if pool := strings.TrimSpace(inv.ExplicitFlags[flag]); pool != "" {
modelsToWarm = append(modelsToWarm, strings.Split(pool, ",")...)
}
}
road.warmCatalog(warmCtx, modelsToWarm)
warmed()
}
ledger := session.UsageLedgerPath()
// A SHELL RUN'S ROWS NAME THE RUN. There is no conversation and no task
// behind them, and a row that named nothing was money the spending page
// could not say anything about; the run's own record folder is the one
// name it has, so its rows are filed as one piece of work under it.
subject := filepath.Base(record)
api, err := modelapi.Open(modelapi.Config{
TaskDir: record,
CompleterFor: road.completerFor,
AuthKeySource: road.authKeySource,
Serves: road.serves,
ModelPrice: road.modelPrice,
Seat: road.seat,
Ceiling: inv.Ceilings.CostUSD,
Bank: func(charge modelapi.Charge) {
// THE MACHINE'S SPENDING LEDGER, one row per call, written here and
// nowhere else: nothing else in this process meters these calls.
line := session.UsageLine{
Model: charge.Model, Calls: 1, Input: charge.TokensIn, Output: charge.TokensOut, USD: charge.CostUSD,
Reconciled: charge.Late, Workspace: inv.Workspace, Task: subject,
}
session.RecordUsage(ledger, session.TagUsage(line, roles.RoleWorker, session.SeatWorker))
view.call(charge)
if ceiling := inv.Ceilings.CostUSD; ceiling > 0 && charge.Spent >= ceiling {
limited.Store(true)
cut()
}
},
Unbilled: func(model string) {
session.RecordUnbilledCall(ledger, session.TagUsage(session.UsageLine{Model: model, Workspace: inv.Workspace, Task: subject}, roles.RoleWorker, session.SeatWorker))
},
// THE LAST CALL'S PRICE IS WAITED FOR, AND THE PERSON IS TOLD WHY. A
// run stopped by ctrl-c or its own ceiling is usually in the middle of
// a call, priced by a receipt about twenty seconds later; this process
// used to exit first, and that call never reached the ledger.
Settling: func(owed int) { fmt.Fprintln(carriedStderr, carriedSettlingLine(owed)) },
Role: lanes.RoleLeafAttached,
Node: inv.Program.Name,
})
if err != nil {
return err
}
defer func() { _ = api.Close() }()
exe, err := os.Executable()
if err != nil {
return fmt.Errorf("find codeaf's own executable to run %s: %w", inv.Program.Name, err)
}
here, err := os.Getwd()
if err != nil {
return fmt.Errorf("read the folder %s was started in: %w", inv.Program.Name, err)
}
grace := carriedGrace
if grace <= 0 {
grace = delegate.DefaultGrace
}
// CTRL-C IS A STOP, SAID AS ONE. The program is sent SIGTERM and given the
// grace to write how it ended; the person is told that much at once rather
// than left watching a terminal that has gone quiet for fifteen seconds.
untell := context.AfterFunc(ctx, func() {
fmt.Fprintf(carriedStderr, "stopping %s: it has %s to say how it ended\n", inv.Program.Name, grace)
})
view.begin()
// THE PROGRAM'S OWN CLOCK is written in its record folder, as a chat's
// run writes it in the task's: the instant its process was started and
// the instant it was gone (delegate.ProgramRecord).
started := time.Now()
view.opened(started)
result, runErr := delegate.Run(runCtx, delegate.Launch{
Name: inv.Program.Name,
Bin: exe,
Args: carriedInFolder(carriedChildLine(inv), inv, folder),
// NO PROVIDER KEY IS INHERITED BY THE PROGRAM (delegate.ChildEnv): the engine
// gets the loopback token it needs, and model commands lose that token.
Env: append(append(delegate.ChildEnv(api.API()), "SENIOR_DEV_IGNORED_AT_START="+folder.IgnoredFile(),
gitidentity.InputsEnv+"="+folder.InputsFile()), delegate.RecordsEnv(record)...),
Dir: here,
StderrPath: filepath.Join(record, carriedStderrName),
Grace: grace,
Hold: folder.Hold(),
}, view)
// THE INSTANT THE PROCESS WAS GONE, not the instant its stdout drained, for
// both the last line and the record's end: a helper the program left holding
// stdout kept the launch open for up to the grace after the exit, and the
// same program read that much longer here than in a conversation, whose
// worker reads it this way ([delegate.Result.ExitedAt]).
ended := result.ExitedAt(started, time.Now())
untell()
// THE FOLDER IS FINISHED THE MOMENT THE PROGRAM HAS GONE, before its last
// prices are waited for. That wait is up to seventy seconds, a second
// ctrl-c during it leaves at once, and a folder finished after it was a
// folder left on the program's branch with its leftovers uncommitted and
// nothing said, for the next run to find. Nothing in the finishing needs
// the API: the program's ending is read off its last record, and its
// process — its whole group, on a stop — is already gone.
view.closed(ended)
if folder != nil {
folder.Passed = view.commitPassed(result, runErr, limited.Load())
}
finish(view.finishingWords(result, limited.Load()))
// The program has exited: its API goes with it, so nothing it left behind
// can spend, and every row it cost is on disk before this process leaves —
// the close waits for the price of a call the stop cut in the middle.
_ = carriedAPIClose(api)
session.CloseUsage()
return view.end(result, runErr, limited.Load(), api.Spent(), ended.Sub(started))
}
// carriedAPIClose closes a shell run's model API, which waits for the price
// of a call a stop cut short; a variable so a test can see what is already
// done by the time that wait begins.
var carriedAPIClose = (*modelapi.Server).Close
// carriedFolder readies the folder a shell run's program works in
// (internal/session's PrepareProgramFolder); nil for a program that edits no
// files, which reads the folder where it is.
func carriedFolder(inv *delegate.Invocation, record string) (*session.ProgramFolder, error) {
if !inv.Program.LandsTree() {
return nil, nil
}
return session.PrepareProgramFolder(session.ProgramFolderOrder{
Program: inv.Program, Dir: inv.Workspace, Brief: inv.Brief(),
Holder: "a run started at a shell", Keep: record,
Instead: "run it in the project's folder, or name that folder with --dir",
})
}
// carriedInFolder puts on a shell run's child line what codeaf decided about
// its folder, after --json and before the person's own words: the folder it
// works in when it is not the one the line names (a repository is worked in
// in a copy of its own, and the person's own --dir is taken off so it cannot
// win), the program's own flags for a folder worked in without git
// ([delegate.Delegate.PlainFolder]), and, ahead of the brief, the line that
// says where a copy is ([session.ProgramFolder.BriefNote]).
func carriedInFolder(child []string, inv *delegate.Invocation, folder *session.ProgramFolder) []string {
if folder == nil {
return child
}
at := slices.Index(child, "--json")
if at < 0 {
return child
}
head, rest := append([]string(nil), child[:at+1]...), child[at+1:]
if folder.Dir != inv.Workspace {
flags, words := rest[:len(rest)-len(inv.Args)], rest[len(rest)-len(inv.Args):]
kept := make([]string, 0, len(flags))
for i := 0; i < len(flags); i++ {
switch flag := flags[i]; {
case flag == "--dir" || flag == "-dir":
i++
case strings.HasPrefix(flag, "--dir=") || strings.HasPrefix(flag, "-dir="):
default:
kept = append(kept, flag)
}
}
head = append(head, "--dir", folder.Dir)
rest = append(kept, words...)
}
if folder.Plain() {
head = append(head, inv.Program.PlainFolder...)
}
if note := folder.BriefNote(); note != "" && len(inv.Args) > 0 && len(rest) >= len(inv.Args) {
flags, words := rest[:len(rest)-len(inv.Args)], rest[len(rest)-len(inv.Args):]
rest = append(append(append([]string(nil), flags...), note+"\n\n"), words...)
}
return append(head, rest...)
}
// carriedSettlingLine is what a shell run says while it waits for the
// receipts still owed on the calls its ending cut short, bounded by the
// provider's own schedule (provider.ReceiptWait).
func carriedSettlingLine(owed int) string {
calls := "1 call that was"
if owed != 1 {
calls = strconv.Itoa(owed) + " calls that were"
}
return "waiting up to " + reltime.Elapsed(provider.ReceiptWait) + " for the price of " + calls + " cut short"
}
// carriedStderrName is the file a shell run keeps its program's stderr in,
// beside the conversation log — the name the chat's run keeps it under too.
const carriedStderrName = "delegate-stderr.log"
// carriedRecordDir is a shell run's record folder: the conversation log, the
// action log, the program record and the program's stderr, under this machine's state root
// where a person can open them after the lines have scrolled away. It has no
// task page to live beside, so it has a folder of its own, one per run.
func carriedRecordDir(name string) string {
return filepath.Join(carriedRecordRoot(name), time.Now().Format("20060102-150405.000000"))
}
// carriedRecordRoot is the folder every shell run of one program keeps its
// record under, one folder per run.
func carriedRecordRoot(name string) string {
return home.Join("v3", "carried", name)
}
// carriedChildLine is the line a shell run starts its child with: THE
// PERSON'S OWN LINE, with --json added after the command word. It is not
// delegate.ChildArgs, which is the chat's line — the default command and the
// shared flags only — because a person at a shell may name another command or
// give the command a flag of its own, and a line rebuilt from the parsed
// invocation would silently drop both. The child reads this line with the same
// parser the host just read it with, from the same folder ([runCarriedHost]
// starts it where it was started), so it arrives at the same invocation.
func carriedChildLine(inv *delegate.Invocation) []string {
line := append([]string(nil), inv.Line...)
head := []string{inv.Program.Name}
if len(line) > 0 {
if _, named := inv.Program.Command(line[0]); named {
head, line = append(head, line[0]), line[1:]
}
}
head = append(head, "--json")
return append(head, line...)
}
// carriedSeatedLine puts the profile's work seat on a shell run's line as the
// program's working seat, in the program's own crew flags
// ([delegate.Delegate.CrewFlags]) — for senior-dev the pool it routes on, and
// the seat's own rung (`model:high`) as its effort — exactly as a
// conversation hands its crew over. They go first, so a flag the person typed
// after them still wins. A program with no crew flags, or no seat, keeps the
// line as typed.
//
// A SHELL RUN USED TO ROUTE ON SENIOR-DEV'S OWN LIST while the same program
// started from the chat worked on the person's crew, so one person on one
// profile got a different model depending on the door; the seat was only a
// fallback for calls nothing here could serve.
func carriedSeatedLine(inv *delegate.Invocation, seat string) []string {
model, rung := roles.SplitEffort(seat)
if inv.Program.CrewFlags == nil || strings.TrimSpace(model) == "" {
return inv.Line
}
flags := inv.Program.CrewFlags(delegate.Crew{Hands: strings.TrimSpace(model), Effort: rung})
at := 0
if len(inv.Line) > 0 {
if _, named := inv.Program.Command(inv.Line[0]); named {
at = 1
}
}
line := append([]string(nil), inv.Line[:at]...)
line = append(line, flags...)
return append(line, inv.Line[at:]...)
}
// carriedResolvedModel replaces only the model flag's value on the person's
// line. Every other program flag and every word of the brief stays as typed.
func carriedResolvedModel(line []string, flag, model string) []string {
resolved := append([]string(nil), line...)
for i, word := range resolved {
if word == "--" {
break
}
switch {
case word == "--"+flag || word == "-"+flag:
if i+1 < len(resolved) {
resolved[i+1] = model
}
case strings.HasPrefix(word, "--"+flag+"="):
resolved[i] = "--" + flag + "=" + model
case strings.HasPrefix(word, "-"+flag+"="):
resolved[i] = "-" + flag + "=" + model
}
}
return resolved
}
// carriedExit is an ending on the exit ladder: the work stands, a limit you
// set stopped it, or it ran and did not finish.
func carriedExit(status string) error {
switch status {
case delegate.StatusPass:
return exitDone
case delegate.StatusBudget:
return exitLimit
default:
return exitIncomplete
}
}
// ── the front page ──────────────────────────────────────────────────────────
// carriedHeading heads the group `codeaf --help` lists the carried programs
// under, in the table's own register: what the group is, a dash, the one thing
// a reader needs to know about all of it. It names no machinery: a person
// reads the program's own name, never the word the code calls it by.
const carriedHeading = "Hand it a whole task — a program codeaf carries does it on its own"
// carriedSynopsis is the shape every carried program's line takes: the brief,
// and the folder and the two ceilings codeaf puts on every one of them
// (delegate.Parse). It is ONE LINE ON PURPOSE: the front page was cut to fit a
// screen and a bit, and a program costs it two lines — this and its summary.
// `--json`, the program's own commands and their flags are its `--help`.
const carriedSynopsis = `"<brief>" [--dir dir] [--max-cost usd] [--max-hours h]`
// carriedGroup is the group `codeaf --help` gives the programs a build
// carries: one line per program in the table's shape, its summary under it.
// A build that carries none gets no group at all — not a heading over nothing
// — which is every Windows build.
func carriedGroup(programs []delegate.Delegate) string {
if len(programs) == 0 {
return ""
}
lines := []string{carriedHeading}
for _, program := range programs {
lines = append(lines, foldSynopsis("codeaf "+program.Name+" "+carriedSynopsis)...)
indent := strings.Repeat(" ", helpTextColumn)
for _, line := range wrapAt(program.Summary, helpWidth-helpTextColumn) {
lines = append(lines, indent+line)
}
}
// A program's own commands and flags are its `--help`, which the page's
// last line already names for every command; saying it again here would be
// a line of the capped page spent on a sentence the reader has.
return strings.Join(lines, "\n")
}
// synopsisFold is the column a folded synopsis continues at: under the verb,
// so the flags stay one column, which is where the table folds every other
// command's (main.go's layout law).
const synopsisFold = 14
// foldSynopsis writes one command's synopsis at column 2 and folds it, when it
// must, at [synopsisFold], so no line draws wider than [helpWidth].
func foldSynopsis(synopsis string) []string {
words := strings.Fields(synopsis)
if len(words) == 0 {
return nil
}
lines := []string{" " + words[0]}
for _, word := range words[1:] {
last := len(lines) - 1
if len(lines[last])+1+len(word) > helpWidth {
lines = append(lines, strings.Repeat(" ", synopsisFold)+word)
continue
}
lines[last] += " " + word
}
return lines
}
// frontPage is `codeaf --help` as it is printed: the one table, with the
// programs this build carries listed as a group of their own right after the
// work you hand it — they are work you hand it, the whole of a task. The table
// itself stays one constant ([usageText]) so every per-command page is still a
// reading of it; the group is read from the build's list at the moment of
// printing, because that list is what the build carries.
func frontPage() string {
group := carriedGroup(builtin.All())
if group == "" {
return usageText
}
const after = "\nLook at what happened"
at := strings.Index(usageText, after)
if at < 0 {
return usageText + "\n\n" + group
}
return usageText[:at] + "\n" + group + "\n" + usageText[at:]
}
// ── what a person at the shell sees ─────────────────────────────────────────
// carriedView is a shell run's delegate.Sink and its call line: the stage as
// it changes, each step, each model call, and the ending, as lines a person
// reads — or, with --json, the program's records passed through as records.
// The reader's goroutine, the API's calls and the host itself all write here,
// so every write is taken under one lock.
type carriedView struct {
mu sync.Mutex
out io.Writer
inv *delegate.Invocation
record string
records *delegate.Emitter
stage string
status string
calls int
terminal *delegate.Terminal
// program is the run's program record as it stands, rewritten whole in
// the record folder each time it learns something: its start, its hello,
// its end.
program delegate.ProgramRecord
// folder is the folder the run was readied in, and leftFolder is how the
// run left it (internal/session's ProgramFolderEnd.Sentence); nil and
// empty for a program that edits no files.
folder *session.ProgramFolder
leftFolder string
}
func newCarriedView(out io.Writer, inv *delegate.Invocation, record string) *carriedView {
view := &carriedView{out: out, inv: inv, record: record}
if inv.JSON {
view.records = delegate.NewEmitter(out)
}
return view
}
// begin says what is starting, where.
func (v *carriedView) begin() {
if v.records != nil {
return
}
if ceilings := v.inv.Ceilings.Summary(); ceilings != "" {
v.say("%s · working in %s · %s", v.inv.Program.Name, v.where(), ceilings)
} else {
v.say("%s · working in %s", v.inv.Program.Name, v.where())
}
// A COPY IS CUT FROM A COMMIT, so what the person had not committed is not
// in it, and a person at a shell is told so before the run spends a cent on
// work that needed it — as a conversation's receipt tells them.
if left := v.leftBehind(); left != "" {
v.say("%s", left)
}
}
// leftBehind is the line about the changes the person's checkout had not
// committed when the program's copy was cut ([session.ProgramFolder.LeftBehindWords]).
func (v *carriedView) leftBehind() string {
v.mu.Lock()
defer v.mu.Unlock()
if v.folder == nil {
return ""
}
return v.folder.LeftBehindWords()
}
// inFolder keeps the folder the run was readied in, for the line that says
// where it works.
func (v *carriedView) inFolder(folder *session.ProgramFolder) {
v.mu.Lock()
defer v.mu.Unlock()
v.folder = folder
}
// where is the folder the program works in, as the first line says it: on its
// own branch when codeaf cut one.
func (v *carriedView) where() string {
v.mu.Lock()
defer v.mu.Unlock()
if v.folder == nil {
return v.inv.Workspace
}
if v.folder.Plain() {
return v.folder.Dir
}
if v.folder.Copied() {
return v.folder.Repo + ", in a copy of its own on its own branch " + v.folder.Branch
}
return v.folder.Dir + ", on its own branch " + v.folder.Branch
}
// left keeps how the run left its folder, for the lines that end the run.
func (v *carriedView) left(sentence string) {
v.mu.Lock()
defer v.mu.Unlock()
v.leftFolder = sentence
}
// endingWords is the program's ending in the one sentence a person reads
// ([carriedEnding]), used with its message when its run did not pass.
func (v *carriedView) endingWords() string {
terminal, _ := v.ending()
if terminal == nil {
return ""
}
return carriedEnding(v.inv.Program.Name, *terminal)
}
// commitPassed uses the conversation road's meaning of passed: a completed
// run whose own check passed. Handing in work does not make a failed check
// pass, and a terminal arriving during a stop cannot make that run pass.
func (v *carriedView) commitPassed(result delegate.Result, runErr error, limited bool) bool {
terminal, _ := v.ending()
return !limited && !result.Stopped && runErr == nil && terminal != nil &&
terminal.Verdict() == "pass" && (terminal.Status == delegate.StatusPass ||
(terminal.Status == delegate.StatusFail && terminal.HandedIn()))
}
// finishingWords preserves the terminal's existing commit words. When the
// program left none, the shell's own ending sentence supplies the fallback.
func (v *carriedView) finishingWords(result delegate.Result, limited bool) string {
if words := v.endingWords(); words != "" {
return words
}
name := v.inv.Program.Name
if limited {
return name + " was stopped at a limit you set"
}
line := fmt.Sprintf("%s exited %d without saying how it ended", name, result.ExitCode)
if result.Reading.LastStage != "" {
line += "; its last stage was " + result.Reading.LastStage
}
return line
}
func (v *carriedView) Hello(h delegate.Hello) {
v.remember(func(record *delegate.ProgramRecord) { record.Stages = h.Stages })
if v.records != nil {
_ = v.records.Hello(v.inv.Program.Name, h.Stages)
}
}
// opened writes the program record the moment the program's process is
// started: whose run it is, its ceiling, and when it began.
func (v *carriedView) opened(at time.Time) {
v.remember(func(record *delegate.ProgramRecord) {
record.Name, record.CeilingUSD, record.StartedAt = v.inv.Program.Name, v.inv.Ceilings.CostUSD, at
})
}
// heard writes the models the program says it runs on onto its record, when a
// stage names them ([delegate.StageRecord.Models]).
func (v *carriedView) heard(record delegate.StageRecord) {
v.mu.Lock()
defer v.mu.Unlock()
if v.program.Heard(record) {
v.write()
}
}
// closed writes the instant the program's process was gone.
func (v *carriedView) closed(at time.Time) {
v.remember(func(record *delegate.ProgramRecord) { record.EndedAt = at })
}
// remember changes the program record and writes it whole. It is a record, so
// a disk that refuses it costs the record and never the run.
func (v *carriedView) remember(change func(record *delegate.ProgramRecord)) {
v.mu.Lock()
defer v.mu.Unlock()
change(&v.program)
v.write()
}
// write writes the program record whole, naming the program when nothing has
// yet. Its caller holds the lock.
func (v *carriedView) write() {
if v.program.Name == "" {
v.program.Name = v.inv.Program.Name
}
_ = delegate.WriteProgram(v.record, v.program)
}
// kept writes one received record to the run's action log in its record
// folder, stamped with the moment it arrived — the same log a chat's run keeps
// beside its task (delegate.ActionsFile), so the two roads leave one record. It
// is a record, so a disk that refuses it costs the record and never the run.
func (v *carriedView) kept(action delegate.Action) {
if strings.TrimSpace(v.record) == "" {
return
}
_ = delegate.AppendAction(v.record, action)
}
func (v *carriedView) Stage(record delegate.StageRecord) {
v.kept(delegate.StageAction(time.Now(), record))
v.heard(record)
if v.records != nil {
_ = v.records.Stage(record)
return
}
stage, status := record.Stage, record.Status
if !v.moved(stage, status) {
return
}
if status == "" {
v.say("%s", stage)
return
}
v.say("%s · %s", stage, status)
}
// moved takes the program's new phase and answers whether it is a change: a
// stage said twice is one line, not two.
func (v *carriedView) moved(stage, status string) bool {
v.mu.Lock()
defer v.mu.Unlock()
changed := stage != v.stage || status != v.status
v.stage, v.status = stage, status
return changed
}
func (v *carriedView) Step(record delegate.StepRecord) {