Skip to content

Archive-support blockers: never bind a source silently to the wrong document - #16

Merged
lowlorenz merged 48 commits into
devfrom
fix/archive-support-blockers
Jul 29, 2026
Merged

Archive-support blockers: never bind a source silently to the wrong document#16
lowlorenz merged 48 commits into
devfrom
fix/archive-support-blockers

Conversation

@lowlorenz

Copy link
Copy Markdown
Contributor

Closes the blockers that stood between the archive-support work and dev, plus everything three review rounds turned up in the fixes themselves.

Why this branch exists

The unifying theme is that a source must never resolve to the wrong document silently. Several paths violated that, and two of them were introduced by earlier commits on this very branch.

The blockers (Tasks 1–7)

  • Expert LLM calls enforce their per-attempt timeout by aborting, not via pi-ai's timeoutMs — the Google/Vertex providers ignore timeoutMs entirely and honour only signal, so an abort is the only mechanism that bounds every provider.
  • output_file on a task_id follow-up follows the inherited source instead of falling back to the workspace root.
  • An ambiguous bare basename now errors instead of resolving to whichever member sorts first.
  • change_source additions survive session_start (startup/switch/resume/fork) and /select-collection, via a session sidecar.
  • Collections are identified by filename stem, not display name, so renaming a manifest no longer orphans its entity index and memory.
  • A nested source's data key resolves correctly in the extension even on a cold cache, instead of falling back to basename(sourceDir).

What review found, and this fixes

  • change_source bound the agent to the wrong document. An out-of-tree archive derives its ref from the basename, so /mnt/archive/F_1864 collided with in-tree sources/F_1864: the add was a no-op while the tool reported success quoting the archive's page count and path. Every page tool then read the other document, both writing into one data/ dir. It now refuses before any side effect and names the directory that owns the ref.
  • A source was silently dropped from the catalog — a regression from this branch's own nested-source fix. Normalizing \ unconditionally treats it as a separator on POSIX, where it is a legal filename character, so a dir named city\Nested folded onto nested city/Nested's ref and one of the two vanished. Reachable from a Windows-made ZIP. Only the platform separator is translated now.
  • The explicit-vs-inherited source rule was half-fixed, so source: "" on a follow-up resolved an output dir from the inherited source while the turn itself failed "page_id requires a source". Both sites now call one shared function.
  • sessionIdFromFile read the entire transcript while its docblock claimed otherwise; past Node's 512 MiB string limit it throws, the bare catch swallows it, and the fork carry-forward silently loses everything. Now a bounded 64 KiB header read.
  • Trailing-slash and /. spellings of one directory no longer produce a false refusal or a source whose data dir is the workspace data/ root.

Testing

npm test in both packages: 142 agent checks, 52 host checks, all three typechecks green.

npm test previously never built the agent package whose build output the tests derive their expected values from — a clean checkout failed with an opaque ERR_MODULE_NOT_FOUND inside the extension host, and a stale dist made the drift detectors agree with themselves. It now builds it first, and all six test scripts are documented and wired (only two were listed before).

Every fix is mutation-verified: reverting it turns canary checks red. That mattered — three fixes originally shipped with tests that passed with the fix removed, because the code under test was inlined in the pi entrypoint's closures and the canaries asserted about retyped copies.

Known limits, deliberately not addressed here

  • Coverage of the wiring, as opposed to the helpers: re-inlining the buggy expression at the extensions/index.ts call sites, or deleting sources.ts's normalization, still leaves the suites green. Needs a separator seam in discoverSources.
  • toSlug is not injective, so two distinct sources can still share one data/ dir (city/Adressbuch 1864 vs city/Adressbuch_1864). Fixing it renames data dirs and would orphan existing workspace output, so it wants a migration decision.
  • deriveRef's ".." test is a substring rather than a path-segment test (a parent named ..city drops a source).
  • A manifest-supplied ref bypasses normalization — the one input shape where the two packages genuinely disagree. Unreachable today; collections have never shipped.
  • The interactive end-to-end smoke has not been run (it needs a live workspace, API keys, and a VS Code restart). The UI test also flakes roughly 1 run in 4 on unchanged code, now documented in TESTING.md — so one green run is weak evidence.

