Skip to content
Open
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
43 changes: 43 additions & 0 deletions packages/core/src/__tests__/scheduled-task.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,7 @@ describe('scheduled-task catalog', () => {
const now = Date.UTC(2026, 0, 5, 8, 0, 0);
const execution = {
cwd: '/tmp/project',
llmConnectionId: 'connection-anthropic',
llmConnectionSlug: 'anthropic',
model: 'claude-sonnet-4-5-20250929',
permissionMode: 'ask',
Expand Down Expand Up @@ -206,6 +207,34 @@ describe('scheduled-task catalog', () => {
}
});

it('requires an immutable Connection identity for new Agent tasks', () => {
const now = Date.UTC(2026, 0, 5, 8, 0, 0);
const result = normalizeCreateScheduledTaskInput(
{
title: 'Missing identity',
intentBody: 'run',
schedule: { kind: 'once', runAt: now + 60_000 },
effect: {
kind: 'agent_run',
execution: {
cwd: '/tmp/project',
llmConnectionSlug: 'anthropic',
model: 'claude',
permissionMode: 'ask',
collaborationMode: 'agent',
orchestrationMode: 'default',
},
},
createdBy: { kind: 'user' },
},
now,
);
assert.deepEqual(result, {
ok: false,
message: 'execution.llmConnectionId is required',
});
});

it('rejects future recurrence anchors outside the scheduling horizon', () => {
const now = Date.UTC(2026, 0, 5, 8, 0, 0);
for (const schedule of [
Expand Down Expand Up @@ -240,6 +269,7 @@ describe('decodePersistedScheduledTask', () => {
kind: 'agent_run',
execution: {
cwd: '/repo',
llmConnectionId: 'connection-anthropic',
llmConnectionSlug: 'anthropic',
model: 'claude',
permissionMode: 'ask',
Expand Down Expand Up @@ -275,6 +305,19 @@ describe('decodePersistedScheduledTask', () => {
assert.equal(decodePersistedScheduledTask(markPersisted<ScheduledTask>(base)), base);
});

it('keeps legacy slug-only Agent tasks readable', () => {
const { llmConnectionId: _legacyId, ...legacyExecution } =
base.effect.kind === 'agent_run' ? base.effect.execution : {};
const legacy = {
...base,
effect: { kind: 'agent_run' as const, execution: legacyExecution },
} as ScheduledTask;
const decoded = decodePersistedScheduledTask(markPersisted<ScheduledTask>(legacy));
assert.equal(decoded.effect.kind, 'agent_run');
if (decoded.effect.kind !== 'agent_run') return;
assert.equal(decoded.effect.execution.llmConnectionId, undefined);
});

it('leaves effects without an execution template alone', () => {
const notify: ScheduledTask = { ...base, effect: { kind: 'notify', channel: 'local' } };
assert.equal(decodePersistedScheduledTask(markPersisted<ScheduledTask>(notify)), notify);
Expand Down
7 changes: 6 additions & 1 deletion packages/core/src/agent-run.ts
Original file line number Diff line number Diff line change
Expand Up @@ -87,7 +87,12 @@ export type RootExecutionDescriptor =
}
| { kind: 'regenerate'; sourceTurnId: string }
| { kind: 'context_compact' }
| { kind: 'scheduled_task'; scheduledTaskId: string }
| {
kind: 'scheduled_task';
scheduledTaskId: string;
/** Includes the immutable Connection target for Agent ScheduledTasks. */
executionFingerprint?: `sha256:${string}`;
}
| { kind: 'legacy_automation'; automationId: string }
| { kind: 'goal'; goalId: string }
| {
Expand Down
6 changes: 6 additions & 0 deletions packages/core/src/scheduled-task.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,8 @@ export type ScheduledTaskEffect =
export interface ScheduledTaskExecutionTemplate {
readonly cwd: string;
readonly projectId?: string | null;
/** Immutable Connection entity identity. Omitted only on legacy slug-only rows. */
readonly llmConnectionId?: string;
readonly llmConnectionSlug: string;
readonly model: string;
readonly thinkingLevel?: ThinkingLevel;
Expand Down Expand Up @@ -510,6 +512,9 @@ 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.llmConnectionId !== 'string' || !value.llmConnectionId.trim()) {
return fail('execution.llmConnectionId is required');
}
if (typeof value.llmConnectionSlug !== 'string' || !value.llmConnectionSlug.trim()) {
return fail('execution.llmConnectionSlug is required');
}
Expand Down Expand Up @@ -541,6 +546,7 @@ function normalizeExecution(
value: {
cwd: value.cwd.trim(),
...(projectId === undefined ? {} : { projectId }),
llmConnectionId: value.llmConnectionId.trim(),
llmConnectionSlug: value.llmConnectionSlug.trim(),
model: value.model.trim(),
...(value.thinkingLevel === undefined ? {} : { thinkingLevel: value.thinkingLevel }),
Expand Down
125 changes: 123 additions & 2 deletions packages/runtime-host/src/__tests__/execution-host.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { createServer, type Server } from 'node:http';
import { connect, type Socket } from 'node:net';
import { tmpdir } from 'node:os';
import { dirname, join } from 'node:path';
import { DatabaseSync } from 'node:sqlite';
import { test } from 'node:test';
import { TOOL_BOUNDARY_PROTOCOL_V1 } from '@maka/core/runtime-event';
import { canonicalToolArgsHash } from '@maka/core/tool-args-identity';
Expand Down Expand Up @@ -66,6 +67,7 @@ import {
openInteractiveExecutionStoresForRead,
openInteractiveExecutionStoresForWrite,
} from '@maka/storage/execution-stores';
import { OPERATIONAL_STATE_DATABASE_NAME } from '@maka/storage/operational-state-store';
import { openInteractiveRuntimePolicyStoresForWrite } from '@maka/storage/runtime-policy-stores';
import {
resolveRootControlNamespace,
Expand Down Expand Up @@ -162,6 +164,10 @@ test('production Host fails slug-only ScheduledTask Agent runs before binding ex
timeout: 30_000,
}, async () => {
await withExecutionRoot(async (fixture) => {
const seededConnection = await fixture.seedConnectionEffect(
'http://127.0.0.1:1',
'test-secret',
);
const host = await fixture.startHost();
const desktop = await connectClient(fixture.root);
try {
Expand All @@ -175,8 +181,9 @@ test('production Host fails slug-only ScheduledTask Agent runs before binding ex
kind: 'agent_run',
execution: {
cwd: fixture.root,
llmConnectionSlug: 'fake',
model: 'fake-model',
llmConnectionId: seededConnection.connectionId,
llmConnectionSlug: seededConnection.slug,
model: seededConnection.enabledModelIds[0]!,
permissionMode: 'ask',
collaborationMode: 'agent',
orchestrationMode: 'default',
Expand All @@ -187,6 +194,21 @@ test('production Host fails slug-only ScheduledTask Agent runs before binding ex
assert.equal(created.kind, 'task');
if (created.kind !== 'task') return;

// Simulate a record written by a pre-#3927 build. Legacy rows remain
// readable, but must fail closed before a Session or AgentRun is bound.
const database = new DatabaseSync(join(fixture.root, OPERATIONAL_STATE_DATABASE_NAME));
try {
database
.prepare(
`UPDATE workflow_scheduled_tasks
SET record_json = json_remove(record_json, '$.effect.execution.llmConnectionId')
WHERE task_id = ?`,
)
.run(created.task.id);
} finally {
database.close();
}

const fired = await desktop.request('scheduled-task.mutate', {
kind: 'trigger_now',
taskId: created.task.id,
Expand All @@ -208,6 +230,105 @@ test('production Host fails slug-only ScheduledTask Agent runs before binding ex
});
});

test('two UDS Clients never rebind an Agent ScheduledTask after Connection slug reuse', {
timeout: 30_000,
}, async () => {
await withExecutionRoot(async (fixture) => {
const original = await fixture.seedConnectionEffect('http://127.0.0.1:1', 'test-secret');
const model = original.enabledModelIds[0]!;
const host = await fixture.startHost();
const creator = await connectClient(fixture.root);
const trigger = await connectClient(fixture.root);
try {
const created = await creator.request('scheduled-task.mutate', {
kind: 'create',
input: {
title: 'Connection slug reuse proof',
intentBody: 'The deleted account must never be replaced silently.',
schedule: { kind: 'once', runAt: Date.now() + 60_000 },
effect: {
kind: 'agent_run',
execution: {
cwd: fixture.root,
llmConnectionId: original.connectionId,
llmConnectionSlug: original.slug,
model,
permissionMode: 'ask',
collaborationMode: 'agent',
orchestrationMode: 'default',
},
},
},
});
assert.equal(created.kind, 'task');
if (created.kind !== 'task') return;

const catalog = await trigger.request('connection.catalog.query', { kind: 'start' });
assert.equal(catalog.kind, 'page');
if (catalog.kind !== 'page') return;
const header = catalog.items.find(
(item) => item.kind === 'connection' && item.connectionId === original.connectionId,
);
assert.equal(header?.kind, 'connection');
if (header?.kind !== 'connection') return;

const removed = await trigger.request('connection.catalog.remove', {
expected: { connectionId: original.connectionId, revision: header.revision },
});
assert.equal(removed.kind, 'committed');
if (removed.kind !== 'committed') return;
const replacement = await trigger.request('connection.catalog.create', {
expectedCatalogRevision: removed.catalogRevision,
connection: {
slug: original.slug,
name: 'Replacement account',
providerType: original.providerType,
...(original.baseUrl === undefined ? {} : { baseUrl: original.baseUrl }),
enabled: true,
enabledModelIds: [model],
},
});
assert.equal(replacement.kind, 'committed');
if (replacement.kind !== 'committed') return;
assert.notEqual(replacement.connection.connectionId, original.connectionId);

const fired = await trigger.request('scheduled-task.mutate', {
kind: 'trigger_now',
taskId: created.task.id,
});
assert.equal(fired.kind, 'task');
if (fired.kind !== 'task') return;
assert.equal(fired.task.runs[0]?.outcome, 'failed');
assert.equal(fired.task.lastError, 'ScheduledTask model connection identity changed');
const failedRun = fired.task.runs[0];
assert.ok(failedRun?.sessionId);
assert.ok(failedRun?.runId);
const databaseAfterFire = new DatabaseSync(
join(fixture.root, OPERATIONAL_STATE_DATABASE_NAME),
);
try {
assert.equal(
databaseAfterFire
.prepare('SELECT 1 AS present FROM session_metadata WHERE session_id = ?')
.get(failedRun.sessionId),
undefined,
);
assert.equal(
databaseAfterFire
.prepare('SELECT 1 AS present FROM core_agent_runs WHERE run_id = ?')
.get(failedRun.runId),
undefined,
);
} finally {
databaseAfterFire.close();
}
} finally {
await Promise.allSettled([creator.close(), trigger.close()]);
await fixture.stopHost(host);
}
});
});

test('production Host settles dispatched Client Capabilities before publishing Ready', async () => {
await withExecutionRoot(async (fixture) => {
const prepared = await seedDispatchedClientCapability(fixture);
Expand Down
Loading