first pass of merging in warp (doesn't build)
This commit is contained in:
@@ -0,0 +1,54 @@
|
||||
# Queue terminal commands alongside prompts
|
||||
|
||||
GitHub: [warpdotdev/warp#11912](https://github.com/warpdotdev/warp/issues/11912). Builds on the v2 queued-prompts panel from [`specs/REMOTE-1543/PRODUCT.md`](../REMOTE-1543/PRODUCT.md) and [`specs/APP-4562/PRODUCT.md`](../APP-4562/PRODUCT.md).
|
||||
|
||||
Figma: none provided (the only new visual is a shell-mode `!` prefix on command rows).
|
||||
|
||||
## Summary
|
||||
Extend the v2 prompt-queuing model so that a shell command submitted while prompt queuing is active is queued in the same panel as prompts, instead of interrupting the running agent. Queued prompts and commands fire in strict FIFO order: a command runs after the item before it finishes, and the item after it fires once the command's terminal block completes.
|
||||
|
||||
## Problem
|
||||
Today, when auto-queue is enabled and an agent run is in progress, submitting a shell command from shell mode (e.g. `! ls` or `cargo run`) interrupts/cancels the in-progress agent work and runs the command immediately, rather than queuing it. Only agent prompts can be queued. Users want to line up terminal commands behind in-progress agent work the same way they line up prompts.
|
||||
|
||||
## Goals
|
||||
- Let users queue shell commands while an agent run is in progress, interleaved in FIFO order with queued prompts.
|
||||
- Execute each queued command when it reaches the head of the queue, and advance to the next item only when the command finishes.
|
||||
- Reuse the existing queued-prompts panel, interactions, and draining semantics so commands behave like prompts everywhere it makes sense.
|
||||
- Work in both local Agent Mode and cloud-mode panes.
|
||||
|
||||
## Behavior
|
||||
|
||||
### Gating
|
||||
1. All behavior below is gated on the `QueuedPromptsV2` feature flag. When it is off, shell-command submissions behave exactly as today: they run immediately and interrupt any in-progress agent run.
|
||||
|
||||
### Queuing a command
|
||||
2. A shell-mode submission is queued (instead of run immediately) under exactly the same conditions that queue an agent prompt today: an agent conversation in this pane is in progress (or summarizing), and prompt queuing is active for it (the auto-queue/queue-next toggle is on, or — under V2 — the conversation is summarizing). The only change from prompt queuing is that the input being in shell mode no longer disqualifies the submission.
|
||||
3. A queued command appears as a new row at the tail of the same queued-prompts panel, in FIFO order with any queued prompts. Its preview text is the command exactly as typed (including a leading `!` the user typed for shell mode is normalized away the same way the shell editor handles it).
|
||||
4. A queued command row is visually distinguished from a prompt row by a leading `!` rendered in the same blue used for the shell-mode `!` prefix in the input. Aside from the prefix, the row renders like a prompt row.
|
||||
5. Queued command rows are not locked: they support the same interactions as queued prompt rows — drag-to-reorder, hover-revealed edit (pencil) and delete (trash), and they participate in collapse/expand exactly like prompt rows. Editing a command row edits its command text. If the in-progress agent turn finishes while a command row is being edited, the row's text is moved into the input with the input forced into shell mode (so it stays a command), mirroring how an edited prompt row is moved into the input on a clean finish.
|
||||
6. Submitting an empty shell input does not append a row (existing trim-and-skip behavior).
|
||||
7. If prompt queuing is not active (no in-progress agent run and auto-queue off), a shell submission runs immediately and is not queued (unchanged from today).
|
||||
|
||||
### Draining and execution
|
||||
8. When the in-progress agent conversation finishes cleanly, the queue drains from the head, one item per finish, exactly as queued prompts do today. If the head is a prompt, it is submitted to the agent. If the head is a command, it is executed.
|
||||
9. A drained command executes through the same terminal routing a manually submitted shell command uses for that pane: in a local pane it runs in the local terminal; in a cloud-mode / shared-session pane it is submitted via the shared session to run in the remote environment. Because the command came from the queue rather than the current editor buffer, any draft the user is composing in the input is preserved while the command starts and completes.
|
||||
10. When a command fires, its row is removed from the panel immediately (the same moment a fired prompt row disappears). The running command is visible as its own terminal block; the panel does not keep a duplicate "running" row.
|
||||
11. The item after a command fires only after that command finishes — i.e. its terminal block completes / the process exits. A command that does not terminate (e.g. a long-running dev server) holds the queue until it exits or is killed; no later item fires in the meantime.
|
||||
12. A command's exit status does not affect draining: a non-zero exit still counts as finished, so the next item fires regardless of whether the command succeeded.
|
||||
13. Strict FIFO holds across mixed item types. For a queue of [prompt A, command B, prompt C]: A runs first; B runs only after A's agent turn finishes; C is submitted only after B's terminal block completes. (This is the issue's motivating example.)
|
||||
|
||||
### Keeping order while a command runs
|
||||
14. While a queued command is running, prompt queuing stays active even though the agent itself is idle: any input the user submits during that window (prompt or command) is appended to the back of the queue, preserving strict FIFO — the same as submitting while a prompt is running. Nothing the user submits jumps ahead of, or runs concurrently with, the in-flight command.
|
||||
|
||||
### Long-running agent commands (LRC)
|
||||
15. If a fired queued prompt causes the agent to start an agent-controlled long-running command (managed by a subagent), the queue must not advance while that LRC is still in flight. Intermediate subagent responses produced while the LRC runs do not drain the queue; the next item fires only once the whole agent turn — including the LRC — has finished. (This preserves today's queued-prompt behavior.)
|
||||
16. Agent-executed command blocks (including LRC snapshots) never count as the completion of a queued command. Only the queued command the panel actually dispatched advances the queue.
|
||||
|
||||
### Pause on error / cancel
|
||||
17. When the in-progress conversation finishes for a non-clean reason (error, cancellation, cancellation during requested command execution), draining pauses immediately, exactly as for queued prompts. If the input editor is empty, the head item is removed from the panel and its text is placed in the input editor (a command's text is restored with the input forced into shell mode, so it stays a command rather than an agent prompt); if the editor is non-empty, nothing is removed or modified. Remaining rows stay intact, and draining resumes the next time the conversation completes an exchange cleanly.
|
||||
|
||||
### Lifecycle
|
||||
18. The queue (including command rows and any in-flight-command tracking) is scoped to its conversation. It persists across agent-view exit and conversation switches so a background conversation can continue draining, and is cleared when the conversation is removed or its owning terminal view is cleared — identical to queued-prompt lifecycle today.
|
||||
|
||||
### Cloud mode
|
||||
19. All of the above applies in cloud-mode panes. Commands queue while the cloud agent run is in progress and, when drained, are submitted via the shared session to the remote environment; the queue advances when the remote command's block completes. Draining off the cloud conversation's completion uses the same conversation-status path that cloud-mode queued prompts already use.
|
||||
@@ -0,0 +1,104 @@
|
||||
# Queue terminal commands alongside prompts — Tech Spec
|
||||
|
||||
See `specs/gh-11912/PRODUCT.md` for user-visible behavior. This layers command support onto the v2 queued-prompts machinery from `specs/REMOTE-1543/` and `specs/APP-4562/`.
|
||||
|
||||
## Context
|
||||
The queued-prompts queue is an app-wide singleton, `QueuedQueryModel` (`app/src/ai/blocklist/queued_query.rs`), keyed by `AIConversationId`. Each conversation has a `ConversationQueueState { queue: Vec<QueuedQuery>, editing, queue_next_prompt_override, command_in_flight }`. A `QueuedQuery` carries `id`, `text`, `origin: QueuedQueryOrigin`, and a `kind` enum that is either a prompt with attachments or a command without attachments (`app/src/ai/blocklist/queued_query.rs:47`). Draining is mediated by `peek_autofire` → `AutofireAction` → `remove_fired_row` so the send path can read a row's attachments before the row is removed (`app/src/ai/blocklist/queued_query.rs:439`, `app/src/ai/blocklist/queued_query.rs:468`).
|
||||
|
||||
Submit/queue flow:
|
||||
- The enter handler tries queue gates before executing: `maybe_queue_input_for_in_progress_conversation` then `maybe_queue_input_during_cloud_setup`, then slash-command handling and the shell-execution fallback (`app/src/terminal/input.rs:12871-12874`, shell execution at `app/src/terminal/input.rs:13096`).
|
||||
- Before #11912, shell mode made `maybe_queue_input_for_in_progress_conversation` return false, so a shell command fell through to `try_execute_command` and cancelled any in-progress conversation. The implemented gate now computes `is_command` from the input mode (`app/src/terminal/input.rs:13552`) and allows shell command rows under the `QueuedPromptsV2` gate.
|
||||
- `try_execute_command` (`app/src/terminal/input.rs:6855`) routes correctly per pane: for a shared-session viewer/executor (cloud-mode pane) it sends the command to the sharer via `try_execute_command_on_behalf_of_shared_session_participant`; otherwise it runs locally via `try_execute_command_from_source`.
|
||||
|
||||
Drain + submit:
|
||||
- `TerminalView::handle_finished_conversation` (`app/src/terminal/view.rs:4804`) → `drain_queued_prompts` (`app/src/terminal/view.rs:5301`). The clean path peeks the head and, for `AutofireAction::Submit`, calls `Input::submit_queued_prompt_for_active_pane` (`app/src/terminal/input.rs:13453`, routes cloud-followup / shared-session-viewer / local). The non-clean path (`Error|Cancelled`) restores the head into the editor via `pop_front`.
|
||||
- Local finish detection: `handle_ai_controller_event`, `FinishedReceivingOutput` arm (`app/src/terminal/view.rs:5139`). It already defers finishing while `has_active_subagent()` is true (`app/src/terminal/view.rs:5149`, `app/src/ai/agent/conversation.rs:3079`) — this is the LRC guard.
|
||||
- Cloud finish detection: `handle_ai_history_model_event`, `UpdatedConversationStatus` arm (`app/src/terminal/view.rs:6163`), gated on `QueuedPromptsV2` + `is_ambient_agent_session`, draining only on a transition to a terminal status.
|
||||
|
||||
Command completion signals (the new draining inputs) — local and shared-session are distinct and must both be handled:
|
||||
- **Local**: `ModelEvent::AfterBlockCompleted` is handled in `TerminalView` (`app/src/terminal/view.rs:12020`); the completed block is `block_type`, and `BlockType::User(UserBlockCompleted { serialized_block, was_part_of_agent_interaction, .. })` exposes success via `serialized_block.exit_code.was_successful()` (`app/src/terminal/view.rs:12123`). Agent-executed commands are `BlockType::User` with `was_part_of_agent_interaction == true`.
|
||||
- **Shared session / cloud**: the sharer emits `OrderedTerminalEventType::CommandExecutionFinished { next_block_id }` from `command_finished` (`app/src/terminal/model/terminal_model.rs:2829`) on every command completion. The viewer event loop receives it and advances queued commands through `TerminalView::on_queued_command_finished` (`app/src/terminal/shared_session/viewer/event_loop.rs:292`). This is the authoritative remote-command-finished signal for a cloud/viewer pane — it does not depend on the indirect, byte-parse-driven block completion on the viewer. (The viewer already keys its own per-command cleanup of `pending_command_execution_request` off the synced block completion in `Input::handle_block_completed_event`, `app/src/terminal/input.rs:14472`.)
|
||||
|
||||
Panel: `app/src/terminal/view/queued_prompts_panel.rs` renders each row; the row render is where the `!` prefix is added.
|
||||
|
||||
Feature flag: `FeatureFlag::QueuedPromptsV2` (already wired).
|
||||
|
||||
## Proposed changes
|
||||
|
||||
### 1. Model: a queued item is a prompt or a command (`queued_query.rs`)
|
||||
Replace the flat `attachments` field with a `kind` enum so attachments are *structurally* impossible on a command:
|
||||
```rust
|
||||
pub enum QueuedQueryKind {
|
||||
/// An agent prompt; may carry image/file attachments captured from the input.
|
||||
Prompt { attachments: Vec<PendingAttachment> },
|
||||
/// A shell command run in the terminal / via the shared session. Cannot hold attachments.
|
||||
Command,
|
||||
}
|
||||
```
|
||||
- `QueuedQuery` keeps the shared `id`, `text`, `origin` and gains `kind: QueuedQueryKind`. `text` stays shared (both kinds have a string); only `attachments` move into the `Prompt` variant, so a `Command` has nowhere to put them. Use `Vec` (not `Option<Vec>`): an empty vec already means "no attachments", so `Option` would add a redundant `None`-vs-empty state.
|
||||
- Accessors: `is_command()` = `matches!(self.kind, QueuedQueryKind::Command)`; `attachments()` returns the `Prompt` variant's slice or `&[]` for a `Command`. Constructors: keep `new` / `new_with_attachments` (build `Prompt`), add `new_command(text)` (builds `Command`). `origin` stays telemetry-only; firing branches on `kind`, not `origin`.
|
||||
- Commands are normal mutable rows: `is_locked()` stays false for them, so reorder/edit/delete/`pop_front` behave exactly as for prompts.
|
||||
- Extend the drain action: add `AutofireAction::ExecuteCommand { query_id, command }`. `peek_autofire` (`app/src/ai/blocklist/queued_query.rs:439`) returns `ExecuteCommand` for a `Command` head, and `Submit` / `PopFromEditMode` otherwise.
|
||||
- Track the in-flight command on `ConversationQueueState`: add `command_in_flight: bool` with `arm_command_in_flight`, `clear_command_in_flight`, and `has_command_in_flight(conversation_id) -> bool` (`app/src/ai/blocklist/queued_query.rs:314`). Storing this on the singleton lets the `Input` gate read it and both `TerminalView` and the viewer event loop set/clear it. Clear it in `drop_conversation` so lifecycle cleanup (`app/src/ai/blocklist/queued_query.rs:286`) resets it.
|
||||
|
||||
### 2. Queue gate: allow shell submissions to queue (`input.rs`)
|
||||
In `maybe_queue_input_for_in_progress_conversation` (`app/src/terminal/input.rs:13541`):
|
||||
- Remove the `!is_ai_input_enabled()` early return. Compute `let is_command = !self.ai_input_model.as_ref(ctx).is_ai_input_enabled();` up front.
|
||||
- Extend the in-progress predicate so the queue stays active while a command runs (PRODUCT §14): queue when the conversation is in-progress/blocked **or** `QueuedQueryModel::as_ref(ctx).has_command_in_flight(conversation_id)`.
|
||||
- The `is_queue_next_prompt_enabled` / summarizing gate is unchanged: queuing is allowed when the auto-queue toggle is on, or when the conversation is summarizing under `QueuedPromptsV2` (PRODUCT §2, §7).
|
||||
- The `/queue`-argument unwrap only applies in AI mode (a shell command won't match `commands::QUEUE`); skip it for `is_command`.
|
||||
- Append `QueuedQuery::new_command(prompt)` when `is_command`, else the existing prompt row. Do not take pending attachments for commands.
|
||||
|
||||
Because the enter handler already calls this gate before the shell-execution branch (`app/src/terminal/input.rs:12871`), a queued command never reaches `try_execute_command`, so the interrupt-the-agent path is bypassed. No change to `maybe_queue_input_during_cloud_setup` (queuing-while-running is the in-progress gate, not the setup gate).
|
||||
|
||||
### 3. Drain dispatch: execute commands (`view.rs`, `input.rs`)
|
||||
In `drain_queued_prompts` (`app/src/terminal/view.rs:5301`), `FinishReason::Complete` arm, handle the new action:
|
||||
- `AutofireAction::ExecuteCommand { query_id, command }`: call `remove_fired_row` (row disappears immediately, PRODUCT §10), then dispatch via `Input::execute_queued_command(command, conversation_id, ctx)`. That helper uses a queued-command execution source (local `QueuedCommand`, or shared-session `preserve_input`) so terminal routing is reused without treating the current editor buffer as the submitted command. It arms `command_in_flight` only after dispatch starts. Do **not** peek/drain the next item here — the next item fires on block completion (§4). If dispatch returns false (cannot execute, e.g. precmd not yet received), restore `command` into the editor in shell mode if the editor is empty, matching the non-clean restore, so the queue never wedges.
|
||||
- `Submit` arm unchanged. `PopFromEditMode` now carries `is_command` (set from `first.is_command()` in `peek_autofire`): when restoring a command's text into an empty input, force shell mode via `Input::set_input_mode_terminal(true, ctx)` and skip the attachment restore (commands have none), so an edited command that the clean drain pops into the input stays a command instead of being submitted as an agent prompt (PRODUCT §5).
|
||||
|
||||
The non-clean arm (`Error|Cancelled`) restores the head via `pop_front` + `replace_buffer_content(query.text())`; when `query.is_command()`, it also forces shell mode via `set_input_mode_terminal(false, ctx)` and skips the attachment restore, so a restored command stays a command (PRODUCT §17).
|
||||
|
||||
### 4. Command-finished trigger advances the queue (`view.rs`, `viewer/event_loop.rs`)
|
||||
Add one shared entry point on `TerminalView`, `on_queued_command_finished(&mut self, ctx)`: find the conversation owned by this terminal view whose queue has `command_in_flight` armed, call `clear_command_in_flight(conv)`, then `self.drain_queued_prompts(conv, FinishReason::Complete, ctx)`. Resolve the armed owner rather than the pane's currently active conversation so a conversation switch while the command runs does not wedge the original queue. Passing `Complete` regardless of exit status implements PRODUCT §12; going through `drain_queued_prompts` (not `handle_finished_conversation`) avoids re-running conversation-finished callbacks.
|
||||
Fire it from **both** command-finished sources so local and cloud panes are each covered by their authoritative signal:
|
||||
- **Local** — in the `ModelEvent::AfterBlockCompleted` handler (`app/src/terminal/view.rs:12020`), after the existing `pending_command_queue` handling, when `block_type` is `BlockType::User(b)` with `!b.was_part_of_agent_interaction`, call `self.on_queued_command_finished(ctx)` (`app/src/terminal/view.rs:12166`).
|
||||
- **Shared session / cloud** — handle `OrderedTerminalEventType::CommandExecutionFinished` in the viewer event loop (`app/src/terminal/shared_session/viewer/event_loop.rs:292`): `self.terminal_view.upgrade(ctx)` then `view.update(.., |view, ctx| view.on_queued_command_finished(ctx))`.
|
||||
- Both are gated on the armed `command_in_flight` flag, and `on_queued_command_finished` clears it *before* draining, so it is naturally idempotent: if a viewer happens to observe both a synced `AfterBlockCompleted` and a `CommandExecutionFinished`, only the first advances the queue (the second sees no armed flag and no-ops). The armed-only gate also keeps agent-executed command blocks / LRC snapshots from advancing the queue — they complete while the flag is unarmed, since the agent is idle whenever a queued command is in flight (PRODUCT §16). The same armed flag suppresses editor freeze/clear behavior on queued command start and completion, preserving any draft the user is composing. When the next item is a prompt, `submit_queued_prompt_for_active_pane` routes it to the cloud-followup / local path as today.
|
||||
|
||||
### 5. LRC handling (no code change, documented)
|
||||
For queued prompts that trigger an agent LRC, the existing `has_active_subagent()` guard in the `FinishedReceivingOutput` arm (`app/src/terminal/view.rs:5149`) already defers `handle_finished_conversation` until the subagent/LRC turn truly ends (PRODUCT §15); the cloud `UpdatedConversationStatus` path only drains on a terminal status, which the agent won't report mid-LRC. The new command-finished trigger (§4) cannot fire during an LRC because it is armed only while a queued *command* is in flight, i.e. when the agent is idle. No new code is required, but this reasoning is the reason the trigger is gated on the armed flag rather than on every `User` block completion.
|
||||
|
||||
### 6. Panel: blue `!` prefix on command rows (`queued_prompts_panel.rs`)
|
||||
In the row render, when `query.is_command()`, render a leading `!` glyph before the preview text using the same blue color token the input uses for the shell-mode `!` prefix (locate the shell-prefix color in the input/editor styling and reuse the token rather than hard-coding a color). All other affordances (drag handle, edit/delete buttons, collapse) are unchanged (PRODUCT §4, §5).
|
||||
|
||||
## End-to-end flow (queue = [prompt A, command B, prompt C])
|
||||
```mermaid
|
||||
flowchart LR
|
||||
A["A submitted to agent"] --> Afin["agent turn finishes (FinishedReceivingOutput / UpdatedConversationStatus)"]
|
||||
Afin --> DB["drain_queued_prompts → ExecuteCommand B"]
|
||||
DB --> Bexec["Input::execute_queued_command → preserve-input command source; arm command_in_flight"]
|
||||
Bexec --> Bdone["command finished: AfterBlockCompleted (local) OR CommandExecutionFinished (cloud)"]
|
||||
Bdone --> DC["on_queued_command_finished: clear flag → drain → Submit C"]
|
||||
DC --> Cexec["submit_queued_prompt_for_active_pane (agent / cloud followup)"]
|
||||
```
|
||||
|
||||
## Testing and validation
|
||||
Current automated coverage maps to `PRODUCT.md` invariants as follows; follow repo test conventions (`*_tests.rs` included via `#[path]`).
|
||||
- **§1 (flag)** — `app/src/terminal/input_tests.rs`: `shell_submission_is_not_queued_when_v2_disabled` asserts shell submissions do not become command rows when `QueuedPromptsV2` is off.
|
||||
- **§2, §7, §14 (gating)** — `app/src/terminal/input_tests.rs`: `shell_submission_queues_as_command_row_when_gated_under_v2` asserts shell submissions queue as `Command` rows while `has_command_in_flight` keeps the queue active even though the agent is idle.
|
||||
- **§3–§5 (model)** — `app/src/ai/blocklist/queued_query_tests.rs`: command rows are structurally commands with empty attachments; `peek_autofire` yields `ExecuteCommand` for a command head; edited command heads pop with `is_command`; command rows are reorderable/editable/deletable; `arm/clear/has_command_in_flight`; conversation deletion clears in-flight state.
|
||||
- **§5, §17 (restore keeps command kind)** — `app/src/terminal/view/queued_prompts_tests.rs` and `app/src/terminal/input_tests.rs`: clean drain of an edited command, non-clean drain of a command, and delete-row restore all put the command text back in shell mode.
|
||||
- **§9, §10, §14, §19 (dispatch and draft preservation)** — `app/src/terminal/input_tests.rs`, `app/src/terminal/shared_session/viewer/event_loop_tests.rs`, and `app/src/terminal/shared_session/viewer/terminal_manager_tests.rs`: send-now command dispatch removes the row, arms in-flight state, and preserves drafts; queued command completion preserves local drafts; shared-session `CommandExecutionStarted` preserves drafts while a queued command is armed; `CommandExecutionFinished` clears in-flight state idempotently even after conversation switches; command request failure clears in-flight state.
|
||||
- **§18 (lifecycle)** — `app/src/ai/blocklist/queued_query_tests.rs`: conversation deletion and terminal-view clearing drop queued state, including in-flight command state.
|
||||
- **Behavior covered by implementation review rather than current focused tests** — `app/src/terminal/view.rs`: the clean drain command-head path removes the row, dispatches `execute_queued_command`, and does not drain the next item until completion; `on_queued_command_finished` passes `FinishReason::Complete` regardless of exit status; the local completion hook filters `was_part_of_agent_interaction`; `FinishedReceivingOutput` still uses the `has_active_subagent()` guard for LRC turns.
|
||||
- Full `./script/format` and `cargo clippy` (per WARP.md) before PR; run the focused queue test suites (`queued_query`, `queued_prompts`, input/view tests). Do not run the app to test.
|
||||
|
||||
## Parallelization
|
||||
Single workstream. The change threads one set of shared types (`QueuedQueryKind`, `AutofireAction`, `command_in_flight`) through the model, the input gate, the view drain, both command-finished triggers, and the panel render; every edit depends on the model change landing first, and the files (`queued_query.rs`, `input.rs`, `view.rs`, `shared_session/viewer/event_loop.rs`, `queued_prompts_panel.rs`) overlap heavily with the in-flight changes. Splitting across child agents would create merge churn on the same files without reducing wall-clock time, so it is implemented directly.
|
||||
|
||||
## Risks and mitigations
|
||||
- **Queue wedges if a command never starts**: only arm `command_in_flight` when `try_execute_command` returns true; on false, clear and restore text (§3).
|
||||
- **Double-drain**: the command-finished trigger is armed only during command-in-flight (agent idle), and the conversation-finish paths won't re-fire (the conversation already reached a terminal status before the command ran).
|
||||
- **Spurious advance from agent command blocks**: filtered by `!was_part_of_agent_interaction` (local) plus the armed flag, which is only set while the agent is idle (§4, §16).
|
||||
- **Viewer completion reliability**: cloud panes advance on the explicit `OrderedTerminalEventType::CommandExecutionFinished` event rather than the indirect, byte-parse-driven block completion on the viewer. If both a synced `AfterBlockCompleted` and a `CommandExecutionFinished` reach a viewer, the armed-flag clear in `on_queued_command_finished` dedupes them; validated by the §19 cloud test.
|
||||
- **`origin` vs `kind` confusion**: `origin` stays telemetry-only; all firing decisions branch on `kind` to avoid conflating source with type.
|
||||
Reference in New Issue
Block a user