Skip to content
Draft
29 changes: 27 additions & 2 deletions cmd/codeaf/app.go
Original file line number Diff line number Diff line change
Expand Up @@ -1697,7 +1697,7 @@ func (b *appBackend) bootSettle(a *appAgent, started bool) {
}
if stop {
b.locked(func() { live.stopping = live.running })
live.conn.Conv.Interrupt()
appStopByPerson(live.conn.Conv)
return
}
for _, s := range early {
Expand Down Expand Up @@ -3419,11 +3419,36 @@ func (b *appBackend) Stop(id string) error {
}
if live := b.liveOf(a); live != nil {
b.locked(func() { live.stopping = live.running })
live.conn.Conv.Interrupt()
appStopByPerson(live.conn.Conv)
}
return nil
}

// appStopGrace is how long the app's Stop waits for a turn to let go before
// the engine abandons it. It is the terminal's figure and argued there
// (internal/tui3's stopGrace): far above every settle that can be measured, so
// it fires only on a wait that never looks at its context.
const appStopGrace = 10 * time.Second

// appBoundedStop is a conversation whose stop can carry its second stage.
type appBoundedStop interface {
InterruptWithin(grace time.Duration)
}

// appStopByPerson is the person's Stop, BOUNDED WHERE THE CONVERSATION CAN BE.
// An interrupt alone only cuts the turn's context, and a turn parked on a wait
// that ignores it (a tool that takes a lock, a read that never looks) stayed
// "working" until the app was quit: the app has no second key to press, so the
// engine is given the bound with the stop ([session.Agent.InterruptWithin]).
// The turn's stream then closes and the turn settles as stopped by the person.
func appStopByPerson(conv appConversation) {
if bounded, ok := conv.(appBoundedStop); ok {
bounded.InterruptWithin(appStopGrace)
return
}
conv.Interrupt()
}

// CloseAgent lets the conversation go and says the agent is closed. Its journal
// stays on disk; agents.open or agents.send opens it again.
func (b *appBackend) CloseAgent(id string) error {
Expand Down
7 changes: 7 additions & 0 deletions cmd/codeaf/app_lend.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"sync"
"time"

"github.com/Agent-Field/codeaf/internal/appwire"
"github.com/Agent-Field/codeaf/internal/guard"
Expand Down Expand Up @@ -166,6 +167,12 @@ func (l lentAgent) InterruptFor(door session.StopDoor) {
l.Agent.InterruptFor(door)
}

// InterruptWithin is the person's stop without the view's bound behind it. A
// stage's turn belongs to the factory's runner, which is still reading it: the
// view letting go of that turn would leave the runner carrying on over work
// that had not ended. The job's own Stop is the door that ends a stage.
func (l lentAgent) InterruptWithin(time.Duration) { l.Agent.Interrupt() }

// InterruptNamed is [lentAgent.InterruptFor] with the window the door named.
func (l lentAgent) InterruptNamed(door session.StopDoor, name string) {
if endsAView(door) {
Expand Down
90 changes: 90 additions & 0 deletions cmd/codeaf/app_stop_bound_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,90 @@
package main

import (
"sync"
"testing"
"time"

"github.com/Agent-Field/codeaf/internal/appwire"
)

// THE APP'S STOP IS A BOUNDED STOP.
//
// The desktop app reported a Stop that did nothing: the Factory manager's turn
// was parked inside a call that never looked at its context, the interrupt
// changed nothing about it, and the app has no second key. So the person's
// Stop now hands the engine its bound with the stop ([appStopByPerson]) and
// the engine lets go of the turn when the bound runs out. This pins the app's
// half: the bound is asked for, and a turn the engine let go of settles as
// stopped by the person, once.

// boundedConversation is a [fakeConversation] whose stop can carry its second
// stage. Letting go is the engine closing the turn's stream, which the fake's
// hold stands for.
type boundedConversation struct {
*fakeConversation

mu sync.Mutex
graces []time.Duration
letGo func()
}

func (c *boundedConversation) InterruptWithin(grace time.Duration) {
c.mu.Lock()
c.graces = append(c.graces, grace)
letGo := c.letGo
c.mu.Unlock()
if letGo != nil {
letGo()
}
}

func TestAppStopAsksForItsBoundAndTheTurnLetGoSettlesStopped(t *testing.T) {
hold := make(chan struct{})
conv := &boundedConversation{fakeConversation: &fakeConversation{hold: hold}}
conv.letGo = sync.OnceFunc(func() { close(hold) })
rec := newAppRecorder()
backend := newFakeBackend(rec, func(ask appOpenAsk) (*appConn, error) {
return &appConn{Conv: conv, Workspace: ask.Workspace, Transcript: appFakeTranscript, Release: func() {}}, nil
})
defer backend.Close()
agent, err := mustCreate(t, backend, appwire.CreateAsk{Workspace: t.TempDir()})
if err != nil {
t.Fatal(err)
}
if _, err := backend.Send(agent.ID, "park on something that cannot be cancelled", ""); err != nil {
t.Fatal(err)
}
if err := backend.Stop(agent.ID); err != nil {
t.Fatalf("stop = %v", err)
}

conv.mu.Lock()
graces := append([]time.Duration(nil), conv.graces...)
conv.mu.Unlock()
if len(graces) != 1 || graces[0] != appStopGrace {
t.Fatalf("the stop asked for the bounds %v, want one of %s", graces, appStopGrace)
}
conv.fakeConversation.mu.Lock()
plain := conv.fakeConversation.interrupted
conv.fakeConversation.mu.Unlock()
if plain != 0 {
t.Fatalf("a bounded stop also sent %d plain interrupts", plain)
}

i := rec.waitFor(t, 5*time.Second, appTurnDone)
rec.mu.Lock()
defer rec.mu.Unlock()
if d := rec.data[i].(appwire.TurnDone); d.OK || d.ErrorKind != appwire.ErrorStopped || d.Error != "stopped" {
t.Fatalf("turn.done = %+v", d)
}
receipts := 0
for _, data := range rec.data {
if e, ok := data.(appwire.EntryAdded); ok && e.Entry.Kind == appwire.EntryFooter && e.Entry.Footer == appwire.FooterStoppedByYou {
receipts++
}
}
if receipts != 1 {
t.Fatalf("%d stop receipts, want 1", receipts)
}
}
Loading
Loading