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
1 change: 1 addition & 0 deletions packages/worker-utils/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@
"./cloud-agent-next-client": "./src/cloud-agent-next-client.ts",
"./kilo-model-id": "./src/kilo-model-id.ts",
"./cloud-agent-queue-report": "./src/cloud-agent-queue-report.ts",
"./cloud-agent-failure": "./src/cloud-agent-failure.ts",
"./security-auto-analysis-policy": "./src/security-auto-analysis-policy.ts",
"./dependabot-dismissal-target": "./src/dependabot-dismissal-target.ts"
},
Expand Down
75 changes: 75 additions & 0 deletions packages/worker-utils/src/cloud-agent-failure.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
import { describe, expect, it } from 'vitest';
import {
CLOUD_AGENT_FAILURE_CODES,
CLOUD_AGENT_FAILURE_STAGES,
CloudAgentCallbackFailureSchema,
CloudAgentSafeFailureSchema,
isWorkspaceFailureSubtype,
WORKSPACE_FAILURE_SUBTYPES,
} from './cloud-agent-failure.js';

describe('CloudAgentCallbackFailureSchema', () => {
it('retains failures accepted by the strict producer contract', () => {
const failure = {
stage: 'pre_dispatch',
code: 'workspace_setup_failed',
subtype: 'git_clone_timeout',
attempts: 2,
message: 'Repository clone timed out',
};

expect(CloudAgentCallbackFailureSchema.parse(failure)).toEqual(failure);
});

it.each([
{ code: 'future_failure_code', message: 'Future failure' },
{ code: 'workspace_setup_failed', subtype: 'future_workspace_failure' },
{ code: 'assistant_error', futureField: true },
{ attempts: -1 },
{ message: 'x'.repeat(4_097) },
])('discards unsupported or malformed structured failures: %o', failure => {
expect(CloudAgentCallbackFailureSchema.parse(failure)).toBeUndefined();
});
});

describe('CloudAgentSafeFailureSchema', () => {
it('accepts every shared contract value', () => {
for (const stage of CLOUD_AGENT_FAILURE_STAGES) {
expect(CloudAgentSafeFailureSchema.safeParse({ stage }).success).toBe(true);
}
for (const code of CLOUD_AGENT_FAILURE_CODES) {
expect(CloudAgentSafeFailureSchema.safeParse({ code }).success).toBe(true);
}
for (const subtype of WORKSPACE_FAILURE_SUBTYPES) {
expect(
CloudAgentSafeFailureSchema.safeParse({ code: 'workspace_setup_failed', subtype }).success
).toBe(true);
expect(isWorkspaceFailureSubtype(subtype)).toBe(true);
}
});

it('requires workspace_setup_failed when subtype is present', () => {
expect(CloudAgentSafeFailureSchema.safeParse({ subtype: 'git_clone_timeout' }).success).toBe(
false
);
expect(
CloudAgentSafeFailureSchema.safeParse({
code: 'assistant_error',
subtype: 'git_clone_timeout',
}).success
).toBe(false);
});

it('enforces strict optional field bounds', () => {
expect(CloudAgentSafeFailureSchema.safeParse({}).success).toBe(true);
expect(CloudAgentSafeFailureSchema.safeParse({ attempts: 0, message: 'x' }).success).toBe(true);
expect(CloudAgentSafeFailureSchema.safeParse({ attempts: -1 }).success).toBe(false);
expect(CloudAgentSafeFailureSchema.safeParse({ attempts: 1.5 }).success).toBe(false);
expect(CloudAgentSafeFailureSchema.safeParse({ message: '' }).success).toBe(false);
expect(CloudAgentSafeFailureSchema.safeParse({ message: 'x'.repeat(4_097) }).success).toBe(
false
);
expect(CloudAgentSafeFailureSchema.safeParse({ extra: true }).success).toBe(false);
expect(isWorkspaceFailureSubtype('not_allowlisted')).toBe(false);
});
});
83 changes: 83 additions & 0 deletions packages/worker-utils/src/cloud-agent-failure.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,83 @@
import { z } from 'zod';

export const CLOUD_AGENT_FAILURE_STAGES = [
'pre_dispatch',
'post_dispatch_no_activity',
'agent_activity',
'interruption',
'unknown',
] as const;

export const CloudAgentFailureStageSchema = z.enum(CLOUD_AGENT_FAILURE_STAGES);
export type CloudAgentFailureStage = z.infer<typeof CloudAgentFailureStageSchema>;

export const CLOUD_AGENT_FAILURE_CODES = [
'sandbox_connect_failed',
'workspace_setup_failed',
'kilo_server_failed',
'wrapper_start_failed',
'invalid_delivery_request',
'session_metadata_missing',
'model_missing',
'delivery_failure_unknown',
'wrapper_disconnected',
'wrapper_no_output',
'wrapper_ping_timeout',
'wrapper_error_before_activity',
'assistant_error',
'wrapper_error_after_activity',
'missing_assistant_reply',
'user_interrupt',
'container_shutdown',
'system_interrupt',
'unclassified',
] as const;

