Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
37 commits
Select commit Hold shift + click to select a range
61d45c9
fix(debug-retention): collect key-log-only proxy run dirs
Juliusolsson05 Sep 27, 2026
cb7843f
fix(lsp): re-check physical containment after server startup, right b…
Juliusolsson05 Sep 27, 2026
fe09a29
fix(agent-activity): cache a context id only after its line is writte…
Juliusolsson05 Sep 27, 2026
c92ebe8
fix(lsp): re-check every open incl. shared joins, guard the virtual-d…
Juliusolsson05 Sep 27, 2026
8755ce2
fix(agent-activity): never reissue a context id after a failed or par…
Juliusolsson05 Sep 27, 2026
f72b614
fix(debug-retention): an unreadable child or bundle ledger protects, …
Juliusolsson05 Sep 27, 2026
608dc54
revert(debug-retention): leave the manual-ledger loader to #1417 (q118)
Juliusolsson05 Sep 27, 2026
49e23d5
plan: a monitor run this store never examined is not empty (#1453)
Juliusolsson05 Sep 27, 2026
674fb1f
fix(performance): retention keeps a run this store never examined (#1…
Juliusolsson05 Sep 27, 2026
949b2d9
fix(debug-retention): collect key-log-only run dirs from new runs onl…
Juliusolsson05 Sep 27, 2026
5d21370
Merge origin/main into fix/retention-collects-keylog-run-dirs (keep m…
Juliusolsson05 Sep 27, 2026
d8c62e9
fix(debug-retention): key-log-only collection starts at this machine'…
Juliusolsson05 Sep 27, 2026
8bd7e32
fix(debug-retention): key-log-only collection excludes a baseline cap…
Juliusolsson05 Sep 27, 2026
d3804ec
fix(debug-retention): no baseline from a missing proxy root; runs bor…
Juliusolsson05 Sep 27, 2026
94204b0
docs(debug-retention): baseline membership, not names, decides; the q…
Juliusolsson05 Sep 27, 2026
1cb8b3c
fix(lsp): every containment assertion first checks the root still res…
Juliusolsson05 Sep 27, 2026
68baa3e
fix(debug-retention): drop the birthtime filter; a clock step back co…
Juliusolsson05 Sep 27, 2026
4c64d3d
fix(agent-activity): an unreadable month file refuses the append inst…
Juliusolsson05 Sep 27, 2026
995e993
fix(agent-activity): an unreadable open-interval snapshot is set asid…
Juliusolsson05 Sep 27, 2026
8068967
fix(performance): unreadable incidents make a run unknown, and a dele…
Juliusolsson05 Sep 27, 2026
92295a1
fix(performance): unknown tier content keeps its run, and retention k…
Juliusolsson05 Sep 27, 2026
7ccbdd6
fix(agent-activity): a partial recovery sets aside only what was not …
Juliusolsson05 Sep 27, 2026
f1d60b6
docs(performance): state the cross-process check-then-rm residual (#1…
Juliusolsson05 Sep 27, 2026
2880b8d
test(performance): real clock and aged fixtures so each retention gua…
Juliusolsson05 Sep 27, 2026
afb2a08
fix(performance): never expire, compact or rewrite another store's ru…
Juliusolsson05 Sep 27, 2026
39174f1
fix(performance): the incident limit also never rewrites another stor…
Juliusolsson05 Sep 27, 2026
35001c8
test(debug-retention): pin the birthtime-filter revert and the baseli…
Juliusolsson05 Sep 27, 2026
12960b6
fix(agent-activity): an unreadable aliases file refuses the append an…
Juliusolsson05 Sep 27, 2026
bb11acd
Merge origin/main (#1411 incident refusals) into fix/monitor-unexamin…
Juliusolsson05 Sep 27, 2026
8c2aaf0
fix(lsp): the virtual-document check covers the leaf didOpen names (#…
Juliusolsson05 Sep 27, 2026
0659b36
test(lsp): open the ordering tests under a real canonical root
Juliusolsson05 Sep 27, 2026
4241aa2
test(activity): pin loadAliases' ENOENT-only catch on the read path
Juliusolsson05 Sep 27, 2026
c77a0e6
test(performance): age #1411's retention fixtures so each guard is te…
Juliusolsson05 Sep 27, 2026
b5a08b2
Merge pull request #1388 from Juliusolsson05/fix/retention-collects-k…
Juliusolsson05 Sep 27, 2026
de52c5a
Merge pull request #1412 from Juliusolsson05/fix/lsp-open-containment
Juliusolsson05 Sep 27, 2026
72a753a
Merge pull request #1414 from Juliusolsson05/fix/activity-context-id-…
Juliusolsson05 Sep 27, 2026
2e539f6
Merge pull request #1455 from Juliusolsson05/fix/monitor-unexamined-run
Juliusolsson05 Sep 27, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions docs/plans/2026-09-27-lsp-open-containment.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
# LSP open: re-check physical containment at the moment of use (#1268)

**Gap.** `authorizeContext` (`src/main/ipc/lsp.ts`) validates the physical target, then `LspManager.openDocumentNow` awaits server startup, which can take seconds on a cold spawn. Only after that does it build a lexical `file://` URI and send `didOpen`. If a directory on the path is swapped for a symlink to an outside directory during startup, the server is handed a URI that resolves outside the root.

**Fix.**
- **Manager:** `OpenDocumentParams.assertPhysicalTarget`, a callback from the authorizing caller. The manager awaits it inside the per-document queue, after server startup and immediately before a NEW server document's `didOpen`. A refusal fails open (returns false: no LSP for this document) and names nothing to the server.
- **IPC:** both open paths (`lsp:open-document`, `lsp:reopen-document`) pass `lspPhysicalTargetAssertion(context)`. It re-runs the same rule: `resolveInsideRoot` + `validateExistingTarget` (no symlink, canonical inside the root) + regular file + an unchanged relative location.
- **Why a callback and not a filesystem check in the manager:** the IPC layer owns authorization for both editor roots and AI Workspace entries, and the manager's unit tests run on fake roots.

**Tests.**
- **Real filesystem:** the reviewer's probe. Authorize `src/a.ts`, then swap `src` for a symlink to an outside directory: refused. A leaf that became a symlink: refused. An untouched file: passes. A virtual document: nothing to check.
- **Manager:**
- the re-check runs after `initialized` and before `didOpen`;
- a refusal returns false with no notification;
- a pass opens normally.
- **Mutations killed:** no re-check in the manager; no physical validation in the assertion.

## After review a of #1412
- **Shared joins:** the re-check runs at the top of the queued step for EVERY open, including one that joins an existing shared document (another alias of the same file). Before, only a new document's `didOpen` was guarded, so a join after a swap sent `didChange` for the escaped URI.
- **Virtual documents:** they are named under `root/.agent-code-lsp`, so that directory must not be a symlink out of the root. They now get a re-check too.
- **No exact relative-path comparison:** a case-only rename on a case-insensitive filesystem still resolves inside the root, and refusing it only lost LSP. Containment plus a regular file is the property.

**Residuals.**
- **One await:** the window between the re-check and the notification is one await, inherent to any path-based open.
- **A swap after a document is already open** (a later `didChange`, or a document request, for a URI the server already holds) is not guarded. The server already has that URI, and no per-change path check stops it from reading the path later. This issue is the authorization-to-use window of an OPEN.
- **The IPC wiring** of the callback is not covered by a test, because the handlers need Electron.

## After review b (1cb8b3cf)
Every assertion first checks that the canonical root still resolves to itself (`assertRootUnchanged`). A pathless document's check used to return early when the virtual directory was missing, without checking the root. Review b's other findings are the path-based LSP limit; B6 (owner proxy) accepted this PR as narrowing the window, `Refs #1268`.

## After review c
- The virtual branch now checks the LEAF `didOpen` names (`virtual-<hash>.<ext>`, from `lspVirtualDocumentName`, shared with the manager). It must be absent, or a regular file inside the root. A leaf symlink created in advance escaped with no timing window. A pathless open without its leaf name is refused.
- The regular-file re-check is pinned: an authorized file that became a directory is refused.
- The IPC wiring of the callback still has no committed test. A wiring test is feasible with the existing Electron mock; it is left out under the freeze and stated as a residual.
56 changes: 56 additions & 0 deletions docs/plans/2026-09-27-monitor-unexamined-run.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,56 @@
# A run the monitor store never examined is not empty (#1453)

## Evidence
- Found while verifying #1411 (q115), with a probe: store B indexes the monitor folder at startup. A second store under another run id then creates `runs/run-a` and writes `incidents.json` + `operations.json`. B's next `maintain()` deletes `run-a` (ENOENT afterwards).
- Cause, `MonitorHistoryStore.maintain()`: the expired-run pass deletes every run folder not in `index` / `incidentRuns` / `unindexedRuns`. Those maps are filled only by startup indexing, so a run that appeared later is "not known", which the pass reads as "empty".
- Two app processes can share one data folder: `--packaging-smoke` skips the single-instance lock.
- It is the same "never seen means empty" shape the worker rule forbids (q109, q115).

## Change
- `examinedRuns`: the runs startup indexing actually looked at.
- Retention deletes a run as empty only if it was examined. An unexamined run is unknown and waits for the next start to index it.
- `clear()` resets the set with the rest.

## Not changed (residual)
- The capacity budget (`pruneRuns`) may still remove an unexamined run, as it already may for `unindexedRuns`. That is the documented policy: the 128 MiB ceiling wins over unknown runs. It orders an unexamined run as oldest, since it has no indexed points.
- ~~Two live stores can still examine each other's run while it is empty at startup.~~ Closed by review b's `touchedSince` (see below).

## Test (real files)
`MonitorHistoryStore.test.ts`: run-a appears after store B indexed.
- It survives two of B's maintenance passes. This is red before the fix (ENOENT on the first pass).
- A restarted store examines it and keeps its in-retention incident.
- Past retention, its incident file is deleted, and its (then empty, aged) folder on a later pass, so the protection is not permanent.

## Review a (round 1), fixed
- **An unreadable or untrusted incident file:** at indexing, an `incidents.json` that exists but cannot be read, parsed or trusted now makes the run UNKNOWN (`unindexedRuns`). It used to read as `[]`, so an examined run was deleted.
- **`examinedRuns` is forgotten when the run is deleted** (retention or capacity), so a name another store recreates with fresh data is unexamined again and kept.
- **Tests:** real files. An incident file at mode 000 during indexing survives maintenance once readable; a run recreated after its retention deletion survives. Both were red before. (Their mutation gates were shadowed by review b's `touchedSince` until review c's real-clock rewrite; see below.)

## Review b (round 1), fixed
- **A tier `stat` failure other than ENOENT** now marks the run unknown instead of skipping the tier as absent.
- **A tier file with unparseable lines** marks the run unknown. It was indexed with no points, deleted as fully expired, and then its run was deleted.
- **Retention keeps any run with a file touched within the retention window** (`touchedSince`; any list or stat failure counts as touched). A run examined while empty can be filled later by the other store, which is residual 2 above, now closed. One side effect: once an expired run's last file is removed, its fresh folder mtime keeps the empty folder for one more window.
- **Tests:** real files, one per finding (ELOOP tier link, unparseable tier line, filled after examination). Each was red before. Mutations: "unparsed ignored" and "no touched check" fail on their own; the stat and touched guards back each other up on the ELOOP case (removing both fails).

## Review a round 2: residual (manager decision)
A second store's write can land between retention's final `touchedSince()` check and its recursive `rm()`: a check-then-act race between two uncoordinated processes. It needs two app processes sharing one data folder (possible only under `--packaging-smoke`, which skips the single-instance lock) and a write inside that sub-millisecond window. Closing it needs a cross-process lock on the monitor folder. A rename-to-tombstone-then-recheck scheme narrows it but brings restore-collision cases of its own. That is left out under the PR freeze; B6 decides whether to accept this residual or require the lock.

## Review c (round 1), fixed (tests and docs)
- **The problem:** every new test used a 1970-scale fake clock, so `touchedSince` saw every fixture as freshly touched and shadowed the other guards. Removing the `examinedRuns` guard itself (#1453's fix), the unknown-incidents guard, or the forget-on-delete survived the suite.
- **The fix:** the tests run on the real clock, and each fixture's files and folder are aged past retention with `utimes`, so only the guard a test names can keep the run. A cleanup-direction assertion is added: an examined run whose data expired loses its folder on a later pass.
- **Mutations, each killed on its own:** the `examinedRuns` guard dropped (2 red); `examinedRuns` never populated (2 red); examined kept after delete; unknown incidents unprotected; unparsed lines ignored; no touched check (2 red). Only the ELOOP case has two guards (indexing and `touchedSince`), as noted in its test.
- **Docs:** residual 2 is closed; "deleted as before" is corrected (the incident file goes first, the emptied folder on a later pass).


## Review b round 2, fixed
- **The problem:** the index is only a snapshot of another live store's run. Expiring or compacting a foreign tier file, or rewriting its incidents, from that snapshot deleted what the other store wrote afterwards, including content appended after indexing.
- **The fix:** each foreign tier and incident file's size and mtime are recorded at indexing (`foreignFiles`). Every expiry, compaction and incident rewrite of a foreign file first checks the file is unchanged. A changed or vanished file makes the run unknown (`unindexedRuns`) instead. After the store's own rewrite of a foreign file, the new fingerprint is recorded. The store's own run needs no check, since only it writes there.
- **Tests (two live stores on one folder, real files and clock):**
- a foreign tier expired from a stale index;
- content appended to a foreign tier after indexing (compaction);
- a fresh incident added after indexing (incident rewrite).
With the check disabled, the first two fail; removing only the incident-loop check fails the third.
- **Residual unchanged:** the sub-millisecond window between the check and the act (the review a round 2 cross-process residual).

## Review b round 3, fixed
The global incident limit also rewrote a foreign incident file from the cached rows. The unchanged-file check now lives inside `writeRunIncidents`, so retention and the limit both pass it. Pinned by "never enforces the incident limit on another live store's changed file". Removing the check fails that test and the incident-retention test.
51 changes: 51 additions & 0 deletions docs/plans/2026-09-27-retention-collects-keylog-run-dirs.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
# Debug retention collects key-log-only proxy run dirs (#1385)

## Problem
`collectProxyRunDirs` recognised a run dir only by `proxy-events.jsonl`. A run dir holding just `session-meta.json` + `sslkeylog.log` was walked into and never collected. #1380 review c recounted names and sizes only (contents never read): 23 such dirs on the owner's machine, 5.18 MB of plaintext TLS session secrets, May–September 2026.

## Fix (narrowed to FUTURE runs; B6's oldest-first list)
- A run dir is recognised by either evidence file: `proxy-events.jsonl` or `sslkeylog.log`. It matches the key log itself (review c).
- A dir with `proxy-events.jsonl` is collected as before.
- A key-log-only dir is collected only when it is NOT in the BASELINE: the set of key-log-only dirs that existed when a build containing this code first started (`keyLogBaseline()`, captured at run start in `holdDebugStoragePruneUntilRecovered`, written once with an exclusive create to `STATE_DIR/debug-retention-keylog-baseline.json`). Capture is strict: any directory it cannot list means no baseline, nothing is written, and a later start retries. With no baseline, NO key-log-only dir is collected.
- **WHY a captured set (#1388 review a, two rounds):**
- a date constant excluded runs made on the merge day forever;
- a first-prune marker was written minutes after start (the boot gate delays the first prune), so runs made in between were excluded forever;
- any timestamp comparison admits a pre-upgrade run whose name sorts later after a clock step back.
Membership in "what already existed" needs no clock.
- Every key-log-only dir in the baseline, including the owner's 23, is left untouched and never walked into, whatever its name. Names play no part: a NEW key-log-only dir is collected even if its name is not a timestamp. The decision on the existing ones is tracked in #1460 (q91).
- `session-meta.json` alone stays uncollected, and `_shared-conf` is still skipped.

## Owner decision kept (q91)
- Deleting the EXISTING key logs is still the owner's decision. This PR no longer makes it: the first prune after merge does not touch any of the 23 dirs.
- New key-log-only runs fall under the normal TTL pass (48 h, `AGENT_CODE_DEBUG_TTL_HOURS`) and the proxy budget.

## Also (q115, "unknown is never empty")
`dirStats` no longer skips a child it cannot read. Only ENOENT means absent; any other error leaves the whole dir uncollected that pass. The manual-bundle ledger loader is NOT changed here, because W4's #1417 owns it (q118).

## Tests
`debugRetention.keylog.test.ts`, on the real directory shapes (`proxy/<project>/<session-key>/<ISO timestamp>/`):
- a NEW key-log-only dir is collected beside a normal run dir;
- baseline members (including one whose name sorts after a new run), `_shared-conf` and a metadata-only dir are not;
- the unreadable-child test: fail once, recover, maintain, and the bytes survive.
- Mutations killed: removing the cutoff, and removing the name check.

## Review a (round 1)
- **Fixed:** the date constant replaced by the first-run marker (above).
- **Tests:** a same-day run after the marker is collected; one before it is not; a null cutoff collects no key-log-only dir; the marker is written once and kept, and fails closed on an unknown shape or an unreadable path.
- **Mutations killed:** no cutoff; null collecting everything; any marker shape accepted. Removing the up-front marker read ALONE survives, because the exclusive create then hits EEXIST and reads the stored marker. Removing both guards fails.
- **Not changed (finding 1):** a run dir with an events file AND a key log is collected whole, key log included. That is main's existing behaviour for event-bearing runs, which this PR does not touch. The owner decision (q91) is about the key-log-only dirs, which stay untouched.

## Review a round 2 + b (fixed at the next head)
- The marker is replaced by the baseline set, captured at run start.
- **Tests:** a baseline dir named after a new run (a clock step back) stays excluded; a run made after capture is collected; capture over an unreadable subtree yields no baseline and writes nothing; a baseline that cannot be written is not established (review b); the file is reused and a malformed one fails closed.
- **Mutations killed:** membership ignored; null collecting everything; lenient capture; capture recording nothing; returning an unsaved baseline.
- **Residuals:**
- The early-capture wiring in `holdDebugStoragePruneUntilRecovered` is not separately pinned; the boot-gate suite exercises it against a scratch state dir.
- `dirStats`' EIO/ELOOP branches (review b) are not reproducible on a real filesystem: symlink entries are skipped, and EIO cannot be produced on demand. EACCES is pinned.


## Review a round 3 (last pass)
- **Fixed, unsafe direction:** a proxy root missing at capture saved an empty baseline, so old key logs that reappeared became collectable. Capture now has no ENOENT exception, even for the root: no baseline, nothing written, retried at a later start.
- **Not fixed, conservative direction (decided with review b round 3):** a run created WHILE the startup scan runs is baselined and kept forever. A birthtime filter was tried and REVERTED: after a clock step back, a pre-existing dir's birthtime can look later than the capture start, which would exclude an old key log from the baseline (the unsafe direction). Capture starts at run start, before any session exists, so the window is the few milliseconds of the scan.
- **Residual, conservative direction:** after a failed capture (for example an unwritable state dir), runs made before a later successful capture are baselined and never collected. That is a retention gap, never a deletion. The same holds on a fresh install that has no proxy folder yet: the first capture happens at the start after the folder appears.
- **Mutation killed:** the root ENOENT exception.
Loading
Loading