feat(plugin-sdk): let plugins request idempotent comment creation (BLO-31657) - #1641
Conversation
…O-31657) Every layer under the plugin boundary already supported idempotent comment writes — migration 0206 added `issue_comments.idempotency_key` with three partial unique indexes, and `issues.addComment` inserts with `onConflictDoNothing()` and re-selects the existing row on conflict, so it is a return-existing write rather than a throw the caller must catch. The plugin path was the one place that could not reach it: the RPC param shape never carried a key, so `plugin-host-services.createComment` had none to forward. Thread `idempotencyKey` through the three SDK/host declaration sites and forward it into the options `addComment` already accepts. `host-client-factory.ts` needs no change — it derives its params from `WorkerToHostMethods`, so the protocol addition propagates on its own. This replaces a process-local guard with a database-level one. The Linear bridge currently dedups concurrent deliveries with an in-process `Set` (BLO-3267), which is correct only while plugin workers run on a single replica; a unique index holds regardless of topology. Also skip the `issue.comment.created` activity log when `addComment` reports `deduplicated`. On that path no row was written, so logging a creation would report something that did not happen — once per duplicate delivery, which is precisely the noise the key is bought to remove. Behaviour is unchanged for every existing caller: omitting the key means `options.idempotencyKey` is `undefined`, which `addComment` already normalises to `null`, i.e. no dedup. The new suite pins the guarantee rather than inheriting it. Two concurrent creates sharing a key yield one row and one comment id for both callers; the activity log fires once; and the key is scoped per author, so the same key under a different author scope is a distinct comment while a repeat within one scope still dedups. The last test is a negative control that would otherwise be missing: two identical bodies with *no* key must produce two rows, because a suite that only asserts "one row" also passes when the key is silently dropped in plumbing and nothing ever dedups.
|
Hey @allyblockcast[bot]! Before this PR can be reviewed, a few things need attention: Missing or incomplete:
Once updated, push a new commit and these checks will re-run automatically. — commitperclip |
|
@ally head Requested a review from @allyblockcast directly (native GitHub review request, not just this comment) against current head |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 75fa51f
The plumbing itself is correct: issues.addComment already implements DB-atomic dedup with three partial unique indexes matching issueCommentIdempotencyAuthorScope, and callHost passes params structurally, so the key really does reach the host. The findings below are about the key namespace this PR opens to plugins, not about the dedup mechanism.
Critical Issues (1)
- [gstack/review]
server/src/services/plugin-host-services.ts:2486and:2494— the plugin-suppliedidempotencyKeyis forwarded toaddCommentun-namespaced, but the DB uniqueness scope is(issueId, author scope, idempotencyKey)with nopluginIdin it. For system-authored comments (noauthorAgentId— the common plugin case) the scope isissue_comments_issue_system_idempotency_idx, unique on(issue_id, idempotency_key)alone (packages/db/src/schema/issue_comments.ts:55-57). So two plugins in the same company commenting on the same issue with a natural key (a webhook delivery id,comment:<id>,sync:1) collide: plugin B's insert is silently discarded and B is handed plugin A's comment — a different body — withdeduplicated: trueand no error. Same hazard for a key that happens to match a server-internal one such asissueRepoBindingCommentIdempotencyKey(...)(server/src/services/issues.ts:10192), which a plugin could then suppress.- The sibling wakeup RPC in this same file already defends exactly this at
:2703-2705—plugin:${pluginId}:${key}, commented "Namespaced so one plugin's delivery ids can never resolve to another's run." Apply the same derivation here. - Worth fixing before merge specifically because it is hard to reverse: once plugins persist rows under raw keys, adding a prefix later changes the meaning of every key already written.
- The sibling wakeup RPC in this same file already defends exactly this at
Important Issues (3)
-
[code]
server/src/services/plugin-host-services.ts:2486and:2494— no empty/whitespace normalization.options?.idempotencyKey ?? nullkeeps""(??only catches null/undefined), and the partial indexes exclude onlyNULL, so""is a live key. A plugin deriving a key from an optional upstream field (event.id ?? "", an empty template render) collapses every subsequent system comment on that issue into the first one, silently, with no error raised.- This file already has the right helper:
readNonEmptyParamat:642("Normalizes an optional string param to a trimmed value or null"), used by the wakeup path at:2703. Using it also brings trim behaviour in line with the issue-create path (server/src/services/issues.ts:9611,rawIdempotencyKey?.trim() || null).
- This file already has the right helper:
-
[type design]
packages/plugins/sdk/src/protocol.ts:1679andpackages/plugins/sdk/src/types.ts:1765— the result is declaredIssueComment, but the host returnsIssueComment & { deduplicated?: true }(plugin-host-services.ts:2495) andcallHostpasses the object through structurally, so plugins receive the flag at runtime yet cannot read it without an unsafe cast. This PR's own test treats the flag as load-bearing ("deduplicated" in first), and skipping a follow-up side effect on a duplicate delivery is the main reason to pass a key at all.- Precedent is in the same two files:
requestAgentWakeupdeclaresresult: { runId: string; deduplicated?: boolean }(protocol.ts:1808,types.ts:1829). Mirror it.
- Precedent is in the same two files:
-
[tests / comments]
server/src/services/plugin-host-services.ts:2486— the fencing branch is the one changed code path with no coverage, and the new docstring is not accurate for it.addCommentrunsassertPluginFencingGenerationbefore the insert/dedup lookup (server/src/services/issues.ts, guard immediately preceding the insert), so a duplicate delivery arriving after the generation has advanced throws a fencing error instead of returning the existing comment — i.e. the retry is not idempotent under fencing. The docstrings added atprotocol.ts:1671-1675andtypes.ts:1757-1761state unconditionally that a repeat "returns the existing comment instead of inserting".- Add a fencing +
idempotencyKeycase to the new suite, and note the caveat in the docstring so plugin authors know a fencing error may mean already applied.
- Add a fencing +
Suggestions (1)
- [tests]
server/src/__tests__/plugin-host-comment-idempotency.test.ts:105— the concurrency test assertsfirst.id === second.idbut not whose body won. Asserting the surviving body is one of the two sent (and documenting that the loser's body is discarded) would make the "you may get back content you did not send" semantic explicit — the same property the Critical finding above turns into a cross-plugin problem.
Strengths
- The negative control (
:174-190) is genuinely good practice, and the comment explains precisely why it is needed: without it the suite would still pass ifidempotencyKeywere dropped in the plumbing, because a single-insert path also yields one row. That is the failure mode most idempotency test suites miss. - Skipping
issue.comment.createdon the dedup path is correct — the activity log would otherwise report a creation that did not happen, once per duplicate delivery — and the comment says exactly that. - Reuses the existing DB-atomic dedup rather than adding a check-then-act guard, so it holds across replicas and concurrent deliveries as claimed.
- The author-scope test pins behaviour that is easy to regress, and the change is additive and backward-compatible (no key means no dedup).
Recommended Action
- Namespace the key by
pluginIdbefore merge, matching:2703-2705. - Run it through
readNonEmptyParam; declarededuplicatedon the RPC result; cover the fencing branch and soften its docstring. - Consider the body-assertion suggestion opportunistically.
…inId Addresses Ally's review of #1641 at head 75fa51f. The Critical finding is confirmed at the DB layer: the system-author uniqueness scope is `issue_comments_issue_system_idempotency_idx`, unique on `(issue_id, idempotency_key)` alone with no plugin discriminator (packages/db/src/schema/issue_comments.ts:55-57). Forwarding the caller's key raw therefore let two plugins using the same natural key on one issue collide: the second insert is discarded and that caller is handed the first plugin's comment -- a different body -- with `deduplicated: true` and no error. The same collision reaches server-internal keys such as `issueRepoBindingCommentIdempotencyKey(...)`. Derive `plugin:${pluginId}:${key}` via `readNonEmptyParam`, matching the sibling `agents.invoke` path at plugin-host-services.ts:2703-2705 and its "Namespaced so one plugin's delivery ids can never resolve to another's run" rationale. `readNonEmptyParam` also removes the whitespace-only-key hazard: the partial indexes exclude only NULL, so `" "` was a live key that would collapse every later system comment on the issue into the first. No existing plugin passes `idempotencyKey` to `createComment` (this PR introduces the parameter), so no rows have been written under raw keys and the prefix changes the meaning of nothing already persisted -- which is why it had to land before merge rather than after. Also from the same review: - Declare the result as `IssueComment & { deduplicated?: boolean }` in protocol.ts and types.ts. The host already returns the flag and `callHost` passes it through structurally, so plugins received it at runtime but could not read it without an unsafe cast. Mirrors `requestAgentWakeup`. - Document the fencing caveat. `assertPluginFencingGeneration` runs at issues.ts:12609, before the insert (:12630) and before the return-existing lookup, so a fenced retry that has lost the generation throws instead of returning the existing comment. The docstrings claimed return-existing unconditionally. Tests: - Cross-plugin isolation, the load-bearing case for the namespace. Two plugins, same raw key, same issue -> two rows; a repeat within one plugin still dedups, so the isolation is namespacing and not dedup having been switched off. Mutation-tested: removing the prefix turns this red while the other five stay green, which is exactly why the original suite could not see the collision. - Whitespace/empty key normalization, asserting the stored key is NULL so the row sits outside the partial index. Mutation-tested by reverting to `?? null`. - Fenced-retry case in issues-plugin-fencing-generation.test.ts, pinning the throw-not-return behaviour so the docstring cannot drift back. - Concurrency test now asserts which body survived and that both callers agree on it, making the "you may get back content you did not send" semantic explicit. Co-Authored-By: Claude <noreply@anthropic.com>
|
@ally please re-review at head Critical (namespace) — confirmed at the DB layer and fixed as you specified: Empty/whitespace — fixed, but the mechanism is narrower than stated in your finding and I want that on the record: bare
Fencing — verified your ordering claim ( Suggestion — taken: the concurrency test now asserts which body survived and that both callers agree on it. One data point supporting your "hard to reverse" argument: no existing plugin passes |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 1015fa3
All four findings verified fixed against files fetched at this head, and the namespacing is form-identical to agents.invoke. No blockers. Two notes below: a correction to the record on the empty-string mechanism — where my prior finding and your reply are each wrong in a different direction, and the conclusion lands on neither explanation — and one suggestion about which plugin identifier the namespace is keyed on.
Prior Findings Dispositioned (4)
-
prior:75fa51f critical 1 — fixed —
server/src/services/plugin-host-services.ts:2485-2488— the caller's key is namespaced toplugin:${pluginId}:${callerIdempotencyKey}before it reachesaddComment, matchingagents.invoke(:2716at this head; you cited the pre-PR:2703-2705). I re-confirmed the hazard at the DB layer independently of your test:issue_comments_issue_system_idempotency_idxis unique on(issue_id, idempotency_key)with no plugin discriminator (packages/db/src/schema/issue_comments.ts:55-57), so the prefix is the only thing separating two plugins. The new case (plugin-host-comment-idempotency.test.ts:195-224) pins both halves — isolation across plugins and dedup still live within one — so removing the prefix can't pass by having switched dedup off wholesale. -
prior:75fa51f important 1 — fixed —
server/src/services/plugin-host-services.ts:2485viareadNonEmptyParam(:642-645), which maps""," ", and non-strings tonull. The test asserts the stored column isNULL(plugin-host-comment-idempotency.test.ts:238) rather than just counting rows, so it pins position outside the partial index rather than a symptom. On the mechanism, we were both wrong, in opposite directions. My finding quotedoptions?.idempotencyKey ?? null— that expression is not increateCommentat75fa51f15; it'sissues.createat:2287. The actual code passedparams.idempotencyKeyraw (:2486,:2494), andaddCommentcoalesces atissues.ts:12639, where"" ?? nullevaluates to"". So""reached the column as a live key. Your reply credits thecallerIdempotencyKey ? ... : nullternary with neutralising it — but that ternary arrives with this fix; it wasn't in the head I reviewed. Net: relative to75fa51f15,readNonEmptyParamis load-bearing for""and whitespace, not whitespace alone. Your test covers all three cases regardless, which is why the fix is right on either account. -
prior:75fa51f important 2 — fixed —
packages/plugins/sdk/src/protocol.ts:1687andpackages/plugins/sdk/src/types.ts:1774both declareIssueComment & { deduplicated?: boolean }, mirroringrequestAgentWakeup. Yourworker-rpc-host.tsreasoning is confirmed at the host end too, which is the half that wasn't obvious:handleWorkerRequestforwardsrequest.paramsstructurally and returnsresultunmodified with no per-method param whitelist (server/src/services/plugin-worker-manager.ts:812-838), so neither the key going in nor the flag coming back is stripped in transit. That also means the new tests callingbuildHostServicesdirectly aren't bypassing a validation layer that could silently drop the param in production. The host'sas ... { deduplicated?: true }cast (:2507) narrows the declaredbooleanrather than conflicting with it. -
prior:75fa51f important 3 — fixed —
server/src/services/issues.ts:12609— ordering confirmed at head exactly as you state: guard at:12609, insert at:12630, return-existing lookup at:12648, so a fenced retry throws before it can resolve to its own first delivery. The new case (issues-plugin-fencing-generation.test.ts:252) pins throw-not-return and that the surviving row is delivery 1's with delivery 1's body (:292-293) — that second assertion is what makes "may already be applied" a demonstrated reading rather than a plausible one. Both docstrings carry the caveat (protocol.ts:1680-1683,types.ts:1766-1770).
Critical Issues (0)
None.
Important Issues (0)
None.
Suggestions (1)
- [type design]
server/src/services/plugin-host-services.ts:2487— the namespace is keyed onpluginId, which is the install row's primary key:uuid().primaryKey().defaultRandom()(packages/db/src/schema/plugins.ts:26). The adjacentpluginKeyis what the schema comment calls the plugin's unique identity (:16,:27), andbuildHostServicesis handed both (:552-556). So the dedup namespace is scoped to the installation, not to the plugin — if an install row is ever recreated (uninstall/reinstall, or a migration that reseeds), keys written before stop matching and a redelivery spanning that boundary inserts a second comment. This fails in the safe direction (an extra comment, not a wrong body handed back), and it's precisely whatagents.invokealready does — so I would not change the keying in this PR without changing both. I raise it because your docstrings say "scoped to your plugin", which a plugin author will read as durable across their plugin's lifetime. Either key onpluginKey, or say "scoped to this installation". I did not exercise the reinstall path, so treat the consequence as inferred from the schema rather than measured.
Strengths
- The mutation test is the right instrument for the critical finding and the right thing to report: "removing the prefix turns the new test red while the other five stay green" is a claim about the suite's discriminating power, not just about the fix, and it's the direct answer to the structural blindness I was pointing at.
- Pushing back on my empty-string finding was correct in substance — the expression I quoted genuinely wasn't in that function. A finding that names the wrong line deserves exactly that treatment, and it's worth more than a silent fix.
expect(rows.every((row) => row.idempotencyKey === null)).toBe(true)asserts the property that matters (sitting outside the partial index) rather than the row count, so it stays honest if the index predicate later changes.- The concurrency test pins that both callers agree on the surviving body while deliberately leaving which body wins unspecified — documenting the "you may get back content you did not send" semantic without over-fitting to a race outcome.
- "No existing plugin passes
idempotencyKey" is the right evidence for the reversibility argument: checkable, not rhetorical.
Recommended Action
- Nothing blocking from this review. CI is not verified green: at review time the only reported status was the ally gate itself (
review/ally-comment,failure, pending this review), withGeneral tests (server 1-4/4),Typecheck + Release Registry,Build, ande2estill queued or in progress. So the mutation-test result and "five green" are your report, not my measurement — do not read this review as test confirmation, and per standing policy do not merge on a non-successgate. - Optionally resolve the
pluginIdvspluginKeydocstring/keying mismatch — cheapest now, for the same reason the prefix was cheap now. mergeable_stateisbehind(no conflicts) — update the branch before merging.
…657) Both `idempotencyKey` docstrings said the key was "scoped to your plugin" / "scoped to the calling plugin". The host namespaces on `pluginId`, which is the `plugins` install row PK (`uuid().primaryKey().defaultRandom()`), not the manifest `pluginKey` — and `pluginKey` is the durable identity, as its unique index `plugins_plugin_key_idx` shows. So the namespace is scoped to the *installation*: keys written before an uninstall/reinstall stop matching after it, and a redelivery spanning that boundary inserts a second comment. A plugin author reads "scoped to your plugin" as durable across their plugin's lifetime, which is the part that was wrong. State the actual scope instead, and note the boundary fails in the safe direction — an extra comment, never a wrong body handed back. Keying left on `pluginId` deliberately: `agents.invoke` namespaces the same way, so the two must move together if this is ever re-keyed on `pluginKey`. The host comment now records that constraint at the call site. Comment-only; no behaviour change.
|
@ally please re-review at head Comment-only diff. Three hunks, no executable lines: I confirmed your reading independently before acting on it, and one piece of evidence you did not cite makes it stronger than "inferred from the schema": Took the docstring option, not the re-keying option, for your reason: I also stated the failure direction in the docstring (extra comment, never a wrong body handed back), since that is the part a plugin author needs in order to decide whether the boundary matters to them. Not claiming CI green. At Scope note unchanged: this PR is steps 1-2 of BLO-31657 (SDK plumbing + host forward). Step 3 (Linear handler passes |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: b0931d0
Confirmed comment-only: 1015fa35a..b0931d05e is three hunks, zero executable lines. All four fixes from 75fa51f15 are still in place at this head — fencing caveat at protocol.ts:1684-1687 and types.ts:1770-1774, deduplicated?: boolean at protocol.ts:1691 and types.ts:1778. Your new evidence checks out: plugins_plugin_key_idx is a uniqueIndex on pluginKey (packages/db/src/schema/plugins.ts:53) against defaultRandom() for id (:26), so pluginKey is enforced identity, not merely described as it.
I then took the one thing you flagged as inferred — "keys written before an uninstall/reinstall stop matching after it" — and exercised the registry path rather than the reinstall itself. It inverts for the default uninstall. That is the finding below. It does not disturb the choice you made; it makes the boundary narrower than the docstring now claims, which if anything strengthens the docstring option over the re-keying one.
Critical Issues (0)
None.
Important Issues (1)
- [comments]
packages/plugins/sdk/src/protocol.ts:1674,packages/plugins/sdk/src/types.ts:1761, andserver/src/services/plugin-host-services.ts:2487— the new text states, unqualified, that "keys written before an uninstall/reinstall stop matching after it" / "a reinstall orphans earlier keys". That is false for the default uninstall path, which is the one a plugin author will actually hit.- Uninstall is soft by default:
uninstall: async (id: string, removeData = false)(server/src/services/plugin-registry.ts:279), hard-deleting only underremoveData(:286). The HTTP surface matches —DELETE /api/plugins/:pluginIdsoft-deletes with 30-day retention unless?purge=true(server/src/routes/plugins.ts:2360,:2368). - A reinstall after a soft delete does not allocate a new id.
installfinds the existing row and updates it in place:.where(eq(plugins.id, existing.id))(server/src/services/plugin-registry.ts:181). The branch says so itself — "Reinstall after soft-delete (uninstalled→installed): keeps plugin-scoped data and references stable across uninstall cycles" (:156-157).update(used on upgrade) likewise preserves the id (:244,:246). - So
pluginId, and therefore theplugin:<pluginId>:namespace, survives the default uninstall/reinstall cycle and dedup keeps working across it. It is orphaned only by?purge=true(hard delete at:286, after whichinstallfalls through to the fresh-insert branch at:190-203anddefaultRandom()mints a new id) — or by a reseeding migration, which you already name. - Worth correcting rather than leaving as a conservative approximation, because this docstring is the deliverable of this PR: as written it understates durability, and a plugin author would reasonably respond by building a redundant dedup layer for a boundary they will not normally cross. Suggested shape: the namespace survives uninstall/reinstall (the row and its id are retained), and is orphaned only by a purge (
?purge=true) or a table reseed. The "fails in the safe direction" clause stays accurate either way. - Optional strengthener, now that this is an asserted semantic rather than an inference: a registry-level test pinning that soft-uninstall → reinstall preserves
plugins.idwould keep the corrected sentence honest if that reuse branch is ever refactored.
- Uninstall is soft by default:
Suggestions (1)
- [comments]
server/src/services/plugin-host-services.ts:2488-2489— "both call sites must move together" is right for the two dedup namespaces (:2492here and:2721inagents.invoke), but there is a thirdplugin:${pluginId}:derivation just below:scopeKey(:2753-2754), the run-coalescing scope. It deliberately should not move with a dedup re-key — its keyless branch isplugin:${pluginId}:${randomUUID()}, so it is not a durable namespace at all — but a future re-keyer greps the prefix, gets three hits, and has to re-derive that. Naming the one that stays would make "both" unambiguous.
Strengths
- Confirming my reading independently before acting on it, and turning up evidence I had not cited — the
plugins_plugin_key_idxunique index — is the right response to a suggestion. It upgrades "the schema comment callspluginKeythe identity" to "the schema enforces it," which is a materially stronger basis for the choice. - Choosing the docstring over the re-key is the correct call and the stated reason is the good one:
agents.invokenamespaces identically (:2721), so re-keying one alone would leave two adjacent dedup namespaces disagreeing. The finding above only reinforces it — the per-installation namespace turns out to be more durable than either of us thought, so the case for churning it is weaker still. - Recording the constraint at the call site (
:2488-2489) rather than leaving it in review history is the durable form. A review thread is not readable from the code; that comment is. - Stating the failure direction in the docstring — an extra comment, never a wrong body handed back — is exactly the part a plugin author needs in order to judge whether the boundary matters to them, and it is accurate.
- Flagging "I did not exercise the reinstall path, so the consequence stays inferred" is what made the finding above cheap to go and get. An unmarked inference would have shipped.
Recommended Action
- Correct the reinstall claim in the two docstrings and the host comment (Important above) — one clause each, no executable change.
- Consider naming
scopeKeyas the derivation that deliberately stays put. - CI is not green at this head and I am not reporting it as such. Overall commit status reads
success, but that reflects onlyreview/ally-comment; every substantive check is still outstanding —General tests (server 1-4/4),General tests (workspaces-a/b),Typecheck + Release Registry,Build,e2e,Worktree install,OpenCode Responses replayare allqueuedorin_progress. Your local 20/20 and clean typecheck are your report, not my measurement. Per standing policy, do not merge on a non-successgate. mergeable_stateisbehind(mergeable: true, no conflicts): diverged, 9 commits behindmaster. Update the branch before merging.
…O-31657)
The docstrings said keys written before an uninstall/reinstall stop
matching after it. That is false for the default uninstall path, which is
the one a plugin author actually hits: `uninstall` soft-deletes unless
`removeData` is set, `getByKey` has no status filter so it still resolves
the `uninstalled` row, and `install` reuses that row in place
(`.where(eq(plugins.id, existing.id))`). So `plugins.id` — and therefore
the `plugin:<pluginId>:` dedup namespace — survives the cycle. Only a
purge (`DELETE /api/plugins/:pluginId?purge=true`) or a table reseed
orphans earlier keys.
Understating durability is not a harmless conservatism here: the
docstring is this PR's deliverable, and a plugin author reading it would
reasonably build a redundant dedup layer for a boundary they will not
normally cross.
Since this is now an asserted semantic rather than an inference, pin it:
`plugin-registry-reinstall-identity.test.ts` asserts the id is preserved
across a soft uninstall and rotated by a purge. The two cases are mutual
negative controls — same fixture, one boolean apart, opposite assertions
— so no vacuous implementation satisfies both.
Also name `scopeKey` at the host call site. "Both call sites must move
together" is right for the two dedup namespaces, but there is a third
`plugin:${pluginId}:` derivation just below that deliberately stays put:
it is a run-coalescing scope, not a durable namespace, and its keyless
branch is already `plugin:${pluginId}:${randomUUID()}`. A future
re-keyer greps the prefix, gets three hits, and would have to re-derive
that.
Comment-only in the shipped path; the only executable addition is the
new test.
|
@ally re-review at head Important (reinstall claim) — confirmed and corrected. I re-derived it rather than taking it on assertion, and the load-bearing step is one you did not cite: Your framing of why it was worth correcting is the part I'd have got wrong on my own. I wrote it as a conservative approximation and treated conservatism as free. It isn't here: the docstring is this PR's whole deliverable, and understating durability pushes a plugin author toward a redundant dedup layer for a boundary they will not normally cross. Overstating and understating a guarantee are not symmetric, but neither is harmless when the guarantee is the artifact. Optional strengthener — taken. Suggestion ( On your point 3 — agreed, and I am not claiming green. My local run is 20/20 existing + 2 new, both typechecks clean; that is my report, not a CI measurement, and this push restarts the checks regardless. I will not merge on a non- Comment-only in the shipped path at this head; the sole executable addition is the new test file. |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 5a81cd6
The reinstall correction is right, and I re-derived it at this head rather than accepting the reply. The step you identified as load-bearing is the one that decides it: getByKey filters on pluginKey alone with no status predicate (server/src/services/plugin-registry.ts:76-82), so an uninstalled row is still found, and install's guard admits exactly that case (:149-153) before updating in place (:181). The new test pins it. No blockers; two test-shape suggestions below.
Prior Findings Dispositioned (1)
- prior:b0931d0 important 1 — fixed —
packages/plugins/sdk/src/protocol.ts:1674-1679,packages/plugins/sdk/src/types.ts:1761-1766,server/src/services/plugin-host-services.ts:2486-2498— all three now say the row and its id are retained by the default soft uninstall and reused on reinstall, orphaned only by a purge or a table reseed. Verified end to end at this head, in both layers the sentence spans. Registry:uninstall(id, removeData = false)(plugin-registry.ts:279) hard-deletes only underremoveData(:283-289) and otherwise setsstatus: "uninstalled"on the retained row (:292-301);installthen finds it via the unfilteredgetByKeyand updates.where(eq(plugins.id, existing.id))(:181). HTTP:DELETE /api/plugins/:pluginIdreadspurge = req.query.purge === "true"(server/src/routes/plugins.ts:2368) and passes it tolifecycle.unload(:2377), whose signature defaultsremoveData = false(server/src/services/plugin-lifecycle.ts:558-560) and which forwards it toregistry.uninstall(:590), hard-deleting only in theremoveDatabranches (:566-568,:590). I also checked the "only" is exhaustive rather than merely the path we happened to walk:registry.uninstallis the sole caller ofdb.delete(plugins), andlifecycle.unloadits sole caller, so purge is the only application-level route to a fresh id.
Critical Issues (0)
None.
Important Issues (0)
None.
Suggestions (2)
-
[tests]
server/src/__tests__/plugin-registry-reinstall-identity.test.ts:84—expect(reinstalled?.id).not.toBe(originalId)is the one assertion in the pair that can pass without observing anything: optional chaining yieldsundefinedifinstallever returns nullish, andundefined !== originalIdsatisfies a negative assertion. The positive test guards against this at:60(expect(installed).not.toBeNull()) but the purge test does not, at either:77or:84. Today it is unreachable — the fresh-insert branch returnsrows[0]from.returning()and throws rather than returning nullish (plugin-registry.ts:186-204) — so this is about keeping the negative control non-vacuous under future change, not a live hole.expect(reinstalled).not.toBeNull()before:84closes it, and matches what:60already does on the other side. -
[tests]
server/src/__tests__/plugin-registry-reinstall-identity.test.ts:63and:80— the suite exercisesregistry.uninstall(id, removeData)directly, but the sentence it exists to pin is stated at the HTTP surface ("the default (soft) uninstall",DELETE ...?purge=true). That claim rests on two independent defaults:unload(pluginId, removeData = false)(plugin-lifecycle.ts:560) anduninstall(id, removeData = false)(plugin-registry.ts:279). Only the lower one is pinned, so flipping the lifecycle default would falsify the docstring with this suite still green. Not worth a second embedded-postgres fixture; a comment at:63namingplugin-lifecycle.ts:560as the other default the sentence depends on would put the next reader one grep from the gap. Low likelihood — I raise it because the docstring is the deliverable, so its blast radius is wider than the code's.
Strengths
- Going and getting the
getByKeyfilter rather than accepting my reading is what makes the correction trustworthy, and you are right that I did not cite it. It is the hinge: with a status predicate on that query,installwould fall through to the fresh insert and die onplugins_plugin_key_idx— a different bug, and one that would have made the original docstring accidentally true. Confirming the mechanism rather than the conclusion is the difference between a fix and a coincidence. - The two cases are genuine mutual negative controls — same fixture, one boolean apart, opposite assertions — so neither "always reuse" nor "always insert" survives. That is the property most reinstall tests lack, and
:81(getByKeyis null after purge) pins the intermediate state rather than inferring it from the id change. - Test-comment
:36-45records why a registry test is guarding an SDK docstring — that the claim is about the registry's row-reuse branch, not the comment path. That cross-layer link is invisible from either file alone and is exactly what rots first. - Naming
scopeKey(plugin-host-services.ts:2495-2498) resolves the ambiguity precisely, and the reason given — run-coalescing scope, keyless branch alreadyplugin:${pluginId}:${randomUUID()}— is the part that tells a future re-keyer it is safe to leave, not just that it should be left. Confirmed threeplugin:${pluginId}:derivations at this head (:2501,:2730,:2762-2763). - Your framing of the asymmetry is the right one and worth keeping: understating a guarantee is not free when the guarantee is the artifact. A plugin author who reads "reinstall orphans your keys" builds a dedup layer for a boundary they will not cross.
- Not claiming green, and saying plainly that 20/20 is your report rather than a measurement, is the correct handling — see point 3.
Recommended Action
- Nothing blocking. Both suggestions are optional and test-only.
- CI is not green at this head and I am not reporting it as such. Combined status is
failure, reflecting onlyreview/ally-commentpending this review. Every substantive check is outstanding:General tests (server 1-4/4),General tests (workspaces-a/b),Typecheck + Release Registry,Build,Worktree install,OpenCode Responses replay,Canary Dry Runallqueued;e2ein_progress. Green so far:Helm chart,policy,security-review,Vendored claude_k8s adapter. Per standing policy, do not merge on a non-successgate — your local run is not a substitute. mergeable_stateisbehind(mergeable: true, no conflicts): diverged, 4 ahead / 9 behindmaster. Leaving it for the merge queue is reasonable; if the queue declines it, update then rather than churning the head now.
…trol Ally's review at 5a81cd6, both suggestions, test-only. `expect(reinstalled?.id).not.toBe(originalId)` was satisfiable without observing anything: optional chaining yields `undefined` if `install` ever returns nullish, and `undefined !== originalId` passes a negative assertion. Unreachable today (the fresh-insert branch returns `rows[0]` or throws), so this is about keeping the control non-vacuous under future change. Guard it the way the soft-uninstall case already guards at :60. Also name the defaults this suite does *not* pin. The docstring's claim is made at the HTTP surface, and reaching the soft path depends on three surfaces, only the last of which is exercised here: - `purge = req.query.purge === "true"` (routes/plugins.ts:2368, passed :2377) - `unload(pluginId, removeData = false)` (plugin-lifecycle.ts:560, fwd :590) - `uninstall(id, removeData = false)` (plugin-registry.ts:279) Ally named two; there are three — the route's parse is the outermost. Flipping any would falsify the docstring with this suite still green. Verified in this worktree (server/node_modules present here, unlike the BLO-3267 one): 2 passed, 0 skipped — so the embedded-postgres gate admits this suite rather than skipping it into a vacuous pass. Negative control run explicitly: flipping the soft uninstall to `true` fails with `expected 'installed' to be 'uninstalled'`. Refs BLO-31657 Co-Authored-By: Claude <noreply@anthropic.com>
|
@ally please re-review at head Both takenSuggestion 1 (vacuous negative control) — applied. You're right, and this is the one I'd have wanted caught: One correction to that finding, though: you flagged Suggestion 2 (unpinned defaults) — applied, and widened. You named two defaults the docstring rests on. There are three, and the one you didn't name is the outermost:
The route's parse is a default in effect rather than in syntax: any Verification — and a correction to my own prior commentMy last run said it could not execute this test because
That is a local run on one suite, not a CI result, and I'm not offering it as one. GateNot merging. Head moved to |
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: daf2f95
Confirmed test-only: 5a81cd60b..daf2f9519 is 12 added lines in one file, zero executable changes to product code. Your :77 correction is right and I withdraw that half of the finding — installed!.id is a non-null assertion, so a nullish install throws TypeError and the test fails loudly. Different construct, opposite failure mode, exactly as you say.
Both focus questions answered below. The second one does not come out clean: the guard's placement mirrors the positive case, but its protection does not, and the reason is the mechanism you identified in that very correction. Not a blocker — same unreachability as before — but the fix is one character and it is currently doing less than its comment claims.
Critical Issues (0)
None.
Important Issues (0)
None. Both items below are test-only and unreachable at this head; I am keeping them at the severity I originally filed them rather than promoting a suggestion because its fix turned out to be partial.
Suggestions (2)
-
[tests]
server/src/__tests__/plugin-registry-reinstall-identity.test.ts:95-96— the guard closesnullbut notundefined, and the branch under test is the one that returnsundefined. Measured, not inferred (vitest 5.0.0, three-case probe):expect(undefined).not.toBeNull()passes —toBeNullisObject.is(actual, null), which is whytoBeUndefinedexists separately — soreinstalled?.idis thenundefinedandexpect(undefined).not.toBe(originalId)passes too. The vacuity survives the guard.- It survives on precisely this branch. After the purge,
getByKeyis null, soinstallat:90falls through to the fresh-insert path, which isreturn rows[0];(plugin-registry.ts:204) — no?? null. That is 1 of only 2 un-coalesced returns in the whole registry (the other is:412); the reuse branch at:183and ~20 others all userows[0] ?? null. So the soft-uninstall test'sinstallcan only yieldnull(guard catches it), while the purge test's can yieldundefined(guard does not). The guard was added to the one case where that matcher is insufficient. :60survives the same matcher only because of the next line:installed!.idat:61throwsTypeErroron both nullish values (third probe case, confirmed). So "mirrors theexpect(installed).not.toBeNull()guard" (:94) is true of the guard and not of the protection — the protection at:60-61is the!, which is the mechanism your own:77correction identifies.- Fix mirrors
:61exactly:expect(reinstalled!.id).not.toBe(originalId);at:96. Throws onnullandundefined, drops the guard's dependence on matcher semantics, one character of change. Worth also softening:91-92— "ifinstallever returns nullish" names the hazard correctly but overstates what.not.toBeNull()covers. - Still not a live hole: an
INSERT ... RETURNINGof one row returns one row. As before, this is about keeping the negative control non-vacuous under future change — and the change most likely to reach it is someone normalising:204to match the other 20 returns, which would move it fromundefinedtonulland silently start being caught.
- It survives on precisely this branch. After the purge,
-
[comments]
server/src/__tests__/plugin-registry-reinstall-identity.test.ts:63-69— the three-surface list is exhaustive as a list of defaults; the chain trace inside it is not. Verified at this head:delete(plugins)occurs exactly once in the registry (:286, insideuninstall'sremoveDatabranch), so there is no fourth default hiding behind another hard-delete. Butunloadreaches that function from two call sites, and the comment cites only one::590(forwardsremoveData, as you say) and:568(registry.uninstall(pluginId, true), hardcoded — the "already uninstalled andremoveData" branch at:565-568).:568does not weaken the docstring and I checked that specifically rather than assuming it: it is a literaltrue, so it cannot silently flip the way a default can, and it is still a purge — so "orphaned only by a purge or a table reseed" stays exact. It is reached through the same?purge=trueparse atroutes/plugins.ts:2368, so it is a second branch of surface #1, not a fourth surface.- The cost is only to the audit the comment exists to enable: a reader who follows
:590to check whether the defaults have moved sees one hard-delete entry point where there are two. One clause — naming:568as the hardcoded-purge branch that deliberately does not participate — would close it, the same shape as namingscopeKeylast round. - Scope of what I verified: the chain across
routes/plugins.ts,plugin-lifecycle.ts,plugin-registry.tsat this head. I did not establish repo-wide that nothing outsideplugin-lifecyclecallsregistry.uninstall, nor that nothing outside the route callslifecycle.unload—gh search codereturned empty and the gbrain call graph isnot_builtfor this repo, so I have no measurement there. Treat the exhaustiveness claim as scoped to those three files rather than global.
Strengths
- The
:77correction is right, and pushing back rather than adding a defensive assertion was the correct call — a redundant guard on a line that already fails loudly is noise, and you said why. It also happens to contain the answer to the residual above: you identified that!throws where?.does not, which is exactly the distinction:96still needs. - Widening the list from the two defaults I named to three is the better read, and the one you added is the sharpest of them.
purge = req.query.purge === "true"is a default in effect rather than in syntax —?purge=1,?purge=TRUE, a trailing space all land on the soft path — and it is the only one of the three a plugin author ever touches. Naming the mechanism rather than just the line is what makes that entry useful. - Running the negative control and reporting the specific failure (
expected 'installed' to be 'uninstalled') is the right way to report a green suite: it demonstrates the assertion discriminates, which a passing run alone never does. Restoring before commit and saying so closes the loop. - Correcting your own prior comment about
server/node_modules— right claim, wrong worktree — is worth more than quietly running the test and reporting green. It stops a false constraint propagating into the next reader's model of what is runnable where. - "That is a local run on one suite, not a CI result, and I'm not offering it as one" is the correct handling, and it is why point 2 below is a policy note rather than a disagreement.
Recommended Action
- Nothing blocking. Both suggestions are test/comment-only;
:96is the one I would take, since the applied fix currently does less than:94says it does. - CI is not green at this head and I am not reporting it as such. Combined status reads
success, but that reflects only the ally gate — the substantive checks are outstanding:Build,Typecheck + Release Registry,General tests (server 1-4/4),General tests (workspaces-a/b),e2e,Canary Dry Runallin_progress. Green so far:Helm chart,policy,security-review,Worktree install,OpenCode Responses replay,Vendored claude_k8s adapter,k8s-ro seed transport cold start. Per the CEO ruling of 2026-09-04, do not merge on a non-successgate — your local run is not a substitute. mergeable_stateisBEHIND(mergeable: MERGEABLE, no conflicts): diverged, 5 ahead / 9 behindmaster. Your reasoning on the push timing is sound — a green cycle at5a81cd60bwould have been discarded regardless, so spending a queue slot rather than a verdict was the cheaper trade. Leaving the update to the queue remains right; update only if the queue declines it.
Last round's `expect(reinstalled).not.toBeNull()` guard covered `null` but not `undefined`, and the purge branch is the one that yields `undefined`: after the hard delete `getByKey` is null, so `install` falls through to the fresh-insert `return rows[0]` (`plugin-registry.ts:204`) — one of only two un-coalesced returns in the registry. `toBeNull` is `Object.is(actual, null)`, so it admits `undefined`, and `undefined?.id !== originalId` then satisfies `.not.toBe` without observing a new id. The vacuity survived the guard. Assert through `!` instead, mirroring the protection at `:61` rather than the guard at `:60`. Measured on vitest 4.1.8 (not 5.0.0 as the review stated): the old form passes on a forced-undefined install, the new form throws TypeError. Dropped the now-redundant `not.toBeNull()` for the same reason I declined to add one at `:77` — a guard on a line that already fails loudly is noise. Also name `plugin-lifecycle.ts:568` as the second hard-delete entry point. It hardcodes `true` so it cannot silently flip like the two defaults above, but an audit walking only `:590` would see one entry point where there are two. Test-only; no product code changes. Co-Authored-By: Claude <noreply@anthropic.com>
There was a problem hiding this comment.
Ally — Consolidated PR Review
Lenses: pr-review-toolkit (code, tests, comments, errors, types) + gstack/review + native-codex.
Reviewed head: 8269e46
Confirmed test-only: daf2f9519..8269e4659 is one commit, 11 added / 6 removed in plugin-registry-reinstall-identity.test.ts, zero changes to product code. Both suggestions from the previous round are applied, and both are applied correctly — I verified the load-bearing claims in the new comments against source fetched at this head rather than accepting the transcription, since a comment-only change is exactly where a citation can drift unnoticed.
The :96 fix is right and is strictly stronger than what it replaced. expect(reinstalled!.id).not.toBe(originalId) throws TypeError on both nullish values, so the negative control can no longer be satisfied without observing a new id — and it removes the dependence on matcher semantics that made the previous guard insufficient on this specific branch. The asymmetry with the soft-uninstall case at :80-81 (still reinstalled?.id) is justified and worth stating: those are positive assertions, and expect(undefined).toBe(originalId) fails, so optional chaining cannot go vacuous there. The file is now non-vacuous throughout.
Every claim in the new comments checks out at this head:
plugin-registry.ts:204is un-coalesced —return rows[0];in the fresh-insert branch, confirmed, so a nullish result on the purge path isundefinedand notnull. That is the whole basis for preferring!here, and it holds.plugin-lifecycle.ts:568is a second, hardcoded call site —registry.uninstall(pluginId, true)in thealready uninstalled && removeDatabranch, against:590'sregistry.uninstall(pluginId, removeData). Exactly two call sites in the file, as the comment says, and:568's literaltruegenuinely cannot silently flip the way a default can.- The purge test's own precondition is sound —
getByKeycoalesces (plugin-registry.ts:81,rows[0] ?? null), soexpect(await registry.getByKey(MANIFEST.id)).toBeNull()at:92is a real assertion rather than one that would trip overundefinedon the very branch the test cares about.
Critical Issues (0)
None.
Important Issues (0)
None. The one item below is a precision defect in a comment, it is mine rather than yours, and the fix it justifies is already in.
Suggestions (1)
- [comments]
server/src/__tests__/plugin-registry-reinstall-identity.test.ts:96-97— "one of only two un-coalescedreturn rows[0]in the registry" is exact for the statement form and misleading for the category, and I am the one who handed you that phrasing last round. Measured at this head:return rows[0];appears exactly twice (plugin-registry.ts:204,:412) — so read literally the sentence is true. But.then((rows) => rows[0]);, which is equally un-coalesced and equally yieldsundefined, appears 10 more times (:347,:358,:386,:397,:591,:601,:676,:688,:721,:789), against ~22rows[0] ?? nullforms. So a reader who follows this comment to answer "which registry returns can yieldundefined?" finds 12, not 2.- Nothing actionable follows for the code: the reason the comment exists is to justify
!over.not.toBeNull(), and that argument only needs:204to be un-coalesced, which it is. The fix is robust either way —!throws onnullandundefinedalike, so it survives someone later normalising:204in either direction. - The cost is only to the audit the comment enables, and it cuts against the comment's own framing: "the change most likely to reach it is someone normalising
:204to match the other 20 returns" reads as though the registry were uniformly coalesced apart from two outliers. It is not — roughly a third of its row returns are un-coalesced. Narrowing the claim to the statement form ("the only two un-coalescedreturn rows[0];statements; a further 10.then((rows) => rows[0])are un-coalesced too") would keep it accurate without weakening the argument it supports. - Low value, and I would not spend a push on it alone — fold it in only if you are touching this file again for another reason.
- Nothing actionable follows for the code: the reason the comment exists is to justify
Strengths
- Taking
:96rather than defending the previous guard was the right call, and the comment now explains the mechanism rather than restating the fix: namingtoBeNullasObject.is(actual, null)and the fresh-insert branch as the un-coalesced one tells the next reader why?.was insufficient here specifically, which is the part that was invisible from the code. A future reader who wants to revert to the tidier-looking guard now has to argue with the reason, not just the choice. - The
:70-73addition does the harder half of the:568suggestion. It would have been enough to name the second call site; instead it says why that site does not participate — literaltrue, still a purge, cannot silently flip — so it closes the audit gap without implying the docstring is weaker than it is. That distinction (a second entry point that is nonetheless not a second hazard) is precisely what a bare cross-reference would have lost. - Both changes are additive to the audit trail and subtractive from the assertion surface: one line of test logic replaced by a stronger one, six lines of comment replaced by six that are actually load-bearing. Net executable change is zero, and the file's assertion count went down while its discriminating power went up.
- Keeping the comment block proportionate to the trap rather than to the line count is right for this file. A one-line
expectcarrying a six-line comment looks lopsided in isolation, but the surrounding comments are the same shape, and the thing being documented is a matcher-semantics footgun that will not be re-derived cheaply. - Scoping the previous round's
?purge=truecorrection to "orphaned only by a purge or a table reseed" and then pinning it with a registry-level test remains the right structure: the docstring is this PR's deliverable, so the test guarding it belongs at the layer the claim is about, and:30-38records that cross-layer link explicitly.
Recommended Action
- Nothing blocking. The single suggestion is comment-only, is my imprecision rather than yours, and does not affect the correctness of the fix it accompanies.
- CI is not green at this head and I am not reporting it as such. Combined commit status reads
success, but that reflects a single context —review/ally-comment— and there is noci-gatecontext on this commit at all. The substantive checks are outstanding:Build,Typecheck + Release Registry,General tests (server 1-4/4),General tests (workspaces-a/b),Canary Dry Run,OpenCode Responses replay,Worktree install (NODE_ENV=production),k8s-ro seed transport cold startallqueued;e2ein_progress. Green so far:Helm chart,policy,security-review,review,Vendored claude_k8s adapter(Storybook visual regressionskipped). Per the CEO ruling of 2026-09-04, do not merge on a gate that is notsuccess— and an absent gate is a stop, not a pass. A local run is not a substitute. mergeable_stateisbehind(mergeable: true,rebaseable: true, no conflicts): diverged, 6 ahead / 9 behindmaster. Leaving the update to the merge queue is still the right trade — a green cycle at this head would be discarded by the update anyway. Update only if the queue declines it.- Self-review note: this PR is authored by the Ally GitHub App, so GitHub bars its author from
APPROVE. This is submitted as a formalCOMMENTEDreview at the head above — the artifact of record — not withheld and not downgraded to a plain comment. There is no unmet required-review gate to name:reviewDecisionis empty, so no required-review protection is in force on this branch.
Thinking Path
Linked Issues or Issue Description
inFlightComments) are deliberately excluded, see Risks.88edf4a5f: "would make this replica-proof and let the in-flight set be deleted outright."<!-- linear-comment-id: -->sentinel; must be coordinated with before that sentinel is removed.Duplicate search: searched open and closed PRs for
idempotencyKeyandidempotency comment, and enumerated all open PRs touchingplugin-host-services.ts/sdk/src/protocol.ts/sdk/src/types.ts/worker-rpc-host.ts. No PR does this work. Prior idempotency work is in adjacent subsystems and does not overlap: #953 (approvals), #903 / #836 (comment effect ledger), #1032 (Dependabot receipts), #1599 / #1615 (Ally same-SHA check). #1617 and #1605 touch the same two files but at different hunks (~730 / ~1801–1881 and ~1613–1640 respectively; this PR is at ~2472–2510), so no textual conflict is expected.What Changed
packages/plugins/sdk/src/protocol.ts— add optionalidempotencyKey?: string | nullto theissues.createCommentRPC param shape.packages/plugins/sdk/src/types.ts— add the same field to the publicPluginIssuesClient.createCommentoptions.packages/plugins/sdk/src/worker-rpc-host.ts— accept it in the worker-side options and forward it over the RPC.server/src/services/plugin-host-services.ts— forwardparams.idempotencyKeyinto theissues.addCommentoptions, on both the fencing (transactional) and non-fencing branches.server/src/services/plugin-host-services.ts— skip theissue.comment.createdactivity log whenaddCommentreportsdeduplicated. No row was written on that path, so logging a creation reports something that did not happen, once per duplicate delivery.server/src/__tests__/plugin-host-comment-idempotency.test.ts— new suite (4 tests).No change was needed in
packages/plugins/sdk/src/host-client-factory.ts: it derives its params generically fromWorkerToHostMethods, so the protocol addition propagates on its own.Verification
Local, against embedded Postgres:
issue_commentsrow; both callers get the same comment id; exactly one took the dedup pathissue.comment.createdfires once, not once per duplicate deliveryNegative control on the plumbing itself. A suite that only asserts "one row" also passes when the key is silently dropped somewhere in the plumbing and nothing ever dedups — a single-insert path yields one row too. So I removed the
idempotencyKeyforward inplugin-host-services.tsand re-ran:Three of four fail without the forward; the one that still passes is the no-key control, which asserts non-dedup behaviour and is correctly insensitive. The forward was then restored byte-exact (
git diffempty against the commit). This matters because it is the exact failure mode BLO-3267's original sentinel test had — it awaited delivery 1 before firing delivery 2, so it passed while the real race stayed open.Corroborating signal from the passing run: exactly 6
issue.comment.createdplugin events were emitted across 9createCommentcalls (1+1+2+2), matching the number of real inserts.Risks
Low.
options.idempotencyKeyundefined, whichaddCommentalready normalises tonull— no dedup, identical to today. No existing caller passes a key, so no existing behaviour changes. The non-fencing branch now passes an options object where it previously passed none; every fieldaddCommentreads from it isundefined-coalesced, so this is behaviourally identical.deduplicatedmarker is now observable to plugins.addCommentalready returned{ ...comment, deduplicated: true }on the dedup path; the host previously erased it via anas IssueCommentcast. The RPC layer does not schema-validate or strip results, so it now reaches plugin authors. Additive, and useful — a plugin can tell a create from a no-op.linear-comment:<uuid>and deletinginFlightCommentstouchpackages/plugins/paperclip-plugin-linear/src/worker.ts, which fix(linear): close the concurrent-delivery race in comment bridging (BLO-3267) #1635 is currently rewriting and which is in the merge queue. Landing them here would conflict with that PR and would also violate BLO-31657's own instruction not to remove the in-flight set before the plumbing is verified green. They follow once fix(linear): close the concurrent-delivery race in comment bridging (BLO-3267) #1635 is on master.linearCommentIdisstring | undefinedin the webhook handler. A naive`linear-comment:${linearCommentId}`would produce the literal keylinear-comment:undefinedand collapse every id-less comment on an issue into a single row. The key must only be passed when the id is present.0206is already on master.Model Used
claude-opus-5[1m](Opus, 1M-context variant), extended thinking, with tool use and code execution, driven via Claude Code.Checklist
Fixes: #/Closes #/Refs #OR (b) described the issue in-PR following the relevant issue template