mirror of
https://github.com/esengine/DeepSeek-Reasonix.git
synced 2026-11-07 10:45:59 -10:00
Merge pull request #9971 from SivanCola/fix/runtime-state-consistency
fix(runtime): unify local and remote session state / 统一本地与远程会话运行状态
This commit is contained in:
106 files changed
+5926
-617
No files matched your search
@@ -204,6 +204,8 @@ type App struct {
|
||||
// startup.
|
||||
projectTreeChangedHook func()
|
||||
projectTreeRuntime projectTreeRuntimeState
|
||||
runtimeStateProjection desktopRuntimeProjection
|
||||
remoteRuntimeSync remoteRuntimeSync
|
||||
|
||||
// singleSurfaceMu serializes open/reuse plus visible-tab pruning for the
|
||||
// one-conversation layout so overlapping navigation cannot remove the tab
|
||||
|
||||
@@ -55,7 +55,9 @@ func installStubControllerWithCurrentPrompt(t *testing.T, app *App, tab *Workspa
|
||||
SessionContextStatic: sessioncontext.Sections{Workspace: "Current workspace: " + strconv.Quote(workspaceRoot)},
|
||||
Sink: event.Discard,
|
||||
})
|
||||
app.mu.Lock()
|
||||
tab.Ctrl = ctrl
|
||||
app.mu.Unlock()
|
||||
app.bindControllerDisplayRecorder(ctrl)
|
||||
return ctrl
|
||||
}
|
||||
|
||||
@@ -0,0 +1,131 @@
|
||||
#!/usr/bin/env node
|
||||
// Real Chromium UI with controlled runtime frames; backend ownership is covered
|
||||
// separately by the controller/Serve/remote HTTP regression tests.
|
||||
import path from "node:path";
|
||||
import fs from "node:fs/promises";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { createServer } from "vite";
|
||||
const root = path.resolve(path.dirname(fileURLToPath(import.meta.url)), "..");
|
||||
process.env.PLAYWRIGHT_BROWSERS_PATH = path.join(root, ".pw-browsers");
|
||||
const { chromium } = await import("playwright");
|
||||
const server = await createServer({ root, logLevel: "error", server: { host: "127.0.0.1", port: 4668, strictPort: true } });
|
||||
await server.listen();
|
||||
const browser = await chromium.launch({ headless: true });
|
||||
const page = await browser.newPage({ viewport: { width: 1440, height: 1000 } });
|
||||
const errors = [];
|
||||
page.on("pageerror", error => errors.push(error.message));
|
||||
const check = (yes, message) => { if (!yes) throw new Error(message); console.log("PASS " + message); };
|
||||
try {
|
||||
await page.goto("http://127.0.0.1:4668/?mock=bench&bench=1");
|
||||
const input = page.locator("textarea.composer__input:not([aria-hidden=true])");
|
||||
await input.waitFor();
|
||||
await page.locator('.project-tree__topic-main:has-text("bench:small-6t")').click();
|
||||
await page.waitForFunction(() => document.querySelector(".transcript")?.textContent?.includes("ASYNC LAYOUT EXPANSION COMPLETE"));
|
||||
await page.evaluate(async () => {
|
||||
const { app, onRemoteTabOpened, onRemoteTabUpdated } = await import("/src/lib/bridge.ts");
|
||||
const { runtimeStateStore } = await import("/src/lib/runtimeStateStore.ts");
|
||||
const { acceptRuntimeState } = await import("/src/lib/runtimeStateReducer.ts");
|
||||
const tabs = await app.ListTabs();
|
||||
const tree = { topics: [] };
|
||||
const selected = tabs.find(tab => tab.sessionPath?.includes("small")) ?? tabs[0];
|
||||
window.__runtimeFixture = { tab: selected, revision: 0, calls: [], queries: [], fail: true, accept: (...args) => acceptRuntimeState(runtimeStateStore, ...args), topics: tree.topics };
|
||||
onRemoteTabOpened(tab => { window.__runtimeFixture.tab = tab; });
|
||||
onRemoteTabUpdated(tab => { window.__runtimeFixture.tab = tab; });
|
||||
const original = window.go;
|
||||
window.go = { main: { App: new Proxy({}, { get(_target, key) {
|
||||
if (key === "CaptureInboxTarget") return async (tabId, sessionPath) => {
|
||||
if (!sessionPath || sessionPath !== window.__runtimeFixture.tab.sessionPath) throw new Error("Composer did not bind its selected session path: " + JSON.stringify({ tabId, sessionPath, expected: window.__runtimeFixture.tab.sessionPath }));
|
||||
const remote = window.__runtimeFixture.tab.remote;
|
||||
return { tabId, sessionPath, generation: 1, selection: 0, remote: Boolean(remote), hostId: remote?.hostId, workspace: remote?.workspace };
|
||||
};
|
||||
if (key === "LookupInboxFollowupForTarget") return async (...args) => {
|
||||
window.__runtimeFixture.queries.push(args);
|
||||
if (window.__runtimeFixture.fail) throw new Error("receipt unavailable");
|
||||
return { itemId: "runtime-queued", disposition: "idempotent_hit", position: 0, paused: false };
|
||||
};
|
||||
if (key === "EnqueueInboxFollowupForTarget") return async (...args) => {
|
||||
window.__runtimeFixture.calls.push(args);
|
||||
if (window.__runtimeFixture.fail) throw new Error("fixture enqueue unavailable");
|
||||
return { itemId: "runtime-queued", disposition: "queued", position: 1, paused: false };
|
||||
};
|
||||
const installed = window.go; window.go = original;
|
||||
const value = app[key]; window.go = installed;
|
||||
if (key === "OpenRemoteProjectTab") return async (...args) => { const tab = await value(...args); window.__runtimeFixture.tab = tab; return tab; };
|
||||
return value;
|
||||
} }) } };
|
||||
});
|
||||
const publish = async (phase, extra = {}, remote = false) => page.evaluate(({ phase, extra, remote }) => {
|
||||
const f = window.__runtimeFixture;
|
||||
const tab = f.tab;
|
||||
const state = { schemaVersion: 1, runtimeEpoch: "fixture-controller", revision: ++f.revision, phase,
|
||||
running: phase === "executing" || phase === "finishing", turnId: "fixture-turn", turnStatus: phase === "executing" ? "in_progress" : "completed",
|
||||
turnEventSeq: 1, pendingPrompt: false, cancelRequested: false, cancellable: phase === "executing", backgroundJobs: 0, activity: phase === "executing" ? "thinking" : "", ...extra };
|
||||
return f.accept({ epoch: "fixture-app", revision: f.revision, topics: f.topics, sessions: [{
|
||||
tabId: tab.id, scope: tab.scope ?? "project", workspaceRoot: tab.workspaceRoot, topicId: tab.topicId ?? "",
|
||||
sessionPath: tab.sessionPath ?? "", sessionGeneration: 1, open: true, remote,
|
||||
hostId: tab.remote?.hostId, freshness: extra.freshness ?? "synced", state,
|
||||
}] }, true);
|
||||
}, { phase, extra, remote });
|
||||
await publish("finishing");
|
||||
await page.locator(".composer-run-strip").filter({ hasText: /Finishing|正在收尾/ }).waitFor();
|
||||
check(await page.locator(".composer__btn--stop").count() === 0, "finishing hides Stop");
|
||||
check(await page.locator(".composer-card--running,.composer-run-strip__dot").count() === 0, "finishing has no animated run marker");
|
||||
await input.fill("durable next turn");
|
||||
await input.press("Enter");
|
||||
await page.waitForFunction(() => window.__runtimeFixture.calls.length === 1);
|
||||
check(await input.inputValue() === "durable next turn", "failed enqueue preserves draft");
|
||||
await fs.mkdir("/tmp/reasonix-runtime-evidence", { recursive: true });
|
||||
await page.screenshot({ path: "/tmp/reasonix-runtime-evidence/pending-followup.png" });
|
||||
await publish("idle");
|
||||
check(await page.locator(".composer__btn--send").getAttribute("aria-label") === "Check send result", "phase transition keeps receipt confirmation action");
|
||||
await page.evaluate(() => { window.__runtimeFixture.fail = false; });
|
||||
await input.press("Enter");
|
||||
await page.waitForFunction(() => document.querySelector("textarea.composer__input:not([aria-hidden=true])")?.value === "");
|
||||
const calls = await page.evaluate(() => window.__runtimeFixture.calls);
|
||||
const queries = await page.evaluate(() => window.__runtimeFixture.queries);
|
||||
check(calls.length === 1 && queries.length === 1 && calls[0].at(-1) === queries[0].at(-1), "retry only queries the original durable idempotency key");
|
||||
await publish("idle", { backgroundJobs: 2 });
|
||||
await page.locator(".composer-run-strip").filter({ hasText: /2/ }).waitFor();
|
||||
check(await page.locator(".project-tree__folder-active-indicator:not(.project-tree__folder-active-indicator--static)").count() > 0, "background jobs keep project activity visible");
|
||||
await publish("idle");
|
||||
await page.locator(".composer-run-strip").waitFor({ state: "hidden" });
|
||||
check(await page.locator(".project-tree__folder-active-indicator").count() === 0, "last job completion clears project activity");
|
||||
await page.locator('.project-tree__folder-main:has(svg.lucide-cloud)').click();
|
||||
await page.locator('.project-tree__topic-main:has-text("Remote demo session")').click();
|
||||
await page.locator(".remote-surface--ready").waitFor();
|
||||
await publish("finishing", {}, true);
|
||||
await page.locator(".composer-run-strip").filter({ hasText: /Finishing|正在收尾/ }).waitFor();
|
||||
await input.fill("remote durable next turn");
|
||||
await input.press("Enter");
|
||||
await page.waitForFunction(() => window.__runtimeFixture.calls.length === 2);
|
||||
check(await input.inputValue() === "", "remote finishing queues the next input and clears it after receipt");
|
||||
await publish("executing", { freshness: "unknown" }, true);
|
||||
await page.locator(".composer-run-strip").filter({ hasText: /sync|同步/i }).waitFor();
|
||||
check(await input.isDisabled(), "remote disconnect blocks send while preserving unknown state");
|
||||
check(await page.locator(".composer__btn--stop").count() === 0, "unknown remote state hides Stop");
|
||||
await fs.mkdir("/tmp/reasonix-runtime-evidence", { recursive: true });
|
||||
await page.screenshot({ path: "/tmp/reasonix-runtime-evidence/remote-unknown.png" });
|
||||
await publish("executing", {}, true);
|
||||
await page.locator(".composer__btn--stop").waitFor();
|
||||
check(!(await input.isDisabled()), "remote reconnect restores authoritative execution controls");
|
||||
await page.evaluate(async () => {
|
||||
const { __emitMockRemoteTab } = await import("/src/lib/bridge.ts");
|
||||
const tabId = window.__runtimeFixture.tab.id;
|
||||
__emitMockRemoteTab(tabId, "event", { kind: "turn_started", turnId: "fixture-turn" });
|
||||
__emitMockRemoteTab(tabId, "event", { kind: "text", text: "runtime missing completion fixture" });
|
||||
});
|
||||
await page.locator(".remote-surface").getByText("runtime missing completion fixture", { exact: true }).waitFor();
|
||||
await publish("idle", {}, true);
|
||||
await page.locator(".composer-run-strip").waitFor({ state: "hidden" });
|
||||
check(await page.locator(".composer__btn--stop").count() === 0, "remote completion removes the run control");
|
||||
await page.waitForFunction(() => !document.querySelector('.remote-surface [data-transcript-block-phase="active"]'));
|
||||
check(await page.locator(".remote-surface").getByText("runtime missing completion fixture", { exact: true }).count() === 0,
|
||||
"trusted idle without turn_done settles the real transcript and reconciles durable history");
|
||||
await page.locator('.project-tree__topic-main:has-text("bench:geometry")').click();
|
||||
await page.waitForFunction(() => document.querySelector(".transcript")?.textContent?.includes("Geometry contract fixture complete."));
|
||||
check(await page.locator(".remote-surface").count() === 0, "local switch retains ownership after remote runtime frames");
|
||||
check(errors.length === 0, "runtime scenarios produce no browser errors: " + errors.join("; "));
|
||||
} catch (error) {
|
||||
console.error("Runtime fixture toasts:", await page.locator(".toast__text").allTextContents());
|
||||
throw error;
|
||||
} finally { await browser.close(); await server.close(); }
|
||||
@@ -44,7 +44,7 @@
|
||||
"test:window-state": "tsx src/__tests__/window-state-ordering.test.ts",
|
||||
"test:all": "pnpm test:typecheck && pnpm test:updater && pnpm test:window-state && pnpm test && pnpm test:remote && pnpm test:performance",
|
||||
"test:app-lifecycle": "tsx src/__tests__/app-lifecycle.test.tsx && tsx src/__tests__/committed-command-lifecycle.test.tsx && tsx src/__tests__/committed-command-execution.test.tsx && tsx src/__tests__/navigation-surface-lifecycle.test.tsx && tsx src/__tests__/app-lifecycle-probe.test.ts && tsx src/__tests__/subscription-scope.test.ts && tsx src/__tests__/composer-source-operations.test.tsx && tsx src/__tests__/session-prompt-lifecycle.test.tsx && tsx src/__tests__/desktop-preferences-lifecycle.test.tsx && tsx src/__tests__/onboarding-commands.test.tsx && tsx src/__tests__/topicbar-actions-lifecycle.test.tsx && tsx src/__tests__/decision-slots-lifecycle.test.tsx && tsx src/__tests__/session-experience-settings.test.tsx && tsx src/__tests__/project-topic-lifecycle.test.tsx && tsx src/__tests__/conversation-projection.test.ts && tsx src/__tests__/remote-composer-presentation.test.tsx && tsx src/__tests__/remote-composer-commands.test.tsx && tsx src/__tests__/terminal-panel-commands.test.tsx && tsx src/__tests__/workspace-panel-commands.test.tsx && tsx src/__tests__/desktop-navigation-lifecycle.test.tsx && tsx src/__tests__/runtime-status-lifecycle.test.tsx && tsx src/__tests__/session-control-commands.test.ts && tsx src/__tests__/automation-navigation-lifecycle.test.tsx && tsx src/__tests__/mock-remote-catalog.test.ts && tsx src/__tests__/topicbar-region.test.tsx && tsx src/__tests__/controller-profile-lifecycle.test.tsx && tsx src/__tests__/session-submission-lifecycle.test.tsx && tsx src/__tests__/pending-plan-revision-lifecycle.test.tsx && tsx src/__tests__/session-undo-lifecycle.test.tsx && tsx src/__tests__/session-clear-commands.test.tsx && tsx src/__tests__/turn-verification-commands.test.tsx && tsx src/__tests__/delivery-continue-commands.test.tsx && tsx src/__tests__/active-tab-mirror.test.tsx && tsx src/__tests__/windows-maximised-sync.test.tsx && tsx src/__tests__/topic-summary-commands.test.tsx && tsx src/__tests__/worktree-merge-commands.test.tsx && tsx src/__tests__/composer-insert-commands.test.tsx && node --test bench/app-memory-evidence.test.mjs && node --test bench/app-memory-shards.test.mjs bench/app-memory-paths.test.mjs && node --test scripts/check-app-layers.test.mjs",
|
||||
"test:app-browser": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/app-browser.mjs",
|
||||
"test:app-browser": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/app-browser.mjs && node bench/runtime-state.mjs",
|
||||
"test:app-memory": "PLAYWRIGHT_BROWSERS_PATH=.pw-browsers node bench/app-memory.mjs",
|
||||
"check:app-layers": "node scripts/check-app-entry-contract.mjs && node scripts/check-app-layers.test.mjs && node scripts/check-app-layers.mjs"
|
||||
},
|
||||
|
||||
@@ -297,9 +297,9 @@ for (const path of localeChunks) {
|
||||
// ceiling for cross-platform CI.
|
||||
// Search assignment copy adds 239 / 231 B over main-v2 (63147 / 63920 B).
|
||||
// Measured result: 63386 / 64151 B; retain bounded cross-platform headroom.
|
||||
// Turn result copy adds 554 / 566 B to the latest-base chunks, measuring
|
||||
// 64219 / 64964 B with recovery guidance included. Round to the next tenth.
|
||||
const budget = name.startsWith("zh-TW-") ? 63.5 * 1024 : 62.8 * 1024;
|
||||
// Runtime/receipt confirmation copy adds 89 / 99 B to the integrated
|
||||
// turn-result base (64219 / 64964 B). Measured: 64308 / 65063 B.
|
||||
const budget = name.startsWith("zh-TW-") ? 63.6 * 1024 : 62.9 * 1024;
|
||||
assertBudget(`${name} gzip`, gzipBytes(path), budget);
|
||||
}
|
||||
|
||||
@@ -418,8 +418,12 @@ const rawInitialBytes = [...initialJS, ...initialCSS, ...appShellCSS]
|
||||
// against the 2398.0 KiB base; retain only the next one-decimal ceiling.
|
||||
// Shared availability, visible recovery and retry controls measure 2407.215 KiB
|
||||
// (+6.107 KiB, 0.25% over the prior welcome head). Retain the next tenth.
|
||||
// Turn results add 12585 B (0.51%) over main-v2's 2464923 B: bounded receipt
|
||||
// projection, status presentation and view bindings. Result: 2477508 B.
|
||||
const rawInitialBudgetKiB = 2_419.6;
|
||||
// The integrated turn-result base measures 2477492 B. Runtime state and
|
||||
// session-bound receipt confirmation and its mock session contract add
|
||||
// 5944 B (0.240%): 2483436 B total.
|
||||
// Durable session isolation and missed-completion reconciliation add 1232 B
|
||||
// (0.050% over that head), measuring 2484668 B total.
|
||||
// Keep the next tenth; gzip, CSS, and individual chunk limits stay unchanged.
|
||||
const rawInitialBudgetKiB = 2_426.5;
|
||||
assertBudget("initial raw JavaScript and CSS", rawInitialBytes, rawInitialBudgetKiB * 1024);
|
||||
assertBudget("largest initial JavaScript chunk raw", largestInitialJSRaw, 1_000 * 1024);
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useLayoutEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRuntimeStateSync } from "./lib/useRuntimeState";
|
||||
import { useCommittedCommand } from "./lib/useCommittedCommand";
|
||||
import { openExternal } from "./lib/bridge";
|
||||
import { useT, useI18n } from "./lib/i18n";
|
||||
@@ -34,6 +35,7 @@ setReasoningDisplayPending();
|
||||
* shell view. Wiring only — no domain logic lives here.
|
||||
*/
|
||||
export function AppRuntime() {
|
||||
useRuntimeStateSync();
|
||||
const appRenderToken = createAppRenderToken();
|
||||
useLayoutEffect(() => commitAppRenderToken(appRenderToken));
|
||||
const runtime = useAppRuntimeAdapter();
|
||||
|
||||
@@ -0,0 +1,166 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { act } from "react";
|
||||
import { installDom, installBridgeApp, renderComposer } from "./composerInboxHarness";
|
||||
import { runtimeStateStore, type RuntimeProjection } from "../lib/runtimeStateStore";
|
||||
import { acceptRuntimeState } from "../lib/runtimeStateReducer";
|
||||
import { pendingFollowups, followupSessionKey, type InboxTarget } from "../lib/pendingFollowup";
|
||||
import { composerDraftKeyForTab } from "../lib/composerDraftKey";
|
||||
|
||||
const pendingSession = followupSessionKey("session-a");
|
||||
|
||||
const flush = () => new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||
async function paste(text: string) {
|
||||
await act(async () => {
|
||||
const event = new window.Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(event, "clipboardData", { value: { files: [], items: [], types: ["text/plain"], getData: () => text } });
|
||||
document.querySelector("textarea.composer__input:not([aria-hidden=true])")!.dispatchEvent(event);
|
||||
await flush();
|
||||
});
|
||||
}
|
||||
async function send() {
|
||||
await act(async () => { (document.querySelector(".composer__btn--send") as HTMLButtonElement).click(); await flush(); });
|
||||
}
|
||||
function state(phase: "idle" | "executing" | "finishing", revision: number): RuntimeProjection {
|
||||
return { epoch: "confirmation-test", revision, topics: [], sessions: [{
|
||||
tabId: "tab-a", scope: "project", workspaceRoot: "/repo", topicId: "topic", sessionPath: "session-a", sessionGeneration: 1,
|
||||
open: true, remote: true, freshness: "synced", state: { schemaVersion: 1, runtimeEpoch: "controller", revision,
|
||||
phase, running: phase !== "idle", turnId: "turn", turnStatus: "completed", turnEventSeq: 3,
|
||||
pendingPrompt: false, cancelRequested: false, cancellable: phase === "executing", backgroundJobs: 0, activity: "" },
|
||||
}] };
|
||||
}
|
||||
|
||||
for (const phase of ["idle", "executing", "finishing"] as const) {
|
||||
const dom = installDom();
|
||||
let posts = 0, direct = 0, steers = 0, queries = 0;
|
||||
const target: InboxTarget = { tabId: "tab-a", sessionPath: "session-a", generation: 7, selection: 3, remote: true };
|
||||
let key = "", confirmed = false;
|
||||
installBridgeApp({
|
||||
CaptureInboxTarget: async () => target,
|
||||
EnqueueInboxFollowupForTarget: async (captured: InboxTarget, _display: string, _submit: string, _invocations: unknown, id: string) => {
|
||||
posts++; key = id; assert.deepEqual(captured, target); throw new Error("reply and first receipt lookup lost");
|
||||
},
|
||||
LookupInboxFollowupForTarget: async (captured: InboxTarget, id: string) => {
|
||||
queries++; assert.deepEqual(captured, target); assert.equal(id, key);
|
||||
if (!confirmed) throw new Error("receipt unavailable");
|
||||
return { itemId: "already-consumed", disposition: "idempotent_hit", position: 0, paused: false };
|
||||
},
|
||||
InboxSnapshot: async () => ({ revision: 1, paused: false, recovered: false, items: [], itemsCount: 0, bytes: 0, maxItems: 64, maxBytes: 1024 }),
|
||||
});
|
||||
runtimeStateStore.commit(state("finishing", 1));
|
||||
let view = await renderComposer({ inboxSessionPath: "session-a", onSend: () => { direct++; }, onSteer: async () => { steers++; } });
|
||||
await paste("original follow-up");
|
||||
await send();
|
||||
assert.equal(posts, 1);
|
||||
assert.equal(document.querySelector(".composer__btn--send")?.getAttribute("aria-label"), "Check send result");
|
||||
await act(async () => { acceptRuntimeState(runtimeStateStore, state(phase, 2), true); await flush(); });
|
||||
await send();
|
||||
assert.equal(queries, 1);
|
||||
assert.equal(posts, 1);
|
||||
assert.equal(direct + steers, 0);
|
||||
assert.ok(pendingFollowups.get(pendingSession));
|
||||
|
||||
await view.rerender({ tabId: "tab-b", sessionKey: "session-b", inboxSessionPath: "session-b" });
|
||||
assert.notEqual(document.querySelector(".composer__btn--send")?.getAttribute("aria-label"), "Check send result");
|
||||
await view.rerender({ tabId: "tab-a", sessionKey: "session-a", inboxSessionPath: "session-a" });
|
||||
if (phase === "executing") {
|
||||
await act(async () => { view.root.unmount(); });
|
||||
view = await renderComposer({ inboxSessionPath: "session-a", onSend: () => { direct++; }, onSteer: async () => { steers++; } });
|
||||
assert.equal(document.querySelector(".composer__btn--send")?.getAttribute("aria-label"), "Check send result");
|
||||
assert.match(document.querySelector('[role="status"]')?.textContent ?? "", /original follow-up/, "the original request survives remount");
|
||||
}
|
||||
await paste("later draft edit");
|
||||
confirmed = true;
|
||||
await send();
|
||||
assert.equal(posts, 1);
|
||||
assert.equal(queries, 2);
|
||||
assert.equal(direct + steers, 0);
|
||||
assert.equal(pendingFollowups.get(pendingSession), undefined);
|
||||
assert.match((document.querySelector("textarea.composer__input:not([aria-hidden=true])") as HTMLTextAreaElement).value, /later draft edit/);
|
||||
assert.equal(document.querySelector(".composer-guidance-item"), null, "completed receipt must not resurrect a queued row");
|
||||
await act(async () => { view.root.unmount(); });
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
let posts = 0;
|
||||
installBridgeApp({ EnqueueInboxFollowup: async () => { posts++; throw new Error("legacy transport outcome unknown"); } });
|
||||
runtimeStateStore.commit(state("finishing", 1));
|
||||
const view = await renderComposer();
|
||||
await paste("legacy follow-up");
|
||||
await send();
|
||||
await act(async () => { runtimeStateStore.commit(state("idle", 2)); });
|
||||
await send();
|
||||
assert.equal(posts, 1, "missing receipt capability never permits another POST");
|
||||
const pending = pendingFollowups.get(pendingSession);
|
||||
assert.ok(pending);
|
||||
await view.rerender({ inboxHostId: "different-host", inboxWorkspace: "/repo" });
|
||||
assert.notEqual(document.querySelector(".composer__btn--send")?.getAttribute("aria-label"), "Check send result", "host identity isolates otherwise identical draft keys");
|
||||
await act(async () => { pendingFollowups.clear(pendingSession, pending); view.root.unmount(); });
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
let posts = 0;
|
||||
installBridgeApp({ EnqueueInboxFollowup: async () => { posts++; throw new Error("reasonix_error:inbox_not_submitted"); } });
|
||||
runtimeStateStore.commit(state("finishing", 1));
|
||||
const view = await renderComposer();
|
||||
await paste("rejected follow-up");
|
||||
await send();
|
||||
assert.equal(pendingFollowups.get(pendingSession), undefined, "a definite pre-execution rejection releases the request");
|
||||
assert.match((document.querySelector("textarea.composer__input:not([aria-hidden=true])") as HTMLTextAreaElement).value, /rejected follow-up/);
|
||||
await send();
|
||||
assert.equal(posts, 2, "a known unexecuted request remains retryable");
|
||||
await act(async () => { view.root.unmount(); });
|
||||
dom.window.close();
|
||||
}
|
||||
console.log("PASS: uncertain follow-up stays bound across phases, selection, edits, and remounts");
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
const tab = { id: "tab-a", scope: "project", workspaceRoot: "/repo", topicId: "topic", sessionPath: "session-a" };
|
||||
const sessionKey = composerDraftKeyForTab(tab);
|
||||
assert.equal(sessionKey, composerDraftKeyForTab({ ...tab, sessionPath: "session-b" }));
|
||||
let posts = 0, direct = 0, reads = 0;
|
||||
let release!: () => void;
|
||||
installBridgeApp({
|
||||
CaptureInboxTarget: async () => ({ tabId: "tab-a", sessionPath: "session-a", generation: 1, selection: 0, remote: false }),
|
||||
EnqueueInboxFollowupForTarget: async () => { posts++; throw new Error("reply lost"); },
|
||||
LookupInboxFollowupForTarget: async (target: InboxTarget) => {
|
||||
reads++; assert.equal(target.sessionPath, "session-a");
|
||||
await new Promise<void>(resolve => { release = resolve; });
|
||||
return { itemId: "original", disposition: "idempotent_hit", position: 0, paused: false };
|
||||
},
|
||||
});
|
||||
runtimeStateStore.commit(state("finishing", 1));
|
||||
const view = await renderComposer({ sessionKey, onSend: () => { direct++; } });
|
||||
await paste("original task"); await send();
|
||||
await act(async () => { runtimeStateStore.commit({ ...state("idle", 2), sessions: state("idle", 2).sessions.map(s => ({ ...s, sessionPath: "session-b" })) }); });
|
||||
await view.rerender({ inboxSessionPath: "session-b" });
|
||||
assert.notEqual(document.querySelector(".composer__btn--send")?.getAttribute("aria-label"), "Check send result");
|
||||
await send();
|
||||
assert.equal(direct, 1); assert.equal(reads, 0); assert.equal(posts, 1);
|
||||
await view.rerender({ inboxSessionPath: "session-a" });
|
||||
await send();
|
||||
assert.equal(reads, 1);
|
||||
await view.rerender({ inboxSessionPath: "session-b" });
|
||||
await paste("new draft for B");
|
||||
await act(async () => { release(); await flush(); });
|
||||
assert.match((document.querySelector("textarea.composer__input:not([aria-hidden=true])") as HTMLTextAreaElement).value, /new draft for B/);
|
||||
assert.equal(pendingFollowups.get(pendingSession), undefined);
|
||||
await act(async () => { view.root.unmount(); }); dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom();
|
||||
let posts = 0;
|
||||
installBridgeApp({ EnqueueInboxFollowup: async () => { posts++; } });
|
||||
runtimeStateStore.commit(state("finishing", 1));
|
||||
const view = await renderComposer({ inboxSessionPath: undefined });
|
||||
await paste("path not bound"); await send();
|
||||
assert.equal(posts, 0, "unknown durable identity cannot fall back to a topic key");
|
||||
await act(async () => { view.root.unmount(); }); dom.window.close();
|
||||
}
|
||||
assert.notEqual(followupSessionKey("/same", "host", "/one"), followupSessionKey("/same", "host", "/two"));
|
||||
console.log("PASS: same-topic sessions, late receipts, and unbound paths preserve request ownership");
|
||||
@@ -1,13 +1,9 @@
|
||||
// Run: tsx src/__tests__/composer-inbox-recovery.test.tsx
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Composer } from "../components/Composer";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import { ToastProvider } from "../lib/toast";
|
||||
import type { CollaborationMode, ToolApprovalMode } from "../lib/types";
|
||||
import { installDom, installBridgeApp, renderComposer } from "./composerInboxHarness";
|
||||
import { runtimeStateStore, type RuntimeProjection } from "../lib/runtimeStateStore";
|
||||
import { acceptRuntimeState } from "../lib/runtimeStateReducer";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
@@ -34,119 +30,6 @@ async function waitFor(label: string, check: () => boolean) {
|
||||
ok(false, label);
|
||||
}
|
||||
|
||||
class TestResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
function installDom(language = "en-US") {
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
Object.defineProperty(dom.window.navigator, "language", { configurable: true, value: language });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.HTMLTextAreaElement = dom.window.HTMLTextAreaElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.CustomEvent = dom.window.CustomEvent;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.InputEvent = dom.window.InputEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
|
||||
globalThis.MutationObserver = dom.window.MutationObserver;
|
||||
globalThis.File = dom.window.File;
|
||||
globalThis.FileReader = dom.window.FileReader;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
globalThis.ResizeObserver = TestResizeObserver;
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
matches: true,
|
||||
media: "(prefers-reduced-motion: reduce)",
|
||||
onchange: null,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
addListener() {},
|
||||
removeListener() {},
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
});
|
||||
return dom;
|
||||
}
|
||||
|
||||
function installBridgeApp(methods: Record<string, unknown>) {
|
||||
(window as unknown as { go: { main: { App: Record<string, unknown> } } }).go = {
|
||||
main: {
|
||||
App: {
|
||||
Commands: async () => [],
|
||||
Models: async () => [],
|
||||
ModelsForTab: async () => [],
|
||||
ListDir: async () => [],
|
||||
ListDirForTab: async () => [],
|
||||
SearchFileRefs: async () => [],
|
||||
SearchFileRefsForTab: async () => [],
|
||||
...methods,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {}, strictMode = false) {
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
let currentProps: Parameters<typeof Composer>[0] = {
|
||||
running: false,
|
||||
collaborationMode: "normal" as CollaborationMode,
|
||||
toolApprovalMode: "ask" as ToolApprovalMode,
|
||||
|
||||
goal: "",
|
||||
cwd: "/repo",
|
||||
modelLabel: "DeepSeek-R1",
|
||||
tabId: "tab-a",
|
||||
sessionKey: "session-a",
|
||||
onSend: () => {},
|
||||
onCancel: async () => ({ discardedItemIds: [] }),
|
||||
onCycleMode: () => {},
|
||||
onSetMode: () => {},
|
||||
onSetCollaborationMode: () => {},
|
||||
onSetToolApprovalMode: () => {},
|
||||
onToggleYoloApprovalMode: () => {},
|
||||
onClearGoal: () => {},
|
||||
onSwitchModel: () => {},
|
||||
onSetEffort: () => {},
|
||||
|
||||
ready: true,
|
||||
...props,
|
||||
};
|
||||
const paint = async (nextProps: Partial<Parameters<typeof Composer>[0]> = {}) => {
|
||||
currentProps = { ...currentProps, ...nextProps };
|
||||
const view = (
|
||||
<LocaleProvider>
|
||||
<ToastProvider>
|
||||
<Composer {...currentProps} />
|
||||
</ToastProvider>
|
||||
</LocaleProvider>
|
||||
);
|
||||
await act(async () => {
|
||||
root.render(strictMode ? <React.StrictMode>{view}</React.StrictMode> : view);
|
||||
await flushTimers();
|
||||
});
|
||||
};
|
||||
await paint();
|
||||
return { root, rerender: paint };
|
||||
}
|
||||
|
||||
function recoveredSnapshot(count = 3) {
|
||||
const items = Array.from({ length: count }, (_, index) => ({
|
||||
id: `recovered-${index + 1}`,
|
||||
@@ -793,6 +676,53 @@ console.log("\ncomposer inbox recovery");
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
{
|
||||
const dom = installDom("zh-CN");
|
||||
const calls: { display: string; submit: string; id: string }[] = [];
|
||||
const lookups: string[] = [];
|
||||
let rejectEnqueue = true, cancelled = 0, direct = 0;
|
||||
installBridgeApp({
|
||||
InboxSnapshot: async () => ({ ...recoveredSnapshot(0), paused: false, recovered: false }),
|
||||
CaptureInboxTarget: async () => ({ tabId: "tab-a", sessionPath: "session-a", generation: 1, selection: 0, remote: false }),
|
||||
EnqueueInboxFollowupForTarget: async (_target: unknown, display: string, submit: string, _invocations: unknown, id: string) => {
|
||||
calls.push({ display, submit, id });
|
||||
if (rejectEnqueue) throw new Error("transport outcome unknown");
|
||||
return { itemId: "durable-followup", disposition: "queued_followup", position: 1, paused: false };
|
||||
},
|
||||
LookupInboxFollowupForTarget: async (_target: unknown, id: string) => {
|
||||
lookups.push(id);
|
||||
return { itemId: "durable-followup", disposition: "idempotent_hit", position: 0, paused: false };
|
||||
},
|
||||
});
|
||||
const snapshot: RuntimeProjection = { epoch: "finishing-test", revision: 1, topics: [], sessions: [{
|
||||
tabId: "tab-a", scope: "project", workspaceRoot: "/repo", topicId: "topic", sessionPath: "session-a", sessionGeneration: 1,
|
||||
open: true, remote: false, freshness: "synced", state: { schemaVersion: 1, runtimeEpoch: "controller-test", revision: 1,
|
||||
phase: "finishing", running: true, turnId: "turn", turnStatus: "completed", turnEventSeq: 3,
|
||||
pendingPrompt: false, cancelRequested: false, cancellable: false, backgroundJobs: 0, activity: "" },
|
||||
}] };
|
||||
acceptRuntimeState(runtimeStateStore, snapshot, true);
|
||||
const { root } = await renderComposer({ running: false, inboxSessionPath: "session-a", onSend: () => { direct++; }, onCancel: () => { cancelled++; } });
|
||||
ok(Boolean(document.querySelector(".composer-run-strip")?.textContent?.includes("正在收尾")), "finishing is visibly announced from shared state after TurnDone");
|
||||
ok(!document.querySelector(".composer-card--running") && !document.querySelector(".composer-run-strip__dot"), "finishing does not animate as model execution");
|
||||
ok(!document.querySelector(".composer__btn--stop"), "finishing hides current-turn stop");
|
||||
const input = document.querySelector("textarea.composer__input:not([aria-hidden=true])") as HTMLTextAreaElement;
|
||||
await act(async () => {
|
||||
const paste = new window.Event("paste", { bubbles: true, cancelable: true });
|
||||
Object.defineProperty(paste, "clipboardData", { value: { files: [], items: [], types: ["text/plain"], getData: () => "follow up after cleanup" } });
|
||||
input.dispatchEvent(paste);
|
||||
await flushTimers();
|
||||
});
|
||||
await act(async () => { (document.querySelector(".composer__btn--send") as HTMLButtonElement).click(); await flushTimers(); });
|
||||
ok(calls.length === 1 && input.value.includes("follow up"), "uncertain queue failure retains draft and makes one request");
|
||||
rejectEnqueue = false;
|
||||
await act(async () => { (document.querySelector(".composer__btn--send") as HTMLButtonElement).click(); await flushTimers(); });
|
||||
ok(calls.length === 1 && calls[0].id !== "" && calls[0].id === lookups[0], "explicit retry only looks up the original idempotency key");
|
||||
ok(calls[0]?.submit === "follow up after cleanup" && input.value === "", "durable queue receipt clears the complete submitted draft");
|
||||
ok(direct === 0 && cancelled === 0, "finishing neither directly submits nor cancels");
|
||||
await act(async () => { root.unmount(); });
|
||||
dom.window.close();
|
||||
}
|
||||
|
||||
if (failed > 0) {
|
||||
process.stderr.write(`\n${failed} failed, ${passed} passed\n`);
|
||||
process.exit(1);
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { JSDOM } from "jsdom";
|
||||
import React from "react";
|
||||
import { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { Composer } from "../components/Composer";
|
||||
import { LocaleProvider } from "../lib/i18n";
|
||||
import { ToastProvider } from "../lib/toast";
|
||||
import type { CollaborationMode, ToolApprovalMode } from "../lib/types";
|
||||
|
||||
const flushTimers = () => new Promise<void>(resolve => setTimeout(resolve, 0));
|
||||
|
||||
class TestResizeObserver {
|
||||
observe() {}
|
||||
unobserve() {}
|
||||
disconnect() {}
|
||||
}
|
||||
|
||||
export function installDom(language = "en-US") {
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
url: "http://localhost/",
|
||||
});
|
||||
(globalThis as typeof globalThis & { IS_REACT_ACT_ENVIRONMENT: boolean }).IS_REACT_ACT_ENVIRONMENT = true;
|
||||
globalThis.window = dom.window as unknown as Window & typeof globalThis;
|
||||
globalThis.document = dom.window.document;
|
||||
Object.defineProperty(globalThis, "navigator", { configurable: true, value: dom.window.navigator });
|
||||
Object.defineProperty(dom.window.navigator, "language", { configurable: true, value: language });
|
||||
globalThis.Node = dom.window.Node;
|
||||
globalThis.HTMLElement = dom.window.HTMLElement;
|
||||
globalThis.HTMLTextAreaElement = dom.window.HTMLTextAreaElement;
|
||||
globalThis.Event = dom.window.Event;
|
||||
globalThis.CustomEvent = dom.window.CustomEvent;
|
||||
globalThis.KeyboardEvent = dom.window.KeyboardEvent;
|
||||
globalThis.InputEvent = dom.window.InputEvent;
|
||||
globalThis.MouseEvent = dom.window.MouseEvent;
|
||||
globalThis.PointerEvent = dom.window.MouseEvent as unknown as typeof PointerEvent;
|
||||
globalThis.MutationObserver = dom.window.MutationObserver;
|
||||
globalThis.File = dom.window.File;
|
||||
globalThis.FileReader = dom.window.FileReader;
|
||||
globalThis.localStorage = dom.window.localStorage;
|
||||
globalThis.requestAnimationFrame = dom.window.requestAnimationFrame.bind(dom.window);
|
||||
globalThis.cancelAnimationFrame = dom.window.cancelAnimationFrame.bind(dom.window);
|
||||
globalThis.ResizeObserver = TestResizeObserver;
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "attachEvent", { configurable: true, value: () => {} });
|
||||
Object.defineProperty(dom.window.HTMLElement.prototype, "detachEvent", { configurable: true, value: () => {} });
|
||||
Object.defineProperty(window, "matchMedia", {
|
||||
configurable: true,
|
||||
value: () => ({
|
||||
matches: true,
|
||||
media: "(prefers-reduced-motion: reduce)",
|
||||
onchange: null,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
addListener() {},
|
||||
removeListener() {},
|
||||
dispatchEvent: () => false,
|
||||
}),
|
||||
});
|
||||
return dom;
|
||||
}
|
||||
|
||||
export function installBridgeApp(methods: Record<string, unknown>) {
|
||||
(window as unknown as { go: { main: { App: Record<string, unknown> } } }).go = {
|
||||
main: {
|
||||
App: {
|
||||
Commands: async () => [],
|
||||
Models: async () => [],
|
||||
ModelsForTab: async () => [],
|
||||
ListDir: async () => [],
|
||||
ListDirForTab: async () => [],
|
||||
SearchFileRefs: async () => [],
|
||||
SearchFileRefsForTab: async () => [],
|
||||
...methods,
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function renderComposer(props: Partial<Parameters<typeof Composer>[0]> = {}, strictMode = false) {
|
||||
const rootEl = document.getElementById("root");
|
||||
if (!rootEl) throw new Error("missing root");
|
||||
const root = createRoot(rootEl);
|
||||
let currentProps: Parameters<typeof Composer>[0] = {
|
||||
running: false,
|
||||
collaborationMode: "normal" as CollaborationMode,
|
||||
toolApprovalMode: "ask" as ToolApprovalMode,
|
||||
|
||||
goal: "",
|
||||
cwd: "/repo",
|
||||
modelLabel: "DeepSeek-R1",
|
||||
tabId: "tab-a",
|
||||
sessionKey: "session-a",
|
||||
inboxSessionPath: "session-a",
|
||||
onSend: () => {},
|
||||
onCancel: async () => ({ discardedItemIds: [] }),
|
||||
onCycleMode: () => {},
|
||||
onSetMode: () => {},
|
||||
onSetCollaborationMode: () => {},
|
||||
onSetToolApprovalMode: () => {},
|
||||
onToggleYoloApprovalMode: () => {},
|
||||
onClearGoal: () => {},
|
||||
onSwitchModel: () => {},
|
||||
onSetEffort: () => {},
|
||||
|
||||
ready: true,
|
||||
...props,
|
||||
};
|
||||
const paint = async (nextProps: Partial<Parameters<typeof Composer>[0]> = {}) => {
|
||||
currentProps = { ...currentProps, ...nextProps };
|
||||
const view = (
|
||||
<LocaleProvider>
|
||||
<ToastProvider>
|
||||
<Composer {...currentProps} />
|
||||
</ToastProvider>
|
||||
</LocaleProvider>
|
||||
);
|
||||
await act(async () => {
|
||||
root.render(strictMode ? <React.StrictMode>{view}</React.StrictMode> : view);
|
||||
await flushTimers();
|
||||
});
|
||||
};
|
||||
await paint();
|
||||
return { root, rerender: paint };
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
import React, { act } from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { RemoteSessionSurface } from "../../components/RemoteSessionSurface";
|
||||
import { LocaleProvider } from "../../lib/i18n";
|
||||
import { useRemoteSession, type RemoteSessionApi } from "../../lib/useRemoteSession";
|
||||
import { __emitMockRemoteTab, type AppBindings } from "../../lib/bridge";
|
||||
import type { TabMeta } from "../../lib/types";
|
||||
|
||||
export async function runRemoteRuntimeCases({ remoteTab, ok, tape, flush, setSnapshotHistory }: {
|
||||
remoteTab: TabMeta;
|
||||
ok: (value: boolean, label: string) => void;
|
||||
tape: string[];
|
||||
flush: () => Promise<void>;
|
||||
setSnapshotHistory: (history: unknown[]) => void;
|
||||
}) {
|
||||
// A lost turn_done must settle the actual shared Transcript from runtime
|
||||
// evidence, while a late history response must not erase the next turn.
|
||||
const { runtimeStateStore } = await import("../../lib/runtimeStateStore");
|
||||
let runtimeProbe: RemoteSessionApi;
|
||||
function RuntimeProbe() {
|
||||
runtimeProbe = useRemoteSession("tab-runtime", "ready", "/runtime-session");
|
||||
return <RemoteSessionSurface tab={{ ...remoteTab, id: "tab-runtime" }} session={runtimeProbe} />;
|
||||
}
|
||||
const runtimeNode = document.createElement("div");
|
||||
document.body.append(runtimeNode);
|
||||
const runtimeRoot = createRoot(runtimeNode);
|
||||
await act(async () => { runtimeRoot.render(<LocaleProvider><RuntimeProbe /></LocaleProvider>); await flush(); });
|
||||
let runtimeRevision = 0;
|
||||
async function publishRuntime(phase: "idle" | "executing" | "finishing", turnId = "lost-turn", freshness: "synced" | "unknown" = "synced", sessionPath = "/runtime-session") {
|
||||
await act(async () => {
|
||||
runtimeStateStore.commit({ epoch: "runtime-app", revision: ++runtimeRevision, topics: [], sessions: [{
|
||||
tabId: "tab-runtime", scope: "project", workspaceRoot: "~/app", topicId: "", sessionPath,
|
||||
sessionGeneration: 1, open: true, remote: true, freshness,
|
||||
state: { schemaVersion: 1, runtimeEpoch: "runtime-controller", revision: runtimeRevision,
|
||||
phase, running: phase !== "idle", turnId, turnStatus: phase === "idle" ? "completed" : "in_progress",
|
||||
turnEventSeq: runtimeRevision, pendingPrompt: false, cancelRequested: false,
|
||||
cancellable: phase === "executing", backgroundJobs: 0, activity: "" },
|
||||
}] });
|
||||
await flush();
|
||||
});
|
||||
}
|
||||
await act(async () => {
|
||||
__emitMockRemoteTab("tab-runtime", "event", { kind: "turn_started", turnId: "lost-turn" });
|
||||
__emitMockRemoteTab("tab-runtime", "event", { kind: "text", text: "partial runtime answer" });
|
||||
await flush();
|
||||
});
|
||||
const beforeRuntimeHistory = tape.filter(entry => entry === "snapshot:tab-runtime").length;
|
||||
await publishRuntime("idle", "lost-turn", "unknown");
|
||||
ok(runtimeProbe!.transcript.running, "unknown runtime does not settle a stream");
|
||||
await publishRuntime("finishing");
|
||||
ok(runtimeProbe!.transcript.running, "finishing does not settle the transcript before completion");
|
||||
await publishRuntime("idle", "previous-turn");
|
||||
ok(runtimeProbe!.transcript.running, "idle evidence for a previous turn cannot settle the current turn");
|
||||
await publishRuntime("idle", "lost-turn", "synced", "/other-session");
|
||||
ok(runtimeProbe!.transcript.running, "another selected session cannot settle the current transcript");
|
||||
await act(async () => {
|
||||
__emitMockRemoteTab("tab-runtime", "event", { kind: "retrying", retryAttempt: 2, retryMax: 4 });
|
||||
await flush();
|
||||
});
|
||||
ok(runtimeProbe!.transcript.retry !== undefined, "lost-completion fixture includes an active retry");
|
||||
setSnapshotHistory([{ role: "assistant", content: "complete durable runtime answer" }]);
|
||||
await publishRuntime("idle");
|
||||
await act(async () => flush());
|
||||
ok(!runtimeProbe!.running && !runtimeProbe!.transcript.running && !runtimeProbe!.transcript.turnActive
|
||||
&& runtimeProbe!.transcript.live === undefined && runtimeProbe!.transcript.retry === undefined,
|
||||
"trusted idle settles transcript, live stream, and turn activity together");
|
||||
ok(runtimeNode.textContent?.includes("complete durable runtime answer") === true
|
||||
&& !runtimeNode.querySelector('[data-transcript-block-phase="active"]'),
|
||||
"lost completion restores durable content and removes the actual active DOM state");
|
||||
await publishRuntime("idle");
|
||||
ok(tape.filter(entry => entry === "snapshot:tab-runtime").length === beforeRuntimeHistory + 1,
|
||||
"ordinary idle revisions do not repeatedly reload history");
|
||||
await act(async () => { await runtimeProbe!.submit("next optimistic turn"); await flush(); });
|
||||
await publishRuntime("idle");
|
||||
ok(runtimeProbe!.transcript.running, "a newer idle revision of the old turn cannot settle an optimistic submission");
|
||||
await act(async () => {
|
||||
__emitMockRemoteTab("tab-runtime", "event", { kind: "turn_started", turnId: "next-turn" });
|
||||
__emitMockRemoteTab("tab-runtime", "event", { kind: "text", text: "next live answer" });
|
||||
await flush();
|
||||
});
|
||||
await publishRuntime("idle");
|
||||
ok(runtimeProbe!.transcript.running, "old idle remains fenced after the next turn_started");
|
||||
const originalSnapshot = window.go!.main.App.RemoteTabSnapshot;
|
||||
let releaseRuntimeHistory: ((value: Awaited<ReturnType<AppBindings["RemoteTabSnapshot"]>>) => void) | undefined;
|
||||
window.go!.main.App.RemoteTabSnapshot = async () => new Promise(resolve => { releaseRuntimeHistory = resolve; });
|
||||
await publishRuntime("idle", "next-turn");
|
||||
await act(async () => {
|
||||
__emitMockRemoteTab("tab-runtime", "event", { kind: "turn_started", turnId: "third-turn" });
|
||||
__emitMockRemoteTab("tab-runtime", "event", { kind: "text", text: "third live answer" });
|
||||
await flush();
|
||||
releaseRuntimeHistory?.({ history: [{ role: "assistant", content: "obsolete history" }] });
|
||||
await flush();
|
||||
});
|
||||
ok(runtimeProbe!.transcript.running && runtimeProbe!.transcript.live?.text === "third live answer"
|
||||
&& !runtimeProbe!.transcript.items.some(item => item.kind === "assistant" && item.text === "obsolete history"),
|
||||
"history reconciliation cannot replace a newer streaming turn");
|
||||
await publishRuntime("idle", "third-turn");
|
||||
await act(async () => {
|
||||
__emitMockRemoteTab("tab-runtime", "event", { kind: "usage", turnId: "third-turn", usage: { promptTokens: 12, completionTokens: 3 } });
|
||||
releaseRuntimeHistory?.({ history: [{ role: "assistant", content: "third durable answer" }] });
|
||||
await flush();
|
||||
});
|
||||
ok(runtimeProbe!.transcript.items.some(item => item.kind === "assistant" && item.text === "third durable answer"),
|
||||
"late telemetry from the settled turn does not discard durable history");
|
||||
window.go!.main.App.RemoteTabSnapshot = originalSnapshot;
|
||||
await act(async () => runtimeRoot.unmount());
|
||||
runtimeNode.remove();
|
||||
}
|
||||
@@ -7,6 +7,7 @@ Object.assign(globalThis, { window: dom.window, document: dom.window.document, l
|
||||
try {
|
||||
const local = (await app.ListTabs())[0];
|
||||
const remote = await app.OpenRemoteProjectTab("demo", "~/app", { sessionName: "intro" });
|
||||
assert.equal(remote.sessionPath, "~/app/sessions/intro.jsonl");
|
||||
assert.ok((await app.ListTabs()).some(tab => tab.id === remote.id), "remote open and ListTabs share the backend catalog");
|
||||
await app.SetActiveTab(remote.id);
|
||||
assert.deepEqual((await app.ListTabs()).filter(tab => tab.active).map(tab => tab.id), [remote.id]);
|
||||
@@ -14,9 +15,11 @@ try {
|
||||
assert.equal((await app.ListTabs()).find(tab => tab.id === remote.id)?.label, "fixture/model");
|
||||
const renewed = await app.OpenRemoteProjectTab("demo", "~/app", { newSession: true });
|
||||
assert.equal(renewed.id, remote.id, "remote new session reuses its workspace surface");
|
||||
assert.ok(renewed.sessionPath && renewed.sessionPath !== remote.sessionPath, "new session replaces the durable session identity");
|
||||
assert.equal((await app.ListTabs()).filter(tab => tab.id === remote.id).length, 1);
|
||||
assert.equal((await app.ListTabs()).find(tab => tab.id === remote.id)?.topicTitle, "New session");
|
||||
const status = await app.RemoteTabStatus(remote.id) as Record<string, unknown>;
|
||||
assert.equal(status.sessionPath, renewed.sessionPath);
|
||||
assert.equal(status.plan, false);
|
||||
assert.equal(status.toolApprovalMode, "ask");
|
||||
assert.equal(status.goal, "");
|
||||
|
||||
@@ -52,7 +52,7 @@ ok(
|
||||
"session rows open the matching in-app remote session",
|
||||
);
|
||||
ok(
|
||||
/rows\.map\(\(row\): ProjectNode =>/.test(remoteSource) && /mergeRemoteSessionsIntoTree\(tree, remoteSessions, t\)/.test(source) &&
|
||||
/rows\.map\(\(row\): ProjectNode =>/.test(remoteSource) && /useRemoteRuntimeTree\(tree, remoteSessions, t\)/.test(source) &&
|
||||
/root: node\.remote!\.workspace/.test(remoteSource) && /sessionPath: row\.path/.test(remoteSource),
|
||||
"remote group children render with the active workspace and session identity",
|
||||
);
|
||||
|
||||
@@ -1,23 +1,16 @@
|
||||
import React, { act } from "react";
|
||||
import { RemoteNavigationHarness } from "./helpers/RemoteNavigationHarness";
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
import type { AppBindings } from "../lib/bridge";
|
||||
import type { TabMeta } from "../lib/types";
|
||||
import type { RemoteSessionApi } from "../lib/useRemoteSession";
|
||||
|
||||
let passed = 0;
|
||||
let failed = 0;
|
||||
function ok(value: boolean, label: string) {
|
||||
if (value) {
|
||||
process.stdout.write(` PASS ${label}\n`);
|
||||
passed += 1;
|
||||
} else {
|
||||
process.stdout.write(` FAIL ${label}\n`);
|
||||
failed += 1;
|
||||
}
|
||||
process.stdout.write(` ${value ? "PASS" : "FAIL"} ${label}\n`);
|
||||
if (value) passed += 1;
|
||||
else failed += 1;
|
||||
}
|
||||
|
||||
console.log("\nRemote session surface + hook");
|
||||
const dom = new JSDOM("<!doctype html><html><body><div id=\"root\"></div></body></html>", {
|
||||
pretendToBeVisual: true,
|
||||
@@ -440,6 +433,12 @@ await act(async () => {
|
||||
ok(!document.querySelector(".extension-form"), "remote extension form clears after an accepted submission");
|
||||
}
|
||||
|
||||
await act(async () => {
|
||||
__emitMockRemoteTab("tab-remote-1", "event", { kind: "text", text: "retain this partial answer across disconnect" });
|
||||
await flush();
|
||||
});
|
||||
ok(document.querySelector("main .transcript")?.textContent?.includes("retain this partial answer across disconnect") === true,
|
||||
"disconnect fixture has visible transcript content before connection loss");
|
||||
await act(async () => {
|
||||
__emitMockRemoteTab("tab-remote-1", "state", { state: "serve_down", error: "tunnel closed" });
|
||||
await flush();
|
||||
@@ -448,7 +447,8 @@ await act(async () => {
|
||||
const warning = document.querySelector(".session-recovery[role=alert]");
|
||||
ok(Boolean(warning), "serve_down renders the warning state");
|
||||
ok(!warning?.closest("main"), "recovery controls are outside the collapsible transcript main");
|
||||
ok(Boolean(document.querySelector("main .transcript")), "disconnect retains the already loaded transcript");
|
||||
ok(document.querySelector("main .transcript")?.textContent?.includes("retain this partial answer across disconnect") === true,
|
||||
"disconnect retains the already loaded transcript");
|
||||
await act(async () => { warning?.querySelector<HTMLButtonElement>("button[aria-controls]")?.click(); });
|
||||
ok(warning?.textContent?.includes("tunnel closed") === true, "serve error detail renders");
|
||||
await act(async () => {
|
||||
@@ -794,6 +794,7 @@ ok(replayProbe?.transcript.approval?.id === "replayed-approval", "a remote mode
|
||||
await act(async () => { replayProbe?.drainApprovals(["replayed-approval"]); await flush(); });
|
||||
ok(replayProbe?.transcript.approval === undefined, "a remote mode transaction clears the exact approval it auto-allowed");
|
||||
await act(async () => replayRoot.unmount());
|
||||
await (await import("./helpers/remoteRuntimeReconciliationCases")).runRemoteRuntimeCases({ remoteTab, ok, tape, flush, setSnapshotHistory: value => { snapshotHistory = value; } });
|
||||
dom.window.close();
|
||||
process.stdout.write(`\n${passed} passed, ${failed} failed\n`);
|
||||
if (failed > 0) process.exit(1);
|
||||
@@ -0,0 +1,78 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createRuntimeStateStore, selectRuntime, type RuntimeProjection, type RuntimeState } from "../lib/runtimeStateStore";
|
||||
import { startRuntimeStateSync } from "../lib/runtimeStateSync";
|
||||
import { acceptRuntimeState } from "../lib/runtimeStateReducer";
|
||||
|
||||
const state: RuntimeState = { schemaVersion: 1, runtimeEpoch: "controller-a", revision: 1, phase: "executing", running: true,
|
||||
turnId: "turn-a", turnStatus: "in_progress", turnEventSeq: 1, pendingPrompt: false, cancelRequested: false, cancellable: true, backgroundJobs: 0, activity: "thinking" };
|
||||
const projection = (revision: number, changes: Partial<RuntimeState> = {}): RuntimeProjection => ({ epoch: "app-a", revision, topics: [],
|
||||
sessions: [{ tabId: "a", scope: "project", workspaceRoot: "/fixture", topicId: "topic", sessionPath: "/fixture/session", sessionGeneration: 1,
|
||||
open: true, remote: false, freshness: "synced", state: { ...state, revision, ...changes } }] });
|
||||
const rawStore = createRuntimeStateStore();
|
||||
const store = { ...rawStore, accept: (next: RuntimeProjection, authoritative = false) => acceptRuntimeState(rawStore, next, authoritative) };
|
||||
let updates = 0;
|
||||
store.subscribe(() => updates++);
|
||||
assert.equal(store.accept(projection(1)), "accepted");
|
||||
const first = store.getSnapshot();
|
||||
assert.equal(store.accept(projection(1)), "duplicate");
|
||||
assert.equal(store.getSnapshot(), first);
|
||||
assert.equal(updates, 1);
|
||||
assert.equal(store.accept(projection(1, { running: false })), "conflict");
|
||||
assert.equal(store.getSnapshot(), first);
|
||||
assert.equal(store.accept(projection(0)), "stale");
|
||||
assert.equal(store.accept(projection(2, { phase: "finishing", activity: "", cancellable: false })), "accepted");
|
||||
let view = selectRuntime(store.getSnapshot()!.sessions[0]);
|
||||
assert.equal(view.kind, "finishing"); assert.equal(view.spinning, false); assert.equal(view.cancellable, false); assert.equal(view.running, true);
|
||||
store.accept(projection(3, { phase: "idle", running: false, activity: "", cancellable: false, backgroundJobs: 2 }));
|
||||
view = selectRuntime(store.getSnapshot()!.sessions[0]);
|
||||
assert.equal(view.kind, "background_job"); assert.equal(view.running, false);
|
||||
store.fail();
|
||||
assert.equal(selectRuntime(store.getSnapshot()!.sessions[0], store.getFailed()).kind, "unknown");
|
||||
assert.equal(store.getSnapshot()!.sessions[0].state.backgroundJobs, 2);
|
||||
assert.equal(store.accept({ ...projection(1), epoch: "other" }), "conflict");
|
||||
assert.equal(store.accept({ ...projection(1), epoch: "other" }, true), "accepted");
|
||||
const mutable = projection(10);
|
||||
assert.equal(store.accept(mutable, true), "accepted");
|
||||
mutable.sessions[0].state.running = false;
|
||||
assert.equal(store.getSnapshot()!.sessions[0].state.running, true, "caller mutation cannot change a committed revision");
|
||||
const malformed = projection(11);
|
||||
delete (malformed.sessions[0].state as Partial<RuntimeState>).cancellable;
|
||||
assert.equal(store.accept(malformed), "conflict", "partial new-schema booleans cannot imply idle or uncancellable");
|
||||
|
||||
function deferred<T>() { let resolve!: (value: T) => void; let reject!: (err: unknown) => void;
|
||||
const promise = new Promise<T>((yes, no) => { resolve = yes; reject = no; }); return { promise, resolve, reject }; }
|
||||
const synced = createRuntimeStateStore();
|
||||
let receive!: (snapshot: RuntimeProjection) => void, focus!: () => void;
|
||||
let pending = deferred<RuntimeProjection>();
|
||||
let reads = 0, subscribed = false, unsubscribed = false;
|
||||
const timers = new Map<number, { callback: () => void; delay: number }>();
|
||||
let timerID = 0;
|
||||
const stop = startRuntimeStateSync({
|
||||
subscribe: callback => { subscribed = true; receive = callback; return () => { unsubscribed = true; }; },
|
||||
read: () => { assert.equal(subscribed, true, "subscribe before initial GET"); reads++; return pending.promise; },
|
||||
timer: (callback, delay) => { const id = ++timerID; timers.set(id, { callback, delay }); return id; },
|
||||
clearTimer: id => { timers.delete(id as number); },
|
||||
focus: callback => { focus = callback; return () => {}; },
|
||||
}, synced);
|
||||
focus(); focus(); assert.equal(reads, 1, "focus shares in-flight GET");
|
||||
receive(projection(4));
|
||||
pending.resolve(projection(2));
|
||||
await pending.promise; await Promise.resolve();
|
||||
assert.equal(synced.getSnapshot()!.revision, 4, "old GET cannot overwrite SSE");
|
||||
assert.equal([...timers.values()][0].delay, 30000);
|
||||
for (const delay of [5000, 10000, 20000, 30000, 30000]) {
|
||||
pending = deferred<RuntimeProjection>();
|
||||
[...timers.values()][0].callback();
|
||||
pending.reject(new Error("offline"));
|
||||
await pending.promise.catch(() => {}); await Promise.resolve();
|
||||
assert.equal([...timers.values()][0].delay, delay);
|
||||
assert.equal(synced.getSnapshot()!.revision, 4, "failure preserves known state");
|
||||
}
|
||||
pending = deferred<RuntimeProjection>(); focus(); pending.resolve(projection(5));
|
||||
await pending.promise; await Promise.resolve();
|
||||
assert.equal([...timers.values()][0].delay, 30000);
|
||||
assert.equal(synced.getFailed(), false);
|
||||
const final = synced.getSnapshot();
|
||||
stop(); receive(projection(6)); focus();
|
||||
assert.equal(synced.getSnapshot(), final); assert.equal(unsubscribed, true); assert.equal(timers.size, 0);
|
||||
console.log("runtime state: immutable revisions, selectors, GET/SSE ordering, recovery, singleflight and disposal passed");
|
||||
@@ -0,0 +1,23 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { createProjectTreeRuntimeProjection, projectTreeApplyRuntimeTopics } from "../lib/projectTreeRuntime";
|
||||
import type { ProjectNode, ProjectRuntimeTopic } from "../lib/types";
|
||||
|
||||
const children: ProjectNode[] = ["first", "second"].map(id => ({ key: id, kind: "session", label: `Title ${id}`,
|
||||
root: "/repo", topicId: "topic", sessionPath: `/sessions/${id}`, preview: `Preview ${id}`, children: [] }));
|
||||
const topic: ProjectNode = { key: "topic", kind: "topic", label: "Saved title", root: "/repo", topicId: "topic", children };
|
||||
const tree: ProjectNode[] = [{ key: "project", kind: "project", label: "Project", root: "/repo", children: [topic] }];
|
||||
const runtime: ProjectRuntimeTopic[] = [{ scope: "project", workspaceRoot: "/repo", node: { ...topic, label: "Runtime title",
|
||||
children: children.map(child => ({ ...child, label: "Path fallback", preview: "", running: true, status: "thinking" })) } }];
|
||||
const next = projectTreeApplyRuntimeTopics(tree, runtime);
|
||||
assert.equal(next[0].children![0].label, "Saved title");
|
||||
for (const child of next[0].children![0].children!) {
|
||||
assert.equal(child.label, `Title ${child.key}`);
|
||||
assert.equal(child.preview, `Preview ${child.key}`);
|
||||
assert.equal(child.running, true);
|
||||
}
|
||||
assert.equal(projectTreeApplyRuntimeTopics(next, runtime), next, "identical runtime overlay preserves tree identity");
|
||||
const projection = createProjectTreeRuntimeProjection();
|
||||
projection.apply(tree, runtime);
|
||||
const pagedAway = projection.apply([{ ...tree[0], children: [] }], runtime);
|
||||
assert.equal(pagedAway[0].children![0].children![0].preview, "Preview first", "resident catalog metadata survives paging");
|
||||
console.log("PASS: memory-only runtime topics preserve catalog metadata and structural sharing");
|
||||
@@ -250,7 +250,7 @@ export type ComposerSurfaceInput = {
|
||||
submitDisabledReason?: string;
|
||||
};
|
||||
base: ComposerBase;
|
||||
tab: { readOnly?: boolean; floorInferred?: boolean } | undefined;
|
||||
tab: { readOnly?: boolean; floorInferred?: boolean; sessionPath?: string; remote?: { hostId: string; workspace: string } } | undefined;
|
||||
tabId: string | undefined;
|
||||
profile: ReturnType<typeof useComposerProfileProjection>;
|
||||
router: { handleSend: ComposerProps["onSend"]; handleSteer: ComposerProps["onSteer"] };
|
||||
@@ -321,6 +321,9 @@ export function buildComposerSurface(input: ComposerSurfaceInput): DecisionFoote
|
||||
suspendedByDecision: view.decisionActive,
|
||||
transientDismissSignal: input.transientDismissSignal,
|
||||
sessionKey: input.sessionKey,
|
||||
inboxSessionPath: input.tab?.sessionPath,
|
||||
inboxHostId: input.tab?.remote?.hostId,
|
||||
inboxWorkspace: input.tab?.remote?.workspace,
|
||||
workspaceScopeKey: input.workspaceScopeKey,
|
||||
fileRefRefreshKey: input.fileRefRefreshKey,
|
||||
guidanceConsumedKey: input.guidance?.key,
|
||||
|
||||
@@ -1,4 +1,6 @@
|
||||
import { recoveryStatusText, type RecoveryRetry } from "../lib/recoveryStatus";
|
||||
import { useRuntimeSession } from "../lib/useRuntimeState";
|
||||
import { pendingFollowups, confirmFollowup, followupNotSubmitted, followupSessionKey, type PendingFollowup } from "../lib/pendingFollowup";
|
||||
import { useAppNavigationStore } from "../store/appNavigation";
|
||||
import { lazy, Suspense, useCallback, useEffect, useLayoutEffect, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
||||
import type { CSSProperties, ClipboardEvent, DragEvent, KeyboardEvent, MouseEvent as ReactMouseEvent, PointerEvent as ReactPointerEvent } from "react";
|
||||
@@ -7,7 +9,7 @@ import { asArray } from "../lib/array";
|
||||
import { filterAtMatches } from "../lib/atMatches";
|
||||
import { DedupIndex, sha256 } from "../lib/attachDedup";
|
||||
import { app, onFilesDropped } from "../lib/bridge";
|
||||
import { enqueueInboxGuidanceForActiveTurn, steerInboxItemForActiveTurn } from "../lib/inboxSubmit";
|
||||
import { steerInboxItemForActiveTurn } from "../lib/inboxSubmit";
|
||||
import { formatInboxError, isInboxItemMissing } from "../lib/inboxError";
|
||||
import { inboxScopeKey } from "../lib/composerInboxQueue";
|
||||
import { useComposerInboxRefresh } from "../lib/useComposerInboxRefresh";
|
||||
@@ -600,6 +602,8 @@ export function Composer({
|
||||
transientDismissSignal,
|
||||
sessionKey,
|
||||
inboxSessionPath,
|
||||
inboxHostId,
|
||||
inboxWorkspace,
|
||||
workspaceScopeKey,
|
||||
fileRefRefreshKey,
|
||||
guidanceConsumedKey,
|
||||
@@ -706,6 +710,8 @@ export function Composer({
|
||||
transientDismissSignal?: number;
|
||||
sessionKey?: string;
|
||||
inboxSessionPath?: string;
|
||||
inboxHostId?: string;
|
||||
inboxWorkspace?: string;
|
||||
workspaceScopeKey?: string;
|
||||
fileRefRefreshKey?: number | string;
|
||||
guidanceConsumedKey?: string;
|
||||
@@ -733,6 +739,14 @@ export function Composer({
|
||||
const redoComboLabel = useShortcutComboLabel("composer.redo");
|
||||
const yoloComboLabel = useShortcutComboLabel("toolApproval.yolo");
|
||||
const draftKey = sessionKey || tabId || DEFAULT_COMPOSER_DRAFT_KEY;
|
||||
const runtimeState = useRuntimeSession(tabId, inboxSessionPath);
|
||||
const finishing = runtimeState.finishing;
|
||||
if (runtimeState.known) running = runtimeState.running ?? running;
|
||||
if (runtimeState.unknown) disabled = true;
|
||||
const pendingKey = followupSessionKey(inboxSessionPath, inboxHostId, inboxWorkspace);
|
||||
const pendingKeyRef = useRef(pendingKey);
|
||||
pendingKeyRef.current = pendingKey;
|
||||
const pendingFollowup = useSyncExternalStore(pendingFollowups.subscribe, () => pendingFollowups.get(pendingKey));
|
||||
const inboxSessionKey = inboxScopeKey(inboxSessionPath, workspaceScopeKey);
|
||||
const now = useTick(running);
|
||||
const [text, setText] = useState("");
|
||||
@@ -1221,7 +1235,7 @@ export function Composer({
|
||||
const applyInboxQueue = useCallback((items: PendingGuidance[]) => updatePendingGuidanceForDraft(draftKey, () => items), [draftKey]);
|
||||
const collapseInboxQueue = useCallback(() => setGuidanceExpanded(false), []);
|
||||
const refreshInboxQueue = useCallback(() => setGuidanceRetryNonce((value) => value + 1), []);
|
||||
useComposerInboxRefresh(tabId, draftKey, guidanceDraftKey, inboxSessionKey, guidanceQueuePreviewKey, guidanceRetryNonce, running, applyInboxQueue, collapseInboxQueue, refreshInboxQueue);
|
||||
useComposerInboxRefresh(tabId, draftKey, guidanceDraftKey, inboxSessionKey, guidanceQueuePreviewKey, guidanceRetryNonce, running, applyInboxQueue, collapseInboxQueue, refreshInboxQueue, runtimeState.state?.revision);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
@@ -1987,11 +2001,38 @@ export function Composer({
|
||||
showToast(text, "warn");
|
||||
}, [showToast, t]);
|
||||
|
||||
const followupDraftFingerprint = (key: string): string => {
|
||||
const draft = key === activeDraftKeyRef.current ? {
|
||||
text: textRef.current, invocations: invocationsRef.current, attachments: attachmentsRef.current,
|
||||
workspaceRefs: workspaceRefsRef.current, sessionRefs: sessionRefsRef.current,
|
||||
selectedTextRefs: selectedTextRefsRef.current, pastedBlocks: pastedBlocksRef.current,
|
||||
} : draftsBySessionRef.current[key] ?? emptyComposerDraft();
|
||||
return JSON.stringify([draft.text, draft.invocations, draft.attachments, draft.workspaceRefs,
|
||||
draft.sessionRefs, draft.selectedTextRefs, draft.pastedBlocks]);
|
||||
};
|
||||
|
||||
const submit = async () => {
|
||||
if (disabled || (!running && submitDisabled) || readOnly) return;
|
||||
const submitDraftKey = activeDraftKeyRef.current;
|
||||
const submitPendingKey = pendingKey;
|
||||
const submitTabId = tabId;
|
||||
const ownsDraft = () => activeDraftKeyRef.current !== submitDraftKey || pendingKeyRef.current === submitPendingKey;
|
||||
if (draftIsSubmitting(submitDraftKey)) return;
|
||||
const unresolved = pendingFollowups.get(submitPendingKey);
|
||||
if (unresolved) {
|
||||
updateSubmittingForDraft(submitDraftKey, true);
|
||||
try {
|
||||
await confirmFollowup(app, unresolved);
|
||||
if (ownsDraft() && pendingFollowups.get(submitPendingKey) === unresolved && followupDraftFingerprint(submitDraftKey) === unresolved.draft) clearSubmittedDraft(submitDraftKey);
|
||||
pendingFollowups.clear(submitPendingKey, unresolved);
|
||||
setGuidanceRetryNonce(value => value + 1);
|
||||
} catch {
|
||||
showToast(t("runtime.unconfirmed"), "warn");
|
||||
} finally {
|
||||
updateSubmittingForDraft(submitDraftKey, false);
|
||||
}
|
||||
return;
|
||||
}
|
||||
if (disabled || (!running && submitDisabled) || readOnly) return;
|
||||
const currentText = textRef.current;
|
||||
const rawDraft = trimInvocationDraft(currentText, invocationsRef.current);
|
||||
const typedGoalDraft = goalModeOn && !activeGoal && rawDraft.invocations.length === 0
|
||||
@@ -2027,7 +2068,14 @@ export function Composer({
|
||||
}
|
||||
setComposerPrompt(null);
|
||||
updateSubmittingForDraft(submitDraftKey, true);
|
||||
const submittedDraft = followupDraftFingerprint(submitDraftKey);
|
||||
const currentSessionRefs = sessionRefsRef.current;
|
||||
const currentSelectedTextRefs = selectedTextRefsRef.current;
|
||||
const currentPastedBlocks = [...pastedBlocksRef.current];
|
||||
try {
|
||||
if (finishing && !submitPendingKey) throw new Error("reasonix_error:inbox_not_submitted");
|
||||
const target = finishing && app.CaptureInboxTarget
|
||||
? await app.CaptureInboxTarget(submitTabId || "", inboxSessionPath || "") : undefined;
|
||||
const orderedAttachments = sortComposerAttachments(currentAttachments);
|
||||
const refs = [
|
||||
...currentWorkspaceRefs.map((ref) => formatWorkspaceReference(ref.path, ref.isDir)),
|
||||
@@ -2036,16 +2084,13 @@ export function Composer({
|
||||
const displayRefs = [
|
||||
...currentWorkspaceRefs.map((ref) => formatWorkspaceReference(ref.displayPath || ref.path, ref.isDir)),
|
||||
...orderedAttachments.map(formatAttachmentDisplayReference),
|
||||
...selectedTextRefsRef.current.map(formatSelectionLabel),
|
||||
...currentSelectedTextRefs.map(formatSelectionLabel),
|
||||
].join(" ");
|
||||
const displayText = [trimmedText, displayRefs].filter(Boolean).join(trimmedText && displayRefs ? " " : "");
|
||||
// PR-B: when past:chats refs are attached, prepend their formatted transcript
|
||||
// to submitText only (displayText stays unchanged so the user still sees their
|
||||
// original prompt in the input preview). With no refs we keep the original
|
||||
// submitText verbatim — no header, no rewording, byte-identical to pre-PR-B.
|
||||
const currentSessionRefs = sessionRefsRef.current;
|
||||
const currentSelectedTextRefs = selectedTextRefsRef.current;
|
||||
const currentPastedBlocks = [...pastedBlocksRef.current];
|
||||
const sessionContext = currentSessionRefs.length === 0 ? "" : await buildSessionContext(currentSessionRefs, t);
|
||||
const selectedTextContext = formatSelectedTextContext(currentSelectedTextRefs);
|
||||
const invocationText = serializeInvocationSubmit(trimmedText, trimmedDraft.invocations);
|
||||
@@ -2066,7 +2111,7 @@ export function Composer({
|
||||
const guidanceText = displayText.trim() || (structured?.display.trim() ?? "");
|
||||
const guidanceSubmitText = submitText.trim();
|
||||
if (guidanceText) {
|
||||
if (!localDurableGuidance && onSteer) {
|
||||
if (!finishing && !localDurableGuidance && onSteer) {
|
||||
try {
|
||||
await onSteer(guidanceSubmitText, submitTabId);
|
||||
clearSubmittedDraft(submitDraftKey);
|
||||
@@ -2078,11 +2123,24 @@ export function Composer({
|
||||
// Durable follow-up: only clear the composer after a durable receipt.
|
||||
const receiptTracker = guidanceReceiptTrackerRef.current;
|
||||
receiptTracker?.start(submitDraftKey);
|
||||
let unresolvedRequest: PendingFollowup | undefined;
|
||||
try {
|
||||
const receipt = await enqueueInboxGuidanceForActiveTurn(app, submitTabId || "", guidanceText, guidanceSubmitText, structured, turnId);
|
||||
const { enqueueInboxGuidance, enqueueInboxGuidanceForActiveTurn } = await import("../lib/inboxGuidanceSubmit");
|
||||
const request: PendingFollowup = { key: `followup-${crypto.randomUUID()}`, target,
|
||||
tabId: submitTabId || "", display: guidanceText, submit: guidanceSubmitText, structured, draft: submittedDraft };
|
||||
if (finishing) {
|
||||
unresolvedRequest = request;
|
||||
pendingFollowups.set(submitPendingKey, request);
|
||||
}
|
||||
const receipt = finishing
|
||||
? target && app.EnqueueInboxFollowupForTarget
|
||||
? await app.EnqueueInboxFollowupForTarget(target, guidanceText, guidanceSubmitText, structured?.invocations ?? [], request.key)
|
||||
: await enqueueInboxGuidance(app, submitTabId || "", guidanceText, guidanceSubmitText, structured, { idempotency: request.key })
|
||||
: await enqueueInboxGuidanceForActiveTurn(app, submitTabId || "", guidanceText, guidanceSubmitText, structured, turnId);
|
||||
if (receipt?.error) throw new Error(receipt.error);
|
||||
if (!receipt?.itemId) throw new Error("Follow-up receipt unconfirmed");
|
||||
const consumedBeforeReceipt = receiptTracker?.takeConsumed(submitDraftKey, receipt.itemId) ?? false;
|
||||
if (!consumedBeforeReceipt) {
|
||||
if (!consumedBeforeReceipt && !finishing) {
|
||||
updatePendingGuidanceForDraft(submitDraftKey, (items) => {
|
||||
const next = items.map((item) => receipt.paused ? { ...item, paused: true } : item);
|
||||
if (next.some((item) => item.id === receipt.itemId)) return next;
|
||||
@@ -2098,8 +2156,14 @@ export function Composer({
|
||||
}];
|
||||
});
|
||||
}
|
||||
clearSubmittedDraft(submitDraftKey);
|
||||
if (ownsDraft() && (!finishing || pendingFollowups.get(submitPendingKey) === request) && followupDraftFingerprint(submitDraftKey) === submittedDraft) clearSubmittedDraft(submitDraftKey);
|
||||
if (finishing) {
|
||||
pendingFollowups.clear(submitPendingKey, request);
|
||||
setGuidanceRetryNonce(value => value + 1);
|
||||
}
|
||||
if (finishing) showToast(t("runtime.queued"), "info");
|
||||
} catch (error) {
|
||||
if (unresolvedRequest && followupNotSubmitted(error)) pendingFollowups.clear(submitPendingKey, unresolvedRequest);
|
||||
showToast(formatInboxError(error, locale), "warn");
|
||||
// Keep draft on durable failure.
|
||||
} finally {
|
||||
@@ -2742,6 +2806,7 @@ export function Composer({
|
||||
// handleCancel stops the in-flight turn; if it was cancelled before the server
|
||||
// replied, the just-sent text is handed back so we drop it back into the input.
|
||||
const handleCancel = async () => {
|
||||
if (finishing || runtimeState.unknown || runtimeState.cancellable === false) return;
|
||||
const targetDraftKey = activeDraftKeyRef.current;
|
||||
if (cancelSettlingDraftsRef.current.has(targetDraftKey)) return;
|
||||
cancelSettlingDraftsRef.current.add(targetDraftKey);
|
||||
@@ -3598,7 +3663,7 @@ export function Composer({
|
||||
: pendingAsk
|
||||
? "ask"
|
||||
: null;
|
||||
const showRunStrip = Boolean(retry || waitingPrompt);
|
||||
const showRunStrip = Boolean(retry || waitingPrompt || finishing || runtimeState.unknown || runtimeState.kind === "background_job" || runtimeState.kind === "cancelling");
|
||||
const effectiveComposerHeight = composerHeight === null
|
||||
? null
|
||||
: resolveComposerContentSizing({
|
||||
@@ -3741,7 +3806,7 @@ export function Composer({
|
||||
return t("composer.runAnnounceRunning");
|
||||
}
|
||||
})();
|
||||
const runStateText = retry
|
||||
const runStateText = runtimeState.unknown ? t("runtime.unknown") : finishing ? t("runtime.finishing") : runtimeState.kind === "cancelling" ? t("status.jobStopping") : runtimeState.kind === "background_job" ? t("runtime.background", { count: runtimeState.state?.backgroundJobs ?? 0 }) : retry
|
||||
? recoveryStatusText(t, retry, now)
|
||||
: waitingPrompt === "approval"
|
||||
? t("composer.runWaitingApproval", { tool: pendingApprovalLabel ?? "" })
|
||||
@@ -3770,9 +3835,9 @@ export function Composer({
|
||||
: null;
|
||||
const submitEmpty = !text.trim() && attachments.length === 0 && workspaceRefs.length === 0 &&
|
||||
!invocations.some((invocation) => invocation.command.kind === "skill");
|
||||
const submitBlocked = submitting || pendingPaste > 0 || (submitEmpty && !(goalModeOn && !activeGoal)) || disabled || (!running && submitDisabled) || readOnly;
|
||||
const submitBlocked = submitting || (!pendingFollowup && (pendingPaste > 0 || (submitEmpty && !(goalModeOn && !activeGoal)) || disabled || (!running && submitDisabled) || readOnly));
|
||||
const submitUnavailableHint = !running && submitDisabled ? submitDisabledReason : undefined;
|
||||
const submitTooltip = running
|
||||
const submitTooltip = pendingFollowup ? t("runtime.checkReceipt") : running
|
||||
? t("composer.queueGuidance", { combo: sendComboLabel })
|
||||
: t("composer.send", { combo: sendComboLabel });
|
||||
const composerPlaceholder = readOnly
|
||||
@@ -4314,8 +4379,12 @@ export function Composer({
|
||||
</div>
|
||||
)}
|
||||
{retry?.recovery?.waiting && <Suspense fallback={null}><RecoveryWaitBanner retry={retry} now={now} onStop={() => void handleCancel()} stopDisabled={cancelSettlingDraftsRef.current.has(draftKey)} /></Suspense>}
|
||||
{pendingFollowup && <div className="composer-guidance-item" role="status">
|
||||
<span className="composer-guidance-item__text" title={pendingFollowup.display}>{pendingFollowup.display}</span>
|
||||
<span>{t("runtime.unconfirmed")}</span>
|
||||
</div>}
|
||||
<div
|
||||
className={`composer-card${composerHeight !== null || composerResizing ? " composer-card--resized" : ""}${composerAutoExpanded ? " composer-card--autosized" : ""}${composerAutoOverflow ? " composer-card--auto-overflow" : ""}${composerResizing ? " composer-card--resizing" : ""}${running ? (waitingPrompt ? " composer-card--waiting" : " composer-card--running") : ""}`}
|
||||
className={`composer-card${composerHeight !== null || composerResizing ? " composer-card--resized" : ""}${composerAutoExpanded ? " composer-card--autosized" : ""}${composerAutoOverflow ? " composer-card--auto-overflow" : ""}${composerResizing ? " composer-card--resizing" : ""}${running && !finishing && !runtimeState.unknown ? (waitingPrompt ? " composer-card--waiting" : " composer-card--running") : ""}`}
|
||||
ref={composerCardRef}
|
||||
style={composerCardStyle}
|
||||
>
|
||||
@@ -4338,7 +4407,7 @@ export function Composer({
|
||||
/>
|
||||
{showRunStrip && runStateText && (
|
||||
<div className={`composer-run-strip${waitingPrompt ? " composer-run-strip--waiting" : ""}`}>
|
||||
<span className="composer-run-strip__dot" aria-hidden="true" />
|
||||
{!finishing && !runtimeState.unknown && <span className="composer-run-strip__dot" aria-hidden="true" />}
|
||||
<span className="composer-run-strip__text">{runStateText}</span>
|
||||
</div>
|
||||
)}
|
||||
@@ -4568,13 +4637,13 @@ export function Composer({
|
||||
</div>}
|
||||
</div>
|
||||
<div className={`composer-toolbar-send${submitUnavailableHint ? " composer-toolbar-send--unavailable" : ""}`}>
|
||||
{running && (
|
||||
{running && !finishing && !runtimeState.unknown && (
|
||||
<Tooltip label={t("composer.stop")}>
|
||||
<button
|
||||
className="composer__btn composer__btn--stop"
|
||||
type="button"
|
||||
onClick={() => void handleCancel()}
|
||||
disabled={cancelSettlingDraftsRef.current.has(draftKey)}
|
||||
disabled={runtimeState.cancellable === false || cancelSettlingDraftsRef.current.has(draftKey)}
|
||||
aria-label={t("composer.stop")}
|
||||
>
|
||||
<Square size={12} fill="currentColor" />
|
||||
@@ -4588,7 +4657,7 @@ export function Composer({
|
||||
disabled={submitBlocked}
|
||||
aria-label={submitTooltip}
|
||||
>
|
||||
{running ? <CornerDownRight size={16} /> : <ArrowUp size={16} />}
|
||||
{pendingFollowup ? <Search size={16} /> : running ? <CornerDownRight size={16} /> : <ArrowUp size={16} />}
|
||||
</button>
|
||||
</Tooltip>
|
||||
{submitUnavailableHint && <span className="composer-toolbar-send__hint">{submitUnavailableHint}</span>}
|
||||
|
||||
@@ -27,7 +27,7 @@ import { summarizeProjectTreeSessions } from "../lib/projectTreeDiagnostics";
|
||||
import { GLOBAL_PROJECT_ORDER_KEY, ProjectTreeFolderActivity, ProjectTreeGroupRows, applyProjectOrder, projectTreeProjectRoots, reorderedProjectRoots, useProjectTreeOrganization, type ProjectDropPosition } from "./ProjectTreeOrganization";
|
||||
import { ProjectTreeSessionArchiveMenu } from "./ProjectTreeSessionArchiveMenu";
|
||||
import { ProjectTreeHeaderAddControl, ProjectTreeRemoteAction, projectTreeHeaderAddItems } from "./ProjectTreeAddControls";
|
||||
import { activeRemoteProjectAncestorKeys, buildRemoteProjectMenuItems, mergeRemoteSessionsIntoTree, openRemoteSessionNode, remoteProjectKey, remoteServeBadgeState, renameRemoteProjectTitle, RemoteProjectEmptyState, useRemoteProjectGroups, useRemoteSessionActions } from "./ProjectTreeRemoteGroups";
|
||||
import { activeRemoteProjectAncestorKeys, buildRemoteProjectMenuItems, useRemoteRuntimeTree, openRemoteSessionNode, remoteProjectKey, remoteServeBadgeState, renameRemoteProjectTitle, RemoteProjectEmptyState, useRemoteProjectGroups, useRemoteSessionActions } from "./ProjectTreeRemoteGroups";
|
||||
import type { ProjectTreeProps } from "./ProjectTreeProps";
|
||||
|
||||
function projectNodeKey(node: ProjectNode, depth: number): string {
|
||||
@@ -442,7 +442,7 @@ export function ProjectTree({
|
||||
}, [applyRuntimeProjection]);
|
||||
refreshRef.current = refresh;
|
||||
const { openRemoteProject, openRemoteWindow, remoteSessions, setRemoteSessions, remoteServers, remoteGroupBusy, remoteGroupError, ensureRemoteGroupSessions, refreshRemoteSessions } = useRemoteProjectGroups(tree, showToast, expanded, query);
|
||||
const treeWithRemoteSessions = useMemo(() => mergeRemoteSessionsIntoTree(tree, remoteSessions, t), [remoteSessions, t, tree]);
|
||||
const treeWithRemoteSessions = useRemoteRuntimeTree(tree, remoteSessions, t);
|
||||
const remoteSessionActions = useRemoteSessionActions(remoteSessions, refreshRemoteSessions, (error) => showToast(error instanceof Error ? error.message : String(error), "error"));
|
||||
const { addingProject, handleAddProject, openBlankProjectFlow, blankProjectFlow, openRemoteConnectFlow, remoteConnectFlow } = useProjectCreation({
|
||||
onAddProject,
|
||||
@@ -1121,7 +1121,7 @@ export function ProjectTree({
|
||||
// spinning orange dot, and that pill replaces the relative time so the
|
||||
// paused-for-user state is scannable in the background tab list.
|
||||
const showStatusInSide = status === "thinking" || status === "streaming" || status === "waiting_confirmation" || status === "background_job";
|
||||
const showWaitingPill = waitingConfirmation;
|
||||
const showWaitingPill = waitingConfirmation || status === "finishing" || status === "cancelling" || status === "unknown";
|
||||
const showSideTime = sideTimeVisible && !showWaitingPill;
|
||||
const unread = projectTreeTopicHasUnreadActivity(node, readActivity, activeScope, activeWorkspaceRoot, activeTopicId, activeSessionPath, readBaselineAt);
|
||||
const topicId = node.topicId ?? "";
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
import { useSyncExternalStore } from "react";
|
||||
import { runtimeStateStore, selectRuntime } from "../lib/runtimeStateStore";
|
||||
import { useT } from "../lib/i18n";
|
||||
import { asArray } from "../lib/array";
|
||||
import type { ProjectNode } from "../lib/types";
|
||||
import { projectTreeFolderHasActiveRuntime } from "./ProjectTreeOrganization";
|
||||
|
||||
export default function ProjectTreeFolderActivity({ folder }: { folder: ProjectNode }) {
|
||||
const snapshot = useSyncExternalStore(runtimeStateStore.subscribe, runtimeStateStore.getSnapshot);
|
||||
const failed = useSyncExternalStore(runtimeStateStore.subscribe, runtimeStateStore.getFailed);
|
||||
const t = useT();
|
||||
const matched = snapshot?.sessions.filter(session => folder.remote
|
||||
? session.remote && session.hostId === folder.remote.hostId && session.workspaceRoot === folder.remote.workspace
|
||||
: session.scope === (folder.kind === "global_folder" ? "global" : "project") && (session.scope === "global" || session.workspaceRoot === folder.root));
|
||||
const sessions = matched && [...new Map(matched.map(session => [`${session.hostId ?? "local"}\0${session.workspaceRoot}\0${session.sessionPath}`, session])).values()];
|
||||
if (sessions) {
|
||||
const active = sessions.filter(session => {
|
||||
const view = selectRuntime(session, failed);
|
||||
return view.unknown || (view.known && (view.kind !== "idle" || session.state.backgroundJobs > 0));
|
||||
});
|
||||
if (!active.length) return null;
|
||||
const details = active.map(session => {
|
||||
const state = session.state;
|
||||
const topic = asArray(folder.children).find(node => node.topicId === session.topicId || (session.sessionPath && node.sessionPath === session.sessionPath));
|
||||
const labels: string[] = [];
|
||||
if (failed || session.freshness !== "synced") labels.push(t("runtime.unknown"));
|
||||
if (state.phase === "finishing") labels.push(t("runtime.finishing"));
|
||||
if (state.cancelRequested) labels.push(t("status.jobStopping"));
|
||||
if (state.pendingPrompt) labels.push(t("projectTree.status.waitingConfirmation"));
|
||||
if (state.phase === "executing") labels.push(t(state.activity === "streaming" ? "projectTree.status.streaming" : "projectTree.status.thinking"));
|
||||
if (state.backgroundJobs) labels.push(t("runtime.background", { count: state.backgroundJobs }));
|
||||
return `${topic?.label || session.tabId}: ${labels.join(" · ")}`;
|
||||
}).join("; ");
|
||||
const spinning = active.some(session => selectRuntime(session, failed).spinning);
|
||||
return <span className={`project-tree__folder-active-indicator${spinning ? "" : " project-tree__folder-active-indicator--static"}`} style={spinning ? undefined : { animation: "none" }} role="status" aria-label={details} title={details} />;
|
||||
}
|
||||
if (!projectTreeFolderHasActiveRuntime(folder)) return null;
|
||||
return <span className="project-tree__folder-active-indicator" role="status" aria-label={t("projectTree.status.thinking")} />;
|
||||
}
|
||||
@@ -1,4 +1,4 @@
|
||||
import { useCallback, useEffect, useRef, useState, type DragEvent, type HTMLAttributes, type ReactNode } from "react";
|
||||
import { useCallback, useEffect, useRef, useState, lazy, Suspense, type DragEvent, type HTMLAttributes, type ReactNode } from "react";
|
||||
import { Archive, FolderMinus, Pencil } from "lucide-react";
|
||||
import { app } from "../lib/bridge";
|
||||
import { asArray } from "../lib/array";
|
||||
@@ -419,7 +419,7 @@ export function projectTreeFolderHasActiveRuntime(folder: ProjectNode): boolean
|
||||
return asArray(folder.children).some(projectTreeTopicArchiveBlocked);
|
||||
}
|
||||
|
||||
const FolderActivity = lazy(() => import("./ProjectTreeFolderActivity"));
|
||||
export function ProjectTreeFolderActivity({ folder }: { folder: ProjectNode }) {
|
||||
if (!projectTreeFolderHasActiveRuntime(folder)) return null;
|
||||
return <span className="project-tree__folder-active-indicator" aria-hidden="true" />;
|
||||
return <Suspense fallback={null}><FolderActivity folder={folder} /></Suspense>;
|
||||
}
|
||||
@@ -1,7 +1,8 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, type Dispatch, type SetStateAction } from "react";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState, useSyncExternalStore, type Dispatch, type SetStateAction } from "react";
|
||||
import { Plus, Server, Square, XCircle } from "lucide-react";
|
||||
|
||||
import { app, onRemoteTabOpened, onRemoteTabUpdated } from "../lib/bridge";
|
||||
import { runtimeStateStore, selectRuntime, type RuntimeProjection } from "../lib/runtimeStateStore";
|
||||
import type { Translator } from "../lib/i18n";
|
||||
import type { ProjectNode, RemoteServerView, RemoteSessionView, RemoteTabRefView } from "../lib/types";
|
||||
import type { ToastContextValue } from "../lib/toast";
|
||||
@@ -15,6 +16,12 @@ export function remoteProjectKey(ref: RemoteTabRefView): string {
|
||||
return `${ref.hostId}\u0000${ref.workspace}`;
|
||||
}
|
||||
|
||||
export function useRemoteRuntimeTree(tree: ProjectNode[], sessions: Record<string, RemoteSessionView[]>, t: Translator) {
|
||||
const runtime = useSyncExternalStore(runtimeStateStore.subscribe, runtimeStateStore.getSnapshot);
|
||||
const failed = useSyncExternalStore(runtimeStateStore.subscribe, runtimeStateStore.getFailed);
|
||||
return useMemo(() => mergeRemoteSessionsIntoTree(tree, sessions, t, runtime, failed), [tree, sessions, t, runtime, failed]);
|
||||
}
|
||||
|
||||
export function activeRemoteProjectAncestorKeys(
|
||||
nodes: ProjectNode[],
|
||||
activeRemote: RemoteTabRefView,
|
||||
@@ -36,11 +43,17 @@ export function mergeRemoteSessionsIntoTree(
|
||||
tree: ProjectNode[],
|
||||
sessions: Record<string, RemoteSessionView[]>,
|
||||
t: Translator,
|
||||
runtime?: RuntimeProjection,
|
||||
failed = false,
|
||||
): ProjectNode[] {
|
||||
return tree.map((node) => {
|
||||
if (!node.remote) return node;
|
||||
const rows = sessions[remoteProjectKey(node.remote)] ?? [];
|
||||
const remoteChildren = rows.map((row): ProjectNode => ({
|
||||
const remoteChildren = rows.map((row): ProjectNode => {
|
||||
const session = runtime?.sessions.find(session => session.hostId === node.remote!.hostId && session.workspaceRoot === node.remote!.workspace && session.sessionPath === row.path);
|
||||
const state = selectRuntime(session, failed);
|
||||
const status = state.unknown ? "unknown" : state.known && state.kind !== "idle" && state.kind !== "legacy" ? state.kind : undefined;
|
||||
return ({
|
||||
key: `remote-session-${node.remote!.hostId}-${node.remote!.workspace}-${row.name}`,
|
||||
kind: "topic",
|
||||
label: row.title || row.name || t("projectTree.newTopic"),
|
||||
@@ -48,12 +61,13 @@ export function mergeRemoteSessionsIntoTree(
|
||||
topicId: `${node.remote!.hostId}\u0000${node.remote!.workspace}\u0000${row.name}`,
|
||||
sessionPath: row.path,
|
||||
turns: row.turns,
|
||||
running: row.running,
|
||||
running: state.known ? state.unknown ? false : Boolean(state.running || session!.state.pendingPrompt || session!.state.backgroundJobs) : row.running,
|
||||
status: status as ProjectNode["status"],
|
||||
lastActivityAt: row.lastActivityAt,
|
||||
pinned: row.pinned,
|
||||
remoteSession: { hostId: node.remote!.hostId, workspace: node.remote!.workspace, name: row.name, path: row.path, title: row.title },
|
||||
children: [],
|
||||
}));
|
||||
}); });
|
||||
return { ...node, children: [...remoteChildren, ...(node.children ?? [])] };
|
||||
});
|
||||
}
|
||||
@@ -227,6 +241,7 @@ export function useRemoteProjectGroups(
|
||||
|
||||
useEffect(() => onRemoteTabUpdated((meta) => {
|
||||
if (!meta.remote) return;
|
||||
if (runtimeStateStore.getSnapshot()?.sessions.some(session => session.tabId === meta.id && session.state.schemaVersion === 1)) return;
|
||||
const key = remoteProjectKey(meta.remote);
|
||||
if (!groupKeys.includes(key) || !eligibleSessionKeys.current.has(key)) return;
|
||||
const load = ++nextLoad.current;
|
||||
|
||||
@@ -3,6 +3,7 @@ import { mockProviderTemplate, mockPreset, mockBundlePreset, mockKimiAPIModels,
|
||||
// @ts-ignore generated locally; fresh checkouts use the disabled drift check below.
|
||||
import type * as GeneratedApp from "../../wailsjs/go/main/App";
|
||||
import type { InvocationRequest } from "./invocationDisplay";
|
||||
import type { FollowupBindings } from "./pendingFollowup";
|
||||
import { addBreadcrumb } from "./breadcrumbs";
|
||||
import { maybeShare } from "./queryCoalesce";
|
||||
import { makeMockSessionCatalogBindings } from "./sessionCatalogBridge";
|
||||
@@ -198,7 +199,7 @@ interface DesktopWindowState {
|
||||
}
|
||||
// AppBindings is the hand-written React-to-Go contract. _CheckGeneratedBindings
|
||||
// catches generated methods missing here; update this interface and typecheck.
|
||||
export interface AppBindings extends SessionCatalogBindings, ProjectTreeOrganizationBindings, HistoryCatalogBindings, TaskCatalogBindings, BlankProjectBindings, QualityFloorBindings, SessionTitleBindings, ScrollDiagnosticBindings, RemoteProjectBindings, MCPAppBindings, PinnedContextBindings {
|
||||
export interface AppBindings extends SessionCatalogBindings, ProjectTreeOrganizationBindings, HistoryCatalogBindings, TaskCatalogBindings, BlankProjectBindings, QualityFloorBindings, SessionTitleBindings, ScrollDiagnosticBindings, RemoteProjectBindings, MCPAppBindings, PinnedContextBindings, FollowupBindings {
|
||||
Platform(): Promise<string>;
|
||||
MinimiseMainWindow(): Promise<void>;
|
||||
ToggleMaximiseMainWindow(): Promise<void>;
|
||||
@@ -259,8 +260,6 @@ export interface AppBindings extends SessionCatalogBindings, ProjectTreeOrganiza
|
||||
maxItems: number;
|
||||
maxBytes: number;
|
||||
}>;
|
||||
EnqueueInboxFollowup(tabID: string, display: string, submit: string, idempotency: string): Promise<{ itemId: string; disposition: string; position: number; paused: boolean; idempotent?: boolean; error?: string }>;
|
||||
EnqueueInboxFollowupWithInvocations(tabID: string, display: string, submit: string, invocations: InvocationRequest[], idempotency: string): Promise<{ itemId: string; disposition: string; position: number; paused: boolean; idempotent?: boolean; error?: string }>;
|
||||
EnqueueInboxSteer(tabID: string, display: string, submit: string, idempotency: string): Promise<{ itemId: string; disposition: string; position: number; paused: boolean; idempotent?: boolean; error?: string }>;
|
||||
EnqueueInboxSteerForTurn?(tabID: string, turnID: string, display: string, submit: string, idempotency: string): Promise<{ itemId: string; disposition: string; position: number; paused: boolean; idempotent?: boolean; error?: string }>;
|
||||
SteerInboxItem(tabID: string, itemID: string): Promise<{
|
||||
|
||||
@@ -18,6 +18,7 @@ const CODE_INDEX = {
|
||||
channel_read_only: 10,
|
||||
workspace_starting: 11,
|
||||
workspace_start_failed: 12,
|
||||
inbox_not_submitted: 16,
|
||||
} as const;
|
||||
|
||||
type InboxErrorCode = keyof typeof CODE_INDEX;
|
||||
@@ -43,6 +44,7 @@ const ERROR_COPY: Record<Locale, readonly string[]> = {
|
||||
"The inbox operation could not be completed",
|
||||
"The turn ended before guidance could be applied. It will remain queued for the next turn",
|
||||
"Cancel failed: {error}",
|
||||
"The message was not sent. Refresh the session and try again",
|
||||
],
|
||||
zh: [
|
||||
"收件箱已暂停",
|
||||
@@ -61,6 +63,7 @@ const ERROR_COPY: Record<Locale, readonly string[]> = {
|
||||
"无法完成收件箱操作",
|
||||
"引导尚未应用时当前回合已结束;它会保留在队列中,供下一回合处理",
|
||||
"取消失败:{error}",
|
||||
"消息未发送,请刷新会话后重试",
|
||||
],
|
||||
"zh-TW": [
|
||||
"收件匣已暫停",
|
||||
@@ -79,6 +82,7 @@ const ERROR_COPY: Record<Locale, readonly string[]> = {
|
||||
"無法完成收件匣操作",
|
||||
"引導尚未套用時目前回合已結束;它會保留在佇列中,供下一回合處理",
|
||||
"取消失敗:{error}",
|
||||
"訊息未傳送,請重新整理會話後重試",
|
||||
],
|
||||
};
|
||||
|
||||
|
||||
@@ -0,0 +1,46 @@
|
||||
import type { AppBindings } from "./bridge";
|
||||
import type { StructuredInvocationSubmit } from "./invocationDisplay";
|
||||
import { resolveActiveTurnId } from "./inboxSubmit";
|
||||
|
||||
type InboxEnqueueBindings = Pick<AppBindings, "EnqueueInboxFollowup" | "EnqueueInboxFollowupWithInvocations" | "EnqueueInboxSteer" | "EnqueueInboxSteerForTurn">;
|
||||
|
||||
export async function enqueueInboxGuidanceForActiveTurn(
|
||||
binding: InboxEnqueueBindings & Pick<AppBindings, "ListTabs">,
|
||||
tabId: string,
|
||||
display: string,
|
||||
submit: string,
|
||||
structured?: StructuredInvocationSubmit,
|
||||
knownTurnId?: string,
|
||||
) {
|
||||
const turnId = !structured && typeof binding.EnqueueInboxSteerForTurn === "function"
|
||||
? await resolveActiveTurnId(binding, tabId, knownTurnId)
|
||||
: knownTurnId;
|
||||
return enqueueInboxGuidance(binding, tabId, display, submit, structured, { steer: true, turnId });
|
||||
}
|
||||
|
||||
export function enqueueInboxGuidance(
|
||||
binding: InboxEnqueueBindings,
|
||||
tabId: string,
|
||||
display: string,
|
||||
submit: string,
|
||||
structured?: StructuredInvocationSubmit,
|
||||
opts?: { steer?: boolean; turnId?: string; idempotency?: string },
|
||||
) {
|
||||
if (structured) {
|
||||
return binding.EnqueueInboxFollowupWithInvocations(
|
||||
tabId,
|
||||
structured.display.trim() || display,
|
||||
structured.input.trim(),
|
||||
structured.invocations,
|
||||
opts?.idempotency ?? "",
|
||||
);
|
||||
}
|
||||
if (opts?.steer && typeof binding.EnqueueInboxSteer === "function") {
|
||||
if (typeof binding.EnqueueInboxSteerForTurn === "function") {
|
||||
if (!opts.turnId) return Promise.reject(new Error("active turn id is unavailable; refresh and try again"));
|
||||
return binding.EnqueueInboxSteerForTurn(tabId, opts.turnId, display, submit || display, "");
|
||||
}
|
||||
return binding.EnqueueInboxSteer(tabId, display, submit || display, "");
|
||||
}
|
||||
return binding.EnqueueInboxFollowup(tabId, display, submit || display, opts?.idempotency ?? "");
|
||||
}
|
||||
@@ -1,9 +1,7 @@
|
||||
import type { AppBindings } from "./bridge";
|
||||
import type { StructuredInvocationSubmit } from "./invocationDisplay";
|
||||
import { asArray } from "./array";
|
||||
import type { QuestionAnswer } from "./types";
|
||||
|
||||
type InboxEnqueueBindings = Pick<AppBindings, "EnqueueInboxFollowup" | "EnqueueInboxFollowupWithInvocations" | "EnqueueInboxSteer" | "EnqueueInboxSteerForTurn">;
|
||||
type ActiveTurnBindings = Pick<AppBindings, "ListTabs" | "SteerInboxItem" | "SteerInboxItemForTurn">;
|
||||
|
||||
export async function resolveActiveTurnId(binding: Pick<AppBindings, "ListTabs">, tabId: string, _known?: string): Promise<string | undefined> {
|
||||
@@ -55,44 +53,3 @@ export async function steerInboxItemForActiveTurn(binding: ActiveTurnBindings, t
|
||||
if (!turnId) throw new Error("active turn id is unavailable; refresh and try again");
|
||||
return binding.SteerInboxItemForTurn(tabId, turnId, itemId);
|
||||
}
|
||||
|
||||
export async function enqueueInboxGuidanceForActiveTurn(
|
||||
binding: InboxEnqueueBindings & Pick<AppBindings, "ListTabs">,
|
||||
tabId: string,
|
||||
display: string,
|
||||
submit: string,
|
||||
structured?: StructuredInvocationSubmit,
|
||||
knownTurnId?: string,
|
||||
) {
|
||||
const turnId = !structured && typeof binding.EnqueueInboxSteerForTurn === "function"
|
||||
? await resolveActiveTurnId(binding, tabId, knownTurnId)
|
||||
: knownTurnId;
|
||||
return enqueueInboxGuidance(binding, tabId, display, submit, structured, { steer: true, turnId });
|
||||
}
|
||||
|
||||
export function enqueueInboxGuidance(
|
||||
binding: InboxEnqueueBindings,
|
||||
tabId: string,
|
||||
display: string,
|
||||
submit: string,
|
||||
structured?: StructuredInvocationSubmit,
|
||||
opts?: { steer?: boolean; turnId?: string },
|
||||
) {
|
||||
if (structured) {
|
||||
return binding.EnqueueInboxFollowupWithInvocations(
|
||||
tabId,
|
||||
structured.display.trim() || display,
|
||||
structured.input.trim(),
|
||||
structured.invocations,
|
||||
"",
|
||||
);
|
||||
}
|
||||
if (opts?.steer && typeof binding.EnqueueInboxSteer === "function") {
|
||||
if (typeof binding.EnqueueInboxSteerForTurn === "function") {
|
||||
if (!opts.turnId) return Promise.reject(new Error("active turn id is unavailable; refresh and try again"));
|
||||
return binding.EnqueueInboxSteerForTurn(tabId, opts.turnId, display, submit || display, "");
|
||||
}
|
||||
return binding.EnqueueInboxSteer(tabId, display, submit || display, "");
|
||||
}
|
||||
return binding.EnqueueInboxFollowup(tabId, display, submit || display, "");
|
||||
}
|
||||
@@ -27,6 +27,7 @@ export function createMockRemoteProjects(tabs: MockRemoteTabCatalog): {
|
||||
const key = (hostId: string, workspace: string) => `${hostId}\u0000${workspace}`;
|
||||
const tabIdFor = (hostId: string, workspace: string) => `remote-mock-${hostId}-${workspace}`.replace(/[^a-z0-9-]/gi, "_");
|
||||
const status = (tabId: string) => ({
|
||||
sessionPath: tabs.get(tabId)?.sessionPath,
|
||||
label: tabs.get(tabId)?.label ?? "", running: false, pendingPrompt: false,
|
||||
backgroundJobs: 0, plan: false, toolApprovalMode: "ask", goal: "",
|
||||
});
|
||||
@@ -59,6 +60,7 @@ export function createMockRemoteProjects(tabs: MockRemoteTabCatalog): {
|
||||
id,
|
||||
scope: "project",
|
||||
workspaceRoot: workspace,
|
||||
sessionPath: `${workspace}/sessions/${opts?.sessionName || "intro"}.jsonl`,
|
||||
workspaceName,
|
||||
topicId: "",
|
||||
topicTitle: workspaceName,
|
||||
@@ -72,8 +74,12 @@ export function createMockRemoteProjects(tabs: MockRemoteTabCatalog): {
|
||||
remoteState: "ready",
|
||||
};
|
||||
}
|
||||
if (opts?.newSession) tab.topicTitle = "New session";
|
||||
if (opts?.newSession) {
|
||||
tab.topicTitle = "New session";
|
||||
tab.sessionPath = `${workspace}/sessions/${crypto.randomUUID()}.jsonl`;
|
||||
}
|
||||
if (opts?.sessionName) {
|
||||
tab.sessionPath = `${workspace}/sessions/${opts.sessionName}.jsonl`;
|
||||
const rows = sessions[key(hostId, workspace)] ?? [];
|
||||
tab.topicTitle = rows.find((row) => row.name === opts.sessionName)?.title || tab.workspaceName;
|
||||
for (const row of rows) row.current = row.name === opts.sessionName;
|
||||
|
||||
@@ -0,0 +1,49 @@
|
||||
import type { AppBindings } from "./bridge";
|
||||
import type { InvocationRequest, StructuredInvocationSubmit } from "./invocationDisplay";
|
||||
|
||||
export type InboxTarget = { tabId: string; sessionPath: string; generation: number; selection: number; remote: boolean; hostId?: string; workspace?: string };
|
||||
export type FollowupReceipt = { itemId: string; disposition: string; position: number; paused: boolean; idempotent?: boolean; error?: string };
|
||||
export interface FollowupBindings {
|
||||
EnqueueInboxFollowup(tabID: string, display: string, submit: string, idempotency: string): Promise<FollowupReceipt>;
|
||||
EnqueueInboxFollowupWithInvocations(tabID: string, display: string, submit: string, invocations: InvocationRequest[], idempotency: string): Promise<FollowupReceipt>;
|
||||
CaptureInboxTarget?(tabID: string, expectedPath: string): Promise<InboxTarget>;
|
||||
EnqueueInboxFollowupForTarget?(target: InboxTarget, display: string, submit: string, invocations: InvocationRequest[], key: string): Promise<FollowupReceipt>;
|
||||
LookupInboxFollowupForTarget?(target: InboxTarget, key: string): Promise<FollowupReceipt>;
|
||||
}
|
||||
export type PendingFollowup = {
|
||||
key: string; target?: InboxTarget; tabId: string; display: string; submit: string;
|
||||
structured?: StructuredInvocationSubmit; draft: string;
|
||||
};
|
||||
|
||||
// TabMeta supplies the backend's session path. UI topic/draft keys and tab
|
||||
// generations are deliberately excluded from this durable request identity.
|
||||
export function followupSessionKey(sessionPath?: string, hostId?: string, workspace?: string): string {
|
||||
const path = sessionPath?.trim();
|
||||
if (!path || (hostId && !workspace)) return "";
|
||||
return JSON.stringify(hostId ? ["remote", hostId, workspace, path] : ["local", path]);
|
||||
}
|
||||
|
||||
// Process-local draft ownership survives Composer remounts. Nothing here is
|
||||
// inferred from the currently executing turn, and unresolved entries never expire.
|
||||
const requests = new Map<string, PendingFollowup>();
|
||||
const listeners = new Set<() => void>();
|
||||
export const pendingFollowups = {
|
||||
get: (draft: string) => requests.get(draft),
|
||||
subscribe: (listener: () => void) => { listeners.add(listener); return () => { listeners.delete(listener); }; },
|
||||
set(draft: string, request: PendingFollowup) { requests.set(draft, request); listeners.forEach(listener => listener()); },
|
||||
clear(draft: string, request: PendingFollowup) {
|
||||
if (requests.get(draft) !== request) return;
|
||||
requests.delete(draft); listeners.forEach(listener => listener());
|
||||
},
|
||||
};
|
||||
|
||||
export async function confirmFollowup(binding: AppBindings, request: PendingFollowup): Promise<FollowupReceipt> {
|
||||
if (!request.target || !binding.LookupInboxFollowupForTarget) throw new Error("Follow-up receipt lookup unavailable");
|
||||
const receipt = await binding.LookupInboxFollowupForTarget(request.target, request.key);
|
||||
if (!receipt?.itemId || receipt.error) throw new Error(receipt?.error || "Follow-up receipt unconfirmed");
|
||||
return receipt;
|
||||
}
|
||||
|
||||
export function followupNotSubmitted(error: unknown): boolean {
|
||||
return /reasonix_error:(inbox_not_submitted|inbox_capacity_items|inbox_capacity_bytes|inbox_item_too_large|inbox_empty|inbox_schema_readonly|channel_read_only)(?:$|\b)/.test(String(error));
|
||||
}
|
||||
@@ -1,4 +1,5 @@
|
||||
import { asArray } from "./array";
|
||||
import { runtimeStateStore } from "./runtimeStateStore";
|
||||
import type { ProjectNode, ProjectRuntimeTopic, ProjectTreeRuntimeSnapshot } from "./types";
|
||||
|
||||
const noExcludedTopicIds: ReadonlySet<string> = new Set();
|
||||
@@ -7,6 +8,15 @@ function withoutRuntimeState(node: ProjectNode): ProjectNode {
|
||||
return { ...node, open: undefined, running: undefined, status: undefined, children: [] };
|
||||
}
|
||||
|
||||
function runtimeChildren(runtime: ProjectNode, catalog?: ProjectNode): ProjectNode[] {
|
||||
const known = new Map(asArray(catalog?.children).map(child => [child.key, child]));
|
||||
return asArray(runtime.children).map(child => {
|
||||
const metadata = known.get(child.key);
|
||||
return metadata ? { ...metadata, open: child.open, running: child.running, status: child.status,
|
||||
children: runtimeChildren(child, metadata) } : child;
|
||||
});
|
||||
}
|
||||
|
||||
function runtimeTopicKey(scope: string, workspaceRoot: string, topicId: string): string {
|
||||
return `${scope}\u0000${workspaceRoot}\u0000${topicId}`;
|
||||
}
|
||||
@@ -47,7 +57,7 @@ function rememberResidentTopics(
|
||||
if (topic.runtimeOnly || !topic.topicId || excludedTopicIds.has(topic.topicId)) continue;
|
||||
const key = runtimeTopicKey(scope, root, topic.topicId);
|
||||
catalogKeys.add(key);
|
||||
residentTopics.set(key, withoutRuntimeState(topic));
|
||||
residentTopics.set(key, { ...withoutRuntimeState(topic), children: asArray(topic.children) });
|
||||
}
|
||||
}
|
||||
return catalogKeys;
|
||||
@@ -106,7 +116,7 @@ export function projectTreeApplyRuntimeTopics(
|
||||
open: runtime.node.open,
|
||||
running: runtime.node.running,
|
||||
status: runtime.node.status,
|
||||
children: asArray(runtime.node.children),
|
||||
children: runtimeChildren(runtime.node, node),
|
||||
} : withoutRuntimeState(node);
|
||||
base.push(reconcileNode(node, next));
|
||||
}
|
||||
@@ -128,7 +138,7 @@ export function projectTreeApplyRuntimeTopics(
|
||||
running: topic.node.running,
|
||||
status: topic.node.status,
|
||||
runtimeOnly: true,
|
||||
children: asArray(topic.node.children),
|
||||
children: runtimeChildren(topic.node, resident ?? current),
|
||||
}));
|
||||
}
|
||||
return reconcileNode(project, { ...project, children: [...runtimeOnly, ...base] });
|
||||
@@ -177,13 +187,24 @@ export function bindProjectTreeRuntime(
|
||||
snapshot = next;
|
||||
setTree(apply);
|
||||
};
|
||||
const stop = onProjectTreeRuntimeChanged(accept);
|
||||
void getSnapshot()?.then(accept).catch(() => {});
|
||||
const unified = () => {
|
||||
const current = runtimeStateStore.getSnapshot();
|
||||
if (current) {
|
||||
const topics = runtimeStateStore.getFailed() ? current.topics.map(topic => ({ ...topic, node: { ...topic.node, running: false, status: "unknown" as const } })) : current.topics;
|
||||
snapshot = null;
|
||||
accept({ revision: current.revision, topics });
|
||||
}
|
||||
};
|
||||
const stopUnified = runtimeStateStore.subscribe(unified);
|
||||
const stop = onProjectTreeRuntimeChanged(next => { if (!runtimeStateStore.getSnapshot()) accept(next); });
|
||||
unified();
|
||||
if (!runtimeStateStore.getSnapshot()) void getSnapshot()?.then(next => { if (!runtimeStateStore.getSnapshot()) accept(next); }).catch(() => {});
|
||||
return {
|
||||
apply,
|
||||
dispose() {
|
||||
active = false;
|
||||
stop();
|
||||
stopUnified();
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -347,6 +347,9 @@ export function topicUnknownTimeLabel(node: ProjectNode, t: Translator): string
|
||||
|
||||
const topicStatusLabels: Record<ProjectTopicStatus, DictKey> = {
|
||||
thinking: "projectTree.status.thinking",
|
||||
finishing: "runtime.finishing",
|
||||
unknown: "runtime.unknown",
|
||||
cancelling: "status.jobStopping",
|
||||
streaming: "projectTree.status.streaming",
|
||||
waiting_confirmation: "projectTree.status.waitingConfirmation",
|
||||
background_job: "projectTree.status.backgroundJob",
|
||||
@@ -357,6 +360,7 @@ const topicStatusLabels: Record<ProjectTopicStatus, DictKey> = {
|
||||
};
|
||||
|
||||
export function normalizeTopicStatus(status?: string): ProjectTopicStatus | "" {
|
||||
if (status === "finishing" || status === "cancelling" || status === "unknown") return status;
|
||||
if (!status) return "";
|
||||
if (status === "thinking" || status === "streaming" || status === "waiting_confirmation" || status === "background_job" || status === "paused" || status === "awaiting_delivery" || status === "error" || status === "diverged_recovery") {
|
||||
return status;
|
||||
@@ -374,6 +378,7 @@ export function topicStatus(node: ProjectNode): ProjectTopicStatus | "" {
|
||||
}
|
||||
|
||||
export function projectTreeTopicArchiveBlocked(node: ProjectNode): boolean {
|
||||
if (node.status === "finishing" || node.status === "cancelling" || node.status === "unknown") return true;
|
||||
if (asArray(node.children).some(projectTreeTopicArchiveBlocked)) return true;
|
||||
const status = normalizeTopicStatus(node.status);
|
||||
if (status === "thinking" || status === "streaming" || status === "waiting_confirmation" || status === "background_job") return true;
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
import type { RuntimeProjection, RuntimeState, createRuntimeStateStore } from "./runtimeStateStore";
|
||||
|
||||
function validState(state: RuntimeState) {
|
||||
if (!state || state.schemaVersion !== 1) return Boolean(state);
|
||||
return Boolean(state.runtimeEpoch) && Number.isSafeInteger(state.revision) && state.revision > 0
|
||||
&& ["idle", "executing", "finishing", "closed"].includes(state.phase)
|
||||
&& [state.running, state.pendingPrompt, state.cancelRequested, state.cancellable].every(value => typeof value === "boolean")
|
||||
&& Number.isSafeInteger(state.backgroundJobs) && state.backgroundJobs >= 0;
|
||||
}
|
||||
|
||||
export function acceptRuntimeState(store: ReturnType<typeof createRuntimeStateStore>, next: RuntimeProjection, authoritative = false): "accepted" | "duplicate" | "stale" | "conflict" {
|
||||
const snapshot = store.getSnapshot();
|
||||
if (!next?.epoch || !Number.isSafeInteger(next.revision) || !Array.isArray(next.sessions) || !Array.isArray(next.topics)) return "stale";
|
||||
if (snapshot && snapshot.epoch !== next.epoch && !authoritative) return "conflict";
|
||||
if (snapshot?.epoch === next.epoch) {
|
||||
if (next.revision < snapshot.revision) return "stale";
|
||||
if (next.revision === snapshot.revision) {
|
||||
if (JSON.stringify(next) !== JSON.stringify(snapshot)) return "conflict";
|
||||
store.commit(snapshot);
|
||||
return "duplicate";
|
||||
}
|
||||
}
|
||||
if (next.sessions.some(session => !validState(session?.state))) return "conflict";
|
||||
// Preserve per-session identity across unrelated updates.
|
||||
const old = new Map(snapshot?.sessions.map(session => [`${session.tabId}\0${session.sessionPath}`, session]));
|
||||
const committed = { ...next, topics: structuredClone(next.topics), sessions: next.sessions.map(session => {
|
||||
const previous = old.get(`${session.tabId}\0${session.sessionPath}`);
|
||||
return previous && JSON.stringify(previous) === JSON.stringify(session) ? previous : Object.freeze({ ...session, state: Object.freeze({ ...session.state }) });
|
||||
}) };
|
||||
store.commit(committed);
|
||||
return "accepted";
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import type { ProjectRuntimeTopic } from "./types";
|
||||
|
||||
export interface RuntimeState {
|
||||
schemaVersion: number;
|
||||
runtimeEpoch: string;
|
||||
revision: number;
|
||||
phase: "idle" | "executing" | "finishing" | "closed";
|
||||
running: boolean;
|
||||
turnId: string;
|
||||
turnStatus: string;
|
||||
turnEventSeq: number;
|
||||
pendingPrompt: boolean;
|
||||
cancelRequested: boolean;
|
||||
cancellable: boolean;
|
||||
backgroundJobs: number;
|
||||
activity: string;
|
||||
}
|
||||
export interface RuntimeSession {
|
||||
tabId: string;
|
||||
scope: string;
|
||||
workspaceRoot: string;
|
||||
topicId: string;
|
||||
sessionPath: string;
|
||||
sessionGeneration: number;
|
||||
open: boolean;
|
||||
remote: boolean;
|
||||
hostId?: string;
|
||||
freshness: "synced" | "unknown" | "syncing";
|
||||
state: RuntimeState;
|
||||
}
|
||||
export interface RuntimeProjection {
|
||||
epoch: string;
|
||||
revision: number;
|
||||
sessions: RuntimeSession[];
|
||||
topics: ProjectRuntimeTopic[];
|
||||
}
|
||||
|
||||
export function selectRuntime(session?: RuntimeSession, failed = false) {
|
||||
const state = session?.state;
|
||||
const known = state?.schemaVersion === 1;
|
||||
const unknown = Boolean(session && (failed || session.freshness !== "synced"));
|
||||
const finishing = known && state.phase === "finishing";
|
||||
const kind = unknown ? "unknown" : !known ? "legacy" : finishing ? "finishing"
|
||||
: state.cancelRequested ? "cancelling" : state.pendingPrompt ? "waiting_confirmation"
|
||||
: state.phase === "executing" ? state.activity === "streaming" ? "streaming" : "thinking"
|
||||
: state.backgroundJobs > 0 ? "background_job" : "idle";
|
||||
return { kind, known, unknown, finishing, state,
|
||||
running: known ? state.running : undefined,
|
||||
cancellable: known ? !unknown && !finishing && state.cancellable && !state.cancelRequested : undefined,
|
||||
spinning: !unknown && (kind === "thinking" || kind === "streaming" || kind === "cancelling" || kind === "background_job"),
|
||||
};
|
||||
}
|
||||
|
||||
export function createRuntimeStateStore() {
|
||||
let snapshot: RuntimeProjection | undefined;
|
||||
let failed = false;
|
||||
const listeners = new Set<() => void>();
|
||||
const notify = () => listeners.forEach(listener => listener());
|
||||
return {
|
||||
getSnapshot: () => snapshot,
|
||||
getFailed: () => failed,
|
||||
subscribe(listener: () => void) { listeners.add(listener); return () => { listeners.delete(listener); }; },
|
||||
fail() { if (!failed) { failed = true; notify(); } },
|
||||
commit(next: RuntimeProjection) {
|
||||
if (snapshot === next && !failed) return;
|
||||
snapshot = next;
|
||||
failed = false;
|
||||
notify();
|
||||
},
|
||||
};
|
||||
}
|
||||
export const runtimeStateStore = createRuntimeStateStore();
|
||||
@@ -0,0 +1,69 @@
|
||||
import { app } from "./bridge";
|
||||
import { acceptRuntimeState } from "./runtimeStateReducer";
|
||||
import { runtimeStateStore, type RuntimeProjection } from "./runtimeStateStore";
|
||||
export interface RuntimeSyncPorts {
|
||||
subscribe: (accept: (snapshot: RuntimeProjection) => void) => () => void;
|
||||
read: () => Promise<RuntimeProjection>;
|
||||
timer: (callback: () => void, delay: number) => unknown;
|
||||
clearTimer: (timer: unknown) => void;
|
||||
focus: (callback: () => void) => () => void;
|
||||
diagnostic?: (data: { reason: string; revision?: number; stale: number; conflicts: number; failures: number }) => void;
|
||||
}
|
||||
export function startRuntimeStateSync(ports: RuntimeSyncPorts, store = runtimeStateStore) {
|
||||
let disposed = false, inFlight = false, failures = 0;
|
||||
let stale = 0, conflicts = 0;
|
||||
let timer: unknown;
|
||||
const note = (reason: string, revision?: number) => ports.diagnostic?.({ reason, revision, stale, conflicts, failures });
|
||||
const sync = async (reason = "initial") => {
|
||||
if (disposed || inFlight) return;
|
||||
inFlight = true;
|
||||
ports.clearTimer(timer);
|
||||
const before = store.getSnapshot();
|
||||
try {
|
||||
const snapshot = await ports.read();
|
||||
if (disposed) return;
|
||||
const result = acceptRuntimeState(store, snapshot, before === store.getSnapshot());
|
||||
if (result === "stale") { stale++; note("stale-read", snapshot.revision); }
|
||||
if (result === "conflict") { conflicts++; throw new Error("Runtime snapshot version conflict"); }
|
||||
failures = snapshot.sessions.some(session => session.remote && session.freshness !== "synced") ? failures + 1 : 0;
|
||||
} catch {
|
||||
if (!disposed) { failures++; store.fail(); note(reason); }
|
||||
} finally {
|
||||
inFlight = false;
|
||||
if (!disposed) timer = ports.timer(() => { void sync("periodic"); }, failures ? [5000, 10000, 20000, 30000][Math.min(failures - 1, 3)] : 30000);
|
||||
}
|
||||
};
|
||||
const off = ports.subscribe(snapshot => {
|
||||
if (disposed) return;
|
||||
const result = acceptRuntimeState(store, snapshot);
|
||||
if (result === "stale") { stale++; note("stale-event", snapshot.revision); }
|
||||
if (result === "conflict") { conflicts++; note("conflicting-event", snapshot.revision); void sync("conflicting-event"); }
|
||||
});
|
||||
const offFocus = ports.focus(() => { void sync("focus-or-connection"); });
|
||||
void sync();
|
||||
return () => { disposed = true; off(); offFocus(); ports.clearTimer(timer); };
|
||||
}
|
||||
|
||||
export function startAppRuntimeStateSync() {
|
||||
return startRuntimeStateSync({
|
||||
diagnostic: data => console.debug("runtime synchronization", { source: "desktop-runtime", ...data }),
|
||||
subscribe: accept => window.runtime?.EventsOn("runtime-state:changed", (snapshot: unknown) => accept(snapshot as RuntimeProjection)) ?? (() => {}),
|
||||
read: async () => {
|
||||
if (!app.SyncRuntimeState) return app.GetRuntimeStateSnapshot!();
|
||||
try { return await app.SyncRuntimeState(); }
|
||||
catch { return app.GetRuntimeStateSnapshot!(); }
|
||||
},
|
||||
timer: (callback, delay) => window.setTimeout(callback, delay),
|
||||
clearTimer: timer => { if (timer !== undefined) window.clearTimeout(timer as number); },
|
||||
focus: callback => {
|
||||
const connections = new Map<string, string>();
|
||||
const off = window.runtime?.EventsOn("remote-tab:updated", (payload: unknown) => {
|
||||
const tab = payload as { id?: string; remoteState?: string };
|
||||
if (tab.id && connections.get(tab.id) !== tab.remoteState) { connections.set(tab.id, tab.remoteState ?? ""); callback(); }
|
||||
});
|
||||
window.addEventListener("focus", callback);
|
||||
window.addEventListener("online", callback);
|
||||
return () => { off?.(); window.removeEventListener("focus", callback); window.removeEventListener("online", callback); };
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -87,6 +87,8 @@ export interface ProjectGroupsSnapshot {
|
||||
}
|
||||
|
||||
export interface SessionCatalogBindings {
|
||||
GetRuntimeStateSnapshot?(): Promise<import("./runtimeStateStore").RuntimeProjection>;
|
||||
SyncRuntimeState?(): Promise<import("./runtimeStateStore").RuntimeProjection>;
|
||||
GetProjectTreeSnapshot(): Promise<ProjectTreeSnapshot>;
|
||||
GetProjectTreeRuntimeSnapshot?(): Promise<ProjectTreeRuntimeSnapshot>;
|
||||
ListProjectTopics(req: ProjectTopicPageRequest): Promise<ProjectTopicPage>;
|
||||
|
||||
@@ -695,7 +695,7 @@ export interface DeliveryWorktreeOpenResult {
|
||||
|
||||
export * from "./worktreeMergeTypes";
|
||||
|
||||
export type ProjectTopicStatus = "thinking" | "streaming" | "waiting_confirmation" | "background_job" | "paused" | "awaiting_delivery" | "error" | "diverged_recovery";
|
||||
export type ProjectTopicStatus = "thinking" | "streaming" | "finishing" | "cancelling" | "unknown" | "waiting_confirmation" | "background_job" | "paused" | "awaiting_delivery" | "error" | "diverged_recovery";
|
||||
|
||||
export interface TopicMeta {
|
||||
id: string;
|
||||
|
||||
@@ -21,6 +21,7 @@ export function useComposerInboxRefresh(
|
||||
applyQueue: (items: PendingGuidance[]) => void,
|
||||
collapse: () => void,
|
||||
bump: () => void,
|
||||
runtimeRevision?: number,
|
||||
) {
|
||||
const inboxSessionKeyRef = useRef(inboxSessionKey);
|
||||
const appliedRevisionRef = useRef<{ scope: string; revision: number }>({ scope: inboxSessionKey, revision: -1 });
|
||||
@@ -66,5 +67,5 @@ export function useComposerInboxRefresh(
|
||||
if (live) applyQueue(localGuidanceFallback(previewKey));
|
||||
});
|
||||
return () => { live = false; };
|
||||
}, [draftKey, guidanceDraftKey, previewKey, running, tabId, retryNonce, inboxSessionKey, applyQueue, collapse]);
|
||||
}, [draftKey, guidanceDraftKey, previewKey, running, tabId, retryNonce, inboxSessionKey, applyQueue, collapse, runtimeRevision]);
|
||||
}
|
||||
@@ -2,6 +2,7 @@
|
||||
// per-tab output, tool state, and approvals while the user switches tabs; components
|
||||
// render the active tab's state.
|
||||
import { runtimeStatusSnapshotIsStale } from "./runtimeStatusFreshness";
|
||||
import { useRuntimeSession } from "./useRuntimeState";
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { asArray } from "./array";
|
||||
import { createControllerModelCommands } from "./controllerModelCommands";
|
||||
@@ -2515,6 +2516,7 @@ export function useController() {
|
||||
// can schedule an authoritative refetch after it rejects a stale snapshot.
|
||||
const scheduleStalePromptReconcileRef = useRef<(tabId: string) => void>(() => {});
|
||||
const [activeTabId, setActiveTabId] = useState<string | undefined>();
|
||||
const runtimeState = useRuntimeSession(activeTabId);
|
||||
const activeTabIdRef = useRef<string | undefined>(undefined);
|
||||
// Invalidates async navigation completions even for ABA switches where the
|
||||
// visible tab ID eventually returns to the original value.
|
||||
@@ -3744,7 +3746,7 @@ export function useController() {
|
||||
await reconcileTabRuntime(tabId, { refreshAncillary: false });
|
||||
}, [reconcileTabRuntime]);
|
||||
useStaleTurnWatchdog({
|
||||
tabId: activeTabId, visibleState: activeState, activeTabIdRef, statesRef,
|
||||
tabId: runtimeState.known ? undefined : activeTabId, visibleState: activeState, activeTabIdRef, statesRef,
|
||||
lastTurnActivityAtByTab, reconcile: reconcileStaleTurn,
|
||||
});
|
||||
|
||||
@@ -5029,8 +5031,9 @@ export function useController() {
|
||||
} catch { /* ignore */ }
|
||||
}, []);
|
||||
|
||||
const projectedState = useMemo(() => runtimeState.known ? { ...activeState, running: runtimeState.running ?? activeState.running } : activeState, [activeState, runtimeState.known, runtimeState.running]);
|
||||
return {
|
||||
state: activeState,
|
||||
state: projectedState,
|
||||
liveStore,
|
||||
activeTabId,
|
||||
send, sendToTab, recoverDeliveryToTab, runShell, runShellForTab, steer, steerForTab, notice,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import { useRuntimeSession } from "./useRuntimeState";
|
||||
import { app, onRemoteTabEvent, onRemoteTabState } from "./bridge";
|
||||
import type { CancelOutcome } from "./inboxCancel";
|
||||
import { initialState, reducer, type ControllerLiveStore, type State } from "./useController";
|
||||
@@ -17,15 +18,15 @@ const loadRemoteSurface = () => import("../components/RemoteSessionSurface");
|
||||
// backend_status action so the remote surface reuses the local tab's running
|
||||
// reconciliation (including its staleness guards). The serve reports the
|
||||
// fields it knows; the rest stay undefined and the reducer keeps prior values.
|
||||
function remoteStatusToAction(status: unknown, snapshotAt: number) {
|
||||
function remoteStatusToAction(status: unknown, snapshotAt: number, previousRunning = false) {
|
||||
const raw = (status ?? null) as { running?: unknown; pendingPrompt?: unknown; backgroundJobs?: unknown; cancelRequested?: unknown; cancellable?: unknown } | null;
|
||||
return {
|
||||
type: "backend_status" as const,
|
||||
running: raw?.running === true,
|
||||
running: typeof raw?.running === "boolean" ? raw.running : previousRunning,
|
||||
pendingPrompt: raw?.pendingPrompt === undefined ? undefined : raw.pendingPrompt === true,
|
||||
backgroundJobs: typeof raw?.backgroundJobs === "number" ? raw.backgroundJobs : undefined,
|
||||
cancelRequested: raw?.cancelRequested === undefined ? undefined : raw.cancelRequested === true,
|
||||
cancellable: raw?.cancellable === undefined ? (raw?.running === true) : raw.cancellable === true,
|
||||
cancellable: raw?.cancellable === undefined ? undefined : raw.cancellable === true,
|
||||
snapshotAt,
|
||||
};
|
||||
}
|
||||
@@ -203,12 +204,13 @@ export function useActiveRemoteSession(
|
||||
showToast: (message: string, level: "error") => void,
|
||||
) {
|
||||
const active = Boolean(activeTab?.remote);
|
||||
const session = useRemoteSession(active && activeTab ? activeTab.id : undefined, activeTab?.remoteState);
|
||||
const session = useRemoteSession(active && activeTab ? activeTab.id : undefined, activeTab?.remoteState, activeTab?.sessionPath);
|
||||
const composer = useRemoteComposer(session, showToast);
|
||||
return { active, session, ready: active && session.state === "ready" && session.hydrated && Boolean(session.composerProfile), ...composer };
|
||||
}
|
||||
|
||||
export function useRemoteSession(tabId: string | undefined, initial?: RemoteTabStateValue): RemoteSessionApi {
|
||||
export function useRemoteSession(tabId: string | undefined, initial?: RemoteTabStateValue, sessionPath?: string): RemoteSessionApi {
|
||||
const runtimeState = useRuntimeSession(tabId, sessionPath);
|
||||
const [state, setState] = useState<RemoteTabStateValue>(initial === "disconnected" ? "connecting" : (initial ?? "connecting"));
|
||||
const [error, setError] = useState("");
|
||||
const [transcript, setTranscript] = useState<State>(initialState);
|
||||
@@ -227,6 +229,11 @@ export function useRemoteSession(tabId: string | undefined, initial?: RemoteTabS
|
||||
const bufferedEventsRef = useRef<WireEvent[]>([]);
|
||||
const hydrateRef = useRef<{ tabId: string; run: (force?: boolean) => Promise<void> } | null>(null);
|
||||
const refreshStatusRef = useRef<{ tabId: string; run: () => Promise<void> } | null>(null);
|
||||
const reconcileHistoryRef = useRef<(() => Promise<void>) | null>(null);
|
||||
const activityRevisionRef = useRef(0);
|
||||
const eventTurnIdRef = useRef<string | undefined>(undefined);
|
||||
const runtimeAtActivityRef = useRef(runtimeState.state);
|
||||
const pendingTurnRef = useRef<{ previousTurnId?: string } | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
transcriptRef.current = transcript;
|
||||
@@ -268,6 +275,8 @@ export function useRemoteSession(tabId: string | undefined, initial?: RemoteTabS
|
||||
setPromptError("");
|
||||
setTranscript(initialState);
|
||||
transcriptRef.current = initialState;
|
||||
pendingTurnRef.current = null;
|
||||
eventTurnIdRef.current = undefined;
|
||||
setModelLabel("");
|
||||
setCommands([]);
|
||||
setComposerProfile(undefined);
|
||||
@@ -294,12 +303,13 @@ export function useRemoteSession(tabId: string | undefined, initial?: RemoteTabS
|
||||
return historyReconcilePromise;
|
||||
}
|
||||
historyReconcilePromise = (async () => {
|
||||
const requestedActivity = activityRevisionRef.current;
|
||||
const snap = await app.RemoteTabSnapshot(tabId);
|
||||
// Ready-to-ready state publications represent /new, /clear, or
|
||||
// saved-session adoption just as surely as reconnect publications do.
|
||||
// A durable-history read started for the previous session must never
|
||||
// replace the newly hydrated transcript.
|
||||
if (cancelled || connectionGeneration !== requestedGeneration) return;
|
||||
if (cancelled || connectionGeneration !== requestedGeneration || activityRevisionRef.current !== requestedActivity) return;
|
||||
const messages = Array.isArray(snap.history) ? (snap.history as HistoryMessage[]) : [];
|
||||
const checkpoints = remoteCheckpoints(snap.checkpoints);
|
||||
setCommands(Array.isArray(snap.commands) ? snap.commands as CommandInfo[] : []);
|
||||
@@ -318,6 +328,7 @@ export function useRemoteSession(tabId: string | undefined, initial?: RemoteTabS
|
||||
if (rerun && !cancelled) void reconcileHistory().catch(() => undefined);
|
||||
}
|
||||
};
|
||||
reconcileHistoryRef.current = reconcileHistory;
|
||||
|
||||
const replayMissingPrompt = async (status: unknown, promptPresent: boolean, expectedGeneration: number) => {
|
||||
if ((status as RemoteStatus | null)?.pendingPrompt !== true || promptPresent) return;
|
||||
@@ -347,7 +358,7 @@ export function useRemoteSession(tabId: string | undefined, initial?: RemoteTabS
|
||||
if (cancelled || connectionGeneration !== expectedConnectionGeneration) return;
|
||||
applyRemoteStatus(status);
|
||||
setTranscript((current) => hydrateRemoteTelemetry(
|
||||
reducer(current, remoteStatusToAction(status, Date.now())),
|
||||
reducer(current, remoteStatusToAction(status, Date.now(), current.running)),
|
||||
status,
|
||||
));
|
||||
await replayMissingPrompt(status, Boolean(transcriptRef.current.approval || transcriptRef.current.ask), expectedConnectionGeneration);
|
||||
@@ -417,7 +428,7 @@ export function useRemoteSession(tabId: string | undefined, initial?: RemoteTabS
|
||||
// Hydrate doubles as the post-reconnect running reconciliation:
|
||||
// whatever the serve reports about its current state lands now,
|
||||
// not only after the next watchdog tick.
|
||||
next = reducer(next, remoteStatusToAction(status, Date.now()));
|
||||
next = reducer(next, remoteStatusToAction(status, Date.now(), next.running));
|
||||
next = hydrateRemoteTelemetry(next, status);
|
||||
const seenPrompts = new Set<string>();
|
||||
for (const event of replay) {
|
||||
@@ -480,12 +491,9 @@ export function useRemoteSession(tabId: string | undefined, initial?: RemoteTabS
|
||||
}
|
||||
if (s.state === "ready") {
|
||||
void hydrate(true);
|
||||
} else {
|
||||
// Leaving ready can only mean the serve connection dropped. A turn
|
||||
// that was running is now unobservable — stop the pill instead of
|
||||
// spinning forever on a turn_done that can never arrive.
|
||||
setTranscript((prev) => (prev.running || prev.turnActive ? reducer(prev, { type: "turn_interrupted" }) : prev));
|
||||
}
|
||||
// Disconnection preserves the observed transcript and runtime; it does
|
||||
// not imply a terminal outcome for the model turn.
|
||||
});
|
||||
// Subscribe before activating a restored shell. SetActiveTab republishes
|
||||
// terminal bootstrap states, while the snapshot loop covers a ready event
|
||||
@@ -496,6 +504,11 @@ export function useRemoteSession(tabId: string | undefined, initial?: RemoteTabS
|
||||
const offEvent = onRemoteTabEvent(tabId, (raw) => {
|
||||
if (cancelled) return;
|
||||
const event = (raw ?? {}) as WireEvent;
|
||||
if (event.kind === "turn_started" || (event.turnId && event.turnId !== eventTurnIdRef.current)) {
|
||||
activityRevisionRef.current += 1;
|
||||
eventTurnIdRef.current = event.turnId;
|
||||
}
|
||||
if (event.kind === "turn_started") pendingTurnRef.current = null;
|
||||
if (hydratingRef.current) {
|
||||
bufferedEventsRef.current.push(event);
|
||||
return;
|
||||
@@ -512,18 +525,37 @@ export function useRemoteSession(tabId: string | undefined, initial?: RemoteTabS
|
||||
bufferedEventsRef.current = [];
|
||||
if (hydrateRef.current?.run === hydrate) hydrateRef.current = null;
|
||||
if (refreshStatusRef.current?.run === refreshStatus) refreshStatusRef.current = null;
|
||||
if (reconcileHistoryRef.current === reconcileHistory) reconcileHistoryRef.current = null;
|
||||
offState();
|
||||
offEvent();
|
||||
};
|
||||
}, [applyRemoteStatus, tabId]);
|
||||
|
||||
// The runtime projection owns liveness. Feed a confirmed completion through
|
||||
// the shared reducer so the transcript and live store settle together. A
|
||||
// finishing/unknown snapshot is not completion, and an earlier turn cannot
|
||||
// settle a newer stream or an optimistic submission.
|
||||
useEffect(() => {
|
||||
const observed = runtimeState.state;
|
||||
const current = transcriptRef.current;
|
||||
if (!hydrated || state !== "ready" || runtimeState.unknown || !runtimeState.known
|
||||
|| !observed || observed.phase !== "idle" || observed.running
|
||||
|| observed === runtimeAtActivityRef.current || !current.running
|
||||
|| (pendingTurnRef.current && (!observed.turnId || observed.turnId === pendingTurnRef.current.previousTurnId))
|
||||
|| (current.activeTurnId && observed.turnId !== current.activeTurnId)) return;
|
||||
const next = reducer(current, remoteStatusToAction(observed, Date.now(), current.running));
|
||||
if (next.running) return;
|
||||
setTranscript(next);
|
||||
void reconcileHistoryRef.current?.().catch(() => undefined);
|
||||
}, [hydrated, state, runtimeState.state, runtimeState.unknown, runtimeState.known]);
|
||||
|
||||
// Running-state watchdog: while the pill claims a turn is running, poll the
|
||||
// serve's /status and feed it through the shared backend_status reducer.
|
||||
// This is the remote twin of the local tab's reconcile loop — a lost
|
||||
// turn_done frame (dropped SSE, slow-consumer drop, half-dead tunnel) then
|
||||
// clears within one tick instead of spinning forever.
|
||||
useEffect(() => {
|
||||
if (!tabId || !hydrated || state !== "ready" || !transcript.running) return;
|
||||
if (runtimeState.known || !tabId || !hydrated || state !== "ready" || !transcript.running) return;
|
||||
const reconcile = () => {
|
||||
const current = refreshStatusRef.current;
|
||||
if (!current || current.tabId !== tabId) return;
|
||||
@@ -535,7 +567,7 @@ export function useRemoteSession(tabId: string | undefined, initial?: RemoteTabS
|
||||
return () => {
|
||||
window.clearInterval(timer);
|
||||
};
|
||||
}, [tabId, hydrated, state, transcript.running]);
|
||||
}, [tabId, hydrated, state, transcript.running, runtimeState.known]);
|
||||
|
||||
const submit = useCallback(async (text: string) => {
|
||||
if (!tabId) return;
|
||||
@@ -544,6 +576,9 @@ export function useRemoteSession(tabId: string | undefined, initial?: RemoteTabS
|
||||
// Optimistic user bubble, exactly like the local send path. seq rides
|
||||
// the reducer's counter; the submission id only needs uniqueness.
|
||||
const submissionId = `remote-${Date.now()}`;
|
||||
activityRevisionRef.current += 1;
|
||||
runtimeAtActivityRef.current = runtimeState.state;
|
||||
pendingTurnRef.current = { previousTurnId: runtimeState.state?.turnId };
|
||||
setTranscript((s) => reducer(s, { type: "user", text: trimmed, seq: s.seq, submissionId }));
|
||||
try {
|
||||
await app.SubmitRemoteTab(tabId, trimmed);
|
||||
@@ -554,7 +589,7 @@ export function useRemoteSession(tabId: string | undefined, initial?: RemoteTabS
|
||||
setTranscript((s) => reducer(s, { type: "send_failed", submissionId, error }));
|
||||
throw e;
|
||||
}
|
||||
}, [tabId]);
|
||||
}, [tabId, runtimeState.state]);
|
||||
|
||||
const runManagementCommand = useCallback(async (text: string, rehydrate = false) => {
|
||||
if (!tabId) return;
|
||||
|
||||
@@ -0,0 +1,22 @@
|
||||
import { useEffect, useSyncExternalStore } from "react";
|
||||
import { app } from "./bridge";
|
||||
import { runtimeStateStore, selectRuntime } from "./runtimeStateStore";
|
||||
|
||||
export function useRuntimeSession(tabId?: string, sessionPath?: string) {
|
||||
const snapshot = useSyncExternalStore(runtimeStateStore.subscribe, runtimeStateStore.getSnapshot);
|
||||
const failed = useSyncExternalStore(runtimeStateStore.subscribe, runtimeStateStore.getFailed);
|
||||
const session = snapshot?.sessions.find(session => session.open && session.tabId === tabId && (!sessionPath || session.sessionPath === sessionPath));
|
||||
return selectRuntime(session, failed);
|
||||
}
|
||||
|
||||
export function useRuntimeStateSync() {
|
||||
useEffect(() => {
|
||||
if (!app.GetRuntimeStateSnapshot) return;
|
||||
let disposed = false;
|
||||
let stop: (() => void) | undefined;
|
||||
void import("./runtimeStateSync").then(({ startAppRuntimeStateSync }) => {
|
||||
if (!disposed) stop = startAppRuntimeStateSync();
|
||||
});
|
||||
return () => { disposed = true; stop?.(); };
|
||||
}, []);
|
||||
}
|
||||
@@ -1470,6 +1470,12 @@ export const en = {
|
||||
"projectTree.previously": "previously",
|
||||
"projectTree.running": "responding",
|
||||
"projectTree.status.thinking": "thinking",
|
||||
"runtime.finishing": "Finishing up",
|
||||
"runtime.unknown": "Disconnected / awaiting state sync",
|
||||
"runtime.background": "Background jobs running ({count})",
|
||||
"runtime.queued": "Queued",
|
||||
"runtime.checkReceipt": "Check send result",
|
||||
"runtime.unconfirmed": "Send result unconfirmed",
|
||||
"projectTree.status.streaming": "responding",
|
||||
"projectTree.status.waitingConfirmation": "awaiting",
|
||||
"projectTree.status.backgroundJob": "background job",
|
||||
|
||||
@@ -2935,6 +2935,12 @@ export const zhTW: Record<DictKey, string> = {
|
||||
"projectTree.previously": "之前",
|
||||
"projectTree.running": "輸出中",
|
||||
"projectTree.status.thinking": "思考中",
|
||||
"runtime.finishing": "正在收尾",
|
||||
"runtime.unknown": "連線中斷/狀態待同步",
|
||||
"runtime.background": "背景工作執行中({count})",
|
||||
"runtime.queued": "已排隊",
|
||||
"runtime.checkReceipt": "核對傳送結果",
|
||||
"runtime.unconfirmed": "傳送結果未確認",
|
||||
"projectTree.status.streaming": "輸出中",
|
||||
"projectTree.status.waitingConfirmation": "待確認",
|
||||
"projectTree.status.backgroundJob": "背景任務",
|
||||
|
||||
@@ -1471,6 +1471,12 @@ export const zh: Record<DictKey, string> = {
|
||||
"projectTree.previously": "之前",
|
||||
"projectTree.running": "输出中",
|
||||
"projectTree.status.thinking": "思考中",
|
||||
"runtime.finishing": "正在收尾",
|
||||
"runtime.unknown": "连接中断/状态待同步",
|
||||
"runtime.background": "后台任务运行中({count})",
|
||||
"runtime.queued": "已排队",
|
||||
"runtime.checkReceipt": "核对发送结果",
|
||||
"runtime.unconfirmed": "发送结果未确认",
|
||||
"projectTree.status.streaming": "输出中",
|
||||
"projectTree.status.waitingConfirmation": "待确认",
|
||||
"projectTree.status.backgroundJob": "后台任务",
|
||||
|
||||
+21
-10
@@ -30,6 +30,7 @@ func inboxWailsError(err error) error {
|
||||
target error
|
||||
code string
|
||||
}{
|
||||
{control.ErrInboxSessionChanged, "inbox_not_submitted"},
|
||||
{sessioninbox.ErrCapacityItems, "inbox_capacity_items"},
|
||||
{sessioninbox.ErrCapacityBytes, "inbox_capacity_bytes"},
|
||||
{sessioninbox.ErrItemTooLarge, "inbox_item_too_large"},
|
||||
@@ -158,6 +159,9 @@ func (a *App) inboxCtrl(tabID string) (control.SessionAPI, error) {
|
||||
|
||||
// InboxSnapshot returns durable inbox metadata for a tab (no bodies).
|
||||
func (a *App) InboxSnapshot(tabID string) (InboxSnapshotView, error) {
|
||||
if a.isRemoteTab(tabID) {
|
||||
return a.remoteInboxSnapshot(tabID)
|
||||
}
|
||||
ctrl, err := a.inboxCtrl(tabID)
|
||||
if err != nil {
|
||||
return InboxSnapshotView{}, err
|
||||
@@ -198,7 +202,7 @@ func (a *App) EnqueueInboxSteerForTurn(tabID, turnID, display, submit, idempoten
|
||||
if status.TurnID != turnID || !status.Running {
|
||||
return InboxReceiptView{}, fmt.Errorf("turn %q is not the active turn for tab %q", turnID, tabID)
|
||||
}
|
||||
return a.enqueueInboxWithController(tabID, ctrl, sessioninbox.IntentSteer, display, submit, nil, idempotency, true, turnID)
|
||||
return a.enqueueInboxWithController(tabID, ctrl, sessioninbox.IntentSteer, display, submit, nil, idempotency, true, turnID, "")
|
||||
}
|
||||
|
||||
// SteerInboxItem attempts to apply an existing durable queue item to the
|
||||
@@ -289,14 +293,20 @@ func (a *App) CancelTabWithInboxItemsResult(tabID string, itemIDs []string) (Inb
|
||||
}
|
||||
|
||||
func (a *App) enqueueInbox(tabID string, intent sessioninbox.InboxIntent, display, submit string, invocations []InvocationRequest, idempotency string, trySteer bool) (InboxReceiptView, error) {
|
||||
a.remoteTabMu.Lock()
|
||||
remote := a.remoteTabs[tabID] != nil
|
||||
a.remoteTabMu.Unlock()
|
||||
if remote && !trySteer {
|
||||
return a.enqueueRemoteFollowup(tabID, display, submit, invocations, idempotency)
|
||||
}
|
||||
ctrl, err := a.inboxCtrl(tabID)
|
||||
if err != nil {
|
||||
return InboxReceiptView{}, err
|
||||
}
|
||||
return a.enqueueInboxWithController(tabID, ctrl, intent, display, submit, invocations, idempotency, trySteer, "")
|
||||
return a.enqueueInboxWithController(tabID, ctrl, intent, display, submit, invocations, idempotency, trySteer, "", "")
|
||||
}
|
||||
|
||||
func (a *App) enqueueInboxWithController(tabID string, ctrl control.SessionAPI, intent sessioninbox.InboxIntent, display, submit string, invocations []InvocationRequest, idempotency string, trySteer bool, turnID string) (InboxReceiptView, error) {
|
||||
func (a *App) enqueueInboxWithController(tabID string, ctrl control.SessionAPI, intent sessioninbox.InboxIntent, display, submit string, invocations []InvocationRequest, idempotency string, trySteer bool, turnID, expectedPath string) (InboxReceiptView, error) {
|
||||
if ensurer, ok := ctrl.(interface{ EnsureSessionPath() }); ok {
|
||||
ensurer.EnsureSessionPath()
|
||||
}
|
||||
@@ -309,13 +319,14 @@ func (a *App) enqueueInboxWithController(tabID string, ctrl control.SessionAPI,
|
||||
display = submit
|
||||
}
|
||||
req := control.InboxRequest{
|
||||
Intent: intent,
|
||||
Display: display,
|
||||
Raw: submit,
|
||||
Submit: submit,
|
||||
Source: "desktop",
|
||||
Idempotency: strings.TrimSpace(idempotency),
|
||||
Invocations: controlInvocationRequests(invocations),
|
||||
ExpectedSessionPath: expectedPath,
|
||||
Intent: intent,
|
||||
Display: display,
|
||||
Raw: submit,
|
||||
Submit: submit,
|
||||
Source: "desktop",
|
||||
Idempotency: strings.TrimSpace(idempotency),
|
||||
Invocations: controlInvocationRequests(invocations),
|
||||
}
|
||||
var (
|
||||
rec sessioninbox.InboxReceipt
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
|
||||
"reasonix/internal/control"
|
||||
)
|
||||
|
||||
// A receipt belongs to a durable session, not the UI tab which originally
|
||||
// submitted it. Rebinding is read-only; enqueue still uses the original fence.
|
||||
func (a *App) remoteReceiptTarget(original InboxTargetView) (InboxTargetView, error) {
|
||||
if original.HostID == "" || original.Workspace == "" {
|
||||
return original, nil // old callers cannot safely rebind without identity
|
||||
}
|
||||
a.remoteTabMu.Lock()
|
||||
defer a.remoteTabMu.Unlock()
|
||||
var chosen *remoteTab
|
||||
for _, tab := range a.remoteTabs {
|
||||
if tab == nil || tab.ref.HostID != original.HostID || tab.ref.Workspace != original.Workspace ||
|
||||
tab.routing.currentPath != original.SessionPath || tab.state != "ready" ||
|
||||
tab.client == nil || tab.routing.rehydratingPath != "" {
|
||||
continue
|
||||
}
|
||||
if chosen == nil || tab.id < chosen.id || tab.id == original.TabID {
|
||||
chosen = tab
|
||||
}
|
||||
if tab.id == original.TabID {
|
||||
break
|
||||
}
|
||||
}
|
||||
if chosen == nil {
|
||||
return InboxTargetView{}, fmt.Errorf("inbox receipt session unavailable")
|
||||
}
|
||||
return InboxTargetView{TabID: chosen.id, SessionPath: original.SessionPath, Generation: chosen.gen,
|
||||
Selection: chosen.selectionRevision, Remote: true, HostID: chosen.ref.HostID, Workspace: chosen.ref.Workspace}, nil
|
||||
}
|
||||
|
||||
type localReceiptOwner struct {
|
||||
tab *WorkspaceTab
|
||||
ctrl control.SessionAPI
|
||||
path string
|
||||
generation uint64
|
||||
}
|
||||
|
||||
func (a *App) localReceiptTarget(path string) (localReceiptOwner, error) {
|
||||
key := sessionRuntimeKey(path)
|
||||
if key == "" {
|
||||
return localReceiptOwner{}, fmt.Errorf("inbox receipt session unavailable")
|
||||
}
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
var owner localReceiptOwner
|
||||
for _, tab := range a.runtimeTabsLocked() {
|
||||
if tab.Ctrl == nil || tab.ReadOnly || sessionRuntimeKey(tab.SessionPath) != key {
|
||||
continue
|
||||
}
|
||||
if owner.ctrl != nil && owner.ctrl != tab.Ctrl {
|
||||
return localReceiptOwner{}, fmt.Errorf("inbox receipt owner is ambiguous")
|
||||
}
|
||||
owner = localReceiptOwner{tab: tab, ctrl: tab.Ctrl, path: tab.SessionPath, generation: tab.SessionGeneration}
|
||||
}
|
||||
if owner.ctrl == nil {
|
||||
return owner, fmt.Errorf("inbox receipt session unavailable")
|
||||
}
|
||||
return owner, nil
|
||||
}
|
||||
|
||||
func (a *App) localReceiptOwnerCurrent(owner localReceiptOwner) bool {
|
||||
if sessionRuntimeKey(owner.ctrl.SessionPath()) != sessionRuntimeKey(owner.path) {
|
||||
return false
|
||||
}
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
for _, tab := range a.runtimeTabsLocked() {
|
||||
if tab == owner.tab && tab.Ctrl == owner.ctrl && !tab.ReadOnly && tab.SessionGeneration == owner.generation &&
|
||||
sessionRuntimeKey(tab.SessionPath) == sessionRuntimeKey(owner.path) {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"net/http"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/sessioninbox"
|
||||
)
|
||||
|
||||
func TestInboxReceiptSurvivesLocalTabReopen(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "session.jsonl")
|
||||
ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Sink: event.Discard})
|
||||
if err := ctrl.SetInboxPaused(true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := &App{tabs: map[string]*WorkspaceTab{"old": {ID: "old", Ctrl: ctrl, SessionPath: path, SessionGeneration: 1, Ready: true}}}
|
||||
target, err := a.CaptureInboxTarget("old", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
receipt, err := a.EnqueueInboxFollowupForTarget(target, "original", "original", nil, "original-key")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
ctrl.Close()
|
||||
reopened := control.New(control.Options{SessionDir: dir, SessionPath: path, Sink: event.Discard})
|
||||
defer reopened.Close()
|
||||
a.tabs = map[string]*WorkspaceTab{"new": {ID: "new", Ctrl: reopened, SessionPath: path, SessionGeneration: 1, Ready: true}}
|
||||
actual, err := a.LookupInboxFollowupForTarget(target, "original-key")
|
||||
if err != nil || actual.ItemID != receipt.ItemID {
|
||||
t.Fatalf("reopened receipt = %+v, %v", actual, err)
|
||||
}
|
||||
if _, err := a.EnqueueInboxFollowupForTarget(target, "repeat", "repeat", nil, "repeat"); err == nil {
|
||||
t.Fatal("old write fence accepted new tab")
|
||||
}
|
||||
if got := len(reopened.InboxSnapshot().Items); got != 1 {
|
||||
t.Fatalf("replayed submission: %d items", got)
|
||||
}
|
||||
a.detachedSessions = map[string]*WorkspaceTab{sessionRuntimeKey(path): a.tabs["new"]}
|
||||
a.tabs = nil
|
||||
if actual, err := a.LookupInboxFollowupForTarget(target, "original-key"); err != nil || actual.ItemID != receipt.ItemID {
|
||||
t.Fatalf("detached owner receipt = %+v, %v", actual, err)
|
||||
}
|
||||
a.detachedSessions = nil
|
||||
if _, err := a.LookupInboxFollowupForTarget(target, "original-key"); err == nil || len(a.tabs)+len(a.detachedSessions) != 0 {
|
||||
t.Fatal("lookup created an owner for an unavailable session")
|
||||
}
|
||||
}
|
||||
|
||||
type receiptOwnerHook struct {
|
||||
control.SessionAPI
|
||||
beforeReturn func()
|
||||
}
|
||||
|
||||
func (c *receiptOwnerHook) LookupInboxReceiptForSession(_, _ string) (sessioninbox.InboxReceipt, bool, error) {
|
||||
c.beforeReturn()
|
||||
return sessioninbox.InboxReceipt{ItemID: "original"}, true, nil
|
||||
}
|
||||
|
||||
func TestInboxReceiptRejectsLocalOwnerChangedDuringRead(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "session.jsonl")
|
||||
ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Sink: event.Discard})
|
||||
defer ctrl.Close()
|
||||
for _, change := range []string{"generation", "controller", "path", "removed", "readonly"} {
|
||||
t.Run(change, func(t *testing.T) {
|
||||
a := &App{}
|
||||
hook := &receiptOwnerHook{SessionAPI: ctrl}
|
||||
tab := &WorkspaceTab{ID: "new", Ctrl: hook, SessionPath: path, SessionGeneration: 2}
|
||||
a.tabs = map[string]*WorkspaceTab{"new": tab}
|
||||
hook.beforeReturn = func() {
|
||||
a.mu.Lock()
|
||||
defer a.mu.Unlock()
|
||||
switch change {
|
||||
case "generation":
|
||||
tab.SessionGeneration++
|
||||
case "controller":
|
||||
tab.Ctrl = ctrl
|
||||
case "path":
|
||||
tab.SessionPath = filepath.Join(dir, "other.jsonl")
|
||||
case "removed":
|
||||
delete(a.tabs, "new")
|
||||
case "readonly":
|
||||
tab.ReadOnly = true
|
||||
}
|
||||
}
|
||||
if got, err := a.LookupInboxFollowupForTarget(InboxTargetView{TabID: "old", SessionPath: path}, "original"); err == nil || got.ItemID != "" {
|
||||
t.Fatalf("stale receipt escaped %s fence: %+v %v", change, got, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestInboxReceiptRebindsRemoteTabWithoutReplaying(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
reads := 0
|
||||
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.Method != http.MethodGet || req.URL.Query().Get("session") != runtimeRemoteTestPath || req.URL.Query().Get("key") != "original" {
|
||||
t.Errorf("wrong receipt route: %s %s", req.Method, req.URL.Path)
|
||||
}
|
||||
reads++
|
||||
return remoteRuntimeTestResponse(req, 200, `{"itemId":"original","position":0,"disposition":"idempotent_hit","paused":false}`), nil
|
||||
})}
|
||||
a, tab := remoteRuntimeTestApp(client)
|
||||
target, err := a.CaptureInboxTarget(tab.id, runtimeRemoteTestPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
delete(a.remoteTabs, tab.id)
|
||||
tab.id = "reopened"
|
||||
tab.gen++
|
||||
a.remoteTabs[tab.id] = tab
|
||||
if got, err := a.LookupInboxFollowupForTarget(target, "original"); err != nil || got.ItemID != "original" {
|
||||
t.Fatalf("lookup = %+v %v", got, err)
|
||||
}
|
||||
if reads != 1 {
|
||||
t.Fatalf("reads = %d", reads)
|
||||
}
|
||||
if _, err := a.EnqueueInboxFollowupForTarget(target, "repeat", "repeat", nil, "repeat"); err == nil {
|
||||
t.Fatal("old write target was rebound")
|
||||
}
|
||||
tab.ref.Workspace = "different-workspace"
|
||||
if _, err := a.LookupInboxFollowupForTarget(target, "original"); err == nil || reads != 1 {
|
||||
t.Fatal("receipt crossed workspace identity")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,144 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/url"
|
||||
"strings"
|
||||
|
||||
"reasonix/internal/sessioninbox"
|
||||
)
|
||||
|
||||
// InboxTargetView fences follow-ups across both local replacement and remote
|
||||
// selection changes. It is process-local and never written to the inbox ledger.
|
||||
type InboxTargetView struct {
|
||||
TabID string `json:"tabId"`
|
||||
SessionPath string `json:"sessionPath"`
|
||||
Generation uint64 `json:"generation"`
|
||||
Selection uint64 `json:"selection"`
|
||||
Remote bool `json:"remote"`
|
||||
HostID string `json:"hostId,omitempty"`
|
||||
Workspace string `json:"workspace,omitempty"`
|
||||
}
|
||||
|
||||
func (a *App) CaptureInboxTarget(tabID, expectedPath string) (InboxTargetView, error) {
|
||||
a.runtimeAdmissionMu.RLock()
|
||||
defer a.runtimeAdmissionMu.RUnlock()
|
||||
return a.captureInboxTarget(tabID, expectedPath)
|
||||
}
|
||||
|
||||
func (a *App) captureInboxTarget(tabID, expectedPath string) (InboxTargetView, error) {
|
||||
a.remoteTabMu.Lock()
|
||||
remote := a.remoteTabs[tabID]
|
||||
if remote != nil {
|
||||
defer a.remoteTabMu.Unlock()
|
||||
if remote.state != "ready" || remote.client == nil || remote.routing.rehydratingPath != "" || remote.routing.currentPath == "" || remote.routing.currentPath != expectedPath {
|
||||
return InboxTargetView{}, fmt.Errorf("inbox target changed")
|
||||
}
|
||||
return InboxTargetView{TabID: tabID, SessionPath: expectedPath, Generation: remote.gen, Selection: remote.selectionRevision,
|
||||
Remote: true, HostID: remote.ref.HostID, Workspace: remote.ref.Workspace}, nil
|
||||
}
|
||||
a.remoteTabMu.Unlock()
|
||||
a.mu.RLock()
|
||||
defer a.mu.RUnlock()
|
||||
for _, tab := range a.tabs {
|
||||
if tab.ID == tabID && tab.Ctrl != nil && tab.SessionPath != "" && tab.SessionPath == expectedPath {
|
||||
return InboxTargetView{TabID: tabID, SessionPath: expectedPath, Generation: tab.SessionGeneration}, nil
|
||||
}
|
||||
}
|
||||
return InboxTargetView{}, fmt.Errorf("inbox target changed")
|
||||
}
|
||||
|
||||
func (a *App) remoteInboxTarget(target InboxTargetView) (*http.Client, string, error) {
|
||||
a.remoteTabMu.Lock()
|
||||
defer a.remoteTabMu.Unlock()
|
||||
tab := a.remoteTabs[target.TabID]
|
||||
if tab == nil || tab.client == nil || tab.state != "ready" || tab.gen != target.Generation || tab.selectionRevision != target.Selection || tab.routing.currentPath != target.SessionPath || tab.routing.rehydratingPath != "" || (target.HostID != "" && (tab.ref.HostID != target.HostID || tab.ref.Workspace != target.Workspace)) {
|
||||
return nil, "", fmt.Errorf("inbox target changed")
|
||||
}
|
||||
return tab.client, tab.base, nil
|
||||
}
|
||||
|
||||
// EnqueueInboxFollowupForTarget is additive; older bindings retain their APIs.
|
||||
func (a *App) EnqueueInboxFollowupForTarget(target InboxTargetView, display, submit string, invocations []InvocationRequest, key string) (InboxReceiptView, error) {
|
||||
if strings.TrimSpace(key) == "" {
|
||||
return InboxReceiptView{}, inboxNotSubmitted(fmt.Errorf("idempotency key required"))
|
||||
}
|
||||
if target.Remote {
|
||||
client, base, err := a.remoteInboxTarget(target)
|
||||
if err != nil {
|
||||
return InboxReceiptView{}, inboxNotSubmitted(err)
|
||||
}
|
||||
return a.enqueueRemoteFollowupAt(client, base, target.SessionPath, display, submit, invocations, key)
|
||||
}
|
||||
a.runtimeAdmissionMu.RLock()
|
||||
defer a.runtimeAdmissionMu.RUnlock()
|
||||
current, err := a.captureInboxTarget(target.TabID, target.SessionPath)
|
||||
if err != nil || current != target {
|
||||
return InboxReceiptView{}, inboxNotSubmitted(fmt.Errorf("inbox target changed"))
|
||||
}
|
||||
ctrl, err := a.inboxCtrl(target.TabID)
|
||||
if err != nil {
|
||||
return InboxReceiptView{}, inboxNotSubmitted(err)
|
||||
}
|
||||
return a.enqueueInboxWithController(target.TabID, ctrl, sessioninbox.IntentFollowup, display, submit, invocations, key, false, "", target.SessionPath)
|
||||
}
|
||||
|
||||
func inboxNotSubmitted(err error) error {
|
||||
return &inboxCodedError{code: "inbox_not_submitted", cause: err}
|
||||
}
|
||||
|
||||
// LookupInboxFollowupForTarget never creates an item, including on a missing
|
||||
// or expired receipt. Position zero identifies an already removed item.
|
||||
func (a *App) LookupInboxFollowupForTarget(target InboxTargetView, key string) (InboxReceiptView, error) {
|
||||
if target.Remote {
|
||||
current, err := a.remoteReceiptTarget(target)
|
||||
if err != nil {
|
||||
return InboxReceiptView{}, err
|
||||
}
|
||||
target = current
|
||||
client, base, err := a.remoteInboxTarget(target)
|
||||
if err != nil {
|
||||
return InboxReceiptView{}, err
|
||||
}
|
||||
ctx, cancel := commandContext(a)
|
||||
defer cancel()
|
||||
data, err := serveGet(ctx, client, serveURL(base, "/inbox/receipt?key="+url.QueryEscape(key)+"&session="+url.QueryEscape(target.SessionPath)))
|
||||
if err != nil {
|
||||
return InboxReceiptView{}, err
|
||||
}
|
||||
var receipt InboxReceiptView
|
||||
if err := json.Unmarshal(data, &receipt); err != nil {
|
||||
return receipt, err
|
||||
}
|
||||
currentClient, currentBase, err := a.remoteInboxTarget(target)
|
||||
if err != nil || currentClient != client || currentBase != base {
|
||||
return InboxReceiptView{}, fmt.Errorf("inbox receipt route changed during read")
|
||||
}
|
||||
return receipt, nil
|
||||
}
|
||||
a.runtimeAdmissionMu.RLock()
|
||||
defer a.runtimeAdmissionMu.RUnlock()
|
||||
owner, err := a.localReceiptTarget(target.SessionPath)
|
||||
if err != nil {
|
||||
return InboxReceiptView{}, err
|
||||
}
|
||||
reader, ok := owner.ctrl.(interface {
|
||||
LookupInboxReceiptForSession(string, string) (sessioninbox.InboxReceipt, bool, error)
|
||||
})
|
||||
if !ok {
|
||||
return InboxReceiptView{}, fmt.Errorf("inbox receipt lookup unavailable")
|
||||
}
|
||||
receipt, found, err := reader.LookupInboxReceiptForSession(owner.path, key)
|
||||
if err != nil {
|
||||
return InboxReceiptView{}, err
|
||||
}
|
||||
if !found {
|
||||
return InboxReceiptView{}, fmt.Errorf("inbox receipt unconfirmed")
|
||||
}
|
||||
if !a.localReceiptOwnerCurrent(owner) {
|
||||
return InboxReceiptView{}, fmt.Errorf("inbox receipt owner changed during read")
|
||||
}
|
||||
return InboxReceiptView{ItemID: receipt.ItemID, Disposition: string(receipt.Disposition), Position: receipt.Position, Paused: receipt.Paused, Idempotent: receipt.Idempotent}, nil
|
||||
}
|
||||
@@ -0,0 +1,159 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/serve"
|
||||
)
|
||||
|
||||
func TestRemoteInboxTargetLostReceiptOnlyQueriesOriginalRequest(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "session.jsonl")
|
||||
ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Sink: event.Discard})
|
||||
defer ctrl.Close()
|
||||
if err := ctrl.SetInboxPaused(true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
server := httptest.NewServer(serve.New(ctrl, nil, config.ServeConfig{}).Handler())
|
||||
defer server.Close()
|
||||
posts, lookups := 0, 0
|
||||
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.Method == http.MethodPost {
|
||||
posts++
|
||||
resp, err := http.DefaultTransport.RoundTrip(req)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
resp.Body.Close()
|
||||
return nil, errors.New("accepted POST reply lost")
|
||||
}
|
||||
lookups++
|
||||
if req.URL.Query().Get("session") != path || req.URL.Query().Get("key") != "original-request" {
|
||||
t.Errorf("receipt request changed identity: %s", req.URL.Path)
|
||||
}
|
||||
if lookups == 1 {
|
||||
return nil, errors.New("first receipt response lost")
|
||||
}
|
||||
return http.DefaultTransport.RoundTrip(req)
|
||||
})}
|
||||
a, tab := remoteRuntimeTestApp(client)
|
||||
tab.base, tab.routing.currentPath, tab.session.path = server.URL, path, path
|
||||
target, err := a.CaptureInboxTarget(tab.id, path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := a.EnqueueInboxFollowupForTarget(target, "original input", "original input", nil, "original-request"); err == nil {
|
||||
t.Fatal("expected uncertain response")
|
||||
}
|
||||
if len(ctrl.InboxSnapshot().Items) != 1 {
|
||||
t.Fatal("POST was not durably accepted")
|
||||
}
|
||||
for range 3 {
|
||||
receipt, err := a.LookupInboxFollowupForTarget(target, "original-request")
|
||||
if err != nil || receipt.ItemID == "" {
|
||||
t.Fatalf("lookup = %+v, %v", receipt, err)
|
||||
}
|
||||
}
|
||||
if posts != 1 || lookups != 4 || len(ctrl.InboxSnapshot().Items) != 1 {
|
||||
t.Fatalf("lookup replayed POST: posts=%d lookups=%d", posts, lookups)
|
||||
}
|
||||
for _, change := range []string{"selection", "generation", "path", "host"} {
|
||||
t.Run(change, func(t *testing.T) {
|
||||
oldGen, oldSelection, oldPath, oldHost := tab.gen, tab.selectionRevision, tab.routing.currentPath, tab.ref.HostID
|
||||
defer func() {
|
||||
tab.gen, tab.selectionRevision, tab.routing.currentPath, tab.ref.HostID = oldGen, oldSelection, oldPath, oldHost
|
||||
}()
|
||||
switch change {
|
||||
case "selection":
|
||||
tab.selectionRevision++
|
||||
case "generation":
|
||||
tab.gen++
|
||||
case "path":
|
||||
tab.routing.currentPath = "other"
|
||||
case "host":
|
||||
tab.ref.HostID = "different-host"
|
||||
}
|
||||
before := lookups
|
||||
_, lookupErr := a.LookupInboxFollowupForTarget(target, "original-request")
|
||||
if change == "path" || change == "host" {
|
||||
if lookupErr == nil || lookups != before {
|
||||
t.Fatal("lookup accepted different session or host")
|
||||
}
|
||||
} else if lookupErr != nil || lookups != before+1 {
|
||||
t.Fatalf("same-session read did not recover after reconnect: %v", lookupErr)
|
||||
}
|
||||
if _, err := a.EnqueueInboxFollowupForTarget(target, "new", "new", nil, "new"); err == nil || !strings.Contains(err.Error(), "inbox_not_submitted") {
|
||||
t.Fatalf("stale target was not explicitly rejected: %v", err)
|
||||
}
|
||||
if posts != 1 {
|
||||
t.Fatal("stale write target crossed network boundary")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestLocalInboxTargetRejectsReplacementAndPreservesReceipt(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "session.jsonl")
|
||||
ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Sink: event.Discard})
|
||||
defer ctrl.Close()
|
||||
if err := ctrl.SetInboxPaused(true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a := &App{tabs: map[string]*WorkspaceTab{"tab": {ID: "tab", Ctrl: ctrl, SessionPath: path, SessionGeneration: 1, Ready: true}}}
|
||||
target, err := a.CaptureInboxTarget("tab", path)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
receipt, err := a.EnqueueInboxFollowupForTarget(target, "input", "input", nil, "local-request")
|
||||
if err != nil || receipt.ItemID == "" {
|
||||
t.Fatalf("enqueue = %+v, %v", receipt, err)
|
||||
}
|
||||
confirmed, err := a.LookupInboxFollowupForTarget(target, "local-request")
|
||||
if err != nil || confirmed.ItemID != receipt.ItemID {
|
||||
t.Fatalf("lookup = %+v, %v", confirmed, err)
|
||||
}
|
||||
if _, err := a.LookupInboxFollowupForTarget(target, "unknown"); err == nil {
|
||||
t.Fatal("missing receipt reported as confirmed")
|
||||
}
|
||||
a.tabs["tab"].SessionGeneration++
|
||||
if _, err := a.EnqueueInboxFollowupForTarget(target, "replacement", "replacement", nil, "replacement"); err == nil {
|
||||
t.Fatal("replacement accepted old request")
|
||||
}
|
||||
if got, err := a.LookupInboxFollowupForTarget(target, "local-request"); err != nil || got.ItemID != receipt.ItemID {
|
||||
t.Fatalf("same-session read could not rebind: %+v %v", got, err)
|
||||
}
|
||||
if len(ctrl.InboxSnapshot().Items) != 1 {
|
||||
t.Fatal("replacement created another item")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteInboxReceiptRejectsSelectionChangedDuringRead(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
var a *App
|
||||
var tab *remoteTab
|
||||
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
a.remoteTabMu.Lock()
|
||||
tab.selectionRevision++
|
||||
a.remoteTabMu.Unlock()
|
||||
return remoteRuntimeTestResponse(req, 200, `{"itemId":"old-item","position":0,"disposition":"idempotent_hit","paused":false}`), nil
|
||||
})}
|
||||
a, tab = remoteRuntimeTestApp(client)
|
||||
target, err := a.CaptureInboxTarget(tab.id, runtimeRemoteTestPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if receipt, err := a.LookupInboxFollowupForTarget(target, "original"); err == nil || receipt.ItemID != "" {
|
||||
t.Fatalf("stale read escaped its selection fence: %+v, %v", receipt, err)
|
||||
}
|
||||
}
|
||||
@@ -7,18 +7,15 @@ import (
|
||||
"sort"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
)
|
||||
|
||||
type projectTreeRuntimeState struct {
|
||||
revision atomic.Uint64
|
||||
// activityAt records the last activity-status event per tab ID. The TTL
|
||||
// watchdog reaps a live status that sees no terminating event; the state
|
||||
// lives here (not on WorkspaceTab) to keep tabs.go within budget.
|
||||
watchdogOnce sync.Once
|
||||
activityMu sync.Mutex
|
||||
activityAt map[string]time.Time
|
||||
activityMu sync.Mutex
|
||||
activityAt map[string]time.Time
|
||||
}
|
||||
|
||||
// noteActivityStatus refreshes a tab's activity timestamp on every status
|
||||
@@ -26,9 +23,7 @@ type projectTreeRuntimeState struct {
|
||||
// long a status has been displayed, so a long but active turn is never reaped.
|
||||
func (s *projectTreeRuntimeState) noteActivityStatus(a *App, tabID string) {
|
||||
s.setActivityAt(tabID, time.Now())
|
||||
// The watchdog starts lazily with the first status event — before that
|
||||
// there is nothing to reap.
|
||||
s.watchdogOnce.Do(func() { a.watchTopicActivityStatus() })
|
||||
// Runtime snapshots, not silence, determine whether work remains active.
|
||||
}
|
||||
|
||||
func (s *projectTreeRuntimeState) setActivityAt(tabID string, at time.Time) {
|
||||
@@ -93,21 +88,43 @@ func (a *App) catalogRuntimeSnapshots() []catalogRuntimeSnapshot {
|
||||
// projection. The frontend subscribes first and then calls this method; the
|
||||
// independent revision makes either arrival order deterministic.
|
||||
func (a *App) GetProjectTreeRuntimeSnapshot() ProjectTreeRuntimeSnapshot {
|
||||
revision := uint64(0)
|
||||
if a != nil {
|
||||
revision = a.projectTreeRuntime.revision.Load()
|
||||
if a == nil {
|
||||
return ProjectTreeRuntimeSnapshot{Topics: []ProjectRuntimeTopic{}}
|
||||
}
|
||||
return a.projectTreeRuntimeSnapshot(revision)
|
||||
snapshot := a.GetRuntimeStateSnapshot()
|
||||
return ProjectTreeRuntimeSnapshot{Revision: snapshot.Revision, Topics: snapshot.Topics}
|
||||
}
|
||||
|
||||
func (a *App) projectTreeRuntimeSnapshot(revision uint64) ProjectTreeRuntimeSnapshot {
|
||||
func cloneRuntimeTopics(topics []ProjectRuntimeTopic) []ProjectRuntimeTopic {
|
||||
var cloneNode func(ProjectNode) ProjectNode
|
||||
cloneNode = func(node ProjectNode) ProjectNode {
|
||||
next := node
|
||||
next.Children = make([]ProjectNode, len(node.Children))
|
||||
for i, child := range node.Children {
|
||||
next.Children[i] = cloneNode(child)
|
||||
}
|
||||
if node.Remote != nil {
|
||||
remote := *node.Remote
|
||||
next.Remote = &remote
|
||||
}
|
||||
return next
|
||||
}
|
||||
result := make([]ProjectRuntimeTopic, len(topics))
|
||||
for i, topic := range topics {
|
||||
result[i] = topic
|
||||
result[i].Node = cloneNode(topic.Node)
|
||||
}
|
||||
return result
|
||||
}
|
||||
|
||||
func (a *App) projectTreeRuntimeTopics(snapshots []catalogRuntimeSnapshot) []ProjectRuntimeTopic {
|
||||
type runtimeGroup struct {
|
||||
scope string
|
||||
workspaceRoot string
|
||||
snapshots []catalogRuntimeSnapshot
|
||||
}
|
||||
groups := map[string]*runtimeGroup{}
|
||||
for _, snapshot := range a.catalogRuntimeSnapshots() {
|
||||
for _, snapshot := range snapshots {
|
||||
scope, root := normalizeDesktopTopicScope(snapshot.scope, snapshot.workspaceRoot)
|
||||
snapshot.scope, snapshot.workspaceRoot = scope, root
|
||||
key := topicSummaryKey(scope, root, snapshot.topicID)
|
||||
@@ -126,12 +143,12 @@ func (a *App) projectTreeRuntimeSnapshot(revision uint64) ProjectTreeRuntimeSnap
|
||||
topics := make([]ProjectRuntimeTopic, 0, len(keys))
|
||||
for _, key := range keys {
|
||||
group := groups[key]
|
||||
nodes, _ := a.runtimeProjectTopicNodes(group.scope, group.workspaceRoot, group.snapshots)
|
||||
nodes, _ := a.runtimeProjectTopicNodes(group.scope, group.workspaceRoot, group.snapshots, false)
|
||||
if len(nodes) > 0 {
|
||||
topics = append(topics, ProjectRuntimeTopic{Scope: group.scope, WorkspaceRoot: group.workspaceRoot, Node: nodes[0]})
|
||||
}
|
||||
}
|
||||
return ProjectTreeRuntimeSnapshot{Revision: revision, Topics: topics}
|
||||
return topics
|
||||
}
|
||||
|
||||
func (a *App) attachExistingSessionRuntime(tab *WorkspaceTab, path string, wailsCtx context.Context) bool {
|
||||
@@ -163,8 +180,9 @@ func (a *App) emitProjectTreeRuntimeChanged() {
|
||||
if a == nil {
|
||||
return
|
||||
}
|
||||
revision := a.projectTreeRuntime.revision.Add(1)
|
||||
a.emitRuntimeEvent("project-tree:runtime-changed", a.projectTreeRuntimeSnapshot(revision))
|
||||
snapshot := a.GetRuntimeStateSnapshot()
|
||||
a.emitRuntimeEvent("project-tree:runtime-changed", ProjectTreeRuntimeSnapshot{Revision: snapshot.Revision, Topics: snapshot.Topics})
|
||||
a.emitRuntimeEvent("runtime-state:changed", snapshot)
|
||||
}
|
||||
|
||||
// The tagged legacy event keeps the previous frontend usable for one release.
|
||||
@@ -185,8 +203,6 @@ const (
|
||||
// used instead of a per-session P99: turn durations are not tracked
|
||||
// per session in this package, and simplicity wins (#8528/#8555/#8859).
|
||||
topicActivityStatusTTL = 10 * time.Minute
|
||||
// topicActivityReapInterval is how often the watchdog scans for orphans.
|
||||
topicActivityReapInterval = 30 * time.Second
|
||||
)
|
||||
|
||||
// liveTopicActivityStatus reports statuses that must be terminated by a
|
||||
@@ -200,23 +216,6 @@ func liveTopicActivityStatus(status string) bool {
|
||||
return false
|
||||
}
|
||||
|
||||
// watchTopicActivityStatus reaps live activity statuses that have seen no
|
||||
// turn event for longer than the TTL — the missed-TurnDone safety net.
|
||||
func (a *App) watchTopicActivityStatus() {
|
||||
a.goSafe("topicActivityWatchdog", func() {
|
||||
ticker := time.NewTicker(topicActivityReapInterval)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
select {
|
||||
case <-a.bootContext().Done():
|
||||
return
|
||||
case now := <-ticker.C:
|
||||
a.reapStaleTopicActivityStatus(now)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
func (a *App) reapStaleTopicActivityStatus(now time.Time) {
|
||||
activityAt := a.projectTreeRuntime.activityAtSnapshot()
|
||||
a.mu.Lock()
|
||||
|
||||
@@ -43,8 +43,8 @@ func TestProjectTreeRuntimeSnapshotFindsRestoredTabBeforeFirstEvent(t *testing.T
|
||||
Ready: true,
|
||||
}
|
||||
snapshot := app.GetProjectTreeRuntimeSnapshot()
|
||||
if snapshot.Revision != 0 || len(snapshot.Topics) != 1 {
|
||||
t.Fatalf("initial runtime snapshot = %+v, want one topic at revision 0", snapshot)
|
||||
if snapshot.Revision == 0 || len(snapshot.Topics) != 1 {
|
||||
t.Fatalf("initial runtime snapshot = %+v, want one committed topic with a revision", snapshot)
|
||||
}
|
||||
topic := snapshot.Topics[0]
|
||||
if topic.Node.TopicID != "topic-restored" || !topic.Node.Open || !topic.Node.Running {
|
||||
|
||||
@@ -220,19 +220,6 @@ func (a *App) stopRemoteRuntime() {
|
||||
a.closeCredentialProxy()
|
||||
}
|
||||
|
||||
// emitRemoteEvent bridges a kernel callback to the frontend through the async
|
||||
// emitter so a slow webview never blocks the kernel.
|
||||
func (a *App) emitRemoteEvent(name string, payload any) {
|
||||
if a.remoteEventHook != nil {
|
||||
a.remoteEventHook(name, payload)
|
||||
}
|
||||
ctx := a.bootContext()
|
||||
if ctx == nil {
|
||||
return
|
||||
}
|
||||
a.runtimeEvents.Emit(ctx, name, payload)
|
||||
}
|
||||
|
||||
// remoteEventSink implementation on *App.
|
||||
func (a *App) onStatus(s RemoteConnectionStatusView) {
|
||||
a.emitRemoteEvent("remote:status", s)
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package main
|
||||
|
||||
import "strings"
|
||||
|
||||
// emitRemoteEvent bridges a kernel callback to the frontend through the async
|
||||
// emitter so a slow webview never blocks the kernel.
|
||||
func (a *App) emitRemoteEvent(name string, payload any) {
|
||||
if a.remoteEventHook != nil {
|
||||
a.remoteEventHook(name, payload)
|
||||
}
|
||||
if strings.HasPrefix(name, "remote-tab:") && strings.HasSuffix(name, ":state") {
|
||||
a.emitRuntimeStateChanged()
|
||||
}
|
||||
ctx := a.bootContext()
|
||||
if ctx == nil {
|
||||
return
|
||||
}
|
||||
a.runtimeEvents.Emit(ctx, name, payload)
|
||||
}
|
||||
@@ -0,0 +1,99 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/url"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/sessioninbox"
|
||||
)
|
||||
|
||||
// remoteInboxSnapshot reads the selected durable queue using the same route
|
||||
// identity as remote runtime synchronization. A reply from a replaced tunnel
|
||||
// or selection must not clear or populate the current composer's queue.
|
||||
func (a *App) remoteInboxSnapshot(tabID string) (InboxSnapshotView, error) {
|
||||
a.remoteTabMu.Lock()
|
||||
tab := a.remoteTabs[tabID]
|
||||
if tab == nil || tab.client == nil || tab.state != "ready" || tab.routing.currentPath == "" || tab.routing.rehydratingPath != "" {
|
||||
a.remoteTabMu.Unlock()
|
||||
return InboxSnapshotView{}, fmt.Errorf("remote tab %q is not ready for inbox reads", tabID)
|
||||
}
|
||||
client, base, path := tab.client, tab.base, tab.routing.currentPath
|
||||
gen, selection := tab.gen, tab.selectionRevision
|
||||
a.remoteTabMu.Unlock()
|
||||
|
||||
ctx, cancel := commandContext(a)
|
||||
defer cancel()
|
||||
data, err := serveGet(ctx, client, serveURL(base, "/inbox?session="+url.QueryEscape(path)))
|
||||
if err != nil {
|
||||
return InboxSnapshotView{}, err
|
||||
}
|
||||
var snap sessioninbox.InboxSnapshot
|
||||
if err := json.Unmarshal(data, &snap); err != nil {
|
||||
return InboxSnapshotView{}, err
|
||||
}
|
||||
if snap.SessionPath == "" || agent.CanonicalSessionPath(snap.SessionPath) != agent.CanonicalSessionPath(path) {
|
||||
return InboxSnapshotView{}, fmt.Errorf("remote inbox session changed")
|
||||
}
|
||||
a.remoteTabMu.Lock()
|
||||
defer a.remoteTabMu.Unlock()
|
||||
current := a.remoteTabs[tabID]
|
||||
if current != tab || current.gen != gen || current.selectionRevision != selection || current.client != client || current.base != base || current.state != "ready" || current.routing.currentPath != path || current.routing.rehydratingPath != "" {
|
||||
return InboxSnapshotView{}, fmt.Errorf("remote inbox route changed during read")
|
||||
}
|
||||
return inboxSnapshotView(snap), nil
|
||||
}
|
||||
|
||||
// enqueueRemoteFollowup preserves the route, rich input and caller's stable
|
||||
// key. An uncertain POST is reconciled by reading its receipt, never replayed.
|
||||
func (a *App) enqueueRemoteFollowup(tabID, display, submit string, invocations []InvocationRequest, idempotency string) (InboxReceiptView, error) {
|
||||
client, base, path, err := a.remoteTabCommandTarget(tabID)
|
||||
if err != nil {
|
||||
return InboxReceiptView{}, err
|
||||
}
|
||||
return a.enqueueRemoteFollowupAt(client, base, path, display, submit, invocations, idempotency)
|
||||
}
|
||||
|
||||
func (a *App) enqueueRemoteFollowupAt(client *http.Client, base, path, display, submit string, invocations []InvocationRequest, idempotency string) (InboxReceiptView, error) {
|
||||
ctx, cancel := commandContext(a)
|
||||
defer cancel()
|
||||
body, err := json.Marshal(map[string]any{"intent": "followup", "input": submit, "display": display, "invocations": invocations, "idempotencyKey": idempotency})
|
||||
if err != nil {
|
||||
return InboxReceiptView{}, err
|
||||
}
|
||||
resp, err := serveDoForSession(ctx, client, http.MethodPost, serveURL(base, "/inbox/items"), body, path)
|
||||
if err == nil {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 200 && resp.StatusCode < 300 {
|
||||
var receipt InboxReceiptView
|
||||
err = json.NewDecoder(io.LimitReader(resp.Body, 1<<20)).Decode(&receipt)
|
||||
if err == nil && receipt.ItemID != "" {
|
||||
return receipt, nil
|
||||
}
|
||||
} else {
|
||||
err = fmt.Errorf("follow-up enqueue failed (%d)", resp.StatusCode)
|
||||
if resp.StatusCode >= 400 && resp.StatusCode < 500 {
|
||||
if resp.StatusCode != http.StatusRequestTimeout {
|
||||
return InboxReceiptView{}, inboxNotSubmitted(err)
|
||||
}
|
||||
return InboxReceiptView{}, err
|
||||
}
|
||||
}
|
||||
}
|
||||
if idempotency != "" {
|
||||
lookupCtx, lookupCancel := commandContext(a)
|
||||
defer lookupCancel()
|
||||
data, lookupErr := serveGet(lookupCtx, client, serveURL(base, "/inbox/receipt?key="+url.QueryEscape(idempotency)+"&session="+url.QueryEscape(path)))
|
||||
var receipt InboxReceiptView
|
||||
if lookupErr == nil && json.Unmarshal(data, &receipt) == nil && receipt.ItemID != "" {
|
||||
return receipt, nil
|
||||
}
|
||||
}
|
||||
if err == nil {
|
||||
err = fmt.Errorf("follow-up receipt unavailable")
|
||||
}
|
||||
return InboxReceiptView{}, err
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/serve"
|
||||
"reasonix/internal/sessioninbox"
|
||||
)
|
||||
|
||||
type remoteInboxRunner struct {
|
||||
started chan string
|
||||
release chan struct{}
|
||||
}
|
||||
|
||||
func (r remoteInboxRunner) Run(ctx context.Context, input string) error {
|
||||
r.started <- input
|
||||
select {
|
||||
case <-r.release:
|
||||
return nil
|
||||
case <-ctx.Done():
|
||||
return ctx.Err()
|
||||
}
|
||||
}
|
||||
|
||||
type remoteInboxSink struct {
|
||||
states chan event.RuntimeStateSnapshot
|
||||
}
|
||||
|
||||
func (*remoteInboxSink) Emit(event.Event) {}
|
||||
func (s *remoteInboxSink) RuntimeStateChanged(state event.RuntimeStateSnapshot) {
|
||||
s.states <- state
|
||||
}
|
||||
|
||||
func TestRemoteRuntimeInboxReceiptSnapshotAndConsumptionOverHTTP(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := t.TempDir()
|
||||
path := filepath.Join(dir, "remote.jsonl")
|
||||
if err := os.WriteFile(path, nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
runner := remoteInboxRunner{started: make(chan string, 2), release: make(chan struct{}, 2)}
|
||||
sink := &remoteInboxSink{states: make(chan event.RuntimeStateSnapshot, 64)}
|
||||
ctrl := control.New(control.Options{SessionDir: dir, SessionPath: path, Runner: runner, Sink: sink})
|
||||
t.Cleanup(func() {
|
||||
defer ctrl.Close()
|
||||
before := ctrl.RuntimeStateSnapshot()
|
||||
if before.Phase != "executing" && before.Phase != "finishing" {
|
||||
return
|
||||
}
|
||||
_ = ctrl.SetInboxPaused(true)
|
||||
ctrl.Cancel()
|
||||
deadline := time.NewTimer(5 * time.Second)
|
||||
defer deadline.Stop()
|
||||
for {
|
||||
select {
|
||||
case state := <-sink.states:
|
||||
if state.Phase == "idle" && state.Revision > before.Revision {
|
||||
return
|
||||
}
|
||||
case <-deadline.C:
|
||||
t.Error("isolated inbox runner did not settle before cleanup")
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
server := httptest.NewServer(serve.New(ctrl, nil, config.ServeConfig{}).Handler())
|
||||
defer server.Close()
|
||||
a, tab := remoteRuntimeTestApp(server.Client())
|
||||
tab.base, tab.routing.currentPath, tab.session.path = server.URL, path, path
|
||||
ctrl.Send("hold first turn")
|
||||
select {
|
||||
case <-runner.started:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("first turn did not start")
|
||||
}
|
||||
receipt, err := a.EnqueueInboxFollowupWithInvocations(tab.id, "rich follow-up", "next input", nil, "remote-inbox-key")
|
||||
if err != nil || receipt.ItemID == "" {
|
||||
t.Fatalf("enqueue = %+v, %v", receipt, err)
|
||||
}
|
||||
snapshot, err := a.InboxSnapshot(tab.id)
|
||||
if err != nil || snapshot.SessionPath != path || len(snapshot.Items) != 1 || snapshot.Items[0].ID != receipt.ItemID || snapshot.Items[0].Preview != "rich follow-up" || snapshot.MaxItems == 0 || snapshot.Bytes == 0 {
|
||||
t.Fatalf("receipt did not resolve to durable composer snapshot: %+v, %v", snapshot, err)
|
||||
}
|
||||
runner.release <- struct{}{}
|
||||
select {
|
||||
case <-runner.started:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("durable follow-up was not dispatched")
|
||||
}
|
||||
active, err := a.InboxSnapshot(tab.id)
|
||||
if err != nil || len(active.Items) != 1 || active.Items[0].ID != receipt.ItemID || active.Items[0].State != "running" || active.Revision <= snapshot.Revision || !ctrl.RuntimeStateSnapshot().Running {
|
||||
t.Fatalf("dispatched follow-up did not publish its composer-hidden state while still running: %+v, %v", active, err)
|
||||
}
|
||||
runner.release <- struct{}{}
|
||||
deadline := time.NewTimer(5 * time.Second)
|
||||
defer deadline.Stop()
|
||||
for {
|
||||
select {
|
||||
case state := <-sink.states:
|
||||
if state.Phase != "idle" {
|
||||
continue
|
||||
}
|
||||
consumed, err := a.InboxSnapshot(tab.id)
|
||||
if err != nil || consumed.Items == nil || len(consumed.Items) != 0 || consumed.Revision <= snapshot.Revision {
|
||||
t.Fatalf("consumption did not remove durable item: %+v, %v", consumed, err)
|
||||
}
|
||||
return
|
||||
case <-deadline.C:
|
||||
t.Fatal("follow-up did not settle")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteRuntimeInboxRejectsStaleResponseIdentity(t *testing.T) {
|
||||
for _, change := range []string{"generation", "selection", "client", "base", "path", "rehydrating", "replaced", "closed"} {
|
||||
t.Run(change, func(t *testing.T) {
|
||||
started, release := make(chan struct{}), make(chan struct{})
|
||||
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.Path != "/inbox" || req.URL.Query().Get("session") != runtimeRemoteTestPath {
|
||||
t.Errorf("unfenced inbox request: %s", req.URL)
|
||||
}
|
||||
close(started)
|
||||
<-release
|
||||
return remoteRuntimeTestResponse(req, http.StatusOK, `{"sessionPath":"`+runtimeRemoteTestPath+`","revision":4,"items":[]}`), nil
|
||||
})}
|
||||
a, tab := remoteRuntimeTestApp(client)
|
||||
done := make(chan error, 1)
|
||||
go func() { _, err := a.InboxSnapshot(tab.id); done <- err }()
|
||||
<-started
|
||||
a.remoteTabMu.Lock()
|
||||
switch change {
|
||||
case "generation":
|
||||
tab.gen++
|
||||
case "selection":
|
||||
tab.selectionRevision++
|
||||
case "client":
|
||||
tab.client = &http.Client{}
|
||||
case "base":
|
||||
tab.base = "http://other.invalid"
|
||||
case "path":
|
||||
tab.routing.currentPath = "/other.jsonl"
|
||||
case "rehydrating":
|
||||
tab.routing.rehydratingPath = runtimeRemoteTestPath
|
||||
case "replaced":
|
||||
a.remoteTabs[tab.id] = &remoteTab{id: tab.id}
|
||||
case "closed":
|
||||
delete(a.remoteTabs, tab.id)
|
||||
}
|
||||
a.remoteTabMu.Unlock()
|
||||
close(release)
|
||||
if err := <-done; err == nil {
|
||||
t.Fatal("stale remote inbox response was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteRuntimeInboxRejectsMissingOrWrongResponseSession(t *testing.T) {
|
||||
for _, path := range []string{"", "/sessions/other.jsonl"} {
|
||||
t.Run(path, func(t *testing.T) {
|
||||
data, _ := json.Marshal(sessioninbox.InboxSnapshot{SessionPath: path})
|
||||
a, tab := remoteRuntimeTestApp(&http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return remoteRuntimeTestResponse(req, http.StatusOK, string(data)), nil
|
||||
})})
|
||||
if _, err := a.InboxSnapshot(tab.id); err == nil {
|
||||
t.Fatal("unscoped remote inbox response was accepted")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -11,6 +11,7 @@ import (
|
||||
"time"
|
||||
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/event"
|
||||
)
|
||||
|
||||
// remoteTabModelSeq stamps every remote-tab model assignment; the credential
|
||||
@@ -90,7 +91,10 @@ type remoteTab struct {
|
||||
|
||||
// Transient runtime state is projected into TabMeta even while this tab is
|
||||
// inactive, matching the local tab strip's running/prompt/job indicators.
|
||||
runtime remoteTabRuntimeState
|
||||
runtime remoteTabRuntimeState
|
||||
runtimeStates map[string]event.RuntimeStateSnapshot
|
||||
runtimeUnknown map[string]uint64
|
||||
runtimeConflicts map[string]event.RuntimeStateSnapshot
|
||||
// routing fences all-session SSE and retains background project-tree state.
|
||||
routing remoteTabSessionRouting
|
||||
// selectionRevision fences async OpenRemoteProjectTab resumes so an older
|
||||
@@ -100,6 +104,8 @@ type remoteTab struct {
|
||||
}
|
||||
|
||||
type remoteTabRuntimeState struct {
|
||||
syncFailed bool
|
||||
snapshot event.RuntimeStateSnapshot
|
||||
// revision orders asynchronous /status snapshots against newer requests
|
||||
// and SSE-derived runtime mutations within the same connection generation.
|
||||
revision uint64
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"log/slog"
|
||||
"maps"
|
||||
"net/http"
|
||||
"sort"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/event"
|
||||
)
|
||||
|
||||
type remoteRuntimeSync struct {
|
||||
mu sync.Mutex
|
||||
pending bool
|
||||
unsupported map[string]string
|
||||
}
|
||||
|
||||
var remoteRuntimeDiagnostics struct {
|
||||
stale, conflicts, failures atomic.Uint64
|
||||
}
|
||||
|
||||
func validRuntimeState(state event.RuntimeStateSnapshot) bool {
|
||||
return state.SchemaVersion == 1 && state.RuntimeEpoch != "" && state.Revision > 0 && state.BackgroundJobs >= 0 &&
|
||||
(state.Phase == "idle" || state.Phase == "executing" || state.Phase == "finishing" || state.Phase == "closed")
|
||||
}
|
||||
|
||||
// acceptRemoteRuntimeStateLocked shares source ordering for GET and SSE. Host
|
||||
// generation and route validation remain the caller's responsibility.
|
||||
func acceptRemoteRuntimeStateLocked(tab *remoteTab, path string, next event.RuntimeStateSnapshot, authoritative bool) bool {
|
||||
if !validRuntimeState(next) {
|
||||
return false
|
||||
}
|
||||
if path == "" {
|
||||
path = tab.routing.currentPath
|
||||
}
|
||||
if tab.runtimeStates == nil {
|
||||
tab.runtimeStates = map[string]event.RuntimeStateSnapshot{}
|
||||
}
|
||||
previous, found := tab.runtimeStates[path]
|
||||
if found && previous.RuntimeEpoch != next.RuntimeEpoch && !authoritative {
|
||||
return false
|
||||
}
|
||||
if !found && !authoritative {
|
||||
return false
|
||||
}
|
||||
if found && previous.RuntimeEpoch == next.RuntimeEpoch {
|
||||
if next.Revision < previous.Revision {
|
||||
slog.Debug("remote runtime stale snapshot discarded", "source", "serve", "revision", next.Revision, "stale", remoteRuntimeDiagnostics.stale.Add(1))
|
||||
return false
|
||||
}
|
||||
if next.Revision == previous.Revision {
|
||||
if previous != next {
|
||||
slog.Warn("remote runtime snapshot version conflict", "source", "serve", "revision", next.Revision, "conflicts", remoteRuntimeDiagnostics.conflicts.Add(1))
|
||||
return false
|
||||
}
|
||||
if !authoritative || tab.runtimeUnknown[path] == 0 {
|
||||
return false
|
||||
}
|
||||
}
|
||||
}
|
||||
tab.runtimeStates[path] = next
|
||||
tab.runtime.syncFailed = false
|
||||
delete(tab.runtimeUnknown, path)
|
||||
delete(tab.runtimeConflicts, path)
|
||||
if path == tab.routing.currentPath {
|
||||
tab.runtime.snapshot = next
|
||||
tab.runtime.running, tab.runtime.pendingPrompt = next.Running, next.PendingPrompt
|
||||
tab.runtime.backgroundJobs, tab.runtime.cancellable, tab.runtime.cancelRequested = next.BackgroundJobs, next.Cancellable, next.CancelRequested
|
||||
}
|
||||
if tab.routing.running == nil {
|
||||
tab.routing.running = map[string]bool{}
|
||||
}
|
||||
tab.routing.running[path] = next.ActiveWork()
|
||||
tab.runtime.revision++
|
||||
return true
|
||||
}
|
||||
|
||||
func (a *App) acceptRemoteRuntimeFrame(tabID string, gen uint64, path string, frame json.RawMessage) {
|
||||
var payload struct {
|
||||
State json.RawMessage `json:"runtimeState"`
|
||||
}
|
||||
if json.Unmarshal(frame, &payload) != nil || len(payload.State) == 0 {
|
||||
return
|
||||
}
|
||||
state, decodeErr := decodeRemoteRuntimeState(payload.State)
|
||||
a.remoteTabMu.Lock()
|
||||
tab := a.remoteTabs[tabID]
|
||||
a.remoteTabMu.Unlock()
|
||||
if tab == nil {
|
||||
return
|
||||
}
|
||||
tab.routeEventMu.Lock()
|
||||
defer tab.routeEventMu.Unlock()
|
||||
a.remoteTabMu.Lock()
|
||||
if a.remoteTabs[tabID] != tab || tab.gen != gen {
|
||||
a.remoteTabMu.Unlock()
|
||||
return
|
||||
}
|
||||
if path == "" {
|
||||
path = tab.routing.currentPath
|
||||
}
|
||||
if decodeErr != nil {
|
||||
markRemoteRuntimeUnknownLocked(tab, path)
|
||||
a.remoteTabMu.Unlock()
|
||||
a.emitRuntimeStateChanged()
|
||||
a.goRemoteTabSafe("remoteRuntimeSync", func() { _, _ = a.SyncRuntimeState() })
|
||||
return
|
||||
}
|
||||
previous, found := tab.runtimeStates[path]
|
||||
resync := !found || previous.RuntimeEpoch != state.RuntimeEpoch || (previous.Revision == state.Revision && previous != state)
|
||||
if resync {
|
||||
if tab.runtimeConflicts[path] == state {
|
||||
resync = false
|
||||
} else {
|
||||
if tab.runtimeConflicts == nil {
|
||||
tab.runtimeConflicts = map[string]event.RuntimeStateSnapshot{}
|
||||
}
|
||||
tab.runtimeConflicts[path] = state
|
||||
}
|
||||
}
|
||||
changed := acceptRemoteRuntimeStateLocked(tab, path, state, false)
|
||||
meta := remoteTabMetaLocked(tab)
|
||||
a.remoteTabMu.Unlock()
|
||||
if changed {
|
||||
a.emitRemoteEvent("remote-tab:updated", meta)
|
||||
a.emitRuntimeStateChanged()
|
||||
} else if resync {
|
||||
a.goRemoteTabSafe("remoteRuntimeSync", func() { _, _ = a.SyncRuntimeState() })
|
||||
}
|
||||
}
|
||||
|
||||
type remoteRuntimeTarget struct {
|
||||
id string
|
||||
gen, selection uint64
|
||||
client *http.Client
|
||||
states map[string]event.RuntimeStateSnapshot
|
||||
unknown map[string]uint64
|
||||
}
|
||||
type remoteRuntimeConnection struct {
|
||||
id, key, base string
|
||||
client *http.Client
|
||||
targets []remoteRuntimeTarget
|
||||
}
|
||||
|
||||
// SyncRuntimeState reconciles each Serve connection once, regardless of how
|
||||
// many tabs it owns. It is separate from the memory-only snapshot getter.
|
||||
func (a *App) SyncRuntimeState() (RuntimeStateProjection, error) {
|
||||
syncer := &a.remoteRuntimeSync
|
||||
syncer.mu.Lock()
|
||||
if syncer.pending {
|
||||
syncer.mu.Unlock()
|
||||
return a.GetRuntimeStateSnapshot(), nil
|
||||
}
|
||||
syncer.pending = true
|
||||
if syncer.unsupported == nil {
|
||||
syncer.unsupported = map[string]string{}
|
||||
}
|
||||
syncer.mu.Unlock()
|
||||
defer func() { syncer.mu.Lock(); syncer.pending = false; syncer.mu.Unlock() }()
|
||||
connections := map[string]remoteRuntimeConnection{}
|
||||
a.remoteTabMu.Lock()
|
||||
for id, tab := range a.remoteTabs {
|
||||
if tab.client == nil || tab.state != "ready" {
|
||||
continue
|
||||
}
|
||||
key := fmt.Sprintf("%s\x00%s\x00%s", tab.ref.HostID, tab.ref.Workspace, tab.base)
|
||||
conn := connections[key]
|
||||
conn.id, conn.key, conn.base, conn.client = id, key, tab.base, tab.client
|
||||
states := map[string]event.RuntimeStateSnapshot{}
|
||||
maps.Copy(states, tab.runtimeStates)
|
||||
conn.targets = append(conn.targets, remoteRuntimeTarget{id, tab.gen, tab.selectionRevision, tab.client, states, maps.Clone(tab.runtimeUnknown)})
|
||||
connections[key] = conn
|
||||
}
|
||||
a.remoteTabMu.Unlock()
|
||||
var firstErr error
|
||||
|
||||
for _, conn := range connections {
|
||||
if err := a.syncRemoteRuntimeConnection(conn); err != nil && firstErr == nil {
|
||||
firstErr = err
|
||||
}
|
||||
}
|
||||
if firstErr != nil {
|
||||
slog.Warn("remote runtime synchronization failed", "source", "serve", "reason", "reconcile", "connections", len(connections), "failures", remoteRuntimeDiagnostics.failures.Add(1))
|
||||
}
|
||||
a.emitRuntimeStateChanged()
|
||||
return a.GetRuntimeStateSnapshot(), firstErr
|
||||
}
|
||||
|
||||
func (a *App) markRemoteRuntimeSyncFailed(targets []remoteRuntimeTarget, failed bool) {
|
||||
a.remoteTabMu.Lock()
|
||||
defer a.remoteTabMu.Unlock()
|
||||
for _, target := range targets {
|
||||
if tab := a.remoteTabs[target.id]; tab != nil && tab.gen == target.gen && tab.client == target.client && tab.selectionRevision == target.selection {
|
||||
tab.runtime.syncFailed = failed
|
||||
if failed {
|
||||
markRemoteRuntimeUnknownLocked(tab, tab.routing.currentPath)
|
||||
for path := range tab.runtimeStates {
|
||||
markRemoteRuntimeUnknownLocked(tab, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (a *App) syncRemoteRuntimeConnection(conn remoteRuntimeConnection) error {
|
||||
syncer := &a.remoteRuntimeSync
|
||||
sort.Slice(conn.targets, func(i, j int) bool { return conn.targets[i].id < conn.targets[j].id })
|
||||
first := conn.targets[0]
|
||||
capabilityKey := fmt.Sprintf("%s\x00%s\x00%d\x00%p", conn.key, first.id, first.gen, first.client)
|
||||
syncer.mu.Lock()
|
||||
unsupported := syncer.unsupported[conn.key] == capabilityKey
|
||||
syncer.mu.Unlock()
|
||||
if unsupported {
|
||||
_, err := a.RemoteTabStatus(conn.id)
|
||||
a.markRemoteRuntimeSyncFailed(conn.targets, err != nil)
|
||||
return err
|
||||
}
|
||||
ctx, cancel := context.WithTimeout(a.bootContext(), 5*time.Second)
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, serveURL(conn.base, "/runtime-states"), nil)
|
||||
if err != nil {
|
||||
cancel()
|
||||
a.markRemoteRuntimeSyncFailed(conn.targets, true)
|
||||
return err
|
||||
}
|
||||
resp, err := conn.client.Do(req)
|
||||
if err != nil {
|
||||
cancel()
|
||||
a.markRemoteRuntimeSyncFailed(conn.targets, true)
|
||||
return err
|
||||
}
|
||||
if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusNotImplemented {
|
||||
resp.Body.Close()
|
||||
cancel()
|
||||
syncer.mu.Lock()
|
||||
syncer.unsupported[conn.key] = capabilityKey
|
||||
syncer.mu.Unlock()
|
||||
_, err := a.RemoteTabStatus(conn.id)
|
||||
a.markRemoteRuntimeSyncFailed(conn.targets, err != nil)
|
||||
return err
|
||||
}
|
||||
var payload struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
Sessions []struct {
|
||||
SessionPath string `json:"sessionPath"`
|
||||
State json.RawMessage `json:"state"`
|
||||
} `json:"sessions"`
|
||||
}
|
||||
err = json.NewDecoder(http.MaxBytesReader(nil, resp.Body, 4<<20)).Decode(&payload)
|
||||
resp.Body.Close()
|
||||
cancel()
|
||||
if err != nil || resp.StatusCode != http.StatusOK || payload.SchemaVersion != 1 || payload.Sessions == nil {
|
||||
a.markRemoteRuntimeSyncFailed(conn.targets, true)
|
||||
return fmt.Errorf("runtime state synchronization failed")
|
||||
}
|
||||
states := make(map[string]event.RuntimeStateSnapshot, len(payload.Sessions))
|
||||
for _, session := range payload.Sessions {
|
||||
state, decodeErr := decodeRemoteRuntimeState(session.State)
|
||||
_, duplicate := states[session.SessionPath]
|
||||
if decodeErr != nil || session.SessionPath == "" || duplicate {
|
||||
a.markRemoteRuntimeSyncFailed(conn.targets, true)
|
||||
return fmt.Errorf("invalid runtime state synchronization payload")
|
||||
}
|
||||
states[session.SessionPath] = state
|
||||
}
|
||||
a.applyRemoteRuntimeSnapshot(conn, states)
|
||||
return nil
|
||||
}
|
||||
|
||||
func (a *App) applyRemoteRuntimeSnapshot(conn remoteRuntimeConnection, states map[string]event.RuntimeStateSnapshot) {
|
||||
a.remoteTabMu.Lock()
|
||||
defer a.remoteTabMu.Unlock()
|
||||
for _, target := range conn.targets {
|
||||
tab := a.remoteTabs[target.id]
|
||||
if tab == nil || tab.base != conn.base || tab.gen != target.gen || tab.client != target.client || tab.selectionRevision != target.selection || tab.routing.rehydratingPath != "" {
|
||||
continue
|
||||
}
|
||||
tab.runtime.syncFailed = false
|
||||
seen := map[string]bool{}
|
||||
for path, state := range states {
|
||||
seen[path] = true
|
||||
// A failure or malformed frame observed after this GET started
|
||||
// requires a fresh request, even when the cached facts are unchanged.
|
||||
if tab.runtimeUnknown[path] != target.unknown[path] {
|
||||
continue
|
||||
}
|
||||
previous := tab.runtimeStates[path]
|
||||
// A GET can establish an epoch only if no newer source update
|
||||
// has changed this binding while it was in flight.
|
||||
if previous.RuntimeEpoch != state.RuntimeEpoch && previous != target.states[path] {
|
||||
continue
|
||||
}
|
||||
acceptRemoteRuntimeStateLocked(tab, path, state, true)
|
||||
}
|
||||
if !seen[tab.routing.currentPath] && tab.runtimeStates[tab.routing.currentPath] == target.states[tab.routing.currentPath] {
|
||||
markRemoteRuntimeUnknownLocked(tab, tab.routing.currentPath)
|
||||
}
|
||||
for path, state := range tab.runtimeStates {
|
||||
if !seen[path] && path != tab.routing.currentPath && state == target.states[path] && tab.runtimeUnknown[path] == target.unknown[path] {
|
||||
delete(tab.runtimeStates, path)
|
||||
delete(tab.runtimeUnknown, path)
|
||||
delete(tab.routing.running, path)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func markRemoteRuntimeUnknownLocked(tab *remoteTab, path string) {
|
||||
if tab.runtimeUnknown == nil {
|
||||
tab.runtimeUnknown = map[string]uint64{}
|
||||
}
|
||||
tab.runtime.revision++
|
||||
tab.runtimeUnknown[path] = tab.runtime.revision
|
||||
}
|
||||
@@ -0,0 +1,429 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/event"
|
||||
)
|
||||
|
||||
const runtimeRemoteTestPath = "/sessions/current.jsonl"
|
||||
|
||||
func remoteRuntimeTestSnapshot(epoch string, revision uint64, phase string) event.RuntimeStateSnapshot {
|
||||
return event.RuntimeStateSnapshot{SchemaVersion: 1, RuntimeEpoch: epoch, Revision: revision, Phase: phase,
|
||||
Running: phase == "executing" || phase == "finishing", Cancellable: phase == "executing"}
|
||||
}
|
||||
|
||||
func remoteRuntimeTestApp(client *http.Client) (*App, *remoteTab) {
|
||||
tab := &remoteTab{id: "remote-runtime", state: "ready", gen: 7, selectionRevision: 3,
|
||||
client: client, base: "http://runtime-fixture.invalid", ref: RemoteTabRef{HostID: "fixture-host", Workspace: "/workspace"},
|
||||
session: remoteTabSessionState{name: "current", path: runtimeRemoteTestPath},
|
||||
routing: remoteTabSessionRouting{currentPath: runtimeRemoteTestPath, running: map[string]bool{}},
|
||||
}
|
||||
return &App{remoteTabs: map[string]*remoteTab{tab.id: tab}}, tab
|
||||
}
|
||||
|
||||
func remoteRuntimeTestResponse(req *http.Request, code int, body string) *http.Response {
|
||||
return &http.Response{StatusCode: code, Header: make(http.Header), Body: io.NopCloser(strings.NewReader(body)), Request: req}
|
||||
}
|
||||
|
||||
func remoteRuntimeTestJSON(t *testing.T, value any) string {
|
||||
t.Helper()
|
||||
raw, err := json.Marshal(value)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return string(raw)
|
||||
}
|
||||
|
||||
func remoteRuntimeTestPayload(t *testing.T, state event.RuntimeStateSnapshot) string {
|
||||
return remoteRuntimeTestJSON(t, map[string]any{"schemaVersion": 1, "sessions": []any{map[string]any{"sessionPath": runtimeRemoteTestPath, "state": state}}})
|
||||
}
|
||||
|
||||
func awaitRemoteRuntimeSync(t *testing.T, result <-chan error) {
|
||||
t.Helper()
|
||||
select {
|
||||
case err := <-result:
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("runtime synchronization did not complete")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteRuntimeStateReducerOrdersAndFencesInstances(t *testing.T) {
|
||||
_, tab := remoteRuntimeTestApp(nil)
|
||||
initial := remoteRuntimeTestSnapshot("epoch-a", 3, "executing")
|
||||
if !acceptRemoteRuntimeStateLocked(tab, runtimeRemoteTestPath, initial, true) {
|
||||
t.Fatal("authoritative binding rejected")
|
||||
}
|
||||
hostRevision := tab.runtime.revision
|
||||
for _, state := range []event.RuntimeStateSnapshot{
|
||||
initial, remoteRuntimeTestSnapshot("epoch-a", 2, "idle"),
|
||||
remoteRuntimeTestSnapshot("epoch-a", 3, "idle"), remoteRuntimeTestSnapshot("epoch-b", 9, "idle"),
|
||||
} {
|
||||
if acceptRemoteRuntimeStateLocked(tab, runtimeRemoteTestPath, state, false) {
|
||||
t.Fatalf("accepted duplicate/stale/conflicting/unbound state: %+v", state)
|
||||
}
|
||||
if tab.runtime.snapshot != initial || tab.runtime.revision != hostRevision {
|
||||
t.Fatalf("rejected state mutated projection: %+v", tab.runtime)
|
||||
}
|
||||
}
|
||||
newer := remoteRuntimeTestSnapshot("epoch-a", 4, "idle")
|
||||
if !acceptRemoteRuntimeStateLocked(tab, runtimeRemoteTestPath, newer, false) || tab.runtime.running {
|
||||
t.Fatal("newer idle did not clear running")
|
||||
}
|
||||
background := remoteRuntimeTestSnapshot("background", 1, "executing")
|
||||
if !acceptRemoteRuntimeStateLocked(tab, "/sessions/background.jsonl", background, true) {
|
||||
t.Fatal("background instance registration failed")
|
||||
}
|
||||
if tab.runtime.snapshot != newer || !tab.routing.running["/sessions/background.jsonl"] {
|
||||
t.Fatal("background runtime replaced selected session or failed to aggregate")
|
||||
}
|
||||
replacement := remoteRuntimeTestSnapshot("epoch-b", 1, "idle")
|
||||
if !acceptRemoteRuntimeStateLocked(tab, runtimeRemoteTestPath, replacement, true) || tab.runtime.snapshot != replacement {
|
||||
t.Fatal("authoritative new epoch was rejected")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteRuntimeStateGETCannotOverwriteNewerSSE(t *testing.T) {
|
||||
for _, epochChanged := range []bool{false, true} {
|
||||
name := "same-epoch"
|
||||
if epochChanged {
|
||||
name = "new-epoch"
|
||||
}
|
||||
t.Run(name, func(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
entered, release := make(chan struct{}), make(chan struct{})
|
||||
releaseGET := sync.OnceFunc(func() { close(release) })
|
||||
defer releaseGET()
|
||||
response := remoteRuntimeTestSnapshot("epoch-a", 4, "finishing")
|
||||
if epochChanged {
|
||||
response = remoteRuntimeTestSnapshot("epoch-b", 1, "idle")
|
||||
}
|
||||
body := remoteRuntimeTestPayload(t, response)
|
||||
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
close(entered)
|
||||
<-release
|
||||
return remoteRuntimeTestResponse(req, 200, body), nil
|
||||
})}
|
||||
a, tab := remoteRuntimeTestApp(client)
|
||||
acceptRemoteRuntimeStateLocked(tab, runtimeRemoteTestPath, remoteRuntimeTestSnapshot("epoch-a", 2, "executing"), true)
|
||||
result := make(chan error, 1)
|
||||
go func() { _, err := a.SyncRuntimeState(); result <- err }()
|
||||
select {
|
||||
case <-entered:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("GET did not start")
|
||||
}
|
||||
newer := remoteRuntimeTestSnapshot("epoch-a", 5, "idle")
|
||||
frame := json.RawMessage(remoteRuntimeTestJSON(t, map[string]any{"runtimeState": newer}))
|
||||
a.acceptRemoteRuntimeFrame(tab.id, tab.gen, runtimeRemoteTestPath, frame)
|
||||
releaseGET()
|
||||
awaitRemoteRuntimeSync(t, result)
|
||||
if got := tab.runtime.snapshot; got != newer {
|
||||
t.Fatalf("late GET overwrote newer SSE: got=%+v want=%+v", got, newer)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteRuntimeStateGETRejectsChangedSelectionAndGeneration(t *testing.T) {
|
||||
for _, fence := range []string{"generation", "selection", "client", "rehydration"} {
|
||||
t.Run(fence, func(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
entered, release := make(chan struct{}), make(chan struct{})
|
||||
releaseGET := sync.OnceFunc(func() { close(release) })
|
||||
defer releaseGET()
|
||||
body := remoteRuntimeTestPayload(t, remoteRuntimeTestSnapshot("replacement", 9, "executing"))
|
||||
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
close(entered)
|
||||
<-release
|
||||
return remoteRuntimeTestResponse(req, 200, body), nil
|
||||
})}
|
||||
a, tab := remoteRuntimeTestApp(client)
|
||||
initial := remoteRuntimeTestSnapshot("initial", 1, "idle")
|
||||
acceptRemoteRuntimeStateLocked(tab, runtimeRemoteTestPath, initial, true)
|
||||
result := make(chan error, 1)
|
||||
go func() { _, err := a.SyncRuntimeState(); result <- err }()
|
||||
select {
|
||||
case <-entered:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("GET did not start")
|
||||
}
|
||||
a.remoteTabMu.Lock()
|
||||
switch fence {
|
||||
case "generation":
|
||||
tab.gen++
|
||||
case "selection":
|
||||
tab.selectionRevision++
|
||||
case "client":
|
||||
tab.client = &http.Client{}
|
||||
case "rehydration":
|
||||
tab.routing.rehydratingPath = "/sessions/next.jsonl"
|
||||
}
|
||||
a.remoteTabMu.Unlock()
|
||||
releaseGET()
|
||||
awaitRemoteRuntimeSync(t, result)
|
||||
if tab.runtime.snapshot != initial {
|
||||
t.Fatalf("stale %s request changed runtime: %+v", fence, tab.runtime.snapshot)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteRuntimeStateSSERejectsOldPumpAndDuplicate(t *testing.T) {
|
||||
a, tab := remoteRuntimeTestApp(nil)
|
||||
initial := remoteRuntimeTestSnapshot("epoch", 2, "executing")
|
||||
acceptRemoteRuntimeStateLocked(tab, runtimeRemoteTestPath, initial, true)
|
||||
var events atomic.Int32
|
||||
a.remoteEventHook = func(string, any) { events.Add(1) }
|
||||
for _, fixture := range []struct {
|
||||
gen uint64
|
||||
state event.RuntimeStateSnapshot
|
||||
}{
|
||||
{tab.gen - 1, remoteRuntimeTestSnapshot("epoch", 3, "idle")}, {tab.gen, initial},
|
||||
} {
|
||||
frame := json.RawMessage(remoteRuntimeTestJSON(t, map[string]any{"runtimeState": fixture.state}))
|
||||
a.acceptRemoteRuntimeFrame(tab.id, fixture.gen, runtimeRemoteTestPath, frame)
|
||||
}
|
||||
if tab.runtime.snapshot != initial || events.Load() != 0 {
|
||||
t.Fatalf("old/duplicate frame mutated state or notified: state=%+v events=%d", tab.runtime.snapshot, events.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteRuntimeStateLegacy404CachedForConnection(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
var probes, statuses atomic.Int32
|
||||
statusBody := remoteRuntimeTestJSON(t, map[string]any{"sessionPath": runtimeRemoteTestPath, "running": false})
|
||||
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.Path == "/runtime-states" {
|
||||
probes.Add(1)
|
||||
return remoteRuntimeTestResponse(req, 404, "not supported"), nil
|
||||
}
|
||||
if req.URL.Path == "/status" {
|
||||
statuses.Add(1)
|
||||
return remoteRuntimeTestResponse(req, 200, statusBody), nil
|
||||
}
|
||||
return nil, errors.New("unexpected runtime fallback endpoint")
|
||||
})}
|
||||
a, _ := remoteRuntimeTestApp(client)
|
||||
for range 2 {
|
||||
if _, err := a.SyncRuntimeState(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
if probes.Load() != 1 || statuses.Load() != 2 {
|
||||
t.Fatalf("legacy capability fallback count probes=%d statuses=%d", probes.Load(), statuses.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteRuntimeStateReconnectProbesCapabilityAgain(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
var probes atomic.Int32
|
||||
updated := remoteRuntimeTestSnapshot("new-server", 1, "idle")
|
||||
body := remoteRuntimeTestPayload(t, updated)
|
||||
statusBody := remoteRuntimeTestJSON(t, map[string]any{"sessionPath": runtimeRemoteTestPath, "running": false})
|
||||
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.Path == "/runtime-states" {
|
||||
if probes.Add(1) == 1 {
|
||||
return remoteRuntimeTestResponse(req, 404, "old server"), nil
|
||||
}
|
||||
return remoteRuntimeTestResponse(req, 200, body), nil
|
||||
}
|
||||
return remoteRuntimeTestResponse(req, 200, statusBody), nil
|
||||
})}
|
||||
a, tab := remoteRuntimeTestApp(client)
|
||||
if _, err := a.SyncRuntimeState(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
a.remoteTabMu.Lock()
|
||||
tab.gen++
|
||||
a.remoteTabMu.Unlock()
|
||||
if _, err := a.SyncRuntimeState(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if probes.Load() != 2 || tab.runtime.snapshot != updated {
|
||||
t.Fatalf("new connection inherited old capability rejection: probes=%d state=%+v", probes.Load(), tab.runtime.snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteRuntimeFollowupUnknownPOSTLooksUpReceiptWithoutReplay(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
var posts, lookups atomic.Int32
|
||||
var posted map[string]json.RawMessage
|
||||
receiptBody := remoteRuntimeTestJSON(t, map[string]any{"itemId": "accepted-item", "disposition": "queued"})
|
||||
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.Method == http.MethodPost && req.URL.Path == "/inbox/items" {
|
||||
posts.Add(1)
|
||||
if err := json.NewDecoder(req.Body).Decode(&posted); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return nil, errors.New("response lost after durable accept")
|
||||
}
|
||||
if req.Method == http.MethodGet && req.URL.Path == "/inbox/receipt" {
|
||||
lookups.Add(1)
|
||||
if req.URL.Query().Get("key") != "stable-key" || req.URL.Query().Get("session") != runtimeRemoteTestPath {
|
||||
return nil, errors.New("receipt lookup lost original session/key")
|
||||
}
|
||||
return remoteRuntimeTestResponse(req, 200, receiptBody), nil
|
||||
}
|
||||
return nil, errors.New("unexpected followup request")
|
||||
})}
|
||||
a, tab := remoteRuntimeTestApp(client)
|
||||
invocations := []InvocationRequest{{Name: "fixture-skill", Kind: "skill", Offset: 0}}
|
||||
receipt, err := a.enqueueRemoteFollowup(tab.id, "rich display", "model input", invocations, "stable-key")
|
||||
if err != nil || receipt.ItemID != "accepted-item" || posts.Load() != 1 || lookups.Load() != 1 {
|
||||
t.Fatalf("unknown write was lost or replayed: receipt=%+v err=%v posts=%d lookups=%d", receipt, err, posts.Load(), lookups.Load())
|
||||
}
|
||||
var display, input, key string
|
||||
var gotInvocations []InvocationRequest
|
||||
_ = json.Unmarshal(posted["display"], &display)
|
||||
_ = json.Unmarshal(posted["input"], &input)
|
||||
_ = json.Unmarshal(posted["idempotencyKey"], &key)
|
||||
_ = json.Unmarshal(posted["invocations"], &gotInvocations)
|
||||
if display != "rich display" || input != "model input" || key != "stable-key" || !reflect.DeepEqual(gotInvocations, invocations) {
|
||||
t.Fatalf("rich followup changed: %s", remoteRuntimeTestJSON(t, posted))
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteRuntimeFollowupRejectedPOSTCannotReuseOlderReceipt(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
var lookups atomic.Int32
|
||||
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.Method == http.MethodPost {
|
||||
return remoteRuntimeTestResponse(req, http.StatusConflict, "idempotency conflict"), nil
|
||||
}
|
||||
lookups.Add(1)
|
||||
return remoteRuntimeTestResponse(req, http.StatusOK, `{"itemId":"older-request"}`), nil
|
||||
})}
|
||||
a, tab := remoteRuntimeTestApp(client)
|
||||
receipt, err := a.enqueueRemoteFollowup(tab.id, "changed draft", "changed input", nil, "reused-key")
|
||||
if err == nil || receipt.ItemID != "" || lookups.Load() != 0 {
|
||||
t.Fatalf("definite rejection adopted unrelated receipt: receipt=%+v err=%v lookups=%d", receipt, err, lookups.Load())
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteRuntimeStateDisconnectPreservesWorkAndReconnectAdoptsEpoch(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
reconnected := remoteRuntimeTestSnapshot("restarted-server", 1, "idle")
|
||||
body := remoteRuntimeTestPayload(t, reconnected)
|
||||
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return remoteRuntimeTestResponse(req, 200, body), nil
|
||||
})}
|
||||
a, tab := remoteRuntimeTestApp(client)
|
||||
old := remoteRuntimeTestSnapshot("old-server", 8, "executing")
|
||||
old.BackgroundJobs = 1
|
||||
acceptRemoteRuntimeStateLocked(tab, runtimeRemoteTestPath, old, true)
|
||||
oldGen, base := tab.gen, tab.base
|
||||
if !a.reconnectRemoteTabGeneration(tab.id, oldGen) {
|
||||
t.Fatal("current pump did not enter reconnecting")
|
||||
}
|
||||
disconnected := a.GetRuntimeStateSnapshot()
|
||||
if len(disconnected.Sessions) != 1 || disconnected.Sessions[0].Freshness != "unknown" || disconnected.Sessions[0].State != old {
|
||||
t.Fatalf("disconnect fabricated completion or trusted stale work: %+v", disconnected)
|
||||
}
|
||||
if _, _, _, err := a.remoteTabCommandTarget(tab.id); err == nil {
|
||||
t.Fatal("disconnected session still accepts commands")
|
||||
}
|
||||
// Complete the authenticated connection generation; the state GET may
|
||||
// establish the new server epoch while old pump frames remain fenced out.
|
||||
a.remoteTabMu.Lock()
|
||||
tab.client, tab.base, tab.state = client, base, "ready"
|
||||
a.remoteTabMu.Unlock()
|
||||
if _, err := a.SyncRuntimeState(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
stale := remoteRuntimeTestSnapshot("old-server", 99, "executing")
|
||||
frame := json.RawMessage(remoteRuntimeTestJSON(t, map[string]any{"runtimeState": stale}))
|
||||
a.acceptRemoteRuntimeFrame(tab.id, oldGen, runtimeRemoteTestPath, frame)
|
||||
current := a.GetRuntimeStateSnapshot()
|
||||
if current.Sessions[0].State != reconnected || current.Sessions[0].Freshness != "synced" {
|
||||
t.Fatalf("reconnect did not converge to new authority: %+v", current)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteRuntimeStateOldSelectionFrameOnlyUpdatesBackground(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
a, tab := remoteRuntimeTestApp(nil)
|
||||
old := remoteRuntimeTestSnapshot("old-selection", 2, "executing")
|
||||
acceptRemoteRuntimeStateLocked(tab, runtimeRemoteTestPath, old, true)
|
||||
nextPath := "/sessions/next.jsonl"
|
||||
next := remoteRuntimeTestSnapshot("new-selection", 1, "executing")
|
||||
a.remoteTabMu.Lock()
|
||||
tab.selectionRevision++
|
||||
commitRemoteTabAttachRoute(tab, nextPath, false)
|
||||
acceptRemoteRuntimeStateLocked(tab, nextPath, next, true)
|
||||
a.remoteTabMu.Unlock()
|
||||
oldCompleted := remoteRuntimeTestSnapshot("old-selection", 3, "idle")
|
||||
frame := json.RawMessage(remoteRuntimeTestJSON(t, map[string]any{"runtimeState": oldCompleted}))
|
||||
a.acceptRemoteRuntimeFrame(tab.id, tab.gen, runtimeRemoteTestPath, frame)
|
||||
if tab.routing.currentPath != nextPath || tab.runtime.snapshot != next {
|
||||
t.Fatalf("old selection frame changed foreground: path=%q state=%+v", tab.routing.currentPath, tab.runtime.snapshot)
|
||||
}
|
||||
if tab.runtimeStates[runtimeRemoteTestPath] != oldCompleted || tab.routing.running[runtimeRemoteTestPath] {
|
||||
t.Fatal("old selection completion was lost from background aggregation")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteRuntimeStatePublicationOrdersGenerationRetirement(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
a, tab := remoteRuntimeTestApp(&http.Client{})
|
||||
initial := remoteRuntimeTestSnapshot("current", 1, "executing")
|
||||
acceptRemoteRuntimeStateLocked(tab, runtimeRemoteTestPath, initial, true)
|
||||
entered, release := make(chan struct{}), make(chan struct{})
|
||||
unblock := sync.OnceFunc(func() { close(release) })
|
||||
defer unblock()
|
||||
a.remoteEventHook = func(name string, _ any) {
|
||||
if name == "remote-tab:updated" {
|
||||
close(entered)
|
||||
<-release
|
||||
}
|
||||
}
|
||||
finished := make(chan struct{})
|
||||
next := remoteRuntimeTestSnapshot("current", 2, "idle")
|
||||
frame := json.RawMessage(remoteRuntimeTestJSON(t, map[string]any{"runtimeState": next}))
|
||||
go func() { a.acceptRemoteRuntimeFrame(tab.id, 7, runtimeRemoteTestPath, frame); close(finished) }()
|
||||
select {
|
||||
case <-entered:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("runtime frame did not reach metadata publication")
|
||||
}
|
||||
attempted, retired := make(chan struct{}), make(chan struct{})
|
||||
go func() { close(attempted); a.reconnectRemoteTabGeneration(tab.id, 7); close(retired) }()
|
||||
<-attempted
|
||||
// Match the existing remote publication regression's interleaving: the
|
||||
// callback is held at a known boundary, while retirement attempts the same
|
||||
// publication fence. This timeout only verifies that it remains blocked.
|
||||
select {
|
||||
case <-retired:
|
||||
t.Fatal("generation retirement overtook in-flight runtime metadata; stale frame can follow the reconnect barrier")
|
||||
case <-time.After(30 * time.Millisecond):
|
||||
}
|
||||
a.remoteTabMu.Lock()
|
||||
intact := tab.gen == 7 && tab.state == "ready"
|
||||
a.remoteTabMu.Unlock()
|
||||
if !intact {
|
||||
t.Fatal("generation changed before prior runtime publication completed")
|
||||
}
|
||||
unblock()
|
||||
select {
|
||||
case <-finished:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("runtime publication did not finish")
|
||||
}
|
||||
select {
|
||||
case <-retired:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("generation retirement did not finish")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,53 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
|
||||
"reasonix/internal/event"
|
||||
)
|
||||
|
||||
// Decode before converting to value fields: missing false/zero facts are not
|
||||
// observations of an idle runtime. Unknown extension fields remain compatible.
|
||||
func decodeRemoteRuntimeState(data json.RawMessage) (event.RuntimeStateSnapshot, error) {
|
||||
var fields map[string]json.RawMessage
|
||||
var state event.RuntimeStateSnapshot
|
||||
if err := json.Unmarshal(data, &fields); err != nil {
|
||||
return state, err
|
||||
}
|
||||
for _, key := range []string{"schemaVersion", "runtimeEpoch", "revision", "phase", "running", "pendingPrompt", "cancelRequested", "cancellable", "backgroundJobs"} {
|
||||
value, found := fields[key]
|
||||
if !found || bytes.Equal(bytes.TrimSpace(value), []byte("null")) {
|
||||
return state, fmt.Errorf("runtime state missing %s", key)
|
||||
}
|
||||
}
|
||||
if err := json.Unmarshal(data, &state); err != nil {
|
||||
return state, err
|
||||
}
|
||||
if !validRuntimeState(state) {
|
||||
return state, fmt.Errorf("invalid runtime state")
|
||||
}
|
||||
return state, nil
|
||||
}
|
||||
|
||||
func (p *remoteTabStatusPayload) UnmarshalJSON(data []byte) error {
|
||||
type plain remoteTabStatusPayload
|
||||
var wire struct {
|
||||
*plain
|
||||
State json.RawMessage `json:"runtimeState"`
|
||||
}
|
||||
wire.plain = (*plain)(p)
|
||||
if err := json.Unmarshal(data, &wire); err != nil {
|
||||
return err
|
||||
}
|
||||
if len(wire.State) == 0 {
|
||||
return nil
|
||||
}
|
||||
state, err := decodeRemoteRuntimeState(wire.State)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
p.RuntimeState = &state
|
||||
return nil
|
||||
}
|
||||
@@ -390,6 +390,10 @@ func (a *App) remoteTabPump(ctx context.Context, tabID string, gen uint64, opene
|
||||
return
|
||||
}
|
||||
kind, framePath, current, reset := probeRemoteTabFrame(frame)
|
||||
if kind == "runtime_state" {
|
||||
a.acceptRemoteRuntimeFrame(tabID, gen, framePath, json.RawMessage(frame))
|
||||
continue
|
||||
}
|
||||
// A takeover notice for the session this tab is viewing flips the
|
||||
// spectator pin live: the entry-time probe only runs when the tab
|
||||
// enters a session, so a mid-view takeover (or its reversal) would
|
||||
|
||||
@@ -13,6 +13,7 @@ import (
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/event"
|
||||
)
|
||||
|
||||
// errRemoteTabStatusSuperseded marks the benign lost race where a /status
|
||||
@@ -208,13 +209,14 @@ func (a *App) reserveRemoteTabStatusSequence(tabID string, client *http.Client,
|
||||
}
|
||||
|
||||
type remoteTabStatusPayload struct {
|
||||
SessionName string `json:"sessionName"`
|
||||
SessionPath string `json:"sessionPath"`
|
||||
Running *bool `json:"running"`
|
||||
PendingPrompt *bool `json:"pendingPrompt"`
|
||||
BackgroundJobs *int `json:"backgroundJobs"`
|
||||
CancelRequested *bool `json:"cancelRequested"`
|
||||
Cancellable *bool `json:"cancellable"`
|
||||
RuntimeState *event.RuntimeStateSnapshot `json:"runtimeState"`
|
||||
SessionName string `json:"sessionName"`
|
||||
SessionPath string `json:"sessionPath"`
|
||||
Running *bool `json:"running"`
|
||||
PendingPrompt *bool `json:"pendingPrompt"`
|
||||
BackgroundJobs *int `json:"backgroundJobs"`
|
||||
CancelRequested *bool `json:"cancelRequested"`
|
||||
Cancellable *bool `json:"cancellable"`
|
||||
// TakenOver reports Serve's single-writer handoff state: a local runtime
|
||||
// on the serve host owns the session and this tab is read-only.
|
||||
TakenOver *bool `json:"takenOver"`
|
||||
@@ -222,9 +224,10 @@ type remoteTabStatusPayload struct {
|
||||
|
||||
func (a *App) recordRemoteTabSessionStatus(tabID string, client *http.Client, gen, statusSeq uint64, status json.RawMessage) bool {
|
||||
var payload remoteTabStatusPayload
|
||||
if gen == 0 || statusSeq == 0 || json.Unmarshal(status, &payload) != nil {
|
||||
if gen == 0 || statusSeq == 0 {
|
||||
return false
|
||||
}
|
||||
decodeErr := json.Unmarshal(status, &payload)
|
||||
a.remoteTabMu.Lock()
|
||||
tab := a.remoteTabs[tabID]
|
||||
a.remoteTabMu.Unlock()
|
||||
@@ -238,6 +241,13 @@ func (a *App) recordRemoteTabSessionStatus(tabID string, client *http.Client, ge
|
||||
a.remoteTabMu.Unlock()
|
||||
return false
|
||||
}
|
||||
if decodeErr != nil {
|
||||
markRemoteRuntimeUnknownLocked(tab, tab.routing.currentPath)
|
||||
a.remoteTabMu.Unlock()
|
||||
a.emitRuntimeStateChanged()
|
||||
a.goRemoteTabSafe("remoteRuntimeSync", func() { _, _ = a.SyncRuntimeState() })
|
||||
return false
|
||||
}
|
||||
// Serve still reports the outgoing foreground until an in-flight /resume
|
||||
// commits. That status is older than the provisional route and must not roll
|
||||
// it back; target SSE frames are already buffering behind its ready barrier.
|
||||
@@ -273,10 +283,22 @@ func (a *App) recordRemoteTabSessionStatus(tabID string, client *http.Client, ge
|
||||
if pathChanged {
|
||||
a.goRemoteTabSafe("remoteTabStatusTitle", func() { a.refreshRemoteTabTitle(tabID) })
|
||||
}
|
||||
a.emitRuntimeStateChanged()
|
||||
return true
|
||||
}
|
||||
|
||||
func applyRemoteTabStatusPayload(tab *remoteTab, payload remoteTabStatusPayload) {
|
||||
if payload.RuntimeState != nil && validRuntimeState(*payload.RuntimeState) {
|
||||
acceptRemoteRuntimeStateLocked(tab, payload.SessionPath, *payload.RuntimeState, true)
|
||||
payload.Running, payload.PendingPrompt, payload.BackgroundJobs, payload.CancelRequested, payload.Cancellable = nil, nil, nil, nil, nil
|
||||
} else if payload.RuntimeState == nil && payload.Running != nil {
|
||||
// An actual legacy status confirms only its selected session. Never
|
||||
// leave a previous schema-1 observation shadowing these legacy facts.
|
||||
delete(tab.runtimeStates, tab.routing.currentPath)
|
||||
delete(tab.runtimeUnknown, tab.routing.currentPath)
|
||||
tab.runtime.snapshot = event.RuntimeStateSnapshot{}
|
||||
tab.runtime.syncFailed = false
|
||||
}
|
||||
if name := strings.TrimSpace(payload.SessionName); name != "" {
|
||||
tab.session.name = name
|
||||
tab.session.newSession = false
|
||||
|
||||
@@ -0,0 +1,254 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"reflect"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
)
|
||||
|
||||
type RuntimeSessionState struct {
|
||||
TabID string `json:"tabId"`
|
||||
Scope string `json:"scope"`
|
||||
WorkspaceRoot string `json:"workspaceRoot"`
|
||||
TopicID string `json:"topicId"`
|
||||
SessionPath string `json:"sessionPath"`
|
||||
SessionGeneration uint64 `json:"sessionGeneration"`
|
||||
Open bool `json:"open"`
|
||||
Remote bool `json:"remote"`
|
||||
HostID string `json:"hostId,omitempty"`
|
||||
Freshness string `json:"freshness"`
|
||||
State event.RuntimeStateSnapshot `json:"state"`
|
||||
}
|
||||
|
||||
type RuntimeStateProjection struct {
|
||||
Epoch string `json:"epoch"`
|
||||
Revision uint64 `json:"revision"`
|
||||
Sessions []RuntimeSessionState `json:"sessions"`
|
||||
Topics []ProjectRuntimeTopic `json:"topics"`
|
||||
}
|
||||
|
||||
type desktopRuntimeProjection struct {
|
||||
mu sync.Mutex
|
||||
snapshot RuntimeStateProjection
|
||||
}
|
||||
|
||||
type localRuntimeBindingKey struct {
|
||||
key string
|
||||
open bool
|
||||
}
|
||||
|
||||
type localRuntimeBinding struct {
|
||||
tab *WorkspaceTab
|
||||
view RuntimeSessionState
|
||||
ctrl control.SessionAPI
|
||||
catalog catalogRuntimeSnapshot
|
||||
}
|
||||
|
||||
// localRuntimeBindingsLocked copies identity and display metadata as one
|
||||
// binding. App.mu must be held; controller methods must stay outside that lock.
|
||||
func (a *App) localRuntimeBindingsLocked() map[localRuntimeBindingKey]localRuntimeBinding {
|
||||
bindings := make(map[localRuntimeBindingKey]localRuntimeBinding, len(a.tabs)+len(a.detachedSessions))
|
||||
collect := func(key string, tab *WorkspaceTab, open bool) {
|
||||
if tab == nil {
|
||||
return
|
||||
}
|
||||
bindings[localRuntimeBindingKey{key, open}] = localRuntimeBinding{tab: tab, ctrl: tab.Ctrl,
|
||||
view: RuntimeSessionState{TabID: tab.ID, Scope: tab.Scope, WorkspaceRoot: tab.WorkspaceRoot,
|
||||
TopicID: tab.TopicID, SessionPath: tab.SessionPath, SessionGeneration: tab.SessionGeneration, Open: open, Freshness: "synced"},
|
||||
catalog: catalogRuntimeSnapshot{scope: tab.Scope, workspaceRoot: tab.WorkspaceRoot, topicID: tab.TopicID, sessionPath: tab.SessionPath,
|
||||
activity: tab.ActivityStatus, topicTitle: tab.TopicTitle, topicTitleSource: tab.topicTitleSource, open: open}}
|
||||
}
|
||||
for key, tab := range a.tabs {
|
||||
collect(key, tab, true)
|
||||
}
|
||||
for key, tab := range a.detachedSessions {
|
||||
collect(key, tab, false)
|
||||
}
|
||||
return bindings
|
||||
}
|
||||
|
||||
func (a *App) sampleLocalRuntimeBindings() []localRuntimeBinding {
|
||||
for {
|
||||
a.mu.RLock()
|
||||
bindings := a.localRuntimeBindingsLocked()
|
||||
a.mu.RUnlock()
|
||||
states := make(map[localRuntimeBindingKey]event.RuntimeStateSnapshot, len(bindings))
|
||||
for key, binding := range bindings {
|
||||
states[key] = controllerRuntimeState(binding.ctrl)
|
||||
}
|
||||
// A controller can rotate its session, be replaced, or move between
|
||||
// open and detached while sampled. Retry the binding set so its state
|
||||
// cannot be published under the previous session identity.
|
||||
a.mu.RLock()
|
||||
current := a.localRuntimeBindingsLocked()
|
||||
valid := len(current) == len(bindings)
|
||||
for key, binding := range bindings {
|
||||
if current[key] != binding {
|
||||
valid = false
|
||||
break
|
||||
}
|
||||
}
|
||||
a.mu.RUnlock()
|
||||
if !valid {
|
||||
continue
|
||||
}
|
||||
result := make([]localRuntimeBinding, 0, len(bindings))
|
||||
for key, binding := range bindings {
|
||||
binding.view.State = states[key]
|
||||
result = append(result, binding)
|
||||
}
|
||||
return result
|
||||
}
|
||||
}
|
||||
|
||||
func controllerRuntimeState(ctrl control.SessionAPI) event.RuntimeStateSnapshot {
|
||||
if reader, ok := ctrl.(control.RuntimeStateReader); ok {
|
||||
return reader.RuntimeStateSnapshot()
|
||||
}
|
||||
if ctrl == nil {
|
||||
return event.RuntimeStateSnapshot{Phase: "idle"}
|
||||
}
|
||||
legacy := ctrl.RuntimeStatus()
|
||||
phase := "idle"
|
||||
if legacy.Running {
|
||||
phase = "executing"
|
||||
}
|
||||
return event.RuntimeStateSnapshot{Phase: phase, Running: legacy.Running, PendingPrompt: legacy.PendingPrompt,
|
||||
BackgroundJobs: legacy.BackgroundJobs, Cancellable: legacy.Cancellable, CancelRequested: legacy.CancelRequested,
|
||||
TurnID: legacy.TurnID, TurnStatus: legacy.Status, TurnEventSeq: legacy.TurnEventSeq}
|
||||
}
|
||||
|
||||
func runtimeDisplayStatus(state event.RuntimeStateSnapshot, result string) string {
|
||||
switch {
|
||||
case state.Phase == "finishing":
|
||||
return "finishing"
|
||||
case state.CancelRequested:
|
||||
return "cancelling"
|
||||
case state.PendingPrompt:
|
||||
return topicStatusWaitingConfirmation
|
||||
case state.Phase == "executing":
|
||||
if state.Activity == "streaming" {
|
||||
return topicStatusStreaming
|
||||
}
|
||||
return topicStatusThinking
|
||||
case state.BackgroundJobs > 0:
|
||||
return topicStatusBackgroundJob
|
||||
}
|
||||
if result == topicStatusError || result == topicStatusPaused || result == topicStatusAwaitingDelivery {
|
||||
return result
|
||||
}
|
||||
return ""
|
||||
}
|
||||
|
||||
func catalogControllerStatus(ctrl control.SessionAPI, activity string) (string, bool) {
|
||||
state := controllerRuntimeState(ctrl)
|
||||
return catalogStateStatus(state, activity)
|
||||
}
|
||||
|
||||
func catalogStateStatus(state event.RuntimeStateSnapshot, activity string) (string, bool) {
|
||||
if state.SchemaVersion == 1 {
|
||||
return runtimeDisplayStatus(state, activity), state.ActiveWork()
|
||||
}
|
||||
legacy := control.RuntimeStatus{Running: state.Running, PendingPrompt: state.PendingPrompt, BackgroundJobs: state.BackgroundJobs}
|
||||
status := catalogRuntimeStatus(activity, legacy)
|
||||
return status, status != "" || state.ActiveWork()
|
||||
}
|
||||
|
||||
// GetRuntimeStateSnapshot reads committed controller snapshots after copying
|
||||
// bindings off App.mu. A projection revision is allocated together with content.
|
||||
func (a *App) GetRuntimeStateSnapshot() RuntimeStateProjection {
|
||||
r := &a.runtimeStateProjection
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
bindings := a.sampleLocalRuntimeBindings()
|
||||
next := RuntimeStateProjection{Epoch: r.snapshot.Epoch, Sessions: []RuntimeSessionState{}}
|
||||
catalog := []catalogRuntimeSnapshot{}
|
||||
if next.Epoch == "" {
|
||||
next.Epoch = newSessionRuntimeID("projection")
|
||||
}
|
||||
for _, binding := range bindings {
|
||||
view := binding.view
|
||||
next.Sessions = append(next.Sessions, view)
|
||||
if binding.catalog.topicID != "" {
|
||||
entry := binding.catalog
|
||||
entry.state = &view.State
|
||||
catalog = append(catalog, entry)
|
||||
}
|
||||
}
|
||||
next.Topics = a.projectTreeRuntimeTopics(catalog)
|
||||
a.remoteTabMu.Lock()
|
||||
for _, tab := range a.remoteTabs {
|
||||
freshness := "synced"
|
||||
if tab.state != "ready" || tab.session.takenOver || tab.runtime.syncFailed || tab.runtimeUnknown[tab.routing.currentPath] != 0 {
|
||||
freshness = "unknown"
|
||||
}
|
||||
state := tab.runtimeStates[tab.routing.currentPath]
|
||||
if state.SchemaVersion == 0 {
|
||||
state = event.RuntimeStateSnapshot{Phase: "idle", Running: tab.runtime.running, PendingPrompt: tab.runtime.pendingPrompt,
|
||||
BackgroundJobs: tab.runtime.backgroundJobs, Cancellable: tab.runtime.cancellable, CancelRequested: tab.runtime.cancelRequested}
|
||||
if state.Running {
|
||||
state.Phase = "executing"
|
||||
}
|
||||
}
|
||||
next.Sessions = append(next.Sessions, RuntimeSessionState{TabID: tab.id, Scope: "remote", HostID: tab.ref.HostID, WorkspaceRoot: tab.ref.Workspace,
|
||||
SessionPath: tab.routing.currentPath, Open: true, Remote: true, Freshness: freshness, State: state})
|
||||
for path, background := range tab.runtimeStates {
|
||||
if path == tab.routing.currentPath {
|
||||
continue
|
||||
}
|
||||
freshness := "synced"
|
||||
if tab.state != "ready" || tab.session.takenOver || tab.runtime.syncFailed || tab.runtimeUnknown[path] != 0 {
|
||||
freshness = "unknown"
|
||||
}
|
||||
next.Sessions = append(next.Sessions, RuntimeSessionState{TabID: tab.id, Scope: "remote", HostID: tab.ref.HostID, WorkspaceRoot: tab.ref.Workspace,
|
||||
SessionPath: path, Remote: true, Freshness: freshness, State: background})
|
||||
}
|
||||
}
|
||||
a.remoteTabMu.Unlock()
|
||||
sort.Slice(next.Sessions, func(i, j int) bool {
|
||||
if next.Sessions[i].TabID == next.Sessions[j].TabID {
|
||||
return next.Sessions[i].SessionPath < next.Sessions[j].SessionPath
|
||||
}
|
||||
return next.Sessions[i].TabID < next.Sessions[j].TabID
|
||||
})
|
||||
next.Revision = r.snapshot.Revision
|
||||
if !reflect.DeepEqual(next, r.snapshot) {
|
||||
next.Revision++
|
||||
r.snapshot = next
|
||||
}
|
||||
result := r.snapshot
|
||||
result.Sessions = append([]RuntimeSessionState{}, result.Sessions...)
|
||||
result.Topics = cloneRuntimeTopics(result.Topics)
|
||||
return result
|
||||
}
|
||||
|
||||
func (a *App) emitRuntimeStateChanged() {
|
||||
if a != nil {
|
||||
a.emitRuntimeEvent("runtime-state:changed", a.GetRuntimeStateSnapshot())
|
||||
}
|
||||
}
|
||||
|
||||
func (s *tabEventSink) RuntimeStateChanged(snapshot event.RuntimeStateSnapshot) {
|
||||
id, app := s.binding()
|
||||
if app == nil {
|
||||
return
|
||||
}
|
||||
app.mu.RLock()
|
||||
tab := app.tabByEventSinkIDLocked(id)
|
||||
var ctrl control.SessionAPI
|
||||
if tab != nil {
|
||||
ctrl = tab.Ctrl
|
||||
}
|
||||
app.mu.RUnlock()
|
||||
if ctrl == nil {
|
||||
return
|
||||
}
|
||||
current := controllerRuntimeState(ctrl)
|
||||
if current.RuntimeEpoch != snapshot.RuntimeEpoch || current.Revision > snapshot.Revision {
|
||||
return
|
||||
}
|
||||
app.emitProjectTreeRuntimeChangedWithLegacy()
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
)
|
||||
|
||||
// The gate pauses a real projection read after App bindings were copied. Its
|
||||
// result may then belong to a controller whose session was rotated meanwhile.
|
||||
type bindingRuntimeReader struct {
|
||||
control.SessionAPI
|
||||
mu sync.Mutex
|
||||
state event.RuntimeStateSnapshot
|
||||
entered chan struct{}
|
||||
release chan struct{}
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (r *bindingRuntimeReader) RuntimeStateSnapshot() event.RuntimeStateSnapshot {
|
||||
r.once.Do(func() {
|
||||
if r.entered != nil {
|
||||
close(r.entered)
|
||||
<-r.release
|
||||
}
|
||||
})
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
return r.state
|
||||
}
|
||||
|
||||
func TestRuntimeStateProjectionRevalidatesLocalBindingAfterSampling(t *testing.T) {
|
||||
for _, mutation := range []string{"controller", "generation", "path", "scope", "tab", "detach", "close"} {
|
||||
t.Run(mutation, func(t *testing.T) {
|
||||
old := &bindingRuntimeReader{state: event.RuntimeStateSnapshot{SchemaVersion: 1, RuntimeEpoch: "old", Revision: 1, Phase: "executing", Running: true},
|
||||
entered: make(chan struct{}), release: make(chan struct{})}
|
||||
nextState := event.RuntimeStateSnapshot{SchemaVersion: 1, RuntimeEpoch: "new", Revision: 2, Phase: "idle"}
|
||||
next := &bindingRuntimeReader{state: nextState}
|
||||
tab := &WorkspaceTab{ID: "binding", Scope: "global", SessionPath: "/old.jsonl", SessionGeneration: 1, Ctrl: old}
|
||||
a := &App{tabs: map[string]*WorkspaceTab{tab.ID: tab}, detachedSessions: map[string]*WorkspaceTab{}}
|
||||
done := make(chan RuntimeStateProjection, 1)
|
||||
go func() { done <- a.GetRuntimeStateSnapshot() }()
|
||||
select {
|
||||
case <-old.entered:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("projection did not reach controller read")
|
||||
}
|
||||
// Acquiring App.mu here is also the deterministic proof that the
|
||||
// runtime reader never runs while holding the application lock.
|
||||
a.mu.Lock()
|
||||
switch mutation {
|
||||
case "controller":
|
||||
tab.Ctrl = next
|
||||
case "generation":
|
||||
tab.SessionGeneration++
|
||||
case "path":
|
||||
tab.SessionPath = "/new.jsonl"
|
||||
case "scope":
|
||||
tab.Scope, tab.WorkspaceRoot = "project", "/workspace"
|
||||
case "tab":
|
||||
tab = &WorkspaceTab{ID: tab.ID, Scope: "global", SessionPath: "/new.jsonl", SessionGeneration: 2, Ctrl: next}
|
||||
a.tabs[tab.ID] = tab
|
||||
case "detach":
|
||||
delete(a.tabs, tab.ID)
|
||||
a.detachedSessions[tab.SessionPath] = tab
|
||||
case "close":
|
||||
delete(a.tabs, tab.ID)
|
||||
}
|
||||
old.mu.Lock()
|
||||
if mutation != "controller" && mutation != "tab" {
|
||||
old.state = nextState
|
||||
}
|
||||
old.mu.Unlock()
|
||||
wantPath, wantGeneration := tab.SessionPath, tab.SessionGeneration
|
||||
wantScope, wantRoot := tab.Scope, tab.WorkspaceRoot
|
||||
a.mu.Unlock()
|
||||
close(old.release)
|
||||
var got RuntimeStateProjection
|
||||
select {
|
||||
case got = <-done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("projection did not finish after binding replacement")
|
||||
}
|
||||
if mutation == "close" {
|
||||
if len(got.Sessions) != 0 {
|
||||
t.Fatalf("closed binding leaked into projection: %+v", got.Sessions)
|
||||
}
|
||||
return
|
||||
}
|
||||
if len(got.Sessions) != 1 {
|
||||
t.Fatalf("expected one current binding: %+v", got.Sessions)
|
||||
}
|
||||
view := got.Sessions[0]
|
||||
if view.SessionPath != wantPath || view.SessionGeneration != wantGeneration || view.Scope != wantScope || view.WorkspaceRoot != wantRoot || view.State != nextState || view.Open != (mutation != "detach") {
|
||||
t.Fatalf("projection paired state with stale binding: %+v", view)
|
||||
}
|
||||
if fresh := a.GetRuntimeStateSnapshot(); fresh.Revision != got.Revision {
|
||||
t.Fatalf("binding repair required an unrelated subsequent read: first=%+v fresh=%+v", got, fresh)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,186 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
)
|
||||
|
||||
func TestRemoteRuntimeMissingSelectedSessionRequiresOwnConfirmation(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
body := `{"schemaVersion":1,"sessions":[]}`
|
||||
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return remoteRuntimeTestResponse(req, http.StatusOK, body), nil
|
||||
})}
|
||||
a, tab := remoteRuntimeTestApp(client)
|
||||
old := remoteRuntimeTestSnapshot("retired", 7, "executing")
|
||||
acceptRemoteRuntimeStateLocked(tab, runtimeRemoteTestPath, old, true)
|
||||
if _, err := a.SyncRuntimeState(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
view := a.GetRuntimeStateSnapshot().Sessions[0]
|
||||
if view.Freshness != "unknown" || view.State != old {
|
||||
t.Fatalf("missing runtime became authoritative: %+v", view)
|
||||
}
|
||||
other := remoteRuntimeTestSnapshot("other", 1, "idle")
|
||||
acceptRemoteRuntimeStateLocked(tab, "/sessions/other.jsonl", other, true)
|
||||
if tab.runtimeUnknown[runtimeRemoteTestPath] == 0 {
|
||||
t.Fatal("another session cleared uncertainty")
|
||||
}
|
||||
body = remoteRuntimeTestPayload(t, old)
|
||||
if _, err := a.SyncRuntimeState(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
view = a.GetRuntimeStateSnapshot().Sessions[0]
|
||||
if view.Freshness != "synced" || view.State != old {
|
||||
t.Fatalf("identical authority did not confirm: %+v", view)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteRuntimeGETCannotConfirmLaterUncertainty(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
entered, release := make(chan struct{}), make(chan struct{})
|
||||
enterOnce, releaseOnce := sync.OnceFunc(func() { close(entered) }), sync.OnceFunc(func() { close(release) })
|
||||
defer releaseOnce()
|
||||
state := remoteRuntimeTestSnapshot("controller", 3, "executing")
|
||||
body := remoteRuntimeTestPayload(t, state)
|
||||
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
enterOnce()
|
||||
<-release
|
||||
return remoteRuntimeTestResponse(req, 200, body), nil
|
||||
})}
|
||||
a, tab := remoteRuntimeTestApp(client)
|
||||
acceptRemoteRuntimeStateLocked(tab, runtimeRemoteTestPath, state, true)
|
||||
result := make(chan error, 1)
|
||||
go func() { _, err := a.SyncRuntimeState(); result <- err }()
|
||||
<-entered
|
||||
a.remoteTabMu.Lock()
|
||||
markRemoteRuntimeUnknownLocked(tab, runtimeRemoteTestPath)
|
||||
a.remoteTabMu.Unlock()
|
||||
releaseOnce()
|
||||
awaitRemoteRuntimeSync(t, result)
|
||||
if tab.runtimeUnknown[runtimeRemoteTestPath] == 0 {
|
||||
t.Fatal("old GET erased a later failure without changed facts")
|
||||
}
|
||||
if _, err := a.SyncRuntimeState(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if tab.runtimeUnknown[runtimeRemoteTestPath] != 0 {
|
||||
t.Fatal("fresh GET did not restore confidence")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteRuntimeLegacyRecoveryConfirmsOnlySelectedSession(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
if req.URL.Path == "/runtime-states" {
|
||||
return remoteRuntimeTestResponse(req, 404, "unsupported"), nil
|
||||
}
|
||||
return remoteRuntimeTestResponse(req, 200, `{"sessionPath":"/sessions/current.jsonl","running":false}`), nil
|
||||
})}
|
||||
a, tab := remoteRuntimeTestApp(client)
|
||||
state := remoteRuntimeTestSnapshot("previous-server", 1, "executing")
|
||||
acceptRemoteRuntimeStateLocked(tab, runtimeRemoteTestPath, state, true)
|
||||
acceptRemoteRuntimeStateLocked(tab, "/sessions/background.jsonl", state, true)
|
||||
markRemoteRuntimeUnknownLocked(tab, runtimeRemoteTestPath)
|
||||
markRemoteRuntimeUnknownLocked(tab, "/sessions/background.jsonl")
|
||||
if _, err := a.SyncRuntimeState(); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, session := range a.GetRuntimeStateSnapshot().Sessions {
|
||||
if session.Open {
|
||||
if session.State.SchemaVersion != 0 || session.State.Running || session.Freshness != "synced" {
|
||||
t.Fatalf("legacy recovery = %+v", session)
|
||||
}
|
||||
} else if session.Freshness != "unknown" {
|
||||
t.Fatal("legacy foreground status confirmed background")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeProjectionDoesNotReadPreviewFiles(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
dir := t.TempDir()
|
||||
first, second := filepath.Join(dir, "first.jsonl"), filepath.Join(dir, "second.jsonl")
|
||||
a := &App{tabs: map[string]*WorkspaceTab{
|
||||
"one": {ID: "one", Scope: "project", WorkspaceRoot: dir, TopicID: "topic", SessionPath: first},
|
||||
"two": {ID: "two", Scope: "project", WorkspaceRoot: dir, TopicID: "topic", SessionPath: second},
|
||||
}}
|
||||
if err := os.WriteFile(agent.BranchMetaPath(first), []byte(`{"preview":"before"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
before := a.GetRuntimeStateSnapshot()
|
||||
if err := os.WriteFile(agent.BranchMetaPath(first), []byte(`{"preview":"after"}`), 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for range 20 {
|
||||
if after := a.GetRuntimeStateSnapshot(); !reflect.DeepEqual(before, after) {
|
||||
t.Fatalf("unstable memory projection: %+v", after)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteRuntimeWireRequiredFacts(t *testing.T) {
|
||||
valid := remoteRuntimeTestJSON(t, remoteRuntimeTestSnapshot("controller", 2, "idle"))
|
||||
if state, err := decodeRemoteRuntimeState(json.RawMessage(valid)); err != nil || state.Running || state.BackgroundJobs != 0 {
|
||||
t.Fatalf("explicit false/zero rejected: %+v %v", state, err)
|
||||
}
|
||||
for _, field := range []string{"schemaVersion", "runtimeEpoch", "revision", "phase", "running", "pendingPrompt", "cancelRequested", "cancellable", "backgroundJobs"} {
|
||||
for _, replacement := range []string{"missing", "null", `[]`} {
|
||||
t.Run(field+"/"+replacement, func(t *testing.T) {
|
||||
var fields map[string]json.RawMessage
|
||||
if err := json.Unmarshal([]byte(valid), &fields); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if replacement == "missing" {
|
||||
delete(fields, field)
|
||||
} else {
|
||||
fields[field] = json.RawMessage(replacement)
|
||||
}
|
||||
raw, err := json.Marshal(fields)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := decodeRemoteRuntimeState(raw); err == nil {
|
||||
t.Fatal("accepted absent or malformed required fact")
|
||||
}
|
||||
var status remoteTabStatusPayload
|
||||
if err := json.Unmarshal([]byte(`{"running":false,"runtimeState":`+string(raw)+`}`), &status); err == nil {
|
||||
t.Fatal("status downgraded malformed schema 1 to legacy flags")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRemoteRuntimeInvalidFullSnapshotCannotPrune(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
client := &http.Client{Transport: roundTripFunc(func(req *http.Request) (*http.Response, error) {
|
||||
return remoteRuntimeTestResponse(req, 200, `{"schemaVersion":1,"sessions":[{"sessionPath":"/sessions/current.jsonl","state":{"schemaVersion":1,"runtimeEpoch":"controller","revision":2,"phase":"idle"}}]}`), nil
|
||||
})}
|
||||
a, tab := remoteRuntimeTestApp(client)
|
||||
old := remoteRuntimeTestSnapshot("controller", 1, "executing")
|
||||
acceptRemoteRuntimeStateLocked(tab, runtimeRemoteTestPath, old, true)
|
||||
acceptRemoteRuntimeStateLocked(tab, "/sessions/background.jsonl", old, true)
|
||||
if _, err := a.SyncRuntimeState(); err == nil {
|
||||
t.Fatal("invalid payload accepted")
|
||||
}
|
||||
if len(tab.runtimeStates) != 2 || tab.runtimeStates[runtimeRemoteTestPath] != old {
|
||||
t.Fatal("invalid full GET replaced or pruned facts")
|
||||
}
|
||||
for _, view := range a.GetRuntimeStateSnapshot().Sessions {
|
||||
if view.Freshness != "unknown" {
|
||||
t.Fatal("invalid full GET cleared uncertainty")
|
||||
}
|
||||
}
|
||||
a.acceptRemoteRuntimeFrame(tab.id, tab.gen, runtimeRemoteTestPath, json.RawMessage(`{"runtimeState":{"schemaVersion":1,"runtimeEpoch":"controller","revision":2,"phase":"idle"}}`))
|
||||
if tab.runtimeStates[runtimeRemoteTestPath] != old {
|
||||
t.Fatal("invalid SSE replaced facts")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"reflect"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/control"
|
||||
)
|
||||
|
||||
// A real controller delivers TurnDone while its admission gate is still held.
|
||||
// The project tree must subsequently receive the settled state, without the
|
||||
// user opening another tab or causing a catalog reload.
|
||||
func TestRuntimeStateCompletedTurnPublishesIdleProjectTree(t *testing.T) {
|
||||
isolateDesktopUserDirs(t)
|
||||
app := NewApp()
|
||||
ctx, cancel := context.WithCancel(t.Context())
|
||||
defer cancel()
|
||||
app.ctx = ctx
|
||||
snapshots := make(chan ProjectTreeRuntimeSnapshot, 64)
|
||||
app.runtimeEvents.emit = func(_ context.Context, name string, payload ...any) {
|
||||
if name == "project-tree:runtime-changed" {
|
||||
snapshots <- payload[0].(ProjectTreeRuntimeSnapshot)
|
||||
}
|
||||
}
|
||||
sink := &tabEventSink{tabID: "runtime-regression", app: app}
|
||||
runner := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
|
||||
releaseRunner := sync.OnceFunc(func() { close(runner.release) })
|
||||
ctrl := control.New(control.Options{
|
||||
Runner: runner, SessionDir: t.TempDir(), Label: "runtime regression", Sink: sink,
|
||||
})
|
||||
defer ctrl.Close()
|
||||
defer releaseRunner()
|
||||
tab := &WorkspaceTab{
|
||||
ID: sink.tabID, Scope: "global", TopicID: "runtime-regression-topic",
|
||||
Ctrl: ctrl, Ready: true, sink: sink, disabledMCP: map[string]ServerView{},
|
||||
}
|
||||
// Hold the independent metadata-save lane busy: runtime completion must
|
||||
// not depend on an autosave/catalog refresh happening to repair its state.
|
||||
// scheduleTabSnapshot will only mark saveAgain while this lane is occupied.
|
||||
tab.saving = true
|
||||
app.tabs[tab.ID] = tab
|
||||
app.tabOrder = []string{tab.ID}
|
||||
app.activeTabID = tab.ID
|
||||
ctrl.Submit("complete the isolated test turn")
|
||||
select {
|
||||
case <-runner.started:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("isolated runner did not start")
|
||||
}
|
||||
// Observe execution before allowing completion, so a publisher that
|
||||
// intentionally coalesces intermediate snapshots cannot hide the start.
|
||||
startDeadline := time.NewTimer(5 * time.Second)
|
||||
defer startDeadline.Stop()
|
||||
started:
|
||||
for {
|
||||
select {
|
||||
case snapshot := <-snapshots:
|
||||
if len(snapshot.Topics) == 1 && snapshot.Topics[0].Node.Running {
|
||||
break started
|
||||
}
|
||||
case <-startDeadline.C:
|
||||
t.Fatal("running controller never published its activity")
|
||||
}
|
||||
}
|
||||
releaseRunner()
|
||||
waitNotRunning(t, ctrl)
|
||||
|
||||
deadline := time.NewTimer(5 * time.Second)
|
||||
defer deadline.Stop()
|
||||
var last ProjectTreeRuntimeSnapshot
|
||||
for {
|
||||
select {
|
||||
case last = <-snapshots:
|
||||
if len(last.Topics) != 1 {
|
||||
continue
|
||||
}
|
||||
node := last.Topics[0].Node
|
||||
if node.Running || node.Status != "" {
|
||||
continue
|
||||
}
|
||||
fresh := app.GetProjectTreeRuntimeSnapshot()
|
||||
if last.Revision == fresh.Revision && !reflect.DeepEqual(last, fresh) {
|
||||
t.Fatalf("one revision identifies different runtime snapshots: published=%+v read=%+v", last, fresh)
|
||||
}
|
||||
status := ctrl.RuntimeStatus()
|
||||
if status.Running || status.PendingPrompt || status.BackgroundJobs != 0 {
|
||||
t.Fatalf("idle publication disagrees with controller: %+v", status)
|
||||
}
|
||||
return
|
||||
case <-deadline.C:
|
||||
t.Fatalf("completed controller never published idle: controller=%+v last=%+v fresh=%+v", ctrl.RuntimeStatus(), last, app.GetProjectTreeRuntimeSnapshot())
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -8,6 +8,7 @@ import (
|
||||
"time"
|
||||
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/sessioncatalog"
|
||||
)
|
||||
|
||||
@@ -29,6 +30,7 @@ type catalogRuntimeSnapshot struct {
|
||||
topicTitle string
|
||||
topicTitleSource string
|
||||
ctrl control.SessionAPI
|
||||
state *event.RuntimeStateSnapshot
|
||||
open bool
|
||||
}
|
||||
|
||||
@@ -62,16 +64,13 @@ func (a *App) catalogRuntimeOverlays() (map[string]catalogRuntimeOverlay, map[st
|
||||
topics := map[string]catalogRuntimeOverlay{}
|
||||
sessions := map[string]catalogRuntimeOverlay{}
|
||||
for _, snap := range a.catalogRuntimeSnapshots() {
|
||||
runtimeStatus := control.RuntimeStatus{}
|
||||
path := strings.TrimSpace(snap.sessionPath)
|
||||
if snap.ctrl != nil {
|
||||
runtimeStatus = snap.ctrl.RuntimeStatus()
|
||||
if path == "" {
|
||||
path = snap.ctrl.SessionPath()
|
||||
}
|
||||
}
|
||||
status := catalogRuntimeStatus(snap.activity, runtimeStatus)
|
||||
running := status != "" || runtimeStatus.Running || runtimeStatus.PendingPrompt || runtimeStatus.BackgroundJobs > 0
|
||||
status, running := catalogControllerStatus(snap.ctrl, snap.activity)
|
||||
overlay := catalogRuntimeOverlay{open: snap.open, running: running, status: status}
|
||||
key := topicSummaryKey(snap.scope, snap.workspaceRoot, snap.topicID)
|
||||
current := topics[key]
|
||||
@@ -190,10 +189,10 @@ func (a *App) runtimeOnlyProjectTopicsWithSessions(scope, workspaceRoot string)
|
||||
}
|
||||
snapshots = append(snapshots, snapshot)
|
||||
}
|
||||
return a.runtimeProjectTopicNodes(scope, workspaceRoot, snapshots)
|
||||
return a.runtimeProjectTopicNodes(scope, workspaceRoot, snapshots, true)
|
||||
}
|
||||
|
||||
func (a *App) runtimeProjectTopicNodes(scope, workspaceRoot string, snapshots []catalogRuntimeSnapshot) ([]ProjectNode, map[string][]string) {
|
||||
func (a *App) runtimeProjectTopicNodes(scope, workspaceRoot string, snapshots []catalogRuntimeSnapshot, previews bool) ([]ProjectNode, map[string][]string) {
|
||||
byTopic := map[string][]catalogRuntimeSnapshot{}
|
||||
sessionsByTopic := map[string][]string{}
|
||||
for _, snapshot := range snapshots {
|
||||
@@ -213,6 +212,7 @@ func (a *App) runtimeProjectTopicNodes(scope, workspaceRoot string, snapshots []
|
||||
out := []ProjectNode{}
|
||||
for _, topicID := range topicIDs {
|
||||
sessions := byTopic[topicID]
|
||||
sort.Slice(sessions, func(i, j int) bool { return sessions[i].sessionPath < sessions[j].sessionPath })
|
||||
kind := "topic"
|
||||
sessionKind := "session"
|
||||
if scope != "project" {
|
||||
@@ -229,12 +229,10 @@ func (a *App) runtimeProjectTopicNodes(scope, workspaceRoot string, snapshots []
|
||||
Health: string(sessioncatalog.HealthOK), Children: []ProjectNode{},
|
||||
}
|
||||
for _, session := range sessions {
|
||||
runtimeStatus := control.RuntimeStatus{}
|
||||
if session.ctrl != nil {
|
||||
runtimeStatus = session.ctrl.RuntimeStatus()
|
||||
status, running := catalogControllerStatus(session.ctrl, session.activity)
|
||||
if session.state != nil {
|
||||
status, running = catalogStateStatus(*session.state, session.activity)
|
||||
}
|
||||
status := catalogRuntimeStatus(session.activity, runtimeStatus)
|
||||
running := status != "" || runtimeStatus.Running || runtimeStatus.PendingPrompt || runtimeStatus.BackgroundJobs > 0
|
||||
if len(sessions) == 1 {
|
||||
node.Open = session.open
|
||||
node.Running = running
|
||||
@@ -246,9 +244,13 @@ func (a *App) runtimeProjectTopicNodes(scope, workspaceRoot string, snapshots []
|
||||
if sessionLabel == "" || sessionLabel == "." {
|
||||
sessionLabel = label
|
||||
}
|
||||
preview := ""
|
||||
if previews {
|
||||
preview = sessionPreviewForPath(path)
|
||||
}
|
||||
node.Children = append(node.Children, ProjectNode{
|
||||
Key: projectSessionNodeKey(scope, path), Kind: sessionKind, Label: sessionLabel,
|
||||
Root: workspaceRoot, TopicID: topicID, SessionPath: path, Preview: sessionPreviewForPath(path),
|
||||
Root: workspaceRoot, TopicID: topicID, SessionPath: path, Preview: preview,
|
||||
Open: session.open, Running: running, Status: status,
|
||||
TurnsState: string(sessioncatalog.TurnsUnknown), Health: string(sessioncatalog.HealthOK),
|
||||
Children: []ProjectNode{},
|
||||
|
||||
@@ -182,6 +182,12 @@ func TestEmitProjectTreeChangedDoesNotBlockOnRuntimeEventsEmit(t *testing.T) {
|
||||
} else if event, ok := payload[0].(ProjectTreeRuntimeSnapshot); !ok || event.Topics == nil || event.Revision == 0 {
|
||||
t.Errorf("runtime payload = %#v, want a versioned snapshot with [] topics", payload[0])
|
||||
}
|
||||
case "runtime-state:changed":
|
||||
if len(payload) != 1 {
|
||||
t.Errorf("unified runtime payload count = %d", len(payload))
|
||||
} else if snapshot, ok := payload[0].(RuntimeStateProjection); !ok || snapshot.Sessions == nil || snapshot.Revision == 0 {
|
||||
t.Errorf("invalid unified runtime snapshot: %#v", payload[0])
|
||||
}
|
||||
case "project-tree:changed":
|
||||
legacyCalls.Add(1)
|
||||
if len(payload) != 0 {
|
||||
|
||||
@@ -78,7 +78,12 @@ func TestProjectTreeSplitsMultipleRuntimeSessionsInSameTopic(t *testing.T) {
|
||||
app := NewApp()
|
||||
runnerA := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
|
||||
runnerB := &blockingRunner{started: make(chan struct{}), release: make(chan struct{})}
|
||||
ctrlA := control.New(control.Options{Runner: runnerA, SessionDir: dir, SessionPath: sessionA, Label: "a", Sink: event.Discard})
|
||||
asks := make(chan struct{}, 1)
|
||||
ctrlA := control.New(control.Options{Runner: runnerA, SessionDir: dir, SessionPath: sessionA, Label: "a", Sink: event.FuncSink(func(e event.Event) {
|
||||
if e.Kind == event.AskRequest {
|
||||
asks <- struct{}{}
|
||||
}
|
||||
})})
|
||||
ctrlB := control.New(control.Options{Runner: runnerB, SessionDir: dir, SessionPath: sessionB, Label: "b", Sink: event.Discard})
|
||||
defer ctrlA.Close()
|
||||
defer ctrlB.Close()
|
||||
@@ -116,6 +121,18 @@ func TestProjectTreeSplitsMultipleRuntimeSessionsInSameTopic(t *testing.T) {
|
||||
ctrlB.Submit("block B")
|
||||
<-runnerA.started
|
||||
<-runnerB.started
|
||||
askCtx, cancelAsk := context.WithCancel(t.Context())
|
||||
askDone := make(chan struct{})
|
||||
go func() {
|
||||
defer close(askDone)
|
||||
_, _ = ctrlA.Ask(askCtx, []event.AskQuestion{{ID: "choice", Prompt: "Choose"}})
|
||||
}()
|
||||
defer func() { cancelAsk(); <-askDone }()
|
||||
select {
|
||||
case <-asks:
|
||||
case <-time.After(3 * time.Second):
|
||||
t.Fatal("waiting prompt was not committed")
|
||||
}
|
||||
|
||||
nodes := app.ListProjectTree()
|
||||
if len(nodes) != 1 || len(nodes[0].Children) != 1 {
|
||||
@@ -141,6 +158,7 @@ func TestProjectTreeSplitsMultipleRuntimeSessionsInSameTopic(t *testing.T) {
|
||||
|
||||
close(runnerA.release)
|
||||
close(runnerB.release)
|
||||
cancelAsk()
|
||||
waitNotRunning(t, ctrlA)
|
||||
waitNotRunning(t, ctrlB)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,53 @@
|
||||
# Session runtime state
|
||||
|
||||
[简体中文](RUNTIME_STATE.zh-CN.md)
|
||||
|
||||
Desktop, Serve and remote sessions consume committed controller snapshots. Transcript events still carry content and completion results. Runtime reconciliation adds no model request or history reload and changes no session or inbox persistence format.
|
||||
|
||||
## Visible behavior
|
||||
|
||||
| State | Session and project indicator | Composer |
|
||||
| --- | --- | --- |
|
||||
| Executing | Thinking or streaming activity | Existing send and Stop semantics |
|
||||
| Finishing | Static finishing label | Hide current-turn Stop; durably queue the next input |
|
||||
| Waiting for confirmation | Confirmation indicator | Existing prompt and ownership guards |
|
||||
| Cancelling | Remains cancelling until settled | Avoid duplicate cancellation |
|
||||
| Background jobs | Count jobs across active and inactive sessions | Do not portray jobs as foreground model execution |
|
||||
| Awaiting synchronization | Static uncertainty, retaining last known facts | Pause send/Stop until ownership and state are confirmed |
|
||||
|
||||
A successful finishing enqueue shows “Queued” and clears the draft. Failure preserves the draft and reports the error. Explicit retry of the same draft uses the same idempotency key. An uncertain remote POST only reads the receipt for its captured session and key; it never automatically replays the write. Remote queue snapshots validate the connection and session identity and update when the next turn starts.
|
||||
|
||||
Project activity includes every owned runtime and deduplicates the same remote session across tabs. Cancelled jobs retain their count and existing resource protection until they actually exit.
|
||||
|
||||
## Contract and ownership
|
||||
|
||||
`event.RuntimeStateSnapshot` schema version 1 explicitly includes:
|
||||
|
||||
- `runtimeEpoch` and `revision`: instance identity and monotonic version; one version has one immutable value.
|
||||
- `phase`: `idle`, `executing`, `finishing` or `closed`.
|
||||
- `running`, `turnId`, `turnStatus`, `turnEventSeq`.
|
||||
- `pendingPrompt`, `cancelRequested`, `cancellable`, `backgroundJobs`, `activity`.
|
||||
|
||||
The legacy `Running()` execution/finishing protection remains intact. Controllers sample at commit boundaries and publish through bounded notifications outside owner locks. Jobs publish final counts after closing done. Runtime notifications bypass the WAL, transcript and provider messages.
|
||||
|
||||
Desktop `GetRuntimeStateSnapshot` and `runtime-state:changed` share a complete projection with epoch/revision, sessions and topics. Sampling reads controllers outside App locks and then revalidates the complete local binding set, including controller, tab, session generation, path and open/detached identity. The older project-tree interface adapts the same projection.
|
||||
|
||||
Serve `GET /runtime-states` reads foreground and detached controllers from memory; `/status` adds `runtimeState`. Session-specific reads resolve the controller owning that session. SSE `runtime_state` uses existing session tagging and cannot publish the new session state before its `session_changed` barrier. External takeover and read-only mirrors retain existing ownership rules.
|
||||
|
||||
The remote GET/SSE reducer preserves generation, client, selection and route fences. Older versions are discarded, identical versions are no-ops, and conflicting versions trigger coalesced resynchronization. Adopting a new epoch requires an authoritative read. A late GET cannot overwrite newer SSE state or a replacement binding.
|
||||
|
||||
## Synchronization and compatibility
|
||||
|
||||
The application subscribes before reading. Pushes apply immediately. One application owner checks every 30 seconds, with one read per Serve connection per pass. Attachment, focus and connection changes reconcile immediately; concurrent requests coalesce. Failures back off at 5, 10, 20 and 30 seconds without clearing known state.
|
||||
|
||||
With the new contract available, legacy per-session watchdogs no longer decide runtime truth. The ten-minute silence cleanup no longer determines project activity. A legacy Serve returning 404/501 is remembered for that connection generation and uses existing status interfaces. Missing fields are not zero values, and legacy payloads never imply finishing. Existing protocol fields remain supported.
|
||||
|
||||
Diagnostics report source, anonymous epoch, revision, phase, synchronization reason and stale/conflict/failure counts only. They exclude per-token logs, prompts, credentials and full paths.
|
||||
|
||||
## Validation
|
||||
|
||||
- Root module: `go test ./...`; `go test -race ./internal/control ./internal/jobs ./internal/event ./internal/serve`.
|
||||
- Desktop module: `go test ./...`; `go test -race . -run 'RuntimeState|RemoteRuntime|ProjectTreeRuntime|SessionRuntime|RuntimeBinding'`.
|
||||
- Frontend: runtime-state store, Composer inbox recovery and project-tree suites; `pnpm test:remote`, `pnpm test:app-lifecycle`, `pnpm build`.
|
||||
- Browser: `node bench/runtime-state.mjs` runs real Chromium with controlled runtime frames for finishing, enqueue/retry, jobs, remote disconnection/recovery and switching. `pnpm test:app-browser` covers ordinary submission and application lifecycle. Frame fixtures supplement controller and HTTP/SSE integration tests.
|
||||
- Native Desktop requires separate verification. An isolated macOS application with a loopback test model exercises real Wails submission, completion and sidebar settlement. Browser success does not establish Windows or Linux native behavior.
|
||||
@@ -0,0 +1,53 @@
|
||||
# 会话运行状态
|
||||
|
||||
[English](RUNTIME_STATE.md)
|
||||
|
||||
Desktop、Serve 和远程会话使用控制器提交的运行状态快照。正文和完成事件仍由原来的 transcript 链路处理,运行状态同步不会增加模型请求、重载历史或更改会话/inbox 持久化格式。
|
||||
|
||||
## 界面行为
|
||||
|
||||
| 状态 | 会话与项目指示 | 发送控件 |
|
||||
| --- | --- | --- |
|
||||
| 正在执行 | 按实际活动显示思考或输出 | 保留已有发送、停止语义 |
|
||||
| 正在收尾 | 静态“正在收尾”,不再显示思考动画 | 隐藏本轮停止;新输入持久化排队 |
|
||||
| 等待确认 | 显示待确认 | 保留原确认入口及保护 |
|
||||
| 正在取消 | 继续显示取消中,直到控制器结束 | 不重复发送取消 |
|
||||
| 后台任务 | 显示任务数量;非当前会话也计入项目 | 不把后台任务当作前台模型执行 |
|
||||
| 状态待同步 | 保留最后已知事实,以静态不可确认状态显示 | 暂停发送和停止,恢复连接后重新确认 |
|
||||
|
||||
收尾时入队成功显示“已排队”并清空输入;失败保留输入并显示错误。相同草稿重试使用原幂等键。远端 POST 结果不明时只查询该会话、该键的 receipt,不自动重发写请求。远端队列快照按会话和连接身份校验,接续任务开始后会更新条目状态。
|
||||
|
||||
项目指示汇总该项目所有运行实例。同一远程会话通过多个标签显示时只计一次。后台任务被取消但尚未实际退出时,仍保留计数及原有资源保护。
|
||||
|
||||
## 协议与边界
|
||||
|
||||
`event.RuntimeStateSnapshot` schema version 1 显式包含:
|
||||
|
||||
- `runtimeEpoch`、`revision`:运行实例代次和单调版本;同版本对应同内容。
|
||||
- `phase`:`idle`、`executing`、`finishing` 或 `closed`。
|
||||
- `running`、`turnId`、`turnStatus`、`turnEventSeq`。
|
||||
- `pendingPrompt`、`cancelRequested`、`cancellable`、`backgroundJobs`、`activity`。
|
||||
|
||||
旧 `Running()` 的执行/收尾保护语义保持不变。控制器在提交边界生成快照,经有界、离锁通知发布;后台任务在真正关闭 done 后发布最终计数。通知不写入 WAL、transcript 或 provider 消息。
|
||||
|
||||
Desktop 的 `GetRuntimeStateSnapshot` 和 `runtime-state:changed` 使用相同完整投影,包含投影 epoch/revision、sessions 和 topics。本地采样离开 App 锁读取控制器后,复验标签、控制器、会话代次、路径及打开/分离身份。旧项目树接口由同一投影适配。
|
||||
|
||||
Serve 的 `GET /runtime-states` 仅读取前台及 detached 控制器的内存快照;`/status` 增加 `runtimeState`。指定 session 时优先匹配真实拥有该 session 的实例。SSE 的 `runtime_state` 使用既有 session tagging,新的会话状态不会越过 `session_changed` 屏障。外部接管和只读镜像继续遵守原 ownership 规则。
|
||||
|
||||
远程 reducer 同时处理 GET/SSE,保留连接 generation、client、selection 和路由保护。旧版本被丢弃,同版本同内容为无操作,同版本冲突触发合并后的重同步。新 epoch 需要权威读取确认;晚到 GET 不能覆盖更新的 SSE 或新绑定。
|
||||
|
||||
## 同步与兼容
|
||||
|
||||
应用先订阅、后读快照。正常变化立即推送;应用级 owner 每 30 秒校验,每个 Serve 连接在一次校验中只读取一次。挂接、焦点恢复和连接变化触发即时校验,在途请求合并。失败按 5、10、20、30 秒退避;失败不清空已知状态。
|
||||
|
||||
新快照可用时,旧的逐会话运行 watchdog 不再判定运行真值,十分钟沉默清理也不再决定项目活动状态。旧 Serve 的 404/501 能力缺失在当前连接代次内记住,使用原有状态接口;缺失字段不作为零值,也不猜测收尾阶段。所有旧协议字段保留。
|
||||
|
||||
诊断仅记录状态来源、匿名代次、版本、阶段、同步原因及丢弃/冲突/失败计数;不逐 token 输出,不记录提示词、凭据或完整路径。
|
||||
|
||||
## 验证入口
|
||||
|
||||
- 根模块:`go test ./...`;`go test -race ./internal/control ./internal/jobs ./internal/event ./internal/serve`。
|
||||
- Desktop 独立模块:`go test ./...`;`go test -race . -run 'RuntimeState|RemoteRuntime|ProjectTreeRuntime|SessionRuntime|RuntimeBinding'`。
|
||||
- 前端:`runtime-state-store.test.ts`、`composer-inbox-recovery.test.tsx`、项目树专项、`pnpm test:remote`、`pnpm test:app-lifecycle`、`pnpm build`。
|
||||
- 浏览器:`node bench/runtime-state.mjs` 使用真实 Chromium 与可控帧覆盖收尾、排队、失败重试、后台任务、远程断线/恢复及会话切换;`pnpm test:app-browser` 覆盖常规发送和界面生命周期。浏览器帧夹具不能替代控制器及 HTTP/SSE 集成回归。
|
||||
- 原生桌面需另外验证。macOS 的本地隔离应用和 loopback 测试模型可验证真实 Wails 发送、完成及侧边栏清除;Windows/Linux 不能由浏览器结果推定通过。
|
||||
@@ -0,0 +1,9 @@
|
||||
package cli
|
||||
|
||||
import "reasonix/internal/event"
|
||||
|
||||
func (s *metricsSink) RuntimeStateChanged(snapshot event.RuntimeStateSnapshot) {
|
||||
if s != nil {
|
||||
event.PublishRuntimeState(s.inner, snapshot)
|
||||
}
|
||||
}
|
||||
@@ -87,6 +87,7 @@ func (c *Controller) admitGuardedTurn(body func(ctx context.Context) error, park
|
||||
if onStart != nil {
|
||||
onStart()
|
||||
}
|
||||
c.refreshRuntimeState(event.Event{})
|
||||
c.spawnGuardedTurn(ctx, cancel, body)
|
||||
return turnStarted
|
||||
}
|
||||
@@ -41,6 +41,7 @@ func (c *Controller) cancelTurnLocked() (string, bool) {
|
||||
}
|
||||
|
||||
func (c *Controller) finishCancel(turnID string, cancelled bool) {
|
||||
defer c.refreshRuntimeState(event.Event{})
|
||||
if cancelled {
|
||||
c.emitTurnStatus(event.TurnCancelling, turnID)
|
||||
return
|
||||
|
||||
@@ -95,6 +95,7 @@ var errNoSessionPath = errors.New("session has content but no session path; conv
|
||||
// Controller drives one chat session. Construct with New; drive with the command
|
||||
// methods; observe through the Sink passed in Options.
|
||||
type Controller struct {
|
||||
runtimeState controllerRuntimeState
|
||||
// promptResolveMu serializes exact prompt decisions on one controller. It
|
||||
// prevents two UI submissions from racing through separate prompt managers.
|
||||
promptResolveMu sync.Mutex
|
||||
@@ -792,6 +793,7 @@ func New(opts Options) *Controller {
|
||||
func() string { return c.parentSessionID() },
|
||||
))
|
||||
}
|
||||
c.initializeRuntimeState()
|
||||
return c
|
||||
}
|
||||
|
||||
@@ -1068,6 +1070,7 @@ func (c *Controller) finishGuardedTurn(err error, completion *guardedTurnComplet
|
||||
}
|
||||
c.mu.Unlock()
|
||||
|
||||
c.refreshRuntimeState(event.Event{})
|
||||
defer func() {
|
||||
c.mu.Lock()
|
||||
c.finishing = false
|
||||
@@ -1075,12 +1078,14 @@ func (c *Controller) finishGuardedTurn(err error, completion *guardedTurnComplet
|
||||
c.finishingBoundary.end()
|
||||
if c.closed {
|
||||
c.mu.Unlock()
|
||||
c.refreshRuntimeState(event.Event{})
|
||||
return
|
||||
}
|
||||
if len(c.parkedTurns) == 0 {
|
||||
c.mu.Unlock()
|
||||
// No parked compatibility body: admit the next durable inbox item.
|
||||
c.maybeDispatchInbox()
|
||||
c.refreshRuntimeState(event.Event{})
|
||||
return
|
||||
}
|
||||
next := c.parkedTurns[0]
|
||||
@@ -1091,6 +1096,7 @@ func (c *Controller) finishGuardedTurn(err error, completion *guardedTurnComplet
|
||||
c.canceling = false
|
||||
c.mu.Unlock()
|
||||
c.spawnGuardedTurn(ctx, cancel, next)
|
||||
c.refreshRuntimeState(event.Event{})
|
||||
}()
|
||||
c.inbox.mu.Lock()
|
||||
// Prefer a single representative id for the wire event (first active).
|
||||
@@ -2584,6 +2590,7 @@ func (c *Controller) AnswerQuestion(id string, answers []event.AskAnswer) {
|
||||
// AnswerQuestionChecked persists the prompt transition before releasing the
|
||||
// agent loop. A failed ledger write leaves the prompt pending and retryable.
|
||||
func (c *Controller) AnswerQuestionChecked(id string, answers []event.AskAnswer) error {
|
||||
defer c.refreshRuntimeState(event.Event{})
|
||||
c.promptResolveMu.Lock()
|
||||
defer c.promptResolveMu.Unlock()
|
||||
return c.answerQuestionCheckedLocked(id, answers)
|
||||
@@ -4233,6 +4240,7 @@ func (c *Controller) SetFreshSessionPath(p string) {
|
||||
}
|
||||
|
||||
func (c *Controller) setSessionPath(p string, fresh bool) {
|
||||
defer c.refreshRuntimeState(event.Event{})
|
||||
// See snapshotMu: the swap must not interleave with an in-flight save.
|
||||
c.snapshotMu.Lock()
|
||||
c.mu.Lock()
|
||||
@@ -5013,6 +5021,7 @@ const (
|
||||
)
|
||||
|
||||
func (c *Controller) close(fireSessionEnd bool, jobsMode closeJobsMode) {
|
||||
defer c.refreshRuntimeState(event.Event{})
|
||||
// Desktop tab lifecycles can race a rebind/model-switch/close on the same
|
||||
// controller; make teardown idempotent so a duplicate Close cannot re-fire
|
||||
// SessionEnd hooks or re-run cleanup. The first caller's jobsMode wins.
|
||||
@@ -5151,6 +5160,7 @@ func (c *Controller) SetToolApprovalMode(mode string) {
|
||||
// and config writes never drain; Auto keeps explicit memory asks but drains
|
||||
// fallback ones; YOLO drains both. Frontends must keep the rest (#6432).
|
||||
func (c *Controller) ApplyToolApprovalMode(mode string) []string {
|
||||
defer c.refreshRuntimeState(event.Event{})
|
||||
mode = normalizeToolApprovalMode(mode)
|
||||
// Capture mode-change recovery dismissals before approval drain so a
|
||||
// same-value hydrate/reconcile never rotates Episode state, while a real
|
||||
|
||||
@@ -187,10 +187,10 @@ func TestCancelJobCannotCrossSessionBoundary(t *testing.T) {
|
||||
t.Cleanup(manager.Close)
|
||||
pathA := filepath.Join(t.TempDir(), "session-a.jsonl")
|
||||
pathB := filepath.Join(t.TempDir(), "session-b.jsonl")
|
||||
controllerA := New(Options{Jobs: manager})
|
||||
controllerB := New(Options{Jobs: manager})
|
||||
controllerA.sessionPath = pathA
|
||||
controllerB.sessionPath = pathB
|
||||
controllerA := New(Options{Jobs: manager, SessionPath: pathA})
|
||||
controllerB := New(Options{Jobs: manager, SessionPath: pathB})
|
||||
t.Cleanup(controllerA.Close)
|
||||
t.Cleanup(controllerB.Close)
|
||||
|
||||
jobA := manager.StartForSession(agent.BranchID(pathA), "bash", "a", func(ctx context.Context, _ io.Writer) (string, error) {
|
||||
<-ctx.Done()
|
||||
|
||||
+15
-10
@@ -10,6 +10,7 @@ import (
|
||||
"sync"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/sessioninbox"
|
||||
)
|
||||
@@ -30,15 +31,16 @@ const (
|
||||
|
||||
// InboxRequest is the frontend-facing enqueue payload.
|
||||
type InboxRequest struct {
|
||||
Intent sessioninbox.InboxIntent
|
||||
Display string
|
||||
Raw string
|
||||
Submit string
|
||||
Format string
|
||||
Source string
|
||||
Idempotency string
|
||||
Invocations []InvocationRequest
|
||||
Extra map[string]string
|
||||
ExpectedSessionPath string // optional exact-session fence; never persisted
|
||||
Intent sessioninbox.InboxIntent
|
||||
Display string
|
||||
Raw string
|
||||
Submit string
|
||||
Format string
|
||||
Source string
|
||||
Idempotency string
|
||||
Invocations []InvocationRequest
|
||||
Extra map[string]string
|
||||
// FreezeRefs lists workspace-relative paths to freeze at enqueue time.
|
||||
FreezeRefs []string
|
||||
}
|
||||
@@ -282,6 +284,9 @@ func (c *Controller) EnqueueInbox(req InboxRequest) (sessioninbox.InboxReceipt,
|
||||
if err != nil {
|
||||
return sessioninbox.InboxReceipt{}, err
|
||||
}
|
||||
if req.ExpectedSessionPath != "" && st.SessionPath() != req.ExpectedSessionPath {
|
||||
return sessioninbox.InboxReceipt{}, ErrInboxSessionChanged
|
||||
}
|
||||
submit := strings.TrimSpace(firstNonEmptyStr(req.Submit, req.Raw))
|
||||
if submit == "" && len(req.Invocations) == 0 {
|
||||
submit = strings.TrimSpace(req.Display)
|
||||
@@ -312,7 +317,7 @@ func (c *Controller) EnqueueInbox(req InboxRequest) (sessioninbox.InboxReceipt,
|
||||
Envelope: env,
|
||||
Source: req.Source,
|
||||
Idempotency: req.Idempotency,
|
||||
SessionID: c.parentSessionID(),
|
||||
SessionID: agent.BranchID(st.SessionPath()),
|
||||
})
|
||||
if err != nil {
|
||||
if errors.Is(err, sessioninbox.ErrCapacityItems) || errors.Is(err, sessioninbox.ErrCapacityBytes) || errors.Is(err, sessioninbox.ErrItemTooLarge) {
|
||||
|
||||
@@ -0,0 +1,24 @@
|
||||
package control
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"reasonix/internal/sessioninbox"
|
||||
)
|
||||
|
||||
var ErrInboxSessionChanged = errors.New("inbox session changed before submission")
|
||||
|
||||
func (c *Controller) LookupInboxReceipt(key string) (sessioninbox.InboxReceipt, bool, error) {
|
||||
return c.LookupInboxReceiptForSession("", key)
|
||||
}
|
||||
|
||||
func (c *Controller) LookupInboxReceiptForSession(path, key string) (sessioninbox.InboxReceipt, bool, error) {
|
||||
store, err := c.ensureInbox()
|
||||
if err != nil {
|
||||
return sessioninbox.InboxReceipt{}, false, err
|
||||
}
|
||||
if path != "" && store.SessionPath() != path {
|
||||
return sessioninbox.InboxReceipt{}, false, ErrInboxSessionChanged
|
||||
}
|
||||
receipt, found := store.LookupReceipt(key)
|
||||
return receipt, found, nil
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
package control
|
||||
|
||||
import (
|
||||
"errors"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"reasonix/internal/event"
|
||||
)
|
||||
|
||||
func TestInboxExpectedSessionCannotSubmitOrConfirmReplacement(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
first, second := filepath.Join(dir, "first.jsonl"), filepath.Join(dir, "second.jsonl")
|
||||
c := New(Options{SessionDir: dir, SessionPath: first, Sink: event.Discard})
|
||||
defer c.Close()
|
||||
if err := c.SetInboxPaused(true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
request := InboxRequest{ExpectedSessionPath: first, Submit: "original", Idempotency: "original"}
|
||||
receipt, err := c.TryEnqueueFollowup(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
confirmed, found, err := c.LookupInboxReceiptForSession(first, request.Idempotency)
|
||||
if err != nil || !found || confirmed.ItemID != receipt.ItemID {
|
||||
t.Fatalf("original confirmation = %+v, %v, %v", confirmed, found, err)
|
||||
}
|
||||
c.SetSessionPath(second)
|
||||
if err := c.SetInboxPaused(true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if _, err := c.TryEnqueueFollowup(request); !errors.Is(err, ErrInboxSessionChanged) {
|
||||
t.Fatalf("stale request = %v", err)
|
||||
}
|
||||
if _, _, err := c.LookupInboxReceiptForSession(first, request.Idempotency); !errors.Is(err, ErrInboxSessionChanged) {
|
||||
t.Fatalf("stale lookup = %v", err)
|
||||
}
|
||||
if got := c.InboxSnapshot(); got.SessionPath != second || len(got.Items) != 0 {
|
||||
t.Fatalf("replacement mutated: %+v", got)
|
||||
}
|
||||
request.ExpectedSessionPath = ""
|
||||
if _, err := c.TryEnqueueFollowup(request); err != nil {
|
||||
t.Fatalf("legacy request no longer works: %v", err)
|
||||
}
|
||||
}
|
||||
@@ -183,6 +183,7 @@ func (c *Controller) AnswerMCPInteraction(id, action string, content map[string]
|
||||
// AnswerMCPInteractionChecked persists the prompt transition before releasing
|
||||
// the blocked MCP call, so a crashed frontend cannot lose an answered decision.
|
||||
func (c *Controller) AnswerMCPInteractionChecked(id, action string, content map[string]any) error {
|
||||
defer c.refreshRuntimeState(event.Event{})
|
||||
c.promptResolveMu.Lock()
|
||||
defer c.promptResolveMu.Unlock()
|
||||
return c.answerMCPInteractionCheckedLocked(id, action, content)
|
||||
|
||||
@@ -16,6 +16,7 @@ func (c *Controller) ResolvePlanDecision(id string, action PlanDecisionAction) e
|
||||
// ResolvePlanDecisionWithFeedback durably stages revision guidance before the
|
||||
// approval is removed. A persistence failure leaves the card pending for retry.
|
||||
func (c *Controller) ResolvePlanDecisionWithFeedback(id string, action PlanDecisionAction, feedback string) error {
|
||||
defer c.refreshRuntimeState(event.Event{})
|
||||
c.promptResolveMu.Lock()
|
||||
defer c.promptResolveMu.Unlock()
|
||||
return c.resolvePlanDecisionWithFeedbackLocked(id, action, feedback)
|
||||
|
||||
@@ -305,6 +305,7 @@ type PromptAnswer struct {
|
||||
// specialized resolvers retain their validation and durable receipts; this
|
||||
// mutex makes their check-and-wake path single-writer for one controller.
|
||||
func (c *Controller) ResolvePromptExact(identity PromptIdentity, answer PromptAnswer) error {
|
||||
defer c.refreshRuntimeState(event.Event{})
|
||||
if c == nil {
|
||||
return ErrPromptNotPending
|
||||
}
|
||||
|
||||
@@ -0,0 +1,187 @@
|
||||
package control
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"log/slog"
|
||||
"sync"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/jobs"
|
||||
"reasonix/internal/turnevent"
|
||||
)
|
||||
|
||||
// RuntimeStateReader is optional so older embedders of SessionAPI keep working.
|
||||
type RuntimeStateReader interface {
|
||||
RuntimeStateSnapshot() event.RuntimeStateSnapshot
|
||||
}
|
||||
|
||||
type controllerRuntimeState struct {
|
||||
mu sync.Mutex // serializes sampling, commit and publication order; never held by observers
|
||||
snapshot event.RuntimeStateSnapshot
|
||||
ledger *turnevent.Ledger
|
||||
path string
|
||||
activity string
|
||||
sink event.Sink
|
||||
pending *event.RuntimeStateSnapshot
|
||||
draining bool
|
||||
jobUnsubscribe func()
|
||||
}
|
||||
|
||||
func newRuntimeStateEpoch() string {
|
||||
var bytes [16]byte
|
||||
if _, err := rand.Read(bytes[:]); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
return hex.EncodeToString(bytes[:])
|
||||
}
|
||||
|
||||
// RuntimeStateSnapshot returns committed memory, never IO or an independently
|
||||
// sampled combination of controller/prompt/job state.
|
||||
func (c *Controller) RuntimeStateSnapshot() event.RuntimeStateSnapshot {
|
||||
c.runtimeState.mu.Lock()
|
||||
defer c.runtimeState.mu.Unlock()
|
||||
return c.runtimeState.snapshot
|
||||
}
|
||||
|
||||
func (c *Controller) initializeRuntimeState() {
|
||||
c.runtimeState.mu.Lock()
|
||||
c.runtimeState.sink = c.sink
|
||||
c.runtimeState.mu.Unlock()
|
||||
c.refreshRuntimeState(event.Event{})
|
||||
if c.jobs != nil {
|
||||
// A manager may be shared across a controller rebuild. Subscribe to all
|
||||
// session transitions and filter against the current committed binding.
|
||||
_, stop := c.jobs.SubscribeRuntime("", func(state jobs.RuntimeState) {
|
||||
c.refreshRuntimeState(event.Event{})
|
||||
})
|
||||
c.runtimeState.mu.Lock()
|
||||
c.runtimeState.jobUnsubscribe = stop
|
||||
c.runtimeState.mu.Unlock()
|
||||
c.refreshRuntimeState(event.Event{})
|
||||
}
|
||||
}
|
||||
|
||||
// refreshRuntimeState is a commit boundary, not a read-side workaround. Every
|
||||
// lifecycle and job boundary calls it after releasing its owning locks. A
|
||||
// single sampler re-reads current owners instead of replaying stale booleans.
|
||||
func (c *Controller) refreshRuntimeState(e event.Event) {
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
r := &c.runtimeState
|
||||
r.mu.Lock()
|
||||
if r.sink == nil {
|
||||
r.mu.Unlock()
|
||||
return
|
||||
} // construction has not finished
|
||||
c.mu.Lock()
|
||||
running, finishing, closed, cancelling, path := c.running, c.finishing, c.closed, c.canceling, c.sessionPath
|
||||
c.mu.Unlock()
|
||||
ledger := c.turnEventLedger()
|
||||
initialized := r.snapshot.SchemaVersion == 1
|
||||
base, activity := r.snapshot, r.activity
|
||||
if r.snapshot.RuntimeEpoch == "" || r.path != path || r.ledger != ledger {
|
||||
base = event.RuntimeStateSnapshot{RuntimeEpoch: newRuntimeStateEpoch()}
|
||||
activity = ""
|
||||
}
|
||||
next := base
|
||||
next.SchemaVersion = 1
|
||||
next.Phase = "idle"
|
||||
switch {
|
||||
case running:
|
||||
next.Phase = "executing"
|
||||
case finishing:
|
||||
next.Phase = "finishing"
|
||||
case closed:
|
||||
next.Phase = "closed"
|
||||
}
|
||||
next.Running = running || finishing
|
||||
next.CancelRequested = cancelling
|
||||
next.PendingPrompt = c.approval.hasPending()
|
||||
next.Cancellable = !finishing && (running || next.PendingPrompt || cancelling)
|
||||
next.BackgroundJobs = 0
|
||||
if c.jobs != nil {
|
||||
next.BackgroundJobs = len(c.jobs.RunningForSession(agent.BranchID(path)))
|
||||
}
|
||||
if ledger != nil {
|
||||
next.TurnID, next.TurnStatus, next.TurnEventSeq = ledger.RuntimeIdentity()
|
||||
}
|
||||
// Sampling owners is off their locks. Do not commit a mixture if the
|
||||
// admission/close/binding boundary advanced while another owner was read.
|
||||
c.mu.Lock()
|
||||
stable := running == c.running && finishing == c.finishing && closed == c.closed && cancelling == c.canceling && path == c.sessionPath
|
||||
c.mu.Unlock()
|
||||
if !stable || ledger != c.turnEventLedger() {
|
||||
r.mu.Unlock()
|
||||
c.refreshRuntimeState(event.Event{})
|
||||
return
|
||||
}
|
||||
if closed && !running && next.BackgroundJobs == 0 && r.jobUnsubscribe != nil {
|
||||
stop := r.jobUnsubscribe
|
||||
r.jobUnsubscribe = nil
|
||||
defer stop()
|
||||
}
|
||||
activity = runtimeActivity(next, e, activity)
|
||||
next.Activity = activity
|
||||
// Token deltas do not need runtime notifications. Keep the last published
|
||||
// watermark until a semantic state changes, avoiding a second token stream.
|
||||
compare := next
|
||||
compare.TurnEventSeq = r.snapshot.TurnEventSeq
|
||||
if compare == r.snapshot {
|
||||
r.mu.Unlock()
|
||||
return
|
||||
}
|
||||
next.Revision++
|
||||
r.snapshot = next
|
||||
r.path, r.ledger, r.activity = path, ledger, activity
|
||||
defer slog.Debug("runtime state committed", "source", "controller", "epoch", next.RuntimeEpoch[:8], "revision", next.Revision, "phase", next.Phase)
|
||||
if !initialized {
|
||||
r.mu.Unlock()
|
||||
return
|
||||
}
|
||||
r.pending = &next
|
||||
if r.draining {
|
||||
r.mu.Unlock()
|
||||
return
|
||||
}
|
||||
r.draining = true
|
||||
r.mu.Unlock()
|
||||
go c.publishRuntimeState()
|
||||
}
|
||||
|
||||
func (c *Controller) publishRuntimeState() {
|
||||
r := &c.runtimeState
|
||||
for {
|
||||
r.mu.Lock()
|
||||
if r.pending == nil {
|
||||
r.draining = false
|
||||
r.mu.Unlock()
|
||||
return
|
||||
}
|
||||
snapshot, sink := *r.pending, r.sink
|
||||
r.pending = nil
|
||||
r.mu.Unlock()
|
||||
event.PublishRuntimeState(sink, snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
func runtimeActivity(state event.RuntimeStateSnapshot, e event.Event, activity string) string {
|
||||
if state.Phase == "executing" {
|
||||
if e.TurnID == "" || e.TurnID == state.TurnID {
|
||||
switch e.Kind {
|
||||
case event.Text, event.Message:
|
||||
activity = "streaming"
|
||||
case event.TurnStarted, event.Reasoning, event.ToolDispatch, event.ToolProgress, event.ToolResult, event.CompactionStarted, event.Retrying:
|
||||
activity = "thinking"
|
||||
}
|
||||
}
|
||||
if activity == "" {
|
||||
activity = "thinking"
|
||||
}
|
||||
} else {
|
||||
activity = ""
|
||||
}
|
||||
return activity
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
package control
|
||||
|
||||
import "reasonix/internal/event"
|
||||
|
||||
func (s *inboxEventSink) RuntimeStateChanged(snapshot event.RuntimeStateSnapshot) {
|
||||
if s != nil {
|
||||
event.PublishRuntimeState(s.inner, snapshot)
|
||||
}
|
||||
}
|
||||
func (s *frontendEventSink) RuntimeStateChanged(snapshot event.RuntimeStateSnapshot) {
|
||||
if s != nil {
|
||||
event.PublishRuntimeState(s.inner, snapshot)
|
||||
}
|
||||
}
|
||||
func (s *turnEventSink) RuntimeStateChanged(snapshot event.RuntimeStateSnapshot) {
|
||||
if s != nil {
|
||||
event.PublishRuntimeState(s.innerSnapshot(), snapshot)
|
||||
}
|
||||
}
|
||||
func (s *turnEventDurableSink) RuntimeStateChanged(snapshot event.RuntimeStateSnapshot) {
|
||||
if s != nil {
|
||||
event.PublishRuntimeState(s.inner(), snapshot)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
package control
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/event"
|
||||
)
|
||||
|
||||
type runtimeStateTestSink struct {
|
||||
event.Sink
|
||||
states chan event.RuntimeStateSnapshot
|
||||
}
|
||||
|
||||
func (s *runtimeStateTestSink) RuntimeStateChanged(state event.RuntimeStateSnapshot) {
|
||||
s.states <- state
|
||||
}
|
||||
|
||||
func runtimeStateAwait(t *testing.T, states <-chan event.RuntimeStateSnapshot, accept func(event.RuntimeStateSnapshot) bool) event.RuntimeStateSnapshot {
|
||||
t.Helper()
|
||||
timer := time.NewTimer(5 * time.Second)
|
||||
defer timer.Stop()
|
||||
var last event.RuntimeStateSnapshot
|
||||
for {
|
||||
select {
|
||||
case last = <-states:
|
||||
if accept(last) {
|
||||
return last
|
||||
}
|
||||
case <-timer.C:
|
||||
t.Fatalf("runtime publication did not reach expected state; last=%+v", last)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func runtimeStateSignal(t *testing.T, signal <-chan struct{}, description string) {
|
||||
t.Helper()
|
||||
select {
|
||||
case <-signal:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal(description)
|
||||
}
|
||||
}
|
||||
|
||||
func assertRuntimeStateSameVersion(t *testing.T, published, current event.RuntimeStateSnapshot) {
|
||||
t.Helper()
|
||||
if published.RuntimeEpoch == current.RuntimeEpoch && published.Revision == current.Revision && published != current {
|
||||
t.Fatalf("same runtime version has different contents: published=%+v current=%+v", published, current)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeStateSnapshotFinishingAndFinalPublication(t *testing.T) {
|
||||
isolateControlConfigHome(t)
|
||||
entered, release := make(chan struct{}, 1), make(chan struct{})
|
||||
releaseDone := sync.OnceFunc(func() { close(release) })
|
||||
sink := &runtimeStateTestSink{Sink: holdFinishingWindow(release, entered, nil), states: make(chan event.RuntimeStateSnapshot, 32)}
|
||||
c := New(Options{SessionDir: t.TempDir(), Sink: sink})
|
||||
defer c.Close()
|
||||
defer releaseDone()
|
||||
initial := c.RuntimeStateSnapshot()
|
||||
if initial.SchemaVersion != 1 || initial.RuntimeEpoch == "" || initial.Revision == 0 || initial.Phase != "idle" {
|
||||
t.Fatalf("invalid initial contract: %+v", initial)
|
||||
}
|
||||
if second := c.RuntimeStateSnapshot(); second != initial {
|
||||
t.Fatalf("reading runtime state changed the snapshot: first=%+v second=%+v", initial, second)
|
||||
}
|
||||
runRelease := make(chan struct{})
|
||||
releaseBody := sync.OnceFunc(func() { close(runRelease) })
|
||||
defer releaseBody()
|
||||
c.runGuarded(func(context.Context) error { <-runRelease; return nil })
|
||||
executing := runtimeStateAwait(t, sink.states, func(s event.RuntimeStateSnapshot) bool { return s.Phase == "executing" && s.TurnID != "" })
|
||||
if !executing.Running || !executing.Cancellable || executing.Activity != "thinking" || executing.Revision <= initial.Revision {
|
||||
t.Fatalf("invalid execution state: %+v", executing)
|
||||
}
|
||||
releaseBody()
|
||||
runtimeStateSignal(t, entered, "TurnDone did not enter finishing window")
|
||||
finishing := c.RuntimeStateSnapshot()
|
||||
if finishing.Phase != "finishing" || !finishing.Running || finishing.Activity != "" || finishing.Cancellable {
|
||||
t.Fatalf("finishing must keep admission closed without presenting thought/cancel: %+v", finishing)
|
||||
}
|
||||
if !c.RuntimeStatus().Running {
|
||||
t.Fatal("legacy running guard opened during TurnDone fan-out")
|
||||
}
|
||||
publishedFinishing := runtimeStateAwait(t, sink.states, func(s event.RuntimeStateSnapshot) bool { return s.Phase == "finishing" })
|
||||
assertRuntimeStateSameVersion(t, publishedFinishing, c.RuntimeStateSnapshot())
|
||||
releaseDone()
|
||||
completed := runtimeStateAwait(t, sink.states, func(s event.RuntimeStateSnapshot) bool { return s.Phase == "idle" && s.Revision > executing.Revision })
|
||||
if completed.Running || completed.Cancellable || completed.PendingPrompt || completed.Activity != "" || completed.BackgroundJobs != 0 {
|
||||
t.Fatalf("completed publication retained active work: %+v", completed)
|
||||
}
|
||||
if completed.RuntimeEpoch != initial.RuntimeEpoch || completed.Revision <= publishedFinishing.Revision {
|
||||
t.Fatalf("completion lost instance identity or ordering: finishing=%+v completed=%+v", publishedFinishing, completed)
|
||||
}
|
||||
assertRuntimeStateSameVersion(t, completed, c.RuntimeStateSnapshot())
|
||||
}
|
||||
|
||||
func TestRuntimeStateQueuedTurnNeverPublishesPreviousIdleOverNext(t *testing.T) {
|
||||
isolateControlConfigHome(t)
|
||||
entered, release := make(chan struct{}, 1), make(chan struct{})
|
||||
releaseDone := sync.OnceFunc(func() { close(release) })
|
||||
sink := &runtimeStateTestSink{Sink: holdFinishingWindow(release, entered, nil), states: make(chan event.RuntimeStateSnapshot, 64)}
|
||||
c := New(Options{SessionDir: t.TempDir(), Sink: sink})
|
||||
defer c.Close()
|
||||
defer releaseDone()
|
||||
c.runGuarded(func(context.Context) error { return nil })
|
||||
runtimeStateSignal(t, entered, "first turn did not enter finishing window")
|
||||
first := c.RuntimeStateSnapshot()
|
||||
secondStarted, secondRelease := make(chan struct{}), make(chan struct{})
|
||||
releaseSecond := sync.OnceFunc(func() { close(secondRelease) })
|
||||
defer releaseSecond()
|
||||
if admission := c.runGuarded(func(context.Context) error { close(secondStarted); <-secondRelease; return nil }); admission != turnParked {
|
||||
t.Fatalf("follow-up admission=%v; expected parked", admission)
|
||||
}
|
||||
releaseDone()
|
||||
runtimeStateSignal(t, secondStarted, "parked second turn did not start")
|
||||
second := runtimeStateAwait(t, sink.states, func(s event.RuntimeStateSnapshot) bool {
|
||||
if s.Revision > first.Revision && s.Phase == "idle" {
|
||||
t.Fatalf("old completion published idle while queued turn owns admission: %+v", s)
|
||||
}
|
||||
return s.Revision > first.Revision && s.Phase == "executing" && s.TurnID != "" && s.TurnID != first.TurnID
|
||||
})
|
||||
if second.RuntimeEpoch != first.RuntimeEpoch || second.Revision <= first.Revision {
|
||||
t.Fatalf("queued turn lost runtime ordering: first=%+v second=%+v", first, second)
|
||||
}
|
||||
assertRuntimeStateSameVersion(t, second, c.RuntimeStateSnapshot())
|
||||
releaseSecond()
|
||||
final := runtimeStateAwait(t, sink.states, func(s event.RuntimeStateSnapshot) bool { return s.Phase == "idle" && s.Revision > second.Revision })
|
||||
if final.TurnID != second.TurnID && final.TurnID != "" {
|
||||
t.Fatalf("old turn replaced the completed next turn: second=%+v final=%+v", second, final)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeStatePromptCancellationAndClosed(t *testing.T) {
|
||||
for _, kind := range []string{"ask", "approval"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
isolateControlConfigHome(t)
|
||||
requests := make(chan struct{}, 1)
|
||||
sink := &runtimeStateTestSink{Sink: event.FuncSink(func(e event.Event) {
|
||||
if e.Kind == event.AskRequest || e.Kind == event.ApprovalRequest {
|
||||
requests <- struct{}{}
|
||||
}
|
||||
}), states: make(chan event.RuntimeStateSnapshot, 64)}
|
||||
c := New(Options{SessionDir: t.TempDir(), Sink: sink})
|
||||
defer c.Close()
|
||||
if kind == "ask" {
|
||||
c.runner = &askBlockingRunner{c: c}
|
||||
} else {
|
||||
c.runner = &approvalBlockingRunner{c: c}
|
||||
}
|
||||
c.Send("isolated interaction")
|
||||
runtimeStateSignal(t, requests, "prompt was not requested")
|
||||
pending := runtimeStateAwait(t, sink.states, func(s event.RuntimeStateSnapshot) bool { return s.PendingPrompt })
|
||||
if !pending.Running || !pending.Cancellable {
|
||||
t.Fatalf("pending prompt is not actionable: %+v", pending)
|
||||
}
|
||||
c.Cancel()
|
||||
c.Cancel()
|
||||
idle := runtimeStateAwait(t, sink.states, func(s event.RuntimeStateSnapshot) bool { return s.Phase == "idle" && s.Revision > pending.Revision })
|
||||
if idle.PendingPrompt || idle.CancelRequested || idle.Cancellable || idle.Running {
|
||||
t.Fatalf("cancel left stale prompt state: %+v", idle)
|
||||
}
|
||||
c.Close()
|
||||
closed := runtimeStateAwait(t, sink.states, func(s event.RuntimeStateSnapshot) bool { return s.Phase == "closed" })
|
||||
if closed.Running || closed.PendingPrompt || closed.Cancellable || closed.Activity != "" || closed.Revision <= idle.Revision {
|
||||
t.Fatalf("invalid closed snapshot: %+v", closed)
|
||||
}
|
||||
assertRuntimeStateSameVersion(t, closed, c.RuntimeStateSnapshot())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeStateCloseDuringFinishingCannotResurrectActivity(t *testing.T) {
|
||||
isolateControlConfigHome(t)
|
||||
entered, release := make(chan struct{}, 1), make(chan struct{})
|
||||
releaseDone := sync.OnceFunc(func() { close(release) })
|
||||
sink := &runtimeStateTestSink{Sink: holdFinishingWindow(release, entered, nil), states: make(chan event.RuntimeStateSnapshot, 32)}
|
||||
c := New(Options{SessionDir: t.TempDir(), Sink: sink})
|
||||
defer c.Close()
|
||||
defer releaseDone()
|
||||
c.runGuarded(func(context.Context) error { return nil })
|
||||
runtimeStateSignal(t, entered, "turn did not enter finishing window")
|
||||
c.Close()
|
||||
closed := runtimeStateAwait(t, sink.states, func(s event.RuntimeStateSnapshot) bool { return s.Phase == "closed" })
|
||||
if closed.Running || closed.Cancellable || closed.PendingPrompt || closed.Activity != "" {
|
||||
t.Fatalf("closed runtime retained finishing activity: %+v", closed)
|
||||
}
|
||||
releaseDone()
|
||||
// A rejected new admission is a synchronous observation after Close. The
|
||||
// released old completion may still run, but it must never reopen the gate.
|
||||
if got := c.runGuarded(func(context.Context) error { t.Error("closed runtime admitted a new turn"); return nil }); got != turnDroppedClosed {
|
||||
t.Fatalf("admission after close=%v, want closed", got)
|
||||
}
|
||||
current := c.RuntimeStateSnapshot()
|
||||
if current.Phase != "closed" || current.Running || current.Activity != "" {
|
||||
t.Fatalf("old completion resurrected closed runtime: %+v", current)
|
||||
}
|
||||
assertRuntimeStateSameVersion(t, closed, current)
|
||||
}
|
||||
|
||||
func TestRuntimeStateStreamingActivityClearsOnCompletion(t *testing.T) {
|
||||
isolateControlConfigHome(t)
|
||||
sink := &runtimeStateTestSink{Sink: event.Discard, states: make(chan event.RuntimeStateSnapshot, 32)}
|
||||
c := New(Options{SessionDir: t.TempDir(), Sink: sink})
|
||||
defer c.Close()
|
||||
release := make(chan struct{})
|
||||
releaseBody := sync.OnceFunc(func() { close(release) })
|
||||
defer releaseBody()
|
||||
c.runGuarded(func(context.Context) error {
|
||||
c.sink.Emit(event.Event{Kind: event.Text, Text: "isolated visible output"})
|
||||
<-release
|
||||
return nil
|
||||
})
|
||||
streaming := runtimeStateAwait(t, sink.states, func(s event.RuntimeStateSnapshot) bool { return s.Activity == "streaming" })
|
||||
if streaming.Phase != "executing" || !streaming.Running {
|
||||
t.Fatalf("streaming state is not executing: %+v", streaming)
|
||||
}
|
||||
releaseBody()
|
||||
idle := runtimeStateAwait(t, sink.states, func(s event.RuntimeStateSnapshot) bool { return s.Phase == "idle" && s.Revision > streaming.Revision })
|
||||
if idle.Activity != "" || idle.Running {
|
||||
t.Fatalf("completed runtime retained streaming activity: %+v", idle)
|
||||
}
|
||||
assertRuntimeStateSameVersion(t, idle, c.RuntimeStateSnapshot())
|
||||
}
|
||||
|
||||
type runtimeStateSlowTestSink struct {
|
||||
entered chan struct{}
|
||||
release chan struct{}
|
||||
done chan struct{}
|
||||
states chan event.RuntimeStateSnapshot
|
||||
once sync.Once
|
||||
}
|
||||
|
||||
func (s *runtimeStateSlowTestSink) Emit(e event.Event) {
|
||||
if e.Kind == event.TurnDone {
|
||||
s.done <- struct{}{}
|
||||
}
|
||||
}
|
||||
func (s *runtimeStateSlowTestSink) RuntimeStateChanged(state event.RuntimeStateSnapshot) {
|
||||
s.once.Do(func() { close(s.entered); <-s.release })
|
||||
s.states <- state
|
||||
}
|
||||
|
||||
func TestRuntimeStateSlowObserverDoesNotBlockTurnAndRetainsFinalSnapshot(t *testing.T) {
|
||||
isolateControlConfigHome(t)
|
||||
sink := &runtimeStateSlowTestSink{entered: make(chan struct{}), release: make(chan struct{}), done: make(chan struct{}, 1), states: make(chan event.RuntimeStateSnapshot, 32)}
|
||||
releaseObserver := sync.OnceFunc(func() { close(sink.release) })
|
||||
c := New(Options{SessionDir: t.TempDir(), Sink: sink})
|
||||
defer c.Close()
|
||||
defer releaseObserver()
|
||||
bodyRelease := make(chan struct{})
|
||||
releaseBody := sync.OnceFunc(func() { close(bodyRelease) })
|
||||
defer releaseBody()
|
||||
c.runGuarded(func(context.Context) error { <-bodyRelease; return nil })
|
||||
runtimeStateSignal(t, sink.entered, "runtime observer did not enter")
|
||||
releaseBody()
|
||||
runtimeStateSignal(t, sink.done, "slow runtime observer blocked turn completion")
|
||||
releaseObserver()
|
||||
final := runtimeStateAwait(t, sink.states, func(state event.RuntimeStateSnapshot) bool {
|
||||
return state.Phase == "idle" && state.TurnStatus == event.TurnCompleted
|
||||
})
|
||||
if final.Running || final.Activity != "" {
|
||||
t.Fatalf("slow observer lost final idle: %+v", final)
|
||||
}
|
||||
assertRuntimeStateSameVersion(t, final, c.RuntimeStateSnapshot())
|
||||
}
|
||||
|
||||
type runtimeStateFailureRunner func(context.Context, string) error
|
||||
|
||||
func (r runtimeStateFailureRunner) Run(ctx context.Context, input string) error {
|
||||
return r(ctx, input)
|
||||
}
|
||||
|
||||
func TestRuntimeStateRunnerFailuresPublishIdleAndPreserveFailure(t *testing.T) {
|
||||
for _, failure := range []string{"error", "panic"} {
|
||||
t.Run(failure, func(t *testing.T) {
|
||||
isolateControlConfigHome(t)
|
||||
started, release := make(chan struct{}), make(chan struct{})
|
||||
releaseRunner := sync.OnceFunc(func() { close(release) })
|
||||
done := make(chan event.Event, 2)
|
||||
sink := &runtimeStateTestSink{states: make(chan event.RuntimeStateSnapshot, 64), Sink: event.FuncSink(func(e event.Event) {
|
||||
if e.Kind == event.TurnDone {
|
||||
done <- e
|
||||
}
|
||||
})}
|
||||
message := "runtime-state " + failure + " fixture"
|
||||
runner := runtimeStateFailureRunner(func(context.Context, string) error {
|
||||
close(started)
|
||||
<-release
|
||||
if failure == "panic" {
|
||||
panic(message)
|
||||
}
|
||||
return errors.New(message)
|
||||
})
|
||||
c := New(Options{SessionDir: t.TempDir(), Sink: sink, Runner: runner})
|
||||
defer c.Close()
|
||||
defer releaseRunner()
|
||||
c.Send("exercise isolated runner failure")
|
||||
runtimeStateSignal(t, started, "runner did not start")
|
||||
executing := runtimeStateAwait(t, sink.states, func(s event.RuntimeStateSnapshot) bool {
|
||||
return s.Phase == "executing" && s.Running && s.Cancellable && s.TurnID != ""
|
||||
})
|
||||
releaseRunner()
|
||||
idle := runtimeStateAwait(t, sink.states, func(s event.RuntimeStateSnapshot) bool {
|
||||
return s.Phase == "idle" && s.Revision > executing.Revision
|
||||
})
|
||||
if idle.Running || idle.PendingPrompt || idle.CancelRequested || idle.Cancellable || idle.Activity != "" || idle.BackgroundJobs != 0 {
|
||||
t.Fatalf("%s retained active state in final publication: %+v", failure, idle)
|
||||
}
|
||||
if idle.RuntimeEpoch != executing.RuntimeEpoch || idle.TurnStatus != event.TurnFailed {
|
||||
t.Fatalf("%s lost failure identity/status: executing=%+v idle=%+v", failure, executing, idle)
|
||||
}
|
||||
terminal := waitTurnDoneEvent(t, done)
|
||||
if terminal.Err == nil || !strings.Contains(terminal.Err.Error(), message) || terminal.Status != event.TurnFailed || terminal.Cancelled {
|
||||
t.Fatalf("%s failure notification was lost or reclassified: %+v", failure, terminal)
|
||||
}
|
||||
// The final idle publication follows synchronous TurnDone delivery.
|
||||
// Therefore another queued terminal notification would be a duplicate.
|
||||
select {
|
||||
case extra := <-done:
|
||||
t.Fatalf("%s emitted duplicate terminal notification: %+v", failure, extra)
|
||||
default:
|
||||
}
|
||||
assertRuntimeStateSameVersion(t, idle, c.RuntimeStateSnapshot())
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -39,12 +39,14 @@ func (c *Controller) runSynchronousTurn(
|
||||
c.running = true
|
||||
c.canceling = false
|
||||
c.mu.Unlock()
|
||||
c.refreshRuntimeState(event.Event{})
|
||||
finish := func() {
|
||||
c.mu.Lock()
|
||||
c.running = false
|
||||
c.cancel = nil
|
||||
c.canceling = false
|
||||
c.mu.Unlock()
|
||||
c.refreshRuntimeState(event.Event{})
|
||||
cancel()
|
||||
}
|
||||
if onAdmitted != nil {
|
||||
|
||||
@@ -158,6 +158,7 @@ func (s *turnEventSink) persistAndPublish(e event.Event) error {
|
||||
}
|
||||
ledger := s.c.turnEventLedger()
|
||||
if ledger == nil {
|
||||
s.c.refreshRuntimeState(e)
|
||||
s.publishInner(e)
|
||||
return nil
|
||||
}
|
||||
@@ -167,6 +168,7 @@ func (s *turnEventSink) persistAndPublish(e event.Event) error {
|
||||
// Outside-turn notices are not lifecycle records and must pass through after
|
||||
// bootstrap or a terminal event.
|
||||
if ledger.ActiveTurnID() == "" {
|
||||
s.c.refreshRuntimeState(e)
|
||||
s.publishInner(e)
|
||||
return nil
|
||||
}
|
||||
@@ -216,6 +218,7 @@ func (s *turnEventSink) persistAndPublish(e event.Event) error {
|
||||
if !ok {
|
||||
return nil
|
||||
}
|
||||
s.c.refreshRuntimeState(stamped)
|
||||
s.publishInner(stamped)
|
||||
if e.Kind == event.TurnDone && !ledger.ProjectionAckRequired() {
|
||||
if err := ledger.AcknowledgeProjection(stamped.TurnID); err != nil {
|
||||
@@ -363,6 +366,7 @@ func (c *Controller) turnEventRuntimeStatus() (string, event.TurnStatus, uint64,
|
||||
}
|
||||
|
||||
func (c *Controller) rebindTurnEvents(sessionPath string) {
|
||||
defer c.refreshRuntimeState(event.Event{})
|
||||
if c == nil {
|
||||
return
|
||||
}
|
||||
@@ -392,6 +396,7 @@ func (c *Controller) rebindTurnEvents(sessionPath string) {
|
||||
}
|
||||
|
||||
func (c *Controller) failTurnEventLedger(err error) {
|
||||
defer c.refreshRuntimeState(event.Event{})
|
||||
if c == nil || err == nil {
|
||||
return
|
||||
}
|
||||
|
||||
@@ -214,6 +214,7 @@ func (c *Controller) requestWriteAccessDecision(ctx context.Context, toolName, s
|
||||
|
||||
// ResolveApproval answers a pending approval with an explicit scope.
|
||||
func (c *Controller) ResolveApproval(id string, allow bool, scope sandbox.ApprovalScope) error {
|
||||
defer c.refreshRuntimeState(event.Event{})
|
||||
c.promptResolveMu.Lock()
|
||||
defer c.promptResolveMu.Unlock()
|
||||
return c.resolveApprovalLocked(id, allow, scope)
|
||||
|
||||
@@ -16,6 +16,7 @@ type AuditForwarder struct{ Inner Sink }
|
||||
// wrappers use compile-time assertions instead of maintaining partial lists in
|
||||
// tests that silently drift when a new capability is added.
|
||||
type OptionalSinkCapabilities interface {
|
||||
RuntimeStateSink
|
||||
DelegationAuditSink
|
||||
ReadinessAuditSink
|
||||
AnchorSafetyAuditSink
|
||||
|
||||
@@ -0,0 +1,48 @@
|
||||
package event
|
||||
|
||||
import "reasonix/internal/nilutil"
|
||||
|
||||
// RuntimeStateSnapshot is a host-only, replaceable observation. It is never a
|
||||
// transcript or durable turn record. Running retains the legacy admission gate.
|
||||
type RuntimeStateSnapshot struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
RuntimeEpoch string `json:"runtimeEpoch"`
|
||||
Revision uint64 `json:"revision"`
|
||||
Phase string `json:"phase"`
|
||||
Running bool `json:"running"`
|
||||
TurnID string `json:"turnId"`
|
||||
TurnStatus TurnStatus `json:"turnStatus"`
|
||||
TurnEventSeq uint64 `json:"turnEventSeq"`
|
||||
PendingPrompt bool `json:"pendingPrompt"`
|
||||
CancelRequested bool `json:"cancelRequested"`
|
||||
Cancellable bool `json:"cancellable"`
|
||||
BackgroundJobs int `json:"backgroundJobs"`
|
||||
Activity string `json:"activity"`
|
||||
}
|
||||
|
||||
func (s RuntimeStateSnapshot) ActiveWork() bool {
|
||||
return s.Running || s.PendingPrompt || s.BackgroundJobs > 0
|
||||
}
|
||||
|
||||
// RuntimeStateSink is independent of Emit: a state refresh must not become a
|
||||
// new ledger record, extension invocation, or model-visible message.
|
||||
type RuntimeStateSink interface{ RuntimeStateChanged(RuntimeStateSnapshot) }
|
||||
|
||||
func PublishRuntimeState(sink Sink, snapshot RuntimeStateSnapshot) {
|
||||
if nilutil.IsNil(sink) {
|
||||
return
|
||||
}
|
||||
if target, ok := sink.(RuntimeStateSink); ok {
|
||||
target.RuntimeStateChanged(snapshot)
|
||||
}
|
||||
}
|
||||
|
||||
func (f AuditForwarder) RuntimeStateChanged(s RuntimeStateSnapshot) { PublishRuntimeState(f.Inner, s) }
|
||||
func (s *syncSink) RuntimeStateChanged(snapshot RuntimeStateSnapshot) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
PublishRuntimeState(s.inner, snapshot)
|
||||
}
|
||||
func (c *coalescer) RuntimeStateChanged(snapshot RuntimeStateSnapshot) {
|
||||
c.enqueueCapability(func() { PublishRuntimeState(c.inner, snapshot) })
|
||||
}
|
||||
@@ -0,0 +1,78 @@
|
||||
package event
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
type runtimeStateCaptureSink struct {
|
||||
states chan RuntimeStateSnapshot
|
||||
events atomic.Int32
|
||||
}
|
||||
|
||||
func (s *runtimeStateCaptureSink) Emit(Event) { s.events.Add(1) }
|
||||
func (s *runtimeStateCaptureSink) RuntimeStateChanged(state RuntimeStateSnapshot) { s.states <- state }
|
||||
|
||||
func TestRuntimeStateForwardsOutsideTranscriptEvents(t *testing.T) {
|
||||
for _, kind := range []string{"direct", "sync", "coalesce", "audit", "combined"} {
|
||||
t.Run(kind, func(t *testing.T) {
|
||||
capture := &runtimeStateCaptureSink{states: make(chan RuntimeStateSnapshot, 1)}
|
||||
var sink Sink = capture
|
||||
switch kind {
|
||||
case "sync":
|
||||
sink = Sync(sink)
|
||||
case "coalesce":
|
||||
sink = Coalesce(sink, time.Millisecond)
|
||||
case "audit":
|
||||
sink = runtimeAuditTestSink{AuditForwarder: AuditForwarder{Inner: sink}}
|
||||
case "combined":
|
||||
sink = Sync(Coalesce(runtimeAuditTestSink{AuditForwarder: AuditForwarder{Inner: sink}}, time.Millisecond))
|
||||
}
|
||||
want := RuntimeStateSnapshot{SchemaVersion: 1, RuntimeEpoch: "test-runtime", Revision: 9, Phase: "finishing", Running: true, TurnID: "test-turn"}
|
||||
PublishRuntimeState(sink, want)
|
||||
select {
|
||||
case got := <-capture.states:
|
||||
if got != want {
|
||||
t.Fatalf("wrapper changed snapshot: got=%+v want=%+v", got, want)
|
||||
}
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("wrapper swallowed runtime capability")
|
||||
}
|
||||
if capture.events.Load() != 0 {
|
||||
t.Fatal("runtime notification entered the transcript event channel")
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
type runtimeAuditTestSink struct{ AuditForwarder }
|
||||
|
||||
func (s runtimeAuditTestSink) Emit(e Event) { s.Inner.Emit(e) }
|
||||
|
||||
func TestRuntimeStateJSONExplicitZeroValues(t *testing.T) {
|
||||
raw, err := json.Marshal(RuntimeStateSnapshot{SchemaVersion: 1, RuntimeEpoch: "test", Revision: 1, Phase: "idle"})
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var fields map[string]any
|
||||
if err := json.Unmarshal(raw, &fields); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
for _, key := range []string{"running", "pendingPrompt", "cancelRequested", "cancellable"} {
|
||||
if value, ok := fields[key]; !ok || value != false {
|
||||
t.Fatalf("%s must explicitly clear stale state: %s", key, raw)
|
||||
}
|
||||
}
|
||||
if value, ok := fields["backgroundJobs"]; !ok || value != float64(0) {
|
||||
t.Fatalf("backgroundJobs must explicitly clear stale count: %s", raw)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeStatePublishAcceptsLegacyAndNilSinks(t *testing.T) {
|
||||
var typedNil *runtimeStateCaptureSink
|
||||
for _, sink := range []Sink{nil, typedNil, Discard, FuncSink(func(Event) { t.Fatal("legacy sink received a synthetic event") })} {
|
||||
PublishRuntimeState(sink, RuntimeStateSnapshot{Phase: "idle"})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
package eventwire
|
||||
|
||||
import (
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/provider"
|
||||
)
|
||||
|
||||
// Event is the JSON-friendly form shared by event frontends.
|
||||
// externalizable:"true" marks large string payloads the Remote protocol may
|
||||
// offload via content refs without changing provider-visible semantics.
|
||||
type Event struct {
|
||||
RuntimeState *event.RuntimeStateSnapshot `json:"runtimeState,omitempty"`
|
||||
Kind string `json:"kind"`
|
||||
PromptID string `json:"promptId,omitempty"`
|
||||
PromptKind string `json:"promptKind,omitempty"`
|
||||
PromptLegacy bool `json:"promptLegacy,omitempty"`
|
||||
TurnID string `json:"turnId,omitempty"`
|
||||
Sequence uint64 `json:"seq,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Text string `json:"text,omitempty" externalizable:"true"`
|
||||
Detail string `json:"detail,omitempty" externalizable:"true"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Reasoning string `json:"reasoning,omitempty" externalizable:"true"`
|
||||
MemoryCitations []MemoryCitation `json:"memoryCitations,omitempty"`
|
||||
Level string `json:"level,omitempty"`
|
||||
Tool *Tool `json:"tool,omitempty"`
|
||||
Usage *Usage `json:"usage,omitempty"`
|
||||
Approval *Approval `json:"approval,omitempty"`
|
||||
Ask *Ask `json:"ask,omitempty"`
|
||||
MCPInteraction *MCPInteraction `json:"mcpInteraction,omitempty"`
|
||||
Compaction *Compaction `json:"compaction,omitempty"`
|
||||
Maintenance *ContextMaintenance `json:"maintenance,omitempty"`
|
||||
Guardian *Guardian `json:"guardian,omitempty"`
|
||||
DecisionReceipt *DecisionReceipt `json:"decisionReceipt,omitempty"`
|
||||
Extension *ExtensionSurface `json:"extension,omitempty"`
|
||||
Err string `json:"err,omitempty" externalizable:"true"`
|
||||
Outcome string `json:"outcome,omitempty"`
|
||||
Readiness *FinalReadiness `json:"readiness,omitempty"`
|
||||
ProtocolRecovery *provider.ProtocolRecoveryAction `json:"protocolRecovery,omitempty"`
|
||||
Diagnostic *provider.FailureDiagnostic `json:"diagnostic,omitempty"`
|
||||
Receipt *CompletionReceipt `json:"receipt,omitempty"`
|
||||
CheckpointTurn *int `json:"checkpointTurn,omitempty"`
|
||||
Recovery *event.RecoveryStatus `json:"recovery,omitempty"`
|
||||
RetryAttempt int `json:"retryAttempt,omitempty"`
|
||||
RetryMax int `json:"retryMax,omitempty"`
|
||||
RetryScope string `json:"retryScope,omitempty"` // "headers" | "stream" | "protocol"; omit for older clients
|
||||
StreamAttempt *StreamAttempt `json:"streamAttempt,omitempty"`
|
||||
// ItemID correlates Steer / TurnDone / unapplied-steer with a durable
|
||||
// session-inbox entry. Empty for legacy text-only guidance.
|
||||
ItemID string `json:"itemId,omitempty"`
|
||||
// SessionPath routes frames emitted by detached Serve controllers. Older
|
||||
// clients ignore the omitted/unknown field and keep single-session behavior.
|
||||
SessionPath string `json:"sessionPath,omitempty"`
|
||||
// SessionCurrent is set by Serve at publication time for frames belonging
|
||||
// to its foreground controller. It lets all-session clients adopt an
|
||||
// externally selected/recovered foreground without polling on every token.
|
||||
SessionCurrent bool `json:"sessionCurrent,omitempty"`
|
||||
// SessionReset distinguishes a fresh /new or /clear target from a resumed
|
||||
// durable session when a different client rotates the foreground.
|
||||
SessionReset bool `json:"sessionReset,omitempty"`
|
||||
Workspace *WorkspaceChanged `json:"workspace,omitempty"`
|
||||
// Phase is set on turn_phase events: working | checking | verifying | reviewing.
|
||||
Phase string `json:"phase,omitempty"`
|
||||
// Completion is set on completion_summary events (content-free quality summary).
|
||||
Completion *CompletionSummary `json:"completion,omitempty"`
|
||||
}
|
||||
@@ -9,65 +9,6 @@ import (
|
||||
"reasonix/internal/provider"
|
||||
)
|
||||
|
||||
// Event is the JSON-friendly form shared by event frontends.
|
||||
// externalizable:"true" marks large string payloads the Remote protocol may
|
||||
// offload via content refs without changing provider-visible semantics.
|
||||
type Event struct {
|
||||
Kind string `json:"kind"`
|
||||
PromptID string `json:"promptId,omitempty"`
|
||||
PromptKind string `json:"promptKind,omitempty"`
|
||||
PromptLegacy bool `json:"promptLegacy,omitempty"`
|
||||
TurnID string `json:"turnId,omitempty"`
|
||||
Sequence uint64 `json:"seq,omitempty"`
|
||||
Status string `json:"status,omitempty"`
|
||||
Text string `json:"text,omitempty" externalizable:"true"`
|
||||
Detail string `json:"detail,omitempty" externalizable:"true"`
|
||||
Code string `json:"code,omitempty"`
|
||||
Reasoning string `json:"reasoning,omitempty" externalizable:"true"`
|
||||
MemoryCitations []MemoryCitation `json:"memoryCitations,omitempty"`
|
||||
Level string `json:"level,omitempty"`
|
||||
Tool *Tool `json:"tool,omitempty"`
|
||||
Usage *Usage `json:"usage,omitempty"`
|
||||
Approval *Approval `json:"approval,omitempty"`
|
||||
Ask *Ask `json:"ask,omitempty"`
|
||||
MCPInteraction *MCPInteraction `json:"mcpInteraction,omitempty"`
|
||||
Compaction *Compaction `json:"compaction,omitempty"`
|
||||
Maintenance *ContextMaintenance `json:"maintenance,omitempty"`
|
||||
Guardian *Guardian `json:"guardian,omitempty"`
|
||||
DecisionReceipt *DecisionReceipt `json:"decisionReceipt,omitempty"`
|
||||
Extension *ExtensionSurface `json:"extension,omitempty"`
|
||||
Err string `json:"err,omitempty" externalizable:"true"`
|
||||
Outcome string `json:"outcome,omitempty"`
|
||||
Readiness *FinalReadiness `json:"readiness,omitempty"`
|
||||
ProtocolRecovery *provider.ProtocolRecoveryAction `json:"protocolRecovery,omitempty"`
|
||||
Diagnostic *provider.FailureDiagnostic `json:"diagnostic,omitempty"`
|
||||
Receipt *CompletionReceipt `json:"receipt,omitempty"`
|
||||
CheckpointTurn *int `json:"checkpointTurn,omitempty"`
|
||||
Recovery *event.RecoveryStatus `json:"recovery,omitempty"`
|
||||
RetryAttempt int `json:"retryAttempt,omitempty"`
|
||||
RetryMax int `json:"retryMax,omitempty"`
|
||||
RetryScope string `json:"retryScope,omitempty"` // "headers" | "stream" | "protocol"; omit for older clients
|
||||
StreamAttempt *StreamAttempt `json:"streamAttempt,omitempty"`
|
||||
// ItemID correlates Steer / TurnDone / unapplied-steer with a durable
|
||||
// session-inbox entry. Empty for legacy text-only guidance.
|
||||
ItemID string `json:"itemId,omitempty"`
|
||||
// SessionPath routes frames emitted by detached Serve controllers. Older
|
||||
// clients ignore the omitted/unknown field and keep single-session behavior.
|
||||
SessionPath string `json:"sessionPath,omitempty"`
|
||||
// SessionCurrent is set by Serve at publication time for frames belonging
|
||||
// to its foreground controller. It lets all-session clients adopt an
|
||||
// externally selected/recovered foreground without polling on every token.
|
||||
SessionCurrent bool `json:"sessionCurrent,omitempty"`
|
||||
// SessionReset distinguishes a fresh /new or /clear target from a resumed
|
||||
// durable session when a different client rotates the foreground.
|
||||
SessionReset bool `json:"sessionReset,omitempty"`
|
||||
Workspace *WorkspaceChanged `json:"workspace,omitempty"`
|
||||
// Phase is set on turn_phase events: working | checking | verifying | reviewing.
|
||||
Phase string `json:"phase,omitempty"`
|
||||
// Completion is set on completion_summary events (content-free quality summary).
|
||||
Completion *CompletionSummary `json:"completion,omitempty"`
|
||||
}
|
||||
|
||||
// CompletionSummary is the JSON form of event.CompletionSummaryInfo.
|
||||
type CompletionSummary struct {
|
||||
Preset string `json:"preset"` // deprecated; pinned compat value
|
||||
|
||||
+45
-160
@@ -139,6 +139,7 @@ type Job struct {
|
||||
|
||||
artifactPath string
|
||||
artifactMetaPath string
|
||||
artifactStatus Status // last metadata phase; may precede published terminal status
|
||||
artifactFile *os.File
|
||||
artifactComplete bool
|
||||
artifactErr string
|
||||
@@ -150,13 +151,14 @@ type Job struct {
|
||||
|
||||
// Manager is the session's background-job table. It is safe for concurrent use.
|
||||
type Manager struct {
|
||||
sink event.Sink
|
||||
root context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
onJobStart func(done <-chan struct{})
|
||||
ownerID string
|
||||
ownerDone sync.Once
|
||||
runtimeObservers runtimeObservers
|
||||
sink event.Sink
|
||||
root context.Context
|
||||
cancel context.CancelFunc
|
||||
wg sync.WaitGroup
|
||||
onJobStart func(done <-chan struct{})
|
||||
ownerID string
|
||||
ownerDone sync.Once
|
||||
// sessionOwnershipProbe authorizes destructive repair of persisted running
|
||||
// artifacts. A nil probe is conservative: an observer that cannot prove it
|
||||
// owns the transcript must never publish an interrupted tombstone.
|
||||
@@ -403,143 +405,8 @@ func (m *Manager) startInvalid(parentSession, kind, label string, validationErr
|
||||
m.order = append(m.order, key)
|
||||
m.mu.Unlock()
|
||||
close(j.done)
|
||||
m.recordCompletion(parentSession, id, kind, label, Failed, validationErr)
|
||||
return j
|
||||
}
|
||||
|
||||
// StartForSession launches a job owned by parentSession. Session-scoped readers
|
||||
// only see jobs whose owner matches the active session.
|
||||
func (m *Manager) StartForSession(parentSession, kind, label string, run func(ctx context.Context, out io.Writer) (string, error)) *Job {
|
||||
parentSession = strings.TrimSpace(parentSession)
|
||||
kind = strings.TrimSpace(kind)
|
||||
if err := validatePathSegment(parentSession, "parentSession"); err != nil {
|
||||
return m.startInvalid(parentSession, kind, label, err)
|
||||
}
|
||||
if err := validatePathSegment(kind, "kind"); err != nil {
|
||||
return m.startInvalid(parentSession, kind, label, err)
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.seq++
|
||||
id := fmt.Sprintf("%s-%d", kind, m.seq)
|
||||
ctx, cancel := context.WithCancel(m.root)
|
||||
startedAt := nowMs()
|
||||
logPath, metaPath, file, artifactErr := m.openArtifactLocked(parentSession, id)
|
||||
j := &Job{
|
||||
ID: id,
|
||||
Kind: kind,
|
||||
Label: label,
|
||||
SessionID: parentSession,
|
||||
status: Running,
|
||||
startedAt: startedAt,
|
||||
activityAt: startedAt,
|
||||
cancel: cancel,
|
||||
done: make(chan struct{}),
|
||||
artifactPath: logPath,
|
||||
artifactMetaPath: metaPath,
|
||||
artifactFile: file,
|
||||
artifactComplete: artifactErr == "",
|
||||
artifactErr: artifactErr,
|
||||
}
|
||||
ctx = WithSession(ctx, parentSession)
|
||||
ctx = context.WithValue(ctx, jobCtxKey{}, j)
|
||||
key := jobKey(parentSession, id)
|
||||
m.jobs[key] = j
|
||||
m.order = append(m.order, key)
|
||||
m.mu.Unlock()
|
||||
j.mu.Lock()
|
||||
if err := m.writeJobMetaLocked(j, Running); err != nil {
|
||||
j.artifactComplete = false
|
||||
j.artifactErr = err.Error()
|
||||
}
|
||||
j.mu.Unlock()
|
||||
if m.onJobStart != nil {
|
||||
m.onJobStart(j.done)
|
||||
}
|
||||
|
||||
m.emitIfActive(parentSession, event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: startedText(kind, id, label)})
|
||||
|
||||
if !nilutil.IsNil(m.taskRecorder) {
|
||||
m.taskRecorder.RecordStart(id, kind, label)
|
||||
}
|
||||
|
||||
m.wg.Add(1)
|
||||
if m.stalledWarning > 0 {
|
||||
m.wg.Add(1)
|
||||
go m.monitorStalled(parentSession, j)
|
||||
}
|
||||
go func() {
|
||||
defer m.wg.Done()
|
||||
result, err := runRecovered(ctx, jobWriter{j}, run)
|
||||
j.mu.Lock()
|
||||
j.runReturned = true
|
||||
j.mu.Unlock()
|
||||
|
||||
var st Status
|
||||
switch {
|
||||
case ctx.Err() != nil:
|
||||
st = Killed
|
||||
case err != nil:
|
||||
st = Failed
|
||||
if result == "" {
|
||||
result = err.Error()
|
||||
}
|
||||
default:
|
||||
st = Done
|
||||
}
|
||||
finishedAt := nowMs()
|
||||
if result != "" {
|
||||
j.mu.Lock()
|
||||
if j.artifactFile != nil {
|
||||
if _, writeErr := j.artifactFile.WriteString(result); writeErr != nil {
|
||||
j.artifactErr = writeErr.Error()
|
||||
}
|
||||
} else {
|
||||
j.result = result
|
||||
}
|
||||
j.tail = appendTail(j.tail, []byte(result), defaultTailBytes)
|
||||
j.mu.Unlock()
|
||||
}
|
||||
targetDir := m.artifactTargetDirForJob(j)
|
||||
j.mu.Lock()
|
||||
if j.artifactFile != nil {
|
||||
if closeErr := j.artifactFile.Close(); closeErr != nil && j.artifactErr == "" {
|
||||
j.artifactErr = closeErr.Error()
|
||||
}
|
||||
j.artifactFile = nil
|
||||
}
|
||||
if j.artifactErr != "" {
|
||||
j.artifactComplete = false
|
||||
}
|
||||
j.finishedAt = finishedAt
|
||||
if targetDir != "" {
|
||||
if moveErr := j.moveArtifactToDirLocked(targetDir); moveErr != nil {
|
||||
j.noteArtifactErr("migration: " + moveErr.Error())
|
||||
}
|
||||
}
|
||||
metaErr := m.writeJobMetaLocked(j, st)
|
||||
if metaErr != nil {
|
||||
j.noteArtifactErr("metadata: " + metaErr.Error())
|
||||
}
|
||||
j.mu.Unlock()
|
||||
// Queue the drain note (and emit the closing Notice) BEFORE publishing the
|
||||
// terminal status. Wait(nil)/resolve only block on Running jobs, so if the
|
||||
// status flipped to terminal before the note was queued, a Wait could observe
|
||||
// completion, skip j.done, and DrainCompletedNote would race ahead of the
|
||||
// bookkeeping (the TestDrainMultiple -race flake). Recording first makes an
|
||||
// observed terminal status imply the note is already queued.
|
||||
m.recordCompletion(parentSession, id, kind, label, st, err)
|
||||
|
||||
j.mu.Lock()
|
||||
if j.status != Killed { // a concurrent Kill already published Killed — keep it
|
||||
j.status = st
|
||||
}
|
||||
if j.artifactPath != "" && j.artifactComplete {
|
||||
j.result = ""
|
||||
j.tail = nil
|
||||
}
|
||||
j.mu.Unlock()
|
||||
close(j.done)
|
||||
}()
|
||||
m.recordCompletion(j, Failed, validationErr)
|
||||
m.notifyRuntime(parentSession, id)
|
||||
return j
|
||||
}
|
||||
|
||||
@@ -601,6 +468,7 @@ func (m *Manager) artifactDirLocked(parentSession string) string {
|
||||
}
|
||||
|
||||
func (m *Manager) writeJobMetaLocked(j *Job, st Status) error {
|
||||
j.artifactStatus = st
|
||||
if j.artifactMetaPath == "" {
|
||||
return nil
|
||||
}
|
||||
@@ -775,17 +643,18 @@ func (m *Manager) monitorStalled(parentSession string, j *Job) {
|
||||
|
||||
// recordCompletion queues the finished-job summary for DrainCompletedNote and
|
||||
// emits a closing Notice (warn for a failure, info otherwise).
|
||||
func (m *Manager) recordCompletion(parentSession, id, kind, label string, st Status, err error) {
|
||||
func (m *Manager) recordCompletion(j *Job, st Status, err error) string {
|
||||
id, kind, label := j.ID, j.Kind, j.Label
|
||||
tag := id
|
||||
if label != "" {
|
||||
tag = fmt.Sprintf("%s (%s)", id, label)
|
||||
}
|
||||
parentSession = strings.TrimSpace(parentSession)
|
||||
shouldEmit := false
|
||||
m.mu.Lock()
|
||||
parentSession := strings.TrimSpace(j.SessionID)
|
||||
if parentSession != "" && m.destroying[parentSession] {
|
||||
m.mu.Unlock()
|
||||
return
|
||||
return parentSession
|
||||
}
|
||||
m.completed = append(m.completed, completion{
|
||||
sessionID: parentSession,
|
||||
@@ -811,6 +680,7 @@ func (m *Manager) recordCompletion(parentSession, id, kind, label string, st Sta
|
||||
if shouldEmit {
|
||||
m.sink.Emit(event.Event{Kind: event.Notice, Code: event.NoticeCodeBackgroundJobFinished, Level: level, Text: text, Detail: detail})
|
||||
}
|
||||
return parentSession
|
||||
}
|
||||
|
||||
func (m *Manager) recordStalled(parentSession, id, kind, label string) {
|
||||
@@ -1239,6 +1109,7 @@ func validateTrustedSessionPath(sessionPath string) error {
|
||||
// the session sidecar. sessionPath must come from the trusted store/controller
|
||||
// path; this method does not establish filesystem containment on its own.
|
||||
func (m *Manager) SetActiveSessionPath(parentSession, sessionPath string) {
|
||||
defer func() { m.notifyRuntime("", "") }()
|
||||
parentSession = strings.TrimSpace(parentSession)
|
||||
sessionPath = strings.TrimSpace(sessionPath)
|
||||
// Preserve the legacy active-only behavior for calls without a complete
|
||||
@@ -1280,26 +1151,33 @@ func (m *Manager) SetActiveSessionPath(parentSession, sessionPath string) {
|
||||
loaded := m.loaded[parentSession]
|
||||
m.mu.Unlock()
|
||||
|
||||
var migrationErr error
|
||||
if oldDir != "" && newDir != "" && oldDir != newDir {
|
||||
oldSession := parentSession
|
||||
if adoptDefault {
|
||||
oldSession = ""
|
||||
}
|
||||
if err := m.migrateArtifactDirForSession(oldSession, oldDir, newDir); err != nil {
|
||||
if adoptDefault {
|
||||
m.mu.Lock()
|
||||
m.adoptUnscopedJobsLocked(parentSession)
|
||||
m.mu.Unlock()
|
||||
migrationErr = m.migrateArtifactDirForSession(oldSession, oldDir, newDir)
|
||||
}
|
||||
if adoptDefault {
|
||||
m.mu.Lock()
|
||||
adopted := m.adoptUnscopedJobsLocked(parentSession)
|
||||
m.mu.Unlock()
|
||||
for _, j := range adopted {
|
||||
j.mu.Lock()
|
||||
st := j.artifactStatus
|
||||
if st == "" {
|
||||
st = j.status
|
||||
}
|
||||
m.recordArtifactMigrationError(parentSession, err)
|
||||
} else {
|
||||
m.mu.Lock()
|
||||
if adoptDefault {
|
||||
m.adoptUnscopedJobsLocked(parentSession)
|
||||
if err := m.writeJobMetaLocked(j, st); err != nil {
|
||||
j.noteArtifactErr("ownership metadata: " + err.Error())
|
||||
}
|
||||
m.mu.Unlock()
|
||||
j.mu.Unlock()
|
||||
}
|
||||
}
|
||||
if migrationErr != nil {
|
||||
m.recordArtifactMigrationError(parentSession, migrationErr)
|
||||
}
|
||||
if !loaded {
|
||||
m.loadSessionArtifacts(parentSession, sessionPath, newDir)
|
||||
}
|
||||
@@ -1314,10 +1192,11 @@ func (m *Manager) hasUnscopedJobsLocked() bool {
|
||||
return false
|
||||
}
|
||||
|
||||
func (m *Manager) adoptUnscopedJobsLocked(parentSession string) {
|
||||
func (m *Manager) adoptUnscopedJobsLocked(parentSession string) []*Job {
|
||||
var adopted []*Job
|
||||
parentSession = strings.TrimSpace(parentSession)
|
||||
if parentSession == "" {
|
||||
return
|
||||
return adopted
|
||||
}
|
||||
for i := range m.completed {
|
||||
if strings.TrimSpace(m.completed[i].sessionID) == "" {
|
||||
@@ -1337,7 +1216,12 @@ func (m *Manager) adoptUnscopedJobsLocked(parentSession string) {
|
||||
continue
|
||||
}
|
||||
delete(m.jobs, oldKey)
|
||||
// Manager readers and artifact writers use distinct locks. Ownership
|
||||
// changes hold both, always in manager-before-job order.
|
||||
j.mu.Lock()
|
||||
j.SessionID = parentSession
|
||||
j.mu.Unlock()
|
||||
adopted = append(adopted, j)
|
||||
m.jobs[newKey] = j
|
||||
for i, key := range m.order {
|
||||
if key == oldKey {
|
||||
@@ -1345,6 +1229,7 @@ func (m *Manager) adoptUnscopedJobsLocked(parentSession string) {
|
||||
}
|
||||
}
|
||||
}
|
||||
return adopted
|
||||
}
|
||||
|
||||
func (m *Manager) recordArtifactMigrationError(parentSession string, err error) {
|
||||
|
||||
@@ -0,0 +1,75 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"io"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRuntimeStateAdoptionAndCompletionShareOwnership(t *testing.T) {
|
||||
for _, phase := range []string{"before-completion", "during-notice", "after-done"} {
|
||||
t.Run(phase, func(t *testing.T) {
|
||||
sink := &blockingFinishedSink{entered: make(chan struct{}), released: make(chan struct{})}
|
||||
m := NewManager(sink)
|
||||
defer m.Close()
|
||||
runRelease := make(chan struct{})
|
||||
releaseRun := sync.OnceFunc(func() { close(runRelease) })
|
||||
releaseNotice := sync.OnceFunc(func() { close(sink.released) })
|
||||
defer releaseRun()
|
||||
defer releaseNotice()
|
||||
job := m.StartForSession("", "bash", "adoption", func(context.Context, io.Writer) (string, error) {
|
||||
<-runRelease
|
||||
return "complete", nil
|
||||
})
|
||||
path := filepath.Join(t.TempDir(), "adopted.jsonl")
|
||||
if phase == "before-completion" {
|
||||
m.SetActiveSessionPath("adopted", path)
|
||||
}
|
||||
releaseRun()
|
||||
<-sink.entered
|
||||
select {
|
||||
case <-job.done:
|
||||
t.Fatal("done closed before completion notice finished")
|
||||
default:
|
||||
}
|
||||
if phase == "during-notice" {
|
||||
m.SetActiveSessionPath("adopted", path)
|
||||
}
|
||||
releaseNotice()
|
||||
<-job.done
|
||||
if phase == "after-done" {
|
||||
m.SetActiveSessionPath("adopted", path)
|
||||
}
|
||||
if got := m.DrainCompletedNoteForSession("other"); got != "" {
|
||||
t.Fatalf("completion leaked: %s", got)
|
||||
}
|
||||
if got := m.DrainCompletedNoteForSession("adopted"); strings.Count(got, job.ID) != 1 {
|
||||
t.Fatalf("completion not owned exactly once: %q", got)
|
||||
}
|
||||
if got := m.DrainCompletedNoteForSession("adopted"); got != "" {
|
||||
t.Fatalf("completion replayed: %q", got)
|
||||
}
|
||||
job.mu.Lock()
|
||||
metaPath := job.artifactMetaPath
|
||||
job.mu.Unlock()
|
||||
if metaPath != "" {
|
||||
data, err := os.ReadFile(metaPath)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var meta artifactMeta
|
||||
if err := json.Unmarshal(data, &meta); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if meta.SessionID != "adopted" {
|
||||
t.Fatalf("artifact kept old owner: %q", meta.SessionID)
|
||||
}
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
package jobs
|
||||
|
||||
import "sync"
|
||||
|
||||
// RuntimeState counts operational work, including cancelled processes which
|
||||
// have not exited. Revisions order updates from concurrent jobs in a manager.
|
||||
type RuntimeState struct {
|
||||
SessionID string
|
||||
JobID string
|
||||
Revision uint64
|
||||
Running int
|
||||
}
|
||||
|
||||
type runtimeObservers struct {
|
||||
mu sync.Mutex
|
||||
revision uint64
|
||||
next uint64
|
||||
listeners map[uint64]*runtimeSubscription
|
||||
}
|
||||
|
||||
type runtimeSubscription struct {
|
||||
mu sync.Mutex
|
||||
session string
|
||||
callback func(RuntimeState)
|
||||
pending *RuntimeState
|
||||
draining bool
|
||||
closed bool
|
||||
}
|
||||
|
||||
func (s *runtimeSubscription) enqueue(state RuntimeState) {
|
||||
s.mu.Lock()
|
||||
if s.closed {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.pending = &state
|
||||
if s.draining {
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
s.draining = true
|
||||
s.mu.Unlock()
|
||||
go func() {
|
||||
for {
|
||||
s.mu.Lock()
|
||||
if s.closed || s.pending == nil {
|
||||
s.draining = false
|
||||
s.mu.Unlock()
|
||||
return
|
||||
}
|
||||
state := *s.pending
|
||||
s.pending = nil
|
||||
s.mu.Unlock()
|
||||
s.callback(state)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// SubscribeRuntime registers before reading, so a completion cannot disappear
|
||||
// in the subscribe/read gap. Callbacks run off-lock with one pending snapshot.
|
||||
func (m *Manager) SubscribeRuntime(session string, callback func(RuntimeState)) (RuntimeState, func()) {
|
||||
m.runtimeObservers.mu.Lock()
|
||||
defer m.runtimeObservers.mu.Unlock()
|
||||
m.runtimeObservers.next++
|
||||
id := m.runtimeObservers.next
|
||||
if m.runtimeObservers.listeners == nil {
|
||||
m.runtimeObservers.listeners = map[uint64]*runtimeSubscription{}
|
||||
}
|
||||
sub := &runtimeSubscription{session: session, callback: callback}
|
||||
m.runtimeObservers.listeners[id] = sub
|
||||
initial := RuntimeState{SessionID: session, Revision: m.runtimeObservers.revision, Running: len(m.RunningForSession(session))}
|
||||
return initial, func() {
|
||||
m.runtimeObservers.mu.Lock()
|
||||
delete(m.runtimeObservers.listeners, id)
|
||||
m.runtimeObservers.mu.Unlock()
|
||||
sub.mu.Lock()
|
||||
sub.closed = true
|
||||
sub.pending = nil
|
||||
sub.mu.Unlock()
|
||||
}
|
||||
}
|
||||
|
||||
// notifyRuntime runs only after committing a start or closing done. Notices
|
||||
// still precede done to protect DrainCompletedNote; they are not state signals.
|
||||
func (m *Manager) notifyRuntime(session, jobID string) {
|
||||
m.runtimeObservers.mu.Lock()
|
||||
defer m.runtimeObservers.mu.Unlock()
|
||||
m.runtimeObservers.revision++
|
||||
for _, sub := range m.runtimeObservers.listeners {
|
||||
if sub.session != "" && session != "" && sub.session != session {
|
||||
continue
|
||||
}
|
||||
sub.enqueue(RuntimeState{SessionID: sub.session, JobID: jobID, Revision: m.runtimeObservers.revision, Running: len(m.RunningForSession(sub.session))})
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,234 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"io"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func receiveRuntimeState(t *testing.T, states <-chan RuntimeState) RuntimeState {
|
||||
t.Helper()
|
||||
select {
|
||||
case state := <-states:
|
||||
return state
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("runtime subscriber did not receive a committed state")
|
||||
return RuntimeState{}
|
||||
}
|
||||
}
|
||||
|
||||
// Completion notices are emitted after the drain note is queued, but before
|
||||
// the job's lifetime ends. Runtime subscribers must observe a separate settled
|
||||
// lifecycle notification; treating this notice as idle would release guards
|
||||
// while the job is still unwinding.
|
||||
func TestRuntimeStateCompletionNoticePrecedesJobExit(t *testing.T) {
|
||||
sink := &blockingFinishedSink{entered: make(chan struct{}), released: make(chan struct{})}
|
||||
m := NewManager(sink)
|
||||
defer m.Close()
|
||||
m.SetActiveSessionPath("runtime-session", filepath.Join(t.TempDir(), "session.jsonl"))
|
||||
job := m.StartForSession("runtime-session", "bash", "completion boundary", func(context.Context, io.Writer) (string, error) {
|
||||
return "isolated result", nil
|
||||
})
|
||||
released := false
|
||||
defer func() {
|
||||
if !released {
|
||||
close(sink.released)
|
||||
}
|
||||
}()
|
||||
select {
|
||||
case <-sink.entered:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("completion notice was not delivered")
|
||||
}
|
||||
select {
|
||||
case <-job.done:
|
||||
t.Fatal("job lifetime ended before completion bookkeeping returned")
|
||||
default:
|
||||
}
|
||||
if got := m.RunningForSession("runtime-session"); len(got) != 1 || got[0].ID != job.ID {
|
||||
t.Fatalf("notice must not prematurely release runtime protection: %+v", got)
|
||||
}
|
||||
if note := m.DrainCompletedNoteForSession("runtime-session"); note == "" {
|
||||
t.Fatal("completion notice became visible before its drain note")
|
||||
}
|
||||
close(sink.released)
|
||||
released = true
|
||||
select {
|
||||
case <-job.done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("job failed to unwind after notice delivery")
|
||||
}
|
||||
if got := m.RunningForSession("runtime-session"); len(got) != 0 {
|
||||
t.Fatalf("completed job remains running: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeStateCompletionPublishedAfterJobExit(t *testing.T) {
|
||||
sink := &blockingFinishedSink{entered: make(chan struct{}), released: make(chan struct{})}
|
||||
m := NewManager(sink)
|
||||
defer m.Close()
|
||||
m.SetActiveSessionPath("runtime-session", filepath.Join(t.TempDir(), "session.jsonl"))
|
||||
states := make(chan RuntimeState, 4)
|
||||
initial, unsubscribe := m.SubscribeRuntime("runtime-session", func(state RuntimeState) { states <- state })
|
||||
defer unsubscribe()
|
||||
if initial.Running != 0 || initial.SessionID != "runtime-session" {
|
||||
t.Fatalf("unexpected initial snapshot: %+v", initial)
|
||||
}
|
||||
runRelease := make(chan struct{})
|
||||
job := m.StartForSession("runtime-session", "bash", "settled notification", func(context.Context, io.Writer) (string, error) {
|
||||
<-runRelease
|
||||
return "done", nil
|
||||
})
|
||||
var releaseOnce sync.Once
|
||||
defer releaseOnce.Do(func() { close(sink.released) })
|
||||
started := receiveRuntimeState(t, states)
|
||||
close(runRelease)
|
||||
if started.Running != 1 || started.JobID != job.ID || started.Revision <= initial.Revision {
|
||||
t.Fatalf("invalid started snapshot: initial=%+v started=%+v", initial, started)
|
||||
}
|
||||
select {
|
||||
case <-sink.entered:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("completion notice was not delivered")
|
||||
}
|
||||
select {
|
||||
case state := <-states:
|
||||
t.Fatalf("completion published before job exit: %+v", state)
|
||||
default:
|
||||
}
|
||||
releaseOnce.Do(func() { close(sink.released) })
|
||||
completed := receiveRuntimeState(t, states)
|
||||
if completed.Running != 0 || completed.JobID != job.ID || completed.Revision <= started.Revision {
|
||||
t.Fatalf("invalid completion snapshot: started=%+v completed=%+v", started, completed)
|
||||
}
|
||||
select {
|
||||
case <-job.done:
|
||||
default:
|
||||
t.Fatal("idle snapshot was published before closing the job lifetime")
|
||||
}
|
||||
if got := m.RunningForSession("runtime-session"); len(got) != 0 {
|
||||
t.Fatalf("published idle disagrees with running query: %+v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeStateCancelledJobRetainsProtectionUntilExit(t *testing.T) {
|
||||
m := NewManager(nil)
|
||||
defer m.Close()
|
||||
states := make(chan RuntimeState, 4)
|
||||
_, unsubscribe := m.SubscribeRuntime("runtime-session", func(state RuntimeState) { states <- state })
|
||||
defer unsubscribe()
|
||||
cancelled := make(chan struct{})
|
||||
runRelease := make(chan struct{})
|
||||
var releaseOnce sync.Once
|
||||
defer releaseOnce.Do(func() { close(runRelease) })
|
||||
job := m.StartForSession("runtime-session", "bash", "cancel unwind", func(ctx context.Context, _ io.Writer) (string, error) {
|
||||
<-ctx.Done()
|
||||
close(cancelled)
|
||||
<-runRelease
|
||||
return "", ctx.Err()
|
||||
})
|
||||
started := receiveRuntimeState(t, states)
|
||||
if !m.KillForSession("runtime-session", job.ID) {
|
||||
t.Fatal("cancel request was rejected")
|
||||
}
|
||||
select {
|
||||
case <-cancelled:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("job did not receive cancellation")
|
||||
}
|
||||
current, stopProbe := m.SubscribeRuntime("runtime-session", func(RuntimeState) {})
|
||||
stopProbe()
|
||||
if started.Running != 1 || current.Running != 1 {
|
||||
t.Fatalf("cancellation released protection before exit: started=%+v current=%+v", started, current)
|
||||
}
|
||||
releaseOnce.Do(func() { close(runRelease) })
|
||||
for {
|
||||
completed := receiveRuntimeState(t, states)
|
||||
if completed.Running != 0 {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case <-job.done:
|
||||
default:
|
||||
t.Fatal("cancelled job published idle before exiting")
|
||||
}
|
||||
if completed.Revision <= started.Revision {
|
||||
t.Fatalf("completion revision did not advance: %+v", completed)
|
||||
}
|
||||
break
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeStatePublishesForNonActiveSession(t *testing.T) {
|
||||
m := NewManager(nil)
|
||||
defer m.Close()
|
||||
m.SetActiveSession("visible-session")
|
||||
states := make(chan RuntimeState, 4)
|
||||
_, unsubscribe := m.SubscribeRuntime("background-session", func(state RuntimeState) { states <- state })
|
||||
defer unsubscribe()
|
||||
runRelease := make(chan struct{})
|
||||
job := m.StartForSession("background-session", "bash", "hidden session", func(context.Context, io.Writer) (string, error) {
|
||||
<-runRelease
|
||||
return "done", nil
|
||||
})
|
||||
started := receiveRuntimeState(t, states)
|
||||
close(runRelease)
|
||||
completed := receiveRuntimeState(t, states)
|
||||
if started.SessionID != "background-session" || started.Running != 1 || started.JobID != job.ID {
|
||||
t.Fatalf("non-active start was misrouted: %+v", started)
|
||||
}
|
||||
if completed.SessionID != "background-session" || completed.Running != 0 || completed.JobID != job.ID || completed.Revision <= started.Revision {
|
||||
t.Fatalf("non-active completion was missing or misrouted: %+v", completed)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeStateUnsubscribeDiscardsPendingCallbacks(t *testing.T) {
|
||||
m := NewManager(nil)
|
||||
defer m.Close()
|
||||
entered, release, returned := make(chan struct{}), make(chan struct{}), make(chan struct{})
|
||||
var count atomic.Int32
|
||||
_, unsubscribe := m.SubscribeRuntime("runtime-session", func(RuntimeState) {
|
||||
if count.Add(1) == 1 {
|
||||
close(entered)
|
||||
<-release
|
||||
close(returned)
|
||||
}
|
||||
})
|
||||
defer unsubscribe()
|
||||
var releaseOnce sync.Once
|
||||
defer releaseOnce.Do(func() { close(release) })
|
||||
job := m.StartForSession("runtime-session", "bash", "unsubscribed job", func(context.Context, io.Writer) (string, error) { return "done", nil })
|
||||
select {
|
||||
case <-entered:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("subscriber did not enter")
|
||||
}
|
||||
select {
|
||||
case <-job.done:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("slow subscriber blocked job completion")
|
||||
}
|
||||
m.runtimeObservers.mu.Lock()
|
||||
var subscription *runtimeSubscription
|
||||
for _, candidate := range m.runtimeObservers.listeners {
|
||||
subscription = candidate
|
||||
}
|
||||
m.runtimeObservers.mu.Unlock()
|
||||
unsubscribe()
|
||||
releaseOnce.Do(func() { close(release) })
|
||||
<-returned
|
||||
// Wait for the already-entered callback to leave the dispatcher. This is
|
||||
// an observation barrier, not a delay used to infer no future callbacks.
|
||||
waitFor(t, func() bool {
|
||||
subscription.mu.Lock()
|
||||
defer subscription.mu.Unlock()
|
||||
return !subscription.draining
|
||||
})
|
||||
if got := count.Load(); got != 1 {
|
||||
t.Fatalf("unsubscribe allowed %d callbacks; only the entered callback may finish", got)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,147 @@
|
||||
package jobs
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"io"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/nilutil"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// StartForSession launches a job owned by parentSession. Session-scoped readers
|
||||
// only see jobs whose owner matches the active session.
|
||||
func (m *Manager) StartForSession(parentSession, kind, label string, run func(ctx context.Context, out io.Writer) (string, error)) *Job {
|
||||
parentSession = strings.TrimSpace(parentSession)
|
||||
kind = strings.TrimSpace(kind)
|
||||
if err := validatePathSegment(parentSession, "parentSession"); err != nil {
|
||||
return m.startInvalid(parentSession, kind, label, err)
|
||||
}
|
||||
if err := validatePathSegment(kind, "kind"); err != nil {
|
||||
return m.startInvalid(parentSession, kind, label, err)
|
||||
}
|
||||
m.mu.Lock()
|
||||
m.seq++
|
||||
id := fmt.Sprintf("%s-%d", kind, m.seq)
|
||||
ctx, cancel := context.WithCancel(m.root)
|
||||
startedAt := nowMs()
|
||||
logPath, metaPath, file, artifactErr := m.openArtifactLocked(parentSession, id)
|
||||
j := &Job{
|
||||
ID: id,
|
||||
Kind: kind,
|
||||
Label: label,
|
||||
SessionID: parentSession,
|
||||
status: Running,
|
||||
startedAt: startedAt,
|
||||
activityAt: startedAt,
|
||||
cancel: cancel,
|
||||
done: make(chan struct{}),
|
||||
artifactPath: logPath,
|
||||
artifactMetaPath: metaPath,
|
||||
artifactFile: file,
|
||||
artifactComplete: artifactErr == "",
|
||||
artifactErr: artifactErr,
|
||||
}
|
||||
ctx = WithSession(ctx, parentSession)
|
||||
ctx = context.WithValue(ctx, jobCtxKey{}, j)
|
||||
key := jobKey(parentSession, id)
|
||||
m.jobs[key] = j
|
||||
m.order = append(m.order, key)
|
||||
m.mu.Unlock()
|
||||
j.mu.Lock()
|
||||
if err := m.writeJobMetaLocked(j, Running); err != nil {
|
||||
j.artifactComplete = false
|
||||
j.artifactErr = err.Error()
|
||||
}
|
||||
j.mu.Unlock()
|
||||
if m.onJobStart != nil {
|
||||
m.onJobStart(j.done)
|
||||
}
|
||||
|
||||
m.emitIfActive(parentSession, event.Event{Kind: event.Notice, Level: event.LevelInfo, Text: startedText(kind, id, label)})
|
||||
m.notifyRuntime(parentSession, id)
|
||||
|
||||
if !nilutil.IsNil(m.taskRecorder) {
|
||||
m.taskRecorder.RecordStart(id, kind, label)
|
||||
}
|
||||
|
||||
m.wg.Add(1)
|
||||
if m.stalledWarning > 0 {
|
||||
m.wg.Add(1)
|
||||
go m.monitorStalled(parentSession, j)
|
||||
}
|
||||
go m.runJob(ctx, j, run)
|
||||
return j
|
||||
}
|
||||
|
||||
func (m *Manager) runJob(ctx context.Context, j *Job, run func(context.Context, io.Writer) (string, error)) {
|
||||
defer m.wg.Done()
|
||||
result, err := runRecovered(ctx, jobWriter{j}, run)
|
||||
j.mu.Lock()
|
||||
j.runReturned = true
|
||||
j.mu.Unlock()
|
||||
|
||||
var st Status
|
||||
switch {
|
||||
case ctx.Err() != nil:
|
||||
st = Killed
|
||||
case err != nil:
|
||||
st = Failed
|
||||
if result == "" {
|
||||
result = err.Error()
|
||||
}
|
||||
default:
|
||||
st = Done
|
||||
}
|
||||
finishedAt := nowMs()
|
||||
if result != "" {
|
||||
j.mu.Lock()
|
||||
if j.artifactFile != nil {
|
||||
if _, writeErr := j.artifactFile.WriteString(result); writeErr != nil {
|
||||
j.artifactErr = writeErr.Error()
|
||||
}
|
||||
} else {
|
||||
j.result = result
|
||||
}
|
||||
j.tail = appendTail(j.tail, []byte(result), defaultTailBytes)
|
||||
j.mu.Unlock()
|
||||
}
|
||||
targetDir := m.artifactTargetDirForJob(j)
|
||||
j.mu.Lock()
|
||||
if j.artifactFile != nil {
|
||||
if closeErr := j.artifactFile.Close(); closeErr != nil && j.artifactErr == "" {
|
||||
j.artifactErr = closeErr.Error()
|
||||
}
|
||||
j.artifactFile = nil
|
||||
}
|
||||
if j.artifactErr != "" {
|
||||
j.artifactComplete = false
|
||||
}
|
||||
j.finishedAt = finishedAt
|
||||
if targetDir != "" {
|
||||
if moveErr := j.moveArtifactToDirLocked(targetDir); moveErr != nil {
|
||||
j.noteArtifactErr("migration: " + moveErr.Error())
|
||||
}
|
||||
}
|
||||
metaErr := m.writeJobMetaLocked(j, st)
|
||||
if metaErr != nil {
|
||||
j.noteArtifactErr("metadata: " + metaErr.Error())
|
||||
}
|
||||
j.mu.Unlock()
|
||||
// Queue the drain note and closing Notice before terminal status so Wait
|
||||
// cannot observe completion before DrainCompletedNote sees its bookkeeping.
|
||||
// The structured runtime notification follows the actual done boundary.
|
||||
parentSession := m.recordCompletion(j, st, err)
|
||||
|
||||
j.mu.Lock()
|
||||
if j.status != Killed { // a concurrent Kill already published Killed — keep it
|
||||
j.status = st
|
||||
}
|
||||
if j.artifactPath != "" && j.artifactComplete {
|
||||
j.result = ""
|
||||
j.tail = nil
|
||||
}
|
||||
j.mu.Unlock()
|
||||
close(j.done)
|
||||
m.notifyRuntime(parentSession, j.ID)
|
||||
}
|
||||
+56
-6
@@ -12,6 +12,7 @@ import (
|
||||
|
||||
func (s *Server) registerInboxRoutes(mux *http.ServeMux) {
|
||||
mux.HandleFunc("GET /inbox", s.inboxList)
|
||||
mux.HandleFunc("GET /inbox/receipt", s.inboxReceipt)
|
||||
mux.HandleFunc("POST /inbox/items", s.foregroundMutation(s.inboxEnqueue))
|
||||
mux.HandleFunc("GET /inbox/items/{id}", s.inboxGet)
|
||||
mux.HandleFunc("PATCH /inbox/items/{id}", s.foregroundMutation(s.inboxUpdate))
|
||||
@@ -33,7 +34,7 @@ func writeInboxError(w http.ResponseWriter, err error) {
|
||||
http.Error(w, err.Error(), http.StatusRequestEntityTooLarge) // 413
|
||||
case errors.Is(err, sessioninbox.ErrCapacityItems), errors.Is(err, sessioninbox.ErrCapacityBytes),
|
||||
errors.Is(err, sessioninbox.ErrInvalidState), errors.Is(err, sessioninbox.ErrPaused),
|
||||
errors.Is(err, sessioninbox.ErrNotFound):
|
||||
errors.Is(err, sessioninbox.ErrNotFound), errors.Is(err, sessioninbox.ErrIdempotencyConflict):
|
||||
http.Error(w, err.Error(), http.StatusConflict) // 409
|
||||
case errors.Is(err, sessioninbox.ErrEmpty):
|
||||
http.Error(w, err.Error(), http.StatusBadRequest)
|
||||
@@ -43,17 +44,36 @@ func writeInboxError(w http.ResponseWriter, err error) {
|
||||
}
|
||||
|
||||
func (s *Server) inboxList(w http.ResponseWriter, r *http.Request) {
|
||||
_ = r
|
||||
s.bindMu.Lock()
|
||||
defer s.bindMu.Unlock()
|
||||
if !s.validateInboxReadSessionLocked(w, r) {
|
||||
return
|
||||
}
|
||||
snap := s.inboxAPI().InboxSnapshot()
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
_ = json.NewEncoder(w).Encode(snap)
|
||||
}
|
||||
|
||||
// validateInboxReadSessionLocked keeps legacy unscoped reads compatible while
|
||||
// fencing modern Desktop reads against a concurrent foreground replacement.
|
||||
func (s *Server) validateInboxReadSessionLocked(w http.ResponseWriter, r *http.Request) bool {
|
||||
if !s.validateExpectedSessionLocked(w, r) {
|
||||
return false
|
||||
}
|
||||
if err := s.expectedSessionPathErrorLocked(r.URL.Query().Get("session")); err != nil {
|
||||
http.Error(w, err.Error(), http.StatusConflict)
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
func (s *Server) inboxEnqueue(w http.ResponseWriter, r *http.Request) {
|
||||
var body struct {
|
||||
Input string `json:"input"`
|
||||
Intent string `json:"intent"`
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
Input string `json:"input"`
|
||||
Display string `json:"display"`
|
||||
Invocations []control.InvocationRequest `json:"invocations"`
|
||||
Intent string `json:"intent"`
|
||||
IdempotencyKey string `json:"idempotencyKey"`
|
||||
}
|
||||
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || strings.TrimSpace(body.Input) == "" {
|
||||
http.Error(w, "missing input", http.StatusBadRequest)
|
||||
@@ -69,11 +89,15 @@ func (s *Server) inboxEnqueue(w http.ResponseWriter, r *http.Request) {
|
||||
}
|
||||
req := control.InboxRequest{
|
||||
Intent: intent,
|
||||
Display: body.Input,
|
||||
Display: body.Display,
|
||||
Raw: body.Input,
|
||||
Submit: body.Input,
|
||||
Source: "http",
|
||||
Idempotency: body.IdempotencyKey,
|
||||
Invocations: body.Invocations,
|
||||
}
|
||||
if req.Display == "" {
|
||||
req.Display = body.Input
|
||||
}
|
||||
var rec sessioninbox.InboxReceipt
|
||||
var err error
|
||||
@@ -91,6 +115,32 @@ func (s *Server) inboxEnqueue(w http.ResponseWriter, r *http.Request) {
|
||||
_ = json.NewEncoder(w).Encode(rec)
|
||||
}
|
||||
|
||||
func (s *Server) inboxReceipt(w http.ResponseWriter, r *http.Request) {
|
||||
s.bindMu.Lock()
|
||||
defer s.bindMu.Unlock()
|
||||
if !s.validateInboxReadSessionLocked(w, r) {
|
||||
return
|
||||
}
|
||||
ctrl := s.ctl()
|
||||
reader, ok := ctrl.(interface {
|
||||
LookupInboxReceipt(string) (sessioninbox.InboxReceipt, bool, error)
|
||||
})
|
||||
if !ok {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
receipt, found, err := reader.LookupInboxReceipt(r.URL.Query().Get("key"))
|
||||
if err != nil {
|
||||
writeInboxError(w, err)
|
||||
return
|
||||
}
|
||||
if !found {
|
||||
http.NotFound(w, r)
|
||||
return
|
||||
}
|
||||
writeJSON(w, receipt)
|
||||
}
|
||||
|
||||
func (s *Server) inboxGet(w http.ResponseWriter, r *http.Request) {
|
||||
id := r.PathValue("id")
|
||||
meta, env, err := s.inboxAPI().ReadInboxItem(id)
|
||||
|
||||
@@ -22,15 +22,17 @@ import (
|
||||
// turns alive without sending a background session's frames to the foreground
|
||||
// browser.
|
||||
type sessionTagSink struct {
|
||||
bc *Broadcaster
|
||||
mu sync.Mutex
|
||||
path string
|
||||
active bool
|
||||
pending []event.Event
|
||||
pendingRuntimeState *event.RuntimeStateSnapshot
|
||||
bc *Broadcaster
|
||||
mu sync.Mutex
|
||||
path string
|
||||
active bool
|
||||
runtimeActive bool
|
||||
pending []event.Event
|
||||
}
|
||||
|
||||
func newSessionTagSink(bc *Broadcaster) *sessionTagSink {
|
||||
return &sessionTagSink{bc: bc}
|
||||
return &sessionTagSink{bc: bc, runtimeActive: true}
|
||||
}
|
||||
|
||||
// SessionTagSink is exported for the CLI, which builds Serve's initial
|
||||
@@ -43,6 +45,9 @@ func NewSessionTagSink(bc *Broadcaster) *SessionTagSink {
|
||||
|
||||
func (s *sessionTagSink) SetPath(path string) {
|
||||
s.mu.Lock()
|
||||
if s.path != "" && s.path != canonicalSessionPath(path) {
|
||||
s.runtimeActive = false
|
||||
}
|
||||
s.path = canonicalSessionPath(path)
|
||||
s.activateLocked()
|
||||
s.mu.Unlock()
|
||||
@@ -53,6 +58,7 @@ func (s *sessionTagSink) SetPath(path string) {
|
||||
func (s *sessionTagSink) PrimePath(path string) {
|
||||
s.mu.Lock()
|
||||
s.path = canonicalSessionPath(path)
|
||||
s.runtimeActive = s.bc.CurrentSession() == s.path
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -63,6 +69,7 @@ func (s *sessionTagSink) BufferPath(path string) {
|
||||
s.mu.Lock()
|
||||
s.path = canonicalSessionPath(path)
|
||||
s.active = false
|
||||
s.runtimeActive = false
|
||||
s.mu.Unlock()
|
||||
}
|
||||
|
||||
@@ -84,6 +91,10 @@ func (s *sessionTagSink) activateLocked() {
|
||||
return
|
||||
}
|
||||
s.active = true
|
||||
if s.runtimeActive && s.pendingRuntimeState != nil {
|
||||
s.bc.publishRuntimeState(s.path, *s.pendingRuntimeState)
|
||||
s.pendingRuntimeState = nil
|
||||
}
|
||||
for _, e := range s.pending {
|
||||
if s.path != "" {
|
||||
e.SessionPath = s.path
|
||||
@@ -449,6 +460,23 @@ func (s *Server) busyDetach(ctx context.Context, cur *control.Controller, target
|
||||
|
||||
func (s *Server) announceSessionChanged(path string, reset bool) {
|
||||
s.bc.Emit(event.Event{Kind: event.SessionChanged, SessionPath: path, SessionReset: reset})
|
||||
if ctrl, ok := s.ctl().(*control.Controller); ok {
|
||||
if tag := s.tagFor(ctrl); tag != nil {
|
||||
tag.ActivateRuntime()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// The routing barrier precedes the new instance's runtime projection. Content
|
||||
// boot notices retain their historical order relative to session_changed.
|
||||
func (s *sessionTagSink) ActivateRuntime() {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
s.runtimeActive = true
|
||||
if s.active && s.pendingRuntimeState != nil {
|
||||
s.bc.publishRuntimeState(s.path, *s.pendingRuntimeState)
|
||||
s.pendingRuntimeState = nil
|
||||
}
|
||||
}
|
||||
|
||||
var errReplacedDuringBind = &replacedDuringBindError{}
|
||||
|
||||
@@ -531,6 +531,12 @@ func (c *balanceProbeController) RuntimeStatus() control.RuntimeStatus {
|
||||
return control.RuntimeStatus{Running: true, PendingPrompt: true, Cancellable: true}
|
||||
}
|
||||
|
||||
func (c *balanceProbeController) RuntimeStateSnapshot() event.RuntimeStateSnapshot {
|
||||
state := c.Controller.RuntimeStateSnapshot()
|
||||
state.Phase, state.Running, state.PendingPrompt, state.Cancellable = "executing", true, true, true
|
||||
return state
|
||||
}
|
||||
|
||||
func TestStatusRuntimeQuerySkipsBalance(t *testing.T) {
|
||||
bc := NewBroadcaster()
|
||||
ctrl := &balanceProbeController{Controller: control.New(control.Options{Sink: bc})}
|
||||
|
||||
@@ -0,0 +1,152 @@
|
||||
package serve
|
||||
|
||||
import (
|
||||
"crypto/rand"
|
||||
"encoding/hex"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"reflect"
|
||||
"sort"
|
||||
"sync"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/eventwire"
|
||||
)
|
||||
|
||||
type runtimeSessionView struct {
|
||||
SessionPath string `json:"sessionPath"`
|
||||
Current bool `json:"current"`
|
||||
State event.RuntimeStateSnapshot `json:"state"`
|
||||
}
|
||||
|
||||
func runtimeStateAndStatus(ctrl control.SessionAPI) (event.RuntimeStateSnapshot, control.RuntimeStatus) {
|
||||
state, status := runtimeStateOf(ctrl), ctrl.RuntimeStatus()
|
||||
if state.SchemaVersion == 1 {
|
||||
status.Running, status.PendingPrompt, status.BackgroundJobs, status.CancelRequested, status.Cancellable = state.Running, state.PendingPrompt, state.BackgroundJobs, state.CancelRequested, state.Cancellable
|
||||
}
|
||||
return state, status
|
||||
}
|
||||
|
||||
type runtimeStatesView struct {
|
||||
SchemaVersion int `json:"schemaVersion"`
|
||||
Epoch string `json:"epoch"`
|
||||
Revision uint64 `json:"revision"`
|
||||
Sessions []runtimeSessionView `json:"sessions"`
|
||||
}
|
||||
type serveRuntimeProjection struct {
|
||||
mu sync.Mutex
|
||||
snapshot runtimeStatesView
|
||||
}
|
||||
|
||||
func runtimeStateOf(ctrl control.SessionAPI) event.RuntimeStateSnapshot {
|
||||
if reader, ok := ctrl.(control.RuntimeStateReader); ok {
|
||||
return reader.RuntimeStateSnapshot()
|
||||
}
|
||||
status := ctrl.RuntimeStatus()
|
||||
phase := "idle"
|
||||
if status.Running {
|
||||
phase = "executing"
|
||||
}
|
||||
return event.RuntimeStateSnapshot{Phase: phase, Running: status.Running, PendingPrompt: status.PendingPrompt,
|
||||
BackgroundJobs: status.BackgroundJobs, CancelRequested: status.CancelRequested, Cancellable: status.Cancellable}
|
||||
}
|
||||
|
||||
// runtimeStates is a memory-only reconciliation surface, including detached
|
||||
// controllers. It does not list transcripts, generate titles, or fetch balance.
|
||||
func (s *Server) runtimeStates(w http.ResponseWriter, _ *http.Request) {
|
||||
writeJSON(w, s.runtimeStatesSnapshot())
|
||||
}
|
||||
|
||||
func (s *Server) runtimeStatesSnapshot() runtimeStatesView {
|
||||
r := &s.runtimeProjection
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
s.bindMu.Lock()
|
||||
current := s.ctl()
|
||||
controllers := []control.SessionAPI{current}
|
||||
s.detachedMu.Lock()
|
||||
for _, detached := range s.detached {
|
||||
if detached.ctrl != current {
|
||||
controllers = append(controllers, detached.ctrl)
|
||||
}
|
||||
}
|
||||
s.detachedMu.Unlock()
|
||||
result := runtimeStatesView{SchemaVersion: 1, Epoch: r.snapshot.Epoch, Revision: r.snapshot.Revision, Sessions: []runtimeSessionView{}}
|
||||
for _, ctrl := range controllers {
|
||||
if ctrl == nil {
|
||||
continue
|
||||
}
|
||||
result.Sessions = append(result.Sessions, runtimeSessionView{SessionPath: agent.CanonicalSessionPath(ctrl.SessionPath()), Current: ctrl == current, State: runtimeStateOf(ctrl)})
|
||||
}
|
||||
s.bindMu.Unlock()
|
||||
sort.Slice(result.Sessions, func(i, j int) bool {
|
||||
return result.Sessions[i].State.RuntimeEpoch < result.Sessions[j].State.RuntimeEpoch
|
||||
})
|
||||
if result.Epoch == "" {
|
||||
var id [16]byte
|
||||
if _, err := rand.Read(id[:]); err != nil {
|
||||
panic(err)
|
||||
}
|
||||
result.Epoch = hex.EncodeToString(id[:])
|
||||
}
|
||||
if !reflect.DeepEqual(result, r.snapshot) {
|
||||
result.Revision++
|
||||
r.snapshot = result
|
||||
}
|
||||
result.Sessions = append([]runtimeSessionView{}, r.snapshot.Sessions...)
|
||||
return result
|
||||
}
|
||||
|
||||
func (b *Broadcaster) RuntimeStateChanged(snapshot event.RuntimeStateSnapshot) {
|
||||
b.publishRuntimeState(b.CurrentSession(), snapshot)
|
||||
}
|
||||
func (b *Broadcaster) publishRuntimeState(path string, snapshot event.RuntimeStateSnapshot) {
|
||||
path = agent.CanonicalSessionPath(path)
|
||||
b.mu.Lock()
|
||||
defer b.mu.Unlock()
|
||||
frame, err := json.Marshal(eventwire.Event{Kind: "runtime_state", SessionPath: path, SessionCurrent: path == b.current, RuntimeState: &snapshot})
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
for ch, sub := range b.subs {
|
||||
if !sub.all && path != "" && path != b.current {
|
||||
continue
|
||||
}
|
||||
enqueueSubscriberWireFrame(ch, frame, "runtime_state")
|
||||
}
|
||||
}
|
||||
|
||||
func (s *sessionTagSink) RuntimeStateChanged(snapshot event.RuntimeStateSnapshot) {
|
||||
s.mu.Lock()
|
||||
defer s.mu.Unlock()
|
||||
if !s.active || !s.runtimeActive {
|
||||
s.pendingRuntimeState = &snapshot
|
||||
return
|
||||
}
|
||||
s.bc.publishRuntimeState(s.path, snapshot)
|
||||
}
|
||||
|
||||
// A per-session status query must resolve its own controller, not the foreground.
|
||||
func (s *Server) ownedRuntimeStatusView(path string) (map[string]any, bool) {
|
||||
s.bindMu.Lock()
|
||||
defer s.bindMu.Unlock()
|
||||
ctrl := s.ctl()
|
||||
if ctrl == nil || agent.CanonicalSessionPath(ctrl.SessionPath()) != agent.CanonicalSessionPath(path) {
|
||||
s.detachedMu.Lock()
|
||||
detached := s.detached[agent.CanonicalSessionPath(path)]
|
||||
ctrl = nil
|
||||
if detached != nil {
|
||||
ctrl = detached.ctrl
|
||||
}
|
||||
s.detachedMu.Unlock()
|
||||
}
|
||||
if ctrl == nil {
|
||||
return nil, false
|
||||
}
|
||||
state := runtimeStateOf(ctrl)
|
||||
return map[string]any{"runtimeState": state, "running": state.Running, "pendingPrompt": state.PendingPrompt,
|
||||
"backgroundJobs": state.BackgroundJobs, "cancelRequested": state.CancelRequested, "cancellable": state.Cancellable,
|
||||
"sessionPath": path, "takenOver": false, "label": ctrl.Label(), "plan": ctrl.PlanMode(), "toolApprovalMode": ctrl.ToolApprovalMode(), "goal": ctrl.Goal()}, true
|
||||
}
|
||||
@@ -0,0 +1,365 @@
|
||||
package serve
|
||||
|
||||
import (
|
||||
"bufio"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"reflect"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"reasonix/internal/agent"
|
||||
"reasonix/internal/config"
|
||||
"reasonix/internal/control"
|
||||
"reasonix/internal/event"
|
||||
"reasonix/internal/eventwire"
|
||||
"reasonix/internal/sessioninbox"
|
||||
)
|
||||
|
||||
type runtimeStateServeRunner struct{ started chan struct{} }
|
||||
|
||||
type runtimeStateServeSink struct {
|
||||
states chan event.RuntimeStateSnapshot
|
||||
}
|
||||
|
||||
func (*runtimeStateServeSink) Emit(event.Event) {}
|
||||
func (s *runtimeStateServeSink) RuntimeStateChanged(state event.RuntimeStateSnapshot) {
|
||||
s.states <- state
|
||||
}
|
||||
|
||||
func (r runtimeStateServeRunner) Run(ctx context.Context, _ string) error {
|
||||
close(r.started)
|
||||
<-ctx.Done()
|
||||
return ctx.Err()
|
||||
}
|
||||
|
||||
func runtimeStateServeController(t *testing.T, dir, name string, runner interface {
|
||||
Run(context.Context, string) error
|
||||
}) *control.Controller {
|
||||
t.Helper()
|
||||
path := filepath.Join(dir, name+".jsonl")
|
||||
if err := os.WriteFile(path, nil, 0o600); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
sink := &runtimeStateServeSink{states: make(chan event.RuntimeStateSnapshot, 64)}
|
||||
c := control.New(control.Options{SessionDir: dir, SessionPath: path, Label: name, Runner: runner, Sink: sink})
|
||||
t.Cleanup(func() {
|
||||
defer c.Close()
|
||||
before := c.RuntimeStateSnapshot()
|
||||
if !c.Running() && before.Phase != "executing" && before.Phase != "finishing" {
|
||||
return
|
||||
}
|
||||
// Stop dispatching the isolated follow-up queue, then let the real
|
||||
// finishing notification establish a barrier before TempDir removal.
|
||||
_ = c.SetInboxPaused(true)
|
||||
c.Cancel()
|
||||
deadline := time.NewTimer(5 * time.Second)
|
||||
defer deadline.Stop()
|
||||
for {
|
||||
select {
|
||||
case state := <-sink.states:
|
||||
if state.Phase == "idle" && state.Revision > before.Revision {
|
||||
return
|
||||
}
|
||||
case <-deadline.C:
|
||||
t.Error("isolated runtime did not settle before cleanup")
|
||||
return
|
||||
}
|
||||
}
|
||||
})
|
||||
return c
|
||||
}
|
||||
|
||||
func runtimeStateHTTPGet(t *testing.T, endpoint string, target any) {
|
||||
t.Helper()
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
response, err := client.Get(endpoint)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusOK {
|
||||
t.Fatalf("GET %s status=%d", endpoint, response.StatusCode)
|
||||
}
|
||||
if err := json.NewDecoder(response.Body).Decode(target); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeStateHTTPIncludesDetachedAndKeepsSnapshotImmutable(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
foreground := runtimeStateServeController(t, dir, "foreground", nil)
|
||||
detached := runtimeStateServeController(t, dir, "detached", nil)
|
||||
server := New(foreground, nil, config.ServeConfig{})
|
||||
detachedPath := agent.CanonicalSessionPath(detached.SessionPath())
|
||||
server.detached[detachedPath] = &detachedSession{path: detachedPath, ctrl: detached}
|
||||
httpServer := httptest.NewServer(server.Handler())
|
||||
defer httpServer.Close()
|
||||
// This endpoint reads managed runtimes, so removing a transcript cannot
|
||||
// make it disappear or cause a catalog/history scan to fail.
|
||||
if err := os.Remove(detached.SessionPath()); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var first, second runtimeStatesView
|
||||
runtimeStateHTTPGet(t, httpServer.URL+"/runtime-states", &first)
|
||||
runtimeStateHTTPGet(t, httpServer.URL+"/runtime-states", &second)
|
||||
if first.SchemaVersion != 1 || first.Epoch == "" || first.Revision == 0 || len(first.Sessions) != 2 {
|
||||
t.Fatalf("invalid full snapshot: %+v", first)
|
||||
}
|
||||
if !reflect.DeepEqual(first, second) {
|
||||
t.Fatalf("unchanged memory produced different versions/content: first=%+v second=%+v", first, second)
|
||||
}
|
||||
byPath := map[string]runtimeSessionView{}
|
||||
for _, session := range first.Sessions {
|
||||
byPath[session.SessionPath] = session
|
||||
}
|
||||
if got := byPath[agent.CanonicalSessionPath(foreground.SessionPath())]; !got.Current || got.State != foreground.RuntimeStateSnapshot() {
|
||||
t.Fatalf("foreground snapshot mismatch: %+v", got)
|
||||
}
|
||||
if got := byPath[detachedPath]; got.Current || got.State != detached.RuntimeStateSnapshot() {
|
||||
t.Fatalf("detached snapshot mismatch: %+v", got)
|
||||
}
|
||||
read := server.runtimeStatesSnapshot()
|
||||
read.Sessions[0].State.Phase = "corrupt-test-copy"
|
||||
if reflect.DeepEqual(read, server.runtimeStatesSnapshot()) {
|
||||
t.Fatal("caller mutated the stored projection through its returned slice")
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeStateHTTPStatusUsesRequestedDetachedController(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
foreground := runtimeStateServeController(t, dir, "foreground", nil)
|
||||
runner := runtimeStateServeRunner{started: make(chan struct{})}
|
||||
detached := runtimeStateServeController(t, dir, "detached", runner)
|
||||
server := New(foreground, nil, config.ServeConfig{})
|
||||
detachedPath := agent.CanonicalSessionPath(detached.SessionPath())
|
||||
server.detached[detachedPath] = &detachedSession{path: detachedPath, ctrl: detached}
|
||||
detached.Send("isolated running turn")
|
||||
select {
|
||||
case <-runner.started:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("detached runner did not start")
|
||||
}
|
||||
httpServer := httptest.NewServer(server.Handler())
|
||||
defer httpServer.Close()
|
||||
var status struct {
|
||||
SessionPath string `json:"sessionPath"`
|
||||
Running bool `json:"running"`
|
||||
RuntimeState event.RuntimeStateSnapshot `json:"runtimeState"`
|
||||
}
|
||||
runtimeStateHTTPGet(t, httpServer.URL+"/status?runtime=1&session="+url.QueryEscape(detachedPath), &status)
|
||||
if status.SessionPath != detachedPath || !status.Running || status.RuntimeState.Phase != "executing" || status.RuntimeState.RuntimeEpoch != detached.RuntimeStateSnapshot().RuntimeEpoch {
|
||||
t.Fatalf("detached status was borrowed from foreground: %+v", status)
|
||||
}
|
||||
if foreground.RuntimeStateSnapshot().Running {
|
||||
t.Fatal("fixture foreground unexpectedly running")
|
||||
}
|
||||
}
|
||||
|
||||
func readRuntimeSSEFrame(t *testing.T, reader *bufio.Reader) eventwire.Event {
|
||||
t.Helper()
|
||||
for {
|
||||
line, err := reader.ReadString('\n')
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if !strings.HasPrefix(line, "data: ") {
|
||||
continue
|
||||
}
|
||||
var frame eventwire.Event
|
||||
if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &frame); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
return frame
|
||||
}
|
||||
}
|
||||
|
||||
// Runtime snapshots are an additive host-only channel. Existing transcript
|
||||
// tests still assert their exact protocol sequence after filtering that kind.
|
||||
func nextServeProtocolFrame(t *testing.T, frames <-chan []byte, beforeRuntime func(eventwire.Event)) eventwire.Event {
|
||||
t.Helper()
|
||||
timer := time.NewTimer(5 * time.Second)
|
||||
defer timer.Stop()
|
||||
for {
|
||||
select {
|
||||
case raw := <-frames:
|
||||
var frame eventwire.Event
|
||||
if err := json.Unmarshal(raw, &frame); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if frame.Kind == "runtime_state" {
|
||||
if beforeRuntime != nil {
|
||||
beforeRuntime(frame)
|
||||
}
|
||||
continue
|
||||
}
|
||||
return frame
|
||||
case <-timer.C:
|
||||
t.Fatal("expected protocol frame was not delivered")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func assertNoServeProtocolFrames(t *testing.T, frames <-chan []byte) {
|
||||
t.Helper()
|
||||
for {
|
||||
select {
|
||||
case raw := <-frames:
|
||||
var frame eventwire.Event
|
||||
if err := json.Unmarshal(raw, &frame); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if frame.Kind != "runtime_state" {
|
||||
t.Fatalf("unexpected duplicate protocol frame: %s", raw)
|
||||
}
|
||||
default:
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeStateSSEFiltersSessionsAndPreservesHostOnlyPayload(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
foreground := runtimeStateServeController(t, dir, "foreground", nil)
|
||||
broadcaster := NewBroadcaster()
|
||||
server := New(foreground, broadcaster, config.ServeConfig{})
|
||||
httpServer := httptest.NewServer(server.Handler())
|
||||
defer httpServer.Close()
|
||||
client := &http.Client{Timeout: 5 * time.Second}
|
||||
currentResponse, err := client.Get(httpServer.URL + "/events")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer currentResponse.Body.Close()
|
||||
allResponse, err := client.Get(httpServer.URL + "/events?all=1")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer allResponse.Body.Close()
|
||||
backgroundPath := agent.CanonicalSessionPath(filepath.Join(dir, "background.jsonl"))
|
||||
background := event.RuntimeStateSnapshot{SchemaVersion: 1, RuntimeEpoch: "background", Revision: 2, Phase: "idle"}
|
||||
current := event.RuntimeStateSnapshot{SchemaVersion: 1, RuntimeEpoch: "foreground", Revision: 3, Phase: "finishing", Running: true}
|
||||
backgroundSink := newSessionTagSink(broadcaster)
|
||||
backgroundSink.SetPath(backgroundPath)
|
||||
backgroundSink.RuntimeStateChanged(background)
|
||||
foregroundSink := newSessionTagSink(broadcaster)
|
||||
foregroundSink.SetPath(foreground.SessionPath())
|
||||
foregroundSink.RuntimeStateChanged(current)
|
||||
currentFrame := readRuntimeSSEFrame(t, bufio.NewReader(currentResponse.Body))
|
||||
if currentFrame.Kind != "runtime_state" || !currentFrame.SessionCurrent || currentFrame.RuntimeState == nil || *currentFrame.RuntimeState != current {
|
||||
t.Fatalf("current stream received background or changed payload: %+v", currentFrame)
|
||||
}
|
||||
allReader := bufio.NewReader(allResponse.Body)
|
||||
backgroundFrame := readRuntimeSSEFrame(t, allReader)
|
||||
if backgroundFrame.Kind != "runtime_state" || backgroundFrame.SessionCurrent || backgroundFrame.SessionPath != backgroundPath || backgroundFrame.RuntimeState == nil || *backgroundFrame.RuntimeState != background {
|
||||
t.Fatalf("all-session background frame mismatch: %+v", backgroundFrame)
|
||||
}
|
||||
if next := readRuntimeSSEFrame(t, allReader); next.SessionPath != agent.CanonicalSessionPath(foreground.SessionPath()) || !next.SessionCurrent {
|
||||
t.Fatalf("all-session foreground frame mismatch: %+v", next)
|
||||
}
|
||||
}
|
||||
|
||||
func TestRuntimeStateInboxReceiptRetainsRichFollowupAndSessionFence(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
runner := runtimeStateServeRunner{started: make(chan struct{})}
|
||||
ctrl := runtimeStateServeController(t, dir, "foreground", runner)
|
||||
ctrl.Send("hold isolated foreground turn")
|
||||
select {
|
||||
case <-runner.started:
|
||||
case <-time.After(5 * time.Second):
|
||||
t.Fatal("foreground runner did not start")
|
||||
}
|
||||
server := New(ctrl, nil, config.ServeConfig{})
|
||||
httpServer := httptest.NewServer(server.Handler())
|
||||
defer httpServer.Close()
|
||||
request := map[string]any{
|
||||
"input": "run fixture-skill", "display": "rich display", "intent": "followup", "idempotencyKey": "stable-key",
|
||||
"invocations": []control.InvocationRequest{{Name: "fixture-skill", Kind: "skill", Offset: 4}},
|
||||
}
|
||||
raw, err := json.Marshal(request)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, httpServer.URL+"/inbox/items", strings.NewReader(string(raw)))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
req.Header.Set(sessionPathHeader, ctrl.SessionPath())
|
||||
response, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusAccepted {
|
||||
t.Fatalf("enqueue status=%d", response.StatusCode)
|
||||
}
|
||||
var enqueued sessioninbox.InboxReceipt
|
||||
if err := json.NewDecoder(response.Body).Decode(&enqueued); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
_, envelope, err := ctrl.ReadInboxItem(enqueued.ItemID)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if envelope.DisplayText != "rich display" || len(envelope.Invocations) != 1 || envelope.Invocations[0].Name != "fixture-skill" || envelope.Invocations[0].Kind != "skill" || envelope.Invocations[0].Offset != 4 {
|
||||
t.Fatalf("HTTP enqueue lost rich prompt: %+v", envelope)
|
||||
}
|
||||
var recovered sessioninbox.InboxReceipt
|
||||
runtimeStateHTTPGet(t, httpServer.URL+"/inbox/receipt?key=stable-key&session="+url.QueryEscape(ctrl.SessionPath()), &recovered)
|
||||
if recovered.ItemID != enqueued.ItemID {
|
||||
t.Fatalf("idempotency receipt changed item: enqueued=%+v recovered=%+v", enqueued, recovered)
|
||||
}
|
||||
var snapshot sessioninbox.InboxSnapshot
|
||||
runtimeStateHTTPGet(t, httpServer.URL+"/inbox?session="+url.QueryEscape(ctrl.SessionPath()), &snapshot)
|
||||
if snapshot.SessionPath != ctrl.SessionPath() || len(snapshot.Items) != 1 || snapshot.Items[0].ID != enqueued.ItemID {
|
||||
t.Fatalf("remote inbox snapshot does not match receipt: %+v", snapshot)
|
||||
}
|
||||
for _, endpoint := range []string{"/inbox", "/inbox/receipt?key=stable-key"} {
|
||||
for _, fence := range []string{"query", "header", "conflicting"} {
|
||||
t.Run(endpoint+"/"+fence, func(t *testing.T) {
|
||||
u, err := url.Parse(httpServer.URL + endpoint)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
query := u.Query()
|
||||
wrongPath := filepath.Join(dir, "wrong.jsonl")
|
||||
if fence == "query" || fence == "conflicting" {
|
||||
query.Set("session", wrongPath)
|
||||
} else {
|
||||
query.Set("session", ctrl.SessionPath())
|
||||
}
|
||||
u.RawQuery = query.Encode()
|
||||
req, _ := http.NewRequest(http.MethodGet, u.String(), nil)
|
||||
switch fence {
|
||||
case "header":
|
||||
req.Header.Set(expectedSessionPathHeader, wrongPath)
|
||||
case "conflicting":
|
||||
req.Header.Set(expectedSessionPathHeader, ctrl.SessionPath())
|
||||
}
|
||||
response, err := http.DefaultClient.Do(req)
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer response.Body.Close()
|
||||
if response.StatusCode != http.StatusConflict {
|
||||
t.Fatalf("inbox read crossed %s fence: status=%d", fence, response.StatusCode)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
wrong, err := http.Get(httpServer.URL + "/inbox/receipt?key=stable-key&session=" + url.QueryEscape(filepath.Join(dir, "wrong.jsonl")))
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
defer wrong.Body.Close()
|
||||
if wrong.StatusCode != http.StatusConflict {
|
||||
t.Fatalf("receipt lookup crossed session fence: status=%d", wrong.StatusCode)
|
||||
}
|
||||
}
|
||||
+11
-2
@@ -44,7 +44,8 @@ var logoWordmarkSVG []byte
|
||||
// Server wires a controller to its HTTP surface. The Broadcaster must be the
|
||||
// same sink the controller was constructed with, so events reach SSE clients.
|
||||
type Server struct {
|
||||
mu sync.RWMutex // guards ctrl, which rebuild paths swap at runtime
|
||||
runtimeProjection serveRuntimeProjection
|
||||
mu sync.RWMutex // guards ctrl, which rebuild paths swap at runtime
|
||||
// bindMu serializes every entry point that changes the active session
|
||||
// path or controller generation — /resume, /new, /fork, switchModel, and
|
||||
// extension reload. net/http runs handlers
|
||||
@@ -541,6 +542,7 @@ func (s *Server) handler() http.Handler {
|
||||
mux.HandleFunc("GET /provider-setup", s.providerSetupStatus)
|
||||
mux.HandleFunc("POST /provider-setup", s.providerSetupSave)
|
||||
mux.HandleFunc("GET /events", s.events)
|
||||
mux.HandleFunc("GET /runtime-states", s.runtimeStates)
|
||||
mux.HandleFunc("GET /history", s.history)
|
||||
mux.HandleFunc("GET /context", s.context)
|
||||
mux.HandleFunc("POST /submit", s.submit)
|
||||
@@ -1317,6 +1319,12 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request) {
|
||||
if raw := r.URL.Query().Get("session"); raw != "" {
|
||||
if path, err := s.resolveSessionPath(raw); err == nil {
|
||||
held := s.sessionMirrored(path) || leaseHeldByForeignRuntime(path)
|
||||
if !held {
|
||||
if view, ok := s.ownedRuntimeStatusView(path); ok {
|
||||
writeJSON(w, view)
|
||||
return
|
||||
}
|
||||
}
|
||||
writeJSON(w, s.statusViewForPath(path, held))
|
||||
if s.sessionMirrored(path) {
|
||||
s.maybeAutoReclaimMirrored(path)
|
||||
@@ -1332,8 +1340,9 @@ func (s *Server) status(w http.ResponseWriter, r *http.Request) {
|
||||
ctrl := s.ctl()
|
||||
used, window := ctrl.ContextSnapshot()
|
||||
hit, miss := ctrl.SessionCache()
|
||||
rs := ctrl.RuntimeStatus()
|
||||
state, rs := runtimeStateAndStatus(ctrl)
|
||||
sess := map[string]any{
|
||||
"runtimeState": state,
|
||||
"label": ctrl.Label(),
|
||||
"running": rs.Running,
|
||||
"plan": ctrl.PlanMode(),
|
||||
|
||||
Loaded 100 of 106 files, more files were not shown because too many files have changed in this diff.
Show more
Reference in new issue
Block a user