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 @@
- Fixed `/goal` re-prompting a parent that had correctly delegated to subagents and ended its turn: the continuation now waits until descendant work settles, then resumes automatically.
50 changes: 50 additions & 0 deletions packages/coding-agent/src/core/agent-session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1060,6 +1060,7 @@ export class AgentSession {

private _goalState: GoalState = emptyGoalState();
private _goalAccountingStartedAt: number | undefined = undefined;
private _goalContinuationAwaitsRlmWork = false;
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
private _goalAccountedAssistantMessages = new WeakSet<AssistantMessage>();
private _goalAbortInProgress = false;
private _autonomousState: AutonomousRuntimeState;
Expand Down Expand Up @@ -1744,6 +1745,7 @@ export class AgentSession {
}

private _clearQueuedGoalContexts(): void {
this._goalContinuationAwaitsRlmWork = false;
this._pendingNextTurnMessages = this._pendingNextTurnMessages.filter(
(message) => message.customType !== GOAL_CONTEXT_CUSTOM_TYPE,
);
Expand Down Expand Up @@ -1775,6 +1777,7 @@ export class AgentSession {
updatedAt: now,
};
this._goalAccountingStartedAt = now;
this._goalContinuationAwaitsRlmWork = false;
this._setGoalState(goal);
return this._goalState;
}
Expand Down Expand Up @@ -2035,6 +2038,41 @@ export class AgentSession {
}
}

private _maybeResumeGoalContinuationAfterRlmWork(): void {
if (!this._goalContinuationAwaitsRlmWork) return;
if (this._disposed || this._disposing || this._hasUnsettledRlmQuiescenceWork()) return;
if (this._goalState.status !== "active" || !this._goalState.objective) {
this._goalContinuationAwaitsRlmWork = false;
return;
}
// Keep the deferral while admission is paused or the pump is suspended
// (post-abort); the pause release and resumeQueuedWork retry.
if (this._sessionInputAdmissionPauses.size > 0 || this._sessionInputPumpSuspended) return;
const goalBeforeResume = this._goalState;
try {
this._ensureGoalRuntimeActive();
this._setGoalState({
...this._goalState,
continuationsUsed: this._goalState.continuationsUsed + 1,
lastReason: undefined,
lastError: undefined,
});
const message = createGoalContextMessage(this._goalState, "continuation");
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
const normalized = normalizeMessageContent(message.content);
// No front: a settling child's terminal notice must be read first.
this._admitSessionInput(
this._createPreparedTurnAction("followUp", normalized.text, normalized.images, {
message,
resumeIfIdle: true,
}),
);
Comment thread
snimu marked this conversation as resolved.
this._goalContinuationAwaitsRlmWork = false;
} catch {
// Admission can race a new pause; roll back so the retry re-counts.
this._setGoalState(goalBeforeResume);
}
Comment thread
cursor[bot] marked this conversation as resolved.
}
Comment thread
macroscopeapp[bot] marked this conversation as resolved.

