diff --git a/packages/goal/goal-session/src/index.ts b/packages/goal/goal-session/src/index.ts index 604b117aa3..002fd90a24 100644 --- a/packages/goal/goal-session/src/index.ts +++ b/packages/goal/goal-session/src/index.ts @@ -176,10 +176,9 @@ export function apply(ctx: Context): void { const attempt = state.attempt if (attempt !== undefined) { - // Prompt admission joined the loop's running interval, so a drive pass - // (which requires idle) can no longer observe an unsettled attempt; - // kept as a backstop against a future trigger inside the interval. - /* v8 ignore next -- unreachable yield, see above */ + // Still unsettled: a contained turn-close failure reaches idle with the + // attempt's turn open in the log and no terminal reason recorded, so + // the drive pass must yield rather than misread it as settled. if (attempt.reason === undefined) return state.attempt = undefined const turn = attempt.turn diff --git a/packages/goal/goal-session/tests/goal-session.spec.ts b/packages/goal/goal-session/tests/goal-session.spec.ts index 2b48be61d4..a7b3f59373 100644 --- a/packages/goal/goal-session/tests/goal-session.spec.ts +++ b/packages/goal/goal-session/tests/goal-session.spec.ts @@ -712,6 +712,31 @@ describe('same-session goal driving', () => { expect(test.adapter.requests).toHaveLength(1) }) + it('yields to a round whose turn/end never committed instead of misreading it as settled', async () => { + const test = await harness([textResponse('round ran')]) + // A persistent pre-commit turn/end rejection: the loop contains the close + // failure and reaches idle, but the round's attempt holds a turn with no + // terminal reason. The idle drive pass must yield to that unsettled + // attempt rather than classify an absent reason or crash into disarm. + test.ctx.on('internal/dispatch', (_mode, name, args) => { + if (name !== 'session/event') return + const event = args[1] as { type: string } + if (event.type === 'turn/end') throw new Error('turn close permanently rejected') + }) + test.ctx.goals.create(test.agent, { objective: 'survive a lost turn end' }) + await waitForRequests(test.adapter, 1) + await test.agent.whenIdle() + await new Promise((resolve) => { setImmediate(resolve) }) + + // One request ran; the unsettled attempt parked the driver without a + // second reservation and without disarming the goal. + expect(test.adapter.requests).toHaveLength(1) + expect(test.ctx.goals.get(test.agent)).toMatchObject({ + phase: 'active', + activation: 'armed', + }) + }) + it('disarms instead of continuing when a plugin reports a post-turn persistence failure', async () => { const test = await harness([textResponse('round one')]) test.ctx.on('session/event', (session, event) => {