Browse Source

Merge remote-tracking branch 'origin/master' into codex/skill-system

# Conflicts:
#	packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts
Yichen Jiang 2 months ago
parent
commit
e4be19b29e

+ 54 - 24
packages/workflow/workflow-workerthread/tests/workflow-workerthread.spec.ts

@@ -15,6 +15,32 @@ function fakeParent(): Agent {
   return { id: AgentId('workflow-parent'), options: {} } as unknown as Agent
 }
 
+// Worker-thread startup is CPU-bound (a fresh thread compiles the runtime on
+// every start): on a contended CI runner it regularly blows past vitest's 5s
+// default test timeout, observed repeatedly on the coverage lane.
+vi.setConfig({ testTimeout: 30_000 })
+
+/**
+ * `vi.waitFor` with a contention-proof default timeout: the 1s default
+ * flaked repeatedly on the CI coverage lane, where worker-thread cold start
+ * (CPU-bound — a fresh thread compiles the runtime) competes with three
+ * sibling vitest workers for CPU. The 10s default is for exactly those
+ * races — waiting for a worker to start, run its first script line, or
+ * deliver an async child-registration message to the host. It is NOT for a
+ * wait that asserts the HOST reacted PROMPTLY to something that already
+ * happened (a settled result, an observed worker death): those keep an
+ * explicit tight override below, or the generous default would silently
+ * accept a multi-second regression in host-side reap latency as passing
+ * (proven by injecting a 6s delay into one such reap and watching the
+ * un-overridden version of this helper still pass in ~6s).
+ * @param assertion - retried until it stops throwing or the timeout elapses.
+ * @param timeout - override for a wait that must stay deliberately tight.
+ * @returns resolves when the assertion passes.
+ */
+function waitFor(assertion: () => void, timeout = 10_000): Promise<void> {
+  return vi.waitFor(assertion, { timeout, interval: 50 })
+}
+
 /** The vm-context escape hatch, spelled once: real Worker tests use it to make the WORKER misbehave. */
 const ESCAPE = "globalThis.constructor.constructor('return process')()"
 
