Skip to content

fix: address query scheduler follow-up review feedback - #1994

Merged
Jurij89 merged 2 commits into
testnet-canaryfrom
codex/issue-1989-review-followup
Jul 30, 2026
Merged

fix: address query scheduler follow-up review feedback#1994
Jurij89 merged 2 commits into
testnet-canaryfrom
codex/issue-1989-review-followup

Conversation

@lupuszr

@lupuszr lupuszr commented Jul 29, 2026

Copy link
Copy Markdown
Contributor

Context and scope

Follow-up to the review feedback on merged PR #1991. The original change prevents scoped API reads from starving higher-priority store work; this PR tightens the cancellation, shared-flight, telemetry, route, and UI invariants identified in two review rounds.

Related to #1989 and the SWM symptom evidence in #1990.

This PR does not claim that the separate mainnet Blazegraph atomic graph-replace HTTP 500/deadline failures have the same upstream query. The urn:dkg:internal:atomic-graph-replace:* names are transactional staging graphs. Recovering the Blazegraph/Jetty exception and correlating JVM, disk, lock, and scheduler telemetry remain separate evidence requirements.

What this fixes

The proven code-level failure was a cross-workflow store-pressure cascade:

  1. an untrusted /api/query read could enter the normal store lane;
  2. disconnect cancellation and read options were not carried consistently through every planning/execution read;
  3. shared graph-discovery flights could accidentally inherit one caller's abort signal;
  4. completed signal compositions could retain listeners and object graphs;
  5. route and dashboard terminal states did not consistently distinguish cancellation, admission shedding, and execution failure.

The branch keeps API reads in the background lane by default, threads cancellation to interruptible stores, preserves shared-flight safety, and makes cancellation a first-class terminal outcome.

Finding-by-finding resolution

First review round

Finding Why it mattered Fix Regression proof
Store-read options were manually threaded through the query engine Planning or execution helpers could silently lose signal, priority, or source Build one QueryStoreReadContext at query entry and expose bound execution and shared-discovery lanes Query-engine option propagation tests
Cancelled operations lowered dashboard success rates Client disconnects looked like service failures Preserve cancelled rows/counts, but compute health as success / (success + error) Node UI DB and tracker tests
Scheduler-busy mapping was stringly typed Message changes could turn a retryable 503 into a 500 Match the canonical StoreSchedulerBusyError class Real route 503 test
Query engine and graph guard duplicated SPARQL scanner logic Lexer behavior could drift between security and planning paths Move scanner primitives into shared sparql-utils.ts Query and graph-scope guard suites
Cancellation duplicated failure terminal flow Operation/phase bookkeeping could diverge Use shared status-parameterized terminal transitions Operation tracker tests
Route handoff was only indirectly covered Correct lower-layer behavior did not prove /api/query passed the values Exercise handleQueryRoutes directly and assert signal, priority, source, disconnect, and 503 behavior CLI route lifecycle suite
Write-side close cancellation was missing A store could report closed while an update still unwound Track, abort, and drain both reads and writes through one lifecycle generation In-flight update close/drain test

Second review round