Merge as a real merge commit, not a squash, to keep the per-task history and its review trail.

lowlorenz added 30 commits July 2, 2026 11:32
Eight tasks, each shipping a test that fails first — the branch's eight
existing gates all pass, so green tests are not evidence here.

Design decisions recorded: session-scoped sidecar for change_source
persistence (a manifest would silently convert the session to a named
collection and appear to lose its memory and data); a sourceDir->dataKey
cache in the host rather than duplicating dataKeyForRef/toSlug/deriveRef
across a package boundary; an explicit collection id separate from the
display name; and timeout-as-abort inside completeWithRetry, since
signal is the one mechanism every provider honours.

Scope also absorbs the two issues the timeout fix makes reachable: the
missing try/catch (Google throws when the signal is pre-aborted) and the
timeout-vs-user-abort distinction (without it, retries silently become 0
for timeouts).
Pre-flight scan found the plan violating its own failing-test-first
constraint in three places. Task 2's canary asserted a literal true where
the real property is 'it terminated' — now a watchdog race. Task 3's test
was specified to PASS before the fix, leaving blocker 1 with no failing
test — now extracts a pure outputBaseDir so the inherited-source case is
directly assertable. Task 1 is explicitly exempted, changing only text.

Also folds in three Minor findings the previous SDD run recorded and
never fixed, all in files this plan already touches.
setup.md ended with a literal <<<< that renders in the VS Code Getting
Started walkthrough. workspace-templates.ts wrote 'design for resa
resume' into every new workspace's SKILL.md. Also drops the two 0-byte
MEMORY.MD files committed by dev-running the agent inside the repo, and
ignores those paths so they cannot come back.

Folds in two Minor findings the previous SDD run left unresolved: the
task_batch source description claimed source is unused with images (it is
forwarded, enabling that item's view tools), and loadImageAsPng
duplicated downscaleToLimit's resize options literal.
The Google and Vertex providers never reference timeoutMs and honour only
signal, so the 300s budget was a silent no-op on Gemini — a stalled
expert held its batch slot forever and retries could not help, because
the loop blocked on an attempt that never resolved.

Each attempt now gets its own AbortController composed with the user's
cancel signal. A timed-out attempt stays retryable (the loop tests the
user signal, never the composed one) and is reported distinctly from a
user cancel. Attempts that THROW instead of resolving are also retried
and converted to an error response, since Google throws when the signal
is pre-aborted — previously that would have escaped uncaught.
A timeout racing a genuinely successful attempt could discard the real
response: runAttempt read the timer-set timedOut flag after await
attempt(...), so a call that legitimately resolved right at the
timeout boundary was still reported as timed out and retried. A
usable response (stopReason neither "error" nor "aborted") is now
authoritative over a late-firing timer, both in runAttempt/isRetryable
and in expert-turn.ts's reporting order.

Also added the missing synchronous already-aborted pre-check for the
user signal (adding an abort listener to an already-aborted signal
never fires, per WHATWG semantics), hardened the canary's user-abort
mock to actually derive its result from the signal it receives instead
of hardcoding stopReason "aborted", added a reset assertion to the
timeout-then-success check, added a new check for the timer/success
race, and restored the dropped chronos.expertRetries /
chronos.expertRequestTimeout settings cross-reference comment.
Running the UI test with the repo root as cwd produces a root-level
.vscode-test/ that the chronos-vscode-scoped rule did not cover, leaving
a multi-hundred-MB VS Code download exposed to an accidental commit --
the same class of artifact as the MEMORY.MD files just removed.
A task_id follow-up inherits the session's source for viewing, but the
output dir was chosen from params.source alone and fell back to the
workspace root. Re-running an extraction wrote the file outside the
source's data dir, left the original stale, and reported success.
Task 3 fixed output_file's blind spot to a task_id follow-up's inherited
source but left details.source/the @path citation keyed off params.source
alone. A sourceless follow-up still self-zooms via view_page/view_region
against the inherited source, so its toolUses entries carried real page
numbers under an undefined details.source — clicking that citation chip in
the webview fell back to whatever source the panel currently has open,
not the one actually viewed.

Extract effectiveSourceRel (mirrors outputBaseDir's explicit-wins-over-
inherited rule) and derive details.source/sourceRel from it instead of
params.source directly.
…4, R5)

