Skip to content
Draft
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 cargento/skills/cargento/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -316,7 +316,7 @@ Paths 2 and 3 are complementary and can both be installed. Keep `Notification` o
- **"This request" ETA** = per-session current-turn estimate shown while Working. Estimated total = median of that session's past turns that lasted at least as long as the current one has so far. Turn boundaries: user prompt → last event before the next prompt (Claude, Gemini, Droid); active-branch user messages for Pi; explicit start/end events (Codex `task_started`/`task_complete`, Copilot `user.message`/`session.task_complete`); or DB message timestamps (OpenCode, Goose). JSONL harnesses use an incremental whole-file scanner (survives turns longer than the transcript tail). Pi retains the latest 50 completed durations. No ETA for Cursor. "running longer than recent turns" = no past turn was this long. Naive by design. A ⚠️ appears when elapsed or estimated total ≥ 15 min (`LONG_TURN_WARN_SEC`). Elapsed measures generation, not waiting: a mid-turn quiet stretch longer than 5 minutes (`TURN_GAP_RESET_SEC` — permission prompt, open question, sleep) re-anchors the clock at the post-gap event.
- **Loop detection** = four tool calls in a row (`LOOP_ERROR_RUN_THRESHOLD`) coming back as errors inside one request. **Claude Code only**, because Claude is the only harness that records whether a tool call failed: Codex's tool-output records carry no error field, Copilot writes no tool-end record at all, and Droid's records look right but no failing Droid call has been captured, so nothing is inferred for any of them and their rows never carry the signal. It reads the transcript, never the tool's input, so no command text is held anywhere. Four is measured rather than picked: across the 25 most recent local transcripts, runs of three and runs of four each appeared in the same single session, and five in none — so four costs no detections and keeps clear of the benign runs (an `ls` that found nothing, a `git` in a deleted worktree) that filled the sample. The count is the longest run inside the current request and it clears at your next prompt, not when the failures stop, since a loop that has just gone quiet is what you walked back to the machine to find. It raises no flag of its own — the `long turn` chip and the ⚠️ still fire on duration alone, and a loop only changes what they say when you reach for them, which is the difference between "it is slow" and "it is repeating a failure". Where the request is not long enough for either, the working card and the calm detail panel say it anyway. The pattern is honest but not proof: iterating on a failing test looks the same from outside.
- **Est. remaining** (per tracked-task session) = average duration of that session's completed tasks × open task count. Naive by design; "no estimate" until a session has a completed task that took ≥30s.
- **Spacedock stage strip** = one line per in-flight entity, showing that workflow's stages in declaration order with the entity's current stage highlighted. A bold entity name means a worker for it is running now; a plain one is read from the entity's state file or, failing that, the boot snapshot. Only Claude reports workers, so a Pi strip is never bold. In flight means moving: an entity resting on the initial stage or a terminal one is left out unless boot called it dispatchable, so a queue of thirty waiting on `intake` does not crowd out the two being worked. Entities whose state file has not been touched inside the freshness window are history, not work, and are skipped — that is what keeps a long-retired workflow off the card of a first officer that merely discovered it. A long workflow is windowed around the current stage, with `…` standing in for the stages elided. An over-long entity name is elided in the **middle**, never the tail — entities in one workflow share a long prefix and differ only at the end — and hovering it shows the full slug. No strip appears when the boot output is outside the scanned head of a long transcript, when the workflow README cannot be read, or when its frontmatter uses a construct the reader does not model — it renders nothing rather than a guess.
- **Spacedock stage strip** = one line per in-flight entity, showing that workflow's stages in declaration order with the entity's current stage highlighted. A bold entity name means a worker for it is running now; a plain one is read from the entity's state file or, failing that, the boot snapshot. Only Claude reports workers, so a Pi strip is never bold. In flight means moving: an entity resting on the initial stage or a terminal one is left out unless boot called it dispatchable, so a queue of thirty waiting on `intake` does not crowd out the two being worked. Entities whose state file has not been touched inside the freshness window are history, not work, and are skipped — that is what keeps a long-retired workflow off the card of a first officer that merely discovered it. A long workflow is windowed around the current stage, with `…` standing in for the stages elided. An over-long entity name is elided in the **middle**, never the tail — entities in one workflow share a long prefix and differ only at the end — and hovering it shows the full slug. No strip appears when the workflow README cannot be read, or when its frontmatter uses a construct the reader does not model — it renders nothing rather than a guess. On a long transcript the strip can take a few refreshes to arrive rather than appearing on the first: the reader walks the transcript forward a bounded slice at a time looking for the boot output, which lands at the start of a session launched as a first officer and wherever the conversation had reached when one adopts the role mid-way.
- **Session title** = the harness's own generated title where it writes one (Claude records these, and they read like "Debug Spacedock workflow steps not displaying"), otherwise a prompt from the session itself — the **opening** one on Claude and Pi, which is what the great majority of titled rows fall back to, and the **newest** one only on Codex, which writes no generated title at all. That difference is why the line beneath the title exists: an opening prompt names the session durably and goes stale as work moves on. It is cleaned up rather than shown raw: a slash command reads as `/plugin` instead of the markup the harness wrapped it in, a dispatched worker's prompt shows the instruction instead of the envelope, absolute paths collapse to their last segment so the path does not eat the whole line, and an over-long title is cut on a word boundary. Relative paths and URLs are left whole, because the repo and PR number in a link are the informative part. Nothing is summarized by a model, so no session text leaves the machine.
- **The line beneath the title** (on the opt-in `http://127.0.0.1:4553/?next=true` UI) answers a different question from the title, which is why it is a second line and not a better first one. A title names the session; this names what it is working on now. It always carries a label and the age of the record it came from, because that is what tells you how much to trust it: `asked, 4m:` is the newest thing you told it, `agent, 4m:` is the agent's own statement of what it was starting this turn, and `earlier, 2h:` is an older instruction shown because the newest one was a bare "proceed" that names no work. Where none of those can be established honestly the line is absent and the row shows its title alone, which is the old behaviour: a confident wrong line would mask the project name underneath it for as long as the session lives. Claude keeps its generated title on line 1 even when that title has gone stale, because it is how you recognise the row. Codex shows no second line when its title is already that same newest instruction — not always in full, since 167 of 280 local Codex titles are cut at the title width and 144 of those suppress the line while holding back a median 56 further characters. Repeating a prompt under itself costs a row two lines to say one thing, and the fuller reading is a keystroke away in the session panel.
- **Project** = the last two segments of the session's working directory (`spacedock/subspace`), the same rule on every harness so one directory reads identically whichever agent opened it. Bare basename is not enough: sibling worktrees are routinely all named the same thing. Claude is the only harness whose store does not hand over a path — its `projects/` directory name encodes one with every separator replaced by `-`, which cannot be split back apart — so the real working directory is read from the transcript records, and a transcript too young to carry one falls back to that encoded name whole. Cursor reads it from the `meta.json` beside the chat store, and falls back to the harness name when that file records nothing that resolves to a real directory. Two or more sessions on one label that are working or waiting on you are marked `N live` wherever that label is printed — the collision two agents in one checkout cause, which grouping alone left you to spot. The marker claims the label and not the directory: the label is the last two segments, so sibling worktrees read alike, and the tooltip says as much. A label that is only a harness name is never marked, because that is what a collector falls back to when no directory resolved at all — two Cursor windows with unreadable workspaces share the word `cursor` and nothing else. Idle sessions are not counted, and blocked ones are: a stopped session writes nothing, while answering a gate is exactly what sets the other half of a collision writing again.
Expand Down
85 changes: 62 additions & 23 deletions cargento/skills/cargento/cargento_runtime/spacedock.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@
import os
import re
import stat as stat_module
from typing import TYPE_CHECKING, Any, TypeGuard
from typing import IO, TYPE_CHECKING, Any, TypeGuard

