feat(api): cycle-routed approval resolution across server, console, schemas, SDKs

POST /approve accepts cycle_id / call_id selectors and 409s on stale
selectors with the current cycle's ids so clients re-render instead of
silently resolving an unrelated batch. Selector-less bodies pin the
resolve to the cycle the lookup returned (not "whichever is oldest by
the time the resolve runs"), and Approve+Always names apply only after
the pinned cycle actually resolved — the auto-approve whitelist can no
longer describe a different batch than the one that resolved.

approve_request carries cycle_id; approval_resolved carries cycle_id +
call_ids; SSE reconnect replays every live cycle's card. The console
collector, coordinator UI fan-out, and both SDKs (Python + TS) thread
the cycle correlation through.

BREAKING (1.7): the singular pending_approval_detail field is removed
from dashboard rows, workstream detail, and node snapshots — replaced
by the pending_approval_details list (one entry per live cycle, each
carrying its cycle_id). stable/1.6 keeps the old shape.
This commit is contained in:
Patrick Buckley
2026-07-05 01:16:22 -07:00
parent 185dcc2960
commit 18c3301428
12 changed files with 388 additions and 151 deletions
+10
View File
@@ -75,15 +75,25 @@ export interface ToolInfoEvent {
items: Array<Record<string, unknown>>;
}
/** One approval CYCLE awaiting the operator. Several can be outstanding
* at once (parallel task agents each gate their own tool calls) — key
* prompt UI by `cycle_id` and echo it back on the approve POST. */
export interface ApproveRequestEvent {
type: "approve_request";
cycle_id: string;
items: Array<Record<string, unknown>>;
judge_pending?: boolean;
}
/** A specific approval cycle resolved; `cycle_id`/`call_ids` identify
* which prompt to dismiss. */
export interface ApprovalResolvedEvent {
type: "approval_resolved";
approved: boolean;
feedback: string;
always?: boolean;
cycle_id: string;
call_ids: string[];
}
export interface ToolResultEvent {
+9
View File
@@ -166,6 +166,13 @@ export class TurnstoneServer extends BaseClient {
approved?: boolean;
feedback?: string | null;
always?: boolean;
/** Resolve exactly this approval cycle (from ApproveRequestEvent.cycle_id).
* Omitting it resolves the OLDEST live cycle — ambiguous when parallel
* task agents have several prompts outstanding, so pass it whenever the
* triggering event is known. */
cycleId?: string;
/** Alternative selector: any call_id inside the target cycle. */
callId?: string;
}): Promise<StatusResponse> {
return this.request(
"POST",
@@ -175,6 +182,8 @@ export class TurnstoneServer extends BaseClient {
approved: opts.approved ?? true,
feedback: opts.feedback,
always: opts.always,
cycle_id: opts.cycleId,
call_id: opts.callId,
},
},
);