Skip to content

feat(task): derive a task phase, and give a task somewhere to write down what it is for - #310

Open
nvkvin wants to merge 12 commits into
simion:mainfrom
nvkvin:feature/task-status-feature
Open

nvkvin wants to merge 12 commits into
simion:mainfrom
nvkvin:feature/task-status-feature

Conversation

@nvkvin

@nvkvin nvkvin commented Sep 17, 2026

Copy link
Copy Markdown
Contributor

#292 shipped a hand-set task status and you dropped it, for a reason I agreed with at the time and still do.
Three of its five states repeated the PR chip, the work badge and archiving, and the two that did not had to be kept current by hand.
A signal a person maintains, sitting beside signals the app polls, goes stale and then lies.
What you said at the end of that thread was that anything derivable belongs on the row and anything typed does not, and that "which of these am I still working on" was a real question with nowhere to live.

This is the second attempt, built on that rule rather than around it.

The rule it is built on

A person may set the states the machine cannot see.
The machine owns every state it can see.
A manual state clears itself the moment evidence arrives.

In progress, In review and Done are derived at render and stored nowhere, so no UI can hand-set them and there is nothing to go stale.
Parked is the one hand-set value, and it is allowed to be one because it has no live twin anywhere in git, in the forge or in any process, so there is nothing for it to drift from.
It also un-sets itself, which is the part that matters.

What is derived, and from what

Phase Derived from
Todo nothing has been asked of this task yet
In progress started_at, stamped at the first prompt a human submits
In review an open PR, or with no PR at all: own commits, clean tree, nothing unpushed
Done archived, a merged PR, or the branch reaching its base by any route

Todo needed started_at to exist at all.
Every GUI create spawns an agent, so a spawn was never evidence anybody had given the task work, and without that stamp Todo was structurally unreachable.

Done's git half is biased toward false throughout, because a missed Done costs nothing and a wrong one tells you to archive live work.
Two tiers: with own commits it asks whether every branch-side commit is patch-equivalent on the base, plus a squash variant replaying the tree as one commit on the merge base; with none it needs the tip to be an ancestor, the creation commit known, the tip moved off it, and a reflog entry whose own sha landed on the base.
That last condition is what separates a fast-forward merge from a branch an agent rebased onto a newer base, and from one that committed and then reset its work away.
Both are identical in the DAG.

The half that is typed, and why

Two things about a task have no live twin, so nothing can derive them.

The first is what the task is FOR.
There was nowhere on the record to write that down, so the only place to put it was the agent's prompt box, and submitting that starts the task.
Noting down work to pick up later was therefore impossible: every way of saying what a task was for also started it.
goal is that text, and Start later in the New Task dialog routes the prompt box into it instead of delivering it.
A task with a goal and no started_at reads as Planned, which is rendered rather than derived into a fifth value, because started_at already answers what a Planned state would store.

The second is Parked, with an optional free-text reason.
There is deliberately no Blocked state: "blocked on the API key" is a park reason, one state with a note rather than two that differ only in why.
The park clears on the next prompt into any terminal, through the same markStarted that stamps started_at, so the one flag a person sets is also the one thing the app un-sets for them.

