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
7 changes: 5 additions & 2 deletions cmd/codeaf/chatv3.go
Original file line number Diff line number Diff line change
Expand Up @@ -347,6 +347,9 @@ func openChatV3(name string, args []string, pickSession bool) error {
chosen, cfg := launch.Model, launch.Config

if text := strings.TrimSpace(*once); text != "" {
// A --once chat draws no surface, so an owed usage notice is printed
// here, ahead of the answer it would otherwise never be seen beside.
payTelemetryNoticeOnStderr()
// Nobody is watching a --once run, so nobody can answer a question. The
// policy's "prompt" therefore refuses the call with a result the model
// can act on (internal/session's consent.go), and a person who wants
Expand Down Expand Up @@ -938,15 +941,15 @@ func openV3Launch(proc *v3Process, opts v3Options) (*v3Launch, error) {
// engine's own nothing. The ask is built once and a client is made from it
// per call, each billed to the judge's own seat.
taskLanded := poolJudgeHook(settings, settings.ProfileDir, workspace,
config.CrewCatalog, poolJudgeAsk(settings, settings.ProfileDir), time.Now, "task")
config.CrewCatalog, poolJudgeAsk(proc.liveSettings(settings), settings.ProfileDir), time.Now, "task")
// The runs a live process would have judged but a process death left unjudged,
// and the headless doors that never had this hook: at start, on a goroutine
// nobody waits on, judge the resumed session's own final-state nodes and the
// pending file's rows, each exactly once, bounded so it never holds the prompt. The
// process tracker cancels and joins it at close.
poolErrandGoCtx(settings.ProfileDir, "pool/judge-sweep", func(ctx context.Context) {
poolJudgeSweepRun(ctx, settings, settings.ProfileDir, found.Place.Tasks(),
config.CrewCatalog, poolJudgeAsk(settings, settings.ProfileDir), time.Now)
config.CrewCatalog, poolJudgeAsk(proc.liveSettings(settings), settings.ProfileDir), time.Now)
})

cfg := session.Config{
Expand Down
12 changes: 12 additions & 0 deletions cmd/codeaf/chatv3_media.go
Original file line number Diff line number Diff line change
Expand Up @@ -236,7 +236,19 @@ func v3MediaPick(models *catalog.Catalog) func(string, string) (string, error) {
// absence law is a plain nil check. Without it, a build that could not make a
// client would put every generation tool on the belt and fail each one on its
// first call — the belt that lies, which is the thing the law exists to stop.
//
// NO KEY IS ABSENCE, NOT A FAULT, AND ABSENCE SAYS NOTHING. A first launch
// builds its conversation before setup has asked for a key, so this door is
// reached keyless on every new install; the line it used to log landed on the
// person's terminal just before the surface took it, and was the first thing
// they read after quitting (the fresh-install check of 2026-09-25). The key
// asked about is the one the client would carry — [config.Config.ClientConfig]
// resolves it the way [config.Config.MediaClient] does — and only a failure
// with a key in hand is a fault worth a line.
func v3MediaClient(settings config.Config) session.MediaGenerator {
if strings.TrimSpace(settings.ClientConfig(settings.Model).APIKey) == "" {
return nil
}
client, err := settings.MediaClient()
if err != nil || client == nil {
if err != nil {
Expand Down
16 changes: 16 additions & 0 deletions cmd/codeaf/chatv3_process.go
Original file line number Diff line number Diff line change
Expand Up @@ -842,3 +842,19 @@ func (s *v3Seam) anchor(agent interface {
}
return resolved, nil
}

// liveSettings answers a launch's settings as they stand NOW: the launch's own
// copy, with the account this process holds at the moment of asking laid over
// it. A launch copies the process's settings when it opens, which on a first
// launch is before setup has a key, and [v3Process.setAPIKey] reaches the
// process and its agents but never a copy something else kept. Anything that
// runs later on a launch's behalf and calls a model — the Model Pool's judge is
// the one — asks through this, so it carries the key the person has given
// rather than the one the boot did not have.
func (p *v3Process) liveSettings(base config.Config) func() config.Config {
return func() config.Config {
live := base
live.APIKey, live.Sources = p.currentAccount()
return live
}
}
5 changes: 5 additions & 0 deletions cmd/codeaf/chatv3_surface.go
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,11 @@ func runSurface(ctx context.Context, options tui3.Options) error {
wire, closeWire := v3Wire()
defer closeWire()
options.Output = wire
// THE USAGE NOTICE IS THE SURFACE'S TO SHOW when this chat still owes it
// (telemetry_lifecycle.go), because every door that draws a surface comes
// through here and a notice printed before the alt screen is a notice
// nobody reads until they quit.
telemetryNoticeForSurface(&options)
return withSurfaceLogger(options.ProfileDir, func() error {
return runSurfaceProgram(ctx, options)
})
Expand Down
169 changes: 169 additions & 0 deletions cmd/codeaf/firstrun_quiet_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,169 @@
package main

// A first launch prints nothing a person did not ask for, and the minutes after
// a key is pasted into setup are the same install's first minutes: the fresh-
// install check of 2026-09-25 (dev 1194d4b8f) found a raw log line on the
// terminal before any key existed, and a Model Pool judge that went on asking
// with the key the boot did not have, failing every landing of the first
// session and marking each one judged so no later start ever scored it.

import (
"bytes"
"context"
"io"
"log"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"strings"
"sync"
"testing"
"time"

"github.com/Agent-Field/codeaf/internal/config"
"github.com/Agent-Field/codeaf/internal/pool/judge"
"github.com/Agent-Field/codeaf/internal/provider"
)

// captureStandardLog points the standard logger at a buffer for one test and
// puts the writer that was there back afterwards; before the surface parks it,
// the standard logger IS the person's terminal.
func captureStandardLog(t *testing.T) *bytes.Buffer {
t.Helper()
var buf bytes.Buffer
previous := log.Writer()
log.SetOutput(&buf)
t.Cleanup(func() { log.SetOutput(previous) })
return &buf
}

// Contract 3.1: with no key, the media client is an ABSENT capability — no
// generation tool on the belt and no line on the terminal. Nothing asked for
// it, so nothing says it is missing.
func TestAFirstLaunchWithNoKeyLeavesMediaAbsentWithoutALine(t *testing.T) {
t.Setenv("CODEAF_HOME", t.TempDir())
t.Setenv("OPENROUTER_API_KEY", "")
t.Setenv("OPENAI_API_KEY", "")
logged := captureStandardLog(t)

settings := config.Config{Model: "vendor/model", BaseURL: "https://openrouter.ai/api/v1"}
if media := v3MediaClient(settings); media != nil {
t.Fatalf("a keyless install built a media client: %#v", media)
}
if logged.Len() != 0 {
t.Fatalf("a keyless first launch wrote %q to the terminal, want nothing", logged.String())
}
}

// Contract 3.1, the other half: a media client that fails for any reason
// other than the missing key is still a fault somebody may need to read, so
// that one keeps its line in the log.
func TestAMediaFailureThatIsNotTheMissingKeyStillLogs(t *testing.T) {
t.Setenv("CODEAF_HOME", t.TempDir())
logged := captureStandardLog(t)

settings := config.Config{Model: "vendor/model", APIKey: "a-key", BaseURL: ""}
if media := v3MediaClient(settings); media != nil {
t.Fatalf("a media client with no base URL was built: %#v", media)
}
if !strings.Contains(logged.String(), "media: no generation endpoint") {
t.Fatalf("a real media fault left no line in the log: %q", logged.String())
}
}

// Contract 3.4: a judge with no key is an absent judge. It leaves no reason
// naming the missing key and, above all, no judged marker, so the landing is
// still there for the next start's sweep to score once a key exists.
func TestAJudgeWithNoKeyLeavesTheLandingForTheNextStart(t *testing.T) {
t.Setenv("CODEAF_HOME", t.TempDir())
t.Setenv("CODEAF_MODEL_POOL", "on")
t.Setenv("CODEAF_MODEL_POOL_SUBMIT_URL", "http://127.0.0.1:1/submit")
profileDir := t.TempDir()
poolDir := config.ProfilePath(profileDir, "pool")

asked := map[string]bool{}
noKey := func(model string) judge.Ask {
return func(context.Context, string, string) (string, error) {
asked[model] = true
return "", provider.ErrNoAPIKey
}
}
hook := poolJudgeHook(config.Config{}, profileDir, t.TempDir(), poolTestCatalog, noKey, time.Now, "task")
if hook == nil {
t.Fatal("a pool whose mode allows reading built no hook")
}
landing := poolTestLanding()
hook(landing)

if alreadyJudged(poolDir, landing.ID, landing.Attempt) {
t.Fatal("a landing no judge could ask for want of a key was marked judged, so no later start will ever score it")
}
if last := readJudgeLast(poolDir); last != nil && strings.Contains(last.Reason, "no API key") {
t.Fatalf("the missing key was recorded as the judge's reason: %q", last.Reason)
}
if len(asked) > 1 {
t.Fatalf("the judge asked %d candidates with no key to ask with, want it to stop at the first", len(asked))
}
}

// Contract 3.3: the chat's judge is built when the conversation is, which on
// a first launch is before setup has a key. The key pasted into setup reaches
// the process ([v3Process.setAPIKey]), and the judge's next question carries
// it — the judge reads the process's settings when it asks, not the copy the
// boot held.
func TestTheChatsJudgeAsksWithTheKeyPastedAfterBoot(t *testing.T) {
t.Setenv("CODEAF_HOME", t.TempDir())
t.Setenv("OPENROUTER_API_KEY", "")
t.Setenv("OPENAI_API_KEY", "")

var mu sync.Mutex
var bearer []string
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_, _ = io.Copy(io.Discard, r.Body)
mu.Lock()
bearer = append(bearer, r.Header.Get("Authorization"))
mu.Unlock()
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"id":"x","object":"chat.completion","model":"other/judge","choices":[{"index":0,"message":{"role":"assistant","content":"{\"score\": 88, \"reason\": \"it does what was asked\"}"},"finish_reason":"stop"}],"usage":{"prompt_tokens":10,"completion_tokens":5}}`))
}))
defer server.Close()

profileDir := t.TempDir()
boot := config.Config{BaseURL: server.URL, ProfileDir: profileDir}
proc := &v3Process{ProfileDir: profileDir, Settings: boot}
ask := poolJudgeAsk(proc.liveSettings(boot), profileDir)

if err := proc.setAPIKey("key-pasted-in-setup"); err != nil {
t.Fatal(err)
}
answer, err := ask("other/judge")(context.Background(), "system", "user")
if err != nil {
t.Fatalf("the judge asked after setup failed: %v", err)
}
if !strings.Contains(answer, "88") {
t.Fatalf("the judge's answer = %q", answer)
}
mu.Lock()
defer mu.Unlock()
if len(bearer) == 0 || bearer[len(bearer)-1] != "Bearer key-pasted-in-setup" {
t.Fatalf("the judge asked with %q, want the key pasted in setup", bearer)
}
}

// Contract 3.3, the wiring: the chat door builds its judge from the live
// settings, never from the boot's copy. The helper above is only as good as
// the one line that uses it.
func TestTheChatDoorBuildsItsJudgeFromTheLiveSettings(t *testing.T) {
source, err := os.ReadFile(filepath.Join(".", "chatv3.go"))
if err != nil {
t.Fatal(err)
}
text := string(source)
if strings.Contains(text, "poolJudgeAsk(settings,") {
t.Fatal("chatv3.go builds a judge from the boot's settings copy, which never learns the key pasted into setup")
}
if strings.Count(text, "poolJudgeAsk(proc.liveSettings(settings)") < 2 {
t.Fatal("chatv3.go does not build both its judges (the landing hook and the start sweep) from proc.liveSettings")
}
}
14 changes: 14 additions & 0 deletions cmd/codeaf/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,30 @@ import (
"github.com/Agent-Field/codeaf/internal/plan"
"github.com/Agent-Field/codeaf/internal/plandb"
"github.com/Agent-Field/codeaf/internal/router"
"github.com/Agent-Field/codeaf/internal/session"
"github.com/Agent-Field/codeaf/internal/telemetry"
"github.com/Agent-Field/codeaf/internal/trace"
codeupdate "github.com/Agent-Field/codeaf/internal/update"
)

func main() {
home.Adopt(log.Printf)
registerRunningCLI()
os.Exit(execute())
}

// registerRunningCLI tells the worker harness which binary a worker's `codeaf`
// reaches: THIS one, under whatever file name it was installed. A worker's shell
// is taught `codeaf patch`, and without this the word resolved on the machine's
// PATH — nothing on a devaf install, an older codeaf on the fresh-install check
// of 2026-09-25 (internal/session's [session.SetRunningCLI] says why it is
// registered here rather than probed there).
func registerRunningCLI() {
if self, err := os.Executable(); err == nil {
session.SetRunningCLI(self)
}
}

// surfaceMaxProcs is the GOMAXPROCS a surface runs under on a machine bigger
// than this. It is not the machine's core count, and the number was chosen by
// counting what the runtime does with the ones above it, not by taste — see
Expand Down
19 changes: 18 additions & 1 deletion cmd/codeaf/poolrecord.go
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,15 @@ func poolJudgeLandingContext(ctx context.Context, settings config.Config, profil
judgeID = candidate
break
}
// A JUDGE WITH NO KEY IS AN ABSENT JUDGE, NOT A FAILED ONE. Every
// candidate would meet the same missing key, so none is asked after this
// one, and the landing is left exactly as it was: no reason naming the
// key in judge-last.json and, above all, no judged marker — the marker is
// permanent, and a landing marked here was one no later start, key in
// hand, would ever score. The restart sweep judges it then.
if errors.Is(err, provider.ErrNoAPIKey) {
return
}
// No seat at all came back: that is the judge's own failure — a 429, a
// timeout, a refusal — and not a verdict on the run, so its reason is
// said here and the next candidate is asked.
Expand Down Expand Up @@ -308,9 +317,17 @@ func outboxPath(poolDir string) string {
// poolJudgeAsk builds the maker of one judge's ask: a client per call, built
// from the model's own config, the answer read as the judge's plain string,
// and the call billed to the judge's own seat.
func poolJudgeAsk(settings config.Config, profileDir string) func(model string) judge.Ask {
//
// THE SETTINGS ARE READ WHEN THE JUDGE ASKS, NOT WHEN IT IS BUILT. The chat
// builds its judge with the conversation, and on a first launch that is before
// setup has a key: a judge holding the boot's copy asked every landing of the
// install's first session keyless, and failed each one. live is the door's
// answer at the moment of asking ([v3Process.liveSettings]), so a key pasted
// into setup, or changed in /settings, is the key the next judgment carries.
func poolJudgeAsk(live func() config.Config, profileDir string) func(model string) judge.Ask {
return func(model string) judge.Ask {
return func(ctx context.Context, system, user string) (string, error) {
settings := live()
client, err := provider.NewClient(settings.ClientConfig(model))
if err != nil {
return "", err
Expand Down
56 changes: 53 additions & 3 deletions cmd/codeaf/telemetry_lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ import (
"github.com/Agent-Field/codeaf/internal/guard"
"github.com/Agent-Field/codeaf/internal/telemetry"
"github.com/Agent-Field/codeaf/internal/trace"
"github.com/Agent-Field/codeaf/internal/tui3"
)

// telemetryConfiguredOff is the config's one answer to the ladder, read the
Expand Down Expand Up @@ -85,10 +86,25 @@ func telemetryBegin() telemetrySession {
// the sentence that asks permission to send, and a pipe that will never
// send has nobody to ask — and marking it shown would create the very
// directory this run promised not to write.
//
// A CHAT OWES IT TO THE SURFACE INSTEAD. A chat on a terminal is about to
// hand that terminal to a full-screen surface, and a notice printed here
// sat on the normal screen underneath it: read only after quitting, and
// marked seen from the moment it was printed, so the first exit sent the
// counts at the instant the notice first became visible. So a chat marks
// nothing here. It owes the notice, [runSurface] hands it to the surface,
// and the surface marks it once a frame has drawn it; a chat that never
// draws one (`--once`) prints it on the road it does take
// ([payTelemetryNoticeOnStderr]). Until the mark, [telemetry.Flush] sends
// nothing.
if telemetry.Enabled() && !telemetry.NoticeShown() && !telemetryHasJSON(args) &&
(mode == telemetry.ModeTask || stderrIsTerminal()) {
telemetry.PrintNotice()
telemetry.MarkNoticeShown()
(mode == telemetry.ModeTask || noticeTerminal()) {
if mode == telemetry.ModeChat {
telemetryNoticeOwed = true
} else {
telemetry.PrintNotice()
telemetry.MarkNoticeShown()
}
}
// Both opening events go through SpoolSync, not the fire-and-forget Spool:
// first_run must be on disk before session_started even exists, and a run's
Expand Down Expand Up @@ -238,6 +254,40 @@ func telemetryStopReason(code int) string {
// of the ladder's own — but the notice's rule is narrower than that, because
// `codeaf do 2>/dev/null` in a person's own script is not CI and still must
// not spend the one line the person will never read.
// noticeTerminal is [stderrIsTerminal] as the notice asks it, a seam so a test
// can stand a terminal behind a process whose stderr is a pipe.
var noticeTerminal = stderrIsTerminal

// telemetryNoticeOwed says this chat's notice is owed to the surface rather
// than printed: set at the start ([telemetryBegin]) and paid by the frame that
// draws it ([runSurface]).
var telemetryNoticeOwed bool

// payTelemetryNoticeOnStderr prints an owed notice on a chat road that draws no
// surface — `--once` writes its answer to the terminal as plain lines, so the
// notice printed ahead of it is read ahead of it — and marks it seen.
func payTelemetryNoticeOnStderr() {
if !telemetryNoticeOwed {
return
}
telemetryNoticeOwed = false
telemetry.PrintNotice()
telemetry.MarkNoticeShown()
}

// telemetryNoticeForSurface lays an owed notice on the surface's options: the
// exact text, and the mark the surface calls after the frame that drew it.
func telemetryNoticeForSurface(options *tui3.Options) {
if !telemetryNoticeOwed {
return
}
options.TelemetryNotice = telemetry.Notice
options.TelemetryNoticeShown = func() {
telemetryNoticeOwed = false
telemetry.MarkNoticeShown()
}
}

func stderrIsTerminal() bool {
return stdinIsTerminal(os.Stderr)
}
Loading
Loading