From 638320c0073f987c2a4ef8ef3a216079a04f28c5 Mon Sep 17 00:00:00 2001 From: kenny lopez Date: Mon, 17 Aug 2026 10:32:52 +0100 Subject: [PATCH] Fix mobile agent terminal state handling Signed-off-by: kenny lopez --- .../agent_activity/active_agent_turns.dart | 14 ++++- .../active_agent_turns_test.dart | 55 ++++++++++++++++++- 2 files changed, 63 insertions(+), 6 deletions(-) diff --git a/mobile/lib/features/channels/agent_activity/active_agent_turns.dart b/mobile/lib/features/channels/agent_activity/active_agent_turns.dart index 0de0cd8b4..d13a17dca 100644 --- a/mobile/lib/features/channels/agent_activity/active_agent_turns.dart +++ b/mobile/lib/features/channels/agent_activity/active_agent_turns.dart @@ -7,7 +7,9 @@ import 'observer_subscription.dart'; const _defaultLivenessTimeout = Duration(seconds: 30); const _activeTurnClockInterval = Duration(seconds: 5); -const _maximumLivenessInterval = Duration(hours: 24); +// Matches buzz-acp's MAX_TURN_DURATION_CEILING_SECS. A disabled or unusually +// sparse liveness cadence must not expire before any legal turn can finish. +const _maximumTurnDuration = Duration(days: 7); const _livenessTimeoutSlack = Duration(seconds: 30); /// Lifecycle state reconstructed from owner-scoped observer frames. @@ -114,6 +116,12 @@ List reduceAgentTurnStates( if (turnId != null) { terminalOrderById[turnId] = frameOrderAt; final existing = turnsById[turnId]; + // The harness's generic completion guard can run after its result + // handler emits the specific failure outcome. + if (frame.kind == 'turn_completed' && + existing?.phase == AgentTurnPhase.error) { + continue; + } final channelId = existing?.channelId ?? frame.channelId; if (channelId == null) continue; turnsById[turnId] = @@ -293,11 +301,11 @@ Duration _livenessTimeout(dynamic payload) { final intervalSeconds = rawInterval.toInt(); if (intervalSeconds <= 0) { - return _maximumLivenessInterval + _livenessTimeoutSlack; + return _maximumTurnDuration + _livenessTimeoutSlack; } final boundedInterval = intervalSeconds.clamp( 5, - _maximumLivenessInterval.inSeconds, + _maximumTurnDuration.inSeconds, ); final timeoutSeconds = boundedInterval + _livenessTimeoutSlack.inSeconds; return Duration( diff --git a/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart b/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart index fa5c8b60b..5cf022a65 100644 --- a/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart +++ b/mobile/test/features/channels/agent_activity/active_agent_turns_test.dart @@ -50,6 +50,26 @@ void main() { expect(turns[1].errorMessage, 'Tool permission denied'); }); + test('keeps an error terminal when generic completion arrives later', () { + final turns = reduceAgentTurnStates({ + 'agent-a': [ + _frame(seq: 1, second: 1, kind: 'turn_started'), + _frame( + seq: 2, + second: 2, + kind: 'turn_error', + payload: {'error': 'Agent timed out'}, + ), + _frame(seq: 3, second: 3, kind: 'turn_completed'), + ], + }, now: DateTime.utc(2026, 8, 16, 12, 1)); + + expect(turns, hasLength(1)); + expect(turns.single.phase, AgentTurnPhase.error); + expect(turns.single.errorMessage, 'Agent timed out'); + expect(turns.single.terminalAt, DateTime.utc(2026, 8, 16, 12, 0, 2)); + }); + test('expires silence without claiming the turn finished', () { final turns = reduceAgentTurnStates({ 'agent-a': [_frame(seq: 1, second: 1, kind: 'turn_started')], @@ -84,6 +104,35 @@ void main() { expect(afterTimeout, isEmpty); }); + test('honors advertised liveness intervals longer than one day', () { + final frames = { + 'agent-a': [ + _frame( + seq: 1, + second: 1, + kind: 'turn_started', + payload: {'livenessIntervalSecs': 48 * 60 * 60}, + ), + ], + }; + + final beforeTimeout = reduceAgentTurnStates( + frames, + now: DateTime.utc(2026, 8, 18, 12, 0, 30), + ); + final afterTimeout = reduceAgentTurnStates( + frames, + now: DateTime.utc(2026, 8, 18, 12, 0, 32), + ); + + expect(beforeTimeout, hasLength(1)); + expect( + beforeTimeout.single.livenessTimeout, + const Duration(hours: 48, seconds: 30), + ); + expect(afterTimeout, isEmpty); + }); + test('keeps liveness-disabled turns until the bounded crash backstop', () { final frames = { 'agent-a': [ @@ -98,17 +147,17 @@ void main() { final longRunning = reduceAgentTurnStates( frames, - now: DateTime.utc(2026, 8, 17, 12), + now: DateTime.utc(2026, 8, 23, 12), ); final pastBackstop = reduceAgentTurnStates( frames, - now: DateTime.utc(2026, 8, 17, 12, 0, 32), + now: DateTime.utc(2026, 8, 23, 12, 0, 32), ); expect(longRunning, hasLength(1)); expect( longRunning.single.livenessTimeout, - const Duration(hours: 24, seconds: 30), + const Duration(days: 7, seconds: 30), ); expect(pastBackstop, isEmpty); });