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
1 change: 1 addition & 0 deletions packages/coding-agent/CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

## [Unreleased]
- Conventional MCP autoload now reads the user scope from the agent directory instead of a home-relative `<home>/.gjc/agent` path (#4767). Every writer and denylist reader already resolved user scope through `getMCPConfigPath("user")` (the agent directory), while native discovery derived it from the load context's home, so the two disagreed the moment an agent-directory profile was in play: `GJC_CODING_AGENT_DIR=<profile> gjc mcp add <name>` wrote `<profile>/mcp.json` and reported the server as loaded by ordinary sessions at startup, but startup read `~/.gjc/agent/mcp.json` — the profile's own registrations never loaded and the default profile's servers loaded into the profile instead. `loadAllMCPConfigs` accepts an `agentDir`, sessions created with their own `agentDir` pass it, and the `disabledServers` denylist follows the same scope, so discovery, `gjc mcp add`, the `/mcp` wizard, and `gjc customize doctor` all name one file. This also restores isolation for the MCP autoload suites, which established their temp user scope with `setAgentDir()`: after the trusted-home provenance rework their `os.homedir()` mock no longer reached discovery, so on a developer machine the red-team suite read the real `~/.gjc/agent/mcp.json` and in CI it found nothing.
- Immediate `turn.prompt` after `turn.abort` acknowledgement now starts exactly one successor turn instead of being silently consumed by aborted-turn teardown. Abort unwind no longer classifies the successor as steering into the dying loop, delayed `agent_end` is paired with the aborted invocation rather than the successor, and abort acknowledgement waits for that serialized terminal transition — no client-side sleep (#4749). Retention is bounded in the other direction too: abort stays terminal across overlapping aborts. Every abort request advances an abort-admission epoch synchronously, including one that shares an already in-flight unwind, so a prompt admitted between two aborts is refused as a cancelled preflight instead of refreshing its generation and starting work the user already aborted twice. An abort sharing another abort's unwind also applies its own request-scoped effects (preflight cancellation, and abort visibility so a later real abort is not silenced by an earlier `silent: true` abort). A prompt admitted after an abort settles still starts a normal successor turn.
- Esc/Ctrl+C now recover a WSL/basic-terminal session whose busy indicator outlived its turn (#4741). Both the global interrupt/clear listener and the editor escape handler treated any mounted working loader as unconditionally cancellable work: after a completed turn left the loader mounted, every press ran a no-op abort, consumed the key, and reset the escape gestures, so the composer stayed `working [esc]`/`Fetching … [esc]` and Esc, Ctrl+C, and Ctrl+X never did anything (Ctrl+Z suspend/resume was the only escape). The loading branch now stops the stale indicator via the shared activity-indicator stop and lets the key fall through to idle semantics when there is no pending submission (including a started one still inside prompt preflight, exposed through the new `hasPendingSubmission()` context query), no queued steering/follow-up/compaction messages, and the session is neither streaming nor compacting; active work (streaming, queued messages, pending optimistic or started-preflight submission) still aborts exactly as before. Recovery gates on drainable queues only: the new `AgentSession.drainableQueuedMessageCount` counts exactly the steering and follow-up entries that `clearQueue()`/`popLastQueuedMessage()`/`getQueuedMessageEntries()` return, whereas the aggregate `queuedMessageCount` also counts hidden next-turn context that no key press can drain and that deliberately survives turn completion (a `todo_write` failure reminder queued with `deliverAs: "nextTurn"` and no `triggerTurn`) — gating on the aggregate left a permanently nonzero count that reproduced the same lockout while idle. Hidden next-turn ordering and delivery are unchanged; recovery neither clears nor delivers those entries. The same correction applies to the two adjacent gates whose handlers are also visible-queue-only: `app.message.sendNow` no longer advertises itself when only hidden context is queued (its only outcome was "No visible queued message to send"), and an empty submit while streaming no longer aborts the live turn to flush a queue that holds nothing drainable.
- Bare-default Codex and Anthropic provider-overload retries now honor the configured retry ceiling instead of entering the unbounded transient path, and every replay still requires a clean retry scope after extension lifecycle handlers participate.
- `gjc accounts` command errors no longer escape as uncaught exceptions in text mode. `accounts pin` resolves its target through `resolveOAuthPinTarget`, which throws a typed `OAuthCredentialSelectorError` for user-correctable selector problems (API-key rows, active overrides, disabled or missing accounts, ambiguity), and `runAccountsCommand` rendered `AccountsCommandError` only in `--json` mode — text mode rethrew everything, so even the command's own "Provider … is not configured; no pin was written" surfaced as a stack trace plus a `gjc-crash.log` entry. Selector failures now map to `AccountsCommandError` with the message preserved (so `--json` reports `accounts-error` instead of `internal-error`), and text mode prints one clean stderr line with exit code 1. The framework's `CliParseError` handling and the JSON machine contract (exactly one document, never stacks or secrets) are unchanged.
Expand Down
90 changes: 90 additions & 0 deletions packages/coding-agent/src/sdk/host/session-runtime.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2534,6 +2534,96 @@ describe("post-acceptance invocation terminalization", () => {
await rm(cwd, { recursive: true, force: true });
}
});
test("immediate prompt after abort ack is not terminalized by the aborted turn's delayed agent_end", async () => {
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-abort-immediate-prompt-"));
try {
const firstInflight = Promise.withResolvers<void>();
const successorStarted = Promise.withResolvers<void>();
let prompts = 0;
const harness = await invocationHarness("abort-immediate-prompt", cwd, {
sendUserMessage: async (_content, options) => {
prompts += 1;
await options?.onPreflightAcceptCommit?.();
if (prompts === 1) {
await firstInflight.promise;
return;
}
successorStarted.resolve();
await Promise.withResolvers<void>().promise;
},
});
const first = await harness.control("turn.prompt", { text: "first" });
expect(first.ok).toBe(true);
const firstIds = { commandId: first.result?.commandId, turnId: first.result?.turnId };
await harness.emit("agent_start");
expect(await harness.control("turn.abort", {})).toMatchObject({ ok: true });
firstInflight.reject(Object.assign(new Error("turn aborted"), { code: "aborted" }));
const second = await harness.control("turn.prompt", { text: "successor" });
expect(second.ok).toBe(true);
const secondIds = { commandId: second.result?.commandId, turnId: second.result?.turnId };
expect(secondIds.commandId).not.toBe(firstIds.commandId);
expect(secondIds.turnId).not.toBe(firstIds.turnId);
await harness.emit("agent_start");
await successorStarted.promise;
await harness.emit("agent_end");
expect(await harness.query("turn.prompt_status", secondIds)).toMatchObject({
result: { status: expect.stringMatching(/accepted|in_flight/) },
});
expect(await settledStatus(harness, "turn.prompt_status", firstIds)).toMatchObject({
status: "failed",
error: { code: "aborted" },
});
await harness.emit("agent_end");
expect(await settledStatus(harness, "turn.prompt_status", secondIds)).toMatchObject({
status: "terminal_ok",
});
expect(prompts).toBe(2);
await harness.stop();
} finally {
await Bun.sleep(50);
await rm(cwd, { recursive: true, force: true });
}
});

test("abort_and_prompt starts exactly one successor and is not duplicated by delayed abort teardown", async () => {
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-abort-and-prompt-once-"));
try {
const firstInflight = Promise.withResolvers<void>();
const abortReleased = Promise.withResolvers<void>();
let prompts = 0;
const harness = await invocationHarness("abort-and-prompt-once", cwd, {
sendUserMessage: async (_content, options) => {
prompts += 1;
await options?.onPreflightAcceptCommit?.();
if (prompts === 1) {
await firstInflight.promise;
return;
}
},
abort: () => abortReleased.promise,
});
const first = await harness.control("turn.prompt", { text: "first" });
expect(first.ok).toBe(true);
await harness.emit("agent_start");
const replacement = harness.control("turn.abort_and_prompt", { text: "replacement" });
firstInflight.reject(Object.assign(new Error("turn aborted"), { code: "aborted" }));
await harness.emit("agent_end");
abortReleased.resolve();
const accepted = await replacement;
expect(accepted.ok).toBe(true);
const successorIds = { commandId: accepted.result?.commandId, turnId: accepted.result?.turnId };
await harness.emit("agent_start");
await harness.emit("agent_end");
expect(await settledStatus(harness, "turn.prompt_status", successorIds)).toMatchObject({
status: "terminal_ok",
});
expect(prompts).toBe(2);
await harness.stop();
} finally {
await Bun.sleep(50);
await rm(cwd, { recursive: true, force: true });
}
});

test("a failed skill invocation still reports a terminal failed status", async () => {
const cwd = await mkdtemp(path.join(os.tmpdir(), "gjc-terminalize-skill-"));
Expand Down
93 changes: 60 additions & 33 deletions packages/coding-agent/src/sdk/host/session-runtime.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2351,8 +2351,8 @@ function createControlSurface(
// Follow-ups never start inline; ownership correlates at promotion.
true,
),
abort: () => {
ctx.abort();
abort: async () => {
await Promise.resolve(ctx.abort()).catch(() => undefined);
return { aborted: true };
},
abortTerminal: terminalAbort,
Expand Down Expand Up @@ -2573,6 +2573,13 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
waitForGateResolutionQuiescence: () => Promise<void>;
activeInvocation?: { kind: InvocationKind; correlation: InvocationCorrelation };
drainedInvocations?: Array<{ kind: InvocationKind; correlation: InvocationCorrelation }>;
openLifecycleBatches: Array<{
invocations: Array<{
kind: InvocationKind;
correlation: InvocationCorrelation;
connectionId: string | undefined;
}>;
}>;
disposeGate?: () => void;
}
| undefined;
Expand All @@ -2594,6 +2601,28 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
const emitLifecycle = async (type: "agent_start" | "agent_end", ctx: ExtensionContext): Promise<void> => {
const current = active;
if (!current) return;
const adoptLifecycleBatch = (
batch:
| Array<{
kind: InvocationKind;
correlation: InvocationCorrelation;
connectionId: string | undefined;
}>
| undefined,
): void => {
if (!batch || batch.length === 0) {
current.activeInvocation = undefined;
current.drainedInvocations = undefined;
activePromptOwnerHolder.connectionIds = undefined;
return;
}
current.activeInvocation = batch[0];
current.drainedInvocations = batch.map(({ kind, correlation }) => ({ kind, correlation }));
const owners = new Set<string>();
for (const entry of batch) if (entry.connectionId !== undefined) owners.add(entry.connectionId);
activePromptOwnerHolder.connectionIds = owners;
};
let transitions: Array<{ kind: InvocationKind; correlation: InvocationCorrelation }> = [];
if (type === "agent_start") {
// Drain EVERY entry admitted for this run: a continuation may promote
// several follow-ups (each with its own requester correlation) into one
Expand All @@ -2603,19 +2632,24 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
// continuation agent_start with an empty queue leaves the current owner
// untouched (review thread P1).
const drained = current.pending.splice(0);
current.activeInvocation = drained[0];
if (drained.length > 0) {
const owners = new Set<string>();
for (const entry of drained) if (entry.connectionId !== undefined) owners.add(entry.connectionId);
activePromptOwnerHolder.connectionIds = owners;
// A single run may drain several follow-ups promoted together; each
// has its own durable record that must reach terminal state, so keep
// the full batch for the transition pass below (review thread P1).
current.drainedInvocations = drained.map(({ kind, correlation }) => ({ kind, correlation }));
}
if (current.activeInvocation?.kind === "prompt") {
current.deadlineManager.onAccepted(current.activeInvocation.correlation);
current.openLifecycleBatches.push({ invocations: drained });
adoptLifecycleBatch(drained);
if (current.activeInvocation?.kind === "prompt") {
current.deadlineManager.onAccepted(current.activeInvocation.correlation);
}
}
transitions = drained.map(({ kind, correlation }) => ({ kind, correlation }));
} else {
// Pair this agent_end with the oldest unmatched start. A delayed
// aborted-turn end that lands after a successor agent_start must
// terminalize the aborted invocation, never the successor.
const ended = current.openLifecycleBatches[0];

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Claim lifecycle batches before awaiting reconciliation

When the aborted turn's agent_end handler pauses in noteTransition and a fast successor starts and ends, both end handlers can read the same head batch because it is shifted only after the asynchronous transition work. The successor end then terminalizes the predecessor again, while both handlers eventually remove their respective queue entries without ever applying an end transition to the successor, leaving its prompt status accepted or in-flight indefinitely. Fresh evidence beyond the earlier delayed-handler report is that this revision lets both handlers capture openLifecycleBatches[0] before either removes it; claim or shift each batch synchronously when its end handler starts.

Useful? React with 👍 / 👎.

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P1 Badge Correlate publication waiters to lifecycle batches

When the aborted turn's agent_end handler is delayed and a fast successor starts and ends first, the successor handler can reach the global terminalPublicationCapture.resolvers and resolve every waiter even if its claimed lifecycle batch belongs to the successor. The terminal abort then records the predecessor's terminalPublished as true and returns before the predecessor event reaches the ring/broadcast. Associate each publication waiter with the invocation batch/correlation selected here and resolve only waiters for that batch.

Useful? React with 👍 / 👎.

transitions = ended
? ended.invocations.map(({ kind, correlation }) => ({ kind, correlation }))
: current.activeInvocation
? [current.activeInvocation]
: [];
}
// Observe whether the lifecycle publication actually landed: a terminal
// abort awaits this result so its durable row only claims
Expand All @@ -2624,12 +2658,6 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
// recorded as observed=false, never rethrown into the api handler.
let observed = true;
try {
const transitions =
current.drainedInvocations && current.drainedInvocations.length > 0
? current.drainedInvocations
: current.activeInvocation
? [current.activeInvocation]
: [];
for (const invocation of transitions) {
await current.reconciliation.noteTransition(invocation.kind, invocation.correlation, { type } as never);
if ((type as string) === "agent_end" || (type as string) === "agent_failed") {
Expand All @@ -2641,20 +2669,10 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
observed = false;
}
if (type === "agent_end") {
if (current.activeInvocation?.kind === "prompt") {
current.deadlineManager.clear(current.activeInvocation.correlation);
} else if (current.drainedInvocations) {
for (const inv of current.drainedInvocations)
if (inv.kind === "prompt") current.deadlineManager.clear(inv.correlation);
}
current.activeInvocation = undefined;
current.drainedInvocations = undefined;
// The turn is over: no connection owns it anymore. Clearing here means
// an abort against a later agent-initiated turn (monitor/cron
// follow-up) finds no owner and fails closed, instead of letting the
// previous prompt's owner stop a turn it did not submit (review
// thread P1).
activePromptOwnerHolder.connectionIds = undefined;
if (current.openLifecycleBatches.length > 0) current.openLifecycleBatches.shift();
for (const invocation of transitions)
if (invocation.kind === "prompt") current.deadlineManager.clear(invocation.correlation);
adoptLifecycleBatch(current.openLifecycleBatches[0]?.invocations);
// Resolve EVERY concurrent waiter for the aborted turn: the turn emits
// exactly one agent_end, and each admitted abort of it must observe the
// same publication result rather than a single latest-wins slot (review
Expand Down Expand Up @@ -2728,6 +2746,13 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
correlation: InvocationCorrelation;
connectionId: string | undefined;
}> = [];
const openLifecycleBatches: Array<{
invocations: Array<{
kind: InvocationKind;
correlation: InvocationCorrelation;
connectionId: string | undefined;
}>;
}> = [];
const configRevision = { current: 0 };
let acceptingGateResolutions = true;
const inFlightGateResolutions = new Set<Promise<unknown>>();
Expand Down Expand Up @@ -3003,6 +3028,7 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
steerReconciliation,
deadlineManager,
pending,
openLifecycleBatches,
registerBroker,
fenceGateResolutions: () => {
acceptingGateResolutions = false;
Expand Down Expand Up @@ -3031,6 +3057,7 @@ export function createSdkSessionRuntimeExtension(api: ExtensionAPI, options: Cre
steerReconciliation,
deadlineManager,
pending,
openLifecycleBatches,
registerBroker,
fenceGateResolutions: () => {
acceptingGateResolutions = false;
Expand Down
4 changes: 1 addition & 3 deletions packages/coding-agent/src/sdk/session.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2849,9 +2849,7 @@ export async function createAgentSession(options: CreateAgentSessionOptions = {}
model: agent.state.model,
isIdle: () => !session.isStreaming,
hasQueuedMessages: () => session.queuedMessageCount > 0,
abort: () => {
session.abort();
},
abort: () => session.abort(),
settings,
});
const toolContextStore = new ToolContextStore(getSessionContext);
Expand Down
Loading
Loading