fix(harness): stop a settled identity failure from vetoing the graph cache [SAP-2984] - #744
Conversation
…cache An agent with no `definitionSlug` has its source parsed to recover a name. The project's cache gate was `every(agent => !agent.extractionFailed)`, so a single agent that can never be named — a companion package with no `defineAgent` export, or one whose dependencies were never installed — held the whole project at `degraded` and its "graph may be incomplete" banner on every open, forever. Re-projecting cannot improve those agents. Gate on whether identity work FINISHED, not on whether it succeeded. An inspection that returns has settled and no longer vetoes; one the budget cut off mid-read is still pending and does, because caching then would freeze a provisional local label on screen. An inspector that throws told us nothing about which it was, so it stays pending. The affected agents keep their `inventory-extraction-failed` warnings — the graph becomes cacheable, it does not go quiet about what it could not resolve.
… symlink The workflow registry keys rows by path, but only the graph-refresh caller resolved symlinks before scanning. Booting under a symlinked launch directory registered every agent under the symlinked path, then the first graph refresh registered them all a second time under the resolved one. The duplicates collided into `local:` fallback keys, so every cross-agent target became ambiguous and its edge disappeared from the graph. Resolve the scan root once inside `scanWorkflowsAndBroadcast`, which covers every scan entry point rather than the one that already did it. This is what makes `system-graph-freshness.test.ts` fail on macOS, where `os.tmpdir()` is `/var/...` for `/private/var/...`. The added spec creates the symlink explicitly, so it reproduces on any platform.
Review — PR #744 (round 1)🔒 CONFIDENTIALITY — PR body publishes an internal workspace inventoryThe changeset and code comments are clean (generic roles, no customer or product Correctness —
|
…clear Review of #744 found the first commit's "an inspection that returns has settled" rule too broad, and it was right. `inspectManifestName` also returns `failed` when the project has no `node_modules` yet — a condition a later projection of the SAME unchanged source clears once dependencies land. Extraction failures are deliberately not cached for exactly that reason (core/canvas-cache.ts), but nothing re-projects on its own: the graph watcher only reacts to `.ts`/`.tsx` outside ignored directories, so `node_modules` appearing fires nothing, and the "Graph may be incomplete" banner carries the only Retry button. Caching that state froze a `local:` label on screen with no way back. `ManifestNameInspection` now reports `retryable` on a failure, and only a retryable one keeps the project uncacheable. A project with sources but no installed dependencies is retryable; one already installed, or with no TypeScript at all to find a `defineAgent` in, is settled. `retryable` is required rather than optional so every construction site states its intent instead of defaulting into the unsafe half. Also narrows the symlink fix. Resolving inside `WorkflowRegistry` covers more entry points, as the review suggested, but registry paths are compared by exact string elsewhere — a session auto-binds on `workflow.path === session.cwd` (server/index.ts:1057) — and rewriting stored paths silently unbound them: six auto-bind specs failed. So the scan root is still resolved at the server, and `connectPath` now matches an existing row by resolved directory while keeping that row's own spelling, which closes the "+ Connect duplicates a scanned row" hole without touching path identity.
Review — PR #744 (round 2, delta since
|
…hints Round-2 review of #744 found the `node_modules` probe wrong in both directions, and it was right. `extractWorkflowGraph` returns `{ ok: false, reason }` for a check process that crashed or timed out as well, so installed deps plus a timeout read as settled — caching a provisional label and removing the Retry that would have fixed it, which is round 1's failure mode at a different cause. And npm/yarn workspaces hoist `node_modules` to the repo root, so a package directory has none even when its dependencies are installed: there, every real failure read as retryable and the project stayed degraded forever, which is the state this change exists to remove. `reason` cannot decide it either — it is free-form text assembled from an agent's own error message, a stderr tail, or a timeout string. So drop the inference and keep only the claim the filesystem proves outright: a project with no TypeScript in it has no `defineAgent` to find, and no install or re-run will invent one. Adding a source file changes the answer, and that is exactly what the graph watcher already sees. Every other failure stays retryable, which is how the project behaves today — the classification is deliberately one-directional, because guessing "settled" wrongly costs a user the retry affordance while guessing "retryable" wrongly costs nothing. Measured on the workspace that motivated this: 4 of its 5 failing agents have zero TypeScript files, so the settled set is unchanged while both misreads are gone.
Primary change type
Problem and motivation
An agent with no
definitionSlughas its source parsed to recover a name. Theproject's cache gate was
prepared.every((agent) => !agent.extractionFailed),so one agent whose source could not be read vetoed the entire project's
cache.
SystemGraphStore.finishBuildthen committed the projection asdegradedinstead ofready— the "Graph may be incomplete" banner and thedegradedvalue ofX-Sapiom-System-Graph-Cache.Some of those agents have nothing to find: a dashboard companion with no
defineAgentwritten anywhere in it. Re-projecting cannot improve them, so thebanner was permanent.
Measured on a real 76-agent / 9-project workspace, not inferred. 21 of 76
registry rows have
definitionSlug: null; 16 are named by manifest inspectionand 5 fail. Those 5 held all 76 at
degraded, and an explicit "Retry"(
POST …/system-graph/refresh) came backdegradedtoo.One claim I could not reproduce and am therefore not making: that
main"re-projects on every open and never caches". It does not — the revision stayed
at
1across repeated opens. What is permanent is the degraded label and thebanner, not repeated projection work.
Summary and scope
Cache on whether identity work can still produce a different answer, not on
whether it succeeded.
PreparedAgentgainsidentityPendingalongsideextractionFailed, andcacheablegates on the new field. Three cases:identityPendingfalsefailedfor any other reasontruemapWithDeadline→prepareFallbackAgent)truelocal:label on screen.Rows 2 and 3 exist because two review rounds found the first version wrong,
and both were right. Round 1: I had treated every inspection that returned as
settled, but
inspectManifestNamealso returnsfailedfor "nonode_modulesyet", which a later projection of the same unchanged source clears — extraction
failures are deliberately not cached for exactly that reason
(
core/canvas-cache.ts). Nothing re-projects on its own: the graph watcher onlyreacts to
.ts/.tsxoutside ignored directories, sonode_modulesappearingfires nothing, and the degraded banner carries the only Retry button. That
version froze a
local:label with no way back.Round 2 then found my fix for it — probing for a local
node_modules— wrong inboth directions:
extractWorkflowGraphalso returns{ ok: false, reason }for a check process that crashed or timed out, so installed-deps-plus-timeout
read as settled (round 1's bug at a different cause); and npm/yarn workspaces
hoist
node_modulesto the repo root, so a package directory has none even wheninstalled, making every real failure there read retryable and pinning the project
to
degradedforever — the exact state this PR exists to remove.reasoncannot decide it either: it is free-form text assembled from an agent'sown error message, a stderr tail, or a timeout string. So the inference is gone,
and only the claim the filesystem proves outright remains — a project with no
TypeScript in it has no
defineAgentto find, and no install or re-run willinvent one. Adding a source file changes the answer, and that is exactly what
the watcher already sees. The classification is deliberately one-directional:
guessing "settled" wrongly costs a user the retry affordance, guessing
"retryable" wrongly costs nothing beyond today's behaviour.
retryableisrequired, not optional, so every construction site states its intent instead
of defaulting into the unsafe half.
extractionFailedis untouched and still drives the warnings. Affected agentskeep every
inventory-extraction-failedwarning: the graph becomes cacheable,it does not go quiet about what it could not resolve.
The symlink defect — a distinct bug, see "Related work".
scanWorkflowsAndBroadcastresolves its scan root, andconnectPathnowmatches an existing row by resolved directory. The registry keys rows by path
and only the graph-refresh caller resolved symlinks, so booting under a
symlinked launch directory registered every agent under the symlinked path and
the first graph refresh registered them all again under the resolved one. The
duplicates collided into
local:fallback keys, every cross-agent target becameambiguous, and its edge silently disappeared.
What I did NOT do, and why — this contradicts a review suggestion. Round 1
asked me to move resolution inside
WorkflowRegistry.scan/connectPathandrewrite stored paths, covering every caller at once. I implemented that, and it
regresses session auto-bind:
server/index.ts:1057matchesworkflow.path === session.cwdby exact string,session.cwdis neverresolved, and
index.ts:1018callsworkflowRegistry.scandirectly. Sixauto-bind specs plus two others failed (
expected null to be '/var/folders/…').Making that correct means resolving path identity across session binding too,
which is a much larger change than this PR should carry. So the scan root is
still resolved at the server, and
connectPathmatches canonically whilekeeping the existing row's own spelling — that closes the "+ Connect
duplicates a scanned row" hole without touching path identity. Healing registries
that already contain both spellings is likewise deferred: any rule that picks a
winner changes a stored path, which is the same regression. The changeset now
says so outright rather than reading as an unconditional fix. Worth a follow-up
issue; not worth smuggling into this one.
Out of scope. No new contract beyond the
retryablefield, and no changesto
packages/agent. The broader package-inventory migration remains open in#741 and is not merged or borrowed from here.
relationshipsCompletestillparticipates in
cacheable(system-graph.ts:413) with its old all-or-nothingsemantics; that is a separate judgement and I left it alone.
Related work
Related issue or discussion: SAP-2984. The branch name says
sap-2983forhistorical reasons — Linear assigned SAP-2983 to a different issue after the
branch was pushed. The broader package-inventory migration that also fixes this
incidentally is #741 (not merged, not depended on here).
A correction to the premise this task was written from, stated plainly.
src/server/system-graph-freshness.test.tsfails deterministically on macOS onclean
main(18.2s,expected false to be trueat line 190) and is green onLinux CI. I was told it was catching this cache bug and should flip green with
the fix. It is not, and it does not. Evidence:
ready/stalethroughout — neverdegraded. Every agent in that fixturehas a
definitionSlugfrom itssapiom.json, soinspectManifestNameisnever called and
extractionFailedis never set. Theevery(...)gate is notexercised by that test at all.
cacheable: truehard-coded — the maximal version ofthis fix — the test still failed identically at line 190, 18.2s.
["local:growth", "local:growth~2", "local:research", "local:research~2", …]with two
duplicate-agent-keywarnings.os.tmpdir()is/var/folders/…for
/private/var/folders/…, the boot scan used the symlinked spelling andthe graph refresh the resolved one, and the duplicate pair made
agent:insights → agent:growthambiguous. Re-running the test with arealpath'd
tempRooton unmodifiedmainmade it pass in 2.0s.So it is not a flake, and it was worth chasing — but it was catching a different
bug. Rather than report the discrepancy and leave a deterministically red test
on macOS with a one-line cause identified, I fixed that too, as separate commits.
It is what turns the test green (18.2s red → 2.0s green).
Validation
Mutation-tested every guard. A count-only assertion passes when nothing
happened, so each new spec was proven to fail against the restored bug:
identityPending→extractionFailedin thecacheablegateidentityPending: !definitionSlug→falseinprepareFallbackAgentidentityPending = inspected.retryable→falsecouldStillBeNamedforced to always-settledcouldStillBeNamedforced to always-retryableconnectPathmatches raw path instead of resolvedcanonicalGraphPath(scanRoot)→scanRootexpected [ 'local:growth', …(4) ]; freshness test back to 18.2s redReporting what did not work. My first version of the symlink spec survived
its own mutation — it passed in 181ms with the bug restored, because it
asserted only on the boot scan and the duplicate rows do not exist until a
second scan runs under the resolved spelling. Adding an agent after boot (which
makes the watcher trigger that second scan) is what gave it teeth. The table
above is the fixed version.
Real server
A 76-agent / 9-project workspace, on a dedicated port and
--state-root. Of the5 agents that fail inspection, 4 are settled and 1 is retryable — 4 of them
contain zero TypeScript files — measured directly:
So on that workspace the vetoing set drops 5 → 1, and it correctly stays
degraded— one project there really does need an install, and the banner istelling the truth. My round-1 body claimed this workspace flipped to
ready;under the corrected rule it does not, and the earlier screenshot has been
replaced rather than left standing.
Scratch workspaces isolate each case (no real agent source was modified):
maindegraded/degradedready/completedegraded/degradeddegraded/degraded(correct — keeps its Retry)Warnings are identical in both rows; only the lifecycle differs.
What I did not prove end to end. I verified that the retryable project keeps
its banner and Retry, and that dropping an empty
node_modulesinto it doesnot flip it to
ready(correctly — the extraction still fails). I did notstand up a genuinely installable agent to watch Retry recover it to
ready;that path rests on
canvas-cache.ts's documented contract that failures arenever cached, plus its own tests, not on a run of mine.
Edits mid-run keep re-projecting — the real risk of making this cacheable is a
frozen graph, so I drove it:
Every edit bumps the revision and the graph never sticks at a cached snapshot.
Before — a settled failure holds the project at "Graph may be incomplete"
After — banner gone, the warning still reported
After — a project whose dependencies are missing keeps its banner and Retry
Tests and documentation
Three new specs in
system-graph-inventory.test.ts(settled failures cache andkeep their warnings; a clearable failure still vetoes; an in-flight inspection
still vetoes), three in
definition-name.test.tscovering theretryableclassification against a real filesystem (including that installed dependencies
do not make a failure settled), one in
workflow-registry.test.ts(connecting a symlinked path lands on the scanned row), and one in
system-graph-freshness.test.ts(a symlinked launch directory registers eachagent once). The symlink spec creates the link explicitly rather than relying on
os.tmpdir(), so it reproduces on Linux CI too, where the macOS-only failurenever appears. Both pre-existing
cacheable: falsespecs still pass unchanged.The
cacheablecontract's doc comment and the newinstallCouldStillNamehelperstate the rule and the failure each prevents. No user-facing docs beyond the
changeset.
Compatibility and release impact
ManifestNameInspectionis internal to this package; its
failedvariant now carries a requiredretryable. Behaviour change: a project whose enrichment failures all havenowhere left to go now reports
ready/X-Sapiom-System-Graph-Cache: completeinstead ofdegraded, and no longer shows the "Graph may beincomplete" banner. A project with any TypeScript that still fails to extract
is unchanged, banner and Retry included. Warnings are unchanged in both cases.
.changeset/graph-cache-settled-identity.md(patch,@sapiom/harness). Rewritten twice under review: the original claimed apackage "whose dependencies were never installed can never be named" (false),
and it now also states that the symlink fix prevents new duplicates rather
than healing a workspace that already contains a pair.
Security
will follow the
Security Policy for
private reporting.
AI assistance
Claude Code wrote the diff, the specs, and this description. Verification was
not taken on trust: the red freshness test was reproduced and instrumented
before any code changed, the stated cause was disproved by hard-coding
cacheable: trueand watching it stay red, every guard was mutation-tested (onespec survived its own mutation and was rewritten — noted above), and the
before/after numbers come from driving real servers on a dedicated port and
state root rather than from the mock fixtures CI uses. Two review rounds each
found a real correctness bug — in the original rule, then in my first fix for it
— and both are described above rather than quietly corrected; so is the one
suggestion I did not take, with the failing test names as evidence.
Checklist
CONTRIBUTING.md, and this contribution follows the direct-PR or issue-first policy.any N/A checks above.