Co-authored-by: nova28 <17953305+nova28@users.noreply.github.com> Co-authored-by: Carlos Florencio <carlosmflorencio@gmail.com>
20 KiB
status, system, requirements
| status | system | requirements | ||
|---|---|---|---|---|
| draft | tasks |
|
Orphaned Workspace Task Indicator System Design
Purpose and boundaries
The tasks system owns the read side of the orphan marker: deriving a task-level
boolean from tasks.metadata.workspace.orphaned and delivering it on every task
payload.
What is frozen, and what is not. The requirements' ## Out of scope freezes the
producer's predicate and its call sites, both shipped in 84c83c3d5 (#3235).
REQ-005 changes only the metadata-write primitive those call sites use: narrowing an
unguarded whole-sub-map write to a guarded one changes how a decision is recorded,
never which tasks get marked. No predicate, call site, or key name changes.
Prior art (receipts in ## Prior art). interrupted-task-indicator.md is the
direct precedent, and this design reuses its shape: a derived DTO boolean from a
metadata key, no schema migration, no new route or event type, an explicit
icon-precedence rule, and a named recovery owner for every failed write. Two
departures: rendering is board-only (matching auto_start_failed), and there is a
historical population to repair (REQ-003).
Adjacent contracts this design uses but does not own:
internal/task/service/handoff_workspace_orphan.go (producer of the four marker keys,
and owner of the clear-on-unarchive path this design constrains for concurrency only),
and the icon precedence chain in apps/web/lib/ui/state-icons.tsx, shared with the
interrupted and auto_start_failed markers. v1.TaskContext is untouched: the
detail-panel banner was cut at the round-7 review (see
## Deferred: explanation surface).
Prior art
Split from the requirements, at its byte cap. Leg 1, our compiled wiki: DID NOT
RUN — @henry resolved the vault from ~/.obsidian-wiki/config.henry but ls
returned Operation not permitted (macOS TCC, which also blocks the grep fallback),
and QMD could not substitute (QMD_TRANSPORT unset, no mcp__qmd__*, no qmd on
PATH). A skipped step, not an empty one. Leg 2, saas-kb: DID NOT RUN — tool
absent; intended queries were unstartable-task affordance and blocked-card badge, no
claim made. Leg 3, this repository: RAN — searched docs/specs and
docs/decisions for auto_start_failed|interrupted_at|orphan, read producer commit
84c83c3d5. interrupted-task-indicator
is the direct precedent whose shape this reuses; PR #3235 shipped no spec.
Requirement mapping
Each REQ-TASKS-ORPHANED-WORKSPACE-NNN: 001 → Data and
contracts + Delivery paths; 002 →
Frontend components. There is no REQ-004: it was cut at the
round-7 spec review and what a follow-up cannot cheaply re-derive is preserved under
## Deferred: explanation surface.
003 and 005 are owned by sibling designs.
orphaned-workspace-marker-startup-repair
owns the boot-time stamping and clearing passes (003).
orphaned-workspace-guarded-metadata-write
owns SetTaskWorkspaceMetadataIfUnchanged, its OrphanWriteGuard clause contract, and
the four $.workspace call sites that must adopt it (005). The repair is that
primitive's heaviest consumer. Read all three before changing any.
Measurement receipts
Queries behind the requirements' ## Measured population. Run 2026-09-05 against
file:/Users/henry/.kandev/data/kandev.db?mode=ro, reproduced at spec review. The
table is task_workspace_groups, not workspace_groups.
-- (1) live children of an archived parent, by mode. inherit_parent 1, ws 55.
-- Third column checks the own-environment carve-out: every candidate returned 0.
SELECT COALESCE(json_extract(c.metadata,'$.workspace.mode'),'<null>'), COUNT(*),
(SELECT COUNT(*) FROM task_environments e WHERE e.task_id = c.id)
FROM tasks c JOIN tasks p ON c.parent_id = p.id
WHERE c.archived_at IS NULL AND p.archived_at IS NOT NULL GROUP BY 1;
-- (2) tasks carrying any orphan marker. Returned: 0.
SELECT COUNT(*) FROM tasks
WHERE json_extract(metadata,'$.workspace.orphaned') IS NOT NULL;
The one orphaned task (b7c01447, metadata.workspace exactly
{"mode":"inherit_parent"}) is unmarked because it was born stranded, created after
its parent's archive. The creation-time guard that would now reject it post-dates the
row, so the population is historical, not growing.
Components and responsibilities
Backend
| Component | Responsibility |
|---|---|
internal/task/models/models.go ToAPI |
Derive WorkspaceOrphaned for the v1 DTO |
internal/task/dto/dto.go FromTaskWithSessionInfo |
Derive it for TaskDTO |
pkg/api/v1/task.go |
Carry workspace_orphaned on v1.Task |
internal/backendapp/boot_state_routes.go mapKanbanTaskState |
Add workspaceOrphaned to the camelCase whitelist |
internal/task/service/service_events.go publishTaskEventNow |
Emit explicit true/false |
Six more backend files change under the sibling designs and are not restated here.
handoff_workspace_orphan.go, service_tasks.go and handoff_cascade.go change only
to adopt the guarded write (REQ-005). handoff_workspace_orphan_repair.go (new),
repository/sqlite/task.go (the two selection queries) and backendapp/helpers.go (the
invocation) are REQ-003, owned by
orphaned-workspace-marker-startup-repair.
Frontend
| Component | Responsibility |
|---|---|
lib/types/http.ts |
workspace_orphaned?: boolean on the Task type |
lib/kanban/map-task.ts |
workspaceOrphaned: source.workspace_orphaned — a bare pass-through, see below |
lib/ssr/mapper.ts |
Boot-payload resolution, mirroring resolveAutoStartFailed |
lib/state/slices/kanban/types.ts |
workspaceOrphaned?: boolean on the store task |
lib/ws/handlers/tasks.ts |
preserveOmittedField entry |
lib/ws/handlers/kanban.ts |
Carry-forward on the kanban.update merge |
lib/ui/state-icons.tsx |
Third branch in getMarkerIconOverride, plus the icon component |
components/kanban-card-content.tsx |
Board render site and an edit: renderTaskStatusIcon's early-return gate must name the new marker, or the card stays blank |
components/kanban/graph2-step-node.tsx |
Board render site, no edit: it calls getTaskStateIcon ungated |
Frontend components
getMarkerIconOverride (state-icons.tsx) gains a third branch after
autoStartFailed:
if (TERMINAL_TASK_STATES.has(state)) return null;
if (interrupted) return TASK_INTERRUPTED_ICON;
if (autoStartFailed) return TASK_AUTO_START_FAILED_ICON;
if (workspaceOrphaned) return TASK_WORKSPACE_ORPHANED_ICON;
return null;
Do not reorder the enclosing chain. getTaskStateIconConfig calls
getMarkerIconOverride last, after pending permission, pending clarification,
generating/background activity and isWaitingForInputState. Terminal state is
checked inside getMarkerIconOverride, so it gates the three markers but does
not outrank those four earlier affordances, which AC-002.3 preserves. The
requirements' precedence list describes the resulting order; it is not an order to
re-sort.
That branch alone renders NOTHING on the kanban card — the edit most likely to be
missed. The two board surfaces reach getTaskStateIcon differently.
graph2-step-node.tsx:181 calls it unconditionally, so the new branch suffices there.
kanban-card-content.tsx does not: renderTaskStatusIcon opens with
if (!showRunningSpinner && !needsMe && !hasActivity && !showInterrupted && !showAutoStartFailed) {
return null;
}
returning before getTaskStateIcon is called. For the population REQ-002 exists to
surface — a live CREATED/TODO subtask, no session, no activity, no pending prompt —
all five are false: shouldShowTaskRunningSpinner (state-icons.tsx:268-279) is false
for TODO, and with a null primarySessionState true only for
IN_PROGRESS/SCHEDULING. So add showWorkspaceOrphaned to that gate and thread the
value into the options beside autoStartFailed (:339-345). The gate already naming
showInterrupted and showAutoStartFailed individually is the proof each marker must
be added here too. AC-002.1 requires both surfaces so a test on one cannot stand in for
the other.
getTaskStateIcon dispatches marker configs by identity comparison to a wrapper
component owning the tooltip and aria-label, so the marker needs a
WorkspaceOrphanedTaskIcon beside InterruptedTaskIcon and
AutoStartFailedTaskIcon, plus a matching identity branch. Icon identity is what
AC-002.1 is asserted against, mirroring kanban-card-status-icon.test.tsx. Both
existing markers are STYLE_ERROR red and differ only by shape, so prefer a distinct
hue and shape: a muted or slashed glyph reads as "cannot run", not "run failed".
Copy lives in common.json beside interruptedByRestart and autoStartFailed, in
en, pt-pt, zh-cn, zh-hk, zh-tw, plus pseudo (pnpm run i18n:pseudo;
pnpm run i18n:zh-hant for the Traditional pair). No U+2014 (AC-002.6).
Data and contracts
The marker is unchanged, and REQ-001 reads it, never writes it:
orphaned (bool true), orphaned_reason (string "parent_archived"),
orphaned_parent_id (string), orphaned_at (string, RFC 3339 UTC), all under
tasks.metadata.workspace.
Derivation, per AC-001.2, is a strict boolean-true test, not the != nil
presence test interrupted and auto_start_failed use:
func workspaceOrphaned(meta map[string]interface{}) bool {
ws, ok := meta["workspace"].(map[string]interface{})
if !ok {
return false
}
v, _ := ws[orphanedWorkspaceKey].(bool)
mode, _ := ws["mode"].(string)
return v && mode == workspaceModeInheritParent
}
The mode conjunct is AC-001.9, and it is what keeps the marker honest against
the writers this spec does not guard. Three paths change mode off inherit_parent
and leave the four keys standing: the delete path (handoff_cascade.go:903),
normalizeWorkspaceModeAfterReparent (service_tasks.go:2077, called :1922 on any
parent_id change, reachable from task_http_handlers.go:958 and
task_ws_handlers.go:194), and detachTaskQuery (sqlite/task.go:1497, raw SQL
behind POST /tasks/:id/detach). All three leave the task startable, and without the
conjunct each strands a badge AC-003.9 cannot retract — its clearing CTE needs a
present, unarchived parent, so a marker naming a still-archived or deleted one never
matches — while AC-003.4 forbids clearing on predicate failure. Re-parenting is the
operator's obvious response to the badge, so the marker would punish the action it
prompts. The conjunct closes all three, and any future one, in the single place the
value is derived; AC-005.6 still clears the keys on the delete path as row hygiene
rather than as the only guard.
The two existing markers can use a presence test because their clear path removes the
key. This one clears the same way, but the key is a bool whose false value is
meaningful, so a presence test would report true for "orphaned": false. The type
assertion also satisfies AC-001.3: a non-object workspace yields false, not a
panic.
This helper is the single derivation site. ToAPI, FromTaskWithSessionInfo,
publishTaskEventNow and the repair all call it rather than re-testing the key,
making AC-001.2 and AC-001.9 structural. The repair's SQL predicate mirrors it
(see the startup-repair design) — the one place the rule is expressed twice,
and the two must change together. Both CAS clauses of the guarded write mirror the
comma-ok string assertions the same way.
No schema migration, column, route, WS action, or event type (AC-001.7).
Delivery paths
Three independently hand-built maps carry task fields to the client. All three are required; each has been the site of a real omission bug.
- HTTP DTO —
internal/task/dto/dto.go(TaskDTO) andinternal/task/models/models.go(ToAPIforv1.Task, beside theAutoStartFailedassignment), which already compute both existing markers. - Boot payload —
mapKanbanTaskStateis an explicit camelCase whitelist writing straight into the frontend store shape, so a new DTO field is invisible to first paint until listed there. It carries nometadatakey at all, which is why REQ-001 specifies a derived boolean rather than letting the client readmetadata.workspace.orphaned. - WS
task.updated—publishTaskEventNow, as an explicit boolean, per theauto_start_failedprecedent:preserveOmittedFieldpins the previous value when a key is absent, so an omitted key would make a clear as invisible as the set it undoes. (interruptedis deliberately not on this path; do not copy that.) It is shared bytask.created,task.updatedandtask.state_changed, so one edit meets AC-001.5.
models.PublicTaskMetadata clones metadata and redacts only deferred-launch
attribution, so workspace already reaches the HTTP and WS payloads.
The two frontend mappers take OPPOSITE null conventions, and the difference is
load-bearing. lib/ssr/mapper.ts normalizes — resolveAutoStartFailed is
task.auto_start_failed ?? false (:15) — because it feeds a FULL boot snapshot.
lib/kanban/map-task.ts must NOT: it passes interrupted and autoStartFailed
through with no ?? false (:245-246), unlike its ?? false / ?? undefined
neighbours, because preserveOmittedField (ws/handlers/tasks.ts:82) detects an
omitted wire key by testing nextTask[field] === undefined. Normalize there and that
test never fires, so AC-001.6's preserve-on-omission is silently gone on the WS delta
path — a false clear on exactly the partial delta it exists to cover, with nothing
failing fast.
Deferred: explanation surface
REQ-004 (a banner on the task detail context panel naming the archived parent) was
cut at the round-7 spec review; deferred, no follow-up filed. The requirements'
## Out of scope holds the decision and why. This section holds only what a follow-up
cannot cheaply re-derive.
- The panel is not on the kanban route.
TaskDetailContextPanelis mounted in exactly one place,TaskContextSection(OfficeSimplePane.tsx:526), rendered only by/office/tasks/[id];/tasks/[id](kanban-task-shell.tsx:66-98) never mounts it. So either mount it there too, or explain inside the marker's own tooltip, which is already localized and already on both board surfaces. The second is cheaper. - A parent title needs a same-workspace gate.
orphaned_parent_idis user-writable andRepository.GetTask(task.go:509-517) filters ont.idalone, so render a title only when the resolved parent'sworkspace_idmatches the subject's; otherwise treat it as a miss, as also when the id is absent, empty, non-string or unresolvable. ExtendresolveWorkspaceFields(handoff_context.go:106-118) rather than adding a secondGetTask, and do not reuseworkspace_statusorblocked_reason. - The panel will not converge on a producer-driven mark. Neither marker wrapper
refreshes
task.UpdatedAtwhileSetTaskMetadataKey(sqlite/task.go:1110) bumpsupdated_atin the DB, anduseTaskContext(use-task-context.ts:44) refetches only whentask.updatedAtchanges. The repair's re-read (AC-003.5) is unaffected. - Its early return (
if (!showRelations && !showDocs && !showWorkspace) return null) sits aboveWorkspaceStatusBanner, so arequires_configurationstatus alone renders nothing today.
Failure and recovery
- A failed repair write logs a Warn naming the task and continues (AC-003.6); the next startup retries it, since the pass is idempotent and re-derives its work list. A lost guard is not a failed write: it is a silent skip, publishing nothing (AC-005.5), logged at Debug.
- A failed repair selection query logs a Warn and skips that pass for the boot without failing startup (AC-003.11). The repair enhances an already working board; it must never stop the process coming up.
- A failed producer mark already logs and continues upstream; unchanged here. The result is a task with no marker until the next repair pass.
- A failed or crashed producer clear leaves a marker naming a parent that is no longer archived, and nothing in the producer revisits it: the clear runs once, on unarchive, and that unarchive has already happened. The repair is the recovery owner (AC-003.9), the one case in which it may clear — and it must reach archived children to be that owner, since their markers have no other revisit path.
- An unresolvable, absent, empty, or non-string
orphaned_parent_idnever affects the derived boolean, which readsorphanedandmodeonly. It narrows what AC-003.9 can retract, nothing else; the marker is still correct, since the task cannot start. - A task whose
modeis changed offinherit_parentstops reporting orphaned at once (AC-001.9), even though its four keys persist. That is the recovery owner for the reparent, detach and delete paths, none of which AC-003.9 can reach. - A
task.updatedthat omits the key must not clear a set marker client-side (AC-001.6) —preserveOmittedField, which the WS-side explicit boolean makes unnecessary in practice but which guards thekanban.updatemerge path.
Persistence
No schema change: the four marker keys are already durable in tasks.metadata, and the
read side adds no table, column, index, or retention rule. The historical population that
no archive event will reach is stamped by a boot-time pass owned by a sibling design,
orphaned-workspace-marker-startup-repair,
which also owns REQ-003's clearing pass, its dialect branches and its logging contract.
Security
None beyond existing task-visibility rules. The marker is display metadata on a task
the viewer can already read; it exposes no filesystem path, branch name or credential,
orphaned_parent_id names a task in the same workspace, and the context fields ride
the already-authorized GET /api/v1/tasks/:id/context. Task metadata is user-writable
through the generic metadata update surface (protectedTaskMetadataUpdate preserves
only the deferred-launch key), so every value this capability reads is
attacker-chosen in the ordinary sense. Setting orphaned on your own task only badges
a card you own, matching the interrupted marker's accepted posture. Nothing here
projects orphaned_parent_id or anything derived from it: with REQ-004 cut, the id is
read only by the repair's clearing JOIN, which compares it to a row id and returns no
attacker-visible value. A surface that ever renders the parent's title must add a
same-workspace gate first, because Repository.GetTask applies no workspace predicate
— see ## Deferred: explanation surface. The same user-writability is why two rules
are load-bearing: AC-001.2's strict value test (a hand-written "orphaned": false is
reachable) and the CAS's json_type gate (so is a non-string orphaned_parent_id).
Observability
- The producer already logs
marked inherit_parent child orphaned by parent archiveandcleared inherit_parent child orphan marker after parent unarchiveat Info withtask_idandparent_task_id; reuse it. - A lost guard is Debug, not Warn: a concurrent writer won, which is correct behavior rather than a fault.
- No new metric: the population is bounded, and the logs answer "did the pass run and what did it touch". The repair pass's own logging contract (AC-003.12) is in its sibling design.
Related decisions
No ADR. This adds no architectural boundary: it extends the derived-marker pattern
in interrupted-task-indicator and
the guarded-metadata-write pattern already present as
SetTaskMetadataKeyIfNotArchived and SetTaskMetadataKeyIfNoActiveSession.