mirror of
https://github.com/getpaseo/paseo.git
synced 2026-11-10 10:25:03 +00:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0fe2e844dc | ||
|
|
4a97a60e5a | ||
|
|
48b16353e9 | ||
|
|
24b5fd5010 | ||
|
|
9b088df7bc | ||
|
|
bcb9724907 | ||
|
|
163b6df4b3 |
@@ -10,7 +10,7 @@ Closing an observation must remain safe after its root is renamed or removed. Wo
|
||||
|
||||
Linux topology repair is trailing-debounced and scoped to the renamed path. A file rename needs one classification stat; a new or moved directory scans only its subtree. Full-tree scans are reserved for startup and ignore-set replacement. The initial Linux scan conservatively emits existing files discovered before their directory watcher was installed; WorkspaceGit batches these into one refresh. Benchmark this startup cost with pre-populated trees.
|
||||
|
||||
Windows and macOS use one native recursive watcher plus an ignored-pruned, directory-indexed inventory. Native events deliver immediately. Named events trigger serialized shallow scans of their parent directories; known directory events also reconcile that subtree. This recovers concrete create and delete paths omitted by coalescing without walking the whole root for each edit. Classification is admission-controlled to 32 concurrent `fs.stat` calls, since `fs.stat` runs on the libuv threadpool the whole daemon shares. Startup and exclusion changes use an awaited full inventory barrier. Activity arms a full safety audit 30 seconds after it settles, with a 30-second minimum interval and a five-minute starvation bound; pathless native events also trigger an immediate shallow root scan. Observation fails into polling rather than retaining more than 250,000 file and directory entries per root. A repository that large is also expensive to poll, so degraded polling doubles its gap from 5 seconds to a 60-second ceiling while the workspace snapshot comes back unchanged, and resets to 5 seconds on the first change. Explicit reads still force a refresh; the ceiling only bounds how long an edit made outside Paseo stays undiscovered.
|
||||
Windows and macOS use one native recursive watcher plus an ignored-pruned, directory-indexed inventory. Native events deliver immediately. Named events trigger serialized shallow scans of their parent directories; known directory events also reconcile that subtree. This recovers concrete create and delete paths omitted by coalescing without walking the whole root for each edit. Classification is admission-controlled to 32 concurrent `fs.stat` calls, since `fs.stat` runs on the libuv threadpool the whole daemon shares. Startup and exclusion changes use an awaited full inventory barrier. Activity arms a full safety audit 30 seconds after it settles, with a 30-second minimum interval and a five-minute starvation bound; pathless native events also trigger an immediate shallow root scan. Observation fails into polling rather than retaining more than 250,000 file and directory entries per root.
|
||||
|
||||
Read aggregate health from the owning observer service. Runtime metrics include active observations, native handles, tracked native files, pending events and immediate reconciliation work, scoped and full reconciliation activity and latency, and failure counts. `nativeTrackedFileCount` and other per-file counters are native-backend diagnostics only — the Linux backend keeps no per-file inventory and reports zero — so a cross-platform assertion belongs on delivered events, not those counters. Delayed safety audits do not count as pending work. Closing the service releases every subscription and clears its diagnostics. Do not add path lists, watcher handles, or platform-specific controls to this interface.
|
||||
|
||||
|
||||
+2
-5
@@ -28,15 +28,12 @@ manage their own relationship or permissions.
|
||||
## Session grants and agent operations
|
||||
|
||||
Hub uses the same authenticated, resumable Session protocol as other clients. Its persisted
|
||||
`hub.execute` permission authorizes ordinary agent creation, workspace titling, messaging,
|
||||
cancellation, archival, agent/workspace observation, timeline subscriptions, and workspace recovery. This authority is
|
||||
`hub.execute` permission authorizes ordinary agent creation, messaging, cancellation, archival,
|
||||
agent/workspace observation, timeline subscriptions, and workspace recovery. This authority is
|
||||
daemon-wide; it is not limited to agents created by that Hub. Daemon configuration, terminals,
|
||||
browser control, and permission management still require their own permissions. See
|
||||
[permissions.md](permissions.md).
|
||||
|
||||
Creating an agent may create a directory workspace. The same `hub.execute` session can title that
|
||||
workspace through `workspace.title.set.request` without `workspace.manage`.
|
||||
|
||||
Clients using this contract check `server_info.features.hubAgentRpc` and
|
||||
`server_info.features.agentRequestReceipts` once. An older host must be upgraded; do not silently
|
||||
fall back to creating a fresh agent when continuation was requested.
|
||||
|
||||
+1
-1
@@ -27,7 +27,7 @@ A pairing invitation is neither. It is an expiring, single-use exchange that cre
|
||||
| `workspace.write` | Prompts, agent control, files, terminals, git operations, and scripts |
|
||||
| `workspace.manage` | Create, rename, archive, and remove projects and workspaces |
|
||||
| `automation.manage` | Schedules, heartbeats, and loops |
|
||||
| `hub.execute` | Agent lifecycle, workspace titling, observation, and recovery |
|
||||
| `hub.execute` | Agent lifecycle, agent/workspace observation, and workspace recovery |
|
||||
|
||||
Agents and terminals use workspace authority. Both can execute code and mutate the workspace, so separate write permissions would claim an isolation boundary the daemon cannot enforce.
|
||||
|
||||
|
||||
+5
-14
@@ -8,8 +8,7 @@ This guide walks through adding a new agent provider end-to-end. There are two i
|
||||
names and nesting are the provider's native contract; options are not portable between providers.
|
||||
Paseo validates the object with the selected provider's strict schema before constructing a session.
|
||||
Unknown keys fail with their `providerOptions.*` path. Paseo-owned controls such as cwd, model,
|
||||
prompt, environment, session identity, MCP transport, callbacks, and hooks are not accepted as
|
||||
top-level provider options.
|
||||
prompt, environment, session identity, MCP transport, callbacks, and hooks cannot be passed here.
|
||||
|
||||
This Paseo version accepts these keys:
|
||||
|
||||
@@ -20,18 +19,10 @@ This Paseo version accepts these keys:
|
||||
`allow_local_binding`, `allow_upstream_proxy`, `dangerously_allow_all_unix_sockets`,
|
||||
`dangerously_allow_non_loopback_proxy`, `domains`, and `unix_sockets`. See the
|
||||
[Codex configuration reference](https://developers.openai.com/codex/config-reference).
|
||||
- **Claude:** `allowedTools`, `disallowedTools`, `additionalDirectories`, `extraArgs`, `sandbox`, and
|
||||
`settings`. `providerOptions.extraArgs` passes the SDK's documented
|
||||
[`Options.extraArgs`](https://platform.claude.com/docs/en/agent-sdk/typescript#options) map
|
||||
unchanged: keys omit the leading `--`, string values supply an argument value, and `null`
|
||||
supplies a boolean flag. For example, `providerOptions: { extraArgs: { chrome: null } }`
|
||||
passes `--chrome`, and `providerOptions: { extraArgs: { model: "x" } }` passes `--model x`.
|
||||
Set it in session configuration or a plugin's `server.before("agent.create", ...)` hook; see
|
||||
[plugin configuration hooks](../public-docs/plugins/reference.md#change-configuration-and-inject-an-mcp-server). Values are literal;
|
||||
shell expressions such as `$(command)` are not evaluated. The accepted sandbox
|
||||
fields cover enablement, fail-if-unavailable behavior, excluded and unsandboxed commands,
|
||||
filesystem read/write rules, network domain/socket/local-binding rules, weaker nested
|
||||
sandboxing, ignored violations, and the ripgrep command. `settings` accepts native
|
||||
- **Claude:** `allowedTools`, `disallowedTools`, `additionalDirectories`, `sandbox`, and `settings`.
|
||||
The accepted sandbox fields cover enablement, fail-if-unavailable behavior, excluded and
|
||||
unsandboxed commands, filesystem read/write rules, network domain/socket/local-binding rules,
|
||||
weaker nested sandboxing, ignored violations, and the ripgrep command. `settings` accepts native
|
||||
`permissions.{allow,ask,deny}` and sandbox settings. See the
|
||||
[Claude Agent SDK TypeScript reference](https://platform.claude.com/docs/en/agent-sdk/typescript)
|
||||
and [Claude settings reference](https://code.claude.com/docs/en/settings).
|
||||
|
||||
@@ -377,7 +377,8 @@ export function KeyboardShortcutsSection() {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
|
||||
if (event.code === "Backspace" && heldModifiersFromEvent(event) === null) {
|
||||
const key = event.key ?? "";
|
||||
if (key === "Backspace") {
|
||||
setCapturedCombos((current) => (current.length > 0 ? current.slice(0, -1) : current));
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -14,13 +14,10 @@ const SHORTCUTS_ROW = "show-shortcuts";
|
||||
* daemon is left unmanaged so the app talks to the E2E daemon instead of trying
|
||||
* to start one of its own.
|
||||
*/
|
||||
async function installDesktopBridge(
|
||||
page: Page,
|
||||
platform: "darwin" | "win32" = "darwin",
|
||||
): Promise<void> {
|
||||
await page.addInitScript((desktopPlatform) => {
|
||||
async function installDesktopBridge(page: Page): Promise<void> {
|
||||
await page.addInitScript(() => {
|
||||
window.paseoDesktop = {
|
||||
platform: desktopPlatform,
|
||||
platform: "darwin",
|
||||
events: { on: () => () => {} },
|
||||
invoke: async (command: string) => {
|
||||
if (command === "get_desktop_settings") {
|
||||
@@ -32,11 +29,11 @@ async function installDesktopBridge(
|
||||
return null;
|
||||
},
|
||||
};
|
||||
}, platform);
|
||||
});
|
||||
}
|
||||
|
||||
async function openShortcutsSettings(page: Page, platform: "darwin" | "win32" = "darwin") {
|
||||
await installDesktopBridge(page, platform);
|
||||
async function openShortcutsSettings(page: Page) {
|
||||
await installDesktopBridge(page);
|
||||
await gotoAppShell(page);
|
||||
await openSettings(page);
|
||||
await openSettingsSection(page, "shortcuts");
|
||||
@@ -70,38 +67,6 @@ async function closeRowMenu(page: Page) {
|
||||
await expect(page.getByTestId(`shortcut-bind-${SHORTCUTS_ROW}`)).toHaveCount(0);
|
||||
}
|
||||
|
||||
async function clearShortcut(page: Page, label: string, action: string) {
|
||||
await page.getByRole("button", { name: `Actions for ${label}` }).click();
|
||||
await page.getByTestId(`shortcut-clear-${action}`).click();
|
||||
}
|
||||
|
||||
async function captureShortcut(page: Page, label: string, action: string, keys: string) {
|
||||
await page.getByRole("button", { name: `Actions for ${label}` }).click();
|
||||
await page.getByTestId(`shortcut-bind-${action}`).click();
|
||||
await page.keyboard.press(keys);
|
||||
}
|
||||
|
||||
async function expectInterruptShortcut(page: Page, chord: string) {
|
||||
const row = page.getByText("Interrupt agent", { exact: true }).locator("..");
|
||||
await expect(row.getByText(chord, { exact: true })).toBeVisible();
|
||||
}
|
||||
|
||||
async function finishInterruptCapture(page: Page, action: "Done" | "Cancel") {
|
||||
const row = page.getByText("Interrupt agent", { exact: true }).locator("..");
|
||||
await row.getByRole("button", { name: action }).click();
|
||||
}
|
||||
|
||||
async function eraseLastCapturedCombo(page: Page, firstCombo: string, secondCombo: string) {
|
||||
await captureShortcut(page, "Interrupt agent", "agent-interrupt", "Alt+K");
|
||||
await page.keyboard.press("Alt+J");
|
||||
await expectInterruptShortcut(page, firstCombo);
|
||||
await expectInterruptShortcut(page, secondCombo);
|
||||
await page.keyboard.press("Backspace");
|
||||
await expectInterruptShortcut(page, firstCombo);
|
||||
const row = page.getByText("Interrupt agent", { exact: true }).locator("..");
|
||||
await expect(row.getByText(secondCombo, { exact: true })).toHaveCount(0);
|
||||
}
|
||||
|
||||
test("unassigning a shortcut leaves it inert until it is reset", async ({ page }) => {
|
||||
await openShortcutsSettings(page);
|
||||
|
||||
@@ -181,35 +146,3 @@ test("unassigning a shortcut leaves it inert until it is reset", async ({ page }
|
||||
await page.keyboard.press("Escape");
|
||||
await expect(dialog).not.toBeVisible({ timeout: 10_000 });
|
||||
});
|
||||
|
||||
for (const { platform, keys, label, firstCombo, secondCombo } of [
|
||||
{
|
||||
platform: "darwin",
|
||||
keys: "Meta+Shift+Backspace",
|
||||
label: "⇧⌘⌫",
|
||||
firstCombo: "⌥K",
|
||||
secondCombo: "⌥J",
|
||||
},
|
||||
{
|
||||
platform: "win32",
|
||||
keys: "Control+Shift+Backspace",
|
||||
label: "Ctrl+Shift+⌫",
|
||||
firstCombo: "Alt+K",
|
||||
secondCombo: "Alt+J",
|
||||
},
|
||||
] as const) {
|
||||
test(`binds modified Backspace and erases a combo with bare Backspace on ${platform}`, async ({
|
||||
page,
|
||||
}) => {
|
||||
await openShortcutsSettings(page, platform);
|
||||
await clearShortcut(page, "Archive workspace", "archive-workspace");
|
||||
await captureShortcut(page, "Interrupt agent", "agent-interrupt", keys);
|
||||
await expectInterruptShortcut(page, label);
|
||||
await finishInterruptCapture(page, "Done");
|
||||
await expectInterruptShortcut(page, label);
|
||||
|
||||
await eraseLastCapturedCombo(page, firstCombo, secondCombo);
|
||||
await finishInterruptCapture(page, "Cancel");
|
||||
await expectInterruptShortcut(page, label);
|
||||
});
|
||||
}
|
||||
|
||||
@@ -4,10 +4,9 @@ import type {
|
||||
ProviderInput,
|
||||
ProviderRegistration,
|
||||
} from "@getpaseo/plugin/server/provider";
|
||||
import { setImmediate as nextTurn } from "node:timers/promises";
|
||||
import { describe, expect, test } from "vitest";
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import type { AgentClient, AgentStreamEvent } from "./agent-sdk-types.js";
|
||||
import type { AgentStreamEvent } from "./agent-sdk-types.js";
|
||||
import { PluginAgentClientRegistry } from "./plugin-provider.js";
|
||||
import {
|
||||
isStaleProviderSessionError,
|
||||
@@ -26,7 +25,6 @@ interface ProviderHarnessOptions {
|
||||
capabilities?: ProviderConnection["capabilities"];
|
||||
completeTurn?: boolean;
|
||||
openChildren?: (rootSessionId: string, emit: (event: ProviderEvent) => void) => void;
|
||||
handleInput?: (input: ProviderInput, emit: (event: ProviderEvent) => void) => Promise<boolean>;
|
||||
}
|
||||
|
||||
function createProviderHarness(options: ProviderHarnessOptions = {}) {
|
||||
@@ -45,7 +43,6 @@ function createProviderHarness(options: ProviderHarnessOptions = {}) {
|
||||
capabilities,
|
||||
async send(input) {
|
||||
inputs.push(input);
|
||||
if (await options.handleInput?.(input, emit)) return;
|
||||
if (input.type === "catalog") {
|
||||
emit({
|
||||
type: "catalog",
|
||||
@@ -281,59 +278,6 @@ function expectNestedChildren(events: AgentStreamEvent[]) {
|
||||
}
|
||||
|
||||
describe("PluginAgentClientRegistry", () => {
|
||||
test.each([false, true])(
|
||||
"contains a failed session open while send is pending: %s",
|
||||
async (pendingSend) => {
|
||||
let listener: ((event: ProviderEvent) => void) | undefined;
|
||||
const unhandled: unknown[] = [];
|
||||
const observeUnhandled = (reason: unknown) => unhandled.push(reason);
|
||||
const registry = new PluginAgentClientRegistry(createTestLogger());
|
||||
registry.replace([
|
||||
{
|
||||
id: "failing-provider",
|
||||
label: "Failing provider",
|
||||
async connect() {
|
||||
return {
|
||||
version: 1,
|
||||
capabilities: ["session.persistence"],
|
||||
async send(input) {
|
||||
if (input.type !== "session.open") return;
|
||||
listener?.({
|
||||
type: "request.failed",
|
||||
requestId: input.requestId,
|
||||
error: { message: "OMP persistent session registration is in progress" },
|
||||
});
|
||||
// Keep acceptance pending across a Node event-loop turn, as IPC can do.
|
||||
if (pendingSend) await nextTurn();
|
||||
},
|
||||
onEvent(nextListener) {
|
||||
listener = nextListener;
|
||||
return () => {
|
||||
listener = undefined;
|
||||
};
|
||||
},
|
||||
async close() {},
|
||||
};
|
||||
},
|
||||
},
|
||||
]);
|
||||
process.on("unhandledRejection", observeUnhandled);
|
||||
try {
|
||||
await expect(
|
||||
registry.clients()["failing-provider"]!.createSession({
|
||||
provider: "failing-provider",
|
||||
cwd: "/workspace",
|
||||
}),
|
||||
).rejects.toThrow("OMP persistent session registration is in progress");
|
||||
await nextTurn();
|
||||
expect(unhandled).toEqual([]);
|
||||
} finally {
|
||||
await registry.shutdown();
|
||||
process.off("unhandledRejection", observeUnhandled);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
test("preserves nested provider child ownership during opening", async () => {
|
||||
const harness = createProviderHarness({ openChildren: openNestedChildren });
|
||||
const registry = new PluginAgentClientRegistry(createTestLogger());
|
||||
@@ -596,167 +540,3 @@ describe("PluginAgentClientRegistry", () => {
|
||||
expect(harness.inputs.map((input) => input.type)).toContain("session.close");
|
||||
});
|
||||
});
|
||||
|
||||
type RequestKind = "session.open" | "catalog" | "session.configure" | "session.prompt";
|
||||
|
||||
async function requestFromClient(client: AgentClient, kind: RequestKind): Promise<unknown> {
|
||||
if (kind === "catalog") return client.fetchCatalog({ scope: "global" });
|
||||
const session = await client.createSession({ provider: client.provider, cwd: "/workspace" });
|
||||
if (kind === "session.open") return session;
|
||||
if (kind === "session.configure") return session.setMode("build");
|
||||
return session.startTurn("hello", { clientMessageId: "pending-message" });
|
||||
}
|
||||
|
||||
async function withObservedProvider(
|
||||
options: ProviderHarnessOptions,
|
||||
run: (client: AgentClient, registry: PluginAgentClientRegistry) => Promise<void>,
|
||||
): Promise<void> {
|
||||
const harness = createProviderHarness(options);
|
||||
const registry = new PluginAgentClientRegistry(createTestLogger());
|
||||
const unhandled: unknown[] = [];
|
||||
const observe = (reason: unknown) => unhandled.push(reason);
|
||||
process.on("unhandledRejection", observe);
|
||||
registry.replace([harness.registration]);
|
||||
try {
|
||||
await run(registry.clients()[harness.registration.id]!, registry);
|
||||
await registry.shutdown();
|
||||
await nextTurn();
|
||||
expect(unhandled).toEqual([]);
|
||||
} finally {
|
||||
await registry.shutdown();
|
||||
process.off("unhandledRejection", observe);
|
||||
}
|
||||
}
|
||||
|
||||
describe("pending provider responses", () => {
|
||||
test.each<RequestKind>(["session.open", "catalog", "session.configure", "session.prompt"])(
|
||||
"preserves the send error for %s",
|
||||
async (kind) => {
|
||||
const failure = new Error("Provider send failed");
|
||||
await withObservedProvider(
|
||||
{
|
||||
async handleInput(input) {
|
||||
if (input.type === kind) throw failure;
|
||||
return false;
|
||||
},
|
||||
},
|
||||
async (client) => {
|
||||
await expect(requestFromClient(client, kind)).rejects.toBe(failure);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
[
|
||||
"catalog",
|
||||
expect.objectContaining({
|
||||
models: [expect.objectContaining({ id: "plugin-model", isDefault: true })],
|
||||
defaultModeId: "build",
|
||||
}),
|
||||
],
|
||||
["session.configure", undefined],
|
||||
] as const)(
|
||||
"contains an early request.failed for %s and permits a successful retry",
|
||||
async (kind, result) => {
|
||||
let failed = false;
|
||||
await withObservedProvider(
|
||||
{
|
||||
async handleInput(input, emit) {
|
||||
if (input.type !== kind || !("requestId" in input) || failed) return false;
|
||||
failed = true;
|
||||
emit({
|
||||
type: "request.failed",
|
||||
requestId: input.requestId,
|
||||
error: { message: "Provider request failed", code: "busy", diagnostic: "retry" },
|
||||
});
|
||||
await nextTurn();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
async (client) => {
|
||||
await expect(requestFromClient(client, kind)).rejects.toMatchObject({
|
||||
message: "Provider request failed",
|
||||
code: "busy",
|
||||
diagnostic: "retry",
|
||||
});
|
||||
await expect(requestFromClient(client, kind)).resolves.toEqual(result);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
test.each([
|
||||
["session.open", "Provider session closed"],
|
||||
["session.configure", "Provider session closed"],
|
||||
["session.prompt", StaleProviderSessionError],
|
||||
] as const)("contains session closure while accepting %s", async (kind, error) => {
|
||||
await withObservedProvider(
|
||||
{
|
||||
async handleInput(input, emit) {
|
||||
if (input.type !== kind || !("sessionId" in input)) return false;
|
||||
emit({
|
||||
type: "session.closed",
|
||||
sessionId: input.sessionId,
|
||||
error: { message: "Provider session closed" },
|
||||
});
|
||||
await nextTurn();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
async (client) => {
|
||||
await expect(requestFromClient(client, kind)).rejects.toThrow(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test.each([
|
||||
["session.open", "Provider connection closed"],
|
||||
["catalog", "Provider closed"],
|
||||
["session.configure", "Provider closed"],
|
||||
["session.prompt", StaleProviderSessionError],
|
||||
] as const)("contains connection shutdown while accepting %s", async (kind, error) => {
|
||||
let disconnect!: () => Promise<void>;
|
||||
await withObservedProvider(
|
||||
{
|
||||
async handleInput(input) {
|
||||
if (input.type !== kind) return false;
|
||||
await disconnect();
|
||||
await nextTurn();
|
||||
return true;
|
||||
},
|
||||
},
|
||||
async (client, registry) => {
|
||||
disconnect = () => registry.shutdown();
|
||||
await expect(requestFromClient(client, kind)).rejects.toThrow(error);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
test.each([true, false])(
|
||||
"preserves cancellation with acceptance pending: %s",
|
||||
async (pending) => {
|
||||
const controller = new AbortController();
|
||||
const reason = new Error("Catalog refresh cancelled");
|
||||
await withObservedProvider(
|
||||
{
|
||||
async handleInput(input) {
|
||||
if (input.type !== "catalog") return false;
|
||||
if (pending) {
|
||||
controller.abort(reason);
|
||||
await nextTurn();
|
||||
} else {
|
||||
setImmediate(() => controller.abort(reason));
|
||||
}
|
||||
return true;
|
||||
},
|
||||
},
|
||||
async (client) => {
|
||||
await expect(
|
||||
client.fetchCatalog({ scope: "global" }, { signal: controller.signal }),
|
||||
).rejects.toBe(reason);
|
||||
},
|
||||
);
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -92,9 +92,6 @@ function deferred<Value>(): Deferred<Value> {
|
||||
resolve = onResolve;
|
||||
reject = onReject;
|
||||
});
|
||||
// Provider events can reject before send() settles and the caller awaits this promise.
|
||||
// Observe that interval without replacing the rejecting promise returned to the caller.
|
||||
void promise.catch(() => undefined);
|
||||
return { promise, resolve, reject };
|
||||
}
|
||||
|
||||
|
||||
@@ -51,7 +51,6 @@ describe("provider-owned option schemas", () => {
|
||||
ClaudeProviderOptionsSchema.parse({
|
||||
allowedTools: ["Read"],
|
||||
disallowedTools: ["Bash(rm *)"],
|
||||
extraArgs: { chrome: null, model: "x" },
|
||||
sandbox: {
|
||||
enabled: true,
|
||||
failIfUnavailable: true,
|
||||
@@ -64,26 +63,7 @@ describe("provider-owned option schemas", () => {
|
||||
},
|
||||
settings: { permissions: { ask: ["Bash(*)"], deny: ["Edit(.env)"] } },
|
||||
}),
|
||||
).toMatchObject({
|
||||
extraArgs: { chrome: null, model: "x" },
|
||||
sandbox: { enabled: true, failIfUnavailable: true },
|
||||
});
|
||||
});
|
||||
|
||||
test.each([true, 42, ["x"]])("rejects invalid Claude extra argument values: %j", (value) => {
|
||||
expect(() =>
|
||||
validateProviderOptions("claude", ClaudeProviderOptionsSchema, {
|
||||
extraArgs: { chrome: value },
|
||||
}),
|
||||
).toThrow("providerOptions.extraArgs.chrome");
|
||||
});
|
||||
|
||||
test("rejects a raw argument list for the SDK argument map", () => {
|
||||
expect(() =>
|
||||
validateProviderOptions("claude", ClaudeProviderOptionsSchema, {
|
||||
extraArgs: ["--chrome"],
|
||||
}),
|
||||
).toThrow("providerOptions.extraArgs");
|
||||
).toMatchObject({ sandbox: { enabled: true, failIfUnavailable: true } });
|
||||
});
|
||||
|
||||
test("reports the exact invalid Claude option path", () => {
|
||||
|
||||
@@ -785,29 +785,6 @@ describe("ClaudeAgentSession features", () => {
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("passes extra Claude Code CLI arguments to the SDK", async () => {
|
||||
const { queryFactory, launches } = createQueryMock();
|
||||
const client = new ClaudeAgentClient({
|
||||
logger,
|
||||
queryFactory,
|
||||
resolveBinary: async () => "/test/claude/bin",
|
||||
});
|
||||
const session = await client.createSession({
|
||||
provider: "claude",
|
||||
cwd: process.cwd(),
|
||||
providerOptions: {
|
||||
extraArgs: { chrome: null, model: "x" },
|
||||
},
|
||||
});
|
||||
|
||||
await expect(session.startTurn("hello")).resolves.toEqual({
|
||||
turnId: expect.stringMatching(/^foreground-turn-/),
|
||||
});
|
||||
|
||||
expect(launches[0]?.options.extraArgs).toEqual({ chrome: null, model: "x" });
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("lists fast mode only for supported Opus models", async () => {
|
||||
const client = new ClaudeAgentClient({ logger, resolveBinary: async () => "/test/claude/bin" });
|
||||
|
||||
|
||||
@@ -48,7 +48,6 @@ export const ClaudeProviderOptionsSchema = z
|
||||
allowedTools: z.array(z.string()).optional(),
|
||||
disallowedTools: z.array(z.string()).optional(),
|
||||
additionalDirectories: z.array(z.string()).optional(),
|
||||
extraArgs: z.record(z.string(), z.string().nullable()).optional(),
|
||||
sandbox: z
|
||||
.object({
|
||||
enabled: z.boolean().optional(),
|
||||
|
||||
@@ -4,7 +4,11 @@ import { PassThrough } from "node:stream";
|
||||
import pino from "pino";
|
||||
import { describe, expect, test } from "vitest";
|
||||
|
||||
import { JsonlRpcProcess, type JsonlRpcExit } from "./jsonl-rpc-process.js";
|
||||
import {
|
||||
JsonlRpcProcess,
|
||||
JsonlRpcRequestRejectedError,
|
||||
type JsonlRpcExit,
|
||||
} from "./jsonl-rpc-process.js";
|
||||
|
||||
const CHILD_SOURCE = String.raw`
|
||||
const readline = require("node:readline");
|
||||
@@ -154,13 +158,13 @@ describe("JsonlRpcProcess", () => {
|
||||
}
|
||||
});
|
||||
|
||||
test("rejects unsuccessful responses", async () => {
|
||||
test("rejects unsuccessful responses as rejected requests", async () => {
|
||||
const transport = startProcess();
|
||||
|
||||
try {
|
||||
await expect(transport.request({ type: "fail" })).rejects.toThrow(
|
||||
"child rejected the request",
|
||||
);
|
||||
const request = transport.request({ type: "fail" });
|
||||
await expect(request).rejects.toThrow("child rejected the request");
|
||||
await expect(request).rejects.toBeInstanceOf(JsonlRpcRequestRejectedError);
|
||||
} finally {
|
||||
await transport.close();
|
||||
}
|
||||
@@ -173,9 +177,12 @@ describe("JsonlRpcProcess", () => {
|
||||
try {
|
||||
child.stderr.write("still waiting");
|
||||
|
||||
await expect(transport.request({ type: "hang" })).rejects.toThrow(
|
||||
const request = transport.request({ type: "hang" });
|
||||
await expect(request).rejects.toThrow(
|
||||
/JSONL RPC request timed out phase=hang elapsedMs=\d+ timeoutMs=50\nstill waiting/,
|
||||
);
|
||||
// A timed-out request may still be carried out, so it is not a rejection.
|
||||
await expect(request).rejects.not.toBeInstanceOf(JsonlRpcRequestRejectedError);
|
||||
} finally {
|
||||
await transport.close();
|
||||
}
|
||||
|
||||
@@ -40,6 +40,9 @@ interface PendingRequest {
|
||||
timer: NodeJS.Timeout | null;
|
||||
}
|
||||
|
||||
/** The process answered the request with a failure, so it did not carry it out. */
|
||||
export class JsonlRpcRequestRejectedError extends Error {}
|
||||
|
||||
export interface JsonlRpcExit {
|
||||
code: number | null;
|
||||
signal: NodeJS.Signals | null;
|
||||
@@ -283,7 +286,7 @@ export class JsonlRpcProcess {
|
||||
this.pending.delete(response.id);
|
||||
if (!response.success) {
|
||||
pending.reject(
|
||||
new Error(
|
||||
new JsonlRpcRequestRejectedError(
|
||||
response.error ?? `${this.diagnosticName} ${response.command ?? "request"} failed`,
|
||||
),
|
||||
);
|
||||
|
||||
@@ -8,6 +8,7 @@ import type { OmpNoTurnScheduler, OmpProviderIdleScheduler } from "./agent.js";
|
||||
import type { OmpUsagePollScheduler } from "./usage-poller.js";
|
||||
import { resolveOmpProviderParams } from "./provider-config.js";
|
||||
import { OmpHarness } from "./test-utils/omp-harness.js";
|
||||
import { JsonlRpcRequestRejectedError } from "../jsonl-rpc-process.js";
|
||||
|
||||
const TURN_LIFECYCLE_EVENTS = new Set<AgentStreamEvent["type"]>([
|
||||
"turn_started",
|
||||
@@ -376,25 +377,6 @@ describe("OMP agent client and session", () => {
|
||||
expect(omp.completedTurnCount()).toBe(1);
|
||||
});
|
||||
|
||||
test("does not complete a turn when a custom message arrives before its user message", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
await omp.requireStartTurn("hello OMP");
|
||||
|
||||
const runtime = omp.runtime();
|
||||
runtime.beginTurn();
|
||||
runtime.acceptCustomMessage("startup notice");
|
||||
|
||||
expect(omp.completedTurnCount()).toBe(0);
|
||||
|
||||
runtime.acceptPrompt("hello OMP", "user-1");
|
||||
runtime.streamAssistantText("model turn completed");
|
||||
runtime.finishTurn();
|
||||
await waitForImmediate();
|
||||
|
||||
expect(omp.completedTurnCount()).toBe(1);
|
||||
});
|
||||
|
||||
test("omits live custom messages when display is false", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
@@ -759,6 +741,221 @@ describe("OMP agent client and session", () => {
|
||||
]);
|
||||
});
|
||||
|
||||
test("a replacement prompt keeps its client correlation when OMP ends the aborted turn late", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
|
||||
await omp.requireStartTurnFromClient("first prompt", "client-a");
|
||||
const runtime = omp.runtime();
|
||||
runtime.beginTurn();
|
||||
runtime.acceptPrompt("first prompt", "omp-user-a");
|
||||
runtime.streamAssistantText("working on it", "omp-assistant-a");
|
||||
await omp.waitForUserMessages(1);
|
||||
|
||||
// OMP takes no steer, so Paseo interrupts and replaces the foreground turn.
|
||||
await omp.interrupt();
|
||||
await omp.requireStartTurnFromClient("second prompt", "client-b");
|
||||
runtime.beginTurn();
|
||||
|
||||
// OMP flushes the aborted turn's terminal event after the replacement started.
|
||||
runtime.emit({
|
||||
type: "agent_end",
|
||||
messages: [{ role: "assistant", content: [], responseId: "omp-assistant-a" }],
|
||||
});
|
||||
await omp.waitForEvent("turn_completed");
|
||||
|
||||
runtime.acceptPrompt("second prompt", "omp-user-b");
|
||||
runtime.streamAssistantText("second answer", "omp-assistant-b");
|
||||
runtime.finishTurn();
|
||||
await omp.waitForUserMessages(2);
|
||||
|
||||
expect(omp.timeline().filter((item) => item.type === "user_message")).toEqual([
|
||||
{
|
||||
type: "user_message",
|
||||
text: "first prompt",
|
||||
messageId: "omp-user-a",
|
||||
clientMessageId: "client-a",
|
||||
},
|
||||
{
|
||||
type: "user_message",
|
||||
text: "second prompt",
|
||||
messageId: "omp-user-b",
|
||||
clientMessageId: "client-b",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("a prompt echoed after a hidden custom message completes the turn keeps its client correlation", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
|
||||
await omp.requireStartTurnFromClient("do the thing", "client-a");
|
||||
const runtime = omp.runtime();
|
||||
runtime.beginTurn();
|
||||
runtime.emit({
|
||||
type: "message_end",
|
||||
message: { role: "custom", content: "xd://inventory", display: false },
|
||||
});
|
||||
await omp.waitForEvent("turn_completed");
|
||||
|
||||
runtime.acceptPrompt("do the thing", "omp-user-a");
|
||||
runtime.streamAssistantText("done", "omp-assistant-a");
|
||||
runtime.finishTurn();
|
||||
await omp.waitForUserMessages(1);
|
||||
|
||||
expect(omp.timeline().filter((item) => item.type === "user_message")).toEqual([
|
||||
{
|
||||
type: "user_message",
|
||||
text: "do the thing",
|
||||
messageId: "omp-user-a",
|
||||
clientMessageId: "client-a",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("a re-emitted echo does not consume a later submission's client correlation", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
|
||||
await omp.requireStartTurnFromClient("again", "client-a");
|
||||
const runtime = omp.runtime();
|
||||
runtime.beginTurn();
|
||||
runtime.acceptPrompt("again", "omp-user-a");
|
||||
runtime.streamAssistantText("first answer", "omp-assistant-a");
|
||||
runtime.finishTurn();
|
||||
await omp.waitForUserMessages(1);
|
||||
|
||||
await omp.requireStartTurnFromClient("again", "client-b");
|
||||
runtime.beginTurn();
|
||||
// OMP re-sends the first turn's entry before echoing the new submission.
|
||||
runtime.acceptPrompt("again", "omp-user-a");
|
||||
runtime.acceptPrompt("again", "omp-user-b");
|
||||
runtime.streamAssistantText("second answer", "omp-assistant-b");
|
||||
runtime.finishTurn();
|
||||
await omp.waitForUserMessages(2);
|
||||
|
||||
expect(omp.timeline().filter((item) => item.type === "user_message")).toEqual([
|
||||
{ type: "user_message", text: "again", messageId: "omp-user-a", clientMessageId: "client-a" },
|
||||
{ type: "user_message", text: "again", messageId: "omp-user-b", clientMessageId: "client-b" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("a prompt OMP echoes after its dispatch failed keeps its client correlation", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
|
||||
const runtime = omp.runtime();
|
||||
// The prompt reached OMP, but Paseo's request failed before it was answered.
|
||||
runtime.promptError = new Error("OMP prompt request timed out");
|
||||
await omp.requireStartTurnFromClient("slow prompt", "client-a");
|
||||
await omp.waitForEvent("turn_failed");
|
||||
runtime.promptError = null;
|
||||
|
||||
// OMP accepted it anyway and echoes it once it gets to the turn.
|
||||
runtime.beginTurn();
|
||||
runtime.acceptPrompt("slow prompt", "omp-user-a");
|
||||
runtime.streamAssistantText("late answer", "omp-assistant-a");
|
||||
runtime.finishTurn();
|
||||
await omp.waitForUserMessages(1);
|
||||
|
||||
expect(omp.timeline().filter((item) => item.type === "user_message")).toEqual([
|
||||
{
|
||||
type: "user_message",
|
||||
text: "slow prompt",
|
||||
messageId: "omp-user-a",
|
||||
clientMessageId: "client-a",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("a timed-out prompt OMP echoes late keeps its own correlation when the same text is resubmitted", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
|
||||
const runtime = omp.runtime();
|
||||
// OMP recorded the prompt, but Paseo's request timed out before it was answered.
|
||||
runtime.promptError = new Error("OMP prompt request timed out");
|
||||
await omp.requireStartTurnFromClient("retry me", "client-a");
|
||||
await omp.waitForEvent("turn_failed");
|
||||
runtime.promptError = null;
|
||||
|
||||
await omp.requireStartTurnFromClient("retry me", "client-b");
|
||||
// OMP echoes both prompts in the order it received them.
|
||||
runtime.beginTurn();
|
||||
runtime.acceptPrompt("retry me", "omp-user-a");
|
||||
runtime.acceptPrompt("retry me", "omp-user-b");
|
||||
runtime.streamAssistantText("done", "omp-assistant-b");
|
||||
runtime.finishTurn();
|
||||
await omp.waitForUserMessages(2);
|
||||
|
||||
expect(omp.timeline().filter((item) => item.type === "user_message")).toEqual([
|
||||
{
|
||||
type: "user_message",
|
||||
text: "retry me",
|
||||
messageId: "omp-user-a",
|
||||
clientMessageId: "client-a",
|
||||
},
|
||||
{
|
||||
type: "user_message",
|
||||
text: "retry me",
|
||||
messageId: "omp-user-b",
|
||||
clientMessageId: "client-b",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("a prompt OMP rejects leaves no correlation for a later submission", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
|
||||
const runtime = omp.runtime();
|
||||
runtime.promptError = new JsonlRpcRequestRejectedError("OMP refused the prompt");
|
||||
await omp.requireStartTurnFromClient("retry me", "client-a");
|
||||
await omp.waitForEvent("turn_failed");
|
||||
runtime.promptError = null;
|
||||
|
||||
await omp.requireStartTurnFromClient("retry me", "client-b");
|
||||
runtime.beginTurn();
|
||||
runtime.acceptPrompt("retry me", "omp-user-b");
|
||||
runtime.streamAssistantText("done", "omp-assistant-b");
|
||||
runtime.finishTurn();
|
||||
await omp.waitForUserMessages(1);
|
||||
|
||||
expect(omp.timeline().filter((item) => item.type === "user_message")).toEqual([
|
||||
{
|
||||
type: "user_message",
|
||||
text: "retry me",
|
||||
messageId: "omp-user-b",
|
||||
clientMessageId: "client-b",
|
||||
},
|
||||
]);
|
||||
});
|
||||
|
||||
test("the same prompt text on two turns correlates to its own submission", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
|
||||
await omp.requireStartTurnFromClient("again", "client-a");
|
||||
const runtime = omp.runtime();
|
||||
runtime.beginTurn();
|
||||
runtime.acceptPrompt("again", "omp-user-a");
|
||||
runtime.streamAssistantText("first answer", "omp-assistant-a");
|
||||
runtime.finishTurn();
|
||||
await omp.waitForUserMessages(1);
|
||||
|
||||
await omp.requireStartTurnFromClient("again", "client-b");
|
||||
runtime.beginTurn();
|
||||
runtime.acceptPrompt("again", "omp-user-b");
|
||||
runtime.streamAssistantText("second answer", "omp-assistant-b");
|
||||
runtime.finishTurn();
|
||||
await omp.waitForUserMessages(2);
|
||||
|
||||
expect(omp.timeline().filter((item) => item.type === "user_message")).toEqual([
|
||||
{ type: "user_message", text: "again", messageId: "omp-user-a", clientMessageId: "client-a" },
|
||||
{ type: "user_message", text: "again", messageId: "omp-user-b", clientMessageId: "client-b" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("re-emitted user message_end frames dedupe by native entry id", async () => {
|
||||
const omp = new OmpHarness();
|
||||
await omp.start();
|
||||
|
||||
@@ -72,6 +72,7 @@ import { getUserMessageText } from "./message-history.js";
|
||||
import { mapOmpSystemNoticeToNotification } from "./system-notice.js";
|
||||
import { materializeProviderImage } from "../provider-image-output.js";
|
||||
import { OmpCliRuntime } from "./cli-runtime.js";
|
||||
import { JsonlRpcRequestRejectedError } from "../jsonl-rpc-process.js";
|
||||
import { listOmpImportableSessions, readOmpImportSessionConfig } from "./session-descriptor.js";
|
||||
import type { OmpRuntime, OmpRuntimeSession, OmpStartSessionInput } from "./runtime.js";
|
||||
import type {
|
||||
@@ -322,6 +323,9 @@ function renderTextOnlyImageHint(image: { data: string; mimeType: string }): str
|
||||
}
|
||||
}
|
||||
|
||||
// Bounds the dispatched prompts whose client correlation is still unclaimed.
|
||||
const MAX_PENDING_CLIENT_CORRELATIONS = 16;
|
||||
|
||||
function convertPromptInput(
|
||||
prompt: AgentPromptInput,
|
||||
options: { model: OmpModel | null | undefined },
|
||||
@@ -855,7 +859,12 @@ export class OmpAgentSession implements AgentSession {
|
||||
private activeAskUserDialog: ActiveAskUserDialog | null = null;
|
||||
private pendingCombinedAskUserResponse: PendingCombinedAskUserResponse | null = null;
|
||||
private activeTurnId: string | null = null;
|
||||
private activeClientMessageId: string | null = null;
|
||||
// A dispatched prompt owns its client correlation, not the turn that carried
|
||||
// it: OMP echoes the prompt as a user message that can land after Paseo has
|
||||
// already completed the turn (a replacement turn, or a turn completed by a
|
||||
// custom message). Correlations are consumed by the echo they belong to, so
|
||||
// the same text submitted on different turns stays separate.
|
||||
private readonly pendingClientCorrelations: Array<{ text: string; clientMessageId: string }> = [];
|
||||
private activeAssistantMessageId: string | null = null;
|
||||
private activeTurnTerminalAssistantMessage: OmpAgentMessage | null = null;
|
||||
private activeTurnStarted = false;
|
||||
@@ -962,9 +971,11 @@ export class OmpAgentSession implements AgentSession {
|
||||
|
||||
const payload = convertPromptInput(prompt, { model: this.state.model });
|
||||
const turnId = randomUUID();
|
||||
if (options?.clientMessageId) {
|
||||
this.rememberClientCorrelation(payload.text, options.clientMessageId);
|
||||
}
|
||||
this.live = true;
|
||||
this.activeTurnId = turnId;
|
||||
this.activeClientMessageId = options?.clientMessageId ?? null;
|
||||
this.activeAssistantMessageId = null;
|
||||
this.activeTurnTerminalAssistantMessage = null;
|
||||
this.activeTurnStarted = false;
|
||||
@@ -994,12 +1005,16 @@ export class OmpAgentSession implements AgentSession {
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
if (error instanceof JsonlRpcRequestRejectedError) {
|
||||
// OMP refused the prompt, so no echo will ever claim it. Any other
|
||||
// failure (a timeout) leaves the prompt with OMP, which still echoes it.
|
||||
this.forgetClientCorrelation(options?.clientMessageId);
|
||||
}
|
||||
if (this.activeTurnId !== turnId) {
|
||||
return;
|
||||
}
|
||||
this.usagePoller.stopTurn();
|
||||
this.activeTurnId = null;
|
||||
this.activeClientMessageId = null;
|
||||
this.activeTurnStarted = false;
|
||||
this.activeTurnHasUserMessage = false;
|
||||
this.activeAssistantMessageId = null;
|
||||
@@ -1132,7 +1147,6 @@ export class OmpAgentSession implements AgentSession {
|
||||
this.terminalizeActiveWork();
|
||||
this.usagePoller.stopTurn();
|
||||
this.activeTurnId = null;
|
||||
this.activeClientMessageId = null;
|
||||
this.activeTurnStarted = false;
|
||||
this.activeTurnHasUserMessage = false;
|
||||
this.activeAssistantMessageId = null;
|
||||
@@ -1293,6 +1307,36 @@ export class OmpAgentSession implements AgentSession {
|
||||
return this.activeTurnId ?? undefined;
|
||||
}
|
||||
|
||||
private rememberClientCorrelation(text: string, clientMessageId: string): void {
|
||||
this.pendingClientCorrelations.push({ text, clientMessageId });
|
||||
// OMP never echoes some prompts (a local-only command, a session that dies
|
||||
// mid-turn). Keep the queue bounded so those cannot accumulate.
|
||||
if (this.pendingClientCorrelations.length > MAX_PENDING_CLIENT_CORRELATIONS) {
|
||||
this.pendingClientCorrelations.shift();
|
||||
}
|
||||
}
|
||||
|
||||
private forgetClientCorrelation(clientMessageId: string | undefined): void {
|
||||
const index = this.pendingClientCorrelations.findIndex(
|
||||
(entry) => entry.clientMessageId === clientMessageId,
|
||||
);
|
||||
if (index !== -1) {
|
||||
this.pendingClientCorrelations.splice(index, 1);
|
||||
}
|
||||
}
|
||||
|
||||
// OMP echoes prompts in the order Paseo sent them, and a prompt OMP refused
|
||||
// is forgotten on dispatch, so the oldest unclaimed correlation with the
|
||||
// echoed text belongs to this echo.
|
||||
private takeClientCorrelation(text: string): string | null {
|
||||
const index = this.pendingClientCorrelations.findIndex((entry) => entry.text === text);
|
||||
if (index === -1) {
|
||||
return null;
|
||||
}
|
||||
const [entry] = this.pendingClientCorrelations.splice(index, 1);
|
||||
return entry?.clientMessageId ?? null;
|
||||
}
|
||||
|
||||
private scheduleNoTurnPromptCompletion(turnId: string): void {
|
||||
this.cancelNoTurnPromptCompletion();
|
||||
const abort = new AbortController();
|
||||
@@ -1346,6 +1390,7 @@ export class OmpAgentSession implements AgentSession {
|
||||
const outputs = this.pendingNoTurnOutputs.filter((output) => output.turnId === turnId);
|
||||
this.clearNoTurnBuffers();
|
||||
if (promptText) {
|
||||
const clientMessageId = this.takeClientCorrelation(promptText);
|
||||
this.emit({
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
@@ -1353,7 +1398,7 @@ export class OmpAgentSession implements AgentSession {
|
||||
item: {
|
||||
type: "user_message",
|
||||
text: promptText,
|
||||
...(this.activeClientMessageId ? { clientMessageId: this.activeClientMessageId } : {}),
|
||||
...(clientMessageId ? { clientMessageId } : {}),
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -1791,7 +1836,6 @@ export class OmpAgentSession implements AgentSession {
|
||||
}
|
||||
const turnId = this.activeTurnId;
|
||||
this.activeTurnId = null;
|
||||
this.activeClientMessageId = null;
|
||||
this.activeTurnStarted = false;
|
||||
this.activeTurnHasUserMessage = false;
|
||||
this.activeTurnTerminalAssistantMessage = null;
|
||||
@@ -2033,6 +2077,9 @@ export class OmpAgentSession implements AgentSession {
|
||||
});
|
||||
}
|
||||
}
|
||||
if (!this.activeTurnHasUserMessage) {
|
||||
this.completeTurn(turnId, []);
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -2045,7 +2092,6 @@ export class OmpAgentSession implements AgentSession {
|
||||
}
|
||||
const nativeMessage = event.message as OmpAgentMessage & { id?: unknown; entryId?: unknown };
|
||||
const messageId = readNativeMessageId(nativeMessage);
|
||||
const clientMessageId = this.activeClientMessageId;
|
||||
const emitUserMessage = (resolvedMessageId?: string): void => {
|
||||
if (resolvedMessageId) {
|
||||
// OMP re-emits user message_end frames for entries it has already
|
||||
@@ -2056,6 +2102,9 @@ export class OmpAgentSession implements AgentSession {
|
||||
}
|
||||
this.emittedUserMessageIds.add(resolvedMessageId);
|
||||
}
|
||||
// Claimed only once a row is really emitted, so a re-emitted frame cannot
|
||||
// consume the correlation of a later submission with the same text.
|
||||
const clientMessageId = this.takeClientCorrelation(text);
|
||||
this.emit({
|
||||
type: "timeline",
|
||||
provider: this.provider,
|
||||
@@ -2129,7 +2178,6 @@ export class OmpAgentSession implements AgentSession {
|
||||
|
||||
private completeTurn(turnId: string | undefined, messages: OmpAgentMessage[]): void {
|
||||
this.activeTurnId = null;
|
||||
this.activeClientMessageId = null;
|
||||
this.activeAssistantMessageId = null;
|
||||
this.activeTurnTerminalAssistantMessage = null;
|
||||
this.activeTurnStarted = false;
|
||||
|
||||
@@ -44,6 +44,7 @@ export interface OmpStartSessionInput {
|
||||
|
||||
export interface OmpRuntimeSession {
|
||||
onEvent(callback: (event: OmpRuntimeEvent) => void): () => void;
|
||||
// Rejects with JsonlRpcRequestRejectedError when OMP refused the prompt.
|
||||
prompt(
|
||||
message: string,
|
||||
images?: Array<{ type: "image"; data: string; mimeType: string }>,
|
||||
|
||||
@@ -133,6 +133,7 @@ export class FakeOmpSession implements OmpRuntimeSession {
|
||||
compactError: Error | null = null;
|
||||
emitCompactEnd = true;
|
||||
getStateError: Error | null = null;
|
||||
promptError: Error | null = null;
|
||||
promptAck: OmpPromptAck = {};
|
||||
branchResponse: { text?: string; cancelled?: boolean } = { text: "" };
|
||||
branchMessages: Array<{ entryId: string; text: string }> = [];
|
||||
@@ -182,6 +183,9 @@ export class FakeOmpSession implements OmpRuntimeSession {
|
||||
}
|
||||
this.prompts.push({ message, imageCount: images?.length ?? 0 });
|
||||
this.promptWaiters.shift()?.();
|
||||
if (this.promptError) {
|
||||
throw this.promptError;
|
||||
}
|
||||
const heldPrompt = this.nextHeldPrompt;
|
||||
if (heldPrompt) {
|
||||
this.nextHeldPrompt = null;
|
||||
|
||||
@@ -466,6 +466,33 @@ export class OmpHarness {
|
||||
await promptStarted;
|
||||
}
|
||||
|
||||
// Drains the microtask queue until the session has reached the state the test
|
||||
// is waiting on, so no test depends on how many async steps a path takes.
|
||||
async waitUntil(condition: () => boolean, description: string): Promise<void> {
|
||||
for (let index = 0; index < 100; index += 1) {
|
||||
if (condition()) return;
|
||||
await waitForImmediate();
|
||||
}
|
||||
throw new Error(`OMP harness timed out waiting for ${description}`);
|
||||
}
|
||||
|
||||
async waitForUserMessages(count: number): Promise<void> {
|
||||
await this.waitUntil(
|
||||
() => this.timeline().filter((item) => item.type === "user_message").length >= count,
|
||||
`${count} user message(s)`,
|
||||
);
|
||||
}
|
||||
|
||||
async waitForEvent(type: AgentStreamEvent["type"]): Promise<void> {
|
||||
await this.waitUntil(() => this.eventTypes().includes(type), `a ${type} event`);
|
||||
}
|
||||
|
||||
async requireStartTurnFromClient(message: string, clientMessageId: string): Promise<void> {
|
||||
const promptStarted = this.omp.latestSession().nextPrompt();
|
||||
await this.requireSession().startTurn(message, { clientMessageId });
|
||||
await promptStarted;
|
||||
}
|
||||
|
||||
async interrupt(): Promise<void> {
|
||||
await this.requireSession().interrupt();
|
||||
}
|
||||
|
||||
@@ -2385,41 +2385,49 @@ describe("OpenCode adapter startTurn error handling", () => {
|
||||
});
|
||||
|
||||
test("sends exact Hub MCP permission grants without approving unrelated tools", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCode = new TestOpenCodeClient();
|
||||
runtime.enqueueClient(openCode);
|
||||
const client = new OpenCodeAgentClient(createTestLogger(), undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
try {
|
||||
const session = await client.createSession({
|
||||
const promptAsync = vi.fn(async () => ({ data: {}, error: undefined }));
|
||||
const fakeClient = {
|
||||
global: {
|
||||
event: vi.fn().mockImplementation(async ({ signal }: { signal: AbortSignal }) => ({
|
||||
stream: {
|
||||
async *[Symbol.asyncIterator](): AsyncGenerator<OpenCodeEvent> {
|
||||
yield { type: "server.connected", properties: {} } as OpenCodeEvent;
|
||||
await waitForAbort(signal);
|
||||
},
|
||||
},
|
||||
})),
|
||||
},
|
||||
session: { promptAsync },
|
||||
} as never;
|
||||
const session = new __openCodeInternals.OpenCodeAgentSession(
|
||||
{
|
||||
provider: "opencode",
|
||||
cwd,
|
||||
providerOptions: { permission: { bash: "ask", question: "deny" } },
|
||||
cwd: "/tmp/test",
|
||||
providerOptions: { permission: { bash: "ask", hub_reply: "deny" } },
|
||||
toolPolicy: {
|
||||
preapproved: [{ kind: "mcp", server: "hub", tool: "finish_execution" }],
|
||||
},
|
||||
});
|
||||
try {
|
||||
const created = openCode.calls.sessionCreate[0] as {
|
||||
permission?: Array<{ permission: string; pattern: string; action: string }>;
|
||||
};
|
||||
expect(created.permission).toEqual([
|
||||
},
|
||||
fakeClient,
|
||||
"ses_unit_test",
|
||||
createTestLogger(),
|
||||
);
|
||||
|
||||
await session.startTurn("finish");
|
||||
|
||||
expect(promptAsync).toHaveBeenCalledWith(
|
||||
expect.objectContaining({
|
||||
permission: [
|
||||
{ permission: "hub_finish_execution", pattern: "*", action: "allow" },
|
||||
{ permission: "bash", pattern: "*", action: "ask" },
|
||||
{ permission: "question", pattern: "*", action: "deny" },
|
||||
]);
|
||||
expect(created.permission).not.toContainEqual(
|
||||
expect.objectContaining({ permission: "bash", action: "allow" }),
|
||||
);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
{ permission: "hub_reply", pattern: "*", action: "deny" },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(promptAsync.mock.calls[0]?.[0].permission).not.toContainEqual(
|
||||
expect.objectContaining({ permission: "bash", action: "allow" }),
|
||||
);
|
||||
await session.close();
|
||||
});
|
||||
|
||||
test("waits for the stop abort and provider idle before starting the next prompt", async () => {
|
||||
@@ -6725,124 +6733,3 @@ describe("OpenCode snapshot summary false-idle regression", () => {
|
||||
await session.close();
|
||||
});
|
||||
});
|
||||
|
||||
describe("OpenCode session permission rules", () => {
|
||||
const logger = createTestLogger();
|
||||
function permissionConfig(cwd: string): AgentSessionConfig {
|
||||
return {
|
||||
provider: "opencode",
|
||||
cwd,
|
||||
providerOptions: { permission: { bash: "ask", external_directory: "allow" } },
|
||||
toolPolicy: { preapproved: [{ kind: "mcp", server: "hub", tool: "finish_execution" }] },
|
||||
};
|
||||
}
|
||||
// OpenCode stores permission rules on the session (POST /session, PATCH /session/{id}) and
|
||||
// drops an unknown `permission` field on prompt_async, so rules sent with a prompt never
|
||||
// reach the server.
|
||||
const expectedRules = [
|
||||
{ permission: "hub_finish_execution", pattern: "*", action: "allow" },
|
||||
{ permission: "bash", pattern: "*", action: "ask" },
|
||||
{ permission: "external_directory", pattern: "*", action: "allow" },
|
||||
];
|
||||
|
||||
test("creates the session with the agent's permission rules", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCode = new TestOpenCodeClient();
|
||||
runtime.enqueueClient(openCode);
|
||||
const client = new OpenCodeAgentClient(logger, undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
try {
|
||||
const session = await client.createSession(permissionConfig(cwd));
|
||||
try {
|
||||
expect(openCode.calls.sessionCreate[0]).toEqual(
|
||||
expect.objectContaining({ permission: expectedRules }),
|
||||
);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("applies the agent's permission rules to a resumed session", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCode = new TestOpenCodeClient();
|
||||
runtime.enqueueClient(openCode);
|
||||
const client = new OpenCodeAgentClient(logger, undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
try {
|
||||
const session = await client.resumeSession(
|
||||
{ provider: "opencode", sessionId: "ses_resumed_rules", metadata: { cwd } },
|
||||
permissionConfig(cwd),
|
||||
);
|
||||
try {
|
||||
expect(openCode.calls.sessionUpdate[0]).toEqual(
|
||||
expect.objectContaining({
|
||||
sessionID: "ses_resumed_rules",
|
||||
permission: expectedRules,
|
||||
}),
|
||||
);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("fails the resume when the rules cannot be applied", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCode = new TestOpenCodeClient();
|
||||
openCode.sessionUpdateResponse = { error: { data: { message: "session is archived" } } };
|
||||
runtime.enqueueClient(openCode);
|
||||
const client = new OpenCodeAgentClient(logger, undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
try {
|
||||
// Resuming without the rules would run the agent under a more permissive policy than
|
||||
// the one it was configured with, which is the failure this guards.
|
||||
await expect(
|
||||
client.resumeSession(
|
||||
{ provider: "opencode", sessionId: "ses_archived", metadata: { cwd } },
|
||||
permissionConfig(cwd),
|
||||
),
|
||||
).rejects.toThrow("Failed to apply OpenCode session permission rules");
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
test("leaves a session without rules untouched", async () => {
|
||||
const cwd = tmpCwd();
|
||||
const runtime = new TestOpenCodeHarness();
|
||||
const openCode = new TestOpenCodeClient();
|
||||
runtime.enqueueClient(openCode);
|
||||
const client = new OpenCodeAgentClient(logger, undefined, {
|
||||
serverManager: runtime,
|
||||
createClient: runtime.createClient,
|
||||
});
|
||||
try {
|
||||
const session = await client.resumeSession({
|
||||
provider: "opencode",
|
||||
sessionId: "ses_resumed_no_rules",
|
||||
metadata: { cwd },
|
||||
});
|
||||
try {
|
||||
expect(openCode.calls.sessionUpdate).toEqual([]);
|
||||
} finally {
|
||||
await session.close();
|
||||
}
|
||||
} finally {
|
||||
rmSync(cwd, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
||||
@@ -1452,22 +1452,12 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
directory: openCodeConfig.cwd,
|
||||
});
|
||||
|
||||
// OpenCode stores permission rules on the session, so they are set here and on resume
|
||||
// rather than sent with each prompt, which drops them.
|
||||
const permission = buildOpenCodePermissionRules(
|
||||
openCodeConfig.providerOptions,
|
||||
openCodeConfig.toolPolicy,
|
||||
);
|
||||
|
||||
try {
|
||||
// Creating the first session for a directory is part of OpenCode coming up, so it
|
||||
// shares the server startup budget instead of a shorter one that fails agent
|
||||
// creation on contended cold starts.
|
||||
const response = await withTimeout(
|
||||
client.session.create({
|
||||
directory: openCodeConfig.cwd,
|
||||
...(permission ? { permission } : {}),
|
||||
}),
|
||||
client.session.create({ directory: openCodeConfig.cwd }),
|
||||
OPENCODE_SERVER_STARTUP_TIMEOUT_MS,
|
||||
`OpenCode session.create timed out after ${Math.round(
|
||||
OPENCODE_SERVER_STARTUP_TIMEOUT_MS / 1000,
|
||||
@@ -1537,7 +1527,6 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
});
|
||||
|
||||
try {
|
||||
await this.applySessionPermissionRules(client, openCodeConfig, handle.sessionId);
|
||||
await this.populateModelContextWindowCache(client, openCodeConfig.cwd);
|
||||
const unbindBridge = this.bindBridgeSession(handle.sessionId, launchContext);
|
||||
|
||||
@@ -1561,23 +1550,6 @@ export class OpenCodeAgentClient implements AgentClient {
|
||||
}
|
||||
}
|
||||
|
||||
private async applySessionPermissionRules(
|
||||
client: OpencodeClient,
|
||||
config: OpenCodeAgentConfig,
|
||||
sessionId: string,
|
||||
): Promise<void> {
|
||||
const permission = buildOpenCodePermissionRules(config.providerOptions, config.toolPolicy);
|
||||
if (!permission) return;
|
||||
const response = readOpenCodeRecord(
|
||||
await client.session.update({ sessionID: sessionId, directory: config.cwd, permission }),
|
||||
);
|
||||
if (response?.error) {
|
||||
throw new Error(
|
||||
`Failed to apply OpenCode session permission rules: ${toDiagnosticErrorMessage(response.error)}`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
private acquireServer(
|
||||
config: OpenCodeAgentConfig,
|
||||
launchContext?: AgentLaunchContext,
|
||||
@@ -3538,6 +3510,10 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
this.config.systemPrompt,
|
||||
this.config.daemonAppendSystemPrompt,
|
||||
);
|
||||
const permission = buildOpenCodePermissionRules(
|
||||
this.config.providerOptions,
|
||||
this.config.toolPolicy,
|
||||
);
|
||||
const model = this.parseModel(this.config.model);
|
||||
const effectiveMode = resolveOpenCodeRuntimeAgentId(this.currentMode);
|
||||
const effectiveVariant = this.config.thinkingOptionId ?? undefined;
|
||||
@@ -3549,6 +3525,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
messageID: promptId,
|
||||
parts,
|
||||
...(systemPrompt ? { system: systemPrompt } : {}),
|
||||
...(permission ? { permission } : {}),
|
||||
...(model ? { model } : {}),
|
||||
...(effectiveMode ? { agent: effectiveMode } : {}),
|
||||
...(effectiveVariant ? { variant: effectiveVariant } : {}),
|
||||
@@ -3873,6 +3850,10 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
this.config.systemPrompt,
|
||||
this.config.daemonAppendSystemPrompt,
|
||||
);
|
||||
const permission = buildOpenCodePermissionRules(
|
||||
this.config.providerOptions,
|
||||
this.config.toolPolicy,
|
||||
);
|
||||
const promptResponse = await this.client.session.promptAsync({
|
||||
sessionID: this.sessionId,
|
||||
directory: this.config.cwd,
|
||||
@@ -3887,6 +3868,7 @@ class OpenCodeAgentSession implements AgentSession {
|
||||
}
|
||||
: {}),
|
||||
...(systemPrompt ? { system: systemPrompt } : {}),
|
||||
...(permission ? { permission } : {}),
|
||||
...(model ? { model } : {}),
|
||||
...(effectiveMode ? { agent: effectiveMode } : {}),
|
||||
...(effectiveVariant ? { variant: effectiveVariant } : {}),
|
||||
|
||||
@@ -11,52 +11,6 @@ import { OpenCodeAgentClient } from "./opencode-agent.js";
|
||||
import { OpenCodeBridge } from "./opencode/bridge.js";
|
||||
import { OpenCodeServerManager } from "./opencode/server-manager.js";
|
||||
|
||||
test("real OpenCode server persists provider permissions across creation and resume", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-permissions-"));
|
||||
const cwd = path.join(root, "repo");
|
||||
const logger = createTestLogger();
|
||||
const manager = new OpenCodeServerManager({ logger, resolveHomeDir: () => root });
|
||||
const client = new OpenCodeAgentClient(logger, undefined, { serverManager: manager });
|
||||
let session: Awaited<ReturnType<OpenCodeAgentClient["createSession"]>> | undefined;
|
||||
let inspection: Awaited<ReturnType<OpenCodeServerManager["acquireCurrent"]>> | undefined;
|
||||
|
||||
try {
|
||||
await mkdir(cwd);
|
||||
session = await client.createSession({
|
||||
provider: "opencode",
|
||||
cwd,
|
||||
providerOptions: { permission: { external_directory: "allow" } },
|
||||
});
|
||||
inspection = await manager.acquireCurrent();
|
||||
const sdk = createOpencodeClient({ baseUrl: inspection.server.url, directory: cwd });
|
||||
async function readPermission(sessionId: string) {
|
||||
const response = await sdk.session.get({ sessionID: sessionId, directory: cwd });
|
||||
if (response.error) throw new Error(JSON.stringify(response.error));
|
||||
return response.data?.permission;
|
||||
}
|
||||
const handle = session.describePersistence()!;
|
||||
expect(await readPermission(handle.sessionId)).toEqual([
|
||||
{ permission: "external_directory", pattern: "*", action: "allow" },
|
||||
]);
|
||||
|
||||
await session.close();
|
||||
// OpenCode appends session-update rules and the last matching rule wins, so a resume
|
||||
// with a different policy leaves both entries in order.
|
||||
session = await client.resumeSession(handle, {
|
||||
providerOptions: { permission: { external_directory: "deny" } },
|
||||
});
|
||||
expect(await readPermission(handle.sessionId)).toEqual([
|
||||
{ permission: "external_directory", pattern: "*", action: "allow" },
|
||||
{ permission: "external_directory", pattern: "*", action: "deny" },
|
||||
]);
|
||||
} finally {
|
||||
await inspection?.release();
|
||||
await session?.close();
|
||||
await manager.shutdown();
|
||||
await rm(root, { recursive: true, force: true });
|
||||
}
|
||||
}, 120_000);
|
||||
|
||||
test("real OpenCode server shares one process while shell.env stays session-scoped", async () => {
|
||||
const root = await mkdtemp(path.join(os.tmpdir(), "paseo-opencode-real-"));
|
||||
const firstCwd = path.join(root, "first");
|
||||
|
||||
@@ -78,15 +78,11 @@ describe("SessionAuthorization", () => {
|
||||
const authorization = new SessionAuthorization(["hub.execute"]);
|
||||
for (const type of [
|
||||
"create_agent_request",
|
||||
"workspace.title.set.request",
|
||||
"fetch_agents_request",
|
||||
"send_agent_message_request",
|
||||
"fetch_agent_request",
|
||||
"agent.timeline.set_subscription.request",
|
||||
"send_agent_message_request",
|
||||
"workspace.recovery.inspect.request",
|
||||
"workspace.recovery.restore.request",
|
||||
"archive_workspace_request",
|
||||
"cancel_agent_request",
|
||||
] as const) {
|
||||
expect(authorization.allowsInbound(inboundMessage(type))).toBe(true);
|
||||
}
|
||||
@@ -94,17 +90,8 @@ describe("SessionAuthorization", () => {
|
||||
"status",
|
||||
"agent_update",
|
||||
"agent_stream",
|
||||
"workspace_update",
|
||||
"rpc_error",
|
||||
"workspace.title.set.response",
|
||||
"fetch_agents_response",
|
||||
"fetch_agent_response",
|
||||
"agent.timeline.set_subscription.response",
|
||||
"send_agent_message_response",
|
||||
"workspace.recovery.inspect.response",
|
||||
"workspace.recovery.restore.response",
|
||||
"archive_workspace_response",
|
||||
"cancel_agent_response",
|
||||
] as const) {
|
||||
expect(authorization.allowsOutbound(outboundMessage(type))).toBe(true);
|
||||
}
|
||||
|
||||
@@ -207,7 +207,7 @@ const INBOUND_PERMISSION = {
|
||||
"workspace.script.list.request": "workspace.read",
|
||||
"workspace.script.start.request": "workspace.write",
|
||||
"workspace.script.stop.request": "workspace.write",
|
||||
"workspace.title.set.request": ["workspace.manage", "hub.execute"],
|
||||
"workspace.title.set.request": "workspace.manage",
|
||||
workspace_setup_status_request: "workspace.read",
|
||||
"workspace.setup.run.request": "workspace.write",
|
||||
write_project_config_request: "workspace.write",
|
||||
@@ -430,7 +430,7 @@ const OUTBOUND_PERMISSION = {
|
||||
"workspace.script.list.response": "workspace.read",
|
||||
"workspace.script.start.response": "workspace.write",
|
||||
"workspace.script.stop.response": "workspace.write",
|
||||
"workspace.title.set.response": ["workspace.manage", "hub.execute"],
|
||||
"workspace.title.set.response": "workspace.manage",
|
||||
workspace_setup_progress: "workspace.read",
|
||||
workspace_setup_status_response: "workspace.read",
|
||||
"workspace.setup.run.response": "workspace.write",
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import { afterEach, expect, test } from "vitest";
|
||||
import type { SessionOutboundMessage } from "../messages.js";
|
||||
import { HubRelationshipHarness } from "./test-utils/relationship-harness.js";
|
||||
|
||||
let relationship: HubRelationshipHarness | null = null;
|
||||
@@ -17,13 +16,6 @@ async function launchRelationship(): Promise<HubRelationshipHarness> {
|
||||
return launched;
|
||||
}
|
||||
|
||||
function createdAgentWorkspaceId(message: SessionOutboundMessage): string {
|
||||
if (message.type !== "status" || message.payload.status !== "agent_created")
|
||||
throw new Error("Agent was not created");
|
||||
if (!message.payload.agent.workspaceId) throw new Error("Workspace was not created");
|
||||
return message.payload.agent.workspaceId;
|
||||
}
|
||||
|
||||
test("Hub retries one durable daemon execution across concurrency and reconstruction", async () => {
|
||||
const hub = await launchRelationship();
|
||||
|
||||
@@ -62,46 +54,6 @@ test("Hub execute can steer ordinary agents while unrelated administration stays
|
||||
expect(hub.serverInfoPermissions()).toEqual([["hub.execute"]]);
|
||||
});
|
||||
|
||||
test("Hub execute can title the workspace created with an agent", async () => {
|
||||
const hub = await launchRelationship();
|
||||
const created = await hub.requestOrdinary({
|
||||
type: "create_agent_request",
|
||||
requestId: "create-for-title",
|
||||
config: { provider: "codex", cwd: hub.repoRoot() },
|
||||
});
|
||||
expect(created).toMatchObject({
|
||||
type: "status",
|
||||
payload: { status: "agent_created", agent: { workspaceId: expect.any(String) } },
|
||||
});
|
||||
const workspaceId = createdAgentWorkspaceId(created);
|
||||
|
||||
expect(
|
||||
await hub.requestOrdinary({
|
||||
type: "workspace.title.set.request",
|
||||
requestId: "title-created-workspace",
|
||||
workspaceId,
|
||||
title: "Hub execution",
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: "workspace.title.set.response",
|
||||
payload: { workspaceId, accepted: true, title: "Hub execution", error: null },
|
||||
});
|
||||
|
||||
expect(
|
||||
await hub.requestOrdinary({
|
||||
type: "fetch_workspaces_request",
|
||||
requestId: "read-created-workspace",
|
||||
}),
|
||||
).toMatchObject({
|
||||
type: "fetch_workspaces_response",
|
||||
payload: {
|
||||
entries: expect.arrayContaining([
|
||||
expect.objectContaining({ id: workspaceId, title: "Hub execution" }),
|
||||
]),
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
test("ordinary Hub create and message retries do not duplicate agents or prompts", async () => {
|
||||
const hub = await launchRelationship();
|
||||
const create = {
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { cp, mkdir, mkdtemp, readFile, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import { expect, onTestFinished, test } from "vitest";
|
||||
import { DaemonClient } from "../test-utils/daemon-client.js";
|
||||
import { createTestPaseoDaemon } from "../test-utils/paseo-daemon.js";
|
||||
@@ -458,80 +457,3 @@ test("a provider that never acknowledges session.close does not hold the daemon
|
||||
// test fails by timing out.
|
||||
await expect(daemon.daemon.stop()).resolves.toBeUndefined();
|
||||
}, 90_000);
|
||||
|
||||
const repoRoot = fileURLToPath(new URL("../../../../../", import.meta.url));
|
||||
|
||||
// Hold the provider close until the subprocess has entered shutdown cleanup.
|
||||
async function createSlowClosingProviderPlugin() {
|
||||
const directory = await mkdtemp(path.join(tmpdir(), "paseo-slow-close-plugin-"));
|
||||
const closeStarted = path.join(directory, "close-started");
|
||||
const closeRelease = path.join(directory, "close-release");
|
||||
const cleanupStarted = path.join(directory, "cleanup-started");
|
||||
await cp(path.join(repoRoot, "plugin-examples/provider-direct"), directory, { recursive: true });
|
||||
const providerPath = path.join(directory, "server", "provider.ts");
|
||||
const source = (await readFile(providerPath, "utf8")).replaceAll("\r\n", "\n");
|
||||
const patched = `import { readFile, writeFile } from "node:fs/promises";\n${source.replace(
|
||||
" closed = true;\n sessions.clear();",
|
||||
` closed = true;
|
||||
await writeFile(${JSON.stringify(closeStarted)}, "started");
|
||||
while (true) {
|
||||
try { await readFile(${JSON.stringify(closeRelease)}); break; }
|
||||
catch { await new Promise((resolve) => setTimeout(resolve, 10)); }
|
||||
}
|
||||
sessions.clear();
|
||||
console.log("provider close completed");`,
|
||||
)}`;
|
||||
expect(patched).toContain(`await writeFile(${JSON.stringify(closeStarted)}, "started")`);
|
||||
await writeFile(providerPath, patched);
|
||||
const entryPath = path.join(directory, "index.server.ts");
|
||||
const entry = (await readFile(entryPath, "utf8")).replaceAll("\r\n", "\n");
|
||||
const withCleanup = `import { writeFile } from "node:fs/promises";\n${entry.replace(
|
||||
" return () => {};",
|
||||
` return async () => { await writeFile(${JSON.stringify(cleanupStarted)}, "started"); console.log("plugin cleanup completed"); };`,
|
||||
)}`;
|
||||
expect(withCleanup).toContain(`await writeFile(${JSON.stringify(cleanupStarted)}, "started")`);
|
||||
await writeFile(entryPath, withCleanup);
|
||||
return { directory, closeStarted, closeRelease, cleanupStarted };
|
||||
}
|
||||
|
||||
test("reloading a plugin does not send on a closed IPC channel", async () => {
|
||||
const {
|
||||
directory: pluginDirectory,
|
||||
closeStarted,
|
||||
closeRelease,
|
||||
cleanupStarted,
|
||||
} = await createSlowClosingProviderPlugin();
|
||||
const cwd = await mkdtemp(path.join(tmpdir(), "paseo-reload-teardown-"));
|
||||
const daemon = await createTestPaseoDaemon();
|
||||
const client = new DaemonClient({ url: `ws://127.0.0.1:${daemon.port}/ws` });
|
||||
try {
|
||||
await client.connect();
|
||||
await client.patchDaemonConfig({ pluginsEnabled: true });
|
||||
await client.installDirectoryPlugin(pluginDirectory);
|
||||
const agent = await client.createAgent({ provider: "direct-example", cwd });
|
||||
const turn = client.sendMessage(agent.id, "Say hello").catch(() => undefined);
|
||||
const reload = client.reloadPlugin("provider-direct-example");
|
||||
await expect
|
||||
.poll(() => readFile(closeStarted, "utf8").catch(() => null), { timeout: 20_000 })
|
||||
.toBe("started");
|
||||
await expect
|
||||
.poll(() => readFile(cleanupStarted, "utf8").catch(() => null), { timeout: 20_000 })
|
||||
.toBe("started");
|
||||
await writeFile(closeRelease, "release");
|
||||
const reloaded = await reload;
|
||||
expect(reloaded.status).toBe("running");
|
||||
const entries = await client.getPluginLogs("provider-direct-example");
|
||||
const messages = entries.map((entry) => entry.message).join("\n");
|
||||
expect(messages).not.toContain("ERR_IPC_CHANNEL_CLOSED");
|
||||
expect(entries.filter((entry) => entry.message === "plugin cleanup completed")).toHaveLength(1);
|
||||
expect(entries.filter((entry) => entry.message === "provider close completed")).toHaveLength(1);
|
||||
await turn;
|
||||
await client.archiveAgent(agent.id).catch(() => undefined);
|
||||
} finally {
|
||||
await writeFile(closeRelease, "release");
|
||||
await client.close();
|
||||
await daemon.close();
|
||||
await rm(cwd, { recursive: true, force: true });
|
||||
await rm(pluginDirectory, { recursive: true, force: true });
|
||||
}
|
||||
}, 60_000);
|
||||
|
||||
@@ -51,7 +51,7 @@ const handlers = new Map<string, RegisteredRpc>();
|
||||
const providers = new Map<string, ProviderRegistration>();
|
||||
const providerConnections = new Map<
|
||||
string,
|
||||
{ connection: ProviderConnection; unsubscribe: () => void; closing?: Promise<void> }
|
||||
{ connection: ProviderConnection; unsubscribe: () => void }
|
||||
>();
|
||||
const pendingProviderConnections = new Map<string, { tombstoned: boolean }>();
|
||||
let cleanup: (() => void | Promise<void>) | null = null;
|
||||
@@ -193,7 +193,6 @@ async function sendProviderInput(
|
||||
if (stopping) throw new Error("Plugin is stopping");
|
||||
const current = providerConnections.get(message.connectionId);
|
||||
if (!current) throw new Error(`Unknown provider connection: ${message.connectionId}`);
|
||||
if (current.closing) throw new Error("Provider connection is closing");
|
||||
await current.connection.send(message.input);
|
||||
send({
|
||||
type: "provider.accepted",
|
||||
@@ -202,25 +201,13 @@ async function sendProviderInput(
|
||||
});
|
||||
}
|
||||
|
||||
// The connection stays registered until its close has reported, so shutdown
|
||||
// waits for a close already in flight instead of disconnecting underneath it.
|
||||
async function closeProviderConnection(connectionId: string): Promise<void> {
|
||||
const current = providerConnections.get(connectionId);
|
||||
if (!current) return;
|
||||
if (current.closing) return current.closing;
|
||||
const closing = (async () => {
|
||||
current.unsubscribe();
|
||||
try {
|
||||
await current.connection.close();
|
||||
send({ type: "provider.closed", connectionId });
|
||||
} catch (error) {
|
||||
send({ type: "provider.closed", connectionId, error: describeError(error) });
|
||||
} finally {
|
||||
providerConnections.delete(connectionId);
|
||||
}
|
||||
})();
|
||||
current.closing = closing;
|
||||
return closing;
|
||||
providerConnections.delete(connectionId);
|
||||
current.unsubscribe();
|
||||
await current.connection.close();
|
||||
send({ type: "provider.closed", connectionId });
|
||||
}
|
||||
|
||||
function runtimeRequire(name: string): unknown {
|
||||
@@ -410,7 +397,13 @@ process.on("message", (rawMessage: unknown) => {
|
||||
return;
|
||||
}
|
||||
if (message.type === "provider.close") {
|
||||
void closeProviderConnection(message.connectionId);
|
||||
void closeProviderConnection(message.connectionId).catch((error) => {
|
||||
send({
|
||||
type: "provider.closed",
|
||||
connectionId: message.connectionId,
|
||||
error: describeError(error),
|
||||
});
|
||||
});
|
||||
return;
|
||||
}
|
||||
if (message.type === "paseo_frame" || message.type === "paseo_close") return;
|
||||
|
||||
@@ -34,26 +34,6 @@ async function readTextIfPresent(filePath: string): Promise<string | null> {
|
||||
}
|
||||
}
|
||||
|
||||
function pluginMessages(runtime: PluginRuntime, pluginId: string): string[] {
|
||||
return runtime.getLogs(pluginId).map((entry) => entry.message);
|
||||
}
|
||||
|
||||
function spawnObservedPluginProcess(onMessage: (message: unknown) => void) {
|
||||
const loaderUrl = new URL("../../terminal/terminal-ts-loader.mjs", import.meta.url).href;
|
||||
const setup = `import { register } from "node:module"; register(${JSON.stringify(loaderUrl)});`;
|
||||
const child = fork(new URL("./plugin-process.ts", import.meta.url), [], {
|
||||
execArgv: [
|
||||
"--experimental-strip-types",
|
||||
"--import",
|
||||
`data:text/javascript,${encodeURIComponent(setup)}`,
|
||||
],
|
||||
serialization: "advanced",
|
||||
stdio: ["ignore", "pipe", "pipe", "ipc"],
|
||||
});
|
||||
child.on("message", onMessage);
|
||||
return child;
|
||||
}
|
||||
|
||||
function createReloadChild(
|
||||
name: string,
|
||||
events: string[],
|
||||
@@ -1060,75 +1040,6 @@ export default function contribute(plugin: unknown) {
|
||||
await rm(cleanupFile, { force: true });
|
||||
});
|
||||
|
||||
it("rejects provider input while a connection is closing", async () => {
|
||||
const releaseDirectory = await mkdtemp(path.join(tmpdir(), "paseo-provider-close-"));
|
||||
const releaseFile = path.join(releaseDirectory, "release");
|
||||
const directory = await createPlugin(
|
||||
"closing-provider",
|
||||
`import { readFile, writeFile } from "node:fs/promises";
|
||||
import type { ProviderRegistration } from "@getpaseo/plugin/server/provider";
|
||||
const provider: ProviderRegistration = {
|
||||
id: "delayed",
|
||||
label: "Delayed",
|
||||
async connect() {
|
||||
return {
|
||||
version: 1,
|
||||
capabilities: [],
|
||||
async send() { console.log("provider accepted input"); },
|
||||
onEvent() { return () => undefined; },
|
||||
async close() {
|
||||
console.log("provider close started");
|
||||
while (true) {
|
||||
try { await readFile(${JSON.stringify(releaseFile)}); break; }
|
||||
catch { await new Promise((resolve) => setTimeout(resolve, 10)); }
|
||||
}
|
||||
console.log("provider close completed");
|
||||
},
|
||||
};
|
||||
},
|
||||
};
|
||||
export default function contribute(server: { registerProvider(provider: ProviderRegistration): void }) {
|
||||
server.registerProvider(provider);
|
||||
return () => console.log("plugin cleanup started");
|
||||
}`,
|
||||
);
|
||||
const closedReports: unknown[] = [];
|
||||
const observeMessage = (message: unknown) => {
|
||||
if ((message as { type?: string }).type === "provider.closed") closedReports.push(message);
|
||||
};
|
||||
const runtime = createTestRuntime({
|
||||
spawnChild: () => spawnObservedPluginProcess(observeMessage),
|
||||
});
|
||||
await runtime.startPlugin("closing-provider", directory);
|
||||
try {
|
||||
const connection = await runtime.connectProvider("closing-provider", "delayed", {
|
||||
versions: [1],
|
||||
capabilities: [],
|
||||
});
|
||||
const closing = connection.close();
|
||||
await expect
|
||||
.poll(() => pluginMessages(runtime, "closing-provider"))
|
||||
.toContain("provider close started");
|
||||
await expect(connection.send({ type: "catalog", requestId: "during-close" })).rejects.toThrow(
|
||||
"Provider connection is closing",
|
||||
);
|
||||
const stopping = runtime.stopPluginById("closing-provider");
|
||||
await expect
|
||||
.poll(() => pluginMessages(runtime, "closing-provider"))
|
||||
.toContain("plugin cleanup started");
|
||||
await writeFile(releaseFile, "release");
|
||||
await Promise.all([closing, stopping]);
|
||||
const messages = pluginMessages(runtime, "closing-provider");
|
||||
expect(messages).toContain("provider close completed");
|
||||
expect(messages).not.toContain("provider accepted input");
|
||||
expect(closedReports).toHaveLength(1);
|
||||
} finally {
|
||||
await writeFile(releaseFile, "release");
|
||||
await runtime.stopAll();
|
||||
await rm(releaseDirectory, { recursive: true, force: true });
|
||||
}
|
||||
});
|
||||
|
||||
it("closes a provider connection that resolves during plugin shutdown", async () => {
|
||||
const suffix = `${process.pid}-${Date.now()}`;
|
||||
const startedFile = path.join(tmpdir(), `paseo-provider-connect-started-${suffix}`);
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
import { mkdir, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdir, mkdtemp, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { afterEach, beforeEach, describe, expect, test, vi } from "vitest";
|
||||
@@ -803,7 +803,7 @@ describe("ScheduleService", () => {
|
||||
const runStarted = new Promise<void>((resolve) => {
|
||||
releaseRun = resolve;
|
||||
});
|
||||
const store = new ScheduleStore(join(tempDir, "schedules"), createTestLogger());
|
||||
const store = new ScheduleStore(join(tempDir, "schedules"));
|
||||
const legacy = await store.create({
|
||||
name: null,
|
||||
prompt: "finish/update race",
|
||||
@@ -1902,51 +1902,6 @@ describe("ScheduleService", () => {
|
||||
await service2.stop();
|
||||
});
|
||||
|
||||
test("starts with the valid schedules when the schedules directory holds files that are not schedules", async () => {
|
||||
const service1 = createScheduleService({
|
||||
paseoHome: tempDir,
|
||||
logger: createTestLogger(),
|
||||
agentManager: new AgentManager({ logger: createTestLogger() }),
|
||||
agentStorage,
|
||||
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||
now: () => now,
|
||||
runner: async () => ({ agentId: null, output: "ok" }),
|
||||
});
|
||||
const created = await service1.create({
|
||||
prompt: "Still scheduled",
|
||||
cadence: { type: "every", everyMs: 60_000 },
|
||||
target: {
|
||||
type: "new-agent",
|
||||
config: { provider: "claude", cwd: tempDir },
|
||||
},
|
||||
runOnCreate: false,
|
||||
});
|
||||
await service1.stop();
|
||||
|
||||
const schedulesDir = join(tempDir, "schedules");
|
||||
await writeFile(join(schedulesDir, "notes.json"), JSON.stringify({ hello: "world" }));
|
||||
const { lastRunAt: _omitted, ...withoutLastRunAt } = created;
|
||||
await writeFile(
|
||||
join(schedulesDir, "deadbeef.json"),
|
||||
JSON.stringify({ ...withoutLastRunAt, id: "deadbeef" }),
|
||||
);
|
||||
await writeFile(join(schedulesDir, "broken.json"), "{ not json");
|
||||
|
||||
const service2 = createScheduleService({
|
||||
paseoHome: tempDir,
|
||||
logger: createTestLogger(),
|
||||
agentManager: new AgentManager({ logger: createTestLogger() }),
|
||||
agentStorage,
|
||||
providerSnapshotManager: NO_UNATTENDED_SCHEDULE_POLICY,
|
||||
now: () => now,
|
||||
runner: async () => ({ agentId: null, output: "ok" }),
|
||||
});
|
||||
await service2.start();
|
||||
|
||||
expect((await service2.list()).map((schedule) => schedule.id)).toEqual([created.id]);
|
||||
await service2.stop();
|
||||
});
|
||||
|
||||
test("startup recovery archives an interrupted run workspace with an associated agent", async () => {
|
||||
const service1 = createScheduleService({
|
||||
paseoHome: tempDir,
|
||||
@@ -1971,7 +1926,7 @@ describe("ScheduleService", () => {
|
||||
const interruptedAt = now.toISOString();
|
||||
const associatedAgentId = "11111111-1111-4111-8111-111111111111";
|
||||
const workspaceId = "wks_interrupted_with_agent";
|
||||
const store = new ScheduleStore(join(tempDir, "schedules"), createTestLogger());
|
||||
const store = new ScheduleStore(join(tempDir, "schedules"));
|
||||
await store.update(created.id, (schedule) => ({
|
||||
...schedule,
|
||||
runs: [
|
||||
@@ -2039,7 +1994,7 @@ describe("ScheduleService", () => {
|
||||
|
||||
const interruptedAt = now.toISOString();
|
||||
const workspaceId = "wks_interrupted_without_agent";
|
||||
const store = new ScheduleStore(join(tempDir, "schedules"), createTestLogger());
|
||||
const store = new ScheduleStore(join(tempDir, "schedules"));
|
||||
await store.update(created.id, (schedule) => ({
|
||||
...schedule,
|
||||
runs: [
|
||||
|
||||
@@ -264,8 +264,8 @@ export class ScheduleService {
|
||||
private tickTimer: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
constructor(options: ScheduleServiceOptions) {
|
||||
this.store = new ScheduleStore(join(options.paseoHome, "schedules"));
|
||||
this.logger = options.logger.child({ module: "schedule-service" });
|
||||
this.store = new ScheduleStore(join(options.paseoHome, "schedules"), this.logger);
|
||||
this.agentManager = options.agentManager;
|
||||
this.agentStorage = options.agentStorage;
|
||||
this.createAgent = options.createAgent;
|
||||
|
||||
@@ -1,10 +1,7 @@
|
||||
import { chmod, mkdtemp, rm, writeFile } from "node:fs/promises";
|
||||
import { mkdtemp, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import { tmpdir } from "node:os";
|
||||
import { Writable } from "node:stream";
|
||||
import pino from "pino";
|
||||
import { afterEach, beforeEach, describe, expect, test } from "vitest";
|
||||
import { createTestLogger } from "../../test-utils/test-logger.js";
|
||||
import { ScheduleStore } from "./store.js";
|
||||
|
||||
describe("ScheduleStore", () => {
|
||||
@@ -13,7 +10,7 @@ describe("ScheduleStore", () => {
|
||||
|
||||
beforeEach(async () => {
|
||||
tempDir = await mkdtemp(join(tmpdir(), "schedule-store-test-"));
|
||||
store = new ScheduleStore(tempDir, createTestLogger());
|
||||
store = new ScheduleStore(tempDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
@@ -43,66 +40,13 @@ describe("ScheduleStore", () => {
|
||||
runs: [],
|
||||
});
|
||||
|
||||
const reloaded = new ScheduleStore(tempDir, createTestLogger());
|
||||
const reloaded = new ScheduleStore(tempDir);
|
||||
const listed = await reloaded.list();
|
||||
|
||||
expect(created.id).toHaveLength(8);
|
||||
expect(listed).toEqual([created]);
|
||||
});
|
||||
|
||||
test("reports an invalid schedule file once while it stays invalid, by name, and lists the rest", async () => {
|
||||
const created = await store.create({
|
||||
name: "Morning summary",
|
||||
prompt: "Summarize new commits",
|
||||
cadence: { type: "every", everyMs: 60_000 },
|
||||
target: { type: "new-agent", config: { provider: "claude", cwd: tempDir } },
|
||||
status: "active",
|
||||
createdAt: "2026-01-01T00:00:00.000Z",
|
||||
updatedAt: "2026-01-01T00:00:00.000Z",
|
||||
nextRunAt: "2026-01-01T00:01:00.000Z",
|
||||
lastRunAt: null,
|
||||
pausedAt: null,
|
||||
expiresAt: null,
|
||||
maxRuns: null,
|
||||
runs: [],
|
||||
});
|
||||
await writeFile(join(tempDir, "notes.json"), JSON.stringify({ hello: "world" }));
|
||||
const logLines: Array<{ msg: string; filePath?: string }> = [];
|
||||
const logger = pino(
|
||||
{ level: "error" },
|
||||
new Writable({
|
||||
write(chunk, _encoding, callback) {
|
||||
logLines.push(JSON.parse(chunk.toString("utf8")));
|
||||
callback();
|
||||
},
|
||||
}),
|
||||
);
|
||||
const reloaded = new ScheduleStore(tempDir, logger);
|
||||
|
||||
expect(await reloaded.list()).toEqual([created]);
|
||||
expect(await reloaded.list()).toEqual([created]);
|
||||
await rm(join(tempDir, "notes.json"));
|
||||
expect(await reloaded.list()).toEqual([created]);
|
||||
await writeFile(join(tempDir, "notes.json"), "{ not json");
|
||||
expect(await reloaded.list()).toEqual([created]);
|
||||
|
||||
const skipped = {
|
||||
msg: "Skipping invalid schedule file",
|
||||
filePath: join(tempDir, "notes.json"),
|
||||
};
|
||||
expect(logLines.map(({ msg, filePath }) => ({ msg, filePath }))).toEqual([skipped, skipped]);
|
||||
});
|
||||
|
||||
test.skipIf(process.platform === "win32" || process.getuid?.() === 0)(
|
||||
"fails the listing when a schedule file cannot be read",
|
||||
async () => {
|
||||
await writeFile(join(tempDir, "unreadable.json"), "{}");
|
||||
await chmod(join(tempDir, "unreadable.json"), 0o000);
|
||||
|
||||
await expect(store.list()).rejects.toMatchObject({ code: "EACCES" });
|
||||
},
|
||||
);
|
||||
|
||||
test("update round-trips an updated schedule to disk", async () => {
|
||||
const created = await store.create({
|
||||
name: "before",
|
||||
@@ -137,7 +81,7 @@ describe("ScheduleStore", () => {
|
||||
};
|
||||
await store.update(created.id, () => updated);
|
||||
|
||||
const reloaded = await new ScheduleStore(tempDir, createTestLogger()).get(created.id);
|
||||
const reloaded = await new ScheduleStore(tempDir).get(created.id);
|
||||
expect(reloaded).toEqual(updated);
|
||||
});
|
||||
|
||||
@@ -238,9 +182,7 @@ describe("ScheduleStore", () => {
|
||||
prompt: "after",
|
||||
runs: [{ id: "run-1" }],
|
||||
});
|
||||
await expect(
|
||||
new ScheduleStore(tempDir, createTestLogger()).get(created.id),
|
||||
).resolves.toMatchObject({
|
||||
await expect(new ScheduleStore(tempDir).get(created.id)).resolves.toMatchObject({
|
||||
prompt: "after",
|
||||
runs: [{ id: "run-1" }],
|
||||
});
|
||||
@@ -269,7 +211,7 @@ describe("ScheduleStore", () => {
|
||||
}
|
||||
}
|
||||
|
||||
const gatedStore = new GatedListScheduleStore(tempDir, createTestLogger());
|
||||
const gatedStore = new GatedListScheduleStore(tempDir);
|
||||
const target = {
|
||||
type: "new-agent" as const,
|
||||
config: { provider: "claude" as const, cwd: tempDir },
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { randomBytes } from "node:crypto";
|
||||
import { mkdir, readFile, readdir, rm } from "node:fs/promises";
|
||||
import { join } from "node:path";
|
||||
import type { Logger } from "pino";
|
||||
import {
|
||||
StoredScheduleSchema,
|
||||
type ScheduleTarget,
|
||||
@@ -88,27 +87,11 @@ function matchesNameAndTarget(
|
||||
);
|
||||
}
|
||||
|
||||
function parseStoredSchedule(
|
||||
content: string,
|
||||
): { success: true; data: StoredSchedule } | { success: false; error: unknown } {
|
||||
let json: unknown;
|
||||
try {
|
||||
json = JSON.parse(content);
|
||||
} catch (error) {
|
||||
return { success: false, error };
|
||||
}
|
||||
return StoredScheduleSchema.safeParse(json);
|
||||
}
|
||||
|
||||
export class ScheduleStore {
|
||||
private readonly scheduleMutations = new Map<string, Promise<unknown>>();
|
||||
private readonly identityMutations = new Map<string, Promise<unknown>>();
|
||||
private reportedInvalidFiles = new Set<string>();
|
||||
|
||||
constructor(
|
||||
private readonly dir: string,
|
||||
private readonly logger: Logger,
|
||||
) {}
|
||||
constructor(private readonly dir: string) {}
|
||||
|
||||
private filePath(id: string): string {
|
||||
return join(this.dir, `${id}.json`);
|
||||
@@ -118,32 +101,17 @@ export class ScheduleStore {
|
||||
await mkdir(this.dir, { recursive: true });
|
||||
}
|
||||
|
||||
// The service lists schedules on every tick, so a file that is not a valid schedule is
|
||||
// reported when it first appears rather than once per second.
|
||||
async list(): Promise<StoredSchedule[]> {
|
||||
await this.ensureDir();
|
||||
const entries = await readdir(this.dir, { withFileTypes: true });
|
||||
const files = await Promise.all(
|
||||
const schedules = await Promise.all(
|
||||
entries
|
||||
.filter((entry) => entry.isFile() && entry.name.endsWith(".json"))
|
||||
.map(async (entry) => {
|
||||
const filePath = join(this.dir, entry.name);
|
||||
return { filePath, parsed: parseStoredSchedule(await readFile(filePath, "utf-8")) };
|
||||
const content = await readFile(join(this.dir, entry.name), "utf-8");
|
||||
return StoredScheduleSchema.parse(JSON.parse(content));
|
||||
}),
|
||||
);
|
||||
const schedules: StoredSchedule[] = [];
|
||||
const invalidFiles = new Set<string>();
|
||||
for (const { filePath, parsed } of files) {
|
||||
if (parsed.success) {
|
||||
schedules.push(parsed.data);
|
||||
continue;
|
||||
}
|
||||
invalidFiles.add(filePath);
|
||||
if (!this.reportedInvalidFiles.has(filePath)) {
|
||||
this.logger.error({ err: parsed.error, filePath }, "Skipping invalid schedule file");
|
||||
}
|
||||
}
|
||||
this.reportedInvalidFiles = invalidFiles;
|
||||
return schedules.sort((left, right) => left.createdAt.localeCompare(right.createdAt));
|
||||
}
|
||||
|
||||
|
||||
@@ -85,43 +85,6 @@ function headBranch(dir: string): string {
|
||||
return execFileSync("git", ["rev-parse", "--abbrev-ref", "HEAD"], { cwd: dir }).toString().trim();
|
||||
}
|
||||
|
||||
// A clone whose local branch tracks origin/main. Upstream inheritance only shows up
|
||||
// against a real remote, so these fixtures cross the git boundary like the happy paths above.
|
||||
function initClonedRepo(): string {
|
||||
const root = realpathSync(mkdtempSync(join(tmpdir(), "git-mutation-clone-")));
|
||||
tempRepos.push(root);
|
||||
const remote = join(root, "remote.git");
|
||||
const seed = join(root, "seed");
|
||||
const work = join(root, "work");
|
||||
function run(cwd: string, ...args: string[]): void {
|
||||
execFileSync("git", args, { cwd, stdio: "pipe" });
|
||||
}
|
||||
execFileSync("git", ["init", "--bare", "-b", "main", remote], { cwd: root, stdio: "pipe" });
|
||||
execFileSync("git", ["clone", "--quiet", remote, seed], { cwd: root, stdio: "pipe" });
|
||||
run(seed, "config", "user.email", "test@example.com");
|
||||
run(seed, "config", "user.name", "Paseo Test");
|
||||
writeFileSync(join(seed, "README.md"), "hello\n");
|
||||
run(seed, "add", "-A");
|
||||
run(seed, "commit", "-m", "init");
|
||||
run(seed, "push", "--quiet", "-u", "origin", "main");
|
||||
execFileSync("git", ["clone", "--quiet", remote, work], { cwd: root, stdio: "pipe" });
|
||||
run(work, "config", "user.email", "test@example.com");
|
||||
run(work, "config", "user.name", "Paseo Test");
|
||||
return work;
|
||||
}
|
||||
|
||||
// Always exits 0 and prints an empty line when the branch has no upstream, so a git or
|
||||
// process failure surfaces as a throw instead of being read as "no upstream".
|
||||
function upstreamOf(dir: string, branch: string): string {
|
||||
return execFileSync(
|
||||
"git",
|
||||
["for-each-ref", "--format=%(upstream:short)", `refs/heads/${branch}`],
|
||||
{ cwd: dir, stdio: ["pipe", "pipe", "pipe"] },
|
||||
)
|
||||
.toString()
|
||||
.trim();
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
while (tempRepos.length > 0) {
|
||||
const dir = tempRepos.pop();
|
||||
@@ -226,40 +189,6 @@ describe("createBranchFromBase", () => {
|
||||
expect(invalidateCalls).toEqual([]);
|
||||
expect(snapshotCalls).toContainEqual({ cwd: dir, force: true, reason: "create-branch" });
|
||||
});
|
||||
|
||||
// https://github.com/getpaseo/paseo/issues/5213 — a branch that tracks origin/main sends the
|
||||
// next push to main: `git push` under push.default=tracking and Paseo's own push both read
|
||||
// branch.<name>.merge. A branch Paseo creates must carry no upstream, exactly as the worktree
|
||||
// path already guarantees with `git worktree add -b <branch> --no-track <base>`.
|
||||
test("leaves no upstream when the base is a remote-tracking ref (real repo)", async () => {
|
||||
const dir = initClonedRepo();
|
||||
const { service } = buildService({
|
||||
resolution: { kind: "remote-only", name: "main", remoteRef: "origin/main" },
|
||||
});
|
||||
await service.createBranchFromBase({
|
||||
cwd: dir,
|
||||
baseBranch: "origin/main",
|
||||
newBranchName: "feature-from-remote",
|
||||
});
|
||||
expect(headBranch(dir)).toBe("feature-from-remote");
|
||||
expect(upstreamOf(dir, "feature-from-remote")).toBe("");
|
||||
});
|
||||
|
||||
test("leaves no upstream when git is configured to inherit tracking (real repo)", async () => {
|
||||
const dir = initClonedRepo();
|
||||
execFileSync("git", ["config", "branch.autoSetupMerge", "inherit"], {
|
||||
cwd: dir,
|
||||
stdio: "pipe",
|
||||
});
|
||||
const { service } = buildService({ resolution: { kind: "local", name: "main" } });
|
||||
await service.createBranchFromBase({
|
||||
cwd: dir,
|
||||
baseBranch: "main",
|
||||
newBranchName: "feature-inherited",
|
||||
});
|
||||
expect(headBranch(dir)).toBe("feature-inherited");
|
||||
expect(upstreamOf(dir, "feature-inherited")).toBe("");
|
||||
});
|
||||
});
|
||||
|
||||
describe("notifyGitMutation", () => {
|
||||
|
||||
@@ -118,12 +118,7 @@ export function createGitMutationService(deps: {
|
||||
}
|
||||
|
||||
await ensureCleanWorkingTree(cwd);
|
||||
// --no-track: a branch we create is not a continuation of its base, so it gets no
|
||||
// upstream. Without it git hands the new branch `origin/<base>` whenever the base is a
|
||||
// remote-tracking ref or branch.autoSetupMerge asks for inheritance, and every later
|
||||
// push then reads that upstream and lands the work on the default branch. The worktree
|
||||
// path makes the same promise with `git worktree add -b <branch> --no-track <base>`.
|
||||
await runGitCommand(["checkout", "-b", newBranchName, "--no-track", baseBranch], {
|
||||
await runGitCommand(["checkout", "-b", newBranchName, baseBranch], {
|
||||
cwd,
|
||||
timeout: 120_000,
|
||||
});
|
||||
|
||||
@@ -996,20 +996,17 @@ describe("WorkspaceGitService checkout observation", () => {
|
||||
|
||||
test("origin/main refreshes a main checkout without configured upstream", async () => {
|
||||
const watcher = createWatcherHarness();
|
||||
const releaseInitialFacts = createDeferred<void>();
|
||||
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => {
|
||||
await releaseInitialFacts.promise;
|
||||
return {
|
||||
...createCheckoutFacts(cwd),
|
||||
currentBranch: "main",
|
||||
remoteUrl: REMOTE_URL,
|
||||
resolvedBaseRef: "main",
|
||||
comparisonBaseRef: null,
|
||||
branchRemoteName: null,
|
||||
branchMergeRef: null,
|
||||
upstreamStatus: null,
|
||||
};
|
||||
});
|
||||
const getCheckoutSnapshotFacts = vi.fn(async (cwd: string) => ({
|
||||
...createCheckoutFacts(cwd),
|
||||
currentBranch: "main",
|
||||
remoteUrl: REMOTE_URL,
|
||||
resolvedBaseRef: "main",
|
||||
comparisonBaseRef: null,
|
||||
branchRemoteName: null,
|
||||
branchMergeRef: null,
|
||||
upstreamStatus: null,
|
||||
}));
|
||||
// A remote URL starts the background fetch; keep it off the real git binary.
|
||||
const runGitFetch = vi.fn(async () => ({
|
||||
changes: [],
|
||||
nonRemoteRefsChanged: false,
|
||||
@@ -1021,19 +1018,11 @@ describe("WorkspaceGitService checkout observation", () => {
|
||||
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
releaseInitialFacts.resolve();
|
||||
await vi.waitFor(() => {
|
||||
expect(getWatcherRecordsForDirectory(watcher, GIT_DIR)).toHaveLength(1);
|
||||
expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
|
||||
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
|
||||
expect(service.getMetrics().fetchInFlightCount).toBe(0);
|
||||
expect(runGitFetch).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(1);
|
||||
const repoWatcher = getWatcherRecordsForDirectory(watcher, GIT_DIR)[0]!;
|
||||
repoWatcher.callback(null, [
|
||||
{ path: path.join(GIT_DIR, "refs", "remotes", "origin", "main"), type: "update" },
|
||||
]);
|
||||
watcher.records
|
||||
.find((record) => record.directory === GIT_DIR)
|
||||
?.callback(null, [
|
||||
{ path: path.join(GIT_DIR, "refs", "remotes", "origin", "main"), type: "update" },
|
||||
]);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(getCheckoutSnapshotFacts).toHaveBeenCalledTimes(2);
|
||||
@@ -1556,87 +1545,6 @@ describe("WorkspaceGitService checkout observation", () => {
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("degraded polling backs off while the snapshot is unchanged and resets on a change", async () => {
|
||||
// A repository large enough to defeat the recursive watcher makes every degraded refresh
|
||||
// expensive, so a fixed cadence keeps the daemon shelling out Git on an untouched workspace.
|
||||
const watcher = createWatcherHarness({ failDirectories: new Set([REPO_CWD]) });
|
||||
let isDirty = false;
|
||||
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd, { isDirty }));
|
||||
const service = createService(watcher, { getCheckoutStatus });
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(service.getMetrics().workingTreeWatchTargetCount).toBe(1);
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
// Quiet ticks at 5s, 10s, 20s, 40s, then the 60s ceiling.
|
||||
for (const [elapsedMs, expectedCalls] of [
|
||||
[5_000, 2],
|
||||
[10_000, 3],
|
||||
[20_000, 4],
|
||||
[40_000, 5],
|
||||
[60_000, 6],
|
||||
[60_000, 7],
|
||||
] as const) {
|
||||
await vi.advanceTimersByTimeAsync(elapsedMs);
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(expectedCalls);
|
||||
}
|
||||
|
||||
// A fixed 5s cadence would have run 39 polls over the same 195s.
|
||||
expect(getCheckoutStatus.mock.calls.length).toBeLessThan(10);
|
||||
|
||||
// A real change snaps the loop back to the base interval.
|
||||
isDirty = true;
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(8);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(getCheckoutStatus).toHaveBeenCalledTimes(9);
|
||||
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("degraded repository-metadata polling backs off and resets on a change", async () => {
|
||||
// Only the Git-directory watcher fails, so the metadata fallback is the one poll loop running.
|
||||
const watcher = createWatcherHarness({ failDirectories: new Set([GIT_DIR]) });
|
||||
let isDirty = false;
|
||||
const getCheckoutStatus = vi.fn(async (cwd: string) => createCheckoutStatus(cwd, { isDirty }));
|
||||
const service = createService(watcher, { getCheckoutStatus });
|
||||
const subscription = service.registerWorkspace({ cwd: REPO_CWD }, vi.fn());
|
||||
|
||||
await vi.waitFor(() => {
|
||||
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
|
||||
expect(service.getMetrics().workspaceRefreshInFlightCount).toBe(0);
|
||||
});
|
||||
const callsBeforePolling = getCheckoutStatus.mock.calls.length;
|
||||
function pollCount(): number {
|
||||
return getCheckoutStatus.mock.calls.length - callsBeforePolling;
|
||||
}
|
||||
|
||||
// Quiet ticks at 5s, 10s, 20s, 40s, then the 60s ceiling.
|
||||
for (const [elapsedMs, expectedPolls] of [
|
||||
[5_000, 1],
|
||||
[10_000, 2],
|
||||
[20_000, 3],
|
||||
[40_000, 4],
|
||||
[60_000, 5],
|
||||
[60_000, 6],
|
||||
] as const) {
|
||||
await vi.advanceTimersByTimeAsync(elapsedMs);
|
||||
expect(pollCount()).toBe(expectedPolls);
|
||||
}
|
||||
|
||||
isDirty = true;
|
||||
await vi.advanceTimersByTimeAsync(60_000);
|
||||
expect(pollCount()).toBe(7);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
expect(pollCount()).toBe(8);
|
||||
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
});
|
||||
|
||||
test("non-Git fallback promotes an externally initialized checkout", async () => {
|
||||
const watcher = createWatcherHarness();
|
||||
let isGit = false;
|
||||
@@ -1850,17 +1758,17 @@ describe("WorkspaceGitService checkout observation", () => {
|
||||
expect(service.getMetrics().workspaceObservationSetupInFlightCount).toBe(0);
|
||||
});
|
||||
const statusCallsAfterSetup = getCheckoutStatus.mock.calls.length;
|
||||
await vi.advanceTimersByTimeAsync(29_000);
|
||||
await vi.advanceTimersByTimeAsync(5_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(getCheckoutStatus.mock.calls.length).toBeGreaterThan(statusCallsAfterSetup);
|
||||
});
|
||||
await vi.advanceTimersByTimeAsync(24_000);
|
||||
expect(getWatcherSubscribeCallCount(watcher, REPO_CWD)).toBe(1);
|
||||
await vi.advanceTimersByTimeAsync(1_000);
|
||||
await vi.waitFor(() => {
|
||||
expect(getWatcherSubscribeCallCount(watcher, REPO_CWD)).toBe(2);
|
||||
});
|
||||
expect(erroredUnsubscribe).toHaveBeenCalledTimes(1);
|
||||
// Recovery schedules a debounced refresh. Advance the fake clock past the debounce
|
||||
// explicitly; vi.waitFor alone runs out of real time before it on a slow runner.
|
||||
await vi.advanceTimersByTimeAsync(2_000);
|
||||
expect(getCheckoutStatus.mock.calls.length).toBeGreaterThan(statusCallsAfterSetup);
|
||||
|
||||
subscription.unsubscribe();
|
||||
service.dispose();
|
||||
|
||||
@@ -86,13 +86,6 @@ const FORGE_PR_STATUS_POLL_FAST_INTERVAL_MS = 20_000;
|
||||
const FORGE_PR_STATUS_POLL_SLOW_INTERVAL_MS = 120_000;
|
||||
const FORGE_PR_STATUS_POLL_ERROR_BACKOFF_CAP_MS = 300_000;
|
||||
const DEGRADED_GIT_POLL_INTERVAL_MS = 5_000;
|
||||
// Degraded polling shells out a full Git refresh per tick, and the repositories that defeat the
|
||||
// recursive watcher are the ones where that refresh is expensive. At a fixed cadence the daemon
|
||||
// therefore burns a core forever on a workspace nobody has touched. Double the gap after every
|
||||
// tick that produced an identical snapshot, and drop back to the base interval as soon as one
|
||||
// does not. Explicit reads still force a refresh, so this only bounds background discovery of
|
||||
// changes made outside Paseo.
|
||||
const DEGRADED_GIT_POLL_MAX_INTERVAL_MS = 60_000;
|
||||
// A dependency install creates directories for minutes. Collapse the burst into
|
||||
// a couple of `git ls-files` runs rather than one per event.
|
||||
const WORKING_TREE_IGNORE_REFRESH_QUIET_MS = 300;
|
||||
@@ -449,7 +442,6 @@ interface RepoGitTarget {
|
||||
subscription: FileObserverSubscription | null;
|
||||
fallbackPolling: boolean;
|
||||
fallbackPollTimer: NodeJS.Timeout | null;
|
||||
fallbackPollQuietTickCount: number;
|
||||
recovery: WatchRecoveryState;
|
||||
intervalId: NodeJS.Timeout | null;
|
||||
fetchInFlight: boolean;
|
||||
@@ -509,7 +501,6 @@ interface WorkingTreeWatchTarget {
|
||||
workspaceKeys: Set<string>;
|
||||
fallbackPolling: boolean;
|
||||
fallbackPollTimer: NodeJS.Timeout | null;
|
||||
fallbackPollQuietTickCount: number;
|
||||
recovery: WatchRecoveryState;
|
||||
listeners: Set<() => void>;
|
||||
closed: boolean;
|
||||
@@ -1361,7 +1352,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
workspaceKeys: new Set(),
|
||||
fallbackPolling: false,
|
||||
fallbackPollTimer: null,
|
||||
fallbackPollQuietTickCount: 0,
|
||||
recovery: { attemptCount: 0, timer: null, establishedAt: null },
|
||||
listeners: new Set(),
|
||||
closed: false,
|
||||
@@ -1568,25 +1558,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
}
|
||||
}
|
||||
|
||||
private degradedPollDelayMs(quietTickCount: number): number {
|
||||
return Math.min(
|
||||
DEGRADED_GIT_POLL_MAX_INTERVAL_MS,
|
||||
DEGRADED_GIT_POLL_INTERVAL_MS * 2 ** quietTickCount,
|
||||
);
|
||||
}
|
||||
|
||||
// A refresh that throws is swallowed by refreshWorkspaceTarget and leaves the fingerprint
|
||||
// alone, so it reads as a quiet tick and widens the gap. That is the intended outcome: a
|
||||
// repository whose Git commands keep failing is the last one to retry every 5 seconds.
|
||||
private async refreshDegradedPollTarget(
|
||||
workspaceTarget: WorkspaceGitTarget,
|
||||
request: WorkspaceGitRefreshRequest,
|
||||
): Promise<boolean> {
|
||||
const fingerprintBefore = workspaceTarget.latestFingerprint;
|
||||
await this.refreshWorkspaceTarget(workspaceTarget, request);
|
||||
return workspaceTarget.latestFingerprint !== fingerprintBefore;
|
||||
}
|
||||
|
||||
private startWorkingTreeWatchFallback(
|
||||
target: WorkingTreeWatchTarget,
|
||||
reason: WorkingTreeWatchFallbackReason,
|
||||
@@ -1595,20 +1566,19 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
return;
|
||||
}
|
||||
target.fallbackPolling = true;
|
||||
target.fallbackPollQuietTickCount = 0;
|
||||
const { cwd } = target;
|
||||
const poll = async () => {
|
||||
target.fallbackPollTimer = null;
|
||||
if (target.closed || this.workingTreeWatchTargets.get(target.cwd) !== target) {
|
||||
return;
|
||||
}
|
||||
const changes = await Promise.all(
|
||||
await Promise.all(
|
||||
Array.from(target.workspaceKeys, async (workspaceKey) => {
|
||||
const workspaceTarget = this.workspaceTargets.get(workspaceKey);
|
||||
if (!workspaceTarget) {
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
const changed = await this.refreshDegradedPollTarget(workspaceTarget, {
|
||||
await this.refreshWorkspaceTarget(workspaceTarget, {
|
||||
force: false,
|
||||
refreshStructure: target.repoRoot === null,
|
||||
refreshWorktree: true,
|
||||
@@ -1622,18 +1592,11 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
workspaceTarget.observationSetupComplete = false;
|
||||
this.scheduleWorkspaceObservationSetup(workspaceTarget);
|
||||
}
|
||||
return changed;
|
||||
}),
|
||||
);
|
||||
this.notifyWorkingTreeConsumers(target);
|
||||
if (!target.closed && (target.subscription === null || target.repoRoot === null)) {
|
||||
target.fallbackPollQuietTickCount = changes.some(Boolean)
|
||||
? 0
|
||||
: target.fallbackPollQuietTickCount + 1;
|
||||
target.fallbackPollTimer = setTimeout(
|
||||
poll,
|
||||
this.degradedPollDelayMs(target.fallbackPollQuietTickCount),
|
||||
);
|
||||
target.fallbackPollTimer = setTimeout(poll, DEGRADED_GIT_POLL_INTERVAL_MS);
|
||||
} else {
|
||||
target.fallbackPolling = false;
|
||||
}
|
||||
@@ -1643,7 +1606,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
{
|
||||
cwd,
|
||||
intervalMs: DEGRADED_GIT_POLL_INTERVAL_MS,
|
||||
maxIntervalMs: DEGRADED_GIT_POLL_MAX_INTERVAL_MS,
|
||||
reason,
|
||||
},
|
||||
"Working tree watcher unavailable; using bounded polling fallback",
|
||||
@@ -2145,7 +2107,6 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
subscription: null,
|
||||
fallbackPolling: false,
|
||||
fallbackPollTimer: null,
|
||||
fallbackPollQuietTickCount: 0,
|
||||
recovery: { attemptCount: 0, timer: null, establishedAt: null },
|
||||
intervalId: null,
|
||||
fetchInFlight: false,
|
||||
@@ -2646,18 +2607,17 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
return;
|
||||
}
|
||||
target.fallbackPolling = true;
|
||||
target.fallbackPollQuietTickCount = 0;
|
||||
const poll = async () => {
|
||||
target.fallbackPollTimer = null;
|
||||
if (target.closed || this.repoTargets.get(target.repoGitRoot) !== target) {
|
||||
return;
|
||||
}
|
||||
const workingTreeTargets = new Set<WorkingTreeWatchTarget>();
|
||||
const changes = await Promise.all(
|
||||
await Promise.all(
|
||||
Array.from(target.workspaceKeys, async (workspaceKey) => {
|
||||
const workspaceTarget = this.workspaceTargets.get(workspaceKey);
|
||||
if (!workspaceTarget) {
|
||||
return false;
|
||||
return;
|
||||
}
|
||||
this.invalidateCheckoutDiffCache(workspaceTarget.cwd, "base");
|
||||
if (workspaceTarget.latestFacts?.isGit) {
|
||||
@@ -2667,7 +2627,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
if (workingTreeTarget) {
|
||||
workingTreeTargets.add(workingTreeTarget);
|
||||
}
|
||||
return await this.refreshDegradedPollTarget(workspaceTarget, {
|
||||
await this.refreshWorkspaceTarget(workspaceTarget, {
|
||||
force: false,
|
||||
refreshStructure: true,
|
||||
refreshWorktree: true,
|
||||
@@ -2684,13 +2644,7 @@ export class WorkspaceGitServiceImpl implements WorkspaceGitService {
|
||||
this.notifyWorkingTreeConsumers(workingTreeTarget);
|
||||
}
|
||||
if (!target.closed && target.subscription === null) {
|
||||
target.fallbackPollQuietTickCount = changes.some(Boolean)
|
||||
? 0
|
||||
: target.fallbackPollQuietTickCount + 1;
|
||||
target.fallbackPollTimer = setTimeout(
|
||||
poll,
|
||||
this.degradedPollDelayMs(target.fallbackPollQuietTickCount),
|
||||
);
|
||||
target.fallbackPollTimer = setTimeout(poll, DEGRADED_GIT_POLL_INTERVAL_MS);
|
||||
} else {
|
||||
target.fallbackPolling = false;
|
||||
}
|
||||
|
||||
@@ -12,7 +12,6 @@ import { tmpdir } from "node:os";
|
||||
import path from "node:path";
|
||||
import { afterEach, beforeEach, describe, expect, it } from "vitest";
|
||||
import { isPlatform } from "../test-utils/platform.js";
|
||||
import { startPathContainmentMetrics, stopPathContainmentMetrics } from "./path.js";
|
||||
import { startGitCommandMetrics, stopGitCommandMetrics } from "./run-git-command.js";
|
||||
import {
|
||||
searchDirectoryEntries,
|
||||
@@ -170,26 +169,6 @@ describe("searchDirectoryEntries", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it.skipIf(isWindows)(
|
||||
"prunes a symlink whose target is inside a Git-ignored directory",
|
||||
async () => {
|
||||
initGitRepo(searchRoot, "generated/\n");
|
||||
mkdirSync(path.join(searchRoot, "generated", "output"), { recursive: true });
|
||||
symlinkSync(path.join(searchRoot, "generated", "output"), path.join(searchRoot, "linked"));
|
||||
|
||||
await expect(
|
||||
searchDirectoryEntries({
|
||||
root: searchRoot,
|
||||
query: "linked",
|
||||
pathFormat: "relative",
|
||||
includeFiles: false,
|
||||
includeDirectories: true,
|
||||
respectGitIgnore: true,
|
||||
}),
|
||||
).resolves.toEqual([]);
|
||||
},
|
||||
);
|
||||
|
||||
it("configures raw blank queries independently from explicit root aliases", async () => {
|
||||
const rootEntries = [
|
||||
{ path: "projects", kind: "directory" as const },
|
||||
@@ -974,62 +953,3 @@ describe("relative typed-entry configuration", () => {
|
||||
expect(results).toEqual([{ path: "blankpage/editor", kind: "directory" }]);
|
||||
});
|
||||
});
|
||||
|
||||
// Reading a tree is cheap; re-deriving each entry's path state is not. A scan that asks whether
|
||||
// every ancestor of every entry is inside the root, or is Git-ignored, costs multiples of the
|
||||
// read and is what made large home directories exceed the client request timeout.
|
||||
describe("home-tree scan cost", () => {
|
||||
const DEPTH = 8;
|
||||
const CHAINS = 40;
|
||||
let scanRoot: string;
|
||||
|
||||
beforeEach(() => {
|
||||
scanRoot = realpathSync.native(mkdtempSync(path.join(tmpdir(), "directory-scan-cost-")));
|
||||
for (let chain = 0; chain < CHAINS; chain += 1) {
|
||||
const segments: string[] = [];
|
||||
for (let level = 0; level < DEPTH; level += 1) segments.push(`c${chain}l${level}`);
|
||||
mkdirSync(path.join(scanRoot, ...segments), { recursive: true });
|
||||
}
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
rmSync(scanRoot, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
// Nothing matches, so the scan walks the whole tree instead of stopping at a confident result.
|
||||
function scanWholeTree() {
|
||||
return searchAbsoluteDirectoryPaths({
|
||||
homeDir: scanRoot,
|
||||
query: "nomatch",
|
||||
maxDepth: DEPTH + 4,
|
||||
});
|
||||
}
|
||||
|
||||
async function countContainmentChecks(run: () => Promise<unknown>): Promise<number> {
|
||||
startPathContainmentMetrics();
|
||||
await run();
|
||||
return stopPathContainmentMetrics();
|
||||
}
|
||||
|
||||
it("derives no containment for a tree that cannot leave the root", async () => {
|
||||
const checks = await countContainmentChecks(scanWholeTree);
|
||||
|
||||
expect({ checks, scanned: CHAINS * DEPTH }).toEqual({ checks: 0, scanned: 320 });
|
||||
});
|
||||
|
||||
it.skipIf(isWindows)(
|
||||
"derives containment for symlinked entries, which can leave the root",
|
||||
async () => {
|
||||
const outside = realpathSync.native(mkdtempSync(path.join(tmpdir(), "directory-scan-away-")));
|
||||
symlinkSync(outside, path.join(scanRoot, "escape"), "dir");
|
||||
|
||||
const checks = await countContainmentChecks(scanWholeTree);
|
||||
|
||||
expect({ checked: checks > 0, escaped: (await scanWholeTree()).includes(outside) }).toEqual({
|
||||
checked: true,
|
||||
escaped: false,
|
||||
});
|
||||
rmSync(outside, { recursive: true, force: true });
|
||||
},
|
||||
);
|
||||
});
|
||||
|
||||
@@ -46,7 +46,6 @@ interface ChildEntry {
|
||||
name: string;
|
||||
resolvedPath: string;
|
||||
kind: DirectorySuggestionKind;
|
||||
viaSymlink: boolean;
|
||||
}
|
||||
|
||||
interface RawChildEntry {
|
||||
@@ -250,7 +249,7 @@ async function searchChildren(input: SearchInput): Promise<RankedEntry[]> {
|
||||
async function searchTree(input: SearchInput): Promise<RankedEntry[]> {
|
||||
if (!(input.maxEntriesScanned > 0)) return [];
|
||||
const roots = (await readChildren(input.root)).filter((entry) =>
|
||||
staysInsideRoot(entry, input.root),
|
||||
isPathInsideRoot(input.root, entry.resolvedPath),
|
||||
);
|
||||
const visited = new Set<string>([input.root]);
|
||||
const branches = roots.flatMap((entry) =>
|
||||
@@ -295,7 +294,7 @@ async function* walkBranch(
|
||||
return;
|
||||
visited.add(entry.resolvedPath);
|
||||
const children = (await readChildren(entry.resolvedPath)).filter((child) =>
|
||||
staysInsideRoot(child, input.root),
|
||||
isPathInsideRoot(input.root, child.resolvedPath),
|
||||
);
|
||||
const branches = children.flatMap((child) =>
|
||||
shouldDiscover(child, input)
|
||||
@@ -330,14 +329,8 @@ async function* roundRobin<T>(branches: Array<AsyncGenerator<T>>): AsyncGenerato
|
||||
}
|
||||
}
|
||||
|
||||
// Only a symlink can resolve outside the tree being walked. Every other child is its parent's
|
||||
// path plus a name, and the parent was already proved inside the root.
|
||||
function staysInsideRoot(entry: ChildEntry, root: string): boolean {
|
||||
return !entry.viaSymlink || isPathInsideRoot(root, entry.resolvedPath);
|
||||
}
|
||||
|
||||
function shouldDiscover(entry: ChildEntry, input: SearchInput): boolean {
|
||||
if (isNewlyGitIgnored(entry, input)) return false;
|
||||
if (isGitIgnoredPath(entry.resolvedPath, input)) return false;
|
||||
if (entry.kind === "file") {
|
||||
return input.includeFiles && !entry.name.startsWith(".");
|
||||
}
|
||||
@@ -346,14 +339,6 @@ function shouldDiscover(entry: ChildEntry, input: SearchInput): boolean {
|
||||
return input.hiddenDirectoryNames.has(entry.name);
|
||||
}
|
||||
|
||||
// Traversal only descends through entries that already passed this check, so every ancestor of
|
||||
// a named child is known discoverable and only the entry itself can be newly ignored. A symlink
|
||||
// resolves to a path with different ancestors, so it still needs the full walk.
|
||||
function isNewlyGitIgnored(entry: ChildEntry, input: SearchInput): boolean {
|
||||
if (entry.viaSymlink) return isGitIgnoredPath(entry.resolvedPath, input);
|
||||
return input.gitIgnoredPaths.has(entry.resolvedPath);
|
||||
}
|
||||
|
||||
function isGitIgnoredPath(absolutePath: string, input: SearchInput): boolean {
|
||||
let candidate = absolutePath;
|
||||
while (candidate !== input.root && isPathInsideRoot(input.root, candidate)) {
|
||||
@@ -727,14 +712,14 @@ function toRawChildEntry(dirent: Dirent): RawChildEntry | null {
|
||||
async function resolveChild(directory: string, entry: RawChildEntry): Promise<ChildEntry | null> {
|
||||
const visiblePath = path.join(directory, entry.name);
|
||||
if (entry.kind !== "symlink") {
|
||||
return { name: entry.name, resolvedPath: visiblePath, kind: entry.kind, viaSymlink: false };
|
||||
return { name: entry.name, resolvedPath: visiblePath, kind: entry.kind };
|
||||
}
|
||||
|
||||
const resolvedPath = await realpath(visiblePath).catch(() => null);
|
||||
if (!resolvedPath) return null;
|
||||
const info = await stat(resolvedPath).catch(() => null);
|
||||
const kind = getEntryKind(info);
|
||||
return kind ? { name: entry.name, resolvedPath, kind, viaSymlink: true } : null;
|
||||
return kind ? { name: entry.name, resolvedPath, kind } : null;
|
||||
}
|
||||
|
||||
function getEntryKind(info: Stats | null): DirectorySuggestionKind | null {
|
||||
@@ -743,9 +728,10 @@ function getEntryKind(info: Stats | null): DirectorySuggestionKind | null {
|
||||
return null;
|
||||
}
|
||||
|
||||
// Reads already reject stale entries by expiry and by directory metadata, so this only has to
|
||||
// bound the map. Sweeping it for expired keys would cost a full pass on every cache miss.
|
||||
function pruneCache(): void {
|
||||
if (directoryListCache.size <= DIRECTORY_LIST_CACHE_MAX_ENTRIES) return;
|
||||
for (const [key, entry] of directoryListCache)
|
||||
if (entry.expiresAt <= Date.now()) directoryListCache.delete(key);
|
||||
while (directoryListCache.size > DIRECTORY_LIST_CACHE_MAX_ENTRIES) {
|
||||
const key = directoryListCache.keys().next().value;
|
||||
if (!key) return;
|
||||
|
||||
@@ -58,22 +58,7 @@ export function createRealpathAwarePathMatcher(target: string): (candidate: stri
|
||||
};
|
||||
}
|
||||
|
||||
let pathContainmentChecks: number | null = null;
|
||||
|
||||
// Containment is re-derived from scratch on every call. A tree walk that asks it per entry, or
|
||||
// per ancestor of every entry, costs more than reading the tree does, so tests count the calls.
|
||||
export function startPathContainmentMetrics(): void {
|
||||
pathContainmentChecks = 0;
|
||||
}
|
||||
|
||||
export function stopPathContainmentMetrics(): number {
|
||||
const checks = pathContainmentChecks ?? 0;
|
||||
pathContainmentChecks = null;
|
||||
return checks;
|
||||
}
|
||||
|
||||
export function isPathInsideRoot(root: string, candidate: string): boolean {
|
||||
if (pathContainmentChecks !== null) pathContainmentChecks += 1;
|
||||
return getRelativePathInsideRoot(root, candidate) !== null;
|
||||
}
|
||||
|
||||
|
||||
Binary file not shown.
|
Before Width: | Height: | Size: 66 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 66 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 74 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 52 KiB |
Reference in New Issue
Block a user