32 KiB
status, system, requirements
| status | system | requirements | ||||
|---|---|---|---|---|---|---|
| current | tasks |
|
Task Plan Comments System Design
Purpose and boundaries
The task system owns pending feedback on the current task plan. Session state selects a delivery destination but never owns, filters, or persists the comments. The backend is authoritative for comment content and consumption; the web application projects that task state into Plan editors and every task session composer.
This design replaces the browser-local, session-scoped ownership documented in the superseded Plan Comment Drafts design. Other comment sources remain session-scoped and keep their existing browser persistence and client-side formatting.
The recovery refinements are implemented by the Plan comment recovery package. The existing persistence, task ownership, and atomic admission boundaries remain the basis for implementation.
Requirement mapping
| Requirement | Design sections |
|---|---|
REQ-TASKS-PLAN-COMMENTS-001 |
Persistence model, Frontend projection, Plan lifecycle |
REQ-TASKS-PLAN-COMMENTS-002 |
Composer delivery, Atomic acceptance, Failure and recovery |
REQ-TASKS-PLAN-COMMENTS-003 |
Run routing, Atomic acceptance |
REQ-TASKS-PLAN-COMMENTS-004 |
Legacy migration, Failure and recovery, Responsive and accessibility behavior |
Ownership decision
A comment is pending task-plan state. It is not copied to sessions and it does
not carry a session_id. A session becomes relevant only when a user chooses
one of two dispatch paths:
- ordinary Send targets the selected session;
- plan-comment Run targets the current primary session.
Pending rows are not ambient model context. Opening a session, switching tabs, or becoming primary does not expose them to an agent. The backend expands the chosen rows into a prompt only during explicit delivery.
See Persist Pending Plan Comments with the Task Plan for the alternatives and rationale.
Components and responsibilities
PlanServiceand the task repository own comment CRUD, authorization, plan identity, collection revision allocation, and plan/task deletion.- Task Plan WebSocket handlers expose comment snapshots and mutations. The task event broadcaster publishes authoritative replacement snapshots after committed changes.
- The direct-message and message-queue admission boundaries validate comment references, format their persisted contents, persist the target prompt, and consume the comments as one transaction.
- The production message adapter implements
AtomicQueuedPromptCoordinator, forwarding queue capacity and committed-prompt notification to the orchestrator. A compile-time assertion keeps this capability wired through the adapter used by the actual WebSocket handler. - The frontend task-plan state holds one comment snapshot per task. It does not
place plan comments in
CommentsState.bySessionor underkandev.comments.<sessionId>. TaskPlanPanelrenders the shared annotations. Every task chat composer reads the same task snapshot for its context item.useRunCommentkeeps existing behavior for non-plan comment types. Its plan branch resolves and guards the task's primary destination.
Persistence model
task_plan_comments
id text primary key; caller-generated UUID
task_id text owning task
plan_id text current task plan
body text user feedback
selected_text text selected plan text used for display and fallback anchoring
anchor_from integer Tiptap document position at creation
anchor_to integer Tiptap document position at creation
version integer optimistic mutation version, starts at 1
created_at timestamp UTC
updated_at timestamp UTC
task_id and plan_id identify the same current plan. Database constraints
prevent a row from pairing a plan with another task. The task and plan foreign
keys use ON DELETE CASCADE. The list order is created_at, then id, so all
clients render a stable order.
task_plans.comments_revision is a monotonic integer, starting at zero. Every
committed create, edit, delete, orphan cleanup, migration, or delivery
consumption increments it in the same transaction. A snapshot has this shape:
{
"task_id": "task-id",
"plan_id": "plan-id",
"revision": 12,
"comments": []
}
The plan ID distinguishes a newly created plan from a deleted plan whose revision counter previously had the same value.
The service bounds bodies to 64 KiB and selected text to 256 KiB, measured in
UTF-8 bytes. Each create or update transaction also validates a maximum of 100
pending comments and 1 MiB of combined bodies and selected text. Exceeding a
limit rolls back the mutation and its revision. The shared plancomments
package owns these limits so SQLite and Postgres use the same policy.
Delivery references
Message admission receives only identifiers and versions, never trusted comment text:
{
"plan_comment_refs": [{ "id": "comment-id", "version": 3 }]
}
The backend loads the rows for the request's task_id, validates that they
belong to the current plan, and formats the stored body and selected text.
Message and queue metadata retain the accepted IDs and versions for provenance
and idempotent replay. The persisted message or queued prompt contains the
expanded Markdown, so later comment deletion cannot change what the agent
receives.
The transactional resolver checks the final rendered prompt against the 1 MiB message limit before direct or queued insertion and comment consumption. This also protects delivery of oversized legacy rows. Both transports return a validation error without changing the pending snapshot.
WebSocket contracts and synchronization
The task-plan handler family adds these authorized actions:
task.plan.comments.listtask.plan.comments.createtask.plan.comments.updatetask.plan.comments.delete
Every request carries task_id. Create also carries plan_id, a
caller-generated id, selected text, anchor positions, and body. Update and
delete carry plan_id, id, and expected_version. A stale plan or version
returns a stable conflict error and the current snapshot. Repeating a create
with the same ID and identical task, plan, anchor, and text returns success;
reusing the ID for different data returns a conflict.
Every successful mutation returns the complete snapshot. The task event
broadcaster publishes task.plan.comments.changed with that same snapshot.
The frontend replaces its local set only when the event has the current plan
ID and a revision at least as new as its cached revision. It refetches after a
reconnect or any detected plan-identity mismatch. Full snapshots keep event
recovery simple because no client must replay missing deltas.
All four actions authorize through task_id. The gateway's deeper task-action
backstop requires that field, matching the existing task-plan actions. No
session authorization grants access to comments from an otherwise inaccessible
task.
Frontend projection
The task-plan slice gains a comment snapshot keyed by task ID, plus loading,
mutation, and migration state. usePlanComments(taskId) owns initial load,
CRUD, event reconciliation, and retry. Transient selection and open-editor
state can remain local to TaskPlanPanel; it is reset only when the task or
plan identity changes, not when the selected session changes.
TaskPlanPanel no longer accepts an active session as the comment owner.
Selection-based Add remains available whenever a current plan exists. The
desktop Popover and mobile Drawer stay open with the entered body intact until
the backend acknowledges create or update. While a mutation is pending, the
relevant action is disabled; failure appears inline and can be retried.
Every mounted composer for the task derives its plan-comment context item from the task snapshot. The item shows the shared count and opens the Plan surface. It has no remove control: removing it from one session would either lie about the shared context or become a surprising task-wide bulk delete. Users edit or delete individual comments on the plan.
The Tiptap projection transaction marker introduced by the session-switch
repair remains useful. Backend snapshot reconciliation is presentation work
and must not trigger orphan deletion. Untagged destructive plan edits continue
to report truly removed anchors, and TaskPlanPanel turns that report into an
authorized backend delete.
Composer delivery
At submit time, a session composer snapshots the visible comment IDs and
versions and passes them through message.add or message.queue.add alongside
the unexpanded composer content. It does not prepend plan-comment Markdown in
buildSubmitMessage or duplicate the feedback inside
buildDocumentContext. Other comment sources keep their current formatting
and clearing behavior.
The submitted session_id remains the ordinary delivery target. A primary
session mismatch does not redirect an ordinary Send. Existing workflow routing
may still replace a session under its documented turn-start rules; plan
comments introduce no primary-based rerouting to that path.
The client retains one caller-generated admission ID for an unchanged submit payload across automatic reconciliation and explicit user retry. Changing the message, comment versions, attachments, or destination creates a new identity. Plan-comment Run retains its identity across a primary-session refresh because a rejected stale-primary attempt has not been admitted.
An empty composer body is valid when plan_comment_refs is non-empty. The
backend canonical formatter prepends the existing visible ### Plan Comments
shape to the base content for both ACP and passthrough sessions. It does not put
the feedback in a hidden system block, so the transcript, queue editor, and
passthrough terminal all show the same submitted prompt.
Run routing
For a plan comment, useRunComment ignores the selected session as a target.
It reads the current task primary and submits only the clicked comment with
require_primary_session: true and plan mode enabled. A promptable primary uses
message.add; a busy primary uses a distinct message.queue.add entry rather
than message.queue.append. A distinct entry gives the action a durable
identity and avoids merging its comment-consumption boundary into unrelated
queued text.
Both endpoints validate under their task lock that the supplied session still
belongs to the task and is still primary. A primary_session_changed response
includes the safe current primary ID and state so the frontend can refresh its
routing state; it does not consume the comment. The user can retry without any
chance that the stale session received the feedback.
When no primary exists, or the primary is terminal and cannot accept direct or queued input, the Run control is disabled and the Popover or Drawer explains that a primary session must be available. Add, edit, and delete remain enabled because comment ownership is independent of session availability.
Atomic acceptance
Direct-message and durable-queue repositories already insert inside database transactions. Direct delivery acquires a task-scoped admission lease before its exact preflight and keeps it across task-state and turn-start hooks through the final transaction. Plan-comment mutations and plan deletion join the same lease, so a successful preflight cannot become stale only after irreversible hooks run. SQLite uses a process-local task guard. PostgreSQL pairs that guard with an advisory lock shared across backend processes; its writer pool must allow at least two connections because the lease owns one while hooks and the final transaction use another.
Comment-bearing variants then extend the final transaction boundary in this order:
- Lock or guard the active, unarchived task row, then take the existing per-session admission lock. This preserves the repository's task-before-session lock order.
- If
require_primary_sessionis set, verify the target session is the current primary. Re-read the target state and reject a terminal session or a direct-delivery state that changed since routing was selected. - Load every referenced comment for the current plan and require the exact submitted version. Missing, duplicated, stale, or cross-task references reject the whole operation.
- Build the canonical prompt from those persisted rows and the submitted base content.
- Claim any staged file attachments, insert the user message and durable
delivery receipt, conditionally delete every referenced comment, and
increment
task_plans.comments_revision. A promptable direct send uses the same receipt as a deferred send so a process crash after commit cannot lose its dispatch intent. - Commit all of those changes together, then publish the ordinary
message/queue event and the complete
task.plan.comments.changedsnapshot.
A shared leaf transaction helper owns reference validation, conditional
deletion, and revision allocation so the task-message and message-queue
repositories do not implement different semantics. The direct-message path
retains client_message_id. Comment-bearing queue additions add a
caller-generated client_queue_id; the queue repository treats an exact replay
as a read and rejects reuse for different content or ownership. Immediate
auto-merge is skipped for these entries so the idempotency identity remains
observable through admission. Reserve/dispatch retains that queue row as a
replay receipt and writes a deterministic, idempotent transcript before
external delivery. The transcript metadata retains the caller queue ID, so a
lost response can still reconcile after the queue receipt is acknowledged.
Every pre-dispatch reservation has a random ownership token and a short lease.
Only that token may release, acknowledge, or cross the delivery boundary. A
live lease blocks another backend process; after expiry a new owner receives a
new token and every mutation from the stale owner fails closed. Immediately
before provider or passthrough I/O, the repository atomically verifies the
task is still active and marks the receipt delivery_attempted. Failures before
that marker release the receipt for retry. Once marked, no automatic retry is
allowed, including after an ambiguous transport failure or failed
acknowledgement, because the external system may already have accepted the
prompt. Startup reconciliation resumes expired pre-attempt receipts and clears
attempted receipts without redispatching them.
Send Now follows the same rule for every comment-bearing source. It persists one deterministic transcript for the combined ordered envelope before marking all source receipts attempted in one transaction; source-level transcript markers never suppress that combined transcript.
This boundary gives each comment one accepted delivery. If another request
consumes or edits a referenced row first, the loser rolls back its message or
queue insert and returns plan_comments_changed. The frontend refreshes the
snapshot and leaves its composer text intact rather than sending a different
prompt than the user reviewed.
Plan lifecycle
Open comment editor during background reads
For AC-TASKS-PLAN-COMMENTS-001.9 through .11, TaskPlanPanel shows its
full-panel loading placeholder only without a current plan. Revalidation keeps
the content and comment Popover/Drawer mounted. Tiptap becomes read-only and
hides formatting, slash, and drag controls until settlement, preventing plan
edits that an incoming revision could overwrite. Comment input stays editable.
No draft storage, backend writes, or loader semantics change. Same-plan refresh
must not reset comment text. Task/plan identity changes still clear selection.
Tests assert input identity and text across pending, successful, and failed reads, initial loading and owner changes, plus desktop/phone Add/Update and plan read-only transitions.
Persisted comment lifecycle
Plan content updates and revision reverts keep the same current plan row and therefore keep pending comments. On projection, the editor first tries saved positions and then the existing selected-text fallback. If an untagged user edit truly removes the marked range, the client requests deletion. A comment can remain pending while no Plan panel is mounted; opening the plan performs the same reconciliation.
task.plan.delete deletes the current task_plans row and cascades its pending
comments. Task deletion cascades both. Plan revision history contains plan
content only and does not retain pending comments. Recreating a plan creates a
new plan ID and an empty comment collection.
Legacy migration
Separate discovery, migration, and snapshot loading
usePlanCommentMigration owns only promotion of legacy browser drafts.
usePlanComments continues to own current-plan and authoritative comment reads.
The latter's commentsErrorByTaskId is not evidence that a legacy draft exists
and must not be converted into migration failure.
Discovery reads kandev.comments.<sessionId> only for sessions attributed to the
task by useTaskSessions or current task-session state. It can inspect known
session records while the authoritative session list is pending, and rescans
when membership becomes available or the browser returns to the foreground.
Failed or incomplete discovery is not a completed scan, but has no delivery
restriction in the absence of identified task-owned draft content. Do not
attribute an unrelated session's stored rows to the open task. No server request
is required by migration when its task scan contains zero legacy records.
Once identified, retain each pending record in the recovery state until exact
backend acknowledgement and selective storage cleanup. A later failed or empty
storage read must not erase that known pending set. Keep other comment sources,
malformed-but-readable payload entries, original UUIDs, and concurrent local
edits intact. listLegacyPlanComments and
removeAcknowledgedLegacyPlanComment remain the persistence boundaries; any
additional read outcome must distinguish unavailable storage from an
authoritative empty result without changing unrelated storage helpers.
For a task with identified drafts:
- Resolve its current plan while connected. If no plan exists, retain the drafts and wait for plan availability; do not repeatedly upload or delete.
- Upload each draft with its existing UUID and content. Each create response already supplies an authoritative snapshot; reconcile it by task, plan ID, and revision before acknowledging the exact stored record.
- Re-read after acknowledgement and retain failed or concurrently edited rows. Keep the acknowledged row/version when its legacy body changes, and reconcile that body with the existing version-checked update API, never another create for the same UUID. Changed anchors or conflicting server edits remain pending; do not overwrite them. A lost update response can reconcile only when the authoritative row matches the intended body and anchor. A conflict snapshot alone does not acknowledge a different local body.
- Complete migration only after every identified row has backend acknowledgement and confirmed selective browser-storage cleanup. Do not append an unconditional list request: a failed background snapshot refresh must not undo successful migration or keep an empty migration blocked.
Recovery state and lifetime
Replace the status-only commentsMigrationStatusByTaskId projection with one
task-keyed recovery record in the existing task-plan slice. The proposed
PlanCommentMigrationState contains status, identified pending-record count,
and actionable failure classification; the
commentsMigrationByTaskId map and setTaskPlanCommentMigrationState action
update that projection together. The old map and setter are removed, not kept
as a second source of truth. Phases distinguish discovery, active migration,
retry waiting, missing plan, actionable failure, and completion.
Promise, timer, pending-record, and subscriber bookkeeping stays in one store-scoped coordinator keyed by task with a plan-identity generation. React consumers share it, including simultaneous Plan, structured-composer, and passthrough mounts. At most one operation and one retry timer per task are active. StrictMode and repeated focus events must not create duplicate uploads or reset the retry budget on every render.
Use connected, visible consumers to admit network attempts. Replace the
uncoalesced focus/visibility listeners in usePlanComments with
useForegroundRefresh; defer work while disconnected and resume on the next
connected transition. Discovery and legacy recovery use the same foreground
trigger and connection check. plan-comment-loading.ts owns the ordinary
read promise, bounded retry timer, and plan generation behind usePlanComments;
plan-comment-migration.ts owns identified drafts and acknowledgements behind
usePlanCommentMigration. Both layers preserve successful snapshots through
read failures; no application-wide transport timeout or retry policy changes.
Transient failures get three connected attempts with delays of one and two seconds after the first two failures. After that burst, schedule single background attempts at 30, 60, then at most once per 120 seconds while visible and connected. These are implementation constants, not operator settings. Coalesced reconnect, foreground, and explicit Retry triggers can bring the next attempt forward, but share the in-flight operation. Explicit Retry, successful recovery, or a change of plan identity resets the failure budget. A replacement plan gets its own quiet retry burst; confirming loaded metadata for the same plan does not reset its accumulated failures. Disconnected or hidden periods do not spend attempts. Stop scheduled work when the last consumer unmounts; remount rescans and resumes without a browser-global completed marker.
Typed content, limit, authorization, and UUID-conflict rejections are actionable and do not enter an automatic mutation loop. Resolve a plan-not-found response through current-plan lookup. Untyped transport failures and server read failures use bounded retry. Manual Retry rechecks prerequisites as well as remaining records, and cannot clear identified draft evidence just to release Send.
Each operation captures task/plan identity and a generation. Recheck before each upload, state write, and acknowledgement. Task or plan invalidation cancels future work and rejects late completion from the previous generation. A transition between unknown and confirmed-absent plan state also invalidates lookups; confirming loaded metadata for the same existing plan only wakes work. A successful current-generation upload removes only its exact legacy row; it does not delete an entire storage key containing other records.
Delivery restriction
Use one shared selector for composer migration blocking: at least one
identified legacy record for that task remains unresolved. Neither an idle
phase nor a general read error satisfies that predicate. The structured
useSubmitHandler and passthrough submission guard both use it. A blocked
attempt preserves the typed message and returns localized pending-context
feedback; recovery never resubmits it.
Persisted snapshot comments remain visible during failed background reads.
Normal Send continues to freeze their IDs and versions with
toTaskPlanCommentRefs, so backend exact-version admission protects their
content. A successful refresh, including an empty snapshot or confirmed absent
plan, clears obsolete read errors but never acknowledges unresolved local rows.
Run selects one persisted comment. Remove the unrelated task-wide migration
status check from runTaskPlanComment and the selection toolbar; keep persisted
version, primary availability, exact reference, and server acceptance checks.
The recovery owner must also report no identified pending legacy record for
that selected ID, including an acknowledged upload awaiting browser cleanup.
Consuming its server row first would leave the retained local row without a
matching acknowledgement on reload; durable UUID admission prevents recreation
but does not finish browser cleanup. Unrelated records do not participate in
this selected-ID check and remain pending. Add-and-Run must still await persistence
of the selected new comment before invoking Run.
Failure and recovery
- Discovery and transient background recovery are quiet. A comment restoration notice is eligible only for identified unresolved drafts after the initial retry burst or for an actionable failure, including an absent current plan. General read failures never produce that notice. A task with no identified drafts has no migration restriction, independently of plan availability.
- Known pending feedback remains protected as described in Delivery restriction. A partial upload cannot release a Send that would omit a remaining row; an unrelated pending row cannot block Run of an already persisted comment.
- Failed create or edit keeps the entered body and selection in the open editor. Failed delete keeps the annotation visible. Desktop outside-click or Escape dismissal and mobile Drawer dismissal are ignored while any mutation is pending.
- A direct or queue admission error leaves both the comments and composer draft unchanged.
- A lost response is reconciled with the caller-generated message or queue ID. A create retry reuses its pending caller ID. An accepted replay returns the durable queue row or transcript; an absent delivery leaves the comments pending.
- A primary-session conflict triggers an authoritative task-session refresh, including an explicitly absent primary or a replacement not yet in the local store. A request-local store subscription detects changes to primary identity, session state, and queue incarnation while the request is pending. Once any routing change occurs, neither the older response nor its error fallback may replace that projection, even if routing changes back before the response. The subscription is disposed when the request settles.
- Quick Chat and config chat clear their automatic launch descriptor when the first eligible attempt starts, keeping a session-scoped recovery draft. A rejected attempt restores that draft to an empty composer for explicit retry; reopening the panel must not automatically resend it. Settlement remains bound to the original session and never replaces newer user-entered text.
- Reconnect reloads a complete snapshot. A stale WebSocket snapshot cannot replace a newer revision.
- A queue-capacity failure does not consume comments. Cancellation or editing of an already accepted queued prompt does not resurrect them because the queue content is now the durable delivery record.
Responsive and accessibility behavior
Desktop keeps the anchored selection Popover. Phones and coarse pointers keep the bottom Drawer, inset containment, safe-area clearance, one internal scroll owner, focus return, and 44 px actions established by the responsive Plan contract. Both surfaces use the same task-level query and mutations.
The pending state is announced on the action being performed. Mutation and routing errors are visible text, not color-only or tooltip-only feedback. The Run control exposes why it is unavailable when no eligible primary exists. Session switching must not dismiss or mutate a persisted comment merely as a side effect of changing responsive navigation.
PlanCommentMigrationNotice remains an inline context notice in the existing
Plan surface and both composers, but renders only actionable recovery state.
No progress banner is mounted for discovery or automatic retry. Retain draft
focus when it appears or disappears. The existing task mobile composition in
task-layout.tsx and the Plan Drawer are the shipped exemplars: chat remains
the primary phone destination and its scroll/safe-area ownership is unchanged.
Do not add a recovery modal or a second scroll region. Retry keeps a 28 px
desktop control and a minimum 44 px coarse-pointer target. Shared logic owns
eligibility; responsive wrappers own wrapping and containment. Pending-Send
copy describes preserved feedback and automatic recovery through translations,
without instructing users to perform a migration.
Verification
- Repository tests cover schema replay on SQLite and Postgres, task/plan cascades, stable ordering, optimistic versions, idempotent create, complete snapshots, and authorization.
- Direct-message and queue tests prove canonical server formatting, empty-body submission, exact-version consumption, rollback on stale references or queue capacity, idempotent transport replay, task/session mismatch rejection, and the primary guard.
- Frontend tests prove one task snapshot drives every Plan and composer, session changes do not filter it, async editors retain failed input, Run chooses the primary, and legacy migration removes only acknowledged plan records.
- Recovery tests cover empty and unknown plan state, initial discovery failure, automatic reconnect and foreground recovery, exhausted retries, mixed acknowledged/unacknowledged rows, unmount and plan-identity races, unrelated task records, and persisted-comment Run during another draft's recovery.
- Desktop Playwright coverage distinguishes selected-session Send from primary-session Run in a two-session task and proves task-wide removal after acceptance.
- Mobile Playwright coverage proves the same shared context and routing through the session picker and Plan Drawer, with no horizontal overflow or undersized actions.
- Desktop and phone recovery scenarios inject correlated comment/read failures while leaving unrelated chat frames live, send plain text without Retry on empty tasks, and prove actual legacy feedback recovers without a manual action. Existing selected-session Send and primary-session Run scenarios remain part of those focused suites.
Observability
The existing task event bus carries authoritative snapshots after mutations and delivery consumption. Publication failures log identifiers and collection state where available, never comment bodies or selected plan text. Stable transport error codes distinguish stale comments, stale primary selection, queue capacity, and replay conflicts. No new production metric is required initially because deterministic repository and E2E coverage owns the correctness boundary.
Related decisions
- Persist Pending Plan Comments with the Task Plan
- Keep Queue Auto-run Server Owned
- Separate Message Queue Provenance, Cancellation, and Capacity
- Keep Saved-Prompt Expansion Server-Owned