refactor(acp): simplify pass — wire exit-hook, drop dead idle timer

Post-implementation /simplify cleanup. Registers the plan-specified
process.on('exit', killAllProcesses) safety hook in index.ts (was missing —
closes an orphan-subprocess gap on hard exit). Removes the unwired idle-timer
(engine StuckTaskDetector + dispose()/registry teardown is authoritative per
KTD4a) and its tests. Fixes a stale dispositionFor doc comment, removes a
redundant identifier re-normalization in the event bridge, and clarifies why
the FusionCategory type keeps git_write/task_agent_mutation. Behavior-
preserving; 177 tests green.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
gsxdsm
2026-06-03 09:31:32 -07:00
parent 489a287d6f
commit 63d96bd418
6 changed files with 22 additions and 65 deletions

View File

@@ -10,7 +10,6 @@ import {
forceKill,
spawnAgent,
activeProcessCount,
createIdleTimer,
} from "../process-manager.js";
const spawned: ChildProcess[] = [];
@@ -160,24 +159,3 @@ describe("spawnAgent", () => {
});
});
describe("createIdleTimer", () => {
it("fires onIdle after the interval and reset re-arms", async () => {
let fired = 0;
const timer = createIdleTimer(30, () => {
fired += 1;
});
await new Promise((r) => setTimeout(r, 50));
expect(fired).toBe(1);
timer.clear();
});
it("clear prevents firing", async () => {
let fired = 0;
const timer = createIdleTimer(20, () => {
fired += 1;
});
timer.clear();
await new Promise((r) => setTimeout(r, 40));
expect(fired).toBe(0);
});
});

View File

@@ -98,11 +98,10 @@ function buildResponse(sel: {
}
/**
* Read the per-category disposition from the live policy (exempt → allow).
*
* Exported so the fs-capabilities write path (U7) can reuse the exact same
* per-category gate-reading logic for `file_write_delete` instead of duplicating
* it (and risking drift from the U5 security floor).
* Read the raw per-category disposition from the live policy (exempt → allow),
* before the Risk S1 acknowledgement escalation. Callers that gate untrusted
* actions should use `effectiveDisposition` (which applies the escalation); this
* is the unescalated primitive it builds on.
*/
export function dispositionFor(
category: FusionCategory | "exempt",

View File

@@ -207,7 +207,8 @@ export function createEventBridge(callbacks: AcpCallbacks): EventBridge {
// them (a partial update may only set status/output).
if (update.title != null) tracked.title = safeTitle(update.title);
if (update.kind != null) tracked.kind = update.kind;
setTracked(update.toolCallId, tracked);
// `id` is already bounded above; setTracked re-keys with the same value.
setTracked(id, tracked);
const status = update.status;
if (status !== "completed" && status !== "failed") {

View File

@@ -2,6 +2,12 @@ import { definePlugin } from "@fusion/plugin-sdk";
import type { FusionPlugin, PluginRuntimeFactory, PluginRuntimeManifestMetadata } from "@fusion/plugin-sdk";
import { resolveCliSettings } from "./cli-spawn.js";
import { AcpRuntimeAdapter } from "./runtime-adapter.js";
import { killAllProcesses } from "./process-manager.js";
// Reap any live agent subprocesses on hard process exit so none are orphaned
// (KTD4 — the registry SIGKILL is the authoritative no-orphan guarantee). Scoped
// to tracked agent subprocesses only; never touches other processes/ports.
process.on("exit", killAllProcesses);
export const ACP_RUNTIME_ID = "acp";
const ACP_RUNTIME_VERSION = "0.1.0";

View File

@@ -152,38 +152,3 @@ export function captureStderr(child: ChildProcess): () => string {
});
return () => buffer;
}
// --- inactivity timer (KTD4: high ceiling, engine is the authority) --------
/** Default idle ceiling. The engine's StuckTaskDetector is authoritative. */
export const DEFAULT_IDLE_CEILING_MS = 30 * 60_000;
export interface IdleTimer {
reset(): void;
clear(): void;
}
/**
* Create an inactivity timer that fires `onIdle` after `ms` of no `reset()`.
* The default ceiling is intentionally high (KTD4) — this is a backstop, not
* the primary aborter.
*/
export function createIdleTimer(ms: number, onIdle: () => void): IdleTimer {
let handle: NodeJS.Timeout | undefined;
const arm = () => {
handle = setTimeout(onIdle, ms);
// Don't keep the event loop alive solely for the backstop timer.
handle.unref?.();
};
arm();
return {
reset() {
if (handle) clearTimeout(handle);
arm();
},
clear() {
if (handle) clearTimeout(handle);
handle = undefined;
},
};
}

View File

@@ -24,8 +24,16 @@ export interface AcpCallbacks {
export type GateDisposition = "allow" | "block" | "require-approval";
/**
* Fusion action-gate categories the ACP `toolCall.kind` is classified into
* (KTD3a). `"exempt"` is implicit (read-only / benign) and always allows.
* Fusion action-gate categories — the full policy-rule keyspace, used to read
* `permissionPolicy.rules[category]`. `"exempt"` is implicit (read-only / benign)
* and always allows.
*
* Note: ACP's `ToolKind` has no git/task discriminator, so `classifyToolKind`
* only ever produces `file_write_delete` / `command_execution` / `network_api`
* (+ exempt). `git_write` and `task_agent_mutation` remain part of the category
* type because the policy rules are keyed by all categories — git writes in
* particular route through `file_write_delete` gating PLUS the path-jail's hard
* `.git/**` reject (KTD6a), not a dedicated `git_write` classification.
*/
export type FusionCategory =
| "git_write"