From b634d42dcf15ef4e6743b06d1e0773a754443360 Mon Sep 17 00:00:00 2001 From: Ian Gulliver Date: Mon, 19 Oct 2026 14:08:56 -0700 Subject: [PATCH] don't restart after interrupt --- docs/operations.md | 2 +- internal/agents/lead.go | 3 +++ internal/agents/manager.go | 6 +++++- internal/agents/queue.go | 16 ++++++++++++++-- internal/agents/ready.go | 2 +- internal/agents/team.go | 2 +- 6 files changed, 25 insertions(+), 6 deletions(-) diff --git a/docs/operations.md b/docs/operations.md index 811b21d..c955423 100644 --- a/docs/operations.md +++ b/docs/operations.md @@ -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 " 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 " 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. diff --git a/internal/agents/lead.go b/internal/agents/lead.go index a9616e7..87bc5bf 100644 --- a/internal/agents/lead.go +++ b/internal/agents/lead.go @@ -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 { diff --git a/internal/agents/manager.go b/internal/agents/manager.go index 43df6d2..083bf6d 100644 --- a/internal/agents/manager.go +++ b/internal/agents/manager.go @@ -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 { diff --git a/internal/agents/queue.go b/internal/agents/queue.go index 18879a0..d4b9e0c 100644 --- a/internal/agents/queue.go +++ b/internal/agents/queue.go @@ -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 { diff --git a/internal/agents/ready.go b/internal/agents/ready.go index 2a76d8a..d29c988 100644 --- a/internal/agents/ready.go +++ b/internal/agents/ready.go @@ -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) diff --git a/internal/agents/team.go b/internal/agents/team.go index 24f082b..f8b48c8 100644 --- a/internal/agents/team.go +++ b/internal/agents/team.go @@ -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)