Recovery predicate and backoff
apps/web/lib/plan-comment-recovery.ts ↗Central predicate decides when Send blocks and when the notice appears, based only on identified drafts.
Predicate
export function planCommentRecovery(state = EMPTY_PLAN_COMMENT_MIGRATION) {
const isBlocking = state.pendingCount > 0;
return {
...state,
isBlocking,
isReady: !isBlocking,
needsAttention:
isBlocking && (state.status === "failed" || state.status === "waiting_for_plan"),
};
}
Backoff
export function planCommentRecoveryDelay(failures: number) {
if (failures < 3) return failures * 1000;
return Math.min(30_000 * 2 ** (failures - 3), 120_000);
}
Legacy migration coordinator
apps/web/hooks/domains/comments/plan-comment-migration.ts ↗One per-task owner scans browser storage, uploads identified drafts, and retries with shared timers and generation guards.
State and attach
export class PlanCommentMigration {
private pending = new Map<string, PendingRecord>();
private consumers = new Map<symbol, () => Promise<void>>();
private failures = 0;
private failure: Failure = null;
private generation = 0;
attach(discover: () => Promise<void>) {
const consumer = Symbol();
this.consumers.set(consumer, discover);
if (!this.unsubscribe) this.observe();
return () => {
this.consumers.delete(consumer);
if (this.consumers.size) return;
this.generation++;
this.cancelTimer();
this.unsubscribe?.();
};
}
Gate and retry
private kick(): Promise<void> | undefined {
if (this.failure === "conflict" || this.failure === "rejected") {
this.publish("failed");
return;
}
if (!this.pending.size && this.discovery.complete && this.storageAvailable) {
this.publish("complete");
return;
}
if (this.pending.size && this.plan() === null && !this.refreshPlan) {
this.publish("waiting_for_plan");
return;
}
if (!this.ready()) { this.publish("idle"); return; }
}
Selective cleanup
private async upload(pending: PendingRecord, planId: string, generation: number): Promise<Failure> {
const acknowledged = pending.acknowledged?.plan_id === planId ? pending.acknowledged : undefined;
if (!acknowledged || acknowledged.body !== comment.text) {
const snapshot = acknowledged
? await updateTaskPlanComment({ ...input, expectedVersion: acknowledged.version })
: await createTaskPlanComment({ ...input, ...legacyAnchor(comment) });
this.store.getState().setTaskPlanComments(this.taskId, snapshot);
if (!this.recordAcknowledgement(pending, planId, comment, snapshot)) return "conflict";
}
return acknowledgeLegacyRecord(record);
}
Ordinary comment loader
apps/web/hooks/domains/comments/plan-comment-loading.ts ↗Loads the authoritative snapshot independently, preserves it through failures, and never sets migration failure.
Loader
class PlanCommentLoader {
private consumers = 0;
private generation = 0;
private planEpoch = 0;
private failures = 0;
attach() {
this.consumers++;
if (!this.unsubscribe) this.observe();
if (this.failures) void this.load(true);
return () => {
this.consumers--;
if (this.consumers) return;
this.generation++;
this.pause();
};
}
async read(force: boolean, generation: number) {
try {
const plan = await this.resolvePlan(force, generation);
if (plan && this.ready()) {
const snapshot = await getTaskPlanComments(this.taskId);
this.store.getState().setTaskPlanComments(this.taskId, snapshot);
}
this.failures = 0;
} catch {
this.failedRead(generation, expectedEpoch);
}
}
Notice shows only actionable drafts
apps/web/components/task/plan-comment-migration-notice.tsx ↗Hides background discovery and transient retries; shows Retry only when identified drafts need user action.
Component
export function PlanCommentMigrationNotice({ status, needsAttention, retry }: PlanCommentMigrationNoticeProps) {
const { t } = useTranslation("task");
if (!needsAttention) return null;
const isFailed = status === "failed";
const message = isFailed ? t("planCommentMigrationFailed") : t("planCommentMigrationNeedsPlan");
return (
<div role={isFailed ? "alert" : "status"} data-testid="plan-comment-migration-notice">
<IconAlertTriangle className="h-4 w-4 shrink-0" />
<span className="min-w-0 flex-1">{message}</span>
<Button type="button" size="sm" variant="outline" onClick={retry}>{t("retry")}</Button>
</div>
);
}
Send and Run gates
apps/web/hooks/domains/comments/use-run-comment.ts ↗Run checks only the selected persisted comment and primary session; Send blocks only when pendingCount > 0.
Run guard
async function runTaskPlanComment(comment: PlanComment, taskId: string, storeApi, clientAdmissionId) {
if (!comment.version || comment.version < 1 || hasPendingPlanCommentMigration(storeApi, taskId, comment.id)) {
throw new PlanCommentRunError("plan-comment-not-persisted");
}
const availability = resolvePlanCommentRunAvailability(storeApi.getState(), taskId);
if (availability.reason || !availability.session) throw new PlanCommentRunError(availability.reason);
}
Panel no longer blocks Run on migration
function planCommentRunDisabledReason(reason) {
if (reason === "no-primary-session") return t("task:noPrimarySessionForPlanComment");
if (reason === "primary-session-unavailable") return t("task:primarySessionUnavailableForPlanComment");
return null;
}
Task-scoped migration state
apps/web/lib/state/slices/session/types.ts ↗Replaces status-only map with count and failure so the gate and notice share one source of truth.
Type
export type PlanCommentMigrationStatus = "idle" | "running" | "retrying" | "complete" | "waiting_for_plan" | "failed";
export type PlanCommentMigrationState = {
status: PlanCommentMigrationStatus;
pendingCount: number;
failure: "transient" | "conflict" | "rejected" | null;
};
Slice
export type TaskPlansState = {
byTaskId: Record<string, TaskPlan | null>;
commentsByTaskId: Record<string, TaskPlanCommentSnapshot | undefined>;
commentsMigrationByTaskId: Record<string, PlanCommentMigrationState | undefined>;
};