Skip to content

Drive action resolution through batch sync passes - #378

Draft
ndisidore wants to merge 5 commits into
mainfrom
feat/action-apply-through-foundation
Draft

Drive action resolution through batch sync passes#378
ndisidore wants to merge 5 commits into
mainfrom
feat/action-apply-through-foundation

Conversation

@ndisidore

Copy link
Copy Markdown
Member

This adds the shared applyActionsThrough contract and replaces the overseer’s auto-approval drainer with ActionSyncDriver. The driver resolves pending actions in ordered passes, stages vetoes, records failures, and falls back to the existing per-action calls for gatekeepers that have not migrated. The public approveAction and rejectAction RPC signatures remain unchanged. Frontend behavior is intentionally unchanged; the existing per-action controls stay in place, and batch UI work will follow separately. The contract requires idempotency and re-reporting persisted invalidations so a crash cannot misattribute cascade-invalidated actions. Covered by the backend tests and workspace build and lint.

@github-actions github-actions Bot added kernel Changes to the Workshop kernel workshop/shared Changes to shared Workshop APIs labels Aug 28, 2026
@github-actions

Copy link
Copy Markdown

Preview: pr378-feat-action-a-7a61eb44

https://pr378-feat-action-a-7a61eb44-router.cloudflare-os-previews.workers.dev

Dashboard · deleted when this PR closes

@ask-bonk

ask-bonk Bot commented Aug 28, 2026

Copy link
Copy Markdown
  1. P1 actions.ts:296: Legacy fallback applies multiple actions before persisting any approvals. A crash during a later call leaves earlier external effects pending locally; retries may duplicate them or permanently stop on “already applied.”

  2. P1 actions.ts:287: Legacy rejectAction() failures are swallowed and vetoPending is cleared. Transient failures permanently lose the rejection. Modern batch failures retain the flag, but no restart/alarm automatically retries it (actions.ts:122).

  3. P1 actions.ts:200: Future cascade invalidations absent from the initial snapshot are discarded, even if submitted during the RPC await. The originating veto is then cleared, so the action can later be incorrectly recorded as approved.

  4. P2 overseer.ts:9513: approveAction() returns success whenever the target is no longer pending, including when the same pass cascade-rejected it. This violates the approval contract and can cause clients to optimistically display it as approved.

github run

Four fixes in the batch sync driver and its approval entry point:

- The legacy per-action fallback (still the only path any gatekeeper
  implements) applied every action before persisting any approval. A
  failure after an apply landed left the record pending, and a replayed
  legacy applyAction throws on an already-applied action, wedging it
  forever. Approvals are now persisted as each one lands, through one
  idempotent approve() the batch reconcile replays harmlessly.
- A legacy rejectAction failure cleared vetoPending unconditionally.
  Only a DO reset -- rolled back, hence provably undelivered -- now
  keeps the veto staged for the next pass; a settled/unknown action
  still clears, since it would throw forever.
- A cascade invalidation naming an action submitted during the RPC
  await was discarded, leaving it pending to be recorded approved later
  though the gatekeeper had deleted it. The action map is refreshed
  before reconciling when invalidations are reported.
- approveAction() returned success whenever the action was no longer
  pending, including when the same pass cascade-rejected it, which the
  client displays optimistically as approved. It now throws unless the
  record ended approved, and resumes awaitDecision turns first so a
  failure here can't strand another chat.
// undecided action from the same gatekeeper (attributed to this approver), then continues
// through any auto-eligible actions the cleared gate unblocked.
let decided = await this.impl.syncActions(
action.gatekeeperId, {frontier: action.action, resolvedBy: profile});

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: This turns the existing singular Approve button into approval for every earlier pending action on the connection. Those earlier actions can belong to other chats and have unrelated side effects; the chat UI shows only the clicked card, and even the activity popover exposes controls before/all without communicating this frontier behavior. A user can therefore authorize actions they have not reviewed. Until the batch UI explicitly presents and confirms the covered set, approveAction(id) should not silently treat the click as authority for earlier manual gates.

}
}
for (let chatId of chatIds) {
await this.#maybeResumeAfterActionDecision(chatId);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: The batch has already durably approved actions from every chat before this sequential resume loop runs. If one #maybeResumeAfterActionDecision() rejects (for example, getChatContext() or the profile lookup fails), the remaining chat IDs are skipped. Retrying the original approval cannot recover them because it now fails the initial state !== pending check, and future syncs no longer return those already-decided IDs, so those suspended turns stay stranded. Attempt every affected chat (for example with all-settled handling) before propagating a failure, or persist resumable work.

}
// Still pending: the gatekeeper stopped at (or before) this action; surface the
// display-safe reason so the user can resolve the problem and retry.
throw new Error(fresh.failure ?? `Failed to apply action: ${id}`);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2: When the gatekeeper stops at an earlier action, the specific failure is stored on that earlier record, while the clicked frontier record has no failure and this returns only Failed to apply action: <id>. The unchanged useResolveAction also discards the RPC message, and no action UI renders the new failure field. Thus the user gets no indication which earlier action blocked the batch or the display-safe reason the contract requires them to resolve. Surface the stopping record/reason through this call or render failures on pending records before enabling frontier approvals.

@ask-bonk

ask-bonk Bot commented Aug 28, 2026

Copy link
Copy Markdown

Submitted 3 actionable inline review comments.

github run

`syncActions` read as a read/mirror operation, but the call sends
irreversible external writes. Renamed to `applyDecidedActions`, which
also names the union the pass delivers: approvals (manual, then
auto-eligible) applied, rejections delivered as vetoes.
`settledActionSync` -> `awaitActionsSettled`, and the driver follows so
the vocabulary doesn't split at the boundary: `sync` -> `apply`,
`#syncOnce` -> `#applyOnce`, `settled` -> `awaitSettled`, `StagedSync`
-> `StagedPass`. The class, log component, and event prefix keep "sync":
the subsystem is action-sync, its operation is applying decisions.

Also log a failed pass. Two callers deliver one through `waitUntil`
(rejection, auto-approve opt-in) and never observe the rejection, so a
background veto or auto-approval that failed to reach the gatekeeper
left no structured trace.
async awaitSettled(gatekeeperId: number): Promise<void> {
for (;;) {
let running = this.#running.get(gatekeeperId);
if (!running) return;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: awaitSettled() only observes that the driver is idle; it does not reserve it. Since rejectAction() then awaits this call, a concurrent approval can start an apply pass before the rejection resumes and persists its state. The gatekeeper can apply the action externally, after which reconciliation sees the freshly rejected record and skips marking it approved, leaving the audit log rejected despite the side effect. Serialize the rejection transition through the same per-gatekeeper queue, or hold a reservation through the re-check and write.

logger.warn("action sync pass failed", {
event: "action.sync.failed", gatekeeperId, error,
});
slot.reject(error);

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: A failed or lost batch response discards the manual approval intent while leaving the record pending. If the gatekeeper applied the action before the response was lost, the user can next reject that record; the new contract says a veto of an already-applied action is ignored, and this driver then clears vetoPending, so the durable log says rejected even though the effect happened. Persist unresolved approval intent or add an outcome-reconciliation result that distinguishes an already-applied action before accepting a rejection.

* yet applied). Action IDs listed in `vetoes` are actions the user has rejected.
*
* Actions are applied in ascending ID order. Vetoed actions and actions invalidated by a veto
* become terminal no-ops. Processing stops at the first application failure; a pending in-range

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: The contract says processing stops at the first application failure, but it never requires every supplied veto to be durably processed before applications begin. A compliant ascending implementation can fail applying action 1 before handling veto 2; the caller nevertheless clears veto 2 on any stopped result (actions.ts:259-267), permanently losing the rejection. Require vetoes to be durably accepted first, return acknowledgements, or retain sent vetoes when processing stops.

for (let recordId of decided) {
let record = this.impl.storage.actions.get(recordId);
if (record?.type === "action" && record.caller.from === "agent" &&
record.description.awaitDecision) {

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: This checks the requested awaitDecision, not whether the turn actually suspended. An auto-eligible awaitDecision action blocked behind an earlier manual gate does not latch suspension in submitAction() because willAutoApprove is true, so its agent finishes normally. When the earlier gate is later approved, this batch includes the auto action in decided and starts an unsolicited new agent turn after the original has ended. Persist/check actual suspension state rather than inferring it from the description.

// chokepoint, and every transition must record the resolving user and whether it was
// automatic.
let manualAscending = manualApprovals.toSorted((a, b) => a.frontier - b.frontier);
let frontier = manualAscending.at(-1)?.frontier ?? 0;

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1: 0 is a valid sequential integer under the public contract, but using it as the empty frontier can apply action 0 without approval. With pending action 0 and staged veto 1, firstUndecided ignores action 0 (record.action > frontier), advances the frontier through veto 1, and sends applyActionsThrough(1, [1]); action 0 executes but has no attribution and remains pending locally. Represent an absent frontier below every valid ID, or explicitly require and validate positive action IDs.

@ask-bonk

ask-bonk Bot commented Aug 28, 2026

Copy link
Copy Markdown

Submitted 5 actionable inline review comments.

github run

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

Labels

kernel Changes to the Workshop kernel workshop/shared Changes to shared Workshop APIs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant