Skip to content

Commit ff484b5

Browse files
committed
feat(agent-core-v2): self-heal corrupted wire journals during restore
1 parent 7bf13ce commit ff484b5

16 files changed

Lines changed: 695 additions & 36 deletions

File tree

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,5 @@
1+
---
2+
"@pymodel/pythinker-code": patch
3+
---
4+
5+
Fix sessions that fail to resume when their session journal is truncated or corrupted, for example after the disk fills up.

packages/agent-core-v2/src/app/telemetry/events.ts

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -479,6 +479,13 @@ export interface SessionLoadFailedEvent {
479479
reason: string;
480480
}
481481

482+
export interface WireRepairEvent {
483+
kind: 'corrupted' | 'truncated';
484+
outcome: 'repaired' | 'failed';
485+
dropped_count: number;
486+
backup_created: boolean;
487+
}
488+
482489
export interface FirstLaunchEvent {}
483490

484491
export interface ExitEvent {
@@ -1058,6 +1065,16 @@ export const telemetryEventDefinitions = {
10581065
comment: 'A session resume fails.',
10591066
properties: { reason: 'Error code, error name, or unknown' },
10601067
}),
1068+
wire_repair: defineTelemetryEvent<WireRepairEvent>({
1069+
owner: 'pythinker-code',
1070+
comment: 'A corrupted wire journal is truncated to its valid prefix and healed on disk.',
1071+
properties: {
1072+
kind: 'Corruption kind: unparseable middle line or torn final line',
1073+
outcome: 'Whether the on-disk repair succeeded',
1074+
dropped_count: 'Journal lines dropped from the corrupted tail',
1075+
backup_created: 'Whether a first-time .bak backup of the corrupted file was created',
1076+
},
1077+
}),
10611078
first_launch: defineTelemetryEvent<FirstLaunchEvent>({
10621079
owner: 'pythinker-code',
10631080
comment: 'The CLI runs for the first time on this device.',

packages/agent-core-v2/src/persistence/backends/node-fs/appendLogStore.ts

Lines changed: 17 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ import {
77
AppendLogCorruptedError,
88
IAppendLogStore,
99
type AppendLogOptions,
10+
type AppendLogReadOptions,
1011
} from '#/persistence/interface/appendLogStore';
1112

1213
const textEncoder = new TextEncoder();
@@ -48,8 +49,9 @@ export class AppendLogStore implements IAppendLogStore {
4849
this.scheduleFlush(scope, key, state);
4950
}
5051

51-
async *read<R>(scope: string, key: string): AsyncIterable<R> {
52+
async *read<R>(scope: string, key: string, options?: AppendLogReadOptions): AsyncIterable<R> {
5253
await this.flushLog(scope, key);
54+
const onTruncate = options?.onTruncate;
5355
const textDecoder = new TextDecoder();
5456
let pending = '';
5557
let lineNumber = 0;
@@ -60,7 +62,14 @@ export class AppendLogStore implements IAppendLogStore {
6062
const raw = pending.slice(0, newlineIndex);
6163
pending = pending.slice(newlineIndex + 1);
6264
lineNumber++;
63-
const record = this.parseLine<R>(raw, scope, key, lineNumber, false);
65+
let record: R | undefined;
66+
try {
67+
record = this.parseLine<R>(raw, scope, key, lineNumber, false);
68+
} catch (error) {
69+
if (onTruncate === undefined) throw error;
70+
onTruncate({ lineNumber, reason: 'corrupted', cause: error });
71+
return;
72+
}
6473
if (record !== undefined) yield record;
6574
newlineIndex = pending.indexOf('\n');
6675
}
@@ -69,7 +78,12 @@ export class AppendLogStore implements IAppendLogStore {
6978
if (pending.length > 0) {
7079
lineNumber++;
7180
const record = this.parseLine<R>(pending, scope, key, lineNumber, true);
72-
if (record !== undefined) yield record;
81+
if (record !== undefined) {
82+
yield record;
83+
} else if (onTruncate !== undefined) {
84+
const line = pending.endsWith('\r') ? pending.slice(0, -1) : pending;
85+
if (line.length > 0) onTruncate({ lineNumber, reason: 'truncated' });
86+
}
7387
}
7488
}
7589

packages/agent-core-v2/src/persistence/interface/appendLogStore.ts

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,11 +21,21 @@ export interface AppendLogOptions {
2121
readonly onError?: (error: unknown) => void;
2222
}
2323

24+
export interface AppendLogTruncation {
25+
readonly lineNumber: number;
26+
readonly reason: 'corrupted' | 'truncated';
27+
readonly cause?: unknown;
28+
}
29+
30+
export interface AppendLogReadOptions {
31+
readonly onTruncate?: (truncation: AppendLogTruncation) => void;
32+
}
33+
2434
export interface IAppendLogStore {
2535
readonly _serviceBrand: undefined;
2636

2737
append<R>(scope: string, key: string, record: R, options?: AppendLogOptions): void;
28-
read<R>(scope: string, key: string): AsyncIterable<R>;
38+
read<R>(scope: string, key: string, options?: AppendLogReadOptions): AsyncIterable<R>;
2939
rewrite<R>(scope: string, key: string, records: readonly R[]): Promise<void>;
3040
flush(): Promise<void>;
3141
close(): Promise<void>;
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
import type { ILogService } from '#/_base/log/log';
2+
import type { ITelemetryService } from '#/app/telemetry/telemetry';
3+
import type {
4+
AppendLogTruncation,
5+
IAppendLogStore,
6+
} from '#/persistence/interface/appendLogStore';
7+
import type { IFileSystemStorageService } from '#/persistence/interface/storage';
8+
9+
export interface WireJournalRepairServices {
10+
readonly appendLog: IAppendLogStore;
11+
readonly storage: IFileSystemStorageService;
12+
readonly log: ILogService;
13+
readonly telemetry: ITelemetryService;
14+
}
15+
16+
export function wireJournalBackupKey(key: string): string {
17+
return `${key}.bak`;
18+
}
19+
20+
export async function repairWireJournal(
21+
services: WireJournalRepairServices,
22+
scope: string,
23+
key: string,
24+
records: readonly unknown[],
25+
truncation: AppendLogTruncation,
26+
): Promise<void> {
27+
const { appendLog, storage, log, telemetry } = services;
28+
let backupCreated = false;
29+
let outcome: 'repaired' | 'failed' = 'repaired';
30+
let droppedCount = 0;
31+
let repairError: unknown;
32+
try {
33+
const original = await storage.read(scope, key);
34+
if (original !== undefined) {
35+
droppedCount = Math.max(0, countJournalLines(original) - records.length);
36+
const backupKey = wireJournalBackupKey(key);
37+
if ((await storage.size(scope, backupKey)) === undefined) {
38+
await storage.write(scope, backupKey, original, { atomic: true });
39+
backupCreated = true;
40+
}
41+
}
42+
await appendLog.rewrite(scope, key, records);
43+
} catch (error) {
44+
outcome = 'failed';
45+
repairError = error;
46+
}
47+
log.warn('corrupted wire journal truncated to its valid prefix', {
48+
scope,
49+
key,
50+
lineNumber: truncation.lineNumber,
51+
reason: truncation.reason,
52+
outcome,
53+
droppedCount,
54+
backupCreated,
55+
error: repairError instanceof Error ? repairError.message : undefined,
56+
});
57+
telemetry.track2('wire_repair', {
58+
kind: truncation.reason,
59+
outcome,
60+
dropped_count: droppedCount,
61+
backup_created: backupCreated,
62+
});
63+
}
64+
65+
function countJournalLines(data: Uint8Array): number {
66+
let lines = 0;
67+
let hasContent = false;
68+
for (const byte of data) {
69+
if (byte === 0x0a) {
70+
lines++;
71+
hasContent = false;
72+
} else {
73+
hasContent = true;
74+
}
75+
}
76+
return hasContent ? lines + 1 : lines;
77+
}

packages/agent-core-v2/src/wire/wireService.ts

Lines changed: 51 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
11
import { onUnexpectedError } from '#/_base/errors/unexpectedError';
22
import { Service } from '#/_base/di/service';
3+
import { ILogService } from '#/_base/log/log';
34
import { LifecycleScope } from '#/app/scopes';
45
import { ScopeActivation, registerScopedService } from '#/_base/di/scope';
56
import { IAgentBlobService } from '#/agent/blob/agentBlobService';
67
import { IAgentScopeContext } from '#/agent/scopeContext/scopeContext';
8+
import { ITelemetryService } from '#/app/telemetry/telemetry';
79
import type { ContentPart } from '#/kosong/contract/message';
8-
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
9-
import { StorageError, StorageErrors } from '#/persistence/interface/storage';
10+
import {
11+
type AppendLogTruncation,
12+
IAppendLogStore,
13+
} from '#/persistence/interface/appendLogStore';
14+
import { IFileSystemStorageService, StorageError, StorageErrors } from '#/persistence/interface/storage';
1015

1116
import { IWireService } from './wire';
1217
import { WireError, WireErrors } from './errors';
18+
import { repairWireJournal } from './repair';
1319
import {
1420
WIRE_PROTOCOL_VERSION,
1521
isNewerWireVersion,
@@ -38,14 +44,18 @@ export class WireService extends Service implements IWireService {
3844
@IAgentScopeContext scopeContext: IAgentScopeContext,
3945
@IAppendLogStore private readonly log: IAppendLogStore,
4046
@IAgentBlobService private readonly blobService: IAgentBlobService,
47+
@IFileSystemStorageService private readonly storage: IFileSystemStorageService,
48+
@ILogService private readonly logger: ILogService,
49+
@ITelemetryService private readonly telemetry: ITelemetryService,
4150
) {
4251
super();
4352
this.wireScope = scopeContext.scope();
4453
this._register(this.log.acquire(this.wireScope, AGENT_WIRE_RECORD_KEY));
4554
}
4655

4756
async seal(): Promise<void> {
48-
for await (const record of this.log.read(this.wireScope, AGENT_WIRE_RECORD_KEY)) {
57+
const tolerate = { onTruncate: () => {} };
58+
for await (const record of this.log.read(this.wireScope, AGENT_WIRE_RECORD_KEY, tolerate)) {
4959
void record;
5060
return;
5161
}
@@ -78,7 +88,12 @@ export class WireService extends Service implements IWireService {
7888
}
7989

8090
async *readJournal(): AsyncIterable<WireRecord> {
81-
const source = this.log.read<WireRecord>(this.wireScope, AGENT_WIRE_RECORD_KEY);
91+
let truncation: AppendLogTruncation | undefined;
92+
const source = this.log.read<WireRecord>(this.wireScope, AGENT_WIRE_RECORD_KEY, {
93+
onTruncate: (info) => {
94+
truncation = info;
95+
},
96+
});
8297
let migrations: readonly WireMigration[] = [];
8398
let rewrittenRecords: WireRecord[] | undefined;
8499
let newerWireVersion = false;
@@ -128,11 +143,42 @@ export class WireService extends Service implements IWireService {
128143
if (!hasRecords) {
129144
rewrittenRecords = [createWireMetadataRecord()];
130145
}
131-
if (rewrittenRecords !== undefined) {
146+
if (truncation !== undefined) {
147+
await this.repairJournal(truncation, rewrittenRecords);
148+
} else if (rewrittenRecords !== undefined) {
132149
await this.log.rewrite(this.wireScope, AGENT_WIRE_RECORD_KEY, rewrittenRecords);
133150
}
134151
}
135152

153+
private async repairJournal(
154+
truncation: AppendLogTruncation,
155+
rewrittenRecords: WireRecord[] | undefined,
156+
): Promise<void> {
157+
let records: WireRecord[] = rewrittenRecords ?? [];
158+
if (rewrittenRecords === undefined) {
159+
const tolerate = { onTruncate: () => {} };
160+
for await (const record of this.log.read<WireRecord>(
161+
this.wireScope,
162+
AGENT_WIRE_RECORD_KEY,
163+
tolerate,
164+
)) {
165+
records.push(record);
166+
}
167+
}
168+
await repairWireJournal(
169+
{
170+
appendLog: this.log,
171+
storage: this.storage,
172+
log: this.logger,
173+
telemetry: this.telemetry,
174+
},
175+
this.wireScope,
176+
AGENT_WIRE_RECORD_KEY,
177+
records,
178+
truncation,
179+
);
180+
}
181+
136182
async flush(): Promise<void> {
137183
await this.persistQueue;
138184
await this.log.flush();

packages/agent-core-v2/src/workspace/sessionLifecycle/sessionLifecycleService.ts

Lines changed: 32 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import {
1010
} from '#/_base/di/scope';
1111
import { unwrapErrorCause } from '#/_base/errors/errors';
1212
import { AsyncEmitter, Emitter, type Event, type IWaitUntil } from '#/_base/event';
13+
import { ILogService } from '#/_base/log/log';
1314
import { drainLogCloses } from '#/_base/log/logService';
1415
import { DEFAULT_PLAN_MODE_SECTION } from '#/features/plan/configSection';
1516
import { IAgentPlanService } from '#/features/plan/plan';
@@ -27,8 +28,12 @@ import {
2728
import { ITelemetryService } from '#/app/telemetry/telemetry';
2829
import { ErrorCodes, Error2, isError2 } from '#/errors';
2930
import { IHostFileSystem, type HostDirEntry } from '#/os/interface/hostFileSystem';
30-
import { IAppendLogStore } from '#/persistence/interface/appendLogStore';
31+
import {
32+
type AppendLogTruncation,
33+
IAppendLogStore,
34+
} from '#/persistence/interface/appendLogStore';
3135
import { IAtomicDocumentStore } from '#/persistence/interface/atomicDocumentStore';
36+
import { IFileSystemStorageService } from '#/persistence/interface/storage';
3237
import {
3338
IAgentLifecycleService,
3439
MAIN_AGENT_ID,
@@ -51,6 +56,7 @@ import {
5156
createWireMetadataRecord,
5257
type WireRecord,
5358
} from '#/wire/record';
59+
import { repairWireJournal } from '#/wire/repair';
5460
import { IModelCatalog } from '#/kosong/model/catalog';
5561
import { IModelService } from '#/kosong/model/model';
5662
import { IProviderService } from '#/kosong/provider/provider';
@@ -145,6 +151,8 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
145151
@ISessionIndexMirror private readonly indexMirror: ISessionIndexMirror,
146152
@IAppendLogStore private readonly appendLogStore: IAppendLogStore,
147153
@IAtomicDocumentStore private readonly docs: IAtomicDocumentStore,
154+
@IFileSystemStorageService private readonly storage: IFileSystemStorageService,
155+
@ILogService private readonly log: ILogService,
148156
@IHostFileSystem private readonly hostFs: IHostFileSystem,
149157
@IEventService private readonly event: IEventService,
150158
@ITelemetryService private readonly telemetry: ITelemetryService,
@@ -663,12 +671,30 @@ export class SessionLifecycleService extends Disposable implements ISessionLifec
663671
await agentHandle.accessor.get(IEventDispatcher).flush();
664672
}
665673
}
666-
return collect(
667-
this.appendLogStore.read<WireRecord>(
668-
agentScopeOf(sessionScopeOf(this.handlerScope, sourceSessionId), agentId),
669-
AGENT_WIRE_RECORD_KEY,
670-
),
674+
const scope = agentScopeOf(sessionScopeOf(this.handlerScope, sourceSessionId), agentId);
675+
let truncation: AppendLogTruncation | undefined;
676+
const records = await collect(
677+
this.appendLogStore.read<WireRecord>(scope, AGENT_WIRE_RECORD_KEY, {
678+
onTruncate: (info) => {
679+
truncation = info;
680+
},
681+
}),
671682
);
683+
if (truncation !== undefined) {
684+
await repairWireJournal(
685+
{
686+
appendLog: this.appendLogStore,
687+
storage: this.storage,
688+
log: this.log,
689+
telemetry: this.telemetry,
690+
},
691+
scope,
692+
AGENT_WIRE_RECORD_KEY,
693+
records,
694+
truncation,
695+
);
696+
}
697+
return records;
672698
}
673699

674700
private async pruneTruncatedForkFiles(

0 commit comments

Comments
 (0)