don't restart after interrupt

This commit is contained in:
Ian Gulliver
2026-10-19 14:08:56 -07:00
parent 598ee788f8
commit b634d42dcf
6 changed files with 25 additions and 6 deletions
+1 -1
View File
@@ -203,7 +203,7 @@ curl -H 'Tailscale-User-Login: you@example.com' -H 'Tailscale-User-Name: You' ht
## Agent sessions
`POST /api/tasks/{id}/sessions` starts a Claude session in the task's worktree and sends the prompt as its first turn; `POST /api/sessions/{id}/messages` starts later turns and `POST /api/sessions/{id}/interrupt` ends the current one. A message sent while a task session's turn is running, from the app, the lead's `send_message`, a parent's `message_subtask` or byre's review findings, is queued (`Manager.Message` in `internal/agents/queue.go`) in `task_inputs`, keyed by task and team member (`member`, empty on a task with one agent) so it survives a handoff to a new session, and recorded, in the same transaction, as a `user.queued` event (its `text`, `images` and `task_inputs` `id`, the sender as actor), and the request returns the running session with 202. `DELETE /api/tasks/{id}/queue/{input id}` takes a queued message back (`Manager.Withdraw`): under the task's queue lock (`queueLockKey`), which both hand-over paths (`deliverQueued`, `takeQueuedMidTurn`) hold from reading the queue until the message is sent or taken, whichever session of the task is delivering, it deletes the row and records a `user.unqueued` event with its `id`, or answers 409 once the message has been handed over (the row is gone). A model it carries is applied to the session at once and takes effect from the next turn. The agent gets queued messages after its next tool call, the same way as inbox answers (see "Inbox"): each is recorded as its own `user.message` with the sender as actor and `mid_turn: true`, and the agent reads them, headed "<sender> sent you this message while you were working", in the hook's `additionalContext` (Codex: `turn/steer`; Antigravity: a user message injected by its `PreInvocation` hook before the model's next call, `agyBeforeModel` in `internal/agents/antigravity.go`). Messages are handed over oldest first, stopping at the first one with images, since mid-turn delivery carries only text; none are handed over during a handoff turn. Whatever is still queued when the turn ends goes out one message per turn, ahead of held inbox answers, from `Deliver`; startup does the same for idle sessions. Closing the task deletes its queued messages. `sqlite3 byre.db 'SELECT * FROM task_inputs'` lists them. A task has at most one session that isn't `completed` per team member, so a task with one agent has at most one (see "Teams"). A message may carry `model`; when it differs from the session's, byre writes it to `agent_sessions.model`, records a `session.model` event (shown as a note), and sends the live CLI a `set_model` control request before the message, so the switch applies from that turn and survives a resume (`--model`) or compaction. The composer's model menu sends `model` only when the choice differs from the session's model, or from the `session.context` model when the session has none. The session's commits use the git identity of the user who created it, so that user must have set one with `PUT /api/me`.
`POST /api/tasks/{id}/sessions` starts a Claude session in the task's worktree and sends the prompt as its first turn; `POST /api/sessions/{id}/messages` starts later turns and `POST /api/sessions/{id}/interrupt` ends the current one. A message sent while a task session's turn is running, from the app, the lead's `send_message`, a parent's `message_subtask` or byre's review findings, is queued (`Manager.Message` in `internal/agents/queue.go`) in `task_inputs`, keyed by task and team member (`member`, empty on a task with one agent) so it survives a handoff to a new session, and recorded, in the same transaction, as a `user.queued` event (its `text`, `images` and `task_inputs` `id`, the sender as actor), and the request returns the running session with 202. `DELETE /api/tasks/{id}/queue/{input id}` takes a queued message back (`Manager.Withdraw`): under the task's queue lock (`queueLockKey`), which both hand-over paths (`deliverQueued`, `takeQueuedMidTurn`) hold from reading the queue until the message is sent or taken, whichever session of the task is delivering, it deletes the row and records a `user.unqueued` event with its `id`, or answers 409 once the message has been handed over (the row is gone). A model it carries is applied to the session at once and takes effect from the next turn. The agent gets queued messages after its next tool call, the same way as inbox answers (see "Inbox"): each is recorded as its own `user.message` with the sender as actor and `mid_turn: true`, and the agent reads them, headed "<sender> sent you this message while you were working", in the hook's `additionalContext` (Codex: `turn/steer`; Antigravity: a user message injected by its `PreInvocation` hook before the model's next call, `agyBeforeModel` in `internal/agents/antigravity.go`). Messages are handed over oldest first, stopping at the first one with images, since mid-turn delivery carries only text; none are handed over during a handoff turn. Whatever is still queued when the turn ends goes out one message per turn, ahead of held inbox answers, from `Deliver`; startup does the same for idle sessions. An interrupt holds all of that: after a turn ends interrupted, byre starts no turn on its own until someone sends the session a message (`Manager.interrupted`, true while the live session's `interrupted` flag is set, which the next `user.message` clears). The turn end delivers nothing, the ready-for-merge prompt waits, a message from byre, an agent (`message_subtask`) or the board is queued as if the session were running instead of starting a turn, and a lead holds its signals (a human's queued lead message still goes out). Whatever was held goes out mid-turn during, or at the end of, the next turn the human (or the lead's `send_message`) starts; an inbox answer starts a turn as usual. The flag is in memory only, so a byre restart releases what was held. Closing the task deletes its queued messages. `sqlite3 byre.db 'SELECT * FROM task_inputs'` lists them. A task has at most one session that isn't `completed` per team member, so a task with one agent has at most one (see "Teams"). A message may carry `model`; when it differs from the session's, byre writes it to `agent_sessions.model`, records a `session.model` event (shown as a note), and sends the live CLI a `set_model` control request before the message, so the switch applies from that turn and survives a resume (`--model`) or compaction. The composer's model menu sends `model` only when the choice differs from the session's model, or from the `session.context` model when the session has none. The session's commits use the git identity of the user who created it, so that user must have set one with `PUT /api/me`.
`users.default_model` is the model the app starts new work on: ⌘K's Start and New task (whose composer then offers it first), the Project tab's and TODO links' `start`, suggestion links' `start` and `discuss` (when there is no live lead session), and the composer of a task or lead with no live session. It is returned as `default_model` on `GET /api/me`, set with `PUT /api/me/default-model` from the Settings tab's Default model menu, and records `user.updated`, so every open app reloads it. The server itself never reads it: the lead's `create_task` and `start_todo` default to the lead's own model, and `create_subtask` to the parent session's.
+3
View File
@@ -149,6 +149,9 @@ func (m *Manager) DeliverLead(ctx context.Context, owner string) error {
}
batch, actor, text, images := nextLeadTurn(inputs)
if batch[0].Kind == store.LeadSignal && m.interrupted(sess.Id) {
return nil
}
if batch[0].Kind == store.LeadSignal {
state, err := m.leadStateText(ctx, owner)
if err != nil {
+5 -1
View File
@@ -1245,11 +1245,15 @@ func (m *Manager) afterTurn(ctx context.Context, l *live) {
m.startDiscard(l.taskID)
return
}
m.deliverTeam(ctx, l)
if m.interrupted(l.id) {
m.logUndelivered(ctx, l.id, "turn was interrupted")
return
}
err = m.Deliver(ctx, l.id)
if err != nil {
slog.Error("deliver inbox responses", "session", l.id, "err", err)
}
m.deliverTeam(ctx, l)
}
func commitMessage(res endResult) string {
+14 -2
View File
@@ -47,7 +47,8 @@ func (m *Manager) Message(ctx context.Context, sessionID, actor, text, model str
if err != nil {
return api.AgentSession{}, false, err
}
if sess.State != api.AgentSessionStateRunning || sess.Role != api.Implementer {
held := sess.State == api.AgentSessionStateIdle && sess.Role == api.Implementer && automatedActor(actor) && m.interrupted(sess.Id)
if !held && (sess.State != api.AgentSessionStateRunning || sess.Role != api.Implementer) {
sess, err = m.Send(ctx, sessionID, actor, text, model, images...)
return sess, false, err
}
@@ -72,10 +73,21 @@ func (m *Manager) Message(ctx context.Context, sessionID, actor, text, model str
if err != nil {
return api.AgentSession{}, false, err
}
slog.Info("message queued", "session", sess.Id, "task", sess.TaskId, "actor", actor)
slog.Info("message queued", "session", sess.Id, "task", sess.TaskId, "actor", actor, "interrupted", held)
return sess, true, nil
}
func automatedActor(actor string) bool {
return actor == "byre" || actor == agentActor || actor == boardActor
}
func (m *Manager) interrupted(sessionID string) bool {
m.mu.Lock()
defer m.mu.Unlock()
l := m.live[sessionID]
return l != nil && l.interrupted
}
func (m *Manager) Withdraw(ctx context.Context, taskID string, inputID int64, actor string) error {
task, err := m.store.GetTask(ctx, taskID)
if err != nil {
+1 -1
View File
@@ -81,7 +81,7 @@ func (m *Manager) tryPromptReady(ctx context.Context, taskID string, fromReview
return nil
}
sess, err = m.store.GetSession(ctx, sess.Id)
if err != nil || sess.State == api.AgentSessionStateRunning {
if err != nil || sess.State == api.AgentSessionStateRunning || m.interrupted(sess.Id) {
return err
}
latest, err := m.store.LastSnapshotNot(ctx, taskID, api.SnapshotTriggerAnswer)
+1 -1
View File
@@ -213,7 +213,7 @@ func (m *Manager) deliverTeam(ctx context.Context, l *live) {
return
}
for _, s := range teamSessions(sessions) {
if s.Id == l.id || s.State != api.AgentSessionStateIdle {
if s.Id == l.id || s.State != api.AgentSessionStateIdle || m.interrupted(s.Id) {
continue
}
err = m.Deliver(ctx, s.Id)