Skip to content
Closed
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
4 changes: 3 additions & 1 deletion .claude/skills/env-reference/SKILL.md
Original file line number Diff line number Diff line change
Expand Up @@ -117,9 +117,11 @@ See `apps/api/.env.example` for the full list. Key variables:
- `ACP_ACTIVITY_BINDING_CACHE_MAX_ENTRIES` — Maximum cached ACP activity bindings retained by one Worker isolate (default: `2048`)

- `ACP_INTERACTIONS_ENABLED` — Dormant durable ACP interaction foundation kill switch. Slice A defaults this to `false`; later slices must intentionally enable producers/consumers (default: `false`)
- `ACP_INTERACTION_PERMISSION_TASK_DEADLINE_MS` / `ACP_INTERACTION_PERMISSION_CONVERSATION_DEADLINE_MS` / `ACP_INTERACTION_DEADLINE_MARGIN_MS` — Runtime permission deadlines for task and conversation sessions and the margin before an enclosing prompt deadline (defaults: `1800000` / `7200000` / `60000`)
- `ACP_INTERACTION_MAX_DEADLINE_MS` — Absolute deadline ceiling for runtime-created requests (default: `14400000`)
- `ACP_INTERACTION_MAX_PENDING_PER_SESSION` — Maximum pending durable ACP interactions per chat (default: `8`)
- `ACP_INTERACTION_REQUEST_MAX_BYTES`, `ACP_INTERACTION_OPTIONS_MAX_COUNT`, `ACP_INTERACTION_OPTION_NAME_MAX_CHARS`, `ACP_INTERACTION_FORM_SCHEMA_MAX_BYTES`, `ACP_INTERACTION_FORM_SCHEMA_MAX_PROPERTIES`, `ACP_INTERACTION_FORM_SCHEMA_MAX_ENUM` — Request/detail and schema bounds for encrypted ACP interaction payloads (defaults: `32768`, `16`, `200`, `16384`, `20`, `50`)
- `ACP_INTERACTION_REQUEST_MAX_BYTES`, `ACP_INTERACTION_OPTIONS_MAX_COUNT`, `ACP_INTERACTION_OPTION_ID_MAX_CHARS`, `ACP_INTERACTION_OPTION_NAME_MAX_CHARS`, `ACP_INTERACTION_FORM_SCHEMA_MAX_BYTES`, `ACP_INTERACTION_FORM_SCHEMA_MAX_PROPERTIES`, `ACP_INTERACTION_FORM_SCHEMA_MAX_ENUM` — Request/detail and schema bounds for encrypted ACP interaction payloads (defaults: `32768`, `16`, `128`, `200`, `16384`, `20`, `50`)
- `ACP_INTERACTION_RUNTIME_RECEIPT_LIMIT` / `ACP_INTERACTION_RUNTIME_RESPONSE_MAX_BYTES` — Per-SessionHost idempotency receipt bound and maximum create/settle response body read by the runtime (defaults: `256` / `65536`)
- `ACP_INTERACTION_ANSWER_MAX_BYTES` / `ACP_INTERACTION_ANSWER_STRING_MAX_BYTES` — Answer decision and individual answer string bounds before encrypted storage (defaults: `16384` / `4096`)
- `ACP_INTERACTION_RETRY_DELAYS_MS` / `ACP_INTERACTION_RETRY_STEADY_MS` / `ACP_INTERACTION_DELIVERY_WINDOW_MS` — Durable answer outbox retry sequence, steady retry delay, and max post-answer retry window (defaults: `1000,5000,30000,120000,300000`, `300000`, `900000`)
- `ACP_INTERACTION_SENSITIVE_PURGE_MS`, `ACP_INTERACTION_SUMMARY_RETENTION_MS`, `ACP_INTERACTION_SUMMARY_LAST_SETTLED`, `ACP_INTERACTION_SNAPSHOT_LAST_SETTLED` — Sensitive encrypted payload purge, settled summary retention, and bounded snapshot controls (defaults: `3600000`, `2592000000`, `100`, `20`)
Expand Down
6 changes: 6 additions & 0 deletions apps/api/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -799,11 +799,17 @@ INFOMANIAK_IP_POLL_INTERVAL_MS=3000
# ACP_ACTIVITY_BINDING_CACHE_MAX_ENTRIES=2048 # Max cached ACP activity bindings per Worker isolate
# Dormant durable ACP interaction foundation. Slice A keeps this disabled; later slices wire runtime/UI producers and consumers.
# ACP_INTERACTIONS_ENABLED=false # Fail-closed feature flag for durable ACP interaction creation
# ACP_INTERACTION_PERMISSION_TASK_DEADLINE_MS=1800000 # Runtime permission deadline for task sessions (30m)
# ACP_INTERACTION_PERMISSION_CONVERSATION_DEADLINE_MS=7200000 # Runtime permission deadline for conversation sessions (2h)
# ACP_INTERACTION_MAX_DEADLINE_MS=14400000 # Absolute max request deadline (4h)
# ACP_INTERACTION_DEADLINE_MARGIN_MS=60000 # Margin before an enclosing prompt deadline
# ACP_INTERACTION_MAX_PENDING_PER_SESSION=8 # Pending durable interactions allowed per chat
# ACP_INTERACTION_REQUEST_MAX_BYTES=32768 # Encrypted request detail cap
# ACP_INTERACTION_OPTIONS_MAX_COUNT=16 # Permission option count cap
# ACP_INTERACTION_OPTION_ID_MAX_CHARS=128 # Permission option identifier cap
# ACP_INTERACTION_OPTION_NAME_MAX_CHARS=200 # Permission option display cap
# ACP_INTERACTION_RUNTIME_RECEIPT_LIMIT=256 # In-memory idempotency receipts retained per SessionHost
# ACP_INTERACTION_RUNTIME_RESPONSE_MAX_BYTES=65536 # Max runtime create/settle response body read
# ACP_INTERACTION_FORM_SCHEMA_MAX_BYTES=16384 # Encrypted form schema cap
# ACP_INTERACTION_FORM_SCHEMA_MAX_PROPERTIES=20 # Form schema property cap
# ACP_INTERACTION_FORM_SCHEMA_MAX_ENUM=50 # Form enum option cap
Expand Down
23 changes: 23 additions & 0 deletions apps/api/src/durable-objects/vm-agent-container.ts
Original file line number Diff line number Diff line change
Expand Up @@ -169,6 +169,29 @@ export class VmAgentContainer extends Container<Env> {
return this.proxyHttpAuthorized(request, port);
}

