mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-11-10 11:46:36 +01:00
feat(task-chat): approve harness-proposed plans (Codex plan mode, Claude ExitPlanMode) (#19291)
## Thinking Path > - Paperclip is the open source app people use to manage AI agents for work > - Agents run through the native runner. Codex and Claude Code each have their own plan mode, and each produces a proposed-plan document. > - Paperclip already has a planning work mode, an issue `plan` document, and a `request_confirmation` approval gate for plans. > - These parts were not connected. The Codex proposed-plan item was transcript text only. The server path never put Codex into plan mode. Claude was never put into plan mode, and its ExitPlanMode plan was dropped. A planning run could close its task as done with no approval. > - This pull request turns a harness's own proposed plan into the issue's Plan document and the existing approval gate, for Codex and Claude. > - The benefit is that users approve or send back the agent's real plan from the task chat. Approve resumes the agent. Request changes sends the reason back to the agent. ## Linked Issues or Issue Description Refs #19252 **Subsystem affected** Native runner provider events (Codex and ACPX Claude), the runner Codex provider (Rust), server native plan synchronization, and the task chat UI. **Problem or motivation** Codex and Claude both produce a proposed-plan document in their native plan mode. Paperclip did not use it. In live tests on the server path, a Codex planning run was not in Codex plan mode, saved its own `implementation-plan` document, and marked the task done without approval. Claude planning runs were not in Claude plan mode, and the ExitPlanMode plan text never reached Paperclip. **Proposed solution** Add a canonical `plan.proposed` event for a harness's proposed plan. Put Codex and Claude into their native plan mode for planning runs. Save a completed proposal as the issue `plan` document with the existing `request_confirmation` gate. Agents can also propose a plan on a standard task; the work mode stays standard. **Alternatives considered** Reuse `plan.updated`. Its schema is the within-turn checklist and is not the durable Plan document, so a separate event keeps the two clear. Stronger prompt text without native plan mode. Live tests showed that the model kept its own document workflow. **Roadmap alignment** This reuses the issue plan document, planning work mode and approval gate that already exist. It adds one canonical provider event. ## What Changed - `packages/paperclip-runner/protocol/schemas/*`: add the `plan.proposed` event (`paperclip.plan.proposed.v1`: `proposalId`, `status`, `markdown` up to 60k characters, `source`). Regenerate the schema bundle, validators and manifest. - `packages/paperclip-runner/src/provider-events.ts`: map the Codex `plan` item and a `<proposed_plan>` block in a completed Codex message to `plan.proposed`. Add `claudePlanProposalEvent`. - `packages/paperclip-runner/runner/crates/runner-core/src/codex_provider.rs`: send Codex's native `collaborationMode: plan` on `turn/start` for a read-only (planning) Codex run. Before this change the server path never sent it. - `runner-core/src/acpx_event_payload.rs`: admit `plan.proposed` as ACPX rich display activity. - `drivers/acpx/codex-runtime-adapter.ts`: set Claude Code to native plan mode for a read-only run. Capture Claude's ExitPlanMode plan from its permission request, record it, and decline the native "Ready to code?" prompt. - `drivers/acpx/codex-acpx-driver.ts`, `cli/acpx-runtime-sidecar.ts`: emit the captured proposal as a rich event. Complete that turn instead of reporting it as cancelled. - `backends/codex-native-backend.ts`: per-harness plan-mode instructions, and a short opt-in hint for Claude on standard tasks. - `server/src/services/native-runtime/native-session-executor.ts`: synchronize a completed `plan.proposed` into the `plan` document and a `request_confirmation` in planning and standard mode. Do not change the issue status during the run (that revoked the run's lease). The run finalizer turns the pending confirmation into the review wait. - `server/src/services/heartbeat-run-summary.ts`: keep a `<proposed_plan>` block out of the posted reply. - UI: new `plan_proposal` provider family. The task chat shows a live "Writing plan" card while a proposal streams and a one-line record when it completes. Resolved plan reviews keep a receipt ("Approved Plan v1", "Requested changes to Plan v1: reason"). Plan previews skip section headings. - Storybook: `Tasks and conversations/.../Task chat/Proposed plan approval` shows the journey. - `doc/SPEC-implementation.md`: describe harness-proposed plan approval. ## Verification - `cargo test -p paperclip-runner-core --lib` (new: `read_only_codex_turns_use_native_plan_collaboration_mode`) - `cd packages/paperclip-runner && npx vitest run src/provider-events.test.ts src/drivers/acpx/codex-runtime-adapter.test.ts` - `cd server && npx vitest run src/__tests__/native-plan-proposal-sync.test.ts src/__tests__/heartbeat-run-summary.test.ts` - `cd ui && npx vitest run src/components/transcript/native-run-events.test.ts src/components/task-chat` - Live test on a local worktree instance with real harnesses: - Codex (`gpt-5.6-sol`), planning task: Codex asked one question, then produced a native plan item. The task showed Plan v1 with Approve plan / Request changes and moved to in review. Approve changed the work mode to standard. Codex then implemented the plan and its tests passed. - Codex, Request changes with a reason: Codex woke in planning mode and produced Plan v2 that includes the change. - Claude (`claude-opus-5-5`), planning task: Claude was in plan mode. ExitPlanMode became Plan v1 and the turn completed. Approve resumed Claude in standard mode, and Claude implemented the plan. - Claude, standard task that asks for a plan first: Claude stopped before code changes and asked for plan approval. The work mode stayed standard. - Storybook: open `Proposed plan approval` and check each stage. ## Risks - Codex planning runs now really use Codex plan mode on the server path. Codex can ask a clarifying question before it proposes. This matches Codex plan mode. - Claude planning runs now use Claude plan mode. A declined ExitPlanMode ends the Claude turn; the runner reports it as completed. Claude's tool row for that request records a rejection from Claude's side. - Codex does not stream the proposed plan on the server path yet, so the live card shows a placeholder until the plan completes. - A `<proposed_plan>` block in any completed Codex message on a standard task now asks for approval. This is the documented opt-in path. > For core feature work, check [`ROADMAP.md`](ROADMAP.md) first and discuss it in `#dev` before opening the PR. Feature PRs that overlap with planned core work may need to be redirected — check the roadmap first. See `CONTRIBUTING.md`. ## Model Used - Anthropic Claude Opus 5.5 (`claude-opus-5-5`), via Claude Code with tool use (shell, file edits, browser). Long context. No extended thinking mode was used. ## Checklist - [x] I have included a thinking path that traces from project context to this change - [x] I have specified the model used (with version and capability details) - [x] I have checked ROADMAP.md and confirmed this PR does not duplicate planned core work - [x] I have searched GitHub for duplicate or related PRs and linked them above - [x] I have either (a) linked existing issues with `Fixes: #` / `Closes #` / `Refs #` OR (b) described the issue in-PR following the relevant issue template - [x] I have not referenced internal/instance-local Paperclip issues or links (only public GitHub `#NNN` / `github.com/paperclipai/paperclip` URLs) - [x] My branch name describes the change (e.g. `docs/...`, `fix/...`) and contains no internal Paperclip ticket id or instance-derived details - [x] I have run tests locally and they pass - [x] I have added or updated tests where applicable - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [ ] All Paperclip CI gates are green - [ ] Greptile is 5/5 with no open P2s, recommendations, or follow-ups - [ ] I will address all Greptile and reviewer comments before requesting merge 🤖 Generated with [Claude Code](https://claude.com/claude-code) --------- Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
1b0f78100d
commit
22281d4b3a
51 files changed
+2015
-116
No files matched your search
@@ -9,6 +9,7 @@ import {
|
||||
isExternalChatPresentationContext,
|
||||
isChatOriginPublicationCandidate,
|
||||
mergeHeartbeatRunResultJson,
|
||||
completedPlanProposalIds,
|
||||
readCompletedAssistantMessageCandidate,
|
||||
resolveHeartbeatRunResponse,
|
||||
selectHeartbeatRunFinalAgentMessage,
|
||||
@@ -177,6 +178,29 @@ describe("readCompletedAssistantMessageCandidate", () => {
|
||||
},
|
||||
);
|
||||
|
||||
it("leaves a recorded proposed plan to its Plan document and posts only the surrounding reply", () => {
|
||||
const candidate = (text: string, proposed = true) => readCompletedAssistantMessageCandidate({
|
||||
seq: 3,
|
||||
prpEvent: { sourceEventId: "runner:plan", itemId: "msg-1", payload: { kind: "agentMessage", channel: "final", text } },
|
||||
proposedItemIds: proposed ? new Set(["msg-1"]) : new Set(),
|
||||
});
|
||||
const withPlan = "Plan ready for approval.\n<proposed_plan>\n# Fix\n1. Ship\n</proposed_plan>";
|
||||
expect(candidate(withPlan)?.text).toBe("Plan ready for approval.");
|
||||
expect(candidate("<proposed_plan>\n# Fix\n</proposed_plan>")).toBeNull();
|
||||
// Without a recorded proposal the reply keeps the plan text.
|
||||
expect(candidate(withPlan, false)?.text).toBe(withPlan);
|
||||
const documented = "Use this format:\n```\n<proposed_plan>\n# Plan\n</proposed_plan>\n```";
|
||||
expect(candidate(documented)?.text).toBe(documented);
|
||||
expect(candidate("Plan ready.\n<proposed_plan>\n# Fix\n1. Ship…[truncated]")?.text).toBe("Plan ready.");
|
||||
// A fenced example in a cut-off preview keeps its text.
|
||||
const fencedPreview = "Format:\n```\n<proposed_plan>\n# Plan…[truncated]";
|
||||
expect(candidate(fencedPreview)?.text).toBe(fencedPreview);
|
||||
expect(completedPlanProposalIds([
|
||||
{ payload: { prpEvent: { payload: { proposalId: "msg-1", status: "completed" } } } },
|
||||
{ payload: { prpEvent: { payload: { proposalId: "msg-2", status: "streaming" } } } },
|
||||
])).toEqual(new Set(["msg-1"]));
|
||||
});
|
||||
|
||||
it("keeps the persisted PRP v1 assistant_message alias readable", () => {
|
||||
expect(
|
||||
readCompletedAssistantMessageCandidate({
|
||||
|
||||
@@ -0,0 +1,200 @@
|
||||
import { resolveExternalTestDatabaseUrl } from "./helpers/external-test-database.js";
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { eq } from "drizzle-orm";
|
||||
import { afterAll, afterEach, beforeAll, describe, expect, it } from "vitest";
|
||||
import {
|
||||
activityLog,
|
||||
agents,
|
||||
companies,
|
||||
createDb,
|
||||
documentRevisions,
|
||||
documents,
|
||||
goals,
|
||||
heartbeatRuns,
|
||||
issueDocuments,
|
||||
issueThreadInteractions,
|
||||
issues,
|
||||
} from "@paperclipai/db";
|
||||
import { synchronizeCompletedProviderPlan } from "../services/native-runtime/native-session-executor.js";
|
||||
import { documentService } from "../services/documents.js";
|
||||
import {
|
||||
getEmbeddedPostgresTestSupport,
|
||||
startEmbeddedPostgresTestDatabase,
|
||||
} from "./helpers/embedded-postgres.js";
|
||||
|
||||
const external = resolveExternalTestDatabaseUrl("PAPERCLIP_PLAN_PROPOSAL_TEST_DATABASE_URL");
|
||||
const embeddedPostgresSupport = external ? { supported: true } : await getEmbeddedPostgresTestSupport();
|
||||
const describeEmbeddedPostgres = embeddedPostgresSupport.supported ? describe : describe.skip;
|
||||
|
||||
const PLAN = "# Stop duplicate webhook deliveries\n\n1. Add a unique index.\n2. Upsert on the event id.";
|
||||
|
||||
describeEmbeddedPostgres("harness-proposed plan synchronization", () => {
|
||||
let db!: ReturnType<typeof createDb>;
|
||||
let tempDb: Awaited<ReturnType<typeof startEmbeddedPostgresTestDatabase>> | null = null;
|
||||
|
||||
beforeAll(async () => {
|
||||
if (!external) tempDb = await startEmbeddedPostgresTestDatabase("paperclip-plan-proposal-sync-");
|
||||
db = createDb(external ?? tempDb!.connectionString, { maxConnections: 6 });
|
||||
}, 20_000);
|
||||
|
||||
afterEach(async () => {
|
||||
await db.delete(activityLog);
|
||||
await db.delete(issueThreadInteractions);
|
||||
await db.delete(issueDocuments);
|
||||
await db.delete(documentRevisions);
|
||||
await db.delete(documents);
|
||||
await db.delete(heartbeatRuns);
|
||||
await db.delete(issues);
|
||||
await db.delete(goals);
|
||||
await db.delete(agents);
|
||||
await db.delete(companies);
|
||||
});
|
||||
|
||||
afterAll(async () => {
|
||||
await db?.$client.end({ timeout: 1 });
|
||||
await tempDb?.cleanup();
|
||||
});
|
||||
|
||||
async function seed(workMode: "standard" | "planning" | "ask") {
|
||||
const companyId = randomUUID();
|
||||
const goalId = randomUUID();
|
||||
const issueId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const runId = randomUUID();
|
||||
await db.insert(companies).values({ id: companyId, name: "Paperclip", issuePrefix: "PPS", requireBoardApprovalForNewAgents: false });
|
||||
await db.insert(goals).values({ id: goalId, companyId, title: "Ship", level: "task", status: "active" });
|
||||
await db.insert(agents).values({
|
||||
id: agentId, companyId, name: "Claude", role: "engineer", status: "active",
|
||||
adapterType: "claude_local", adapterConfig: {}, runtimeConfig: {}, permissions: {},
|
||||
});
|
||||
await db.insert(issues).values({
|
||||
id: issueId, companyId, goalId, title: "Fix duplicate deliveries", status: "in_progress",
|
||||
priority: "medium", assigneeAgentId: agentId, workMode,
|
||||
});
|
||||
await db.insert(heartbeatRuns).values({ id: runId, companyId, agentId, status: "running", contextSnapshot: { issueId } });
|
||||
const binding = { runId, issueId, companyId, agentId };
|
||||
return { companyId, issueId, agentId, runId, binding };
|
||||
}
|
||||
|
||||
function proposalEvent(markdown: string, status: "streaming" | "completed" = "completed") {
|
||||
return {
|
||||
sourceEventId: `event-${randomUUID()}`,
|
||||
turnId: "turn-1",
|
||||
eventType: "plan.proposed",
|
||||
payload: {
|
||||
schema: "paperclip.plan.proposed.v1",
|
||||
proposalId: "toolu_plan",
|
||||
status,
|
||||
markdown,
|
||||
source: "claude_exit_plan_mode",
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
it("saves an agent-initiated proposal on a Standard task as the Plan document and asks for approval", async () => {
|
||||
const { issueId, binding } = await seed("standard");
|
||||
const execution = { binding, provider: { kind: "acpx" }, task: { workMode: "standard" } } as never;
|
||||
|
||||
const result = await synchronizeCompletedProviderPlan({ db, execution, event: proposalEvent(PLAN) });
|
||||
|
||||
expect(result).toMatchObject({ status: "synchronized", planId: "toolu_plan", documentRevision: 1 });
|
||||
expect((await documentService(db).getIssueDocumentByKey(issueId, "plan"))?.body).toBe(PLAN);
|
||||
const [interaction] = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, issueId));
|
||||
expect(interaction).toMatchObject({ kind: "request_confirmation", status: "pending", sourceRunId: binding.runId });
|
||||
expect(interaction?.payload).toMatchObject({
|
||||
acceptLabel: "Approve plan",
|
||||
rejectLabel: "Request changes",
|
||||
target: { type: "issue_document", key: "plan", revisionNumber: 1 },
|
||||
});
|
||||
const [issue] = await db.select().from(issues).where(eq(issues.id, issueId));
|
||||
// The agent opted into a plan gate; the task's work mode stays Standard.
|
||||
// The run finalizer turns the pending confirmation into the review wait.
|
||||
expect(issue).toMatchObject({ status: "in_progress", workMode: "standard" });
|
||||
});
|
||||
|
||||
it("revises an existing Plan document from its current revision", async () => {
|
||||
const { issueId, binding } = await seed("standard");
|
||||
await documentService(db).upsertIssueDocument({
|
||||
issueId, key: "plan", title: "Plan", format: "markdown", body: "# Old plan", createdByAgentId: binding.agentId,
|
||||
});
|
||||
const execution = { binding, provider: { kind: "acpx" }, task: { workMode: "standard" } } as never;
|
||||
|
||||
const result = await synchronizeCompletedProviderPlan({ db, execution, event: proposalEvent(PLAN) });
|
||||
|
||||
expect(result).toMatchObject({ status: "synchronized", documentRevision: 2 });
|
||||
expect((await documentService(db).getIssueDocumentByKey(issueId, "plan"))?.body).toBe(PLAN);
|
||||
});
|
||||
|
||||
it("lets a planning run's full proposal supersede its own earlier checklist sync", async () => {
|
||||
const { issueId, binding } = await seed("planning");
|
||||
const execution = {
|
||||
binding,
|
||||
provider: { kind: "codex" },
|
||||
task: { workMode: "planning" },
|
||||
executionMode: "plan",
|
||||
planningContext: { documentId: null, baseRevisionId: null, baseRevisionNumber: 0, markdown: "", sha256: "none", reviewContext: {} },
|
||||
} as never;
|
||||
const checklist = await synchronizeCompletedProviderPlan({
|
||||
db,
|
||||
execution,
|
||||
event: {
|
||||
sourceEventId: "checklist-1",
|
||||
eventType: "plan.updated",
|
||||
payload: { schema: "paperclip.plan.updated.v1", planId: "turn-1", revision: 1, complete: true, steps: [{ stepId: "s1", body: "Inspect", status: "completed" }] },
|
||||
},
|
||||
});
|
||||
expect(checklist).toMatchObject({ status: "synchronized", documentRevision: 1 });
|
||||
|
||||
const proposal = await synchronizeCompletedProviderPlan({ db, execution, event: proposalEvent(PLAN) });
|
||||
|
||||
expect(proposal).toMatchObject({ status: "synchronized", documentRevision: 2 });
|
||||
expect((await documentService(db).getIssueDocumentByKey(issueId, "plan"))?.body).toBe(PLAN);
|
||||
const pending = await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.status, "pending"));
|
||||
expect(pending.map((row) => (row.payload as { target?: { revisionNumber?: number } }).target?.revisionNumber)).toContain(2);
|
||||
});
|
||||
|
||||
it("still refuses a proposal when someone else edited the plan after the pinned base", async () => {
|
||||
const { issueId, binding } = await seed("planning");
|
||||
await documentService(db).upsertIssueDocument({
|
||||
issueId, key: "plan", title: "Plan", format: "markdown", body: "# Human edit", createdByUserId: "local-board",
|
||||
});
|
||||
const execution = {
|
||||
binding,
|
||||
provider: { kind: "codex" },
|
||||
task: { workMode: "planning" },
|
||||
executionMode: "plan",
|
||||
planningContext: { documentId: null, baseRevisionId: null, baseRevisionNumber: 0, markdown: "", sha256: "none", reviewContext: {} },
|
||||
} as never;
|
||||
|
||||
const result = await synchronizeCompletedProviderPlan({ db, execution, event: proposalEvent(PLAN) });
|
||||
|
||||
expect(result).toMatchObject({ status: "conflict" });
|
||||
expect((await documentService(db).getIssueDocumentByKey(issueId, "plan"))?.body).toBe("# Human edit");
|
||||
});
|
||||
|
||||
it("never writes the Plan document from an Ask-mode run", async () => {
|
||||
const { issueId, binding } = await seed("ask");
|
||||
const execution = { binding, provider: { kind: "acpx" }, task: { workMode: "ask" } } as never;
|
||||
|
||||
expect(await synchronizeCompletedProviderPlan({ db, execution, event: proposalEvent(PLAN) })).toBeNull();
|
||||
expect(await documentService(db).getIssueDocumentByKey(issueId, "plan")).toBeNull();
|
||||
expect(await db.select().from(issueThreadInteractions).where(eq(issueThreadInteractions.issueId, issueId))).toEqual([]);
|
||||
});
|
||||
|
||||
it("ignores streaming proposals and Standard-mode checklists", async () => {
|
||||
const { issueId, binding } = await seed("standard");
|
||||
const execution = { binding, provider: { kind: "codex" }, task: { workMode: "standard" } } as never;
|
||||
|
||||
expect(await synchronizeCompletedProviderPlan({ db, execution, event: proposalEvent("# Partial", "streaming") })).toBeNull();
|
||||
expect(await synchronizeCompletedProviderPlan({
|
||||
db,
|
||||
execution,
|
||||
event: {
|
||||
sourceEventId: "checklist-1",
|
||||
eventType: "plan.updated",
|
||||
payload: { schema: "paperclip.plan.updated.v1", planId: "turn-1", revision: 1, complete: true, steps: [{ stepId: "s1", body: "Done", status: "completed" }] },
|
||||
},
|
||||
})).toBeNull();
|
||||
expect(await documentService(db).getIssueDocumentByKey(issueId, "plan")).toBeNull();
|
||||
});
|
||||
});
|
||||
@@ -227,6 +227,7 @@ export const PRP_V1_EVENT_TYPES = new Set([
|
||||
"harness.exited",
|
||||
"harness.diagnostic",
|
||||
"plan.updated",
|
||||
"plan.proposed",
|
||||
"tool.execution.started",
|
||||
"tool.execution.progressed",
|
||||
"tool.execution.completed",
|
||||
|
||||
@@ -29,6 +29,53 @@ function readNumericField(record: Record<string, unknown>, key: string) {
|
||||
return key in record ? (record[key] ?? null) : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Splits a message around one `<proposed_plan>` block. The tags must sit on
|
||||
* their own lines outside fenced code, so quoting or documenting the tag never
|
||||
* creates a proposal.
|
||||
*/
|
||||
function splitProposedPlanBlock(value: string): { plan: string | null; rest: string; unclosedStart: number | null } {
|
||||
const lines = value.split("\n");
|
||||
let fence: string | null = null;
|
||||
let start = -1;
|
||||
for (let index = 0; index < lines.length; index += 1) {
|
||||
const trimmed = lines[index]!.trim();
|
||||
// Fences are tracked inside the block too, so a fenced example tag in
|
||||
// the plan never opens or closes it.
|
||||
const marker = /^(`{3,}|~{3,})/.exec(trimmed)?.[1];
|
||||
if (marker) {
|
||||
fence = fence === null ? marker[0]! : (trimmed.startsWith(fence) ? null : fence);
|
||||
continue;
|
||||
}
|
||||
if (fence !== null) continue;
|
||||
if (start < 0 && /^<proposed_plan>$/i.test(trimmed)) start = index;
|
||||
else if (start >= 0 && /^<\/proposed_plan>$/i.test(trimmed)) {
|
||||
return {
|
||||
plan: lines.slice(start + 1, index).join("\n").trim(),
|
||||
rest: [...lines.slice(0, start), ...lines.slice(index + 1)].join("\n").trim(),
|
||||
unclosedStart: null,
|
||||
};
|
||||
}
|
||||
}
|
||||
// An opening tag outside fences that never closes: a cut-off preview.
|
||||
return {
|
||||
plan: null,
|
||||
rest: value,
|
||||
unclosedStart: start < 0 ? null : lines.slice(0, start).join("\n").length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* The posted reply leaves a proposal to its Plan document. A stored message
|
||||
* preview can be cut inside the block; its cut-off remainder is hidden too.
|
||||
*/
|
||||
function withoutProposedPlanBlock(value: string): string {
|
||||
const { plan, rest, unclosedStart } = splitProposedPlanBlock(value);
|
||||
if (plan !== null) return rest;
|
||||
if (!value.endsWith("…[truncated]") || unclosedStart === null) return value;
|
||||
return value.slice(0, unclosedStart).trim();
|
||||
}
|
||||
|
||||
function readCommentText(value: unknown) {
|
||||
if (typeof value !== "string") return null;
|
||||
return value.trim().length > 0 ? value : null;
|
||||
@@ -229,9 +276,26 @@ export function isChatOriginPublicationCandidate(contextSnapshot: unknown): bool
|
||||
* solely for the compatibility fallback selected by the resolver. Other
|
||||
* channels and item kinds remain activity, never durable reply candidates.
|
||||
*/
|
||||
/**
|
||||
* Item ids whose `<proposed_plan>` block became a completed `plan.proposed`
|
||||
* event. Only those replies leave the plan to its Plan document.
|
||||
*/
|
||||
export function completedPlanProposalIds(rows: readonly { payload: unknown }[]): Set<string> {
|
||||
const ids = new Set<string>();
|
||||
for (const row of rows) {
|
||||
const payload = record(record(record(row.payload).prpEvent).payload);
|
||||
if (payload.status === "completed" && typeof payload.proposalId === "string") {
|
||||
ids.add(payload.proposalId);
|
||||
}
|
||||
}
|
||||
return ids;
|
||||
}
|
||||
|
||||
export function readCompletedAssistantMessageCandidate(input: {
|
||||
seq: number;
|
||||
prpEvent: unknown;
|
||||
/** Item ids with a recorded proposal; other replies keep their plan text. */
|
||||
proposedItemIds?: ReadonlySet<string>;
|
||||
}): CompletedFinalAgentMessageCandidate | null {
|
||||
const prpEvent = record(input.prpEvent);
|
||||
const payload = record(prpEvent.payload);
|
||||
@@ -244,7 +308,15 @@ export function readCompletedAssistantMessageCandidate(input: {
|
||||
if (payload.channel !== "final" && payload.channel !== "unknown") {
|
||||
return null;
|
||||
}
|
||||
const text = readCommentText(payload.text);
|
||||
// A harness's `<proposed_plan>` block that became a recorded proposal is the
|
||||
// task's Plan document and approval card; that reply keeps only the
|
||||
// surrounding prose. Without a recorded proposal the reply keeps the plan.
|
||||
const itemId = typeof prpEvent.itemId === "string" ? prpEvent.itemId
|
||||
: typeof payload.itemId === "string" ? payload.itemId : null;
|
||||
const proposed = itemId !== null && input.proposedItemIds?.has(itemId) === true;
|
||||
const text = readCommentText(
|
||||
typeof payload.text === "string" && proposed ? withoutProposedPlanBlock(payload.text) : payload.text,
|
||||
);
|
||||
if (!text) return null;
|
||||
return {
|
||||
seq: input.seq,
|
||||
|
||||
@@ -121,6 +121,7 @@ import {
|
||||
import {
|
||||
findHeartbeatRunCompletionComment,
|
||||
hasAcceptedSemanticResult,
|
||||
completedPlanProposalIds,
|
||||
readCompletedAssistantMessageCandidate,
|
||||
selectHeartbeatRunFinalAgentMessage,
|
||||
type RunPresentationDecision,
|
||||
@@ -2336,11 +2337,23 @@ export function createHeartbeatLifecycle(db: Db, dependencies: HeartbeatLifecycl
|
||||
sourceEventId: string | null;
|
||||
channel: "final" | "unknown";
|
||||
}> = [];
|
||||
const proposedItemIds = completedPlanProposalIds(await db
|
||||
.select({ payload: heartbeatRunEvents.payload })
|
||||
.from(heartbeatRunEvents)
|
||||
.where(
|
||||
and(
|
||||
eq(heartbeatRunEvents.companyId, companyId),
|
||||
eq(heartbeatRunEvents.runId, runId),
|
||||
eq(heartbeatRunEvents.eventType, "plan.proposed"),
|
||||
),
|
||||
)
|
||||
.limit(200));
|
||||
for (const row of rows) {
|
||||
const prpEvent = parseObject(parseObject(row.payload).prpEvent);
|
||||
const candidate = readCompletedAssistantMessageCandidate({
|
||||
seq: row.seq,
|
||||
prpEvent,
|
||||
proposedItemIds,
|
||||
});
|
||||
if (candidate) candidates.push(candidate);
|
||||
}
|
||||
|
||||
@@ -91,6 +91,7 @@ import {
|
||||
findHeartbeatRunCompletionComment,
|
||||
isExternalChatPresentationContext,
|
||||
readAcceptedTaskContinuationSummary,
|
||||
completedPlanProposalIds,
|
||||
readCompletedAssistantMessageCandidate,
|
||||
resolveHeartbeatRunResponse,
|
||||
selectHeartbeatRunFinalAgentMessage,
|
||||
@@ -1019,9 +1020,15 @@ async function recoverCommittedNativeChatResponseInScope(
|
||||
sql`${heartbeatRunEvents.payload} #>> '{prpEvent,payload,kind}' = 'agentMessage'`,
|
||||
sql`${heartbeatRunEvents.payload} #>> '{prpEvent,payload,channel}' = 'final'`,
|
||||
)).orderBy(desc(heartbeatRunEvents.seq)).limit(200);
|
||||
const proposedItemIds = completedPlanProposalIds(await tx.select({ payload: heartbeatRunEvents.payload })
|
||||
.from(heartbeatRunEvents).where(and(
|
||||
eq(heartbeatRunEvents.companyId, input.companyId),
|
||||
eq(heartbeatRunEvents.runId, run.id),
|
||||
eq(heartbeatRunEvents.eventType, "plan.proposed"),
|
||||
)).limit(200));
|
||||
const finalAgentMessage = selectHeartbeatRunFinalAgentMessage({
|
||||
candidates: rows.flatMap((row) => {
|
||||
const candidate = readCompletedAssistantMessageCandidate({ seq: row.seq, prpEvent: record(row.payload).prpEvent });
|
||||
const candidate = readCompletedAssistantMessageCandidate({ seq: row.seq, prpEvent: record(row.payload).prpEvent, proposedItemIds });
|
||||
return candidate ? [candidate] : [];
|
||||
}),
|
||||
});
|
||||
|
||||
@@ -1593,6 +1593,35 @@ export function continuingPendingInteractionIds(
|
||||
.map((response) => response.interactionId);
|
||||
}
|
||||
|
||||
/**
|
||||
* A planning run can synchronize its completed checklist before the harness's
|
||||
* full proposal arrives. The proposal then supersedes that same-run revision
|
||||
* (its pending review expires as stale); a revision written by anyone else
|
||||
* since the pinned base still fails the revision check.
|
||||
*/
|
||||
async function proposalBaseRevisionId(input: {
|
||||
db: Db;
|
||||
issueId: string;
|
||||
runId: string;
|
||||
pinnedRevisionId: string | null;
|
||||
proposal: boolean;
|
||||
}): Promise<string | null> {
|
||||
if (!input.proposal) return input.pinnedRevisionId;
|
||||
const latest = await documentService(input.db).getIssueDocumentByKey(input.issueId, "plan");
|
||||
if (!latest?.latestRevisionId || latest.latestRevisionId === input.pinnedRevisionId) {
|
||||
return input.pinnedRevisionId;
|
||||
}
|
||||
const latestRevision = await input.db
|
||||
.select({ createdByRunId: documentRevisions.createdByRunId })
|
||||
.from(documentRevisions)
|
||||
.where(eq(documentRevisions.id, latest.latestRevisionId))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
return latestRevision?.createdByRunId === input.runId
|
||||
? latest.latestRevisionId
|
||||
: input.pinnedRevisionId;
|
||||
}
|
||||
|
||||
export async function synchronizeCompletedProviderPlan(input: {
|
||||
db: Db;
|
||||
execution: NativeExecutionInput;
|
||||
@@ -1603,25 +1632,55 @@ export async function synchronizeCompletedProviderPlan(input: {
|
||||
payload: Record<string, unknown>;
|
||||
};
|
||||
}): Promise<PlanSynchronization | null> {
|
||||
if (
|
||||
input.event.eventType !== "plan.updated" ||
|
||||
input.event.payload.complete !== true
|
||||
)
|
||||
return null;
|
||||
if (
|
||||
!("executionMode" in input.execution) ||
|
||||
input.execution.executionMode !== "plan"
|
||||
)
|
||||
return null;
|
||||
const planningContext = input.execution.planningContext;
|
||||
if (!planningContext) return null;
|
||||
const planId =
|
||||
typeof input.event.payload.planId === "string"
|
||||
const planMode =
|
||||
"executionMode" in input.execution &&
|
||||
input.execution.executionMode === "plan";
|
||||
// A harness's own proposed-plan document (Codex plan item, Claude
|
||||
// ExitPlanMode) asks for approval in any work mode: in Planning it is the
|
||||
// planning deliverable, and in Standard the agent opted to stop and ask.
|
||||
// A within-turn checklist only stands in for the plan in Planning mode.
|
||||
const proposal =
|
||||
input.event.eventType === "plan.proposed" &&
|
||||
input.event.payload.status === "completed";
|
||||
const completedChecklist =
|
||||
input.event.eventType === "plan.updated" &&
|
||||
input.event.payload.complete === true;
|
||||
if (!proposal && !(completedChecklist && planMode)) return null;
|
||||
// Only plan-capable work modes may write the Plan document. Ask and skill
|
||||
// test runs answer in the thread; a proposal there must not become a plan.
|
||||
const workMode = input.execution.task.workMode;
|
||||
if (proposal && workMode !== "standard" && workMode !== "planning") return null;
|
||||
const planningContext =
|
||||
"executionMode" in input.execution && input.execution.executionMode === "plan"
|
||||
? input.execution.planningContext
|
||||
: null;
|
||||
if (planMode && !planningContext) return null;
|
||||
const baseRevisionId = planningContext
|
||||
? await proposalBaseRevisionId({
|
||||
db: input.db,
|
||||
issueId: input.execution.binding.issueId,
|
||||
runId: input.execution.binding.runId,
|
||||
pinnedRevisionId: planningContext.baseRevisionId,
|
||||
proposal,
|
||||
})
|
||||
: ((
|
||||
await documentService(input.db).getIssueDocumentByKey(
|
||||
input.execution.binding.issueId,
|
||||
"plan",
|
||||
)
|
||||
)?.latestRevisionId ?? null);
|
||||
const planId = proposal
|
||||
? typeof input.event.payload.proposalId === "string"
|
||||
? input.event.payload.proposalId
|
||||
: ""
|
||||
: typeof input.event.payload.planId === "string"
|
||||
? input.event.payload.planId
|
||||
: "";
|
||||
const providerRevision = Number.isSafeInteger(input.event.payload.revision)
|
||||
? Number(input.event.payload.revision)
|
||||
: 0;
|
||||
const providerRevision = proposal
|
||||
? 1
|
||||
: Number.isSafeInteger(input.event.payload.revision)
|
||||
? Number(input.event.payload.revision)
|
||||
: 0;
|
||||
const body = providerPlanMarkdown(input.event.payload);
|
||||
const digest = createHash("sha256").update(body).digest("hex");
|
||||
if (!planId || providerRevision < 1 || !body) {
|
||||
@@ -1630,7 +1689,7 @@ export async function synchronizeCompletedProviderPlan(input: {
|
||||
planId,
|
||||
providerRevision,
|
||||
status: "invalid",
|
||||
baseRevisionId: planningContext.baseRevisionId,
|
||||
baseRevisionId: baseRevisionId,
|
||||
digest,
|
||||
documentRevision: null,
|
||||
currentRevisionId: null,
|
||||
@@ -1693,7 +1752,7 @@ export async function synchronizeCompletedProviderPlan(input: {
|
||||
title: "Plan",
|
||||
format: "markdown",
|
||||
body,
|
||||
baseRevisionId: planningContext.baseRevisionId,
|
||||
baseRevisionId: baseRevisionId,
|
||||
changeSummary: provenance,
|
||||
createdByAgentId: input.execution.binding.agentId,
|
||||
createdByRunId: input.execution.binding.runId,
|
||||
@@ -1714,7 +1773,7 @@ export async function synchronizeCompletedProviderPlan(input: {
|
||||
planId,
|
||||
providerRevision,
|
||||
status: "conflict",
|
||||
baseRevisionId: planningContext.baseRevisionId,
|
||||
baseRevisionId: baseRevisionId,
|
||||
digest,
|
||||
documentRevision: latest?.latestRevisionNumber ?? null,
|
||||
currentRevisionId: latest?.latestRevisionId ?? null,
|
||||
@@ -1731,7 +1790,7 @@ export async function synchronizeCompletedProviderPlan(input: {
|
||||
planId,
|
||||
providerRevision,
|
||||
status: "conflict",
|
||||
baseRevisionId: planningContext.baseRevisionId,
|
||||
baseRevisionId: baseRevisionId,
|
||||
digest,
|
||||
documentRevision: current?.latestRevisionNumber ?? null,
|
||||
currentRevisionId: current?.latestRevisionId ?? null,
|
||||
@@ -1739,7 +1798,6 @@ export async function synchronizeCompletedProviderPlan(input: {
|
||||
};
|
||||
}
|
||||
let confirmationId: string;
|
||||
let confirmationPending = false;
|
||||
try {
|
||||
const confirmation = await issueThreadInteractionService(input.db).create(
|
||||
{
|
||||
@@ -1779,62 +1837,29 @@ export async function synchronizeCompletedProviderPlan(input: {
|
||||
},
|
||||
);
|
||||
confirmationId = confirmation.id;
|
||||
confirmationPending = confirmation.status === "pending";
|
||||
} catch {
|
||||
return {
|
||||
eventId: input.event.sourceEventId,
|
||||
planId,
|
||||
providerRevision,
|
||||
status: "approval_failed",
|
||||
baseRevisionId: planningContext.baseRevisionId,
|
||||
baseRevisionId: baseRevisionId,
|
||||
digest,
|
||||
documentRevision: revision.revisionNumber,
|
||||
currentRevisionId: revision.id,
|
||||
confirmationId: null,
|
||||
};
|
||||
}
|
||||
if (confirmationPending) {
|
||||
try {
|
||||
const currentIssue = await input.db
|
||||
.select({ status: issues.status })
|
||||
.from(issues)
|
||||
.where(eq(issues.id, input.execution.binding.issueId))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (currentIssue && currentIssue.status !== "in_review") {
|
||||
await issueService(input.db).update(input.execution.binding.issueId, {
|
||||
status: "in_review",
|
||||
actorAgentId: input.execution.binding.agentId,
|
||||
});
|
||||
}
|
||||
} catch {
|
||||
const settledIssue = await input.db
|
||||
.select({ status: issues.status })
|
||||
.from(issues)
|
||||
.where(eq(issues.id, input.execution.binding.issueId))
|
||||
.limit(1)
|
||||
.then((rows) => rows[0] ?? null);
|
||||
if (settledIssue?.status !== "in_review") {
|
||||
return {
|
||||
eventId: input.event.sourceEventId,
|
||||
planId,
|
||||
providerRevision,
|
||||
status: "approval_failed",
|
||||
baseRevisionId: planningContext.baseRevisionId,
|
||||
digest,
|
||||
documentRevision: revision.revisionNumber,
|
||||
currentRevisionId: revision.id,
|
||||
confirmationId,
|
||||
};
|
||||
}
|
||||
}
|
||||
}
|
||||
// Synchronization runs while the native run still owns the task. Changing
|
||||
// the task status here would revoke the run's execution lease; the pending
|
||||
// confirmation is a governance gate that the run finalizer turns into the
|
||||
// review wait.
|
||||
return {
|
||||
eventId: input.event.sourceEventId,
|
||||
planId,
|
||||
providerRevision,
|
||||
status,
|
||||
baseRevisionId: planningContext.baseRevisionId,
|
||||
baseRevisionId: baseRevisionId,
|
||||
digest,
|
||||
documentRevision: revision.revisionNumber,
|
||||
currentRevisionId: revision.id,
|
||||
|
||||
Reference in new issue
Block a user