diff --git a/src/config/server-timing.ts b/src/config/server-timing.ts index d4e98ff2..7a9c1858 100644 --- a/src/config/server-timing.ts +++ b/src/config/server-timing.ts @@ -99,3 +99,11 @@ export const STALE_DATA_MAX_AGE_MS = 60 * 60 * 1000; /** Standard 5-minute inactivity timeout for streams and caches (ms) */ export const INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000; + +/** + * Gap between a paste-mode cron prompt's text and its Enter (ms). The two must be + * separate writes: Claude Code takes a raw `\r` burst of about a hundred + * characters as a paste and turns its `\r` into a newline. A separate `\r` 80 ms + * after the text was measured to submit; this leaves room for a longer prompt. + */ +export const CRON_PASTE_ENTER_DELAY_MS = 300; diff --git a/src/cron/cron-service.ts b/src/cron/cron-service.ts index cce014ee..7a57424c 100644 --- a/src/cron/cron-service.ts +++ b/src/cron/cron-service.ts @@ -20,7 +20,7 @@ import { getErrorMessage, createErrorResponse, ApiErrorCode } from '../types/api import { MAX_CONCURRENT_SESSIONS, MAX_CRON_JOBS, MAX_CRON_RUN_HISTORY } from '../config/map-limits.js'; import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../user-store.js'; import { sessionCapacityState, isWorkingDirAllowedForUsername } from '../web/route-helpers.js'; -import { CRON_READY_MAX_ATTEMPTS, CRON_READY_SETTLE_MS } from '../config/server-timing.js'; +import { CRON_PASTE_ENTER_DELAY_MS, CRON_READY_MAX_ATTEMPTS, CRON_READY_SETTLE_MS } from '../config/server-timing.js'; import { DEFAULT_BLOCKED_TREES, isBlockedAttachmentPath, @@ -100,6 +100,41 @@ const CRON_WORKING_DIR_BLOCKED_TREES: readonly string[] = [...DEFAULT_BLOCKED_TR /** Prompt delivery is single-line only (writeViaMux/Ink constraint). */ const HAS_NEWLINE = /[\r\n]/; +/** The three session calls prompt delivery needs, so it can be tested without a PTY. */ +type CronPromptTarget = Pick; + +/** + * Send a cron job's (single-line) prompt into its session and press Enter. + * + * `typed` goes through the mux: the text is typed, Enter is its own key, and the + * session re-presses it while the prompt is still on the composer. + * + * `paste` writes the text straight into the PTY, and must send its Enter as a + * SEPARATE write. It used to send `\r` in one piece, and Claude Code (measured + * on 2.1.283) takes a burst of about a hundred characters as a paste, so the `\r` + * landed as a newline and the prompt sat unsent while the run reported + * `prompt_sent`. The Enter goes down the same PTY as the text, so it cannot overtake + * it, and the same composer check then covers a CLI that was not taking Enter yet. + * + * @returns false when the session had no PTY or mux to write to + */ +export async function deliverCronPrompt( + target: CronPromptTarget, + prompt: string, + inputMode: CronJob['inputMode'], + wait: (ms: number) => Promise = delay +): Promise { + if (inputMode !== 'paste') { + return target.writeViaMux(prompt.endsWith('\r') ? prompt : `${prompt}\r`); + } + const text = prompt.replace(/[\r\n]+$/, ''); + if (!target.write(text)) return false; + await wait(CRON_PASTE_ENTER_DELAY_MS); + if (!target.write('\r')) return false; + target.verifySubmitted(text); + return true; +} + /** Order-insensitive equality for the weekly-days arrays. */ function sameDays(a: number[] | undefined, b: number[] | undefined): boolean { const x = [...(a ?? [])].sort((p, q) => p - q); @@ -623,15 +658,9 @@ export class CronService { const s = this.deps.sessions.get(sessionId); if (!s) return; try { - const payload = prompt.endsWith('\r') ? prompt : `${prompt}\r`; - let delivered = true; - if (job.inputMode === 'paste') { - s.write(payload); - } else { - delivered = await s.writeViaMux(payload); - } + const delivered = await deliverCronPrompt(s, prompt, job.inputMode); if (!delivered) { - this.failRun(job, run, 'Failed to send prompt: mux write failed'); + this.failRun(job, run, 'Failed to send prompt: the session could not be written to'); return; } run.status = 'prompt_sent'; diff --git a/src/session.ts b/src/session.ts index e5da2586..3a26e90e 100644 --- a/src/session.ts +++ b/src/session.ts @@ -4207,6 +4207,16 @@ export class Session extends EventEmitter { return false; } + /** + * Arm the composer check for a prompt that went out some other way than + * `writeViaMux`, e.g. cron's paste mode, which writes the body raw and its Enter + * separately. `text` is what the composer line starts with while the prompt is still + * unsent; the check re-presses Enter only while that holds. + */ + verifySubmitted(text: string): void { + this._verifySubmitted(`${text}\r`); + } + /** * Arm the composer check for a write that carried Enter (session-submit-verifier.ts): * Claude Code 2.1.277+ ignores Enter for the first 30-50 s after the composer paints, diff --git a/test/cron-service.test.ts b/test/cron-service.test.ts index cf10f37d..8b36bd32 100644 --- a/test/cron-service.test.ts +++ b/test/cron-service.test.ts @@ -16,7 +16,13 @@ import { describe, it, expect, beforeEach, vi } from 'vitest'; import { existsSync, mkdtempSync, mkdirSync, writeFileSync, symlinkSync } from 'node:fs'; import { tmpdir } from 'node:os'; import { join } from 'node:path'; -import { CronService, clampCronExternalCliConfigs, type CronDeps } from '../src/cron/cron-service.js'; +import { + CronService, + clampCronExternalCliConfigs, + deliverCronPrompt, + type CronDeps, +} from '../src/cron/cron-service.js'; +import { CRON_PASTE_ENTER_DELAY_MS } from '../src/config/server-timing.js'; import { CronJobSchema } from '../src/web/schemas.js'; import { MAX_CRON_JOBS } from '../src/config/map-limits.js'; import type { CronJob, CronJobRun } from '../src/types/cron.js'; @@ -705,3 +711,77 @@ describe('clampCronExternalCliConfigs', () => { } }); }); + +/** + * Paste mode used to write `\r` in one piece. Claude Code takes a raw burst of + * about a hundred characters as a paste and turns its `\r` into a newline, so the + * prompt sat unsent on the composer while the run said `prompt_sent`. + */ +describe('deliverCronPrompt', () => { + const PROMPT = + 'Reply with only the word ok and nothing else, this sentence is padding to reach about one hundred chars.'; + + function fakeTarget(ok = true) { + const calls: string[] = []; + const target = { + write: vi.fn((d: string) => { + calls.push(`write:${JSON.stringify(d)}`); + return ok; + }), + writeViaMux: vi.fn(async (d: string) => { + calls.push(`mux:${JSON.stringify(d)}`); + return ok; + }), + verifySubmitted: vi.fn((t: string) => { + calls.push(`verify:${JSON.stringify(t)}`); + }), + }; + return { target, calls }; + } + const noWait = async (): Promise => {}; + + it('paste mode writes the text and its Enter separately, then arms the composer check', async () => { + const { target, calls } = fakeTarget(); + const waits: number[] = []; + + const ok = await deliverCronPrompt(target, PROMPT, 'paste', async (ms) => { + waits.push(ms); + calls.push('wait'); + }); + + expect(ok).toBe(true); + expect(calls).toEqual([ + `write:${JSON.stringify(PROMPT)}`, + 'wait', + 'write:"\\r"', + `verify:${JSON.stringify(PROMPT)}`, + ]); + expect(waits).toEqual([CRON_PASTE_ENTER_DELAY_MS]); + expect(target.writeViaMux).not.toHaveBeenCalled(); + }); + + it('never puts the Enter in the same write as the text', async () => { + const { target } = fakeTarget(); + + await deliverCronPrompt(target, `${PROMPT}\r`, 'paste', noWait); + + for (const [data] of target.write.mock.calls) { + expect(data === '\r' || !data.includes('\r')).toBe(true); + } + }); + + it('typed mode is unchanged: one mux write that carries the Enter', async () => { + const { target, calls } = fakeTarget(); + + await deliverCronPrompt(target, PROMPT, 'typed', noWait); + + expect(calls).toEqual([`mux:${JSON.stringify(`${PROMPT}\r`)}`]); + }); + + it('reports a session it could not write to, instead of claiming the prompt went out', async () => { + const { target } = fakeTarget(false); + + expect(await deliverCronPrompt(target, PROMPT, 'paste', noWait)).toBe(false); + expect(target.verifySubmitted).not.toHaveBeenCalled(); + }); +});