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:
@@ -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> {
|
||||
|
||||
Reference in New Issue
Block a user