Skip to content
Closed
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
169 changes: 141 additions & 28 deletions packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -950,6 +950,14 @@ type AutonomousRuntimeSnapshot = Pick<
"continuationsUsed" | "gateAttempts" | "lastGateFailure" | "lastGateFailureSnapshot"
>;

type RlmChildUpdateEvent = Extract<AgentSessionEvent, { type: "rlm_child_update" }>;

interface PendingRlmChildUpdate {
event: RlmChildUpdateEvent;
/** C01 assignment fence rechecked both at enqueue and publication. */
isCurrent: () => boolean;
}

interface RlmChildRun {
id: string;
/** UUID attempt fence, minted before this run is visible to any host. */
Expand Down Expand Up @@ -1120,6 +1128,12 @@ export class AgentSession {
followUps: [],
};
private _agentEventQueue: Promise<void> = Promise.resolve();
// Presentation-only, per-parent coalescing. This never gates child admission or model work.
private _rlmChildUpdateFlushTimer: ReturnType<typeof setTimeout> | undefined;
private _rlmChildUpdateGeneration = 0;
private readonly _pendingRlmChildUpdates = new Map<string, PendingRlmChildUpdate>();
private _observerFailureDiagnostics = 0;
private _afterToolHookFailureDiagnostics = 0;

/** Session-owned actions. Items are never fed into Agent.steer/followUp. */
private readonly _actionStore = new ActionStore<QueuedSessionAction>();
Expand Down Expand Up @@ -1475,25 +1489,31 @@ export class AgentSession {
return undefined;
}

const hookResult = await runner.emitToolResult({
type: "tool_result",
toolName: toolCall.name,
toolCallId: toolCall.id,
input: args as Record<string, unknown>,
content: result.content,
details: result.details,
isError,
});
try {
const hookResult = await runner.emitToolResult({
type: "tool_result",
toolName: toolCall.name,
toolCallId: toolCall.id,
input: args as Record<string, unknown>,
content: result.content,
details: result.details,
isError,
});

if (!hookResult) {
if (!hookResult) {
return undefined;
}

return {
content: hookResult.content,
details: hookResult.details,
isError: hookResult.isError ?? isError,
};
} catch (error) {
// Result hooks observe completed work; unlike beforeToolCall they are not a veto.
this._recordBoundedDiagnostic("after-tool-hook", error);
return undefined;
}

return {
content: hookResult.content,
details: hookResult.details,
isError: hookResult.isError ?? isError,
};
};
}