@@ -316,7 +342,7 @@ describe('dsh-workflow-workerthread', () => {
       const runEnds: WorkflowResultInfo[] = []
       ctx.on('workflow/end', (_info, result) => { runEnds.push(result) })
       const handle = ctx.workflows.start({ ...scripted("return await agent('long job')"), parent })
-      await vi.waitFor(() => { expect(provider.runs.length).toBe(1) })
+      await waitFor(() => { expect(provider.runs.length).toBe(1) })
       handle.cancel('user stopped it')
       const result = await handle.result
       expect(result.stopReason).toBe('cancelled')
@@ -357,7 +383,7 @@ describe('dsh-workflow-workerthread', () => {
 
       const controller = new AbortController()
       const second = ctx.workflows.start({ ...scripted("return await agent('job')"), parent, signal: controller.signal })
-      await vi.waitFor(() => { expect(provider.runs.length).toBe(1) })
+      await waitFor(() => { expect(provider.runs.length).toBe(1) })
       controller.abort()
       expect((await second.result).stopReason).toBe('cancelled')
       await second.dispose()
@@ -400,7 +426,7 @@ describe('dsh-workflow-workerthread', () => {
         `),
         parent,
       })
-      await vi.waitFor(() => { expect(narration).toContain('started') })
+      await waitFor(() => { expect(narration).toContain('started') })
       handle.cancel('raced the completion')
       const result = await handle.result
       expect(result.stopReason).toBe('cancelled')
@@ -480,7 +506,7 @@ describe('dsh-workflow-workerthread', () => {
       })
       const result = await handle.result
       expect(result.stopReason).toBe('completed')
-      await vi.waitFor(() => { expect(provider.runs.length).toBe(1) })
+      await waitFor(() => { expect(provider.runs.length).toBe(1) })
       await handle.dispose()
       // Not a waitFor: by the time dispose() returns, the slow child disposal
       // must already be complete (host-side registry quiescence).
@@ -525,8 +551,11 @@ describe('dsh-workflow-workerthread', () => {
       const result = await handle.result
       expect(result.stopReason).toBe('completed')
       // BEFORE dispose(): the settlement itself must have aborted the signal —
-      // without it this child would stay live until dispose's terminate.
-      await vi.waitFor(() => { expect(aborted).toEqual(['workflow settled']) })
+      // without it this child would stay live until dispose's terminate. This
+      // is a HOST-PROMPTNESS claim, not a cold-start race — a tight explicit
+      // bound (unlike the file default) so a multi-second reap regression
+      // cannot pass by outlasting the wait.
+      await waitFor(() => { expect(aborted).toEqual(['workflow settled']) }, 1000)
       await handle.dispose()
     })
 
@@ -572,9 +601,9 @@ describe('dsh-workflow-workerthread', () => {
         `),
         parent: fakeParent(),
       })
-      await vi.waitFor(() => { expect(starts).toBe(1) })
+      await waitFor(() => { expect(starts).toBe(1) })
       handle.cancel('stop now')
-      await vi.waitFor(() => { expect(cancelled).toEqual(['stop now']) }, { timeout: 800 })
+      await waitFor(() => { expect(cancelled).toEqual(['stop now']) }, 800)
       // The wedged worker's own completion loses to the in-flight cancel.
       const result = await handle.result
       expect(result.stopReason).toBe('cancelled')
@@ -602,7 +631,7 @@ describe('dsh-workflow-workerthread', () => {
         `),
         parent,
       })
-      await vi.waitFor(() => { expect(provider.runs.length).toBe(1) })
+      await waitFor(() => { expect(provider.runs.length).toBe(1) })
       const before = Date.now()
       await handle.dispose()
       // Bounded by the grace (plus the terminate), never by the 1.5s spin.
@@ -625,7 +654,7 @@ describe('dsh-workflow-workerthread', () => {
         `),
         parent,
       })
-      await vi.waitFor(() => { expect(provider.runs.length).toBe(1) })
+      await waitFor(() => { expect(provider.runs.length).toBe(1) })
       const handleDispose = handle.dispose()
       const result = await handle.result
       // The script itself settled (the wrapper's own dispose RPC found the
@@ -663,10 +692,7 @@ describe('dsh-workflow-workerthread', () => {
         `),
         parent,
       })
-      await vi.waitFor(
-        () => { expect(order.filter(entry => entry.startsWith('start:')).length).toBe(2) },
-        { timeout: 5_000 },
-      )
+      await waitFor(() => { expect(order.filter(entry => entry.startsWith('start:')).length).toBe(2) })
       const fast = provider.runs.find(run => (run.request.prompt[0] as { text?: string }).text === 'fast')!
       fast.settle(text('fast done'))
       handle.cancel('stop now')
@@ -697,7 +723,7 @@ describe('dsh-workflow-workerthread', () => {
         ...scripted("await parallel([() => agent('a'), () => agent('b')])\nreturn 'unreachable'"),
         parent,
       })
-      await vi.waitFor(() => { expect(provider.runs.length).toBe(2) })
+      await waitFor(() => { expect(provider.runs.length).toBe(2) })
       handle.cancel('user stop')
       const result = await handle.result
       expect(result.stopReason).toBe('cancelled')
@@ -752,7 +778,9 @@ describe('dsh-workflow-workerthread', () => {
       // A worker death is a stop reason like any other: workflow/end fires
       // with the error outcome — for a bus observer it is the only obituary.
       expect(runEnds).toEqual([{ stopReason: 'error', error: result.error, agentsStarted: 1 }])
-      await vi.waitFor(() => { expect(cancelled.length).toBe(1) })
+      // Result already settled — this is the reap's promptness, not a
+      // cold-start race; tight explicit bound (see the helper's doc comment).
+      await waitFor(() => { expect(cancelled.length).toBe(1) }, 1000)
       await handle.dispose()
     }, 15_000)
 
@@ -773,10 +801,12 @@ describe('dsh-workflow-workerthread', () => {
       expect(result.stopReason).toBe('error')
       expect(result.error).toContain('worker blew up')
       // The reap wound the stray child down (cancel + a CLEAN dispose).
-      await vi.waitFor(() => {
+      // Result already settled — this is the reap's promptness, not a
+      // cold-start race; tight explicit bound (see the helper's doc comment).
+      await waitFor(() => {
         expect(provider.runs.length).toBe(1)
         expect(provider.runs[0]!.disposed).toBe(true)
-      })
+      }, 1000)
       await handle.dispose()
     }, 15_000)
 
@@ -805,10 +835,7 @@ describe('dsh-workflow-workerthread', () => {
         `),
         parent,
       })
-      await vi.waitFor(
-        () => { expect(order.filter(entry => entry.startsWith('start:')).length).toBe(2) },
-        { timeout: 5_000 },
-      )
+      await waitFor(() => { expect(order.filter(entry => entry.startsWith('start:')).length).toBe(2) })
       const fast = provider.runs.find(run => (run.request.prompt[0] as { text?: string }).text === 'fast')!
       fast.settle(text('fast done'))
       const result = await handle.result
@@ -843,7 +870,10 @@ describe('dsh-workflow-workerthread', () => {
       const result = await handle.result
       expect(result.stopReason).toBe('error')
       expect(result.error).toContain('exit code 5')
-      await vi.waitFor(() => { expect(provider.runs[0]!.disposed).toBe(true) })
+      // Result already settled — this is the reap's promptness (bounded
+      // above the mock's fixed 300ms dispose delay, not a cold-start race);
+      // tight explicit bound (see the helper's doc comment).
+      await waitFor(() => { expect(provider.runs[0]!.disposed).toBe(true) }, 1000)
       await handle.dispose()
     }, 15_000)
 
@@ -861,7 +891,7 @@ describe('dsh-workflow-workerthread', () => {
       })
       const logs: string[] = []
       ctx.on('workflow/log', (_info, message) => { logs.push(message) })
-      await vi.waitFor(() => { expect(logs).toContain('armed') })
+      await waitFor(() => { expect(logs).toContain('armed') })
       handle.cancel('stop it')
       // The grace is deliberately huge: only the worker's own death (exit 3,
       // unreachable by the cancel — the script ignores hooks) settles this.