Skip to content

Commit af5206e

Browse files
committed
fix: bound print-mode shutdown and truncated-fork cutoffs
Abort print-mode agent quiescence when cleanup times out, keep dispatcher flush and app dispose running if telemetry shutdown fails, and compute truncated-fork cutoff from the retained turn window before dropping file-history records.
1 parent 4d7a7b5 commit af5206e

8 files changed

Lines changed: 96 additions & 38 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+
Keep print-mode shutdown from skipping journal flush, and keep truncated forks from dropping later work in the retained turn.

‎apps/pythinker-code/src/cli/v2/run-v2-print.ts‎

Lines changed: 24 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -201,7 +201,7 @@ export async function runV2Print(
201201
}
202202

203203
let restorePermission = async (): Promise<void> => {};
204-
let quiesceAgents = async (): Promise<void> => {};
204+
let quiesceAgents = async (_signal?: AbortSignal): Promise<void> => {};
205205
let releaseQuiescence: (() => void) | undefined;
206206
let flushWires = async (): Promise<void> => {};
207207
let removeTerminationCleanup: (() => void) | undefined;
@@ -211,15 +211,21 @@ export async function runV2Print(
211211
const pending = (cleanupPromise ??= (async () => {
212212
removeTerminationCleanup?.();
213213
setCrashPhase('shutdown');
214+
const quiesceAbort = new AbortController();
214215
try {
215216
await restorePermission();
216-
await raceWithTimeout(quiesceAgents(), CLI_SHUTDOWN_TIMEOUT_MS).catch(() => {});
217+
await raceWithTimeout(quiesceAgents(quiesceAbort.signal), CLI_SHUTDOWN_TIMEOUT_MS).catch(
218+
() => {},
219+
);
217220
} finally {
221+
quiesceAbort.abort();
218222
try {
219223
await Promise.all([
220224
raceWithTimeout(flushWires(), CLI_SHUTDOWN_TIMEOUT_MS).catch(() => {}),
221225
telemetryService !== undefined
222-
? raceWithTimeout(telemetryService.shutdown(), CLI_SHUTDOWN_TIMEOUT_MS)
226+
? raceWithTimeout(telemetryService.shutdown(), CLI_SHUTDOWN_TIMEOUT_MS).catch(
227+
() => {},
228+
)
223229
: Promise.resolve(),
224230
shutdownTelemetry({ timeoutMs: CLI_SHUTDOWN_TIMEOUT_MS }).catch(() => {}),
225231
]);
@@ -274,8 +280,8 @@ export async function runV2Print(
274280

275281
const resolved = await resolveNativeSession(app, opts, workDir, defaultModel, stderr);
276282
restorePermission = resolved.restorePermission;
277-
quiesceAgents = async () => {
278-
releaseQuiescence = await quiesceSessionAgents(resolved.session, resolved.agent);
283+
quiesceAgents = async (signal) => {
284+
releaseQuiescence = await quiesceSessionAgents(resolved.session, resolved.agent, signal);
279285
};
280286
flushWires = () => flushSessionWires(resolved.session, resolved.agent);
281287

@@ -967,6 +973,7 @@ function collectSessionAgentHandles(
967973
async function quiesceSessionAgents(
968974
session: ISessionScopeHandle,
969975
mainAgent: IAgentScopeHandle,
976+
signal?: AbortSignal,
970977
): Promise<(() => void) | undefined> {
971978
const handles = collectSessionAgentHandles(session, mainAgent);
972979
const promptServices = handles.flatMap((handle) => {
@@ -993,6 +1000,7 @@ async function quiesceSessionAgents(
9931000
}),
9941001
);
9951002
for (;;) {
1003+
if (signal?.aborted) return undefined;
9961004
await Promise.allSettled(promptServices.map((service) => service.drain()));
9971005
for (const loop of loops) {
9981006
for (const turnId of loop.status().pendingTurnIds) loop.cancel(turnId);
@@ -1022,6 +1030,10 @@ async function quiesceSessionAgents(
10221030
return false;
10231031
}
10241032
});
1033+
if (signal?.aborted) {
1034+
for (const guard of guards) guard.dispose();
1035+
return undefined;
1036+
}
10251037
if (frozen && !busy) {
10261038
return () => {
10271039
for (const guard of guards) guard.dispose();
@@ -1039,9 +1051,13 @@ async function flushSessionWires(
10391051
mainAgent: IAgentScopeHandle,
10401052
): Promise<void> {
10411053
await Promise.allSettled(
1042-
collectSessionAgentHandles(session, mainAgent).map((handle) =>
1043-
handle.accessor.get(IEventDispatcher).flush(),
1044-
),
1054+
collectSessionAgentHandles(session, mainAgent).flatMap((handle) => {
1055+
try {
1056+
return [handle.accessor.get(IEventDispatcher).flush()];
1057+
} catch {
1058+
return [];
1059+
}
1060+
}),
10451061
);
10461062
}
10471063

‎packages/agent-core-v2/src/app/sessionExport/sessionExportService.ts‎

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { join, resolve } from 'pathe';
1+
import { join, relative, resolve } from 'pathe';
22
import { LifecycleScope } from '#/app/scopes';
33
import { ScopeActivation, registerScopedService } from '#/_base/di/scope';
44
import type { ISessionScopeHandle } from '#/_base/di/scope';
@@ -205,8 +205,8 @@ export async function exportSessionDirectory(input: {
205205
const stableSessionLog = sessionLogSource;
206206
const selectedSessionFiles: SessionZipEntry[] = sessionFiles.filter((file) => {
207207
if (file === sessionLogPath) return false;
208-
const parts = file.split(/[\\/]/);
209-
return !parts.includes(FILE_HISTORY_BLOB_PREFIX) && !parts.includes('notify');
208+
const parts = relative(sessionDir, file).split(/[\\/]/);
209+
return parts[0] !== FILE_HISTORY_BLOB_PREFIX && parts[0] !== 'notify';
210210
});
211211
if (stableSessionLog !== undefined) {
212212
selectedSessionFiles.push({ path: sessionLogPath, source: stableSessionLog });

‎packages/agent-core-v2/src/features/fileHistory/fileHistoryRetention.ts‎

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -108,8 +108,9 @@ export async function touchForkedFileHistory(input: FileHistoryRetentionInput):
108108
try {
109109
const entries = await input.hostFs.readdir(agentsDir);
110110
agentNames = entries.filter((entry) => entry.isDirectory).map((entry) => entry.name);
111-
} catch {
112-
return;
111+
} catch (error) {
112+
if (isMissingPathError(error)) return;
113+
throw error;
113114
}
114115
for (const name of agentNames) {
115116
try {
@@ -118,8 +119,9 @@ export async function touchForkedFileHistory(input: FileHistoryRetentionInput):
118119
await touchFileHistorySession(input);
119120
return;
120121
}
121-
} catch {
122-
continue;
122+
} catch (error) {
123+
if (isMissingPathError(error)) continue;
124+
throw error;
123125
}
124126
}
125127
}

‎packages/agent-core-v2/src/session/agentLifecycle/agentLifecycleService.ts‎

Lines changed: 23 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -480,12 +480,29 @@ export class AgentLifecycleService extends Disposable implements IAgentLifecycle
480480
}
481481
const deadline = Date.now() + REMOVE_PROMPT_QUIESCE_TIMEOUT_MS;
482482
for (;;) {
483-
await prompt.drain(reason);
484-
for (const turnId of loop.status().pendingTurnIds) {
485-
loop.cancel(turnId, reason);
486-
}
487-
loop.cancel(undefined, reason);
488-
await Promise.all([loop.settled(), compactionSettled]);
483+
const remaining = deadline - Date.now();
484+
if (remaining <= 0) break;
485+
let drainTimedOut = false;
486+
let drainTimer: ReturnType<typeof setTimeout> | undefined;
487+
await Promise.race([
488+
(async () => {
489+
await prompt.drain(reason);
490+
for (const turnId of loop.status().pendingTurnIds) {
491+
loop.cancel(turnId, reason);
492+
}
493+
loop.cancel(undefined, reason);
494+
await Promise.all([loop.settled(), compactionSettled]);
495+
})().finally(() => {
496+
if (drainTimer !== undefined) clearTimeout(drainTimer);
497+
}),
498+
new Promise<void>((resolve) => {
499+
drainTimer = setTimeout(() => {
500+
drainTimedOut = true;
501+
resolve();
502+
}, remaining);
503+
}),
504+
]);
505+
if (drainTimedOut) break;
489506
let idle = true;
490507
try {
491508
const snapshot = prompt.list();

‎packages/agent-core-v2/src/workspace/sessionLifecycle/internal/forkTurnSlice.ts‎

Lines changed: 19 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -43,20 +43,19 @@ export function sliceMainRecordsAtTurn(
4343

4444
const end = turnStarts[turnIndex + 1] ?? records.length;
4545
const retainedTurnInputs = turnInputIndicesThrough(records, turnIndex);
46-
const retained = records
46+
const window = records
4747
.slice(0, end)
4848
.filter(
4949
(record, index) =>
50-
!record.type.startsWith(FILE_HISTORY_RECORD_PREFIX) &&
51-
(!isUserVisibleTurnInputRecord(record) || retainedTurnInputs.has(index)),
50+
!isUserVisibleTurnInputRecord(record) || retainedTurnInputs.has(index),
5251
);
53-
const cutoffTimes = retained
54-
.map(recordTime)
55-
.filter((time): time is number => time !== undefined);
52+
const retained = window.filter(
53+
(record) => !record.type.startsWith(FILE_HISTORY_RECORD_PREFIX),
54+
);
5655
const lastPrompt = promptMetadataFromTurnRecord(records[start]!);
5756
return {
5857
records: retained,
59-
cutoffTime: cutoffTimes.length === 0 ? undefined : Math.max(...cutoffTimes),
58+
cutoffTime: cutoffFrom(window),
6059
lastPrompt,
6160
};
6261
}
@@ -85,19 +84,15 @@ export function sliceMainRecordsBeforePrompt(
8584
return sliceMainRecordsAtTurn(records, sourceSessionId, activeTurnIndex - 1);
8685
}
8786
const activeStart = turnStarts[0]!;
88-
const retained = records
87+
const window = records
8988
.slice(0, activeStart)
90-
.filter(
91-
(record) =>
92-
!record.type.startsWith(FILE_HISTORY_RECORD_PREFIX) &&
93-
!isUserVisibleTurnInputRecord(record),
94-
);
95-
const cutoffTimes = retained
96-
.map(recordTime)
97-
.filter((time): time is number => time !== undefined);
89+
.filter((record) => !isUserVisibleTurnInputRecord(record));
90+
const retained = window.filter(
91+
(record) => !record.type.startsWith(FILE_HISTORY_RECORD_PREFIX),
92+
);
9893
return {
9994
records: retained,
100-
cutoffTime: cutoffTimes.length === 0 ? undefined : Math.max(...cutoffTimes),
95+
cutoffTime: cutoffFrom(window),
10196
};
10297
}
10398

@@ -117,6 +112,13 @@ export function sliceSubagentRecordsAtTime(
117112
return records.slice(0, end);
118113
}
119114

115+
function cutoffFrom(records: readonly WireRecord[]): number | undefined {
116+
const times = records
117+
.map(recordTime)
118+
.filter((time): time is number => time !== undefined);
119+
return times.length === 0 ? undefined : Math.max(...times);
120+
}
121+
120122
function isUserVisibleTurnRecord(record: WireRecord): boolean {
121123
if (record.type !== 'context.append_message') return false;
122124
const message = asRecord(record['message']);

‎packages/agent-core-v2/test/app/sessionExport/sessionExport.test.ts‎

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -159,6 +159,20 @@ describe('sessionExport', () => {
159159
expect(result.entries.some((entry) => entry.includes('notify'))).toBe(false);
160160
});
161161

162+
it('does not treat an ancestor directory named notify as session notify state', async () => {
163+
const tmp = await mkdtemp(join(tmpdir(), 'notify-ancestor-'));
164+
const sessionDir = join(tmp, 'notify', 'sessions', 'ws_demo', 'ses_nested');
165+
await mkdir(sessionDir, { recursive: true });
166+
await writeFile(join(sessionDir, 'state.json'), '{}\n', 'utf-8');
167+
168+
const result = await exportSessionDirectory({
169+
request: { sessionId: 'ses_nested', outputPath: join(tmp, 'export.zip'), version: '1.0.0-test' },
170+
summary: { id: 'ses_nested', sessionDir },
171+
});
172+
173+
expect(result.entries).toEqual(['manifest.json', 'state.json']);
174+
});
175+
162176
it('uses a timestamped default output path when outputPath is omitted', async () => {
163177
const tmp = await mkdtemp(join(tmpdir(), 'session-export-test-'));
164178
const sessionDir = join(tmp, 'sessions', 'ws_demo', 'ses_default_output');

‎packages/agent-core-v2/test/workspace/sessionLifecycle/forkTurnSlice.test.ts‎

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -73,6 +73,7 @@ describe('sliceMainRecordsAtTurn', () => {
7373
'metadata',
7474
'context.append_message',
7575
]);
76+
expect(slice.cutoffTime).toBe(4);
7677
});
7778
});
7879

@@ -129,6 +130,7 @@ describe('sliceMainRecordsBeforePrompt', () => {
129130

130131
const slice = sliceMainRecordsBeforePrompt(records, 'ses_source', 'prompt-1');
131132
expect(slice.records).toEqual([{ type: 'metadata', protocol_version: '1.5', created_at: 1 }]);
133+
expect(slice.cutoffTime).toBe(2);
132134
});
133135

134136
it('rejects an unknown active prompt', () => {

0 commit comments

Comments
 (0)