Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Claude Sonnet 5 <noreply@anthropic.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
32 KiB
status, system, requirements, created, owners
| status | system | requirements | created | owners | ||||
|---|---|---|---|---|---|---|---|---|
| current | tasks |
|
2026-09-02 |
|
Task plan content size limit System Design
Purpose and boundaries
This design adds one admission rule to the plan write path: content above a fixed byte ceiling is refused before plan storage work happens. It changes no coalescing rule, no revision numbering, no truncation threshold, and no read path. The truncation guard and this ceiling never both fire on one write: an oversized write is refused before truncation is evaluated, and a write that passes the ceiling is evaluated for truncation exactly as it is today. They answer different questions — how much of the prior document survived, versus how large the submitted one is.
Where the ceiling lives
Two surfaces admit caller-supplied plan content, and both call PlanService:
create_task_plan_kandev ─┐
update_task_plan_kandev ─┤ mcp/handlers ─┐
│ ├─► PlanService.CreatePlan / UpdatePlan
task.plan.create ────────┤ task/handlers ─┘ │
task.plan.update ────────┘ ▼
per-task lock ─► upsertPlan ─► repository
The ceiling belongs in PlanService.CreatePlan and PlanService.UpdatePlan, not
in either handler. Both surfaces already share the service, and the equivalent
lesson is recorded in REQ-TASKS-TITLE-LENGTH-LIMIT-001: a per-surface check is
one a surface can skip, and a third write path added later inherits nothing. The
service is the seam that satisfies
AC-TASKS-PLAN-CONTENT-SIZE-LIMIT-001.3.
RevertPlan does not get the check. It admits no caller-supplied content; its
content comes from a task_plan_revisions row this system already stored. Gating
it would strand any task whose history predates the ceiling, with no way to
restore its own plan. This is safe because the ceiling gates every path by which
content enters the revision table, so a revert can only restore something an
earlier, ungated write stored. That is the reasoning behind
AC-TASKS-PLAN-CONTENT-SIZE-LIMIT-001.9, and the residual it accepts is that
HEAD can hold above-ceiling content after a revert.
Placement inside the write methods
func (s *PlanService) CreatePlan(ctx context.Context, req CreatePlanRequest) (PlanWriteResult, error) {
if req.TaskID == "" {
return PlanWriteResult{}, ErrTaskIDRequired
}
if err := s.authorize(ctx, req.TaskID); err != nil {
return PlanWriteResult{}, err
}
if len(req.Content) > MaxPlanContentBytes {
task, err := s.repo.GetTask(ctx, req.TaskID)
if err != nil {
return PlanWriteResult{}, err
}
if task == nil {
return PlanWriteResult{}, repoerrors.ErrTaskNotFound
}
}
if err := checkPlanContentSize(req.Content); err != nil { // new
return PlanWriteResult{}, err
}
release := s.locks.acquire(req.TaskID)
...
}
UpdatePlan uses the same validation helper. The helper runs authorization first.
For an oversized request it then confirms task existence before returning the size
error. This preserves the accepted task-document contract: a write for a missing
task returns not_found and does not expose storage constraints. The extra lookup
does not read the plan row and is only needed for an oversized request; normal
writes keep their existing access path.
The ordering in AC-TASKS-PLAN-CONTENT-SIZE-LIMIT-001.5 is deliberate. Task ID
and access checks retain their existing precedence, including the rule that a
missing task returns not_found without storage details. The size check then runs
before any plan-row read and before the per-task lock: an oversized write that
queued for the lock would let a flood of them serialize ahead of legitimate writes.
The task existence preflight is not plan storage work and does not serialize with
other plan writes.
The content decision itself reads no stored plan state, so two concurrent writes
for the same task cannot influence each other's size outcome and the size check
needs no lock of its own (AC-TASKS-PLAN-CONTENT-SIZE-LIMIT-001.6). When the
request is rejected, upsertPlan never runs, so no HEAD row is touched, no
revision is written or coalesced, and no event is published
(AC-TASKS-PLAN-CONTENT-SIZE-LIMIT-001.4). The backend holds no admission state
between calls, so a resubmission is judged fresh (AC-TASKS-PLAN-CONTENT-SIZE-LIMIT-001.7).
The constant and the unit
// MaxPlanContentBytes bounds the plan content a single write may store.
const MaxPlanContentBytes = 256 << 10 // 256 KiB
An exported constant in internal/task/service, matching maxUserStateBodyBytes
in internal/plugins/user_state_handlers.go in both value and reasoning. It is
not read from configuration, environment, or a runtime feature toggle
(AC-TASKS-PLAN-CONTENT-SIZE-LIMIT-001.11).
The unit is bytes, via len(content), not runes. This differs on purpose from
planTruncationDetected, which counts runes because it reasons about how much of
a document survived and a script change can preserve bytes while destroying
characters. Here the quantity being bounded is memory and scan cost, which is
proportional to bytes, and the storage limit it backstops is a byte limit. A
comment at the constant should record this, because the neighbouring guard's
opposite choice looks like an inconsistency otherwise.
The comparison is len(content) > MaxPlanContentBytes, which makes exactly the
ceiling admissible and one byte more a rejection
(AC-TASKS-PLAN-CONTENT-SIZE-LIMIT-001.2). Content at or below the ceiling
reaches upsertPlan on an unchanged path, including empty content where the write
path already accepts it (AC-TASKS-PLAN-CONTENT-SIZE-LIMIT-001.8).
That qualifier is load-bearing, because the two write paths differ on empty content
today. The MCP handlers reject content == "" with a VALIDATION_ERROR reading
content is required before they call PlanService, so on the agent path empty
content never reaches the ceiling check at all. The browser WebSocket handlers have
no such pre-check, so empty content reaches the service and is admitted. This
capability changes neither. Do not remove the MCP content is required check in
order to make empty content reach upsertPlan on every path: that is a separate
contract change and is out of scope here. The ceiling only ever rejects a write;
it never widens what a path admits.
Error type and message
The message must carry the submitted size, so a fixed sentinel string is not enough. The service returns a typed error wrapping a sentinel:
var ErrPlanContentTooLarge = errors.New("plan content exceeds the size limit")
type PlanContentTooLargeError struct {
Submitted int
Limit int
}
func (e *PlanContentTooLargeError) Error() string { ... }
func (e *PlanContentTooLargeError) Is(target error) bool { return target == ErrPlanContentTooLarge }
errors.Is support lets planws and tests match the class while the rendered
text stays dynamic.
internal/task/planws/errors.go maps it. Its existing table pairs a sentinel with
a fixed message, and this is the first plan error whose message is computed from
the request, so errorResponse gains one branch checked before the table walk
rather than a new table entry with a placeholder message. It resolves to
ws.ErrorCodeValidation, which satisfies
AC-TASKS-PLAN-CONTENT-SIZE-LIMIT-002.4, and is reachable from CreateError and
UpdateError only — no other plan operation can return it.
That same branch is the one place the structured details map described under
## Browser surface is populated (reason, limit, submitted). The rest of the
table keeps passing nil details, unchanged. The map is additive and the browser is
its only consumer: the agent path renders the message text and ignores details
entirely, so REQ-TASKS-PLAN-CONTENT-SIZE-LIMIT-002 still depends on the message
carrying every fact it requires.
DispatcherBackendClient.RequestPayload already forwards a WebSocket error to the
agent verbatim as backend error [VALIDATION_ERROR]: <message>, and both MCP
handlers already turn that into mcp.NewToolResultError(err.Error()). So the
single WebSocket message is the agent-facing text, and it has to carry
everything REQ-TASKS-PLAN-CONTENT-SIZE-LIMIT-002 requires: the limit and
submitted size (002.1), that nothing was stored and the existing plan is
unchanged (002.2), and an instruction to shorten the held document that neither
suggests retrying unchanged nor rebuilding from memory (002.3). The last clause follows planTruncationWarning's recorded reasoning: an agent told only that
a write failed reaches for the tool it has, and rebuilding the plan from memory is precisely the
loss both guards exist to prevent. No further MCP-handler changes are needed.
The tool descriptions in registerPlanTools (internal/mcp/server/server.go)
gain the ceiling on the content parameter of both write tools
(AC-TASKS-PLAN-CONTENT-SIZE-LIMIT-002.5), stated as a byte count so it matches
the enforced unit.
Browser surface
useTaskPlan already catches the rejection and sets its error state, and
already leaves the draft alone on failure: savePlan returns null without
calling setTaskPlan, so the editor keeps the user's text and the panel keeps
reporting unsaved changes. AC-TASKS-PLAN-CONTENT-SIZE-LIMIT-003.2 and 003.3
are therefore existing behavior to pin with tests, not behavior to build.
The gap is that useTaskPlanPanelState in components/task/task-plan-panel.tsx
does not destructure error, so nothing renders it. Combined with a 1500 ms
autosave, a user would type into an editor that had silently stopped persisting
and lose the work on reload — a worse outcome than the growth this capability
prevents, which is why REQ-TASKS-PLAN-CONTENT-SIZE-LIMIT-003 exists rather than
leaving the browser path ungated. The panel must render the failure (003.1),
through t() in the task namespace with the limit and the submitted size as
interpolated values, added in all five shipped locales (003.6).
Four details a builder would otherwise have to invent, every one of which fails silently if guessed wrong, so the reasoning is kept next to the rule.
1. How the panel knows a save failed at all. The obvious wiring is wrong.
error in useTaskPlan is a SINGLE state slot shared by five operations:
fetchPlan, savePlan, removePlan, loadRevisions and revertTo. Two of them run
automatically when the panel mounts, fetchPlan and loadRevisions (the latter through
useTaskPlanRevisions, which is handed the same setError). isSaving does not
discriminate either, because save, delete and revert all set it and neither loader does.
Nothing renders error today, so this capability is its first consumer and would inherit
that ambiguity whole.
Wiring the shared slot into the panel would report a plan-write rejection for a failure
that never touched the write path, and the reachable case is not exotic: a task whose stored
plan predates the ceiling (AC-TASKS-PLAN-CONTENT-SIZE-LIMIT-001.10 keeps it readable and
editable) opens the panel, loadRevisions fails on a transient blip, and the user is told
their plan was rejected for size when no write was attempted. That is the false save-status
signal REQ-TASKS-PLAN-CONTENT-SIZE-LIMIT-003 exists to prevent, and it contradicts
003.3.
So useTaskPlan exposes a save-scoped signal distinct from the shared slot: a
saveError, cleared at the start of savePlan and set only in savePlan's catch branch.
The panel renders that and only that (003.1, 003.7). It starts null, so a panel that
has attempted no save renders no failure. The pre-existing error slot keeps its current
behavior and its current consumers; this capability does not change what the other four
operations write, it simply does not read them.
2. How the panel knows the save failure was an oversize rejection. Not by parsing the backend's English prose — that breaks the moment the copy is reworded and cannot be localized. And not by re-measuring the editor either: the draft is not the thing that was rejected, and by the time the rejection arrives it may not even be the thing that was submitted. The rejection carries its own facts, and the channel for them already exists.
pkg/websocket.NewError(id, action, code, message, details map[string]interface{})
already takes a structured details map, and the browser already preserves it:
toWebSocketRequestError in lib/ws/request-error.ts copies code and details onto a
WebSocketRequestError, which client.request rejects with and which lands in
savePlan's catch branch. Nothing new has to be plumbed through the WebSocket client; the
path is wired end to end today and merely unused here.
So the size branch in planws emits details alongside its message:
ws.NewError(msg.ID, msg.Action, ws.ErrorCodeValidation, sizeErr.Error(),
map[string]interface{}{
"reason": "plan_content_too_large",
"limit": sizeErr.Limit,
"submitted": sizeErr.Submitted,
})
reason is the discriminator, because the error code cannot be one: task_id is required
and content is required are already VALIDATION_ERROR on these same actions. It is a
stable wire token compared with ===, so it is never localized and never reworded; the
frontend comparison needs an // i18n-exempt line comment saying so.
savePlan classifies once, in its catch branch, and stores the result rather than the
raw message:
type PlanSaveError =
| { kind: "content-too-large"; limit: number; submitted: number }
| { kind: "generic"; message: string };
The kind is content-too-large when the caught value is a WebSocketRequestError whose
details.reason is exactly plan_content_too_large and whose details.limit and
details.submitted are both finite numbers. In every other case — a different reason, a
missing or malformed details, or a transport failure that never reached the backend at
all — the kind is generic and carries err.message. No version-skew fallback is needed
on top of that: internal/webapp/embedded embeds the built SPA into the backend binary, so
a shipped build cannot pair a frontend that expects reason with a backend that predates
it. ErrorPayload.Details is already json:"details,omitempty", so the field simply does
not appear on the errors that do not set it.
What the panel renders for each kind, and why 003.6 still holds. Both kinds render
localized copy from the task namespace; neither renders the backend's string. The
content-too-large kind renders a size-specific message interpolating limit and
submitted (003.1). The generic kind renders one localized "could not save your plan"
message. err.message is retained on the variant for DIAGNOSTICS ONLY — it is what
savePlan already hands to console.error — and is never the rendered string. This is what
keeps 003.5 and 003.6 from colliding. 003.5 requires that the failure the panel
reports CORRESPOND to the attempt that failed rather than to a size rejection that did not
happen; a localized generic message satisfies that, because what 003.5 is protecting
against is telling the user to shorten a document when nothing was rejected for size.
003.6 requires that whatever is displayed be localized copy in all five shipped locales,
which a raw backend or transport string can never be. So: do NOT render err.message, and
do NOT append it to the localized frame. It is English, it is written for an agent audience
(see ## Error type and message), and in the transport-failure case it is not a backend
message at all.
Deciding once, at the moment the attempt resolves, is what makes 003.1 and 003.5 hold
together. An edit that lands while the request is in flight cannot change either number or
the kind, and an edit that lands between one failure and the next attempt cannot flip the
rendered copy. Re-measuring the editor instead breaks all three cases; they are enumerated once under
## Rejected alternatives.
One consequence worth naming: the frontend needs no mirrored copy of the ceiling, because it
displays the limit the backend sent. A lib/plan-content.ts module and a
MAX_PLAN_CONTENT_BYTES constant are deliberately not part of this design — a mirrored
constant earns its drift risk only when the frontend must decide something before it can ask
the backend, and here it never decides anything.
3. How the panel stops resubmitting a draft that was just rejected. This is the trap that matters most, because the plain reading of the autosave effect is wrong and the failure mode is the exact resource problem this capability exists to prevent.
The effect's dependency array is [draftContent, plan, isSaving, attemptSave, saveError, taskId], and isSaving is
not inert. savePlan sets it true before its await and false in its finally, so every
attempt flips it twice and re-runs the effect twice. The false → true run returns early on the
isSaving guard; the true → false run finds hasChanges still true — a failed save never
calls setTaskPlan, so plan.content still differs from draftContent — and arms a fresh
1500 ms timer carrying byte-identical content. savePlan's identity is stable across a failed
save, so nothing breaks the cycle: left alone, a rejected 256 KiB draft is resubmitted every
1.5 seconds for as long as the panel stays open. 003.4 is not free, and no existing test
would catch it.
The suppression is a ref in usePlanDraft holding the content of the most recent autosave
attempt. It starts null, which no draft is equal to, so a panel that has attempted no
save never suppresses its first autosave. The ordering is part of the rule:
- It is assigned immediately before
savePlanis called, never in the promise's failure branch.setTaskPlanSaving(taskId, false)runs insavePlan'sfinally, which fires before the awaiting caller's continuation, so a ref written from that continuation could lose the race with the very re-render it is meant to guard. Written first, the guard is correct regardless of scheduling. - The autosave effect refuses to arm a timer while
draftContentis identical to that ref. With the existinghasChangesandisSavingguards, this is what makes003.4true. - A successful save clears it to
null. - A task change clears it too.
usePlanDraftreceivestaskId, resets the attempt ref, and replaces the local draft with that task's stored content (or an empty string) before the autosave effect can run. This is required even when both tasks have no plan, because theirplan?.contentvalues are bothundefined. - A generic save failure does not suppress unchanged content. The hook receives
the save-scoped error and suppresses only a matching
content-too-largerejection; transport or server failures can retry after the saving state returns to idle. - An explicit user-requested save proceeds unconditionally. That is
003.4's "or the user explicitly requests a save", and it is the user's escape hatch from a suppressed autosave. The explicit path and the autosave timer both use the hook'sattemptSaveentry point, while only the autosave effect applies the suppression guard. - It is a ref rather than state on purpose: assigning it must not itself re-run the effect.
Two consequences to state rather than let Build discover. The guard is on content identity,
not edit events, so a user who edits away from rejected content and back byte-for-byte stays
suppressed until they save explicitly — correct, because resubmitting those exact bytes would
fail identically. And because the ref is consulted before arming, it also closes detail 4's
overlapping-save re-arm: when the earlier of two in-flight saves clears isSaving and re-runs
the effect, the ref still holds the later save's content, so no third attempt is armed.
4. Which task and which attempt a rejection belongs to. saveError has to answer "did
this task's latest write fail", and neither half comes for free.
It cannot simply copy the existing error slot's shape. error is a plain useState local
to the hook, unlike plan, isLoading and isSaving, which are read per task from the store.
Nothing resets a useState when taskId changes, and there is no remount to do it: PlanContent
(components/task/dockview-panel-content.tsx) renders <TaskPlanPanel taskId={taskId} visible />
with no key={taskId}, so one instance survives every active-task switch. Reject a save on task
A, switch to B, and A's banner is displayed against B's plan.
Three rules, and together they are 003.8:
- Reset on task change.
useTaskPlan(hooks/domains/session/use-task-plan.ts) clearssaveErrorin an effect keyed ontaskId.usePlanDraft(hooks/domains/session/use-plan-draft.ts) resets its last-attempt ref and synchronizes the local editor draft in an effect keyed ontaskId. The sync marks the render as external so it cannot autosave the previous task's draft, including when both tasks have no plan.taskIdis nullable, and a change to no task is a change like any other. The sequence counter is deliberately not reset, because it only needs to stay monotonic. - Discard stale results.
savePlancompares thetaskIdit was invoked for against acurrentTaskIdRefthat the hook assigns on every render, and writessaveErroronly while the two are equal; otherwise it drops the result. The ref is load-bearing, not bookkeeping.savePlanis auseCallbackwithtaskIdin its dependency array, so an in-flight attempt's continuation runs inside the closure that captured the OLDtaskId; comparing that captured value against the same closure'staskIdcompares a variable with itself, always passes, and discards nothing. Reading the CURRENT task through a ref is the only way a stale closure can see what the live render thinks the task is. Assign it in the hook body on every render, NOT in an effect: an effect commits after the render, so a save that resolves in between would compare against the previous task's id and the guard would be wrong in exactly the window it exists to cover. Its initial value is the first render'staskId, so it is neverundefinedwhen a continuation reads it. This covers what the reset effect cannot — a write for task A that fails after the switch to task B. - A task ID can appear again after an A → B → A switch. The hook therefore
increments a task-instance generation synchronously when
taskIdchanges; each attempt captures that generation, and a continuation must match both the current task ID and the current generation before it can setsaveError. - Latest started attempt wins.
savePlantakes a monotonically increasing sequence number from a ref at the top of each attempt — the ref starts at 0 and is incremented before the number is read, so the first attempt is 1 and 0 is never a live attempt — and writessaveErroronly while its own number is still the highest issued. "Last to complete" is not a usable rule:savingByTaskIdis one boolean per task insession-slice.ts, not a refcount, so with two writes in flight the first to finish clears it while the second is still running, and completion order carries no information about which attempt is more recent. Ordering by start is decided by a counter this code owns rather than inferred from a flag it does not.
003.5 is deliberately worded around when the rejection clears rather than its outcome:
savePlan clears its error state at the top, before the request goes out, so a displayed
rejection disappears when the next attempt begins, not when that attempt succeeds. Everything
else the AC requires — no rejection returning for an at-or-below-ceiling submission, an
unrelated failure surfacing its own reason, and editing between attempts not changing which
failure is shown — follows from detail 2's decide-once classification. A test written against
"clears on success" would pass without exercising any of it.
Rejected alternatives
- Clamp instead of reject. Silently storing the first 256 KiB is the exact
whole-document destruction
planTruncationDetectedexists to warn about, and it would destroy the tail with no revision holding it. Rejection keeps the caller's copy authoritative. - Enforce in the two MCP handlers. Leaves the browser path open, and a future
write path inherits nothing. Rejected for the reason recorded in
REQ-TASKS-TITLE-LENGTH-LIMIT-001. - A per-message limit on the WebSocket gateway. The gateway's 32 MiB
SetReadLimitis transport-wide; lowering it to bound plans would cap every other action, and it does not cover a non-WebSocket caller. - A configurable ceiling. Makes the published agent guidance install-dependent and gives an operator a way to switch the bound off.
- Gate reverts too. Strands tasks whose history predates the ceiling.
- Classify the browser rejection by measuring the draft against a mirrored
ceiling. The draft is not what was rejected, and by the time the rejection lands
it may not be what was submitted either. Measuring it renders a size rejection that
never happened when an unrelated failure coincides with an oversized paste, hides a
real one when the user trims while the request is in flight, and silently swaps the
copy under an unchanged failure when the user trims afterwards. Replaced by the
decide-once classification from the error's
details. This also removes the reason for a mirroredMAX_PLAN_CONTENT_BYTESconstant on the frontend. - Rely on the autosave effect not re-firing after a failed save. It does re-fire:
isSavingis in its dependency array and every attempt flips it twice. Believing otherwise ships an indefinite 1.5-second resubmit loop for the exact oversized document this capability refuses. Replaced by the last-attempt ref in detail 3. key={taskId}on<TaskPlanPanel>to scope the rejection to its task. A remount would reset both pieces of state and drop a stale attempt's continuation, so it does solve the TASK half of003.8. Not chosen because it does not touch the ATTEMPT half: two writes in flight for the SAME task share one key and one mount, so the sequence ref is required either way, and once it exists, resetting the named state and the draft in a task-aware sync beats remounting the panel and re-running its load effects on every task switch.PlanEditoris already keyedkey={`${taskId}-${state.editorKey}`}; the draft hook now resets its local value even when both plans are absent.
Testing
Backend, in internal/task/service:
- Boundary: exactly 262,144 bytes accepted, 262,145 rejected, on both
CreatePlanandUpdatePlan. - Rejection persists nothing: HEAD unchanged, revision count unchanged, no event published on the bus.
- Ordering: an oversized request with an empty
TaskIDreturnsErrTaskIDRequired; an oversized request for a missing task returnsnot_foundwithout size details; an inaccessible task is rejected by the authorizer before the size response; and an oversized write for a valid task does not wait behind its plan lock. - No lock contention: an oversized write returns while another write for the same task holds the lock.
RevertPlanrestores an above-ceiling stored revision successfully.- A multi-byte-character document just under the ceiling in bytes is accepted even though its rune count is far lower, pinning the byte unit.
internal/task/planws: the typed error maps to VALIDATION_ERROR with a message
containing both numbers, from CreateError and UpdateError.
internal/mcp/handlers: the oversized write returns an error response, not a
success payload with a warning.
Frontend, all with fake timers where a debounce is involved:
use-task-plan: a rejection setssaveErrorto kindcontent-too-largecarrying thelimitandsubmittedvalues from the error'sdetails, and the draft is retained.use-task-plan: a failure whosedetailsis absent, or whosereasonis anything other thanplan_content_too_large, sets kindgeneric— this is the branch that keeps a transport failure from being reported as a size rejection.use-task-plan: a save that is rejected for size while the draft is edited below the ceiling before the rejection resolves still reportscontent-too-largewith the originally submitted size; and a save that fails for an unrelated reason while the draft is edited above the ceiling still reportsgeneric. These two are the whole point of deciding once, and a test that measures the draft instead would pass in neither.task-plan-panel: the rejection renders, with the limit and submitted size from the attempt.task-plan-panel(003.5+003.6): agenericsave failure renders the localized generic copy and NOT the backend's message string. Assert both halves — that the translated generic text is present, and that the text oferr.messagedoes NOT appear anywhere in the rendered output. A test that only asserts "some error is shown" passes with the raw backend string rendered verbatim, which is the exact thing003.6forbids.task-plan-panel(003.4): after a rejected autosave, advance fake timers through the fullisSavingtrue → false round trip and well pastAUTO_SAVE_DELAY, and assert the save function was called exactly once. Advancing a single debounce interval is not sufficient — the re-arm happens on theisSavingfalse transition, which a one-interval test never reaches, so that version of the test passes against the infinite-retry bug. Then assert that editing the draft re-arms it, and that an explicit save resubmits even when the content is unchanged.task-plan-panel(003.7): with a draft above the ceiling, fail each of the four operations the AC enumerates —fetchPlan,loadRevisions,revertToanddeletePlan— one at a time, and assert the plan-write rejection does NOT render in any of them. This is the regression the sharederrorslot would otherwise ship, so it is the case that must not be dropped for being awkward to set up.task-plan-panel(003.8): reject a save on task A, changetaskIdto task B, and assert the rejection is no longer displayed. With no plan on either task, assert the local draft resets to empty and no autosave sends task A's rejected draft to task B. Separately, resolve a task A rejection after the switch and assert nothing is displayed for task B.use-task-plan(003.8): with two saves in flight, resolve the earlier-started one last and assert the later-started one's outcome is what remains displayed.use-task-plan(003.8, stale-task discarding): this test must be written so it FAILS against a guard that compares the capturedtaskIdwith the closure's owntaskId. Start a save for task A, change the hook'staskIdto B, THEN reject A's request, and assert nothing is displayed. The tautological guard passes A's rejection through and renders it against B, so a test that never changestaskIdbetween the call and the rejection cannot tell the two implementations apart.
There is no lib/plan-content test because there is no such module: the frontend takes the
limit from the rejection rather than mirroring it. If a later change reintroduces a mirrored
constant it needs a UTF-8-versus-UTF-16 measurement test, because draft.length under-reports
every non-ASCII document and Kandev ships zh-cn, zh-hk, zh-tw and pt-pt.
The PR includes focused desktop and mobile E2E checks. The mobile case is important because the plan panel has a separate touch entry point and must keep the same rejection, draft-retention, and recovery behavior. E2E checks cover the browser/WebSocket boundary that unit tests cannot exercise; service and hook tests remain the precise checks for admission ordering and state ownership.