/** Forward only to an already-running runtime; never wake or start recovery. */
async proxyHttpNoWake(request: Request, port?: number): Promise<Response> {
const status = await this.ctx.storage.get<LifecycleStatus>('lifecycleStatus');
if (status !== 'running') {
return recoveryResponse('RUNTIME_STOPPED', RUNTIME_STOPPED_MESSAGE, 410);
}
const container = this.ctx.container;
if (!container?.running) {
return recoveryResponse('RUNTIME_STOPPED', RUNTIME_STOPPED_MESSAGE, 410);
}
try {
// Container.containerFetch() is intentionally forbidden here: the pinned
// SDK starts compute when either the real runtime is stopped or its own
// persisted health state is stale. The direct port primitive never starts
// a container; if stop/crash wins after the running check, fetch rejects.
const tcpPort = container.getTcpPort(port ?? this.defaultPort);
const containerUrl = request.url.replace('https:', 'http:');
return await tcpPort.fetch(containerUrl, request);
} catch {
return interruptedRequestResponse(request);
}
}

private async proxyHttpAuthorized(
request: Request,
port?: number,
Expand Down
6 changes: 6 additions & 0 deletions apps/api/src/env.ts
Original file line number Diff line number Diff line change
Expand Up @@ -846,11 +846,17 @@ export interface Env extends WebhookTriggerEnv, TaskRecoveryEnv {
PROJECT_EVENT_WAKE_MAX_PER_SUBSCRIPTION?: string;
PROJECT_EVENT_SOURCE_OUTBOX_SWEEP_WALL_MS?: string;
ACP_INTERACTIONS_ENABLED?: string;
ACP_INTERACTION_PERMISSION_TASK_DEADLINE_MS?: string;
ACP_INTERACTION_PERMISSION_CONVERSATION_DEADLINE_MS?: string;
ACP_INTERACTION_MAX_DEADLINE_MS?: string;
ACP_INTERACTION_DEADLINE_MARGIN_MS?: string;
ACP_INTERACTION_MAX_PENDING_PER_SESSION?: string;
ACP_INTERACTION_REQUEST_MAX_BYTES?: string;
ACP_INTERACTION_OPTIONS_MAX_COUNT?: string;
ACP_INTERACTION_OPTION_ID_MAX_CHARS?: string;
ACP_INTERACTION_OPTION_NAME_MAX_CHARS?: string;
ACP_INTERACTION_RUNTIME_RECEIPT_LIMIT?: string;
ACP_INTERACTION_RUNTIME_RESPONSE_MAX_BYTES?: string;
ACP_INTERACTION_FORM_SCHEMA_MAX_BYTES?: string;
ACP_INTERACTION_FORM_SCHEMA_MAX_PROPERTIES?: string;
ACP_INTERACTION_FORM_SCHEMA_MAX_ENUM?: string;
Expand Down
19 changes: 15 additions & 4 deletions apps/api/src/routes/projects/acp-interaction-callback.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,14 +64,21 @@ async function verifyWorkspaceCallback(c: {
};
}

async function assertAgentSessionCurrent(env: Env, workspaceId: string, agentSessionId: string) {
async function assertAgentSessionExists(
env: Env,
workspaceId: string,
agentSessionId: string,
requireRunning: boolean
) {
const row = await env.DATABASE.prepare(
`SELECT id, status FROM agent_sessions WHERE id = ? AND workspace_id = ? LIMIT 1`
)
.bind(agentSessionId, workspaceId)
.first<{ id: string; status: string }>();
if (!row) throw errors.notFound('Agent session');
if (row.status !== 'running') throw errors.conflict(`Agent session is ${row.status}`);
if (requireRunning && row.status !== 'running') {
throw errors.conflict(`Agent session is ${row.status}`);
}
}

function settleStatusCode(status: string): 200 | 404 | 409 {
Expand All @@ -90,7 +97,7 @@ acpInteractionCallbackRoute.post(
async (c) => {
const identity = await verifyWorkspaceCallback(c);
const body = c.req.valid('json');
await assertAgentSessionCurrent(c.env, identity.workspaceId, body.agentSessionId);
await assertAgentSessionExists(c.env, identity.workspaceId, body.agentSessionId, true);
const result = await createInteraction(c.env, {
...body,
projectId: identity.projectId,
Expand Down Expand Up @@ -122,7 +129,11 @@ acpInteractionCallbackRoute.post(
if (body.interactionId !== c.req.param('interactionId')) {
throw errors.badRequest('interactionId route/body mismatch');
}
await assertAgentSessionCurrent(c.env, identity.workspaceId, body.agentSessionId);
// A terminal session may race the runtime's final settle callback. The
// InteractionStore still fences settlement by agentSessionId, generation,
// and runtimeIdentity, so accepting the existing row cannot settle another
// runtime's interaction.
await assertAgentSessionExists(c.env, identity.workspaceId, body.agentSessionId, false);
const result = await settleInteraction(c.env, {
...body,
projectId: identity.projectId,
Expand Down
42 changes: 42 additions & 0 deletions apps/api/src/services/acp-interaction-config.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {
DEFAULT_ACP_INTERACTION_ALARM_WALL_TIME_MS,
DEFAULT_ACP_INTERACTION_ANSWER_MAX_BYTES,
DEFAULT_ACP_INTERACTION_ANSWER_STRING_MAX_BYTES,
DEFAULT_ACP_INTERACTION_DEADLINE_MARGIN_MS,
DEFAULT_ACP_INTERACTION_DELIVERY_BATCH_SIZE,
DEFAULT_ACP_INTERACTION_DELIVERY_WINDOW_MS,
DEFAULT_ACP_INTERACTION_EXPIRY_BATCH_SIZE,
Expand All @@ -11,12 +12,17 @@ import {
DEFAULT_ACP_INTERACTION_FORM_SCHEMA_MAX_PROPERTIES,
DEFAULT_ACP_INTERACTION_MAX_DEADLINE_MS,
DEFAULT_ACP_INTERACTION_MAX_PENDING_PER_SESSION,
DEFAULT_ACP_INTERACTION_OPTION_ID_MAX_CHARS,
DEFAULT_ACP_INTERACTION_OPTION_NAME_MAX_CHARS,
DEFAULT_ACP_INTERACTION_OPTIONS_MAX_COUNT,
DEFAULT_ACP_INTERACTION_OUTBOX_BATCH_SIZE,
DEFAULT_ACP_INTERACTION_PERMISSION_CONVERSATION_DEADLINE_MS,
DEFAULT_ACP_INTERACTION_PERMISSION_TASK_DEADLINE_MS,
DEFAULT_ACP_INTERACTION_REQUEST_MAX_BYTES,
DEFAULT_ACP_INTERACTION_RETRY_DELAYS_MS,
DEFAULT_ACP_INTERACTION_RETRY_STEADY_MS,
DEFAULT_ACP_INTERACTION_RUNTIME_RECEIPT_LIMIT,
DEFAULT_ACP_INTERACTION_RUNTIME_RESPONSE_MAX_BYTES,
DEFAULT_ACP_INTERACTION_SENSITIVE_PURGE_MS,
DEFAULT_ACP_INTERACTION_SNAPSHOT_LAST_SETTLED,
DEFAULT_ACP_INTERACTION_SUMMARY_LAST_SETTLED,
Expand All @@ -26,11 +32,17 @@ import {

export interface AcpInteractionConfigEnv {
ACP_INTERACTIONS_ENABLED?: string;
ACP_INTERACTION_PERMISSION_TASK_DEADLINE_MS?: string;
ACP_INTERACTION_PERMISSION_CONVERSATION_DEADLINE_MS?: string;
ACP_INTERACTION_MAX_DEADLINE_MS?: string;
ACP_INTERACTION_DEADLINE_MARGIN_MS?: string;
ACP_INTERACTION_MAX_PENDING_PER_SESSION?: string;
ACP_INTERACTION_REQUEST_MAX_BYTES?: string;
ACP_INTERACTION_OPTIONS_MAX_COUNT?: string;
ACP_INTERACTION_OPTION_ID_MAX_CHARS?: string;
ACP_INTERACTION_OPTION_NAME_MAX_CHARS?: string;
ACP_INTERACTION_RUNTIME_RECEIPT_LIMIT?: string;
ACP_INTERACTION_RUNTIME_RESPONSE_MAX_BYTES?: string;
ACP_INTERACTION_FORM_SCHEMA_MAX_BYTES?: string;
ACP_INTERACTION_FORM_SCHEMA_MAX_PROPERTIES?: string;
ACP_INTERACTION_FORM_SCHEMA_MAX_ENUM?: string;
Expand All @@ -52,11 +64,17 @@ export interface AcpInteractionConfigEnv {

export interface AcpInteractionConfig {
enabled: boolean;
permissionTaskDeadlineMs: number;
permissionConversationDeadlineMs: number;
maxDeadlineMs: number;
deadlineMarginMs: number;
maxPendingPerSession: number;
requestMaxBytes: number;
optionsMaxCount: number;
optionIdMaxChars: number;
optionNameMaxChars: number;
runtimeReceiptLimit: number;
runtimeResponseMaxBytes: number;
formSchemaMaxBytes: number;
formSchemaMaxProperties: number;
formSchemaMaxEnum: number;
Expand Down Expand Up @@ -99,10 +117,22 @@ function positiveIntList(value: string | undefined, fallback: readonly number[])
export function getAcpInteractionConfig(env: AcpInteractionConfigEnv): AcpInteractionConfig {
return {
enabled: envFlag(env.ACP_INTERACTIONS_ENABLED, DEFAULT_ACP_INTERACTIONS_ENABLED),
permissionTaskDeadlineMs: positiveInt(
env.ACP_INTERACTION_PERMISSION_TASK_DEADLINE_MS,
DEFAULT_ACP_INTERACTION_PERMISSION_TASK_DEADLINE_MS
),
permissionConversationDeadlineMs: positiveInt(
env.ACP_INTERACTION_PERMISSION_CONVERSATION_DEADLINE_MS,
DEFAULT_ACP_INTERACTION_PERMISSION_CONVERSATION_DEADLINE_MS
),
maxDeadlineMs: positiveInt(
env.ACP_INTERACTION_MAX_DEADLINE_MS,
DEFAULT_ACP_INTERACTION_MAX_DEADLINE_MS
),
deadlineMarginMs: positiveInt(
env.ACP_INTERACTION_DEADLINE_MARGIN_MS,
DEFAULT_ACP_INTERACTION_DEADLINE_MARGIN_MS
),
maxPendingPerSession: positiveInt(
env.ACP_INTERACTION_MAX_PENDING_PER_SESSION,
DEFAULT_ACP_INTERACTION_MAX_PENDING_PER_SESSION
Expand All @@ -115,10 +145,22 @@ export function getAcpInteractionConfig(env: AcpInteractionConfigEnv): AcpIntera
env.ACP_INTERACTION_OPTIONS_MAX_COUNT,
DEFAULT_ACP_INTERACTION_OPTIONS_MAX_COUNT
),
optionIdMaxChars: positiveInt(
env.ACP_INTERACTION_OPTION_ID_MAX_CHARS,
DEFAULT_ACP_INTERACTION_OPTION_ID_MAX_CHARS
),
optionNameMaxChars: positiveInt(
env.ACP_INTERACTION_OPTION_NAME_MAX_CHARS,
DEFAULT_ACP_INTERACTION_OPTION_NAME_MAX_CHARS
),
runtimeReceiptLimit: positiveInt(
env.ACP_INTERACTION_RUNTIME_RECEIPT_LIMIT,
DEFAULT_ACP_INTERACTION_RUNTIME_RECEIPT_LIMIT
),
runtimeResponseMaxBytes: positiveInt(
env.ACP_INTERACTION_RUNTIME_RESPONSE_MAX_BYTES,
DEFAULT_ACP_INTERACTION_RUNTIME_RESPONSE_MAX_BYTES
),
formSchemaMaxBytes: positiveInt(
env.ACP_INTERACTION_FORM_SCHEMA_MAX_BYTES,
DEFAULT_ACP_INTERACTION_FORM_SCHEMA_MAX_BYTES
Expand Down
19 changes: 19 additions & 0 deletions apps/api/src/services/acp-interaction-delivery.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import {
ACP_INTERACTION_CAPABILITY_VERSION,
type AcpInteractionAnswerDecision,
AcpRuntimeAnswerResponseSchema,
buildAcpInteractionAnswerPath,
Expand Down Expand Up @@ -128,12 +129,21 @@ export async function deliverAcpInteractionAnswer(
workspaceId: target.workspaceId,
requestTimeoutMs,
recoverContainerOnTimeout: false,
noWakeContainer: true,
}
)
);
if (capabilities.runtimeIdentity !== input.runtimeIdentity) {
return { outcome: 'interrupted', reason: 'runtime identity changed before delivery' };
}
if (
!capabilities.interactions?.supported ||
capabilities.interactions.version !== ACP_INTERACTION_CAPABILITY_VERSION ||
!capabilities.interactions.answerEndpoint ||
!capabilities.interactions.permissionBridge
) {
return { outcome: 'interrupted', reason: 'runtime permission bridge unsupported' };
}
const raw = await nodeAgentRequest(
target.nodeId,
env,
Expand All @@ -144,6 +154,7 @@ export async function deliverAcpInteractionAnswer(
workspaceId: target.workspaceId,
requestTimeoutMs,
recoverContainerOnTimeout: false,
noWakeContainer: true,
body: JSON.stringify({
protocolVersion: 1,
interactionId: input.interactionId,
Expand All @@ -159,6 +170,14 @@ export async function deliverAcpInteractionAnswer(
}
return { outcome: 'interrupted', reason: response.status };
} catch (error) {
if (error instanceof NodeAgentHttpError && error.statusCode === 409) {
try {
const response = v.parse(AcpRuntimeAnswerResponseSchema, JSON.parse(error.responseBody));
return { outcome: 'interrupted', reason: response.status };
} catch {
return { outcome: 'unconfirmed', reason: 'runtime conflict response was invalid' };
}
}
if (error instanceof NodeAgentHttpError && error.statusCode === 404) {
return { outcome: 'interrupted', reason: 'runtime waiter missing' };
}
Expand Down
32 changes: 32 additions & 0 deletions apps/api/src/services/acp-interaction-runtime-config.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
import {
ACP_INTERACTION_PROTOCOL_VERSION,
type AcpInteractionRuntimeConfig,
} from '@simple-agent-manager/shared';

import type { Env } from '../env';
import { getAcpInteractionConfig } from './acp-interaction-config';

export function buildAcpInteractionRuntimeConfig(
env: Env,
taskMode: string | null | undefined
): AcpInteractionRuntimeConfig {
const config = getAcpInteractionConfig(env);
return {
enabled: config.enabled,
protocolVersion: ACP_INTERACTION_PROTOCOL_VERSION,
permissionDeadlineMs:
taskMode === 'conversation'
? config.permissionConversationDeadlineMs
: config.permissionTaskDeadlineMs,
maxDeadlineMs: config.maxDeadlineMs,
deadlineMarginMs: config.deadlineMarginMs,
requestMaxBytes: config.requestMaxBytes,
optionsMaxCount: config.optionsMaxCount,
optionIdMaxChars: config.optionIdMaxChars,
optionNameMaxChars: config.optionNameMaxChars,
receiptLimit: config.runtimeReceiptLimit,
responseMaxBytes: config.runtimeResponseMaxBytes,
settleRetryDelaysMs: config.retryDelaysMs,
settleRetrySteadyMs: config.retrySteadyMs,
};
}
Loading
Loading