Skip to content

Commit fa2daf4

Browse files
committed
fix(agent-graph): validate historical epoch queries
1 parent 6212969 commit fa2daf4

4 files changed

Lines changed: 52 additions & 4 deletions

File tree

packages/runtime-host/src/__tests__/agent-graph-coordinator.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -252,6 +252,13 @@ describe('Host Agent Graph coordinator', () => {
252252
],
253253
);
254254
assert.equal(second.result.nextBeforeEpoch, null);
255+
256+
const ahead = await coordinator.handlers['agent.graph.epochs.query'](
257+
{ rootSessionId: 'root-1', beforeEpoch: 999 },
258+
context(),
259+
);
260+
assert.equal(ahead.ok, false);
261+
if (!ahead.ok) assert.equal(ahead.error.code, 'invalid_request');
255262
coordinator.close();
256263
});
257264

packages/runtime-host/src/server/agent-graph-coordinator.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -137,6 +137,12 @@ export class HostAgentGraphCoordinator {
137137
...(input.beforeEpoch === undefined ? {} : { beforeEpoch: input.beforeEpoch }),
138138
limit: AGENT_GRAPH_EPOCH_PAGE_SIZE,
139139
});
140+
if (input.beforeEpoch !== undefined && input.beforeEpoch > page.currentEpoch) {
141+
throw new AgentGraphClientOperationError(
142+
'invalid_request',
143+
`Agent graph epoch cursor ${input.beforeEpoch} is ahead of current epoch ${page.currentEpoch}`,
144+
);
145+
}
140146
return {
141147
ok: true,
142148
result: {

packages/runtime/src/__tests__/stream-graph-coordinator.test.ts

Lines changed: 25 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -19,6 +19,7 @@ import { type RuntimeEvent } from '@maka/core/runtime-event';
1919
import {
2020
createSessionStore,
2121
createSqliteSessionMetadataStore,
22+
isSessionNotFoundError,
2223
OPERATIONAL_STATE_DATABASE_NAME,
2324
} from '@maka/storage';
2425
import { createSqliteAgentRunStore, createWorkspaceRuntimeStore } from '@maka/storage';
@@ -328,6 +329,15 @@ describe('host-managed agent graph coordinator', () => {
328329
coordinator.toolsForSession(childSessions[0]!.id),
329330
/only to root Sessions/,
330331
);
332+
await assert.rejects(
333+
coordinator.listGraphEpochPage(childSessions[0]!.id, { limit: 32 }),
334+
(error: unknown) =>
335+
error instanceof AgentGraphClientOperationError && error.code === 'operation_conflict',
336+
);
337+
await assert.rejects(
338+
coordinator.listGraphEpochPage(randomUUID(), { limit: 32 }),
339+
(error: unknown) => isSessionNotFoundError(error),
340+
);
331341
let childStopEntered = false;
332342
await assert.rejects(
333343
coordinator.stopExecution(childSessions[0]!.id, {
@@ -545,11 +555,18 @@ describe('host-managed agent graph coordinator', () => {
545555
test('reads the epoch page and current marker from one storage observation', async () => {
546556
const controlStore = createSqliteSessionMetadataStore(':memory:');
547557
let resolveCalls = 0;
558+
let headerReads = 0;
548559
const coordinator = new AgentGraphCoordinator({
549560
sessionStore: {
550561
listForRecovery: async () => [],
551-
readHeader: async () => {
552-
throw new Error('epoch listing must not read the Session header');
562+
readHeader: async (sessionId: string) => {
563+
headerReads += 1;
564+
return {
565+
id: sessionId,
566+
status: 'active',
567+
isArchived: false,
568+
orchestrationMode: 'graph',
569+
} as never;
553570
},
554571
},
555572
runStore: { listSessionRuns: async () => [] },
@@ -612,6 +629,12 @@ describe('host-managed agent graph coordinator', () => {
612629
assert.equal(page.currentEpoch, 3);
613630
assert.equal(page.epochs[0]?.graphId, 'agent_graph_3');
614631
assert.equal(resolveCalls, 0);
632+
assert.equal(headerReads, 1);
633+
await assert.rejects(
634+
coordinator.listGraphEpochPage('root-session', { beforeEpoch: 999, limit: 32 }),
635+
(error: unknown) =>
636+
error instanceof AgentGraphClientOperationError && error.code === 'invalid_request',
637+
);
615638
} finally {
616639
await coordinator.close();
617640
controlStore.close();

packages/runtime/src/stream-graph-coordinator.ts

Lines changed: 14 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -294,7 +294,7 @@ export class AgentGraphCoordinator {
294294
}
295295

296296
async listGraphEpochs(rootSessionId: string): Promise<readonly AgentGraphEpochBinding[]> {
297-
requireRootSessionId(rootSessionId);
297+
await this.#assertRootGraphReader(rootSessionId);
298298
const current = await this.currentGraphEpoch(rootSessionId);
299299
if (!this.#input.epochStore) return [current];
300300
const epochs = await this.#input.epochStore.listAgentGraphEpochs(rootSessionId);
@@ -305,9 +305,10 @@ export class AgentGraphCoordinator {
305305
rootSessionId: string,
306306
options: { readonly beforeEpoch?: number; readonly limit: number },
307307
): Promise<AgentGraphEpochPage & { readonly currentEpoch: number }> {
308-
requireRootSessionId(rootSessionId);
308+
await this.#assertRootGraphReader(rootSessionId);
309309
if (!this.#input.epochStore) {
310310
const current = await this.currentGraphEpoch(rootSessionId);
311+
assertEpochCursorNotAhead(options.beforeEpoch, current.epoch);
311312
return {
312313
epochs:
313314
options.beforeEpoch === undefined || current.epoch < options.beforeEpoch ? [current] : [],
@@ -323,6 +324,7 @@ export class AgentGraphCoordinator {
323324
...options,
324325
});
325326
if (page.currentEpoch !== null) {
327+
assertEpochCursorNotAhead(options.beforeEpoch, page.currentEpoch);
326328
return {
327329
epochs: page.epochs,
328330
nextBeforeEpoch: page.nextBeforeEpoch,
@@ -331,6 +333,7 @@ export class AgentGraphCoordinator {
331333
}
332334
// No durable rows yet: synthesize the legacy virtual epoch identity.
333335
const current = await this.currentGraphEpoch(rootSessionId);
336+
assertEpochCursorNotAhead(options.beforeEpoch, current.epoch);
334337
return {
335338
epochs: options.beforeEpoch === undefined ? [current] : [],
336339
nextBeforeEpoch: null,
@@ -1488,6 +1491,15 @@ export class AgentGraphCoordinator {
14881491
}
14891492
}
14901493

1494+
function assertEpochCursorNotAhead(beforeEpoch: number | undefined, currentEpoch: number): void {
1495+
if (beforeEpoch !== undefined && beforeEpoch > currentEpoch) {
1496+
throw new AgentGraphClientOperationError(
1497+
'invalid_request',
1498+
`Agent graph epoch cursor ${beforeEpoch} is ahead of current epoch ${currentEpoch}`,
1499+
);
1500+
}
1501+
}
1502+
14911503
interface ScheduleWakeFence {
14921504
stopGeneration: number;
14931505
mayResumePaused: boolean;

0 commit comments

Comments
 (0)