(entry: ScheduleEntry)
| 80 | } |
| 81 | |
| 82 | async function runOne(entry: ScheduleEntry): Promise<void> { |
| 83 | const store = getScheduleStore(); |
| 84 | const startMs = Date.now(); |
| 85 | const runId = store.recordRunStart(entry.id); |
| 86 | const logPath = path.join(RUN_LOG_DIR, `${entry.id}.${runId}.log`); |
| 87 | const logStream = fsSync.createWriteStream(logPath, { flags: 'w' }); |
| 88 | logStream.write(`# schedule: ${entry.name} (${entry.id})\n# cron: ${entry.cron}\n# cwd: ${entry.cwd}\n# started: ${new Date().toISOString()}\n# prompt: ${entry.prompt.replace(/\n/g, '\n# ')}\n\n`); |
| 89 | |
| 90 | // cwd must exist; otherwise mark errored and bail (no point retrying every minute) |
| 91 | try { |
| 92 | const st = fsSync.statSync(entry.cwd); |
| 93 | if (!st.isDirectory()) throw new Error(`not a directory`); |
| 94 | } catch (e: any) { |
| 95 | const msg = `cwd invalid: ${entry.cwd} (${e.message})`; |
| 96 | logStream.end(msg + '\n'); |
| 97 | store.recordRunFinish(runId, entry.id, 'error', 1, msg, Date.now() - startMs); |
| 98 | return; |
| 99 | } |
| 100 | |
| 101 | // Build child args: qodex --print <prompt> --yes [--model ...] |
| 102 | // We use --yes so permission prompts auto-approve; without it the headless run |
| 103 | // would deny everything and the schedule would be useless. A recipe (e.g. verified-pr) |
| 104 | // wraps the goal in an unattended-safe protocol before it's fed to the agent. |
| 105 | const runPrompt = buildRecipePrompt(entry.recipe, entry.prompt); |
| 106 | const args: string[] = ['--print', runPrompt, '--yes']; |
| 107 | if (entry.model) { args.push('--model', entry.model); } |
| 108 | |
| 109 | // Resolve the qodex CLI path. In production the user runs `npm link` so `qodex` |
| 110 | // is on PATH; in dev we may need to point at bin/qodex.mjs directly. |
| 111 | const cliPath = resolveCliPath(); |
| 112 | |
| 113 | // Ask the child to write a ground-truth receipt here (built by QodeX from the git diff + |
| 114 | // the checkers it ran, not the model). We prefer it over parsing the model's stdout block. |
| 115 | const receiptFile = path.join(RUN_LOG_DIR, `${entry.id}.${runId}.receipt.json`); |
| 116 | |
| 117 | return new Promise<void>((resolve) => { |
| 118 | const child = spawn(cliPath, args, { |
| 119 | cwd: entry.cwd, |
| 120 | env: { ...process.env, QODEX_SCHEDULED: '1', QODEX_RECEIPT_FILE: receiptFile }, |
| 121 | stdio: ['ignore', 'pipe', 'pipe'], |
| 122 | }); |
| 123 | |
| 124 | let output = ''; |
| 125 | child.stdout?.on('data', (d: Buffer) => { const s = d.toString(); output += s; logStream.write(s); }); |
| 126 | child.stderr?.on('data', (d: Buffer) => { const s = d.toString(); output += s; logStream.write(s); }); |
| 127 | |
| 128 | const hardKill = setTimeout(() => { |
| 129 | try { child.kill('SIGKILL'); } catch {} |
| 130 | }, 30 * 60 * 1000); // 30 minute hard cap |
| 131 | |
| 132 | child.on('error', (e: any) => { |
| 133 | clearTimeout(hardKill); |
| 134 | const msg = `spawn failed: ${e.message}`; |
| 135 | logStream.end('\n' + msg + '\n'); |
| 136 | store.recordRunFinish(runId, entry.id, 'error', 127, msg, Date.now() - startMs); |
| 137 | resolve(); |
| 138 | }); |
| 139 |
no test coverage detected