Skip to content

Poll legacy custom action managers to a terminal status - #1074

Merged
jugonzalez12 merged 8 commits into
mainfrom
julian/legacy-action-status
Aug 12, 2026
Merged

Poll legacy custom action managers to a terminal status#1074
jugonzalez12 merged 8 commits into
mainfrom
julian/legacy-action-status

Conversation

@jugonzalez12

Copy link
Copy Markdown
Contributor

What this does

registerLegacyAction wraps actions from the deprecated CustomActionManager / RegisterActionManagerLimited interfaces as ordinary action handlers. Until now the wrapper discarded the id and status the legacy manager returned, so an action still running at the legacy manager's own short wait was resolved as complete, with whatever partial response existed — a false success for any slow legacy action.

The wrapper now:

  • keeps the old behavior for synchronous managers that never populated an id or status (their invoke response resolves the action, exactly as before);
  • for an explicitly in-flight result (PENDING/RUNNING with a usable id), polls the legacy manager's GetActionStatus to a terminal status before resolving, with capped backoff (1s doubling to 30s);
  • tolerates a few consecutive status-lookup failures (one flaky remote read must not convert a succeeding action into a failure), carries the last real response, and fails closed on persistent lookup errors, a reported failure, or an unexpected status;
  • is bounded by the handler context's deadline, so a status that never converges terminates with the handler budget rather than polling forever.

Why it's here