from cargento_runtime import records, sessions
from cargento_runtime import state as runtime_state
Expand Down Expand Up @@ -363,34 +363,73 @@ def boot_entity_dir(envelopes: list[dict[str, Any]], workflow_dir: str) -> str:


def transcript_boot(config: RuntimeConfig, state: RuntimeState, path: str) -> list[dict[str, Any]]:
"""Boot envelopes from a transcript's head, cached per (path, size).

Boot output is written once at session start and never rewritten, so the
scan is amortised: keying on size lets a still-growing session pick the
envelope up on a later refresh without rescanning an unchanged prefix.
"""Boot envelopes from a transcript, scanned forward across refreshes.

An earlier version read only the first ``spacedock_boot_scan_bytes`` on the
stated assumption that boot output "is written once at session start". That
holds for a first officer launched as the agent, and not for a session that
adopts the role mid-conversation by loading the skill: boot then lands
wherever the conversation had reached. Measured on a real transcript, the
envelope sat 3.3 MB into a 7.0 MB file, so the strip never appeared.

So the cursor advances instead of the window widening. Each pass reads about
one budget's worth of whole lines from where the last pass stopped and keeps
what it found, which makes the total cost once per byte rather than once per
byte per refresh. Whole lines because a record split across two passes would
parse as neither, and read through ``readline`` rather than a fixed slice
because a tool result carrying an envelope can be longer than the budget on
its own and a fixed slice would stall the cursor on it forever.

