feat: add assistant proposal worker runtime

This commit is contained in:
zenord
2026-08-18 18:48:47 +08:00
parent 947f19e3cd
commit b4121c9fd6
32 changed files with 2827 additions and 986 deletions
+817
View File
@@ -0,0 +1,817 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import type { AcpConfig, PermissionPolicy } from "../config.js";
import { chatKeyFor, type AssistantBinding, type DurableSessionStore } from "../core/durable-session-store.js";
import type { Proposal, ProposalPending, ProposalStore } from "../core/proposal-store.js";
import type { ResolvedBot } from "../roles/role-registry.js";
import type { ConversationRequest, ConversationResponse, ConversationRuntime, RuntimeEventSink, RuntimeStats } from "./types.js";
import { AcpSessionRestoreError, AcpWorker } from "./worker.js";
const ASSISTANT_POLICY: PermissionPolicy = { mode: "deny", allowedTools: [], allowedCommandPatterns: [] };
const ASSISTANT_ENVELOPE = /<GORI_ASSISTANT_ACTION_V1>(?<json>[\s\S]*?)<\/GORI_ASSISTANT_ACTION_V1>\s*$/;
const WORKER_ENVELOPE = /<GORI_WORKER_RESULT_V1>(?<json>[\s\S]*?)<\/GORI_WORKER_RESULT_V1>\s*$/;
export type AssistantAction =
| { type: "create_proposal"; title: string; goal: string; steps: string[] }
| { type: "confirm"; id?: string; answer?: string }
| { type: "start_next" }
| { type: "cancel"; id?: string }
| { type: "stop" };
export interface ParsedAssistantEnvelope {
reply: string;
actions: AssistantAction[];
}
export type WorkerResultStatus = "SUCCESS" | "NEEDS_CONFIRMATION" | "FAILED";
export interface ParsedWorkerResult {
status: WorkerResultStatus;
summary: string;
question?: string;
nextStep?: string;
dirty?: boolean;
}
interface ActiveWorker {
proposalId: string;
worker: AcpWorker;
settled: boolean;
run?: Promise<void>;
}
export interface AssistantManagerOptions {
assistantWorkspaceHome?: string;
kimiCodeHome?: string;
allowUnverifiedAssistantAgent?: boolean;
maxAssistantSessions?: number;
}
export class AssistantManager implements ConversationRuntime {
private readonly assistantWorkers = new Map<string, AcpWorker>();
private readonly chatChains = new Map<string, Promise<unknown>>();
private readonly assistantSalt = crypto.randomBytes(32);
private readonly maxAssistantSessions: number;
private readonly sweeper: NodeJS.Timeout;
private active?: ActiveWorker;
private eventSink?: RuntimeEventSink;
private kimiHome?: string;
private crashes = 0;
private shuttingDown = false;
private schedulerChain: Promise<void> = Promise.resolve();
constructor(
private readonly config: AcpConfig,
private readonly bot: ResolvedBot,
private readonly store: DurableSessionStore,
readonly proposals: ProposalStore,
private readonly options: AssistantManagerOptions = {}
) {
this.maxAssistantSessions = options.maxAssistantSessions || 4;
this.sweeper = setInterval(() => void this.sweep().catch(() => undefined), config.sweepIntervalMs);
this.sweeper.unref();
}
async initialize(): Promise<void> {
for (const proposal of this.proposals.list({ status: "working" })) {
if (proposal.workerProcessGroup) {
try {
await AcpWorker.terminatePersistedGroup(proposal.workerProcessGroup, this.config.cancelGraceMs);
} catch (error) {
console.error(`Persisted worker cleanup failed for proposal ${proposal.id} (${error instanceof Error ? error.name : "unknown error"})`);
}
}
const summary = "worker_lost: the runner restarted while this proposal was working; its worker was terminated and the proposal was marked failed";
await this.proposals.update(proposal.id, {
status: "failed",
pending: { kind: "failure", summary },
lastWorkerSummary: summary,
workerProcessGroup: undefined,
finishedAt: Date.now()
});
}
}
setEventSink(sink: RuntimeEventSink): void {
this.eventSink = sink;
}
async prompt(request: ConversationRequest): Promise<ConversationResponse> {
if (this.shuttingDown) throw new Error("ACP runtime is shutting down");
const chatKey = chatKeyFor(request.platform, request.chatId);
const reply = await this.enqueueChat(chatKey, () => this.runAssistantTurn(chatKey, request));
return { text: reply, botId: this.bot.id, agentId: this.bot.agent.id, mode: "assistant" };
}
async cancel(platform: string, chatId: string): Promise<boolean> {
const chatKey = chatKeyFor(platform, chatId);
return this.scheduler(() => this.cancelLocked(chatKey));
}
async confirm(platform: string, chatId: string): Promise<boolean> {
const chatKey = chatKeyFor(platform, chatId);
return this.scheduler(() => this.confirmLocked(chatKey));
}
async stop(platform: string, chatId: string): Promise<boolean> {
const chatKey = chatKeyFor(platform, chatId);
return this.scheduler(() => this.stopActiveLocked(chatKey));
}
listProposals(platform: string, chatId: string): Proposal[] {
const chatKey = chatKeyFor(platform, chatId);
return this.proposals.list().filter((proposal) => proposal.ownerChatKey === chatKey);
}
async reset(platform: string, chatId: string): Promise<void> {
const chatKey = chatKeyFor(platform, chatId);
const worker = this.assistantWorkers.get(chatKey);
if (worker) {
this.assistantWorkers.delete(chatKey);
await worker.terminate();
}
await this.store.deleteBinding(chatKey);
}
status(platform: string, chatId: string): Record<string, string | number | boolean> {
const proposals = this.proposals.list();
const active = this.active;
const activeProposal = active ? this.proposals.get(active.proposalId) : undefined;
return {
bot: this.bot.id,
agent: this.bot.agent.id,
workspace: this.bot.workspace,
assistantSessions: this.assistantWorkers.size,
proposals: proposals.length,
queuedProposals: proposals.filter((proposal) => proposal.status === "queued").length,
workingProposals: proposals.filter((proposal) => proposal.status === "working").length,
awaitingConfirmation: proposals.filter((proposal) => proposal.status === "awaiting_user_confirmation").length,
workerRunning: Boolean(active?.worker.inFlight),
owner: Boolean(activeProposal && activeProposal.ownerChatKey === chatKeyFor(platform, chatId))
};
}
stats(): RuntimeStats {
const assistants = [...this.assistantWorkers.values()];
return {
activeWorkers: assistants.length + (this.active ? 1 : 0),
inFlight: assistants.filter((worker) => worker.inFlight).length + (this.active?.worker.inFlight ? 1 : 0),
crashes: this.crashes,
persistedBindings: this.store.stats().bindings
};
}
async shutdown(): Promise<void> {
if (this.shuttingDown) return;
this.shuttingDown = true;
clearInterval(this.sweeper);
const active = this.active;
this.active = undefined;
if (active) {
active.settled = true;
if (active.worker.inFlight) await active.worker.cancel().catch(() => undefined);
active.worker.terminateImmediately();
await active.worker.terminate().catch(() => undefined);
await active.run?.catch(() => undefined);
}
const assistants = [...this.assistantWorkers.values()];
this.assistantWorkers.clear();
await Promise.all(assistants.map((worker) => worker.terminate().catch(() => undefined)));
await Promise.allSettled([...this.chatChains.values()]);
}
private async runAssistantTurn(chatKey: string, request: ConversationRequest): Promise<string> {
const worker = await this.acquireAssistantWorker(chatKey);
try {
const reply = await worker.prompt(this.assistantUserPrompt(request));
const parsed = await this.parseAssistantReply(worker, reply);
if (worker.isolationViolated) throw new Error("Assistant session attempted forbidden tool activity");
await this.store.touchBinding(chatKey);
await this.executeActions(chatKey, request, parsed.actions);
return parsed.reply;
} catch (error) {
if (worker.isolationViolated) await this.store.deleteBinding(chatKey).catch(() => undefined);
throw error;
}
}
private async parseAssistantReply(worker: AcpWorker, reply: string): Promise<ParsedAssistantEnvelope> {
let parsed = parseAssistantActions(reply);
if (!parsed) parsed = parseAssistantActions(await worker.prompt(assistantRepairPrompt()));
if (!parsed) throw new Error("Assistant did not return a valid GORI_ASSISTANT_ACTION_V1 envelope after one repair attempt");
return parsed;
}
private async executeActions(chatKey: string, request: ConversationRequest, actions: AssistantAction[]): Promise<void> {
for (const action of actions) {
if (action.type === "create_proposal") {
await this.proposals.create({
title: action.title,
goal: action.goal,
steps: action.steps,
ownerChatKey: chatKey,
requesterUserId: request.userId
});
} else if (action.type === "confirm") {
await this.scheduler(() => this.confirmLocked(chatKey, action.id, action.answer));
} else if (action.type === "start_next") {
await this.scheduler(() => this.tryStartLocked());
} else if (action.type === "cancel") {
await this.scheduler(() => this.cancelLocked(chatKey, action.id));
} else if (action.type === "stop") {
await this.scheduler(() => this.stopActiveLocked(chatKey));
}
}
}
private async confirmLocked(chatKey: string, id?: string, answer?: string): Promise<boolean> {
const proposal = this.resolveTargetProposal(chatKey, id, ["proposed", "awaiting_user_confirmation"]);
if (!proposal) return false;
if (proposal.status === "proposed") {
await this.proposals.update(proposal.id, { status: "queued", confirmedAt: Date.now() });
await this.tryStartLocked();
return true;
}
const pending = proposal.pending;
if (!pending) return false;
if (pending.kind === "success") {
await this.terminateActiveLocked(proposal.id);
await this.proposals.update(proposal.id, { status: "completed", pending: undefined, finishedAt: Date.now() });
return true;
}
if (pending.kind === "failure") {
await this.terminateActiveLocked(proposal.id);
if (answer && answer.trim().toLowerCase() === "retry") {
await this.proposals.update(proposal.id, { status: "working", pending: undefined, startedAt: Date.now() });
this.launchWorker(proposal.id);
} else {
await this.proposals.update(proposal.id, { status: "failed", pending: undefined, finishedAt: Date.now() });
}
return true;
}
await this.proposals.update(proposal.id, { status: "working", pending: undefined });
this.continueWorker(proposal.id, answer || "Confirmed. Please continue.", pending.question);
return true;
}
private async cancelLocked(chatKey: string, id?: string): Promise<boolean> {
const proposal = this.resolveTargetProposal(chatKey, id, ["proposed", "queued"]);
if (!proposal) return false;
await this.proposals.update(proposal.id, { status: "cancelled", finishedAt: Date.now() });
return true;
}
private async stopActiveLocked(chatKey: string): Promise<boolean> {
const active = this.active;
if (!active) return false;
const proposal = this.proposals.get(active.proposalId);
if (!proposal || proposal.ownerChatKey !== chatKey) return false;
active.settled = true;
this.active = undefined;
if (active.worker.inFlight) await active.worker.cancel().catch(() => undefined);
active.worker.terminateImmediately();
await active.worker.terminate().catch(() => undefined);
void active.run?.catch(() => undefined);
await this.proposals.update(proposal.id, {
status: "cancelled",
pending: undefined,
workerProcessGroup: undefined,
lastWorkerSummary: "stopped by user",
finishedAt: Date.now()
});
return true;
}
private async terminateActiveLocked(proposalId: string): Promise<void> {
const active = this.active;
if (!active || active.proposalId !== proposalId) return;
active.settled = true;
this.active = undefined;
active.worker.terminateImmediately();
await active.worker.terminate().catch(() => undefined);
void active.run?.catch(() => undefined);
await this.proposals.update(proposalId, { workerProcessGroup: undefined });
}
private async tryStartLocked(): Promise<void> {
if (this.active) return;
if (this.proposals.list({ status: "working" }).length > 0) return;
if (this.proposals.list({ status: "awaiting_user_confirmation" }).length > 0) return;
const next = this.proposals.list({ status: "queued" })[0];
if (!next) return;
await this.proposals.update(next.id, { status: "working", startedAt: Date.now() });
this.launchWorker(next.id);
}
private resolveTargetProposal(chatKey: string, id: string | undefined, statuses: Proposal["status"][]): Proposal | undefined {
if (id) {
const proposal = this.proposals.get(id);
return proposal && proposal.ownerChatKey === chatKey && statuses.includes(proposal.status) ? proposal : undefined;
}
return this.proposals.list()
.filter((proposal) => proposal.ownerChatKey === chatKey && statuses.includes(proposal.status))
.sort((left, right) => right.updatedAt - left.updatedAt)[0];
}
private launchWorker(proposalId: string, resumeContext?: string): void {
const proposal = this.proposals.get(proposalId);
if (!proposal || proposal.status !== "working") return;
const worker = new AcpWorker(this.bot, this.config, (crashed, error) => this.onWorkerCrash(proposalId, crashed, error), {
kind: "worker",
cwd: this.bot.workspace,
onSpawn: async (group) => {
if (this.active?.proposalId !== proposalId) throw new Error("Worker proposal changed before process identity was persisted");
await this.proposals.update(proposalId, { workerProcessGroup: group });
}
});
const active: ActiveWorker = { proposalId, worker, settled: false };
this.active = active;
const run = this.startWorkerRun(active, resumeContext);
active.run = run;
void run.catch((error) => this.handleWorkerRunFailure(active, error));
}
private continueWorker(proposalId: string, answer: string, question?: string): void {
const active = this.active;
if (active && active.proposalId === proposalId && active.worker.nativeSessionId) {
active.settled = false;
const run = this.runWorkerTurn(active, workerAnswerPrompt(answer, question));
active.run = run;
void run.catch((error) => this.handleWorkerRunFailure(active, error));
return;
}
const proposal = this.proposals.get(proposalId);
const context = proposal
? `The previous worker was lost. Its last summary: ${proposal.lastWorkerSummary || "unknown"}. Its question: ${question || "unknown"}. The user answered: ${answer}`
: answer;
this.launchWorker(proposalId, context);
}
private async startWorkerRun(active: ActiveWorker, resumeContext?: string): Promise<void> {
const nativeSessionId = await active.worker.start();
if (this.active !== active || active.settled) throw new Error("Worker was superseded before its session started");
await this.proposals.update(active.proposalId, { workerNativeSessionId: nativeSessionId });
await active.worker.prompt(this.bot.workerBootstrap, "initializing");
if (this.active !== active || active.settled) throw new Error("Worker was superseded during bootstrap");
const proposal = this.proposals.get(active.proposalId);
if (!proposal) throw new Error("Worker proposal no longer exists");
await this.runWorkerTurn(active, workerTaskPrompt(proposal, resumeContext));
}
private async runWorkerTurn(active: ActiveWorker, promptText: string): Promise<void> {
const reply = await active.worker.prompt(withWorkerResultProtocol(promptText));
if (this.active !== active || active.settled) throw new Error("Worker was superseded during its turn");
let result = parseWorkerResult(reply);
if (!result) {
const repaired = await active.worker.prompt(workerRepairPrompt());
if (this.active !== active || active.settled) throw new Error("Worker was superseded during result repair");
result = parseWorkerResult(repaired);
}
if (!result) throw new Error("Worker did not return a valid GORI_WORKER_RESULT_V1 envelope after one repair attempt");
await this.settleWorkerResult(active, result);
}
private async settleWorkerResult(active: ActiveWorker, result: ParsedWorkerResult): Promise<void> {
if (this.active !== active || active.settled || this.shuttingDown) return;
const transitioned = await this.scheduler(async () => {
if (this.active !== active || active.settled || this.shuttingDown) return false;
const proposal = this.proposals.get(active.proposalId);
if (!proposal || proposal.status !== "working") { active.settled = true; return false; }
active.settled = true;
const pending: ProposalPending = result.status === "SUCCESS"
? { kind: "success", summary: result.summary }
: result.status === "FAILED"
? { kind: "failure", summary: result.summary }
: { kind: "step", summary: result.summary, question: result.question, nextStep: result.nextStep };
await this.proposals.update(active.proposalId, {
status: "awaiting_user_confirmation",
pending,
lastWorkerSummary: result.summary
});
if (result.status !== "NEEDS_CONFIRMATION") {
this.active = undefined;
await active.worker.terminate().catch(() => undefined);
await this.proposals.update(active.proposalId, { workerProcessGroup: undefined });
}
return true;
});
if (transitioned) await this.notifyOwner(active.proposalId);
}
private async handleWorkerRunFailure(active: ActiveWorker, error: unknown): Promise<void> {
try {
if (this.shuttingDown || active.settled) return;
await active.worker.terminate().catch(() => undefined);
const transitioned = await this.scheduler(async () => {
if (this.active !== active || active.settled || this.shuttingDown) return false;
const proposal = this.proposals.get(active.proposalId);
if (!proposal || proposal.status !== "working") { active.settled = true; return false; }
active.settled = true;
this.active = undefined;
const summary = `worker_error: ${error instanceof Error ? error.message : String(error)}`;
await this.proposals.update(active.proposalId, {
status: "awaiting_user_confirmation",
pending: { kind: "failure", summary },
lastWorkerSummary: summary,
workerProcessGroup: undefined
});
return true;
});
if (transitioned) await this.notifyOwner(active.proposalId);
} catch (cleanupError) {
console.error(`Worker failure handling failed (${cleanupError instanceof Error ? cleanupError.name : "unknown error"})`);
}
}
private onWorkerCrash(proposalId: string, _worker: AcpWorker, error: Error): void {
this.crashes++;
console.error(`ACP worker crash: ${error.message}`);
const active = this.active;
if (!active || active.proposalId !== proposalId) return;
void this.handleWorkerRunFailure(active, new Error("worker_crashed: the ACP worker process exited unexpectedly"));
}
private async notifyOwner(proposalId: string): Promise<void> {
const proposal = this.proposals.get(proposalId);
const sink = this.eventSink;
if (!proposal || !sink) return;
const chatKey = proposal.ownerChatKey;
void this.enqueueChat(chatKey, async () => {
if (this.shuttingDown) return;
try {
const reply = await this.runAssistantEvent(chatKey, proposal.id);
if (reply) await sink(chatKey, reply);
} catch (error) {
console.error(`Assistant event notification failed (${error instanceof Error ? error.name : "unknown error"})`);
const latest = this.proposals.get(proposalId);
if (latest?.pending) await sink(chatKey, fallbackEventText(latest)).catch(() => undefined);
}
}).catch(() => undefined);
}
private async runAssistantEvent(chatKey: string, proposalId: string): Promise<string> {
const proposal = this.proposals.get(proposalId);
if (!proposal?.pending) return "";
const worker = await this.acquireAssistantWorker(chatKey);
try {
const reply = await worker.prompt(assistantEventPrompt(proposal));
const parsed = await this.parseAssistantReply(worker, reply);
if (worker.isolationViolated) throw new Error("Assistant session attempted forbidden tool activity");
await this.store.touchBinding(chatKey);
return parsed.reply;
} catch (error) {
if (worker.isolationViolated) await this.store.deleteBinding(chatKey).catch(() => undefined);
throw error;
}
}
private async acquireAssistantWorker(chatKey: string): Promise<AcpWorker> {
const existing = this.assistantWorkers.get(chatKey);
if (existing) {
existing.lastUsedAt = Date.now();
return existing;
}
await this.reserveAssistantCapacity();
const persisted = this.store.getBinding(chatKey);
const binding = persisted && this.validBinding(persisted) ? persisted : undefined;
if (persisted && !binding) await this.store.deleteBinding(chatKey);
const cwd = this.prepareAssistantWorkspace(chatKey, binding);
const spawnWorker = async (nativeSessionId?: string): Promise<AcpWorker> => {
const worker = new AcpWorker(this.bot, this.config, (crashed, error) => {
this.crashes++;
if (this.assistantWorkers.get(chatKey) === crashed) this.assistantWorkers.delete(chatKey);
console.error(`ACP assistant worker crash: ${error.message}`);
}, {
kind: "assistant",
cwd,
env: this.assistantEnv(),
policy: ASSISTANT_POLICY,
onIsolationViolation: (violating) => {
if (this.assistantWorkers.get(chatKey) === violating) this.assistantWorkers.delete(chatKey);
},
allowUnverifiedAssistantAgent: this.options.allowUnverifiedAssistantAgent
});
await worker.start(nativeSessionId);
return worker;
};
let worker: AcpWorker;
let resumed = Boolean(binding);
try {
worker = await spawnWorker(binding?.nativeSessionId);
} catch (error) {
if (!(error instanceof AcpSessionRestoreError) || !binding) throw error;
await this.store.deleteBinding(chatKey);
resumed = false;
worker = await spawnWorker();
}
this.assistantWorkers.set(chatKey, worker);
if (!resumed) {
try {
await worker.prompt(this.bot.assistantBootstrap, "initializing");
const now = Date.now();
await this.store.setBinding({
chatKey,
agentId: this.bot.agent.id,
nativeSessionId: worker.nativeSessionId!,
assistantWorkspace: cwd,
botFingerprint: this.bot.fingerprint,
createdAt: now,
updatedAt: now
});
} catch (error) {
if (this.assistantWorkers.get(chatKey) === worker) this.assistantWorkers.delete(chatKey);
await worker.terminate().catch(() => undefined);
throw error;
}
}
return worker;
}
private validBinding(binding: AssistantBinding): boolean {
return binding.botFingerprint === this.bot.fingerprint && binding.agentId === this.bot.agent.id;
}
private async reserveAssistantCapacity(): Promise<void> {
while (this.assistantWorkers.size >= this.maxAssistantSessions
|| this.assistantWorkers.size + (this.active ? 1 : 0) >= this.config.maxProcesses) {
const candidate = [...this.assistantWorkers.entries()]
.filter(([, worker]) => !worker.inFlight)
.sort((left, right) => left[1].lastUsedAt - right[1].lastUsedAt)[0];
if (!candidate) throw new Error(`Assistant session limit reached (${this.maxAssistantSessions})`);
this.assistantWorkers.delete(candidate[0]);
await candidate[1].terminate();
}
}
private async sweep(): Promise<void> {
const cutoff = Date.now() - this.config.idleTimeoutMs;
for (const [chatKey, worker] of [...this.assistantWorkers.entries()]) {
if (!worker.inFlight && worker.lastUsedAt < cutoff) {
this.assistantWorkers.delete(chatKey);
await worker.terminate().catch(() => undefined);
}
}
}
private prepareAssistantWorkspace(chatKey: string, binding?: AssistantBinding): string {
const home = path.resolve(this.options.assistantWorkspaceHome || process.env.GORI_AGENT_HOME || process.cwd());
const key = crypto.createHmac("sha256", this.assistantSalt).update(chatKey).digest("hex");
const directory = binding?.assistantWorkspace || path.join(home, "state", "assistant-workspaces", key);
return prepareAssistantWorkspace(directory, this.bot.workspace);
}
private assistantEnv(): Record<string, string> {
if (this.bot.agent.env.KIMI_CODE_HOME) return {};
if (!this.kimiHome) {
const home = this.options.kimiCodeHome
|| path.join(path.resolve(this.options.assistantWorkspaceHome || process.env.GORI_AGENT_HOME || process.cwd()), "state", "kimi", "assistant");
ensurePrivateDirectoryChain(home);
this.kimiHome = home;
}
return { KIMI_CODE_HOME: this.kimiHome };
}
private assistantUserPrompt(request: ConversationRequest): string {
return [
"[User message]",
request.text,
"",
"[Proposal states]",
...this.proposalSummaries(),
"",
"[Worker state]",
this.workerStateSummary()
].join("\n");
}
private proposalSummaries(): string[] {
const proposals = this.proposals.list();
if (proposals.length === 0) return ["none"];
return proposals.map((proposal) => {
const pending = proposal.pending
? ` pending=${proposal.pending.kind} summary=${JSON.stringify(proposal.pending.summary)}${proposal.pending.question ? ` question=${JSON.stringify(proposal.pending.question)}` : ""}`
: "";
return `- id=${proposal.id} status=${proposal.status} title=${JSON.stringify(proposal.title)}${pending}`;
});
}
private workerStateSummary(): string {
const active = this.active;
if (!active) return "no active worker";
const worker = active.worker;
const runningSeconds = worker.turnStartedAt ? Math.max(0, Math.floor((Date.now() - worker.turnStartedAt) / 1_000)) : 0;
return `proposal=${active.proposalId} phase=${worker.phase} inFlight=${worker.inFlight} runningSeconds=${runningSeconds}`;
}
private enqueueChat<T>(chatKey: string, operation: () => Promise<T>): Promise<T> {
const tail = this.chatChains.get(chatKey) || Promise.resolve();
const result = tail.then(operation, operation);
const marker = result.then(() => undefined, () => undefined);
this.chatChains.set(chatKey, marker);
void marker.then(() => { if (this.chatChains.get(chatKey) === marker) this.chatChains.delete(chatKey); });
return result;
}
private scheduler<T>(operation: () => Promise<T>): Promise<T> {
const result = this.schedulerChain.then(operation, operation);
this.schedulerChain = result.then(() => undefined, () => undefined);
return result;
}
}
export function parseAssistantActions(text: string): ParsedAssistantEnvelope | undefined {
const match = ASSISTANT_ENVELOPE.exec(text);
if (!match?.groups) return undefined;
let value: unknown;
try { value = JSON.parse(match.groups.json); } catch { return undefined; }
if (!isRecord(value) || typeof value.reply !== "string" || !Array.isArray(value.actions)) return undefined;
const actions: AssistantAction[] = [];
for (const item of value.actions) {
const action = parseAssistantAction(item);
if (!action) return undefined;
actions.push(action);
}
return { reply: value.reply, actions };
}
function parseAssistantAction(value: unknown): AssistantAction | undefined {
if (!isRecord(value) || typeof value.type !== "string") return undefined;
if (value.type === "create_proposal") {
if (typeof value.title !== "string" || value.title.length === 0
|| typeof value.goal !== "string" || value.goal.length === 0
|| !Array.isArray(value.steps) || value.steps.some((step) => typeof step !== "string" || step.length === 0)) return undefined;
return { type: "create_proposal", title: value.title, goal: value.goal, steps: value.steps as string[] };
}
if (value.type === "confirm") {
if ((value.id !== undefined && typeof value.id !== "string") || (value.answer !== undefined && typeof value.answer !== "string")) return undefined;
return { type: "confirm", id: value.id as string | undefined, answer: value.answer as string | undefined };
}
if (value.type === "start_next") return { type: "start_next" };
if (value.type === "cancel") {
if (value.id !== undefined && typeof value.id !== "string") return undefined;
return { type: "cancel", id: value.id as string | undefined };
}
if (value.type === "stop") return { type: "stop" };
return undefined;
}
export function parseWorkerResult(text: string): ParsedWorkerResult | undefined {
const match = WORKER_ENVELOPE.exec(text);
if (!match?.groups) return undefined;
let value: unknown;
try { value = JSON.parse(match.groups.json); } catch { return undefined; }
if (!isRecord(value) || typeof value.summary !== "string") return undefined;
if (value.status !== "SUCCESS" && value.status !== "NEEDS_CONFIRMATION" && value.status !== "FAILED") return undefined;
if ((value.question !== undefined && typeof value.question !== "string")
|| (value.nextStep !== undefined && typeof value.nextStep !== "string")
|| (value.dirty !== undefined && typeof value.dirty !== "boolean")) return undefined;
return {
status: value.status,
summary: value.summary,
question: value.question as string | undefined,
nextStep: value.nextStep as string | undefined,
dirty: value.dirty as boolean | undefined
};
}
function withWorkerResultProtocol(text: string): string {
return `${text}\n\nWhen this turn is finished, end your response with exactly one hidden worker result envelope. Use <GORI_WORKER_RESULT_V1>{"status":"SUCCESS","summary":"..."}</GORI_WORKER_RESULT_V1> only when the proposal is fully complete, status "FAILED" with a summary when it cannot be completed, or status "NEEDS_CONFIRMATION" with a "question" when user confirmation is required before continuing. Optional fields: "nextStep", "dirty". Do not emit any other status value or any text after the envelope.`;
}
function workerRepairPrompt(): string {
return "Your previous response did not end with a valid GORI_WORKER_RESULT_V1 envelope. Return exactly one valid envelope with status SUCCESS, NEEDS_CONFIRMATION, or FAILED and a summary. Do not perform more work.";
}
function assistantRepairPrompt(): string {
return "Your previous response did not end with a valid GORI_ASSISTANT_ACTION_V1 envelope. Reply again with the user-facing text in the JSON \"reply\" field and an \"actions\" array (empty if no state change is needed).";
}
function workerTaskPrompt(proposal: Proposal, resumeContext?: string): string {
return [
"Execute this confirmed proposal in the workspace.",
`Title: ${proposal.title}`,
`Goal: ${proposal.goal}`,
"Steps:",
...proposal.steps.map((step, index) => `${index + 1}. ${step}`),
...(resumeContext ? ["", "Additional context:", resumeContext] : []),
"",
"If you encounter a dirty or unexpected target, or anything that requires the user's decision, stop and report NEEDS_CONFIRMATION with a clear question instead of forcing the change."
].join("\n");
}
function workerAnswerPrompt(answer: string, question?: string): string {
return [
"The user answered your question about the current proposal.",
`Your question was: ${question || "unknown"}`,
`User answer: ${answer}`,
"Continue executing the same proposal accordingly."
].join("\n");
}
function assistantEventPrompt(proposal: Proposal): string {
const pending = proposal.pending!;
return [
"[Internal event from gori-agent. This is not a user message.]",
`The worker for proposal "${proposal.title}" (id ${proposal.id}) reported ${pending.kind === "step" ? "NEEDS_CONFIRMATION" : pending.kind === "success" ? "SUCCESS" : "FAILED"}.`,
`Summary: ${pending.summary}`,
pending.question ? `Question for the user: ${pending.question}` : "Question for the user: none",
pending.nextStep ? `Suggested next step: ${pending.nextStep}` : "Suggested next step: none",
"Tell the user, in your own words, what happened and what they can do next (confirm, answer the question, retry, or stop). End with the usual GORI_ASSISTANT_ACTION_V1 envelope; its actions array MUST be empty because actions are ignored for internal events."
].join("\n");
}
function fallbackEventText(proposal: Proposal): string {
const pending = proposal.pending!;
const state = pending.kind === "step" ? "等待你回答一个问题" : pending.kind === "success" ? "执行完成,待你确认" : "执行失败,待你确认或重试";
return `任务「${proposal.title}」${state}。摘要:${pending.summary}${pending.question ? ` 问题:${pending.question}` : ""}`;
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
function prepareAssistantWorkspace(directory: string, projectWorkspace: string): string {
const project = fs.realpathSync.native(projectWorkspace);
if (pathsOverlap(canonicalProspectivePath(directory), project)) throw new Error("Assistant workspace must not overlap the project workspace");
ensurePrivateDirectoryChain(directory);
const workspace = fs.realpathSync.native(directory);
const agents = ensurePrivateSubdirectories(workspace, [".kimi-code", "agents"]);
const profile = path.join(agents, "agent.md");
const temp = path.join(agents, `.agent.${process.pid}.${crypto.randomBytes(8).toString("hex")}.tmp`);
const content = `---\nname: agent\ndescription: gori-agent no-tool assistant profile\noverride: true\ntools: []\nsubagents: []\n---\nYou are the user-facing Assistant. You have no tools and cannot delegate. Never claim to inspect files, execute commands, call Skills or MCP, or see the Worker's private context. If the information in a prompt is insufficient, say so clearly.\n`;
let fd: number | undefined;
try {
fd = fs.openSync(temp, "wx", 0o600);
fs.writeFileSync(fd, content, "utf8");
fs.fsyncSync(fd);
fs.closeSync(fd);
fd = undefined;
fs.renameSync(temp, profile);
fs.chmodSync(profile, 0o600);
} catch (error) {
if (fd !== undefined) fs.closeSync(fd);
try { fs.unlinkSync(temp); } catch { /* best effort */ }
throw error;
}
return workspace;
}
function pathsOverlap(left: string, right: string): boolean {
const relativeA = path.relative(left, right);
const relativeB = path.relative(right, left);
return relativeA === "" || (!relativeA.startsWith("..") && !path.isAbsolute(relativeA))
|| (!relativeB.startsWith("..") && !path.isAbsolute(relativeB));
}
function canonicalProspectivePath(target: string): string {
const suffix: string[] = [];
let current = path.resolve(target);
while (!fs.existsSync(current)) {
const parent = path.dirname(current);
if (parent === current) throw new Error(`Cannot resolve assistant workspace path: ${target}`);
suffix.unshift(path.basename(current));
current = parent;
}
return path.join(fs.realpathSync.native(current), ...suffix);
}
function ensurePrivateDirectoryChain(target: string): void {
const pending: string[] = [];
let current = path.resolve(target);
while (!fs.existsSync(current)) {
const parent = path.dirname(current);
if (parent === current) throw new Error(`Cannot create assistant workspace path: ${target}`);
pending.unshift(path.basename(current));
current = parent;
}
assertSafeDirectory(current);
for (const segment of pending) current = createPrivateSubdirectory(current, segment);
assertPrivateDirectory(current);
}
function ensurePrivateSubdirectories(root: string, segments: string[]): string {
let current = root;
for (const segment of segments) current = createPrivateSubdirectory(current, segment);
return current;
}
function createPrivateSubdirectory(parent: string, segment: string): string {
const directory = path.join(parent, segment);
try { fs.mkdirSync(directory, { mode: 0o700 }); }
catch (error) { if ((error as NodeJS.ErrnoException).code !== "EEXIST") throw error; }
assertPrivateDirectory(directory);
return directory;
}
function assertSafeDirectory(directory: string): fs.Stats {
const stat = fs.lstatSync(directory);
if (!stat.isDirectory() || stat.isSymbolicLink()) throw new Error(`Refusing unsafe assistant workspace directory: ${directory}`);
return stat;
}
function assertPrivateDirectory(directory: string): void {
const stat = assertSafeDirectory(directory);
if (typeof process.getuid === "function" && stat.uid !== process.getuid()) throw new Error(`Assistant workspace directory is not owned by the current user: ${directory}`);
fs.chmodSync(directory, 0o700);
}
+66 -13
View File
@@ -4,10 +4,14 @@ import * as acp from "@agentclientprotocol/sdk";
import type { AgentCapabilities, InitializeResponse, RequestPermissionRequest, RequestPermissionResponse, SessionNotification } from "@agentclientprotocol/sdk";
import type { PermissionPolicy } from "../config.js";
export type SafeActivityCategory = "read" | "write" | "execute" | "search" | "delegate" | "other";
export interface AcpClientOptions {
initializeTimeoutMs: number;
policy: PermissionPolicy;
onSessionActivity?: () => void;
onSessionActivity?: (category?: SafeActivityCategory) => void;
forbidToolActivity?: boolean;
onToolViolation?: () => void;
}
export class AcpClient {
@@ -15,11 +19,16 @@ export class AcpClient {
private capabilities: AgentCapabilities = {};
private activeSessionId?: string;
private collecting = false;
private toolViolation = false;
private violationReject?: (error: Error) => void;
private chunks: string[] = [];
constructor(private readonly child: ChildProcessWithoutNullStreams, private readonly options: AcpClientOptions) {
const app = acp.client({ name: "gori-agent" })
.onRequest(acp.methods.client.session.requestPermission, ({ params }) => decidePermission(params, options.policy))
.onRequest(acp.methods.client.session.requestPermission, ({ params }) => {
if (options.forbidToolActivity) this.recordToolViolation();
return decidePermission(params, options.policy);
})
.onNotification(acp.methods.client.session.update, ({ params }) => this.handleUpdate(params));
const stream = acp.ndJsonStream(
Writable.toWeb(child.stdin) as WritableStream<Uint8Array>,
@@ -40,8 +49,13 @@ export class AcpClient {
}
async newSession(cwd: string): Promise<string> {
const response = await this.connection.agent.request(acp.methods.agent.session.new, { cwd, mcpServers: [] });
const response = await withTimeout(
this.connection.agent.request(acp.methods.agent.session.new, { cwd, mcpServers: [] }),
this.options.initializeTimeoutMs,
"ACP session/new timed out"
);
this.activeSessionId = response.sessionId;
if (this.toolViolation) throw new Error("Assistant session attempted forbidden tool activity");
return response.sessionId;
}
@@ -49,16 +63,21 @@ export class AcpClient {
this.collecting = false;
this.chunks = [];
this.activeSessionId = sessionId;
const request = <T>(promise: Promise<T>, operation: string): Promise<T> => withTimeout(
promise,
this.options.initializeTimeoutMs,
`ACP session/${operation} timed out`
);
try {
if (this.capabilities.sessionCapabilities?.resume) {
try {
await this.connection.agent.request(acp.methods.agent.session.resume, { sessionId, cwd, mcpServers: [] });
await request(this.connection.agent.request(acp.methods.agent.session.resume, { sessionId, cwd, mcpServers: [] }), "resume");
} catch (error) {
if (!this.capabilities.loadSession) throw error;
await this.connection.agent.request(acp.methods.agent.session.load, { sessionId, cwd, mcpServers: [] });
await request(this.connection.agent.request(acp.methods.agent.session.load, { sessionId, cwd, mcpServers: [] }), "load");
}
} else if (this.capabilities.loadSession) {
await this.connection.agent.request(acp.methods.agent.session.load, { sessionId, cwd, mcpServers: [] });
await request(this.connection.agent.request(acp.methods.agent.session.load, { sessionId, cwd, mcpServers: [] }), "load");
} else {
throw new Error("ACP backend cannot resume or load sessions");
}
@@ -71,19 +90,32 @@ export class AcpClient {
async prompt(text: string, cancellationSignal?: AbortSignal): Promise<string> {
if (!this.activeSessionId) throw new Error("ACP session is not active");
if (this.toolViolation) throw new Error("Assistant session attempted forbidden tool activity");
this.chunks = [];
this.collecting = true;
const violation = new Promise<never>((_resolve, reject) => { this.violationReject = reject; });
try {
await this.connection.agent.request(acp.methods.agent.session.prompt, {
sessionId: this.activeSessionId,
prompt: [{ type: "text", text }]
}, cancellationSignal ? { cancellationSignal } : undefined);
await Promise.race([
this.connection.agent.request(acp.methods.agent.session.prompt, {
sessionId: this.activeSessionId,
prompt: [{ type: "text", text }]
}, cancellationSignal ? { cancellationSignal } : undefined),
violation
]);
if (this.toolViolation) throw new Error("Assistant session attempted forbidden tool activity");
return this.chunks.join("").trim();
} finally {
this.violationReject = undefined;
this.collecting = false;
}
}
async settleIsolation(): Promise<void> {
if (!this.options.forbidToolActivity) return;
await new Promise((resolve) => setTimeout(resolve, 50));
if (this.toolViolation) throw new Error("Assistant session attempted forbidden tool activity");
}
async cancel(): Promise<void> {
if (this.activeSessionId) await this.connection.agent.notify(acp.methods.agent.session.cancel, { sessionId: this.activeSessionId });
}
@@ -97,12 +129,33 @@ export class AcpClient {
close(error?: unknown): void { this.connection.close(error); }
private handleUpdate(notification: SessionNotification): void {
if (notification.sessionId !== this.activeSessionId) return;
this.options.onSessionActivity?.();
if (!this.collecting) return;
if (this.activeSessionId && notification.sessionId !== this.activeSessionId) return;
const update = notification.update;
const category = activityCategory(update);
if (category && this.options.forbidToolActivity) this.recordToolViolation();
this.options.onSessionActivity?.(category);
if (!this.collecting || notification.sessionId !== this.activeSessionId) return;
if (update.sessionUpdate === "agent_message_chunk" && update.content.type === "text") this.chunks.push(update.content.text);
}
private recordToolViolation(): void {
if (this.toolViolation) return;
this.toolViolation = true;
this.violationReject?.(new Error("Assistant session attempted forbidden tool activity"));
this.options.onToolViolation?.();
}
}
function activityCategory(update: SessionNotification["update"]): SafeActivityCategory | undefined {
if (!String(update.sessionUpdate).startsWith("tool_call")) return undefined;
const record = update as unknown as Record<string, unknown>;
const raw = JSON.stringify({ kind: record.kind, name: record.name, title: record.title }).toLowerCase();
if (/read|view|fetch/.test(raw)) return "read";
if (/grep|glob|search|find/.test(raw)) return "search";
if (/write|edit|patch/.test(raw)) return "write";
if (/bash|terminal|execute|command/.test(raw)) return "execute";
if (/agent|delegate|subagent/.test(raw)) return "delegate";
return "other";
}
export function decidePermission(request: RequestPermissionRequest, policy: PermissionPolicy): RequestPermissionResponse {
-153
View File
@@ -1,153 +0,0 @@
import type { AcpConfig } from "../config.js";
import { chatKeyFor, type DurableSessionStore, type SessionBinding } from "../core/durable-session-store.js";
import type { ResolvedBot } from "../roles/role-registry.js";
import type { ConversationRequest, ConversationResponse, ConversationRuntime, RuntimeStats } from "./types.js";
import { AcpSessionRestoreError, AcpWorker } from "./worker.js";
export class AcpSessionManager implements ConversationRuntime {
private readonly workers = new Map<string, AcpWorker>();
private readonly inFlight = new Map<string, AcpWorker>();
private readonly sweeper: NodeJS.Timeout;
private crashes = 0;
private shuttingDown = false;
constructor(
private readonly config: AcpConfig,
private readonly bot: ResolvedBot,
private readonly store: DurableSessionStore
) {
this.sweeper = setInterval(() => void this.sweep(), config.sweepIntervalMs);
this.sweeper.unref();
}
async prompt(request: ConversationRequest): Promise<ConversationResponse> {
if (this.shuttingDown) throw new Error("ACP runtime is shutting down");
const chatKey = chatKeyFor(request.platform, request.chatId);
let binding = this.store.getBinding(chatKey);
if (binding && (binding.botFingerprint !== this.bot.fingerprint || binding.agentId !== this.bot.agent.id || binding.workspace !== this.bot.workspace)) {
await this.dropBinding(binding);
binding = undefined;
}
let worker: AcpWorker;
try { worker = await this.acquireWorker(binding); }
catch (error) {
if (!(error instanceof AcpSessionRestoreError) || !binding) throw error;
await this.store.deleteBinding(chatKey);
binding = undefined;
worker = await this.acquireWorker();
}
this.inFlight.set(chatKey, worker);
try {
if (!binding) {
const now = Date.now();
await worker.prompt(this.bot.bootstrap, "initializing");
binding = {
chatKey, agentId: this.bot.agent.id, nativeSessionId: worker.nativeSessionId!,
workspace: this.bot.workspace, botFingerprint: this.bot.fingerprint, createdAt: now, updatedAt: now
};
await this.store.setBinding(binding);
}
const text = await worker.prompt(request.text);
await this.store.touchBinding(chatKey);
return { text: text || "(ACP agent returned no text)", botId: this.bot.id, agentId: this.bot.agent.id };
} catch (error) {
if (worker.nativeSessionId) this.workers.delete(workerKey(this.bot.agent.id, worker.nativeSessionId));
await worker.terminate();
throw error;
} finally {
if (this.inFlight.get(chatKey) === worker) this.inFlight.delete(chatKey);
}
}
async cancel(platform: string, chatId: string): Promise<boolean> {
const worker = this.inFlight.get(chatKeyFor(platform, chatId));
return worker ? worker.cancel() : false;
}
async reset(platform: string, chatId: string): Promise<void> {
const chatKey = chatKeyFor(platform, chatId);
await this.cancel(platform, chatId);
const binding = await this.store.deleteBinding(chatKey);
if (binding) await this.stopWorker(binding.agentId, binding.nativeSessionId);
}
status(platform: string, chatId: string): Record<string, string | number | boolean> {
const chatKey = chatKeyFor(platform, chatId);
const binding = this.store.getBinding(chatKey);
const worker = this.inFlight.get(chatKey);
const now = Date.now();
return {
bot: this.bot.id,
agent: this.bot.agent.id,
workspace: this.bot.workspace,
persisted: Boolean(binding),
running: Boolean(worker),
phase: worker?.phase || "idle",
runningSeconds: worker?.turnStartedAt ? Math.max(0, Math.floor((now - worker.turnStartedAt) / 1000)) : 0,
idleSeconds: worker?.lastActivityAt ? Math.max(0, Math.floor((now - worker.lastActivityAt) / 1000)) : 0
};
}
stats(): RuntimeStats {
return { activeWorkers: this.workers.size, inFlight: this.inFlight.size, crashes: this.crashes, persistedBindings: this.store.stats().bindings };
}
async shutdown(): Promise<void> {
if (this.shuttingDown) return;
this.shuttingDown = true;
clearInterval(this.sweeper);
await Promise.all([...this.inFlight.values()].map((worker) => worker.cancel().catch(() => false)));
await Promise.all([...this.workers.values()].map((worker) => worker.terminate()));
this.workers.clear();
this.inFlight.clear();
}
private async acquireWorker(binding?: SessionBinding): Promise<AcpWorker> {
if (binding) {
const existing = this.workers.get(workerKey(binding.agentId, binding.nativeSessionId));
if (existing) return existing;
}
await this.ensureCapacity();
const worker = new AcpWorker(this.bot, this.config, (crashed, error) => {
this.crashes++;
if (crashed.nativeSessionId) this.workers.delete(workerKey(this.bot.agent.id, crashed.nativeSessionId));
console.error(`ACP worker crash: ${error.message}`);
});
const nativeSessionId = await worker.start(binding?.nativeSessionId);
this.workers.set(workerKey(this.bot.agent.id, nativeSessionId), worker);
return worker;
}
private async ensureCapacity(): Promise<void> {
if (this.workers.size < this.config.maxProcesses) return;
const candidate = [...this.workers.entries()].filter(([, worker]) => !worker.inFlight).sort((a, b) => a[1].lastUsedAt - b[1].lastUsedAt)[0];
if (!candidate) throw new Error(`ACP worker limit reached (${this.config.maxProcesses})`);
this.workers.delete(candidate[0]);
await candidate[1].terminate();
}
private async sweep(): Promise<void> {
const cutoff = Date.now() - this.config.idleTimeoutMs;
const expired = [...this.workers.entries()].filter(([, worker]) => !worker.inFlight && worker.lastUsedAt < cutoff);
for (const [key, worker] of expired) {
this.workers.delete(key);
await worker.terminate();
}
}
private async dropBinding(binding: SessionBinding): Promise<void> {
await this.store.deleteBinding(binding.chatKey);
await this.stopWorker(binding.agentId, binding.nativeSessionId);
}
private async stopWorker(agentId: string, nativeSessionId: string): Promise<void> {
const key = workerKey(agentId, nativeSessionId);
const worker = this.workers.get(key);
if (!worker) return;
this.workers.delete(key);
await worker.terminate();
}
}
function workerKey(agentId: string, nativeSessionId: string): string { return `${agentId}:${nativeSessionId}`; }
+8
View File
@@ -1,4 +1,5 @@
import type { PermissionPolicy } from "../config.js";
import type { Proposal } from "../core/proposal-store.js";
export interface AcpBackendSpec {
id: string;
@@ -19,6 +20,7 @@ export interface ConversationResponse {
text: string;
botId: string;
agentId: string;
mode?: "assistant";
}
export interface RuntimeStats {
@@ -28,6 +30,8 @@ export interface RuntimeStats {
persistedBindings: number;
}
export type RuntimeEventSink = (chatKey: string, text: string) => Promise<void>;
export interface ConversationRuntime {
prompt(request: ConversationRequest): Promise<ConversationResponse>;
cancel(platform: string, chatId: string): Promise<boolean>;
@@ -35,6 +39,10 @@ export interface ConversationRuntime {
status(platform: string, chatId: string): Record<string, string | number | boolean>;
stats(): RuntimeStats;
shutdown(): Promise<void>;
listProposals?(platform: string, chatId: string): Proposal[];
confirm?(platform: string, chatId: string): Promise<boolean>;
stop?(platform: string, chatId: string): Promise<boolean>;
setEventSink?(sink: RuntimeEventSink): void;
}
export interface PermissionContext {
+140 -30
View File
@@ -1,11 +1,26 @@
import { spawn, type ChildProcessWithoutNullStreams } from "node:child_process";
import type { AcpConfig } from "../config.js";
import crypto from "node:crypto";
import fs from "node:fs";
import type { AcpConfig, PermissionPolicy } from "../config.js";
import type { WorkerProcessGroup } from "../core/proposal-store.js";
import type { ResolvedBot } from "../roles/role-registry.js";
import { AcpClient } from "./client.js";
import { AcpClient, type SafeActivityCategory } from "./client.js";
export class AcpSessionRestoreError extends Error {}
export type AcpWorkerPhase = "idle" | "initializing" | "processing";
export type AcpWorkerKind = "assistant" | "worker";
export interface AcpWorkerOptions {
kind?: AcpWorkerKind;
cwd?: string;
env?: Record<string, string>;
policy?: PermissionPolicy;
onActivity?: (category?: SafeActivityCategory) => void;
onIsolationViolation?: (worker: AcpWorker) => void;
onSpawn?: (group: WorkerProcessGroup) => Promise<void>;
allowUnverifiedAssistantAgent?: boolean;
}
export class AcpWorker {
private child?: ChildProcessWithoutNullStreams;
@@ -13,8 +28,13 @@ export class AcpWorker {
private abort?: AbortController;
private exited = false;
private stopping = false;
private termination?: Promise<void>;
private stderrBytes = 0;
readonly kind: AcpWorkerKind;
readonly cwd: string;
nativeSessionId?: string;
processGroup?: WorkerProcessGroup;
isolationViolated = false;
lastUsedAt = Date.now();
turnStartedAt?: number;
lastActivityAt?: number;
@@ -24,14 +44,29 @@ export class AcpWorker {
constructor(
readonly bot: ResolvedBot,
private readonly config: AcpConfig,
private readonly onCrash: (worker: AcpWorker, error: Error) => void
) {}
private readonly onCrash: (worker: AcpWorker, error: Error) => void,
private readonly options: AcpWorkerOptions = {}
) {
this.kind = options.kind || "worker";
this.cwd = options.cwd || bot.workspace;
}
static async terminatePersistedGroup(group: WorkerProcessGroup, timeoutMs: number): Promise<void> {
if (!processGroupExists(group.pgid)) return;
if (!processGroupHasToken(group.pgid, group.token)) {
throw new Error(`Persisted ACP process group ${group.pgid} no longer matches its worker token`);
}
signalProcessGroup(group.pgid, "SIGKILL");
await waitForProcessGroupExit(group.pgid, timeoutMs);
}
async start(nativeSessionId?: string): Promise<string> {
const processToken = crypto.randomUUID();
this.child = spawn(this.bot.agent.command, this.bot.agent.args, {
cwd: this.bot.workspace,
env: { ...process.env, ...this.bot.agent.env },
cwd: this.cwd,
env: { ...process.env, ...this.bot.agent.env, ...this.options.env, GORI_AGENT_WORKER_TOKEN: processToken },
shell: false,
detached: true,
stdio: ["pipe", "pipe", "pipe"]
});
this.child.stderr.on("data", (chunk: Buffer) => this.logStderr(chunk));
@@ -42,16 +77,31 @@ export class AcpWorker {
});
this.client = new AcpClient(this.child, {
initializeTimeoutMs: this.config.initializeTimeoutMs,
policy: this.bot.permissions,
onSessionActivity: () => { this.lastActivityAt = Date.now(); }
policy: this.options.policy || this.bot.permissions,
forbidToolActivity: this.kind === "assistant",
onToolViolation: () => {
this.isolationViolated = true;
this.options.onIsolationViolation?.(this);
this.terminateImmediately();
},
onSessionActivity: (category) => {
this.lastActivityAt = Date.now();
this.options.onActivity?.(category);
}
});
try {
await this.client.initialize();
if (!this.child.pid) throw new Error("ACP worker has no process ID");
this.processGroup = { pgid: this.child.pid, token: processToken };
await this.options.onSpawn?.(this.processGroup);
const initialized = await this.client.initialize();
if (this.kind === "assistant" && !this.options.allowUnverifiedAssistantAgent && initialized.agentInfo?.name !== "Kimi Code CLI") {
throw new Error("Assistant sessions require Kimi Code ACP with execution-layer no-tool profiles");
}
if (nativeSessionId) {
await this.client.resumeSession(nativeSessionId, this.bot.workspace);
await this.client.resumeSession(nativeSessionId, this.cwd);
this.nativeSessionId = nativeSessionId;
} else {
this.nativeSessionId = await this.client.newSession(this.bot.workspace);
this.nativeSessionId = await this.client.newSession(this.cwd);
}
return this.nativeSessionId;
} catch (error) {
@@ -81,7 +131,9 @@ export class AcpWorker {
}, this.config.promptTimeoutMs);
});
try {
return await Promise.race([this.client.prompt(text, this.abort.signal), timeoutPromise]);
const result = await Promise.race([this.client.prompt(text, this.abort.signal), timeoutPromise]);
if (this.kind === "assistant") await this.client.settleIsolation();
return result;
} finally {
if (timeout) clearTimeout(timeout);
this.inFlight = false;
@@ -101,24 +153,31 @@ export class AcpWorker {
return true;
}
async terminate(): Promise<void> {
if (this.stopping) return;
terminateImmediately(): void {
this.stopping = true;
if (this.client && !this.exited) {
await Promise.race([
this.client.closeSession().catch(() => undefined),
new Promise<void>((resolve) => setTimeout(resolve, this.config.cancelGraceMs))
]);
}
this.abort?.abort();
this.client?.close(new Error("ACP worker terminated because workspace ownership was lost"));
if (this.child) killProcessGroup(this.child, "SIGKILL");
this.termination ||= this.finishTermination("SIGKILL");
}
terminate(): Promise<void> {
if (this.termination) return this.termination;
this.stopping = true;
this.abort?.abort();
this.client?.close();
if (this.child && !this.exited) {
this.child.kill("SIGTERM");
await waitForExit(this.child, this.config.cancelGraceMs);
if (!this.exited) {
this.child.kill("SIGKILL");
await waitForExit(this.child, this.config.cancelGraceMs);
}
}
this.termination = this.finishTermination("SIGTERM");
return this.termination;
}
private async finishTermination(initialSignal: NodeJS.Signals): Promise<void> {
if (!this.child?.pid) return;
const pid = this.child.pid;
killProcessGroup(this.child, initialSignal);
await waitForExit(this.child, this.config.cancelGraceMs);
killProcessGroup(this.child, "SIGKILL");
await waitForExit(this.child, this.config.cancelGraceMs);
await waitForProcessGroupExit(pid, this.config.cancelGraceMs);
}
private logStderr(chunk: Buffer): void {
@@ -126,16 +185,49 @@ export class AcpWorker {
if (!remaining) return;
const text = chunk.subarray(0, remaining).toString("utf8").trimEnd();
this.stderrBytes += Buffer.byteLength(text);
if (text) console.error(`[acp:${this.bot.agent.id}] ${text}`);
if (text) console.error(`[acp:${this.kind}:${this.bot.agent.id}] ${text}`);
}
private crashed(error: Error): void {
if (this.stopping) return;
this.stopping = true;
this.onCrash(this, error);
this.abort?.abort();
this.client?.close(error);
this.termination = this.finishTermination("SIGKILL");
void this.termination.then(
() => this.onCrash(this, error),
(cleanupError) => this.onCrash(this, new Error(`${error.message}; process-group cleanup failed: ${cleanupError instanceof Error ? cleanupError.message : String(cleanupError)}`))
);
}
}
function killProcessGroup(child: ChildProcessWithoutNullStreams, signal: NodeJS.Signals): void {
if (child.pid) signalProcessGroup(child.pid, signal);
}
function signalProcessGroup(pgid: number, signal: NodeJS.Signals): void {
try { process.kill(-pgid, signal); }
catch (error) { if ((error as NodeJS.ErrnoException).code !== "ESRCH") throw error; }
}
function processGroupHasToken(pgid: number, token: string): boolean {
for (const entry of fs.readdirSync("/proc")) {
if (!/^\d+$/.test(entry)) continue;
try {
const stat = fs.readFileSync(`/proc/${entry}/stat`, "utf8");
const close = stat.lastIndexOf(")");
const fields = stat.slice(close + 2).split(" ");
if (Number(fields[2]) !== pgid) continue;
const environ = fs.readFileSync(`/proc/${entry}/environ`);
if (environ.toString("utf8").split("\0").includes(`GORI_AGENT_WORKER_TOKEN=${token}`)) return true;
} catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code !== "ENOENT" && code !== "EACCES" && code !== "EPERM") throw error;
}
}
return false;
}
function waitForExit(child: ChildProcessWithoutNullStreams, timeoutMs: number): Promise<void> {
if (child.exitCode !== null || child.signalCode !== null) return Promise.resolve();
return new Promise((resolve) => {
@@ -143,3 +235,21 @@ function waitForExit(child: ChildProcessWithoutNullStreams, timeoutMs: number):
child.once("close", () => { clearTimeout(timer); resolve(); });
});
}
async function waitForProcessGroupExit(pid: number, timeoutMs: number): Promise<void> {
const deadline = Date.now() + timeoutMs;
while (processGroupExists(pid) && Date.now() < deadline) {
await new Promise((resolve) => setTimeout(resolve, 10));
}
if (processGroupExists(pid)) throw new Error(`ACP process group ${pid} did not terminate`);
}
function processGroupExists(pid: number): boolean {
try { process.kill(-pid, 0); return true; }
catch (error) {
const code = (error as NodeJS.ErrnoException).code;
if (code === "ESRCH") return false;
if (code === "EPERM") return true;
throw error;
}
}
+6
View File
@@ -1,3 +1,4 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
import process from "node:process";
@@ -33,6 +34,10 @@ export function loadExampleConfig(): AppConfig {
return parseConfig(JSON.parse(fs.readFileSync(examplePath, "utf8")) as unknown);
}
export function configDigest(config: AppConfig): string {
return crypto.createHash("sha256").update(JSON.stringify(config)).digest("hex");
}
export function writeConfigFile(configPath: string, config: AppConfig): void {
const parsed = parseConfig(config);
const directory = path.dirname(configPath);
@@ -62,6 +67,7 @@ export function operationalConfigProblems(config: AppConfig): string[] {
if (isPlaceholder(config.bot.workspace) || config.bot.workspace.includes("/absolute/path/")) problems.push("bot.workspace is still a placeholder");
if (isPlaceholder(config.bot.agent.command) || config.bot.agent.command.includes("/home/USER/")) problems.push("bot.agent.command is still a placeholder");
else if (!isExecutable(config.bot.agent.command)) problems.push("bot.agent.command is not executable");
if (config.bot.agent.args.length !== 1 || config.bot.agent.args[0] !== "acp") problems.push("bot.agent.args must be exactly ['acp'] so the no-tool side profile cannot be bypassed");
try {
if (!fs.statSync(config.bot.workspace).isDirectory()) problems.push("bot.workspace must be an existing directory");
} catch { problems.push("bot.workspace must be an existing directory"); }
+8
View File
@@ -3,6 +3,7 @@ import path from "node:path";
import { probeAcpBackend } from "../acp/probe.js";
import { defaultStateFile, type AppConfig } from "../config.js";
import { BotProfileResolver } from "../roles/role-registry.js";
import { findOverlappingWorkspace } from "../core/workspace-scope.js";
import { operationalConfigProblems } from "./config-file.js";
import { printUrlHints } from "./net.js";
@@ -25,6 +26,13 @@ export async function runDoctor(config: AppConfig, configPath: string): Promise<
try { new BotProfileResolver(config); } catch (error) {
problems += reportError(error instanceof Error ? error.message : String(error));
}
try {
const instancesDirectory = path.dirname(path.dirname(configPath));
const conflict = findOverlappingWorkspace(config.bot.workspace, config.bot.id, instancesDirectory);
if (conflict) problems += reportError(`bot '${conflict}' declares an identical, parent, or child workspace; workspace scopes must be disjoint`);
} catch (error) {
problems += reportError(`cannot validate peer workspaces: ${error instanceof Error ? error.message : String(error)}`);
}
if (config.gateway.platform.type === "qq") console.log(`QQ connection mode: ${config.gateway.platform.connectionMode}`);
if (config.bot.permissions.mode === "auto") console.log("WARN: bot auto-approves every permission request.");
if (config.bot.permissions.mode === "allowlist" && config.bot.permissions.allowedTools.some((tool) => ["bash", "terminal"].includes(tool.toLowerCase())) && config.bot.permissions.allowedCommandPatterns.length === 0) {
+24 -1
View File
@@ -1,8 +1,12 @@
#!/usr/bin/env node
import os from "node:os";
import path from "node:path";
import process from "node:process";
import { pathToFileURL } from "node:url";
import { assertOperationalConfig, loadConfigFile } from "./config-file.js";
import type { AppConfig } from "../config.js";
import { findOverlappingWorkspace } from "../core/workspace-scope.js";
import { startServer } from "../server.js";
import { assertOperationalConfig, configDigest, loadConfigFile } from "./config-file.js";
interface ShutdownTarget { shutdown(): Promise<void> }
interface SignalEmitter {
@@ -14,9 +18,28 @@ export async function runInstanceRunner(argv: string[]): Promise<number> {
if (argv.length !== 1) throw new Error("instance-runner requires exactly one config path");
const loaded = loadConfigFile(argv[0]);
assertOperationalConfig(loaded.config, loaded.path);
assertRunnerConfigSnapshot(loaded.config);
assertRunnerWorkspaceSafety(loaded.config, loaded.path);
return waitForShutdown(await startServer(loaded.config));
}
export function assertRunnerConfigSnapshot(config: AppConfig, expected = process.env.GORI_AGENT_CONFIG_DIGEST): void {
if (!expected || expected !== configDigest(config)) {
throw new Error("instance config changed after start validation; refusing to launch");
}
}
export function assertRunnerWorkspaceSafety(config: AppConfig, configFile: string, root = agentRoot()): void {
const expected = path.join(root, "instances", config.bot.id, "config.json");
if (path.resolve(configFile) !== expected) throw new Error("instance-runner config path does not match the instance directory contract");
const conflict = findOverlappingWorkspace(config.bot.workspace, config.bot.id, path.join(root, "instances"));
if (conflict) throw new Error(`Refusing to start: bot '${conflict}' declares an identical, parent, or child workspace; workspace scopes must be disjoint`);
}
function agentRoot(): string {
return path.resolve(process.env.GORI_AGENT_ROOT || path.join(os.homedir(), ".gori-agent"));
}
export function waitForShutdown(running: ShutdownTarget, signals: SignalEmitter = process): Promise<number> {
return new Promise<number>((resolve) => {
let stopping = false;
+102 -45
View File
@@ -6,12 +6,14 @@ import path from "node:path";
import process from "node:process";
import { fileURLToPath } from "node:url";
import { defaultStateFile } from "../config.js";
import { assertOperationalConfig, loadConfigFile } from "./config-file.js";
import { findOverlappingWorkspace } from "../core/workspace-scope.js";
import { assertOperationalConfig, configDigest, loadConfigFile } from "./config-file.js";
import { runDoctor } from "./doctor.js";
import { localBaseUrl } from "./net.js";
import { runSetup } from "./setup.js";
const BOT_ID_PATTERN = /^[a-z0-9](?:[a-z0-9-]{0,62})$/;
const LIFECYCLE_FLOCK = "/usr/bin/flock";
export function agentRoot(): string {
return path.resolve(process.env.GORI_AGENT_ROOT || path.join(os.homedir(), ".gori-agent"));
@@ -64,58 +66,70 @@ async function initInstance(botId: string): Promise<number> {
}
async function setupInstance(botId: string): Promise<number> {
const loaded = loadInstance(botId);
assertConfigMode(loaded.path);
loadInstance(botId);
const paths = runtimePaths(botId);
ensureInstanceDirectories(paths.home, false);
const current = inspectPid(paths.pidFile, loaded.path);
assertSetupPidSafe(botId, current);
if (current.kind === "stale") removeStalePid(paths.pidFile, current);
const written = await runSetup(loaded.path, { botId, instancesDirectory: instancesRoot() });
if (!written) return 0;
return doctorInstance(botId);
return withInstanceLifecycleLock(paths.home, async () => {
const loaded = loadInstance(botId);
assertConfigMode(loaded.path);
const current = inspectPid(paths.pidFile, loaded.path);
assertSetupPidSafe(botId, current);
if (current.kind === "stale") removeStalePid(paths.pidFile, current);
const written = await runSetup(loaded.path, { botId, instancesDirectory: instancesRoot() });
if (!written) return 0;
return doctorInstance(botId);
});
}
async function startInstance(botId: string): Promise<number> {
const loaded = loadInstance(botId);
assertOperationalConfig(loaded.config, loaded.path);
loadInstance(botId);
const paths = runtimePaths(botId);
ensureInstanceDirectories(paths.home, false);
const current = inspectPid(paths.pidFile, loaded.path);
if (current.kind === "running") throw new Error(`Instance '${botId}' is already running (pid ${current.pid})`);
if (current.kind === "foreign") throw new Error(`Refusing to start: PID file references unrelated live process ${current.pid}`);
if (current.kind === "stale") removeStalePid(paths.pidFile, current);
await assertPortAvailable(loaded.config.gateway.server.host, loaded.config.gateway.server.port);
return withInstanceLifecycleLock(paths.home, async () => {
const loaded = loadInstance(botId);
assertOperationalConfig(loaded.config, loaded.path);
assertNoOverlappingWorkspace(loaded.config.bot.workspace, botId);
fs.accessSync(LIFECYCLE_FLOCK, fs.constants.X_OK);
const current = inspectPid(paths.pidFile, loaded.path);
if (current.kind === "running") throw new Error(`Instance '${botId}' is already running (pid ${current.pid})`);
if (current.kind === "foreign") throw new Error(`Refusing to start: PID file references unrelated live process ${current.pid}`);
if (current.kind === "stale") removeStalePid(paths.pidFile, current);
await assertPortAvailable(loaded.config.gateway.server.host, loaded.config.gateway.server.port);
const logFd = fs.openSync(paths.logFile, "a", 0o600);
fs.chmodSync(paths.logFile, 0o600);
const runnerFile = instanceRunnerPath();
let child: ReturnType<typeof spawn> | undefined;
try {
child = spawn(process.execPath, [runnerFile, loaded.path], {
cwd: paths.home,
env: { ...process.env, GORI_AGENT_HOME: paths.home },
detached: true,
stdio: ["ignore", logFd, logFd]
});
await new Promise<void>((resolve, reject) => {
child!.once("spawn", resolve);
child!.once("error", reject);
});
if (!child.pid) throw new Error("Instance child process has no PID");
atomicWriteMode(paths.pidFile, `${child.pid}\n`, 0o600);
await waitForHealthyInstance(child.pid, loaded.config.gateway.platform.type, botId, loaded.config);
child.unref();
console.log(`Instance '${botId}' started (pid ${child.pid})`);
console.log(`Log: ${paths.logFile}`);
return 0;
} catch (error) {
if (child?.pid) {
await terminateStartedChild(child.pid);
removePidIfOwned(paths.pidFile, child.pid);
}
throw new Error(`Instance '${botId}' failed to start; inspect ${paths.logFile}: ${error instanceof Error ? error.message : String(error)}`);
} finally { fs.closeSync(logFd); }
const logFd = fs.openSync(paths.logFile, "a", 0o600);
fs.chmodSync(paths.logFile, 0o600);
const runnerFile = instanceRunnerPath();
let child: ReturnType<typeof spawn> | undefined;
try {
child = spawn(process.execPath, [runnerFile, loaded.path], {
cwd: paths.home,
env: {
...process.env,
GORI_AGENT_HOME: paths.home,
GORI_AGENT_CONFIG_DIGEST: configDigest(loaded.config)
},
detached: true,
stdio: ["ignore", logFd, logFd]
});
await new Promise<void>((resolve, reject) => {
child!.once("spawn", resolve);
child!.once("error", reject);
});
if (!child.pid) throw new Error("Instance child process has no PID");
atomicWriteMode(paths.pidFile, `${child.pid}\n`, 0o600);
await waitForHealthyInstance(child.pid, loaded.config.gateway.platform.type, botId, loaded.config);
child.unref();
console.log(`Instance '${botId}' started (pid ${child.pid})`);
console.log(`Log: ${paths.logFile}`);
return 0;
} catch (error) {
if (child?.pid) {
await terminateStartedChild(child.pid);
removePidIfOwned(paths.pidFile, child.pid);
}
throw new Error(`Instance '${botId}' failed to start; inspect ${paths.logFile}: ${error instanceof Error ? error.message : String(error)}`);
} finally { fs.closeSync(logFd); }
});
}
async function stopInstance(botId: string): Promise<number> {
@@ -362,6 +376,49 @@ async function waitForHealthyInstance(pid: number, platform: string, botId: stri
throw new Error(`health check timed out: ${lastError}`);
}
function assertNoOverlappingWorkspace(workspace: string, botId: string): void {
const conflict = findOverlappingWorkspace(workspace, botId, instancesRoot());
if (conflict) throw new Error(`Refusing to start: bot '${conflict}' declares an identical, parent, or child workspace; workspace scopes must be disjoint`);
}
async function withInstanceLifecycleLock<T>(home: string, operation: () => Promise<T>): Promise<T> {
const lockFile = path.join(home, "state", "instance.lifecycle.lock");
const fd = fs.openSync(lockFile, "a", 0o600);
fs.closeSync(fd);
fs.chmodSync(lockFile, 0o600);
const holder = spawn(LIFECYCLE_FLOCK, ["-n", lockFile, process.execPath, "-e", "process.stdout.write('READY\\n'); process.stdin.resume()"], {
shell: false,
stdio: ["pipe", "pipe", "pipe"]
});
try {
await new Promise<void>((resolve, reject) => {
let output = "";
const timer = setTimeout(() => reject(new Error("Timed out acquiring instance lifecycle lock")), 2_000);
const finish = (error?: Error): void => {
clearTimeout(timer);
holder.stdout.removeAllListeners("data");
holder.removeAllListeners("error");
holder.removeAllListeners("close");
error ? reject(error) : resolve();
};
holder.stdout.on("data", (chunk: Buffer) => {
output += chunk.toString("utf8");
if (output.includes("READY\n")) finish();
});
holder.once("error", (error) => finish(error));
holder.once("close", () => finish(new Error("Another setup or start operation is already running for this instance")));
});
return await operation();
} finally {
holder.stdin.end();
if (holder.exitCode === null && holder.signalCode === null) holder.kill("SIGTERM");
await new Promise<void>((resolve) => {
if (holder.exitCode !== null || holder.signalCode !== null) resolve();
else holder.once("close", () => resolve());
});
}
}
function delay(ms: number): Promise<void> { return new Promise((resolve) => setTimeout(resolve, ms)); }
async function terminateStartedChild(pid: number): Promise<void> {
+7 -2
View File
@@ -83,11 +83,12 @@ const platformSchema = z.discriminatedUnion("type", [feishuSchema, wecomSchema,
const acpSchema = z.object({
stateFile: z.string().default(""),
initializeTimeoutMs: z.number().int().positive().default(10_000),
promptTimeoutMs: z.number().int().positive().default(7_200_000),
promptTimeoutMs: z.number().int().positive().default(14_400_000),
cancelGraceMs: z.number().int().positive().default(5_000),
idleTimeoutMs: z.number().int().positive().default(1_800_000),
sweepIntervalMs: z.number().int().positive().default(60_000),
maxProcesses: z.number().int().positive().default(8)
maxProcesses: z.number().int().positive().default(8),
maxAssistantSessions: z.number().int().positive().default(4)
}).strict();
export const configSchema = z.object({
@@ -154,6 +155,10 @@ export function defaultStateFile(config: AppConfig): string {
return path.join(path.resolve(home), "state", "acp-sessions.json");
}
export function defaultProposalFile(config: AppConfig): string {
return path.join(path.dirname(defaultStateFile(config)), "proposals.json");
}
export function loadConfig(configPath = process.env.GORI_GATEWAY_CONFIG): AppConfig {
return loadConfigFromPath(resolveConfigPath(configPath));
}
+4 -6
View File
@@ -1,4 +1,4 @@
export type CommandKind = "help" | "status" | "cancel" | "new" | "retired-role";
export type CommandKind = "help" | "status" | "list" | "confirm" | "stop" | "cancel";
export interface ParsedCommand {
kind: CommandKind;
@@ -12,12 +12,10 @@ export class CommandRouter {
switch (command.toLowerCase()) {
case "/help": return { kind: "help" };
case "/status": return { kind: "status" };
case "/list": return { kind: "list" };
case "/confirm": return { kind: "confirm" };
case "/stop": return { kind: "stop" };
case "/cancel": return { kind: "cancel" };
case "/new": return { kind: "new" };
case "/roles":
case "/role":
case "/agents":
case "/agent": return { kind: "retired-role" };
default: return undefined;
}
}
+12 -12
View File
@@ -6,21 +6,21 @@ export interface StoreIdentity {
platform: string;
}
export interface SessionBinding {
export interface AssistantBinding {
chatKey: string;
agentId: string;
nativeSessionId: string;
workspace: string;
assistantWorkspace: string;
botFingerprint: string;
createdAt: number;
updatedAt: number;
}
interface StoreData {
version: 2;
version: 3;
botId: string;
platform: string;
bindings: Record<string, SessionBinding>;
bindings: Record<string, AssistantBinding>;
}
export class DurableSessionStore {
@@ -76,12 +76,12 @@ export class DurableSessionStore {
}
}
getBinding(chatKey: string): SessionBinding | undefined {
getBinding(chatKey: string): AssistantBinding | undefined {
const value = this.data.bindings[chatKey];
return value ? structuredClone(value) : undefined;
}
async setBinding(binding: SessionBinding): Promise<void> {
async setBinding(binding: AssistantBinding): Promise<void> {
this.data.bindings[binding.chatKey] = structuredClone(binding);
await this.persist();
}
@@ -93,7 +93,7 @@ export class DurableSessionStore {
await this.persist();
}
async deleteBinding(chatKey: string): Promise<SessionBinding | undefined> {
async deleteBinding(chatKey: string): Promise<AssistantBinding | undefined> {
const existing = this.data.bindings[chatKey];
delete this.data.bindings[chatKey];
if (existing) await this.persist();
@@ -133,23 +133,23 @@ export class DurableSessionStore {
export function chatKeyFor(platform: string, chatId: string): string { return `${platform}:${chatId}`; }
function emptyData(identity: StoreIdentity): StoreData {
return { version: 2, botId: identity.botId, platform: identity.platform, bindings: {} };
return { version: 3, botId: identity.botId, platform: identity.platform, bindings: {} };
}
function parseStoreData(value: unknown): StoreData {
if (!isRecord(value) || value.version !== 2 || typeof value.botId !== "string" || typeof value.platform !== "string" || !isRecord(value.bindings)) {
throw new Error("unsupported state version; Config v3 requires state v2 in a new GORI_AGENT_HOME");
if (!isRecord(value) || value.version !== 3 || typeof value.botId !== "string" || typeof value.platform !== "string" || !isRecord(value.bindings)) {
throw new Error("unsupported state version; Config v3 requires state v3 in a new GORI_AGENT_HOME");
}
for (const [chatKey, binding] of Object.entries(value.bindings)) {
if (!isRecord(binding)
|| binding.chatKey !== chatKey
|| typeof binding.agentId !== "string"
|| typeof binding.nativeSessionId !== "string"
|| typeof binding.workspace !== "string"
|| typeof binding.assistantWorkspace !== "string"
|| typeof binding.botFingerprint !== "string"
|| typeof binding.createdAt !== "number"
|| typeof binding.updatedAt !== "number") {
throw new Error(`invalid state v2 binding '${chatKey}'`);
throw new Error(`invalid state v3 binding '${chatKey}'`);
}
}
return value as unknown as StoreData;
+87 -196
View File
@@ -3,35 +3,13 @@ import type { GatewayPolicy } from "../config.js";
import type { PlatformAdapter } from "./adapter.js";
import { CommandRouter, type ParsedCommand } from "./command-router.js";
import { chatKeyFor } from "./durable-session-store.js";
import type { IncomingMessage } from "./types.js";
import type { IncomingMessage, MessageTarget } from "./types.js";
export interface GatewayResult { ok: boolean; reply?: string; ignored?: boolean; error?: string }
export interface GatewayTimer { cancel(): void }
export interface GatewayOptions {
now?: () => number;
schedule?: (callback: () => void, delayMs: number) => GatewayTimer;
}
interface ChatWorkState {
running: boolean;
queued: number;
startedAt?: number;
}
interface AsyncInbound {
phase: "queued" | "running" | "finished";
receivedAt: number;
initialNoticeSent: boolean;
queuedNoticeSent: boolean;
timer?: GatewayTimer;
stream: ReplyStream;
}
interface LockedResult {
result: GatewayResult;
delivery: Promise<void>;
interface ChatTarget {
target: MessageTarget;
adapter: PlatformAdapter;
}
class ReplyStream {
@@ -60,22 +38,14 @@ class ReplyStream {
}
export class Gateway {
private readonly locks = new Map<string, Promise<void>>();
private readonly chatWork = new Map<string, ChatWorkState>();
private readonly asyncInbounds = new Set<AsyncInbound>();
private readonly now: () => number;
private readonly schedule: (callback: () => void, delayMs: number) => GatewayTimer;
private readonly chatTargets = new Map<string, ChatTarget>();
private closed = false;
readonly commandRouter = new CommandRouter();
constructor(
private readonly policy: GatewayPolicy,
private readonly runtime: ConversationRuntime,
options: GatewayOptions = {}
) {
this.now = options.now || Date.now;
this.schedule = options.schedule || defaultSchedule;
}
private readonly runtime: ConversationRuntime
) {}
async receive(message: IncomingMessage, adapter: PlatformAdapter, options: { synchronous?: boolean } = {}): Promise<GatewayResult> {
const policyError = this.checkPolicy(message);
@@ -84,82 +54,74 @@ export class Gateway {
return { ok: true, ignored: true, error: policyError };
}
const command = this.commandRouter.parse(message.text);
if (command?.kind === "help" || command?.kind === "status" || command?.kind === "cancel") {
return this.executeAndReply(command, message, adapter, options);
}
this.chatTargets.set(chatKeyFor(message.platform, message.chatId), {
target: {
platform: message.platform,
chatId: message.chatId,
userId: message.userId,
raw: message.raw
},
adapter
});
const chatKey = chatKeyFor(message.platform, message.chatId);
if (command || options.synchronous) {
const result = await this.withChatLock(chatKey, () => this.executeSynchronous(command, message));
const command = this.commandRouter.parse(message.text);
if (command) {
const result = await this.executeCommandResult(command, message);
if (!options.synchronous) await new ReplyStream(message, adapter).enqueue(result.reply || "");
return result;
}
const inbound: AsyncInbound = {
phase: "queued",
receivedAt: this.now(),
initialNoticeSent: false,
queuedNoticeSent: false,
stream: new ReplyStream(message, adapter)
};
this.asyncInbounds.add(inbound);
this.armTimer(inbound, inbound.receivedAt + 15_000);
if (options.synchronous) return this.promptResult(message);
const locked = await this.withChatLock(chatKey, async () => {
inbound.phase = "running";
if (inbound.queuedNoticeSent) void inbound.stream.enqueue("轮到这件事了,我现在开始处理。");
try {
const reply = (await this.runtime.prompt({
platform: message.platform,
chatId: message.chatId,
userId: message.userId,
text: message.text,
messageId: message.messageId
})).text;
this.finishInbound(inbound);
return { result: { ok: true, reply }, delivery: inbound.stream.enqueue(reply) };
} catch (error) {
const errorText = error instanceof Error ? error.message : String(error);
const reply = `Agent error: ${errorText}`;
this.finishInbound(inbound);
return { result: { ok: false, error: errorText, reply }, delivery: inbound.stream.enqueue(reply) };
}
});
await locked.delivery;
return locked.result;
try {
const reply = (await this.runtime.prompt({
platform: message.platform,
chatId: message.chatId,
userId: message.userId,
text: message.text,
messageId: message.messageId
})).text;
await new ReplyStream(message, adapter).enqueue(reply);
return { ok: true, reply };
} catch (error) {
const errorText = error instanceof Error ? error.message : String(error);
const reply = `Agent error: ${errorText}`;
await new ReplyStream(message, adapter).enqueue(reply);
return { ok: false, error: errorText, reply };
}
}
stats(): ReturnType<ConversationRuntime["stats"]> & { lockedChats: number } {
return { ...this.runtime.stats(), lockedChats: this.locks.size };
async sendEvent(chatKey: string, text: string): Promise<void> {
if (this.closed) return;
const entry = this.chatTargets.get(chatKey);
if (!entry) return;
try {
await entry.adapter.sendMessage({ target: entry.target, text });
} catch {
console.error(`Gateway event send failed (platform=${entry.target.platform})`);
}
}
stats(): ReturnType<ConversationRuntime["stats"]> {
return this.runtime.stats();
}
shutdown(): void {
if (this.closed) return;
this.closed = true;
for (const inbound of this.asyncInbounds) {
inbound.phase = "finished";
inbound.timer?.cancel();
inbound.timer = undefined;
}
this.asyncInbounds.clear();
}
private async executeAndReply(
command: ParsedCommand,
message: IncomingMessage,
adapter: PlatformAdapter,
options: { synchronous?: boolean }
): Promise<GatewayResult> {
const result = await this.executeSynchronous(command, message);
if (options.synchronous) return result;
await new ReplyStream(message, adapter).enqueue(result.reply || "");
return result;
}
private async executeSynchronous(command: ParsedCommand | undefined, message: IncomingMessage): Promise<GatewayResult> {
private async executeCommandResult(command: ParsedCommand, message: IncomingMessage): Promise<GatewayResult> {
try {
const reply = command ? await this.executeCommand(command, message) : (await this.runtime.prompt({
return { ok: true, reply: await this.executeCommand(command, message) };
} catch (error) {
const errorText = error instanceof Error ? error.message : String(error);
return { ok: false, error: errorText, reply: `Agent error: ${errorText}` };
}
}
private async promptResult(message: IncomingMessage): Promise<GatewayResult> {
try {
const reply = (await this.runtime.prompt({
platform: message.platform,
chatId: message.chatId,
userId: message.userId,
@@ -175,60 +137,30 @@ export class Gateway {
private async executeCommand(command: ParsedCommand, message: IncomingMessage): Promise<string> {
switch (command.kind) {
case "help": return ["Commands:", "/status", "/cancel", "/new", "/help"].join("\n");
case "help": return HELP_TEXT;
case "status": {
const chatKey = chatKeyFor(message.platform, message.chatId);
const work = this.chatWork.get(chatKey);
const status = {
...this.runtime.status(message.platform, message.chatId),
gatewayRunning: Boolean(work?.running),
queued: work?.queued || 0,
gatewayRunningSeconds: work?.startedAt !== undefined
? Math.max(0, Math.floor((this.now() - work.startedAt) / 1000)) : 0
};
const status = this.runtime.status(message.platform, message.chatId);
return `OK\n${Object.entries(status).map(([key, value]) => `${key}=${value}`).join("\n")}`;
}
case "new":
await this.runtime.reset(message.platform, message.chatId);
return "Started a new native ACP session for this chat.";
case "cancel": return this.cancelText(message);
case "retired-role": return "Role switching was removed in Config v3; this instance has one fixed Bot and ACP agent.";
case "list": {
if (!this.runtime.listProposals) return "当前运行时不支持列出提案。";
const proposals = this.runtime.listProposals(message.platform, message.chatId);
if (proposals.length === 0) return "当前没有提案。";
return proposals.map((proposal) => `${proposal.id} [${proposal.status}] ${proposal.title}`).join("\n");
}
case "confirm": {
if (!this.runtime.confirm) return "当前运行时不支持确认操作。";
return await this.runtime.confirm(message.platform, message.chatId) ? "已确认,继续执行。" : "当前没有待确认的提案。";
}
case "stop": {
if (!this.runtime.stop) return "当前运行时不支持停止操作。";
return await this.runtime.stop(message.platform, message.chatId) ? "已停止当前任务。" : "当前没有正在执行的任务。";
}
case "cancel":
return await this.runtime.cancel(message.platform, message.chatId) ? "已取消最近的提案。" : "没有可取消的提案。";
}
}
private async cancelText(message: IncomingMessage): Promise<string> {
return await this.runtime.cancel(message.platform, message.chatId) ? "Cancellation requested." : "No active turn to cancel.";
}
private armTimer(inbound: AsyncInbound, deadline: number): void {
if (this.closed || inbound.phase === "finished") return;
inbound.timer?.cancel();
inbound.timer = this.schedule(() => {
inbound.timer = undefined;
if (this.closed || inbound.phase === "finished") return;
const now = this.now();
if (now < deadline) {
this.armTimer(inbound, deadline);
return;
}
const elapsed = now - inbound.receivedAt;
if (!inbound.initialNoticeSent) {
inbound.initialNoticeSent = true;
inbound.queuedNoticeSent = inbound.phase === "queued";
}
void inbound.stream.enqueue(reminderText(inbound.phase, elapsed));
this.armTimer(inbound, nextReminderDeadline(inbound.receivedAt, now));
}, Math.max(0, deadline - this.now()));
}
private finishInbound(inbound: AsyncInbound): void {
if (inbound.phase === "finished") return;
inbound.phase = "finished";
inbound.timer?.cancel();
inbound.timer = undefined;
this.asyncInbounds.delete(inbound);
}
private checkPolicy(message: IncomingMessage): string | undefined {
if (this.policy.allowedUsers.length > 0 && !this.policy.allowedUsers.includes(message.userId)) return "User not allowed";
if (this.policy.allowedChats.length > 0 && !this.policy.allowedChats.includes(message.chatId)) return "Chat not allowed";
@@ -236,55 +168,14 @@ export class Gateway {
return undefined;
}
private async withChatLock<T>(key: string, fn: () => Promise<T>): Promise<T> {
const previous = this.locks.get(key) || Promise.resolve();
const state = this.chatWork.get(key) || { running: false, queued: 0 };
state.queued++;
this.chatWork.set(key, state);
let release!: () => void;
const current = new Promise<void>((resolve) => { release = resolve; });
const lock = previous.then(() => current);
this.locks.set(key, lock);
await previous;
state.queued--;
state.running = true;
state.startedAt = this.now();
try {
return await fn();
} finally {
state.running = false;
state.startedAt = undefined;
release();
if (this.locks.get(key) === lock) {
this.locks.delete(key);
this.chatWork.delete(key);
}
}
}
}
function defaultSchedule(callback: () => void, delayMs: number): GatewayTimer {
const timer = setTimeout(callback, delayMs);
timer.unref();
return { cancel: () => clearTimeout(timer) };
}
function nextReminderDeadline(receivedAt: number, now: number): number {
const elapsed = now - receivedAt;
if (elapsed < 60_000) return receivedAt + 60_000;
if (elapsed < 180_000) return receivedAt + 180_000;
if (elapsed < 480_000) return receivedAt + 480_000;
return receivedAt + 480_000 + (Math.floor((elapsed - 480_000) / 600_000) + 1) * 600_000;
}
function reminderText(phase: AsyncInbound["phase"], elapsed: number): string {
if (phase === "queued") {
return elapsed < 60_000
? "前面还有一件事没处理完,这条我记着,轮到后马上处理。"
: "前面的事情还没处理完,这条还在等。我没有漏掉,轮到后会接着做。";
}
if (elapsed < 60_000) return "收到,我还在处理,稍等我一下。";
if (elapsed < 180_000) return "还在弄,暂时还没出结果,弄好我马上回你。";
if (elapsed < 480_000) return "这件事比预想中多花了一点时间,我还在继续处理。你不用一直盯着,弄好我会直接回你。";
return "我还在处理这件事,确实花了些时间。想看看现在的状态可以发 /status,不想继续等也可以发 /cancel。";
}
const HELP_TEXT = [
"直接用自然语言告诉我你要做什么即可,我会自己判断并处理。",
"兜底命令:",
"/list 列出当前提案",
"/confirm 确认最近待确认的提案",
"/stop 停止正在执行的任务",
"/cancel 取消最近未开始的提案",
"/status 查看运行状态"
].join("\n");
+305
View File
@@ -0,0 +1,305 @@
import crypto from "node:crypto";
import fs from "node:fs";
import path from "node:path";
export interface StoreIdentity {
botId: string;
platform: string;
}
export interface WorkerProcessGroup {
pgid: number;
token: string;
}
export type ProposalStatus =
| "proposed"
| "queued"
| "working"
| "awaiting_user_confirmation"
| "completed"
| "failed"
| "cancelled";
export interface ProposalPending {
kind: "step" | "success" | "failure";
summary: string;
question?: string;
nextStep?: string;
}
export interface Proposal {
id: string;
title: string;
goal: string;
steps: string[];
ownerChatKey: string;
requesterUserId: string;
status: ProposalStatus;
workerNativeSessionId?: string;
workerProcessGroup?: WorkerProcessGroup;
pending?: ProposalPending;
lastWorkerSummary?: string;
createdAt: number;
confirmedAt?: number;
startedAt?: number;
updatedAt: number;
finishedAt?: number;
}
export interface CreateProposalInput {
title: string;
goal: string;
steps: string[];
ownerChatKey: string;
requesterUserId: string;
}
export interface ProposalPatch {
title?: string;
goal?: string;
steps?: string[];
status?: ProposalStatus;
workerNativeSessionId?: string;
workerProcessGroup?: WorkerProcessGroup;
pending?: ProposalPending;
lastWorkerSummary?: string;
confirmedAt?: number;
startedAt?: number;
finishedAt?: number;
}
interface StoreData {
version: 1;
botId: string;
platform: string;
proposals: Record<string, Proposal>;
}
export class ProposalStore {
private data: StoreData;
private queue: Promise<void> = Promise.resolve();
private lockFd?: fs.promises.FileHandle;
private closed = false;
constructor(readonly file: string, readonly identity: StoreIdentity) {
this.data = emptyData(identity);
}
async open(): Promise<void> {
const directory = path.dirname(this.file);
await fs.promises.mkdir(directory, { recursive: true, mode: 0o700 });
const directoryStat = await fs.promises.lstat(directory);
if (!directoryStat.isDirectory() || directoryStat.isSymbolicLink()) throw new Error(`Refusing unsafe state directory: ${directory}`);
const directoryMode = directoryStat.mode & 0o777;
if (directoryMode !== 0o700) throw new Error(`State directory mode must be 0700, got 0${directoryMode.toString(8)}`);
if (typeof process.getuid === "function" && directoryStat.uid !== process.getuid()) throw new Error("State directory is not owned by the current user");
const lockFile = `${this.file}.lock`;
try {
this.lockFd = await acquireLock(lockFile);
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "EEXIST" || !await removeStaleLock(lockFile)) {
if ((error as NodeJS.ErrnoException).code === "EEXIST") throw new Error(`Proposal state is locked by another gori-agent instance: ${lockFile}`);
throw error;
}
this.lockFd = await acquireLock(lockFile).catch((retryError) => {
if ((retryError as NodeJS.ErrnoException).code === "EEXIST") throw new Error(`Proposal state is locked by another gori-agent instance: ${lockFile}`);
throw retryError;
});
}
try {
await this.lockFd.writeFile(`${process.pid}\n`);
await this.lockFd.sync();
} catch (error) {
await this.releaseLock();
throw error;
}
try {
const raw = await fs.promises.readFile(this.file, "utf8");
const parsed = parseStoreData(JSON.parse(raw) as unknown);
if (parsed.botId !== this.identity.botId || parsed.platform !== this.identity.platform) {
throw new Error(`proposal state identity mismatch: expected bot '${this.identity.botId}' platform '${this.identity.platform}'`);
}
this.data = parsed;
} catch (error) {
if ((error as NodeJS.ErrnoException).code !== "ENOENT") {
await this.releaseLock();
throw new Error(`Cannot read proposal state '${this.file}'; original file was preserved: ${error instanceof Error ? error.message : String(error)}`);
}
}
}
get(id: string): Proposal | undefined {
const value = this.data.proposals[id];
return value ? structuredClone(value) : undefined;
}
list(filter?: { status?: ProposalStatus }): Proposal[] {
const proposals = Object.values(this.data.proposals)
.filter((proposal) => !filter?.status || proposal.status === filter.status)
.sort((a, b) => (a.confirmedAt ?? a.createdAt) - (b.confirmedAt ?? b.createdAt));
return structuredClone(proposals);
}
async create(input: CreateProposalInput): Promise<Proposal> {
if (typeof input.title !== "string" || input.title.length === 0) throw new Error("proposal title must be a non-empty string");
if (typeof input.goal !== "string" || input.goal.length === 0) throw new Error("proposal goal must be a non-empty string");
if (!Array.isArray(input.steps) || input.steps.some((step) => typeof step !== "string" || step.length === 0)) {
throw new Error("proposal steps must be an array of non-empty strings");
}
if (typeof input.ownerChatKey !== "string" || input.ownerChatKey.length === 0) throw new Error("proposal ownerChatKey must be a non-empty string");
if (typeof input.requesterUserId !== "string" || input.requesterUserId.length === 0) throw new Error("proposal requesterUserId must be a non-empty string");
const now = Date.now();
const proposal: Proposal = {
id: crypto.randomUUID(),
title: input.title,
goal: input.goal,
steps: [...input.steps],
ownerChatKey: input.ownerChatKey,
requesterUserId: input.requesterUserId,
status: "proposed",
createdAt: now,
updatedAt: now
};
this.data.proposals[proposal.id] = proposal;
await this.persist();
return structuredClone(proposal);
}
async update(id: string, patch: ProposalPatch): Promise<Proposal> {
const proposal = this.data.proposals[id];
if (!proposal) throw new Error(`unknown proposal '${id}'`);
const candidate: Proposal = { ...structuredClone(proposal), ...structuredClone(patch), id, updatedAt: Date.now() };
validateProposal(candidate, id);
this.data.proposals[id] = candidate;
await this.persist();
return structuredClone(candidate);
}
stats(): { proposals: number } {
return { proposals: Object.keys(this.data.proposals).length };
}
async flush(): Promise<void> { await this.queue; }
async close(): Promise<void> {
if (this.closed) return;
this.closed = true;
try { await this.flush(); } finally { await this.releaseLock(); }
}
private persist(): Promise<void> {
if (this.closed) return Promise.reject(new Error("Proposal store is closed"));
const snapshot = JSON.stringify(this.data, null, 2) + "\n";
const operation = this.queue.then(() => atomicWrite(this.file, snapshot));
this.queue = operation.catch(() => undefined);
return operation;
}
private async releaseLock(): Promise<void> {
if (!this.lockFd) return;
await this.lockFd.close();
this.lockFd = undefined;
await fs.promises.unlink(`${this.file}.lock`).catch((error: NodeJS.ErrnoException) => {
if (error.code !== "ENOENT") throw error;
});
}
}
function emptyData(identity: StoreIdentity): StoreData {
return { version: 1, botId: identity.botId, platform: identity.platform, proposals: {} };
}
const STATUSES: readonly ProposalStatus[] = ["proposed", "queued", "working", "awaiting_user_confirmation", "completed", "failed", "cancelled"];
function parseStoreData(value: unknown): StoreData {
if (!isRecord(value) || value.version !== 1 || typeof value.botId !== "string" || typeof value.platform !== "string" || !isRecord(value.proposals)) {
throw new Error("unsupported proposal state version; expected version 1");
}
for (const [id, proposal] of Object.entries(value.proposals)) {
if (!isRecord(proposal) || proposal.id !== id) throw new Error(`invalid proposal '${id}'`);
validateProposal(proposal as unknown as Proposal, id);
}
return value as unknown as StoreData;
}
function validateProposal(proposal: Proposal, id: string): void {
const invalid = (reason: string): never => { throw new Error(`invalid proposal '${id}': ${reason}`); };
if (typeof proposal.title !== "string" || proposal.title.length === 0) invalid("title");
if (typeof proposal.goal !== "string" || proposal.goal.length === 0) invalid("goal");
if (!Array.isArray(proposal.steps) || proposal.steps.some((step) => typeof step !== "string" || step.length === 0)) invalid("steps");
if (typeof proposal.ownerChatKey !== "string" || proposal.ownerChatKey.length === 0) invalid("ownerChatKey");
if (typeof proposal.requesterUserId !== "string" || proposal.requesterUserId.length === 0) invalid("requesterUserId");
if (!STATUSES.includes(proposal.status)) invalid("status");
if (proposal.workerNativeSessionId !== undefined && typeof proposal.workerNativeSessionId !== "string") invalid("workerNativeSessionId");
if (proposal.workerProcessGroup !== undefined) {
const group = proposal.workerProcessGroup;
if (!isRecord(group) || !Number.isSafeInteger(group.pgid) || group.pgid <= 1 || typeof group.token !== "string" || group.token.length === 0) {
invalid("workerProcessGroup");
}
}
if (proposal.pending !== undefined) {
const pending = proposal.pending;
if (!isRecord(pending)
|| !["step", "success", "failure"].includes(String(pending.kind))
|| typeof pending.summary !== "string"
|| (pending.question !== undefined && typeof pending.question !== "string")
|| (pending.nextStep !== undefined && typeof pending.nextStep !== "string")) {
invalid("pending");
}
}
if (proposal.lastWorkerSummary !== undefined && typeof proposal.lastWorkerSummary !== "string") invalid("lastWorkerSummary");
if (typeof proposal.createdAt !== "number") invalid("createdAt");
if (typeof proposal.updatedAt !== "number") invalid("updatedAt");
if (proposal.confirmedAt !== undefined && typeof proposal.confirmedAt !== "number") invalid("confirmedAt");
if (proposal.startedAt !== undefined && typeof proposal.startedAt !== "number") invalid("startedAt");
if (proposal.finishedAt !== undefined && typeof proposal.finishedAt !== "number") invalid("finishedAt");
}
function isRecord(value: unknown): value is Record<string, unknown> {
return typeof value === "object" && value !== null && !Array.isArray(value);
}
async function acquireLock(lockFile: string): Promise<fs.promises.FileHandle> {
return fs.promises.open(lockFile, "wx", 0o600);
}
async function removeStaleLock(lockFile: string): Promise<boolean> {
let handle: fs.promises.FileHandle | undefined;
try {
handle = await fs.promises.open(lockFile, "r");
const pid = Number((await handle.readFile("utf8")).trim());
if (!Number.isSafeInteger(pid) || pid <= 1 || processExists(pid)) return false;
const opened = await handle.stat();
const current = await fs.promises.lstat(lockFile);
if (opened.dev !== current.dev || opened.ino !== current.ino) return false;
await fs.promises.unlink(lockFile);
return true;
} catch { return false; }
finally { await handle?.close().catch(() => undefined); }
}
function processExists(pid: number): boolean {
try { process.kill(pid, 0); return true; }
catch (error) { return (error as NodeJS.ErrnoException).code === "EPERM"; }
}
async function atomicWrite(file: string, content: string): Promise<void> {
const temp = `${file}.${process.pid}.${Date.now()}.tmp`;
let handle: fs.promises.FileHandle | undefined;
try {
handle = await fs.promises.open(temp, "wx", 0o600);
await handle.writeFile(content, "utf8");
await handle.sync();
await handle.close();
handle = undefined;
await fs.promises.rename(temp, file);
await fs.promises.chmod(file, 0o600);
const dir = await fs.promises.open(path.dirname(file), "r");
try { await dir.sync(); } finally { await dir.close(); }
} catch (error) {
if (handle) await handle.close().catch(() => undefined);
await fs.promises.unlink(temp).catch(() => undefined);
throw error;
}
}
+37
View File
@@ -0,0 +1,37 @@
import fs from "node:fs";
import path from "node:path";
import { parseConfig } from "../config.js";
export function canonicalWorkspace(workspace: string): string {
return fs.realpathSync.native(path.resolve(workspace));
}
export function findOverlappingWorkspace(workspace: string, botId: string, instancesDirectory: string): string | undefined {
const target = canonicalWorkspace(workspace);
let entries: fs.Dirent[];
try { entries = fs.readdirSync(instancesDirectory, { withFileTypes: true }); }
catch (error) { if ((error as NodeJS.ErrnoException).code === "ENOENT") return undefined; throw error; }
for (const entry of entries) {
if (entry.name === botId) continue;
if (!entry.isDirectory() || entry.isSymbolicLink()) throw new Error(`Refusing unsafe peer instance entry: ${entry.name}`);
const file = path.join(instancesDirectory, entry.name, "config.json");
let stat: fs.Stats;
try { stat = fs.lstatSync(file); }
catch (error) {
if ((error as NodeJS.ErrnoException).code === "ENOENT") throw new Error(`Peer instance is missing config.json: ${entry.name}`);
throw error;
}
if (!stat.isFile() || stat.isSymbolicLink()) throw new Error(`Refusing unsafe peer config: ${file}`);
let peer;
try { peer = parseConfig(JSON.parse(fs.readFileSync(file, "utf8"))); }
catch (error) { throw new Error(`Invalid peer Config v3 '${file}': ${error instanceof Error ? error.message : String(error)}`); }
const other = canonicalWorkspace(peer.bot.workspace);
if (other === target || isInside(other, target) || isInside(target, other)) return entry.name;
}
return undefined;
}
function isInside(parent: string, child: string): boolean {
const relative = path.relative(parent, child);
return relative !== "" && !relative.startsWith("..") && !path.isAbsolute(relative);
}
+23 -5
View File
@@ -2,12 +2,13 @@ import crypto from "node:crypto";
import type { AppConfig, BotConfig } from "../config.js";
import { SkillLoader, type LoadedSkill } from "./skill-loader.js";
const BOOTSTRAP_SCHEMA_VERSION = 1;
const BOOTSTRAP_SCHEMA_VERSION = 4;
export interface ResolvedBot extends BotConfig {
loadedSkills: LoadedSkill[];
fingerprint: string;
bootstrap: string;
assistantBootstrap: string;
workerBootstrap: string;
}
export class BotProfileResolver {
@@ -28,18 +29,35 @@ export class BotProfileResolver {
...config.bot,
loadedSkills,
fingerprint,
bootstrap: buildBootstrap(config.bot, loadedSkills)
assistantBootstrap: buildAssistantBootstrap(config.bot),
workerBootstrap: buildWorkerBootstrap(config.bot, loadedSkills)
};
}
}
function buildBootstrap(bot: BotConfig, skills: LoadedSkill[]): string {
function buildAssistantBootstrap(bot: BotConfig): string {
return [
`Initialize this ACP session with gori-agent bootstrap schema v${BOOTSTRAP_SCHEMA_VERSION}. Treat these instructions as persistent context. Reply only with READY.`,
`Initialize this ACP session with gori-agent assistant bootstrap schema v${BOOTSTRAP_SCHEMA_VERSION}. Treat these instructions as persistent context. Reply only with READY.`,
`Bot: ${bot.id}`,
`Workspace: ${bot.workspace}`,
bot.persona ? `Persona:\n${bot.persona}` : "Persona: general coding assistant",
"You are the user-facing Assistant. You have no tools and cannot inspect files, run commands, call Skills or MCP. You chat with the user, propose work, and explain worker feedback. Never claim to have executed anything yourself.",
"Every reply must end with exactly one hidden action envelope: <GORI_ASSISTANT_ACTION_V1>{\"reply\":\"...\",\"actions\":[...]}</GORI_ASSISTANT_ACTION_V1>. Put the user-facing text in the JSON \"reply\" field, not outside the envelope.",
"Supported actions: {\"type\":\"create_proposal\",\"title\":\"...\",\"goal\":\"...\",\"steps\":[\"...\"]}; {\"type\":\"confirm\",\"id\":\"optional\",\"answer\":\"optional\"}; {\"type\":\"start_next\"}; {\"type\":\"cancel\",\"id\":\"optional\"}; {\"type\":\"stop\"}. Use an empty actions array when no state change is needed.",
"A proposal only starts after the user confirms it. Confirming a proposal whose worker reported SUCCESS marks it completed; confirming a FAILED proposal marks it failed; to retry a failed proposal, confirm with answer \"retry\". When a worker asks a question (NEEDS_CONFIRMATION), confirm with the user's answer to continue the same worker. \"stop\" terminates the active worker and cancels its proposal; \"start_next\" starts the oldest confirmed queued proposal. Never invent other actions or statuses."
].join("\n\n");
}
function buildWorkerBootstrap(bot: BotConfig, skills: LoadedSkill[]): string {
return [
`Initialize this ACP session with gori-agent worker bootstrap schema v${BOOTSTRAP_SCHEMA_VERSION}. Treat these instructions as persistent context. Reply only with READY.`,
`Bot: ${bot.id}`,
`Workspace: ${bot.workspace}`,
bot.persona ? `Persona:\n${bot.persona}` : "Persona: general coding assistant",
`Permission policy enforced by the ACP client: ${JSON.stringify(bot.permissions)}`,
"You are the Worker. You execute exactly one confirmed proposal in the configured workspace and never talk to the user directly.",
"For every turn, end your response with exactly one hidden result envelope: <GORI_WORKER_RESULT_V1>{\"status\":\"SUCCESS\",\"summary\":\"...\"}</GORI_WORKER_RESULT_V1>. Status is SUCCESS only when the proposal is fully done, FAILED when it cannot be completed, or NEEDS_CONFIRMATION when you must ask the user before continuing (for example a dirty or unexpected target). Optional fields: \"question\", \"nextStep\", \"dirty\". Always include a short user-readable \"summary\". Never emit any other status or text after the envelope.",
"Keep every command and tool process attached to this ACP worker. Never daemonize, call setsid, use nohup, create a detached process, or leave a background process running after the turn.",
...skills.map((skill) => `Skill ${skill.id} (${skill.file}):\n${skill.content}`)
].join("\n\n");
}
+31 -11
View File
@@ -1,11 +1,12 @@
import express from "express";
import type { Server } from "node:http";
import { fileURLToPath } from "node:url";
import { AcpSessionManager } from "./acp/session-manager.js";
import { defaultStateFile, loadConfig, type AppConfig } from "./config.js";
import { AssistantManager } from "./acp/assistant-manager.js";
import { defaultProposalFile, defaultStateFile, loadConfig, type AppConfig } from "./config.js";
import type { PlatformAdapter } from "./core/adapter.js";
import { DurableSessionStore } from "./core/durable-session-store.js";
import { Gateway } from "./core/gateway.js";
import { ProposalStore } from "./core/proposal-store.js";
import { FeishuAdapter } from "./platforms/feishu/adapter.js";
import { QqAdapter } from "./platforms/qq/adapter.js";
import { QqGatewayClient } from "./platforms/qq/gateway-client.js";
@@ -17,8 +18,9 @@ import { BotProfileResolver } from "./roles/role-registry.js";
export interface GatewayRuntime {
app: express.Express;
gateway: Gateway;
sessionManager: AcpSessionManager;
assistantManager: AssistantManager;
store: DurableSessionStore;
proposals: ProposalStore;
platformAdapter: PlatformAdapter;
qqGatewayClient?: QqGatewayClient;
shutdown(): Promise<void>;
@@ -26,12 +28,26 @@ export interface GatewayRuntime {
export async function createGatewayRuntime(config: AppConfig): Promise<GatewayRuntime> {
const platformType = config.gateway.platform.type;
const store = new DurableSessionStore(defaultStateFile(config), { botId: config.bot.id, platform: platformType });
const identity = { botId: config.bot.id, platform: platformType };
const store = new DurableSessionStore(defaultStateFile(config), identity);
const proposals = new ProposalStore(defaultProposalFile(config), identity);
let assistantManager: AssistantManager | undefined;
await store.open();
try {
await proposals.open();
} catch (error) {
await store.close().catch(() => undefined);
throw error;
}
try {
const bot = new BotProfileResolver(config).bot;
const sessionManager = new AcpSessionManager(config.runtime.acp, bot, store);
const gateway = new Gateway(config.gateway.policy, sessionManager);
assistantManager = new AssistantManager(config.runtime.acp, bot, store, proposals, {
maxAssistantSessions: config.runtime.acp.maxAssistantSessions
});
await assistantManager.initialize();
const manager = assistantManager;
const gateway = new Gateway(config.gateway.policy, manager);
manager.setEventSink((chatKey, text) => gateway.sendEvent(chatKey, text));
const platformAdapter = createPlatformAdapter(config, gateway);
const qqGatewayClient = config.gateway.platform.type === "qq" && config.gateway.platform.connectionMode === "websocket"
? new QqGatewayClient(config.gateway.platform, platformAdapter as QqAdapter) : undefined;
@@ -67,17 +83,21 @@ export async function createGatewayRuntime(config: AppConfig): Promise<GatewayRu
let closing: Promise<void> | undefined;
return {
app, gateway, sessionManager, store, platformAdapter, qqGatewayClient,
app, gateway, assistantManager: manager, store, proposals, platformAdapter, qqGatewayClient,
shutdown: () => closing ||= (async () => {
qqGatewayClient?.stop();
gateway.shutdown();
const results = await Promise.allSettled([sessionManager.shutdown(), store.close()]);
const failed = results.find((result): result is PromiseRejectedResult => result.status === "rejected");
if (failed) throw failed.reason;
let shutdownError: unknown;
try { await manager.shutdown(); } catch (error) { shutdownError = error; }
try { await proposals.close(); } catch (error) { shutdownError ||= error; }
try { await store.close(); } catch (error) { shutdownError ||= error; }
if (shutdownError) throw shutdownError;
})()
};
} catch (error) {
await store.close();
await assistantManager?.shutdown().catch(() => undefined);
await proposals.close().catch(() => undefined);
await store.close().catch(() => undefined);
throw error;
}
}