[codex] Roll up May 17 branch changes (#6210)

## Thinking Path

> - Paperclip is the control plane for autonomous AI companies, so agent
work needs visible ownership, recovery, and operator controls.
> - This local branch had accumulated several related control-plane
reliability and operator-experience fixes across recovery actions,
watchdog folding, model-profile defaults, mentions, markdown editing,
plugin launchers, and small UI polish.
> - The branch needed to be converted into a PR against the current
`origin/master` without losing dirty work or including lockfile/workflow
churn.
> - The safest standalone shape is a single rollup PR because the
recovery/server/UI files overlap heavily across the local commits and
splitting would create avoidable conflicts.
> - This pull request replays the local branch onto latest
`origin/master`, preserves the uncommitted work as logical commits, and
adds a Zod 4 validator compatibility fix found during verification.
> - The benefit is that the May 17 local branch can be reviewed and
merged as one coherent, conflict-free branch under the 100-file Greptile
limit.

## What Changed

- Rebased the local May 17 branch work onto current `origin/master` in a
dedicated worktree.
- Preserved and committed previously dirty changes for recovery retry
handling, plugin/sidebar launcher polish, and `.herenow` ignores.
- Added recovery-action behavior for returning source issues to `todo`
when retrying source-scoped recovery.
- Included the existing local recovery/liveness/watchdog fold, Codex
cheap-profile, markdown/mention, duplicate-agent, and UI polish commits
from the branch.
- Normalized shared validator `z.record(...)` schemas to explicit
string-key records for Zod 4 compatibility.
- Confirmed the PR has no `pnpm-lock.yaml` or `.github/workflows/*`
changes and stays below the 100-file Greptile limit.

## Verification

- `pnpm install --frozen-lockfile --ignore-scripts`
- `npm run install` in
`node_modules/.pnpm/sqlite3@5.1.7/node_modules/sqlite3` to build the
local native sqlite3 binding after installing with scripts disabled
- `pnpm exec vitest run packages/shared/src/validators/issue.test.ts
packages/shared/src/project-mentions.test.ts
packages/adapter-utils/src/server-utils.test.ts
server/src/__tests__/heartbeat-model-profile.test.ts
server/src/__tests__/issue-recovery-actions.test.ts
server/src/__tests__/issue-agent-mutation-ownership-routes.test.ts
server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts
server/src/__tests__/plugin-local-folders.test.ts
ui/src/components/IssueRecoveryActionCard.test.tsx
ui/src/components/Sidebar.test.tsx
ui/src/components/SidebarAccountMenu.test.tsx
ui/src/components/IssueProperties.test.tsx
ui/src/components/MarkdownEditor.test.tsx
ui/src/components/MarkdownBody.test.tsx
ui/src/lib/duplicate-agent-payload.test.ts
ui/src/pages/Routines.test.tsx`
- First pass: 13 files passed with 201 passing tests; 3 server files
failed before sqlite3 native binding was built.
- After rebuilding sqlite3:
`server/src/__tests__/heartbeat-model-profile.test.ts`,
`server/src/__tests__/issue-recovery-actions.test.ts`, and
`server/src/__tests__/heartbeat-active-run-output-watchdog.test.ts`
passed/loaded; embedded Postgres tests were skipped by the local host
guard.
- `pnpm --filter @paperclipai/shared typecheck`
- `pnpm --filter @paperclipai/adapter-utils typecheck`
- `pnpm --filter @paperclipai/server typecheck`
- `pnpm --filter @paperclipai/ui typecheck`

## Risks

- Medium risk: this is a broad rollup PR across recovery semantics,
server tests, shared validators, and UI surfaces.
- Some embedded Postgres tests skipped locally due the host guard, so CI
should provide the stronger database-backed signal.
- UI changes were covered by component tests, but no browser screenshot
was captured in this PR creation pass.
- This branch may overlap with existing recovery/liveness PR work; merge
this PR independently or restack/close overlapping branches rather than
merging duplicate implementations together.

> 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

- OpenAI Codex, GPT-5-based coding agent, tool-enabled local repository
and GitHub workflow, medium reasoning effort.

## 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
- [ ] 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>
This commit is contained in:
Dotta 2026-05-17 17:15:06 -05:00 committed by GitHub
parent 705c1b8d81
commit d734bd43d1
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
83 changed files with 3675 additions and 180 deletions

View file

@ -117,6 +117,13 @@ const updateIssueRouteSchema = updateIssueSchema.extend({
type ParsedExecutionState = NonNullable<ReturnType<typeof parseIssueExecutionState>>;
type NormalizedExecutionPolicy = NonNullable<ReturnType<typeof normalizeIssueExecutionPolicy>>;
type IssueRouteSnapshot = typeof issueRows.$inferSelect;
type RecoveryRevalidationTrigger =
| "issue_update"
| "comment"
| "document"
| "work_product"
| "read_projection";
type CompanySearchService = {
search(companyId: string, query: CompanySearchQuery): Promise<CompanySearchResponse>;
};
@ -636,6 +643,8 @@ function queueResolvedInteractionContinuationWakeup(input: {
};
actor: { actorType: "user" | "agent"; actorId: string };
source: string;
forceFreshSession?: boolean;
workspaceRefreshReason?: string | null;
}) {
if (
input.interaction.continuationPolicy !== "wake_assignee"
@ -648,6 +657,8 @@ function queueResolvedInteractionContinuationWakeup(input: {
if (input.interaction.status === "expired") return;
if (!input.issue.assigneeAgentId || isClosedIssueStatus(input.issue.status)) return;
const forceFreshSession = input.forceFreshSession === true;
const workspaceRefreshReason = readNonEmptyString(input.workspaceRefreshReason);
void input.heartbeat.wakeup(input.issue.assigneeAgentId, {
source: "automation",
triggerDetail: "system",
@ -673,6 +684,8 @@ function queueResolvedInteractionContinuationWakeup(input: {
sourceRunId: input.interaction.sourceRunId ?? null,
wakeReason: "issue_commented",
source: input.source,
...(forceFreshSession ? { forceFreshSession: true } : {}),
...(workspaceRefreshReason ? { workspaceRefreshReason } : {}),
},
}).catch((err) => logger.warn({
err,
@ -843,6 +856,7 @@ export function issueRoutes(
const workProductsSvc = workProductService(db);
const documentsSvc = documentService(db);
const issueReferencesSvc = issueReferenceService(db);
const issueThreadInteractionsSvc = issueThreadInteractionService(db);
const routinesSvc = routineService(db, {
pluginWorkerManager: opts.pluginWorkerManager,
});
@ -857,6 +871,182 @@ export function issueRoutes(
};
const feedbackExportService = opts?.feedbackExportService;
const environmentsSvc = environmentService(db);
async function classifySourceRecoveryRevalidation(input: {
issue: IssueRouteSnapshot;
trigger: RecoveryRevalidationTrigger;
statusChanged?: boolean;
assigneeChanged?: boolean;
blockersChanged?: boolean;
executionPolicyChanged?: boolean;
monitorChanged?: boolean;
documentChanged?: boolean;
workProductChanged?: boolean;
resumeRequested?: boolean;
reopened?: boolean;
blockedToTodoRecovery?: boolean;
}): Promise<string | null> {
const { issue } = input;
if (issue.status === "done" || issue.status === "cancelled") {
return `Recovery action became stale because the source issue reached ${issue.status}.`;
}
if (input.blockedToTodoRecovery === true) {
return "Recovery action became stale because the source issue was manually moved from blocked to todo.";
}
if (input.trigger === "read_projection") return null;
if (
input.trigger === "comment" &&
input.resumeRequested !== true &&
input.reopened !== true &&
input.statusChanged !== true
) {
return null;
}
const durableSourceChange =
input.statusChanged === true ||
input.assigneeChanged === true ||
input.blockersChanged === true ||
input.executionPolicyChanged === true ||
input.monitorChanged === true ||
input.documentChanged === true ||
input.workProductChanged === true ||
input.resumeRequested === true ||
input.reopened === true;
if (!durableSourceChange) return null;
if (issue.status === "blocked") {
const readiness = await svc.getDependencyReadiness(issue.id);
if (readiness.unresolvedBlockerCount > 0) {
return "Recovery action became stale because the source issue now has unresolved first-class blockers.";
}
return null;
}
if (issue.assigneeUserId && issue.status !== "done" && issue.status !== "cancelled") {
return "Recovery action became stale because the source issue now has a human owner.";
}
if ((issue.status === "todo" || issue.status === "in_progress") && issue.assigneeAgentId) {
return `Recovery action became stale because the source issue is ${issue.status} with an agent owner.`;
}
if (issue.status === "in_review") {
const executionState = parseIssueExecutionState(issue.executionState);
const participant = executionState?.status === "pending" ? executionState.currentParticipant : null;
if (
(participant?.type === "agent" && readNonEmptyString(participant.agentId)) ||
(participant?.type === "user" && readNonEmptyString(participant.userId))
) {
return "Recovery action became stale because the source issue now has a typed review participant.";
}
const interactions = await issueThreadInteractionsSvc.listForIssue(issue.id);
if (interactions.some((interaction) => interaction.status === "pending")) {
return "Recovery action became stale because the source issue now has a pending issue interaction.";
}
const approvals = await issueApprovalsSvc.listApprovalsForIssue(issue.id);
if (approvals.some((approval) => approval.status === "pending" || approval.status === "revision_requested")) {
return "Recovery action became stale because the source issue now has a pending approval.";
}
}
const monitor = summarizeIssueMonitor(issue, normalizeIssueExecutionPolicy(issue.executionPolicy ?? null));
if (monitor.nextCheckAt && Date.parse(monitor.nextCheckAt) > Date.now()) {
return "Recovery action became stale because the source issue now has a scheduled monitor.";
}
return null;
}
async function revalidateActiveSourceRecovery(input: {
issue: IssueRouteSnapshot;
trigger: RecoveryRevalidationTrigger;
actor?: ReturnType<typeof getActorInfo> | null;
activeRecoveryAction?: Awaited<ReturnType<typeof recoveryActionsSvc.getActiveForIssue>> | null;
statusChanged?: boolean;
assigneeChanged?: boolean;
blockersChanged?: boolean;
executionPolicyChanged?: boolean;
monitorChanged?: boolean;
documentChanged?: boolean;
workProductChanged?: boolean;
resumeRequested?: boolean;
reopened?: boolean;
blockedToTodoRecovery?: boolean;
}) {
const activeRecoveryAction =
input.activeRecoveryAction === undefined
? await recoveryActionsSvc.getActiveForIssue(input.issue.companyId, input.issue.id)
: input.activeRecoveryAction;
if (!activeRecoveryAction) return null;
const resolutionNote = await classifySourceRecoveryRevalidation(input);
if (!resolutionNote) return activeRecoveryAction;
const resolved = await recoveryActionsSvc.resolveActiveForIssue({
companyId: input.issue.companyId,
sourceIssueId: input.issue.id,
actionId: activeRecoveryAction.id,
status: "cancelled",
outcome: "cancelled",
resolutionNote,
});
if (!resolved) return activeRecoveryAction;
const actor = input.actor;
await logActivity(db, {
companyId: input.issue.companyId,
actorType: actor?.actorType ?? "system",
actorId: actor?.actorId ?? "system",
agentId: actor?.agentId ?? null,
runId: actor?.runId ?? null,
action: "issue.recovery_action_resolved",
entityType: "issue",
entityId: input.issue.id,
details: {
identifier: input.issue.identifier,
recoveryActionId: resolved.id,
recoveryActionStatus: resolved.status,
outcome: resolved.outcome,
sourceIssueStatus: input.issue.status,
resolutionNote: resolved.resolutionNote,
source: "source_revalidation",
trigger: input.trigger,
},
});
return null;
}
async function revalidateActiveSourceRecoveryForRead(input: Parameters<typeof revalidateActiveSourceRecovery>[0]) {
try {
return await revalidateActiveSourceRecovery(input);
} catch (err) {
logger.warn(
{ err, issueId: input.issue.id, trigger: input.trigger },
"failed to revalidate recovery action during read projection",
);
return input.activeRecoveryAction ?? null;
}
}
async function revalidateActiveSourceRecoveryAfterCommittedWrite(
input: Parameters<typeof revalidateActiveSourceRecovery>[0],
) {
try {
return await revalidateActiveSourceRecovery(input);
} catch (err) {
logger.warn(
{ err, issueId: input.issue.id, trigger: input.trigger },
"failed to revalidate recovery action after committed issue write",
);
return input.activeRecoveryAction ?? null;
}
}
function withContentPath<T extends { id: string }>(attachment: T) {
return {
...attachment,
@ -1240,6 +1430,51 @@ export function issueRoutes(
return false;
}
async function assertRecoveryActionAuthority(
req: Request,
res: Response,
issue: { id: string; companyId: string; assigneeAgentId: string | null },
activeRecoveryAction: Awaited<ReturnType<typeof recoveryActionsSvc.getActiveForIssue>>,
input: { source: "issue_update" | "recovery_action_resolution" },
) {
if (req.actor.type !== "agent") return true;
if (!activeRecoveryAction) return true;
const actorAgentId = req.actor.agentId;
if (!actorAgentId) {
res.status(403).json({ error: "Agent authentication required" });
return false;
}
if (issue.assigneeAgentId === actorAgentId) return true;
if (
issue.assigneeAgentId &&
await hasActiveCheckoutManagementOverride(actorAgentId, issue.companyId, issue.assigneeAgentId)
) {
return true;
}
if (activeRecoveryAction.ownerAgentId === actorAgentId) return true;
if (
activeRecoveryAction.ownerAgentId &&
await hasActiveCheckoutManagementOverride(actorAgentId, issue.companyId, activeRecoveryAction.ownerAgentId)
) {
return true;
}
res.status(403).json({
error: "Agent cannot resolve another owner's recovery action",
details: {
issueId: issue.id,
recoveryActionId: activeRecoveryAction.id,
actorAgentId,
assigneeAgentId: issue.assigneeAgentId,
recoveryOwnerAgentId: activeRecoveryAction.ownerAgentId,
source: input.source,
securityPrinciples: ["Least Privilege", "Complete Mediation", "Secure Defaults"],
},
});
return false;
}
async function resolveActiveIssueRun(issue: {
id: string;
assigneeAgentId: string | null;
@ -1512,6 +1747,19 @@ export function issueRoutes(
listSuccessfulRunHandoffStates(db, companyId, issueIds),
recoveryActionsSvc.listActiveForIssues(companyId, issueIds),
]);
const actor = getActorInfo(req);
await Promise.all(result.map(async (issue) => {
const activeRecoveryAction = recoveryActionByIssue.get(issue.id) ?? null;
if (!activeRecoveryAction) return;
const revalidated = await revalidateActiveSourceRecoveryForRead({
issue,
trigger: "read_projection",
actor,
activeRecoveryAction,
});
if (revalidated) recoveryActionByIssue.set(issue.id, revalidated);
else recoveryActionByIssue.delete(issue.id);
}));
res.json(result.map((issue) => ({
...issue,
successfulRunHandoff: handoffStates.get(issue.id) ?? null,
@ -1668,6 +1916,12 @@ export function issueRoutes(
relations,
recoveryActionsByRelationIssue,
);
const revalidatedActiveRecoveryAction = await revalidateActiveSourceRecoveryForRead({
issue,
trigger: "read_projection",
actor: getActorInfo(req),
activeRecoveryAction,
});
res.json({
issue: {
@ -1680,7 +1934,7 @@ export function issueRoutes(
...(blockerAttention ? { blockerAttention } : {}),
productivityReview,
scheduledRetry,
activeRecoveryAction,
activeRecoveryAction: revalidatedActiveRecoveryAction,
priority: issue.priority,
projectId: issue.projectId,
goalId: goal?.id ?? issue.goalId,
@ -1786,6 +2040,12 @@ export function issueRoutes(
relations,
recoveryActionsByRelationIssue,
);
const revalidatedActiveRecoveryAction = await revalidateActiveSourceRecoveryForRead({
issue,
trigger: "read_projection",
actor: getActorInfo(req),
activeRecoveryAction,
});
const mentionedProjects = mentionedProjectIds.length > 0
? await projectsSvc.listByIds(issue.companyId, mentionedProjectIds)
: [];
@ -1801,7 +2061,7 @@ export function issueRoutes(
productivityReview,
successfulRunHandoff: successfulRunHandoffStates.get(issue.id) ?? null,
scheduledRetry,
activeRecoveryAction,
activeRecoveryAction: revalidatedActiveRecoveryAction,
blockedBy: relationsWithRecoveryActions.blockedBy,
blocks: relationsWithRecoveryActions.blocks,
relatedWork: referenceSummary,
@ -1823,7 +2083,11 @@ export function issueRoutes(
return;
}
assertCompanyAccess(req, issue.companyId);
const active = await recoveryActionsSvc.getActiveForIssue(issue.companyId, issue.id);
const active = await revalidateActiveSourceRecoveryForRead({
issue,
trigger: "read_projection",
actor: getActorInfo(req),
});
res.json({
active,
actions: active ? [active] : [],
@ -1839,6 +2103,18 @@ export function issueRoutes(
}
assertCompanyAccess(req, existing.companyId);
if (!(await assertAgentIssueMutationAllowed(req, res, existing))) return;
const activeRecoveryAction = await recoveryActionsSvc.getActiveForIssue(existing.companyId, existing.id);
if (
!(await assertRecoveryActionAuthority(
req,
res,
existing,
activeRecoveryAction,
{ source: "recovery_action_resolution" },
))
) {
return;
}
const { actionId, outcome, sourceIssueStatus, resolutionNote } = req.body;
if (outcome === "false_positive" || outcome === "cancelled") {
@ -1948,6 +2224,36 @@ export function issueRoutes(
},
});
if (
sourceIssueStatus === "todo" &&
existing.status !== result.issue.status &&
result.issue.assigneeAgentId
) {
void heartbeat.wakeup(result.issue.assigneeAgentId, {
source: "automation",
triggerDetail: "system",
reason: "issue_recovery_action_restored",
payload: {
issueId: result.issue.id,
recoveryActionId: result.recoveryAction.id,
mutation: "recovery_action_resolution",
},
requestedByActorType: actor.actorType,
requestedByActorId: actor.actorId,
contextSnapshot: {
issueId: result.issue.id,
taskId: result.issue.id,
wakeReason: "issue_recovery_action_restored",
source: "issue.recovery_action_resolution",
recoveryActionId: result.recoveryAction.id,
},
}).catch((err) =>
logger.warn(
{ err, issueId: result.issue.id, agentId: result.issue.assigneeAgentId },
"failed to wake agent after recovery action restored issue",
));
}
res.json({
issue: {
...result.issue,
@ -2087,6 +2393,13 @@ export function issueRoutes(
});
}
await revalidateActiveSourceRecoveryAfterCommittedWrite({
issue,
trigger: "document",
actor,
documentChanged: true,
});
res.status(result.created ? 201 : 200).json(doc);
});
@ -2274,6 +2587,13 @@ export function issueRoutes(
source: "issue.document_restored",
});
await revalidateActiveSourceRecoveryAfterCommittedWrite({
issue,
trigger: "document",
actor,
documentChanged: true,
});
res.json(result.document);
},
);
@ -2344,6 +2664,12 @@ export function issueRoutes(
actor,
source: "issue.document_deleted",
});
await revalidateActiveSourceRecoveryAfterCommittedWrite({
issue,
trigger: "document",
actor,
documentChanged: true,
});
res.json({ ok: true });
});
@ -2376,6 +2702,12 @@ export function issueRoutes(
entityId: issue.id,
details: { workProductId: product.id, type: product.type, provider: product.provider },
});
await revalidateActiveSourceRecoveryAfterCommittedWrite({
issue,
trigger: "work_product",
actor,
workProductChanged: true,
});
res.status(201).json(product);
});
@ -2410,6 +2742,12 @@ export function issueRoutes(
entityId: existing.issueId,
details: { workProductId: product.id, changedKeys: Object.keys(req.body).sort() },
});
await revalidateActiveSourceRecoveryAfterCommittedWrite({
issue,
trigger: "work_product",
actor,
workProductChanged: true,
});
res.json(product);
});
@ -2444,6 +2782,12 @@ export function issueRoutes(
entityId: existing.issueId,
details: { workProductId: removed.id, type: removed.type },
});
await revalidateActiveSourceRecoveryAfterCommittedWrite({
issue,
trigger: "work_product",
actor,
workProductChanged: true,
});
res.json(removed);
});
@ -2931,6 +3275,28 @@ export function issueRoutes(
const requestedAssigneeAgentId =
normalizedAssigneeAgentId === undefined ? existing.assigneeAgentId : normalizedAssigneeAgentId;
const explicitMoveToTodoRequested = reopenRequested || resumeRequested === true;
const recoveryRelevantSourceMutationRequested =
req.body.status !== undefined ||
normalizedAssigneeAgentId !== undefined ||
req.body.assigneeUserId !== undefined ||
Array.isArray(req.body.blockedByIssueIds) ||
req.body.executionPolicy !== undefined ||
explicitMoveToTodoRequested;
const activeRecoveryActionBeforeUpdate = recoveryRelevantSourceMutationRequested
? await recoveryActionsSvc.getActiveForIssue(existing.companyId, existing.id)
: null;
if (
recoveryRelevantSourceMutationRequested &&
!(await assertRecoveryActionAuthority(
req,
res,
existing,
activeRecoveryActionBeforeUpdate,
{ source: "issue_update" },
))
) {
return;
}
const effectiveMoveToTodoRequested =
explicitMoveToTodoRequested ||
(!!commentBody &&
@ -3207,6 +3573,7 @@ export function issueRoutes(
let issueResponse: typeof issue & {
blockedBy?: unknown;
blocks?: unknown;
activeRecoveryAction?: unknown;
relatedWork?: Awaited<ReturnType<typeof issueReferencesSvc.listIssueReferenceSummary>>;
referencedIssueIdentifiers?: string[];
} = issue;
@ -3258,6 +3625,32 @@ export function issueRoutes(
previous.status !== undefined &&
issue.status === "todo";
const reopenFromStatus = reopened ? existing.status : null;
const statusChangedFromBlockedToTodo =
existing.status === "blocked" &&
issue.status === "todo" &&
(req.body.status !== undefined || reopened);
const revalidatedRecoveryAction = await revalidateActiveSourceRecoveryAfterCommittedWrite({
issue,
trigger: "issue_update",
actor,
activeRecoveryAction: activeRecoveryActionBeforeUpdate ?? undefined,
statusChanged: existing.status !== issue.status,
assigneeChanged:
existing.assigneeAgentId !== issue.assigneeAgentId ||
existing.assigneeUserId !== issue.assigneeUserId,
blockersChanged: Array.isArray(req.body.blockedByIssueIds),
executionPolicyChanged: req.body.executionPolicy !== undefined,
monitorChanged,
resumeRequested: resumeRequested === true,
reopened,
blockedToTodoRecovery: statusChangedFromBlockedToTodo,
});
if (activeRecoveryActionBeforeUpdate && !revalidatedRecoveryAction) {
issueResponse = {
...issueResponse,
activeRecoveryAction: null,
};
}
await logActivity(db, {
companyId: issue.companyId,
actorType: actor.actorType,
@ -3531,10 +3924,6 @@ export function issueRoutes(
existing.status === "backlog" &&
issue.status !== "backlog" &&
req.body.status !== undefined;
const statusChangedFromBlockedToTodo =
existing.status === "blocked" &&
issue.status === "todo" &&
(req.body.status !== undefined || reopened);
const statusChangedFromClosedToTodo =
isClosedIssueStatus(existing.status) &&
issue.status === "todo" &&
@ -4126,12 +4515,18 @@ export function issueRoutes(
});
}
const acceptedPlanConfirmation =
interaction.kind === "request_confirmation" &&
interaction.status === "accepted" &&
issue.workMode === "planning";
queueResolvedInteractionContinuationWakeup({
heartbeat,
issue: continuationWakeIssue,
interaction,
actor,
source: "issue.interaction.accept",
forceFreshSession: acceptedPlanConfirmation,
workspaceRefreshReason: acceptedPlanConfirmation ? "accepted_plan_confirmation" : null,
});
res.json(interaction);
@ -4630,6 +5025,16 @@ export function issueRoutes(
source: "issue.comment",
});
await revalidateActiveSourceRecoveryAfterCommittedWrite({
issue: currentIssue,
trigger: "comment",
actor,
statusChanged: reopened,
resumeRequested: resumeRequested === true,
reopened,
blockedToTodoRecovery: reopened && reopenFromStatus === "blocked" && currentIssue.status === "todo",
});
// Merge all wakeups from this comment into one enqueue per agent to avoid duplicate runs.
void (async () => {
const wakeups = new Map<string, Parameters<typeof heartbeat.wakeup>[1]>();