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); +});