Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
26 changes: 17 additions & 9 deletions packages/core/src/__tests__/scheduled-task.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -147,11 +147,10 @@ describe('scheduled-task catalog', () => {
assert.deepEqual(result, { ok: false, message: 'Schedule must fire before expiresAt' });
});

it('refuses to create an Automation on the retired backend', () => {
// #3211: this is create/update input, not a decoder — stored Automations
// are read back with JSON.parse and never reach here. Accepting `'fake'`
// would let a brand new Automation be written that can only fail later at
// activation.
it('drops a backend key from Automation create input', () => {
// #3306: `backend` left the template. A caller still sending one — any
// value, including the retired `'fake'` (#3211) — must not get it frozen
// into a new record.
const now = Date.UTC(2026, 0, 5, 8, 0, 0);
const execution = {
cwd: '/tmp/project',
Expand All @@ -161,20 +160,29 @@ describe('scheduled-task catalog', () => {
collaborationMode: 'agent',
orchestrationMode: 'default',
};
const create = (backend: string) =>
const create = (backend?: string) =>
normalizeCreateScheduledTaskInput(
{
title: 'Nightly run',
intentBody: 'do the thing',
schedule: { kind: 'once', runAt: now + 60_000 },
effect: { kind: 'agent_run', execution: { ...execution, backend } },
effect: {
kind: 'agent_run',
execution: backend === undefined ? execution : { ...execution, backend },
},
createdBy: { kind: 'user' },
},
now,
);

assert.deepEqual(create('fake'), { ok: false, message: 'execution.backend is invalid' });
assert.equal(create('ai-sdk').ok, true);
for (const backend of [undefined, 'ai-sdk', 'fake']) {
const result = create(backend);
assert.equal(result.ok, true);
if (!result.ok) return;
assert.equal(result.value.effect.kind, 'agent_run');
if (result.value.effect.kind !== 'agent_run') return;
assert.equal('backend' in result.value.effect.execution, false);
}
});

it('rejects future recurrence anchors outside the scheduling horizon', () => {
Expand Down
11 changes: 0 additions & 11 deletions packages/core/src/scheduled-task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import { isOrchestrationMode, type OrchestrationMode } from './orchestration.js'
import { isThinkingLevel, type ThinkingLevel } from './model-thinking.js';
import { isPermissionMode, type PermissionMode } from './permission.js';
import { isBotDeliveryProvider, type BotProvider } from './bot-chat-settings.js';
import type { PersistedBackendKind } from './session.js';

export const SCHEDULED_TASK_TITLE_MAX_CHARS = 120;
export const SCHEDULED_TASK_INTENT_MAX_CHARS = 8_000;
Expand Down Expand Up @@ -52,7 +51,6 @@ export type ScheduledTaskEffect =
export interface ScheduledTaskExecutionTemplate {
readonly cwd: string;
readonly projectId?: string | null;
readonly backend: PersistedBackendKind;
readonly llmConnectionSlug: string;
readonly model: string;
readonly thinkingLevel?: ThinkingLevel;
Expand Down Expand Up @@ -488,14 +486,6 @@ function normalizeExecution(
): ScheduledTaskNormalizeResult<ScheduledTaskExecutionTemplate> {
if (!isObject(value)) return fail('agent_run requires execution template');
if (typeof value.cwd !== 'string' || !value.cwd.trim()) return fail('execution.cwd is required');
if (typeof value.backend !== 'string') return fail('execution.backend is required');
// Create/update input, not a decoder: stored Automations are read back with
// `JSON.parse` in scheduled-task-store.ts and never pass through here. So the
// retired `'fake'` is refused (#3211) — accepting it would let a brand new
// Automation be written that can only fail later at activation.
if (value.backend !== 'ai-sdk') {
return fail('execution.backend is invalid');
}
if (typeof value.llmConnectionSlug !== 'string' || !value.llmConnectionSlug.trim()) {
return fail('execution.llmConnectionSlug is required');
}
Expand Down Expand Up @@ -527,7 +517,6 @@ function normalizeExecution(
value: {
cwd: value.cwd.trim(),
...(projectId === undefined ? {} : { projectId }),
backend: value.backend,
llmConnectionSlug: value.llmConnectionSlug.trim(),
model: value.model.trim(),
...(value.thinkingLevel === undefined ? {} : { thinkingLevel: value.thinkingLevel }),
Expand Down
12 changes: 6 additions & 6 deletions packages/runtime-host/src/__tests__/host-kernel.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1719,7 +1719,7 @@ describe('non-serving Runtime Host kernel', () => {
});
});

test('rejects a handshake whose compatibility epoch does not match', async () => {
test('rejects a previous-epoch Client before admitting ScheduledTask commands', async () => {
await withHostPaths(async (paths) => {
const candidate = await startTestRuntimeHostCandidate(paths, {
rootPath: paths.root,
Expand All @@ -1732,10 +1732,10 @@ describe('non-serving Runtime Host kernel', () => {
try {
await writeClientFrame(transport, {
kind: 'hello',
clientInstanceId: 'epoch-mismatch-client',
clientInstanceId: 'previous-epoch-client',
protocolMin: CURRENT_PROTOCOL.min,
protocolMax: CURRENT_PROTOCOL.max,
compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH + 1,
compatibilityEpoch: RUNTIME_HOST_COMPATIBILITY_EPOCH - 1,
compositionId: 'maka.interactive',
});
const response = decodeHostFrame(await transport.read(2_000));
Expand All @@ -1747,9 +1747,9 @@ describe('non-serving Runtime Host kernel', () => {
await assert.rejects(
() =>
writeClientFrame(transport, {
requestId: 'post-epoch-mismatch-status',
operation: 'host.status',
input: {},
requestId: 'post-epoch-mismatch-scheduled-task-query',
operation: 'scheduled-task.query',
input: { kind: 'list' },
}),
(error: unknown) => error instanceof RuntimeHostTransportError && error.code === 'closed',
);
Expand Down
6 changes: 5 additions & 1 deletion packages/runtime-host/src/__tests__/protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -99,7 +99,11 @@ describe('Runtime Host bootstrap protocol', () => {
});

test('publishes a new compatibility epoch for sandbox failure results', () => {
assert.equal(RUNTIME_HOST_COMPATIBILITY_EPOCH, 33);
assert.ok(RUNTIME_HOST_COMPATIBILITY_EPOCH > 32);
});

test('publishes a new compatibility epoch for backend-free ScheduledTask templates', () => {
assert.equal(RUNTIME_HOST_COMPATIBILITY_EPOCH, 34);
});

test('selects the highest mutually supported protocol and rejects a gap', () => {
Expand Down
63 changes: 36 additions & 27 deletions packages/runtime-host/src/__tests__/scheduled-task-protocol.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,7 +12,7 @@ import {
authorizeRuntimeHostOperation,
createRuntimeHostConnectionAuthority,
} from '../server/connection-authority.js';
import { decodeScheduledTask, decodeScheduledTaskMutateInput } from '../protocol/scheduled-task.js';
import { decodeScheduledTaskMutateInput } from '../protocol/scheduled-task.js';

describe('ScheduledTask protocol', () => {
test('requires Host-path authority only when a mutation submits a Host path', () => {
Expand All @@ -35,37 +35,47 @@ describe('ScheduledTask protocol', () => {
}
});

test('a retired backend decodes on the way out but not on the way in', () => {
// #3211: the same execution decoder serves both directions, and they carry
// different backend invariants. A stored Automation frozen by a build that
// shipped FakeBackend must stay readable; a live create/update may not
// introduce the retired value.
const retired = (effect: ScheduledTaskEffect): ScheduledTaskEffect =>
test('a backend key from an older build is tolerated and dropped, both directions', () => {
// #3306: `backend` left the template, but Automations frozen by older
// builds still carry it — including the retired `'fake'` (#3211). The
// execution decoder is a closed shape, so the key must stay tolerated on
// the way in while never landing on the decoded value.
const withRetiredBackend = (effect: ScheduledTaskEffect): unknown =>
effect.kind === 'agent_run'
? { ...effect, execution: { ...effect.execution, backend: 'fake' } }
: effect;
const template = agentRunEffect('project-1');
const expectedExecution = template.kind === 'agent_run' ? template.execution : assert.fail();
const assertDropped = (effect: ScheduledTaskEffect | undefined) => {
assert.equal(effect?.kind, 'agent_run');
if (effect?.kind !== 'agent_run') return;
assert.deepEqual(effect.execution, expectedExecution);
};

const stored = { ...scheduledTask('task-1'), effect: retired(agentRunEffect('project-1')) };
assert.equal(decodeScheduledTask(stored).effect.kind, 'agent_run');
// Stored direction, through the full query-result frame.
const fetched = decodeScheduledTaskQueryResult({
kind: 'task',
task: { ...scheduledTask('task-1'), effect: withRetiredBackend(template) },
});
assertDropped(fetched.kind === 'task' ? fetched.task?.effect : undefined);

for (const input of [
{
kind: 'create' as const,
input: {
title: 'Inspect workspace',
intentBody: 'Summarize the workspace.',
schedule: { kind: 'once' as const, runAt: 1 },
effect: retired(agentRunEffect('project-1')),
},
},
{
kind: 'update' as const,
taskId: 'task-1',
patch: { effect: retired(agentRunEffect('project-1')) },
const created = decodeScheduledTaskMutateInput({
kind: 'create',
input: {
title: 'Inspect workspace',
intentBody: 'Summarize the workspace.',
schedule: { kind: 'once', runAt: 1 },
effect: withRetiredBackend(template),
},
]) {
assert.throws(() => decodeScheduledTaskMutateInput(input), /Invalid ScheduledTask backend/);
}
});
assertDropped(created.kind === 'create' ? created.input.effect : undefined);

const updated = decodeScheduledTaskMutateInput({
kind: 'update',
taskId: 'task-1',
patch: { effect: withRetiredBackend(template) },
});
assertDropped(updated.kind === 'update' ? updated.patch.effect : undefined);
});

test('accepts signal-only catalog changes', () => {
Expand Down Expand Up @@ -140,7 +150,6 @@ function agentRunEffect(projectId: string | null | undefined): ScheduledTaskEffe
execution: {
cwd: '/workspace',
...(projectId === undefined ? {} : { projectId }),
backend: 'ai-sdk',
llmConnectionSlug: 'openai',
model: 'gpt-5',
permissionMode: 'ask',
Expand Down
5 changes: 4 additions & 1 deletion packages/runtime-host/src/protocol/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -72,7 +72,10 @@ export const RUNTIME_HOST_REGISTRATION_SCHEMA_VERSION = 1 as const;
export const RUNTIME_HOST_PROTOCOL_VERSION = 0 as const;
// Increment when the same protocol version no longer guarantees safe Client-Host
// interoperability. Mismatches are rejected before domain commands are admitted.
export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 33 as const;
export const RUNTIME_HOST_COMPATIBILITY_EPOCH = 34 as const;
// 34: ScheduledTask execution templates no longer emit `backend`. Epoch-33
// Clients require that closed-shape response field, so a newer Host must reject
// them during the handshake instead of failing on the first Automation read.
// 33: Live tool results may carry the bounded sandbox failure reason. Older
// Clients reject that closed-frame addition, so mixed peers must not connect.
// 32: `request_authorization_code` leaves the OAuth presentation wire. An older
Expand Down
36 changes: 10 additions & 26 deletions packages/runtime-host/src/protocol/scheduled-task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,7 +24,6 @@ import {
type UpdateScheduledTaskInput,
} from '@maka/core/scheduled-task';
import { isThinkingLevel } from '@maka/core/model-thinking';
import type { PersistedBackendKind } from '@maka/core/session';
import {
requireCount,
requireEncodedByteLimit,
Expand Down Expand Up @@ -304,7 +303,7 @@ export function decodeScheduledTask(value: unknown): ScheduledTask {
body: boundedText(intent.body, 'ScheduledTask intent body', SCHEDULED_TASK_INTENT_MAX_CHARS),
},
schedule: decodeSchedule(task.schedule),
effect: decodeEffect(task.effect, 'stored'),
effect: decodeEffect(task.effect),
status: task.status,
nextFireAt: nullableCount(task.nextFireAt, 'ScheduledTask nextFireAt'),
lastFireAt: nullableCount(task.lastFireAt, 'ScheduledTask lastFireAt'),
Expand Down Expand Up @@ -341,7 +340,7 @@ function decodeCreateInput(value: unknown): Omit<CreateScheduledTaskInput, 'crea
SCHEDULED_TASK_INTENT_MAX_CHARS,
),
schedule: decodeSchedule(input.schedule),
effect: decodeEffect(input.effect, 'mutation'),
effect: decodeEffect(input.effect),
...(Object.hasOwn(input, 'maxFires')
? { maxFires: nullablePositiveCount(input.maxFires, 'ScheduledTask maxFires') }
: {}),
Expand Down Expand Up @@ -380,7 +379,7 @@ function decodeUpdateInput(value: unknown): UpdateScheduledTaskInput {
}
: {}),
...(Object.hasOwn(patch, 'schedule') ? { schedule: decodeSchedule(patch.schedule) } : {}),
...(Object.hasOwn(patch, 'effect') ? { effect: decodeEffect(patch.effect, 'mutation') } : {}),
...(Object.hasOwn(patch, 'effect') ? { effect: decodeEffect(patch.effect) } : {}),
...(Object.hasOwn(patch, 'maxFires')
? { maxFires: nullablePositiveCount(patch.maxFires, 'ScheduledTask maxFires') }
: {}),
Expand Down Expand Up @@ -454,7 +453,7 @@ function decodeSchedule(value: unknown): ScheduledTaskSchedule {
throw invalidProtocolFrame('Invalid ScheduledTask schedule');
}

function decodeEffect(value: unknown, origin: BackendOrigin): ScheduledTaskEffect {
function decodeEffect(value: unknown): ScheduledTaskEffect {
const effect = requireRecord(value, 'ScheduledTask effect');
if (effect.kind === 'notify') {
if (effect.channel === 'local') {
Expand Down Expand Up @@ -490,7 +489,7 @@ function decodeEffect(value: unknown, origin: BackendOrigin): ScheduledTaskEffec
'kind',
'execution',
]);
return { kind: 'agent_run', execution: decodeExecution(exact.execution, origin) };
return { kind: 'agent_run', execution: decodeExecution(exact.execution) };
}
if (effect.kind === 'session_resume') {
const exact = requireExactRecord(effect, 'ScheduledTask Session resume effect', [
Expand All @@ -510,23 +509,23 @@ function decodeEffect(value: unknown, origin: BackendOrigin): ScheduledTaskEffec
throw invalidProtocolFrame('Invalid ScheduledTask effect');
}

function decodeExecution(value: unknown, origin: BackendOrigin): ScheduledTaskExecutionTemplate {
function decodeExecution(value: unknown): ScheduledTaskExecutionTemplate {
// `backend` left the template (#3306), but templates frozen by older builds
// still carry it and this is a closed shape: the key must stay tolerated on
// the way in, and it never lands on the decoded value.
const execution = requireShapedRecord(
value,
'ScheduledTask execution template',
[
'cwd',
'backend',
'llmConnectionSlug',
'model',
'permissionMode',
'collaborationMode',
'orchestrationMode',
],
['projectId', 'thinkingLevel'],
['projectId', 'thinkingLevel', 'backend'],
Comment thread
yihanzhu marked this conversation as resolved.
);
if (!isBackendFor(origin, execution.backend))
throw invalidProtocolFrame('Invalid ScheduledTask backend');
if (!isPermissionMode(execution.permissionMode)) {
throw invalidProtocolFrame('Invalid ScheduledTask permission mode');
}
Expand All @@ -551,7 +550,6 @@ function decodeExecution(value: unknown, origin: BackendOrigin): ScheduledTaskEx
...(Object.hasOwn(execution, 'projectId')
? { projectId: execution.projectId as string | null }
: {}),
backend: execution.backend,
llmConnectionSlug: boundedText(
execution.llmConnectionSlug,
'ScheduledTask connection slug',
Expand Down Expand Up @@ -634,17 +632,3 @@ function nullablePositiveCount(value: unknown, label: string): number | null {
if (count === 0) throw invalidProtocolFrame(`Invalid ${label}`);
return count;
}

/**
* Which direction an execution template is crossing the protocol.
*
* The same decoder serves both, but they carry different backend invariants:
* a `'stored'` template is durable data that may have been frozen by a build
* shipping FakeBackend and must stay decodable, while a `'mutation'` template
* is a live write and may not introduce the retired value (#3211).
*/
type BackendOrigin = 'stored' | 'mutation';

function isBackendFor(origin: BackendOrigin, value: unknown): value is PersistedBackendKind {
return value === 'ai-sdk' || (origin === 'stored' && value === 'fake');
}
Original file line number Diff line number Diff line change
Expand Up @@ -781,7 +781,6 @@ function executionTemplateFromHeader(header: SessionHeader): ScheduledTaskExecut
return {
cwd: header.cwd,
...(header.projectId === undefined ? {} : { projectId: header.projectId }),
backend: header.backend,
llmConnectionSlug: header.llmConnectionSlug,
model: header.model,
...(header.thinkingLevel === undefined ? {} : { thinkingLevel: header.thinkingLevel }),
Expand Down