@staix/agent-mem 0.21.0 → 0.22.1

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 (38) hide show
  1. package/.claude-plugin/plugin.json +1 -1
  2. package/README.md +27 -1
  3. package/package.json +5 -2
  4. package/src/cli-agent-reads.ts +3 -1
  5. package/src/cli.ts +36 -4
  6. package/src/commands-table.ts +3 -0
  7. package/src/index.ts +6 -0
  8. package/src/memory/commands.ts +3 -2
  9. package/src/memory/generation.ts +6 -3
  10. package/src/memory/profile-lock-diagnostic.ts +69 -0
  11. package/src/memory/public-prune-activation.ts +4 -4
  12. package/src/memory/reader-lease.ts +3 -2
  13. package/src/memory/shard-sync.ts +1 -2
  14. package/src/memory/shard.ts +4 -0
  15. package/src/memory/sync-cadence.ts +2 -1
  16. package/src/memory/sync-placement-abandon.ts +142 -0
  17. package/src/memory/sync-placement-activation-locks.ts +28 -0
  18. package/src/memory/sync-placement-activation-path.ts +2 -0
  19. package/src/memory/sync-placement-activation-proof.ts +68 -0
  20. package/src/memory/sync-placement-activation-storage.ts +167 -0
  21. package/src/memory/sync-placement-activation.ts +185 -0
  22. package/src/memory/sync-placement-capture.ts +104 -23
  23. package/src/memory/sync-placement-effective-writers.ts +84 -0
  24. package/src/memory/sync-placement-guard.ts +18 -8
  25. package/src/memory/sync-placement-inspection.ts +5 -1
  26. package/src/memory/sync-placement-journal-storage.ts +51 -7
  27. package/src/memory/sync-placement-journal.ts +11 -4
  28. package/src/memory/sync-placement-prerequisites.ts +50 -0
  29. package/src/memory/sync-placement-transport.ts +17 -6
  30. package/src/memory/sync-placement-writer-history.ts +278 -0
  31. package/src/memory/sync-placement-writer-renewal.ts +179 -0
  32. package/src/memory/sync-placement-writer-revision-format.ts +122 -0
  33. package/src/memory/sync-placement-writer-roots.ts +167 -0
  34. package/src/memory/sync-placement-writers.ts +135 -0
  35. package/src/memory/sync.ts +1 -2
  36. package/src/setup-toml.ts +3 -2
  37. package/src/setup.ts +101 -14
  38. package/src/sync-placement-writer-context.ts +136 -0
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "agent-mem",
3
- "version": "0.21.0",
3
+ "version": "0.22.1",
4
4
  "description": "Bounded project memory tools and seven deterministic capture hooks, without a resident worker.",
