Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
101 commits
Select commit Hold shift + click to select a range
4049e6d
🤖 feat: share workspace memory notes across a sub-agent task tree
ThomasK33 Sep 8, 2026
0145db8
🤖 fix: address Codex review on shared sub-agent memory
ThomasK33 Sep 8, 2026
71493b3
🤖 fix: address second Codex round on shared sub-agent memory
ThomasK33 Sep 8, 2026
0c87b76
🤖 fix: address third Codex round on shared sub-agent memory
ThomasK33 Sep 8, 2026
b5370de
🤖 fix: address fourth Codex round on shared sub-agent memory
ThomasK33 Sep 8, 2026
17af514
🤖 fix: address fifth Codex round on shared sub-agent memory
ThomasK33 Sep 8, 2026
bb45838
🤖 fix: address sixth Codex round on shared sub-agent memory
ThomasK33 Sep 8, 2026
6921a48
🤖 fix: address seventh Codex round on shared sub-agent memory
ThomasK33 Sep 8, 2026
340239d
🤖 fix: address eighth Codex round on shared sub-agent memory
ThomasK33 Sep 8, 2026
08ef9f2
🤖 fix: address ninth Codex round on shared sub-agent memory
ThomasK33 Sep 8, 2026
31cca37
🤖 fix: lint (non-null assertion in shared-memory row migration)
ThomasK33 Sep 8, 2026
5e3f111
🤖 fix: address tenth Codex round on shared sub-agent memory
ThomasK33 Sep 8, 2026
9f36514
🤖 fix: address eleventh Codex round on shared sub-agent memory
ThomasK33 Sep 8, 2026
16682a2
🤖 fix: address twelfth Codex round on shared sub-agent memory
ThomasK33 Sep 8, 2026
0cc6910
🤖 fix: address thirteenth Codex round on shared sub-agent memory
ThomasK33 Sep 8, 2026
1a0234d
🤖 fix: address fourteenth Codex round on shared sub-agent memory
ThomasK33 Sep 8, 2026
802a4ca
🤖 fix: lint (nullish assignment / optional chain in owner pinning)
ThomasK33 Sep 8, 2026
4292d17
🤖 fix: address fifteenth Codex round on shared sub-agent memory
ThomasK33 Sep 8, 2026
8500c5a
🤖 fix: address sixteenth Codex round on shared sub-agent memory
ThomasK33 Sep 8, 2026
55e23e3
🤖 fix: address seventeenth Codex round on shared sub-agent memory
ThomasK33 Sep 8, 2026
df301b3
🤖 fix: address eighteenth Codex round on shared sub-agent memory
ThomasK33 Sep 8, 2026
2383632
🤖 fix: address nineteenth Codex round on shared sub-agent memory
ThomasK33 Sep 8, 2026
78bedbe
🤖 fix: address twentieth Codex round on shared sub-agent memory
ThomasK33 Sep 8, 2026
ddceece
🤖 fix: address twenty-first Codex round on shared sub-agent memory
ThomasK33 Sep 8, 2026
8a6f2db
🤖 fix: address twenty-second Codex round on shared sub-agent memory
ThomasK33 Sep 8, 2026
829666e
🤖 fix: address twenty-third Codex round on shared sub-agent memory
ThomasK33 Sep 8, 2026
6cf04ee
🤖 fix: lint (emitChange / notifyExternalMutation are synchronous again)
ThomasK33 Sep 8, 2026
0adbd6d
🤖 fix: address twenty-fourth Codex round on shared sub-agent memory
ThomasK33 Sep 8, 2026
029e9e2
🤖 fix: address twenty-fifth Codex round on shared sub-agent memory
ThomasK33 Sep 8, 2026
6cbdd84
🤖 fix: address twenty-sixth Codex round on shared sub-agent memory
ThomasK33 Sep 8, 2026
25a06cf
🤖 fix: address twenty-seventh Codex round on shared sub-agent memory
ThomasK33 Sep 8, 2026
5489cf0
🤖 fix: address twenty-eighth Codex round on shared sub-agent memory
ThomasK33 Sep 9, 2026
99f20a1
🤖 fix: address twenty-ninth Codex round on shared sub-agent memory
ThomasK33 Sep 9, 2026
c4e9c4b
🤖 fix: address thirtieth Codex round on shared sub-agent memory
ThomasK33 Sep 9, 2026
2918865
🤖 fix: address thirty-first Codex round on shared sub-agent memory
ThomasK33 Sep 9, 2026
ef11a3b
🤖 style: format memoryService.ts
ThomasK33 Sep 9, 2026
8038ae4
🤖 fix: address thirty-second Codex round on shared sub-agent memory
ThomasK33 Sep 9, 2026
76794a3
🤖 fix: address thirty-third Codex round on shared sub-agent memory
ThomasK33 Sep 9, 2026
0af1ff6
🤖 fix: address thirty-fourth Codex round on shared sub-agent memory
ThomasK33 Sep 9, 2026
e1a7c5e
🤖 fix: address thirty-fifth Codex round on shared sub-agent memory
ThomasK33 Sep 9, 2026
369f9e3
🤖 fix: address thirty-sixth Codex round on shared sub-agent memory
ThomasK33 Sep 9, 2026
7dba2c9
🤖 fix: address thirty-seventh Codex round on shared sub-agent memory
ThomasK33 Sep 9, 2026
94d719f
🤖 fix: address thirty-eighth Codex round on shared sub-agent memory
ThomasK33 Sep 9, 2026
435dfbd
🤖 fix: address thirty-ninth Codex round on shared sub-agent memory
ThomasK33 Sep 9, 2026
9d62467
🤖 fix: address fortieth Codex round on shared sub-agent memory
ThomasK33 Sep 9, 2026
0cab99a
🤖 fix: address forty-first Codex round on shared sub-agent memory
ThomasK33 Sep 9, 2026
9443392
🤖 fix: address forty-second Codex round on shared sub-agent memory
ThomasK33 Sep 9, 2026
fb6f593
🤖 fix: address forty-third Codex round on shared sub-agent memory
ThomasK33 Sep 9, 2026
cf4c884
🤖 fix: keep removal of unknown workspaces idempotent on a fresh root
ThomasK33 Sep 9, 2026
f86659a
🤖 fix: address forty-fourth Codex round on shared sub-agent memory
ThomasK33 Sep 9, 2026
b400dd5
🤖 chore: merge origin/main into workspace-sharing-xcph
ThomasK33 Sep 9, 2026
e7c214a
🤖 fix: address forty-fifth Codex round on shared sub-agent memory
ThomasK33 Sep 9, 2026
b157b20
🤖 fix: address forty-sixth Codex round on shared sub-agent memory
ThomasK33 Sep 9, 2026
d2dd83b
🤖 fix: address forty-seventh Codex round on shared sub-agent memory
ThomasK33 Sep 9, 2026
58cabb3
🤖 fix: address forty-eighth Codex round on shared sub-agent memory
ThomasK33 Sep 9, 2026
09c8068
🤖 fix: address forty-ninth Codex round on shared sub-agent memory
ThomasK33 Sep 9, 2026
2ca7f1a
🤖 fix: address fiftieth Codex round on shared sub-agent memory
ThomasK33 Sep 9, 2026
6b32c8d
🤖 fix: address fifty-first Codex round on shared sub-agent memory
ThomasK33 Sep 9, 2026
ae8d9d5
🤖 fix: address fifty-second Codex round on shared sub-agent memory
ThomasK33 Sep 9, 2026
3fb79ba
🤖 fix: address fifty-third Codex round on shared sub-agent memory
ThomasK33 Sep 9, 2026
2f2bfd4
🤖 fix: address fifty-fourth Codex round on shared sub-agent memory
ThomasK33 Sep 9, 2026
736791e
🤖 fix: address fifty-fifth Codex round on shared sub-agent memory
ThomasK33 Sep 9, 2026
b7bee85
🤖 fix: address fifty-sixth Codex round on shared sub-agent memory
ThomasK33 Sep 9, 2026
e57ca7a
🤖 fix: address fifty-seventh Codex round on shared sub-agent memory
ThomasK33 Sep 9, 2026
6778f65
🤖 fix: address fifty-eighth Codex round on shared sub-agent memory
ThomasK33 Sep 10, 2026
8fe7e9f
🤖 fix: address fifty-ninth Codex round on shared sub-agent memory
ThomasK33 Sep 10, 2026
4460d8a
🤖 fix: address sixtieth Codex round on shared sub-agent memory
ThomasK33 Sep 10, 2026
b2e8c69
🤖 fix: address sixty-first Codex round on shared sub-agent memory
ThomasK33 Sep 10, 2026
caf2c6a
🤖 fix: address sixty-second Codex round on shared sub-agent memory
ThomasK33 Sep 10, 2026
fec9766
🤖 fix: address sixty-third Codex round on shared sub-agent memory
ThomasK33 Sep 10, 2026
18a8f99
🤖 fix: address sixty-fourth Codex round on shared sub-agent memory
ThomasK33 Sep 10, 2026
13e4525
🤖 fix: address sixty-fifth Codex round on shared sub-agent memory
ThomasK33 Sep 10, 2026
7d2a32e
🤖 fix: address sixty-sixth Codex round on shared sub-agent memory
ThomasK33 Sep 10, 2026
eb70048
🤖 fix: address sixty-seventh Codex round on shared sub-agent memory
ThomasK33 Sep 10, 2026
5a6f3a4
🤖 fix: address sixty-eighth Codex round on shared sub-agent memory
ThomasK33 Sep 10, 2026
efc46a8
🤖 fix: address sixty-ninth Codex round on shared sub-agent memory
ThomasK33 Sep 10, 2026
cce8f19
Merge remote-tracking branch 'origin/main' into workspace-sharing-xcph
ThomasK33 Sep 10, 2026
c433acf
🤖 fix: address seventieth Codex round on shared sub-agent memory
ThomasK33 Sep 10, 2026
042664b
🤖 fix: address seventy-first Codex round on shared sub-agent memory
ThomasK33 Sep 10, 2026
85c5600
🤖 fix: address seventy-second Codex round on shared sub-agent memory
ThomasK33 Sep 10, 2026
d086582
🤖 fix: address seventy-third Codex round on shared sub-agent memory
ThomasK33 Sep 10, 2026
5afeec3
🤖 fix: address seventy-fourth Codex round on shared sub-agent memory
ThomasK33 Sep 10, 2026
f096b6b
🤖 fix: address seventy-fifth Codex round on shared sub-agent memory
ThomasK33 Sep 10, 2026
b3c98e1
🤖 fix: address seventy-sixth Codex round on shared sub-agent memory
ThomasK33 Sep 10, 2026
ceba4a7
🤖 fix: address seventy-seventh Codex round on shared sub-agent memory
ThomasK33 Sep 10, 2026
aa3aa30
🤖 fix: address seventy-eighth Codex round on shared sub-agent memory
ThomasK33 Sep 10, 2026
36ee55a
🤖 fix: address seventy-ninth Codex round on shared sub-agent memory
ThomasK33 Sep 10, 2026
5bd3061
🤖 fix: address eightieth Codex round on shared sub-agent memory
ThomasK33 Sep 11, 2026
edacdb1
Merge remote-tracking branch 'origin/main' into workspace-sharing-xcph
ThomasK33 Sep 11, 2026
8383b85
🤖 fix: address eighty-first Codex round on shared sub-agent memory
ThomasK33 Sep 11, 2026
a22b8fe
🤖 fix: address eighty-second Codex round on shared sub-agent memory
ThomasK33 Sep 11, 2026
e5cb003
🤖 fix: address eighty-third Codex round on shared sub-agent memory
ThomasK33 Sep 11, 2026
9aa1bde
🤖 tests: drop the workflow card's placeholder sequence in the auto-re…
ThomasK33 Sep 11, 2026
48735dd
🤖 fix: address eighty-fourth Codex round on shared sub-agent memory
ThomasK33 Sep 11, 2026
1f2453f
🤖 fix: address eighty-fifth Codex round on shared sub-agent memory
ThomasK33 Sep 11, 2026
0099ff0
🤖 fix: address eighty-sixth Codex round on shared sub-agent memory
ThomasK33 Sep 11, 2026
42e3e36
🤖 fix: address eighty-seventh Codex round on shared sub-agent memory
ThomasK33 Sep 11, 2026
1061c45
🤖 fix: address eighty-eighth Codex round on shared sub-agent memory
ThomasK33 Sep 11, 2026
5ba3627
🤖 fix: address eighty-ninth Codex round on shared sub-agent memory
ThomasK33 Sep 11, 2026
ba9ebc7
🤖 fix: address ninetieth Codex round on shared sub-agent memory
ThomasK33 Sep 11, 2026
15ea436
🤖 fix: address ninety-first Codex round on shared sub-agent memory
ThomasK33 Sep 11, 2026
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
49 changes: 43 additions & 6 deletions src/cli/debug/refinements.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ import { afterEach, describe, expect, it, spyOn } from "bun:test";

