Skip to content

🤖 tests: SessionLocksMemory keeps use leases in its table and makes lock folders through HostFiles - #7376

Merged
ThomasK33 merged 13 commits into
mainfrom
phase5/b-lease-memory
Oct 24, 2026
Merged

ThomasK33 merged 13 commits into
mainfrom
phase5/b-lease-memory

Conversation

@ThomasK33

@ThomasK33 ThomasK33 commented Oct 24, 2026 •

Copy link
Copy Markdown
Member

Summary

5b PR 2 of phase 5 (Loop host I/O) of the agent-loop refactor. SessionLocksMemory keeps the use leases in its own lock table, so useLeasesFor no longer goes to the Node layer. It makes the lock folders through an injected HostFiles, so on the memory volume a lock writes nothing on the disk. The change is test-only: 4 test files, no production code.

Background

Phase 4 PR 6 (#7367) added the memory lock layer and left useLeasesFor on the Node layer. Phase 4 PR 9 (#7372) added the file-lock path. Both made each lock's folder on the real disk (mkdirSync), for parity with the Node locks. The proof turn of phase 5 allows no disk writes outside the config root, so phase 5 plan note 5 routes those folders through HostFiles. HostFiles has only an async mkdir. When a waiter awaited its folder before it joined the FIFO, phase 4 saw a later waiter go first in 14 of 1500 runs.

Implementation

  • memorySessionLocks.testHarness.ts:
    • useLeasesFor gives one leases object per config. Each lease is a table entry at its lease-file path (<token>.<kind>.lock in workspaceUseLockDir). hold publishes the lease, then probes the gate entry at workspaceMutationLockPath, as WorkspaceUseLeases.hold does. A live gate makes it withdraw the lease and throw WorkspaceMutationInProgressError with today's text. isHeld is true for any live lease of that kind in the table. Nothing in it awaits, so it needs no transition lock.
    • Both lock kinds (file locks and cross-process records) take their FIFO place first and then await files.promises.mkdir (acquireAfterFolder). A failed mkdir rejects with its error, as the Node locks do, and gives up the place or the lock. The 2 mkdirSync calls, their eslint-disable lines and the node:fs import are gone.
    • SessionLocksMemory now needs HostFilesTag, and memorySessionLocks(clock, table, files) takes the files.
  • sessionLocks.contract.testHarness.ts: 2 new contract cases on all 4 layers (node, memory, node + foreign, memory + foreign):
    • "a live foreign mutator makes hold refuse with today's text, and its release admits"
    • "isHeld sees another backend's live lease of that kind, and not a dead one"
  • sessionLocks.contract.test.ts:
    • The 2 node-only tests "foreign: a live foreign mutator makes hold() refuse with today's text" and "foreign: a live fake lease of another backend makes isHeld true" are removed. The 2 contract cases above cover them on all 4 layers, node + foreign included.
    • The FIFO case "memory: a release hands the file lock to the waiters in FIFO order" is now deterministic. It holds each mkdir open and finishes the later waiter's folder first.
    • A new case covers a failed lock folder, for a free lock and for a queued waiter.
    • The memory contract layer uses nodeHostFiles, because the contract cases stat the folders on the disk.
  • historyFiles.golden.test.ts: the memory locks get the memory host, and the test checks that <root>/locks holds no file and no folder.

Risks

Low, test code only. A memory lease writes no lease file, so only the memory table sees it. WorkspaceUseLeases.withMutationGate and taskService's findForeignUse read lease files on the disk, and they are not SessionLocks members, so they cannot see a memory lease. The module doc says this. No test uses them with the memory layer today.

Validation

  • Scratch edits, each reverted:
    • With no gate probe in the memory hold, the memory gate contract case fails.
    • With the mkdir before the queue place, the FIFO case fails at once (order [c, b]).
    • With no give-up after a failed mkdir, the failed-folder case fails.
  • The FIFO case with --rerun-each 1500: 1500 pass, 0 fail.
  • bun test ./src/node/services/sessionLocks/ ./src/node/services/workspaceUseLeases.test.ts ./src/node/services/sessionFiles/historyFiles.golden.test.ts: 84 pass, 0 fail.
  • make static-check and make lint-eslint.
  • Loop-test tripwire, 3 runs against main eeddd29: 0 trips. The merge of main (🤖 refactor: HistoryService takes its session locks from SessionLocks #7372) changed none of the 4 files' diff.
  • Round 1: a cross-process acquire whose signal aborts during the folder's mkdir now rejects and frees the lock it took, as the Node lock checks the signal after its mkdir. A new memory case fails without the check (scratch edit). On that head: make static-check, make lint-eslint, the 3 test paths above (85 pass) and the FIFO case 1500 of 1500.

Generated with xum • Model: anthropic:claude-opus-5-5 • Thinking: high • Cost: $129.93

…ontract

SessionLocksMemory keeps lock state in a table per build and waits on the HostClock, so a TestClock test moves time instead of waiting. withForeignHolders plays other backends: a live holder refuses at once with today's text, and a dead one is reclaimed. The contract now runs on node, memory, node + foreign and memory + foreign.
…nts, gate order and holder text

Codex round 1 on #7367: an aborted signal wins over a fake live holder, the memory layer rejects with ENOENT when createParentDirectory is false and the parent is gone, the fake gate is probed after the lease is published, fake holders record an identity so the text equals the Node text for a current record, and fake pids are offsets from process.pid.
…the memory record lock

Codex round 2 on #7367: an aborted signal wins over a missing parent, and a parent removed after the first check (or during the wait) refuses with ENOENT and frees the lock, as Node's write would.
…ake leases in isHeld

Codex round 3 on #7367. The memory layer's parent checks did not converge, so a record lock with createParentDirectory false now goes to the Node locks, whose own write gives WorkflowRunStore the ENOENT it needs. Other record locks create their parent as Node does. The foreign layer's isHeld counts a live fake lease in the workspace's lease folder.
@typescript-eslint/await-thenable (CI lint-full): bun types rejects.toThrow and rejects.toBeInstanceOf as void.
…ild SessionLocks without bun:test

The memory acquireProcessFileLock creates the lock's parent as fileLock.ts does, and writes no lock file (#7326 follow-up from #7367 round 4). buildSessionLocks moves to its own module so testHistoryService.ts, which the Jest integration tests load, can build a layer.
HistoryService gets a third constructor argument, locks (default nodeSessionLocks), and exposes it as readonly locks. The workspace FIFO and the history write lock go through it, so historyService.ts imports no lock module. HistoryLive yields SessionLocksTag, and createTestHistoryService takes a locks layer. historyService.foreignHolder.test.ts plays a live and a dead foreign writer, and the golden leak test also runs on SessionLocksMemory.
The memory layer's folder side effect (e9272f9) awaited fs.mkdir before a
waiter joined the FIFO, so a later caller could queue first. The contract case
"a release hands the file lock to the waiters in FIFO order" failed 14 of
1500 reruns that way, and once in make test. A synchronous mkdir keeps the
call order: 0 of 1500 fail.
# Conflicts:
#	src/node/services/sessionLocks/memorySessionLocks.testHarness.ts
#	src/node/services/sessionLocks/sessionLocks.contract.test.ts
#	src/node/services/sessionLocks/sessionLocks.contract.testHarness.ts
…hods

c0e84fa made both memory-layer folder writes synchronous so waiters queue in
call order. The local no-sync-fs-methods rule refuses mkdirSync, so each call
gets a disable comment with that reason, as serverCrashLogging.ts does.
…ck folders through HostFiles

The memory layer's useLeasesFor holds each lease as a table entry at the lease
file's path: hold publishes the lease, then probes the gate entry, and refuses
with today's WorkspaceMutationInProgressError text. isHeld sees every live lease
of that kind on the table. The contract gets the gate and isHeld cases on all
four layers, which replace the two node-only foreign tests.

The lock folders go to an injected HostFiles (SessionLocksMemory now needs
HostFilesTag), so the golden run on the memory volume leaves nothing under
<root>/locks. A waiter takes its FIFO place before it awaits its folder, and a
failed mkdir gives up the place or the lock. The FIFO case now holds the mkdir
calls and readies the later folder first, so it fails at once without that order.
@chatgpt-codex-connector

chatgpt-codex-connector Bot commented Oct 24, 2026 •

Copy link
Copy Markdown

Codex Review Summary

This comment shows the latest Codex review activity on this pull request.

Review Status Commit Review trigger
📝 Code Review ✅ Completed 2026-10-24T01:05:03.198083Z 517fef0 New commits
🔒 Security Review ✅ Completed 2026-10-24T01:05:39.471846Z 517fef0 New commits
ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review" or "@codex security review".

Codex reacts with 👀 while any review is running, comments if it has suggestions, and reacts with 👍 once all reviews finish with no findings.

@chatgpt-codex-connector chatgpt-codex-connector Bot left a comment

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

💡 Codex Review

Here are some automated review suggestions for this pull request.

Reviewed commit: d305291a12

ℹ️ About Codex in GitHub

Your team has set up Codex to review pull requests in this repo. Reviews are triggered when you

  • Open a pull request for review
  • Mark a draft as ready
  • Comment "@codex review".

If Codex has suggestions, it will comment; otherwise it will react with 👍.

Codex can also answer questions or update the PR. Try commenting "@codex address that feedback".

Comment thread src/node/services/sessionLocks/memorySessionLocks.testHarness.ts
@github-actions

github-actions Bot commented Oct 24, 2026 •

Copy link
Copy Markdown
Contributor

🟢 e2e stories (Haiku): 14 passed

27 agent steps · 9 replayed from cache · 26 model calls · 162.7k tokens (72% cached)
Spend: $0.09 of the $1.00 cap (proxy ledger)

All 14 tests in 9 files
Test Agent Time
🟢 chatRestart.e2e.ts · 1 passed 2 steps · 4 calls 23.6s
🟢 a chat survives a restart of the server 2 steps · 4 calls 23.6s
🟢 compaction.e2e.ts · 1 passed 3 steps · 9 calls 44.1s
🟢 /compact replaces the old chat with a summary 3 steps · 9 calls 44.1s
🟢 pluginTrustRestart.e2e.ts · 1 passed 4 steps · 2 calls 10.3s
🟢 a trusted plugin hook stays trusted after the server restarts 4 steps · 2 calls 10.3s
🟢 settingsRoundTrip.e2e.ts · 1 passed 3 steps · 1 call 11.2s
🟢 a chat survives trips through Settings 3 steps · 1 call 11.2s
🟢 sidebarPerWorkspace.e2e.ts · 1 passed 4 steps · 1 call 4.1s
🟢 a new workspace starts with its right sidebar collapsed while another keeps it open 4 steps · 1 call 4.1s
🟢 smoke.e2e.ts · 1 passed 2 steps · 1 call 2.4s
🟢 the agent opens the playground workspace 2 steps · 1 call 2.4s
🟢 streamErrors.e2e.ts · 1 passed 1 step · 1 call 7.1s
🟢 stream errors show in the chat, and the chat recovers 1 step · 1 call 7.1s
🟢 todoNudge.e2e.ts · 1 passed 2 steps · 1 call 5.2s
🟢 the agent finishes open TODO items after the turn-end nudge 2 steps · 1 call 5.2s
🟢 touchAlignment.e2e.ts · 6 passed 6 steps · 6 calls 21.9s
🟢 the Settings close button is level with the title on a touch screen 390px wide 1 step · 1 call 3.6s
🟢 the Settings close button is level with the title on a touch screen 768px wide 1 step · 1 call 4.0s
🟢 Backup settings checkboxes are small and level with their labels on a touch phone 1 step · 1 call 4.6s
🟢 the heartbeat checkbox is small and level with its label on a touch phone 1 step · 1 call 3.3s
🟢 a reply's model and time stay on one line on a touch phone 1 step · 1 call 3.4s
🟢 the workspace actions menu stays on the title row on a touch phone 1 step · 1 call 3.2s

e2e 0.20.0 · 2m 13s · web · run artifacts

🟢 e2e Electron stories (Haiku): 3 passed

3 agent steps · 3 model calls · 8.1k tokens (32% cached)
Spend: $0.01 of the $1.00 cap (proxy ledger)

All 3 tests in 3 files
Test Agent Time
🟢 electron/deepLink.e2e.ts · 1 passed 1 step · 1 call 6.8s
🟢 a deep link of a second launch starts a chat in the running app 1 step · 1 call 6.8s
🟢 electron/menuBar.e2e.ts · 1 passed 1 step · 1 call 7.0s
🟢 the workspace menu bar is the desktop title bar 1 step · 1 call 7.0s
🟢 electron/smoke.e2e.ts · 1 passed 1 step · 1 call 5.0s
🟢 Xum Electron opens the playground workspace 1 step · 1 call 5.0s

e2e 0.20.0 · 20.8s · electron · run artifacts

🟢 e2e agent repros (Haiku): no tests selected

Spend: $0.00 of the $1.00 cap (proxy ledger)

e2e 0.20.0 · 5.3s · 2 targets (web, phone) · run artifacts

…kdir

The Node lock checks the caller's signal after its mkdir. The memory layer took
a free lock before the mkdir, so an abort during it still returned the lock. It
now checks the signal after the mkdir and frees the lock it took.
@ThomasK33
ThomasK33 added this pull request to the merge queue Oct 24, 2026
Merged via the queue into main with commit 06fe1fb Oct 24, 2026
33 of 34 checks passed
@ThomasK33
ThomasK33 deleted the phase5/b-lease-memory branch October 24, 2026 01:54
yermakoffivan pushed a commit to yermakoffivan/mux that referenced this pull request Oct 26, 2026
…ck attempt count, temp lock folders, foreign ENOENT) (coder#7493)

## Summary

Test-only PR for the open test items of coder#7326 (phase 4 review
follow-ups). It fixes one temp-root leak, removes a host-load race from
the try-lock test, cleans up the temp lock folders of
`crossProcessLock.test.ts`, and makes the foreign-holder test layer give
ENOENT for a missing parent as the Node lock does. No product code
changes.

Refs coder#7326

## Items

| coder#7326 item | Result |
| --- | --- |
| Finding 3: a failed seed append leaks the temp root of the refine
scenario | Done. `oneRefineApply`
(`sessionLocks.callOptions.scenarios.testHarness.ts`) now opens its
`try` right after `createTestHistoryService()`, so the seeding (folder,
config edit, appends) runs inside it and `cleanup()` always runs. The
scenario is a function that a test calls, not a test file, so a `try` is
its `beforeEach`. |
| PR 17 advisor item 1: the T5 test races one try-lock attempt against a
1 s timer | Done. The test counts the attempts (each one links a record
onto `lockPath`) and expects exactly 1. A second attempt moves the
frozen clock past the deadline, so a retrying build still ends. |
| PR 17 advisor item 2: `tempLockPath()` leaves its folders | Done.
`tempLockPath()` records each folder, and a file-level `afterEach`
removes them. |
| PR 6 (coder#7367) follow-up: the memory layer does not create the missing
parent of `lockPath` | Already done on main, no code here: coder#7376 added
`acquireAfterFolder` (`memorySessionLocks.testHarness.ts`), which makes
the folder through `HostFiles` before the take, and the contract case "a
file lock creates its missing parent" (coder#7372) runs on the memory layer.
|
| PR 6 (coder#7367) follow-up: a live fake holder refuses instead of ENOENT
when `createParentDirectory` is false | Done. `wrapWithForeignHolders`
hands the call to the inner lock when `createParentDirectory` is false
and the parent is gone. The inner lock then fails with ENOENT, as the
Node lock does before it reads any holder. coder#7485 is still open, so
`fileLock.ts` is not touched. |
| Finding 4: Windows path separators | No code. Reasoned close in coder#7326:
no CI lane runs this file on Windows. |

## Validation

- T5 fails on the try-lock mutant: with `crossProcessLock.ts:585`
changed from `>=` to `>`, the test fails with "Expected: 1, Received:
2". It passes on main code.
- The new contract test "foreign: a missing parent wins over a live
holder when createParentDirectory is false" fails without the wrapper
change (it gets the timeout error), and its second half (parent back,
live holder refuses) fails if the wrapper always delegates.
- A run of `crossProcessLock.test.ts` leaves no `cross-process-lock-*`
folder from `tempLockPath()`. One older folder kind,
`cross-process-lock-fifo-*` from the threadpool-starvation test, still
stays behind: it is not made by `tempLockPath()` and is outside this
item.

## Risks

Low. Test code only. The foreign-holder change only affects tests that
pass `createParentDirectory: false` with a missing parent.

---

_Generated with `xum` • Model: `anthropic:claude-opus-5-5` • Thinking:
`high` • Cost: `$198.14`_

<!-- mux-attribution: model=anthropic:claude-opus-5-5 thinking=high
costs=198.14 -->
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