R4: the newest collection-canary.mjs block asserted a literal
"sources/Frankfurt_1864" for effectiveSourceRel, which relies on
node:path's relative() and returns platform-native separators
(backslash on win32) — three checks would false-fail there. Compose
the expectation with join("sources", "Frankfurt_1864") instead,
matching the older outputBaseDir block's convention of deriving
expectations from fixture primitives rather than literals.

R5 (subsumes R3): outputBaseDir and effectiveSourceRel each repeated
the explicit-wins-over-inherited precedence rule and an identical
broad `catch { return ""; }`. Extract the precedence rule into a
shared effectiveRef() helper, and the catch into a shared
resolveOrEmpty() helper, without merging the two functions (their
no-source fallbacks genuinely differ: ctx.workspaceDir vs "").

The catch stays an unconditional catch-all rather than narrowed by
error type: a future ambiguity error from resolveByAlias is never
silently lost end-to-end even though these two helpers swallow it,
because both call sites in createTaskTool sit adjacent to
runExpertTurn's own independent resolveSource call, which surfaces
whatever it throws as result.error. Documented this reasoning in
resolveOrEmpty's doc comment so the ambiguity-throw task isn't
surprised by it.
resolveByAlias returned the first member whose basename matched, so two
nested sources sharing a basename were indistinguishable — the agent
silently got one and wrote its extraction into that source's data dir.
Mirrors resolveExpertModel, which already errors on an ambiguous id.
lowlorenz added 18 commits July 28, 2026 17:32
buildCollectionFromDiscovery clears and repopulates from sources/ on
every session_start, so an out-of-tree source added via change_source was
wiped with nothing to restore it from — refs the agent had been told to
use started throwing mid-conversation.

Additions now persist per-session in session-collections.json and are
replayed after discovery. Selecting 'All sources' clears only the
collection name, not the additions. Deletes session-source-store.ts,
which had no importers left.
…, R2)

- R1: /select-collection wiped extra members added via change_source in both
  its success branches (all-sources and named-collection), reproducing the
  exact bug Task 5 fixed for session_start. Extracted the replay loop into
  an exported replayExtraMembers() helper and call it at both sites.
- R2: saveSessionExtraMember now guards against a non-array extraMembers in
  a hand-edited/corrupted store instead of spreading a string into
  characters or throwing on an object.
- Extended collection-canary.mjs with checks for both (33 checks total).
My pre-flight scan checked only tasks 1-3 against the plan's own
failing-test-first constraint. Task 7's UI-test step said 'add an
assertion' with no step that runs it before the fix, so it could have
shipped a test that passes either way.
…ry check

replayExtraMembers only depends on collection-context/session-collection-store,
so move it from extensions/index.ts to tools/collection-context.ts (no import
cycle: session-collection-store.ts imports only node:fs/node:path) - this stops
the canary from needing to load the full pi-package entrypoint to exercise
twelve lines (R4).

The "already-present ref is not overwritten by replay" canary check asserted
identity on a ref ("InTree") that was never actually reachable from
extraMembers, so it passed even with the has(ref) guard removed. Persist an
extra member whose deriveRef genuinely collides with a discovered source so
the guard is actually exercised (R5).

Replace the dead void'd archiveAfterFirstReplay capture with a real assertion
that a second replay-after-wipe reconstructs the same member fields (R6).
listCollections reported the in-JSON name and discarded the filename,
then loadCollection resolved that value AS a filename — so a manifest
whose name differed from its filename was unselectable, and restore
failed the same way via a console.warn the user never saw.

Adds an explicit id (the filename stem) used as the option value, the
manifest lookup key, and the persisted value; name stays display-only.
Migrates already-persisted display names to ids on load.
collectionKey derived its slug from the mutable display name, contradicting
the id-as-stable-identity guarantee Task 6 established — renaming a
manifest's "name" field silently relocated its entity index and memory
file. Key on id instead, with the "all-sources" fallback preserved.