Review on the inline_wait PR (#1071 and previous drafts) flagged that the legacy path discards the inner status — meaning the requested wait can't be honored for legacy managers, and slow legacy actions false-complete regardless. This change fixes that at the root rather than threading the wait into the deprecated interface: once the wrapped handler blocks to a terminal status, inline_wait composes with legacy managers with no extra plumbing (an action finishing inside the window returns its real terminal status inline; one still running returns an honest RUNNING). It also stands alone: the false-complete existed before inline_wait did.

We can choose to reject this kind of behavior change, but I think it's appropriate for us to decide whether or not to address this issue, since it's raised by bots over and over.

Behavior changes and risk

Perimeter. Only connectors on the deprecated interfaces are affected, and within those, only actions that are still in flight when the legacy manager's own wait expires. Fast legacy actions (the common case — single API calls) return a terminal status from the invoke directly, never enter the poll loop, and behave byte-for-byte as before. Modern actions don't touch this code.

The honest sharp edge: this makes previously-dead code load-bearing. Because the old wrapper discarded the status, a legacy manager's GetActionStatus has never been exercised through this path — bugs in hand-rolled implementations have been invisible. After this change, a persistently failing lookup fails the action (visibly, after three consecutive errors) and a status that never converges occupies a goroutine until the handler deadline, then fails. A visible failure is the correct replacement for a silent false success, but it can read as a regression for a fire-and-forget action that "worked" before. Mitigating this in practice: the known legacy implementations return the SDK's own ActionManager as their CustomActionManager, so the GetActionStatus being promoted to load-bearing is the SDK's own, not custom code. Reviewer ask: if you know of a connector that hand-rolls GetActionStatus, that's the one worth checking against this change.

Observable shift for slow legacy actions. Callers see an honest in-flight status at the wait boundary instead of a fast false COMPLETE, with the real outcome landing when the inner action finishes. This is deployment-order safe: standalone, the platform's current handling of in-flight responses is unchanged; combined with the inline_wait change, slow legacy actions gain real inline outcomes.

Resource bounds. Worst case is one goroutine per slow legacy invoke for the handler budget, and a handful of status calls under the 30s backoff cap — the same order as any slow modern handler.

Testing

Five new tests cover the wrapper: synchronous managers resolve unchanged, in-flight results poll to success and to failure, consecutive lookup errors fail closed at the threshold, and a transient error followed by recovery succeeds. Note the poll intervals are production constants, so these tests contribute several seconds of real wall time to the connectorbuilder suite.

Comment thread pkg/connectorbuilder/actions.go Outdated
Comment thread pkg/connectorbuilder/actions.go
Comment thread pkg/connectorbuilder/actions.go
Comment thread pkg/connectorbuilder/actions.go Outdated
Comment thread pkg/connectorbuilder/actions_legacy_test.go
@github-actions

github-actions Bot commented Aug 10, 2026

Copy link
Copy Markdown
Contributor

General PR Review: Poll legacy custom action managers to a terminal status

Blocking Issues: 0 | Suggestions: 3 | Threads Resolved: 0
Criteria: Criteria status: loaded .claude/skills/ci-review.md from trusted base a386e27af483.
Review mode: full
View review run

Review Summary

Scanned the full PR diff for security and correctness: the registerLegacyAction poll loop in pkg/connectorbuilder/actions.go, the ctxzap parity fix in pkg/actions/actions.go (invokeResourceAction already had it; invokeGlobalAction now matches), the new 550-line test file, and the vendored go.uber.org/zap/zaptest/observer addition. Risk triage: not silent (a wrong outcome surfaces as a visible FAILED), not durable (no c1z/token/wire artifact), but schedule-and-timing dependent and at connector consumer distance, with remediation on the redeploy rung — MEDIUM, and the PR already carries the permutation table (TestRegisterLegacyActionSeamAndPollOutcomes) that a change of this shape needs. No blocking security or correctness issues found; the deliberate compatibility break for slow legacy actions is explicitly called out in the PR body, so it is reported as a suggestion about the missing opt-out rather than as a blocker. Vendoring is consistent (go.uber.org/zap/internal already present, modules.txt correctly ordered, go.mod/go.sum unchanged and correctly so, since zap is already a direct dependency).

Security Issues

None found.

Correctness Issues

None found.

Suggestions

  • pkg/connectorbuilder/actions.go:37 — the 1s initial poll interval equals defaultInlineWait, so a polled legacy action can never resolve inside the default inline wait, undercutting the PR's stated inline-wait composition goal.
  • pkg/connectorbuilder/actions.go:317 — the pollResp != nil guard also overwrites annos, silently dropping invoke-time annotations when a poll returns a payload with no annotations.
  • pkg/connectorbuilder/actions.go:23 — the fail-closed poll has no exported opt-out or tuning knob, and pkg/sdk/version.go is unchanged despite a deliberate default-behavior change for deprecated-interface connectors.
Prompt for AI agents
Verify each finding against the current code and only fix it if needed.

## Suggestions

In `pkg/connectorbuilder/actions.go`:
- Around line 36-39: `defaultLegacyPollIntervals.initial` is `time.Second`, which is
  exactly `defaultInlineWait` in `pkg/actions/actions.go` (line 222). Because the outer
  `invokeGlobalAction`/`invokeResourceAction` inline wait timer starts at the same moment
  the handler goroutine starts, the 1s wait always fires before the first status poll can
  complete. The practical effect is that any legacy action that enters the poll loop
  returns `RUNNING` from `InvokeAction` when the caller does not request an explicit
  `inline_wait` — the terminal status can never be delivered inline, which contradicts the
  PR's stated goal that "an action finishing inside the window returns its real terminal
  status inline". Fix: lower the initial interval to a sub-second value (for example
  100ms-250ms) while keeping the doubling and the 30s cap, so a fast async legacy action
  can settle before the inline wait expires. The backoff cap still protects a slow
  action's rate-limit budget.

- Around line 316-318: the guard `if pollResp != nil && (...)` correctly protects `resp`
  from being displaced by an indeterminate poll's payload, but the assignment
  `resp, annos = pollResp, pollAnnos` also replaces `annos`. When a poll returns a
  non-nil response with nil annotations (a common shape, since the legacy
  `CustomActionManager.GetActionStatus` contract permits nil annotations), the
  annotations that `InvokeAction` returned are silently discarded. The pre-change wrapper
  always propagated the invoke annotations. Fix: assign `annos = pollAnnos` only when
  `pollAnnos != nil`, keeping the invoke annotations otherwise. Add a case to
  `TestRegisterLegacyActionSeamAndPollOutcomes` (or a dedicated test) where the invoke
  returns annotations, the settling poll returns a response with nil annotations, and the
  resolved outer action still carries the invoke annotations.

