feat: show owners a live worker status snapshot

While a worker turn is running, the assistant prompt for the working
proposal's owner includes a compact status card (elapsed time, last
tool activity category with age, per-category counts) built from the
ACP session/update stream the runtime already receives. Other users
still see only the desensitized busy state, and raw update content
never enters the assistant session.
This commit is contained in:
zenord
2026-08-19 16:39:42 +08:00
parent bd96595ba8
commit 4f7b4a9115
5 changed files with 112 additions and 6 deletions
+42 -6
View File
@@ -8,7 +8,7 @@ import type { IncomingAttachment, OutgoingImageRef } from "../core/types.js";
import { MAX_OUTBOUND_IMAGES, validateWorkspaceImage } from "../core/workspace-images.js";
import type { ResolvedBot } from "../roles/role-registry.js";
import type { ConversationRequest, ConversationResponse, ConversationRuntime, RuntimeEventSink, RuntimeStats } from "./types.js";
import type { AcpPromptContent } from "./client.js";
import type { AcpPromptContent, SafeActivityCategory } from "./client.js";
import { AcpSessionRestoreError, AcpWorker } from "./worker.js";
const ASSISTANT_POLICY: PermissionPolicy = { mode: "deny", allowedTools: [], allowedCommandPatterns: [] };
@@ -48,8 +48,20 @@ interface ActiveWorker {
worker: AcpWorker;
settled: boolean;
run?: Promise<void>;
activity: WorkerActivity;
}
// Real-time snapshot of the active worker's session/update activity; only categories and
// timestamps are kept, never tool arguments, outputs, or message text.
interface WorkerActivity {
turnStartedAt?: number;
lastActivityAt?: number;
lastCategory?: SafeActivityCategory;
counts: Partial<Record<SafeActivityCategory, number>>;
}
const ACTIVITY_CATEGORIES: readonly SafeActivityCategory[] = ["read", "search", "write", "execute", "delegate", "other"];
interface WorkerLaunchOptions {
resumeSessionId?: string;
resumeContext?: string;
@@ -453,17 +465,24 @@ export class AssistantManager implements ConversationRuntime {
private launchWorker(proposalId: string, options: WorkerLaunchOptions = {}): void {
const proposal = this.proposals.get(proposalId);
if (!proposal || proposal.status !== "working") return;
const active: ActiveWorker = { proposalId, worker: this.spawnProposalWorker(proposalId), settled: false };
const activity: WorkerActivity = { counts: {} };
const active: ActiveWorker = { proposalId, worker: this.spawnProposalWorker(proposalId, activity), settled: false, activity };
this.active = active;
const run = this.startWorkerRun(active, options);
active.run = run;
void run.catch((error) => this.handleWorkerRunFailure(active, error));
}
private spawnProposalWorker(proposalId: string): AcpWorker {
private spawnProposalWorker(proposalId: string, activity: WorkerActivity): AcpWorker {
return new AcpWorker(this.bot, this.config, (crashed, error) => this.onWorkerCrash(proposalId, crashed, error), {
kind: "worker",
cwd: this.bot.workspace,
onActivity: (category) => {
activity.lastActivityAt = Date.now();
if (!category) return;
activity.lastCategory = category;
activity.counts[category] = (activity.counts[category] || 0) + 1;
},
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 });
@@ -480,7 +499,7 @@ export class AssistantManager implements ConversationRuntime {
} catch (error) {
if (!(error instanceof AcpSessionRestoreError) || this.active !== active || active.settled) throw error;
console.log(`Worker resume failed for proposal ${proposalTag(active.proposalId)}; starting a fresh session`);
active.worker = this.spawnProposalWorker(active.proposalId);
active.worker = this.spawnProposalWorker(active.proposalId, active.activity);
await active.worker.start();
options = {
...options,
@@ -503,6 +522,10 @@ export class AssistantManager implements ConversationRuntime {
}
private async runWorkerTurn(active: ActiveWorker, promptText: string, images?: IncomingAttachment[]): Promise<void> {
active.activity.turnStartedAt = Date.now();
active.activity.lastActivityAt = undefined;
active.activity.lastCategory = undefined;
active.activity.counts = {};
const reply = await active.worker.prompt(withWorkerResultProtocol(this.workerPromptContent(active.worker, promptText, images)));
if (this.active !== active || active.settled) throw new Error("Worker was superseded during its turn");
let result = parseWorkerResult(reply);
@@ -831,8 +854,21 @@ export class AssistantManager implements ConversationRuntime {
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}`;
const activity = active.activity;
const runningMs = activity.turnStartedAt ? Math.max(0, Date.now() - activity.turnStartedAt) : 0;
const running = `${String(Math.floor(runningMs / 60_000)).padStart(2, "0")}:${String(Math.floor((runningMs % 60_000) / 1_000)).padStart(2, "0")}`;
const lastActivity = activity.lastCategory
? `${activity.lastCategory} (${Math.max(0, Math.floor((Date.now() - (activity.lastActivityAt || activity.turnStartedAt || Date.now())) / 1_000))}s ago)`
: "none yet";
const counts = ACTIVITY_CATEGORIES
.filter((category) => activity.counts[category])
.map((category) => `${category}×${activity.counts[category]}`)
.join(" ") || "none yet";
return [
`title=${JSON.stringify(proposal.title)} running=${running} phase=${worker.phase} inFlight=${worker.inFlight}`,
`lastActivity=${lastActivity}; counts: ${counts}`,
"guidance: this is a real-time snapshot of the worker as of this message; when the user asks about progress, paraphrase it in your own words and never invent details beyond this snapshot."
].join("\n");
}
private enqueueChat<T>(chatKey: string, operation: () => Promise<T>): Promise<T> {