import * as fsPromises from "node:fs/promises";
import * as path from "node:path";
import { Config } from "@/node/config";
import { appendRefinementEvent } from "@/node/services/refinement/refinementJournal";
import { TestTempDir } from "@/node/services/tools/testHelpers";
import { refinementsCommand } from "./refinements";
Expand All @@ -11,8 +12,14 @@ import { refinementsCommand } from "./refinements";
* created, inside a `<root>/sessions/<ws>` layout so the confinement roots
* resolve like a real mux home.
*/
async function seedFixture(root: string): Promise<{ sessionDir: string; skillFile: string }> {
async function seedFixture(
root: string
): Promise<{ sessionDir: string; skillFile: string; config: Config }> {
const sessionDir = path.join(root, "sessions", "ws-cli");
// Rollback resolves shared-memory ownership from a config.json that must
// exist (an absent one reads as mid-rewrite): persist the empty default.
const config = new Config(root);
await config.editConfig((cfg) => cfg);
const skillFile = path.join(root, "checkout", ".mux", "skills", "cli-skill", "SKILL.md");
await fsPromises.mkdir(path.dirname(skillFile), { recursive: true });
await fsPromises.writeFile(skillFile, "---\nname: cli-skill\n---\n", "utf-8");
Expand All @@ -24,7 +31,7 @@ async function seedFixture(root: string): Promise<{ sessionDir: string; skillFil
inverse: { op: "delete-files", paths: [skillFile] },
evidence: { toolName: "agent_skill_write" },
});
return { sessionDir, skillFile };
return { sessionDir, skillFile, config };
}