export const CloudAgentFailureCodeSchema = z.enum(CLOUD_AGENT_FAILURE_CODES);
export type CloudAgentFailureCode = z.infer<typeof CloudAgentFailureCodeSchema>;

export const WORKSPACE_FAILURE_SUBTYPES = [
'git_clone_timeout',
'git_checkout_timeout',
'git_authentication_failed',
'git_network_failed',
'git_pack_corrupt',
'git_checkout_conflict',
'git_branch_missing',
'sandbox_storage_full',
'kilo_import_timeout',
'kilo_import_failed',
'setup_command_timeout',
'setup_command_failed',
'workspace_setup_unknown',
] as const;

export const WorkspaceFailureSubtypeSchema = z.enum(WORKSPACE_FAILURE_SUBTYPES);
export type WorkspaceFailureSubtype = z.infer<typeof WorkspaceFailureSubtypeSchema>;

export const CLOUD_AGENT_SAFE_FAILURE_MESSAGE_MAX_LENGTH = 4_096;

export const CloudAgentSafeFailureSchema = z
.object({
stage: CloudAgentFailureStageSchema.optional(),
code: CloudAgentFailureCodeSchema.optional(),
subtype: WorkspaceFailureSubtypeSchema.optional(),
attempts: z.number().int().nonnegative().optional(),
message: z.string().min(1).max(CLOUD_AGENT_SAFE_FAILURE_MESSAGE_MAX_LENGTH).optional(),
})
.strict()
.refine(failure => failure.subtype === undefined || failure.code === 'workspace_setup_failed', {
message: 'Workspace failure subtype requires workspace_setup_failed failure code',
path: ['subtype'],
});

export type CloudAgentSafeFailure = z.infer<typeof CloudAgentSafeFailureSchema>;

export const CloudAgentCallbackFailureSchema = z.preprocess(failure => {
const parsed = CloudAgentSafeFailureSchema.safeParse(failure);
return parsed.success ? parsed.data : undefined;
}, CloudAgentSafeFailureSchema.optional());

export function isWorkspaceFailureSubtype(value: unknown): value is WorkspaceFailureSubtype {
return WorkspaceFailureSubtypeSchema.safeParse(value).success;
}
33 changes: 4 additions & 29 deletions packages/worker-utils/src/cloud-agent-queue-report.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { z } from 'zod';
import {
CloudAgentFailureCodeSchema,
CloudAgentFailureStageSchema,
} from './cloud-agent-failure.js';

