Recover terminal timeline from authoritative journal
This commit is contained in:
parent
0f72bc207a
commit
9aeb84d428
@ -2,12 +2,16 @@ class HistoryWindow {
|
|||||||
const HistoryWindow({
|
const HistoryWindow({
|
||||||
required this.lines,
|
required this.lines,
|
||||||
required this.hasMoreAbove,
|
required this.hasMoreAbove,
|
||||||
|
this.outputSeedText = '',
|
||||||
this.oldestSequence,
|
this.oldestSequence,
|
||||||
this.newestSequence,
|
this.newestSequence,
|
||||||
|
this.currentSequence,
|
||||||
});
|
});
|
||||||
|
|
||||||
final List<String> lines;
|
final List<String> lines;
|
||||||
final bool hasMoreAbove;
|
final bool hasMoreAbove;
|
||||||
|
final String outputSeedText;
|
||||||
final int? oldestSequence;
|
final int? oldestSequence;
|
||||||
final int? newestSequence;
|
final int? newestSequence;
|
||||||
|
final int? currentSequence;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -158,6 +158,7 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
|
|||||||
bool _awaitingReconnectRestore = false;
|
bool _awaitingReconnectRestore = false;
|
||||||
bool _shouldReconnectOnResume = false;
|
bool _shouldReconnectOnResume = false;
|
||||||
bool _showExpandedControls = false;
|
bool _showExpandedControls = false;
|
||||||
|
bool _hasProvisionalSnapshot = false;
|
||||||
bool _terminalAutoResizeEnabled = true;
|
bool _terminalAutoResizeEnabled = true;
|
||||||
_TerminalInputMode _inputMode = _TerminalInputMode.read;
|
_TerminalInputMode _inputMode = _TerminalInputMode.read;
|
||||||
TerminalConnectionState? _lastConnectionState;
|
TerminalConnectionState? _lastConnectionState;
|
||||||
@ -475,10 +476,13 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
|
|||||||
}
|
}
|
||||||
_historySeeded = _terminalHasVisibleContent;
|
_historySeeded = _terminalHasVisibleContent;
|
||||||
_scheduleSnapshotPersist();
|
_scheduleSnapshotPersist();
|
||||||
|
if (_hasProvisionalSnapshot) {
|
||||||
|
unawaited(_rebuildRecentTimelineFromJournal());
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
void _handleHistoryLoaded(HistoryWindow history) {
|
void _handleHistoryLoaded(HistoryWindow history) {
|
||||||
final seedText = _buildHistorySeedText(history.lines);
|
final seedText = history.outputSeedText;
|
||||||
if (seedText.isEmpty) {
|
if (seedText.isEmpty) {
|
||||||
_pendingHistorySeed = null;
|
_pendingHistorySeed = null;
|
||||||
return;
|
return;
|
||||||
@ -613,17 +617,6 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
|
|||||||
return end == text.length ? text : text.substring(0, end);
|
return end == text.length ? text : text.substring(0, end);
|
||||||
}
|
}
|
||||||
|
|
||||||
static String _buildHistorySeedText(List<String> lines) {
|
|
||||||
final outputLines = <String>[];
|
|
||||||
for (final line in lines) {
|
|
||||||
if (line.startsWith('[output] ')) {
|
|
||||||
outputLines.add(line.substring('[output] '.length));
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return outputLines.join('\r\n');
|
|
||||||
}
|
|
||||||
|
|
||||||
static String _normalizeTerminalKeyboardInput(String input) {
|
static String _normalizeTerminalKeyboardInput(String input) {
|
||||||
if (!input.contains('\n')) {
|
if (!input.contains('\n')) {
|
||||||
return input;
|
return input;
|
||||||
@ -1332,9 +1325,31 @@ class _TerminalPageState extends ConsumerState<TerminalPage>
|
|||||||
}
|
}
|
||||||
|
|
||||||
terminal.write(snapshot.bufferText);
|
terminal.write(snapshot.bufferText);
|
||||||
|
_hasProvisionalSnapshot = true;
|
||||||
_historySeeded = true;
|
_historySeeded = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _rebuildRecentTimelineFromJournal() async {
|
||||||
|
final history = await _coordinator.loadRecentHistoryWindow();
|
||||||
|
if (!mounted || history == null || history.outputSeedText.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
final lastReceivedSequence = _coordinator.lastReceivedSequence;
|
||||||
|
if (lastReceivedSequence != null &&
|
||||||
|
history.currentSequence != null &&
|
||||||
|
lastReceivedSequence > history.currentSequence!) {
|
||||||
|
_hasProvisionalSnapshot = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
_resetTerminalForReplay();
|
||||||
|
terminal.write(history.outputSeedText);
|
||||||
|
_hasProvisionalSnapshot = false;
|
||||||
|
_historySeeded = true;
|
||||||
|
_scheduleSnapshotPersist();
|
||||||
|
}
|
||||||
|
|
||||||
void _scheduleSnapshotPersist() {
|
void _scheduleSnapshotPersist() {
|
||||||
_snapshotPersistTimer?.cancel();
|
_snapshotPersistTimer?.cancel();
|
||||||
_snapshotPersistTimer = Timer(const Duration(milliseconds: 180), () {
|
_snapshotPersistTimer = Timer(const Duration(milliseconds: 180), () {
|
||||||
|
|||||||
@ -87,12 +87,15 @@ class TerminalSessionCoordinator extends ChangeNotifier {
|
|||||||
int? _lastSentColumns;
|
int? _lastSentColumns;
|
||||||
int? _lastSentRows;
|
int? _lastSentRows;
|
||||||
int? _lastReceivedSequence;
|
int? _lastReceivedSequence;
|
||||||
|
int? _recoveryGapBaselineSequence;
|
||||||
bool _isBackendResizeEnabled = true;
|
bool _isBackendResizeEnabled = true;
|
||||||
|
|
||||||
bool get isLoadingOlderHistory => _isLoadingOlderHistory;
|
bool get isLoadingOlderHistory => _isLoadingOlderHistory;
|
||||||
|
|
||||||
String get connectionStatus => _connectionStatus;
|
String get connectionStatus => _connectionStatus;
|
||||||
|
|
||||||
|
int? get lastReceivedSequence => _lastReceivedSequence;
|
||||||
|
|
||||||
void setBackendResizeEnabled(bool enabled) {
|
void setBackendResizeEnabled(bool enabled) {
|
||||||
if (_isBackendResizeEnabled == enabled) {
|
if (_isBackendResizeEnabled == enabled) {
|
||||||
return;
|
return;
|
||||||
@ -114,6 +117,7 @@ class TerminalSessionCoordinator extends ChangeNotifier {
|
|||||||
}
|
}
|
||||||
|
|
||||||
final sessionGeneration = ++_sessionGeneration;
|
final sessionGeneration = ++_sessionGeneration;
|
||||||
|
_recoveryGapBaselineSequence = isReconnect ? _lastReceivedSequence : null;
|
||||||
|
|
||||||
if (isReconnect) {
|
if (isReconnect) {
|
||||||
controller.markReconnecting();
|
controller.markReconnecting();
|
||||||
@ -341,11 +345,26 @@ class TerminalSessionCoordinator extends ChangeNotifier {
|
|||||||
'socket.restore.rx',
|
'socket.restore.rx',
|
||||||
'sequence=${restore.sequence} pending=${restore.pendingInput.length}',
|
'sequence=${restore.sequence} pending=${restore.pendingInput.length}',
|
||||||
);
|
);
|
||||||
|
final recoveryGapBaselineSequence = _recoveryGapBaselineSequence;
|
||||||
_lastReceivedSequence = restore.sequence;
|
_lastReceivedSequence = restore.sequence;
|
||||||
|
_recoveryGapBaselineSequence = null;
|
||||||
onRestore(restore);
|
onRestore(restore);
|
||||||
|
if (recoveryGapBaselineSequence != null &&
|
||||||
|
recoveryGapBaselineSequence < restore.sequence) {
|
||||||
|
unawaited(
|
||||||
|
_recoverHistoricalOutput(
|
||||||
|
afterSequence: recoveryGapBaselineSequence,
|
||||||
|
stopBeforeSequence: restore.sequence,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _loadHistory({bool loadOlder = false}) async {
|
Future<HistoryWindow?> loadRecentHistoryWindow() async {
|
||||||
|
return _loadHistory();
|
||||||
|
}
|
||||||
|
|
||||||
|
Future<HistoryWindow?> _loadHistory({bool loadOlder = false}) async {
|
||||||
try {
|
try {
|
||||||
final payload = await apiClient.getSessionJournal(
|
final payload = await apiClient.getSessionJournal(
|
||||||
session.sessionId,
|
session.sessionId,
|
||||||
@ -364,7 +383,10 @@ class TerminalSessionCoordinator extends ChangeNotifier {
|
|||||||
'history.loaded',
|
'history.loaded',
|
||||||
'${history.lines.length} lines, more=${history.hasMoreAbove}, newest=${history.newestSequence}',
|
'${history.lines.length} lines, more=${history.hasMoreAbove}, newest=${history.newestSequence}',
|
||||||
);
|
);
|
||||||
} catch (_) {}
|
return history;
|
||||||
|
} catch (_) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
Future<void> _recoverMissingOutput({required int afterSequence}) async {
|
Future<void> _recoverMissingOutput({required int afterSequence}) async {
|
||||||
@ -408,6 +430,51 @@ class TerminalSessionCoordinator extends ChangeNotifier {
|
|||||||
} catch (_) {}
|
} catch (_) {}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
Future<void> _recoverHistoricalOutput({
|
||||||
|
required int afterSequence,
|
||||||
|
required int stopBeforeSequence,
|
||||||
|
}) async {
|
||||||
|
try {
|
||||||
|
var cursor = afterSequence;
|
||||||
|
while (true) {
|
||||||
|
final payload = await apiClient.getSessionJournal(
|
||||||
|
session.sessionId,
|
||||||
|
limit: historyPageSize,
|
||||||
|
afterSequence: cursor,
|
||||||
|
);
|
||||||
|
final items = _readJournalItems(payload);
|
||||||
|
if (items.isEmpty) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
var reachedStop = false;
|
||||||
|
for (final item in items) {
|
||||||
|
if (item.sequence <= cursor) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.sequence >= stopBeforeSequence) {
|
||||||
|
reachedStop = true;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (item.kind == 'output') {
|
||||||
|
diagnosticLog?.add('socket.frame.recover', item.payload);
|
||||||
|
controller.registerIncomingFrame();
|
||||||
|
controller.applyFrame(item.payload);
|
||||||
|
onFrame(item.payload);
|
||||||
|
}
|
||||||
|
|
||||||
|
cursor = item.sequence;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (reachedStop || payload['hasMoreAfter'] != true) {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (_) {}
|
||||||
|
}
|
||||||
|
|
||||||
void _scheduleReconnect() {
|
void _scheduleReconnect() {
|
||||||
_cancelPendingReconnect();
|
_cancelPendingReconnect();
|
||||||
_cancelPendingResize();
|
_cancelPendingResize();
|
||||||
@ -581,9 +648,13 @@ class TerminalSessionCoordinator extends ChangeNotifier {
|
|||||||
}) {
|
}) {
|
||||||
final items = _readJournalItems(payload);
|
final items = _readJournalItems(payload);
|
||||||
final lines = _renderHistoryLines(items);
|
final lines = _renderHistoryLines(items);
|
||||||
|
final outputSeedText = _buildOutputSeed(items);
|
||||||
final mergedLines = existing == null
|
final mergedLines = existing == null
|
||||||
? lines
|
? lines
|
||||||
: <String>[...lines, ...existing.lines];
|
: <String>[...lines, ...existing.lines];
|
||||||
|
final mergedOutputSeed = existing == null
|
||||||
|
? outputSeedText
|
||||||
|
: outputSeedText + existing.outputSeedText;
|
||||||
final oldestSequence = items.isEmpty
|
final oldestSequence = items.isEmpty
|
||||||
? existing?.oldestSequence
|
? existing?.oldestSequence
|
||||||
: items.first.sequence;
|
: items.first.sequence;
|
||||||
@ -594,11 +665,23 @@ class TerminalSessionCoordinator extends ChangeNotifier {
|
|||||||
return HistoryWindow(
|
return HistoryWindow(
|
||||||
lines: mergedLines,
|
lines: mergedLines,
|
||||||
hasMoreAbove: payload['hasMoreBefore'] == true,
|
hasMoreAbove: payload['hasMoreBefore'] == true,
|
||||||
|
outputSeedText: mergedOutputSeed,
|
||||||
oldestSequence: oldestSequence,
|
oldestSequence: oldestSequence,
|
||||||
newestSequence: newestSequence,
|
newestSequence: newestSequence,
|
||||||
|
currentSequence: (payload['currentSequence'] as num?)?.toInt(),
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
String _buildOutputSeed(List<_JournalItem> items) {
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
for (final item in items) {
|
||||||
|
if (item.kind == 'output') {
|
||||||
|
buffer.write(item.payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buffer.toString();
|
||||||
|
}
|
||||||
|
|
||||||
List<_JournalItem> _readJournalItems(Map<String, dynamic> payload) {
|
List<_JournalItem> _readJournalItems(Map<String, dynamic> payload) {
|
||||||
final rawItems = (payload['items'] as List?) ?? const <dynamic>[];
|
final rawItems = (payload['items'] as List?) ?? const <dynamic>[];
|
||||||
return rawItems
|
return rawItems
|
||||||
|
|||||||
@ -203,6 +203,7 @@ void main() {
|
|||||||
'[output] one',
|
'[output] one',
|
||||||
'[output] two',
|
'[output] two',
|
||||||
]);
|
]);
|
||||||
|
expect(controller.historyWindow.outputSeedText, 'zero\r\none\r\ntwo\r\n');
|
||||||
expect(controller.historyWindow.hasMoreAbove, isFalse);
|
expect(controller.historyWindow.hasMoreAbove, isFalse);
|
||||||
},
|
},
|
||||||
);
|
);
|
||||||
@ -627,6 +628,91 @@ void main() {
|
|||||||
expect(restores, hasLength(1));
|
expect(restores, hasLength(1));
|
||||||
expect(restores.single.pendingInput, 't status');
|
expect(restores.single.pendingInput, 't status');
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test(
|
||||||
|
'restore sequence fills reconnect gap before newer live output continues',
|
||||||
|
() async {
|
||||||
|
final controller = TerminalInteractionController();
|
||||||
|
final apiClient = _FakeAgentApiClient(
|
||||||
|
journalResponses: [
|
||||||
|
<String, dynamic>{
|
||||||
|
'sessionId': 'abc',
|
||||||
|
'items': const <Map<String, dynamic>>[],
|
||||||
|
'hasMoreBefore': false,
|
||||||
|
'hasMoreAfter': false,
|
||||||
|
'currentSequence': 0,
|
||||||
|
},
|
||||||
|
<String, dynamic>{
|
||||||
|
'sessionId': 'abc',
|
||||||
|
'items': [
|
||||||
|
{
|
||||||
|
'sessionId': 'abc',
|
||||||
|
'sequence': 5,
|
||||||
|
'kind': 'output',
|
||||||
|
'payload': 'gap-5',
|
||||||
|
'timestampUtc': '2026-04-07T03:20:05Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'sessionId': 'abc',
|
||||||
|
'sequence': 6,
|
||||||
|
'kind': 'output',
|
||||||
|
'payload': 'gap-6',
|
||||||
|
'timestampUtc': '2026-04-07T03:20:06Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'hasMoreBefore': true,
|
||||||
|
'hasMoreAfter': false,
|
||||||
|
'currentSequence': 7,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final sessionFactory = _FakeTerminalSessionFactory();
|
||||||
|
final reconnectScheduler = _FakeReconnectScheduler();
|
||||||
|
final session = Session(
|
||||||
|
sessionId: 'abc',
|
||||||
|
name: 'codex-main',
|
||||||
|
status: 'idle',
|
||||||
|
);
|
||||||
|
final receivedFrames = <String>[];
|
||||||
|
final coordinator = TerminalSessionCoordinator(
|
||||||
|
controller: controller,
|
||||||
|
apiClient: apiClient,
|
||||||
|
session: session,
|
||||||
|
sessionFactory: sessionFactory.create,
|
||||||
|
onFrame: receivedFrames.add,
|
||||||
|
onRestore: (_) {},
|
||||||
|
viewportProvider: () => const TerminalViewport(columns: 80, rows: 24),
|
||||||
|
reconnectScheduler: reconnectScheduler.schedule,
|
||||||
|
);
|
||||||
|
|
||||||
|
await coordinator.start();
|
||||||
|
sessionFactory.createdSessions.single.emitRestore(
|
||||||
|
const TerminalRestorePayload(
|
||||||
|
sessionId: 'abc',
|
||||||
|
sequence: 4,
|
||||||
|
screenText: 'before-gap',
|
||||||
|
pendingInput: '',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
sessionFactory.createdSessions.single.disconnect();
|
||||||
|
await reconnectScheduler.runPending();
|
||||||
|
|
||||||
|
final secondSession = sessionFactory.createdSessions.last;
|
||||||
|
secondSession.emitRestore(
|
||||||
|
const TerminalRestorePayload(
|
||||||
|
sessionId: 'abc',
|
||||||
|
sequence: 7,
|
||||||
|
screenText: 'tail',
|
||||||
|
pendingInput: '',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
await Future<void>.delayed(Duration.zero);
|
||||||
|
|
||||||
|
expect(apiClient.requestedJournalAfterSequences, [4]);
|
||||||
|
expect(receivedFrames, ['gap-5', 'gap-6']);
|
||||||
|
},
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
class _FakeAgentApiClient extends AgentApiClient {
|
class _FakeAgentApiClient extends AgentApiClient {
|
||||||
|
|||||||
@ -1241,6 +1241,111 @@ void main() {
|
|||||||
},
|
},
|
||||||
);
|
);
|
||||||
|
|
||||||
|
testWidgets(
|
||||||
|
're-entering an existing session replaces provisional snapshot content with authoritative recent journal output',
|
||||||
|
(tester) async {
|
||||||
|
final snapshotStorage = _MemoryTerminalSnapshotStorage([
|
||||||
|
const TerminalSnapshot(
|
||||||
|
sessionId: 'session-1',
|
||||||
|
projectId: 'project-1',
|
||||||
|
sessionName: 'codex-main',
|
||||||
|
bufferText: 'tail-only',
|
||||||
|
updatedAtUtc: '2026-04-06T09:00:00Z',
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
final apiClient = _SequencedHistoryAgentApiClient(
|
||||||
|
responses: [
|
||||||
|
<String, dynamic>{
|
||||||
|
'sessionId': 'session-1',
|
||||||
|
'items': [
|
||||||
|
{
|
||||||
|
'sessionId': 'session-1',
|
||||||
|
'sequence': 5,
|
||||||
|
'kind': 'output',
|
||||||
|
'payload': 'header\r\n',
|
||||||
|
'timestampUtc': '2026-04-07T03:20:05Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'sessionId': 'session-1',
|
||||||
|
'sequence': 6,
|
||||||
|
'kind': 'output',
|
||||||
|
'payload': 'middle-output\r\n',
|
||||||
|
'timestampUtc': '2026-04-07T03:20:06Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'sessionId': 'session-1',
|
||||||
|
'sequence': 7,
|
||||||
|
'kind': 'output',
|
||||||
|
'payload': 'tail-only',
|
||||||
|
'timestampUtc': '2026-04-07T03:20:07Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'hasMoreBefore': false,
|
||||||
|
'hasMoreAfter': false,
|
||||||
|
'currentSequence': 8,
|
||||||
|
},
|
||||||
|
<String, dynamic>{
|
||||||
|
'sessionId': 'session-1',
|
||||||
|
'items': [
|
||||||
|
{
|
||||||
|
'sessionId': 'session-1',
|
||||||
|
'sequence': 5,
|
||||||
|
'kind': 'output',
|
||||||
|
'payload': 'header\r\n',
|
||||||
|
'timestampUtc': '2026-04-07T03:20:05Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'sessionId': 'session-1',
|
||||||
|
'sequence': 6,
|
||||||
|
'kind': 'output',
|
||||||
|
'payload': 'middle-output\r\n',
|
||||||
|
'timestampUtc': '2026-04-07T03:20:06Z',
|
||||||
|
},
|
||||||
|
{
|
||||||
|
'sessionId': 'session-1',
|
||||||
|
'sequence': 7,
|
||||||
|
'kind': 'output',
|
||||||
|
'payload': 'tail-only',
|
||||||
|
'timestampUtc': '2026-04-07T03:20:07Z',
|
||||||
|
},
|
||||||
|
],
|
||||||
|
'hasMoreBefore': false,
|
||||||
|
'hasMoreAfter': false,
|
||||||
|
'currentSequence': 8,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
);
|
||||||
|
final transportFactory = _QueuedTerminalSocketTransportFactory(
|
||||||
|
connectionStartupFrames: const [
|
||||||
|
[
|
||||||
|
_StartupFrame('{"type":"attached","sessionId":"session-1"}'),
|
||||||
|
_StartupFrame(
|
||||||
|
'{"type":"restore","sessionId":"session-1","sequence":8,"screenText":"tail-only","pendingInput":""}',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
);
|
||||||
|
|
||||||
|
await _pumpTerminalPage(
|
||||||
|
tester,
|
||||||
|
session: _session('session-1', 'codex-main'),
|
||||||
|
apiClient: apiClient,
|
||||||
|
socketFactory: TerminalSocketSessionFactory(
|
||||||
|
transportFactory: transportFactory.create,
|
||||||
|
),
|
||||||
|
snapshotStorage: snapshotStorage,
|
||||||
|
);
|
||||||
|
|
||||||
|
final terminal = tester
|
||||||
|
.widget<TerminalView>(find.byType(TerminalView))
|
||||||
|
.terminal;
|
||||||
|
|
||||||
|
expect(terminal.buffer.getText(), contains('header'));
|
||||||
|
expect(terminal.buffer.getText(), contains('middle-output'));
|
||||||
|
expect(apiClient.requestedBeforeSequences, [null, null]);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
|
||||||
testWidgets(
|
testWidgets(
|
||||||
'terminal reconnect keeps a richer local snapshot when restore is shorter',
|
'terminal reconnect keeps a richer local snapshot when restore is shorter',
|
||||||
(tester) async {
|
(tester) async {
|
||||||
|
|||||||
@ -0,0 +1,216 @@
|
|||||||
|
# Terminal Authoritative Timeline Recovery Implementation Plan
|
||||||
|
|
||||||
|
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||||
|
|
||||||
|
**Goal:** Rebuild recent terminal output from the backend journal after reconnect or reopen so the frontend no longer treats local snapshots or replay tails as the final recovery truth.
|
||||||
|
|
||||||
|
**Architecture:** Keep local snapshots as provisional first paint only. After websocket attach restore arrives, fetch a fresh recent journal window from the agent and use it as the authoritative recent timeline for the terminal buffer. For reconnects within an existing page, also keep sequence-gap recovery so missing output between the last seen sequence and the restore sequence is filled without losing continuity.
|
||||||
|
|
||||||
|
**Tech Stack:** Flutter, Dart, xterm, existing journal API, websocket restore/output flow
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
### Task 1: Define An Authoritative Recent History Seed
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/mobile_app/lib/features/terminal/history_window.dart`
|
||||||
|
- Modify: `apps/mobile_app/lib/features/terminal/terminal_session_coordinator.dart`
|
||||||
|
- Test: `apps/mobile_app/test/features/terminal/terminal_session_coordinator_test.dart`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing test for raw recent output seed construction**
|
||||||
|
|
||||||
|
```dart
|
||||||
|
test('history window exposes a raw output seed for authoritative rebuild', () async {
|
||||||
|
final coordinator = TerminalSessionCoordinator(...);
|
||||||
|
|
||||||
|
await coordinator.start();
|
||||||
|
|
||||||
|
expect(controller.historyWindow.outputSeedText, 'one\r\ntwo\r\n');
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `C:\tools\flutter\bin\flutter.bat test test/features/terminal/terminal_session_coordinator_test.dart`
|
||||||
|
Expected: FAIL because `HistoryWindow` has no `outputSeedText`
|
||||||
|
|
||||||
|
- [ ] **Step 3: Add the output seed to `HistoryWindow`**
|
||||||
|
|
||||||
|
```dart
|
||||||
|
class HistoryWindow {
|
||||||
|
const HistoryWindow({
|
||||||
|
required this.lines,
|
||||||
|
required this.hasMoreAbove,
|
||||||
|
required this.outputSeedText,
|
||||||
|
this.oldestSequence,
|
||||||
|
this.newestSequence,
|
||||||
|
this.currentSequence,
|
||||||
|
});
|
||||||
|
|
||||||
|
final String outputSeedText;
|
||||||
|
final int? currentSequence;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Build the output seed from raw journal output payloads**
|
||||||
|
|
||||||
|
```dart
|
||||||
|
String _buildOutputSeed(List<_JournalItem> items) {
|
||||||
|
final buffer = StringBuffer();
|
||||||
|
for (final item in items) {
|
||||||
|
if (item.kind == 'output') {
|
||||||
|
buffer.write(item.payload);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return buffer.toString();
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `C:\tools\flutter\bin\flutter.bat test test/features/terminal/terminal_session_coordinator_test.dart`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
### Task 2: Reconnect Gap Recovery Uses The Existing Sequence Anchor
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/mobile_app/lib/features/terminal/terminal_session_coordinator.dart`
|
||||||
|
- Test: `apps/mobile_app/test/features/terminal/terminal_session_coordinator_test.dart`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing reconnect gap recovery test**
|
||||||
|
|
||||||
|
```dart
|
||||||
|
test('restore sequence fills reconnect gap before live output continues', () async {
|
||||||
|
...
|
||||||
|
await coordinator.start();
|
||||||
|
sessionFactory.createdSessions.single.emitRestore(
|
||||||
|
const TerminalRestorePayload(
|
||||||
|
sessionId: 'abc',
|
||||||
|
sequence: 7,
|
||||||
|
screenText: 'tail',
|
||||||
|
pendingInput: '',
|
||||||
|
),
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(apiClient.requestedJournalAfterSequences, [4]);
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `C:\tools\flutter\bin\flutter.bat test test/features/terminal/terminal_session_coordinator_test.dart`
|
||||||
|
Expected: FAIL because reconnect restore does not fill the pre-restore gap
|
||||||
|
|
||||||
|
- [ ] **Step 3: Capture reconnect baseline before opening the next socket**
|
||||||
|
|
||||||
|
```dart
|
||||||
|
if (isReconnect && _lastReceivedSequence != null) {
|
||||||
|
_recoveryGapBaselineSequence = _lastReceivedSequence;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: Recover missing output after restore without rewinding sequence state**
|
||||||
|
|
||||||
|
```dart
|
||||||
|
if (baseline != null && baseline < restore.sequence) {
|
||||||
|
await _recoverHistoricalOutput(
|
||||||
|
afterSequence: baseline,
|
||||||
|
stopBeforeSequence: restore.sequence,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `C:\tools\flutter\bin\flutter.bat test test/features/terminal/terminal_session_coordinator_test.dart`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
### Task 3: Replace Provisional Snapshot Content With An Authoritative Recent Journal Window
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Modify: `apps/mobile_app/lib/features/terminal/terminal_page.dart`
|
||||||
|
- Modify: `apps/mobile_app/lib/features/terminal/terminal_session_coordinator.dart`
|
||||||
|
- Test: `apps/mobile_app/test/widget_test.dart`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Write the failing reopen recovery widget test**
|
||||||
|
|
||||||
|
```dart
|
||||||
|
testWidgets('re-entering a session replaces provisional snapshot content with authoritative recent journal output', (tester) async {
|
||||||
|
...
|
||||||
|
expect(terminal.buffer.getText(), contains('middle-output'));
|
||||||
|
});
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run test to verify it fails**
|
||||||
|
|
||||||
|
Run: `C:\tools\flutter\bin\flutter.bat test test/widget_test.dart`
|
||||||
|
Expected: FAIL because reopen still leaves only snapshot/replay tail content
|
||||||
|
|
||||||
|
- [ ] **Step 3: Mark local snapshot rendering as provisional**
|
||||||
|
|
||||||
|
```dart
|
||||||
|
bool _hasProvisionalSnapshot = false;
|
||||||
|
|
||||||
|
Future<void> _restoreLocalSnapshot() async {
|
||||||
|
...
|
||||||
|
terminal.write(snapshot.bufferText);
|
||||||
|
_hasProvisionalSnapshot = true;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 4: After restore, fetch a fresh recent journal window and replace provisional content**
|
||||||
|
|
||||||
|
```dart
|
||||||
|
Future<void> _rebuildRecentTimelineFromJournal() async {
|
||||||
|
final history = await _coordinator.loadRecentHistoryWindow();
|
||||||
|
if (!mounted || history == null || history.outputSeedText.isEmpty) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
_resetTerminalForReplay();
|
||||||
|
terminal.write(history.outputSeedText);
|
||||||
|
_hasProvisionalSnapshot = false;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 5: Keep the replacement safe by skipping it when newer live output already arrived**
|
||||||
|
|
||||||
|
```dart
|
||||||
|
if (history.currentSequence != null &&
|
||||||
|
_coordinator.lastReceivedSequence != null &&
|
||||||
|
_coordinator.lastReceivedSequence! > history.currentSequence!) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
- [ ] **Step 6: Run test to verify it passes**
|
||||||
|
|
||||||
|
Run: `C:\tools\flutter\bin\flutter.bat test test/widget_test.dart`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
### Task 4: Focused Verification
|
||||||
|
|
||||||
|
**Files:**
|
||||||
|
- Test: `apps/mobile_app/test/features/terminal/terminal_session_coordinator_test.dart`
|
||||||
|
- Test: `apps/mobile_app/test/widget_test.dart`
|
||||||
|
|
||||||
|
- [ ] **Step 1: Run coordinator recovery tests**
|
||||||
|
|
||||||
|
Run: `C:\tools\flutter\bin\flutter.bat test test/features/terminal/terminal_session_coordinator_test.dart`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 2: Run widget recovery tests**
|
||||||
|
|
||||||
|
Run: `C:\tools\flutter\bin\flutter.bat test test/widget_test.dart`
|
||||||
|
Expected: PASS
|
||||||
|
|
||||||
|
- [ ] **Step 3: Commit**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
git add apps/mobile_app/lib/features/terminal/history_window.dart \
|
||||||
|
apps/mobile_app/lib/features/terminal/terminal_session_coordinator.dart \
|
||||||
|
apps/mobile_app/lib/features/terminal/terminal_page.dart \
|
||||||
|
apps/mobile_app/test/features/terminal/terminal_session_coordinator_test.dart \
|
||||||
|
apps/mobile_app/test/widget_test.dart \
|
||||||
|
docs/superpowers/plans/2026-04-10-terminal-authoritative-timeline-recovery.md
|
||||||
|
git commit -m "Recover terminal timeline from authoritative journal history"
|
||||||
|
```
|
||||||
@ -0,0 +1,202 @@
|
|||||||
|
# Terminal Authoritative Timeline Recovery Design
|
||||||
|
|
||||||
|
## Goal
|
||||||
|
|
||||||
|
Make terminal reconnect and app reopen recover the recent terminal timeline as a trustworthy server-owned history, not as a best-effort replay tail.
|
||||||
|
|
||||||
|
The user experience target is closer to a chat timeline:
|
||||||
|
|
||||||
|
- input and output both matter
|
||||||
|
- ordering must match what actually happened
|
||||||
|
- reconnect or reopen must not silently drop a middle segment
|
||||||
|
- local cache may improve speed, but it must not define correctness
|
||||||
|
|
||||||
|
## Product Position
|
||||||
|
|
||||||
|
This product is a remote terminal client backed by a long-lived server-side session.
|
||||||
|
|
||||||
|
That means:
|
||||||
|
|
||||||
|
- the backend owns session continuity
|
||||||
|
- the backend must own timeline truth
|
||||||
|
- the frontend should only accelerate first paint and display the authoritative timeline
|
||||||
|
|
||||||
|
The current product only partially does this. The backend owns the process and keeps a journal, but the frontend still reconstructs recovery from local snapshots and bounded replay text. That is why users can see the latest output while missing a middle segment.
|
||||||
|
|
||||||
|
## Problem Statement
|
||||||
|
|
||||||
|
Today the reconnect and reopen path mixes three different sources:
|
||||||
|
|
||||||
|
- local mobile snapshot
|
||||||
|
- websocket restore built from a bounded replay buffer
|
||||||
|
- journal-backed history browsing
|
||||||
|
|
||||||
|
These sources answer different questions:
|
||||||
|
|
||||||
|
- local snapshot answers `what did this device last paint`
|
||||||
|
- replay buffer answers `what recent output tail can the backend quickly send`
|
||||||
|
- journal answers `what actually happened`
|
||||||
|
|
||||||
|
The product currently treats the first two as recovery truth. That is the root cause of incomplete history after reconnect or reopen.
|
||||||
|
|
||||||
|
## Required Product Behavior
|
||||||
|
|
||||||
|
For ordinary shell workflows, the product must behave like a trustworthy timeline:
|
||||||
|
|
||||||
|
- if the user typed commands while the app was gone, those input events remain in order
|
||||||
|
- if the terminal produced output while the app was gone, that output remains in order
|
||||||
|
- when the user returns, the recent timeline is rebuilt from backend truth
|
||||||
|
- local cache may paint first, but it must be corrected by backend truth
|
||||||
|
|
||||||
|
This phase does not promise full screen-state recovery for every TUI or cursor-addressed redraw case.
|
||||||
|
|
||||||
|
This phase does promise trustworthy recent terminal history.
|
||||||
|
|
||||||
|
## Architecture Decision
|
||||||
|
|
||||||
|
The backend journal becomes the authoritative recovery source for recent timeline reconstruction.
|
||||||
|
|
||||||
|
The roles become:
|
||||||
|
|
||||||
|
- `SessionIoJournalStore`: authoritative terminal timeline source
|
||||||
|
- websocket `restore`: reconnect anchor only
|
||||||
|
- local snapshot: same-device acceleration only
|
||||||
|
- frontend `xterm`: renderer of the recovered timeline
|
||||||
|
|
||||||
|
## Recovery Model
|
||||||
|
|
||||||
|
### 1. Snapshot Role
|
||||||
|
|
||||||
|
The local snapshot is allowed to do only one thing:
|
||||||
|
|
||||||
|
- show provisional content immediately while waiting for backend recovery
|
||||||
|
|
||||||
|
It must also persist the last authoritative sequence seen by the client.
|
||||||
|
|
||||||
|
It must not be treated as the final recovered history.
|
||||||
|
|
||||||
|
### 2. Restore Role
|
||||||
|
|
||||||
|
The websocket restore payload remains useful, but only as:
|
||||||
|
|
||||||
|
- a fast reconnect anchor
|
||||||
|
- the backend's current sequence baseline
|
||||||
|
- an approximation of the latest visible tail
|
||||||
|
|
||||||
|
It must not be treated as the full recovery answer.
|
||||||
|
|
||||||
|
### 3. Journal Role
|
||||||
|
|
||||||
|
The journal is the source of truth for:
|
||||||
|
|
||||||
|
- input events
|
||||||
|
- output events
|
||||||
|
- their ordering
|
||||||
|
- gap detection across reconnect and reopen
|
||||||
|
|
||||||
|
When the client returns and has an earlier sequence than the backend restore sequence, the client must request journal events after the local snapshot sequence and rebuild the missing recent timeline.
|
||||||
|
|
||||||
|
## Recovery Flow
|
||||||
|
|
||||||
|
### Reopen Flow
|
||||||
|
|
||||||
|
1. Read local snapshot
|
||||||
|
2. Paint snapshot text provisionally if available
|
||||||
|
3. Record snapshot `lastSequence` as recovery baseline
|
||||||
|
4. Connect websocket
|
||||||
|
5. Receive websocket `restore` with current backend sequence
|
||||||
|
6. If backend sequence is newer than local baseline, fetch journal events after local baseline
|
||||||
|
7. Rebuild the missing recent timeline into the terminal
|
||||||
|
8. Continue with live websocket output
|
||||||
|
|
||||||
|
### Reconnect Flow
|
||||||
|
|
||||||
|
1. Keep local buffer on screen during reconnect
|
||||||
|
2. Receive websocket `restore`
|
||||||
|
3. Compare restore sequence against the last client sequence
|
||||||
|
4. Fetch journal events for any missing gap
|
||||||
|
5. Apply missing input and output events in order
|
||||||
|
6. Continue with live websocket output
|
||||||
|
|
||||||
|
## Rendering Rules
|
||||||
|
|
||||||
|
For this phase, recent timeline rebuild should focus on trustworthy shell history, not full terminal emulation.
|
||||||
|
|
||||||
|
Rules:
|
||||||
|
|
||||||
|
- output events are written back into `xterm`
|
||||||
|
- input events are represented in history and recovery bookkeeping
|
||||||
|
- the frontend must avoid double-applying journal output and later websocket live frames
|
||||||
|
- reconnect gap recovery and reopen recovery must share the same sequence-based logic
|
||||||
|
|
||||||
|
## Sequence Rules
|
||||||
|
|
||||||
|
The frontend must persist and use a single authoritative sequence concept:
|
||||||
|
|
||||||
|
- `lastReceivedSequence` from websocket output or restore
|
||||||
|
- `snapshot.lastSequence` when writing local cache
|
||||||
|
- `restore.sequence` from backend attach
|
||||||
|
|
||||||
|
Gap rule:
|
||||||
|
|
||||||
|
- if `restore.sequence <= snapshot.lastSequence`, no journal catch-up is needed
|
||||||
|
- if `restore.sequence > snapshot.lastSequence`, fetch journal events after `snapshot.lastSequence`
|
||||||
|
|
||||||
|
## Scope Boundaries
|
||||||
|
|
||||||
|
### In Scope
|
||||||
|
|
||||||
|
- recent timeline correctness for ordinary shell use
|
||||||
|
- reconnect gap recovery using journal sequence
|
||||||
|
- reopen recovery using snapshot sequence plus journal catch-up
|
||||||
|
- trustworthy ordering of recent input and output
|
||||||
|
|
||||||
|
### Out Of Scope
|
||||||
|
|
||||||
|
- full server-side terminal screen ownership
|
||||||
|
- exact restoration of arbitrary cursor-addressed screen state
|
||||||
|
- perfect recovery for fullscreen TUIs
|
||||||
|
- replay of the entire session into the frontend buffer with no limit
|
||||||
|
|
||||||
|
## Recommended Implementation Shape
|
||||||
|
|
||||||
|
### Frontend
|
||||||
|
|
||||||
|
Add snapshot metadata:
|
||||||
|
|
||||||
|
- `lastSequence`
|
||||||
|
|
||||||
|
Update coordinator behavior:
|
||||||
|
|
||||||
|
- allow the page to set a recovery baseline sequence before connect
|
||||||
|
- after restore, recover journal events after that baseline
|
||||||
|
- reuse the existing sequence-gap recovery path for reconnect and reopen
|
||||||
|
|
||||||
|
Update page behavior:
|
||||||
|
|
||||||
|
- local snapshot paints first
|
||||||
|
- snapshot sequence is passed to coordinator as provisional baseline
|
||||||
|
- restore no longer serves as the final complete answer
|
||||||
|
|
||||||
|
### Backend
|
||||||
|
|
||||||
|
No immediate protocol redesign is required for phase 1.
|
||||||
|
|
||||||
|
The existing journal API and restore sequence are enough to implement authoritative recent timeline recovery.
|
||||||
|
|
||||||
|
## Why This Is The Right Phase 1
|
||||||
|
|
||||||
|
This is the shortest path that fixes the real trust issue.
|
||||||
|
|
||||||
|
It does not pretend the replay buffer is complete.
|
||||||
|
It does not force an immediate full server-side screen engine rollout.
|
||||||
|
It moves correctness to the only durable source already present in the product: the journal.
|
||||||
|
|
||||||
|
That is the right business move because users first need confidence that nothing in the recent timeline disappeared.
|
||||||
|
|
||||||
|
## Success Criteria
|
||||||
|
|
||||||
|
- reconnect no longer loses a middle segment when newer output arrived while disconnected
|
||||||
|
- reopen no longer restores only the replay tail when the backend has a newer recent timeline
|
||||||
|
- local snapshot remains useful for first paint but does not override backend truth
|
||||||
|
- focused tests prove sequence-based catch-up for reconnect and reopen
|
||||||
Loading…
Reference in New Issue
Block a user