Comment thread
cursor[bot] marked this conversation as resolved.
private _runOrQueueGoalContext(kind: "continuation" | "objective_updated", images?: ImageContent[]): void {
if (!this._goalState.objective) return;
this._ensureGoalRuntimeActive();
Expand Down Expand Up @@ -3215,6 +3253,13 @@ export class AgentSession {
if (signal?.aborted || this._goalState.status !== "active" || !this._goalState.objective) {
return [];
}
// Delegating and ending the turn is correct behavior; hold the continuation
// until descendants settle instead of re-prompting a waiting parent.
if (this._hasUnsettledRlmQuiescenceWork()) {
this._goalContinuationAwaitsRlmWork = true;
return [];
}
this._goalContinuationAwaitsRlmWork = false;
try {
this._ensureGoalRuntimeActive(context.context);
const nextGoal = {
Expand Down Expand Up @@ -6572,6 +6617,7 @@ export class AgentSession {
this._sessionInputPumpEpoch++;
this._notifySessionInputCheckpointChange();
this._flushDeferredRlmTerminalNotices();
this._maybeResumeGoalContinuationAfterRlmWork();
this._scheduleSessionInputPump();
},
};
Expand Down Expand Up @@ -6688,6 +6734,7 @@ export class AgentSession {
/** Resume the scheduler after requestAbort/abortForUpdateRestart suspended it; owned pause leases are unaffected. */
resumeQueuedWork(): boolean {
this._resumeSessionInputAdmission();
this._maybeResumeGoalContinuationAfterRlmWork();
this._scheduleSessionInputPump();
return this._hasSelectableSessionInput();
}
Expand Down Expand Up @@ -9263,6 +9310,7 @@ export class AgentSession {
this._abandonedRlmQuiescenceChildIds.add(run.id);
this._unsettledRlmChildRuns.delete(run);
run.settlement.resolve();
this._maybeResumeGoalContinuationAfterRlmWork();
}

private _cancelActiveRlmChildRuns(reason: string): void {
Expand Down Expand Up @@ -9601,6 +9649,7 @@ export class AgentSession {
run.settlement.resolve();
run.deletionReservation.resolve();
this._unsettledRlmChildRuns.delete(run);
this._maybeResumeGoalContinuationAfterRlmWork();
}
Comment thread
macroscopeapp[bot] marked this conversation as resolved.

private _observeRlmRunDeletionCleanup(
Expand Down Expand Up @@ -10423,6 +10472,7 @@ export class AgentSession {
run.settled = true;
run.settlement.resolve();
this._unsettledRlmChildRuns.delete(run);
this._maybeResumeGoalContinuationAfterRlmWork();
}
}
})().catch(() => undefined);
Expand Down
125 changes: 125 additions & 0 deletions packages/coding-agent/test/goal-continuation-quiescence.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
import { describe, expect, it, vi } from "vitest";
import { AgentSession } from "../src/core/agent-session.js";

type Harness = {
_goalState: { status: string; objective?: string; continuationsUsed: number };
_goalContinuationAwaitsRlmWork: boolean;
_disposed: boolean;
_disposing: boolean;
_sessionInputAdmissionPauses: Set<symbol>;
_sessionInputPumpSuspended: boolean;
_hasUnsettledRlmQuiescenceWork: () => boolean;
_stopGoalContinuationForTerminalMessage: () => boolean;
_ensureGoalRuntimeActive: () => void;
_setGoalState: (goal: unknown) => void;
_createPreparedTurnAction: ReturnType<typeof vi.fn>;
_admitSessionInput: ReturnType<typeof vi.fn>;
};

const getGoalContinuation = Reflect.get(AgentSession.prototype, "_getGoalContinuationMessages") as (
this: Harness,
context: { message: unknown; context: unknown },
) => Promise<unknown[]>;
const maybeResume = Reflect.get(AgentSession.prototype, "_maybeResumeGoalContinuationAfterRlmWork") as (
this: Harness,
) => void;

function harness(overrides: Partial<Harness> = {}): Harness {
return {
_goalState: { status: "active", objective: "ship it", continuationsUsed: 0 },
_goalContinuationAwaitsRlmWork: false,
_disposed: false,
_disposing: false,
_sessionInputAdmissionPauses: new Set(),
_sessionInputPumpSuspended: false,
_hasUnsettledRlmQuiescenceWork: () => false,
_stopGoalContinuationForTerminalMessage: () => false,
_ensureGoalRuntimeActive: () => {},
_setGoalState: function (this: Harness, goal: unknown) {
this._goalState = goal as Harness["_goalState"];
},
_createPreparedTurnAction: vi.fn((schedule: string, _text: string, _images: unknown, options: unknown) => ({
schedule,
options,
})),
_admitSessionInput: vi.fn(),
...overrides,
};
}

const context = { message: { role: "assistant", stopReason: "stop" }, context: {} };

describe("goal continuation vs unsettled subagent work", () => {
it("defers the continuation while descendant work is unsettled", async () => {
const mode = harness({ _hasUnsettledRlmQuiescenceWork: () => true });
await expect(getGoalContinuation.call(mode, context)).resolves.toEqual([]);
expect(mode._goalContinuationAwaitsRlmWork).toBe(true);
expect(mode._goalState.continuationsUsed).toBe(0);
});

it("continues normally when no descendant work is pending", async () => {
const mode = harness();
const messages = await getGoalContinuation.call(mode, context);
expect(messages).toHaveLength(1);
expect(mode._goalContinuationAwaitsRlmWork).toBe(false);
expect(mode._goalState.continuationsUsed).toBe(1);
});

it("resumes a deferred continuation exactly once, unqueued, idle-waking, and counted", () => {
const mode = harness({ _goalContinuationAwaitsRlmWork: true });
maybeResume.call(mode);
maybeResume.call(mode);
expect(mode._admitSessionInput).toHaveBeenCalledTimes(1);
const [action, options] = mode._admitSessionInput.mock.calls[0]!;
expect((action as { options: { resumeIfIdle: boolean } }).options.resumeIfIdle).toBe(true);
expect(options).toBeUndefined();
expect(mode._goalState.continuationsUsed).toBe(1);
});

it("keeps the deferral while admission is paused and retries after release", () => {
const paused = harness({
_goalContinuationAwaitsRlmWork: true,
_sessionInputAdmissionPauses: new Set([Symbol("pause")]),
});
maybeResume.call(paused);
expect(paused._admitSessionInput).not.toHaveBeenCalled();
expect(paused._goalContinuationAwaitsRlmWork).toBe(true);

paused._sessionInputAdmissionPauses.clear();
maybeResume.call(paused);
expect(paused._admitSessionInput).toHaveBeenCalledTimes(1);
expect(paused._goalContinuationAwaitsRlmWork).toBe(false);
});

it("keeps the deferral while the pump is suspended after an abort", () => {
const mode = harness({ _goalContinuationAwaitsRlmWork: true, _sessionInputPumpSuspended: true });
maybeResume.call(mode);
expect(mode._admitSessionInput).not.toHaveBeenCalled();
expect(mode._goalContinuationAwaitsRlmWork).toBe(true);
});

it("keeps the deferral and rolls back the count when admission throws", () => {
const mode = harness({
_goalContinuationAwaitsRlmWork: true,
_admitSessionInput: vi.fn(() => {
throw new Error("admission race");
}),
});
maybeResume.call(mode);
expect(mode._goalContinuationAwaitsRlmWork).toBe(true);
expect(mode._goalState.continuationsUsed).toBe(0);
});

it("stays deferred while work remains and drops the deferral for inactive goals", () => {
const busy = harness({ _goalContinuationAwaitsRlmWork: true, _hasUnsettledRlmQuiescenceWork: () => true });
maybeResume.call(busy);
expect(busy._admitSessionInput).not.toHaveBeenCalled();
expect(busy._goalContinuationAwaitsRlmWork).toBe(true);

const inactive = harness({ _goalContinuationAwaitsRlmWork: true });
inactive._goalState = { status: "paused", objective: "ship it", continuationsUsed: 0 };
maybeResume.call(inactive);
expect(inactive._admitSessionInput).not.toHaveBeenCalled();
expect(inactive._goalContinuationAwaitsRlmWork).toBe(false);
});
});
Loading