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
3 changes: 2 additions & 1 deletion src/common/constants/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,8 @@
* per-project notes; never committed to the repo, survives
* workspaces; carried by the settings backup only when the
* user opts into the project bundle)
* - workspace -> <sessionDir>/memory/ (host-local, deleted with the workspace)
* - workspace -> <sessionDir>/memory/ of the task-tree OWNER (host-local, deleted
* with that workspace; sub-agents share their parent's store)
*/

/** Virtual root prefix all memory paths are expressed under. */
Expand Down
2 changes: 1 addition & 1 deletion src/common/utils/tools/toolDefinitions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2511,7 +2511,7 @@ export const TOOL_DEFINITIONS = {
"Scopes (all paths are virtual):\n" +
"- /memories/global/... — personal, permanent, shared across all projects\n" +
"- /memories/project/... — private notes about this project; host-local, never committed to the repo (included in the settings backup only when the user opts in), survives workspaces\n" +
"- /memories/workspace/... — scratch state for this workspace; deleted with the workspace\n" +
"- /memories/workspace/... — scratch state for this workspace, shared with its sub-agents (a sub-agent reads and writes its parent's workspace notes); deleted with the owning workspace\n" +
"Commands:\n" +
"- view: list a directory (up to 2 levels, dotfiles excluded) or show a file with line numbers (offset/limit supported)\n" +
"- create: create a new file; ERRORS if the file already exists (to overwrite: delete first, then create)\n" +
Expand Down
22 changes: 21 additions & 1 deletion src/node/config/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -884,6 +884,11 @@ interface ConfigLoadFailureState {
// re-log the same corrupt-config error once per instance.
const configLoadFailureStates = new Map<string, ConfigLoadFailureState>();

/** Location of config.json under a Xum root (also used by callers that must stat it without a Config). */
export function configFilePath(rootDir: string): string {
return path.join(rootDir, "config.json");
}

function configLoadFailureState(configFile: string): ConfigLoadFailureState {
let state = configLoadFailureStates.get(configFile);
if (!state) {
Expand Down Expand Up @@ -934,6 +939,21 @@ export class Config {
readonly sessionsDir: string;
readonly srcDir: string;
private readonly configFile: string;
/**
* Cheap durable change signal for config.json: one stat, no parse. Any
* backend's rewrite changes size/mtime (atomic replace also changes the
* inode), so a memo built against a previous stamp knows to rebuild —
* unlike onConfigChanged, which only fires for THIS process's edits. A
* missing/unreadable file yields a distinct stamp.
*/
configFileStamp(): string {
try {
const st = fs.statSync(this.configFile, { bigint: true });
return `${st.dev}:${st.ino}:${st.size}:${st.mtimeNs}`;
} catch {
return "missing";
}
}
private readonly providersConfigStore: ProvidersConfigStore;
private readonly emitter = new EventEmitter();
/**
Expand Down Expand Up @@ -969,7 +989,7 @@ export class Config {
this.rootDir = sessionLocator.rootDir;
this.sessionsDir = sessionLocator.sessionsDir;
this.srcDir = sessionLocator.srcDir;
this.configFile = path.join(this.rootDir, "config.json");
this.configFile = configFilePath(this.rootDir);
this.providersConfigStore = providersConfigStore ?? new ProvidersConfigStore(this.rootDir);
}

Expand Down
71 changes: 70 additions & 1 deletion src/node/orpc/routerSubscriptions.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,11 @@ import { TestClock } from "effect/testing";
import { SUBSCRIPTION_HEARTBEAT_INTERVAL_MS } from "@/common/utils/withQueueHeartbeat";
import { disposeAppRuntime, makeAppRuntime } from "@/node/services/di/appRuntime";
import type { ORPCContext } from "./context";
import { subscribeWorkspaceActivity, subscribeDesignExperiment } from "./routerSubscriptions";
import {
subscribeWorkspaceActivity,
subscribeDesignExperiment,
subscribeMemoryChanges,
} from "./routerSubscriptions";

test("subscription handlers forward the oRPC runtime Clock", async () => {
const app = makeAppRuntime(TestClock.layer());
Expand All @@ -37,6 +41,71 @@ test("subscription handlers forward the oRPC runtime Clock", async () => {
expect(workspaceService.listenerCount("activity")).toBe(0);
});

test("memory subscriptions match workspace-scope events on the shared memory owner", async () => {
// Workspace-scope change events carry the memory OWNER (task-tree root);
// a sub-agent's subscription must see edits to the notebook it shares.
const app = makeAppRuntime(TestClock.layer());
const memoryService = new EventEmitter();
const memoryConsolidationService = new EventEmitter();
const controller = new AbortController();
const ownerOf = new Map([["ws-child", "ws-owner"]]);
const context = {
"effect/context": app.context,
workspaceService: { getInfo: () => Promise.resolve(null) },
memoryService: Object.assign(memoryService, {
resolveWorkspaceMemoryOwnerId: (workspaceId: string) =>
ownerOf.get(workspaceId) ?? workspaceId,
}),
memoryConsolidationService,
} as unknown as ORPCContext;
const stream = subscribeMemoryChanges(context, "ws-child", controller.signal);
try {
const first = stream.next();
// The listener attaches once the generator has started running.
while (memoryService.listenerCount("change") === 0) {
await new Promise((resolve) => setTimeout(resolve, 0));
}
const base = {
scope: "workspace",
path: "/memories/workspace/n.md",
actor: "agent",
projectPath: "",
};
memoryService.emit("change", { ...base, workspaceId: "ws-other" });
memoryService.emit("change", { ...base, workspaceId: "ws-child" });
memoryService.emit("change", { ...base, workspaceId: "ws-owner" });
// Global scope is never filtered: it marks the end of the batch, so
// receiving it second proves the other/child events were dropped.
const marker = { ...base, scope: "global", path: "/memories/global/g.md", workspaceId: "" };
memoryService.emit("change", marker);
expect((await first).value).toEqual({ ...base, workspaceId: "ws-owner" });
expect((await stream.next()).value).toEqual(marker);

// Ownership change for THIS workspace (owner removed): synthesized
// root-addressed refresh + status refresh, now addressed to the new owner.
ownerOf.set("ws-child", "ws-child");
memoryService.emit("ownersInvalidated", ["ws-unrelated"]);
memoryService.emit("ownersInvalidated", ["ws-child"]);
expect((await stream.next()).value).toEqual({
scope: "workspace",
path: "/memories/workspace",
actor: "user",
workspaceId: "ws-child",
projectPath: "",
});
expect((await stream.next()).value).toEqual({
kind: "consolidation_status",
workspaceId: "ws-child",
projectPath: "",
});
} finally {
controller.abort();
await stream.return(undefined);
await disposeAppRuntime(app.managed);
}
expect(memoryService.listenerCount("change")).toBe(0);
});

test("Design subscriptions publish sibling changes only after client shutdown", async () => {
using temp = new DisposableTempDir("design-subscription");
const flags = path.join(temp.path, EXPERIMENT_OVERRIDES_FILE_NAME);
Expand Down
31 changes: 30 additions & 1 deletion src/node/orpc/routerSubscriptions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,7 @@ import type { LogEntry } from "@/node/services/logBuffer";
import { subscribeLogFeed } from "@/node/services/logBuffer";
import {
resolveMemoryProjectIdentity,
toVirtualPath,
type MemoryChangeEvent,
} from "@/node/services/memoryService";

Expand Down Expand Up @@ -207,20 +208,48 @@ export function subscribeMemoryChanges(
validate?.();
const metadata = workspaceId ? await context.workspaceService.getInfo(workspaceId) : null;
const projectPath = metadata ? resolveMemoryProjectIdentity(metadata) : null;
// Workspace-scope events carry the memory OWNER (task-tree root), which is
// also the store this subscriber's workspace displays. Resolved per event
// (memoized, cheap): the owner can change while the tab stays open — a
// removed owner makes the child fall back to its own store.
yield* runtimeSubscription(context, {
signal,
subscribe: (emit) => {
const onChange = (event: MemoryChangeEvent) => {
if (event.scope === "workspace" && event.workspaceId !== workspaceId) return;
if (
event.scope === "workspace" &&
event.workspaceId !==
(workspaceId
? context.memoryService.resolveWorkspaceMemoryOwnerId(workspaceId)
: null)
)
return;
if (event.scope === "project" && event.projectPath !== projectPath) return;
emit.push(event);
};
const onStatusChange = (event: MemoryConsolidationStatusChangeEventPayload) =>
emit.push(event);
// Ownership changed for this workspace (its owner was removed; it
// now reads its own store). No mutation event accompanies a removal,
// so synthesize a root-addressed refresh for the file list and the
// consolidation status, both of which described the old store.
const onOwnersInvalidated = (workspaceIds: string[]) => {
if (!workspaceId || !workspaceIds.includes(workspaceId)) return;
emit.push({
scope: "workspace",
path: toVirtualPath("workspace", ""),
actor: "user",
workspaceId: context.memoryService.resolveWorkspaceMemoryOwnerId(workspaceId),
projectPath: projectPath ?? "",
});
emit.push({ kind: "consolidation_status", workspaceId, projectPath: projectPath ?? "" });
};
context.memoryService.on("change", onChange);
context.memoryService.on("ownersInvalidated", onOwnersInvalidated);
context.memoryConsolidationService.on("statusChange", onStatusChange);
return () => {
context.memoryService.off("change", onChange);
context.memoryService.off("ownersInvalidated", onOwnersInvalidated);
context.memoryConsolidationService.off("statusChange", onStatusChange);
};
},
Expand Down
49 changes: 48 additions & 1 deletion src/node/services/agentSession.memoryContext.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,8 @@ function createSession(args: {
interface PrivateSessionAccess {
resolveMemoryContext: (
modelString: string,
options?: Parameters<AIService["buildMemorySessionContext"]>[2]
options?: Parameters<AIService["buildMemorySessionContext"]>[2],
cache?: Map<string, unknown>
) => Promise<MemorySessionContext | undefined>;
getPostCompactionAttachmentsIfNeeded: () => Promise<unknown>;
}
Expand Down Expand Up @@ -127,6 +128,52 @@ describe("AgentSession memory context", () => {
expect(await priv.resolveMemoryContext("test-model")).toEqual(context);
expect(await priv.resolveMemoryContext("test-model")).toEqual(context);
expect(buildMemorySessionContext).toHaveBeenCalledTimes(1);

// A write by another task-tree member to the shared workspace notebook
// invalidates from outside; the next resolve rebuilds from disk.
session.invalidateMemoryContext();
expect(await priv.resolveMemoryContext("test-model")).toEqual(context);
expect(buildMemorySessionContext).toHaveBeenCalledTimes(2);
} finally {
await session.dispose();
}
});

test("does not cache a context whose build overlapped an invalidation", async () => {
using sessionDir = new DisposableTempDir("agent-session-memory-context-race");
const { historyService, cleanup } = await createTestHistoryService();
historyCleanup = cleanup;

let release!: () => void;
const gate = new Promise<void>((resolve) => (release = resolve));
const stale: MemorySessionContext = { indexEntries: [], hotMemoriesBlock: "<hot>stale</hot>" };
const fresh: MemorySessionContext = { indexEntries: [], hotMemoriesBlock: "<hot>fresh</hot>" };
let calls = 0;
const buildMemorySessionContext = mock(async () => {
calls++;
if (calls === 1) await gate;
return calls === 1 ? stale : fresh;
});
const session = createSession({
historyService,
sessionDir: path.join(sessionDir.path, WORKSPACE_ID),
buildMemorySessionContext,
});
const priv = session as unknown as PrivateSessionAccess;

try {
// A rollover candidate builds into its own staged map.
const staged = new Map();
const building = priv.resolveMemoryContext("test-model", undefined, staged);
// A sibling session writes the shared notebook mid-build: the files
// the build read are already stale.
session.invalidateMemoryContext();
release();
expect(await building).toEqual(stale);
// Served once for the request that needed it, but never cached.
expect(staged.size).toBe(0);
expect(await priv.resolveMemoryContext("test-model")).toEqual(fresh);
expect(buildMemorySessionContext).toHaveBeenCalledTimes(2);
} finally {
await session.dispose();
}
Expand Down
46 changes: 38 additions & 8 deletions src/node/services/agentSession.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1049,6 +1049,24 @@ export class AgentSession {
* prompt-cache-stable bytes without preserving stale files forever.
*/
private memoryContextByModelString = new Map<string, CachedMemoryContext>();

/**
* Drop the cached memory context so the next stream rebuilds the index and
* hot set from disk. Own memory tool calls clear it on tool-call-end; this
* entry point is for writes by OTHER sessions to a store this session also
* reads (a sub-agent editing the task tree's shared workspace notes).
*/
invalidateMemoryContext(): void {
this.memoryContextByModelString.clear();
Comment thread
ThomasK33 marked this conversation as resolved.
// A build already awaiting buildMemorySessionContext read the pre-write
// files, and a rollover candidate stages its cache in a separate map that
// is installed later: bumping the generation stops both from
// (re)populating the cache with the stale snapshot.
this.memoryContextGeneration++;
}

private memoryContextGeneration = 0;

/**
* Cache the last-known experiment state so we don't spam metadata refresh
* when post-compaction context is disabled.
Expand Down Expand Up @@ -5490,6 +5508,7 @@ export class AgentSession {
message: "Full request preparation is unavailable; use /compact or restart.",
});
const cache = new Map<string, CachedMemoryContext>();
const cacheGeneration = this.memoryContextGeneration;
// Admission must not pause the goal yet, but the pinned tools must match the later manual pause.
let prospectiveGoalStatusForToolAvailability: StreamMessageOptions["prospectiveGoalStatusForToolAvailability"];
if (manualIntervention && this.workspaceGoalService) {
Expand Down Expand Up @@ -5593,7 +5612,14 @@ export class AgentSession {
: prepared;
return Ok({
start: (startOptions) => {
this.memoryContextByModelString = cache;
// A shared-notebook write by another tree session while this
// candidate was prepared invalidated the installed map only; the
// staged one is then unavoidably stale for this request and must
// not be reused by later turns.
this.memoryContextByModelString =
cacheGeneration === this.memoryContextGeneration
? cache
: new Map<string, CachedMemoryContext>();
return prepared.data.start(startOptions);
},
[Symbol.asyncDispose]: () => prepared.data[Symbol.asyncDispose](),
Expand Down Expand Up @@ -10367,6 +10393,7 @@ export class AgentSession {
return cached.context ?? undefined;
}

const generation = this.memoryContextGeneration;
// Guard for test mocks that may not implement buildMemorySessionContext.
const context =
typeof this.aiService.buildMemorySessionContext === "function"
Expand All @@ -10375,13 +10402,16 @@ export class AgentSession {
tokenBudgetActive,
})
: null;
cache.set(modelString, {
context,
includesHotMemories: includeHotMemories,
tokenBudgetActive,
memoryEnabled,
hotSetEnabled,
});
// Invalidated mid-build: serve this snapshot once, do not cache it.
if (generation === this.memoryContextGeneration) {
cache.set(modelString, {
context,
includesHotMemories: includeHotMemories,
tokenBudgetActive,
memoryEnabled,
hotSetEnabled,
});
}
return context ?? undefined;
}

Expand Down
21 changes: 20 additions & 1 deletion src/node/services/di/layers/core.ts
Original file line number Diff line number Diff line change
Expand Up @@ -57,7 +57,7 @@ import { MCPConfigService } from "@/node/services/mcpConfigService";
import { MCPServerManager } from "@/node/services/mcpServerManager";
import { MemoryConsolidationService } from "@/node/services/memoryConsolidationService";
import { MemoryMetaService } from "@/node/services/memoryMeta";
import { MemoryService } from "@/node/services/memoryService";
import { MemoryService, type MemoryChangeEvent } from "@/node/services/memoryService";
import { ProviderService } from "@/node/services/providerService";
import { SessionUsageService } from "@/node/services/sessionUsageService";
import { StreamManager } from "@/node/services/streamManager";
Expand Down Expand Up @@ -573,6 +573,25 @@ export const CoreWiringLive: Layer.Layer<
workspaceService.emitWorkflowRunActivity(event);
turnRequestBuilderBindings.workflowResultContinuationSender = workspaceService;
workspaceService.setMemoryConsolidationService(memoryConsolidationService);
// 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) => {
if (event.scope !== "workspace" || event.workspaceId === "") return;
// One config snapshot for the whole pass: cold owner lookups would
// otherwise parse the config once per live session, synchronously.
let cfg: ReturnType<typeof config.loadConfigOrDefault> | undefined;
const snapshot = () => (cfg ??= config.loadConfigOrDefault());
workspaceService.invalidateMemoryContextWhere(
(workspaceId) =>
memoryService.resolveWorkspaceMemoryOwnerId(workspaceId, snapshot) === event.workspaceId
Comment thread
ThomasK33 marked this conversation as resolved.
);
});
// Ownership itself changed (an owner was removed while its sub-agents
// live on): those sessions' cached contexts still describe the old store.
memoryService.on("ownersInvalidated", (workspaceIds: string[]) => {
const affected = new Set(workspaceIds);
workspaceService.invalidateMemoryContextWhere((workspaceId) => affected.has(workspaceId));
});
if (opts.devToolsService) {
// DevTools debug-log cleanup when workspaces are archived/removed.
workspaceService.setDevToolsService(opts.devToolsService);
Expand Down
Loading
Loading