Skip to content
Closed
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
9 changes: 4 additions & 5 deletions cmd/codeaf/chatv3_standing.go
Original file line number Diff line number Diff line change
Expand Up @@ -460,8 +460,7 @@ func v3StandingSeam(seam *session.Standing) tui3.StandingSeam {
// named as an interface so a test can hand it a definition pointing at a dead
// path without going anywhere near this machine's launchd.
type backgroundTimer interface {
Drift() (standing.WatchDrift, error)
Install(ctx context.Context) error
Repair(ctx context.Context) (standing.WatchDrift, error)
}

// repairBackgroundChecks puts a drifted timer back, and says one line about it
Expand Down Expand Up @@ -489,11 +488,11 @@ func repairBackgroundChecks(watch backgroundTimer, wanted bool) string {
if watch == nil || !wanted {
return ""
}
drift, err := watch.Drift()
if err != nil || !drift.Present || !drift.Stale {
drift, err := watch.Repair(context.Background())
if !drift.Present {
return ""
}
if err := watch.Install(context.Background()); err != nil {
if err != nil {
return "could not put the background check back: " + err.Error()
}
if drift.Gone && drift.Executable != "" {
Expand Down
12 changes: 8 additions & 4 deletions cmd/codeaf/chatv3_standing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -62,11 +62,15 @@ type driftedTimer struct {
fail error
}

func (d *driftedTimer) Drift() (standing.WatchDrift, error) { return d.drift, d.err }

func (d *driftedTimer) Install(context.Context) error {
func (d *driftedTimer) Repair(context.Context) (standing.WatchDrift, error) {
if d.err != nil {
return standing.WatchDrift{}, d.err
}
if !d.drift.Present || !d.drift.Stale {
return standing.WatchDrift{}, nil
}
d.installs++
return d.fail
return d.drift, d.fail
}

// A TIMER POINTING AT A PROGRAM THAT MOVED RUNS NOTHING, and nothing on screen
Expand Down
47 changes: 23 additions & 24 deletions cmd/codeaf/do_engine_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -149,6 +149,23 @@ func beltRunEnv(t *testing.T) string {
return home
}

// beltModelPins preserves the fixture's resource settings while changing the
// seats a test intends to exercise. Replacing the profile wholesale would
// silently re-enable the real machine gate on a busy test host (#1525).
func beltModelPins(t *testing.T, profileDir string, pins map[string]string) {
t.Helper()
settings := config.NewSettings(config.SettingsOptions{ProfileDir: profileDir})
for key, value := range pins {
row, ok := settings.Row(key)
if !ok {
t.Fatalf("missing setting %s", key)
}
if err := row.Apply(value); err != nil {
t.Fatal(err)
}
}
}

// beltPlandbDoor is the real plandb CLI behind the resolver's override, built
// once for the package. THE LOOP ENDS IN THE STORE: a worker's task is done
// when `plandb done` marks it so and no other way, so a scripted worker that
Expand Down Expand Up @@ -376,16 +393,10 @@ func TestDoOnTheRunEngineSeatsEveryLaunchOnTheDoorsModels(t *testing.T) {
if err := os.MkdirAll(profileDir, 0o700); err != nil {
t.Fatal(err)
}
rows, err := json.Marshal(map[string]string{
beltModelPins(t, profileDir, map[string]string{
config.KeyTierWorkerModel: "vendor/profile-worker",
config.KeyTierMastermindModel: "vendor/profile-thinking",
})
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(config.BudgetConfigPath(profileDir), rows, 0o600); err != nil {
t.Fatal(err)
}

workspace := beltRepoWorkspace(t)
const (
Expand Down Expand Up @@ -433,7 +444,7 @@ func TestDoOnTheRunEngineSeatsEveryLaunchOnTheDoorsModels(t *testing.T) {
}

var stdout, stderr strings.Builder
err = doErrand(doRequest{
err := doErrand(doRequest{
task: "write out.txt and say what you did", workspace: workspace, asJSON: true,
timeout: 60 * time.Second, slots: bound(1), model: workModel, planModel: planModel, checkModel: planModel,
stdout: &stdout, stderr: &stderr, newBeltCompleter: newBelt,
Expand Down Expand Up @@ -668,18 +679,12 @@ func TestDoOnTheRunEngineSeatsACheckOnTheCheckModel(t *testing.T) {
if err := os.MkdirAll(profileDir, 0o700); err != nil {
t.Fatal(err)
}
rows, err := json.Marshal(map[string]string{
beltModelPins(t, profileDir, map[string]string{
config.KeyTierLowModel: "vendor/profile-small",
config.KeyTierWorkerModel: "vendor/profile-worker",
config.KeyTierHighModel: "vendor/profile-careful",
config.KeyTierMastermindModel: "vendor/profile-thinking",
})
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(config.BudgetConfigPath(profileDir), rows, 0o600); err != nil {
t.Fatal(err)
}

workspace := beltRepoWorkspace(t)
const (
Expand Down Expand Up @@ -722,7 +727,7 @@ func TestDoOnTheRunEngineSeatsACheckOnTheCheckModel(t *testing.T) {
}

var stdout, stderr strings.Builder
err = doErrand(doRequest{
err := doErrand(doRequest{
task: "write out.txt and say what you did", workspace: workspace, asJSON: true,
timeout: 60 * time.Second, slots: bound(1),
model: workModel, planModel: planModel, checkModel: checkModel,
Expand Down Expand Up @@ -767,17 +772,11 @@ func TestDoOnTheRunEngineSeatsAnUnpinnedCheckOnTheCrewsChecker(t *testing.T) {
if err := os.MkdirAll(profileDir, 0o700); err != nil {
t.Fatal(err)
}
rows, err := json.Marshal(map[string]string{
beltModelPins(t, profileDir, map[string]string{
config.KeyTierWorkerModel: "vendor/profile-worker",
config.KeyTierHighModel: "vendor/profile-careful",
config.KeyTierMastermindModel: "vendor/profile-thinking",
})
if err != nil {
t.Fatal(err)
}
if err := os.WriteFile(config.BudgetConfigPath(profileDir), rows, 0o600); err != nil {
t.Fatal(err)
}

workspace := beltRepoWorkspace(t)
// The same one-seat shape as the two tests above.
Expand Down Expand Up @@ -813,7 +812,7 @@ func TestDoOnTheRunEngineSeatsAnUnpinnedCheckOnTheCrewsChecker(t *testing.T) {
}

var stdout, stderr strings.Builder
err = doErrand(doRequest{
err := doErrand(doRequest{
task: "write out.txt and say what you did", workspace: workspace, asJSON: true,
timeout: 60 * time.Second, slots: bound(1),
stdout: &stdout, stderr: &stderr, newBeltCompleter: newBelt,
Expand Down
8 changes: 8 additions & 0 deletions docs/changes/unreleased/1622-standing-timer-ownership.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,8 @@
---
kind: fixed
title: First standing approval preserves the shared timer's owner
pr: 1622
surface: [engine]
invalidates:
- "Approving the first standing item in a new profile could take another profile's OS timer despite launch-time ownership protection. Implicit setup and repair now check ownership under the same interprocess lock as explicit settings changes. Another owner is retained and a blocked setup is reported honestly."
---
11 changes: 11 additions & 0 deletions internal/manual/chat/keeping-an-eye.md
Original file line number Diff line number Diff line change
Expand Up @@ -701,6 +701,17 @@ program — and a launch speaks only for its own pair.
else's: it neither claims it nor rewrites it, and its `/status` says nothing is
checking that home.

Approving the first standing item follows the same ownership rule. It installs a
missing timer or repairs this home's stale timer, but never takes another home's
or live build's timer. If another home owns it, the item is saved and CodeAF says
that the background check could not be installed and the existing timer was left
unchanged. The confirmation also says the item was saved but needs a codeaf window
open for this home while no background timer checks it. A non-waking permission
rule does not need a timer. This home is not being checked in the background; use the background
checks row in `/settings` only when you deliberately want to move the shared timer.
Ownership checks and changes are serialized across processes, including that
explicit settings action.

## Do reminders work over --host — yes, on the far machine

Yes, and this is the one ambient thing a connection does not take away. Over
Expand Down
41 changes: 39 additions & 2 deletions internal/session/standing_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -724,12 +724,18 @@ func TestStandingListSpeaksThePersonsWords(t *testing.T) {
// fakeWatch is this machine's scheduler, stood in for. Nothing in these tests
// goes near launchd.
type fakeWatch struct {
ensures int
installs int
uninstalls int
fail error
installed bool
}

func (w *fakeWatch) Ensure(ctx context.Context) error {
w.ensures++
return w.Install(ctx)
}

func (w *fakeWatch) Install(context.Context) error {
w.installs++
if w.fail != nil {
Expand Down Expand Up @@ -791,8 +797,8 @@ func TestTheFirstThingThatStandsTurnsBackgroundChecksOnAndSaysSo(t *testing.T) {
watch := &fakeWatch{}
events := standRatify(t, store, watch, t.TempDir())

if watch.installs != 1 {
t.Fatalf("the timer was installed %d times, want exactly 1", watch.installs)
if watch.installs != 1 || watch.ensures != 1 {
t.Fatalf("implicit ensures=%d, installs=%d; want one ensure", watch.ensures, watch.installs)
}
if line := backgroundLine(events); line != standingBackgroundLine {
t.Fatalf("the line said %q, want %q", line, standingBackgroundLine)
Expand Down Expand Up @@ -2002,3 +2008,34 @@ func standingNextUpdate(t *testing.T, lane <-chan Event) Event {
}
}
}

func TestFirstStandingApprovalPreservesAnotherProfilesTimer(t *testing.T) {
store := newFakeStanding(t)
watch := &fakeWatch{fail: standing.ErrWatchOwned}
events := standRatify(t, store, watch, t.TempDir())
if watch.ensures != 1 || watch.installed {
t.Fatalf("implicit ownership refusal: ensures=%d installed=%v", watch.ensures, watch.installed)
}
line := backgroundLine(events)
if !strings.HasPrefix(line, standingBackgroundFailed) || !strings.Contains(line, "existing timer was left unchanged") {
t.Fatalf("ownership refusal was not told honestly: %q", line)
}
if len(store.created) != 1 {
t.Fatal("timer refusal must not discard the approved standing item")
}
if output := toolOutput(t, events, "stand"); !strings.Contains(output, "Background checks are not installed") || !strings.Contains(output, "item was saved") {
t.Fatalf("model receipt hid background unavailability: %q", output)
}
// The first-setup marker suppresses repeated UI notices, not truthful receipts.
events = standRatify(t, store, watch, t.TempDir())
if output := toolOutput(t, events, "stand"); !strings.Contains(output, "Background checks are not installed") {
t.Fatalf("later receipt claimed background execution: %q", output)
}
}

func TestNonWakingRuleDoesNotRequireBackgroundTimer(t *testing.T) {
agent := &Agent{config: Config{Standing: &Standing{Watch: &fakeWatch{}}}}
if note := agent.standingBackgroundLimitation(standing.Item{When: standing.When{Kind: standing.WhenHold}}); note != "" {
t.Fatalf("permission rule was said to need a timer: %q", note)
}
}
27 changes: 17 additions & 10 deletions internal/session/steer_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -867,13 +867,15 @@ func TestASteerAdoptsAnOldBashAndItsExitArrivesLater(t *testing.T) {

func TestASteerWaitsForAYoungBash(t *testing.T) {
t.Parallel()
release := filepath.Join(t.TempDir(), "release-young-bash")
completer := &scriptedCompleter{steps: []step{
func(context.Context, []ai.Message) (*ai.Response, error) {
return toolResponse("young-bash", "bash", `{"command":"sleep 0.5; echo young-finished"}`), nil
},
bashCall("young-bash", "while [ ! -f "+shellQuoted(release)+" ]; do sleep 0.01; done; echo young-finished"),
func(context.Context, []ai.Message) (*ai.Response, error) { return textResponse("done"), nil },
}}
agent, _ := newTestAgent(t, completer, nil)
// The command stays observable until released; its age is a separate
// input, not a race against a half-second sleep on a busy test machine.
advanceSteerAge(agent, 0)
turn := mustSubmit(t, agent, "run the quick check")
waitFor(t, "young foreground bash to start", func() bool { return len(agent.inFlightBash.snapshot()) == 1 })
agent.mu.Lock()
Expand All @@ -882,18 +884,23 @@ func TestASteerWaitsForAYoungBash(t *testing.T) {
if generation != nil {
t.Fatal("a young bash still had a model generation to cut")
}
began := time.Now()
steered := mustSteer(t, agent, "then read the result")
collect(t, turn)
events := collect(t, steered)
if elapsed := time.Since(began); elapsed < 200*time.Millisecond {
t.Fatalf("young bash landed in %s, want the batch to finish first", elapsed)
select {
case event := <-steered:
if got := steerLanding([]Event{event}); got != "waiting for the running step" {
t.Fatalf("steer landing while bash is held = %q", got)
}
case <-time.After(10 * time.Second):
t.Fatal("the steer was not accepted while the bash was held")
}
if list := agent.jobs.list(); list != "No background jobs." {
t.Fatalf("young bash became a job: %q", list)
}
if got := steerLanding(events); got != "waiting for the running step" {
t.Fatalf("steer landing = %q", got)
writeFile(t, release, "finish now")
collect(t, turn)
collect(t, steered)
if got := roleText(completer.request(1), "tool"); !strings.Contains(got, "young-finished") {
t.Fatalf("the resumed turn did not receive the foreground result: %q", got)
}
}

Expand Down
3 changes: 1 addition & 2 deletions internal/session/task_phase_test.go
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
package session

import (
"context"
"strings"
"testing"
"time"
Expand Down Expand Up @@ -330,7 +329,7 @@ func TestTheHandoverSaysItIsBriefingAWorkerWhileItWritesTheBrief(t *testing.T) {
ran := make(ranNodes, 2)
stubbedGraph(agent, func(node *TaskNode) { ran <- node })

events, err := agent.Submit(context.Background(), "work through the four things I listed and report back")
events, err := agent.Submit(watchedContext(agent), "work through the four things I listed and report back")
if err != nil {
t.Fatalf("Submit: %v", err)
}
Expand Down
4 changes: 3 additions & 1 deletion internal/session/task_quick_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -350,6 +350,9 @@ func TestTwoQuickTasksClaimingOneFileRunOneAfterTheOther(t *testing.T) {

agent, graph, _ := quickAgent(t, completer)
events := mustSubmit(t, agent, "rewrite both sections of notes.md")
// Admission precedes journaling the tool receipt. Finish the parent turn
// while both workers are held before inspecting what the model was told.
collect(t, events)

// WHICH OF THE TWO WINS IS THE SCHEDULER'S, and the law is about the pair
// rather than about either one: exactly one of them is working, and the
Expand Down Expand Up @@ -386,7 +389,6 @@ func TestTwoQuickTasksClaimingOneFileRunOneAfterTheOther(t *testing.T) {
}

close(release)
collect(t, events)
waitDoneNode(t, running)
waitDoneNode(t, waiting)
if state := waiting.stateNow(); state != TaskDone {
Expand Down
19 changes: 15 additions & 4 deletions internal/session/task_run_belt_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -164,11 +164,22 @@ func registerBeltRunEngine(t *testing.T, engine RunEngine) {
// directory's removal, and lost it under load as "directory not empty".
func endBeltRun(t *testing.T, agent *Agent, double *beltRunDouble) {
t.Helper()
agent.beltMu.Lock()
run := agent.beltRun
agent.beltMu.Unlock()
close(double.release)
beltRunWaitFor(t, "the run to end", func() bool {
agent.beltMu.Lock()
defer agent.beltMu.Unlock()
return agent.beltRun == nil
if run == nil {
return
}
// releaseBeltRun clears the agent's pointer before it closes the store.
// The run's completion channel covers that final write/close as well.
beltRunWaitFor(t, "the run and its store to close", func() bool {
select {
case <-run.over:
return true
default:
return false
}
})
}

Expand Down
5 changes: 3 additions & 2 deletions internal/session/task_run_orphan_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -210,8 +210,9 @@ func TestClosingUnderAProgramsRunEndsItInItsStoreFirst(t *testing.T) {
t.Fatalf("the second run is on root %q with brief %q", spec.Store.RootID(), spec.Store.Task(spec.Store.RootID()).Description)
}
endBeltRun(t, again, second)
close(first.release)
<-first.finished
// Start returning is not the end of the owner's bookkeeping. Join the
// closed agent's run before the fixture removes its store and workspace.
endBeltRun(t, agent, first)
}

// THE RESTORE ROAD: a conversation read back from disk whose run row comes
Expand Down
Loading
Loading