Expand All @@ -1510,18 +1530,95 @@ export class AgentSession {
// Event Subscription
// =========================================================================

private _recordBoundedDiagnostic(kind: "observer" | "after-tool-hook", error: unknown): void {
const count = kind === "observer" ? ++this._observerFailureDiagnostics : ++this._afterToolHookFailureDiagnostics;
// Diagnostics deliberately exclude event, hook, tool, prompt, result, and stack content.
if (count <= 10) {
const errorClass = error instanceof Error ? error.constructor.name : typeof error;
console.warn(`AgentSession ${kind} failure (${errorClass})`);
}
}

/** Emit an event to all listeners */
private _emit(event: AgentSessionEvent): void {
const terminalChildUpdate =
event.type === "rlm_child_update" &&
(event.child.status === "done" || event.child.status === "error" || event.child.status === "cancelled");
if ((event.type !== "rlm_child_update" || terminalChildUpdate) && this._pendingRlmChildUpdates.size > 0) {
// Structural and terminal events cannot overtake a retained progress snapshot.
this._flushRlmChildUpdates();
}
for (const l of this._eventListeners) {
try {
l(event);
} catch {
} catch (error) {
// A failing observer must not prevent other subscribers from
// receiving lifecycle and persistence events.
this._recordBoundedDiagnostic("observer", error);
}
}
}

/**
* Retain only the newest nonterminal snapshot per child until the next turn.
* The caller supplies C01's assignment fence so a replaced incarnation cannot
* be published from a stale timeout.
*/
private _queueRlmChildUpdate(
event: RlmChildUpdateEvent,
isCurrent: () => boolean,
publishSynchronously: boolean,
): void {
if (this._disposed || !isCurrent()) return;
const { child } = event;
const terminal = child.status === "done" || child.status === "error" || child.status === "cancelled";
if (terminal) {
// A terminal must not overtake the final retained activity for this child.
this._flushRlmChildUpdates();
if (!this._disposed && isCurrent()) this._emit(event);
return;
}
if (publishSynchronously) {
// A status edge replaces any older activity for its own child, then
// preserves ordering with activity retained for other children.
this._pendingRlmChildUpdates.delete(child.id);
this._flushRlmChildUpdates();
if (!this._disposed && isCurrent()) this._emit(event);
return;
}

this._pendingRlmChildUpdates.set(child.id, { event, isCurrent });
if (this._rlmChildUpdateFlushTimer !== undefined) return;
const generation = this._rlmChildUpdateGeneration;
this._rlmChildUpdateFlushTimer = setTimeout(() => {
if (generation !== this._rlmChildUpdateGeneration) return;
this._rlmChildUpdateFlushTimer = undefined;
if (this._disposed) return;
this._flushRlmChildUpdates();
}, 0);
}

private _flushRlmChildUpdates(): void {
if (this._rlmChildUpdateFlushTimer !== undefined) {
clearTimeout(this._rlmChildUpdateFlushTimer);
this._rlmChildUpdateFlushTimer = undefined;
}
const pending = [...this._pendingRlmChildUpdates.values()];
this._pendingRlmChildUpdates.clear();
for (const { event, isCurrent } of pending) {
if (!this._disposed && isCurrent()) this._emit(event);
}
}

private _cancelRlmChildUpdateFlush(): void {
this._rlmChildUpdateGeneration++;
if (this._rlmChildUpdateFlushTimer !== undefined) {
clearTimeout(this._rlmChildUpdateFlushTimer);
this._rlmChildUpdateFlushTimer = undefined;
}
this._pendingRlmChildUpdates.clear();
}

private _emitQueueUpdate(): void {
const actions = this.getSessionActionSnapshot();
if (JSON.stringify(actions) === JSON.stringify(this._lastSessionActionSnapshot)) return;
Expand Down Expand Up @@ -4000,6 +4097,7 @@ export class AgentSession {
if (this._disposed) {
return;
}
this._cancelRlmChildUpdateFlush();
this._disposed = true;
this._sessionActionCommitDisposeAbortController.abort();
try {
Expand Down Expand Up @@ -6555,6 +6653,7 @@ export class AgentSession {
const compactionOperation = this._compactionOperation;
const branchSummaryOperation = this._branchSummaryOperation;
this.requestAbort();
this._cancelRlmChildUpdateFlush();
this._cancelActiveRlmChildRuns("Parent session aborted");
this._goalAbortInProgress = this._goalState.status === "active";
try {
Expand All @@ -6577,6 +6676,7 @@ export class AgentSession {
this._sessionInputPumpSuspended = true;
this._cancelPostCompactionContinue();
this.abortRetry();
this._cancelRlmChildUpdateFlush();
this._cancelActiveRlmChildRuns("Parent session aborted for update restart");
this._goalAbortInProgress = this._goalState.status === "active";
this.agent.abort();
Expand Down Expand Up @@ -9798,16 +9898,20 @@ export class AgentSession {
if (run.status === "cancelled") throw new Error(run.error ?? "RLM child cancelled");
};
this._activeRlmChildRuns.set(run.id, run);
const emitChildUpdate = () => {
const activeOwner =
this._activeRlmChildRuns.get(run.id) === run &&
this._activeRlmChildRuns.get(run.id)?.assignmentId === run.assignmentId;
const retainedOwner =
this._rlmChildSessions.get(run.id) === childSession &&
this._rlmChildSessionAssignments.get(run.id) === run.assignmentId;
if (!activeOwner && !retainedOwner) return;
let runningPublished = false;
const emitChildUpdate = (structural = false) => {
const isCurrent = () => {
const activeOwner =
this._activeRlmChildRuns.get(run.id) === run &&
this._activeRlmChildRuns.get(run.id)?.assignmentId === run.assignmentId;
const retainedOwner =
this._rlmChildSessions.get(run.id) === childSession &&
this._rlmChildSessionAssignments.get(run.id) === run.assignmentId;
return activeOwner || retainedOwner;
};
if (!isCurrent()) return;
const childModel = childSession?.model ?? modelSelection.model;
this._emit({
const event: RlmChildUpdateEvent = {
type: "rlm_child_update",
child: {
id: childNodeId,
Expand All @@ -9826,7 +9930,12 @@ export class AgentSession {
repliedSinceTask: childSession?._repliedToParentSinceTask,
error: run.error,
},
});
};
const terminal = run.status === "done" || run.status === "error" || run.status === "cancelled";
const publishSynchronously =
structural || run.status === "queued" || (run.status === "running" && !runningPublished) || terminal;
this._queueRlmChildUpdate(event, isCurrent, publishSynchronously);
if (run.status === "running") runningPublished = true;
};
run.emitUpdate = emitChildUpdate;
emitChildUpdate();
Expand Down Expand Up @@ -9963,7 +10072,11 @@ export class AgentSession {
runningToolCount = Math.max(0, runningToolCount - 1);
if (runningToolCount === 0) activity = { kind: "waiting" };
emitChildUpdate();
} else if (event.type === "session_info_changed" || event.type === "recap_update") {
} else if (event.type === "session_info_changed") {
// A child rename changes its public identity. Unlike recap/progress activity,
// it must be visible before another macrotask can replace the snapshot.
emitChildUpdate(true);
} else if (event.type === "recap_update") {
emitChildUpdate();
}
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,10 @@ export interface DaemonSocketClient {
catchupPurposes?: Map<string, "replacement" | "resync">;
/** The single catch-up drain currently serving this client. */
catchupPromise?: Promise<void>;
/** An attachment-local catch-up callback is queued for the next event-loop turn. */
catchupDrainScheduled?: boolean;
/** Invalidates queued attachment-local catch-up callbacks during cleanup. */
catchupGeneration?: number;
/** Delayed retry after transient catch-up snapshot preparation failure. */
catchupRetryTimer?: NodeJS.Timeout;
backpressured?: boolean;
Expand Down
Loading