Skip to content

Commit 60cf7a4

Browse files
committed
feat(profiles): gate subagent launches on session profile authority
Require corroborated session profile authority before subagent_run, subagent_continue and every launch stage. Test session fakes now expose getSessionFile like real session managers. Refs #1064
1 parent d2ee5b8 commit 60cf7a4

4 files changed

Lines changed: 326 additions & 7 deletions

File tree

‎extensions/gentle-agents.ts‎

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,8 @@ import { resolveVisualSettings } from "../lib/visual-customization-policy.ts";
2727
import { createCompletionQueue } from "../lib/agents-completion-delivery.ts";
2828
import { createAgentMessageQueue, type PendingAgentMessage } from "../lib/agents-message-delivery.ts";
2929
import { AGENT_MODE, discoverAgents, formatModelRef, loadAgentsConfig, resolveAgentProfile, withPinnedModelProfiles, type AgentDefinition, type AgentMode } from "../lib/agents-config.ts";
30-
import { readSessionProfileBinding, sessionOrPinModelProfiles } from "../lib/session-profile-binding.ts";
30+
import { sessionOrPinModelProfiles } from "../lib/session-profile-binding.ts";
31+
import { requireSessionProfileAuthority } from "../lib/session-profile-authority.ts";
3132
import { resolveBackgroundSubagentsPolicy } from "../lib/background-subagents-policy.ts";
3233
import { installBackgroundCacheWarming } from "../lib/background-cache-warming.ts";
3334
import { isFinished, MISSING_TOOLS_NOTE_PREFIX, TASK_EVENT, TASK_STATUS, TaskStore, type AskRequest, type TaskRecord } from "../lib/agents-protocol.ts";
@@ -1542,7 +1543,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
15421543
// the same session layer and can never disagree about it (#1558: a bound
15431544
// session used to kill its own non-git writer mid-preparation because
15441545
// admission still read only the pin/global layers).
1545-
const sessionBinding = readSessionProfileBinding(ctx.sessionManager.getSessionId());
1546+
const sessionBinding = requireSessionProfileAuthority(ctx.sessionManager).binding;
15461547
if (!resume && isGenericBoundedWriter(agent.name) && surfaces?.some(isDevelopmentSurface) && !deps.resolveWorktree(originalCwd, originalCwd) && repositoryRoot === undefined) {
15471548
const root = safeBootstrapDirectory(originalCwd);
15481549
if (!root || (workspaceRoot !== undefined && (!isAbsolute(workspaceRoot) || safeBootstrapDirectory(workspaceRoot) !== root))) throw new Error("Writer bootstrap requires the original safe project root.");
@@ -1559,6 +1560,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
15591560
admittedModel = `${catalogModel.provider}/${catalogModel.id}`;
15601561
if (!current() || !await prepareBoundSessionRepository(originalManager, originalCwd, signal) || !current() || safeBootstrapDirectory(originalCwd) !== root || resolveSessionWorktree(originalCwd, originalCwd)?.root !== root) throw new Error("Writer repository preparation was unavailable or its session/target changed.");
15611562
}
1563+
requireSessionProfileAuthority(originalManager);
15621564
const parentCwd = ctx.sessionManager.getCwd();
15631565
// An explicit target is validated before any queue or session-dir writes.
15641566
const parentIdentity = deps.resolveWorktree(parentCwd, parentCwd);
@@ -1602,6 +1604,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
16021604
// with the same wholesale-replacement contract as the pin. The binding is
16031605
// resolved here, at task-request creation, so queued and running children
16041606
// keep the routing frozen into their requests even if the session rebinds.
1607+
requireSessionProfileAuthority(originalManager);
16051608
const config = withPinnedModelProfiles(
16061609
loadAgentsConfig(roots(ctx)),
16071610
sessionOrPinModelProfiles(
@@ -1626,6 +1629,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
16261629
foreignGrants.assertCurrent(ctx, identity);
16271630
}
16281631
if (signal?.aborted) throw new Error("Subagent launch aborted before queueing.");
1632+
requireSessionProfileAuthority(originalManager);
16291633
mkdirSync(sessionDir, { recursive: true });
16301634
const parentSessionManager = ctx.sessionManager as unknown as ReviewSessionManager;
16311635
const parentSessionId = ctx.sessionManager.getSessionId() ?? "";
@@ -2037,6 +2041,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
20372041
},
20382042
},
20392043
async (params, ctx, signal) => {
2044+
requireSessionProfileAuthority(ctx.sessionManager);
20402045
const work = Object.hasOwn(params, "work") ? decodeWorkDescriptor(params.work) : undefined;
20412046
const manager = ctx.sessionManager, sessionId = manager.getSessionId();
20422047
if (typeof params.agent !== "string" || !params.agent.trim() || typeof params.task !== "string" || !params.task.trim() || (params.context !== undefined && typeof params.context !== "string") || (params.label !== undefined && typeof params.label !== "string")) throw new Error("Subagent dispatch requires a named agent, non-empty task and string context/label.");
@@ -2122,6 +2127,7 @@ export default function gentleAgents(pi: ExtensionAPI, env: NodeJS.ProcessEnv =
21222127
"Resume a finished subagent task in its own session with a follow-up prompt.",
21232128
{ required: ["task_id", "prompt"], properties: { task_id: { type: "string" }, prompt: { type: "string" }, label: { type: "string", description: "Three to six words naming the follow-up." }, mode: { type: "string", enum: ["task", "background"] } } },
21242129
async (params, ctx, signal) => {
2130+
requireSessionProfileAuthority(ctx.sessionManager);
21252131
if (Object.hasOwn(params, "sdd_change") || Object.hasOwn(params, "remediation") || Object.hasOwn(params, "research_selection")) return text("Error: retired SDD delegation is not supported.", { error: "retired SDD delegation" });
21262132
const previous = await resolveTask(String(params.task_id));
21272133
if (!previous) return unknownTask(params.task_id, ctx);

‎lib/session-profile-authority.ts‎

Lines changed: 107 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,107 @@
1+
// Current admission/publication bridge. Controller evidence stays in the
2+
// integration owner; this module owns no quarantine, capability or cache.
3+
import { existsSync } from "node:fs";
4+
import { readSessionProfileDisk } from "./session-profile-disk-reader.ts";
5+
import {
6+
readCurrentSessionProfileOutcome,
7+
type SessionProfileLifecycleManager,
8+
} from "./session-profile-integration.ts";
9+
import type { SessionProfileAppendOutcome } from "./session-profile-append-controller.ts";
10+
import {
11+
bindSessionProfile,
12+
clearSessionProfileBinding,
13+
readSessionProfileBinding,
14+
type SessionProfileBinding,
15+
} from "./session-profile-binding.ts";
16+
17+
export type SessionProfileAuthority =
18+
| { available: true; binding?: SessionProfileBinding }
19+
| { available: false };
20+
21+
/** Publish only the returned detached snapshot, including preserved disk state
22+
* on append failure. Terminal invalid/future records never expose a fallback.
23+
*/
24+
export function publishSessionProfileOutcome(
25+
manager: SessionProfileLifecycleManager,
26+
outcome: SessionProfileAppendOutcome,
27+
): SessionProfileAuthority {
28+
const id = manager.getSessionId();
29+
const state = outcome.state;
30+
if (state.status === "bound") {
31+
bindSessionProfile(id, state.binding.name, state.binding.modelProfiles);
32+
return { available: true, binding: readSessionProfileBinding(id) };
33+
}
34+
clearSessionProfileBinding(id);
35+
return state.status === "absent" || state.status === "cleared"
36+
? { available: true }
37+
: { available: false };
38+
}
39+
40+
function noProfile(manager: SessionProfileLifecycleManager): boolean {
41+
const entries = manager.getBranch();
42+
return (
43+
Array.isArray(entries) &&
44+
!entries.some((entry) => {
45+
if (!entry || typeof entry !== "object") return true;
46+
const value = entry as { type?: unknown; customType?: unknown };
47+
return (
48+
value.type === "custom" &&
49+
typeof value.customType === "string" &&
50+
value.customType.startsWith("gentle-pi.session-profile/")
51+
);
52+
})
53+
);
54+
}
55+
56+
/** Load-order independent read. Without an owner, disk is read-only authority;
57+
* with an owner, every request revalidates its existing negative evidence.
58+
* An ordinary empty/preflush branch with no profile meaning retains its legacy
59+
* routing. Missing disk never restores a profile found only in memory.
60+
*/
61+
export function readSessionProfileAuthority(
62+
manager: SessionProfileLifecycleManager,
63+
): SessionProfileAuthority {
64+
try {
65+
const id = manager.getSessionId(),
66+
file = manager.getSessionFile?.();
67+
if (!id) return { available: false };
68+
const owned = readCurrentSessionProfileOutcome(manager);
69+
const outcome = owned ?? {
70+
status: "persisted" as const,
71+
state: readSessionProfileDisk(manager),
72+
};
73+
const empty =
74+
outcome.state.status === "indeterminate" &&
75+
["missing-source", "unreadable-file"].includes(outcome.state.reason) &&
76+
noProfile(manager) &&
77+
(file === undefined || !existsSync(file));
78+
if (manager.getSessionId() !== id || manager.getSessionFile?.() !== file)
79+
return { available: false };
80+
if (empty) {
81+
if (owned) clearSessionProfileBinding(id);
82+
return {
83+
available: true,
84+
binding: owned ? undefined : readSessionProfileBinding(id),
85+
};
86+
}
87+
return publishSessionProfileOutcome(manager, outcome);
88+
} catch {
89+
try {
90+
clearSessionProfileBinding(manager.getSessionId());
91+
} catch {
92+
/* Unobservable manager. */
93+
}
94+
return { available: false };
95+
}
96+
}
97+
98+
export function requireSessionProfileAuthority(
99+
manager: SessionProfileLifecycleManager,
100+
): SessionProfileAuthority & { available: true } {
101+
const authority = readSessionProfileAuthority(manager);
102+
if (!authority.available)
103+
throw new Error(
104+
"Session profile authority unavailable; affected launches are blocked until corroborated state or an explicit selection is available.",
105+
);
106+
return authority;
107+
}

‎tests/gentle-agents.test.ts‎

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ function fakeContext(tui: { requestRender(): void } = fakeTui, confirmResult: (t
201201
cwd,
202202
hasUI: true,
203203
mode: "tui",
204-
sessionManager: { getSessionId: () => "s1", getCwd: () => cwd, getEntries: () => [], getBranch: () => [] },
204+
sessionManager: { getSessionId: () => "s1", getCwd: () => cwd, getEntries: () => [], getBranch: () => [], getSessionFile: () => undefined },
205205
ui: {
206206
notify: (message: string) => dialogs.push(`notify:${message}`),
207207
custom: (factory: (tui: unknown, theme: unknown, keybindings: unknown, done: (value: unknown) => void) => Overlay, options: unknown) =>
@@ -3982,8 +3982,8 @@ test("the card follows the active session: after /new the earlier session's task
39823982
await tools.get("subagent_run")!.execute("c1", { agent: "explore", task: "Long job", mode: "background" }, undefined, undefined, ctx);
39833983
await tick();
39843984
assert.match(widget()![1], /◐ explore Long job/);
3985-
const sessions = ctx as unknown as { sessionManager: { getSessionId(): string; getCwd(): string; getBranch(): []; getEntries(): [] } };
3986-
sessions.sessionManager = { getSessionId: () => "s2", getCwd: () => cwd, getBranch: () => [], getEntries: () => [] };
3985+
const sessions = ctx as unknown as { sessionManager: { getSessionId(): string; getCwd(): string; getBranch(): []; getEntries(): []; getSessionFile(): undefined } };
3986+
sessions.sessionManager = { getSessionId: () => "s2", getCwd: () => cwd, getBranch: () => [], getEntries: () => [], getSessionFile: () => undefined };
39873987
await fire("session_start", ctx, { type: "session_start", reason: "new" });
39883988
assert.deepEqual(widget(), [], "the new session starts with an empty card");
39893989
assert.match((await tools.get("subagent_list_tasks")!.execute("c2", {}, undefined, undefined, ctx)).content[0].text, /No subagent tasks in this session/);
@@ -3995,7 +3995,7 @@ test("the card follows the active session: after /new the earlier session's task
39953995
assert.doesNotMatch(overlay.render(80).map(stripAnsi).join("\n"), /◐ Subagent explore/, "retained children of a replaced session do not imply an open orchestrator");
39963996
overlay.handleInput("\x1b");
39973997
await opened;
3998-
sessions.sessionManager = { getSessionId: () => "s1", getCwd: () => cwd, getBranch: () => [], getEntries: () => [] };
3998+
sessions.sessionManager = { getSessionId: () => "s1", getCwd: () => cwd, getBranch: () => [], getEntries: () => [], getSessionFile: () => undefined };
39993999
await fire("session_start", ctx, { type: "session_start", reason: "resume" });
40004000
assert.match(widget()![1], /◐ explore Long job/, "resuming the first session shows its task again");
40014001
});
@@ -5800,7 +5800,7 @@ test("a subdirectory session cwd and workspace_root of the same worktree share o
58005800
const env: NodeJS.ProcessEnv = {};
58015801
gentleAgents(h.pi, env, { ...runtime.deps, resolveWorktree, env, agentHome: profile });
58025802
const { ctx } = fakeContext();
5803-
Object.assign(ctx, { cwd: sub, sessionManager: { getSessionId: () => "s1", getCwd: () => sub, getEntries: () => [], getBranch: () => [] } });
5803+
Object.assign(ctx, { cwd: sub, sessionManager: { getSessionId: () => "s1", getCwd: () => sub, getEntries: () => [], getBranch: () => [], getSessionFile: () => undefined } });
58045804
await h.fire("session_start", ctx);
58055805
const task = "Write it.\n\n## Allowed edit surfaces\nsrc/app.ts\n\n## Return\nReport";
58065806
try {

0 commit comments

Comments
 (0)