@amalgm/shell 0.1.74 → 0.1.76

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.
@@ -21,6 +21,7 @@ import { applyRepositoryFiles, captureRepository, inspectRepositoryTransportFile
21
21
  import { inspectGitRegistration, sameGitIdentity, } from "./git-registration-host.js";
22
22
  import { projectMaterializedGraph } from "./materialized-graph.js";
23
23
  import { NodeWatchHost, } from "./watching/index.js";
24
+ import { GroundCoordinator } from "./ground-coordination.js";
24
25
  import { WireClient, WireRequestError } from "./wire-client.js";
25
26
  const SMALL_CONTENT_UPLOAD_CONCURRENCY = 16;
26
27
  const FILE_CONTENT_DOWNLOAD_CONCURRENCY = 4;
@@ -28,6 +29,9 @@ const FILE_BATCH_DOWNLOAD_CONCURRENCY = 2;
28
29
  const DETECT_QUIET_MS = 12;
29
30
  const DETECT_MAX_DEFERRAL_MS = 75;
30
31
  const DETECT_RETRY_MS = 250;
32
+ /** A Detect walk yields the event loop this often so Send, Receive, and
33
+ * Apply progress during a large reconciliation instead of queueing behind it. */
34
+ const DETECT_WALK_YIELD_ENTRIES = 256;
31
35
  const DOWNLOAD_CONTENT_RETRY_ATTEMPTS = 3;
32
36
  const APPLY_GROUND_WAIT_TIMEOUT_MS = 15_000;
33
37
  const GROUND_ROW_COLUMNS = [
@@ -56,7 +60,10 @@ export class UserGroundHost {
56
60
  namedDetect = null;
57
61
  coldOperation = false;
58
62
  closing = false;
59
- groundEffectTail = Promise.resolve();
63
+ /** Visible-ground effects coordinate by address cone: Detect passes hold
64
+ * the roots they observe, Apply holds its target addresses, Register and
65
+ * Add hold the ground they materialize. Unrelated cones never wait. */
66
+ ground = new GroundCoordinator();
60
67
  port;
61
68
  constructor(options) {
62
69
  this.options = options;
@@ -245,7 +252,7 @@ export class UserGroundHost {
245
252
  status: "started",
246
253
  workspaceId: input.workspace.uuid,
247
254
  });
248
- const release = await this.acquireGroundEffect();
255
+ const release = await this.acquireGroundScope(workspaceAddCones(input.destinationParent, input.workspace, userRoot));
249
256
  this.stage({
250
257
  primitive: "download",
251
258
  stage: "ground-wait",
@@ -326,62 +333,85 @@ export class UserGroundHost {
326
333
  return { register, add };
327
334
  }
328
335
  /** Resume the same host effect used by the SDK command before readiness.
329
- * Staged ground has no binding, so recovery cannot race Watch or Detect. */
336
+ * Staged ground has no binding, so recovery cannot race Watch or Detect.
337
+ * Each intent is one workspace's durable responsibility, not the machine's:
338
+ * an intent that cannot be satisfied now is reported and stays durable for
339
+ * the next start or an explicit re-Add, while readiness proceeds. */
330
340
  async resumeWorkspaceAdds(identity) {
331
- const database = this.databasePath(identity);
332
- const userRoot = this.userRoot(identity);
333
- const bindingDir = workspaceBindingDir(userRoot, identity.deviceId);
334
- for (const intent of readWorkspaceAddIntents(database)) {
335
- const release = await this.acquireGroundEffect();
336
- const visibleAcrossBlindInterval = pathExists(intent.destinationPath);
341
+ for (const intent of readWorkspaceAddIntents(this.databasePath(identity))) {
337
342
  try {
338
- const selection = workspaceAddSelection(intent);
339
- const stagedAcrossBlindInterval = pathExists(intent.stagingPath);
340
- if (stagedAcrossBlindInterval && hasRootRows(database, intent.resourceId, intent.workspaceId)) {
341
- // Rows prove that staging was verified before the crash, not that its
342
- // bytes remained unchanged while no watcher existed. Return this
343
- // private install to the intent-only stage and reproduce it from the
344
- // verified immutable cache before making it visible.
345
- deleteRootRows(database, intent.resourceId, intent.workspaceId);
346
- }
347
- await installCloudWorkspace({
348
- identity,
349
- userRoot,
350
- database,
351
- cacheDir: this.cacheDir(identity),
352
- bindingDir,
353
- resourceId: intent.resourceId,
354
- workspace: selection.workspace,
355
- reference: selection.reference,
356
- references: intent.references,
357
- records: intent.records,
358
- destinationParent: dirname(intent.destinationPath),
359
- cloudHead: intent.cloudHead,
360
- readContent: (artifact) => this.downloadContent(intent.resourceId, this.cacheDir(identity), artifact, 10, { journey: "add", workspaceId: intent.workspaceId }),
361
- onStage: (stage) => this.options.onWorkspaceAddStage?.({
362
- workspaceId: intent.workspaceId,
363
- stage,
364
- }),
365
- });
366
- this.ensureWatchers(identity, visibleAcrossBlindInterval ? [] : [intent.workspaceId]);
367
- assertHealthyWatch(this.watchHost.evidence(), intent.workspaceId);
343
+ await this.resumeWorkspaceAdd(identity, intent);
368
344
  }
369
- finally {
370
- release();
345
+ catch (error) {
346
+ const message = error instanceof Error ? error.message : String(error);
347
+ this.stage({
348
+ primitive: "download",
349
+ stage: "add-resume",
350
+ status: "failed",
351
+ workspaceId: intent.workspaceId,
352
+ measurements: { error: message },
353
+ });
354
+ this.options.onApplyError?.(new Error(`workspace Add ${intent.workspaceId} could not resume and stays pending: ${message}`, { cause: error }));
371
355
  }
372
- if (visibleAcrossBlindInterval) {
373
- // Ground that was visible while the runtime was absent may have been
374
- // edited after its verified reveal. It is an ordinary startup blind
375
- // interval, so Watch must retain complete suspicion and Detect must
376
- // settle it before the Add responsibility can be cleared.
377
- await this.flushObservedChanges();
356
+ }
357
+ }
358
+ async resumeWorkspaceAdd(identity, intent) {
359
+ const database = this.databasePath(identity);
360
+ const userRoot = this.userRoot(identity);
361
+ const bindingDir = workspaceBindingDir(userRoot, identity.deviceId);
362
+ const release = await this.acquireGroundScope([
363
+ intent.destinationPath,
364
+ intent.stagingPath,
365
+ userRoot,
366
+ ]);
367
+ const visibleAcrossBlindInterval = pathExists(intent.destinationPath);
368
+ try {
369
+ const selection = workspaceAddSelection(intent);
370
+ const stagedAcrossBlindInterval = pathExists(intent.stagingPath);
371
+ if (stagedAcrossBlindInterval && hasRootRows(database, intent.resourceId, intent.workspaceId)) {
372
+ // Rows prove that staging was verified before the crash, not that its
373
+ // bytes remained unchanged while no watcher existed. Return this
374
+ // private install to the intent-only stage and reproduce it from the
375
+ // verified immutable cache before making it visible.
376
+ deleteRootRows(database, intent.resourceId, intent.workspaceId);
378
377
  }
379
- await this.options.onWorkspaceAddStage?.({
380
- workspaceId: intent.workspaceId,
381
- stage: "watching",
378
+ await installCloudWorkspace({
379
+ identity,
380
+ userRoot,
381
+ database,
382
+ cacheDir: this.cacheDir(identity),
383
+ bindingDir,
384
+ resourceId: intent.resourceId,
385
+ workspace: selection.workspace,
386
+ reference: selection.reference,
387
+ references: intent.references,
388
+ records: intent.records,
389
+ destinationParent: dirname(intent.destinationPath),
390
+ cloudHead: intent.cloudHead,
391
+ readContent: (artifact) => this.downloadContent(intent.resourceId, this.cacheDir(identity), artifact, 10, { journey: "add", workspaceId: intent.workspaceId }),
392
+ onStage: (stage) => this.options.onWorkspaceAddStage?.({
393
+ workspaceId: intent.workspaceId,
394
+ stage,
395
+ }),
382
396
  });
383
- deleteWorkspaceAddIntent(database, intent.workspaceId);
397
+ this.ensureWatchers(identity, visibleAcrossBlindInterval ? [] : [intent.workspaceId]);
398
+ assertHealthyWatch(this.watchHost.evidence(), intent.workspaceId);
384
399
  }
400
+ finally {
401
+ release();
402
+ }
403
+ if (visibleAcrossBlindInterval) {
404
+ // Ground that was visible while the runtime was absent may have been
405
+ // edited after its verified reveal. It is an ordinary startup blind
406
+ // interval, so Watch must retain complete suspicion and Detect must
407
+ // settle it before the Add responsibility can be cleared.
408
+ await this.flushObservedChanges();
409
+ }
410
+ await this.options.onWorkspaceAddStage?.({
411
+ workspaceId: intent.workspaceId,
412
+ stage: "watching",
413
+ });
414
+ deleteWorkspaceAddIntent(database, intent.workspaceId);
385
415
  }
386
416
  /** Finish the user-visible register journey inside the one persistent Files
387
417
  * owner. The declaration remains durable if any later stage fails, and a
@@ -445,9 +475,15 @@ export class UserGroundHost {
445
475
  this.stage({
446
476
  primitive: "register", stage: "ground-wait", status: "started", workspaceId,
447
477
  });
478
+ const contentOwner = watch.roots.find((root) => root.rootId === workspaceId)?.contentOwner;
479
+ if (!contentOwner) {
480
+ throw new Error(`registered workspace ${workspaceId} has no content coverage owner`);
481
+ }
448
482
  if (this.syncing)
449
483
  await this.syncing;
450
- releaseGround = await this.acquireGroundEffect();
484
+ // Registration reconciles the declared coverage family and core
485
+ // reference ground; unrelated roots keep detecting and applying.
486
+ releaseGround = await this.acquireGroundScope([contentOwner, this.userRoot(identity)]);
451
487
  this.stage({
452
488
  primitive: "register",
453
489
  stage: "ground-wait",
@@ -455,10 +491,6 @@ export class UserGroundHost {
455
491
  workspaceId,
456
492
  durationMs: performance.now() - groundWaitStarted,
457
493
  });
458
- const contentOwner = watch.roots.find((root) => root.rootId === workspaceId)?.contentOwner;
459
- if (!contentOwner) {
460
- throw new Error(`registered workspace ${workspaceId} has no content coverage owner`);
461
- }
462
494
  const observations = this.watchHost.observations().filter((observation) => observation.directory === this.userRoot(identity)
463
495
  || pathWithin(contentOwner, observation.directory));
464
496
  this.watchDirty = false;
@@ -679,17 +711,34 @@ export class UserGroundHost {
679
711
  bindingDir: workspaceBindingDir(this.userRoot(identity), identity.deviceId),
680
712
  readContent: (artifact) => this.downloadContent(this.cloudState.resourceId, this.cacheDir(identity), artifact, DOWNLOAD_CONTENT_RETRY_ATTEMPTS, { journey: "apply" }),
681
713
  captureLocal: async (intent, plan, position) => {
682
- // Apply never suppresses Watch. This explicit whole-root suspicion is
683
- // the synchronous proof boundary used only when Apply observed a
684
- // save racing its reveal and must make that save durable first.
685
- this.watchHost.suspectAll();
714
+ // Apply never suppresses Watch. This explicit named suspicion is the
715
+ // synchronous proof boundary used only when Apply observed a save
716
+ // racing its reveal and must make that save durable first. It rings
717
+ // exactly the addresses Apply compared, so the proof costs one named
718
+ // Detect lane, not a walk of every root.
719
+ let addresses = [];
720
+ try {
721
+ addresses = host.applyAddresses(intent, plan);
722
+ }
723
+ catch {
724
+ // Without an address the whole plan is suspect below.
725
+ }
726
+ if (addresses.length === 0)
727
+ this.watchHost.suspectAll();
728
+ for (const address of addresses)
729
+ this.watchHost.suspect(address);
686
730
  this.watchDirty = true;
687
- await this.syncNow();
731
+ // A pass already queued may have frozen its observations before this
732
+ // ring; keep proving until the generation that holds it has settled.
733
+ const cutoff = this.watchHost.observations();
734
+ do {
735
+ await this.syncNow();
736
+ } while (!this.closing && !this.watchHost.settled(cutoff));
688
737
  if (!host.store.hasLocalWorkAfter(intent.authorityId, intent.entityId, intent.throughSequence)) {
689
738
  throw new Error(`Apply ${position} capture for ${plan.target.type} ${plan.target.name} (${intent.entityId}) produced no durable local record`);
690
739
  }
691
740
  },
692
- acquireGround: () => this.acquireGroundEffect(APPLY_GROUND_WAIT_TIMEOUT_MS),
741
+ acquireGround: (cones) => this.acquireGroundScope(cones, APPLY_GROUND_WAIT_TIMEOUT_MS),
693
742
  onStage: (evidence) => {
694
743
  this.options.onApplyStage?.(evidence);
695
744
  this.stage({
@@ -716,10 +765,23 @@ export class UserGroundHost {
716
765
  },
717
766
  sha256Hex,
718
767
  now: Date.now,
719
- onBackgroundError: (error) => this.options.onApplyError?.(error),
720
- // Path cones and repository territories are wider than one UUID. Until
721
- // the host exposes those locks, one serial machine-effect owner is the
722
- // only honest concurrency setting.
768
+ // A failed Apply lane is one entity's outcome; the rail keeps every
769
+ // other lane moving and retries this one. Surface it as evidence so a
770
+ // persistently failing entity is visible rather than silent.
771
+ onBackgroundError: (error) => {
772
+ this.options.onApplyError?.(error);
773
+ this.stage({
774
+ primitive: "apply",
775
+ stage: "lane",
776
+ status: "failed",
777
+ measurements: { error: error instanceof Error ? error.message : String(error) },
778
+ });
779
+ },
780
+ // The host now coordinates by address cone, so Apply's final guard waits
781
+ // only for effects on its own addresses. The rail itself still drains
782
+ // one entity at a time: parallel private preparation multiplies content
783
+ // fetches per connection and has not been certified against the
784
+ // gateway's content lane. Raise deliberately, with that proof.
723
785
  concurrency: 1,
724
786
  });
725
787
  this.applyHost = host;
@@ -1311,54 +1373,56 @@ export class UserGroundHost {
1311
1373
  this.scheduleRescan();
1312
1374
  }
1313
1375
  }
1314
- async acquireGroundEffect(timeoutMs) {
1315
- let release;
1316
- const held = new Promise((resolve) => { release = resolve; });
1317
- const preceding = this.groundEffectTail;
1318
- this.groundEffectTail = preceding.then(() => held);
1319
- let released = false;
1320
- const releaseOnce = () => {
1321
- if (released)
1322
- return;
1323
- released = true;
1324
- release();
1325
- };
1326
- if (timeoutMs === undefined) {
1327
- await preceding;
1328
- return releaseOnce;
1329
- }
1330
- if (!Number.isSafeInteger(timeoutMs) || timeoutMs < 1) {
1331
- // This queued slot has not become active. Let it pass through as soon as
1332
- // the preceding owner finishes so invalid input cannot poison the tail.
1333
- void preceding.then(releaseOnce, releaseOnce);
1334
- throw new Error("ground-effect wait timeout must be a positive integer");
1335
- }
1336
- let timeout = null;
1337
- try {
1338
- await Promise.race([
1339
- preceding,
1340
- new Promise((_resolve, reject) => {
1341
- timeout = setTimeout(() => reject(new Error(`ground-effect wait exceeded ${timeoutMs}ms`)), timeoutMs);
1342
- }),
1343
- ]);
1344
- return releaseOnce;
1345
- }
1346
- catch (error) {
1347
- // The timed-out waiter still owns one FIFO slot. Automatically release
1348
- // that slot when it reaches the front; otherwise one timeout would block
1349
- // every later filesystem effect forever.
1350
- void preceding.then(releaseOnce, releaseOnce);
1351
- throw error;
1352
- }
1353
- finally {
1354
- if (timeout)
1355
- clearTimeout(timeout);
1356
- }
1376
+ acquireGroundScope(cones, timeoutMs) {
1377
+ return this.ground.acquire(cones, timeoutMs === undefined ? {} : { timeoutMs });
1357
1378
  }
1358
1379
  async syncLocalChanges(identity) {
1380
+ if (!this.cloudState)
1381
+ throw new Error("cloud state is unavailable for user-ground Watch");
1382
+ const userRoot = this.userRoot(identity);
1383
+ const repair = registrationRepairGround(this.databasePath(identity), userRoot, identity.deviceId);
1384
+ if (repair.core || repair.emptyRoots.length > 0) {
1385
+ // A durable binding is registration evidence even if the native event
1386
+ // that created it was lost across a crash. Rebuild coverage first, then
1387
+ // express that uncertainty through Watch like every other Detect input.
1388
+ // These are ambiguous recovery facts, so each affected root is wholly
1389
+ // suspect; unrelated roots remain untouched.
1390
+ this.ensureWatchers(identity);
1391
+ if (repair.core)
1392
+ this.watchHost.suspect(userRoot);
1393
+ for (const root of repair.emptyRoots)
1394
+ this.watchHost.suspect(root);
1395
+ }
1396
+ // Detect is per root. A root without pending suspicion has nothing to
1397
+ // prove and never enters a pass: it costs no walk, no notebook plan, no
1398
+ // repository inspection, and no ground coordination. Only rung roots are
1399
+ // observed, and each rung root is proved on its own lane before any root
1400
+ // widens to reconciliation. Rings after this snapshot own the next pass.
1401
+ const observations = this.watchHost.observations();
1402
+ const pending = observations.filter(({ suspicion }) => suspicion.paths === null || suspicion.paths.length > 0);
1403
+ // An idle root's generation is already proved; settling it keeps every
1404
+ // root's settled generation current without any filesystem work.
1405
+ this.watchHost.settle(observations.filter((observation) => !pending.includes(observation)));
1406
+ if (pending.length === 0) {
1407
+ this.stage({
1408
+ primitive: "detect",
1409
+ stage: "pass",
1410
+ status: "completed",
1411
+ durationMs: 0,
1412
+ measurements: { lane: "idle", roots: 0, records: 0 },
1413
+ });
1414
+ return;
1415
+ }
1416
+ const cones = new Set(pending.map((observation) => observation.directory));
1417
+ const coreScan = cones.has(userRoot);
1359
1418
  const waitStarted = performance.now();
1360
- this.stage({ primitive: "detect", stage: "ground-wait", status: "started" });
1361
- const release = await this.acquireGroundEffect();
1419
+ this.stage({
1420
+ primitive: "detect",
1421
+ stage: "ground-wait",
1422
+ status: "started",
1423
+ measurements: { roots: pending.length },
1424
+ });
1425
+ const release = await this.acquireGroundScope([...cones]);
1362
1426
  this.stage({
1363
1427
  primitive: "detect",
1364
1428
  stage: "ground-wait",
@@ -1366,39 +1430,50 @@ export class UserGroundHost {
1366
1430
  durationMs: performance.now() - waitStarted,
1367
1431
  });
1368
1432
  try {
1369
- await this.syncLocalChangesUnlocked(identity);
1433
+ await this.syncLocalChangesUnlocked(identity, pending, coreScan);
1370
1434
  }
1371
1435
  finally {
1372
1436
  release();
1373
1437
  }
1374
1438
  }
1375
- async syncLocalChangesUnlocked(identity) {
1439
+ async syncLocalChangesUnlocked(identity, pending, coreScan) {
1376
1440
  if (!this.cloudState)
1377
1441
  throw new Error("cloud state is unavailable for user-ground Watch");
1378
- const observations = this.watchHost.observations();
1379
1442
  const passStarted = performance.now();
1380
1443
  this.stage({
1381
1444
  primitive: "detect",
1382
1445
  stage: "pass",
1383
1446
  status: "started",
1384
1447
  measurements: {
1385
- roots: observations.length,
1386
- completeRoots: observations.filter(({ suspicion }) => suspicion.paths === null).length,
1387
- namedPaths: observations.reduce((count, { suspicion }) => count + (suspicion.paths?.length ?? 0), 0),
1448
+ roots: pending.length,
1449
+ completeRoots: pending.filter(({ suspicion }) => suspicion.paths === null).length,
1450
+ namedPaths: pending.reduce((count, { suspicion }) => count + (suspicion.paths?.length ?? 0), 0),
1388
1451
  },
1389
1452
  });
1390
- let widenedRoots = new Set();
1391
- if (this.namedDetect) {
1453
+ const reconcileSuspicions = new Map();
1454
+ const reconcileObservations = [];
1455
+ let namedRoots = 0;
1456
+ let namedRecords = 0;
1457
+ let unsettled = false;
1458
+ for (const observation of pending) {
1459
+ if (observation.suspicion.paths === null || !this.namedDetect) {
1460
+ reconcileSuspicions.set(observation.directory, observation.suspicion);
1461
+ reconcileObservations.push(observation);
1462
+ continue;
1463
+ }
1392
1464
  // The named lane is an optimization over the same settled evidence.
1393
- // Any proof failure occurs before its SQLite transaction and widens to
1394
- // reconciliation; it must never poison a Watch generation in a timer
1395
- // rejection that only another filesystem event can revive.
1396
- const named = await this.namedDetect.detect(observations).catch(() => null);
1465
+ // Any proof failure occurs before its SQLite transaction and widens
1466
+ // only this root to reconciliation; it must never poison a Watch
1467
+ // generation in a timer rejection that only another event can revive.
1468
+ const named = await this.namedDetect.detect([observation]).catch(() => null);
1469
+ if (named?.unsettled) {
1470
+ unsettled = true;
1471
+ continue;
1472
+ }
1397
1473
  if (named?.handled) {
1398
1474
  for (const evidence of named.evidence) {
1399
- const observation = observations.find((candidate) => candidate.directory === evidence.directory);
1400
1475
  this.reportDetectScan({
1401
- rootId: observation?.rootId ?? evidence.directory,
1476
+ rootId: observation.rootId,
1402
1477
  directory: evidence.directory,
1403
1478
  scope: "paths",
1404
1479
  entries: evidence.metadataReads,
@@ -1411,24 +1486,34 @@ export class UserGroundHost {
1411
1486
  records: evidence.records,
1412
1487
  recordBytes: evidence.recordBytes,
1413
1488
  });
1489
+ namedRecords += evidence.records;
1414
1490
  }
1415
- this.watchHost.settle(observations);
1416
- this.applyRail?.localStateChanged();
1417
- this.recordRail?.localRecordsAvailable();
1418
- this.stage({
1419
- primitive: "detect",
1420
- stage: "pass",
1421
- status: "completed",
1422
- durationMs: performance.now() - passStarted,
1423
- measurements: {
1424
- lane: "named",
1425
- roots: named.evidence.length,
1426
- records: named.evidence.reduce((count, evidence) => count + evidence.records, 0),
1427
- },
1428
- });
1429
- return;
1491
+ namedRoots += 1;
1492
+ // This root's truth is durable; its generation settles now so a later
1493
+ // failure in another root's reconciliation cannot make it re-prove.
1494
+ this.watchHost.settle([observation]);
1495
+ continue;
1430
1496
  }
1431
- widenedRoots = new Set(named?.widenedRoots ?? []);
1497
+ const widened = named?.widenedRoots?.includes(observation.directory) === true;
1498
+ reconcileSuspicions.set(observation.directory, widened ? { ...observation.suspicion, paths: null } : observation.suspicion);
1499
+ reconcileObservations.push(observation);
1500
+ }
1501
+ if (namedRoots > 0) {
1502
+ this.applyRail?.localStateChanged();
1503
+ this.recordRail?.localRecordsAvailable();
1504
+ }
1505
+ if (reconcileObservations.length === 0) {
1506
+ this.stage({
1507
+ primitive: "detect",
1508
+ stage: "pass",
1509
+ status: "completed",
1510
+ durationMs: performance.now() - passStarted,
1511
+ measurements: { lane: "named", roots: namedRoots, records: namedRecords },
1512
+ });
1513
+ if (unsettled) {
1514
+ throw pipelineStageError("detection", new Error("repository control operation is unsettled"));
1515
+ }
1516
+ return;
1432
1517
  }
1433
1518
  let detection;
1434
1519
  let acceptedMaterializedRows;
@@ -1442,9 +1527,8 @@ export class UserGroundHost {
1442
1527
  database: this.databasePath(identity),
1443
1528
  cacheDir: this.cacheDir(identity),
1444
1529
  onScan: (evidence) => this.reportDetectScan(evidence),
1445
- suspicions: new Map(observations.map((observation) => [observation.directory, widenedRoots.has(observation.directory)
1446
- ? { ...observation.suspicion, paths: null }
1447
- : observation.suspicion])),
1530
+ suspicions: reconcileSuspicions,
1531
+ coreScan,
1448
1532
  });
1449
1533
  detection = scanned.detection;
1450
1534
  acceptedMaterializedRows = scanned.acceptedMaterializedRows;
@@ -1501,7 +1585,7 @@ export class UserGroundHost {
1501
1585
  }
1502
1586
  }
1503
1587
  this.namedDetect?.refreshEnrollmentPolicy();
1504
- this.watchHost.settle(observations);
1588
+ this.watchHost.settle(reconcileObservations);
1505
1589
  this.applyRail?.localStateChanged();
1506
1590
  this.recordRail?.localRecordsAvailable();
1507
1591
  this.stage({
@@ -1509,8 +1593,16 @@ export class UserGroundHost {
1509
1593
  stage: "pass",
1510
1594
  status: "completed",
1511
1595
  durationMs: performance.now() - passStarted,
1512
- measurements: { lane: "reconciliation", records: detectedRecords.length },
1596
+ measurements: {
1597
+ lane: "reconciliation",
1598
+ roots: reconcileObservations.length,
1599
+ namedRoots,
1600
+ records: detectedRecords.length + namedRecords,
1601
+ },
1513
1602
  });
1603
+ if (unsettled) {
1604
+ throw pipelineStageError("detection", new Error("repository control operation is unsettled"));
1605
+ }
1514
1606
  }
1515
1607
  async publishPendingSnapshotsBeforeLookup(identity, resourceId) {
1516
1608
  const pending = readSnapshotPublications(this.databasePath(identity));
@@ -2015,6 +2107,41 @@ function countDescendantRows(file, rootUUID) {
2015
2107
  database.close();
2016
2108
  }
2017
2109
  }
2110
+ /** Recovery facts created before Watch could prove registration: a binding
2111
+ * whose portable reference is absent from the notebook, or a bound root that
2112
+ * has never produced a local entity row. They pull only those exact roots
2113
+ * into the next Detect pass. */
2114
+ function registrationRepairGround(file, userRoot, deviceId) {
2115
+ const bindings = materializedWorkspaceBindings(workspaceBindingDir(userRoot, deviceId));
2116
+ if (bindings.length === 0)
2117
+ return { core: false, emptyRoots: [] };
2118
+ if (!existsSync(file)) {
2119
+ return { core: true, emptyRoots: bindings.map(({ directory }) => directory) };
2120
+ }
2121
+ const core = readGroundRootAtPath(file, "detection_notebook", userRoot);
2122
+ if (!core) {
2123
+ return { core: true, emptyRoots: bindings.map(({ directory }) => directory) };
2124
+ }
2125
+ const database = initializeDatabase(file);
2126
+ try {
2127
+ const referenced = new Set(database.prepare(`
2128
+ SELECT payload_version AS payloadVersion FROM detection_notebook
2129
+ WHERE root_uuid = ? AND type = 'reference' AND status = 'active'
2130
+ AND payload_version IS NOT NULL
2131
+ `).all(core.uuid).map((row) => row.payloadVersion));
2132
+ const hasRows = database.prepare(`
2133
+ SELECT 1 AS present FROM entities WHERE root_uuid = ? LIMIT 1
2134
+ `);
2135
+ return {
2136
+ core: bindings.some(({ workspaceId }) => !referenced.has(workspaceId)),
2137
+ emptyRoots: bindings.filter(({ workspaceId }) => !hasRows.get(workspaceId))
2138
+ .map(({ directory }) => directory),
2139
+ };
2140
+ }
2141
+ finally {
2142
+ database.close();
2143
+ }
2144
+ }
2018
2145
  function readGroundUUIDs(file, table, rootUUIDs) {
2019
2146
  if (!existsSync(file))
2020
2147
  return new Set();
@@ -2036,6 +2163,20 @@ function readGroundUUIDs(file, table, rootUUIDs) {
2036
2163
  database.close();
2037
2164
  }
2038
2165
  }
2166
+ /** The ground an Add materialization touches: its destination, its sibling
2167
+ * staging directory, and core ground where the binding and reference live.
2168
+ * An unresolvable parent still names itself; the install reports the error. */
2169
+ function workspaceAddCones(destinationParent, workspace, userRoot) {
2170
+ let parent = resolve(destinationParent);
2171
+ try {
2172
+ parent = realpathSync(parent);
2173
+ }
2174
+ catch {
2175
+ // Reported by installCloudWorkspace with its exact error.
2176
+ }
2177
+ const destination = join(parent, workspace.name);
2178
+ return [destination, workspaceAddStagingPath(destination, workspace.uuid), userRoot];
2179
+ }
2039
2180
  function workspaceAddStagingPath(destinationPath, workspaceId) {
2040
2181
  return join(dirname(destinationPath), `.amalgm-${workspaceId}.adding`);
2041
2182
  }
@@ -2569,6 +2710,11 @@ function createDeclaredHome(input) {
2569
2710
  }
2570
2711
  }
2571
2712
  }
2713
+ /** One macrotask turn: pending I/O, timers, and wire frames run before the
2714
+ * caller resumes. */
2715
+ function yieldEventLoop() {
2716
+ return new Promise((resolve) => setImmediate(resolve));
2717
+ }
2572
2718
  function slashPath(path) {
2573
2719
  return path.split(sep).join("/");
2574
2720
  }
@@ -2629,7 +2775,7 @@ function materializedWorkspaceBindings(bindingDir) {
2629
2775
  return bindings.sort((left, right) => left.workspaceId.localeCompare(right.workspaceId));
2630
2776
  }
2631
2777
  async function reconcileGround(input) {
2632
- const { identity, userRoot, database, cacheDir, suspicions, registrationWorkspaceId, onScan, } = input;
2778
+ const { identity, userRoot, database, cacheDir, suspicions, coreScan, registrationWorkspaceId, onScan, } = input;
2633
2779
  const resourceId = privateEntityResourceId(identity.userId, sha256Hex);
2634
2780
  const bindingDir = workspaceBindingDir(userRoot, identity.deviceId);
2635
2781
  const boundRootEntries = materializedWorkspaceBindings(bindingDir);
@@ -2759,30 +2905,40 @@ async function reconcileGround(input) {
2759
2905
  });
2760
2906
  // Each root commits its local projection before Detect yields. Native
2761
2907
  // callbacks can therefore preserve new suspicion between large roots.
2762
- await new Promise((resolve) => setImmediate(resolve));
2908
+ await yieldEventLoop();
2763
2909
  return result;
2764
2910
  };
2765
- await scan({
2766
- identity,
2767
- resourceId,
2768
- rootUUID: coreUUID,
2769
- rootName: identity.userEmail,
2770
- rootPath: userRoot,
2771
- rootType: "workspace",
2772
- database,
2773
- cacheDir,
2774
- policy,
2775
- bindingDir,
2776
- suspects: missingPortableReference && coreSuspicion !== null
2777
- ? [...new Set([...coreSuspicion, "workspaces"])]
2778
- : coreSuspicion,
2779
- existingRows: existing.filter((row) => row.rootUUID === coreUUID),
2780
- suspicion: observationFor(userRoot),
2781
- });
2911
+ // A Watch pass names exactly the roots it observed. A root outside that
2912
+ // observation has nothing to prove and is not scanned, planned, or
2913
+ // inspected; its rows stay as they are. Only two facts can still pull an
2914
+ // unobserved root in: core ground missing a portable reference for a
2915
+ // binding, and a bound root that has never produced a row.
2916
+ const observed = (root) => !suspicions || suspicions.has(root);
2917
+ if (observed(userRoot) || (missingPortableReference && coreScan !== false)) {
2918
+ await scan({
2919
+ identity,
2920
+ resourceId,
2921
+ rootUUID: coreUUID,
2922
+ rootName: identity.userEmail,
2923
+ rootPath: userRoot,
2924
+ rootType: "workspace",
2925
+ database,
2926
+ cacheDir,
2927
+ policy,
2928
+ bindingDir,
2929
+ suspects: missingPortableReference && coreSuspicion !== null
2930
+ ? [...new Set([...coreSuspicion, "workspaces"])]
2931
+ : coreSuspicion,
2932
+ existingRows: existing.filter((row) => row.rootUUID === coreUUID),
2933
+ suspicion: observationFor(userRoot),
2934
+ });
2935
+ }
2782
2936
  const physicalRoots = registrationOwner ? [registrationOwner] : outerBoundRoots;
2783
2937
  for (const { workspaceId, directory: rootPath } of physicalRoots) {
2784
2938
  const existingRoot = existingByUuid.get(workspaceId);
2785
2939
  const existingWorkspaceRows = existingByOutermostRoot.get(workspaceId) || [];
2940
+ if (!observed(rootPath) && existingWorkspaceRows.length > 0)
2941
+ continue;
2786
2942
  const registeredPath = registrationBoundary
2787
2943
  && registrationOwner?.workspaceId === workspaceId
2788
2944
  && registrationBoundary.workspaceId !== workspaceId
@@ -2823,14 +2979,21 @@ async function reconcileGround(input) {
2823
2979
  }
2824
2980
  async function reconcileRoot(input) {
2825
2981
  const { identity, resourceId, rootUUID, rootName, rootPath, rootType, database, cacheDir, policy, bindingDir, suspects = null, evidence, } = input;
2826
- const existingRows = input.existingRows
2827
- ?? readRows(database).filter((row) => row.resourceId === resourceId && row.rootUUID === rootUUID);
2828
- const existingByPath = new Map(existingRows.flatMap((row) => {
2982
+ // Prior rows reach this scan from every binding this root encloses. A row's
2983
+ // stored relativePath is relative to the root that last committed it, which
2984
+ // is a different frame when an enclosing boundary was just registered
2985
+ // (child/README.md is stored as "README.md" under the child root). Address
2986
+ // every prior row in this scan's frame before anything compares it with an
2987
+ // observed entry; absolutePath is the only coordinate both frames share.
2988
+ const existingRows = (input.existingRows
2989
+ ?? readRows(database).filter((row) => row.resourceId === resourceId && row.rootUUID === rootUUID))
2990
+ .flatMap((row) => {
2829
2991
  if (!pathWithin(rootPath, row.absolutePath))
2830
2992
  return [];
2831
- const local = slashPath(relative(rootPath, row.absolutePath));
2832
- return [[local, row]];
2833
- }));
2993
+ const relativePath = slashPath(relative(rootPath, row.absolutePath));
2994
+ return [relativePath === row.relativePath ? row : { ...row, relativePath }];
2995
+ });
2996
+ const existingByPath = new Map(existingRows.map((row) => [row.relativePath, row]));
2834
2997
  const registeredBoundaries = input.registeredBoundaries ?? new Map();
2835
2998
  if (suspects !== null
2836
2999
  && suspects.length === 0
@@ -2970,6 +3133,7 @@ async function reconcileRoot(input) {
2970
3133
  }
2971
3134
  return [...new Set(paths)].sort();
2972
3135
  };
3136
+ let visitedEntries = 0;
2973
3137
  const visit = async (directory, parentUUID, parentType, base = "", activeRepository = parentType === "repo.git"
2974
3138
  ? {
2975
3139
  root: directory,
@@ -2996,6 +3160,11 @@ async function reconcileRoot(input) {
2996
3160
  const absolutePath = join(directory, child.name);
2997
3161
  const stats = lstatSync(absolutePath);
2998
3162
  evidence && (evidence.entries += 1);
3163
+ // The walk is synchronous metadata work. Yield periodically so the
3164
+ // wire, Receive, and Apply keep running while a large root is proved.
3165
+ // A Watch ring during the yield remains a later generation.
3166
+ if (++visitedEntries % DETECT_WALK_YIELD_ENTRIES === 0)
3167
+ await yieldEventLoop();
2999
3168
  const existing = existingByPath.get(relativePath);
3000
3169
  const registeredBoundaryId = stats.isDirectory()
3001
3170
  ? registeredBoundaries.get(resolve(absolutePath))
@@ -3994,6 +4163,19 @@ async function installCloudWorkspace(input) {
3994
4163
  alreadyMaterialized: pending === null,
3995
4164
  };
3996
4165
  }
4166
+ // One machine materializes each UUID in exactly one place. The selected
4167
+ // closure may enclose a boundary this machine already added elsewhere; a
4168
+ // fresh install would re-root those rows under the new destination while
4169
+ // the old binding stayed bound and watched over ground with no rows, and
4170
+ // the next Detect pass there would mint a second identity for every file.
4171
+ const selectedUUIDs = new Set(records.map((record) => record.uuid));
4172
+ const enclosedElsewhere = allLocalRows.filter((row) => row.uuid !== workspace.uuid && selectedUUIDs.has(row.uuid));
4173
+ if (enclosedElsewhere.length > 0) {
4174
+ const roots = [...new Set(enclosedElsewhere.map((row) => row.rootUUID))]
4175
+ .map((rootUUID) => allLocalRows.find((row) => row.uuid === rootUUID)?.absolutePath ?? rootUUID);
4176
+ throw new Error(`files add: workspace ${workspace.uuid} encloses ${enclosedElsewhere.length} entities already `
4177
+ + `materialized on this machine under ${roots.join(", ")}; remove that materialization first`);
4178
+ }
3997
4179
  const parent = realpathSync(resolve(destinationParent));
3998
4180
  if (!statSync(parent).isDirectory())
3999
4181
  throw new Error("destination must be a directory");