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
333 changes: 312 additions & 21 deletions apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.test.ts

Large diffs are not rendered by default.

90 changes: 89 additions & 1 deletion apps/server/src/orchestration-v2/Adapters/ClaudeAdapterV2.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3044,6 +3044,9 @@ interface ActiveClaudeTurnContext {
// compaction makes the usage stale (null) without forgetting the request.
streamUsage: { readonly messageId: string; usage: ClaudeMessageUsage | null } | null;
readonly toolCalls: Map<string, ActiveClaudeToolCall>;
readonly deniedByT3ToolUseIds: Set<string>;
readonly permissionDenials: Map<string, SDKResultMessage["permission_denials"][number]>;
readonly toolErrorTexts: Map<string, string>;
readonly ignoredTaskIds: Set<string>;
readonly announcedUsageLimits: Set<string>;
authenticationFailureMessage: string | undefined;
Expand Down Expand Up @@ -3753,6 +3756,8 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
});
const requestedContinuations = yield* Ref.make(new Set<string>());
const heldProposedPlansByToolUseId = new Map<string, string>();
// Held frames can drain into a different turn.
const heldDeniedByT3Tools = new WeakSet<ClaudeToolUseContentBlock>();
const runtimeContext = yield* Effect.context<never>();
const runPromise = Effect.runPromiseWith(runtimeContext);

