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
61 changes: 57 additions & 4 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -668,6 +668,58 @@ jobs:
packages/workflow/tool-ralph/tests/integration.spec.ts
packages/subprocess/subprocess-local/tests/process-exit.spec.ts

# Every other lane in this workflow runs Node, so a regression in the Rust
# shell would otherwise first surface in the release build. The check needs
# neither a pnpm install nor a bundled runtime: the frontend the build
# embeds is checked in under src-tauri/frontend. The packer is the only
# thing that ever downloads the sidecar named by the bundle table, but
# tauri-build's codegen asserts every externalBin path exists before it
# compiles, so the check stages a stand-in for it.
windows-rust-check:
if: github.event_name == 'pull_request'
runs-on: >-
${{ vars.DSH_CI_FAILOVER_WINDOWS == 'blacksmith' && 'blacksmith-16vcpu-windows-2025'
|| vars.DSH_CI_FAILOVER_WINDOWS == 'selfhosted'
&& github.event.pull_request.user.login != 'dependabot[bot]'
&& fromJSON('["self-hosted", "dsh-win-ci", "windows"]')
|| 'windows-2025' }}
name: windows rust / shell check
timeout-minutes: 60
steps:
- uses: actions/checkout@v6
with:
persist-credentials: false
# A cold check compiles the whole Tauri dependency tree. The in-house pool
# keeps its tool caches on the VM, and saving from it would poison the
# hosted cache namespace with self-hosted entries — the same guard the
# pnpm store restore carries.
- uses: actions/cache@v4
if: vars.DSH_CI_FAILOVER_WINDOWS != 'selfhosted' || github.event.pull_request.user.login == 'dependabot[bot]'
with:
path: |
~/.cargo/registry
~/.cargo/git
apps/desktop/src-tauri/target
key: ${{ runner.os }}-cargo-check-${{ hashFiles('apps/desktop/src-tauri/Cargo.lock') }}
restore-keys: |
${{ runner.os }}-cargo-check-
- uses: dtolnay/rust-toolchain@stable
# tauri-build asserts every bundle resource exists before a single crate
# compiles, and the only thing that ever puts the sidecar and the runtime
# payload there is prepare:tauri-runtime, which needs the whole workspace
# payload. A check never launches the shell, so stand-ins satisfy the
# assertions and keep this lane free of pnpm.
- name: Stage the bundle paths the build script validates
shell: pwsh
run: |
$sidecar = 'apps/desktop/src-tauri/bin/node-x86_64-pc-windows-msvc.exe'
New-Item -ItemType Directory -Force (Split-Path -Parent $sidecar) | Out-Null
Set-Content -LiteralPath $sidecar -Value 'placeholder for prepare:tauri-runtime to replace'
New-Item -ItemType Directory -Force 'apps/desktop/src-tauri/staging/runtime' | Out-Null
- name: Check the desktop shell
shell: pwsh
run: cargo check --locked --manifest-path apps/desktop/src-tauri/Cargo.toml

windows-observational:
if: github.event_name == 'pull_request'
continue-on-error: true
Expand Down Expand Up @@ -716,9 +768,10 @@ jobs:
# Single stable required check for branch protection: require "all checks
# passed" instead of enumerating matrix legs whose names change as lanes and
# node versions evolve. Every blocking job in THIS workflow must be listed in
# `needs`. Native Windows build and process checks are required; Wine and
# the deferred Python runtime targets live in ci-master.yml and do not
# participate in this PR verdict. `needs` cannot cross workflow files.
# `needs`. Native Windows build and process checks are required, and so is
# the Rust check that compiles the desktop shell; Wine and the deferred
# Python runtime targets live in ci-master.yml and do not participate in
# this PR verdict. `needs` cannot cross workflow files.
# An explicit status function runs the verdict after failed/skipped needs
# without keeping a cancelled workflow alive for an obsolete verdict.
all-checks-passed:
Expand All @@ -738,7 +791,7 @@ jobs:
&& github.event.pull_request.user.login != 'dependabot[bot]'
&& fromJSON('["self-hosted", "linux", "x64", "vm-backup"]')
|| 'ubuntu-latest' }}
needs: [node-24, node-24-coverage, node-24-bench, node-24-consumers, node-compat, python-sdk, python-runtime, windows-build, windows-native-tests]
needs: [node-24, node-24-coverage, node-24-bench, node-24-consumers, node-compat, python-sdk, python-runtime, windows-build, windows-native-tests, windows-rust-check]
if: ${{ !cancelled() && github.event_name == 'pull_request' }}
steps:
- name: Fail if any needed job did not succeed
Expand Down
4 changes: 2 additions & 2 deletions apps/desktop/README.i18n.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -2,5 +2,5 @@
# side as of the last confirmed-consistent state. Both languages carry equal authority;
# after editing either side, bring the other along and re-record with:
# pnpm run verify-translation-pairing --write apps/desktop/README.md
README.md: d2042dd3edd64a898e33deea906f6a9d93f8c010
README.zh.md: 3ee8dab7fa509877ac079d4e780b723fc4d21df8
README.md: e14123f1613caff1c72d85130cfb069542bfba3d
README.zh.md: 86942b235c579dd39569a245889140347613b7af
2 changes: 2 additions & 0 deletions apps/desktop/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ The Host is a Node sidecar launched without a child-process IPC channel, so it r

