@dzhechkov/harness-core 0.8.30 → 0.8.31

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.
Files changed (129) hide show
  1. package/.dz-manifest.json +233 -93
  2. package/README.md +269 -0
  3. package/dist/agentdb-index.d.ts +45 -0
  4. package/dist/agentdb-index.d.ts.map +1 -1
  5. package/dist/agentdb-index.js +345 -85
  6. package/dist/agentdb-index.js.map +1 -1
  7. package/dist/agentdb-reindex-marker.d.ts +170 -0
  8. package/dist/agentdb-reindex-marker.d.ts.map +1 -0
  9. package/dist/agentdb-reindex-marker.js +293 -0
  10. package/dist/agentdb-reindex-marker.js.map +1 -0
  11. package/dist/agentdb-snapshot-rotation.d.ts +169 -0
  12. package/dist/agentdb-snapshot-rotation.d.ts.map +1 -0
  13. package/dist/agentdb-snapshot-rotation.js +322 -0
  14. package/dist/agentdb-snapshot-rotation.js.map +1 -0
  15. package/dist/agentdb-snapshot.d.ts +126 -0
  16. package/dist/agentdb-snapshot.d.ts.map +1 -0
  17. package/dist/agentdb-snapshot.js +195 -0
  18. package/dist/agentdb-snapshot.js.map +1 -0
  19. package/dist/apply-leg.d.ts +202 -0
  20. package/dist/apply-leg.d.ts.map +1 -0
  21. package/dist/apply-leg.js +975 -0
  22. package/dist/apply-leg.js.map +1 -0
  23. package/dist/book-kb.d.ts.map +1 -1
  24. package/dist/book-kb.js +57 -1
  25. package/dist/book-kb.js.map +1 -1
  26. package/dist/brain.d.ts +3 -0
  27. package/dist/brain.d.ts.map +1 -1
  28. package/dist/brain.js +1 -1
  29. package/dist/brain.js.map +1 -1
  30. package/dist/cadence.d.ts +30 -1
  31. package/dist/cadence.d.ts.map +1 -1
  32. package/dist/cadence.js +66 -2
  33. package/dist/cadence.js.map +1 -1
  34. package/dist/guard.d.ts +14 -0
  35. package/dist/guard.d.ts.map +1 -1
  36. package/dist/guard.js +36 -0
  37. package/dist/guard.js.map +1 -1
  38. package/dist/index.d.ts +23 -8
  39. package/dist/index.d.ts.map +1 -1
  40. package/dist/index.js +18 -5
  41. package/dist/index.js.map +1 -1
  42. package/dist/integration-probe-worker.d.ts.map +1 -1
  43. package/dist/integration-probe-worker.js +4 -6
  44. package/dist/integration-probe-worker.js.map +1 -1
  45. package/dist/integrations-verify.d.ts.map +1 -1
  46. package/dist/integrations-verify.js +3 -1
  47. package/dist/integrations-verify.js.map +1 -1
  48. package/dist/managed-hooks.d.ts +11 -2
  49. package/dist/managed-hooks.d.ts.map +1 -1
  50. package/dist/managed-hooks.js +33 -7
  51. package/dist/managed-hooks.js.map +1 -1
  52. package/dist/mutation-gate.d.ts +35 -1
  53. package/dist/mutation-gate.d.ts.map +1 -1
  54. package/dist/mutation-gate.js +49 -9
  55. package/dist/mutation-gate.js.map +1 -1
  56. package/dist/operations.d.ts.map +1 -1
  57. package/dist/operations.js +94 -0
  58. package/dist/operations.js.map +1 -1
  59. package/dist/patterns.d.ts.map +1 -1
  60. package/dist/patterns.js +23 -4
  61. package/dist/patterns.js.map +1 -1
  62. package/dist/repository-origin.d.ts +1 -1
  63. package/dist/repository-origin.d.ts.map +1 -1
  64. package/dist/repository-origin.js +7 -1
  65. package/dist/repository-origin.js.map +1 -1
  66. package/dist/round-exec.d.ts +41 -0
  67. package/dist/round-exec.d.ts.map +1 -0
  68. package/dist/round-exec.js +45 -0
  69. package/dist/round-exec.js.map +1 -0
  70. package/dist/round.d.ts +109 -0
  71. package/dist/round.d.ts.map +1 -0
  72. package/dist/round.js +148 -0
  73. package/dist/round.js.map +1 -0
  74. package/dist/run-records.d.ts +8 -0
  75. package/dist/run-records.d.ts.map +1 -1
  76. package/dist/run-records.js +69 -3
  77. package/dist/run-records.js.map +1 -1
  78. package/dist/setup.d.ts +8 -0
  79. package/dist/setup.d.ts.map +1 -1
  80. package/dist/setup.js +166 -3
  81. package/dist/setup.js.map +1 -1
  82. package/dist/sqlite-read-helpers.d.ts +60 -0
  83. package/dist/sqlite-read-helpers.d.ts.map +1 -0
  84. package/dist/sqlite-read-helpers.js +118 -0
  85. package/dist/sqlite-read-helpers.js.map +1 -0
  86. package/dist/statusline.d.ts +20 -3
  87. package/dist/statusline.d.ts.map +1 -1
  88. package/dist/statusline.js +40 -7
  89. package/dist/statusline.js.map +1 -1
  90. package/dist/store-counts.d.ts +30 -0
  91. package/dist/store-counts.d.ts.map +1 -1
  92. package/dist/store-counts.js +189 -0
  93. package/dist/store-counts.js.map +1 -1
  94. package/dist/usage.d.ts +55 -0
  95. package/dist/usage.d.ts.map +1 -1
  96. package/dist/usage.js +184 -33
  97. package/dist/usage.js.map +1 -1
  98. package/dist/vector-tier.d.ts +18 -1
  99. package/dist/vector-tier.d.ts.map +1 -1
  100. package/dist/vector-tier.js +26 -2
  101. package/dist/vector-tier.js.map +1 -1
  102. package/package.json +6 -6
  103. package/sbom.json +442 -92
  104. package/src/agentdb-index.ts +315 -32
  105. package/src/agentdb-reindex-marker.ts +330 -0
  106. package/src/agentdb-snapshot-rotation.ts +393 -0
  107. package/src/agentdb-snapshot.ts +222 -0
  108. package/src/apply-leg.ts +1046 -0
  109. package/src/book-kb.ts +64 -1
  110. package/src/brain.ts +11 -2
  111. package/src/cadence.ts +87 -2
  112. package/src/guard.ts +46 -0
  113. package/src/index.ts +58 -5
  114. package/src/integration-probe-worker.ts +4 -6
  115. package/src/integrations-verify.ts +3 -1
  116. package/src/managed-hooks.ts +32 -7
  117. package/src/mutation-gate.ts +87 -8
  118. package/src/operations.ts +89 -0
  119. package/src/patterns.ts +23 -4
  120. package/src/repository-origin.ts +7 -1
  121. package/src/round-exec.ts +85 -0
  122. package/src/round.ts +228 -0
  123. package/src/run-records.ts +77 -2
  124. package/src/setup.ts +198 -3
  125. package/src/sqlite-read-helpers.ts +128 -0
  126. package/src/statusline.ts +57 -9
  127. package/src/store-counts.ts +245 -1
  128. package/src/usage.ts +243 -29
  129. package/src/vector-tier.ts +45 -4
