fix: scope proposals to chat users

This commit is contained in:
zenord
2026-08-18 20:58:08 +08:00
parent b4121c9fd6
commit 4af5049605
10 changed files with 289 additions and 122 deletions
+105 -77
View File
@@ -99,42 +99,42 @@ export class AssistantManager implements ConversationRuntime {
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));
const conversationKey = assistantKeyFor(chatKeyFor(request.platform, request.chatId), request.userId);
const reply = await this.enqueueChat(conversationKey, () => this.runAssistantTurn(conversationKey, request));
return { text: reply, botId: this.bot.id, agentId: this.bot.agent.id, mode: "assistant" };
}
async cancel(platform: string, chatId: string): Promise<boolean> {
async cancel(platform: string, chatId: string, userId: string): Promise<boolean> {
const chatKey = chatKeyFor(platform, chatId);
return this.scheduler(() => this.cancelLocked(chatKey));
return this.scheduler(() => this.cancelLocked(chatKey, userId));
}
async confirm(platform: string, chatId: string): Promise<boolean> {
async confirm(platform: string, chatId: string, userId: string): Promise<boolean> {
const chatKey = chatKeyFor(platform, chatId);
return this.scheduler(() => this.confirmLocked(chatKey));
return this.scheduler(() => this.confirmLocked(chatKey, userId));
}
async stop(platform: string, chatId: string): Promise<boolean> {
async stop(platform: string, chatId: string, userId: string): Promise<boolean> {
const chatKey = chatKeyFor(platform, chatId);
return this.scheduler(() => this.stopActiveLocked(chatKey));
return this.scheduler(() => this.stopActiveLocked(chatKey, userId));
}
listProposals(platform: string, chatId: string): Proposal[] {
listProposals(platform: string, chatId: string, userId: string): Proposal[] {
const chatKey = chatKeyFor(platform, chatId);
return this.proposals.list().filter((proposal) => proposal.ownerChatKey === chatKey);
return this.proposals.list().filter((proposal) => proposal.ownerChatKey === chatKey && proposal.requesterUserId === userId);
}
async reset(platform: string, chatId: string): Promise<void> {
const chatKey = chatKeyFor(platform, chatId);
const worker = this.assistantWorkers.get(chatKey);
async reset(platform: string, chatId: string, userId: string): Promise<void> {
const conversationKey = assistantKeyFor(chatKeyFor(platform, chatId), userId);
const worker = this.assistantWorkers.get(conversationKey);
if (worker) {
this.assistantWorkers.delete(chatKey);
this.assistantWorkers.delete(conversationKey);
await worker.terminate();
}
await this.store.deleteBinding(chatKey);
await this.store.deleteBinding(conversationKey);
}
status(platform: string, chatId: string): Record<string, string | number | boolean> {
status(platform: string, chatId: string, userId?: string): Record<string, string | number | boolean> {
const proposals = this.proposals.list();
const active = this.active;
const activeProposal = active ? this.proposals.get(active.proposalId) : undefined;
@@ -148,7 +148,7 @@ export class AssistantManager implements ConversationRuntime {
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))
owner: Boolean(userId && activeProposal && activeProposal.ownerChatKey === chatKeyFor(platform, chatId) && activeProposal.requesterUserId === userId)
};
}
@@ -181,17 +181,17 @@ export class AssistantManager implements ConversationRuntime {
await Promise.allSettled([...this.chatChains.values()]);
}
private async runAssistantTurn(chatKey: string, request: ConversationRequest): Promise<string> {
const worker = await this.acquireAssistantWorker(chatKey);
private async runAssistantTurn(conversationKey: string, request: ConversationRequest): Promise<string> {
const worker = await this.acquireAssistantWorker(conversationKey);
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);
await this.store.touchBinding(conversationKey);
await this.executeActions(chatKeyFor(request.platform, request.chatId), request, parsed.actions);
return parsed.reply;
} catch (error) {
if (worker.isolationViolated) await this.store.deleteBinding(chatKey).catch(() => undefined);
if (worker.isolationViolated) await this.store.deleteBinding(conversationKey).catch(() => undefined);
throw error;
}
}
@@ -214,23 +214,23 @@ export class AssistantManager implements ConversationRuntime {
requesterUserId: request.userId
});
} else if (action.type === "confirm") {
await this.scheduler(() => this.confirmLocked(chatKey, action.id, action.answer));
await this.scheduler(() => this.confirmLocked(chatKey, request.userId, action.id, action.answer));
} else if (action.type === "start_next") {
await this.scheduler(() => this.tryStartLocked());
await this.scheduler(() => this.tryStartLocked(chatKey, request.userId));
} else if (action.type === "cancel") {
await this.scheduler(() => this.cancelLocked(chatKey, action.id));
await this.scheduler(() => this.cancelLocked(chatKey, request.userId, action.id));
} else if (action.type === "stop") {
await this.scheduler(() => this.stopActiveLocked(chatKey));
await this.scheduler(() => this.stopActiveLocked(chatKey, request.userId));
}
}
}
private async confirmLocked(chatKey: string, id?: string, answer?: string): Promise<boolean> {
const proposal = this.resolveTargetProposal(chatKey, id, ["proposed", "awaiting_user_confirmation"]);
private async confirmLocked(chatKey: string, userId: string, id?: string, answer?: string): Promise<boolean> {
const proposal = this.resolveTargetProposal(chatKey, userId, 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();
await this.tryStartLocked(chatKey, userId);
return true;
}
const pending = proposal.pending;
@@ -255,31 +255,47 @@ export class AssistantManager implements ConversationRuntime {
return true;
}
private async cancelLocked(chatKey: string, id?: string): Promise<boolean> {
const proposal = this.resolveTargetProposal(chatKey, id, ["proposed", "queued"]);
private async cancelLocked(chatKey: string, userId: string, id?: string): Promise<boolean> {
const proposal = this.resolveTargetProposal(chatKey, userId, 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> {
private async stopActiveLocked(chatKey: string, userId: 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()
});
const activeProposal = active ? this.proposals.get(active.proposalId) : undefined;
if (active && activeProposal && activeProposal.ownerChatKey === chatKey && activeProposal.requesterUserId === userId) {
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(activeProposal.id, {
status: "cancelled",
pending: undefined,
workerProcessGroup: undefined,
lastWorkerSummary: "stopped by user",
finishedAt: Date.now()
});
return true;
}
const waiting = this.resolveTargetProposal(chatKey, userId, undefined, ["awaiting_user_confirmation"]);
if (!waiting?.pending) return false;
await this.terminateActiveLocked(waiting.id);
if (waiting.pending.kind === "success") {
await this.proposals.update(waiting.id, { status: "completed", pending: undefined, finishedAt: Date.now() });
} else if (waiting.pending.kind === "failure") {
await this.proposals.update(waiting.id, { status: "failed", pending: undefined, finishedAt: Date.now() });
} else {
await this.proposals.update(waiting.id, {
status: "cancelled",
pending: undefined,
lastWorkerSummary: "stopped by user",
finishedAt: Date.now()
});
}
return true;
}
@@ -294,23 +310,25 @@ export class AssistantManager implements ConversationRuntime {
await this.proposals.update(proposalId, { workerProcessGroup: undefined });
}
private async tryStartLocked(): Promise<void> {
private async tryStartLocked(chatKey: string, userId: string): 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];
const next = this.proposals.list({ status: "queued" })
.find((proposal) => proposal.ownerChatKey === chatKey && proposal.requesterUserId === userId);
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 {
private resolveTargetProposal(chatKey: string, userId: string, id: string | undefined, statuses: Proposal["status"][]): Proposal | undefined {
const owned = (proposal: Proposal) => proposal.ownerChatKey === chatKey && proposal.requesterUserId === userId;
if (id) {
const proposal = this.proposals.get(id);
return proposal && proposal.ownerChatKey === chatKey && statuses.includes(proposal.status) ? proposal : undefined;
return proposal && owned(proposal) && statuses.includes(proposal.status) ? proposal : undefined;
}
return this.proposals.list()
.filter((proposal) => proposal.ownerChatKey === chatKey && statuses.includes(proposal.status))
.filter((proposal) => owned(proposal) && statuses.includes(proposal.status))
.sort((left, right) => right.updatedAt - left.updatedAt)[0];
}
@@ -437,10 +455,11 @@ export class AssistantManager implements ConversationRuntime {
const sink = this.eventSink;
if (!proposal || !sink) return;
const chatKey = proposal.ownerChatKey;
void this.enqueueChat(chatKey, async () => {
const conversationKey = assistantKeyFor(chatKey, proposal.requesterUserId);
void this.enqueueChat(conversationKey, async () => {
if (this.shuttingDown) return;
try {
const reply = await this.runAssistantEvent(chatKey, proposal.id);
const reply = await this.runAssistantEvent(conversationKey, proposal.id);
if (reply) await sink(chatKey, reply);
} catch (error) {
console.error(`Assistant event notification failed (${error instanceof Error ? error.name : "unknown error"})`);
@@ -450,37 +469,37 @@ export class AssistantManager implements ConversationRuntime {
}).catch(() => undefined);
}
private async runAssistantEvent(chatKey: string, proposalId: string): Promise<string> {
private async runAssistantEvent(conversationKey: string, proposalId: string): Promise<string> {
const proposal = this.proposals.get(proposalId);
if (!proposal?.pending) return "";
const worker = await this.acquireAssistantWorker(chatKey);
const worker = await this.acquireAssistantWorker(conversationKey);
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);
await this.store.touchBinding(conversationKey);
return parsed.reply;
} catch (error) {
if (worker.isolationViolated) await this.store.deleteBinding(chatKey).catch(() => undefined);
if (worker.isolationViolated) await this.store.deleteBinding(conversationKey).catch(() => undefined);
throw error;
}
}
private async acquireAssistantWorker(chatKey: string): Promise<AcpWorker> {
const existing = this.assistantWorkers.get(chatKey);
private async acquireAssistantWorker(conversationKey: string): Promise<AcpWorker> {
const existing = this.assistantWorkers.get(conversationKey);
if (existing) {
existing.lastUsedAt = Date.now();
return existing;
}
await this.reserveAssistantCapacity();
const persisted = this.store.getBinding(chatKey);
const persisted = this.store.getBinding(conversationKey);
const binding = persisted && this.validBinding(persisted) ? persisted : undefined;
if (persisted && !binding) await this.store.deleteBinding(chatKey);
const cwd = this.prepareAssistantWorkspace(chatKey, binding);
if (persisted && !binding) await this.store.deleteBinding(conversationKey);
const cwd = this.prepareAssistantWorkspace(conversationKey, 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);
if (this.assistantWorkers.get(conversationKey) === crashed) this.assistantWorkers.delete(conversationKey);
console.error(`ACP assistant worker crash: ${error.message}`);
}, {
kind: "assistant",
@@ -488,7 +507,7 @@ export class AssistantManager implements ConversationRuntime {
env: this.assistantEnv(),
policy: ASSISTANT_POLICY,
onIsolationViolation: (violating) => {
if (this.assistantWorkers.get(chatKey) === violating) this.assistantWorkers.delete(chatKey);
if (this.assistantWorkers.get(conversationKey) === violating) this.assistantWorkers.delete(conversationKey);
},
allowUnverifiedAssistantAgent: this.options.allowUnverifiedAssistantAgent
});
@@ -501,17 +520,17 @@ export class AssistantManager implements ConversationRuntime {
worker = await spawnWorker(binding?.nativeSessionId);
} catch (error) {
if (!(error instanceof AcpSessionRestoreError) || !binding) throw error;
await this.store.deleteBinding(chatKey);
await this.store.deleteBinding(conversationKey);
resumed = false;
worker = await spawnWorker();
}
this.assistantWorkers.set(chatKey, worker);
this.assistantWorkers.set(conversationKey, worker);
if (!resumed) {
try {
await worker.prompt(this.bot.assistantBootstrap, "initializing");
const now = Date.now();
await this.store.setBinding({
chatKey,
chatKey: conversationKey,
agentId: this.bot.agent.id,
nativeSessionId: worker.nativeSessionId!,
assistantWorkspace: cwd,
@@ -520,7 +539,7 @@ export class AssistantManager implements ConversationRuntime {
updatedAt: now
});
} catch (error) {
if (this.assistantWorkers.get(chatKey) === worker) this.assistantWorkers.delete(chatKey);
if (this.assistantWorkers.get(conversationKey) === worker) this.assistantWorkers.delete(conversationKey);
await worker.terminate().catch(() => undefined);
throw error;
}
@@ -573,20 +592,22 @@ export class AssistantManager implements ConversationRuntime {
}
private assistantUserPrompt(request: ConversationRequest): string {
const chatKey = chatKeyFor(request.platform, request.chatId);
return [
"[User message]",
request.text,
"",
"[Proposal states]",
...this.proposalSummaries(),
...this.proposalSummaries(chatKey, request.userId),
"",
"[Worker state]",
this.workerStateSummary()
this.workerStateSummary(chatKey, request.userId)
].join("\n");
}
private proposalSummaries(): string[] {
const proposals = this.proposals.list();
private proposalSummaries(chatKey: string, userId: string): string[] {
const proposals = this.proposals.list()
.filter((proposal) => proposal.ownerChatKey === chatKey && proposal.requesterUserId === userId);
if (proposals.length === 0) return ["none"];
return proposals.map((proposal) => {
const pending = proposal.pending
@@ -596,9 +617,13 @@ export class AssistantManager implements ConversationRuntime {
});
}
private workerStateSummary(): string {
private workerStateSummary(chatKey: string, userId: string): string {
const active = this.active;
if (!active) return "no active worker";
const proposal = this.proposals.get(active.proposalId);
if (!proposal || proposal.ownerChatKey !== chatKey || proposal.requesterUserId !== userId) {
return "busy: a worker is executing another user's proposal; details hidden";
}
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}`;
@@ -620,8 +645,11 @@ export class AssistantManager implements ConversationRuntime {
}
}
export function parseAssistantActions(text: string): ParsedAssistantEnvelope | undefined {
const match = ASSISTANT_ENVELOPE.exec(text);
export function assistantKeyFor(chatKey: string, userId: string): string {
return `${chatKey}#${userId}`;
}
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; }