Skip to content
69 changes: 56 additions & 13 deletions packages/coding-agent/src/modes/daemon/daemon-supervisor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3181,23 +3181,66 @@ export class DaemonSupervisor {

private assertSavedSiblingNameAvailable(siblings: SessionInfo[], target: SessionInfo, name: string): void {
const setDepth = target.rlmDepth ?? siblings.find((sibling) => sibling.rlmDepth !== undefined)?.rlmDepth ?? 0;
const targetPath = canonicalSessionPath(target.path);
const parentSessionPath = target.parentSessionPath ? canonicalSessionPath(target.parentSessionPath) : undefined;
if (setDepth <= 0 || !parentSessionPath) {
throw new Error("Saved sibling catalog has no direct parent");
}

// catalog.siblings() is deliberately bounded: it returns the child set, not
// its parent. Preserve that boundary while supplying a local structural
// anchor to Core03's immutable exact-one-parent catalog validation. Reject
// ambiguous persisted rows rather than letting a malformed saved catalog
// weaken a name reservation.
const parentId = `saved-sibling-parent:${parentSessionPath}`;
const ids = new Set<string>();
const paths = new Set<string>();
let targetCount = 0;
for (const sibling of siblings) {
const siblingPath = canonicalSessionPath(sibling.path);
const siblingParentPath = sibling.parentSessionPath
? canonicalSessionPath(sibling.parentSessionPath)
: undefined;
if (
ids.has(sibling.id) ||
paths.has(siblingPath) ||
sibling.id === parentId ||
siblingParentPath !== parentSessionPath ||
Comment thread
sethkarten marked this conversation as resolved.
(sibling.rlmDepth !== undefined && sibling.rlmDepth !== setDepth)
) {
throw new Error("Saved sibling catalog is structurally ambiguous");
}
ids.add(sibling.id);
paths.add(siblingPath);
if (sibling.id === target.id && siblingPath === targetPath) targetCount += 1;
}
if (targetCount !== 1) {
throw new Error("Saved sibling catalog does not contain its target");
}

assertAgentSessionNameAvailable(
siblings.map((info) => {
const summary = summaryForInactiveSession(info);
return {
id: summary.sessionId,
...(summary.sessionName ? { name: summary.sessionName } : {}),
depth: setDepth,
status: classifySessionRosterStatus(summary),
...(summary.parentSessionPath
? { parentSessionPath: canonicalSessionPath(summary.parentSessionPath) }
: {}),
};
}),
[
{
id: parentId,
depth: setDepth - 1,
status: "inactive" as const,
sessionPath: parentSessionPath,
},
...siblings.map((info) => {
const summary = summaryForInactiveSession(info);
return {
id: summary.sessionId,
...(summary.sessionName ? { name: summary.sessionName } : {}),
depth: setDepth,
status: classifySessionRosterStatus(summary),
parentSessionPath,
};
}),
],
{
name,
depth: setDepth,
parentSessionPath: target.parentSessionPath ? canonicalSessionPath(target.parentSessionPath) : undefined,
parentSessionPath,
Comment thread
sethkarten marked this conversation as resolved.
ignoreSessionId: target.id,
},
);
Expand Down
101 changes: 101 additions & 0 deletions packages/coding-agent/test/agent-messages.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,101 @@
import { describe, expect, it } from "vitest";
import {
AGENT_FAMILY_REACH_ERROR,
assertAgentFamilyReach,
buildAgentFamilyRoster,
} from "../src/core/agent-messages.js";

describe("agent message structural family validation", () => {
it("excludes malformed family edges while retaining catalog-resolved depth-two siblings", () => {
const root = { id: "root", depth: 0, status: "running" as const, sessionPath: "/root" };
const otherRoot = { id: "other-root", depth: 0, status: "running" as const, sessionPath: "/other" };
const child = {
id: "child",
depth: 1,
status: "idle" as const,
parentSessionPath: "/root",
sessionPath: "/child",
};
const malformedRoot = {
id: "malformed-root",
depth: 0,
status: "idle" as const,
parentSessionId: "root",
parentSessionPath: "/root",
};
const contradictoryChild = {
id: "contradictory-child",
depth: 1,
status: "idle" as const,
parentSessionId: "root",
parentSessionPath: "/other",
};
const depthSkippingDescendant = {
id: "depth-skipping-descendant",
depth: 2,
status: "idle" as const,
parentSessionId: "root",
parentSessionPath: "/root",
};
const malformedDeepSiblingA = {
id: "malformed-deep-sibling-a",
depth: 2,
status: "idle" as const,
parentSessionId: "root",
parentSessionPath: "/root",
};
const malformedDeepSiblingB = {
id: "malformed-deep-sibling-b",
depth: 2,
status: "idle" as const,
parentSessionId: "root",
parentSessionPath: "/root",
};
const catalog = [
root,
child,
malformedRoot,
contradictoryChild,
depthSkippingDescendant,
malformedDeepSiblingA,
malformedDeepSiblingB,
];

// A root carrying a parent claim, contradictory dual claims, and a skipped
// depth must not become a direct family edge.
for (const malformed of [malformedRoot, contradictoryChild, depthSkippingDescendant]) {
expect(() => assertAgentFamilyReach(root, malformed, catalog)).toThrow(AGENT_FAMILY_REACH_ERROR);
expect(() => assertAgentFamilyReach(malformed, root, catalog)).toThrow(AGENT_FAMILY_REACH_ERROR);
}
expect(() => assertAgentFamilyReach(otherRoot, contradictoryChild, catalog)).toThrow(AGENT_FAMILY_REACH_ERROR);

// Two malformed depth-two rows that claim the root are not pseudo-siblings,
// and neither leaks into a roster.
expect(() => assertAgentFamilyReach(malformedDeepSiblingA, malformedDeepSiblingB, catalog)).toThrow(
AGENT_FAMILY_REACH_ERROR,
);
expect(buildAgentFamilyRoster(malformedDeepSiblingA, catalog).entries).toEqual([]);
expect(buildAgentFamilyRoster(root, catalog).entries.map((entry) => entry.id)).toEqual(["child"]);

// A real depth-one parent in the supplied catalog restores legitimate
// depth-two siblings without weakening the malformed-edge exclusions above.
const deepParent = { id: "deep-parent", depth: 1, status: "running" as const, sessionPath: "/deep-parent" };
const deepSiblingA = {
id: "deep-sibling-a",
depth: 2,
status: "idle" as const,
parentSessionId: "deep-parent",
parentSessionPath: "/deep-parent",
};
const deepSiblingB = {
id: "deep-sibling-b",
depth: 2,
status: "idle" as const,
parentSessionPath: "/deep-parent",
};
const deepCatalog = [deepParent, deepSiblingA, deepSiblingB];
expect(() => assertAgentFamilyReach(deepSiblingA, deepSiblingB)).toThrow(AGENT_FAMILY_REACH_ERROR);
expect(assertAgentFamilyReach(deepSiblingA, deepSiblingB, deepCatalog)).toBe("sibling");
expect(assertAgentFamilyReach(deepSiblingB, deepSiblingA, deepCatalog)).toBe("sibling");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -313,6 +313,42 @@ describe("daemon supervisor passive subagent topology", () => {
);
});

it("fails closed when a saved sibling catalog has conflicting identity or topology", () => {
const directory = mkdtempSync(join(tmpdir(), "prime-supervisor-saved-sibling-conflict-"));
tempDirs.push(directory);
const parentSessionPath = join(directory, "parent.jsonl");
const base = {
cwd: directory,
created: new Date(0),
modified: new Date(0),
messageCount: 0,
firstMessage: "",
allMessagesText: "",
parentSessionPath,
rlmDepth: 1,
};
const target = { ...base, id: "target", path: join(directory, "target.jsonl") };
const sibling = { ...base, id: "sibling", path: join(directory, "sibling.jsonl"), name: "taken" };
const supervisor = new DaemonSupervisor(join(directory, "daemon.sock"), {
defaultSessionConfig: { agentDir: directory, cwd: directory },
descriptorDir: join(directory, "workers"),
}) as unknown as SupervisorInternals;

for (const conflictingSiblings of [
[target, { ...sibling, id: target.id }],
[target, { ...sibling, path: target.path }],
[target, { ...sibling, parentSessionPath: join(directory, "other-parent.jsonl") }],
[target, { ...sibling, rlmDepth: 2 }],
]) {
expect(() => supervisor.assertSavedSiblingNameAvailable(conflictingSiblings, target, "taken")).toThrow(
"Saved sibling catalog is structurally ambiguous",
);
}
expect(() => supervisor.assertSavedSiblingNameAvailable([sibling], target, "taken")).toThrow(
"Saved sibling catalog does not contain its target",
);
});

it("publishes an opening reservation before named create validation awaits", async () => {
const directory = mkdtempSync(join(tmpdir(), "prime-supervisor-named-create-race-"));
tempDirs.push(directory);
Expand Down