Skip to content

Commit f728316

Browse files
authored
Merge pull request #12 from pylon-code/fix/correlated-lifecycle-upstream-compat
fix(coding-agent): preserve correlated prompt ownership
2 parents 8551520 + 08d39fa commit f728316

5 files changed

Lines changed: 658 additions & 51 deletions

File tree

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
- Kept correlated prompt payloads immutable after queue ownership and preserved their event and usage attribution through post-compaction continuation.

‎packages/coding-agent/src/core/agent-session.ts‎

Lines changed: 190 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -1071,6 +1071,12 @@ export class AgentSession {
10711071
private readonly _promptLifecycles = new PromptLifecycleStore();
10721072
private readonly _pendingCorrelatedPromptAdmissions = new Map<string, AbortController>();
10731073
private readonly _promptEventContext = new AsyncLocalStorage<string>();
1074+
private readonly _deferredPromptLifecycleSettlements = new Map<Promise<void>, string>();
1075+
private readonly _promptEventQueueFailures = new Set<string>();
1076+
private readonly _postCompactionPromptOwners = new WeakMap<PostCompactionContinuationSettlement, string>();
1077+
private readonly _agentEventPromptCorrelations = new WeakMap<AgentEvent, string>();
1078+
private readonly _postCompactionPromptScheduleRevisions = new Map<string, number>();
1079+
private _activePostCompactionPromptCorrelationId: string | undefined;
10741080
private _sessionReplacementFenced = false;
10751081
private _sessionReplacementAdmissionGuard: (() => boolean) | undefined;
10761082
private _sessionInputPump: Promise<void> = Promise.resolve();
@@ -1313,6 +1319,14 @@ export class AgentSession {
13131319
this._goalAccountingStartedAt = Date.now();
13141320
}
13151321

1322+
// A continuation settlement can be reused or replaced, so ownership follows
1323+
// the causal scheduling call rather than object identity.
1324+
const schedulePostCompactionContinue = this._schedulePostCompactionContinue.bind(this);
1325+
this._schedulePostCompactionContinue = (...args: Parameters<typeof this._schedulePostCompactionContinue>) => {
1326+
this._recordPostCompactionPromptSchedule();
1327+
return schedulePostCompactionContinue(...args);
1328+
};
1329+
13161330
this._unsubscribeAgent = this.agent.subscribe(this._handleAgentEvent);
13171331
this._installAgentToolHooks();
13181332
this._installAgentTurnHook();
@@ -1537,20 +1551,94 @@ export class AgentSession {
15371551
phase: "completed" | "cancelled" | "failed",
15381552
): void {
15391553
if (correlationId === undefined) return;
1554+
const eventQueueFailed = this._promptEventQueueFailures.delete(correlationId);
15401555
const current = this._promptLifecycles.get(correlationId);
15411556
if (!current || isPromptLifecycleTerminal(current.phase)) return;
1542-
this._transitionPromptLifecycle(correlationId, phase);
1557+
this._postCompactionPromptScheduleRevisions.delete(correlationId);
1558+
// Later agent events intentionally recover the shared queue. A recovered tail must
1559+
// not turn an earlier correlated persistence failure into false completion.
1560+
this._transitionPromptLifecycle(correlationId, phase === "completed" && eventQueueFailed ? "failed" : phase);
15431561
}
15441562

1545-
private _emit(event: AgentSessionEvent): void {
1563+
private _isDeliveredPromptLifecycleActive(correlationId: string | undefined): correlationId is string {
1564+
if (correlationId === undefined) return false;
1565+
const lifecycle = this._promptLifecycles.get(correlationId);
1566+
return lifecycle?.deliveryCrossed === true && !isPromptLifecycleTerminal(lifecycle.phase);
1567+
}
1568+
1569+
private _currentPromptEventCorrelationId(): string | undefined {
15461570
const contextualCorrelationId = this._promptEventContext.getStore();
1547-
const contextualLifecycle =
1548-
contextualCorrelationId === undefined ? undefined : this._promptLifecycles.get(contextualCorrelationId);
1549-
const promptCorrelationId =
1550-
event.promptCorrelationId ??
1551-
(contextualLifecycle?.deliveryCrossed === true && !isPromptLifecycleTerminal(contextualLifecycle.phase)
1552-
? contextualLifecycle.correlationId
1553-
: null);
1571+
return this._isDeliveredPromptLifecycleActive(contextualCorrelationId) ? contextualCorrelationId : undefined;
1572+
}
1573+
1574+
private _recordPostCompactionPromptSchedule(): void {
1575+
const correlationId = this._currentPromptEventCorrelationId();
1576+
if (correlationId !== undefined) {
1577+
const revision = this._postCompactionPromptScheduleRevisions.get(correlationId) ?? 0;
1578+
this._postCompactionPromptScheduleRevisions.set(correlationId, revision + 1);
1579+
}
1580+
}
1581+
1582+
private _transferDeferredPromptLifecycle(
1583+
correlationId: string,
1584+
settlement: PostCompactionContinuationSettlement,
1585+
): boolean {
1586+
const replacement = this._postCompactionContinuationSettlement;
1587+
if (
1588+
replacement === undefined ||
1589+
replacement === settlement ||
1590+
replacement.settled ||
1591+
this._postCompactionPromptOwners.get(settlement) !== correlationId ||
1592+
!this._isDeliveredPromptLifecycleActive(correlationId)
1593+
) {
1594+
return false;
1595+
}
1596+
this._postCompactionPromptOwners.set(replacement, correlationId);
1597+
this._deferPromptLifecycleSettlement(correlationId, replacement);
1598+
return true;
1599+
}
1600+
1601+
private _deferPromptLifecycleSettlement(
1602+
correlationId: string,
1603+
settlement: PostCompactionContinuationSettlement,
1604+
): void {
1605+
const settle = async (phase: "completed" | "failed") => {
1606+
let queueTailFailed = false;
1607+
try {
1608+
await this._agentEventQueue;
1609+
} catch {
1610+
queueTailFailed = true;
1611+
phase = "failed";
1612+
}
1613+
const recoveredEventFailure = this._promptEventQueueFailures.has(correlationId);
1614+
this._settlePromptLifecycle(correlationId, phase);
1615+
if (queueTailFailed || recoveredEventFailure) {
1616+
throw new Error("Correlated prompt event processing failed");
1617+
}
1618+
};
1619+
const operation = settlement.promise.then(
1620+
() => {
1621+
if (this._transferDeferredPromptLifecycle(correlationId, settlement)) return;
1622+
return settle("completed");
1623+
},
1624+
() => settle("failed"),
1625+
);
1626+
this._deferredPromptLifecycleSettlements.set(operation, correlationId);
1627+
void operation
1628+
.finally(() => {
1629+
this._deferredPromptLifecycleSettlements.delete(operation);
1630+
})
1631+
.catch(() => undefined);
1632+
}
1633+
1634+
private _failDeferredPromptLifecycles(): void {
1635+
for (const correlationId of new Set(this._deferredPromptLifecycleSettlements.values())) {
1636+
this._settlePromptLifecycle(correlationId, "failed");
1637+
}
1638+
}
1639+
1640+
private _emit(event: AgentSessionEvent): void {
1641+
const promptCorrelationId = event.promptCorrelationId ?? this._currentPromptEventCorrelationId() ?? null;
15541642
const correlatedEvent =
15551643
event.promptCorrelationId === undefined ? ({ ...event, promptCorrelationId } as AgentSessionEvent) : event;
15561644
for (const l of this._eventListeners) {
@@ -3492,6 +3580,13 @@ export class AgentSession {
34923580
}
34933581

34943582
private _handleAgentEvent = (event: AgentEvent): void => {
3583+
if (event.type === "agent_start" && this.unfinishedActionCount === 0) {
3584+
const settlement = this._postCompactionContinuationSettlement;
3585+
const correlationId = settlement ? this._postCompactionPromptOwners.get(settlement) : undefined;
3586+
if (this._isDeliveredPromptLifecycleActive(correlationId)) {
3587+
this._activePostCompactionPromptCorrelationId = correlationId;
3588+
}
3589+
}
34953590
this._createRetryPromiseForAgentEnd(event);
34963591
if (event.type === "message_start" || event.type === "message_end") {
34973592
for (const action of this._actionStore.ownedActions()) {
@@ -3551,10 +3646,37 @@ export class AgentSession {
35513646
}
35523647
}
35533648
}
3554-
this._agentEventQueue = this._agentEventQueue.then(
3555-
() => this._processAgentEvent(event),
3556-
() => this._processAgentEvent(event),
3557-
);
3649+
const contextualCorrelationId = this._currentPromptEventCorrelationId();
3650+
const eventCorrelationId =
3651+
contextualCorrelationId ??
3652+
(this._isDeliveredPromptLifecycleActive(this._activePostCompactionPromptCorrelationId)
3653+
? this._activePostCompactionPromptCorrelationId
3654+
: undefined);
3655+
if (eventCorrelationId !== undefined) this._agentEventPromptCorrelations.set(event, eventCorrelationId);
3656+
if (
3657+
event.type === "agent_end" &&
3658+
eventCorrelationId !== undefined &&
3659+
this._activePostCompactionPromptCorrelationId === eventCorrelationId
3660+
) {
3661+
this._activePostCompactionPromptCorrelationId = undefined;
3662+
}
3663+
const processEvent = async () => {
3664+
try {
3665+
if (eventCorrelationId === undefined) {
3666+
await this._processAgentEvent(event);
3667+
} else {
3668+
await this._promptEventContext.run(eventCorrelationId, () => this._processAgentEvent(event));
3669+
}
3670+
} catch (error) {
3671+
if (this._isDeliveredPromptLifecycleActive(eventCorrelationId)) {
3672+
this._promptEventQueueFailures.add(eventCorrelationId);
3673+
}
3674+
throw error;
3675+
} finally {
3676+
this._agentEventPromptCorrelations.delete(event);
3677+
}
3678+
};
3679+
this._agentEventQueue = this._agentEventQueue.then(processEvent, processEvent);
35583680
this._agentEventQueue.catch(() => {});
35593681
};
35603682

@@ -3610,6 +3732,7 @@ export class AgentSession {
36103732

36113733
private async _processAgentEvent(event: AgentEvent): Promise<void> {
36123734
let clearedDispatchEnded = false;
3735+
const eventCorrelationId = this._agentEventPromptCorrelations.get(event);
36133736
if ((event.type === "message_start" || event.type === "message_end") && event.message.role === "toolResult") {
36143737
this._applyLateIpythonSentAgentMessages(event.message);
36153738
}
@@ -3662,11 +3785,10 @@ export class AgentSession {
36623785

36633786
this._addLoginGuidanceToAuthError(event);
36643787

3665-
if (event.type === "message_end" && event.message.role === "assistant") {
3666-
const correlationId = this._promptEventContext.getStore();
3667-
if (correlationId !== undefined) this._promptLifecycles.addUsage(correlationId, event.message.usage);
3788+
if (event.type === "message_end" && event.message.role === "assistant" && eventCorrelationId !== undefined) {
3789+
this._promptLifecycles.addUsage(eventCorrelationId, event.message.usage);
36683790
}
3669-
this._emit(event);
3791+
this._emit({ ...event, promptCorrelationId: eventCorrelationId ?? null } as AgentSessionEvent);
36703792

36713793
if (event.type === "message_end") {
36723794
if (event.message.role === "custom") {
@@ -4251,6 +4373,7 @@ export class AgentSession {
42514373
return;
42524374
}
42534375
this._disposed = true;
4376+
this._failDeferredPromptLifecycles();
42544377
for (const run of this._unsettledRlmChildRuns) run.suppressTerminalNotice = true;
42554378
for (const controller of this._rlmQuiescenceWaitAborts) controller.abort();
42564379
this._sessionActionCommitDisposeAbortController.abort();
@@ -6063,7 +6186,7 @@ export class AgentSession {
60636186
this._emitQueueUpdate();
60646187
try {
60656188
const startPreparedTurnActions = () => this._startPreparedTurnActions(actions, epoch);
6066-
await (first.promptCorrelationId
6189+
const postCompactionPromptSettlement = await (first.promptCorrelationId
60676190
? this._promptEventContext.run(first.promptCorrelationId, startPreparedTurnActions)
60686191
: startPreparedTurnActions());
60696192
for (const action of actions) {
@@ -6079,7 +6202,14 @@ export class AgentSession {
60796202
}
60806203
if (action.lifecycle.state === "running") {
60816204
transitionSessionAction(action, { state: "completed" });
6082-
this._settlePromptLifecycle(action.promptCorrelationId, "completed");
6205+
if (action.promptCorrelationId !== undefined && postCompactionPromptSettlement) {
6206+
this._deferPromptLifecycleSettlement(
6207+
action.promptCorrelationId,
6208+
postCompactionPromptSettlement,
6209+
);
6210+
} else {
6211+
this._settlePromptLifecycle(action.promptCorrelationId, "completed");
6212+
}
60836213
this._actionStore.ticketFor(action).settleCompleted();
60846214
this._settleAgentMessage(action.agentMessageId, "completion");
60856215
}
@@ -6266,15 +6396,22 @@ export class AgentSession {
62666396
}
62676397
}
62686398

6269-
private async _startPreparedTurnActions(actions: QueuedSessionAction[], epoch: number): Promise<void> {
6399+
private async _startPreparedTurnActions(
6400+
actions: QueuedSessionAction[],
6401+
epoch: number,
6402+
): Promise<PostCompactionContinuationSettlement | undefined> {
62706403
let nextTurnMessages: CustomMessage[] = [];
62716404
const activeTurns = () =>
62726405
actions.filter(
62736406
(action): action is SessionAction<PreparedTurnPayload> =>
62746407
action.payload.kind === "turn" && action.lifecycle.state === "preparing",
62756408
);
62766409
const firstTurn = activeTurns()[0];
6277-
if (!firstTurn) return;
6410+
if (!firstTurn) return undefined;
6411+
const postCompactionScheduleRevisionBeforeTurn =
6412+
firstTurn.promptCorrelationId === undefined
6413+
? 0
6414+
: (this._postCompactionPromptScheduleRevisions.get(firstTurn.promptCorrelationId) ?? 0);
62786415
const executionPolicy = firstTurn.payload.executionPolicy;
62796416
const restoreNextTurnContext = () => {
62806417
this._pendingNextTurnMessages.unshift(...nextTurnMessages);
@@ -6375,6 +6512,12 @@ export class AgentSession {
63756512
await promptPromise;
63766513
if (executionPolicy.completionIncludesRetryChain) await this.waitForRetry();
63776514
if (!this._hasCancelledDispatchCapture()) await this._agentEventQueue;
6515+
if (
6516+
firstTurn.promptCorrelationId !== undefined &&
6517+
this._promptEventQueueFailures.has(firstTurn.promptCorrelationId)
6518+
) {
6519+
throw new Error("Correlated prompt event processing failed");
6520+
}
63786521
if (
63796522
turns.some(
63806523
(action) =>
@@ -6386,6 +6529,18 @@ export class AgentSession {
63866529
throw new Error("Session input dispatch settled without durable delivery");
63876530
}
63886531
this._forgetConsumedPostCompactionContinuations(turns.map((action) => primaryDeliveryRecord(action).message));
6532+
const postCompactionContinuation = this._postCompactionContinuationSettlement;
6533+
if (
6534+
firstTurn.promptCorrelationId !== undefined &&
6535+
postCompactionContinuation !== undefined &&
6536+
!postCompactionContinuation.settled &&
6537+
(this._postCompactionPromptScheduleRevisions.get(firstTurn.promptCorrelationId) ?? 0) !==
6538+
postCompactionScheduleRevisionBeforeTurn
6539+
) {
6540+
this._postCompactionPromptOwners.set(postCompactionContinuation, firstTurn.promptCorrelationId);
6541+
return postCompactionContinuation;
6542+
}
6543+
return undefined;
63896544
} catch (error) {
63906545
const delivered = new Set(this.agent.state.messages);
63916546
this._pendingNextTurnMessages.unshift(...nextTurnMessages.filter((message) => !delivered.has(message)));
@@ -6748,6 +6903,8 @@ export class AgentSession {
67486903
this._emitQueueUpdate();
67496904
return "applied";
67506905
}
6906+
// Correlated ownership binds the request fingerprint to the admitted payload.
6907+
if (item.promptCorrelationId !== undefined) return "rejected";
67516908
if (
67526909
item.payload.kind === "turn" &&
67536910
(item.payload.acceptedAgentMessage ||
@@ -7182,13 +7339,19 @@ export class AgentSession {
71827339
}
71837340
}
71847341

7185-
/** Waits out any owned post-compaction continuation and rejects when one cannot start; {@link waitForIdle} never rejects. */
7342+
/** Waits out owned post-compaction work and rejects when it cannot start or correlated event processing fails. */
71867343
async waitForHeadlessIdle(): Promise<void> {
71877344
while (true) {
71887345
await this.waitForIdle();
71897346
const postCompactionContinuation = this._postCompactionContinuationSettlement?.promise;
7190-
if (!postCompactionContinuation) return;
7191-
await postCompactionContinuation;
7347+
const promptLifecycleSettlements = [...this._deferredPromptLifecycleSettlements.keys()];
7348+
if (!postCompactionContinuation && promptLifecycleSettlements.length === 0) return;
7349+
const settlements = postCompactionContinuation
7350+
? [postCompactionContinuation, ...promptLifecycleSettlements]
7351+
: promptLifecycleSettlements;
7352+
const results = await Promise.allSettled(settlements);
7353+
const failure = results.find((result): result is PromiseRejectedResult => result.status === "rejected");
7354+
if (failure) throw failure.reason;
71927355
}
71937356
}
71947357

@@ -7239,6 +7402,7 @@ export class AgentSession {
72397402
}
72407403

72417404
requestAbort(): void {
7405+
this._failDeferredPromptLifecycles();
72427406
for (const run of [...this._unsettledRlmChildRuns]) {
72437407
if (run.status === "cancelled") this._abandonRlmRunForQuiescence(run);
72447408
}
@@ -7286,6 +7450,7 @@ export class AgentSession {
72867450
}
72877451

72887452
abortForUpdateRestart(): void {
7453+
this._failDeferredPromptLifecycles();
72897454
// Cancel scheduled pumps and suspend new ones: queued inputs must survive
72907455
// into the restart manifest instead of starting a turn during teardown.
72917456
this._sessionInputPumpRequested = false;
@@ -7890,6 +8055,7 @@ export class AgentSession {
78908055
}
78918056

78928057
private async _invalidatePendingAutoRefineForBranchChange(): Promise<void> {
8058+
this._failDeferredPromptLifecycles();
78938059
this._autoRefineReviewAbort?.abort();
78948060
this._discardPendingAutoRefine({ cancelPostCompactionContinue: true });
78958061
this._assistantTurnsSinceAutoRefine = 0;

‎packages/coding-agent/test/agent-session-queue-mutation.test.ts‎

Lines changed: 32 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -148,6 +148,38 @@ describe("AgentSession queue mutation", () => {
148148
await running.catch(() => {});
149149
});
150150

151+
it("keeps correlated queued payloads immutable while allowing reordering and cancellation", async () => {
152+
createSession();
153+
const { running } = await blockSession();
154+
await session.prompt("owned", {
155+
streamingBehavior: "followUp",
156+
queueIfBusy: true,
157+
promptCorrelationId: "owned-queue",
158+
});
159+
await session.followUp("other");
160+
const fingerprintBefore = session
161+
.getSessionActionRecoverySnapshot()
162+
.promptLifecycles?.requestFingerprints?.find((entry) => entry.correlationId === "owned-queue");
163+
164+
expect(queue().followUp).toEqual(["owned", "other"]);
165+
expect(fingerprintBefore).toBeDefined();
166+
expect(mutate("followUp", 0, "owned", { type: "replace", text: "rewritten", lane: "steering" })).toBe("rejected");
167+
expect(queue().followUp).toEqual(["owned", "other"]);
168+
expect(
169+
session
170+
.getSessionActionRecoverySnapshot()
171+
.promptLifecycles?.requestFingerprints?.find((entry) => entry.correlationId === "owned-queue"),
172+
).toEqual(fingerprintBefore);
173+
174+
expect(mutate("followUp", 0, "owned", { type: "move", direction: 1 })).toBe("applied");
175+
expect(queue().followUp).toEqual(["other", "owned"]);
176+
expect(mutate("followUp", 1, "owned", { type: "delete" })).toBe("applied");
177+
expect(session.getPromptLifecycle("owned-queue")).toMatchObject({ phase: "cancelled" });
178+
179+
await session.abort();
180+
await running.catch(() => {});
181+
});
182+
151183
it("replaces turn text in place across the image tri-state and rebuilds content and records", async () => {
152184
createSession();
153185
const { running } = await blockSession();

0 commit comments

Comments
 (0)