Finding Severity Why it mattered How it was fixed Regression proof
A shared GraphSetIndexStore refresh could capture the first caller's signal High Caller A disconnecting could reject the coalesced upstream refresh used by caller B Shared discovery retains priority/source but omits caller cancellation upstream; each caller races the shared promise against its own signal locally Real GraphSetIndexStore two-caller test: A aborts, B completes, one upstream scan
Slow-query operation classification scanned every query eagerly High Normal successful reads paid an O(query bytes) telemetry cost even when no slow event could be emitted Move full classification behind threshold and sampling gates, alongside hashing Slow-query tests plus the benchmark below
AbortSignal.any compositions retained source-signal graphs High A long-lived generation signal accumulated completed per-request graph references Replace AbortSignal.any with an explicit disposable scope; dispose on synchronous start failure, task settlement, adapter completion, and abort Listener-balance soak test, reason-forwarding test, lifecycle-settlement test
HTTP signal disposal happened at fetch-header completion High, found during parity validation Delayed JSON/N-Quads bodies stopped observing caller aborts, and a retry could wait forever Keep the composed caller/deadline signal linked through response-body consumption and dispose only after the body settles Cross-adapter delayed body cancellation/admission parity tests
A disconnected /api/query response was never ended Medium The route cancelled store work but could leave the HTTP response open Call res.end() when the disconnect error reaches the route and the response is not already ended Route test asserts writableEnded === true
Real route handoff coverage was requested Medium Unit-level option tests alone would not prove production wiring Retain direct handleQueryRoutes coverage for exact option handoff, disconnect cancellation, and typed 503 shedding CLI route lifecycle suite, 6/6
Cancelled operations were not visually distinct Low The UI could render cancellation as warning/failure-like state Add a dedicated cancelled badge/color and apply it to badges, mini-Gantt, phase timeline, legend, and detail rows Static rendered badge test plus DB tests
Admission shedding was recorded as execution failure Low Work that never entered the store polluted failure health Record StoreSchedulerBusyError as cancelled before returning retryable 503 Route test asserts cancel once and fail never
DKG_API_QUERY_PRIORITY had no boot-time validation or effective-value log Low A typo could silently defeat an incident rollback expectation Resolve once at daemon boot, warn on invalid non-empty values, fail safe to background, and log the effective lane Resolver/configuration logger test
The duplicate lexer concern needed explicit closure Low It was unclear whether both parsing paths really shared one implementation Keep the first-round shared scanner refactor; no second scanner was reintroduced Existing query/guard suites
Guard-order regressions around authorized VALUES were missing Low Early graph injection could bypass later default-graph or nested-GRAPH validation Add tests proving authorized top-level VALUES does not bypass either guard Two new query-engine guard tests

Sequence: shared discovery is caller-safe

sequenceDiagram
    autonumber
    participant A as API caller A
    participant B as API caller B
    participant R as Query route
    participant Q as Query engine
    participant I as GraphSetIndexStore
    participant S as Store backend

    A->>R: POST /api/query
    R->>Q: signal A, background, api.query
    Q->>I: listGraphsByPrefix with shared lane
    Note over Q,I: priority and source retained; caller signal omitted
    I->>S: start one refresh

    B->>R: POST /api/query
    R->>Q: signal B, background, api.query
    Q->>I: join the same refresh

    A--xR: client disconnects
    R->>Q: abort signal A
    Q--xR: caller A local race rejects
    R->>R: tracker.cancel and response.end

    S-->>I: graph list
    I-->>Q: shared refresh resolves
    Q-->>R: caller B continues
    R-->>B: 200 response
Loading

Sequence: cancellation ownership lasts through body cleanup

sequenceDiagram
    autonumber
    participant R as Route caller signal
    participant L as Store work lifecycle
    participant P as Priority scheduler
    participant H as SPARQL HTTP adapter
    participant B as Response body

    R->>L: run caller work
    L->>L: compose caller plus generation scope
    L->>P: admit tracked task
    P->>H: execute with lifecycle signal
    H->>H: compose lifecycle plus deadline scope
    H-->>B: fetch resolves headers
    Note over H,B: signal scope remains linked

    alt caller disconnects during body decode
        R->>L: abort caller
        L->>H: propagate abort
        H->>B: abort delayed json, text, or N-Quads read
        B--xH: body cleanup rejects
    else body completes normally
        B-->>H: parsed response
    end

    H->>H: dispose HTTP scope
    H-->>P: operation settles
    P-->>L: release admission
    L->>L: dispose lifecycle scope
    Note over P,L: retry can enter only after prior cleanup settles
Loading

Sequence: normal reads skip full telemetry parsing

