Skip to content

Commit aa09987

Browse files
neo-gpttobiu
andauthored
feat(orchestrator): restore pending summary markers (#12199) (#12273)
Co-authored-by: tobiu <tobiasuhlig78@gmail.com>
1 parent 3315515 commit aa09987

7 files changed

Lines changed: 377 additions & 6 deletions

File tree

ai/daemons/orchestrator/scheduling/summary.mjs

Lines changed: 49 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -3,12 +3,44 @@ import {
33
markNodesAsRead
44
} from '../../bridge/queries.mjs';
55

6+
/**
7+
* Reads pending low-latency summarization markers from the coordinator table.
8+
*
9+
* @summary Keeps the scheduler's pending-lane check cheap: it only needs to know
10+
* whether pending rows exist, while the spawned summary child remains responsible
11+
* for lease-claiming and draining the actual jobs.
12+
* @param {Object} db SQLite database handle.
13+
* @param {Object} [options]
14+
* @param {Number} [options.limit=50] Maximum marker ids to return.
15+
* @returns {String[]}
16+
*/
17+
export function getPendingSummarizationJobs(db, {limit = 50} = {}) {
18+
if (!db?.prepare) {
19+
return [];
20+
}
21+
22+
const numericLimit = Number.isInteger(limit) && limit > 0 ? limit : 50;
23+
24+
try {
25+
return db.prepare(`
26+
SELECT session_id
27+
FROM SummarizationJobs
28+
WHERE status = 'pending'
29+
ORDER BY rowid ASC
30+
LIMIT ?
31+
`).all(numericLimit).map(row => row.session_id).filter(Boolean);
32+
} catch {
33+
return [];
34+
}
35+
}
36+
637
/**
738
* Builds the task trigger for the summarization sweep lane.
839
*
9-
* Two wake-up sources, in priority order:
40+
* Three wake-up sources, in priority order:
1041
* 1. Unread sunset handovers — priority because they unblock the next agent boot
11-
* 2. Periodic sweep — fallback for ordinary unsummarized sessions
42+
* 2. Pending disconnect markers — targeted low-latency session close handling
43+
* 3. Periodic sweep — fallback for ordinary unsummarized sessions
1244
*
1345
* Pure function. Test the scheduling contract without mounting the SQLite graph.
1446
*
@@ -17,9 +49,10 @@ import {
1749
* @param {Number} options.lastRunAt Last summary task start timestamp.
1850
* @param {Number} options.intervalMs Periodic sweep interval; `0` disables the interval source.
1951
* @param {Object[]} [options.handovers=[]] Unread sunset-handover message nodes.
52+
* @param {String[]} [options.pendingJobs=[]] Pending SummarizationJobs session ids.
2053
* @returns {Object|null} A summary task trigger or null when no work is due.
2154
*/
22-
export function buildSummaryTrigger({now, lastRunAt, intervalMs, handovers = []}) {
55+
export function buildSummaryTrigger({now, lastRunAt, intervalMs, handovers = [], pendingJobs = []}) {
2356
if (handovers.length > 0) {
2457
return {
2558
taskName : 'summary',
@@ -29,6 +62,15 @@ export function buildSummaryTrigger({now, lastRunAt, intervalMs, handovers = []}
2962
};
3063
}
3164

65+
if (pendingJobs.length > 0) {
66+
return {
67+
taskName : 'summary',
68+
source : 'pending-summarization',
69+
reason : `pending-summarization:${pendingJobs.length}`,
70+
pendingCount: pendingJobs.length
71+
};
72+
}
73+
3274
if (intervalMs > 0 && now - lastRunAt >= intervalMs) {
3375
return {
3476
taskName: 'summary',
@@ -50,6 +92,7 @@ export function buildSummaryTrigger({now, lastRunAt, intervalMs, handovers = []}
5092
* @param {Number} options.now Current timestamp in milliseconds.
5193
* @param {Number} options.summarySweepIntervalMs Periodic summary sweep interval.
5294
* @param {Function} [options.getUnreadSunsetHandoversFn] Test seam for handover reads.
95+
* @param {Function} [options.getPendingSummarizationJobsFn] Test seam for pending marker reads.
5396
* @param {Function} [options.markNodesAsReadFn] Test seam for handover mark-read writes.
5497
* @param {Function} [options.log] Optional orchestrator log function.
5598
* @returns {Object|null} Task trigger with optional `onSuccess` callback, or null.
@@ -60,13 +103,16 @@ export function getDueTask({
60103
now,
61104
summarySweepIntervalMs,
62105
getUnreadSunsetHandoversFn = getUnreadSunsetHandovers,
106+
getPendingSummarizationJobsFn = getPendingSummarizationJobs,
63107
markNodesAsReadFn = markNodesAsRead,
64108
log
65109
}) {
66110
const handovers = getUnreadSunsetHandoversFn(db);
111+
const pendingJobs = handovers.length > 0 ? [] : getPendingSummarizationJobsFn(db);
67112
const trigger = buildSummaryTrigger({
68113
now,
69114
handovers,
115+
pendingJobs,
70116
intervalMs: summarySweepIntervalMs,
71117
lastRunAt : state.summary?.lastRunAt || 0
72118
});

ai/mcp/server/memory-core/Server.mjs

Lines changed: 5 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -221,15 +221,18 @@ class Server extends BaseServer {
221221
/**
222222
* @summary SSE-only hook fired by `TransportService` on session disconnect. Removes the
223223
* per-session McpServer from `CoalescingEngineService`'s broadcast set (counterpart to
224-
* `createMcpServer`'s `addMcpServer` registration). Summarization is orchestrator-driven
225-
* (the `summary` task), not triggered on disconnect.
224+
* `createMcpServer`'s `addMcpServer` registration) and writes a cheap idempotent
225+
* `SummarizationJobs.pending` marker. The orchestrator `summary` task drains that marker;
226+
* this hook never summarizes inline.
226227
* @param {String} sessionId
227228
* @param {Object} mcpServerInstance
228229
*/
229230
onSessionClosed(sessionId, mcpServerInstance) {
230231
if (mcpServerInstance) {
231232
CoalescingEngineService.removeMcpServer(mcpServerInstance);
232233
}
234+
235+
SessionService.queueSummarizationJob(sessionId);
233236
}
234237

235238
/**

ai/scripts/lifecycle/summarize-sessions.mjs

Lines changed: 11 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -27,7 +27,17 @@ async function summarize() {
2727
try {
2828
await Memory_SessionService.initAsync();
2929

30-
console.log('[summarize-sessions] Starting session summarization...');
30+
console.log('[summarize-sessions] Draining pending session summarization markers...');
31+
const pendingResult = await Memory_SessionService.summarizePendingSessions();
32+
33+
if (pendingResult && pendingResult.error) {
34+
console.error(`[summarize-sessions] Pending summarization failed: ${pendingResult.message}`);
35+
process.exit(1);
36+
}
37+
38+
console.log(`[summarize-sessions] Pending drain complete. Pending: ${pendingResult?.pending || 0}; processed: ${pendingResult?.processed || 0}`);
39+
40+
console.log('[summarize-sessions] Starting drift-detection session summarization...');
3141
// includeAll: false will only summarize sessions from the last 30 days
3242
const result = await Memory_SessionService.summarizeSessions({ includeAll: false });
3343

ai/services/memory-core/SessionService.mjs

Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -953,6 +953,109 @@ ${aggregatedContent}
953953
};
954954
}
955955

956+
/**
957+
* Queues a low-latency summarization marker for a disconnected session.
958+
*
959+
* @summary Writes an idempotent `pending` SummarizationJobs row without running
960+
* summarization inline. Multiple MCP server instances can race this cheap marker
961+
* safely; the orchestrator-owned summary lane remains the only drainer and the
962+
* existing lease transition serializes the expensive summarization work.
963+
* @param {String} sessionId Session id whose transport disconnected.
964+
* @returns {Boolean} true when the marker was written, false otherwise.
965+
*/
966+
queueSummarizationJob(sessionId) {
967+
if (!sessionId || typeof sessionId !== 'string') {
968+
return false;
969+
}
970+
971+
const db = GraphService.db?.storage?.db;
972+
if (!db) {
973+
logger.warn(`[SessionService] queueSummarizationJob skipped for ${sessionId}: SQLite graph is not available.`);
974+
return false;
975+
}
976+
977+
try {
978+
db.prepare(`
979+
INSERT INTO SummarizationJobs (session_id, status, lease_token, expires_at, retry_count)
980+
VALUES (?, 'pending', NULL, NULL, 0)
981+
ON CONFLICT(session_id) DO UPDATE SET
982+
status = 'pending',
983+
lease_token = NULL,
984+
expires_at = NULL
985+
WHERE SummarizationJobs.status != 'completed'
986+
AND SummarizationJobs.status != 'in_progress'
987+
`).run(sessionId);
988+
989+
return true;
990+
} catch (e) {
991+
logger.warn(`[SessionService] Error queueing summarization job for ${sessionId}: ${e.message}`);
992+
return false;
993+
}
994+
}
995+
996+
/**
997+
* Reads pending low-latency summarization job ids in insertion order.
998+
*
999+
* @summary Provides the orchestrator child process with the exact pending lane
1000+
* payload before it falls back to the broader drift sweep.
1001+
* @param {Object} [options]
1002+
* @param {Number} [options.limit=50] Maximum pending rows to drain in one child run.
1003+
* @returns {String[]}
1004+
*/
1005+
getPendingSummarizationJobIds({limit = 50} = {}) {
1006+
const db = GraphService.db?.storage?.db;
1007+
if (!db) return [];
1008+
1009+
const numericLimit = Number.isInteger(limit) && limit > 0 ? limit : 50;
1010+
1011+
try {
1012+
return db.prepare(`
1013+
SELECT session_id
1014+
FROM SummarizationJobs
1015+
WHERE status = 'pending'
1016+
ORDER BY rowid ASC
1017+
LIMIT ?
1018+
`).all(numericLimit).map(row => row.session_id).filter(Boolean);
1019+
} catch (e) {
1020+
logger.warn(`[SessionService] Error reading pending summarization jobs: ${e.message}`);
1021+
return [];
1022+
}
1023+
}
1024+
1025+
/**
1026+
* Drains pending summarization markers through the existing lease-backed summarizer.
1027+
*
1028+
* @summary Reuses {@link summarizeSessions} with explicit session ids so each
1029+
* `pending` row travels through `claimSummarizationJob()` and cannot be processed
1030+
* twice by overlapping pending/drift drains.
1031+
* @param {Object} [options]
1032+
* @param {Number} [options.limit=50] Maximum pending rows to drain in one child run.
1033+
* @returns {Promise<{processed: number, sessions: object[], pending: number}>}
1034+
*/
1035+
async summarizePendingSessions({limit = 50} = {}) {
1036+
const sessionIds = this.getPendingSummarizationJobIds({limit});
1037+
const processed = [];
1038+
1039+
for (const sessionId of sessionIds) {
1040+
const result = await this.summarizeSessions({sessionId});
1041+
1042+
if (result?.error) {
1043+
logger.warn(`[SessionService] Pending summarization failed for ${sessionId}: ${result.message}`);
1044+
continue;
1045+
}
1046+
1047+
if (Array.isArray(result?.sessions)) {
1048+
processed.push(...result.sessions);
1049+
}
1050+
}
1051+
1052+
return {
1053+
pending : sessionIds.length,
1054+
processed: processed.length,
1055+
sessions : processed
1056+
};
1057+
}
1058+
9561059
/**
9571060
* Claims an exclusive lease on a summarization job using the SummarizationJobs table.
9581061
* Prevents race conditions across concurrent MCP instances.

test/playwright/unit/ai/daemons/orchestrator/scheduling/summary.spec.mjs

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import {test, expect} from '@playwright/test';
22
import {
33
buildSummaryTrigger,
4+
getPendingSummarizationJobs,
45
getDueTask
56
} from '../../../../../../../ai/daemons/orchestrator/scheduling/summary.mjs';
67

@@ -19,6 +20,36 @@ test.describe('orchestrator/scheduling/summary (#11864 / Epic #11831)', () => {
1920
});
2021
});
2122

23+
test('#12199: buildSummaryTrigger prioritizes pending summarization markers before the periodic sweep', () => {
24+
expect(buildSummaryTrigger({
25+
now : 600000,
26+
lastRunAt : 0,
27+
intervalMs : 600000,
28+
handovers : [],
29+
pendingJobs: ['session-1', 'session-2']
30+
})).toEqual({
31+
taskName : 'summary',
32+
source : 'pending-summarization',
33+
reason : 'pending-summarization:2',
34+
pendingCount: 2
35+
});
36+
});
37+
38+
test('#12199: sunset handovers stay higher priority than pending summarization markers', () => {
39+
expect(buildSummaryTrigger({
40+
now : 600000,
41+
lastRunAt : 0,
42+
intervalMs : 600000,
43+
handovers : [{id: 'MESSAGE:1'}],
44+
pendingJobs: ['session-1']
45+
})).toEqual({
46+
taskName : 'summary',
47+
source : 'sunset-handover',
48+
reason : 'sunset-handover:1',
49+
handoverCount: 1
50+
});
51+
});
52+
2253
test('buildSummaryTrigger returns a periodic sweep trigger only when the interval is due', () => {
2354
expect(buildSummaryTrigger({now: 599999, lastRunAt: 0, intervalMs: 600000, handovers: []})).toBeNull();
2455

@@ -60,6 +91,24 @@ test.describe('orchestrator/scheduling/summary (#11864 / Epic #11831)', () => {
6091
expect(result.onSuccess).toBeUndefined();
6192
});
6293

94+
test('#12199: getDueTask reads pending markers when no handover is waiting', () => {
95+
const result = getDueTask({
96+
db : 'mock-db',
97+
state : {summary: {lastRunAt: 0}},
98+
now : 100,
99+
summarySweepIntervalMs : 600000,
100+
getUnreadSunsetHandoversFn : () => [],
101+
getPendingSummarizationJobsFn : () => ['session-1']
102+
});
103+
104+
expect(result).toEqual({
105+
taskName : 'summary',
106+
source : 'pending-summarization',
107+
reason : 'pending-summarization:1',
108+
pendingCount: 1
109+
});
110+
});
111+
63112
test('getDueTask returns sunset-handover trigger with onSuccess callback', () => {
64113
const markCalls = [];
65114
const handovers = [{id: 'MESSAGE:1'}, {id: 'MESSAGE:2'}];
@@ -79,4 +128,23 @@ test.describe('orchestrator/scheduling/summary (#11864 / Epic #11831)', () => {
79128
result.onSuccess();
80129
expect(markCalls).toEqual([{db: 'mock-db', nodes: handovers}]);
81130
});
131+
132+
test('#12199: getPendingSummarizationJobs reads pending session ids only', () => {
133+
const calls = [];
134+
const db = {
135+
prepare: (sql) => {
136+
calls.push(sql);
137+
return {
138+
all: (limit) => {
139+
calls.push(limit);
140+
return [{session_id: 'session-1'}, {session_id: 'session-2'}, {session_id: null}];
141+
}
142+
};
143+
}
144+
};
145+
146+
expect(getPendingSummarizationJobs(db, {limit: 2})).toEqual(['session-1', 'session-2']);
147+
expect(calls[0]).toContain("WHERE status = 'pending'");
148+
expect(calls[1]).toBe(2);
149+
});
82150
});

test/playwright/unit/ai/mcp/server/memory-core/Server.spec.mjs

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -141,4 +141,34 @@ test.describe('Neo.ai.mcp.server.memory-core.Server', () => {
141141

142142
serverInstance.destroy();
143143
});
144+
145+
test('#12199: onSessionClosed writes a pending summarization marker without summarizing inline', async () => {
146+
const SDK = await import('../../../../../../../ai/services.mjs');
147+
const serverInstance = Neo.create('Neo.ai.mcp.server.memory-core.Server');
148+
const mcpServerInstance = {id: 'mcp-session-server'};
149+
const calls = [];
150+
const removed = [];
151+
152+
const originalQueue = SDK.Memory_SessionService.queueSummarizationJob;
153+
const originalRemove = SDK.Memory_CoalescingEngineService.removeMcpServer;
154+
155+
SDK.Memory_SessionService.queueSummarizationJob = (sessionId) => {
156+
calls.push(sessionId);
157+
return true;
158+
};
159+
SDK.Memory_CoalescingEngineService.removeMcpServer = (instance) => {
160+
removed.push(instance);
161+
};
162+
163+
try {
164+
serverInstance.onSessionClosed('closed-session-1', mcpServerInstance);
165+
166+
expect(removed).toEqual([mcpServerInstance]);
167+
expect(calls).toEqual(['closed-session-1']);
168+
} finally {
169+
SDK.Memory_SessionService.queueSummarizationJob = originalQueue;
170+
SDK.Memory_CoalescingEngineService.removeMcpServer = originalRemove;
171+
serverInstance.destroy();
172+
}
173+
});
144174
});

0 commit comments

Comments
 (0)