Skip to content

publish_and_wait can return the previous publish's revision, before the new one is scanned #116

Description

@CaptainAni187

publish_and_wait can return the revision from the previous publish, and return straight away, before the security scan it exists to wait for has finished.

The module docstring explains the constraint it is working around:

publishing a draft returns only {state: "scanning"} (HTTP 202) while a security scan runs; there is no revision id in that response. The caller has to list the branch's newest revision and poll it until status == "published"

So wait_for_commit has nothing to match on, and identifies the new revision purely as "whatever is newest right now":

listed = self.revisions.list(id=agent_id, branch_id=branch_id, limit=1)
revs = getattr(listed.data, "revisions", None) or []
if revs:
    rev_id = revs[0].id
    ...
    if last_status == "published":
        return rev

On a branch that has been published before, there is a window after publish_draft returns 202 scanning where the new revision row has not surfaced in the listing yet. During that window the newest revision is the previous one, which is already published, so the first poll matches on it and returns.

Reproducing

A stand-in client following the flow the docstring describes: publish_draft answers scanning with no revision id, and the listing returns the previous revision r1 on the first poll and the new r2 after that.

from types import SimpleNamespace as NS
from smallestai.atoms.helpers.versioning import Versioning

class FakeRevisions:
    def __init__(self, script):
        self.script, self.calls = script, 0
    def list(self, id, branch_id, limit):
        rev_id = self.script[min(self.calls, len(self.script) - 1)]
        self.calls += 1
        return NS(data=NS(revisions=[NS(id=rev_id)]))
    def get(self, id, branch_id, revision_id):
        return NS(data=NS(revision=NS(id=revision_id, status="published",
                                      security_check=NS(status="passed"))))

class FakeBranches:
    def publish_draft(self, id, branch_id, label=None):
        return NS(data=NS(state="scanning", revision=None))   # 202, no revision id

class FakeClient:
    def __init__(self, script):
        self.atoms = NS(agent_versioning_branches=FakeBranches(),
                        agent_versioning_revisions=FakeRevisions(script))

v = Versioning(FakeClient(script=["r1", "r2"]))
got = v.publish_and_wait("agent-1", "branch-1", timeout=10.0, poll_interval=0.1)
print("publish_and_wait returned:", got.id)
publish_and_wait returned: r1

r2 is the revision this publish created. r1 is the one before it.

Why it matters

edit_and_publish hands that revision straight back, and the documented next step is branches.make_live(...). A caller that edits a prompt, gets what looks like a successful publish, and makes the branch live is acting on a revision that is not the edit they just made, while the real one is still being scanned. A scan that later fails is never seen, because the function already returned.

It is timing dependent, so it will look fine in testing and show up occasionally in production, which is the worst shape for this kind of bug.

A possible fix

Read the newest revision id once before calling publish_draft, then have wait_for_commit ignore that id and wait for a different one to appear and reach published. A branch with no revisions yet starts from None, so the first publish works unchanged.

That does mean wait_for_commit needs the baseline passed in when it is called from publish_and_wait, while staying usable on its own. Whether it takes an optional after_revision_id argument or publish_and_wait stops delegating and inlines the poll is your call, which is why I am raising it rather than sending a patch. Happy to send one once you have picked a direction.

Found while reading the helpers for #114, on main at fb2502c.

Activity

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

      Milestone

      No milestone

      Relationships

      None yet

      Development

      No branches or pull requests

      Issue actions