Skip to content
88 changes: 73 additions & 15 deletions packages/coding-agent/src/modes/daemon/daemon-supervisor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -338,6 +338,12 @@ function throwIfAdmissionCancelled(admission: SupervisorPromptAdmission | undefi
if (admission?.status === "cancelled") throw new PromptAdmissionCancelledError();
}

/** Match catalog sibling topology: relative parents are rooted at each child session file. */
function canonicalSavedSiblingParentPath(session: Pick<SessionInfo, "path" | "parentSessionPath">): string | undefined {
if (!session.parentSessionPath) return undefined;
return canonicalSessionPath(resolve(dirname(session.path), session.parentSessionPath));
}

class SupervisorRecoveryCancelledError extends Error {
readonly code = "supervisor_recovery_cancelled" as const;
}
Expand Down Expand Up @@ -2083,7 +2089,9 @@ export class DaemonSupervisor {
(session) => canonicalSessionPath(session.path) === canonicalSessionPath(createCommand.sessionPath!),
);
const targetSummary = target ? summaryForInactiveSession(target) : { sessionId: "new-root", rlmDepth: 0 };
const reservation = this.summaryNameReservationInput(targetSummary, createCommand.name);
const reservation = target
? this.savedSiblingNameReservationInput(target, savedSiblings, createCommand.name)
: this.summaryNameReservationInput(targetSummary, createCommand.name);
return this.withSessionNameReservation(reservation, async () => {
if (target?.parentSessionPath && (target.rlmDepth ?? 0) > 0) {
this.assertSavedSiblingNameAvailable(savedSiblings, target, createCommand.name!);
Expand Down Expand Up @@ -3180,10 +3188,19 @@ export class DaemonSupervisor {
const siblings = await this.catalog.siblings(sessionPath, sessionDir ?? this.defaultSessionConfig.sessionDir);
const saved = siblings.find((info) => canonicalSessionPath(info.path) === targetPath);
if (!saved) throw new Error(`Session not found: ${sessionPath}`);
return this.savedSiblingNameReservationInput(saved, siblings, name);
}

private savedSiblingNameReservationInput(
saved: SessionInfo,
siblings: readonly SessionInfo[],
name: string,
): { name: string; depth: number; parentSessionId?: string; parentSessionPath?: string } {
const parentSessionPath = canonicalSavedSiblingParentPath(saved);
return {
name,
depth: saved.rlmDepth ?? siblings.find((sibling) => sibling.rlmDepth !== undefined)?.rlmDepth ?? 0,
parentSessionPath: saved.parentSessionPath,
...(parentSessionPath ? { parentSessionPath } : {}),
};
}

Expand Down Expand Up @@ -3222,23 +3239,64 @@ 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 = canonicalSavedSiblingParentPath(target);
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 = canonicalSavedSiblingParentPath(sibling);
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");
});
});
Loading