Simplify subtree pause controls

This commit is contained in:
Dotta committed 2026-04-23 09:45:54 -05:00
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);
});
});
+18 -32
View File
@@ -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";
}
+22 -7
View File
@@ -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,