akm-cli 0.9.15-beta.1 → 0.9.15-beta.2
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.
- package/CHANGELOG.md +175 -13
- package/dist/akm +54 -1
- package/dist/akm-migrate +34 -1
- package/dist/cli.js +37 -1
- package/dist/commands/improve/locks.js +3 -2
- package/dist/commands/sources/installed-stashes.js +58 -16
- package/dist/commands/sources/stash-cli.js +17 -0
- package/dist/core/config/schema/embedding.js +41 -0
- package/dist/core/file-lock.js +49 -15
- package/dist/core/parent-watchdog.js +64 -0
- package/dist/core/run-lock.js +13 -2
- package/dist/indexer/index-rebuild-lock.js +4 -4
- package/dist/indexer/index-written-assets.js +9 -1
- package/dist/indexer/indexer.js +79 -16
- package/dist/indexer/materialize-embeddings.js +299 -33
- package/dist/indexer/search/search-source.js +23 -1
- package/dist/llm/embedders/remote.js +340 -42
- package/dist/scripts/akm-migrate-node.js +398 -77
- package/dist/scripts/akm-migrate.js +398 -77
- package/dist/storage/repositories/embedding-salvage-repository.js +184 -0
- package/dist/storage/repositories/index-schema.js +16 -0
- package/dist/tasks/run/run-native-task.js +23 -1
- package/docs/migration/release-notes/0.9.15.md +85 -4
- package/docs/migration/release-notes/README.md +3 -2
- package/docs/reference/cli.md +28 -3
- package/docs/reference/configuration.md +67 -15
- package/package.json +1 -1
- package/schemas/akm-config.json +39 -0
package/dist/core/file-lock.js
CHANGED
|
@@ -120,9 +120,36 @@ function releaseLockRaw(lockPath) {
|
|
|
120
120
|
export function tryAcquireLockSync(lockPath, payload) {
|
|
121
121
|
return withLockOperationMutex(lockPath, () => tryAcquireLockRaw(lockPath, payload));
|
|
122
122
|
}
|
|
123
|
-
/**
|
|
123
|
+
/**
|
|
124
|
+
* Best-effort launcher pid from `AKM_LAUNCHER_PID` (set by
|
|
125
|
+
* `scripts/node-runtime/akm`/`akm-migrate`, #956) — undefined when unset,
|
|
126
|
+
* empty, or not a positive integer (never trust an ambient env var blindly
|
|
127
|
+
* into a lock message). Also the gate the parent-death watchdog
|
|
128
|
+
* (`core/parent-watchdog.ts`) uses to stay inert outside a launcher-managed
|
|
129
|
+
* run.
|
|
130
|
+
*/
|
|
131
|
+
export function launcherPidFromEnv() {
|
|
132
|
+
const raw = process.env.AKM_LAUNCHER_PID;
|
|
133
|
+
if (!raw)
|
|
134
|
+
return undefined;
|
|
135
|
+
const pid = Number.parseInt(raw, 10);
|
|
136
|
+
return Number.isInteger(pid) && pid > 0 ? pid : undefined;
|
|
137
|
+
}
|
|
138
|
+
/**
|
|
139
|
+
* Build a PID-bearing payload with a unique token for one acquisition attempt.
|
|
140
|
+
* Adds `launcherPid` (#956) whenever this process is running under the
|
|
141
|
+
* published launcher, so a lock's holder can be identified by the pid every
|
|
142
|
+
* process listing and task log actually shows (the launcher's) as well as
|
|
143
|
+
* the pid that holds the lock (the bun/node child).
|
|
144
|
+
*/
|
|
124
145
|
export function createLockPayload(metadata = {}) {
|
|
125
|
-
|
|
146
|
+
const launcherPid = launcherPidFromEnv();
|
|
147
|
+
return JSON.stringify({
|
|
148
|
+
...metadata,
|
|
149
|
+
pid: process.pid,
|
|
150
|
+
...(launcherPid !== undefined ? { launcherPid } : {}),
|
|
151
|
+
lockId: randomUUID(),
|
|
152
|
+
});
|
|
126
153
|
}
|
|
127
154
|
/**
|
|
128
155
|
* Inspect an existing sentinel at `lockPath` without modifying it.
|
|
@@ -153,17 +180,17 @@ export function probeLock(lockPath, opts) {
|
|
|
153
180
|
return { state: "absent" };
|
|
154
181
|
const { rawContent, identity } = snapshot;
|
|
155
182
|
const ageMs = Date.now() - identity.mtimeMs;
|
|
156
|
-
const holderPid =
|
|
183
|
+
const { holderPid, launcherPid } = extractLockIdentity(rawContent);
|
|
157
184
|
if (holderPid === undefined) {
|
|
158
185
|
return { state: "stale", reason: "invalid_pid", ageMs, rawContent, identity };
|
|
159
186
|
}
|
|
160
187
|
if (!isProcessAlive(holderPid)) {
|
|
161
|
-
return { state: "stale", reason: "pid_dead", holderPid, ageMs, rawContent, identity };
|
|
188
|
+
return { state: "stale", reason: "pid_dead", holderPid, launcherPid, ageMs, rawContent, identity };
|
|
162
189
|
}
|
|
163
190
|
if (opts?.staleAfterMs !== undefined && ageMs > opts.staleAfterMs) {
|
|
164
|
-
return { state: "stale", reason: "age_exceeded", holderPid, ageMs, rawContent, identity };
|
|
191
|
+
return { state: "stale", reason: "age_exceeded", holderPid, launcherPid, ageMs, rawContent, identity };
|
|
165
192
|
}
|
|
166
|
-
return { state: "held", holderPid, ageMs, rawContent, identity };
|
|
193
|
+
return { state: "held", holderPid, launcherPid, ageMs, rawContent, identity };
|
|
167
194
|
}
|
|
168
195
|
/**
|
|
169
196
|
* Revalidate and quarantine the probed sentinel while holding the same operation
|
|
@@ -254,25 +281,32 @@ export function releaseLock(ownership) {
|
|
|
254
281
|
});
|
|
255
282
|
}
|
|
256
283
|
/**
|
|
257
|
-
* Extract a
|
|
258
|
-
*
|
|
259
|
-
* a
|
|
260
|
-
*
|
|
284
|
+
* Extract a holder pid, and (#956) a launcher pid when the payload recorded
|
|
285
|
+
* one, from a sentinel body. Accepts the two shapes used across the
|
|
286
|
+
* codebase: a bare numeric string (config-io, vault, lockfile — never
|
|
287
|
+
* carries a `launcherPid`) and a JSON object with `pid`/`launcherPid` fields
|
|
288
|
+
* (`createLockPayload`). `holderPid` is undefined when the body is
|
|
289
|
+
* unparseable or yields a non-positive integer; `launcherPid` is undefined
|
|
290
|
+
* whenever the payload has none, independent of whether `holderPid` parsed.
|
|
261
291
|
*/
|
|
262
|
-
function
|
|
292
|
+
function extractLockIdentity(content) {
|
|
263
293
|
const trimmed = content.trim();
|
|
264
294
|
if (!trimmed)
|
|
265
|
-
return
|
|
295
|
+
return {};
|
|
266
296
|
if (trimmed.startsWith("{")) {
|
|
267
297
|
try {
|
|
268
298
|
const parsed = JSON.parse(trimmed);
|
|
269
299
|
const pid = typeof parsed.pid === "number" ? parsed.pid : Number.NaN;
|
|
270
|
-
|
|
300
|
+
const rawLauncherPid = typeof parsed.launcherPid === "number" ? parsed.launcherPid : Number.NaN;
|
|
301
|
+
return {
|
|
302
|
+
holderPid: Number.isInteger(pid) && pid > 0 ? pid : undefined,
|
|
303
|
+
launcherPid: Number.isInteger(rawLauncherPid) && rawLauncherPid > 0 ? rawLauncherPid : undefined,
|
|
304
|
+
};
|
|
271
305
|
}
|
|
272
306
|
catch {
|
|
273
|
-
return
|
|
307
|
+
return {};
|
|
274
308
|
}
|
|
275
309
|
}
|
|
276
310
|
const pid = Number.parseInt(trimmed, 10);
|
|
277
|
-
return Number.isInteger(pid) && pid > 0 ? pid : undefined;
|
|
311
|
+
return { holderPid: Number.isInteger(pid) && pid > 0 ? pid : undefined };
|
|
278
312
|
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// This Source Code Form is subject to the terms of the Mozilla Public
|
|
2
|
+
// License, v. 2.0. If a copy of the MPL was not distributed with this
|
|
3
|
+
// file, You can obtain one at https://mozilla.org/MPL/2.0/.
|
|
4
|
+
/**
|
|
5
|
+
* Parent-death watchdog (#956).
|
|
6
|
+
*
|
|
7
|
+
* The published launcher (`scripts/node-runtime/akm`) now forwards SIGTERM to
|
|
8
|
+
* its child, so a `kill <launcher-pid>` no longer orphans it — but a launcher
|
|
9
|
+
* that dies WITHOUT delivering a signal (SIGKILL, an out-of-memory kill, a
|
|
10
|
+
* supervisor that force-removes the process) still reparents the child to
|
|
11
|
+
* init with nothing to catch. Field evidence: 40 orphaned
|
|
12
|
+
* `bun …/dist/cli.js` processes over one day, up to 10h old, some created by
|
|
13
|
+
* a curate hook killing its own launcher on timeout — every akm invocation
|
|
14
|
+
* under the launcher is exposed to this, not only `akm index`, so this
|
|
15
|
+
* watchdog runs for every command (wired at the CLI entry point in
|
|
16
|
+
* `src/cli.ts`), not just index.
|
|
17
|
+
*
|
|
18
|
+
* `process.ppid` on POSIX changes the instant the parent exits (the child is
|
|
19
|
+
* reparented, usually to pid 1) — polling it is the standard orphan-detection
|
|
20
|
+
* trick when no direct death notification exists. This module owns only the
|
|
21
|
+
* poll/compare mechanics; the caller's `onOrphaned` decides what "stop"
|
|
22
|
+
* means. `src/cli.ts` wires it to `process.kill(process.pid, "SIGTERM")`,
|
|
23
|
+
* reusing the same self-signal every command already has rather than a
|
|
24
|
+
* second, parallel abort path. Only `akm index`/`improve` register their own
|
|
25
|
+
* SIGTERM listener (the index command's AbortController in
|
|
26
|
+
* `commands/sources/stash-cli.ts`) and get a graceful, in-process shutdown
|
|
27
|
+
* from it. Every other command has no listener of its own, so the runtime's
|
|
28
|
+
* default disposition terminates it directly — `exit` handlers (lock
|
|
29
|
+
* release included) do NOT run — and any lock it held is cleared later by
|
|
30
|
+
* the next acquirer's dead-pid stale-reclaim (`file-lock.ts`), not by
|
|
31
|
+
* in-process release. Either way the orphaned process stops, which is this
|
|
32
|
+
* watchdog's actual job.
|
|
33
|
+
*/
|
|
34
|
+
/** Pure comparison seam: true once the observed ppid differs from the one seen at startup. */
|
|
35
|
+
export function isReparented(initialPpid, currentPpid) {
|
|
36
|
+
return currentPpid !== initialPpid;
|
|
37
|
+
}
|
|
38
|
+
/**
|
|
39
|
+
* Start polling `getPpid()` every `intervalMs` and invoke `onOrphaned` once,
|
|
40
|
+
* the first time the observed ppid no longer matches `initialPpid`. The timer
|
|
41
|
+
* is unref'ed so it never keeps the process alive on its own — a command that
|
|
42
|
+
* finishes normally still exits promptly.
|
|
43
|
+
*/
|
|
44
|
+
export function startParentDeathWatchdog(options) {
|
|
45
|
+
const { initialPpid, onOrphaned } = options;
|
|
46
|
+
const intervalMs = options.intervalMs ?? 2000;
|
|
47
|
+
const getPpid = options.getPpid ?? (() => process.ppid);
|
|
48
|
+
const setIntervalFn = options.setIntervalFn ?? setInterval;
|
|
49
|
+
const clearIntervalFn = options.clearIntervalFn ?? clearInterval;
|
|
50
|
+
let fired = false;
|
|
51
|
+
const timer = setIntervalFn(() => {
|
|
52
|
+
if (fired)
|
|
53
|
+
return;
|
|
54
|
+
if (isReparented(initialPpid, getPpid())) {
|
|
55
|
+
fired = true;
|
|
56
|
+
onOrphaned();
|
|
57
|
+
}
|
|
58
|
+
}, intervalMs);
|
|
59
|
+
if (typeof timer !== "number")
|
|
60
|
+
timer.unref?.();
|
|
61
|
+
return {
|
|
62
|
+
stop: () => clearIntervalFn(timer),
|
|
63
|
+
};
|
|
64
|
+
}
|
package/dist/core/run-lock.js
CHANGED
|
@@ -31,6 +31,17 @@ import path from "node:path";
|
|
|
31
31
|
import { ConfigError } from "./errors.js";
|
|
32
32
|
import { createLockPayload, probeLock, reclaimStaleLock, tryAcquireLockSync } from "./file-lock.js";
|
|
33
33
|
import { describeInaccessiblePath } from "./path-access.js";
|
|
34
|
+
/**
|
|
35
|
+
* Render a lock holder's pid for a message: `"4242"`, or `"4242 (launcher
|
|
36
|
+
* 4240)"` when the holder's launcher pid is known (#956) — every process
|
|
37
|
+
* listing and task log shows the launcher pid, not the bun/node child's, so
|
|
38
|
+
* naming only the holder pid left an operator unable to connect the two.
|
|
39
|
+
*/
|
|
40
|
+
export function formatLockHolderPid(holder) {
|
|
41
|
+
if (holder.pid === null)
|
|
42
|
+
return "unknown";
|
|
43
|
+
return holder.launcherPid !== null ? `${holder.pid} (launcher ${holder.launcherPid})` : String(holder.pid);
|
|
44
|
+
}
|
|
34
45
|
function parseLockPayload(rawContent) {
|
|
35
46
|
if (!rawContent)
|
|
36
47
|
return null;
|
|
@@ -42,7 +53,7 @@ function parseLockPayload(rawContent) {
|
|
|
42
53
|
}
|
|
43
54
|
}
|
|
44
55
|
function holderOf(lock) {
|
|
45
|
-
return { pid: lock?.pid ?? null, startedAt: lock?.startedAt ?? null };
|
|
56
|
+
return { pid: lock?.pid ?? null, startedAt: lock?.startedAt ?? null, launcherPid: lock?.launcherPid ?? null };
|
|
46
57
|
}
|
|
47
58
|
/**
|
|
48
59
|
* Attempt to acquire `lockPath`. Returns `{ state: "acquired" }` with an
|
|
@@ -69,7 +80,7 @@ export function tryAcquireRunLock(lockPath, options) {
|
|
|
69
80
|
return { state: "acquired", ownership };
|
|
70
81
|
// Re-grabbed by another racer in this exact window — no holder detail
|
|
71
82
|
// available without re-probing (which could itself race again).
|
|
72
|
-
return { state: "held", holder: { pid: null, startedAt: null } };
|
|
83
|
+
return { state: "held", holder: { pid: null, startedAt: null, launcherPid: null } };
|
|
73
84
|
}
|
|
74
85
|
if (probe.state === "inaccessible") {
|
|
75
86
|
throw new ConfigError(`${options.label} lock exists but is not readable: ${describeInaccessiblePath(lockPath, probe.code)}.`, "DATA_DIR_UNREADABLE");
|
|
@@ -22,7 +22,7 @@
|
|
|
22
22
|
import { releaseLock } from "../core/file-lock.js";
|
|
23
23
|
import { tryWithMaintenanceStartBarrier, withMaintenanceStartBarrier } from "../core/maintenance-barrier.js";
|
|
24
24
|
import { getIndexRebuildLockPath } from "../core/paths.js";
|
|
25
|
-
import { tryAcquireRunLock } from "../core/run-lock.js";
|
|
25
|
+
import { formatLockHolderPid, tryAcquireRunLock } from "../core/run-lock.js";
|
|
26
26
|
import { warn, warnVerbose } from "../core/warn.js";
|
|
27
27
|
export function indexRebuildLockPath() {
|
|
28
28
|
return getIndexRebuildLockPath();
|
|
@@ -53,18 +53,18 @@ export function tryAcquireIndexRebuildLock(skipIfLocked) {
|
|
|
53
53
|
const result = tryWithMaintenanceStartBarrier(acquire);
|
|
54
54
|
if (!result) {
|
|
55
55
|
warn("[index] maintenance barrier held; skipping (--skip-if-locked)");
|
|
56
|
-
return { state: "skipped", holder: { pid: null, startedAt: null } };
|
|
56
|
+
return { state: "skipped", holder: { pid: null, startedAt: null, launcherPid: null } };
|
|
57
57
|
}
|
|
58
58
|
if (result.state === "acquired")
|
|
59
59
|
return result;
|
|
60
|
-
warn(`[index] another index run holds the lock (PID ${result.holder
|
|
60
|
+
warn(`[index] another index run holds the lock (PID ${formatLockHolderPid(result.holder)}, started ${result.holder.startedAt}); ` +
|
|
61
61
|
"skipping (--skip-if-locked)");
|
|
62
62
|
return { state: "skipped", holder: result.holder };
|
|
63
63
|
}
|
|
64
64
|
const result = withMaintenanceStartBarrier(acquire);
|
|
65
65
|
if (result.state === "acquired")
|
|
66
66
|
return result;
|
|
67
|
-
warn(`[index] another index run is active (pid ${result.holder
|
|
67
|
+
warn(`[index] another index run is active (pid ${formatLockHolderPid(result.holder)}, started ${result.holder.startedAt}); ` +
|
|
68
68
|
"this run will contend with it — pass --skip-if-locked for scheduled runs");
|
|
69
69
|
return { state: "contended", holder: result.holder };
|
|
70
70
|
}
|
|
@@ -27,6 +27,7 @@ import { isDataDirUnreadableError } from "../core/errors.js";
|
|
|
27
27
|
import { probeLock } from "../core/file-lock.js";
|
|
28
28
|
import { isPathAbsent } from "../core/path-access.js";
|
|
29
29
|
import { getDbPath, getIndexRebuildLockPath } from "../core/paths.js";
|
|
30
|
+
import { formatLockHolderPid } from "../core/run-lock.js";
|
|
30
31
|
import { warn, warnVerbose } from "../core/warn.js";
|
|
31
32
|
import { closeDatabase, openExistingDatabase } from "../storage/repositories/index-connection.js";
|
|
32
33
|
import { deleteEntriesByIds, getEntryCount, upsertEntry } from "../storage/repositories/index-entries-repository.js";
|
|
@@ -75,7 +76,14 @@ export async function indexWrittenAssets(stashDir, filePaths, options = {}) {
|
|
|
75
76
|
// rebuild in progress will pick up the change on its own.
|
|
76
77
|
const rebuildProbe = probeLock(getIndexRebuildLockPath());
|
|
77
78
|
if (rebuildProbe.state === "held") {
|
|
78
|
-
|
|
79
|
+
// #956: name the launcher pid alongside the holder pid when known —
|
|
80
|
+
// every process listing and task log shows the launcher's pid, not
|
|
81
|
+
// the bun/node child's.
|
|
82
|
+
const holderLabel = formatLockHolderPid({
|
|
83
|
+
pid: rebuildProbe.holderPid,
|
|
84
|
+
launcherPid: rebuildProbe.launcherPid ?? null,
|
|
85
|
+
});
|
|
86
|
+
warn(`index rebuild in progress (pid ${holderLabel}); the next index pass will index ${filePaths.join(", ")}`);
|
|
79
87
|
return true;
|
|
80
88
|
}
|
|
81
89
|
const dbPath = getDbPath();
|
package/dist/indexer/indexer.js
CHANGED
|
@@ -19,6 +19,7 @@ import { isLlmFeatureEnabled } from "../llm/feature-gate.js";
|
|
|
19
19
|
import { resolveIndexPassExecution } from "../llm/index-passes.js";
|
|
20
20
|
import { preflightStructuredLlmRunner } from "../llm/structured-call.js";
|
|
21
21
|
import { resolveSourcesForOrigin } from "../registry/origin-resolve.js";
|
|
22
|
+
import { salvageEmbeddingsBeforeDiscard } from "../storage/repositories/embedding-salvage-repository.js";
|
|
22
23
|
import { closeDatabase, openExistingDatabase, openIndexDatabase, openReadonlyExistingDatabase, } from "../storage/repositories/index-connection.js";
|
|
23
24
|
import { deleteAllEntries, deleteEntriesByBundle, deleteEntriesByDirAndBundle, deleteEntriesByDirExceptRefs, deleteEntriesByIds, deleteUsageEventsByEntryIds, findEntryIdByRef, getAllEntries, getEmbeddableEntryCount, getEntryCount, getIndexedBundleIdsByDir, getIndexedDirPathsByBundleId, relinkUsageEvents, upsertEntry, } from "../storage/repositories/index-entries-repository.js";
|
|
24
25
|
import { clearStaleCacheEntries, computeBodyHash, getLlmCacheEntry, } from "../storage/repositories/index-llm-cache-repository.js";
|
|
@@ -184,21 +185,54 @@ async function runWalkPhase(ctx) {
|
|
|
184
185
|
});
|
|
185
186
|
ctx.timing.tLlmEnd = Date.now();
|
|
186
187
|
}
|
|
188
|
+
/**
|
|
189
|
+
* The ONE embedding-phase implementation (#954): generate and
|
|
190
|
+
* store vectors for every entry missing one, then compute the `hasEmbeddings`
|
|
191
|
+
* fact and the semantic-search verification off the result. `akmIndex`'s own
|
|
192
|
+
* (non-deferred) run calls this from {@link runEmbeddingPhase} below; `akm
|
|
193
|
+
* bundle update`'s coordinator calls it directly on its own connection AFTER
|
|
194
|
+
* its unified update transaction commits, since the ambient-transaction drift
|
|
195
|
+
* guard (and the whole point of per-batch commit, #954) requires `db` to have
|
|
196
|
+
* no ambient transaction open.
|
|
197
|
+
*/
|
|
198
|
+
export async function runEmbeddingPass(params) {
|
|
199
|
+
const { db, config, onProgress, signal, reembed } = params;
|
|
200
|
+
const embeddingResult = await generateEmbeddingsForDb(db, config, onProgress, signal, undefined, {
|
|
201
|
+
forceReembed: reembed,
|
|
202
|
+
});
|
|
203
|
+
setMeta(db, "hasEmbeddings", embeddingResult.success ? "1" : "0");
|
|
204
|
+
const semanticEntryCount = getEmbeddableEntryCount(db);
|
|
205
|
+
onProgress({ phase: "finalize", message: "Verifying semantic search state." });
|
|
206
|
+
const verification = verifyIndexState(db, config, semanticEntryCount, embeddingResult);
|
|
207
|
+
onProgress({ phase: "verify", message: verification.message });
|
|
208
|
+
return { embeddingResult, verification };
|
|
209
|
+
}
|
|
187
210
|
/**
|
|
188
211
|
* Embedding phase: generate and store vector embeddings for all unembedded
|
|
189
|
-
* entries. Writes `ctx.embeddingResult` for the
|
|
212
|
+
* entries. Writes `ctx.embeddingResult` and `ctx.verification` for the
|
|
213
|
+
* finalize phase / caller.
|
|
190
214
|
*/
|
|
191
215
|
async function runEmbeddingPhase(ctx) {
|
|
192
|
-
const { db, config, signal, onProgress, reembed } = ctx;
|
|
216
|
+
const { db, config, signal, onProgress, reembed, deferredUpdateTransaction } = ctx;
|
|
193
217
|
throwIfAborted(signal);
|
|
218
|
+
if (deferredUpdateTransaction) {
|
|
219
|
+
// `akm bundle update`'s deferred pass (#954): the embedding
|
|
220
|
+
// phase runs AFTER the coordinator's own commit, on its own connection,
|
|
221
|
+
// via the coordinator's direct `runEmbeddingPass` call — never here,
|
|
222
|
+
// inside the borrowed transaction (the ambient-transaction drift guard
|
|
223
|
+
// would reject it anyway). `runFinalizePhase` records semantic state as
|
|
224
|
+
// "pending".
|
|
225
|
+
ctx.timing.tEmbedEnd = Date.now();
|
|
226
|
+
return;
|
|
227
|
+
}
|
|
194
228
|
// Forward the signal. Without it generateEmbeddingsForDb's abort machinery was
|
|
195
229
|
// inert — its throwIfAborted checks and the signal it threads into embedBatch
|
|
196
230
|
// (which RemoteEmbedder passes to every fetch and LocalEmbedder honours between
|
|
197
231
|
// chunks) never saw a controller. Ctrl-C and the improve budget abort could not
|
|
198
232
|
// stop the embedding phase, the longest phase of an index run.
|
|
199
|
-
|
|
200
|
-
|
|
201
|
-
|
|
233
|
+
const { embeddingResult, verification } = await runEmbeddingPass({ db, config, onProgress, signal, reembed });
|
|
234
|
+
ctx.embeddingResult = embeddingResult;
|
|
235
|
+
ctx.verification = verification;
|
|
202
236
|
ctx.timing.tEmbedEnd = Date.now();
|
|
203
237
|
}
|
|
204
238
|
/**
|
|
@@ -206,8 +240,8 @@ async function runEmbeddingPhase(ctx) {
|
|
|
206
240
|
* usage events, recompute utility scores, update index metadata, and emit the
|
|
207
241
|
* verify event.
|
|
208
242
|
*/
|
|
209
|
-
async function runFinalizePhase(ctx
|
|
210
|
-
const { db, config, sources, sourceDirs, stashDir, signal, onProgress } = ctx;
|
|
243
|
+
async function runFinalizePhase(ctx) {
|
|
244
|
+
const { db, config, sources, sourceDirs, stashDir, signal, onProgress, deferredUpdateTransaction } = ctx;
|
|
211
245
|
ctx.timing.tFinalizeStart = Date.now();
|
|
212
246
|
// `upsertEntry` and every canonical delete own their FTS projection. This is
|
|
213
247
|
// an observation point, not a second materialization pass.
|
|
@@ -249,22 +283,37 @@ async function runFinalizePhase(ctx, deferredUpdateTransaction) {
|
|
|
249
283
|
// An incomplete run preserves the prior freshness watermark. Advancing it
|
|
250
284
|
// could make a recovered source look unchanged even though this run never
|
|
251
285
|
// persisted its files.
|
|
252
|
-
const embeddingResult = ctx.embeddingResult ?? { success: false };
|
|
253
286
|
if (ctx.scanComplete) {
|
|
254
287
|
setMeta(db, "builtAt", new Date().toISOString());
|
|
255
288
|
setMeta(db, "stashDir", stashDir);
|
|
256
289
|
setMeta(db, "stashDirs", JSON.stringify(sourceDirs));
|
|
257
290
|
setMeta(db, "sourceOwners", JSON.stringify(sourceOwners(sources)));
|
|
258
291
|
}
|
|
259
|
-
setMeta(db, "hasEmbeddings", embeddingResult.success ? "1" : "0");
|
|
260
292
|
warnIfVecMissing(db);
|
|
261
293
|
const totalEntries = getEntryCount(db);
|
|
262
|
-
|
|
263
|
-
|
|
264
|
-
|
|
265
|
-
|
|
266
|
-
|
|
267
|
-
|
|
294
|
+
if (deferredUpdateTransaction) {
|
|
295
|
+
// #954: the embedding phase was skipped for this borrowed
|
|
296
|
+
// transaction — record semantic state as pending, never ready, until the
|
|
297
|
+
// coordinator's own post-commit `runEmbeddingPass` call reports the
|
|
298
|
+
// truth on a fresh connection.
|
|
299
|
+
setMeta(db, "hasEmbeddings", "0");
|
|
300
|
+
const semanticEntryCount = getEmbeddableEntryCount(db);
|
|
301
|
+
const message = "Semantic index update deferred until after the source-update commit.";
|
|
302
|
+
onProgress({ phase: "verify", message });
|
|
303
|
+
ctx.verification = {
|
|
304
|
+
ok: true,
|
|
305
|
+
message,
|
|
306
|
+
semanticSearchEnabled: config.semanticSearchMode === "auto",
|
|
307
|
+
semanticSearchMode: config.semanticSearchMode,
|
|
308
|
+
semanticStatus: config.semanticSearchMode === "off" ? "disabled" : "pending",
|
|
309
|
+
embeddingProvider: getEmbeddingProvider(config.embedding),
|
|
310
|
+
entryCount: semanticEntryCount,
|
|
311
|
+
embeddingCount: getEmbeddingCount(db),
|
|
312
|
+
vecAvailable: isVecAvailable(db),
|
|
313
|
+
};
|
|
314
|
+
}
|
|
315
|
+
// Non-deferred: ctx.verification was already populated by runEmbeddingPhase
|
|
316
|
+
// (via the shared runEmbeddingPass).
|
|
268
317
|
ctx.totalEntries = totalEntries;
|
|
269
318
|
ctx.timing.tFinalizeEnd = Date.now();
|
|
270
319
|
// suppress unused warning — sources was previously used inline
|
|
@@ -513,6 +562,11 @@ async function akmIndexReal(options) {
|
|
|
513
562
|
force: full,
|
|
514
563
|
materialize: options.hydrateSources !== false,
|
|
515
564
|
secrets: storeSecretResolver,
|
|
565
|
+
// Same progress channel as every other phase (#954) — a
|
|
566
|
+
// stalled clone/fetch here runs BEFORE index.db is even opened, so
|
|
567
|
+
// without this it looked identical to "no database open, nothing
|
|
568
|
+
// written".
|
|
569
|
+
onProgress: (message) => onProgress({ phase: "preflight", message }),
|
|
516
570
|
});
|
|
517
571
|
const sourceCacheEnd = Date.now();
|
|
518
572
|
const allSourceEntries = resolveSourceEntries(stashDir, config);
|
|
@@ -553,6 +607,7 @@ async function akmIndexReal(options) {
|
|
|
553
607
|
onProgress,
|
|
554
608
|
signal,
|
|
555
609
|
t0,
|
|
610
|
+
deferredUpdateTransaction: options.deferredUpdateTransaction,
|
|
556
611
|
});
|
|
557
612
|
indexRunContext = ctx;
|
|
558
613
|
onProgress({
|
|
@@ -592,7 +647,7 @@ async function akmIndexReal(options) {
|
|
|
592
647
|
}
|
|
593
648
|
cleanEnd = Date.now();
|
|
594
649
|
await runEmbeddingPhase(ctx);
|
|
595
|
-
await runFinalizePhase(ctx
|
|
650
|
+
await runFinalizePhase(ctx);
|
|
596
651
|
// ────────────────────────────────────────────────────────────────────────
|
|
597
652
|
// runFinalizePhase always populates these before returning.
|
|
598
653
|
const verification = ctx.verification;
|
|
@@ -1159,6 +1214,14 @@ function persistDirRecords(db, dirRecords, doFullDelete, warnings, sourceRoots,
|
|
|
1159
1214
|
// transaction so delete and re-insert are atomic — a concurrent reader
|
|
1160
1215
|
// never observes an empty database between the two operations.
|
|
1161
1216
|
if (fullDelete) {
|
|
1217
|
+
// #955: copy every (search_text hash, embedding) pair about to be
|
|
1218
|
+
// discarded wholesale into `embedding_salvage`, tagged with the
|
|
1219
|
+
// fingerprint the discarded vectors were generated under, BEFORE the
|
|
1220
|
+
// wipe below — inside the SAME transaction so the copy and the
|
|
1221
|
+
// discard commit or roll back together. The embedding phase later in
|
|
1222
|
+
// this run hands salvaged vectors back to unchanged content instead
|
|
1223
|
+
// of re-embedding the whole corpus.
|
|
1224
|
+
salvageEmbeddingsBeforeDiscard(db);
|
|
1162
1225
|
// Entries and every child materialization share one deletion authority.
|
|
1163
1226
|
// Usage events live in state.db and survive so finalize can relink them
|
|
1164
1227
|
// to the replacement generation's row ids.
|