describe("debug refinements command", () => {
Expand All @@ -37,7 +44,7 @@ describe("debug refinements command", () => {

it("lists rows and performs a rollback with lineage output", async () => {
using tempDir = new TestTempDir("test-debug-refinements");
const { sessionDir, skillFile } = await seedFixture(tempDir.path);
const { sessionDir, skillFile, config } = await seedFixture(tempDir.path);
const lines: string[] = [];
const logSpy = spyOn(console, "log").mockImplementation((line: string) => {
lines.push(line);
Expand All @@ -50,7 +57,7 @@ describe("debug refinements command", () => {
const rowId = lines[0].split(" ")[0];

lines.length = 0;
await refinementsCommand("ws-cli", { sessionDir, rollback: rowId });
await refinementsCommand("ws-cli", { sessionDir, config, rollback: rowId });
// Earlier test files in the same process may have reset exitCode to 0,
// so assert "not failing" rather than "never touched".
expect(process.exitCode ?? 0).toBe(0);
Expand All @@ -74,19 +81,49 @@ describe("debug refinements command", () => {

it("reports refusals on stderr and sets a failing exit code", async () => {
using tempDir = new TestTempDir("test-debug-refinements-refuse");
const { sessionDir } = await seedFixture(tempDir.path);
const { sessionDir, config } = await seedFixture(tempDir.path);
const logSpy = spyOn(console, "log").mockImplementation(() => undefined);
const errors: string[] = [];
const errorSpy = spyOn(console, "error").mockImplementation((line: string) => {
errors.push(line);
});
try {
await refinementsCommand("ws-cli", { sessionDir, rollback: "missing-id" });
await refinementsCommand("ws-cli", { sessionDir, config, rollback: "missing-id" });
expect(process.exitCode).toBe(1);
expect(errors.join("\n")).toContain("No refinement row");
} finally {
logSpy.mockRestore();
errorSpy.mockRestore();
}
});

it("refuses a rollback while config.json is absent instead of assuming self-ownership", async () => {
using tempDir = new TestTempDir("test-debug-refinements-noconfig");
const { sessionDir, skillFile, config } = await seedFixture(tempDir.path);
const lines: string[] = [];
const logSpy = spyOn(console, "log").mockImplementation((line: string) => {
lines.push(line);
});
const errors: string[] = [];
const errorSpy = spyOn(console, "error").mockImplementation((line: string) => {
errors.push(line);
});
try {
await refinementsCommand("ws-cli", { sessionDir });
const rowId = lines[0].split(" ")[0];
await fsPromises.rm(path.join(tempDir.path, "config.json"));
await refinementsCommand("ws-cli", { sessionDir, config, rollback: rowId });
expect(process.exitCode).toBe(1);
expect(errors.join("\n")).toContain("shared-memory ownership could not be resolved");
expect(
await fsPromises.access(skillFile).then(
() => true,
() => false
)
).toBe(true);
} finally {
logSpy.mockRestore();
errorSpy.mockRestore();
}
});
});
33 changes: 32 additions & 1 deletion src/cli/debug/refinements.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,10 @@
import * as path from "path";
import { defaultConfig } from "@/node/config";
import { defaultConfig, type Config } from "@/node/config";
import {
resolveSharedWorkspaceMemoryTopology,
type SharedWorkspaceMemoryTopology,
} from "@/node/services/memoryWorkspaceOwner";
import { getErrorMessage } from "@/common/utils/errors";
import {
MemoryRefinementActionSchema,
RollbackRefinementActionSchema,
Expand Down Expand Up @@ -37,6 +42,8 @@ export interface RefinementsCommandOptions {
force?: boolean;
/** Test seam: bypass ~/.mux session resolution for fixture sessions. */
sessionDir?: string;
/** Test seam: the config whose task tree resolves shared-memory ownership. */
config?: Pick<Config, "loadExistingConfigOrThrow" | "sessionsDir">;
}

/**
Expand All @@ -50,8 +57,32 @@ export async function refinementsCommand(
const sessionDir = opts.sessionDir ?? path.join(defaultConfig.sessionsDir, workspaceId);

if (opts.rollback !== undefined) {
// Sub-agents journal workspace-scope rows that point into the owner's
// session dir; admit that root the same way the in-app tool does, from a
// config that must EXIST and read: a tolerant (or fresh-install) view
// would resolve a sub-agent to ITSELF, and the rollback would then mutate
// its hidden legacy notebook (no owner root, no adoption remap) and
// report success. Throws → the command fails before touching anything.
const config = opts.config ?? defaultConfig;
let topology: SharedWorkspaceMemoryTopology;
try {
topology = resolveSharedWorkspaceMemoryTopology(config, workspaceId);
} catch (error) {
console.error(
`Refusing rollback of '${opts.rollback}': shared-memory ownership could not be resolved (${getErrorMessage(error)})`
);
process.exitCode = 1;
return;
}
const result = await rollbackRefinement({
sessionDir,
sharedWorkspaceMemorySessionDir: topology.ownerSessionDir,
// Reloaded per check (plan-time and in-lock), not from the snapshot
// above: a live backend may register a new tree member while this
// process waits for the shared-store lock, and its rows must count.
// Same existence-requiring load: an unproven tree refuses the rollback.
listSharedWorkspaceMemoryPeerSessionDirs: () =>
resolveSharedWorkspaceMemoryTopology(config, workspaceId).peerSessionDirs,
id: opts.rollback,
force: opts.force,
evidence: { toolName: "debug-cli", actor: "user" },
Expand Down
11 changes: 10 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 All @@ -18,6 +19,14 @@ export const MEMORY_VIRTUAL_ROOT = "/memories";
export const MEMORY_SCOPES = ["global", "project", "workspace"] as const;
export type MemoryScope = (typeof MEMORY_SCOPES)[number];

/**
* `<sessionsDir>/<owner>/memory.revision`: opaque token rewritten on every
* mutation of that owner's shared `/memories/workspace` store. Sessions and
* Memory tabs in OTHER backend processes (multi-instance) compare it before
* reusing a cached index/hot set — in-process consumers get change events.
*/
export const WORKSPACE_MEMORY_REVISION_FILE_NAME = "memory.revision";

export type MemoryAccessLevel = "read" | "readwrite";

/**
Expand Down
8 changes: 8 additions & 0 deletions src/common/orpc/schemas/memory.ts
Original file line number Diff line number Diff line change
Expand Up @@ -96,10 +96,12 @@ export type MemoryConsolidationRecordPayload = z.infer<typeof MemoryConsolidatio

export const CompactionCompletionMetadataSchema = z.object({
workspaceId: z.string(),
workspaceMemoryWritable: z.boolean().optional(),
summaryMessageId: z.string(),
summaryHistorySequence: z.number(),
compactionEpoch: z.number(),
previousBoundaryHistorySequence: z.number().optional(),
closingPolicyEpoch: z.number().optional(),
compactionRequestMessageId: z.string(),
// RLM keep-recent floor: preserved-tail copies appended after the boundary.
preservedTailMessageCount: z.number().optional(),
Expand All @@ -115,6 +117,12 @@ export const MemoryHarvestRecordSchema = z.object({
acceptedCandidates: z.number(),
skippedCandidates: z.number(),
error: z.string().optional(),
/**
* Terminal refusal (policy unknown/read-only, or a turn of the epoch never
* recorded its policy): unlike an exhausted failure, the epoch's owner
* notebook must not be swept on its behalf either.
*/
refused: z.boolean().optional(),
usage: z.object({ inputTokens: z.number(), outputTokens: z.number() }).optional(),
completionMetadata: CompactionCompletionMetadataSchema.optional(),
});
Expand Down
8 changes: 8 additions & 0 deletions src/common/schemas/project.ts
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,14 @@ export const WorkspaceConfigSchema = z.object({
description:
"If set, this workspace is a child workspace spawned from the parent workspaceId (enables nesting in UI and backend orchestration).",
}),
workspaceMemoryWritableByEpoch: z.record(z.string(), z.boolean()).optional().meta({
description:
"Whether this workspace's agent may write /memories/workspace, accumulated (fail-closed AND over its normal turns) per compaction epoch — keyed by the history sequence of the durable context boundary that opened the epoch (-1 before any boundary); only the newest few epochs are kept. Persisted so a post-compaction memory harvest that resumes in a fresh session (restart, recovery) or observes a closing epoch while another backend already records the next one still knows the policy; harvest fails closed when unknown.",
}),
memoryOwnerWorkspaceId: z.string().optional().meta({
description:
"Memory owner pinned when an intermediate ancestor was removed while this descendant stayed alive: the parentWorkspaceId chain no longer reaches the task-tree root, so this keeps /memories/workspace bound to the root's store (memoryWorkspaceOwner.ts). Set only by workspace removal.",
}),
agentType: z.string().optional().meta({
description: 'If set, selects an agent preset for this workspace (e.g., "explore" or "exec").',
}),
Expand Down
16 changes: 16 additions & 0 deletions src/common/types/compaction.ts
Original file line number Diff line number Diff line change
@@ -1,9 +1,25 @@
export interface CompactionCompletionMetadata {
workspaceId: string;
/**
* Whether the workspace's agent may write `/memories/workspace` (its memory
* access policy on the last non-compaction turn). Post-compaction harvest
* writes candidates into that store — for a sub-agent, the OWNER's shared
* notebook — so a read-only (explore-like) agent must not harvest. Absent on
* legacy records; only an explicit `false` refuses.
*/
workspaceMemoryWritable?: boolean;
summaryMessageId: string;
summaryHistorySequence: number;
compactionEpoch: number;
previousBoundaryHistorySequence?: number;
/**
* The workspace-memory policy epoch the compacted rows belonged to
* (workspaceMemoryPolicyEpochOf over the compacted history): the key their
* turns recorded under and the one the harvest and the policy carry read.
* Absent on records persisted by builds before the history segment stamp
* (compactionClosingPolicyEpoch derives their legacy identity).
*/
closingPolicyEpoch?: number;
compactionRequestMessageId: string;
/**
* RLM keep-recent floor: number of preserved-tail copies appended after the
Expand Down
88 changes: 88 additions & 0 deletions src/common/types/durableEvent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,45 @@ export const RefinementDataSchema = z.object({
evidence: JsonValueSchema.optional(),
/** Envelope `id` of the entry this one rolls back. */
rollbackOf: z.string().optional(),
/**
* Stable source identity (`<workspaceId>:<row id>`) when this row was
* copied from a removed sub-agent's journal into its memory owner's
* (sharedMemoryRowMigration.ts); lets a retried migration skip it.
*/
migratedFrom: z.string().optional(),
/**
* Where a migrated row was ORIGINALLY appended: the journal (workspace id)
* whose append sequence positions it, and its `seq` there. Two rows of one
* origin were serialized by that store's mutation lock (clock + append run
* inside it), so their origin sequence IS their mutation order — even when
* neither carries a usable clock value (pre-sharing history, a failed clock
* write). Copies are appended to the owner journal later than they happened
* and in migration order, so their own `seq` is no order evidence; a
* copy-of-copy keeps the first origin. Absent on a copy (older builds,
* corruption) = order unknown, never the copying journal's position.
* Native rows need no fields: their origin is (`workspaceId`, `seq`).
*/
originJournal: z.string().optional(),
originSeq: z.number().optional(),
/**
* Cross-session order key for rollback conflict detection (`sourceTs ?? ts`):
* a shared workspace store's monotonic clock, advanced under the store's
* mutation lock by every mutation (workspaceMemoryRevision.ts), so rows in
* an owner's and its sub-agents' journals — whose `ts`/`seq` are not
* comparable — still order totally; migrated rows keep their source value.
* Persisted rows are raw JSON: only a value isValidSourceClock accepts is
* order evidence; a present value outside that domain reads as order
* unknown (refinementRollback.ts), never as "earlier than everything".
*/
sourceTs: z.number().optional(),
Comment thread
ThomasK33 marked this conversation as resolved.
/**
* The mutation landed but the shared store's clock write failed, so this
* row has NO defensible position relative to other rows (its `ts`/`seq`
* are journal-local). Rollback conflict detection treats such a row as
* conflicting with every overlapping row in either direction (force
* overrides), instead of ordering it by an incomparable timestamp.
*/
orderUnknown: z.literal(true).optional(),
/** Expected post-action file hashes (RefinementPostStateSchema in refinement.ts). */
postState: JsonValueSchema.optional(),
/**
Expand Down Expand Up @@ -176,3 +215,52 @@ type DistributiveOmit<T, K extends PropertyKey> = T extends unknown ? Omit<T, K>
export type DurableEventDraft = DistributiveOmit<DurableEvent, "v" | "seq" | "id" | "ts"> & {
id?: string;
};

/**
* A usable shared-store clock value: the clock is `max(Date.now(), prev + 1)`
* (workspaceMemoryRevision.ts), so a genuine value is a positive safe integer.
* Zero, negatives, fractions, unsafe integers and non-numbers are corruption.
*/
export function isValidSourceClock(value: unknown): value is number {
return typeof value === "number" && Number.isSafeInteger(value) && value > 0;
}

/** Where a refinement row was originally appended (see `originJournal`). */
export interface RefinementRowOrigin {
journal: string;
seq: number;
}

/**
* The journal position that orders a refinement row against rows of the same
* origin (RefinementDataSchema.originJournal). A native row is positioned by
* the journal it was READ from (`journalWorkspaceId`, the session's workspace
* id — the caller's knowledge, never the row's own `workspaceId`, which is
* persisted data a corrupt row could carry equal to another journal's; r79)
* and only while the row agrees with it. A migrated copy is positioned only
* by a carried, well-formed origin. Anything else is no order evidence (null).
*/
export function refinementRowOrigin(
row: {
workspaceId: string;
seq: number;
data: { migratedFrom?: string; originJournal?: unknown; originSeq?: unknown };
},
journalWorkspaceId: string | undefined
): RefinementRowOrigin | null {
if (row.data.migratedFrom === undefined) {
if (journalWorkspaceId === undefined || row.workspaceId !== journalWorkspaceId) return null;
return { journal: journalWorkspaceId, seq: row.seq };
}
const { originJournal, originSeq } = row.data;
if (
typeof originJournal !== "string" ||
originJournal === "" ||
typeof originSeq !== "number" ||
!Number.isSafeInteger(originSeq) ||
originSeq < 0
) {
return null;
}
return { journal: originJournal, seq: originSeq };
}
36 changes: 36 additions & 0 deletions src/common/types/message.ts
Original file line number Diff line number Diff line change
Expand Up @@ -963,7 +963,31 @@ export interface ContextBudgetRejectedMessage {
export interface MuxMetadata {
/** Highest persisted history sequence included in the provider request that produced this assistant. */
requestHistorySequence?: number;
/**
* The compaction epoch (opening boundary's history sequence, -1 before any
* boundary) under which the turn that produced this assistant row recorded
* its workspace-memory write policy, before the row was appended
* (TurnRequestBuilder start()). The post-compaction harvest accepts a turn
* only when this matches the epoch being harvested: a turn started before
* a destructive reset but appended after the new boundary carries the old
* epoch, whose deny the reset discarded. Builds that do not maintain the
* policy (older ones, after a downgrade) leave it unset, so their turns'
* user rows are never taken as accounted for (memoryConsolidationService.ts).
*/
workspaceMemoryPolicyEpoch?: number;
historySequence?: number; // Assigned by backend for global message ordering (required when writing to history)
/**
* Start sequence of the history segment this row was appended in
* (HistoryService, `history-segment.json`). A full clear opens a new
* segment whose sequences continue above every sequence the cleared
* history ever used, so a boundary's history sequence — and the
* boundary-less epoch identity `-(segmentStart + 1)` derived from this
* stamp (workspaceMemoryPolicyEpochOf) — never recurs across destructive
* clears: a turn started before a clear cannot be mistaken for one of the
* segment that replaced it. Omitted in the first segment (start 0), which
* legacy rows without the stamp belong to as well.
*/
historySegment?: number;
/** Provider step boundaries in parts, persisted so continuous compaction can keep complete steps. */
stepStartPartIndices?: number[];
duration?: number;
Expand Down Expand Up @@ -1081,6 +1105,18 @@ export interface MuxMetadata {
*/
rlmPreservedTailCopy?: boolean;

/**
* Compaction epoch (opening boundary's history sequence, -1 before any) the
* copied row was ORIGINALLY produced under — carried unchanged through
* repeated copies, so a copy of a copy still names the first epoch. The
* workspace-memory write policy of that epoch applies to the copy
* (TurnRequestBuilder → WorkspaceService.recordWorkspaceMemoryWritable):
* derived from history it would be wrong whenever the active-epoch read
* holds only the newest boundary. Absent on copies written before the
* field existed, whose source epochs never had a policy record either.
*/
rlmPreservedTailSourcePolicyEpoch?: number;

/**
* @file mention snapshot token(s) this message provides content for.
* Marks send-time materialized snapshot rows (the only @mention expansion
Expand Down
Loading
Loading