diff --git a/docs/reference/tui.md b/docs/reference/tui.md index a96c5a0e..4f87dd39 100644 --- a/docs/reference/tui.md +++ b/docs/reference/tui.md @@ -1079,7 +1079,7 @@ duration written as the sheet reopens it (e.g. `stops at 6h, $50.00`; `stops at | --- | --- | | Title | `Epics` | | About | `Every epic in this directory, newest first: task, status, and session count.`; on this machine, `Every epic on this machine, newest first: workspace, task, status, and session count.` | -| Rows | `YYYY-MM-DD HH:MM · `, about `[ · ] · session(s)[ · failed\|stopped\|was left unfinished][ · resumable]`. ``, with `~` for the home directory, only on this machine. `resumable`: the run is of this directory, its flow is resumable now and it left a journal. A run in progress reads `was left unfinished`. | +| Rows | `YYYY-MM-DD HH:MM · `, about `[ · ] · session(s)[ · failed\|stopped\|is still going\|was left unfinished][ · resumable]`. ``, with `~` for the home directory, only on this machine. `resumable`: the run is of this directory, its flow is resumable now and it left a journal. A run in progress -- here, under a host, or in `hmz exec` -- reads `is still going`; `was left unfinished` is one whose process went before it ended. | | Buttons | **Search…** (flow, task, run name, and workspace on this machine); **this machine** / **this directory**, as `ctrl+t`; **Import…**, as `i` | | Keys | `enter open ctrl+t this machine\|this directory i import / search tab actions esc close` | | Empty | `no flow has been run in this directory yet` / `no flow has been run on this machine yet`; `reading…` while a machine's are read | diff --git a/docs/reference/web.md b/docs/reference/web.md index e4311393..a7d731f4 100644 --- a/docs/reference/web.md +++ b/docs/reference/web.md @@ -201,9 +201,10 @@ asks one, through the same side conversations: a second question to one still an | `GET /api/usage` | `days` (`30`; 1 to 365); `workspace` (only its runs) | `days` (per UTC day, oldest first), `flows` (costliest first), `ended` (`{how: n}`), `whole`: each with `runs`, `cost`, `output_tokens`, `seconds`, and `money`, `tokens`, `worked` as the terminal interface says them | A row is `name`, `flow`, `task` (its first line, cut at 240 characters), `began`, `ended`, -`how` (as the run ended; `running` for the run going, `unfinished` for one that never said), -`agents` (`[{role, runs}]`), `sessions` (a count), `resumable`, `held` (whether it is the run -going), `spent`: what its budget counted as it ended, or `null`, `backup` (the targets it was +`how` (as the run ended; `running` for a run going -- held by a host, or by another process such +as `hmz exec` -- and `unfinished` for one whose process went before it said), +`agents` (`[{role, runs}]`), `sessions` (a count), `resumable`, `held` (whether it is the run a +host is going with, which the page follows live and can stop), `spent`: what its budget counted as it ended, or `null`, `backup` (the targets it was started with) and `backed` (`{target: {at, ok, error, why}}`: how its last backup to each went). A run of no such name is `404`. diff --git a/docs/user/web.md b/docs/user/web.md index 7b3598a5..ba8caa79 100644 --- a/docs/user/web.md +++ b/docs/user/web.md @@ -57,7 +57,7 @@ lists its chats, and an epic opens out into its agents and the sessions each ope | Where | What it shows | What you can do there | | --- | --- | --- | -| **Sidebar** | **New epic**, **Search epics**, **Flows**, then a folder per directory -- every one an epic ran in or that was opened here -- with its epics newest first: how each ended, or `live` | Open an epic; press its arrow to open it out into its agents, and each agent into its sessions; **+** on a folder starts an epic there | +| **Sidebar** | **New epic**, **Search epics**, **Flows**, then a folder per directory -- every one an epic ran in or that was opened here -- with its epics newest first: how each ended, or `live`; `●` without `live` is one another process goes with, such as `hmz exec`, read as it writes itself down | Open an epic; press its arrow to open it out into its agents, and each agent into its sessions; **+** on a folder starts an epic there | | **New epic** | A box for the task, the directory and the flow picked at the top as a chat app picks a model | Start it; **Options** on the box sets the rest up. See [Starting an epic](#starting-an-epic) | | **An epic** | Its graph: who handed to whom -- a `chat` opens on its conversation instead | Read it, steer it while it goes, export it, pick it up. See [An epic](#an-epic) | | **A session** | Its thread, as the terminal draws it | Say a line into it, or answer what it asks, while the epic goes | diff --git a/specs/runtime/SPEC.md b/specs/runtime/SPEC.md index 6d2f6946..1d8d8c53 100644 --- a/specs/runtime/SPEC.md +++ b/specs/runtime/SPEC.md @@ -219,6 +219,7 @@ def where(epic: Path, session: Session) -> Path: ... def logs(epic: Path, session: Session) -> dict[str, Path]: ... def picks_up(epic: Path) -> bool: ... def foreign(epic: Path) -> str: ... # why an imported run cannot be picked up here, or "" +def going(epic: Path) -> bool: ... # whether its run is held now, by this process or another def state(epic: Path, flow: str = "") -> dict[str, Any]: ... # flow by canonical ref def resumed(flow: str, workspace: Path | str | None = None) -> Path | None: ... BACKED = "backed" # the line saying how one backup of an epic went diff --git a/specs/runtime/doing.md b/specs/runtime/doing.md index c8c71c6d..9b3370a0 100644 --- a/specs/runtime/doing.md +++ b/specs/runtime/doing.md @@ -119,6 +119,7 @@ class Epics: def resumed(self, flow: str) -> Path | None: ... def picks_up(self, epic: Path) -> bool: ... def foreign(self, epic: Path) -> str: ... + def going(self, epic: Path) -> bool: ... def traced( self, epic: Path, diff --git a/src/hmz/runtime/doing/epics.py b/src/hmz/runtime/doing/epics.py index 1a265707..645323ef 100644 --- a/src/hmz/runtime/doing/epics.py +++ b/src/hmz/runtime/doing/epics.py @@ -103,6 +103,12 @@ def foreign(self, epic: Path) -> str: return foreign(epic) + def going(self, epic: Path) -> bool: + """Whether one epic's run is still going, in this process or any other.""" + from hmz.runtime.epic import going + + return going(epic) + def state(self, epic: Path, flow: str = "") -> dict[str, Any]: """What a resumable flow kept in one run: the run's own flow, or one it called by ref.""" from hmz.runtime.epic import state diff --git a/src/hmz/runtime/epic.py b/src/hmz/runtime/epic.py index 8540fadd..b5311c5e 100644 --- a/src/hmz/runtime/epic.py +++ b/src/hmz/runtime/epic.py @@ -94,6 +94,7 @@ "called", "epics", "foreign", + "going", "harbor", "harnessed", "logs", @@ -487,7 +488,7 @@ def picks_up(epic: Path) -> bool: and for one imported from a workspace this machine has not got (:func:`foreign`). """ return ( - not _going(epic) + not going(epic) and any(one.get("t") == "call" for one in _journal(epic)) and not foreign(epic) ) @@ -586,7 +587,7 @@ def _hold(at: Path) -> int | None: return held -def _going(epic: Path) -> bool: +def going(epic: Path) -> bool: """Whether the run one epic is of is still going, here or in any other process. Args: diff --git a/src/hmz/tui/pick.py b/src/hmz/tui/pick.py index 65d3bd54..f5a001fd 100644 --- a/src/hmz/tui/pick.py +++ b/src/hmz/tui/pick.py @@ -10041,9 +10041,12 @@ def _how(ran: Ran) -> str: ran: The run. Returns: - What became of it, in words: a run with no end written down is one that was abandoned - where it stood -- the machine it was on went, or the interface came down under it. + What became of it, in words: a run with no end written down is still going where a + process holds it -- this one, a host, an `hmz exec` -- and otherwise one that was + abandoned where it stood: the machine it was on went, or the interface came down under it. """ + if not ran.how and _hmz().epics.going(ran.at): + return "is still going" return { "done": "finished", "failed": "failed", diff --git a/src/hmz/web/runs.py b/src/hmz/web/runs.py index e1c780ef..80d95e37 100644 --- a/src/hmz/web/runs.py +++ b/src/hmz/web/runs.py @@ -141,6 +141,16 @@ def named(self, name: str) -> Ran: return ran raise Refusal(f"There is no epic {name}.", 404) + def going(self, ran: Ran) -> bool: + """Whether a run with no end written down is still going, in whatever process holds it. + + The host says which run it holds; a run another process holds -- `hmz exec`, a + terminal interface holding its runs itself -- says so only by holding its epic. + """ + from hmz.runtime.epic import going + + return not ran.how and going(ran.at) + def hmz(self, ran: Ran) -> Any: """humanize, for the workspace a run was run in -- there still or not.""" return self._places.of(ran.workspace or str(self._places.here), made=True).hmz @@ -307,8 +317,14 @@ def held_by(runs: list[Ran], running: dict[str, dict[str, Any]]) -> set[Path]: return held -def row(ran: Ran, *, held: bool) -> dict[str, Any]: - """One run as the list says it.""" +def row(ran: Ran, *, held: bool, going: bool = False) -> dict[str, Any]: + """One run as the list says it. + + Args: + ran: The run. + held: Whether it is the run a host is going with, which the page follows live. + going: Whether it is going at all, held by a host or by any other process. + """ first = ran.task.strip().splitlines()[0] if ran.task.strip() else "" return { "name": ran.name, @@ -317,7 +333,7 @@ def row(ran: Ran, *, held: bool) -> dict[str, Any]: "task": first[:_TOLD] + ("…" if len(first) > _TOLD else ""), "began": ran.began, "ended": ran.ended, - "how": ran.how or (RUNNING if held else UNFINISHED), + "how": ran.how or (RUNNING if held or going else UNFINISHED), "agents": [{"role": one.agent, "runs": one.spec} for one in ran.agents], "sessions": len(ran.sessions), "resumable": ran.resumable, @@ -346,17 +362,18 @@ def labelled(spent: dict[str, Any] | None) -> dict[str, Any] | None: def detail( - ran: Ran, *, held: bool, picks_up: bool, foreign: str = "" + ran: Ran, *, held: bool, picks_up: bool, foreign: str = "", going: bool = False ) -> dict[str, Any]: """One run, whole, as its own page says it. Args: ran: The run. - held: Whether it is the run going. + held: Whether it is the run a host is going with. + going: Whether it is going at all, held by a host or by any other process. picks_up: Whether it can be picked up. foreign: Why one imported cannot be picked up here, or "". """ - return row(ran, held=held) | { + return row(ran, held=held, going=going) | { "at": str(ran.at), "task": ran.task, "ref": ran.ref, @@ -555,7 +572,9 @@ def _listed(asked: Asked) -> dict[str, Any]: status = asked.query.get("status", "") flow = asked.query.get("flow", "") wanted = asked.query.get("q", "").casefold() - rows = [row(ran, held=ran.at in held) for ran in runs] + rows = [ + row(ran, held=ran.at in held, going=asked.site.runs.going(ran)) for ran in runs + ] counts: dict[str, int] = {} for one in rows: counts[one["how"]] = counts.get(one["how"], 0) + 1 @@ -593,9 +612,13 @@ def _one(asked: Asked) -> dict[str, Any]: hmz = runs.hmz(ran) picks_up = hmz.epics.picks_up(ran.at) foreign = hmz.epics.foreign(ran.at) - return detail(ran, held=ran.at in held, picks_up=picks_up, foreign=foreign) | { - "calls": calls(hmz.epics.tree(ran.at)) - } + return detail( + ran, + held=ran.at in held, + picks_up=picks_up, + foreign=foreign, + going=runs.going(ran), + ) | {"calls": calls(hmz.epics.tree(ran.at))} def _trace(asked: Asked) -> dict[str, Any]: diff --git a/src/hmz/web/static/sidebar.js b/src/hmz/web/static/sidebar.js index ce77d3bf..479e45a8 100644 --- a/src/hmz/web/static/sidebar.js +++ b/src/hmz/web/static/sidebar.js @@ -318,7 +318,9 @@ export function mountSidebar(root, context, where) { /** What can be done to one epic, from wherever it is listed. */ export function epicMenu(run) { const base = `#/epics/${encodeURIComponent(run.name)}` - const resumes = run.resumable && !run.held && run.how !== 'done' + // Going, whether a host holds it or another process does -- `hmz exec`, say. + const going = run.held || run.how === 'running' + const resumes = run.resumable && !going && run.how !== 'done' return [ { head: run.task.split('\n')[0] }, { label: 'Open', glyph: 'open', href: base }, @@ -330,7 +332,7 @@ export function epicMenu(run) { { label: 'Copy link', glyph: 'link', act: () => copy(`${location.origin}/${base}`, 'Link copied') }, { label: 'Copy task', glyph: 'copy', act: () => copy(run.task, 'Task copied') }, { label: 'Copy name', glyph: 'copy', act: () => copy(run.name, 'Name copied') }, - run.held ? null : { label: 'Export', glyph: 'download', act: () => (location.href = `/api/runs/${encodeURIComponent(run.name)}/bundle`) }, + going ? null : { label: 'Export', glyph: 'download', act: () => (location.href = `/api/runs/${encodeURIComponent(run.name)}/bundle`) }, { label: 'Upload', glyph: 'up', title: backedSaid(run.backed), act: () => upload(run.name) }, run.held ? '-' : null, run.held ? { label: 'Stop', glyph: 'stop', danger: true, act: () => ask(run.workspace, 'stop').then(() => toast('Stopping'), (error) => toast(error.message)) } : null, diff --git a/src/hmz/web/static/views/search.js b/src/hmz/web/static/views/search.js index 90084c60..c9fbca0f 100644 --- a/src/hmz/web/static/views/search.js +++ b/src/hmz/web/static/views/search.js @@ -119,7 +119,7 @@ export function mount(root, { query }) { h('span', { class: 'mono' }, run.spent ? `${run.spent.money || '—'} · ${run.spent.tokens} out` : '—'), run.ended ? h('span', { class: 'mono' }, `took ${lasting((Date.parse(run.ended) - Date.parse(run.began)) / 1000)}`) : null, h('span', { class: 'mono' }, `${run.sessions} session${run.sessions === 1 ? '' : 's'}`), - run.resumable && !run.held && run.how !== 'done' ? h('span', { class: 'node-tag' }, 'Resumable') : null, + run.resumable && !run.held && run.how !== 'running' && run.how !== 'done' ? h('span', { class: 'node-tag' }, 'Resumable') : null, ), ), () => epicMenu(run)), ), diff --git a/tests/integration/test_tui_epics.py b/tests/integration/test_tui_epics.py index 5d82be7a..3549a3a8 100644 --- a/tests/integration/test_tui_epics.py +++ b/tests/integration/test_tui_epics.py @@ -77,6 +77,19 @@ async def test_a_run_stopped_is_listed_with_its_task_and_how_it_ended( assert rows(app) == [one.name for one in epics(workspace)] +async def test_a_run_another_process_holds_is_listed_as_still_going( + workspace: Path, +) -> None: + # Held as `hmz exec` holds the run it goes with, which no host here speaks for. + with Epic("chat", "elsewhere", workspace): + async with Humanize().run_test(size=SIZE) as pilot: + await opened(pilot) + await typed(pilot, "/epics") + await on(pilot, Epics) + + await shows(pilot, "elsewhere · 0 sessions · is still going") + + async def test_a_run_exported_from_epics_is_an_archive_where_it_was_asked_for( workspace: Path, ) -> None: diff --git a/tests/integration/test_web_runs.py b/tests/integration/test_web_runs.py index ce9a7f86..474d007b 100644 --- a/tests/integration/test_web_runs.py +++ b/tests/integration/test_web_runs.py @@ -9,6 +9,7 @@ import pytest from hmz import machine +from hmz.runtime.epic import Epic from tests.integration.doubles_core import AGENT, install, started from tests.integration.doubles_web import Browser, elsewhere, served, until @@ -201,6 +202,24 @@ def _chatted(browser: Browser, task: str) -> None: until(heard, _record("ended"), "the run ending") +def test_an_epic_another_process_goes_with_is_listed_as_running(project: Path) -> None: + # Held as `hmz exec` holds the run it goes with, which no host here speaks for. + with Epic("chat", "elsewhere", project) as held, served() as site: + browser = Browser(site) + browser.signs_in() + + (ran,) = browser.get("/api/runs").json()["runs"] + assert (ran["how"], ran["held"]) == ("running", False) + one = browser.get(f"/api/runs/{held.path.name}").json() + assert one["how"] == "running" + + with served() as site: + browser = Browser(site) + browser.signs_in() + (ran,) = browser.get("/api/runs").json()["runs"] + assert ran["how"] == "done" + + def test_an_epic_written_down_is_read_back_out_of_the_logs_it_kept( project: Path, ) -> None: diff --git a/tests/unit/runtime/doing/test_epics.py b/tests/unit/runtime/doing/test_epics.py index d71d59be..8234a6ab 100644 --- a/tests/unit/runtime/doing/test_epics.py +++ b/tests/unit/runtime/doing/test_epics.py @@ -66,6 +66,14 @@ def test_the_runs_of_a_workspace_are_read_back(workspace: Path) -> None: assert epics.state(second, "official/other") == {} +def test_a_run_reads_as_going_only_while_it_is_held(workspace: Path) -> None: + epics = Epics(workspace) + with Epic("ralph", "t", workspace) as held: + assert epics.going(held.path) is True + + assert epics.going(held.path) is False + + def test_every_run_on_this_machine_is_read_back_oldest_first( workspace: Path, tmp_path: Path ) -> None: diff --git a/tests/unit/runtime/test_epic.py b/tests/unit/runtime/test_epic.py index ee0ac153..25d4c22b 100644 --- a/tests/unit/runtime/test_epic.py +++ b/tests/unit/runtime/test_epic.py @@ -722,6 +722,14 @@ def test_a_run_still_going_cannot_be_picked_up(workspace: Path) -> None: assert epic.picks_up(going.path) is True +def test_a_run_is_going_while_its_epic_is_held(workspace: Path) -> None: + with Epic("f", "t", workspace) as held: + assert epic.going(held.path) is True + + assert epic.going(held.path) is False + assert epic.going(workspace / "never") is False + + def test_what_a_flow_kept_is_read_back_key_by_key(tmp_path: Path) -> None: _journal( tmp_path / RESUME, diff --git a/tests/unit/web/test_runs.py b/tests/unit/web/test_runs.py index 4a17bdce..5412c8ad 100644 --- a/tests/unit/web/test_runs.py +++ b/tests/unit/web/test_runs.py @@ -8,7 +8,7 @@ import pytest -from hmz.web.runs import held_by, refused, sessions_of, spending +from hmz.web.runs import held_by, refused, row, sessions_of, spending if TYPE_CHECKING: from hmz.runtime.epic import Ran @@ -174,3 +174,43 @@ def test_an_import_refused_is_a_conflict_naming_the_epic_only_where_it_is_here( assert refusal.status == status assert str(refusal) == f"{said}." assert refusal.said.get("name") == named + + +@dataclass(frozen=True) +class _Row: + """As much of a run written down as a row of the list reads.""" + + how: str = "" + name: str = "20261012T135159.936Z-bc3e81" + workspace: str = "/work/one" + flow: str = "ralph" + task: str = "fix it" + began: str = "2026-10-08T10:00:00+00:00" + ended: str = "" + agents: tuple[Any, ...] = () + sessions: tuple[Any, ...] = () + resumable: bool = False + spent: dict[str, Any] | None = None + backup: tuple[str, ...] = () + backed: dict[str, Any] | None = None + + +@pytest.mark.parametrize( + ("how", "held", "going", "said"), + [ + ("", True, True, "running"), + # Held by a process no host speaks for -- `hmz exec` -- and going all the same. + ("", False, True, "running"), + ("", False, False, "unfinished"), + ("done", False, False, "done"), + ], +) +def test_a_run_is_said_running_while_any_process_holds_it( + how: str, held: bool, going: bool, said: str +) -> None: + ran = cast("Ran", _Row(how=how)) + + listed = row(ran, held=held, going=going) + + assert listed["how"] == said + assert listed["held"] is held