Skip to content
Draft
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: 4 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@

## Windows-Specific Notes

- A per-thread Claude subagent override must remove case-insensitive aliases of `CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS` from the copied launch environment before setting the canonical key. Keep the inherited environment unchanged so sibling sessions retain their own policy.

- The separate scheduled/manual reliability workflow runs the existing Windows native installer/runtime/uninstaller smoke only on disposable GitHub-hosted Windows runners. It does not run on user machines or move process-backed/provider tests into the default suite. macOS uses its isolated DMG/ZIP smoke, and Linux keeps its existing artifact build and pipeline coverage.

- Generic attachment originals and metadata are private `0600` files where POSIX permissions apply; provider-readable derivatives are `0400`. On Windows leave derivatives user-writable under Cafe's user-owned data-directory ACL instead of setting the read-only attribute, which would prevent safe cleanup. File names are inert display metadata and storage uses server-minted identifiers; Windows path separators in dropped names must never become server paths. Document extraction children use the current executable with `ELECTRON_RUN_AS_NODE=1` for packaged Electron backends and preserve only required Windows system-directory environment entries, not provider credentials or user-selected Node hooks. macOS/Linux retain the same isolated child behavior and their native permission checks.
Expand Down Expand Up @@ -99,6 +101,8 @@ If a tradeoff is required, choose correctness, durability, and debuggability ove

## Provider Status And Maintenance

- Codex and Claude composer controls can persist an optional per-thread `threadSubagentLimit` model option from 1–64; `inherit` resets the thread to the selected provider instance policy. Keep this option when changing model traits, but exclude it from sticky defaults for unrelated chats. Save server threads through the exact environment's `thread.meta.update` command and drafts through their exact draft identity. The session snapshot records the materialized override (null for inheritance, omitted for older daemons); changes reconcile through the existing idle start/resume path without interrupting active work. Codex must still use its existing V1/V2 launch translation, and Claude must use a copied query environment with the official Agent-tool admission key and its documented exemptions. This is a concurrency ceiling, not a request to spawn an exact number of agents.

