Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src/node/services/di/layers/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -573,6 +573,7 @@ export const CoreWiringLive: Layer.Layer<
workspaceService.emitWorkflowRunActivity(event);
turnRequestBuilderBindings.workflowResultContinuationSender = workspaceService;
workspaceService.setMemoryConsolidationService(memoryConsolidationService);
workspaceService.setSharedWorkspaceMemoryStore(memoryService);
// Workspace-scope change events carry the memory OWNER (task-tree root);
// every live session resolving to that owner reads the same notebook.
memoryService.on("change", (event: MemoryChangeEvent) => {
Expand Down
174 changes: 174 additions & 0 deletions src/node/services/memoryLegacyAdoption.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,174 @@
/**
* Legacy-notebook adoption manifest: the durable record of which files of a
* sub-agent's PRE-SHARING private notebook (`<childSession>/memory`, written by
* builds that kept `/memories/workspace` per workspace) were folded into the
* task-tree owner's shared store, and where each landed
* (MemoryService.adoptLegacyPrivateStore).
*/
import * as fsPromises from "node:fs/promises";
import * as path from "node:path";

/**
* File in the sub-agent's SESSION dir (beside its legacy `memory` dir, never
* inside it) recording, per relPath, the sha256 of the content already copied
* into the shared store (adoptLegacyPrivateStore). Outside the legacy root on
* purpose: everything under `<childSession>/memory` is the model-writable
* `/memories/workspace` namespace of a downgraded build (the path grammar
* admits dotfiles), and this manifest's `created`/`target`/hash fields are
* trusted as provenance. The session dir itself is not addressable through
* any memory path.
*/
export const LEGACY_ADOPTION_MANIFEST_FILE_NAME = "memory-adoption-manifest.json";

export function legacyAdoptionManifestPath(childSessionDir: string): string {
return path.join(childSessionDir, LEGACY_ADOPTION_MANIFEST_FILE_NAME);
}

/**
* One adopted legacy file: content hash, child sidecar fingerprint, owner-store
* relPath, and whether the adoption CREATED that owner file (provenance: a
* pre-existing identical owner note is the owner's own). `pending`: written
* BEFORE the copy lands (provenance must not depend on the copy's existence: a
* retry finding the bytes already at the target could not tell an interrupted
* adoption from an owner note); cleared once the sidecar fold completed.
*
* Every field beyond the three strings is optional and unknown fields are
* ignored on read, so later builds can extend the record (downgrade-time
* reconciliation of edited/deleted sources) without invalidating manifests
* written by this one.
*/
export interface LegacyAdoptionRecord {
content: string;
sidecar: string;
target: string;
created?: boolean;
pending?: boolean;
/**
* Identity of the owner file this adoption wrote (`ino:size:mtimeNs` right
* after the write): the copy is THIS generation of the file, not merely a
* file holding the adopted bytes — an owner who deleted and recreated (or
* edited and restored) the note to identical bytes owns the new file.
* Absent (write before stamping, or the stamp could not be taken): the copy
* is never treated as this adoption's.
*/
targetStamp?: string;
}

/**
* Parse one manifest record. Lifecycle flags are raw JSON: a value that is
* neither absent nor boolean fails CLOSED — `pending` reads as set (the pass
* is redone), `created` as unset (no destructive provenance) — so a corrupted
* flag can never make an interrupted pass look settled.
*/
function parseLegacyAdoptionRecord(value: unknown): LegacyAdoptionRecord | null {
if (typeof value !== "object" || value === null) return null;
const record = value as Record<string, unknown>;
if (
typeof record.content !== "string" ||
typeof record.sidecar !== "string" ||
typeof record.target !== "string"
) {
return null;
}
if (record.targetStamp !== undefined && typeof record.targetStamp !== "string") return null;
const flag = (raw: unknown, malformed: boolean): boolean | undefined =>
raw === undefined ? undefined : typeof raw === "boolean" ? raw : malformed;
return {
content: record.content,
sidecar: record.sidecar,
target: record.target,
created: flag(record.created, false),
pending: flag(record.pending, true),
targetStamp: record.targetStamp,
};
}

/**
* A manifest that exists and could be read but does not parse as a record
* map. Distinguished from an UNREADABLE file (EACCES, EIO — the plain fs
* error) so strict callers can quarantine the former (its bytes are the
* file's state) while still refusing on the latter.
*/
export class LegacyAdoptionManifestMalformedError extends Error {
constructor(manifestPath: string, detail: string) {
super(`the legacy adoption manifest at ${manifestPath} is malformed (${detail})`);
this.name = "LegacyAdoptionManifestMalformedError";
}
}

/**
* Read of the adoption manifest. A MISSING file reads as "nothing adopted"
* for every caller. Tolerant callers also read an unreadable (EACCES, EIO)
* or malformed file — bad JSON, a non-object, a record missing its string
* fields — as empty (self-healing: the next pass rewrites it). `strict`
* callers throw on all of those: the adoption pass and the removal handover
* decide what may be considered handed over on the manifest's authority, and
* an empty substitute would drop provenance. A Map, not a plain object: a
* legacy note may legitimately be named `__proto__` (any store-valid
* relPath), and assigning that key on an ordinary object hits the prototype
* setter instead of creating an entry the serialization would carry — the
* note would then be re-adopted on every access. JSON.parse and
* Object.fromEntries create own properties, so the round-trip is exact.
*/
export async function readLegacyAdoptionManifest(
manifestPath: string,
options?: { strict?: boolean }
): Promise<Map<string, LegacyAdoptionRecord>> {
let raw: string;
try {
raw = await fsPromises.readFile(manifestPath, "utf-8");
} catch (error) {
const code = (error as NodeJS.ErrnoException | null)?.code;
if (options?.strict === true && code !== "ENOENT" && code !== "ENOTDIR") throw error;
return new Map();
}
const malformed = (detail: string): Map<string, LegacyAdoptionRecord> => {
if (options?.strict === true) {
throw new LegacyAdoptionManifestMalformedError(manifestPath, detail);
}
return new Map();
};
let parsed: unknown;
try {
parsed = JSON.parse(raw);
} catch {
return malformed("not JSON");
}
if (typeof parsed !== "object" || parsed === null || Array.isArray(parsed)) {
return malformed("not an object");
}
const entries: Array<[string, LegacyAdoptionRecord]> = [];
for (const [relPath, value] of Object.entries(parsed)) {
const record = parseLegacyAdoptionRecord(value);
if (record === null) return malformed(`record '${relPath}'`);
entries.push([relPath, record]);
}
return new Map(entries);
}

/**
* The file identity a LegacyAdoptionRecord.targetStamp records, or why there
* is none: "absent" only when the stat PROVES the path is gone (ENOENT /
* ENOTDIR); any other failure (EACCES, EIO) is "unreadable" — it says
* nothing about the path, so callers deciding on absence must refuse.
*/
export async function adoptionTargetPresence(
absPath: string
): Promise<{ stamp: string } | "absent" | "unreadable"> {
try {
const stat = await fsPromises.lstat(absPath, { bigint: true });
return { stamp: `${stat.ino}:${stat.size}:${stat.mtimeNs}` };
} catch (error) {
const code = (error as NodeJS.ErrnoException | null)?.code;
return code === "ENOENT" || code === "ENOTDIR" ? "absent" : "unreadable";
}
}

/**
* The file identity a LegacyAdoptionRecord.targetStamp records; null when the
* file cannot be stat'ed (the record then carries no stamp: preserved).
*/
export async function adoptionTargetStamp(absPath: string): Promise<string | null> {
const presence = await adoptionTargetPresence(absPath);
return typeof presence === "string" ? null : presence.stamp;
}
103 changes: 101 additions & 2 deletions src/node/services/memoryMeta.test.ts
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { describe, it, expect } from "bun:test";
import { describe, it, expect, spyOn } from "bun:test";
import { Effect } from "effect";

import * as fsPromises from "node:fs/promises";
import * as path from "node:path";
import { MemoryMetaService, memoryLogicalKey } from "./memoryMeta";
import { MemoryMetaService, MemoryMetaWriteError, memoryLogicalKey } from "./memoryMeta";
import { TestTempDir } from "./tools/testHelpers";
import { getErrorMessage } from "@/common/utils/errors";

describe("memoryLogicalKey", () => {
it("keys each scope by its stable identity, never the physical path", () => {
Expand Down Expand Up @@ -249,4 +250,102 @@ describe("MemoryMetaService", () => {
}
});
});
it("does not cache an empty view taken while the sidecar was unreadable", async () => {
using tempDir = new TestTempDir("test-memory-meta");
const service = new MemoryMetaService(tempDir.path);
await service.setPinned("global:prefs.md", true);
// Transient read failure (EACCES interval): this read heals to empty, but
// the next one must retry the file — not serve the empty view and then
// write it back over the real pins.
const reader = spyOn(fsPromises, "readFile").mockImplementationOnce((() =>
Promise.reject(Object.assign(new Error("EACCES"), { code: "EACCES" }))) as never);
const reloaded = new MemoryMetaService(tempDir.path);
expect(await reloaded.getPinnedKeys()).toEqual(new Set());
reader.mockRestore();
expect(await reloaded.getPinnedKeys()).toEqual(new Set(["global:prefs.md"]));
await reloaded.setPinned("workspace:ws-1:scratch.md", true);
expect(await new MemoryMetaService(tempDir.path).getPinnedKeys()).toEqual(
new Set(["global:prefs.md", "workspace:ws-1:scratch.md"])
);
});

it("refuses a mutation whose read of the sidecar failed instead of overwriting it", async () => {
using tempDir = new TestTempDir("test-memory-meta");
await new MemoryMetaService(tempDir.path).setPinned("global:prefs.md", true);
// The mutating call itself hits the transient failure: its healed empty
// view must not become the file, or every existing pin is erased.
const reader = spyOn(fsPromises, "readFile").mockImplementationOnce((() =>
Promise.reject(Object.assign(new Error("EACCES"), { code: "EACCES" }))) as never);
const fresh = new MemoryMetaService(tempDir.path);
try {
const failure = await fresh.setPinned("workspace:ws-1:scratch.md", true).then(
() => null,
(error: unknown) => error
);
expect(failure).toBeInstanceOf(MemoryMetaWriteError);
expect((failure as MemoryMetaWriteError).reason).toContain("could not be read");
} finally {
reader.mockRestore();
}
expect(await new MemoryMetaService(tempDir.path).getPinnedKeys()).toEqual(
new Set(["global:prefs.md"])
);
// Once readable again the same instance mutates normally.
await fresh.setPinned("workspace:ws-1:scratch.md", true);
expect(await new MemoryMetaService(tempDir.path).getPinnedKeys()).toEqual(
new Set(["global:prefs.md", "workspace:ws-1:scratch.md"])
);
// getEntriesOrThrow refuses the healed substitute a plain read serves.
const strict = new MemoryMetaService(tempDir.path);
const strictReader = spyOn(fsPromises, "readFile").mockImplementationOnce((() =>
Promise.reject(Object.assign(new Error("EACCES"), { code: "EACCES" }))) as never);
try {
const failure = await strict.getEntriesOrThrow().then(
() => null,
(error: unknown) => error
);
expect(getErrorMessage(failure)).toContain("could not be read");
} finally {
strictReader.mockRestore();
}
expect((await strict.getEntriesOrThrow()).has("global:prefs.md")).toBe(true);
});

it("mergeKeys folds a subtree into a second key, keeping the source", async () => {
using tempDir = new TestTempDir("test-memory-meta");
const service = new MemoryMetaService(tempDir.path);
// Child-keyed entries under one directory: one with no owner counterpart,
// one whose owner entry already has larger counters and its own pin.
await service.setPinned("workspace:ws-child:dir/only.md", true);
await service.recordAccess("workspace:ws-child:dir/both.md", { write: true });
await service.setPinned("workspace:ws-child:dir/both.md", true);
for (let i = 0; i < 3; i++) {
await service.recordAccess("workspace:ws-owner:dir/both.md", { write: false });
}
// A sibling whose key merely starts with the same characters is not in
// the subtree (segment-aware matching).
await service.setPinned("workspace:ws-child:dir-2/x.md", true);
await service.mergeKeys("workspace:ws-child:dir", "workspace:ws-owner:dir", {
pinned: "target",
});
let entries = await service.getEntries();
// Missing target: copied. Existing target: larger counters, its own pin.
expect(entries.get("workspace:ws-owner:dir/only.md")?.pinned).toBe(true);
expect(entries.get("workspace:ws-owner:dir/both.md")?.pinned).toBe(false);
expect(entries.get("workspace:ws-owner:dir/both.md")?.accessCount).toBe(3);
expect(entries.get("workspace:ws-owner:dir/both.md")?.lastWriteAt).not.toBeNull();
expect(entries.has("workspace:ws-owner:dir-2/x.md")).toBe(false);
// The source stays for a downgraded build, and the fold is idempotent.
expect(entries.get("workspace:ws-child:dir/only.md")?.pinned).toBe(true);
await service.mergeKeys("workspace:ws-child:dir", "workspace:ws-owner:dir", {
pinned: "target",
});
expect(await service.getEntries()).toEqual(entries);
// `pinned: "source"`: the child's pin overrides the owner's.
await service.mergeKeys("workspace:ws-child:dir/both.md", "workspace:ws-owner:dir/both.md", {
pinned: "source",
});
entries = await service.getEntries();
expect(entries.get("workspace:ws-owner:dir/both.md")?.pinned).toBe(true);
});
});
Loading
Loading