Boot proceeds in a fixed order. The main window opens on the packaged loader page. The host reader waits for a `ready` event carrying the engine URL, or for a `fatal` event carrying the cause. On readiness the shell publishes the boot payload, the engine's stream origin, to a window slot and to the current document, waits until the engine port accepts TCP connections, then navigates the window to the engine URL. The engine serves the document with its injection table already rendered, so the carrier never replays it. Every document in the window also receives an initialization script that installs the `dshDesktopBoot` bridge and its readiness gate, and the page-load hook publishes the held payload again into any document that loads later. The Web client awaits `dshDesktopBoot.ready()` before it starts and reports its own boot failures through the `alpha-boot-failed` window event, which the shell presents outside the Web page.

A session the engine no longer accepts is repaired by navigating rather than by restarting anything. A browser navigation without a valid cookie answers 401 with a minimal HTML page whose script calls `dshDesktopBoot.unauthorized(location.href)`; the shell listens for that `alpha-unauthorized` event, reads the engine URL it recorded when the host reported readiness, and navigates the window back to it. That URL carries the launch token of the host that is still serving, so the navigation re-mints the cookie and the application reloads.

A host that exits before reporting readiness, an engine port that still refuses connections after readiness, or a readiness event carrying an unreadable URL keeps the loader visible and opens one native dialog naming the cause. The shell exits once that dialog is dismissed, because nothing recovers a dead engine. A white window with no content is not a reachable state: the loader stays until the engine answers or the failure is reported.

Window close requests send `{"type":"shutdown"}` on the host's stdin and wait up to ten seconds before killing the process. The single-instance guard focuses the existing window instead of starting a second process, so two Alpha processes never race on one profile.
Expand Down
2 changes: 2 additions & 0 deletions apps/desktop/README.zh.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,8 @@ Host 是不带子进程 IPC 通道的 Node sidecar,通过 stdout 和 stdin 上

启动按固定顺序进行。主窗口先打开内置加载页。Host 读取线程等待携带引擎地址的 `ready` 事件,或携带原因的 `fatal` 事件。就绪后,壳把启动载荷(引擎的 stream 来源)写入窗口槽位和当前文档,等待引擎端口接受 TCP 连接,然后把窗口导航到引擎地址。引擎提供的文档已经渲染好自身的注入表,因此载体不再重放它。窗口内每个文档还会收到一个初始化脚本,安装 `dshDesktopBoot` 桥及其就绪门;页面加载钩子会把已持有的载荷再次发布给之后加载的文档。Web 客户端先等待 `dshDesktopBoot.ready()` 再启动,并通过 `alpha-boot-failed` 窗口事件上报自己的启动失败,由壳在 Web 页面之外展示。

引擎不再接受的会话通过重新导航来修复,而不是重启任何东西。没有有效 cookie 的浏览器导航会得到一份 401 的极简 HTML 页面,其脚本调用 `dshDesktopBoot.unauthorized(location.href)`;壳监听这个 `alpha-unauthorized` 事件,读取 host 上报就绪时记录的引擎地址,再把窗口导航回去。该地址携带仍在服务的 host 的启动令牌,因此这次导航会重新签发 cookie,应用随之重新加载。

Host 在报告就绪前退出、就绪后引擎端口仍拒绝连接、或就绪事件携带无法解析的地址时,加载页保持可见,并弹出一个说明原因的原生对话框。对话框被确认后壳退出,因为引擎已死无可恢复。没有内容的白色窗口不是可达状态:要么等到引擎应答,要么收到失败报告。

