From 843e4d1002459ed93411adc8f078035d58df5ae8 Mon Sep 17 00:00:00 2001 From: john Date: Sun, 16 Aug 2026 08:52:16 +0800 Subject: [PATCH] fix(prod): dedupe scheduled tasks and reconcile orphan delivery contracts Prevent duplicate active scheduled tasks, fail static preparing contracts when HTML is missing, raise MindSpace remote timeout default to 30s, and add 103 repair scripts for inspection follow-ups. Co-authored-by: Cursor --- mindspace-delivery-contract.mjs | 18 ++++ mindspace-runtime-config.mjs | 2 +- scheduled-task-executor.mjs | 18 +++- scheduled-task-executor.test.mjs | 23 ++++ scheduled-task-service.mjs | 27 ++++- scheduled-task-service.test.mjs | 56 ++++++++++ scripts/repair-goosed-harness-path-103.sh | 52 +++++++++ scripts/repair-production-inspection-103.mjs | 105 +++++++++++++++++++ 8 files changed, 296 insertions(+), 5 deletions(-) create mode 100755 scripts/repair-goosed-harness-path-103.sh create mode 100644 scripts/repair-production-inspection-103.mjs diff --git a/mindspace-delivery-contract.mjs b/mindspace-delivery-contract.mjs index 3c95698..8885ae7 100644 --- a/mindspace-delivery-contract.mjs +++ b/mindspace-delivery-contract.mjs @@ -47,6 +47,24 @@ export async function markPageDeliveryContractReady({ pool, userId, relativePath return Number(result?.affectedRows ?? 0) > 0; } +export async function markPageDeliveryContractFailed({ + pool, + userId, + relativePath, + failureReason = 'delivery_material_missing', +} = {}) { + const workspaceRelativePath = normalizeDeliveryRelativePath(relativePath); + if (!pool || !userId || !workspaceRelativePath) return false; + const now = Date.now(); + const [result] = await pool.query( + `UPDATE h5_page_delivery_contracts + SET status = 'failed', failure_reason = ?, updated_at = ? + WHERE user_id = ? AND workspace_relative_path = ? AND status = 'preparing'`, + [String(failureReason ?? 'delivery_material_missing'), now, userId, workspaceRelativePath], + ); + return Number(result?.affectedRows ?? 0) > 0; +} + export async function releaseMaterializedPageDeliveryContracts({ pool, userId, diff --git a/mindspace-runtime-config.mjs b/mindspace-runtime-config.mjs index 63cef09..c317c3c 100644 --- a/mindspace-runtime-config.mjs +++ b/mindspace-runtime-config.mjs @@ -86,7 +86,7 @@ export function resolveMindSpaceServerRuntimeOptions(h5Root, env = process.env) authToken: String(env.MINDSPACE_REMOTE_AUTH_TOKEN ?? '').trim(), operationBasePath: String(env.MINDSPACE_REMOTE_OPERATION_BASE_PATH ?? '/mindspace/v1/adapter') .trim(), - timeoutMs: Math.max(1000, Number(env.MINDSPACE_REMOTE_TIMEOUT_MS ?? 15_000)), + timeoutMs: Math.max(1000, Number(env.MINDSPACE_REMOTE_TIMEOUT_MS ?? 30_000)), }, }; } diff --git a/scheduled-task-executor.mjs b/scheduled-task-executor.mjs index fecdf4f..e34acef 100644 --- a/scheduled-task-executor.mjs +++ b/scheduled-task-executor.mjs @@ -3,6 +3,7 @@ import fs from 'node:fs'; import path from 'node:path'; import { buildChatSkillPrompt, SCHEDULED_TASK_AUTOMATION_SKILL_NAME } from './chat-skills.mjs'; import { + markPageDeliveryContractFailed, markPageDeliveryContractReady, normalizeDeliveryRelativePath, preparePageDeliveryContract, @@ -366,7 +367,22 @@ export async function reconcileStuckStaticPageDeliveryContracts({ row.user_id, relativePath, ); - if (!fs.existsSync(filePath)) continue; + if (!fs.existsSync(filePath)) { + if ( + await markPageDeliveryContractFailed({ + pool, + userId: row.user_id, + relativePath, + failureReason: 'materialized_html_missing', + }) + ) { + logger.info?.('[ScheduledTask] failed orphan static delivery contract', { + userId: row.user_id, + relativePath, + }); + } + continue; + } if ( await markPageDeliveryContractReady({ pool, diff --git a/scheduled-task-executor.test.mjs b/scheduled-task-executor.test.mjs index f9b1108..1ca5946 100644 --- a/scheduled-task-executor.test.mjs +++ b/scheduled-task-executor.test.mjs @@ -140,6 +140,29 @@ test('reconcileStuckStaticPageDeliveryContracts releases materialized static pag assert.deepEqual(released, [relativePath]); }); +test('reconcileStuckStaticPageDeliveryContracts fails orphan contracts without html', async () => { + const dir = await fs.mkdtemp(path.join(os.tmpdir(), 'scheduled-task-orphan-')); + const userId = 'user-1'; + const relativePath = 'public/missing.html'; + const pool = { + async query(sql) { + if (sql.includes('FROM h5_page_delivery_contracts')) { + return [[{ user_id: userId, workspace_relative_path: relativePath }]]; + } + if (sql.includes("SET status = 'failed'")) { + return [{ affectedRows: 1 }]; + } + return [[]]; + }, + }; + const released = await reconcileStuckStaticPageDeliveryContracts({ + pool, + h5Root: dir, + logger: { info() {} }, + }); + assert.deepEqual(released, []); +}); + test('extractScheduledTaskDeliveryText reads last assistant message', () => { const text = extractScheduledTaskDeliveryText([ { role: 'user', content: [{ type: 'text', text: 'hi' }] }, diff --git a/scheduled-task-service.mjs b/scheduled-task-service.mjs index f327d48..5d9530d 100644 --- a/scheduled-task-service.mjs +++ b/scheduled-task-service.mjs @@ -137,6 +137,27 @@ export function createScheduledTaskService(pool, { defaultTimezone = DEFAULT_TIM timezone: tz, now: clock.now(), }); + const normalizedTitle = safeTitle || safeTaskSpec.slice(0, 80); + const normalizedHour = hour == null ? null : Number(hour); + const normalizedMinute = Number(minute ?? 0); + if (safeRecurrence !== 'once') { + const [duplicateRows] = await pool.query( + `SELECT * + FROM h5_scheduled_tasks + WHERE user_id = ? + AND status = 'active' + AND recurrence = ? + AND hour <=> ? + AND minute = ? + AND title = ? + ORDER BY created_at ASC + LIMIT 1`, + [userId, safeRecurrence, normalizedHour, normalizedMinute, normalizedTitle], + ); + if (duplicateRows?.[0]) { + return rowToTask(duplicateRows[0]); + } + } const id = crypto.randomUUID(); const ts = clock.now(); await pool.query( @@ -148,11 +169,11 @@ export function createScheduledTaskService(pool, { defaultTimezone = DEFAULT_TIM [ id, userId, - safeTitle || safeTaskSpec.slice(0, 80), + normalizedTitle, safeTaskSpec, safeRecurrence, - hour == null ? null : Number(hour), - Number(minute ?? 0), + normalizedHour, + normalizedMinute, weekday == null ? null : Number(weekday), tz, nextRunAt, diff --git a/scheduled-task-service.test.mjs b/scheduled-task-service.test.mjs index 32a5b05..3a7d37d 100644 --- a/scheduled-task-service.test.mjs +++ b/scheduled-task-service.test.mjs @@ -19,6 +19,9 @@ test('createTask inserts scheduled automation row', async () => { const inserts = []; const service = createScheduledTaskService({ async query(sql, params) { + if (sql.includes('FROM h5_scheduled_tasks') && sql.includes('status = \'active\'')) { + return [[]]; + } if (sql.includes('INSERT INTO h5_scheduled_tasks')) { inserts.push(params); return [{ affectedRows: 1 }]; @@ -69,6 +72,59 @@ test('createTask inserts scheduled automation row', async () => { assert.equal(inserts.length, 1); }); +test('createTask returns existing active duplicate for same schedule', async () => { + let insertCount = 0; + const service = createScheduledTaskService({ + async query(sql) { + if (sql.includes('FROM h5_scheduled_tasks') && sql.includes('status = \'active\'')) { + return [[{ + id: 'task-existing', + user_id: 'user-1', + title: '每日天气预报播报(上海+武穴)', + task_spec: 'old spec', + recurrence: 'daily', + hour: 8, + minute: 0, + weekday: null, + timezone: 'Asia/Shanghai', + next_run_at: 1780000000000, + last_run_at: null, + notify_channel: 'both', + status: 'active', + attempts: 0, + last_error: null, + last_result_json: null, + source_channel: 'agent', + source_session_id: null, + source_message_id: null, + source_text: null, + created_at: 1780000000000, + updated_at: 1780000000000, + }]]; + } + if (sql.includes('INSERT INTO h5_scheduled_tasks')) { + insertCount += 1; + return [{ affectedRows: 1 }]; + } + return [[]]; + }, + }, { + clock: { now: () => 1780000000000 }, + }); + + const task = await service.createTask({ + userId: 'user-1', + title: '每日天气预报播报(上海+武穴)', + taskSpec: 'new spec', + recurrence: 'daily', + hour: 8, + minute: 0, + }); + + assert.equal(task.id, 'task-existing'); + assert.equal(insertCount, 0); +}); + test('cancelTask updates status to cancelled', async () => { const service = createScheduledTaskService({ async query(sql, params) { diff --git a/scripts/repair-goosed-harness-path-103.sh b/scripts/repair-goosed-harness-path-103.sh new file mode 100755 index 0000000..dad9123 --- /dev/null +++ b/scripts/repair-goosed-harness-path-103.sh @@ -0,0 +1,52 @@ +#!/usr/bin/env bash +set -euo pipefail + +ROOT="${GOOSED_NATIVE_ROOT:-/Users/john/Project/tkmind_go-native}" +LAUNCHD_DIR="${HOME}/Library/LaunchAgents" +GUI="gui/$(id -u)" +PATH_EXPORT='export PATH="/opt/homebrew/opt/node@24/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin"' + +log() { printf '[repair-goosed-harness-path] %s\n' "$*"; } + +patch_run_script() { + local script="${ROOT}/run-goosed-native.sh" + [[ -f "${script}" ]] || { log "missing ${script}"; exit 1; } + if grep -q 'opt/homebrew/opt/node@24/bin' "${script}"; then + log "run-goosed-native.sh already exports node PATH" + return 0 + fi + cp "${script}" "${script}.bak-inspection-$(date +%Y%m%d-%H%M%S)" + awk -v path_line="${PATH_EXPORT}" ' + /^set -euo pipefail$/ { + print + print path_line + next + } + { print } + ' "${script}" > "${script}.tmp" + mv "${script}.tmp" "${script}" + chmod +x "${script}" + log "patched ${script}" +} + +patch_launchd_plist() { + local port="$1" + local plist="${LAUNCHD_DIR}/cn.tkmind.goosed-native-${port}.plist" + [[ -f "${plist}" ]] || return 0 + if plutil -extract EnvironmentVariables raw "${plist}" >/dev/null 2>&1; then + plutil -replace EnvironmentVariables.PATH -string "/opt/homebrew/opt/node@24/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" "${plist}" + else + plutil -insert EnvironmentVariables -dictionary "${plist}" + plutil -insert EnvironmentVariables.PATH -string "/opt/homebrew/opt/node@24/bin:/opt/homebrew/bin:/usr/local/bin:/usr/bin:/bin" "${plist}" + fi + launchctl bootout "${GUI}/cn.tkmind.goosed-native-${port}" 2>/dev/null || true + launchctl bootstrap "${GUI}" "${plist}" + log "reloaded ${plist}" +} + +patch_run_script +for port in 18006 18007 18008 18009 18010 18011 18012 18013 18014; do + patch_launchd_plist "${port}" +done + +log "done" diff --git a/scripts/repair-production-inspection-103.mjs b/scripts/repair-production-inspection-103.mjs new file mode 100644 index 0000000..b18d1f5 --- /dev/null +++ b/scripts/repair-production-inspection-103.mjs @@ -0,0 +1,105 @@ +#!/usr/bin/env node +/** + * One-shot 103 production repairs from 2026-08-16 inspection. + * Dry-run by default; pass --apply to mutate database rows. + */ +import process from 'node:process'; +import { reconcileStuckStaticPageDeliveryContracts } from '../scheduled-task-executor.mjs'; + +const TANG_USER_ID = 'a70ff537-8908-486e-9b6c-042e07cc25db'; +const TASKS_TO_CANCEL = [ + '781a77e5-a787-4f37-b864-e21d7e918855', + '9df3f89a-3a06-434d-8b4c-17b1aeb8911f', + '7f953d5f-6fc3-4191-b52a-bffcef48e77f', + '9b537fe8-eea2-4e39-8bcb-e8f169986327', +]; + +function loadPool() { + const databaseUrl = process.env.DATABASE_URL; + if (!databaseUrl) { + throw new Error('缺少 DATABASE_URL'); + } + return import('mysql2/promise').then(({ default: mysql }) => + mysql.createPool({ uri: databaseUrl, connectionLimit: 2 })); +} + +async function main() { + const apply = process.argv.includes('--apply'); + const h5Root = process.env.H5_ROOT || '/Users/john/Project/Memind'; + const pool = await loadPool(); + const now = Date.now(); + const actions = []; + + for (const taskId of TASKS_TO_CANCEL) { + const [rows] = await pool.query( + `SELECT id, user_id, title, status, hour, minute + FROM h5_scheduled_tasks WHERE id = ? LIMIT 1`, + [taskId], + ); + const row = rows?.[0]; + if (!row) { + actions.push({ taskId, action: 'skip', reason: 'not_found' }); + continue; + } + if (row.status === 'cancelled') { + actions.push({ taskId, action: 'skip', reason: 'already_cancelled', title: row.title }); + continue; + } + actions.push({ + taskId, + action: apply ? 'cancel' : 'would_cancel', + title: row.title, + status: row.status, + schedule: `${row.hour ?? '?'}:${String(row.minute ?? 0).padStart(2, '0')}`, + }); + if (apply) { + await pool.query( + `UPDATE h5_scheduled_tasks + SET status = 'cancelled', updated_at = ?, last_error = NULL + WHERE id = ? AND user_id = ?`, + [now, taskId, TANG_USER_ID], + ); + } + } + + const [activeWeather] = await pool.query( + `SELECT id, title, hour, minute, status, last_run_at + FROM h5_scheduled_tasks + WHERE user_id = ? AND title LIKE '%天气预报%' AND status = 'active' + ORDER BY hour, created_at`, + [TANG_USER_ID], + ); + + let reconcileSummary = []; + if (apply) { + reconcileSummary = await reconcileStuckStaticPageDeliveryContracts({ + pool, + h5Root, + limit: 50, + logger: console, + }); + } else { + const [stuck] = await pool.query( + `SELECT user_id, workspace_relative_path, data_mode, status + FROM h5_page_delivery_contracts + WHERE status = 'preparing' AND data_mode = 'static' + ORDER BY updated_at ASC + LIMIT 50`, + ); + reconcileSummary = stuck; + } + + console.log(JSON.stringify({ + mode: apply ? 'apply' : 'dry-run', + cancelledTasks: actions, + remainingActiveWeatherTasks: activeWeather, + deliveryContracts: reconcileSummary, + }, null, 2)); + + await pool.end(); +} + +main().catch((error) => { + console.error(error); + process.exit(1); +});