Skip to content
Merged
Show file tree
Hide file tree
Changes from 22 commits
Commits
Show all changes
25 commits
Select commit Hold shift + click to select a range
e4fba20
feat(coding-agent): ACP lineage-v1 provenance producer
snimu Aug 28, 2026
d7e9a41
fix(coding-agent): harden lineage retry identity, spawn ancestry, and…
snimu Aug 28, 2026
cd18572
fix(coding-agent): eager lineage body hash and mutation-hardened tests
snimu Aug 28, 2026
e66d91e
test(coding-agent): kill the last two lineage mutants
snimu Aug 28, 2026
06b80dc
feat(coding-agent)!: convert the provenance producer to ACP semantic-…
snimu Aug 28, 2026
1e531cb
test(coding-agent): kill the five surviving semantic-edge mutants
snimu Aug 28, 2026
9f62820
feat(coding-agent): failed subagent runs also return their last commit
snimu Aug 28, 2026
fdfd5fa
fix(coding-agent): harden semantic-edge retry identity and ledger fau…
snimu Aug 28, 2026
7f8c934
fix(coding-agent): distinct request identity per split-turn summary call
snimu Aug 28, 2026
4f53497
merge: main into feat/acp-lineage-v1 (kernel protocol 2)
snimu Aug 28, 2026
5b9c8fb
fix(coding-agent): degrade the semantic-edge recorder instead of thro…
snimu Aug 29, 2026
d066a75
fix(coding-agent): flush pending edges to the last-committed summary …
snimu Aug 29, 2026
82241bc
fix(coding-agent): dedupe the terminal flush against generated contin…
snimu Aug 29, 2026
3b44c95
Merge main into local-acp-lineage-v1
snimu Aug 31, 2026
3c654e6
refactor(coding-agent): reschedule agent-trace uploads through a disk…
snimu Sep 1, 2026
0e7da14
fix(coding-agent): make the trace outbox per-entry, durable at persis…
snimu Sep 1, 2026
2eb82f2
fix(coding-agent): retry failed intent markers and cap Retry-After at…
snimu Sep 2, 2026
3e473f3
Merge remote-tracking branch 'origin/main' into feat/acp-lineage-v1
snimu Sep 2, 2026
ec01ea1
Merge remote-tracking branch 'origin/refactor/agent-traces-outbox' in…
snimu Sep 2, 2026
64ef1b6
fix(coding-agent): commit summary slices only when the compaction com…
snimu Sep 2, 2026
856a088
Merge branch 'feat/acp-lineage-v1' into feat/acp-lineage-delivery
snimu Sep 2, 2026
2b27521
feat(coding-agent): register the semantic-edge ledger with the agent-…
snimu Sep 2, 2026
2ac123e
fix(coding-agent): re-register the ledger intent when the outbox ledg…
snimu Sep 2, 2026
69ef139
Merge remote-tracking branch 'origin/main' into feat/acp-lineage-deli…
snimu Sep 3, 2026
22606d5
fix(coding-agent): consent-gate outbox intent at persist time and dro…
snimu Sep 3, 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
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
Register the per-session semantic-edge ledger with the agent-traces outbox: durable kind-tagged intent at persist, an append-only byte cursor, catch-up counting, and pruning on deletion. No delivery endpoint exists yet; pending ledgers are counted, never sent.
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Outdated
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Added an ACP semantic-edges-v1 producer: each agent session appends an append-only `semantic-edges.jsonl` ledger beside its session artifacts, every provider turn and compaction summary call carries one opaque request ID on `X-ACP-Model-Request-ID` and `Idempotency-Key` (minted before the call, committed or failed when its stream resolves, and stable across retry attempts of the same call body), spawned subagents record their parent session and spawning request while successful children record their return, and `deriveSemanticEdges` folds a session tree's ledgers into commit-gated `continuation`/`subagent_call`/`subagent_return`/`compaction` edges matching the verifiers semantic-edges-v1 schema. Derivation only — nothing publishes or reads the ledger yet.
1 change: 1 addition & 0 deletions packages/coding-agent/.changes/eng-5838-traces-outbox.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
- Reworked agent-trace upload scheduling as a disk-cursor outbox: upload intent and per-session uploaded-content cursors persist as one small entry file per session under `agent-traces-outbox/` in the agent dir, a startup catch-up uploads anything a previous process never finished (pruning cursors of deleted session files), scheduled and catch-up uploads never re-send unchanged sessions (the explicit `/traces upload` command still force-uploads), and rate-limited uploads reschedule (honoring an advertised Retry-After) instead of sleeping. Session disposal and process exit no longer wait on trace uploads at all, and upload timers never keep the process alive; the exit drain barrier is gone (the startup catch-up replaces it).
12 changes: 2 additions & 10 deletions packages/coding-agent/src/core/agent-session-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,6 @@ import type {
AgentSessionRuntimeDiagnostic,
AgentSessionServices,
} from "./agent-session-services.js";
import { flushAgentTraceUpload, logDetachedAgentTraceFlushFailure } from "./agent-traces.js";
import { isNoModelsAvailableMessage } from "./auth-guidance.js";
import type { ReplacedSessionContext, SessionShutdownEvent, SessionStartEvent } from "./extensions/index.js";
import { emitSessionShutdownEvent } from "./extensions/runner.js";
Expand Down Expand Up @@ -204,7 +203,6 @@ export class AgentSessionRuntime implements SubagentRuntimeHost {
reason,
targetSessionFile,
});
this.detachTraceFlush();
this.beforeSessionInvalidate?.();
// Await the kernel's final snapshot flush before invalidating the session.
await this.session.disposeAsync();
Expand Down Expand Up @@ -345,6 +343,8 @@ export class AgentSessionRuntime implements SubagentRuntimeHost {
rlmSessionDir: options.sessionDir,
rlmParentNodeId: options.rlmParentNodeId,
rlmParentAgent: options.parentSession.sessionName ?? options.parentSession.sessionId,
semanticParentSessionId: options.parentSession.sessionId,
Comment thread
snimu marked this conversation as resolved.
semanticSpawnedByRequestId: options.spawnedByRequestId,
},
runtimeMetadata: {
kind: "subagent",
Expand Down Expand Up @@ -686,13 +686,6 @@ export class AgentSessionRuntime implements SubagentRuntimeHost {
return { cancelled: false };
}

private detachTraceFlush(): void {
const sessionManager = this.session.sessionManager;
void flushAgentTraceUpload(sessionManager).catch((error) =>
logDetachedAgentTraceFlushFailure(sessionManager.getSessionFile(), error),
);
}

private async disposeOnce(options: AgentSessionRuntimeDisposeOptions): Promise<void> {
let disposeError: unknown;
try {
Expand All @@ -703,7 +696,6 @@ export class AgentSessionRuntime implements SubagentRuntimeHost {
} catch (error) {
disposeError ??= error;
}
this.detachTraceFlush();
try {
this.beforeSessionInvalidate?.();
} catch (error) {
Expand Down
9 changes: 9 additions & 0 deletions packages/coding-agent/src/core/agent-session-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@ import { ModelRegistry } from "./model-registry.js";
import { DefaultResourceLoader, type DefaultResourceLoaderOptions, type ResourceLoader } from "./resource-loader.js";
import type { SubagentRuntimeHost } from "./rlm-runtime.js";
import { type CreateAgentSessionResult, createAgentSession } from "./sdk.js";
import { semanticEdgeLedgerPath } from "./semantic-edges.js";
import type { SessionManager } from "./session-manager.js";
import { SettingsManager } from "./settings-manager.js";
import { installAgentTelemetry, isTelemetryEnabled } from "./telemetry.js";
Expand Down Expand Up @@ -62,6 +63,8 @@ export interface AgentSessionCreationOptions {
rlmSessionDir?: string;
rlmParentNodeId?: string;
rlmParentAgent?: string;
semanticParentSessionId?: string;
semanticSpawnedByRequestId?: string;
subagentRuntimeHost?: SubagentRuntimeHost;
rlmHeartbeatController?: AgentRlmHeartbeatController;
prewarmIpythonKernel?: boolean;
Expand Down Expand Up @@ -223,6 +226,10 @@ export async function createAgentSessionFromServices(
installAgentTraceUpload(options.sessionManager, {
authStorage: options.services.authStorage,
settingsManager: options.services.settingsManager,
semanticEdgesLedgerPath: semanticEdgeLedgerPath({
rlmSessionDir: options.rlmSessionDir,
sessionArtifactDir: options.sessionManager.getSessionArtifactDir(),
}),
});
const result = await createAgentSession({
cwd: options.services.cwd,
Expand Down Expand Up @@ -251,6 +258,8 @@ export async function createAgentSessionFromServices(
rlmSessionDir: options.rlmSessionDir,
rlmParentNodeId: options.rlmParentNodeId,
rlmParentAgent: options.rlmParentAgent,
semanticParentSessionId: options.semanticParentSessionId,
semanticSpawnedByRequestId: options.semanticSpawnedByRequestId,
subagentRuntimeHost: options.subagentRuntimeHost,
rlmHeartbeatController: options.rlmHeartbeatController,
sessionStartEvent: options.sessionStartEvent,
Expand Down
167 changes: 137 additions & 30 deletions packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,7 +71,6 @@ import {
normalizeObserveMaxChars,
ORCHESTRATION_HEARTBEAT_SKILL_NAME,
} from "./agent-observe.js";
import { flushAgentTraceUpload } from "./agent-traces.js";
import {
addLoginGuidanceToAuthError,
formatAuthenticationFailedMessage,
Expand Down Expand Up @@ -232,6 +231,12 @@ import {
type RlmSubagentRuntime,
type SubagentRuntimeHost,
} from "./rlm-runtime.js";
import {
modelRequestHeaders,
SemanticEdgeRecorder,
semanticEdgeLedgerPath,
wrapStreamFnWithSemanticEdges,
} from "./semantic-edges.js";
import {
ActionStore,
type ActionTicket,
Expand Down Expand Up @@ -442,6 +447,8 @@ export interface AgentSessionConfig {
rlmSessionDir?: string;
rlmParentNodeId?: string;
rlmParentAgent?: string;
semanticParentSessionId?: string;
semanticSpawnedByRequestId?: string;
subagentRuntimeHost?: SubagentRuntimeHost;
autonomous?: AgentAutonomousConfig;
prewarmIpythonKernel?: boolean;
Expand Down Expand Up @@ -1166,6 +1173,7 @@ export class AgentSession {
private _rlmMaxDepth: number;
private _rlmMaxDepthSource: RlmMaxDepthSource;
private _rlmSessionDir?: string;
private readonly _semanticEdges: SemanticEdgeRecorder;
private _rlmParentNodeId?: string;
private _rlmParentAgent?: string;
private _repliedToParentSinceTask: boolean | undefined;
Expand Down Expand Up @@ -1274,6 +1282,16 @@ export class AgentSession {
this._rlmSessionDir = config.rlmSessionDir;
this._rlmParentNodeId = config.rlmParentNodeId;
this._rlmParentAgent = config.rlmParentAgent;
this._semanticEdges = new SemanticEdgeRecorder({
ledgerPath: semanticEdgeLedgerPath({
rlmSessionDir: this._rlmSessionDir,
sessionArtifactDir: this.sessionManager.getSessionArtifactDir(),
}),
sessionId: this.sessionManager.getSessionId(),
parentSessionId: config.semanticParentSessionId,
spawnedByRequestId: config.semanticSpawnedByRequestId,
});
this.agent.streamFn = wrapStreamFnWithSemanticEdges(this.agent.streamFn, this._semanticEdges);
// A resumed child may have replied before this process started; false would
// claim knowledge that is not present in the session transcript.
this._repliedToParentSinceTask =
Expand Down Expand Up @@ -3715,6 +3733,7 @@ export class AgentSession {
}

private _resolveRetry(): void {
this._semanticEdges.clearTurnRetry();
if (this._retryResolve) {
this._retryResolve();
this._retryResolve = undefined;
Expand Down Expand Up @@ -4276,6 +4295,10 @@ export class AgentSession {
return this._rlmDepth;
}

get semanticEdges(): SemanticEdgeRecorder {
return this._semanticEdges;
}

get rlmMaxDepth(): number {
return this._rlmMaxDepth;
}
Expand Down Expand Up @@ -7507,41 +7530,100 @@ export class AgentSession {
let extensionCompaction: CompactionResult | undefined;
let fromExtension = false;

if (this._extensionRunner.hasHandlers("session_before_compact")) {
const result = (await this._extensionRunner.emit({
type: "session_before_compact",
preparation,
branchEntries: pathEntries,
customInstructions,
signal,
})) as SessionBeforeCompactResult | undefined;
const semanticCompaction = this._semanticEdges.beginCompaction();
let compactionRecorded = false;
const uncommittedSlices: string[] = [];
let summary: string;
let firstKeptEntryId: string;
let tokensBefore: number;
let details: CompactionResult["details"];
try {
if (this._extensionRunner.hasHandlers("session_before_compact")) {
const result = (await this._extensionRunner.emit({
type: "session_before_compact",
preparation,
branchEntries: pathEntries,
customInstructions,
signal,
})) as SessionBeforeCompactResult | undefined;

if (result?.cancel) {
throw new Error("Compaction cancelled");
if (result?.cancel) {
throw new Error("Compaction cancelled");
}

if (result?.compaction) {
extensionCompaction = result.compaction;
fromExtension = true;
}
}

if (result?.compaction) {
extensionCompaction = result.compaction;
fromExtension = true;
if (extensionCompaction) {
({ summary, firstKeptEntryId, tokensBefore, details } = extensionCompaction);
} else {
// Each summary wire call gets its own request ID: split turns send two
// different bodies, and one Idempotency-Key must never cover both. A slice
// that succeeds on the wire stays uncommitted until the compaction itself
// commits: a racing sibling's failure (or an abort) must leave no committed
// summary request for the next turn's continuation edge to attach to.
const summaryCall = async <T>(
call: (callHeaders: Record<string, string> | undefined) => Promise<T>,
): Promise<T> => {
const requestId = this._semanticEdges.startCompactionRequest(semanticCompaction.compactionId);
if (requestId === undefined) {
return call(headers);
}
try {
const result = await call({ ...headers, ...modelRequestHeaders(requestId) });
uncommittedSlices.push(requestId);
return result;
} catch (error) {
this._semanticEdges.failRequest(requestId);
throw error;
}
};
({ summary, firstKeptEntryId, tokensBefore, details } = await compact(
preparation,
model,
apiKey,
headers,
customInstructions,
signal,
this.thinkingLevel,
summaryCall,
));
}
}

const { summary, firstKeptEntryId, tokensBefore, details } =
extensionCompaction ??
(await compact(preparation, model, apiKey, headers, customInstructions, signal, this.thinkingLevel));
if (signal.aborted) {
throw new Error("Compaction cancelled");
}

if (signal.aborted) {
throw new Error("Compaction cancelled");
// Ledger-before-effect: the compaction outcome is durable before the transcript
// 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;
for (const requestId of uncommittedSlices.splice(0)) {
this._semanticEdges.finishRequest(requestId);
}
this._semanticEdges.finishCompaction(semanticCompaction.compactionId, "completed");
this.sessionManager.appendCompaction(
summary,
firstKeptEntryId,
tokensBefore,
details,
fromExtension,
customInstructions,
);
} catch (error) {
for (const requestId of uncommittedSlices.splice(0)) {
this._semanticEdges.failRequest(requestId);
}
if (!compactionRecorded) {
const cancelled =
error instanceof Error && (error.name === "AbortError" || error.message === "Compaction cancelled");
this._semanticEdges.finishCompaction(semanticCompaction.compactionId, cancelled ? "cancelled" : "failed");
}
throw error;
}

this.sessionManager.appendCompaction(
summary,
firstKeptEntryId,
tokensBefore,
details,
fromExtension,
customInstructions,
);
const newEntries = this.sessionManager.getEntries();
this.agent.state.messages = this.sessionManager.buildSessionContext().messages;
this._mergeUnpersistedOutcomes(this.agent.state.messages);
Expand Down Expand Up @@ -9460,6 +9542,7 @@ export class AgentSession {
sessionDir: string;
model: Model<any>;
thinkingLevel?: ThinkingLevel;
spawnedByRequestId?: string;
}): CreateRlmSubagentRuntimeOptions {
return {
parentSession: this,
Expand All @@ -9482,6 +9565,7 @@ export class AgentSession {
rlmDepth: this._rlmDepth + 1,
rlmMaxDepth: this._rlmMaxDepth,
rlmParentNodeId: options.id,
spawnedByRequestId: options.spawnedByRequestId,
};
}

Expand Down Expand Up @@ -9547,6 +9631,8 @@ export class AgentSession {
rlmSessionDir: options.sessionDir,
rlmParentNodeId: options.rlmParentNodeId,
rlmParentAgent: options.parentSession.sessionName ?? options.parentSession.sessionId,
semanticParentSessionId: options.parentSession.sessionId,
semanticSpawnedByRequestId: options.spawnedByRequestId,
sessionStartEvent: { type: "session_start", reason: "startup" },
});
if (child.sessionName !== options.sessionName) {
Expand Down Expand Up @@ -10404,6 +10490,10 @@ export class AgentSession {
kwargs: Record<string, unknown> = {},
spawnCode?: string,
): Promise<RlmSpawnHandle> {
// Snapshot before any await: the spawning request is the turn whose tool call is
// executing now. A spawn arriving outside an active run (a detached kernel task
// firing while the parent is idle) has no such turn; an absent edge beats a wrong one.
const spawnedByRequestId = this.isStreaming ? this._semanticEdges.lastTurnRequestId : undefined;
const { name: rawName, model: rawModel, thinking: rawThinking, ...unsupported } = kwargs;
const unsupportedKwargs = Object.keys(unsupported);
if (unsupportedKwargs.length > 0) {
Expand Down Expand Up @@ -10497,6 +10587,7 @@ export class AgentSession {
sessionDir: childSessionDir,
model: modelSelection.model,
thinkingLevel: requestedThinkingLevel,
spawnedByRequestId,
}),
onSessionPublished: publishChildSession,
};
Expand Down Expand Up @@ -10591,7 +10682,6 @@ export class AgentSession {
}
const text = compactRlmText(readAssistantText(assistant));
if (text) run.answerPreview = text;
void flushAgentTraceUpload(child.sessionManager).catch(() => undefined);
emitChildUpdate();
} else if (event.type === "message_start" || event.type === "message_update") {
if (event.message.role === "assistant") {
Expand Down Expand Up @@ -10642,6 +10732,11 @@ export class AgentSession {
await child.waitForRlmQuiescence();
if (run.error) throw new Error(run.error);
run.status = "done";
// Only successful completions return; the edge lands on the parent's next commit.
const childLastCommitted = child.semanticEdges.lastCommittedRequestId;
if (childLastCommitted !== undefined) {
this._semanticEdges.recordChildReturned(child.sessionId, childLastCommitted);
}
run.durationMs = Date.now() - startedAt;
run.activity = undefined;
emitChildUpdate();
Expand Down Expand Up @@ -10676,6 +10771,13 @@ export class AgentSession {
run.status = "error";
run.error = runError.message;
}
// A failed child still returns an error outcome the parent consumes;
// cancelled runs and zero-commit children return nothing.
const failedChild = childSession ?? childRuntime?.session;
const failedLastCommitted = failedChild?.semanticEdges.lastCommittedRequestId;
if (run.status === "error" && failedChild && failedLastCommitted !== undefined) {
this._semanticEdges.recordChildReturned(failedChild.sessionId, failedLastCommitted);
}
run.durationMs = Date.now() - startedAt;
run.activity = undefined;
if (run.status === "error" && childSession === undefined) {
Expand Down Expand Up @@ -10998,6 +11100,11 @@ export class AgentSession {
}

const delayMs = settings.baseDelayMs * 2 ** (this._retryAttempt - 1);
// Park now: the retry re-issues the failed call and must reuse its Idempotency-Key.
// Payload hooks mutate the wire body after the hash point, so reuse is forfeited.
if (!this._extensionRunner.hasHandlers("before_provider_request")) {
this._semanticEdges.prepareTurnRetry();
}

this._emit({
type: "auto_retry_start",
Expand Down
Loading