@@ -12,10 +12,19 @@
12
12
  * @packageDocumentation
13
13
  */
14
14
 
15
- import { existsSync, mkdirSync } from 'node:fs';
16
- import { join, dirname } from 'node:path';
15
+ import { existsSync, mkdirSync, copyFileSync, realpathSync } from 'node:fs';
16
+ import { join, dirname, resolve, relative, isAbsolute, basename } from 'node:path';
17
17
  import { pathToFileURL } from 'node:url';
18
18
  import { createRequire } from 'node:module';
19
+
20
+ import { openSqliteReadOnly } from '@dzhechkov/memory';
21
+
22
+ import { applyReadonlyPragmas } from './sqlite-read-helpers.js';
23
+ import { rotatePreReindexSnapshotsUnlocked, type SnapshotRotationReport } from './agentdb-snapshot-rotation.js';
24
+ import { snapshotSqliteDatabase, restoreSqliteSnapshot, type SnapshotMethod, type SnapshotDbCtor } from './agentdb-snapshot.js';
25
+ import { withAgentdbSnapshotLock, writeReindexMarker, clearReindexMarker, markReindexMarkerRecoveryRequired, reindexMarkerPath, msFromBackupPath } from './agentdb-reindex-marker.js';
26
+ import { NamedLockTimeoutError } from './named-lock.js';
27
+ import type { StoreLockOptions } from './store-lock.js';
19
28
  // The backlog dedup embed form (PURE, zero-dep — no cycle): dz-backlog rows must be embedded in the