Also corrects Selection.name's doc in session-collection-store.ts: the
field has held a collection id since Task 6, not a display name, but the
on-disk JSON key stays "name" for backward compatibility with existing
session-collections.json entries and the legacy-read canary.

Extracts resolveSessionCollectionSelection, a pure decision function, out
of session_start's previously untested display-name migration branch, and
adds canary coverage for it plus for the collectionKey fix.
The host derived basename(sourceDir) in previewSource/openViewLink while
the agent slugs nested refs (dataKeyForRef), so a citation click for
sources/city/X pointed the Data tab at data/X instead of data/city--X —
it silently emptied and latched the wrong value. The dropdown's
endsWith("/" + currentSource) matcher was likewise written for
basenames and stopped matching once currentSource became a slug.

Caches the data key the agent already sends as sourceName on every
show_page/page_list HTTP message, keyed by sourceDir, instead of
duplicating dataKeyForRef/toSlug/deriveRef across the chronos/
chronos-vscode package boundary (show_text doesn't carry sourceDir on
the wire, so it's left setting currentSourceName directly as before —
already correct since it comes straight from the agent). Citation
clicks now look up that cache (falling back to basename only for a
directory the agent has never named) instead of re-deriving it, and
the source dropdown compares a same-space dataKey sent alongside each
option instead of an endsWith hack.

Extends the UI test fixture with a nested source and a citation that
names it explicitly, and asserts the resulting currentSource is the
slug, not the directory basename.
…/F3)

The host's dataKeyBySourceDir fallback used basename(sourceDir) whenever
the agent hadn't yet named a directory this session (resuming a session,
or citing a nested source the agent never show_page'd). That's actively
wrong for nested sources: the agent slugs sources/city/X to
data/city--X, so the fallback silently pointed the Data tab at a
directory that doesn't exist. This made the dropdown's dataKey
(postSources) permanently wrong for a cold nested source too.

Add chronos-vscode/src/panel/data-key.ts, a dependency-free mirror of the
agent's own deriveRef/dataKeyForRef/toSlug (chronos/tools/collection-context.ts,
chronos/utils/source-discovery.ts), and use it as the fallback instead of
basename. The agent's cached value still wins when present; this only
covers the cold-cache case, correctly, for in-tree flat, in-tree nested
(any depth), and out-of-tree sources alike.

With a correct fallback, postSources() already computes the right key on
a cold cache, so no re-fire is needed there.

Add test/data-key-equivalence-test.mjs, which imports the agent's real
compiled functions from chronos/dist and the host's mirror (compiled
on the fly with esbuild) and asserts they agree across a table of cases,
with no hardcoded expected key — this is what keeps the duplication
honest if the agent's derivation ever changes. Also replace suite.js's
hardcoded "city--Nested_1900" literal with the same dynamic derivation,
and add a genuine cold-cache regression check (a second nested fixture
source, sources/city/Nested_1875, that the mock pi never mentions).
…y (F1)

buildCollectionFromDiscovery keyed collection members by discoverSources'
platform-native s.name instead of routing it through deriveRef, which is
the one place that normalizes \ to / -- on win32 this silently collapsed
distinct nested sources with the same basename onto one data/ dir and left
the agent's reported sourceName disagreeing with the host's already-
normalized deriveDataKeyFallback. Fixed both sides: the agent derives the
ref via deriveRef(workspaceDir, s.path), and chronos-vscode's discoverSources
normalizes its own name the same way so /select-source's exact-ref match
still works.

Added a collection-canary.mjs case that reproduces the collision class of
bug without needing Windows (a literal-backslash-named POSIX directory
exercises the real buildCollectionFromDiscovery, and a synthetic win32-style
ref pair fed into dataKeyForRef demonstrates the collision directly), plus a
data-key-equivalence-test.mjs case sourced from chronos-vscode's actual
discoverSources output rather than a hand-built path.
…d (F2)

pi's fork (VS Code's "edit a past message") mints a brand-new session id, so
loadSessionExtraMembers/loadSessionCollection for that id found nothing --
every change_source addition and collection narrowing silently evaporated on
edit-and-resend, exactly the failure this store exists to fix for
startup/switch/resume. session_start's previousSessionFile is a file PATH,
not an id, so read the old session's id out of its own header
(sessionIdFromFile) and copy its collection selection + extraMembers to the
new id (carryForkedSessionState) before the rest of session_start runs.