关闭窗口请求会向 Host 的 stdin 发送 `{"type":"shutdown"}`,最多等待十秒后才结束进程。单实例守卫会聚焦已有窗口而不是启动第二个进程,两个 Alpha 进程因此不会争抢同一个 profile。
Expand Down
58 changes: 57 additions & 1 deletion apps/desktop/src-tauri/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,9 @@
//! document in this window receives an initialization script that installs the
//! `dshDesktopBoot` bridge and the gate itself.
//!
//! The same bridge reports a session the engine no longer accepts, so the shell navigates the
//! window back to the engine and mints a fresh one from the launch token only it holds.
//!
//! Once the first engine is up, a watchdog supervises the engine for the rest of the session.
//! It watches the host process, probes the engine's request loop, and replaces the host when
//! either stops answering, so a machine that was suspended or had its lid closed never leaves
Expand Down Expand Up @@ -69,6 +72,8 @@ const MAX_WAKE_PROBES: u32 = 3;
const MAX_HOST_RESTARTS: u32 = 4;
/// Window event through which the Web client reports a failed boot to the shell.
const BOOT_FAILED_EVENT: &str = "alpha-boot-failed";
/// Window event the engine's denial page reports a lost session through.
const UNAUTHORIZED_EVENT: &str = "alpha-unauthorized";
/// Window event the loader page observes for startup failures.
const FATAL_EVENT: &str = "alpha-fatal";