- Around line 19-24 (and the registration call sites at lines 381 and 402): the new
  fail-closed polling behavior is unconditional and has no escape hatch.
  `maxConsecutiveStatusErrors`, `legacyPollIntervals`, and `defaultLegacyPollIntervals`
  are all unexported, so a connector whose legacy manager has a stub or unimplemented
  `GetActionStatus` (previously dead code on this path, as the PR body acknowledges) will
  see every in-flight legacy action flip from `COMPLETE` to `FAILED` roughly 7 seconds
  after invoke, with no way to tune or disable the behavior short of migrating off the
  deprecated interface. The repo's SDK compatibility guidance asks that new behavior
  default to the old behavior and be opt-in where possible. Fix options, in rough order of
  preference: (a) add an exported builder option that disables legacy polling or supplies
  custom intervals/threshold, so an affected connector has a migration path; (b) resolve a
  persistently indeterminate status with the last known response instead of failing,
  reserving the hard failure for an explicit `FAILED` status; (c) at minimum, bump
  `pkg/sdk/version.go` (currently `v0.23.0`) by a minor version as the pre-1.0
  compatibility signal for a default-behavior change, and add a migration note describing
  which legacy connectors are affected.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No blocking issues found.

@jugonzalez12

Copy link
Copy Markdown
Contributor Author

