Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion docs/data-model.md

Large diffs are not rendered by default.

6 changes: 6 additions & 0 deletions docs/e2e-coverage.md
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,12 @@ until `make e2e` is green and this file reflects it.
| ✅ Dashboard groups | A project group renders as a folder with its member cards INSIDE it (not merely adjacent) and a membership count; collapsing on the dashboard collapses the sidebar folder and expanding from the sidebar re-opens the dashboard one, since both read `collapsedGroups`; a group typed as "Infrastructure" renders, collapses and shares state under the NORMALIZED "INFRASTRUCTURE" on both surfaces, which is the sharing claim itself and which an all-caps fixture name cannot prove; a folder holding an active task floats above an idle section without reordering its own members | `projects.e2e.ts` |
| ✅ Dashboard live signals | A task row carries the same work badge the sidebar does, with the same precedence (a seeded `done` shows, a later attention outranks it, clearing both removes it), read through `dashboardBadge()` because `work-badge` is no longer unique on the page; the PR chip is absent until the pr store holds a lookup and then reports its state | `projects.e2e.ts` |
| ✅ Dashboard recents | A store with no history renders no Recent row at all; visiting a task adds its chip; archiving that task removes it, so the row never offers a dead link | `projects.e2e.ts` |
| ✅ Dashboard phases | **Todo until the first prompt, not until opening**, which is the claim this row exists to pin: a task created and never opened reads `todo` on its row and carries no `started_at`; activating it spawns fakeagent and it is STILL `todo`, asserted against a record the spawn REWROTE (`spawn_count` reaches 1 on disk while `started_at` on that same listing is still null), so the claim is "the record moved and the stamp was not in it" rather than "we looked too early"; the first `submitToAgent` through xterm's own input path moves the row to `in_progress`, drops the Todo pill count by exactly one, and lands a `started_at` on disk that travelled null -> minutes old. Every pill is always rendered, in the order all / todo / in_progress / in_review / done / parked (Parked last, after Done: it is not a stage of the life the first four spell out), so the counts are what is asserted and never a pill's presence. A seeded PR then drives a worktree task (Todo after its spawn too, In progress after one prompt) through the whole ladder: open -> `in_review`, draft -> `in_progress`, open + `changes_requested` -> `in_review`, open + failing checks -> `in_review`, closed -> `in_progress`, merged -> `done`. With the PR snapshot cleared, REAL git drives the rest against the task's own worktree and the fixture's bare origin, each step behind a forced `useTaskGit.refresh(id, true)` (the poller's 30s floor makes `taskGitPassNow` a no-op between two steps seconds apart): started with no commits and no remote branch is `in_progress`; one commit of its own is still `in_progress`, because `ahead` is null and null is not "nothing left to push"; `push -u origin <branch>` makes it `in_review`; a single untracked file takes it back to `in_progress` and deleting it returns it to `in_review`, so the rule is proved in both directions; fast-forwarding the branch into the fixture's `main` (and pushing `origin/main` on behind it) makes it `done` with no PR anywhere. Selecting a pill hides every non-matching row while the pill counts stay put (they describe the fleet, not the view) and the Projects header still counts projects; a filter matching nothing replaces rows AND cards with the one `dashboard-phase-empty` line reading "Nothing done"; pressing the selected pill again hands the selection back to All and the rows return. Teardown restores the fixture's `main`, the bare origin's `refs/heads/main` and the branch on both sides from SHAs recorded in `before()` (never from what a case returned), then asserts in `after()` that the fixture is clean, that no `e2e-phase-*` branch survives and that `ls-remote origin` holds no such head, so a leak fails here instead of in `git.e2e.ts` on a base that moved | `projects.e2e.ts` |
| ✅ Dashboard age | A row whose task has no `last_opened_at` renders no age at all; a stamp three days old renders "3 days ago" in `task-age`; activating that task clears the label; and the never-opened, never-prompted task beside it reads `todo`, since a row's age and its phase are independent halves. Persistence is proven on a SECOND task nobody has opened, so the value has to travel null -> a stamp minutes old rather than being satisfied by one that was already there (Rust holds a stamp younger than `TOUCH_MIN_SECS` instead of rewriting the file, so the same assertion on an already-activated task would pass on its creation stamp): the record is read off disk through `tasks_list` before and after `setActiveTask`. That and the `started_at` null -> fresh check in the phases row are the two assertions in these rows with no DOM to read instead | `projects.e2e.ts` |
| ✅ Planned tasks (goal + Start later) | The New Task dialog's prompt box with **Start later** ticked writes the text down as the task's GOAL and sends nothing: the task is created, its agent still spawns (`spawn_count` reaches 1 on disk), the row reads `todo` with the goal rendered beside it, and `started_at` is still absent from the record, so Planned is a rendering of a goal plus an unstarted task rather than a phase of its own. The same text in the same box with the box UNCHECKED is delivered instead, so that task is `in_progress` from birth and carries no goal: the contrast is the case, since the first half alone would pass against a build that simply dropped the prompt. The checkbox is a styled button, not a native input, so its state is read from `aria-checked`, and it reads unticked on every open. The sidebar menu's "Start with goal" then delivers a planned task's goal through the same seeder: the task reaches `in_progress` with a fresh `started_at` on disk, the goal SURVIVES on the record and on the row (it is a record of intent, not a queue entry that delivering consumes), and the menu row retires itself once the task has started. "Edit goal…" (the label follows whether there is one) opens the goal dialog with focus in the textarea, and saving rewrites the goal on disk and on the row without moving the phase | `projects.e2e.ts` |
| ✅ Parking (the one hand-set value) | Driven through the real sidebar menu and the real dialog: Park writes `parked_at` plus the optional reason, the row reads `parked`, shows the word "Parked" and carries the reason in that node's tooltip. Re-parking rewrites the reason and deliberately does NOT move `parked_at`, which answers "since when". One menu row carries both labels, following the record ("Park task" / "Unpark task"), and unpark opens no dialog because there is nothing to ask: it clears the stamp and the reason together, on disk, and the row returns to its derived phase. The "Also stop the task" checkbox is offered only for a MOUNTED task and defaults to ticked, and it is `aria-checked` that says so. Both dialogs take focus when opened from the dropdown, measured rather than assumed: `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 the control that the helper is not answering the same thing regardless. Cancelling the park dialog parks nothing. The reason-rewrite step goes through the menu's own "Edit park reason…" row, which exists because the park row flips to Unpark once `parked_at` is set: without it the dialog's pre-fill and the Rust rewrite-without-restamping path would be unreachable by hand. That step also asserts the "Also stop" checkbox is NOT rendered while editing and that the task is still mounted afterwards, because the checkbox is hidden there while its state still holds its ticked default, which made saving a reason kill a running task's agents until it was guarded | `projects.e2e.ts` |
| ✅ A park clears itself on the next prompt | The claim the whole design rests on, and the reason a hand-set value is allowed to sit beside derived ones at all: a started task is parked through the menu (with the stop option unticked so its agent survives), then ONE prompt is submitted into its terminal through xterm's own input path and nothing else. The row returns from `parked` to `in_progress`, the Parked word goes, and `parked_at` and `park_reason` are both gone from the record, with nobody having touched the menu, the palette or the store | `projects.e2e.ts` |
| ✅ Parked in the phase table | Done outranks Parked: a parked worktree task given a merged PR snapshot reads `done` and renders no Parked word, while `parked_at` stays ON the record (outranked, not wiped), and taking the snapshot away brings Parked back, which is what proves the PR did it. The filter row carries a sixth pill, Parked, last after Done; selecting it leaves exactly the parked tasks on the board (the pill's count and the row count agree, and every surviving row reads `parked`) and drops an In progress task that was there a moment earlier | `projects.e2e.ts` |
| ✅ Agent settings | Disable/re-enable an agent CLI via agentsSave | `agent.e2e.ts` |
| ✅ Run config modal | The #124 run-commands manager opens for a project | `run.e2e.ts` |
| ✅ SVG source/preview toggle | An `.svg` opens on the rendered picture (the default stays "preview", so a file-tree click still shows the image), the same source / preview / split toolbar markdown uses switches to the editable source and to both at once, an UNSAVED edit re-renders the picture (the preview is fed by the editor buffer, not disk, so a disk-backed one could not move), and toggling writes the `svgDefaultView` pref for the next file (GH #247) | `editor.e2e.ts` |
Expand Down
44 changes: 44 additions & 0 deletions docs/gotchas.md
Original file line number Diff line number Diff line change
Expand Up @@ -773,3 +773,47 @@ family as the "Reset to defaults" loss that first put these fields in the TS typ
(a default entry spread over fields TypeScript did not know about) and as the
clone-that-snapshots-its-parent trap in `agents.ts`. See
[agent-accounts.md](agent-accounts.md).

## Task record setters serialize on the main thread, and only there

Every small per-task setter in `lib.rs` (`task_record_spawn`,
`task_set_has_history`, `task_set_tabs`, `task_set_yolo`, some thirty of them)
is an unlocked read-modify-write of the task's whole JSON file: load, find,
mutate one field, `save_task`. Nothing guards two of them against each other.
They are correct anyway, because they are all sync commands and Tauri runs
sync commands on the main thread one after another. That invariant was never
written down, and `task_touch` broke it by accident: it fires on every
activation, so it was made async + `spawn_blocking` to stay off the main
thread, which put it on another thread at the exact moment `task_set_tabs`
and `task_record_spawn` fire for the same task (the pane mounts and spawns
within milliseconds of the activation). The e2e run then found a task
activated seconds earlier with `last_opened_at: null` on disk: a sibling had
read the record before the touch wrote it and written its own copy back
after. The fix was to make the touch sync like its siblings, which costs one
small read and one atomic write on the main thread, strictly less than a
sibling's `load_tasks_all()`.

So: a per-task setter that writes the record is sync, or it takes a lock
that every other writer of that record also takes. The existing async writers
(`task_archive_sync`, `task_restore_sync`, `pr_lookup_blocking`,
`task_pr_create`) are the known exposure: rare and user-paced, or a 30s
background poll whose read-to-write window is a few microseconds, so nobody
has seen them lose a write. Adding a frequent one is how the race stops being
theoretical.

`task_mark_started` is the hard case: it fires on EVERY prompt submission,
which is exactly when the pane is spawning and `task_record_spawn` and
`task_set_tabs` are firing for the same task, so it is sync like `task_touch`.
It used to be write-once as well, which meant there was only ever one write to
lose; it is not any more, because it also clears the park (a prompt into a
parked task means the user has picked it back up). The STAMP is still written
once, but the command can write on any call, so the rule is doing real work
here rather than being belt-and-braces. It still skips the write when nothing
changed, which is the case on almost every call. The two setters beside it,
`task_set_goal` and `task_set_parked`, are sync for the same reason and are
user-paced on top of it.

`task_git_phase_state` is the other half of the rule: it is IO-heavy enough to
need `spawn_blocking`, so it is strictly READ-ONLY on the record and must
never call `save_task`. If it ever needs to persist something, that write
goes through a sync command, not through the async one that computed it.
95 changes: 95 additions & 0 deletions docs/ideas/phase-automation.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,95 @@
# Driving work from the phase

**Status: idea. Nothing here is built, and nobody has committed to building
it.** The phase it builds on is implemented and documented in
[ui.md](../ui.md); this doc is only about what could sit on top of it.

## What exists, and why it makes this possible

A task's phase is derived at render from signals already in memory: the task
record, the PR store and a git lookup. Todo, In progress, In review and Done
are computed, not typed. The one hand-set value is Parked, which exists
because "I put this down" has no live twin anywhere in git or the forge, and
it clears itself on the next prompt.

The rule the design stands 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.

That rule is what makes automation cheap here. Walk a task through the
pipeline and every step leaves a real fact behind:

| Step | The fact it writes | What the phase does |
| --- | --- | --- |
| goal submitted as a prompt | `started_at` | Todo to In progress |
| agent commits and pushes | clean tree, `ahead = 0` | In progress to In review |
| PR opens | forge state | stays In review |
| PR merges, or the branch reaches base | `merged_into_base` | Done |

So an automated pipeline needs **no status field at all**. It does the work,
and the phase follows because the work happened. Compare that with syncing a
stored column on a PR webhook: the column is a second copy of something git
already knows, and a second copy is the thing PR #292 was rejected for.

## The shape

A Planned task (a goal, no `started_at`) is the entry point. Today you start
it by hand and the goal is delivered as the first prompt. The idea is that
the same transition can be asked to carry more:

```
Planned In progress In review Done
+-----------+ +-----------------+ +----------------+ +-----------+
| rate- | -> | agent runs with | -> | second model | -> | PR merged |
| limits | | the goal as its | | reviews the | | |
| (goal set)| | prompt | | diff, pushes | | |
+-----------+ +-----------------+ | a PR | +-----------+
+----------------+
^ ^ ^
| | |
you write automatic automatic, and
the goal optional per task
```

You define the work once, in the goal. Starting it runs the implementation,
the review and the PR, and you are handed something to look at. The phase is
the readout throughout, never the mechanism.

## What would have to exist first

1. **An action attached to a transition.** Termic already has the pieces: the
prompt library, the review flow, the message queue and PR create. What is
missing is the binding from "this task entered In progress" to "run this
prompt in it".
2. **A place to configure it per project.** A repo whose review prompt is
special needs to say so, and `.termic.yaml` is where that already lives.
3. **Completion that is not a guess.** "The agent finished" has to be a real
signal. Agent hooks report done today, and the git state says whether
anything was committed. Both are needed: an agent that reports done having
written nothing has not finished, it has failed.

## Where this should stop

**No automatic merge.** The pipeline's job is to hand back a reviewable PR,
not to land it. This repo's own contributing rules say green suites are not a
manual test and that the one gate an agent-written change has to pass is a
human actually driving it. An automation that merges its own work walks
straight past that. Producing the PR is the valuable part anyway; the last
click is cheap and it is the one worth keeping.

**Fan-out has to be deliberate and visible.** One transition could spend a lot
of money across a lot of agents. Whatever starts work from a transition should
say what it is about to do before it does it, and should be off by default.

## Open questions

1. Is the transition the right trigger, or is it a button on the card? The
trigger reads better on a board and worse everywhere else, and Termic has
no board today.
2. Should a failed run move the phase back, or park the task with the failure
as its reason? Parking is more honest and it reuses something that exists,
but a machine writing the one hand-set field needs thinking about.
3. Does the second-model review belong here at all, or is it just a prompt you
run like any other? The answer probably depends on whether its result has
to gate the PR.
Loading
Loading