Skip to content
Merged
14 changes: 14 additions & 0 deletions docs/changes/unreleased/1514-teams-behaviour.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
---
kind: fixed
title: one wake per member reply, team_stop reaches background members, a demoted manager loses its verbs
pr: 1514
surface: [chat, engine, remote]
invalidates:
- "A member's reply to its manager woke the manager, and the finished event of the same member turn woke it again seconds later, so the ten-wake loop breaker tripped after about five real rounds. One reply is now one wake, and ten wakes are ten replies."
- "team_stop did not stop a member codeaf opened in the background with no window on it; the tool and the manual said so. The member's own session now performs its manager's stop, and the tool and the manual say it stops."
- "A conversation removed as a team's manager kept every manager verb on its belt, each refusing when called (team.go said this was deliberate). The verbs now leave at its next step, and a remembered call is told the role went."
- "Closing a sub-team whose manager alone was working closed it at once while the manager kept running. The card now opens with Wrap up first leading, and Close now stops that manager's turn but keeps its tab."
- "A team's daily spend that could not be read counted as $0, so the cap stopped holding. A capped team now starts nothing new until the spend can be read, and says why."
- "A team's packet file rotation kept only waiting packets, so a decided answer lived one more rotation and was gone. Answers not yet handed to their asker, and today's cap decisions, are now carried across rotations."
- "Teams.Name and Teams.Propose with a zero or negative budget gave the engine's model call no deadline. Zero now means the engine's 30-second ceiling, and a negative budget makes no call."
---
26 changes: 26 additions & 0 deletions docs/design/conversations-and-teams/DESIGN.md
Original file line number Diff line number Diff line change
Expand Up @@ -274,6 +274,9 @@ tool call reads nothing until it returns, and a directive is advice a model may
is the person's own Stop: it ends the current turn, deletes nothing, and leaves background
tasks and jobs running.

(Changed 2026-09-25: a member codeaf opened without a window now reads its manager's
stop from Traffic during a running turn and ends that turn too.)

**What it may not do.** Approve members' permission prompts. Those are the person's safety
gate, and a manager that could answer them would make every approval rule meaningless. If
that is ever wanted, it is a separate, explicit per-team setting.
Expand Down Expand Up @@ -524,6 +527,13 @@ the person to type again is not running a team, so the lines that ask for an ans
something else starts, and the Traffic says `opened @handle; this team's auto-wake is off, so
no turn was started.`