20
29
  // SAME bounded form the dedup query uses, including through the reindex path.
21
30
  import { BACKLOG_TASK_TYPE, dedupEmbedText } from './backlog-embed.js';
@@ -84,6 +93,48 @@ CREATE TABLE IF NOT EXISTS pattern_embeddings (
84
93
  FOREIGN KEY (pattern_id) REFERENCES reasoning_patterns(id) ON DELETE CASCADE
85
94
  );`;
86
95
 
96
+ /**
97
+ * Create (or verify) an EMPTY AgentDB-schema store at `resolveAgentdbPath(projectRoot, dbPath)`,
98
+ * without indexing any rows (AM-4, feature `setup-installs-apply-leg`, dz-harness-hub issue #10
99
+ * defect 4).
100
+ *
101
+ * WHY THIS EXISTS: before this, `.dz/agentdb.db` came into being only as a side effect of the
102
+ * SessionEnd/PreCompact writer's first `dz consolidate` — so a project that had run
103
+ * `dz setup --memory agentdb` but not yet completed one full session had `memory.backend=agentdb`
104
+ * configured with NO database file at all, and any lesson taught in that window before the first
105
+ * consolidate had nothing to mirror into (the apply leg's daemon reads THIS file — see
106
+ * `dz-embed-daemon.mjs`). `dz setup`'s "Install apply-leg" step now calls this directly so the
107
+ * store exists from the moment setup finishes, not from the moment a session happens to end.
108
+ *
109
+ * SYNCHRONOUS deliberately: `runSetup` is a synchronous function (a `child_process.execSync`
110
+ * install already precedes every write it does), and creating an empty schema needs only
111
+ * `better-sqlite3` — never the async `EmbeddingService` {@link indexPatternsToAgentdb} loads for a
112
+ * real write. Reuses {@link REASONING_BANK_SCHEMA} verbatim — the ONE schema string every writer in
113
+ * this module execs — so this path can never drift into declaring a second, competing schema.
114
+ *
115
+ * Best-effort, like every setup step: a project without `better-sqlite3` installed yet (or one
116
+ * whose native binary is unusable) gets `{ok:false, error}` and setup reports it in the step detail
117
+ * rather than throwing — the writer/daemon still self-heal on the next session either way.
118
+ */
119
+ export function ensureAgentdbSchema(projectRoot: string, dbPath?: string): { readonly ok: boolean; readonly error?: string } {
120
+ try {
121
+ const req = createRequire(join(projectRoot, 'package.json'));
122
+ const Database = req('better-sqlite3') as new (p: string) => NativeDb;
123
+ const dbFile = resolveAgentdbPath(projectRoot, dbPath);
124
+ mkdirSync(dirname(dbFile), { recursive: true });
125
+ const db = new Database(dbFile);
126
+ try {
127
+ db.pragma('journal_mode = WAL');
128
+ db.exec(REASONING_BANK_SCHEMA);
129
+ } finally {
130
+ db.close();
131
+ }
132
+ return { ok: true };
133
+ } catch (err) {
134
+ return { ok: false, error: `agentdb schema init failed: ${err instanceof Error ? err.message : String(err)}` };
135
+ }
136
+ }
137
+
87
138
  /**
88
139
  * Index `rows` into the shared AgentDB vector store. Returns `{indexed:0}` for an empty input and
89
140
  * `{indexed:0, error}` when `agentdb`/`better-sqlite3` cannot be resolved from the project.
@@ -286,9 +337,35 @@ function openReadonly(projectRoot: string, dbPath?: string): { db: ReadonlyDb }
286
337
  return { error: DEPS_MISSING };
287
338
  }
288
339
  try {
289
- const db = new Database(dbFile, { readonly: true }); // WAL readers are safe next to the MCP server
290
- db.pragma('busy_timeout = 5000');
291
- return { db };
340
+ // ADR-001: `{ readonly: true }` alone still fails `unable to open database file` on a
341
+ // directory that cannot create `-wal`/`-shm` — the ladder in `openSqliteReadOnly` falls
342
+ // back to a tmp copy instead, so this "best-effort, never throws" contract keeps working
343
+ // from a read-only-mounted sandbox too. `close()` on the returned handle removes the copy.
344
+ const handle = openSqliteReadOnly(dbFile, { Database });
345
+ const db = handle.db as ReadonlyDb;
346
+ // FR-1 (readonly-residuals): a throwing pragma must not leak the connection or a tmp-copy —
347
+ // applyReadonlyPragmas closes + cleans up before rethrowing.
348
+ applyReadonlyPragmas(handle, dbFile);
349
+ return {
350
+ // `ReadonlyDb` (above) intentionally exposes only `pragma`/`prepare`/`close` — the real
351
+ // better-sqlite3 instance underneath also has `transaction`/`exec`/etc, but no caller in
352
+ // this file uses them (confirmed, fix round 1, Q4/#8), so they stay hidden by the type on
353
+ // purpose. Widening `ReadonlyDb` to add a method should be a deliberate decision, not an
354
+ // incidental leak through `db as ReadonlyDb` above.
355
+ db: {
356
+ pragma: db.pragma.bind(db),
357
+ prepare: db.prepare.bind(db),
358
+ close: () => {
359
+ // `cleanup()` in `finally` — the tmp-copy must be removed even if `db.close()` throws
360
+ // (fix round 1, MEDIUM #3; matches `SqliteReadOnlyStore.close()` in `sqlite-readonly.ts`).
361
+ try {
362
+ db.close();
363
+ } finally {
364
+ handle.cleanup();
365
+ }
366
+ },
367
+ },
368
+ };
292
369
  } catch (err) {
293
370
  return { error: `open failed: ${err instanceof Error ? err.message : String(err)}` };
294
371
  }
@@ -761,7 +838,17 @@ export function bumpAgentdbUses(
761
838
  export async function reindexAgentdbRows(
762
839
  projectRoot: string,
763
840
  rows: readonly AgentdbRow[],
764
- opts: { dbPath?: string; taskTypes?: readonly string[]; backupPath?: string } = {},
841
+ opts: {
842
+ dbPath?: string;
843
+ taskTypes?: readonly string[];
844
+ backupPath?: string;
845
+ keepSnapshots?: number;
846
+ /** Force the fallback snapshot path — test-only injection for AC-2 (FR-2). */
847
+ snapshotStrategy?: 'vacuum-into' | 'copy+wal';
848
+ /** agentdb-snapshot-lock: test/tuning-only override for the snapshot-lock acquisition deadline
849
+ * — omitted, the lock uses its ordinary default timeout. */
850
+ lockTimeoutMs?: number;
851
+ } = {},
765
852
  ): Promise<{
766
853
  reembedded: number;
767
854
  model?: string;
@@ -770,52 +857,196 @@ export async function reindexAgentdbRows(
770
857
  error?: string;
771
858
  /** Task types left in the OLD embedding space because this reindex does not own them. */
772
859
  staleTaskTypes?: string[];
860
+ /** Pre-reindex snapshot rotation outcome — present ONLY on a successful reindex (FR-6). */
861
+ snapshots?: SnapshotRotationReport;
862
+ /** How the pre-reindex snapshot was actually taken — always named when a snapshot ran (FR-2). */
863
+ snapshotMethod?: SnapshotMethod;
864
+ /** Present only for a fallback method — the reason `vacuum-into` did not run (FR-2). */
865
+ snapshotNote?: string;
866
+ /** AM-3: present whenever a rollback was attempted — 'restored' is the only success signal. */
867
+ rollback?: 'restored' | 'failed';
868
+ /** AM-3: present only when `rollback === 'failed'` — why the restore did not fully complete. */
869
+ rollbackError?: string;
773
870
  }> {
774
871
  const dbFile = resolveAgentdbPath(projectRoot, opts.dbPath);
775
- const backupPath = opts.backupPath ?? `${dbFile}.pre-reindex-${Date.now()}.bak`;
776
- if (existsSync(dbFile)) {
777
- try {
778
- const { copyFileSync } = await import('node:fs');
779
- copyFileSync(dbFile, backupPath);
780
- if (existsSync(`${dbFile}.embed-manifest.json`)) {
781
- copyFileSync(`${dbFile}.embed-manifest.json`, `${backupPath}.embed-manifest.json`);
782
- }
783
- } catch (err) {
784
- return { reembedded: 0, backupPath, error: `snapshot failed reindex aborted: ${err instanceof Error ? err.message : String(err)}` };
872
+ const ms = Date.now();
873
+ const backupPath = opts.backupPath ?? `${dbFile}.pre-reindex-${ms}.bak`;
874
+ // agentdb-snapshot-lock: test/tuning-only override for every lock acquisition this call makes
875
+ // (snapshot, rollback, success-path rotation) omitted, each uses its ordinary default timeout.
876
+ const lockOpts: StoreLockOptions = opts.lockTimeoutMs !== undefined ? { timeoutMs: opts.lockTimeoutMs } : {};
877
+
878
+ // AM-5: opts.backupPath must resolve INSIDE dirname(dbFile) — normalized via `resolve`, checked via
879
+ // `relative` so neither a `..`-escaping relative path nor a foreign absolute path can steer the
880
+ // snapshot (and its `-wal`/`-shm`/manifest siblings) outside the db's own directory. No snapshot is
881
+ // attempted when this check fails the reindex aborts before sqlite is even resolved.
882
+ if (opts.backupPath !== undefined) {
883
+ // Lead edit after re-review (Codex C): the boundary is PHYSICAL, not lexical — a symlinked
884
+ // parent (`<dbDir>/link/x.bak` with `link` pointing outside) is resolved with realpath before the
885
+ // comparison. A parent that does not exist yet cannot be a symlink, so the lexical path stands.
886
+ const physical = (p: string): string => { try { return realpathSync(p); } catch { return p; } };
887
+ const dbDir = physical(resolve(dirname(dbFile)));
888
+ const candidate = resolve(opts.backupPath);
889
+ const rel = relative(dbDir, join(physical(dirname(candidate)), basename(candidate)));
890
+ if (rel.startsWith('..') || isAbsolute(rel)) {
891
+ return { reembedded: 0, error: `opts.backupPath must stay inside ${dbDir}, got: ${opts.backupPath}` };
785
892
  }
786
893
  }
894
+
895
+ // FR-3: sqlite resolves BEFORE any snapshot is taken — an unavailable dependency must abort with
896
+ // no new `pre-reindex-*` file on disk, not a snapshot immediately followed by a DEPS_MISSING error.
787
897
  let sqliteUrl: string;
788
898
  try {
789
899
  const req = createRequire(join(projectRoot, 'package.json'));
790
900
  sqliteUrl = pathToFileURL(req.resolve('better-sqlite3')).href;
791
901
  } catch {
792
- return { reembedded: 0, backupPath, error: DEPS_MISSING };
902
+ return { reembedded: 0, error: DEPS_MISSING };
903
+ }
904
+ let Database: new (p: string) => UpsertDb;
905
+ try {
906
+ ({ default: Database } = (await import(sqliteUrl)) as { default: new (p: string) => UpsertDb });
907
+ } catch {
908
+ return { reembedded: 0, error: DEPS_MISSING };
793
909
  }
910
+
911
+ let snapshotMethod: SnapshotMethod | undefined;
912
+ let snapshotNote: string | undefined;
913
+ // exactOptionalPropertyTypes: an optional field must be OMITTED, never assigned `undefined` —
914
+ // spread `snapMeta()` in at every return site instead of naming the two fields directly. A
915
+ // function (not a value computed once) so a return that runs BEFORE the snapshot fully finishes
916
+ // (the manifest-copy failure below) still reports whatever method was already determined —
917
+ // FR-2/"absence of a receipt is not success": snapshotMethod is named whenever a snapshot ran,
918
+ // even one that failed on a LATER best-effort step.
919
+ const snapMeta = (): { snapshotMethod?: SnapshotMethod; snapshotNote?: string } => ({
920
+ ...(snapshotMethod !== undefined ? { snapshotMethod } : {}),
921
+ ...(snapshotNote !== undefined ? { snapshotNote } : {}),
922
+ });
923
+
924
+ // AM-2 (fix-round after Codex review Grade D): the reindex-in-progress marker is written INSIDE
925
+ // the SAME critical section as the snapshot itself, under ONE lock acquisition — never before it.
926
+ // A lock timeout now throws before EITHER the snapshot OR the marker exist, so a busy lock leaves
927
+ // the directory byte-identical (previously the marker was written unconditionally BEFORE the
928
+ // lock was even attempted, so a busy lock still left a transient marker on disk for the life of
929
+ // this call). AM-1: the marker write is attempted FIRST inside the callback, before any snapshot
930
+ // — a live marker from a still-running reindex refuses this call "без снимка" (no snapshot ever
931
+ // taken for the refused attempt; nothing has been deleted yet, so there is nothing to roll back).
932
+ // AM-4: the marker's `ms` is recomputed from the ACTUAL `backupPath` filename — decoupled from the
933
+ // `ms` variable above, which only seeds the DEFAULT backupPath. A non-standard `opts.backupPath`
934
+ // (no `.pre-reindex-<n>.bak` suffix) names no family, so the marker carries `ms: null`.
935
+ const markerMs = msFromBackupPath(backupPath);
936
+ let markerToken: string;
937
+ try {
938
+ const markerResult = withAgentdbSnapshotLock(dbFile, () => {
939
+ const written = writeReindexMarker(dbFile, { ms: markerMs, pid: process.pid, startedAt: Date.now(), backupPath });
940
+ if (!written.ok) return written; // AM-1: refuse before touching the database at all
941
+ // FR-1/FR-2 (agentdb-snapshot-lock): the critical section under the lock is exactly the file
942
+ // operations below (`VACUUM INTO`/copy + the manifest-sibling copy, plus the marker write
943
+ // above) — short and synchronous. Re-embedding (the long, unlocked part of a reindex) happens
944
+ // well after this block returns.
945
+ // Lead edit after re-review (Codex D, finding 2): a snapshot that THROWS inside this section
946
+ // must not leave the just-written marker behind until the TTL — clear it (we own the token)
947
+ // and rethrow so the outer catch reports the snapshot failure as before.
948
+ try {
949
+ if (existsSync(dbFile)) {
950
+ const outcome = snapshotSqliteDatabase(
951
+ Database as unknown as SnapshotDbCtor,
952
+ dbFile,
953
+ backupPath,
954
+ opts.snapshotStrategy !== undefined ? { strategy: opts.snapshotStrategy } : {},
955
+ );
956
+ snapshotMethod = outcome.method;
957
+ snapshotNote = outcome.note;
958
+ if (existsSync(`${dbFile}.embed-manifest.json`)) {
959
+ copyFileSync(`${dbFile}.embed-manifest.json`, `${backupPath}.embed-manifest.json`);
960
+ }
961
+ }
962
+ } catch (snapErr) {
963
+ clearReindexMarker(dbFile, written.token);
964
+ throw snapErr;
965
+ }
966
+ return written;
967
+ }, lockOpts);
968
+ if (!markerResult.ok) {
969
+ return { reembedded: 0, backupPath, ...snapMeta(), error: markerResult.error };
970
+ }
971
+ markerToken = markerResult.token;
972
+ } catch (err) {
973
+ // FR-4: a busy snapshot lock is reported distinctly ("snapshot lock busy: …") and aborts with
974
+ // NO snapshot, NO marker and NO change to the database — `fn` above never ran, so nothing was
975
+ // written (AM-2).
976
+ if (err instanceof NamedLockTimeoutError) {
977
+ return { reembedded: 0, error: `snapshot lock busy: ${err.message}` };
978
+ }
979
+ return { reembedded: 0, backupPath, ...snapMeta(), error: `snapshot failed — reindex aborted: ${err instanceof Error ? err.message : String(err)}` };
980
+ }
981
+
982
+ // AM-3: the marker is cleared in `finally` below only when no rollback was needed, or the
983
+ // rollback SUCCEEDED. A FAILED rollback leaves the marker in place as "requires manual recovery"
984
+ // — its family may be the only intact copy of the pre-reindex state, and clearing the marker here
985
+ // would let a concurrent `dz brain snapshots --prune` remove it right out from under an operator
986
+ // who has not yet acted on the advice named in the returned error.
987
+ let rollbackFailed = false;
988
+ try {
989
+
794
990
  const model = resolveEmbedModel(projectRoot);
795
- if ('error' in model) return { reembedded: 0, backupPath, error: model.error };
991
+ if ('error' in model) return { reembedded: 0, backupPath, ...snapMeta(), error: model.error };
796
992
  const oldVersion = readEmbedManifest(dbFile)?.version ?? 1;
797
993
  const version = Math.max(oldVersion + 1, 2);
798
994
 
995
+ const markerPath = reindexMarkerPath(dbFile);
996
+ /** AM-3: names both paths a failed rollback leaves an operator to reconcile by hand. */
997
+ const rollbackFailNote = (): string =>
998
+ `snapshot at ${backupPath} was not confirmed restored; marker at ${markerPath} is left in place — requires manual recovery`;
999
+
799
1000
  /**
800
1001
  * Undo a half-done reindex. The DELETE has already run and the manifest may already name the new
801
1002
  * model, so leaving the store as-is would be WORSE than before we started: a manifest that claims a
802
- * space the rows are not in. Restore both from the snapshot taken above. Best-effort and never
803
- * throws the caller is already returning an error.
1003
+ * space the rows are not in. Restore both from the snapshot taken above. FR-4: the caller of
1004
+ * `rollback()` has ALREADY closed every write connection this function opened (both the DELETE's
1005
+ * `db.close()` in the `finally` below and `indexPatternsToAgentdb`'s own `finally { db.close() }`)
1006
+ * before this runs.
1007
+ *
1008
+ * AM-3: never throws, but never silently reports a failed restore as a success either — "absence of
1009
+ * a receipt is not success" applies to a rollback exactly as much as to a forward operation. Returns
1010
+ * `{ restored: 'restored' }` or `{ restored: 'failed', error }`; the caller folds `error` into the
1011
+ * top-level `error` string as "; rollback failed: …" and surfaces `rollback`/`rollbackError`.
804
1012
  */
805
- const rollback = async (): Promise<void> => {
1013
+ const rollback = async (): Promise<{ restored: 'restored' | 'failed'; error?: string }> => {
806
1014
  try {
807
- const { copyFileSync } = await import('node:fs');
808
- if (existsSync(backupPath)) copyFileSync(backupPath, dbFile);
809
- const manifestBak = `${backupPath}.embed-manifest.json`;
810
- if (existsSync(manifestBak)) copyFileSync(manifestBak, `${dbFile}.embed-manifest.json`);
811
- } catch {
812
- /* the snapshot path is still reported to the caller */
1015
+ // Lead edit after re-review: a snapshot that WAS taken but is now missing is a FAILED rollback,
1016
+ // never a silently "restored" one. Only the no-snapshot case (the db did not exist) has nothing to restore.
1017
+ if (!existsSync(backupPath) && snapshotMethod !== undefined) {
1018
+ return { restored: 'failed', error: `rollback failed: snapshot missing at ${backupPath}` };
1019
+ }
1020
+ // FR-1/FR-2 (agentdb-snapshot-lock): the restore + manifest-copy are the file operations this
1021
+ // lock guards. The caller has ALREADY closed every write connection before `rollback()` runs
1022
+ // (see the doc comment above), so this critical section stays exactly as short as the forward
1023
+ // snapshot's.
1024
+ return withAgentdbSnapshotLock(dbFile, (): { restored: 'restored' | 'failed'; error?: string } => {
1025
+ if (existsSync(backupPath)) {
1026
+ if (snapshotMethod === undefined) {
1027
+ // Structurally should not happen (a backup file with no recorded method), but AM-1's whole
1028
+ // point is: never guess the method from file presence. Fail loudly instead.
1029
+ return { restored: 'failed', error: 'rollback failed: snapshot method unknown, refusing to guess -wal handling' };
1030
+ }
1031
+ const outcome = restoreSqliteSnapshot(dbFile, backupPath, snapshotMethod);
1032
+ if (!outcome.ok) {
1033
+ return { restored: 'failed', error: `rollback failed: ${outcome.error ?? 'restore failed'}` };
1034
+ }
1035
+ }
1036
+ const manifestBak = `${backupPath}.embed-manifest.json`;
1037
+ if (existsSync(manifestBak)) copyFileSync(manifestBak, `${dbFile}.embed-manifest.json`);
1038
+ return { restored: 'restored' };
1039
+ }, lockOpts);
1040
+ } catch (err) {
1041
+ if (err instanceof NamedLockTimeoutError) {
1042
+ return { restored: 'failed', error: `rollback failed: lock busy: ${err.message}` };
1043
+ }
1044
+ return { restored: 'failed', error: `rollback failed: ${err instanceof Error ? err.message : String(err)}` };
813
1045
  }
814
1046
  };
815
1047
 
816
1048
  let stale: string[] = [];
817
1049
  try {
818
- const { default: Database } = (await import(sqliteUrl)) as { default: new (p: string) => UpsertDb };
819
1050
  mkdirSync(dirname(dbFile), { recursive: true });
820
1051
  const db = new Database(dbFile);
821
1052
  try {
@@ -851,19 +1082,71 @@ export async function reindexAgentdbRows(
851
1082
 
852
1083
  const indexed = await indexPatternsToAgentdb(projectRoot, rows, { dbPath: dbFile });
853
1084
  if (indexed.error !== undefined) {
854
- await rollback();
855
- return { reembedded: 0, backupPath, error: indexed.error };
1085
+ const rb = await rollback();
1086
+ if (rb.restored === 'failed') rollbackFailed = true; // AM-3: the `finally` below must not clear the marker
1087
+ return {
1088
+ reembedded: 0,
1089
+ backupPath,
1090
+ ...snapMeta(),
1091
+ rollback: rb.restored,
1092
+ ...(rb.error !== undefined ? { rollbackError: rb.error } : {}),
1093
+ error: rb.restored === 'failed' ? `${indexed.error}; ${rb.error}; ${rollbackFailNote()}` : indexed.error,
1094
+ };
1095
+ }
1096
+ // FR-1/FR-6: rotation runs ONLY on this success path — an `error` return above never reaches
1097
+ // here, so old snapshots are never touched while they might be the only working copy left.
1098
+ // NFR-2: calls the UNLOCKED primitive under OUR OWN `withAgentdbSnapshotLock` — never the public
1099
+ // `rotatePreReindexSnapshots` wrapper, which would try to take the same named lock a second time.
1100
+ const keepSnapshots = opts.keepSnapshots ?? 3;
1101
+ let snapshots: SnapshotRotationReport;
1102
+ try {
1103
+ snapshots = withAgentdbSnapshotLock(
1104
+ dbFile,
1105
+ () => rotatePreReindexSnapshotsUnlocked(dbFile, { keep: keepSnapshots, protectPath: backupPath }),
1106
+ lockOpts,
1107
+ );
1108
+ } catch (err) {
1109
+ if (!(err instanceof NamedLockTimeoutError)) throw err;
1110
+ snapshots = { kept: [], removed: [], removedBytes: 0, keep: keepSnapshots, errors: [`lock busy: ${err.message}`] };
856
1111
  }
857
1112
  return {
858
1113
  reembedded: indexed.indexed,
859
1114
  model: model.model,
860
1115
  version,
861
1116
  backupPath,
1117
+ ...snapMeta(),
862
1118
  ...(stale.length > 0 ? { staleTaskTypes: stale } : {}),
1119
+ snapshots,
863
1120
  };
864
1121
  } catch (err) {
865
- await rollback();
866
- return { reembedded: 0, backupPath, error: `reindex failed: ${err instanceof Error ? err.message : String(err)}` };
1122
+ const rb = await rollback();
1123
+ if (rb.restored === 'failed') rollbackFailed = true; // AM-3: the `finally` below must not clear the marker
1124
+ const baseError = `reindex failed: ${err instanceof Error ? err.message : String(err)}`;
1125
+ return {
1126
+ reembedded: 0,
1127
+ backupPath,
1128
+ ...snapMeta(),
1129
+ rollback: rb.restored,
1130
+ ...(rb.error !== undefined ? { rollbackError: rb.error } : {}),
1131
+ error: rb.restored === 'failed' ? `${baseError}; ${rb.error}; ${rollbackFailNote()}` : baseError,
1132
+ };
1133
+ }
1134
+ } finally {
1135
+ // AM-3: never clear a marker left behind by a FAILED rollback (see the comment above
1136
+ // `rollbackFailed`'s declaration) — every other path (no rollback needed, or a rollback that
1137
+ // actually restored) clears it exactly as before.
1138
+ // Lead edit after re-review (Codex D, findings 3/4): every marker mutation runs under the snapshot
1139
+ // lock, so compare-and-delete and stale replacement can never interleave with another owner.
1140
+ // A failed rollback flags the marker as recovery-required instead (never expires, refuses reindex).
1141
+ try {
1142
+ withAgentdbSnapshotLock(dbFile, () => {
1143
+ if (rollbackFailed) markReindexMarkerRecoveryRequired(dbFile, markerToken, 'rollback failed — restore the snapshot manually');
1144
+ else clearReindexMarker(dbFile, markerToken);
1145
+ }, lockOpts);
1146
+ } catch {
1147
+ /* lock busy at cleanup: the marker stays; a live one expires by TTL, a recovery-required one is
1148
+ re-flagged on the next attempt — never throw out of finally over the real result */
1149
+ }
867
1150
  }
868
1151
  }
869
1152