From b8f2b28295944ee5e1a2b16d335fdace5100b290 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Thu, 28 May 2026 11:41:18 -0300 Subject: [PATCH 1/3] fix(turn-orchestrator): serialize approval wakes to stop parallel-approval races MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two approval::resolve writes for one session fan out via turn::on_approval into concurrent turn::function_awaiting_approval wakes. The turn-step queue has no per-session ordering (Enqueue takes only a queue name), so both wakes loaded the same parked turn_state, executed every call, and finalized the batch — running side-effecting functions twice and emitting duplicate function_execution_end / turn_end frames, which wedged the turn. Add a per-session lease built on the only atomic primitive the state worker exposes (state::update increment, a locked read-modify-write): acquire when the prior holder count is 0, release by resetting it, and let a contender steal a lease older than the TTL to recover from a crashed holder. Gate the function_awaiting_approval transition behind it via runTransition({serialize}). A contender that cannot acquire throws TransientError, so the durable queue retries it after the holder releases and it then stale-skips. Also revert the earlier approve-always parked-sibling change, which targeted a different scenario that did not reproduce the reported bug. Cover the race with a parallel-approval e2e test; the harness now models state::update as a faithful atomic increment and the queue's TransientError retry. --- harness/src/approval-gate/settings/verdict.ts | 30 -------- .../function-awaiting-approval/ports.ts | 6 -- .../function-awaiting-approval/process.ts | 4 +- .../function-awaiting-approval/run.ts | 14 +--- harness/src/turn-orchestrator/hook.ts | 26 ++++++- .../src/turn-orchestrator/run-transition.ts | 37 ++++++++++ .../state-runtime/session-lease.ts | 69 +++++++++++++++++++ .../integration/parallel-approval-harness.ts | 61 ++++++++++++++-- .../integration/parallel-approval.e2e.test.ts | 38 +++++----- 9 files changed, 206 insertions(+), 79 deletions(-) delete mode 100644 harness/src/approval-gate/settings/verdict.ts create mode 100644 harness/src/turn-orchestrator/state-runtime/session-lease.ts diff --git a/harness/src/approval-gate/settings/verdict.ts b/harness/src/approval-gate/settings/verdict.ts deleted file mode 100644 index b3f7ca44..00000000 --- a/harness/src/approval-gate/settings/verdict.ts +++ /dev/null @@ -1,30 +0,0 @@ -/** - * Pure approval verdict derived from a per-session settings snapshot. - * - * Shared by the dispatch-time gate (`consultBefore`) and the parked-call - * re-evaluation in `function_awaiting_approval`, so both honor the same - * grants. Returns `'allow'` when the settings alone authorize the call, - * or `null` when they say nothing (caller falls through to policy / a - * human decision). - * - * - `mode === 'full'` → allow everything. - * - `approved_always` match → allow in EVERY mode (a remembered - * human "approve always" decision). - * - `mode === 'auto'` + allowlist match → allow (auto-mode allowlist is - * dormant outside Auto). - */ - -import type { ApprovalSettings } from './types.js'; - -function listed(entries: ApprovalSettings['always_allow'], function_id: string): boolean { - return entries.some((entry) => entry.function_id === function_id); -} - -export function settingsVerdict(settings: ApprovalSettings, function_id: string): 'allow' | null { - if (settings.mode === 'full') return 'allow'; - if (listed(settings.approved_always, function_id)) return 'allow'; - if (settings.mode === 'auto' && listed(settings.always_allow, function_id)) { - return 'allow'; - } - return null; -} diff --git a/harness/src/turn-orchestrator/function-awaiting-approval/ports.ts b/harness/src/turn-orchestrator/function-awaiting-approval/ports.ts index aa26cd07..350d3877 100644 --- a/harness/src/turn-orchestrator/function-awaiting-approval/ports.ts +++ b/harness/src/turn-orchestrator/function-awaiting-approval/ports.ts @@ -3,8 +3,6 @@ */ import { ApprovalDecisionSchema, STATE_SCOPE } from '../../approval-gate/schemas.js'; -import { readSettings } from '../../approval-gate/settings/store.js'; -import type { ApprovalSettings } from '../../approval-gate/settings/types.js'; import type { ISdk } from '../../runtime/iii.js'; import type { z } from 'zod'; export type ApprovalDecision = z.infer; @@ -17,7 +15,6 @@ export function parseApprovalDecision(value: unknown): ApprovalDecision | null { export type AwaitingApprovalPorts = { readDecision(session_id: string, function_call_id: string): Promise; - readSettings(session_id: string): Promise; }; export function createAwaitingApprovalPorts(iii: ISdk): AwaitingApprovalPorts { @@ -30,8 +27,5 @@ export function createAwaitingApprovalPorts(iii: ISdk): AwaitingApprovalPorts { }); return parseApprovalDecision(raw); }, - readSettings(session_id) { - return readSettings(iii, session_id); - }, }; } diff --git a/harness/src/turn-orchestrator/function-awaiting-approval/process.ts b/harness/src/turn-orchestrator/function-awaiting-approval/process.ts index 0081769b..7b61e858 100644 --- a/harness/src/turn-orchestrator/function-awaiting-approval/process.ts +++ b/harness/src/turn-orchestrator/function-awaiting-approval/process.ts @@ -47,7 +47,9 @@ export function register(iii: ISdk): void { 'turn::function_awaiting_approval', async (payload: TurnStepPayload) => { const parsed = TurnStepPayloadSchema.parse(payload); - return runTransition(iii, 'function_awaiting_approval', handleAwaitingApproval, parsed); + return runTransition(iii, 'function_awaiting_approval', handleAwaitingApproval, parsed, { + serialize: true, + }); }, { description: diff --git a/harness/src/turn-orchestrator/function-awaiting-approval/run.ts b/harness/src/turn-orchestrator/function-awaiting-approval/run.ts index f1646cf8..ebd5aabc 100644 --- a/harness/src/turn-orchestrator/function-awaiting-approval/run.ts +++ b/harness/src/turn-orchestrator/function-awaiting-approval/run.ts @@ -2,7 +2,6 @@ * Resolve approval decisions and route the batch after each decision. */ -import { settingsVerdict } from '../../approval-gate/settings/verdict.js'; import { text } from '../../types/content.js'; import type { FunctionResult } from '../../types/function.js'; import { finalizeBatch, runOneCall } from '../function-execute/run.js'; @@ -52,11 +51,6 @@ export async function processResolvedApprovals( const work = rec.work; let awaiting = [...rec.awaiting_approval]; const executed = { ...work.executed }; - // Lazily snapshotted once per wake: a grant made AFTER a call parked - // (e.g. "approve always" on a sibling of the same function id, or a - // switch to auto/full mode) must release the still-parked calls it now - // covers — otherwise the batch never finalizes and the turn hangs. - let settings: Awaited> | null = null; for (const entry of [...awaiting]) { const callId = entry.function_call_id; @@ -66,13 +60,7 @@ export async function processResolvedApprovals( continue; } - let decision = await readPorts.readDecision(rec.session_id, callId); - if (!decision) { - if (settings === null) settings = await readPorts.readSettings(rec.session_id); - if (settingsVerdict(settings, entry.function_id) === 'allow') { - decision = { decision: 'allow', reason: null }; - } - } + const decision = await readPorts.readDecision(rec.session_id, callId); if (!decision) continue; const current = work.prepared.find((p) => p.call.id === callId)!; diff --git a/harness/src/turn-orchestrator/hook.ts b/harness/src/turn-orchestrator/hook.ts index 9421c563..4f888bd7 100644 --- a/harness/src/turn-orchestrator/hook.ts +++ b/harness/src/turn-orchestrator/hook.ts @@ -23,7 +23,7 @@ import { permissionsDenyEnvelope } from '../approval-gate/denial.js'; import { DENIAL_SCHEMA_VERSION, type DenialEnvelope } from '../approval-gate/schemas.js'; import { isHumanOnlyApprovalFunction } from '../approval-gate/settings/human-only.js'; import { readSettings } from '../approval-gate/settings/store.js'; -import { settingsVerdict } from '../approval-gate/settings/verdict.js'; +import type { ApprovalSettings } from '../approval-gate/settings/types.js'; import type { CheckPermissionsPayload, PolicyCheckReply, @@ -63,6 +63,18 @@ function extractSessionId(args: unknown): string | null { return null; } +function isAlwaysAllowed(settings: ApprovalSettings, function_id: string): boolean { + return settings.always_allow.some( + (entry: ApprovalSettings['always_allow'][number]) => entry.function_id === function_id, + ); +} + +function isApprovedAlways(settings: ApprovalSettings, function_id: string): boolean { + return settings.approved_always.some( + (entry: ApprovalSettings['approved_always'][number]) => entry.function_id === function_id, + ); +} + export async function consultBefore(iii: ISdk, function_call: FunctionCall): Promise { if (isHumanOnlyApprovalFunction(function_call.function_id)) { return { @@ -74,8 +86,16 @@ export async function consultBefore(iii: ISdk, function_call: FunctionCall): Pro const session_id = extractSessionId(function_call.arguments); const settings = session_id ? await readSettings(iii, session_id) : null; - if (settings && settingsVerdict(settings, function_call.function_id) === 'allow') { - return { kind: 'allow' }; + if (settings) { + if (settings.mode === 'full') return { kind: 'allow' }; + // Per-session "approve always" grants apply in every mode — they are + // remembered human decisions, not an auto-policy. + if (isApprovedAlways(settings, function_call.function_id)) { + return { kind: 'allow' }; + } + if (settings.mode === 'auto' && isAlwaysAllowed(settings, function_call.function_id)) { + return { kind: 'allow' }; + } } try { diff --git a/harness/src/turn-orchestrator/run-transition.ts b/harness/src/turn-orchestrator/run-transition.ts index c9827a02..44e392ad 100644 --- a/harness/src/turn-orchestrator/run-transition.ts +++ b/harness/src/turn-orchestrator/run-transition.ts @@ -14,12 +14,24 @@ import { logger } from '../runtime/otel.js'; import { TransientError } from './errors.js'; import { emit } from './events.js'; import { type TurnStepPayload, type TurnStepResult } from './schemas.js'; +import { acquireSessionLease, releaseSessionLease } from './state-runtime/session-lease.js'; import { createTurnStore } from './state-runtime/store.js'; import { type TurnState, type TurnStateRecord, transitionTo } from './state.js'; import { syntheticAssistant } from './synthetic-assistant.js'; export type TransitionHandler = (iii: ISdk, rec: TurnStateRecord) => Promise; +export type RunTransitionOptions = { + /** + * Serialize this transition behind a per-session lease. Required for states + * woken by a fan-out trigger that can enqueue concurrent steps for one + * session (function_awaiting_approval — one wake per approval::resolve). A + * contender that cannot acquire throws {@link TransientError} so the durable + * queue retries it after the holder releases. + */ + serialize?: boolean; +}; + /** Returns a stale skip result when the queue message no longer matches persisted state. */ function staleSkipResult(expectedState: TurnState, rec: TurnStateRecord): TurnStepResult | null { if (rec.state === expectedState) return null; @@ -70,6 +82,31 @@ export async function runTransition( state: TurnState, handle: TransitionHandler, payload: TurnStepPayload, + options?: RunTransitionOptions, +): Promise { + if (!options?.serialize) { + return runTransitionInner(iii, state, handle, payload); + } + const acquired = await acquireSessionLease(iii, payload.session_id); + if (!acquired) { + // Another wake holds the session. Retry via the queue once it releases; + // by then the persisted state has usually advanced and we stale-skip. + throw new TransientError( + `turn::${state}: session ${payload.session_id} held by a concurrent step`, + ); + } + try { + return await runTransitionInner(iii, state, handle, payload); + } finally { + await releaseSessionLease(iii, payload.session_id); + } +} + +async function runTransitionInner( + iii: ISdk, + state: TurnState, + handle: TransitionHandler, + payload: TurnStepPayload, ): Promise { const store = createTurnStore(iii); const rec = await store.loadRecord(payload.session_id); diff --git a/harness/src/turn-orchestrator/state-runtime/session-lease.ts b/harness/src/turn-orchestrator/state-runtime/session-lease.ts new file mode 100644 index 00000000..90da1598 --- /dev/null +++ b/harness/src/turn-orchestrator/state-runtime/session-lease.ts @@ -0,0 +1,69 @@ +/** + * Per-session mutual-exclusion lease for turn FSM transitions. + * + * The `turn-step` durable queue has no per-session ordering — `Enqueue` takes + * only a queue name (see iii-sdk `TriggerAction.Enqueue`). So two + * `turn::function_awaiting_approval` wakes for one session (one per + * `approval::resolve` write, fanned out by the `turn::on_approval` state + * trigger) can run concurrently. Without serialization both load the same + * parked `turn_state`, execute every call, and finalize — duplicating side + * effects (the function runs twice) and emitting duplicate + * `function_execution_end` / `turn_end` frames, which wedges the turn. + * + * The only atomic primitive the state worker exposes is `state::update` with + * `increment` — a locked read-modify-write that returns the prior value (the + * kv adapter holds the store write-lock for the whole op). Acquire increments + * a per-session holder counter; the caller that observes prior `0` (or a + * missing key) won and may proceed. Release resets the counter to `0`. + * + * Crash recovery: a holder that dies mid-transition never resets the counter, + * which would wedge the session forever. A contender whose acquire fails + * therefore steals a lease whose recorded acquire time is older than + * {@link LEASE_TTL_MS}. The steal is best-effort (a post-crash window can let + * two contenders through), but that degrades to the pre-fix behavior only + * briefly after a crash — far better than a permanent deadlock. + */ + +import type { ISdk } from '../../runtime/iii.js'; +import { stateGet, stateSet, stateUpdate } from '../../runtime/state.js'; + +export const LEASE_SCOPE = 'turn_lease'; +export const LEASE_AT_SCOPE = 'turn_lease_at'; +/** A holder older than this is assumed crashed and may be stolen. */ +export const LEASE_TTL_MS = 30_000; + +/** Atomically bump the holder counter; returns the prior count (0 when free/missing). */ +async function bumpHolders(iii: ISdk, session_id: string): Promise { + const res = await stateUpdate(iii, LEASE_SCOPE, session_id, [ + { type: 'increment', path: '', by: 1 }, + ]); + const prior = (res as { old_value?: unknown } | null)?.old_value; + return typeof prior === 'number' ? prior : 0; +} + +/** + * Try to acquire the session lease. Returns `true` when this caller holds it + * and must call {@link releaseSessionLease}; `false` when another transition + * holds it (the caller should back off / retry). + */ +export async function acquireSessionLease(iii: ISdk, session_id: string): Promise { + if ((await bumpHolders(iii, session_id)) === 0) { + await stateSet(iii, LEASE_AT_SCOPE, session_id, Date.now()); + return true; + } + // Contended — recover a lease abandoned by a crashed holder. + const acquiredAt = await stateGet(iii, LEASE_AT_SCOPE, session_id); + if (typeof acquiredAt === 'number' && Date.now() - acquiredAt > LEASE_TTL_MS) { + await stateSet(iii, LEASE_SCOPE, session_id, 0); + if ((await bumpHolders(iii, session_id)) === 0) { + await stateSet(iii, LEASE_AT_SCOPE, session_id, Date.now()); + return true; + } + } + return false; +} + +/** Release the session lease. Safe to call only from the holder. */ +export async function releaseSessionLease(iii: ISdk, session_id: string): Promise { + await stateSet(iii, LEASE_SCOPE, session_id, 0); +} diff --git a/harness/tests/integration/parallel-approval-harness.ts b/harness/tests/integration/parallel-approval-harness.ts index 75c91ade..f10a00a5 100644 --- a/harness/tests/integration/parallel-approval-harness.ts +++ b/harness/tests/integration/parallel-approval-harness.ts @@ -9,6 +9,7 @@ import { handleApprovalStateWrite, handleAwaitingApproval, } from '../../src/turn-orchestrator/function-awaiting-approval/process.js'; +import { TransientError } from '../../src/turn-orchestrator/errors.js'; import { handleExecute } from '../../src/turn-orchestrator/function-execute/process.js'; import { enterFunctionExecute } from '../../src/turn-orchestrator/function-execute/run.js'; import { runTransition } from '../../src/turn-orchestrator/run-transition.js'; @@ -77,14 +78,39 @@ async function runTurnStep(iii: ISdk, function_id: string, session_id: string): return; } if (function_id === 'turn::function_awaiting_approval') { - await runTransition(iii, 'function_awaiting_approval', handleAwaitingApproval, payload); + await runTransition(iii, 'function_awaiting_approval', handleAwaitingApproval, payload, { + serialize: true, + }); + } +} + +/** + * Model the durable queue's TransientError retry: a wake that loses the + * per-session lease re-runs after the holder releases. Yields between attempts + * so the lease holder can make progress. + */ +async function runTurnStepWithRetry( + iii: ISdk, + function_id: string, + session_id: string, +): Promise { + for (let attempt = 0; attempt < 100; attempt += 1) { + try { + await runTurnStep(iii, function_id, session_id); + return; + } catch (err) { + if (err instanceof TransientError) { + await flushMicrotasks(); + continue; + } + throw err; + } } } export function createParallelApprovalHarness(): ParallelApprovalHarness { const stateStore = new Map(); const emitted: AgentEvent[] = []; - let eventSeq = 0; const iii = { trigger: vi.fn( @@ -126,12 +152,35 @@ export function createParallelApprovalHarness(): ParallelApprovalHarness { } if (function_id === 'state::update') { - eventSeq += 1; - return { old_value: eventSeq - 1 }; + // Faithful atomic read-modify-write per (scope, key): the engine's + // kv adapter holds the store write-lock for the whole op, so + // increment returns the prior value (null/absent → treated as 0). + // Both the event counter and the per-session lease depend on this. + const p = payload as { + scope: string; + key: string; + ops?: Array<{ type: string; path?: string; by?: number }>; + }; + const storeKey = `${p.scope}/${p.key}`; + const old_value = stateStore.has(storeKey) + ? structuredClone(stateStore.get(storeKey)) + : null; + let next: unknown = old_value; + for (const op of p.ops ?? []) { + if (op.type === 'increment' && (op.path ?? '') === '') { + next = (typeof next === 'number' ? next : 0) + (op.by ?? 1); + } + } + stateStore.set(storeKey, next); + return { old_value, new_value: structuredClone(next) }; } if (function_id === 'stream::set') { - const p = payload as { data: AgentEvent }; + const p = payload as { stream_name?: string; data: AgentEvent }; + // events.ts mirrors every turn_end onto a second `agent::turn_end` + // stream for compaction. Record only the primary `agent::events` + // stream so `emitted` is a faithful one-entry-per-event log. + if (p.stream_name === 'agent::turn_end') return null; emitted.push(p.data); return null; } @@ -154,7 +203,7 @@ export function createParallelApprovalHarness(): ParallelApprovalHarness { if (function_id.startsWith('turn::') && action != null) { const p = payload as { session_id: string }; - await runTurnStep(iii as unknown as ISdk, function_id, p.session_id); + await runTurnStepWithRetry(iii as unknown as ISdk, function_id, p.session_id); return null; } diff --git a/harness/tests/integration/parallel-approval.e2e.test.ts b/harness/tests/integration/parallel-approval.e2e.test.ts index 47150670..88335491 100644 --- a/harness/tests/integration/parallel-approval.e2e.test.ts +++ b/harness/tests/integration/parallel-approval.e2e.test.ts @@ -1,5 +1,4 @@ import { afterEach, describe, expect, it, vi } from 'vitest'; -import { approveAlways } from '../../src/approval-gate/settings/approve-always.js'; import * as agentTriggerModule from '../../src/turn-orchestrator/agent-trigger.js'; import { createParallelApprovalHarness, @@ -155,40 +154,39 @@ describe('parallel approval e2e', () => { ); }); - it('releases a parked sibling of the same function when "approve always" is granted', async () => { + it('resolves two approvals fired in parallel without double-executing or double-finalizing', async () => { const h = createParallelApprovalHarness(); vi.spyOn(agentTriggerModule, 'dispatchWithHook') .mockResolvedValueOnce({ kind: 'pending' }) .mockResolvedValueOnce({ kind: 'pending' }); h.seedExecute( - 'sess-grant', + 'sess-par', makeAssistantWithCalls([ { id: 'fc-1', functionId: 'shell::run' }, { id: 'fc-2', functionId: 'shell::run' }, ]), ); - await h.runExecute('sess-grant'); - + await h.runExecute('sess-par'); expect( - h.loadTurnRecord('sess-grant')?.awaiting_approval?.map((e) => e.function_call_id), + h.loadTurnRecord('sess-par')?.awaiting_approval?.map((e) => e.function_call_id), ).toEqual(['fc-1', 'fc-2']); - // "Approve always" on fc-1: persist the per-session grant for the - // function id, then resolve only the clicked call (mirrors the UI's - // handleAlwaysAllow). The grant must release the still-parked sibling - // fc-2, which shares the function id, on the same wake. - await approveAlways(h.iii, 'sess-grant', 'shell::run'); - await h.resolveApproval('sess-grant', 'fc-1', 'allow'); - - const rec = h.loadTurnRecord('sess-grant'); - expect(rec?.awaiting_approval).toEqual([]); - expect(rec?.state).toBe('steering_check'); - // Batch finalized: work cleared, both calls produced results, and the - // sibling fc-2 ran without its own explicit approval::resolve. - expect(rec?.work).toBeUndefined(); - expect(rec?.function_results?.map((r) => r.function_call_id).sort()).toEqual(['fc-1', 'fc-2']); + // Both prompts approved concurrently: two approval::resolve writes whose + // wakes interleave on the shared turn_state record. The sequential tests + // never exercise this. runTransition has no optimistic-concurrency guard + // (saveRecord is an unconditional overwrite), so both wakes load the same + // parked record, each executes EVERY call, and each finalizes the batch — + // duplicating side effects and emitting turn_end multiple times. + await Promise.all([ + h.resolveApproval('sess-par', 'fc-1', 'allow'), + h.resolveApproval('sess-par', 'fc-2', 'allow'), + ]); + + const turnEnds = h.emitted.filter((e) => e.type === 'turn_end'); + expect(executionEvents(h.emitted, 'function_execution_end', 'fc-1')).toHaveLength(1); expect(executionEvents(h.emitted, 'function_execution_end', 'fc-2')).toHaveLength(1); + expect(turnEnds).toHaveLength(1); }); it('persists the decision and wakes function_awaiting_approval via approval::resolve', async () => { From 792876c76a826dc1a8f9181ee5227dd000cff7c8 Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Thu, 28 May 2026 16:58:52 -0300 Subject: [PATCH 2/3] chore(harness): clear pre-existing biome correctness lint errors Drop genuinely-unused symbols flagged by biome 2.4.10 (correctness rules, fail the harness lint check): - copy-assets.mjs: unused `stat` import - session/tree/register.ts: unused `activePath as _activePath` import - session/tree/store.test.ts: unused `T` generic type parameter Unblocks CI; these were already red on main, unrelated to the approval-race fix. --- harness/scripts/copy-assets.mjs | 2 +- harness/src/session/tree/register.ts | 1 - harness/tests/session/tree/store.test.ts | 2 +- 3 files changed, 2 insertions(+), 3 deletions(-) diff --git a/harness/scripts/copy-assets.mjs b/harness/scripts/copy-assets.mjs index cf95ccd9..d625a76e 100644 --- a/harness/scripts/copy-assets.mjs +++ b/harness/scripts/copy-assets.mjs @@ -5,7 +5,7 @@ * `include_str!` for embedded data. */ -import { cp, mkdir, readdir, stat } from 'node:fs/promises'; +import { cp, mkdir, readdir } from 'node:fs/promises'; import { dirname, join, relative } from 'node:path'; const root = new URL('..', import.meta.url).pathname; diff --git a/harness/src/session/tree/register.ts b/harness/src/session/tree/register.ts index dd2de65f..270300ab 100644 --- a/harness/src/session/tree/register.ts +++ b/harness/src/session/tree/register.ts @@ -8,7 +8,6 @@ import type { ISdk } from '../../runtime/iii.js'; import type { AgentMessage } from '../../types/agent-message.js'; import { type UpdatePartItem, - activePath as _activePath, appendMessage, appendSynthetic as appendSyntheticOp, cloneSession, diff --git a/harness/tests/session/tree/store.test.ts b/harness/tests/session/tree/store.test.ts index e8a9fba2..f563e84e 100644 --- a/harness/tests/session/tree/store.test.ts +++ b/harness/tests/session/tree/store.test.ts @@ -15,7 +15,7 @@ function makeEntry(id: string, timestamp: number): SessionEntry { function fakeIii(entries: SessionEntry[]): ISdk { return { - trigger: async (req: { function_id: string }): Promise => { + trigger: async (req: { function_id: string }): Promise => { if (req.function_id === 'state::list') { return entries as unknown as R; } From 13a7c33b4959c8bf15b433f195c3b422a901c81e Mon Sep 17 00:00:00 2001 From: Ytallo Layon Date: Thu, 28 May 2026 17:06:48 -0300 Subject: [PATCH 3/3] style(harness): biome-format parallel-approval e2e test Collapse a hand-written multi-line expect() to biome's canonical layout; this format mismatch was the single error failing the harness lint+test CI. --- harness/tests/integration/parallel-approval.e2e.test.ts | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/harness/tests/integration/parallel-approval.e2e.test.ts b/harness/tests/integration/parallel-approval.e2e.test.ts index 88335491..c82f9777 100644 --- a/harness/tests/integration/parallel-approval.e2e.test.ts +++ b/harness/tests/integration/parallel-approval.e2e.test.ts @@ -168,9 +168,9 @@ describe('parallel approval e2e', () => { ]), ); await h.runExecute('sess-par'); - expect( - h.loadTurnRecord('sess-par')?.awaiting_approval?.map((e) => e.function_call_id), - ).toEqual(['fc-1', 'fc-2']); + expect(h.loadTurnRecord('sess-par')?.awaiting_approval?.map((e) => e.function_call_id)).toEqual( + ['fc-1', 'fc-2'], + ); // Both prompts approved concurrently: two approval::resolve writes whose // wakes interleave on the shared turn_state record. The sequential tests