Skip to content

Commit fbae9ff

Browse files
Add a persistent desktop follow-up queue with safe cancellation
1 parent b1cccee commit fbae9ff

22 files changed

Lines changed: 771 additions & 79 deletions

‎CHANGELOG.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,11 @@
1919
- Required task inspection recognizes successful native database reads. Small
2020
model contexts can use a shorter, enforced response budget while retaining
2121
required input; requests that still cannot fit fail before contacting the model.
22+
- The native desktop can queue follow-up messages while the current task streams,
23+
show waiting work across project conversations and cancel individual queued
24+
tasks. Reloads select the running task first; queue cancellation cannot stop a
25+
task that has already begun. Sidebar activity and transcript progress now
26+
distinguish queued, running and completed work.
2227
- Native goals preserve milestone results, pause and resume, require recorded
2328
inspection/verification for the default checklist, and stop on failed checks.
2429
Goal tasks appear live in the conversation; results and progress are accessible

‎README.md‎

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,6 +20,7 @@ workflows, [managed background processes](docs/NATIVE_BACKGROUND.md), and
2020
[project inspection and diagnostics](docs/NATIVE_INSPECTION.md),
2121
[project and task notes](docs/NATIVE_MEMORY.md),
2222
[native SQLite inspection](docs/NATIVE_SQLITE.md),
23+
[queued follow-ups](docs/NATIVE_QUEUE.md),
2324
[approved MCP stdio and HTTP tools](docs/NATIVE_MCP.md), a
2425
[native MCP stdio server](docs/NATIVE_MCP.md#connect-another-coding-tool-to-shadowcode), and a
2526
[native CLI](docs/NATIVE_CLI.md) that shares the active desktop engine or runs

‎docs/NATIVE_DESKTOP.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -66,6 +66,11 @@ ID so a navigation change cannot send the decision to another task.
6666
tools, also available through the CLI and MCP server, with parameter binding,
6767
live WAL support, cancellation and result limits.
6868

69+
[Queued follow-ups](NATIVE_QUEUE.md) let you send the next instruction while the
70+
current task streams. The project queue shows waiting messages across
71+
conversations, retains their model/mode choices and supports cancellation.
72+
Reloads return to the running task before waiting follow-ups.
73+
6974
Project [lifecycle commands](NATIVE_HOOKS.md) can be reviewed and enabled in
7075
Settings → Hooks or through the same native CLI. Their command gates and
7176
completion checks run inside the native task lifecycle without Python callbacks.

‎docs/NATIVE_MIGRATION.md‎

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -104,6 +104,11 @@ Implemented foundation:
104104
workspaces, scoped approvals, cancellation that waits for cleanup, restart
105105
recovery, and atomic completion/usage/event commits. Continuation and branching
106106
retain valid function-call histories; interrupted calls are never blindly replayed.
107+
- A [desktop follow-up queue](NATIVE_QUEUE.md) with visible project-wide waiting
108+
messages, model/mode snapshots, independent queued cancellation, continued
109+
streaming, conversation switching and reload recovery. Active history remains
110+
discoverable beyond recent-list limits; a stale queue action cannot cancel a
111+
task that has started running.
107112
- Native filesystem, search, patch, shell, Git, and plan tools with durable audit
108113
events. Patches preflight every file, reject ambiguous context, preserve line
109114
endings, and journal edits for conflict-aware rewind. Shell and Git side effects

‎docs/NATIVE_QUEUE.md‎

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
# Queued follow-ups — native 0.20 development
2+
3+
Keep working on the next instruction while ShadowCode runs a task. In the native
4+
desktop, enter a message and press Enter or **Queue follow-up**. The running
5+
response continues to stream; **Up next** shows the project's waiting messages
6+
in execution order. Expand a message to read it in full, or use its cancel button
7+
to remove it from the queue.
8+
9+
Model and mode choices apply to the new follow-up. The queued task retains those
10+
choices and uses the completed conversation when it starts, including the
11+
previous task's actual tool results. Plan and Review keep their read-only
12+
permissions. Queuing does not grant approvals or run two tasks in one project
13+
at once.
14+
15+
## Conversations and navigation
16+
17+
The queue belongs to the project. A new conversation in the same project waits
18+
for its earlier tasks; conversations in other projects can run independently,
19+
within the engine's four-project concurrency limit. Queue rows from another
20+
conversation include a link to it. The sidebar distinguishes waiting tasks from
21+
running tasks and does not label completed history as active.
22+
23+
Switching conversations or reloading the window retains the queue. Returning to
24+
a conversation selects its running task first, then its oldest waiting task.
25+
When a task finishes, the desktop loads the next task and its intervening
26+
history. Cancelling a waiting message does not replace the active task's plan,
27+
usage or progress state. Prompts appear in execution order when their tasks
28+
start, without duplicate user messages.
29+
30+
The queue cancel button only cancels work that is still queued. If the task has
31+
started since the button was displayed, ShadowCode reports that change and
32+
leaves it running. Open its conversation and use **Stop task** to stop it.
33+
Stopping a running task leaves other queued tasks eligible to run; cancel those
34+
messages too if they are no longer wanted.
35+
36+
## Limits and recovery
37+
38+
- Up to 64 tasks can be running or queued across the application. Excess
39+
submissions return an error and restore the draft.
40+
- Slash commands and new file uploads wait until the project is idle. Ordinary
41+
follow-up messages can refer to files already in the project.
42+
- The active-job listing includes old running/queued tasks even when another
43+
project has produced many newer completed tasks.
44+
- Reloading a window does not stop jobs. Closing the native application performs
45+
managed shutdown and cancels its active and queued work. After an abrupt exit,
46+
unfinished tasks are marked interrupted for review; they are not replayed
47+
automatically.
48+
- This desktop queue uses the native engine. The supported 0.19 Python/browser
49+
release retains its existing one-task composer. The native CLI also supports
50+
queued submissions; see [NATIVE_CLI.md](NATIVE_CLI.md).
51+
52+
See [NATIVE_VERIFICATION.md](NATIVE_VERIFICATION.md) for the engine and real-window
53+
checks and [NATIVE_MIGRATION.md](NATIVE_MIGRATION.md) for the remaining release gates.

‎docs/NATIVE_VERIFICATION.md‎

Lines changed: 21 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,7 +6,7 @@ remain in [NATIVE_MIGRATION.md](NATIVE_MIGRATION.md).
66

77
## Automated checks
88

9-
`cargo fmt --all --check`, Clippy with warnings denied, and all **182 native
9+
`cargo fmt --all --check`, Clippy with warnings denied, and all **183 native
1010
integration tests** pass on the development machine. The suite covers:
1111

1212
- Config validation, private secrets, untrusted project overlays, profile locks,
@@ -22,6 +22,11 @@ integration tests** pass on the development machine. The suite covers:
2222
conflict preflight that preserves unrelated edits.
2323
- Approval isolation, expiry, cancellation, aborted futures, read-only tasks,
2424
queued cancellation, hung providers, and managed shutdown.
25+
- Queued-only cancellation refuses tasks that have already started and preserves
26+
their running state. Task startup and cancellation share a transition lock.
27+
Active work remains listed behind 10,005 newer completed jobs; conversation
28+
selection prefers running work, then FIFO waiting work, then actual completion
29+
time, including when a newer submission was cancelled before its predecessor.
2530
- Tool-call/result pairing across compaction and recovery, live file attachments,
2631
verification retries, token limits, and labelled estimates for absent usage.
2732
- Smaller response reserves for constrained models, enforced in the actual
@@ -273,13 +278,26 @@ passed Rust, CLI and MCP checks but caught a notification mid-fade at insufficie
273278
contrast. Toasts now animate position with fully opaque text. The rebuilt native
274279
window passes all twelve accessibility views and the complete twenty-request
275280
workflow locally, including the changed-selection goal approval regression.
276-
Seventeen interface unit tests cover ordered replay, pagination, stream finalization,
281+
Twenty interface unit tests cover ordered replay, pagination, stream finalization,
277282
listener cleanup, interruption, native tool cards, routing/fallback replay,
278283
workflow provenance, durable command/hook cards, completion checks between
279284
answers and task results, and disambiguated model labels that omit URL credentials. The seven existing browser
280285
tests continue to pass through the legacy transport.
281286

282-
The same native window workflow also passes when launched from the local optimized
287+
The queued-follow-up update extends the real native-window probe to 22 scripted
288+
model requests and 15 passing Axe views. While a model response is deliberately
289+
held open, the UI queues two follow-ups, creates another conversation in the
290+
same project, queues and cancels its message, returns to the original task and
291+
reloads. The running task stays selected and visible; the queue survives.
292+
The probe cancels a waiting message without interrupting the first task, then
293+
releases it and observes the second task start automatically with its selected
294+
model, read-only Review tools and the completed predecessor's conversation.
295+
Cancelled messages never contact the model. Queue screenshots and accessibility
296+
reports cover light, dark and compact layouts; the sidebar returns to idle after
297+
completion. UI unit tests also cover interleaved queue cancellation without
298+
resetting the running task's plan/usage, and prompt ordering without duplication.
299+
300+
The earlier twenty-request window workflow also passed from the local optimized
283301
AppImage in FUSE-free extraction mode with the legacy `ui` argument. The package
284302
checker confirms that AppImage and Debian packages contain native ELF application
285303
code with matching versions and no Python runtime or sidecars. Packaging now

‎native/core/src/engine.rs‎

Lines changed: 35 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -503,23 +503,37 @@ impl Engine {
503503
self.request_cancel(id)?;
504504
self.wait(id).await
505505
}
506+
pub async fn cancel_queued(&self, id: &str) -> Result<Job> {
507+
self.request_cancel_if(id, true)?;
508+
self.wait(id).await
509+
}
506510
pub(crate) fn request_cancel(&self, id: &str) -> Result<()> {
511+
self.request_cancel_if(id, false)
512+
}
513+
fn request_cancel_if(&self, id: &str, only_if_queued: bool) -> Result<()> {
514+
const LEFT_QUEUE: &str = "This task has already left the queue. Open its conversation to review it or stop active work.";
507515
let Some(job) = self.running(id)? else {
508-
self.job(id)?.context("Job not found")?;
516+
let previous = self.job(id)?.context("Job not found")?;
517+
ensure!(
518+
!only_if_queued || previous.status == "cancelled",
519+
LEFT_QUEUE
520+
);
509521
return Ok(());
510522
};
511-
job.cancel.cancel();
512-
self.0.approvals.deny_task(&job.snapshot()?.task_id);
513-
{
523+
let task_id = {
514524
let mut record = job
515525
.record
516526
.lock()
517527
.map_err(|_| anyhow!("Job lock poisoned"))?;
528+
ensure!(!only_if_queued || record.status == "queued", LEFT_QUEUE);
529+
job.cancel.cancel();
518530
if matches!(record.status.as_str(), "queued" | "running") {
519531
record.status = "cancelling".into();
520532
self.0.store.save_job(&json!(*record))?;
521533
}
522-
}
534+
record.task_id.clone()
535+
};
536+
self.0.approvals.deny_task(&task_id);
523537
// A queued cancellation should not wait for the current coding task.
524538
let is_front = {
525539
let queues = self
@@ -740,18 +754,22 @@ impl Engine {
740754
Ok(())
741755
}
742756
async fn run(&self, running: &Running) -> Result<(String, Value)> {
743-
ensure!(
744-
!running.cancel.is_cancelled(),
745-
"Task cancelled before starting"
746-
);
747-
let mut job = running.snapshot()?;
748-
job.status = "running".into();
749-
job.started_at = crate::now();
750-
*running
751-
.record
752-
.lock()
753-
.map_err(|_| anyhow!("Job lock poisoned"))? = job.clone();
754-
self.0.store.save_job(&json!(job))?;
757+
// Queue removal and starting work share this lock. A stale queue button
758+
// can never cancel a task that has already transitioned to running.
759+
let job = {
760+
let mut record = running
761+
.record
762+
.lock()
763+
.map_err(|_| anyhow!("Job lock poisoned"))?;
764+
ensure!(
765+
!running.cancel.is_cancelled(),
766+
"Task cancelled before starting"
767+
);
768+
record.status = "running".into();
769+
record.started_at = crate::now();
770+
self.0.store.save_job(&json!(*record))?;
771+
record.clone()
772+
};
755773
self.0.store.execute(
756774
"UPDATE tasks SET status='running' WHERE id=?",
757775
[&job.task_id],

‎native/core/src/service.rs‎

Lines changed: 9 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -624,13 +624,9 @@ impl Service {
624624
self.select(&workspace, session["id"].as_str().map(str::to_owned))?;
625625
return Ok(session);
626626
}
627-
("GET", "/api/jobs") => return Ok(json!({"jobs":store.jobs(1000)?})),
627+
("GET", "/api/jobs") => return Ok(json!({"jobs":store.active_and_recent_jobs(1000)?})),
628628
("GET", "/api/jobs/current") => {
629-
let jobs = store.jobs(10000)?;
630-
let job = jobs.into_iter().find(|job| {
631-
job["session_id"] == q("session_id")
632-
&& (q("include_finished") == "true" || active(job))
633-
});
629+
let job = store.current_job(q("session_id"), q("include_finished") == "true")?;
634630
return Ok(json!({"job":job}));
635631
}
636632
("POST", "/api/jobs/test") => {
@@ -1038,7 +1034,13 @@ impl Service {
10381034
let job = self.engine.job(parts[2])?.context("Job not found")?;
10391035
match (request.method.as_str(), parts.get(3).copied()) {
10401036
("GET", None) => return Ok(json!(job)),
1041-
("POST", Some("cancel")) => return Ok(json!(self.engine.cancel(&job.id).await?)),
1037+
("POST", Some("cancel")) => {
1038+
return Ok(json!(if body["only_if_queued"] == true {
1039+
self.engine.cancel_queued(&job.id).await?
1040+
} else {
1041+
self.engine.cancel(&job.id).await?
1042+
}))
1043+
}
10421044
("GET", Some("events")) => {
10431045
return Ok(
10441046
json!({"events":store.events_after(&job.session_id,q("after").parse().unwrap_or(0),job.finished_at.map(|_|job.event_cursor),query_limit(&query,512,2000))?,"job":job}),

‎native/core/src/store.rs‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -531,6 +531,20 @@ impl Store {
531531
.pop()
532532
.map(|v| v["payload"].clone()))
533533
}
534+
/// Keep active work visible even after unrelated projects produce a large
535+
/// amount of completed history. Insertion order is the workspace FIFO order.
536+
pub fn active_and_recent_jobs(&self, limit: usize) -> Result<Vec<Value>> {
537+
Ok(self.query(
538+
"SELECT payload FROM desktop_jobs WHERE rowid IN (SELECT rowid FROM desktop_jobs ORDER BY rowid DESC LIMIT ?) OR json_extract(payload,'$.status') IN ('queued','running','cancelling') ORDER BY rowid DESC",
539+
[limit.clamp(1, 10000)],
540+
)?.into_iter().map(|row|row["payload"].clone()).collect())
541+
}
542+
pub fn current_job(&self, session: &str, include_finished: bool) -> Result<Option<Value>> {
543+
Ok(self.query(
544+
"SELECT payload FROM desktop_jobs WHERE json_extract(payload,'$.session_id')=? AND (? OR json_extract(payload,'$.status') IN ('queued','running','cancelling')) ORDER BY CASE json_extract(payload,'$.status') WHEN 'running' THEN 0 WHEN 'cancelling' THEN 1 WHEN 'queued' THEN 2 ELSE 3 END, CASE WHEN json_extract(payload,'$.status')='queued' THEN rowid END ASC, json_extract(payload,'$.finished_at') DESC, rowid DESC LIMIT 1",
545+
rusqlite::params![session,include_finished],
546+
)?.pop().map(|row|row["payload"].clone()))
547+
}
534548
/// Called only by the profile-lock owner, before accepting new work.
535549
pub fn recover_jobs(&self) -> Result<usize> {
536550
let mut db = self.lock()?;

‎native/core/tests/engine_tasks.rs‎

Lines changed: 22 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -182,6 +182,13 @@ async fn queued_followups_run_in_order_and_duplicate_active_work_is_rejected() {
182182
assert_eq!(result.summary, expected);
183183
}
184184
assert_eq!(server.requests.lock().unwrap().len(), 4);
185+
assert!(engine
186+
.cancel_queued(&first.id)
187+
.await
188+
.unwrap_err()
189+
.to_string()
190+
.contains("left the queue"));
191+
assert_eq!(engine.job(&first.id).unwrap().unwrap().status, "completed");
185192
engine.shutdown().await.unwrap();
186193
}
187194

@@ -197,11 +204,25 @@ async fn queued_cancel_is_immediate_and_shutdown_cancels_a_stalled_provider() {
197204
let mut req = request(root.path(), "queued", Some(first.session_id.clone()));
198205
req.queue = true;
199206
let queued = engine.start(req).await.unwrap();
200-
let result = tokio::time::timeout(Duration::from_secs(1), engine.cancel(&queued.id))
207+
let result = tokio::time::timeout(Duration::from_secs(1), engine.cancel_queued(&queued.id))
201208
.await
202209
.unwrap()
203210
.unwrap();
204211
assert_eq!(result.status, "cancelled");
212+
tokio::time::timeout(Duration::from_secs(3), async {
213+
while engine.job(&first.id).unwrap().unwrap().status != "running" {
214+
tokio::task::yield_now().await;
215+
}
216+
})
217+
.await
218+
.unwrap();
219+
assert!(engine
220+
.cancel_queued(&first.id)
221+
.await
222+
.unwrap_err()
223+
.to_string()
224+
.contains("left the queue"));
225+
assert_eq!(engine.job(&first.id).unwrap().unwrap().status, "running");
205226
tokio::time::timeout(Duration::from_secs(3), engine.shutdown())
206227
.await
207228
.unwrap()

0 commit comments

Comments
 (0)