-
Notifications
You must be signed in to change notification settings - Fork 0
fix: settle approval and worktree races #126
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Open
moshloop
wants to merge
4
commits into
main
Choose a base branch
from
fix/ci-flakes-and-test-cache
base: main
Could not load branches
Branch not found: {{ refName }}
Loading
Could not load tags
Nothing to show
Loading
Are you sure you want to change the base?
Some commits from the old base branch may be removed from the timeline,
and old review comments may become outdated.
Open
Changes from all commits
Commits
Show all changes
4 commits
Select commit
Hold shift + click to select a range
9580331
refactor(aichat): collapse the duplicate suspended-seed waits
moshloop b56593d
fix(aichat): wait for a suspending run to park before refusing its ap…
moshloop 8799906
fix(gitagent): publish the agent worktree only once it is complete
moshloop 7200771
test(cli): keep the Go toolchain caches across the test HOME override
moshloop File filter
Filter by extension
Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
There are no files selected for viewing
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,149 @@ | ||
| package aichat_test | ||
|
|
||
| import ( | ||
| "context" | ||
| "encoding/json" | ||
| "time" | ||
|
|
||
| "github.com/flanksource/captain/pkg/aichat" | ||
| "github.com/flanksource/captain/pkg/api" | ||
| "github.com/flanksource/captain/pkg/database" | ||
| "github.com/flanksource/commons-db/dbtest" | ||
| "github.com/google/uuid" | ||
|
|
||
| . "github.com/onsi/ginkgo/v2" | ||
| . "github.com/onsi/gomega" | ||
| ) | ||
|
|
||
| // A provider approval is answerable — it is on the session, and its ID has gone | ||
| // out on the event stream — from the moment the permission frame is observed. | ||
| // The run it blocks only reaches `waiting` once the stream has finished the turn | ||
| // and encoded its checkpoint, several statements later. These specs pin what | ||
| // happens to an answer that arrives inside that window, which is where a person | ||
| // clicking Approve promptly, and the mocked lifecycle suite, both landed. | ||
| var _ = Describe("Approvals answered while the suspension is still landing", func() { | ||
| It("waits for the run to park rather than refusing the answer", func(ctx SpecContext) { | ||
| fixture := newApprovalFixture(ctx, "captain_aichat_approval_settles") | ||
|
|
||
| // Nothing has parked the run yet; under the bare store guard this is the | ||
| // exact moment that produced "cannot be resolved before its prompt run is | ||
| // waiting" and a 409. | ||
| suspended := make(chan error, 1) | ||
| go func() { | ||
| time.Sleep(200 * time.Millisecond) | ||
| suspended <- suspendOnAccountsApproval(ctx, fixture.execution) | ||
| }() | ||
|
|
||
| continuation, err := fixture.authority.ResolveToolApproval(ctx, aichat.ToolApprovalResolution{ | ||
| ThreadID: fixture.thread.ID, ApprovalID: fixture.approvalID, Approved: false, Reason: "not now", | ||
| }) | ||
| Expect(err).NotTo(HaveOccurred(), "an answer that raced the suspension is still a valid answer") | ||
| Expect(continuation).NotTo(BeNil(), "the resolution has to hand back the continuation that resumes the run") | ||
| DeferCleanup(continuation.Execution.Close) | ||
| Expect(<-suspended).To(Succeed()) | ||
|
|
||
| resolved, err := fixture.store.GetSession(ctx, fixture.thread.ID) | ||
| Expect(err).NotTo(HaveOccurred()) | ||
| Expect(resolved.Requests).To(HaveLen(1)) | ||
| Expect(resolved.Requests[0].State).To(Equal(string(database.TurnRequestStateDenied))) | ||
| Expect(resolved.Requests[0].Reason).To(Equal("not now")) | ||
| }) | ||
|
|
||
| It("refuses an answer once the run it blocks has already ended", func(ctx SpecContext) { | ||
| fixture := newApprovalFixture(ctx, "captain_aichat_approval_run_ended") | ||
|
|
||
| runID, err := uuid.Parse(fixture.execution.PromptRunID()) | ||
| Expect(err).NotTo(HaveOccurred()) | ||
| run, err := fixture.db.GetPromptRun(ctx, runID) | ||
| Expect(err).NotTo(HaveOccurred()) | ||
| cancelled := database.PromptRunStateCancelled | ||
| _, err = fixture.db.UpdatePromptRun(ctx, database.UpdatePromptRunInput{ | ||
| ID: run.ID, ExpectedVersion: run.Version, State: &cancelled, | ||
| }) | ||
| Expect(err).NotTo(HaveOccurred()) | ||
|
|
||
| // No suspension is coming, so this must fail on the run's state rather | ||
| // than burn the whole settle budget waiting for one. | ||
| started := time.Now() | ||
| _, err = fixture.authority.ResolveToolApproval(ctx, aichat.ToolApprovalResolution{ | ||
| ThreadID: fixture.thread.ID, ApprovalID: fixture.approvalID, Approved: true, | ||
| }) | ||
| Expect(err).To(MatchError(database.ErrTurnRequestConflict)) | ||
| Expect(err).To(MatchError(ContainSubstring("already ended (cancelled)"))) | ||
| Expect(time.Since(started)).To(BeNumerically("<", time.Second), | ||
| "a run that ended is a decided answer, not something to wait out") | ||
| }) | ||
| }) | ||
|
|
||
| type approvalFixture struct { | ||
| db *database.DB | ||
| store *aichat.DatabaseThreadStore | ||
| authority *aichat.DatabaseExecutionAuthority | ||
| thread *aichat.Thread | ||
| execution aichat.Execution | ||
| approvalID string | ||
| } | ||
|
|
||
| // newApprovalFixture drives a chat turn up to the point where the provider has | ||
| // asked for permission and the durable approval exists, but the run has not yet | ||
| // been parked. | ||
| func newApprovalFixture(ctx context.Context, name string) approvalFixture { | ||
| GinkgoHelper() | ||
| testDB := dbtest.ForGinkgo(dbtest.Options{Name: name}) | ||
| db, err := database.Open(ctx, database.WithDSN(testDB.DSN()), database.WithMigrations()) | ||
| Expect(err).NotTo(HaveOccurred()) | ||
| DeferCleanup(db.Close) | ||
| store, err := aichat.NewDatabaseThreadStore(db) | ||
| Expect(err).NotTo(HaveOccurred()) | ||
| thread, err := store.Create(ctx, "Accounts") | ||
| Expect(err).NotTo(HaveOccurred()) | ||
| authority, err := aichat.NewDatabaseExecutionAuthority(db) | ||
| Expect(err).NotTo(HaveOccurred()) | ||
| execution, err := authority.Begin(ctx, aichat.ExecutionRequest{ | ||
| ThreadID: thread.ID, RequestID: "user-message-1", Title: thread.Title, | ||
| Spec: api.Spec{Model: withCaps(api.Model{Name: "gemini", Mode: api.ModeAPI})}, | ||
| }) | ||
| Expect(err).NotTo(HaveOccurred()) | ||
| Expect(store.AppendMessage(ctx, thread.ID, aichat.UIMessage{ | ||
| ID: "user-message-1", TurnID: execution.TurnID(), Role: "user", | ||
| Parts: []aichat.UIPart{{Type: "text", Text: "Edit the account"}}, | ||
| })).To(Succeed()) | ||
| permission, err := execution.Observe(ctx, api.Event{ | ||
| Kind: api.EventPermission, ToolCallID: "call-account-1", Tool: "accounts_edit", | ||
| Input: map[string]any{"id": "acc-1"}, | ||
| }) | ||
| Expect(err).NotTo(HaveOccurred()) | ||
| Expect(store.AppendMessage(ctx, thread.ID, aichat.UIMessage{ | ||
| ID: execution.TurnID() + "-assistant", TurnID: execution.TurnID(), Role: "assistant", | ||
| Parts: []aichat.UIPart{{ | ||
| Type: "dynamic-tool", ToolName: "accounts_edit", ToolCallID: "call-account-1", | ||
| State: "approval-requested", Input: json.RawMessage(`{"id":"acc-1"}`), | ||
| Approval: &aichat.Approval{ID: permission.ApprovalID}, | ||
| }}, | ||
| })).To(Succeed()) | ||
| return approvalFixture{ | ||
| db: db, store: store, authority: authority, thread: thread, | ||
| execution: execution, approvalID: permission.ApprovalID, | ||
| } | ||
| } | ||
|
|
||
| // suspendOnAccountsApproval completes the turn the way a provider that needs an | ||
| // approval does: a terminal result carrying the approval state and the private | ||
| // checkpoint the resume replays from. This is what parks the run in `waiting`. | ||
| func suspendOnAccountsApproval(ctx context.Context, execution aichat.Execution) error { | ||
| _, err := execution.Observe(ctx, api.Event{ | ||
| Kind: api.EventResult, Success: true, | ||
| ToolApproval: &api.ToolApprovalState{ | ||
| Messages: []api.Message{{Role: api.RoleAssistant, Parts: []api.Part{{ | ||
| Type: api.PartToolRequest, ToolRequest: &api.ToolRequest{ | ||
| ToolCallID: "call-account-1", Name: "accounts_edit", Input: json.RawMessage(`{"id":"acc-1"}`), | ||
| }, | ||
| }}}}, | ||
| Calls: []api.ToolApprovalCall{{Request: api.ToolApprovalRequest{ | ||
| ToolCallID: "call-account-1", Tool: "accounts_edit", Input: json.RawMessage(`{"id":"acc-1"}`), | ||
| }}}, | ||
| ProviderCheckpoint: &api.ProviderCheckpoint{Codec: "test-provider", Version: 1, Payload: []byte("checkpoint")}, | ||
| }, | ||
| }) | ||
| return err | ||
| } |
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
Oops, something went wrong.
Oops, something went wrong.
Add this suggestion to a batch that can be applied as a single commit.
This suggestion is invalid because no changes were made to the code.
Suggestions cannot be applied while the pull request is closed.
Suggestions cannot be applied while viewing a subset of changes.
Only one suggestion per line can be applied in a batch.
Add this suggestion to a batch that can be applied as a single commit.
Applying suggestions on deleted lines is not supported.
You must change the existing code in this line in order to create a valid suggestion.
Outdated suggestions cannot be applied.
This suggestion has been applied or marked resolved.
Suggestions cannot be applied from pending reviews.
Suggestions cannot be applied on multi-line comments.
Suggestions cannot be applied while the pull request is queued to merge.
Suggestion cannot be applied right now. Please check back later.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
🔒 Security & Privacy | 🛡️ Analyzed with Security Review | 🟡 Minor | ⚡ Quick win
🧩 Analysis chain
🏁 Script executed:
Repository: flanksource/captain
Length of output: 38098
🏁 Script executed:
Repository: flanksource/captain
Length of output: 50375
🤖 get_repo_knowledge executed:
get_repo_knowledge flanksource/captain /tmp/coderabbit-repo-knowledge/flanksource-captain-6a68f9a0/conventionsLength of output: 978
Information Disclosure
Reachability: External
Exploitability: Difficult
CWE: CWE-200 — Exposure of Sensitive Information to an Unauthorized Actor
Preserve thread scope before reading the prompt run.
ResolveToolApprovalrunsawaitSuspendedRunbefore it parsesThreadID. The preflight loads the approval and prompt run by approval ID only. The HTTP handler returns these errors directly. A caller with an approval UUID from another thread can learn the foreign prompt-run ID and state, or hold the request until the preflight timeout.Parse
ThreadIDbefore the preflight and make the lookup session-scoped, or letResolveToolApprovalRequestperform the session-scoped check first. Add a regression test for a foreign approval ID.🤖 Prompt for AI Agents