Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 35 additions & 0 deletions docs/architecture/detached-task-dispatch.md
Original file line number Diff line number Diff line change
Expand Up @@ -187,6 +187,7 @@ Public job verbs are:
| `list` | List durable target jobs. |
| `answer` | Resolve one persisted permission request for `remote` approval policy. |
| `append` | Queue an idempotent steering message for the active turn. |
| `continue` | Queue the next turn for a job whose previous turn has finished. |

Git delivery and synchronization use these internal data-plane verbs:

Expand Down Expand Up @@ -242,6 +243,40 @@ available, probe reports the missing runner and submission remains disabled
rather than falling back to local execution. Device dispatch never performs
SSH-style installation through the Relay.

## Conversation model

A dispatch session is a conversation, not a single exchange. One job owns one
target session, one worktree, and one append-only event log; each user message
is a turn inside it:

- while a turn is running, a message is an `append` that steers it;
- once it has finished, a message is a `continue` that queues the next turn.

`continue` rewinds only the job's run state to `queued` and clears the runtime
turn id, so a fresh detached worker picks it up. The worker restores the target
session rather than creating it, which is what gives the follow-up turn the
previous turns as context. Its `turnId` is caller-generated, so a retry after an
ambiguous response resolves to the same turn instead of starting a second one.

Because the event log is per job rather than per turn, the controller's cursor,
transcript cache, and projection are unchanged by follow-ups: the observer keeps
reading one growing transcript.

## Workspace naming

A target checkout lives at:

```text
~/.bitfun/dispatch/worktrees/<repoKey>/<project>-<short job id>
```

`repoKey` groups every checkout of one source repository under its shared clone.
The leaf mirrors the local managed-worktree convention so a target directory is
recognizable rather than a bare job UUID. The project name is advisory input from
the controller: the target sanitizes it, falls back to the remote URL's basename
and then to a constant, and rejects anything that is not a single safe path
component — the path is never shaped by an untrusted string.

## Event and observer contract

The target event log is append-only within a retained window. A status response
Expand Down
52 changes: 46 additions & 6 deletions src/apps/cli/src/dispatch/mod.rs
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,12 @@ use serde::de::DeserializeOwned;

use protocol::{
DispatchAnswerRequest, DispatchAnswerResponse, DispatchAppendRequest, DispatchAppendResponse,
DispatchCancelRequest, DispatchCancelResponse, DispatchJobListEntry, DispatchJobState,
DispatchListRequest, DispatchProbeRequest, DispatchProbeResponse, DispatchStatusRequest,
DispatchStatusResponse, DispatchSubmitRequest, DispatchSubmitResponse,
DispatchWorkspaceBundleBeginRequest, DispatchWorkspaceBundleChunkRequest,
DispatchWorkspaceBundleCommitRequest, DispatchWorkspaceProbe,
DispatchWorkspaceProvisionRequest, DispatchWorkspaceSyncChunkRequest,
DispatchCancelRequest, DispatchCancelResponse, DispatchContinueRequest,
DispatchContinueResponse, DispatchJobListEntry, DispatchJobState, DispatchListRequest,
DispatchProbeRequest, DispatchProbeResponse, DispatchStatusRequest, DispatchStatusResponse,
DispatchSubmitRequest, DispatchSubmitResponse, DispatchWorkspaceBundleBeginRequest,
DispatchWorkspaceBundleChunkRequest, DispatchWorkspaceBundleCommitRequest,
DispatchWorkspaceProbe, DispatchWorkspaceProvisionRequest, DispatchWorkspaceSyncChunkRequest,
DispatchWorkspaceSyncRequest, DISPATCH_PROTOCOL_VERSION, MAX_DISPATCH_TEXT_BYTES,
};
use store::{CreateJobOutcome, DispatchStateRecord, DispatchStore};
Expand Down Expand Up @@ -61,6 +61,8 @@ pub(crate) async fn run_dispatch_verb(
serde_json::to_value(answer(parse(input)?)?).context("encode permission answer")
}
"append" => serde_json::to_value(append(parse(input)?)?).context("encode appended message"),
"continue" => serde_json::to_value(continue_job(parse(input)?)?)
.context("encode follow-up turn response"),
"workspace-provision" => serde_json::to_value(workspace::provision(parse::<
DispatchWorkspaceProvisionRequest,
>(input)?)?)
Expand Down Expand Up @@ -209,6 +211,44 @@ async fn submit(mut request: DispatchSubmitRequest) -> Result<DispatchSubmitResp
})
}

/// Start the next turn in an existing dispatch session.
///
/// The job keeps its identity, workspace, and event log; only its run state
/// rewinds so a fresh worker can pick up the queued prompt. That is what makes
/// the controller's projection a continuous transcript instead of one job per
/// message.
fn continue_job(request: DispatchContinueRequest) -> Result<DispatchContinueResponse> {
if request.protocol_version != DISPATCH_PROTOCOL_VERSION {
bail!(
"unsupported dispatch protocolVersion {}; target requires {}",
request.protocol_version,
DISPATCH_PROTOCOL_VERSION
);
}
if request.prompt.trim().is_empty() {
bail!("dispatch follow-up requires a prompt");
}
if request.prompt.len() > MAX_DISPATCH_TEXT_BYTES {
bail!("dispatch follow-up prompt exceeds the 32 KiB safety limit");
}
if !runner::is_supported() {
bail!("dispatch detached workers are supported only on Linux and macOS");
}
let store = DispatchStore::open_default()?;
let job = store.load_job(&request.job_id)?;
// A worker may still be settling the previous turn's terminal state.
reconcile_worker_liveness(&store, &request.job_id)?;
let state = store.queue_follow_up_turn(&request)?;
ensure_worker_spawned(&store, &request.job_id, state.state)?;
Ok(DispatchContinueResponse {
accepted: true,
job_id: request.job_id,
session_id: job.request.session_id,
turn_id: request.turn_id,
state: state.state,
})
}

fn ensure_worker_spawned(
store: &DispatchStore,
job_id: &str,
Expand Down
34 changes: 34 additions & 0 deletions src/apps/cli/src/dispatch/protocol.rs
Original file line number Diff line number Diff line change
Expand Up @@ -167,6 +167,11 @@ pub(crate) struct DispatchWorkspaceProvisionRequest {
pub(crate) repo_key: String,
#[serde(default)]
pub(crate) remote_url: Option<String>,
/// Readable name of the controller's project, used to build a worktree path
/// a human can recognize. Advisory: the target sanitizes it and falls back
/// to the remote's own basename, so a hostile value cannot shape the path.
#[serde(default)]
pub(crate) project_label: Option<String>,
/// Full 40-character commit id. A ref name would be ambiguous — it can move
/// between the controller resolving it and the target fetching it.
pub(crate) base_commit: String,
Expand Down Expand Up @@ -316,6 +321,35 @@ pub(crate) struct DispatchWorkspaceSyncChunkResponse {
pub(crate) eof: bool,
}

/// Start the next turn in a dispatch session whose previous turn has finished.
///
/// Distinct from `append`, which steers a turn that is still running. This is
/// what lets a dispatch session hold a conversation: the target session, its
/// worktree, and its event log all persist, and only the job's run state
/// rewinds so a new worker can pick it up.
#[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(crate) struct DispatchContinueRequest {
pub(crate) protocol_version: u32,
pub(crate) job_id: String,
/// Caller-generated identity for this turn, so a retried request cannot
/// start two.
pub(crate) turn_id: String,
pub(crate) prompt: String,
#[serde(default)]
pub(crate) display_content: Option<String>,
}

#[derive(Clone, Debug, Serialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase")]
pub(crate) struct DispatchContinueResponse {
pub(crate) accepted: bool,
pub(crate) job_id: String,
pub(crate) session_id: String,
pub(crate) turn_id: String,
pub(crate) state: DispatchJobState,
}

#[derive(Clone, Debug, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "camelCase", deny_unknown_fields)]
pub(crate) struct DispatchCancelRequest {
Expand Down
Loading
Loading