Fix mobile agent terminal state handling

Signed-off-by: kenny lopez <klopez4212@gmail.com>
This commit is contained in:
kenny lopez
2026-08-17 10:32:52 +01:00
parent 1ca9793e7d
commit 638320c007
2 changed files with 63 additions and 6 deletions
@@ -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<AgentTurnState> 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(
@@ -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);
});