Expand Down Expand Up @@ -7596,6 +7601,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (

const toolPresentations = claudeToolPresentationsFromAssistantMessage(message);
for (const toolUse of claudeToolUseBlocksFromAssistantMessage(message)) {
if (heldDeniedByT3Tools.delete(toolUse)) {
context.deniedByT3ToolUseIds.add(toolUse.id);
}
const nativeToolInput = claudeNativeToolInputFromUnknown(toolUse.input);
if (toolUse.name === "Agent") {
rememberClaudeSubagentLaunch(
Expand Down Expand Up @@ -7641,6 +7649,15 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
}

for (const { toolResult, output } of claudeToolResultEntriesFromMessage(message)) {
if (isClaudeToolResultError(toolResult)) {
context.toolErrorTexts.set(
toolResult.tool_use_id,
claudeSubagentResultText(claudeNativeToolOutputFromToolResult(toolResult))
.replace(/\s+/gu, " ")
.trim()
.slice(0, 500),
);
}
const subagent = yield* resolveSubagentByToolUseId(context, toolResult.tool_use_id);
const runHandles = parseClaudeWorkflowRunHandles(claudeNativeToolOutputValue(output));
if (subagent !== undefined && runHandles !== undefined) {
Expand Down Expand Up @@ -7832,6 +7849,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
}

if (message.type === "result") {
for (const denial of message.permission_denials) {
context.permissionDenials.set(denial.tool_use_id, denial);
}
const completedAt = yield* DateTime.now;
const interrupted = (yield* Ref.get(interruptedTurns)).has(context.providerTurnId);
const wasSteered = (yield* Ref.get(steeredTurns)).has(context.providerTurnId);
Expand Down Expand Up @@ -7888,6 +7908,54 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
resultFailure?.class === "usage_limit"
? { ...resultFailure, resetAt: resetAt ?? resultFailure.resetAt ?? null }
: resultFailure;
const denials = Array.from(context.permissionDenials.values()).filter(
(denial) => !context.deniedByT3ToolUseIds.has(denial.tool_use_id),
);
if (denials.length > 0) {
const tools = [...new Set(denials.map((denial) => denial.tool_name))].join(", ");
const reasons = [
...new Set(
denials.flatMap((denial) => {
const reason = context.toolErrorTexts.get(denial.tool_use_id);
return reason ? [reason] : [];
}),
),
]
.join("; ")
.slice(0, 1500);
const title = `Claude denied ${denials.length} action${denials.length === 1 ? "" : "s"} (${tools})`;
const nativeItemId = `${context.providerTurnId}:permission-denials`;
yield* emitProviderEvent({
type: "turn_item.updated",
driver: CLAUDE_PROVIDER,
turnItem: {
id: idAllocator.derive.turnItemFromProviderItem({
driver: CLAUDE_PROVIDER,
nativeItemId,
}),
threadId: context.input.threadId,
runId: context.input.runId,
nodeId: context.input.rootNodeId,
providerThreadId: context.input.providerThread.id,
providerTurnId: context.providerTurnId,
nativeItemRef: {
driver: CLAUDE_PROVIDER,
nativeId: nativeItemId,
strength: "weak",
},
parentItemId: null,
ordinal: yield* resolveItemOrdinal(context, nativeItemId),
type: "system_notice",
status: "completed",
title,
message: reasons.length > 0 ? `${title}: ${reasons}` : title,
needsAttention: true,
startedAt: completedAt,
completedAt,
updatedAt: completedAt,
},
});
}
yield* finalizeActiveTurn({
context,
status: interrupted
Expand Down Expand Up @@ -8317,7 +8385,24 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
});

const canUseTool: CanUseTool = (toolName, toolInput, callbackOptions) =>
runPromise(canUseToolEffect(toolName, toolInput, callbackOptions));
runPromise(
Effect.gen(function* () {
const context = yield* Ref.get(activeTurn);
const result = yield* canUseToolEffect(toolName, toolInput, callbackOptions);
if (result.behavior === "deny" && context !== null) {
// Held output may belong to a queued wake rather than this prompt.
const heldTool = context.heldRootFrames
.flatMap(claudeToolUseBlocksFromAssistantMessage)
.find((tool) => tool.id === callbackOptions.toolUseID);
if (heldTool !== undefined) {
heldDeniedByT3Tools.add(heldTool);
} else {
context.deniedByT3ToolUseIds.add(callbackOptions.toolUseID);
}
}
return result;
}),
);

const onUserDialog: NonNullable<ClaudeQueryOptions["onUserDialog"]> = (
request,
Expand Down Expand Up @@ -8713,6 +8798,9 @@ export const makeClaudeAdapterV2 = Effect.fn("makeClaudeAdapterV2")(function* (
},
streamUsage: null,
toolCalls: new Map(),
deniedByT3ToolUseIds: new Set(),
permissionDenials: new Map(),
toolErrorTexts: new Map(),
ignoredTaskIds: new Set(),
announcedUsageLimits: new Set(),
authenticationFailureMessage: undefined,
Expand Down
80 changes: 79 additions & 1 deletion apps/server/src/orchestration-v2/ProjectionSettlement.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ const createRun = Effect.fn(function* (
threadId: ThreadId,
status: OrchestrationV2Run["status"] = "completed",
ordinal = 1,
overrides: Partial<OrchestrationV2Run> = {},
) {
const store = yield* ProjectionStore.ProjectionStoreV2;
const runId = RunId.make(`run:${threadId}:${ordinal}`);
Expand All @@ -100,6 +101,7 @@ const createRun = Effect.fn(function* (
completedAt: ["completed", "rolled_back", "failed"].includes(status) ? old : null,
checkpointId: null,
contextHandoffId: null,
...overrides,
},
});
return runId;
Expand Down Expand Up @@ -353,6 +355,69 @@ it.effect.each([
}).pipe(Effect.provide(testLayer)),
);

it.effect.each([
["sql", layerSql],
["memory", ProjectionStore.layerMemory],
] as const)(
"%s: attention notices hold settlement until another run actually starts",
([, testLayer]) =>
Effect.gen(function* () {
const store = yield* ProjectionStore.ProjectionStoreV2;
const later = DateTime.add(old, { hours: 1 });
for (const [name, needsAttention, nextRun, eligible] of [
["attention", true, null, false],
["ordinary-notice", undefined, null, true],
["queued", true, { status: "queued", startedAt: null }, false],
["canceled-before-start", true, { status: "cancelled", startedAt: null }, false],
["later-start", true, { startedAt: later }, true],
] satisfies ReadonlyArray<
readonly [string, boolean | undefined, Partial<OrchestrationV2Run> | null, boolean]
>) {
const threadId = yield* createThread(name);
const runId = yield* createRun(threadId, "completed", 2);
yield* store.apply({
id: EventId.make(`event:notice:${runId}`),
type: "turn-item.updated",
threadId,
occurredAt: old,
payload: {
id: TurnItemId.make(`notice:${runId}`),
threadId,
runId,
nodeId: null,
providerThreadId: null,
providerTurnId: null,
nativeItemRef: null,
parentItemId: null,
ordinal: 1,
type: "system_notice",
status: "completed",
title: null,
startedAt: old,
completedAt: old,
updatedAt: old,
message: "Needs attention",
...(needsAttention === undefined ? {} : { needsAttention }),
},
});
if (nextRun !== null) {
yield* createRun(threadId, "completed", 3, nextRun);
}
const notice = (yield* store.getThreadProjection(threadId)).turnItems.find(
(item) => item.id === TurnItemId.make(`notice:${runId}`),
);
assert.equal(notice?.type, "system_notice");
if (notice?.type !== "system_notice") return;
assert.equal(notice.needsAttention, needsAttention, name);
assert.deepEqual(
(yield* store.getSettlementCandidates(threadId)).map((candidate) => candidate.id),
eligible ? [threadId] : [],
name,
);
}
}).pipe(Effect.provide(testLayer)),
);

const pullRequestLink = (number: number) => ({
host: "github.com",
repository: "owner/repository",
Expand Down Expand Up @@ -458,7 +523,7 @@ it.effect(
);
assert.isTrue(queries.every(([query]) => !query.includes("COUNT(")));
const pendingQuery = queries.find(([query]) =>
query.includes("FROM orchestration_v2_projection_turn_items"),
query.includes("FROM orchestration_v2_projection_turn_items i"),
);
assert.isDefined(pendingQuery);
const metadata = yield* store.getThread(threadId);
Expand All @@ -469,6 +534,19 @@ it.effect(
pendingQuery![1],
);
assert.isTrue(plan.some((row) => row.detail.includes("turn_items_shell_pending_idx")));
const candidateQuery = queries.find(([query]) => query.includes("AS latest_run_id"));
assert.isDefined(candidateQuery);
const candidatePlan = yield* sql.unsafe<{ readonly detail: string }>(
`EXPLAIN QUERY PLAN ${candidateQuery![0]}`,
candidateQuery![1],
);
assert.isTrue(
candidatePlan.some((row) =>
row.detail.startsWith(
"SEARCH notice USING INDEX orchestration_v2_projection_turn_items_attention_notice_idx (thread_id=? AND run_id=?)",
),
),
);
const countsPlan = yield* sql<{ readonly detail: string }>`EXPLAIN QUERY PLAN
SELECT thread_id, run_id, COUNT(*) FROM orchestration_v2_projection_turn_items
WHERE thread_id = ${threadId} AND run_id IS NOT NULL GROUP BY thread_id, run_id`;
Expand Down
38 changes: 38 additions & 0 deletions apps/server/src/orchestration-v2/ProjectionStore.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5648,6 +5648,21 @@ export const layer: Layer.Layer<ProjectionStoreV2, never, SqlClient.SqlClient> =
SELECT 1 FROM orchestration_v2_projection_runtime_requests request
WHERE request.thread_id = t.thread_id AND request.status = 'pending'
)
AND NOT EXISTS (
SELECT 1 FROM orchestration_v2_projection_turn_items notice
INDEXED BY orchestration_v2_projection_turn_items_attention_notice_idx
WHERE notice.thread_id = t.thread_id
AND notice.run_id = (
SELECT started.run_id FROM orchestration_v2_projection_runs started
WHERE started.thread_id = t.thread_id
AND json_extract(started.payload_json, '$.startedAt') IS NOT NULL
ORDER BY json_extract(started.payload_json, '$.startedAt') DESC,
started.ordinal DESC
LIMIT 1
)
AND notice.type = 'system_notice'
AND json_extract(notice.payload_json, '$.needsAttention') = 1
)
ORDER BY t.updated_at ASC, t.thread_id ASC
`;
if (rows.length === 0) return [];
Expand Down Expand Up @@ -6226,6 +6241,29 @@ export const layerMemory: Layer.Layer<ProjectionStoreV2> = Layer.effect(
!runs.some(isActivityRunForShell) &&
!runtimeRequests.some((request) => request.status === "pending"),
)
.filter(({ runs, turnItems }) => {
// Queue order can differ from the order runs actually start.
let latestStartedRun: OrchestrationV2Run | undefined;
let latestStartedAt = -Infinity;
for (const run of runs) {
if (run.startedAt === null) continue;
const startedAt = DateTime.toEpochMillis(run.startedAt);
if (
latestStartedRun === undefined ||
startedAt > latestStartedAt ||
(startedAt === latestStartedAt && run.ordinal > latestStartedRun.ordinal)
) {
latestStartedRun = run;
latestStartedAt = startedAt;
}
}
return !turnItems.some(
(item) =>
item.runId === latestStartedRun?.id &&
item.type === "system_notice" &&
item.needsAttention === true,
);
})
.map((projection) => {
const shell = threadShellFromProjection(projection);
return {
Expand Down
2 changes: 2 additions & 0 deletions apps/server/src/persistence/Migrations.ts
Original file line number Diff line number Diff line change
Expand Up @@ -67,6 +67,7 @@ import Migration0061 from "./Migrations/061_PullRequestReviewGuides.ts";
import Migration0062 from "./Migrations/062_ThreadFailureLookupIndex.ts";
import Migration0063 from "./Migrations/063_DropUnusedV1Projections.ts";
import Migration0064 from "./Migrations/064_CanonicalizeThreadPullRequestLinks.ts";
import Migration0065 from "./Migrations/065_ThreadAttentionNoticeLookupIndex.ts";

export const migrationEntries = [
[1, "OrchestrationEvents", Migration0001],
Expand Down Expand Up @@ -134,6 +135,7 @@ export const migrationEntries = [
[62, "ThreadFailureLookupIndex", Migration0062],
[63, "DropUnusedV1Projections", Migration0063],
[64, "CanonicalizeThreadPullRequestLinks", Migration0064],
[65, "ThreadAttentionNoticeLookupIndex", Migration0065],
] as const;

export const migrationManifest = migrationEntries.map(([id, name]) => [id, name] as const);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@ layer("055_OrchestrationV2", (it) => {
Effect.sync(() => {
assert.deepStrictEqual(
migrationEntries.map(([id]) => id),
Array.from({ length: 64 }, (_, index) => index + 1),
Array.from({ length: 65 }, (_, index) => index + 1),
);
}),
);
Expand All @@ -36,6 +36,7 @@ layer("055_OrchestrationV2", (it) => {
[62, "ThreadFailureLookupIndex"],
[63, "DropUnusedV1Projections"],
[64, "CanonicalizeThreadPullRequestLinks"],
[65, "ThreadAttentionNoticeLookupIndex"],
]);
assert.deepStrictEqual(yield* runMigrations(), []);

Expand Down Expand Up @@ -66,6 +67,7 @@ layer("055_OrchestrationV2", (it) => {
{ migration_id: 62, name: "ThreadFailureLookupIndex" },
{ migration_id: 63, name: "DropUnusedV1Projections" },
{ migration_id: 64, name: "CanonicalizeThreadPullRequestLinks" },
{ migration_id: 65, name: "ThreadAttentionNoticeLookupIndex" },
]);

const tables = yield* sql<{ readonly name: string }>`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -123,8 +123,10 @@ layer("064_CanonicalizeThreadPullRequestLinks", (it) => {
'user', ${v1Payload}, '{}', 1)
`;

assert.deepEqual(yield* runMigrations(), [[64, "CanonicalizeThreadPullRequestLinks"]]);
assert.deepEqual(yield* runMigrations(), []);
assert.deepEqual(yield* runMigrations({ toMigrationInclusive: 64 }), [
[64, "CanonicalizeThreadPullRequestLinks"],
]);
assert.deepEqual(yield* runMigrations({ toMigrationInclusive: 64 }), []);
const rows = yield* sql<{ readonly thread_id: string; readonly payload_json: string }>`
SELECT thread_id, payload_json FROM orchestration_v2_projection_threads
`;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
import * as Effect from "effect/Effect";
import * as SqlClient from "effect/sql/SqlClient";

export default Effect.gen(function* () {
const sql = yield* SqlClient.SqlClient;

yield* sql`
CREATE INDEX IF NOT EXISTS orchestration_v2_projection_turn_items_attention_notice_idx
ON orchestration_v2_projection_turn_items(thread_id, run_id)
WHERE type = 'system_notice' AND json_extract(payload_json, '$.needsAttention') = 1
`;
});
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,7 @@ describe("V2 preview upgrade", () => {
[62, "ThreadFailureLookupIndex"],
[63, "DropUnusedV1Projections"],
[64, "CanonicalizeThreadPullRequestLinks"],
[65, "ThreadAttentionNoticeLookupIndex"],
]);
assert.deepStrictEqual(yield* runMigrations(), []);
assert.deepStrictEqual(yield* sql`SELECT * FROM orchestration_v2_legacy_imports`, imports);
Expand Down Expand Up @@ -131,6 +132,7 @@ describe("V2 preview upgrade", () => {
[62, "ThreadFailureLookupIndex"],
[63, "DropUnusedV1Projections"],
[64, "CanonicalizeThreadPullRequestLinks"],
[65, "ThreadAttentionNoticeLookupIndex"],
]);
}).pipe(Effect.provide(NodeSqliteClient.layer({ filename: ":memory:" }))),
);
Expand Down
Loading
Loading