forked from farhoodlabs/paperclip
d9f91576a0
## Thinking Path > - Paperclip orchestrates AI agents for zero-human companies, so planning approvals and child-issue fan-out are part of the core control-plane loop. > - Accepted plans are supposed to be a safe bridge from planning into execution, especially when agents wake from review decisions and reuse isolated workspaces. > - The duplicate-subtask incident showed that an accepted plan revision could be interpreted more than once across overlapping runs, which broke the single-source-of-truth model for issue decomposition. > - Fixing that required tightening the backend contract first: accepted-plan decomposition needs an exact-once fingerprint, durable claim state, and retry-safe child creation. > - Once that backend behavior existed, the board still needed visibility into what happened, so the issue detail view needed a dedicated decomposition section instead of forcing operators to reconstruct child creation from raw activity. > - This pull request adds the exact-once decomposition primitive, hardens wake routing and regressions around the incident, and surfaces decomposition state in the UI so future incidents are both prevented and easier to inspect. ## What Changed - Added accepted-plan decomposition semantics to `doc/execution-semantics.md`, including the exact-once fingerprint, durable claim/result expectations, and retry/resume behavior. - Added persistent accepted-plan decomposition claims in the backend, including schema, shared types/validators, service logic, and issue routes for creating and listing decomposition state. - Hardened heartbeat routing so an accepted-plan continuation stays scoped to the relevant planning issue instead of opportunistically re-decomposing another accepted issue on the same assignee. - Added regression coverage for the original failure modes: concurrent same-parent retries, cross-issue accepted-plan isolation, and partial child recreation under the same fingerprint. - Added the `Plan decomposition` issue-detail section plus supporting API/query-key/activity formatting updates so operators can see revision status, owner, child counts, and the linked child issues directly in the UI. - Included the small follow-up UI fix so the decomposition section still renders when the issue work mode is no longer `planning`. ## Verification - `pnpm --filter @paperclipai/server typecheck` - `pnpm --filter @paperclipai/ui typecheck` - `pnpm --filter @paperclipai/db typecheck` - `pnpm exec vitest run server/src/__tests__/issues-service.test.ts` - `pnpm exec vitest run server/src/__tests__/issues-service.test.ts -t "lists persisted decompositions with child issue summaries"` - `pnpm exec vitest run server/src/__tests__/issues-service.test.ts -t "accepted plan decomposition" server/src/__tests__/heartbeat-accepted-plan-workspace-refresh.test.ts server/src/__tests__/heartbeat-context-summary.test.ts` - Manual UI path: create a planning issue without an isolated execution workspace, add a `plan` document, accept the `request_confirmation`, let Paperclip create child issues, then reopen the parent issue detail page and confirm the `Plan decomposition` section shows the accepted revision, status, idempotent-claim badge, and child links. - Separate follow-up bug noted during manual UI validation: accepting a plan on an issue whose run never records `workspace_finalize` is tracked in `PAPA-445` and is not part of this PR’s fix scope. ## Risks - This adds a new migration and a large Drizzle snapshot update; reviewers should confirm the schema shape and generated metadata match the intended decomposition table. - The exact-once claim changes sit on the accepted-plan fan-out path, so regressions there could block legitimate child creation or mis-handle retries if the claim state machine is wrong. - The new UI only appears when decomposition records exist; reviewers should use the manual verification path above rather than expecting existing issues on a stale local instance to show the section automatically. - `PAPA-445` remains an open follow-up for the `workspace_finalize` accept gate when a planning handoff never records finalize; that bug can interfere with reproducing the UI flow on isolated workspaces but does not change the correctness of the exact-once decomposition feature itself. > Checked `ROADMAP.md`: this PR is a bug fix / control-plane hardening change for accepted-plan decomposition, not a new uncoordinated roadmap feature. ## Model Used - OpenAI Codex via Paperclip `codex_local` (GPT-5-based coding agent; exact backend model ID/context window not exposed in the run context), with repository tool use, shell execution, and code-editing capabilities. <img width="806" height="1069" alt="Screenshot 2026-05-27 at 11 05 48 PM" src="https://github.com/user-attachments/assets/5b00b670-96cd-4470-b0a3-581743bcae28" /> ## 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 run tests locally and they pass - [x] I have added or updated tests where applicable - [x] If this change affects the UI, I have included before/after screenshots - [x] I have updated relevant documentation to reflect my changes - [x] I have considered and documented any risks above - [x] I will address all Greptile and reviewer comments before requesting merge --------- Co-authored-by: Paperclip <noreply@paperclip.ing>
395 lines
16 KiB
TypeScript
395 lines
16 KiB
TypeScript
import type { Agent } from "@paperclipai/shared";
|
|
import type { CompanyUserProfile } from "./company-members";
|
|
|
|
type ActivityDetails = Record<string, unknown> | null | undefined;
|
|
|
|
type ActivityParticipant = {
|
|
type: "agent" | "user";
|
|
agentId?: string | null;
|
|
userId?: string | null;
|
|
};
|
|
|
|
type ActivityIssueReference = {
|
|
id?: string | null;
|
|
identifier?: string | null;
|
|
title?: string | null;
|
|
};
|
|
|
|
interface ActivityFormatOptions {
|
|
agentMap?: Map<string, Agent>;
|
|
userProfileMap?: Map<string, CompanyUserProfile>;
|
|
currentUserId?: string | null;
|
|
}
|
|
|
|
const ACTIVITY_ROW_VERBS: Record<string, string> = {
|
|
"issue.created": "created",
|
|
"issue.updated": "updated",
|
|
"issue.checked_out": "checked out",
|
|
"issue.released": "released",
|
|
"issue.comment_added": "commented on",
|
|
"issue.comment_cancelled": "cancelled a queued comment on",
|
|
"issue.attachment_added": "attached file to",
|
|
"issue.attachment_removed": "removed attachment from",
|
|
"issue.document_created": "created document for",
|
|
"issue.document_updated": "updated document on",
|
|
"issue.document_locked": "locked document on",
|
|
"issue.document_unlocked": "unlocked document on",
|
|
"issue.document_deleted": "deleted document from",
|
|
"issue.monitor_scheduled": "scheduled monitor on",
|
|
"issue.monitor_triggered": "triggered monitor for",
|
|
"issue.monitor_cleared": "cleared monitor on",
|
|
"issue.monitor_skipped": "skipped monitor for",
|
|
"issue.monitor_exhausted": "exhausted monitor on",
|
|
"issue.monitor_recovery_wake_queued": "queued monitor recovery for",
|
|
"issue.monitor_recovery_issue_created": "created monitor recovery for",
|
|
"issue.monitor_escalated_to_board": "escalated monitor for",
|
|
"issue.commented": "commented on",
|
|
"issue.deleted": "deleted",
|
|
"issue.successful_run_handoff_required": "flagged missing next step on",
|
|
"issue.successful_run_handoff_resolved": "recorded next step chosen on",
|
|
"issue.successful_run_handoff_escalated": "escalated missing next step on",
|
|
"issue.accepted_plan_decomposition_updated": "updated accepted-plan decomposition on",
|
|
"issue.recovery_action_opened": "opened a recovery action on",
|
|
"issue.recovery_action_resolved": "resolved the recovery action on",
|
|
"issue.recovery_action_escalated": "escalated the recovery action on",
|
|
"agent.created": "created",
|
|
"agent.updated": "updated",
|
|
"agent.paused": "paused",
|
|
"agent.resumed": "resumed",
|
|
"agent.terminated": "terminated",
|
|
"agent.key_created": "created API key for",
|
|
"agent.budget_updated": "updated budget for",
|
|
"agent.runtime_session_reset": "reset session for",
|
|
"heartbeat.invoked": "invoked heartbeat for",
|
|
"heartbeat.cancelled": "cancelled heartbeat for",
|
|
"heartbeat.output_stale_source_resolved": "system-folded stale run on",
|
|
"heartbeat.output_stale_recovery_recursion_refused": "refused recovery-on-recovery for",
|
|
"approval.created": "requested approval",
|
|
"approval.approved": "approved",
|
|
"approval.rejected": "rejected",
|
|
"project.created": "created",
|
|
"project.updated": "updated",
|
|
"project.deleted": "deleted",
|
|
"goal.created": "created",
|
|
"goal.updated": "updated",
|
|
"goal.deleted": "deleted",
|
|
"cost.reported": "reported cost for",
|
|
"cost.recorded": "recorded cost for",
|
|
"company.created": "created company",
|
|
"company.updated": "updated company",
|
|
"company.archived": "archived",
|
|
"company.budget_updated": "updated budget for",
|
|
};
|
|
|
|
const ISSUE_ACTIVITY_LABELS: Record<string, string> = {
|
|
"issue.created": "created the issue",
|
|
"issue.updated": "updated the issue",
|
|
"issue.checked_out": "checked out the issue",
|
|
"issue.released": "released the issue",
|
|
"issue.comment_added": "added a comment",
|
|
"issue.comment_cancelled": "cancelled a queued comment",
|
|
"issue.feedback_vote_saved": "saved feedback on an AI output",
|
|
"issue.attachment_added": "added an attachment",
|
|
"issue.attachment_removed": "removed an attachment",
|
|
"issue.document_created": "created a document",
|
|
"issue.document_updated": "updated a document",
|
|
"issue.document_locked": "locked a document",
|
|
"issue.document_unlocked": "unlocked a document",
|
|
"issue.document_deleted": "deleted a document",
|
|
"issue.monitor_scheduled": "scheduled a monitor",
|
|
"issue.monitor_triggered": "triggered a monitor",
|
|
"issue.monitor_cleared": "cleared a monitor",
|
|
"issue.monitor_skipped": "skipped a monitor",
|
|
"issue.monitor_exhausted": "exhausted a monitor",
|
|
"issue.monitor_recovery_wake_queued": "queued a monitor recovery wake",
|
|
"issue.monitor_recovery_issue_created": "created a monitor recovery issue",
|
|
"issue.monitor_escalated_to_board": "escalated a monitor to the board",
|
|
"issue.deleted": "deleted the issue",
|
|
"issue.successful_run_handoff_required": "Run finished without a clear next step",
|
|
"issue.successful_run_handoff_resolved": "Next step chosen",
|
|
"issue.successful_run_handoff_escalated": "Run finished without a next step - recovery escalated",
|
|
"issue.recovery_action_opened": "Opened a source-scoped recovery action",
|
|
"issue.recovery_action_resolved": "Resolved the recovery action",
|
|
"issue.recovery_action_escalated": "Escalated the recovery action",
|
|
"issue.accepted_plan_decomposition_updated": "updated the accepted-plan decomposition",
|
|
"agent.created": "created an agent",
|
|
"agent.updated": "updated the agent",
|
|
"agent.paused": "paused the agent",
|
|
"agent.resumed": "resumed the agent",
|
|
"agent.terminated": "terminated the agent",
|
|
"heartbeat.invoked": "invoked a heartbeat",
|
|
"heartbeat.cancelled": "cancelled a heartbeat",
|
|
"heartbeat.output_stale_source_resolved": "System folded a stale run",
|
|
"heartbeat.output_stale_recovery_recursion_refused": "Refused recovery-on-recovery escalation",
|
|
"approval.created": "requested approval",
|
|
"approval.approved": "approved",
|
|
"approval.rejected": "rejected",
|
|
};
|
|
|
|
function asRecord(value: unknown): Record<string, unknown> | null {
|
|
if (!value || typeof value !== "object" || Array.isArray(value)) return null;
|
|
return value as Record<string, unknown>;
|
|
}
|
|
|
|
function humanizeValue(value: unknown): string {
|
|
if (typeof value !== "string") return String(value ?? "none");
|
|
return value.replace(/_/g, " ");
|
|
}
|
|
|
|
function isActivityParticipant(value: unknown): value is ActivityParticipant {
|
|
const record = asRecord(value);
|
|
if (!record) return false;
|
|
return record.type === "agent" || record.type === "user";
|
|
}
|
|
|
|
function isActivityIssueReference(value: unknown): value is ActivityIssueReference {
|
|
return asRecord(value) !== null;
|
|
}
|
|
|
|
function readParticipants(details: ActivityDetails, key: string): ActivityParticipant[] {
|
|
const value = details?.[key];
|
|
if (!Array.isArray(value)) return [];
|
|
return value.filter(isActivityParticipant);
|
|
}
|
|
|
|
function readIssueReferences(details: ActivityDetails, key: string): ActivityIssueReference[] {
|
|
const value = details?.[key];
|
|
if (!Array.isArray(value)) return [];
|
|
return value.filter(isActivityIssueReference);
|
|
}
|
|
|
|
function formatUserLabel(userId: string | null | undefined, options: ActivityFormatOptions = {}): string {
|
|
if (!userId || userId === "local-board") return "Board";
|
|
if (options.currentUserId && userId === options.currentUserId) return "You";
|
|
const profile = options.userProfileMap?.get(userId);
|
|
if (profile) return profile.label;
|
|
return `user ${userId.slice(0, 5)}`;
|
|
}
|
|
|
|
function formatParticipantLabel(participant: ActivityParticipant, options: ActivityFormatOptions): string {
|
|
if (participant.type === "agent") {
|
|
const agentId = participant.agentId ?? "";
|
|
return options.agentMap?.get(agentId)?.name ?? "agent";
|
|
}
|
|
return formatUserLabel(participant.userId, options);
|
|
}
|
|
|
|
function formatIssueReferenceLabel(reference: ActivityIssueReference): string {
|
|
if (reference.identifier) return reference.identifier;
|
|
if (reference.title) return reference.title;
|
|
if (reference.id) return reference.id.slice(0, 8);
|
|
return "issue";
|
|
}
|
|
|
|
function formatChangedEntityLabel(
|
|
singular: string,
|
|
plural: string,
|
|
labels: string[],
|
|
): string {
|
|
if (labels.length <= 0) return plural;
|
|
if (labels.length === 1) return `${singular} ${labels[0]}`;
|
|
return `${labels.length} ${plural}`;
|
|
}
|
|
|
|
function readNumber(value: unknown): number | null {
|
|
if (typeof value === "number" && Number.isFinite(value)) return value;
|
|
return null;
|
|
}
|
|
|
|
function readStringArrayLength(value: unknown): number {
|
|
if (!Array.isArray(value)) return 0;
|
|
return value.filter((entry) => typeof entry === "string" && entry.length > 0).length;
|
|
}
|
|
|
|
function formatAcceptedPlanDecompositionDetail(details: ActivityDetails): string | null {
|
|
if (!details) return null;
|
|
const status = typeof details.status === "string" ? details.status : null;
|
|
const requested = readNumber(details.requestedChildCount);
|
|
const totalChildren = readStringArrayLength(details.childIssueIds);
|
|
const newlyCreated = readStringArrayLength(details.newlyCreatedChildIssueIds);
|
|
const reused = Math.max(0, totalChildren - newlyCreated);
|
|
const parts: string[] = [];
|
|
if (newlyCreated > 0) parts.push(`created ${newlyCreated} new`);
|
|
if (reused > 0) parts.push(`reused ${reused} existing`);
|
|
if (parts.length === 0 && requested !== null) parts.push(`${requested} requested`);
|
|
const summary = parts.length > 0 ? parts.join(", ") : null;
|
|
if (status === "completed" && summary) return `decomposition completed (${summary})`;
|
|
if (status === "completed") return "decomposition completed";
|
|
if (status === "in_flight" && summary) return `decomposition in flight (${summary})`;
|
|
return summary;
|
|
}
|
|
|
|
function formatIssueUpdatedVerb(details: ActivityDetails): string | null {
|
|
if (!details) return null;
|
|
const previous = asRecord(details._previous) ?? {};
|
|
if (details.status !== undefined) {
|
|
const from = previous.status;
|
|
return from
|
|
? `changed status from ${humanizeValue(from)} to ${humanizeValue(details.status)} on`
|
|
: `changed status to ${humanizeValue(details.status)} on`;
|
|
}
|
|
if (details.priority !== undefined) {
|
|
const from = previous.priority;
|
|
return from
|
|
? `changed priority from ${humanizeValue(from)} to ${humanizeValue(details.priority)} on`
|
|
: `changed priority to ${humanizeValue(details.priority)} on`;
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function formatAssigneeName(details: ActivityDetails, options: ActivityFormatOptions): string | null {
|
|
if (!details) return null;
|
|
const agentId = details.assigneeAgentId;
|
|
const userId = details.assigneeUserId;
|
|
if (typeof agentId === "string" && agentId) {
|
|
return options.agentMap?.get(agentId)?.name ?? "agent";
|
|
}
|
|
if (typeof userId === "string" && userId) {
|
|
return formatUserLabel(userId, options);
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function formatIssueUpdatedAction(details: ActivityDetails, options: ActivityFormatOptions = {}): string | null {
|
|
if (!details) return null;
|
|
const previous = asRecord(details._previous) ?? {};
|
|
const parts: string[] = [];
|
|
|
|
if (details.status !== undefined) {
|
|
const from = previous.status;
|
|
parts.push(
|
|
from
|
|
? `changed the status from ${humanizeValue(from)} to ${humanizeValue(details.status)}`
|
|
: `changed the status to ${humanizeValue(details.status)}`,
|
|
);
|
|
}
|
|
if (details.priority !== undefined) {
|
|
const from = previous.priority;
|
|
parts.push(
|
|
from
|
|
? `changed the priority from ${humanizeValue(from)} to ${humanizeValue(details.priority)}`
|
|
: `changed the priority to ${humanizeValue(details.priority)}`,
|
|
);
|
|
}
|
|
if (details.assigneeAgentId !== undefined || details.assigneeUserId !== undefined) {
|
|
const assigneeName = formatAssigneeName(details, options);
|
|
parts.push(assigneeName ? `assigned the issue to ${assigneeName}` : "unassigned the issue");
|
|
}
|
|
if (details.title !== undefined) parts.push("updated the title");
|
|
if (details.description !== undefined) parts.push("updated the description");
|
|
|
|
return parts.length > 0 ? parts.join(", ") : null;
|
|
}
|
|
|
|
function formatStructuredIssueChange(input: {
|
|
action: string;
|
|
details: ActivityDetails;
|
|
options: ActivityFormatOptions;
|
|
forIssueDetail: boolean;
|
|
}): string | null {
|
|
const details = input.details;
|
|
if (!details) return null;
|
|
|
|
if (input.action === "issue.blockers_updated") {
|
|
const added = readIssueReferences(details, "addedBlockedByIssues").map(formatIssueReferenceLabel);
|
|
const removed = readIssueReferences(details, "removedBlockedByIssues").map(formatIssueReferenceLabel);
|
|
if (added.length > 0 && removed.length === 0) {
|
|
const changed = formatChangedEntityLabel("blocker", "blockers", added);
|
|
return input.forIssueDetail ? `added ${changed}` : `added ${changed} to`;
|
|
}
|
|
if (removed.length > 0 && added.length === 0) {
|
|
const changed = formatChangedEntityLabel("blocker", "blockers", removed);
|
|
return input.forIssueDetail ? `removed ${changed}` : `removed ${changed} from`;
|
|
}
|
|
return input.forIssueDetail ? "updated blockers" : "updated blockers on";
|
|
}
|
|
|
|
if (input.action === "issue.reviewers_updated" || input.action === "issue.approvers_updated") {
|
|
const added = readParticipants(details, "addedParticipants").map((participant) => formatParticipantLabel(participant, input.options));
|
|
const removed = readParticipants(details, "removedParticipants").map((participant) => formatParticipantLabel(participant, input.options));
|
|
const singular = input.action === "issue.reviewers_updated" ? "reviewer" : "approver";
|
|
const plural = input.action === "issue.reviewers_updated" ? "reviewers" : "approvers";
|
|
if (added.length > 0 && removed.length === 0) {
|
|
const changed = formatChangedEntityLabel(singular, plural, added);
|
|
return input.forIssueDetail ? `added ${changed}` : `added ${changed} to`;
|
|
}
|
|
if (removed.length > 0 && added.length === 0) {
|
|
const changed = formatChangedEntityLabel(singular, plural, removed);
|
|
return input.forIssueDetail ? `removed ${changed}` : `removed ${changed} from`;
|
|
}
|
|
return input.forIssueDetail ? `updated ${plural}` : `updated ${plural} on`;
|
|
}
|
|
|
|
return null;
|
|
}
|
|
|
|
export function formatActivityVerb(
|
|
action: string,
|
|
details?: Record<string, unknown> | null,
|
|
options: ActivityFormatOptions = {},
|
|
): string {
|
|
if (action === "issue.updated") {
|
|
const issueUpdatedVerb = formatIssueUpdatedVerb(details);
|
|
if (issueUpdatedVerb) return issueUpdatedVerb;
|
|
}
|
|
|
|
const structuredChange = formatStructuredIssueChange({
|
|
action,
|
|
details,
|
|
options,
|
|
forIssueDetail: false,
|
|
});
|
|
if (structuredChange) return structuredChange;
|
|
|
|
return ACTIVITY_ROW_VERBS[action] ?? action.replace(/[._]/g, " ");
|
|
}
|
|
|
|
export function formatIssueActivityAction(
|
|
action: string,
|
|
details?: Record<string, unknown> | null,
|
|
options: ActivityFormatOptions = {},
|
|
): string {
|
|
if (action === "issue.updated") {
|
|
const issueUpdatedAction = formatIssueUpdatedAction(details, options);
|
|
if (issueUpdatedAction) return issueUpdatedAction;
|
|
}
|
|
|
|
const structuredChange = formatStructuredIssueChange({
|
|
action,
|
|
details,
|
|
options,
|
|
forIssueDetail: true,
|
|
});
|
|
if (structuredChange) return structuredChange;
|
|
|
|
if (action === "issue.accepted_plan_decomposition_updated") {
|
|
const detail = formatAcceptedPlanDecompositionDetail(details);
|
|
if (detail) return detail;
|
|
}
|
|
|
|
if (action.startsWith("issue.monitor_") && details) {
|
|
const serviceName = typeof details.serviceName === "string" && details.serviceName.trim()
|
|
? details.serviceName.trim()
|
|
: null;
|
|
const base = ISSUE_ACTIVITY_LABELS[action] ?? action.replace(/[._]/g, " ");
|
|
return serviceName ? `${base} for ${serviceName}` : base;
|
|
}
|
|
|
|
if (
|
|
(
|
|
action === "issue.document_created" ||
|
|
action === "issue.document_updated" ||
|
|
action === "issue.document_locked" ||
|
|
action === "issue.document_unlocked" ||
|
|
action === "issue.document_deleted"
|
|
) &&
|
|
details
|
|
) {
|
|
const key = typeof details.key === "string" ? details.key : "document";
|
|
const title = typeof details.title === "string" && details.title ? ` (${details.title})` : "";
|
|
return `${ISSUE_ACTIVITY_LABELS[action] ?? action} ${key}${title}`;
|
|
}
|
|
|
|
return ISSUE_ACTIVITY_LABELS[action] ?? action.replace(/[._]/g, " ");
|
|
}
|