Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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.
38 changes: 38 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.
Resolved
private _goalAccountedAssistantMessages = new WeakSet<AssistantMessage>();
private _goalAbortInProgress = false;
private _autonomousState: AutonomousRuntimeState;
Expand Down Expand Up @@ -2035,6 +2036,32 @@ 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; the pause release retries.
if (this._sessionInputAdmissionPauses.size > 0) return;
try {
this._ensureGoalRuntimeActive();
const message = createGoalContextMessage(this._goalState, "continuation");
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
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.
Resolved
this._goalContinuationAwaitsRlmWork = false;
} catch {
// Admission can race a new pause; the deferral stays set for the retry.
}
Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
}
Comment thread
macroscopeapp[bot] marked this conversation as resolved.
Resolved

Comment thread
cursor[bot] marked this conversation as resolved.
Resolved
private _runOrQueueGoalContext(kind: "continuation" | "objective_updated", images?: ImageContent[]): void {
if (!this._goalState.objective) return;
this._ensureGoalRuntimeActive();
Expand Down Expand Up @@ -3215,6 +3242,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 +6606,7 @@ export class AgentSession {
this._sessionInputPumpEpoch++;
this._notifySessionInputCheckpointChange();
this._flushDeferredRlmTerminalNotices();
this._maybeResumeGoalContinuationAfterRlmWork();
this._scheduleSessionInputPump();
},
};
Expand Down Expand Up @@ -9263,6 +9298,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 +9637,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.
Resolved

private _observeRlmRunDeletionCleanup(
Expand Down Expand Up @@ -10423,6 +10460,7 @@ export class AgentSession {
run.settled = true;
run.settlement.resolve();
this._unsettledRlmChildRuns.delete(run);
this._maybeResumeGoalContinuationAfterRlmWork();
}
}
})().catch(() => undefined);
Expand Down
114 changes: 114 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,114 @@
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>;
_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(),
_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 and idle-waking", () => {
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();
});

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 when admission throws", () => {
const mode = harness({
_goalContinuationAwaitsRlmWork: true,
_admitSessionInput: vi.fn(() => {
throw new Error("admission race");
}),
});
maybeResume.call(mode);
expect(mode._goalContinuationAwaitsRlmWork).toBe(true);
});

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