@quolu/lattice 0.53.1 → 0.54.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
@@ -2,14 +2,26 @@
2
2
 
3
3
  import { readBridgeConfig } from '../src/bridge-config.mjs';
4
4
  import {
5
- readBridgeStopRequest, removeBridgeDaemonActiveMarker, removeBridgeDaemonDescriptor,
6
- writeBridgeDaemonDescriptor, writeBridgeStopReceipt,
5
+ bridgeDaemonVersionDrifted, readBridgeStopRequest, removeBridgeDaemonActiveMarker,
6
+ removeBridgeDaemonDescriptor, writeBridgeDaemonDescriptor, writeBridgeStopReceipt,
7
7
  } from '../src/bridge-daemon.mjs';
8
8
  import { createBridgeHubHeartbeatController } from '../src/bridge-hub-heartbeat.mjs';
9
+ import { migrateBridgeToHub, retireBridgeTunnelLaunchAgent } from '../src/bridge-hub-migration.mjs';
10
+ import { bridgeRegistrarSettings } from '../src/bridge-registrar.mjs';
9
11
  import { bridgeRuntimeController } from '../src/bridge-server.mjs';
10
12
 
13
+ // Throttles bridgeDaemonVersionDrifted's disk read and the migration/tunnel-
14
+ // retirement checks' subprocess calls (ssh, launchctl) — the 250ms reconcile
15
+ // tick exists for local responsiveness, not for polling external processes
16
+ // 4x/sec. Migration and tunnel-retirement share this interval: once migrated,
17
+ // the migration check itself becomes a single cheap config-field read
18
+ // (`current.hub !== null`), so there is no cost to leaving both armed forever.
19
+ const BACKGROUND_CHECK_INTERVAL_MS = 60_000;
20
+
11
21
  const env = process.env;
12
22
  const hubHeartbeat = createBridgeHubHeartbeatController({ env });
23
+ let lastVersionCheckAt = 0;
24
+ let lastMigrationCheckAt = 0;
13
25
  const instanceToken = env.LATTICE_BRIDGE_INSTANCE_TOKEN;
14
26
  if (typeof instanceToken !== 'string' || !/^[0-9a-f]{64}$/u.test(instanceToken)) {
15
27
  process.stderr.write(`${JSON.stringify({ schema: 'lattice.bridge_daemon_error.v1',
@@ -61,6 +73,51 @@ timer = setInterval(async () => {
61
73
  await removeBridgeDaemonActiveMarker({ env });
62
74
  process.exit(0);
63
75
  }
76
+ // A stale-version exit is a clean stop, not a failure: whatever supervises
77
+ // this process (launchd KeepAlive, the Windows supervisor loop) relaunches
78
+ // it immediately, and the fresh process imports whatever is on disk now —
79
+ // this is the mechanism that makes "npm update, done" actually true rather
80
+ // than leaving an already-running daemon serving replaced code forever.
81
+ if (Date.now() - lastVersionCheckAt >= BACKGROUND_CHECK_INTERVAL_MS) {
82
+ lastVersionCheckAt = Date.now();
83
+ if (await bridgeDaemonVersionDrifted({})) {
84
+ await close();
85
+ await removeBridgeDaemonDescriptor({ env });
86
+ await removeBridgeDaemonActiveMarker({ env });
87
+ process.exit(0);
88
+ }
89
+ }
90
+ // bh5 auto-migration: a terminal still carrying the pre-hub registrar env
91
+ // (LaunchAgent-baked, so it outlives any single process) upgrades itself
92
+ // to hub registration with no operator action — see bridge-hub-migration.mjs's
93
+ // module doc for why this is the whole point of the owner's "update it,
94
+ // done" acceptance test. Runs on the same throttle as the version check;
95
+ // once migrated it is a single cheap config-field read, so leaving it
96
+ // armed forever costs nothing. Tunnel retirement is attempted alongside
97
+ // it (not gated to the migration transition alone) so a retirement that
98
+ // failed once keeps getting retried rather than being a one-shot.
99
+ if (Date.now() - lastMigrationCheckAt >= BACKGROUND_CHECK_INTERVAL_MS) {
100
+ lastMigrationCheckAt = Date.now();
101
+ if (bridgeRegistrarSettings(env) !== null) {
102
+ await migrateBridgeToHub({ env }).catch((error) => {
103
+ process.stderr.write(`${JSON.stringify({ schema: 'lattice.bridge_daemon_error.v1',
104
+ code: error?.code ?? 'BRIDGE_HUB_MIGRATION_FAILED',
105
+ message: error?.message ?? 'bridge hub migration failed' })}\n`);
106
+ });
107
+ const migratedConfig = await readBridgeConfig({ env });
108
+ if (migratedConfig?.hub !== null && migratedConfig?.hub !== undefined) {
109
+ // retireBridgeTunnelLaunchAgent's own contract never throws for any
110
+ // expected outcome (not loaded, bootout failure, launchctl absent —
111
+ // all typed returns); this catch is only for a genuinely unexpected
112
+ // bug in that function, and it is still logged, not swallowed.
113
+ await retireBridgeTunnelLaunchAgent({ env }).catch((error) => {
114
+ process.stderr.write(`${JSON.stringify({ schema: 'lattice.bridge_daemon_error.v1',
115
+ code: error?.code ?? 'BRIDGE_TUNNEL_RETIREMENT_FAILED',
116
+ message: error?.message ?? 'bridge tunnel retirement failed' })}\n`);
117
+ });
118
+ }
119
+ }
120
+ }
64
121
  const config = await readBridgeConfig({ env });
65
122
  if (config === null || !config.enabled) {
66
123
  await close();
@@ -1,10 +1,7 @@
1
1
  #!/usr/bin/env node
2
2
 
3
- import { stat } from 'node:fs/promises';
4
- import path from 'node:path';
5
-
6
- import { readTodoStoreStable } from '../src/todo-store.mjs';
7
3
  import { TODO_STATUS_DISPATCH_ONLY, projectTodoStatus } from '../src/todo-status.mjs';
4
+ import { createTodoStoreCache } from '../src/todo-store-cache.mjs';
8
5
  import { ganttLiveHeadDigest, renderTodoGanttForProject } from '../src/todo-cli.mjs';
9
6
  import { readProjectExternalPane } from '../src/project-identity.mjs';
10
7
  import {
@@ -35,27 +32,12 @@ const port = typeof configured === 'string' && /^(?:0|[1-9][0-9]{0,4})$/u.test(c
35
32
  const registry = createTodoGanttProjectRegistry();
36
33
  const roots = new Map();
37
34
  const reportedStoreReadFailures = new Set();
38
- const storeCache = new Map();
39
-
40
- function manifestFingerprint(value) {
41
- return `${value.dev}:${value.ino}:${value.size}:${value.mtimeMs}:${value.ctimeMs}`;
42
- }
43
-
44
- async function readCachedStore(repoRoot) {
45
- const manifestRef = path.join(repoRoot, '.lattice', 'todo', 'manifest.json');
46
- for (let attempt = 0; attempt < 3; attempt += 1) {
47
- const beforeFingerprint = manifestFingerprint(await stat(manifestRef));
48
- const cached = storeCache.get(repoRoot);
49
- if (cached?.fingerprint === beforeFingerprint) return cached.store;
50
- const store = await readTodoStoreStable({ repoRoot });
51
- const afterFingerprint = manifestFingerprint(await stat(manifestRef));
52
- if (beforeFingerprint === afterFingerprint) {
53
- storeCache.set(repoRoot, { fingerprint: afterFingerprint, store });
54
- return store;
55
- }
56
- }
57
- return readTodoStoreStable({ repoRoot });
58
- }
35
+ // room 2488's "gantt serve固着" symptom (a dashboard stuck on stale/broken state that
36
+ // only a process restart cleared, with the store and git both already fixed) traced to
37
+ // this cache — see src/todo-store-cache.mjs for why it is content-digest keyed rather
38
+ // than stat()-fingerprint keyed.
39
+ const storeCache = createTodoStoreCache();
40
+ const readCachedStore = (repoRoot) => storeCache.read(repoRoot);
59
41
 
60
42
  async function synchronize() {
61
43
  const active = await readVisibleTodoDashboardProjects({ env,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@quolu/lattice",
3
- "version": "0.53.1",
3
+ "version": "0.54.0",
4
4
  "description": "Schedulability compiler for multi-agent development: observe real code boundaries, refactor the conflicting seam, recompile the plan for parallel execution",
5
5
  "author": {
6
6
  "name": "Quo / クオ at kitepon.dev",
@@ -105,3 +105,22 @@ export function resolveBridgeListenAddress({ configured, interfaces = {} } = {})
105
105
  return { state: 'rebindable', effective: candidates[0], configured: wanted, candidates,
106
106
  reason: 'configured_address_absent_rebound_within_subnet' };
107
107
  }
108
+
109
+ /**
110
+ * Pick a LAN-facing address for a terminal that has none yet — bh5's Mac
111
+ * auto-migration (loopback + ssh tunnel → direct hub registration) and any
112
+ * first-time setup with no address preference. Deliberately simpler than
113
+ * `resolveBridgeListenAddress`: that function preserves "same intent" across
114
+ * a DHCP move by requiring a same-subnet match against an already-configured
115
+ * address, but there is no prior intent to preserve when the terminal had no
116
+ * LAN presence before. The first non-internal address, sorted for
117
+ * determinism, is a reasonable default; ambiguous hosts (more than one
118
+ * candidate) are still reported so a caller can choose to surface that rather
119
+ * than silently pick.
120
+ */
121
+ export function pickBridgeLanAddress({ interfaces = {}, family = null } = {}) {
122
+ const candidates = bridgeHostAddresses(interfaces)
123
+ .filter((entry) => !entry.internal && (family === null || entry.family === family))
124
+ .map((entry) => entry.address);
125
+ return { address: candidates[0] ?? null, candidates };
126
+ }
@@ -1,7 +1,7 @@
1
1
  import { spawn } from 'node:child_process';
2
2
  import { randomBytes } from 'node:crypto';
3
3
  import { constants as fsConstants } from 'node:fs';
4
- import { chmod, lstat, open, rename, rm, writeFile } from 'node:fs/promises';
4
+ import { chmod, lstat, open, readFile, rename, rm, writeFile } from 'node:fs/promises';
5
5
  import { isIP } from 'node:net';
6
6
  import path from 'node:path';
7
7
  import { parseTree } from 'jsonc-parser';
@@ -9,6 +9,7 @@ import { parseTree } from 'jsonc-parser';
9
9
  import {
10
10
  BRIDGE_PORT_MAX, BRIDGE_PORT_MIN, BridgeConfigError, bridgeConfigPaths, readBridgeConfig,
11
11
  } from './bridge-config.mjs';
12
+ import packageJson from '../package.json' with { type: 'json' };
12
13
 
13
14
  const DESCRIPTOR_SCHEMA = 'lattice.bridge_daemon.v1';
14
15
  const START_TIMEOUT_MS = 5_000;
@@ -379,3 +380,25 @@ export async function stopBridgeDaemon({ env = process.env } = {}) {
379
380
  }
380
381
  throw new BridgeConfigError('BRIDGE_DAEMON_STOP_FAILED', 'bridge daemon did not stop');
381
382
  }
383
+
384
+ const PACKAGE_JSON_PATH = path.resolve(import.meta.dirname, '../package.json');
385
+
386
+ /**
387
+ * Whether the on-disk package.json now reports a different version than the
388
+ * one this running process loaded at start — i.e. `npm install`/`update`
389
+ * replaced the files under a still-running daemon (the "daemon の版持ち"
390
+ * trap, AGENTS.md: a daemon keeps serving whatever module it imported at
391
+ * startup no matter what gets installed afterward). A long-running process
392
+ * cannot hot-swap its own already-imported modules; the only fix is to exit
393
+ * and let whatever supervises it (launchd's KeepAlive, the Windows
394
+ * supervisor's restart loop) relaunch a fresh process that imports the new
395
+ * code. Read failures return `false` — an unrelated fs hiccup must not force
396
+ * a restart loop.
397
+ */
398
+ export async function bridgeDaemonVersionDrifted({ packageJsonPath = PACKAGE_JSON_PATH } = {}) {
399
+ let onDisk;
400
+ try {
401
+ onDisk = JSON.parse(await readFile(packageJsonPath, 'utf8'));
402
+ } catch { return false; }
403
+ return typeof onDisk?.version === 'string' && onDisk.version !== packageJson.version;
404
+ }
@@ -0,0 +1,118 @@
1
+ /**
2
+ * Mac auto-migration (bh5): a terminal running the old single-slot topology
3
+ * (loopback bridge + `LATTICE_BRIDGE_REGISTRAR_*` ssh registrar) upgrades to
4
+ * hub registration with zero manual commands. The owner's stated acceptance
5
+ * test is literal: an agent who knows nothing about hub/port/flags/migration
6
+ * runs a normal package update, and the bridge finds its own way onto the
7
+ * public page (room 2446, 2461) — no `--hub` flag, no LaunchAgent surgery.
8
+ *
9
+ * The trigger is the registrar call the daemon already makes on every new
10
+ * binding (`bridge-registrar.mjs`'s `registerBridgeUpstream`, used by
11
+ * `bridge-launch-agent.mjs`'s plist today only to keep the reverse-proxy
12
+ * literal current). The v2 registrar script (room 2452) additionally returns
13
+ * `hub_url` in that same response — this module is what turns "a hub_url
14
+ * showed up in a registration reply" into "reconfigure this bridge to use
15
+ * it and retire the ssh tunnel", entirely from information the terminal
16
+ * already had a reason to ask for.
17
+ */
18
+
19
+ import { execFile } from 'node:child_process';
20
+ import { rm } from 'node:fs/promises';
21
+ import { networkInterfaces } from 'node:os';
22
+ import path from 'node:path';
23
+ import { promisify } from 'node:util';
24
+
25
+ import { pickBridgeLanAddress } from './bridge-address.mjs';
26
+ import { configureBridge, readBridgeConfig } from './bridge-config.mjs';
27
+ import {
28
+ bridgeRegistrarSettings, deriveBridgeHubUrlFromRegistration, registerBridgeUpstream,
29
+ } from './bridge-registrar.mjs';
30
+
31
+ const execFileAsync = promisify(execFile);
32
+
33
+ /** The ssh reverse-tunnel LaunchAgent from the pre-hub topology
34
+ * (docs/operations/lattice-kitepon-deployment.md) — distinct from
35
+ * `dev.kitepon.lattice.bridge`, which `bridge-launch-agent.mjs` owns and
36
+ * this migration never touches. */
37
+ export const BRIDGE_TUNNEL_LAUNCH_AGENT_LABEL = 'dev.kitepon.lattice.bridge-tunnel';
38
+
39
+ /**
40
+ * Attempt one migration step. Called from the daemon's reconcile loop, so it
41
+ * must be cheap to call when there is nothing to do and must never throw for
42
+ * a condition the caller should just keep running through (no registrar
43
+ * configured, already migrated, hub unreachable this cycle) — only a
44
+ * genuinely invalid registrar env (`bridgeRegistrarSettings`'s own
45
+ * half-configured-pair failure) propagates, matching every other registrar
46
+ * caller's behavior.
47
+ */
48
+ export async function migrateBridgeToHub({
49
+ env = process.env, interfaces = networkInterfaces(), readConfig = readBridgeConfig,
50
+ configure = configureBridge, register = registerBridgeUpstream,
51
+ } = {}) {
52
+ const registrar = bridgeRegistrarSettings(env);
53
+ if (registrar === null) return { migrated: false, reason: 'registrar_not_configured' };
54
+ const current = await readConfig({ env });
55
+ if (current === null || !current.enabled) return { migrated: false, reason: 'bridge_not_enabled' };
56
+ if (current.hub !== null) return { migrated: false, reason: 'already_migrated' };
57
+
58
+ const registration = await register({ port: current.listen.port, env });
59
+ const hubUrl = deriveBridgeHubUrlFromRegistration(registration);
60
+ if (hubUrl === null) return { migrated: false, reason: 'no_hub_url_available', registration };
61
+
62
+ const picked = pickBridgeLanAddress({ interfaces });
63
+ if (picked.address === null) return { migrated: false, reason: 'no_lan_address_available' };
64
+
65
+ const updated = await configure({
66
+ address: picked.address, port: null, reuseCurrentPort: false,
67
+ upstream: current.upstream, hub: { url: hubUrl },
68
+ allowedHosts: current.allowed_hosts.filter((host) => host !== current.listen.address),
69
+ env,
70
+ });
71
+ return { migrated: true, config: updated, hubUrl };
72
+ }
73
+
74
+ function launchAgentPlistPath(label, env) {
75
+ const home = env.HOME;
76
+ if (typeof home !== 'string' || !path.isAbsolute(home)) return null;
77
+ return path.join(home, 'Library', 'LaunchAgents', `${label}.plist`);
78
+ }
79
+
80
+ /**
81
+ * Retire the pre-hub ssh reverse-tunnel LaunchAgent, once migration has
82
+ * actually landed a hub URL — never speculatively, so a bridge that never
83
+ * reaches `migrateBridgeToHub`'s success path never touches this agent.
84
+ * Idempotent and non-fatal: a tunnel that is not loaded (already retired, or
85
+ * this deployment never had one) is success, not an error, and any
86
+ * `launchctl` failure here must not crash a daemon whose primary job — hub
87
+ * registration — has already succeeded by the time this runs.
88
+ */
89
+ export async function retireBridgeTunnelLaunchAgent({
90
+ env = process.env, uid = process.getuid?.(), runner = defaultTunnelLaunchctlRunner,
91
+ label = BRIDGE_TUNNEL_LAUNCH_AGENT_LABEL,
92
+ } = {}) {
93
+ if (!Number.isSafeInteger(uid) || uid < 0) return { retired: false, reason: 'uid_unavailable' };
94
+ const service = `gui/${uid}/${label}`;
95
+ let probe;
96
+ try { probe = await runner(['print', service]); } catch { return { retired: false, reason: 'launchctl_unavailable' }; }
97
+ if (probe.code !== 0) return { retired: false, reason: 'not_loaded' };
98
+ try {
99
+ const bootout = await runner(['bootout', service]);
100
+ if (bootout.code !== 0) return { retired: false, reason: 'bootout_failed' };
101
+ } catch { return { retired: false, reason: 'bootout_failed' }; }
102
+ const plistPath = launchAgentPlistPath(label, env);
103
+ if (plistPath !== null) await rm(plistPath, { force: true }).catch(() => {});
104
+ return { retired: true };
105
+ }
106
+
107
+ export async function defaultTunnelLaunchctlRunner(args) {
108
+ try {
109
+ const result = await execFileAsync('/bin/launchctl', args, { encoding: 'utf8' });
110
+ return { code: 0, stdout: result.stdout ?? '', stderr: result.stderr ?? '' };
111
+ } catch (error) {
112
+ if (Number.isInteger(error?.code)) {
113
+ return { code: error.code, stdout: error.stdout ?? '', stderr: error.stderr ?? '' };
114
+ }
115
+ throw error;
116
+ }
117
+ }
118
+
@@ -523,6 +523,15 @@ export async function startBridgeHubServer({
523
523
  return;
524
524
  }
525
525
  const rawPath = requestUrl.split('?', 1)[0];
526
+ if (rawPath === '/') {
527
+ // The old single-terminal bridge served the project index at root; hub only ever
528
+ // routed `/projects/*`, so the public entrance 404'd (room 2488 — a functional
529
+ // regression, not a design one: the front door itself was gone, independent of how
530
+ // it looks). Redirect rather than duplicate handleProjectsIndex's logic at a second path.
531
+ response.writeHead(301, { location: '/projects/', 'cache-control': 'no-store' });
532
+ response.end();
533
+ return;
534
+ }
526
535
  if (rawPath === '/__lattice/hub/register') { await handleRegister(incoming, response); return; }
527
536
  if (rawPath === '/projects/') { await handleProjectsIndex(incoming, response); return; }
528
537
  const match = PROJECT_ROUTE.exec(rawPath);
@@ -0,0 +1,45 @@
1
+ /**
2
+ * A content-addressed cache in front of `readTodoStoreStable`, for long-running
3
+ * processes (the dashboard daemon) that re-render on every request/poll and would
4
+ * otherwise re-validate the whole merged store every time.
5
+ *
6
+ * An earlier version keyed the cache on a stat()-derived fingerprint (dev/ino/size/
7
+ * mtimeMs/ctimeMs). On filesystems with coarse mtime granularity (observed on
8
+ * WSL/DrvFs), two different manifest contents written close together can land on the
9
+ * same fingerprint, so the cache would serve a stale store as current — and since the
10
+ * mismatch never surfaces as an error, nothing invalidates it until the process
11
+ * restarts. Hashing the manifest's actual bytes costs one small file read and removes
12
+ * that failure mode entirely.
13
+ */
14
+
15
+ import { createHash } from 'node:crypto';
16
+ import { readFile } from 'node:fs/promises';
17
+ import path from 'node:path';
18
+
19
+ import { readTodoStoreStable } from './todo-store.mjs';
20
+
21
+ async function manifestContentDigest(manifestRef) {
22
+ return createHash('sha256').update(await readFile(manifestRef)).digest('hex');
23
+ }
24
+
25
+ /**
26
+ * @param {object} [options]
27
+ * @param {(options: object) => Promise<object>} [options.readStable] injection point for tests
28
+ */
29
+ export function createTodoStoreCache({ readStable = readTodoStoreStable } = {}) {
30
+ const cache = new Map();
31
+ return {
32
+ async read(repoRoot) {
33
+ const manifestRef = path.join(repoRoot, '.lattice', 'todo', 'manifest.json');
34
+ const digest = await manifestContentDigest(manifestRef);
35
+ const cached = cache.get(repoRoot);
36
+ if (cached?.digest === digest) return cached.store;
37
+ // Read before caching: a failure here (including the store's own inconsistency
38
+ // detection) must not populate the cache, so the very next call re-reads instead
39
+ // of serving a poisoned entry.
40
+ const store = await readStable({ repoRoot });
41
+ cache.set(repoRoot, { digest, store });
42
+ return store;
43
+ },
44
+ };
45
+ }
@@ -1392,6 +1392,18 @@ export async function readTodoStoreStable(options = {}) {
1392
1392
  if (!Number.isSafeInteger(maximumAttempts) || maximumAttempts < 1 || maximumAttempts > 16) {
1393
1393
  throw new TypeError('maximumAttempts must be 1..16');
1394
1394
  }
1395
+ // `manifest_journal_head_mismatch`/`manifest_plan_binding_mismatch` are treated as a
1396
+ // transient in-flight write and retried. That is correct while a concurrent writer is
1397
+ // mid-commit, but a crashed writer can leave the SAME mismatch permanently — retrying
1398
+ // forever against a manifest that never changes just burns attempts and then reports
1399
+ // a content-free STORE_BUSY, hiding the real STORE_INCONSISTENT reason the caller needs
1400
+ // to actually recover (2026-08-10 P0: a crashed `todo start` left exactly this behind).
1401
+ // Track the manifest digest seen at the START of the previous attempt: if it is
1402
+ // unchanged going into this attempt too, no writer completed anything in between, so
1403
+ // the "transient" classification no longer has evidence behind it — surface the real
1404
+ // error instead of exhausting the budget on a window that was never closing.
1405
+ let previousAttemptManifestDigest = null;
1406
+ let lastError = null;
1395
1407
  for (let attempt = 1; attempt <= maximumAttempts; attempt += 1) {
1396
1408
  const before = await readArtifact(repoRoot, MANIFEST_REF, {
1397
1409
  code: 'STORE_INCONSISTENT', maxBytes: TODO_LIMITS.snapshotBytes, validate: validateTodoManifest,
@@ -1411,12 +1423,21 @@ export async function readTodoStoreStable(options = {}) {
1411
1423
  const transientWriteWindow = error.code === 'STORE_INCONSISTENT'
1412
1424
  && ['manifest_journal_head_mismatch', 'manifest_plan_binding_mismatch']
1413
1425
  .includes(error.detail.reason);
1414
- if (before.manifest_digest === after.manifest_digest && !transientWriteWindow) throw error;
1426
+ const stableAcrossAttempts = previousAttemptManifestDigest === before.manifest_digest;
1427
+ if (before.manifest_digest === after.manifest_digest
1428
+ && (!transientWriteWindow || stableAcrossAttempts)) throw error;
1429
+ lastError = error;
1415
1430
  }
1431
+ previousAttemptManifestDigest = before.manifest_digest;
1416
1432
  if (attempt < maximumAttempts) {
1417
1433
  await new Promise((resolve) => setTimeout(resolve, Math.min(16, 2 ** attempt)));
1418
1434
  }
1419
1435
  }
1436
+ // Exhausted without ever observing a genuinely closing write. Surface the last typed
1437
+ // STORE_INCONSISTENT reason rather than a bare STORE_BUSY — the caller (and a human
1438
+ // reading the error) needs to know which store artifact actually disagrees, not just
1439
+ // that reads kept failing.
1440
+ if (lastError !== null) throw lastError;
1420
1441
  fail('STORE_BUSY', 'stable_read_exhausted', { attempts: maximumAttempts });
1421
1442
  }
1422
1443