fix(harness): reach the graph fast path on a real install — take over #731 [SAP-2978] - #741
fix(harness): reach the graph fast path on a real install — take over #731 [SAP-2978]#741gwitwer wants to merge 14 commits into
Conversation
Define the versioned @sapiom/agent inventory contract, move Studio graph identity enrichment behind it, and join path-bearing navigation through an atomic revisioned sidecar. Closes: SAP-2955
Refs: SAP-2955
Refs: SAP-2955
Refs: SAP-2955
Refs: SAP-2955
Refs: SAP-2955
…they settled Gating the graph cache on the inventory's `status` reproduced the all-or- nothing cliff it was meant to remove. The package-inventory contract requires `status: "degraded"` whenever any identity is provisional, so one agent that can never be named — a dashboard with no `defineAgent`, a package with no `node_modules` — vetoed the whole project's fast path. The graph re-projected on every open and never left `degraded`, which is the "Graph may be incomplete" banner. Cache on whether identity work has finished instead. `identity-unavailable`, `identity-invalid` and `duplicate-agent-key` have all settled: re-projecting cannot improve them, and a source edit re-projects through the watcher anyway. Only `identity-pending` still has enrichment in flight, and caching that would freeze provisional names on screen. Measured against a 76-agent, 9-project tree: the whole workspace and every project reach `ready` and serve `X-Sapiom-System-Graph-Cache: complete`, where `main` leaves four of them `degraded`. Breaking one agent's source mid-run takes that agent to a provisional key and leaves the other six cached and `ready`; on `main` the same edit degrades the whole project. The affected agents keep their `inventory-extraction-failed` warnings, so the graph is cached without going quiet about what it could not resolve. This is also what makes `system-graph-freshness.test.ts` pass on macOS, where it has been failing on `main`. Restoring the `status` gate reproduces its 18s timeout. The spec's bus assertion is now bound to the revision the read observed rather than to the state alone, so it fails when the broadcast goes quiet: stubbing `onChange` to a no-op times the wait out. Co-authored-by: Yash <yash@sapiom.ai>
Co-authored-by: Yash <yash@sapiom.ai>
The contract doc said an unresolved agent makes the snapshot degraded, which is now true only while its inspection is still pending. Co-authored-by: Yash <yash@sapiom.ai>
…ision Two agents that share a registry marker look identical only until their sources are read. Counting them as a duplicate while both are still pending reports `duplicate-agent-key` — an issue the contract treats as settled — so the projection above it declared the identities finished and cached a graph whose names were still guesses, then enrichment retracted the warning a moment later. Duplicate detection now considers only agents whose identity has resolved. Restoring pending agents to the counts fails the new spec. Co-authored-by: Yash <yash@sapiom.ai>
Review — PR #741 (round 1)🔒 ConfidentialityPublished surfaces are clean. Both changesets, One item outside the shipped surfaces: the PR body pastes Findings1. A superseded build silently drops
|
A build that loses its generation carries the only callback that starts identity work, and `reportRefreshFailure` supersedes an in-flight build with `refreshPending` already false and `automaticRetryUsed` already true — so nothing schedules a follow-up and no later read queues one. Dropping the callback there left every identity `identity-pending`, which can never be cacheable: the stuck-`degraded` state through another door. Arm it on both exits, after the commit decision rather than before it. A callback that refreshes synchronously would otherwise bump the generation out from under the very result being committed; both orderings are now guarded by a spec that fails when it is reversed.
`PackageInventoryAgent` is defined in terms of the identity-issue union and `staticSignals.payload` in terms of the JSON type, but neither name was exported. A consumer switching over the four provisional reasons — the point of the contract — had to re-declare the union by hand, and that copy would diverge silently the first time a protocol adds a reason. Export both while this is still the minor that introduces them.
The bounded retry existed to survive a read that straddled a graph commit, but it re-read as fast as the network answered, so all three attempts could land on the same pre-commit snapshot and the wait never happened. Back off between attempts, injectably so the specs stay fast. Also drops the `workspaceRelativeLocalKey` re-export that no production caller survived; its one test imports it from the shared module.
Review — PR #741 (round 2)Delta reviewed: Earlier findings
Round 1 got one thing wrong: the New findingsNone. Nits
VerdictApprove — all three round-1 findings are fixed, published prose in the delta is clean. |
|
Converting to draft — not because anything is wrong with it, but because it should not merge in this shape. 95% of this PR is one commit: The user-visible defect this PR incidentally fixes — the permanent "Graph may be incomplete" banner — is being landed separately as a narrow fix on What's genuinely useful here, kept for whoever lands the migration:
@ynadge — this is yours to land whenever you're ready. Nothing has been force-pushed to your branch; #731 is untouched. |
|
Thanks for pushing this investigation and its follow-up fixes forward. SAP-2955 is being consolidated from current The replacement keeps the useful fixes established here—settled-identity caching, pending-collision handling, enrichment after superseded builds, the public identity-issue type, and bounded navigation backoff—while keeping package inventory limited to identity/location and preserving the current right-pane, group-aware map. Per Yash's direction, I am leaving #741 open so he can coordinate with you directly. I have not changed this branch or its history. |
Primary change type
Problem and motivation
Studio's project dependency graph never reached its fast path on a real install.
Most registry rows carry no
definitionSlug, so the projector fell back to parsing each agent'ssource to recover its name, bounded at 4 concurrent / 5s for the whole project. Anything that missed
emitted
inventory-extraction-failed, andcacheablewasprepared.every((agent) => !agent.extractionFailed).One agent that could not be read made the entire graph non-cacheable — it re-projected from
scratch on every open and stayed
degraded, which is the "Graph may be incomplete" banner.Measured on a 76-agent / 9-project tree,
mainleaves the whole workspace and three of its projectspermanently
degraded, and pays 176–784ms of blocking source extraction on every cold open.This takes over #731 (SAP-2955, @ynadge), which is the fix for that, and had gone stale with merge
conflicts.
Same workspace, same 76 agents, same 11 projection warnings. Above:
main. Below: this branch.Opening the project, recorded
main— the banner is up the whole time:https://raw.githubusercontent.com/sapiom/sapiom-js/pr-media/media/741/open-project-before.webm
This branch — first open renders provisional nodes at once, settles to
ready, and the return visitis served from cache:
https://raw.githubusercontent.com/sapiom/sapiom-js/pr-media/media/741/open-project-after.webm
Full-window stills of the same two runs:
before ·
after
Summary and scope
Carried in from #731 (Yash's six commits, rebased onto
mainwith authorship preserved):@sapiom/agent(PACKAGE_INVENTORY_PROTOCOL,packageInventorySchema)that separates a canonical identity from a provisional one, and names why an identity is
provisional (
identity-pending,identity-unavailable,identity-invalid,duplicate-agent-key).bounded and batched, cached by source fingerprint.
GET /api/workspaces/:key/system-graph/navigation— revision-matched navigation targets, so thebrowser stops reconstructing paths from mutable state.
Added here, because the defect survived the takeover:
every(...)cliff had moved, not gone. feat(harness): migrate graphs to package inventory (SAP-2955) #731 replaced it withcacheable: inventory.status === "complete",and the package-inventory contract requires
status: "degraded"whenever any identity isprovisional. So one permanently unidentifiable agent still vetoed the whole project's cache — the
original bug with a new spelling. Cacheability now depends on whether identity work has finished,
not on how it finished:
identity-unavailable,identity-invalidandduplicate-agent-keyaresettled and cacheable; only
identity-pendingrefuses the cache, because caching mid-enrichmentwould freeze provisional names on screen.
staleassertion had become unsatisfiable under feat(harness): migrate graphs to package inventory (SAP-2955) #731 (a degraded projectionrefreshes with its lifecycle preserved and never re-enters
stale), and its lifecycle expectationswere relaxed to
degraded. Both are restored toready, and the bus assertion is now bound to therevision the read observed rather than to the state alone.
Not in scope: retargeting #732 (follow-up). No change to relationship detection, the Group axis,
or the rail.
One commit from #731 was dropped:
4ce82ae1 test(harness): use checkout-invariant root graph keyresolved to empty. It renamed a graph-node test id used by a rail→graph→node navigation detour that
#733 deleted; nothing of it survived the rebase.
Related work
Related issue or discussion: SAP-2978. Takes over #731 (SAP-2955).
Validation
Real-server verification
CI runs
VITE_MOCK=1, whose fixture has a handful of uniquely-named agents. Everything below wasmeasured against a local 76-agent / 9-project tree — duplicate names, agents with no
defineAgent,agents with no
node_modules— onmainand on this branch, each on its own port and its own--state-root. Project names below are anonymised.GET /api/workspaces/:key/system-graph, read after the projection settles:maindegraded/X-…-Cache: degradedready/completedegradedreadydegradedreadydegradedreadyreadyreadyreadyreadyEvery project is served from cache on the second open (revision stable, no re-projection). Cold-open
latency drops from blocking extraction to a provisional read: 784ms → 42ms for the 76-agent
workspace, 176–494ms → 11–51ms per project.
E1.3, forced mid-run. A 7-agent copy of one project, all canonical and
readyon both builds.Break one agent's source (invalid TypeScript in its entrypoint), then wait for the watcher:
Same broken agent, same warning. On
mainit takes the whole project down with it; here the othersix stay cached and
ready, and the broken one is named. Restoring the source returns it to acanonical identity and clears the warning (revision 11 → 15), so the cache does not go stale forever.
E1.4.
GET …/system-graph/navigationreturnsrevision: 5against a graph atrevision: 5, 12targets covering all 12 nodes,
Cache-Control: no-store. Onmainthat path falls through to theSPA and returns
text/html.E1.5.
system-graph-freshness.test.tsfails deterministically on macOS on cleanmain(18.2s,vi.waitFortimeout on the directory-rename step). It passes here, 5 runs for 5, in ~1.7s. It is notquarantined and no assertion was weakened — the opposite: it asserts
readywhere #731 had relaxed itto
degraded. Reverting only thesettledgate reproduces main's 18s timeout, which is the evidencethat this change is what fixes it.
Mutation tests. Every guard added here was proven by breaking the code under it:
settled: inventory.status === "complete"(the old cliff)settled: true(ignore pending)onChange→ no-op (bus goes quiet)packageInventorySchema.parseboundaryA mutation that did not fail, and what I did about it. #731 added
"uses the normalized inventory status even if a provider mutates its result", which mutates the
inventory object after
listAgentsresolves.buildconsumes the inventory in the same tick itreceives it, so that mutation can never land in between — removing the re-parse entirely left the
test green. It is replaced with one that hands the builder an inventory whose
statuscontradicts itsown identities and asserts the build is refused; that one does fail when the boundary is removed.
Tests and documentation
system-graph.test.ts: settled-but-provisional agent is cacheable and still warns; a pendingidentity refuses the cache; the contract boundary rejects a self-contradicting inventory.
system-graph-freshness.test.ts: first read is provisional anddegraded, settles toreadywithout the source ever becoming identifiable; bus announcement bound to its revision.
package-inventory.spec.ts,system-graph-inventory.test.ts,WorkspaceGraphView.test.ts,api.test.ts,system-graph-navigation.test.ts).packages/harness/docs/workspace-system-graph.mdupdated in the carried-in work.Compatibility and release impact
@sapiom/agentgains the package-inventorytypes and schema;
@sapiom/harnessgains the navigation route. A graph with an unreadable agent nowreports
readywith a per-agent warning instead ofdegradedfor the whole project — that is the fix..changeset/calm-graphs-navigate.md(@sapiom/harness) and.changeset/bright-graphs-navigate.md(@sapiom/agent).Security
will follow the
Security Policy for
private reporting.
AI assistance
Claude Code performed the rebase and conflict resolution, wrote the
settledcacheability change andits specs, and ran the verification above. Every claim in this description is a measured result: the
before/after tables come from paired runs of the same script against
mainand this branch, and eachnew guard was mutation-tested by breaking the code under it and confirming the spec failed.
Checklist
CONTRIBUTING.md, and this contribution follows the direct-PR or issue-first policy.any N/A checks above.
Review round 1 — what changed
Finding 1 (
afterCommitdropped on a superseded build) — fixed, and it was real.reportRefreshFailurebumps the generation withrefreshPendingalready false andautomaticRetryUsedalready true, so a superseded build's enrichment callback was discarded withnothing left to schedule a follow-up: every identity stays
identity-pending, nothing can becacheable, stuck
degraded. Enrichment is now armed on both exits.The suggested placement did not work. Arming it before the
canCommitbranch failssystem-graph.test.ts › serves the current accepted revision when afterCommit immediately refreshes— a synchronous refresh inside the callback bumps the generation out from under the result being
committed. It is armed after the commit decision on both paths instead. Both orderings now have a
spec that fails when reversed.
Finding 2 (unnameable contract types) — fixed.
PackageInventoryIdentityIssueand the JSONpayload type (exported as
PackageInventoryJsonValue) are now public from@sapiom/agent, with aspec that uses both through the package entrypoint.
Finding 3 (duplicate slugs settled before enrichment) — fixed, independently. Found and pushed
before the review landed, same diagnosis. Duplicate detection now considers only agents whose
identity has resolved: two agents sharing a marker are not a collision until their sources are read.
Nit —
workspaceRelativeLocalKeyre-export: removed; the one test imports it from the sharedmodule.
Nit — navigation retry with no delay: fixed. Back-off between attempts, injectable so the specs
stay fast, with a spec that fails when the wait is removed.
Nit —
serve()usespeek()instead of the awaited snapshot: kept deliberately, and it iscovered.
afterCommitmay refresh synchronously, so the awaited snapshot can already be supersededby the time the response is written;
peek()returns the accepted revision, which is whatserves the current accepted revision when afterCommit immediately refreshespins. Body and headerare derived from the same snapshot, so they cannot disagree.
Confidentiality: the local path and the project names are removed from this description.
Every fix above was mutation-tested — the guard was confirmed to fail with the fix reverted.