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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
573 changes: 523 additions & 50 deletions agent-network/bin/cli.ts

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions agent-network/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 1 addition & 1 deletion agent-network/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@sleep2agi/agent-network",
"version": "2.3.0-preview.34",
"version": "2.3.0-preview.35",
"description": "AI Agent Network CLI — Local-first multi-agent orchestration across 6 runtimes (Claude Code CLI / Claude Agent SDK / Codex SDK / Codex app-server / Grok Build ACP / OpenCode CLI) and 8+ LLM providers. Apache 2.0.",
"type": "module",
"main": "dist/src/client.js",
Expand Down
4 changes: 2 additions & 2 deletions agent-network/src/opencode-agent-node-pair.ts
Original file line number Diff line number Diff line change
Expand Up @@ -17,8 +17,8 @@ import type { Stats } from "fs";
import { basename, delimiter, dirname, isAbsolute, join, relative, resolve } from "path";
import { opencodeOwnedPathModeIsSafe } from "./opencode-owner-mode";

export const OPENCODE_AGENT_NETWORK_VERSION = "2.3.0-preview.34";
export const OPENCODE_AGENT_NODE_VERSION = "2.5.0-preview.26";
export const OPENCODE_AGENT_NETWORK_VERSION = "2.3.0-preview.35";
export const OPENCODE_AGENT_NODE_VERSION = "2.5.0-preview.27";
export const OPENCODE_AGENT_NODE_SPEC =
`@sleep2agi/agent-node@${OPENCODE_AGENT_NODE_VERSION}`;

Expand Down
129 changes: 129 additions & 0 deletions agent-network/src/opencode-wrapper-stop.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,129 @@
import { afterEach, describe, expect, test } from "bun:test";
import { spawn, type ChildProcess } from "child_process";
import { readFileSync } from "fs";
import {
signalExactProcessGracefully,
stopExactProcessTermOnly,
type ExactProcessState,
} from "./opencode-wrapper-stop";

const children: ChildProcess[] = [];
const detachedGroups: number[] = [];

function procStartTime(pid: number): string | undefined {
try {
const stat = readFileSync(`/proc/${pid}/stat`, "utf8");
const close = stat.lastIndexOf(")");
return close < 0 ? undefined : stat.slice(close + 2).trim().split(/\s+/)[19];
} catch {
return undefined;
}
}

function exactState(pid: number, startTime: string): ExactProcessState {
const current = procStartTime(pid);
if (current === undefined) {
try {
process.kill(pid, 0);
return "unknown";
} catch {
return "exited-or-reused";
}
}
return current === startTime ? "same" : "exited-or-reused";
}

async function waitForExit(child: ChildProcess, timeoutMs = 2_000): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) return;
await Promise.race([
new Promise<void>((resolve) => child.once("exit", () => resolve())),
new Promise<void>((_, reject) => setTimeout(() => reject(new Error("child did not exit")), timeoutMs)),
]);
}

async function waitForProcState(pid: number, wanted: string, timeoutMs = 2_000): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
try {
const state = readFileSync(`/proc/${pid}/stat`, "utf8").match(/\)\s+([A-Z])/i)?.[1];
if (state === wanted) return;
} catch {}
await new Promise((resolve) => setTimeout(resolve, 5));
}
throw new Error(`process ${pid} did not enter state ${wanted}`);
}

afterEach(async () => {
for (const pgid of detachedGroups.splice(0)) {
try { process.kill(-pgid, "SIGKILL"); } catch {}
}
for (const child of children.splice(0)) {
if (child.pid && child.exitCode === null && child.signalCode === null) {
try { process.kill(child.pid, "SIGCONT"); } catch {}
try { process.kill(child.pid, "SIGKILL"); } catch {}
await waitForExit(child).catch(() => {});
}
}
});

describe("bound OpenCode wrapper TERM-only stop", () => {
test("a responsive wrapper exits after SIGTERM", async () => {
const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"], {
stdio: "ignore",
});
children.push(child);
const pid = child.pid!;
const startTime = procStartTime(pid)!;

expect(await stopExactProcessTermOnly({
pid,
readState: () => exactState(pid, startTime),
timeoutMs: 2_000,
pollMs: 10,
})).toBe(true);
await waitForExit(child);
});

