mirror of
https://github.com/wassname/pi-plan.git
synced 2026-09-25 14:00:15 +08:00
Wake supervisors on owned worker stops
Require interactive workers to use the correlated OpenGoalWorker path, reject mismatched attachments, and queue stop supervision even while the parent is busy. Preserve direct human control of worker conversations and models.\n\nCo-Authored-By: PI/OpenAI <288921227+claudypoo@users.noreply.github.com>
This commit is contained in:
+18
-4
@@ -67,6 +67,7 @@ const REPORT = "pi-goals-report", REVIEW = "pi-goals-report-review", REVIEW_DRAF
|
||||
const RUN = "pi-goals-worker-run", STOP = "pi-goals-worker-stop", WORKER_EVENT = "pi-goals-worker-event";
|
||||
type GoalEventKind = "review_request" | "decision" | "blocker" | "completion" | "progress" | "running" | "waiting" | "receipt" | "no_change" | "aborted" | "unclassified";
|
||||
const REVIEWABLE_EVENTS = new Set<GoalEventKind>(["review_request", "blocker", "completion"]);
|
||||
const ATTENTION_EVENTS = new Set<GoalEventKind>(["decision", "aborted", "unclassified"]);
|
||||
interface WorkerStop { type: "stopped"; entryId: string; to: string; requestId: string; plan: string; text: string; identity: Peer; kind?: GoalEventKind; }
|
||||
interface Report { id: string; plan: string; session: string; sessionFile: string; requestId: string; task?: string; text: string; kind: GoalEventKind; supersedes?: string; }
|
||||
type WorkerEvent = Report;
|
||||
@@ -397,14 +398,14 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
if (records<Report>(ctx, REPORT).some(saved => saved.id === report.id)) return;
|
||||
pi.appendEntry(REPORT, report);
|
||||
send(workerReview(report.plan, report.session, `${report.id}\n${report.text}\n\n${savedWorkerView(ctx, { ...report, runtimeId: state.worker?.identity?.sessionId })}`), false, true);
|
||||
if (wake && ctx.isIdle()) remindReports(ctx);
|
||||
if (wake) remindReports(ctx);
|
||||
}
|
||||
function recordWorkerEvent(ctx: ExtensionContext, event: WorkerEvent, wake = true) {
|
||||
if (REVIEWABLE_EVENTS.has(event.kind)) { recordReport(ctx, event, wake); return; }
|
||||
if (records<Report>(ctx, REPORT).some(saved => saved.id === event.id) || records<WorkerEvent>(ctx, WORKER_EVENT).some(saved => saved.id === event.id)) return;
|
||||
pi.appendEntry(WORKER_EVENT, event);
|
||||
const context = ["waiting", "aborted", "unclassified"].includes(event.kind) ? `${event.text}\n\n${savedWorkerView(ctx, { ...event, runtimeId: state.worker?.identity?.sessionId })}` : event.text;
|
||||
const needsDirectAttention = event.kind === "decision" && wake && ctx.isIdle();
|
||||
const needsDirectAttention = ATTENTION_EVENTS.has(event.kind) && wake;
|
||||
send(workerStatus(event.plan, event.session, event.id, event.kind, context), needsDirectAttention, true);
|
||||
}
|
||||
function remindReports(ctx: ExtensionContext) {
|
||||
@@ -447,6 +448,12 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
}
|
||||
if (event.type !== "message" || !event.payload || typeof event.payload !== "object") return;
|
||||
const data = event.payload as { type?: string; to?: string; requestId?: string; plan?: string; sessionFile?: string; text?: string; identity?: Peer; entryId?: string; kind?: GoalEventKind; review?: ReportReview };
|
||||
if (data.type === "attachment_rejected" && state.child && state.parent && event.fromSessionId === state.parent.intercomId && data.requestId === state.parent.requestId && data.plan === state.plan) {
|
||||
const text = data.text || nativeMessages.attachmentRejected;
|
||||
state.mode = "paused"; state.parent = undefined; generation++; notice = true; save(); refresh(ctx);
|
||||
send(text, true, true);
|
||||
return;
|
||||
}
|
||||
if (data.type === "review" && state.child && state.parent && event.fromSessionId === state.parent.intercomId && records<State>(ctx, STATE).some(saved => saved.child && saved.parent?.intercomId === event.fromSessionId && saved.parent.requestId === data.requestId && saved.plan === data.plan) && data.sessionFile === ctx.sessionManager.getSessionFile() && data.review) {
|
||||
const prior = records<ReportReview>(ctx, REVIEW).find(saved => reviewedReportId(saved) === reviewedReportId(data.review!));
|
||||
const review = prior || data.review;
|
||||
@@ -472,6 +479,13 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
return;
|
||||
}
|
||||
const worker = state.worker;
|
||||
if (data.type === "attached" && !state.child && worker?.parentId && data.to === worker.parentId && data.plan === state.plan
|
||||
&& (!data.requestId || data.requestId !== worker.requestId || Boolean(worker.intercomId && worker.intercomId !== event.fromSessionId))) {
|
||||
const text = nativeMessages.uncorrelatedAttachment(worker.requestId, data.requestId, worker.paneId || worker.identity?.paneId);
|
||||
channel?.publish({ type: "attachment_rejected", to: event.fromSessionId, requestId: data.requestId, plan: data.plan, text }, { audience: "capable" });
|
||||
send(workerStatus(data.plan || state.plan || "unknown", event.fromSessionId, `attachment:${data.requestId || "missing"}`, "unclassified", text), true, true);
|
||||
return;
|
||||
}
|
||||
if (state.child || !worker || !state.plan || !worker.parentId || !data.requestId || data.to !== worker.parentId || event.fromSessionId === data.to || data.requestId !== worker.requestId || data.plan !== state.plan) return;
|
||||
if (data.type === "attached" && typeof data.sessionFile === "string" && isAbsolute(data.sessionFile) && (!worker.intercomId || worker.intercomId === event.fromSessionId)) {
|
||||
worker.intercomId = event.fromSessionId; worker.sessionFile = data.sessionFile;
|
||||
@@ -517,8 +531,6 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
if (automatic && kind === "unclassified") {
|
||||
const ended = records<WorkerStop>(liveContext, STOP).filter(inRun).at(-1);
|
||||
if (ended) return ended;
|
||||
const status = records<WorkerStop>(liveContext, WORKER_EVENT).filter(inRun).at(-1);
|
||||
if (status) { pi.appendEntry(STOP, status); return status; } // Finish this run without another status or wake.
|
||||
}
|
||||
const type = automatic || REVIEWABLE_EVENTS.has(kind) ? STOP : WORKER_EVENT;
|
||||
const entryId = `${runId}:${digest(`${state.parent.requestId}:${state.plan}:${kind}:${text}`)}`;
|
||||
@@ -646,6 +658,8 @@ export default function mainSupervisor(pi: ExtensionAPI) {
|
||||
}
|
||||
});
|
||||
pi.on("tool_call", (event, ctx) => {
|
||||
if (state.mode !== "chat" && event.toolName === "intercom" && event.input?.openProjectPaneIfMissing === true) return { block: true, reason: nativeMessages.workerCreationRequiresOpenGoalWorker };
|
||||
if (state.mode !== "chat" && event.toolName === "subagent" && event.input?.action === "project.open") return { block: true, reason: nativeMessages.workerCreationRequiresOpenGoalWorker };
|
||||
if (["schedule_task", "manage_scheduled_task"].includes(event.toolName)) {
|
||||
const input = event.input as Record<string, unknown>;
|
||||
const ids = ownedCheckInIds(ctx);
|
||||
|
||||
+8
-5
@@ -161,9 +161,9 @@ const helperGuidance = "Use ordinary stock async helpers when useful, not anothe
|
||||
export const childPlanRole = "You are the delegated implementation worker. Save evidence and report progress for your delegated work; leave plan maintenance to the parent. Preserve agreed goals, requirements and discriminators; the supervisor owns goal-status changes and completion approval. Do not launch a second writer. Call AttachGoalPlan with the explicit plan path in your task before implementation (also after reconnect if unbound). Immediately report your actual Intercom UUID, saved-session path and current provider/model to the supplied supervisor ID. Identify unavailable fields as unknown; do not equate runtime IDs, session filenames and Intercom IDs. Call ReportGoalEvent when there is a meaningful result or status change, including a later blocker or completion after progress. Do not repeat unchanged events. review_request, blocker and completion require formal parent review. decision asks the parent to choose or steer directly without that form. progress, running, waiting, receipt and no_change do not require review; use progress when work changed but the correct instruction is simply to continue. Treat any proposed change to protected project intent, editorial/publication authority, core research design or evaluation principles as a decision, not an ordinary implementation choice. Put the canonical summary and exact artifact paths in the event. When waiting, name the child/job you await, its owner or handle, and what will wake you. Ending a turn while followed work continues is not task completion. Then stay open for live messages. Do not exit or use caller_ping; unsent editor drafts are not visible in model context." + " " + helperGuidance;
|
||||
export function readyApproved(workerName: string, planPath: string, notedWorker: string | undefined, plan: string, supervisorId: string): string {
|
||||
const launch = notedWorker
|
||||
? `Inspect recorded history ${notedWorker} and actual writer state. If live, steer that exact Intercom session; do not replace its conversation. If stopped, preserve history and drafts and use stock project.status/project.close/project.open only after verified safe stop.`
|
||||
? `Inspect recorded history ${notedWorker} and actual writer state. If live, steer that exact Intercom session; do not replace its conversation. If stopped, preserve history and drafts. Do not bypass worker ownership with project.open; use a bounded stock helper if the recorded pane cannot be safely reused.`
|
||||
: `Use OpenGoalWorker with a bounded proposed task for '${workerName}'. It uses stock project.open, not subagent execution. A new worker attaches and waits; after inspecting its report, send the authorized task through exact-session Intercom.`;
|
||||
return `[pi-goals: approval — Ready]\nReady approved this plan: ${planPath}. Stay here as supervisor. ${launch} Confirm your actual Intercom UUID with status/list; your Pi session ID ${supervisorId} is a distinct field. Await explicit worker attachment and a report with actual Intercom UUID, saved-session path and resolved model. Inspect results and steer corrections in that same open session. A receipt or idle pane is not attachment, writer exit or completion.\n\n${quotedPlan(planPath, foldPlan(plan), "working set before Log")}`;
|
||||
return `[pi-goals: approval — Ready]\nReady approved this plan: ${planPath}. Stay here as supervisor. ${launch} Confirm your actual Intercom UUID with status/list; your Pi session ID ${supervisorId} is a distinct field. Await explicit worker attachment and a report with actual Intercom UUID, saved-session path and resolved model. worker_view must show the correlated saved session before assignment. Never create a replacement goals-worker through raw Intercom openProjectPaneIfMissing or subagent project.open: those panes are not parent-owned and their automatic stop events cannot be supervised. Use OpenGoalWorker for an interactive worker, or a bounded stock helper for authorized non-pane work. Inspect results and steer corrections in the same owned session. A receipt, roster row or idle pane is not attachment, writer exit or completion.\n\n${quotedPlan(planPath, foldPlan(plan), "working set before Log")}`;
|
||||
}
|
||||
|
||||
export function workerAssignment(plan: string, parent: string, requestId: string, task: string, model?: string): string {
|
||||
@@ -184,8 +184,8 @@ Use full review_subagent evidence only for completion, a bounded artifact needin
|
||||
Humour is a reflective meta-learning mechanism, not decoration. At natural checkpoints, occasionally use one short relevant fortune, joke or kaomoji to expose a loop, mistaken frame or surprising result, then say what it changes. Keep it sparse; never put it in formal evidence or force cheerfulness. (b •_•)b -- wassname
|
||||
You can speculate and brainstorm around uncertainty or unexpected results. Label guesses as guesses, consider alternative explanations, and look for a useful way to tell them apart. Keep exploration brief, open-minded and fun: take a step back, play with surprising ideas, question the current framing, and enjoy exploring the broader perspective while staying connected to the agreed goal.
|
||||
Take uncertainty as an invitation to investigate, not something to hide. Have room to play with ideas, question yourself and the worker, and appreciate a good surprise. Investigate surprising results, find mistaken assumptions, make complicated ideas simpler, and disagree usefully rather than agree politely. Keep the work moving without turning supervision into paperwork. A little affectionate teasing is welcome when it fits, and workers can push back too. Keep the humor friendly and the criticism specific. -- Pi/Astra
|
||||
Use OpenGoalWorker for the first native project pane and stock Intercom for exact-session assignment/report/steering. Do not use subagent as a second goals-worker backend. Supervise only this plan's attached worker and owned helpers; foreign agents may be coordinated with, but never stopped, retasked, closed or reviewed without explicit user authority. ${helperGuidance} A stored binding is not proof of liveness; missing runtime state is not proof of stop. Verify actual Intercom identities with list/status; your Pi session ID is ${supervisorId}, a distinct field. Require artifact paths, saved verification and blocker/error reports. When the worker stops for any reason, inspect actual artifacts and saved messages before approving or correcting it in the same open session. A recap or receipt alone sends no instruction and proves no action. Record actual pane identity, '- worker session:' and '- worker intercom session:' with provenance. CompleteGoal belongs only to this parent or explicitly confirmed solo self-verification.
|
||||
Keep normal tools and honor human model changes. Inherit by default. If the user supplies a model preference, pass it explicitly to the agent through OpenGoalWorker's model instruction or exact-session Intercom steering; let the agent configure it through supported controls and verify its actual choice. project.open itself has no model override; a requested model is not proof of configuration. Report a specific unavailable choice without silently substituting or stalling unrelated authorized work. After compaction reread the plan. Lost connection or exhausted credits does not erase work. Preserve drafts and saved sessions; confirm other writers stopped before solo takeover. Revisions use ordinary Intercom in the same context. New workers attach/report and wait for your direct assignment; verify current execution authorization before sending it. For stopped-worker replacement, inspect saved history and partial work, preserve any editor draft/queued input and confirm the exact writer stopped before stock project.close/project.open. Idle or absence alone cannot establish draft safety or writer exit. If safety is unobservable, retain the pane and inspect it; do not invent recovery controls. Do not reapply historical preferences over later human choices. Never replace an unreviewed conversation or start a duplicate writer.`;
|
||||
Use OpenGoalWorker for the native project pane and stock Intercom only for exact-session assignment/report/steering after correlated attachment. Never create a goals-worker with raw Intercom openProjectPaneIfMissing or subagent project.open. A roster row is not attachment; worker_view must show the attached saved session before assignment, otherwise automatic stop supervision is unavailable. Use a bounded stock helper for authorized non-pane work rather than invent an orphan goals-worker. Do not use subagent as a second goals-worker backend. Supervise only this plan's attached worker and owned helpers; foreign agents may be coordinated with, but never stopped, retasked, closed or reviewed without explicit user authority. ${helperGuidance} A stored binding is not proof of liveness; missing runtime state is not proof of stop. Verify actual Intercom identities with list/status; your Pi session ID is ${supervisorId}, a distinct field. Require artifact paths, saved verification and blocker/error reports. When the worker stops for any reason, inspect actual artifacts and saved messages before approving or correcting it in the same open session. A recap or receipt alone sends no instruction and proves no action. Record actual pane identity, '- worker session:' and '- worker intercom session:' with provenance. CompleteGoal belongs only to this parent or explicitly confirmed solo self-verification.
|
||||
Keep normal tools and honor human model changes. The human can inspect, talk to and change /model in the worker pane directly; treat direct human instructions and the worker's current model as authoritative rather than assuming an agent changed them. Do not revert either unless the human asks. Inherit by default. If the user supplies a model preference to the supervisor, pass it explicitly to the agent through OpenGoalWorker's model instruction or exact-session Intercom steering; let the agent configure it through supported controls and verify its actual choice. project.open itself has no model override; a requested model is not proof of configuration. Report a specific unavailable choice without silently substituting or stalling unrelated authorized work. After compaction reread the plan. Lost connection or exhausted credits does not erase work. Preserve drafts and saved sessions; confirm other writers stopped before solo takeover. Revisions use ordinary Intercom in the same context. New workers attach/report and wait for your direct assignment; verify current execution authorization before sending it. For stopped-worker replacement, inspect saved history and partial work, preserve any editor draft/queued input and confirm the exact writer stopped before stock project.close/project.open. Idle or absence alone cannot establish draft safety or writer exit. If safety is unobservable, retain the pane and inspect it; do not invent recovery controls. Do not reapply historical preferences over later human choices. Never replace an unreviewed conversation or start a duplicate writer.`;
|
||||
}
|
||||
// Routine notices quote only selected goal lines; full context stops at Log.
|
||||
const goalLines = (text: string) => foldPlan(text).split("\n").filter(line => GOAL_LINE.test(line)).join("\n");
|
||||
@@ -262,7 +262,7 @@ export const goalToolBlocked = (mode: string) => `Goals are ${mode}; this execut
|
||||
export const emptyEvidence = (path: string) => `Empty evidence: ${path}`;
|
||||
export const evidenceUnavailable = (error: unknown) => `Evidence unavailable: ${String(error)}. No sign-off recorded.`;
|
||||
export const planUnavailable = (path: string | undefined, error: unknown) => `Goal plan ${path ?? "not attached"} unavailable: ${String(error)}. Do not implement or sign off until it is restored or explicitly attached. Retain all progress and reviewed plan status; do not restart completed work.`;
|
||||
export const childPlanAttached = (path: string) => `Attached worker plan ${path}; plan context restored without altering the file. Parent retains completion authority.`;
|
||||
export const childPlanAttached = (path: string) => `Worker attachment request sent for ${path}; plan context restored without altering the file. The parent must accept the correlated request before assigning work. Parent retains completion authority.`;
|
||||
export function completionLog(goal: string, observation: string, evidence: string[], solo: boolean): string {
|
||||
return `- ${solo ? "Solo self-verification" : "Parent review"}: ${JSON.stringify(goal)}; ${JSON.stringify(observation)}; evidence ${JSON.stringify(evidence)}`;
|
||||
}
|
||||
@@ -308,4 +308,7 @@ export const nativeMessages = {
|
||||
intercomNotReady: "Intercom is still connecting. Call intercom status/list, verify the live parent identity, then retry this operation in the same session. No attachment or launch changed.",
|
||||
reportUnavailable: "Automatic worker notice could not reach Intercom. The saved result remains here; restore the connection and report to the exact parent. Do not infer delivery or completion.",
|
||||
attached: (sessionFile: string) => `Worker attached. Saved session: ${sessionFile}. Inspect its report and current authorization before assigning work through Intercom. Attachment is not completion.`,
|
||||
attachmentRejected: "Parent rejected this worker attachment because it did not match the owned launch request. Stop; do not edit, launch jobs or accept assignments. Ask the parent to use OpenGoalWorker or an authorized stock helper.",
|
||||
uncorrelatedAttachment: (expected: string | undefined, received: string | undefined, pane: string | undefined) => `Rejected uncorrelated worker attachment: expected request ${expected ?? "unknown"}${pane ? ` for pane ${pane}` : ""}, received ${received ?? "missing"}. This pane is not parent-owned, so its automatic stop events cannot be supervised. Do not assign it goals-worker work; use OpenGoalWorker or a bounded stock helper.`,
|
||||
workerCreationRequiresOpenGoalWorker: "Goal mode can create an interactive worker only through OpenGoalWorker, which records attachment and automatic stop correlation. Raw Intercom openProjectPaneIfMissing and subagent project.open would create an orphan worker. Message existing sessions normally, or use a bounded stock helper for authorized non-pane work.",
|
||||
};
|
||||
|
||||
+34
-2
@@ -96,6 +96,7 @@ it("reserves formal reviews for approvals, completion and genuine blockers", ()
|
||||
expect(role).toContain("Goal, Changed, Judgment, Next, Need from you");
|
||||
expect(role).toContain("never stopped, retasked, closed or reviewed without explicit user authority");
|
||||
expect(role).toContain("Humour is a reflective meta-learning mechanism");
|
||||
expect(role).toContain("The human can inspect, talk to and change /model in the worker pane directly");
|
||||
expect(role).toContain("Never wait on an inferred or nonexistent pane");
|
||||
expect(goalCheckInWake).toContain("reserve formal review for completion, bounded artifact approval or a genuine accepted blocker");
|
||||
});
|
||||
@@ -1158,6 +1159,11 @@ it("passive pause is visible immediately while its model notice waits safely for
|
||||
// The native surface has one project binding; these replace old launch-schema/helper tests.
|
||||
it("opens no-focus, records explicit attachment only, and wakes review only for the exact worker", async () => {
|
||||
const f = fixture(); await f.draft(); await f.command("ready");
|
||||
for (const event of [
|
||||
{ toolName: "intercom", input: { action: "send", cwd: "/tmp/other", openProjectPaneIfMissing: true } },
|
||||
{ toolName: "subagent", input: { action: "project.open", cwd: "/tmp/other" } },
|
||||
]) expect(f.hooks.get("tool_call")(event, f.ctx)).toMatchObject({ block: true, reason: expect.stringContaining("orphan worker") });
|
||||
expect(f.hooks.get("tool_call")({ toolName: "intercom", input: { action: "send", to: "existing" } }, f.ctx)).toBeUndefined();
|
||||
f.channel.listSessions.mockRejectedValueOnce(new Error("Intercom is not connected"));
|
||||
const waiting = await f.tools.get("OpenGoalWorker").execute("open", { task: "first" }, undefined, undefined, f.ctx);
|
||||
expect(waiting.content[0].text).toContain("still connecting"); expect(openProjectPane).not.toHaveBeenCalled();
|
||||
@@ -1168,6 +1174,10 @@ it("opens no-focus, records explicit attachment only, and wakes review only for
|
||||
expect(worker).toMatchObject({ paneId: "native-pane", intercomId: "worker-id", sessionFile: "/tmp/native-worker.jsonl" });
|
||||
expect(f.messages.at(-1)).toMatchObject({ message: { customType: "pi-goals-supervision", display: true, content: expect.stringContaining("Metadata only; no acknowledgement or review turn requested") }, options: { triggerTurn: false } });
|
||||
expect(f.messages.at(-1).savedPrompt).toBeUndefined();
|
||||
f.event({ type: "message", fromSessionId: "orphan-worker", payload: { type: "attached", to: worker.parentId, requestId: "invented-request", plan: f.path, sessionFile: "/tmp/orphan.jsonl" } });
|
||||
expect(f.channel.publish).toHaveBeenLastCalledWith(expect.objectContaining({ type: "attachment_rejected", to: "orphan-worker", requestId: "invented-request" }), { audience: "capable" });
|
||||
expect(f.messages.at(-1)?.message.content).toContain("Rejected uncorrelated worker attachment");
|
||||
expect(f.messages.at(-1)?.savedPrompt).toBe(true);
|
||||
const notice = { type: "stopped", to: worker.parentId, requestId: worker.requestId, plan: f.path, entryId: "revision-1", kind: "blocker", text: "Blocked: input missing" };
|
||||
const count = f.messages.length;
|
||||
for (const fromSessionId of [worker.parentId, "foreign-id"]) f.event({ type: "message", fromSessionId, payload: notice });
|
||||
@@ -1192,9 +1202,15 @@ it("opens no-focus, records explicit attachment only, and wakes review only for
|
||||
expect(f.messages.at(-1)?.message.content).toContain("## Worker status: decision");
|
||||
expect(f.messages.at(-1)?.savedPrompt).toBe(true);
|
||||
expect(f.ctx.sessionManager.getBranch().filter((entry: any) => entry.customType === "pi-goals-report")).toHaveLength(formalReports);
|
||||
f.ctx.isIdle.mockReturnValue(false);
|
||||
f.event({ type: "message", fromSessionId: "worker-id", payload: { ...notice, entryId: "automatic-stop", kind: "unclassified", text: "Worker turn ended without an explicit event." } });
|
||||
expect(f.messages.at(-1)?.message.content).toContain("## Worker status: unclassified");
|
||||
expect(f.messages.at(-1)?.savedPrompt).toBe(true);
|
||||
expect(f.ctx.sessionManager.getBranch().filter((entry: any) => entry.customType === "pi-goals-report")).toHaveLength(formalReports);
|
||||
f.ctx.isIdle.mockReturnValue(true);
|
||||
await f.command("status");
|
||||
expect(f.ctx.ui.notify.mock.lastCall?.[0]).toContain("Latest worker status event: decision");
|
||||
expect(f.ctx.ui.notify.mock.lastCall?.[0]).not.toContain("decision-1");
|
||||
expect(f.ctx.ui.notify.mock.lastCall?.[0]).toContain("Latest worker status event: unclassified");
|
||||
expect(f.ctx.ui.notify.mock.lastCall?.[0]).not.toContain("automatic-stop");
|
||||
expect(readFileSync(f.path, "utf8")).not.toContain("[✓]");
|
||||
await f.command("stop");
|
||||
f.event({ type: "message", fromSessionId: "worker-id", payload: { ...notice, entryId: "revision-2", text: "New report during pause" } });
|
||||
@@ -1204,6 +1220,22 @@ it("opens no-focus, records explicit attachment only, and wakes review only for
|
||||
expect(f.messages).toHaveLength(cleared);
|
||||
});
|
||||
|
||||
it("automatically reports worker turn end and pauses a rejected attachment", async () => {
|
||||
const f = fixture(); const path = join(f.ctx.cwd, "supplied.md"); writeFileSync(path, f.plan);
|
||||
await f.tools.get("AttachGoalPlan").execute("attach", { path, parent: "live-parent", requestId: "owned-request" }, undefined, undefined, f.ctx);
|
||||
f.hooks.get("agent_start")({}, f.ctx);
|
||||
const assistant = { role: "assistant", content: [{ type: "text", text: "Awaiting review." }], stopReason: "stop" };
|
||||
f.ctx.sessionManager.getBranch().push({ type: "message", id: "automatic-stop-turn", message: assistant });
|
||||
await f.tools.get("ReportGoalEvent").execute("waiting", { kind: "waiting", summary: "Awaiting review." }, undefined, undefined, f.ctx);
|
||||
expect(f.channel.publish).toHaveBeenLastCalledWith(expect.objectContaining({ type: "stopped", requestId: "owned-request", kind: "waiting" }), { audience: "capable" });
|
||||
f.hooks.get("agent_end")({ messages: [assistant] }, f.ctx);
|
||||
expect(f.channel.publish).toHaveBeenLastCalledWith(expect.objectContaining({ type: "stopped", requestId: "owned-request", kind: "unclassified", text: "Awaiting review." }), { audience: "capable" });
|
||||
f.event({ type: "message", fromSessionId: "live-parent", payload: { type: "attachment_rejected", to: "worker", requestId: "owned-request", plan: path, text: "Parent rejected the uncorrelated attachment." } });
|
||||
expect(f.entries.at(-1).data).toMatchObject({ child: true, mode: "paused", plan: path });
|
||||
expect(f.entries.at(-1).data.parent).toBeUndefined();
|
||||
expect(f.messages.at(-1)?.message.content).toContain("Parent rejected the uncorrelated attachment");
|
||||
});
|
||||
|
||||
it("ordinary project peer explicitly attaches as worker, never gaining approval authority", async () => {
|
||||
vi.stubEnv("PI_SUBAGENT_CHILD", "1");
|
||||
const helperApi = { on: vi.fn(), registerTool: vi.fn() };
|
||||
|
||||
@@ -188,9 +188,10 @@ it("plans and reviews the same worker across failure, delivery retry and reload"
|
||||
for (const inspection of inspections) { expect(inspection.isError).not.toBe(true); expect((inspection.result as any).details.results).toEqual([]); }
|
||||
expect((inspections.at(-1)!.result as any).details.spawnBudget.used).toBe(0);
|
||||
expect(worker.messages.slice(inspectAt).find(m => m.type === "tool_execution_end" && m.toolName === "OpenGoalWorker")?.isError).toBe(true);
|
||||
expect(requests.parent).toHaveLength(parentCount); // inspection/receipt did not wake the supervisor
|
||||
await expect.poll(() => requests.parent.length).toBe(parentCount + 1); // automatic turn end wakes the supervisor; the receipt itself does not
|
||||
expect(JSON.stringify(requests.parent.at(-1)!.messages)).toContain("Worker status: unclassified");
|
||||
const workerState = await state(worker), workerFile = workerState.sessionFile;
|
||||
expect(records(parentState.sessionFile, "pi-goals-worker-event").map(event => event.kind)).toEqual(["receipt"]);
|
||||
expect(records(parentState.sessionFile, "pi-goals-worker-event").map(event => event.kind)).toEqual(["receipt", "unclassified"]); // turn end is independently observable
|
||||
const receiptNotice = entries(parentState.sessionFile).find(entry => entry.customType === "pi-goals-notice" && String(entry.data?.content).includes("Attached and waiting."));
|
||||
expect(Buffer.byteLength(receiptNotice.data.content)).toBeLessThan(1500); // routine status does not carry a history dump
|
||||
// Real native scheduler commands continue after the worker turn. Hold their HTTP
|
||||
|
||||
Reference in New Issue
Block a user