mirror of
https://github.com/paperclipai/paperclip.git
synced 2026-10-24 09:59:44 +02: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
@@ -325,11 +325,20 @@ type PaperclipWakeBlockerSummary = {
|
||||
priority: string | null;
|
||||
};
|
||||
|
||||
type PaperclipWakeTreeHoldSummary = {
|
||||
holdId: string | null;
|
||||
rootIssueId: string | null;
|
||||
mode: string | null;
|
||||
reason: string | null;
|
||||
};
|
||||
|
||||
type PaperclipWakePayload = {
|
||||
reason: string | null;
|
||||
issue: PaperclipWakeIssue | null;
|
||||
checkedOutByHarness: boolean;
|
||||
dependencyBlockedInteraction: boolean;
|
||||
treeHoldInteraction: boolean;
|
||||
activeTreeHold: PaperclipWakeTreeHoldSummary | null;
|
||||
unresolvedBlockerIssueIds: string[];
|
||||
unresolvedBlockerSummaries: PaperclipWakeBlockerSummary[];
|
||||
executionStage: PaperclipWakeExecutionStage | null;
|
||||
@@ -435,6 +444,16 @@ function normalizePaperclipWakeBlockerSummary(value: unknown): PaperclipWakeBloc
|
||||
return { id, identifier, title, status, priority };
|
||||
}
|
||||
|
||||
function normalizePaperclipWakeTreeHoldSummary(value: unknown): PaperclipWakeTreeHoldSummary | null {
|
||||
const hold = parseObject(value);
|
||||
const holdId = asString(hold.holdId, "").trim() || null;
|
||||
const rootIssueId = asString(hold.rootIssueId, "").trim() || null;
|
||||
const mode = asString(hold.mode, "").trim() || null;
|
||||
const reason = asString(hold.reason, "").trim() || null;
|
||||
if (!holdId && !rootIssueId && !mode && !reason) return null;
|
||||
return { holdId, rootIssueId, mode, reason };
|
||||
}
|
||||
|
||||
function normalizePaperclipWakeExecutionPrincipal(value: unknown): PaperclipWakeExecutionPrincipal | null {
|
||||
const principal = parseObject(value);
|
||||
const typeRaw = asString(principal.type, "").trim().toLowerCase();
|
||||
@@ -511,7 +530,8 @@ export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayl
|
||||
.filter((entry): entry is PaperclipWakeBlockerSummary => Boolean(entry))
|
||||
: [];
|
||||
|
||||
if (comments.length === 0 && commentIds.length === 0 && childIssueSummaries.length === 0 && unresolvedBlockerIssueIds.length === 0 && unresolvedBlockerSummaries.length === 0 && !executionStage && !continuationSummary && !livenessContinuation && !normalizePaperclipWakeIssue(payload.issue)) {
|
||||
const activeTreeHold = normalizePaperclipWakeTreeHoldSummary(payload.activeTreeHold);
|
||||
if (comments.length === 0 && commentIds.length === 0 && childIssueSummaries.length === 0 && unresolvedBlockerIssueIds.length === 0 && unresolvedBlockerSummaries.length === 0 && !activeTreeHold && !executionStage && !continuationSummary && !livenessContinuation && !normalizePaperclipWakeIssue(payload.issue)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
@@ -520,6 +540,8 @@ export function normalizePaperclipWakePayload(value: unknown): PaperclipWakePayl
|
||||
issue: normalizePaperclipWakeIssue(payload.issue),
|
||||
checkedOutByHarness: asBoolean(payload.checkedOutByHarness, false),
|
||||
dependencyBlockedInteraction: asBoolean(payload.dependencyBlockedInteraction, false),
|
||||
treeHoldInteraction: asBoolean(payload.treeHoldInteraction, false),
|
||||
activeTreeHold,
|
||||
unresolvedBlockerIssueIds,
|
||||
unresolvedBlockerSummaries,
|
||||
executionStage,
|
||||
@@ -614,6 +636,14 @@ export function renderPaperclipWakePrompt(
|
||||
lines.push(`- unresolved blocker issue ids: ${normalized.unresolvedBlockerIssueIds.join(", ")}`);
|
||||
}
|
||||
}
|
||||
if (normalized.treeHoldInteraction) {
|
||||
lines.push("- tree-hold interaction: yes");
|
||||
lines.push("- execution scope: respond or triage the human comment; the subtree remains paused until an explicit resume action");
|
||||
if (normalized.activeTreeHold) {
|
||||
const hold = normalized.activeTreeHold;
|
||||
lines.push(`- active tree hold: ${hold.holdId ?? "unknown"}${hold.rootIssueId ? ` rooted at ${hold.rootIssueId}` : ""}${hold.mode ? ` (${hold.mode})` : ""}`);
|
||||
}
|
||||
}
|
||||
if (normalized.missingCount > 0) {
|
||||
lines.push(`- omitted comments: ${normalized.missingCount}`);
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
|
||||
import type { Issue, IssueTreeControlPreview, IssueTreeHold } from "@paperclipai/shared";
|
||||
import type { Agent, Issue, IssueTreeControlPreview, IssueTreeHold } from "@paperclipai/shared";
|
||||
import { act, type ButtonHTMLAttributes, type ReactNode } from "react";
|
||||
import { createRoot, type Root } from "react-dom/client";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
@@ -273,11 +273,13 @@ vi.mock("@/components/ui/popover", () => ({
|
||||
|
||||
vi.mock("@/components/ui/dialog", () => ({
|
||||
Dialog: ({ children, open }: { children?: ReactNode; open?: boolean }) => (open ? <div>{children}</div> : null),
|
||||
DialogContent: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
DialogDescription: ({ children }: { children?: ReactNode }) => <p>{children}</p>,
|
||||
DialogFooter: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
DialogHeader: ({ children }: { children?: ReactNode }) => <div>{children}</div>,
|
||||
DialogTitle: ({ children }: { children?: ReactNode }) => <h2>{children}</h2>,
|
||||
DialogContent: ({ children, className }: { children?: ReactNode; className?: string }) => (
|
||||
<div data-slot="dialog-content" className={className}>{children}</div>
|
||||
),
|
||||
DialogDescription: ({ children, className }: { children?: ReactNode; className?: string }) => <p className={className}>{children}</p>,
|
||||
DialogFooter: ({ children, className }: { children?: ReactNode; className?: string }) => <div className={className}>{children}</div>,
|
||||
DialogHeader: ({ children, className }: { children?: ReactNode; className?: string }) => <div className={className}>{children}</div>,
|
||||
DialogTitle: ({ children, className }: { children?: ReactNode; className?: string }) => <h2 className={className}>{children}</h2>,
|
||||
}));
|
||||
|
||||
vi.mock("@/components/ui/sheet", () => ({
|
||||
@@ -366,6 +368,34 @@ function createIssue(overrides: Partial<Issue> = {}): Issue {
|
||||
} as Issue;
|
||||
}
|
||||
|
||||
function createAgent(overrides: Partial<Agent> = {}): Agent {
|
||||
return {
|
||||
id: "agent-1",
|
||||
companyId: "company-1",
|
||||
name: "CodexCoder",
|
||||
urlKey: "codexcoder",
|
||||
role: "engineer",
|
||||
title: "Software Engineer",
|
||||
icon: "code",
|
||||
status: "active",
|
||||
reportsTo: null,
|
||||
capabilities: null,
|
||||
adapterType: "codex_local",
|
||||
adapterConfig: {},
|
||||
runtimeConfig: {},
|
||||
budgetMonthlyCents: 0,
|
||||
spentMonthlyCents: 0,
|
||||
pauseReason: null,
|
||||
pausedAt: null,
|
||||
permissions: { canCreateAgents: false },
|
||||
lastHeartbeatAt: null,
|
||||
metadata: null,
|
||||
createdAt: new Date("2026-04-21T00:00:00.000Z"),
|
||||
updatedAt: new Date("2026-04-21T00:00:00.000Z"),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createPauseHold(overrides: Partial<IssueTreeHold> = {}): IssueTreeHold {
|
||||
const now = new Date("2026-04-21T00:00:00.000Z");
|
||||
return {
|
||||
@@ -444,7 +474,7 @@ function createResumePreview(): IssueTreeControlPreview {
|
||||
skippedIssues: 0,
|
||||
activeRuns: 0,
|
||||
queuedRuns: 0,
|
||||
affectedAgents: 0,
|
||||
affectedAgents: 1,
|
||||
},
|
||||
countsByStatus: { todo: 2 },
|
||||
issues: [
|
||||
@@ -455,7 +485,7 @@ function createResumePreview(): IssueTreeControlPreview {
|
||||
status: "todo",
|
||||
parentId: null,
|
||||
depth: 0,
|
||||
assigneeAgentId: null,
|
||||
assigneeAgentId: "agent-1",
|
||||
assigneeUserId: null,
|
||||
activeRun: null,
|
||||
activeHoldIds: ["hold-1"],
|
||||
@@ -470,7 +500,7 @@ function createResumePreview(): IssueTreeControlPreview {
|
||||
status: "todo",
|
||||
parentId: "issue-1",
|
||||
depth: 1,
|
||||
assigneeAgentId: null,
|
||||
assigneeAgentId: "agent-1",
|
||||
assigneeUserId: null,
|
||||
activeRun: null,
|
||||
activeHoldIds: ["hold-1"],
|
||||
@@ -481,6 +511,61 @@ function createResumePreview(): IssueTreeControlPreview {
|
||||
],
|
||||
skippedIssues: [],
|
||||
activeRuns: [],
|
||||
affectedAgents: [{ agentId: "agent-1", issueCount: 2, activeRunCount: 0 }],
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
function createPausePreview(): IssueTreeControlPreview {
|
||||
return {
|
||||
companyId: "company-1",
|
||||
rootIssueId: "issue-1",
|
||||
mode: "pause",
|
||||
generatedAt: new Date("2026-04-21T00:00:00.000Z"),
|
||||
releasePolicy: { strategy: "manual" },
|
||||
totals: {
|
||||
totalIssues: 2,
|
||||
affectedIssues: 2,
|
||||
skippedIssues: 0,
|
||||
activeRuns: 1,
|
||||
queuedRuns: 0,
|
||||
affectedAgents: 0,
|
||||
},
|
||||
countsByStatus: { todo: 2 },
|
||||
issues: [
|
||||
{
|
||||
id: "issue-1",
|
||||
identifier: "PAP-1",
|
||||
title: "Issue detail smoke",
|
||||
status: "todo",
|
||||
parentId: null,
|
||||
depth: 0,
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
activeRun: null,
|
||||
activeHoldIds: [],
|
||||
action: "pause",
|
||||
skipped: false,
|
||||
skipReason: null,
|
||||
},
|
||||
{
|
||||
id: "child-1",
|
||||
identifier: "PAP-2",
|
||||
title: "Paused child",
|
||||
status: "in_review",
|
||||
parentId: "issue-1",
|
||||
depth: 1,
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
activeRun: null,
|
||||
activeHoldIds: [],
|
||||
action: "pause",
|
||||
skipped: false,
|
||||
skipReason: null,
|
||||
},
|
||||
],
|
||||
skippedIssues: [],
|
||||
activeRuns: [],
|
||||
affectedAgents: [],
|
||||
warnings: [],
|
||||
};
|
||||
@@ -557,6 +642,46 @@ function createRestorePreview(): IssueTreeControlPreview {
|
||||
};
|
||||
}
|
||||
|
||||
function createCancelPreview(issueCount = 8): IssueTreeControlPreview {
|
||||
const issues = Array.from({ length: issueCount }, (_, index) => ({
|
||||
id: index === 0 ? "issue-1" : `child-${index}`,
|
||||
identifier: index === 0 ? "PAP-1" : `PAP-${index + 1}`,
|
||||
title: index === 0 ? "Issue detail smoke" : `Cancellable child ${index}`,
|
||||
status: "todo" as const,
|
||||
parentId: index === 0 ? null : "issue-1",
|
||||
depth: index === 0 ? 0 : 1,
|
||||
assigneeAgentId: null,
|
||||
assigneeUserId: null,
|
||||
activeRun: null,
|
||||
activeHoldIds: [],
|
||||
action: "cancel" as const,
|
||||
skipped: false,
|
||||
skipReason: null,
|
||||
}));
|
||||
|
||||
return {
|
||||
companyId: "company-1",
|
||||
rootIssueId: "issue-1",
|
||||
mode: "cancel",
|
||||
generatedAt: new Date("2026-04-21T00:00:00.000Z"),
|
||||
releasePolicy: { strategy: "manual" },
|
||||
totals: {
|
||||
totalIssues: issueCount,
|
||||
affectedIssues: issueCount,
|
||||
skippedIssues: 0,
|
||||
activeRuns: 0,
|
||||
queuedRuns: 0,
|
||||
affectedAgents: 0,
|
||||
},
|
||||
countsByStatus: { todo: issueCount },
|
||||
issues,
|
||||
skippedIssues: [],
|
||||
activeRuns: [],
|
||||
affectedAgents: [],
|
||||
warnings: [],
|
||||
};
|
||||
}
|
||||
|
||||
async function flushReact() {
|
||||
await act(async () => {
|
||||
await Promise.resolve();
|
||||
@@ -702,6 +827,7 @@ describe("IssueDetail", () => {
|
||||
);
|
||||
mockIssuesApi.listTreeHolds.mockResolvedValue([activeHold]);
|
||||
mockIssuesApi.previewTreeControl.mockResolvedValue(createResumePreview());
|
||||
mockAgentsApi.list.mockResolvedValue([createAgent()]);
|
||||
mockIssuesApi.releaseTreeHold.mockImplementation(() => {
|
||||
activePauseHoldState = null;
|
||||
return Promise.resolve(releasedHold);
|
||||
@@ -739,6 +865,7 @@ describe("IssueDetail", () => {
|
||||
.filter((button) => button.textContent?.trim() === "Resume subtree")
|
||||
.at(-1);
|
||||
expect(applyResumeButton).toBeTruthy();
|
||||
expect(container.textContent).toContain("CodexCoder");
|
||||
|
||||
await act(async () => {
|
||||
applyResumeButton!.click();
|
||||
@@ -748,7 +875,7 @@ describe("IssueDetail", () => {
|
||||
|
||||
expect(mockIssuesApi.releaseTreeHold).toHaveBeenCalledWith("PAP-1", "hold-1", {
|
||||
reason: null,
|
||||
metadata: { wakeAgents: false },
|
||||
metadata: { wakeAgents: true },
|
||||
});
|
||||
expect(mockIssuesApi.getTreeControlState.mock.calls.length).toBeGreaterThanOrEqual(2);
|
||||
expect(mockPushToast).toHaveBeenCalledWith(expect.objectContaining({
|
||||
@@ -761,6 +888,88 @@ describe("IssueDetail", () => {
|
||||
});
|
||||
});
|
||||
|
||||
it("uses simplified full-subtree pause controls", async () => {
|
||||
const childIssue = createIssue({
|
||||
id: "child-1",
|
||||
parentId: "issue-1",
|
||||
identifier: "PAP-2",
|
||||
issueNumber: 2,
|
||||
title: "Paused child",
|
||||
});
|
||||
const pausePreview = createPausePreview();
|
||||
const pauseHold = createPauseHold({
|
||||
id: "pause-hold-1",
|
||||
mode: "pause",
|
||||
reason: null,
|
||||
releasePolicy: { strategy: "manual", note: "full_pause" },
|
||||
members: [],
|
||||
});
|
||||
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue());
|
||||
mockIssuesApi.list.mockImplementation((_companyId, filters?: { parentId?: string }) =>
|
||||
Promise.resolve(filters?.parentId === "issue-1" ? [childIssue] : []),
|
||||
);
|
||||
mockIssuesApi.previewTreeControl.mockResolvedValue(pausePreview);
|
||||
mockIssuesApi.createTreeHold.mockResolvedValue({ hold: pauseHold, preview: pausePreview });
|
||||
mockAuthApi.getSession.mockResolvedValue({
|
||||
session: { userId: "user-1" },
|
||||
user: { id: "user-1" },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDetail />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
const moreButton = container.querySelector('button[aria-label="More issue actions"]') as HTMLButtonElement | null;
|
||||
expect(moreButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
moreButton!.dispatchEvent(new KeyboardEvent("keydown", { key: "Enter", bubbles: true }));
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
const pauseMenuButton = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.trim() === "Pause subtree...");
|
||||
expect(pauseMenuButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
pauseMenuButton!.click();
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(mockIssuesApi.previewTreeControl).toHaveBeenCalledWith("PAP-1", {
|
||||
mode: "pause",
|
||||
releasePolicy: { strategy: "manual" },
|
||||
});
|
||||
expect(container.textContent).not.toContain("Pause mode");
|
||||
expect(container.textContent).not.toContain("Release policy");
|
||||
expect(container.textContent).not.toContain("Status breakdown");
|
||||
expect(container.textContent).not.toContain("Active runs cancelled");
|
||||
expect(container.textContent).toContain("Paused child");
|
||||
|
||||
const pauseApplyButton = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.trim() === "Pause and stop work");
|
||||
expect(pauseApplyButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
pauseApplyButton!.click();
|
||||
});
|
||||
await flushReact();
|
||||
|
||||
expect(mockIssuesApi.createTreeHold).toHaveBeenCalledWith("PAP-1", {
|
||||
mode: "pause",
|
||||
reason: null,
|
||||
releasePolicy: { strategy: "manual", note: "full_pause" },
|
||||
});
|
||||
});
|
||||
|
||||
it("exposes restore subtree from the issue actions menu", async () => {
|
||||
const childIssue = createIssue({
|
||||
id: "child-1",
|
||||
@@ -835,7 +1044,7 @@ describe("IssueDetail", () => {
|
||||
releasePolicy: { strategy: "manual" },
|
||||
});
|
||||
expect(container.textContent).toContain("Restore issues cancelled by this subtree operation so work can resume.");
|
||||
expect(container.textContent).toContain("Issues restored: 1");
|
||||
expect(container.textContent).toContain("Cancelled child");
|
||||
|
||||
const restoreApplyButton = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.trim() === "Restore 1 issues");
|
||||
@@ -853,4 +1062,72 @@ describe("IssueDetail", () => {
|
||||
metadata: { wakeAgents: false },
|
||||
});
|
||||
});
|
||||
|
||||
it("bounds the subtree control dialog with an internal scroll body", async () => {
|
||||
const childIssue = createIssue({
|
||||
id: "child-1",
|
||||
parentId: "issue-1",
|
||||
identifier: "PAP-2",
|
||||
issueNumber: 2,
|
||||
title: "Cancellable child",
|
||||
});
|
||||
|
||||
mockIssuesApi.get.mockResolvedValue(createIssue());
|
||||
mockIssuesApi.list.mockImplementation((_companyId, filters?: { parentId?: string }) =>
|
||||
Promise.resolve(filters?.parentId === "issue-1" ? [childIssue] : []),
|
||||
);
|
||||
mockIssuesApi.previewTreeControl.mockResolvedValue(createCancelPreview(24));
|
||||
mockAuthApi.getSession.mockResolvedValue({
|
||||
session: { userId: "user-1" },
|
||||
user: { id: "user-1" },
|
||||
});
|
||||
|
||||
await act(async () => {
|
||||
root.render(
|
||||
<QueryClientProvider client={queryClient}>
|
||||
<IssueDetail />
|
||||
</QueryClientProvider>,
|
||||
);
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
const cancelMenuButton = Array.from(container.querySelectorAll("button"))
|
||||
.find((button) => button.textContent?.trim() === "Cancel subtree...");
|
||||
expect(cancelMenuButton).toBeTruthy();
|
||||
|
||||
await act(async () => {
|
||||
cancelMenuButton!.click();
|
||||
});
|
||||
await flushReact();
|
||||
await flushReact();
|
||||
|
||||
expect(mockIssuesApi.previewTreeControl).toHaveBeenCalledWith("PAP-1", {
|
||||
mode: "cancel",
|
||||
releasePolicy: { strategy: "manual" },
|
||||
});
|
||||
|
||||
const dialogContent = container.querySelector('[data-slot="dialog-content"]') as HTMLDivElement | null;
|
||||
expect(dialogContent).toBeTruthy();
|
||||
expect(dialogContent!.className).toContain("max-h-[calc(100dvh-2rem)]");
|
||||
expect(dialogContent!.className).toContain("overflow-hidden");
|
||||
expect(dialogContent!.className).toContain("flex-col");
|
||||
|
||||
const bodyScrollRegion = Array.from(dialogContent!.querySelectorAll("div"))
|
||||
.find((element) =>
|
||||
typeof element.className === "string"
|
||||
&& element.className.includes("overflow-y-auto")
|
||||
&& element.textContent?.includes("Reason (required)"),
|
||||
);
|
||||
expect(bodyScrollRegion?.className).toContain("min-h-0");
|
||||
expect(bodyScrollRegion?.className).toContain("overscroll-contain");
|
||||
|
||||
const footer = Array.from(dialogContent!.querySelectorAll("div"))
|
||||
.find((element) =>
|
||||
typeof element.className === "string"
|
||||
&& element.className.includes("border-t")
|
||||
&& element.textContent?.includes("Close"),
|
||||
);
|
||||
expect(footer?.className).toContain("bg-background");
|
||||
});
|
||||
});
|
||||
+87
-177
@@ -65,6 +65,7 @@ import { IssueChatThread, type IssueChatComposerHandle } from "../components/Iss
|
||||
import { IssueContinuationHandoff } from "../components/IssueContinuationHandoff";
|
||||
import { IssueDocumentsSection } from "../components/IssueDocumentsSection";
|
||||
import { IssuesList } from "../components/IssuesList";
|
||||
import { AgentIcon } from "../components/AgentIconPicker";
|
||||
import { IssueReferenceActivitySummary } from "../components/IssueReferenceActivitySummary";
|
||||
import { IssueRelatedWorkPanel } from "../components/IssueRelatedWorkPanel";
|
||||
import { IssueProperties } from "../components/IssueProperties";
|
||||
@@ -157,33 +158,11 @@ const TREE_CONTROL_MODE_LABEL: Record<IssueTreeControlMode, string> = {
|
||||
restore: "Restore subtree",
|
||||
};
|
||||
const TREE_CONTROL_MODE_HELP_TEXT: Record<IssueTreeControlMode, string> = {
|
||||
pause: "Pause active execution in this issue subtree and hold descendant execution until release.",
|
||||
pause: "Pause active execution in this issue subtree until an explicit resume.",
|
||||
resume: "Release the active subtree pause hold so held work can continue.",
|
||||
cancel: "Cancel non-terminal issues in this subtree and stop queued/running work where possible.",
|
||||
restore: "Restore issues cancelled by this subtree operation so work can resume.",
|
||||
};
|
||||
const TREE_CONTROL_STRATEGY_OPTIONS = [
|
||||
{ value: "manual", label: "Manual release" },
|
||||
{ value: "after_active_runs_finish", label: "Release after active runs finish" },
|
||||
] as const;
|
||||
const TREE_CONTROL_PAUSE_VARIANTS = [
|
||||
{
|
||||
value: "course_correction",
|
||||
label: "Course-correction pause",
|
||||
description: "Hold descendants while the root can still run for planner follow-up.",
|
||||
},
|
||||
{
|
||||
value: "full_pause",
|
||||
label: "Full subtree pause",
|
||||
description: "Stop root and descendant execution until an explicit resume action.",
|
||||
},
|
||||
] as const;
|
||||
type TreeControlPauseVariant = (typeof TREE_CONTROL_PAUSE_VARIANTS)[number]["value"];
|
||||
|
||||
function parsePauseVariantFromNote(note: string | null | undefined): TreeControlPauseVariant {
|
||||
const normalized = note?.trim().toLowerCase();
|
||||
return normalized === "course_correction" ? "course_correction" : "full_pause";
|
||||
}
|
||||
|
||||
function treeControlPreviewErrorCopy(error: unknown): string {
|
||||
if (error instanceof ApiError) {
|
||||
@@ -1048,12 +1027,8 @@ export function IssueDetail() {
|
||||
const [treeControlOpen, setTreeControlOpen] = useState(false);
|
||||
const [treeControlMode, setTreeControlMode] = useState<IssueTreeControlMode>("pause");
|
||||
const [treeControlReason, setTreeControlReason] = useState("");
|
||||
const [treeControlPauseVariant, setTreeControlPauseVariant] = useState<TreeControlPauseVariant>("course_correction");
|
||||
const [treeControlWakeAgentsOnResume, setTreeControlWakeAgentsOnResume] = useState(false);
|
||||
const [treeControlCancelConfirmed, setTreeControlCancelConfirmed] = useState(false);
|
||||
const [treeControlReleaseStrategy, setTreeControlReleaseStrategy] = useState<"manual" | "after_active_runs_finish">(
|
||||
"manual",
|
||||
);
|
||||
const [optimisticComments, setOptimisticComments] = useState<OptimisticIssueComment[]>([]);
|
||||
const [locallyQueuedCommentRunIds, setLocallyQueuedCommentRunIds] = useState<Map<string, string>>(() => new Map());
|
||||
const [pendingCommentComposerFocusKey, setPendingCommentComposerFocusKey] = useState(0);
|
||||
@@ -1247,14 +1222,12 @@ export function IssueDetail() {
|
||||
"tree-control-preview",
|
||||
issueId ?? "pending",
|
||||
treeControlMode,
|
||||
treeControlReleaseStrategy,
|
||||
treeControlPauseVariant,
|
||||
],
|
||||
queryFn: () =>
|
||||
issuesApi.previewTreeControl(issueId!, {
|
||||
mode: treeControlMode,
|
||||
releasePolicy: {
|
||||
strategy: treeControlReleaseStrategy,
|
||||
strategy: "manual",
|
||||
},
|
||||
}),
|
||||
enabled: treeControlOpen && !!issueId && canManageTreeControl,
|
||||
@@ -1543,10 +1516,8 @@ export function IssueDetail() {
|
||||
mode: treeControlMode,
|
||||
reason: treeControlReason.trim() || null,
|
||||
releasePolicy: {
|
||||
strategy: treeControlReleaseStrategy,
|
||||
...(treeControlMode === "pause"
|
||||
? { note: treeControlPauseVariant }
|
||||
: {}),
|
||||
strategy: "manual",
|
||||
...(treeControlMode === "pause" ? { note: "full_pause" } : {}),
|
||||
},
|
||||
...(treeControlMode === "restore"
|
||||
? { metadata: { wakeAgents: treeControlWakeAgentsOnResume } }
|
||||
@@ -1557,7 +1528,6 @@ export function IssueDetail() {
|
||||
onSuccess: async (result) => {
|
||||
const modeLabel = TREE_CONTROL_MODE_LABEL[result.hold.mode];
|
||||
const cancelCount = result.preview?.totals.activeRuns ?? 0;
|
||||
const pauseModeText = treeControlPauseVariant === "course_correction" ? "Course-correction pause enabled." : "Full subtree pause enabled.";
|
||||
pushToast({
|
||||
title: result.kind === "release"
|
||||
? "Subtree resumed"
|
||||
@@ -1567,10 +1537,10 @@ export function IssueDetail() {
|
||||
body: result.kind === "release"
|
||||
? (result.hold.releaseReason?.trim() || "Active subtree pause released.")
|
||||
: result.hold.mode === "pause"
|
||||
? `${pauseModeText} ${cancelCount} run${cancelCount === 1 ? "" : "s"} cancelled.`
|
||||
? `Subtree paused. ${cancelCount} run${cancelCount === 1 ? "" : "s"} cancelled.`
|
||||
: result.hold.reason?.trim()
|
||||
? result.hold.reason
|
||||
: `Control applied with ${result.hold.releasePolicy?.strategy ?? "manual"} release policy.`,
|
||||
: "Subtree control applied.",
|
||||
});
|
||||
setTreeControlOpen(false);
|
||||
setTreeControlReason("");
|
||||
@@ -2671,26 +2641,13 @@ export function IssueDetail() {
|
||||
|
||||
const hasAttachments = attachmentList.length > 0;
|
||||
const treePreviewWarnings = treeControlPreview?.warnings ?? [];
|
||||
const activePauseVariant = parsePauseVariantFromNote(
|
||||
activeRootPauseHold?.releasePolicy?.note ?? activePauseHold?.releasePolicy?.note,
|
||||
);
|
||||
const heldDescendantCount = activeRootPauseHold?.members?.filter((member) => member.depth > 0 && !member.skipped).length
|
||||
?? Math.max(heldIssueIds.size - 1, 0);
|
||||
const canShowSubtreeControls = canManageTreeControl && childIssues.length > 0;
|
||||
const canResumeSubtree = canShowSubtreeControls && activePauseHold?.isRoot === true;
|
||||
const canRestoreSubtree = canShowSubtreeControls && activeCancelHolds.length > 0;
|
||||
const previewTerminalSkippedCount = treeControlPreview?.skippedIssues.filter((candidate) => candidate.skipReason === "terminal_status").length ?? 0;
|
||||
const previewAffectedIssueCount = treePreviewAffectedIssues.length;
|
||||
const previewAffectedDescendantCount = treePreviewAffectedIssues.filter((candidate) => candidate.depth > 0).length;
|
||||
const previewAffectedAgentCount = treeControlPreview?.totals.affectedAgents ?? 0;
|
||||
const treePreviewPrimaryCountLabel =
|
||||
treeControlMode === "pause"
|
||||
? "Issues held"
|
||||
: treeControlMode === "cancel"
|
||||
? "Issues cancelled"
|
||||
: treeControlMode === "restore"
|
||||
? "Issues restored"
|
||||
: "Issues released";
|
||||
const treeControlPrimaryButtonLabel =
|
||||
treeControlMode === "pause"
|
||||
? "Pause and stop work"
|
||||
@@ -2699,27 +2656,39 @@ export function IssueDetail() {
|
||||
: treeControlMode === "restore"
|
||||
? `Restore ${previewAffectedIssueCount} issues`
|
||||
: "Resume subtree";
|
||||
const deferredComposerHint = activePauseHold && !activePauseHold.isRoot
|
||||
? "Will be delivered when the subtree resumes."
|
||||
: null;
|
||||
const rootCourseCorrectionComposerHint = activePauseHold?.isRoot && activePauseVariant === "course_correction"
|
||||
const treePreviewAffectedIssueRows = treePreviewAffectedIssues.map((candidate) => ({
|
||||
candidate,
|
||||
issue: {
|
||||
...issue,
|
||||
id: candidate.id,
|
||||
identifier: candidate.identifier,
|
||||
title: candidate.title,
|
||||
status: candidate.status,
|
||||
parentId: candidate.parentId,
|
||||
assigneeAgentId: candidate.assigneeAgentId,
|
||||
assigneeUserId: candidate.assigneeUserId,
|
||||
executionRunId: candidate.activeRun?.id ?? null,
|
||||
} satisfies Issue,
|
||||
}));
|
||||
const treePreviewAffectedAgentRows = (treeControlPreview?.affectedAgents ?? [])
|
||||
.map((previewAgent) => ({
|
||||
...previewAgent,
|
||||
agent: agentMap.get(previewAgent.agentId) ?? null,
|
||||
}))
|
||||
.sort((a, b) => (a.agent?.name ?? a.agentId).localeCompare(b.agent?.name ?? b.agentId));
|
||||
const pausedComposerHint = activePauseHold
|
||||
? (
|
||||
issue.assigneeAgentId
|
||||
? `Sending this comment will wake ${agentMap.get(issue.assigneeAgentId)?.name ?? "the assignee"} in course-correction mode. Descendants stay held.`
|
||||
: "Assign a planner above to run course corrections."
|
||||
? `Sending this comment will wake ${agentMap.get(issue.assigneeAgentId)?.name ?? "the assignee"} for triage while the subtree remains paused.`
|
||||
: "Assign an agent to wake them for triage while the subtree remains paused."
|
||||
)
|
||||
: null;
|
||||
const composerHint = rootCourseCorrectionComposerHint ?? deferredComposerHint;
|
||||
const queuedCommentReason: "hold" | "active_run" | "other" =
|
||||
activePauseHold && (!activePauseHold.isRoot || activePauseVariant === "full_pause")
|
||||
? "hold"
|
||||
: "active_run";
|
||||
const composerHint = pausedComposerHint;
|
||||
const queuedCommentReason: "hold" | "active_run" | "other" = "active_run";
|
||||
const canApplyTreeControl =
|
||||
Boolean(treeControlPreview)
|
||||
&& !treeControlPreviewLoading
|
||||
&& (treeControlMode !== "cancel" || (treeControlReason.trim().length > 0 && treeControlCancelConfirmed))
|
||||
&& (treeControlMode !== "resume" || !treeControlWakeAgentsOnResume || previewAffectedAgentCount > 0)
|
||||
&& (treeControlMode !== "restore" || !treeControlWakeAgentsOnResume || previewAffectedAgentCount > 0);
|
||||
&& (treeControlMode !== "cancel" || (treeControlReason.trim().length > 0 && treeControlCancelConfirmed));
|
||||
const attachmentUploadButton = (
|
||||
<>
|
||||
<input
|
||||
@@ -2792,9 +2761,7 @@ export function IssueDetail() {
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<span className="font-medium">Subtree pause is active.</span>
|
||||
<span className="text-xs text-amber-900/80 dark:text-amber-100/80">
|
||||
{activePauseVariant === "course_correction"
|
||||
? "Course-correction mode keeps descendants held while root planning can continue."
|
||||
: "Full pause mode holds the full subtree until resume."}
|
||||
Root and descendant execution is held until resume. Human comments can still wake assignees for triage.
|
||||
</span>
|
||||
</div>
|
||||
<div className="text-xs text-amber-900/80 dark:text-amber-100/80">
|
||||
@@ -2807,7 +2774,7 @@ export function IssueDetail() {
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setTreeControlMode("resume");
|
||||
setTreeControlWakeAgentsOnResume(false);
|
||||
setTreeControlWakeAgentsOnResume(true);
|
||||
setTreeControlOpen(true);
|
||||
}}
|
||||
>
|
||||
@@ -2818,6 +2785,7 @@ export function IssueDetail() {
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
setTreeControlMode("resume");
|
||||
setTreeControlWakeAgentsOnResume(true);
|
||||
setTreeControlOpen(true);
|
||||
}}
|
||||
>
|
||||
@@ -3017,7 +2985,7 @@ export function IssueDetail() {
|
||||
className="flex items-center gap-2 w-full px-2 py-1.5 text-xs rounded hover:bg-accent/50"
|
||||
onClick={() => {
|
||||
setTreeControlMode("resume");
|
||||
setTreeControlWakeAgentsOnResume(false);
|
||||
setTreeControlWakeAgentsOnResume(true);
|
||||
setTreeControlOpen(true);
|
||||
setMoreOpen(false);
|
||||
}}
|
||||
@@ -3448,46 +3416,20 @@ export function IssueDetail() {
|
||||
</Tabs>
|
||||
|
||||
<Dialog open={treeControlOpen} onOpenChange={setTreeControlOpen}>
|
||||
<DialogContent className="sm:max-w-[560px]">
|
||||
<DialogHeader>
|
||||
<DialogContent className="flex max-h-[calc(100dvh-2rem)] flex-col gap-0 overflow-hidden p-0 sm:max-w-[560px]">
|
||||
<DialogHeader className="border-b border-border/60 px-6 pb-4 pr-12 pt-6">
|
||||
<DialogTitle>{TREE_CONTROL_MODE_LABEL[treeControlMode]}</DialogTitle>
|
||||
<DialogDescription>
|
||||
{TREE_CONTROL_MODE_HELP_TEXT[treeControlMode]}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<div className="min-h-0 flex-1 space-y-4 overflow-y-auto overscroll-contain px-6 py-4">
|
||||
{treeControlMode === "cancel" ? (
|
||||
<div className="rounded-md border border-destructive/30 bg-destructive/10 p-3 text-xs text-destructive">
|
||||
Cancelling a subtree is destructive. Non-terminal issues will be marked cancelled, and running or queued work will be interrupted where possible.
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{treeControlMode === "pause" ? (
|
||||
<div className="space-y-2">
|
||||
<p className="text-xs font-medium text-muted-foreground">Pause mode</p>
|
||||
{TREE_CONTROL_PAUSE_VARIANTS.map((variant) => (
|
||||
<label
|
||||
key={variant.value}
|
||||
className={cn(
|
||||
"flex cursor-pointer items-start gap-2 rounded-md border p-2 text-sm",
|
||||
treeControlPauseVariant === variant.value ? "border-primary bg-primary/5" : "border-border",
|
||||
)}
|
||||
>
|
||||
<input
|
||||
type="radio"
|
||||
className="mt-0.5"
|
||||
checked={treeControlPauseVariant === variant.value}
|
||||
onChange={() => setTreeControlPauseVariant(variant.value)}
|
||||
/>
|
||||
<span>
|
||||
<span className="block font-medium">{variant.label}</span>
|
||||
<span className="text-xs text-muted-foreground">{variant.description}</span>
|
||||
</span>
|
||||
</label>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs text-muted-foreground">
|
||||
{treeControlMode === "cancel" ? "Reason (required)" : "Reason (optional)"}
|
||||
@@ -3501,41 +3443,36 @@ export function IssueDetail() {
|
||||
</div>
|
||||
|
||||
{(treeControlMode === "resume" || treeControlMode === "restore") ? (
|
||||
<label className="flex items-start gap-2 rounded-md border border-border bg-muted/20 p-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5"
|
||||
disabled={previewAffectedAgentCount === 0}
|
||||
checked={treeControlWakeAgentsOnResume}
|
||||
onChange={(event) => setTreeControlWakeAgentsOnResume(event.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
<span className="block font-medium">Wake affected agents ({previewAffectedAgentCount})</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{previewAffectedAgentCount === 0
|
||||
? "No assigned agents are eligible to wake from this preview."
|
||||
: treeControlMode === "restore"
|
||||
? "Unchecked by default. Restore does not wake agents unless explicitly enabled."
|
||||
: "Unchecked by default. Resume does not wake agents unless explicitly enabled."}
|
||||
<div className="space-y-2">
|
||||
<label className="flex items-start gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="mt-0.5"
|
||||
disabled={previewAffectedAgentCount === 0}
|
||||
checked={treeControlWakeAgentsOnResume}
|
||||
onChange={(event) => setTreeControlWakeAgentsOnResume(event.target.checked)}
|
||||
/>
|
||||
<span>
|
||||
<span className="block font-medium">Wake affected agents ({previewAffectedAgentCount})</span>
|
||||
<span className="text-xs text-muted-foreground">
|
||||
{previewAffectedAgentCount === 0
|
||||
? "No assigned agents are eligible to wake from this preview."
|
||||
: "Wake assigned agents after this operation completes."}
|
||||
</span>
|
||||
</span>
|
||||
</span>
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
{(treeControlMode === "pause" || treeControlMode === "resume") ? (
|
||||
<div className="space-y-1.5">
|
||||
<label className="text-xs text-muted-foreground">Release policy</label>
|
||||
<select
|
||||
value={treeControlReleaseStrategy}
|
||||
onChange={(event) => setTreeControlReleaseStrategy(event.target.value as "manual" | "after_active_runs_finish")}
|
||||
className="h-9 w-full rounded-md border border-input bg-background px-2 text-sm"
|
||||
>
|
||||
{TREE_CONTROL_STRATEGY_OPTIONS.map((option) => (
|
||||
<option key={option.value} value={option.value}>
|
||||
{option.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{treeControlWakeAgentsOnResume && treePreviewAffectedAgentRows.length > 0 ? (
|
||||
<div className="max-h-32 space-y-1 overflow-y-auto overscroll-contain">
|
||||
{treePreviewAffectedAgentRows.map(({ agentId, agent }) => (
|
||||
<div key={agentId} className="flex items-center gap-2 rounded-sm px-1 py-1 text-sm hover:bg-accent/50">
|
||||
<span className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full border border-border bg-background">
|
||||
<AgentIcon icon={agent?.icon} className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{agent?.name ?? agentId.slice(0, 8)}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
@@ -3551,7 +3488,7 @@ export function IssueDetail() {
|
||||
</label>
|
||||
) : null}
|
||||
|
||||
<div className="rounded-md border border-border bg-muted/20 p-3">
|
||||
<div className="space-y-2">
|
||||
{treeControlPreviewLoading ? (
|
||||
<div className="space-y-2">
|
||||
<Skeleton className="h-4 w-40" />
|
||||
@@ -3573,17 +3510,7 @@ export function IssueDetail() {
|
||||
</Button>
|
||||
</div>
|
||||
) : treeControlPreview ? (
|
||||
<div className="mt-2 space-y-2">
|
||||
<div className="grid grid-cols-1 gap-1 text-xs text-muted-foreground sm:grid-cols-2">
|
||||
<span>{treePreviewPrimaryCountLabel}: {previewAffectedIssueCount}</span>
|
||||
<span>Descendants affected: {previewAffectedDescendantCount}</span>
|
||||
<span>Active runs cancelled: {treeControlPreview.totals.activeRuns}</span>
|
||||
<span>Queued runs dropped: {treeControlPreview.totals.queuedRuns}</span>
|
||||
<span>Agents affected: {treeControlPreview.totals.affectedAgents}</span>
|
||||
{previewTerminalSkippedCount > 0 ? (
|
||||
<span>Terminal issues skipped: {previewTerminalSkippedCount}</span>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
{treePreviewWarnings.length > 0 ? (
|
||||
<div className="space-y-1">
|
||||
{treePreviewWarnings.map((warning) => (
|
||||
@@ -3593,38 +3520,21 @@ export function IssueDetail() {
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
{treeControlPreview.countsByStatus ? (
|
||||
<details className="rounded border border-border/60 bg-background p-2 text-xs text-muted-foreground">
|
||||
<summary className="cursor-pointer font-medium text-foreground">Status breakdown</summary>
|
||||
<div className="mt-2 grid grid-cols-2 gap-1">
|
||||
{Object.entries(treeControlPreview.countsByStatus).map(([status, count]) => (
|
||||
<span key={status}>{status.replaceAll("_", " ")}: {count ?? 0}</span>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
{treeControlPreview.affectedAgents.length > 0 ? (
|
||||
<details className="rounded border border-border/60 bg-background p-2 text-xs text-muted-foreground">
|
||||
<summary className="cursor-pointer font-medium text-foreground">Affected agents ({treeControlPreview.affectedAgents.length})</summary>
|
||||
<div className="mt-2 space-y-1">
|
||||
{treeControlPreview.affectedAgents.map((agent) => (
|
||||
<div key={agent.agentId} className="flex items-center justify-between gap-2">
|
||||
<span>{agentMap.get(agent.agentId)?.name ?? agent.agentId.slice(0, 8)}</span>
|
||||
<span>{agent.issueCount} issues · {agent.activeRunCount} active runs</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</details>
|
||||
) : null}
|
||||
{treePreviewAffectedIssues.length > 0 ? (
|
||||
<div className="max-h-40 space-y-1 overflow-y-auto rounded border border-border/70 bg-background p-2">
|
||||
{treePreviewAffectedIssues.slice(0, 20).map((candidate) => (
|
||||
<div key={candidate.id} className="flex items-center justify-between gap-2 text-xs">
|
||||
<span className="truncate">
|
||||
{candidate.identifier ? `${candidate.identifier} ` : ""}
|
||||
{candidate.title}
|
||||
</span>
|
||||
<span className="text-muted-foreground">{candidate.status}</span>
|
||||
{treePreviewAffectedIssueRows.length > 0 ? (
|
||||
<div className="max-h-56 overflow-y-auto overscroll-contain">
|
||||
{treePreviewAffectedIssueRows.map(({ candidate, issue: previewIssue }) => (
|
||||
<div key={candidate.id} style={candidate.depth > 0 ? { paddingLeft: `${Math.min(candidate.depth, 6) * 14}px` } : undefined}>
|
||||
<Link
|
||||
to={createIssueDetailPath(candidate.identifier ?? candidate.id)}
|
||||
issuePrefetch={previewIssue}
|
||||
className="group flex items-start gap-2 border-b border-border py-2 pl-1 pr-2 text-sm no-underline text-inherit transition-colors last:border-b-0 hover:bg-accent/50 sm:items-center"
|
||||
>
|
||||
<StatusIcon status={candidate.status} />
|
||||
<span className="shrink-0 font-mono text-xs text-muted-foreground">
|
||||
{candidate.identifier ?? candidate.id.slice(0, 8)}
|
||||
</span>
|
||||
<span className="min-w-0 flex-1 truncate">{candidate.title}</span>
|
||||
</Link>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -3635,7 +3545,7 @@ export function IssueDetail() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<DialogFooter>
|
||||
<DialogFooter className="border-t border-border/60 bg-background px-6 py-4">
|
||||
<Button variant="outline" onClick={() => setTreeControlOpen(false)} disabled={executeTreeControl.isPending}>
|
||||
Close
|
||||
</Button>
|
||||
|
||||
Reference in new issue
Block a user