fix(agent): recover stale runs and improve new-user OA delivery

Fix DEV logout cookie clearing, materialize selected MindSpace OA assets before agent runs, and recover zombie runs from synced workspace pages. Add client run wait timeout, harness retry limits, page-edit asset forwarding, and logout/john2 scenario tests.

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
john
2026-07-10 20:05:52 +08:00
parent aede1e6fcb
commit 7f8d692d16
34 changed files with 1318 additions and 125 deletions
+117 -5
View File
@@ -6,10 +6,26 @@ import path from 'node:path';
import test from 'node:test';
import { createAgentRunGateway } from './agent-run-gateway.mjs';
function createFakePool({ sessionDeliverables = {} } = {}) {
function createFakePool({ sessionDeliverables = {}, workspaceDeliverables = {} } = {}) {
const runs = new Map();
const events = [];
const sessionFinishedAt = (runId) => {
const timestamps = events
.filter((event) => event.runId === runId && event.eventType === 'session_finished')
.map((event) => Number(event.createdAt ?? 0));
return timestamps.length ? Math.max(...timestamps) : null;
};
const isStaleRunningRow = (row, startedCutoff, sessionFinishedCutoff) => {
if (row.status !== 'running' || row.started_at == null) return false;
const finishedAt = sessionFinishedAt(row.id);
return (
Number(row.started_at) <= Number(startedCutoff)
|| (finishedAt != null && Number(finishedAt) <= Number(sessionFinishedCutoff))
);
};
const latestHeartbeatAt = (runId) => {
const timestamps = events
.filter((event) => event.runId === runId && event.eventType === 'worker_heartbeat')
@@ -69,6 +85,28 @@ function createFakePool({ sessionDeliverables = {} } = {}) {
.length,
}]];
}
if (sql.includes('SELECT') && sql.includes('session_finished_at') && sql.includes('r.started_at <= ?')) {
const [startedCutoff, sessionFinishedCutoff, limit = 1] = params;
return [[...runs.values()]
.filter((row) => isStaleRunningRow(row, startedCutoff, sessionFinishedCutoff))
.sort((a, b) => {
const aKey = Number(sessionFinishedAt(a.id) ?? a.started_at ?? 0);
const bKey = Number(sessionFinishedAt(b.id) ?? b.started_at ?? 0);
return aKey - bKey;
})
.slice(0, Number(limit))
.map((row) => ({
id: row.id,
user_id: row.user_id,
agent_session_id: row.agent_session_id,
request_id: row.request_id,
started_at: row.started_at,
updated_at: row.updated_at,
attempts: row.attempts,
latest_heartbeat_at: latestHeartbeatAt(row.id),
session_finished_at: sessionFinishedAt(row.id),
}))];
}
if (sql.includes('SELECT') && sql.includes('latest_heartbeat_at') && sql.includes('COALESCE(h.latest_heartbeat_at, r.started_at) <= ?')) {
const [cutoff, limit = 1] = params;
return [[...runs.values()]
@@ -172,6 +210,20 @@ function createFakePool({ sessionDeliverables = {} } = {}) {
});
return [{ affectedRows: 1 }];
}
if (sql.includes("WHERE id = ?") && sql.includes("status = 'running'") && sql.includes('session_finished')) {
const [errorMessage, updatedAt, completedAt, id, startedCutoff, sessionFinishedCutoff] = params;
const row = runs.get(id);
if (!row || !isStaleRunningRow(row, startedCutoff, sessionFinishedCutoff)) {
return [{ affectedRows: 0 }];
}
Object.assign(row, {
status: 'failed',
error_message: errorMessage,
updated_at: updatedAt,
completed_at: completedAt,
});
return [{ affectedRows: 1 }];
}
if (sql.includes("WHERE id = ?") && sql.includes("status = 'running'") && sql.includes('worker_heartbeat')) {
const [errorMessage, updatedAt, completedAt, id, cutoff] = params;
const row = runs.get(id);
@@ -195,6 +247,15 @@ function createFakePool({ sessionDeliverables = {} } = {}) {
const [userId, sessionId] = params;
return [sessionDeliverables[`${userId}:${sessionId}`] ?? []];
}
if (sql.includes('FROM h5_page_records p') && sql.includes('auto_synced')) {
const userId = params[0];
const sinceMs = params.length > 1 ? Number(params[1]) : null;
let rows = workspaceDeliverables[userId] ?? [];
if (sinceMs != null) {
rows = rows.filter((row) => Number(row.updated_at ?? 0) >= sinceMs);
}
return [rows];
}
if (sql.includes('UPDATE h5_agent_runs SET')) {
const id = params.at(-1);
const row = runs.get(id);
@@ -1420,7 +1481,7 @@ test('stale running recovery marks old running rows failed with an event', async
);
});
test('stale running recovery ignores old running rows with fresh heartbeat', async () => {
test('stale running recovery still considers old runs even with fresh heartbeat', async () => {
const pool = createFakePool();
const gateway = createAgentRunGateway({
pool,
@@ -1450,9 +1511,60 @@ test('stale running recovery ignores old running rows with fresh heartbeat', asy
const result = await gateway.recoverStaleRunningRuns({ staleMs: 1000, dryRun: false });
assert.equal(result.considered, 0);
assert.equal(result.recovered, 0);
assert.equal(pool.runs.get(run.id).status, 'running');
assert.equal(result.considered, 1);
assert.equal(result.recovered, 1);
assert.equal(pool.runs.get(run.id).status, 'failed');
});
test('stale running recovery succeeds when workspace pages exist after sync', async () => {
const startedAt = Date.now() - 5000;
const pool = createFakePool({
workspaceDeliverables: {
'user-1': [{
page_id: 'page-synced',
title: '苏州攻略',
publication_id: 'pub-synced',
publication_status: 'online',
public_url: 'http://127.0.0.1:5173/u/john/pages/page-synced',
updated_at: startedAt + 1000,
}],
},
});
const syncCalls = [];
const gateway = createAgentRunGateway({
pool,
userAuth: {},
tkmindProxy: {},
autoDispatch: false,
runTimeoutMs: 1000,
syncUserPagesOnSuccess: async ({ userId, sessionId, runId }) => {
syncCalls.push({ userId, sessionId, runId });
},
});
const run = await gateway.createRun('user-1', {
requestId: 'req-stale-deliverable',
sessionId: 'session-stale-deliverable',
userMessage: { role: 'user', content: [] },
});
Object.assign(pool.runs.get(run.id), {
status: 'running',
attempts: 1,
agent_session_id: 'session-stale-deliverable',
started_at: startedAt,
updated_at: startedAt,
});
const result = await gateway.recoverStaleRunningRuns({ staleMs: 1000, dryRun: false });
assert.equal(result.considered, 1);
assert.equal(result.recovered, 1);
assert.equal(pool.runs.get(run.id).status, 'succeeded');
assert.equal(syncCalls.length, 1);
assert.equal(
pool.events.some((event) => event.runId === run.id && event.eventType === 'run_recovered_from_deliverables'),
true,
);
});
test('queue status reports running heartbeat age and missing heartbeat count', async () => {