PR #3616
Sections
Review

fix(web): recover plan comments without blocking empty chat

main ← feature/investigate-plan-com-7d5 54 files +1847 −612 PR #3616 ↗

Empty chat no longer blocks Send when background plan-comment recovery fails; only identified legacy drafts block delivery and show a Retry notice.

Why this change

A failed background read marked plan-comment migration as failed even when no legacy drafts existed. The Send gate then blocked every message and never cleared, even after a later successful read.

What it does

Architecture, end to end

Ordinary reads and legacy migration now run on separate coordinators. The composer gate checks only identified pending drafts.

flowchart LR
  Store[(Zustand taskPlans)] --> Loader[PlanCommentLoader]
  Store --> Migration[PlanCommentMigration]
  Storage[(sessionStorage kandev.comments.*)] --> Migration
  Sessions[useTaskSessions] --> Migration
  PlanAPI[getTaskPlan] --> Migration
  PlanAPI --> Loader
  CommentAPI[getTaskPlanComments] --> Loader
  Migration --> Gate{pendingCount > 0 ?}
  Loader --> Snapshot[(commentsByTaskId)]
  Gate -- yes --> Block[Block Send + needsAttention]
  Gate -- no --> Allow[Allow Send]
  Snapshot --> Composer[Chat composer]
  Snapshot --> PlanPanel[Plan panel]
  Block --> Notice[PlanCommentMigrationNotice]
  Allow --> Composer

Key code changes

Drag to pan. Use the + and − buttons to zoom. Click a node to open the full code. The arrows show how the parts interact.

drag to pan · +/− to zoom · click a node for details

Recovery predicate and backoffapps/web/lib/plan-comment-recovery.ts ↗
planCommentRecovery(state): { isBlocking, isReady, needsAttention }
Click for details →

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);
}
class PlanCommentMigration
Click for details →

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);
}
class PlanCommentLoader
Click for details →

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);
    }
  }
PlanCommentMigrationNotice({ status, needsAttention, retry })
Click for details →

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>
  );
}
resolvePlanCommentRunAvailability(state, taskId)
Click for details →

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;
}
PlanCommentMigrationState { status, pendingCount, failure }
Click for details →

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>;
};
Read the changes as a list

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>;
};

Data and storage

Migration state is task-scoped and counts only identified legacy drafts for that task.

FieldTypeNotes
statusenumidle, running, retrying, complete, waiting_for_plan, failed
pendingCountnumberidentified legacy drafts still needing upload
failureenum | nulltransient, conflict, rejected, or null
commentsByTaskIdTaskPlanCommentSnapshotauthoritative backend snapshot, preserved through read failures
generationnumberinvalidates in-flight work when plan or task changes

Risk

5 / 10 Medium
1 low5 medium10 high

Why this score

  • Frontend only, but changes the Send admission gate for every composer.
  • Selective storage cleanup must not delete unrelated comments or malformed rows.
  • Retry timers and generation guards must not leak or duplicate uploads across mounts.

Trade-offs and review notes

Where to look first

  1. Verify planCommentRecovery blocks only when pendingCount > 0 and needsAttention only for failed or waiting_for_plan.
  2. Check PlanCommentMigration retains pending records across failed storage reads and plan changes.
  3. Confirm PlanCommentLoader never publishes migration failure and preserves the last snapshot.
  4. Confirm Run checks hasPendingPlanCommentMigration for the selected ID only, not the whole task.