This makes the "startup/switch/resume/fork" doc comments in
collection-context.ts and change-source.ts actually true instead of
aspirational.
…name (F5)

members.find((m) => m.ref === requested || basename(m.path) === requested)
took whichever member matched EITHER condition first in sort order — with
members "a/X" and "X", "a/X" sorts first and its basename matches, so
/select-source X previewed the wrong document even though an exact ref "X"
exists. Changed to two sequential finds (exact ref, then basename) so an
exact ref always wins, matching resolveSource's precedence.

Also corrected resolveByAlias's doc comment, which claimed to mirror
/select-source's match exactly -- it mirrors precedence now, but still
differs on an ambiguous basename (resolveByAlias throws; /select-source
silently takes the first sorted match).
explicitSource ?? inheritedSource only falls through on null/undefined, so
an explicit source: "" was treated as a real (empty) source rather than
"absent", ignoring an inherited task_id follow-up source and writing
output_file/details.source to the workspace root instead. Changed to ||.
Collections have never shipped -- zero collection files exist on dev or
master -- so the display-name-to-id migration in
resolveSessionCollectionSelection guarded a population of zero sessions,
and it was the sole cause of a real hazard: a stored value that happens to
equal a DIFFERENT collection's display name would silently resolve to the
wrong collection, strictly worse than the pre-existing safe fallback to
all-sources.

Removed:
- resolveSessionCollectionSelection's needsRewrite/display-name-resolution
  branch and its store-rewrite wiring in session_start. Kept the function
  itself as a pure id validator -- it's still meaningfully testable in
  isolation from the imperative load/rewrite side effects, matching the
  rest of this file's testing approach.
- /select-collection's fallback that matched a requested value against
  c.name after c.id. Matches on id only now.

The pre-existing silent fallback to all-sources when a stored id matches
nothing is unchanged. Updated the R18 canary block to drop the deleted
migration's assertions and added the hazard case as a passing check instead
(a stored value equal only to a different collection's display name must
stay on the auto-collection, not resolve to that collection).
… F6)

test/suite.js and test/data-key-equivalence-test.mjs import chronos/dist to
derive their expected data keys instead of hardcoding them, but `npm test` never
built chronos/ — so on a clean checkout the import failed with an opaque
ERR_MODULE_NOT_FOUND from inside the extension host, and with a stale dist the
drift detectors agreed with themselves.

- chronos-vscode: add build:agent; npm test now builds the agent, then runs the
  two host tests as well as the UI test.
- chronos: add npm test running all four canaries (after a build, since they
  import the build output).
- suite.js: name the missing build in the error instead of throwing a raw
  module-resolution failure.
- Document all six test scripts (only two were listed), the stale-dist hazard,
  and the UI test's measured ~1-in-4 flake.
- CLAUDE.md workspace layout: session-sources.json no longer exists; describe
  session-collections.json and its extraMembers replay.
- Stop telling the model to construct data/_collections/<collection>/ itself —
  the resolved dir is injected into its system prompt.
Final-review wave 2. One functional defect, one regression this branch
introduced, one long-standing critical, and the fixes whose tests passed with the
fix removed.

change_source silently bound the agent to the WRONG document (critical). An
out-of-tree archive derives its ref from the basename, so /mnt/archive/F_1864
collided with an in-tree sources/F_1864: the add was a no-op while the tool still
reported success WITH THE ARCHIVE's page count and path, after which every
list_pages/task read the other document and both wrote into one data/ dir.
resolveByAlias — which throws on an ambiguous basename — was never reached,
because the collision happens before any lookup. It now refuses the add and says
which directory owns the ref, before any side effect (no mkdir, no sidecar write,
no viewer event). replayExtraMembers cannot refuse, so it warns instead of
skipping in silence.

The tool also now resolve()s its input, so one directory has one spelling. The
collision check is a string compare against a stored member path: a trailing
slash made "/a/S/" look like a different directory from "/a/S" and refused an add
whose named "owner" was that very directory. And basename("/a/S/.") is ".", which
produced ref "." and pointed a source's data dir at the workspace data/ ROOT
while reporting success.

