<!-- Authored per the the-loop:writing skill. -->
Bugfix spec: the-loop graph status <ref> reports a stale node for a daemon-run item
Phase 1 of 4 (bugfix → design → testing plan → tasks). Source: the 2026-09-19 e2e Slack run's B7 and O6 (
docs/reports/e2e-slack-test-2026-09-19.md), left out of issue-393 and filed as issue-396.
Summary
graph status never read the state file at all, and said nothing about it. Two independent misses, each of which lands the command on the same silent fallback:
- The command names a work item by its spec-directory id, never by its ref. The report ran
graph status github:…#1. The runtime built the spec directory from the argument verbatim —docs/specs/github:…#1/— a directory that does not exist, soWorkItemState.loadreturned a fresh state and the pointer fell back to the graph's start node,phase-selection. This is why the answer was wrong even from inside the work item's own checkout, wheredocs/specs/issue-1/work-item-state.jsonwas current: the file was one directory over from where the command looked. --repodefaults to.From the daemon's config directory (O6) there is nodocs/specs/issue-1/at all, so the same fallback fires with a bare id too.
In both cases the output is indistinguishable from a work item that genuinely sits at phase-selection. The daemon, meanwhile, writes the state file into the session's checkout (GraphLink._build_runtime roots the runtime at the session's cwd, a per-work-item worktree under routing.workspace) — the one place graph status had no way to find from another directory.
Steps to reproduce
- Drive a work item through the daemon until
graph.advancedrecords a node pastphase-selection(any node will do). - From the daemon's config directory:
the-loop --config <daemon config> graph status github:<owner>/<repo>#<n>. - From inside the work item's checkout: the same command.
Expected vs actual
- Expected: both print the node the runtime last wrote, and say which
work-item-state.jsonthey read. - Actual: both print
at phase-selection · waiting for an authorized user to choose the phases, with no path and no hint that nothing was read.
Root cause (confirmed)
core.graphs.check→Runtime.status(work_item_id)→WorkItemState.load(...)returnsWorkItemState(work_item=…)with an emptycurrent_nodewhen no file exists;statusthen substitutesgraph.start. Correct for a fresh item, silent for a wrong path.graphlink.spec_id_foris the only ref → id translation in the tree and it runs on the ingress path alone; the CLI's positional argument is passed through untranslated.- The session registry (
<state.root>/local/<slug>.json) records each work item's sessioncwd— the checkout the daemon drives — andsessions listreads it;graph statusdid not.
Requirements
Requirement 1 — the position reported is the one the runtime wrote
User story: As an operator debugging from a shell, I want graph status <ref> to report the node the daemon's runtime last recorded, from whichever directory I run it, so that the one read-only view of "where is this item" is not misleading.
Acceptance criteria (EARS)
- WHEN
graph statusorcheckis given a work-item ref (github:<owner>/<repo>#<n>, optionally host-qualified) THEN the CLI SHALL address the same spec directory the daemon does for that ref (issue-<n>), exactly asgraphlink.spec_id_forderives it; a bare id SHALL keep working unchanged, and a ref of another provider SHALL be passed through as it is today. - WHEN
graph status <ref>orcheck <ref>is run without--repofrom a directory that holds no spec directory for the work item, AND the session registry the CLI config resolves (routing.registryDir, else<state.root>/local/) records a session for that ref whosecwdis a directory THEN the command SHALL evaluate the work item in that checkout and SHALL print the checkout it resolved, naming the registry as its source. - WHEN
--repois given THEN it SHALL be used verbatim; the registry SHALL NOT be consulted. WHEN the current directory holds the spec directory THEN it SHALL be used; the registry SHALL NOT be consulted. - The same translation (R1.1) SHALL apply to every graph verb that takes the positional work item (
advance,complete,run,skip,force,repos), so a ref names one directory everywhere. The checkout resolution (R1.2) SHALL apply to the read verbs only —graph statusandcheck— because a mutating verb writes into the checkout it is pointed at, and that choice stays the operator's (--repoor the working directory).
Requirement 2 — a wrong answer is diagnosable
User story: As an operator, when the command could not find the state file, I want it to say which path it looked at, so that a stale answer is visible as one.
Acceptance criteria (EARS)
- WHEN
graph statusorcheck <work item>renders its table THEN it SHALL print onestate:line naming thework-item-state.jsonit read; WHEN no such file exists THEN the line SHALL saynot foundand, when the work item's state directory itself is absent, SHALL say so and point at--repo/ the work item's checkout. - The core
checkreport (and soPOST /api/v1/graph/checkand thecheck_work_itemMCP tool) SHALL carrystatePath(the file read, else the path that was looked for) andstateFound(boolean) beside its existing keys — additive; the position-unknown answer of issue-238 (repoResolved: false) SHALL stay exactly the six keys it has, naming no path. --format jsonofcheckSHALL carry the two fields;check --allSHALL NOT gain a per-itemstate:line (its table is a drift summary).- The fix SHALL include regression tests that fail before the fix and pass after: a ref resolves to the daemon's spec directory; the report names the state file; a ref from a foreign directory resolves the checkout through the registry; a bare id from a foreign directory prints the not-found path with its hint.
Security considerations
- Actors & trust: the operator at a shell, on the machine that holds the CLI config and the session registry.
check/statusstay pure (no network, no subprocess, no mutation): the registry read is a JSON file the same process already reads forsessions list, through the sameSessionRegistry. - New attack surface: none. The ref → id translation goes through
WorkItemRef.parse(anintnumber) andspec_id_for, so a crafted argument cannot name a path segment it could not name before — the derived id isissue-<int>. The registry'scwdis a path this machine's daemon recorded when it spawned the session (already trusted bysessions resume/attach); it is used only if it is an existing directory, andresolve_repore-vets it at core's boundary as it vets--repo. - Information disclosure:
statePathis a path under a repository the caller named (or the registry resolved for them); the issue-238 rule — a repo that does not resolve is answered with no path at all — is unchanged and pinned by its existing test. The API's caller is on the loopback the service already answers with checkout contents. - Fail-closed: an unparsable ref, a missing registry, an unreadable record or a
cwdthat is not a directory all fall through to today's behaviour — the current directory — now with thestate:line making the miss visible.
Out of scope
- Reading a portable record or the daemon's
state.rootfor the graph pointer: neither holds it (issue-368 moved the graph section out of the portable record;work-item-state.jsonin the checkout is the one record). - Resolving the checkout for the mutating verbs (R1.4 says why).
- A GitHub URL as the positional (
instance.scope.workItemsaccepts one); the report used a ref, and the URL form is a separate convenience.
Open questions
None.