mirror of
https://github.com/Ark0N/Codeman.git
synced 2026-11-14 02:29:33 +01:00
Claude Code lets a subscription's one-hour prompt cache expire after an hour idle, and the first turn back re-writes the whole conversation at the cache-write rate; nearly every gap over an hour goes cold, while the saving is concentrated in gaps of one to four hours. Codeman now sends one short keep-alive turn five minutes before the expiry Claude Code reports on its statusline, so the next real prompt still reads the conversation from cache, and stops two hours (configurable) after the last real prompt. Codeman owns the idle policy for these sessions, as agreed in Discussion #554: a launch with keep-warm on also carries idleCompaction: false in its one --settings JSON, so Claude Code's own idle compaction cannot replace the conversation with a summary a minute before the nudge. It is decided once per launch (keep-warm on, the statusLine exporter injected, Claude Code 2.1.290 or newer, an unknown version fails closed like --name), recorded on the mux record, and picked up by any relaunch; a launch with keep-warm off is byte-identical. A session switched on after launch is kept warm best-effort and the readout says so. Every guard lives in one pure rulebook: the opt-in (an agent-spawned session does not inherit the app default), a launch that reports cache state, a warm 1h cache with enough tokens at stake and no compaction this gap, a live pane, no usage-limit pause, no respawn, Ralph, task or waiting agent, idle, no open dialog, an empty composer and no recent keystroke. The nudge is a submit and moves lastSubmitAt, which the response viewer's conversation correlation needs; the cap counts from its own persisted lastRealPromptAt instead. Read My Mind drops the exact nudge text, and the turn's stop hook and idle prompt open pre-acknowledged so nothing alerts. The last cache report and the clock survive a restart, so the timer re-arms.
77 lines
3.0 KiB
TypeScript
77 lines
3.0 KiB
TypeScript
/**
|
|
* `POST /api/sessions/:id/keep-warm` (session-routes.ts): the per-session opt-in for the
|
|
* prompt-cache keep-warm. Stores the choice, persists and broadcasts the session, asks the
|
|
* scheduler to re-evaluate, clears the choice on `null`, and refuses a CLI with no cache
|
|
* telemetry. app.inject(), no port.
|
|
*/
|
|
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
|
import Fastify, { type FastifyInstance } from 'fastify';
|
|
import fastifyCookie from '@fastify/cookie';
|
|
import { createMockRouteContext, type MockRouteContext } from '../mocks/index.js';
|
|
import { installRouteErrorHandler } from '../../src/web/route-error-handler.js';
|
|
import { registerSessionRoutes } from '../../src/web/routes/session-routes.js';
|
|
|
|
interface Harness {
|
|
app: FastifyInstance;
|
|
ctx: MockRouteContext;
|
|
}
|
|
|
|
async function createHarness(): Promise<Harness> {
|
|
const app = Fastify({ logger: false });
|
|
await app.register(fastifyCookie);
|
|
const ctx = createMockRouteContext();
|
|
registerSessionRoutes(app, ctx);
|
|
installRouteErrorHandler(app);
|
|
await app.ready();
|
|
return { app, ctx };
|
|
}
|
|
|
|
describe('POST /api/sessions/:id/keep-warm', () => {
|
|
let harness: Harness;
|
|
|
|
beforeEach(async () => {
|
|
harness = await createHarness();
|
|
});
|
|
|
|
afterEach(async () => {
|
|
await harness.app.close();
|
|
});
|
|
|
|
const post = (payload: unknown) =>
|
|
harness.app.inject({ method: 'POST', url: `/api/sessions/${harness.ctx._sessionId}/keep-warm`, payload });
|
|
|
|
it('stores the choice, persists, broadcasts and re-evaluates', async () => {
|
|
const res = await post({ enabled: true });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(JSON.parse(res.body).data.keepWarm).toMatchObject({ enabled: true });
|
|
expect(harness.ctx._session.keepWarmEnabled).toBe(true);
|
|
expect(harness.ctx.persistSessionState).toHaveBeenCalled();
|
|
expect(harness.ctx.keepWarm.schedule).toHaveBeenCalledWith(harness.ctx._sessionId);
|
|
// The re-evaluation runs first, so the broadcast state and the response carry the new status.
|
|
expect(harness.ctx.keepWarm.schedule.mock.invocationCallOrder[0]).toBeLessThan(
|
|
harness.ctx.persistSessionState.mock.invocationCallOrder[0]
|
|
);
|
|
});
|
|
|
|
it('clears the choice on null so the app-wide default applies again', async () => {
|
|
await post({ enabled: false });
|
|
const res = await post({ enabled: null });
|
|
expect(res.statusCode).toBe(200);
|
|
expect(harness.ctx._session.keepWarmEnabled).toBeUndefined();
|
|
expect(JSON.parse(res.body).data.keepWarm.enabled).toBeNull();
|
|
});
|
|
|
|
it('rejects a body without enabled', async () => {
|
|
expect((await post({})).statusCode).toBe(400);
|
|
});
|
|
|
|
it('refuses a CLI that reports no cache telemetry', async () => {
|
|
harness.ctx._session.mode = 'shell';
|
|
const res = await post({ enabled: true });
|
|
// This harness has no envelope hook, so the refusal shows in the body, not the status.
|
|
expect(JSON.parse(res.body)).toMatchObject({ success: false, errorCode: 'INVALID_INPUT' });
|
|
expect(harness.ctx._session.keepWarmEnabled).toBeUndefined();
|
|
expect(harness.ctx.keepWarm.schedule).not.toHaveBeenCalled();
|
|
});
|
|
});
|