mirror of
https://github.com/wassname/pi-goals.git
synced 2026-09-11 12:43:57 +08:00
Send incremental worker views with direction and tracked job state
Borrow the provider tracker queries from cecb1e9. Keep unavailable state unknown, bind incremental views to acknowledged source entries, reset after compaction, bound serialized payloads, and recheck tracked work at sign-off.
This commit is contained in:
@@ -15,7 +15,7 @@ Plan in one Pi session, then do the work there while a stronger visible Pi sessi
|
||||
|
||||
The two Pi sessions are visible. You can switch to the supervisor pane and talk to it directly. Supervisor instructions are shown in full, including in collapsed tool rows; ordinary messages and emitted thinking use Pi's display settings. The supervisor is prompted to give brief progress assessments and use judgment about when to intervene.
|
||||
|
||||
On resume, monitoring and read-only tools are restored. Periodic views report whether Pi is idle; they do not measure background jobs. Intercom disconnects are reported; unsent current views and unacknowledged instructions are retained in Pi session history for reconnect. A receipt confirms transport handling, not execution. Reviews stop after all goals are completed or cancelled, and both panes remain available. These mechanics are tested; useful judgment and savings from a cheaper worker still require a representative two-model run. -- Pi/OpenAI
|
||||
On resume, monitoring and read-only tools are restored. Views include the latest human direction, source-session path, worker model, and new messages since the last acknowledged view. They report Pi idleness and tracked process/subagent activity separately. Unavailable trackers stay unknown; unregistered detached jobs are not tracked. Approval is blocked while tracked work is active or unknown. Intercom disconnects are reported; unsent current views and unacknowledged instructions are retained in Pi session history for reconnect. A receipt confirms transport handling, not execution. Reviews stop after all goals are completed or cancelled, and both panes remain available. These mechanics are tested; useful judgment and savings from a cheaper worker still require a representative two-model run. -- Pi/OpenAI
|
||||
|
||||
## Install
|
||||
|
||||
|
||||
@@ -11,14 +11,16 @@ User priority: pi-intercom is the session-to-session transport. Keep one pi-goal
|
||||
- failure modes: false readiness, duplicate delivery, wrong-session routing, stale callbacks after reload, disconnected peer treated as active.
|
||||
- deliverable: isolated two-session message transcript with exact instructions received, reconnect/reload checks, and saved validation output. Do not operate user panes.
|
||||
- evidence: [37 passing tests, typecheck and lint](../reviews/20260908-intercom-validation.txt). Two client sessions exchange readiness, view, and exact advice through a real isolated Intercom broker. Lifecycle mocks cover resume/deduplication. This is not yet a full live Pi-pair test. Mailbox source and polling were removed.
|
||||
- [ ] goal: supervisor receives a useful bounded worker overview
|
||||
- [x] goal: supervisor receives a useful bounded worker overview
|
||||
- Borrow latest human direction, source-session path, and incremental progress from `origin/feature/simple-visible-supervision`.
|
||||
- failure modes: lost authorization, repeated summaries, truncated evidence treated as complete.
|
||||
- deliverable: saved before/after overview fixtures covering compaction and changed human direction.
|
||||
- [ ] goal: supervisor distinguishes agent idleness from tracked background work
|
||||
- evidence: [generated fixture views](../reviews/20260908-worker-overview-example.txt) retain the human direction while omitting acknowledged old detail. [43-test validation](../reviews/20260908-worker-overview-validation.txt) also checks compaction reset and serialized Unicode limits; fixture content is synthetic, not a model performance claim.
|
||||
- [x] goal: supervisor distinguishes agent idleness from tracked background work
|
||||
- Borrow existing process/subagent tracker queries; report unavailable trackers as unknown.
|
||||
- failure modes: approving while a tracked job runs, treating a local queue as a dependency of remote work.
|
||||
- deliverable: idle/running/unknown status cases and a blocker-diagnosis scenario.
|
||||
- evidence: the same validation log checks active and unavailable tracker reports and rejection of approval with unknown background state. Actual independent diagnosis of the queue mistake remains a behavioral acceptance task.
|
||||
- [ ] goal: role model choices persist
|
||||
- Borrow planning/worker/supervisor model preference behavior without changing active user settings.
|
||||
- failure modes: automatic model changes overwrite user choices; a missing model silently substitutes another.
|
||||
|
||||
@@ -0,0 +1,27 @@
|
||||
INITIAL VIEW
|
||||
The worker stopped.
|
||||
|
||||
review trigger: settled
|
||||
source session: /isolated/worker.jsonl
|
||||
worker model: test/worker
|
||||
latest human direction:
|
||||
Modal does not use our GPU.
|
||||
tool calls with no result: none
|
||||
tracked background work: processes: 0; subagents: 0; unregistered work is not tracked
|
||||
|
||||
new worker transcript (initial or reset view):
|
||||
I am waiting for the local queue.
|
||||
|
||||
AFTER ACKNOWLEDGMENT
|
||||
The worker stopped.
|
||||
|
||||
review trigger: settled
|
||||
source session: /isolated/worker.jsonl
|
||||
worker model: test/worker
|
||||
latest human direction:
|
||||
Modal does not use our GPU.
|
||||
tool calls with no result: none
|
||||
tracked background work: processes: 0; subagents: 0; unregistered work is not tracked
|
||||
|
||||
new worker transcript since the last acknowledged view:
|
||||
The command launches a Modal remote GPU; local default stays paused.
|
||||
@@ -0,0 +1,68 @@
|
||||
|
||||
> @wassname2/pi-goals@0.2.2 test
|
||||
> vitest run --reporter=verbose
|
||||
|
||||
|
||||
RUN v4.1.9 /home/code/.pi/agent/git/github.com/wassname/pi-goals
|
||||
|
||||
✓ test/worker-view.test.ts > keeps human direction and source location while sending only new messages 6ms
|
||||
✓ test/worker-view.test.ts > restarts after compaction and does not report historical tool calls as active 0ms
|
||||
✓ test/worker-view.test.ts > bounds serialized Unicode and quoted logs while marking omissions 2ms
|
||||
✓ test/background.test.ts > reports tracked running work, rather than equating idle agent with finished jobs 2ms
|
||||
✓ test/background.test.ts > distinguishes missing providers from an unavailable installed tracker 5ms
|
||||
✓ test/package-agent.test.ts > package manifest > includes the extension without registering a packaged subagent 2ms
|
||||
✓ test/intercom.test.ts > pi-intercom transport > receives exact advice once, acknowledges it and rejects unrelated peers 5ms
|
||||
✓ test/intercom.test.ts > pi-intercom transport > restores an unacknowledged steer on reconnect and stops replay after acknowledgment 3ms
|
||||
✓ test/intercom.test.ts > pi-intercom transport > advances the incremental overview only after acknowledgment 1ms
|
||||
✓ test/intercom.test.ts > pi-intercom transport > cancels a readiness wait on shutdown 1ms
|
||||
✓ test/herdr.test.ts > supervisor pane command > forks the planning session with pi-goals owning its Intercom dependency 2ms
|
||||
✓ test/herdr.test.ts > supervisor pane command > accepts Herdr's text version output and stale pane cleanup 26ms
|
||||
✓ test/prompts.test.ts > planning prompt > requires fact finding or a focused question before a goal 2ms
|
||||
✓ test/prompts.test.ts > planning prompt > restores the same rule after compaction 1ms
|
||||
✓ test/prompts.test.ts > planning prompt > anchors work and sign-off to the user-visible result 0ms
|
||||
✓ test/append-log.test.ts > appendLog (the extension's only plan-file write) > creates ## Log at EOF when absent 1ms
|
||||
✓ test/append-log.test.ts > appendLog (the extension's only plan-file write) > appends after the last existing log line, before any following header 0ms
|
||||
✓ test/fold.test.ts > foldPlan (current goals are above ## Log; durable memory is below it) > keeps the title, user voice and goals 1ms
|
||||
✓ test/fold.test.ts > foldPlan (current goals are above ## Log; durable memory is below it) > drops the log, the learnings and the unlimited appendix 1ms
|
||||
✓ test/fold.test.ts > foldPlan (current goals are above ## Log; durable memory is below it) > returns the whole plan when there is no ## Log yet (a fresh draft) 0ms
|
||||
✓ test/fold.test.ts > openSubtasks (the widget shows the next action, so the plan IS the task list) > lists the active goal's open and in-progress subtasks, stopping at the next goal 1ms
|
||||
✓ test/fold.test.ts > openSubtasks (the widget shows the next action, so the plan IS the task list) > does not leak subtasks from the goal below 1ms
|
||||
✓ test/tick-goal.test.ts > tickGoal (sign-off ticks the goal; agent only ticks on wording drift) > ticks the exact-matching goal line, case-insensitive, leaving subtasks alone 2ms
|
||||
✓ test/tick-goal.test.ts > tickGoal (sign-off ticks the goal; agent only ticks on wording drift) > returns null on wording drift (fuzzy matching is the judge's job, not TypeScript's) 1ms
|
||||
✓ test/tick-goal.test.ts > tickGoal (sign-off ticks the goal; agent only ticks on wording drift) > returns null when the subject matches more than one goal line 0ms
|
||||
stdout | test/intercom-broker.test.ts > exchanges readiness, views and exact advice over a real isolated pi-intercom broker
|
||||
Intercom broker: readiness confirmed; exact worker view and supervisor advice received.
|
||||
|
||||
✓ test/intercom-broker.test.ts > exchanges readiness, views and exact advice over a real isolated pi-intercom broker 226ms
|
||||
✓ test/goals-flow.test.ts > /goals flow > reports actual idle state, invalidates stopped views on start, and stops completed plans 45ms
|
||||
✓ test/goals-flow.test.ts > /goals flow > preserves drafts, records the interview, and keeps planning read-only 17ms
|
||||
✓ test/goals-flow.test.ts > /goals flow > forks a visible supervisor on Ready and keeps the main session as worker 20ms
|
||||
✓ test/goals-flow.test.ts > /goals flow > starts work only after the supervisor launcher resolves 18ms
|
||||
✓ test/goals-flow.test.ts > /goals flow > delivers an Intercom instruction to the worker 22ms
|
||||
✓ test/goals-flow.test.ts > /goals flow > closes the supervisor on clear but keeps the plan file 20ms
|
||||
✓ test/goals-flow.test.ts > /goals flow > accepts only an approval for the exact clean commit and goal block 44ms
|
||||
✓ test/goals-flow.test.ts > process role > keeps subagent children and visible supervisors out of the worker extension 0ms
|
||||
✓ test/supervisor-session.test.ts > visible supervisor session > restores monitoring and read-only tools without replaying persisted views 5ms
|
||||
✓ test/supervisor-session.test.ts > visible supervisor session > renders all advice in real Pi tool rows, including collapsed and restored rows 20ms
|
||||
✓ test/supervisor-session.test.ts > visible supervisor session > asks for judgment and useful recaps without inventing instructions 1ms
|
||||
✓ test/supervisor-session.test.ts > visible supervisor session > writes readiness only after removing writing tools 1ms
|
||||
✓ test/supervisor-session.test.ts > visible supervisor session > compacts a large planning fork before writing readiness 1ms
|
||||
✓ test/supervisor-session.test.ts > visible supervisor session > does not become ready when initial compaction fails 1ms
|
||||
✓ test/supervisor-session.test.ts > visible supervisor session > writes a durable worker instruction 1ms
|
||||
✓ test/supervisor-session.test.ts > visible supervisor session > records approval only from a stopped view with evidence and no active work 49ms
|
||||
✓ test/rpc-review.test.ts > RPC review flow > opens Refine's editor before it starts the revision turn 1197ms
|
||||
|
||||
Test Files 13 passed (13)
|
||||
Tests 43 passed (43)
|
||||
Start at 16:41:18
|
||||
Duration 1.43s (transform 927ms, setup 0ms, import 3.00s, tests 1.77s, environment 1ms)
|
||||
|
||||
|
||||
> @wassname2/pi-goals@0.2.2 typecheck
|
||||
> tsc --noEmit
|
||||
|
||||
|
||||
> @wassname2/pi-goals@0.2.2 lint
|
||||
> biome check src/ test/
|
||||
|
||||
Checked 24 files in 19ms. No fixes applied.
|
||||
@@ -0,0 +1,33 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
|
||||
// Pi/OpenAI: Adapted from this repo's feature/simple-visible-supervision at cecb1e9.
|
||||
export async function backgroundState(pi: ExtensionAPI): Promise<{ quiet: boolean; description: string }> {
|
||||
const tools = pi.getAllTools();
|
||||
const hasProcesses = tools.some(tool => tool.name === "process");
|
||||
const hasSubagents = tools.some(tool => tool.name === "subagent");
|
||||
let processes: unknown;
|
||||
pi.events.emit("processes:request:list", { reply: (value: unknown) => { processes = value; } });
|
||||
const rows = Array.isArray(processes) ? processes : !hasProcesses && processes === undefined ? [] : null;
|
||||
const known = rows?.every(p => p && ["running", "terminating", "terminate_timeout", "exited", "killed"].includes(p.status));
|
||||
const activeProcesses = known ? rows!.filter(p => !["exited", "killed"].includes(p.status)) : null;
|
||||
let subagents: number | null = hasSubagents ? null : 0;
|
||||
if (hasSubagents) {
|
||||
const requestId = randomUUID();
|
||||
subagents = await new Promise<number | null>(resolve => {
|
||||
let unsubscribe: (() => void) | undefined;
|
||||
const finish = (value: number | null) => { clearTimeout(timer); unsubscribe?.(); resolve(value); };
|
||||
const timer = setTimeout(() => finish(null), 2000);
|
||||
unsubscribe = pi.events.on(`subagents:rpc:v1:reply:${requestId}`, (reply: any) => {
|
||||
if (reply?.requestId !== requestId) return;
|
||||
const count = reply?.success && reply?.data?.fleet?.version === 1 ? reply.data.fleet.totalActive : undefined;
|
||||
finish(Number.isSafeInteger(count) && count >= 0 ? count : null);
|
||||
});
|
||||
pi.events.emit("subagents:rpc:v1:request", { version: 1, requestId, method: "status", params: {}, source: { extension: "pi-goals" } });
|
||||
});
|
||||
}
|
||||
return {
|
||||
quiet: activeProcesses?.length === 0 && subagents === 0,
|
||||
description: `processes: ${activeProcesses?.length ?? "unknown"}${activeProcesses?.length ? ` (${activeProcesses.map(p => p.name || p.id).join(", ")})` : ""}; subagents: ${subagents ?? "unknown"}; unregistered detached work is not tracked`,
|
||||
};
|
||||
}
|
||||
+34
-10
@@ -21,6 +21,7 @@ import { fileURLToPath } from "node:url";
|
||||
import type { ExtensionAPI, ExtensionContext } from "@earendil-works/pi-coding-agent";
|
||||
import { Type } from "typebox";
|
||||
import { approvalMatches, approvalPath, goalBlock, hashGoalBlock, readApproval, repositoryState } from "./approval.js";
|
||||
import { backgroundState } from "./background.js";
|
||||
import { closeSupervisorPane, openSupervisorPane } from "./herdr.js";
|
||||
import { GoalIntercom } from "./intercom.js";
|
||||
import { completeGoalDescription, completeGoalParamDescription, planDrafting, planningState, resync } from "./prompts.js";
|
||||
@@ -97,6 +98,7 @@ interface PlanState {
|
||||
supervisorPaneId: string | null;
|
||||
approvalId: string | null;
|
||||
planVersion: number | null;
|
||||
latestDirection: string;
|
||||
}
|
||||
|
||||
export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
@@ -116,6 +118,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
supervisorPaneId: null,
|
||||
approvalId: null,
|
||||
planVersion: null,
|
||||
latestDirection: "",
|
||||
};
|
||||
let planningContextPending = false;
|
||||
let resyncReason: string | null = "New session.";
|
||||
@@ -185,11 +188,22 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
}
|
||||
|
||||
let workerTurns = 0;
|
||||
let viewGeneration = 0;
|
||||
let viewTimer: ReturnType<typeof setInterval> | undefined;
|
||||
|
||||
function publishWorkerView(ctx: ExtensionContext, reason: "ready" | "settled" | "turns" | "interval" | "started"): void {
|
||||
if (state.phase !== "working") return;
|
||||
intercom.view(workerView(ctx.sessionManager.getBranch(), reason, reason !== "started" && ctx.isIdle()), reason);
|
||||
async function publishWorkerView(ctx: ExtensionContext, reason: "ready" | "settled" | "turns" | "interval" | "started"): Promise<void> {
|
||||
if (state.phase !== "working" || intercom.ended) return;
|
||||
const generation = ++viewGeneration;
|
||||
const binding = state.approvalId;
|
||||
const background = reason === "started" ? { quiet: false, description: "agent starting; background state not queried" } : await backgroundState(pi);
|
||||
if (intercom.ended || generation !== viewGeneration || binding !== state.approvalId || state.phase !== "working") return;
|
||||
const entries = ctx.sessionManager.getBranch();
|
||||
const view = workerView(entries, reason, reason !== "started" && ctx.isIdle(), {
|
||||
sourceSession: ctx.sessionManager.getSessionFile()!, latestDirection: state.latestDirection,
|
||||
model: ctx.model ? `${ctx.model.provider}/${ctx.model.id}` : "not selected",
|
||||
since: intercom.acknowledgedEntry, background: background.description,
|
||||
});
|
||||
intercom.view(view, reason, entries.at(-1)?.id, background.quiet);
|
||||
const goals = scanGoals(readPlan(ctx));
|
||||
if (goals.length > 0 && goals.every((goal) => goal.status === "done" || goal.status === "cancelled")) {
|
||||
stopWorkerTimers();
|
||||
@@ -199,7 +213,9 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
}
|
||||
|
||||
function startWorkerTimers(ctx: ExtensionContext): void {
|
||||
if (!viewTimer) viewTimer = setInterval(() => publishWorkerView(ctx, "interval"), 60 * 60_000);
|
||||
if (!viewTimer) viewTimer = setInterval(() => {
|
||||
void publishWorkerView(ctx, "interval").catch(error => { if (!intercom.ended) ctx.ui.notify(`Worker view failed: ${String(error)}`, "error"); });
|
||||
}, 60 * 60_000);
|
||||
}
|
||||
|
||||
function stopWorkerTimers(): void {
|
||||
@@ -290,7 +306,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
ctx.ui.notify("Could not close the visible supervisor; no new plan was started.", "warning");
|
||||
return;
|
||||
}
|
||||
state = { ...state, phase: "planning", supervisorPaneId: null, approvalId: null, planVersion: nextVersion(ctx) };
|
||||
state = { ...state, phase: "planning", supervisorPaneId: null, approvalId: null, planVersion: nextVersion(ctx), latestDirection: arg };
|
||||
planningContextPending = true;
|
||||
resyncReason = null;
|
||||
writePlan(ctx, "");
|
||||
@@ -344,11 +360,14 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
|
||||
// PI: Human plan-mode replies are durable evidence of the interview, not model summaries.
|
||||
pi.on("input", async (event, ctx) => {
|
||||
if (state.phase === "planning" && event.source !== "extension") writePlan(ctx, appendInterview(readPlan(ctx), event.text));
|
||||
if (event.source === "extension") return;
|
||||
state = { ...state, latestDirection: event.text };
|
||||
persist();
|
||||
if (state.phase === "planning") writePlan(ctx, appendInterview(readPlan(ctx), event.text));
|
||||
});
|
||||
|
||||
pi.on("agent_start", async (_event, ctx) => {
|
||||
publishWorkerView(ctx, "started");
|
||||
await publishWorkerView(ctx, "started");
|
||||
});
|
||||
|
||||
pi.on("turn_end", async (_event, ctx) => {
|
||||
@@ -357,7 +376,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
workerTurns++;
|
||||
if (workerTurns < 50) return;
|
||||
workerTurns = 0;
|
||||
publishWorkerView(ctx, "turns");
|
||||
await publishWorkerView(ctx, "turns");
|
||||
});
|
||||
|
||||
pi.on("tool_call", async (event, ctx) => {
|
||||
@@ -383,7 +402,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
// PI: Print after Pi settles. agent_end is still streaming, so its message queues behind the menu.
|
||||
pi.on("agent_settled", async (_event, ctx) => {
|
||||
if (state.phase === "working") {
|
||||
publishWorkerView(ctx, "settled");
|
||||
await publishWorkerView(ctx, "settled");
|
||||
return;
|
||||
}
|
||||
if (state.phase !== "planning" || !ctx.hasUI) return;
|
||||
@@ -402,6 +421,8 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
if (choice === "Refine") {
|
||||
const notes = await ctx.ui.editor("What should change about the plan?", "");
|
||||
if (!notes?.trim()) continue;
|
||||
state = { ...state, latestDirection: notes };
|
||||
persist();
|
||||
writePlan(ctx, appendInterview(plan, notes));
|
||||
planningContextPending = true;
|
||||
pi.sendUserMessage(`Revise the plan at ${planPath(ctx)} using these human notes:\n\n${notes}\n\nKeep the same goal structure.`, { deliverAs: "followUp" });
|
||||
@@ -427,7 +448,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
resyncReason = "The plan was approved.";
|
||||
persist();
|
||||
startWorkerTimers(ctx);
|
||||
publishWorkerView(ctx, "ready");
|
||||
await publishWorkerView(ctx, "ready");
|
||||
updateWidget(ctx);
|
||||
ctx.ui.notify(`Visible supervisor opened in Herdr pane ${state.supervisorPaneId}.`, "info");
|
||||
pi.sendUserMessage("The plan is approved. Begin implementation as the worker.");
|
||||
@@ -452,6 +473,7 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
supervisorPaneId: last?.data?.supervisorPaneId ?? null,
|
||||
approvalId: last?.data?.approvalId ?? null,
|
||||
planVersion: last?.data?.planVersion ?? null,
|
||||
latestDirection: last?.data?.latestDirection ?? "",
|
||||
};
|
||||
planningContextPending = state.phase === "planning";
|
||||
resyncReason = state.phase === "working" ? "New session." : null;
|
||||
@@ -476,6 +498,8 @@ export default function piGoalsExtension(pi: ExtensionAPI): void {
|
||||
async execute(_id, params, _signal, _onUpdate, ctx) {
|
||||
if (state.phase !== "working") return result("Planning is not approved. Choose Ready before signing off a goal.", true);
|
||||
if (!state.approvalId) return result("Goal sign-off blocked: no current supervisor review.", true);
|
||||
const background = await backgroundState(pi);
|
||||
if (intercom.ended || !background.quiet) return result(`Goal sign-off blocked: ${background.description}`, true);
|
||||
const plan = readPlan(ctx);
|
||||
if (!plan.trim()) return result(`No plan file at ${planRel(ctx)}. Run /goals to draft one.`, true);
|
||||
const block = goalBlock(plan, params.goal);
|
||||
|
||||
+25
-11
@@ -3,8 +3,8 @@ import type { ExtensionAPI, ExtensionContext, SessionStartEvent } from "@earendi
|
||||
import type { IntercomExtensionChannel, IntercomExtensionEvent } from "pi-intercom/extension-api.ts";
|
||||
|
||||
export type Role = "worker" | "supervisor";
|
||||
export interface View { id: string; text: string; reason: string }
|
||||
interface Message { binding: string; role: Role; kind: "hello" | "view" | "steer" | "received"; id: string; text?: string; reason?: string; ready?: boolean }
|
||||
export interface View { id: string; text: string; reason: string; through?: string; backgroundQuiet: boolean }
|
||||
interface Message { binding: string; role: Role; kind: "hello" | "view" | "steer" | "received"; id: string; text?: string; reason?: string; ready?: boolean; through?: string; backgroundQuiet?: boolean }
|
||||
const STATE = "pi-goals-intercom";
|
||||
|
||||
export class GoalIntercom {
|
||||
@@ -21,6 +21,7 @@ export class GoalIntercom {
|
||||
private received = new Set<string>();
|
||||
private waiters = new Set<() => void>();
|
||||
latestView?: View;
|
||||
acknowledgedEntry?: string;
|
||||
onView: (view: View) => void = () => {};
|
||||
onSteer: (text: string) => void = () => {};
|
||||
|
||||
@@ -48,15 +49,19 @@ export class GoalIntercom {
|
||||
this.pending.clear();
|
||||
this.received.clear();
|
||||
this.latestView = undefined;
|
||||
this.acknowledgedEntry = undefined;
|
||||
for (const entry of ctx.sessionManager.getEntries()) {
|
||||
if (entry.type !== "custom" || entry.customType !== STATE) continue;
|
||||
const record = entry.data as { direction: string; message: Message };
|
||||
const message = record.message;
|
||||
if (message.binding !== binding) continue;
|
||||
if (record.direction === "out" && message.kind === "steer") this.pending.set(message.id, message);
|
||||
if (record.direction === "ack") this.pending.delete(message.id);
|
||||
if (record.direction === "ack") {
|
||||
this.pending.delete(message.id);
|
||||
if (message.through) this.acknowledgedEntry = message.through;
|
||||
}
|
||||
if (record.direction === "in") this.received.add(message.id);
|
||||
if (message.kind === "view") this.latestView = { id: message.id, text: message.text!, reason: message.reason! };
|
||||
if (message.kind === "view") this.latestView = { id: message.id, text: message.text!, reason: message.reason!, through: message.through, backgroundQuiet: message.backgroundQuiet === true };
|
||||
}
|
||||
this.hello();
|
||||
}
|
||||
@@ -80,11 +85,11 @@ export class GoalIntercom {
|
||||
});
|
||||
}
|
||||
|
||||
view(text: string, reason: string): View {
|
||||
view(text: string, reason: string, through?: string, backgroundQuiet = false): View {
|
||||
const id = randomUUID();
|
||||
const message: Message = { binding: this.binding, role: this.role, kind: "view", id, text: `${text}\n\nworker view id: ${id}`, reason };
|
||||
const message: Message = { binding: this.binding, role: this.role, kind: "view", id, text: `${text}\n\nworker view id: ${id}`, reason, through, backgroundQuiet };
|
||||
this.record("out", message);
|
||||
this.latestView = { id, text: message.text!, reason };
|
||||
this.latestView = { id, text: message.text!, reason, through, backgroundQuiet };
|
||||
if (this.connected) this.publish(message);
|
||||
return this.latestView;
|
||||
}
|
||||
@@ -143,14 +148,23 @@ export class GoalIntercom {
|
||||
return;
|
||||
}
|
||||
if (event.fromSessionId !== this.peer || !this.ready) return;
|
||||
if (message.kind === "received") { this.pending.delete(message.id); this.record("ack", message); return; }
|
||||
if (message.kind === "received") {
|
||||
this.pending.delete(message.id);
|
||||
const through = message.id === this.latestView?.id ? this.latestView.through : undefined;
|
||||
if (through) this.acknowledgedEntry = through;
|
||||
this.record("ack", { ...message, through });
|
||||
return;
|
||||
}
|
||||
if (this.received.has(message.id)) {
|
||||
if (message.kind === "steer") this.publish({ ...message, role: this.role, kind: "received" });
|
||||
if (message.kind === "steer" || (message.kind === "view" && message.reason !== "started")) this.publish({ binding: this.binding, role: this.role, kind: "received", id: message.id });
|
||||
return;
|
||||
}
|
||||
if (message.kind === "view" && this.role === "supervisor") {
|
||||
this.latestView = { id: message.id, text: message.text!, reason: message.reason! };
|
||||
if (message.reason !== "started") this.onView(this.latestView);
|
||||
this.latestView = { id: message.id, text: message.text!, reason: message.reason!, through: message.through, backgroundQuiet: message.backgroundQuiet === true };
|
||||
if (message.reason !== "started") {
|
||||
this.onView(this.latestView);
|
||||
this.publish({ binding: this.binding, role: this.role, kind: "received", id: message.id });
|
||||
}
|
||||
} else if (message.kind === "steer" && this.role === "worker") {
|
||||
this.onSteer(message.text!);
|
||||
this.received.add(message.id);
|
||||
|
||||
@@ -192,6 +192,7 @@ export function registerVisibleSupervisor(pi: ExtensionAPI): void {
|
||||
const newest = intercom.latestView;
|
||||
if (!intercom.connected || !newest || view !== newest.text) return result("Cannot approve without inspecting the latest worker view.", true);
|
||||
if (!view?.startsWith("The worker stopped.")) return result("Cannot approve without a current stopped-worker view.", true);
|
||||
if (!newest.backgroundQuiet) return result("Cannot approve while tracked background work is active or unknown.", true);
|
||||
const pendingTool = view.match(/^tool calls with no result: (?!none$)(.+)$/m);
|
||||
const pendingChild = view.match(/^child pi processes still running: (?!none$)(.+)$/m);
|
||||
if (pendingTool || pendingChild) return result(`Cannot approve while work is active: ${(pendingTool ?? pendingChild)![1]}`, true);
|
||||
|
||||
+26
-5
@@ -12,6 +12,7 @@ export interface SessionMessage {
|
||||
}
|
||||
|
||||
export interface SessionEntry {
|
||||
id?: string;
|
||||
type?: string;
|
||||
summary?: string;
|
||||
message?: SessionMessage;
|
||||
@@ -38,10 +39,30 @@ function outstandingTools(entries: SessionEntry[]): string[] {
|
||||
return [...calls].filter(([id]) => !results.has(id)).map(([, name]) => name);
|
||||
}
|
||||
|
||||
export function workerView(entries: SessionEntry[], reason: "ready" | "settled" | "turns" | "interval" | "started", idle: boolean): string {
|
||||
const summary = [...entries].reverse().find((entry) => entry.type === "compaction" && entry.summary)?.summary;
|
||||
const recent = entries.flatMap((entry) => entry.type === "message" && entry.message ? [text(entry.message)] : []).filter(Boolean).slice(-12).join("\n\n").slice(-12_000);
|
||||
const outstanding = outstandingTools(entries);
|
||||
function bounded(value: string, bytes: number, tail = false): string {
|
||||
if (Buffer.byteLength(JSON.stringify(value)) <= bytes) return value;
|
||||
let size = Math.min(value.length, bytes - 100);
|
||||
while (Buffer.byteLength(JSON.stringify(tail ? value.slice(-size) : value.slice(0, size))) > bytes - 100) size = Math.floor(size * 0.8);
|
||||
const notice = "[truncated; inspect source session]";
|
||||
return tail ? `${notice}\n${value.slice(-size)}` : `${value.slice(0, size)}\n${notice}`;
|
||||
}
|
||||
|
||||
export interface ViewContext {
|
||||
sourceSession: string;
|
||||
latestDirection: string;
|
||||
model: string;
|
||||
since?: string;
|
||||
background: string;
|
||||
}
|
||||
|
||||
export function workerView(entries: SessionEntry[], reason: "ready" | "settled" | "turns" | "interval" | "started", idle: boolean, context: ViewContext): string {
|
||||
const compactAt = entries.map(entry => entry.type).lastIndexOf("compaction");
|
||||
const since = context.since ? entries.findIndex(entry => entry.id === context.since) : -1;
|
||||
const from = since >= compactAt ? since + 1 : compactAt + 1;
|
||||
const fresh = entries.slice(from);
|
||||
const recent = fresh.flatMap(entry => entry.type === "message" && entry.message ? [text(entry.message)] : []).filter(Boolean).join("\n\n");
|
||||
const summary = since < compactAt ? entries[compactAt]?.summary : undefined;
|
||||
const outstanding = outstandingTools(entries.slice(compactAt + 1));
|
||||
const state = reason === "ready" ? "is ready to begin" : idle ? "stopped" : "is still working";
|
||||
return `The worker ${state}.\n\nreview trigger: ${reason}\ntool calls with no result: ${outstanding.join(", ") || "none"}\nbackground job state: not measured; inspect job evidence before approval\n\n${summary ? `last compaction summary:\n${summary}\n\n` : ""}recent worker transcript:\n${recent || "none"}`;
|
||||
return `The worker ${state}.\n\nreview trigger: ${reason}\nsource session: ${bounded(context.sourceSession, 800)}\nworker model: ${bounded(context.model, 300)}\nlatest human direction:\n${bounded(context.latestDirection || "not recorded", 1800)}\ntool calls with no result: ${bounded(outstanding.join(", ") || "none", 500)}\ntracked background work: ${bounded(context.background, 800)}\n\n${summary ? `compaction summary (worker account, not independent evidence):\n${bounded(summary, 2500)}\n\n` : ""}new worker transcript${since === -1 ? " (initial or reset view)" : " since the last acknowledged view"}:\n${bounded(recent || "No new messages.", 7000, true)}`;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,31 @@
|
||||
import { EventEmitter } from "node:events";
|
||||
import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
|
||||
import { expect, it, vi } from "vitest";
|
||||
import { backgroundState } from "../src/background.js";
|
||||
|
||||
function api(tools: string[], processes?: unknown, subagents?: number) {
|
||||
const bus = new EventEmitter();
|
||||
if (processes !== undefined) bus.on("processes:request:list", request => request.reply(processes));
|
||||
if (subagents !== undefined) bus.on("subagents:rpc:v1:request", request => bus.emit(`subagents:rpc:v1:reply:${request.requestId}`, { requestId: request.requestId, success: true, data: { fleet: { version: 1, totalActive: subagents } } }));
|
||||
return { getAllTools: () => tools.map(name => ({ name })), events: { emit: (name: string, value: unknown) => bus.emit(name, value), on: (name: string, fn: (...args: any[]) => void) => { bus.on(name, fn); return () => { bus.off(name, fn); }; } } } as unknown as ExtensionAPI;
|
||||
}
|
||||
|
||||
it("reports tracked running work, rather than equating idle agent with finished jobs", async () => {
|
||||
const active = await backgroundState(api(["process", "subagent"], [{ name: "generation", status: "running" }], 1));
|
||||
expect(active.quiet).toBe(false);
|
||||
expect(active.description).toContain("processes: 1 (generation)");
|
||||
expect(active.description).toContain("subagents: 1");
|
||||
const finished = await backgroundState(api(["process", "subagent"], [{ status: "exited" }], 0));
|
||||
expect(finished.quiet).toBe(true);
|
||||
});
|
||||
|
||||
it("distinguishes missing providers from an unavailable installed tracker", async () => {
|
||||
expect((await backgroundState(api([]))).quiet).toBe(true);
|
||||
expect(await backgroundState(api(["process"]))).toMatchObject({ quiet: false, description: expect.stringContaining("processes: unknown") });
|
||||
vi.useFakeTimers();
|
||||
try {
|
||||
const unavailable = backgroundState(api(["subagent"]));
|
||||
await vi.advanceTimersByTimeAsync(2000);
|
||||
expect(await unavailable).toMatchObject({ quiet: false, description: expect.stringContaining("subagents: unknown") });
|
||||
} finally { vi.useRealTimers(); }
|
||||
});
|
||||
@@ -52,6 +52,17 @@ describe("pi-intercom transport", () => {
|
||||
expect(resumed.fixture.sent.filter(message => message.kind === "steer")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("advances the incremental overview only after acknowledgment", async () => {
|
||||
const runtime = setup("worker");
|
||||
await runtime.link.waitReady();
|
||||
const view = runtime.link.view("The worker stopped.", "settled", "entry-1", true);
|
||||
expect(runtime.link.acknowledgedEntry).toBeUndefined();
|
||||
runtime.fixture.receive({ binding: "binding", role: "supervisor", kind: "received", id: view.id });
|
||||
expect(runtime.link.acknowledgedEntry).toBe("entry-1");
|
||||
const resumed = setup("worker", [...runtime.entries]);
|
||||
expect(resumed.link.acknowledgedEntry).toBe("entry-1");
|
||||
});
|
||||
|
||||
it("cancels a readiness wait on shutdown", async () => {
|
||||
const runtime = setup("worker");
|
||||
await runtime.link.waitReady();
|
||||
|
||||
@@ -49,8 +49,8 @@ function setup(cwd: string, planPath: string, tokens: number | null = 10, onComp
|
||||
activeTools: () => activeTools, branch: (value: any[]) => { branch = value; }, ctx, entries, hooks, transport, messages, tools,
|
||||
ready: () => transport.sent.some(message => message.kind === "hello" && message.role === "supervisor" && message.ready),
|
||||
start: async () => { await hooks.get("session_start")({}, ctx); await new Promise(resolve => setImmediate(resolve)); },
|
||||
view: (id: string, text: string, reason = "settled") => {
|
||||
transport.receive({ binding: "approval-1", role: "worker", kind: "view", id, text, reason });
|
||||
view: (id: string, text: string, reason = "settled", backgroundQuiet = true) => {
|
||||
transport.receive({ binding: "approval-1", role: "worker", kind: "view", id, text, reason, backgroundQuiet });
|
||||
return { text };
|
||||
},
|
||||
};
|
||||
@@ -216,6 +216,11 @@ describe("visible supervisor session", () => {
|
||||
const stale = await runtime.tools.get("ApproveGoal").execute("id", { goal: "make the file", verifyOutputPath: "verify.txt" }, undefined, undefined, runtime.ctx);
|
||||
expect(stale.isError).toBe(true);
|
||||
expect(stale.content[0].text).toContain("latest worker view");
|
||||
const unknown = runtime.view("third", "The worker stopped.\ntracked background work: unknown", "settled", false);
|
||||
runtime.branch([{ type: "message", message: { role: "user", content: [{ type: "text", text: unknown.text }] } }]);
|
||||
const blocked = await runtime.tools.get("ApproveGoal").execute("id", { goal: "make the file", verifyOutputPath: "verify.txt" }, undefined, undefined, runtime.ctx);
|
||||
expect(blocked.isError).toBe(true);
|
||||
expect(blocked.content[0].text).toContain("background work is active or unknown");
|
||||
} finally { rmSync(cwd, { recursive: true, force: true }); }
|
||||
});
|
||||
});
|
||||
|
||||
@@ -0,0 +1,37 @@
|
||||
import { expect, it } from "vitest";
|
||||
import { workerView } from "../src/worker-view.js";
|
||||
|
||||
const context = { sourceSession: "/sessions/worker.jsonl", latestDirection: "Modal uses a remote GPU.", model: "provider/worker", background: "processes: 0; subagents: 0" };
|
||||
const entry = (id: string, text: string) => ({ id, type: "message", message: { role: "assistant", content: text } });
|
||||
|
||||
it("keeps human direction and source location while sending only new messages", () => {
|
||||
const view = workerView([entry("old", "old detail"), entry("new", "new result")], "interval", true, { ...context, since: "old" });
|
||||
expect(view).toContain(context.latestDirection);
|
||||
expect(view).toContain(context.sourceSession);
|
||||
expect(view).toContain("new result");
|
||||
expect(view).not.toContain("old detail");
|
||||
});
|
||||
|
||||
it("restarts after compaction and does not report historical tool calls as active", () => {
|
||||
const entries = [
|
||||
{ id: "old", type: "message", message: { role: "assistant", content: [{ type: "toolCall", id: "call", name: "edit" }] } },
|
||||
{ id: "compact", type: "compaction", summary: "Saved worker account." },
|
||||
entry("new", "new result"),
|
||||
];
|
||||
const initial = workerView(entries, "interval", true, { ...context, since: "old" });
|
||||
expect(initial).toContain("Saved worker account.");
|
||||
expect(initial).toContain("tool calls with no result: none");
|
||||
const next = workerView(entries, "interval", true, { ...context, since: "new" });
|
||||
expect(next).not.toContain("Saved worker account.");
|
||||
expect(next).toContain("No new messages.");
|
||||
});
|
||||
|
||||
it("bounds serialized Unicode and quoted logs while marking omissions", () => {
|
||||
const view = workerView([
|
||||
{ id: "compact", type: "compaction", summary: '"\\🧪'.repeat(20_000) },
|
||||
entry("new", '"\\🧪'.repeat(20_000)),
|
||||
], "interval", true, { ...context, latestDirection: "Remote only. ".repeat(3000) });
|
||||
expect(Buffer.byteLength(JSON.stringify({ binding: "binding", role: "worker", kind: "view", id: "id", text: view }))).toBeLessThan(16_000);
|
||||
expect(view).toContain("[truncated; inspect source session]");
|
||||
expect(view).toContain(context.sourceSession);
|
||||
});
|
||||
Reference in New Issue
Block a user