5
5
  "mcpServers": {
6
6
  "agent-mem": {
package/README.md CHANGED
@@ -2,7 +2,7 @@
2
2
 
3
3
  Curated memory with append-only JSONL logs, a derived SQLite index, short- and long-term tiers, a typed graph, goals with leases, and git sync between machines. Extracted from [agent-hub](https://github.com/STAIxBWLB/agent-hub), with compatible storage and entry IDs.
4
4
 
5
- Version: 0.21.0. Requires Bun 1.4.2 or later; no runtime dependencies.
5
+ Version: 0.22.1. Requires Bun 1.4.2 or later; no runtime dependencies.
6
6
 
7
7
  ## Quick start
8
8
 
@@ -555,3 +555,29 @@ Private cursors under `hook-transcripts/` store only a session hash, file identi
555
555
  The follow-up bound is a benchmark parameter, not a production retry loop. Backlogs advance on later Stop/SessionEnd calls of the same session. Anything unread after its last hook remains uncaptured, so a cold install on a long session keeps only what its remaining hooks can scan; committed rows are not lost.
556
556
 
557
557
  Search gives these events the existing 1.25 conversation weight and session grouping. The brief selects up to two newest screened main-session Recap/Compact rows first, then fills from the existing chronological rows, under the same record, byte and token caps. With no summaries the output is unchanged. See [verification evidence](docs/bench/transcript-summaries-201.md).
558
+
559
+
560
+ ## Initial sync placement activation
561
+
562
+ Available from 0.22.0. 0.21.0 contains the placement protocol but not this command.
563
+
564
+ A person can activate per-row outbound placement on one whole memory home. It is forward-only, and never runs migrations, changes privacy policy or raises a reader floor automatically. Real-home activation still needs the person's separate authorization.
565
+
566
+ 1. Preview and complete the existing shard and public-prune migrations with `amem migrate --shards --dry-run` and `amem migrate --public-prune-view --dry-run` (then their separately reviewed `--apply` steps).
567
+ 2. On a 0.22 installation, have the person append the existing `home` floor at 0.22.0 in `formats.json`, preserving prior records, and close cooperating memory reader handles. This is recommended at activation and required before the first writer renewal, so old 0.21 readers refuse before opening a renewable home.
568
+ 3. Declare every local amem installation that writes this home, including an agent-hub library installation even if setup has no receipt for it. The CLI and current setup-receipt roots are mandatory members.
569
+
570
+ ```sh
571
+ amem migrate --sync-placement --dry-run --writer-root '<cli-install-root>' --writer-root '<host-amem-install-root>' --json
572
+ amem migrate --sync-placement --apply --writer-root '<cli-install-root>' --writer-root '<host-amem-install-root>' --json
573
+ # Only for an interrupted initial activation, using its retained frozen inputs:
574
+ amem migrate --sync-placement --resume --json
575
+ # If inputs changed and BOTH marker files still have their original v2 bytes:
576
+ amem migrate --sync-placement --abandon --json
577
+ ```
578
+
579
+ Dry-run shows root versions/pins and safe policy counts, never row text or patterns, and writes nothing. Initial writer errors name the root category and cause, with upgrade/setup instructions. If a pre-marker interruption retained an intent and its inputs changed, person `--abandon` removes only its owned initial metadata so a fresh preview/apply can follow. It cannot remove a v3 marker, active fence or journal history. After apply, choose the private remote on the first v3 sync (`amem sync --remote <private-git-url>`); ordinary and cadence sync then read the immutable root locator and measure the installs afresh. Allowed originals can publish while declined originals remain in the local append-only history, with counts in the sync receipt. No writer roots need to be repeated on sync.
580
+
581
+ On an active home, the same dry-run/apply commands explicitly renew the writer inventory on its existing epoch (#282). Declare the complete new 0.22.0-or-later roots after updating host pins and managed setup receipts. Ordinary and cadence sync then locate the new inventory and measure it afresh; original activation data, holds and prior receipts are unchanged. Pending publication or source-recovery work must first be finished with the inventoried install, for example after `dot ai rollback amem` or restoring the host's previous pin.
582
+
583
+ An interrupted renewal resumes only its exact intent. Before commit append starts, person `--abandon` can discard its unpublished metadata; after the first commit-log byte, only forward resume is supported. Unpublished intent staging requires explicit `--abandon`, then a fresh dry-run; it is never cleaned automatically. Compatible ordinary reads remain available while only renewal metadata is pending. After a crash, a retained `profile.lock` still blocks new reader admission until person `--resume` or, before commit append, `--abandon`. Never edit activation files, downgrade markers or start another epoch to discard a hold. See the [renewal contract](docs/contracts.md#same-epoch-writer-inventory-renewal-282). Real-home renewal and release remain separately authorized person operations.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@staix/agent-mem",
3
- "version": "0.21.0",
3
+ "version": "0.22.1",
4
4
  "description": "Curated, shared memory for coding agents: append-only logs, a derived index, tiers and a typed graph.",
5
5
  "license": "MIT",
6
6
  "type": "module",
@@ -23,7 +23,10 @@
23
23
  "@staix/agent-mem-v013": "npm:@staix/agent-mem@0.13.0",
24
24
  "@staix/agent-mem-v015": "npm:@staix/agent-mem@0.15.0",
25
25
  "@staix/agent-mem-v017": "npm:@staix/agent-mem@0.17.0",
26
- "@staix/agent-mem-v018": "npm:@staix/agent-mem@0.18.0"
26
+ "@staix/agent-mem-v018": "npm:@staix/agent-mem@0.18.0",
27
+ "bun-types-host-1-4-2": "npm:bun-types@1.4.2",
28
+ "@staix/agent-mem-v0220": "npm:@staix/agent-mem@0.22.0",
29
+ "@types/bun-host-1-4-2": "npm:@types/bun@1.4.2"
27
30
  },
28
31
  "exports": {
29
32
  ".": "./src/index.ts",
@@ -1,6 +1,7 @@
1
1
  import { noteDisplayActor } from "./memory/cli-attribution.ts";
2
2
  import { singleLine } from "./memory/style.ts";
3
3
  import { createMemoryMcp } from "./mcp-memory.ts";
4
+ import { safeProfileLockMessage } from "./memory/profile-lock-diagnostic.ts";
4
5
  import { memoryHome } from "./memory/home.ts";
5
6
  import type { ParsedCommand } from "./commands-table.ts";
6
7
 
@@ -111,7 +112,8 @@ export async function agentCliRead(
111
112
  return shown;
112
113
  }
113
114
  return output;
114
- } catch {
115
+ } catch (error) {
116
+ if(safeProfileLockMessage(error))throw error;
115
117
  throw new Error(UNAVAILABLE);
116
118
  } finally {
117
119
  try { memory?.close(); } catch { /* close failures do not expose partial reads */ }
package/src/cli.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { assertFormatFloor } from "./memory/formats.ts";
2
- import { placementRuntimeRefusal } from "./memory/sync-placement-guard.ts";
2
+ import { placementRuntimeRefusal, readPlacementRuntimeMarkers } from "./memory/sync-placement-guard.ts";
3
3
  import { activatePublicPruneView } from "./memory/public-prune-activation.ts";
4
4
  import { AgentUsageError, agentCliRead } from "./cli-agent-reads.ts";
5
5
  import { cliAttribution, withCliAttribution } from "./memory/cli-attribution.ts";
@@ -12,6 +12,7 @@ import { createRecallStalenessProbe } from "./memory/recall-staleness.ts";
12
12
  import { recordMemoryUse } from "./memory/use-telemetry.ts";
13
13
  import { DecayApplyError } from "./memory/decay-review.ts";
14
14
  import { createMemoryMcp } from "./mcp-memory.ts";
15
+ import { safeProfileLockMessage } from "./memory/profile-lock-diagnostic.ts";
15
16
  import { MCP_TOOLS, serveMcp, writeConfirmed } from "./mcp.ts";
16
17
  import { createStandalonePolicy } from "./memory/policy.ts";
17
18
  import { configuredPolicy, configuredPolicyOptions, loadAmemConfig, repoConfigRoot } from "./memory/config.ts";
@@ -20,6 +21,7 @@ import type { IngestKind, IngestReceipt } from "./ingest.ts";
20
21
  import { importClaudeMemCapture, importClaudeMemWorkState } from "./memory/import.ts";
21
22
  import { closeSync, constants, existsSync, fstatSync, lstatSync, openSync, readFileSync, readSync, rmSync, writeFileSync } from "node:fs";
22
23
  import { isAbsolute, join } from "node:path";
24
+ import { fileURLToPath } from "node:url";
23
25
  import { MemoryStore, looksSecret, machineId, openShardStore, type StoreOptions } from "./memory/store.ts";
24
26
  import { rawMemoryCommand, rawGoalCommand, claudeMemImportText } from "./memory/commands.ts";
25
27
  import { memoryProjectKey } from "./memory/project.ts";
@@ -313,6 +315,7 @@ export async function runCli(argv: readonly string[], context: CliContext = {}):
313
315
  }
314
316
  // Data-free errors (decided before any store or file is opened) keep their own message.
315
317
  if (marked && error instanceof AgentUsageError) throw error;
318
+ const recovery=marked?safeProfileLockMessage(error):undefined;if(recovery)throw new Error(recovery);
316
319
  if (marked) {
317
320
  // parseCommand is pure: when it rejects argv, the error it raised is a usage error, not memory data.
318
321
  let usage: Error | undefined, parsed: ReturnType<typeof parseCommand> | undefined;
@@ -378,6 +381,14 @@ async function runCliCommand(argv: readonly string[], context: CliContext): Prom
378
381
  }
379
382
  const { definition, options, rawArgs: args } = parsed;
380
383
  const identity = cliAttribution() ?? { actor: "person" as const, peer: "person" }, actor = identity.actor;
384
+ if (parsed.path[0] === "migrate" && options["--sync-placement"] === true) {
385
+ const modes = ["--dry-run","--apply","--resume","--abandon","--deactivate"].filter(key=>options[key]===true);
386
+ if (modes.length>1 || parsed.positionals.length || ["--shards","--public-prune-view","--generation","--deactivate"].some(key=>options[key]!==undefined))
387
+ throw new AgentUsageError("Sync placement is a home-wide forward-only migration; use only --sync-placement with --dry-run, --apply, --resume or pre-marker --abandon and the writer roots, never combine migration families or deactivate.");
388
+ if (actor === "agent") throw new AgentUsageError("Sync placement activation requires a person; ask the person to run amem migrate --sync-placement --dry-run and review the home-wide forward-only transition.");
389
+ } else if (parsed.path[0] === "migrate" && (options["--writer-root"] !== undefined||options["--abandon"]!==undefined)) {
390
+ throw new AgentUsageError("--writer-root and --abandon apply only to --sync-placement; run amem migrate --sync-placement --dry-run to review the inventory.");
391
+ }
381
392
  if (parsed.path[0] === "setup") {
382
393
  if (parsed.positionals.length !== 1 || options["--apply"] === true && options["--undo"] === true
383
394
  || options["--adopt-current"] === true && (options["--apply"] !== true || parsed.positionals[0] === "console")) throw new Error("usage: amem setup claude|codex|kimi|pi|qwen|jcode|opencode|gemini|agy|all|console [--root <abs path>] [--apply [--adopt-current]|--undo]");
@@ -472,9 +483,18 @@ async function runCliCommand(argv: readonly string[], context: CliContext): Prom
472
483
  }
473
484
  if ((options["--history"] === true || options["--as-of"] !== undefined) && (options["--all-projects"] === true || options["--tier"] === "repo")) throw Error("historical recall selects this active project's curated store; cross-project/repo history is unavailable");
474
485
  if (sub === "migrate") {
475
- const modes = ["--apply", "--dry-run", "--resume", "--deactivate"].filter(key => options[key] === true);
486
+ const modes = ["--apply", "--dry-run", "--resume", "--abandon", "--deactivate"].filter(key => options[key] === true);
476
487
  if (modes.length > 1) throw new Error("migration modes are mutually exclusive");
477
488
  const mode = options["--apply"] ? "apply" : options["--resume"] ? "resume" : options["--deactivate"] ? "deactivate" : "dry-run";
489
+ if (options["--sync-placement"] === true) {
490
+ const { activateSyncPlacement } = await import("./memory/sync-placement-activation.ts");
491
+ const result = await activateSyncPlacement({ memoryDir:memoryHome(),mode:options["--abandon"]?"abandon":mode as "dry-run"|"apply"|"resume",
492
+ writerRoots:repeated(argv,"--writer-root"),ownRoot:fileURLToPath(new URL("..",import.meta.url)),
493
+ authorize:()=>cliAttribution()?.actor==="person" });
494
+ if (options["--json"] === true) return JSON.stringify(result,null,2);
495
+ // Metadata only: the source reader and locator are never serialized into this receipt.
496
+ return `sync placement: ${result.mode}; ${result.active?"active":"inactive"}; home-wide, forward-only (no deactivate)\n${JSON.stringify(result,null,2)}`;
497
+ }
478
498
  const common = { memoryDir: memoryHome(), ...(options["--generation"] ? { generation: String(options["--generation"]) } : {}), authorizedPerson: true, tty: true };
479
499
  if (options["--public-prune-view"] === true) {
480
500
  if (options["--deactivate"] || options["--generation"] || options["--shards"]) throw new Error("public prune view is forward-only on an active split; no deactivate, generation replacement or new split");
@@ -1062,15 +1082,21 @@ async function runCliCommand(argv: readonly string[], context: CliContext): Prom
1062
1082
  const syncArgs = args.filter((arg, index) => arg !== "--if-due" && arg !== "--force" && !captureArgs.has(index) && (!legacySearch || arg !== "--filter") && (!explainSearch || arg !== "--explain"));
1063
1083
  const memoryArgs = captureImport && !options["--json"] ? [...syncArgs, "--json"] : syncArgs;
1064
1084
  let output: string;
1085
+ let placementWriters: import("./sync-placement-writer-context.ts").PlacementWriterContext | undefined;
1065
1086
  const stop = ifDue ? stopSignal(context.syncDeadlineMs ?? SYNC_IF_DUE_DEADLINE_MS, process) : undefined;
1066
1087
  try {
1088
+ if (sub === "sync" && (readPlacementRuntimeMarkers(syncHome).profile as { publicationFormat?:string } | undefined)?.publicationFormat === "agent-mem.sync-placement/v1") {
1089
+ const { activePlacementWriterContext } = await import("./sync-placement-writer-context.ts");
1090
+ placementWriters = activePlacementWriterContext(syncHome,fileURLToPath(new URL("..",import.meta.url)));
1091
+ }
1067
1092
  output = await withRepoRows(await rawMemoryCommand(memoryArgs, cwd, { person: identity.actor === "person", rankedSearch: identity.actor === "person" && !legacySearch, ...(explainSearch ? { explainSearch: true } : {}), project: boundProject(), open, readable: notes => identity.actor === "person" ? notes : notes.filter(view => view.project === boundProject() && policyOf().screenStandalone(view)), stale: createRecallStalenessProbe({ cwd: recallRoot ?? cwd, rootConfirmed, policy: () => configured }), outputReady: notes => outputReady(notes.map(view => view.id)), importPolicy: () => text => configured.declinesPersonal(text),
1068
- sync: { screen: row => configured.screenStandalone(row), policyVersion: () => JSON.stringify(configuredPolicyOptions(memoryHome(), policyCwd)), ...(stop ? { signal: stop.signal } : {}) }, // the repository-narrowed policy binds what sync publishes too
1093
+ sync: { ...(placementWriters ? { placementAttestation:placementWriters.attest } : {}), screen: row => configured.screenStandalone(row), policyVersion: () => JSON.stringify(configuredPolicyOptions(memoryHome(), policyCwd)), ...(stop ? { signal: stop.signal } : {}) }, // the repository-narrowed policy binds what sync publishes too
1069
1094
  onBridgeStatus: status => { receipt.bridgeStatus = status; },
1070
1095
  // #188: ids-only listing of nearby machine-local rows; policy-refused rows are neither listed nor counted.
1071
1096
  forgetNearby: note => forgetNearby({ base: memoryHome(), project: note.project, text: note.text, title: note.title, screen: value => configured.screenStandalone(value) }) }));
1072
1097
  } catch (error) {
1073
1098
  if (sub === "sync") stampSync((error as Error).message, stop?.cause());
1099
+ if (sub === "sync" && placementWriters?.lastRefusal) throw Error(`${placementWriters.lastRefusal.message} ${(error as Error).message}`);
1074
1100
  throw error;
1075
1101
  }
1076
1102
  finally {
@@ -1082,7 +1108,13 @@ async function runCliCommand(argv: readonly string[], context: CliContext): Prom
1082
1108
  if (sub === "sync") {
1083
1109
  // `--json` returns the failed result instead of throwing: read the real outcome from it, never assume a returned string means the sync worked.
1084
1110
  let failure: string | undefined;
1085
- if (options["--json"]) { try { const result = JSON.parse(output) as { ok?: unknown; error?: unknown }; if (result.ok === false) failure = typeof result.error === "string" ? result.error : "sync failed"; } catch { /* not a sync result */ } }
1111
+ if (options["--json"]) { try {
1112
+ const result = JSON.parse(output) as { ok?: unknown; error?: unknown };
1113
+ if (result.ok === false) {
1114
+ failure = typeof result.error === "string" ? result.error : "sync failed";
1115
+ if (placementWriters?.lastRefusal) { result.error = `${placementWriters.lastRefusal.message} ${failure}`; output = JSON.stringify(result,null,2); }
1116
+ }
1117
+ } catch { /* not a sync result */ } }
1086
1118
  stampSync(failure, stop?.cause());
1087
1119
  }
1088
1120
  if (captureImport) {
@@ -116,6 +116,9 @@ export const COMMANDS: Record<string, CommandDefinition> = {
116
116
  "--resume": flag("Resume the recorded migration"), "--deactivate": flag("Select shared v1 without deleting a generation"),
117
117
  "--shards": flag("Migrate the curated home into project and user shards"),
118
118
  "--public-prune-view": flag("Enable the forward-only v2 public prune view on an active split"),
119
+ "--sync-placement": flag("Person-only forward-only placement activation on this memory home; no automatic migration or renewal"),
120
+ "--abandon": flag("Person-only: discard an interrupted sync-placement attempt only before either v3 marker is installed"),
121
+ "--writer-root": { description: "Repeatable absolute amem install root in the complete local writer inventory", value: "path" },
119
122
  } },
120
123
  status: { description: "Report shard migration and pending old-layout appends", options: json },
121
124
  doctor: { description: "One read-only report (F15): duplicates and stale notes, pending candidates, capture freshness per CLI, the brief's token cost, index equals rebuild, reader leases, the policy in effect, ingest and sync cadence", options: json },
package/src/index.ts CHANGED
@@ -72,3 +72,9 @@ export * from "./memory/sync-placement-journal-format.ts";
72
72
  export * from "./memory/sync-placement-projection.ts";
73
73
  export * from "./memory/sync-placement-journal.ts";
74
74
  export type { PlacementCapturePort } from "./memory/sync-placement-capture.ts";
75
+ export { measureSyncPlacementWriters, SyncPlacementWriterError } from "./memory/sync-placement-writers.ts";
76
+ export type { MeasuredSyncPlacementWriterRoot, SyncPlacementWriterMeasurement, SyncPlacementWriterErrorCode } from "./memory/sync-placement-writers.ts";
77
+ export { readSyncPlacementWriterRoots } from "./memory/sync-placement-writer-roots.ts";
78
+ export { readEffectiveSyncPlacementWriters, readEffectiveSyncPlacementWriterRoots } from "./memory/sync-placement-effective-writers.ts";
79
+ export type { EffectiveSyncPlacementWriters } from "./memory/sync-placement-effective-writers.ts";
80
+ export { readSetupWriterRoots, SetupWriterRootsError } from "./setup.ts";
@@ -206,11 +206,12 @@ export async function rawMemoryCommand(args: string[], cwd: string, opts: Memory
206
206
  const r = await syncMemory(store, { ...opts.sync, ...(remote ? { remote } : {}) });
207
207
  if (json) return JSON.stringify(r, null, 2);
208
208
  if (!r.ok) fail(`${names.memory} sync: ${r.error}`);
209
+ const placement = r.placementPublication, placementText = placement ? `; placement ${placement.outcome}, ${placement.exported} exported, ${placement.withheld} withheld this attempt` : "";
209
210
  if (r.pruneApplication) {
210
211
  const a = r.pruneApplication;
211
- return `synced${r.pushed ? " and pushed" : ""}: ${r.entries} long-term entries transported; ${a.proofComplete} prune proofs complete, ${a.projectionActive ?? "pending"} foreign projections active, ${a.nativeApplied ?? "pending"} native prunes applied, ${a.held} held${r.warning ? `\nnote: ${r.warning}` : ""}`;
212
+ return `synced${r.pushed ? " and pushed" : ""}: ${r.entries} long-term entries transported; ${a.proofComplete} prune proofs complete, ${a.projectionActive ?? "pending"} foreign projections active, ${a.nativeApplied ?? "pending"} native prunes applied, ${a.held} held${placementText}${r.warning ? `\nnote: ${r.warning}` : ""}`;
212
213
  }
213
- return `synced${r.pushed ? " and pushed" : ""}: ${r.entries} long-term entries visible here${r.warning ? `\nnote: ${r.warning}` : ""}`;
214
+ return `synced${r.pushed ? " and pushed" : ""}: ${r.entries} long-term entries visible here${placementText}${r.warning ? `\nnote: ${r.warning}` : ""}`;
214
215
  }
215
216
  case "dedupe": {
216
217
  const apply = rest.includes("--apply");
@@ -9,6 +9,7 @@ import { dirname, join } from "node:path";
9
9
  import { canonicalPath, processLiveness, processSignature, realPath } from "./platform.ts";
10
10
  import { shardDir, userShardDir } from "./shard.ts";
11
11
  import { readPublicPruneInputSetChain } from "./public-prune-input-chain.ts";
12
+ import { profileLockRecoveryError, safeProfileLockMessage } from "./profile-lock-diagnostic.ts";
12
13
 
13
14
  export interface BoundProfile {
14
15
  kind: "shared-v1" | "generation";
@@ -96,12 +97,14 @@ export function readSelector(memoryDir: string, absoluteDeadline?: number): { se
96
97
  const current = readProfileJSON(join(memoryDir, "profile.json"));
97
98
  if (result.revision === (current === undefined ? "absent" : hashJSON(current))) return result;
98
99
  } catch (error) {
99
- if (error instanceof SyncPlacementRuntimeError) throw error;
100
- if (error instanceof Error && error.message === "public prune view unavailable") throw error; // A spent wait budget is never restarted.
100
+ if (safeProfileLockMessage(error)) throw error;
101
+ if (error instanceof SyncPlacementRuntimeError) throw profileLockRecoveryError(memoryDir) ?? error;
102
+ if (error instanceof Error && error.message === "public prune view unavailable") throw profileLockRecoveryError(memoryDir) ?? error; // A spent wait budget is never restarted.
101
103
  if (!lstatSync(join(memoryDir, PUBLIC_PRUNE_INTENT), { throwIfNoEntry: false }) && !lstatSync(join(memoryDir, "profile.lock"), { throwIfNoEntry: false })) throw error;
102
- waitForPublicPruneWriter(memoryDir, deadline);
104
+ try { waitForPublicPruneWriter(memoryDir, deadline); } catch (failure) { throw profileLockRecoveryError(memoryDir) ?? failure; }
103
105
  }
104
106
  }
107
+ const diagnostic=profileLockRecoveryError(memoryDir);if(diagnostic)throw diagnostic;
105
108
  return unavailable();
106
109
  }
107
110
 
@@ -0,0 +1,69 @@
1
+ /** Read-only error hints, never admission, recovery or proof that an owner is dead. */
2
+ import { closeSync, constants, fstatSync, lstatSync, openSync, opendirSync, readSync, type BigIntStats } from "node:fs";
3
+ import { join } from "node:path";
4
+ import { realPath } from "./platform.ts";
5
+
6
+ const messages={
7
+ shards:"An interrupted or still-running person shard migration retains profile.lock; have the person finish it or run amem migrate --shards --resume.",
8
+ prune:"An interrupted or still-running person public-prune view migration retains profile.lock; have the person finish it or run amem migrate --public-prune-view --resume.",
9
+ activation:"An interrupted or still-running person sync-placement activation retains profile.lock; have the person run amem migrate --sync-placement --resume, or amem migrate --sync-placement --abandon only while both markers retain their original v2 bytes.",
10
+ renewal:"An interrupted or still-running person writer-inventory renewal retains profile.lock; have the person run amem migrate --sync-placement --resume, or amem migrate --sync-placement --abandon only before the first commit-log byte.",
11
+ staging:"An interrupted or still-running person writer-inventory renewal retains profile.lock and unpublished intent staging; have the person run amem migrate --sync-placement --abandon, then amem migrate --sync-placement --dry-run before another apply.",
12
+ } as const;
13
+ type Family=keyof typeof messages;
14
+ const recognised=new WeakMap<object,string>();
15
+ /** A privacy-safe exception carries only one of our fixed messages, never a caught message or path. */
16
+ export function rememberProfileLockError(error:object,text:string):void {
17
+ if((Object.values(messages) as readonly string[]).includes(text))recognised.set(error,text);
18
+ }
19
+ export function safeProfileLockMessage(error:unknown):string|undefined {
20
+ return error!==null&&(typeof error==="object"||typeof error==="function")?recognised.get(error):undefined;
21
+ }
22
+ const same=(a:BigIntStats,b:BigIntStats)=>a.dev===b.dev&&a.ino===b.ino&&a.uid===b.uid&&a.mode===b.mode&&a.nlink===b.nlink&&a.size===b.size&&a.mtimeNs===b.mtimeNs&&a.ctimeNs===b.ctimeNs;
23
+ function owned(s:BigIntStats,limit:number,privateFile=true):void {
24
+ if(!s.isFile()||s.isSymbolicLink()||s.uid!==BigInt(process.getuid?.()??-1)||s.nlink!==1n||(s.mode&(privateFile?0o077n:0o022n))!==0n||s.size>BigInt(limit))throw Error();
25
+ }
26
+ const object=(v:unknown):v is Record<string,unknown>=>!!v&&typeof v==="object"&&!Array.isArray(v);
27
+ const hex=(v:unknown,width:number)=>typeof v==="string"&&v.length===width&&!/[^a-f0-9]/.test(v);
28
+ /** Only called after an existing refusal. Absent lock means no extra marker/intent read at all.
29
+ * ponytail: fixed metadata limits plus20000 home names/256 staging files; a reviewed bounded descriptor inventory is needed to grow this hint. */
30
+ export function profileLockRefusal(home:string):string|undefined {
31
+ try{
32
+ const checks:(()=>void)[]=[];
33
+ const read=(name:string,limit:number,privateFile=true):Record<string,unknown>|undefined=>{
34
+ const file=join(home,name),before=lstatSync(file,{bigint:true,throwIfNoEntry:false});
35
+ if(!before){checks.push(()=>{if(lstatSync(file,{throwIfNoEntry:false}))throw Error();});return;}
36
+ owned(before,limit,privateFile);const fd=openSync(file,constants.O_RDONLY|constants.O_NOFOLLOW|constants.O_NONBLOCK);
37
+ try{
38
+ const s=fstatSync(fd,{bigint:true});if(!same(before,s))throw Error();
39
+ const bytes=Buffer.alloc(Number(s.size)+1);let count=0;
40
+ while(count<bytes.length){const n=readSync(fd,bytes,count,bytes.length-count,count);if(!n)break;count+=n;}
41
+ if(count!==Number(s.size)||!same(s,fstatSync(fd,{bigint:true}))||!same(s,lstatSync(file,{bigint:true})))throw Error();
42
+ const value:unknown=JSON.parse(new TextDecoder("utf-8",{fatal:true}).decode(bytes.subarray(0,count)));if(!object(value))throw Error();
43
+ checks.push(()=>{if(!same(s,lstatSync(file,{bigint:true})))throw Error();});return value;
44
+ }finally{closeSync(fd);}
45
+ };
46
+ const owner=read("profile.lock",4096);if(!owner)return;
47
+ if(Object.keys(owner).sort().join()!=="pid,signature,token"||!Number.isSafeInteger(owner.pid)||Number(owner.pid)<=0||!hex(owner.signature,64)||!hex(owner.token,32))return;
48
+ const canonical=realPath(home),profile=read("profile.json",262144,false),shards=read("shards.json",262144,false);
49
+ const prune=read("public-prune-view.intent.json",1048576),activation=read("sync-placement-activation.json",1048576),renewal=read("sync-placement-writer-renewal.json",1048576);
50
+ const families=new Set<Family>();
51
+ if(prune){if(prune.schema!=="agent-mem.public-prune-activation/v1"&&prune.schema!=="agent-mem.public-prune-advance/v1"||prune.home!==canonical||!hex(prune.generation,32))return;families.add("prune");}
52
+ if(activation){if(activation.schema!=="agent-mem.sync-placement-activation/v1"||activation.home!==canonical||!object(activation.epoch)||!hex(activation.epoch.generation,32))return;families.add("activation");}
53
+ if(renewal){if(renewal.schema!=="agent-mem.sync-placement-writer-renewal/v1"||renewal.home!==canonical||!object(renewal.revision)||renewal.revision.schema!=="agent-mem.sync-placement-writer-revision/v1"||!hex(renewal.revision.generation,32)||!hex(renewal.revision.epochHash,64))return;families.add("renewal");}
54
+ if(shards?.schema==="agent-mem.shards/v1"&&shards.phase==="captured"&&hex(shards.generation,32))families.add("shards");
55
+ if(!families.size&&profile?.schema==="agent-mem.profile/v3"&&shards?.schema==="agent-mem.shards/v3"&&shards.phase==="active"&&hex(profile.generation,32)&&shards.generation===profile.generation&&hex(profile.placementHead,64)&&profile.placementHead===shards.placementHead){
56
+ const reader=opendirSync(home);let names=0,staging=0,nameBytes=0;
57
+ try{for(;;){const entry=reader.readSync();if(!entry)break;if(++names>20000||(nameBytes+=Buffer.byteLength(entry.name))>8*1024*1024)throw Error();
58
+ const prefix="sync-placement-writer-renewal.json.pending-";if(!entry.name.startsWith(prefix)||!hex(entry.name.slice(prefix.length),64))continue;
59
+ if(++staging>256)throw Error();const file=join(home,entry.name),s=lstatSync(file,{bigint:true});owned(s,1048576);checks.push(()=>{if(!same(s,lstatSync(file,{bigint:true})))throw Error();});
60
+ }}finally{reader.closeSync();}
61
+ if(staging)families.add("staging");
62
+ }
63
+ for(const check of checks)check();if(families.size!==1)return;
64
+ return messages[[...families][0]!];
65
+ }catch{return undefined;}
66
+ }
67
+ export function profileLockRecoveryError(home:string):Error|undefined {
68
+ const text=profileLockRefusal(home);if(!text)return;const error=new Error(text);error.name="ProfileLockMigrationError";rememberProfileLockError(error,text);return error;
69
+ }
@@ -80,7 +80,7 @@ function realmBinding(home: string, realms: Realm[]): void {
80
80
  for (const realm of realms) guard(metadata(realm.profile) === realm.metadata);
81
81
  }
82
82
  /** Atomic create-once publication, recovering only this exact complete staging pair. */
83
- function immutable(file: string, text: string): void {
83
+ export function writeImmutableActivationMetadata(file: string, text: string): void {
84
84
  const base = dirname(file); if (!lstatSync(base, { throwIfNoEntry: false })) { mkdirSync(base, { mode: 0o700 }); fsyncDirectory(dirname(base)); } owned(base, true);
85
85
  const bytes = Buffer.from(text), temp = file + ".pending-" + createHash("sha256").update(bytes).digest("hex");
86
86
  if (lstatSync(file, { throwIfNoEntry: false })) {
@@ -231,7 +231,7 @@ export async function activatePublicPruneView(options: PublicPruneActivationOpti
231
231
  const dirs = [...new Set([home, ...intent.realms.map(realm => realm.profile.dir)])].sort();
232
232
  for (const dir of dirs) { assertNoLiveReaders(dir, {includeShards:true}); const release = acquireMutation(dir); guard(release); releases.push(release); }
233
233
  validateAdvance(intent);
234
- if (pending) immutable(intentFile, canonicalJSON(intent) + "\n");
234
+ if (pending) writeImmutableActivationMetadata(intentFile, canonicalJSON(intent) + "\n");
235
235
  if (!pending) {
236
236
  guard(readProfileBytes(home) === intent.previousProfile && raw(join(home, "shards.json")) === intent.previousShards && aliases(home) === intent.aliases);
237
237
  realmBinding(home, intent.realms);
@@ -240,14 +240,14 @@ export async function activatePublicPruneView(options: PublicPruneActivationOpti
240
240
  }) }, home);
241
241
  }
242
242
  const viewHash = publicPruneViewHash(intent.view);
243
- immutable(join(home, "public-prune-input-sets", viewHash + ".json"), canonicalJSON(intent.view) + "\n");
243
+ writeImmutableActivationMetadata(join(home, "public-prune-input-sets", viewHash + ".json"), canonicalJSON(intent.view) + "\n");
244
244
  readPublicPruneViewForActivation(home, viewHash, intent.generation);
245
245
  let profile = readProfileBytes(home), shards = raw(join(home, "shards.json"));
246
246
  guard((profile === intent.previousProfile || profile === intent.finalProfile) && (shards === intent.previousShards || shards === intent.finalShards));
247
247
  guard(profile !== intent.finalProfile || shards === intent.finalShards);
248
248
  verify(intent, profile, shards); for (const realm of intent.realms) syncIndex(nextProfile(realm, intent));
249
249
  if (!pending && profile === intent.finalProfile && shards === intent.finalShards) return { mode: options.mode, generation: intent.generation, active: true, alreadyActive: true, realms: intent.realms.length, viewHash, maskBuildCheck: "verified" };
250
- immutable(intentFile, canonicalJSON(intent) + "\n");
250
+ writeImmutableActivationMetadata(intentFile, canonicalJSON(intent) + "\n");
251
251
  await options.checkpoint?.("after-intent"); verify(intent, profile, shards);
252
252
  if (shards !== intent.finalShards) { writeProfileJSON(join(home, "shards.json"), JSON.parse(intent.finalShards)); shards = intent.finalShards; }
253
253
  await options.checkpoint?.("after-shards"); verify(intent, profile, shards);
@@ -4,6 +4,7 @@ import { readProfileJSON, writeProfileJSON } from "./generation.ts";
4
4
  import { readerAdmissionDatabase } from "./mutation.ts";
5
5
  import { processLiveness, processSignature } from "./platform.ts";
6
6
  import { waitForPublicPruneWriter } from "./public-prune-view.ts";
7
+ import { profileLockRecoveryError, safeProfileLockMessage } from "./profile-lock-diagnostic.ts";
7
8
 
8
9
  const READER_LEASE_SCHEMA = "agent-mem.reader-lease/v1";
9
10
  const RENEW_MS = 25_000;
@@ -52,8 +53,8 @@ function admitReader(memoryDir: string): void {
52
53
  // Even a malformed or stale marker refuses admission; only the profile-lock owner may reclaim it.
53
54
  try {
54
55
  waitForPublicPruneWriter(memoryDir);
55
- if (lstatSync(join(memoryDir, "profile.lock"), { throwIfNoEntry: false })) throw new Error("profile lock prevents reader admission; finish or resume the migration with amem migrate --resume");
56
- } catch { throw new Error("profile lock prevents reader admission; finish or resume the migration with amem migrate --resume"); }
56
+ if (lstatSync(join(memoryDir, "profile.lock"), { throwIfNoEntry: false })) throw profileLockRecoveryError(memoryDir) ?? new Error("profile lock prevents reader admission; finish or resume the migration with amem migrate --resume");
57
+ } catch (error) { if(safeProfileLockMessage(error))throw error;throw profileLockRecoveryError(memoryDir) ?? new Error("profile lock prevents reader admission; finish or resume the migration with amem migrate --resume"); }
57
58
  }
58
59
 
59
60
  /** One lease per process/home/namespace, reference-counted across handles.
@@ -1,4 +1,3 @@
1
- import { syncPlacementMemory } from "./sync-placement-transport.ts";
2
1
  import { ShardSyncApplications, ShardSyncApplicationError } from "./shard-sync-applications.ts";
3
2
  import { ShardHistoryAuditor, ShardHistoryError, shardHistoryRefusal } from "./shard-sync-history.ts";
4
3
  import { validatePlacementProfile, type PlacementProfile } from "./sync-placement-format.ts";
@@ -446,7 +445,7 @@ async function prepareHolds(targets: readonly Target[], root: string, url: strin
446
445
  export async function syncShardedMemory(store: MemoryStore, opts: SyncOptions = {}, testOnlyUnexpectedError?: (error: unknown) => void, testOnlyPhase?: (phase: SyncPhase) => void): Promise<SyncResult> {
447
446
  const placementRefusal = placementRuntimeRefusal(store.profile.home, store.profile);
448
447
  if (placementRefusal) return { ok: false, pushed: false, entries: 0, error: placementRefusal };
449
- if (store.profile.publicationFormat === "agent-mem.sync-placement/v1") return syncPlacementMemory(store, opts);
448
+ if (store.profile.publicationFormat === "agent-mem.sync-placement/v1") return (await import("./sync-placement-transport.ts")).syncPlacementMemory(store, opts);
450
449
  let pushed = false, releaseTransport: (() => void) | undefined, releaseSync: (() => void) | undefined;
451
450
  const releases: (() => void)[] = [], transient: string[] = [], temporary: string[] = [];
452
451
  const v2 = store.profile.foreignPruneFormat === "agent-mem.public-prune-witness/v1";
@@ -8,6 +8,7 @@ import { existsSync, linkSync, lstatSync, mkdirSync, readFileSync, rmSync, write
8
8
  import { join } from "node:path";
9
9
  import { canonicalJSON, generationDescriptor, hashJSON, readProfileJSON, readSelector, validGeneration, PROFILE_LOCK_TIMEOUT_MS, type BoundProfile, type GenerationSeal } from "./generation.ts";
10
10
  import { SHARD_SCHEMA, resolveAlias } from "./aliases.ts";
11
+ import { profileLockRecoveryError, safeProfileLockMessage } from "./profile-lock-diagnostic.ts";
11
12
 
12
13
  /**
13
14
  * #15 T1: the per-project shard layout under the user home. One shard per project key at `projects/<key-hash>/`;
@@ -126,6 +127,7 @@ function shardSnapshot(memoryDir: string, deadline: number) {
126
127
  let selector: ReturnType<typeof readSelector>;
127
128
  try { selector = readSelector(memoryDir, deadline); }
128
129
  catch (error) {
130
+ if(safeProfileLockMessage(error))throw error;
129
131
  if (error instanceof SyncPlacementRuntimeError) throw error;
130
132
  // The writer's spent wait budget or unavailable proof is never restarted here.
131
133
  if (error instanceof Error && error.message === "public prune view unavailable") throw error;
@@ -145,8 +147,10 @@ function shardSnapshot(memoryDir: string, deadline: number) {
145
147
  throw new Error("curated shard selector changed");
146
148
  }
147
149
  export function readShardedSelection(memoryDir: string): ShardedSelection {
150
+ try {
148
151
  const { journal } = shardSnapshot(memoryDir, Date.now() + PROFILE_LOCK_TIMEOUT_MS);
149
152
  return { schema: journal.schema, generation: journal.generation, phase: "active", selectorRevision: journal.selectorRevision };
153
+ }catch(error){if(safeProfileLockMessage(error))throw error;throw profileLockRecoveryError(memoryDir)??error;}
150
154
  }
151
155
 
152
156
  /** Fixed curated paths cannot redirect writes into legacy inputs or another shard.
@@ -88,8 +88,9 @@ export function endLikeSignal(name: string): void {
88
88
  */
89
89
  function syncRepository(home: string): string | undefined {
90
90
  try {
91
- if (lstatSync(join(home, "shards.json"), { throwIfNoEntry: false })) return join(home, "shard-sync", "objects.git", "config");
92
91
  const { selector } = readSelector(home);
92
+ if (selector?.schema === "agent-mem.profile/v3") return join(home,"shard-sync-placement","objects.git","config");
93
+ if (lstatSync(join(home, "shards.json"), { throwIfNoEntry: false })) return join(home, "shard-sync", "objects.git", "config");
93
94
  if (selector?.kind === "generation") return join(generationDir(home, selector.generation!), ".git", "config");
94
95
  return join(home, ".git", "config");
95
96
  } catch { return undefined; }
@@ -0,0 +1,142 @@
1
+ /** Person coordinator's precommit-only metadata cleanup. No rollback, source rewrite, renewal or package measurement. */
2
+ import { createHash } from "node:crypto";
3
+ import { closeSync, constants, fstatSync, fsyncSync, lstatSync, openSync, opendirSync, readSync, rmdirSync, unlinkSync, type BigIntStats } from "node:fs";
4
+ import { dirname, join } from "node:path";
5
+ import { types } from "node:util";
6
+ import { canonicalJSON } from "./generation.ts";
7
+ import { realPath } from "./platform.ts";
8
+ import { assertMutationHandle, type MutationHandle } from "./mutation.ts";
9
+ import { placementEpochHash, SyncPlacementJournalError, type SyncPlacementJournalErrorCode } from "./sync-placement-journal-format.ts";
10
+ import { validatePlacementActivationIntent, readPlacementActivationIntent, clearPlacementActivationIntent, PLACEMENT_ACTIVATION_INTENT_FILE, type PlacementActivationIntent } from "./sync-placement-activation-storage.ts";
11
+ import { planSyncPlacementWriterRoots, SYNC_PLACEMENT_WRITER_ROOTS_FILE } from "./sync-placement-writer-roots.ts";
12
+ import { syncPlacementWriterId } from "./sync-placement-writers.ts";
13
+ import { SYNC_SCREEN_MAX_BYTES, SYNC_SCREEN_MAX_ROWS } from "./sync-screen.ts";
14
+
15
+ export interface PlacementAbandonOptions { mutation: MutationHandle; assertCurrent(): void }
16
+ export class PlacementActivationAbandonError extends Error {
17
+ readonly code: "forward" | "metadata" | "bound";
18
+ constructor(code: "forward" | "metadata" | "bound" = "metadata") {
19
+ super(code === "forward"
20
+ ? "activation markers or active fence changed; use forward --resume with a compatible recovery release"
21
+ : code === "bound" ? "activation cleanup metadata exceeds the supported bound; inspect it with a compatible recovery release"
22
+ : "activation cleanup metadata is unsafe, changed or unknown; inspect it with a compatible recovery release before deleting anything");
23
+ this.name = "PlacementActivationAbandonError"; this.code = code;
24
+ }
25
+ }
26
+ function fail(code: SyncPlacementJournalErrorCode = "corrupt"): never { throw new SyncPlacementJournalError(code); }
27
+ const bytes = (value: unknown) => Buffer.from(canonicalJSON(value) + "\n");
28
+ const hash = (value: Uint8Array) => createHash("sha256").update(value).digest("hex");
29
+ const identity = (s: BigIntStats) => [s.dev, s.ino, s.uid, s.mode].map(String).join(":");
30
+ const stamp = (s: BigIntStats) => [s.dev, s.ino, s.uid, s.mode, s.nlink, s.size, s.mtimeNs, s.ctimeNs].map(String).join(":");
31
+ function owned(s: BigIntStats, directory = false, links: readonly bigint[] = [1n], marker = false): void {
32
+ if (s.isSymbolicLink() || s.uid !== BigInt(process.getuid?.() ?? -1)
33
+ || (directory ? !s.isDirectory() || (s.mode & 0o077n) !== 0n : !s.isFile() || !links.includes(s.nlink) || (marker ? (s.mode & 0o022n) !== 0n : (s.mode & 0o777n) !== 0o600n))) fail();
34
+ }
35
+ interface Member { stat: BigIntStats; expected: Buffer }
36
+ interface State { files: Map<string, Member>; dirs: Map<string, BigIntStats> }
37
+ function readMember(file: string, expected: Buffer, prefix = false, links: readonly bigint[] = [1n], marker = false): Member | undefined {
38
+ const before = lstatSync(file, { bigint: true, throwIfNoEntry: false }); if (!before) return undefined;
39
+ owned(before, false, links, marker); if (before.size > BigInt(expected.length)) fail("bound");
40
+ const fd = openSync(file, constants.O_RDONLY | constants.O_NOFOLLOW | constants.O_NONBLOCK);
41
+ try {
42
+ const opened = fstatSync(fd, { bigint: true }); if (stamp(opened) !== stamp(before)) fail("changed");
43
+ const data = Buffer.alloc(Number(opened.size) + 1); let at = 0;
44
+ while (at < data.length) { const n = readSync(fd, data, at, data.length - at, at); if (!n) break; at += n; }
45
+ if (at !== Number(opened.size) || stamp(fstatSync(fd, { bigint: true })) !== stamp(opened) || stamp(lstatSync(file, { bigint: true })) !== stamp(opened)) fail("changed");
46
+ if ((!prefix && at !== expected.length) || !data.subarray(0, at).equals(expected.subarray(0, at))) fail("conflict");
47
+ return { stat: opened, expected };
48
+ } finally { closeSync(fd); }
49
+ }
50
+ function directory(path: string): BigIntStats | undefined {
51
+ const value = lstatSync(path, { bigint: true, throwIfNoEntry: false }); if (value) owned(value, true); return value;
52
+ }
53
+ function names(path: string, maximum: number): string[] {
54
+ const result: string[] = [], iterator = opendirSync(path); let size = 2;
55
+ try { for (;;) { const entry = iterator.readSync(); if (!entry) break; if (result.length >= maximum) fail("bound");
56
+ size += Buffer.byteLength(JSON.stringify(entry.name)) + 1; if (size > SYNC_SCREEN_MAX_BYTES) fail("bound"); result.push(entry.name);
57
+ } } finally { iterator.closeSync(); } return result.sort();
58
+ }
59
+ /** Called only after person approval and under activation's existing live profile/readers/sync/mutation fences. */
60
+ export function abandonPlacementActivation(homeInput: string, input: PlacementActivationIntent, options: PlacementAbandonOptions): void {
61
+ try {
62
+ const intent = validatePlacementActivationIntent(input), initialHome = lstatSync(homeInput, { bigint: true }); owned(initialHome, true);
63
+ const home = realPath(homeInput); if (home !== intent.home || identity(directory(home)!) !== identity(initialHome)) fail("changed");
64
+ const epochHash = placementEpochHash(intent.epoch), storage = join(home, "sync-placement"), epochs = join(storage, "epochs"), epochDir = join(epochs, epochHash);
65
+ const intentFile = join(home, PLACEMENT_ACTIVATION_INTENT_FILE), intentBytes = bytes(intent), intentTemp = intentFile + ".pending-" + hash(intentBytes);
66
+ const roots = intent.roots.map(root => {
67
+ const writerId = syncPlacementWriterId(root), writer = intent.plan.writers.find(value => value.writerId === writerId); if (!writer) fail("conflict");
68
+ return { root, writerId, version: writer.version, pin: writer.pin };
69
+ });
70
+ const locatorBytes = bytes(planSyncPlacementWriterRoots({ epochHash, homeHash: intent.epoch.homeHash, activationHash: intent.plan.seal, roots },
71
+ { epochHash, homeHash: intent.epoch.homeHash, activationHash: intent.plan.seal, writers: intent.plan.writers }));
72
+ const locator = join(home, SYNC_PLACEMENT_WRITER_ROOTS_FILE), locatorTemp = locator + ".pending-" + hash(locatorBytes);
73
+ const expectedEpoch = new Map([["epoch.json", bytes(intent.epoch)], ["activation.json", bytes(intent.plan)]]);
74
+ let clearing = false;
75
+ const proof = () => {
76
+ assertMutationHandle(options.mutation, home); options.assertCurrent(); assertMutationHandle(options.mutation, home);
77
+ if (identity(directory(home)!) !== identity(initialHome)) fail("changed");
78
+ try {
79
+ if (!readMember(join(home, "profile.json"), Buffer.from(intent.previousProfile), false, [1n], true)
80
+ || !readMember(join(home, "shards.json"), Buffer.from(intent.previousShards), false, [1n], true)) throw new PlacementActivationAbandonError("forward");
81
+ } catch { throw new PlacementActivationAbandonError("forward"); }
82
+ if (directory(storage) && lstatSync(join(storage, "active.json"), { throwIfNoEntry: false })) throw new PlacementActivationAbandonError("forward");
83
+ const actual = readPlacementActivationIntent(home);
84
+ if (actual ? canonicalJSON(actual) !== canonicalJSON(intent) : !clearing) fail("changed");
85
+ };
86
+ const scan = (): State => {
87
+ const files = new Map<string, Member>(), dirs = new Map<string, BigIntStats>([[home, directory(home)!]]);
88
+ for (const path of [storage, epochs, epochDir]) { const stat = directory(path); if (stat) dirs.set(path, stat); }
89
+ if (dirs.has(storage)) for (const name of names(storage, 2)) if (name !== "epochs") fail("corrupt");
90
+ const knownTemps = new Set([intentTemp, locatorTemp]);
91
+ for (const name of names(home, SYNC_SCREEN_MAX_ROWS)) if (name.startsWith(PLACEMENT_ACTIVATION_INTENT_FILE + ".pending-") || name.startsWith(SYNC_PLACEMENT_WRITER_ROOTS_FILE + ".pending-"))
92
+ if (!knownTemps.has(join(home, name))) fail("corrupt");
93
+ const add = (path: string, expected: Buffer, prefix = false, links: readonly bigint[] = [1n]) => { const value = readMember(path, expected, prefix, links); if (value) files.set(path, value); return value; };
94
+ const final = add(locator, locatorBytes, false, [1n, 2n]), temp = add(locatorTemp, locatorBytes, true, [1n, 2n]);
95
+ if (final?.stat.nlink === 2n || temp?.stat.nlink === 2n) {
96
+ if (!final || !temp || final.stat.size !== BigInt(locatorBytes.length) || temp.stat.size !== BigInt(locatorBytes.length) || stamp(final.stat) !== stamp(temp.stat)) fail("corrupt");
97
+ }
98
+ if (dirs.has(epochDir)) for (const name of names(epochDir, 3)) { const expected = expectedEpoch.get(name); if (!expected) fail("corrupt"); add(join(epochDir, name), expected, true); }
99
+ const current = add(intentFile, intentBytes, false, [1n, 2n]), companion = add(intentTemp, intentBytes, false, [1n, 2n]);
100
+ if (current?.stat.nlink === 2n || companion) {
101
+ if (!current || !companion || current.stat.nlink !== 2n || companion.stat.nlink !== 2n || stamp(current.stat) !== stamp(companion.stat)) fail("corrupt");
102
+ }
103
+ if (!current && !clearing) fail("changed"); return { files, dirs };
104
+ };
105
+ proof(); let state = scan();
106
+ const check = () => {
107
+ proof(); const next = scan();
108
+ const expectedFiles = [...state.files.keys()].filter(path => !clearing || path !== intentFile).sort(), foundFiles = [...next.files.keys()].filter(path => !clearing || path !== intentFile).sort();
109
+ if (canonicalJSON(expectedFiles) !== canonicalJSON(foundFiles) || canonicalJSON([...state.dirs.keys()].sort()) !== canonicalJSON([...next.dirs.keys()].sort())) fail("changed");
110
+ for (const [path, before] of state.files) { const after = next.files.get(path); if (clearing && path === intentFile && !after) continue; if (!after || stamp(before.stat) !== stamp(after.stat)) fail("changed"); }
111
+ for (const [path, before] of state.dirs) if (identity(before) !== identity(next.dirs.get(path)!)) fail("changed");
112
+ };
113
+ const sync = (path: string) => {
114
+ check(); const expected = state.dirs.get(path); if (!expected) fail("changed"); const fd = openSync(path, constants.O_RDONLY | constants.O_DIRECTORY | constants.O_NOFOLLOW);
115
+ try { if (identity(fstatSync(fd, { bigint: true })) !== identity(expected)) fail("changed"); fsyncSync(fd); } finally { closeSync(fd); } check();
116
+ };
117
+ const remove = (path: string) => {
118
+ if (!state.files.has(path)) return; check(); const before = state.files.get(path)!; unlinkSync(path); state.files.delete(path);
119
+ for (const [peer, item] of state.files) if (item.stat.dev === before.stat.dev && item.stat.ino === before.stat.ino) {
120
+ const now = lstatSync(peer, { bigint: true }); owned(now);
121
+ if (before.stat.nlink !== 2n || now.nlink !== 1n || now.dev !== item.stat.dev || now.ino !== item.stat.ino || now.uid !== item.stat.uid || now.mode !== item.stat.mode || now.size !== item.stat.size || now.mtimeNs !== item.stat.mtimeNs) fail("changed");
122
+ item.stat = now;
123
+ }
124
+ sync(dirname(path));
125
+ };
126
+ remove(locatorTemp); remove(locator);
127
+ for (const name of ["activation.json", "epoch.json"]) remove(join(epochDir, name));
128
+ if (state.dirs.has(epochDir)) { check(); if (names(epochDir, 1).length) fail("changed"); rmdirSync(epochDir); state.dirs.delete(epochDir); sync(epochs); }
129
+ remove(intentTemp);
130
+ // A prior owner may have unlinked a member or removed this epoch before its directory fsync.
131
+ // Reassert every retained parent before the intent can acknowledge that cleanup as complete.
132
+ for (const path of [epochs, storage, home]) if (state.dirs.has(path)) sync(path);
133
+ check(); clearing = true;
134
+ clearPlacementActivationIntent(home, intent, { mutation: options.mutation, assertCurrent: check }); state.files.delete(intentFile); check();
135
+ } catch (error) {
136
+ if (error && typeof error === "object" && !types.isProxy(error) && error instanceof PlacementActivationAbandonError) throw error;
137
+ if (error && typeof error === "object" && !types.isProxy(error) && error instanceof SyncPlacementJournalError) {
138
+ const value = Object.getOwnPropertyDescriptor(error, "code"); if (value && "value" in value && ["invalid", "corrupt", "bound", "conflict", "pending", "changed", "authorization", "policy", "local", "busy"].includes(value.value)) throw new PlacementActivationAbandonError(value.value === "bound" ? "bound" : "metadata");
139
+ }
140
+ throw new PlacementActivationAbandonError();
141
+ }
142
+ }