mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-28 20:21:50 +01:00
Simplify subtree pause controls
This commit is contained in:
1 parent
7290dce4df
commit
94c49f97ef
8 files changed
+499
-271
No files matched your search
@@ -346,7 +346,7 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
|
||||
expect(blockedWakeRequestCount).toBeGreaterThanOrEqual(2);
|
||||
});
|
||||
|
||||
it("suppresses descendant wakeups while allowing root course-correction comment wakes under a pause hold", async () => {
|
||||
it("suppresses normal wakeups while allowing comment interaction wakes under a pause hold", async () => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const rootIssueId = randomUUID();
|
||||
@@ -424,31 +424,32 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(skippedWake).toMatchObject({ status: "skipped", reason: "issue_tree_hold_active" });
|
||||
|
||||
const rootCommentWake = await heartbeat.wakeup(agentId, {
|
||||
const childCommentWake = await heartbeat.wakeup(agentId, {
|
||||
source: "automation",
|
||||
triggerDetail: "system",
|
||||
reason: "issue_commented",
|
||||
payload: { issueId: rootIssueId, commentId: randomUUID() },
|
||||
contextSnapshot: { issueId: rootIssueId, wakeReason: "issue_commented" },
|
||||
payload: { issueId: childIssueId, commentId: randomUUID() },
|
||||
contextSnapshot: { issueId: childIssueId, wakeReason: "issue_commented" },
|
||||
});
|
||||
|
||||
expect(rootCommentWake).not.toBeNull();
|
||||
const rootRun = await db
|
||||
expect(childCommentWake).not.toBeNull();
|
||||
const childRun = await db
|
||||
.select({ contextSnapshot: heartbeatRuns.contextSnapshot })
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, rootCommentWake!.id))
|
||||
.where(eq(heartbeatRuns.id, childCommentWake!.id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(rootRun?.contextSnapshot).toMatchObject({
|
||||
expect(childRun?.contextSnapshot).toMatchObject({
|
||||
treeHoldInteraction: true,
|
||||
activeTreeHold: {
|
||||
holdId: hold.id,
|
||||
rootIssueId,
|
||||
mode: "pause",
|
||||
courseCorrectionOnly: true,
|
||||
interaction: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
|
||||
it("suppresses root comment wakes when the active hold is full_pause", async () => {
|
||||
it("allows comment interaction wakes when a legacy hold has a full_pause note", async () => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const rootIssueId = randomUUID();
|
||||
@@ -500,15 +501,19 @@ describeEmbeddedPostgres("heartbeat dependency-aware queued run selection", () =
|
||||
contextSnapshot: { issueId: rootIssueId, wakeReason: "issue_commented" },
|
||||
});
|
||||
|
||||
expect(rootCommentWake).toBeNull();
|
||||
const skippedWake = await db
|
||||
.select({
|
||||
status: agentWakeupRequests.status,
|
||||
reason: agentWakeupRequests.reason,
|
||||
})
|
||||
.from(agentWakeupRequests)
|
||||
.where(sql`${agentWakeupRequests.payload} ->> 'issueId' = ${rootIssueId}`)
|
||||
expect(rootCommentWake).not.toBeNull();
|
||||
const rootRun = await db
|
||||
.select({ contextSnapshot: heartbeatRuns.contextSnapshot })
|
||||
.from(heartbeatRuns)
|
||||
.where(eq(heartbeatRuns.id, rootCommentWake!.id))
|
||||
.then((rows) => rows[0] ?? null);
|
||||
expect(skippedWake).toMatchObject({ status: "skipped", reason: "issue_tree_hold_active" });
|
||||
expect(rootRun?.contextSnapshot).toMatchObject({
|
||||
treeHoldInteraction: true,
|
||||
activeTreeHold: {
|
||||
rootIssueId,
|
||||
mode: "pause",
|
||||
interaction: true,
|
||||
},
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -333,12 +333,13 @@ describeEmbeddedPostgres("issueTreeControlService", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("blocks held descendant checkout but allows root comment course-correction checkout", async () => {
|
||||
it("blocks normal checkout but allows comment interaction checkout under a pause hold", async () => {
|
||||
const companyId = randomUUID();
|
||||
const agentId = randomUUID();
|
||||
const rootIssueId = randomUUID();
|
||||
const childIssueId = randomUUID();
|
||||
const rootRunId = randomUUID();
|
||||
const childRunId = randomUUID();
|
||||
|
||||
await db.insert(companies).values({
|
||||
id: companyId,
|
||||
@@ -376,15 +377,26 @@ describeEmbeddedPostgres("issueTreeControlService", () => {
|
||||
assigneeAgentId: agentId,
|
||||
},
|
||||
]);
|
||||
await db.insert(heartbeatRuns).values({
|
||||
id: rootRunId,
|
||||
companyId,
|
||||
agentId,
|
||||
invocationSource: "automation",
|
||||
triggerDetail: "system",
|
||||
status: "queued",
|
||||
contextSnapshot: { issueId: rootIssueId, wakeReason: "issue_commented" },
|
||||
});
|
||||
await db.insert(heartbeatRuns).values([
|
||||
{
|
||||
id: rootRunId,
|
||||
companyId,
|
||||
agentId,
|
||||
invocationSource: "automation",
|
||||
triggerDetail: "system",
|
||||
status: "queued",
|
||||
contextSnapshot: { issueId: rootIssueId, wakeReason: "issue_commented", commentId: randomUUID() },
|
||||
},
|
||||
{
|
||||
id: childRunId,
|
||||
companyId,
|
||||
agentId,
|
||||
invocationSource: "automation",
|
||||
triggerDetail: "system",
|
||||
status: "queued",
|
||||
contextSnapshot: { issueId: childIssueId, wakeReason: "issue_commented", commentId: randomUUID() },
|
||||
},
|
||||
]);
|
||||
|
||||
const treeSvc = issueTreeControlService(db);
|
||||
await treeSvc.createHold(companyId, rootIssueId, {
|
||||
@@ -402,6 +414,10 @@ describeEmbeddedPostgres("issueTreeControlService", () => {
|
||||
}),
|
||||
});
|
||||
|
||||
const checkedOutChild = await issueSvc.checkout(childIssueId, agentId, ["todo"], childRunId);
|
||||
expect(checkedOutChild.status).toBe("in_progress");
|
||||
expect(checkedOutChild.checkoutRunId).toBe(childRunId);
|
||||
|
||||
const checkedOutRoot = await issueSvc.checkout(rootIssueId, agentId, ["todo"], rootRunId);
|
||||
expect(checkedOutRoot.status).toBe("in_progress");
|
||||
expect(checkedOutRoot.checkoutRunId).toBe(rootRunId);
|
||||
@@ -429,12 +445,8 @@ describeEmbeddedPostgres("issueTreeControlService", () => {
|
||||
actor: { actorType: "user", actorId: "board-user", userId: "board-user" },
|
||||
});
|
||||
|
||||
await expect(issueSvc.checkout(rootIssueId, agentId, ["todo"], rootRunId)).rejects.toMatchObject({
|
||||
status: 409,
|
||||
details: expect.objectContaining({
|
||||
rootIssueId,
|
||||
mode: "pause",
|
||||
}),
|
||||
});
|
||||
const checkedOutLegacyFullPauseRoot = await issueSvc.checkout(rootIssueId, agentId, ["todo"], rootRunId);
|
||||
expect(checkedOutLegacyFullPauseRoot.status).toBe("in_progress");
|
||||
expect(checkedOutLegacyFullPauseRoot.checkoutRunId).toBe(rootRunId);
|
||||
});
|
||||
});
|
||||
@@ -80,7 +80,7 @@ import {
|
||||
sanitizeRuntimeServiceBaseEnv,
|
||||
} from "./workspace-runtime.js";
|
||||
import { issueService } from "./issues.js";
|
||||
import { issueTreeControlService, isCourseCorrectionPauseReleasePolicy } from "./issue-tree-control.js";
|
||||
import { issueTreeControlService } from "./issue-tree-control.js";
|
||||
import {
|
||||
getIssueContinuationSummaryDocument,
|
||||
refreshIssueContinuationSummary,
|
||||
@@ -168,11 +168,6 @@ function resolveCodexTransientFallbackMode(attempt: number): CodexTransientFallb
|
||||
return "fresh_session_safer_invocation";
|
||||
}
|
||||
const RUNNING_ISSUE_WAKE_REASONS_REQUIRING_FOLLOWUP = new Set(["approval_approved"]);
|
||||
const TREE_HOLD_COURSE_CORRECTION_WAKE_REASONS = new Set([
|
||||
"issue_commented",
|
||||
"issue_reopened_via_comment",
|
||||
"issue_comment_mentioned",
|
||||
]);
|
||||
const SESSIONED_LOCAL_ADAPTERS = new Set([
|
||||
"claude_local",
|
||||
"codex_local",
|
||||
@@ -1257,17 +1252,17 @@ function shouldRequireIssueCommentForWake(
|
||||
);
|
||||
}
|
||||
|
||||
const BLOCKED_INTERACTION_WAKE_REASONS = new Set([
|
||||
const ISSUE_INTERACTION_WAKE_REASONS = new Set([
|
||||
"issue_commented",
|
||||
"issue_reopened_via_comment",
|
||||
"issue_comment_mentioned",
|
||||
]);
|
||||
|
||||
function allowsBlockedIssueInteractionWake(
|
||||
function allowsIssueInteractionWake(
|
||||
contextSnapshot: Record<string, unknown> | null | undefined,
|
||||
) {
|
||||
const wakeReason = readNonEmptyString(contextSnapshot?.wakeReason);
|
||||
if (!wakeReason || !BLOCKED_INTERACTION_WAKE_REASONS.has(wakeReason)) return false;
|
||||
if (!wakeReason || !ISSUE_INTERACTION_WAKE_REASONS.has(wakeReason)) return false;
|
||||
return Boolean(deriveCommentId(contextSnapshot, null));
|
||||
}
|
||||
|
||||
@@ -1636,6 +1631,8 @@ async function buildPaperclipWakePayload(input: {
|
||||
: null,
|
||||
checkedOutByHarness: input.contextSnapshot[PAPERCLIP_HARNESS_CHECKOUT_KEY] === true,
|
||||
dependencyBlockedInteraction: input.contextSnapshot.dependencyBlockedInteraction === true,
|
||||
treeHoldInteraction: input.contextSnapshot.treeHoldInteraction === true,
|
||||
activeTreeHold: parseObject(input.contextSnapshot.activeTreeHold),
|
||||
unresolvedBlockerIssueIds: Array.isArray(input.contextSnapshot.unresolvedBlockerIssueIds)
|
||||
? input.contextSnapshot.unresolvedBlockerIssueIds.filter((value): value is string => typeof value === "string" && value.length > 0)
|
||||
: [],
|
||||
@@ -3507,12 +3504,8 @@ export function heartbeatService(db: Db) {
|
||||
const issueId = readNonEmptyString(context.issueId);
|
||||
if (issueId) {
|
||||
const activePauseHold = await treeControlSvc.getActivePauseHoldGate(run.companyId, issueId);
|
||||
const wakeReason = readNonEmptyString(context.wakeReason) ?? readNonEmptyString(context.reason);
|
||||
const isRootCourseCorrectionWake =
|
||||
activePauseHold?.isRoot === true &&
|
||||
isCourseCorrectionPauseReleasePolicy(activePauseHold.releasePolicy) &&
|
||||
Boolean(wakeReason && TREE_HOLD_COURSE_CORRECTION_WAKE_REASONS.has(wakeReason));
|
||||
if (activePauseHold && !isRootCourseCorrectionWake) {
|
||||
const treeHoldInteractionWake = activePauseHold && allowsIssueInteractionWake(context);
|
||||
if (activePauseHold && !treeHoldInteractionWake) {
|
||||
await cancelRunInternal(run.id, "Cancelled because issue is held by an active subtree pause hold");
|
||||
await logActivity(db, {
|
||||
companyId: run.companyId,
|
||||
@@ -3536,7 +3529,7 @@ export function heartbeatService(db: Db) {
|
||||
|
||||
const dependencyReadiness = await issuesSvc.listDependencyReadiness(run.companyId, [issueId]);
|
||||
const unresolvedBlockerCount = dependencyReadiness.get(issueId)?.unresolvedBlockerCount ?? 0;
|
||||
if (unresolvedBlockerCount > 0 && !allowsBlockedIssueInteractionWake(context)) {
|
||||
if (unresolvedBlockerCount > 0 && !allowsIssueInteractionWake(context)) {
|
||||
logger.debug({ runId: run.id, issueId, unresolvedBlockerCount }, "claimQueuedRun: skipping blocked run");
|
||||
return null;
|
||||
}
|
||||
@@ -6119,12 +6112,8 @@ export function heartbeatService(db: Db) {
|
||||
const deferredPayload = parseObject(deferred.payload);
|
||||
const deferredContextSeed = parseObject(deferredPayload[DEFERRED_WAKE_CONTEXT_KEY]);
|
||||
const activePauseHold = await treeControlSvc.getActivePauseHoldGate(issue.companyId, issue.id);
|
||||
const deferredWakeReason = readNonEmptyString(deferredContextSeed.wakeReason) ?? readNonEmptyString(deferred.reason);
|
||||
const canPromoteRootCourseCorrection =
|
||||
activePauseHold?.isRoot === true &&
|
||||
isCourseCorrectionPauseReleasePolicy(activePauseHold.releasePolicy) &&
|
||||
Boolean(deferredWakeReason && TREE_HOLD_COURSE_CORRECTION_WAKE_REASONS.has(deferredWakeReason));
|
||||
if (activePauseHold && !canPromoteRootCourseCorrection) {
|
||||
const treeHoldInteractionWake = activePauseHold && allowsIssueInteractionWake(deferredContextSeed);
|
||||
if (activePauseHold && !treeHoldInteractionWake) {
|
||||
await tx
|
||||
.update(agentWakeupRequests)
|
||||
.set({
|
||||
@@ -6139,14 +6128,14 @@ export function heartbeatService(db: Db) {
|
||||
|
||||
const promotedContextSeed: Record<string, unknown> = { ...deferredContextSeed };
|
||||
if (activePauseHold) {
|
||||
const courseCorrectionOnly = isCourseCorrectionPauseReleasePolicy(activePauseHold.releasePolicy);
|
||||
promotedContextSeed.treeHoldInteraction = true;
|
||||
promotedContextSeed.activeTreeHold = {
|
||||
holdId: activePauseHold.holdId,
|
||||
rootIssueId: activePauseHold.rootIssueId,
|
||||
mode: activePauseHold.mode,
|
||||
reason: activePauseHold.reason,
|
||||
releasePolicy: activePauseHold.releasePolicy,
|
||||
courseCorrectionOnly,
|
||||
interaction: true,
|
||||
};
|
||||
}
|
||||
const deferredCommentIds = extractWakeCommentIds(deferredContextSeed);
|
||||
@@ -6540,13 +6529,9 @@ export function heartbeatService(db: Db) {
|
||||
if (issueId) {
|
||||
const activePauseHold = await treeControlSvc.getActivePauseHoldGate(agent.companyId, issueId);
|
||||
if (activePauseHold) {
|
||||
const wakeReason = readNonEmptyString(enrichedContextSnapshot.wakeReason) ?? reason;
|
||||
const isRootCourseCorrectionWake =
|
||||
activePauseHold.isRoot &&
|
||||
isCourseCorrectionPauseReleasePolicy(activePauseHold.releasePolicy) &&
|
||||
Boolean(wakeReason && TREE_HOLD_COURSE_CORRECTION_WAKE_REASONS.has(wakeReason));
|
||||
const treeHoldInteractionWake = allowsIssueInteractionWake(enrichedContextSnapshot);
|
||||
|
||||
if (!isRootCourseCorrectionWake) {
|
||||
if (!treeHoldInteractionWake) {
|
||||
await writeSkippedRequest("issue_tree_hold_active");
|
||||
await logActivity(db, {
|
||||
companyId: agent.companyId,
|
||||
@@ -6569,13 +6554,14 @@ export function heartbeatService(db: Db) {
|
||||
return null;
|
||||
}
|
||||
|
||||
enrichedContextSnapshot.treeHoldInteraction = true;
|
||||
enrichedContextSnapshot.activeTreeHold = {
|
||||
holdId: activePauseHold.holdId,
|
||||
rootIssueId: activePauseHold.rootIssueId,
|
||||
mode: activePauseHold.mode,
|
||||
reason: activePauseHold.reason,
|
||||
releasePolicy: activePauseHold.releasePolicy,
|
||||
courseCorrectionOnly: isCourseCorrectionPauseReleasePolicy(activePauseHold.releasePolicy),
|
||||
interaction: true,
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -6697,7 +6683,7 @@ export function heartbeatService(db: Db) {
|
||||
const blockedInteractionWake =
|
||||
dependencyReadiness &&
|
||||
!dependencyReadiness.isDependencyReady &&
|
||||
allowsBlockedIssueInteractionWake(enrichedContextSnapshot);
|
||||
allowsIssueInteractionWake(enrichedContextSnapshot);
|
||||
|
||||
if (blockedInteractionWake) {
|
||||
enrichedContextSnapshot.dependencyBlockedInteraction = true;
|
||||
|
||||
@@ -77,13 +77,6 @@ function normalizeReleasePolicy(
|
||||
return releasePolicy ?? DEFAULT_RELEASE_POLICY;
|
||||
}
|
||||
|
||||
export function isCourseCorrectionPauseReleasePolicy(
|
||||
releasePolicy: IssueTreeHoldReleasePolicy | null | undefined,
|
||||
) {
|
||||
const note = typeof releasePolicy?.note === "string" ? releasePolicy.note.trim().toLowerCase() : "";
|
||||
return note !== "full_pause";
|
||||
}
|
||||
|
||||
function coerceIssueStatus(status: string): IssueStatus {
|
||||
return ISSUE_STATUSES.includes(status as IssueStatus) ? (status as IssueStatus) : "backlog";
|
||||
}
|
||||
|
||||
@@ -38,7 +38,6 @@ import { resolveIssueGoalId, resolveNextIssueGoalId } from "./issue-goal-fallbac
|
||||
import { getDefaultCompanyGoal } from "./goals.js";
|
||||
import {
|
||||
issueTreeControlService,
|
||||
isCourseCorrectionPauseReleasePolicy,
|
||||
type ActiveIssueTreePauseHoldGate,
|
||||
} from "./issue-tree-control.js";
|
||||
|
||||
@@ -50,7 +49,7 @@ const ISSUE_LIST_RELATED_QUERY_CHUNK_SIZE = 500;
|
||||
export const MAX_CHILD_ISSUES_CREATED_BY_HELPER = 25;
|
||||
const MAX_CHILD_COMPLETION_SUMMARIES = 20;
|
||||
const CHILD_COMPLETION_SUMMARY_BODY_MAX_CHARS = 500;
|
||||
const TREE_HOLD_COURSE_CORRECTION_WAKE_REASONS = new Set([
|
||||
const ISSUE_INTERACTION_WAKE_REASONS = new Set([
|
||||
"issue_commented",
|
||||
"issue_reopened_via_comment",
|
||||
"issue_comment_mentioned",
|
||||
@@ -87,6 +86,18 @@ function readStringFromRecord(record: unknown, key: string) {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value.trim() : null;
|
||||
}
|
||||
|
||||
function readLatestWakeCommentId(record: unknown) {
|
||||
if (!record || typeof record !== "object") return null;
|
||||
const value = (record as Record<string, unknown>).wakeCommentIds;
|
||||
if (Array.isArray(value)) {
|
||||
const latest = value
|
||||
.filter((entry): entry is string => typeof entry === "string" && entry.trim().length > 0)
|
||||
.at(-1);
|
||||
if (latest) return latest.trim();
|
||||
}
|
||||
return readStringFromRecord(record, "wakeCommentId") ?? readStringFromRecord(record, "commentId");
|
||||
}
|
||||
|
||||
export interface IssueFilters {
|
||||
status?: string;
|
||||
assigneeAgentId?: string;
|
||||
@@ -941,12 +952,12 @@ export function issueService(db: Db) {
|
||||
}
|
||||
}
|
||||
|
||||
async function isRootCourseCorrectionCheckoutAllowed(
|
||||
async function isTreeHoldInteractionCheckoutAllowed(
|
||||
companyId: string,
|
||||
checkoutRunId: string | null,
|
||||
gate: ActiveIssueTreePauseHoldGate,
|
||||
_gate: ActiveIssueTreePauseHoldGate,
|
||||
) {
|
||||
if (!gate.isRoot || !checkoutRunId || !isCourseCorrectionPauseReleasePolicy(gate.releasePolicy)) return false;
|
||||
if (!checkoutRunId) return false;
|
||||
const run = await db
|
||||
.select({ contextSnapshot: heartbeatRuns.contextSnapshot })
|
||||
.from(heartbeatRuns)
|
||||
@@ -955,7 +966,11 @@ export function issueService(db: Db) {
|
||||
const wakeReason =
|
||||
readStringFromRecord(run?.contextSnapshot, "wakeReason") ??
|
||||
readStringFromRecord(run?.contextSnapshot, "reason");
|
||||
return Boolean(wakeReason && TREE_HOLD_COURSE_CORRECTION_WAKE_REASONS.has(wakeReason));
|
||||
return Boolean(
|
||||
wakeReason &&
|
||||
ISSUE_INTERACTION_WAKE_REASONS.has(wakeReason) &&
|
||||
readLatestWakeCommentId(run?.contextSnapshot),
|
||||
);
|
||||
}
|
||||
|
||||
async function assertAssignableUser(companyId: string, userId: string) {
|
||||
@@ -2228,7 +2243,7 @@ export function issueService(db: Db) {
|
||||
const activePauseHold = await treeControlSvc.getActivePauseHoldGate(issueCompany.companyId, id);
|
||||
if (
|
||||
activePauseHold &&
|
||||
!(await isRootCourseCorrectionCheckoutAllowed(issueCompany.companyId, checkoutRunId, activePauseHold))
|
||||
!(await isTreeHoldInteractionCheckoutAllowed(issueCompany.companyId, checkoutRunId, activePauseHold))
|
||||
) {
|
||||
throw conflict("Issue checkout blocked by active subtree pause hold", {
|
||||
issueId: id,
|
||||
|
||||
Reference in new issue
Block a user