docs: design delayed prompt injection
This commit is contained in:
@@ -0,0 +1,198 @@
|
|||||||
|
# Delayed prompt injection
|
||||||
|
|
||||||
|
**Date:** 2026-06-11
|
||||||
|
**Status:** Approved
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Add a TUI-only feature that schedules one delayed prompt for a specific hqt
|
||||||
|
session and injects it when the selected time arrives. The primary use case is
|
||||||
|
recovering a harness that stopped because it ran out of budget: the user can
|
||||||
|
schedule a restart/continue prompt and let hqt deliver it later.
|
||||||
|
|
||||||
|
Scheduled prompts fire only while the hqt TUI is running. If hqt is closed at
|
||||||
|
the due time, the prompt remains pending and fires on the next TUI poll after
|
||||||
|
hqt starts again.
|
||||||
|
|
||||||
|
## User Behavior
|
||||||
|
|
||||||
|
The session list gets a new keybinding, `p`, labeled "Prompt Later". Pressing
|
||||||
|
it for the selected session opens a modal that collects:
|
||||||
|
|
||||||
|
- Prompt text.
|
||||||
|
- A relative delay such as `30m`, `2h`, `1h30m`, or `90s`.
|
||||||
|
- A confirmation action.
|
||||||
|
|
||||||
|
Each session supports exactly one active delayed prompt. Scheduling a new prompt
|
||||||
|
for that session replaces the existing pending, sent, or failed prompt. A
|
||||||
|
companion cancel action, `P` / `Shift+P`, removes the selected session's active
|
||||||
|
prompt.
|
||||||
|
|
||||||
|
Visualization is intentionally compact:
|
||||||
|
|
||||||
|
- The session row shows the next pending trigger, for example
|
||||||
|
`◉ waiting @ 14:30` or `● idle @ 12m`.
|
||||||
|
- After scheduling, canceling, sending, or failing a prompt, the TUI shows a
|
||||||
|
short notification such as `Prompt scheduled for hqt-4 at 14:30`.
|
||||||
|
|
||||||
|
The row indicator is shown only for pending prompts. Sent prompts disappear from
|
||||||
|
the active indicator. Failed prompts are not retried automatically; they can be
|
||||||
|
replaced or canceled from the selected session.
|
||||||
|
|
||||||
|
## Architecture
|
||||||
|
|
||||||
|
Use a persisted database table and drive dispatch from the existing TUI poller.
|
||||||
|
This matches the current service architecture and keeps scheduling state visible
|
||||||
|
across TUI restarts without introducing a background daemon.
|
||||||
|
|
||||||
|
### Data Model
|
||||||
|
|
||||||
|
Add a `ScheduledPrompt` model and migration:
|
||||||
|
|
||||||
|
- `id` primary key.
|
||||||
|
- `session_id` foreign key to `sessions.id`, unique.
|
||||||
|
- `prompt` text, required.
|
||||||
|
- `due_at` datetime, required.
|
||||||
|
- `status` string, one of `pending`, `sending`, `sent`, or `failed`.
|
||||||
|
- `error` text, nullable.
|
||||||
|
- `created_at` datetime.
|
||||||
|
- `sent_at` datetime, nullable.
|
||||||
|
|
||||||
|
The unique `session_id` constraint enforces one active prompt record per
|
||||||
|
session. Scheduling a prompt replaces any existing prompt row for that session,
|
||||||
|
including failed or sent rows.
|
||||||
|
|
||||||
|
### Service Layer
|
||||||
|
|
||||||
|
`SessionService` owns delayed prompt behavior because it already owns session
|
||||||
|
lookup, resume/fallback behavior, status polling, and tmux interaction.
|
||||||
|
|
||||||
|
Add service methods along these lines:
|
||||||
|
|
||||||
|
```python
|
||||||
|
def schedule_prompt(self, session_id: int, prompt: str, due_at: datetime) -> ScheduledPrompt:
|
||||||
|
...
|
||||||
|
|
||||||
|
def cancel_scheduled_prompt(self, session_id: int) -> None:
|
||||||
|
...
|
||||||
|
|
||||||
|
def get_scheduled_prompt(self, session_id: int) -> ScheduledPrompt | None:
|
||||||
|
...
|
||||||
|
|
||||||
|
async def dispatch_due_prompts(self, now: datetime) -> list[DispatchResult]:
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
|
Extend `SessionInfo` with an optional `scheduled_prompt` field so `SessionList`
|
||||||
|
can render the compact row indicator from the same object it already receives
|
||||||
|
for each row.
|
||||||
|
|
||||||
|
### Tmux Layer
|
||||||
|
|
||||||
|
`TmuxRunner` already has literal `send_text()` and `send_enter()` methods. Add
|
||||||
|
thin `TmuxManager` delegates and make both send paths return success/failure
|
||||||
|
instead of fire-and-forget so the service can record failed injection.
|
||||||
|
|
||||||
|
Prompt injection sends the prompt body as literal text, then sends Enter as a
|
||||||
|
separate tmux key event. Prompt content such as `-flag`, quotes, semicolons, or
|
||||||
|
the word `Enter` must be treated as text, not as tmux commands or key names.
|
||||||
|
|
||||||
|
### TUI Runtime
|
||||||
|
|
||||||
|
The existing 3-second TUI poll loop becomes the scheduler runtime:
|
||||||
|
|
||||||
|
1. Call `dispatch_due_prompts(now)`.
|
||||||
|
2. Surface any sent/failed dispatch results as notifications.
|
||||||
|
3. Refresh session rows and tmux labels using the existing status flow.
|
||||||
|
|
||||||
|
No prompt fires while the TUI process is not running. Overdue prompts fire on
|
||||||
|
the first poll after startup.
|
||||||
|
|
||||||
|
## Dispatch Semantics
|
||||||
|
|
||||||
|
When a pending prompt is due:
|
||||||
|
|
||||||
|
1. Load the target session row.
|
||||||
|
2. Change the prompt status to `sending` and commit before interacting with
|
||||||
|
tmux, so overlapping poll workers cannot inject the same prompt twice.
|
||||||
|
3. If the session row is gone, mark the prompt `failed`.
|
||||||
|
4. If the tmux window is alive, inject directly.
|
||||||
|
5. If the window is dead or missing, resume the session without switching tmux
|
||||||
|
focus, then inject.
|
||||||
|
6. On successful text + Enter injection, mark the row `sent` and set `sent_at`.
|
||||||
|
7. On any failure, mark the row `failed` and store an error message.
|
||||||
|
|
||||||
|
The current `attach_session()` method resumes and then selects the tmux window.
|
||||||
|
Factor an internal helper, `_ensure_session_alive(db, sess) -> bool`, so due
|
||||||
|
prompt dispatch can reuse the resume/fallback ladder without changing the
|
||||||
|
user's active tmux window.
|
||||||
|
|
||||||
|
Failed prompts must not retry automatically on every poll. This avoids sending
|
||||||
|
duplicate prompts or repeatedly restarting a harness after a transient failure.
|
||||||
|
The user can replace the failed prompt by scheduling a new one, or clear it with
|
||||||
|
the cancel action.
|
||||||
|
|
||||||
|
## TUI Details
|
||||||
|
|
||||||
|
Add a new modal screen, `SchedulePromptScreen`, under `src/hqt/tui/screens/`.
|
||||||
|
It should follow existing modal conventions from the project:
|
||||||
|
|
||||||
|
- Plain Textual form controls.
|
||||||
|
- Compact dialog styling using existing CSS patterns.
|
||||||
|
- Inline validation for empty prompt or invalid delay.
|
||||||
|
- OK and Cancel actions.
|
||||||
|
|
||||||
|
Delay parsing is relative-only for the first version. Accepted units are
|
||||||
|
seconds, minutes, and hours. Combined values such as `1h30m` are accepted.
|
||||||
|
Absolute timestamps, recurring prompts, and multiple queued prompts are out of
|
||||||
|
scope.
|
||||||
|
|
||||||
|
`SessionList` row rendering includes pending prompt information when present.
|
||||||
|
Show `@ <countdown>` while the due time is less than 60 minutes away, rounded
|
||||||
|
up to whole minutes, for example `@ 12m`. Show local 24-hour time for prompts
|
||||||
|
that are at least 60 minutes away, for example `@ 14:30`.
|
||||||
|
|
||||||
|
## Error Handling
|
||||||
|
|
||||||
|
- Scheduling with no selected session shows a warning notification.
|
||||||
|
- Empty prompt text or invalid delay keeps the modal open with an inline error.
|
||||||
|
- Scheduling for a deleted session raises `ServiceError` and shows a TUI error.
|
||||||
|
- Deleted target session at dispatch time marks the prompt failed.
|
||||||
|
- Resume/fallback failure marks the prompt failed with the existing tmux/spawn
|
||||||
|
error when available.
|
||||||
|
- Text or Enter injection failure marks the prompt failed.
|
||||||
|
- Database errors are allowed to surface through existing service error handling.
|
||||||
|
|
||||||
|
## Testing
|
||||||
|
|
||||||
|
Add focused tests at the same layers the current code uses:
|
||||||
|
|
||||||
|
- Model/migration test for the `scheduled_prompts` table and unique
|
||||||
|
`session_id`.
|
||||||
|
- Service tests for schedule, replace, cancel, and retrieving pending prompt
|
||||||
|
metadata in session rows.
|
||||||
|
- Service dispatch tests for alive-window injection, dead-window auto-resume
|
||||||
|
without attach/select, missing session failure, injection failure, and failed
|
||||||
|
prompts not retrying on the next poll.
|
||||||
|
- Tmux tests for manager send delegates and literal send behavior returning
|
||||||
|
success/failure.
|
||||||
|
- TUI tests for the keybindings, modal validation, scheduling action, cancel
|
||||||
|
action, row indicator, and poll-time dispatch notifications.
|
||||||
|
|
||||||
|
After implementation changes, the project checks must pass:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
uv run ruff format src tests
|
||||||
|
uv run ruff check src tests
|
||||||
|
uv run ty check
|
||||||
|
```
|
||||||
|
|
||||||
|
## Out of Scope
|
||||||
|
|
||||||
|
- Firing delayed prompts while hqt is closed.
|
||||||
|
- CLI scheduling.
|
||||||
|
- Multiple queued prompts per session.
|
||||||
|
- Absolute date/time scheduling.
|
||||||
|
- Recurring prompts.
|
||||||
|
- Prompt history or a full queue-management screen.
|
||||||
|
- Harness-specific prompt templates.
|
||||||
Reference in New Issue
Block a user