(Changed 2026-09-25: a running managed member stats its team's Traffic once per
tick and reads it when it moves, to honor a manager's stop.)

(Changed 2026-09-25: a member's reply and the finished event from that same turn
start one manager wake, even when the event arrives after the settle. Ten wakes
therefore mean ten such reply rounds.)

**Mentioning a team or a chat from the composer.** `@` is still the one list
(`internal/tui3`'s `files.go`, `mention.go`). Its first row is the words team, chat
and file, each a press that types `@team:`, `@chat:` or `@file:` and keeps that
Expand Down Expand Up @@ -570,6 +580,9 @@ the Traffic's asking row and a note, not a question on the manager's tab. Over `
are off and say so. An unreadable teams file on the engine is not moved aside from a window over
`--host`; the window holds no teams until it can be read.

(Changed 2026-09-25: a stop also reaches a background member with no window;
starting a new member still needs the window's door.)

**Not in v1.** Collision flags when two members touch the same files, and dispatch of whole
plans. (Nested managers, a sub-team's manager a member of the parent team with reports flowing
up and directives down, were built later: 8.9.)
Expand Down Expand Up @@ -990,6 +1003,10 @@ it), and `Close team…`. Closable with `esc`.
the teams page's pane while that page stands, so the rail beside them still says which team
they are about; they covered the rail before. Elsewhere they are centred on the frame.

(Changed 2026-09-25: a manager of a team being closed is counted as working and
its current turn is stopped by Close now even if it is also in an open parent;
its tab stays open. Other shared conversations keep the prior rule.)

### 8.6 Where this departs from the brief, and why

- **The home flag resolves to the nearest manager up its chain**, so giving an unmanaged
Expand Down Expand Up @@ -1093,6 +1110,10 @@ different crossing and a new packet. A decided `raise` lifts the ceiling to
the person's own words hold until the day turns or the cap is changed. Spend is read through
`TeamSpend` only when `TeamSpendStamp` moved (per pool, per session), never per model request.

(Changed 2026-09-25: if a capped pool's spend read fails, the start is held
with the read error, no cap packet is raised, and the failed reading is retried
on the next check. An uncapped pool does not read spend.)

**Wrap up first: the door and the marker (for d2).** The interface appends ONE Traffic entry to
the team's log:

Expand Down Expand Up @@ -1129,6 +1150,11 @@ packet whole, trail and escalated state included). The reader folds the rotated
current one; a carry replaces what the older file said of that id. A waiting packet is never
lost; a decided one stays readable for one more rotation.

(Changed 2026-09-25: a decided answer not yet handed to its raiser is carried
across rotations, newest first within half the rotation size. Today's cap packets
are carried too. Delivery appends a `told` line; after that the answer has the
former one-rotation lifetime. Conflicts have their own Traffic directives.)

**Over `--host`.** All of the above runs where the conversations run, the engine: questions,
caps, the wrap-up clock and the verbs are the engine's session reading the engine's profile and
ledger, so a window over `--host` needs nothing new. The clock travels with the team:
Expand Down
25 changes: 19 additions & 6 deletions internal/manual/chat/team-manager.md
Original file line number Diff line number Diff line change
Expand Up @@ -159,8 +159,8 @@ message. With auto-wake off the conversation is still opened behind the one you
no turn is started; the traffic says `opened @lexer; this team's auto-wake is off, so no turn
was started. It reads the brief when it next runs.`, and the brief arrives on that next turn
the same way. When the manager stops a member,
this window stops it the way your own Stop would. Both happen only in a window that has those
conversations open.
its current turn ends the way your own Stop would, including when codeaf opened it in the
background without a window. Starting a new member still needs a window holding the manager.

## Who a Traffic row is from and who it is to

Expand Down Expand Up @@ -221,7 +221,7 @@ on one waits for you.
| `team_status` | every member's handle, title and state (running, asking, idle, failed), the question waiting, the files touched, and recent traffic | no |
| `team_read` | the end of one member's conversation, bounded; the member is not told | no |
| `team_send` | a message to one member, to several (one message, every handle in `to`), or to everyone, as a note (information, which waits) or a directive (an instruction, which starts an idle member) | no |
| `team_stop` | ends one member's current turn, the way your own Stop does: nothing is deleted, and its background tasks and jobs keep running. It is carried out by a window that has the member open; a member codeaf opened in the background, with no window on it, is not stopped and its turn runs to its end | no |
| `team_stop` | ends one member's current turn, the way your own Stop does, whether a window has it open or codeaf opened it in the background: nothing is deleted, and its background tasks and jobs keep running | no |
| `team_start` | a new member conversation with a handle and a brief; it opens in the team's folder and is handed the brief, marked as the manager's, on its first request. With kind `team` it starts a sub-team instead (see **Sub-teams**) | yes |
| `team_decide` | answers a decision packet waiting on the manager, most often a member's question: an option, or its own words | no |
| `team_escalate` | sends a packet waiting on the manager up, to its own manager or to you, with the reason it is not the manager's to decide | no |
Expand All @@ -234,6 +234,17 @@ every one of them is logged in the team's traffic. Questions, packets, caps and
on the page **Team questions, decisions and caps**.
Like any tool, each can be set to ask or allow in `/settings` under the tool approvals.

## What happens when a conversation stops being the manager

At its next step, a conversation removed as manager loses `team_status`, `team_read`,
`team_send`, `team_stop`, `team_start`, `team_decide`, `team_escalate` and
`team_close_report`. If it is still a member of a managed team, it keeps `team_post` and
`team_raise`; outside a team it loses those too. A remembered call to a removed manager
tool says `team_send is no longer one of your tools: this conversation no longer manages a
team.` A removed member tool says `team_post is no longer one of your tools: this
conversation is no longer a member of a team with a manager.` Made a manager again, it gets
the manager tools back at its next step.

## The member's verbs

A member of a team that has a manager has `team_post`: a message to the room (every member and
Expand Down Expand Up @@ -348,9 +359,10 @@ working, that is straight away. When it is idle, it depends on the kind of messa
- A **directive** starts an idle member's turn. The member is handed the directive, marked
`◆ directive from manager`, never as if you had typed it.
- A **note** wakes nobody. An idle member reads it when it next runs, for whatever reason.
- A member's **reply to the manager** (`team_post` to the manager), and a member finishing,
failing or starting to wait on you, start an idle manager's turn. Replies that arrive within
a few seconds of each other are gathered into one turn rather than one turn each.
- A member's **reply to the manager** (`team_post` to the manager), and a member finishing
without a reply, failing or starting to wait on you, start an idle manager's turn. A reply
and the end of the same turn are one wake, even if the ending arrives after the manager
already ran. Replies arriving within a few seconds are gathered into one turn.

A member no window has open is opened by codeaf in the background so it can run, and a window
that opens it later joins the running conversation. When that cannot be done, the traffic says
Expand All @@ -361,6 +373,7 @@ conversation is running. A wake spends through the same limits a turn you start
more bound it: one conversation is woken at most 20 times an hour, and a manager woken 10 times
by its team with nothing from you stops being woken and asks you instead, as a waiting line in
the traffic. It is woken again after you next say something to it.
The ten wakes count ten replies when each member turn replies and then finishes.

A team's auto-wake can be turned off. `team messages wake` in `/settings` under **Teams** is
the default every team inherits (on), and a team can override it for itself and the teams
Expand Down
19 changes: 17 additions & 2 deletions internal/manual/chat/team-questions-and-caps.md
Original file line number Diff line number Diff line change
Expand Up @@ -43,8 +43,15 @@ when it has no manager above it. Your answer is handed to the manager marked `
and wakes it.

Packets are kept in the profile of the machine the conversations run on, in
`teams/<id>/decisions.jsonl` for the team each was raised from. Past a megabyte the file starts
a new one, keeping every packet still waiting.
`teams/<id>/decisions.jsonl` for the team each was raised from.

## Do answered team questions survive packet file rotation

Past a megabyte the packet file starts a new one. It keeps every packet still waiting,
today's cap decisions, and decided answers not yet handed to their raisers, newest first
within half the rotation size. Older owed answers beyond that bound remain readable for one
more rotation. Once a raiser has been handed an answer, codeaf records that and keeps it
readable for one more rotation.

## A team's daily cap

Expand All @@ -66,6 +73,14 @@ When the pool reaches its cap:
A manager can never raise a cap: money is yours. Every held wake is one line in the traffic,
`held @web: harbor reached its $5 cap today`.

## What if today's team spending cannot be read

When a capped team's usage ledger or team list cannot be read, no new paid team work starts.
The Traffic says `held @web: harbor has a $5 daily cap and today's spend could not be read
(<error>), so nothing new starts until it can be read`. `team_start` gives the same reason
after `No new member starts:`. No cap card is raised against an unreadable figure. The next
check reads again, and an uncapped team does not read the ledger for this check.

## Two windows ask once when a team reaches its cap

You are asked once for that team, that day, and that ceiling. A second codeaf window, or a
Expand Down
8 changes: 5 additions & 3 deletions internal/manual/chat/teams-page.md
Original file line number Diff line number Diff line change
Expand Up @@ -302,11 +302,13 @@ away; `←` `→` choose a colour. `esc` or `Done` puts the card away.
**Close now** and **Cancel**. When the team has a manager, `Wrap up first` leads: the manager
is asked to have everyone finish and commit and to bring you a closing report, which arrives
as a card on this page with `Close` and `Keep going`; the team closes when you choose Close.
`Close now` stops every member's turn and closes their tabs at once. `Cancel` or `esc`
A working manager is named on the card even when it is also in a team above this one.
`Close now` stops every member's turn, including that manager's, and closes tabs belonging
only to the closing teams. A manager also in an open team above keeps its tab. `Cancel` or `esc`
changes nothing.

Closing a team closes the teams under it. A conversation that is also in another open team is
never stopped by the close. The conversation you are looking at keeps its tab, so a close never
Closing a team closes the teams under it. Other conversations that are also in another open team
are not stopped by the close. The conversation you are looking at keeps its tab, so a close never
moves you. A closed team spends nothing, is not on the conversations view or the strip, and
waits under `▸ Closed · N`.

Expand Down
6 changes: 6 additions & 0 deletions internal/manual/chat_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,7 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) {
{"how do I see all my teams and what waits on me", "teams-page"},
{"does renaming a team update the message box", "teams-page"},
{"how do I close a team", "teams-page"},
{"will closing a sub-team stop its manager if that manager is also in the parent team", "teams-page"},
{"the pane said the manager was open in another window", "teams-page"},
{"how do I reopen a closed team", "teams-page"},
{"where do I change one team's settings", "teams-page"},
Expand All @@ -68,10 +69,15 @@ func TestTheChatManualAnswersTheQuestionsPeopleAsk(t *testing.T) {
{"can the manager answer a member's permission prompt", "team-manager"},
{"how does a member post to the room", "team-manager"},
{"does a directive wake an idle member", "team-manager"},
{"why did my manager wake twice for one reply", "team-manager"},
{"can a manager stop a member working in the background without a window", "team-manager"},
{"what tools does a conversation lose when it stops being the manager", "team-manager"},
// Questions, packets, caps and wrapping up (team-questions-and-caps.md).
{"does a member's question go to the manager or to me", "team-questions-and-caps"},
{"what is a decision packet", "team-questions-and-caps"},
{"what happens when a team reaches its daily cap", "team-questions-and-caps"},
{"what if codeaf cannot read today's team spending", "team-questions-and-caps"},
{"do answered team questions survive packet file rotation", "team-questions-and-caps"},
{"why did two windows both ask me about the team cap", "team-questions-and-caps"},
{"how do I wrap up a team before closing it", "team-questions-and-caps"},
{"what happens to a wrap-up when codeaf restarts", "team-questions-and-caps"},
Expand Down
19 changes: 14 additions & 5 deletions internal/remote/teamask.go
Original file line number Diff line number Diff line change
Expand Up @@ -34,13 +34,16 @@ func teamAskKnown(agent any) bool { _, ok := agent.(teamAskDoor); return ok }
// suggestion.
const teamAskOffWord = "this engine cannot suggest team names or teams"

// teamAskWithin is the engine's bound on one ask: the wall's budget, or none
// when the wall sent none.
// teamAskCeiling bounds a peer's request while leaving the wall's five-second
// name wait and ten-second Organize wait in control of their own calls.
const teamAskCeiling = 30 * time.Second

// teamAskWithin uses the shorter of the wall's budget and the engine's ceiling.
func teamAskWithin(budget time.Duration) (context.Context, context.CancelFunc) {
if budget > 0 {
return context.WithTimeout(context.Background(), budget)
if budget == 0 || budget > teamAskCeiling {
budget = teamAskCeiling
}
return context.WithCancel(context.Background())
return context.WithTimeout(context.Background(), budget)
}

// teamAskCall answers the two asks from agent, and says whether the method was
Expand All @@ -61,6 +64,9 @@ func teamAskCall(agent WrappedAgent, call Frame) (json.RawMessage, bool, error)
if err != nil {
return nil, true, err
}
if args.Budget < 0 {
return nil, true, context.DeadlineExceeded
}
ctx, cancel := teamAskWithin(args.Budget)
defer cancel()
name, err := door.NameTeam(ctx, args.Titles)
Expand All @@ -74,6 +80,9 @@ func teamAskCall(agent WrappedAgent, call Frame) (json.RawMessage, bool, error)
if err != nil {
return nil, true, err
}
if args.Budget < 0 {
return nil, true, context.DeadlineExceeded
}
ctx, cancel := teamAskWithin(args.Budget)
defer cancel()
proposal, err := door.ProposeTeams(ctx, args.In)
Expand Down
52 changes: 52 additions & 0 deletions internal/remote/teamask_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ type askAgent struct {
in session.TeamProposalInput
deadline bool
left time.Duration
calls int
}

func (a *askAgent) NameTeam(ctx context.Context, titles []string) (string, error) {
Expand All @@ -37,13 +38,64 @@ func (a *askAgent) ProposeTeams(ctx context.Context, in session.TeamProposalInpu
}

func (a *askAgent) note(ctx context.Context) {
a.calls++
deadline, ok := ctx.Deadline()
a.deadline = ok
if ok {
a.left = time.Until(deadline)
}
}

func TestTeamAskBudgetBoundsEveryModelCall(t *testing.T) {
if teamAskCeiling <= 10*time.Second {
t.Fatalf("the engine ceiling %v cuts off the wall's Organize wait", teamAskCeiling)
}
for _, tc := range []struct {
name string
budget time.Duration
want time.Duration
}{
{"zero", 0, teamAskCeiling},
{"huge", time.Hour, teamAskCeiling},
{"small", 250 * time.Millisecond, 250 * time.Millisecond},
} {
for _, method := range []string{MethodTeamsName, MethodTeamsPropose} {
t.Run(tc.name+"/"+method, func(t *testing.T) {
far := &askAgent{fakeAgent: &fakeAgent{model: "m"}}
loop := askLoop(t, far)
var err error
if method == MethodTeamsName {
_, err = loop.Client.call(nil, method, TeamNameArgs{Budget: tc.budget})
} else {
_, err = loop.Client.call(nil, method, TeamProposeArgs{Budget: tc.budget})
}
if err != nil {
t.Fatal(err)
}
if far.calls != 1 || !far.deadline || far.left <= 0 || far.left > tc.want || far.left < tc.want-time.Second {
t.Fatalf("budget %v called %d times with deadline=%v, remaining=%v; want at most %v", tc.budget, far.calls, far.deadline, far.left, tc.want)
}
})
}
}
}

func TestExpiredTeamAskNeverCallsTheModel(t *testing.T) {
far := &askAgent{fakeAgent: &fakeAgent{model: "m"}}
loop := askLoop(t, far)
for _, method := range []string{MethodTeamsName, MethodTeamsPropose} {
var err error
if method == MethodTeamsName {
_, err = loop.Client.call(nil, method, TeamNameArgs{Budget: -time.Nanosecond})
} else {
_, err = loop.Client.call(nil, method, TeamProposeArgs{Budget: -time.Nanosecond})
}
if err == nil || err.Error() != context.DeadlineExceeded.Error() || far.calls != 0 {
t.Fatalf("expired %s called the model %d times and returned %v", method, far.calls, err)
}
}
}

func askLoop(t *testing.T, agent WrappedAgent) *Loop {
t.Helper()
loop, err := Loopback(Hello{Version: Version}, Options{Boot: func(Hello) (*Engine, error) {
Expand Down
Loading
Loading