test("a parent-forwarded SIGINT retains a SIGSTOP'd wrapper and detached child", async () => {
const wrapperSource = [
'const { spawn } = require("child_process");',
'const child = spawn(process.execPath, ["-e", "setInterval(() => {}, 1000)"],',
' { detached: true, stdio: "ignore" });',
'process.stdout.write(String(child.pid) + "\\n");',
'setInterval(() => {}, 1000);',
].join("\n");
const child = spawn(process.execPath, ["-e", wrapperSource], {
stdio: ["ignore", "pipe", "inherit"],
});
children.push(child);
const pid = child.pid!;
const startTime = procStartTime(pid)!;
const detachedPid = await new Promise<number>((resolve, reject) => {
const timer = setTimeout(() => reject(new Error("wrapper did not report detached child")), 2_000);
child.stdout!.once("data", (chunk) => {
clearTimeout(timer);
resolve(Number(String(chunk).trim()));
});
});
detachedGroups.push(detachedPid);
expect(procStartTime(detachedPid)).toBeDefined();
process.kill(pid, "SIGSTOP");
await waitForProcState(pid, "T");

expect(await signalExactProcessGracefully({
pid,
readState: () => exactState(pid, startTime),
timeoutMs: 100,
pollMs: 10,
}, "SIGINT")).toBe(false);
expect(exactState(pid, startTime)).toBe("same");
expect(readFileSync(`/proc/${pid}/stat`, "utf8").match(/\)\s+([A-Z])/i)?.[1]).toBe("T");
// The failure retains the ownership relationship: the wrapper is still
// present to resume its shutdown handler, and its detached ACP analogue
// remains live instead of becoming an ownerless survivor after SIGKILL.
expect(procStartTime(detachedPid)).toBeDefined();
expect(Number(readFileSync(`/proc/${detachedPid}/stat`, "utf8")
.slice(readFileSync(`/proc/${detachedPid}/stat`, "utf8").lastIndexOf(")") + 2)
.trim().split(/\s+/)[2])).toBe(detachedPid);
});
});
54 changes: 54 additions & 0 deletions agent-network/src/opencode-wrapper-stop.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
export type ExactProcessState = "same" | "exited-or-reused" | "unknown";

export interface TermOnlyExactProcessStopOptions {
pid: number;
/** Revalidate the caller's exact PID/start-time/argv receipt. */
readState: () => ExactProcessState;
timeoutMs?: number;
pollMs?: number;
}

export type GracefulExactProcessSignal = "SIGTERM" | "SIGINT";

/**
* Signal an exact process with a graceful signal only.
*
* Bound OpenCode's agent-node wrapper owns a detached ACP process group. If
* the wrapper cannot run its async SIGTERM/SIGINT handler (for example it is
* SIGSTOP'd or its event loop is wedged), SIGKILLing only the wrapper would
* orphan that group. Therefore a timeout is a hard failure: leave the
* wrapper and its descendants intact for inspection/retry, and never claim a
* successful stop.
*/
export async function signalExactProcessGracefully(
options: TermOnlyExactProcessStopOptions,
signal: GracefulExactProcessSignal,
): Promise<boolean> {
const timeoutMs = options.timeoutMs ?? 8_000;
const pollMs = options.pollMs ?? 250;
const initial = options.readState();
if (initial === "exited-or-reused") return true;
if (initial === "unknown") return false;

try {
process.kill(options.pid, signal);
} catch {
const afterError = options.readState();
return afterError === "exited-or-reused";
}

const deadline = Date.now() + timeoutMs;
while (Date.now() < deadline) {
const state = options.readState();
if (state === "exited-or-reused") return true;
if (state === "unknown") return false;
await new Promise((resolve) => setTimeout(resolve, pollMs));
}
return options.readState() === "exited-or-reused";
}