deriveRef dropped a source from the catalog (regression, from the F1 commit).
Normalizing `\` unconditionally treats it as a separator on POSIX, where it is a
legal filename character: a dir named `city\Nested` folded onto the ref of a
genuinely nested `city/Nested`, and since members is keyed by ref, one of the two
silently vanished. Reachable from a Windows-made ZIP. Now only the PLATFORM
separator is translated, via a shared refFromRelative that takes the separator as
a parameter — which also makes the win32 branch executable from a POSIX host,
where the host half of that fix had no coverage at all. resolveByAlias folded `\`
the same way on its lenient `sources/`-prefixed path, resolving that spelling to
the wrong document; it now uses the same helper.

The explicit-vs-inherited source rule was half-fixed. expert-turn.ts still used
`??`, so `task({task_id, source: "", page_id})` resolved an output dir from the
inherited source while the turn itself failed "page_id requires a source" — and
without page_id the expert silently lost view_page/view_region. Both sites now
call one exported effectiveSourceRef, so the docblocks promising they cannot
disagree are structurally true rather than aspirational.

sessionIdFromFile read the ENTIRE session transcript while its docblock claimed
it read only the first line. Past Node's 512 MiB string limit readFileSync
throws, the bare catch swallows it, and the fork carry-forward silently loses
everything — on exactly the long sessions it exists to protect. Now a bounded
64 KiB header read (a header above that fails closed; pi's are ~200 bytes).

Coverage: /select-source's precedence and the fork guard were inline in the pi
entrypoint's closures, so the canaries asserted about retyped copies and stayed
green when the real code was reverted. Both are now exported
(pickMemberByRequest, forkedPreviousSessionFile) and the canaries drive those
functions. Note this covers the HELPERS, not the wiring: re-inlining the buggy
expression at the extensions/index.ts call sites, or deleting sources.ts's
normalization, still leaves the suites green. Asserting the wiring needs a
separator seam in discoverSources and is recorded as a follow-up.

Each fix is mutation-verified: reverting it turns canary checks red — the
deriveRef fold, the resolveByAlias fold, the dropped resolve(), effectiveSourceRef
back to `??`, the change_source refusal, a truncated header read, the
pickMemberByRequest single pass, and the host-side normalization (which formerly
left the equivalence test fully green when deleted).

Gate: 142 agent checks, 52 host checks (30 equivalence + 3 collection-id + 19
UI), all three typechecks green.

Deferred (in the ledger; they rename data dirs or need design): toSlug is not
injective so two distinct sources can still share one data/ dir; deriveRef's ".."
test is a substring rather than a path-segment test; a manifest-supplied `ref` is
still unnormalized.
Plans are working notes for one change. They go stale the moment their branch
lands, and they dominated the diff they belonged to: the archive-support blocker
plan was 1435 lines, the single largest file in its own PR, and plans together
were 3004 of that PR's 8502 added lines — more than three times the source it
actually changed.

Specs (docs/superpowers/specs/) stay tracked. They record WHY a design was
chosen, which stays useful after the branch is gone; the plan is just the
step list for getting there.

The files remain on disk, and their content stays in history — nothing is lost,
it just stops arriving in review.
The UI-test job installed only chronos-vscode's dependencies, so `npm test`
failed at its new first step — building chronos/ — with ~60 TS2591/TS2307 errors
for missing @types/node and the pi packages. Reproduced locally by moving
chronos/node_modules aside: byte-identical failure.

That build step is not optional: test/suite.js and
test/data-key-equivalence-test.mjs derive their expected nested-source data keys
FROM chronos/dist instead of hardcoding them, so a missing dist fails opaquely
inside the extension host and a stale one makes both agree with themselves. The
job now installs the agent package and caches its lockfile too.

Also: nothing in CI ran the agent's canaries — image downscaling, expert retry,
timeout-as-abort, and the whole collection/ref/data-key/session-sidecar suite,
~140 assertions, were local-only. The build job runs them now.

Fixes the stale comment claiming `npm test` is just esbuild + run-ui-test.mjs.
@lowlorenz
lowlorenz merged commit 361f253 into dev Jul 29, 2026
2 checks passed
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.

1 participant