- Codex thread resume must keep Cafe's finite 64 MiB newline-delimited protocol boundary. On Codex 0.151+, request `excludeTurns` plus a one-turn, `notLoaded`, descending `initialTurnsPage`, then normalize that bounded page into the existing snapshot lifecycle so active-turn recovery still works without hydrating a multi-hour transcript into one JSON-RPC line. If an older Codex build ignores pagination and returns an oversized persisted resume response, classify only the nested `CodexAppServerIncomingMessageTooLargeError` tag and retry exactly once without the persisted cursor; never broaden this recovery to arbitrary provider text or raise the line limit. A failed start can leave `starting`/`ready`/`idle`, `session.started`, or `thread.started` events in the daemon journal, so ProviderRuntimeIngestion must not let those non-conclusive notifications clear a projected start error unless a new turn-start intent is pending or ProviderService confirms a matching registered ready/running session.
- `ProviderRegistry` is the sole production owner of initial provider-status refresh admission. Keep initial CLI probes bounded to two concurrent instances, and do not add independent Codex or Claude startup probe fibers that bypass that aggregate limit. An externally admitted managed provider must not start its periodic clock until that initial refresh settles. Periodic probes then use a deterministic phase derived from the stable provider instance id: the first periodic check runs after one full interval plus that phase, then repeats at the normal interval. Renderer config subscriptions are read-only and must never trigger provider probes during reconnect.
- Codex CLI status probes must be disposable subprocesses with tree-aware cleanup: use an isolated process group on POSIX and the platform child-tree path on Windows. Before releasing the child scope, a timeout sends `SIGTERM`, waits one bounded grace interval for actual process exit, and then sends `SIGKILL` with a second bounded wait; the scope itself keeps `SIGKILL` as the final backstop. This explicit wait is required because Effect's `forceKillAfter` currently bounds signal dispatch rather than the later exit wait. Provider snapshots may report only fixed phase names, bounded outcomes, and durations for runtime-home preparation, version, login status, and account usage; never include command text, stdout/stderr, auth state payloads, or unrestricted paths in these diagnostics.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,7 +15,7 @@ import {
ProviderInstanceId,
PROVIDER_SESSION_TITLE_MAX_CHARS,
} from "@cafecode/contracts";
import { createModelSelection } from "@cafecode/shared/model";
import { createModelSelection, resolveThreadSubagentLimit } from "@cafecode/shared/model";
import {
ApprovalRequestId,
CommandId,
Expand Down Expand Up @@ -269,6 +269,10 @@ describe("ProviderCommandReactor", () => {
? { model: inputModelSelection?.model ?? modelSelection.model }
: {}),
...(inputModelSelection ? { modelSelection: inputModelSelection } : {}),
threadSubagentLimit: resolveThreadSubagentLimit(
inputModelSelection,
providerInstanceId ?? ProviderInstanceId.make(provider),
),
threadId,
resumeCursor: resumeCursor ?? { opaque: `resume-${sessionIndex}` },
createdAt: now,
Expand Down Expand Up @@ -1774,6 +1778,97 @@ describe("ProviderCommandReactor", () => {
expect(harness.generateThreadTitle).not.toHaveBeenCalled();
});

it.each(["codex", "claudeAgent"])(
"defers %s thread limits during active work and resumes once idle",
async (driver) => {
const instanceId = ProviderInstanceId.make(driver);
const initial = createModelSelection(
instanceId,
driver === "codex" ? "gpt-5-codex" : "claude-opus-4-6",
);
const harness = await createHarness({
threadModelSelection: initial,
liveSteer: "supported",
});
const threadId = ThreadId.make("thread-1");
const createdAt = "2026-01-01T00:00:00.000Z";
const send = async (index: number) => {
await Effect.runPromise(
harness.engine.dispatch({
type: "thread.turn.start",
commandId: CommandId.make(`limit-turn-${index}`),
threadId,
message: {
messageId: asMessageId(`limit-message-${index}`),
role: "user",
text: "continue",
attachments: [],
},
interactionMode: DEFAULT_PROVIDER_INTERACTION_MODE,
runtimeMode: "approval-required",
createdAt,
}),
);
await waitFor(() => harness.sendTurn.mock.calls.length === index);
};
await send(1);
const active = harness.runtimeSessions[0]!;
harness.runtimeSessions[0] = {
...active,
status: "running",
activeTurnId: asTurnId("limit-active"),
};
const changed = createModelSelection(instanceId, initial.model, [
{ id: "threadSubagentLimit", value: "4" },
]);
await Effect.runPromise(
harness.engine.dispatch({
type: "thread.meta.update",
commandId: CommandId.make("limit-change"),
threadId,
modelSelection: changed,
}),
);
await harness.drain();
expect(harness.startSession).toHaveBeenCalledTimes(1);
expect(harness.interruptTurn).not.toHaveBeenCalled();
expect(harness.runtimeSessions[0]?.threadSubagentLimit).toBeNull();
expect(
(await harness.readModel()).threads.find((thread) => thread.id === threadId)
?.modelSelection,
).toEqual(changed);

harness.runtimeSessions[0] = { ...active, status: "ready" };
await harness.markThreadReady();
await send(2);
expect(harness.startSession).toHaveBeenCalledTimes(2);
expect(harness.startSession.mock.calls[1]?.[1]).toMatchObject({
modelSelection: changed,
resumeCursor: active.resumeCursor,
});
expect(harness.runtimeSessions[0]?.threadSubagentLimit).toBe(4);
await harness.markThreadReady();
await send(3);
expect(harness.startSession).toHaveBeenCalledTimes(2);

const inherited = createModelSelection(instanceId, initial.model, [
{ id: "threadSubagentLimit", value: "inherit" },
]);
await Effect.runPromise(
harness.engine.dispatch({
type: "thread.meta.update",
commandId: CommandId.make("limit-reset"),
threadId,
modelSelection: inherited,
}),
);
await harness.markThreadReady();
await send(4);
expect(harness.startSession).toHaveBeenCalledTimes(3);
expect(harness.runtimeSessions[0]?.threadSubagentLimit).toBeNull();
},
);

it("forwards provider model options through session start and turn send", async () => {
const harness = await createHarness();
const now = "2026-01-01T00:00:00.000Z";
Expand Down
37 changes: 34 additions & 3 deletions apps/server/src/orchestration/Layers/ProviderCommandReactor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ import {
type RuntimeMode,
TurnId,
} from "@cafecode/contracts";
import { omitThreadSubagentLimitOption, resolveThreadSubagentLimit } from "@cafecode/shared/model";
import {
isTemporaryWorktreeBranch,
LEGACY_WORKTREE_BRANCH_PREFIX,
Expand Down Expand Up @@ -82,6 +83,10 @@ import {
decideCodexSteerRecovery,
} from "../codexSteerRecovery.ts";
const isProviderAdapterRequestError = Schema.is(ProviderAdapterRequestError);

function withoutThreadLimit(selection: ModelSelection | undefined) {
return selection && { ...selection, options: omitThreadSubagentLimitOption(selection.options) };
}
const isProviderAdapterProcessError = Schema.is(ProviderAdapterProcessError);
const isProviderDriverKind = Schema.is(ProviderDriverKind);
const RAW_PROVIDER_PROCESS_FAILURE_PATTERN =
Expand Down Expand Up @@ -1445,6 +1450,18 @@ const make = Effect.gen(function* () {
});
}
const preferredProvider: ProviderDriverKind = desiredDriverKind;
const desiredThreadSubagentLimit =
preferredProvider === "codex" || preferredProvider === "claudeAgent"
? yield* Effect.try({
try: () => resolveThreadSubagentLimit(desiredModelSelection, desiredInstanceId),
catch: () =>
new ProviderAdapterRequestError({
provider: preferredProvider,
method: "thread.turn.start",
detail: "Thread subagent limit must be an integer from 1 to 64.",
}),
})
: null;
const requestedInstanceChange = desiredInstanceId !== currentInstanceId;
const currentInfo = requestedInstanceChange
? activeSession === undefined
Expand Down Expand Up @@ -1588,12 +1605,25 @@ const make = Effect.gen(function* () {
const restartResumeModelSelectionChanged =
sessionModelSwitch === "restart-resume" &&
requestedModelSelection !== undefined &&
!Equal.equals(activeSession.modelSelection, requestedModelSelection);
!Equal.equals(
withoutThreadLimit(activeSession.modelSelection),
withoutThreadLimit(requestedModelSelection),
);
const shouldRestartForModelSelectionChange =
restartResumeModelSelectionChanged ||
(preferredProvider === "claudeAgent" &&
requestedModelSelection !== undefined &&
!Equal.equals(previousModelSelection, requestedModelSelection));
!Equal.equals(
withoutThreadLimit(previousModelSelection),
withoutThreadLimit(requestedModelSelection),
));
const threadSubagentLimitChanged =
(preferredProvider === "codex" || preferredProvider === "claudeAgent") &&
(activeSession.threadSubagentLimit ?? null) !== desiredThreadSubagentLimit;
// This process setting is deferred while the provider owns active work.
// The next idle start/resume reconciles it from the durable thread options.
const deferThreadSubagentLimitChange =
activeSession.status === "running" || activeSession.activeTurnId !== undefined;

if (
!runtimeModeChanged &&
Expand All @@ -1603,7 +1633,8 @@ const make = Effect.gen(function* () {
!instanceChanged &&
!providerResumeIdentityChanged &&
!shouldRestartForModelChange &&
!shouldRestartForModelSelectionChange
!shouldRestartForModelSelectionChange &&
(!threadSubagentLimitChanged || deferThreadSubagentLimitChange)
) {
return activeSession;
}
Expand Down
61 changes: 61 additions & 0 deletions apps/server/src/provider/Layers/ClaudeAdapter.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -567,6 +567,67 @@ describe("ClaudeAdapterLive", () => {
);
});

it.effect(
"isolates thread subagent limits from sibling Claude sessions and rejects invalid replacement",
() => {
const inheritedEnvironment = {
CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS: "8",
claude_code_max_concurrent_subagents: "63",
};
const harness = makeHarness({
claudeConfig: { maxConcurrentSubagents: 12 },
environment: inheritedEnvironment,
});
return Effect.gen(function* () {
const adapter = yield* ClaudeAdapter;
for (const [id, value, expected] of [
["override", "4", "4"],
["sibling", undefined, "12"],
["reset", "inherit", "12"],
] as const) {
const session = yield* adapter.startSession({
threadId: ThreadId.make(id),
runtimeMode: "full-access",
modelSelection: createModelSelection(
ProviderInstanceId.make("claudeAgent"),
"claude-opus-4-6",
value === undefined ? undefined : [{ id: "threadSubagentLimit", value }],
),
});
assert.equal(
harness.getLastCreateQueryInput()?.options.env?.CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS,
expected,
);
assert.equal(session.threadSubagentLimit, value === "4" ? 4 : null);
if (process.platform === "win32" && value === "4") {
assert.isUndefined(
harness.getLastCreateQueryInput()?.options.env?.claude_code_max_concurrent_subagents,
);
}
}
assert.equal(inheritedEnvironment.CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS, "8");
assert.equal(inheritedEnvironment.claude_code_max_concurrent_subagents, "63");
const closeCalls = harness.query.closeCalls;
const result = yield* Effect.result(
adapter.startSession({
threadId: ThreadId.make("override"),
runtimeMode: "full-access",
modelSelection: createModelSelection(
ProviderInstanceId.make("claudeAgent"),
"claude-opus-4-6",
[{ id: "threadSubagentLimit", value: "65" }],
),
}),
);
assert.equal(result._tag, "Failure");
assert.equal(harness.query.closeCalls, closeCalls);
}).pipe(
Effect.provideService(Random.Random, makeDeterministicRandomService()),
Effect.provide(harness.layer),
);
},
);

it.effect("runs Claude SDK sessions with the configured Claude HOME", () => {
const harness = makeHarness({ claudeConfig: { homePath: "~/.claude-work" } });
return Effect.gen(function* () {
Expand Down
27 changes: 26 additions & 1 deletion apps/server/src/provider/Layers/ClaudeAdapter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ import {
getProviderOptionCurrentValue,
getProviderOptionDescriptors,
resolvePromptInjectedEffort,
resolveThreadSubagentLimit,
} from "@cafecode/shared/model";
import * as Cause from "effect/Cause";
import * as DateTime from "effect/DateTime";
Expand Down Expand Up @@ -6822,6 +6823,16 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
});
}

// Reject malformed resource policy before replacing any live session.
const threadSubagentLimit = yield* Effect.try({
try: () => resolveThreadSubagentLimit(input.modelSelection, boundInstanceId),
catch: () =>
new ProviderAdapterValidationError({
provider: PROVIDER,
operation: "startSession",
issue: "Invalid thread subagent limit.",
}),
});
const existingContext = sessions.get(input.threadId);
if (existingContext) {
yield* Effect.logWarning("claude.session.replacing", {
Expand Down Expand Up @@ -7330,6 +7341,19 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
const existingResumeSessionId = durableResumeState?.resume;
const resumeBaseTurnCount = durableResumeState?.turnCount ?? 0;

const threadEnvironment = { ...claudeEnvironment };
if (threadSubagentLimit !== null) {
// Node selects only one casing of duplicate Windows environment keys.
// Remove inherited aliases so this exact thread's override wins.
if (process.platform === "win32") {
for (const key of Object.keys(threadEnvironment)) {
if (key.toUpperCase() === "CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS") {
delete threadEnvironment[key];
}
}
}
threadEnvironment.CLAUDE_CODE_MAX_CONCURRENT_SUBAGENTS = String(threadSubagentLimit);
}
const queryOptions: ClaudeQueryOptions = {
...(input.cwd ? { cwd: input.cwd } : {}),
...(apiModelId ? { model: apiModelId } : {}),
Expand Down Expand Up @@ -7416,7 +7440,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
),
);
},
env: claudeEnvironment,
env: threadEnvironment,
...(claudeAdditionalDirectories.length > 0
? { additionalDirectories: [...claudeAdditionalDirectories] }
: {}),
Expand Down Expand Up @@ -7477,6 +7501,7 @@ export const makeClaudeAdapter = Effect.fn("makeClaudeAdapter")(function* (
}
: undefined;
const session: ProviderSession = {
threadSubagentLimit,
threadId,
provider: PROVIDER,
providerInstanceId: boundInstanceId,
Expand Down
Loading
Loading