sequenceDiagram
    autonumber
    participant Q as Completed SPARQL query
    participant T as Slow-query telemetry
    participant C as SPARQL classifier
    participant E as Event sink

    Q->>T: sparql, source, start time
    T->>T: threshold enabled?
    alt below threshold or telemetry disabled
        T-->>Q: return without scanning query
    else threshold exceeded
        T->>T: sampling gate
        alt not sampled
            T-->>Q: return without scanning query
        else sampled
            T->>C: classify complete query
            C-->>T: operation form
            T->>T: hash and size query
            T->>E: emit bounded slow-query event
        end
    end
Loading

Benchmarks

Environment: Node v25.2.1, macOS arm64. These are isolated microbenchmarks for the reviewed hot paths, not end-to-end HTTP latency.

Slow-query telemetry gate

Nine-run median. The eager column runs the full canonical SPARQL classifier, matching the old placement. The gated column runs the current below-threshold fast path.

Query bytes Eager classifier Gated normal path Avoided work
2,704 101.13 us/op 0.0102 us/op 99.99%
13,000 455.89 us/op 0.0042 us/op >99.99%
26,501 538.38 us/op 0.0042 us/op >99.99%
53,006 1,047.20 us/op 0.0050 us/op >99.99%

The important result is asymptotic: ordinary queries no longer scan their full SPARQL text for telemetry. Classification still runs for sampled slow events.

Abort-signal composition retention

Forced-GC soak over 200,000 completed compositions sharing one long-lived generation signal:

Implementation Retained heap Retained/op Composition time
AbortSignal.any 351,796,136 B 1,758.98 B/op 5,250 ns/op
Disposable scope + dispose() 98,600 B 0.493 B/op 1,530 ns/op

On this runtime, explicit disposal reduced retained heap by more than 99.97% and composition time by about 70.9%. Absolute retained bytes vary by Node/V8 version; the invariant enforced by tests is balanced listener ownership after every settled operation.

End-to-end terminal behavior

Outcome Store execution HTTP result Tracker status Health denominator
Success Completed 200 success Included
Query/input failure Started and failed 4xx/5xx as applicable error Included
Scheduler shedding Never admitted 503 + Retry-After: 1 cancelled Excluded
Caller disconnect Queued/in-flight work aborted and drained Socket ended cancelled Excluded

Validation

  • @origintrail-official/dkg-storage
    • build
    • full package: 471 passed, 26 skipped
    • focused cancellation parity: 44/44
  • @origintrail-official/dkg-query
    • build
    • full package: 330/330
    • query engine: 121/121
  • @origintrail-official/dkg-node-ui
    • build
    • operations status + DB: 104/104
  • @origintrail-official/dkg CLI
    • build
    • route lifecycle: 6/6
  • git diff --check

Base proof

Current head: be2e59b1366d9985f77639279b413d7a4416203f.

At the time of this update:

  • live main: b676567723d038649690681375a0fbf1142915db
  • live testnet-canary: 736d6c28e3508998d60cf175ebab4eb890f3a7fe
  • both are ancestors of this PR head

Review focus

  1. Shared-flight cancellation ownership: caller aborts must never poison a coalesced upstream graph refresh.
  2. Signal-scope lifetime: disposal must happen after response-body cleanup, not after headers.
  3. Terminal classification: scheduler shedding and disconnects remain observable without being counted as execution failures.
  4. Scope boundary: this removes a proven source of store pressure but leaves the mainnet Blazegraph atomic-replace root cause explicitly unproven.

@Jurij89

Jurij89 commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review — fix: address query scheduler follow-up review feedback