export const CloudAgentRunStatuses = [
'queued',
Expand Down Expand Up @@ -37,35 +41,6 @@ export const DIAGNOSTIC_RETENTION_MS = 30 * 24 * 60 * 60 * 1000;
const IsoTimestampSchema = z.string().datetime({ offset: true });
const OperationalIdentifierSchema = z.string().min(1).max(MAX_OPERATIONAL_IDENTIFIER_LENGTH);
const WrapperRunIdentifierSchema = OperationalIdentifierSchema.regex(/^wr_[A-Za-z0-9_-]+$/);
const CloudAgentFailureStageSchema = z.enum([
'pre_dispatch',
'post_dispatch_no_activity',
'agent_activity',
'interruption',
'unknown',
]);
const CloudAgentFailureCodeSchema = z.enum([
'sandbox_connect_failed',
'workspace_setup_failed',
'kilo_server_failed',
'wrapper_start_failed',
'invalid_delivery_request',
'session_metadata_missing',
'model_missing',
'delivery_failure_unknown',
'wrapper_disconnected',
'wrapper_no_output',
'wrapper_ping_timeout',
'wrapper_error_before_activity',
'assistant_error',
'wrapper_error_after_activity',
'missing_assistant_reply',
'user_interrupt',
'container_shutdown',
'system_interrupt',
'unclassified',
]);

const validFailureClassifications = new Set(
CloudAgentRunFailureClassifications.map(
classification => `${classification.failureStage}:${classification.failureCode}`
Expand Down
3 changes: 3 additions & 0 deletions pnpm-lock.yaml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions services/auto-triage-infra/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
"@cloudflare/workers-types": "catalog:",
"@typescript/native-preview": "catalog:",
"typescript": "catalog:",
"vitest": "catalog:",
"wrangler": "catalog:"
},
"dependencies": {
Expand Down
143 changes: 143 additions & 0 deletions services/auto-triage-infra/src/triage-orchestrator.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,143 @@
import { beforeEach, describe, expect, it, vi } from 'vitest';

vi.mock('cloudflare:workers', () => ({
DurableObject: class DurableObject {
protected ctx: unknown;
protected env: unknown;

constructor(ctx: unknown, env: unknown) {
this.ctx = ctx;
this.env = env;
}
},
}));

import { TriageOrchestrator } from './triage-orchestrator';
import { classificationCallbackPayloadSchema, type TriageTicket } from './types';

const callbackSecret = 'callback-secret';
const cloudAgentSessionId = 'agent_triage';

function createTicket(): TriageTicket {
return {
ticketId: 'ticket-1',
authToken: 'auth-token',
sessionInput: {
repoFullName: 'kilocode/example',
issueNumber: 42,
issueTitle: 'Failure callback',
issueBody: null,
duplicateThreshold: 0.8,
autoFixThreshold: 0.9,
modelSlug: 'test-model',
},
owner: { type: 'user', id: 'user-1', userId: 'user-1' },
status: 'analyzing',
cloudAgentSessionId,
callbackSecret,
updatedAt: '2026-06-10T00:00:00.000Z',
};
}

function createHarness() {
let storedState = createTicket();
const put = vi.fn(async (_key: string, value: TriageTicket) => {
storedState = structuredClone(value);
});
const deleteAlarm = vi.fn(async () => {});
const context = {
storage: {
get: async () => structuredClone(storedState),
put,
deleteAlarm,
},
} as unknown as DurableObjectState;
const environment = {
API_URL: 'https://api.example.com',
INTERNAL_API_SECRET: 'internal-secret',
};
const orchestrator = new TriageOrchestrator(context, environment as never);

return { orchestrator, getStoredState: () => storedState, put, deleteAlarm };
}

describe('TriageOrchestrator classification failure callbacks', () => {
beforeEach(() => {
vi.stubGlobal(
'fetch',
vi.fn(async () => new Response(null, { status: 200 }))
);
});

it('persists the structured failure message instead of the legacy error message', async () => {
const harness = createHarness();

await harness.orchestrator.completeClassification(callbackSecret, {
cloudAgentSessionId,
status: 'failed',
errorMessage: 'legacy wrapper error',
failure: {
code: 'workspace_setup_failed',
subtype: 'git_clone_timeout',
message: 'Repository clone timed out',
},
});

expect(harness.getStoredState()).toMatchObject({
status: 'failed',
errorMessage: 'Repository clone timed out',
});
expect(fetch).toHaveBeenCalledWith(
'https://api.example.com/api/internal/triage-status/ticket-1',
expect.objectContaining({
body: JSON.stringify({
status: 'failed',
errorMessage: 'Repository clone timed out',
}),
})
);
});

it.each([
{ failure: { code: 'future_failure_code' } },
{ failure: { subtype: 'future_workspace_failure' } },
{ failure: { extra: true } },
{ failure: { attempts: -1 } },
{ failure: { message: 'x'.repeat(4_097) } },
])('discards incompatible failure and retains the legacy payload: %o', extension => {
expect(
classificationCallbackPayloadSchema.parse({
cloudAgentSessionId,
status: 'failed',
errorMessage: 'legacy wrapper error',
...extension,
})
).toEqual({
cloudAgentSessionId,
status: 'failed',
errorMessage: 'legacy wrapper error',
failure: undefined,
});
});

it('persists the legacy error message when structured failure is absent', async () => {
const harness = createHarness();

await harness.orchestrator.completeClassification(callbackSecret, {
cloudAgentSessionId,
status: 'failed',
errorMessage: 'legacy wrapper error',
});

expect(harness.getStoredState()).toMatchObject({
status: 'failed',
errorMessage: 'legacy wrapper error',
});
expect(fetch).toHaveBeenCalledWith(
'https://api.example.com/api/internal/triage-status/ticket-1',
expect.objectContaining({
body: JSON.stringify({ status: 'failed', errorMessage: 'legacy wrapper error' }),
})
);
});
});
1 change: 1 addition & 0 deletions services/auto-triage-infra/src/triage-orchestrator.ts
Original file line number Diff line number Diff line change
Expand Up @@ -157,6 +157,7 @@ export class TriageOrchestrator extends DurableObject<Env> {

if (payload.status !== 'completed') {
const errorMessage =
payload.failure?.message ??
payload.errorMessage ??
`Classification session ended with status '${payload.status}' without an error message.`;
await this.updateStatus('failed', { errorMessage });
Expand Down
2 changes: 2 additions & 0 deletions services/auto-triage-infra/src/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import { z } from 'zod';
import type { TriageOrchestrator } from './triage-orchestrator';
import type { Owner, MCPServerConfig } from '@kilocode/worker-utils';
import { CloudAgentCallbackFailureSchema } from '@kilocode/worker-utils/cloud-agent-failure';

export type { Owner, MCPServerConfig };

Expand Down Expand Up @@ -149,6 +150,7 @@ export const classificationCallbackPayloadSchema = z.object({
cloudAgentSessionId: z.string(),
status: z.enum(['completed', 'failed', 'interrupted']),
errorMessage: z.string().optional(),
failure: CloudAgentCallbackFailureSchema,
lastAssistantMessageText: z.string().optional(),
});

Expand Down
Loading