@zokizuan/satori-mcp 6.2.0 → 6.3.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.
package/README.md CHANGED
@@ -7,12 +7,18 @@ Most users should install Satori through `@zokizuan/satori-cli`. The installer w
7
7
  ## Install
8
8
 
9
9
  ```bash
10
- npx -y @zokizuan/satori-cli@latest install --client all --runtime offline
11
- npx -y @zokizuan/satori-cli@latest doctor
10
+ npm install -g @zokizuan/satori-cli@latest
11
+ satori install --client all
12
+ satori doctor
12
13
  ```
13
14
 
14
15
  The local Potion runtime currently supports Linux x64, including Windows through WSL2. Connected Voyage and explicit local Ollama configurations are also available. See the [main README](https://github.com/ham-zax/satori#quick-start) for runtime choices.
15
16
 
17
+ When installed through the CLI, compatible offline Potion + LanceDB clients
18
+ share one private local host, provider/LanceDB state, and one Potion worker. Each
19
+ client remains an independent MCP session. Direct `npx @zokizuan/satori-mcp`
20
+ execution is still isolated and does not join the managed host.
21
+
16
22
  Direct package execution is intended for inspection and custom harnesses:
17
23
 
18
24
  ```bash
@@ -64,6 +70,9 @@ In a fresh two-task OpenCode comparison where both arms answered correctly, Sato
64
70
  - Inbound call-graph evidence is advisory and should be verified before blast-radius edits.
65
71
  - Provider, model, dimensions, projection, and vector backend are persisted compatibility identities; changing them requires a reindex.
66
72
  - Multiple incompatible live Satori runtimes are blocked from mutating the same publication.
73
+ - Managed offline Potion + LanceDB clients on Linux x64/WSL2 share one private
74
+ local host. Connected providers, Milvus, and explicit Ollama runtimes keep
75
+ the direct per-client lifecycle.
67
76
  - Offline neural reranking is not shipped today. The candidate boundary permits a future complete-set local scorer to fail back atomically to exact + BM25 + single-vector ordering.
68
77
 
69
78
  ## Development
@@ -8,6 +8,25 @@ export function buildCanonicalSymbolRegistryView(symbols) {
8
8
  }
9
9
  return symbolsByKey;
10
10
  }
11
+ function spanContains(parent, child) {
12
+ if (parent.file !== child.file) {
13
+ return false;
14
+ }
15
+ if (parent.span.startByte !== undefined
16
+ && parent.span.endByte !== undefined
17
+ && child.span.startByte !== undefined
18
+ && child.span.endByte !== undefined) {
19
+ return parent.span.startByte <= child.span.startByte
20
+ && child.span.endByte <= parent.span.endByte;
21
+ }
22
+ return parent.span.startLine <= child.span.startLine
23
+ && child.span.endLine <= parent.span.endLine;
24
+ }
25
+ function spanSize(symbol, useBytes) {
26
+ return useBytes
27
+ ? Math.max(0, symbol.span.endByte - symbol.span.startByte)
28
+ : Math.max(0, symbol.span.endLine - symbol.span.startLine);
29
+ }
11
30
  function resolveParent(input) {
12
31
  const { symbol } = input;
13
32
  if (!symbol.parentKey) {
@@ -17,17 +36,26 @@ function resolveParent(input) {
17
36
  : "missing",
18
37
  };
19
38
  }
20
- const candidates = (input.registry.get(symbol.parentKey) || [])
21
- .filter((candidate) => candidate.symbolInstanceId !== symbol.symbolInstanceId);
22
- if (candidates.length === 0) {
39
+ const containingCandidates = (input.registry.get(symbol.parentKey) || [])
40
+ .filter((candidate) => (candidate.symbolInstanceId !== symbol.symbolInstanceId
41
+ && spanContains(candidate, symbol)));
42
+ if (containingCandidates.length === 0) {
23
43
  return { state: "missing" };
24
44
  }
25
- if (candidates.length > 1) {
45
+ const byteCandidates = containingCandidates.filter((candidate) => (candidate.span.startByte !== undefined
46
+ && candidate.span.endByte !== undefined
47
+ && symbol.span.startByte !== undefined
48
+ && symbol.span.endByte !== undefined));
49
+ const candidates = byteCandidates.length > 0 ? byteCandidates : containingCandidates;
50
+ const useBytes = byteCandidates.length > 0;
51
+ const innermostSize = Math.min(...candidates.map((candidate) => spanSize(candidate, useBytes)));
52
+ const innermost = candidates.filter((candidate) => spanSize(candidate, useBytes) === innermostSize);
53
+ if (innermost.length !== 1) {
26
54
  return { state: "ambiguous" };
27
55
  }
28
56
  return {
29
57
  state: "resolved",
30
- parentSymbolId: candidates[0].symbolInstanceId,
58
+ parentSymbolId: innermost[0].symbolInstanceId,
31
59
  };
32
60
  }
33
61
  export function projectCanonicalSymbolIdentity(input) {
@@ -868,6 +868,13 @@ export class ManageIndexingHandlers {
868
868
  }
869
869
  else {
870
870
  const operation = persistOperation("blocked");
871
+ if (result.reason === "repair_proof_limit") {
872
+ return this.host.manageResponse("repair", absolutePath, "blocked", result.message, {
873
+ reason: "repair_proof_limit",
874
+ repairProof: result.proof,
875
+ ...(operation ? { operation } : {}),
876
+ });
877
+ }
871
878
  const createHint = this.host.buildCreateHint(absolutePath);
872
879
  return this.host.manageResponse("repair", absolutePath, "blocked", result.message, {
873
880
  reason: result.reason || "needs_create",
@@ -7,7 +7,7 @@ export type ManageIndexAction = (typeof MANAGE_INDEX_ACTIONS)[number];
7
7
  export type ManageIndexStatus = "ok" | "not_ready" | "not_indexed" | "requires_reindex" | "blocked" | "error";
8
8
  export declare const MANAGE_INDEX_STATUS_DETAILS: readonly ["summary", "capabilities", "diagnostics", "full"];
9
9
  export type ManageIndexStatusDetail = (typeof MANAGE_INDEX_STATUS_DETAILS)[number];
10
- export type ManageIndexReason = "indexing" | "not_indexed" | "requires_reindex" | "unnecessary_reindex_ignore_only" | "preflight_unknown" | "backend_timeout" | "remote_delete_pending" | "missing_provider_config" | "vector_backend_unavailable" | "runtime_owner_conflict" | "mutation_in_progress" | "needs_create";
10
+ export type ManageIndexReason = "indexing" | "not_indexed" | "requires_reindex" | "unnecessary_reindex_ignore_only" | "preflight_unknown" | "backend_timeout" | "remote_delete_pending" | "missing_provider_config" | "vector_backend_unavailable" | "runtime_owner_conflict" | "mutation_in_progress" | "needs_create" | "repair_proof_limit";
11
11
  export type VectorBackendResponseCode = "ZILLIZ_CLUSTER_STOPPED" | "VECTOR_BACKEND_AUTH_FAILED" | "VECTOR_BACKEND_UNREACHABLE" | "VECTOR_BACKEND_TIMEOUT" | "VECTOR_BACKEND_CONNECTION_CLOSED";
12
12
  export type ManageReindexPreflightOutcome = "reindex_required" | "reindex_unnecessary_ignore_only" | "unknown" | "probe_failed";
13
13
  export interface ManageIndexToolHint {
@@ -1,7 +1,7 @@
1
1
  import { compareContractStrings, getGraphNeighbors, getRelationshipsForSymbol, isTestOrFixturePath, } from "@zokizuan/satori-core";
2
2
  import { DEFAULT_CALL_GRAPH_TEST_REFERENCE_LIMIT } from "./call-graph.js";
3
3
  import { buildSourceBackedPythonCalleeFallback, buildSourceBackedPythonCallerFallback, } from "./python-call-fallback.js";
4
- import { buildInboundNotesOnlySearchQuery } from "./search-response-helpers.js";
4
+ import { buildInboundVerificationSearchQuery } from "./search-response-helpers.js";
5
5
  function compareNullableNumbersAsc(a, b) {
6
6
  const left = typeof a === "number" ? a : Number.POSITIVE_INFINITY;
7
7
  const right = typeof b === "number" ? b : Number.POSITIVE_INFINITY;
@@ -28,7 +28,7 @@ export function uniqueInboundCallerSiteFile(notes) {
28
28
  continue;
29
29
  }
30
30
  const file = typeof note.file === "string" ? note.file.trim() : "";
31
- if (!file) {
31
+ if (!file || file === "(aggregate)") {
32
32
  continue;
33
33
  }
34
34
  allSites.add(file);
@@ -43,6 +43,7 @@ export function uniqueInboundCallerSiteFile(notes) {
43
43
  return [...preferred][0];
44
44
  }
45
45
  const MAX_DETAILED_TEST_SUPPRESSED_CALLER_NOTES = 3;
46
+ const INBOUND_COVERAGE_PARTIAL_WARNING = "CALL_GRAPH_INBOUND_COVERAGE_PARTIAL";
46
47
  /**
47
48
  * Production suppressed callers first; collapse excess test/fixture caller notes into one summary.
48
49
  */
@@ -408,11 +409,13 @@ export class RelationshipBackedCallGraph {
408
409
  ...combinedEdges.flatMap((edge) => [edge.srcSymbolId, edge.dstSymbolId]),
409
410
  ]);
410
411
  const combinedNodes = this.sortNodes([...nodeById.values()].filter((node) => referencedNodeIds.has(node.symbolId)));
412
+ const hasNoInboundEdges = (input.direction === "callers" || input.direction === "both") && !combinedEdges.some((edge) => (edge.dstSymbolId === input.resolvedSymbol.symbolInstanceId));
411
413
  const warnings = [...new Set([
412
414
  ...neighbors.warnings,
413
415
  ...(droppedEdgesOutsideSourceSpan > 0 ? [`CALL_GRAPH_EDGE_OUTSIDE_SOURCE_SPAN:${droppedEdgesOutsideSourceSpan}`] : []),
414
416
  ...(addedDynamicCalleeEdges.length > 0 ? [`SOURCE_BACKED_DYNAMIC_CALLEES:${addedDynamicCalleeEdges.length}`] : []),
415
417
  ...(addedDynamicCallerEdges.length > 0 ? [`SOURCE_BACKED_DYNAMIC_CALLERS:${addedDynamicCallerEdges.length}`] : []),
418
+ ...(hasNoInboundEdges ? [INBOUND_COVERAGE_PARTIAL_WARNING] : []),
416
419
  ])].sort(compareContractStrings);
417
420
  // Sort first for determinism within bands, then production-first inbound note priority.
418
421
  const combinedNotes = prioritizeInboundSuppressedNotes(this.sortNotes([
@@ -420,17 +423,13 @@ export class RelationshipBackedCallGraph {
420
423
  ...(addedDynamicCalleeEdges.length > 0 ? dynamicCalleeFallback.notes : []),
421
424
  ...(addedDynamicCallerEdges.length > 0 ? dynamicCallerFallback.notes : []),
422
425
  ]));
423
- const wantsInbound = input.direction === "callers" || input.direction === "both";
424
- const inboundEdgeCount = combinedEdges.filter((edge) => (edge.dstSymbolId === input.resolvedSymbol.symbolInstanceId)).length;
425
- const hasInboundSuppressedNotes = combinedNotes.some((note) => (note.type === "suppressed_edge"
426
- && typeof note.detail === "string"
427
- && note.detail.includes("caller candidate")));
428
- // Notes-only inbound: promote executable must: identifier search (no fake edges).
429
- // path: uses unique suppressed *caller site* file when available — prefer production
430
- // over test/fixture sites (never the callee defining file alone).
426
+ // Empty inbound traversal: disclose advisory coverage and promote executable
427
+ // must: identifier search without fabricating an edge. path: uses a unique
428
+ // suppressed caller site when proven, never the callee defining file alone.
431
429
  let hints;
432
- if (wantsInbound && inboundEdgeCount === 0 && hasInboundSuppressedNotes) {
433
- const constructed = buildInboundNotesOnlySearchQuery({
430
+ if (hasNoInboundEdges) {
431
+ const constructed = buildInboundVerificationSearchQuery({
432
+ symbolName: input.resolvedSymbol.name,
434
433
  symbolLabel: input.resolvedSymbol.label,
435
434
  symbolId: input.resolvedSymbol.symbolInstanceId,
436
435
  file: uniqueInboundCallerSiteFile(combinedNotes),
@@ -446,7 +445,7 @@ export class RelationshipBackedCallGraph {
446
445
  scope: "runtime",
447
446
  resultMode: "grouped",
448
447
  },
449
- reason: "Inbound graph edges were suppressed as low-confidence or incomplete; use deterministic must: search to find production call sites.",
448
+ reason: "Inbound graph coverage is partial and returned no callers; use deterministic must: search to verify production call sites.",
450
449
  },
451
450
  ],
452
451
  };
@@ -95,6 +95,7 @@ export function buildChangedCodeDebug(input) {
95
95
  const symbols = changedSymbols.slice(0, input.maxSymbols);
96
96
  const cappedDirectCallers = directCallers.slice(0, input.maxDirectCallers);
97
97
  return {
98
+ basis: "git_tracked_worktree",
98
99
  files,
99
100
  symbols,
100
101
  directCallers: cappedDirectCallers,
@@ -20,10 +20,11 @@ export declare function buildSearchGroupRecommendedAction(codebaseRoot: string,
20
20
  export declare function buildTopRecommendedSearchAction(codebaseRoot: string, results: SearchGroupResult[]): SearchRecommendedNextAction | undefined;
21
21
  export declare function buildTopRecommendedRawSearchAction(codebaseRoot: string, results: SearchChunkResult[]): SearchRecommendedNextAction | undefined;
22
22
  /**
23
- * Build a must: identifier query for notes-only inbound graph recovery (M7/C1).
23
+ * Build a must: identifier query for empty inbound graph verification.
24
24
  * Never feeds raw multi-token labels into must: (operator tokenizer is whitespace-based).
25
25
  */
26
- export declare function buildInboundNotesOnlySearchQuery(input: {
26
+ export declare function buildInboundVerificationSearchQuery(input: {
27
+ symbolName?: string;
27
28
  symbolLabel?: string;
28
29
  symbolId?: string;
29
30
  file?: string;
@@ -377,11 +377,13 @@ export function buildTopRecommendedRawSearchAction(codebaseRoot, results) {
377
377
  };
378
378
  }
379
379
  /**
380
- * Build a must: identifier query for notes-only inbound graph recovery (M7/C1).
380
+ * Build a must: identifier query for empty inbound graph verification.
381
381
  * Never feeds raw multi-token labels into must: (operator tokenizer is whitespace-based).
382
382
  */
383
- export function buildInboundNotesOnlySearchQuery(input) {
384
- const identifier = extractIdentifierFromSymbolLabel(input.symbolLabel)
383
+ export function buildInboundVerificationSearchQuery(input) {
384
+ const symbolName = input.symbolName?.trim();
385
+ const identifier = (symbolName && !/\s/.test(symbolName) ? symbolName : undefined)
386
+ || extractIdentifierFromSymbolLabel(input.symbolLabel)
385
387
  || extractIdentifierFromSymbolLabel(input.symbolId);
386
388
  if (!identifier) {
387
389
  return { query: "", pathFilterIncluded: false };
@@ -398,6 +398,7 @@ export interface SearchDebugHint {
398
398
  boostedCandidates: number;
399
399
  };
400
400
  changedCode?: {
401
+ basis: "git_tracked_worktree";
401
402
  files: string[];
402
403
  symbols: Array<{
403
404
  file: string;
@@ -10,6 +10,7 @@ interface SyncManagerOptions {
10
10
  mutationLeaseCoordinator?: MutationLeaseCoordinator;
11
11
  crossProcessJoinTimeoutMs?: number;
12
12
  crossProcessJoinPollMs?: number;
13
+ onLifecycleActivityChanged?: () => void;
13
14
  }
14
15
  export type FreshnessDecisionMode = 'synced' | 'skipped_recent' | 'skipped_source_unchanged' | 'coalesced' | 'skipped_indexing' | 'skipped_requires_reindex' | 'skipped_source_checkpoint_unavailable' | 'skipped_mutation_in_progress' | 'skipped_missing_path' | 'reconciled_ignore_change' | 'ignore_reload_failed';
15
16
  export interface FreshnessDecision {
@@ -100,6 +101,8 @@ export declare class SyncManager {
100
101
  private activeSyncs;
101
102
  private lastSyncTimes;
102
103
  private backgroundSyncTimer;
104
+ private backgroundSyncEnabled;
105
+ private backgroundSyncFlight;
103
106
  private watcherModeStarted;
104
107
  private watchEnabled;
105
108
  private watchDebounceMs;
@@ -112,6 +115,7 @@ export declare class SyncManager {
112
115
  private ignoreRulesVersions;
113
116
  private pendingIgnoreChangeEdits;
114
117
  private activeIgnoreReconciles;
118
+ private activeWatcherSyncs;
115
119
  private freshnessEpochs;
116
120
  private sourceCheckpointObservations;
117
121
  private sourceCheckpointStatuses;
@@ -120,6 +124,7 @@ export declare class SyncManager {
120
124
  private readonly mutationLeaseCoordinator?;
121
125
  private readonly crossProcessJoinTimeoutMs;
122
126
  private readonly crossProcessJoinPollMs;
127
+ private readonly onLifecycleActivityChanged?;
123
128
  constructor(context: Context, snapshotManager: SnapshotManager, options?: SyncManagerOptions);
124
129
  private bumpFreshnessEpoch;
125
130
  getPreparedReadObservation(codebasePath: string): PreparedReadObservationResult;
@@ -140,7 +145,15 @@ export declare class SyncManager {
140
145
  private joinCrossProcessSync;
141
146
  handleSyncIndex(): Promise<void>;
142
147
  startBackgroundSync(): void;
148
+ private scheduleBackgroundSync;
149
+ private runBackgroundSync;
143
150
  stopBackgroundSync(): void;
151
+ /**
152
+ * Stops new provider-owned synchronization work and joins every lifecycle
153
+ * flight that may still hold mutation or backend authority.
154
+ */
155
+ stopAndDrainLifecycle(): Promise<void>;
156
+ getActiveLifecycleOperationCount(): number;
144
157
  getWatchDebounceMs(): number;
145
158
  private canScheduleWatchSync;
146
159
  private getIgnoreRuleVersion;
package/dist/core/sync.js CHANGED
@@ -42,6 +42,8 @@ export class SyncManager {
42
42
  this.activeSyncs = new Map();
43
43
  this.lastSyncTimes = new Map();
44
44
  this.backgroundSyncTimer = null;
45
+ this.backgroundSyncEnabled = false;
46
+ this.backgroundSyncFlight = null;
45
47
  this.watcherModeStarted = false;
46
48
  this.watchedCodebases = new Set();
47
49
  this.watchers = new Map();
@@ -52,6 +54,7 @@ export class SyncManager {
52
54
  this.ignoreRulesVersions = new Map();
53
55
  this.pendingIgnoreChangeEdits = new Map();
54
56
  this.activeIgnoreReconciles = new Map();
57
+ this.activeWatcherSyncs = new Set();
55
58
  this.freshnessEpochs = new Map();
56
59
  this.sourceCheckpointObservations = new Map();
57
60
  this.sourceCheckpointStatuses = new Map();
@@ -62,6 +65,7 @@ export class SyncManager {
62
65
  this.now = options.now || (() => Date.now());
63
66
  this.onSyncCompleted = options.onSyncCompleted;
64
67
  this.mutationLeaseCoordinator = options.mutationLeaseCoordinator;
68
+ this.onLifecycleActivityChanged = options.onLifecycleActivityChanged;
65
69
  this.crossProcessJoinTimeoutMs = Math.max(1, options.crossProcessJoinTimeoutMs ?? DEFAULT_CROSS_PROCESS_JOIN_TIMEOUT_MS);
66
70
  this.crossProcessJoinPollMs = Math.max(1, options.crossProcessJoinPollMs ?? DEFAULT_CROSS_PROCESS_JOIN_POLL_MS);
67
71
  }
@@ -167,20 +171,22 @@ export class SyncManager {
167
171
  if (previousObservation && previousObservation !== checkpointEvidence.observationToken) {
168
172
  this.bumpFreshnessEpoch(codebasePath);
169
173
  }
170
- return null;
174
+ return { checkpoint: checkpointEvidence };
171
175
  }
172
176
  if (!checkpointEvidence)
173
- return null;
177
+ return { checkpoint: null };
174
178
  this.sourceCheckpointStatuses.set(codebasePath, checkpointEvidence.status);
175
179
  this.sourceCheckpointObservations.delete(codebasePath);
176
180
  this.lastSyncTimes.delete(codebasePath);
177
181
  this.bumpFreshnessEpoch(codebasePath);
178
182
  return {
179
- mode: 'skipped_source_checkpoint_unavailable',
180
- checkedAt,
181
- thresholdMs,
182
- checkpointStatus: checkpointEvidence.status,
183
- errorMessage: checkpointEvidence.message,
183
+ failure: {
184
+ mode: 'skipped_source_checkpoint_unavailable',
185
+ checkedAt,
186
+ thresholdMs,
187
+ checkpointStatus: checkpointEvidence.status,
188
+ errorMessage: checkpointEvidence.message,
189
+ },
184
190
  };
185
191
  }
186
192
  persistOwnedOperationStart(lease, ownsLease) {
@@ -278,10 +284,10 @@ export class SyncManager {
278
284
  const checkedAtMs = this.now();
279
285
  const checkedAt = new Date(checkedAtMs).toISOString();
280
286
  if (options.reason === 'ignore_change') {
281
- const checkpointFailure = await this.validateSourceFreshnessCheckpoint(codebasePath, checkedAt, thresholdMs, options.preparedVectorReceipt);
282
- if (checkpointFailure)
283
- return checkpointFailure;
284
- return this.runIgnoreReconcile(codebasePath, options.coalescedEdits, undefined, options.mutationLease);
287
+ const checkpointValidation = await this.validateSourceFreshnessCheckpoint(codebasePath, checkedAt, thresholdMs, options.preparedVectorReceipt);
288
+ if ('failure' in checkpointValidation)
289
+ return checkpointValidation.failure;
290
+ return this.runIgnoreReconcile(codebasePath, options.coalescedEdits, undefined, options.mutationLease, checkpointValidation.checkpoint?.generationReceipt);
285
291
  }
286
292
  // Join a live mutation before inspecting its checkpoint. The owner may be
287
293
  // between marker withdrawal and checkpoint publication.
@@ -307,16 +313,16 @@ export class SyncManager {
307
313
  // Source-freshness ownership is a precondition for every incremental path,
308
314
  // including ignore reconciliation. The identity comes from Core authority,
309
315
  // never from the lifecycle snapshot.
310
- const checkpointFailure = await this.validateSourceFreshnessCheckpoint(codebasePath, checkedAt, thresholdMs, options.preparedVectorReceipt);
311
- if (checkpointFailure)
312
- return checkpointFailure;
316
+ const checkpointValidation = await this.validateSourceFreshnessCheckpoint(codebasePath, checkedAt, thresholdMs, options.preparedVectorReceipt);
317
+ if ('failure' in checkpointValidation)
318
+ return checkpointValidation.failure;
313
319
  let currentIgnoreControlSignature;
314
320
  if (options.skipIgnoreControlCheck !== true) {
315
321
  currentIgnoreControlSignature = await this.computeIgnoreControlSignature(codebasePath);
316
322
  const persistedIgnoreControlSignature = this.snapshotManager.getCodebaseIgnoreControlSignature?.(codebasePath);
317
323
  if (typeof persistedIgnoreControlSignature === 'string') {
318
324
  if (persistedIgnoreControlSignature !== currentIgnoreControlSignature) {
319
- return this.runIgnoreReconcile(codebasePath, 1, currentIgnoreControlSignature, options.mutationLease);
325
+ return this.runIgnoreReconcile(codebasePath, 1, currentIgnoreControlSignature, options.mutationLease, checkpointValidation.checkpoint?.generationReceipt);
320
326
  }
321
327
  }
322
328
  else if ((this.snapshotManager.getCodebaseStatus(codebasePath) === 'indexed'
@@ -329,7 +335,7 @@ export class SyncManager {
329
335
  ? this.context.hasSynchronizerForCodebase(codebasePath)
330
336
  : false;
331
337
  if (indexedPaths.length > 0 || hasSynchronizer) {
332
- return this.runIgnoreReconcile(codebasePath, 1, currentIgnoreControlSignature, options.mutationLease);
338
+ return this.runIgnoreReconcile(codebasePath, 1, currentIgnoreControlSignature, options.mutationLease, checkpointValidation.checkpoint?.generationReceipt);
333
339
  }
334
340
  }
335
341
  }
@@ -367,7 +373,7 @@ export class SyncManager {
367
373
  try {
368
374
  return await this.syncCodebase(codebasePath, options.mutationLease, currentIgnoreControlSignature, {
369
375
  exactSourceComparisonPaths: options.exactSourceComparisonPaths,
370
- });
376
+ }, checkpointValidation.checkpoint?.generationReceipt);
371
377
  }
372
378
  catch (e) {
373
379
  // Log and rethrow to allow callers to handle/see failure
@@ -409,7 +415,7 @@ export class SyncManager {
409
415
  errorMessage: outcome.errorMessage,
410
416
  };
411
417
  }
412
- async runIgnoreReconcile(codebasePath, coalescedEdits = 1, nextIgnoreControlSignature, existingLease) {
418
+ async runIgnoreReconcile(codebasePath, coalescedEdits = 1, nextIgnoreControlSignature, existingLease, preparedVectorReceipt) {
413
419
  const reconcileKey = this.normalizeReconcileKey(codebasePath);
414
420
  const inFlight = this.activeIgnoreReconciles.get(reconcileKey);
415
421
  const checkedAtMs = this.now();
@@ -447,7 +453,7 @@ export class SyncManager {
447
453
  try {
448
454
  lastDurableOperation = this.persistOwnedOperationStart(lease, releaseLease);
449
455
  console.log(`[SYNC] 🔁 Ignore control files changed for '${codebasePath}', running reconciliation.`);
450
- const promise = this.reconcileIgnoreRulesChange(codebasePath, coalescedEdits, nextIgnoreControlSignature, lease);
456
+ const promise = this.reconcileIgnoreRulesChange(codebasePath, coalescedEdits, nextIgnoreControlSignature, lease, preparedVectorReceipt);
451
457
  this.activeIgnoreReconciles.set(reconcileKey, promise);
452
458
  const decision = await promise;
453
459
  const phase = decision.mode === "ignore_reload_failed" ? "failed" : "completed";
@@ -478,7 +484,7 @@ export class SyncManager {
478
484
  }
479
485
  }
480
486
  }
481
- async reconcileIgnoreRulesChange(codebasePath, coalescedEdits = 1, nextIgnoreControlSignature, mutationLease) {
487
+ async reconcileIgnoreRulesChange(codebasePath, coalescedEdits = 1, nextIgnoreControlSignature, mutationLease, preparedVectorReceipt) {
482
488
  const checkedAtMs = this.now();
483
489
  const checkedAt = new Date(checkedAtMs).toISOString();
484
490
  const startedAt = checkedAtMs;
@@ -531,9 +537,13 @@ export class SyncManager {
531
537
  }
532
538
  this.assertMutationCurrent(mutationLease);
533
539
  this.snapshotManager.saveCodebaseSnapshot();
540
+ // Deleting newly ignored payload invalidates ordinary live proof.
541
+ // Carry the pre-delete receipt so Core can revalidate that exact
542
+ // source generation after the mutation lease is held.
534
543
  const syncDecision = await this.ensureFreshness(codebasePath, 0, {
535
544
  skipIgnoreControlCheck: true,
536
545
  mutationLease,
546
+ preparedVectorReceipt,
537
547
  });
538
548
  const lastSyncAt = syncDecision.lastSyncAt;
539
549
  const lastSyncMs = lastSyncAt ? Date.parse(lastSyncAt) : undefined;
@@ -597,7 +607,7 @@ export class SyncManager {
597
607
  };
598
608
  }
599
609
  }
600
- async syncCodebase(codebasePath, existingLease, currentIgnoreControlSignature, joinRequest = {}) {
610
+ async syncCodebase(codebasePath, existingLease, currentIgnoreControlSignature, joinRequest = {}, preparedVectorReceipt) {
601
611
  if (this.snapshotManager.getCodebaseStatus(codebasePath) === 'indexing') {
602
612
  console.log(`[SYNC] ⏭️ Skipping sync for '${codebasePath}' because indexing is active.`);
603
613
  return { mode: 'skipped_indexing' };
@@ -672,7 +682,7 @@ export class SyncManager {
672
682
  this.mutationLeaseCoordinator.publishWhileCurrent(lease, publish);
673
683
  }
674
684
  : undefined;
675
- const fencedCheckpoint = await this.inspectSourceFreshnessCheckpoint(codebasePath);
685
+ const fencedCheckpoint = await this.inspectSourceFreshnessCheckpoint(codebasePath, preparedVectorReceipt);
676
686
  if (fencedCheckpoint && fencedCheckpoint.status !== 'valid') {
677
687
  throw new Error(`Incremental sync cannot continue because its authoritative source checkpoint is ${fencedCheckpoint.status}: ${fencedCheckpoint.message}`);
678
688
  }
@@ -980,23 +990,76 @@ export class SyncManager {
980
990
  }
981
991
  }
982
992
  startBackgroundSync() {
983
- if (this.backgroundSyncTimer) {
993
+ if (this.backgroundSyncEnabled) {
984
994
  return;
985
995
  }
986
- const run = async () => {
987
- await this.handleSyncIndex();
988
- // recursive schedule to prevent overlap
989
- this.backgroundSyncTimer = setTimeout(run, BACKGROUND_SYNC_INTERVAL_MS);
990
- };
991
- // Initial delay
992
- this.backgroundSyncTimer = setTimeout(run, BACKGROUND_SYNC_INITIAL_DELAY_MS);
996
+ this.backgroundSyncEnabled = true;
997
+ this.scheduleBackgroundSync(BACKGROUND_SYNC_INITIAL_DELAY_MS);
998
+ }
999
+ scheduleBackgroundSync(delayMs) {
1000
+ if (!this.backgroundSyncEnabled)
1001
+ return;
1002
+ this.backgroundSyncTimer = setTimeout(() => {
1003
+ this.backgroundSyncTimer = null;
1004
+ void this.runBackgroundSync();
1005
+ }, delayMs);
1006
+ }
1007
+ runBackgroundSync() {
1008
+ const flight = (async () => {
1009
+ try {
1010
+ await this.handleSyncIndex();
1011
+ }
1012
+ catch (error) {
1013
+ console.error('[SYNC] Periodic synchronization pass failed:', error);
1014
+ }
1015
+ })();
1016
+ this.backgroundSyncFlight = flight;
1017
+ this.onLifecycleActivityChanged?.();
1018
+ void flight.finally(() => {
1019
+ if (this.backgroundSyncFlight === flight) {
1020
+ this.backgroundSyncFlight = null;
1021
+ this.onLifecycleActivityChanged?.();
1022
+ }
1023
+ this.scheduleBackgroundSync(BACKGROUND_SYNC_INTERVAL_MS);
1024
+ });
1025
+ return flight;
993
1026
  }
994
1027
  stopBackgroundSync() {
1028
+ this.backgroundSyncEnabled = false;
995
1029
  if (this.backgroundSyncTimer) {
996
1030
  clearTimeout(this.backgroundSyncTimer);
997
1031
  this.backgroundSyncTimer = null;
998
1032
  }
999
1033
  }
1034
+ /**
1035
+ * Stops new provider-owned synchronization work and joins every lifecycle
1036
+ * flight that may still hold mutation or backend authority.
1037
+ */
1038
+ async stopAndDrainLifecycle() {
1039
+ this.stopBackgroundSync();
1040
+ await this.stopWatcherMode();
1041
+ for (;;) {
1042
+ const pending = new Set();
1043
+ if (this.backgroundSyncFlight) {
1044
+ pending.add(this.backgroundSyncFlight);
1045
+ }
1046
+ for (const flight of this.activeWatcherSyncs) {
1047
+ pending.add(flight);
1048
+ }
1049
+ for (const flight of this.activeSyncs.values()) {
1050
+ pending.add(flight);
1051
+ }
1052
+ for (const flight of this.activeIgnoreReconciles.values()) {
1053
+ pending.add(flight);
1054
+ }
1055
+ if (pending.size === 0)
1056
+ return;
1057
+ await Promise.allSettled(pending);
1058
+ }
1059
+ }
1060
+ getActiveLifecycleOperationCount() {
1061
+ return (this.backgroundSyncFlight ? 1 : 0) + this.activeWatcherSyncs.size;
1062
+ }
1000
1063
  getWatchDebounceMs() {
1001
1064
  return this.watchDebounceMs;
1002
1065
  }
@@ -1144,29 +1207,38 @@ export class SyncManager {
1144
1207
  const current = this.pendingIgnoreChangeEdits.get(codebasePath) || 0;
1145
1208
  this.pendingIgnoreChangeEdits.set(codebasePath, current + 1);
1146
1209
  }
1147
- const timer = setTimeout(async () => {
1210
+ const timer = setTimeout(() => {
1148
1211
  this.debounceTimers.delete(codebasePath);
1149
- const coalescedIgnoreEdits = this.pendingIgnoreChangeEdits.get(codebasePath) || 0;
1150
- this.pendingIgnoreChangeEdits.delete(codebasePath);
1151
- try {
1152
- if (coalescedIgnoreEdits > 0) {
1153
- const decision = await this.ensureFreshness(codebasePath, 0, {
1154
- reason: 'ignore_change',
1155
- coalescedEdits: coalescedIgnoreEdits,
1156
- });
1157
- if (decision.mode === 'ignore_reload_failed') {
1158
- console.warn(`[SYNC-WATCH] Ignore-rule reconcile failed for '${codebasePath}': ${decision.errorMessage || 'unknown_error'} (fallbackSyncExecuted=${decision.fallbackSyncExecuted === true})`);
1159
- }
1160
- else {
1161
- console.log(`[SYNC-WATCH] Ignore-rule reconcile completed for '${codebasePath}' (version=${decision.ignoreRulesVersion ?? 'n/a'}, deleted=${decision.deletedFiles ?? 0}, added=${decision.addedFiles ?? 0}, coalesced=${decision.coalescedEdits ?? 1})`);
1212
+ const flight = (async () => {
1213
+ const coalescedIgnoreEdits = this.pendingIgnoreChangeEdits.get(codebasePath) || 0;
1214
+ this.pendingIgnoreChangeEdits.delete(codebasePath);
1215
+ try {
1216
+ if (coalescedIgnoreEdits > 0) {
1217
+ const decision = await this.ensureFreshness(codebasePath, 0, {
1218
+ reason: 'ignore_change',
1219
+ coalescedEdits: coalescedIgnoreEdits,
1220
+ });
1221
+ if (decision.mode === 'ignore_reload_failed') {
1222
+ console.warn(`[SYNC-WATCH] Ignore-rule reconcile failed for '${codebasePath}': ${decision.errorMessage || 'unknown_error'} (fallbackSyncExecuted=${decision.fallbackSyncExecuted === true})`);
1223
+ }
1224
+ else {
1225
+ console.log(`[SYNC-WATCH] Ignore-rule reconcile completed for '${codebasePath}' (version=${decision.ignoreRulesVersion ?? 'n/a'}, deleted=${decision.deletedFiles ?? 0}, added=${decision.addedFiles ?? 0}, coalesced=${decision.coalescedEdits ?? 1})`);
1226
+ }
1227
+ return;
1162
1228
  }
1163
- return;
1229
+ await this.ensureFreshness(codebasePath, 0);
1164
1230
  }
1165
- await this.ensureFreshness(codebasePath, 0);
1166
- }
1167
- catch (error) {
1168
- console.error(`[SYNC-WATCH] Debounced sync failed for '${codebasePath}':`, error);
1169
- }
1231
+ catch (error) {
1232
+ console.error(`[SYNC-WATCH] Debounced sync failed for '${codebasePath}':`, error);
1233
+ }
1234
+ })();
1235
+ this.activeWatcherSyncs.add(flight);
1236
+ this.onLifecycleActivityChanged?.();
1237
+ void flight.finally(() => {
1238
+ if (this.activeWatcherSyncs.delete(flight)) {
1239
+ this.onLifecycleActivityChanged?.();
1240
+ }
1241
+ });
1170
1242
  }, this.watchDebounceMs);
1171
1243
  this.debounceTimers.set(codebasePath, timer);
1172
1244
  }
@@ -1325,7 +1397,6 @@ export class SyncManager {
1325
1397
  this.debounceTimers.clear();
1326
1398
  this.watcherIgnoreMatchers.clear();
1327
1399
  this.pendingIgnoreChangeEdits.clear();
1328
- this.activeIgnoreReconciles.clear();
1329
1400
  this.lastSyncTimes.clear();
1330
1401
  this.ignoreRulesVersions.clear();
1331
1402
  this.freshnessEpochs.clear();