Repository navigation
Refuse GoogleAdkPlugin Activities that a later plugin replaces (breaking) - #339
Conversation
…ing) #161 made configureWorker refuse a different Activity registered before GoogleAdkPlugin. A plugin applied after it (later in plugins, or on the NativeConnection) still replaced adk-invokeModel and the other plugin Activities without an error. GoogleAdkPlugin.runWorker now checks the Worker's final Activities and throws a TypeError if one of its Activities was replaced by a different one. Before throwing, it stops the native Worker and lets run() release it, so the Worker never polls and connection.close() still works. The error now names the fixes that apply: rename or remove the other Activity, run the other integration on its own Worker and task queue, or use one GoogleAdkPlugin instance on this Worker. The README says which setups are refused and which registrations fail at Worker.create and which at worker.run(). This matches AiSdkPlugin (#162). Breaking change: a Worker whose later plugin replaces a GoogleAdkPlugin Activity used to run and now fails in worker.run().
Worker.runUntil stops the Worker after its callback only if the Worker is already running. Awaiting the check first meant a callback that throws synchronously, or two plugins with the check, left runUntil polling forever.
DABH
left a comment
There was a problem hiding this comment.
Review at head 854783d (two commits over 4bcde9a; main has no google-adk changes since, and #161 is in no released tag: npm's latest is still 0.1.0). On a worktree at the head with pnpm 10.27.0: make lint clean (1 pre-existing warning), make test 235 of 235; a first full run that stalled under unrelated machine load was not the PR (graph.test.js, where it stalled, passes 30 of 30 alone at both the head and main). Both new tests fail without the code they guard: with main's plugin.ts and activity-sources.ts the refusal test never settles (ava's 60 s timeout fires with it pending while the Worker keeps running), and with a143b65's awaited check the runUntil test stalls past the same timeout. Behaviour was probed with real Workers on a dev server: the refused Worker rejects run() within 4 ms with the documented TypeError and ends STOPPED; a Workflow already waiting on its task queue gets no WorkflowTaskStarted and describeTaskQueue lists no workflow or activity poller, while a control Worker on the same queue and the same shared connection then completes the Workflow and does appear; the same instance on the NativeConnection and the Worker, with a later plugin on the connection, is refused the same way and connection.close() succeeds; runUntil surfaces the TypeError directly when its callback has finished, or as the workerError cause of a CombinedWorkerRunError when it has not (the SDK's handling of any fatal Worker error). Checked and not a problem: the pass path starts the Worker in the same tick (SimplePlugin.runWorker is synchronous), which is what runUntil needs; the README's plugin order matches Worker.create in @temporalio/worker 1.24.0 (src/worker.ts:513); the remedy text matches #202 and #162 word for word; and Python (More than one activity named ... at Worker construction) and Go (a duplicate registration panics) already refuse a duplicate in either order, so this brings TypeScript in line. Replay: no change. Only Worker-side code and the README change, Activity names, inputs and results are the same, replay Workers never call runWorker (src/worker.ts:2238), and the suite's replay tests over the checked-in histories pass. Nothing material was left unverified.
Severity: P1 = wrong result or stuck run in realistic use; P2 = correctness or robustness in an edge case; P3 = docs, tests, cleanup.
#161 follow-ups. Both of my comments on #161 are addressed. The README now lists the refused two-instance setups in the suggested words and moves the setActivityOptions clause to the two-options-object case, and a later plugin is now refused at worker.run(). For the error text, the PR keeps one remedy shared with #162 and #202 rather than a message per case; that follows the one-wording point from the same review and still names the fix for both two-instance layouts, so I take it as settled.
Approving: the check does what the PR claims on every layout probed, both new tests fail without the code they guard, replay is unaffected and CI is green; the two comments below are non-blocking.
| `Duplicate registration of Activity '<name>'`; earlier versions kept the last | ||
| one without a warning. If it is registered before `GoogleAdkPlugin` (in the | ||
| Worker's `activities` or by an earlier plugin), `Worker.create` fails. If a | ||
| plugin applied after `GoogleAdkPlugin` replaces one, `worker.run()` fails |
There was a problem hiding this comment.
P3 (non-blocking): a later plugin that wraps these Activities and delegates to them is now refused as well, and neither this section nor the PR's breaking-change note covers that case. End to end on a dev server with plugins: [new GoogleAdkPlugin({ modelProvider }), new SimplePlugin({ name: 'wrap', activities: (existing) => Object.fromEntries(Object.entries(existing ?? {}).map(([k, f]) => [k, (...a) => f(...a)])) })]: on main the agent Workflow completes, with the plugin's model Activity running inside the wrapper; at the head the Worker is refused with Duplicate registration of Activity 'adk-invokeModel': a plugin applied after GoogleAdkPlugin replaced its Activity with this name. The check cannot tell a wrapper from a replacement, so refusing is defensible. But the note frames the old behaviour as a wrong handler running, while a delegating wrapper ran the right one, and the error's remedies do not lead to the fix: moving the wrapper before GoogleAdkPlugin, as the first bullet of this section advises for observability plugins, leaves these Activities unwrapped, and a NativeConnection plugin is always applied after the Worker's. No first-party plugin does this (the SDK's OpenTelemetryPlugin and this repo's TypeScript plugins use interceptors), so it is an edge case. Suggested sentence here, plus a clause in the breaking-change note: 'A plugin applied after GoogleAdkPlugin that wraps these Activities counts as replacing them; wrap Activity execution with an Activity interceptor (interceptors.activity) instead.'
There was a problem hiding this comment.
Added your sentence in a717a48: a plugin applied after GoogleAdkPlugin that wraps these Activities counts as replacing them, so wrap Activity execution with an Activity interceptor (interceptors.activity) instead. The breaking-change note in the PR body now says a delegating wrapper used to run the plugin's handler and is refused now.
| connection, | ||
| namespace: env.namespace, | ||
| taskQueue: uid('adk-later-plugin'), | ||
| plugins: [new GoogleAdkPlugin(), later], |
There was a problem hiding this comment.
P3 (non-blocking, tests): this test uses one plugin instance, so nothing pins the layout that runs the check twice (the same instance on the NativeConnection and the Worker, with the later plugin on the connection), and a refactor to the public worker.shutdown() would pass this test while breaking that layout. I swapped the private initiateShutdown() call in refuseLaterReplacement for const running = next(worker); worker.shutdown(); return running.then(fail, fail);. This test still passes, but in the double layout the inner check stops the Worker first, the outer worker.shutdown() throws IllegalStateError: Not running. Current state: DRAINING instead of the TypeError, and an immediate connection.close() fails with Cannot close connection while Workers hold a reference to it. At the head that layout is refused correctly (the second initiateShutdown() is harmless), so this is only about keeping it that way. This case passes at the head and fails with that swap:
test.serial('runWorker refuses a later replacement with one instance on the connection and the Worker', async (t) => {
const env = getEnv();
const adk = new GoogleAdkPlugin();
const later = new SimplePlugin({ name: 'later', activities: { 'adk-invokeModel': async () => 'other' } });
const connection = await NativeConnection.connect({ address: env.address, plugins: [adk, later] });
const worker = await Worker.create({
connection,
namespace: env.namespace,
taskQueue: uid('adk-later-twice'),
plugins: [adk],
});
await t.throwsAsync(worker.run(), {
instanceOf: TypeError,
message: /^Duplicate registration of Activity 'adk-invokeModel': a plugin applied after GoogleAdkPlugin replaced/,
});
t.is(worker.getState(), 'STOPPED');
await connection.close();
});There was a problem hiding this comment.
Added your test in a717a48: runWorker refuses a later replacement with one instance on the connection and the Worker. It passes at the head. With your swap to the public worker.shutdown(), it fails: that layout throws IllegalStateError instead of the TypeError, while the single-instance test still passes.
A later plugin that wraps GoogleAdkPlugin's Activities and delegates to them is refused too, because the check cannot tell a wrapper from a replacement. The README now says so and points to Activity interceptors instead. A new test runs the check twice: the same instance on the NativeConnection and the Worker, with the later plugin on the connection. It fails if the release switches to the public worker.shutdown(), which throws IllegalStateError in that layout.
|
Thanks for the review. Both comments are addressed in a717a48, and I replied in each thread. The PR body's breaking-change note now covers delegating wrappers and lists the new test. |
What was changed
Follow-up to #161, which made
GoogleAdkPlugin.configureWorkerrefuse a different Activity registered before the plugin. This bringsGoogleAdkPluginin line withAiSdkPluginin #162:runWorkernow refuses a plugin applied afterGoogleAdkPlugin(later inplugins, or on theNativeConnection) that replaced one of its Activities with a different one.runWorkerruns after every plugin'sconfigureWorker, so it sees the finalworker.options.activities. Before it throws, it stops the native Worker and letsrun()release it, so the Worker never polls andconnection.close()still works. There is no public way to stop a Worker that has not run, so this uses the Worker's protectednativeWorker.The error names the fixes that apply, in the same words as Refuse a different Activity under an AiSdkPlugin Activity name (breaking) #162 and Breaking: fail Worker creation when a StrandsPlugin Activity would replace another Activity #202:
README: which registrations fail at
Worker.createand which atworker.run(), and which two-instance setups are refused, such as onemodelProviderin two options objects, or a plugin without amodelProvidernext to one with it.These answer DABH's two comments on #161, which was merged before they were addressed.
Breaking change: a Worker whose later plugin replaces a
GoogleAdkPluginActivity used to run, with the other plugin's handler, and now fails inworker.run(). Rename or remove the other Activity, or run the other integration on its own Worker and task queue. A later plugin that wraps these Activities and delegates to them is refused too, although it used to run the plugin's handler inside the wrapper; wrap Activity execution with an Activity interceptor (interceptors.activity) instead. #161 is not released yet, so both changes ship together in the next@temporalio/google-adkrelease.Rollback: Worker configuration only. Activity names, inputs and results don't change, so recorded histories replay unchanged.
Why?
See #159. The check from #161 only sees Activities registered before the plugin, so a later plugin still replaced them silently, and Workflows reached the wrong handler.
Checklist
Follow-up to Fail Worker creation when GoogleAdkPlugin would replace another Activity (breaking) #161 ([Bug] google-adk: GoogleAdkPlugin silently replaces another Activity with the same name #159).
How was this tested:
runWorker refuses an Activity that a later plugin replaced, before pollinginsrc/__tests__/agent.test.ts: a real Worker withGoogleAdkPluginand then a plugin registeringadk-invokeModel. It checks theTypeError, that the Worker endsSTOPPED, and thatconnection.close()succeeds. It fails without therunWorkercheck (run()starts polling).runWorker refuses a later replacement with one instance on the connection and the Worker: the same instance on theNativeConnectionand the Worker, with the later plugin on the connection, so the check runs twice. It fails if the release uses the publicworker.shutdown()instead of the nativeinitiateShutdown().runWorker starts the Worker at once, so runUntil stops it after its callback: when nothing was replaced,runWorkerstarts the Worker without yielding, becauseWorker.runUntilstops the Worker after its callback only if it is already running. With anawaitbefore starting the Worker, the test times out.make lintpasses,make test235 passed at854783d,agent.test.ts4 passed with the double-layout test added, andcheck_conventions.pypasses.AiSdkPlugin(Refuse a different Activity under an AiSdkPlugin Activity name (breaking) #162) was also run against a dev server: the refused Worker takes no Workflow Task, and the same instance on the Worker and theNativeConnection, with a later plugin on the connection, is refused the same way.CI covers the other Node versions and platforms.
Any docs updates needed?
The README section, included here.