Serialized via the scanner lock, like ``turns.scan_turns``: two concurrent
/api/data requests would otherwise both advance the cursor and each parse
half the new bytes.
"""
try:
size = os.path.getsize(path)
except OSError:
return []
key = (path, min(size, config.spacedock_boot_scan_bytes))
with state.cache_lock:
cached = state.spacedock_boot_cache.get(key)
if cached is not None:
return cached
envelope_records: list[dict[str, Any]] = []
try:
with open(path, "rb") as handle:
blob = handle.read(config.spacedock_boot_scan_bytes)
with state.scanner_lock:
scan = state.spacedock_boot_scan.get(path)
if scan is None or int(scan["pos"]) > size: # new, truncated or rotated
scan = {"pos": 0, "envelopes": []}
runtime_state.bounded_put(
state.spacedock_boot_scan, path, scan, limit=config.max_cache_entries
)
envelopes: list[dict[str, Any]] = scan["envelopes"]
if int(scan["pos"]) >= size or len(envelopes) >= config.spacedock_max_boot_records:
# Nothing appended, or the record cap is already met. Leaving the
# cursor where it is keeps a settled transcript at one `stat`.
return list(envelopes)
try:
with open(path, "rb") as handle:
handle.seek(int(scan["pos"]))
blob = _boot_pass_bytes(config, handle)
except OSError:
return list(envelopes)
scan["pos"] = int(scan["pos"]) + len(blob)
if b"definition_dir" in blob:
envelope_records = boot_records(config, blob)
except OSError:
return []
with state.cache_lock:
runtime_state.bounded_put(
state.spacedock_boot_cache, key, envelope_records, limit=config.max_cache_entries
)
return envelope_records
room = config.spacedock_max_boot_records - len(envelopes)
envelopes.extend(boot_records(config, blob)[:room])
return list(envelopes)


def _boot_pass_bytes(config: RuntimeConfig, handle: IO[bytes]) -> bytes:
"""One pass of complete lines, stopping at or just past the byte budget.

A trailing partial line is left unread so the next pass sees it whole. The
budget is therefore a floor on where a pass stops rather than a ceiling on
what it reads: one line longer than the budget is read whole, because the
alternative is never reading it.
"""
lines: list[bytes] = []
read = 0
while read < config.spacedock_boot_scan_bytes:
line = handle.readline()
if not line.endswith(b"\n"):
break # EOF, or a line still being written
lines.append(line)
read += len(line)
return b"".join(lines)


def open_regular(path: str) -> int | None:
Expand Down
4 changes: 3 additions & 1 deletion cargento/skills/cargento/cargento_runtime/state.py
Original file line number Diff line number Diff line change
Expand Up @@ -86,7 +86,9 @@ class RuntimeState:
turn_scan: dict[str, Any] = field(default_factory=dict)
agent_class_cache: dict[str, tuple[bool, str, str]] = field(default_factory=dict)
spacedock_role_cache: dict[str, str] = field(default_factory=dict)
spacedock_boot_cache: dict[tuple[str, int], list[dict[str, Any]]] = field(default_factory=dict)
# {"pos": int, "envelopes": list} per transcript, advanced under scanner_lock.
# A cursor rather than a keyed cache: see S-6 in docs/design-spacedock.md.
spacedock_boot_scan: dict[str, dict[str, Any]] = field(default_factory=dict)
spacedock_workflow_cache: dict[tuple[str, int, int], dict[str, Any] | None] = field(
default_factory=dict
)
Expand Down
2 changes: 1 addition & 1 deletion cargento/skills/cargento/tests/support.py
Original file line number Diff line number Diff line change
Expand Up @@ -304,13 +304,13 @@ def clear_state(state: RuntimeState) -> None:
state.cwd_cache.clear()
state.agent_class_cache.clear()
state.spacedock_role_cache.clear()
state.spacedock_boot_cache.clear()
state.spacedock_workflow_cache.clear()
state.spacedock_entity_cache.clear()
state.cursor_metadata_cache.clear()
with state.scanner_lock:
state.pi_scan.clear()
state.turn_scan.clear()
state.spacedock_boot_scan.clear()
with state.collect_memo_lock:
state.snapshot.clear()
with state.usage_fetch_lock:
Expand Down
2 changes: 1 addition & 1 deletion cargento/skills/cargento/tests/test_config_diagnostics.py
Original file line number Diff line number Diff line change
Expand Up @@ -448,7 +448,7 @@ def test_runtime_states_retain_start_times_and_isolate_mutable_fields(self) -> N
"turn_scan",
"agent_class_cache",
"spacedock_role_cache",
"spacedock_boot_cache",
"spacedock_boot_scan",
"spacedock_workflow_cache",
"spacedock_entity_cache",
"cursor_metadata_cache",
Expand Down
Loading