Conversation
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.
…eature # Conflicts: # src-tauri/src/lib.rs
Owner
|
Solid design, the merge-detection tests hold up. Real gap: |
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. |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment
Add this suggestion to a batch that can be applied as a single commit.This suggestion is invalid because no changes were made to the code.Suggestions cannot be applied while the pull request is closed.Suggestions cannot be applied while viewing a subset of changes.Only one suggestion per line can be applied in a batch.Add this suggestion to a batch that can be applied as a single commit.Applying suggestions on deleted lines is not supported.You must change the existing code in this line in order to create a valid suggestion.Outdated suggestions cannot be applied.This suggestion has been applied or marked resolved.Suggestions cannot be applied from pending reviews.Suggestions cannot be applied on multi-line comments.Suggestions cannot be applied while the pull request is queued to merge.Suggestion cannot be applied right now. Please check back later.
#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
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
started_at, stamped at the first prompt a human submitsTodo needed
started_atto 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.
goalis 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_atreads as Planned, which is rendered rather than derived into a fifth value, becausestarted_atalready 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
markStartedthat stampsstarted_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.
dirtyandaheadare 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_touchracedtask_record_spawnfor 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_startedis 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:e2eandmake e2eall 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