@gmickel/gno 1.37.1 → 1.39.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.
Files changed (47) hide show
  1. package/assets/skill/README.md +2 -0
  2. package/browser-extension/artifacts/{gno-browser-clipper-v1.37.1.zip → gno-browser-clipper-v1.39.0.zip} +0 -0
  3. package/browser-extension/artifacts/gno-browser-clipper-v1.39.0.zip.sha256 +1 -0
  4. package/browser-extension/dist/manifest.json +1 -1
  5. package/package.json +1 -1
  6. package/spec/cli.md +243 -26
  7. package/spec/output-schemas/agents-mutation.schema.json +108 -0
  8. package/spec/output-schemas/agents-verify.schema.json +89 -0
  9. package/src/cli/commands/agents/block.ts +164 -0
  10. package/src/cli/commands/agents/commands.ts +413 -0
  11. package/src/cli/commands/agents/engine.ts +417 -0
  12. package/src/cli/commands/agents/harnesses.ts +298 -0
  13. package/src/cli/commands/agents/index.ts +35 -0
  14. package/src/cli/commands/cleanup.ts +8 -2
  15. package/src/cli/commands/collection/clear-embeddings.ts +6 -1
  16. package/src/cli/commands/completion/scripts.ts +5 -0
  17. package/src/cli/commands/doctor-activation.ts +5 -1
  18. package/src/cli/commands/doctor.ts +72 -2
  19. package/src/cli/commands/embed.ts +227 -194
  20. package/src/cli/commands/index-cmd.ts +74 -50
  21. package/src/cli/commands/init.ts +5 -1
  22. package/src/cli/commands/profile-apply.ts +5 -1
  23. package/src/cli/commands/setup-activation.ts +2 -1
  24. package/src/cli/commands/setup.ts +2 -1
  25. package/src/cli/commands/shared.ts +5 -1
  26. package/src/cli/commands/status.ts +5 -1
  27. package/src/cli/commands/tags.ts +18 -3
  28. package/src/cli/commands/update.ts +34 -27
  29. package/src/cli/commands/vec.ts +13 -4
  30. package/src/cli/errors.ts +3 -2
  31. package/src/cli/program.ts +449 -194
  32. package/src/config/defaults.ts +2 -0
  33. package/src/config/index.ts +3 -0
  34. package/src/config/types.ts +32 -1
  35. package/src/core/file-lock.ts +16 -4
  36. package/src/core/write-lease.ts +354 -0
  37. package/src/embed/backlog.ts +9 -1
  38. package/src/embed/retry.ts +116 -3
  39. package/src/sdk/client.ts +3 -1
  40. package/src/sdk/embed.ts +8 -3
  41. package/src/sdk/types.ts +2 -0
  42. package/src/serve/embed-scheduler.ts +8 -0
  43. package/src/serve/resident-runtime.ts +5 -1
  44. package/src/store/sqlite/adapter.ts +28 -4
  45. package/src/store/sqlite/scoped-index.ts +5 -1
  46. package/src/store/vector/sqlite-vec.ts +2 -1
  47. package/browser-extension/artifacts/gno-browser-clipper-v1.37.1.zip.sha256 +0 -1
@@ -1,15 +1,36 @@
1
1
  import type { EmbeddingPort } from "../llm/types";
2
+ import type { StoreResult } from "../store/types";
2
3
  import type { BacklogItem, VectorIndexPort, VectorRow } from "../store/vector";
3
4
 
5
+ import { isSqliteLockContention } from "../core/file-lock";
4
6
  import { formatDocForEmbedding } from "../pipeline/contextual";
5
7
  import { embedTextsWithRecovery } from "./batch";
6
8
 
7
9
  export const MAX_EMBED_CHUNK_ATTEMPTS = 2;
8
10
  export const MAX_EMBED_FAILURE_SAMPLES = 5;
9
11
 