Expand All @@ -84,7 +89,8 @@ const LOADER_READ_TIMEOUT_MS: u64 = 500;
/// The shell resolves the readiness promise once it holds the engine payload, either by
/// carrying the payload into the document before navigation or by resolving the deferred
/// after the document loaded. The client reports its own failures back through a window
/// event so the shell can present them outside the Web page.
/// event so the shell can present them outside the Web page, and reports a session the
/// engine denies so the shell can navigate the window back to the engine.
const BOOT_BRIDGE: &str = r#"(() => {
const resolved = Promise.withResolvers();
const global = globalThis;
Expand All @@ -102,6 +108,13 @@ const BOOT_BRIDGE: &str = r#"(() => {
.catch(() => {});
}
},
unauthorized: (href) => {
const api = global.__TAURI__;
if (api !== undefined && api.event !== undefined && typeof api.event.emit === "function") {
void Promise.resolve(api.event.emit("alpha-unauthorized", { href: String(href) }))
.catch(() => {});
}
},
saveMarkdown: (filename, content) => {
const api = global.__TAURI__;
if (api === undefined || api.core === undefined || typeof api.core.invoke !== "function") {
Expand Down Expand Up @@ -138,6 +151,13 @@ struct EngineLaunch {
#[derive(Default, Clone)]
struct Supervision(Arc<AtomicBool>);

/// Engine URL the shell last navigated to.
///
/// The URL carries the launch token of the host that is still serving, so it is the one
/// address that re-establishes a session the engine no longer accepts.
#[derive(Default)]
struct EngineUrl(Mutex<Option<Url>>);

/// Boot payload handed to the Web client; written by the host reader before navigation.
type BootPayload = Arc<Mutex<Option<Value>>>;

Expand Down Expand Up @@ -166,6 +186,12 @@ struct BootFailurePayload {
message: Option<String>,
}

/// One window event the engine's denial page reports when its session is gone.
#[derive(Deserialize)]
struct UnauthorizedPayload {
href: Option<String>,
}

/// Save one Markdown transcript through a native save dialog.
///
/// The dialog runs on a blocking worker because the native file dialog spins its own
Expand Down Expand Up @@ -213,6 +239,7 @@ pub fn run() {
.setup(|app| {
app.manage(HostState::default());
app.manage(Supervision::default());
app.manage(EngineUrl::default());
let handle = app.handle().clone();
match resolve_launch(&handle) {
Ok((node, runtime, project)) => {
Expand Down Expand Up @@ -467,6 +494,28 @@ fn build_window(
let reporter = failure_handle.clone();
failure_handle.run_on_main_thread(move || report_fatal(&reporter, &message)).ok();
});
let unauthorized_handle = handle.clone();
window.listen(UNAUTHORIZED_EVENT, move |event: Event| {
// The engine denies a browser navigation that carries no session cookie, and only
// the shell holds the launch token that establishes one. The host that answered
// readiness last is still serving, so navigating to it again mints a fresh session.
let denied = serde_json::from_str::<UnauthorizedPayload>(event.payload())
.ok()
.and_then(|reported| reported.href)
.unwrap_or_default();
let held = unauthorized_handle
.try_state::<EngineUrl>()
.and_then(|state| state.0.lock().ok().and_then(|slot| slot.clone()));
let Some(target) = held else { return };
eprintln!("alpha: engine denied {denied}; reopening {target}");
let reporter = unauthorized_handle.clone();
let _ = unauthorized_handle.run_on_main_thread(move || {
let Some(window) = reporter.get_webview_window("main") else { return };
if let Err(error) = window.navigate(target) {
report_fatal(&reporter, &format!("Alpha could not reopen its engine: {error}"));
}
});
});
Ok(window)
}

Expand All @@ -484,6 +533,13 @@ fn start_engine(
) -> Result<Url, String> {
let reports = spawn_host(handle, launch)?;
let url = await_ready(&reports)?;
// A document the engine denies must be able to return to the host that answered, so the
// shell keeps the URL that carries this host's launch token.
if let Some(state) = handle.try_state::<EngineUrl>() {
if let Ok(mut slot) = state.0.lock() {
*slot = Some(url.clone());
}
}
let held = payload_object(&url);
let target = url.clone();
let navigator = window.clone();
Expand Down
17 changes: 14 additions & 3 deletions packages/acp/acp/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -114,10 +114,16 @@ export function apply(ctx: Context, config: AcpConfig): void {
* neither starts sessions unselected so the loop fails loud.
* @returns the resolved default selection, or undefined when none exists.
*/
const deploymentSelection = (): ModelSelection | undefined => {
const deploymentSelection = async (): Promise<ModelSelection | undefined> => {
if (config.provider !== undefined && config.model !== undefined) {
return { provider: config.provider, model: config.model }
}
// Sibling rows mount concurrently, so the settings section backing the user
// default can still be installing when a client's first session/new lands.
// Await the settled tree before reading it, as the headless runner does,
// or an unselected agent reaches the loop with no {{model}} to bind.
const loader = ctx.get('loader') as LoaderReadiness | undefined
await loader?.await()
return agentDefaultModel.currentSelection() ?? undefined
}

Expand Down Expand Up @@ -218,7 +224,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
// deployment that configures a roster has to join one here first
// (@mutantcat/dsh-agent-presets README, "Composing a child agent").
let record: AcpSession
const selection = deploymentSelection()
const selection = await deploymentSelection()
try {
record = await AcpSession.create(ctx, {
sessionId,
Expand Down Expand Up @@ -270,7 +276,7 @@ export function apply(ctx: Context, config: AcpConfig): void {
throw invalidParams(`session cwd does not match: ${params.cwd}`)
}
let record: AcpSession
const selection = deploymentSelection()
const selection = await deploymentSelection()
try {
record = await AcpSession.resume(ctx, {
sessionId,
Expand Down Expand Up @@ -468,6 +474,11 @@ interface SessionListCursor {
sessionId: string
}

/** Settlement signal of the application tree, named without the loader dependency. */
interface LoaderReadiness {
await(): Promise<unknown>
}

/** Resolve and validate the deployment-owned session page limit. */
function resolveSessionListPageSize(value: number | undefined): number {
const resolved = value ?? DEFAULT_SESSION_LIST_PAGE_SIZE
Expand Down
1 change: 1 addition & 0 deletions packages/api/session-controller/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,7 @@
"dependencies": {
"@mutantcat/dsh-brand": "workspace:^",
"@mutantcat/dsh-deque": "workspace:^",
"@mutantcat/dsh-session-format-catalog": "workspace:^",
"@mutantcat/schemastery": "workspace:^",
"mime-types": "^3.0.2",
"zod": "^4.4.3"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,6 @@

import type { Context } from '@mutantcat/cordis'
import type { SessionEvent, SessionId } from '@mutantcat/dsh-session'
import type { SessionPersistence } from '@mutantcat/dsh-session-persistence'
import { RemoteError } from '@mutantcat/dsh-typert-protocol'

/** One exported message in the Markdown transcript. */
Expand All @@ -31,7 +30,7 @@ export async function exportSessionMarkdown(
sessionId: SessionId,
signal: AbortSignal,
): Promise<string> {
const persistence = ctx.get('sessionPersistence') as SessionPersistence | undefined
const persistence = ctx.get('sessionPersistence')
if (persistence === undefined) {
throw new RemoteError('gateway/internal', 'no session-persistence backend is mounted', {})
}
Expand Down
Loading
Loading