fix(interactive-subagents): avoid Orca polling fan-out
This commit is contained in:
@@ -129,7 +129,7 @@ The currently published and deployed extension set is:
|
|||||||
- `@jbcode/pi-dictate@0.1.0`;
|
- `@jbcode/pi-dictate@0.1.0`;
|
||||||
- `@jbcode/pi-effort-controls@0.1.0`;
|
- `@jbcode/pi-effort-controls@0.1.0`;
|
||||||
- `@jbcode/pi-prompt-snippets@0.1.0`;
|
- `@jbcode/pi-prompt-snippets@0.1.0`;
|
||||||
- `@jbcode/pi-interactive-subagents@3.7.2`;
|
- `@jbcode/pi-interactive-subagents@3.7.3`;
|
||||||
- `@jbcode/pi-todo-herdr@0.2.5`.
|
- `@jbcode/pi-todo-herdr@0.2.5`.
|
||||||
|
|
||||||
Configure the scoped Gitea npm registry and install only the packages you want. Keep the token in the user environment or user-level npm configuration; never commit it:
|
Configure the scoped Gitea npm registry and install only the packages you want. Keep the token in the user environment or user-level npm configuration; never commit it:
|
||||||
@@ -143,7 +143,7 @@ Configure the scoped Gitea npm registry and install only the packages you want.
|
|||||||
pi install npm:@jbcode/pi-dictate@0.1.0
|
pi install npm:@jbcode/pi-dictate@0.1.0
|
||||||
pi install npm:@jbcode/pi-effort-controls@0.1.0
|
pi install npm:@jbcode/pi-effort-controls@0.1.0
|
||||||
pi install npm:@jbcode/pi-prompt-snippets@0.1.0
|
pi install npm:@jbcode/pi-prompt-snippets@0.1.0
|
||||||
pi install npm:@jbcode/pi-interactive-subagents@3.7.2
|
pi install npm:@jbcode/pi-interactive-subagents@3.7.3
|
||||||
pi install npm:@jbcode/pi-todo-herdr@0.2.5
|
pi install npm:@jbcode/pi-todo-herdr@0.2.5
|
||||||
pi list
|
pi list
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -56,7 +56,7 @@
|
|||||||
},
|
},
|
||||||
"extensions/interactive-subagents": {
|
"extensions/interactive-subagents": {
|
||||||
"name": "@jbcode/pi-interactive-subagents",
|
"name": "@jbcode/pi-interactive-subagents",
|
||||||
"version": "3.7.2",
|
"version": "3.7.3",
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@earendil-works/pi-coding-agent": "catalog:",
|
"@earendil-works/pi-coding-agent": "catalog:",
|
||||||
"@earendil-works/pi-tui": "catalog:",
|
"@earendil-works/pi-tui": "catalog:",
|
||||||
|
|||||||
@@ -52,7 +52,7 @@ After configuring the same scope and token in the npm configuration visible to P
|
|||||||
pi install npm:@jbcode/pi-dictate@0.1.0
|
pi install npm:@jbcode/pi-dictate@0.1.0
|
||||||
pi install npm:@jbcode/pi-effort-controls@0.1.0
|
pi install npm:@jbcode/pi-effort-controls@0.1.0
|
||||||
pi install npm:@jbcode/pi-prompt-snippets@0.1.0
|
pi install npm:@jbcode/pi-prompt-snippets@0.1.0
|
||||||
pi install npm:@jbcode/pi-interactive-subagents@3.7.2
|
pi install npm:@jbcode/pi-interactive-subagents@3.7.3
|
||||||
pi install npm:@jbcode/pi-todo-herdr@0.2.5
|
pi install npm:@jbcode/pi-todo-herdr@0.2.5
|
||||||
```
|
```
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
{
|
{
|
||||||
"name": "@jbcode/pi-interactive-subagents",
|
"name": "@jbcode/pi-interactive-subagents",
|
||||||
"version": "3.7.2",
|
"version": "3.7.3",
|
||||||
"description": "Interactive subagents for Pi — spawn, orchestrate, and manage sub-agent sessions in Orca, Herdr or tmux.",
|
"description": "Interactive subagents for Pi — spawn, orchestrate, and manage sub-agent sessions in Orca, Herdr or tmux.",
|
||||||
"type": "module",
|
"type": "module",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
|
|||||||
@@ -14,6 +14,7 @@ const execFileAsync = promisify(execFile);
|
|||||||
|
|
||||||
/** GNOME screen reader path — never a valid Orca CLI. */
|
/** GNOME screen reader path — never a valid Orca CLI. */
|
||||||
const GNOME_SCREEN_READER = "/usr/bin/orca";
|
const GNOME_SCREEN_READER = "/usr/bin/orca";
|
||||||
|
const ASYNC_READ_SCREEN_TIMEOUT_MS = 5_000;
|
||||||
|
|
||||||
let cachedCli: string | null | undefined;
|
let cachedCli: string | null | undefined;
|
||||||
|
|
||||||
@@ -275,14 +276,26 @@ export function readScreen(surface: string, lines = 50): string {
|
|||||||
return parseReadTail(output);
|
return parseReadTail(output);
|
||||||
}
|
}
|
||||||
|
|
||||||
export async function readScreenAsync(surface: string, lines = 50): Promise<string> {
|
async function readScreenWithCli(
|
||||||
const cli = requireOrca();
|
cli: string,
|
||||||
|
surface: string,
|
||||||
|
lines: number,
|
||||||
|
timeout = ASYNC_READ_SCREEN_TIMEOUT_MS,
|
||||||
|
): Promise<string> {
|
||||||
const { stdout } = await execFileAsync(cli, readScreenArgs(surface, lines), {
|
const { stdout } = await execFileAsync(cli, readScreenArgs(surface, lines), {
|
||||||
encoding: "utf8",
|
encoding: "utf8",
|
||||||
|
timeout,
|
||||||
|
killSignal: "SIGKILL",
|
||||||
});
|
});
|
||||||
return parseReadTail(stdout);
|
return parseReadTail(stdout);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export async function readScreenAsync(surface: string, lines = 50): Promise<string> {
|
||||||
|
return readScreenWithCli(requireOrca(), surface, lines);
|
||||||
|
}
|
||||||
|
|
||||||
|
export const __orcaTest__ = { readScreenWithCli };
|
||||||
|
|
||||||
export function closeSurface(surface: string): void {
|
export function closeSurface(surface: string): void {
|
||||||
const cli = requireOrca();
|
const cli = requireOrca();
|
||||||
const args = ["terminal", "close", "--terminal", surface];
|
const args = ["terminal", "close", "--terminal", surface];
|
||||||
|
|||||||
@@ -153,18 +153,28 @@ function readCompletionFile(path: string): PollResult | null {
|
|||||||
return { reason: "sentinel", exitCode };
|
return { reason: "sentinel", exitCode };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
type PollOptions = {
|
||||||
|
interval: number;
|
||||||
|
sessionFile?: string;
|
||||||
|
sentinelFile?: string;
|
||||||
|
completionFile?: string;
|
||||||
|
onTick?: (elapsed: number) => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
/** Avoid launching a terminal CLI reader when a local completion signal is authoritative. */
|
||||||
|
function shouldPollSurface(
|
||||||
|
options: Pick<PollOptions, "sessionFile" | "sentinelFile" | "completionFile">,
|
||||||
|
): boolean {
|
||||||
|
return !options.sessionFile && !options.sentinelFile && !options.completionFile;
|
||||||
|
}
|
||||||
|
|
||||||
export const __pollForExitTest__ = { interpretExitSidecar, readCompletionFile };
|
export const __pollForExitTest__ = { interpretExitSidecar, readCompletionFile };
|
||||||
|
|
||||||
export async function pollForExit(
|
export async function pollForExit(
|
||||||
surface: string,
|
surface: string,
|
||||||
signal: AbortSignal,
|
signal: AbortSignal,
|
||||||
options: {
|
options: PollOptions,
|
||||||
interval: number;
|
readSurface: (surface: string, lines?: number) => Promise<string> = readScreenAsync,
|
||||||
sessionFile?: string;
|
|
||||||
sentinelFile?: string;
|
|
||||||
completionFile?: string;
|
|
||||||
onTick?: (elapsed: number) => void;
|
|
||||||
},
|
|
||||||
): Promise<PollResult> {
|
): Promise<PollResult> {
|
||||||
const start = Date.now();
|
const start = Date.now();
|
||||||
|
|
||||||
@@ -197,12 +207,14 @@ export async function pollForExit(
|
|||||||
} catch {}
|
} catch {}
|
||||||
}
|
}
|
||||||
|
|
||||||
try {
|
if (shouldPollSurface(options)) {
|
||||||
const screen = await readScreenAsync(surface, 5);
|
try {
|
||||||
const match = screen.match(/__SUBAGENT_DONE_(\d+)__/);
|
const screen = await readSurface(surface, 5);
|
||||||
if (match) return { reason: "sentinel", exitCode: Number.parseInt(match[1], 10) };
|
const match = screen.match(/__SUBAGENT_DONE_(\d+)__/);
|
||||||
} catch {
|
if (match) return { reason: "sentinel", exitCode: Number.parseInt(match[1], 10) };
|
||||||
// Surface reads are best-effort. Filesystem completion remains authoritative.
|
} catch {
|
||||||
|
// Surface reads are a best-effort fallback when no completion file is available.
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const elapsed = Math.floor((Date.now() - start) / 1000);
|
const elapsed = Math.floor((Date.now() - start) / 1000);
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
import { describe, it, before, after, beforeEach } from "node:test";
|
import { describe, it, before, after, beforeEach } from "node:test";
|
||||||
import assert from "node:assert/strict";
|
import assert from "node:assert/strict";
|
||||||
import { mkdtempSync, writeFileSync, readFileSync, mkdirSync, rmSync, existsSync } from "node:fs";
|
import {
|
||||||
|
chmodSync,
|
||||||
|
existsSync,
|
||||||
|
mkdirSync,
|
||||||
|
mkdtempSync,
|
||||||
|
readFileSync,
|
||||||
|
rmSync,
|
||||||
|
writeFileSync,
|
||||||
|
} from "node:fs";
|
||||||
import { join } from "node:path";
|
import { join } from "node:path";
|
||||||
import { tmpdir } from "node:os";
|
import { tmpdir } from "node:os";
|
||||||
import { fileURLToPath } from "node:url";
|
import { fileURLToPath } from "node:url";
|
||||||
@@ -32,11 +40,13 @@ import {
|
|||||||
|
|
||||||
import {
|
import {
|
||||||
__pollForExitTest__,
|
__pollForExitTest__,
|
||||||
|
pollForExit,
|
||||||
selectMuxBackend,
|
selectMuxBackend,
|
||||||
shellEscape,
|
shellEscape,
|
||||||
} from "../pi-extension/subagents/surface.ts";
|
} from "../pi-extension/subagents/surface.ts";
|
||||||
import { parseCreatedPane, parseCreatedTab } from "../pi-extension/subagents/herdr.ts";
|
import { parseCreatedPane, parseCreatedTab } from "../pi-extension/subagents/herdr.ts";
|
||||||
import {
|
import {
|
||||||
|
__orcaTest__,
|
||||||
parseCreatedSplit,
|
parseCreatedSplit,
|
||||||
parseCreatedTerminal,
|
parseCreatedTerminal,
|
||||||
parseReadTail,
|
parseReadTail,
|
||||||
@@ -1812,6 +1822,60 @@ describe("subagent-done.ts", () => {
|
|||||||
describe("surface exit signals", () => {
|
describe("surface exit signals", () => {
|
||||||
const { interpretExitSidecar, readCompletionFile } = __pollForExitTest__;
|
const { interpretExitSidecar, readCompletionFile } = __pollForExitTest__;
|
||||||
|
|
||||||
|
it("does not poll the terminal while waiting for a filesystem completion signal", async () => {
|
||||||
|
const dir = createTestDir();
|
||||||
|
const completionFile = join(dir, "child.exit-code");
|
||||||
|
let surfaceReads = 0;
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await pollForExit(
|
||||||
|
"unused-surface",
|
||||||
|
new AbortController().signal,
|
||||||
|
{
|
||||||
|
interval: 1,
|
||||||
|
completionFile,
|
||||||
|
onTick() {
|
||||||
|
writeFileSync(completionFile, "7\n");
|
||||||
|
},
|
||||||
|
},
|
||||||
|
async () => {
|
||||||
|
surfaceReads += 1;
|
||||||
|
return "__SUBAGENT_DONE_0__";
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
|
assert.deepEqual(result, { reason: "sentinel", exitCode: 7 });
|
||||||
|
assert.equal(surfaceReads, 0);
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
it("bounds an asynchronous Orca screen read and kills its CLI process", async () => {
|
||||||
|
const dir = createTestDir();
|
||||||
|
const pidFile = join(dir, "reader.pid");
|
||||||
|
const cli = join(dir, "fake-orca");
|
||||||
|
writeFileSync(
|
||||||
|
cli,
|
||||||
|
`#!/usr/bin/env node\nrequire("node:fs").writeFileSync(${JSON.stringify(pidFile)}, String(process.pid));\nsetInterval(() => {}, 1000);\n`,
|
||||||
|
);
|
||||||
|
chmodSync(cli, 0o755);
|
||||||
|
|
||||||
|
try {
|
||||||
|
await assert.rejects(__orcaTest__.readScreenWithCli(cli, "unused-surface", 5, 500));
|
||||||
|
const pid = Number.parseInt(readFileSync(pidFile, "utf8"), 10);
|
||||||
|
let running = true;
|
||||||
|
try {
|
||||||
|
process.kill(pid, 0);
|
||||||
|
} catch {
|
||||||
|
running = false;
|
||||||
|
}
|
||||||
|
assert.equal(running, false);
|
||||||
|
} finally {
|
||||||
|
rmSync(dir, { recursive: true, force: true });
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
it("reads and removes a shell completion file", () => {
|
it("reads and removes a shell completion file", () => {
|
||||||
withTempDir((dir) => {
|
withTempDir((dir) => {
|
||||||
const completionFile = join(dir, "child.exit-code");
|
const completionFile = join(dir, "child.exit-code");
|
||||||
|
|||||||
Reference in New Issue
Block a user