12
+ /** Total upsert attempts (initial + retries) when persistence hits SQLITE_BUSY/LOCKED. */
13
+ export const UPSERT_CONTENTION_MAX_ATTEMPTS = 5;
14
+ export const UPSERT_CONTENTION_BASE_DELAY_MS = 250;
15
+ export const UPSERT_CONTENTION_BACKOFF_FACTOR = 2;
16
+ export const UPSERT_CONTENTION_JITTER_RATIO = 0.25;
17
+ export const UPSERT_CONTENTION_MAX_DELAY_MS = 5_000;
18
+
19
+ export const UPSERT_CONTENTION_ERROR_SAMPLE =
20
+ "index is busy (SQLITE_BUSY) — another writer holds the database; rerun `gno embed`";
21
+
22
+ export const STORE_WRITE_FAILURE_SUGGESTION =
23
+ "Store write failed. Rerun `gno embed` once more; if it repeats, run `gno doctor` and `gno vec sync`.";
24
+
10
25
  export interface EmbedStoreBatchResult {
11
26
  embedded: number;
12
27
  errors: number;
28
+ /**
29
+ * Chunks whose persistence failed after SQLITE_BUSY/SQLITE_LOCKED retries.
30
+ * Distinct from `errors` (embedding-provider / non-contention store failures).
31
+ * Default 0.
32
+ */
33
+ contentionErrors: number;
13
34
  retryItems: BacklogItem[];
14
35
  errorSamples: string[];
15
36
  suggestion?: string;
@@ -17,6 +38,10 @@ export interface EmbedStoreBatchResult {
17
38
  batchError?: string;
18
39
  }
19
40
 
41
+ // fn-127 integration: CLI consumers (src/cli/commands/embed.ts,
42
+ // src/cli/commands/index-cmd.ts) must surface contentionErrors in their
43
+ // summary and exit non-zero — the integrator wires that.
44
+
20
45
  export function chunkRetryKey(item: Pick<BacklogItem, "mirrorHash" | "seq">) {
21
46
  return `${item.mirrorHash}\0${item.seq}`;
22
47
  }
@@ -52,12 +77,81 @@ export function formatLlmFailure(
52
77
  : error.message;
53
78
  }
54
79
 
80
+ /**
81
+ * Classify a store-layer failure as SQLite lock contention.
82
+ * Matches SQLITE_BUSY/LOCKED on the error itself (stubs) or its `cause`
83
+ * (upsertVectors preserves the original SQLite error there).
84
+ */
85
+ export function isUpsertLockContention(error: {
86
+ code?: unknown;
87
+ cause?: unknown;
88
+ }): boolean {
89
+ return isSqliteLockContention(error) || isSqliteLockContention(error.cause);
90
+ }
91
+
92
+ /**
93
+ * Delay before the next upsert retry after `failedAttemptIndex` (0-based)
94
+ * contention failures. Formula: base * factor^attempt ± jitter, capped.
95
+ */
96
+ export function upsertContentionDelayMs(
97
+ failedAttemptIndex: number,
98
+ random: () => number = Math.random
99
+ ): number {
100
+ const exponential =
101
+ UPSERT_CONTENTION_BASE_DELAY_MS *
102
+ UPSERT_CONTENTION_BACKOFF_FACTOR ** failedAttemptIndex;
103
+ const jitterMultiplier =
104
+ 1 + (random() * 2 - 1) * UPSERT_CONTENTION_JITTER_RATIO;
105
+ return Math.min(
106
+ UPSERT_CONTENTION_MAX_DELAY_MS,
107
+ Math.max(0, exponential * jitterMultiplier)
108
+ );
109
+ }
110
+
111
+ function resolveContentionDelayMs(
112
+ failedAttemptIndex: number,
113
+ delays: number[] | undefined
114
+ ): number {
115
+ if (delays) {
116
+ return delays[failedAttemptIndex] ?? 0;
117
+ }
118
+ return upsertContentionDelayMs(failedAttemptIndex);
119
+ }
120
+
121
+ /**
122
+ * Retry `upsertVectors` on SQLITE_BUSY/SQLITE_LOCKED with exponential backoff.
123
+ * `delays` is a test seam that replaces the computed schedule (missing slots = 0).
124
+ */
125
+ export async function upsertVectorsWithContentionRetry(
126
+ vectorIndex: Pick<VectorIndexPort, "upsertVectors">,
127
+ vectors: VectorRow[],
128
+ delays?: number[]
129
+ ): Promise<StoreResult<void>> {
130
+ let storeResult = await vectorIndex.upsertVectors(vectors);
131
+ let attempts = 1;
132
+ while (
133
+ !storeResult.ok &&
134
+ isUpsertLockContention(storeResult.error) &&
135
+ attempts < UPSERT_CONTENTION_MAX_ATTEMPTS
136
+ ) {
137
+ const delayMs = resolveContentionDelayMs(attempts - 1, delays);
138
+ if (delayMs > 0) {
139
+ await Bun.sleep(delayMs);
140
+ }
141
+ storeResult = await vectorIndex.upsertVectors(vectors);
142
+ attempts += 1;
143
+ }
144
+ return storeResult;
145
+ }
146
+
55
147
  export async function embedAndStoreBatch(params: {
56
148
  embedPort: EmbeddingPort;
57
149
  vectorIndex: VectorIndexPort;
58
150
  items: BacklogItem[];
59
151
  modelUri: string;
60
152
  embedFingerprint: string;
153
+ /** Test seam: override contention-retry delays in milliseconds. */
154
+ delays?: number[];
61
155
  }): Promise<EmbedStoreBatchResult> {
62
156
  const { embedPort, vectorIndex, items, modelUri, embedFingerprint } = params;
63
157
  const embedResult = await embedTextsWithRecovery(
@@ -72,6 +166,7 @@ export async function embedAndStoreBatch(params: {
72
166
  return {
73
167
  embedded: 0,
74
168
  errors: embedResult.error.retryable ? 0 : items.length,
169
+ contentionErrors: 0,
75
170
  retryItems: embedResult.error.retryable ? items : [],
76
171
  errorSamples: [formattedError],
77
172
  suggestion: embedResult.error.retryable
@@ -103,6 +198,7 @@ export async function embedAndStoreBatch(params: {
103
198
  return {
104
199
  embedded: 0,
105
200
  errors: 0,
201
+ contentionErrors: 0,
106
202
  retryItems,
107
203
  errorSamples: embedResult.value.failureSamples,
108
204
  suggestion: embedResult.value.retrySuggestion,
@@ -111,15 +207,31 @@ export async function embedAndStoreBatch(params: {
111
207
  };
112
208
  }
113
209
 
114
- const storeResult = await vectorIndex.upsertVectors(vectors);
210
+ const storeResult = await upsertVectorsWithContentionRetry(
211
+ vectorIndex,
212
+ vectors,
213
+ params.delays
214
+ );
115
215
  if (!storeResult.ok) {
216
+ if (isUpsertLockContention(storeResult.error)) {
217
+ return {
218
+ embedded: 0,
219
+ errors: 0,
220
+ contentionErrors: vectors.length,
221
+ retryItems,
222
+ errorSamples: [UPSERT_CONTENTION_ERROR_SAMPLE],
223
+ suggestion: UPSERT_CONTENTION_ERROR_SAMPLE,
224
+ batchFailed: embedResult.value.batchFailed,
225
+ batchError: embedResult.value.batchError,
226
+ };
227
+ }
116
228
  return {
117
229
  embedded: 0,
118
230
  errors: vectors.length,
231
+ contentionErrors: 0,
119
232
  retryItems,
120
233
  errorSamples: [storeResult.error.message],
121
- suggestion:
122
- "Store write failed. Rerun `gno embed` once more; if it repeats, run `gno doctor` and `gno vec sync`.",
234
+ suggestion: STORE_WRITE_FAILURE_SUGGESTION,
123
235
  batchFailed: embedResult.value.batchFailed,
124
236
  batchError: embedResult.value.batchError,
125
237
  };
@@ -128,6 +240,7 @@ export async function embedAndStoreBatch(params: {
128
240
  return {
129
241
  embedded: vectors.length,
130
242
  errors: 0,
243
+ contentionErrors: 0,
131
244
  retryItems,
132
245
  errorSamples: embedResult.value.failureSamples,
133
246
  suggestion: embedResult.value.retrySuggestion,
package/src/sdk/client.ts CHANGED
@@ -276,7 +276,9 @@ async function resolveClientState(
276
276
 
277
277
  const store = new SqliteAdapter();
278
278
  store.setConfigPath(configPath ?? "<inline-config>");
279
- unwrapStore(await store.open(dbPath, config.ftsTokenizer));
279
+ unwrapStore(
280
+ await store.open(dbPath, config.ftsTokenizer, config.busyTimeoutMs)
281
+ );
280
282
  unwrapStore(await store.syncCollections(config.collections));
281
283
  unwrapStore(await store.syncContexts(config.contexts ?? []));
282
284
 
package/src/sdk/embed.ts CHANGED
@@ -117,9 +117,10 @@ async function forceEmbedAll(
117
117
  vectorIndex: VectorIndexPort,
118
118
  modelUri: string,
119
119
  batchSize: number
120
- ): Promise<{ embedded: number; errors: number }> {
120
+ ): Promise<{ embedded: number; errors: number; contentionErrors: number }> {
121
121
  let embedded = 0;
122
122
  let errors = 0;
123
+ let contentionErrors = 0;
123
124
  let cursor: { mirrorHash: string; seq: number } | undefined;
124
125
  const retryQueue = new Map<string, { item: BacklogItem; attempts: number }>();
125
126
  const embedFingerprint = getEmbeddingFingerprint({
@@ -164,6 +165,7 @@ async function forceEmbedAll(
164
165
  });
165
166
  embedded += retryResult.embedded;
166
167
  errors += retryResult.errors;
168
+ contentionErrors += retryResult.contentionErrors;
167
169
  retryEmbedded += retryResult.embedded;
168
170
 
169
171
  const retryByKey = new Set(
@@ -212,6 +214,7 @@ async function forceEmbedAll(
212
214
  });
213
215
  embedded += embedResult.embedded;
214
216
  errors += embedResult.errors;
217
+ contentionErrors += embedResult.contentionErrors;
215
218
  enqueueRetryItems(embedResult.retryItems, 1);
216
219
 
217
220
  if (embedded > beforeEmbedded) {
@@ -232,7 +235,7 @@ async function forceEmbedAll(
232
235
  }
233
236
  }
234
237
 
235
- return { embedded, errors };
238
+ return { embedded, errors, contentionErrors };
236
239
  }
237
240
 
238
241
  async function checkVecAvailable(db: Database): Promise<boolean> {
@@ -343,7 +346,7 @@ export async function runEmbed(
343
346
  }
344
347
 
345
348
  const startedAt = Date.now();
346
- let result: { embedded: number; errors: number };
349
+ let result: { embedded: number; errors: number; contentionErrors: number };
347
350
  if (force) {
348
351
  result = await forceEmbedAll(
349
352
  db,
@@ -369,12 +372,14 @@ export async function runEmbed(
369
372
  result = {
370
373
  embedded: processed.value.embedded,
371
374
  errors: processed.value.errors,
375
+ contentionErrors: processed.value.contentionErrors ?? 0,
372
376
  };
373
377
  }
374
378
 
375
379
  return {
376
380
  embedded: result.embedded,
377
381
  errors: result.errors,
382
+ contentionErrors: result.contentionErrors,
378
383
  duration: (Date.now() - startedAt) / 1000,
379
384
  model: modelUri,
380
385
  searchAvailable: vectorIndex.searchAvailable,
package/src/sdk/types.ts CHANGED
@@ -204,6 +204,8 @@ export interface GnoEmbedOptions {
204
204
  export interface GnoEmbedResult {
205
205
  embedded: number;
206
206
  errors: number;
207
+ /** Persistence lock-contention failures; distinct from embedding-provider `errors`. */
208
+ contentionErrors?: number;
207
209
  duration: number;
208
210
  model: string;
209
211
  searchAvailable: boolean;
@@ -130,6 +130,14 @@ export function createEmbedScheduler(deps: EmbedSchedulerDeps): EmbedScheduler {
130
130
  console.error("[embed-scheduler] Embed failed:", result.error.message);
131
131
  return { embedded: 0, errors: 0 };
132
132
  }
133
+ if ((result.value.contentionErrors ?? 0) > 0) {
134
+ // Chunks deferred by SQLITE_BUSY stay in the backlog; retry the run
135
+ // instead of silently leaving embeddings a pass behind (fn-127 R6).
136
+ console.error(
137
+ `[embed-scheduler] ${result.value.contentionErrors} chunks deferred by index contention; rescheduling`
138
+ );
139
+ needsRerun = true;
140
+ }
133
141
  if (result.value.embedded > 0) onEmbedded?.(result.value);
134
142
  return result.value;
135
143
  } finally {
@@ -212,7 +212,11 @@ export async function startResidentRuntime(
212
212
  const paths = (deps.getConfigPaths ?? getConfigPaths)();
213
213
  const actualConfigPath = resolve(options.configPath ?? paths.configFile);
214
214
  store.setConfigPath(actualConfigPath);
215
- const openResult = await store.open(dbPath, initialConfig.ftsTokenizer);
215
+ const openResult = await store.open(
216
+ dbPath,
217
+ initialConfig.ftsTokenizer,
218
+ initialConfig.busyTimeoutMs
219
+ );
216
220
  if (!openResult.ok) {
217
221
  await ownerLock.release();
218
222
  return { success: false, error: openResult.error.message };
@@ -114,9 +114,12 @@ import { buildUri, deriveDocid, stripUriIndex } from "../../app/constants";
114
114
  import {
115
115
  type Collection,
116
116
  type Context,
117
+ DEFAULT_BUSY_TIMEOUT_MS,
117
118
  type EgressPolicy,
118
119
  type EgressPolicySource,
119
120
  type FtsTokenizer,
121
+ MAX_BUSY_TIMEOUT_MS,
122
+ MIN_BUSY_TIMEOUT_MS,
120
123
  resolveConfiguredEgressPolicy,
121
124
  } from "../../config/types";
122
125
  import {
@@ -476,6 +479,19 @@ function isDatabaseLockedError(cause: unknown): boolean {
476
479
  );
477
480
  }
478
481
 
482
+ /** Resolve a caller-supplied busy_timeout, defaulting rather than using 0. */
483
+ function resolveBusyTimeoutMs(busyTimeoutMs?: number): number {
484
+ if (
485
+ busyTimeoutMs === undefined ||
486
+ !Number.isInteger(busyTimeoutMs) ||
487
+ busyTimeoutMs < MIN_BUSY_TIMEOUT_MS ||
488
+ busyTimeoutMs > MAX_BUSY_TIMEOUT_MS
489
+ ) {
490
+ return DEFAULT_BUSY_TIMEOUT_MS;
491
+ }
492
+ return busyTimeoutMs;
493
+ }
494
+
479
495
  export class SqliteAdapter implements StorePort, SqliteDbProvider {
480
496
  private db: Database | null = null;
481
497
  private dbPath = "";
@@ -492,7 +508,8 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
492
508
 
493
509
  async open(
494
510
  dbPath: string,
495
- ftsTokenizer: FtsTokenizer
511
+ ftsTokenizer: FtsTokenizer,
512
+ busyTimeoutMs: number = DEFAULT_BUSY_TIMEOUT_MS
496
513
  ): Promise<StoreResult<MigrationResult>> {
497
514
  try {
498
515
  this.db = new Database(dbPath, { create: true });
@@ -501,7 +518,9 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
501
518
 
502
519
  // Enable pragmas for performance and safety
503
520
  this.db.exec("PRAGMA foreign_keys = ON");
504
- this.db.exec("PRAGMA busy_timeout = 5000");
521
+ this.db.exec(
522
+ `PRAGMA busy_timeout = ${resolveBusyTimeoutMs(busyTimeoutMs)}`
523
+ );
505
524
 
506
525
  // Keep WAL everywhere so readers can continue while a writer is active.
507
526
  // CI still relaxes fsync/temp-store for speed, but MEMORY journal mode
@@ -558,12 +577,17 @@ export class SqliteAdapter implements StorePort, SqliteDbProvider {
558
577
  }
559
578
 
560
579
  /** Open an existing index with SQLite enforced query-only semantics. */
561
- openReadOnly(dbPath: string): StoreResult<void> {
580
+ openReadOnly(
581
+ dbPath: string,
582
+ busyTimeoutMs: number = DEFAULT_BUSY_TIMEOUT_MS
583
+ ): StoreResult<void> {
562
584
  try {
563
585
  this.db = new Database(dbPath, { readonly: true, strict: true });
564
586
  this.dbPath = dbPath;
565
587
  this.db.exec("PRAGMA query_only = ON");
566
- this.db.exec("PRAGMA busy_timeout = 5000");
588
+ this.db.exec(
589
+ `PRAGMA busy_timeout = ${resolveBusyTimeoutMs(busyTimeoutMs)}`
590
+ );
567
591
  this.contextGeneration += 1;
568
592
  return ok(undefined);
569
593
  } catch (cause) {
@@ -38,7 +38,11 @@ export async function openScopedIndexStore(options: {
38
38
 
39
39
  const store = new SqliteAdapter();
40
40
  store.setConfigPath(options.configPath ?? "<inline-config>");
41
- const openResult = await store.open(dbPath, options.config.ftsTokenizer);
41
+ const openResult = await store.open(
42
+ dbPath,
43
+ options.config.ftsTokenizer,
44
+ options.config.busyTimeoutMs
45
+ );
42
46
  if (!openResult.ok) {
43
47
  throw new Error(openResult.error.message);
44
48
  }
@@ -193,7 +193,8 @@ export async function createVectorIndexPort(
193
193
  return Promise.resolve(
194
194
  err(
195
195
  "VECTOR_WRITE_FAILED",
196
- `Vector write failed: ${e instanceof Error ? e.message : String(e)}`
196
+ `Vector write failed: ${e instanceof Error ? e.message : String(e)}`,
197
+ e
197
198
  )
198
199
  );
199
200
  }
@@ -1 +0,0 @@
1
- 6954195a14c9cdff85a265043590873823a0b25b287866c036d83153a95b8b14 gno-browser-clipper-v1.37.1.zip