Documentation
¶
Overview ¶
Package pending implements the staging store for Supervised mode.
When a chat has SupervisedMode=true, the fs handler doesn't apply file writes immediately. It registers a pending op in this store and blocks on the op's resume channel until the user resolves it via the UI. The UI posts resolve_pending_change / resolve_all_pending_changes (see hub/command_pending.go); that resolution closes the channel with an accept/reject signal, the fs handler unblocks, and the write either applies or returns an error to the agent.
Design points:
In-memory only. Pending ops don't survive a server restart because the blocked fs handlers are bound to the live ACP session; if the process dies, the kiro-cli turn is already lost and restoring pending state would point at callers that no longer exist.
Per-chat FIFO. Ops for a chat are tracked in insertion order so the UI can render "3 pending · oldest to newest" without re-sorting. The ordering also makes "accept all" predictable: files get applied in the order kiro-cli emitted them, so a create-then-edit sequence on the same path applies cleanly.
Concurrent writes to the same path are refused. If op A is already pending for path P and kiro-cli emits op B for P, B's Add returns ErrPathBusy and the fs handler returns an error to the agent without staging. Simpler than chained-op semantics and matches the "pause the agent" intent — the agent should wait on A to settle before queueing B.
Idempotent resolution. Resolving a non-existent or already- resolved op returns ErrUnknown (caller maps to 404/400); it never panics or double-delivers.
Text caps. OldText/NewText are stored verbatim because the SSE payload needs them for the inline diff. Callers MUST truncate above Cap (4 MiB per side) and set the Truncated flag so the UI can fetch full content from disk on demand.
Index ¶
- Constants
- Variables
- type AddParams
- type Kind
- type Resolution
- type Store
- func (s *Store) AcceptAllForChat(chatID api.ChatID) []api.PendingChange
- func (s *Store) Add(ctx context.Context, p *AddParams) (waitCh <-chan struct{}, readResolution func() Resolution, err error)
- func (s *Store) ChatIDs() []api.ChatID
- func (s *Store) CountForChat(chatID api.ChatID) int
- func (s *Store) Get(toolCallID string) (api.PendingChange, bool)
- func (s *Store) ListForChat(chatID api.ChatID) []api.PendingChange
- func (s *Store) RejectAllForChat(chatID api.ChatID) []api.PendingChange
- func (s *Store) Resolve(_ context.Context, chatID api.ChatID, toolCallID string, ...) (api.PendingChange, error)
- func (s *Store) ResolveWithText(_ context.Context, chatID api.ChatID, toolCallID, mergedText string) (api.PendingChange, error)
Constants ¶
const ( KindCreate = api.PendingKindCreate KindEdit = api.PendingKindEdit KindDelete = api.PendingKindDelete )
Kind constants — aliases for the canonical api.PendingChangeKind values.
const ( ActionAccept = api.PendingActionAccept ActionReject = api.PendingActionReject )
ActionAccept and ActionReject are retained as typed constants for callers that need the api.PendingAction value without importing api directly. Resolve now accepts api.PendingAction, making invalid actions a compile error.
const Cap = 4 << 20
Cap is the canonical per-side text cap for OldText/NewText in a pending op (4 MiB). This is the authoritative constant for the maximum file-write payload size across the application — both the Supervised staging path (this package) and the unstaged fs handler (hub/bridge_fs.go's fsWriteCap) MUST use this value so a staged write never holds more bytes than the unstaged path would accept. Callers that receive oversized content MUST truncate (marking Truncated=true) or reject; the store doesn't enforce.
const MaxPendingPerChat = 256
MaxPendingPerChat caps the number of staged ops per chat. A misbehaving agent that emits thousands of fs/write_text_file calls during a Supervised-mode turn would otherwise pin up to ~8 MiB per op (OldText + NewText at Cap each) × N ops in heap memory. 256 is generous for realistic supervised turns (typically <20 ops) and bounds the worst-case heap at ~2 GiB per chat.
const MaxPendingTotal = 2048
MaxPendingTotal caps the total number of staged ops across every chat. Belt-and-braces for the case where one quiet chat is under the per-chat cap but ten concurrent chats each accumulate a handful. Bounds worst-case heap at ~16 GiB across the process.
Variables ¶
var ( // ErrUnknown is returned by Resolve when the tool call id has // no pending op (never staged, or already settled). ErrUnknown = errors.New("pending: unknown tool call id") // ErrPathBusy is returned by Add when the chat already has a // pending op for the same path. The fs handler surfaces this to // the agent as a rejection, which is the cleanest signal; the // alternative (queueing) would require speculative application // order and is more work than the problem justifies. ErrPathBusy = errors.New("pending: path already staged") // ErrBadKind is returned when Add gets something other than // KindCreate / KindEdit / KindDelete. ErrBadKind = errors.New("pending: kind must be create, edit, or delete") // ErrEmptyID is returned when Add gets a blank ToolCallID. ErrEmptyID = errors.New("pending: tool_call_id is required") // ErrEmptyChatID is returned when Add gets a blank ChatID. ErrEmptyChatID = errors.New("pending: chat_id is required") // ErrEmptyPath is returned when Add gets a blank Path. ErrEmptyPath = errors.New("pending: path is required") // ErrTooManyPending is returned when the per-chat or total // pending-op caps are exhausted. The fs handler surfaces this // to the agent as a tool failure; the agent should wait for // existing ops to resolve before retrying. ErrTooManyPending = errors.New("pending: too many pending changes") // ErrMergeNotApplicable is returned by ResolveWithText when the op // kind is KindDelete. Merged text applies to edits AND creates (a // create's proposed content can be partially accepted hunk by hunk, // including down to an empty result); accepting a delete with a // merged body would resurrect content the delete was meant to // remove. See resolve.go for the enforced contract. ErrMergeNotApplicable = errors.New("pending: merged_text not applicable to delete ops") )
Errors returned by the store. Callers map these to HTTP status codes in command_pending.go.
Functions ¶
This section is empty.
Types ¶
type AddParams ¶
type AddParams struct {
ToolCallID string
ChatID api.ChatID
Path string
Kind Kind
OldText string
NewText string
Truncated bool
}
AddParams is the caller's input to Add. Separate from op so the store controls the resume channel and timestamp.
type Kind ¶
type Kind = api.PendingChangeKind
Kind is an alias for api.PendingChangeKind, establishing a single source of truth for pending-change kinds. The api package owns the canonical type; this alias avoids a mass rename of internal callers.
type Resolution ¶
Resolution is the outcome of a pending op after the resume channel closes. Returned atomically by the readResolution closure from Add, eliminating the need for separate MergedText/ClearMergedText calls.
Merged distinguishes a user-authored partial merge (via ResolveWithText) from a plain accept. It is the flag callers gate the write-override on — NOT MergedText != "" — so a partial merge that resolves to an empty file (e.g. a create whose only hunk was rejected) still overrides the agent's content with the empty result instead of silently writing the agent's original text.
type Store ¶
type Store struct {
// contains filtered or unexported fields
}
Store is the staging registry. One Store instance serves every chat in the hub.
func (*Store) AcceptAllForChat ¶
func (s *Store) AcceptAllForChat(chatID api.ChatID) []api.PendingChange
AcceptAllForChat accepts every pending op for the chat in a single lock acquisition.
func (*Store) Add ¶
func (s *Store) Add(ctx context.Context, p *AddParams) (waitCh <-chan struct{}, readResolution func() Resolution, err error)
Add stages a new pending op and returns the blocking channel the fs handler must read to learn the decision. The channel is closed when the user resolves (accept or reject), when the op is flushed via RejectAllForChat, or when ctx is cancelled. The returned readResolution func reports the final decision (including any merged text) after the channel has closed; reads take the store mutex so the mutex provides the happens-before barrier.
If ctx is cancelled before the op is resolved, the store auto-rejects the op (accepted=false) and closes the resume channel. Callers that don't need cancellation should pass context.Background().
Returns ErrPathBusy when the chat already has a pending op for the same path; callers should return an error to the agent in that case (no staging, no block).
Returns ErrTooManyPending when either the per-chat or total pending-op caps are exhausted.
func (*Store) ChatIDs ¶
ChatIDs returns the set of chat IDs that currently have at least one pending op. O(1) bounded by pending ops (not total chats). Used by the hub's replay path to avoid scanning the entire chat directory.
func (*Store) CountForChat ¶
CountForChat returns the number of pending ops for the chat. Cheap O(1) read used by tests; not part of the hot path.
func (*Store) Get ¶
func (s *Store) Get(toolCallID string) (api.PendingChange, bool)
Get returns the snapshot of one op, or ok=false if it's not pending. Used by the editor's pending-diff virtual path endpoint.
func (*Store) ListForChat ¶
func (s *Store) ListForChat(chatID api.ChatID) []api.PendingChange
ListForChat returns snapshots of every pending op for the chat in insertion order. Used by SSE reconnect replay. Safe to call from any goroutine.
func (*Store) RejectAllForChat ¶
func (s *Store) RejectAllForChat(chatID api.ChatID) []api.PendingChange
RejectAllForChat flushes every pending op for the chat, closing each resume channel with accepted=false.
func (*Store) Resolve ¶
func (s *Store) Resolve(_ context.Context, chatID api.ChatID, toolCallID string, action api.PendingAction) (api.PendingChange, error)
Resolve settles a single op. chatID scopes the resolution: an op whose ChatID differs from chatID is treated as unknown, so a resolve command carrying a mismatched chat_id can never settle another chat's op. Returns ErrUnknown if the id isn't pending. Idempotent: a second Resolve for a closed op returns ErrUnknown (the op is gone by then). On success, returns the op's snapshot so the hub can broadcast the resolved event with the original path/kind (the caller may not have them handy).
func (*Store) ResolveWithText ¶
func (s *Store) ResolveWithText(_ context.Context, chatID api.ChatID, toolCallID, mergedText string) (api.PendingChange, error)
ResolveWithText settles an op with accept semantics BUT overrides the agent's proposed NewText with caller-supplied merged content. Used by per-hunk Accept/Reject flows where the client computes a partial merge from OldText + only the accepted hunks' NewText.
chatID scopes the resolution: if the op's ChatID differs from chatID it is treated as unknown (ErrUnknown), so a resolve carrying a mismatched chat_id can never settle another chat's op. This is defense-in-depth alongside the chat-unique op keys.
KindDelete ops refuse merged text: there is no file content to override — the user-intended action is either "accept the delete" or "reject the delete". Accepting a delete with merged content would turn a deletion into a caller-controlled write, which is a data-integrity bug. KindCreate and KindEdit are both allowed; KindCreate means "the agent wanted to create this file with X; the user says write Y instead."
The merged text is stored on the op struct and returned atomically via the Resolution closure from Add, with Merged=true so the caller gates the write-override on the flag rather than on non-empty text (an empty merge must still override the agent's proposal). No separate cleanup step is needed — the Resolution read is the single retrieval point.
Returns ErrUnknown if the id isn't pending. Merged text must be under Cap bytes; callers validate before invoking. Accept semantics are implicit — to reject everything, use plain Resolve(…, "reject").