From b500f288e5050b770a78574904b9ecca9efa634d Mon Sep 17 00:00:00 2001 From: gaebal-gajae Date: Tue, 4 Aug 2026 09:23:44 +0000 Subject: [PATCH 1/2] fix(extensions): preserve live handler context accessors Attaching timeout signals by spreading the extension context eagerly evaluated and froze its live model getter. It also moved accessor failures outside the runner error boundary, breaking SDK lifecycle containment. Lore-id: c3817ded Constraint: each handler must receive an isolated timeout AbortSignal without snapshotting live context accessors Rejected: mutate the shared emit context | timed-out handlers could observe a later sibling signal Rejected: prototype delegation | changes own-property enumeration semantics for extensions Confidence: high Scope-risk: narrow Reversibility: easy Tested: extension runner, SDK model selection, SDK host lifecycle, coding-agent typecheck Not-tested: complete shard under operator home because unrelated GC discovery exceeds its 20000-entry cap --- packages/coding-agent/CHANGELOG.md | 1 + .../src/extensibility/extensions/runner.ts | 11 +++- .../test/extensions-runner.test.ts | 61 +++++++++++++++++++ 3 files changed, 72 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/CHANGELOG.md b/packages/coding-agent/CHANGELOG.md index 42b1677a7c..e85f542aa2 100644 --- a/packages/coding-agent/CHANGELOG.md +++ b/packages/coding-agent/CHANGELOG.md @@ -13,6 +13,7 @@ ### Fixed - Telegram daemon restart now revokes every persisted callback alias before polling. Reconnecting sessions must replay a pending ask to receive fresh, owner-bound aliases; old controls remain stale, and their keyboards are best-effort terminalized when the original Telegram message id is available. Shutdown now fences new session messages and drains every admitted handler before final callback persistence and ownership release, preventing a successful send racing shutdown from publishing alias state after a successor takes ownership (#3727). +- Extension handler timeout signals now preserve lazy, live context accessors instead of eagerly snapshotting them. Model changes made through SDK controls are immediately visible to later context reads, and unused getters can no longer reject lifecycle emission before the runner's extension error boundary (#3817). - Direct interactive launches inside tmux now bind automatic window renames to the originating pane's immutable pane/window identities and observed window index. If that binding changes before mutation, GJC preserves every window name instead of renaming whichever window became active (#3808). ### Fixed diff --git a/packages/coding-agent/src/extensibility/extensions/runner.ts b/packages/coding-agent/src/extensibility/extensions/runner.ts index 23ac33a39a..bb77361f25 100644 --- a/packages/coding-agent/src/extensibility/extensions/runner.ts +++ b/packages/coding-agent/src/extensibility/extensions/runner.ts @@ -79,6 +79,15 @@ export function testSetExtensionHandlerTimeoutMs(timeoutMs: number): void { const EXTENSION_HANDLER_TIMEOUT = Symbol("extensionHandlerTimeout"); const MAX_PENDING_CREDENTIAL_DISABLED = 32; +function createHandlerContext(ctx: ExtensionContext, signal: AbortSignal): ExtensionContext { + const descriptors = Object.getOwnPropertyDescriptors(ctx); + descriptors.signal = { + configurable: true, + enumerable: true, + value: signal, + }; + return Object.defineProperties({}, descriptors) as ExtensionContext; +} /** * Events handled by the generic emit() method. @@ -690,7 +699,7 @@ export class ExtensionRunner { ): Promise { let timeout: NodeJS.Timeout | undefined; const abortController = new AbortController(); - const handlerContext: ExtensionContext = { ...ctx, signal: abortController.signal }; + const handlerContext = createHandlerContext(ctx, abortController.signal); try { const timeoutPromise = new Promise(resolve => { timeout = setTimeout(() => resolve(EXTENSION_HANDLER_TIMEOUT), timeoutMs); diff --git a/packages/coding-agent/test/extensions-runner.test.ts b/packages/coding-agent/test/extensions-runner.test.ts index 9241a9f8be..4fdd0acc1f 100644 --- a/packages/coding-agent/test/extensions-runner.test.ts +++ b/packages/coding-agent/test/extensions-runner.test.ts @@ -12,6 +12,8 @@ import { ExtensionRunner, testSetExtensionHandlerTimeoutMs, } from "@gajae-code/coding-agent/extensibility/extensions/runner"; +import type { ExtensionContext } from "@gajae-code/coding-agent/extensibility/extensions/types"; + import { AuthStorage } from "@gajae-code/coding-agent/session/auth-storage"; import { SessionManager } from "@gajae-code/coding-agent/session/session-manager"; import { getProjectAgentDir, logger, TempDir } from "@gajae-code/utils"; @@ -645,6 +647,65 @@ describe("ExtensionRunner", () => { }); describe("handler timeouts", () => { + it("preserves live context accessors while attaching a handler abort signal", async () => { + let currentModel = { id: "first-model" }; + const observedModels: string[] = []; + const extension = { + path: "live-context-extension", + handlers: new Map([ + [ + "session_start", + [ + async (_event: unknown, ctx: ExtensionContext) => { + expect(ctx.signal).toBeInstanceOf(AbortSignal); + observedModels.push(ctx.model?.id ?? "missing"); + currentModel = { id: "second-model" }; + observedModels.push(ctx.model?.id ?? "missing"); + }, + ], + ], + ]), + }; + const runner = new ExtensionRunner( + [extension as never], + { flagValues: new Map(), pendingProviderRegistrations: [] } as never, + tempDir.path(), + sessionManager, + modelRegistry, + ); + runner.initialize({} as never, { getModel: () => currentModel } as never); + + await expect(runner.emit({ type: "session_start" })).resolves.toBeUndefined(); + expect(observedModels).toEqual(["first-model", "second-model"]); + }); + + it("does not evaluate unused context accessors before the handler error boundary", async () => { + const handler = vi.fn(async (_event: unknown, ctx: ExtensionContext) => { + expect(ctx.signal).toBeInstanceOf(AbortSignal); + }); + const extension = { + path: "lazy-context-extension", + handlers: new Map([["session_start", [handler]]]), + }; + const runner = new ExtensionRunner( + [extension as never], + { flagValues: new Map(), pendingProviderRegistrations: [] } as never, + tempDir.path(), + sessionManager, + modelRegistry, + ); + runner.initialize( + {} as never, + { + getModel: () => { + throw new Error("model accessor must stay lazy"); + }, + } as never, + ); + + await expect(runner.emit({ type: "session_start" })).resolves.toBeUndefined(); + expect(handler).toHaveBeenCalledTimes(1); + }); it("times out session_start handlers, emits an error, and continues to sibling extensions", async () => { const hangExtensionPath = path.join(tempDir.path(), "hang-session-start.ts"); const fastExtensionPath = path.join(tempDir.path(), "fast-session-start.ts"); From 42e73aa1a5ce4106fbd02fa351f05a1be26ec48c Mon Sep 17 00:00:00 2001 From: gaebal-gajae Date: Tue, 4 Aug 2026 09:51:22 +0000 Subject: [PATCH 2/2] fix(extensions): keep handler signals writable Descriptor cloning preserved live accessors but accidentally made the injected signal non-writable. ExtensionContext exposes a mutable signal property, so strict extensions must retain object-literal assignment behavior. Lore-id: c3817sig Constraint: preserve the signal descriptor semantics introduced by the extension timeout context Confidence: high Scope-risk: narrow Reversibility: easy Tested: focused extension runner and SDK lifecycle/model suite; coding-agent check --- .../src/extensibility/extensions/runner.ts | 1 + .../coding-agent/test/extensions-runner.test.ts | 14 +++++++++++++- 2 files changed, 14 insertions(+), 1 deletion(-) diff --git a/packages/coding-agent/src/extensibility/extensions/runner.ts b/packages/coding-agent/src/extensibility/extensions/runner.ts index bb77361f25..f09337fa48 100644 --- a/packages/coding-agent/src/extensibility/extensions/runner.ts +++ b/packages/coding-agent/src/extensibility/extensions/runner.ts @@ -84,6 +84,7 @@ function createHandlerContext(ctx: ExtensionContext, signal: AbortSignal): Exten descriptors.signal = { configurable: true, enumerable: true, + writable: true, value: signal, }; return Object.defineProperties({}, descriptors) as ExtensionContext; diff --git a/packages/coding-agent/test/extensions-runner.test.ts b/packages/coding-agent/test/extensions-runner.test.ts index 4fdd0acc1f..fa088f9399 100644 --- a/packages/coding-agent/test/extensions-runner.test.ts +++ b/packages/coding-agent/test/extensions-runner.test.ts @@ -647,7 +647,7 @@ describe("ExtensionRunner", () => { }); describe("handler timeouts", () => { - it("preserves live context accessors while attaching a handler abort signal", async () => { + it("preserves live context accessors and writable signal semantics", async () => { let currentModel = { id: "first-model" }; const observedModels: string[] = []; const extension = { @@ -658,6 +658,18 @@ describe("ExtensionRunner", () => { [ async (_event: unknown, ctx: ExtensionContext) => { expect(ctx.signal).toBeInstanceOf(AbortSignal); + const descriptor = Object.getOwnPropertyDescriptor(ctx, "signal"); + expect(descriptor).toMatchObject({ + configurable: true, + enumerable: true, + value: ctx.signal, + writable: true, + }); + const replacementSignal = new AbortController().signal; + expect(() => { + ctx.signal = replacementSignal; + }).not.toThrow(); + expect(ctx.signal).toBe(replacementSignal); observedModels.push(ctx.model?.id ?? "missing"); currentModel = { id: "second-model" }; observedModels.push(ctx.model?.id ?? "missing");