@@ -65,7 +65,7 @@ vi.mock('@relaycast/sdk', () => ({
6565 RelayError : MockRelayError ,
6666} ) ) ;
6767
68- // ── Mock AgentRelay ───────── ─────────────────────────────────────────────────
68+ // ── Mock HarnessDriverClient ─────────────────────────────────────────────────
6969
7070let waitForExitFn : ( ms ?: number ) => Promise < 'exited' | 'timeout' | 'released' > ;
7171let waitForIdleFn : ( ms ?: number ) => Promise < 'idle' | 'timeout' | 'exited' > ;
@@ -95,26 +95,25 @@ vi.mock('node:child_process', async () => {
9595 } ;
9696} ) ;
9797
98- const mockAgent = {
99- name : 'test-agent-abc' ,
100- get waitForExit ( ) {
101- return waitForExitFn ;
102- } ,
103- get waitForIdle ( ) {
104- return waitForIdleFn ;
105- } ,
106- release : vi . fn ( ) . mockResolvedValue ( undefined ) ,
107- } ;
108-
109- const mockHuman = {
110- name : 'WorkflowRunner' ,
111- sendMessage : vi . fn ( ) . mockResolvedValue ( undefined ) ,
112- } ;
113-
11498function never < T > ( ) : Promise < T > {
11599 return new Promise ( ( ) => { } ) ;
116100}
117101
102+ // Spawned-agent handle shaped like harness-driver's SpawnedAgentHandle, but
103+ // driven by the test's waitForExitFn/waitForIdleFn. waitForExit/waitForIdle
104+ // return `{ reason }` objects (the runner reads `.reason`), not raw strings.
105+ function makeMockHandle ( name : string ) {
106+ return {
107+ name,
108+ runtime : 'pty' as const ,
109+ exitCode : undefined as number | undefined ,
110+ exitSignal : undefined as string | undefined ,
111+ waitForExit : ( ms ?: number ) => waitForExitFn ( ms ) . then ( ( reason ) => ( { reason } ) ) ,
112+ waitForIdle : ( ms ?: number ) => waitForIdleFn ( ms ) . then ( ( reason ) => ( { reason } ) ) ,
113+ release : vi . fn ( ) . mockResolvedValue ( { name } ) ,
114+ } ;
115+ }
116+
118117const defaultSpawnPtyImplementation = async ( { name, task } : { name : string ; task ?: string } ) => {
119118 const queued = mockSpawnOutputs . shift ( ) ;
120119 const stepComplete = task ?. match ( / S T E P _ C O M P L E T E : ( [ ^ \n ] + ) / ) ?. [ 1 ] ?. trim ( ) ;
@@ -128,45 +127,71 @@ const defaultSpawnPtyImplementation = async ({ name, task }: { name: string; tas
128127 : 'STEP_COMPLETE:unknown\n' ) ;
129128
130129 queueMicrotask ( ( ) => {
131- emitRelayEvent ( 'workerOutput' , { name, chunk : output } ) ;
130+ emitMockEvent ( 'workerOutput' , { name, chunk : output } ) ;
132131 } ) ;
133132
134- return { ... mockAgent , name } ;
133+ return makeMockHandle ( name ) ;
135134} ;
136135
137- const relayListeners = new Map < string , Set < ( ...args : any [ ] ) => void > > ( ) ;
138-
139- function emitRelayEvent ( event : string , payload : any ) {
140- const set = relayListeners . get ( event ) ;
141- if ( ! set ) return ;
142- for ( const fn of set ) fn ( payload ) ;
136+ // The runner consumes broker events via `client.onEvent(BrokerEvent)`. Tests
137+ // still call `emitMockEvent('workerOutput'|'messageReceived'|...)`; translate
138+ // those legacy named events into the BrokerEvent shapes the runner switches on.
139+ const eventListeners = new Set < ( event : any ) => void > ( ) ;
140+ function emitMockEvent ( event : string , payload : any = { } ) : void {
141+ let broker : any ;
142+ switch ( event ) {
143+ case 'workerOutput' :
144+ broker = { kind : 'worker_stream' , name : payload . name , stream : 'stdout' , chunk : payload . chunk } ;
145+ break ;
146+ case 'messageReceived' :
147+ broker = {
148+ kind : 'relay_inbound' ,
149+ event_id : payload . eventId ,
150+ from : payload . from ,
151+ target : payload . to ,
152+ body : payload . text ,
153+ thread_id : payload . threadId ,
154+ } ;
155+ break ;
156+ case 'agentSpawned' :
157+ broker = { kind : 'agent_spawned' , name : payload . name , runtime : payload . runtime ?? 'pty' } ;
158+ break ;
159+ case 'agentReleased' :
160+ broker = { kind : 'agent_released' , name : payload . name } ;
161+ break ;
162+ case 'agentExited' :
163+ broker = { kind : 'agent_exited' , name : payload . name , code : payload . exitCode , signal : payload . exitSignal } ;
164+ break ;
165+ case 'agentIdle' :
166+ broker = { kind : 'agent_idle' , name : payload . name , idle_secs : payload . idleSecs } ;
167+ break ;
168+ default :
169+ broker = { kind : event , ...payload } ;
170+ }
171+ for ( const cb of [ ...eventListeners ] ) cb ( broker ) ;
143172}
144173
174+ // Back-compat alias for tests that still call emitRelayEvent.
175+ const emitRelayEvent = emitMockEvent ;
176+
145177const mockRelayInstance = {
146178 spawnPty : vi . fn ( ) . mockImplementation ( defaultSpawnPtyImplementation ) ,
147- human : vi . fn ( ) . mockReturnValue ( mockHuman ) ,
148- shutdown : vi . fn ( ) . mockResolvedValue ( undefined ) ,
149- onBrokerStderr : vi . fn ( ) . mockReturnValue ( ( ) => { } ) ,
150- addListener : vi . fn ( ( event : string , fn : ( ...args : any [ ] ) => void ) => {
151- let set = relayListeners . get ( event ) ;
152- if ( ! set ) {
153- set = new Set ( ) ;
154- relayListeners . set ( event , set ) ;
155- }
156- set . add ( fn ) ;
157- return ( ) => {
158- set ! . delete ( fn ) ;
159- } ;
179+ onEvent : vi . fn ( ( cb : ( event : any ) => void ) => {
180+ eventListeners . add ( cb ) ;
181+ return ( ) => eventListeners . delete ( cb ) ;
160182 } ) ,
183+ connectEvents : vi . fn ( ) ,
161184 listAgents : vi . fn ( ) . mockResolvedValue ( [ ] ) ,
162- listAgentsRaw : vi . fn ( ) . mockResolvedValue ( [ ] ) ,
185+ release : vi . fn ( ) . mockResolvedValue ( { name : '' } ) ,
186+ sendMessage : vi . fn ( ) . mockResolvedValue ( { event_id : 'evt' , targets : [ ] } ) ,
187+ shutdown : vi . fn ( ) . mockResolvedValue ( undefined ) ,
163188} ;
164189
165190let relayEventCounter = 0 ;
166191
167192function emitRelayChannelMessage ( message : { from : string ; to : string ; text : string } ) {
168193 setTimeout ( ( ) => {
169- emitRelayEvent ( 'messageReceived' , {
194+ emitMockEvent ( 'messageReceived' , {
170195 eventId : `evt-${ ++ relayEventCounter } ` ,
171196 from : message . from ,
172197 to : message . to ,
@@ -176,9 +201,13 @@ function emitRelayChannelMessage(message: { from: string; to: string; text: stri
176201 } , 0 ) ;
177202}
178203
179- vi . mock ( '../relay.js' , ( ) => ( {
180- AgentRelay : vi . fn ( ) . mockImplementation ( ( ) => mockRelayInstance ) ,
181- } ) ) ;
204+ vi . mock ( '@agent-relay/harness-driver' , async ( importOriginal ) => {
205+ const actual = await importOriginal < typeof import ( '@agent-relay/harness-driver' ) > ( ) ;
206+ return {
207+ ...actual ,
208+ HarnessDriverClient : { spawn : vi . fn ( async ( ) => mockRelayInstance ) } ,
209+ } ;
210+ } ) ;
182211
183212// Import after mocking
184213const { WorkflowRunner } = await import ( '../runner.js' ) ;
@@ -241,6 +270,11 @@ type WorkflowStepOverride = Partial<NonNullable<RelayYamlConfig['workflows']>[nu
241270
242271function makeSupervisedConfig ( stepOverrides : WorkflowStepOverride = { } ) : RelayYamlConfig {
243272 return makeConfig ( {
273+ // The runner now defaults to strategy:'retry' with repairRetries; these
274+ // supervised tests assert first-pass review/owner outcomes, so opt into
275+ // fail-fast to exercise the failure path deterministically (no retry loop).
276+ // Step-level `retries` overrides still drive the explicit retry tests.
277+ errorHandling : { strategy : 'fail-fast' } ,
244278 swarm : { pattern : 'hub-spoke' } ,
245279 agents : [
246280 { name : 'specialist' , cli : 'claude' , role : 'engineer' } ,
@@ -314,9 +348,8 @@ describe('Completion Pipeline', () => {
314348 waitForExitFn = vi . fn ( ) . mockResolvedValue ( 'exited' ) ;
315349 waitForIdleFn = vi . fn ( ) . mockImplementation ( ( ) => never ( ) ) ;
316350 mockSpawnOutputs = [ ] ;
317- mockAgent . release . mockResolvedValue ( undefined ) ;
318351 mockRelayInstance . spawnPty . mockImplementation ( defaultSpawnPtyImplementation ) ;
319- relayListeners . clear ( ) ;
352+ eventListeners . clear ( ) ;
320353 db = makeDb ( ) ;
321354 runner = new WorkflowRunner ( { db, workspaceId : 'ws-test' } ) ;
322355 } ) ;
@@ -478,6 +511,7 @@ describe('Completion Pipeline', () => {
478511
479512 const run = await runner . execute (
480513 makeConfig ( {
514+ errorHandling : { strategy : 'fail-fast' } ,
481515 agents : [ { name : 'reviewer' , cli : 'claude' , preset : 'reviewer' } ] ,
482516 workflows : [
483517 {
@@ -754,7 +788,9 @@ describe('Completion Pipeline', () => {
754788 // These tests validate the tolerant parser once it's implemented.
755789 // The tolerant parser should accept semantic equivalents.
756790
757- it ( 'should still fail on review output with no usable approval or rejection signal' , async ( ) => {
791+ it ( 'should fail closed (reject) when the reviewer hedges instead of deciding' , async ( ) => {
792+ // Declared indecision ("I need more context before deciding") is treated as
793+ // a fail-closed REJECT so the step retries instead of crashing as malformed.
758794 mockSpawnOutputs = [
759795 'worker finished\n' ,
760796 'STEP_COMPLETE:step-1\n' ,
@@ -763,7 +799,7 @@ describe('Completion Pipeline', () => {
763799
764800 const run = await runner . execute ( makeSupervisedConfig ( ) , 'default' ) ;
765801 expect ( run . status ) . toBe ( 'failed' ) ;
766- expect ( run . error ) . toContain ( 'review response malformed ' ) ;
802+ expect ( run . error ) . toContain ( 'review rejected ' ) ;
767803 } , 15000 ) ;
768804 } ) ;
769805
@@ -1014,7 +1050,7 @@ describe('Completion Pipeline', () => {
10141050 emitRelayEvent ( 'workerOutput' , { name, chunk : output } ) ;
10151051 } ) ;
10161052
1017- const agent = { ... mockAgent , name } ;
1053+ const agent = makeMockHandle ( name ) ;
10181054 if ( name . includes ( 'map-1-worker' ) ) {
10191055 emitRelayChannelMessage ( {
10201056 from : agent . name ,
@@ -1259,7 +1295,10 @@ describe('Completion Pipeline', () => {
12591295
12601296 it ( 'should still fail when marker, owner decision, and evidence are all missing' , async ( ) => {
12611297 mockSpawnOutputs = [ 'Did the work but no marker\n' ] ;
1262- const run = await runner . execute ( makeConfig ( ) , 'default' ) ;
1298+ const run = await runner . execute (
1299+ makeConfig ( { errorHandling : { strategy : 'fail-fast' } } ) ,
1300+ 'default'
1301+ ) ;
12631302 expect ( run . status ) . toBe ( 'failed' ) ;
12641303 expect ( run . error ) . toContain ( 'owner completion decision missing' ) ;
12651304 } , 15000 ) ;
@@ -1706,6 +1745,7 @@ describe('Completion Pipeline', () => {
17061745 it ( 'should fail when process exits with non-zero code and no signal' , async ( ) => {
17071746 // Agent exits with non-zero and no coordination signal — should fail
17081747 const config = makeConfig ( {
1748+ errorHandling : { strategy : 'fail-fast' } ,
17091749 swarm : { pattern : 'dag' , completionGracePeriodMs : 5000 } ,
17101750 agents : [ { name : 'agent-a' , cli : 'claude' } ] ,
17111751 workflows : [
@@ -1735,6 +1775,7 @@ describe('Completion Pipeline', () => {
17351775 it ( 'should respect completionGracePeriodMs: 0 to disable fallback' , async ( ) => {
17361776 // With grace period disabled, missing signals should always fail
17371777 const config = makeConfig ( {
1778+ errorHandling : { strategy : 'fail-fast' } ,
17381779 swarm : { pattern : 'dag' , completionGracePeriodMs : 0 } ,
17391780 agents : [ { name : 'agent-a' , cli : 'claude' } ] ,
17401781 workflows : [
@@ -1862,6 +1903,7 @@ describe('Completion Pipeline', () => {
18621903
18631904 it ( 'should not complete via process-exit fallback when output contains INCOMPLETE_RETRY' , async ( ) => {
18641905 const config = makeConfig ( {
1906+ errorHandling : { strategy : 'fail-fast' } ,
18651907 swarm : { pattern : 'dag' , completionGracePeriodMs : 5000 } ,
18661908 agents : [ { name : 'agent-a' , cli : 'claude' } ] ,
18671909 workflows : [
0 commit comments