Q129: a run with failed actions it never redid does not count as completed
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
1 parent
6afd7bef2a
commit
271a83a7bc
7 files changed
+209
-23
No files matched your search
@@ -0,0 +1,7 @@
|
|||||||
|
// What each journaled action acted on; rows from before stay null, meaning any later success of the same tool redoes them.
|
||||||
|
export const runActionTargetsSchema = `
|
||||||
|
alter table run_actions add column target text;
|
||||||
|
|
||||||
|
insert into schema_migrations(version, name, applied_at)
|
||||||
|
values (92, 'run_action_targets', datetime('now'));
|
||||||
|
`;
|
||||||
@@ -90,6 +90,7 @@ import { hostAccessNoteSchema } from "./migrations/088-host-access-note";
|
|||||||
import { fileVersionsSchema } from "./migrations/089-file-versions";
|
import { fileVersionsSchema } from "./migrations/089-file-versions";
|
||||||
import { erasureReceiptsSchema } from "./migrations/090-erasure-receipts";
|
import { erasureReceiptsSchema } from "./migrations/090-erasure-receipts";
|
||||||
import { requestPurposeSchema } from "./migrations/091-request-purpose";
|
import { requestPurposeSchema } from "./migrations/091-request-purpose";
|
||||||
|
import { runActionTargetsSchema } from "./migrations/092-run-action-targets";
|
||||||
|
|
||||||
interface Migration {
|
interface Migration {
|
||||||
version: number;
|
version: number;
|
||||||
@@ -189,6 +190,7 @@ const MIGRATIONS: Migration[] = [
|
|||||||
{ version: 89, name: "file_versions", sql: fileVersionsSchema },
|
{ version: 89, name: "file_versions", sql: fileVersionsSchema },
|
||||||
{ version: 90, name: "erasure_receipts", sql: erasureReceiptsSchema },
|
{ version: 90, name: "erasure_receipts", sql: erasureReceiptsSchema },
|
||||||
{ version: 91, name: "request_purpose", sql: requestPurposeSchema },
|
{ version: 91, name: "request_purpose", sql: requestPurposeSchema },
|
||||||
|
{ version: 92, name: "run_action_targets", sql: runActionTargetsSchema },
|
||||||
];
|
];
|
||||||
|
|
||||||
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
const SCHEMA_VERSION = MIGRATIONS[MIGRATIONS.length - 1].version;
|
||||||
|
|||||||
@@ -105,19 +105,24 @@ export async function runAgentLoop(deps: AgentRunLoopDeps, input: AgentRunLoopIn
|
|||||||
lastText = turn.text.trim() || lastText;
|
lastText = turn.text.trim() || lastText;
|
||||||
messages.push({ role: "assistant", text: turn.text, toolCalls: turn.toolCalls, ...(turn.replay ? { replay: turn.replay } : {}) });
|
messages.push({ role: "assistant", text: turn.text, toolCalls: turn.toolCalls, ...(turn.replay ? { replay: turn.replay } : {}) });
|
||||||
const results: Array<{ toolCallId: string; toolName: string; output: string }> = [];
|
const results: Array<{ toolCallId: string; toolName: string; output: string }> = [];
|
||||||
|
// A call refused before it ran is still journaled, so a run that never redoes it cannot pass as completed.
|
||||||
|
const refuse = (call: ToolCall, output: string) => {
|
||||||
|
input.onToolCompleted?.(call, output);
|
||||||
|
results.push({ toolCallId: call.id, toolName: call.name, output });
|
||||||
|
};
|
||||||
for (const [index, call] of turn.toolCalls.entries()) {
|
for (const [index, call] of turn.toolCalls.entries()) {
|
||||||
throwIfRunStopped();
|
throwIfRunStopped();
|
||||||
if (index >= MAX_TOOL_CALLS_PER_TURN) {
|
if (index >= MAX_TOOL_CALLS_PER_TURN) {
|
||||||
results.push({ toolCallId: call.id, toolName: call.name, output: `ERROR: not run: this turn asked for ${turn.toolCalls.length} tool calls; the limit is ${MAX_TOOL_CALLS_PER_TURN}. Repeat the ones you still need.` });
|
refuse(call, `ERROR: not run: this turn asked for ${turn.toolCalls.length} tool calls; the limit is ${MAX_TOOL_CALLS_PER_TURN}. Repeat the ones you still need.`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (call.argumentsError) {
|
if (call.argumentsError) {
|
||||||
console.warn(`[run-loop] ${call.name} not run: arguments ${call.argumentsError}`);
|
console.warn(`[run-loop] ${call.name} not run: arguments ${call.argumentsError}`);
|
||||||
results.push({ toolCallId: call.id, toolName: call.name, output: unreadableArguments(call) });
|
refuse(call, unreadableArguments(call));
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (call.name === "complete_goal" && goalAudits >= MAX_GOAL_AUDITS_PER_SESSION) {
|
if (call.name === "complete_goal" && goalAudits >= MAX_GOAL_AUDITS_PER_SESSION) {
|
||||||
results.push({ toolCallId: call.id, toolName: call.name, output: `ERROR: this session already ran ${MAX_GOAL_AUDITS_PER_SESSION} goal audits. Close the gaps they named, then try again next session.` });
|
refuse(call, `ERROR: this session already ran ${MAX_GOAL_AUDITS_PER_SESSION} goal audits. Close the gaps they named, then try again next session.`);
|
||||||
continue;
|
continue;
|
||||||
}
|
}
|
||||||
if (call.name === "complete_goal") goalAudits += 1;
|
if (call.name === "complete_goal") goalAudits += 1;
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import type { DatabaseSync } from "node:sqlite";
|
import type { DatabaseSync } from "node:sqlite";
|
||||||
|
import { posix } from "node:path";
|
||||||
import { createId } from "../../shared/ids";
|
import { createId } from "../../shared/ids";
|
||||||
import type { ToolCall } from "./provider-tool-turn";
|
import type { ToolCall } from "./provider-tool-turn";
|
||||||
|
|
||||||
@@ -10,6 +11,15 @@ const READ_ONLY_TOOLS = new Set([
|
|||||||
const JOURNAL_LINES = 40;
|
const JOURNAL_LINES = 40;
|
||||||
const FIELD_CHARS = 160;
|
const FIELD_CHARS = 160;
|
||||||
const DECLINED_LINES = 5;
|
const DECLINED_LINES = 5;
|
||||||
|
/** The arguments that name what an action acted on (retry controls such as confirm_not_done excluded); a tool not listed is identified by all of its arguments. */
|
||||||
|
const TARGET_ARGUMENTS: Record<string, string[]> = {
|
||||||
|
write_deliverable: ["path"], create_document: ["title", "doc_type"], update_document: ["doc_id"], create_task: ["title"],
|
||||||
|
update_task_status: ["task_id"], hire_agent: ["name"], grant_connector: ["agent_name", "toolkit"], revoke_connector: ["agent_name", "toolkit"],
|
||||||
|
grant_skill: ["agent_name", "skill"], revoke_skill: ["agent_name", "skill"], record_knowledge: ["topic"], revise_knowledge: ["id"],
|
||||||
|
update_goal: ["goal_id"], complete_goal: [], run_command: ["command"], composio_run_action: ["action", "account", "arguments"],
|
||||||
|
};
|
||||||
|
/** A result line that reports failure: a refused or broken call, or a command that exited non-zero. */
|
||||||
|
const FAILED_RESULT = (row: string) => `(${row}.result_excerpt like 'ERROR%' or (${row}.result_excerpt like 'exit code %' and ${row}.result_excerpt <> 'exit code 0'))`;
|
||||||
/** What a held deliverable would become, over a `held_deliverables` row alias: one file, one existing document, or a new document by type and title. */
|
/** What a held deliverable would become, over a `held_deliverables` row alias: one file, one existing document, or a new document by type and title. */
|
||||||
const heldTarget = (row: string) => `(case ${row}.kind when 'file' then ${row}.workspace_root || '/' || ${row}.relative_path when 'document_update' then ${row}.document_id else ${row}.doc_type || ':' || ${row}.title end)`;
|
const heldTarget = (row: string) => `(case ${row}.kind when 'file' then ${row}.workspace_root || '/' || ${row}.relative_path when 'document_update' then ${row}.document_id else ${row}.doc_type || ':' || ${row}.title end)`;
|
||||||
|
|
||||||
@@ -17,10 +27,41 @@ const heldTarget = (row: string) => `(case ${row}.kind when 'file' then ${row}.w
|
|||||||
export function recordRunAction(db: DatabaseSync, input: { orgId: string; runId: string; taskId: string; call: ToolCall; output: string }): void {
|
export function recordRunAction(db: DatabaseSync, input: { orgId: string; runId: string; taskId: string; call: ToolCall; output: string }): void {
|
||||||
if (READ_ONLY_TOOLS.has(input.call.name)) return;
|
if (READ_ONLY_TOOLS.has(input.call.name)) return;
|
||||||
db.prepare(`
|
db.prepare(`
|
||||||
insert into run_actions(id, org_id, run_id, task_id, tool_name, arguments_excerpt, result_excerpt, created_at)
|
insert into run_actions(id, org_id, run_id, task_id, tool_name, arguments_excerpt, result_excerpt, target, created_at)
|
||||||
values (?, ?, ?, ?, ?, ?, ?, ?)
|
values (?, ?, ?, ?, ?, ?, ?, ?, ?)
|
||||||
`).run(createId("rac"), input.orgId, input.runId, input.taskId, input.call.name,
|
`).run(createId("rac"), input.orgId, input.runId, input.taskId, input.call.name,
|
||||||
clip(JSON.stringify(input.call.arguments)), clip(input.output.split("\n")[0] ?? ""), new Date().toISOString());
|
clip(JSON.stringify(input.call.arguments)), clip(input.output.split("\n")[0] ?? ""), actionTarget(input.call), new Date().toISOString());
|
||||||
|
}
|
||||||
|
|
||||||
|
/** What the call acted on, or null when its arguments could not be read. */
|
||||||
|
function actionTarget(call: ToolCall): string | null {
|
||||||
|
if (call.argumentsError) return null;
|
||||||
|
const keys = TARGET_ARGUMENTS[call.name];
|
||||||
|
if (!keys) return JSON.stringify(canonical(call.arguments));
|
||||||
|
return JSON.stringify(keys.map((key) => targetValue(key, call.arguments[key])));
|
||||||
|
}
|
||||||
|
|
||||||
|
/** An argument as the tools read it: text trimmed, paths in one spelling, nested values in one key order. */
|
||||||
|
function targetValue(key: string, value: unknown): unknown {
|
||||||
|
if (typeof value !== "string") return canonical(value ?? null);
|
||||||
|
return key === "path" ? posix.normalize(value.trim().replaceAll("\\", "/")) : value.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Why a session that ended normally still failed: actions that failed and were not later done successfully on the same target (any target when unknown). */
|
||||||
|
export function unresolvedActionFailure(db: DatabaseSync, runId: string): string | null {
|
||||||
|
const failed = db.prepare(`
|
||||||
|
select a.tool_name, a.result_excerpt from run_actions a
|
||||||
|
where a.run_id = ? and ${FAILED_RESULT("a")}
|
||||||
|
and not exists (
|
||||||
|
select 1 from run_actions later
|
||||||
|
where later.run_id = a.run_id and later.tool_name = a.tool_name and later.rowid > a.rowid and not ${FAILED_RESULT("later")}
|
||||||
|
and (a.target is null or later.target = a.target)
|
||||||
|
)
|
||||||
|
order by a.rowid
|
||||||
|
`).all(runId) as Array<{ tool_name: string; result_excerpt: string }>;
|
||||||
|
if (failed.length === 0) return null;
|
||||||
|
const list = failed.map((action) => `${action.tool_name} (${action.result_excerpt})`).join("; ");
|
||||||
|
return `The session ended with ${failed.length} failed action${failed.length === 1 ? "" : "s"} it did not redo: ${list}`;
|
||||||
}
|
}
|
||||||
|
|
||||||
/** What earlier sessions (including an interrupted attempt of this run) already did on the task, and where the last one failed. */
|
/** What earlier sessions (including an interrupted attempt of this run) already did on the task, and where the last one failed. */
|
||||||
@@ -58,3 +99,10 @@ export function runJournalLines(db: DatabaseSync, taskId: string): string[] {
|
|||||||
function clip(text: string): string {
|
function clip(text: string): string {
|
||||||
return text.length > FIELD_CHARS ? `${text.slice(0, FIELD_CHARS)}...` : text;
|
return text.length > FIELD_CHARS ? `${text.slice(0, FIELD_CHARS)}...` : text;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** The same JSON value with object keys sorted, so equal arguments serialize equally. */
|
||||||
|
function canonical(value: unknown): unknown {
|
||||||
|
if (Array.isArray(value)) return value.map(canonical);
|
||||||
|
if (value === null || typeof value !== "object") return value;
|
||||||
|
return Object.fromEntries(Object.keys(value).sort().map((key) => [key, canonical((value as Record<string, unknown>)[key])]));
|
||||||
|
}
|
||||||
@@ -11,7 +11,7 @@ import { SkillService } from "./skill-service";
|
|||||||
import { runAgentLoop, runTools } from "./agent-run-loop";
|
import { runAgentLoop, runTools } from "./agent-run-loop";
|
||||||
import type { RunModel } from "./skill-runtime";
|
import type { RunModel } from "./skill-runtime";
|
||||||
import { combineMidRunPolls, createGoalChangePoller } from "./goal-change-poller";
|
import { combineMidRunPolls, createGoalChangePoller } from "./goal-change-poller";
|
||||||
import { recordRunAction, runJournalLines } from "./run-journal";
|
import { recordRunAction, unresolvedActionFailure, runJournalLines } from "./run-journal";
|
||||||
import { classifyProviderFailure } from "./provider-failure";
|
import { classifyProviderFailure } from "./provider-failure";
|
||||||
import { runStopReason, SESSION_TIME_LIMIT_MESSAGE, SESSION_TIME_LIMIT_MS, withRunControl } from "./run-control";
|
import { runStopReason, SESSION_TIME_LIMIT_MESSAGE, SESSION_TIME_LIMIT_MS, withRunControl } from "./run-control";
|
||||||
import type { AgentToolContext } from "./agent-tools";
|
import type { AgentToolContext } from "./agent-tools";
|
||||||
@@ -145,7 +145,7 @@ export class TaskExecutionService {
|
|||||||
} catch (error) {
|
} catch (error) {
|
||||||
const message = error instanceof Error ? error.message : String(error);
|
const message = error instanceof Error ? error.message : String(error);
|
||||||
console.error(`[task-run] run ${runId} failed before execution: ${message}`);
|
console.error(`[task-run] run ${runId} failed before execution: ${message}`);
|
||||||
this.failRun(orgId, runId, null, message, false);
|
this.failRun(orgId, runId, null, message, null);
|
||||||
this.audit.record({
|
this.audit.record({
|
||||||
orgId,
|
orgId,
|
||||||
entityType: "task_run",
|
entityType: "task_run",
|
||||||
@@ -277,15 +277,18 @@ export class TaskExecutionService {
|
|||||||
});
|
});
|
||||||
if (delivered) this.notifyDataChanged();
|
if (delivered) this.notifyDataChanged();
|
||||||
}
|
}
|
||||||
this.completeRun(orgId, runId, result.lastRequestId, result.hitStepLimit);
|
// Real work whose writes or external actions failed and were not redone has not succeeded, whatever the model said last.
|
||||||
if (task.origin === null) recordRunMemory(this.db, { orgId, runId, projectId: task.project_id, taskTitle: task.title, outcome: "completed", text: result.finalText });
|
const actionFailure = result.hitStepLimit || task.origin !== null ? null : unresolvedActionFailure(this.db, runId);
|
||||||
|
if (actionFailure) this.failRun(orgId, runId, result.lastRequestId, actionFailure, null);
|
||||||
|
else this.completeRun(orgId, runId, result.lastRequestId, result.hitStepLimit);
|
||||||
|
if (task.origin === null) recordRunMemory(this.db, { orgId, runId, projectId: task.project_id, taskTitle: task.title, outcome: actionFailure ? "failed" : "completed", text: actionFailure ?? result.finalText });
|
||||||
this.audit.record({
|
this.audit.record({
|
||||||
orgId,
|
orgId,
|
||||||
entityType: "task_run",
|
entityType: "task_run",
|
||||||
entityId: runId,
|
entityId: runId,
|
||||||
actorType: "system",
|
actorType: "system",
|
||||||
actorId: null,
|
actorId: null,
|
||||||
action: "completed",
|
action: actionFailure ? "failed" : "completed",
|
||||||
payload: {
|
payload: {
|
||||||
taskId: task.id,
|
taskId: task.id,
|
||||||
agentId: agent.id,
|
agentId: agent.id,
|
||||||
@@ -315,7 +318,7 @@ export class TaskExecutionService {
|
|||||||
throw new Error(stopReason);
|
throw new Error(stopReason);
|
||||||
}
|
}
|
||||||
const failure = classifyProviderFailure(error, Date.now());
|
const failure = classifyProviderFailure(error, Date.now());
|
||||||
this.failRun(orgId, runId, providerRequestId, message, failure.transient);
|
this.failRun(orgId, runId, providerRequestId, message, failure.transient ? "transient" : null);
|
||||||
if (failure.transient) this.cooldowns.park(provider, message, failure.retryAfterMs);
|
if (failure.transient) this.cooldowns.park(provider, message, failure.retryAfterMs);
|
||||||
else if (task.origin === null) recordRunMemory(this.db, { orgId, runId, projectId: task.project_id, taskTitle: task.title, outcome: "failed", text: message });
|
else if (task.origin === null) recordRunMemory(this.db, { orgId, runId, projectId: task.project_id, taskTitle: task.title, outcome: "failed", text: message });
|
||||||
this.audit.record({
|
this.audit.record({
|
||||||
@@ -434,12 +437,7 @@ export class TaskExecutionService {
|
|||||||
if (!checkpoint) this.closeChatWakeTask(orgId, runId);
|
if (!checkpoint) this.closeChatWakeTask(orgId, runId);
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/** A chat-wake task only carries its reply run, so the run completing closes it; left open it would strand forever. */
|
||||||
* A chat-wake task exists only to carry its reply run — the reply is the
|
|
||||||
* whole deliverable. The run completing therefore closes the task; left
|
|
||||||
* open it would strand forever (a task with a completed run is never
|
|
||||||
* enqueued again) and pile zombie "Reply to ..." rows onto the board.
|
|
||||||
*/
|
|
||||||
private closeChatWakeTask(orgId: string, runId: string): void {
|
private closeChatWakeTask(orgId: string, runId: string): void {
|
||||||
const run = this.db.prepare("select r.task_id, t.origin from task_runs r join tasks t on t.id = r.task_id where r.id = ? and r.org_id = ?")
|
const run = this.db.prepare("select r.task_id, t.origin from task_runs r join tasks t on t.id = r.task_id where r.id = ? and r.org_id = ?")
|
||||||
.get(runId, orgId) as { task_id: string; origin: string | null } | undefined;
|
.get(runId, orgId) as { task_id: string; origin: string | null } | undefined;
|
||||||
@@ -479,12 +477,12 @@ export class TaskExecutionService {
|
|||||||
return runId;
|
return runId;
|
||||||
}
|
}
|
||||||
|
|
||||||
private failRun(orgId: string, runId: string, providerRequestId: string | null, errorMessage: string, transient: boolean): void {
|
private failRun(orgId: string, runId: string, providerRequestId: string | null, errorMessage: string, failureKind: "transient" | null): void {
|
||||||
this.db.prepare(`
|
this.db.prepare(`
|
||||||
update task_runs
|
update task_runs
|
||||||
set state = ?, provider_request_id = coalesce(?, provider_request_id), completed_at = ?, error_message = ?, failure_kind = ?
|
set state = ?, provider_request_id = coalesce(?, provider_request_id), completed_at = ?, error_message = ?, failure_kind = ?
|
||||||
where id = ? and org_id = ?
|
where id = ? and org_id = ?
|
||||||
`).run(TASK_RUN_STATE.FAILED, providerRequestId, new Date().toISOString(), errorMessage, transient ? "transient" : null, runId, orgId);
|
`).run(TASK_RUN_STATE.FAILED, providerRequestId, new Date().toISOString(), errorMessage, failureKind, runId, orgId);
|
||||||
}
|
}
|
||||||
|
|
||||||
private resolveSkillRuntimeSnapshot(orgId: string, agentId: string, runId: string, maxTokens: number, taskText: string, runModel: RunModel, houseStyle: boolean): SkillRuntimeSnapshotRow | null {
|
private resolveSkillRuntimeSnapshot(orgId: string, agentId: string, runId: string, maxTokens: number, taskText: string, runModel: RunModel, houseStyle: boolean): SkillRuntimeSnapshotRow | null {
|
||||||
@@ -498,6 +496,4 @@ export class TaskExecutionService {
|
|||||||
if (!snapshot) throw new Error("Skill runtime snapshot was not found.");
|
if (!snapshot) throw new Error("Skill runtime snapshot was not found.");
|
||||||
return snapshot;
|
return snapshot;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,128 @@
|
|||||||
|
import { randomUUID } from "node:crypto";
|
||||||
|
import { mkdirSync } from "node:fs";
|
||||||
|
import { tmpdir } from "node:os";
|
||||||
|
import { join } from "node:path";
|
||||||
|
import { describe, expect, test } from "vitest";
|
||||||
|
import { DatabaseConnection } from "../../src/main/database/connection";
|
||||||
|
import { OnboardingService } from "../../src/main/services/onboarding-service";
|
||||||
|
import { ProviderService } from "../../src/main/services/provider-service";
|
||||||
|
import { providerAdapters } from "../../src/main/services/provider-adapters";
|
||||||
|
import { recordRunAction, unresolvedActionFailure } from "../../src/main/services/run-journal";
|
||||||
|
import { TaskExecutionService } from "../../src/main/services/task-execution-service";
|
||||||
|
import { claimNextQueuedRun } from "../../src/main/app/task-queue-worker";
|
||||||
|
import type { AgentTurnResult, ProviderToolTurnInput, ToolCall } from "../../src/main/services/provider-tool-turn";
|
||||||
|
import { answeringModelCheck } from "../helpers/model-check";
|
||||||
|
|
||||||
|
const testVault = {
|
||||||
|
encryptSecret: (secret: string) => Buffer.from(secret, "utf8"),
|
||||||
|
decryptSecret: (ciphertext: Buffer) => ciphertext.toString("utf8"),
|
||||||
|
};
|
||||||
|
|
||||||
|
/** A company whose kickoff run makes the given tool calls, one turn each, then ends with a final answer. */
|
||||||
|
function company(calls: ToolCall[]) {
|
||||||
|
let turns = 0;
|
||||||
|
const turn = async (_input: ProviderToolTurnInput): Promise<AgentTurnResult> => {
|
||||||
|
const call = calls[turns];
|
||||||
|
turns += 1;
|
||||||
|
return call ? { text: "Working.", toolCalls: [call], rawResponseJson: "{}" } : { text: "All done.", toolCalls: [], rawResponseJson: "{}" };
|
||||||
|
};
|
||||||
|
const connection = new DatabaseConnection(join(tmpdir(), `ac-action-failures-${randomUUID()}.db`));
|
||||||
|
const providers = new ProviderService(connection.db, providerAdapters, testVault, answeringModelCheck(turn));
|
||||||
|
const onboarding = new OnboardingService({ db: connection.db, saveProviderCredential: (input) => providers.saveCredential(input) });
|
||||||
|
const execution = new TaskExecutionService(connection.db, providers);
|
||||||
|
const workspace = join(tmpdir(), `ac-action-failures-ws-${randomUUID()}`);
|
||||||
|
mkdirSync(workspace, { recursive: true });
|
||||||
|
let orgId = "";
|
||||||
|
connection.transaction(() => {
|
||||||
|
orgId = onboarding.bootstrap({
|
||||||
|
companyName: "FlowPilot", companyDescription: "CSM analytics.", employeeName: "Yang", workspacePath: workspace,
|
||||||
|
executionMode: "ai_ceo", autoApproveHires: true, reviewDeliverables: false,
|
||||||
|
goalTitle: "Ship the MVP.", goalDescription: "A working product.",
|
||||||
|
providerKey: "openai", providerSecret: "sk-test-key", providerModel: "gpt-5.4",
|
||||||
|
}).orgId;
|
||||||
|
});
|
||||||
|
return { connection, execution, orgId, runId: claimNextQueuedRun(connection)!.id };
|
||||||
|
}
|
||||||
|
|
||||||
|
const failingWrite: ToolCall = { id: "c1", name: "write_deliverable", arguments: { path: "../outside.md", content: "x" } };
|
||||||
|
|
||||||
|
describe("a run whose actions failed", () => {
|
||||||
|
test("does not count as completed when a failed action was never redone", async () => {
|
||||||
|
const h = company([failingWrite]);
|
||||||
|
await h.execution.executeRun(h.orgId, h.runId);
|
||||||
|
const run = h.connection.db.prepare("select state, failure_kind, error_message from task_runs where id = ?").get(h.runId) as { state: string; failure_kind: string; error_message: string };
|
||||||
|
// Not a passing provider problem, so it counts against the task's retry budget like any failure.
|
||||||
|
expect([run.state, run.failure_kind]).toEqual(["failed", null]);
|
||||||
|
expect(run.error_message).toContain("The session ended with 1 failed action it did not redo: write_deliverable");
|
||||||
|
h.connection.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("completes when every failed action was later done successfully", async () => {
|
||||||
|
// The tool trims titles, so " Plan " and "Plan" name the same document.
|
||||||
|
const broken: ToolCall = { id: "c1", name: "create_document", arguments: { title: " Plan ", doc_type: "plan" } };
|
||||||
|
const fixed: ToolCall = { id: "c2", name: "create_document", arguments: { title: "Plan", doc_type: "plan", content: "The plan." } };
|
||||||
|
const h = company([broken, fixed]);
|
||||||
|
await h.execution.executeRun(h.orgId, h.runId);
|
||||||
|
expect(h.connection.db.prepare("select state, failure_kind from task_runs where id = ?").get(h.runId)).toEqual({ state: "completed", failure_kind: null });
|
||||||
|
h.connection.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a success on a different target does not redo the failed one", async () => {
|
||||||
|
const broken: ToolCall = { id: "c1", name: "create_document", arguments: { title: "Required plan", doc_type: "plan" } };
|
||||||
|
const other: ToolCall = { id: "c2", name: "create_document", arguments: { title: "Other report", doc_type: "report", content: "Body." } };
|
||||||
|
const h = company([broken, other]);
|
||||||
|
await h.execution.executeRun(h.orgId, h.runId);
|
||||||
|
const run = h.connection.db.prepare("select state, error_message from task_runs where id = ?").get(h.runId) as { state: string; error_message: string };
|
||||||
|
expect(run.state).toBe("failed");
|
||||||
|
expect(run.error_message).toContain("1 failed action it did not redo: create_document");
|
||||||
|
h.connection.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a call whose arguments could not be read counts as a failed action", async () => {
|
||||||
|
const h = company([{ id: "c1", name: "write_deliverable", arguments: {}, argumentsError: "not valid JSON (Unexpected end): {\"path\":" }]);
|
||||||
|
await h.execution.executeRun(h.orgId, h.runId);
|
||||||
|
const run = h.connection.db.prepare("select state, error_message from task_runs where id = ?").get(h.runId) as { state: string; error_message: string };
|
||||||
|
expect(run.state).toBe("failed");
|
||||||
|
expect(run.error_message).toContain("1 failed action it did not redo: write_deliverable (ERROR: not run: the arguments");
|
||||||
|
h.connection.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a command that exited non-zero is a failure until the same command exits zero", () => {
|
||||||
|
const h = company([]);
|
||||||
|
const task = h.connection.db.prepare("select task_id from task_runs where id = ?").get(h.runId) as { task_id: string };
|
||||||
|
const run = (command: string, output: string) => recordRunAction(h.connection.db, { orgId: h.orgId, runId: h.runId, taskId: task.task_id, call: { id: command, name: "run_command", arguments: { command } }, output });
|
||||||
|
run("pnpm test", "exit code 1\n3 failed");
|
||||||
|
run("git status", "exit code 0\nclean");
|
||||||
|
expect(unresolvedActionFailure(h.connection.db, h.runId)).toBe("The session ended with 1 failed action it did not redo: run_command (exit code 1)");
|
||||||
|
run("pnpm test", "exit code 0\nall passed");
|
||||||
|
expect(unresolvedActionFailure(h.connection.db, h.runId)).toBeNull();
|
||||||
|
h.connection.close();
|
||||||
|
});
|
||||||
|
|
||||||
|
test("a connector action on other arguments does not redo the failed one", () => {
|
||||||
|
const h = company([]);
|
||||||
|
const task = h.connection.db.prepare("select task_id from task_runs where id = ?").get(h.runId) as { task_id: string };
|
||||||
|
const update = (issue: number, output: string) => recordRunAction(h.connection.db, {
|
||||||
|
orgId: h.orgId, runId: h.runId, taskId: task.task_id, output,
|
||||||
|
call: { id: `i${issue}`, name: "composio_run_action", arguments: { action: "GITHUB_UPDATE_ISSUE", arguments: { issue_number: issue } } },
|
||||||
|
});
|
||||||
|
update(1, "ERROR: GITHUB_UPDATE_ISSUE failed: 502");
|
||||||
|
recordRunAction(h.connection.db, {
|
||||||
|
orgId: h.orgId, runId: h.runId, taskId: task.task_id, output: "ERROR: reply lost",
|
||||||
|
call: { id: "multi", name: "composio_run_action", arguments: { action: "GITHUB_UPDATE_ISSUE", arguments: { issue_number: 3, state: "closed" } } },
|
||||||
|
});
|
||||||
|
recordRunAction(h.connection.db, {
|
||||||
|
orgId: h.orgId, runId: h.runId, taskId: task.task_id, output: "{\"successful\":true}",
|
||||||
|
call: { id: "multi2", name: "composio_run_action", arguments: { action: "GITHUB_UPDATE_ISSUE", arguments: { state: "closed", issue_number: 3 } } },
|
||||||
|
});
|
||||||
|
update(2, "{\"successful\":true}");
|
||||||
|
expect(unresolvedActionFailure(h.connection.db, h.runId)).toBe("The session ended with 1 failed action it did not redo: composio_run_action (ERROR: GITHUB_UPDATE_ISSUE failed: 502)");
|
||||||
|
// A retry that confirms the lost reply, with the same input in another key order, redoes issue 1.
|
||||||
|
recordRunAction(h.connection.db, {
|
||||||
|
orgId: h.orgId, runId: h.runId, taskId: task.task_id, output: "{\"successful\":true}",
|
||||||
|
call: { id: "retry", name: "composio_run_action", arguments: { arguments: { issue_number: 1 }, confirm_not_done: true, action: "GITHUB_UPDATE_ISSUE" } },
|
||||||
|
});
|
||||||
|
expect(unresolvedActionFailure(h.connection.db, h.runId)).toBeNull();
|
||||||
|
h.connection.close();
|
||||||
|
});
|
||||||
|
});
|
||||||
@@ -35,7 +35,7 @@ describe("domain services", () => {
|
|||||||
const connection = createConnection();
|
const connection = createConnection();
|
||||||
|
|
||||||
const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>;
|
const rows = connection.db.prepare("select version from schema_migrations order by version asc").all() as Array<{ version: number }>;
|
||||||
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91]);
|
expect(rows.map((row) => row.version)).toEqual([1, 2, 3, 4, 5, 6, 7, 8, 9, 10, 11, 12, 13, 14, 15, 16, 17, 18, 19, 20, 21, 22, 23, 24, 25, 26, 27, 28, 29, 30, 31, 32, 33, 34, 35, 36, 37, 38, 39, 40, 41, 42, 43, 44, 45, 46, 47, 48, 49, 50, 51, 52, 53, 54, 55, 56, 57, 58, 59, 60, 61, 62, 63, 64, 65, 66, 67, 68, 69, 70, 71, 72, 73, 74, 75, 76, 77, 78, 79, 80, 81, 82, 83, 84, 85, 86, 87, 88, 89, 90, 91, 92]);
|
||||||
connection.close();
|
connection.close();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|||||||
Reference in new issue
Block a user