mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-09-30 12:39:42 +02:00
Merge origin/master (cron paste-mode Enter fix) into the landing branch
This commit is contained in:
@@ -99,3 +99,11 @@ export const STALE_DATA_MAX_AGE_MS = 60 * 60 * 1000;
|
|||||||
|
|
||||||
/** Standard 5-minute inactivity timeout for streams and caches (ms) */
|
/** Standard 5-minute inactivity timeout for streams and caches (ms) */
|
||||||
export const INACTIVITY_TIMEOUT_MS = 5 * 60 * 1000;
|
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 `<text>\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;
|
||||||
|
|||||||
@@ -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 { MAX_CONCURRENT_SESSIONS, MAX_CRON_JOBS, MAX_CRON_RUN_HISTORY } from '../config/map-limits.js';
|
||||||
import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../user-store.js';
|
import { canUsernameRunPrivilegedCommands, resolveClaudeModeForUsername } from '../user-store.js';
|
||||||
import { sessionCapacityState, isWorkingDirAllowedForUsername } from '../web/route-helpers.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 {
|
import {
|
||||||
DEFAULT_BLOCKED_TREES,
|
DEFAULT_BLOCKED_TREES,
|
||||||
isBlockedAttachmentPath,
|
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). */
|
/** Prompt delivery is single-line only (writeViaMux/Ink constraint). */
|
||||||
const HAS_NEWLINE = /[\r\n]/;
|
const HAS_NEWLINE = /[\r\n]/;
|
||||||
|
|
||||||
|
/** The three session calls prompt delivery needs, so it can be tested without a PTY. */
|
||||||
|
type CronPromptTarget = Pick<Session, 'write' | 'writeViaMux' | 'verifySubmitted'>;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* 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 `<text>\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<void> = delay
|
||||||
|
): Promise<boolean> {
|
||||||
|
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. */
|
/** Order-insensitive equality for the weekly-days arrays. */
|
||||||
function sameDays(a: number[] | undefined, b: number[] | undefined): boolean {
|
function sameDays(a: number[] | undefined, b: number[] | undefined): boolean {
|
||||||
const x = [...(a ?? [])].sort((p, q) => p - q);
|
const x = [...(a ?? [])].sort((p, q) => p - q);
|
||||||
@@ -623,15 +658,9 @@ export class CronService {
|
|||||||
const s = this.deps.sessions.get(sessionId);
|
const s = this.deps.sessions.get(sessionId);
|
||||||
if (!s) return;
|
if (!s) return;
|
||||||
try {
|
try {
|
||||||
const payload = prompt.endsWith('\r') ? prompt : `${prompt}\r`;
|
const delivered = await deliverCronPrompt(s, prompt, job.inputMode);
|
||||||
let delivered = true;
|
|
||||||
if (job.inputMode === 'paste') {
|
|
||||||
s.write(payload);
|
|
||||||
} else {
|
|
||||||
delivered = await s.writeViaMux(payload);
|
|
||||||
}
|
|
||||||
if (!delivered) {
|
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;
|
return;
|
||||||
}
|
}
|
||||||
run.status = 'prompt_sent';
|
run.status = 'prompt_sent';
|
||||||
|
|||||||
@@ -4207,6 +4207,16 @@ export class Session extends EventEmitter {
|
|||||||
return false;
|
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):
|
* 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,
|
* Claude Code 2.1.277+ ignores Enter for the first 30-50 s after the composer paints,
|
||||||
|
|||||||
@@ -16,7 +16,13 @@ import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|||||||
import { existsSync, mkdtempSync, mkdirSync, writeFileSync, symlinkSync } from 'node:fs';
|
import { existsSync, mkdtempSync, mkdirSync, writeFileSync, symlinkSync } from 'node:fs';
|
||||||
import { tmpdir } from 'node:os';
|
import { tmpdir } from 'node:os';
|
||||||
import { join } from 'node:path';
|
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 { CronJobSchema } from '../src/web/schemas.js';
|
||||||
import { MAX_CRON_JOBS } from '../src/config/map-limits.js';
|
import { MAX_CRON_JOBS } from '../src/config/map-limits.js';
|
||||||
import type { CronJob, CronJobRun } from '../src/types/cron.js';
|
import type { CronJob, CronJobRun } from '../src/types/cron.js';
|
||||||
@@ -705,3 +711,77 @@ describe('clampCronExternalCliConfigs', () => {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Paste mode used to write `<text>\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<void> => {};
|
||||||
|
|
||||||
|
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();
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user