Reviewed the delta c50b39235..be2e59b13 only (#1994 is stacked on #1991, so everything up to c50b39235 was covered in my earlier rounds). Three lenses: ACL re-proof after the third refactor, dispose-vs-cancellation, route/engine regression — each executing the real modules and mutation-testing the new tests.

Verdict: approve. Every finding from my round-2 review is fixed, and — this is the part that matters — the fixes are now pinned by tests that genuinely go red when reverted, which was round 2's actual weakness. Three new LOWs, no blocker. Two of the three are consequences of my own advice being applied literally, which I'd rather flag than let stand.


What landed, with the evidence that convinced me

R1 shared-flight signal leak Proven end-to-end against a real GraphSetIndexStore (round 2's test used a bare OxigraphStore and therefore could not have caught the bug). Reverting :894-899 to reads.listGraphsByPrefixRED: promise rejected "first caller disconnected" instead of resolving. Genuine fail-before.
R1 completeness Every store read classified. The three coalesced entry points are listGraphs, listGraphsByPrefix and — the one everyone missed in round 2 — hasGraph (graph-set-index-store.ts:337-338, ensureGraphSet when pendingFullRefresh). It is reached only via listGraphFamily:1508-1514, whose sole call site :1100 receives reads.shared (passed at :1071). Covered. I verified this one independently — I had it as a suspected HIGH and it turned out to be sound.
R2 eager classifier inferQueryOperation now at sparql-http.ts:729, behind the gates at :720/:722/:723.
R3 AbortSignal.any leak 100k-op soak, --expose-gc: old 108.80 MB → new 0.00 MB. Retained abort listeners on the long-lived signal: 0. Reinstating the AbortSignal.any branch kills all three new tests.
R3 did it break cancellation? 10/10 against a real node:http server (headers-then-hang): abort mid-SELECT-body, mid-readResponseTextBounded, mid-CONSTRUCT, close() mid-body, close() mid-UPDATE, two concurrent close(), and 200 abort/completion races — all settled correctly, none hung. dispose() never unlinks a live signal, also true structurally: store-priority-scheduler.ts:346-370 only rejects queued entries, so task cannot settle while work() still holds the signal.
R4 unterminated response if (!res.writableEnded) res.end();
R5 route test was vacuous Now genuinely pins it. Mutating query.ts:678-680 (signal→undefined, priority→'normal') → RED; deleting the disconnect branch alone → also RED. Both arms independently covered.
LOW shed-as-failure respondIfApiQueryStoreBusy hoisted above tracker.fail; a shed now records tracker.cancel.
LOW badge badge-cancelled arm at :1685, the badge-warningbadge-warn typo fixed, .badge-cancelled added to 12-observability.css:139, phase details render for cancelled at :1377.
LOW boot lane configureApiQueryPriority at lifecycle.ts:1175, before listen at :3700; warns on unrecognised values; route reads the boot-resolved getApiQueryPriority() at :382, not process.env per request.

The ACL parser survived its third move. A 38-case differential matrix between c50b39235 and be2e59b13 produced no fail-open. Only two verdict flips, and the second is a fix: at c50b39235, collectGraphVariables (code-point) and callerGraphValuesAreAuthorized (code-unit) disagreed about astral variable names — ?g𝕏 read as ?g by one of them. Consolidation makes them literally the same function, so they can no longer disagree; adversarially probed with astral+FOREIGN, astral+mixed, and mismatched VALUES/GRAPH variable pairs, all of which inject. The #1989 positive path is still byte-identical, and the guard-order test I asked for is there and load-bearing — moving both asserts below the early return turns it red, and one of the two mutations leaked a row.

I also closed the one question the lens left open: nothing re-wraps StoreSchedulerBusyError between the store and the route. The query engine has four catch blocks total, its single throw new Error (:1262) is guarded on ExactGraphReadError/QUAD_COUNT_MISMATCH, and finalization-handler.ts explicitly rethrows it (:472, :510, :963). So the 503 mapping works on the real path.


LOW 1 — the lexer consolidation dropped an ungated IRIREF scan, and that was my advice's fault

I said "move the forked primitives into one module and import in both." Applied literally, one implementation of each pair had to win — and for findMatchingCloseBrace the gated copy won. The two callers have different correctness criteria: a brace matcher only needs to skip <…> opaquely, whereas the value-extracting skipSparqlIriRef legitimately validates the first character. Now a digit-initial IRIREF containing # or ' fails the gate, falls through to i++, and the # is treated as a line comment that swallows the closing brace:

<1#x> in WHERE           old=INJECT   new=THROW (WHERE block could not be located)
<1'x> in WHERE           old=INJECT   new=THROW
<2001/schema#a> pred.    old=INJECT   new=THROW
<1x>  (no # or quote)    old=INJECT   new=INJECT   ← unchanged
<ab#x> letter-start      old=INJECT   new=INJECT   ← unchanged

The direction is fail-closed, verified for all three consumers of the -1 return: constrainGraphVariablesToAllowedSet throws; injectMinTrustFilter returns null → empty result; wrapWithGraph/wrapWithGraphUnion return the query unwrapped, which executed against the store yields [] (default-graph-only, no named-graph leak). So this is a usability regression on an exotic-but-legal IRI shape, not an exposure.

Fix: give findMatchingSparqlCloseBrace an ungated <…> opaque skip while leaving skipSparqlIriRef gated for the value-extracting callers. One module, two behaviours, because the callers genuinely need different things.

LOW 2 — the successRate denominator went wider than I meant, and hides the exact incident this PR family targets

My note was "a shed shouldn't count as a failure." The fix changed the denominator to success + error (db.ts:2552), which also drops in_progress. Executed against the verbatim SQL under node:sqlite with 5 success / 2 error / 3 cancelled / 4 in-progress:

NEW successRate = 0.714     OLD successRate = 0.357
EMPTY {"totalCount":0,...} -> rate 0        ALL-CANCELLED -> rate 0

On a #1989-style store wedge every hung /api/query sits at in_progress forever, so the dashboard health rate stops degrading precisely when the node is least healthy. Null-safety and divide-by-zero are correct, so this is presentation rather than a defect.

Don't apply the obvious fix literally — adding in_progress back to the denominator means that under steady load a window always contains in-flight rows and the rate can never reach 1.0, which is why the original formula was also wrong. Better: keep healthCount as-is and surface in_progress as its own tile.

LOW 3 — the new lifecycle test pins that dispose() unlinks, never when

Hoisting signalScope.dispose() out of task.finally to immediately after start() — unlinking a still-live signal, the exact regression worth fearing here — leaves abortable-store-work-lifecycle.test.ts fully green (3/3). The class is caught hard elsewhere (sparql-http.test.ts goes 29-failed under that mutation), so this is coverage hygiene, not exposure. One case fixes it: start work that never resolves, assert the generation signal still has exactly one abort listener while in flight.

Also worth a heads-up, not a finding: storeCancellationCompletedTotal now also counts aborts during body parsing (sparql-http.ts:246-266), which previously produced no metric. Single count, no double-increment — but anyone watching that series will see a step change.


CI

Currently red, and I don't believe it's this PR. The failure is hermes-adapter.part-08.test.ts › H-AC-31 — a pure filesystem test (mkdtempSync/writeFileSync/readdirSync around setupHermesProfile) in adapter-hermes, a package this delta does not touch; the delta is confined to query/storage/cli/node-ui. It timed out at 5029 ms against the 5 s default rather than asserting wrong, and the job log shows Hermes local-agent registration skipped: fetch failed. Separately, the storage suite's parallel-worker failures are pre-existing: 18 failed at be2e59b13 vs 16 at c50b39235, and in isolation both worktrees give 82 passed (82). A re-run should clear the gate.

Merge readiness

Approve. All three findings are LOW and none blocks. LOW 1 is the only one I'd want fixed reasonably soon, since it silently rejects a legal query shape.

One coordination note: #1994 contains #1991, so merging this lands both — #1991 should be closed rather than merged separately, or the order made explicit.

Method: 3 lenses over the delta, schema-free, executing real modules (AbortableStoreWorkLifecycle under --expose-gc, both engine versions side by side over a shared corpus, a real node:http server for cancellation, a real GraphSetIndexStore for R1, node:sqlite for the health SQL). Every claimed test was mutation-tested with a proven restore. I independently verified the hasGraph lane coverage, the StoreSchedulerBusyError propagation path, the boot-lane wiring, R4 and the badge fix against the worktree at be2e59b13.

@lupuszr
lupuszr marked this pull request as ready for review July 30, 2026 11:36
const sharedReads = 'shared' in reads ? reads.shared : reads;
const callerSignal = 'shared' in reads ? reads.signal : undefined;
const allGraphs = await raceAgainstCallerAbort(
sharedReads.listGraphsByPrefix(prefix),

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🔴 Bug: Aborted API queries can still start uncancellable graph-discovery work

What's wrong
The cancellation race is applied after the discovery promise has already been created. Because the shared lane intentionally omits the caller signal, an already-aborted request can still enqueue or start graph-discovery reads that cannot be cancelled by that request.

Example
If an /api/query request disconnects before graph discovery begins, createApiQueryRequestLifecycle passes an already-aborted signal. discoverGraphsByPrefix still evaluates sharedReads.listGraphsByPrefix(prefix) first, admitting a store graph-list read with no abort signal, then raceAgainstCallerAbort rejects the HTTP query. The backend/scheduler work continues even though there is no caller left.

Suggested direction
Gate shared discovery on the caller signal before constructing the promise, or split shared/coalesced GraphSetIndexStore refreshes from ordinary store discovery so already-aborted callers do not admit backend work.

For Agents
Look at createQueryStoreReadContext, discoverGraphsByPrefix, and resolveScopedContentGraphAllowList. Preserve the intended GraphSetIndexStore shared-flight behavior, but check reads.signal?.aborted before starting shared discovery and avoid dropping cancellation for non-shared/direct discovery paths. Add a test where the query signal is already aborted before graph discovery and assert no store listGraphsByPrefix/listGraphs call is made.

warn(message: string): void;
}

let effectiveApiQueryPriority: ApiQueryPriority = 'background';

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Issue: Avoid routing policy through a mutable singleton

What's wrong
The new module extracts parsing, but the actual policy is still stored as process-global mutable state. That makes the route behavior depend on an out-of-band startup call, forces tests to reset shared state manually, and makes future per-daemon/per-test configuration harder to reason about.

Example
A route-level test or embedded daemon harness that calls handleQueryRoutes without running runDaemonInner silently uses the module default. A separate test that calls configureApiQueryPriority('normal') must remember to restore global state or later requests inherit the wrong lane.

Suggested direction
Keep the env parsing helper pure and inject the resolved lane through RequestContext or the route handler factory. That deletes getApiQueryPriority, removes the route module's dependency on daemon startup side effects, and makes tests local instead of order-sensitive.

For Agents
Move the resolved API query priority into an explicit daemon/request boundary, likely a new RequestContext field populated from runDaemonInner/handle-request input. Keep resolve/logging at startup, preserve /api/query options { priority, source, signal }, and adjust route tests to build the context with the intended priority instead of mutating module state.

return { bindings: all };
}

private async discoverGraphsByPrefix(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Issue: Make graph-discovery signal ownership explicit instead of structural

What's wrong
The abstraction hides the most important invariant behind a structural runtime check: some graph discovery calls must not receive the caller signal, while execution reads must. Because both flows are represented by nearly identical query/listGraphs... wrappers, readers have to chase the object shape to understand cancellation ownership.

Example
A future helper that receives a StoreReadLane and calls discoverGraphsByPrefix cannot tell from the type whether it is using caller-scoped reads or the shared discovery lane. The behavior changes based on a runtime property check, not the method name or type boundary.

Suggested direction
Replace the QueryStoreReadContext | StoreReadLane union plus 'shared' in reads dispatch with named helpers or distinct types for caller-scoped execution reads and shared graph discovery. The code should communicate at the call site which lane is being used.

For Agents
In packages/query/src/dkg-query-engine.ts, split the read model into explicit execution and discovery responsibilities. For example, make QueryReadContext the only top-level query argument and provide a dedicated discoverSharedGraphsByPrefix(prefix, ctx) helper, while lower-level discovery methods accept an already-selected GraphDiscoveryLane. Preserve the invariant that execution reads carry caller signal and shared discovery flights omit it but are raced against the caller locally.

COUNT(*) as totalCount,
SUM(CASE WHEN status = 'success' THEN 1 ELSE 0 END) as successCount,
SUM(CASE WHEN status = 'error' THEN 1 ELSE 0 END) as errorCount,
SUM(CASE WHEN status IN ('success', 'error') THEN 1 ELSE 0 END) as healthCount,

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Issue: Centralize the health-rate denominator instead of duplicating status policy

What's wrong
The diff changes the meaning of successRate by sprinkling the same status policy across several queries and one JS mapper. That leaves an important analytics rule as repeated string/formula fragments rather than a named model.

Example
If another non-health terminal status is added later, this metric policy has to be updated in four places, and one path already expresses the same rule differently from the others.

Suggested direction
Give success/error health eligibility a single local abstraction and reuse it across summary, time-series, per-type, and by-type rate queries. Even a small SQL fragment plus rate helper would make the policy obvious and harder to drift.

For Agents
Introduce one named health denominator concept in packages/node-ui/src/db.ts, such as a SQL fragment/helper for terminal health statuses and a small successRate(success, error) helper for mapped rows. Preserve counts and existing public return shapes; add or adjust the existing stats tests to prove cancelled rows stay visible but out of the health denominator.

<div style={{ display: 'flex', gap: 4, marginBottom: 3, flexWrap: 'wrap' }}>
{phases.map((p: any, i: number) => {
const color = p.status === 'error' ? '#ef4444' : PHASE_COLORS[p.phase] ?? PHASE_FALLBACK_COLOR;
const color = p.status === 'error'

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Issue: Do not scatter another operation-status presentation branch through the large Operations page

What's wrong
The cancelled-state UI is implemented as repeated special cases inside an already large page. The behavior is simple, but the structure makes status presentation a cross-cutting concern that future changes will have to rediscover and update by hand.

Example
Cancelled phase presentation now requires coordinated edits across MiniGantt labels, MiniGantt bars, hover text, the detail timeline, the legend, detail rows, and StatusBadge. The page already has 1,731 lines, so adding another status as scattered conditionals will make the component harder to scan.

Suggested direction
Model status presentation once and let each view consume that model. This is a better fit than continuing to add one-off status === 'cancelled' branches inside each rendering path.

For Agents
Extract a small operation/phase status presentation helper near the existing phase palette, for example returning { badgeClass, color, label, terminalDetailsLabel }. Replace the repeated ternaries in MiniGantt, PhaseTimeline, OperationDetail, and StatusBadge while preserving the current colors and labels.

any?: (signals: AbortSignal[]) => AbortSignal;
}).any;
if (AnyImpl) return AnyImpl([primary, secondary]);
): AbortSignalScope {

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

🟡 Issue: Make composed-signal cleanup lexical instead of caller-managed

What's wrong
The new API fixes listener lifetime by adding a manual disposal protocol, but it exports that protocol to every caller. This trades one lifecycle problem for an easy-to-misuse abstraction: cleanup is now correct only if each call site remembers the same ceremony.

Example
The next caller can easily write const { signal } = composeAbortSignals(a, b); return fetch(url, { signal }); and leak listeners on long-lived signals because disposal is a convention rather than enforced by the API shape.

Suggested direction
Use a callback-based helper or another ownership pattern that pairs signal composition and disposal in one place. That removes duplicated try/finally blocks and prevents future call sites from accidentally forgetting cleanup.

For Agents
In packages/storage/src/abortable-store-work-lifecycle.ts, consider replacing the exported raw scope protocol with a lexical helper such as withComposedAbortSignal(primary, secondary, fn), or keep the scope internal and expose a safer wrapper. Preserve current abort reason forwarding and ensure the existing SparqlHttpStore query/update paths still keep the composed signal alive through body consumption.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants