From ff74e610f293c318f8a2d9531271d39bcf21e064 Mon Sep 17 00:00:00 2001 From: Edwin Date: Tue, 21 Jul 2026 13:35:57 -0700 Subject: [PATCH] feat(daemon): Run forks self-archive when their dispatched task completes MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Cheap-v1 auto-close (spec 0076): the forked-run prompt now names the fork's own session id and instructs it to call the archive-session tool on itself as its final action — but only after every dispatched block has settled on the owner Program, and never when work remains pending, it is blocked, or the user joined the conversation in the fork. Archiving is the existing soft close, so the transcript and the owner Program's session clip stay valid. Prompt-enforced for now; a daemon-side deterministic close can back it up later under the same contract. --- crates/daemon/src/session.rs | 44 +++++++++++++++++---- specs/0076-program-selection-run-comment.md | 2 + 2 files changed, 39 insertions(+), 7 deletions(-) diff --git a/crates/daemon/src/session.rs b/crates/daemon/src/session.rs index 81565f98..d4061d58 100644 --- a/crates/daemon/src/session.rs +++ b/crates/daemon/src/session.rs @@ -632,14 +632,26 @@ fn program_execution_prompt_with_comment(comment: Option<&str>) -> String { prompt } -fn forked_program_execution_prompt(owner_session_id: &str, comment: Option<&str>) -> String { +fn forked_program_execution_prompt( + owner_session_id: &str, + fork_session_id: &str, + comment: Option<&str>, +) -> String { let mut prompt = program_execution_prompt_with_comment(comment); prompt.push_str(&format!( - "\n\nYou are running in an interactive fork. The Program you must read and update belongs \ - to session `{owner_session_id}`. Always pass `session_id: \"{owner_session_id}\"` to \ - construct_program_get and every construct_program_edit/update call; do not edit a \ - Program belonging to this fork. Apply progress and results directly to that owner \ - document as you work. Do not return a result for the owner to merge." + "\n\nYou are running in an interactive fork (session `{fork_session_id}`). The Program \ + you must read and update belongs to session `{owner_session_id}`. Always pass \ + `session_id: \"{owner_session_id}\"` to construct_program_get and every \ + construct_program_edit/update call; do not edit a Program belonging to this fork. Apply \ + progress and results directly to that owner document as you work. Do not return a \ + result for the owner to merge.\ + \n\nWhen the dispatched work is fully complete and you have settled every block you \ + were dispatched for on the owner Program, close this fork as your final action by \ + calling the archive-session tool (construct_archive_session, or agentd_archive_session \ + if you have the agentd-prefixed toolset) with `session_id: \"{fork_session_id}\"`. \ + Archiving is a soft close: the transcript and the owner Program's session clip stay \ + valid. If work remains pending, you are blocked, or the user has joined the \ + conversation in this fork, leave the session open instead of archiving." )); prompt } @@ -2858,7 +2870,7 @@ impl SessionManager { // The fork's own context env points at this sidecar. Store the // owner's Program context there before delivering the first turn. self.write_program_run_context(&fork_id, &run_context)?; - let prompt = forked_program_execution_prompt(¶ms.session_id, run_comment); + let prompt = forked_program_execution_prompt(¶ms.session_id, &fork_id, run_comment); self.spawn_program_fork_prompt_delivery( fork_id.clone(), fork_created_at_ms, @@ -5655,6 +5667,24 @@ mod tests { assert!(!prompt.contains("focus tests\nand keep output short")); } + /// Spec 0076 auto-close contract: a Run fork's prompt tells it to + /// archive ITSELF (its own session id, not the owner's) as its final + /// action once every dispatched block has settled, and to stay open + /// when blocked / work remains / the user joined the conversation. + /// The owner-targeting contract must survive alongside it. + #[test] + fn forked_run_prompt_instructs_self_archive_on_completion() { + let prompt = forked_program_execution_prompt("owner1", "fork1", None); + + assert!(prompt.contains("session_id: \"owner1\"")); + assert!(prompt.contains("do not edit a Program belonging to this fork")); + assert!(prompt.contains("construct_archive_session")); + assert!(prompt.contains("session_id: \"fork1\"")); + assert!(prompt.contains("settled every block")); + assert!(prompt.contains("final action")); + assert!(prompt.contains("leave the session open")); + } + #[test] fn program_run_context_carries_run_contract_and_registered_smart_clips() { let program = ProgramDocument { diff --git a/specs/0076-program-selection-run-comment.md b/specs/0076-program-selection-run-comment.md index 188bdcf5..9e5f2cd0 100644 --- a/specs/0076-program-selection-run-comment.md +++ b/specs/0076-program-selection-run-comment.md @@ -13,6 +13,8 @@ Selection Run executes in a visible interactive same-harness fork by default, ev When the Run is dispatched to a fork, the daemon annotates the selection with the fork's session clip at dispatch time — the same convention selection verbs and instant dispatch follow — so the Program document shows where the work went and renders the fork's live state in place. The annotated selection keeps its pending shimmer with a plain "Running" status. The annotation is best-effort: if the selection anchor no longer applies verbatim (concurrent edit, ambiguity), the Run proceeds without a clip rather than failing. Owner-targeted Runs (Shift, or callers not requesting a fork) add no session clip: the work happens in the session the user is already looking at, and a self-referential clip would be noise. +A Run fork auto-closes when its task is complete. The fork's dispatch prompt instructs it to archive itself — a soft close that keeps the transcript and the Program's session clip valid — as its final action, only after every block it was dispatched for has settled on the owner Program, and to stay open when work remains pending, it is blocked, or the user has joined the conversation in the fork. This contract is prompt-enforced (the fork performs the archive with its session tools); a daemon-side deterministic close may later back it up, and must preserve the same completion condition and stay-open exceptions. Owner-targeted Runs never auto-close anything: the owning session is the user's own workspace. + The focused instruction editor supports the same basic single-line movement and deletion keys users expect elsewhere in the TUI: C-a, C-e, C-b, C-f, C-d, and C-k. Because the field can wrap visually, C-p, C-n, Up, and Down move between wrapped visual rows. Its Run button remains visually distinct from typed text, is aligned to the right edge, and is highlighted only while the context menu is focused or hovered. The context menu content has one-column horizontal padding inside the border. The extra instruction is run metadata, not Program content. It must not alter the selected markdown, selection block identity, or optimistic shimmer scope. The daemon appends the instruction to the generated Program Run prompt and disables mechanical fast paths that cannot interpret it.