export function stopExactProcessTermOnly(
options: TermOnlyExactProcessStopOptions,
): Promise<boolean> {
return signalExactProcessGracefully(options, "SIGTERM");
}
2 changes: 1 addition & 1 deletion agent-node/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@sleep2agi/agent-node",
"version": "2.5.0-preview.26",
"version": "2.5.0-preview.27",
"description": "AI Agent runtime for CommHub networks. Supports Claude Code CLI, Claude Agent SDK, Codex SDK, Codex app-server, Grok Build ACP, and OpenCode CLI.",
"bin": {
"agent-node": "dist/cli.js"
Expand Down
70 changes: 54 additions & 16 deletions agent-node/src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2690,12 +2690,36 @@ function sanitizeGrokCommhubLeak(text: string): string {
const OPENCODE_PROCESS_BUNDLE_MARKER = "processWithOpencode";
async function processWithOpencode(task: string, _from: string, _images?: string[]): Promise<string> {
debug(`[${OPENCODE_PROCESS_BUNDLE_MARKER}] dispatch`);
if (shuttingDown) {
throw new Error("agent-node is shutting down; refusing to open a new OpenCode process tree");
}
const { openOpencodeRuntime, opencodeThink } =
await import("./runtime/opencode-acp/runtime");

// Reset the holder if the child has already exited — the next call
// will re-open with session/load per the persisted sessionId.
// Dynamic import is an await boundary. A signal can stop the old client and
// clear its holder while this function is suspended; without this second
// gate the resumed turn creates a fresh credential root after shutdown has
// already passed closeOpencodeRuntime(), then process.exit strands it.
if (shuttingDown) {
throw new Error("agent-node is shutting down; refusing to open a new OpenCode process tree");
}

if (!opencodeRuntimeSession && opencodeRuntimeClient) {
if (!opencodeRuntimeClient.cleanupConfirmed) {
throw opencodeRuntimeClient.cleanupError
?? new Error("previous OpenCode open attempt still owns a process tree");
}
opencodeRuntimeClient = null;
}

// Re-open only after the supervisor has proved its whole session empty.
// A dead anchor with residual members is a fail-closed owner state, not a
// license to create a second OpenCode tree.
if (opencodeRuntimeSession && !opencodeRuntimeSession.client.isRunning) {
if (!opencodeRuntimeSession.client.cleanupConfirmed) {
throw opencodeRuntimeSession.client.cleanupError
?? new Error("previous OpenCode supervisor cleanup is unconfirmed; refusing a second child");
}
log(`[opencode] previous child exited — reopening on this turn`);
opencodeRuntimeSession = null;
opencodeRuntimeClient = null;
Expand Down Expand Up @@ -2758,19 +2782,14 @@ async function processWithOpencode(task: string, _from: string, _images?: string

async function closeOpencodeRuntime(reason: string): Promise<void> {
const client = opencodeRuntimeClient ?? opencodeRuntimeSession?.client ?? null;
if (client?.isRunning) {
log(`[opencode] stopping ACP child (${reason})`);
await Promise.race([
client.stop("SIGTERM"),
new Promise<void>((resolve) => setTimeout(resolve, 1_000)),
]).catch((e: any) => {
warn(`[opencode] graceful stop failed: ${e?.message || e}`);
});
if (client.isRunning) {
await client.stop("SIGKILL").catch((e: any) => {
warn(`[opencode] forced stop failed: ${e?.message || e}`);
});
}
if (!client) return;
if (!client.cleanupConfirmed) {
log(`[opencode] stopping ACP supervisor (${reason})`);
await client.stop("SIGTERM", 4_000);
}
if (!client.cleanupConfirmed) {
throw client.cleanupError
?? new Error("OpenCode supervisor stop returned without confirmed session cleanup");
}
opencodeRuntimeClient = null;
opencodeRuntimeSession = null;
Expand Down Expand Up @@ -3201,6 +3220,12 @@ function think(task: string, from: string, taskId: string | null, images?: strin
return Promise.resolve(`执行出错: agent-node 重启中(config-apply drain),任务暂不处理,请稍后重发`);
}
const run = async () => {
// Calls already queued before the signal did not see the admission check
// below. Re-check at execution time so no runtime can begin after shutdown
// has started; OpenCode additionally re-checks across its dynamic import.
if (shuttingDown) {
return "执行出错: agent-node 正在关闭,任务暂不处理,请稍后重发";
}
// Expose CURRENT_TASK_ID for runtime processes (Claude SDK / Codex)
// so the LLM can pass it as parent_task_id when delegating sub-tasks.
// Server has a fallback (latest open task to this caller) but explicit
Expand Down Expand Up @@ -4650,7 +4675,19 @@ const shutdown = async () => {
if (shuttingDown) return;
shuttingDown = true;
log("shutting down...");
await closeOpencodeRuntime("signal shutdown");
try {
await closeOpencodeRuntime("signal shutdown");
} catch (shutdownError: any) {
// Keep agent-node alive as the supervisor owner. A later TERM/INT/HUP can
// retry after SIGCONT or after a transient residual process exits.
shuttingDown = false;
process.exitCode = 1;
warn(
`[opencode] shutdown refused while owner tree is retained: ` +
`${shutdownError?.message ?? shutdownError}`,
);
return;
}
// #261 P0-1 — gate the feishu supervisor loop so it stops re-forking
// on the soon-to-arrive child exit. SIGTERM each tracked worker (give
// it 500 ms to exit gracefully), then SIGKILL holdouts. Without this,
Expand All @@ -4674,6 +4711,7 @@ const shutdown = async () => {
};
process.on("SIGINT", shutdown);
process.on("SIGTERM", shutdown);
process.on("SIGHUP", shutdown);
for (const channel of TELEGRAM_CHANNELS) connectTelegram(channel);
for (const channel of FEISHU_CHANNELS) void connectFeishu(channel);
connectSSE();
Expand Down
Loading
Loading