Park and Stop stay separate actions.
Stop is a resource action (#119, kill the PTYs and keep the session) and must never on its own claim you put the work down.
Parking offers to stop as well, and offering is as far as the coupling goes.
Stop is also every task's state after a relaunch, so a Stop that parked would park the whole fleet overnight.

The precedence question you will ask first

An open PR is In review however dirty the worktree is and however many commits are unpushed.
The asymmetry with the no-PR path is real and deliberate: without a PR a single untracked file pins a task at In progress, and with one, nothing local moves it.

An open PR is an explicit act by a person saying the work is ready to be looked at.
dirty and ahead are proxies for exactly that statement, used only where the person has not made it, and a proxy must not overrule the thing it stands in for.
The practical half matters as much: a dirty worktree under an open PR is what addressing review comments looks like, so a phase that flipped on every edit would be noise.
A draft PR is the control showing this is a rule rather than an oversight, since it reads In progress even on a clean, fully pushed branch.

On the row

One monochrome glyph per row: empty ring, half filled, ring with a dot, check, and a crescent for Parked.

The first version of this drew nothing at all, reasoning from your #292 note that any mark beside the PR chip repeats it.
That was too broad, and driving it showed why: Todo, In progress and Parked were invisible, and the other two were legible only because the chip happened to be there.
Your objection was narrower.
That status square was coloured, in the chip's own vocabulary, so purple meant "merged" on one and "In review" on the other and a row could contradict itself.
Neither half holds here: colour stays the chip's, and the phase is derived from the PR state, so a merged PR is Done and the two marks cannot disagree.
Being monochrome it also survives for a reader who cannot tell the chip's green from its purple, which the chip alone never did.

The chip is not merely redundant either.
It carries failing checks and draft, which the phase throws away on purpose, because CI status is a property of the work rather than a stage of it.

Cost

The git lookup shells out, so it runs only while the dashboard is mounted, which is only while no task is open.
Six tasks a pass, a 30s floor per task, an epoch counter so a pass outliving the page drops its writes, and an equality check so an unchanged answer never reaches the store (bear trap 8).
Archived, main-checkout and open/merged-PR tasks are skipped entirely.

The new record setters are sync single-record read-modify-writes like their siblings.
An async first version of task_touch raced task_record_spawn for the same task and lost its stamp on disk, which is now written up in gotchas.md: those setters are correct only because sync commands run one after another on the main thread.
task_mark_started is also no longer write-once as a command, only as a stamp, because it clears the park on every call.

Testing

cargo test --workspace --lib, npm test (2351), npx tsc --noEmit, npm run typecheck:e2e and make e2e all green.
The e2e coverage is 12 cases in projects.e2e.ts, including the claim the design rests on: park a started task, send one prompt, and the park lifts on disk with nobody touching the menu.
Real git drives the merge-detection cases against the fixture and its bare origin.

One thing worth flagging.
Writing those cases turned up a debug probe I had left in the spec's before() hook, which meant five cases I had reported as passing had never executed.
It is fixed and they have now run, and the fixture-ref restore that throw was silently skipping works too.

Manually tested: created tasks at each stage plus one left in Todo, used Start from the task menu and watched it move to In progress with the agent running, and confirmed In review and Done followed the PR state on their own.

Refs #292

Nothing on the task record says when the user last had it open. The
dashboard's Recent row is localStorage, capped at eight and per
machine, so "which of these am I still on" has no durable answer.
This adds `last_opened_at`, stamped by the app itself in
`setActiveTask` and persisted by a new `task_touch` command. Nothing
is typed by a person.

The command fires on every activation, so it reads the one record out
of whichever profile holds it (re-tagging `profile`, which is
serde(skip), so the save lands back in that profile rather than the
root tree) instead of its siblings' `load_tasks_all()`, and it skips
the write when the existing stamp is under 60s old. It is sync like
those siblings, on purpose: every per-task setter is an unlocked
read-modify-write of the same file and they only stay correct because
sync commands run one after another on the main thread. An async
first version raced `task_record_spawn` for the same task and lost
its stamp on disk; docs/gotchas.md now records the invariant.

The frontend applies the same 60s guard before the IPC and stamps its
own copy inside the set() that setActiveTask was already doing, so a
task switch costs no extra store notification. A future or
unparseable stamp re-stamps on both sides, otherwise a clock that
jumped backwards would suppress every activation for hours.

Also fold `task_record_spawn`'s answer back into the store. The count
was only refreshed by loadAll, so a task launched this session read
as never-spawned until the next reload; the derived phase that
follows reads it.

Refs simion#292
"Which of these am I still working on?" had nowhere to live. PR simion#292
tried a hand-set status and it was dropped in review: three of its
five states repeated the PR chip, the work badge and archiving, and
the two that did not had to be kept current by hand, so they went
stale next to live signals. Same question, opposite mechanism:
nothing here is typed by a person.

`taskPhase()` derives Backlog / In progress / In review / Done at
render from the task record and the PR store, and stores nothing, so
it cannot disagree with the chip. Draft is In progress, a closed
unmerged PR falls back to In progress, changes_requested stays In
review, failing checks do not move it, archived beats merged, and a
failed lookup falls through to the record. The decisions and their
reasons are in the file header and docs/ui.md.

The dashboard gets a filter row with fleet-wide counts (Backlog only
while it has members: every GUI create activates and spawns, so it is
rarely non-empty), cards and folders drop out under a filter, and a
row shows a faint age from one day since it was last opened, nothing
for records written before `last_opened_at` existed. Neither carries
colour and the phase is not written on the row: the PR chip owns
colour on this page, and a row saying "In review" beside a chip that
already says open is the redundancy simion#292 was rejected for. The
sidebar is untouched in this pass.

`relativeDayLabel` is History's date ladder, extracted so the two
surfaces cannot disagree about "3 weeks ago".

e2e: dashboard phases and age in projects.e2e.ts, driven through real
creates, opens and seeded PR snapshots and read off the row's
`data-task-phase`; the persistence half travels null -> fresh on a
task nothing had opened.

Refs simion#292
Three record-level facts the derived phase needs and could not get.

`started_at`: the first prompt a human submitted into any terminal of
the task, stamped once by `task_mark_started` (sync, single-record,
write-once). Creation spawns the agent, so a spawn is not evidence
anyone has given the task work; this is. A one-time backfill
(`data_migration_version` 1, its own ladder so it cannot re-trigger
the workspace migration) stamps every existing record that had
spawned from `last_opened_at` or `created`, so a fleet does not read
Todo on upgrade.

`base_sha`: the commit the branch was cut from, frozen right before
`git branch --no-track` at the two create sites and at restore when
the branch has to be re-cut; None for a reused branch, an imported
worktree and a main checkout, where recording the base's current sha
would name a commit the branch was never cut from.

`task_git_phase_state`: where the branch stands against its base. Own
commits, dirty (untracked counts), ahead of the remote branch (None
when there is none), and merged_into_base, biased toward false because
a wrong Done tells the user to archive live work. Two tiers: with own
commits, every branch-side commit patch-equivalent on the base (rebase
merges) or the squashed tree replayed on the merge base equivalent to
one (squash merges); with none, the tip an ancestor of the base, moved
off a known creation commit, and a reflog `commit` entry that itself
landed on the base. That last condition is what separates a
fast-forward merge from a fresh branch an agent pulled up to a newer
base, or a branch whose work was reset away: identical in the DAG,
measured on git 2.50. The squash probe pins author and committer so
the dangling object dedupes instead of accumulating. Async and
read-only: an async writer would race the sync setters (gotchas.md).

`load_task_by_id` replaces the duplicated profile sweep in the three
single-record commands and re-tags `profile` in one place.

Refs simion#292
Two holes in the derived phase, both of which made it say the wrong
thing about a real task.

Backlog was unreachable, so it is now Todo and it is the state every
task starts in. Creating a task spawns its agent, so a spawn was never
evidence anybody had given it work: the agent is sitting at its
prompt. `markStarted` stamps `started_at` at the first prompt a human
submits, from the nine places one can be sent (the terminal's own
submit and resend, the seed prompt, both CLI send paths, the prompt
runner's two entries and the review-comment sender). A task created
with a prompt is therefore In progress from birth and one created
empty stays Todo until somebody types, which is the distinction the
phase existed to draw.

In review needed a second route in. An open PR is one, but a repo with
no forge, or a branch nobody has opened a PR on yet, had none: the
work was committed and pushed and the phase still read In progress.
`taskGit` polls `task_git_phase_state` and the rule is own commits,
clean tree, nothing unpushed, and no PR at all. Draft and closed PRs
outrank it on purpose, because both are a person saying how ready the
work is and a clean branch underneath does not overrule that.
`base_known` is deliberately not a condition: own commits are counted
against the base branch, so gating on the creation commit would deny
In review to every imported worktree and reused branch.

The poller is scoped to the dashboard's mount, which is the only
place the phase is drawn and is only mounted while no task is open, so
it never shells out to git behind a working agent. Six tasks a pass,
a 30s floor per task, an epoch counter so a pass that outlives the
page drops its writes, and `sameGitState` so an unchanged answer never
reaches the store (bear trap 8). Archived, main-checkout and
open/merged-PR tasks are skipped entirely: the first two have no
branch worth comparing and the last two already know their phase.

e2e covers the two new claims end to end: a task stays Todo after its
agent has spawned and moves on the first prompt, and a branch driven
through commit, push and a real fast-forward merge reads In review
then Done with no PR anywhere. The fixture's refs are captured before
the block and restored after, and the teardown asserts only what this
block owns, since `reset --hard` leaves another spec's untracked file
in place and a blanket check would blame us for it.

Refs simion#292
Refs simion#298
The derived phase answers where work stands, but two things about a
task have no live twin anywhere in git, in the forge or in the agent's
state, so nothing can derive them: what the task is FOR, and whether
the user has deliberately put it down.

The first is the gap that matters. There was nowhere on the record to
write an intention, so the only place to put one was the agent's
prompt box, and submitting that stamps `started_at`. Noting down a
task to pick up later was therefore impossible: every way of saying
what it was for also started it. `goal` is that text. A task with a
goal and no `started_at` is Planned, which is rendered rather than
derived into a fifth phase value, because `started_at` already answers
the question a Planned state would store.

`parked_at` and `park_reason` are the second. There is no Blocked
state: "blocked on the API key" is a parked task with a reason, one
state with an optional note rather than two that differ only in why.

The rule that keeps this from repeating simion#292, where a hand-set status
sat next to a PR chip that already answered the same question and
drifted from it: a person may set the states the machine cannot see,
the machine owns every state it can see, and a manual state clears
itself the moment evidence arrives. In progress, In review and Done
stay derived-only and no UI may set them. Parked outranks every live
signal under it, including an open PR, because it is the most specific
and most recent thing a person has said about the work; Done outranks
Parked, because a parked task whose PR merged is finished either way.

`task_mark_started` is what makes the last clause true. It still
stamps `started_at` write-once, but it now clears the park on EVERY
call, so a prompt into a parked task lifts the park without anyone
remembering to. That stops the command being write-once even though
the stamp still is, which is a change to the invariant in
docs/gotchas.md: the sync-setter rule was belt-and-braces there and is
now doing real work, so the entry says so.

Both new setters are sync single-record read-modify-writes like their
siblings, both skip the write when nothing changed, and both store
text trimmed with blank as None on each side of the boundary, so
emptying a box removes the field instead of leaving one that renders
as nothing. `task_set_parked` returns the resulting stamp so the
caller folds the real one into its store: a re-park must not move
`parked_at`, since that is what "parked 3 days ago" is rendered from
and editing the reason is the usual way to park twice.

No migration. All three fields absent means no goal and not parked,
which is the right answer for every record written before them.

Refs simion#292
Refs simion#298
The record half of this landed in the commit before; this is every
surface that reads or writes it.

**Start later**, in the New Task dialog, is the point of the feature.
The prompt box was previously the only place to say what a task was
for, and everything typed in it was delivered at create, so writing
the intention down and starting the work were the same act. The
checkbox routes the same text to `setTaskGoal` instead of
`seedPromptWhenReady`: the worktree is still cut and the agent still
spawns, but nothing is sent, so the task sits in Todo with its goal on
it until somebody picks it up. It does not persist across dialog
opens, because a sticky "start later" would quietly stop starting
tasks.

The sidebar task menu and the command palette get the same four
actions: Start with goal (only while there is a goal and nothing has
started, and only for an agent that HAS a prompt box, since delivering
prose to a shell would type it and press Return), Edit goal, and Park
or Unpark following the record. Park asks for an optional reason and
offers to stop the task as well, since parking usually means you want
the memory back too. It is an offer and a second call: `stop_task` is
a resource action (GH simion#119) and nothing anywhere makes stopping imply
a park. A task stopped to free memory has not been put down, and after
a relaunch every task is stopped, so stopped-ness could never carry
that meaning.

On the dashboard row, the goal and Parked draw in the age's register:
faint, uncoloured, one line, with the park reason in the tooltip. No
coloured chip, deliberately. simion#292 put a coloured status square beside
the PR chip and the two used the same colours for opposite meanings,
purple being both "merged" and "In review", so the PR chip keeps
colour to itself on this page. Parked also dims the row with the same
`opacity-60` the sidebar gives a task with no live PTY, so "put down"
and "not running" look alike, which they are. The dim and the word key
on the derived PHASE rather than on `parked_at`, so the row can never
say Parked while the Parked pill would not list it.

The predicates behind all of it live in `taskNotes.ts` rather than
inline in the components, because vitest runs `src/**/*.test.ts` in a
node environment and a rule written inside a `.tsx` is a rule nothing
can cover. `noteText` is the read-side mirror of the two normalizers
on the write side, so a goal of only spaces cannot make a row look
planned while the store treats it as absent.

The goal takes `flex-1` (basis 0) rather than `shrink`: two shrinking
items with natural bases split the deficit in proportion to length, so
a long goal would crush the task name, which is the Git Compare bar
trap already written up in docs/ui.md.

Also replaces docs/ideas/task-status.md with
docs/ideas/phase-automation.md. The old file designed the derived
phase, which is now built, so per CLAUDE.md that belongs in the
reference docs and not in ideas/. What is left undecided is the layer
above: transitions that carry an action, so moving a task to In
progress runs the goal as its prompt. That doc ties itself to the two
what-ifs GH simion#298 marks as likely first steps, and records the two
limits worth keeping, that it hands back a reviewable PR rather than
merging one, and that anything spending across many agents is off by
default and says what it is about to do.

Refs simion#292
Refs simion#298
Twelve cases in projects.e2e.ts: five that already existed and had
never run, and seven for the goal and the park.

The existing block could not execute. A debug probe left in its
`before()` hook, `throw new Error("TERMIC PROBE: ...")`, shipped in
70fb284 and survived two commits, so all five of its cases failed on
the hook. The fixture's refs were captured AFTER that throw, which
meant the teardown restoring `main` and the origin had been doing
nothing at all. Behind it, `--format=%(refname:short)` was passed
unquoted into an `execSync` that runs under /bin/sh, where the
parenthesis is a syntax error. Both fixed, and these cases have now
run.

The new ones: Start later writes the prompt box down as the goal and
sends nothing, asserted against the same text with the box unchecked
reaching In progress, because the first half alone would pass against
a build that simply dropped the prompt. Start with goal delivers it
and the goal SURVIVES on the record, since it is intent and not a
queue entry. Park writes its stamp and reason, a re-park rewrites the
reason without moving the stamp, and unpark clears both. A parked
task with a merged PR reads Done with `parked_at` still on disk, and
clearing the snapshot brings Parked back, which is what proves the PR
did it. The Parked pill filters. And the case the design rests on:
park a started task, submit one prompt into its terminal, and the park
lifts on disk with nobody touching the menu.

Writing that turned up a gap in the feature. Once `parked_at` was set,
the menu row and the palette command both flipped to Unpark, so there
was no way back into the park dialog: its pre-fill from the record and
the Rust path that rewrites the reason while refusing to move the
stamp were both unreachable by hand, and the spec had to drive the
store to reach them. A parked task now gets an "Edit park reason..."
row in the menu and the palette, and the dialog says "Edit park
reason" and "Save reason" in that mode.

That fix carried a bug of its own, found before it shipped and pinned
here: the "Also stop the task" checkbox is not rendered while editing,
but its state still held its ticked default, so saving a reason on a
running task would have killed its agents. Guarded on `!editing`, and
the case asserts the task is still mounted afterwards.

Focus was measured rather than assumed. Both dialogs open from a
dropdown, where one sibling defers through requestAnimationFrame and
another does not, and the source does not say which case this is.
`document.activeElement` is read once the menu has UNMOUNTED, so
Radix's focus return to the trigger has already run, with the
menu-open state read first as a control that the helper is not
answering the same thing regardless. No defer is needed, so none was
added.

make e2e: 15 spec files passed, projects.e2e.ts among them. The four
that failed (agent, files, git, settings) are in files this branch
does not touch: its only edit to a shared file is three store-action
calls in TerminalPane, and the failing cases are inline-image
rendering, a file drag, the issue-task project picker and the settings
path preview. The set also moves between runs, which is the signature
the suite is known for and why it does not gate.

Refs simion#292
Refs simion#298
The board did not say where most tasks stood. Todo, In progress and
Parked drew nothing at all; In review and Done were legible only
because the PR chip happened to be there, and Parked spelled itself
out in a word where that chip would sit. Three treatments in one
column, and no answer on most rows.

That was my reading of simion#292 and it was too broad. simion#292 rejected a
COLOURED status square in the PR chip's own vocabulary, where purple
meant "merged" on one and "In review" on the other, so a row could
contradict itself. Neither half of that is true here. Colour stays the
chip's and the glyph is monochrome, and the phase is DERIVED from the
PR state, so a merged PR is Done and the two marks cannot disagree.
What is left is redundancy, which is a much smaller problem than
lying, and it is not even total: the chip carries failing checks and
draft, which the phase throws away on purpose because CI status is a
property of the work rather than a stage of it.

Empty ring, half filled, ring with a dot, check, and the same Moon the
Park menu item and dialog already use, so parking reads as one thing
across three surfaces. It sits between the age and the badges, so it
lands in the same column whether or not the row has a chip. Being
monochrome it also survives for a reader who cannot tell the chip's
green from its purple, which the chip alone never did.

The "Parked" word goes with it and its reason moves into the glyph's
tooltip ("Parked: blocked on the API key"). Parked rows still dim,
and both the dim and the glyph key on the derived phase rather than on
`parked_at`, so a row can never read Parked while the Parked pill
would not list it.

The spec's old question, "is the Parked word present", is dead now
that every row carries a glyph. The new helper reads the glyph's own
`data-phase` independently of the row's `data-task-phase`, so a glyph
that stops following the phase fails the case; reading both off one
attribute would only prove the row agrees with itself.

Two code comments and a docs section asserted "the phase is NOT drawn
on the row" as a decision with simion#292 behind it. All three now say what
actually happened, since a load-bearing doc that is wrong costs more
than one that is missing.

make e2e: 19 of 19 spec files passed.

Refs simion#292
Refs simion#298
Asked what happens when a PR is open and work continues on the branch,
I could answer from the code but not from a test: every `dirty` and
`ahead` case passed a null PR, so the precedence that actually decides
it was unpinned. Five cases now cover it. An open PR stays In review
with a dirty worktree, with unpushed commits, with no remote branch at
all, and mid-rework with nothing committed; a DRAFT PR on a clean
pushed branch is In progress, which is the control showing this is a
rule rather than an oversight.

The comments were worse than missing, they were misleading. Both the
file header and docs/ui.md described the In progress <-> In review
work cycle without saying it is the NO-PR path, so a reader would
come away believing a dirty tree drags an open PR back. Both now lead
with the condition and then state the asymmetry outright, because it
is real: with no PR a single untracked file pins a task at In
progress, and with one, nothing local moves it.

The reason it is right: an open PR is an explicit act by a person
saying the work is ready to be looked at, and `dirty`/`ahead` are
proxies for exactly that statement, used only where the person has not
made it. A proxy must not overrule the thing it stands in for. The
practical half matters as much, since a dirty worktree under an open
PR is what addressing review comments looks like, and a phase flipping
on every edit is the noise `changes_requested` is already kept out of
the phase to avoid.

Refs simion#292
Refs simion#298
It framed itself as somebody else's proposal taken seriously, and cited
that issue for its direction. What to build on top of the phase is this
project's call, so the doc now argues from the phase it actually sits
on: every step of an automated run leaves a real fact behind, which is
why the pipeline needs no status field at all.

Nothing about the design changed. The two limits worth keeping are
still there, that it hands back a reviewable PR rather than merging
one, and that anything spending across many agents is off by default
and says what it is about to do first.

Refs simion#292
`main` moved five commits after this branch was rebased onto it, and
one of them collided textually: the markdown-preview change added
`WrapText` to the same lucide import line this branch had added
`Target` and `Moon` to. Both sides appending to one import is a
conflict with nothing behind it, and both features are intact after
it: the word-wrap command and all four task commands are present.

Merged rather than rebased so the commits keep the shas the open PR
already shows.
@simion

simion commented Sep 21, 2026

Copy link
Copy Markdown
Owner

Solid design, the merge-detection tests hold up. Real gap: BroadcastDialog.send() never calls markStarted, so a task whose first prompt arrives via Broadcast stays stuck at Todo, and a parked task given work through Broadcast never unparks.

@simion

simion commented Sep 21, 2026

Copy link
Copy Markdown
Owner

Also needs a rebase now, it's conflicting with main after today's merges. Fix that plus the broadcast gap above and this is good to merge.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants