Skip to content
Merged
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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Daemon- and runtime-hosted subagents record their spawn lineage again (the production runtime factory dropped it), and a compaction summary slice that resolves after a sibling already failed the compaction settles as failed instead of staying in-flight forever.
11 changes: 10 additions & 1 deletion packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7558,6 +7558,7 @@ export class AgentSession {
const semanticCompaction = this._semanticEdges.beginCompaction();
let compactionRecorded = false;
const uncommittedSlices: string[] = [];
let compactionSettled = false;
let summary: string;
let firstKeptEntryId: string;
let tokensBefore: number;
Expand Down Expand Up @@ -7599,7 +7600,13 @@ export class AgentSession {
}
try {
const result = await call({ ...headers, ...modelRequestHeaders(requestId) });
uncommittedSlices.push(requestId);
// A slice resolving after a sibling's rejection already settled the
// compaction would push into a drained list and stay in-flight forever.
if (compactionSettled) {
this._semanticEdges.failRequest(requestId);
} else {
uncommittedSlices.push(requestId);
}
return result;
} catch (error) {
this._semanticEdges.failRequest(requestId);
Expand All @@ -7626,6 +7633,7 @@ export class AgentSession {
// commits it. Marked first: the ID is consumed even when the write throws, and a
// second finish attempt would mask the original I/O error.
compactionRecorded = true;
compactionSettled = true;
for (const requestId of uncommittedSlices.splice(0)) {
this._semanticEdges.finishRequest(requestId);
}
Expand All @@ -7639,6 +7647,7 @@ export class AgentSession {
customInstructions,
);
} catch (error) {
compactionSettled = true;
for (const requestId of uncommittedSlices.splice(0)) {
this._semanticEdges.failRequest(requestId);
}
Expand Down
106 changes: 58 additions & 48 deletions packages/coding-agent/src/main.ts
Original file line number Diff line number Diff line change
Expand Up @@ -719,10 +719,67 @@ export function resolveRuntimeSessionOptions(
rlmSessionDir: runtimeSessionOptions?.rlmSessionDir,
rlmParentNodeId: runtimeSessionOptions?.rlmParentNodeId,
rlmParentAgent: runtimeSessionOptions?.rlmParentAgent,
semanticParentSessionId: runtimeSessionOptions?.semanticParentSessionId,
semanticSpawnedByRequestId: runtimeSessionOptions?.semanticSpawnedByRequestId,
subagentRuntimeHost: runtimeSessionOptions?.subagentRuntimeHost,
};
}

/** The production runtime factory: every daemon-hosted and runtime-hosted session is created through this path. */
export function createDefaultRuntimeFactory(
runtimeDefaultSessionConfig: AgentSessionRuntimeConfig,
extensionFactories?: ExtensionFactory[],
): CreateAgentSessionRuntimeFactory {
return async ({
cwd,
agentDir,
sessionManager,
sessionStartEvent,
sessionConfig,
sessionOptions: runtimeSessionOptions,
}) => {
const config = mergeAgentSessionRuntimeConfig(runtimeDefaultSessionConfig, sessionConfig);
const prepared = await prepareRuntimeServices({
config,
cwd,
agentDir,
sessionManager,
extensionFactories,
sessionOptionsOverride: runtimeSessionOptions,
});
const { services, sessionOptions, diagnostics } = prepared;
const resolvedSessionOptions = resolveRuntimeSessionOptions(sessionOptions, runtimeSessionOptions);

const created = await createAgentSessionFromServices({
services,
sessionManager,
sessionStartEvent,
...resolvedSessionOptions,
// Main agents boot their kernel in the background at session creation;
// subagent sessions (rlmDepth > 0) keep the lazy first-call start.
prewarmIpythonKernel: true,
// Read serializedRefine from the merged runtime config (passed
// from the JSON/print client through AgentSessionRuntimeConfig)
// so it survives the daemon worker's appMode="daemon" context.
serializedRefine: config.serializedRefine ?? false,
executionMode: config.executionMode,
telemetryDisabled: config.telemetryDisabled,
// Only seed initial goal for top-level sessions (rlmDepth 0).
initialGoal: (runtimeSessionOptions?.rlmDepth ?? 0) === 0 ? config.initialGoal : undefined,
});
const cliThinkingOverride = config.thinking !== undefined || prepared.cliThinkingFromModel;
if (created.session.model && cliThinkingOverride) {
created.session.setThinkingLevel(created.session.thinkingLevel);
}

return {
...created,
services,
diagnostics,
};
};
}

async function prepareRuntimeServices(options: {
config: AgentSessionRuntimeConfig;
cwd: string;
Expand Down Expand Up @@ -1269,54 +1326,7 @@ export async function main(args: string[], options?: MainOptions) {
// daemon fallback must not seed that goal into unrelated future sessions.
const daemonDefaultSessionConfig = daemonServerDefaultSessionConfig(defaultSessionConfig);
const runtimeDefaultSessionConfig = appMode === "daemon" ? daemonDefaultSessionConfig : defaultSessionConfig;
const createRuntime: CreateAgentSessionRuntimeFactory = async ({
cwd,
agentDir,
sessionManager,
sessionStartEvent,
sessionConfig,
sessionOptions: runtimeSessionOptions,
}) => {
const config = mergeAgentSessionRuntimeConfig(runtimeDefaultSessionConfig, sessionConfig);
const prepared = await prepareRuntimeServices({
config,
cwd,
agentDir,
sessionManager,
extensionFactories: options?.extensionFactories,
sessionOptionsOverride: runtimeSessionOptions,
});
const { services, sessionOptions, diagnostics } = prepared;
const resolvedSessionOptions = resolveRuntimeSessionOptions(sessionOptions, runtimeSessionOptions);

const created = await createAgentSessionFromServices({
services,
sessionManager,
sessionStartEvent,
...resolvedSessionOptions,
// Main agents boot their kernel in the background at session creation;
// subagent sessions (rlmDepth > 0) keep the lazy first-call start.
prewarmIpythonKernel: true,
// Read serializedRefine from the merged runtime config (passed
// from the JSON/print client through AgentSessionRuntimeConfig)
// so it survives the daemon worker's appMode="daemon" context.
serializedRefine: config.serializedRefine ?? false,
executionMode: config.executionMode,
telemetryDisabled: config.telemetryDisabled,
// Only seed initial goal for top-level sessions (rlmDepth 0).
initialGoal: (runtimeSessionOptions?.rlmDepth ?? 0) === 0 ? config.initialGoal : undefined,
});
const cliThinkingOverride = config.thinking !== undefined || prepared.cliThinkingFromModel;
if (created.session.model && cliThinkingOverride) {
created.session.setThinkingLevel(created.session.thinkingLevel);
}

return {
...created,
services,
diagnostics,
};
};
const createRuntime = createDefaultRuntimeFactory(runtimeDefaultSessionConfig, options?.extensionFactories);
time("createRuntime");
// Daemon mode never uses the bootstrap runtime, so skip the heavy
// createAgentSessionRuntime below and start listening immediately; sessions
Expand Down
46 changes: 46 additions & 0 deletions packages/coding-agent/test/agent-session-semantic-edges.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -834,6 +834,52 @@ describe("AgentSession semantic edges", () => {
]);
});

it("settles a slice that resolves after a sibling already failed the compaction", async () => {
const harness = await createHarness({
persistSession: true,
settings: { compaction: { keepRecentTokens: 1 } },
});
harnesses.push(harness);
const ledgerPath = join(harness.sessionManager.getSessionArtifactDir() ?? "", SEMANTIC_EDGES_LEDGER_FILENAME);

harness.setResponses([fauxAssistantMessage("one"), fauxAssistantMessage("two")]);
await harness.session.prompt("one");
await harness.session.prompt("two");
const preCompactionIds = startedRequestIds(readSemanticEdgeLedger(ledgerPath));

// The first slice rejects immediately; its sibling resolves only after the
// compaction has already settled as failed and drained the slice list.
const failingStep = () => fauxAssistantMessage("nope", { stopReason: "error", errorMessage: "slice failed" });
const lateSuccessStep = async () => {
await sleep(25);
return fauxAssistantMessage("late summary");
};
harness.setResponses([failingStep, lateSuccessStep]);
await expect(harness.session.compact()).rejects.toThrow(/failed/);

// Every summary slice settles; a late success must not stay in-flight forever.
await waitForAsync(async () => {
const events = readSemanticEdgeLedger(ledgerPath);
const summaryIds = startedRequestIds(events).filter((requestId) => !preCompactionIds.includes(requestId));
const terminal = new Set(
events
.filter((event) => event.type === "request_failed" || event.type === "request_finished")
.map((event) =>
event.type === "request_failed" || event.type === "request_finished" ? event.request_id : "",
),
);
return summaryIds.length === 2 && summaryIds.every((requestId) => terminal.has(requestId));
});
const events = readSemanticEdgeLedger(ledgerPath);
const summaryIds = startedRequestIds(events).filter((requestId) => !preCompactionIds.includes(requestId));
const finished = events
.filter((event) => event.type === "request_finished")
.map((event) => (event.type === "request_finished" ? event.request_id : ""));
for (const requestId of summaryIds) {
expect(finished).not.toContain(requestId);
}
});

it("mints a distinct request identity for each split-turn summary call", async () => {
const harness = await createHarness({
persistSession: true,
Expand Down
14 changes: 14 additions & 0 deletions packages/coding-agent/test/main-interactive-routing.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -384,6 +384,20 @@ describe("runtime session option resolution", () => {
expect(resolved.rlmParentAgent).toBe("parent-worker");
});

test("forwards semantic spawn lineage to the created child session", () => {
const resolved = resolveRuntimeSessionOptions(
{},
{
rlmDepth: 1,
semanticParentSessionId: "parent-session-id",
semanticSpawnedByRequestId: "a".repeat(32),
},
);

expect(resolved.semanticParentSessionId).toBe("parent-session-id");
expect(resolved.semanticSpawnedByRequestId).toBe("a".repeat(32));
});

test("deep-merges autonomous runtime session overrides", () => {
const resolved = resolveRuntimeSessionOptions(
{
Expand Down
86 changes: 85 additions & 1 deletion packages/coding-agent/test/suite/agent-session-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,11 @@ import {
} from "../../src/core/agent-session-runtime.js";
import { AuthStorage } from "../../src/core/auth-storage.js";
import type { SubagentRuntimeHost } from "../../src/core/rlm-runtime.js";
import { readSemanticEdgeLedger, SEMANTIC_EDGES_LEDGER_FILENAME } from "../../src/core/semantic-edges.js";
import {
deriveSemanticEdges,
readSemanticEdgeLedger,
SEMANTIC_EDGES_LEDGER_FILENAME,
} from "../../src/core/semantic-edges.js";
import { SessionManager } from "../../src/core/session-manager.js";
import type {
ExtensionAPI,
Expand All @@ -25,6 +29,7 @@ import type {
SessionShutdownEvent,
SessionStartEvent,
} from "../../src/index.js";
import { createDefaultRuntimeFactory } from "../../src/main.js";

type RecordedSessionEvent =
| SessionBeforeSwitchEvent
Expand Down Expand Up @@ -449,6 +454,85 @@ describe("AgentSessionRuntime characterization", () => {
await runtime.deleteRlmSubagentRuntime("lineage-child", childRuntime.session);
});

it("keeps semantic spawn lineage through the production runtime factory", async () => {
const tempDir = join(tmpdir(), `pi-runtime-factory-${Date.now()}-${Math.random().toString(36).slice(2)}`);
mkdirSync(tempDir, { recursive: true });
cleanups.push(() => rmSync(tempDir, { recursive: true, force: true }));
const faux = registerFauxProvider({ models: [{ id: "faux-1", reasoning: false }] });
cleanups.push(() => faux.unregister());

// The PRODUCTION factory (daemon workers and runtime hosts create every
// session through it), not a test factory that forwards all options: the
// original defect lived in its sessionOptions whitelist and stayed
// invisible to factory-boundary assertions.
const factory = createDefaultRuntimeFactory(
{
agentDir: tempDir,
cwd: tempDir,
sessionDir: join(tempDir, "sessions"),
noSkills: true,
noPromptTemplates: true,
noThemes: true,
telemetryDisabled: true,
},
[
(pi: ExtensionAPI) => {
pi.registerProvider(faux.getModel().provider, {
baseUrl: faux.getModel().baseUrl,
apiKey: "faux-key",
api: faux.api,
models: faux.models.map((registeredModel) => ({
id: registeredModel.id,
name: registeredModel.name,
api: registeredModel.api,
reasoning: registeredModel.reasoning,
input: registeredModel.input,
cost: registeredModel.cost,
contextWindow: registeredModel.contextWindow,
maxTokens: registeredModel.maxTokens,
})),
});
},
],
);

const childSessionDir = join(tempDir, "lineage-child");
const spawnedByRequestId = "a".repeat(32);
const created = await factory({
cwd: tempDir,
agentDir: tempDir,
sessionManager: SessionManager.create(tempDir, childSessionDir),
sessionStartEvent: { type: "session_start", reason: "startup" },
sessionOptions: {
model: faux.getModel(),
thinkingLevel: "off",
rlmDepth: 1,
rlmMaxDepth: 2,
rlmSessionDir: childSessionDir,
rlmParentNodeId: "lineage-child",
rlmParentAgent: "parent-worker",
semanticParentSessionId: "parent-session-id",
semanticSpawnedByRequestId: spawnedByRequestId,
},
});
cleanups.push(() => created.session.dispose());
await created.session.bindExtensions({});

const ledgerPath = join(childSessionDir, SEMANTIC_EDGES_LEDGER_FILENAME);
expect(readSemanticEdgeLedger(ledgerPath)[0]).toMatchObject({
type: "session_registered",
parent_session_id: "parent-session-id",
spawned_by_request_id: spawnedByRequestId,
});

faux.setResponses([fauxAssistantMessage("child work")]);
await created.session.prompt("do the work");
const edges = deriveSemanticEdges([readSemanticEdgeLedger(ledgerPath)]).edges;
expect(edges.filter((edge) => edge.type === "subagent_call")).toMatchObject([
{ source_request_id: spawnedByRequestId },
]);
});

it("disposes hosted RLM children during session replacement", async () => {
const disposeRlmSubagentRuntimes = vi.fn(async () => {});
const host: SubagentRuntimeHost = {
Expand Down
Loading