The review comments here are eerily akin to those of a previous draft (#1063), so the next commit will attempt to skip some of the back-and-forth here.

@jugonzalez12

Copy link
Copy Markdown
Contributor Author

▎ Two deliberate choices: The poll intervals are unexported package variables rather than injected configuration — they're production constants whose only non-default consumer is this package's sequential tests, and a config parameter would add API surface to a deprecated compatibility path. And an explicitly set but unrecognized status at the invoke seam fails closed rather than passing through: only the zero value means "this manager never populated status," while any other value is a claim the wrapper refuses to misread as success. The same statuses arriving from polls get the three-strike tolerance instead, because polls repeat and transient anomalies can recover.

Comment thread pkg/connectorbuilder/actions.go Outdated
Comment thread pkg/connectorbuilder/actions.go Outdated
Comment thread pkg/connectorbuilder/actions.go

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No blocking issues found.

@jugonzalez12

Copy link
Copy Markdown
Contributor Author

Status classification, stated once: the wrapper sorts every invoke and poll status into three buckets. UNSPECIFIED means the manager never populated status — the legacy pass-through, resolving with the response as the old wrapper always did. COMPLETE and FAILED are settled and resolve immediately, at the seam and from polls alike — including the in-band shape where the SDK's own manager reports a handler failure as FAILED with a nil error. Everything else is unresolved: polled to a terminal status when an id is present (with a shared three-strike tolerance covering both lookup errors and indeterminate answers), passed through when there's no id to poll. One consequence to know about enum evolution: if a new terminal status is ever added to BatonActionStatus, this wrapper will treat it as unresolved — poll it to the tolerance threshold, then fail closed — until the value is added to the settled-status checks. That's safe (it can never be misread as success) but deliberate: the settled set is enumerated, not inferred. Poll pacing is captured at registration, so the detached poll goroutine reads no shared state; tests drive the loop in milliseconds through the same parameter.

Comment thread pkg/connectorbuilder/actions.go Outdated
Comment thread pkg/connectorbuilder/actions.go Outdated

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No blocking issues found.

Comment thread pkg/connectorbuilder/actions.go Outdated

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No blocking issues found.

Comment thread pkg/connectorbuilder/actions_legacy_test.go Outdated

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No blocking issues found.

Comment thread pkg/connectorbuilder/actions.go Outdated
Comment thread pkg/connectorbuilder/actions.go Outdated
Comment thread pkg/connectorbuilder/actions.go

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No blocking issues found.

Comment thread pkg/connectorbuilder/actions.go Outdated

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No blocking issues found.

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No blocking issues found.

@jugonzalez12
jugonzalez12 marked this pull request as ready for review August 11, 2026 16:33

@ggreer ggreer left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

I noticed a couple of tiny things where some new functions could be used in existing code, but LGTM.

Comment thread pkg/connectorbuilder/actions.go
Comment thread pkg/connectorbuilder/actions.go
Comment thread pkg/connectorbuilder/actions.go
jugonzalez12 and others added 8 commits August 12, 2026 10:20
registerLegacyAction discarded the id and status a CustomActionManager
returned, so a non-terminal status resolved the outer action as
complete with whatever partial response existed while the underlying
action was still running. Keep the invoke response for synchronous
managers that never populated id or status, and poll GetActionStatus
with capped backoff for explicitly in-flight results, bounded by the
handler context's deadline: tolerate a few consecutive lookup failures,
carry the last real response, and fail the outer action when the inner
one reports failure or an unexpected status.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
A legacy manager reporting failure in-band — FAILED status with a nil
error, as the SDK's own manager does for a fast-failing handler —
resolved the outer action as a success at the invoke seam. Terminal
statuses at the seam now resolve exactly like terminal polls, with the
status checked before the id so an explicit failure without an id
cannot pass through either; only a never-populated status keeps the
legacy fire-and-forget behavior.

The poll loop treats indeterminate statuses with the same three-strike
tolerance as lookup errors, keeps only meaningful poll payloads, and
reports the handler deadline's cause instead of a bare context error.
The global invoke path now carries the caller's logger into the
detached handler context, matching the resource path, so the loop's
warnings are no longer dropped; an observer-backed test pins that.

Poll intervals are variables so tests drive the loop in milliseconds:
an outcome table covers the seam and poll matrices, and the original
interval-driven tests shed about fifteen seconds of suite wall time.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
An indeterminate status at the invoke seam was failed immediately while
the same status arriving from a poll got the three-strike tolerance;
both now take the tolerance path. A settled seam status still resolves
like a terminal poll, an unresolved claim without an id keeps the
fire-and-forget pass-through, and the status helper shrinks to the two
values its call sites pass.

Poll pacing moves from package variables into a small struct captured
at registration: the detached poll goroutine read the cap on every
iteration, so test overrides of the globals were a latent data race.
Tests now pass short intervals directly, and a capturing registry
drives the wrapped handler under a caller-owned timeout cause to cover
the context exit, asserting the budget's cause survives the wrapper.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
…ll pacing

The meaningful-payload guard in the poll loop had no test that could
fail if it were removed, because every fake returned the same payload
for invokes and polls. Scripted polls can now carry their own payload,
and a displacement test invokes with one payload, feeds three
indeterminate polls carrying another, and asserts the fail-closed exit
still returns the invoke's payload.

The settled-status check is one predicate now, used at the invoke seam,
the poll switch, and the retention guard, so the gates cannot drift; a
new terminal enum value joins there or takes the indeterminate path.
Zero or negative poll intervals fall back to the defaults instead of
busy-looping the status poll.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The zero-interval guard had no test that could fail if it were removed
— the same gap the payload-displacement instrument closed one commit
earlier. A zero-valued pacing struct now proves the fallback: the
handler resolves terminally with exactly one poll, and that poll must
wait the defaults' initial tick rather than firing immediately, which
is the observable difference between the guard and a busy loop. A poll
count alone cannot discriminate, since a spinning loop with a single
scripted poll also polls exactly once.

The guard's comment also covers the inverted-pair case, which needs no
normalization: the cap applies from the second tick.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The fallback test only passed a zero-valued pacing struct, which trips
the initial-interval check on its own — weakening the guard to ignore
the cap left the suite green while a live initial with a zero cap
busy-loops from the second tick. The test is now a table whose second
case passes exactly that shape; because the fallback replaces the whole
struct, the same one-poll elapsed lower bound discriminates both
halves: a guarded poll waits the defaults' initial tick, an unguarded
one fires at the un-defaulted initial.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The outer error replaces the response's error field, so the generic
'legacy action failed' message was destroying the inner manager's real
failure text at exactly the moment it should surface. legacyStatusErr
now folds the response's reported error into the outer message, at the
invoke seam and from polls alike. The lookup-error threshold exit also
wraps with the action name like the loop's other exits, instead of
returning a bare context error, and the threshold test asserts on the
wrapper text so the wrap itself is pinned.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
The settled exit read the retained response for the inner error
message, so an in-flight poll's snapshot could be reported as the
failure cause when the poll that settled to FAILED carried no payload
of its own. The error-message source is now the settling poll's
payload — nil falls back to the generic message — while the retained
response remains the returned response value.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
@jugonzalez12
jugonzalez12 force-pushed the julian/legacy-action-status branch from 1270fad to d7d7409 Compare August 12, 2026 17:52
Comment thread pkg/connectorbuilder/actions.go
Comment thread pkg/connectorbuilder/actions.go
Comment thread pkg/connectorbuilder/actions.go

@github-actions github-actions Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

No blocking issues found.

@jugonzalez12

Copy link
Copy Markdown
Contributor Author

The robo-review is not converging (basically reversing its own position), my agent's thoughts:

  1. Poll pacing vs. the default inline wait — decline, with math the robot missed. The premise is right: first poll at 1s, default wait 1s, so a polled legacy action can't settle inline under the default wait. But the proposed fix (sub-second initial interval) doesn't deliver its stated benefit for the population that matters: the known legacy managers are SDK-backed, and their inner InvokeAction blocks for its own one-second wait before the wrapper even has an id to poll — the outer window is exhausted before polling begins, at any pacing. And under real platform windows (~4 minutes), the 1s/3s/7s ladder delivers terminal statuses inline with seconds to spare. The fix only helps a hypothetical third-party manager that returns in-flight instantly and settles in under a second, at the cost of denser early polling against exactly the rate-limited remote managers the backoff protects. If the human reviewer wants it anyway it's a one-constant change, but it shouldn't ride as a robot-round fix.

  2. Nil poll annotations displacing invoke annotations — decline; it's the mirror image of the finding we already declined. Last time the robot wanted annotations retained independently when the payload was nil; now it wants invoke-annotations retained when the settling payload's annotations are nil. Both fail against the same principle already posted on the PR: annotations describe the response they arrived with. A settling response with nil annotations means the final response has none — resurrecting invoke-time annotations pairs stale metadata with a payload it never described. The pre-change behavior it cites isn't a contract to preserve: pre-change there was no poll at all, so "always propagated invoke annotations" is just "returned the only pair that existed."

  3. The escape hatch — decline (a) and (b) firmly, adopt (c) as one sentence of PR text. Option (b) — resolve persistent indeterminate with the last known response — is the one to reject hardest: it reintroduces the false-COMPLETE this PR exists to kill, for exactly the managers whose status reporting can't be trusted. Option (a) adds permanent exported API to a deprecated path, serving a self-contradictory implementation shape: to be harmed, a manager must report in-flight-with-id from InvokeAction while having a broken GetActionStatus — claiming trackability while being untrackable — and the honest outcome for that shape is a visible failure. Option (c)'s version bump rides release commits in this repo, not feature PRs, but the migration-note half is fair and nearly free. Add to the PR body: "Behavior change for deprecated-interface connectors: a legacy manager that reports an in-flight status with an id but cannot answer GetActionStatus will now fail those actions visibly (~7s at default pacing) instead of resolving them as successes at one second. Known implementations delegate to the SDK's own manager and are unaffected."

@jugonzalez12

Copy link
Copy Markdown
Contributor Author

I noticed a couple of tiny things where some new functions could be used in existing code, but LGTM.

addressing these in #1080

@jugonzalez12
jugonzalez12 merged commit 69c2996 into main Aug 12, 2026
12 checks passed
@jugonzalez12
jugonzalez12 deleted the julian/legacy-action-status branch August 12, 2026 19:41
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants