Skip to content

Drive action resolution through batch sync passes - #378

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

Drive action resolution through batch sync passes#378
ndisidore wants to merge 7 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

Comment thread packages/workshop-backend/src/overseer.ts Outdated
Comment thread packages/workshop-backend/src/overseer.ts Outdated
Comment thread packages/workshop-backend/src/overseer.ts Outdated
@ask-bonk

ask-bonk Bot commented Aug 28, 2026

Copy link
Copy Markdown

Submitted 3 actionable inline review comments.

github run

Comment thread packages/workshop-backend/src/actions.ts Outdated
Comment thread packages/workshop-backend/src/actions.ts
Comment thread packages/workshop-shared/src/gatekeeper.ts
Comment thread packages/workshop-backend/src/overseer.ts Outdated
Comment thread packages/workshop-backend/src/actions.ts Outdated
@ask-bonk

ask-bonk Bot commented Aug 28, 2026

Copy link
Copy Markdown

Submitted 5 actionable inline review comments.

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.
`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.
Five fixes in the action-sync driver and the approve/reject entry
points, from review of the batch apply-through work:

- A manual approval was a frontier: any earlier undecided action rode
  along, attributed to whoever clicked, though they had reviewed only
  the one card. Two authorities now extend the frontier and nothing
  else -- the click on that exact action, or an auto-approval rule the
  user enabled for its kind. An undecided action with neither is a
  gate; the pass stops below it, because applyActionsThrough would
  apply it too. A click above a gate is refused through the existing
  `failure` channel rather than by throwing, so a pass carrying several
  coalesced clicks still applies the authorized ones. Out-of-order
  application stays impossible, which dependent actions require.
  `ManualApproval.frontier` -> `action`, since it no longer names one.
- The frontier defaulted to 0, a valid gatekeeper-assigned action ID,
  so "no frontier" sorted at a real action: a staged veto could be
  delivered at a frontier that would apply an undecided action 0. It
  now starts at -1.
- awaitActionsSettled() only observed idleness; the caller's write
  landed a microtask later, long enough for submitAction's own
  waitUntil pass to start. That pass snapshots the action as pending,
  applies it externally, then skips it at reconcile because the record
  now reads rejected -- applied in the world, rejected in the log,
  permanently. Replaced with withSettled(gk, transition), which runs
  the transition synchronously in the same turn as the idle check.
- The resume loop keyed on `description.awaitDecision`, but a turn
  suspends only when no auto-approval rule will clear the action, so
  approving an unrelated gate started an unsolicited turn on a chat
  that had already ended. Suspension is now recorded on the record
  (`suspendedTurn`) and read back. Records written before this deploy
  carry no flag, so a turn suspended at deploy time won't auto-resume;
  the user can continue it with a message.
- One chat's resume reaches getChatContext over RPC and can throw,
  after the pass has durably approved records from several chats, and
  approveAction's own pending guard blocks a retry. Each per-chat
  resume is now isolated and logged.

Also make veto durability normative: the driver clears vetoPending for
every sent veto on any call that returns, including a `stopped` one, so
a gatekeeper that failed an apply before recording a veto would lose
the rejection and let a later frontier apply it. No gatekeeper
implements applyActionsThrough yet, so stating it costs nothing.

`failure` now survives rejection -- the case where the gatekeeper may
already have applied the action and will ignore the veto as
"already applied" -- so the evidence outlives the decision. The approve
path still clears it (an applied action has no failure), as does the
cascade path (the gatekeeper deleted the action, so the reason is
stale).
ActionLogEntry.failure is the gatekeeper's display-safe reason the last
apply attempt stopped at an action -- "specific enough for the user to
resolve the problem", per the contract -- and until now it reached no
one: nothing in the frontend read it, and useResolveAction collapsed the
thrown message into a generic "Failed to approve action" toast.

The new ActionFailureNote renders it at the four places a user meets an
action: the Activity review list, an expanded history row, and both
branches of the chat action card. The toast now surfaces the server's
message, matching the rewind handler's existing pattern.

This is also how a refused approval explains itself -- the driver writes
the "approve <gate> first" reason onto the clicked action -- so the note
appears on the card the user just clicked, in the list they are already
looking at.
@ndisidore
ndisidore force-pushed the feat/action-apply-through-foundation branch from c2151bf to 10514b4 Compare September 2, 2026 15:19
@github-actions github-actions Bot added the workshop/frontend Changes to the Workshop frontend label Sep 2, 2026
} catch (error) {
return {result: {stopped: {
at: action,
reason: error instanceof Error ? error : new Error(String(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: Legacy applyAction() exceptions do not satisfy the new display-safe stopped.reason contract. Converting the raw exception here causes its message to be persisted as ActionRecord.failure and broadcast/rendered to workspace users; current gatekeepers may include provider or internal details in thrown errors because those errors were previously only failures, not UI data. Use a fixed safe message for this fallback (or another explicitly sanitized channel) rather than treating arbitrary legacy exceptions as user-facing text.

console.error(`Failed to ${decision} action:`, error)
toasts.add({ title: `Failed to ${decision} action`, variant: 'error' })
toasts.add({
title: error instanceof Error && error.message

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: Only ApplyActionsThroughResult.stopped.reason is contractually display-safe, but this now renders every exception message from approveAction()/rejectAction(). Transport, validation, profile lookup, and gatekeeper RPC failures can contain internal or provider-controlled details. Keep the generic toast for arbitrary exceptions and return a typed/display-safe error when the server intentionally needs to explain a blocked or stopped action.

}
for (let chatId of chatIds) {
try {
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: A follow-up pass can coalesce approvals from different clients, and each waiter receives the same decided set (the driver test explicitly asserts this). Every approving client therefore enters this loop for every affected chat, while #maybeResumeAfterActionDecision() and #resumeSuspendedAgent() use that client interface’s #clientUser for the summary author and getChatContext(). Concurrent collaborators can race and resume another approver’s chat under the wrong user’s model credentials/billing identity, or fail to resume it if that user lacks the model. Resume each chat once with persisted initiator context, or return only the records attributable to each request.

@ask-bonk

ask-bonk Bot commented Sep 2, 2026

Copy link
Copy Markdown

Submitted 3 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/frontend Changes to the Workshop frontend workshop/shared Changes to shared Workshop APIs

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant